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 <shaojin.wensj@alibaba-inc.com>
This commit is contained in:
callmeYe 2026-07-02 20:04:24 +08:00 committed by GitHub
parent 76addf4ef6
commit 250ead34d7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 233 additions and 57 deletions

View file

@ -3776,6 +3776,7 @@ export function App({
activeTurnStartedAt={activeTurnStartedAt}
workspaceCwd={connection.workspaceCwd || ''}
shellOutputMaxLines={shellOutputMaxLines}
hideSessionTimeline={effectiveChatWidthMode === 'wide'}
showRetryHint={showRetryHint}
onRetryClick={handleRetry}
onBranchSession={handleBranchCurrentSession}

View file

@ -127,6 +127,7 @@ function mount(
messages: Message[],
ref?: RefObject<MessageListHandle | null>,
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<HTMLButtonElement>(
'[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<HTMLButtonElement>(
@ -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();

View file

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

View file

@ -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', () => {

View file

@ -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)}
>
<span className={styles.sessionTimelineTick} />
@ -1592,6 +1666,7 @@ export const MessageList = forwardRef<MessageListHandle, MessageListProps>(
virtualScrollThreshold = VIRTUAL_SCROLL_THRESHOLD,
shellOutputMaxLines,
autoScrollTailIntoView = false,
hideSessionTimeline = false,
showRetryHint = false,
onRetryClick,
onBranchSession,
@ -1751,8 +1826,15 @@ export const MessageList = forwardRef<MessageListHandle, MessageListProps>(
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<MessageListHandle, MessageListProps>(
const observer = new ResizeObserver(updateVisibility);
observer.observe(el);
return () => observer.disconnect();
}, []);
}, [hasEnoughSessionTimelineEntries, hideSessionTimeline]);
// ── Scroll-follow state ──────────────────────────────────────────────
//