From 48bf712b778f0a68356e06cc84dfa49c7ca86bc6 Mon Sep 17 00:00:00 2001 From: Johannes du Plessis Date: Thu, 25 Jun 2026 13:20:36 -0700 Subject: [PATCH] feat: show message timestamps (#1609) * feat: show message timestamps Co-authored-by: open-swe[bot] * fix: suppress fallback message timestamps Co-authored-by: open-swe[bot] * 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] --------- Co-authored-by: open-swe[bot] --- ui/src/components/agents/AgentThreadView.tsx | 4 +- .../agents/messages/AgentMessage.tsx | 70 ++++++++++++++--- .../agents/messages/MessageTimestamp.tsx | 75 +++++++++++++++++++ .../agents/messages/UserMessage.tsx | 8 ++ ui/src/lib/agents/messageTimestamps.ts | 69 +++++++++++++++++ ui/src/lib/agents/streamMessagesToUi.ts | 47 ++++++++++-- ui/src/lib/agents/types.ts | 3 + 7 files changed, 256 insertions(+), 20 deletions(-) create mode 100644 ui/src/components/agents/messages/MessageTimestamp.tsx create mode 100644 ui/src/lib/agents/messageTimestamps.ts diff --git a/ui/src/components/agents/AgentThreadView.tsx b/ui/src/components/agents/AgentThreadView.tsx index 4aca5309..19b8e6f1 100644 --- a/ui/src/components/agents/AgentThreadView.tsx +++ b/ui/src/components/agents/AgentThreadView.tsx @@ -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 diff --git a/ui/src/components/agents/messages/AgentMessage.tsx b/ui/src/components/agents/messages/AgentMessage.tsx index df3a8f0d..8647faca 100644 --- a/ui/src/components/agents/messages/AgentMessage.tsx +++ b/ui/src/components/agents/messages/AgentMessage.tsx @@ -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(["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 ( +
+ {children} + {label && ( + + )} +
+ ); +} + /** * 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 && (
{item.chunks.map((chunk, chunkIndex) => ( -
+ -
+ ))}
)} @@ -208,7 +246,7 @@ export function AgentMessage({ : undefined; return ( -
+ -
+ ); } case "shell-item": return ( -
+ -
+ ); case "reply-item": return ( -
+ -
+ ); case "tool-item": return ( -
+ -
+ ); case "text-chunk": @@ -292,6 +330,14 @@ export function AgentMessage({ projectPath={projectPath} /> )} + + {!message.timestampIsFallback && ( + + )} ); } diff --git a/ui/src/components/agents/messages/MessageTimestamp.tsx b/ui/src/components/agents/messages/MessageTimestamp.tsx new file mode 100644 index 00000000..4894e07d --- /dev/null +++ b/ui/src/components/agents/messages/MessageTimestamp.tsx @@ -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 ( +
+ +
+ ); +} diff --git a/ui/src/components/agents/messages/UserMessage.tsx b/ui/src/components/agents/messages/UserMessage.tsx index 4e5ed0de..42adb574 100644 --- a/ui/src/components/agents/messages/UserMessage.tsx +++ b/ui/src/components/agents/messages/UserMessage.tsx @@ -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 }) { )} + {!message.timestampIsFallback && ( + + )} ); diff --git a/ui/src/lib/agents/messageTimestamps.ts b/ui/src/lib/agents/messageTimestamps.ts new file mode 100644 index 00000000..473b4437 --- /dev/null +++ b/ui/src/lib/agents/messageTimestamps.ts @@ -0,0 +1,69 @@ +const STORAGE_KEY = "agent-message-timestamps-v1" +const MAX_ENTRIES = 5000 + +let cache: Map | null = null + +function load(): Map { + 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): 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) +} diff --git a/ui/src/lib/agents/streamMessagesToUi.ts b/ui/src/lib/agents/streamMessagesToUi.ts index b5ef16a5..c8595773 100644 --- a/ui/src/lib/agents/streamMessagesToUi.ts +++ b/ui/src/lib/agents/streamMessagesToUi.ts @@ -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; }; -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; 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).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, toolCalls: ReadonlyArray = [], subagents: ReadonlyMap = new Map(), + resolveCreatedAt?: (messageId: string) => string | undefined, ): Array { const toolCallsById = new Map(); for (const toolCall of toolCalls) { @@ -304,24 +324,33 @@ export function streamMessagesToUi( agentTurn = null; }; - const appendAgentChunks = (msgId: string, timestamp: string, chunks: Array) => { + const appendAgentChunks = ( + msgId: string, + timestamp: string, + timestampIsFallback: boolean, + chunks: Array, + ) => { 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 diff --git a/ui/src/lib/agents/types.ts b/ui/src/lib/agents/types.ts index e288a182..2e88365f 100644 --- a/ui/src/lib/agents/types.ts +++ b/ui/src/lib/agents/types.ts @@ -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 @@ -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 hidden?: boolean }