Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion packages/web-shell/client/components/MessageList.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@ import {
VIRTUAL_SCROLL_THRESHOLD,
} from './MessageList';

function makeAgentToolGroup(id: string, toolName = 'Agent'): Message {
function makeAgentToolGroup(
id: string,
toolName = 'Agent',
timestamp?: number,
): Message {
return {
id,
role: 'tool_group',
Expand All @@ -20,6 +24,7 @@ function makeAgentToolGroup(id: string, toolName = 'Agent'): Message {
args: { description: `task ${id}` },
},
],
...(timestamp !== undefined ? { timestamp } : {}),
};
}

Expand Down Expand Up @@ -102,6 +107,18 @@ describe('groupParallelAgents', () => {
}
});

it('carries the first launch time onto the grouped parallel-agents row', () => {
const msgs = [
makeAgentToolGroup('1', 'Agent', 1000),
makeAgentToolGroup('2', 'Agent', 2000),
];
const items = groupParallelAgents(msgs);
expect(items[0].type).toBe('parallel_agents');
if (items[0].type === 'parallel_agents') {
expect(items[0].timestamp).toBe(1000);
}
});

it('non-agent message breaks the group', () => {
const msgs = [
makeAgentToolGroup('1'),
Expand Down
26 changes: 20 additions & 6 deletions packages/web-shell/client/components/MessageList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
} from '../adapters/toolClassification';
import { CompactModeContext } from '../App';
import { MessageItem } from './MessageItem';
import { MessageTimestamp } from './MessageTimestamp';
import { ParallelAgentsGroup } from './messages/tools/ParallelAgentsGroup';
import { ToolApproval } from './messages/ToolApproval';
import { AskUserQuestion } from './messages/AskUserQuestion';
Expand Down Expand Up @@ -83,7 +84,16 @@ function getLastUserMessageId(messages: Message[]): string | null {

export type DisplayItem =
| { type: 'message'; key: string; message: Message }
| { type: 'parallel_agents'; key: string; agents: ACPToolCall[] };
| {
type: 'parallel_agents';
key: string;
agents: ACPToolCall[];
/**
* Wall-clock time of the first grouped launch, carried so the grouped
* box reveals its time on hover exactly like a standalone message row.
*/
timestamp?: number;
};

function isAgentOnlyToolGroup(msg: Message): boolean {
return (
Expand Down Expand Up @@ -229,6 +239,7 @@ export function groupParallelAgents(messages: Message[]): DisplayItem[] {
type: 'parallel_agents',
key: `par-${grouped[0].id}`,
agents: grouped.map((m) => (m as { tools: ACPToolCall[] }).tools[0]),
timestamp: grouped[0].timestamp,
});
i = j;
continue;
Expand All @@ -244,6 +255,7 @@ export function groupParallelAgents(messages: Message[]): DisplayItem[] {
type: 'parallel_agents',
key: `par-${grouped[0].id}`,
agents: grouped.map((m) => (m as { tools: ACPToolCall[] }).tools[0]),
timestamp: grouped[0].timestamp,
});
} else {
items.push({
Expand Down Expand Up @@ -677,11 +689,13 @@ export const MessageList = forwardRef<MessageListHandle, MessageListProps>(

if (item.type === 'parallel_agents') {
return (
<ParallelAgentsGroup
agents={item.agents}
pendingApproval={pendingApproval}
onConfirm={onConfirm}
/>
<MessageTimestamp timestamp={item.timestamp}>
<ParallelAgentsGroup
agents={item.agents}
pendingApproval={pendingApproval}
onConfirm={onConfirm}
/>
</MessageTimestamp>
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -142,3 +142,32 @@
.tools .panel {
border-left-width: 2px;
}

/*
* Per-sub-tool hover time. Deliberately a *separate* class pair from the
* message-level MessageTimestamp (.row/.tip): the Tools list nests inside a
* message that is already wrapped by MessageTimestamp, and reusing the same
* hover rule across both layers couples them. These scoped names keep the
* sub-tool tooltip independent of the surrounding message's time tooltip.
*/
.toolTimeRow {
position: relative;
}

.toolTimeRow > .toolTimeTip {
position: absolute;
top: 2px;
right: 4px;
z-index: 5;
color: var(--text-dimmed);
font-size: 11px;
line-height: 1.4;
white-space: nowrap;
pointer-events: none;
opacity: 0;
transition: opacity 0.12s ease;
}

.toolTimeRow:hover > .toolTimeTip {
opacity: 1;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest';
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { I18nProvider } from '../../../i18n';
import type { ACPToolCall } from '../../../adapters/types';
import { formatTimestamp } from '../../MessageTimestamp';

// SubAgentPanel pulls in ToolGroup, which imports App only for
// CompactModeContext; loading the real App module would drag the whole
// application graph into this unit test.
vi.mock('../../../App', async () => {
const { createContext } = await import('react');
return { CompactModeContext: createContext(false) };
});

const { SubAgentPanel } = await import('./SubAgentPanel');

(
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;

const mounted: Array<{ root: Root; container: HTMLElement }> = [];

afterEach(() => {
for (const { root, container } of mounted.splice(0)) {
act(() => root.unmount());
container.remove();
}
});

function renderPanel(tool: ACPToolCall): HTMLElement {
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
act(() => {
root.render(
<I18nProvider language="en">
<SubAgentPanel tool={tool} defaultExpanded inline hideHeader />
</I18nProvider>,
);
});
mounted.push({ root, container });
return container;
}

function makeAgentWithSubTool(subTool: ACPToolCall): ACPToolCall {
return {
callId: 'agent-1',
toolName: 'Task',
status: 'completed',
args: { description: 'demo agent' },
subTools: [subTool],
};
}

describe('SubAgentPanel sub-tool timestamps', () => {
it('renders each sub-tool start time, like the main transcript rows', () => {
// A past date so formatTimestamp always renders the dated form; the
// expectation is derived from the same formatter, so it matches
// regardless of the test machine's clock or timezone.
const startTime = new Date('2020-01-02T03:04:05').getTime();
const container = renderPanel(
makeAgentWithSubTool({
callId: 'sub-1',
toolName: 'Read',
status: 'completed',
startTime,
}),
);
expect(container.textContent).toContain(formatTimestamp(startTime));
});

it('renders a sub-tool without a start time unchanged (no time shown)', () => {
const reference = new Date('2020-01-02T03:04:05').getTime();
const container = renderPanel(
makeAgentWithSubTool({
callId: 'sub-1',
toolName: 'Read',
status: 'completed',
}),
);
expect(container.textContent).not.toContain(formatTimestamp(reference));
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { useWebShellCustomization } from '../../../customization';
// each other's exports at render time — never in top-level code.
import { ToolLine } from '../ToolGroup';
import { Markdown } from '../Markdown';
import { formatTimestamp } from '../../MessageTimestamp';
import {
formatDurationMs,
formatElapsed,
Expand Down Expand Up @@ -70,11 +71,40 @@ function isTaskExecution(raw: unknown): raw is TaskExecution {
);
}

/**
* Reveals a single sub-tool's wall-clock start time on hover in its top-right
* corner, mirroring how the main transcript surfaces each message's time —
* but via a scoped class pair (not MessageTimestamp) so the nested tooltip
* stays independent of the enclosing message's own time tooltip.
*/
function SubToolTime({
timestamp,
children,
}: {
timestamp?: number;
children: ReactNode;
}) {
if (timestamp === undefined) return <>{children}</>;
return (
<div className={styles.toolTimeRow}>
{children}
<span className={styles.toolTimeTip} aria-hidden="true">
{formatTimestamp(timestamp)}
</span>
</div>
);
}

const SubToolLine = memo(function SubToolLine({ tool }: { tool: ACPToolCall }) {
if (tool.subTools || tool.subContent) return <SubAgentPanel tool={tool} />;
// Same row as the main transcript: one-line summary, expandable to
// the full output / diff / file content where the tool has any.
return <ToolLine tool={tool} />;
const body =
tool.subTools || tool.subContent ? (
<SubAgentPanel tool={tool} />
) : (
<ToolLine tool={tool} />
);
return <SubToolTime timestamp={tool.startTime}>{body}</SubToolTime>;
});

function TaskToolCallLine({ tc }: { tc: TaskToolCall }) {
Expand Down
Loading