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
10 changes: 10 additions & 0 deletions app/src/dev/seedChats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,16 @@ export function seedChats(fake: FakeBotloft, crew: Crew): void {
chat.finish(shared, JSON.stringify({ shown: [report, papers] }));
at(92);
chat.reply(crew.scout, SUMMARY);
const memory = chat.tool(crew.scout, "Edit", {
summary: "CLAUDE.md",
file: `${fake.bot(crew.scout).workspace}\\CLAUDE.md`,
input: JSON.stringify({
old_string: "## The owner\n- Reads summaries on Monday",
new_string:
"## The owner\n- Reads summaries on Monday\n- Wants the chart checked against the papers\n- Prefers tables to long lists",
}),
});
chat.finish(memory, "The file has been updated.");
const handoff = chat.tool(crew.scout, "mcp__botloft__send_message", {
summary: "@writer",
input: JSON.stringify({ to: "writer", kind: "task", body: "Turn week-39.md into the report" }),
Expand Down
28 changes: 24 additions & 4 deletions app/src/features/chat/BotRun.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,18 @@
// also shows the reply being written.

import { CircleAlert } from "lucide-react";
import { memo, type ReactNode } from "react";
import { memo, type ReactNode, useMemo } from "react";
import { useT } from "../../i18n";
import { duration, tokens, usedTokens, when } from "../../lib/format";
import type { Bot, ChatItem, TurnItem } from "../../lib/protocol.gen";
import { useApp } from "../../store/context";
import { useArrival } from "../../ui/motion";
import { BotAvatar, moodOf } from "../bots/BotAvatar";
import { QuestionCard } from "../questions/QuestionCard";
import { ApprovalCard } from "./ApprovalCard";
import { Markdown } from "./Markdown";
import { MemoryNote } from "./MemoryNote";
import { memoryOf, memoryPlaces } from "./memory";
import { runParts, splitDraft } from "./rows";
import { SharedFiles } from "./SharedFiles";
import { isSharedFiles } from "./shared";
Expand Down Expand Up @@ -108,12 +111,15 @@ function BotRunView({
live?: Live | undefined;
}) {
const first = items[0];
const parts = runParts(items);
const crew = useApp((state) => state.crews[bot.crewId]);
const places = useMemo(() => memoryPlaces(bot, crew), [bot, crew]);
const memory = (item: ChatItem) => memoryOf(item, places, bot.workspace);
const parts = runParts(items, (item) => memory(item) !== null);
// The bot is still on the last group of calls until it writes or stops.
const busy = Boolean(live?.working && !live.draft);
const tail = parts.at(-1)?.[0];
// Its line already says it is working.
const onCalls = tail?.body.kind === "tool" && !isSharedFiles(tail);
const onCalls = tail?.body.kind === "tool" && !isSharedFiles(tail) && !memory(tail);
return (
// A turn shows up as the bot starts it, still empty; what it does then
// arrives inside, so a run with items never animates as a whole.
Expand All @@ -132,12 +138,26 @@ function BotRunView({
{parts.map((part, index) => {
const head = part[0] as ChatItem;
switch (head.body.kind) {
case "tool":
case "tool": {
const owner = memory(head);
if (owner) {
return (
<MemoryNote
key={head.id}
tool={head.body}
createdAt={head.createdAt}
owner={owner}
name={owner === "bot" ? bot.name : (crew?.name ?? "")}
color={owner === "bot" ? bot.color : undefined}
/>
);
}
return isSharedFiles(head) ? (
<SharedFiles key={head.id} item={head} botId={bot.id} bot={bot.name} />
) : (
<ToolGroup key={head.id} items={part} active={busy && index === parts.length - 1} />
);
}
case "reply":
// It was already on screen as it was written.
return <Markdown key={head.id} text={head.body.text} />;
Expand Down
130 changes: 130 additions & 0 deletions app/src/features/chat/MemoryNote.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
// A line in the bot's turn saying it wrote down something it learned (spec
// 15.3), with whose memory it was: its own, or the crew's. The arrow opens
// what changed; the files button opens the whole memory.

import { BookMarked, ChevronRight, Files } from "lucide-react";
import { useContext, useState } from "react";
import { useT } from "../../i18n";
import type { ToolItem } from "../../lib/protocol.gen";
import { useArrival } from "../../ui/motion";
import { ShowFile } from "../files/showFile";
import { type MemoryChange, type MemoryOwner, memoryChange } from "./memory";

function Lines({ lines, sign }: { lines: string[]; sign: "-" | "+" }) {
const tone = sign === "-" ? "bg-danger/10 text-danger" : "bg-ok/10 text-ok";
return lines.map((line, index) => (
// Lines repeat and never move: their place is their key.
// biome-ignore lint/suspicious/noArrayIndexKey: a fixed list
<span key={index} className={`block px-2 ${tone}`}>
<span aria-hidden className="mr-2 select-none">
{sign}
</span>
<span className="text-ink">{line || " "}</span>
</span>
));
}

function Change({ change }: { change: MemoryChange }) {
const t = useT().chat.memory;
const block =
"max-h-64 overflow-auto whitespace-pre-wrap break-words rounded-lg border border-line bg-sunken py-1.5 font-mono text-xs leading-relaxed";
if (change.kind === "write") {
return (
<>
<p className="text-muted text-xs">{t.now}</p>
<pre className={`${block} px-2.5`}>{change.text}</pre>
</>
);
}
return (
<figure className={block} aria-label={t.changed}>
{change.hunks.map((hunk, index) => (
// biome-ignore lint/suspicious/noArrayIndexKey: a fixed list
<span key={index} className={`block ${index ? "mt-1.5 border-line border-t pt-1.5" : ""}`}>
<Lines lines={hunk.removed} sign="-" />
<Lines lines={hunk.added} sign="+" />
</span>
))}
</figure>
);
}

export function MemoryNote({
tool,
createdAt,
owner,
name,
color,
}: {
tool: ToolItem;
createdAt: number;
owner: MemoryOwner;
/** The bot's name, or the crew's. */
name: string;
/** The bot's color; the crew has none. */
color?: string | undefined;
}) {
const t = useT();
const [open, setOpen] = useState(false);
const showFile = useContext(ShowFile);
const arrival = useArrival(createdAt);
const change = memoryChange(tool);
const words = owner === "bot" ? t.chat.memory.bot : t.chat.memory.crew;
const label = (
<>
<BookMarked aria-hidden size={14} className="shrink-0" />
<span className="shrink-0">{words}</span>
<span className="flex min-w-0 items-center gap-1.5 rounded-full bg-sunken px-2 py-0.5 font-medium text-ink text-xs">
{color && (
<span
aria-hidden
className="size-2 shrink-0 rounded-full"
style={{ background: color }}
/>
)}
<span className="truncate">{name}</span>
</span>
</>
);
const line =
"flex min-w-0 items-center gap-2 rounded-lg px-1.5 py-1 text-left text-muted text-sm";
return (
<div className={`-mx-1.5 flex flex-col ${arrival}`}>
<div className="flex items-center gap-1 self-start">
{change ? (
<button
type="button"
aria-expanded={open}
onClick={() => setOpen(!open)}
className={`${line} transition-colors hover:bg-sunken hover:text-ink`}
>
{label}
<ChevronRight
aria-hidden
size={13}
className={`shrink-0 transition-transform ${open ? "rotate-90" : ""}`}
/>
</button>
) : (
<p className={line}>{label}</p>
)}
{showFile && tool.file && (
<button
type="button"
title={t.files.showInPanel}
aria-label={`${t.files.showInPanel}: ${t.chat.memory.file}`}
onClick={() => showFile(tool.file as string)}
className="grid size-6 shrink-0 place-items-center rounded-lg text-muted transition-colors hover:bg-sunken hover:text-ink"
>
<Files aria-hidden size={14} />
</button>
)}
</div>
{open && change && (
<div className="mt-1 mb-2 ml-7 flex animate-rise flex-col gap-1.5" data-selectable>
<Change change={change} />
</div>
)}
</div>
);
}
145 changes: 145 additions & 0 deletions app/src/features/chat/memory.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import { cleanup, fireEvent, screen, within } from "@testing-library/react";
import { afterEach, describe, expect, test } from "vitest";
import { FakeBotloft } from "../../lib/fake";
import type { ChatItem, ToolItem } from "../../lib/protocol.gen";
import { crewOpened, openBot, renderApp } from "../../test/app";
import { memoryChange, memoryOf, memoryPlaces } from "./memory";

afterEach(cleanup);

const WORKSPACE = "C:\\Users\\owner\\Botloft\\ops\\scout";
const places = memoryPlaces({ workspace: WORKSPACE }, { workFolder: "D:\\Projects\\site" });

function edit(file: string | null, changes: Partial<ToolItem> = {}): ChatItem {
return {
id: "cht_1",
botId: "bot_1",
createdAt: 0,
body: {
kind: "tool",
toolUseId: "toolu_1",
name: "Edit",
summary: "",
explanation: null,
input: "{}",
status: "done",
output: "",
file,
...changes,
},
} as ChatItem;
}

describe("whose memory an edit wrote", () => {
test("the bot's own, in any case and with either slash", () => {
expect(memoryOf(edit(`${WORKSPACE}\\CLAUDE.md`), places, WORKSPACE)).toBe("bot");
expect(memoryOf(edit("c:/users/owner/botloft/ops/scout/claude.md"), places, WORKSPACE)).toBe(
"bot",
);
expect(memoryOf(edit(`${WORKSPACE}\\.claude\\CLAUDE.md`), places, WORKSPACE)).toBe("bot");
// A relative path is from the bot's folder, where it runs.
expect(memoryOf(edit("CLAUDE.md"), places, WORKSPACE)).toBe("bot");
});

test("the crew's, in the crew folder or the work folder", () => {
expect(memoryOf(edit("C:\\Users\\owner\\Botloft\\ops\\CLAUDE.md"), places, WORKSPACE)).toBe(
"crew",
);
expect(memoryOf(edit("D:\\Projects\\site\\CLAUDE.md"), places, WORKSPACE)).toBe("crew");
});

test("nothing for other files, other bots, or calls that did not finish", () => {
expect(memoryOf(edit(`${WORKSPACE}\\notes.md`), places, WORKSPACE)).toBeNull();
expect(
memoryOf(edit("C:\\Users\\owner\\Botloft\\ops\\writer\\CLAUDE.md"), places, WORKSPACE),
).toBeNull();
expect(memoryOf(edit(null), places, WORKSPACE)).toBeNull();
for (const status of ["running", "failed"] as const) {
expect(memoryOf(edit("CLAUDE.md", { status }), places, WORKSPACE)).toBeNull();
}
});
});

describe("what changed", () => {
const tool = () => edit("CLAUDE.md").body as ToolItem;

test("an edit keeps only the lines that changed", () => {
const call = tool();
call.input = JSON.stringify({
old_string: "# Notes\n- likes tea\n- end",
new_string: "# Notes\n- likes coffee\n- signs annual\n- end",
});
expect(memoryChange(call)).toEqual({
kind: "edit",
hunks: [{ removed: ["- likes tea"], added: ["- likes coffee", "- signs annual"] }],
});
});

test("several edits, a whole write, and an input that was cut", () => {
const call = tool();
call.input = JSON.stringify({
edits: [
{ old_string: "a", new_string: "b" },
{ old_string: "same", new_string: "same" },
],
});
expect(memoryChange(call)).toEqual({ kind: "edit", hunks: [{ removed: ["a"], added: ["b"] }] });
call.input = JSON.stringify({ file_path: "CLAUDE.md", content: "# All new" });
expect(memoryChange(call)).toEqual({ kind: "write", text: "# All new" });
call.input = '{"file_path": "CLAUDE.md", "content": "cut he';
expect(memoryChange(call)).toBeNull();
});
});

describe("in the chat", () => {
test("a memory edit is its own line, out of the group, and opens what changed", async () => {
const fake = new FakeBotloft();
const ops = fake.addCrew("Ops");
const scout = fake.addBot(ops.id, "Scout", "Finds sources");
fake.setBotState(scout.id, "idle");
const read = fake.chat.tool(scout.id, "Read", { summary: "brief.md" });
fake.chat.finish(read, "…");
const remember = fake.chat.tool(scout.id, "Edit", {
summary: "CLAUDE.md",
file: `${scout.workspace}\\CLAUDE.md`,
input: JSON.stringify({ old_string: "- old", new_string: "- Dana approves" }),
});
fake.chat.finish(remember, "ok");
const run = fake.chat.tool(scout.id, "Bash", { summary: "ls" });
fake.chat.finish(run, "…");
fake.chat.turn(scout.id);

renderApp(fake);
await crewOpened("Ops");
openBot("Scout");
const line = await screen.findByRole("button", { name: /Updated memory for\s*Scout/ });
// The calls around it are single lines of their own.
expect(screen.getByRole("button", { name: /Read a file/ })).toBeDefined();
expect(screen.queryByRole("button", { name: /Edit a file/ })).toBeNull();

fireEvent.click(line);
const changed = screen.getByLabelText("What changed");
expect(within(changed).getByText("- old")).toBeDefined();
expect(within(changed).getByText("- Dana approves")).toBeDefined();
});

test("the crew's memory names the crew", async () => {
const fake = new FakeBotloft();
const ops = fake.addCrew("Ops");
const scout = fake.addBot(ops.id, "Scout", "Finds sources");
fake.setBotState(scout.id, "idle");
const write = fake.chat.tool(scout.id, "Write", {
file: `${ops.workFolder}\\CLAUDE.md`,
input: JSON.stringify({ content: "# Ops\nAcme signs annual." }),
});
fake.chat.finish(write, "ok");
fake.chat.turn(scout.id);

renderApp(fake);
await crewOpened("Ops");
openBot("Scout");
fireEvent.click(await screen.findByRole("button", { name: /Updated crew memory for\s*Ops/ }));
expect(screen.getByText("The memory now says:")).toBeDefined();
expect(screen.getByText(/Acme signs annual/)).toBeDefined();
});
});
Loading
Loading