diff --git a/CHANGELOG.md b/CHANGELOG.md index b2a72a7109..27745c6f84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -122,6 +122,17 @@ for its default of thirty minutes, and `browserIdleMs: 0` (keep browsers open) w of these settings now also reach the computers `computers.mode: sandbox` makes, which had none of them. +### A conversation opens at the bottom and stays still while a Bot writes + +Opening a conversation shows its newest messages at once, already at the bottom, instead of loading +and then gliding down; a long one draws older messages as somebody scrolls up to them, without +moving what they are reading. Charts, tables, cards and decisions a Bot draws grow in place while +they stream, and no longer flash "not available" before they appear. A reply is revealed at a steady +pace, and the view follows it until the person scrolls up or selects some of it, and picks it up +again when they come back to the end or press the arrow. A rail beside a conversation has a mark +per turn: hovering one previews what was asked and answered, and choosing it goes there. Nothing +changes for a deployment. + ### Generated interfaces use A2UI 1.0 With generative UI on, a Bot builds an interface from the A2UI 1.0 basic components instead of the diff --git a/app/src/components/channels/channel-chat.tsx b/app/src/components/channels/channel-chat.tsx index 43f187a1dd..317647c970 100644 --- a/app/src/components/channels/channel-chat.tsx +++ b/app/src/components/channels/channel-chat.tsx @@ -91,6 +91,48 @@ 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, + voiceWaitedOut = false, +}: { + restoring: boolean; + seeded: boolean; + voicePending: boolean; + /** The voice chats have had their `VOICE_WAIT_MS` since the thread came in. */ + voiceWaitedOut?: boolean; +}): boolean { + return restoring || (!seeded && voicePending && !voiceWaitedOut); +} + +/** + * How long, once the thread is in, the conversation waits for its voice chats before showing + * without them. A slow or failing voice service must not hold up text chat; cards that arrive after + * this join the transcript where they belong. + */ +export const VOICE_WAIT_MS = 1500; + +/** Whether `VOICE_WAIT_MS` has passed since `waiting` became true. Reset whenever it is false. */ +function useWaitedOut(waiting: boolean, key: string): boolean { + const [waitedOut, setWaitedOut] = useState(null); + useEffect(() => { + if (!waiting) return; + const timer = window.setTimeout(() => setWaitedOut(key), VOICE_WAIT_MS); + return () => window.clearTimeout(timer); + }, [waiting, key]); + return waiting && waitedOut === key; +} + export function channelHistoryNotice({ restoring, messageCount, @@ -549,8 +591,24 @@ export function ChannelChat({ useActiveBot(runtimeAgentId); const skillCommands = useSkillCommands(runtimeAgentId); - const historyNotice = channelHistoryNotice({ + /* + * 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 voiceWaitedOut = useWaitedOut( + !restoring && seed === null && voiceArchive.pending, + channel.id, + ); + const historyLoading = historyIsLoading({ restoring, + seeded: seed !== null, + voicePending: voiceArchive.pending, + voiceWaitedOut, + }); + const historyNotice = channelHistoryNotice({ + restoring: historyLoading, messageCount: agent.messages.length, lastMessageAt: channel.lastMessageAt, historyAvailability, @@ -1091,7 +1149,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/components/channels/chat-messages.ts b/app/src/components/channels/chat-messages.ts index 88454bda77..096cc5c3e8 100644 --- a/app/src/components/channels/chat-messages.ts +++ b/app/src/components/channels/chat-messages.ts @@ -320,8 +320,7 @@ function isReadableMessage(message: unknown): message is Readonly { * row it builds is keyed on the id". `message.id` is keyed on harder and was checked nowhere: it * becomes `VisibleChatItem.id`, which is DECLARED `string`, and downstream that one value is the * React key, `MessageScrollerItem`'s `messageId`, the turn-grouping key `anchorRowIds` and `turnOf` - * cut apart at the last colon, and the memo key `createFirstPaintDelays` hangs an entrance delay - * off. + * cut apart at the last colon, and the key `openingStagger` hangs a row's entrance delay off. * * IT SURVIVED BECAUSE IT DOES NOT THROW — the same reason `readAttachmentMetadata` gives for its * own fields having gone unchecked, and the claim there that `metadata` was "the last unchecked @@ -419,7 +418,12 @@ export function toVisibleChatItems( kind: "tool", // One assistant message can carry multiple tool calls. id: toolCall.id, - toolCall, + /* + * A copy, not the agent's object: the client appends argument chunks to the call it + * already handed over, so a row comparing the call it drew with the next one would be + * comparing an object with itself and never see the arguments grow. + */ + toolCall: { ...toolCall, function: { ...toolCall.function } }, ...(results.has(toolCall.id) ? { result: results.get(toolCall.id) } : {}), diff --git a/app/src/components/channels/chat-transcript.tsx b/app/src/components/channels/chat-transcript.tsx index 49379f6a37..ba7002775d 100644 --- a/app/src/components/channels/chat-transcript.tsx +++ b/app/src/components/channels/chat-transcript.tsx @@ -1,9 +1,11 @@ import type { ActivityMessage, Message } from "@ag-ui/core"; import type { Attachment } from "@copilotkit/react-core/v2"; import { + useCopilotKit, useRenderActivityMessage, useRenderToolCall, } from "@copilotkit/react-core/v2"; +import { useQuery } from "@tanstack/react-query"; import { readConnectPrompt, SHOW_APP_ACCESS_TOOL, @@ -18,7 +20,10 @@ import { } from "@tabler/icons-react"; import { motion, useReducedMotion } from "motion/react"; import { + createContext, memo, + useCallback, + useContext, useEffect, useLayoutEffect, useMemo, @@ -26,6 +31,7 @@ import { useState, } from "react"; import { Streamdown } from "streamdown"; +import { ArgumentsArrivingContext } from "@/components/gallery/streaming"; import { Bubble, BubbleContent } from "@/components/ui/bubble"; import { Dialog, @@ -46,7 +52,6 @@ import { MessageScrollerItem, MessageScrollerProvider, MessageScrollerViewport, - useMessageScroller, } from "@/components/ui/message-scroller"; import { Skeleton } from "@/components/ui/skeleton"; import { attachmentUrl } from "@/lib/channels/attachments"; @@ -66,7 +71,9 @@ import { import { readTeamBotConsent } from "@/lib/team-bots"; import { cn } from "@/lib/utils"; import { VOICE_CHAT_ACTIVITY, type VoiceChatEntry } from "@/lib/voice/archive"; +import { deploymentCapabilitiesQueryOptions } from "@/lib/deployment/queries"; import { AppAccessCard } from "./app-access-card"; +import { isBrowserStep } from "./browser-activity"; import { attachmentModality, type SentAttachment, @@ -74,15 +81,28 @@ import { } from "./chat-messages"; import { splitChatOrigin } from "./chat-origin"; import type { QueuedMessage } from "./composer"; +import { FollowScroll, RESUME_PX, useFollowIntent } from "./follow-intent"; +import { LateRenderersContext, LateRenderersWatch } from "./late-renderers"; +import { usePacedReveal } from "./paced-reveal"; import { TeamBotConsentCard } from "./team-bot-consent"; import { dropRepeatedAppCards, groupToolSteps, ToolActivity, + type ToolGroup, type TranscriptItem, useDrawnTool, } from "./tool-activity"; -import { ToolRenderBoundary } from "./tool-boundary"; +import { ToolRenderBoundary, toolCallFinal } from "./tool-boundary"; +import { shouldDrawEarlier, useTranscriptPages } from "./transcript-pages"; +import { TranscriptRail } from "./transcript-rail"; +import { + AT_END_PX, + currentTurn, + isPersonSentMessage, + type Turn, + turnsOf, +} from "./transcript-turns"; import { ToolLine } from "./tool-line"; import { VoiceChatCard } from "./voice-chat-card"; @@ -411,65 +431,116 @@ function Queued({ * alternative is threading a ref out through three components with no other reason to know a * scroller exists. */ -function ScrollNewestQueuedIntoView({ newest }: { newest: string | null }) { - const { scrollToEnd } = useMessageScroller(); - +function ScrollNewestQueuedIntoView({ + newest, + onQueued, +}: { + newest: string | null; + onQueued: () => void; +}) { useEffect(() => { if (newest === null) { return; } - scrollToEnd(); - }, [newest, scrollToEnd]); + // Through the follow state, so the view is told to follow before it is sent to the end. + onQueued(); + }, [newest, onQueued]); return null; } /** - * How many of the newest turns cascade when a channel is opened, and how far apart. - * - * 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. + * How many of the newest rows render in when a conversation opens, and how far apart. * - * 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. + * 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 FIRST_PAINT_STAGGER_COUNT = 12; -const FIRST_PAINT_STAGGER_SECONDS = 0.04; +const OPENING_STAGGER_COUNT = 8; +const OPENING_STAGGER_MS = 30; /** - * Decide, once per message, whether it waits its turn. + * The opening stagger, decided once from the rows present when the conversation opens. * - * 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. + * 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), + ); +} + +/** Where a turn jumped to from the rail sits below the top of the view. */ +const TURN_INSET_PX = 24; + +/** + * Since when a row mounting now is arriving live, rather than having been there when the + * conversation opened: null while it is not. + * + * A ref, not state: it is read once, at a row's first render, and setting it must not re-render + * anything. `ArrivalsLive` provides a fresh one with the scroller, so it is null for the commit that + * mounts the whole history and set to the time in the effect after it; every row mounted from then + * on is something that arrived while the person was here. The time is for a row that can say when + * what it shows happened (see `useArrivedLive`). + */ +const ArrivalsLiveContext = createContext | null>(null); + +/* + * For rows drawn later but older than everything on screen: an earlier page added above as the + * reader scrolls up (`useTranscriptPages`). They are history, so they draw at rest. + */ +const AT_REST: React.RefObject = { current: null }; + +function ArrivalsLive({ children }: { children: React.ReactNode }) { + const live = useRef(null); + useEffect(() => { + live.current = Date.now(); + }, []); + return ( + + {children} + + ); +} + +/** + * Whether this row arrived while the conversation was open, decided once at its first render (see + * `ArrivalsLiveContext`), so a reply streaming into it can never change the answer. * - * `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. + * `happenedAt` is for a row that is late rather than new: a voice chat from before the conversation + * opened, when the voice service answered after the conversation was already showing. It mounts + * among rows on screen but is history, and draws at rest. */ -function createFirstPaintDelays() { - const decided = new Map(); - let settled = false; +function useArrivedLive(happenedAt?: string): boolean { + const liveRef = useContext(ArrivalsLiveContext); + const [live] = useState(() => { + if (liveRef === null) return true; + const since = liveRef.current; + if (since === null) return false; + return happenedAt === undefined || !(Date.parse(happenedAt) < since); + }); + return live; +} - 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; - }, - }; +/** + * How Streamdown draws a Bot's message, decided once when the message mounts. History is finished + * text and takes the static renderer, which does no repair of half-written markdown; anything that + * arrived live, or was still streaming when the conversation opened, takes the streaming one and + * keeps it after it finishes, so the renderer never changes under the reader. Exported for its test. + */ +export function messageMode( + live: boolean, + streaming: boolean, +): "static" | "streaming" { + return live || streaming ? "streaming" : "static"; } /** @@ -491,19 +562,20 @@ 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. 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, - delay = 0, + happenedAt, }: { children: React.ReactNode; - delay?: number; + /** When what the row shows happened, if it can say; see `useArrivedLive`. */ + happenedAt?: string; }) { const shouldReduceMotion = useReducedMotion(); + const live = useArrivedLive(happenedAt); return ( messageMode(live, streaming)); + // A person's message arrives whole; only a Bot's reply is paced. + const revealed = usePacedReveal(text, { + live: live && !isUser, + streaming, + }); /* * Checked before anything else a person's message gets. A firing is not a person's message: the * chip split, the end alignment and the bubble are all wrong for it, and each one of them would @@ -654,7 +735,7 @@ const TranscriptMessage = memo(function TranscriptMessage({ return ( - + @@ -670,7 +751,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 @@ -721,7 +802,9 @@ const TranscriptMessage = memo(function TranscriptMessage({ * closing token arrives, so the answer visibly rewrites itself as it lands. This * closes them for the duration. */ - {text} + + {revealed} + )} @@ -752,7 +835,6 @@ const ATTACHMENT_URL_PREFIX = attachmentUrl(""); */ type AttachmentRowProps = { attachments: readonly SentAttachment[]; - delay: number; }; /** @@ -778,9 +860,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; } @@ -800,12 +879,11 @@ export function sameAttachmentRow( export const TranscriptAttachments = memo(function TranscriptAttachments({ attachments, - delay, }: AttachmentRowProps) { return ( - + @@ -1269,23 +1347,31 @@ 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(); - const drawn = - message.activityType === VOICE_CHAT_ACTIVITY ? ( - - ) : ( - renderActivityMessage(message) - ); + /* + * Drawn before generative UI was switched on (a cold load: the capabilities answer or the runtime + * connection still on its way), an activity found no renderer. Read so it redraws when the + * renderers arrive, rather than staying blank until its own message next changes; see + * `LateRenderersWatch`, which the transcript mounts once for all of them. + */ + useContext(LateRenderersContext); + const call = + message.activityType === VOICE_CHAT_ACTIVITY + ? (message.content.call as VoiceChatEntry) + : null; + const drawn = call ? ( + + ) : ( + renderActivityMessage(message) + ); if (!drawn) return null; return ( - + {drawn} @@ -1303,8 +1389,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({ - delay, +const ToolCallBody = memo(function ToolCallBody({ toolCallId, name, args, @@ -1312,7 +1397,6 @@ const TranscriptToolCall = memo(function TranscriptToolCall({ latest, spokeAfter, }: { - delay: number; toolCallId: string; name: string; args: string; @@ -1352,38 +1436,111 @@ 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 ?? ( - - )} - - - + + + {/* + * 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 tool calls, drawn by ONE component whatever it holds, so a row is never rebuilt as its + * calls change. A lone call is the line or card it always was; a run, or a browser step, is the + * folded group. Both a `tools` group and a single `tool` item come through here, because a lone call + * can be either over its life (it stops folding once its answer asks the person) under one key; see + * `groupToolSteps`. + */ +type ToolRowProps = { + id: string; + steps: ToolGroup["steps"]; + active: boolean; + latest: boolean; + /** The Bot said something after this row in its turn. See `ToolPlace`. */ + spokeAfter: boolean; +}; + +/** + * Whether a tool row would draw the same thing. `groupToolSteps` builds new step objects and a new + * array on every render (it has to: the agent mutates the messages in place), so the default memo + * check saw a change on every token of a reply streaming below and redrew every finished tool group + * in the history, markdown inside collapsed details included. What a row draws is its steps' calls + * and results, and those are what is compared. Exported for its test. + */ +export function sameToolRow(a: ToolRowProps, b: ToolRowProps): boolean { + return ( + a.id === b.id && + a.active === b.active && + a.latest === b.latest && + a.spokeAfter === b.spokeAfter && + a.steps.length === b.steps.length && + a.steps.every((step, index) => { + const other = b.steps[index]; + return ( + other !== undefined && + step.id === other.id && + step.result === other.result && + step.toolCall.function.name === other.toolCall.function.name && + step.toolCall.function.arguments === other.toolCall.function.arguments + ); + }) + ); +} + +const ToolRow = memo(function ToolRow({ + id, + steps, + active, + latest, + spokeAfter, +}: ToolRowProps) { + const [only] = steps; + return ( + + {steps.length === 1 && only && !isBrowserStep(only) ? ( + + + + ) : ( + + )} + + ); +}, sameToolRow); + /** * A tool the runtime executed, drawn for the person watching. * * Named from the reader's side: what was done, against which server, with the server's own words * behind a disclosure. The identifier the model was offered never reaches the screen. * - * Exported for its tests, which draw it without the CopilotKit provider `TranscriptToolCall` needs. + * Exported for its tests, which draw it without the CopilotKit provider `ToolCallBody` needs. */ export function ServerToolLine({ name, @@ -1453,23 +1610,8 @@ export function ServerToolLine({ ); } -/** - * Whether a text item is the person actually sending something, as opposed to a routine firing that - * merely arrived wearing `role: "user"`. - * - * A FIRING IS NOT THE PERSON SPEAKING — `TranscriptMessage` already knows that and draws it as - * `RoutineFiring` rather than as their bubble, via the same `readFiring` check used here. The scroll - * machinery below was the one place left that had not caught up: it smooth-scrolled and anchored on - * `role === "user"` alone, so a routine firing while somebody was reading back through the channel - * yanked their viewport to the bottom as though they had just typed and sent something. They hadn't; - * the schedule had. Exported so this can be checked without mounting anything. - */ -export function isPersonSentMessage( - role: "user" | "assistant", - text: string, -): boolean { - return role === "user" && readFiring(text) === null; -} +/* Moved beside the turns it also decides; re-exported for the callers that read it from here. */ +export { isPersonSentMessage }; /** * WHERE THE CONVERSATION HAS GOT TO, for an app card that may carry its task on once the app is @@ -1563,24 +1705,77 @@ 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, + onSend: () => void, ) { const reducedMotion = useReducedMotion(); const seenRef = useRef(null); + /* + * Whether the conversation has been seen open since it was restored. Separate from `seenRef`: + * the glide needs a previous message to compare with, but a send does not. In a routine's channel + * that holds only firings, the person's first message has nothing before it and is still a send. + */ + const openRef = useRef(false); useLayoutEffect(() => { const element = viewport.current; const previous = seenRef.current; - seenRef.current = newestUserMessageId; + const wasOpen = openRef.current; + // Nothing is recorded while restoring, so the first id after it is a baseline (see above). + seenRef.current = restoring ? null : newestUserMessageId; + openRef.current = !restoring; + + // A send picks following back up whatever the motion setting; only the glide depends on it. + if ( + wasOpen && + !restoring && + newestUserMessageId !== null && + newestUserMessageId !== previous + ) { + onSend(); + } if ( element === null || - newestUserMessageId === null || - previous === null || - previous === newestUserMessageId || - reducedMotion + !shouldGlideOnSend({ + previous, + next: newestUserMessageId, + restoring, + reducedMotion: reducedMotion ?? false, + }) ) { return; } @@ -1594,7 +1789,24 @@ function useSmoothSendScroll( window.clearTimeout(timer); element.style.scrollBehavior = ""; }; - }, [newestUserMessageId, reducedMotion, viewport]); + }, [newestUserMessageId, onSend, reducedMotion, restoring, viewport]); +} + +/** The transcript's one `LateRenderersWatch`, with what it needs from the provider and the server. */ +function TranscriptRenderersWatch({ + onVersion, +}: { + onVersion: (version: number) => void; +}) { + const { copilotkit } = useCopilotKit(); + const { data: capabilities } = useQuery(deploymentCapabilitiesQueryOptions()); + return ( + + ); } export function ChatTranscript({ @@ -1618,9 +1830,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, ); /* @@ -1657,7 +1872,23 @@ export function ChatTranscript({ */ item.kind === "attachments", )?.id ?? null; - useSmoothSendScroll(viewportRef, newestUserMessageId); + /* Whether the view follows a streaming reply; see `follow-intent`. */ + const follow = useFollowIntent(viewportRef, restoring); + const { dispatch: dispatchFollow } = follow; + const followOnSend = useCallback( + () => dispatchFollow({ type: "send" }), + [dispatchFollow], + ); + const followOnQueued = useCallback( + () => dispatchFollow({ type: "queued" }), + [dispatchFollow], + ); + useSmoothSendScroll( + viewportRef, + newestUserMessageId, + restoring, + followOnSend, + ); /* Which tool calls are still the latest thing, for an app card: see `latestFrom`. */ const latestIndex = latestFrom(items, { busy, queued: queued.length }); @@ -1670,174 +1901,423 @@ export function ChatTranscript({ 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. + * 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 delaysRef = useRef | null>( - null, - ); - if (delaysRef.current === null) { - delaysRef.current = createFirstPaintDelays(); + const openingRef = useRef | null>(null); + if (restoring) { + openingRef.current = null; + } else if (openingRef.current === null) { + openingRef.current = openingStagger(items.map((item) => item.id)); } - const delays = delaysRef.current; + const opening = openingRef.current; + const openingProps = (id: string) => { + const delay = opening?.get(id); + return delay === undefined + ? {} + : { + className: "history-arrive", + style: { "--arrive-delay": delay } as React.CSSProperties, + }; + }; + + /* Which rows are drawn; see `useTranscriptPages`. */ + const pages = useTranscriptPages( + items.map((item) => item.id), + restoring, + ); + const { drawEarlier, drawTo, from: pagesFrom } = pages; + const drawEarlierNearTop = useCallback( + (from: number) => { + const element = viewportRef.current; + if ( + element !== null && + shouldDrawEarlier({ + from, + scrollTop: element.scrollTop, + scrollHeight: element.scrollHeight, + }) + ) { + drawEarlier(); + } + }, + [drawEarlier], + ); + /* + * After every page: a page that does not reach LOAD_MARGIN_PX above the fold (short rows, a tall + * screen) draws the one before it. ON THE NEXT FRAME, NOT IN THIS COMMIT. The scroller puts the + * reader back after a prepend from a MutationObserver callback, which runs after layout effects; + * checked here, a reader at the very top (where the browser has nothing to anchor against) still + * read `scrollTop` 0, drew another page, read 0 again, and drew the whole history in one burst, + * which past about fifty pages is more nested updates than React allows. A frame later the + * position is the restored one, and a page is only drawn if the reader really is still near the + * top. Each extra page lands above the fold and is held in place, so the wait is never seen. + */ + useLayoutEffect(() => { + if (restoring) return; + const frame = window.requestAnimationFrame(() => + drawEarlierNearTop(pagesFrom), + ); + return () => window.cancelAnimationFrame(frame); + }, [pagesFrom, restoring, drawEarlierNearTop]); + + /* Activities that may be waiting on their renderers; see `LateRenderersWatch`. */ + const [renderersVersion, setRenderersVersion] = useState(0); + const hasActivities = items.some( + (item) => + item.kind === "activity" && + item.message.activityType !== VOICE_CHAT_ACTIVITY, + ); + /* The rail's marks, and which turn is being read; see `TranscriptRail`. */ + const turns = turnsOf(items); + const turnIds = turns.map((turn) => turn.id).join("\n"); + const [activeTurn, setActiveTurn] = useState(null); /* - * 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. + * Read at most once a frame, with one query: while a reply streams the scroller scrolls on every + * resize, and a lookup and a layout read per turn per scroll event was real work on a long + * conversation. */ - const hasItems = items.length > 0; - useEffect(() => { - if (hasItems) { - delays.settle(); + const activeFrameRef = useRef(null); + const readActiveTurnNow = useCallback(() => { + activeFrameRef.current = null; + const element = viewportRef.current; + if (element === null || turnIds === "") return; + const wanted = new Set(turnIds.split("\n")); + const view = element.getBoundingClientRect(); + const rows: { id: string; top: number }[] = []; + for (const row of element.querySelectorAll( + "[data-message-id]", + )) { + const id = row.dataset.messageId; + if (id !== undefined && wanted.has(id)) { + rows.push({ id, top: row.getBoundingClientRect().top - view.top }); + } } - }, [hasItems, delays]); + setActiveTurn( + currentTurn( + rows, + element.clientHeight / 3, + element.scrollHeight - element.scrollTop - element.clientHeight <= + AT_END_PX, + ), + ); + }, [turnIds]); + const readActiveTurn = useCallback(() => { + if (activeFrameRef.current !== null) return; + activeFrameRef.current = window.requestAnimationFrame(readActiveTurnNow); + }, [readActiveTurnNow]); + useEffect( + () => () => { + if (activeFrameRef.current !== null) { + window.cancelAnimationFrame(activeFrameRef.current); + } + }, + [], + ); + useLayoutEffect(() => { + if (!restoring) readActiveTurnNow(); + }, [restoring, readActiveTurnNow]); + + /* + * A jump from the rail. A turn already drawn is gone to now; one in a page not drawn yet draws + * every page down to it first, and is gone to a frame after that commit, once the scroller has put + * the reader back where they were (the same wait as the fill check above). Instant, never smooth: + * a smooth scroll up passes the top margin and draws pages above while it is moving, and the + * scroller's hold on the reader's place would fight it. + */ + const jumpRef = useRef(null); + const goToTurn = useCallback((id: string) => { + const element = viewportRef.current; + const row = element?.querySelector(`[data-message-id="${CSS.escape(id)}"]`); + if (!element || !row) return; + // Less the transcript's own top padding (`py-6`), so the turn sits where a first row would. + element.scrollTop += + row.getBoundingClientRect().top - + element.getBoundingClientRect().top - + TURN_INSET_PX; + }, []); + /* + * Following stops as the view moves, not before the pages are drawn: the scroller holds the + * reader's place across a prepend by moving `scrollTop` down by what was added, and at the end of + * the conversation that reads exactly like the person scrolling back to the end, which picks + * following up again (`nextFollow`). + */ + const goToTurnStill = useCallback( + (id: string) => { + goToTurn(id); + /* + * Not when the move left the view at the end: a short last turn is clamped there, nothing + * scrolls, and nothing would ever pick following up again. + */ + const element = viewportRef.current; + if ( + element !== null && + element.scrollHeight - element.scrollTop - element.clientHeight > + RESUME_PX + ) { + dispatchFollow({ type: "jump" }); + } + }, + [dispatchFollow, goToTurn], + ); + const jump = useCallback( + (turn: Turn) => { + setActiveTurn(turn.id); + if (turn.index < pagesFrom) { + jumpRef.current = turn.id; + drawTo(turn.index); + } else { + goToTurnStill(turn.id); + } + }, + [drawTo, goToTurnStill, pagesFrom], + ); + // biome-ignore lint/correctness/useExhaustiveDependencies: runs after the commit that drew the pages a jump asked for, which is a change of `pagesFrom`. + useLayoutEffect(() => { + const id = jumpRef.current; + if (id === null) return; + const frame = window.requestAnimationFrame(() => { + jumpRef.current = null; + goToTurnStill(id); + }); + return () => window.cancelAnimationFrame(frame); + }, [pagesFrom, goToTurnStill]); + + /* + * 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 ( +
+
+ + {/* + * 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} + /> + ))} +
+
+ ); + } + + /* + * One row, wherever it is drawn. `index` is the row's place in `items`, not in the slice being + * drawn, so "the last row" and "latest" mean the same with or without older pages above. + */ + const renderRow = (item: TranscriptItem, index: number) => + item.kind === "tools" || item.kind === "tool" ? ( + + = latestIndex} + spokeAfter={lastBotWords > index} + steps={item.kind === "tools" ? item.steps : [item]} + /> + + ) : 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) + ); 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.length === 0 && restoring ? : null} - {items.map((item, index) => - item.kind === "tools" ? ( - - - + {hasActivities ? ( + + ) : null} + + + + { + follow.viewportHandlers.onScroll?.(event); + drawEarlierNearTop(pagesFrom); + readActiveTurn(); + }} + ref={viewportRef} + > + + {/* + * 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. + * + * OLDER PAGES GO BEFORE THIS BLOCK, AND THAT IS THE SAME MECHANISM USED ON PURPOSE. + * When the reader nears the top, the page before is drawn (`useTranscriptPages`). Drawn + * here, ahead of this block, it moves whatever was the first child, and a first child + * that moved is the scroller's own "rows were prepended" case: it puts the row the + * reader was looking at back where it was (`de()`), and never runs the scan above, which + * would read the shifted rows as new ones and jump to an old turn. Rows that arrive + * live still go after this block, at the end, where that scan looks for them. + * + * ONE CASE IT DOES NOT COVER. The scroller takes one path per batch of changes, so a page + * drawn above and a sent message landing in the SAME commit would be held in place and + * the new turn not aligned to the top. They come from different updates (a scroll near + * the top, a send at the bottom) and in practice never commit together; if they ever + * do, the turn is not aligned when it arrives, and a later change that keeps the row + * count can align to it then instead (the scroller's `Ge`), a beat late. + * + * `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. + */} + + {items + .slice(pages.from, pages.split) + .map((item, offset) => + renderRow(item, pages.from + offset), + )} + +
+ {stopped ? ( + + ) : waitingOnFirstToken ? ( + + ) : null} + {working} + {queued.map((message) => ( + onRemoveQueued(message.id) + : undefined + } + text={message.text} /> - - - ) : item.kind === "tool" ? ( - - = latestIndex} - name={item.toolCall.function.name} - result={item.result} - spokeAfter={lastBotWords > index} - 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) - ), - )} - - - - - - + ))} +
+ {items + .slice(pages.split) + .map((item, offset) => renderRow(item, pages.split + offset))} +
+
+ { + // Ours, not the scroller's: it would scroll before it had been told to follow again. + event.preventDefault(); + dispatchFollow({ type: "button" }); + }} + /> + + + +
+
+
+ ); } diff --git a/app/src/components/channels/follow-intent.tsx b/app/src/components/channels/follow-intent.tsx new file mode 100644 index 0000000000..7c36156a03 --- /dev/null +++ b/app/src/components/channels/follow-intent.tsx @@ -0,0 +1,306 @@ +import { useReducedMotion } from "motion/react"; +import { + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, +} from "react"; +import { useMessageScroller } from "@/components/ui/message-scroller"; + +/** + * Whether the transcript follows a streaming reply, decided from what the person does. + * + * The scroller on its own follows whenever the view is at the end, and a layout change that leaves + * the view at the end looks the same to it as somebody scrolling there. So the decision is made + * here, from input events only: moving up the page (wheel, keys, a touch drag, a scrollbar drag) + * stops following at once, selecting text in the transcript pauses it, and it picks up again when + * the person comes back down near the end, presses the button, clears their selection or sends. + * A scroll with no input behind it (content growing, shrinking, the scroller's own moves) is never + * the person, and changes nothing. + */ + +/** How close to the end, in pixels, scrolling back down picks following up again. */ +export const RESUME_PX = 80; + +export type FollowState = { + following: boolean; + pausedBy: "scroll" | "selection" | null; +}; + +export const FOLLOWING: FollowState = { following: true, pausedBy: null }; + +export type FollowEvent = + | { type: "wheel"; deltaY: number } + | { type: "key"; key: string; shiftKey: boolean } + /* Positive: the finger moved down the screen, which scrolls the page up. */ + | { type: "touch"; fingerDeltaY: number } + | { + type: "scroll"; + top: number; + previousTop: number; + fromEnd: number; + pointerDown: boolean; + } + | { type: "selection"; active: boolean } + | { type: "button" } + /* + * A wheel, key or touch moving down the page. The scroller stops following the bottom on any + * input; at the end, where nothing scrolls, only a scroll to the end puts it back. + */ + | { type: "down"; atEnd: boolean } + /* A mark on the rail: they went to a turn of their choosing, so stay with what they chose. */ + | { type: "jump" } + | { type: "send" } + /* A message parked under the conversation: they are looking at the end, so go there. */ + | { type: "queued" }; + +export type FollowStep = { + state: FollowState; + scrollToEnd: ScrollBehavior | null; +}; + +const PAUSED_BY_SCROLL: FollowState = { following: false, pausedBy: "scroll" }; +const PAUSED_BY_SELECTION: FollowState = { + following: false, + pausedBy: "selection", +}; + +const UP_KEYS = new Set(["ArrowUp", "PageUp", "Home"]); +const DOWN_KEYS = new Set(["ArrowDown", "PageDown", "End"]); + +/** Within this many pixels of the end, a downward input has nothing left to scroll. */ +const END_PX = 8; + +function movesUp(event: FollowEvent): boolean { + switch (event.type) { + case "wheel": + return event.deltaY < 0; + case "key": + return UP_KEYS.has(event.key) || (event.key === " " && event.shiftKey); + case "touch": + return event.fingerDeltaY > 0; + case "jump": + return true; + case "scroll": + // Only a scroll the person is dragging: the scrollbar, with a pointer held down on it. + return event.pointerDown && event.top < event.previousTop; + default: + return false; + } +} + +export function nextFollow(state: FollowState, event: FollowEvent): FollowStep { + const same = { state, scrollToEnd: null }; + + if (movesUp(event)) { + return state.pausedBy === "scroll" + ? same + : { state: PAUSED_BY_SCROLL, scrollToEnd: null }; + } + + switch (event.type) { + case "scroll": + return state.pausedBy === "scroll" && + event.top > event.previousTop && + event.fromEnd <= RESUME_PX + ? { state: FOLLOWING, scrollToEnd: "auto" } + : same; + case "selection": + if (event.active && state.following) { + return { state: PAUSED_BY_SELECTION, scrollToEnd: null }; + } + if (!event.active && state.pausedBy === "selection") { + return { state: FOLLOWING, scrollToEnd: "auto" }; + } + return same; + case "down": + return state.following && event.atEnd + ? { state, scrollToEnd: "auto" } + : same; + case "button": + return { state: FOLLOWING, scrollToEnd: "smooth" }; + case "send": + return { state: FOLLOWING, scrollToEnd: null }; + case "queued": + return { state: FOLLOWING, scrollToEnd: "auto" }; + default: + return same; + } +} + +function isEditable(target: EventTarget): boolean { + return ( + target instanceof HTMLElement && + (target.isContentEditable || + target.tagName === "INPUT" || + target.tagName === "TEXTAREA" || + target.tagName === "SELECT") + ); +} + +export type FollowRequest = { behavior: ScrollBehavior; tick: number }; + +type ViewportHandlers = Pick< + React.ComponentProps<"div">, + | "onWheel" + | "onKeyDown" + | "onTouchStart" + | "onTouchMove" + | "onPointerDown" + | "onScroll" +>; + +/** + * The follow state for one transcript, fed from the viewport's input events and from a selection + * anywhere inside it. Back to following whenever a conversation is being restored, so a channel + * opened next never inherits where the last one was left. + * + * `following` goes to the scroller as `autoScroll`; `request` is a scroll to the end that a resume + * asked for, carried out by `FollowScroll` inside the scroller once `autoScroll` is already true. + */ +export function useFollowIntent( + viewportRef: React.RefObject, + restoring: boolean, +): { + following: boolean; + dispatch: (event: FollowEvent) => void; + request: FollowRequest | null; + viewportHandlers: ViewportHandlers; +} { + const reducedMotion = useReducedMotion() ?? false; + const [state, setState] = useState(FOLLOWING); + const [request, setRequest] = useState(null); + const stateRef = useRef(state); + + if (restoring && (state !== FOLLOWING || request !== null)) { + stateRef.current = FOLLOWING; + setState(FOLLOWING); + setRequest(null); + } + + const dispatch = useCallback( + (event: FollowEvent) => { + const step = nextFollow(stateRef.current, event); + if (step.state !== stateRef.current) { + stateRef.current = step.state; + setState(step.state); + } + const behavior = step.scrollToEnd; + if (behavior !== null) { + setRequest((previous) => ({ + behavior: reducedMotion ? "auto" : behavior, + tick: (previous?.tick ?? 0) + 1, + })); + } + }, + [reducedMotion], + ); + + const pointerDownRef = useRef(false); + const previousTopRef = useRef(0); + const touchYRef = useRef(null); + + useEffect(() => { + const release = () => { + pointerDownRef.current = false; + }; + // Only a selection inside the transcript: text selected in the composer is not reading. + const selectionChanged = () => { + const selection = document.getSelection(); + const viewport = viewportRef.current; + dispatch({ + type: "selection", + active: + selection !== null && + !selection.isCollapsed && + (viewport?.contains(selection.anchorNode) ?? false), + }); + }; + // A scrollbar drag can end with the pointer anywhere on the page. + window.addEventListener("pointerup", release); + window.addEventListener("pointercancel", release); + window.addEventListener("blur", release); + document.addEventListener("selectionchange", selectionChanged); + return () => { + window.removeEventListener("pointerup", release); + window.removeEventListener("pointercancel", release); + window.removeEventListener("blur", release); + document.removeEventListener("selectionchange", selectionChanged); + }; + }, [dispatch, viewportRef]); + + const down = (element: HTMLElement) => + dispatch({ + type: "down", + atEnd: + element.scrollHeight - element.scrollTop - element.clientHeight <= + END_PX, + }); + + const viewportHandlers: ViewportHandlers = { + onWheel: (event) => { + dispatch({ type: "wheel", deltaY: event.deltaY }); + if (event.deltaY > 0) down(event.currentTarget); + }, + onKeyDown: (event) => { + // A key typed into a field inside the transcript moves a caret, not the page. + if (event.defaultPrevented || isEditable(event.target)) return; + dispatch({ type: "key", key: event.key, shiftKey: event.shiftKey }); + if (DOWN_KEYS.has(event.key) || (event.key === " " && !event.shiftKey)) { + down(event.currentTarget); + } + }, + onTouchStart: (event) => { + touchYRef.current = event.touches[0]?.clientY ?? null; + }, + onTouchMove: (event) => { + const y = event.touches[0]?.clientY; + if (y === undefined) return; + const last = touchYRef.current; + touchYRef.current = y; + if (last === null) return; + dispatch({ type: "touch", fingerDeltaY: y - last }); + // The finger moving up the screen scrolls the page down. + if (y < last) down(event.currentTarget); + }, + onPointerDown: (event) => { + /* + * A press on the viewport itself, not on anything in it: its scrollbar, or the bare margin + * beside the conversation. Not a gutter check: an overlay scrollbar (macOS with a trackpad) + * takes no width and a right-to-left one sits on the left, so a thumb is pressed inside the + * content box. A press on a message is never a drag, so a layout clamp under a finger + * resting on one is still not the person's. + */ + pointerDownRef.current = event.target === event.currentTarget; + }, + onScroll: (event) => { + const element = event.currentTarget; + const top = element.scrollTop; + const previousTop = previousTopRef.current; + previousTopRef.current = top; + dispatch({ + type: "scroll", + top, + previousTop, + fromEnd: element.scrollHeight - top - element.clientHeight, + pointerDown: pointerDownRef.current, + }); + }, + }; + + return { following: state.following, dispatch, request, viewportHandlers }; +} + +/** + * Carries out a resume's scroll to the end. Renders nothing; it lives inside the scroller to reach + * it, and runs in the commit after the resume, when the scroller has already been told to follow, so + * the scroll leaves it following rather than free. + */ +export function FollowScroll({ request }: { request: FollowRequest | null }) { + const { scrollToEnd } = useMessageScroller(); + useLayoutEffect(() => { + if (request !== null) scrollToEnd({ behavior: request.behavior }); + }, [request, scrollToEnd]); + return null; +} diff --git a/app/src/components/channels/group-chat.tsx b/app/src/components/channels/group-chat.tsx index 4e3cf193e1..2515ce93d2 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,36 @@ 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); - const lastKey = `${messages.length}:${messages.at(-1)?.status ?? ""}`; - useEffect(() => { - if (lastKey) endRef.current?.scrollIntoView({ block: "end" }); - }, [lastKey]); + /* + * 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); + // 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 ( + shouldFollowGroup({ + landed: landed.current, + atBottom: atBottom.current, + newestIsMine, + }) + ) { + element.scrollTop = element.scrollHeight; + landed.current = true; + atBottom.current = true; + } + }, [lastKey, newestIsMine]); if (group.isPending) return null; if (group.isError) @@ -59,7 +83,13 @@ export function GroupChat({ channelId }: { channelId: string }) { return (
-
+
{ + atBottom.current = isNearBottom(event.currentTarget); + }} + ref={viewportRef} + >
{messages.length === 0 ? (

@@ -86,7 +116,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 +241,38 @@ 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 + ); +} + +/** + * 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/src/components/channels/late-renderers.tsx b/app/src/components/channels/late-renderers.tsx new file mode 100644 index 0000000000..acdee07a53 --- /dev/null +++ b/app/src/components/channels/late-renderers.tsx @@ -0,0 +1,90 @@ +import { createContext, useEffect, useRef, useState } from "react"; + +/** The part of the CopilotKit core this needs: its activity renderers, and a way to hear it connect. */ +type RendererCore = { + readonly renderActivityMessages: readonly unknown[]; + subscribe(subscriber: { onRuntimeConnectionStatusChanged?: () => void }): { + unsubscribe(): void; + }; +}; + +/** How many frames, after a trigger, to keep looking for the provider's swap before giving up. */ +const LOOK_FRAMES = 10; + +/** + * A version that goes up when the core's activity renderers change. + * + * The provider swaps that list in a passive effect when generative UI turns on (the capabilities + * answer arriving, `settled` here, or the runtime connecting) and notifies nobody, so an activity + * that rendered before then drew nothing and stayed that way until its own message changed. This + * looks again after each of those, ONE FRAME at a time for up to LOOK_FRAMES, because the provider's + * effect runs after this one and, under a cold start, can land a few frames later; it counts a change + * only when the list really is a different list. + */ +export function useLateRenderers( + core: RendererCore, + waiting: boolean, + settled: unknown, +): number { + const [version, setVersion] = useState(0); + // The list last looked at, kept across looks so a change made before one is not missed. + const seenRef = useRef(core.renderActivityMessages); + + // biome-ignore lint/correctness/useExhaustiveDependencies: `settled` is the trigger, not an input + useEffect(() => { + if (!waiting) return; + let frame = 0; + const look = (left = LOOK_FRAMES) => { + window.cancelAnimationFrame(frame); + frame = window.requestAnimationFrame(() => { + if (core.renderActivityMessages !== seenRef.current) { + seenRef.current = core.renderActivityMessages; + setVersion((current) => current + 1); + } else if (left > 1) { + look(left - 1); + } + }); + }; + const subscription = core.subscribe({ + onRuntimeConnectionStatusChanged: () => look(), + }); + look(); + return () => { + window.cancelAnimationFrame(frame); + subscription.unsubscribe(); + }; + }, [core, waiting, settled]); + + return version; +} + +/** + * The renderers' version for every activity in a transcript. Read by an activity so it redraws when + * the renderers it found missing arrive; see `LateRenderersWatch`. + */ +export const LateRenderersContext = createContext(0); + +/** + * ONE subscription for a whole transcript. Each activity used to carry its own — a core + * subscription, a capabilities observer and a frame poll per row — to patch a signal the SDK does + * not send; here it is listened for once and handed up, for the transcript to pass down through + * `LateRenderersContext`. + * + * Renders nothing, so the transcript can mount it only while it has activities without changing the + * shape of its own tree (which would remount every row). + */ +export function LateRenderersWatch({ + core, + settled, + onVersion, +}: { + core: RendererCore; + settled: unknown; + onVersion: (version: number) => void; +}) { + const version = useLateRenderers(core, true, settled); + useEffect(() => { + if (version > 0) onVersion(version); + }, [version, onVersion]); + return null; +} diff --git a/app/src/components/channels/paced-reveal.ts b/app/src/components/channels/paced-reveal.ts new file mode 100644 index 0000000000..a8aa483b3d --- /dev/null +++ b/app/src/components/channels/paced-reveal.ts @@ -0,0 +1,152 @@ +import { useReducedMotion } from "motion/react"; +import { useEffect, useRef, useState } from "react"; + +/** + * A streaming reply arrives in bursts: nothing for a moment, then a sentence at once. Drawn as it + * arrives, it reads as stuttering. Revealed at a steady pace a little behind what has arrived, it + * reads as being written. The pace has a floor, so a slow stream still moves, and a catch-up + * budget, so a big burst is worked off in well under a second rather than queued behind the floor. + * + * Revealed a word at a time: a prefix ends at the end of the text or just before whitespace, and + * while the stream is still going it stops short of the last word, because that word may be half + * sent — until the text has not grown for TAIL_SETTLES_MS, when the reply has ended its sentence + * and the run is busy with something else. + */ + +/** The slowest the reveal ever goes, so a trickle of text still moves. */ +export const MIN_CHARS_PER_SECOND = 90; +/** While streaming: how long a backlog takes to work off, at most, before the floor takes over. */ +export const CATCH_UP_MS = 400; +/** After the stream ends: everything is out this long after, however much was still to show. */ +export const FINISH_MS = 300; + +const MIN_CHARS_PER_MS = MIN_CHARS_PER_SECOND / 1000; + +/** One frame at 60Hz, and the most one step may count: a tab in the background stalls frames. */ +const FRAME_MS = 16; +const MAX_FRAME_MS = 50; + +function isSpace(character: string | undefined): boolean { + return character !== undefined && /\s/.test(character); +} + +/** + * The longest run that counts as one word. Text with no spaces (Chinese, Japanese, a long link) has + * no word to stop at; past this it is revealed a character at a time rather than held whole. + */ +export const LONGEST_WORD = 24; +/** A tail that has not grown for this long is the end of the reply, not half a word. */ +export const TAIL_SETTLES_MS = 250; + +/** The furthest prefix that may be shown: all of it, or while the tail may be half sent, up to it. */ +function limitOf(text: string, holdTail: boolean): number { + if (!holdTail) return text.length; + const end = Math.max(0, text.length - LONGEST_WORD); + for (let index = text.length - 1; index >= end; index--) { + if (isSpace(text[index])) return index; + } + return end; +} + +export function nextReveal({ + text, + shown, + elapsedMs, + streaming, + holdTail = streaming, + finishInMs, +}: { + text: string; + shown: number; + elapsedMs: number; + streaming: boolean; + /** Whether the last word may still be arriving. While streaming, until the text settles. */ + holdTail?: boolean; + finishInMs: number; +}): number { + const floor = Math.min(shown, text.length); + const limit = limitOf(text, streaming && holdTail); + if (floor >= limit) return floor; + + const backlog = text.length - floor; + const budget = streaming ? CATCH_UP_MS : Math.max(finishInMs, 1); + const rate = Math.max(MIN_CHARS_PER_MS, backlog / budget); + const target = Math.min( + limit, + floor + Math.max(1, Math.ceil(rate * elapsedMs)), + ); + + // Forward to the end of the word the target falls in, unless it is too long to be one. + const stop = Math.min(limit, target + LONGEST_WORD); + let next = target; + while (next < stop && !isSpace(text[next])) next++; + return next === stop && stop < limit ? target : next; +} + +/** + * The part of `text` to draw now. A message that was already there when the conversation opened + * starts fully shown and only paces what grows after; one that arrived live starts from nothing. + * Reduced motion draws text as it arrives. + */ +export function usePacedReveal( + text: string, + { live, streaming }: { live: boolean; streaming: boolean }, +): string { + const reducedMotion = useReducedMotion() ?? false; + const [shown, setShown] = useState(() => (live ? 0 : text.length)); + const finishAtRef = useRef(null); + const grewAtRef = useRef(0); + const lengthRef = useRef(text.length); + if (text.length !== lengthRef.current) { + lengthRef.current = text.length; + grewAtRef.current = performance.now(); + } + + if (streaming) { + finishAtRef.current = null; + } else if (finishAtRef.current === null) { + finishAtRef.current = performance.now() + FINISH_MS; + } + + const pending = !reducedMotion && shown < text.length; + + /* + * ONE LOOP FOR AS LONG AS THERE IS TEXT TO SHOW, reading the newest text from refs. Tokens arrive + * every 10 to 20ms, between frames; restarting the loop on each one restarted its clock too, so + * the next frame measured the few ms since the token rather than the ~16ms since the last frame, + * and the reveal ran at a fraction of its pace, then dumped the backlog when the stream ended. + */ + const textRef = useRef(text); + textRef.current = text; + const streamingRef = useRef(streaming); + streamingRef.current = streaming; + + useEffect(() => { + if (!pending) return; + let last: number | null = null; + let frame = window.requestAnimationFrame(function step(now) { + // The first frame of a loop has no previous one; a frame's worth keeps it moving. + const elapsedMs = Math.min( + MAX_FRAME_MS, + Math.max(0, now - (last ?? now - FRAME_MS)), + ); + last = now; + const finishAt = finishAtRef.current; + const current = textRef.current; + setShown((shownNow) => + nextReveal({ + text: current, + shown: shownNow, + elapsedMs, + streaming: streamingRef.current, + holdTail: now - grewAtRef.current < TAIL_SETTLES_MS, + finishInMs: finishAt === null ? FINISH_MS : finishAt - now, + }), + ); + frame = window.requestAnimationFrame(step); + }); + return () => window.cancelAnimationFrame(frame); + }, [pending]); + + return pending ? text.slice(0, shown) : text; +} diff --git a/app/src/components/channels/tool-activity.tsx b/app/src/components/channels/tool-activity.tsx index 05cd40ca1d..013b3a9986 100644 --- a/app/src/components/channels/tool-activity.tsx +++ b/app/src/components/channels/tool-activity.tsx @@ -55,19 +55,24 @@ export type TranscriptItem = VisibleChatItem | ToolGroup; * * `isDrawn` says which tools the app draws itself; the transcript reads it from `useDrawnTool`. * - * The group takes its first step's id, so the row keeps its identity while later steps stream in. + * EVERY RUN IS A GROUP, A RUN OF ONE INCLUDED, AND KEEPS ITS ROW ID FOR ITS WHOLE LIFE. A lone call + * used to be its own `tool` row and became a `tools` row when a second call arrived, under the same + * key but a different tree, so React tore the row down and rebuilt it and its entrance replayed. A + * group took its first step's id, so when that step turned out to ask the person something and left, + * the group's id changed and it was rebuilt again. Now the transcript draws a group of one as the + * line it always was, inside the one row type, and `rowIds` remembers which row each step was drawn + * in: a run reuses the row of the first of its steps no earlier run in this pass has claimed. Group + * ids carry a `group:` prefix, so they can never collide with a single row's id. */ export function groupToolSteps( items: readonly VisibleChatItem[], isDrawn: (name: string) => boolean, + rowIds: Map = new Map(), ): TranscriptItem[] { - const grouped: TranscriptItem[] = []; + const segments: (VisibleChatItem | ToolItem[])[] = []; 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 }); + if (run.length > 0) segments.push(run); run = []; }; for (const item of items) { @@ -76,10 +81,41 @@ export function groupToolSteps( continue; } close(); - grouped.push(item); + segments.push(item); } close(); - return grouped; + + // Runs claim their rows first, so a group keeps its row when its first step leaves it... + const claimed = new Set(); + const runIds = new Map(); + for (const segment of segments) { + if (!Array.isArray(segment)) continue; + const [first] = segment; + if (!first) continue; + const kept = segment + .map((step) => rowIds.get(step.id)) + .find((id) => id !== undefined && !claimed.has(id)); + const id = kept ?? `group:${first.id}`; + claimed.add(id); + runIds.set(segment, id); + for (const step of segment) rowIds.set(step.id, id); + } + + // ...and a lone call that stops folding (its answer asks the person) keeps the row it had. + return segments.map((segment): TranscriptItem => { + if (Array.isArray(segment)) { + return { + kind: "tools", + id: runIds.get(segment) ?? "", + steps: segment, + }; + } + if (segment.kind !== "tool") return segment; + const previous = rowIds.get(segment.id); + if (previous === undefined || claimed.has(previous)) return segment; + claimed.add(previous); + return { ...segment, id: previous }; + }); } function foldable(item: ToolItem, isDrawn: (name: string) => boolean): boolean { diff --git a/app/src/components/channels/tool-boundary.tsx b/app/src/components/channels/tool-boundary.tsx index 73a9637c01..83f2c6cb85 100644 --- a/app/src/components/channels/tool-boundary.tsx +++ b/app/src/components/channels/tool-boundary.tsx @@ -1,4 +1,5 @@ import { Component, type ReactNode } from "react"; +import { ArgumentsArrivingContext } from "@/components/gallery/streaming"; /** * A component that throws must not take the conversation with it. @@ -8,16 +9,41 @@ 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 }; + static contextType = ArgumentsArrivingContext; + declare context: React.ContextType; + + 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", @@ -27,6 +53,19 @@ export class ToolRenderBoundary extends Component< } render() { + /* + * Failed on arguments that are still arriving: nothing, until the next chunk retries it. Shown, + * the error box flashed between chunks for the whole stream of a card that then drew fine. + */ + if ( + this.state.failed && + !this.state.failedFinal && + !this.props.final && + this.props.resetKey !== undefined && + this.context + ) { + return null; + } if (this.state.failed) { return (

@@ -38,3 +77,25 @@ export class ToolRenderBoundary extends Component< return this.props.children; } } + +/** + * Whether a tool call can no longer change: it has a result, or its arguments are complete. + * + * NOT JUST THE RESULT. A decision has none until the person answers it, and the run stays busy for + * the whole wait, so a decision that threw on its complete arguments was taken for one still + * streaming and drew nothing; the turn hung invisibly until somebody pressed Stop. Streamed + * arguments are a JSON object whose closing brace comes last, so they parse only once the last of + * them has arrived. + */ +export function toolCallFinal( + args: string, + result: string | undefined, +): boolean { + if (result !== undefined) return true; + try { + JSON.parse(args); + return true; + } catch { + return false; + } +} diff --git a/app/src/components/channels/transcript-pages.ts b/app/src/components/channels/transcript-pages.ts new file mode 100644 index 0000000000..5861acb008 --- /dev/null +++ b/app/src/components/channels/transcript-pages.ts @@ -0,0 +1,114 @@ +import { useCallback, useRef, useState } from "react"; + +/** + * How many rows a conversation opens with, and how many more are drawn each time the reader nears + * the top. Twenty fills a tall screen with margin, so the reader never sees the edge of what is + * drawn on open; the rest of a long history costs nothing until somebody scrolls up to it. + */ +export const PAGE_ROWS = 20; + +/** How close to the top, in pixels, the reader gets before the previous page is drawn above. */ +export const LOAD_MARGIN_PX = 800; + +/** The index of the first row drawn when a conversation of `count` rows opens. */ +export function openingFrom(count: number): number { + return Math.max(0, count - PAGE_ROWS); +} + +/** The index of the first row drawn once the page before `from` is added. */ +export function earlierFrom(from: number): number { + return Math.max(0, from - PAGE_ROWS); +} + +/** + * Whether the reader is near enough the top to draw the page before. An unmeasured viewport + * (`scrollHeight` 0: nothing laid out yet) decides nothing. + */ +export function shouldDrawEarlier(view: { + from: number; + scrollTop: number; + scrollHeight: number; +}): boolean { + return ( + view.from > 0 && view.scrollHeight > 0 && view.scrollTop < LOAD_MARGIN_PX + ); +} + +/** + * Which rows are drawn: `items.slice(from)`. `split` is where drawing started when the conversation + * opened, fixed for as long as it stays open; rows before it were added later, above, and render in + * a region of their own (see the `contents` block in `chat-transcript.tsx`). + * + * BOTH ARE HELD AS ROWS, NOT INDICES. Rows are added and removed above them while a conversation is + * open — history recovered from storage merges in a prefix, tool steps regroup — and an index then + * named a different row: rows crossed the boundary into the live region under another parent, where + * they remounted, replayed their entrance and re-typed from nothing, and landed mid-list where the + * scroller reads a new turn. A row that has gone keeps the index it last had. + * + * Forgotten while restoring, the same way the opening stagger is, so a conversation opened again + * (or another channel) starts from its newest page. + */ +export function useTranscriptPages( + ids: readonly string[], + restoring: boolean, +): { + from: number; + split: number; + drawEarlier: () => void; + drawTo: (index: number) => void; +} { + /* One object per opening, so a page drawn for an earlier opening is recognisably not this one's. */ + const openingRef = useRef<{ split: Mark } | null>(null); + const [earlier, setEarlier] = useState<{ + opening: { split: Mark }; + from: Mark; + } | null>(null); + + if (restoring) { + openingRef.current = null; + } else if (openingRef.current === null) { + openingRef.current = { split: markAt(ids, openingFrom(ids.length)) }; + } + const opening = openingRef.current; + const split = opening === null ? 0 : place(ids, opening.split); + const from = + earlier !== null && earlier.opening === opening + ? Math.min(place(ids, earlier.from), split) + : split; + + const stateRef = useRef({ ids, from }); + stateRef.current = { ids, from }; + const drawFrom = useCallback((index: number) => { + const current = openingRef.current; + const { ids: now, from: drawn } = stateRef.current; + if (current === null || index >= drawn) return; + setEarlier({ opening: current, from: markAt(now, Math.max(0, index)) }); + }, []); + const drawEarlier = useCallback( + () => drawFrom(earlierFrom(stateRef.current.from)), + [drawFrom], + ); + + /* `drawTo`: every page down to the row at `index`, for a jump to a turn not drawn yet. */ + return { from, split, drawEarlier, drawTo: drawFrom }; +} + +/** + * A row the boundary sits on, and the index it had when last seen. `id` null is the very start of a + * conversation that had no rows: rows arriving into it are live. + */ +type Mark = { id: string | null; index: number }; + +function markAt(ids: readonly string[], index: number): Mark { + return { id: ids[index] ?? null, index: Math.min(index, ids.length) }; +} + +function place(ids: readonly string[], mark: Mark): number { + if (mark.id === null) return Math.min(mark.index, ids.length); + const index = ids.indexOf(mark.id); + if (index !== -1) { + mark.index = index; + return index; + } + return Math.min(mark.index, ids.length); +} diff --git a/app/src/components/channels/transcript-rail.tsx b/app/src/components/channels/transcript-rail.tsx new file mode 100644 index 0000000000..3c8b82b3fb --- /dev/null +++ b/app/src/components/channels/transcript-rail.tsx @@ -0,0 +1,147 @@ +import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"; +import { memo, useEffect, useRef } from "react"; +import { cn } from "@/lib/utils"; +import type { Turn } from "./transcript-turns"; + +type RailProps = { + turns: readonly Turn[]; + /** The id of the turn being read. */ + active: string | null; + onJump: (turn: Turn) => void; +}; + +function sameTurn(a: Turn, b: Turn): boolean { + return ( + a.id === b.id && + a.index === b.index && + a.prompt === b.prompt && + a.reply === b.reply + ); +} + +/** + * The transcript re-renders on every streamed chunk and rebuilds its turns each time; the rail + * redraws only when what it shows has changed, and then only the marks that did. + */ +export function sameRail(a: RailProps, b: RailProps): boolean { + return ( + a.active === b.active && + a.onJump === b.onJump && + a.turns.length === b.turns.length && + a.turns.every((turn, index) => { + const other = b.turns[index]; + return other !== undefined && sameTurn(turn, other); + }) + ); +} + +/** + * A rail in the margin beside a conversation: a mark per turn, the one being read drawn longer. + * Hovering or focusing a mark previews its turn — what the person sent, and the start of the + * reply — and choosing it goes there. + * + * Only where the margin is wide enough to hold it (`@3xl` of the scroller: the transcript's + * `max-w-2xl` column and a gutter either side), and only once there is more than one turn to move + * between. + */ +export const TranscriptRail = memo(function TranscriptRail({ + turns, + active, + onJump, +}: RailProps) { + const listRef = useRef(null); + + /* A long conversation's rail scrolls; keep the current mark on it. */ + useEffect(() => { + if (active === null) return; + listRef.current + ?.querySelector(`[data-turn="${CSS.escape(active)}"]`) + ?.scrollIntoView?.({ block: "nearest" }); + }, [active]); + + if (turns.length < 2) return null; + + return ( +

