@oh-my-pi/omp-stats 18.3.5 → 18.4.1

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 (192) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +13 -11
  3. package/build.ts +5 -49
  4. package/dist/client/index.css +1 -1
  5. package/dist/client/index.html +2 -2
  6. package/dist/client/index.js +105 -126
  7. package/dist/types/aggregator.d.ts +25 -22
  8. package/dist/types/client/api.d.ts +11 -7
  9. package/dist/types/client/app/LiveChip.d.ts +2 -0
  10. package/dist/types/client/app/Shell.d.ts +16 -0
  11. package/dist/types/client/app/ThemeToggle.d.ts +1 -0
  12. package/dist/types/client/app/nav.d.ts +18 -0
  13. package/dist/types/client/charts/BarList.d.ts +17 -0
  14. package/dist/types/client/charts/Chart.d.ts +49 -0
  15. package/dist/types/client/charts/Legend.d.ts +16 -0
  16. package/dist/types/client/charts/ShareBar.d.ts +11 -0
  17. package/dist/types/client/charts/Sparkline.d.ts +11 -0
  18. package/dist/types/client/charts/TimeChart.d.ts +9 -0
  19. package/dist/types/client/charts/index.d.ts +7 -0
  20. package/dist/types/client/charts/types.d.ts +25 -0
  21. package/dist/types/client/charts/useWidth.d.ts +3 -0
  22. package/dist/types/client/data/colors.d.ts +25 -0
  23. package/dist/types/client/data/formatters.d.ts +9 -1
  24. package/dist/types/client/data/live.d.ts +29 -0
  25. package/dist/types/client/data/query.d.ts +35 -0
  26. package/dist/types/client/data/range.d.ts +31 -0
  27. package/dist/types/client/data/series.d.ts +28 -0
  28. package/dist/types/client/data/useHashRoute.d.ts +18 -4
  29. package/dist/types/client/data/view-models.d.ts +111 -28
  30. package/dist/types/client/index.d.ts +0 -1
  31. package/dist/types/client/routes/CostsRoute.d.ts +2 -2
  32. package/dist/types/client/routes/ErrorsRoute.d.ts +2 -2
  33. package/dist/types/client/routes/FrustrationRoute.d.ts +7 -0
  34. package/dist/types/client/routes/GainRoute.d.ts +1 -2
  35. package/dist/types/client/routes/ModelsRoute.d.ts +2 -2
  36. package/dist/types/client/routes/OverviewRoute.d.ts +1 -2
  37. package/dist/types/client/routes/ProjectsRoute.d.ts +2 -2
  38. package/dist/types/client/routes/ProvidersRoute.d.ts +2 -2
  39. package/dist/types/client/routes/RequestsRoute.d.ts +1 -2
  40. package/dist/types/client/routes/ToolsRoute.d.ts +2 -2
  41. package/dist/types/client/routes/TracesRoute.d.ts +2 -2
  42. package/dist/types/client/routes/index.d.ts +1 -1
  43. package/dist/types/client/traces/AggregatesPanel.d.ts +4 -3
  44. package/dist/types/client/traces/SummaryStrip.d.ts +2 -2
  45. package/dist/types/client/traces/trace-colors.d.ts +18 -12
  46. package/dist/types/client/ui/Badge.d.ts +19 -0
  47. package/dist/types/client/ui/Card.d.ts +25 -0
  48. package/dist/types/client/ui/Drawer.d.ts +25 -0
  49. package/dist/types/client/ui/JsonBlock.d.ts +2 -2
  50. package/dist/types/client/ui/Modal.d.ts +23 -0
  51. package/dist/types/client/ui/RequestDrawer.d.ts +10 -1
  52. package/dist/types/client/ui/SearchInput.d.ts +8 -0
  53. package/dist/types/client/ui/Segmented.d.ts +15 -0
  54. package/dist/types/client/ui/Stat.d.ts +36 -0
  55. package/dist/types/client/ui/States.d.ts +41 -0
  56. package/dist/types/client/ui/Table.d.ts +48 -0
  57. package/dist/types/client/ui/index.d.ts +9 -10
  58. package/dist/types/db.d.ts +90 -106
  59. package/dist/types/frustration.d.ts +70 -0
  60. package/dist/types/index.d.ts +3 -1
  61. package/dist/types/live.d.ts +30 -0
  62. package/dist/types/rollup.d.ts +115 -0
  63. package/dist/types/server.d.ts +6 -1
  64. package/dist/types/shared-types.d.ts +116 -56
  65. package/dist/types/types.d.ts +4 -0
  66. package/dist/types/user-metrics.d.ts +8 -0
  67. package/package.json +7 -13
  68. package/src/aggregator.ts +276 -211
  69. package/src/client/App.tsx +38 -80
  70. package/src/client/api.ts +65 -7
  71. package/src/client/app/LiveChip.tsx +97 -0
  72. package/src/client/app/Shell.tsx +181 -0
  73. package/src/client/app/ThemeToggle.tsx +5 -2
  74. package/src/client/app/nav.ts +78 -0
  75. package/src/client/charts/BarList.tsx +48 -0
  76. package/src/client/charts/Chart.tsx +388 -0
  77. package/src/client/charts/Legend.tsx +61 -0
  78. package/src/client/charts/ShareBar.tsx +26 -0
  79. package/src/client/charts/Sparkline.tsx +38 -0
  80. package/src/client/charts/TimeChart.tsx +22 -0
  81. package/src/client/charts/index.ts +7 -0
  82. package/src/client/charts/types.ts +27 -0
  83. package/src/client/charts/useWidth.ts +19 -0
  84. package/src/client/data/colors.ts +45 -0
  85. package/src/client/data/formatters.ts +30 -9
  86. package/src/client/data/live.tsx +85 -0
  87. package/src/client/data/query.ts +166 -0
  88. package/src/client/data/range.ts +82 -0
  89. package/src/client/data/series.ts +85 -0
  90. package/src/client/data/useHashRoute.ts +41 -83
  91. package/src/client/data/view-models.ts +347 -140
  92. package/src/client/index.tsx +3 -6
  93. package/src/client/routes/CostsRoute.tsx +394 -222
  94. package/src/client/routes/ErrorsRoute.tsx +401 -98
  95. package/src/client/routes/FrustrationRoute.tsx +924 -0
  96. package/src/client/routes/GainRoute.tsx +209 -206
  97. package/src/client/routes/ModelsRoute.tsx +540 -431
  98. package/src/client/routes/OverviewRoute.tsx +283 -311
  99. package/src/client/routes/ProjectsRoute.tsx +298 -156
  100. package/src/client/routes/ProvidersRoute.tsx +948 -416
  101. package/src/client/routes/RequestsRoute.tsx +295 -107
  102. package/src/client/routes/ToolsRoute.tsx +495 -398
  103. package/src/client/routes/TracesRoute.tsx +136 -117
  104. package/src/client/routes/costs.css +24 -0
  105. package/src/client/routes/errors.css +60 -0
  106. package/src/client/routes/frustration.css +123 -0
  107. package/src/client/routes/index.ts +1 -1
  108. package/src/client/routes/models.css +43 -0
  109. package/src/client/routes/projects.css +6 -0
  110. package/src/client/routes/providers.css +28 -0
  111. package/src/client/routes/tools.css +14 -0
  112. package/src/client/styles.css +1396 -1299
  113. package/src/client/traces/AggregatesPanel.tsx +55 -74
  114. package/src/client/traces/Minimap.tsx +6 -15
  115. package/src/client/traces/SpanDrawer.tsx +128 -194
  116. package/src/client/traces/SummaryStrip.tsx +21 -24
  117. package/src/client/traces/TimelineCanvas.tsx +27 -44
  118. package/src/client/traces/TraceView.tsx +175 -130
  119. package/src/client/traces/TranscriptList.tsx +13 -41
  120. package/src/client/traces/trace-colors.ts +60 -53
  121. package/src/client/traces/traces.css +279 -0
  122. package/src/client/ui/Badge.tsx +29 -0
  123. package/src/client/ui/Card.tsx +61 -0
  124. package/src/client/ui/Drawer.tsx +93 -0
  125. package/src/client/ui/JsonBlock.tsx +26 -44
  126. package/src/client/ui/Modal.tsx +106 -0
  127. package/src/client/ui/RequestDrawer.tsx +163 -188
  128. package/src/client/ui/SearchInput.tsx +25 -0
  129. package/src/client/ui/Segmented.tsx +61 -0
  130. package/src/client/ui/Stat.tsx +104 -0
  131. package/src/client/ui/States.tsx +81 -0
  132. package/src/client/ui/Table.tsx +229 -0
  133. package/src/client/ui/index.ts +9 -10
  134. package/src/client/ui/modal.css +54 -0
  135. package/src/client/ui/request-drawer.css +40 -0
  136. package/src/db.ts +379 -983
  137. package/src/frustration.ts +473 -0
  138. package/src/index.ts +22 -22
  139. package/src/live.ts +259 -0
  140. package/src/parser.ts +55 -66
  141. package/src/rollup.ts +951 -0
  142. package/src/server.ts +143 -87
  143. package/src/shared-types.ts +120 -56
  144. package/src/trace.ts +14 -7
  145. package/src/types.ts +4 -0
  146. package/src/user-metrics.ts +13 -0
  147. package/dist/client/styles.css +0 -1976
  148. package/dist/types/client/app/AppLayout.d.ts +0 -16
  149. package/dist/types/client/app/NavRail.d.ts +0 -7
  150. package/dist/types/client/app/RangeControl.d.ts +0 -7
  151. package/dist/types/client/app/SyncButton.d.ts +0 -14
  152. package/dist/types/client/app/TopBar.d.ts +0 -15
  153. package/dist/types/client/app/routes.d.ts +0 -12
  154. package/dist/types/client/components/AgentTokenShare.d.ts +0 -5
  155. package/dist/types/client/components/chart-shared.d.ts +0 -178
  156. package/dist/types/client/components/models-table-shared.d.ts +0 -175
  157. package/dist/types/client/components/range-meta.d.ts +0 -21
  158. package/dist/types/client/data/charts.d.ts +0 -1
  159. package/dist/types/client/data/useResource.d.ts +0 -13
  160. package/dist/types/client/routes/BehaviorRoute.d.ts +0 -7
  161. package/dist/types/client/ui/AsyncBoundary.d.ts +0 -12
  162. package/dist/types/client/ui/DataTable.d.ts +0 -17
  163. package/dist/types/client/ui/EmptyState.d.ts +0 -7
  164. package/dist/types/client/ui/ErrorState.d.ts +0 -6
  165. package/dist/types/client/ui/MetricCluster.d.ts +0 -5
  166. package/dist/types/client/ui/Panel.d.ts +0 -7
  167. package/dist/types/client/ui/SegmentedControl.d.ts +0 -12
  168. package/dist/types/client/ui/Skeleton.d.ts +0 -8
  169. package/dist/types/client/ui/StatusPill.d.ts +0 -7
  170. package/src/client/app/AppLayout.tsx +0 -93
  171. package/src/client/app/NavRail.tsx +0 -44
  172. package/src/client/app/RangeControl.tsx +0 -39
  173. package/src/client/app/SyncButton.tsx +0 -75
  174. package/src/client/app/TopBar.tsx +0 -73
  175. package/src/client/app/routes.ts +0 -93
  176. package/src/client/components/AgentTokenShare.tsx +0 -68
  177. package/src/client/components/chart-shared.tsx +0 -273
  178. package/src/client/components/models-table-shared.tsx +0 -255
  179. package/src/client/components/range-meta.ts +0 -73
  180. package/src/client/data/charts.ts +0 -14
  181. package/src/client/data/useResource.ts +0 -154
  182. package/src/client/routes/BehaviorRoute.tsx +0 -623
  183. package/src/client/ui/AsyncBoundary.tsx +0 -54
  184. package/src/client/ui/DataTable.tsx +0 -122
  185. package/src/client/ui/EmptyState.tsx +0 -16
  186. package/src/client/ui/ErrorState.tsx +0 -25
  187. package/src/client/ui/MetricCluster.tsx +0 -92
  188. package/src/client/ui/Panel.tsx +0 -24
  189. package/src/client/ui/SegmentedControl.tsx +0 -36
  190. package/src/client/ui/Skeleton.tsx +0 -17
  191. package/src/client/ui/StatusPill.tsx +0 -15
  192. package/tailwind.config.js +0 -40
