Skip to content

Commit 452fe37

Browse files
authored
Merge pull request #127 from httpsphl/feat/composer-drafts
feat(chat): keep what the owner was writing when they switch chats
2 parents a53700a + 89f9ee1 commit 452fe37

5 files changed

Lines changed: 55 additions & 3 deletions

File tree

‎app/src/features/chat/ChatComposer.tsx‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { type Bot, FIELD_LIMITS } from "../../lib/protocol.gen";
2222
import { prefs, usePref } from "../../shell/prefs";
2323
import { useApi } from "../../store/context";
2424
import { ContextMeter } from "./ContextMeter";
25+
import { draftOf, keepDraft } from "./drafts";
2526
import { EffortPicker } from "./EffortPicker";
2627
import { rememberImage } from "./images";
2728
import { ModelPicker } from "./ModelPicker";
@@ -71,7 +72,12 @@ export const ChatComposer = memo(function ChatComposer({
7172
}) {
7273
const api = useApi();
7374
const t = useT();
74-
const [text, setText] = useState("");
75+
const botId = bot.id;
76+
const [text, setShown] = useState(() => draftOf(botId));
77+
const setText = (value: string) => {
78+
setShown(value);
79+
keepDraft(botId, value);
80+
};
7581
const [busy, setBusy] = useState(false);
7682
/** A new mode, model or effort, or a compaction, that waits for the bot to finish what it is doing. */
7783
const [later, setLater] = useState<string | null>(null);

‎app/src/features/chat/ChatView.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,8 @@ export const ChatView = memo(function ChatView({ bot, stopped }: { bot: Bot; sto
182182
</div>
183183
</div>
184184
<div className="w-full">
185-
<ChatComposer bot={bot} files={files} stopped={stopped} onSent={onSent} />
185+
{/* Keyed by bot: each one has its own draft (spec 15.1). */}
186+
<ChatComposer key={bot.id} bot={bot} files={files} stopped={stopped} onSent={onSent} />
186187
</div>
187188
{dragging && (
188189
<div className="pointer-events-none absolute inset-3 grid place-items-center rounded-2xl border-2 border-accent border-dashed bg-canvas/85">

‎app/src/features/chat/chat.test.tsx‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,34 @@ function write(text: string) {
3030
}
3131

3232
describe("chat", () => {
33+
test("what the owner was writing waits for them in each chat", async () => {
34+
const { fake, scout } = crew();
35+
const writer = fake.addBot(scout.crewId, "Writer", "Writes drafts");
36+
fake.setBotState(writer.id, "idle");
37+
await openScout(fake);
38+
write("Half a thought");
39+
openBot("Writer");
40+
const writerField = (await screen.findByLabelText("Message to Writer")) as HTMLTextAreaElement;
41+
expect(writerField.value).toBe("");
42+
fireEvent.change(writerField, { target: { value: "For Writer" } });
43+
openBot("Scout");
44+
expect(
45+
(await screen.findByLabelText("Message to Scout")) as HTMLTextAreaElement,
46+
).toHaveProperty("value", "Half a thought");
47+
fireEvent.keyDown(field(), { key: "Enter" });
48+
await within(chat()).findByText("Half a thought");
49+
openBot("Writer");
50+
expect(
51+
(await screen.findByLabelText("Message to Writer")) as HTMLTextAreaElement,
52+
).toHaveProperty("value", "For Writer");
53+
// Drafts outlive the render, and fake ids repeat in the next test.
54+
fireEvent.change(screen.getByLabelText("Message to Writer"), { target: { value: "" } });
55+
openBot("Scout");
56+
expect(
57+
(await screen.findByLabelText("Message to Scout")) as HTMLTextAreaElement,
58+
).toHaveProperty("value", "");
59+
});
60+
3361
test("Enter sends, Shift+Enter does not, and the bubble follows the delivery", async () => {
3462
const { fake } = crew();
3563
await openScout(fake);

‎app/src/features/chat/drafts.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
// What the owner started writing to each bot, so going to another chat and
2+
// back does not lose it (spec 15.1). Kept in memory only, while the app is
3+
// open: a message can carry personal data, and nothing of it goes to disk.
4+
5+
const drafts = new Map<string, string>();
6+
7+
export function draftOf(botId: string): string {
8+
return drafts.get(botId) ?? "";
9+
}
10+
11+
export function keepDraft(botId: string, text: string): void {
12+
if (text) {
13+
drafts.set(botId, text);
14+
} else {
15+
drafts.delete(botId);
16+
}
17+
}

‎docs/spec.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -781,7 +781,7 @@ Layout, como um app de mensagens:
781781
- **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.
782782
- **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.
783783
- **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.
784-
- **Á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).
784+
- **Á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.
785785
- **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.
786786
- **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.
787787
- **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.

0 commit comments

Comments
 (0)