Skip to content

Commit dc6edcd

Browse files
authored
feat(web-shell): show time on parallel-agents box and sub-agent tools (#5084)
The message-time-on-hover feature (#5079) wraps each transcript message with MessageTimestamp, but two sub-agent surfaces were left out and so looked inconsistent with the rest of the transcript: - The "Parallel agents · N/N done" box (ParallelAgentsGroup) renders directly in MessageList, bypassing MessageItem/MessageTimestamp, so it showed no time. Carry the first grouped launch's timestamp onto the parallel_agents display item and wrap the box in MessageTimestamp. - Each sub-tool row inside a SubAgentPanel's Tools list showed no time. Wrap each row in a scoped hover tooltip (.toolTimeRow/.toolTimeTip, kept separate from MessageTimestamp's .row/.tip so the nested tooltip stays independent of the enclosing message's) keyed off the tool's startTime. Both reuse formatTimestamp for an identical HH:mm:ss (or dated) format revealed on hover in the top-right corner, matching the main transcript.
1 parent c631d3a commit dc6edcd

5 files changed

Lines changed: 184 additions & 9 deletions

File tree

packages/web-shell/client/components/MessageList.test.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,11 @@ import {
88
VIRTUAL_SCROLL_THRESHOLD,
99
} from './MessageList';
1010

11-
function makeAgentToolGroup(id: string, toolName = 'Agent'): Message {
11+
function makeAgentToolGroup(
12+
id: string,
13+
toolName = 'Agent',
14+
timestamp?: number,
15+
): Message {
1216
return {
1317
id,
1418
role: 'tool_group',
@@ -20,6 +24,7 @@ function makeAgentToolGroup(id: string, toolName = 'Agent'): Message {
2024
args: { description: `task ${id}` },
2125
},
2226
],
27+
...(timestamp !== undefined ? { timestamp } : {}),
2328
};
2429
}
2530

@@ -102,6 +107,18 @@ describe('groupParallelAgents', () => {
102107
}
103108
});
104109

110+
it('carries the first launch time onto the grouped parallel-agents row', () => {
111+
const msgs = [
112+
makeAgentToolGroup('1', 'Agent', 1000),
113+
makeAgentToolGroup('2', 'Agent', 2000),
114+
];
115+
const items = groupParallelAgents(msgs);
116+
expect(items[0].type).toBe('parallel_agents');
117+
if (items[0].type === 'parallel_agents') {
118+
expect(items[0].timestamp).toBe(1000);
119+
}
120+
});
121+
105122
it('non-agent message breaks the group', () => {
106123
const msgs = [
107124
makeAgentToolGroup('1'),

packages/web-shell/client/components/MessageList.tsx

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
} from '../adapters/toolClassification';
2121
import { CompactModeContext } from '../App';
2222
import { MessageItem } from './MessageItem';
23+
import { MessageTimestamp } from './MessageTimestamp';
2324
import { ParallelAgentsGroup } from './messages/tools/ParallelAgentsGroup';
2425
import { ToolApproval } from './messages/ToolApproval';
2526
import { AskUserQuestion } from './messages/AskUserQuestion';
@@ -83,7 +84,16 @@ function getLastUserMessageId(messages: Message[]): string | null {
8384

8485
export type DisplayItem =
8586
| { type: 'message'; key: string; message: Message }
86-
| { type: 'parallel_agents'; key: string; agents: ACPToolCall[] };
87+
| {
88+
type: 'parallel_agents';
89+
key: string;
90+
agents: ACPToolCall[];
91+
/**
92+
* Wall-clock time of the first grouped launch, carried so the grouped
93+
* box reveals its time on hover exactly like a standalone message row.
94+
*/
95+
timestamp?: number;
96+
};
8797

8898
function isAgentOnlyToolGroup(msg: Message): boolean {
8999
return (
@@ -229,6 +239,7 @@ export function groupParallelAgents(messages: Message[]): DisplayItem[] {
229239
type: 'parallel_agents',
230240
key: `par-${grouped[0].id}`,
231241
agents: grouped.map((m) => (m as { tools: ACPToolCall[] }).tools[0]),
242+
timestamp: grouped[0].timestamp,
232243
});
233244
i = j;
234245
continue;
@@ -244,6 +255,7 @@ export function groupParallelAgents(messages: Message[]): DisplayItem[] {
244255
type: 'parallel_agents',
245256
key: `par-${grouped[0].id}`,
246257
agents: grouped.map((m) => (m as { tools: ACPToolCall[] }).tools[0]),
258+
timestamp: grouped[0].timestamp,
247259
});
248260
} else {
249261
items.push({
@@ -677,11 +689,13 @@ export const MessageList = forwardRef<MessageListHandle, MessageListProps>(
677689

678690
if (item.type === 'parallel_agents') {
679691
return (
680-
<ParallelAgentsGroup
681-
agents={item.agents}
682-
pendingApproval={pendingApproval}
683-
onConfirm={onConfirm}
684-
/>
692+
<MessageTimestamp timestamp={item.timestamp}>
693+
<ParallelAgentsGroup
694+
agents={item.agents}
695+
pendingApproval={pendingApproval}
696+
onConfirm={onConfirm}
697+
/>
698+
</MessageTimestamp>
685699
);
686700
}
687701

packages/web-shell/client/components/messages/tools/SubAgentPanel.module.css

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,3 +142,32 @@
142142
.tools .panel {
143143
border-left-width: 2px;
144144
}
145+
146+
/*
147+
* Per-sub-tool hover time. Deliberately a *separate* class pair from the
148+
* message-level MessageTimestamp (.row/.tip): the Tools list nests inside a
149+
* message that is already wrapped by MessageTimestamp, and reusing the same
150+
* hover rule across both layers couples them. These scoped names keep the
151+
* sub-tool tooltip independent of the surrounding message's time tooltip.
152+
*/
153+
.toolTimeRow {
154+
position: relative;
155+
}
156+
157+
.toolTimeRow > .toolTimeTip {
158+
position: absolute;
159+
top: 2px;
160+
right: 4px;
161+
z-index: 5;
162+
color: var(--text-dimmed);
163+
font-size: 11px;
164+
line-height: 1.4;
165+
white-space: nowrap;
166+
pointer-events: none;
167+
opacity: 0;
168+
transition: opacity 0.12s ease;
169+
}
170+
171+
.toolTimeRow:hover > .toolTimeTip {
172+
opacity: 1;
173+
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
// @vitest-environment jsdom
2+
import { afterEach, describe, expect, it, vi } from 'vitest';
3+
import { act } from 'react';
4+
import { createRoot, type Root } from 'react-dom/client';
5+
import { I18nProvider } from '../../../i18n';
6+
import type { ACPToolCall } from '../../../adapters/types';
7+
import { formatTimestamp } from '../../MessageTimestamp';
8+
9+
// SubAgentPanel pulls in ToolGroup, which imports App only for
10+
// CompactModeContext; loading the real App module would drag the whole
11+
// application graph into this unit test.
12+
vi.mock('../../../App', async () => {
13+
const { createContext } = await import('react');
14+
return { CompactModeContext: createContext(false) };
15+
});
16+
17+
const { SubAgentPanel } = await import('./SubAgentPanel');
18+
19+
(
20+
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
21+
).IS_REACT_ACT_ENVIRONMENT = true;
22+
23+
const mounted: Array<{ root: Root; container: HTMLElement }> = [];
24+
25+
afterEach(() => {
26+
for (const { root, container } of mounted.splice(0)) {
27+
act(() => root.unmount());
28+
container.remove();
29+
}
30+
});
31+
32+
function renderPanel(tool: ACPToolCall): HTMLElement {
33+
const container = document.createElement('div');
34+
document.body.appendChild(container);
35+
const root = createRoot(container);
36+
act(() => {
37+
root.render(
38+
<I18nProvider language="en">
39+
<SubAgentPanel tool={tool} defaultExpanded inline hideHeader />
40+
</I18nProvider>,
41+
);
42+
});
43+
mounted.push({ root, container });
44+
return container;
45+
}
46+
47+
function makeAgentWithSubTool(subTool: ACPToolCall): ACPToolCall {
48+
return {
49+
callId: 'agent-1',
50+
toolName: 'Task',
51+
status: 'completed',
52+
args: { description: 'demo agent' },
53+
subTools: [subTool],
54+
};
55+
}
56+
57+
describe('SubAgentPanel sub-tool timestamps', () => {
58+
it('renders each sub-tool start time, like the main transcript rows', () => {
59+
// A past date so formatTimestamp always renders the dated form; the
60+
// expectation is derived from the same formatter, so it matches
61+
// regardless of the test machine's clock or timezone.
62+
const startTime = new Date('2020-01-02T03:04:05').getTime();
63+
const container = renderPanel(
64+
makeAgentWithSubTool({
65+
callId: 'sub-1',
66+
toolName: 'Read',
67+
status: 'completed',
68+
startTime,
69+
}),
70+
);
71+
expect(container.textContent).toContain(formatTimestamp(startTime));
72+
});
73+
74+
it('renders a sub-tool without a start time unchanged (no time shown)', () => {
75+
const reference = new Date('2020-01-02T03:04:05').getTime();
76+
const container = renderPanel(
77+
makeAgentWithSubTool({
78+
callId: 'sub-1',
79+
toolName: 'Read',
80+
status: 'completed',
81+
}),
82+
);
83+
expect(container.textContent).not.toContain(formatTimestamp(reference));
84+
});
85+
});

packages/web-shell/client/components/messages/tools/SubAgentPanel.tsx

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { useWebShellCustomization } from '../../../customization';
1313
// each other's exports at render time — never in top-level code.
1414
import { ToolLine } from '../ToolGroup';
1515
import { Markdown } from '../Markdown';
16+
import { formatTimestamp } from '../../MessageTimestamp';
1617
import {
1718
formatDurationMs,
1819
formatElapsed,
@@ -70,11 +71,40 @@ function isTaskExecution(raw: unknown): raw is TaskExecution {
7071
);
7172
}
7273

74+
/**
75+
* Reveals a single sub-tool's wall-clock start time on hover in its top-right
76+
* corner, mirroring how the main transcript surfaces each message's time —
77+
* but via a scoped class pair (not MessageTimestamp) so the nested tooltip
78+
* stays independent of the enclosing message's own time tooltip.
79+
*/
80+
function SubToolTime({
81+
timestamp,
82+
children,
83+
}: {
84+
timestamp?: number;
85+
children: ReactNode;
86+
}) {
87+
if (timestamp === undefined) return <>{children}</>;
88+
return (
89+
<div className={styles.toolTimeRow}>
90+
{children}
91+
<span className={styles.toolTimeTip} aria-hidden="true">
92+
{formatTimestamp(timestamp)}
93+
</span>
94+
</div>
95+
);
96+
}
97+
7398
const SubToolLine = memo(function SubToolLine({ tool }: { tool: ACPToolCall }) {
74-
if (tool.subTools || tool.subContent) return <SubAgentPanel tool={tool} />;
7599
// Same row as the main transcript: one-line summary, expandable to
76100
// the full output / diff / file content where the tool has any.
77-
return <ToolLine tool={tool} />;
101+
const body =
102+
tool.subTools || tool.subContent ? (
103+
<SubAgentPanel tool={tool} />
104+
) : (
105+
<ToolLine tool={tool} />
106+
);
107+
return <SubToolTime timestamp={tool.startTime}>{body}</SubToolTime>;
78108
});
79109

80110
function TaskToolCallLine({ tc }: { tc: TaskToolCall }) {

0 commit comments

Comments
 (0)