From 250ead34d7f73ec143f438565570fb46e25ba62c Mon Sep 17 00:00:00 2001 From: callmeYe <512217680@qq.com> Date: Thu, 2 Jul 2026 20:04:24 +0800 Subject: [PATCH] fix(web-shell): polish session timeline rail (#6171) * fix(web-shell): polish session timeline rail * fix(web-shell): handle unicode italic timeline punctuation * fix(web-shell): clean adjacent timeline italics * fix(web-shell): align timeline detail selection * fix(web-shell): harden timeline markdown preview * fix(web-shell): satisfy timeline placeholder lint --------- Co-authored-by: Shaojin Wen --- packages/web-shell/client/App.tsx | 1 + .../components/MessageList.dom.test.tsx | 63 +++++---- .../client/components/MessageList.module.css | 13 +- .../client/components/MessageList.test.ts | 87 +++++++++++- .../client/components/MessageList.tsx | 126 +++++++++++++++--- 5 files changed, 233 insertions(+), 57 deletions(-) diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index 98fa6de2d9..502565a413 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -3776,6 +3776,7 @@ export function App({ activeTurnStartedAt={activeTurnStartedAt} workspaceCwd={connection.workspaceCwd || ''} shellOutputMaxLines={shellOutputMaxLines} + hideSessionTimeline={effectiveChatWidthMode === 'wide'} showRetryHint={showRetryHint} onRetryClick={handleRetry} onBranchSession={handleBranchCurrentSession} diff --git a/packages/web-shell/client/components/MessageList.dom.test.tsx b/packages/web-shell/client/components/MessageList.dom.test.tsx index 0ac087a909..99c8d92322 100644 --- a/packages/web-shell/client/components/MessageList.dom.test.tsx +++ b/packages/web-shell/client/components/MessageList.dom.test.tsx @@ -127,6 +127,7 @@ function mount( messages: Message[], ref?: RefObject, opts: { + hideSessionTimeline?: boolean; isResponding?: boolean; onCanScrollToBottomChange?: (canScrollToBottom: boolean) => void; } = {}, @@ -141,6 +142,7 @@ function mount( ref={ref} messages={messages} pendingApproval={null} + hideSessionTimeline={opts.hideSessionTimeline} isResponding={opts.isResponding} shellOutputMaxLines={50} onCanScrollToBottomChange={opts.onCanScrollToBottomChange} @@ -189,6 +191,11 @@ const mockMessageListWidth = (width: number) => y: 0, toJSON: () => ({}), }); +const simpleTurns = (count: number): Message[] => + Array.from({ length: count }, (_, index) => { + const turn = index + 1; + return [userMsg(`u${turn}`), asstMsg(`a${turn}`)] as Message[]; + }).flat(); describe('MessageList — turn collapse (DOM)', () => { it('collapses a completed turn: hides the step, keeps prompt + answer, shows the toggle', () => { @@ -283,6 +290,10 @@ describe('MessageList — turn collapse (DOM)', () => { asstMsg('a1'), userMsg('u2'), asstMsg('a2'), + userMsg('u3'), + asstMsg('a3'), + userMsg('u4'), + asstMsg('a4'), ]); await nextFrame(); @@ -294,6 +305,8 @@ describe('MessageList — turn collapse (DOM)', () => { expect(entries.map((entry) => entry.getAttribute('data-turn-id'))).toEqual([ 'u1', 'u2', + 'u3', + 'u4', ]); expect(entries[0]?.getAttribute('data-node-kinds')).toBe( 'thought,commentary,tool,plan', @@ -301,10 +314,8 @@ describe('MessageList — turn collapse (DOM)', () => { const details = Array.from( c.querySelectorAll('[data-testid="session-timeline-detail"]'), ); - expect(details).toHaveLength(2); - expect(details[0]?.getAttribute('data-detail')).toContain( - 'thinking · answer · 1 tool call · plan update', - ); + expect(details).toHaveLength(4); + expect(details[0]?.getAttribute('data-detail')).toBe('answer'); const buttons = Array.from( c.querySelectorAll( '[data-testid="session-timeline-entry"] button', @@ -313,7 +324,7 @@ describe('MessageList — turn collapse (DOM)', () => { expect(buttons[0]?.getAttribute('aria-label')).toBe( 'Turn 1: q. Current turn', ); - expect(buttons[0]?.getAttribute('title')).toContain('thinking'); + expect(buttons[0]?.hasAttribute('title')).toBe(false); expect(entries[0]?.getAttribute('data-in-current-range')).toBe('true'); expect(entries[1]?.getAttribute('data-in-current-range')).toBe('true'); expect( @@ -324,17 +335,21 @@ describe('MessageList — turn collapse (DOM)', () => { rectSpy.mockRestore(); }); + it('hides the session timeline until there are at least four turns', async () => { + const rectSpy = mockMessageListWidth(1200); + const c = mount(simpleTurns(3)); + await nextFrame(); + + expect(c.querySelector('[data-testid="session-timeline"]')).toBeNull(); + rectSpy.mockRestore(); + }); + it('clicks a session timeline entry to jump to its turn', async () => { const rectSpy = mockMessageListWidth(1200); const scrollIntoView = vi .spyOn(Element.prototype, 'scrollIntoView') .mockImplementation(() => {}); - const c = mount([ - userMsg('u1'), - asstMsg('a1'), - userMsg('u2'), - asstMsg('a2'), - ]); + const c = mount(simpleTurns(4)); await nextFrame(); const secondEntryButton = c.querySelector( @@ -355,12 +370,19 @@ describe('MessageList — turn collapse (DOM)', () => { it('hides the session timeline when the message list is narrow', async () => { const rectSpy = mockMessageListWidth(1000); - const c = mount([ - userMsg('u1'), - asstMsg('a1'), - userMsg('u2'), - asstMsg('a2'), - ]); + const c = mount(simpleTurns(4)); + await nextFrame(); + + expect(c.querySelector('[data-testid="session-timeline"]')).toBeNull(); + rectSpy.mockRestore(); + }); + + it('hides the session timeline when the caller disables it', async () => { + const rectSpy = mockMessageListWidth(1200); + + const c = mount(simpleTurns(4), undefined, { + hideSessionTimeline: true, + }); await nextFrame(); expect(c.querySelector('[data-testid="session-timeline"]')).toBeNull(); @@ -370,12 +392,7 @@ describe('MessageList — turn collapse (DOM)', () => { it('hides the session timeline when the message list has no width', async () => { const rectSpy = mockMessageListWidth(0); - const c = mount([ - userMsg('u1'), - asstMsg('a1'), - userMsg('u2'), - asstMsg('a2'), - ]); + const c = mount(simpleTurns(4)); await nextFrame(); expect(c.querySelector('[data-testid="session-timeline"]')).toBeNull(); diff --git a/packages/web-shell/client/components/MessageList.module.css b/packages/web-shell/client/components/MessageList.module.css index 06f46a9871..1edcb81f01 100644 --- a/packages/web-shell/client/components/MessageList.module.css +++ b/packages/web-shell/client/components/MessageList.module.css @@ -74,6 +74,7 @@ margin-right: 0 !important; margin-left: 0 !important; transition: none; + transform: translateX(-22px); pointer-events: none; } @@ -130,7 +131,7 @@ .sessionTimelineTick { display: block; - width: 12px; + width: 10px; height: 4px; border-radius: 1px; background: color-mix(in srgb, var(--muted-foreground) 30%, transparent); @@ -143,7 +144,7 @@ .sessionTimelineButton:hover .sessionTimelineTick, .sessionTimelineButton:focus-visible .sessionTimelineTick { background: color-mix(in srgb, var(--foreground) 94%, transparent); - transform: scaleX(4.4); + transform: scaleX(3.8); } .sessionTimelineItem:has(.sessionTimelineButton:hover) @@ -158,7 +159,7 @@ + .sessionTimelineItem .sessionTimelineButton:focus-visible ) .sessionTimelineTick { - transform: scaleX(3.4); + transform: scaleX(3); } .sessionTimelineItem:has(.sessionTimelineButton:hover) @@ -179,7 +180,7 @@ .sessionTimelineButton:focus-visible ) .sessionTimelineTick { - transform: scaleX(2.4); + transform: scaleX(2.2); } .sessionTimelineItem:has(.sessionTimelineButton:hover) @@ -206,7 +207,7 @@ .sessionTimelineButton:focus-visible ) .sessionTimelineTick { - transform: scaleX(1.6); + transform: scaleX(1.5); } .sessionTimelineButtonInRange .sessionTimelineTick { @@ -227,7 +228,7 @@ .sessionTimelineDetails { position: absolute; top: 50%; - left: 72px; + left: 56px; z-index: 1; box-sizing: border-box; display: grid; diff --git a/packages/web-shell/client/components/MessageList.test.ts b/packages/web-shell/client/components/MessageList.test.ts index bb28300099..c9862fc6f0 100644 --- a/packages/web-shell/client/components/MessageList.test.ts +++ b/packages/web-shell/client/components/MessageList.test.ts @@ -443,21 +443,21 @@ describe('getSessionTimelineEntries', () => { { id: 'u1', label: 'hello', - detail: 'thinking · 2 tool calls · plan update', + detail: 'response', timestamp: undefined, nodeKinds: ['thought', 'tool', 'plan'], }, { id: 'u2', label: 'hello', - detail: 'No activity', + detail: 'response', timestamp: undefined, nodeKinds: [], }, ]); }); - it('keeps mid-turn assistant updates but ignores the final answer', () => { + it('prefers the visible final answer over hidden turn steps', () => { expect( getSessionTimelineEntries([ makeUserMessage('u1'), @@ -469,20 +469,40 @@ describe('getSessionTimelineEntries', () => { { id: 'u1', label: 'hello', - detail: 'response · thinking', + detail: 'response', timestamp: undefined, nodeKinds: ['commentary', 'thought'], }, ]); }); - it('summarizes thinking without exposing its content', () => { + it('does not prefer assistant text before later tool work as the final detail', () => { + const [entry] = getSessionTimelineEntries([ + makeUserMessage('u1'), + { ...makeAssistantMessage('mid'), content: "I'll check" }, + makeAgentToolGroup('tool', 'Read'), + ]); + + expect(entry?.detail).toBe("I'll check · 1 tool call"); + }); + + it('uses the final answer detail without exposing thinking content', () => { const [entry] = getSessionTimelineEntries([ makeUserMessage('u1'), makeThinkingMessage('think', 'private reasoning details'), makeAssistantMessage('final'), ]); + expect(entry?.detail).toBe('response'); + expect(entry?.detail).not.toContain('private'); + }); + + it('falls back to a thinking summary when there is no final answer', () => { + const [entry] = getSessionTimelineEntries([ + makeUserMessage('u1'), + makeThinkingMessage('think', 'private reasoning details'), + ]); + expect(entry?.detail).toBe('thinking'); expect(entry?.detail).not.toContain('private'); }); @@ -520,7 +540,7 @@ describe('getSessionTimelineEntries', () => { { id: 'u1', label: 'hello', - detail: '2 parallel agents', + detail: 'response', timestamp: undefined, nodeKinds: ['agents'], }, @@ -544,6 +564,17 @@ describe('getSessionTimelineEntries', () => { ]); }); + it('preserves shell glob syntax in timeline labels', () => { + const [entry] = getSessionTimelineEntries([ + { + ...makeUserShellMessage('shell'), + command: 'find packages/*/src/*.ts', + }, + ]); + + expect(entry?.label).toBe('find packages/*/src/*.ts'); + }); + it('keeps a single prompt turn as no activity', () => { expect(getSessionTimelineEntries([makeUserMessage('u1')])).toEqual([ { @@ -563,6 +594,50 @@ describe('getSessionTimelineEntries', () => { expect(entry?.label.endsWith('…')).toBe(true); expect(/[\uD800-\uDFFF]/u.test(entry?.label ?? '')).toBe(false); }); + + it('cleans markdown markers from timeline details', () => { + const [entry] = getSessionTimelineEntries([ + { + ...makeUserMessage('u1'), + content: '介绍下 `agent-reproduce-align`', + }, + { + ...makeAssistantMessage('a1'), + content: + '**agent-reproduce-align** – 对齐测试技能\n\n**用途:** 在 [Qwen Code](https://example.com) 中运行参考代码,中文*强调*·*范围*—*引用*「_下划线_,保留 snake_case。', + }, + ]); + + expect(entry?.label).toBe('介绍下 `agent-reproduce-align`'); + expect(entry?.detail).toBe( + 'agent-reproduce-align – 对齐测试技能 用途: 在 Qwen Code 中运行参考代码,中文强调·范围—引用「下划线,保留 snake_case。', + ); + }); + + it('cleans preview markdown without rewriting code text', () => { + const [entry] = getSessionTimelineEntries([ + makeUserMessage('u1'), + { + ...makeAssistantMessage('a1'), + content: + '# Title\n> quoted\n- item\n~~gone~~\n![alt text](url)\n`*literal*`\n```ts\n**code**\n```', + }, + ]); + + expect(entry?.detail).toBe( + 'Title quoted item gone alt text *literal* **code**', + ); + }); + + it('falls back when the final answer cleans to an empty detail', () => { + const [entry] = getSessionTimelineEntries([ + makeUserMessage('u1'), + { ...makeAssistantMessage('a1'), content: '![](url)' }, + ]); + + expect(entry?.detail).toBe('assistant update'); + expect(entry?.nodeKinds).toEqual(['commentary']); + }); }); describe('getSessionTimelineRangeForIndexes', () => { diff --git a/packages/web-shell/client/components/MessageList.tsx b/packages/web-shell/client/components/MessageList.tsx index 6dd4ca97dd..660a9bc6da 100644 --- a/packages/web-shell/client/components/MessageList.tsx +++ b/packages/web-shell/client/components/MessageList.tsx @@ -55,6 +55,7 @@ interface MessageListProps { * panels don't yank the reader to the bottom. Defaults to false. */ autoScrollTailIntoView?: boolean; + hideSessionTimeline?: boolean; showRetryHint?: boolean; onRetryClick?: () => void; onBranchSession?: () => void; @@ -531,8 +532,11 @@ export function getTurnTimelineNode(item: DisplayItem): TurnTimelineNode { function compactTimelineText( raw: string | null | undefined, maxLength: number, + options: { stripMarkdown?: boolean } = {}, ): string { - const compact = raw?.replace(/\s+/g, ' ').trim() ?? ''; + const source = + options.stripMarkdown === true ? cleanTimelineMarkdown(raw) : (raw ?? ''); + const compact = source.replace(/\s+/g, ' ').trim(); if (maxLength <= 0) return ''; if (!compact) return ''; const chars = Array.from(compact); @@ -541,6 +545,63 @@ function compactTimelineText( : compact; } +function cleanTimelineMarkdown(raw: string | null | undefined): string { + if (!raw) return ''; + const inlinePlaceholders: string[] = []; + const stashInline = (value: string) => { + const key = `\u0000${inlinePlaceholders.length}\u0000`; + inlinePlaceholders.push(value); + return key; + }; + + let cleaned = raw + .replace(/```[^\n`]*\n?([\s\S]*?)```/g, (_match, code: string) => + stashInline(code), + ) + .replace(/!\[([^\]]*)\]\([^)]+\)/g, '$1') + .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') + .replace(/`([^`\n]+)`/g, (_match, code: string) => stashInline(code)) + .replace(/^\s{0,3}#{1,6}\s+/gm, '') + .replace(/^\s{0,3}>\s?/gm, '') + .replace(/^\s*[-*+]\s+/gm, ''); + + cleaned = stripBalancedTimelineMarker(cleaned, '~~'); + cleaned = stripBalancedTimelineMarker(cleaned, '**'); + cleaned = stripBalancedTimelineMarker(cleaned, '__'); + cleaned = cleaned + .replace(/\*([^*\s][^*]*?\S)\*/g, '$1') + .replace( + /(^|[^\p{L}\p{N}_])_([^_\s][^_]*?\S)_(?=$|[^\p{L}\p{N}_])/gu, + '$1$2', + ); + + for (const [index, value] of inlinePlaceholders.entries()) { + cleaned = cleaned.split(`\u0000${index}\u0000`).join(value); + } + return cleaned; +} + +function stripBalancedTimelineMarker(raw: string, marker: string): string { + let result = ''; + let index = 0; + while (index < raw.length) { + const start = raw.indexOf(marker, index); + if (start === -1) return result + raw.slice(index); + + const contentStart = start + marker.length; + const end = raw.indexOf(marker, contentStart); + if (end === -1) return result + raw.slice(index); + + const content = raw.slice(contentStart, end); + result += + content.trim().length === 0 + ? raw.slice(index, end + marker.length) + : raw.slice(index, start) + content; + index = end + marker.length; + } + return result; +} + function timelineLabelForTurn(message: Message): string { const raw = message.role === 'user' @@ -565,7 +626,7 @@ function timelineDetailSnippetForMessage(message: Message): string { // Thinking content may include private model reasoning; keep details label-only. return SESSION_TIMELINE_KIND_LABEL.thought; case 'assistant': - return compactTimelineText(message.content, 120); + return compactTimelineText(message.content, 120, { stripMarkdown: true }); case 'tool_group': { const count = message.tools.length; return `${count} tool call${count === 1 ? '' : 's'}`; @@ -574,7 +635,7 @@ function timelineDetailSnippetForMessage(message: Message): string { return 'plan update'; case 'system': return isMidTurnInjectedDebugMessage(message) - ? compactTimelineText(message.content, 120) + ? compactTimelineText(message.content, 120, { stripMarkdown: true }) : ''; case 'user': case 'user_shell': @@ -605,6 +666,20 @@ function timelineDetailForTurn( finalAssistantId: string | null, nodeKinds: readonly TurnTimelineNodeKind[], ): string { + if (finalAssistantId !== null) { + for (const item of turnItems) { + if (item.type !== 'message') continue; + const { message } = item; + if (message.id !== finalAssistantId || message.role !== 'assistant') { + continue; + } + const finalAnswerDetail = compactTimelineText(message.content, 180, { + stripMarkdown: true, + }); + if (finalAnswerDetail) return finalAnswerDetail; + } + } + const snippets: string[] = []; for (let i = 0; i < turnItems.length; i += 1) { const item = turnItems[i]!; @@ -638,20 +713,23 @@ export function getSessionTimelineEntries( const pushTurn = () => { if (!turnStart) return; - let finalAssistantId: string | null = null; - for (let i = turnItems.length - 1; i >= 0; i -= 1) { - const item = turnItems[i]; - if ( - item?.role === 'assistant' && - compactTimelineText(item.content, 1).length > 0 && - !item.isStreaming - ) { - finalAssistantId = item.id; - break; - } - } - const timelineItems = groupParallelAgents(turnItems); + const finalAssistantIndex = findFinalAnswerIndex( + timelineItems, + -1, + timelineItems.length - 1, + ); + const finalAssistantItem = + finalAssistantIndex >= 0 ? timelineItems[finalAssistantIndex] : null; + const finalAssistantId = + finalAssistantItem?.type === 'message' && + finalAssistantItem.message.role === 'assistant' && + !finalAssistantItem.message.isStreaming && + compactTimelineText(finalAssistantItem.message.content, 1, { + stripMarkdown: true, + }).length > 0 + ? finalAssistantItem.message.id + : null; const nodeKinds: TurnTimelineNodeKind[] = []; for (const item of timelineItems) { if ( @@ -1243,6 +1321,7 @@ const ESTIMATE_TURN_COLLAPSE = 32; const ESTIMATE_TAIL = 240; const FOLLOW_BOTTOM_THRESHOLD_PX = 30; export const VIRTUAL_SCROLL_THRESHOLD = 200; +const SESSION_TIMELINE_MIN_VISIBLE_ENTRIES = 4; export function shouldUseVirtualScroll( totalCount: number, @@ -1518,10 +1597,6 @@ const SessionTimeline = memo(function SessionTimeline({ ? index === currentRange.currentIndex : entry.id === currentTurnId; const nodeKinds = entry.nodeKinds.join(','); - const titleParts = [ - `Turn ${index + 1}: ${entry.label}`, - entry.detail, - ]; const ariaLabel = [ `Turn ${index + 1}: ${entry.label}`, isCurrent ? 'Current turn' : null, @@ -1548,7 +1623,6 @@ const SessionTimeline = memo(function SessionTimeline({ )} aria-current={isCurrent ? 'step' : undefined} aria-label={ariaLabel} - title={titleParts.join(' · ')} onClick={() => onSelect(entry.id)} > @@ -1592,6 +1666,7 @@ export const MessageList = forwardRef( virtualScrollThreshold = VIRTUAL_SCROLL_THRESHOLD, shellOutputMaxLines, autoScrollTailIntoView = false, + hideSessionTimeline = false, showRetryHint = false, onRetryClick, onBranchSession, @@ -1751,8 +1826,15 @@ export const MessageList = forwardRef( const [isSessionTimelineVisible, setIsSessionTimelineVisible] = useState(false); + const hasEnoughSessionTimelineEntries = + sessionTimelineEntries.length >= SESSION_TIMELINE_MIN_VISIBLE_ENTRIES; useLayoutEffect(() => { + if (hideSessionTimeline || !hasEnoughSessionTimelineEntries) { + setIsSessionTimelineVisible((prev) => (prev ? false : prev)); + return; + } + const el = containerRef.current; if (!el) return; @@ -1769,7 +1851,7 @@ export const MessageList = forwardRef( const observer = new ResizeObserver(updateVisibility); observer.observe(el); return () => observer.disconnect(); - }, []); + }, [hasEnoughSessionTimelineEntries, hideSessionTimeline]); // ── Scroll-follow state ────────────────────────────────────────────── //