Skip to content

Commit 864bd0c

Browse files
authored
Merge pull request #191 from httpsphl/feat/chat-read-image
feat(chat): show the image a bot reads on its tool line
2 parents cef24e2 + 365b86a commit 864bd0c

6 files changed

Lines changed: 193 additions & 6 deletions

File tree

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

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,16 @@ import { ChevronRight, LoaderCircle } from "lucide-react";
77
import { useState } from "react";
88
import { useT } from "../../i18n";
99
import type { ChatItem, ToolItem } from "../../lib/protocol.gen";
10+
import { ReadThumb, useReadImage } from "./readImage";
1011
import { ToolLines } from "./ToolLines";
1112
import { toolDetail, toolTitle } from "./toolNames";
1213
import { toolSummary } from "./toolSummary";
1314

1415
/** The call the bot is on, swapped in as the next one starts. */
15-
function Now({ tool }: { tool: ToolItem }) {
16+
function Now({ tool, botId }: { tool: ToolItem; botId: string }) {
1617
const t = useT();
1718
const detail = tool.explanation || toolDetail(tool, t.tools);
19+
const picture = useReadImage(botId, tool);
1820
return (
1921
<span className="flex min-w-0 animate-rise items-center gap-2">
2022
<LoaderCircle
@@ -31,6 +33,7 @@ function Now({ tool }: { tool: ToolItem }) {
3133
{detail}
3234
</span>
3335
)}
36+
<ReadThumb source={picture} name={detail} />
3437
</span>
3538
);
3639
}
@@ -39,7 +42,7 @@ export function ToolGroup({ items, active }: { items: ChatItem[]; active: boolea
3942
const t = useT();
4043
const [open, setOpen] = useState(false);
4144
const calls = items.flatMap((item) =>
42-
item.body.kind === "tool" ? [{ id: item.id, tool: item.body }] : [],
45+
item.body.kind === "tool" ? [{ id: item.id, botId: item.botId, tool: item.body }] : [],
4346
);
4447
const now = calls.at(-1);
4548
// One finished call is already a single line.
@@ -55,7 +58,7 @@ export function ToolGroup({ items, active }: { items: ChatItem[]; active: boolea
5558
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"
5659
>
5760
{active ? (
58-
<Now key={now.id} tool={now.tool} />
61+
<Now key={now.id} tool={now.tool} botId={now.botId} />
5962
) : (
6063
<span className="min-w-0 truncate">
6164
{toolSummary(

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

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import { ShowFile } from "../files/showFile";
3535
import { isScreenFile, ShowScreen } from "../screens/showScreen";
3636
import { ShowTerminal } from "../terminal/showTerminal";
3737
import { commandOf, isCommand } from "./command";
38+
import { ReadPicture, ReadThumb, useReadImage } from "./readImage";
3839
import { toolDetail, toolKey, toolTitle } from "./toolNames";
3940

4041
const ICONS: Record<string, LucideIcon> = {
@@ -100,7 +101,15 @@ function Status({ status }: { status: ToolItem["status"] }) {
100101
}
101102
}
102103

103-
function ToolLine({ tool, createdAt }: { tool: ToolItem; createdAt: number }) {
104+
function ToolLine({
105+
tool,
106+
botId,
107+
createdAt,
108+
}: {
109+
tool: ToolItem;
110+
botId: string;
111+
createdAt: number;
112+
}) {
104113
const t = useT();
105114
const [open, setOpen] = useState(false);
106115
const showFile = useContext(ShowFile);
@@ -109,6 +118,7 @@ function ToolLine({ tool, createdAt }: { tool: ToolItem; createdAt: number }) {
109118
const showTerminal = useContext(ShowTerminal);
110119
const showDesktop = useContext(ShowDesktop);
111120
const arrival = useArrival(createdAt);
121+
const picture = useReadImage(botId, tool);
112122
const title = toolTitle(tool.name, t.tools);
113123
// A command reads in the bot's own words, when it gave them; the command
114124
// itself is one click away, below.
@@ -135,6 +145,7 @@ function ToolLine({ tool, createdAt }: { tool: ToolItem; createdAt: number }) {
135145
>
136146
{detail}
137147
</span>
148+
<ReadThumb source={picture} name={detail} />
138149
<Status status={tool.status} />
139150
<ChevronRight
140151
aria-hidden
@@ -200,6 +211,7 @@ function ToolLine({ tool, createdAt }: { tool: ToolItem; createdAt: number }) {
200211
</div>
201212
{open && (
202213
<div className="mt-1 mb-2 ml-7 flex animate-rise flex-col gap-1.5" data-selectable>
214+
<ReadPicture source={picture} name={detail} />
203215
<p className="text-muted text-xs">
204216
{command ? t.chat.tools.command : t.chat.tools.input}
205217
</p>
@@ -227,7 +239,7 @@ export function ToolLines({ items }: { items: ChatItem[] }) {
227239
<ul aria-label={t.chat.tools.label} className="-mx-1.5 flex flex-col">
228240
{items.map((item) =>
229241
item.body.kind === "tool" ? (
230-
<ToolLine key={item.id} tool={item.body} createdAt={item.createdAt} />
242+
<ToolLine key={item.id} tool={item.body} botId={item.botId} createdAt={item.createdAt} />
231243
) : null,
232244
)}
233245
</ul>

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,11 @@ export function rememberImage(key: string, url: string): void {
3737
}
3838
}
3939

40+
/** An image remembered under that key, if any. */
41+
export function remembered(key: string): string | undefined {
42+
return cache.get(key);
43+
}
44+
4045
export type ImageSource =
4146
| { kind: "none" }
4247
| { kind: "loading" }
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { act, cleanup, fireEvent, screen, within } from "@testing-library/react";
2+
import { afterEach, describe, expect, test } from "vitest";
3+
import { FakeBotloft } from "../../lib/fake";
4+
import type { ToolItem } from "../../lib/protocol.gen";
5+
import { crewOpened, openBot, renderApp } from "../../test/app";
6+
import { readImagePath } from "./readImage";
7+
8+
afterEach(cleanup);
9+
10+
const read = (input: string, name = "Read") => ({ name, input }) as ToolItem;
11+
12+
describe("the image a bot reads", () => {
13+
test("is found in a Read of a picture, even with its input cut short", () => {
14+
expect(readImagePath(read(JSON.stringify({ file_path: "C:\\Work\\q10.png" })))).toBe(
15+
"C:\\Work\\q10.png",
16+
);
17+
expect(readImagePath(read('{"file_path":"C:\\\\Work\\\\shot.JPG","limit":20'))).toBe(
18+
"C:\\Work\\shot.JPG",
19+
);
20+
expect(readImagePath(read(JSON.stringify({ file_path: "C:\\Work\\notes.md" })))).toBeNull();
21+
expect(readImagePath(read(JSON.stringify({ file_path: "a.png" }), "Write"))).toBeNull();
22+
});
23+
24+
test("shows on the call's line and larger when it opens", async () => {
25+
const fake = new FakeBotloft();
26+
const ops = fake.addCrew("Ops");
27+
const scout = fake.addBot(ops.id, "Scout", "Finds sources");
28+
const file = fake.files.add(scout.id, "q10.png", { text: "picture" });
29+
fake.setBotState(scout.id, "busy");
30+
renderApp(fake);
31+
await crewOpened("Ops");
32+
openBot("Scout");
33+
const chat = await screen.findByRole("list", { name: "Messages" });
34+
35+
act(() => {
36+
fake.chat.tool(scout.id, "Read", {
37+
summary: "q10.png",
38+
input: JSON.stringify({ file_path: file.path }),
39+
});
40+
});
41+
const now = within(chat).getByRole("button", { name: /Read a file/ });
42+
const thumb = await within(now).findByRole("img", { name: "q10.png" });
43+
expect(thumb.getAttribute("src")).toMatch(/^data:image\/png;base64,/);
44+
45+
act(() => {
46+
fake.chat.turn(scout.id);
47+
fake.setBotState(scout.id, "idle");
48+
});
49+
const line = within(chat).getByRole("button", { name: /Read a file/ });
50+
fireEvent.click(line);
51+
expect(within(chat).getAllByRole("img", { name: "q10.png" })).toHaveLength(2);
52+
});
53+
54+
test("an image outside the bot's folders keeps the plain line", async () => {
55+
const fake = new FakeBotloft();
56+
const ops = fake.addCrew("Ops");
57+
const scout = fake.addBot(ops.id, "Scout", "Finds sources");
58+
fake.setBotState(scout.id, "idle");
59+
fake.chat.tool(scout.id, "Read", {
60+
summary: "secret.png",
61+
status: "done",
62+
input: JSON.stringify({ file_path: "D:\\Elsewhere\\secret.png" }),
63+
});
64+
fake.chat.turn(scout.id);
65+
renderApp(fake);
66+
await crewOpened("Ops");
67+
openBot("Scout");
68+
const chat = await screen.findByRole("list", { name: "Messages" });
69+
await within(chat).findByRole("button", { name: /Read a file/ });
70+
await act(async () => {});
71+
expect(within(chat).queryByRole("img", { name: "secret.png" })).toBeNull();
72+
});
73+
});
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
// The picture a bot is looking at: a `Read` of an image shows it small on
2+
// its tool line and bigger when the line opens (spec 15.3). It is read with
3+
// `files.read`, so only images in the bot's folders show; any other path
4+
// keeps the plain line.
5+
6+
import { useEffect, useState } from "react";
7+
import type { ToolItem } from "../../lib/protocol.gen";
8+
import { useApi } from "../../store/context";
9+
import { dataUrl, type ImageSource, remembered, rememberImage } from "./images";
10+
11+
const IMAGE_FILE = /\.(png|jpe?g|gif|webp|bmp)$/i;
12+
const FILE_PATH = /"file_path"\s*:\s*("(?:[^"\\]|\\.)*")/;
13+
14+
/** The image a `Read` call opens, or null for any other call or file. */
15+
export function readImagePath(tool: ToolItem): string | null {
16+
if (tool.name !== "Read") {
17+
return null;
18+
}
19+
// The input may be cut short, so the path is found, not parsed whole.
20+
const found = FILE_PATH.exec(tool.input)?.[1];
21+
if (!found) {
22+
return null;
23+
}
24+
try {
25+
const path: unknown = JSON.parse(found);
26+
return typeof path === "string" && IMAGE_FILE.test(path) ? path : null;
27+
} catch {
28+
return null;
29+
}
30+
}
31+
32+
/** The image of a `Read` call, as it is now in the bot's folder. */
33+
export function useReadImage(botId: string, tool: ToolItem): ImageSource {
34+
const api = useApi();
35+
const path = tool.status === "failed" ? null : readImagePath(tool);
36+
const key = `read:${botId}:${tool.toolUseId}`;
37+
const [source, setSource] = useState<ImageSource>(() => {
38+
const known = remembered(key);
39+
if (known) {
40+
return { kind: "ready", url: known };
41+
}
42+
return path ? { kind: "loading" } : { kind: "none" };
43+
});
44+
45+
useEffect(() => {
46+
if (!path || remembered(key)) {
47+
return;
48+
}
49+
let alive = true;
50+
api.call("files.read", { botId, path }).then(
51+
(read) => {
52+
const url = dataUrl(read.mediaType, read.data);
53+
rememberImage(key, url);
54+
if (alive) {
55+
setSource({ kind: "ready", url });
56+
}
57+
},
58+
() => alive && setSource({ kind: "missing" }),
59+
);
60+
return () => {
61+
alive = false;
62+
};
63+
}, [api, botId, key, path]);
64+
65+
return source;
66+
}
67+
68+
/** The image small, beside the call's words. */
69+
export function ReadThumb({ source, name }: { source: ImageSource; name: string }) {
70+
if (source.kind !== "ready") {
71+
return null;
72+
}
73+
return (
74+
<img
75+
src={source.url}
76+
alt={name}
77+
className="size-5 shrink-0 rounded border border-line bg-sunken object-cover"
78+
/>
79+
);
80+
}
81+
82+
/** The image as large as the opened line allows. */
83+
export function ReadPicture({ source, name }: { source: ImageSource; name: string }) {
84+
if (source.kind !== "ready") {
85+
return null;
86+
}
87+
return (
88+
<img
89+
src={source.url}
90+
alt={name}
91+
className="block max-h-64 max-w-full self-start rounded-lg border border-line bg-sunken object-contain"
92+
/>
93+
);
94+
}

‎docs/spec.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -910,7 +910,7 @@ No chat:
910910
- Mensagens de outros bots aparecem à esquerda, com o avatar e o nome de quem mandou.
911911
- **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 <bot>" e três pontos; quando o outro pega, vira "<bot> 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").
912912
- 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.
913-
- 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.
913+
- 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.
914914
- Pedido de aprovação é um cartão com o que o bot quer fazer e os botões Permitir e Negar.
915915
- 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.
916916
- Anexos aparecem como miniatura (imagem) ou cartão com nome, tipo e tamanho.

0 commit comments

Comments
 (0)