From 74e9314cf054d98a2c1cd145c2deb6eabaee361d Mon Sep 17 00:00:00 2001 From: Shaojin Wen Date: Sat, 13 Jun 2026 09:19:46 +0800 Subject: [PATCH] feat(web-shell): revamp floating todo panel interactions The "Current tasks" panel above the composer was a static display: always expanded, rotate-to-front ordering with jumbled numbering, no progress summary, and it vanished the instant the last item completed. - Collapsible header (persisted in localStorage); collapsed mode is a single line showing progress + the current in-progress item - Progress counter (completed/total) in the header - Natural-order window anchored on the in-progress item replaces the rotation: one completed context line above, pendings below, with clickable "N completed" / "N more" summary lines that expand the full list (and "Show less" to return) - All-done moment: a finished list stays visible as "All tasks completed" until the next user prompt instead of disappearing instantly; historical finished lists stay hidden on session restore - Locate button scrolls the transcript to the source TodoWrite/plan message with a flash highlight (new MessageList imperative scrollToMessage, callId fallback for compact-merged tool groups) - Visual consistency: in_progress uses the accent color, PlanMessage adopts the shared icon set, items ellipsize to one line with a hover tooltip, and the number column scales past 9 items so the status icons stay aligned getFloatingTodos moves to utils/todos.ts and now reports {todos, allCompleted, sourceMessageId, sourceCallId}; panel visibility is a render-time state machine so the active-to-completed transition does not unmount the panel for a frame. New i18n keys for en/zh-CN and 17 new unit tests. --- packages/web-shell/client/App.tsx | 83 +- .../client/components/MessageList.module.css | 15 + .../client/components/MessageList.test.ts | 39 + .../client/components/MessageList.tsx | 828 ++++++++++-------- .../messages/PlanMessage.module.css | 4 +- .../components/messages/PlanMessage.tsx | 17 +- .../client/components/messages/ToolGroup.tsx | 15 +- .../components/panels/TodoPanel.module.css | 121 ++- .../client/components/panels/TodoPanel.tsx | 230 ++++- packages/web-shell/client/i18n.tsx | 18 +- packages/web-shell/client/utils/todos.test.ts | 193 ++++ packages/web-shell/client/utils/todos.ts | 97 +- 12 files changed, 1174 insertions(+), 486 deletions(-) create mode 100644 packages/web-shell/client/utils/todos.test.ts diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index 005581e30e0..66019a0de52 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -20,7 +20,7 @@ import { } from '@qwen-code/webui/daemon-react-sdk'; import { isDaemonTurnError } from '@qwen-code/sdk/daemon'; import { extractPendingPermission } from './adapters/transcriptAdapter'; -import { MessageList } from './components/MessageList'; +import { MessageList, type MessageListHandle } from './components/MessageList'; import { Editor, type EditorHandle } from './components/Editor'; import type { PromptImage } from './adapters/promptTypes'; import { StatusBar, type StatusBarHandle } from './components/StatusBar'; @@ -117,13 +117,8 @@ import { } from './components/messages/GoalStatusMessage'; import { TASKS_STATUS_ACTIVE_EVENT } from './components/messages/TasksStatusMessage'; import { BtwMessage } from './components/messages/BtwMessage'; -import type { - ACPToolCall, - Message, - PermissionRequest, - TodoItem, -} from './adapters/types'; -import { extractTodosFromToolCall, hasActiveTodos } from './utils/todos'; +import type { ACPToolCall, Message, PermissionRequest } from './adapters/types'; +import { getFloatingTodos } from './utils/todos'; import { ThemeProvider } from './themeContext'; import { WebShellCustomizationProvider, @@ -457,31 +452,6 @@ function getBackgroundTaskActivityKey(messages: readonly Message[]): string { return parts.join('|'); } -function getFloatingTodos(messages: readonly Message[]): TodoItem[] { - let todos: TodoItem[] | undefined; - - for (const message of messages) { - if (message.role === 'plan') { - if (hasActiveTodos(message.todos)) { - todos = message.todos; - } else { - todos = []; - } - continue; - } - if (message.role !== 'tool_group') continue; - - for (const tool of message.tools) { - const nextTodos = extractTodosFromToolCall(tool); - if (nextTodos) { - todos = hasActiveTodos(nextTodos) ? nextTodos : []; - } - } - } - - return todos ?? []; -} - function translateCopyMessage( message: string, t: ReturnType, @@ -671,20 +641,49 @@ export function App({ const pendingApprovalRef = useRef(pendingApproval); pendingApprovalRef.current = pendingApproval; const shouldHideComposer = pendingApproval !== null; - const rawFloatingTodos = useMemo( + const floatingTodosState = useMemo( () => getFloatingTodos(messages), [messages], ); const floatingTodos = useStableArray( - rawFloatingTodos, + floatingTodosState.todos, (t) => `${t.id}:${t.status}:${t.content}`, ); + const floatingTodosAllCompleted = floatingTodosState.allCompleted; + // The all-completed list is only shown as a transient "all done" moment + // when the panel was already visible live in this client; on session + // restore (catch-up replay) a historical finished list stays hidden. + // State is adjusted during render (not in an effect) so the + // active → completed transition doesn't unmount the panel for a frame. + const [todoPanelMode, setTodoPanelMode] = useState< + 'hidden' | 'active' | 'completed' + >('hidden'); + const nextTodoPanelMode = + connection.catchingUp || floatingTodos.length === 0 + ? 'hidden' + : !floatingTodosAllCompleted + ? 'active' + : todoPanelMode === 'hidden' + ? 'hidden' + : 'completed'; + if (nextTodoPanelMode !== todoPanelMode) { + setTodoPanelMode(nextTodoPanelMode); + } + const showFloatingTodos = nextTodoPanelMode !== 'hidden'; const backgroundTaskActivityKey = useMemo( () => getBackgroundTaskActivityKey(messages), [messages], ); const statusBarRef = useRef(null); const editorRef = useRef(null); + const messageListRef = useRef(null); + const handleLocateFloatingTodos = useCallback(() => { + if (!floatingTodosState.sourceMessageId) return; + messageListRef.current?.scrollToMessage( + floatingTodosState.sourceMessageId, + floatingTodosState.sourceCallId ?? undefined, + ); + }, [floatingTodosState.sourceMessageId, floatingTodosState.sourceCallId]); const [activeGoal, setActiveGoal] = useState(null); const activeGoalRef = useRef(null); activeGoalRef.current = activeGoal; @@ -2313,13 +2312,14 @@ export function App({
0 + showFloatingTodos ? `${styles.content} ${styles.contentHasMessages}` : styles.content } style={dialogOpen ? { visibility: 'hidden' } : undefined} > - {floatingTodos.length > 0 && !tasksPanelMessage && ( + {showFloatingTodos && !tasksPanelMessage && (
- +
)} {!shouldHideComposer && ( diff --git a/packages/web-shell/client/components/MessageList.module.css b/packages/web-shell/client/components/MessageList.module.css index 66e77dcb10b..c475df75c41 100644 --- a/packages/web-shell/client/components/MessageList.module.css +++ b/packages/web-shell/client/components/MessageList.module.css @@ -19,3 +19,18 @@ background: var(--border-color); border-radius: 3px; } + +.rowFlash { + border-radius: 8px; + animation: row-flash 1.6s ease-out; +} + +@keyframes row-flash { + 0%, + 30% { + background: color-mix(in srgb, var(--accent-color) 16%, transparent); + } + 100% { + background: transparent; + } +} diff --git a/packages/web-shell/client/components/MessageList.test.ts b/packages/web-shell/client/components/MessageList.test.ts index 3bd188d4a61..d7fd8624398 100644 --- a/packages/web-shell/client/components/MessageList.test.ts +++ b/packages/web-shell/client/components/MessageList.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import type { Message } from '../adapters/types'; import { + findDisplayItemIndex, getDisplayItemVirtualKey, groupParallelAgents, shouldUseVirtualScroll, @@ -255,3 +256,41 @@ describe('shouldUseVirtualScroll', () => { expect(shouldUseVirtualScroll(51, 50)).toBe(true); }); }); + +describe('findDisplayItemIndex', () => { + it('finds a row by message id', () => { + const items = groupParallelAgents([ + makeUserMessage('u1'), + makeMultiToolGroup('g1'), + makeUserMessage('u2'), + ]); + expect(findDisplayItemIndex(items, 'g1')).toBe(1); + expect(findDisplayItemIndex(items, 'missing')).toBe(-1); + }); + + it('falls back to the call id when the message id was merged away', () => { + // Simulates compact mode, where consecutive tool groups collapse into + // the first group's message id. + const merged: Message = { + id: 'g1', + role: 'tool_group', + tools: [ + { callId: 'call-a', toolName: 'Read', status: 'completed' }, + { callId: 'call-b', toolName: 'TodoWrite', status: 'completed' }, + ], + }; + const items = groupParallelAgents([makeUserMessage('u1'), merged]); + expect(findDisplayItemIndex(items, 'g2', 'call-b')).toBe(1); + expect(findDisplayItemIndex(items, 'g2', 'call-x')).toBe(-1); + }); + + it('finds tool calls grouped into a parallel agents row', () => { + const items = groupParallelAgents([ + makeAgentToolGroup('a1'), + makeAgentToolGroup('a2'), + ]); + expect(items).toHaveLength(1); + expect(items[0].type).toBe('parallel_agents'); + expect(findDisplayItemIndex(items, 'a2', 'call-a2')).toBe(0); + }); +}); diff --git a/packages/web-shell/client/components/MessageList.tsx b/packages/web-shell/client/components/MessageList.tsx index e213a80c73f..8082f89298c 100644 --- a/packages/web-shell/client/components/MessageList.tsx +++ b/packages/web-shell/client/components/MessageList.tsx @@ -1,10 +1,13 @@ import { + forwardRef, useContext, useEffect, + useImperativeHandle, useLayoutEffect, useRef, useCallback, useMemo, + useState, type ReactNode, type MutableRefObject, } from 'react'; @@ -264,6 +267,45 @@ export function getDisplayItemVirtualKey(item: DisplayItem): string { : `msg:${item.key}`; } +/** + * Locate a display item by message id, falling back to the tool call id for + * tool groups that were merged (compact mode) or grouped (parallel agents) + * under another message's id. + */ +export function findDisplayItemIndex( + items: readonly DisplayItem[], + messageId: string, + callId?: string, +): number { + for (let i = 0; i < items.length; i++) { + const item = items[i]; + if (item.type === 'message') { + if (item.message.id === messageId) return i; + if ( + callId && + item.message.role === 'tool_group' && + item.message.tools.some((tool) => toolContainsCallId(tool, callId)) + ) { + return i; + } + } else if ( + callId && + item.agents.some((agent) => toolContainsCallId(agent, callId)) + ) { + return i; + } + } + return -1; +} + +export interface MessageListHandle { + /** + * Scroll the transcript so the given message is visible and briefly + * highlight it. Returns false when the message is not in the list. + */ + scrollToMessage: (messageId: string, callId?: string) => boolean; +} + const HEADER_INDEX = 0; const ESTIMATE_HEADER = 120; const ESTIMATE_MESSAGE = 80; @@ -278,391 +320,445 @@ export function shouldUseVirtualScroll( return totalCount > threshold; } -export function MessageList({ - messages, - pendingApproval, - onConfirm, - onShowContextDetail, - catchingUp, - welcomeHeader, - workspaceCwd, - tailContent, - tailKey = 'tail', - virtualScrollThreshold = VIRTUAL_SCROLL_THRESHOLD, - autoScrollTailIntoView = false, -}: MessageListProps) { - const compactMode = useContext(CompactModeContext); - const mergedMessages = useMemo( - () => - compactMode - ? mergeCompactToolGroups(messages, pendingApproval) - : messages, - [compactMode, messages, pendingApproval], - ); - const displayItems = useMemo( - () => groupParallelAgents(mergedMessages), - [mergedMessages], - ); - const containerRef = useRef(null); - - // ── Scroll-follow state ────────────────────────────────────────────── - // - // The scroll behavior follows 6 rules: - // - // 1. Default follow-bottom — while the user is looking at the bottom, - // new content (streaming tokens, tool cards expanding, approval - // cards appearing, any height change) keeps the viewport pinned - // to the latest output. - // - // 2. Scroll-up pauses follow — if the user scrolls up, the page - // assumes they want to read history and stops auto-scrolling. - // Even if the model is still streaming, the viewport stays put. - // - // 3. Scroll-back-to-bottom resumes — when the user scrolls back - // near the bottom (< 30px from edge), follow mode re-engages - // and new content resumes sticking. - // - // 4. New message resets follow — after the user sends a message, - // follow mode is forced on so the model's reply scrolls in - // naturally. - // - // 5. Session restore / reconnect — during history replay - // (`catchingUp === true`), all auto-scrolling is suppressed to - // avoid fighting the rapidly replaying transcript. Once replay - // finishes (`catchingUp` flips to falsy), a single scroll-to- - // bottom fires so the user lands at the latest content. - // - // 6. Short content — if the content doesn't overflow the container - // (no scrollbar), scrollToBottom is a no-op. This avoids a - // visual flash when the model just started replying with a - // short first chunk. - // - // Implementation: three refs, three effects, one scroll handler. - // - // - `shouldFollow` — whether auto-scroll is active - // - `lastScrollTop` — previous scrollTop for direction detection - // - `prevLastUserMsgId` — tracks when a new user message appears - // - `prevCatchingUp` — tracks the catchingUp → ready transition - // - // The single auto-scroll driver is a `useLayoutEffect` on - // `totalVirtualSize` (the virtualizer's computed content height). - // Every height change — streaming text, card expand, approval - // appearance — flows through this one effect. - // ───────────────────────────────────────────────────────────────────── - - const shouldFollow = useRef(true); - const lastScrollTop = useRef(0); - const scrollCooldown = useRef(false); - const scrollCooldownCount = useRef(0); - const prevLastUserMsgId = useRef(null); - const prevCatchingUp: MutableRefObject = - useRef(catchingUp); - const catchingUpRef = useRef(catchingUp); - const prevHasTailContent = useRef(false); - catchingUpRef.current = catchingUp; - - const hasTailApproval = useMemo(() => { - if (!pendingApproval) return false; - if (isAskUserQuestion(pendingApproval)) return true; - return !approvalMatchesToolGroup(messages, pendingApproval); - }, [pendingApproval, messages]); - - const hasTailContent = tailContent !== undefined && tailContent !== null; - const hasHeader = !!welcomeHeader; - const headerOffset = hasHeader ? 1 : 0; - const tailApprovalIndex = headerOffset + displayItems.length; - const tailContentIndex = tailApprovalIndex + (hasTailApproval ? 1 : 0); - const totalCount = tailContentIndex + (hasTailContent ? 1 : 0); - const useVirtualScroll = shouldUseVirtualScroll( - totalCount, - virtualScrollThreshold, - ); - - const getItemKey = useCallback( - (index: number) => { - if (hasHeader && index === HEADER_INDEX) return 'slot:header'; - if (hasTailApproval && index === tailApprovalIndex) { - return pendingApproval - ? `slot:approval:${pendingApproval.id}` - : 'slot:approval'; - } - if (hasTailContent && index === tailContentIndex) { - return `slot:tail:${tailKey}`; - } - const item = displayItems[index - headerOffset]; - return item ? getDisplayItemVirtualKey(item) : `slot:row:${index}`; - }, - [ - hasHeader, - hasTailApproval, - tailApprovalIndex, +export const MessageList = forwardRef( + function MessageList( + { + messages, pendingApproval, - hasTailContent, - tailContentIndex, - tailKey, - displayItems, - headerOffset, - ], - ); - - // Rule 6: skip if content doesn't overflow (no scrollbar). - const scrollToBottom = useCallback(() => { - const el = containerRef.current; - if (!el) return; - if (el.scrollHeight <= el.clientHeight) return; - scrollCooldownCount.current += 1; - const gen = scrollCooldownCount.current; - scrollCooldown.current = true; - el.scrollTop = el.scrollHeight; - lastScrollTop.current = el.scrollTop; - requestAnimationFrame(() => { - if (scrollCooldownCount.current === gen) { - scrollCooldown.current = false; - } - }); - }, []); - - const virtualizer = useVirtualizer({ - count: totalCount, - enabled: useVirtualScroll, - getScrollElement: () => containerRef.current, - getItemKey, - estimateSize: (index) => { - if (hasHeader && index === HEADER_INDEX) return ESTIMATE_HEADER; - if (hasTailApproval && index === tailApprovalIndex) { - return ESTIMATE_APPROVAL; - } - if (hasTailContent && index === tailContentIndex) return ESTIMATE_TAIL; - return ESTIMATE_MESSAGE; + onConfirm, + onShowContextDetail, + catchingUp, + welcomeHeader, + workspaceCwd, + tailContent, + tailKey = 'tail', + virtualScrollThreshold = VIRTUAL_SCROLL_THRESHOLD, + autoScrollTailIntoView = false, }, - overscan: 20, - useFlushSync: false, - useAnimationFrameWithResizeObserver: true, - }); - - // Rules 2 & 3: detect scroll direction to toggle follow mode. - // Runs synchronously in the scroll handler — no rAF needed since - // the browser already coalesces scroll events. - const handleScroll = useCallback(() => { - const el = containerRef.current; - if (!el) return; - if (scrollCooldown.current) { - lastScrollTop.current = el.scrollTop; - return; - } - const prev = lastScrollTop.current; - const curr = el.scrollTop; - lastScrollTop.current = curr; - const distanceFromBottom = el.scrollHeight - curr - el.clientHeight; - - // Rule 2: scrolling up → pause follow - if (curr < prev - 1) { - shouldFollow.current = false; - } - // Rule 3: near bottom → resume follow - // (runs unconditionally so that container-resize-induced scrollTop - // clamping — which looks like scrolling up — doesn't permanently - // disable follow when the viewport is still near the bottom) - if (distanceFromBottom < 30) { - shouldFollow.current = true; - } - }, []); + ref, + ) { + const compactMode = useContext(CompactModeContext); + const mergedMessages = useMemo( + () => + compactMode + ? mergeCompactToolGroups(messages, pendingApproval) + : messages, + [compactMode, messages, pendingApproval], + ); + const displayItems = useMemo( + () => groupParallelAgents(mergedMessages), + [mergedMessages], + ); + const containerRef = useRef(null); + + // ── Scroll-follow state ────────────────────────────────────────────── + // + // The scroll behavior follows 6 rules: + // + // 1. Default follow-bottom — while the user is looking at the bottom, + // new content (streaming tokens, tool cards expanding, approval + // cards appearing, any height change) keeps the viewport pinned + // to the latest output. + // + // 2. Scroll-up pauses follow — if the user scrolls up, the page + // assumes they want to read history and stops auto-scrolling. + // Even if the model is still streaming, the viewport stays put. + // + // 3. Scroll-back-to-bottom resumes — when the user scrolls back + // near the bottom (< 30px from edge), follow mode re-engages + // and new content resumes sticking. + // + // 4. New message resets follow — after the user sends a message, + // follow mode is forced on so the model's reply scrolls in + // naturally. + // + // 5. Session restore / reconnect — during history replay + // (`catchingUp === true`), all auto-scrolling is suppressed to + // avoid fighting the rapidly replaying transcript. Once replay + // finishes (`catchingUp` flips to falsy), a single scroll-to- + // bottom fires so the user lands at the latest content. + // + // 6. Short content — if the content doesn't overflow the container + // (no scrollbar), scrollToBottom is a no-op. This avoids a + // visual flash when the model just started replying with a + // short first chunk. + // + // Implementation: three refs, three effects, one scroll handler. + // + // - `shouldFollow` — whether auto-scroll is active + // - `lastScrollTop` — previous scrollTop for direction detection + // - `prevLastUserMsgId` — tracks when a new user message appears + // - `prevCatchingUp` — tracks the catchingUp → ready transition + // + // The single auto-scroll driver is a `useLayoutEffect` on + // `totalVirtualSize` (the virtualizer's computed content height). + // Every height change — streaming text, card expand, approval + // appearance — flows through this one effect. + // ───────────────────────────────────────────────────────────────────── + + const shouldFollow = useRef(true); + const lastScrollTop = useRef(0); + const scrollCooldown = useRef(false); + const scrollCooldownCount = useRef(0); + const prevLastUserMsgId = useRef(null); + const prevCatchingUp: MutableRefObject = + useRef(catchingUp); + const catchingUpRef = useRef(catchingUp); + const prevHasTailContent = useRef(false); + catchingUpRef.current = catchingUp; + + const hasTailApproval = useMemo(() => { + if (!pendingApproval) return false; + if (isAskUserQuestion(pendingApproval)) return true; + return !approvalMatchesToolGroup(messages, pendingApproval); + }, [pendingApproval, messages]); + + const hasTailContent = tailContent !== undefined && tailContent !== null; + const hasHeader = !!welcomeHeader; + const headerOffset = hasHeader ? 1 : 0; + const tailApprovalIndex = headerOffset + displayItems.length; + const tailContentIndex = tailApprovalIndex + (hasTailApproval ? 1 : 0); + const totalCount = tailContentIndex + (hasTailContent ? 1 : 0); + const useVirtualScroll = shouldUseVirtualScroll( + totalCount, + virtualScrollThreshold, + ); - // Clear screen (e.g. /clear) → reset to follow mode. - useEffect(() => { - if (messages.length === 0) { - shouldFollow.current = true; - } - }, [messages.length]); - - // Container-resize guard: when floating panels (e.g. TodoPanel) - // appear or disappear the scroll container's clientHeight changes. - // Snap back to bottom so the user doesn't lose their place while - // follow mode is active. - useEffect(() => { - const el = containerRef.current; - if (!el) return; - const observer = new ResizeObserver(() => { - if (catchingUpRef.current) return; - if (!shouldFollow.current) return; + const getItemKey = useCallback( + (index: number) => { + if (hasHeader && index === HEADER_INDEX) return 'slot:header'; + if (hasTailApproval && index === tailApprovalIndex) { + return pendingApproval + ? `slot:approval:${pendingApproval.id}` + : 'slot:approval'; + } + if (hasTailContent && index === tailContentIndex) { + return `slot:tail:${tailKey}`; + } + const item = displayItems[index - headerOffset]; + return item ? getDisplayItemVirtualKey(item) : `slot:row:${index}`; + }, + [ + hasHeader, + hasTailApproval, + tailApprovalIndex, + pendingApproval, + hasTailContent, + tailContentIndex, + tailKey, + displayItems, + headerOffset, + ], + ); + + // Rule 6: skip if content doesn't overflow (no scrollbar). + const scrollToBottom = useCallback(() => { + const el = containerRef.current; + if (!el) return; + if (el.scrollHeight <= el.clientHeight) return; + scrollCooldownCount.current += 1; + const gen = scrollCooldownCount.current; + scrollCooldown.current = true; + el.scrollTop = el.scrollHeight; + lastScrollTop.current = el.scrollTop; requestAnimationFrame(() => { - if (!catchingUpRef.current && shouldFollow.current) { - scrollToBottom(); + if (scrollCooldownCount.current === gen) { + scrollCooldown.current = false; } }); + }, []); + + const virtualizer = useVirtualizer({ + count: totalCount, + enabled: useVirtualScroll, + getScrollElement: () => containerRef.current, + getItemKey, + estimateSize: (index) => { + if (hasHeader && index === HEADER_INDEX) return ESTIMATE_HEADER; + if (hasTailApproval && index === tailApprovalIndex) { + return ESTIMATE_APPROVAL; + } + if (hasTailContent && index === tailContentIndex) return ESTIMATE_TAIL; + return ESTIMATE_MESSAGE; + }, + overscan: 20, + useFlushSync: false, + useAnimationFrameWithResizeObserver: true, }); - observer.observe(el); - return () => observer.disconnect(); - }, [scrollToBottom]); - - // Rule 4: new user message → force follow on so the model's reply - // scrolls into view as it streams in. - useEffect(() => { - const lastId = getLastUserMessageId(messages); - if (catchingUp) { - prevLastUserMsgId.current = lastId; - return; - } - if (lastId && lastId !== prevLastUserMsgId.current) { - shouldFollow.current = true; - requestAnimationFrame(scrollToBottom); - } - prevLastUserMsgId.current = lastId; - }, [messages, catchingUp, scrollToBottom]); - - // Rule 5: session restore — when catchingUp flips from true → falsy, - // replay just finished. Scroll to bottom once so the user sees the - // latest content without the viewport fighting the replay. - useEffect(() => { - if (prevCatchingUp.current && !catchingUp) { - shouldFollow.current = true; - requestAnimationFrame(scrollToBottom); - } - prevCatchingUp.current = catchingUp; - }, [catchingUp, scrollToBottom]); - - // Rule 6: an inline picker/dialog (tailContent) just appeared. It renders - // at the very bottom of the virtualized list, so if the user had scrolled - // up it would open below the fold and the action would look like a no-op. - // Only opt-in callers (autoScrollTailIntoView) force-follow it into view, so - // unrelated tail panels keep the reader's scroll position. - useEffect(() => { - if ( - autoScrollTailIntoView && - hasTailContent && - !prevHasTailContent.current - ) { - shouldFollow.current = true; - // Re-check follow inside the frame: if the user scrolls up in the gap - // before it fires (Rule 2 clears the flag), don't fight them. - requestAnimationFrame(() => { - if (shouldFollow.current) scrollToBottom(); - }); - } - prevHasTailContent.current = hasTailContent; - }, [autoScrollTailIntoView, hasTailContent, scrollToBottom]); - const renderVirtualItem = useCallback( - (index: number) => { - if (hasHeader && index === HEADER_INDEX) { - return welcomeHeader; + // Imperative scroll-to-message (e.g. the floating TodoPanel's "show in + // transcript" button) with a brief highlight on the target row. + const [flashKey, setFlashKey] = useState(null); + useEffect(() => { + if (!flashKey) return; + const timer = setTimeout(() => setFlashKey(null), 1600); + return () => clearTimeout(timer); + }, [flashKey]); + + const scrollToMessage = useCallback( + (messageId: string, callId?: string): boolean => { + const itemIndex = findDisplayItemIndex(displayItems, messageId, callId); + if (itemIndex < 0) return false; + const rowIndex = itemIndex + headerOffset; + // Explicit navigation away from the tail — pause follow so the + // auto-scroll driver doesn't yank the viewport straight back down. + shouldFollow.current = false; + if (useVirtualScroll) { + virtualizer.scrollToIndex(rowIndex, { align: 'center' }); + } else { + containerRef.current + ?.querySelector(`[data-index="${rowIndex}"]`) + ?.scrollIntoView({ block: 'center', behavior: 'smooth' }); + } + const key = getItemKey(rowIndex); + setFlashKey(null); + requestAnimationFrame(() => setFlashKey(key)); + return true; + }, + [displayItems, headerOffset, useVirtualScroll, virtualizer, getItemKey], + ); + + useImperativeHandle(ref, () => ({ scrollToMessage }), [scrollToMessage]); + + // Rules 2 & 3: detect scroll direction to toggle follow mode. + // Runs synchronously in the scroll handler — no rAF needed since + // the browser already coalesces scroll events. + const handleScroll = useCallback(() => { + const el = containerRef.current; + if (!el) return; + if (scrollCooldown.current) { + lastScrollTop.current = el.scrollTop; + return; } + const prev = lastScrollTop.current; + const curr = el.scrollTop; + lastScrollTop.current = curr; + const distanceFromBottom = el.scrollHeight - curr - el.clientHeight; + + // Rule 2: scrolling up → pause follow + if (curr < prev - 1) { + shouldFollow.current = false; + } + // Rule 3: near bottom → resume follow + // (runs unconditionally so that container-resize-induced scrollTop + // clamping — which looks like scrolling up — doesn't permanently + // disable follow when the viewport is still near the bottom) + if (distanceFromBottom < 30) { + shouldFollow.current = true; + } + }, []); - if (hasTailApproval && index === tailApprovalIndex) { - if (pendingApproval && isAskUserQuestion(pendingApproval)) { - return ( - - ); + // Clear screen (e.g. /clear) → reset to follow mode. + useEffect(() => { + if (messages.length === 0) { + shouldFollow.current = true; + } + }, [messages.length]); + + // Container-resize guard: when floating panels (e.g. TodoPanel) + // appear or disappear the scroll container's clientHeight changes. + // Snap back to bottom so the user doesn't lose their place while + // follow mode is active. + useEffect(() => { + const el = containerRef.current; + if (!el) return; + const observer = new ResizeObserver(() => { + if (catchingUpRef.current) return; + if (!shouldFollow.current) return; + requestAnimationFrame(() => { + if (!catchingUpRef.current && shouldFollow.current) { + scrollToBottom(); + } + }); + }); + observer.observe(el); + return () => observer.disconnect(); + }, [scrollToBottom]); + + // Rule 4: new user message → force follow on so the model's reply + // scrolls into view as it streams in. + useEffect(() => { + const lastId = getLastUserMessageId(messages); + if (catchingUp) { + prevLastUserMsgId.current = lastId; + return; + } + if (lastId && lastId !== prevLastUserMsgId.current) { + shouldFollow.current = true; + requestAnimationFrame(scrollToBottom); + } + prevLastUserMsgId.current = lastId; + }, [messages, catchingUp, scrollToBottom]); + + // Rule 5: session restore — when catchingUp flips from true → falsy, + // replay just finished. Scroll to bottom once so the user sees the + // latest content without the viewport fighting the replay. + useEffect(() => { + if (prevCatchingUp.current && !catchingUp) { + shouldFollow.current = true; + requestAnimationFrame(scrollToBottom); + } + prevCatchingUp.current = catchingUp; + }, [catchingUp, scrollToBottom]); + + // Rule 6: an inline picker/dialog (tailContent) just appeared. It renders + // at the very bottom of the virtualized list, so if the user had scrolled + // up it would open below the fold and the action would look like a no-op. + // Only opt-in callers (autoScrollTailIntoView) force-follow it into view, so + // unrelated tail panels keep the reader's scroll position. + useEffect(() => { + if ( + autoScrollTailIntoView && + hasTailContent && + !prevHasTailContent.current + ) { + shouldFollow.current = true; + // Re-check follow inside the frame: if the user scrolls up in the gap + // before it fires (Rule 2 clears the flag), don't fight them. + requestAnimationFrame(() => { + if (shouldFollow.current) scrollToBottom(); + }); + } + prevHasTailContent.current = hasTailContent; + }, [autoScrollTailIntoView, hasTailContent, scrollToBottom]); + + const renderVirtualItem = useCallback( + (index: number) => { + if (hasHeader && index === HEADER_INDEX) { + return welcomeHeader; } - if (pendingApproval) { - return ( - - ); + + if (hasTailApproval && index === tailApprovalIndex) { + if (pendingApproval && isAskUserQuestion(pendingApproval)) { + return ( + + ); + } + if (pendingApproval) { + return ( + + ); + } + return null; } - return null; - } - if (hasTailContent && index === tailContentIndex) { - return tailContent; - } + if (hasTailContent && index === tailContentIndex) { + return tailContent; + } + + const itemIndex = index - headerOffset; + const item = displayItems[itemIndex]; + if (!item) return null; - const itemIndex = index - headerOffset; - const item = displayItems[itemIndex]; - if (!item) return null; + if (item.type === 'parallel_agents') { + return ( + + ); + } - if (item.type === 'parallel_agents') { return ( - ); - } - - return ( - - ); - }, - [ - hasHeader, - welcomeHeader, - hasTailContent, - tailContent, - tailContentIndex, - hasTailApproval, - tailApprovalIndex, - pendingApproval, - onConfirm, - onShowContextDetail, - headerOffset, - displayItems, - workspaceCwd, - ], - ); - - const virtualItems = virtualizer.getVirtualItems(); - const totalVirtualSize = virtualizer.getTotalSize(); - - // ── Single auto-scroll driver (rules 1, 5, 6) ────────────────────── - // Fires whenever the virtualizer's total content height changes — - // this captures every scenario: streaming tokens appending, tool - // cards expanding/collapsing, approval cards appearing, etc. - // - // Rule 5: during replay (catchingUp) → skip, avoid fighting rapid - // transcript replay. The catchingUp→ready transition effect - // above handles the final scroll. - // Rule 1: when shouldFollow is true → scroll to bottom. - // Rule 6: scrollToBottom itself checks scrollHeight <= clientHeight - // and is a no-op when there's no overflow. - useLayoutEffect(() => { - if (catchingUp) return; - if (shouldFollow.current) { - scrollToBottom(); - } - }, [totalVirtualSize, messages, totalCount, catchingUp, scrollToBottom]); + }, + [ + hasHeader, + welcomeHeader, + hasTailContent, + tailContent, + tailContentIndex, + hasTailApproval, + tailApprovalIndex, + pendingApproval, + onConfirm, + onShowContextDetail, + headerOffset, + displayItems, + workspaceCwd, + ], + ); - return ( -
- {useVirtualScroll ? ( -
- {virtualItems.map((virtualRow) => ( -
- {renderVirtualItem(virtualRow.index)} -
- ))} -
- ) : ( - Array.from({ length: totalCount }, (_, index) => ( -
- {renderVirtualItem(index)} + const virtualItems = virtualizer.getVirtualItems(); + const totalVirtualSize = virtualizer.getTotalSize(); + + // ── Single auto-scroll driver (rules 1, 5, 6) ────────────────────── + // Fires whenever the virtualizer's total content height changes — + // this captures every scenario: streaming tokens appending, tool + // cards expanding/collapsing, approval cards appearing, etc. + // + // Rule 5: during replay (catchingUp) → skip, avoid fighting rapid + // transcript replay. The catchingUp→ready transition effect + // above handles the final scroll. + // Rule 1: when shouldFollow is true → scroll to bottom. + // Rule 6: scrollToBottom itself checks scrollHeight <= clientHeight + // and is a no-op when there's no overflow. + useLayoutEffect(() => { + if (catchingUp) return; + if (shouldFollow.current) { + scrollToBottom(); + } + }, [totalVirtualSize, messages, totalCount, catchingUp, scrollToBottom]); + + return ( +
+ {useVirtualScroll ? ( +
+ {virtualItems.map((virtualRow) => ( +
+ {renderVirtualItem(virtualRow.index)} +
+ ))}
- )) - )} -
- ); -} + ) : ( + Array.from({ length: totalCount }, (_, index) => { + const key = getItemKey(index); + return ( +
+ {renderVirtualItem(index)} +
+ ); + }) + )} +
+ ); + }, +); diff --git a/packages/web-shell/client/components/messages/PlanMessage.module.css b/packages/web-shell/client/components/messages/PlanMessage.module.css index 3015a5f0a9d..bb11eb952f0 100644 --- a/packages/web-shell/client/components/messages/PlanMessage.module.css +++ b/packages/web-shell/client/components/messages/PlanMessage.module.css @@ -24,10 +24,10 @@ } .num { - width: 24px; flex-shrink: 0; text-align: right; color: var(--text-dimmed); + font-variant-numeric: tabular-nums; } .marker { @@ -42,7 +42,7 @@ .inProgress .marker, .inProgress .content { - color: var(--success-color); + color: var(--accent-color); } .completed .marker { diff --git a/packages/web-shell/client/components/messages/PlanMessage.tsx b/packages/web-shell/client/components/messages/PlanMessage.tsx index 23a840e58e5..eb0f38ce5f6 100644 --- a/packages/web-shell/client/components/messages/PlanMessage.tsx +++ b/packages/web-shell/client/components/messages/PlanMessage.tsx @@ -1,5 +1,6 @@ import { memo } from 'react'; import type { TodoItem } from '../../adapters/types'; +import { getTodoStatusIcon } from '../../utils/todos'; import { useI18n } from '../../i18n'; import styles from './PlanMessage.module.css'; @@ -7,12 +8,6 @@ interface PlanMessageProps { todos: TodoItem[]; } -function markerForStatus(status: TodoItem['status']): string { - if (status === 'completed') return '✓'; - if (status === 'in_progress') return '→'; - return ' '; -} - function getStatusClass(status: TodoItem['status']): string { switch (status) { case 'completed': @@ -30,6 +25,10 @@ export const PlanMessage = memo(function PlanMessage({ const { t } = useI18n(); if (todos.length === 0) return null; + // Size the number column to the widest index so the status markers stay + // aligned once the list grows past 9 items. + const numColumnWidth = `${String(todos.length).length + 1}ch`; + return (
{t('plan.title')}
@@ -39,9 +38,11 @@ export const PlanMessage = memo(function PlanMessage({ key={todo.id || index} className={`${styles.item} ${getStatusClass(todo.status)}`} > - {index + 1}. + + {index + 1}. + - {markerForStatus(todo.status)} + {getTodoStatusIcon(todo.status)} {todo.content}
diff --git a/packages/web-shell/client/components/messages/ToolGroup.tsx b/packages/web-shell/client/components/messages/ToolGroup.tsx index ab00472aba4..a96602f3b60 100644 --- a/packages/web-shell/client/components/messages/ToolGroup.tsx +++ b/packages/web-shell/client/components/messages/ToolGroup.tsx @@ -12,7 +12,7 @@ import { SubAgentPanel } from './tools/SubAgentPanel'; import { DiffView } from './tools/DiffView'; import { ToolApproval } from './ToolApproval'; import { parseAnsi, hasAnsi } from '../../utils/ansi'; -import { extractTodosFromToolCall } from '../../utils/todos'; +import { extractTodosFromToolCall, getTodoStatusIcon } from '../../utils/todos'; import { formatDurationMs, formatElapsed, @@ -284,7 +284,7 @@ function TodoWriteContent({ tool }: { tool: ACPToolCall }) { key={todo.id || i} className={`${styles.todoItem} ${getTodoClass(todo.status)}`} > - {getTodoIcon(todo.status)} {todo.content} + {getTodoStatusIcon(todo.status)} {todo.content}
))}
@@ -326,17 +326,6 @@ function getTodoClass(status: TodoItem['status']): string { } } -function getTodoIcon(status: TodoItem['status']): string { - switch (status) { - case 'completed': - return '●'; - case 'in_progress': - return '◐'; - case 'pending': - return '○'; - } -} - interface ToolLineProps { tool: ACPToolCall; approval?: PermissionRequest | null; diff --git a/packages/web-shell/client/components/panels/TodoPanel.module.css b/packages/web-shell/client/components/panels/TodoPanel.module.css index 2bac2d02c27..d4e55ae05b5 100644 --- a/packages/web-shell/client/components/panels/TodoPanel.module.css +++ b/packages/web-shell/client/components/panels/TodoPanel.module.css @@ -1,6 +1,6 @@ .panel { flex-shrink: 0; - padding: 10px 16px; + padding: 6px 10px 8px; margin: 0 16px 4px; border: 1px solid var(--border-color); border-radius: 8px; @@ -9,28 +9,91 @@ } .header { - margin-bottom: 6px; + display: flex; + align-items: center; + gap: 4px; + min-width: 0; +} + +.toggle { + flex: 1; + display: flex; + align-items: baseline; + gap: 6px; + min-width: 0; + margin: 0; + padding: 2px 0; + background: none; + border: none; + cursor: pointer; + font: inherit; + text-align: left; + color: var(--text-primary); +} + +.chevron { + flex-shrink: 0; + width: 12px; + font-size: 10px; + color: var(--text-dimmed); } .title { + flex-shrink: 0; font-size: 13px; font-weight: 600; color: var(--text-primary); - text-decoration: underline; - text-underline-offset: 3px; +} + +.progress { + flex-shrink: 0; + font-size: 12px; + font-family: var(--font-mono); + color: var(--text-dimmed); +} + +.collapsedCurrent { + display: flex; + align-items: baseline; + gap: 4px; + min-width: 0; + margin-left: 6px; + font-size: 13px; + font-family: var(--font-mono); +} + +.locate { + flex-shrink: 0; + margin: 0; + padding: 2px 6px; + background: none; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 13px; + line-height: 1; + color: var(--text-dimmed); +} + +.locate:hover { + color: var(--accent-color); } .list { display: flex; flex-direction: column; gap: 3px; + margin-top: 6px; padding-left: 4px; + max-height: 40vh; + overflow-y: auto; } .item { display: flex; align-items: baseline; gap: 4px; + min-width: 0; font-size: 13px; font-family: var(--font-mono); line-height: 1.5; @@ -39,7 +102,7 @@ .num { flex-shrink: 0; color: var(--text-dimmed); - min-width: 20px; + text-align: right; } .icon { @@ -56,11 +119,11 @@ } .inProgress .icon { - color: var(--success-color); + color: var(--accent-color); } .inProgress .content { - color: var(--success-color); + color: var(--accent-color); } .completed .icon { @@ -72,10 +135,52 @@ } .content { + flex: 1; + min-width: 0; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; color: var(--text-secondary); } -.more .content { +.moreLine { + display: flex; + align-items: baseline; + gap: 4px; + width: fit-content; + margin: 0; + padding: 0; + background: none; + border: none; + cursor: pointer; + /* Match .item so the num placeholder resolves to the same ch width. */ + font-size: 13px; + font-family: var(--font-mono); + line-height: 1.5; color: var(--text-dimmed); +} + +.moreText { font-size: 12px; } + +.moreLine:hover { + color: var(--text-secondary); +} + +.moreLine:hover .moreText { + text-decoration: underline; +} + +.allDone { + font-size: 13px; + font-family: var(--font-mono); + color: var(--success-color); +} + +.collapsedCurrent.allDone { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + display: inline-block; +} diff --git a/packages/web-shell/client/components/panels/TodoPanel.tsx b/packages/web-shell/client/components/panels/TodoPanel.tsx index 5b692036232..8796f1d2b74 100644 --- a/packages/web-shell/client/components/panels/TodoPanel.tsx +++ b/packages/web-shell/client/components/panels/TodoPanel.tsx @@ -1,13 +1,37 @@ +import { memo, useState } from 'react'; import type { TodoItem } from '../../adapters/types'; +import { getTodoStatusIcon, getTodoWindow } from '../../utils/todos'; import { useI18n } from '../../i18n'; import styles from './TodoPanel.module.css'; interface TodoPanelProps { todos: TodoItem[]; title?: string; + /** Scroll the transcript to the message the todos came from. */ + onLocateSource?: () => void; } const MAX_VISIBLE = 5; +const COLLAPSED_STORAGE_KEY = 'web-shell:todo-panel-collapsed'; + +function loadCollapsed(): boolean { + try { + return window.localStorage.getItem(COLLAPSED_STORAGE_KEY) === 'true'; + } catch { + return false; + } +} + +function saveCollapsed(collapsed: boolean) { + try { + window.localStorage.setItem( + COLLAPSED_STORAGE_KEY, + collapsed ? 'true' : 'false', + ); + } catch { + // Ignore storage failures in private browsing or restricted contexts. + } +} function getStatusClass(status: TodoItem['status']): string { switch (status) { @@ -20,63 +44,173 @@ function getStatusClass(status: TodoItem['status']): string { } } -export function TodoPanel({ todos, title }: TodoPanelProps) { +export const TodoPanel = memo(function TodoPanel({ + todos, + title, + onLocateSource, +}: TodoPanelProps) { const { t } = useI18n(); + const [collapsed, setCollapsed] = useState(loadCollapsed); + const [showAll, setShowAll] = useState(false); if (todos.length === 0) return null; - // Rotate list so the current in_progress item is first - const currentIdx = todos.findIndex((t) => t.status === 'in_progress'); - const startIdx = - currentIdx >= 0 - ? currentIdx - : todos.findIndex((t) => t.status === 'pending'); - const rotated = - startIdx > 0 - ? [...todos.slice(startIdx), ...todos.slice(0, startIdx)] - : todos; + const total = todos.length; + const completedCount = todos.filter( + (todo) => todo.status === 'completed', + ).length; + const allCompleted = completedCount === total; + + // Current item: first in_progress, else first pending. + const inProgressIdx = todos.findIndex((td) => td.status === 'in_progress'); + const currentIdx = + inProgressIdx >= 0 + ? inProgressIdx + : todos.findIndex((td) => td.status === 'pending'); + const current = currentIdx >= 0 ? todos[currentIdx] : undefined; + + const { start, end } = showAll + ? { start: 0, end: total } + : getTodoWindow(todos, MAX_VISIBLE); + const visible = todos.slice(start, end); + const hiddenAbove = start; + const hiddenBelow = total - end; + const hiddenAboveAllCompleted = todos + .slice(0, start) + .every((td) => td.status === 'completed'); - const visible = rotated.slice(0, MAX_VISIBLE); - const remaining = rotated.length - MAX_VISIBLE; + const toggleCollapsed = () => { + setCollapsed((prev) => { + const next = !prev; + saveCollapsed(next); + return next; + }); + }; - // Map back to original index for numbering - const originalIndices = - startIdx > 0 - ? [...Array(todos.length).keys()].map( - (i) => (i + startIdx) % todos.length, - ) - : [...Array(todos.length).keys()]; + // Number column sized to the widest index ("10." is wider than "9.") so + // the status icons stay aligned past 9 items; exact in the mono font. + const numColumnWidth = `${String(total).length + 1}ch`; return ( -
+
- {title ?? t('todo.title')} -
-
- {visible.map((todo, i) => ( -
+ + {title ?? t('todo.title')} + + {completedCount}/{total} + + {collapsed && + (allCompleted ? ( + + ✓ {t('todo.allDone')} + + ) : current ? ( + + + + {current.content} + + + ) : null)} + + {onLocateSource && ( +
- ))} - {remaining > 0 && ( -
- - - {t('todo.more', { count: remaining })} - -
+ ↗ + )}
-
+ {!collapsed && ( +
+ {allCompleted ? ( +
✓ {t('todo.allDone')}
+ ) : ( + <> + {hiddenAbove > 0 && ( + + )} + {visible.map((todo, i) => ( +
+ + {start + i + 1}. + + + + {todo.content} + +
+ ))} + {hiddenBelow > 0 && ( + + )} + {showAll && total > MAX_VISIBLE && ( + + )} + + )} +
+ )} + ); -} +}); diff --git a/packages/web-shell/client/i18n.tsx b/packages/web-shell/client/i18n.tsx index 45204aaf1a5..b1d311406ab 100644 --- a/packages/web-shell/client/i18n.tsx +++ b/packages/web-shell/client/i18n.tsx @@ -734,7 +734,14 @@ const EN: Messages = { 'theme.light': 'Light', 'theme.light.desc': 'Terminal-style light skin.', 'theme.title': 'Theme', - 'todo.more': (v) => `... and ${v?.count ?? 0} more`, + 'todo.allDone': 'All tasks completed', + 'todo.collapse': 'Collapse task list', + 'todo.completedAbove': (v) => `✓ ${v?.count ?? 0} completed`, + 'todo.expand': 'Expand task list', + 'todo.locate': 'Show in transcript', + 'todo.more': (v) => `... ${v?.count ?? 0} more`, + 'todo.moreAbove': (v) => `... ${v?.count ?? 0} earlier`, + 'todo.showLess': 'Show less', 'todo.title': 'Current tasks', 'tasks.title': 'Background tasks', 'tasks.empty': 'No tasks currently running', @@ -1514,7 +1521,14 @@ const ZH: Messages = { 'theme.light': '亮色', 'theme.light.desc': '仿终端亮色皮肤。', 'theme.title': '主题', - 'todo.more': (v) => `... 以及其他 ${v?.count ?? 0} 个`, + 'todo.allDone': '任务已全部完成', + 'todo.collapse': '折叠任务列表', + 'todo.completedAbove': (v) => `✓ 已完成 ${v?.count ?? 0} 项`, + 'todo.expand': '展开任务列表', + 'todo.locate': '在会话中定位', + 'todo.more': (v) => `... 还有 ${v?.count ?? 0} 项`, + 'todo.moreAbove': (v) => `... 前面还有 ${v?.count ?? 0} 项`, + 'todo.showLess': '收起', 'todo.title': '当前任务', 'tasks.title': '后台任务', 'tasks.empty': '当前没有运行中的任务', diff --git a/packages/web-shell/client/utils/todos.test.ts b/packages/web-shell/client/utils/todos.test.ts new file mode 100644 index 00000000000..54e404591a5 --- /dev/null +++ b/packages/web-shell/client/utils/todos.test.ts @@ -0,0 +1,193 @@ +import { describe, expect, it } from 'vitest'; +import type { Message, TodoItem } from '../adapters/types'; +import { getFloatingTodos, getTodoWindow } from './todos'; + +function todo(id: string, status: TodoItem['status']): TodoItem { + return { id, content: `task ${id}`, status }; +} + +function planMessage(id: string, todos: TodoItem[]): Message { + return { id, role: 'plan', todos }; +} + +function todoWriteMessage(id: string, todos: TodoItem[]): Message { + return { + id, + role: 'tool_group', + tools: [ + { + callId: `call-${id}`, + toolName: 'TodoWrite', + status: 'completed', + args: { todos }, + }, + ], + }; +} + +function userMessage(id: string): Message { + return { id, role: 'user', content: 'hello' }; +} + +function assistantMessage(id: string): Message { + return { id, role: 'assistant', content: 'working on it' }; +} + +describe('getFloatingTodos', () => { + it('returns the empty state when no messages carry todos', () => { + expect( + getFloatingTodos([userMessage('u1'), assistantMessage('a1')]), + ).toEqual({ + todos: [], + allCompleted: false, + sourceMessageId: null, + sourceCallId: null, + }); + }); + + it('returns the latest active list with its source ids', () => { + const first = [todo('1', 'in_progress')]; + const second = [todo('1', 'completed'), todo('2', 'in_progress')]; + const state = getFloatingTodos([ + todoWriteMessage('m1', first), + todoWriteMessage('m2', second), + ]); + expect(state.todos.map((t) => t.id)).toEqual(['1', '2']); + expect(state.allCompleted).toBe(false); + expect(state.sourceMessageId).toBe('m2'); + expect(state.sourceCallId).toBe('call-m2'); + }); + + it('uses a null sourceCallId for plan messages', () => { + const state = getFloatingTodos([planMessage('p1', [todo('1', 'pending')])]); + expect(state.sourceMessageId).toBe('p1'); + expect(state.sourceCallId).toBeNull(); + }); + + it('keeps an active list visible across later user messages', () => { + const state = getFloatingTodos([ + todoWriteMessage('m1', [todo('1', 'in_progress')]), + userMessage('u1'), + ]); + expect(state.todos).toHaveLength(1); + expect(state.allCompleted).toBe(false); + }); + + it('returns an all-completed list until the next user message', () => { + const done = [todo('1', 'completed'), todo('2', 'completed')]; + const visible = getFloatingTodos([ + todoWriteMessage('m1', done), + assistantMessage('a1'), + ]); + expect(visible.todos).toHaveLength(2); + expect(visible.allCompleted).toBe(true); + + const hidden = getFloatingTodos([ + todoWriteMessage('m1', done), + userMessage('u1'), + ]); + expect(hidden.todos).toHaveLength(0); + }); + + it('shows a new active list started after a finished one', () => { + const state = getFloatingTodos([ + todoWriteMessage('m1', [todo('1', 'completed')]), + userMessage('u1'), + todoWriteMessage('m2', [todo('2', 'pending')]), + ]); + expect(state.todos.map((t) => t.id)).toEqual(['2']); + expect(state.sourceMessageId).toBe('m2'); + }); + + it('ignores user messages sent before the todo update', () => { + const state = getFloatingTodos([ + userMessage('u1'), + todoWriteMessage('m1', [todo('1', 'completed')]), + ]); + expect(state.todos).toHaveLength(1); + expect(state.allCompleted).toBe(true); + }); + + it('clears the panel when a plan message empties the list', () => { + const state = getFloatingTodos([ + todoWriteMessage('m1', [todo('1', 'in_progress')]), + planMessage('p1', []), + ]); + expect(state.todos).toHaveLength(0); + }); +}); + +describe('getTodoWindow', () => { + const statuses = (list: Array): TodoItem[] => + list.map((status, i) => todo(String(i + 1), status)); + + it('shows everything when the list fits', () => { + const todos = statuses(['completed', 'in_progress', 'pending']); + expect(getTodoWindow(todos, 5)).toEqual({ start: 0, end: 3 }); + }); + + it('anchors on the in_progress item with one completed line above', () => { + const todos = statuses([ + 'completed', + 'completed', + 'completed', + 'in_progress', + 'pending', + 'pending', + 'pending', + 'pending', + ]); + expect(getTodoWindow(todos, 5)).toEqual({ start: 2, end: 7 }); + }); + + it('starts at the top when the anchor is the first item', () => { + const todos = statuses([ + 'in_progress', + 'pending', + 'pending', + 'pending', + 'pending', + 'pending', + ]); + expect(getTodoWindow(todos, 5)).toEqual({ start: 0, end: 5 }); + }); + + it('backfills the window when the anchor is near the end', () => { + const todos = statuses([ + 'completed', + 'completed', + 'completed', + 'completed', + 'completed', + 'completed', + 'completed', + 'in_progress', + ]); + expect(getTodoWindow(todos, 5)).toEqual({ start: 3, end: 8 }); + }); + + it('anchors on the first pending item when nothing is in progress', () => { + const todos = statuses([ + 'completed', + 'completed', + 'completed', + 'pending', + 'pending', + 'pending', + 'pending', + ]); + expect(getTodoWindow(todos, 5)).toEqual({ start: 2, end: 7 }); + }); + + it('falls back to the head of the list when everything is completed', () => { + const todos = statuses([ + 'completed', + 'completed', + 'completed', + 'completed', + 'completed', + 'completed', + ]); + expect(getTodoWindow(todos, 5)).toEqual({ start: 0, end: 5 }); + }); +}); diff --git a/packages/web-shell/client/utils/todos.ts b/packages/web-shell/client/utils/todos.ts index 4394dbaa2ae..0216c092dd5 100644 --- a/packages/web-shell/client/utils/todos.ts +++ b/packages/web-shell/client/utils/todos.ts @@ -1,4 +1,4 @@ -import type { ACPToolCall, TodoItem } from '../adapters/types'; +import type { ACPToolCall, Message, TodoItem } from '../adapters/types'; export function parseTodoItemsFromEntries( entries: readonly unknown[], @@ -50,6 +50,101 @@ export function hasActiveTodos(todos: readonly TodoItem[]): boolean { ); } +export function getTodoStatusIcon(status: TodoItem['status']): string { + switch (status) { + case 'completed': + return '●'; + case 'in_progress': + return '◐'; + case 'pending': + return '○'; + } +} + +export interface FloatingTodosState { + todos: TodoItem[]; + /** Every item is completed — the panel shows a transient "all done" state. */ + allCompleted: boolean; + /** Transcript message the latest todo update came from. */ + sourceMessageId: string | null; + /** Tool call id within the source message, when it came from a tool call. */ + sourceCallId: string | null; +} + +const EMPTY_FLOATING_TODOS: FloatingTodosState = { + todos: [], + allCompleted: false, + sourceMessageId: null, + sourceCallId: null, +}; + +export function getFloatingTodos( + messages: readonly Message[], +): FloatingTodosState { + let todos: TodoItem[] = []; + let sourceMessageId: string | null = null; + let sourceCallId: string | null = null; + let userMessageAfter = false; + + for (const message of messages) { + if (message.role === 'user') { + userMessageAfter = true; + continue; + } + if (message.role === 'plan') { + todos = message.todos; + sourceMessageId = message.id; + sourceCallId = null; + userMessageAfter = false; + continue; + } + if (message.role !== 'tool_group') continue; + + for (const tool of message.tools) { + const nextTodos = extractTodosFromToolCall(tool); + if (nextTodos) { + todos = nextTodos; + sourceMessageId = message.id; + sourceCallId = tool.callId; + userMessageAfter = false; + } + } + } + + if (todos.length === 0) return EMPTY_FLOATING_TODOS; + const allCompleted = !hasActiveTodos(todos); + // A finished list stays visible (the "all done" moment) only until the + // user sends the next prompt. + if (allCompleted && userMessageAfter) return EMPTY_FLOATING_TODOS; + return { todos, allCompleted, sourceMessageId, sourceCallId }; +} + +export interface TodoWindow { + start: number; + end: number; +} + +/** + * Natural-order window of up to maxVisible items anchored on the current + * item (first in_progress, else first pending): one item of completed + * context above the anchor, the rest of the budget below it. + */ +export function getTodoWindow( + todos: readonly TodoItem[], + maxVisible: number, +): TodoWindow { + if (todos.length <= maxVisible) return { start: 0, end: todos.length }; + const inProgressIdx = todos.findIndex((t) => t.status === 'in_progress'); + const anchor = + inProgressIdx >= 0 + ? inProgressIdx + : todos.findIndex((t) => t.status === 'pending'); + let start = Math.max(0, Math.max(0, anchor) - 1); + const end = Math.min(todos.length, start + maxVisible); + start = Math.max(0, end - maxVisible); + return { start, end }; +} + function getTodoArray( record: Record | undefined, ): readonly unknown[] | undefined {