@@ -0,0 +1,106 @@
1
+ import { X } from "lucide-react";
2
+ import type React from "react";
3
+ import { useEffect, useRef } from "react";
4
+ import { createPortal } from "react-dom";
5
+ import "./modal.css";
6
+
7
+ export interface ModalAction {
8
+ label: string;
9
+ onClick: () => void;
10
+ disabled?: boolean;
11
+ }
12
+
13
+ export interface ModalProps {
14
+ open: boolean;
15
+ title: React.ReactNode;
16
+ /** Called on Esc, overlay click, the close icon, and the cancel button. */
17
+ onClose: () => void;
18
+ /** Confirming action; receives focus whenever it is present and enabled. */
19
+ primaryAction?: ModalAction;
20
+ cancelLabel?: string;
21
+ children: React.ReactNode;
22
+ }
23
+
24
+ /**
25
+ * Centered dialog over a dimmed overlay, portalled to `document.body` so route
26
+ * containers cannot clip it. Focus lands on the primary action (or the cancel
27
+ * button when there is none) and returns to the opener on close.
28
+ */
29
+ export function Modal({ open, title, onClose, primaryAction, cancelLabel = "Cancel", children }: ModalProps) {
30
+ const primaryRef = useRef<HTMLButtonElement>(null);
31
+ const cancelRef = useRef<HTMLButtonElement>(null);
32
+ const onCloseRef = useRef(onClose);
33
+ onCloseRef.current = onClose;
34
+
35
+ useEffect(() => {
36
+ if (!open) return;
37
+ const opener = document.activeElement instanceof HTMLElement ? document.activeElement : null;
38
+ const handleKeyDown = (e: KeyboardEvent) => {
39
+ if (e.key === "Escape") {
40
+ e.preventDefault();
41
+ onCloseRef.current();
42
+ }
43
+ };
44
+ window.addEventListener("keydown", handleKeyDown);
45
+ return () => {
46
+ window.removeEventListener("keydown", handleKeyDown);
47
+ opener?.focus();
48
+ };
49
+ }, [open]);
50
+
51
+ const hasPrimary = primaryAction !== undefined;
52
+ const primaryDisabled = primaryAction?.disabled ?? false;
53
+ useEffect(() => {
54
+ if (!open) return;
55
+ const target = hasPrimary && !primaryDisabled ? primaryRef.current : cancelRef.current;
56
+ target?.focus();
57
+ }, [open, hasPrimary, primaryDisabled]);
58
+
59
+ if (!open) return null;
60
+
61
+ const handleOverlayClick = (e: React.MouseEvent<HTMLDivElement>) => {
62
+ if (e.target === e.currentTarget) onClose();
63
+ };
64
+
65
+ return createPortal(
66
+ <div className="modal-overlay" onClick={handleOverlayClick} role="presentation">
67
+ <div className="modal" role="dialog" aria-modal="true" aria-labelledby="modal-title">
68
+ <div className="modal-header">
69
+ <h2 id="modal-title" className="modal-title">
70
+ {title}
71
+ </h2>
72
+ <button
73
+ type="button"
74
+ className="btn"
75
+ data-variant="ghost"
76
+ data-icon="true"
77
+ data-size="sm"
78
+ onClick={onClose}
79
+ aria-label="Close"
80
+ >
81
+ <X size={15} />
82
+ </button>
83
+ </div>
84
+ <div className="modal-body">{children}</div>
85
+ <div className="modal-footer">
86
+ <button ref={cancelRef} type="button" className="btn" onClick={onClose}>
87
+ {cancelLabel}
88
+ </button>
89
+ {primaryAction && (
90
+ <button
91
+ ref={primaryRef}
92
+ type="button"
93
+ className="btn"
94
+ data-variant="primary"
95
+ onClick={primaryAction.onClick}
96
+ disabled={primaryAction.disabled}
97
+ >
98
+ {primaryAction.label}
99
+ </button>
100
+ )}
101
+ </div>
102
+ </div>
103
+ </div>,
104
+ document.body,
105
+ );
106
+ }
@@ -1,208 +1,183 @@
1
- import { Clock, Coins, Gauge, Hash, Star, X, Zap } from "lucide-react";
2
- import { useEffect, useRef, useState } from "react";
1
+ import { GitBranch } from "lucide-react";
3
2
  import { getRequestDetails } from "../api";
