+
{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 (
+
+ );
+}
+
+/** 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 (
+
+ );
+}
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.