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
297 changes: 165 additions & 132 deletions packages/web-shell/client/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,12 @@ import {
import { TASKS_STATUS_ACTIVE_EVENT } from './components/messages/TasksStatusMessage';
import { BtwMessage } from './components/messages/BtwMessage';
import type { ACPToolCall, Message, PermissionRequest } from './adapters/types';
import { getFloatingTodos } from './utils/todos';
import {
computeTodoTimeline,
getFloatingTodos,
todoTimelineSignature,
type TodoSnapshotDiff,
} from './utils/todos';
import { ThemeProvider } from './themeContext';
import {
WebShellThemeId,
Expand All @@ -138,6 +143,16 @@ import styles from './App.module.css';

export const CompactModeContext = createContext(false);

/**
* Per-snapshot status diffs (keyed by tool callId or plan message id), so a
* history row can render what changed in that snapshot without re-deriving it
* from the whole transcript. Empty by default so a row rendered outside the
* provider still falls back gracefully.
*/
export const TodoTimelineContext = createContext<Map<string, TodoSnapshotDiff>>(
new Map(),
);

const MODES_CYCLE = DAEMON_APPROVAL_MODES;
const MAX_DISPLAYED_QUEUED_PROMPTS = 3;
const MAX_QUEUED_PROMPT_PREVIEW_CHARS = 240;
Expand Down Expand Up @@ -635,6 +650,22 @@ export function App({
() => getFloatingTodos(messages),
[messages],
);
// Keep the timeline Map referentially stable across streaming ticks that
// don't touch any todo snapshot. The Map is a context value, so a fresh
// reference would re-render every todo/plan row regardless of memoization;
// only rebuild when the todo snapshots themselves change.
const todoTimelineRef = useRef<{
signature: string;
timeline: Map<string, TodoSnapshotDiff>;
} | null>(null);
const todoTimeline = useMemo(() => {
const signature = todoTimelineSignature(messages);
const cached = todoTimelineRef.current;
if (cached && cached.signature === signature) return cached.timeline;
const timeline = computeTodoTimeline(messages);
todoTimelineRef.current = { signature, timeline };
return timeline;
}, [messages]);
const floatingTodos = useStableArray(
floatingTodosState.todos,
(t) => `${t.id}:${t.status}:${t.content}`,
Expand Down Expand Up @@ -2505,138 +2536,140 @@ export function App({

<WebShellCustomizationProvider value={customization}>
<CompactModeContext.Provider value={compactMode}>
<div
className={
showFloatingTodos
? `${styles.content} ${styles.contentHasMessages}`
: styles.content
}
style={dialogOpen ? { visibility: 'hidden' } : undefined}
>
<MessageList
ref={messageListRef}
messages={displayMessages}
pendingApproval={pendingApproval}
onConfirm={handleConfirm}
onShowContextDetail={handleShowContextDetail}
catchingUp={connection.catchingUp}
workspaceCwd={connection.workspaceCwd || ''}
shellOutputMaxLines={shellOutputMaxLines}
showRetryHint={showRetryHint}
onRetryClick={handleRetry}
welcomeHeader={welcomeHeader}
tailContent={
agentsInlineMode ||
memoryInlineOpen ||
modelInlineMode ||
authInlineOpen ||
approvalModeInlineOpen ||
settingsInlineOpen ? (
<>
{authInlineOpen && (
<AuthMessage
onMessage={(text, type = 'status') => {
store.dispatch([
type === 'error'
? { type: 'error', text }
: { type: 'status', text },
]);
}}
onClose={() => setAuthInlineOpen(false)}
/>
)}
{approvalModeInlineOpen && (
<ApprovalModeMessage
currentMode={currentMode}
onSelect={handleSetMode}
onClose={() => setApprovalModeInlineOpen(false)}
/>
)}
{modelInlineMode && (
<ModelMessage
mode={modelInlineMode}
onSelect={
modelInlineMode === 'fast'
? handleFastModelSelect
: handleModelSelect
}
onClose={() => setModelInlineMode(null)}
/>
)}
{agentsInlineMode && (
<AgentsMessage
mode={agentsInlineMode}
onMessage={(text) =>
store.dispatch([{ type: 'status', text }])
}
onClose={() => setAgentsInlineMode(null)}
/>
)}
{memoryInlineOpen && (
<MemoryMessage
refreshSignal={memoryRefreshSignal}
addSignal={memoryAddSignal}
addScope={memoryAddScope}
portalHost={memoryPortalHost}
onMessage={(text, type = 'status') => {
store.dispatch([{ type, text }]);
}}
onClose={() => setMemoryInlineOpen(false)}
/>
)}
{settingsInlineOpen && (
<SettingsMessage
settingsState={workspaceSettingsState}
onClose={() => setSettingsInlineOpen(false)}
onLanguageChange={handleSettingsLanguageChange}
onThemeChange={handleThemeChange}
onSubDialog={(key) => {
setSettingsInlineOpen(false);
if (key === 'fastModel')
setModelInlineMode('fast');
else if (key === 'tools.approvalMode')
setApprovalModeInlineOpen(true);
}}
/>
)}
</>
) : undefined
}
tailKey={
agentsInlineMode ||
memoryInlineOpen ||
modelInlineMode ||
authInlineOpen ||
approvalModeInlineOpen ||
settingsInlineOpen
? `inline-${authInlineOpen ? 'auth' : 'none'}-${modelInlineMode ?? 'none'}-${agentsInlineMode ?? 'none'}-${memoryInlineOpen ? 'memory' : 'none'}-${approvalModeInlineOpen ? 'approval' : 'none'}-${settingsInlineOpen ? 'settings' : 'none'}`
: undefined
}
// The approval-mode/model pickers and the settings panel are
// reachable by mouse from the status bar, so they reveal
// themselves when opened while the user is scrolled up; the
// agents/memory panels keep the user's scroll position.
autoScrollTailIntoView={
approvalModeInlineOpen ||
modelInlineMode !== null ||
settingsInlineOpen
<TodoTimelineContext.Provider value={todoTimeline}>
<div
className={
showFloatingTodos
? `${styles.content} ${styles.contentHasMessages}`
: styles.content
}
virtualScrollThreshold={virtualScrollThreshold}
/>

{btwMessage?.role === 'btw' && (
<div className={styles.btwPanel}>
<BtwMessage
question={btwMessage.question}
answer={btwMessage.answer}
isPending={btwMessage.isPending}
/>
</div>
)}

<StreamingStatus />
</div>
<div ref={setMemoryPortalHost} data-web-shell-overlay-root />
style={dialogOpen ? { visibility: 'hidden' } : undefined}
>
<MessageList
ref={messageListRef}
messages={displayMessages}
pendingApproval={pendingApproval}
onConfirm={handleConfirm}
onShowContextDetail={handleShowContextDetail}
catchingUp={connection.catchingUp}
workspaceCwd={connection.workspaceCwd || ''}
shellOutputMaxLines={shellOutputMaxLines}
showRetryHint={showRetryHint}
onRetryClick={handleRetry}
welcomeHeader={welcomeHeader}
tailContent={
agentsInlineMode ||
memoryInlineOpen ||
modelInlineMode ||
authInlineOpen ||
approvalModeInlineOpen ||
settingsInlineOpen ? (
<>
{authInlineOpen && (
<AuthMessage
onMessage={(text, type = 'status') => {
store.dispatch([
type === 'error'
? { type: 'error', text }
: { type: 'status', text },
]);
}}
onClose={() => setAuthInlineOpen(false)}
/>
)}
{approvalModeInlineOpen && (
<ApprovalModeMessage
currentMode={currentMode}
onSelect={handleSetMode}
onClose={() => setApprovalModeInlineOpen(false)}
/>
)}
{modelInlineMode && (
<ModelMessage
mode={modelInlineMode}
onSelect={
modelInlineMode === 'fast'
? handleFastModelSelect
: handleModelSelect
}
onClose={() => setModelInlineMode(null)}
/>
)}
{agentsInlineMode && (
<AgentsMessage
mode={agentsInlineMode}
onMessage={(text) =>
store.dispatch([{ type: 'status', text }])
}
onClose={() => setAgentsInlineMode(null)}
/>
)}
{memoryInlineOpen && (
<MemoryMessage
refreshSignal={memoryRefreshSignal}
addSignal={memoryAddSignal}
addScope={memoryAddScope}
portalHost={memoryPortalHost}
onMessage={(text, type = 'status') => {
store.dispatch([{ type, text }]);
}}
onClose={() => setMemoryInlineOpen(false)}
/>
)}
{settingsInlineOpen && (
<SettingsMessage
settingsState={workspaceSettingsState}
onClose={() => setSettingsInlineOpen(false)}
onLanguageChange={handleSettingsLanguageChange}
onThemeChange={handleThemeChange}
onSubDialog={(key) => {
setSettingsInlineOpen(false);
if (key === 'fastModel')
setModelInlineMode('fast');
else if (key === 'tools.approvalMode')
setApprovalModeInlineOpen(true);
}}
/>
)}
</>
) : undefined
}
tailKey={
agentsInlineMode ||
memoryInlineOpen ||
modelInlineMode ||
authInlineOpen ||
approvalModeInlineOpen ||
settingsInlineOpen
? `inline-${authInlineOpen ? 'auth' : 'none'}-${modelInlineMode ?? 'none'}-${agentsInlineMode ?? 'none'}-${memoryInlineOpen ? 'memory' : 'none'}-${approvalModeInlineOpen ? 'approval' : 'none'}-${settingsInlineOpen ? 'settings' : 'none'}`
: undefined
}
// The approval-mode/model pickers and the settings panel are
// reachable by mouse from the status bar, so they reveal
// themselves when opened while the user is scrolled up; the
// agents/memory panels keep the user's scroll position.
autoScrollTailIntoView={
approvalModeInlineOpen ||
modelInlineMode !== null ||
settingsInlineOpen
}
virtualScrollThreshold={virtualScrollThreshold}
/>

{btwMessage?.role === 'btw' && (
<div className={styles.btwPanel}>
<BtwMessage
question={btwMessage.question}
answer={btwMessage.answer}
isPending={btwMessage.isPending}
/>
</div>
)}

<StreamingStatus />
</div>
<div ref={setMemoryPortalHost} data-web-shell-overlay-root />
</TodoTimelineContext.Provider>
</CompactModeContext.Provider>
</WebShellCustomizationProvider>

Expand Down
30 changes: 28 additions & 2 deletions packages/web-shell/client/adapters/transcriptToMessages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,8 +227,9 @@ describe('transcriptBlocksToDaemonMessages', () => {
}),
]);

// Adjacent tool blocks share one tool_group (Native CLI batch parity),
// but each TodoWrite call keeps its own tool entry and todo payload.
// Each TodoWrite update stands alone in its own group (it renders as a
// self-contained collapsible checklist), rather than merging with adjacent
// tool calls.
expect(messages).toEqual([
{
id: 'tg-todo-1',
Expand All @@ -239,6 +240,13 @@ describe('transcriptBlocksToDaemonMessages', () => {
callId: 'todo-call-1',
toolName: 'TodoWrite',
}),
],
},
{
id: 'tg-todo-2',
role: 'tool_group',
timestamp: 2,
tools: [
expect.objectContaining({
callId: 'todo-call-2',
toolName: 'TodoWrite',
Expand Down Expand Up @@ -330,6 +338,24 @@ describe('transcriptBlocksToDaemonMessages', () => {
]);
});

it('never merges todo_write updates into or after a regular tool_group', () => {
const messages = transcriptBlocksToDaemonMessages([
toolBlock('t1', 'tc1', 'completed', 1, { toolName: 'Read' }),
toolBlock('todo-1', 'todo-call-1', 'completed', 2, {
toolName: 'todo_write',
toolKind: 'think',
rawInput: { todos: [{ id: '1', content: 'A', status: 'in_progress' }] },
}),
toolBlock('t2', 'tc2', 'completed', 3, { toolName: 'Edit' }),
]);

expect(messages).toMatchObject([
{ role: 'tool_group', tools: [{ callId: 'tc1' }] },
{ role: 'tool_group', tools: [{ callId: 'todo-call-1' }] },
{ role: 'tool_group', tools: [{ callId: 'tc2' }] },
]);
});

it('does not merge real tool calls into synthetic raw-shell groups', () => {
const messages = transcriptBlocksToDaemonMessages([
textBlock('u1', 'user', 'run it', 1),
Expand Down
Loading
Loading