Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion app/src/features/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { type Bot, FIELD_LIMITS } from "../../lib/protocol.gen";
import { prefs, usePref } from "../../shell/prefs";
import { useApi } from "../../store/context";
import { ContextMeter } from "./ContextMeter";
import { draftOf, keepDraft } from "./drafts";
import { EffortPicker } from "./EffortPicker";
import { rememberImage } from "./images";
import { ModelPicker } from "./ModelPicker";
Expand Down Expand Up @@ -71,7 +72,12 @@ export const ChatComposer = memo(function ChatComposer({
}) {
const api = useApi();
const t = useT();
const [text, setText] = useState("");
const botId = bot.id;
const [text, setShown] = useState(() => draftOf(botId));
const setText = (value: string) => {
setShown(value);
keepDraft(botId, value);
};
const [busy, setBusy] = useState(false);
/** A new mode, model or effort, or a compaction, that waits for the bot to finish what it is doing. */
const [later, setLater] = useState<string | null>(null);
Expand Down
3 changes: 2 additions & 1 deletion app/src/features/chat/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,8 @@ export const ChatView = memo(function ChatView({ bot, stopped }: { bot: Bot; sto
</div>
</div>
<div className="w-full">
<ChatComposer bot={bot} files={files} stopped={stopped} onSent={onSent} />
{/* Keyed by bot: each one has its own draft (spec 15.1). */}
<ChatComposer key={bot.id} bot={bot} files={files} stopped={stopped} onSent={onSent} />
</div>
{dragging && (
<div className="pointer-events-none absolute inset-3 grid place-items-center rounded-2xl border-2 border-accent border-dashed bg-canvas/85">
Expand Down
28 changes: 28 additions & 0 deletions app/src/features/chat/chat.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,34 @@ function write(text: string) {
}

describe("chat", () => {
test("what the owner was writing waits for them in each chat", async () => {
const { fake, scout } = crew();
const writer = fake.addBot(scout.crewId, "Writer", "Writes drafts");
fake.setBotState(writer.id, "idle");
await openScout(fake);
write("Half a thought");
openBot("Writer");
const writerField = (await screen.findByLabelText("Message to Writer")) as HTMLTextAreaElement;
expect(writerField.value).toBe("");
fireEvent.change(writerField, { target: { value: "For Writer" } });
openBot("Scout");
expect(
(await screen.findByLabelText("Message to Scout")) as HTMLTextAreaElement,
).toHaveProperty("value", "Half a thought");
fireEvent.keyDown(field(), { key: "Enter" });
await within(chat()).findByText("Half a thought");
openBot("Writer");
expect(
(await screen.findByLabelText("Message to Writer")) as HTMLTextAreaElement,
).toHaveProperty("value", "For Writer");
// Drafts outlive the render, and fake ids repeat in the next test.
fireEvent.change(screen.getByLabelText("Message to Writer"), { target: { value: "" } });
openBot("Scout");
expect(
(await screen.findByLabelText("Message to Scout")) as HTMLTextAreaElement,
).toHaveProperty("value", "");
});

test("Enter sends, Shift+Enter does not, and the bubble follows the delivery", async () => {
const { fake } = crew();
await openScout(fake);
Expand Down
17 changes: 17 additions & 0 deletions app/src/features/chat/drafts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// What the owner started writing to each bot, so going to another chat and
// back does not lose it (spec 15.1). Kept in memory only, while the app is
// open: a message can carry personal data, and nothing of it goes to disk.

const drafts = new Map<string, string>();

export function draftOf(botId: string): string {
return drafts.get(botId) ?? "";
}

export function keepDraft(botId: string, text: string): void {
if (text) {
drafts.set(botId, text);
} else {
drafts.delete(botId);
}
}
2 changes: 1 addition & 1 deletion docs/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -781,7 +781,7 @@ Layout, como um app de mensagens:
- **Configurações**, um diálogo com as partes listadas à esquerda (abas verticais): **Geral**, **Conversa**, **Avisos**, **Aparência**, **Arquivados** e **Sobre**. As escolhas que são só do app ficam no `localStorage` (`botloft.whenClosed`, `botloft.tray`, `botloft.openAtSignIn`, `botloft.notifyNeeds`, `botloft.notifyDone`, `botloft.markReplies`, `botloft.sound`, `botloft.enterSends`, `botloft.followBot`, `botloft.lessMotion`), como o tema. Em Geral, primeiro o **Idioma**, numa lista suspensa (um botão com a escolha que abre a lista embaixo, no visual dos menus, com as setas, Enter e Esc, que fecha só a lista); depois **Em segundo plano**, três chaves, cada uma com uma linha que diz o que a escolha atual faz: "Continuar trabalhando depois de fechar o Botloft" (ligada, os bots seguem depois que a janela fecha; desligada, fechar a janela a esconde, chama `daemon_stop` e fecha, e os bots continuam de onde pararam quando o app abre de novo, com "Iniciando seus bots…" no lugar de "Preparando o Botloft…"; a escolha é do app), "Mostrar o Botloft perto do relógio" (só com a anterior ligada; abaixo), "Iniciar com o Windows" (`startWithWindows`, 14), "Abrir a janela ao entrar no Windows" (só com a anterior ligada; desligada, o Botloft começa perto do relógio, ou só abre quando o dono abrir) e "Não deixar o computador dormir enquanto os bots trabalham" (`keepAwake`, com a linha de que a tampa e o Suspender ainda põem o computador para dormir). As duas do daemon vêm de `settings.get` ao abrir, mudam na hora na tela e voltam atrás, com um aviso, se `settings.update` falhar. Em Conversa, "Enter envia a mensagem" (desligada, Enter quebra a linha e Ctrl+Enter envia, e a dica do compositor diz isso) e "Abrir o navegador e as telas quando um bot começar a usar" (desligada, só o ponto no botão avisa), e quanto tempo um pedido de permissão espera pelo dono (15 min, 30 min, 1 h, 2 h, 4 h ou 8 h; um valor posto à mão no `config.toml` entra na lista), com a linha de que sem resposta o pedido é negado (10.1). Em Avisos, "Quando um bot precisar de você" (ligada), "Quando um bot terminar" (desligada), "Marcar o ícone quando um bot responder" (ligada, `botloft.markReplies`: a marca do ícone na barra de tarefas, 15.2, aparece também enquanto algum bot está não lido; só no Windows, onde essa marca existe) e "Tocar um som" (ligada), com a linha de que, com a janela fechada, os avisos só chegam com o Botloft perto do relógio quando ele não está. Em Aparência, tema, tamanho e "Menos animações", que marca a raiz com `data-motion="less"` e para tudo como o Windows faria (15.3). Em **Arquivados**, uma linha que explica que os bots e as equipes arquivados estão parados e fora da vista, mas o Botloft ainda guarda as conversas deles, e a lista, lida de `archive.list` ao abrir a parte: primeiro as equipes ("Equipe · 2 bots · arquivada há 3 dias"), depois os bots arquivados de equipes ativas ("Bot de Ops · arquivado há 3 dias"); um bot de uma equipe arquivada sai junto com ela e não aparece sozinho. Cada linha tem um botão Excluir, que abre a mesma confirmação dos menus (7.6), dizendo que o bot ou a equipe "sai do Botloft de vez" em vez de "para agora", porque já estava parado. Sem nada arquivado, a parte diz isso. Não há como desarquivar. Em **Sobre**: as versões do Botloft e do Claude Code, o e-mail e "Procurar atualizações", que consulta o feed na hora (15.5) e diz que esta é a versão mais recente ou mostra "Ver atualização", que abre o diálogo de atualizar. A linha do Botloft na tela de boas-vindas segue as mesmas escolhas.
- **Perto do relógio:** com os bots continuando depois de fechar e "Mostrar o Botloft perto do relógio" ligada, o app põe um ícone na área de notificação (o ícone do app, com um ponto quando algo espera o dono, como o overlay da taskbar). A dica diz "Botloft: " e o que acontece: um bot que precisa do dono pelo nome, algo esperando, quantos bots trabalham ou nenhum. Um clique abre a janela; o menu tem essa mesma linha, "Abrir o Botloft", "Pausar todas as equipes" (ou "Retomar todas as equipes", com todas pausadas) e "Sair do Botloft", que fecha o app e deixa os bots trabalhando. Com o ícone, fechar a janela só a esconde; sem ele, o app fecha.
- **Avisos:** quando um bot passa a precisar do dono (pedido de permissão, com a ação no idioma do dono, por exemplo "Rodar um comando", sem o comando; ou entrar na conta do Claude) e, se o dono quiser, quando um bot passa de trabalhando a disponível ("Scout terminou", com a equipe). Com o Botloft fora da frente (janela escondida, minimizada ou sem foco), é um aviso do Windows, com o som do Windows se "Tocar um som" estiver ligada; com ele na frente, só um som curto feito na hora (duas notas, sem arquivo). O que já estava assim quando o app conectou não avisa. Abrir o Botloft pelo aviso (o que traz a janela aberta, 15.2) até 10 min depois abre o bot do último aviso.
- **Área principal com um bot:** cabeçalho com nome, estado e ações; o chat; o compositor embaixo. O compositor aceita texto, colar imagem e arrastar ou escolher arquivos. Enter envia e Shift+Enter quebra linha, ou, se o dono escolheu nas Configurações, Ctrl+Enter envia e Enter quebra linha (a dica aparece enquanto o dono escreve).
- **Área principal com um bot:** cabeçalho com nome, estado e ações; o chat; o compositor embaixo. O compositor aceita texto, colar imagem e arrastar ou escolher arquivos. Enter envia e Shift+Enter quebra linha, ou, se o dono escolheu nas Configurações, Ctrl+Enter envia e Enter quebra linha (a dica aparece enquanto o dono escreve). O texto que o dono começou a escrever fica guardado por bot: ir para outro chat e voltar não o perde. Fica só na memória do app enquanto ele está aberto, porque uma mensagem pode ter dado pessoal; os arquivos escolhidos e ainda não enviados não são guardados.
- **Modo do bot**, no compositor, ao lado do clipe, como no Claude Code: um botão com o modo atual abre um menu para cima, "Modo", com Automático, Manual, Aceitar edições e Plano, cada um com uma linha que fala do bot pelo nome ("Scout decide o que precisa do seu OK") e a marca no atual. Separado, "Ignorar permissões" com o botão Ativar, que abre uma confirmação dizendo que o bot não fica preso à pasta dele (13). Nesse modo o botão fica vermelho e o cabeçalho mostra "Não pergunta nada" em vermelho. Com o bot ocupado, uma linha acima do compositor avisa que ele muda de modo quando terminar o que está fazendo.
- **Modelo do bot**, no compositor, ao lado do botão de enviar: mostra o modelo em uso ("Opus 5.5"; antes do primeiro turno, o nome escolhido ou "Padrão") e abre um menu para cima, "Modelo", com Padrão do plano, Fable, Opus, Sonnet e Haiku. Cada um tem uma linha que fala do bot pelo nome ("Scout fica rápido e capaz, bom para quase tudo"; o padrão diz qual modelo é hoje), e o pé do menu lembra que modelos mais capazes gastam o limite do plano mais rápido. A janela de criar e editar bot tem a mesma escolha. Com o bot ocupado, a mesma linha acima do compositor avisa que ele troca quando terminar.
- **Esforço do bot** (7.4), no compositor, ao lado do modelo: um botão com o nível em uso ("Médio") abre para cima "Esforço", um controle deslizante de cinco pontos, de "Mais rápido" a "Mais inteligente" (Baixo, Médio, Alto, Extra alto, Máximo), como o dos apps do Claude. O ponto **recomendado** vem marcado: é o nível que o modelo do bot usa sozinho (`effortDefault`). Embaixo, o nome do nível e uma linha que fala do bot pelo nome ("Scout equilibra rapidez e raciocínio, bom para quase tudo"); o pé lembra que mais esforço gasta o limite do plano mais rápido e, fora do recomendado, traz "Usar o recomendado". As palavras seguem o controle na hora, mas o bot só é avisado quando o controle fica 400 ms num ponto ou o menu fecha, para não reiniciar a cada passo. Parar no ponto recomendado grava `default`: o bot segue o nível do modelo, também depois de trocar de modelo. Enquanto o Claude Code não disse o nível do modelo, nada vem marcado e o botão diz só "Esforço". Num modelo sem níveis (Haiku) o botão fica apagado e o menu explica, sem o controle. Com o bot ocupado, a linha acima do compositor avisa que ele muda quando terminar.
Expand Down
Loading