mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-09 17:19:02 +00:00
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:
parent
76addf4ef6
commit
250ead34d7
5 changed files with 233 additions and 57 deletions
|
|
@ -3776,6 +3776,7 @@ export function App({
|
|||
activeTurnStartedAt={activeTurnStartedAt}
|
||||
workspaceCwd={connection.workspaceCwd || ''}
|
||||
shellOutputMaxLines={shellOutputMaxLines}
|
||||
hideSessionTimeline={effectiveChatWidthMode === 'wide'}
|
||||
showRetryHint={showRetryHint}
|
||||
onRetryClick={handleRetry}
|
||||
onBranchSession={handleBranchCurrentSession}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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\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: '' },
|
||||
]);
|
||||
|
||||
expect(entry?.detail).toBe('assistant update');
|
||||
expect(entry?.nodeKinds).toEqual(['commentary']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSessionTimelineRangeForIndexes', () => {
|
||||
|
|
|
|||
|
|
@ -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 ──────────────────────────────────────────────
|
||||
//
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue