mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-10-01 07:23:14 +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 { AgentPromptBar } from "@/components/agents/AgentPromptBar"
|
||||||
import { Messages } from "@/components/agents/messages"
|
import { Messages } from "@/components/agents/messages"
|
||||||
import { streamMessagesToUi } from "@/lib/agents/streamMessagesToUi"
|
import { streamMessagesToUi } from "@/lib/agents/streamMessagesToUi"
|
||||||
|
import { messageArrivalTimestamp } from "@/lib/agents/messageTimestamps"
|
||||||
import { useSubmitAgentMessage } from "@/lib/agents/provider/useSubmitAgentMessage"
|
import { useSubmitAgentMessage } from "@/lib/agents/provider/useSubmitAgentMessage"
|
||||||
import { useModelOptions } from "@/lib/agents/provider/useModelOptions"
|
import { useModelOptions } from "@/lib/agents/provider/useModelOptions"
|
||||||
import { useIsMobile } from "@/lib/useIsMobile"
|
import { useIsMobile } from "@/lib/useIsMobile"
|
||||||
|
|
@ -58,7 +59,8 @@ export function AgentThreadView({ thread }: AgentThreadViewProps) {
|
||||||
const live = streamMessagesToUi(
|
const live = streamMessagesToUi(
|
||||||
stream.messages,
|
stream.messages,
|
||||||
stream.toolCalls,
|
stream.toolCalls,
|
||||||
stream.subagents
|
stream.subagents,
|
||||||
|
messageArrivalTimestamp
|
||||||
)
|
)
|
||||||
if (live.length > 0) return live
|
if (live.length > 0) return live
|
||||||
// Optimistic transcript seeded by `AgentsHome` on thread creation (the
|
// Optimistic transcript seeded by `AgentsHome` on thread creation (the
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,17 @@
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
import { ChunkRenderer } from "./ChunkRenderer";
|
import { ChunkRenderer } from "./ChunkRenderer";
|
||||||
|
import { MessageTimestamp } from "./MessageTimestamp";
|
||||||
import { ReasoningBlock } from "./ReasoningBlock";
|
import { ReasoningBlock } from "./ReasoningBlock";
|
||||||
import { buildRenderItems, summarizeExploration } from "./renderItems";
|
import { buildRenderItems, summarizeExploration } from "./renderItems";
|
||||||
import { summarizeChangedFiles } from "./summarizeChangedFiles";
|
import { summarizeChangedFiles } from "./summarizeChangedFiles";
|
||||||
import { TurnChangedFilesCard } from "./TurnChangedFilesCard";
|
import { TurnChangedFilesCard } from "./TurnChangedFilesCard";
|
||||||
import { WorkSummary } from "./WorkSummary";
|
import { WorkSummary } from "./WorkSummary";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
import type { RenderItem } from "./renderItems";
|
import type { RenderItem } from "./renderItems";
|
||||||
import type { Message } from "@/lib/agents/types";
|
import type { Message } from "@/lib/agents/types";
|
||||||
import type { ApprovalCallbacks, ChangedFileSummaryItem } from "./types";
|
import type { ApprovalCallbacks, ChangedFileSummaryItem } from "./types";
|
||||||
|
import { formatHoverTimestamp } from "@/lib/agents/messageTimestamps";
|
||||||
import { SubagentGroup } from "@/components/agents/subagents";
|
import { SubagentGroup } from "@/components/agents/subagents";
|
||||||
import { ToolExecution } from "@/components/agents/ported/ToolExecution";
|
import { ToolExecution } from "@/components/agents/ported/ToolExecution";
|
||||||
import { ShellCommand } from "@/components/agents/ported/ShellCommand";
|
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"]);
|
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,
|
* 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.
|
* where the reply is the maximal suffix made up solely of reply/text items.
|
||||||
|
|
@ -138,13 +167,18 @@ export function AgentMessage({
|
||||||
|
|
||||||
const workDurationMs = useMemo(() => {
|
const workDurationMs = useMemo(() => {
|
||||||
if (measuredDurationMs !== null) return measuredDurationMs;
|
if (measuredDurationMs !== null) return measuredDurationMs;
|
||||||
if (!message.startedAt) return null;
|
if (!message.startedAt || message.timestampIsFallback) return null;
|
||||||
const start = Date.parse(message.startedAt);
|
const start = Date.parse(message.startedAt);
|
||||||
const end = Date.parse(message.timestamp);
|
const end = Date.parse(message.timestamp);
|
||||||
if (!Number.isFinite(start) || !Number.isFinite(end)) return null;
|
if (!Number.isFinite(start) || !Number.isFinite(end)) return null;
|
||||||
const delta = end - start;
|
const delta = end - start;
|
||||||
return delta > 0 ? delta : null;
|
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 { workItems, replyItems } = useMemo(() => splitWorkAndReply(renderItems), [renderItems]);
|
||||||
const collapseWork = !isStreaming && workItems.length > 0;
|
const collapseWork = !isStreaming && workItems.length > 0;
|
||||||
|
|
@ -185,13 +219,17 @@ export function AgentMessage({
|
||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<div className="pt-1 pb-1 space-y-0.5">
|
<div className="pt-1 pb-1 space-y-0.5">
|
||||||
{item.chunks.map((chunk, chunkIndex) => (
|
{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
|
<ToolExecution
|
||||||
chunk={chunk}
|
chunk={chunk}
|
||||||
projectPath={projectPath}
|
projectPath={projectPath}
|
||||||
onOpenDiff={callbacks.onOpenDiff}
|
onOpenDiff={callbacks.onOpenDiff}
|
||||||
/>
|
/>
|
||||||
</div>
|
</ToolRow>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -208,7 +246,7 @@ export function AgentMessage({
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={item.key}>
|
<ToolRow key={item.key} timestamp={item.chunk.timestamp}>
|
||||||
<ToolExecution
|
<ToolExecution
|
||||||
chunk={item.chunk}
|
chunk={item.chunk}
|
||||||
projectPath={projectPath}
|
projectPath={projectPath}
|
||||||
|
|
@ -221,30 +259,30 @@ export function AgentMessage({
|
||||||
modifiedContent: fullFileDiff.modifiedContent,
|
modifiedContent: fullFileDiff.modifiedContent,
|
||||||
} : undefined}
|
} : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</ToolRow>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
case "shell-item":
|
case "shell-item":
|
||||||
return (
|
return (
|
||||||
<div key={item.key}>
|
<ToolRow key={item.key} timestamp={item.chunk.timestamp}>
|
||||||
<ShellCommand
|
<ShellCommand
|
||||||
chunk={item.chunk}
|
chunk={item.chunk}
|
||||||
projectPath={projectPath}
|
projectPath={projectPath}
|
||||||
/>
|
/>
|
||||||
</div>
|
</ToolRow>
|
||||||
);
|
);
|
||||||
|
|
||||||
case "reply-item":
|
case "reply-item":
|
||||||
return (
|
return (
|
||||||
<div key={item.key}>
|
<ToolRow key={item.key} timestamp={item.chunk.timestamp}>
|
||||||
<ReplyCard chunk={item.chunk} />
|
<ReplyCard chunk={item.chunk} />
|
||||||
</div>
|
</ToolRow>
|
||||||
);
|
);
|
||||||
|
|
||||||
case "tool-item":
|
case "tool-item":
|
||||||
return (
|
return (
|
||||||
<div key={item.key}>
|
<ToolRow key={item.key} timestamp={item.chunk.timestamp}>
|
||||||
<ToolExecution
|
<ToolExecution
|
||||||
chunk={item.chunk}
|
chunk={item.chunk}
|
||||||
projectPath={projectPath}
|
projectPath={projectPath}
|
||||||
|
|
@ -253,7 +291,7 @@ export function AgentMessage({
|
||||||
onAutoApprove={callbacks.onAutoApprove}
|
onAutoApprove={callbacks.onAutoApprove}
|
||||||
onOpenDiff={callbacks.onOpenDiff}
|
onOpenDiff={callbacks.onOpenDiff}
|
||||||
/>
|
/>
|
||||||
</div>
|
</ToolRow>
|
||||||
);
|
);
|
||||||
|
|
||||||
case "text-chunk":
|
case "text-chunk":
|
||||||
|
|
@ -292,6 +330,14 @@ export function AgentMessage({
|
||||||
projectPath={projectPath}
|
projectPath={projectPath}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!message.timestampIsFallback && (
|
||||||
|
<MessageTimestamp
|
||||||
|
timestamp={message.timestamp}
|
||||||
|
startedAt={message.startedAt}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</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 { useCallback, useLayoutEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
import { MessageTimestamp } from "./MessageTimestamp";
|
||||||
import type { Message } from "@/lib/agents/types";
|
import type { Message } from "@/lib/agents/types";
|
||||||
|
|
||||||
export function UserMessage({ message }: { message: Message }) {
|
export function UserMessage({ message }: { message: Message }) {
|
||||||
|
|
@ -63,6 +64,13 @@ export function UserMessage({ message }: { message: Message }) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{!message.timestampIsFallback && (
|
||||||
|
<MessageTimestamp
|
||||||
|
timestamp={message.timestamp}
|
||||||
|
align="right"
|
||||||
|
className="mt-1 pr-1"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</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 { AIMessage, HumanMessage, ToolMessage } from "@langchain/core/messages";
|
||||||
|
import { messageArrivalTimestamp } from "./messageTimestamps";
|
||||||
import type { BaseMessage, ContentBlock } from "@langchain/core/messages";
|
import type { BaseMessage, ContentBlock } from "@langchain/core/messages";
|
||||||
import type { AssembledToolCall, SubagentDiscoverySnapshot } from "@langchain/react";
|
import type { AssembledToolCall, SubagentDiscoverySnapshot } from "@langchain/react";
|
||||||
|
|
||||||
|
|
@ -84,19 +85,37 @@ type AgentTurn = {
|
||||||
author: Message["author"];
|
author: Message["author"];
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
startedAt: string;
|
startedAt: string;
|
||||||
|
timestampIsFallback?: boolean;
|
||||||
chunks: Array<Chunk>;
|
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 msg = raw as unknown as Record<string, unknown>;
|
||||||
const createdAt = msg.created_at;
|
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;
|
const responseMetadata = msg.response_metadata;
|
||||||
if (responseMetadata && typeof responseMetadata === "object") {
|
if (responseMetadata && typeof responseMetadata === "object") {
|
||||||
const metadataCreatedAt = (responseMetadata as Record<string, unknown>).created_at;
|
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>,
|
messages: Array<BaseMessage>,
|
||||||
toolCalls: ReadonlyArray<AssembledToolCall> = [],
|
toolCalls: ReadonlyArray<AssembledToolCall> = [],
|
||||||
subagents: ReadonlyMap<string, SubagentDiscoverySnapshot> = new Map(),
|
subagents: ReadonlyMap<string, SubagentDiscoverySnapshot> = new Map(),
|
||||||
|
resolveCreatedAt?: (messageId: string) => string | undefined,
|
||||||
): Array<Message> {
|
): Array<Message> {
|
||||||
const toolCallsById = new Map<string, AssembledToolCall>();
|
const toolCallsById = new Map<string, AssembledToolCall>();
|
||||||
for (const toolCall of toolCalls) {
|
for (const toolCall of toolCalls) {
|
||||||
|
|
@ -304,24 +324,33 @@ export function streamMessagesToUi(
|
||||||
agentTurn = null;
|
agentTurn = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const appendAgentChunks = (msgId: string, timestamp: string, chunks: Array<Chunk>) => {
|
const appendAgentChunks = (
|
||||||
|
msgId: string,
|
||||||
|
timestamp: string,
|
||||||
|
timestampIsFallback: boolean,
|
||||||
|
chunks: Array<Chunk>,
|
||||||
|
) => {
|
||||||
if (!agentTurn) {
|
if (!agentTurn) {
|
||||||
agentTurn = {
|
agentTurn = {
|
||||||
id: msgId,
|
id: msgId,
|
||||||
author: "agent",
|
author: "agent",
|
||||||
timestamp,
|
timestamp,
|
||||||
startedAt: timestamp,
|
startedAt: timestamp,
|
||||||
|
timestampIsFallback,
|
||||||
chunks: [...chunks],
|
chunks: [...chunks],
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
agentTurn.timestamp = timestamp;
|
agentTurn.timestamp = timestamp;
|
||||||
|
agentTurn.timestampIsFallback =
|
||||||
|
agentTurn.timestampIsFallback || timestampIsFallback;
|
||||||
agentTurn.chunks.push(...chunks);
|
agentTurn.chunks.push(...chunks);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
messages.forEach((raw, index) => {
|
messages.forEach((raw, index) => {
|
||||||
const msgId = typeof raw.id === "string" && raw.id ? raw.id : `msg-${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)) {
|
if (HumanMessage.isInstance(raw)) {
|
||||||
flushAgentTurn();
|
flushAgentTurn();
|
||||||
|
|
@ -334,6 +363,7 @@ export function streamMessagesToUi(
|
||||||
id: msgId,
|
id: msgId,
|
||||||
author: "user",
|
author: "user",
|
||||||
timestamp,
|
timestamp,
|
||||||
|
timestampIsFallback,
|
||||||
chunks,
|
chunks,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
|
|
@ -356,6 +386,7 @@ export function streamMessagesToUi(
|
||||||
const chunk: ToolExecutionChunk = {
|
const chunk: ToolExecutionChunk = {
|
||||||
kind: "tool-execution",
|
kind: "tool-execution",
|
||||||
toolCallId,
|
toolCallId,
|
||||||
|
timestamp: messageArrivalTimestamp(toolCallId),
|
||||||
title: toolTitle(name, args),
|
title: toolTitle(name, args),
|
||||||
toolKind: toolKind(name),
|
toolKind: toolKind(name),
|
||||||
input: args,
|
input: args,
|
||||||
|
|
@ -375,7 +406,9 @@ export function streamMessagesToUi(
|
||||||
chunks.push(chunk);
|
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
|
// `ToolMessage`s no longer produce their own chunk — their status/output is
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,8 @@ export interface DiffData {
|
||||||
export interface ToolExecutionChunk {
|
export interface ToolExecutionChunk {
|
||||||
kind: "tool-execution"
|
kind: "tool-execution"
|
||||||
toolCallId: string
|
toolCallId: string
|
||||||
|
/** Stable arrival time for the tool call, shown on hover. */
|
||||||
|
timestamp?: string
|
||||||
title: string
|
title: string
|
||||||
toolKind: AcpToolKind
|
toolKind: AcpToolKind
|
||||||
input?: Record<string, unknown>
|
input?: Record<string, unknown>
|
||||||
|
|
@ -140,6 +142,7 @@ export interface Message {
|
||||||
timestamp: string
|
timestamp: string
|
||||||
/** Timestamp of the first message in an agent turn; used to derive work duration. */
|
/** Timestamp of the first message in an agent turn; used to derive work duration. */
|
||||||
startedAt?: string
|
startedAt?: string
|
||||||
|
timestampIsFallback?: boolean
|
||||||
chunks: Array<Chunk>
|
chunks: Array<Chunk>
|
||||||
hidden?: boolean
|
hidden?: boolean
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue