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), []); +}