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:
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user