diff --git a/app/src/features/chat/ChatComposer.tsx b/app/src/features/chat/ChatComposer.tsx index 9bfee26f..9a295da8 100644 --- a/app/src/features/chat/ChatComposer.tsx +++ b/app/src/features/chat/ChatComposer.tsx @@ -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"; @@ -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(null); diff --git a/app/src/features/chat/ChatView.tsx b/app/src/features/chat/ChatView.tsx index 05fc6d2c..2aa46a72 100644 --- a/app/src/features/chat/ChatView.tsx +++ b/app/src/features/chat/ChatView.tsx @@ -182,7 +182,8 @@ export const ChatView = memo(function ChatView({ bot, stopped }: { bot: Bot; sto
- + {/* Keyed by bot: each one has its own draft (spec 15.1). */} +
{dragging && (
diff --git a/app/src/features/chat/chat.test.tsx b/app/src/features/chat/chat.test.tsx index b443df19..a07a0a9e 100644 --- a/app/src/features/chat/chat.test.tsx +++ b/app/src/features/chat/chat.test.tsx @@ -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); diff --git a/app/src/features/chat/drafts.ts b/app/src/features/chat/drafts.ts new file mode 100644 index 00000000..0ba0fa24 --- /dev/null +++ b/app/src/features/chat/drafts.ts @@ -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(); + +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); + } +} diff --git a/docs/spec.md b/docs/spec.md index a3b2b1a8..dca69367 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -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.