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
@@ -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>