mirror of
https://github.com/luckyyzh/pi-agent-integrated.git
synced 2026-10-03 02:59:35 +00:00
1483 lines
53 KiB
TypeScript
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>
|
|
);
|
|
}
|