mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 23:13:15 +00:00
* 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>
343 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|