feat: show message timestamps (#1609)

* feat: show message timestamps

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>

* fix: suppress fallback message timestamps

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>

* feat: stable message + tool-call hover timestamps

Stamp a stable client-side arrival time per message and tool call (keyed
by id, persisted to localStorage). Messages render the timestamp inline;
tool rows reveal a dim timestamp chip on hover. Real backend created_at
still takes precedence when present.

* fix: hide client-stamped message timestamps

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>

---------

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
This commit is contained in:
Johannes du Plessis 2026-06-25 13:20:36 -07:00 • committed by GitHub
parent 009064011a
commit 48bf712b77
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 256 additions and 20 deletions

View file

@ -14,6 +14,7 @@ import {
import { AgentPromptBar } from "@/components/agents/AgentPromptBar"
import { Messages } from "@/components/agents/messages"
import { streamMessagesToUi } from "@/lib/agents/streamMessagesToUi"
import { messageArrivalTimestamp } from "@/lib/agents/messageTimestamps"
import { useSubmitAgentMessage } from "@/lib/agents/provider/useSubmitAgentMessage"
import { useModelOptions } from "@/lib/agents/provider/useModelOptions"
import { useIsMobile } from "@/lib/useIsMobile"
@ -58,7 +59,8 @@ export function AgentThreadView({ thread }: AgentThreadViewProps) {
const live = streamMessagesToUi(
stream.messages,
stream.toolCalls,
stream.subagents
stream.subagents,
messageArrivalTimestamp
)
if (live.length > 0) return live
// Optimistic transcript seeded by `AgentsHome` on thread creation (the

View file

@ -1,14 +1,17 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { ChunkRenderer } from "./ChunkRenderer";
import { MessageTimestamp } from "./MessageTimestamp";
import { ReasoningBlock } from "./ReasoningBlock";
import { buildRenderItems, summarizeExploration } from "./renderItems";
import { summarizeChangedFiles } from "./summarizeChangedFiles";
import { TurnChangedFilesCard } from "./TurnChangedFilesCard";
import { WorkSummary } from "./WorkSummary";
import type { ReactNode } from "react";
import type { RenderItem } from "./renderItems";
import type { Message } from "@/lib/agents/types";
import type { ApprovalCallbacks, ChangedFileSummaryItem } from "./types";
import { formatHoverTimestamp } from "@/lib/agents/messageTimestamps";
import { SubagentGroup } from "@/components/agents/subagents";
import { ToolExecution } from "@/components/agents/ported/ToolExecution";
import { ShellCommand } from "@/components/agents/ported/ShellCommand";
@ -20,6 +23,32 @@ import { ReplyCard } from "@/components/agents/ported/ReplyCard";
*/
const REPLY_ITEM_TYPES = new Set<RenderItem["type"]>(["text-chunk", "reply-item"]);
/**
* Wraps a tool render item so a dim timestamp chip fades in on hover, anchored
* to the row's top-right corner.
*/
function ToolRow({
timestamp,
className,
children,
}: {
timestamp?: string;
className?: string;
children: ReactNode;
}) {
const label = formatHoverTimestamp(timestamp);
return (
<div className={`group relative ${className ?? ""}`}>
{children}
{label && (
<time className="pointer-events-none absolute right-1 top-1 z-10 rounded bg-[var(--ui-panel)] px-1.5 py-0.5 text-[10px] leading-4 text-[color:var(--ui-text-dim)] tabular-nums opacity-0 shadow-sm transition-opacity group-hover:opacity-100">
{label}
</time>
)}
</div>
);
}
/**
* Split a finished turn's items into collapsible work and the trailing reply,
* where the reply is the maximal suffix made up solely of reply/text items.
@ -138,13 +167,18 @@ export function AgentMessage({
const workDurationMs = useMemo(() => {
if (measuredDurationMs !== null) return measuredDurationMs;
if (!message.startedAt) return null;
if (!message.startedAt || message.timestampIsFallback) return null;
const start = Date.parse(message.startedAt);
const end = Date.parse(message.timestamp);
if (!Number.isFinite(start) || !Number.isFinite(end)) return null;
const delta = end - start;
return delta > 0 ? delta : null;
}, [measuredDurationMs, message.startedAt, message.timestamp]);
}, [
measuredDurationMs,
message.startedAt,
message.timestamp,
message.timestampIsFallback,
]);
const { workItems, replyItems } = useMemo(() => splitWorkAndReply(renderItems), [renderItems]);
const collapseWork = !isStreaming && workItems.length > 0;
@ -185,13 +219,17 @@ export function AgentMessage({
{isExpanded && (
<div className="pt-1 pb-1 space-y-0.5">
{item.chunks.map((chunk, chunkIndex) => (
<div key={chunk.toolCallId || `explored-chunk-${item.id}-${chunkIndex}`} className="flex-1 min-w-0 text-[color:var(--ui-text-dim)]">
<ToolRow
key={chunk.toolCallId || `explored-chunk-${item.id}-${chunkIndex}`}
timestamp={chunk.timestamp}
className="flex-1 min-w-0 text-[color:var(--ui-text-dim)]"
>
<ToolExecution
chunk={chunk}
projectPath={projectPath}
onOpenDiff={callbacks.onOpenDiff}
/>
</div>
</ToolRow>
))}
</div>
)}
@ -208,7 +246,7 @@ export function AgentMessage({
: undefined;
return (
<div key={item.key}>
<ToolRow key={item.key} timestamp={item.chunk.timestamp}>
<ToolExecution
chunk={item.chunk}
projectPath={projectPath}
@ -221,30 +259,30 @@ export function AgentMessage({
modifiedContent: fullFileDiff.modifiedContent,
} : undefined}
/>
</div>
</ToolRow>
);
}
case "shell-item":
return (
<div key={item.key}>
<ToolRow key={item.key} timestamp={item.chunk.timestamp}>
<ShellCommand
chunk={item.chunk}
projectPath={projectPath}
/>
</div>
</ToolRow>
);
case "reply-item":
return (
<div key={item.key}>
<ToolRow key={item.key} timestamp={item.chunk.timestamp}>
<ReplyCard chunk={item.chunk} />
</div>
</ToolRow>
);
case "tool-item":
return (
<div key={item.key}>
<ToolRow key={item.key} timestamp={item.chunk.timestamp}>
<ToolExecution
chunk={item.chunk}
projectPath={projectPath}
@ -253,7 +291,7 @@ export function AgentMessage({
onAutoApprove={callbacks.onAutoApprove}
onOpenDiff={callbacks.onOpenDiff}
/>
</div>
</ToolRow>
);
case "text-chunk":
@ -292,6 +330,14 @@ export function AgentMessage({
projectPath={projectPath}
/>
)}
{!message.timestampIsFallback && (
<MessageTimestamp
timestamp={message.timestamp}
startedAt={message.startedAt}
className="mt-1"
/>
)}
</div>
);
}

View file

@ -0,0 +1,75 @@
type MessageTimestampProps = {
timestamp: string;
startedAt?: string;
align?: "left" | "right";
className?: string;
};
const timeFormatter = new Intl.DateTimeFormat(undefined, {
hour: "numeric",
minute: "2-digit",
second: "2-digit",
});
const datedTimeFormatter = new Intl.DateTimeFormat(undefined, {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
second: "2-digit",
});
const fullFormatter = new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "medium",
});
function parseTimestamp(value?: string | null): Date | null {
if (!value) return null;
const date = new Date(value);
return Number.isNaN(date.getTime()) ? null : date;
}
function isSameLocalDay(a: Date, b: Date): boolean {
return (
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate()
);
}
function shortTimestamp(date: Date): string {
return isSameLocalDay(date, new Date())
? timeFormatter.format(date)
: datedTimeFormatter.format(date);
}
export function MessageTimestamp({
timestamp,
startedAt,
align = "left",
className = "",
}: MessageTimestampProps) {
const date = parseTimestamp(timestamp);
if (!date) return null;
const startDate = parseTimestamp(startedAt);
const title =
startDate && Math.abs(date.getTime() - startDate.getTime()) >= 1000
? `Started ${fullFormatter.format(startDate)} · Last updated ${fullFormatter.format(date)}`
: fullFormatter.format(date);
return (
<div
className={`flex ${align === "right" ? "justify-end" : "justify-start"} ${className}`}
>
<time
dateTime={date.toISOString()}
title={title}
className="text-[11px] leading-4 text-[color:var(--ui-text-dim)] tabular-nums select-none"
>
{shortTimestamp(date)}
</time>
</div>
);
}

View file

@ -1,5 +1,6 @@
import { useCallback, useLayoutEffect, useRef, useState } from "react";
import { MessageTimestamp } from "./MessageTimestamp";
import type { Message } from "@/lib/agents/types";
export function UserMessage({ message }: { message: Message }) {
@ -63,6 +64,13 @@ export function UserMessage({ message }: { message: Message }) {
</div>
</div>
)}
{!message.timestampIsFallback && (
<MessageTimestamp
timestamp={message.timestamp}
align="right"
className="mt-1 pr-1"
/>
)}
</div>
</div>
);

View file

@ -0,0 +1,69 @@
const STORAGE_KEY = "agent-message-timestamps-v1"
const MAX_ENTRIES = 5000
let cache: Map<string, string> | null = null
function load(): Map<string, string> {
if (cache) return cache
cache = new Map()
if (typeof window === "undefined") return cache
try {
const raw = window.localStorage.getItem(STORAGE_KEY)
const parsed = raw ? (JSON.parse(raw) as unknown) : null
if (Array.isArray(parsed)) {
for (const entry of parsed) {
if (
Array.isArray(entry) &&
typeof entry[0] === "string" &&
typeof entry[1] === "string"
) {
cache.set(entry[0], entry[1])
}
}
}
} catch {
// Corrupt/unavailable storage — start empty.
}
return cache
}
function persist(map: Map<string, string>): void {
if (typeof window === "undefined") return
try {
const entries = [...map.entries()]
const trimmed =
entries.length > MAX_ENTRIES ? entries.slice(-MAX_ENTRIES) : entries
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(trimmed))
} catch {
// Quota/serialization failure — timestamps stay in memory only.
}
}
/**
* Stable client-side arrival timestamp for a message id. The LangGraph messages
* we render carry no per-message creation time, so we stamp one the first time a
* message id is observed and reuse it thereafter (persisted to survive reloads,
* since message ids are server-assigned and stable). Real backend timestamps
* (`created_at` / `response_metadata.created_at`) take precedence upstream.
*/
export function messageArrivalTimestamp(messageId: string): string {
const map = load()
const existing = map.get(messageId)
if (existing) return existing
const iso = new Date().toISOString()
map.set(messageId, iso)
persist(map)
return iso
}
const hoverTimeFormatter = new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "medium",
})
/** Format a timestamp for a native hover tooltip (`title`). */
export function formatHoverTimestamp(value?: string): string | undefined {
if (!value) return undefined
const date = new Date(value)
return Number.isNaN(date.getTime()) ? undefined : hoverTimeFormatter.format(date)
}

View file

@ -1,4 +1,5 @@
import { AIMessage, HumanMessage, ToolMessage } from "@langchain/core/messages";
import { messageArrivalTimestamp } from "./messageTimestamps";
import type { BaseMessage, ContentBlock } from "@langchain/core/messages";
import type { AssembledToolCall, SubagentDiscoverySnapshot } from "@langchain/react";
@ -84,19 +85,37 @@ type AgentTurn = {
author: Message["author"];
timestamp: string;
startedAt: string;
timestampIsFallback?: boolean;
chunks: Array<Chunk>;
};
function messageTimestamp(raw: BaseMessage): string {
type MessageTimestamp = {
value: string;
isFallback: boolean;
};
function messageTimestamp(
raw: BaseMessage,
msgId: string,
resolveCreatedAt?: (messageId: string) => string | undefined,
): MessageTimestamp {
const msg = raw as unknown as Record<string, unknown>;
const createdAt = msg.created_at;
if (typeof createdAt === "string" && createdAt) return createdAt;
if (typeof createdAt === "string" && createdAt) {
return { value: createdAt, isFallback: false };
}
const responseMetadata = msg.response_metadata;
if (responseMetadata && typeof responseMetadata === "object") {
const metadataCreatedAt = (responseMetadata as Record<string, unknown>).created_at;
if (typeof metadataCreatedAt === "string" && metadataCreatedAt) return metadataCreatedAt;
if (typeof metadataCreatedAt === "string" && metadataCreatedAt) {
return { value: metadataCreatedAt, isFallback: false };
}
}
return new Date().toISOString();
const resolved = resolveCreatedAt?.(msgId);
if (typeof resolved === "string" && resolved) {
return { value: resolved, isFallback: true };
}
return { value: new Date().toISOString(), isFallback: true };
}
/**
@ -273,6 +292,7 @@ export function streamMessagesToUi(
messages: Array<BaseMessage>,
toolCalls: ReadonlyArray<AssembledToolCall> = [],
subagents: ReadonlyMap<string, SubagentDiscoverySnapshot> = new Map(),
resolveCreatedAt?: (messageId: string) => string | undefined,
): Array<Message> {
const toolCallsById = new Map<string, AssembledToolCall>();
for (const toolCall of toolCalls) {
@ -304,24 +324,33 @@ export function streamMessagesToUi(
agentTurn = null;
};
const appendAgentChunks = (msgId: string, timestamp: string, chunks: Array<Chunk>) => {
const appendAgentChunks = (
msgId: string,
timestamp: string,
timestampIsFallback: boolean,
chunks: Array<Chunk>,
) => {
if (!agentTurn) {
agentTurn = {
id: msgId,
author: "agent",
timestamp,
startedAt: timestamp,
timestampIsFallback,
chunks: [...chunks],
};
} else {
agentTurn.timestamp = timestamp;
agentTurn.timestampIsFallback =
agentTurn.timestampIsFallback || timestampIsFallback;
agentTurn.chunks.push(...chunks);
}
};
messages.forEach((raw, index) => {
const msgId = typeof raw.id === "string" && raw.id ? raw.id : `msg-${index}`;
const timestamp = messageTimestamp(raw);
const { value: timestamp, isFallback: timestampIsFallback } =
messageTimestamp(raw, msgId, resolveCreatedAt);
if (HumanMessage.isInstance(raw)) {
flushAgentTurn();
@ -334,6 +363,7 @@ export function streamMessagesToUi(
id: msgId,
author: "user",
timestamp,
timestampIsFallback,
chunks,
});
return;
@ -356,6 +386,7 @@ export function streamMessagesToUi(
const chunk: ToolExecutionChunk = {
kind: "tool-execution",
toolCallId,
timestamp: messageArrivalTimestamp(toolCallId),
title: toolTitle(name, args),
toolKind: toolKind(name),
input: args,
@ -375,7 +406,9 @@ export function streamMessagesToUi(
chunks.push(chunk);
}
if (chunks.length) appendAgentChunks(msgId, timestamp, chunks);
if (chunks.length) {
appendAgentChunks(msgId, timestamp, timestampIsFallback, chunks);
}
}
// `ToolMessage`s no longer produce their own chunk — their status/output is

View file

@ -66,6 +66,8 @@ export interface DiffData {
export interface ToolExecutionChunk {
kind: "tool-execution"
toolCallId: string
/** Stable arrival time for the tool call, shown on hover. */
timestamp?: string
title: string
toolKind: AcpToolKind
input?: Record<string, unknown>
@ -140,6 +142,7 @@ export interface Message {
timestamp: string
/** Timestamp of the first message in an agent turn; used to derive work duration. */
startedAt?: string
timestampIsFallback?: boolean
chunks: Array<Chunk>
hidden?: boolean
}