4
- import { formatDurationMs, formatInteger, formatMessageCost } from "../data/formatters";
3
+ import {
4
+ formatCost,
5
+ formatDurationMs,
6
+ formatFolder,
7
+ formatInteger,
8
+ formatMessageCost,
9
+ formatRelativeTime,
10
+ formatTimestamp,
11
+ formatTokensPerSecond,
12
+ } from "../data/formatters";
13
+ import { useQuery } from "../data/query";
14
+ import { buildHash, parseHash } from "../data/useHashRoute";
15
+ import { type RequestStatus, requestStatus } from "../data/view-models";
5
16
  import type { RequestDetails } from "../types";
17
+ import { Badge, type Tone } from "./Badge";
18
+ import { Drawer, KeyValues } from "./Drawer";
6
19
  import { JsonBlock } from "./JsonBlock";
7
- import { Skeleton } from "./Skeleton";
8
- import { StatusPill } from "./StatusPill";
20
+ import { ErrorState, Skeleton } from "./States";
21
+ import "./request-drawer.css";
22
+
23
+ /** Label and tone for each request outcome; shared by the request tables. */
24
+ export const REQUEST_STATUS: Record<RequestStatus, { label: string; tone: Tone }> = {
25
+ ok: { label: "OK", tone: "ok" },
26
+ aborted: { label: "Aborted", tone: "warn" },
27
+ failed: { label: "Failed", tone: "bad" },
28
+ };
9
29
 
