@robota-sdk/agent-ui-web 0.0.0-stage → 3.0.0-beta.94

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 (75) hide show
  1. package/CHANGELOG.md +409 -0
  2. package/LICENSE +661 -0
  3. package/README.md +19 -2
  4. package/dist/browser/index.d.ts +715 -0
  5. package/dist/browser/index.d.ts.map +1 -0
  6. package/dist/browser/index.js +7923 -0
  7. package/dist/browser/index.js.map +1 -0
  8. package/dist/node/index.cjs +7973 -0
  9. package/dist/node/index.d.cts +715 -0
  10. package/dist/node/index.d.cts.map +1 -0
  11. package/dist/node/index.d.ts +715 -0
  12. package/dist/node/index.d.ts.map +1 -0
  13. package/dist/node/index.js +7923 -0
  14. package/dist/node/index.js.map +1 -0
  15. package/docs/README.md +62 -0
  16. package/docs/SPEC.md +124 -0
  17. package/package.json +99 -3
  18. package/src/client/runtime-host.ts +84 -0
  19. package/src/client/ws-session-client.ts +131 -0
  20. package/src/clipboard.ts +81 -0
  21. package/src/components/AgentActivityPanel.tsx +371 -0
  22. package/src/components/AgentSwitcherSheet.tsx +93 -0
  23. package/src/components/Brand.tsx +33 -0
  24. package/src/components/Composer.tsx +685 -0
  25. package/src/components/ConversationView.tsx +772 -0
  26. package/src/components/Dialog.tsx +230 -0
  27. package/src/components/DiffLines.tsx +81 -0
  28. package/src/components/ExecutionDetailSheet.tsx +142 -0
  29. package/src/components/HelpSheet.tsx +104 -0
  30. package/src/components/PermissionPrompt.tsx +661 -0
  31. package/src/components/PersonalUsageDashboard.tsx +39 -0
  32. package/src/components/PersonalUsageDashboardDetails.tsx +368 -0
  33. package/src/components/PersonalUsageDashboardSections.tsx +250 -0
  34. package/src/components/PopupMenu.tsx +166 -0
  35. package/src/components/ProjectPanel.tsx +268 -0
  36. package/src/components/SessionMonitor.tsx +180 -0
  37. package/src/components/SessionSidebar.tsx +429 -0
  38. package/src/components/SessionSurface.tsx +455 -0
  39. package/src/components/SessionSurfaceChrome.tsx +316 -0
  40. package/src/components/SettingsFields.tsx +159 -0
  41. package/src/components/SettingsGeneralSection.tsx +49 -0
  42. package/src/components/SettingsMcpSection.tsx +157 -0
  43. package/src/components/SettingsPermissionsSection.tsx +137 -0
  44. package/src/components/SettingsPluginsSection.tsx +165 -0
  45. package/src/components/SettingsProvidersSection.tsx +211 -0
  46. package/src/components/SettingsScreen.tsx +340 -0
  47. package/src/components/StatusControls.tsx +294 -0
  48. package/src/components/composer-attachments.ts +246 -0
  49. package/src/components/personal-usage-dashboard-types.ts +26 -0
  50. package/src/driver-labels.ts +88 -0
  51. package/src/execution-entry-status.ts +55 -0
  52. package/src/goal-view.ts +19 -0
  53. package/src/hooks/command-menu.ts +65 -0
  54. package/src/hooks/excluded-commands.ts +52 -0
  55. package/src/hooks/prompt-state.ts +83 -0
  56. package/src/hooks/server-message-handling.ts +92 -0
  57. package/src/hooks/session-client-types.ts +329 -0
  58. package/src/hooks/ui-intent-state.ts +74 -0
  59. package/src/hooks/use-agent-switcher-state.ts +125 -0
  60. package/src/hooks/use-execution-detail.ts +118 -0
  61. package/src/hooks/use-media-query.ts +27 -0
  62. package/src/hooks/use-model-list.ts +45 -0
  63. package/src/hooks/use-personal-usage.ts +138 -0
  64. package/src/hooks/use-project-panel.ts +117 -0
  65. package/src/hooks/use-schedules-state.ts +92 -0
  66. package/src/hooks/use-session-directory.ts +202 -0
  67. package/src/hooks/use-settings-state.ts +115 -0
  68. package/src/hooks/useSessionClient.ts +991 -0
  69. package/src/index.ts +66 -0
  70. package/src/product-identity.tsx +26 -0
  71. package/src/schedule-view.ts +92 -0
  72. package/src/styles/surface.css +231 -0
  73. package/src/styles/surface.css.d.mts +1 -0
  74. package/src/styles/theme.css +38 -0
  75. package/src/styles/theme.css.d.mts +1 -0
