@robota-sdk/agent-ui-web 0.0.0-stage → 3.0.0-beta.101
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.
- package/CHANGELOG.md +557 -0
- package/LICENSE +661 -0
- package/README.md +19 -2
- package/dist/browser/index.d.ts +736 -0
- package/dist/browser/index.d.ts.map +1 -0
- package/dist/browser/index.js +8525 -0
- package/dist/browser/index.js.map +1 -0
- package/dist/node/index.cjs +8575 -0
- package/dist/node/index.d.cts +736 -0
- package/dist/node/index.d.cts.map +1 -0
- package/dist/node/index.d.ts +736 -0
- package/dist/node/index.d.ts.map +1 -0
- package/dist/node/index.js +8525 -0
- package/dist/node/index.js.map +1 -0
- package/docs/README.md +62 -0
- package/docs/SPEC.md +130 -0
- package/package.json +99 -3
- package/src/app-view/bridge.ts +217 -0
- package/src/app-view/call-tool-result.ts +58 -0
- package/src/app-view/host-context.ts +37 -0
- package/src/app-view/types.ts +12 -0
- package/src/client/runtime-host.ts +84 -0
- package/src/client/ws-session-client.ts +131 -0
- package/src/clipboard.ts +81 -0
- package/src/components/AgentActivityPanel.tsx +371 -0
- package/src/components/AgentSwitcherSheet.tsx +93 -0
- package/src/components/AppView.tsx +147 -0
- package/src/components/Brand.tsx +33 -0
- package/src/components/Composer.tsx +685 -0
- package/src/components/ConversationView.tsx +789 -0
- package/src/components/Dialog.tsx +230 -0
- package/src/components/DiffLines.tsx +81 -0
- package/src/components/ExecutionDetailSheet.tsx +142 -0
- package/src/components/HelpSheet.tsx +104 -0
- package/src/components/PermissionPrompt.tsx +762 -0
- package/src/components/PersonalUsageDashboard.tsx +39 -0
- package/src/components/PersonalUsageDashboardDetails.tsx +368 -0
- package/src/components/PersonalUsageDashboardSections.tsx +250 -0
- package/src/components/PopupMenu.tsx +166 -0
- package/src/components/ProjectPanel.tsx +268 -0
- package/src/components/SessionMonitor.tsx +180 -0
- package/src/components/SessionSidebar.tsx +429 -0
- package/src/components/SessionSurface.tsx +482 -0
- package/src/components/SessionSurfaceChrome.tsx +319 -0
- package/src/components/SettingsFields.tsx +159 -0
- package/src/components/SettingsGeneralSection.tsx +49 -0
- package/src/components/SettingsMcpSection.tsx +157 -0
- package/src/components/SettingsPermissionsSection.tsx +137 -0
- package/src/components/SettingsPluginsSection.tsx +165 -0
- package/src/components/SettingsProvidersSection.tsx +211 -0
- package/src/components/SettingsScreen.tsx +340 -0
- package/src/components/StatusControls.tsx +294 -0
- package/src/components/composer-attachments.ts +246 -0
- package/src/components/personal-usage-dashboard-types.ts +26 -0
- package/src/driver-labels.ts +88 -0
- package/src/execution-entry-status.ts +55 -0
- package/src/goal-view.ts +19 -0
- package/src/hooks/command-menu.ts +65 -0
- package/src/hooks/excluded-commands.ts +52 -0
- package/src/hooks/prompt-state.ts +83 -0
- package/src/hooks/server-message-handling.ts +95 -0
- package/src/hooks/session-client-types.ts +344 -0
- package/src/hooks/ui-intent-state.ts +74 -0
- package/src/hooks/use-agent-switcher-state.ts +125 -0
- package/src/hooks/use-app-view-requests.ts +178 -0
- package/src/hooks/use-execution-detail.ts +118 -0
- package/src/hooks/use-media-query.ts +27 -0
- package/src/hooks/use-model-list.ts +45 -0
- package/src/hooks/use-personal-usage.ts +138 -0
- package/src/hooks/use-project-panel.ts +117 -0
- package/src/hooks/use-schedules-state.ts +92 -0
- package/src/hooks/use-session-directory.ts +202 -0
- package/src/hooks/use-settings-state.ts +115 -0
- package/src/hooks/useSessionClient.ts +1051 -0
- package/src/index.ts +72 -0
- package/src/product-identity.tsx +26 -0
- package/src/schedule-view.ts +92 -0
- package/src/styles/surface.css +231 -0
- package/src/styles/surface.css.d.mts +1 -0
- package/src/styles/theme.css +38 -0
- 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
|
+
}
|