mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 20:53:15 +00:00
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:
parent
009064011a
commit
48bf712b77
7 changed files with 256 additions and 20 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
75
ui/src/components/agents/messages/MessageTimestamp.tsx
Normal file
75
ui/src/components/agents/messages/MessageTimestamp.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
69
ui/src/lib/agents/messageTimestamps.ts
Normal file
69
ui/src/lib/agents/messageTimestamps.ts
Normal 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)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue