Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions src/client/chat/turns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<TurnStatus, { icon: string; color: string; spin?: boolean; word: string }> = {
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;
Expand Down Expand Up @@ -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<string, boolean>,
): boolean {
const override = overrides.get(turnId);
return override === undefined ? turnId === lastTurnId : override;
}

export interface TurnMeta {
tools: string;
reasoning: string;
Expand Down
83 changes: 75 additions & 8 deletions src/client/shell/chat/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<Map<string, boolean>>(new Map());
const openTurnIds = React.useMemo(() => {
const ids = new Set<string>();
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
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -493,6 +554,8 @@ export function ChatView({
onSelect={selectTurn}
onJumpLatest={jumpLatest}
collapsed={indexCollapsed}
onExpandAll={expandAllTurns}
onCollapseAll={collapseAllTurns}
/>
) : null}

Expand Down Expand Up @@ -538,6 +601,8 @@ export function ChatView({
transcript={transcript}
turns={turns}
currentTurnId={currentTurnId}
openTurnIds={openTurnIds}
onToggleTurn={toggleTurn}
onLoadMore={loadMore}
onShowWork={showWork}
onEditTurn={seedDraft}
Expand Down Expand Up @@ -754,6 +819,8 @@ export function ChatView({
setIndexSheet(false);
jumpLatest();
}}
onExpandAll={expandAllTurns}
onCollapseAll={collapseAllTurns}
/>
</div>
<button
Expand Down
93 changes: 60 additions & 33 deletions src/client/shell/chat/MessageList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,13 @@ export interface MessageListProps {
onForkTurn?: (turnId: string) => void;
/** Which turn the index has selected; its strip is the sticky one. */
currentTurnId?: string;
/**
* Turns whose body is shown. Absent from this set, a turn's strip stays
* mounted (so copy/branch keep working) but its messages are hidden.
* Undefined — no caller managing fold state — reads as "every turn open".
*/
openTurnIds?: ReadonlySet<string>;
onToggleTurn?: (turnId: string) => void;
/**
* Passed down as primitives, not as a settings object.
*
Expand Down Expand Up @@ -80,6 +87,8 @@ export const MessageList = React.forwardRef<MessageListHandle, MessageListProps>
onCopyTurn,
onForkTurn,
currentTurnId,
openTurnIds,
onToggleTurn,
showThinking = true,
showToolCalls = true,
},
Expand Down Expand Up @@ -236,15 +245,14 @@ export const MessageList = React.forwardRef<MessageListHandle, MessageListProps>
setStuck(true);
}, [stick]);

// Which message opens which turn, so the strips can be interleaved in one
// pass instead of searching the turn list per message.
const stripFor = React.useMemo(() => {
const map = new Map<string, TurnSummary>();
for (const turn of turns) {
if (turn.messageIds.length) map.set(turn.messageIds[0], turn);
}
// Looked up by id rather than iterated in transcript order: a turn's own
// messages are already listed on it, and rendering turn-by-turn (below) is
// what lets a collapsed turn hide its whole body as one unit.
const messageById = React.useMemo(() => {
const map = new Map<string, (typeof messages)[number]>();
for (const message of messages) map.set(message.id, message);
return map;
}, [turns]);
}, [messages]);

const lastTurnId = turns.length ? turns[turns.length - 1].id : undefined;

Expand Down Expand Up @@ -330,33 +338,48 @@ export const MessageList = React.forwardRef<MessageListHandle, MessageListProps>

{messages.length === 0 ? <EmptyState /> : null}

{messages.map((message) => {
const turn = stripFor.get(message.id);
{turns.map((turn) => {
const open = !openTurnIds || openTurnIds.has(turn.id);
const bodyId = turnBodyId(turn.id);
return (
<React.Fragment key={message.id}>
{turn ? (
<TurnStrip
turn={turn}
anchorId={turn.id}
// Sticky for the turn you are in, static above it. The
// selected turn wins over "the last one" so that jumping
// back through the index leaves a header on screen.
variant={turn.id === (currentTurnId || lastTurnId) ? 'current' : 'past'}
copied={copiedTurn === turn.id}
onCopy={() => copyTurn(turn.id)}
onBranch={onForkTurn ? () => onForkTurn(turn.id) : undefined}
/>
) : null}
<MessageBubble
message={message}
transcript={transcript}
onFork={fork}
onRetry={retry}
onShowWork={showWork}
onEdit={edit}
showThinking={showThinking}
showToolCalls={showToolCalls}
<React.Fragment key={turn.id}>
<TurnStrip
turn={turn}
anchorId={turn.id}
// Sticky for the turn you are in, static above it. The
// selected turn wins over "the last one" so that jumping
// back through the index leaves a header on screen.
variant={turn.id === (currentTurnId || lastTurnId) ? 'current' : 'past'}
copied={copiedTurn === turn.id}
onCopy={() => copyTurn(turn.id)}
onBranch={onForkTurn ? () => onForkTurn(turn.id) : undefined}
open={open}
onToggleOpen={() => onToggleTurn?.(turn.id)}
bodyId={bodyId}
/>
{/* Hidden rather than unmounted: a collapsed turn's bubbles
keep their scroll offsets and streaming subscriptions, so
re-expanding it is instant and cannot desync from a turn
that kept running underneath. */}
<div id={bodyId} hidden={!open} style={{ display: open ? 'flex' : 'none', flexDirection: 'column' }}>
{turn.messageIds.map((messageId) => {
const message = messageById.get(messageId);
if (!message) return null;
return (
<MessageBubble
key={message.id}
message={message}
transcript={transcript}
onFork={fork}
onRetry={retry}
onShowWork={showWork}
onEdit={edit}
showThinking={showThinking}
showToolCalls={showToolCalls}
/>
);
})}
</div>
</React.Fragment>
);
})}
Expand Down Expand Up @@ -401,6 +424,10 @@ const ZERO = (): number => 0;
* replayed from disk and carried across versions, and a selector built by
* concatenation is one odd id away from throwing inside a scroll handler.
*/
function turnBodyId(turnId: string): string {
return `turn-body-${turnId}`;
}

function cssEscape(value: string): string {
const escape = (globalThis as { CSS?: { escape?: (v: string) => string } }).CSS?.escape;
return escape ? escape(value) : value.replace(/["\\]/g, '\\$&');
Expand Down
Loading
Loading