Files
pi-agent-integrated/pi-web/components/MessageView.tsx

1483 lines
53 KiB
TypeScript

"use client";
import { memo, useState, useRef, useEffect, useMemo } from "react";
import { MarkdownBody } from "./MarkdownBody";
import { copyText } from "@/lib/clipboard";
import { useI18n } from "@/hooks/useI18n";
import { parseCompactionSummary } from "@/lib/compaction-summary";
import { getAssistantErrorMessage, isEmptyThinkingBlock } from "@/lib/message-display";
import { parseUnifiedPatch, type SplitDiffCell } from "@/lib/patch";
import type {
AgentMessage,
UserMessage,
AssistantMessage,
CustomMessage,
ToolResultMessage,
BashExecutionMessage,
AssistantContentBlock,
TextContent,
ImageContent,
ToolCallContent,
ThinkingContent,
} from "@/lib/types";
const MAX_THINKING_CACHE_ENTRIES = 100;
const thinkingContentCache = new Map<string, Promise<string>>();
function loadThinkingContent(sessionId: string, entryId: string, blockIndex: number): Promise<string> {
const key = `${sessionId}:${entryId}:${blockIndex}`;
const cached = thinkingContentCache.get(key);
if (cached) {
thinkingContentCache.delete(key);
thinkingContentCache.set(key, cached);
return cached;
}
const request = fetch(
`/api/sessions/${encodeURIComponent(sessionId)}/entries/${encodeURIComponent(entryId)}/thinking?blockIndex=${blockIndex}`,
).then(async (response) => {
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json() as { thinking?: unknown };
if (typeof data.thinking !== "string") throw new Error("Invalid thinking response");
return data.thinking;
}).catch((error) => {
thinkingContentCache.delete(key);
throw error;
});
thinkingContentCache.set(key, request);
if (thinkingContentCache.size > MAX_THINKING_CACHE_ENTRIES) {
const oldestKey = thinkingContentCache.keys().next().value;
if (oldestKey) thinkingContentCache.delete(oldestKey);
}
return request;
}
interface Props {
message: AgentMessage;
isStreaming?: boolean;
toolResults?: Map<string, ToolResultMessage>;
modelNames?: Record<string, string>;
cwd?: string;
onOpenFile?: (filePath: string) => void;
entryId?: string;
onFork?: (entryId: string) => void;
forking?: boolean;
onNavigate?: (entryId: string) => void;
prevAssistantEntryId?: string;
onEditContent?: (content: string) => void;
showTimestamp?: boolean;
prevTimestamp?: number;
sessionId?: string;
}
function formatTime(ts?: number): string | null {
if (!ts) return null;
const d = new Date(ts);
const now = new Date();
const isToday = d.getFullYear() === now.getFullYear() &&
d.getMonth() === now.getMonth() &&
d.getDate() === now.getDate();
const time = d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
if (isToday) return time;
const date = d.toLocaleDateString([], { month: "short", day: "numeric", year: d.getFullYear() !== now.getFullYear() ? "numeric" : undefined });
return `${date} ${time}`;
}
function haveSameRelevantToolResults(
message: AgentMessage,
previous: Map<string, ToolResultMessage> | undefined,
next: Map<string, ToolResultMessage> | undefined,
): boolean {
if (previous === next || message.role !== "assistant") return true;
for (const block of (message as AssistantMessage).content ?? []) {
if (block.type === "toolCall" && previous?.get(block.toolCallId) !== next?.get(block.toolCallId)) {
return false;
}
}
return true;
}
export const MessageView = memo(function MessageView({ message, isStreaming, toolResults, modelNames, cwd, onOpenFile, entryId, onFork, forking, onNavigate, prevAssistantEntryId, onEditContent, showTimestamp, prevTimestamp, sessionId }: Props) {
if (message.role === "user") {
return <UserMessageView message={message as UserMessage} cwd={cwd} onOpenFile={onOpenFile} entryId={entryId} onFork={onFork} forking={forking} onNavigate={onNavigate} prevAssistantEntryId={prevAssistantEntryId} onEditContent={onEditContent} />;
}
if (message.role === "assistant") {
return <AssistantMessageView message={message as AssistantMessage} isStreaming={isStreaming} toolResults={toolResults} modelNames={modelNames} cwd={cwd} onOpenFile={onOpenFile} showTimestamp={showTimestamp} prevTimestamp={prevTimestamp} sessionId={sessionId} entryId={entryId} />;
}
if (message.role === "toolResult") {
// Rendered inline under its toolCall — skip standalone rendering if paired
return null;
}
if (message.role === "custom") {
if ((message as CustomMessage).customType === "compaction") {
return <CompactionMessageView message={message as CustomMessage} />;
}
return <CustomMessageView message={message as CustomMessage} cwd={cwd} onOpenFile={onOpenFile} />;
}
if (message.role === "bashExecution") {
return <BashExecutionView message={message as BashExecutionMessage} sessionId={sessionId} />;
}
return null;
}, (prev, next) => {
return prev.message === next.message
&& prev.isStreaming === next.isStreaming
&& haveSameRelevantToolResults(prev.message, prev.toolResults, next.toolResults)
&& prev.modelNames === next.modelNames
&& prev.cwd === next.cwd
&& prev.onOpenFile === next.onOpenFile
&& prev.entryId === next.entryId
&& prev.onFork === next.onFork
&& prev.forking === next.forking
&& prev.onNavigate === next.onNavigate
&& prev.prevAssistantEntryId === next.prevAssistantEntryId
&& prev.onEditContent === next.onEditContent
&& prev.showTimestamp === next.showTimestamp
&& prev.prevTimestamp === next.prevTimestamp
&& prev.sessionId === next.sessionId;
});
function UserMessageView({ message, cwd, onOpenFile, entryId, onFork, forking, onNavigate, prevAssistantEntryId, onEditContent }: {
message: UserMessage;
cwd?: string;
onOpenFile?: (filePath: string) => void;
entryId?: string;
onFork?: (entryId: string) => void;
forking?: boolean;
onNavigate?: (entryId: string) => void;
prevAssistantEntryId?: string;
onEditContent?: (content: string) => void;
}) {
const { t } = useI18n();
const [hovered, setHovered] = useState(false);
const [copied, setCopied] = useState(false);
const content =
typeof message.content === "string"
? message.content
: message.content
.filter((b): b is TextContent => b.type === "text")
.map((b) => b.text)
.join("\n");
const imageBlocks: ImageContent[] =
typeof message.content === "string"
? []
: message.content.filter((b): b is ImageContent => b.type === "image");
const time = formatTime(message.timestamp);
const canFork = !!entryId && !!onFork;
const canNavigate = !!prevAssistantEntryId && !!onNavigate;
const copyContent = () => {
copyText(content).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
});
};
return (
<div
style={{ marginBottom: 16, display: "flex", flexDirection: "column", alignItems: "flex-end" }}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
<div style={{ display: "flex", alignItems: "flex-end", gap: 6, maxWidth: "85%" }}>
<div
style={{
flex: 1,
minWidth: 0,
background: "var(--user-bg)",
border: "1px solid rgba(59,130,246,0.2)",
borderRadius: 12,
padding: "8px 12px",
fontSize: 14,
lineHeight: 1.6,
color: "var(--text)",
wordBreak: "break-word",
}}
>
{imageBlocks.length > 0 && (
<div style={{ display: "flex", gap: 6, flexWrap: "wrap", marginBottom: content ? 8 : 0 }}>
{imageBlocks.map((img, i) => {
// lib/types.ts ImageContent uses {source:{type,data,media_type,url}}
// pi-ai on-disk format uses flat {data, mimeType} — handle both
const flat = img as unknown as { data?: string; mimeType?: string };
const src = img.source
? img.source.type === "base64"
? `data:${img.source.media_type};base64,${img.source.data}`
: img.source.url ?? ""
: flat.data
? `data:${flat.mimeType};base64,${flat.data}`
: "";
return (
// eslint-disable-next-line @next/next/no-img-element
<img
key={i}
src={src}
alt=""
style={{ maxWidth: 240, maxHeight: 240, borderRadius: 6, objectFit: "contain", display: "block", border: "1px solid rgba(59,130,246,0.15)" }}
/>
);
})}
</div>
)}
{content && <MarkdownBody className="markdown-user-message" cwd={cwd} onOpenFile={onOpenFile}>{content}</MarkdownBody>}
</div>
</div>
{/* Bottom row: action buttons + timestamp */}
{(time || canFork || canNavigate || true) && (
<div style={{
display: "flex", alignItems: "center", justifyContent: "flex-end",
gap: 6, marginTop: 3,
}}>
<div style={{
display: "flex", gap: 3,
opacity: hovered ? 1 : 0,
pointerEvents: hovered ? "auto" : "none",
transition: "opacity 0.12s",
}}>
<button
onClick={copyContent}
title={t("i18n.copyMessage")}
style={{
display: "flex", alignItems: "center", gap: 4,
padding: "3px 8px", height: 22,
background: "none", border: "none",
borderRadius: 5,
color: copied ? "var(--accent)" : "var(--text-dim)",
cursor: "pointer",
fontSize: 11, fontWeight: 400,
whiteSpace: "nowrap",
transition: "color 0.12s",
}}
onMouseEnter={(e) => { if (!copied) e.currentTarget.style.color = "var(--accent)"; }}
onMouseLeave={(e) => { if (!copied) e.currentTarget.style.color = "var(--text-dim)"; }}
>
{copied ? (
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12" />
</svg>
) : (
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
)}
{copied ? t("i18n.copied") : t("i18n.copy")}
</button>
</div>
{(canFork || canNavigate) && (
<div style={{
display: "flex", gap: 3,
opacity: (hovered || forking) ? 1 : 0,
pointerEvents: (hovered || forking) ? "auto" : "none",
transition: "opacity 0.12s",
}}>
{canNavigate && (
<button
onClick={() => { onNavigate!(prevAssistantEntryId!); onEditContent?.(content); }}
title={t("i18n.editFromHereTitle")}
style={{
display: "flex", alignItems: "center", gap: 4,
padding: "3px 8px", height: 22,
background: "none", border: "none",
borderRadius: 5,
color: "var(--text-dim)",
cursor: "pointer",
fontSize: 11, fontWeight: 400,
whiteSpace: "nowrap",
transition: "color 0.12s",
}}
onMouseEnter={(e) => { e.currentTarget.style.color = "var(--accent)"; }}
onMouseLeave={(e) => { e.currentTarget.style.color = "var(--text-dim)"; }}
>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<polyline points="15 10 20 15 15 20" />
<path d="M4 4v7a4 4 0 0 0 4 4h12" />
</svg>
{t("i18n.editFromHere")}
</button>
)}
{canFork && (
<button
onClick={() => { onFork!(entryId!); }}
disabled={forking}
title={forking ? t("i18n.creatingSession") : t("i18n.newSessionTitle")}
style={{
display: "flex", alignItems: "center", gap: 4,
padding: "3px 8px", height: 22,
background: "none", border: "none",
borderRadius: 5,
color: forking ? "var(--accent)" : "var(--text-dim)",
cursor: forking ? "not-allowed" : "pointer",
fontSize: 11, fontWeight: 400,
whiteSpace: "nowrap",
transition: "color 0.12s",
}}
onMouseEnter={(e) => { if (!forking) e.currentTarget.style.color = "var(--accent)"; }}
onMouseLeave={(e) => { if (!forking) e.currentTarget.style.color = "var(--text-dim)"; }}
>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<line x1="6" y1="3" x2="6" y2="15" />
<circle cx="18" cy="6" r="3" />
<circle cx="6" cy="18" r="3" />
<path d="M18 9a9 9 0 0 1-9 9" />
</svg>
{forking ? t("i18n.creating") : t("i18n.newSession")}
</button>
)}
</div>
)}
{time && <span style={{ fontSize: 10, color: "var(--text-dim)" }}>{time}</span>}
</div>
)}
</div>
);
}
function AssistantMessageView({
message,
isStreaming,
toolResults,
modelNames,
cwd,
onOpenFile,
showTimestamp,
prevTimestamp,
sessionId,
entryId,
}: {
message: AssistantMessage;
isStreaming?: boolean;
toolResults?: Map<string, ToolResultMessage>;
modelNames?: Record<string, string>;
cwd?: string;
onOpenFile?: (filePath: string) => void;
showTimestamp?: boolean;
prevTimestamp?: number;
sessionId?: string;
entryId?: string;
}) {
const { t } = useI18n();
const time = showTimestamp ? formatTime(message.timestamp) : null;
const blockItems = (message.content ?? [])
.map((block, originalIndex) => ({ block, originalIndex }))
.filter(({ block }) => !isEmptyThinkingBlock(block, { isStreaming }));
const blocks = blockItems.map(({ block }) => block);
const providerError = getAssistantErrorMessage(message, { isStreaming });
const [hovered, setHovered] = useState(false);
const [copied, setCopied] = useState(false);
const streamStartRef = useRef<number | null>(null);
const [tps, setTps] = useState<number | null>(null);
const blockItemsRef = useRef(blockItems);
blockItemsRef.current = blockItems;
// Streaming-based timing for thinking blocks
const blockStartTimesRef = useRef<Map<number, number>>(new Map());
const [streamingDurations, setStreamingDurations] = useState<Map<number, number>>(new Map());
// Thinking duration derived from file timestamps: time from prev message end to this message end
// This is the total generation time (thinking + any text before first tool call)
const thinkingDurationFromFile = useMemo<number | undefined>(() => {
if (!message.timestamp || !prevTimestamp) return undefined;
const secs = Math.round((message.timestamp - prevTimestamp) / 1000);
return secs > 0 ? secs : undefined;
}, [message.timestamp, prevTimestamp]);
// Tool call durations derived from session file timestamps (accurate for completed messages)
// assistant message timestamp = when generation ended = when tools started running
// toolResult timestamp = when tool execution finished
const toolCallDurations = useMemo<Map<string, number>>(() => {
const map = new Map<string, number>();
if (!toolResults || !message.timestamp) return map;
for (const [callId, result] of toolResults) {
if (result.timestamp && message.timestamp) {
const secs = Math.round((result.timestamp - message.timestamp) / 1000);
if (secs > 0) map.set(callId, secs);
}
}
return map;
}, [toolResults, message.timestamp]);
const textContent = blocks
.filter((b): b is TextContent => b.type === "text")
.map((b) => b.text)
.join("\n");
const copyContent = () => {
copyText(textContent).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
});
};
useEffect(() => {
if (!isStreaming) {
// Finalise any un-finished thinking block durations on stream end
const now = new Date().getTime();
setStreamingDurations((prev: Map<number, number>) => {
const next = new Map(prev);
for (const [idx, start] of blockStartTimesRef.current) {
if (!next.has(idx)) next.set(idx, Math.round((now - start) / 1000));
}
return next;
});
streamStartRef.current = null;
setTps(null);
return;
}
const tick = () => {
const items = blockItemsRef.current;
const bs = items.map(({ block }) => block);
const now = Date.now();
// Record start time for each block the first time we see it
items.forEach(({ originalIndex }) => {
if (!blockStartTimesRef.current.has(originalIndex)) blockStartTimesRef.current.set(originalIndex, now);
});
// When a non-last block has a successor already started, finalise its duration
setStreamingDurations((prev: Map<number, number>) => {
let changed = false;
const next = new Map(prev);
for (let i = 0; i < items.length - 1; i++) {
const originalIndex = items[i].originalIndex;
const nextOriginalIndex = items[i + 1].originalIndex;
if (!next.has(originalIndex) && blockStartTimesRef.current.has(originalIndex)) {
const start = blockStartTimesRef.current.get(originalIndex)!;
const nextStart = blockStartTimesRef.current.get(nextOriginalIndex) ?? now;
next.set(originalIndex, Math.round((nextStart - start) / 1000));
changed = true;
}
}
return changed ? next : prev;
});
let chars = 0;
for (const b of bs) {
if (b.type === "text") chars += (b as TextContent).text?.length ?? 0;
else if (b.type === "thinking") chars += (b as ThinkingContent).thinking?.length ?? 0;
else if (b.type === "toolCall") chars += JSON.stringify((b as ToolCallContent).input ?? {}).length;
}
if (chars === 0) return;
if (streamStartRef.current === null) streamStartRef.current = now;
const elapsed = (now - streamStartRef.current) / 1000;
if (elapsed > 0.5) setTps(chars / 4 / elapsed);
};
const id = setInterval(tick, 300);
return () => clearInterval(id);
}, [isStreaming]);
if (blocks.length === 0 && !isStreaming && !providerError) return null;
return (
<div
style={{ marginBottom: 16 }}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
{/* Model label */}
<div
style={{
fontSize: 11,
color: "var(--text-dim)",
marginBottom: 4,
display: "flex",
alignItems: "center",
gap: 6,
}}
>
{message.provider && (
<span>{modelNames?.[`${message.provider}:${message.model}`] ?? modelNames?.[message.model] ?? message.model}</span>
)}
{isStreaming && (() => {
let chars = 0;
for (const b of blocks) {
if (b.type === "text") chars += (b as TextContent).text?.length ?? 0;
else if (b.type === "thinking") chars += (b as ThinkingContent).thinking?.length ?? 0;
else if (b.type === "toolCall") chars += JSON.stringify((b as ToolCallContent).input ?? {}).length;
}
const est = Math.round(chars / 4);
return (
<>
{est > 0 && (
<span style={{ display: "flex", alignItems: "center", gap: 4, color: "var(--text)" }} title={t("i18n.estimatedTokens")}>
<span style={{ display: "flex", alignItems: "center", gap: 2, fontSize: 11, fontWeight: 400 }}>
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round">
<line x1="5" y1="1.5" x2="5" y2="8.5" /><polyline points="2 6 5 8.5 8 6" />
</svg>
{est}
</span>
{tps !== null && (() => {
const bg = tps >= 50 ? "#53b3cb" : tps >= 30 ? "#9bc53d" : tps >= 15 ? "#f9c22e" : "#e01a4f";
return (
<span style={{ marginLeft: 6, padding: "1px 6px", borderRadius: 4, background: bg, color: "#fff", fontSize: 11, fontWeight: 400 }}>
{tps.toFixed(1)} t/s
</span>
);
})()}
</span>
)}
</>
);
})()}
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{blockItems.map(({ block, originalIndex }) => (
<BlockView key={`${entryId ?? "stream"}-${originalIndex}`} block={block} toolResults={toolResults} isStreaming={isStreaming} streamingDuration={streamingDurations.get(originalIndex) ?? (block.type === "thinking" ? thinkingDurationFromFile : undefined)} toolCallDurations={toolCallDurations} cwd={cwd} onOpenFile={onOpenFile} sessionId={sessionId} entryId={entryId} blockIndex={originalIndex} />
))}
</div>
{providerError && (
<div
role="alert"
style={{
marginTop: blocks.length > 0 ? 8 : 0,
padding: "7px 10px",
border: "1px solid rgba(239,68,68,0.3)",
borderRadius: 6,
background: "rgba(239,68,68,0.07)",
color: "#ef4444",
fontFamily: "var(--font-mono)",
fontSize: 12,
lineHeight: 1.5,
whiteSpace: "pre-wrap",
overflowWrap: "anywhere",
}}
>
Error: {providerError}
</div>
)}
<div style={{
display: "flex", alignItems: "center", gap: 8, marginTop: 4,
}}>
{message.usage && !isStreaming && (
<div style={{ fontSize: 11, color: "var(--text-dim)" }}>
{formatUsage(message.usage)}
</div>
)}
{textContent && !isStreaming && (
<button
onClick={copyContent}
title={t("i18n.copyMessage")}
style={{
display: "flex", alignItems: "center", gap: 4,
padding: "3px 8px", height: 22,
background: "none", border: "none",
borderRadius: 5,
color: copied ? "var(--accent)" : "var(--text-dim)",
cursor: "pointer",
fontSize: 11, fontWeight: 400,
whiteSpace: "nowrap",
opacity: hovered ? 1 : 0,
pointerEvents: hovered ? "auto" : "none",
transition: "opacity 0.12s, color 0.12s",
}}
onMouseEnter={(e) => { if (!copied) e.currentTarget.style.color = "var(--accent)"; }}
onMouseLeave={(e) => { if (!copied) e.currentTarget.style.color = "var(--text-dim)"; }}
>
{copied ? (
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12" />
</svg>
) : (
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
)}
{copied ? t("i18n.copied") : t("i18n.copy")}
</button>
)}
{time && !isStreaming && (
<span style={{ fontSize: 10, color: "var(--text-dim)", marginLeft: "auto" }}>{time}</span>
)}
</div>
</div>
);
}
function BlockView({ block, toolResults, isStreaming, streamingDuration, toolCallDurations, cwd, onOpenFile, sessionId, entryId, blockIndex }: { block: AssistantContentBlock; toolResults?: Map<string, ToolResultMessage>; isStreaming?: boolean; streamingDuration?: number; toolCallDurations?: Map<string, number>; cwd?: string; onOpenFile?: (filePath: string) => void; sessionId?: string; entryId?: string; blockIndex: number }) {
if (block.type === "text") {
return <TextBlock block={block as TextContent} isStreaming={isStreaming} cwd={cwd} onOpenFile={onOpenFile} />;
}
if (block.type === "thinking") {
return <ThinkingBlock block={block as ThinkingContent} duration={streamingDuration} sessionId={sessionId} entryId={entryId} blockIndex={blockIndex} />;
}
if (block.type === "toolCall") {
const tc = block as ToolCallContent;
const result = toolResults?.get(tc.toolCallId);
const duration = toolCallDurations?.get(tc.toolCallId);
return <ToolCallBlock block={tc} result={result} duration={duration} />;
}
return null;
}
function TextBlock({ block, isStreaming, cwd, onOpenFile }: { block: TextContent; isStreaming?: boolean; cwd?: string; onOpenFile?: (filePath: string) => void }) {
return <MarkdownBody isStreaming={isStreaming} cwd={cwd} onOpenFile={onOpenFile}>{block.text}</MarkdownBody>;
}
function ThinkingBlock({ block, duration, sessionId, entryId, blockIndex }: {
block: ThinkingContent;
duration?: number;
sessionId?: string;
entryId?: string;
blockIndex: number;
}) {
const { t } = useI18n();
const [expanded, setExpanded] = useState(false);
const [content, setContent] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const toggle = async () => {
const nextExpanded = !expanded;
setExpanded(nextExpanded);
if (!nextExpanded || !block.deferred || content !== null) return;
if (!sessionId || !entryId) {
setError(t("i18n.thinkingUnavailable"));
return;
}
setLoading(true);
setError(null);
try {
setContent(await loadThinkingContent(sessionId, entryId, blockIndex));
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
};
return (
<div
style={{
border: "1px solid var(--border)",
borderRadius: 6,
overflow: "hidden",
fontSize: 13,
}}
>
<button
onClick={() => void toggle()}
style={{
display: "flex",
alignItems: "center",
gap: 6,
width: "100%",
padding: "6px 10px",
background: "var(--bg-panel)",
border: "none",
color: "var(--text-muted)",
cursor: "pointer",
fontSize: 12,
textAlign: "left",
}}
>
<span>{t("i18n.thinking")}</span>
{duration !== undefined && (
<span style={{ marginLeft: "auto", fontSize: 11, color: "var(--text-dim)", fontVariantNumeric: "tabular-nums" }}>{duration}s</span>
)}
</button>
{expanded && (
<div
style={{
padding: "8px 10px",
color: error ? "#f87171" : "var(--text-muted)",
fontSize: 12,
lineHeight: 1.6,
whiteSpace: "pre-wrap",
background: "var(--bg-panel)",
borderTop: "1px solid var(--border)",
}}
>
{loading ? t("i18n.loadingThinking") : error ?? (block.deferred ? content : block.thinking)}
</div>
)}
</div>
);
}
function ToolCallBlock({ block, result, duration }: { block: ToolCallContent; result?: ToolResultMessage; duration?: number }) {
const [expanded, setExpanded] = useState(false);
const inputStr = JSON.stringify(block.input, null, 2);
const isEditTool = isEditToolName(block.toolName);
const resultDiff = result && !result.isError ? getResultDiff(result) : null;
// Result display
const resultText = result
? result.content.filter((b): b is { type: "text"; text: string } => b.type === "text").map((b) => b.text).join("\n")
: null;
const resultIsEmpty = resultText === null ? false : (resultText.trim() === "(no output)" || resultText.trim() === "");
const isError = result?.isError ?? false;
return (
<div
style={{
borderRadius: 7,
overflow: "hidden",
fontSize: 12,
border: isError ? "1px solid rgba(248,113,113,0.45)" : "1px solid rgba(34,197,94,0.25)",
background: isError ? "rgba(248,113,113,0.05)" : "rgba(34,197,94,0.04)",
}}
>
{/* ── Tool call header ── */}
<button
onClick={() => setExpanded((v) => !v)}
style={{
display: "flex",
alignItems: "center",
gap: 7,
width: "100%",
padding: "6px 10px",
background: "none",
border: "none",
color: "var(--text-muted)",
cursor: "pointer",
fontSize: 12,
textAlign: "left",
minWidth: 0,
}}
>
<span style={{ color: isError ? "#f87171" : "#16a34a", fontFamily: "var(--font-mono)", fontWeight: 600, fontSize: 11, flexShrink: 0 }}>
{block.toolName}
</span>
<span style={{ color: "var(--text-dim)", fontFamily: "var(--font-mono)", fontSize: 11, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", flex: 1, minWidth: 0 }}>
{getToolPreview(block)}
</span>
{duration !== undefined && (
<span style={{ fontSize: 11, color: "var(--text-dim)", flexShrink: 0, fontVariantNumeric: "tabular-nums" }}>{duration}s</span>
)}
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="var(--text-dim)" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0, transform: expanded ? "rotate(180deg)" : "none", transition: "transform 0.15s" }}>
<polyline points="2 3.5 5 6.5 8 3.5" />
</svg>
</button>
{/* ── Expanded: input args ── */}
{expanded && !isEditTool && (
<pre
style={{
margin: 0,
padding: "8px 10px",
color: "var(--text-muted)",
fontSize: 12,
lineHeight: 1.5,
overflow: "auto",
background: "var(--bg-subtle)",
borderTop: isError ? "1px solid rgba(248,113,113,0.25)" : "1px solid rgba(34,197,94,0.2)",
whiteSpace: "pre-wrap",
wordBreak: "break-all",
}}
>
{inputStr}
</pre>
)}
{/* ── Paired result — only shown when expanded ── */}
{expanded && result && (
resultDiff ? (
<PairedDiffResult
diff={resultDiff}
/>
) : (
<PairedResult
text={resultText ?? ""}
isEmpty={resultIsEmpty}
isError={isError}
/>
)
)}
</div>
);
}
interface ResultDiff {
text: string;
}
function PairedDiffResult({ diff }: {
diff: ResultDiff;
}) {
return (
<div
style={{
borderTop: "1px solid rgba(34,197,94,0.15)",
background: "var(--bg)",
}}
>
<SplitPatchView text={diff.text} />
</div>
);
}
function SplitPatchView({ text }: { text: string }) {
const { t } = useI18n();
const files = useMemo(() => parseUnifiedPatch(text), [text]);
if (!files) return <PatchTextView text={text} />;
const showFileHeaders = files.length > 1;
return (
<div style={{ maxHeight: 560, overflowY: "auto", overflowX: "hidden", background: "var(--bg)" }}>
{files.map((file, fileIndex) => (
<div
key={fileIndex}
style={{
minWidth: 0,
borderTop: fileIndex === 0 ? "none" : "1px solid var(--border)",
fontFamily: "var(--font-mono)",
fontSize: 12,
lineHeight: 1.55,
}}
>
{showFileHeaders && (
<div
style={{
display: "grid",
gridTemplateColumns: "minmax(0, 1fr) minmax(0, 1fr)",
position: "sticky",
top: 0,
zIndex: 1,
background: "var(--bg-panel)",
borderBottom: "1px solid var(--border)",
}}
>
<SplitDiffHeader title={file.oldPath || t("i18n.before")} side="left" />
<SplitDiffHeader title={file.newPath || t("i18n.after")} side="right" />
</div>
)}
<div style={{ display: "grid", gridTemplateColumns: "minmax(0, 1fr) minmax(0, 1fr)" }}>
{file.rows.map((row, rowIndex) => {
if (row.type === "hunk") {
return null;
}
return (
<div key={rowIndex} style={{ display: "contents" }}>
<SplitDiffCellView cell={row.left} side="left" />
<SplitDiffCellView cell={row.right} side="right" />
</div>
);
})}
</div>
</div>
))}
</div>
);
}
function SplitDiffHeader({ title, side }: { title: string; side: "left" | "right" }) {
return (
<div
title={title}
style={{
padding: "5px 10px",
color: "var(--text-dim)",
borderRight: side === "left" ? "1px solid var(--border)" : "none",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{title}
</div>
);
}
function SplitDiffCellView({ cell, side }: { cell: SplitDiffCell; side: "left" | "right" }) {
const bg =
cell.type === "added"
? "rgba(34,197,94,0.12)"
: cell.type === "removed"
? "rgba(248,113,113,0.13)"
: cell.type === "empty"
? "var(--bg-subtle)"
: "transparent";
const marker =
cell.type === "added" ? "+" : cell.type === "removed" ? "-" : " ";
const markerColor =
cell.type === "added" ? "#22c55e" : cell.type === "removed" ? "#f87171" : "var(--text-dim)";
return (
<div
style={{
display: "flex",
minWidth: 0,
background: bg,
borderRight: side === "left" ? "1px solid var(--border)" : "none",
}}
>
<span
style={{
width: 42,
padding: "0 6px",
textAlign: "right",
color: "var(--text-dim)",
userSelect: "none",
background: "var(--bg-panel)",
borderRight: "1px solid var(--border)",
flexShrink: 0,
}}
>
{cell.lineNo ?? ""}
</span>
<span
style={{
width: 18,
padding: "0 5px",
color: markerColor,
userSelect: "none",
fontWeight: cell.type === "context" || cell.type === "empty" ? 400 : 700,
flexShrink: 0,
}}
>
{marker}
</span>
<span
style={{
flex: 1,
minWidth: 0,
padding: "0 10px 0 0",
color: cell.type === "empty" ? "var(--text-dim)" : "var(--text)",
whiteSpace: "pre-wrap",
overflowWrap: "anywhere",
}}
>
{cell.text || "\u00a0"}
</span>
</div>
);
}
function PatchTextView({ text }: { text: string }) {
const lines = text.split(/\r?\n/);
return (
<div style={{ maxHeight: 520, overflowY: "auto", overflowX: "hidden", fontFamily: "var(--font-mono)", fontSize: 12, lineHeight: 1.55, minWidth: 0 }}>
{lines.map((line, i) => {
const kind =
line.startsWith("@@") ? "hunk" :
line.startsWith("+") && !line.startsWith("+++") ? "added" :
line.startsWith("-") && !line.startsWith("---") ? "removed" :
"context";
const bg =
kind === "added" ? "rgba(34,197,94,0.12)" :
kind === "removed" ? "rgba(248,113,113,0.13)" :
kind === "hunk" ? "rgba(96,165,250,0.12)" :
"transparent";
const color =
kind === "added" ? "#22c55e" :
kind === "removed" ? "#f87171" :
kind === "hunk" ? "var(--accent)" :
"var(--text)";
return (
<div
key={i}
style={{
display: "flex",
background: bg,
borderLeft: kind === "added"
? "3px solid #22c55e"
: kind === "removed"
? "3px solid #f87171"
: kind === "hunk"
? "3px solid var(--accent)"
: "3px solid transparent",
}}
>
<span
style={{
width: 48,
padding: "0 8px",
color: "var(--text-dim)",
background: "var(--bg-panel)",
borderRight: "1px solid var(--border)",
textAlign: "right",
userSelect: "none",
flexShrink: 0,
}}
>
{i + 1}
</span>
<span style={{ padding: "0 10px", whiteSpace: "pre-wrap", overflowWrap: "anywhere", color }}>
{line || "\u00a0"}
</span>
</div>
);
})}
</div>
);
}
function getResultDiff(result: ToolResultMessage): ResultDiff | null {
const details = (result as ToolResultMessage & { details?: unknown }).details;
if (!isRecord(details)) return null;
const patch = typeof details.patch === "string" ? details.patch : null;
if (patch) return { text: patch };
const diff = typeof details.diff === "string" ? details.diff : null;
if (diff) return { text: diff };
return null;
}
function isEditToolName(toolName: string): boolean {
const name = toolName.toLowerCase();
return name === "edit" ||
name.startsWith("edit_") ||
name.endsWith(".edit") ||
name.endsWith("_edit") ||
name.includes("str_replace") ||
name.includes("replace_editor");
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function PairedResult({ text, isEmpty, isError }: {
text: string;
isEmpty: boolean;
isError: boolean;
}) {
const { t } = useI18n();
return (
<div
style={{
borderTop: `1px solid ${isError ? "rgba(248,113,113,0.3)" : "rgba(34,197,94,0.15)"}`,
background: isError ? "rgba(248,113,113,0.04)" : "var(--bg-subtle)",
}}
>
<pre
style={{
margin: 0,
padding: "8px 10px",
color: isError ? "#f87171" : (isEmpty ? "var(--text-dim)" : "var(--text-muted)"),
fontSize: 12,
lineHeight: 1.5,
overflow: "auto",
maxHeight: 400,
background: "var(--bg)",
whiteSpace: "pre-wrap",
wordBreak: "break-all",
fontStyle: isEmpty ? "italic" : "normal",
opacity: isEmpty ? 0.6 : 1,
}}
>
{isEmpty ? t("i18n.noOutput") : text}
</pre>
</div>
);
}
function CompactionMessageView({ message }: { message: CustomMessage }) {
const { t } = useI18n();
const summary = getMessageText(message.content);
const parsedSummary = useMemo(() => parseCompactionSummary(summary), [summary]);
const time = formatTime(message.timestamp);
return (
<div style={{ marginBottom: 16 }}>
<div
style={{
border: "1px solid var(--border)",
borderRadius: 8,
overflow: "hidden",
background: "var(--bg)",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "7px 10px",
borderBottom: "1px solid var(--border)",
background: "var(--bg-panel)",
color: "var(--text-muted)",
}}
>
<span style={{ fontFamily: "var(--font-mono)", fontSize: 11, fontWeight: 650 }}>
compaction
</span>
{time && <span style={{ marginLeft: "auto", color: "var(--text-dim)", fontSize: 10 }}>{time}</span>}
</div>
<div style={{ padding: "11px 13px 12px" }}>
<div style={{ color: "var(--text)", fontSize: 15, fontWeight: 700, lineHeight: 1.35 }}>
{t("i18n.conversationCompacted")}
</div>
<div style={{ marginTop: 3, marginBottom: 10, color: "var(--text)", fontSize: 14, lineHeight: 1.5 }}>
{t("i18n.compactionDescription")}
</div>
{parsedSummary.body ? (
<MarkdownBody className="markdown-compaction-message">{parsedSummary.body}</MarkdownBody>
) : (
<span style={{ color: "var(--text-dim)", fontSize: 12 }}>{t("i18n.noSummary")}</span>
)}
<CompactionFileMetadata readFiles={parsedSummary.readFiles} modifiedFiles={parsedSummary.modifiedFiles} />
</div>
</div>
</div>
);
}
function CompactionFileMetadata({ readFiles, modifiedFiles }: { readFiles: string[]; modifiedFiles: string[] }) {
const { t } = useI18n();
const total = readFiles.length + modifiedFiles.length;
if (total === 0) return null;
const parts = [];
if (readFiles.length > 0) parts.push(`${readFiles.length} read`);
if (modifiedFiles.length > 0) parts.push(`${modifiedFiles.length} modified`);
return (
<details className="compaction-file-details">
<summary>{t("i18n.fileContext", { details: parts.join(", ") })}</summary>
{modifiedFiles.length > 0 && <CompactionFileList title={t("i18n.modifiedFiles")} files={modifiedFiles} />}
{readFiles.length > 0 && <CompactionFileList title={t("i18n.readFiles")} files={readFiles} />}
</details>
);
}
function CompactionFileList({ title, files }: { title: string; files: string[] }) {
return (
<div className="compaction-file-section">
<div className="compaction-file-title">{title}</div>
<ul className="compaction-file-list">
{files.map((file) => (
<li key={file}>{file}</li>
))}
</ul>
</div>
);
}
function CustomMessageView({ message, cwd, onOpenFile }: { message: CustomMessage; cwd?: string; onOpenFile?: (filePath: string) => void }) {
const { t } = useI18n();
const isHiddenDisplay = message.display === false;
const [contentExpanded, setContentExpanded] = useState(!isHiddenDisplay);
const [detailsExpanded, setDetailsExpanded] = useState(false);
const [copied, setCopied] = useState(false);
const text = getMessageText(message.content);
const images = getMessageImages(message.content);
const hasDetails = message.details !== undefined;
const detailsText = hasDetails ? safeJson(message.details) : "";
const title = formatCustomType(message.customType);
const time = formatTime(message.timestamp);
const copyContent = () => {
copyText(text || detailsText).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
});
};
return (
<div style={{ marginBottom: 16 }}>
<div
style={{
border: "1px solid var(--border)",
borderRadius: 8,
overflow: "hidden",
background: isHiddenDisplay ? "var(--bg-subtle)" : "var(--bg)",
opacity: isHiddenDisplay && !contentExpanded ? 0.82 : 1,
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "7px 10px",
borderBottom: "1px solid var(--border)",
background: "var(--bg-panel)",
color: "var(--text-muted)",
fontSize: 12,
}}
>
<span style={{ color: "var(--text-muted)", fontFamily: "var(--font-mono)", fontSize: 11, fontWeight: 650 }}>
{title}
</span>
{isHiddenDisplay && <span style={{ color: "var(--text-dim)", fontSize: 11 }}>{t("i18n.hiddenExtensionMessage")}</span>}
{time && <span style={{ marginLeft: "auto", color: "var(--text-dim)", fontSize: 10 }}>{time}</span>}
</div>
{contentExpanded ? (
<div style={{ padding: "6px 9px" }}>
{images.length > 0 && (
<div style={{ display: "flex", gap: 6, flexWrap: "wrap", marginBottom: text ? 8 : 0 }}>
{images.map((img, i) => {
const src = imageSource(img);
if (!src) return null;
return (
// eslint-disable-next-line @next/next/no-img-element
<img
key={i}
src={src}
alt=""
style={{ maxWidth: 240, maxHeight: 240, borderRadius: 6, objectFit: "contain", display: "block", border: "1px solid var(--border)" }}
/>
);
})}
</div>
)}
{text ? <MarkdownBody className="markdown-custom-message" cwd={cwd} onOpenFile={onOpenFile}>{text}</MarkdownBody> : <span style={{ color: "var(--text-dim)", fontSize: 12 }}>{t("i18n.noMessage")}</span>}
</div>
) : (
<button
onClick={() => setContentExpanded(true)}
style={{
display: "block",
width: "100%",
padding: "8px 10px",
border: "none",
background: "transparent",
color: "var(--text-dim)",
cursor: "pointer",
fontSize: 12,
textAlign: "left",
}}
>
{text ? previewText(text) : t("i18n.showExtensionMessage")}
</button>
)}
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "4px 9px",
borderTop: "1px solid var(--border)",
background: "var(--bg-subtle)",
}}
>
{text || detailsText ? (
<button
onClick={copyContent}
style={{
padding: "3px 7px",
border: "none",
background: "none",
color: copied ? "var(--accent)" : "var(--text-dim)",
cursor: "pointer",
fontSize: 11,
}}
>
{copied ? t("i18n.copied") : t("i18n.copy")}
</button>
) : null}
{(hasDetails || isHiddenDisplay) && (
<button
onClick={() => {
if (isHiddenDisplay) setContentExpanded((v) => !v);
else setDetailsExpanded((v) => !v);
}}
style={{
marginLeft: "auto",
padding: "3px 7px",
border: "none",
background: "none",
color: "var(--text-dim)",
cursor: "pointer",
fontSize: 11,
}}
>
{isHiddenDisplay
? (contentExpanded ? t("i18n.collapse") : t("i18n.expand"))
: (detailsExpanded ? t("i18n.hideDetails") : t("i18n.showDetails"))}
</button>
)}
</div>
{hasDetails && ((isHiddenDisplay && contentExpanded) || (!isHiddenDisplay && detailsExpanded)) && (
<pre
style={{
margin: 0,
padding: "9px 10px",
borderTop: "1px solid var(--border)",
background: "var(--bg)",
color: "var(--text-muted)",
fontSize: 12,
lineHeight: 1.5,
whiteSpace: "pre-wrap",
wordBreak: "break-word",
maxHeight: 360,
overflow: "auto",
fontFamily: "var(--font-mono)",
}}
>
{detailsText}
</pre>
)}
</div>
</div>
);
}
function getMessageText(content: CustomMessage["content"] | UserMessage["content"]): string {
if (typeof content === "string") return content;
return content
.filter((b): b is TextContent => b.type === "text")
.map((b) => b.text)
.join("\n");
}
function getMessageImages(content: CustomMessage["content"] | UserMessage["content"]): ImageContent[] {
if (typeof content === "string") return [];
return content.filter((b): b is ImageContent => b.type === "image");
}
function imageSource(img: ImageContent): string {
const flat = img as unknown as { data?: string; mimeType?: string };
if (img.source) {
return img.source.type === "base64"
? `data:${img.source.media_type};base64,${img.source.data}`
: img.source.url ?? "";
}
return flat.data ? `data:${flat.mimeType};base64,${flat.data}` : "";
}
function safeJson(value: unknown): string {
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
}
function formatCustomType(type: string): string {
return type || "extension";
}
function previewText(text: string): string {
const normalized = text.replace(/\s+/g, " ").trim();
if (!normalized) return "Show extension message";
return normalized.length > 140 ? `${normalized.slice(0, 140)}...` : normalized;
}
function getToolPreview(block: ToolCallContent): string {
const input = block.input;
if (!input || typeof input !== "object") return "";
const keys = Object.keys(input);
if (keys.length === 0) return "";
// Common tool input patterns
if ("command" in input) return String(input.command).slice(0, 120);
if ("path" in input) return String(input.path).slice(0, 120);
if ("file_path" in input) return String(input.file_path).slice(0, 120);
if ("pattern" in input) return String(input.pattern).slice(0, 120);
if ("query" in input) return String(input.query).slice(0, 120);
const first = input[keys[0]];
return String(first).slice(0, 120);
}
function formatUsage(usage: {
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
cost: { total: number };
}): string {
const parts = [];
if (usage.input) parts.push(`${usage.input.toLocaleString()} in`);
if (usage.output) parts.push(`${usage.output.toLocaleString()} out`);
if (usage.cacheRead) parts.push(`${usage.cacheRead.toLocaleString()} cache R`);
if (usage.cacheWrite) parts.push(`${usage.cacheWrite.toLocaleString()} cache W`);
if (usage.cost?.total) parts.push(`$${usage.cost.total.toFixed(4)}`);
return parts.join(" · ");
}
function BashExecutionView({ message, sessionId }: { message: BashExecutionMessage; sessionId?: string }) {
const [fullOutput, setFullOutput] = useState<string | null>(null);
const [loadingFull, setLoadingFull] = useState(false);
const [fullError, setFullError] = useState<string | null>(null);
const isPending = !message.output && message.exitCode === undefined && !message.cancelled;
const isError = message.cancelled || (message.exitCode !== undefined && message.exitCode !== 0);
const fullOutputUrl = sessionId && message.fullOutputPath
? `/api/agent/${encodeURIComponent(sessionId)}/bash-output?path=${encodeURIComponent(message.fullOutputPath)}`
: null;
const showFullButton = message.truncated && fullOutputUrl && fullOutput === null;
const displayOutput = fullOutput ?? message.output;
async function loadFullOutput() {
if (!fullOutputUrl) return;
setLoadingFull(true);
setFullError(null);
try {
const res = await fetch(fullOutputUrl);
const d = await res.json() as { success?: boolean; data?: { output?: string }; error?: string };
if (d.success) {
setFullOutput(d.data?.output ?? "");
} else {
setFullError(d.error ?? "failed");
}
} catch (e) {
setFullError(String(e));
} finally {
setLoadingFull(false);
}
}
// Reuse the existing ToolCallBlock so user-run bash looks identical to an
// agent-run bash tool call: same header, collapse behavior, result pane.
// Synthesize an equivalent ToolCallContent + ToolResultMessage pair.
const toolName = message.excludeFromContext ? "bash (local)" : "bash";
const block: ToolCallContent = {
type: "toolCall",
toolCallId: `bash-${message.timestamp ?? ""}`,
toolName,
input: { command: message.command },
};
const result: ToolResultMessage | undefined = isPending
? undefined
: {
role: "toolResult",
toolCallId: block.toolCallId,
toolName,
content: displayOutput ? [{ type: "text", text: displayOutput }] : [],
isError,
timestamp: message.timestamp,
};
return (
<div style={{ margin: "6px 0" }}>
<ToolCallBlock block={block} result={result} />
{message.truncated && fullOutputUrl && (
<div style={{ padding: "4px 10px", fontSize: 11, marginTop: -1 }}>
{showFullButton && (
<button
onClick={loadFullOutput}
disabled={loadingFull}
style={{ background: "none", border: "none", color: "var(--accent)", cursor: loadingFull ? "default" : "pointer", fontSize: 11, padding: 0, textDecoration: "underline" }}
>
{loadingFull ? "loading…" : "view full output"}
</button>
)}
<a
href={`${fullOutputUrl}&download=1`}
style={{ marginLeft: showFullButton ? 10 : 0, color: "var(--accent)", fontSize: 11, textDecoration: "underline" }}
>
download full output
</a>
{fullError && <span style={{ marginLeft: 6, color: "var(--text-dim)", fontSize: 11 }}>({fullError})</span>}
</div>
)}
</div>
);
}