From 365b86a4bc86ead8f61aab30b64444cde05d3b70 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:22:24 -0300 Subject: [PATCH] feat(chat): show the image a bot reads on its tool line A Read of a png, jpg, gif, webp or bmp now shows the picture small on the line (also on the folded line while the bot reads it) and larger when the line opens. It is read with files.read, so only images in the bot's folders show; any other path keeps the plain line. --- app/src/features/chat/ToolGroup.tsx | 9 ++- app/src/features/chat/ToolLines.tsx | 16 +++- app/src/features/chat/images.ts | 5 ++ app/src/features/chat/readImage.test.tsx | 73 ++++++++++++++++++ app/src/features/chat/readImage.tsx | 94 ++++++++++++++++++++++++ docs/spec.md | 2 +- 6 files changed, 193 insertions(+), 6 deletions(-) create mode 100644 app/src/features/chat/readImage.test.tsx create mode 100644 app/src/features/chat/readImage.tsx 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.