@oh-my-pi/omp-stats 18.0.10 → 18.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/dist/client/index.css +1 -1
  3. package/dist/client/index.js +101 -101
  4. package/dist/client/styles.css +265 -0
  5. package/dist/types/client/api.d.ts +7 -1
  6. package/dist/types/client/app/routes.d.ts +1 -1
  7. package/dist/types/client/data/useHashRoute.d.ts +2 -0
  8. package/dist/types/client/routes/TracesRoute.d.ts +11 -0
  9. package/dist/types/client/routes/index.d.ts +1 -0
  10. package/dist/types/client/traces/AggregatesPanel.d.ts +9 -0
  11. package/dist/types/client/traces/Minimap.d.ts +14 -0
  12. package/dist/types/client/traces/SpanDrawer.d.ts +14 -0
  13. package/dist/types/client/traces/SummaryStrip.d.ts +9 -0
  14. package/dist/types/client/traces/TimelineCanvas.d.ts +25 -0
  15. package/dist/types/client/traces/TraceView.d.ts +11 -0
  16. package/dist/types/client/traces/TranscriptList.d.ts +24 -0
  17. package/dist/types/client/traces/time-scale.d.ts +40 -0
  18. package/dist/types/client/traces/trace-colors.d.ts +31 -0
  19. package/dist/types/db.d.ts +24 -1
  20. package/dist/types/index.d.ts +1 -1
  21. package/dist/types/parser.d.ts +8 -0
  22. package/dist/types/server.d.ts +3 -2
  23. package/dist/types/shared-types.d.ts +130 -0
  24. package/dist/types/trace.d.ts +33 -0
  25. package/dist/types/types.d.ts +35 -1
  26. package/package.json +8 -8
  27. package/src/client/App.tsx +11 -1
  28. package/src/client/api.ts +23 -0
  29. package/src/client/app/routes.ts +7 -0
  30. package/src/client/data/useHashRoute.ts +28 -10
  31. package/src/client/routes/ProvidersRoute.tsx +1 -0
  32. package/src/client/routes/TracesRoute.tsx +182 -0
  33. package/src/client/routes/index.ts +1 -0
  34. package/src/client/styles.css +316 -0
  35. package/src/client/traces/AggregatesPanel.tsx +88 -0
  36. package/src/client/traces/Minimap.tsx +177 -0
  37. package/src/client/traces/SpanDrawer.tsx +309 -0
  38. package/src/client/traces/SummaryStrip.tsx +37 -0
  39. package/src/client/traces/TimelineCanvas.tsx +768 -0
  40. package/src/client/traces/TraceView.tsx +344 -0
  41. package/src/client/traces/TranscriptList.tsx +166 -0
  42. package/src/client/traces/time-scale.ts +250 -0
  43. package/src/client/traces/trace-colors.ts +77 -0
  44. package/src/db.ts +77 -0
  45. package/src/index.ts +1 -6
  46. package/src/parser.ts +55 -2
  47. package/src/server.ts +69 -17
  48. package/src/shared-types.ts +138 -0
  49. package/src/trace.ts +1002 -0
  50. package/src/types.ts +45 -1
@@ -127,6 +127,20 @@ export interface CostTimeSeriesPoint {
127
127
  /** Request count */
128
128
  requests: number;
129
129
  }
130
+ /**
131
+ * One local calendar day of aggregate request activity, for the `/usage`
132
+ * activity heatmap in the coding-agent TUI.
133
+ */
134
+ export interface DailyActivityPoint {
135
+ /** Local calendar date, `YYYY-MM-DD`. */
136
+ day: string;
137
+ /** Summed API-equivalent cost for the day. */
138
+ cost: number;
139
+ /** Request count for the day. */
140
+ requests: number;
141
+ /** Total tokens (input + output + cache) for the day. */
142
+ totalTokens: number;
143
+ }
130
144
  /**
131
145
  * Overall dashboard stats.
132
146
  */
@@ -419,3 +433,119 @@ export interface ProviderDashboardStats {
419
433
  usageSeries: UsageWindowSeries[];
420
434
  windowInsights: ProviderWindowInsight[];
421
435
  }
