diff --git a/app/src/features/chat/ToolGroup.tsx b/app/src/features/chat/ToolGroup.tsx index c449552d..25d61673 100644 --- a/app/src/features/chat/ToolGroup.tsx +++ b/app/src/features/chat/ToolGroup.tsx @@ -7,14 +7,16 @@ import { ChevronRight, LoaderCircle } from "lucide-react"; import { useState } from "react"; import { useT } from "../../i18n"; import type { ChatItem, ToolItem } from "../../lib/protocol.gen"; +import { ReadThumb, useReadImage } from "./readImage"; import { ToolLines } from "./ToolLines"; import { toolDetail, toolTitle } from "./toolNames"; import { toolSummary } from "./toolSummary"; /** The call the bot is on, swapped in as the next one starts. */ -function Now({ tool }: { tool: ToolItem }) { +function Now({ tool, botId }: { tool: ToolItem; botId: string }) { const t = useT(); const detail = tool.explanation || toolDetail(tool, t.tools); + const picture = useReadImage(botId, tool); return ( )} + ); } @@ -39,7 +42,7 @@ export function ToolGroup({ items, active }: { items: ChatItem[]; active: boolea const t = useT(); const [open, setOpen] = useState(false); const calls = items.flatMap((item) => - item.body.kind === "tool" ? [{ id: item.id, tool: item.body }] : [], + item.body.kind === "tool" ? [{ id: item.id, botId: item.botId, tool: item.body }] : [], ); const now = calls.at(-1); // One finished call is already a single line. @@ -55,7 +58,7 @@ export function ToolGroup({ items, active }: { items: ChatItem[]; active: boolea className="flex min-w-0 max-w-full items-center gap-2 self-start rounded-lg px-1.5 py-1 text-left text-muted text-sm transition-colors hover:bg-sunken hover:text-ink" > {active ? ( - + ) : ( {toolSummary( diff --git a/app/src/features/chat/ToolLines.tsx b/app/src/features/chat/ToolLines.tsx index 7610d75a..2eb10a7b 100644 --- a/app/src/features/chat/ToolLines.tsx +++ b/app/src/features/chat/ToolLines.tsx @@ -35,6 +35,7 @@ import { ShowFile } from "../files/showFile"; import { isScreenFile, ShowScreen } from "../screens/showScreen"; import { ShowTerminal } from "../terminal/showTerminal"; import { commandOf, isCommand } from "./command"; +import { ReadPicture, ReadThumb, useReadImage } from "./readImage"; import { toolDetail, toolKey, toolTitle } from "./toolNames"; const ICONS: Record = { @@ -100,7 +101,15 @@ function Status({ status }: { status: ToolItem["status"] }) { } } -function ToolLine({ tool, createdAt }: { tool: ToolItem; createdAt: number }) { +function ToolLine({ + tool, + botId, + createdAt, +}: { + tool: ToolItem; + botId: string; + createdAt: number; +}) { const t = useT(); const [open, setOpen] = useState(false); const showFile = useContext(ShowFile); @@ -109,6 +118,7 @@ function ToolLine({ tool, createdAt }: { tool: ToolItem; createdAt: number }) { const showTerminal = useContext(ShowTerminal); const showDesktop = useContext(ShowDesktop); const arrival = useArrival(createdAt); + const picture = useReadImage(botId, tool); const title = toolTitle(tool.name, t.tools); // A command reads in the bot's own words, when it gave them; the command // itself is one click away, below. @@ -135,6 +145,7 @@ function ToolLine({ tool, createdAt }: { tool: ToolItem; createdAt: number }) { > {detail} + {open && (
+

{command ? t.chat.tools.command : t.chat.tools.input}

@@ -227,7 +239,7 @@ export function ToolLines({ items }: { items: ChatItem[] }) {
    {items.map((item) => item.body.kind === "tool" ? ( - + ) : null, )}
diff --git a/app/src/features/chat/images.ts b/app/src/features/chat/images.ts index 674b297d..6ce2564c 100644 --- a/app/src/features/chat/images.ts +++ b/app/src/features/chat/images.ts @@ -37,6 +37,11 @@ export function rememberImage(key: string, url: string): void { } } +/** An image remembered under that key, if any. */ +export function remembered(key: string): string | undefined { + return cache.get(key); +} + export type ImageSource = | { kind: "none" } | { kind: "loading" } diff --git a/app/src/features/chat/readImage.test.tsx b/app/src/features/chat/readImage.test.tsx new file mode 100644 index 00000000..beb1ce65 --- /dev/null +++ b/app/src/features/chat/readImage.test.tsx @@ -0,0 +1,73 @@ +import { act, cleanup, fireEvent, screen, within } from "@testing-library/react"; +import { afterEach, describe, expect, test } from "vitest"; +import { FakeBotloft } from "../../lib/fake"; +import type { ToolItem } from "../../lib/protocol.gen"; +import { crewOpened, openBot, renderApp } from "../../test/app"; +import { readImagePath } from "./readImage"; + +afterEach(cleanup); + +const read = (input: string, name = "Read") => ({ name, input }) as ToolItem; + +describe("the image a bot reads", () => { + test("is found in a Read of a picture, even with its input cut short", () => { + expect(readImagePath(read(JSON.stringify({ file_path: "C:\\Work\\q10.png" })))).toBe( + "C:\\Work\\q10.png", + ); + expect(readImagePath(read('{"file_path":"C:\\\\Work\\\\shot.JPG","limit":20'))).toBe( + "C:\\Work\\shot.JPG", + ); + expect(readImagePath(read(JSON.stringify({ file_path: "C:\\Work\\notes.md" })))).toBeNull(); + expect(readImagePath(read(JSON.stringify({ file_path: "a.png" }), "Write"))).toBeNull(); + }); + + test("shows on the call's line and larger when it opens", async () => { + const fake = new FakeBotloft(); + const ops = fake.addCrew("Ops"); + const scout = fake.addBot(ops.id, "Scout", "Finds sources"); + const file = fake.files.add(scout.id, "q10.png", { text: "picture" }); + fake.setBotState(scout.id, "busy"); + renderApp(fake); + await crewOpened("Ops"); + openBot("Scout"); + const chat = await screen.findByRole("list", { name: "Messages" }); + + act(() => { + fake.chat.tool(scout.id, "Read", { + summary: "q10.png", + input: JSON.stringify({ file_path: file.path }), + }); + }); + const now = within(chat).getByRole("button", { name: /Read a file/ }); + const thumb = await within(now).findByRole("img", { name: "q10.png" }); + expect(thumb.getAttribute("src")).toMatch(/^data:image\/png;base64,/); + + act(() => { + fake.chat.turn(scout.id); + fake.setBotState(scout.id, "idle"); + }); + const line = within(chat).getByRole("button", { name: /Read a file/ }); + fireEvent.click(line); + expect(within(chat).getAllByRole("img", { name: "q10.png" })).toHaveLength(2); + }); + + test("an image outside the bot's folders keeps the plain line", async () => { + const fake = new FakeBotloft(); + const ops = fake.addCrew("Ops"); + const scout = fake.addBot(ops.id, "Scout", "Finds sources"); + fake.setBotState(scout.id, "idle"); + fake.chat.tool(scout.id, "Read", { + summary: "secret.png", + status: "done", + input: JSON.stringify({ file_path: "D:\\Elsewhere\\secret.png" }), + }); + fake.chat.turn(scout.id); + renderApp(fake); + await crewOpened("Ops"); + openBot("Scout"); + const chat = await screen.findByRole("list", { name: "Messages" }); + await within(chat).findByRole("button", { name: /Read a file/ }); + await act(async () => {}); + expect(within(chat).queryByRole("img", { name: "secret.png" })).toBeNull(); + }); +}); diff --git a/app/src/features/chat/readImage.tsx b/app/src/features/chat/readImage.tsx new file mode 100644 index 00000000..016425bd --- /dev/null +++ b/app/src/features/chat/readImage.tsx @@ -0,0 +1,94 @@ +// The picture a bot is looking at: a `Read` of an image shows it small on +// its tool line and bigger when the line opens (spec 15.3). It is read with +// `files.read`, so only images in the bot's folders show; any other path +// keeps the plain line. + +import { useEffect, useState } from "react"; +import type { ToolItem } from "../../lib/protocol.gen"; +import { useApi } from "../../store/context"; +import { dataUrl, type ImageSource, remembered, rememberImage } from "./images"; + +const IMAGE_FILE = /\.(png|jpe?g|gif|webp|bmp)$/i; +const FILE_PATH = /"file_path"\s*:\s*("(?:[^"\\]|\\.)*")/; + +/** The image a `Read` call opens, or null for any other call or file. */ +export function readImagePath(tool: ToolItem): string | null { + if (tool.name !== "Read") { + return null; + } + // The input may be cut short, so the path is found, not parsed whole. + const found = FILE_PATH.exec(tool.input)?.[1]; + if (!found) { + return null; + } + try { + const path: unknown = JSON.parse(found); + return typeof path === "string" && IMAGE_FILE.test(path) ? path : null; + } catch { + return null; + } +} + +/** The image of a `Read` call, as it is now in the bot's folder. */ +export function useReadImage(botId: string, tool: ToolItem): ImageSource { + const api = useApi(); + const path = tool.status === "failed" ? null : readImagePath(tool); + const key = `read:${botId}:${tool.toolUseId}`; + const [source, setSource] = useState(() => { + const known = remembered(key); + if (known) { + return { kind: "ready", url: known }; + } + return path ? { kind: "loading" } : { kind: "none" }; + }); + + useEffect(() => { + if (!path || remembered(key)) { + return; + } + let alive = true; + api.call("files.read", { botId, path }).then( + (read) => { + const url = dataUrl(read.mediaType, read.data); + rememberImage(key, url); + if (alive) { + setSource({ kind: "ready", url }); + } + }, + () => alive && setSource({ kind: "missing" }), + ); + return () => { + alive = false; + }; + }, [api, botId, key, path]); + + return source; +} + +/** The image small, beside the call's words. */ +export function ReadThumb({ source, name }: { source: ImageSource; name: string }) { + if (source.kind !== "ready") { + return null; + } + return ( + {name} + ); +} + +/** The image as large as the opened line allows. */ +export function ReadPicture({ source, name }: { source: ImageSource; name: string }) { + if (source.kind !== "ready") { + return null; + } + return ( + {name} + ); +} diff --git a/docs/spec.md b/docs/spec.md index 96e42609..f2c2e283 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -910,7 +910,7 @@ No chat: - Mensagens de outros bots aparecem à esquerda, com o avatar e o nome de quem mandou. - **Bots se chamando.** Uma message de um bot para outro é uma chamada até o outro pegá-la (`read_at`, 9.1). Enquanto toca, uma pílula mostra os mascotes dos dois, um sobre o outro em círculos, com "Chamando " e três pontos; quando o outro pega, vira " atendeu", com um tique, por 4 s, e some. Uma delivery `dead` encerra a chamada, e uma que ninguém pegou em uma hora deixa de ser chamada. Aparece no chat de quem chamou, flutuando acima do compositor sem mexer na conversa, e no cabeçalho da página da crew, com as chamadas de todos os bots dela. Uma pílula por par de bots, a da chamada mais nova. O app guarda quem chamou quem a partir de `message.created` desde que conectou; se foi atendida, sabe pela delivery, que toda conexão carrega de novo. Os mascotes da pílula se olham como os outros (15.3). O leitor de tela ouve quem chama antes ("Writer: Chamando Revisão"). - Um bot citado pelo handle ("@writer") nas respostas e nas messages fica em azul-claro (token `--mention`, que ainda lê a 4,5:1) e um pouco mais forte, para não se perder no texto. E-mail, caminho e o que está em código ou link não contam. -- O que o bot faz com as ferramentas aparece em linhas compactas (ícone, ferramenta, resumo e estado), que abrem para mostrar entrada e saída. As chamadas seguidas ficam dobradas numa linha só, com uma seta para abrir todas: enquanto o bot trabalha nelas, a linha diz a chamada em que ele está (o nome com um brilho passando e um círculo girando, sem os três pontos embaixo) e troca, subindo, a cada chamada nova; quando ele escreve, pede algo ou para, a linha vira um resumo por tipo, na ordem em que fez, com as falhas no fim ("2 passos no navegador, 1 comando rodado (1 falha)"). Uma chamada sozinha que já terminou fica na sua própria linha. Com menos movimento, o nome fica parado. +- O que o bot faz com as ferramentas aparece em linhas compactas (ícone, ferramenta, resumo e estado), que abrem para mostrar entrada e saída. As chamadas seguidas ficam dobradas numa linha só, com uma seta para abrir todas: enquanto o bot trabalha nelas, a linha diz a chamada em que ele está (o nome com um brilho passando e um círculo girando, sem os três pontos embaixo) e troca, subindo, a cada chamada nova; quando ele escreve, pede algo ou para, a linha vira um resumo por tipo, na ordem em que fez, com as falhas no fim ("2 passos no navegador, 1 comando rodado (1 falha)"). Uma chamada sozinha que já terminou fica na sua própria linha. Com menos movimento, o nome fica parado. Um `Read` de imagem (png, jpg, gif, webp, bmp) mostra a imagem pequena na linha, também na linha dobrada enquanto o bot a lê, e maior quando a linha abre, para o dono ver o que o bot está olhando. Ela é lida com `files.read` (8.4), como está agora: uma imagem fora das pastas do bot fica só com o nome. - Pedido de aprovação é um cartão com o que o bot quer fazer e os botões Permitir e Negar. - Pedido para seguir com um plano (10.1) é um cartão com o plano em markdown, um campo para o que deve mudar e os botões Aprovar plano e Pedir mudanças. Respondido, vira uma linha que abre o plano de novo. - Anexos aparecem como miniatura (imagem) ou cartão com nome, tipo e tamanho.