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:
2026-10-05 13:22:04 +07:00
parent 8fb703c6db
commit 03f1673fce
63 changed files with 3879 additions and 1662 deletions
@@ -48,6 +48,8 @@
* ----------------------------------------------------------------------------- */
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { copyText } from "../../lib/clipboard";
import { Check, Copy, FileCode } from "lucide-react";
import { canonicalLang, highlight, tokenClass, type Token } from "../../lib/highlight";
@@ -122,14 +124,13 @@ export function CodeBlock({
const lineTokens = useMemo(() => splitTokensByLine(tokens), [tokens]);
const totalLines = lineTokens.length;
const gutter = showLineNumbers ?? totalLines >= 4;
const { t } = useTranslation("sessions");
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(code);
// copyText reports a failure itself (non-secure origin, no permission).
if (await copyText(code, true)) {
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
} catch {
// Clipboard may be unavailable in some contexts - fail silently.
}
};
@@ -181,7 +182,7 @@ export function CodeBlock({
<div className="ml-auto flex items-center gap-3">
{totalLines > 1 && (
<span className="text-fg-muted font-mono">
{totalLines} {totalLines === 1 ? "line" : "lines"}
{t("conversation.lines", { count: totalLines })}
</span>
)}
<button
@@ -190,15 +191,15 @@ export function CodeBlock({
className={`inline-flex items-center gap-1 transition-colors ${
copied ? "text-status-success" : "text-fg-muted hover:text-fg-secondary"
}`}
aria-label="Copy code"
aria-label={t("conversation.copyCode")}
>
{copied ? (
<>
<Check className="w-3 h-3" /> Copied
<Check className="w-3 h-3" /> {t("common:copied")}
</>
) : (
<>
<Copy className="w-3 h-3" /> Copy
<Copy className="w-3 h-3" /> {t("conversation.copy")}
</>
)}
</button>
@@ -54,11 +54,12 @@
* ----------------------------------------------------------------------------- */
import { useEffect, useState, useCallback, useRef } from "react";
import { ChevronDown, Loader2, ArrowDown, MessagesSquare, RefreshCw } from "lucide-react";
import { Loader2, ArrowDown, MessagesSquare, RefreshCw } from "lucide-react";
import { api } from "../../lib/api";
import { eventBus } from "../../lib/eventBus";
import { isRemoteDataRefreshMessage } from "../../lib/remoteDataEvents";
import { MessageList } from "./MessageList";
import { useTranslation } from "react-i18next";
import type { TranscriptMessage, TranscriptInfo, WSMessage } from "../../lib/types";
// Catch-up poll interval. Claude Code only fires hooks on PreToolUse /
@@ -77,6 +78,7 @@ interface ConversationViewProps {
}
export function ConversationView({ sessionId, initialTranscriptId }: ConversationViewProps) {
const { t } = useTranslation("sessions");
const [messages, setMessages] = useState<TranscriptMessage[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
@@ -99,6 +101,9 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
// manual refresh), we queue exactly one re-fetch so events that landed
// during the in-flight request aren't silently dropped.
const pendingFetchRef = useRef(false);
// Bumped whenever the session or the selected transcript changes: a tail
// fetch started for the previous transcript must not append to the new one.
const genRef = useRef(0);
// Refresh-button spinner state - separate from initial `loading` so the
// existing skeleton doesn't blink during a manual refresh.
const [refreshing, setRefreshing] = useState(false);
@@ -117,7 +122,9 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
}
}
loadTranscripts();
const interval = window.setInterval(loadTranscripts, TRANSCRIPTS_REFRESH_MS);
const interval = window.setInterval(() => {
if (document.visibilityState === "visible") loadTranscripts();
}, TRANSCRIPTS_REFRESH_MS);
return () => {
cancelled = true;
window.clearInterval(interval);
@@ -134,6 +141,9 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
// Initial load: fetch the latest N messages
useEffect(() => {
let cancelled = false;
genRef.current++;
lastLineRef.current = 0;
firstLineRef.current = 0;
async function load() {
try {
@@ -152,7 +162,7 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
firstLineRef.current = result.first_line;
} catch (err) {
if (cancelled) return;
setError(err instanceof Error ? err.message : "Failed to load transcript");
setError(err instanceof Error ? err.message : t("conversation.failedLoad"));
setMessages([]);
setTotal(0);
} finally {
@@ -184,12 +194,14 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
pendingFetchRef.current = false;
const wasBootstrap = lastLineRef.current === 0;
const gen = genRef.current;
try {
const result = await api.sessions.transcript(sessionId, {
agent_id: selectedTranscript || undefined,
...(wasBootstrap ? {} : { after: lastLineRef.current }),
limit: 50,
});
if (gen !== genRef.current) return; // another transcript is shown now
if (result.messages.length === 0) return;
lastLineRef.current = result.last_line;
@@ -393,31 +405,31 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
<select
value={selectedTranscript || ""}
onChange={(e) => setSelectedTranscript(e.target.value || null)}
className="appearance-none bg-surface-2 border border-surface-3 rounded-lg px-3 py-1.5 pr-8 text-sm text-fg-secondary focus:outline-none focus:border-violet-500/50 hover:border-violet-500/30 cursor-pointer transition-colors"
aria-label={t("conversation.transcript")}
className="select py-1.5"
>
{transcripts.map((t) => (
<option key={t.id} value={t.id}>
{t.name}
{transcripts.map((tr) => (
<option key={tr.id} value={tr.id}>
{tr.name}
</option>
))}
</select>
<ChevronDown className="w-3.5 h-3.5 text-fg-muted absolute right-2.5 top-1/2 -translate-y-1/2 pointer-events-none" />
</div>
)}
<span className="inline-flex items-center gap-1.5 text-[11px] text-fg-muted font-mono bg-surface-2 border border-surface-3 rounded-md px-2 py-1">
<MessagesSquare className="w-3 h-3" />
{total} message{total !== 1 ? "s" : ""}
{t("conversation.messages", { count: total })}
</span>
<button
type="button"
onClick={refresh}
disabled={refreshing || loading}
title="Refresh conversation"
aria-label="Refresh conversation"
className="inline-flex items-center gap-1.5 text-[11px] text-fg-secondary hover:text-fg-primary bg-surface-2 border border-surface-3 hover:border-violet-500/30 rounded-md px-2 py-1 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title={t("conversation.refresh")}
aria-label={t("conversation.refresh")}
className="inline-flex items-center gap-1.5 text-[11px] text-fg-secondary hover:text-fg-primary bg-surface-2 border border-surface-3 hover:border-accent/30 rounded-md px-2 py-1 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<RefreshCw className={`w-3 h-3 ${refreshing ? "animate-spin" : ""}`} />
Refresh
{t("common:refresh")}
</button>
</div>
)}
@@ -440,30 +452,26 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
{loadingHistory && (
<div className="flex justify-center py-3">
<Loader2 className="w-4 h-4 text-fg-muted animate-spin" />
<span className="text-xs text-fg-muted ml-2">Loading history...</span>
<span className="text-xs text-fg-muted ml-2">{t("conversation.loadingHistory")}</span>
</div>
)}
{/* Scroll-up for history hint */}
{hasMore && !loadingHistory && !loading && (
<div className="flex justify-center py-2">
<span className="text-[11px] text-fg-muted">↑ Scroll up for older messages</span>
<span className="text-[11px] text-fg-muted">↑ {t("conversation.scrollForOlder")}</span>
</div>
)}
{loading ? (
<div className="flex items-center justify-center py-12 text-fg-muted text-sm">
Loading conversation...
{t("conversation.loading")}
</div>
) : messages.length === 0 ? (
<div className="mx-auto max-w-md py-12 text-center">
<p className="text-sm text-fg-secondary">No conversation records found.</p>
<p className="text-sm text-fg-secondary">{t("conversation.empty")}</p>
<p className="mt-2 text-xs leading-relaxed text-fg-muted">
This session's metadata was imported, but its transcript file is no longer on disk.
Claude Code automatically deletes inactive session transcripts after a retention
period (<code className="text-fg-secondary">cleanupPeriodDays</code>, default 30
days), so older conversations may already be gone. Sessions imported from now on are
snapshotted and kept even after Claude Code prunes the originals.
{t("conversation.emptyBody")}
</p>
</div>
) : (
@@ -478,10 +486,10 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
scrollToBottom();
setShowNewMsg(false);
}}
className="absolute bottom-4 left-1/2 -translate-x-1/2 flex items-center gap-1.5 bg-violet-600 hover:bg-violet-500 text-white text-xs font-medium px-3 py-1.5 rounded-full shadow-lg transition-colors z-10"
className="absolute bottom-4 left-1/2 -translate-x-1/2 flex items-center gap-1.5 bg-accent-solid hover:bg-accent-solid-hover text-white text-xs font-medium px-3 py-1.5 rounded-full shadow-lg transition-colors z-10"
>
<ArrowDown className="w-3 h-3" />
New messages
{t("conversation.newMessages")}
</button>
)}
</div>
+220 -208
View File
@@ -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>
);
});
@@ -51,6 +51,7 @@
*
* ----------------------------------------------------------------------------- */
import i18n from "../../i18n";
import { useState } from "react";
import { ChevronRight, AlertCircle, FileText, CheckCircle2 } from "lucide-react";
import type { TranscriptContent } from "../../lib/types";
@@ -107,6 +108,9 @@ function buildSummary(toolUse: TranscriptContent): string | null {
return null;
}
/** A translated label for the tool panes (plain functions, so i18n directly). */
const tt = (key: string) => i18n.t(`sessions:conversation.tool.${key}`);
/** Render the input pane with tool-aware formatting. */
function renderInput(toolUse: TranscriptContent) {
const input = toolUse.input;
@@ -118,7 +122,7 @@ function renderInput(toolUse: TranscriptContent) {
<CodeBlock
code={String((input as { _truncated: string })._truncated)}
lang="plain"
label="Input (truncated)"
label={tt("inputTruncated")}
/>
);
}
@@ -130,7 +134,7 @@ function renderInput(toolUse: TranscriptContent) {
if (tool === "bash" && typeof obj.command === "string") {
return (
<div className="space-y-2">
<CodeBlock code={obj.command} lang="bash" label="Command" />
<CodeBlock code={obj.command} lang="bash" label={tt("command")} />
{typeof obj.description === "string" && (
<p className="text-xs text-fg-muted italic px-1">{obj.description}</p>
)}
@@ -145,7 +149,7 @@ function renderInput(toolUse: TranscriptContent) {
code={obj.content}
lang={langFromPath(obj.file_path)}
filename={obj.file_path}
label="New file"
label={tt("newFile")}
/>
);
}
@@ -156,19 +160,19 @@ function renderInput(toolUse: TranscriptContent) {
return (
<div className="space-y-2">
<div className="flex items-center gap-1.5 text-xs text-fg-secondary">
<FileText className="w-3.5 h-3.5 text-violet-700 dark:text-violet-400" />
<FileText className="w-3.5 h-3.5 text-syntax-keyword" />
<span className="font-mono">{obj.file_path}</span>
{obj.replace_all === true && (
<span className="text-[10px] uppercase tracking-wider text-status-warning/80 bg-status-warning/10 border border-status-warning/20 rounded px-1.5 py-0.5">
replace all
{tt("replaceAll")}
</span>
)}
</div>
{typeof obj.old_string === "string" && (
<CodeBlock code={obj.old_string} lang={lang} label="Removed" tone="danger" />
<CodeBlock code={obj.old_string} lang={lang} label={tt("removed")} tone="danger" />
)}
{typeof obj.new_string === "string" && (
<CodeBlock code={obj.new_string} lang={lang} label="Added" tone="success" />
<CodeBlock code={obj.new_string} lang={lang} label={tt("added")} tone="success" />
)}
</div>
);
@@ -178,7 +182,7 @@ function renderInput(toolUse: TranscriptContent) {
if (tool === "read" && typeof obj.file_path === "string") {
return (
<div className="flex items-center gap-1.5 text-xs text-fg-secondary bg-surface-4/40 border border-surface-3 rounded-md px-3 py-2">
<FileText className="w-3.5 h-3.5 text-sky-700 dark:text-sky-400 flex-shrink-0" />
<FileText className="w-3.5 h-3.5 text-syntax-builtin flex-shrink-0" />
<span className="font-mono break-all">{obj.file_path}</span>
{(typeof obj.offset === "number" || typeof obj.limit === "number") && (
<span className="text-fg-muted font-mono ml-auto flex-shrink-0">
@@ -196,16 +200,16 @@ function renderInput(toolUse: TranscriptContent) {
<div className="space-y-1.5">
<div className="flex items-center gap-2 text-xs">
<span className="text-fg-muted font-mono uppercase tracking-wider text-[10px]">
Pattern
{tt("pattern")}
</span>
<code className="font-mono text-cyan-700 dark:text-cyan-300 bg-surface-4 border border-surface-3 rounded px-1.5 py-0.5">
<code className="font-mono text-syntax-property bg-surface-4 border border-surface-3 rounded px-1.5 py-0.5">
{obj.pattern}
</code>
</div>
{typeof obj.path === "string" && (
<div className="flex items-center gap-2 text-xs">
<span className="text-fg-muted font-mono uppercase tracking-wider text-[10px]">
Path
{tt("path")}
</span>
<code className="font-mono text-fg-secondary">{obj.path}</code>
</div>
@@ -213,7 +217,7 @@ function renderInput(toolUse: TranscriptContent) {
{typeof obj.glob === "string" && (
<div className="flex items-center gap-2 text-xs">
<span className="text-fg-muted font-mono uppercase tracking-wider text-[10px]">
Glob
{tt("glob")}
</span>
<code className="font-mono text-fg-secondary">{obj.glob}</code>
</div>
@@ -223,17 +227,18 @@ function renderInput(toolUse: TranscriptContent) {
}
// Default: pretty JSON
return <CodeBlock code={JSON.stringify(obj, null, 2)} lang="json" label="Input" />;
return <CodeBlock code={JSON.stringify(obj, null, 2)} lang="json" label={tt("input")} />;
}
/** Render the result pane: detect diff/json/text. */
function renderResult(toolResult: TranscriptContent, toolName: string) {
const text = toolResult.output ?? "";
if (text.length === 0) return <div className="text-xs text-fg-muted italic px-1">(empty)</div>;
if (text.length === 0)
return <div className="text-xs text-fg-muted italic px-1">({tt("empty")})</div>;
const isError = !!toolResult.is_error;
const tool = toolName.toLowerCase();
const label = isError ? "Error" : "Output";
const label = isError ? tt("error") : tt("output");
// Heuristics for language
let lang = "plain";
@@ -273,6 +278,7 @@ export function ToolCallBlock({ toolUse, toolResult }: ToolCallBlockProps) {
{/* Collapsed/expanded toggle */}
<button
onClick={() => setExpanded(!expanded)}
aria-expanded={expanded}
className="w-full flex items-center gap-2.5 px-3 py-2 text-left hover:bg-surface-3/40 transition-colors"
>
<ChevronRight
@@ -297,16 +303,16 @@ export function ToolCallBlock({ toolUse, toolResult }: ToolCallBlockProps) {
{isError ? (
<span className="inline-flex items-center gap-1 text-[10px] uppercase tracking-wider text-status-danger bg-status-danger/15 border border-status-danger/20 rounded px-1.5 py-0.5">
<AlertCircle className="w-3 h-3" />
error
{tt("statusError")}
</span>
) : hasResult ? (
<span className="inline-flex items-center gap-1 text-[10px] uppercase tracking-wider text-status-success/80 bg-status-success/10 border border-status-success/20 rounded px-1.5 py-0.5">
<CheckCircle2 className="w-3 h-3" />
ok
{tt("statusOk")}
</span>
) : (
<span className="text-fg-muted text-[10px] uppercase tracking-wider font-mono">
pending
{tt("statusPending")}
</span>
)}
</span>
@@ -0,0 +1,31 @@
/**
* @file MessageList.keys.test.ts
* @description Message keys survive older history being prepended (the index
* key moved expansion state onto another message).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect } from "vitest";
import { messageKeys } from "../MessageList";
import type { TranscriptMessage } from "../../../lib/types";
const msg = (ts: string, text: string): TranscriptMessage => ({
type: "assistant",
timestamp: ts,
content: [{ type: "text", text }],
});
describe("messageKeys", () => {
it("keeps each message's key when older messages are prepended", () => {
const tail = [msg("2026-01-01T00:00:02Z", "b"), msg("2026-01-01T00:00:03Z", "c")];
const before = messageKeys(tail);
const after = messageKeys([msg("2026-01-01T00:00:01Z", "a"), ...tail]);
expect(after.slice(1)).toEqual(before);
});
it("gives identical twins distinct keys", () => {
const m = msg("2026-01-01T00:00:01Z", "same");
const keys = messageKeys([m, { ...m }]);
expect(new Set(keys).size).toBe(2);
});
});