feat(web-shell): add bottom status items (#6613)

This commit is contained in:
dreamWB 2026-07-09 22:58:39 +08:00 committed by GitHub
parent 21cdb4a4dc
commit c412d62981
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 472 additions and 63 deletions

View file

@ -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 {

View file

@ -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<HTMLDivElement>(null);
const bottomPanelsRef = useRef<HTMLDivElement>(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<MessageListHandle | null>(null);
const editorRef = useRef<EditorHandle | null>(null);
const notifiedComposerReadyRef = useRef<EditorHandle | null>(null);
const footerRef = useRef<HTMLDivElement>(null);
const [canScrollMessageListToBottom, setCanScrollMessageListToBottom] =
useState(false);
const previousFooterRectRef = useRef<DOMRect | null>(null);
@ -4937,6 +4992,7 @@ export function App({
details={todoDetails}
>
<div
style={contentStyle}
className={[
styles.content,
showFloatingTodos ||
@ -4964,6 +5020,7 @@ export function App({
showRetryHint={showRetryHint}
onRetryClick={handleRetry}
onBranchSession={handleBranchCurrentSession}
bottomOverlayInset={bottomPanelInset}
welcomeHeader={
isChatEmptyState ? welcomeHeader : undefined
}
@ -4987,11 +5044,15 @@ export function App({
</TodoContextsProvider>
</CompactModeContext.Provider>
<div ref={footerRef} className={styles.footer}>
<div
ref={footerRef}
style={contentStyle}
className={styles.footer}
>
{canScrollMessageListToBottom && (
<div
className={
showFloatingTodos
showBottomPanels
? `${styles.scrollToBottomLayer} ${styles.scrollToBottomLayerWithTodos}`
: styles.scrollToBottomLayer
}
@ -5018,9 +5079,15 @@ export function App({
</button>
</div>
)}
{showFloatingTodos && (
<div className={styles.bottomPanels}>
<TodoPanel todos={floatingTodos} />
{showBottomPanels && (
<div
ref={bottomPanelsRef}
className={styles.bottomPanels}
>
<TodoPanel
todos={showFloatingTodos ? floatingTodos : []}
statusItems={floatingBottomStatusItems}
/>
</div>
)}
{/* Only render the outer session's approval on the chat

View file

@ -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;

View file

@ -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<string, boolean>
>(() => 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<string | null>(null);
const pendingNewUserSmoothScroll = useRef(false);
const prevLoadingTranscript = useRef(loadingTranscript);
const pendingTranscriptBottomScroll = useRef(Boolean(loadingTranscript));
const transcriptBottomScrollFrame = useRef<number | undefined>(undefined);
const transcriptBottomScrollSettleFrame = useRef<number | undefined>(
undefined,
);
const prevBottomOverlayInset = useRef(bottomOverlayInset);
const prevActiveExecutionKey = useRef<string | null>(null);
const prevCatchingUp: MutableRefObject<boolean | undefined> =
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();

View file

@ -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;
}

View file

@ -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(<I18nProvider language="en">{node}</I18nProvider>);
});
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(
<TodoPanel
todos={[todo('1', 'in_progress'), todo('2', 'pending')]}
statusItems={[
{
id: 'changed-files',
label: '1 file changed',
title: 'Open changed files',
onClick,
},
]}
/>,
);
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(
<TodoPanel
todos={[]}
statusItems={[
{
id: 'changed-files',
label: '2 files changed',
ariaLabel: 'Open changed files',
},
]}
/>,
);
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();
});
});

View file

@ -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 (
<section
className={styles.panel}
aria-label={title ?? t('todo.title')}
aria-label={title ?? (hasTodos ? t('todo.title') : statusOnlyLabel)}
tabIndex={0}
>
<div
className={styles.summary}
aria-label={t('todo.stepProgress', {
current: stepIndex,
total,
})}
>
<span
className={styles.progressRing}
style={{ '--todo-progress': String(progress) } as CSSProperties}
aria-hidden="true"
/>
<span className={styles.stepText}>
<span className={styles.fullText}>
{t('todo.stepProgress', { current: stepIndex, total })}
</span>
<span className={styles.compactText}>
{t('todo.stepFraction', { current: stepIndex, total })}
</span>
</span>
</div>
<div className={styles.detail} role="tooltip">
{todos.map((todo, index) => (
<div
key={`${todo.id || index}:${todo.content}`}
className={`${styles.item} ${getStatusClass(todo.status)}`}
>
<span className={styles.icon} aria-hidden="true">
{todo.status === 'in_progress' ? (
<span className={styles.loadingIcon} />
) : (
getTodoStatusIcon(todo.status)
)}
<div className={styles.summary} aria-label={summaryAriaLabel}>
{hasTodos && (
<>
<span
className={styles.progressRing}
style={{ '--todo-progress': String(progress) } as CSSProperties}
aria-hidden="true"
/>
<span className={styles.stepText}>
<span className={styles.fullText}>
{t('todo.stepProgress', { current: stepIndex, total })}
</span>
<span className={styles.compactText}>
{t('todo.stepFraction', { current: stepIndex, total })}
</span>
</span>
<span className={styles.content} title={todo.content}>
{todo.content}
</span>
</div>
</>
)}
{statusItems.map((item, index) => (
<span key={item.id} className={styles.statusSegmentWrap}>
{(total > 0 || index > 0) && (
<span className={styles.separator} aria-hidden="true">
·
</span>
)}
{item.onClick ? (
<button
type="button"
className={styles.statusSegmentButton}
title={item.title}
aria-label={item.ariaLabel}
onClick={item.onClick}
>
{item.label}
</button>
) : (
<span className={styles.statusSegment} title={item.title}>
{item.label}
</span>
)}
</span>
))}
</div>
{total > 0 && (
<div className={styles.detail} role="tooltip">
{todos.map((todo, index) => (
<div
key={`${todo.id || index}:${todo.content}`}
className={`${styles.item} ${getStatusClass(todo.status)}`}
>
<span className={styles.icon} aria-hidden="true">
{todo.status === 'in_progress' ? (
<span className={styles.loadingIcon} />
) : (
getTodoStatusIcon(todo.status)
)}
</span>
<span className={styles.content} title={todo.content}>
{todo.content}
</span>
</div>
))}
</div>
)}
</section>
);
});

View file

@ -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'

View file

@ -41,6 +41,7 @@ export type {
LoadingPhrasesResolver,
WebShellAtItem,
WebShellAtProvider,
WebShellBottomStatusItem,
WebShellCodeBlockRenderInfo,
WebShellTaskInfo,
WebShellAgentTask,

View file

@ -124,6 +124,7 @@ export type {
WebShellComposerToolbarRightRenderInfo,
WelcomeFooterRenderer,
WelcomeHeaderRenderer,
WebShellBottomStatusItem,
WebShellCodeBlockRenderInfo,
WebShellMarkdownCustomization,
} from './customization';