diff --git a/src/workspaces/officerdev/src/apps/Chat/EmbeddableChat/useEmbeddableChat.ts b/src/workspaces/officerdev/src/apps/Chat/EmbeddableChat/useEmbeddableChat.ts index 5f335428..a081cbcb 100644 --- a/src/workspaces/officerdev/src/apps/Chat/EmbeddableChat/useEmbeddableChat.ts +++ b/src/workspaces/officerdev/src/apps/Chat/EmbeddableChat/useEmbeddableChat.ts @@ -1,5 +1,5 @@ import type { KeyboardEvent } from 'react'; -import { useState, useRef, useEffect } from 'react'; +import { useState, useRef, useEffect, useCallback } from 'react'; import { useUserVisibleModels } from 'state/useModels'; import { useChat, type UseChatType } from '../../../hooks/useChat'; import { useAttachments } from '../useAttachments'; @@ -76,6 +76,10 @@ export function useEmbeddableChat(params: UseEmbeddableChatParams, onMessageComp const bottomRef = useRef(null); const userScrolledRef = useRef(false); const didInitialScrollRef = useRef(false); + // When the reader last touched the scroller. Scroll *position* cannot tell a deliberate scroll-up from + // the list growing under an animation or a row measuring taller than its estimate, and reading it as + // intent is what used to switch auto-scroll off mid-turn and leave it off. + const gestureAtRef = useRef(0); // The prompt currently in flight, kept verbatim so a stop can put it back in the composer. const lastSentRef = useRef(''); @@ -206,31 +210,49 @@ export function useEmbeddableChat(params: UseEmbeddableChatParams, onMessageComp }; }, []); - // Keep the view pinned to the bottom as messages/stream grow — but never yank it down when the user - // has scrolled up (e.g. to read history or to trigger lazy-loading of older messages). The first pass - // is an instant jump (repeated on the next frame so it lands cleanly once the tail window measures); - // later passes animate. Prepending older messages doesn't fire this — the user has scrolled up by then. - useEffect(() => { + /** + * Pin to the bottom, unless the reader has deliberately scrolled away. + * + * Instant, never smoothed. A smooth scroll takes hundreds of milliseconds during which the view is *not* + * at the bottom, and a turn producing output interrupts it long before it arrives — so the chase never + * finished, and every frame of it read as the reader having scrolled up. Instant lands in one event and + * cannot be outrun by the content it is chasing. + */ + const stickToBottom = useCallback(() => { if (userScrolledRef.current) return; - const toBottom = (behavior: ScrollBehavior) => bottomRef.current?.scrollIntoView({ behavior }); - if (!didInitialScrollRef.current) { - didInitialScrollRef.current = true; - toBottom('auto'); - requestAnimationFrame(() => toBottom('auto')); - } else { - toBottom('smooth'); - } - }, [messages, streamingText]); + bottomRef.current?.scrollIntoView({ behavior: 'auto' }); + }, []); + + // Two frames on the first pass, so the jump repeats once the tail window has measured itself. Prepending + // older messages doesn't fire this — the reader has scrolled up by then and `stickToBottom` declines. + useEffect(() => { + stickToBottom(); + if (didInitialScrollRef.current) return; + didInitialScrollRef.current = true; + requestAnimationFrame(stickToBottom); + }, [messages, streamingText, stickToBottom]); // Detect user scrolling up; near the top, lazy-load the next older window (reverse infinite scroll). useEffect(() => { const viewport = scrollViewportRef.current; if (!viewport) return; + // Anything that can move a scroller by hand: the wheel, a drag on the bar, a finger, the keyboard. + // Momentum after a flick needs no window of its own — the gesture itself has already carried the view + // off the bottom and set the flag, and only arriving back at the bottom clears it. + const GESTURE_MS = 700; + const markGesture = () => { + gestureAtRef.current = performance.now(); + }; + const handleScroll = () => { const { scrollTop, scrollHeight, clientHeight } = viewport; const atBottom = scrollHeight - scrollTop - clientHeight < 60; - userScrolledRef.current = !atBottom; + // Being at the bottom is unambiguous, so it always re-arms. Leaving the bottom only counts when the + // reader did it: otherwise a row measuring taller than its 150px estimate, or a fold shrinking, reads + // as "they want to stay up here" and auto-scroll never comes back on. + if (atBottom) userScrolledRef.current = false; + else if (performance.now() - gestureAtRef.current < GESTURE_MS) userScrolledRef.current = true; setShowJumpToBottom(!atBottom); // Only after the initial bottom-anchor has run, so the mount's scrollTop=0 doesn't trigger a fetch. if (didInitialScrollRef.current && hasMoreOlderRef.current && !isLoadingOlderRef.current && scrollTop < 200) { @@ -239,7 +261,17 @@ export function useEmbeddableChat(params: UseEmbeddableChatParams, onMessageComp }; viewport.addEventListener('scroll', handleScroll); - return () => viewport.removeEventListener('scroll', handleScroll); + viewport.addEventListener('wheel', markGesture, { passive: true }); + viewport.addEventListener('touchmove', markGesture, { passive: true }); + viewport.addEventListener('mousedown', markGesture); + viewport.addEventListener('keydown', markGesture); + return () => { + viewport.removeEventListener('scroll', handleScroll); + viewport.removeEventListener('wheel', markGesture); + viewport.removeEventListener('touchmove', markGesture); + viewport.removeEventListener('mousedown', markGesture); + viewport.removeEventListener('keydown', markGesture); + }; }, []); // Focus textarea on mount @@ -299,6 +331,7 @@ export function useEmbeddableChat(params: UseEmbeddableChatParams, onMessageComp availableModels, showJumpToBottom, jumpToBottom, + stickToBottom, commandFeedback, textareaRef, scrollViewportRef, diff --git a/src/workspaces/officerdev/src/apps/Chat/components/MessageBubble.tsx b/src/workspaces/officerdev/src/apps/Chat/components/MessageBubble.tsx index 4b7f102e..e3e6ed1e 100644 --- a/src/workspaces/officerdev/src/apps/Chat/components/MessageBubble.tsx +++ b/src/workspaces/officerdev/src/apps/Chat/components/MessageBubble.tsx @@ -139,11 +139,11 @@ type MessageBubbleProps = { message: ChatMessage; onAnswer?: (text: string) => void; onRetry?: (prompt: string) => void; - /** Set only for the turn in progress; tool rows use it to open themselves while they run. */ - live?: boolean; + /** Set for rows in the turn you are watching; tool rows start expanded and stay that way until it folds. */ + defaultOpen?: boolean; }; -export const MessageBubble = ({ message, onAnswer, onRetry, live }: MessageBubbleProps) => { +export const MessageBubble = ({ message, onAnswer, onRetry, defaultOpen }: MessageBubbleProps) => { switch (message.role) { case 'user': { const rawText = typeof message.text === 'string' ? message.text : ''; @@ -214,7 +214,7 @@ export const MessageBubble = ({ message, onAnswer, onRetry, live }: MessageBubbl if (message.toolName === 'question' && onAnswer) { return ; } - return ; + return ; case 'result': return ( diff --git a/src/workspaces/officerdev/src/apps/Chat/components/MessageList.tsx b/src/workspaces/officerdev/src/apps/Chat/components/MessageList.tsx index a441c3c9..2535633d 100644 --- a/src/workspaces/officerdev/src/apps/Chat/components/MessageList.tsx +++ b/src/workspaces/officerdev/src/apps/Chat/components/MessageList.tsx @@ -5,6 +5,8 @@ import { measureHeight } from 'helpers/measure-zoomed'; import type { UseEmbeddableChatType } from '../EmbeddableChat/useEmbeddableChat'; import { MessageBubble, StreamingBubble } from './MessageBubble'; import { buildRows } from './turn-rows'; +import { useFoldExit } from './fold-exit'; +import { ToolOpenProvider } from './tool-open'; import { TurnEnd, TurnFold } from './TurnFold'; const OVERSCAN = 5; @@ -24,6 +26,7 @@ export const MessageList = ({ manager }: MessageListProps) => { scrollViewportRef, bottomRef, isLoadingOlder, + stickToBottom, } = manager; // The live turn stays open; everything before it is folded. See `turn-rows.ts`. @@ -39,16 +42,42 @@ export const MessageList = ({ manager }: MessageListProps) => { return next; }); + // Whether a turn has run while this list has been mounted. Tool rows expand themselves so you can watch + // work happen, which is only a service to someone who is watching — reopening yesterday's conversation + // should hand you a readable page, not every output it ever produced. Never reset: sending the next + // message starts generating again, so the flag is already true by the time the new turn has any rows. + const watched = useRef(false); + if (isGenerating) watched.current = true; + + // The turn you just superseded, on its way out. It holds the work open for a beat and then shrinks it. + const foldExit = useFoldExit(rows, watched.current); + const virtualizer = useVirtualizer({ count: rows.length, getScrollElement: () => scrollViewportRef.current, estimateSize: () => 150, overscan: OVERSCAN, + // Rows must reconcile by identity, not by position. The default extractor keys them by index, so + // prepending older messages hands every row the key of the row above it — React then keeps the mounted + // component and swaps its props, and the measured height cache follows the slot instead of the content. + getItemKey: (index) => rows[index]?.key ?? index, // Measured height must be in the same unit as the translateY below — layout px, which is what the // panel zoom leaves alone. A raw getBoundingClientRect is in rendered px and stacks the bubbles. measureElement: measureHeight, }); + // Re-pin once the rows have actually measured themselves. + // + // A new row is laid out at the 150px estimate, so the scroll that fires when the message arrives lands on + // a bottom that isn't the real one — and the correction arrives later, through a ResizeObserver, on a + // render that no message change accompanies. Nothing was watching for it. An expanded tool call is several + // times the estimate, which is why this got worse the moment tool rows stopped collapsing on a timer: + // every row of a running turn now overshoots, and the view sat a screenful short of the work. + const totalSize = virtualizer.getTotalSize(); + useLayoutEffect(() => { + stickToBottom(); + }, [totalSize, stickToBottom]); + // Keep the viewport anchored when older messages are prepended: the row that used to be first (now // shifted down by however many rows arrived above it) is pinned back to the top, so content doesn't jump. // Counted in rows rather than messages, because a prepended turn folds down to fewer rows than it has @@ -78,44 +107,49 @@ export const MessageList = ({ manager }: MessageListProps) => { )} {rows.length > 0 && ( -
- {virtualizer.getVirtualItems().map((virtualRow) => { - const row = rows[virtualRow.index]!; - return ( -
-
- {row.kind === 'message' && ( - sendPrompt(text)} - onRetry={(prompt) => sendPrompt(prompt)} - // Every unfolded tool row belongs to the live turn by construction — older turns' - // work only ever appears inside a fold — so generating is the whole condition. - live={isGenerating} - /> - )} - {row.kind === 'fold' && ( - toggleFold(row.key)} - /> - )} - {row.kind === 'turn-end' && } + +
+ {virtualizer.getVirtualItems().map((virtualRow) => { + const row = rows[virtualRow.index]!; + return ( +
+
+ {row.kind === 'message' && ( + sendPrompt(text)} + onRetry={(prompt) => sendPrompt(prompt)} + // Deliberately not `isGenerating`: a turn's work stays open after it finishes, and + // closes when you send the next message and the turn folds behind its summary. + defaultOpen={row.live && watched.current} + /> + )} + {row.kind === 'fold' && ( + toggleFold(row.key)} + /> + )} + {row.kind === 'turn-end' && } +
-
- ); - })} -
+ ); + })} +
+ )} {isGenerating && (
diff --git a/src/workspaces/officerdev/src/apps/Chat/components/ToolActivity.tsx b/src/workspaces/officerdev/src/apps/Chat/components/ToolActivity.tsx index 89be793a..2e061ef8 100644 --- a/src/workspaces/officerdev/src/apps/Chat/components/ToolActivity.tsx +++ b/src/workspaces/officerdev/src/apps/Chat/components/ToolActivity.tsx @@ -1,58 +1,33 @@ -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useMemo, useState } from 'react'; import { FileText, Terminal, Pencil, Search, Globe, Wrench, ChevronRight, Bot } from 'lucide-react'; import type { ChatMessage } from '../types'; import type { DiffLine } from './line-diff'; import { diffStat, editDiff } from './line-diff'; import { CodeSurface } from './CodeSurface'; import { CopyButton } from './CopyButton'; +import { useToolOpen } from './tool-open'; type ToolMessage = Extract; type ToolActivityProps = { message: ToolMessage; /** - * True only for rows in the turn that is happening right now. Read once, at mount: a row that arrives - * while you are watching opens itself, and a row that merely re-mounts later (scrolled back into a - * virtualised list, or reloaded from the transcript) does not. Auto-expansion is a way of watching work - * happen, not a property of the transcript. + * Whether this row starts expanded — true for the turn you are watching happen, false everywhere else. + * + * This used to be a clock: the row stamped its own arrival, held itself open for five seconds and then + * collapsed on a timer. That made a running turn shift under the reader continuously, each row changing + * height at a moment nobody chose, and it made the answer depend on when a virtualised row happened to + * first render rather than on when the call happened. Expansion is now a fact about position in the + * conversation, so a row only changes height when you click it or when you move on to the next turn. */ - live?: boolean; + defaultOpen?: boolean; }; /** - * How long a finished call stays open before it collapses. Long enough to read what came back, short - * enough that a burst of fast calls doesn't leave six panels stacked on screen. Each row runs its own - * clock from its own result, so parallel calls stagger out naturally instead of needing to be sequenced. + * Expanded output is a glance, not a read: the cap keeps a 400-line file from taking the viewport, and + * `CodeSurface`'s own "Show more" gives the whole thing in one click when the glance isn't enough. */ -const AUTO_HOLD_MS = 5000; - -/** Auto-expanded output is a glance, not a read: the cap keeps a 400-line file from taking the viewport. */ -const AUTO_MAX_LINES = 10; -const MANUAL_MAX_LINES = 20; - -/** - * When each call was first seen and when it settled, keyed by tool call id and held outside React. - * - * Two things force it out of the component. Rows are virtualised, so a component unmounting is not the - * call ending and remounting is not a new call — state inside the row would make a scroll look like a - * fresh tool run. And a fast call (`git status`, `wc`) can have its start and its result render together, - * so a row that only opens when it *catches* the unresolved state never opens at all: the very calls - * quick enough to batch are the ones that appeared not to work. - * - * Recording the moment instead of the transition makes both cases the same case — the row asks how long - * ago this call finished, not whether it happened to be watching when it did. - */ -const seen = new Map(); - -function trackCall(id: string, pending: boolean): { at: number; resolved?: number } { - let entry = seen.get(id); - if (!entry) { - entry = { at: Date.now() }; - seen.set(id, entry); - } - if (!pending && entry.resolved === undefined) entry.resolved = Date.now(); - return entry; -} +const MAX_LINES = 10; const toolIcons: Record = { Read: FileText, @@ -208,38 +183,15 @@ function langForPath(path: unknown): string { return EXT_LANG[ext] ?? ext; } -export const ToolActivity = ({ message, live }: ToolActivityProps) => { +export const ToolActivity = ({ message, defaultOpen }: ToolActivityProps) => { const Icon = toolIcons[message.toolName] ?? Wrench; const { head, tail } = splitSummary(getToolSummary(message.toolName, message.toolInput)); const pending = message.output === undefined; const isError = message.isError === true; - // Read once: the hold has to survive the turn ending under it, because the last call of a turn settles - // at roughly the moment `live` goes false and has as much right to its five seconds as the rest. - const canAuto = useRef(live === true); - // Written during render, deliberately: the first render is where the decision is made, so a cache - // populated in an effect would always be one render too late for the row that needs it most. - const call = canAuto.current ? trackCall(message.toolCallId, pending) : null; - - // null until you touch the row, and never reset: your click outranks the clock for as long as the row - // exists. A control that undoes itself two seconds later is worse than no control. - const [override, setOverride] = useState(null); - const [, retick] = useState(0); - - const settledFor = call?.resolved === undefined ? 0 : Date.now() - call.resolved; - const autoOpen = call !== null && (pending || settledFor < AUTO_HOLD_MS); - - // One timer per row, for whatever is left of its own hold — which is what lets parallel calls stagger: - // three greps fired together collapse in the order they came back, not all at once. - useEffect(() => { - if (!call || pending || call.resolved === undefined) return; - const left = call.resolved + AUTO_HOLD_MS - Date.now(); - if (left <= 0) return; - const timer = setTimeout(() => retick((n) => n + 1), left); - return () => clearTimeout(timer); - }, [call, pending]); - - const open = override ?? autoOpen; + // Your click is remembered above the row and outranks the default for as long as the conversation is + // open — including across the unmount a virtualised list does whenever this scrolls out of view. + const [open, toggle] = useToolOpen(message.toolCallId, defaultOpen === true); const children = message.children ?? []; const hint = pending || isError ? null : outputHint(message.toolName, message.output ?? ''); @@ -249,11 +201,11 @@ export const ToolActivity = ({ message, live }: ToolActivityProps) => { return (
- {/* A failure reads as a failure while collapsed. It behaves like every other row — same clock, same - auto-collapse — and differs only in colour, so a call that went wrong stays findable by scanning - rather than by opening things, and stays nameable in conversation without either of us opening it. */} + {/* A failure reads as a failure while collapsed. It behaves like every other row — same default, same + folding — and differs only in colour, so a call that went wrong stays findable by scanning rather + than by opening things, and stays nameable in conversation without either of us opening it. */}
)} diff --git a/src/workspaces/officerdev/src/apps/Chat/components/TurnFold.tsx b/src/workspaces/officerdev/src/apps/Chat/components/TurnFold.tsx index ca4cf725..bc340897 100644 --- a/src/workspaces/officerdev/src/apps/Chat/components/TurnFold.tsx +++ b/src/workspaces/officerdev/src/apps/Chat/components/TurnFold.tsx @@ -1,13 +1,20 @@ +import { useEffect, useState } from 'react'; import { ChevronRight, CheckCircle2 } from 'lucide-react'; import type { ChatMessage } from '../types'; import type { FoldCounts } from './turn-rows'; -import { foldLabel } from './turn-rows'; +import { foldLabel, keyOf } from './turn-rows'; +import { COLLAPSE_MS } from './fold-exit'; import { MessageBubble } from './MessageBubble'; type TurnFoldProps = { messages: ChatMessage[]; counts: FoldCounts; open: boolean; + /** + * Expand the tool rows inside. Set only while this fold is closing over work you were watching a moment + * ago, so it shrinks from what was actually on your screen rather than snapping to a collapsed copy of it. + */ + childrenOpen?: boolean; onToggle: () => void; }; @@ -18,33 +25,63 @@ type TurnFoldProps = { * is a red row you never find. It is also the shared handle: "the one where the tsgo call went red" has to * be sayable without either of us expanding anything. */ -export const TurnFold = ({ messages, counts, open, onToggle }: TurnFoldProps) => ( -
- +export const TurnFold = ({ messages, counts, open, childrenOpen, onToggle }: TurnFoldProps) => { + // The content has to outlive the close by the length of the animation. A fold that unmounts its work on + // the same frame it closes has nothing left to shrink, so it vanishes instead of collapsing — which is + // exactly the jump this is here to remove. + const [keep, setKeep] = useState(false); + useEffect(() => { + if (open) { + setKeep(true); + return; + } + if (!keep) return; + const timer = setTimeout(() => setKeep(false), COLLAPSE_MS); + return () => clearTimeout(timer); + }, [open, keep]); - {open && ( -
- {messages.map((message, i) => ( - - ))} + return ( +
+ + + {/* `0fr` → `1fr` is the one way to animate to a height nobody can know in advance — a turn's work is + whatever it is. The inner `overflow-hidden` is what makes the row clip rather than spill while the + track shrinks, and the virtualiser's own ResizeObserver follows the height down, so the rows below + ride the animation instead of teleporting when it ends. */} +
+
+ {(open || keep) && ( +
+ {/* Keyed by identity, not position: folded work is where an opened tool row is most likely to + be re-ordered under you by a prepend, and an index key reattaches it to whatever now sits + there. */} + {messages.map((message, i) => ( + + ))} +
+ )} +
- )} -
-); +
+ ); +}; /** * A turn that ended without a closing message. Written as a seam, never as prose — see `turn-rows.ts`. diff --git a/src/workspaces/officerdev/src/apps/Chat/components/fold-exit.ts b/src/workspaces/officerdev/src/apps/Chat/components/fold-exit.ts new file mode 100644 index 00000000..1a3f02f1 --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Chat/components/fold-exit.ts @@ -0,0 +1,59 @@ +import { useEffect, useLayoutEffect, useRef, useState } from 'react'; +import type { ChatRow } from './turn-rows'; + +/** + * A beat between your message landing and the previous turn folding away, so the two read as cause and + * effect rather than as one simultaneous lurch. Short enough that it is a rhythm, not a wait. + */ +const HOLD_MS = 180; + +/** Long enough to read as movement, short enough not to be a delay. `TurnFold` animates over the same span. */ +export const COLLAPSE_MS = 260; + +/** The fold that is closing right now: `open` is what it should be showing this frame. */ +export type FoldExit = { key: string; open: boolean }; + +/** + * Turn the fold into something you can watch happen. + * + * Folding is not a collapse — `buildRows` replaces a turn's work with a single summary row, so the rows you + * were reading are gone on the same frame the summary arrives and there is nothing left to animate. The way + * back is to let the new fold be *born open*, holding exactly the messages it replaced, and then close it: + * the list keeps the same total height across the swap and the movement afterwards is a real height change. + * + * Only the newest fold, and only once. `enabled` is the same "have I watched a turn run here" question the + * tool rows ask — opening an old conversation should render its folds closed, not play twenty animations at + * you. Requiring the fold to be the *last* one keeps a prepend of older messages, which mints fold keys + * above the ones already on screen, from being mistaken for a turn you just finished. + */ +export function useFoldExit(rows: ChatRow[], enabled: boolean): FoldExit | null { + const [exit, setExit] = useState(null); + // null until the first pass, which is how "these folds were already here when I arrived" is told apart + // from "this fold just appeared". Every fold on a freshly loaded transcript is new; none of them is recent. + const knownFolds = useRef | null>(null); + + useLayoutEffect(() => { + const foldKeys = rows.filter((row) => row.kind === 'fold').map((row) => row.key); + const known = knownFolds.current; + knownFolds.current = new Set(foldKeys); + if (!known || !enabled) return; + + const newest = foldKeys[foldKeys.length - 1]; + if (!newest || known.has(newest)) return; + setExit({ key: newest, open: true }); + }, [rows, enabled]); + + useEffect(() => { + if (!exit?.open) return; + const timer = setTimeout(() => setExit((prev) => (prev ? { ...prev, open: false } : null)), HOLD_MS); + return () => clearTimeout(timer); + }, [exit]); + + useEffect(() => { + if (!exit || exit.open) return; + const timer = setTimeout(() => setExit(null), COLLAPSE_MS); + return () => clearTimeout(timer); + }, [exit]); + + return exit; +} diff --git a/src/workspaces/officerdev/src/apps/Chat/components/tool-open.tsx b/src/workspaces/officerdev/src/apps/Chat/components/tool-open.tsx new file mode 100644 index 00000000..c0606bbc --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Chat/components/tool-open.tsx @@ -0,0 +1,55 @@ +import { createContext, useContext, useMemo, useState, type ReactNode } from 'react'; + +/** + * Which tool rows you have opened or closed by hand, held above the rows themselves. + * + * Rows are virtualised, so a row unmounting is not the call ending and remounting is not a new call. State + * kept inside the row is thrown away the moment it scrolls past the overscan window — which is exactly the + * reasoning that already lifted the turn folds out of `TurnFold` and into `MessageList`. The tool rows were + * left behind, so scrolling away from a call you had expanded, or any re-render that shifted the list, + * silently closed it again. + * + * Only your explicit clicks live here. The default — open while you are watching the turn happen, collapsed + * once you have moved on — is derived per row from the transcript, so there is nothing to seed, invalidate + * or clean up, and a reload renders the same rows as a live session. + */ +type ToolOpenStore = { + isOpen: (id: string, fallback: boolean) => boolean; + /** `current` is what the row is showing now, so a click means "the opposite of that" regardless of why. */ + toggle: (id: string, current: boolean) => void; +}; + +const ToolOpenContext = createContext(null); + +export const ToolOpenProvider = ({ children }: { children: ReactNode }) => { + const [overrides, setOverrides] = useState>(() => new Map()); + + const store = useMemo( + () => ({ + isOpen: (id, fallback) => overrides.get(id) ?? fallback, + toggle: (id, current) => + setOverrides((prev) => { + const next = new Map(prev); + next.set(id, !current); + return next; + }), + }), + [overrides], + ); + + return {children}; +}; + +/** + * A row's open state and its toggle. + * + * Falls back to component-local state when no provider is above it, so a `ToolActivity` rendered outside the + * message list still expands — it just doesn't survive being unmounted, which outside a virtualised list it + * isn't. + */ +export function useToolOpen(id: string, defaultOpen: boolean): [boolean, () => void] { + const store = useContext(ToolOpenContext); + const [local, setLocal] = useState(null); + const open = store ? store.isOpen(id, defaultOpen) : (local ?? defaultOpen); + return [open, () => (store ? store.toggle(id, open) : setLocal(!open))]; +} diff --git a/src/workspaces/officerdev/src/apps/Chat/components/turn-rows.test.ts b/src/workspaces/officerdev/src/apps/Chat/components/turn-rows.test.ts index 9647826e..0b81f471 100644 --- a/src/workspaces/officerdev/src/apps/Chat/components/turn-rows.test.ts +++ b/src/workspaces/officerdev/src/apps/Chat/components/turn-rows.test.ts @@ -139,6 +139,29 @@ describe('buildRows', () => { }); }); +/** What tool rows key their expansion off. Getting this wrong expands a whole transcript at once. */ +describe('buildRows — the live flag', () => { + const live = (messages: ChatMessage[], isGenerating = false) => + buildRows(messages, isGenerating) + .filter((r) => r.kind === 'message') + .map((r) => (r.kind === 'message' ? r.live : null)); + + test('the current turn is live, and stays live after it finishes', () => { + expect(live([user('go'), tool('a'), say('answer')], true)).toEqual([true, true, true]); + expect(live([user('go'), tool('a'), say('answer')], false)).toEqual([true, true, true]); + }); + + test('a superseded turn is not live — only your question and my answer survive as rows', () => { + expect(live([user('first'), tool('a'), say('answer'), user('second')])).toEqual([false, false, true]); + }); + + test('a window with no user message is unfolded but not live', () => { + // Nothing is current when there is no boundary to find which turn is. Marking it live would open every + // tool call in a lazily-loaded chunk the moment it scrolled into view. + expect(live([say('note'), tool('a'), say('answer')])).toEqual([false, false, false]); + }); +}); + describe('foldLabel', () => { test('reads as a sentence and singularises', () => { expect(foldLabel({ tools: 1, notes: 1, tasks: 0, failed: 0 })).toBe('1 tool call · 1 message'); diff --git a/src/workspaces/officerdev/src/apps/Chat/components/turn-rows.ts b/src/workspaces/officerdev/src/apps/Chat/components/turn-rows.ts index 135b07a9..cbb0382a 100644 --- a/src/workspaces/officerdev/src/apps/Chat/components/turn-rows.ts +++ b/src/workspaces/officerdev/src/apps/Chat/components/turn-rows.ts @@ -40,7 +40,12 @@ export type FoldCounts = { }; export type ChatRow = - | { kind: 'message'; key: string; message: ChatMessage } + /** + * `live` marks the turn that is happening now — the one after your most recent message. It is what tool + * rows key their default expansion off, so "expanded" is a fact about where the row sits in the + * conversation rather than about how long ago it rendered. + */ + | { kind: 'message'; key: string; message: ChatMessage; live: boolean } /** A collapsed run of one turn's work. `messages` is the whole of it, in order, for when it is opened. */ | { kind: 'fold'; key: string; messages: ChatMessage[]; counts: FoldCounts } /** @@ -52,7 +57,7 @@ export type ChatRow = | { kind: 'turn-end'; key: string }; /** Stable enough to survive a prepend of older messages, which shifts every index in the list. */ -function keyOf(message: ChatMessage, fallback: number): string { +export function keyOf(message: ChatMessage, fallback: number): string { switch (message.role) { case 'tool': return `t:${message.toolCallId || fallback}`; @@ -77,12 +82,14 @@ type TurnParams = { fold: boolean; /** Whether the turn has finished — the live turn has not, while a response is still streaming. */ complete: boolean; + /** Stamped onto every row of this turn. Not simply `!fold` — see `buildRows`. */ + live: boolean; }; -function buildTurn({ segment, offset, fold, complete }: TurnParams): ChatRow[] { +function buildTurn({ segment, offset, fold, complete, live }: TurnParams): ChatRow[] { const rows: ChatRow[] = []; const push = (message: ChatMessage, i: number) => - rows.push({ kind: 'message', key: keyOf(message, offset + i), message }); + rows.push({ kind: 'message', key: keyOf(message, offset + i), message, live }); // Your message is never folded — it is the question the whole turn is an answer to. let start = 0; @@ -161,7 +168,9 @@ export function buildRows(messages: ChatMessage[], isGenerating: boolean): ChatR for (let i = 0; i < bounds.length - 1; i++) { const from = bounds[i]!; const to = bounds[i + 1]!; - rows.push(...buildTurn({ segment: messages.slice(from, to), offset: from, fold: true, complete: true })); + rows.push( + ...buildTurn({ segment: messages.slice(from, to), offset: from, fold: true, complete: true, live: false }), + ); } rows.push( ...buildTurn({ @@ -169,6 +178,10 @@ export function buildRows(messages: ChatMessage[], isGenerating: boolean): ChatR offset: liveStart, fold: false, complete: !isGenerating, + // Unfolded is not the same claim as live. With no user message in the window there is no turn boundary + // to find, so the whole window stays unfolded — and calling all of it live would expand every tool call + // in it at once. Nothing is current when we cannot tell which turn is. + live: userAt.length > 0, }), );