From a4c60f9971407d278ee1a0454f25834ef86d6f6a Mon Sep 17 00:00:00 2001 From: dnviti Date: Sun, 26 Jul 2026 16:08:16 +0200 Subject: [PATCH] feat(chat): collapsible turn sections, auto-folded on a new turn (#34) A long conversation used to be one unbroken wall of fully-expanded turns. Each turn's strip now discloses/hides its own body; only the newest turn opens by default, a turn the user has explicitly opened or closed stays that way across new turns, and jumping to a turn via the rail or search force-opens it. Expand-all/collapse-all lives in the turn index header. Co-Authored-By: Claude Sonnet 5 --- src/client/chat/turns.ts | 29 +++++++++ src/client/shell/chat/ChatView.tsx | 83 +++++++++++++++++++++--- src/client/shell/chat/MessageList.tsx | 93 +++++++++++++++++---------- src/client/shell/chat/TurnIndex.tsx | 66 ++++++++++++++++--- src/client/shell/chat/TurnStrip.tsx | 56 +++++++++++++++- test/chat-turns.test.js | 16 +++++ test/chat-view.test.js | 31 +++++++++ 7 files changed, 324 insertions(+), 50 deletions(-) diff --git a/src/client/chat/turns.ts b/src/client/chat/turns.ts index cfd0822..96e95ea 100644 --- a/src/client/chat/turns.ts +++ b/src/client/chat/turns.ts @@ -21,6 +21,14 @@ import { compactCount, formatDuration } from './tool-meta.js'; export type TurnStatus = 'done' | 'running' | 'failed' | 'waiting'; +/** One glyph per status, shared by every surface that shows a turn's outcome. */ +export const STATUS_GLYPH: Record = { + done: { icon: 'check', color: 'var(--success)', word: 'done' }, + failed: { icon: 'circle-x', color: 'var(--destructive)', word: 'failed' }, + waiting: { icon: 'shield', color: 'var(--warning)', word: 'waiting for you' }, + running: { icon: 'loader-circle', color: 'var(--info)', spin: true, word: 'running' }, +}; + export interface TurnSummary { /** Id of the message that opened the turn — the user's, where there is one. */ id: string; @@ -159,6 +167,27 @@ export function turnOf(messageId: string, turns: TurnSummary[]): TurnSummary | u return turns.find((turn) => turn.messageIds.includes(messageId)); } +/** + * Whether a turn's contents should be shown, folded history vs. the one in + * progress. + * + * The default is "only the newest turn is open" — an unset entry in + * `overrides` reads as that default rather than as closed, which is what + * makes a brand-new turn open itself and everything before it fold without + * either one needing its own entry written first. An override, once made, + * wins regardless of which turn is newest — that persistence is what stops + * the next turn starting from slamming shut something the user deliberately + * opened. + */ +export function isTurnOpen( + turnId: string, + lastTurnId: string, + overrides: ReadonlyMap, +): boolean { + const override = overrides.get(turnId); + return override === undefined ? turnId === lastTurnId : override; +} + export interface TurnMeta { tools: string; reasoning: string; diff --git a/src/client/shell/chat/ChatView.tsx b/src/client/shell/chat/ChatView.tsx index ee31aed..1aae365 100644 --- a/src/client/shell/chat/ChatView.tsx +++ b/src/client/shell/chat/ChatView.tsx @@ -4,7 +4,7 @@ import { uploadAttachment } from '../../chat/attachments-api.js'; import { ChatController, type ChatUnavailable } from '../../chat/controller.js'; import { fetchStatus, findFiles } from '../../chat/workspace-api.js'; import { activityEvents } from '../../chat/activity.js'; -import { groupTurns, turnOf, type TurnSummary } from '../../chat/turns.js'; +import { groupTurns, isTurnOpen, turnOf, type TurnSummary } from '../../chat/turns.js'; import type { ChatPanelId, ChatViewSettings } from '../../chat/view-settings.js'; import { DEFAULT_CHAT_VIEW, @@ -153,6 +153,51 @@ export function ChatView({ ? selectedTurnId : lastTurnId; + // A turn's fold state, once the user has set it explicitly. Unset, a turn + // reads as open exactly when it is the newest one — see isTurnOpen — which + // is what makes a fresh turn open itself and everything before it fold + // without this ever growing an entry for turns nobody has touched. + const [openOverrides, setOpenOverrides] = React.useState>(new Map()); + const openTurnIds = React.useMemo(() => { + const ids = new Set(); + for (const turn of turns) { + if (isTurnOpen(turn.id, lastTurnId, openOverrides)) ids.add(turn.id); + } + return ids; + }, [turns, lastTurnId, openOverrides]); + + const toggleTurn = React.useCallback( + (turnId: string) => { + setOpenOverrides((prev) => { + const next = new Map(prev); + next.set(turnId, !isTurnOpen(turnId, lastTurnId, prev)); + return next; + }); + }, + [lastTurnId], + ); + + // Idempotent, so a jump into an already-open turn does not thrash the map. + const openTurn = React.useCallback( + (turnId: string) => { + setOpenOverrides((prev) => { + if (isTurnOpen(turnId, lastTurnId, prev)) return prev; + const next = new Map(prev); + next.set(turnId, true); + return next; + }); + }, + [lastTurnId], + ); + + const expandAllTurns = React.useCallback(() => { + setOpenOverrides(() => new Map(turns.map((turn) => [turn.id, true]))); + }, [turns]); + + const collapseAllTurns = React.useCallback(() => { + setOpenOverrides(() => new Map(turns.map((turn) => [turn.id, false]))); + }, [turns]); + const [searchOpen, setSearchOpen] = React.useState(false); // Paired with a counter, not held as a bare id: clicking the same work pill // twice has to scroll the rail twice, and a string that did not change would @@ -248,10 +293,17 @@ export function ChatView({ [controller], ); - const selectTurn = React.useCallback((id: string) => { - setSelectedTurnId(id); - list.current?.scrollToTurn(id); - }, []); + const selectTurn = React.useCallback( + (id: string) => { + setSelectedTurnId(id); + // The strip itself is always mounted, open or not, so this can scroll + // immediately — only a jump *inside* a turn's body needs to wait for it + // to open first. See revealMessage. + openTurn(id); + list.current?.scrollToTurn(id); + }, + [openTurn], + ); const jumpLatest = React.useCallback(() => { setSelectedTurnId(null); @@ -284,9 +336,18 @@ export function ChatView({ [openRail, transcript, view.showThinking, view.showToolCalls], ); - const revealMessage = React.useCallback((messageId: string) => { - list.current?.scrollToMessage(messageId); - }, []); + const revealMessage = React.useCallback( + (messageId: string) => { + const turn = turnOf(messageId, turns); + if (turn) openTurn(turn.id); + // A turn that was just opened has not painted yet — its bubbles have no + // layout box to scroll to until the next tick. A turn already open + // scrolls one tick later than it strictly needs to, which nothing on + // screen can tell apart from immediate. + window.setTimeout(() => list.current?.scrollToMessage(messageId), 0); + }, + [turns, openTurn], + ); const copyTurn = React.useCallback( (turnId: string) => { @@ -493,6 +554,8 @@ export function ChatView({ onSelect={selectTurn} onJumpLatest={jumpLatest} collapsed={indexCollapsed} + onExpandAll={expandAllTurns} + onCollapseAll={collapseAllTurns} /> ) : null} @@ -538,6 +601,8 @@ export function ChatView({ transcript={transcript} turns={turns} currentTurnId={currentTurnId} + openTurnIds={openTurnIds} + onToggleTurn={toggleTurn} onLoadMore={loadMore} onShowWork={showWork} onEditTurn={seedDraft} @@ -754,6 +819,8 @@ export function ChatView({ setIndexSheet(false); jumpLatest(); }} + onExpandAll={expandAllTurns} + onCollapseAll={collapseAllTurns} /> + ); +} + function TurnRow({ turn, active, diff --git a/src/client/shell/chat/TurnStrip.tsx b/src/client/shell/chat/TurnStrip.tsx index bbfdff8..fecfbe1 100644 --- a/src/client/shell/chat/TurnStrip.tsx +++ b/src/client/shell/chat/TurnStrip.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; import { Icon } from '../../ui/relay/Icon.js'; -import { formatTurnMeta, turnTime, type TurnSummary } from '../../chat/turns.js'; +import { formatTurnMeta, turnTime, STATUS_GLYPH, type TurnSummary } from '../../chat/turns.js'; /** * The 28px rule that opens a turn, and everything that turn cost. @@ -25,6 +25,11 @@ export interface TurnStripProps { onBranch?: () => void; /** Set while the copy has just landed, so the glyph can acknowledge it. */ copied?: boolean; + /** Whether the turn's body is shown below this strip. */ + open: boolean; + onToggleOpen(): void; + /** Id of the element this strip discloses, for `aria-controls`. */ + bodyId?: string; } export function TurnStrip({ @@ -34,10 +39,14 @@ export function TurnStrip({ onCopy, onBranch, copied = false, + open, + onToggleOpen, + bodyId, }: TurnStripProps): React.JSX.Element { const past = variant === 'past'; const meta = formatTurnMeta(turn); const time = turnTime(turn); + const glyph = STATUS_GLYPH[turn.status] || STATUS_GLYPH.done; return (
+ + ) : null} + {/* Only shown while the body it names is hidden — this is the entire + reason a collapsed strip is still worth reading rather than just a + number. */} + {!open ? ( + <> + + {turn.label} + + ) : null} + ]*aria-expanded="false"|aria-expanded="false"[^>]*aria-label="Expand turn 1"/; + assert.ok(closedToggle.test(html), 'turn 1 must read as collapsed'); + assert.ok(/id="turn-body-m1"[^>]*hidden=""/.test(html), 'turn 1 body must be hidden'); + assert.ok(html.includes('run the tests'), 'a collapsed turn must still show what was asked'); + + // The newest turn opens by default and its body is not hidden. + const openToggle = /aria-label="Collapse turn 2"[^>]*aria-expanded="true"|aria-expanded="true"[^>]*aria-label="Collapse turn 2"/; + assert.ok(openToggle.test(html), 'turn 2 must read as open'); + assert.ok(!/id="turn-body-m3"[^>]*hidden=""/.test(html), 'the current turn must not be hidden'); + assert.ok(html.includes('deployed'), 'the open turn must show its content'); + + // The strip stays mounted — and its actions reachable — for a folded turn. + assert.ok(/aria-label="Copy this turn as Markdown"/.test(html), 'copy control must survive folding'); + }); + it('pins a pending approval outside the scroller and announces it', function () { const controller = controllerWith({ state: 'awaiting_permission',