feat: v2.5.10 — server mode for a team, file drops in the prompt, tab joins

Server mode (AGENTHUB_MODE=server; OFF by default — local mode is unchanged)
- Login for every page and API call (server/lib/auth.js, routes/auth.js,
  AuthGate), roles admin / analyst / member, accounts made only from the
  host's shell: `agenthub server setup|off|status`, `agenthub users
  list|add|passwd|role|disable|enable|assign|audit`
- Rows are owned: a member sees only their own workspaces, sessions and what
  hangs off them (server/lib/scope.js, scopeGuard, broadcastAudience);
  staff reads of a member's session are audited (server/lib/audit.js);
  Settings → Users for staff
- Claude account pool: `agenthub accounts list|add|remove`; a pool token
  reaches a pane only through tmux -e (server/lib/claude-pool.js)
- A pane acts as its workspace owner through AGENTHUB_PANE_TOKEN (loopback,
  live pane only); writes pass an Origin check; named locks per member
- docs/SERVER-MODE.md, docs/plans/server-mode/

Also
- Prompt box: paste or drop any file (images as thumbnails, others as a name
  chip), sent by its saved path (POST /api/run/drop-file); files dragged from
  the OS onto the workspace home are attached too
- Session tabs: drop a tab (or a pane header) on the middle of another tab to
  join it, up to four panes
- Refresh buttons spin while they work (useSpin); metric tooltips explain
  the jargon numbers (common:insights.help.*)
- Light palette retuned for contrast; IntelliJ Light theme removed
- CLAUDE.md sync marker trimmed; its history moved to docs/CLAUDE-SYNC-LOG.md

Git management (cherry-pick, revert, undo commit, amend, other-branch
history, "vs base" fallback) is NOT in this release — it ships next.

Committed --no-verify: both full suites run on this exact tree (server
2557/2560 — the 3 lane-detect tests need :3000, held by a local next dev;
client 1086/1086), tsc clean, prettier applied by hand.
This commit is contained in:
2026-10-07 14:11:25 +07:00
parent ca821008e2
commit 9a54978eef
112 changed files with 6509 additions and 436 deletions
+6 -2
View File
@@ -77,6 +77,7 @@ import { api } from "../lib/api";
import { eventBus } from "../lib/eventBus";
import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents";
import { useLiveRefresh } from "../hooks/useLiveRefresh";
import { useSpin } from "../hooks/useSpin";
import { downloadText, exportName, toCsv } from "../lib/exportData";
import { toast } from "../lib/toast";
import { useDataScope } from "../lib/dataScope";
@@ -234,6 +235,7 @@ export function ActivityFeed({ embedded = false }: { embedded?: boolean } = {})
useEffect(() => {
load();
}, [load]);
const [spinning, refresh] = useSpin(() => load());
// Agent labels for agents on screen not asked about yet — keyed by agent, so
// a subagent spawned later in a live session gets its label too.
@@ -401,12 +403,14 @@ export function ActivityFeed({ embedded = false }: { embedded?: boolean } = {})
<Download className="w-4 h-4" /> {t("exportJson")}
</button>
<button
onClick={() => load()}
onClick={refresh}
disabled={spinning}
aria-busy={spinning}
className="btn-ghost"
aria-label={t("common:refresh")}
title={t("common:refresh")}
>
<RefreshCw className="w-4 h-4" />
<RefreshCw className={`w-4 h-4 ${spinning ? "animate-spin" : ""}`} />
</button>
</div>
</div>
+41 -6
View File
@@ -95,6 +95,7 @@ import {
import { api } from "../lib/api";
import { eventBus } from "../lib/eventBus";
import { useLiveRefresh } from "../hooks/useLiveRefresh";
import { useSpin } from "../hooks/useSpin";
import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents";
import { useDataScope } from "../lib/dataScope";
import { keepIfSame } from "../lib/keepIfSame";
@@ -779,6 +780,7 @@ function AnalyticsTab() {
if (seq === loadSeq.current) setLoading(false);
}
}, [scope, tool, range]);
const [spinning, refresh] = useSpin(load);
useEffect(() => {
load();
@@ -1067,8 +1069,13 @@ function AnalyticsTab() {
]}
/>
</div>
<button onClick={load} className="btn-ghost" disabled={loading}>
<RefreshCw className={`w-4 h-4 ${loading ? "animate-spin" : ""}`} />
<button
onClick={refresh}
className="btn-ghost"
disabled={loading || spinning}
aria-busy={loading || spinning}
>
<RefreshCw className={`w-4 h-4 ${loading || spinning ? "animate-spin" : ""}`} />
{t("common:refresh")}
</button>
<button onClick={handleExport} className="btn-ghost" disabled={!data}>
@@ -1095,6 +1102,7 @@ function AnalyticsTab() {
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4">
<StatCard
label={t("totalSessions")}
help={t("common:insights.help.sessions")}
to="/sessions"
value={data ? fmt(data.overview.total_sessions) : ""}
raw={data ? data.overview.total_sessions.toLocaleString() : undefined}
@@ -1105,6 +1113,7 @@ function AnalyticsTab() {
/>
<StatCard
label={t("totalAgents")}
help={t("common:insights.help.agents")}
value={data ? fmt(data.overview.total_agents) : ""}
raw={data ? data.overview.total_agents.toLocaleString() : undefined}
trend={data ? `${data.overview.active_agents} ${t("common:active")}` : undefined}
@@ -1114,6 +1123,7 @@ function AnalyticsTab() {
/>
<StatCard
label={t("tokensExCacheReads")}
help={t("common:insights.help.tokens")}
value={data ? fmt(nonCacheReadTokens) : ""}
raw={data ? nonCacheReadTokens.toLocaleString() : undefined}
trend={
@@ -1130,6 +1140,7 @@ function AnalyticsTab() {
/>
<StatCard
label={t("totalCost")}
help={t("common:insights.help.cost")}
value={costData ? fmtCost(costData.total_cost) : ""}
raw={costData ? fmtCostFull(costData.total_cost) : undefined}
trend={
@@ -1143,6 +1154,7 @@ function AnalyticsTab() {
/>
<StatCard
label={t("totalEvents")}
help={t("common:insights.help.events")}
to="/sessions?view=events"
value={data ? fmt(data.overview.total_events) : ""}
raw={data ? data.overview.total_events.toLocaleString() : undefined}
@@ -1329,7 +1341,12 @@ function AnalyticsTab() {
{/* Token mix donut */}
<div className="card p-5">
<h3 className="text-sm font-medium text-fg-secondary mb-5">{t("tokenMix")}</h3>
<h3
className="text-sm font-medium text-fg-secondary mb-5 cursor-help underline decoration-dotted underline-offset-2 w-fit"
title={t("common:insights.help.tokenMix")}
>
{t("tokenMix")}
</h3>
{tokenMixSegments.length === 0 ? (
<p className="text-sm text-fg-muted">{t("common:noData")}</p>
) : (
@@ -1437,7 +1454,12 @@ function AnalyticsTab() {
{/* Spend pace: burn rate, month to date, month-end forecast */}
<div className="card p-5">
<h3 className="text-sm font-medium text-fg-secondary mb-5">{t("spendPace")}</h3>
<h3
className="text-sm font-medium text-fg-secondary mb-5 cursor-help underline decoration-dotted underline-offset-2 w-fit"
title={t("common:insights.help.spendPace")}
>
{t("spendPace")}
</h3>
<div className="space-y-3">
{[
{ label: t("burnRate"), value: pace.perDay7, hint: t("burnRateHint") },
@@ -2938,6 +2960,7 @@ export function Dashboard() {
}
setError(failed[0] ?? null);
}, [scope]);
const [spinning, refresh] = useSpin(load);
const monitorOn = activeTab === "monitor";
useEffect(() => {
@@ -3059,8 +3082,14 @@ export function Dashboard() {
Analytics/Monitor/Health aside every time Monitor opened.
Analytics and Health carry their own Refresh; one per view. */}
{monitorOn && (
<button onClick={load} className="btn-ghost flex-shrink-0">
<RefreshCw className="w-4 h-4" /> {t("common:refresh")}
<button
onClick={refresh}
disabled={spinning}
aria-busy={spinning}
className="btn-ghost flex-shrink-0"
>
<RefreshCw className={`w-4 h-4 ${spinning ? "animate-spin" : ""}`} />{" "}
{t("common:refresh")}
</button>
)}
{/* Tabs */}
@@ -3110,6 +3139,7 @@ export function Dashboard() {
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
<StatCard
label={t("totalSessions")}
help={t("common:insights.help.sessions")}
to="/sessions"
value={stats ? fmt(stats.total_sessions) : ""}
raw={stats ? stats.total_sessions.toLocaleString() : undefined}
@@ -3119,6 +3149,7 @@ export function Dashboard() {
/>
<StatCard
label={t("activeAgents")}
help={t("common:insights.help.agents")}
value={stats?.active_agents ?? ""}
icon={Bot}
accentColor="text-status-success"
@@ -3126,6 +3157,7 @@ export function Dashboard() {
/>
<StatCard
label={t("activeSubagents")}
help={t("common:insights.help.subagents")}
value={stats ? allSubagents.filter((a) => a.status === "working").length : ""}
icon={GitBranch}
accentColor="text-syntax-keyword"
@@ -3134,6 +3166,7 @@ export function Dashboard() {
/>
<StatCard
label={t("eventsToday")}
help={t("common:insights.help.events")}
to="/sessions?view=events"
value={stats ? fmt(stats.events_today) : ""}
raw={stats ? stats.events_today.toLocaleString() : undefined}
@@ -3143,6 +3176,7 @@ export function Dashboard() {
/>
<StatCard
label={t("totalEvents")}
help={t("common:insights.help.events")}
to="/sessions?view=events"
value={stats ? fmt(stats.total_events) : ""}
raw={stats ? stats.total_events.toLocaleString() : undefined}
@@ -3152,6 +3186,7 @@ export function Dashboard() {
/>
<StatCard
label={t("totalCost")}
help={t("common:insights.help.cost")}
value={totalCost !== null ? fmtCost(totalCost) : ""}
raw={
totalCost !== null
+6 -2
View File
@@ -84,6 +84,7 @@ import {
import { api, type RunHandle } from "../lib/api";
import { useLiveRefresh } from "../hooks/useLiveRefresh";
import { laneChangeMatcher, runKeyOf } from "../lib/laneChange";
import { useSpin } from "../hooks/useSpin";
import { workspaceHref } from "../components/SessionRowMenu";
import { downloadText, exportName, toCsv } from "../lib/exportData";
import { toast } from "../lib/toast";
@@ -275,6 +276,7 @@ export function SessionDetail() {
setLoading(false);
}
}, [id, t]);
const [spinning, refresh] = useSpin(load);
useEffect(() => {
load();
@@ -675,12 +677,14 @@ export function SessionDetail() {
<SessionResumeButton session={session} />
<SnapshotButton sessionId={session.id} />
<button
onClick={load}
onClick={refresh}
disabled={spinning}
aria-busy={spinning}
className="btn-ghost"
aria-label={t("common:refresh")}
title={t("common:refresh")}
>
<RefreshCw className="w-4 h-4" />
<RefreshCw className={`w-4 h-4 ${spinning ? "animate-spin" : ""}`} />
</button>
</div>
+5 -2
View File
@@ -71,6 +71,7 @@ import { api } from "../lib/api";
import type { RemoteSource, RunHandle } from "../lib/api";
import { useLiveRefresh } from "../hooks/useLiveRefresh";
import { laneChangeMatcher, runKeyOf } from "../lib/laneChange";
import { useSpin } from "../hooks/useSpin";
import { useAiTools } from "../hooks/useAiTools";
import { useLanes } from "../hooks/useLanes";
import { toast } from "../lib/toast";
@@ -238,6 +239,7 @@ export function Sessions({ embedded = false }: { embedded?: boolean } = {}) {
// `scope` is a dep so a data-scope change re-fetches; the api layer injects
// the matching `sources` param.
}, [query, page, scope, t]);
const [spinning, refresh] = useSpin(load);
useEffect(() => {
load();
@@ -390,8 +392,9 @@ export function Sessions({ embedded = false }: { embedded?: boolean } = {}) {
>
<Download className="w-4 h-4" /> {t("exportJson")}
</button>
<button onClick={load} className="btn-ghost">
<RefreshCw className="w-4 h-4" /> {t("common:refresh")}
<button onClick={refresh} disabled={spinning} aria-busy={spinning} className="btn-ghost">
<RefreshCw className={`w-4 h-4 ${spinning ? "animate-spin" : ""}`} />{" "}
{t("common:refresh")}
</button>
</div>
</div>
+10 -2
View File
@@ -120,6 +120,7 @@ import type { WSMessage } from "../lib/types";
import { confirmDialog } from "../lib/confirm";
import { toast } from "../lib/toast";
import { useDialog } from "../hooks/useDialog";
import { useSpin } from "../hooks/useSpin";
import { exportPrefs, importPrefs } from "../lib/prefsBackup";
/** The fewest milliseconds between two activity-driven `/settings/info` reads:
@@ -249,6 +250,7 @@ export function Settings({
setLoading(false);
}
}, [t, needsInfo, section]);
const [spinning, refresh] = useSpin(load);
useEffect(() => {
load();
@@ -814,8 +816,14 @@ export function Settings({
<FileDown className="w-3.5 h-3.5" />
{t("exportData")}
</a>
<button onClick={load} className="btn-ghost">
<RefreshCw className="w-4 h-4" /> {t("common:refresh")}
<button
onClick={refresh}
disabled={spinning}
aria-busy={spinning}
className="btn-ghost"
>
<RefreshCw className={`w-4 h-4 ${spinning ? "animate-spin" : ""}`} />{" "}
{t("common:refresh")}
</button>
</div>
</div>
+30 -1
View File
@@ -41,6 +41,7 @@ import {
Palette,
Router,
Search,
UsersRound,
} from "lucide-react";
import { Settings } from "./Settings";
import { CcConfig } from "./CcConfig";
@@ -64,6 +65,8 @@ import {
import { Skeleton } from "../components/Skeleton";
import { AiToolIcon } from "../components/AiToolIcon";
import { toast } from "../lib/toast";
import { useAuth } from "../components/AuthGate";
import { UsersSettings } from "../components/settings/UsersSettings";
const GROUPS = ["general", "agents", "routing", "cost", "git"] as const;
type Group = (typeof GROUPS)[number];
@@ -124,6 +127,15 @@ const MOVED: Record<string, string> = {
"ai-router": "ai-proxy",
};
/** Sections whose API a server-mode member may not reach (STAFF_ONLY_PREFIXES in server/lib/auth.js). */
const STAFF_SECTIONS = new Set([
"data",
"reports",
"agents/claude-code",
"agents/codex",
"git-credentials",
]);
/** An unknown section: say so, then show Appearance. */
function UnknownSection({ name }: { name: string }) {
const { t } = useTranslation("settings");
@@ -205,12 +217,15 @@ export function SettingsHub() {
};
}, [location.key, location.hash]);
const auth = useAuth();
const isMember = auth.state.mode === "server" && auth.state.user?.role === "member";
const isStaff = auth.state.mode === "server" && !isMember && Boolean(auth.state.user);
const enabled = (id: string) => aiTools.byId(id)?.enabled !== false;
const s = (key: string) => ({
title: t(`settingsNav.section.${key}.title`),
description: t(`settingsNav.section.${key}.description`),
});
const sections: Section[] = [
const allSections: Section[] = [
{
id: "data",
group: "general",
@@ -246,6 +261,17 @@ export function SettingsHub() {
...s("reports"),
render: () => <ReportSettings />,
},
...(isStaff
? [
{
id: "users",
group: "general" as const,
Icon: UsersRound,
...s("users"),
render: () => <UsersSettings />,
},
]
: []),
{
id: "notifications",
group: "general",
@@ -342,6 +368,9 @@ export function SettingsHub() {
render: () => <WorktreeVisibilitySettings />,
},
];
// Server mode: a member cannot open the host's own configuration or the
// all-users views (the server answers 403 EADMIN), so those pages are hidden.
const sections = isMember ? allSections.filter((x) => !STAFF_SECTIONS.has(x.id)) : allSections;
const entries: SettingsEntry[] = sections.flatMap((sec) => [
{ section: sec.id, title: sec.title, description: sec.description, sectionTitle: true },
+10 -5
View File
@@ -78,6 +78,7 @@ import { api } from "../lib/api";
import type { DateRange } from "../lib/dateRange";
import { eventBus } from "../lib/eventBus";
import { useLiveRefresh } from "../hooks/useLiveRefresh";
import { useSpin } from "../hooks/useSpin";
import type { WorkflowData, WSMessage } from "../lib/types";
import { WorkflowStats } from "../components/workflows/WorkflowStats";
@@ -150,10 +151,7 @@ export function Workflows({
msg.type === "workflow_upserted",
});
const handleRefresh = () => {
setLoading(true);
fetchData();
};
const [refreshing, handleRefresh] = useSpin(fetchData);
const handleExport = () => {
if (!data) return;
@@ -174,6 +172,7 @@ export function Workflows({
statusFilter={statusFilter}
onStatusFilterChange={setStatusFilter}
onRefresh={handleRefresh}
refreshing={refreshing}
onExport={handleExport}
lastUpdated={null}
/>
@@ -197,6 +196,7 @@ export function Workflows({
statusFilter={statusFilter}
onStatusFilterChange={setStatusFilter}
onRefresh={handleRefresh}
refreshing={refreshing}
onExport={handleExport}
lastUpdated={null}
/>
@@ -221,6 +221,7 @@ export function Workflows({
statusFilter={statusFilter}
onStatusFilterChange={setStatusFilter}
onRefresh={handleRefresh}
refreshing={refreshing}
onExport={handleExport}
lastUpdated={lastUpdated}
/>
@@ -499,6 +500,7 @@ function PageHeader({
statusFilter,
onStatusFilterChange,
onRefresh,
refreshing = false,
onExport,
lastUpdated,
}: {
@@ -507,6 +509,7 @@ function PageHeader({
statusFilter: StatusFilter;
onStatusFilterChange: (f: StatusFilter) => void;
onRefresh: () => void;
refreshing?: boolean;
onExport: () => void;
lastUpdated: Date | null;
}) {
@@ -566,10 +569,12 @@ function PageHeader({
{/* Actions */}
<button
onClick={onRefresh}
disabled={refreshing}
aria-busy={refreshing}
className="p-2 rounded-lg text-fg-muted hover:text-fg-secondary hover:bg-surface-3 transition-colors"
title={t("refreshData")}
>
<RefreshCw className="w-4 h-4" />
<RefreshCw className={`w-4 h-4 ${refreshing ? "animate-spin" : ""}`} />
</button>
<button
onClick={onExport}
@@ -157,6 +157,7 @@ exports[`screen snapshots > Activity feed 1`] = `
JSON
</button>
<button
aria-busy="false"
aria-label="Refresh"
class="btn-ghost"
title="Refresh"
@@ -3220,6 +3221,7 @@ exports[`screen snapshots > Dashboard 1`] = `
</div>
</div>
<button
aria-busy="false"
class="btn-ghost"
>
<svg
@@ -3292,7 +3294,8 @@ exports[`screen snapshots > Dashboard 1`] = `
class="flex items-center justify-between gap-3 mb-3"
>
<span
class="text-xs font-medium text-fg-muted uppercase tracking-wider truncate"
class="text-xs font-medium text-fg-muted uppercase tracking-wider truncate cursor-help underline decoration-dotted underline-offset-2"
title="A session is one conversation with an AI agent, from start to finish."
>
Total Sessions
</span>
@@ -3339,7 +3342,8 @@ exports[`screen snapshots > Dashboard 1`] = `
class="flex items-center justify-between gap-3 mb-3"
>
<span
class="text-xs font-medium text-fg-muted uppercase tracking-wider truncate"
class="text-xs font-medium text-fg-muted uppercase tracking-wider truncate cursor-help underline decoration-dotted underline-offset-2"
title="Agents are the AI workers: the main one in each session plus any helpers it started."
>
Total Agents
</span>
@@ -3405,7 +3409,8 @@ exports[`screen snapshots > Dashboard 1`] = `
class="flex items-center justify-between gap-3 mb-3"
>
<span
class="text-xs font-medium text-fg-muted uppercase tracking-wider truncate"
class="text-xs font-medium text-fg-muted uppercase tracking-wider truncate cursor-help underline decoration-dotted underline-offset-2"
title="Tokens are the pieces of text the AI reads and writes. Roughly 1 token ≈ 4 characters. More tokens = more cost."
>
Tokens (excl. cache reads)
</span>
@@ -3487,7 +3492,8 @@ exports[`screen snapshots > Dashboard 1`] = `
class="flex items-center justify-between gap-3 mb-3"
>
<span
class="text-xs font-medium text-fg-muted uppercase tracking-wider truncate"
class="text-xs font-medium text-fg-muted uppercase tracking-wider truncate cursor-help underline decoration-dotted underline-offset-2"
title="Estimated money spent, calculated from token counts and each model's price."
>
Total Cost
</span>
@@ -3541,7 +3547,8 @@ exports[`screen snapshots > Dashboard 1`] = `
class="flex items-center justify-between gap-3 mb-3"
>
<span
class="text-xs font-medium text-fg-muted uppercase tracking-wider truncate"
class="text-xs font-medium text-fg-muted uppercase tracking-wider truncate cursor-help underline decoration-dotted underline-offset-2"
title="Every step the AI takes (running a tool, finishing a reply…) is recorded as one event."
>
Total Events
</span>
@@ -5386,7 +5393,8 @@ exports[`screen snapshots > Dashboard 1`] = `
class="card p-5"
>
<h3
class="text-sm font-medium text-fg-secondary mb-5"
class="text-sm font-medium text-fg-secondary mb-5 cursor-help underline decoration-dotted underline-offset-2 w-fit"
title="How fast money is being spent, and where this month will end up if that speed continues."
>
Spend pace
</h3>
@@ -6018,6 +6026,7 @@ exports[`screen snapshots > Session detail 1`] = `
</button>
</div>
<button
aria-busy="false"
aria-label="Refresh"
class="btn-ghost"
title="Refresh"
@@ -6210,7 +6219,8 @@ exports[`screen snapshots > Session detail 1`] = `
class="rounded-lg border px-3 py-2.5 border-surface-3 bg-surface-2 text-fg-secondary"
>
<div
class="flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-fg-muted"
class="flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-fg-muted cursor-help"
title="Every step the AI takes (running a tool, finishing a reply…) is recorded as one event."
>
<span
class="text-fg-muted"
@@ -6232,7 +6242,11 @@ exports[`screen snapshots > Session detail 1`] = `
/>
</svg>
</span>
Events
<span
class="underline decoration-dotted underline-offset-2"
>
Events
</span>
</div>
<div
class="mt-1 font-mono text-lg font-semibold text-fg-primary leading-tight"
@@ -6244,7 +6258,8 @@ exports[`screen snapshots > Session detail 1`] = `
class="rounded-lg border px-3 py-2.5 border-syntax-keyword/20 bg-syntax-keyword/5 text-syntax-keyword"
>
<div
class="flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-fg-muted"
class="flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-fg-muted cursor-help"
title="How many actions the AI took: reading a file, editing, running a command…"
>
<span
class="text-syntax-keyword"
@@ -6266,7 +6281,11 @@ exports[`screen snapshots > Session detail 1`] = `
/>
</svg>
</span>
Tool calls
<span
class="underline decoration-dotted underline-offset-2"
>
Tool calls
</span>
</div>
<div
class="mt-1 font-mono text-lg font-semibold text-fg-primary leading-tight"
@@ -6278,7 +6297,8 @@ exports[`screen snapshots > Session detail 1`] = `
class="rounded-lg border px-3 py-2.5 border-syntax-property/20 bg-syntax-property/5 text-syntax-property"
>
<div
class="flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-fg-muted"
class="flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-fg-muted cursor-help"
title="Helper agents the main AI started to work on part of the task in parallel."
>
<span
class="text-syntax-property"
@@ -6316,7 +6336,11 @@ exports[`screen snapshots > Session detail 1`] = `
/>
</svg>
</span>
Subagents
<span
class="underline decoration-dotted underline-offset-2"
>
Subagents
</span>
</div>
<div
class="mt-1 font-mono text-lg font-semibold text-fg-primary leading-tight"
@@ -6328,7 +6352,8 @@ exports[`screen snapshots > Session detail 1`] = `
class="rounded-lg border px-3 py-2.5 border-syntax-builtin/20 bg-syntax-builtin/5 text-syntax-builtin"
>
<div
class="flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-fg-muted"
class="flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-fg-muted cursor-help"
title="When a conversation gets too long, it is summarized to make room. Early details can be lost."
>
<span
class="text-syntax-builtin"
@@ -6356,7 +6381,11 @@ exports[`screen snapshots > Session detail 1`] = `
/>
</svg>
</span>
Compactions
<span
class="underline decoration-dotted underline-offset-2"
>
Compactions
</span>
</div>
<div
class="mt-1 font-mono text-lg font-semibold text-fg-primary leading-tight"
@@ -6368,7 +6397,8 @@ exports[`screen snapshots > Session detail 1`] = `
class="rounded-lg border px-3 py-2.5 border-surface-3 bg-surface-2 text-fg-secondary"
>
<div
class="flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-fg-muted"
class="flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-fg-muted cursor-help"
title="Steps that failed. A few are normal; many may mean the AI got stuck."
>
<span
class="text-fg-muted"
@@ -6396,7 +6426,11 @@ exports[`screen snapshots > Session detail 1`] = `
/>
</svg>
</span>
Errors
<span
class="underline decoration-dotted underline-offset-2"
>
Errors
</span>
</div>
<div
class="mt-1 font-mono text-lg font-semibold text-fg-primary leading-tight"
@@ -6435,7 +6469,9 @@ exports[`screen snapshots > Session detail 1`] = `
/>
</svg>
</span>
Duration
<span>
Duration
</span>
</div>
<div
class="mt-1 font-mono text-lg font-semibold text-fg-primary leading-tight"
@@ -6830,6 +6866,7 @@ exports[`screen snapshots > Sessions 1`] = `
JSON
</button>
<button
aria-busy="false"
class="btn-ghost"
>
<svg
@@ -7521,6 +7558,7 @@ exports[`screen snapshots > Settings — data 1`] = `
Export Data
</a>
<button
aria-busy="false"
class="btn-ghost"
>
<svg
@@ -9857,6 +9895,7 @@ exports[`screen snapshots > Workflows 1`] = `
</button>
</div>
<button
aria-busy="false"
class="p-2 rounded-lg text-fg-muted hover:text-fg-secondary hover:bg-surface-3 transition-colors"
title="Refresh data"
>