@@ -0,0 +1,39 @@
1
+ import { useEffect, useState } from 'react';
2
+
3
+ import {
4
+ CurrentSessionPanel,
5
+ PersonalUsageContent,
6
+ UsageHeader,
7
+ } from './PersonalUsageDashboardSections.js';
8
+
9
+ import type {
10
+ TPersonalUsageDashboardState,
11
+ TUsageBreakdown,
12
+ } from './personal-usage-dashboard-types.js';
13
+
14
+ export function PersonalUsageDashboard({
15
+ state,
16
+ }: {
17
+ state: TPersonalUsageDashboardState;
18
+ }): React.ReactElement {
19
+ const [period, setPeriod] = useState<'7d' | '30d'>('7d');
20
+ const [breakdown, setBreakdown] = useState<TUsageBreakdown>('model');
21
+
22
+ useEffect(() => {
23
+ state.requestPersonalUsage(period);
24
+ }, [period, state.requestPersonalUsage]);
25
+
26
+ return (
27
+ <main className="agent-ui gui-rise h-full overflow-y-auto p-5 md:p-7" aria-label="Personal usage">
28
+ <div className="mx-auto flex max-w-6xl flex-col gap-5">
29
+ <UsageHeader
30
+ period={period}
31
+ setPeriod={setPeriod}
32
+ requestCurrentSessionUsage={state.requestCurrentSessionUsage}
33
+ />
34
+ <CurrentSessionPanel state={state} />
35
+ <PersonalUsageContent state={state} breakdown={breakdown} setBreakdown={setBreakdown} />
36
+ </div>
37
+ </main>
38
+ );
39
+ }
@@ -0,0 +1,368 @@
1
+ import { sessionTitle } from './SessionSidebar.js';
2
+
3
+ import type { Dispatch, SetStateAction } from 'react';
4
+ import type {
5
+ TPersonalUsageDimension,
6
+ TPersonalUsageDashboardState,
7
+ TPersonalUsageReport,
8
+ TUsageBreakdown,
9
+ } from './personal-usage-dashboard-types.js';
10
+
11
+ const number = new Intl.NumberFormat('en-US');
12
+ const PERCENT = 100;
13
+
14
+ /** Breakdown tabs backed by a dimension list (never "activity", which has no "unknown" bucket). */
15
+ const DIMENSION_BREAKDOWNS = ['model', 'provider', 'surface', 'source'] as const;
16
+
17
+ function breakdownLabel(value: TUsageBreakdown): string {
18
+ return value === 'activity' ? 'Activity' : `By ${value}`;
19
+ }
20
+
21
+ /** Plain words for a surface key; anything not called out is title-cased from its raw key. */
22
+ function surfaceLabel(key: string): string {
23
+ if (key === 'cli') return 'Terminal';
24
+ if (key === 'remote') return 'App or browser';
25
+ return key
26
+ .split(/[-_]/)
27
+ .filter((word) => word.length > 0)
28
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
29
+ .join(' ');
30
+ }
31
+
32
+ /** A tab is hidden when every bit of its usage landed in one "we don't know" bucket. */
33
+ function isSingleUnknownBucket(dimensions: readonly TPersonalUsageDimension[]): boolean {
34
+ return dimensions.length === 1 && dimensions[0]?.key === 'unknown';
35
+ }
36
+
37
+ function visibleBreakdowns(report: TPersonalUsageReport): readonly TUsageBreakdown[] {
38
+ const shown = DIMENSION_BREAKDOWNS.filter(
39
+ (value) => !isSingleUnknownBucket(dimensionsFor(report, value)),
40
+ );
41
+ return [...shown, 'activity'];
42
+ }
43
+
44
+ const sessionTimestampFormat = new Intl.DateTimeFormat(undefined, {
45
+ dateStyle: 'medium',
46
+ timeStyle: 'short',
47
+ });
48
+
49
+ /**
50
+ * Never the raw id: a session this workspace's own listing knows is named from that local data (the
51
+ * same `sessionTitle()` the sidebar uses — its `name`, else its own preview, both this person's own
52
+ * local data already shown in this GUI). A session outside that listing (e.g. from another workspace)
53
+ * falls back to the report's content-free first-seen timestamp, or else a plain placeholder — never to
54
+ * the id itself.
55
+ */
56
+ function sessionDisplayTitle(
57
+ state: TPersonalUsageDashboardState,
58
+ report: TPersonalUsageReport,
59
+ sessionId: string,
60
+ ): string {
61
+ const listed = state.sessionListing?.sessions.find((session) => session.id === sessionId);
62
+ if (listed) return sessionTitle(listed);
63
+ const firstSeen = report.sessionFirstSeen?.[sessionId];
64
+ const at = firstSeen ? new Date(firstSeen) : undefined;
65
+ if (at && !Number.isNaN(at.getTime())) {
66
+ return `Session from ${sessionTimestampFormat.format(at)}`;
67
+ }
68
+ return 'Another session';
69
+ }
70
+
71
+ export function Stat({
72
+ label,
73
+ value,
74
+ note,
75
+ }: {
76
+ label: string;
77
+ value: string;
78
+ /** A qualifier shown beside the label, e.g. that a cost is estimated. */
79
+ note?: string;
80
+ }): React.ReactElement {
81
+ return (
82
+ <div className="rounded-2xl bg-card p-5">
83
+ <div className="flex items-baseline gap-1.5 text-[13px] text-muted-foreground">
84
+ {label}
85
+ {note ? <span className="text-subtle">· {note}</span> : null}
86
+ </div>
87
+ <div className="mt-1.5 text-[28px] font-semibold tabular-nums tracking-[-0.02em] text-foreground">
88
+ {value}
89
+ </div>
90
+ </div>
91
+ );
92
+ }
93
+
94
+ function dimensionsFor(
95
+ report: TPersonalUsageReport,
96
+ breakdown: TUsageBreakdown,
97
+ ): readonly TPersonalUsageDimension[] {
98
+ if (breakdown === 'model') return report.byModel;
99
+ if (breakdown === 'provider') return report.byProvider;
100
+ if (breakdown === 'surface') return report.bySurface;
101
+ if (breakdown === 'source') return report.bySource;
102
+ return [];
103
+ }
104
+
105
+ export function BreakdownPanel({
106
+ state,
107
+ report,
108
+ breakdown,
109
+ setBreakdown,
110
+ }: {
111
+ state: TPersonalUsageDashboardState;
112
+ report: TPersonalUsageReport;
113
+ breakdown: TUsageBreakdown;
114
+ setBreakdown: Dispatch<SetStateAction<TUsageBreakdown>>;
115
+ }): React.ReactElement {
116
+ const visible = visibleBreakdowns(report);
117
+ // The tab the person picked may have just become hidden (its usage is now a single unknown bucket,
118
+ // or was all along for a freshly loaded report) — fall back to showing the first visible tab rather
119
+ // than an empty panel, without forcing that choice back into the person's own selection state.
120
+ const active = visible.includes(breakdown) ? breakdown : (visible[0] ?? breakdown);
121
+ return (
122
+ <section className="rounded-2xl bg-card p-5">
123
+ <BreakdownHeader breakdown={active} setBreakdown={setBreakdown} visible={visible} />
124
+ <div className="mt-4 space-y-3">
125
+ {active === 'activity' ? (
126
+ <ActivityRows report={report} />
127
+ ) : (
128
+ <DimensionRows
129
+ state={state}
130
+ report={report}
131
+ breakdown={active}
132
+ dimensions={dimensionsFor(report, active)}
133
+ />
134
+ )}
135
+ </div>
136
+ </section>
137
+ );
138
+ }
139
+
140
+ function BreakdownHeader({
141
+ breakdown,
142
+ setBreakdown,
143
+ visible,
144
+ }: {
145
+ breakdown: TUsageBreakdown;
146
+ setBreakdown: Dispatch<SetStateAction<TUsageBreakdown>>;
147
+ visible: readonly TUsageBreakdown[];
148
+ }): React.ReactElement {
149
+ return (
150
+ <div className="flex flex-wrap items-center justify-between gap-3">
151
+ <h2 className="text-[15px] font-semibold">Breakdown</h2>
152
+ <div className="flex flex-wrap gap-1">
153
+ {visible.map((value) => (
154
+ <button
155
+ key={value}
156
+ type="button"
157
+ aria-pressed={breakdown === value}
158
+ onClick={() => setBreakdown(value)}
159
+ className={`rounded-lg px-2.5 py-1 text-[13px] font-medium ${
160
+ breakdown === value
161
+ ? 'bg-raised text-foreground'
162
+ : 'text-muted-foreground hover:text-foreground'
163
+ }`}
164
+ >
165
+ {breakdownLabel(value)}
166
+ </button>
167
+ ))}
168
+ </div>
169
+ </div>
170
+ );
171
+ }
172
+
173
+ function ActivityRows({ report }: { report: TPersonalUsageReport }): React.ReactElement {
174
+ return (
175
+ <>
176
+ {report.byActivity.map((activity) => (
177
+ <div
178
+ key={activity.key}
179
+ className="flex items-center justify-between gap-3 rounded-xl bg-raised/60 px-3.5 py-2.5"
180
+ >
181
+ <div className="min-w-0">
182
+ <span className="truncate text-[14px] text-foreground">{activity.label}</span>
183
+ {/* A literal space (not just a CSS margin) so the name and its kind never read as one
184
+ run-together word to a screen reader or a plain-text copy. */}
185
+ {' '}
186
+ <span className="ml-1 rounded-full bg-raised px-2 py-0.5 text-[11px] font-medium text-subtle">
187
+ {activity.kind}
188
+ </span>
189
+ </div>
190
+ <span className="text-[13px] tabular-nums text-muted-foreground">
191
+ {number.format(activity.count)} calls
192
+ </span>
193
+ </div>
194
+ ))}
195
+ </>
196
+ );
197
+ }
198
+
199
+ function DimensionRows({
200
+ state,
201
+ report,
202
+ breakdown,
203
+ dimensions,
204
+ }: {
205
+ state: TPersonalUsageDashboardState;
206
+ report: TPersonalUsageReport;
207
+ breakdown: TUsageBreakdown;
208
+ dimensions: readonly TPersonalUsageDimension[];
209
+ }): React.ReactElement {
210
+ return (
211
+ <>
212
+ {dimensions.map((dimension) => (
213
+ <div key={dimension.key} className="grid grid-cols-[minmax(0,1fr)_auto] gap-3">
214
+ <DimensionSummary report={report} breakdown={breakdown} dimension={dimension} />
215
+ <span className="self-start text-[13px] tabular-nums text-subtle">
216
+ {number.format(dimension.turns)} turns
217
+ </span>
218
+ <SessionButtons state={state} report={report} dimension={dimension} />
219
+ </div>
220
+ ))}
221
+ </>
222
+ );
223
+ }
224
+
225
+ function DimensionSummary({
226
+ report,
227
+ breakdown,
228
+ dimension,
229
+ }: {
230
+ report: TPersonalUsageReport;
231
+ breakdown: TUsageBreakdown;
232
+ dimension: TPersonalUsageDimension;
233
+ }): React.ReactElement {
234
+ const width =
235
+ report.totals.totalTokens === 0
236
+ ? 0
237
+ : (dimension.totalTokens / report.totals.totalTokens) * PERCENT;
238
+ const label = breakdown === 'surface' ? surfaceLabel(dimension.key) : dimension.label;
239
+ return (
240
+ <div className="min-w-0">
241
+ <div className="flex justify-between gap-3 text-[14px]">
242
+ <span className="truncate text-foreground">{label}</span>
243
+ <span className="tabular-nums text-muted-foreground">
244
+ {number.format(dimension.totalTokens)} tokens
245
+ </span>
246
+ </div>
247
+ <div className="mt-2 h-1.5 overflow-hidden rounded-full bg-raised">
248
+ <div className="h-full rounded-full bg-accent/80" style={{ width: `${width}%` }} />
249
+ </div>
250
+ </div>
251
+ );
252
+ }
253
+
254
+ function SessionButtons({
255
+ state,
256
+ report,
257
+ dimension,
258
+ }: {
259
+ state: TPersonalUsageDashboardState;
260
+ report: TPersonalUsageReport;
261
+ dimension: TPersonalUsageDimension;
262
+ }): React.ReactElement {
263
+ return (
264
+ <div className="col-span-2 flex flex-wrap gap-1">
265
+ {dimension.sessionIds.map((sessionId) => {
266
+ const title = sessionDisplayTitle(state, report, sessionId);
267
+ return (
268
+ <button
269
+ key={sessionId}
270
+ type="button"
271
+ data-session-id={sessionId}
272
+ onClick={() => state.requestStoredSessionUsage(sessionId)}
273
+ aria-label={`Open session ${title}`}
274
+ className="rounded-md bg-raised px-2 py-1 text-[12px] text-muted-foreground hover:bg-hover hover:text-foreground"
275
+ >
276
+ <span className="max-w-40 truncate align-middle">{title}</span>
277
+ </button>
278
+ );
279
+ })}
280
+ </div>
281
+ );
282
+ }
283
+
284
+ export function StoredSessionPanel({
285
+ state,
286
+ }: {
287
+ state: TPersonalUsageDashboardState;
288
+ }): React.ReactElement | null {
289
+ if (state.storedSessionUsageStatus === 'idle') return null;
290
+ const sessionId = state.storedSessionUsageSessionId ?? '';
291
+ const title = state.personalUsageReport
292
+ ? sessionDisplayTitle(state, state.personalUsageReport, sessionId)
293
+ : 'Another session';
294
+ return (
295
+ <section className="rounded-2xl bg-card p-5" aria-label="Session usage detail" data-session-id={sessionId}>
296
+ <h2 className="text-[15px] font-semibold">{title}</h2>
297
+ <StoredSessionContent state={state} />
298
+ </section>
299
+ );
300
+ }
301
+
302
+ function StoredSessionContent({
303
+ state,
304
+ }: {
305
+ state: TPersonalUsageDashboardState;
306
+ }): React.ReactElement | null {
307
+ if (state.storedSessionUsageStatus === 'loading') {
308
+ return <p className="mt-3 text-[14px] text-muted-foreground">Loading trace…</p>;
309
+ }
310
+ if (state.storedSessionUsageStatus === 'error') {
311
+ return (
312
+ <p className="mt-3 text-[14px] text-destructive">
313
+ {state.storedSessionUsageError ?? 'Session usage failed.'}
314
+ </p>
315
+ );
316
+ }
317
+ const report = state.storedSessionUsageReport;
318
+ if (!report) return null;
319
+ return (
320
+ <div className="mt-3 grid gap-3 sm:grid-cols-3">
321
+ <Stat label="Session tokens" value={number.format(report.totalTokens)} />
322
+ <Stat label="Sources" value={number.format(report.bySource.length)} />
323
+ <Stat label="Trace turns" value={number.format(report.timeline.length)} />
324
+ </div>
325
+ );
326
+ }
327
+
328
+ /** Plain, one-per-condition sentences — shown only for what this view actually left out. */
329
+ function coverageSentences(report: TPersonalUsageReport): string[] {
330
+ const { coverage } = report;
331
+ const sentences: string[] = [];
332
+ if (coverage.unsupportedSessions > 0) {
333
+ const noun = coverage.unsupportedSessions === 1 ? 'session' : 'sessions';
334
+ const verb = coverage.unsupportedSessions === 1 ? 'uses' : 'use';
335
+ sentences.push(
336
+ `${number.format(coverage.unsupportedSessions)} older ${noun} ${verb} a format this view can't read and ${coverage.unsupportedSessions === 1 ? "isn't" : "aren't"} counted.`,
337
+ );
338
+ }
339
+ if (coverage.corruptSessions > 0) {
340
+ const noun = coverage.corruptSessions === 1 ? 'session' : 'sessions';
341
+ sentences.push(
342
+ `${number.format(coverage.corruptSessions)} ${noun} couldn't be read and ${coverage.corruptSessions === 1 ? "isn't" : "aren't"} counted.`,
343
+ );
344
+ }
345
+ if (coverage.legacyObservations > 0) {
346
+ const noun = coverage.legacyObservations === 1 ? 'record' : 'records';
347
+ sentences.push(
348
+ `${number.format(coverage.legacyObservations)} older usage ${noun} ${coverage.legacyObservations === 1 ? "doesn't" : "don't"} have enough detail to break down, so ${coverage.legacyObservations === 1 ? 'it counts' : 'they count'} only toward the totals.`,
349
+ );
350
+ }
351
+ return sentences;
352
+ }
353
+
354
+ export function CoverageNote({
355
+ report,
356
+ }: {
357
+ report: TPersonalUsageReport;
358
+ }): React.ReactElement | null {
359
+ const sentences = coverageSentences(report);
360
+ if (sentences.length === 0) return null;
361
+ return (
362
+ <div className="space-y-1.5 rounded-xl bg-warning/10 px-4 py-3 text-[13px] leading-relaxed text-warning">
363
+ {sentences.map((sentence) => (
364
+ <p key={sentence}>{sentence}</p>
365
+ ))}
366
+ </div>
367
+ );
368
+ }
@@ -0,0 +1,250 @@
1
+ import {
2
+ BreakdownPanel,
3
+ CoverageNote,
4
+ Stat,
5
+ StoredSessionPanel,
6
+ } from './PersonalUsageDashboardDetails.js';
7
+
8
+ import type { Dispatch, SetStateAction } from 'react';
9
+ import type {
10
+ TPersonalUsageDashboardState,
11
+ TPersonalUsageReport,
12
+ TUsageBreakdown,
13
+ } from './personal-usage-dashboard-types.js';
14
+
15
+ const number = new Intl.NumberFormat('en-US');
16
+ const PERCENT = 100;
17
+ const MINIMUM_BAR_PERCENT = 2;
18
+ const MONTH_DAY_OFFSET = 'YYYY-'.length;
19
+ const COST_DECIMAL_PLACES = 2;
20
+
21
+ function StatusPanel({ children }: { children: React.ReactNode }): React.ReactElement {
22
+ return (
23
+ <div className="flex min-h-64 items-center justify-center rounded-2xl bg-card px-8 text-center text-[15px] text-muted-foreground">
24
+ {children}
25
+ </div>
26
+ );
27
+ }
28
+
29
+ export function UsageHeader({
30
+ period,
31
+ setPeriod,
32
+ requestCurrentSessionUsage,
33
+ }: {
34
+ period: '7d' | '30d';
35
+ setPeriod: Dispatch<SetStateAction<'7d' | '30d'>>;
36
+ requestCurrentSessionUsage: () => void;
37
+ }): React.ReactElement {
38
+ return (
39
+ <div className="flex flex-wrap items-end justify-between gap-4">
40
+ <div>
41
+ <div className="text-[13px] font-medium text-accent">Local analytics</div>
42
+ <h1 className="mt-1 text-[28px] font-semibold tracking-[-0.02em]">Personal usage</h1>
43
+ <p className="mt-1 text-[15px] text-muted-foreground">
44
+ Cross-session activity grouped by local calendar day.
45
+ </p>
46
+ </div>
47
+ <div className="flex flex-wrap items-center gap-2">
48
+ <button
49
+ type="button"
50
+ onClick={requestCurrentSessionUsage}
51
+ className="rounded-lg bg-raised px-3 py-2 text-[13px] font-medium text-foreground hover:bg-hover"
52
+ >
53
+ Current session trace
54
+ </button>
55
+ <PeriodPicker period={period} setPeriod={setPeriod} />
56
+ </div>
57
+ </div>
58
+ );
59
+ }
60
+
61
+ function PeriodPicker({
62
+ period,
63
+ setPeriod,
64
+ }: {
65
+ period: '7d' | '30d';
66
+ setPeriod: Dispatch<SetStateAction<'7d' | '30d'>>;
67
+ }): React.ReactElement {
68
+ return (
69
+ <div className="flex rounded-lg bg-raised p-0.5">
70
+ {(['7d', '30d'] as const).map((value) => (
71
+ <button
72
+ key={value}
73
+ type="button"
74
+ aria-pressed={period === value}
75
+ onClick={() => setPeriod(value)}
76
+ className={`rounded-md px-3 py-1.5 text-[13px] font-medium transition-colors ${
77
+ period === value
78
+ ? 'bg-card text-foreground shadow-sm shadow-black/25'
79
+ : 'text-muted-foreground hover:text-foreground'
80
+ }`}
81
+ >
82
+ {value === '7d' ? '7 days' : '30 days'}
83
+ </button>
84
+ ))}
85
+ </div>
86
+ );
87
+ }
88
+
89
+ export function CurrentSessionPanel({
90
+ state,
91
+ }: {
92
+ state: TPersonalUsageDashboardState;
93
+ }): React.ReactElement | null {
94
+ if (state.currentSessionUsageStatus === 'idle') return null;
95
+ let content: React.ReactNode = null;
96
+ if (state.currentSessionUsageStatus === 'loading') {
97
+ content = <p className="mt-3 text-[14px] text-muted-foreground">Loading trace…</p>;
98
+ } else if (state.currentSessionUsageStatus === 'error') {
99
+ content = <p className="mt-3 text-[14px] text-destructive">Current trace unavailable.</p>;
100
+ } else if (state.currentSessionUsageReport) {
101
+ const turns = state.currentSessionUsageReport.timeline.length;
102
+ content = (
103
+ <p className="mt-3 text-[14px] tabular-nums text-muted-foreground">
104
+ This session: {number.format(state.currentSessionUsageReport.totalTokens)} tokens
105
+ {turns > 0 ? ` · ${number.format(turns)} turns` : ''}
106
+ </p>
107
+ );
108
+ }
109
+ return (
110
+ <section className="rounded-2xl bg-card p-5" aria-label="Current session usage">
111
+ <h2 className="text-[15px] font-semibold">Current session trace</h2>
112
+ {content}
113
+ </section>
114
+ );
115
+ }
116
+
117
+ /** Only meaningful beside a shown dollar figure — "Unknown" already says nothing was priced. */
118
+ function unpricedTurnsNote(report: TPersonalUsageReport): string | null {
119
+ if (report.totals.costStatus === 'unknown') return null;
120
+ const count = report.totals.unpricedTurns ?? 0;
121
+ if (count === 0) return null;
122
+ const turnWord = count === 1 ? 'turn' : 'turns';
123
+ const verb = count === 1 ? 'is' : 'are';
124
+ return `${number.format(count)} ${turnWord} without a price ${verb} not included.`;
125
+ }
126
+
127
+ function UsageStats({ report }: { report: TPersonalUsageReport }): React.ReactElement {
128
+ const cost =
129
+ report.totals.costStatus === 'unknown'
130
+ ? 'Unknown'
131
+ : `$${report.totals.costUsd.toFixed(COST_DECIMAL_PLACES)}`;
132
+ const note = unpricedTurnsNote(report);
133
+ return (
134
+ <section aria-label="Usage totals">
135
+ <div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
136
+ <Stat label="Turns" value={number.format(report.totals.turns)} />
137
+ <Stat label="Sessions" value={number.format(report.totals.sessions)} />
138
+ <Stat label="Tokens" value={number.format(report.totals.totalTokens)} />
139
+ <Stat
140
+ label="Cost"
141
+ value={cost}
142
+ {...(report.totals.costStatus === 'estimated' ? { note: 'estimated' } : {})}
143
+ />
144
+ </div>
145
+ {note ? <p className="mt-2 text-[13px] text-subtle">{note}</p> : null}
146
+ </section>
147
+ );
148
+ }
149
+
150
+ function DailyUsageChart({ report }: { report: TPersonalUsageReport }): React.ReactElement {
151
+ const maxTokens = report.daily.reduce(
152
+ (maximum, day) => Math.max(maximum, day.totals.totalTokens),
153
+ 0,
154
+ );
155
+ return (
156
+ <section className="rounded-2xl bg-card p-5">
157
+ <DailyChartHeader report={report} />
158
+ <div
159
+ className="mt-5 flex h-44 items-end gap-1.5"
160
+ role="img"
161
+ aria-label="Daily token usage chart"
162
+ >
163
+ {report.daily.map((day) => {
164
+ const height =
165
+ maxTokens === 0
166
+ ? MINIMUM_BAR_PERCENT
167
+ : Math.max(MINIMUM_BAR_PERCENT, (day.totals.totalTokens / maxTokens) * PERCENT);
168
+ return (
169
+ <div
170
+ key={day.date}
171
+ className="group flex min-w-0 flex-1 flex-col items-center justify-end gap-2"
172
+ >
173
+ <div className="relative flex h-32 w-full items-end">
174
+ <div
175
+ className="mx-auto w-full max-w-10 rounded-md bg-accent/75 transition-colors group-hover:bg-accent"
176
+ style={{ height: `${height}%` }}
177
+ title={`${day.date}: ${number.format(day.totals.totalTokens)} tokens`}
178
+ />
179
+ </div>
180
+ <span className="truncate text-[12px] tabular-nums text-subtle">
181
+ {day.date.slice(MONTH_DAY_OFFSET)}
182
+ </span>
183
+ </div>
184
+ );
185
+ })}
186
+ </div>
187
+ </section>
188
+ );
189
+ }
190
+
191
+ function DailyChartHeader({ report }: { report: TPersonalUsageReport }): React.ReactElement {
192
+ return (
193
+ <div className="flex items-center justify-between gap-3">
194
+ <div>
195
+ <h2 className="text-[15px] font-semibold">Daily usage</h2>
196
+ <p className="mt-0.5 text-[13px] text-muted-foreground">
197
+ {report.interval.startDate} → {report.interval.endDate} · {report.timezone}
198
+ </p>
199
+ </div>
200
+ {report.daily.at(-1)?.partial ? (
201
+ <span className="rounded-full bg-warning/15 px-2.5 py-1 text-[12px] font-medium text-warning">
202
+ Partial day
203
+ </span>
204
+ ) : null}
205
+ </div>
206
+ );
207
+ }
208
+
209
+ export function PersonalUsageContent({
210
+ state,
211
+ breakdown,
212
+ setBreakdown,
213
+ }: {
214
+ state: TPersonalUsageDashboardState;
215
+ breakdown: TUsageBreakdown;
216
+ setBreakdown: Dispatch<SetStateAction<TUsageBreakdown>>;
217
+ }): React.ReactElement {
218
+ const report = state.personalUsageReport;
219
+ if (state.personalUsageStatus === 'loading' && !report) {
220
+ return <StatusPanel>Loading local usage…</StatusPanel>;
221
+ }
222
+ if (state.personalUsageStatus === 'error') {
223
+ return <StatusPanel>{state.personalUsageError ?? 'Usage report failed.'}</StatusPanel>;
224
+ }
225
+ if (!report || isEmptyReport(report)) {
226
+ return <StatusPanel>No recorded usage in this period.</StatusPanel>;
227
+ }
228
+ return (
229
+ <>
230
+ <UsageStats report={report} />
231
+ <DailyUsageChart report={report} />
232
+ <BreakdownPanel
233
+ state={state}
234
+ report={report}
235
+ breakdown={breakdown}
236
+ setBreakdown={setBreakdown}
237
+ />
238
+ <StoredSessionPanel state={state} />
239
+ <CoverageNote report={report} />
240
+ </>
241
+ );
242
+ }
243
+
244
+ function isEmptyReport(report: TPersonalUsageReport): boolean {
245
+ return (
246
+ report.totals.sessions === 0 &&
247
+ report.totals.totalTokens === 0 &&
248
+ report.byActivity.length === 0
249
+ );
250
+ }