From c412d62981b1124a8abf7a212fdc43f99ea93a2c Mon Sep 17 00:00:00 2001 From: dreamWB <22347282+dreamWB@users.noreply.github.com> Date: Thu, 9 Jul 2026 22:58:39 +0800 Subject: [PATCH] feat(web-shell): add bottom status items (#6613) --- packages/web-shell/client/App.module.css | 9 +- packages/web-shell/client/App.tsx | 79 +++++++- .../client/components/MessageList.module.css | 3 +- .../client/components/MessageList.tsx | 183 ++++++++++++++++-- .../components/panels/TodoPanel.module.css | 35 ++++ .../components/panels/TodoPanel.test.tsx | 88 +++++++++ .../client/components/panels/TodoPanel.tsx | 128 ++++++++---- packages/web-shell/client/customization.tsx | 8 + packages/web-shell/client/index.ts | 1 + packages/web-shell/client/index.tsx | 1 + 10 files changed, 472 insertions(+), 63 deletions(-) create mode 100644 packages/web-shell/client/components/panels/TodoPanel.test.tsx diff --git a/packages/web-shell/client/App.module.css b/packages/web-shell/client/App.module.css index d4e713bc94..bfd31780b1 100644 --- a/packages/web-shell/client/App.module.css +++ b/packages/web-shell/client/App.module.css @@ -586,7 +586,7 @@ .bottomPanels { position: absolute; - top: -50px; + bottom: calc(100% + var(--web-shell-bottom-panel-gap, 6px)); right: 0; left: 0; z-index: 3; @@ -615,7 +615,7 @@ .scrollToBottomLayer { position: absolute; - top: -44px; + bottom: calc(100% + 10px); right: 0; left: 0; z-index: 3; @@ -625,7 +625,10 @@ } .scrollToBottomLayerWithTodos { - top: -94px; + bottom: calc( + 100% + var(--web-shell-bottom-panel-height, 0px) + + var(--web-shell-bottom-panel-gap, 6px) + 8px + ); } .scrollToBottomButton { diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index 966e155251..b79aab951b 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -185,6 +185,7 @@ import { type WebShellTaskInfo, type WebShellAtProvider, type WebShellComposerTagIconMap, + type WebShellBottomStatusItem, } from './customization'; import type { CommandDisplayCategoryOrder } from './utils/commandDisplay'; import styles from './App.module.css'; @@ -433,6 +434,8 @@ export interface WebShellProps { renderComposerToolbarRight?: ComposerToolbarRightRenderer; /** Custom component for the footer area below the Editor. Replaces the built-in StatusBar. */ renderFooter?: FooterRenderer; + /** Extra status items shown in the floating bottom panel beside the TODO summary. */ + bottomStatusItems?: readonly WebShellBottomStatusItem[]; /** Collapse thinking blocks to 5 lines with a click-to-expand toggle. */ compactThinking?: boolean; /** Auto-collapse completed turns to just the prompt and final answer, with a per-turn toggle. Defaults to true. */ @@ -489,7 +492,10 @@ const emptyComposerApi: WebShellComposerApi = { submit: () => {}, }; +const EMPTY_BOTTOM_STATUS_ITEMS: readonly WebShellBottomStatusItem[] = []; const DEFAULT_CHAT_MAX_WIDTH = 1000; +const BOTTOM_PANEL_GAP_PX = 6; +const BOTTOM_PANEL_FALLBACK_INSET_PX = 40; type ChatWidthMode = `${typeof DEFAULT_CHAT_MAX_WIDTH}` | 'wide'; const CHAT_WIDTH_STORAGE_KEY = 'qwen-code-web-shell-chat-width'; @@ -840,6 +846,7 @@ export function App({ renderComposerToolbarEnd, renderComposerToolbarRight, renderFooter, + bottomStatusItems, chatMaxWidth, sidebar, splitSessionIds: externalSplitSessionIds, @@ -1133,6 +1140,55 @@ export function App({ setTodoPanelMode(nextTodoPanelMode); } const showFloatingTodos = nextTodoPanelMode !== 'hidden'; + const floatingBottomStatusItems = + bottomStatusItems ?? EMPTY_BOTTOM_STATUS_ITEMS; + const showBottomPanels = + showFloatingTodos || floatingBottomStatusItems.length > 0; + const footerRef = useRef(null); + const bottomPanelsRef = useRef(null); + const [bottomPanelInset, setBottomPanelInset] = useState(0); + const [bottomPanelHeight, setBottomPanelHeight] = useState(0); + useLayoutEffect(() => { + if (!showBottomPanels) { + setBottomPanelInset(0); + setBottomPanelHeight(0); + return; + } + const node = bottomPanelsRef.current; + if (!node) { + setBottomPanelInset(BOTTOM_PANEL_FALLBACK_INSET_PX); + setBottomPanelHeight(0); + return; + } + const updateInset = () => { + const footer = footerRef.current; + const panelRect = node.getBoundingClientRect(); + const footerRect = footer?.getBoundingClientRect(); + const panelHeight = Math.ceil(panelRect.height); + const overlapAboveFooter = footerRect + ? Math.max(0, footerRect.top - panelRect.top) + : panelHeight + BOTTOM_PANEL_GAP_PX; + setBottomPanelHeight(panelHeight); + setBottomPanelInset( + Math.max(BOTTOM_PANEL_FALLBACK_INSET_PX, Math.ceil(overlapAboveFooter)), + ); + }; + updateInset(); + if (typeof ResizeObserver === 'undefined') return; + const observer = new ResizeObserver(updateInset); + observer.observe(node); + if (footerRef.current) observer.observe(footerRef.current); + return () => observer.disconnect(); + }, [showBottomPanels]); + const contentStyle = useMemo( + () => + ({ + '--web-shell-bottom-panel-inset': `${bottomPanelInset}px`, + '--web-shell-bottom-panel-height': `${bottomPanelHeight}px`, + '--web-shell-bottom-panel-gap': `${BOTTOM_PANEL_GAP_PX}px`, + }) as CSSProperties, + [bottomPanelHeight, bottomPanelInset], + ); const backgroundTaskActivityKey = useMemo( () => getBackgroundTaskActivityKey(messages), [messages], @@ -1152,7 +1208,6 @@ export function App({ const messageListRef = useRef(null); const editorRef = useRef(null); const notifiedComposerReadyRef = useRef(null); - const footerRef = useRef(null); const [canScrollMessageListToBottom, setCanScrollMessageListToBottom] = useState(false); const previousFooterRectRef = useRef(null); @@ -4937,6 +4992,7 @@ export function App({ details={todoDetails} >
-
+
{canScrollMessageListToBottom && (
)} - {showFloatingTodos && ( -
- + {showBottomPanels && ( +
+
)} {/* Only render the outer session's approval on the chat diff --git a/packages/web-shell/client/components/MessageList.module.css b/packages/web-shell/client/components/MessageList.module.css index 17fbf47b3a..e5aa5a4b6c 100644 --- a/packages/web-shell/client/components/MessageList.module.css +++ b/packages/web-shell/client/components/MessageList.module.css @@ -1,7 +1,8 @@ .list { flex: 1; overflow-y: auto; - padding: 12px 24px; + padding: 12px 24px calc(8px + var(--web-shell-bottom-panel-inset, 0px)); + scroll-padding-bottom: calc(8px + var(--web-shell-bottom-panel-inset, 0px)); scrollbar-width: thin; scrollbar-color: var(--border) transparent; min-height: 0; diff --git a/packages/web-shell/client/components/MessageList.tsx b/packages/web-shell/client/components/MessageList.tsx index bac24ecfd5..7904ee1a19 100644 --- a/packages/web-shell/client/components/MessageList.tsx +++ b/packages/web-shell/client/components/MessageList.tsx @@ -59,6 +59,12 @@ interface MessageListProps { * panels don't yank the reader to the bottom. Defaults to false. */ autoScrollTailIntoView?: boolean; + /** + * Height reserved for app-level floating UI below the transcript, such as the + * bottom todo/status panel. When it changes while the transcript is following + * the bottom, perform one more bottom alignment after layout settles. + */ + bottomOverlayInset?: number; hideSessionTimeline?: boolean; showRetryHint?: boolean; onRetryClick?: () => void; @@ -2066,6 +2072,7 @@ export const MessageList = memo( tailKey = 'tail', virtualScrollThreshold = VIRTUAL_SCROLL_THRESHOLD, autoScrollTailIntoView = false, + bottomOverlayInset = 0, hideSessionTimeline = false, showRetryHint = false, onRetryClick, @@ -2167,6 +2174,8 @@ export const MessageList = memo( ReadonlyMap >(() => new Map()); const shouldFollow = useRef(true); + const followPausedByUserRef = useRef(false); + const userScrollIntentUntil = useRef(0); const lastScrollTop = useRef(0); const scrollCooldown = useRef(false); const scrollCooldownCount = useRef(0); @@ -2176,6 +2185,12 @@ export const MessageList = memo( const prevLastUserMsgId = useRef(null); const pendingNewUserSmoothScroll = useRef(false); const prevLoadingTranscript = useRef(loadingTranscript); + const pendingTranscriptBottomScroll = useRef(Boolean(loadingTranscript)); + const transcriptBottomScrollFrame = useRef(undefined); + const transcriptBottomScrollSettleFrame = useRef( + undefined, + ); + const prevBottomOverlayInset = useRef(bottomOverlayInset); const prevActiveExecutionKey = useRef(null); const prevCatchingUp: MutableRefObject = useRef(catchingUp); @@ -2331,10 +2346,16 @@ export const MessageList = memo( if (!el) return; const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight; - setShouldFollow(distanceFromBottom < FOLLOW_BOTTOM_THRESHOLD_PX); + const isNearBottom = distanceFromBottom < FOLLOW_BOTTOM_THRESHOLD_PX; + followPausedByUserRef.current = !isNearBottom; + setShouldFollow(isNearBottom); scheduleScrollOverflowReport(); }, [scheduleScrollOverflowReport, setShouldFollow]); + const markUserScrollIntent = useCallback(() => { + userScrollIntentUntil.current = Date.now() + 1000; + }, []); + const scheduleFollowRecheck = useCallback(() => { pendingFollowRecheck.current = true; if (pendingFollowRecheckFrame.current !== undefined) { @@ -2368,6 +2389,14 @@ export const MessageList = memo( if (pendingOverflowFrame.current !== undefined) { window.cancelAnimationFrame(pendingOverflowFrame.current); } + if (transcriptBottomScrollFrame.current !== undefined) { + window.cancelAnimationFrame(transcriptBottomScrollFrame.current); + } + if (transcriptBottomScrollSettleFrame.current !== undefined) { + window.cancelAnimationFrame( + transcriptBottomScrollSettleFrame.current, + ); + } }, [], ); @@ -2382,6 +2411,7 @@ export const MessageList = memo( // bottom" state to report. The toggle may create overflow though, so // re-check after the expanded/collapsed rows have been laid out. if (!el || el.scrollHeight > el.clientHeight + 1) { + followPausedByUserRef.current = true; setShouldFollow(false); } scheduleFollowRecheck(); @@ -2399,6 +2429,7 @@ export const MessageList = memo( const target = event.target; if (!(target instanceof HTMLElement)) return; if (!target.closest('[aria-expanded]')) return; + followPausedByUserRef.current = true; setShouldFollow(false); scheduleFollowRecheck(); }, @@ -2457,6 +2488,7 @@ export const MessageList = memo( const resumeBottomFollow = useCallback( (behavior: ScrollBehavior = 'smooth') => { + followPausedByUserRef.current = false; setShouldFollow(true); scrollToBottom(behavior); }, @@ -2623,6 +2655,7 @@ export const MessageList = memo( // target sits near the bottom, and the next streaming height change // would pull the viewport back to the tail. An instant (non-smooth) // scroll keeps that cooldown window short and deterministic. + followPausedByUserRef.current = true; setShouldFollow(false); scrollCooldownCount.current += 1; const gen = scrollCooldownCount.current; @@ -2764,13 +2797,24 @@ export const MessageList = memo( if (curr < prev - 1) { // Container resizes can clamp scrollTop downward while the viewport is // still at the tail. Treat that as follow mode, not a manual scroll-up. - setShouldFollow(distanceFromBottom < FOLLOW_BOTTOM_THRESHOLD_PX); + const isNearBottom = distanceFromBottom < FOLLOW_BOTTOM_THRESHOLD_PX; + const hasUserScrollIntent = Date.now() <= userScrollIntentUntil.current; + if (isNearBottom) { + followPausedByUserRef.current = false; + setShouldFollow(true); + } else if (hasUserScrollIntent) { + followPausedByUserRef.current = true; + setShouldFollow(false); + } else if (!followPausedByUserRef.current) { + setShouldFollow(false); + } return; } // Rule 3: near bottom → resume follow // Run only after non-upward scrolls. Otherwise a tiny wheel-up near the // tail would pause follow and immediately re-enable it in the same event. if (distanceFromBottom < FOLLOW_BOTTOM_THRESHOLD_PX) { + followPausedByUserRef.current = false; setShouldFollow(true); } }, [ @@ -2787,6 +2831,46 @@ export const MessageList = memo( return () => el.removeEventListener('scroll', handleScroll); }, [getScrollElement, handleScroll]); + useEffect(() => { + const el = getScrollElement(); + if (!el) return; + const markFromPointer = (event: PointerEvent) => { + const rect = el.getBoundingClientRect(); + const scrollbarEdge = 20; + if ( + event.clientX >= rect.right - scrollbarEdge || + event.clientY >= rect.bottom - scrollbarEdge + ) { + markUserScrollIntent(); + } + }; + const markFromKey = (event: KeyboardEvent) => { + if ( + event.key === 'ArrowUp' || + event.key === 'ArrowDown' || + event.key === 'PageUp' || + event.key === 'PageDown' || + event.key === 'Home' || + event.key === 'End' || + event.key === ' ' + ) { + markUserScrollIntent(); + } + }; + el.addEventListener('wheel', markUserScrollIntent, { passive: true }); + el.addEventListener('touchstart', markUserScrollIntent, { + passive: true, + }); + el.addEventListener('pointerdown', markFromPointer, { passive: true }); + el.addEventListener('keydown', markFromKey, { passive: true }); + return () => { + el.removeEventListener('wheel', markUserScrollIntent); + el.removeEventListener('touchstart', markUserScrollIntent); + el.removeEventListener('pointerdown', markFromPointer); + el.removeEventListener('keydown', markFromKey); + }; + }, [getScrollElement, markUserScrollIntent]); + useEffect(() => { const el = getScrollElement(); if (!el || typeof ResizeObserver === 'undefined') return; @@ -2819,6 +2903,7 @@ export const MessageList = memo( // against the next session. useEffect(() => { if (messages.length === 0) { + followPausedByUserRef.current = false; setShouldFollow(true); pendingScrollRef.current = null; setCollapseOverrides((prev) => (prev.size ? new Map() : prev)); @@ -2835,16 +2920,17 @@ export const MessageList = memo( const observer = new ResizeObserver(() => { scheduleSessionTimelineRangeUpdate(); if (catchingUpRef.current) return; - if (!shouldFollow.current) return; + if (followPausedByUserRef.current) return; + setShouldFollow(true); requestAnimationFrame(() => { - if (!catchingUpRef.current && shouldFollow.current) { + if (!catchingUpRef.current && !followPausedByUserRef.current) { scrollToBottom(); } }); }); observer.observe(el); return () => observer.disconnect(); - }, [scrollToBottom, scheduleSessionTimelineRangeUpdate]); + }, [scrollToBottom, scheduleSessionTimelineRangeUpdate, setShouldFollow]); // Rule 4: new user message → force follow on so the model's reply // scrolls into view as it streams in. @@ -2870,6 +2956,7 @@ export const MessageList = memo( lastMessage?.role === 'user' && lastId !== prevLastUserMsgId.current ) { + followPausedByUserRef.current = false; setShouldFollow(true); // A new prompt supersedes any pending "Show in transcript" scroll. pendingScrollRef.current = null; @@ -2885,12 +2972,70 @@ export const MessageList = memo( // latest content without the viewport fighting the replay. useLayoutEffect(() => { if (prevCatchingUp.current && !catchingUp) { + followPausedByUserRef.current = false; setShouldFollow(true); scrollToBottom('auto'); } prevCatchingUp.current = catchingUp; }, [catchingUp, scrollToBottom, setShouldFollow]); + useLayoutEffect(() => { + if (loadingTranscript) { + pendingTranscriptBottomScroll.current = true; + return; + } + if (!pendingTranscriptBottomScroll.current) return; + if (catchingUp || messages.length === 0) return; + + pendingTranscriptBottomScroll.current = false; + followPausedByUserRef.current = false; + setShouldFollow(true); + pendingScrollRef.current = null; + + if (transcriptBottomScrollFrame.current !== undefined) { + window.cancelAnimationFrame(transcriptBottomScrollFrame.current); + } + if (transcriptBottomScrollSettleFrame.current !== undefined) { + window.cancelAnimationFrame(transcriptBottomScrollSettleFrame.current); + } + const scrollIfStillFollowing = () => { + if (catchingUpRef.current || followPausedByUserRef.current) return; + setShouldFollow(true); + scrollToBottom('auto'); + }; + + transcriptBottomScrollFrame.current = window.requestAnimationFrame(() => { + transcriptBottomScrollFrame.current = undefined; + scrollIfStillFollowing(); + transcriptBottomScrollSettleFrame.current = + window.requestAnimationFrame(() => { + transcriptBottomScrollSettleFrame.current = undefined; + scrollIfStillFollowing(); + }); + }); + }, [ + catchingUp, + loadingTranscript, + messages.length, + scrollToBottom, + setShouldFollow, + ]); + + useLayoutEffect(() => { + const insetChanged = + prevBottomOverlayInset.current !== bottomOverlayInset; + prevBottomOverlayInset.current = bottomOverlayInset; + if (!insetChanged) return; + if (catchingUp) return; + if (followPausedByUserRef.current) return; + setShouldFollow(true); + requestAnimationFrame(() => { + if (!catchingUpRef.current && !followPausedByUserRef.current) { + scrollToBottom('auto'); + } + }); + }, [bottomOverlayInset, catchingUp, scrollToBottom, setShouldFollow]); + const runningExecutionKey = useMemo( () => latestActiveExecutionKey(visibleItems), [visibleItems], @@ -2909,14 +3054,15 @@ export const MessageList = memo( } if (runningExecutionKey === prevActiveExecutionKey.current) return; prevActiveExecutionKey.current = runningExecutionKey; - if (shouldFollow.current) { + if (shouldFollow.current || !followPausedByUserRef.current) { requestAnimationFrame(() => { - if (shouldFollow.current) { + if (!followPausedByUserRef.current) { + setShouldFollow(true); scrollToBottom(); } }); } - }, [catchingUp, runningExecutionKey, scrollToBottom]); + }, [catchingUp, runningExecutionKey, scrollToBottom, setShouldFollow]); // 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 @@ -2929,11 +3075,12 @@ export const MessageList = memo( hasTailContent && !prevHasTailContent.current ) { + followPausedByUserRef.current = false; setShouldFollow(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(); + if (!followPausedByUserRef.current) scrollToBottom(); }); } prevHasTailContent.current = hasTailContent; @@ -3078,11 +3225,25 @@ export const MessageList = memo( // Preserve the new-prompt scroll even if a previous disclosure resize is // still settling; it targets the latest virtualizer size from this render. if (pendingFollowRecheck.current && !isNewUserMessage) return; - if (shouldFollow.current || isNewUserMessage) { + if ( + shouldFollow.current || + isNewUserMessage || + !followPausedByUserRef.current + ) { + if (!followPausedByUserRef.current) { + setShouldFollow(true); + } scrollToBottom(isNewUserMessage ? 'smooth' : 'auto'); pendingNewUserSmoothScroll.current = false; } - }, [totalVirtualSize, messages, totalCount, catchingUp, scrollToBottom]); + }, [ + totalVirtualSize, + messages, + totalCount, + catchingUp, + scrollToBottom, + setShouldFollow, + ]); useLayoutEffect(() => { scheduleScrollOverflowReport(); diff --git a/packages/web-shell/client/components/panels/TodoPanel.module.css b/packages/web-shell/client/components/panels/TodoPanel.module.css index 5cf178eead..28e818eced 100644 --- a/packages/web-shell/client/components/panels/TodoPanel.module.css +++ b/packages/web-shell/client/components/panels/TodoPanel.module.css @@ -45,6 +45,41 @@ color: var(--muted-foreground); } +.statusSegmentWrap { + display: inline-flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.separator { + flex: 0 0 auto; + color: color-mix(in srgb, var(--muted-foreground) 72%, transparent); +} + +.statusSegment, +.statusSegmentButton { + min-width: 0; + overflow: hidden; + color: var(--muted-foreground); + text-overflow: ellipsis; + white-space: nowrap; +} + +.statusSegmentButton { + padding: 0; + border: 0; + background: transparent; + cursor: pointer; + font: inherit; +} + +.statusSegmentButton:hover, +.statusSegmentButton:focus-visible { + color: var(--foreground); + outline: none; +} + .fullText { display: inline; } diff --git a/packages/web-shell/client/components/panels/TodoPanel.test.tsx b/packages/web-shell/client/components/panels/TodoPanel.test.tsx new file mode 100644 index 0000000000..96cd9fa71b --- /dev/null +++ b/packages/web-shell/client/components/panels/TodoPanel.test.tsx @@ -0,0 +1,88 @@ +// @vitest-environment jsdom + +import { act, type ReactNode } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { TodoItem } from '../../adapters/types'; +import { I18nProvider } from '../../i18n'; +import { TodoPanel } from './TodoPanel'; + +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + +const mounted: Array<{ root: Root; container: HTMLElement }> = []; + +afterEach(() => { + for (const { root, container } of mounted.splice(0)) { + act(() => root.unmount()); + container.remove(); + } +}); + +function render(node: ReactNode): HTMLElement { + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + act(() => { + root.render({node}); + }); + mounted.push({ root, container }); + return container; +} + +const todo = (id: string, status: TodoItem['status']): TodoItem => ({ + id, + status, + content: `Step ${id}`, +}); + +describe('TodoPanel bottom status items', () => { + it('renders status items beside todo progress with a separator', () => { + const onClick = vi.fn(); + const container = render( + , + ); + + expect(container.textContent).toContain('Step 1 / 2'); + expect(container.textContent).toContain('·'); + const button = container.querySelector( + 'button[title="Open changed files"]', + ); + expect(button?.textContent).toBe('1 file changed'); + act(() => { + (button as HTMLButtonElement).click(); + }); + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it('can render status-only content without todo progress or detail tooltip', () => { + const container = render( + , + ); + + expect(container.textContent).toBe('2 files changed'); + expect(container.querySelector('[role="tooltip"]')).toBeNull(); + expect(container.querySelector('section')?.getAttribute('aria-label')).toBe( + 'Open changed files', + ); + expect(container.querySelector('div[aria-label="Step 0 / 0"]')).toBeNull(); + }); +}); diff --git a/packages/web-shell/client/components/panels/TodoPanel.tsx b/packages/web-shell/client/components/panels/TodoPanel.tsx index e46a4d7114..a8ba4e27d1 100644 --- a/packages/web-shell/client/components/panels/TodoPanel.tsx +++ b/packages/web-shell/client/components/panels/TodoPanel.tsx @@ -1,6 +1,7 @@ import { memo } from 'react'; import type { CSSProperties } from 'react'; import type { TodoItem } from '../../adapters/types'; +import type { WebShellBottomStatusItem } from '../../customization'; import { getTodoStatusIcon } from '../../utils/todos'; import { useI18n } from '../../i18n'; import styles from './TodoPanel.module.css'; @@ -8,6 +9,7 @@ import styles from './TodoPanel.module.css'; interface TodoPanelProps { todos: TodoItem[]; title?: string; + statusItems?: readonly WebShellBottomStatusItem[]; } function getStatusClass(status: TodoItem['status']): string { @@ -24,66 +26,108 @@ function getStatusClass(status: TodoItem['status']): string { export const TodoPanel = memo(function TodoPanel({ todos, title, + statusItems = [], }: TodoPanelProps) { const { t } = useI18n(); - if (todos.length === 0) return null; + if (todos.length === 0 && statusItems.length === 0) return null; const total = todos.length; + const hasTodos = total > 0; const inProgressIdx = todos.findIndex((td) => td.status === 'in_progress'); const currentIdx = inProgressIdx >= 0 ? inProgressIdx : todos.findIndex((td) => td.status === 'pending'); - const stepIndex = currentIdx >= 0 ? currentIdx + 1 : total; - const progress = total > 0 ? stepIndex / total : 0; + const stepIndex = hasTodos ? (currentIdx >= 0 ? currentIdx + 1 : total) : 0; + const progress = hasTodos ? stepIndex / total : 0; + const statusOnlyLabel = + statusItems + .map( + (item) => + item.ariaLabel ?? + item.title ?? + (typeof item.label === 'string' ? item.label : undefined), + ) + .filter(Boolean) + .join(', ') || undefined; + const summaryAriaLabel = hasTodos + ? t('todo.stepProgress', { + current: stepIndex, + total, + }) + : statusOnlyLabel; return (
-
-
- -
- {todos.map((todo, index) => ( -
-
+ + {total > 0 && ( +
+ {todos.map((todo, index) => ( +
+
+ ))} +
+ )}
); }); diff --git a/packages/web-shell/client/customization.tsx b/packages/web-shell/client/customization.tsx index 2f1858be43..8e5ec73b0b 100644 --- a/packages/web-shell/client/customization.tsx +++ b/packages/web-shell/client/customization.tsx @@ -99,6 +99,14 @@ export type UserMessageContentRenderer = ( info: UserMessageContentRenderInfo, ) => ReactNode; +export interface WebShellBottomStatusItem { + id: string; + label: ReactNode; + title?: string; + ariaLabel?: string; + onClick?: () => void; +} + export type WebShellBuiltinComposerTagKind = | 'extension' | 'mcp' diff --git a/packages/web-shell/client/index.ts b/packages/web-shell/client/index.ts index a3c2c4483e..c72096bf0e 100644 --- a/packages/web-shell/client/index.ts +++ b/packages/web-shell/client/index.ts @@ -41,6 +41,7 @@ export type { LoadingPhrasesResolver, WebShellAtItem, WebShellAtProvider, + WebShellBottomStatusItem, WebShellCodeBlockRenderInfo, WebShellTaskInfo, WebShellAgentTask, diff --git a/packages/web-shell/client/index.tsx b/packages/web-shell/client/index.tsx index 1264796f55..7749b22242 100644 --- a/packages/web-shell/client/index.tsx +++ b/packages/web-shell/client/index.tsx @@ -124,6 +124,7 @@ export type { WebShellComposerToolbarRightRenderInfo, WelcomeFooterRenderer, WelcomeHeaderRenderer, + WebShellBottomStatusItem, WebShellCodeBlockRenderInfo, WebShellMarkdownCustomization, } from './customization';