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
+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>
);
});