10
30
  export interface RequestDrawerProps {
11
31
  id: number | null;
12
32
  onClose: () => void;
13
33
  }
14
34
 
35
+ /** Full detail sheet for one model request: timing, tokens, cost, ids, error, and the raw session entry. */
15
36
  export function RequestDrawer({ id, onClose }: RequestDrawerProps) {
16
- const [details, setDetails] = useState<RequestDetails | null>(null);
17
- const [loading, setLoading] = useState(false);
18
- const [error, setError] = useState<Error | null>(null);
19
- const previousActiveElement = useRef<HTMLElement | null>(null);
20
- const closeButtonRef = useRef<HTMLButtonElement>(null);
21
-
22
- useEffect(() => {
23
- if (id === null) {
24
- setDetails(null);
25
- return;
26
- }
27
-
28
- previousActiveElement.current = document.activeElement as HTMLElement | null;
29
- setLoading(true);
30
- setError(null);
31
- setDetails(null);
32
-
33
- const controller = new AbortController();
34
- getRequestDetails(id, controller.signal)
35
- .then(data => {
36
- if (controller.signal.aborted) return;
37
- setDetails(data);
38
- // Focus the close button for accessibility
39
- setTimeout(() => closeButtonRef.current?.focus(), 50);
40
- })
41
- .catch(err => {
42
- if (controller.signal.aborted) return;
43
- setError(err instanceof Error ? err : new Error(String(err)));
44
- })
45
- .finally(() => {
46
- if (!controller.signal.aborted) setLoading(false);
47
- });
48
-
49
- return () => controller.abort();
50
- }, [id]);
51
-
52
- useEffect(() => {
53
- if (id === null) return;
37
+ const query = useQuery(["request", id], () => getRequestDetails(id ?? 0), { enabled: id !== null });
38
+ // Never show the previous request's payload under a new id.
39
+ const details = query.stale ? null : query.data;
40
+ const status = details ? REQUEST_STATUS[requestStatus(details)] : null;
54
41
 
55
- const handleKeyDown = (e: KeyboardEvent) => {
56
- if (e.key === "Escape") {
57
- onClose();
42
+ return (
43
+ <Drawer
44
+ open={id !== null}
45
+ onClose={onClose}
46
+ width={620}
47
+ title={details ? <span className="mono">{details.model}</span> : "Request"}
48
+ subtitle={
49
+ details ? (
50
+ <>
51
+ {details.provider} ·{" "}
52
+ <span title={formatTimestamp(details.timestamp)}>{formatRelativeTime(details.timestamp)}</span>
53
+ </>
54
+ ) : id !== null ? (
55
+ <span className="mono">#{id}</span>
56
+ ) : undefined
58
57
  }
59
- };
60
-
61
- window.addEventListener("keydown", handleKeyDown);
62
- return () => {
63
- window.removeEventListener("keydown", handleKeyDown);
64
- if (previousActiveElement.current) {
65
- previousActiveElement.current.focus();
58
+ actions={
59
+ details && status ? (
60
+ <div className="row" style={{ gap: 6 }}>
61
+ <a
62
+ className="btn"
63
+ data-size="sm"
64
+ data-variant="ghost"
65
+ href={buildHash({
66
+ ...parseHash(window.location.hash),
67
+ section: "traces",
68
+ session: details.sessionFile,
69
+ })}
70
+ onClick={onClose}
71
+ title="Open this request's session in the trace view"
72
+ >
73
+ <GitBranch size={13} /> Trace
74
+ </a>
75
+ <Badge tone={status.tone}>{status.label}</Badge>
76
+ </div>
77
+ ) : undefined
66
78
  }
67
- };
68
- }, [id, onClose]);
69
-
70
- if (id === null) return null;
79
+ >
80
+ {details ? (
81
+ <RequestDetailsBody details={details} aborted={requestStatus(details) === "aborted"} />
82
+ ) : query.error ? (
83
+ <ErrorState error={query.error} onRetry={query.refetch} />
84
+ ) : (
85
+ <div className="stack" style={{ gap: 12 }}>
86
+ <Skeleton height={56} />
87
+ <Skeleton height={120} />
88
+ <Skeleton height={120} />
89
+ <Skeleton height={220} />
90
+ </div>
91
+ )}
92
+ </Drawer>
93
+ );
94
+ }
71
95
 
72
- const handleOverlayClick = (e: React.MouseEvent<HTMLDivElement>) => {
73
- if (e.target === e.currentTarget) {
74
- onClose();
75
- }
76
- };
96
+ function RequestDetailsBody({ details, aborted }: { details: RequestDetails; aborted: boolean }) {
97
+ const { usage } = details;
98
+ const throughput =
99
+ details.duration !== null && details.duration > 0 && usage.output > 0
100
+ ? (usage.output * 1000) / details.duration
101
+ : null;
102
+ // The stats row as ingested, without the session payload shown separately below.
103
+ const { messages, output, ...row } = details;
77
104
 
78
105
  return (
79
- <div className="stats-drawer-overlay" onClick={handleOverlayClick} role="presentation">
80
- <div className="stats-drawer" role="dialog" aria-modal="true" aria-label="Request details">
81
- {/* Drawer Header */}
82
- <div className="stats-drawer-header">
83
- <div className="stats-drawer-header-left">
84
- <h2 className="stats-drawer-title">Request Details</h2>
85
- {details && <span className="stats-drawer-id">ID: {id}</span>}
86
- </div>
87
- <button
88
- ref={closeButtonRef}
89
- type="button"
90
- onClick={onClose}
91
- className="stats-drawer-close-btn"
92
- aria-label="Close request details"
93
- >
94
- <X size={18} />
95
- </button>
106
+ <>
107
+ {details.errorMessage && (
108
+ <section className="request-drawer-error" data-tone={aborted ? "warn" : "bad"} role="note">
109
+ <div className="request-drawer-error-label">{aborted ? "Aborted" : "Error"}</div>
110
+ <pre className="request-drawer-error-text">{details.errorMessage}</pre>
111
+ </section>
112
+ )}
113
+
114
+ <section>
115
+ <div className="section-label">Timing</div>
116
+ <KeyValues
117
+ items={[
118
+ { key: "at", label: "Started", value: formatTimestamp(details.timestamp) },
119
+ { key: "duration", label: "Duration", value: formatDurationMs(details.duration) },
120
+ { key: "ttft", label: "Time to first token", value: formatDurationMs(details.ttft) },
121
+ { key: "tps", label: "Output tokens/s", value: formatTokensPerSecond(throughput) },
122
+ ]}
123
+ />
124
+ </section>
125
+
126
+ <section>
127
+ <div className="section-label">Tokens</div>
128
+ <KeyValues
129
+ items={[
130
+ { key: "input", label: "Uncached input", value: formatInteger(usage.input) },
131
+ { key: "cacheRead", label: "Cache read", value: formatInteger(usage.cacheRead) },
132
+ { key: "cacheWrite", label: "Cache write", value: formatInteger(usage.cacheWrite) },
133
+ { key: "output", label: "Output", value: formatInteger(usage.output) },
134
+ { key: "total", label: "Total", value: formatInteger(usage.totalTokens) },
135
+ {
136
+ key: "premium",
137
+ label: "Premium requests",
138
+ value: formatInteger(Math.round((usage.premiumRequests ?? 0) * 100) / 100),
139
+ },
140
+ ]}
141
+ />
142
+ </section>
143
+
144
+ <section>
145
+ <div className="section-label">API-equivalent cost</div>
146
+ <KeyValues
147
+ items={[
148
+ { key: "total", label: "Total", value: formatMessageCost(details, 4) },
149
+ { key: "input", label: "Input", value: formatCost(usage.cost.input, 4) },
150
+ { key: "cacheRead", label: "Cache read", value: formatCost(usage.cost.cacheRead, 4) },
151
+ { key: "cacheWrite", label: "Cache write", value: formatCost(usage.cost.cacheWrite, 4) },
152
+ { key: "output", label: "Output", value: formatCost(usage.cost.output, 4) },
153
+ ]}
154
+ />
155
+ </section>
156
+
157
+ <section>
158
+ <div className="section-label">Identity</div>
159
+ <KeyValues
160
+ items={[
161
+ { key: "id", label: "Request id", value: details.id ?? "–" },
162
+ { key: "entry", label: "Entry id", value: details.entryId },
163
+ { key: "stop", label: "Stop reason", value: details.stopReason },
164
+ { key: "api", label: "API", value: details.api },
165
+ {
166
+ key: "project",
167
+ label: "Project",
168
+ value: <span title={details.folder}>{formatFolder(details.folder)}</span>,
169
+ },
170
+ ]}
171
+ />
172
+ <div className="request-drawer-file">
173
+ <span className="kv-key">Session file</span>
174
+ <span className="kv-value">{details.sessionFile}</span>
96
175
  </div>
176
+ </section>
97
177
 
98
- <div className="stats-drawer-body">
99
- {loading && (
100
- <div className="stats-drawer-loading">
101
- <Skeleton variant="text" width="60%" height={24} className="mb-4" />
102
- <Skeleton variant="rect" width="100%" height={80} className="mb-4" />
103
- <Skeleton variant="rect" width="100%" height={120} className="mb-4" />
104
- <Skeleton variant="rect" width="100%" height={200} />
105
- </div>
106
- )}
107
-
108
- {error && (
109
- <div className="stats-drawer-error">
110
- <p className="stats-drawer-error-title">Failed to load request details</p>
111
- <p className="stats-drawer-error-message">{error.message}</p>
112
- </div>
113
- )}
114
-
115
- {details && (
116
- <div className="stats-drawer-content">
117
- {/* Status Card */}
118
- <div className="stats-drawer-status-card">
119
- <div className="stats-drawer-status-row">
120
- <div>
121
- <div className="stats-drawer-model">{details.model}</div>
122
- <div className="stats-drawer-provider">{details.provider}</div>
123
- </div>
124
- <StatusPill variant={details.errorMessage ? "danger" : "success"}>
125
- {details.errorMessage ? "Error" : "Success"}
126
- </StatusPill>
127
- </div>
128
- {details.errorMessage && (
129
- <div className="stats-drawer-error-block">
130
- <div className="stats-drawer-error-label">Error Message</div>
131
- <div className="stats-drawer-error-text">{details.errorMessage}</div>
132
- </div>
133
- )}
134
- </div>
135
-
136
- {/* Metrics Grid */}
137
- <div className="stats-drawer-metrics-grid">
138
- <div className="stats-drawer-metric-card">
139
- <div className="stats-drawer-metric-label">
140
- <Coins size={14} className="stats-drawer-metric-icon" />
141
- API-equivalent estimate
142
- </div>
143
- <div className="stats-drawer-metric-value">{formatMessageCost(details, 4)}</div>
144
- </div>
145
-
146
- <div className="stats-drawer-metric-card">
147
- <div className="stats-drawer-metric-label">
148
- <Star size={14} className="stats-drawer-metric-icon" />
149
- Premium
150
- </div>
151
- <div className="stats-drawer-metric-value">
152
- {formatInteger(details.usage.premiumRequests ?? 0)}
153
- </div>
154
- </div>
155
-
156
- <div className="stats-drawer-metric-card">
157
- <div className="stats-drawer-metric-label">
158
- <Hash size={14} className="stats-drawer-metric-icon" />
159
- Total Tokens
160
- </div>
161
- <div className="stats-drawer-metric-value">{formatInteger(details.usage.totalTokens)}</div>
162
- <div className="stats-drawer-metric-sub">
163
- {formatInteger(details.usage.input)} in · {formatInteger(details.usage.output)} out
164
- </div>
165
- </div>
166
-
167
- <div className="stats-drawer-metric-card">
168
- <div className="stats-drawer-metric-label">
169
- <Clock size={14} className="stats-drawer-metric-icon" />
170
- Duration
171
- </div>
172
- <div className="stats-drawer-metric-value">{formatDurationMs(details.duration)}</div>
173
- </div>
174
-
175
- <div className="stats-drawer-metric-card">
176
- <div className="stats-drawer-metric-label">
177
- <Zap size={14} className="stats-drawer-metric-icon" />
178
- TTFT
179
- </div>
180
- <div className="stats-drawer-metric-value">{formatDurationMs(details.ttft)}</div>
181
- </div>
182
-
183
- {details.duration && details.usage.output > 0 && (
184
- <div className="stats-drawer-metric-card">
185
- <div className="stats-drawer-metric-label">
186
- <Gauge size={14} className="stats-drawer-metric-icon" />
187
- Throughput
188
- </div>
189
- <div className="stats-drawer-metric-value">
190
- {((details.usage.output * 1000) / details.duration).toFixed(1)}
191
- </div>
192
- <div className="stats-drawer-metric-sub">tokens/second</div>
193
- </div>
194
- )}
195
- </div>
196
-
197
- {/* JSON blocks */}
198
- <div className="stats-drawer-json-blocks">
199
- <JsonBlock data={details.output} title="Output Payload" initialCollapsed={false} />
200
- <JsonBlock data={details} title="Raw Request Metadata" initialCollapsed={true} />
201
- </div>
202
- </div>
203
- )}
204
- </div>
205
- </div>
206
- </div>
178
+ <JsonBlock data={output} title="Output message" />
179
+ <JsonBlock data={messages} title="Session entry" initialCollapsed />
180
+ <JsonBlock data={row} title="Stats row" initialCollapsed />
181
+ </>
207
182
  );
208
183
  }
@@ -0,0 +1,25 @@
1
+ import { Search } from "lucide-react";
2
+
3
+ export interface SearchInputProps {
4
+ value: string;
5
+ onChange: (value: string) => void;
6
+ placeholder?: string;
7
+ width?: number;
8
+ }
9
+
10
+ /** Text filter with a leading search glyph. */
11
+ export function SearchInput({ value, onChange, placeholder = "Filter…", width }: SearchInputProps) {
12
+ return (
13
+ <label className="search">
14
+ <Search size={14} />
15
+ <input
16
+ className="input"
17
+ type="search"
18
+ value={value}
19
+ placeholder={placeholder}
20
+ onChange={e => onChange(e.target.value)}
21
+ style={width !== undefined ? { width } : undefined}
22
+ />
23
+ </label>
24
+ );
25
+ }
@@ -0,0 +1,61 @@
1
+ import { type ReactNode, useLayoutEffect, useRef, useState } from "react";
2
+
3
+ export interface SegmentedOption<T extends string> {
4
+ value: T;
5
+ label: ReactNode;
6
+ title?: string;
7
+ }
8
+
9
+ export interface SegmentedProps<T extends string> {
10
+ options: readonly SegmentedOption<T>[];
11
+ value: T;
12
+ onChange: (value: T) => void;
13
+ size?: "md" | "sm";
14
+ "aria-label"?: string;
15
+ }
16
+
17
+ /** Pill toggle with a measured sliding thumb. */
18
+ export function Segmented<T extends string>({
19
+ options,
20
+ value,
21
+ onChange,
22
+ size = "md",
23
+ "aria-label": ariaLabel,
24
+ }: SegmentedProps<T>) {
25
+ const rootRef = useRef<HTMLDivElement>(null);
26
+ const [thumb, setThumb] = useState<{ left: number; width: number } | null>(null);
27
+
28
+ useLayoutEffect(() => {
29
+ const root = rootRef.current;
30
+ const active = root?.querySelector<HTMLElement>('[data-active="true"]');
31
+ if (!root || !active) {
32
+ setThumb(null);
33
+ return;
34
+ }
35
+ const measure = () => setThumb({ left: active.offsetLeft, width: active.offsetWidth });
36
+ measure();
37
+ const observer = new ResizeObserver(measure);
38
+ observer.observe(root);
39
+ return () => observer.disconnect();
40
+ }, [value, options]);
41
+
42
+ return (
43
+ <div ref={rootRef} className="segmented" data-size={size} role="radiogroup" aria-label={ariaLabel}>
44
+ {thumb && <span className="segmented-thumb" style={{ left: thumb.left, width: thumb.width }} />}
45
+ {options.map(option => (
46
+ <button
47
+ key={option.value}
48
+ type="button"
49
+ role="radio"
50
+ aria-checked={option.value === value}
51
+ className="segmented-option"
52
+ data-active={option.value === value}
53
+ title={option.title}
54
+ onClick={() => onChange(option.value)}
55
+ >
56
+ {option.label}
57
+ </button>
58
+ ))}
59
+ </div>
60
+ );
61
+ }
@@ -0,0 +1,104 @@
1
+ import { type CSSProperties, type ReactNode, useEffect, useRef, useState } from "react";
2
+ import { Sparkline } from "../charts/Sparkline";
3
+
4
+ export interface StatDelta {
5
+ /** Relative change vs the previous period (0.12 = +12%). `null` when there is no baseline. */
6
+ change: number | null;
7
+ /** Which direction is good news. Default `"up"`. */
8
+ good?: "up" | "down";
9
+ }
10
+
11
+ export interface StatProps {
12
+ label: ReactNode;
13
+ value: ReactNode;
14
+ /** Small print under the value. */
15
+ hint?: ReactNode;
16
+ delta?: StatDelta;
17
+ /** Trend under the value. */
18
+ spark?: readonly number[];
19
+ sparkColor?: string;
20
+ size?: "md" | "sm";
21
+ /** Title tooltip explaining the metric. */
22
+ title?: string;
23
+ }
24
+
25
+ /**
26
+ * One metric tile. The value flashes briefly when it changes after mount, so
27
+ * live updates are noticeable without being loud.
28
+ */
29
+ export function Stat({ label, value, hint, delta, spark, sparkColor, size = "md", title }: StatProps) {
30
+ const flash = useChangeFlash(typeof value === "string" || typeof value === "number" ? value : null);
31
+ return (
32
+ <div className="stat" data-size={size} title={title}>
33
+ <div className="stat-label">{label}</div>
34
+ <div className="stat-value" data-flash={flash}>
35
+ {value}
36
+ </div>
37
+ {(hint !== undefined || delta) && (
38
+ <div className="stat-foot">
39
+ {delta && <Delta {...delta} />}
40
+ {hint !== undefined && <span className="truncate">{hint}</span>}
41
+ </div>
42
+ )}
43
+ {spark && spark.length > 1 && (
44
+ <div className="stat-spark">
45
+ <Sparkline values={spark} color={sparkColor} />
46
+ </div>
47
+ )}
48
+ </div>
49
+ );
50
+ }
51
+
52
+ export interface StatGridProps {
53
+ /** Minimum tile width before wrapping. Default 170px. */
54
+ min?: number;
55
+ children: ReactNode;
56
+ }
57
+
58
+ /** Tiles sharing one card, separated by hairlines. */
59
+ export function StatGrid({ min, children }: StatGridProps) {
60
+ return (
61
+ <div
62
+ className="stat-grid rise"
63
+ style={min !== undefined ? ({ "--stat-min": `${min}px` } as CSSProperties) : undefined}
64
+ >
65
+ {children}
66
+ </div>
67
+ );
68
+ }
69
+
70
+ /** Signed percentage change pill, green when the move is good news. */
71
+ export function Delta({ change, good = "up" }: StatDelta) {
72
+ if (change === null || !Number.isFinite(change)) return <span className="delta">new</span>;
73
+ const rounded = Math.abs(change) < 0.0005 ? 0 : change;
74
+ const tone = rounded === 0 ? undefined : rounded > 0 === (good === "up") ? "good" : "bad";
75
+ const sign = rounded > 0 ? "+" : rounded < 0 ? "−" : "±";
76
+ const pct = Math.abs(rounded) * 100;
77
+ return (
78
+ <span className="delta" data-tone={tone}>
79
+ {sign}
80
+ {pct >= 100 ? pct.toFixed(0) : pct.toFixed(1)}%
81
+ </span>
82
+ );
83
+ }
84
+
85
+ /** Relative change from `previous` to `current`; `null` when there is no baseline. */
86
+ export function relativeChange(current: number, previous: number): number | null {
87
+ if (previous === 0) return current === 0 ? 0 : null;
88
+ return (current - previous) / previous;
89
+ }
90
+
91
+ function useChangeFlash(value: string | number | null): boolean {
92
+ const previous = useRef(value);
93
+ const [flash, setFlash] = useState(false);
94
+ useEffect(() => {
95
+ if (previous.current === value) return;
96
+ const hadValue = previous.current !== null;
97
+ previous.current = value;
98
+ if (!hadValue) return;
99
+ setFlash(true);
100
+ const timer = setTimeout(() => setFlash(false), 900);
101
+ return () => clearTimeout(timer);
102
+ }, [value]);
103
+ return flash;
104
+ }