+ ); +}, sameRail); + +const RailMark = memo(function RailMark({ + turn, + current, + onJump, +}: { + turn: Turn; + current: boolean; + onJump: (turn: Turn) => void; +}) { + return ( + + onJump(turn)} + > + + + + + +

{turn.prompt}

+ {turn.reply ? ( +

+ {turn.reply} +

+ ) : null} +
+
+
+
+ ); +}, sameMark); + +function sameMark( + a: { turn: Turn; current: boolean; onJump: (turn: Turn) => void }, + b: { turn: Turn; current: boolean; onJump: (turn: Turn) => void }, +): boolean { + return ( + a.current === b.current && a.onJump === b.onJump && sameTurn(a.turn, b.turn) + ); +} diff --git a/app/src/components/channels/transcript-turns.ts b/app/src/components/channels/transcript-turns.ts new file mode 100644 index 0000000000..fad0b9ec32 --- /dev/null +++ b/app/src/components/channels/transcript-turns.ts @@ -0,0 +1,130 @@ +import { readFiring } from "@/lib/channels/routine-firing"; +import type { TranscriptItem } from "./tool-activity"; + +/** + * Whether a text item is the person actually sending something, as opposed to a routine firing that + * merely arrived wearing `role: "user"`. + * + * A FIRING IS NOT THE PERSON SPEAKING — `TranscriptMessage` already knows that and draws it as + * `RoutineFiring` rather than as their bubble, via the same `readFiring` check used here. The scroll + * machinery was the one place left that had not caught up: it smooth-scrolled and anchored on + * `role === "user"` alone, so a routine firing while somebody was reading back through the channel + * yanked their viewport to the bottom as though they had just typed and sent something. They hadn't; + * the schedule had. Exported so this can be checked without mounting anything. + */ +export function isPersonSentMessage( + role: "user" | "assistant", + text: string, +): boolean { + return role === "user" && readFiring(text) === null; +} + +/** One mark on the rail: the row it jumps to, and what its preview says. */ +export type Turn = { + /** The id of the turn's first row, which is also its `data-message-id`. */ + id: string; + /** That row's place in the transcript's items, so a jump knows which page to draw. */ + index: number; + prompt: string; + /** The first thing the Bot said back, or "" while it hasn't. */ + reply: string; +}; + +function isPersons(item: TranscriptItem): boolean { + return ( + (item.kind === "text" && isPersonSentMessage(item.role, item.text)) || + item.kind === "attachments" + ); +} + +/* + * The turns are rebuilt on every streamed chunk, and stripping every message again each time was the + * per-token cost the rest of the transcript avoids. Each text is stripped once; only the reply still + * growing is new on a chunk. Cleared whole when it grows large, which only a very long session reaches. + */ +const plainCache = new Map(); +const PLAIN_CACHE_SIZE = 1000; + +/** Markdown read as the words a preview shows: no headings, emphasis, code marks or link targets. */ +export function plainWords(markdown: string): string { + const cached = plainCache.get(markdown); + if (cached !== undefined) return cached; + if (plainCache.size >= PLAIN_CACHE_SIZE) plainCache.clear(); + const plain = strip(markdown); + plainCache.set(markdown, plain); + return plain; +} + +function strip(markdown: string): string { + return markdown + .replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1") + .replace(/^\s{0,3}(#{1,6}|>|[-*+]|\d+\.)\s+/gm, "") + .replace(/[*_~`]+/g, "") + .replace(/\s+/g, " ") + .trim(); +} + +/** + * The conversation's turns, oldest first. A turn starts where the person sent something — text, or + * files, which arrive as their own row beside the caption and are one turn with it — and runs until + * they send again. A routine firing starts none: nobody typed it. + */ +export function turnsOf(items: readonly TranscriptItem[]): Turn[] { + const turns: Turn[] = []; + let current: Turn | null = null; + let files = 0; + let previousWasPersons = false; + + items.forEach((item, index) => { + const persons = isPersons(item); + if (persons && !previousWasPersons) { + current = { id: item.id, index, prompt: "", reply: "" }; + files = 0; + turns.push(current); + } + previousWasPersons = persons; + if (current === null) return; + + if (persons) { + if (item.kind === "text") { + current.prompt = plainWords(item.text); + } else if (item.kind === "attachments") { + files += item.attachments.length; + if (current.prompt === "") { + current.prompt = files === 1 ? "1 file" : `${files} files`; + } + } + } else if ( + item.kind === "text" && + item.role === "assistant" && + current.reply === "" + ) { + current.reply = plainWords(item.text); + } + }); + + return turns; +} + +/** How close to the end, in pixels, counts as being at the end of the conversation. */ +export const AT_END_PX = 8; + +/** + * Which turn is being read, from where each drawn turn's first row starts relative to the view: the + * last to start above `line` (a third of the way down), or the first drawn when none has. At the end + * of the conversation it is the last turn, which may be too short to reach the line: opened at the + * bottom, a short last answer starts low on the screen, and the turn before it would win. + */ +export function currentTurn( + rows: readonly { id: string; top: number }[], + line: number, + atEnd: boolean, +): string | null { + if (atEnd) return rows.at(-1)?.id ?? null; + let current: string | null = null; + for (const row of rows) { + if (current !== null && row.top > line) break; + current = row.id; + } + return current; +} 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} >