open-swe/ui/src/components/agents/messages/AgentMessage.tsx
Johannes du Plessis 48bf712b77
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>
2026-06-25 13:20:36 -07:00

343 lines
12 KiB
TypeScript

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";
import { ReplyCard } from "@/components/agents/ported/ReplyCard";
/**
* Render-item types kept visible (not collapsed) when a turn finishes — the
* agent's actual reply to the user. Everything else is "work".
*/
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.
*/
function splitWorkAndReply(items: Array<RenderItem>): {
workItems: Array<RenderItem>;
replyItems: Array<RenderItem>;
} {
let splitIndex = items.length;
while (splitIndex > 0) {
const prev = items[splitIndex - 1];
if (!prev || !REPLY_ITEM_TYPES.has(prev.type)) break;
splitIndex -= 1;
}
return { workItems: items.slice(0, splitIndex), replyItems: items.slice(splitIndex) };
}
export function AgentMessage({
message,
isStreaming,
isMarkdownLive,
projectPath,
...callbacks
}: {
message: Message;
isStreaming?: boolean;
isMarkdownLive?: boolean;
projectPath?: string;
} & ApprovalCallbacks) {
const renderItems = useMemo(
() => buildRenderItems(message.chunks, message.id),
[message.chunks, message.id],
);
const changedFiles = useMemo(() => summarizeChangedFiles(message.chunks), [message.chunks]);
const changedFilesTotals = useMemo(() => {
let additions = 0;
let deletions = 0;
for (const item of changedFiles) {
additions += item.additions;
deletions += item.deletions;
}
return { additions, deletions };
}, [changedFiles]);
const changedFilesByPath = useMemo(() => {
const byPath = new Map<string, ChangedFileSummaryItem>();
for (const file of changedFiles) {
byPath.set(file.filePath, file);
}
return byPath;
}, [changedFiles]);
const exploredGroupIds = useMemo(
() =>
renderItems
.filter((item): item is Extract<RenderItem, { type: "explored-group" }> => item.type === "explored-group")
.map((item) => item.id),
[renderItems],
);
const hasExploredGroups = exploredGroupIds.length > 0;
const [expandedExploredGroups, setExpandedExploredGroups] = useState<Record<string, boolean>>({});
const wasExplorationLiveRef = useRef(false);
useEffect(() => {
const isLive = !!isStreaming;
if (!hasExploredGroups) {
setExpandedExploredGroups({});
wasExplorationLiveRef.current = isLive;
return;
}
if (isLive) {
const next: Record<string, boolean> = {};
for (const id of exploredGroupIds) {
next[id] = true;
}
setExpandedExploredGroups(next);
wasExplorationLiveRef.current = true;
return;
}
const shouldAutoCollapse = wasExplorationLiveRef.current;
setExpandedExploredGroups((prev) => {
const next: Record<string, boolean> = {};
for (const id of exploredGroupIds) {
next[id] = shouldAutoCollapse ? false : (prev[id] ?? false);
}
const prevKeys = Object.keys(prev);
const nextKeys = Object.keys(next);
if (prevKeys.length !== nextKeys.length) return next;
for (const key of nextKeys) {
if (prev[key] !== next[key]) return next;
}
return prev;
});
wasExplorationLiveRef.current = false;
}, [hasExploredGroups, isStreaming, exploredGroupIds, message.id]);
// Measure wall-clock work time for live runs (most accurate); fall back to
// the turn's first→last message timestamps for transcripts loaded from state.
const [measuredDurationMs, setMeasuredDurationMs] = useState<number | null>(null);
const workStartRef = useRef<number | null>(null);
const wasStreamingRef = useRef(false);
useEffect(() => {
if (isStreaming) {
if (workStartRef.current === null) workStartRef.current = Date.now();
wasStreamingRef.current = true;
return;
}
if (wasStreamingRef.current && workStartRef.current !== null) {
setMeasuredDurationMs(Date.now() - workStartRef.current);
wasStreamingRef.current = false;
}
}, [isStreaming]);
const workDurationMs = useMemo(() => {
if (measuredDurationMs !== null) return measuredDurationMs;
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,
message.timestampIsFallback,
]);
const { workItems, replyItems } = useMemo(() => splitWorkAndReply(renderItems), [renderItems]);
const collapseWork = !isStreaming && workItems.length > 0;
const renderItem = (item: RenderItem, index: number, total: number) => {
switch (item.type) {
case "reasoning-item": {
const reasoningChunk = item.chunk.kind === "reasoning" ? item.chunk : null;
const isLastItem = index === total - 1;
return (
<div key={item.key} className="flex-1 min-w-0">
<ReasoningBlock
text={reasoningChunk?.text ?? ""}
isLive={!!isStreaming && isLastItem}
/>
</div>
);
}
case "explored-group": {
const summary = summarizeExploration(item.chunks);
const isExpanded = expandedExploredGroups[item.id] ?? false;
return (
<div key={item.key}>
<button
type="button"
onClick={() =>
setExpandedExploredGroups((prev) => ({
...prev,
[item.id]: !(prev[item.id] ?? false),
}))
}
className="w-full flex items-center justify-between py-1 text-left hover:opacity-90 transition-opacity"
>
<span className="text-[color:var(--ui-text-muted)] text-[12px]">{summary}</span>
<span className="text-[color:var(--ui-text-dim)] text-xs">{isExpanded ? "Hide" : "Show"}</span>
</button>
{isExpanded && (
<div className="pt-1 pb-1 space-y-0.5">
{item.chunks.map((chunk, chunkIndex) => (
<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}
/>
</ToolRow>
))}
</div>
)}
</div>
);
}
case "subagent-group":
return <SubagentGroup key={item.key} chunks={item.chunks} />;
case "edit-item": {
const fullFileDiff = item.chunk.diffData
? changedFilesByPath.get(item.chunk.diffData.filePath)
: undefined;
return (
<ToolRow key={item.key} timestamp={item.chunk.timestamp}>
<ToolExecution
chunk={item.chunk}
projectPath={projectPath}
onApprove={callbacks.onApprove}
onReject={callbacks.onReject}
onAutoApprove={callbacks.onAutoApprove}
onOpenDiff={callbacks.onOpenDiff}
resolvedDiffData={fullFileDiff ? {
originalContent: fullFileDiff.originalContent,
modifiedContent: fullFileDiff.modifiedContent,
} : undefined}
/>
</ToolRow>
);
}
case "shell-item":
return (
<ToolRow key={item.key} timestamp={item.chunk.timestamp}>
<ShellCommand
chunk={item.chunk}
projectPath={projectPath}
/>
</ToolRow>
);
case "reply-item":
return (
<ToolRow key={item.key} timestamp={item.chunk.timestamp}>
<ReplyCard chunk={item.chunk} />
</ToolRow>
);
case "tool-item":
return (
<ToolRow key={item.key} timestamp={item.chunk.timestamp}>
<ToolExecution
chunk={item.chunk}
projectPath={projectPath}
onApprove={callbacks.onApprove}
onReject={callbacks.onReject}
onAutoApprove={callbacks.onAutoApprove}
onOpenDiff={callbacks.onOpenDiff}
/>
</ToolRow>
);
case "text-chunk":
return (
<div key={item.key} className="flex-1 min-w-0">
<ChunkRenderer
chunk={item.chunk}
projectPath={projectPath}
isMarkdownLive={isMarkdownLive}
{...callbacks}
/>
</div>
);
}
};
return (
<div className="my-2 min-w-0 space-y-2">
{collapseWork ? (
<>
<WorkSummary durationMs={workDurationMs}>
{workItems.map((item, index) => renderItem(item, index, workItems.length))}
</WorkSummary>
{replyItems.map((item, index) =>
renderItem(item, workItems.length + index, renderItems.length),
)}
</>
) : (
renderItems.map((item, index) => renderItem(item, index, renderItems.length))
)}
{changedFiles.length > 0 && !isStreaming && (
<TurnChangedFilesCard
files={changedFiles}
totals={changedFilesTotals}
projectPath={projectPath}
/>
)}
{!message.timestampIsFallback && (
<MessageTimestamp
timestamp={message.timestamp}
startedAt={message.startedAt}
className="mt-1"
/>
)}
</div>
);
}