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
14 changes: 14 additions & 0 deletions app/src/dev/seedChats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,20 @@ export function seedChats(fake: FakeBotloft, crew: Crew): void {
at(90);
chat.finish(chat.tool(crew.writer, "Read", { summary: "week-39.md" }), "42 lines");
chat.reply(crew.writer, "Starting the draft. I'll keep the three highlights as sections.");
const draft = "C:\\Work\\reports\\week-39-report.md";
const outline = chat.tool(crew.writer, "Edit", {
summary: "week-39-report.md",
file: draft,
input: JSON.stringify({
file_path: draft,
old_string: "# Week 39\n\nTODO: highlights",
new_string:
"# Week 39\n\n## Retrieval at scale\n## Agents that plan\n## Small evals\n\nSources: shared/summaries/week-39.md",
}),
lines: { added: 5, removed: 1 },
});
chat.finish(outline, "The file has been updated.");
chat.reply(crew.writer, "The outline is in. Writing the sections now.");
chat.tool(crew.writer, "Write", { summary: "week-39-report.md" });

at(40);
Expand Down
80 changes: 80 additions & 0 deletions app/src/features/chat/EditDiff.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// How much an edit changed, on its tool line ("+12 −3"), and the diff the
// line opens to (spec 15.3). Each changed line carries its + or −, so the
// colors are never the only sign.

import { useT } from "../../i18n";
import type { EditLines } from "../../lib/protocol.gen";
import type { DiffLine } from "./lineDiff";

/** The counts the daemon took from the whole edit (spec 8.2); a zero side
* is left out, and an edit that changed nothing shows nothing. */
export function EditCounts({ lines }: { lines: EditLines }) {
const t = useT();
const { added, removed } = lines;
if (added === 0 && removed === 0) {
return null;
}
const said = [
added > 0 && t.chat.tools.added(added),
removed > 0 && t.chat.tools.removed(removed),
]
.filter(Boolean)
.join(", ");
return (
<span
role="img"
aria-label={said}
title={said}
className="flex shrink-0 gap-1 font-mono text-xs"
>
{added > 0 && <span className="text-ok">+{added}</span>}
{removed > 0 && <span className="text-danger">−{removed}</span>}
</span>
);
}

const MARK: Record<DiffLine["kind"], string> = { same: " ", added: "+", removed: "−" };

const LINE: Record<DiffLine["kind"], string> = {
same: "",
added: "bg-ok/12",
removed: "bg-danger/12",
};

const SIGN: Record<DiffLine["kind"], string> = {
same: "text-muted",
added: "text-ok",
removed: "text-danger",
};

/** One block per replacement, each line with its mark. */
export function EditDiff({ diffs }: { diffs: DiffLine[][] }) {
return (
<div className="flex flex-col gap-1.5">
{diffs.map((lines, n) => (
<pre
// biome-ignore lint/suspicious/noArrayIndexKey: a fixed list, with no ids
key={n}
className="max-h-64 overflow-auto rounded-lg border border-line bg-sunken py-1.5 font-mono text-xs leading-relaxed"
>
{lines.map((line, i) => (
<span
// biome-ignore lint/suspicious/noArrayIndexKey: lines of one fixed text
key={i}
data-kind={line.kind}
className={`flex whitespace-pre-wrap break-all px-2.5 ${LINE[line.kind]}`}
>
<span
aria-hidden={line.kind === "same"}
className={`w-4 shrink-0 select-none ${SIGN[line.kind]}`}
>
{MARK[line.kind]}
</span>
<span className="min-w-0 flex-1">{line.text || " "}</span>
</span>
))}
</pre>
))}
</div>
);
}
28 changes: 23 additions & 5 deletions app/src/features/chat/ToolLines.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import {
Wrench,
X,
} from "lucide-react";
import { useContext, useState } from "react";
import { useContext, useMemo, useState } from "react";
import { useT } from "../../i18n";
import type { ChatItem, ToolItem } from "../../lib/protocol.gen";
import { useArrival } from "../../ui/motion";
Expand All @@ -37,6 +37,8 @@ import { ShowFile } from "../files/showFile";
import { isScreenFile, ShowScreen } from "../screens/showScreen";
import { ShowTerminal } from "../terminal/showTerminal";
import { commandOf, isCommand } from "./command";
import { EditCounts, EditDiff } from "./EditDiff";
import { editDiffs, isEditTool } from "./lineDiff";
import { ReadPicture, ReadThumb, useReadImage } from "./readImage";
import { toolDetail, toolKey, toolTitle } from "./toolNames";

Expand Down Expand Up @@ -129,6 +131,11 @@ function ToolLine({
const said = tool.explanation;
const detail = said || toolDetail(tool, t.tools);
const command = isCommand(tool.name) ? commandOf(tool.input) : null;
// Read only once the line opens: an edit's input can be 4 KB.
const diffs = useMemo(
() => (open && isEditTool(tool.name) ? editDiffs(tool.name, tool.input) : null),
[open, tool.name, tool.input],
);
const Icon = iconOf(toolKey(tool.name));
const block =
"max-h-64 overflow-auto whitespace-pre-wrap break-all rounded-lg border border-line bg-sunken px-2.5 py-1.5 font-mono text-xs leading-relaxed";
Expand All @@ -150,6 +157,7 @@ function ToolLine({
{detail}
</span>
<ReadThumb source={picture} name={detail} />
{tool.lines && tool.status !== "failed" && <EditCounts lines={tool.lines} />}
<Status status={tool.status} />
<ChevronRight
aria-hidden
Expand Down Expand Up @@ -216,13 +224,23 @@ function ToolLine({
{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>
<pre className={block}>{command ? command.text : pretty(tool.input)}</pre>
{diffs ? (
<>
<p className="text-muted text-xs">{t.chat.tools.changes}</p>
<EditDiff diffs={diffs} />
</>
) : (
<>
<p className="text-muted text-xs">
{command ? t.chat.tools.command : t.chat.tools.input}
</p>
<pre className={block}>{command ? command.text : pretty(tool.input)}</pre>
</>
)}
{command && !command.whole && (
<p className="text-warn text-xs">{t.chat.tools.commandCut}</p>
)}
{!diffs && tool.lines && <p className="text-warn text-xs">{t.chat.tools.changesCut}</p>}
{tool.output !== null && (
<>
<p className={`text-xs ${tool.status === "failed" ? "text-danger" : "text-muted"}`}>
Expand Down
138 changes: 138 additions & 0 deletions app/src/features/chat/editDiff.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { act, cleanup, fireEvent, screen, within } from "@testing-library/react";
import { afterEach, describe, expect, test } from "vitest";
import { FakeBotloft } from "../../lib/fake";
import { crewOpened, openBot, renderApp } from "../../test/app";
import { type DiffLine, diffLines, editDiffs, splitLines } from "./lineDiff";

afterEach(cleanup);

/** Lines only in the new text and only in the old, as the daemon counts. */
function counts(lines: DiffLine[]) {
return {
added: lines.filter((line) => line.kind === "added").length,
removed: lines.filter((line) => line.kind === "removed").length,
};
}

describe("the lines an edit changes", () => {
test("split the way the daemon splits them", () => {
expect(splitLines("")).toEqual([]);
expect(splitLines("a\nb\n")).toEqual(["a", "b"]);
expect(splitLines("a\r\n\r\nb")).toEqual(["a", "", "b"]);
expect(splitLines("\n")).toEqual([""]);
});

test("keep what both share and change the rest, removed first", () => {
const diff = diffLines(
"fn a() {\n one();\n two();\n}",
"fn a() {\n one();\n three();\n four();\n}",
);
expect(diff.map((line) => `${line.kind[0]} ${line.text}`)).toEqual([
"s fn a() {",
"s one();",
"r two();",
"a three();",
"a four();",
"s }",
]);
// The same counts as the daemon's tests (crates/botloft-core/src/edits.rs).
expect(counts(diff)).toEqual({ added: 2, removed: 1 });
expect(counts(diffLines("a\nb\nc\nd", "b\na\nc\nd\ne"))).toEqual({ added: 2, removed: 1 });
expect(counts(diffLines("let x = 1;", "let x = 2;"))).toEqual({ added: 1, removed: 1 });
expect(counts(diffLines("a\r\nb\r\n", ""))).toEqual({ added: 0, removed: 2 });
});

test("come from the input of each edit tool, when it came whole", () => {
const edit = JSON.stringify({ file_path: "a.md", old_string: "a", new_string: "a\nb" });
expect(editDiffs("Edit", edit)?.map(counts)).toEqual([{ added: 1, removed: 0 }]);
const multi = JSON.stringify({
edits: [
{ old_string: "a", new_string: "b" },
{ old_string: "x\ny", new_string: "x", replace_all: true },
],
});
expect(editDiffs("MultiEdit", multi)?.map(counts)).toEqual([
{ added: 1, removed: 1 },
{ added: 0, removed: 1 },
]);
const write = JSON.stringify({ file_path: "new.txt", content: "one\ntwo\n" });
expect(editDiffs("Write", write)?.map(counts)).toEqual([{ added: 2, removed: 0 }]);
// Cut to size, only the path (Codex, Antigravity), or another tool.
expect(editDiffs("Edit", `${edit.slice(0, 20)}…`)).toBeNull();
expect(editDiffs("Edit", JSON.stringify({ file_path: "a.md" }))).toBeNull();
expect(editDiffs("Read", JSON.stringify({ content: "a" }))).toBeNull();
});
});

describe("an edit in the chat", () => {
async function chat() {
const fake = new FakeBotloft();
const ops = fake.addCrew("Ops");
const scout = fake.addBot(ops.id, "Scout", "Finds sources");
renderApp(fake);
await crewOpened("Ops");
openBot("Scout");
const list = await screen.findByRole("list", { name: "Messages" });
return { fake, scout, list };
}

test("shows its counts on the line and the diff when opened", async () => {
const { fake, scout, list } = await chat();
act(() => {
fake.chat.tool(scout.id, "Edit", {
summary: "notes.md",
status: "done",
input: JSON.stringify({
file_path: "C:\\Work\\notes.md",
old_string: "# Notes\nTODO",
new_string: "# Notes\nFirst\nSecond",
}),
lines: { added: 2, removed: 1 },
});
});
const line = within(list).getByRole("button", { name: /Edit a file/ });
expect(within(line).getByRole("img", { name: "2 lines added, 1 line removed" })).toBeDefined();
fireEvent.click(line);
expect(within(list).getByText("Changes")).toBeDefined();
expect(within(list).queryByText("Input")).toBeNull();
const kinds = [...list.querySelectorAll("[data-kind]")].map((row) => [
row.getAttribute("data-kind"),
row.textContent?.slice(1),
]);
expect(kinds).toEqual([
["same", "# Notes"],
["removed", "TODO"],
["added", "First"],
["added", "Second"],
]);
});

test("says when the change is too long to show, and shows no counts when it failed", async () => {
const { fake, scout, list } = await chat();
act(() => {
fake.chat.tool(scout.id, "Write", {
summary: "big.md",
status: "done",
input: '{"file_path":"big.md","content":"one\\ntwo…',
lines: { added: 900, removed: 0 },
});
// A reply in between keeps the two calls on lines of their own.
fake.chat.reply(scout.id, "Now the old notes.");
fake.chat.tool(scout.id, "Edit", {
summary: "gone.md",
status: "failed",
input: JSON.stringify({ file_path: "gone.md", old_string: "a", new_string: "b" }),
lines: { added: 1, removed: 1 },
});
});
const [write, failed] = within(list).getAllByRole("button", { name: /a file/ });
if (!write || !failed) {
throw new Error("two lines");
}
expect(within(write).getByRole("img", { name: "900 lines added" })).toBeDefined();
fireEvent.click(write);
expect(within(list).getByText("This change is too long to show here.")).toBeDefined();
expect(within(list).getByText("Input")).toBeDefined();
expect(within(failed).queryByRole("img")).toBeNull();
});
});
Loading
Loading