From 4879129aec2844fd451b4dd89fae7c62571ac9c2 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 29 Oct 2026 09:17:00 -0300 Subject: [PATCH 01/50] Show a channel's history once it is all in, already at the bottom --- .../components/channels/chat-transcript.tsx | 23 +++++- app/tests/transcript-open.test.tsx | 81 +++++++++++++++++++ 2 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 app/tests/transcript-open.test.tsx diff --git a/app/src/components/channels/chat-transcript.tsx b/app/src/components/channels/chat-transcript.tsx index 95a45711d8..7336ec7d5b 100644 --- a/app/src/components/channels/chat-transcript.tsx +++ b/app/src/components/channels/chat-transcript.tsx @@ -1678,6 +1678,28 @@ export function ChatTranscript({ } }, [hasItems, delays]); + /* + * NOTHING BUT THE PLACEHOLDER UNTIL HISTORY IS COMPLETE, AND THE SCROLLER MOUNTED FRESH AFTER IT. + * + * History arrives in pieces: the gateway snapshot, then the stored thread merged into it, then + * the voice archive. Drawing each piece as it came showed a conversation that loaded and then + * moved. And the scroller decides where to land ONCE, in a layout effect, the first time it sees + * any child (`defaultScrollPosition`, applied by `ie()` in `@shadcn/react`'s message-scroller). + * Mounted early, it spent that on the always-present `contents` block below, and the restored + * rows then took the "rows appended" path, which pins a lone person's message to the top instead + * of going to the end. Mounted here, with every row in the same commit, its one landing is the + * bottom of the whole conversation, before the browser paints anything. + */ + if (restoring) { + return ( +
+
+ +
+
+ ); + } + return ( @@ -1755,7 +1777,6 @@ export function ChatTranscript({ /> ))} - {items.length === 0 && restoring ? : null} {items.map((item, index) => item.kind === "tools" ? ( diff --git a/app/tests/transcript-open.test.tsx b/app/tests/transcript-open.test.tsx new file mode 100644 index 0000000000..86dca6e62e --- /dev/null +++ b/app/tests/transcript-open.test.tsx @@ -0,0 +1,81 @@ +import { afterAll, afterEach, beforeAll, expect, test } from "bun:test"; +import type { Message } from "@ag-ui/core"; +import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { cleanup, render } from "@testing-library/react"; +import { ChatTranscript } from "@/components/channels/chat-transcript"; +import { settleReactWork } from "./settle-react-work"; + +/** + * What a person sees when a conversation opens. + * + * History arrives in pieces (the gateway snapshot, then the stored thread merged into it), and the + * scroller decides where to land exactly once, the first time it sees a row. So the transcript + * draws nothing but the placeholder until history is complete, and then mounts the scroller with + * every row at once — the only way its single landing is at the bottom of the whole conversation. + * + * Harness as in `transcript-attachments.test.tsx`: one document for the file, cleaned per test. + */ + +beforeAll(() => { + GlobalRegistrator.register({ url: "http://localhost/" }); +}); + +afterEach(() => { + cleanup(); +}); + +afterAll(async () => { + await settleReactWork(); + GlobalRegistrator.unregister(); +}); + +function user(id: string, text: string): Message { + return { id, role: "user", content: text }; +} + +function assistant(id: string, text: string): Message { + return { id, role: "assistant", content: text }; +} + +const PLACEHOLDER = "Loading this conversation"; + +test("restoring hides rows the snapshot already delivered", () => { + const view = render( + , + ); + + expect(view.queryByText("hello")).toBeNull(); + expect(view.container.querySelectorAll("[data-message-id]").length).toBe(0); + expect(view.getByLabelText(PLACEHOLDER)).toBeTruthy(); +}); + +test("rows appear together when restoring ends", () => { + const messages = [user("u1", "hi"), assistant("a1", "hello")]; + const view = render(); + + view.rerender(); + + expect(view.getByText("hi")).toBeTruthy(); + expect(view.getByText("hello")).toBeTruthy(); + expect(view.container.querySelectorAll("[data-message-id]").length).toBe(2); + expect(view.queryByLabelText(PLACEHOLDER)).toBeNull(); +}); + +test("a seeded channel shows its first message at once", () => { + const view = render( + , + ); + + expect(view.getByText("first")).toBeTruthy(); + expect(view.queryByLabelText(PLACEHOLDER)).toBeNull(); +}); + +test("an empty conversation shows neither placeholder nor rows once restored", () => { + const view = render(); + + expect(view.queryByLabelText(PLACEHOLDER)).toBeNull(); + expect(view.container.querySelectorAll("[data-message-id]").length).toBe(0); +}); From 77a574697d51d3e591a4029bd60e124532401915 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 29 Oct 2026 09:18:12 -0300 Subject: [PATCH 02/50] Wait for a channel's voice chats before showing its history --- app/src/components/channels/channel-chat.tsx | 11 ++- app/src/lib/voice/archive.ts | 6 ++ app/tests/voice-archive-pending.test.tsx | 74 ++++++++++++++++++++ 3 files changed, 89 insertions(+), 2 deletions(-) create mode 100644 app/tests/voice-archive-pending.test.tsx diff --git a/app/src/components/channels/channel-chat.tsx b/app/src/components/channels/channel-chat.tsx index 14a91aa4dc..7fe81df969 100644 --- a/app/src/components/channels/channel-chat.tsx +++ b/app/src/components/channels/channel-chat.tsx @@ -539,8 +539,15 @@ export function ChannelChat({ useActiveBot(runtimeAgentId); const skillCommands = useSkillCommands(runtimeAgentId); + /* + * History is the snapshot, the stored thread AND the voice archive. The transcript shows none of + * it until all three are in, so it lands once at the bottom instead of drawing the conversation + * and then splicing call cards into it; the notice waits for the same moment, so it appears in + * the commit that shows the history rather than a render later, shrinking the view under it. + */ + const historyLoading = restoring || voiceArchive.pending; const historyNotice = channelHistoryNotice({ - restoring, + restoring: historyLoading, messageCount: agent.messages.length, lastMessageAt: channel.lastMessageAt, historyAvailability, @@ -1064,7 +1071,7 @@ export function ChannelChat({ * above. */ queueWhileBusy - restoring={restoring} + restoring={historyLoading} /* * The run, not the turn. Stop reaches a run through the core's abort controller, and that * controller does not exist until `say` has finished waiting for the runtime agent — so diff --git a/app/src/lib/voice/archive.ts b/app/src/lib/voice/archive.ts index 6739ca46b4..8718c6dfe6 100644 --- a/app/src/lib/voice/archive.ts +++ b/app/src/lib/voice/archive.ts @@ -165,6 +165,12 @@ export function useVoiceArchive(channelId: string) { a.startedAt.localeCompare(b.startedAt) || a.id.localeCompare(b.id), ), error: saved.error, + /** + * The saved calls have not come back yet. A channel waits on this before showing its history, + * so a call card is never spliced into a conversation already on screen. False after a failed + * load too: the conversation is readable without its calls, and `error` says what went wrong. + */ + pending: saved.isPending, }; } diff --git a/app/tests/voice-archive-pending.test.tsx b/app/tests/voice-archive-pending.test.tsx new file mode 100644 index 0000000000..13bcc2bd8d --- /dev/null +++ b/app/tests/voice-archive-pending.test.tsx @@ -0,0 +1,74 @@ +import { + afterAll, + afterEach, + beforeAll, + expect, + mock, + spyOn, + test, +} from "bun:test"; +import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { cleanup, renderHook, waitFor } from "@testing-library/react"; +import { useVoiceArchive } from "../src/lib/voice/archive"; +import { queryClient } from "../src/query-client"; +import { settleReactWork } from "./settle-react-work"; + +/** + * A channel's voice chats are part of its history: the transcript is not shown until they are in, + * so it never draws the conversation and then splices a call card into it. `pending` is what the + * channel waits on — and it must end on a failed load too, or the placeholder would stay up for + * good over a conversation that is perfectly readable without its calls. + */ + +beforeAll(() => { + GlobalRegistrator.register({ url: "http://localhost/" }); +}); + +afterEach(() => { + cleanup(); + mock.restore(); + queryClient.clear(); +}); + +afterAll(async () => { + await settleReactWork(); + GlobalRegistrator.unregister(); +}); + +function answerFetch(answer: () => Promise) { + spyOn(globalThis, "fetch").mockImplementation( + Object.assign(answer, { preconnect: globalThis.fetch.preconnect }), + ); +} + +test("pending while saved voice chats load", () => { + answerFetch(() => new Promise(() => {})); + + const { result } = renderHook(() => useVoiceArchive("channel-1")); + + expect(result.current.pending).toBe(true); +}); + +test("not pending once loaded", async () => { + answerFetch(async () => + Response.json({ sessions: [], nextCursor: null }, { status: 200 }), + ); + + const { result } = renderHook(() => useVoiceArchive("channel-1")); + + await waitFor(() => expect(result.current.pending).toBe(false)); + expect(result.current.error).toBeNull(); +}); + +test("not pending after a failed load", async () => { + answerFetch(async () => + Response.json({ error: "Down for maintenance." }, { status: 500 }), + ); + + const { result } = renderHook(() => useVoiceArchive("channel-1")); + + await waitFor(() => expect(result.current.pending).toBe(false), { + timeout: 5000, + }); + expect(result.current.error).not.toBeNull(); +}); From 542bc188f893705fb2eaff709ed9d63602048601 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 29 Oct 2026 09:19:47 -0300 Subject: [PATCH 03/50] Open a conversation without a cascade; animate only what arrives --- .../components/channels/chat-transcript.tsx | 417 ++++++++---------- app/tests/transcript-attachments.test.tsx | 27 +- app/tests/transcript-open.test.tsx | 64 +++ 3 files changed, 245 insertions(+), 263 deletions(-) diff --git a/app/src/components/channels/chat-transcript.tsx b/app/src/components/channels/chat-transcript.tsx index 7336ec7d5b..479ffd17a6 100644 --- a/app/src/components/channels/chat-transcript.tsx +++ b/app/src/components/channels/chat-transcript.tsx @@ -14,7 +14,9 @@ import { } from "@tabler/icons-react"; import { motion, useReducedMotion } from "motion/react"; import { + createContext, memo, + useContext, useEffect, useLayoutEffect, useMemo, @@ -424,51 +426,27 @@ function ScrollNewestQueuedIntoView({ newest }: { newest: string | null }) { } /** - * How many of the newest turns cascade when a channel is opened, and how far apart. + * Whether a row mounting now is arriving live or was already there when the conversation opened. * - * The tail rather than the head: opening a channel lands you at the bottom, so these are the ones - * actually on screen. Staggering from the top of a long history would spend the whole budget on - * messages nobody can see and leave the visible ones arriving last. - * - * Twelve at 40ms is 440ms of cascade before the last one starts. Past roughly half a second this - * stops reading as settling and starts reading as waiting. + * A ref, not state: it is read once, at a row's first render, and flipping it must not re-render + * anything. `ArrivalsLive` provides a fresh one with the scroller, so it starts false for the commit + * that mounts the whole history and turns true in the effect after it; every row mounted from then + * on is something that arrived while the person was here. */ -const FIRST_PAINT_STAGGER_COUNT = 12; -const FIRST_PAINT_STAGGER_SECONDS = 0.04; +const ArrivalsLiveContext = createContext | null>( + null, +); -/** - * Decide, once per message, whether it waits its turn. - * - * FROZEN PER ID ON PURPOSE. `delay` is a prop on a memoised component, so a value that changed - * between renders would break the memo that makes the streaming path cheap — and worse, a message - * whose delay changed could replay its entrance mid-stream. Each id is decided the first time it is - * seen and never revisited. - * - * `settled` flips after the first render that has any items, which is NOT the first render: history - * is restored asynchronously, so a channel's transcript is empty for a beat. Anything appearing - * after that is a live turn and is given no delay at all. - */ -function createFirstPaintDelays() { - const decided = new Map(); - let settled = false; - - return { - settle() { - settled = true; - }, - delayFor(id: string, index: number, total: number): number { - const known = decided.get(id); - if (known !== undefined) { - return known; - } - const offset = total - FIRST_PAINT_STAGGER_COUNT; - const place = index - Math.max(0, offset); - const delay = - settled || place < 0 ? 0 : place * FIRST_PAINT_STAGGER_SECONDS; - decided.set(id, delay); - return delay; - }, - }; +function ArrivalsLive({ children }: { children: React.ReactNode }) { + const live = useRef(false); + useEffect(() => { + live.current = true; + }, []); + return ( + + {children} + + ); } /** @@ -490,19 +468,16 @@ function createFirstPaintDelays() { * accelerated and drops frames exactly when the main thread is busy, which here is while a reply is * streaming. * - * STAGGERED ONLY ON THE FIRST PAINT OF A CHANNEL. A turn sent mid-conversation arrives alone and - * must not wait behind anything, so `delay` is zero for it. Opening a channel is the one moment the - * whole history mounts at once, and cascading the last few reads as the conversation settling - * rather than as a page appearing all at once. `createFirstPaintDelays` decides which is which. + * ONLY FOR WHAT ARRIVES WHILE THE CONVERSATION IS OPEN. History is already there when it opens: + * it was cascading the newest dozen rows in, 40ms apart, and the bottom of the conversation filling + * in over half a second read as the conversation still loading. A row mounted with the history + * draws at rest; `ArrivalsLiveContext` says which is which, decided once at the row's first render + * so a streaming answer can never replay it. */ -function Arriving({ - children, - delay = 0, -}: { - children: React.ReactNode; - delay?: number; -}) { +function Arriving({ children }: { children: React.ReactNode }) { const shouldReduceMotion = useReducedMotion(); + const liveRef = useContext(ArrivalsLiveContext); + const [live] = useState(() => liveRef?.current ?? true); return ( - + @@ -669,7 +643,7 @@ const TranscriptMessage = memo(function TranscriptMessage({ return ( - + {/* A Bot's message takes the whole column, not the width of its words: block content inside it — a fenced code block, a table — should span the transcript rather than @@ -751,7 +725,6 @@ const ATTACHMENT_URL_PREFIX = attachmentUrl(""); */ type AttachmentRowProps = { attachments: readonly SentAttachment[]; - delay: number; }; /** @@ -777,9 +750,6 @@ export function sameAttachmentRow( previous: AttachmentRowProps, next: AttachmentRowProps, ): boolean { - if (previous.delay !== next.delay) { - return false; - } if (previous.attachments.length !== next.attachments.length) { return false; } @@ -799,12 +769,11 @@ export function sameAttachmentRow( export const TranscriptAttachments = memo(function TranscriptAttachments({ attachments, - delay, }: AttachmentRowProps) { return ( - + @@ -1268,10 +1237,8 @@ function activityName(activityType: string): string { * from re-rendering a mounted iframe. */ const TranscriptActivity = memo(function TranscriptActivity({ - delay, message, }: { - delay: number; message: ActivityMessage; }) { const { renderActivityMessage } = useRenderActivityMessage(); @@ -1284,7 +1251,7 @@ const TranscriptActivity = memo(function TranscriptActivity({ if (!drawn) return null; return ( - + {drawn} @@ -1303,14 +1270,12 @@ const TranscriptActivity = memo(function TranscriptActivity({ * parent cannot guarantee is the classic way to make a memo boundary useless. */ const TranscriptToolCall = memo(function TranscriptToolCall({ - delay, toolCallId, name, args, result, latest, }: { - delay: number; toolCallId: string; name: string; args: string; @@ -1343,7 +1308,7 @@ const TranscriptToolCall = memo(function TranscriptToolCall({ }); return ( - + {/* * A TOOL WITH NO REGISTERED RENDERER STILL HAPPENED, and since tools moved to the server @@ -1654,30 +1619,6 @@ export function ChatTranscript({ /* One rule for both kinds of row — see `anchorRowIds`, which is where it is written down. */ const anchorRows = anchorRowIds(items); - /* - * One decider per mounted transcript, so opening a different channel starts the cascade over and - * a message never inherits a delay from a conversation it was not in. - */ - const delaysRef = useRef | null>( - null, - ); - if (delaysRef.current === null) { - delaysRef.current = createFirstPaintDelays(); - } - const delays = delaysRef.current; - - /* - * Settled AFTER the render that first had items, not on mount: history arrives asynchronously, so - * on mount there is nothing to stagger yet and marking it settled then would mean the history - * cascade never happens. - */ - const hasItems = items.length > 0; - useEffect(() => { - if (hasItems) { - delays.settle(); - } - }, [hasItems, delays]); - /* * NOTHING BUT THE PLACEHOLDER UNTIL HISTORY IS COMPLETE, AND THE SCROLLER MOUNTED FRESH AFTER IT. * @@ -1701,148 +1642,140 @@ export function ChatTranscript({ } return ( - - - - - {/* - * DRAWN LAST, WRITTEN FIRST, AND THE SCROLLER IS WHY. Three reviewers have now arrived - * at this block, worked the mechanism out from the library's bundle, and reached the - * same place; it had no comment for any of them to read. This is that comment. - * - * `MessageScrollerContent` is `flex flex-col`, so `order` is live: the spacer takes - * `order-2`, these children take `order-1`, and the transcript rows below keep the - * default `0`. Visual order is therefore items, then anything parked or in flight, then - * the spacer — while DOM order puts this block first. - * - * IT CANNOT SIMPLY BE MOVED DOWN. The scroller finds a newly appended row POSITIONALLY. - * On every content change it takes `Array.from(content.children)` minus the spacer, - * compares the length against the previous length, and when it grew scans FROM THE OLD - * LENGTH FORWARD for the next `data-scroll-anchor="true"` (`je(a, T)` in - * `@shadcn/react/dist/message-scroller`). A new row is only found when it lands at the - * very end of that list. Put this block after `items.map` and every appended row lands - * one slot short of the end, the scan finds this div instead, returns null, and the - * caller falls through to its follow-the-bottom branch — so a new turn stops aligning - * to the top of the viewport with a peek of the previous one, silently, with no test - * failing. - * - * `display: contents` IS LOAD-BEARING FOR THE SAME REASON, and not a layout trick. It - * promotes these children to flex items of the column so they can carry `order`, while - * the div itself stays a single, always-present entry in `content.children` — one - * stable slot the row count can be offset by. Wrapping `items.map` the same way would - * be the natural symmetry and is fatal: the rows would leave `content.children` - * entirely and the scroller would see a transcript of two elements, neither carrying a - * `data-message-id`, so nothing would register, be tracked as visible, or anchor. - * - * WHAT THIS COSTS, STATED RATHER THAN LEFT TO BE REDISCOVERED. `order` moves paint and - * not the DOM, so it moves neither focus order nor the reading order of the enclosing - * `role="log"`. A keyboard user tabbing in reaches the "Remove queued message: …" - * button of every parked message before any control in the conversation, though those - * lines are drawn at the very bottom (WCAG 2.4.3); a screen reader reading the log - * linearly hears the parked messages, and `Stopped`, ahead of the conversation they - * follow on screen (WCAG 1.3.2). - * - * THAT IS A KNOWN, UNPAID DEBT AND NOT AN OVERSIGHT. The fixes available from inside - * this file were each tried on paper and each breaks something worse: `aria-owns` needs - * a generated id per row and re-sequences only the accessibility tree, leaving tab - * order inverted; positive `tabIndex` hijacks the tab sequence of the whole page; - * hoisting the queue out of `MessageScrollerContent` into a sibling region — the - * cleanest END STATE, since a parked message is genuinely not a log entry — puts it - * outside a column that is `min-h-full`, so it lands below the fold on a short - * transcript, outside the `gap-6` rhythm, and outside the spacer's height arithmetic. - * Paying it properly means the scroller identifying new rows by identity rather than by - * position, which is the library's to change and is worth asking for. - */} -
- {stopped ? ( - - ) : waitingOnFirstToken ? ( - - ) : null} - {working} - {queued.map((message) => ( - onRemoveQueued(message.id) - : undefined - } - text={message.text} - /> - ))} -
- {items.map((item, index) => - item.kind === "tools" ? ( - - + + + + + {/* + * DRAWN LAST, WRITTEN FIRST, AND THE SCROLLER IS WHY. Three reviewers have now arrived + * at this block, worked the mechanism out from the library's bundle, and reached the + * same place; it had no comment for any of them to read. This is that comment. + * + * `MessageScrollerContent` is `flex flex-col`, so `order` is live: the spacer takes + * `order-2`, these children take `order-1`, and the transcript rows below keep the + * default `0`. Visual order is therefore items, then anything parked or in flight, then + * the spacer — while DOM order puts this block first. + * + * IT CANNOT SIMPLY BE MOVED DOWN. The scroller finds a newly appended row POSITIONALLY. + * On every content change it takes `Array.from(content.children)` minus the spacer, + * compares the length against the previous length, and when it grew scans FROM THE OLD + * LENGTH FORWARD for the next `data-scroll-anchor="true"` (`je(a, T)` in + * `@shadcn/react/dist/message-scroller`). A new row is only found when it lands at the + * very end of that list. Put this block after `items.map` and every appended row lands + * one slot short of the end, the scan finds this div instead, returns null, and the + * caller falls through to its follow-the-bottom branch — so a new turn stops aligning + * to the top of the viewport with a peek of the previous one, silently, with no test + * failing. + * + * `display: contents` IS LOAD-BEARING FOR THE SAME REASON, and not a layout trick. It + * promotes these children to flex items of the column so they can carry `order`, while + * the div itself stays a single, always-present entry in `content.children` — one + * stable slot the row count can be offset by. Wrapping `items.map` the same way would + * be the natural symmetry and is fatal: the rows would leave `content.children` + * entirely and the scroller would see a transcript of two elements, neither carrying a + * `data-message-id`, so nothing would register, be tracked as visible, or anchor. + * + * WHAT THIS COSTS, STATED RATHER THAN LEFT TO BE REDISCOVERED. `order` moves paint and + * not the DOM, so it moves neither focus order nor the reading order of the enclosing + * `role="log"`. A keyboard user tabbing in reaches the "Remove queued message: …" + * button of every parked message before any control in the conversation, though those + * lines are drawn at the very bottom (WCAG 2.4.3); a screen reader reading the log + * linearly hears the parked messages, and `Stopped`, ahead of the conversation they + * follow on screen (WCAG 1.3.2). + * + * THAT IS A KNOWN, UNPAID DEBT AND NOT AN OVERSIGHT. The fixes available from inside + * this file were each tried on paper and each breaks something worse: `aria-owns` needs + * a generated id per row and re-sequences only the accessibility tree, leaving tab + * order inverted; positive `tabIndex` hijacks the tab sequence of the whole page; + * hoisting the queue out of `MessageScrollerContent` into a sibling region — the + * cleanest END STATE, since a parked message is genuinely not a log entry — puts it + * outside a column that is `min-h-full`, so it lands below the fold on a short + * transcript, outside the `gap-6` rhythm, and outside the spacer's height arithmetic. + * Paying it properly means the scroller identifying new rows by identity rather than by + * position, which is the library's to change and is worth asking for. + */} +
+ {stopped ? ( + + ) : waitingOnFirstToken ? ( + + ) : null} + {working} + {queued.map((message) => ( + onRemoveQueued(message.id) + : undefined + } + text={message.text} + /> + ))} +
+ {items.map((item, index) => + item.kind === "tools" ? ( + + + + + + ) : item.kind === "tool" ? ( + + = latestIndex} + name={item.toolCall.function.name} + result={item.result} + toolCallId={item.toolCall.id} + /> + + ) : item.kind === "activity" ? ( + + + + ) : item.kind === "text" ? ( + - -
-
- ) : item.kind === "tool" ? ( - - = latestIndex} - name={item.toolCall.function.name} - result={item.result} - toolCallId={item.toolCall.id} - /> - - ) : item.kind === "activity" ? ( - - - - ) : item.kind === "text" ? ( - - - - ) : item.kind === "attachments" ? ( - - - - ) : ( - // Every member of the union is handled above. `assertNever` types `item` as - // `never` here, so a future addition to `VisibleChatItem` fails to typecheck at - // this call instead of silently falling into this branch and rendering nothing. - assertNever(item) - ), - )} -
-
- - -
-
+
+ ) : item.kind === "attachments" ? ( + + + + ) : ( + // Every member of the union is handled above. `assertNever` types `item` as + // `never` here, so a future addition to `VisibleChatItem` fails to typecheck at + // this call instead of silently falling into this branch and rendering nothing. + assertNever(item) + ), + )} + + + + +
+
+ ); } diff --git a/app/tests/transcript-attachments.test.tsx b/app/tests/transcript-attachments.test.tsx index 7474210736..a15bc1e132 100644 --- a/app/tests/transcript-attachments.test.tsx +++ b/app/tests/transcript-attachments.test.tsx @@ -758,10 +758,7 @@ test("a rebuilt but unchanged row of files compares equal", () => { const rebuilt = ONE_FILE.map((file) => ({ ...file })); expect( - sameAttachmentRow( - { attachments: ONE_FILE, delay: 0 }, - { attachments: rebuilt, delay: 0 }, - ), + sameAttachmentRow({ attachments: ONE_FILE }, { attachments: rebuilt }), ).toBe(true); }); @@ -777,30 +774,18 @@ test("a row that actually changed does not compare equal", () => { for (const file of changed) { expect( - sameAttachmentRow( - { attachments: ONE_FILE, delay: 0 }, - { attachments: [file], delay: 0 }, - ), + sameAttachmentRow({ attachments: ONE_FILE }, { attachments: [file] }), ).toBe(false); } - // A file added, a file taken away, and the entrance delay itself — all of them redraw. - expect( - sameAttachmentRow( - { attachments: ONE_FILE, delay: 0 }, - { attachments: [], delay: 0 }, - ), - ).toBe(false); + // A file added or a file taken away — both redraw. expect( - sameAttachmentRow( - { attachments: ONE_FILE, delay: 0 }, - { attachments: [...ONE_FILE, ONE_FILE[0]], delay: 0 }, - ), + sameAttachmentRow({ attachments: ONE_FILE }, { attachments: [] }), ).toBe(false); expect( sameAttachmentRow( - { attachments: ONE_FILE, delay: 0 }, - { attachments: ONE_FILE, delay: 0.04 }, + { attachments: ONE_FILE }, + { attachments: [...ONE_FILE, ONE_FILE[0]] }, ), ).toBe(false); }); diff --git a/app/tests/transcript-open.test.tsx b/app/tests/transcript-open.test.tsx index 86dca6e62e..e405bb2cba 100644 --- a/app/tests/transcript-open.test.tsx +++ b/app/tests/transcript-open.test.tsx @@ -79,3 +79,67 @@ test("an empty conversation shows neither placeholder nor rows once restored", ( expect(view.queryByLabelText(PLACEHOLDER)).toBeNull(); expect(view.container.querySelectorAll("[data-message-id]").length).toBe(0); }); + +/** + * Only what arrives while the conversation is open animates in. History is already there when it + * opens; fading it in row by row read as the conversation still loading. motion renders `initial` + * as inline style on the first paint, so a row that animates starts at `opacity: 0`. + */ +function startsHidden(element: Element): boolean { + return /opacity:\s*0(?![.\d])/.test(element.getAttribute("style") ?? ""); +} + +function arriving(container: HTMLElement): Element[] { + return [...container.querySelectorAll('[data-slot="message-arriving"]')]; +} + +test("history rows do not animate in", () => { + const view = render( + , + ); + + const rows = arriving(view.container); + expect(rows.length).toBe(3); + expect(rows.filter(startsHidden).length).toBe(0); +}); + +test("a live row animates in", () => { + const history = [user("u1", "one"), assistant("a1", "two")]; + const view = render(); + + view.rerender( + , + ); + + const rows = arriving(view.container); + expect(rows.length).toBe(3); + expect(rows.map(startsHidden)).toEqual([false, false, true]); +}); + +test("switching channels does not animate the new history", () => { + const view = render( + , + ); + + view.rerender(); + view.rerender( + , + ); + + const rows = arriving(view.container); + expect(rows.length).toBe(2); + expect(rows.filter(startsHidden).length).toBe(0); +}); From 91292e2fb878a5f8a482ad43afc7dbfaf7afeacd Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 29 Oct 2026 09:20:17 -0300 Subject: [PATCH 04/50] Glide only for messages sent after a conversation is open --- .../components/channels/chat-transcript.tsx | 48 +++++++++++--- app/tests/send-glide.test.ts | 63 +++++++++++++++++++ 2 files changed, 104 insertions(+), 7 deletions(-) create mode 100644 app/tests/send-glide.test.ts diff --git a/app/src/components/channels/chat-transcript.tsx b/app/src/components/channels/chat-transcript.tsx index 479ffd17a6..7d8634e45e 100644 --- a/app/src/components/channels/chat-transcript.tsx +++ b/app/src/components/channels/chat-transcript.tsx @@ -1517,9 +1517,40 @@ function assertNever(_item: never): null { const SEND_SCROLL_MS = 700; +/** + * Whether the viewport should glide for a beat because the person just sent something. + * + * ONLY FOR A SEND MADE WHILE THE CONVERSATION IS OPEN. The newest person-sent id also changes while + * history is restored, batch by batch, and gliding then animated the scroller's jump to the bottom + * of a channel being opened: the conversation visibly loaded and then slid. The first id seen once + * history is in is the baseline, never a send. + * + * Exported so it can be checked without mounting anything. + */ +export function shouldGlideOnSend({ + previous, + next, + restoring, + reducedMotion, +}: { + previous: string | null; + next: string | null; + restoring: boolean; + reducedMotion: boolean; +}): boolean { + return ( + !restoring && + !reducedMotion && + previous !== null && + next !== null && + previous !== next + ); +} + function useSmoothSendScroll( viewport: React.RefObject, newestUserMessageId: string | null, + restoring: boolean, ) { const reducedMotion = useReducedMotion(); const seenRef = useRef(null); @@ -1527,14 +1558,17 @@ function useSmoothSendScroll( useLayoutEffect(() => { const element = viewport.current; const previous = seenRef.current; - seenRef.current = newestUserMessageId; + // Nothing is recorded while restoring, so the first id after it is a baseline (see above). + seenRef.current = restoring ? null : newestUserMessageId; if ( element === null || - newestUserMessageId === null || - previous === null || - previous === newestUserMessageId || - reducedMotion + !shouldGlideOnSend({ + previous, + next: newestUserMessageId, + restoring, + reducedMotion: reducedMotion ?? false, + }) ) { return; } @@ -1548,7 +1582,7 @@ function useSmoothSendScroll( window.clearTimeout(timer); element.style.scrollBehavior = ""; }; - }, [newestUserMessageId, reducedMotion, viewport]); + }, [newestUserMessageId, reducedMotion, restoring, viewport]); } export function ChatTranscript({ @@ -1611,7 +1645,7 @@ export function ChatTranscript({ */ item.kind === "attachments", )?.id ?? null; - useSmoothSendScroll(viewportRef, newestUserMessageId); + useSmoothSendScroll(viewportRef, newestUserMessageId, restoring); /* Which tool calls are still the latest thing, for an app card: see `latestFrom`. */ const latestIndex = latestFrom(items, { busy, queued: queued.length }); diff --git a/app/tests/send-glide.test.ts b/app/tests/send-glide.test.ts new file mode 100644 index 0000000000..0103569afe --- /dev/null +++ b/app/tests/send-glide.test.ts @@ -0,0 +1,63 @@ +import { expect, test } from "bun:test"; +import { shouldGlideOnSend } from "@/components/channels/chat-transcript"; + +/** + * The viewport glides for a beat when the person sends something, so the eye can follow their + * message up to where it anchors. It must never glide for history: that animated the jump to the + * bottom while a channel opened, which is the one moment nothing should be seen moving. + */ + +test("no glide while restoring", () => { + expect( + shouldGlideOnSend({ + previous: "a", + next: "b", + restoring: true, + reducedMotion: false, + }), + ).toBe(false); +}); + +test("no glide for the first id after restore", () => { + expect( + shouldGlideOnSend({ + previous: null, + next: "b", + restoring: false, + reducedMotion: false, + }), + ).toBe(false); +}); + +test("glide when a new message is sent", () => { + expect( + shouldGlideOnSend({ + previous: "a", + next: "b", + restoring: false, + reducedMotion: false, + }), + ).toBe(true); +}); + +test("no glide when nothing changed", () => { + expect( + shouldGlideOnSend({ + previous: "a", + next: "a", + restoring: false, + reducedMotion: false, + }), + ).toBe(false); +}); + +test("no glide under reduced motion", () => { + expect( + shouldGlideOnSend({ + previous: "a", + next: "b", + restoring: false, + reducedMotion: true, + }), + ).toBe(false); +}); From 45592f9c01e6cdfbf26df63bdbbad11557bc96fc Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 29 Oct 2026 09:20:50 -0300 Subject: [PATCH 05/50] Open group chats at the bottom and follow only from there --- app/src/components/channels/group-chat.tsx | 50 ++++++++++++++++++---- app/tests/group-chat-scroll.test.ts | 34 +++++++++++++++ 2 files changed, 76 insertions(+), 8 deletions(-) create mode 100644 app/tests/group-chat-scroll.test.ts diff --git a/app/src/components/channels/group-chat.tsx b/app/src/components/channels/group-chat.tsx index 4e3cf193e1..725703f5ba 100644 --- a/app/src/components/channels/group-chat.tsx +++ b/app/src/components/channels/group-chat.tsx @@ -1,6 +1,6 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Link } from "@tanstack/react-router"; -import { useEffect, useRef } from "react"; +import { useLayoutEffect, useRef } from "react"; import { Streamdown } from "streamdown"; import { Bubble, BubbleContent } from "@/components/ui/bubble"; import { @@ -42,12 +42,27 @@ export function GroupChat({ channelId }: { channelId: string }) { (message) => message.status === "running" || message.status === "queued", ); - // Follow the conversation down as lines arrive, the way the single-Bot transcript does. - const endRef = useRef(null); + /* + * LAND AT THE BOTTOM BEFORE THE FIRST PAINT, THEN FOLLOW ONLY A READER WHO IS STILL THERE. + * + * This was `scrollIntoView` in a `useEffect`, which runs after the browser has painted: every + * group chat showed its top for a frame and then jumped. A layout effect moves it before paint. + * And it followed every new line unconditionally, pulling somebody reading an earlier answer back + * down each time a Bot spoke; `atBottom` is where the reader was at their last scroll. + */ + const viewportRef = useRef(null); + const atBottom = useRef(true); + const landedFor = useRef(null); const lastKey = `${messages.length}:${messages.at(-1)?.status ?? ""}`; - useEffect(() => { - if (lastKey) endRef.current?.scrollIntoView({ block: "end" }); - }, [lastKey]); + useLayoutEffect(() => { + const element = viewportRef.current; + if (element === null || lastKey === "0:") return; + if (landedFor.current !== channelId || atBottom.current) { + element.scrollTop = element.scrollHeight; + landedFor.current = channelId; + atBottom.current = true; + } + }, [channelId, lastKey]); if (group.isPending) return null; if (group.isError) @@ -59,7 +74,13 @@ export function GroupChat({ channelId }: { channelId: string }) { return (
-
+
{ + atBottom.current = isNearBottom(event.currentTarget); + }} + ref={viewportRef} + >
{messages.length === 0 ? (

@@ -86,7 +107,6 @@ export function GroupChat({ channelId }: { channelId: string }) { mine={message.ownerUserId === me?.id} /> ))} -

{/* The same column and the same compact composer as a conversation with one Bot. */} @@ -212,3 +232,17 @@ function GroupLine({ ); } + +/** + * Whether a scrolled element is close enough to its end to count as reading the newest line. + * + * Exported so it can be checked without mounting anything. + */ +export function isNearBottom( + element: { scrollTop: number; scrollHeight: number; clientHeight: number }, + threshold = 80, +): boolean { + return ( + element.scrollHeight - element.scrollTop - element.clientHeight <= threshold + ); +} diff --git a/app/tests/group-chat-scroll.test.ts b/app/tests/group-chat-scroll.test.ts new file mode 100644 index 0000000000..a096f994db --- /dev/null +++ b/app/tests/group-chat-scroll.test.ts @@ -0,0 +1,34 @@ +import { expect, test } from "bun:test"; +import { isNearBottom } from "@/components/channels/group-chat"; + +/** + * A group chat follows new lines only for a reader who is already at the bottom. Somebody scrolled + * up to read an earlier answer keeps their place while the Bots go on talking below. + */ + +test("80px from the bottom counts as at the bottom", () => { + expect( + isNearBottom({ scrollTop: 920, scrollHeight: 1000, clientHeight: 0 }), + ).toBe(true); +}); + +test("81px from the bottom does not", () => { + expect( + isNearBottom({ scrollTop: 919, scrollHeight: 1000, clientHeight: 0 }), + ).toBe(false); +}); + +test("a custom threshold is respected", () => { + expect( + isNearBottom( + { scrollTop: 700, scrollHeight: 1000, clientHeight: 100 }, + 200, + ), + ).toBe(true); + expect( + isNearBottom( + { scrollTop: 699, scrollHeight: 1000, clientHeight: 100 }, + 200, + ), + ).toBe(false); +}); From a2c0dbe220504f58766a7e763aa18288e763862e Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 29 Oct 2026 09:38:46 -0300 Subject: [PATCH 06/50] Show a new conversation's first message without waiting for voice chats --- app/src/components/channels/channel-chat.tsx | 27 +++++++++++++++- app/tests/history-loading.test.ts | 33 ++++++++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) create mode 100644 app/tests/history-loading.test.ts diff --git a/app/src/components/channels/channel-chat.tsx b/app/src/components/channels/channel-chat.tsx index 7fe81df969..79d7c58eb4 100644 --- a/app/src/components/channels/channel-chat.tsx +++ b/app/src/components/channels/channel-chat.tsx @@ -87,6 +87,27 @@ function sameActivity( ); } +/** + * Whether the transcript still waits behind the placeholder. + * + * A channel opened from the roster waits for its whole history: the stored thread (`restoring`) and + * its voice chats. A channel just started from the compose screen has no history; its first message + * is already in hand and shows at once, not after a voice-chat request with nothing in it. + * + * Exported for its tests. + */ +export function historyIsLoading({ + restoring, + seeded, + voicePending, +}: { + restoring: boolean; + seeded: boolean; + voicePending: boolean; +}): boolean { + return restoring || (!seeded && voicePending); +} + export function channelHistoryNotice({ restoring, messageCount, @@ -545,7 +566,11 @@ export function ChannelChat({ * and then splicing call cards into it; the notice waits for the same moment, so it appears in * the commit that shows the history rather than a render later, shrinking the view under it. */ - const historyLoading = restoring || voiceArchive.pending; + const historyLoading = historyIsLoading({ + restoring, + seeded: seed !== null, + voicePending: voiceArchive.pending, + }); const historyNotice = channelHistoryNotice({ restoring: historyLoading, messageCount: agent.messages.length, diff --git a/app/tests/history-loading.test.ts b/app/tests/history-loading.test.ts new file mode 100644 index 0000000000..aaf03ccc11 --- /dev/null +++ b/app/tests/history-loading.test.ts @@ -0,0 +1,33 @@ +import { expect, test } from "bun:test"; +import { historyIsLoading } from "@/components/channels/channel-chat"; + +/** + * When the transcript waits behind the placeholder. A channel opened from the roster waits for its + * whole history — the stored thread and its voice chats. A channel just started from the compose + * screen has no history to wait for: its first message is already in hand and must show at once, + * not after a voice-chat request it cannot have anything in. + */ + +test("a restoring channel waits", () => { + expect( + historyIsLoading({ restoring: true, seeded: false, voicePending: false }), + ).toBe(true); +}); + +test("an opened channel waits for its voice chats", () => { + expect( + historyIsLoading({ restoring: false, seeded: false, voicePending: true }), + ).toBe(true); +}); + +test("a channel started from the compose screen does not wait for voice chats", () => { + expect( + historyIsLoading({ restoring: false, seeded: true, voicePending: true }), + ).toBe(false); +}); + +test("nothing left to wait for", () => { + expect( + historyIsLoading({ restoring: false, seeded: false, voicePending: false }), + ).toBe(false); +}); From 04e991c0afb30738cc709677a94f8221d3e3551b Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 29 Oct 2026 09:38:46 -0300 Subject: [PATCH 07/50] Show a message typed while history loads under the placeholder --- app/src/components/channels/chat-transcript.tsx | 17 ++++++++++++++++- app/tests/transcript-open.test.tsx | 15 +++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/app/src/components/channels/chat-transcript.tsx b/app/src/components/channels/chat-transcript.tsx index 7d8634e45e..c60da55309 100644 --- a/app/src/components/channels/chat-transcript.tsx +++ b/app/src/components/channels/chat-transcript.tsx @@ -1668,8 +1668,23 @@ export function ChatTranscript({ if (restoring) { return (
-
+
+ {/* + * A message typed while history loads is parked, not lost, and must be seen to be: a + * composer that cleared with nothing appearing reads as a message that vanished, and the + * person types it again. + */} + {queued.map((message) => ( + onRemoveQueued(message.id) : undefined + } + text={message.text} + /> + ))}
); diff --git a/app/tests/transcript-open.test.tsx b/app/tests/transcript-open.test.tsx index e405bb2cba..624173724e 100644 --- a/app/tests/transcript-open.test.tsx +++ b/app/tests/transcript-open.test.tsx @@ -143,3 +143,18 @@ test("switching channels does not animate the new history", () => { expect(rows.length).toBe(2); expect(rows.filter(startsHidden).length).toBe(0); }); + +test("a message typed while history loads is shown under the placeholder", () => { + const view = render( + , + ); + + expect(view.getByLabelText(PLACEHOLDER)).toBeTruthy(); + expect(view.getByText("typed early")).toBeTruthy(); +}); From b254a9a21df61484e2496e0fb51ab6a3879b5063 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 29 Oct 2026 09:38:46 -0300 Subject: [PATCH 08/50] Bring a person's own group message into view even when scrolled up --- app/src/components/channels/group-chat.tsx | 40 +++++++++++++++++++--- app/tests/group-chat-scroll.test.ts | 29 +++++++++++++++- 2 files changed, 63 insertions(+), 6 deletions(-) diff --git a/app/src/components/channels/group-chat.tsx b/app/src/components/channels/group-chat.tsx index 725703f5ba..2515ce93d2 100644 --- a/app/src/components/channels/group-chat.tsx +++ b/app/src/components/channels/group-chat.tsx @@ -52,17 +52,26 @@ export function GroupChat({ channelId }: { channelId: string }) { */ const viewportRef = useRef(null); const atBottom = useRef(true); - const landedFor = useRef(null); - const lastKey = `${messages.length}:${messages.at(-1)?.status ?? ""}`; + // Once per mount, which is once per channel: the route keys `GroupChat` on its channel id. + const landed = useRef(false); + const newest = messages.at(-1); + const newestIsMine = newest !== undefined && newest.ownerUserId === me?.id; + const lastKey = `${messages.length}:${newest?.status ?? ""}`; useLayoutEffect(() => { const element = viewportRef.current; if (element === null || lastKey === "0:") return; - if (landedFor.current !== channelId || atBottom.current) { + if ( + shouldFollowGroup({ + landed: landed.current, + atBottom: atBottom.current, + newestIsMine, + }) + ) { element.scrollTop = element.scrollHeight; - landedFor.current = channelId; + landed.current = true; atBottom.current = true; } - }, [channelId, lastKey]); + }, [lastKey, newestIsMine]); if (group.isPending) return null; if (group.isError) @@ -246,3 +255,24 @@ export function isNearBottom( element.scrollHeight - element.scrollTop - element.clientHeight <= threshold ); } + +/** + * Whether the group chat moves to its newest line. + * + * Always on opening, and always for the person's own message: they just sent it and are looking for + * it. A Bot's line only for a reader already at the bottom, so somebody reading an earlier answer + * keeps their place. + * + * Exported so it can be checked without mounting anything. + */ +export function shouldFollowGroup({ + landed, + atBottom, + newestIsMine, +}: { + landed: boolean; + atBottom: boolean; + newestIsMine: boolean; +}): boolean { + return !landed || atBottom || newestIsMine; +} diff --git a/app/tests/group-chat-scroll.test.ts b/app/tests/group-chat-scroll.test.ts index a096f994db..e3c24391f3 100644 --- a/app/tests/group-chat-scroll.test.ts +++ b/app/tests/group-chat-scroll.test.ts @@ -1,5 +1,8 @@ import { expect, test } from "bun:test"; -import { isNearBottom } from "@/components/channels/group-chat"; +import { + isNearBottom, + shouldFollowGroup, +} from "@/components/channels/group-chat"; /** * A group chat follows new lines only for a reader who is already at the bottom. Somebody scrolled @@ -32,3 +35,27 @@ test("a custom threshold is respected", () => { ), ).toBe(false); }); + +test("the first landing always goes to the bottom", () => { + expect( + shouldFollowGroup({ landed: false, atBottom: false, newestIsMine: false }), + ).toBe(true); +}); + +test("a reader scrolled up keeps their place when a Bot speaks", () => { + expect( + shouldFollowGroup({ landed: true, atBottom: false, newestIsMine: false }), + ).toBe(false); +}); + +test("the person's own message is always brought into view", () => { + expect( + shouldFollowGroup({ landed: true, atBottom: false, newestIsMine: true }), + ).toBe(true); +}); + +test("a reader at the bottom follows", () => { + expect( + shouldFollowGroup({ landed: true, atBottom: true, newestIsMine: false }), + ).toBe(true); +}); From 8d0e059e642a94f313cf74f2f645f3ee5ad77afa Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 29 Oct 2026 09:45:26 -0300 Subject: [PATCH 09/50] Render a conversation's newest rows in with a short stagger as it opens --- .../components/channels/chat-transcript.tsx | 74 +++++++++++++++++-- app/src/styles.css | 30 ++++++++ app/tests/transcript-open.test.tsx | 49 ++++++++++++ 3 files changed, 145 insertions(+), 8 deletions(-) diff --git a/app/src/components/channels/chat-transcript.tsx b/app/src/components/channels/chat-transcript.tsx index c60da55309..ee55f3c002 100644 --- a/app/src/components/channels/chat-transcript.tsx +++ b/app/src/components/channels/chat-transcript.tsx @@ -425,6 +425,33 @@ function ScrollNewestQueuedIntoView({ newest }: { newest: string | null }) { return null; } +/** + * How many of the newest rows render in when a conversation opens, and how far apart. + * + * The view has already landed at the bottom before the first paint, so these are the rows on + * screen; the stagger runs top of the screen first, newest last. Short on purpose: eight rows 30ms + * apart is a little over 200ms before the last one starts, which reads as the conversation + * appearing rather than as it loading. Older rows, above the fold, are simply there. + */ +const OPENING_STAGGER_COUNT = 8; +const OPENING_STAGGER_MS = 30; + +/** + * The opening stagger, decided once from the rows present when the conversation opens. + * + * Applied as a CSS animation on the scroller's row wrapper, not on the memoised row inside it: the + * row's props stay the same and a streaming answer can never replay it, and a transform composites + * without moving the layout box the scroller just landed on. Exported for its tests. + */ +export function openingStagger(ids: readonly string[]): Map { + const start = Math.max(0, ids.length - OPENING_STAGGER_COUNT); + return new Map( + ids + .slice(start) + .map((id, index) => [id, `${index * OPENING_STAGGER_MS}ms`] as const), + ); +} + /** * Whether a row mounting now is arriving live or was already there when the conversation opened. * @@ -468,11 +495,9 @@ function ArrivalsLive({ children }: { children: React.ReactNode }) { * accelerated and drops frames exactly when the main thread is busy, which here is while a reply is * streaming. * - * ONLY FOR WHAT ARRIVES WHILE THE CONVERSATION IS OPEN. History is already there when it opens: - * it was cascading the newest dozen rows in, 40ms apart, and the bottom of the conversation filling - * in over half a second read as the conversation still loading. A row mounted with the history - * draws at rest; `ArrivalsLiveContext` says which is which, decided once at the row's first render - * so a streaming answer can never replay it. + * ONLY FOR WHAT ARRIVES WHILE THE CONVERSATION IS OPEN. A row mounted with the history draws at + * rest here and takes the opening stagger instead (`openingStagger`); `ArrivalsLiveContext` says + * which is which, decided once at the row's first render so a streaming answer can never replay it. */ function Arriving({ children }: { children: React.ReactNode }) { const shouldReduceMotion = useReducedMotion(); @@ -1653,6 +1678,25 @@ export function ChatTranscript({ /* One rule for both kinds of row — see `anchorRowIds`, which is where it is written down. */ const anchorRows = anchorRowIds(items); + /* + * Decided once, from the rows present when the scroller mounts, and forgotten while restoring so + * a conversation opened again staggers again. Rows that arrive later are not in it: they have + * their own entrance (`Arriving`). + */ + const openingRef = useRef | null>(null); + if (restoring) { + openingRef.current = null; + } else if (openingRef.current === null) { + openingRef.current = openingStagger(items.map((item) => item.id)); + } + const opening = openingRef.current; + const openingProps = (id: string) => { + const delay = opening?.get(id); + return delay === undefined + ? {} + : { className: "history-arrive", style: { animationDelay: delay } }; + }; + /* * NOTHING BUT THE PLACEHOLDER UNTIL HISTORY IS COMPLETE, AND THE SCROLLER MOUNTED FRESH AFTER IT. * @@ -1770,7 +1814,11 @@ export function ChatTranscript({
{items.map((item, index) => item.kind === "tools" ? ( - + ) : item.kind === "tool" ? ( - + = latestIndex} @@ -1789,7 +1841,11 @@ export function ChatTranscript({ /> ) : item.kind === "activity" ? ( - + ) : item.kind === "text" ? ( @@ -1797,6 +1853,7 @@ export function ChatTranscript({ key={item.id} messageId={item.id} scrollAnchor={anchorRows.has(item.id)} + {...openingProps(item.id)} > diff --git a/app/src/styles.css b/app/src/styles.css index 8adf9c0b53..62d45226cc 100644 --- a/app/src/styles.css +++ b/app/src/styles.css @@ -120,3 +120,33 @@ margin-top: 0; } } + +/* + * A conversation's newest rows rendering in as it opens. The delay is set per row inline; see + * `openingStagger` in chat-transcript.tsx. Opacity and transform only, so nothing the scroller + * measured moves. + */ +.history-arrive { + animation: history-arrive 240ms cubic-bezier(0.23, 1, 0.32, 1) both; +} + +@keyframes history-arrive { + from { + opacity: 0; + transform: translateY(6px); + } +} + +/* Reduced motion keeps the fade and drops the movement and the queue: gentler, not absent. */ +@media (prefers-reduced-motion: reduce) { + .history-arrive { + animation-name: history-arrive-fade; + animation-delay: 0ms !important; + } +} + +@keyframes history-arrive-fade { + from { + opacity: 0; + } +} diff --git a/app/tests/transcript-open.test.tsx b/app/tests/transcript-open.test.tsx index 624173724e..8ee4f7f39c 100644 --- a/app/tests/transcript-open.test.tsx +++ b/app/tests/transcript-open.test.tsx @@ -158,3 +158,52 @@ test("a message typed while history loads is shown under the placeholder", () => expect(view.getByLabelText(PLACEHOLDER)).toBeTruthy(); expect(view.getByText("typed early")).toBeTruthy(); }); + +/** + * Opening a conversation renders its newest rows in with a short stagger. It runs after the view + * has landed at the bottom, so it reads as the conversation appearing rather than loading: only + * the rows on screen take part, top of the screen first, newest last. + */ +function openingDelays(container: HTMLElement): (string | null)[] { + return [...container.querySelectorAll("[data-message-id]")].map((row) => + row.classList.contains("history-arrive") + ? (row as HTMLElement).style.animationDelay + : null, + ); +} + +test("the newest history rows render in, staggered toward the newest", () => { + const messages = Array.from({ length: 10 }, (_, index) => + index % 2 === 0 + ? user(`u${index}`, `question ${index}`) + : assistant(`a${index}`, `answer ${index}`), + ); + const view = render(); + + expect(openingDelays(view.container)).toEqual([ + null, + null, + "0ms", + "30ms", + "60ms", + "90ms", + "120ms", + "150ms", + "180ms", + "210ms", + ]); +}); + +test("a row that arrives after opening does not take the opening stagger", () => { + const history = [user("u1", "one"), assistant("a1", "two")]; + const view = render(); + + view.rerender( + , + ); + + expect(openingDelays(view.container)).toEqual(["0ms", "30ms", null]); +}); From c8b3b243704f291fb3cadcaf64874a178e1d7eca Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 29 Oct 2026 09:45:55 -0300 Subject: [PATCH 10/50] Pass the opening stagger delay as a variable so reduced motion can drop it --- app/src/components/channels/chat-transcript.tsx | 5 ++++- app/src/styles.css | 6 ++++-- app/tests/transcript-open.test.tsx | 2 +- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/app/src/components/channels/chat-transcript.tsx b/app/src/components/channels/chat-transcript.tsx index ee55f3c002..3669bb7f07 100644 --- a/app/src/components/channels/chat-transcript.tsx +++ b/app/src/components/channels/chat-transcript.tsx @@ -1694,7 +1694,10 @@ export function ChatTranscript({ const delay = opening?.get(id); return delay === undefined ? {} - : { className: "history-arrive", style: { animationDelay: delay } }; + : { + className: "history-arrive", + style: { "--arrive-delay": delay } as React.CSSProperties, + }; }; /* diff --git a/app/src/styles.css b/app/src/styles.css index 62d45226cc..46f20924b4 100644 --- a/app/src/styles.css +++ b/app/src/styles.css @@ -122,12 +122,14 @@ } /* - * A conversation's newest rows rendering in as it opens. The delay is set per row inline; see + * A conversation's newest rows rendering in as it opens. The delay is set per row as + * `--arrive-delay`; see * `openingStagger` in chat-transcript.tsx. Opacity and transform only, so nothing the scroller * measured moves. */ .history-arrive { animation: history-arrive 240ms cubic-bezier(0.23, 1, 0.32, 1) both; + animation-delay: var(--arrive-delay, 0ms); } @keyframes history-arrive { @@ -141,7 +143,7 @@ @media (prefers-reduced-motion: reduce) { .history-arrive { animation-name: history-arrive-fade; - animation-delay: 0ms !important; + animation-delay: 0ms; } } diff --git a/app/tests/transcript-open.test.tsx b/app/tests/transcript-open.test.tsx index 8ee4f7f39c..46ebb0133c 100644 --- a/app/tests/transcript-open.test.tsx +++ b/app/tests/transcript-open.test.tsx @@ -167,7 +167,7 @@ test("a message typed while history loads is shown under the placeholder", () => function openingDelays(container: HTMLElement): (string | null)[] { return [...container.querySelectorAll("[data-message-id]")].map((row) => row.classList.contains("history-arrive") - ? (row as HTMLElement).style.animationDelay + ? (row as HTMLElement).style.getPropertyValue("--arrive-delay") : null, ); } From 52a8853f9de49fcc8e8d8a0a375d0c8f0f1eb97f Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 29 Oct 2026 09:54:38 -0300 Subject: [PATCH 11/50] Keep a tool row and its key for its whole life as calls join and leave it A lone foldable call is now a group of one under a group: key and is drawn as the same line it was. Existing grouping tests that asserted a lone call's own id or an unprefixed group id now assert the group: id. --- .../components/channels/chat-transcript.tsx | 97 ++++++++++++++----- app/src/components/channels/tool-activity.tsx | 20 +++- app/tests/browser-activity.test.ts | 10 +- app/tests/tool-activity.test.tsx | 32 ++++-- app/tests/tool-rows.test.ts | 87 +++++++++++++++++ 5 files changed, 207 insertions(+), 39 deletions(-) create mode 100644 app/tests/tool-rows.test.ts diff --git a/app/src/components/channels/chat-transcript.tsx b/app/src/components/channels/chat-transcript.tsx index 3669bb7f07..a16b5b1aed 100644 --- a/app/src/components/channels/chat-transcript.tsx +++ b/app/src/components/channels/chat-transcript.tsx @@ -72,9 +72,11 @@ import { dropRepeatedAppCards, groupToolSteps, ToolActivity, + type ToolGroup, useDrawnTool, type TranscriptItem, } from "./tool-activity"; +import { isBrowserStep } from "./browser-activity"; import { attachmentModality, type SentAttachment, @@ -1294,7 +1296,7 @@ const TranscriptActivity = memo(function TranscriptActivity({ * `useRenderToolCall` is called HERE rather than passed in as a prop: a function whose identity the * parent cannot guarantee is the classic way to make a memo boundary useless. */ -const TranscriptToolCall = memo(function TranscriptToolCall({ +const ToolCallBody = memo(function ToolCallBody({ toolCallId, name, args, @@ -1332,29 +1334,70 @@ const TranscriptToolCall = memo(function TranscriptToolCall({ }), }); + return ( + + {/* + * A TOOL WITH NO REGISTERED RENDERER STILL HAPPENED, and since tools moved to the server + * that is now the ordinary case rather than the exception: MCP tools execute in the runtime + * and register no renderer here at all. `renderToolCall` still draws the components the app + * registers, and everything else lands below. + * + * What was called, shimmering until its result arrives, and then the server's own words + * drawn the way a Bot's prose is drawn. + */} + {drawn ?? ( + + )} + + ); +}); + +type ToolCallProps = React.ComponentProps; + +/** A tool call on its own row, arriving as one. */ +function TranscriptToolCall(props: ToolCallProps) { return ( - - {/* - * A TOOL WITH NO REGISTERED RENDERER STILL HAPPENED, and since tools moved to the server - * that is now the ordinary case rather than the exception: MCP tools execute in the runtime - * and register no renderer here at all. `renderToolCall` still draws the components the app - * registers, and everything else lands below. - * - * What was called, shimmering until its result arrives, and then the server's own words - * drawn the way a Bot's prose is drawn. - */} - {drawn ?? ( - - )} - + ); -}); +} + +/** + * A row of calls the transcript folds, drawn by ONE component whatever its size, so the row is not + * rebuilt when a second call joins it. A lone call is the line it always was; a run, or a browser + * step, is the folded group. See `groupToolSteps` for why the row's key survives the same changes. + */ +function FoldedToolRow({ + group, + active, + latest, +}: { + group: ToolGroup; + active: boolean; + latest: boolean; +}) { + const [only] = group.steps; + return ( + + {group.steps.length === 1 && only && !isBrowserStep(only) ? ( + + ) : ( + + )} + + ); +} /** * A tool the runtime executed, drawn for the person watching. @@ -1631,9 +1674,12 @@ export function ChatTranscript({ * cost was markdown parsing and chart SVGs, and those are skipped by the memoised children below, * which is where the 25x came from. This runs per render and is not worth guarding. */ + /* Which row each tool step was drawn in, so a row keeps its key; see `groupToolSteps`. */ + const rowIdsRef = useRef(new Map()); const items = groupToolSteps( dropRepeatedAppCards(toVisibleChatItems(messages)), isDrawn, + rowIdsRef.current, ); /* @@ -1822,12 +1868,11 @@ export function ChatTranscript({ messageId={item.id} {...openingProps(item.id)} > - - - + = latestIndex} + /> ) : item.kind === "tool" ? ( boolean, + rowIds: Map = new Map(), ): TranscriptItem[] { const grouped: TranscriptItem[] = []; + const claimed = new Set(); let run: ToolItem[] = []; const close = () => { const [first] = run; if (!first) return; - if (run.length === 1 && !isBrowserStep(first)) grouped.push(first); - else grouped.push({ kind: "tools", id: first.id, steps: run }); + const kept = run + .map((step) => rowIds.get(step.id)) + .find((id) => id !== undefined && !claimed.has(id)); + const id = kept ?? `group:${first.id}`; + claimed.add(id); + for (const step of run) rowIds.set(step.id, id); + grouped.push({ kind: "tools", id, steps: run }); run = []; }; for (const item of items) { diff --git a/app/tests/browser-activity.test.ts b/app/tests/browser-activity.test.ts index b9a093e0e7..6170b2ed0b 100644 --- a/app/tests/browser-activity.test.ts +++ b/app/tests/browser-activity.test.ts @@ -28,13 +28,13 @@ test("navigation and intervening browser actions become one stable row as result const first = step("one"); const items = [first, step("read", "computer_read"), step("two")]; const grouped = groupToolSteps(items, drawn); - expect(grouped).toEqual([{ kind: "tools", id: "one", steps: items }]); + expect(grouped).toEqual([{ kind: "tools", id: "group:one", steps: items }]); expect( groupToolSteps( [{ ...first, result: '{"ok":true}' }, ...items.slice(1)], drawn, )[0]?.id, - ).toBe("one"); + ).toBe("group:one"); expect(items).toHaveLength(3); }); @@ -53,7 +53,11 @@ test("grouping keeps messages, other tools, and human assistance requests in the ]; for (const boundary of boundaries) { const grouped = groupToolSteps([step("one"), boundary, step("two")], drawn); - expect(grouped.map((item) => item.id)).toEqual(["one", boundary.id, "two"]); + expect(grouped.map((item) => item.id)).toEqual([ + "group:one", + boundary.id, + "group:two", + ]); expect(grouped[1]).toBe(boundary); } }); diff --git a/app/tests/tool-activity.test.tsx b/app/tests/tool-activity.test.tsx index 1e987b797f..34016d7fa3 100644 --- a/app/tests/tool-activity.test.tsx +++ b/app/tests/tool-activity.test.tsx @@ -62,16 +62,26 @@ test("adjacent connector calls fold into one row named after the first", () => { ]; const grouped = groupToolSteps(items, drawn); expect(grouped.map((item) => item.kind)).toEqual(["tools", "text"]); - expect(grouped[0]).toMatchObject({ id: "search", steps: items.slice(0, 3) }); + expect(grouped[0]).toMatchObject({ + id: "group:search", + steps: items.slice(0, 3), + }); }); test("one connector call keeps its own line, and anything the app draws stays where it is", () => { - expect(groupToolSteps([search()], drawn)).toEqual([search()]); + // Drawn as its own line, inside the one row type a run uses, so a second call does not rebuild it. + expect(groupToolSteps([search()], drawn)).toEqual([ + { kind: "tools", id: "group:search", steps: [search()] }, + ]); const chart = call("chart", "showBarChart"); const help = call("help", "computer_request_help"); for (const boundary of [chart, help, text("between")]) { const grouped = groupToolSteps([search("a"), boundary, search("b")], drawn); - expect(grouped.map((item) => item.id)).toEqual(["a", boundary.id, "b"]); + expect(grouped.map((item) => item.id)).toEqual([ + "group:a", + boundary.id, + "group:b", + ]); } }); @@ -81,10 +91,10 @@ test("a run of tools the app does not draw folds like connector calls do", () => ); const grouped = groupToolSteps([...commands, text("answer")], drawn); expect(grouped.map((item) => item.kind)).toEqual(["tools", "text"]); - expect(grouped[0]).toMatchObject({ id: "cmd1", steps: commands }); + expect(grouped[0]).toMatchObject({ id: "group:cmd1", steps: commands }); // On its own it keeps its own line, as a lone connector call does. expect(groupToolSteps([commands[0] as ToolItem], drawn)).toEqual([ - commands[0] as ToolItem, + { kind: "tools", id: "group:cmd1", steps: [commands[0] as ToolItem] }, ]); // And it joins a run of connector calls rather than splitting it. const mixed = groupToolSteps([search("a"), commands[0] as ToolItem], drawn); @@ -101,7 +111,11 @@ test("a tool the app draws ends a run, whatever it is called", () => { ], drawn, ); - expect(grouped.map((item) => item.id)).toEqual(["a", "chart", "c"]); + expect(grouped.map((item) => item.id)).toEqual([ + "group:a", + "chart", + "group:c", + ]); }); test("a tool step is named in words and counted as tools", () => { @@ -198,7 +212,11 @@ test("the app-access card stays out of a group, even before its answer arrives", ]) { const card = call("card", "show_app_access", { app: "gmail" }, result); const grouped = groupToolSteps([search("a"), card, search("b")], drawn); - expect(grouped.map((item) => item.id)).toEqual(["a", "card", "b"]); + expect(grouped.map((item) => item.id)).toEqual([ + "group:a", + "card", + "group:b", + ]); } }); diff --git a/app/tests/tool-rows.test.ts b/app/tests/tool-rows.test.ts new file mode 100644 index 0000000000..bcaac3d8e0 --- /dev/null +++ b/app/tests/tool-rows.test.ts @@ -0,0 +1,87 @@ +import { expect, test } from "bun:test"; +import { connectAppRefusal } from "@openbot/shared/connect-prompt"; +import type { VisibleChatItem } from "../src/components/channels/chat-messages"; +import { groupToolSteps } from "../src/components/channels/tool-activity"; +import { REFUSAL_MARKER } from "../src/lib/plugins/tool-result"; + +/** + * A row of tool calls keeps one key for its whole life. React keys the transcript row on it, so a + * key that changed when a second call arrived, or when the first call turned out to ask the person + * something, tore the row down and rebuilt it: its entrance replayed and anything drawn in it + * flickered. Group ids carry a `group:` prefix so they can never collide with a single row's id. + */ + +type ToolItem = Extract; + +function call(id: string, result?: string): ToolItem { + return { + kind: "tool", + id, + toolCall: { + id, + type: "function", + function: { name: "mcp__exa__web_search_exa", arguments: "{}" }, + }, + ...(result === undefined ? {} : { result }), + }; +} + +const text = (id: string): VisibleChatItem => ({ + kind: "text", + id, + role: "assistant", + text: "Here is what I found.", +}); + +const asks = `${REFUSAL_MARKER} ${connectAppRefusal({ + serverId: "composio-gmail", + title: "Gmail", + message: "You have not connected your Gmail account.", + agentId: "expenses", +})}`; + +const drawsNothing = () => false; + +test("a lone foldable call is a group of one under a group key", () => { + const grouped = groupToolSteps([call("t1")], drawsNothing, new Map()); + expect(grouped).toEqual([ + { kind: "tools", id: "group:t1", steps: [call("t1")] }, + ]); +}); + +test("a second adjacent call keeps the row id", () => { + const rowIds = new Map(); + const first = groupToolSteps([call("t1")], drawsNothing, rowIds); + const second = groupToolSteps([call("t1"), call("t2")], drawsNothing, rowIds); + expect(first.map((item) => item.id)).toEqual(["group:t1"]); + expect(second.map((item) => item.id)).toEqual(["group:t1"]); +}); + +test("a first step that leaves the group does not take the group's id with it", () => { + const rowIds = new Map(); + groupToolSteps([call("t1"), call("t2"), call("t3")], drawsNothing, rowIds); + const after = groupToolSteps( + [call("t1", asks), call("t2"), call("t3")], + drawsNothing, + rowIds, + ); + expect(after.map((item) => [item.kind, item.id])).toEqual([ + ["tool", "t1"], + ["tools", "group:t1"], + ]); +}); + +test("two groups never claim the same row id", () => { + const rowIds = new Map(); + groupToolSteps([call("t1"), call("t2")], drawsNothing, rowIds); + const split = groupToolSteps( + [call("t1"), text("between"), call("t2")], + drawsNothing, + rowIds, + ); + expect(split.map((item) => item.id)).toEqual([ + "group:t1", + "between", + "group:t2", + ]); +}); From f11fa7edae1c32ee5c772ff14db5933e4c3a428c Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 29 Oct 2026 09:56:26 -0300 Subject: [PATCH 12/50] Draw gallery cards once and wait for grants instead of refusing --- app/src/lib/copilot/gallery-tools.tsx | 201 ++++++++++++++++---------- app/src/lib/copilot/grant-store.ts | 114 +++++++++++++++ app/tests/gallery-render.test.tsx | 163 +++++++++++++++++++++ app/tests/grant-store.test.ts | 125 ++++++++++++++++ 4 files changed, 528 insertions(+), 75 deletions(-) create mode 100644 app/src/lib/copilot/grant-store.ts create mode 100644 app/tests/gallery-render.test.tsx create mode 100644 app/tests/grant-store.test.ts diff --git a/app/src/lib/copilot/gallery-tools.tsx b/app/src/lib/copilot/gallery-tools.tsx index 7adfbc77bf..296e3576bb 100644 --- a/app/src/lib/copilot/gallery-tools.tsx +++ b/app/src/lib/copilot/gallery-tools.tsx @@ -1,6 +1,6 @@ import { useFrontendTool, useHumanInTheLoop } from "@copilotkit/react-core/v2"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useState, useSyncExternalStore } from "react"; import { RefusedCard } from "@/components/gallery/refused"; import { agentComponentsQueryOptions, @@ -16,6 +16,13 @@ import { galleryManifest, } from "@/lib/copilot/gallery-registry"; import { NOT_SHOWN, NOT_SHOWN_TAIL } from "@openbot/shared/component-markers"; +import { + cardState, + COULDNT_CHECK, + createGrantStore, + type GrantStore, + grantsFrom, +} from "@/lib/copilot/grant-store"; /** * Register compiled gallery components once per name, scoped to the active Bot with `available`. @@ -36,7 +43,19 @@ export function GalleryTools() { // See sandboxed-tools: the placeholder Bot is not fetchable, so the grant query waits for a // surface to declare a real one. const declared = useDeclaredBotId(); - const { data: granted } = useQuery(agentComponentsQueryOptions(declared)); + const grantsQuery = useQuery(agentComponentsQueryOptions(declared)); + const granted = grantsQuery.data; + // One store per mount; every card reads its grants from here (see `grant-store.ts`). + const [store] = useState(createGrantStore); + useEffect(() => { + store.set({ + grants: grantsFrom({ + data: granted, + isError: grantsQuery.isError, + enabled: declared !== undefined, + }), + }); + }, [store, granted, grantsQuery.isError, declared]); const held = useMemo( () => new Map( @@ -52,13 +71,19 @@ export function GalleryTools() { <> {GALLERY_COMPONENTS.map((spec) => spec.kind === "decision" ? ( - + ) : ( ), )} @@ -70,64 +95,17 @@ function GrantedTool({ grantsFor, spec, held, + store, }: { grantsFor: string; spec: GalleryComponent; held: Map; + store: GrantStore; }) { const description = held.get(spec.name); const isHeld = description !== undefined; - - /** - * Refusals decided by the server, keyed by the call they refused. - * - * Per-call keys preserve the original render for calls made before a grant was revoked. - */ - const [refusals, setRefusals] = useState>( - new Map(), - ); - - const Component = spec.Component; - - const render = useCallback( - (props: { - toolCallId?: string; - args?: Record; - result?: string; - }) => { - const refusal = props.toolCallId - ? refusals.get(props.toolCallId) - : undefined; - if (refusal) { - return ; - } - /* - * A stored refusal. The in-memory map above is gone once the page reloads, and a headless turn - * never had one: the server answered the call itself (`server/src/components/headless.ts`). - * The result is what survives, so a call that drew nothing is drawn as the refusal it was. - */ - if (props.result?.startsWith(NOT_SHOWN)) { - const lead = `${NOT_SHOWN}${spec.title}. `; - const reason = ( - props.result.startsWith(lead) - ? props.result.slice(lead.length) - : props.result.slice(NOT_SHOWN.length) - ).replace(NOT_SHOWN_TAIL, ""); - return ; - } - // Render from the polled grant snapshot so revocations show before a new call starts. - if (!isHeld) { - return ( - - ); - } - return ; - }, - [Component, isHeld, refusals, spec.title], - ); + // Created ONCE: the SDK rebuilds a card whenever this identity changes. See `makeGalleryRender`. + const [render] = useState(() => makeGalleryRender(spec, store)); useFrontendTool({ name: spec.name, @@ -149,7 +127,9 @@ function GrantedTool({ const reason = decision.reason ?? "That component is not allowed here."; const id = context?.toolCall?.id; if (id) { - setRefusals((current) => new Map(current).set(id, reason)); + store.set({ + refusals: new Map(store.get().refusals).set(id, reason), + }); } // Led with the same words the card shows, so the transcript and the card agree. return `${NOT_SHOWN}${spec.title}. ${reason}${NOT_SHOWN_TAIL}`; @@ -162,33 +142,70 @@ function GrantedTool({ return null; } +/** + * The card a gallery tool call draws, made once per component. + * + * ONCE, AND THAT IS THE POINT. The SDK hands this function to `ToolCallRenderer` as the component + * type, and remounts the card whenever it changes identity. It used to be a `useCallback` over the + * grants, so grants arriving (they start unknown on every channel) rebuilt every card: drawn as the + * refusal, then torn down and drawn again as the component. Now it reads the grants from the store + * and only re-renders. Exported for its tests. + */ +export function makeGalleryRender(spec: GalleryComponent, store: GrantStore) { + const Component = spec.Component; + return function GalleryRender(props: { + toolCallId?: string; + args?: Record; + result?: string; + status?: string; + }) { + const snapshot = useSyncExternalStore( + store.subscribe, + store.get, + store.get, + ); + const refusal = props.toolCallId + ? snapshot.refusals.get(props.toolCallId) + : undefined; + if (refusal) { + return ; + } + /* + * A stored refusal. The in-memory map above is gone once the page reloads, and a headless turn + * never had one: the server answered the call itself (`server/src/components/headless.ts`). + * The result is what survives, so a call that drew nothing is drawn as the refusal it was. + */ + if (props.result?.startsWith(NOT_SHOWN)) { + const lead = `${NOT_SHOWN}${spec.title}. `; + const reason = ( + props.result.startsWith(lead) + ? props.result.slice(lead.length) + : props.result.slice(NOT_SHOWN.length) + ).replace(NOT_SHOWN_TAIL, ""); + return ; + } + // From the polled grants, so a revocation shows before a new call starts. Unknown draws. + const state = cardState(snapshot, spec.name, spec.title, props.toolCallId); + if (typeof state === "object") { + return ; + } + return ; + }; +} + function GrantedDecision({ spec, held, + store, }: { spec: GalleryComponent; held: Map; + store: GrantStore; }) { const description = held.get(spec.name); const isHeld = description !== undefined; - const Component = spec.Component; - - /** Stable component type so in-progress decision cards do not remount. */ - const Render = useMemo( - () => - function DecisionRender(props: Record) { - if (isHeld) return ; - return ( - Promise) | undefined - } - title={spec.title} - /> - ); - }, - [Component, isHeld, spec.title], - ); + // Created once, for the same reason as `makeGalleryRender`. + const [Render] = useState(() => makeDecisionRender(spec, store)); useHumanInTheLoop({ name: spec.name, @@ -201,6 +218,40 @@ function GrantedDecision({ return null; } +/** + * The card a decision draws, made once per component. + * + * While the grants are unknown it draws the decision without `respond`, which the decision + * components read as "not yet": the person sees what is being asked and cannot answer it until the + * Bot is known to be allowed to ask. Then the same card enables in place. Exported for its tests. + */ +export function makeDecisionRender(spec: GalleryComponent, store: GrantStore) { + const Component = spec.Component; + return function DecisionRender(props: Record) { + const snapshot = useSyncExternalStore( + store.subscribe, + store.get, + store.get, + ); + const state = cardState(snapshot, spec.name, spec.title); + if (state === "draw") return ; + if (state === "wait-for-grant") { + return ; + } + return ( + Promise) | undefined + } + title={spec.title} + /> + ); + }; +} + /** * A decision the Bot was not allowed to ask for. * @@ -209,12 +260,12 @@ function GrantedDecision({ function RefusedDecision({ title, respond, + reason = `${title} is not available to this Bot at the moment, so the person was not asked. An administrator grants components per Bot, and can unpublish one for every Bot at once.`, }: { title: string; respond?: (result: unknown) => Promise; + reason?: string; }) { - const reason = `${title} is not available to this Bot at the moment, so the person was not asked. An administrator grants components per Bot, and can unpublish one for every Bot at once.`; - useEffect(() => { if (!respond) return; void respond(reason).catch(() => { diff --git a/app/src/lib/copilot/grant-store.ts b/app/src/lib/copilot/grant-store.ts new file mode 100644 index 0000000000..3db33889de --- /dev/null +++ b/app/src/lib/copilot/grant-store.ts @@ -0,0 +1,114 @@ +import type { GrantedComponent } from "@/lib/components/queries"; + +/** + * What a gallery card knows about whether its Bot may show it. + * + * THREE STATES, NOT TWO. Grants are fetched per Bot, and until the surface has declared its Bot and + * the answer has come back nobody knows. Reading that as "not held" drew the refusal first and the + * component a moment later, flashing "not available" at somebody who was allowed to see it. So + * unknown is its own state, and a card in it draws as it would if held. Only an answer, or a load + * that failed outright, decides otherwise. + */ +export type Grants = + | { status: "unknown" } + | { status: "known"; held: ReadonlyMap } + | { status: "failed" }; + +export type GrantSnapshot = { + grants: Grants; + /** Refusals the server decided at call time, keyed by the call they refused. */ + refusals: ReadonlyMap; +}; + +/** + * Grants from the polled query. + * + * Known whenever there is data, even if the latest poll failed: a blip in a five-second poll is not + * a reason to take back what was already known. Failed only when nothing ever arrived. + */ +export function grantsFrom(query: { + data?: readonly GrantedComponent[]; + isError: boolean; + enabled: boolean; +}): Grants { + if (query.data !== undefined) { + return { + status: "known", + held: new Map( + query.data.map((component) => [component.name, component.description]), + ), + }; + } + if (query.enabled && query.isError) return { status: "failed" }; + return { status: "unknown" }; +} + +export const NOT_AVAILABLE = (title: string) => + `${title} is not available to this Bot at the moment. An administrator grants components per Bot, and can unpublish one for every Bot at once.`; + +export const COULDNT_CHECK = (title: string) => + `Couldn't check whether this Bot may show ${title}. Try again in a moment.`; + +export type CardState = "draw" | "wait-for-grant" | { refused: string }; + +/** What one card draws, given what is known about its grant and its own call. */ +export function cardState( + snapshot: GrantSnapshot, + name: string, + title: string, + toolCallId?: string, +): CardState { + const refusal = toolCallId ? snapshot.refusals.get(toolCallId) : undefined; + if (refusal !== undefined) return { refused: refusal }; + const { grants } = snapshot; + if (grants.status === "failed") return { refused: COULDNT_CHECK(title) }; + if (grants.status === "unknown") return "wait-for-grant"; + return grants.held.has(name) ? "draw" : { refused: NOT_AVAILABLE(title) }; +} + +function sameGrants(a: Grants, b: Grants): boolean { + if (a.status !== b.status) return false; + if (a.status !== "known" || b.status !== "known") return true; + if (a.held.size !== b.held.size) return false; + for (const [name, description] of a.held) { + if (b.held.get(name) !== description) return false; + } + return true; +} + +/** + * The grants and refusals every gallery card reads, outside React state on purpose. + * + * The SDK remounts a card whenever the render function it was registered with changes identity, so + * that function is created once and cannot close over grants that change. It subscribes here with + * `useSyncExternalStore` instead, and a change re-renders the card rather than rebuilding it. + */ +export function createGrantStore() { + let snapshot: GrantSnapshot = { + grants: { status: "unknown" }, + refusals: new Map(), + }; + const listeners = new Set<() => void>(); + + return { + get: (): GrantSnapshot => snapshot, + set(next: Partial) { + const grants = + next.grants && !sameGrants(next.grants, snapshot.grants) + ? next.grants + : snapshot.grants; + const refusals = next.refusals ?? snapshot.refusals; + if (grants === snapshot.grants && refusals === snapshot.refusals) return; + snapshot = { grants, refusals }; + for (const listener of listeners) listener(); + }, + subscribe(listener: () => void) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + }; +} + +export type GrantStore = ReturnType; diff --git a/app/tests/gallery-render.test.tsx b/app/tests/gallery-render.test.tsx new file mode 100644 index 0000000000..042ed0c604 --- /dev/null +++ b/app/tests/gallery-render.test.tsx @@ -0,0 +1,163 @@ +import { afterAll, afterEach, beforeAll, expect, test } from "bun:test"; +import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { act, cleanup, render } from "@testing-library/react"; +import { useEffect } from "react"; +import { + makeDecisionRender, + makeGalleryRender, +} from "@/lib/copilot/gallery-tools"; +import { createGrantStore } from "@/lib/copilot/grant-store"; +import type { GalleryComponent } from "@/lib/copilot/gallery-registry"; +import { settleReactWork } from "./settle-react-work"; + +/** + * A card a Bot draws is created ONCE and grows in place. The SDK remounts a card whenever the render + * function it was registered with changes identity, so the render is made once per component and + * reads the grants from a store: grants arriving or changing re-render the card instead of tearing + * it down. Mounts are counted with an effect that runs once per mount. + */ + +beforeAll(() => { + GlobalRegistrator.register({ url: "http://localhost/" }); +}); + +afterEach(() => { + cleanup(); +}); + +afterAll(async () => { + await settleReactWork(); + GlobalRegistrator.unregister(); +}); + +let mounts = 0; +let lastRespond: unknown = "unset"; + +function Counted(props: { title?: string }) { + useEffect(() => { + mounts += 1; + }, []); + return

{props.title ?? "untitled"}

; +} + +function CountedDecision(props: { + args?: { title?: string }; + respond?: unknown; +}) { + useEffect(() => { + mounts += 1; + }, []); + lastRespond = props.respond; + return

{props.args?.title ?? "untitled"}

; +} + +const spec = (Component: GalleryComponent["Component"]) => + ({ + name: "chart", + title: "Bar chart", + kind: "chart", + description: "A chart.", + parameters: {}, + Component, + }) as unknown as GalleryComponent; + +const heldGrants = { + status: "known" as const, + held: new Map([["chart", "A chart."]]), +}; + +afterEach(() => { + mounts = 0; + lastRespond = "unset"; +}); + +test("waits without refusing while grants are unknown", () => { + const Render = makeGalleryRender(spec(Counted), createGrantStore()); + const view = render( + , + ); + + expect(view.getByText("Revenue")).toBeTruthy(); + expect(view.queryByText(/not available/)).toBeNull(); +}); + +test("mounts once while grants load and args stream", () => { + const store = createGrantStore(); + const Render = makeGalleryRender(spec(Counted), store); + const view = render( + , + ); + + act(() => store.set({ grants: heldGrants })); + view.rerender( + , + ); + + expect(view.getByText("Revenue")).toBeTruthy(); + expect(mounts).toBe(1); +}); + +test("refuses once grants say not held", () => { + const store = createGrantStore(); + store.set({ grants: { status: "known", held: new Map() } }); + const Render = makeGalleryRender(spec(Counted), store); + const view = render( + , + ); + + expect(view.getByText(/not available to this Bot/)).toBeTruthy(); +}); + +test("refuses with the couldn't-check reason when grants failed", () => { + const store = createGrantStore(); + store.set({ grants: { status: "failed" } }); + const Render = makeGalleryRender(spec(Counted), store); + const view = render( + , + ); + + expect( + view.getByText(/Couldn't check whether this Bot may show Bar chart/), + ).toBeTruthy(); +}); + +test("an earlier call keeps drawing after a revoke while a new call is refused", () => { + const store = createGrantStore(); + store.set({ + grants: heldGrants, + refusals: new Map([["call-2", "That component was just switched off."]]), + }); + const Render = makeGalleryRender(spec(Counted), store); + const view = render( + <> + + + , + ); + + expect(view.getByText("Earlier")).toBeTruthy(); + expect(view.queryByText("Later")).toBeNull(); + expect(view.getByText(/just switched off/)).toBeTruthy(); +}); + +test("a decision waits disabled, then enables in place", () => { + const store = createGrantStore(); + const Render = makeDecisionRender(spec(CountedDecision), store); + const respond = async () => {}; + const view = render( + , + ); + + expect(lastRespond).toBeUndefined(); + act(() => store.set({ grants: heldGrants })); + view.rerender( + , + ); + + expect(lastRespond).toBe(respond); + expect(mounts).toBe(1); +}); diff --git a/app/tests/grant-store.test.ts b/app/tests/grant-store.test.ts new file mode 100644 index 0000000000..f93d3a64a7 --- /dev/null +++ b/app/tests/grant-store.test.ts @@ -0,0 +1,125 @@ +import { expect, mock, test } from "bun:test"; +import { + cardState, + COULDNT_CHECK, + createGrantStore, + type GrantSnapshot, + grantsFrom, + NOT_AVAILABLE, +} from "@/lib/copilot/grant-store"; + +/** + * What a gallery card knows about whether its Bot may show it. "Not known yet" is not "refused": + * a card that drew the refusal while the grants were still on their way, and then the component, + * flashed "not available" at somebody who was allowed to see it. + */ + +const held = (...names: string[]) => + new Map(names.map((name) => [name, `${name} description`])); + +test("grants are unknown while there is no Bot to ask about", () => { + expect(grantsFrom({ isError: false, enabled: false })).toEqual({ + status: "unknown", + }); +}); + +test("grants are unknown while the first answer is on its way", () => { + expect(grantsFrom({ isError: false, enabled: true })).toEqual({ + status: "unknown", + }); +}); + +test("grants are known once they arrive", () => { + expect( + grantsFrom({ + data: [{ name: "chart", description: "d" }], + isError: false, + enabled: true, + }), + ).toEqual({ status: "known", held: new Map([["chart", "d"]]) }); +}); + +test("grants that could not be loaded at all have failed", () => { + expect(grantsFrom({ isError: true, enabled: true })).toEqual({ + status: "failed", + }); +}); + +test("a failed poll keeps the grants already known", () => { + expect( + grantsFrom({ + data: [{ name: "chart", description: "d" }], + isError: true, + enabled: true, + }), + ).toEqual({ status: "known", held: new Map([["chart", "d"]]) }); +}); + +function snapshot(overrides: Partial = {}): GrantSnapshot { + return { + grants: { status: "unknown" }, + refusals: new Map(), + ...overrides, + }; +} + +test("a card waits, never refuses, while grants are unknown", () => { + expect(cardState(snapshot(), "chart", "Bar chart")).toBe("wait-for-grant"); +}); + +test("a held component draws", () => { + expect( + cardState( + snapshot({ grants: { status: "known", held: held("chart") } }), + "chart", + "Bar chart", + ), + ).toBe("draw"); +}); + +test("a component the Bot does not hold is refused", () => { + expect( + cardState( + snapshot({ grants: { status: "known", held: held() } }), + "chart", + "Bar chart", + ), + ).toEqual({ refused: NOT_AVAILABLE("Bar chart") }); +}); + +test("grants that failed refuse with the couldn't-check reason", () => { + expect( + cardState(snapshot({ grants: { status: "failed" } }), "chart", "Bar chart"), + ).toEqual({ refused: COULDNT_CHECK("Bar chart") }); + expect(COULDNT_CHECK("Bar chart")).toBe( + "Couldn't check whether this Bot may show Bar chart. Try again in a moment.", + ); +}); + +test("a refusal for this call wins over a grant", () => { + expect( + cardState( + snapshot({ + grants: { status: "known", held: held("chart") }, + refusals: new Map([["call-2", "Not in this channel."]]), + }), + "chart", + "Bar chart", + "call-2", + ), + ).toEqual({ refused: "Not in this channel." }); +}); + +test("the store tells its listeners about a change and not about a no-op", () => { + const store = createGrantStore(); + const listener = mock(() => {}); + store.subscribe(listener); + + store.set({ grants: { status: "unknown" } }); + expect(listener).toHaveBeenCalledTimes(0); + + const before = store.get(); + store.set({ grants: { status: "failed" } }); + expect(listener).toHaveBeenCalledTimes(1); + expect(store.get()).not.toBe(before); +}); From 76745492d0fc9d3982766ad1d58e386ae8b0e518 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 29 Oct 2026 09:57:03 -0300 Subject: [PATCH 13/50] Let a card that failed on half-sent arguments try again on the next chunk --- .../components/channels/chat-transcript.tsx | 6 +- app/src/components/channels/tool-boundary.tsx | 28 +++++- app/tests/tool-boundary.test.tsx | 86 +++++++++++++++++++ 3 files changed, 116 insertions(+), 4 deletions(-) create mode 100644 app/tests/tool-boundary.test.tsx diff --git a/app/src/components/channels/chat-transcript.tsx b/app/src/components/channels/chat-transcript.tsx index a16b5b1aed..f8290960d5 100644 --- a/app/src/components/channels/chat-transcript.tsx +++ b/app/src/components/channels/chat-transcript.tsx @@ -1335,7 +1335,11 @@ const ToolCallBody = memo(function ToolCallBody({ }); return ( - + {/* * A TOOL WITH NO REGISTERED RENDERER STILL HAPPENED, and since tools moved to the server * that is now the ordinary case rather than the exception: MCP tools execute in the runtime diff --git a/app/src/components/channels/tool-boundary.tsx b/app/src/components/channels/tool-boundary.tsx index 73a9637c01..94d2fc62fa 100644 --- a/app/src/components/channels/tool-boundary.tsx +++ b/app/src/components/channels/tool-boundary.tsx @@ -8,16 +8,38 @@ import { Component, type ReactNode } from "react"; * as a user-readable failure line; stacks stay in the developer console. */ export class ToolRenderBoundary extends Component< - { name: string; children: ReactNode }, - { failed: boolean } + { + name: string; + children: ReactNode; + /** + * Changes when the call's arguments do. A card that failed on arguments still streaming gets + * another chance with the next chunk, because half an argument list is often what it choked on. + */ + resetKey?: unknown; + /** The call is complete: a failure now is about the arguments themselves, and it stays. */ + final?: boolean; + }, + { failed: boolean; failedFinal: boolean; key: unknown } > { - state = { failed: false }; + state = { failed: false, failedFinal: false, key: undefined as unknown }; static getDerivedStateFromError() { return { failed: true }; } + static getDerivedStateFromProps( + props: { resetKey?: unknown }, + state: { failed: boolean; failedFinal: boolean; key: unknown }, + ) { + if (props.resetKey === state.key) return null; + if (state.failed && !state.failedFinal && props.resetKey !== undefined) { + return { key: props.resetKey, failed: false }; + } + return { key: props.resetKey }; + } + componentDidCatch(error: unknown) { + this.setState({ failedFinal: Boolean(this.props.final) }); // Keep stack details out of the conversation while preserving them for component authors. console.error( "[gallery] a component failed to render", diff --git a/app/tests/tool-boundary.test.tsx b/app/tests/tool-boundary.test.tsx new file mode 100644 index 0000000000..6da521d9f7 --- /dev/null +++ b/app/tests/tool-boundary.test.tsx @@ -0,0 +1,86 @@ +import { afterAll, afterEach, beforeAll, expect, spyOn, test } from "bun:test"; +import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { cleanup, render } from "@testing-library/react"; +import { ToolRenderBoundary } from "@/components/channels/tool-boundary"; +import { settleReactWork } from "./settle-react-work"; + +/** + * A card drawn from a tool call that is still streaming can throw on arguments that are only half + * there, and be perfectly drawable a chunk later. The boundary gives it that chance; only a throw on + * the complete arguments is final. + */ + +beforeAll(() => { + GlobalRegistrator.register({ url: "http://localhost/" }); +}); + +afterEach(() => { + cleanup(); +}); + +afterAll(async () => { + await settleReactWork(); + GlobalRegistrator.unregister(); +}); + +function Fragile({ args }: { args: string }) { + if (args === "bad" || args === "still bad") throw new Error("half-sent"); + return

drawn {args}

; +} + +const FALLBACK = /Couldn't show/; + +test("recovers on the next chunk", () => { + const quiet = spyOn(console, "error").mockImplementation(() => {}); + const view = render( + + + , + ); + expect(view.getByText(FALLBACK)).toBeTruthy(); + + view.rerender( + + + , + ); + + expect(view.getByText("drawn good")).toBeTruthy(); + quiet.mockRestore(); +}); + +test("stays failed once the call is complete", () => { + const quiet = spyOn(console, "error").mockImplementation(() => {}); + const view = render( + + + , + ); + + view.rerender( + + + , + ); + + expect(view.getByText(FALLBACK)).toBeTruthy(); + quiet.mockRestore(); +}); + +test("no reset key keeps today's behaviour", () => { + const quiet = spyOn(console, "error").mockImplementation(() => {}); + const view = render( + + + , + ); + + view.rerender( + + + , + ); + + expect(view.getByText(FALLBACK)).toBeTruthy(); + quiet.mockRestore(); +}); From 8ba8a7d751956ed588e7c2fc16c23d25491de79f Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 29 Oct 2026 09:58:51 -0300 Subject: [PATCH 14/50] Keep gallery items in place while their labels stream, and hold a place for missing numbers --- app/src/components/gallery/cards.tsx | 18 +++-- app/src/components/gallery/charts.tsx | 74 ++++++++++++------ app/src/components/gallery/decisions.tsx | 5 +- app/src/components/gallery/positions.ts | 12 +++ app/src/components/gallery/table.tsx | 9 ++- app/tests/gallery-streaming.test.tsx | 98 ++++++++++++++++++++++++ 6 files changed, 177 insertions(+), 39 deletions(-) create mode 100644 app/src/components/gallery/positions.ts create mode 100644 app/tests/gallery-streaming.test.tsx diff --git a/app/src/components/gallery/cards.tsx b/app/src/components/gallery/cards.tsx index 8e60b1d633..eb871fc078 100644 --- a/app/src/components/gallery/cards.tsx +++ b/app/src/components/gallery/cards.tsx @@ -1,6 +1,7 @@ import { z } from "zod"; import type { GalleryComponent } from "@/lib/copilot/gallery-registry"; import { Badge, GalleryFrame, type Tone } from "./frame"; +import { byPosition } from "./positions"; const tone = z .enum(["neutral", "positive", "caution", "negative"]) @@ -43,8 +44,9 @@ export function RecordCard({ title={title} >
- {fields.map((field) => ( -
+ {byPosition(fields).map(({ item: field, key }) => ( + // Keyed by position: a label is still being typed while the card draws. +
{field.label}
{/* Field values wrap because truncation can hide the reported data. */}
{field.value}
@@ -85,8 +87,8 @@ export function MetricsCard({ return (
- {metrics.map((metric) => ( -
+ {byPosition(metrics).map(({ item: metric, key }) => ( +

{metric.label}

@@ -148,8 +150,8 @@ export function ChecklistCard({ title={title} >