From 377785a2163640e6fd29f7d60579c7193e4f0c50 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Wed, 30 Sep 2026 20:04:46 -0300 Subject: [PATCH] perf(app): stream a reply without re-rendering the whole chat Each chat.delta re-rendered every run, tool line, card and the composer of the open chat, and parsed the whole draft as markdown again, so a long reply cost more with every piece. - BotRun, InboundRow, NoticeRow, ChatComposer and ChatView are memoized; BotRun compares its items one by one, which withItem keeps stable, so only the run being written re-renders. - chatRows runs only when the items change; useFiles and onSent keep their identity, and BotView's show callbacks are stable (useStable), so the chat skips the renders of the view around it (bot changes, screen drafts every 250 ms). - The draft is split after its last finished block (not inside a code fence): that part is parsed once, only the rest again as text arrives. - day() and decimal() reuse their Intl formatters per language. --- app/src/features/bots/BotView.tsx | 14 ++++---- app/src/features/chat/BotRun.tsx | 38 +++++++++++++++++----- app/src/features/chat/ChatComposer.tsx | 5 +-- app/src/features/chat/ChatView.tsx | 31 +++++++++++------- app/src/features/chat/InboundRow.tsx | 11 +++++-- app/src/features/chat/NoticeRow.tsx | 11 +++++-- app/src/features/chat/rows.test.ts | 25 ++++++++++++++- app/src/features/chat/rows.ts | 31 ++++++++++++++++++ app/src/features/chat/stream.test.tsx | 44 ++++++++++++++++++++++++++ app/src/features/chat/useFiles.ts | 8 +++-- app/src/lib/format.ts | 33 ++++++++++++------- app/src/ui/useStable.ts | 13 ++++++++ 12 files changed, 218 insertions(+), 46 deletions(-) create mode 100644 app/src/features/chat/stream.test.tsx create mode 100644 app/src/ui/useStable.ts diff --git a/app/src/features/bots/BotView.tsx b/app/src/features/bots/BotView.tsx index 25e93a3f..f5d916e0 100644 --- a/app/src/features/bots/BotView.tsx +++ b/app/src/features/bots/BotView.tsx @@ -10,6 +10,7 @@ import { Callout } from "../../ui/Callout"; import { PanelClosing, PanelRestored } from "../../ui/panelMotion"; import { SidePanel } from "../../ui/SidePanel"; import { type Tab, Tabs, tabId } from "../../ui/Tabs"; +import { useStable } from "../../ui/useStable"; import { BrowserPanel } from "../browser/BrowserPanel"; import { ShowBrowser } from "../browser/showBrowser"; import { ChatView } from "../chat/ChatView"; @@ -86,14 +87,15 @@ export function BotView({ bot, crew }: { bot: Bot; crew: Crew }) { }; // A tool line in the chat points to its file: read the list first, so the // file is in it when the panel goes to it. - const showFile = async (path: string) => { + // Stable, so the chat does not re-render when this view does. + const showFile = useStable(async (path: string) => { if (!filesOpen) { seen(); } setSide("files"); await files.refresh(); setShown(path); - }; + }); const fresh = filesOpen ? 0 : files.files.filter((file) => file.modifiedAt > seenAt).length; const routines = useApp(useShallow((state) => routinesOf(state, bot.id))); // A browser at rest is open, but the bot is not using it (spec 21.2). @@ -108,17 +110,17 @@ export function BotView({ bot, crew }: { bot: Bot; crew: Crew }) { const screens = useScreens(bot); const screensOpen = side === "screens"; const [screenPath, setScreenPath] = useState(null); - const showScreen = (path: string | null) => { + const showScreen = useStable((path: string | null) => { if (filesOpen) { seen(); } setSide("screens"); setScreenPath(path); void screens.refresh(); - }; + }); // biome-ignore lint/correctness/useExhaustiveDependencies: another bot's screen is not this bot's useEffect(() => setScreenPath(null), [bot.id]); - const showBrowser = (options?: { take?: boolean }) => { + const showBrowser = useStable((options?: { take?: boolean }) => { if (filesOpen) { seen(); } @@ -126,7 +128,7 @@ export function BotView({ bot, crew }: { bot: Bot; crew: Crew }) { if (options?.take) { setTakeBrowser((count) => count + 1); } - }; + }); // The panel follows what the bot starts doing (spec 15.1), over the one // that came back too. useFollowBot({ diff --git a/app/src/features/chat/BotRun.tsx b/app/src/features/chat/BotRun.tsx index 44718794..cdb9d1d6 100644 --- a/app/src/features/chat/BotRun.tsx +++ b/app/src/features/chat/BotRun.tsx @@ -3,7 +3,7 @@ // also shows the reply being written. import { CircleAlert } from "lucide-react"; -import type { ReactNode } from "react"; +import { memo, type ReactNode } from "react"; import { useT } from "../../i18n"; import { duration, tokens, usedTokens, when } from "../../lib/format"; import type { Bot, ChatItem, TurnItem } from "../../lib/protocol.gen"; @@ -11,7 +11,7 @@ import { useArrival } from "../../ui/motion"; import { BotAvatar, moodOf } from "../bots/BotAvatar"; import { ApprovalCard } from "./ApprovalCard"; import { Markdown } from "./Markdown"; -import { runParts } from "./rows"; +import { runParts, splitDraft } from "./rows"; import { ToolLines } from "./ToolLines"; export interface Live { @@ -70,7 +70,33 @@ function Working() { ); } -export function BotRun({ +function Draft({ text }: { text: string }) { + const [done, writing] = splitDraft(text); + return ( +
+ {done && } + +
+ ); +} + +const sameItems = (a: ChatItem[], b: ChatItem[]) => + a.length === b.length && a.every((item, index) => item === b[index]); + +/** + * Re-renders only when something it shows changed: finished runs stay put + * while the last one streams. + */ +export const BotRun = memo( + BotRunView, + (before, after) => + before.bot === after.bot && + before.live?.draft === after.live?.draft && + before.live?.working === after.live?.working && + sameItems(before.items, after.items), +); + +function BotRunView({ items, bot, live, @@ -119,11 +145,7 @@ export function BotRun({ return null; } })} - {live?.draft && ( -
- -
- )} + {live?.draft && } {live && !live.draft && live.working && } diff --git a/app/src/features/chat/ChatComposer.tsx b/app/src/features/chat/ChatComposer.tsx index a7b6b107..ffa25e40 100644 --- a/app/src/features/chat/ChatComposer.tsx +++ b/app/src/features/chat/ChatComposer.tsx @@ -8,6 +8,7 @@ import { type ClipboardEvent, type FormEvent, type KeyboardEvent, + memo, useEffect, useId, useLayoutEffect, @@ -56,7 +57,7 @@ function Chip({ file, onRemove }: { file: PendingFile; onRemove(): void }) { ); } -export function ChatComposer({ +export const ChatComposer = memo(function ChatComposer({ bot, files, stopped, @@ -241,4 +242,4 @@ export function ChatComposer({ )} ); -} +}); diff --git a/app/src/features/chat/ChatView.tsx b/app/src/features/chat/ChatView.tsx index 6920731c..0f7399df 100644 --- a/app/src/features/chat/ChatView.tsx +++ b/app/src/features/chat/ChatView.tsx @@ -2,7 +2,16 @@ // written, and the composer. Files dropped anywhere on it are attached. import { LoaderCircle, Paperclip } from "lucide-react"; -import { type DragEvent, type ReactNode, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { + type DragEvent, + memo, + type ReactNode, + useCallback, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; import { useT } from "../../i18n"; import { day } from "../../lib/format"; import type { Bot } from "../../lib/protocol.gen"; @@ -23,7 +32,8 @@ const STICKY_PX = 80; const hasFiles = (event: DragEvent) => event.dataTransfer.types.includes("Files"); -export function ChatView({ bot, stopped }: { bot: Bot; stopped: boolean }) { +/** Skips the renders of the view around it; the text being written re-renders only the last run. */ +export const ChatView = memo(function ChatView({ bot, stopped }: { bot: Bot; stopped: boolean }) { const t = useT(); const chat = useChat(bot.id); const files = useFiles(); @@ -55,6 +65,10 @@ export function ChatView({ bot, stopped }: { bot: Bot; stopped: boolean }) { } }, [end]); + const onSent = useCallback(() => { + atEnd.current = true; + }, []); + const loadOlder = () => { const element = scroller.current; if (element) { @@ -65,7 +79,7 @@ export function ChatView({ bot, stopped }: { bot: Bot; stopped: boolean }) { const working = bot.state === "busy"; const live: Live | undefined = chat.draft || working ? { draft: chat.draft, working } : undefined; - const rows = chatRows(chat.items); + const rows = useMemo(() => chatRows(chat.items), [chat.items]); const tail = rows.at(-1); const openRun = tail?.kind === "run" && tail.items.at(-1)?.body.kind !== "turn"; @@ -168,14 +182,7 @@ export function ChatView({ bot, stopped }: { bot: Bot; stopped: boolean }) {
- { - atEnd.current = true; - }} - /> +
{dragging && (
@@ -187,4 +194,4 @@ export function ChatView({ bot, stopped }: { bot: Bot; stopped: boolean }) { )} ); -} +}); diff --git a/app/src/features/chat/InboundRow.tsx b/app/src/features/chat/InboundRow.tsx index 20bceda3..8390e8f3 100644 --- a/app/src/features/chat/InboundRow.tsx +++ b/app/src/features/chat/InboundRow.tsx @@ -2,6 +2,7 @@ // another bot's or Botloft's on the left under its name (spec 15.3). import { AlarmClock, ListTodo, Reply } from "lucide-react"; +import { memo } from "react"; import { useT } from "../../i18n"; import { when } from "../../lib/format"; import type { Bot, Message } from "../../lib/protocol.gen"; @@ -103,10 +104,16 @@ function OtherMessage({ message }: { message: Message }) { ); } -export function InboundRow({ message, bot }: { message: Message; bot: Bot }) { +export const InboundRow = memo(function InboundRow({ + message, + bot, +}: { + message: Message; + bot: Bot; +}) { return message.fromKind === "owner" ? ( ) : ( ); -} +}); diff --git a/app/src/features/chat/NoticeRow.tsx b/app/src/features/chat/NoticeRow.tsx index 65cfc673..7f8ecddf 100644 --- a/app/src/features/chat/NoticeRow.tsx +++ b/app/src/features/chat/NoticeRow.tsx @@ -2,6 +2,7 @@ // sign-in problem, a session that started over (spec 8.2). import { CircleAlert, Info, TriangleAlert } from "lucide-react"; +import { memo } from "react"; import { type Messages, useT } from "../../i18n"; import type { NoticeItem } from "../../lib/protocol.gen"; import { useArrival } from "../../ui/motion"; @@ -13,7 +14,13 @@ const LOOK = { } as const; /** `at` is when the notice came, so a new one animates in. */ -export function NoticeRow({ notice, at = 0 }: { notice: NoticeItem; at?: number }) { +export const NoticeRow = memo(function NoticeRow({ + notice, + at = 0, +}: { + notice: NoticeItem; + at?: number; +}) { const t = useT(); const { icon: Icon, tone, frame } = LOOK[notice.level]; const arrival = useArrival(at); @@ -28,7 +35,7 @@ export function NoticeRow({ notice, at = 0 }: { notice: NoticeItem; at?: number

); -} +}); /** The app's own words for notices it knows; the daemon's text otherwise. */ function noticeText(notice: NoticeItem, words: Messages["chat"]["notice"]): string { diff --git a/app/src/features/chat/rows.test.ts b/app/src/features/chat/rows.test.ts index 33adf308..bf007d81 100644 --- a/app/src/features/chat/rows.test.ts +++ b/app/src/features/chat/rows.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "vitest"; import type { ChatBody, ChatItem } from "../../lib/protocol.gen"; -import { chatRows, runParts } from "./rows"; +import { chatRows, runParts, splitDraft } from "./rows"; const DAY = 24 * 60 * 60 * 1000; const start = new Date(2026, 8, 28, 9, 0).getTime(); @@ -63,3 +63,26 @@ describe("chat rows", () => { expect(rows.map((row) => row.kind)).toEqual(["day", "run", "day", "run"]); }); }); + +describe("splitDraft", () => { + test("cuts after the last finished block", () => { + expect(splitDraft("One.\n\nTwo is still")).toEqual(["One.\n\n", "Two is still"]); + expect(splitDraft("# Title\n\nFirst.\n\nSecond")).toEqual(["# Title\n\nFirst.\n\n", "Second"]); + }); + + test("keeps everything together until a block ends", () => { + expect(splitDraft("Only one paragraph so far")).toEqual(["", "Only one paragraph so far"]); + expect(splitDraft("One.\n\n")).toEqual(["", "One.\n\n"]); + }); + + test("does not cut inside a code fence", () => { + const open = "Look:\n\n```ts\nconst a = 1;\n\nconst b = 2;"; + expect(splitDraft(open)).toEqual(["Look:\n\n", "```ts\nconst a = 1;\n\nconst b = 2;"]); + const closed = `${open}\n\`\`\`\n\nAfter.`; + expect(splitDraft(closed)[1]).toBe("After."); + }); + + test("does not cut before an indented line, which may belong to a list item", () => { + expect(splitDraft("- item\n\n more of it")).toEqual(["", "- item\n\n more of it"]); + }); +}); diff --git a/app/src/features/chat/rows.ts b/app/src/features/chat/rows.ts index e8562021..91b2bbc8 100644 --- a/app/src/features/chat/rows.ts +++ b/app/src/features/chat/rows.ts @@ -61,3 +61,34 @@ export function runParts(items: ChatItem[]): ChatItem[][] { } return parts; } + +/** + * Splits the reply being written where its last finished block ends: the + * part before no longer changes, so only the rest is parsed again as text + * arrives. A blank line inside a code fence does not end a block. + */ +export function splitDraft(text: string): [string, string] { + let fence: string | null = null; + let blank = false; + let cut = 0; + let offset = 0; + for (const line of text.split("\n")) { + if (fence === null && blank && /^\S/.test(line)) { + cut = offset; + } + const marker = /^ {0,3}(`{3,}|~{3,})/.exec(line)?.[1]; + if (marker && fence === null) { + fence = marker; + } else if ( + marker && + fence !== null && + marker[0] === fence[0] && + marker.length >= fence.length + ) { + fence = null; + } + blank = line.trim() === ""; + offset += line.length + 1; + } + return [text.slice(0, cut), text.slice(cut)]; +} diff --git a/app/src/features/chat/stream.test.tsx b/app/src/features/chat/stream.test.tsx new file mode 100644 index 00000000..9d1b5054 --- /dev/null +++ b/app/src/features/chat/stream.test.tsx @@ -0,0 +1,44 @@ +import { act, cleanup, screen, within } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { FakeBotloft } from "../../lib/fake"; +import type { ChatItem } from "../../lib/protocol.gen"; +import { crewOpened, openBot, renderApp } from "../../test/app"; + +// Counts how often finished tool lines render. +const renders = vi.hoisted(() => ({ tools: 0 })); +vi.mock("./ToolLines", () => ({ + ToolLines: ({ items }: { items: ChatItem[] }) => { + renders.tools += 1; + return

{items.length} tool calls

; + }, +})); + +afterEach(cleanup); + +describe("streaming", () => { + test("text being written re-renders only the run it belongs to", async () => { + const fake = new FakeBotloft(); + const ops = fake.addCrew("Ops"); + const scout = fake.addBot(ops.id, "Scout", "Finds sources"); + fake.setBotState(scout.id, "idle"); + renderApp(fake); + await crewOpened("Ops"); + openBot("Scout"); + const chat = await screen.findByRole("list", { name: "Messages" }); + act(() => { + fake.chat.tool(scout.id, "Bash", { summary: "npm test", input: "{}" }); + fake.chat.turn(scout.id); + fake.setBotState(scout.id, "busy"); + }); + const before = renders.tools; + + const pieces = ["The first ", "paragraph.\n\n", "The second ", "one."]; + for (const piece of pieces) { + act(() => fake.chat.delta(scout.id, piece)); + } + + expect(within(chat).getByText("The second one.")).toBeDefined(); + expect(within(chat).getByText("The first paragraph.")).toBeDefined(); + expect(renders.tools).toBe(before); + }); +}); diff --git a/app/src/features/chat/useFiles.ts b/app/src/features/chat/useFiles.ts index eeabd8ce..7ff4e8bd 100644 --- a/app/src/features/chat/useFiles.ts +++ b/app/src/features/chat/useFiles.ts @@ -1,7 +1,7 @@ // Files the owner is about to send (spec 9.5), read as base64 when they are // picked, pasted or dropped. Images keep a data URL for their thumbnail. -import { useCallback, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import { t } from "../../i18n"; import { fileSize } from "../../lib/format"; import { FIELD_LIMITS } from "../../lib/protocol.gen"; @@ -85,7 +85,11 @@ export function useFiles() { setError(null); }, []); - return { files, error, setError, add, remove, clear }; + // One object while nothing changes, so the composer can skip renders. + return useMemo( + () => ({ files, error, setError, add, remove, clear }), + [files, error, add, remove, clear], + ); } export type Files = ReturnType; diff --git a/app/src/lib/format.ts b/app/src/lib/format.ts index 1896265a..28327ea8 100644 --- a/app/src/lib/format.ts +++ b/app/src/lib/format.ts @@ -8,6 +8,10 @@ interface Formats { clock: Intl.DateTimeFormat; dated: Intl.DateTimeFormat; relative: Intl.RelativeTimeFormat; + day: Intl.DateTimeFormat; + dayOfYear: Intl.DateTimeFormat; + /** By number of decimals. */ + decimals: [Intl.NumberFormat, Intl.NumberFormat]; } const cache = new Map(); @@ -26,6 +30,20 @@ function formats(): Formats { minute: "2-digit", }), relative: new Intl.RelativeTimeFormat(locale, { numeric: "auto", style: "short" }), + day: new Intl.DateTimeFormat(locale, { weekday: "long", month: "long", day: "numeric" }), + dayOfYear: new Intl.DateTimeFormat(locale, { + weekday: "long", + month: "long", + day: "numeric", + year: "numeric", + }), + decimals: [0, 1].map( + (digits) => + new Intl.NumberFormat(locale, { + minimumFractionDigits: digits, + maximumFractionDigits: digits, + }), + ) as Formats["decimals"], }; cache.set(locale, found); } @@ -57,11 +75,8 @@ export function fromNow(ms: number, now = Date.now()): string { } /** `value` with `digits` decimals, written the language's way ("3.4", "3,4"). */ -function decimal(value: number, digits: number): string { - return new Intl.NumberFormat(currentLocale(), { - minimumFractionDigits: digits, - maximumFractionDigits: digits, - }).format(value); +function decimal(value: number, digits: 0 | 1): string { + return formats().decimals[digits].format(value); } /** "912 B", "48 KB", "3.4 MB". */ @@ -119,10 +134,6 @@ export function duration(ms: number): string { /** "Monday, September 28" for today's year, with the year otherwise. */ export function day(ms: number, now = Date.now()): string { const sameYear = new Date(ms).getFullYear() === new Date(now).getFullYear(); - return new Intl.DateTimeFormat(currentLocale(), { - weekday: "long", - month: "long", - day: "numeric", - year: sameYear ? undefined : "numeric", - }).format(ms); + const { day, dayOfYear } = formats(); + return (sameYear ? day : dayOfYear).format(ms); } diff --git a/app/src/ui/useStable.ts b/app/src/ui/useStable.ts new file mode 100644 index 00000000..319ec532 --- /dev/null +++ b/app/src/ui/useStable.ts @@ -0,0 +1,13 @@ +// A callback that keeps its identity across renders but always runs the +// latest `fn`, so memoized children and context consumers do not re-render +// when the parent does. Not for use during render. + +import { useCallback, useLayoutEffect, useRef } from "react"; + +export function useStable(fn: (...args: A) => R): (...args: A) => R { + const latest = useRef(fn); + useLayoutEffect(() => { + latest.current = fn; + }); + return useCallback((...args: A) => latest.current(...args), []); +}