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 -32
View File
@@ -49,7 +49,8 @@
import { useTranslation } from "react-i18next";
import { Bot, GitBranch, Clock, Wrench, Cpu, Coins } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { memo, useMemo } from "react";
import { Link, useNavigate } from "react-router-dom";
import { AgentStatusBadge } from "./StatusBadge";
import { useContextMenu } from "./ContextMenu";
import { SessionRowMenu } from "./SessionRowMenu";
@@ -82,7 +83,14 @@ interface AgentCardProps {
onClick?: () => void;
}
export function AgentCard({ agent, session, label, onClick }: AgentCardProps) {
/** Memoized: the Dashboard re-renders on every live update, and a card only
* changes with its own agent/session. */
export const AgentCard = memo(function AgentCard({
agent,
session,
label,
onClick,
}: AgentCardProps) {
const navigate = useNavigate();
const { t } = useTranslation("kanban");
const ctx = useContextMenu<true>();
@@ -119,15 +127,15 @@ export function AgentCard({ agent, session, label, onClick }: AgentCardProps) {
// not the parent session's — see issue #185). Use it everywhere this card
// shows a model so a Haiku QA agent under an Opus orchestrator reads as
// Haiku, not Opus. Falls back to the session model only for the main agent.
let subagentModel: string | null = null;
if (!isMain && agent.metadata) {
const subagentModel = useMemo(() => {
if (isMain || !agent.metadata) return null;
try {
const parsed = JSON.parse(agent.metadata) as { model?: string };
subagentModel = parsed?.model ? formatModelName(parsed.model) : null;
return parsed?.model ? formatModelName(parsed.model) : null;
} catch {
subagentModel = null;
return null;
}
}
}, [isMain, agent.metadata]);
// The model badge (footer) must reflect THIS card's agent: the session model
// for main, the subagent's own model for subagents.
const displayModel = isMain ? model : subagentModel;
@@ -143,15 +151,15 @@ export function AgentCard({ agent, session, label, onClick }: AgentCardProps) {
// "29 agents" looks like it should equal a 29-subagent stat when the session
// actually has 28 subagents + 1 main.
const subagentCount = Math.max(0, agentCount - 1);
let sessionTurns = 0;
if (isMain && session?.metadata) {
const sessionTurns = useMemo(() => {
if (!isMain || !session?.metadata) return 0;
try {
const m = JSON.parse(session.metadata) as { turn_count?: number };
if (typeof m?.turn_count === "number") sessionTurns = m.turn_count;
return typeof m?.turn_count === "number" ? m.turn_count : 0;
} catch {
sessionTurns = 0;
return 0;
}
}
}, [isMain, session?.metadata]);
const subtitle = isMain
? [
cwdBase,
@@ -162,13 +170,17 @@ export function AgentCard({ agent, session, label, onClick }: AgentCardProps) {
.join(" · ") || null
: [label || agent.subagent_type, cwdBase].filter(Boolean).join(" · ") || null;
function handleClick() {
if (onClick) {
onClick();
} else {
navigate(`/sessions/${agent.session_id}`);
}
const sessionHref = `/sessions/${agent.session_id}`;
// The whole card is clickable with the mouse; the title is the focusable
// control (a real link, or a button when the caller handles the click).
function handleClick(e: React.MouseEvent) {
if ((e.target as HTMLElement).closest("a,button")) return;
if (onClick) onClick();
else navigate(sessionHref);
}
const titleText = isMain ? mainAgentDisplayName(agent.name, realSessionName) : agent.name;
const titleClass =
"block text-sm font-medium text-fg-secondary truncate text-left max-w-full rounded focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent";
return (
<div
@@ -176,9 +188,9 @@ export function AgentCard({ agent, session, label, onClick }: AgentCardProps) {
onContextMenu={(e) => ctx.open(e, true)}
className={`card-hover p-4 cursor-pointer overflow-hidden ${
isWaiting
? "border-l-2 border-l-yellow-500/60"
? "border-l-2 border-l-status-warning/60"
: isActive
? "border-l-2 border-l-emerald-500/50"
? "border-l-2 border-l-status-success/50"
: ""
}`}
>
@@ -186,21 +198,25 @@ export function AgentCard({ agent, session, label, onClick }: AgentCardProps) {
<div className="flex items-center gap-2.5 min-w-0 overflow-hidden">
<div
className={`w-7 h-7 rounded-md flex items-center justify-center flex-shrink-0 ${
isMain
? "bg-accent/15 text-accent"
: "bg-violet-500/15 text-violet-700 dark:text-violet-400"
isMain ? "bg-accent/15 text-accent" : "bg-syntax-keyword/15 text-syntax-keyword"
}`}
>
{isMain ? <Bot className="w-3.5 h-3.5" /> : <GitBranch className="w-3.5 h-3.5" />}
</div>
<div className="min-w-0 overflow-hidden">
<p className="text-sm font-medium text-fg-secondary truncate">
{/* Auto-generated main-agent titles (e.g. "Main Agent - Session
229d93fd" or "Main Agent - work - e3f8e613") swap the
placeholder for the real session name when one exists; custom
(sub)agent names are left untouched. */}
{isMain ? mainAgentDisplayName(agent.name, realSessionName) : agent.name}
</p>
{/* Auto-generated main-agent titles (e.g. "Main Agent - Session
229d93fd" or "Main Agent - work - e3f8e613") swap the
placeholder for the real session name when one exists; custom
(sub)agent names are left untouched. */}
{onClick ? (
<button type="button" onClick={onClick} className={titleClass}>
{titleText}
</button>
) : (
<Link to={sessionHref} className={titleClass}>
{titleText}
</Link>
)}
{subtitle && <p className="text-[11px] text-fg-muted truncate">{subtitle}</p>}
</div>
</div>
@@ -251,7 +267,7 @@ export function AgentCard({ agent, session, label, onClick }: AgentCardProps) {
{timeAgo(agent.updated_at || agent.started_at)}
</span>
)}
<span className="ml-auto flex items-center gap-1 min-w-0 opacity-50">
<span className="ml-auto flex items-center gap-1 min-w-0">
{realSessionName && <span className="truncate max-w-[10rem]">{realSessionName} ·</span>}
<span className="font-mono flex-shrink-0">{agent.session_id.slice(0, 8)}</span>
</span>
@@ -266,4 +282,4 @@ export function AgentCard({ agent, session, label, onClick }: AgentCardProps) {
)}
</div>
);
}
});
+59 -17
View File
@@ -43,6 +43,7 @@
* ----------------------------------------------------------------------------- */
import React, { useState, useRef, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Calendar, Clock, ChevronLeft, ChevronRight, X } from "lucide-react";
interface DateTimePickerProps {
@@ -52,16 +53,22 @@ interface DateTimePickerProps {
className?: string;
"aria-label"?: string;
title?: string;
/** A "to" bound: picking a day with no time yet means the END of that day
* (23:59), not its first minute. */
endOfDay?: boolean;
}
export function DateTimePicker({
value,
onChange,
placeholder = "Select date & time",
placeholder,
className = "",
"aria-label": ariaLabel,
title,
endOfDay = false,
}: DateTimePickerProps) {
const { t, i18n } = useTranslation("sessions");
const locale = i18n.resolvedLanguage ?? i18n.language;
const [isOpen, setIsOpen] = useState(false);
const [alignment, setAlignment] = useState<"left" | "right">("left");
const containerRef = useRef<HTMLDivElement>(null);
@@ -97,9 +104,18 @@ export function DateTimePicker({
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
useEffect(() => {
if (!isOpen) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setIsOpen(false);
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [isOpen]);
const formatDisplay = (d: Date | null) => {
if (!d || isNaN(d.getTime())) return "";
return d.toLocaleString(undefined, {
return d.toLocaleString(locale, {
month: "short",
day: "numeric",
hour: "2-digit",
@@ -112,7 +128,9 @@ export function DateTimePicker({
if (dateObj && !isNaN(dateObj.getTime())) {
newDate.setHours(dateObj.getHours(), dateObj.getMinutes());
} else {
newDate.setHours(0, 0); // Default midnight
// Default: midnight, or 23:59 for an end bound so the day is included.
if (endOfDay) newDate.setHours(23, 59, 59, 999);
else newDate.setHours(0, 0);
}
updateValue(newDate);
};
@@ -135,7 +153,9 @@ export function DateTimePicker({
const day = String(d.getDate()).padStart(2, "0");
const h = String(d.getHours()).padStart(2, "0");
const mi = String(d.getMinutes()).padStart(2, "0");
onChange(`${y}-${mo}-${day}T${h}:${mi}`);
// An end bound carries its :59.999 so the whole last minute is included.
const tail = endOfDay ? ":59.999" : "";
onChange(`${y}-${mo}-${day}T${h}:${mi}${tail}`);
};
const clearValue = (e: React.MouseEvent) => {
@@ -168,10 +188,15 @@ export function DateTimePicker({
key={i}
type="button"
onClick={() => handleDateClick(i)}
aria-label={new Date(viewDate.getFullYear(), viewDate.getMonth(), i).toLocaleDateString(
locale,
{ weekday: "long", year: "numeric", month: "long", day: "numeric" }
)}
aria-pressed={isSelected}
className={`w-6 h-6 flex items-center justify-center rounded text-[11px] transition-colors
${
isSelected
? "bg-accent text-white font-medium"
? "bg-accent-solid text-white font-medium"
: isToday
? "bg-surface-3 text-accent font-medium"
: "hover:bg-surface-2 text-fg-secondary hover:text-fg-primary"
@@ -193,20 +218,27 @@ export function DateTimePicker({
type="button"
onClick={() => setIsOpen(!isOpen)}
aria-label={ariaLabel}
aria-expanded={isOpen}
aria-haspopup="dialog"
title={title}
className={`flex items-center gap-2 bg-surface-2 border ${isOpen ? "border-accent" : "border-border"} rounded px-2 py-1.5 min-w-[150px] text-xs focus:outline-none focus:border-accent transition-colors w-full text-left`}
className={`flex items-center gap-2 bg-surface-2 border ${isOpen ? "border-accent" : "border-border"} rounded px-2 py-1.5 min-w-[150px] text-xs focus:outline-none focus:border-accent transition-colors w-full text-left ${dateObj ? "pr-7" : ""}`}
>
<Calendar className="w-3.5 h-3.5 text-fg-secondary shrink-0" />
<span className={`flex-1 truncate ${!dateObj ? "text-fg-muted" : "text-fg-secondary"}`}>
{dateObj ? formatDisplay(dateObj) : placeholder}
{dateObj ? formatDisplay(dateObj) : (placeholder ?? t("datePicker.placeholder"))}
</span>
{dateObj && (
<X
className="w-3 h-3 text-fg-muted hover:text-fg-primary shrink-0"
onClick={clearValue}
/>
)}
</button>
{/* A sibling, not a child: a button inside a button is not reachable by keyboard. */}
{dateObj && (
<button
type="button"
onClick={clearValue}
aria-label={t("datePicker.clear")}
className="absolute right-1.5 top-1/2 -translate-y-1/2 p-0.5 rounded text-fg-muted hover:text-fg-primary"
>
<X className="w-3 h-3" />
</button>
)}
{isOpen && (
<div
@@ -219,18 +251,20 @@ export function DateTimePicker({
onClick={() =>
setViewDate(new Date(viewDate.getFullYear(), viewDate.getMonth() - 1, 1))
}
aria-label={t("datePicker.prevMonth")}
className="p-1 hover:bg-surface-2 rounded text-fg-secondary hover:text-fg-primary"
>
<ChevronLeft className="w-4 h-4" />
</button>
<span className="text-xs font-medium text-fg-secondary">
{viewDate.toLocaleString(undefined, { month: "long", year: "numeric" })}
{viewDate.toLocaleString(locale, { month: "long", year: "numeric" })}
</span>
<button
type="button"
onClick={() =>
setViewDate(new Date(viewDate.getFullYear(), viewDate.getMonth() + 1, 1))
}
aria-label={t("datePicker.nextMonth")}
className="p-1 hover:bg-surface-2 rounded text-fg-secondary hover:text-fg-primary"
>
<ChevronRight className="w-4 h-4" />
@@ -240,8 +274,15 @@ export function DateTimePicker({
{/* Calendar Grid */}
<div>
<div className="grid grid-cols-7 gap-1 mb-1">
{["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"].map((day) => (
<div key={day} className="w-6 text-center text-[10px] font-medium text-fg-muted">
{/* 2026-01-04 is a Sunday: seven local days give the weekday initials. */}
{Array.from({ length: 7 }, (_, d) =>
new Date(2026, 0, 4 + d).toLocaleDateString(locale, { weekday: "narrow" })
).map((day, d) => (
<div
key={d}
aria-hidden
className="w-6 text-center text-[10px] font-medium text-fg-muted"
>
{day}
</div>
))}
@@ -253,10 +294,11 @@ export function DateTimePicker({
<div className="pt-3 border-t border-border flex items-center justify-between">
<div className="flex items-center gap-1.5 text-fg-secondary">
<Clock className="w-3.5 h-3.5" />
<span className="text-[11px] font-medium">Time</span>
<span className="text-[11px] font-medium">{t("datePicker.time")}</span>
</div>
<input
type="time"
aria-label={t("datePicker.time")}
value={timeValue}
onChange={handleTimeChange}
className="bg-surface-2 border border-border rounded px-2 py-1 text-xs text-fg-secondary focus:outline-none focus:border-accent w-[85px] time-input-custom"
+3 -3
View File
@@ -277,7 +277,7 @@ function SummaryBlock({
refs.length > 0 ? t("eventDetail.seeDetailsBelow", { fields: refs.join(" · ") }) : null;
return (
<div className="border border-border rounded overflow-hidden bg-surface-3/30">
<div className="px-3 py-1 border-b border-border bg-black/20">
<div className="px-3 py-1 border-b border-border bg-surface-1">
<span className="text-fg-muted text-[10px] uppercase tracking-wide font-semibold">
{t("eventDetail.summary")}
</span>
@@ -394,8 +394,8 @@ function CodeView({ value }: { value: unknown }) {
const text = typeof value === "string" ? value : safeStringify(value);
return (
<div className="relative bg-black/70 border border-border rounded font-mono text-[11px] overflow-hidden">
<div className="flex items-center justify-between px-3 py-1.5 border-b border-border bg-black/40">
<div className="relative bg-surface-0 border border-border rounded font-mono text-[11px] overflow-hidden">
<div className="flex items-center justify-between px-3 py-1.5 border-b border-border bg-surface-1">
<span className="text-fg-muted text-[10px] uppercase tracking-wide">
{typeof value === "string" ? "text" : Array.isArray(value) ? "array" : "json"}
</span>
+127 -6
View File
@@ -87,7 +87,7 @@
* ----------------------------------------------------------------------------- */
import { Checkbox } from "./Checkbox";
import { useEffect, useRef, useState } from "react";
import React, { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Search, X, Filter } from "lucide-react";
import { api } from "../lib/api";
@@ -115,6 +115,45 @@ export const EMPTY_FILTERS: EventFiltersValue = {
to: "",
};
/** URL key per filter field (short, so a shared link stays readable). */
const PARAM_OF: Record<keyof EventFiltersValue, string> = {
event_type: "et",
tool_name: "tn",
agent_id: "ag",
session_id: "sid",
status: "st",
q: "q",
from: "from",
to: "to",
};
/** Filters read from the URL (lists are comma-joined). */
export function filtersFromParams(params: URLSearchParams): EventFiltersValue {
const list = (k: string) => (params.get(k) ?? "").split(",").filter(Boolean);
return {
event_type: list(PARAM_OF.event_type),
tool_name: list(PARAM_OF.tool_name),
agent_id: list(PARAM_OF.agent_id),
session_id: list(PARAM_OF.session_id),
status: list(PARAM_OF.status),
q: params.get(PARAM_OF.q) ?? "",
from: params.get(PARAM_OF.from) ?? "",
to: params.get(PARAM_OF.to) ?? "",
};
}
/** A copy of `base` with the filter keys rewritten; empty filters are dropped. */
export function filtersToParams(f: EventFiltersValue, base: URLSearchParams): URLSearchParams {
const next = new URLSearchParams(base);
for (const [field, key] of Object.entries(PARAM_OF) as [keyof EventFiltersValue, string][]) {
const v = f[field];
const str = Array.isArray(v) ? v.join(",") : v;
if (str) next.set(key, str);
else next.delete(key);
}
return next;
}
export function isEmptyFilters(f: EventFiltersValue): boolean {
return (
f.event_type.length === 0 &&
@@ -180,13 +219,18 @@ export function EventFilters({
tool_names: [],
});
const [searchDraft, setSearchDraft] = useState(value.q);
// The debounced search merges into the LATEST value: a chip toggled during
// the 300 ms must not be reverted by the value captured at keystroke time.
const valueRef = useRef(value);
valueRef.current = value;
// Debounce text search - only push up after 300ms of inactivity.
const searchTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (searchTimer.current) clearTimeout(searchTimer.current);
searchTimer.current = setTimeout(() => {
if (searchDraft !== value.q) onChange({ ...value, q: searchDraft });
const latest = valueRef.current;
if (searchDraft !== latest.q) onChange({ ...latest, q: searchDraft });
}, 300);
return () => {
if (searchTimer.current) clearTimeout(searchTimer.current);
@@ -248,6 +292,7 @@ export function EventFilters({
/>
<span className="text-xs text-fg-muted">→</span>
<DateTimePicker
endOfDay
value={value.to}
onChange={(val: string) => onChange({ ...value, to: val })}
aria-label={t("eventFilters.to")}
@@ -301,11 +346,10 @@ export function EventFilters({
onToggle={(item) => toggle("agent_id", item)}
/>
)}
{!hideSessionFilter && sessionOptions && sessionOptions.length > 0 && (
<ChipGroup
{!hideSessionFilter && (
<SessionChipGroup
label={t("eventFilters.sessionId")}
options={sessionOptions.map((s) => s.id)}
labels={Object.fromEntries(sessionOptions.map((s) => [s.id, s.label]))}
known={sessionOptions}
selected={value.session_id}
onToggle={(item) => toggle("session_id", item)}
/>
@@ -315,22 +359,98 @@ export function EventFilters({
);
}
/**
* The session filter: searched on the server (`q`, 20 rows), never a list of
* every session. Selected ids stay listed (with the label last seen) so they
* can be unticked while the search shows other rows.
*/
function SessionChipGroup({
label,
known,
selected,
onToggle,
}: {
label: string;
known?: Array<{ id: string; label: string }>;
selected: string[];
onToggle: (item: string) => void;
}) {
const { t } = useTranslation("common");
const [query, setQuery] = useState("");
const [rows, setRows] = useState<Array<{ id: string; label: string }>>([]);
const labels = useRef(new Map<string, string>());
for (const k of known ?? []) labels.current.set(k.id, k.label);
const [open, setOpen] = useState(false);
const seq = useRef(0);
useEffect(() => {
if (!open) return;
const timer = setTimeout(
() => {
const mine = ++seq.current;
api.sessions
.list({ q: query.trim() || undefined, limit: 20 })
.then((res) => {
if (mine !== seq.current) return;
const next = res.sessions.map((s) => ({
id: s.id,
label: s.name?.trim() || s.id.slice(0, 8),
}));
for (const r of next) labels.current.set(r.id, r.label);
setRows(next);
})
.catch(() => undefined);
},
query ? 250 : 0
);
return () => clearTimeout(timer);
}, [open, query]);
const options = [...selected, ...rows.map((r) => r.id).filter((id) => !selected.includes(id))];
return (
<ChipGroup
label={label}
options={options}
labels={Object.fromEntries(options.map((id) => [id, labels.current.get(id) ?? id]))}
selected={selected}
onToggle={onToggle}
onOpenChange={setOpen}
search={
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t("eventFilters.searchPlaceholder")}
aria-label={label}
className="w-full mb-1 bg-surface-2 border border-border rounded px-2 py-1 text-[11px] text-fg-secondary focus:outline-none focus:border-accent"
/>
}
/>
);
}
function ChipGroup({
label,
options,
selected,
onToggle,
labels,
onOpenChange,
search,
}: {
label: string;
options: string[];
selected: string[];
onToggle: (item: string) => void;
labels?: Record<string, string>;
onOpenChange?: (open: boolean) => void;
/** Rendered above the options (a search box). */
search?: React.ReactNode;
}) {
const { t } = useTranslation("common");
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => onOpenChange?.(open), [open, onOpenChange]);
// Click-outside dismiss.
useEffect(() => {
@@ -368,6 +488,7 @@ function ChipGroup({
aria-label={label}
className="absolute left-0 mt-1 z-20 min-w-[220px] max-h-64 overflow-auto bg-surface-1 border border-border rounded shadow-xl p-1.5"
>
{search}
{options.length === 0 ? (
<p className="text-[11px] text-fg-muted px-2 py-1 italic">
{t("eventFilters.noOptions")}
+1 -1
View File
@@ -100,7 +100,7 @@ export function EventFiltersInfo() {
<p className="text-[11px] text-fg-secondary mb-2">
{t("eventFilters.help.lifecycleDesc")}
</p>
<code className="block bg-black/40 border border-border rounded p-2 text-[11px] font-mono text-fg-secondary whitespace-pre-wrap">
<code className="block bg-surface-1 border border-border rounded p-2 text-[11px] font-mono text-fg-secondary whitespace-pre-wrap">
{t("eventFilters.help.lifecycleFlow")}
</code>
</Section>
+98
View File
@@ -0,0 +1,98 @@
/**
* @file NeedsYouStrip.tsx
* @description The Dashboard's pinned "Needs you" strip: every active session
* blocked on the user (`awaiting_input_since`), each linking straight to its
* workspace pane (or replay), else its analysis page. Renders nothing while no
* session is waiting. `compact` is the one-line form shown above Analytics.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { BellRing } from "lucide-react";
import { api, type RunHandle } from "../lib/api";
import { timeAgo } from "../lib/format";
import { useLiveRefresh } from "../hooks/useLiveRefresh";
import { workspaceHref } from "./SessionRowMenu";
import type { Session } from "../lib/types";
export function NeedsYouStrip({ compact = false }: { compact?: boolean }) {
const { t } = useTranslation("dashboard");
const [sessions, setSessions] = useState<Session[]>([]);
// The real count: only the first 20 are fetched and listed.
const [total, setTotal] = useState(0);
const [runs, setRuns] = useState<RunHandle[]>([]);
const load = useCallback(() => {
api.sessions
.list({ awaiting: true, limit: 20 })
.then((r) => {
setSessions(r.sessions);
setTotal(r.total);
})
.catch(() => undefined); // the strip is a shortcut; the pages show errors
api.run
.list()
.then((r) => setRuns(r.items))
.catch(() => undefined);
}, []);
useEffect(() => {
load();
}, [load]);
useLiveRefresh(load, {
intervalMs: 15000,
match: (msg) => msg.type === "session_updated" || msg.type === "session_created",
});
if (sessions.length === 0) return null;
return (
<section
aria-label={t("needsYou.title")}
className={`card border-status-warning/40 ${compact ? "px-3 py-2" : "p-4"}`}
>
<div className={`flex flex-wrap items-center gap-2 ${compact ? "" : "mb-3"}`}>
<BellRing className="w-4 h-4 text-status-warning flex-shrink-0" aria-hidden />
<h2 className="text-sm font-medium text-fg-primary">
{t("needsYou.title")}{" "}
<span className="text-fg-muted font-normal">({Math.max(total, sessions.length)})</span>
</h2>
{compact &&
sessions.slice(0, 5).map((s) => (
<Link
key={s.id}
to={workspaceHref(s, runs) ?? `/sessions/${s.id}`}
className="text-xs px-2 py-0.5 rounded-full bg-status-warning/10 border border-status-warning/30 text-fg-secondary hover:text-fg-primary truncate max-w-[14rem]"
>
{s.name || s.id.slice(0, 8)}
</Link>
))}
</div>
{!compact && (
<ul className="divide-y divide-border">
{sessions.map((s) => (
<li key={s.id}>
<Link
to={workspaceHref(s, runs) ?? `/sessions/${s.id}`}
className="flex items-center gap-3 py-2 px-1 hover:bg-surface-4 rounded"
>
<span className="w-2 h-2 rounded-full bg-status-warning flex-shrink-0" />
<span className="text-sm text-fg-secondary truncate flex-1">
{s.name || s.id.slice(0, 8)}
</span>
<span className="text-[11px] text-fg-muted truncate max-w-[16rem]">{s.cwd}</span>
{s.awaiting_input_since && (
<span className="text-[11px] text-fg-muted flex-shrink-0">
{t("needsYou.since", { ago: timeAgo(s.awaiting_input_since) })}
</span>
)}
</Link>
</li>
))}
</ul>
)}
</section>
);
}
@@ -0,0 +1,76 @@
/**
* @file RouteErrorBoundary.tsx
* @description Route-level error boundary around the lazily loaded pages. After
* a self-update an open tab still asks for the OLD `/assets/<Page>-<hash>.js`,
* which the SPA fallback answers with index.html, so the dynamic import
* rejects: that case reloads the page once (a sessionStorage guard stops a
* reload loop). Any other render error — or a second chunk failure — shows a
* themed "Something went wrong — Reload" panel instead of a blank app.
* `reloadOnce` is also used by App's `vite:preloadError` listener.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { Component, type ReactNode } from "react";
import i18n from "../i18n";
const RELOAD_KEY = "agenthub.chunkReloadAt";
/** A second chunk failure within this window means the reload did not help. */
const RELOAD_WINDOW_MS = 30_000;
/** A failed dynamic `import()` (Chrome, Firefox, Safari wordings) or a ChunkLoadError. */
export function isChunkLoadError(err: unknown): boolean {
if (!(err instanceof Error)) return false;
return (
err.name === "ChunkLoadError" ||
/dynamically imported module|Importing a module script failed|error loading dynamically imported module|Unable to preload CSS/i.test(
err.message
)
);
}
/** Reload the page unless this tab already did so in the last 30 s. */
export function reloadOnce(reload: () => void = () => window.location.reload()): boolean {
try {
const last = Number(sessionStorage.getItem(RELOAD_KEY) || 0);
if (Date.now() - last < RELOAD_WINDOW_MS) return false;
sessionStorage.setItem(RELOAD_KEY, String(Date.now()));
} catch {
return false; // no sessionStorage: no loop guard, so never auto-reload
}
reload();
return true;
}
interface Props {
children: ReactNode;
/** Injected in tests; defaults to `window.location.reload`. */
reload?: () => void;
}
export class RouteErrorBoundary extends Component<Props, { error: Error | null }> {
state = { error: null as Error | null };
static getDerivedStateFromError(error: Error) {
return { error };
}
componentDidCatch(error: Error) {
if (isChunkLoadError(error)) reloadOnce(this.props.reload);
}
render() {
if (!this.state.error) return this.props.children;
const reload = this.props.reload ?? (() => window.location.reload());
return (
<div role="alert" className="card flex flex-col items-center gap-3 py-16 text-center">
<p className="text-sm font-medium text-fg-primary">
{i18n.t("dashboard:errorBoundary.title")}
</p>
<p className="text-xs text-fg-muted max-w-md">{i18n.t("dashboard:errorBoundary.body")}</p>
<button type="button" className="btn-primary text-sm" onClick={reload}>
{i18n.t("dashboard:errorBoundary.reload")}
</button>
</div>
);
}
}
+100 -90
View File
@@ -65,9 +65,11 @@ import {
Bot,
} from "lucide-react";
import { api } from "../lib/api";
import { eventBus } from "../lib/eventBus";
import { useLiveRefresh } from "../hooks/useLiveRefresh";
import { useTranslation } from "react-i18next";
import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents";
import { fmt, formatDuration } from "../lib/format";
import { fmt, formatDuration, parseDate } from "../lib/format";
import { effectiveSessionStatus } from "../lib/types";
import { styleForTool } from "./conversation/toolStyle";
import type { Agent, Session, SessionStats } from "../lib/types";
@@ -76,8 +78,8 @@ interface SessionOverviewProps {
agents: Agent[];
}
/** Debounce window for stats refresh - coalesces bursts of hook events into one fetch. */
const REFRESH_DEBOUNCE_MS = 600;
/** Throttle window for stats refresh - coalesces bursts of hook events into one fetch. */
const REFRESH_THROTTLE_MS = 1000;
/** Compact tile used in the top stat row. */
function StatTile({
@@ -93,24 +95,25 @@ function StatTile({
icon: React.ReactNode;
tone?: "default" | "violet" | "emerald" | "amber" | "rose" | "cyan" | "blue";
}) {
// Theme tokens only: status colours for states, syntax hues for categories.
const palette = {
default: "border-surface-3 bg-surface-2 text-fg-secondary",
violet: "border-violet-500/20 bg-violet-500/5 text-violet-800 dark:text-violet-200",
violet: "border-syntax-keyword/20 bg-syntax-keyword/5 text-syntax-keyword",
emerald: "border-status-success/20 bg-status-success/5 text-status-success",
amber: "border-status-warning/20 bg-status-warning/5 text-status-warning",
rose: "border-rose-500/20 bg-rose-500/5 text-rose-800 dark:text-rose-200",
cyan: "border-cyan-500/20 bg-cyan-500/5 text-cyan-800 dark:text-cyan-200",
blue: "border-blue-600/20 bg-blue-600/5 text-blue-700 dark:text-blue-300",
rose: "border-status-danger/20 bg-status-danger/5 text-status-danger",
cyan: "border-syntax-property/20 bg-syntax-property/5 text-syntax-property",
blue: "border-syntax-builtin/20 bg-syntax-builtin/5 text-syntax-builtin",
}[tone];
const iconTone = {
default: "text-fg-muted",
violet: "text-violet-700 dark:text-violet-400",
violet: "text-syntax-keyword",
emerald: "text-status-success",
amber: "text-status-warning",
rose: "text-rose-700 dark:text-rose-400",
cyan: "text-cyan-700 dark:text-cyan-400",
blue: "text-blue-500",
rose: "text-status-danger",
cyan: "text-syntax-property",
blue: "text-syntax-builtin",
}[tone];
return (
@@ -158,8 +161,8 @@ function ToolUsageRow({ toolName, count, max }: { toolName: string; count: numbe
}
export function SessionOverview({ session, agents }: SessionOverviewProps) {
const { t } = useTranslation("sessions");
const [stats, setStats] = useState<SessionStats | null>(null);
const refreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const fetchingRef = useRef(false);
// Tick clock every 30s so a still-active session's "duration" tile stays current.
@@ -170,8 +173,14 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
return () => window.clearInterval(id);
}, [session.status]);
// A refresh asked for while one is in flight is not dropped: it runs once
// when the current one settles (that is the throttle's trailing call).
const pendingRef = useRef(false);
const fetchStats = async () => {
if (fetchingRef.current) return;
if (fetchingRef.current) {
pendingRef.current = true;
return;
}
fetchingRef.current = true;
try {
const result = await api.sessions.stats(session.id);
@@ -180,6 +189,10 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
// Non-fatal - overview just won't update this round.
} finally {
fetchingRef.current = false;
if (pendingRef.current) {
pendingRef.current = false;
void fetchStats();
}
}
};
@@ -189,47 +202,29 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [session.id]);
// Live refresh on websocket events (debounced).
useEffect(() => {
const unsubscribe = eventBus.subscribe((msg) => {
if (isRemoteDataRefreshMessage(msg)) {
if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
refreshTimerRef.current = setTimeout(() => {
refreshTimerRef.current = null;
fetchStats();
}, REFRESH_DEBOUNCE_MS);
return;
}
const isRelevant =
msg.type === "new_event" ||
msg.type === "agent_created" ||
msg.type === "agent_updated" ||
msg.type === "session_updated";
if (!isRelevant) return;
// Live refresh on this session's websocket events, throttled (a steady
// stream still refreshes) and again after a reconnect.
useLiveRefresh(() => void fetchStats(), {
throttleMs: REFRESH_THROTTLE_MS,
match: (msg) => {
if (isRemoteDataRefreshMessage(msg)) return true;
if (
msg.type !== "new_event" &&
msg.type !== "agent_created" &&
msg.type !== "agent_updated" &&
msg.type !== "session_updated"
)
return false;
const data = msg.data as { session_id?: string; id?: string };
// Match either by session_id (events) or by id (session_updated)
const matchesSession = data.session_id === session.id || data.id === session.id;
if (!matchesSession) return;
if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
refreshTimerRef.current = setTimeout(() => {
refreshTimerRef.current = null;
fetchStats();
}, REFRESH_DEBOUNCE_MS);
});
return () => {
unsubscribe();
if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [session.id]);
return data.session_id === session.id || data.id === session.id;
},
});
// Tool calls = sum of all tool counts (PreToolUse+PostToolUse events have a tool_name).
// We approximate "tool calls" as half of that (each call produces a Pre + Post event).
const toolCallCount = useMemo(() => {
if (!stats) return 0;
const total = stats.tools_used.reduce((s, t) => s + t.count, 0);
return Math.round(total / 2);
}, [stats]);
// Exact totals from the server (`tools_used` is only the top 15). An older
// server lacks them: fall back to the visible list's count.
const toolCallCount = stats?.tool_call_count ?? 0;
const distinctTools = stats?.distinct_tool_count ?? stats?.tools_used.length ?? 0;
const maxToolCount = useMemo(() => {
if (!stats) return 0;
@@ -249,11 +244,11 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
// Avg event rate (events / minute)
const eventRate = useMemo(() => {
if (!stats || !session.started_at) return null;
const start = new Date(session.started_at).getTime();
const start = parseDate(session.started_at).getTime();
const end = session.ended_at
? new Date(session.ended_at).getTime()
? parseDate(session.ended_at).getTime()
: stats.last_event_at
? new Date(stats.last_event_at).getTime()
? parseDate(stats.last_event_at).getTime()
: now;
const minutes = Math.max(1, (end - start) / 60_000);
return stats.total_events / minutes;
@@ -287,11 +282,11 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
</span>
<Bot className="w-3.5 h-3.5 text-status-success flex-shrink-0" />
<span className="text-xs text-status-success font-medium flex-shrink-0">
{activeAgent.name || "Agent"}
{activeAgent.name || t("overview.agent")}
</span>
{activeAgent.current_tool && (
<span className="text-[11px] text-fg-secondary font-mono inline-flex items-center gap-1">
<span className="text-fg-muted">running</span>
<span className="text-fg-muted">{t("overview.running")}</span>
<span className="text-status-success">{activeAgent.current_tool}</span>
</span>
)}
@@ -309,45 +304,51 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
{/* Stat tiles */}
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-2.5">
<StatTile
label="Events"
label={t("overview.events")}
value={stats.total_events.toLocaleString()}
hint={
eventRate !== null && eventRate > 0
? `${eventRate < 1 ? eventRate.toFixed(2) : Math.round(eventRate)}/min`
? t("overview.perMin", {
rate: eventRate < 1 ? eventRate.toFixed(2) : Math.round(eventRate),
})
: undefined
}
icon={<Activity className="w-3 h-3" />}
/>
<StatTile
label="Tool calls"
label={t("overview.toolCalls")}
value={toolCallCount.toLocaleString()}
hint={stats.tools_used.length > 0 ? `${stats.tools_used.length} unique` : undefined}
hint={distinctTools > 0 ? t("overview.unique", { count: distinctTools }) : undefined}
icon={<Wrench className="w-3 h-3" />}
tone="violet"
/>
<StatTile
label="Subagents"
label={t("overview.subagents")}
value={stats.agents.subagent.toLocaleString()}
hint={stats.agents.main > 0 ? `+${stats.agents.main} main` : undefined}
hint={
stats.agents.main > 0 ? t("overview.plusMain", { count: stats.agents.main }) : undefined
}
icon={<GitBranch className="w-3 h-3" />}
tone="cyan"
/>
<StatTile
label="Compactions"
label={t("overview.compactions")}
value={stats.agents.compaction.toLocaleString()}
icon={<Layers className="w-3 h-3" />}
tone="blue"
/>
<StatTile
label="Errors"
label={t("overview.errors")}
value={stats.error_count.toLocaleString()}
icon={<AlertTriangle className="w-3 h-3" />}
tone={stats.error_count > 0 ? "rose" : "default"}
/>
<StatTile
label="Duration"
label={t("overview.duration")}
value={durationLabel}
hint={session.status === "active" ? "running" : "completed"}
hint={t(`common:status.${effectiveSessionStatus(session)}`, {
defaultValue: effectiveSessionStatus(session),
})}
icon={<Clock className="w-3 h-3" />}
tone={session.status === "active" ? "emerald" : "default"}
/>
@@ -359,15 +360,17 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
<div className="lg:col-span-2 rounded-lg border border-surface-3 bg-surface-2/60 p-3.5">
<div className="flex items-center justify-between mb-3">
<h3 className="text-xs font-semibold text-fg-secondary uppercase tracking-wider flex items-center gap-1.5">
<Wrench className="w-3.5 h-3.5 text-violet-700 dark:text-violet-400" />
Top tools
<Wrench className="w-3.5 h-3.5 text-syntax-keyword" />
{t("overview.topTools")}
</h3>
<span className="text-[10px] text-fg-muted font-mono">
{stats.tools_used.length} total
{t("overview.distinctTotal", { count: distinctTools })}
</span>
</div>
{stats.tools_used.length === 0 ? (
<div className="text-center py-6 text-xs text-fg-muted">No tool calls yet.</div>
<div className="text-center py-6 text-xs text-fg-muted">
{t("overview.noToolCalls")}
</div>
) : (
<div className="space-y-1.5">
{stats.tools_used.slice(0, 8).map((t) => (
@@ -406,7 +409,7 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
if (stats.agents.compaction > 0) {
rows.push({
key: "__compaction__",
label: "Context Compaction",
label: t("overview.contextCompaction"),
count: stats.agents.compaction,
isCompaction: true,
});
@@ -418,20 +421,24 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
<>
<div className="flex items-center justify-between mb-3">
<h3 className="text-xs font-semibold text-fg-secondary uppercase tracking-wider flex items-center gap-1.5">
<GitBranch className="w-3.5 h-3.5 text-cyan-700 dark:text-cyan-400" />
Subagents
<GitBranch className="w-3.5 h-3.5 text-syntax-property" />
{t("overview.subagents")}
</h3>
<span className="text-[10px] text-fg-muted font-mono">{totalRuns} runs</span>
<span className="text-[10px] text-fg-muted font-mono">
{t("overview.runs", { count: totalRuns })}
</span>
</div>
{rows.length === 0 ? (
<div className="text-center py-6 text-xs text-fg-muted">
No subagents in this session.
{t("overview.noSubagents")}
</div>
) : (
<div className="space-y-1.5">
{rows.slice(0, 8).map((r) => {
const pct = max > 0 ? Math.max(4, Math.round((r.count / max) * 100)) : 0;
const barClass = r.isCompaction ? "bg-status-warning/60" : "bg-cyan-500/60";
const barClass = r.isCompaction
? "bg-status-warning/60"
: "bg-syntax-property/60";
return (
<div key={r.key} className="flex items-center gap-2">
<span
@@ -468,9 +475,11 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
<div className="flex items-center justify-between mb-2.5">
<h3 className="text-xs font-semibold text-fg-secondary uppercase tracking-wider flex items-center gap-1.5">
<Coins className="w-3.5 h-3.5 text-status-warning" />
Token flow
{t("overview.tokenFlow")}
</h3>
<span className="text-[10px] text-fg-muted font-mono">{fmt(totalTokens)} total</span>
<span className="text-[10px] text-fg-muted font-mono">
{t("overview.total", { value: fmt(totalTokens) })}
</span>
</div>
<TokenFlowBar tokens={tokens} total={totalTokens} />
</div>
@@ -481,7 +490,7 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
<div className="rounded-lg border border-surface-3 bg-surface-2/60 p-3.5">
<h3 className="text-xs font-semibold text-fg-secondary uppercase tracking-wider mb-3 flex items-center gap-1.5">
<Activity className="w-3.5 h-3.5 text-fg-secondary" />
Event mix
{t("overview.eventMix")}
</h3>
<div className="flex flex-wrap gap-1.5">
{stats.events_by_type.slice(0, 12).map((e) => (
@@ -502,34 +511,35 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
}
function TokenFlowBar({ tokens, total }: { tokens: SessionStats["tokens"]; total: number }) {
const { t } = useTranslation();
const segments = [
{
key: "cache_read",
label: "Cache read",
label: t("common:token.cacheRead"),
value: tokens.cache_read_tokens,
cls: "bg-sky-500",
text: "text-sky-700 dark:text-sky-300",
cls: "bg-syntax-builtin",
text: "text-syntax-builtin",
},
{
key: "cache_write",
label: "Cache write",
label: t("common:token.cacheWrite"),
value: tokens.cache_write_tokens,
cls: "bg-violet-500",
text: "text-violet-700 dark:text-violet-300",
cls: "bg-syntax-keyword",
text: "text-syntax-keyword",
},
{
key: "input",
label: "Input",
label: t("common:token.input"),
value: tokens.input_tokens,
cls: "bg-status-success",
text: "text-status-success",
},
{
key: "output",
label: "Output",
label: t("common:token.output"),
value: tokens.output_tokens,
cls: "bg-orange-500",
text: "text-orange-700 dark:text-orange-300",
cls: "bg-syntax-number",
text: "text-syntax-number",
},
];
+19 -6
View File
@@ -11,9 +11,26 @@
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { ContextMenu, MenuItem, MenuSeparator, type MenuPoint } from "./ContextMenu";
import { copyText } from "../lib/clipboard";
export type SessionRowRef = { id: string; lane_id?: number | null; cwd?: string | null };
/**
* Where "Open in workspace" goes for a session: its live pane when a dashboard
* run holds it (`&run=`), else its workspace's replay (`&session=`); null when
* no workspace owns it.
*/
export function workspaceHref(
session: SessionRowRef,
runs: ReadonlyArray<{ id: string; laneId: number | null; sessionId: string | null }> = []
): string | null {
const run = runs.find((r) => r.sessionId === session.id && r.laneId != null);
if (run) return `/workspace?lane=${run.laneId}&run=${encodeURIComponent(run.id)}`;
if (session.lane_id != null)
return `/workspace?lane=${session.lane_id}&session=${encodeURIComponent(session.id)}`;
return null;
}
export function SessionRowMenu({
session,
x,
@@ -26,7 +43,7 @@ export function SessionRowMenu({
onClose();
fn();
};
const copy = (text: string) => pick(() => void navigator.clipboard.writeText(text));
const copy = (text: string) => pick(() => void copyText(text));
return (
<ContextMenu x={x} y={y} onClose={onClose} testId={`session-row-menu-${session.id}`}>
@@ -34,11 +51,7 @@ export function SessionRowMenu({
{t("menu.details")}
</MenuItem>
{session.lane_id != null && (
<MenuItem
onSelect={pick(() =>
navigate(`/workspace?lane=${session.lane_id}&session=${encodeURIComponent(session.id)}`)
)}
>
<MenuItem onSelect={pick(() => navigate(workspaceHref(session)!))}>
{t("menu.inWorkspace")}
</MenuItem>
)}
+7
View File
@@ -68,7 +68,10 @@ export function SplashScreen() {
const { t } = useTranslation("splash");
// Show at most once per tab session. Read synchronously so we never flash an
// empty overlay on a repeat mount (StrictMode double-invoke, refresh, etc.).
// Never on a deep link (a shared /sessions/<id>, a notification's
// /workspace?…): the user came for that page, not the welcome.
const [mounted, setMounted] = useState(() => {
if (window.location.pathname !== "/") return false;
try {
return !sessionStorage.getItem(SESSION_KEY);
} catch {
@@ -109,7 +112,11 @@ export function SplashScreen() {
/* sessionStorage may be unavailable (private mode) - show anyway */
}
exitTimer.current = window.setTimeout(beginExit, HOLD_MS);
// Any key dismisses it (not only a click), so a keyboard user is never stuck.
const onKey = () => beginExit();
window.addEventListener("keydown", onKey);
return () => {
window.removeEventListener("keydown", onKey);
if (exitTimer.current) window.clearTimeout(exitTimer.current);
if (doneTimer.current) window.clearTimeout(doneTimer.current);
};
+17 -3
View File
@@ -54,6 +54,7 @@
*
* ----------------------------------------------------------------------------- */
import { Link } from "react-router-dom";
import type { LucideIcon } from "lucide-react";
import { Tip } from "./Tip";
import { StatValueSkeleton } from "./Skeleton";
@@ -74,6 +75,8 @@ interface StatCardProps {
raw?: string;
/** When true, render skeletons in place of value/trend. */
loading?: boolean;
/** Drill-in: the whole card links here (e.g. the list the number counts). */
to?: string;
}
/**
@@ -88,9 +91,10 @@ export function StatCard({
accentColor = "text-accent",
raw,
loading = false,
to,
}: StatCardProps) {
return (
<div className="card p-5">
const body = (
<>
<div className="flex items-center justify-between gap-3 mb-3">
<span className="text-xs font-medium text-fg-muted uppercase tracking-wider truncate">
{label}
@@ -109,6 +113,16 @@ export function StatCard({
<span className="text-xs text-fg-muted mb-1 flex-shrink-0">{trend}</span>
)}
</div>
</div>
</>
);
return to ? (
<Link
to={to}
className="card p-5 block hover:bg-surface-4/40 transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
>
{body}
</Link>
) : (
<div className="card p-5">{body}</div>
);
}
+3 -3
View File
@@ -79,8 +79,8 @@ export const REASON_ICONS: Record<AwaitingReason, LucideIcon> = {
/**
* The "why" chip nested inside a Waiting badge: a small rounded pill with the
* reason's icon and short label. Urgent reasons (permission prompts,
* interruptions) get a hotter amber fill than the calm idle-between-turns
* ones so a scan of a list surfaces the rows that actually block on the human.
* interruptions) get the amber warning fill; the calm idle-between-turns
* ones stay neutral, so a scan of a list surfaces the rows that actually block on the human.
*/
function ReasonChip({ reason }: { reason: AwaitingReason }) {
const { t } = useTranslation();
@@ -91,7 +91,7 @@ function ReasonChip({ reason }: { reason: AwaitingReason }) {
className={`inline-flex items-center gap-1 rounded-full border px-1.5 text-[10px] font-medium leading-4 ${
cfg.urgent
? "bg-status-warning/15 border-status-warning/25 text-status-warning"
: "bg-yellow-500/10 border-yellow-500/20 text-yellow-700/90 dark:text-yellow-400/90"
: "bg-surface-3 border-border text-fg-secondary"
}`}
>
<Icon className="w-2.5 h-2.5 flex-shrink-0" aria-hidden="true" />
@@ -240,7 +240,7 @@ describe("AgentCard", () => {
const { container } = renderCard(<AgentCard agent={makeAgent({ status: "waiting" })} />);
const card = container.querySelector(".card-hover");
expect(card?.className).toContain("border-l-2");
expect(card?.className).toContain("border-l-yellow-500/60");
expect(card?.className).toContain("border-l-status-warning/60");
});
it("should not apply active border for completed agents", () => {
@@ -267,7 +267,7 @@ describe("AgentCard", () => {
);
expect(screen.getByText("Waiting")).toBeInTheDocument();
const card = container.querySelector(".card-hover");
expect(card?.className).toContain("border-l-yellow-500/60");
expect(card?.className).toContain("border-l-status-warning/60");
});
it("keeps the card badge compact: reason is tooltip-only, no inline chip", () => {
@@ -0,0 +1,28 @@
/**
* @file DateTimePicker.test.tsx
* @description An end-bound picker ("To") picks the very end of a day, so an
* event at 23:59:30 is still inside the range.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { DateTimePicker } from "../DateTimePicker";
describe("DateTimePicker", () => {
it("endOfDay picks 23:59:59.999 of the chosen day", () => {
const onChange = vi.fn();
render(<DateTimePicker value="" onChange={onChange} endOfDay aria-label="To" />);
fireEvent.click(screen.getByRole("button", { name: "To" }));
fireEvent.click(screen.getAllByRole("button", { pressed: false })[0]!);
expect(onChange.mock.calls[0]![0]).toMatch(/T23:59:59\.999$/);
});
it("a start bound picks midnight", () => {
const onChange = vi.fn();
render(<DateTimePicker value="" onChange={onChange} aria-label="From" />);
fireEvent.click(screen.getByRole("button", { name: "From" }));
fireEvent.click(screen.getAllByRole("button", { pressed: false })[0]!);
expect(onChange.mock.calls[0]![0]).toMatch(/T00:00$/);
});
});
@@ -9,7 +9,13 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
import { EventFilters, EMPTY_FILTERS, isEmptyFilters } from "../EventFilters";
import {
EventFilters,
EMPTY_FILTERS,
isEmptyFilters,
filtersFromParams,
filtersToParams,
} from "../EventFilters";
import type { EventFiltersValue } from "../EventFilters";
import { api } from "../../lib/api";
@@ -82,4 +88,49 @@ describe("EventFilters", () => {
fireEvent.click(clear);
expect(onChange).toHaveBeenCalledWith(EMPTY_FILTERS);
});
it("a debounced search keeps a facet toggled meanwhile (latest value, not the stale one)", async () => {
vi.useFakeTimers();
try {
const onChange = vi.fn();
const { rerender } = render(<EventFilters value={EMPTY_FILTERS} onChange={onChange} />);
fireEvent.change(screen.getByPlaceholderText(/search summary/i), {
target: { value: "curl" },
});
// The parent applies a toggle before the 300 ms elapse.
const toggled = { ...EMPTY_FILTERS, status: ["error"] };
rerender(<EventFilters value={toggled} onChange={onChange} />);
await act(async () => {
vi.advanceTimersByTime(300);
});
expect(onChange).toHaveBeenLastCalledWith({ ...toggled, q: "curl" });
} finally {
vi.useRealTimers();
}
});
it("searches sessions on the server, 20 at a time", async () => {
const list = vi
.spyOn(api.sessions, "list")
.mockResolvedValue({ sessions: [], total: 0, limit: 20, offset: 0 });
render(<EventFilters value={EMPTY_FILTERS} onChange={() => {}} sessionOptions={[]} />);
fireEvent.click(screen.getByRole("button", { name: /session/i }));
await waitFor(() => expect(list).toHaveBeenCalledWith({ q: undefined, limit: 20 }));
fireEvent.change(screen.getByRole("searchbox"), { target: { value: "abc" } });
await waitFor(() => expect(list).toHaveBeenCalledWith({ q: "abc", limit: 20 }));
});
it("round-trips filters through URL params, leaving other keys alone", () => {
const value: EventFiltersValue = {
...EMPTY_FILTERS,
event_type: ["Stop", "PreToolUse"],
status: ["error"],
q: "curl",
to: "2026-01-02T23:59",
};
const params = filtersToParams(value, new URLSearchParams("tab=timeline"));
expect(params.get("tab")).toBe("timeline");
expect(filtersFromParams(params)).toEqual(value);
expect(filtersToParams(EMPTY_FILTERS, params).toString()).toBe("tab=timeline");
});
});
@@ -0,0 +1,60 @@
/**
* @file RouteErrorBoundary.test.tsx
* @description The route error boundary: a failed lazy chunk (stale assets after
* a self-update) reloads the page once; any other error — or a second chunk
* failure — shows the themed Reload fallback instead of a blank app.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { RouteErrorBoundary, isChunkLoadError, reloadOnce } from "../RouteErrorBoundary";
function Boom({ error }: { error: Error }): never {
throw error;
}
let reload: ReturnType<typeof vi.fn>;
beforeEach(() => {
sessionStorage.clear();
reload = vi.fn();
vi.spyOn(console, "error").mockImplementation(() => {});
});
afterEach(() => vi.restoreAllMocks());
describe("RouteErrorBoundary", () => {
it("recognises dynamic-import failures", () => {
expect(
isChunkLoadError(new TypeError("Failed to fetch dynamically imported module: /a.js"))
).toBe(true);
expect(isChunkLoadError(Object.assign(new Error("x"), { name: "ChunkLoadError" }))).toBe(true);
expect(isChunkLoadError(new Error("Importing a module script failed."))).toBe(true);
expect(isChunkLoadError(new Error("boom"))).toBe(false);
});
it("reloads once on a chunk error, never twice in a row", () => {
expect(reloadOnce(reload)).toBe(true);
expect(reloadOnce(reload)).toBe(false);
expect(reload).toHaveBeenCalledTimes(1);
});
it("shows a Reload fallback for an ordinary render error", () => {
render(
<RouteErrorBoundary reload={reload}>
<Boom error={new Error("boom")} />
</RouteErrorBoundary>
);
expect(screen.getByRole("alert")).toHaveTextContent(/something went wrong/i);
expect(screen.getByRole("button", { name: /reload/i })).toBeInTheDocument();
expect(reload).not.toHaveBeenCalled();
});
it("reloads on a chunk error the first time", () => {
render(
<RouteErrorBoundary reload={reload}>
<Boom error={new TypeError("Failed to fetch dynamically imported module: /x.js")} />
</RouteErrorBoundary>
);
expect(reload).toHaveBeenCalledTimes(1);
});
});
@@ -64,7 +64,7 @@ describe("AgentStatusBadge", () => {
expect(screen.getByText("Waiting")).toBeInTheDocument();
const dot = container.querySelector(".animate-pulse-dot");
expect(dot).toBeInTheDocument();
expect(container.querySelector(".bg-yellow-400")).toBeInTheDocument();
expect(container.querySelector(".bg-status-warning")).toBeInTheDocument();
});
});
@@ -94,7 +94,7 @@ describe("SessionStatusBadge", () => {
expect(screen.getByText("Waiting")).toBeInTheDocument();
const dot = container.querySelector(".animate-pulse-dot");
expect(dot).toBeInTheDocument();
expect(container.querySelector(".bg-yellow-400")).toBeInTheDocument();
expect(container.querySelector(".bg-status-warning")).toBeInTheDocument();
});
});
@@ -135,10 +135,14 @@ describe("awaiting-reason suffix", () => {
});
it("marks urgent reasons with the hotter amber tint", () => {
const { container } = render(<AgentStatusBadge status="waiting" reason="notification" />);
expect(container.querySelector(".text-status-warning")).toBeInTheDocument();
const { container: calm } = render(<AgentStatusBadge status="waiting" reason="stop" />);
expect(calm.querySelector(".text-status-warning")).not.toBeInTheDocument();
render(<AgentStatusBadge status="waiting" reason="notification" />);
expect(screen.getByText("Needs input").closest("span.rounded-full")).toHaveClass(
"text-status-warning"
);
render(<AgentStatusBadge status="waiting" reason="stop" />);
expect(screen.getByText("Turn done").closest("span.rounded-full")).not.toHaveClass(
"text-status-warning"
);
});
it("compact mode suppresses the inline chip but keeps the hover tooltip", () => {
@@ -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);
});
});
@@ -137,8 +137,8 @@ export function CopyButton({ text }: { text: string }) {
export function Terminal({ command, description }: { command: string; description?: string }) {
return (
<div className="relative bg-black/70 border border-border rounded font-mono text-[11px] overflow-hidden">
<div className="flex items-center justify-between px-3 py-1 border-b border-border bg-black/40">
<div className="relative bg-surface-0 border border-border rounded font-mono text-[11px] overflow-hidden">
<div className="flex items-center justify-between px-3 py-1 border-b border-border bg-surface-1">
<span className="text-fg-muted text-[10px] uppercase tracking-wide">terminal</span>
<CopyButton text={command} />
</div>
@@ -207,8 +207,8 @@ function OutputBlock({
}) {
const color = variant === "err" ? "text-status-danger" : "text-fg-secondary";
return (
<div className="relative bg-black/70 border border-border rounded font-mono text-[11px] overflow-hidden">
<div className="flex items-center justify-between px-3 py-1 border-b border-border bg-black/40">
<div className="relative bg-surface-0 border border-border rounded font-mono text-[11px] overflow-hidden">
<div className="flex items-center justify-between px-3 py-1 border-b border-border bg-surface-1">
<span className="text-fg-muted text-[10px] uppercase tracking-wide">{label}</span>
<CopyButton text={text} />
</div>
@@ -234,9 +234,9 @@ export function LineNumberedCode({
}) {
const lines = text.split(/\r?\n/);
return (
<div className="relative bg-black/70 border border-border rounded font-mono text-[11px] overflow-hidden">
<div className="relative bg-surface-0 border border-border rounded font-mono text-[11px] overflow-hidden">
{label && (
<div className="flex items-center justify-between px-3 py-1 border-b border-border bg-black/40">
<div className="flex items-center justify-between px-3 py-1 border-b border-border bg-surface-1">
<span className="text-fg-muted text-[10px] uppercase tracking-wide">{label}</span>
<CopyButton text={text} />
</div>
@@ -246,7 +246,7 @@ export function LineNumberedCode({
<tbody>
{lines.map((line, i) => (
<tr key={i}>
<td className="px-2 text-right text-fg-muted select-none bg-black/40 border-r border-border align-top">
<td className="px-2 text-right text-fg-muted select-none bg-surface-1 border-r border-border align-top">
{i + startLine}
</td>
<td className="px-3 text-fg-secondary whitespace-pre-wrap break-words">{line}</td>
@@ -274,7 +274,7 @@ export function UnifiedDiff({ hunks }: { hunks: DiffHunk[] }) {
return <p className="text-[11px] text-fg-muted italic">no diff</p>;
}
return (
<div className="relative bg-black/70 border border-border rounded font-mono text-[11px] overflow-hidden">
<div className="relative bg-surface-0 border border-border rounded font-mono text-[11px] overflow-hidden">
<div className="overflow-auto max-h-96">
{hunks.map((hunk, i) => (
<HunkView key={i} hunk={hunk} />
@@ -58,8 +58,11 @@
import { useRef, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import * as d3 from "d3";
import type { CompactionImpactData } from "../../lib/types";
import { max, axisBottom, axisLeft, scaleBand, scaleLinear, select, type Selection } from "d3";
// Named imports so Vite tree-shakes d3 down to what these charts use.
const d3 = { axisBottom, axisLeft, max, scaleBand, scaleLinear, select };
// ── Helpers ───────────────────────────────────────────────────────────────────
@@ -122,11 +125,11 @@ function renderHistogram(svg: SVGSVGElement, histo: HistogramBucket[], opts: His
.attr("y1", "0%")
.attr("x2", "0%")
.attr("y2", "100%");
grad.append("stop").attr("offset", "0%").attr("stop-color", "#818cf8");
grad.append("stop").attr("offset", "0%").attr("stop-color", "rgb(var(--accent))");
grad
.append("stop")
.attr("offset", "100%")
.attr("stop-color", "#3730a3")
.attr("stop-color", "rgb(var(--accent-solid))")
.attr("stop-opacity", 0.7);
const g = root.append("g").attr("transform", `translate(${MARGIN.left},${MARGIN.top})`);
@@ -212,7 +215,7 @@ function renderHistogram(svg: SVGSVGElement, histo: HistogramBucket[], opts: His
const barH = innerH - by;
const bg = g.append("g");
let bar: d3.Selection<SVGRectElement, unknown, null, undefined> | null = null;
let bar: Selection<SVGRectElement, unknown, null, undefined> | null = null;
if (d.sessions > 0) {
bar = bg
@@ -254,7 +257,7 @@ function renderHistogram(svg: SVGSVGElement, histo: HistogramBucket[], opts: His
.attr("fill", "transparent")
.style("cursor", "pointer")
.on("mouseenter", (event: MouseEvent) => {
if (bar) bar.attr("fill", "#a5b4fc");
if (bar) bar.attr("fill", "rgb(var(--accent-hover))");
opts.onHover(event, d);
})
.on("mousemove", (event: MouseEvent) => opts.onMove(event))
@@ -305,6 +308,20 @@ export function CompactionImpact({ data }: CompactionImpactProps) {
const avgPerSession = affected > 0 ? data.totalCompactions / affected : 0;
const peak = data.perSession.reduce((m, s) => Math.max(m, s.compactions), 0);
// Redraw on resize (the histogram reads its container's width when drawn),
// like the Session Complexity Scatter does.
const [width, setWidth] = useState(0);
useEffect(() => {
const el = svgRef.current?.parentElement;
if (!el) return;
const obs = new ResizeObserver((entries) => {
const w = entries[0]?.contentRect.width;
if (w) setWidth(Math.round(w));
});
obs.observe(el);
return () => obs.disconnect();
}, [hasData]);
useEffect(() => {
if (!svgRef.current || !hasData) return;
const histo = toHistogram(data.perSession);
@@ -324,7 +341,7 @@ export function CompactionImpact({ data }: CompactionImpactProps) {
onMove: (e) => setTip((p) => (p ? { ...p, x: e.clientX, y: e.clientY } : p)),
onLeave: () => setTip(null),
});
}, [data, hasData, t]);
}, [data, hasData, t, width]);
if (!hasData) {
return (
@@ -55,7 +55,12 @@ import { useState } from "react";
import { useTranslation } from "react-i18next";
import type { ErrorPropagationData } from "../../lib/types";
const DEPTH_COLORS = ["#ef4444", "#f97316", "#eab308", "#a855f7"];
const DEPTH_COLORS = [
"rgb(var(--status-danger))",
"rgb(var(--syn-number))",
"rgb(var(--status-warning))",
"rgb(var(--syn-keyword))",
];
// ── Component ─────────────────────────────────────────────────────────────────
@@ -240,7 +245,7 @@ export function ErrorPropagationMap({ data }: ErrorPropagationMapProps) {
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="#f59e0b"
stroke="rgb(var(--status-warning))"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
@@ -14,7 +14,7 @@
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { api } from "../../lib/api";
import { eventBus } from "../../lib/eventBus";
import { useLiveRefresh } from "../../hooks/useLiveRefresh";
import type { PipelineStats } from "../../lib/types";
/** Percentage of `n` out of `total`, 0 when nothing was recorded. */
@@ -26,7 +26,12 @@ function pct(n: number, total: number): number {
* Per-node pass statistics for the selected pipeline template.
* @returns The panel element, or an empty-state hint when nothing was recorded.
*/
export function LanePipelinePanel() {
export function LanePipelinePanel({
onAvailability,
}: {
/** Told whether any pipeline template is loaded (pipelines are dormant by default). */
onAvailability?: (hasPipelines: boolean) => void;
} = {}) {
const { t } = useTranslation("workflows");
const [data, setData] = useState<PipelineStats | null>(null);
const [selectedId, setSelectedId] = useState<string | null>(null);
@@ -46,18 +51,14 @@ export function LanePipelinePanel() {
void refresh();
}, [refresh]);
// Stages move as agents declare them; debounce so a burst costs one fetch.
// Stages move as agents declare them; throttled (not debounced) so a burst
// costs one fetch and a steady stream still refreshes every 3 s.
useLiveRefresh(() => void refresh(), { match: (msg) => msg.type === "lane_update" });
const hasPipelines = !!data && data.pipelines.length > 0;
useEffect(() => {
let timer: ReturnType<typeof setTimeout>;
const unsub = eventBus.subscribe(() => {
clearTimeout(timer);
timer = setTimeout(() => void refresh(), 3000);
});
return () => {
unsub();
clearTimeout(timer);
};
}, [refresh]);
if (data) onAvailability?.(hasPipelines);
}, [data, hasPipelines, onAvailability]);
if (error) return <p className="text-xs text-status-danger">{error}</p>;
if (!data) return null;
@@ -71,7 +72,7 @@ export function LanePipelinePanel() {
<select
data-testid="pipeline-stats-lane-picker"
aria-label={t("lanes.laneLabel")}
className="max-w-[14rem] truncate rounded border border-border bg-surface-1 px-2 py-0.5 text-xs text-fg-secondary"
className="select max-w-[14rem] truncate py-0.5 text-xs"
value={laneId ?? ""}
onChange={(e) => setLaneId(e.target.value ? Number(e.target.value) : null)}
>
@@ -113,7 +114,7 @@ export function LanePipelinePanel() {
<select
data-testid="pipeline-stats-picker"
aria-label={t("lanes.pipelineLabel")}
className="rounded border border-border bg-surface-1 px-2 py-0.5 text-xs text-fg-secondary"
className="select w-auto py-0.5 text-xs"
value={selected.id}
onChange={(e) => setSelectedId(e.target.value)}
>
@@ -47,8 +47,11 @@
import { useRef, useEffect, useMemo, useState, useCallback } from "react";
import { useTranslation } from "react-i18next";
import * as d3 from "d3";
import type { OrchestrationData } from "../../lib/types";
import { extent, scaleLinear, select } from "d3";
// Named imports so Vite tree-shakes d3 down to what these charts use.
const d3 = { extent, scaleLinear, select };
// ── Types ─────────────────────────────────────────────────────────────────────
@@ -104,52 +107,45 @@ const BADGE_H = 14;
// Layer labels are now computed via i18n inside the component
const OUTCOME_COLORS: Record<string, { fill: string; stroke: string; text: string }> = {
completed: { fill: "#052e16", stroke: "#16a34a", text: "#4ade80" },
error: { fill: "#1f0808", stroke: "#dc2626", text: "#f87171" },
abandoned: { fill: "#1c1a04", stroke: "#ca8a04", text: "#facc15" },
// Every colour is a theme token (or a mix of one over the card surface), so
// the DAG reads in light themes too — it used to be dark-only hex.
const tok = (v: string) => `rgb(var(${v}))`;
const mix = (v: string, pct: number) =>
`color-mix(in srgb, rgb(var(${v})) ${pct}%, rgb(var(--surface-1)))`;
const KIND_VAR: Record<DAGNode["kind"], string> = {
session: "--accent",
main: "--syn-builtin",
subagent: "--status-success",
nested: "--syn-property",
outcome: "--accent",
};
const OUTCOME_VAR: Record<string, string> = {
completed: "--status-success",
error: "--status-danger",
abandoned: "--status-warning",
};
const KIND_GRADIENTS: Record<
DAGNode["kind"],
{ id: string; stops: Array<{ offset: string; color: string }> }
> = {
session: {
id: "grad-session",
stops: [
{ offset: "0%", color: "#312e81" },
{ offset: "100%", color: "#4338ca" },
],
},
main: {
id: "grad-main",
stops: [
{ offset: "0%", color: "#1e3a5f" },
{ offset: "100%", color: "#1d4ed8" },
],
},
subagent: {
id: "grad-subagent",
stops: [
{ offset: "0%", color: "#052e16" },
{ offset: "100%", color: "#166534" },
],
},
nested: {
id: "grad-nested",
stops: [
{ offset: "0%", color: "#134e4a" },
{ offset: "100%", color: "#0f766e" },
],
},
outcome: {
id: "grad-outcome",
stops: [
{ offset: "0%", color: "#1e1b4b" },
{ offset: "100%", color: "#4338ca" },
],
},
};
const OUTCOME_COLORS: Record<string, { fill: string; stroke: string; text: string }> =
Object.fromEntries(
Object.entries(OUTCOME_VAR).map(([k, v]) => [
k,
{ fill: mix(v, 15), stroke: tok(v), text: tok(v) },
])
);
const KIND_GRADIENTS = Object.fromEntries(
(Object.keys(KIND_VAR) as DAGNode["kind"][]).map((kind) => [
kind,
{
id: `grad-${kind}`,
stops: [
{ offset: "0%", color: mix(KIND_VAR[kind], 10) },
{ offset: "100%", color: mix(KIND_VAR[kind], 25) },
],
},
])
) as Record<DAGNode["kind"], { id: string; stops: Array<{ offset: string; color: string }> }>;
// ── Helpers ───────────────────────────────────────────────────────────────────
@@ -168,7 +164,13 @@ function successRate(completed: number, total: number): string {
}
function outcomeColorSet(status: string) {
return OUTCOME_COLORS[status] ?? { fill: "#1a1a28", stroke: "#363650", text: "#9ca3af" };
return (
OUTCOME_COLORS[status] ?? {
fill: tok("--surface-2"),
stroke: tok("--border"),
text: tok("--fg-muted"),
}
);
}
// ── Layout builder ────────────────────────────────────────────────────────────
@@ -641,7 +643,7 @@ export function OrchestrationDAG({ data, onNodeClick, selectedNode }: Orchestrat
.append("path")
.attr("d", path)
.attr("fill", "none")
.attr("stroke", "#6366f1")
.attr("stroke", tok("--accent"))
.attr("stroke-width", stroke + 2)
.attr("stroke-opacity", isZero ? 0 : 0.08)
.attr("filter", "url(#edge-glow)");
@@ -651,7 +653,7 @@ export function OrchestrationDAG({ data, onNodeClick, selectedNode }: Orchestrat
.append("path")
.attr("d", path)
.attr("fill", "none")
.attr("stroke", isZero ? "rgb(var(--border-light))" : "#4f46e5")
.attr("stroke", isZero ? "rgb(var(--border-light))" : tok("--accent"))
.attr("stroke-width", isZero ? 1 : stroke)
.attr("stroke-opacity", isZero ? 0.3 : 0.55)
.attr("stroke-linecap", "round");
@@ -698,7 +700,7 @@ export function OrchestrationDAG({ data, onNodeClick, selectedNode }: Orchestrat
.attr("height", (d) => d.height + 6)
.attr("rx", NODE_RX + 3)
.attr("fill", "none")
.attr("stroke", "#6366f1")
.attr("stroke", tok("--accent"))
.attr("stroke-width", 2)
.attr("filter", "url(#glow)")
.attr("opacity", 0.8);
@@ -717,7 +719,7 @@ export function OrchestrationDAG({ data, onNodeClick, selectedNode }: Orchestrat
return `url(#${KIND_GRADIENTS[d.kind].id})`;
})
.attr("stroke", (d) => {
if (d.id === selectedNode) return "#6366f1";
if (d.id === selectedNode) return tok("--accent");
if (d.kind === "outcome" && d.meta?.status) {
return outcomeColorSet(d.meta.status).stroke;
}
@@ -831,18 +833,22 @@ export function OrchestrationDAG({ data, onNodeClick, selectedNode }: Orchestrat
{t("orchestration.legend")}
</span>
{LEGEND_ITEMS.map((item) => (
<div key={item.label} className="flex items-center gap-1.5">
<div key={item.key} className="flex items-center gap-1.5">
<span
className="inline-block w-3 h-3 rounded-sm flex-shrink-0"
style={{ background: item.color, border: `1px solid ${item.border}` }}
/>
<span className="text-[11px] text-fg-muted">{item.label}</span>
<span className="text-[11px] text-fg-muted">
{t(`orchestration.legendItems.${item.key}`)}
</span>
</div>
))}
<div className="flex items-center gap-1.5 ml-2">
<span
className="inline-block h-[2px] w-8 rounded flex-shrink-0"
style={{ background: "linear-gradient(to right, #312e81, #4f46e5)" }}
style={{
background: `linear-gradient(to right, ${mix("--accent", 30)}, ${tok("--accent")})`,
}}
/>
<span className="text-[11px] text-fg-muted">{t("orchestration.edgeWeight")}</span>
</div>
@@ -1014,51 +1020,16 @@ function describeNode(node: DAGNode, t: TFn): { layer: string; description: stri
// ── Utility functions ─────────────────────────────────────────────────────────
function borderColorForKind(kind: DAGNode["kind"]): string {
switch (kind) {
case "session":
return "#6366f1";
case "main":
return "#3b82f6";
case "subagent":
return "#22c55e";
case "nested":
return "#14b8a6";
case "outcome":
return "#6366f1";
}
return tok(KIND_VAR[kind]);
}
function textColorForKind(kind: DAGNode["kind"]): string {
switch (kind) {
case "session":
return "#a5b4fc";
case "main":
return "#93c5fd";
case "subagent":
return "#86efac";
case "nested":
return "#5eead4";
case "outcome":
return "#c4b5fd";
}
return tok(KIND_VAR[kind]);
}
function badgeBgForKind(kind: DAGNode["kind"], status?: string): string {
if (kind === "outcome" && status) {
return outcomeColorSet(status).stroke + "33";
}
switch (kind) {
case "session":
return "rgba(99,102,241,0.25)";
case "main":
return "rgba(59,130,246,0.25)";
case "subagent":
return "rgba(34,197,94,0.25)";
case "nested":
return "rgba(20,184,166,0.25)";
case "outcome":
return "rgba(99,102,241,0.25)";
}
if (kind === "outcome" && status) return mix(OUTCOME_VAR[status] ?? "--border", 25);
return mix(KIND_VAR[kind], 25);
}
function fmtCount(n: number): string {
@@ -1070,11 +1041,11 @@ function fmtCount(n: number): string {
// ── Legend data ───────────────────────────────────────────────────────────────
const LEGEND_ITEMS = [
{ label: "Sessions", color: "#312e81", border: "#6366f1" },
{ label: "Main Agent", color: "#1e3a5f", border: "#3b82f6" },
{ label: "Subagent Types", color: "#052e16", border: "#22c55e" },
{ label: "Compactions", color: "#134e4a", border: "#14b8a6" },
{ label: "Completed", color: "#052e16", border: "#16a34a" },
{ label: "Error", color: "#1f0808", border: "#dc2626" },
{ label: "Abandoned", color: "#1c1a04", border: "#ca8a04" },
];
{ key: "sessions", v: "--accent" },
{ key: "mainAgent", v: "--syn-builtin" },
{ key: "subagentTypes", v: "--status-success" },
{ key: "compactions", v: "--syn-property" },
{ key: "completed", v: "--status-success" },
{ key: "error", v: "--status-danger" },
{ key: "abandoned", v: "--status-warning" },
].map((i) => ({ ...i, color: mix(i.v, 20), border: tok(i.v) }));
@@ -54,9 +54,12 @@
import { useRef, useEffect, useState, useCallback } from "react";
import { useTranslation } from "react-i18next";
import * as d3 from "d3";
import type { SessionComplexityItem } from "../../lib/types";
import { formatModelName } from "../../lib/format";
import { max, axisBottom, axisLeft, format, scaleLinear, scaleSqrt, select } from "d3";
// Named imports so Vite tree-shakes d3 down to what these charts use.
const d3 = { axisBottom, axisLeft, format, max, scaleLinear, scaleSqrt, select };
// ── Constants ─────────────────────────────────────────────────────────────────
@@ -65,14 +68,14 @@ const MIN_BUBBLE_R = 4;
const MAX_BUBBLE_R = 32;
const STATUS_COLOR: Record<string, string> = {
completed: "#22c55e",
error: "#ef4444",
active: "#6366f1",
abandoned: "#eab308",
completed: "rgb(var(--status-success))",
error: "rgb(var(--status-danger))",
active: "rgb(var(--accent))",
abandoned: "rgb(var(--status-warning))",
};
function statusColor(status: string): string {
return STATUS_COLOR[status] ?? "#6b7280";
return STATUS_COLOR[status] ?? "rgb(var(--fg-muted))";
}
// ── Duration formatting ───────────────────────────────────────────────────────
@@ -56,8 +56,10 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { X, GitFork, Wrench, List, Search, ChevronDown } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { workspaceHref } from "../SessionRowMenu";
import { api } from "../../lib/api";
import { formatDateTime, formatMs, formatModelName } from "../../lib/format";
import { formatDateTime, formatDuration, formatModelName } from "../../lib/format";
import type {
SessionDrillIn as SessionDrillInData,
DashboardEvent,
@@ -157,14 +159,7 @@ function TreeNode({ node, depth }: TreeNodeProps) {
const { t } = useTranslation(["workflows", "common"]);
const indentPx = depth * 20;
const isMain = node.type === "main";
const dur = node.ended_at
? formatMs(
Math.max(
0,
new Date(node.ended_at + "Z").getTime() - new Date(node.started_at + "Z").getTime()
)
)
: t("common:running");
const dur = node.ended_at ? formatDuration(node.started_at, node.ended_at) : t("common:running");
const sc = statusColor(node.status);
const statusLabel = t(`common:status.${node.status}`, { defaultValue: node.status });
@@ -451,14 +446,27 @@ function SessionHeader({ drillIn, onClose, activeTab, onTabChange }: SessionHead
{session.started_at && ` \u00b7 ${safeTimestamp(session.started_at)}`}
</p>
</div>
<button
type="button"
onClick={onClose}
className="flex-shrink-0 w-7 h-7 rounded-md flex items-center justify-center text-fg-muted hover:text-fg-secondary hover:bg-surface-4 transition-colors"
aria-label={t("drillIn.closePanel")}
>
<X className="w-4 h-4" />
</button>
<div className="flex items-center gap-1 flex-shrink-0">
<Link
to={`/sessions/${encodeURIComponent(session.id)}`}
className="btn-ghost text-xs px-2 py-1"
>
{t("drillIn.openSession")}
</Link>
{session.lane_id != null && (
<Link to={workspaceHref(session)!} className="btn-ghost text-xs px-2 py-1">
{t("drillIn.openInWorkspace")}
</Link>
)}
<button
type="button"
onClick={onClose}
className="flex-shrink-0 w-7 h-7 rounded-md flex items-center justify-center text-fg-muted hover:text-fg-secondary hover:bg-surface-4 transition-colors"
aria-label={t("drillIn.closePanel")}
>
<X className="w-4 h-4" />
</button>
</div>
</div>
<TabBar active={activeTab} onChange={onTabChange} />
</div>
@@ -478,51 +486,40 @@ function SessionSelector({ onSelectSession }: SessionSelectorProps) {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const [sessions, setSessions] = useState<Session[]>([]);
const [allSessions, setAllSessions] = useState<Session[]>([]);
const [offset, setOffset] = useState(0);
const [hasMore, setHasMore] = useState(false);
const [loading, setLoading] = useState(false);
const [allLoaded, setAllLoaded] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
// Typing fires overlapping requests; only the newest may write the list.
const seqRef = useRef(0);
const fetchPage = useCallback((pageOffset: number, replace: boolean) => {
// The search runs on the server (`q` matches id, name and folder), one page
// at a time — never a download of every session to filter here.
const fetchPage = useCallback((q: string, pageOffset: number, replace: boolean) => {
const seq = ++seqRef.current;
setLoading(true);
api.sessions
.list({ limit: PAGE_SIZE, offset: pageOffset })
.list({ q: q || undefined, limit: PAGE_SIZE, offset: pageOffset })
.then(({ sessions: page }) => {
if (seq !== seqRef.current) return;
setSessions((prev) => (replace ? page : [...prev, ...page]));
setHasMore(page.length === PAGE_SIZE);
setOffset(pageOffset + page.length);
})
.catch(() => {})
.finally(() => setLoading(false));
.finally(() => {
if (seq === seqRef.current) setLoading(false);
});
}, []);
// Load ALL sessions for search (once, lazily)
const loadAllSessions = useCallback(() => {
if (allLoaded) return;
setAllLoaded(true);
// Fetch large batch for search
api.sessions
.list({ limit: 5000, offset: 0 })
.then(({ sessions: all }) => setAllSessions(all))
.catch(() => {});
}, [allLoaded]);
// Load first page when dropdown opens
// (Re)load the first page while open, debounced on the search text.
const query = search.trim();
useEffect(() => {
if (open && sessions.length === 0) {
fetchPage(0, true);
}
}, [open, sessions.length, fetchPage]);
// When user starts typing, load all sessions for search
useEffect(() => {
if (search.trim().length > 0) {
loadAllSessions();
}
}, [search, loadAllSessions]);
if (!open) return;
const timer = setTimeout(() => fetchPage(query, 0, true), query ? 250 : 0);
return () => clearTimeout(timer);
}, [open, query, fetchPage]);
// Close on outside click
useEffect(() => {
@@ -536,13 +533,7 @@ function SessionSelector({ onSelectSession }: SessionSelectorProps) {
return () => document.removeEventListener("mousedown", handleClick);
}, [open]);
// When searching, filter across ALL sessions; otherwise show paginated
const filtered = search.trim()
? (allSessions.length > 0 ? allSessions : sessions).filter((s) => {
const q = search.toLowerCase();
return (s.name ?? "").toLowerCase().includes(q) || s.id.toLowerCase().includes(q);
})
: sessions;
const filtered = sessions;
function handleSelect(id: string) {
setOpen(false);
@@ -551,7 +542,7 @@ function SessionSelector({ onSelectSession }: SessionSelectorProps) {
}
function handleLoadMore() {
fetchPage(offset, false);
fetchPage(query, offset, false);
}
return (
@@ -560,7 +551,8 @@ function SessionSelector({ onSelectSession }: SessionSelectorProps) {
<div
className={[
"flex items-center gap-2 px-3 py-2 rounded-lg border bg-surface-3 transition-colors cursor-text",
open ? "border-indigo-500/40 ring-1 ring-indigo-500/20" : "border-border",
"focus-within:ring-2 focus-within:ring-accent/40",
open ? "border-accent/40" : "border-border",
].join(" ")}
onClick={() => {
setOpen(true);
@@ -573,6 +565,7 @@ function SessionSelector({ onSelectSession }: SessionSelectorProps) {
type="text"
value={search}
placeholder={t("drillIn.searchPlaceholder")}
aria-label={t("drillIn.searchPlaceholder")}
className="flex-1 bg-transparent text-xs text-fg-secondary placeholder-fg-muted outline-none min-w-0"
onFocus={() => setOpen(true)}
onChange={(e) => {
@@ -648,8 +641,7 @@ function SessionSelector({ onSelectSession }: SessionSelectorProps) {
</div>
)}
{/* Load more - only show when not filtering client-side */}
{!search.trim() && hasMore && (
{hasMore && (
<button
type="button"
onClick={handleLoadMore}
@@ -12,7 +12,7 @@
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { api } from "../../lib/api";
import { eventBus } from "../../lib/eventBus";
import { useLiveRefresh } from "../../hooks/useLiveRefresh";
import type { SkillUsage } from "../../lib/types";
export function SkillUsagePanel({ statusFilter }: { statusFilter: string }) {
@@ -33,17 +33,10 @@ export function SkillUsagePanel({ statusFilter }: { statusFilter: string }) {
void refresh();
}, [refresh]);
useEffect(() => {
let timer: ReturnType<typeof setTimeout>;
const unsub = eventBus.subscribe(() => {
clearTimeout(timer);
timer = setTimeout(() => void refresh(), 3000);
});
return () => {
unsub();
clearTimeout(timer);
};
}, [refresh]);
// Throttled (not debounced): a steady event stream still refreshes every 3 s.
useLiveRefresh(() => void refresh(), {
match: (msg) => msg.type === "new_event" || msg.type === "session_updated",
});
if (error) return <p className="text-xs text-status-danger">{error}</p>;
if (!data) return null;
@@ -589,7 +589,7 @@ export function WorkflowRunsPanel({
<div className="text-[10px] uppercase tracking-wider text-fg-muted mb-0.5">
{t("runs.promptLabel")}
</div>
<pre className="text-[11px] text-fg-secondary whitespace-pre-wrap break-words bg-black/30 rounded p-2 max-h-48 overflow-auto">
<pre className="text-[11px] text-fg-secondary whitespace-pre-wrap break-words bg-surface-1 border border-border rounded p-2 max-h-48 overflow-auto">
{fullPrompt}
</pre>
</div>
@@ -598,7 +598,7 @@ export function WorkflowRunsPanel({
<div className="text-[10px] uppercase tracking-wider text-fg-muted mb-0.5">
{t("runs.resultLabel")}
</div>
<pre className="text-[11px] text-fg-secondary whitespace-pre-wrap break-words bg-black/30 rounded p-2 max-h-96 overflow-auto">
<pre className="text-[11px] text-fg-secondary whitespace-pre-wrap break-words bg-surface-1 border border-border rounded p-2 max-h-96 overflow-auto">
{fullResult}
</pre>
</div>