feat(dashboard): lazy routes, Needs-you strip, URL state, exact numbers
- Workspace, SettingsHub, SessionDetail and the Workflow sub-tab are lazy (main bundle 2,166 kB -> ~920 kB), wrapped in RouteErrorBoundary: a stale chunk after a self-update reloads once (vite:preloadError too), any other error shows a Reload panel instead of a blank page. - Live refresh: lib/throttle.ts + hooks/useLiveRefresh.ts replace trailing debounces that froze under a steady event stream; refresh skips hidden tabs and refetches after a WebSocket reconnect. Monitor loads only while shown, with one batched GET /api/agents?session_ids= instead of N+1; a failing endpoint is an inline error, not a blank Dashboard. - NeedsYouStrip on Monitor and above Analytics. Tabs, filters, page, sort and search live in the URL (Dashboard, Sessions, Activity, SessionDetail). - Sessions: tool / workspace / date-range filters, CSV/JSON export (formula-neutralised), focusable row links, Open in workspace (the dead /run links are gone), stale responses dropped, error + Retry. - Fixes: "NaNs" durations, 30/7-day charts skipping idle days, weekday labels west of UTC (now averages), cache hit rate denominator, billable tokens vs cache reads, exact tool-call counts, "To" date = end of day, invalid ?from= no longer throws, recent list no longer shrinks to 15, stable message keys, transcript switch races. - Light-theme-unreadable bg-black code panels and dark-only DAG fills use tokens; status configs and chart colours use theme tokens; Health tab and other literals translated. - Server (additive): GET /api/agents?session_ids=, GET /api/sessions awaiting=1&from=&to=, stats tool_call_count/distinct_tool_count, event rows carry session/agent labels.
This commit is contained in:
@@ -53,7 +53,9 @@
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import { memo, useState, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import i18n from "../../i18n";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
@@ -76,45 +78,35 @@ import type { TranscriptMessage, TranscriptContent, TranscriptSender } from "../
|
||||
* and accent so attribution is unambiguous. */
|
||||
const SENDER_STYLES: Record<
|
||||
TranscriptSender,
|
||||
{ label: string; icon: typeof User; avatarRing: string; accentBar: string; headerText: string }
|
||||
{ icon: typeof User; avatarRing: string; accentBar: string; headerText: string }
|
||||
> = {
|
||||
user: {
|
||||
label: "User",
|
||||
icon: User,
|
||||
avatarRing:
|
||||
"bg-gradient-to-br from-blue-600/30 to-cyan-500/20 text-blue-700 dark:text-blue-300 ring-1 ring-blue-500/30",
|
||||
accentBar: "before:bg-blue-600/40",
|
||||
headerText: "text-blue-700 dark:text-blue-300",
|
||||
avatarRing: "bg-syntax-builtin/15 text-syntax-builtin ring-1 ring-syntax-builtin/30",
|
||||
accentBar: "before:bg-syntax-builtin/40",
|
||||
headerText: "text-syntax-builtin",
|
||||
},
|
||||
assistant: {
|
||||
label: "Assistant",
|
||||
icon: Bot,
|
||||
avatarRing:
|
||||
"bg-gradient-to-br from-violet-500/30 to-fuchsia-500/20 text-violet-800 dark:text-violet-200 ring-1 ring-violet-400/30",
|
||||
accentBar: "before:bg-violet-500/40",
|
||||
headerText: "text-violet-800 dark:text-violet-200",
|
||||
avatarRing: "bg-syntax-keyword/15 text-syntax-keyword ring-1 ring-syntax-keyword/30",
|
||||
accentBar: "before:bg-syntax-keyword/40",
|
||||
headerText: "text-syntax-keyword",
|
||||
},
|
||||
orchestrator: {
|
||||
label: "Main agent",
|
||||
icon: Workflow,
|
||||
avatarRing:
|
||||
"bg-gradient-to-br from-teal-500/30 to-status-success/20 text-teal-800 dark:text-teal-200 ring-1 ring-teal-400/30",
|
||||
accentBar: "before:bg-teal-500/40",
|
||||
headerText: "text-teal-800 dark:text-teal-200",
|
||||
avatarRing: "bg-syntax-string/15 text-syntax-string ring-1 ring-syntax-string/30",
|
||||
accentBar: "before:bg-syntax-string/40",
|
||||
headerText: "text-syntax-string",
|
||||
},
|
||||
system: {
|
||||
label: "System",
|
||||
icon: Cog,
|
||||
avatarRing:
|
||||
"bg-gradient-to-br from-slate-500/30 to-gray-500/20 text-fg-secondary ring-1 ring-border-light/30",
|
||||
avatarRing: "bg-surface-4 text-fg-secondary ring-1 ring-border-light/30",
|
||||
accentBar: "before:bg-surface-4/40",
|
||||
headerText: "text-fg-secondary",
|
||||
},
|
||||
tool: {
|
||||
label: "Tool",
|
||||
icon: Terminal,
|
||||
avatarRing:
|
||||
"bg-gradient-to-br from-status-warning/30 to-orange-500/20 text-status-warning ring-1 ring-status-warning/30",
|
||||
avatarRing: "bg-status-warning/15 text-status-warning ring-1 ring-status-warning/30",
|
||||
accentBar: "before:bg-status-warning/40",
|
||||
headerText: "text-status-warning",
|
||||
},
|
||||
@@ -242,7 +234,7 @@ function renderSegment(seg: TuiSegment, key: number): React.ReactNode {
|
||||
key={key}
|
||||
text={seg.text}
|
||||
icon={<AlertTriangle className="w-3.5 h-3.5 text-status-warning/70 flex-shrink-0" />}
|
||||
title="System reminder"
|
||||
title={i18n.t("sessions:conversation.systemReminder")}
|
||||
borderClass="border-status-warning/20"
|
||||
bgClass="bg-status-warning/5"
|
||||
textClass="text-status-warning/80"
|
||||
@@ -253,13 +245,11 @@ function renderSegment(seg: TuiSegment, key: number): React.ReactNode {
|
||||
<CollapsibleBlock
|
||||
key={key}
|
||||
text={seg.text}
|
||||
icon={
|
||||
<ScrollText className="w-3.5 h-3.5 text-violet-700/60 dark:text-violet-400/60 flex-shrink-0" />
|
||||
}
|
||||
title="Persisted output"
|
||||
borderClass="border-violet-500/20"
|
||||
bgClass="bg-violet-500/5"
|
||||
textClass="text-violet-700/80 dark:text-violet-300/80"
|
||||
icon={<ScrollText className="w-3.5 h-3.5 text-syntax-keyword/70 flex-shrink-0" />}
|
||||
title={i18n.t("sessions:conversation.persistedOutput")}
|
||||
borderClass="border-syntax-keyword/20"
|
||||
bgClass="bg-syntax-keyword/5"
|
||||
textClass="text-syntax-keyword"
|
||||
/>
|
||||
);
|
||||
case "text": {
|
||||
@@ -296,6 +286,7 @@ function CollapsibleBlock({
|
||||
<div className={`rounded-lg border ${borderClass} ${bgClass} overflow-hidden`}>
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
aria-expanded={expanded}
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-left hover:opacity-80 transition-colors"
|
||||
>
|
||||
{expanded ? (
|
||||
@@ -317,206 +308,227 @@ function CollapsibleBlock({
|
||||
);
|
||||
}
|
||||
|
||||
/** A key per message that survives prepend (older history) and append (live
|
||||
* tail): built from the message itself, with an occurrence counter for twins.
|
||||
* The index was the key before, so expanding a block and then loading older
|
||||
* messages moved the expansion onto another message. */
|
||||
export function messageKeys(messages: TranscriptMessage[]): string[] {
|
||||
const seen = new Map<string, number>();
|
||||
return messages.map((m) => {
|
||||
const first = m.content[0];
|
||||
const sig = first
|
||||
? `${first.type}:${first.id ?? (first.text ?? "").slice(0, 48)}`
|
||||
: (m.title ?? "");
|
||||
const base = `${m.timestamp ?? ""}|${m.type}|${m.sender ?? ""}|${sig}`;
|
||||
const n = seen.get(base) ?? 0;
|
||||
seen.set(base, n + 1);
|
||||
return n ? `${base}#${n}` : base;
|
||||
});
|
||||
}
|
||||
|
||||
export function MessageList({ messages, loading }: MessageListProps) {
|
||||
const [expandedThinking, setExpandedThinking] = useState<Set<number>>(() => new Set());
|
||||
const { t } = useTranslation("sessions");
|
||||
const toolResultMap = useMemo(() => buildToolResultMap(messages), [messages]);
|
||||
const keys = useMemo(() => messageKeys(messages), [messages]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12 text-fg-muted text-sm">
|
||||
Loading conversation...
|
||||
{t("conversation.loading")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (messages.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-12 text-fg-muted text-sm">No conversation records found.</div>
|
||||
);
|
||||
return <div className="text-center py-12 text-fg-muted text-sm">{t("conversation.empty")}</div>;
|
||||
}
|
||||
|
||||
const toolResultMap = buildToolResultMap(messages);
|
||||
|
||||
// Track which user messages are pure tool_result (no text) - we merge those into the preceding assistant message
|
||||
const userMsgHasText = useMemo(() => {
|
||||
const map = new Map<number, boolean>();
|
||||
messages.forEach((msg, idx) => {
|
||||
if (msg.type !== "user") return;
|
||||
const hasText = msg.content.some((c) => c.type === "text");
|
||||
map.set(idx, hasText);
|
||||
});
|
||||
return map;
|
||||
}, [messages]);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{messages.map((msg, idx) => {
|
||||
// Session lifecycle markers (e.g. /rename) render as a centered chip,
|
||||
// not as a user/assistant row.
|
||||
if (msg.type === "session_event") {
|
||||
return <SessionEventRow key={idx} title={msg.title} timestamp={msg.timestamp} />;
|
||||
return <SessionEventRow key={keys[idx]} title={msg.title} timestamp={msg.timestamp} />;
|
||||
}
|
||||
|
||||
// Skip user messages that are purely tool_result - they're rendered inside ToolCallBlock
|
||||
if (msg.type === "user" && !userMsgHasText.get(idx)) {
|
||||
if (msg.type === "user" && !msg.content.some((c) => c.type === "text")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isAssistant = msg.type === "assistant";
|
||||
// The true sender (classified server-side) drives the label + styling.
|
||||
// Falls back to the coarse type for older payloads without `sender`.
|
||||
const sender: TranscriptSender = msg.sender ?? (isAssistant ? "assistant" : "user");
|
||||
const style = SENDER_STYLES[sender] ?? SENDER_STYLES.user;
|
||||
const SenderIcon = style.icon;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className={`relative flex gap-3 rounded-xl px-3 py-2.5 hover:bg-surface-2/30 transition-colors before:absolute before:left-0 before:top-3 before:bottom-3 before:w-0.5 before:rounded-full before:opacity-60 ${style.accentBar}`}
|
||||
>
|
||||
{/* Avatar */}
|
||||
<div
|
||||
className={`flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center mt-0.5 shadow-sm ${style.avatarRing}`}
|
||||
>
|
||||
<SenderIcon className="w-4 h-4" />
|
||||
</div>
|
||||
|
||||
{/* Message body */}
|
||||
<div className="flex-1 min-w-0 space-y-2">
|
||||
{/* Header line */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className={`text-xs font-semibold tracking-wide ${style.headerText}`}>
|
||||
{style.label}
|
||||
</span>
|
||||
{msg.model && (
|
||||
<span className="text-[10px] text-fg-secondary font-mono bg-surface-3/60 border border-surface-3 rounded px-1.5 py-0.5">
|
||||
{formatModelName(msg.model)}
|
||||
</span>
|
||||
)}
|
||||
{msg.usage && (
|
||||
<span className="text-[10px] text-fg-muted font-mono inline-flex items-center gap-1">
|
||||
<span className="text-status-success/70">↓ {fmt(msg.usage.input_tokens)}</span>
|
||||
<span className="text-fg-muted">·</span>
|
||||
<span className="text-orange-700/70 dark:text-orange-300/70">
|
||||
↑ {fmt(msg.usage.output_tokens)}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{msg.timestamp && (
|
||||
<span className="text-[10px] text-fg-muted ml-auto font-mono">
|
||||
{formatLocalTime(msg.timestamp)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content blocks */}
|
||||
{msg.content.map((block, bIdx) => {
|
||||
if (block.type === "text" && block.text) {
|
||||
// Detect task notifications, collapsed by default
|
||||
if (isTaskNotification(block.text)) {
|
||||
return (
|
||||
<CollapsibleBlock
|
||||
key={bIdx}
|
||||
text={block.text}
|
||||
icon={
|
||||
<ScrollText className="w-3.5 h-3.5 text-cyan-700/60 dark:text-cyan-400/60 flex-shrink-0" />
|
||||
}
|
||||
title="Task Notification"
|
||||
borderClass="border-cyan-500/20"
|
||||
bgClass="bg-cyan-500/5"
|
||||
textClass="text-cyan-700/80 dark:text-cyan-400/80"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Detect skill content, collapsed by default
|
||||
if (isSkillContent(block.text)) {
|
||||
const pathMatch = block.text.match(/^Base directory for this skill:\s*(\S+)/);
|
||||
const skillPath = pathMatch ? pathMatch[1]! : "Skill";
|
||||
return (
|
||||
<CollapsibleBlock
|
||||
key={bIdx}
|
||||
text={block.text}
|
||||
icon={<ScrollText className="w-3.5 h-3.5 text-blue-500/60 flex-shrink-0" />}
|
||||
title={skillPath}
|
||||
borderClass="border-blue-600/20"
|
||||
bgClass="bg-blue-600/5"
|
||||
textClass="text-blue-500/80"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Mixed TUI markup: caveat / command / stdout / stderr / system-reminder
|
||||
// can appear inline (sometimes interleaved with prose). Parse the text
|
||||
// into segments and render each with the appropriate visual treatment.
|
||||
if (hasTuiTags(block.text)) {
|
||||
const segments = parseTuiSegments(block.text);
|
||||
return (
|
||||
<div key={bIdx} className="space-y-2 min-w-0">
|
||||
{segments.map((s, sIdx) => renderSegment(s, sIdx))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={bIdx} className="min-w-0">
|
||||
<MarkdownContent text={stripAnsi(block.text)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (block.type === "thinking" && block.text) {
|
||||
const thinkKey = idx * 100 + bIdx;
|
||||
const isExpanded = expandedThinking.has(thinkKey);
|
||||
return (
|
||||
<div
|
||||
key={bIdx}
|
||||
className="rounded-lg border border-status-warning/20 bg-status-warning/5 overflow-hidden"
|
||||
>
|
||||
<button
|
||||
onClick={() =>
|
||||
setExpandedThinking((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(thinkKey)) next.delete(thinkKey);
|
||||
else next.add(thinkKey);
|
||||
return next;
|
||||
})
|
||||
}
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-left hover:bg-status-warning/10 transition-colors"
|
||||
>
|
||||
<ChevronRight
|
||||
className={`w-3.5 h-3.5 text-status-warning/60 transition-transform duration-150 ${
|
||||
isExpanded ? "rotate-90" : ""
|
||||
}`}
|
||||
/>
|
||||
<Brain className="w-3.5 h-3.5 text-status-warning/80" />
|
||||
<span className="text-xs text-status-warning/90 font-medium">Thinking</span>
|
||||
{!isExpanded && (
|
||||
<span className="text-[10px] text-status-warning/40 font-mono ml-auto">
|
||||
{block.text.length.toLocaleString()} chars
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{isExpanded && (
|
||||
<div className="border-t border-status-warning/10 px-3 py-2 text-status-warning/80">
|
||||
<MarkdownContent text={block.text} dense />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (block.type === "tool_use") {
|
||||
const matchedResult = block.id ? (toolResultMap.get(block.id) ?? null) : null;
|
||||
return <ToolCallBlock key={bIdx} toolUse={block} toolResult={matchedResult} />;
|
||||
}
|
||||
|
||||
// tool_result blocks rendered inside ToolCallBlock, skip standalone
|
||||
return null;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <MessageRow key={keys[idx]} msg={msg} toolResultMap={toolResultMap} />;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** One user/assistant row. Memoized: a live tail append re-renders only the
|
||||
* new rows; its thinking-block expansion is its own state, kept by key. */
|
||||
const MessageRow = memo(function MessageRow({
|
||||
msg,
|
||||
toolResultMap,
|
||||
}: {
|
||||
msg: TranscriptMessage;
|
||||
toolResultMap: ReturnType<typeof buildToolResultMap>;
|
||||
}) {
|
||||
const { t } = useTranslation("sessions");
|
||||
const [expandedThinking, setExpandedThinking] = useState<Set<number>>(() => new Set());
|
||||
const isAssistant = msg.type === "assistant";
|
||||
// The true sender (classified server-side) drives the label + styling.
|
||||
// Falls back to the coarse type for older payloads without `sender`.
|
||||
const sender: TranscriptSender = msg.sender ?? (isAssistant ? "assistant" : "user");
|
||||
const style = SENDER_STYLES[sender] ?? SENDER_STYLES.user;
|
||||
const SenderIcon = style.icon;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ contentVisibility: "auto", containIntrinsicSize: "auto 120px" }}
|
||||
className={`relative flex gap-3 rounded-xl px-3 py-2.5 hover:bg-surface-2/30 transition-colors before:absolute before:left-0 before:top-3 before:bottom-3 before:w-0.5 before:rounded-full before:opacity-60 ${style.accentBar}`}
|
||||
>
|
||||
{/* Avatar */}
|
||||
<div
|
||||
className={`flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center mt-0.5 shadow-sm ${style.avatarRing}`}
|
||||
>
|
||||
<SenderIcon className="w-4 h-4" />
|
||||
</div>
|
||||
|
||||
{/* Message body */}
|
||||
<div className="flex-1 min-w-0 space-y-2">
|
||||
{/* Header line */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className={`text-xs font-semibold tracking-wide ${style.headerText}`}>
|
||||
{t(`conversation.sender.${sender}`)}
|
||||
</span>
|
||||
{msg.model && (
|
||||
<span className="text-[10px] text-fg-secondary font-mono bg-surface-3/60 border border-surface-3 rounded px-1.5 py-0.5">
|
||||
{formatModelName(msg.model)}
|
||||
</span>
|
||||
)}
|
||||
{msg.usage && (
|
||||
<span className="text-[10px] text-fg-muted font-mono inline-flex items-center gap-1">
|
||||
<span className="text-status-success/70">↓ {fmt(msg.usage.input_tokens)}</span>
|
||||
<span className="text-fg-muted">·</span>
|
||||
<span className="text-syntax-number">↑ {fmt(msg.usage.output_tokens)}</span>
|
||||
</span>
|
||||
)}
|
||||
{msg.timestamp && (
|
||||
<span className="text-[10px] text-fg-muted ml-auto font-mono">
|
||||
{formatLocalTime(msg.timestamp)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content blocks */}
|
||||
{msg.content.map((block, bIdx) => {
|
||||
if (block.type === "text" && block.text) {
|
||||
// Detect task notifications, collapsed by default
|
||||
if (isTaskNotification(block.text)) {
|
||||
return (
|
||||
<CollapsibleBlock
|
||||
key={bIdx}
|
||||
text={block.text}
|
||||
icon={
|
||||
<ScrollText className="w-3.5 h-3.5 text-syntax-property/70 flex-shrink-0" />
|
||||
}
|
||||
title={t("conversation.taskNotification")}
|
||||
borderClass="border-syntax-property/20"
|
||||
bgClass="bg-syntax-property/5"
|
||||
textClass="text-syntax-property"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Detect skill content, collapsed by default
|
||||
if (isSkillContent(block.text)) {
|
||||
const pathMatch = block.text.match(/^Base directory for this skill:\s*(\S+)/);
|
||||
const skillPath = pathMatch ? pathMatch[1]! : "Skill";
|
||||
return (
|
||||
<CollapsibleBlock
|
||||
key={bIdx}
|
||||
text={block.text}
|
||||
icon={<ScrollText className="w-3.5 h-3.5 text-syntax-builtin/70 flex-shrink-0" />}
|
||||
title={skillPath}
|
||||
borderClass="border-syntax-builtin/20"
|
||||
bgClass="bg-syntax-builtin/5"
|
||||
textClass="text-syntax-builtin"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Mixed TUI markup: caveat / command / stdout / stderr / system-reminder
|
||||
// can appear inline (sometimes interleaved with prose). Parse the text
|
||||
// into segments and render each with the appropriate visual treatment.
|
||||
if (hasTuiTags(block.text)) {
|
||||
const segments = parseTuiSegments(block.text);
|
||||
return (
|
||||
<div key={bIdx} className="space-y-2 min-w-0">
|
||||
{segments.map((s, sIdx) => renderSegment(s, sIdx))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={bIdx} className="min-w-0">
|
||||
<MarkdownContent text={stripAnsi(block.text)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (block.type === "thinking" && block.text) {
|
||||
const thinkKey = bIdx;
|
||||
const isExpanded = expandedThinking.has(thinkKey);
|
||||
return (
|
||||
<div
|
||||
key={bIdx}
|
||||
className="rounded-lg border border-status-warning/20 bg-status-warning/5 overflow-hidden"
|
||||
>
|
||||
<button
|
||||
aria-expanded={isExpanded}
|
||||
onClick={() =>
|
||||
setExpandedThinking((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(thinkKey)) next.delete(thinkKey);
|
||||
else next.add(thinkKey);
|
||||
return next;
|
||||
})
|
||||
}
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-left hover:bg-status-warning/10 transition-colors"
|
||||
>
|
||||
<ChevronRight
|
||||
className={`w-3.5 h-3.5 text-status-warning/60 transition-transform duration-150 ${
|
||||
isExpanded ? "rotate-90" : ""
|
||||
}`}
|
||||
/>
|
||||
<Brain className="w-3.5 h-3.5 text-status-warning/80" />
|
||||
<span className="text-xs text-status-warning/90 font-medium">
|
||||
{t("conversation.thinking")}
|
||||
</span>
|
||||
{!isExpanded && (
|
||||
<span className="text-[10px] text-status-warning/40 font-mono ml-auto">
|
||||
{t("conversation.chars", {
|
||||
count: block.text.length,
|
||||
formatted: block.text.length.toLocaleString(),
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{isExpanded && (
|
||||
<div className="border-t border-status-warning/10 px-3 py-2 text-status-warning/80">
|
||||
<MarkdownContent text={block.text} dense />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (block.type === "tool_use") {
|
||||
const matchedResult = block.id ? (toolResultMap.get(block.id) ?? null) : null;
|
||||
return <ToolCallBlock key={bIdx} toolUse={block} toolResult={matchedResult} />;
|
||||
}
|
||||
|
||||
// tool_result blocks rendered inside ToolCallBlock, skip standalone
|
||||
return null;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user