Skip to content

Commit 7fb1dd2

Browse files
authored
Merge pull request #76 from httpsphl/perf/app-chat
perf(app): stream a reply without re-rendering the whole chat
2 parents 86dd217 + 377785a commit 7fb1dd2

12 files changed

Lines changed: 218 additions & 46 deletions

File tree

‎app/src/features/bots/BotView.tsx‎

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { Callout } from "../../ui/Callout";
1010
import { PanelClosing, PanelRestored } from "../../ui/panelMotion";
1111
import { SidePanel } from "../../ui/SidePanel";
1212
import { type Tab, Tabs, tabId } from "../../ui/Tabs";
13+
import { useStable } from "../../ui/useStable";
1314
import { BrowserPanel } from "../browser/BrowserPanel";
1415
import { ShowBrowser } from "../browser/showBrowser";
1516
import { ChatView } from "../chat/ChatView";
@@ -86,14 +87,15 @@ export function BotView({ bot, crew }: { bot: Bot; crew: Crew }) {
8687
};
8788
// A tool line in the chat points to its file: read the list first, so the
8889
// file is in it when the panel goes to it.
89-
const showFile = async (path: string) => {
90+
// Stable, so the chat does not re-render when this view does.
91+
const showFile = useStable(async (path: string) => {
9092
if (!filesOpen) {
9193
seen();
9294
}
9395
setSide("files");
9496
await files.refresh();
9597
setShown(path);
96-
};
98+
});
9799
const fresh = filesOpen ? 0 : files.files.filter((file) => file.modifiedAt > seenAt).length;
98100
const routines = useApp(useShallow((state) => routinesOf(state, bot.id)));
99101
// A browser at rest is open, but the bot is not using it (spec 21.2).
@@ -108,25 +110,25 @@ export function BotView({ bot, crew }: { bot: Bot; crew: Crew }) {
108110
const screens = useScreens(bot);
109111
const screensOpen = side === "screens";
110112
const [screenPath, setScreenPath] = useState<string | null>(null);
111-
const showScreen = (path: string | null) => {
113+
const showScreen = useStable((path: string | null) => {
112114
if (filesOpen) {
113115
seen();
114116
}
115117
setSide("screens");
116118
setScreenPath(path);
117119
void screens.refresh();
118-
};
120+
});
119121
// biome-ignore lint/correctness/useExhaustiveDependencies: another bot's screen is not this bot's
120122
useEffect(() => setScreenPath(null), [bot.id]);
121-
const showBrowser = (options?: { take?: boolean }) => {
123+
const showBrowser = useStable((options?: { take?: boolean }) => {
122124
if (filesOpen) {
123125
seen();
124126
}
125127
setSide("browser");
126128
if (options?.take) {
127129
setTakeBrowser((count) => count + 1);
128130
}
129-
};
131+
});
130132
// The panel follows what the bot starts doing (spec 15.1), over the one
131133
// that came back too.
132134
useFollowBot({

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

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,15 @@
33
// also shows the reply being written.
44

55
import { CircleAlert } from "lucide-react";
6-
import type { ReactNode } from "react";
6+
import { memo, type ReactNode } from "react";
77
import { useT } from "../../i18n";
88
import { duration, tokens, usedTokens, when } from "../../lib/format";
99
import type { Bot, ChatItem, TurnItem } from "../../lib/protocol.gen";
1010
import { useArrival } from "../../ui/motion";
1111
import { BotAvatar, moodOf } from "../bots/BotAvatar";
1212
import { ApprovalCard } from "./ApprovalCard";
1313
import { Markdown } from "./Markdown";
14-
import { runParts } from "./rows";
14+
import { runParts, splitDraft } from "./rows";
1515
import { ToolLines } from "./ToolLines";
1616

1717
export interface Live {
@@ -70,7 +70,33 @@ function Working() {
7070
);
7171
}
7272

73-
export function BotRun({
73+
function Draft({ text }: { text: string }) {
74+
const [done, writing] = splitDraft(text);
75+
return (
76+
<div aria-live="off" className="flex animate-fade flex-col gap-[0.6em] opacity-90">
77+
{done && <Markdown text={done} />}
78+
<Markdown text={writing} streaming />
79+
</div>
80+
);
81+
}
82+
83+
const sameItems = (a: ChatItem[], b: ChatItem[]) =>
84+
a.length === b.length && a.every((item, index) => item === b[index]);
85+
86+
/**
87+
* Re-renders only when something it shows changed: finished runs stay put
88+
* while the last one streams.
89+
*/
90+
export const BotRun = memo(
91+
BotRunView,
92+
(before, after) =>
93+
before.bot === after.bot &&
94+
before.live?.draft === after.live?.draft &&
95+
before.live?.working === after.live?.working &&
96+
sameItems(before.items, after.items),
97+
);
98+
99+
function BotRunView({
74100
items,
75101
bot,
76102
live,
@@ -119,11 +145,7 @@ export function BotRun({
119145
return null;
120146
}
121147
})}
122-
{live?.draft && (
123-
<div aria-live="off" className="animate-fade opacity-90">
124-
<Markdown text={live.draft} streaming />
125-
</div>
126-
)}
148+
{live?.draft && <Draft text={live.draft} />}
127149
{live && !live.draft && live.working && <Working />}
128150
</div>
129151
</li>

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
type ClipboardEvent,
99
type FormEvent,
1010
type KeyboardEvent,
11+
memo,
1112
useEffect,
1213
useId,
1314
useLayoutEffect,
@@ -56,7 +57,7 @@ function Chip({ file, onRemove }: { file: PendingFile; onRemove(): void }) {
5657
);
5758
}
5859

59-
export function ChatComposer({
60+
export const ChatComposer = memo(function ChatComposer({
6061
bot,
6162
files,
6263
stopped,
@@ -241,4 +242,4 @@ export function ChatComposer({
241242
)}
242243
</form>
243244
);
244-
}
245+
});

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

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,16 @@
22
// written, and the composer. Files dropped anywhere on it are attached.
33

44
import { LoaderCircle, Paperclip } from "lucide-react";
5-
import { type DragEvent, type ReactNode, useLayoutEffect, useMemo, useRef, useState } from "react";
5+
import {
6+
type DragEvent,
7+
memo,
8+
type ReactNode,
9+
useCallback,
10+
useLayoutEffect,
11+
useMemo,
12+
useRef,
13+
useState,
14+
} from "react";
615
import { useT } from "../../i18n";
716
import { day } from "../../lib/format";
817
import type { Bot } from "../../lib/protocol.gen";
@@ -23,7 +32,8 @@ const STICKY_PX = 80;
2332

2433
const hasFiles = (event: DragEvent) => event.dataTransfer.types.includes("Files");
2534

26-
export function ChatView({ bot, stopped }: { bot: Bot; stopped: boolean }) {
35+
/** Skips the renders of the view around it; the text being written re-renders only the last run. */
36+
export const ChatView = memo(function ChatView({ bot, stopped }: { bot: Bot; stopped: boolean }) {
2737
const t = useT();
2838
const chat = useChat(bot.id);
2939
const files = useFiles();
@@ -55,6 +65,10 @@ export function ChatView({ bot, stopped }: { bot: Bot; stopped: boolean }) {
5565
}
5666
}, [end]);
5767

68+
const onSent = useCallback(() => {
69+
atEnd.current = true;
70+
}, []);
71+
5872
const loadOlder = () => {
5973
const element = scroller.current;
6074
if (element) {
@@ -65,7 +79,7 @@ export function ChatView({ bot, stopped }: { bot: Bot; stopped: boolean }) {
6579

6680
const working = bot.state === "busy";
6781
const live: Live | undefined = chat.draft || working ? { draft: chat.draft, working } : undefined;
68-
const rows = chatRows(chat.items);
82+
const rows = useMemo(() => chatRows(chat.items), [chat.items]);
6983
const tail = rows.at(-1);
7084
const openRun = tail?.kind === "run" && tail.items.at(-1)?.body.kind !== "turn";
7185

@@ -168,14 +182,7 @@ export function ChatView({ bot, stopped }: { bot: Bot; stopped: boolean }) {
168182
</div>
169183
</div>
170184
<div className="w-full">
171-
<ChatComposer
172-
bot={bot}
173-
files={files}
174-
stopped={stopped}
175-
onSent={() => {
176-
atEnd.current = true;
177-
}}
178-
/>
185+
<ChatComposer bot={bot} files={files} stopped={stopped} onSent={onSent} />
179186
</div>
180187
{dragging && (
181188
<div className="pointer-events-none absolute inset-3 grid place-items-center rounded-2xl border-2 border-accent border-dashed bg-canvas/85">
@@ -187,4 +194,4 @@ export function ChatView({ bot, stopped }: { bot: Bot; stopped: boolean }) {
187194
)}
188195
</section>
189196
);
190-
}
197+
});

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

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// another bot's or Botloft's on the left under its name (spec 15.3).
33

44
import { AlarmClock, ListTodo, Reply } from "lucide-react";
5+
import { memo } from "react";
56
import { useT } from "../../i18n";
67
import { when } from "../../lib/format";
78
import type { Bot, Message } from "../../lib/protocol.gen";
@@ -103,10 +104,16 @@ function OtherMessage({ message }: { message: Message }) {
103104
);
104105
}
105106

106-
export function InboundRow({ message, bot }: { message: Message; bot: Bot }) {
107+
export const InboundRow = memo(function InboundRow({
108+
message,
109+
bot,
110+
}: {
111+
message: Message;
112+
bot: Bot;
113+
}) {
107114
return message.fromKind === "owner" ? (
108115
<OwnerMessage message={message} bot={bot} />
109116
) : (
110117
<OtherMessage message={message} />
111118
);
112-
}
119+
});

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

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// sign-in problem, a session that started over (spec 8.2).
33

44
import { CircleAlert, Info, TriangleAlert } from "lucide-react";
5+
import { memo } from "react";
56
import { type Messages, useT } from "../../i18n";
67
import type { NoticeItem } from "../../lib/protocol.gen";
78
import { useArrival } from "../../ui/motion";
@@ -13,7 +14,13 @@ const LOOK = {
1314
} as const;
1415

1516
/** `at` is when the notice came, so a new one animates in. */
16-
export function NoticeRow({ notice, at = 0 }: { notice: NoticeItem; at?: number }) {
17+
export const NoticeRow = memo(function NoticeRow({
18+
notice,
19+
at = 0,
20+
}: {
21+
notice: NoticeItem;
22+
at?: number;
23+
}) {
1724
const t = useT();
1825
const { icon: Icon, tone, frame } = LOOK[notice.level];
1926
const arrival = useArrival(at);
@@ -28,7 +35,7 @@ export function NoticeRow({ notice, at = 0 }: { notice: NoticeItem; at?: number
2835
</p>
2936
</li>
3037
);
31-
}
38+
});
3239

3340
/** The app's own words for notices it knows; the daemon's text otherwise. */
3441
function noticeText(notice: NoticeItem, words: Messages["chat"]["notice"]): string {

‎app/src/features/chat/rows.test.ts‎

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { describe, expect, test } from "vitest";
22
import type { ChatBody, ChatItem } from "../../lib/protocol.gen";
3-
import { chatRows, runParts } from "./rows";
3+
import { chatRows, runParts, splitDraft } from "./rows";
44

55
const DAY = 24 * 60 * 60 * 1000;
66
const start = new Date(2026, 8, 28, 9, 0).getTime();
@@ -63,3 +63,26 @@ describe("chat rows", () => {
6363
expect(rows.map((row) => row.kind)).toEqual(["day", "run", "day", "run"]);
6464
});
6565
});
66+
67+
describe("splitDraft", () => {
68+
test("cuts after the last finished block", () => {
69+
expect(splitDraft("One.\n\nTwo is still")).toEqual(["One.\n\n", "Two is still"]);
70+
expect(splitDraft("# Title\n\nFirst.\n\nSecond")).toEqual(["# Title\n\nFirst.\n\n", "Second"]);
71+
});
72+
73+
test("keeps everything together until a block ends", () => {
74+
expect(splitDraft("Only one paragraph so far")).toEqual(["", "Only one paragraph so far"]);
75+
expect(splitDraft("One.\n\n")).toEqual(["", "One.\n\n"]);
76+
});
77+
78+
test("does not cut inside a code fence", () => {
79+
const open = "Look:\n\n```ts\nconst a = 1;\n\nconst b = 2;";
80+
expect(splitDraft(open)).toEqual(["Look:\n\n", "```ts\nconst a = 1;\n\nconst b = 2;"]);
81+
const closed = `${open}\n\`\`\`\n\nAfter.`;
82+
expect(splitDraft(closed)[1]).toBe("After.");
83+
});
84+
85+
test("does not cut before an indented line, which may belong to a list item", () => {
86+
expect(splitDraft("- item\n\n more of it")).toEqual(["", "- item\n\n more of it"]);
87+
});
88+
});

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

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,3 +61,34 @@ export function runParts(items: ChatItem[]): ChatItem[][] {
6161
}
6262
return parts;
6363
}
64+
65+
/**
66+
* Splits the reply being written where its last finished block ends: the
67+
* part before no longer changes, so only the rest is parsed again as text
68+
* arrives. A blank line inside a code fence does not end a block.
69+
*/
70+
export function splitDraft(text: string): [string, string] {
71+
let fence: string | null = null;
72+
let blank = false;
73+
let cut = 0;
74+
let offset = 0;
75+
for (const line of text.split("\n")) {
76+
if (fence === null && blank && /^\S/.test(line)) {
77+
cut = offset;
78+
}
79+
const marker = /^ {0,3}(`{3,}|~{3,})/.exec(line)?.[1];
80+
if (marker && fence === null) {
81+
fence = marker;
82+
} else if (
83+
marker &&
84+
fence !== null &&
85+
marker[0] === fence[0] &&
86+
marker.length >= fence.length
87+
) {
88+
fence = null;
89+
}
90+
blank = line.trim() === "";
91+
offset += line.length + 1;
92+
}
93+
return [text.slice(0, cut), text.slice(cut)];
94+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { act, cleanup, screen, within } from "@testing-library/react";
2+
import { afterEach, describe, expect, test, vi } from "vitest";
3+
import { FakeBotloft } from "../../lib/fake";
4+
import type { ChatItem } from "../../lib/protocol.gen";
5+
import { crewOpened, openBot, renderApp } from "../../test/app";
6+
7+
// Counts how often finished tool lines render.
8+
const renders = vi.hoisted(() => ({ tools: 0 }));
9+
vi.mock("./ToolLines", () => ({
10+
ToolLines: ({ items }: { items: ChatItem[] }) => {
11+
renders.tools += 1;
12+
return <p>{items.length} tool calls</p>;
13+
},
14+
}));
15+
16+
afterEach(cleanup);
17+
18+
describe("streaming", () => {
19+
test("text being written re-renders only the run it belongs to", async () => {
20+
const fake = new FakeBotloft();
21+
const ops = fake.addCrew("Ops");
22+
const scout = fake.addBot(ops.id, "Scout", "Finds sources");
23+
fake.setBotState(scout.id, "idle");
24+
renderApp(fake);
25+
await crewOpened("Ops");
26+
openBot("Scout");
27+
const chat = await screen.findByRole("list", { name: "Messages" });
28+
act(() => {
29+
fake.chat.tool(scout.id, "Bash", { summary: "npm test", input: "{}" });
30+
fake.chat.turn(scout.id);
31+
fake.setBotState(scout.id, "busy");
32+
});
33+
const before = renders.tools;
34+
35+
const pieces = ["The first ", "paragraph.\n\n", "The second ", "one."];
36+
for (const piece of pieces) {
37+
act(() => fake.chat.delta(scout.id, piece));
38+
}
39+
40+
expect(within(chat).getByText("The second one.")).toBeDefined();
41+
expect(within(chat).getByText("The first paragraph.")).toBeDefined();
42+
expect(renders.tools).toBe(before);
43+
});
44+
});

0 commit comments

Comments
 (0)