mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-09 17:19:02 +00:00
feat(web-shell): add bottom status items (#6613)
This commit is contained in:
parent
21cdb4a4dc
commit
c412d62981
10 changed files with 472 additions and 63 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ export type {
|
|||
LoadingPhrasesResolver,
|
||||
WebShellAtItem,
|
||||
WebShellAtProvider,
|
||||
WebShellBottomStatusItem,
|
||||
WebShellCodeBlockRenderInfo,
|
||||
WebShellTaskInfo,
|
||||
WebShellAgentTask,
|
||||
|
|
|
|||
|
|
@ -124,6 +124,7 @@ export type {
|
|||
WebShellComposerToolbarRightRenderInfo,
|
||||
WelcomeFooterRenderer,
|
||||
WelcomeHeaderRenderer,
|
||||
WebShellBottomStatusItem,
|
||||
WebShellCodeBlockRenderInfo,
|
||||
WebShellMarkdownCustomization,
|
||||
} from './customization';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue