mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-10 01:29:17 +00:00
feat(web-shell): show message time on hover (#5079)
* fix(acp-bridge): preserve original timestamp when replaying session history History replay re-emits each persisted record with its original epoch-ms time nested in update._meta, but BridgeClient.sessionUpdate published the frame without lifting it to the envelope. EventBus.publish then stamped envelope _meta.serverTimestamp with publish-time Date.now(), which the client's extractServerTimestamp picks up at higher priority than the nested original — so a resumed session rendered every historical message at the resume moment instead of when it was sent. Lift update._meta.timestamp (or serverTimestamp) to the envelope serverTimestamp so EventBus preserves it. Live updates without such a timestamp keep the Date.now() fallback unchanged. * feat(web-shell): show each history message's time on hover Carry each transcript block's wall-clock time (serverTimestamp ?? clientReceivedAt) onto every message and reveal it as a CSS-only hover tooltip in the message list. Same-day messages show HH:mm:ss; older ones show yyyy-MM-dd HH:mm:ss (local time, zero-padded).
This commit is contained in:
parent
aebf82cd29
commit
84d01e7070
9 changed files with 434 additions and 89 deletions
|
|
@ -476,6 +476,66 @@ describe('BridgeClient — A2UI session update publishing', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('BridgeClient — original timestamp preservation', () => {
|
||||
const noPermissionFlow = () => {
|
||||
throw new Error('test: permission flow should not run');
|
||||
};
|
||||
|
||||
function makeClientFor(sessionId: string, publish: ReturnType<typeof vi.fn>) {
|
||||
const fakeEntry = { sessionId, events: { publish } };
|
||||
return new BridgeClient(
|
||||
((sid: string) => (sid === sessionId ? fakeEntry : undefined)) as never,
|
||||
noPermissionFlow as never,
|
||||
{ request: noPermissionFlow } as never,
|
||||
0,
|
||||
Infinity,
|
||||
);
|
||||
}
|
||||
|
||||
it('lifts a replayed update._meta.timestamp to the envelope serverTimestamp', async () => {
|
||||
const publish = vi.fn().mockReturnValue(true);
|
||||
const client = makeClientFor('sess:replay', publish);
|
||||
// A previous-day epoch — must survive to the envelope so EventBus does not
|
||||
// overwrite it with publish-time Date.now().
|
||||
const original = 1_700_000_000_000;
|
||||
|
||||
await client.sessionUpdate({
|
||||
sessionId: 'sess:replay',
|
||||
update: {
|
||||
sessionUpdate: 'user_message_chunk',
|
||||
content: { type: 'text', text: 'hi' },
|
||||
_meta: { timestamp: original },
|
||||
},
|
||||
} as Parameters<BridgeClient['sessionUpdate']>[0]);
|
||||
|
||||
expect(publish).toHaveBeenCalledTimes(1);
|
||||
const frame = publish.mock.calls[0][0] as {
|
||||
_meta?: { serverTimestamp?: number };
|
||||
};
|
||||
expect(frame._meta?.serverTimestamp).toBe(original);
|
||||
});
|
||||
|
||||
it('passes no envelope _meta for live updates without a timestamp', async () => {
|
||||
const publish = vi.fn().mockReturnValue(true);
|
||||
const client = makeClientFor('sess:live', publish);
|
||||
|
||||
await client.sessionUpdate({
|
||||
sessionId: 'sess:live',
|
||||
update: {
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
content: { type: 'text', text: 'yo' },
|
||||
},
|
||||
} as Parameters<BridgeClient['sessionUpdate']>[0]);
|
||||
|
||||
expect(publish).toHaveBeenCalledTimes(1);
|
||||
const frame = publish.mock.calls[0][0] as {
|
||||
_meta?: { serverTimestamp?: number };
|
||||
};
|
||||
// No envelope _meta → EventBus.publish applies its own Date.now() fallback.
|
||||
expect(frame._meta).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Wenshao review #4335 / 3271978365 — `requestPermission`'s pre-publish
|
||||
* `CancelSentinelCollisionError` guard prevents an orphan SSE
|
||||
|
|
|
|||
|
|
@ -431,10 +431,27 @@ export class BridgeClient implements Client {
|
|||
}
|
||||
params = a2ui.sanitizedParams;
|
||||
}
|
||||
// History replay re-emits each persisted record carrying its ORIGINAL
|
||||
// wall-clock time as an epoch-ms `timestamp` nested in `update._meta` (set
|
||||
// by the message/tool emitters). Lift it to the envelope-level
|
||||
// `serverTimestamp` so `EventBus.publish` preserves it instead of stamping
|
||||
// publish-time `Date.now()` — otherwise a resumed session renders every
|
||||
// historical message at the resume moment instead of when it was sent.
|
||||
// Live updates without such a timestamp pass no envelope `_meta` and keep
|
||||
// the EventBus `Date.now()` fallback unchanged.
|
||||
const updateMeta = (params.update as { _meta?: Record<string, unknown> })
|
||||
._meta;
|
||||
const originalTs =
|
||||
updateMeta?.['serverTimestamp'] ?? updateMeta?.['timestamp'];
|
||||
const serverTimestamp =
|
||||
typeof originalTs === 'number' && Number.isFinite(originalTs)
|
||||
? originalTs
|
||||
: undefined;
|
||||
events.publish({
|
||||
type: 'session_update',
|
||||
data: params,
|
||||
...originator,
|
||||
...(serverTimestamp !== undefined ? { _meta: { serverTimestamp } } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -60,14 +60,28 @@ export interface DaemonMessageTodoItem {
|
|||
priority?: 'high' | 'medium' | 'low';
|
||||
}
|
||||
|
||||
export interface DaemonUserMessage {
|
||||
/**
|
||||
* Fields shared by every history message. Kept as a base interface so a new
|
||||
* cross-cutting field is declared once rather than on each role.
|
||||
*/
|
||||
export interface DaemonMessageMeta {
|
||||
/**
|
||||
* Wall-clock epoch milliseconds when the backing transcript block was first
|
||||
* observed, populated from `serverTimestamp ?? clientReceivedAt`. Surfaced
|
||||
* as a hover tooltip in the message list. Undefined for synthetic messages
|
||||
* that have no backing block.
|
||||
*/
|
||||
timestamp?: number;
|
||||
}
|
||||
|
||||
export interface DaemonUserMessage extends DaemonMessageMeta {
|
||||
id: string;
|
||||
role: 'user';
|
||||
content: string;
|
||||
images?: Array<{ data: string; mimeType: string }>;
|
||||
}
|
||||
|
||||
export interface DaemonAssistantMessage {
|
||||
export interface DaemonAssistantMessage extends DaemonMessageMeta {
|
||||
id: string;
|
||||
role: 'assistant';
|
||||
content: string;
|
||||
|
|
@ -75,19 +89,19 @@ export interface DaemonAssistantMessage {
|
|||
isStreaming?: boolean;
|
||||
}
|
||||
|
||||
export interface DaemonToolGroupMessage {
|
||||
export interface DaemonToolGroupMessage extends DaemonMessageMeta {
|
||||
id: string;
|
||||
role: 'tool_group';
|
||||
tools: DaemonMessageToolCall[];
|
||||
}
|
||||
|
||||
export interface DaemonPlanMessage {
|
||||
export interface DaemonPlanMessage extends DaemonMessageMeta {
|
||||
id: string;
|
||||
role: 'plan';
|
||||
todos: DaemonMessageTodoItem[];
|
||||
}
|
||||
|
||||
export interface DaemonSystemMessage {
|
||||
export interface DaemonSystemMessage extends DaemonMessageMeta {
|
||||
id: string;
|
||||
role: 'system';
|
||||
content: string;
|
||||
|
|
@ -95,7 +109,7 @@ export interface DaemonSystemMessage {
|
|||
retryable?: boolean;
|
||||
}
|
||||
|
||||
export interface DaemonUserShellMessage {
|
||||
export interface DaemonUserShellMessage extends DaemonMessageMeta {
|
||||
id: string;
|
||||
role: 'user_shell';
|
||||
command: string;
|
||||
|
|
@ -103,7 +117,7 @@ export interface DaemonUserShellMessage {
|
|||
cwd?: string;
|
||||
}
|
||||
|
||||
export interface DaemonBtwMessage {
|
||||
export interface DaemonBtwMessage extends DaemonMessageMeta {
|
||||
id: string;
|
||||
role: 'btw';
|
||||
question: string;
|
||||
|
|
@ -111,7 +125,7 @@ export interface DaemonBtwMessage {
|
|||
isPending: boolean;
|
||||
}
|
||||
|
||||
export interface DaemonInsightProgressMessage {
|
||||
export interface DaemonInsightProgressMessage extends DaemonMessageMeta {
|
||||
id: string;
|
||||
role: 'insight_progress';
|
||||
stage: string;
|
||||
|
|
@ -119,13 +133,13 @@ export interface DaemonInsightProgressMessage {
|
|||
detail?: string;
|
||||
}
|
||||
|
||||
export interface DaemonInsightReadyMessage {
|
||||
export interface DaemonInsightReadyMessage extends DaemonMessageMeta {
|
||||
id: string;
|
||||
role: 'insight_ready';
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface DaemonInsightErrorMessage {
|
||||
export interface DaemonInsightErrorMessage extends DaemonMessageMeta {
|
||||
id: string;
|
||||
role: 'insight_error';
|
||||
error: string;
|
||||
|
|
|
|||
|
|
@ -148,6 +148,7 @@ describe('transcriptBlocksToDaemonMessages', () => {
|
|||
{
|
||||
id: 'plan-1',
|
||||
role: 'plan',
|
||||
timestamp: 1,
|
||||
todos: [
|
||||
{
|
||||
id: 'plan-0',
|
||||
|
|
@ -183,6 +184,7 @@ describe('transcriptBlocksToDaemonMessages', () => {
|
|||
{
|
||||
id: 'plan-1',
|
||||
role: 'plan',
|
||||
timestamp: 1,
|
||||
todos: [
|
||||
{
|
||||
id: 'plan-1',
|
||||
|
|
@ -231,6 +233,7 @@ describe('transcriptBlocksToDaemonMessages', () => {
|
|||
{
|
||||
id: 'tg-todo-1',
|
||||
role: 'tool_group',
|
||||
timestamp: 1,
|
||||
tools: [
|
||||
expect.objectContaining({
|
||||
callId: 'todo-call-1',
|
||||
|
|
@ -255,6 +258,7 @@ describe('transcriptBlocksToDaemonMessages', () => {
|
|||
{
|
||||
id: 'tg-t1',
|
||||
role: 'tool_group',
|
||||
timestamp: 1,
|
||||
tools: [
|
||||
expect.objectContaining({ callId: 'tc1', toolName: 'Read' }),
|
||||
expect.objectContaining({ callId: 'tc2', toolName: 'Grep' }),
|
||||
|
|
@ -378,6 +382,7 @@ describe('transcriptBlocksToDaemonMessages', () => {
|
|||
{
|
||||
id: 'insight-1-ip',
|
||||
role: 'insight_progress',
|
||||
timestamp: 1,
|
||||
stage: 'scan',
|
||||
progress: 0.5,
|
||||
detail: 'reading',
|
||||
|
|
@ -396,11 +401,36 @@ describe('transcriptBlocksToDaemonMessages', () => {
|
|||
]);
|
||||
|
||||
expect(messages).toEqual([
|
||||
{ id: 'insight-1-t-0', role: 'assistant', content: 'before' },
|
||||
{ id: 'insight-1-ir-0', role: 'insight_ready', path: '/tmp/report.md' },
|
||||
{ id: 'insight-1-t-2', role: 'assistant', content: 'middle' },
|
||||
{ id: 'insight-1-ie-0', role: 'insight_error', error: 'boom' },
|
||||
{ id: 'insight-1-t-4', role: 'assistant', content: 'after' },
|
||||
{
|
||||
id: 'insight-1-t-0',
|
||||
role: 'assistant',
|
||||
content: 'before',
|
||||
timestamp: 1,
|
||||
},
|
||||
{
|
||||
id: 'insight-1-ir-0',
|
||||
role: 'insight_ready',
|
||||
path: '/tmp/report.md',
|
||||
timestamp: 1,
|
||||
},
|
||||
{
|
||||
id: 'insight-1-t-2',
|
||||
role: 'assistant',
|
||||
content: 'middle',
|
||||
timestamp: 1,
|
||||
},
|
||||
{
|
||||
id: 'insight-1-ie-0',
|
||||
role: 'insight_error',
|
||||
error: 'boom',
|
||||
timestamp: 1,
|
||||
},
|
||||
{
|
||||
id: 'insight-1-t-4',
|
||||
role: 'assistant',
|
||||
content: 'after',
|
||||
timestamp: 1,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
@ -813,6 +843,7 @@ describe('transcriptBlocksToDaemonMessages', () => {
|
|||
role: 'assistant',
|
||||
content: 'hello world',
|
||||
isStreaming: false,
|
||||
timestamp: 1,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
|
@ -1876,6 +1907,7 @@ describe('transcriptBlocksToDaemonMessages', () => {
|
|||
content: 'Connection lost',
|
||||
variant: 'error',
|
||||
retryable: false,
|
||||
timestamp: 1,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
|
@ -1900,6 +1932,7 @@ describe('transcriptBlocksToDaemonMessages', () => {
|
|||
content: 'Request failed',
|
||||
variant: 'error',
|
||||
retryable: true,
|
||||
timestamp: 1,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
|
@ -1922,6 +1955,7 @@ describe('transcriptBlocksToDaemonMessages', () => {
|
|||
role: 'system',
|
||||
content: 'Session initialized',
|
||||
variant: 'info',
|
||||
timestamp: 1,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
|
@ -1938,6 +1972,7 @@ describe('transcriptBlocksToDaemonMessages', () => {
|
|||
content: '',
|
||||
thinking: 'let me think about this',
|
||||
isStreaming: false,
|
||||
timestamp: 1,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
|
@ -2008,9 +2043,21 @@ describe('transcriptBlocksToDaemonMessages', () => {
|
|||
]);
|
||||
|
||||
expect(messages).toEqual([
|
||||
{ id: 'a1', role: 'assistant', content: 'first', isStreaming: false },
|
||||
{ id: 'u1', role: 'user', content: 'question' },
|
||||
{ id: 'a2', role: 'assistant', content: 'second', isStreaming: false },
|
||||
{
|
||||
id: 'a1',
|
||||
role: 'assistant',
|
||||
content: 'first',
|
||||
isStreaming: false,
|
||||
timestamp: 1,
|
||||
},
|
||||
{ id: 'u1', role: 'user', content: 'question', timestamp: 2 },
|
||||
{
|
||||
id: 'a2',
|
||||
role: 'assistant',
|
||||
content: 'second',
|
||||
isStreaming: false,
|
||||
timestamp: 3,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
@ -2027,6 +2074,7 @@ describe('transcriptBlocksToDaemonMessages', () => {
|
|||
content: 'here is my answer',
|
||||
thinking: 'analyzing...',
|
||||
isStreaming: false,
|
||||
timestamp: 1,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
|
@ -2074,6 +2122,7 @@ describe('transcriptBlocksToDaemonMessages', () => {
|
|||
role: 'system',
|
||||
content: 'Request cancelled.',
|
||||
variant: 'info',
|
||||
timestamp: 20,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
|
@ -2090,6 +2139,7 @@ describe('transcriptBlocksToDaemonMessages', () => {
|
|||
role: 'system',
|
||||
content: '请求已取消。',
|
||||
variant: 'info',
|
||||
timestamp: 20,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -85,6 +85,10 @@ export function transcriptBlocksToDaemonMessages(
|
|||
|
||||
for (let i = 0; i < blocks.length; i++) {
|
||||
const block = blocks[i];
|
||||
// Wall-clock of this block, surfaced as a hover tooltip on the rendered
|
||||
// message. Prefer the daemon-authoritative stamp so every client agrees;
|
||||
// fall back to the local receive time when the daemon left it unset.
|
||||
const blockTime = block.serverTimestamp ?? block.clientReceivedAt;
|
||||
|
||||
switch (block.kind) {
|
||||
case 'user': {
|
||||
|
|
@ -95,6 +99,7 @@ export function transcriptBlocksToDaemonMessages(
|
|||
id: block.id,
|
||||
role: 'user',
|
||||
content: textBlock.text,
|
||||
timestamp: blockTime,
|
||||
};
|
||||
// Attach images if present
|
||||
if (textBlock.images && textBlock.images.length > 0) {
|
||||
|
|
@ -134,6 +139,7 @@ export function transcriptBlocksToDaemonMessages(
|
|||
id: `${block.id}-ir-${readyCount++}`,
|
||||
role: 'insight_ready',
|
||||
path: seg.data.path,
|
||||
timestamp: blockTime,
|
||||
});
|
||||
} else if (seg.data.type === 'insight_error') {
|
||||
hasTerminal = true;
|
||||
|
|
@ -141,6 +147,7 @@ export function transcriptBlocksToDaemonMessages(
|
|||
id: `${block.id}-ie-${errorCount++}`,
|
||||
role: 'insight_error',
|
||||
error: seg.data.error,
|
||||
timestamp: blockTime,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
|
|
@ -148,6 +155,7 @@ export function transcriptBlocksToDaemonMessages(
|
|||
id: `${block.id}-t-${messages.length}`,
|
||||
role: 'assistant',
|
||||
content: seg.text,
|
||||
timestamp: blockTime,
|
||||
});
|
||||
currentAssistantIdx = messages.length - 1;
|
||||
}
|
||||
|
|
@ -159,6 +167,7 @@ export function transcriptBlocksToDaemonMessages(
|
|||
stage: lastProgress.stage,
|
||||
progress: lastProgress.progress,
|
||||
detail: lastProgress.detail,
|
||||
timestamp: blockTime,
|
||||
});
|
||||
}
|
||||
needsNewContentMessage = true;
|
||||
|
|
@ -182,6 +191,7 @@ export function transcriptBlocksToDaemonMessages(
|
|||
role: 'assistant',
|
||||
content: textBlock.text,
|
||||
isStreaming: textBlock.streaming,
|
||||
timestamp: blockTime,
|
||||
});
|
||||
currentAssistantIdx = messages.length - 1;
|
||||
needsNewContentMessage = false;
|
||||
|
|
@ -221,6 +231,7 @@ export function transcriptBlocksToDaemonMessages(
|
|||
content: '',
|
||||
thinking: textBlock.text,
|
||||
isStreaming: textBlock.streaming,
|
||||
timestamp: blockTime,
|
||||
});
|
||||
currentAssistantIdx = messages.length - 1;
|
||||
needsNewContentMessage = false;
|
||||
|
|
@ -254,7 +265,7 @@ export function transcriptBlocksToDaemonMessages(
|
|||
break;
|
||||
}
|
||||
|
||||
appendToolCallMessage(messages, block.id, toolCall);
|
||||
appendToolCallMessage(messages, block.id, toolCall, blockTime);
|
||||
toolsByCallId.set(toolCall.callId, toolCall);
|
||||
needsNewContentMessage = true;
|
||||
break;
|
||||
|
|
@ -300,6 +311,7 @@ export function transcriptBlocksToDaemonMessages(
|
|||
rawOutput: shellBlock.text,
|
||||
},
|
||||
],
|
||||
timestamp: blockTime,
|
||||
});
|
||||
needsNewContentMessage = true;
|
||||
}
|
||||
|
|
@ -314,6 +326,7 @@ export function transcriptBlocksToDaemonMessages(
|
|||
command: shellBlock.command,
|
||||
output: shellBlock.text,
|
||||
...(shellBlock.cwd ? { cwd: shellBlock.cwd } : {}),
|
||||
timestamp: blockTime,
|
||||
});
|
||||
needsNewContentMessage = true;
|
||||
break;
|
||||
|
|
@ -367,13 +380,23 @@ export function transcriptBlocksToDaemonMessages(
|
|||
if (!isSubAgentPermission) {
|
||||
permissionToolCall.status = 'in_progress';
|
||||
}
|
||||
appendToolCallMessage(messages, block.id, permissionToolCall);
|
||||
appendToolCallMessage(
|
||||
messages,
|
||||
block.id,
|
||||
permissionToolCall,
|
||||
blockTime,
|
||||
);
|
||||
toolsByCallId.set(permissionToolCall.callId, permissionToolCall);
|
||||
needsNewContentMessage = true;
|
||||
} else {
|
||||
permissionToolCall.status = 'failed';
|
||||
permissionToolCall.endTime = permBlock.updatedAt;
|
||||
appendToolCallMessage(messages, block.id, permissionToolCall);
|
||||
appendToolCallMessage(
|
||||
messages,
|
||||
block.id,
|
||||
permissionToolCall,
|
||||
blockTime,
|
||||
);
|
||||
toolsByCallId.set(permissionToolCall.callId, permissionToolCall);
|
||||
needsNewContentMessage = true;
|
||||
}
|
||||
|
|
@ -392,6 +415,7 @@ export function transcriptBlocksToDaemonMessages(
|
|||
id: block.id,
|
||||
role: 'plan',
|
||||
todos,
|
||||
timestamp: blockTime,
|
||||
});
|
||||
needsNewContentMessage = true;
|
||||
break;
|
||||
|
|
@ -405,6 +429,7 @@ export function transcriptBlocksToDaemonMessages(
|
|||
role: 'system',
|
||||
content: text,
|
||||
variant: 'info',
|
||||
timestamp: blockTime,
|
||||
});
|
||||
needsNewContentMessage = true;
|
||||
break;
|
||||
|
|
@ -418,6 +443,7 @@ export function transcriptBlocksToDaemonMessages(
|
|||
content: errorBlock.text,
|
||||
variant: 'error',
|
||||
retryable: errorBlock.source === 'turn_error',
|
||||
timestamp: blockTime,
|
||||
});
|
||||
needsNewContentMessage = true;
|
||||
break;
|
||||
|
|
@ -429,6 +455,7 @@ export function transcriptBlocksToDaemonMessages(
|
|||
role: 'system',
|
||||
content: promptCancelledText,
|
||||
variant: 'info',
|
||||
timestamp: blockTime,
|
||||
});
|
||||
needsNewContentMessage = true;
|
||||
break;
|
||||
|
|
@ -457,6 +484,7 @@ function appendToolCallMessage(
|
|||
messages: DaemonMessage[],
|
||||
blockId: string,
|
||||
toolCall: DaemonMessageToolCall,
|
||||
timestamp?: number,
|
||||
): void {
|
||||
// Native CLI groups every tool call of one scheduler batch into a single
|
||||
// bordered tool_group (mapToDisplay in useReactToolScheduler). The daemon
|
||||
|
|
@ -485,6 +513,7 @@ function appendToolCallMessage(
|
|||
id: `tg-${blockId}`,
|
||||
role: 'tool_group',
|
||||
tools: [toolCall],
|
||||
timestamp,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import { memo } from 'react';
|
||||
import { memo, type ReactElement } from 'react';
|
||||
import type {
|
||||
ACPToolCall,
|
||||
Message,
|
||||
PermissionRequest,
|
||||
TodoItem,
|
||||
} from '../adapters/types';
|
||||
import { MessageTimestamp } from './MessageTimestamp';
|
||||
import { UserMessage } from './messages/UserMessage';
|
||||
import { AssistantMessage } from './messages/AssistantMessage';
|
||||
import { SystemMessage } from './messages/SystemMessage';
|
||||
|
|
@ -43,73 +44,83 @@ export const MessageItem = memo(function MessageItem({
|
|||
onRetryClick,
|
||||
shellOutputMaxLines,
|
||||
}: MessageItemProps) {
|
||||
switch (message.role) {
|
||||
case 'user':
|
||||
return <UserMessage content={message.content} images={message.images} />;
|
||||
case 'assistant':
|
||||
return (
|
||||
<AssistantMessage
|
||||
content={message.content}
|
||||
thinking={message.thinking}
|
||||
isStreaming={message.isStreaming}
|
||||
/>
|
||||
);
|
||||
case 'tool_group':
|
||||
return (
|
||||
<ToolGroup
|
||||
tools={message.tools}
|
||||
pendingApproval={pendingApproval}
|
||||
onConfirm={onConfirm}
|
||||
workspaceCwd={workspaceCwd}
|
||||
shellOutputMaxLines={shellOutputMaxLines}
|
||||
/>
|
||||
);
|
||||
case 'plan':
|
||||
return <PlanMessage todos={message.todos} />;
|
||||
case 'system':
|
||||
return (
|
||||
<SystemMessage
|
||||
content={message.content}
|
||||
variant={message.variant}
|
||||
onShowContextDetail={onShowContextDetail}
|
||||
isLatest={isLatest}
|
||||
showRetryHint={showRetryHint && message.retryable === true}
|
||||
onRetryClick={onRetryClick}
|
||||
/>
|
||||
);
|
||||
case 'user_shell':
|
||||
return (
|
||||
<UserShellMessage command={message.command} output={message.output} />
|
||||
);
|
||||
case 'btw':
|
||||
return (
|
||||
<BtwMessage
|
||||
question={message.question}
|
||||
answer={message.answer}
|
||||
isPending={message.isPending}
|
||||
/>
|
||||
);
|
||||
case 'insight_progress':
|
||||
return (
|
||||
<InsightProgress
|
||||
progress={{
|
||||
stage: message.stage,
|
||||
progress: message.progress,
|
||||
detail: message.detail,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
case 'insight_ready':
|
||||
return <InsightReady path={message.path} />;
|
||||
case 'insight_error':
|
||||
return (
|
||||
<div style={{ color: 'var(--error-color, #e06c75)' }}>
|
||||
{message.error}
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
const body = ((): ReactElement | null => {
|
||||
switch (message.role) {
|
||||
case 'user':
|
||||
return (
|
||||
<UserMessage content={message.content} images={message.images} />
|
||||
);
|
||||
case 'assistant':
|
||||
return (
|
||||
<AssistantMessage
|
||||
content={message.content}
|
||||
thinking={message.thinking}
|
||||
isStreaming={message.isStreaming}
|
||||
/>
|
||||
);
|
||||
case 'tool_group':
|
||||
return (
|
||||
<ToolGroup
|
||||
tools={message.tools}
|
||||
pendingApproval={pendingApproval}
|
||||
onConfirm={onConfirm}
|
||||
workspaceCwd={workspaceCwd}
|
||||
shellOutputMaxLines={shellOutputMaxLines}
|
||||
/>
|
||||
);
|
||||
case 'plan':
|
||||
return <PlanMessage todos={message.todos} />;
|
||||
case 'system':
|
||||
return (
|
||||
<SystemMessage
|
||||
content={message.content}
|
||||
variant={message.variant}
|
||||
onShowContextDetail={onShowContextDetail}
|
||||
isLatest={isLatest}
|
||||
showRetryHint={showRetryHint && message.retryable === true}
|
||||
onRetryClick={onRetryClick}
|
||||
/>
|
||||
);
|
||||
case 'user_shell':
|
||||
return (
|
||||
<UserShellMessage command={message.command} output={message.output} />
|
||||
);
|
||||
case 'btw':
|
||||
return (
|
||||
<BtwMessage
|
||||
question={message.question}
|
||||
answer={message.answer}
|
||||
isPending={message.isPending}
|
||||
/>
|
||||
);
|
||||
case 'insight_progress':
|
||||
return (
|
||||
<InsightProgress
|
||||
progress={{
|
||||
stage: message.stage,
|
||||
progress: message.progress,
|
||||
detail: message.detail,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
case 'insight_ready':
|
||||
return <InsightReady path={message.path} />;
|
||||
case 'insight_error':
|
||||
return (
|
||||
<div style={{ color: 'var(--error-color, #e06c75)' }}>
|
||||
{message.error}
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
if (body === null) return null;
|
||||
|
||||
return (
|
||||
<MessageTimestamp timestamp={message.timestamp}>{body}</MessageTimestamp>
|
||||
);
|
||||
}, areMessageItemPropsEqual);
|
||||
|
||||
function areMessageItemPropsEqual(
|
||||
|
|
@ -130,6 +141,7 @@ function areMessageItemPropsEqual(
|
|||
function areMessagesEqual(prev: Message, next: Message): boolean {
|
||||
if (prev === next) return true;
|
||||
if (prev.id !== next.id || prev.role !== next.role) return false;
|
||||
if (prev.timestamp !== next.timestamp) return false;
|
||||
switch (prev.role) {
|
||||
case 'user':
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
.row {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/*
|
||||
* Anchored inside the message's top-right corner rather than floating above
|
||||
* it: the message list (`.list`) is `overflow-y: auto`, so a tooltip spilling
|
||||
* outside the row box would be clipped or trigger a scrollbar. Staying within
|
||||
* the row keeps it visible at the scroll edges. `pointer-events: none` lets
|
||||
* clicks and inner hover interactions (tool expand, links) pass through.
|
||||
*/
|
||||
.tip {
|
||||
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;
|
||||
}
|
||||
|
||||
.row:hover > .tip {
|
||||
opacity: 1;
|
||||
}
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { act, type ReactNode } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { MessageTimestamp, formatTimestamp } from './MessageTimestamp';
|
||||
|
||||
(
|
||||
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 render(node: ReactNode): HTMLElement {
|
||||
const container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
act(() => root.render(node));
|
||||
mounted.push({ root, container });
|
||||
return container;
|
||||
}
|
||||
|
||||
describe('formatTimestamp', () => {
|
||||
// Built from local-time parts so expectations are timezone independent
|
||||
// (month is 0-based: 5 = June).
|
||||
const now = new Date(2026, 5, 13, 12, 0, 0);
|
||||
|
||||
it('shows only HH:mm:ss for a same-day timestamp', () => {
|
||||
const ts = new Date(2026, 5, 13, 9, 8, 7).getTime();
|
||||
expect(formatTimestamp(ts, now)).toBe('09:08:07');
|
||||
});
|
||||
|
||||
it('shows full yyyy-MM-dd HH:mm:ss for an earlier day in the same year', () => {
|
||||
const ts = new Date(2026, 0, 2, 9, 8, 7).getTime();
|
||||
expect(formatTimestamp(ts, now)).toBe('2026-01-02 09:08:07');
|
||||
});
|
||||
|
||||
it('shows full yyyy-MM-dd HH:mm:ss for a previous year', () => {
|
||||
// Same month/day as `now` but last year — must not be read as "today".
|
||||
const ts = new Date(2025, 5, 13, 9, 8, 7).getTime();
|
||||
expect(formatTimestamp(ts, now)).toBe('2025-06-13 09:08:07');
|
||||
});
|
||||
});
|
||||
|
||||
describe('MessageTimestamp', () => {
|
||||
it('reveals the wall-clock time as a hover tooltip when a timestamp is set', () => {
|
||||
const ts = new Date(2026, 5, 13, 9, 8, 7).getTime();
|
||||
const container = render(
|
||||
<MessageTimestamp timestamp={ts}>
|
||||
<div>body</div>
|
||||
</MessageTimestamp>,
|
||||
);
|
||||
|
||||
const tip = container.querySelector('span[aria-hidden="true"]');
|
||||
expect(tip).not.toBeNull();
|
||||
// Every variant ends in HH:mm:ss; the leading parts depend on the real
|
||||
// "now", so assert the shape rather than an exact string here.
|
||||
expect(tip?.textContent).toMatch(/\d{2}:\d{2}:\d{2}$/);
|
||||
expect(container.textContent).toContain('body');
|
||||
});
|
||||
|
||||
it('renders children unchanged with no tooltip when timestamp is undefined', () => {
|
||||
const container = render(
|
||||
<MessageTimestamp>
|
||||
<div data-testid="child">body</div>
|
||||
</MessageTimestamp>,
|
||||
);
|
||||
|
||||
expect(container.querySelector('span[aria-hidden="true"]')).toBeNull();
|
||||
// No wrapper element is introduced: the child stays a direct child of the
|
||||
// mount container, so message spacing/structure is untouched.
|
||||
const child = container.querySelector('[data-testid="child"]');
|
||||
expect(child).not.toBeNull();
|
||||
expect(child?.parentElement).toBe(container);
|
||||
});
|
||||
});
|
||||
53
packages/web-shell/client/components/MessageTimestamp.tsx
Normal file
53
packages/web-shell/client/components/MessageTimestamp.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import type { ReactNode } from 'react';
|
||||
import styles from './MessageTimestamp.module.css';
|
||||
|
||||
interface MessageTimestampProps {
|
||||
/** Wall-clock epoch ms of the message; omitted for synthetic messages. */
|
||||
timestamp?: number;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a rendered history message and reveals its wall-clock time as a
|
||||
* CSS-only tooltip on hover. When the message carries no timestamp the
|
||||
* children render unchanged, so no empty wrapper is introduced.
|
||||
*/
|
||||
export function MessageTimestamp({
|
||||
timestamp,
|
||||
children,
|
||||
}: MessageTimestampProps) {
|
||||
if (timestamp === undefined) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
return (
|
||||
<div className={styles.row}>
|
||||
{children}
|
||||
<span className={styles.tip} aria-hidden="true">
|
||||
{formatTimestamp(timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Local-time clock, dropping the date only for same-day timestamps:
|
||||
* - same day → `HH:mm:ss`
|
||||
* - earlier → `yyyy-MM-dd HH:mm:ss`
|
||||
*
|
||||
* Fixed order and zero-padded (unlike toLocaleString) so stacked timestamps
|
||||
* align. `now` is injectable so the branch logic is unit-testable without
|
||||
* depending on the wall clock.
|
||||
*/
|
||||
export function formatTimestamp(ts: number, now: Date = new Date()): string {
|
||||
const d = new Date(ts);
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
const hms = `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
||||
const sameDay =
|
||||
d.getFullYear() === now.getFullYear() &&
|
||||
d.getMonth() === now.getMonth() &&
|
||||
d.getDate() === now.getDate();
|
||||
if (sameDay) {
|
||||
return hms;
|
||||
}
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${hms}`;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue