Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions app/src/features/chat/ToolGroup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<span className="flex min-w-0 animate-rise items-center gap-2">
<LoaderCircle
Expand All @@ -31,6 +33,7 @@ function Now({ tool }: { tool: ToolItem }) {
{detail}
</span>
)}
<ReadThumb source={picture} name={detail} />
</span>
);
}
Expand All @@ -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.
Expand All @@ -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 ? (
<Now key={now.id} tool={now.tool} />
<Now key={now.id} tool={now.tool} botId={now.botId} />
) : (
<span className="min-w-0 truncate">
{toolSummary(
Expand Down
16 changes: 14 additions & 2 deletions app/src/features/chat/ToolLines.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, LucideIcon> = {
Expand Down Expand Up @@ -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);
Expand All @@ -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.
Expand All @@ -135,6 +145,7 @@ function ToolLine({ tool, createdAt }: { tool: ToolItem; createdAt: number }) {
>
{detail}
</span>
<ReadThumb source={picture} name={detail} />
<Status status={tool.status} />
<ChevronRight
aria-hidden
Expand Down Expand Up @@ -200,6 +211,7 @@ function ToolLine({ tool, createdAt }: { tool: ToolItem; createdAt: number }) {
</div>
{open && (
<div className="mt-1 mb-2 ml-7 flex animate-rise flex-col gap-1.5" data-selectable>
<ReadPicture source={picture} name={detail} />
<p className="text-muted text-xs">
{command ? t.chat.tools.command : t.chat.tools.input}
</p>
Expand Down Expand Up @@ -227,7 +239,7 @@ export function ToolLines({ items }: { items: ChatItem[] }) {
<ul aria-label={t.chat.tools.label} className="-mx-1.5 flex flex-col">
{items.map((item) =>
item.body.kind === "tool" ? (
<ToolLine key={item.id} tool={item.body} createdAt={item.createdAt} />
<ToolLine key={item.id} tool={item.body} botId={item.botId} createdAt={item.createdAt} />
) : null,
)}
</ul>
Expand Down
5 changes: 5 additions & 0 deletions app/src/features/chat/images.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down
73 changes: 73 additions & 0 deletions app/src/features/chat/readImage.test.tsx
Original file line number Diff line number Diff line change
@@ -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();
});
});
94 changes: 94 additions & 0 deletions app/src/features/chat/readImage.tsx
Original file line number Diff line number Diff line change
@@ -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<ImageSource>(() => {
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 (
<img
src={source.url}
alt={name}
className="size-5 shrink-0 rounded border border-line bg-sunken object-cover"
/>
);
}

/** 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 (
<img
src={source.url}
alt={name}
className="block max-h-64 max-w-full self-start rounded-lg border border-line bg-sunken object-contain"
/>
);
}
2 changes: 1 addition & 1 deletion docs/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <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").
- 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.
Expand Down
Loading