Skip to content
20 changes: 18 additions & 2 deletions packages/web-shell/client/components/MessageItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ interface MessageItemProps {
onBranchSession?: () => void;
showAssistantActions?: boolean;
showAssistantBranch?: boolean;
isLocateFlashing?: boolean;
shellOutputMaxLines: number;
}

Expand All @@ -44,13 +45,18 @@ export const MessageItem = memo(function MessageItem({
onBranchSession,
showAssistantActions = false,
showAssistantBranch = false,
isLocateFlashing = false,
shellOutputMaxLines,
}: MessageItemProps) {
const body = ((): ReactElement | null => {
switch (message.role) {
case 'user':
return (
<UserMessage content={message.content} images={message.images} />
<UserMessage
content={message.content}
images={message.images}
isLocateFlashing={isLocateFlashing}
/>
);
case 'assistant':
return (
Expand All @@ -61,6 +67,7 @@ export const MessageItem = memo(function MessageItem({
onBranchSession={onBranchSession}
showFooterActions={showAssistantActions}
showBranchAction={showAssistantBranch}
isLocateFlashing={isLocateFlashing}
/>
);
case 'thinking':
Expand All @@ -69,6 +76,7 @@ export const MessageItem = memo(function MessageItem({
content={message.content}
isStreaming={message.isStreaming}
timestamp={message.timestamp}
isLocateFlashing={isLocateFlashing}
/>
);
case 'tool_group':
Expand All @@ -78,10 +86,17 @@ export const MessageItem = memo(function MessageItem({
pendingApproval={pendingApproval}
workspaceCwd={workspaceCwd}
shellOutputMaxLines={shellOutputMaxLines}
isLocateFlashing={isLocateFlashing}
/>
);
case 'plan':

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The case 'system' branch (line 102) does not forward isLocateFlashing to SystemMessage, while all other updated branches (user, assistant, thinking, tool_group, plan) receive it. displayItemMatchesLocateTarget correctly returns true for system messages by ID, so MessageItem receives isLocateFlashing=true but the system branch silently drops it — the row scrolls into view but no flash appears.

Consider forwarding the prop to SystemMessage (with a matching flash wrapper), or wrapping the case 'system' return in a <div> with the flash class, mirroring the parallel_agents pattern.

— qwen3.7-max via Qwen Code /review

return <PlanMessage id={message.id} todos={message.todos} />;
return (
<PlanMessage
id={message.id}
todos={message.todos}
isLocateFlashing={isLocateFlashing}
/>
);
case 'system':
return (
<SystemMessage
Expand Down Expand Up @@ -239,6 +254,7 @@ function areMessageItemPropsEqual(
if (prev.onBranchSession !== next.onBranchSession) return false;
if (prev.showAssistantActions !== next.showAssistantActions) return false;
if (prev.showAssistantBranch !== next.showAssistantBranch) return false;
if (prev.isLocateFlashing !== next.isLocateFlashing) return false;
if (prev.shellOutputMaxLines !== next.shellOutputMaxLines) return false;
return areMessagesEqual(prev.message, next.message);
}
Expand Down
86 changes: 83 additions & 3 deletions packages/web-shell/client/components/MessageList.dom.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { act, createRef, type RefObject } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import type { Message } from '../adapters/types';
import { I18nProvider } from '../i18n';
import flashStyles from './MessageLocateFlash.module.css';
import styles from './MessageList.module.css';

// Mock the App context and the heavy row children so this test exercises only
Expand All @@ -18,15 +19,18 @@ vi.mock('./MessageItem', async () => {
MessageItem: ({
message,
showAssistantActions,
isLocateFlashing,
}: {
message: Message;
showAssistantActions?: boolean;
isLocateFlashing?: boolean;
}) =>
React.createElement(
'div',
{
'data-testid': `msg-${message.id}`,
'data-assistant-actions': String(Boolean(showAssistantActions)),
'data-locate-flashing': isLocateFlashing ? 'true' : undefined,
},
message.role === 'thinking'
? React.createElement('button', {
Expand All @@ -37,9 +41,13 @@ vi.mock('./MessageItem', async () => {
),
};
});
vi.mock('./messages/tools/ParallelAgentsGroup', () => ({
ParallelAgentsGroup: () => null,
}));
vi.mock('./messages/tools/ParallelAgentsGroup', async () => {
const React = await import('react');
return {
ParallelAgentsGroup: () =>
React.createElement('div', { 'data-testid': 'parallel-agents' }),
};
});
vi.mock('./messages/ToolApproval', () => ({ ToolApproval: () => null }));
vi.mock('./messages/AskUserQuestion', () => ({ AskUserQuestion: () => null }));
vi.mock('@tanstack/react-virtual', () => ({
Expand Down Expand Up @@ -133,6 +141,18 @@ const toolMsg = (id: string): ToolGroupMessage => ({
role: 'tool_group',
tools: [{ callId: `call-${id}`, toolName: 'Read', status: 'completed' }],
});
const agentMsg = (id: string): ToolGroupMessage => ({
id,
role: 'tool_group',
tools: [
{
callId: `call-${id}`,
toolName: 'Task',
status: 'completed',
args: { subagent_type: 'explore' },
},
],
});
const asstMsg = (id: string): AssistantMessage => ({
id,
role: 'assistant',
Expand Down Expand Up @@ -403,6 +423,31 @@ describe('MessageList — turn collapse (DOM)', () => {
rectSpy.mockRestore();
});

it('marks scheduled task turns in the session timeline', async () => {
const rectSpy = mockMessageListWidth(1200);
const c = mount([
{ ...userMsg('u1'), source: 'cron', content: 'scheduled tracking task' },
asstMsg('a1'),
userMsg('u2'),
asstMsg('a2'),
userMsg('u3'),
asstMsg('a3'),
userMsg('u4'),
asstMsg('a4'),
]);
await nextFrame();

const scheduledDetail = c.querySelector(
'[data-turn-id="u1"] [data-testid="session-timeline-detail"]',
);
expect(scheduledDetail?.getAttribute('data-scheduled-task')).toBe('true');
expect(
scheduledDetail?.querySelector(`.${styles.sessionTimelineDetailsIcon}`),
).not.toBeNull();
expect(scheduledDetail?.textContent).toContain('scheduled tracking task');
rectSpy.mockRestore();
});

it('hides the session timeline until there are at least four turns', async () => {
const rectSpy = mockMessageListWidth(1200);
const c = mount(simpleTurns(3));
Expand Down Expand Up @@ -431,10 +476,45 @@ describe('MessageList — turn collapse (DOM)', () => {
});

expect(scrollIntoView).toHaveBeenCalledWith({ block: 'center' });
await nextFrame();

const targetMessage = c.querySelector('[data-testid="msg-u2"]');
expect(targetMessage?.getAttribute('data-locate-flashing')).toBe('true');
expect(targetMessage?.closest('[data-index]')?.className).not.toMatch(
/flash/i,
);
scrollIntoView.mockRestore();
rectSpy.mockRestore();
});

it('flashes grouped parallel agents inside the row when locating a tool', async () => {
const scrollIntoView = vi
.spyOn(Element.prototype, 'scrollIntoView')
.mockImplementation(() => {});
const ref = createRef<MessageListHandle>();
const c = mount(
[userMsg('u1'), agentMsg('g1'), agentMsg('g2'), asstMsg('a1')],
ref,
);

let found = false;
act(() => {
found = ref.current!.scrollToMessage('g1', 'call-g1');
});
await nextFrame();

expect(found).toBe(true);
expect(scrollIntoView).toHaveBeenCalledWith({ block: 'center' });
const parallelAgents = c.querySelector('[data-testid="parallel-agents"]');
expect(parallelAgents?.parentElement?.className).toContain(
flashStyles.flash,
);
expect(parallelAgents?.closest('[data-index]')?.className).not.toMatch(
/flash/i,
);
scrollIntoView.mockRestore();
});

it('hides the session timeline when the message list is narrow', async () => {
const rectSpy = mockMessageListWidth(1000);

Expand Down
61 changes: 31 additions & 30 deletions packages/web-shell/client/components/MessageList.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -147,11 +147,6 @@
}
}

.rowFlash {
border-radius: 8px;
animation: row-flash 1.6s ease-out;
}

.turnStatusRow,
.turnAnswerRow {
max-width: 100%;
Expand Down Expand Up @@ -210,20 +205,20 @@
position: relative;
display: flex;
flex-direction: column;
gap: 13px;
gap: 12px;
margin: 0;
padding: 0;
list-style: none;
}

.sessionTimelineItem {
position: relative;
height: 8px;
height: 3px;
}

.sessionTimelineButton {
position: relative;
top: -4px;
top: -6.5px;
display: flex;
align-items: center;
width: 58px;
Expand All @@ -249,7 +244,7 @@
.sessionTimelineTick {
display: block;
width: 10px;
height: 4px;
height: 3px;
border-radius: 1px;
background: color-mix(in srgb, var(--muted-foreground) 30%, transparent);
transform-origin: left center;
Expand All @@ -261,7 +256,7 @@
.sessionTimelineButton:hover .sessionTimelineTick,
.sessionTimelineButton:focus-visible .sessionTimelineTick {
background: color-mix(in srgb, var(--foreground) 94%, transparent);
transform: scaleX(3.8);
transform: scaleX(2.8);
}

.sessionTimelineItem:has(.sessionTimelineButton:hover)
Expand All @@ -276,7 +271,7 @@
+ .sessionTimelineItem .sessionTimelineButton:focus-visible
)
.sessionTimelineTick {
transform: scaleX(3);
transform: scaleX(2.2);
}

.sessionTimelineItem:has(.sessionTimelineButton:hover)
Expand All @@ -297,7 +292,7 @@
.sessionTimelineButton:focus-visible
)
.sessionTimelineTick {
transform: scaleX(2.2);
transform: scaleX(1.6);
}

.sessionTimelineItem:has(.sessionTimelineButton:hover)
Expand All @@ -324,7 +319,7 @@
.sessionTimelineButton:focus-visible
)
.sessionTimelineTick {
transform: scaleX(1.5);
transform: scaleX(1.2);
}

.sessionTimelineButtonInRange .sessionTimelineTick {
Expand Down Expand Up @@ -372,8 +367,26 @@
transform: translate3d(0, -50%, 0) scale(1);
}

.sessionTimelineDetails::before {
content: attr(data-title);
.sessionTimelineDetailsTitle {
display: flex;
align-items: center;
min-width: 0;
gap: 8px;
}

.sessionTimelineDetailsIcon {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: color-mix(in srgb, var(--muted-foreground) 86%, transparent);
fill: none;
stroke: currentColor;
stroke-linecap: round;
stroke-linejoin: round;
stroke-width: 1.4;
}

.sessionTimelineDetailsTitleText {
overflow: hidden;
color: var(--foreground);
font-size: 14px;
Expand All @@ -383,15 +396,13 @@
white-space: nowrap;
}

.sessionTimelineDetails::after {
content: attr(data-detail);
display: -webkit-box;
.sessionTimelineDetailsDetail {
overflow: hidden;
color: color-mix(in srgb, var(--muted-foreground) 86%, transparent);
font-size: 13px;
line-height: 1.45;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
text-overflow: ellipsis;
white-space: nowrap;
}

@media (prefers-reduced-motion: reduce) {
Expand All @@ -406,13 +417,3 @@
display: none;
}
}

@keyframes row-flash {
0%,
30% {
background: color-mix(in srgb, var(--agent-blue-500) 16%, transparent);
}
100% {
background: transparent;
}
}
Loading
Loading