From 89f9ee1bb95934f5cadbd322c1607f89de8ff0db Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Fri, 2 Oct 2026 17:41:18 -0300 Subject: [PATCH] feat(chat): keep what the owner was writing when they switch chats The composer text lived in component state, so going to another chat and back lost it. Each bot now has its own draft, kept in memory while the app is open (never on disk, since a message can carry personal data). The composer is keyed by bot so drafts and pending notes do not leak between chats. Files picked but not sent are still not kept. Spec 15.1 updated. --- app/src/features/chat/ChatComposer.tsx | 8 +++++++- app/src/features/chat/ChatView.tsx | 3 ++- app/src/features/chat/chat.test.tsx | 28 ++++++++++++++++++++++++++ app/src/features/chat/drafts.ts | 17 ++++++++++++++++ docs/spec.md | 2 +- 5 files changed, 55 insertions(+), 3 deletions(-) create mode 100644 app/src/features/chat/drafts.ts 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.