436
+ /**
437
+ * One row of the Traces session list: a root session with every child
438
+ * transcript (task subagents, advisors) folded in.
439
+ */
440
+ export interface SessionSummary {
441
+ /** Absolute root session file path (trace key). */
442
+ file: string;
443
+ /** Decoded project path (e.g. `/work/pi`). */
444
+ folder: string;
445
+ title: string | null;
446
+ /** ms epoch of first activity. */
447
+ startedAt: number;
448
+ /** ms epoch of last activity, children included. */
449
+ endedAt: number;
450
+ /** Assistant messages, children folded in. */
451
+ requests: number;
452
+ toolCalls: number;
453
+ /** Child transcript count. */
454
+ subagents: number;
455
+ totalTokens: number;
456
+ costTotal: number;
457
+ models: string[];
458
+ }
459
+ export type TraceSpanKind = "turn" | "model" | "tool" | "subagent" | "background";
460
+ /** One rendered block on a trace track lane. */
461
+ export interface TraceSpan {
462
+ /** `${track.id}:${entryId}` (+`:${toolCallId}` for tool spans); stable across refetch. */
463
+ id: string;
464
+ kind: TraceSpanKind;
465
+ /** ms epoch. */
466
+ start: number;
467
+ /** ms epoch, >= start. */
468
+ end: number;
469
+ /** ≤80 chars: tool name / model id / user-text head / agent name. */
470
+ label: string;
471
+ /** ≤160 chars: args projection / result head / task text. */
472
+ detail?: string;
473
+ /** Journal entry id for /api/session/entry. */
474
+ entryId?: string;
475
+ toolCallId?: string;
476
+ model?: string;
477
+ /** usage.totalTokens (model spans). */
478
+ tokens?: number;
479
+ /** usage.cost.total (model spans). */
480
+ cost?: number;
481
+ /** Time to first token, ms offset from start. */
482
+ ttft?: number;
483
+ isError?: boolean;
484
+ /** End synthesized (no result / pending at session exit). */
485
+ unterminated?: boolean;
486
+ /** Set on `subagent` spans whose child transcript became a track. */
487
+ childTrackId?: string;
488
+ }
489
+ /** Point event drawn on a track header row. */
490
+ export interface TraceMarker {
491
+ /** ms epoch. */
492
+ time: number;
493
+ kind: "compaction" | "model_change" | "mode_change" | "reset" | "session_exit";
494
+ /** e.g. "compaction 142k→38k", model id, mode name, exit kind. */
495
+ label: string;
496
+ }
497
+ /** One transcript (main session, subagent, advisor) in a trace. */
498
+ export interface TraceTrack {
499
+ /** "main" or slash-joined child key: "Scout1", "Scout1/Nested2", "__advisor". */
500
+ id: string;
501
+ parentId: string | null;
502
+ label: string;
503
+ /** session_init.agent when recorded. */
504
+ agent: string | null;
505
+ /** session_init.resolvedModel ?? first assistant model. */
506
+ model: string | null;
507
+ /** Absolute transcript path (for entry fetch). */
508
+ file: string;
509
+ /** Sorted by start. */
510
+ spans: TraceSpan[];
511
+ markers: TraceMarker[];
512
+ }
513
+ /** Per-tool duration aggregate across all tracks of one trace. */
514
+ export interface TraceToolStat {
515
+ tool: string;
516
+ calls: number;
517
+ errors: number;
518
+ totalMs: number;
519
+ maxMs: number;
520
+ }
521
+ /** Headline aggregates for one trace. */
522
+ export interface TraceSummary {
523
+ wallMs: number;
524
+ /** Summed model-span duration on the main track. */
525
+ modelMs: number;
526
+ /** Summed tool-span duration on the main track. */
527
+ toolMs: number;
528
+ /** Wall time not covered by any span on any track. */
529
+ idleMs: number;
530
+ turns: number;
531
+ requests: number;
532
+ toolCalls: number;
533
+ subagents: number;
534
+ totalTokens: number;
535
+ costTotal: number;
536
+ /** Sorted totalMs desc. */
537
+ toolStats: TraceToolStat[];
538
+ }
539
+ /** Complete span tree for one session, `/api/session/trace` payload. */
540
+ export interface SessionTrace {
541
+ file: string;
542
+ title: string | null;
543
+ cwd: string | null;
544
+ startedAt: number;
545
+ endedAt: number;
546
+ /** Root transcript mtime; doubles as the ETag. */
547
+ mtimeMs: number;
548
+ /** DFS order, main first. */
549
+ tracks: TraceTrack[];
550
+ summary: TraceSummary;
551
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Session trace assembly for the Traces dashboard section.
3
+ *
4
+ * Builds a span tree (turns, model calls, tool calls, subagents, background
5
+ * jobs) directly from raw session JSONL — the only source with tool start/end
6
+ * times — while stats.db supplies the session-list rollups. Entry shapes are
7
+ * mirrored structurally from the coding-agent journal (`tool_execution_start`,
8
+ * `session_exit`, task results, async-result batches); stats never imports
9
+ * coding-agent.
10
+ */
11
+ import type { SessionEntry, SessionSummary, SessionTrace } from "./types.js";
12
+ /** Client-supplied path escaped the sessions root or is not a transcript. Maps to HTTP 400. */
13
+ export declare class TracePathError extends Error {
14
+ }
15
+ /**
16
+ * Trace payload schema/assembly revision, folded into the HTTP ETag. Bump when
17
+ * span assembly changes so browsers don't revalidate stale cached bodies
18
+ * against an unchanged transcript mtime.
19
+ */
20
+ export declare const TRACE_ETAG_VERSION = 2;
21
+ /**
22
+ * Assemble the full span tree for one root session transcript.
23
+ * Throws {@link TracePathError} for paths outside the sessions root; ENOENT
24
+ * passes through for the caller's 404 mapping.
25
+ */
26
+ export declare function buildSessionTrace(fileParam: string): Promise<SessionTrace>;
27
+ /** Fetch one full journal entry by id from a transcript, for the span drawer. */
28
+ export declare function getTraceEntry(fileParam: string, entryId: string): Promise<SessionEntry | null>;
29
+ /**
30
+ * List root sessions for the Traces section, folding every synced child
31
+ * transcript (subagents, advisors) into its root row.
32
+ */
33
+ export declare function listSessionSummaries(limit?: number, q?: string): Promise<SessionSummary[]>;
@@ -70,7 +70,41 @@ export interface SessionServiceTierChangeEntry {
70
70
  timestamp: string;
71
71
  serviceTier: ServiceTierByFamily | ServiceTier | null;
72
72
  }
73
- export type SessionEntry = SessionHeader | SessionMessageEntry | SessionServiceTierChangeEntry | {
73
+ export interface SessionModelUsageEntry {
74
+ type: "model_usage";
75
+ id: string;
76
+ parentId: string | null;
77
+ timestamp: string;
78
+ purpose: string;
79
+ role?: string;
80
+ api: string;
81
+ provider: string;
82
+ model: string;
83
+ usage: Usage;
84
+ stopReason?: StopReason;
85
+ errorMessage?: string;
86
+ }
87
+ /**
88
+ * Custom journal entry (`tool_execution_start`, `session_exit`, …). Mirrors
89
+ * the coding-agent shape structurally — stats never imports coding-agent.
90
+ */
91
+ export interface SessionCustomEntry {
92
+ type: "custom";
93
+ id?: string;
94
+ parentId?: string | null;
95
+ timestamp?: string;
96
+ customType: string;
97
+ data?: Record<string, unknown>;
98
+ }
99
+ /** Structural variants the trace builder matches on beyond messages. */
100
+ export interface SessionTypedEntry {
101
+ type: "session_init" | "compaction" | "model_change" | "mode_change" | "reset_boundary";
102
+ id?: string;
103
+ parentId?: string | null;
104
+ timestamp?: string;
105
+ [key: string]: unknown;
106
+ }
107
+ export type SessionEntry = SessionHeader | SessionMessageEntry | SessionServiceTierChangeEntry | SessionModelUsageEntry | SessionCustomEntry | SessionTypedEntry | {
74
108
  type: string;
75
109
  };
76
110
  /**
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/omp-stats",
4
- "version": "18.0.10",
4
+ "version": "18.1.0",
5
5
  "description": "Local observability dashboard for pi AI usage statistics",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Stencil Labs, Inc.",
@@ -32,16 +32,16 @@
32
32
  "gen:stats:reset": "bun scripts/generate-client-bundle.ts --reset",
33
33
  "build": "bun run build.ts",
34
34
  "dev": "bun run src/index.ts",
35
- "check": "biome check . && bun run check:types",
35
+ "check": "oxlint . && oxfmt --check --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts' && bun run check:types",
36
36
  "check:types": "tsgo -p tsconfig.json --noEmit && tsgo -p tsconfig.client.json --noEmit",
37
- "lint": "biome lint .",
38
- "fix": "biome check --write --unsafe .",
39
- "fmt": "biome format --write ."
37
+ "lint": "oxlint .",
38
+ "fix": "oxlint --fix --fix-suggestions . && bun run fmt",
39
+ "fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
40
40
  },
41
41
  "dependencies": {
42
- "@oh-my-pi/pi-ai": "18.0.10",
43
- "@oh-my-pi/pi-catalog": "18.0.10",
44
- "@oh-my-pi/pi-utils": "18.0.10",
42
+ "@oh-my-pi/pi-ai": "18.1.0",
43
+ "@oh-my-pi/pi-catalog": "18.1.0",
44
+ "@oh-my-pi/pi-utils": "18.1.0",
45
45
  "@tailwindcss/node": "^4.3.2",
46
46
  "chart.js": "^4.5.1",
47
47
  "lucide-react": "^1.24.0",
@@ -13,11 +13,12 @@ import {
13
13
  ProvidersRoute,
14
14
  RequestsRoute,
15
15
  ToolsRoute,
16
+ TracesRoute,
16
17
  } from "./routes";
17
18
  import { RequestDrawer } from "./ui/RequestDrawer";
18
19
 
19
20
  export default function App() {
20
- const { section, setSection, range, setRange } = useHashRoute();
21
+ const { section, setSection, range, setRange, session, setSession } = useHashRoute();
21
22
  const [refreshTrigger, setRefreshTrigger] = useState(0);
22
23
  const [selectedRequestId, setSelectedRequestId] = useState<number | null>(null);
23
24
  const [updatedAt, setUpdatedAt] = useState<number | null>(() => Date.now());
@@ -63,6 +64,15 @@ export default function App() {
63
64
  onRequestClick={setSelectedRequestId}
64
65
  />
65
66
  );
67
+ case "traces":
68
+ return (
69
+ <TracesRoute
70
+ active={isActive}
71
+ session={session}
72
+ onOpenSession={setSession}
73
+ refreshTrigger={refreshTrigger}
74
+ />
75
+ );
66
76
  case "errors":
67
77
  return (
68
78
  <ErrorsRoute
package/src/client/api.ts CHANGED
@@ -8,6 +8,8 @@ import type {
8
8
  OverviewStats,
9
9
  ProviderDashboardStats,
10
10
  RequestDetails,
11
+ SessionSummary,
12
+ SessionTrace,
11
13
  TimeRange,
12
14
  ToolDashboardStats,
13
15
  } from "./types";
@@ -116,3 +118,24 @@ export async function getProviderDashboardStats(
116
118
  signal,
117
119
  });
118
120
  }
121
+ export async function getSessions(limit = 100, q?: string, signal?: AbortSignal): Promise<SessionSummary[]> {
122
+ const params = new URLSearchParams({ limit: String(limit) });
123
+ if (q) params.set("q", q);
124
+ return fetchJson<SessionSummary[]>(`${API_BASE}/sessions?${params}`, { signal });
125
+ }
126
+
127
+ export async function getSessionTrace(file: string, signal?: AbortSignal): Promise<SessionTrace> {
128
+ return fetchJson<SessionTrace>(`${API_BASE}/session/trace?file=${encodeURIComponent(file)}`, { signal });
129
+ }
130
+
131
+ /** Fetch one full journal entry for the span drawer. Entries are opaque JSON. */
132
+ export async function getSessionEntryDetail(
133
+ file: string,
134
+ id: string,
135
+ signal?: AbortSignal,
136
+ ): Promise<{ entry: unknown }> {
137
+ return fetchJson<{ entry: unknown }>(
138
+ `${API_BASE}/session/entry?file=${encodeURIComponent(file)}&id=${encodeURIComponent(id)}`,
139
+ { signal },
140
+ );
141
+ }
@@ -5,6 +5,7 @@ import {
5
5
  Cpu,
6
6
  Folder,
7
7
  LayoutDashboard,
8
+ ListTree,
8
9
  Plug,
9
10
  Smile,
10
11
  TrendingUp,
@@ -15,6 +16,7 @@ import type React from "react";
15
16
  export type DashboardSection =
16
17
  | "overview"
17
18
  | "requests"
19
+ | "traces"
18
20
  | "errors"
19
21
  | "models"
20
22
  | "providers"
@@ -42,6 +44,11 @@ export const routes: DashboardRoute[] = [
42
44
  label: "Requests",
43
45
  icon: Activity,
44
46
  },
47
+ {
48
+ id: "traces",
49
+ label: "Traces",
50
+ icon: ListTree,
51
+ },
45
52
  {
46
53
  id: "errors",
47
54
  label: "Errors",
@@ -5,6 +5,7 @@ import type { TimeRange } from "../types";
5
5
  const VALID_SECTIONS: DashboardSection[] = [
6
6
  "overview",
7
7
  "requests",
8
+ "traces",
8
9
  "errors",
9
10
  "models",
10
11
  "providers",
@@ -17,7 +18,7 @@ const VALID_SECTIONS: DashboardSection[] = [
17
18
 
18
19
  const VALID_RANGES: TimeRange[] = ["1h", "24h", "7d", "30d", "90d", "all"];
19
20
 
20
- function parseHash(hash: string): { section: DashboardSection; range: TimeRange } {
21
+ function parseHash(hash: string): { section: DashboardSection; range: TimeRange; session: string | null } {
21
22
  const cleanHash = hash.replace(/^#\/?/, "");
22
23
  const [pathPart, queryPart] = cleanHash.split("?");
23
24
 
@@ -26,15 +27,22 @@ function parseHash(hash: string): { section: DashboardSection; range: TimeRange
26
27
  : "overview";
27
28
 
28
29
  let range: TimeRange = "24h";
30
+ let session: string | null = null;
29
31
  if (queryPart) {
30
32
  const params = new URLSearchParams(queryPart);
31
33
  const rangeParam = params.get("range") as TimeRange;
32
34
  if (VALID_RANGES.includes(rangeParam)) {
33
35
  range = rangeParam;
34
36
  }
37
+ session = params.get("s");
35
38
  }
36
39
 
37
- return { section, range };
40
+ return { section, range, session };
41
+ }
42
+
43
+ function buildHash(section: string, range: TimeRange, session?: string | null): string {
44
+ const sessionPart = session ? `&s=${encodeURIComponent(session)}` : "";
45
+ return `/${section}?range=${range}${sessionPart}`;
38
46
  }
39
47
 
40
48
  export function useHashRoute() {
@@ -51,31 +59,39 @@ export function useHashRoute() {
51
59
  };
52
60
  }, []);
53
61
 
54
- const updateHash = useCallback((section: string, range: TimeRange) => {
55
- window.location.hash = `/${section}?range=${range}`;
62
+ const updateHash = useCallback((section: string, range: TimeRange, session?: string | null) => {
63
+ window.location.hash = buildHash(section, range, session);
56
64
  }, []);
57
65
 
58
66
  const setSection = useCallback(
59
67
  (newSection: DashboardSection) => {
60
- updateHash(newSection, route.range);
68
+ // The deep-linked session only applies to the traces view.
69
+ updateHash(newSection, route.range, newSection === "traces" ? route.session : null);
61
70
  },
62
- [route.range, updateHash],
71
+ [route.range, route.session, updateHash],
63
72
  );
64
73
 
65
74
  const setRange = useCallback(
66
75
  (newRange: string) => {
67
76
  const nextRange = VALID_RANGES.includes(newRange as TimeRange) ? (newRange as TimeRange) : "24h";
68
- updateHash(route.section, nextRange);
77
+ updateHash(route.section, nextRange, route.session);
78
+ },
79
+ [route.section, route.session, updateHash],
80
+ );
81
+
82
+ const setSession = useCallback(
83
+ (file: string | null) => {
84
+ updateHash(route.section, route.range, file);
69
85
  },
70
- [route.section, updateHash],
86
+ [route.section, route.range, updateHash],
71
87
  );
72
88
 
73
89
  useEffect(() => {
74
90
  const currentHash = window.location.hash;
75
91
  const parsed = parseHash(currentHash);
76
- const expectedHash = `#/${parsed.section}?range=${parsed.range}`;
92
+ const expectedHash = `#${buildHash(parsed.section, parsed.range, parsed.session)}`;
77
93
  if (currentHash !== expectedHash) {
78
- window.location.hash = `/${parsed.section}?range=${parsed.range}`;
94
+ window.location.hash = buildHash(parsed.section, parsed.range, parsed.session);
79
95
  }
80
96
  }, []);
81
97
 
@@ -84,5 +100,7 @@ export function useHashRoute() {
84
100
  setSection,
85
101
  range: route.range,
86
102
  setRange,
103
+ session: route.session,
104
+ setSession,
87
105
  };
88
106
  }
@@ -234,6 +234,7 @@ function PeakHoursPanel({ hourly, providers }: { hourly: ProviderHourlyPoint[];
234
234
  const chartTheme = CHART_THEMES[theme];
235
235
 
236
236
  const { tokensByHour, peakHour } = useMemo(() => {
237
+ // oxlint-disable-next-line unicorn/no-new-array -- length preallocation
237
238
  const tokens = new Array<number>(24).fill(0);
238
239
  for (const point of hourly) {
239
240
  if (provider !== ALL_PROVIDERS && point.provider !== provider) continue;
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Traces section: root-session list (subagents folded in) that opens the
3
+ * flamegraph trace viewer for a selected session.
4
+ */
5
+
6
+ import { useMemo, useState } from "react";
7
+ import { getSessions } from "../api";
8
+ import { formatCompact, formatCost, formatDurationMs, formatRelativeTime } from "../data/formatters";
9
+ import { useResource } from "../data/useResource";
10
+ import { TraceView } from "../traces/TraceView";
11
+ import type { SessionSummary } from "../types";
12
+ import { AsyncBoundary, DataTable, Panel } from "../ui";
13
+
14
+ export interface TracesRouteProps {
15
+ active: boolean;
16
+ session: string | null;
17
+ onOpenSession: (file: string | null) => void;
18
+ refreshTrigger: number;
19
+ }
20
+
21
+ function ModelChips({ models }: { models: string[] }) {
22
+ const shown = models.slice(0, 3);
23
+ return (
24
+ <div style={{ display: "flex", gap: 4, flexWrap: "wrap" }}>
25
+ {shown.map(model => (
26
+ <span
27
+ key={model}
28
+ className="stats-text-muted truncate"
29
+ style={{
30
+ fontSize: 10,
31
+ border: "1px solid var(--border)",
32
+ borderRadius: 999,
33
+ padding: "1px 6px",
34
+ maxWidth: 140,
35
+ }}
36
+ >
37
+ {model}
38
+ </span>
39
+ ))}
40
+ {models.length > 3 && (
41
+ <span className="stats-text-muted" style={{ fontSize: 10 }}>
42
+ +{models.length - 3}
43
+ </span>
44
+ )}
45
+ </div>
46
+ );
47
+ }
48
+
49
+ export function TracesRoute({ active, session, onOpenSession, refreshTrigger }: TracesRouteProps) {
50
+ const [filter, setFilter] = useState("");
51
+
52
+ const {
53
+ data: sessions,
54
+ error,
55
+ loading,
56
+ } = useResource(["sessions", refreshTrigger], signal => getSessions(200, undefined, signal), {
57
+ pollMs: 30000,
58
+ enabled: active && session === null,
59
+ });
60
+
61
+ const filtered = useMemo(() => {
62
+ if (!sessions) return [];
63
+ const needle = filter.trim().toLowerCase();
64
+ if (!needle) return sessions;
65
+ return sessions.filter(
66
+ row =>
67
+ (row.title ?? "").toLowerCase().includes(needle) ||
68
+ row.folder.toLowerCase().includes(needle) ||
69
+ row.models.some(model => model.toLowerCase().includes(needle)),
70
+ );
71
+ }, [sessions, filter]);
72
+
73
+ const columns = useMemo(
74
+ () => [
75
+ {
76
+ key: "title",
77
+ header: "Title",
78
+ render: (item: SessionSummary) => (
79
+ <div className="stats-font-medium stats-text-primary truncate" style={{ maxWidth: 280 }}>
80
+ {item.title ?? item.file.split("/").pop()}
81
+ </div>
82
+ ),
83
+ },
84
+ {
85
+ key: "folder",
86
+ header: "Project",
87
+ render: (item: SessionSummary) => (
88
+ <span className="stats-text-muted truncate" style={{ maxWidth: 160, display: "inline-block" }}>
89
+ {item.folder.split("/").slice(-2).join("/")}
90
+ </span>
91
+ ),
92
+ },
93
+ {
94
+ key: "started",
95
+ header: "Started",
96
+ render: (item: SessionSummary) => formatRelativeTime(item.startedAt),
97
+ },
98
+ {
99
+ key: "duration",
100
+ header: "Duration",
101
+ numeric: true,
102
+ render: (item: SessionSummary) => formatDurationMs(item.endedAt - item.startedAt),
103
+ },
104
+ { key: "requests", header: "Requests", numeric: true, render: (item: SessionSummary) => item.requests },
105
+ { key: "toolCalls", header: "Tools", numeric: true, render: (item: SessionSummary) => item.toolCalls },
106
+ { key: "subagents", header: "Agents", numeric: true, render: (item: SessionSummary) => item.subagents },
107
+ {
108
+ key: "tokens",
109
+ header: "Tokens",
110
+ numeric: true,
111
+ render: (item: SessionSummary) => formatCompact(item.totalTokens),
112
+ },
113
+ { key: "cost", header: "Cost", numeric: true, render: (item: SessionSummary) => formatCost(item.costTotal) },
114
+ { key: "models", header: "Models", render: (item: SessionSummary) => <ModelChips models={item.models} /> },
115
+ ],
116
+ [],
117
+ );
118
+
119
+ const renderMobileCard = (item: SessionSummary, onClick?: () => void) => (
120
+ <div className="stats-mobile-card" onClick={onClick}>
121
+ <div className="stats-mobile-card-header">
122
+ <div className="stats-font-semibold stats-text-primary truncate">
123
+ {item.title ?? item.file.split("/").pop()}
124
+ </div>
125
+ </div>
126
+ <div className="stats-mobile-card-grid">
127
+ <div>
128
+ <div className="stats-mobile-card-label">Started</div>
129
+ <div className="stats-mobile-card-value">{formatRelativeTime(item.startedAt)}</div>
130
+ </div>
131
+ <div>
132
+ <div className="stats-mobile-card-label">Duration</div>
133
+ <div className="stats-mobile-card-value">{formatDurationMs(item.endedAt - item.startedAt)}</div>
134
+ </div>
135
+ <div>
136
+ <div className="stats-mobile-card-label">Requests</div>
137
+ <div className="stats-mobile-card-value">{item.requests}</div>
138
+ </div>
139
+ <div>
140
+ <div className="stats-mobile-card-label">Cost</div>
141
+ <div className="stats-mobile-card-value">{formatCost(item.costTotal)}</div>
142
+ </div>
143
+ </div>
144
+ </div>
145
+ );
146
+
147
+ if (session !== null) {
148
+ return <TraceView file={session} active={active} onBack={() => onOpenSession(null)} />;
149
+ }
150
+
151
+ return (
152
+ <div className="stats-route-container">
153
+ <Panel
154
+ title="Sessions"
155
+ subtitle="Recent sessions with subagent activity folded in — click one to open its trace"
156
+ actions={
157
+ <input
158
+ type="search"
159
+ value={filter}
160
+ onChange={event => setFilter(event.target.value)}
161
+ placeholder="Filter by title, project, model…"
162
+ aria-label="Filter sessions"
163
+ spellCheck={false}
164
+ className="stats-trace-input"
165
+ style={{ width: 220 }}
166
+ />
167
+ }
168
+ >
169
+ <AsyncBoundary loading={loading} error={error} data={sessions}>
170
+ <DataTable
171
+ columns={columns}
172
+ data={filtered}
173
+ keyExtractor={item => item.file}
174
+ onRowClick={item => onOpenSession(item.file)}
175
+ renderMobileCard={renderMobileCard}
176
+ emptyText="No sessions found — run a Sync to index recent activity"
177
+ />
178
+ </AsyncBoundary>
179
+ </Panel>
180
+ </div>
181
+ );
182
+ }
@@ -8,3 +8,4 @@ export * from "./ProjectsRoute";
8
8
  export * from "./ProvidersRoute";
9
9
  export * from "./RequestsRoute";
10
10
  export * from "./ToolsRoute";
11
+ export * from "./TracesRoute";