@agent-native/dispatch 0.1.1 → 0.2.3

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 (146) hide show
  1. package/dist/actions/index.d.ts.map +1 -1
  2. package/dist/actions/index.js +2 -0
  3. package/dist/actions/index.js.map +1 -1
  4. package/dist/actions/list-dispatch-usage-metrics.d.ts +3 -0
  5. package/dist/actions/list-dispatch-usage-metrics.d.ts.map +1 -0
  6. package/dist/actions/list-dispatch-usage-metrics.js +18 -0
  7. package/dist/actions/list-dispatch-usage-metrics.js.map +1 -0
  8. package/dist/actions/navigate.d.ts +1 -0
  9. package/dist/actions/navigate.d.ts.map +1 -1
  10. package/dist/actions/navigate.js +3 -17
  11. package/dist/actions/navigate.js.map +1 -1
  12. package/dist/actions/view-screen.d.ts.map +1 -1
  13. package/dist/actions/view-screen.js +19 -0
  14. package/dist/actions/view-screen.js.map +1 -1
  15. package/dist/components/agents-panel.js +3 -3
  16. package/dist/components/app-keys-popover.js +2 -2
  17. package/dist/components/create-app-popover.js +2 -2
  18. package/dist/components/dispatch-shell.js +2 -2
  19. package/dist/components/index.d.ts +1 -0
  20. package/dist/components/index.d.ts.map +1 -1
  21. package/dist/components/index.js.map +1 -1
  22. package/dist/components/layout/Header.js +5 -5
  23. package/dist/components/layout/Header.js.map +1 -1
  24. package/dist/components/layout/Layout.d.ts +28 -3
  25. package/dist/components/layout/Layout.d.ts.map +1 -1
  26. package/dist/components/layout/Layout.js +138 -28
  27. package/dist/components/layout/Layout.js.map +1 -1
  28. package/dist/components/messaging-setup-panel.js +4 -4
  29. package/dist/components/ui/accordion.js +1 -1
  30. package/dist/components/ui/alert-dialog.js +2 -2
  31. package/dist/components/ui/alert.js +1 -1
  32. package/dist/components/ui/avatar.js +1 -1
  33. package/dist/components/ui/badge.js +1 -1
  34. package/dist/components/ui/breadcrumb.js +1 -1
  35. package/dist/components/ui/button.js +1 -1
  36. package/dist/components/ui/calendar.js +2 -2
  37. package/dist/components/ui/card.js +1 -1
  38. package/dist/components/ui/carousel.d.ts +2 -2
  39. package/dist/components/ui/carousel.js +2 -2
  40. package/dist/components/ui/chart.js +1 -1
  41. package/dist/components/ui/checkbox.js +1 -1
  42. package/dist/components/ui/command.js +2 -2
  43. package/dist/components/ui/context-menu.js +1 -1
  44. package/dist/components/ui/dialog.js +1 -1
  45. package/dist/components/ui/drawer.js +1 -1
  46. package/dist/components/ui/dropdown-menu.js +1 -1
  47. package/dist/components/ui/form.js +2 -2
  48. package/dist/components/ui/hover-card.js +1 -1
  49. package/dist/components/ui/input-otp.js +1 -1
  50. package/dist/components/ui/input.js +1 -1
  51. package/dist/components/ui/label.js +1 -1
  52. package/dist/components/ui/menubar.js +1 -1
  53. package/dist/components/ui/navigation-menu.js +1 -1
  54. package/dist/components/ui/pagination.d.ts +1 -1
  55. package/dist/components/ui/pagination.js +2 -2
  56. package/dist/components/ui/popover.js +1 -1
  57. package/dist/components/ui/progress.js +1 -1
  58. package/dist/components/ui/radio-group.js +1 -1
  59. package/dist/components/ui/resizable.js +1 -1
  60. package/dist/components/ui/scroll-area.js +1 -1
  61. package/dist/components/ui/select.js +1 -1
  62. package/dist/components/ui/separator.js +1 -1
  63. package/dist/components/ui/sheet.js +1 -1
  64. package/dist/components/ui/sidebar.d.ts +2 -2
  65. package/dist/components/ui/sidebar.d.ts.map +1 -1
  66. package/dist/components/ui/sidebar.js +9 -9
  67. package/dist/components/ui/sidebar.js.map +1 -1
  68. package/dist/components/ui/skeleton.js +1 -1
  69. package/dist/components/ui/slider.js +1 -1
  70. package/dist/components/ui/sonner.js +1 -1
  71. package/dist/components/ui/spinner.js +1 -1
  72. package/dist/components/ui/switch.js +1 -1
  73. package/dist/components/ui/table.js +1 -1
  74. package/dist/components/ui/tabs.js +1 -1
  75. package/dist/components/ui/textarea.js +1 -1
  76. package/dist/components/ui/toast.js +1 -1
  77. package/dist/components/ui/toaster.js +2 -2
  78. package/dist/components/ui/toggle-group.js +2 -2
  79. package/dist/components/ui/toggle.js +1 -1
  80. package/dist/components/ui/tooltip.js +1 -1
  81. package/dist/components/ui/use-toast.d.ts +1 -1
  82. package/dist/components/ui/use-toast.js +1 -1
  83. package/dist/hooks/use-navigation-state.d.ts +2 -1
  84. package/dist/hooks/use-navigation-state.d.ts.map +1 -1
  85. package/dist/hooks/use-navigation-state.js +36 -8
  86. package/dist/hooks/use-navigation-state.js.map +1 -1
  87. package/dist/hooks/use-toast.d.ts +1 -1
  88. package/dist/routes/index.d.ts.map +1 -1
  89. package/dist/routes/index.js +3 -2
  90. package/dist/routes/index.js.map +1 -1
  91. package/dist/routes/pages/_index.js +1 -1
  92. package/dist/routes/pages/agents.js +2 -2
  93. package/dist/routes/pages/approval.js +2 -2
  94. package/dist/routes/pages/approvals.js +4 -4
  95. package/dist/routes/pages/apps.$appId.js +3 -3
  96. package/dist/routes/pages/apps.js +5 -5
  97. package/dist/routes/pages/audit.js +1 -1
  98. package/dist/routes/pages/destinations.js +6 -6
  99. package/dist/routes/pages/extensions.$id.d.ts +2 -0
  100. package/dist/routes/pages/extensions.$id.d.ts.map +1 -0
  101. package/dist/routes/pages/extensions.$id.js +6 -0
  102. package/dist/routes/pages/extensions.$id.js.map +1 -0
  103. package/dist/routes/pages/extensions._index.d.ts +2 -0
  104. package/dist/routes/pages/extensions._index.d.ts.map +1 -0
  105. package/dist/routes/pages/extensions._index.js +6 -0
  106. package/dist/routes/pages/extensions._index.js.map +1 -0
  107. package/dist/routes/pages/identities.js +2 -2
  108. package/dist/routes/pages/integrations.js +4 -4
  109. package/dist/routes/pages/messaging.js +2 -2
  110. package/dist/routes/pages/metrics.d.ts +5 -0
  111. package/dist/routes/pages/metrics.d.ts.map +1 -0
  112. package/dist/routes/pages/metrics.js +135 -0
  113. package/dist/routes/pages/metrics.js.map +1 -0
  114. package/dist/routes/pages/new-app.js +1 -1
  115. package/dist/routes/pages/overview.d.ts.map +1 -1
  116. package/dist/routes/pages/overview.js +9 -17
  117. package/dist/routes/pages/overview.js.map +1 -1
  118. package/dist/routes/pages/team.js +1 -1
  119. package/dist/routes/pages/vault.js +10 -10
  120. package/dist/routes/pages/workspace.js +10 -10
  121. package/dist/server/lib/pre-auth-routing.d.ts.map +1 -1
  122. package/dist/server/lib/pre-auth-routing.js +9 -2
  123. package/dist/server/lib/pre-auth-routing.js.map +1 -1
  124. package/dist/server/lib/usage-metrics-store.d.ts +93 -0
  125. package/dist/server/lib/usage-metrics-store.d.ts.map +1 -0
  126. package/dist/server/lib/usage-metrics-store.js +386 -0
  127. package/dist/server/lib/usage-metrics-store.js.map +1 -0
  128. package/package.json +11 -6
  129. package/src/actions/index.ts +2 -0
  130. package/src/actions/list-dispatch-usage-metrics.ts +19 -0
  131. package/src/actions/navigate.ts +5 -17
  132. package/src/actions/view-screen.ts +18 -0
  133. package/src/components/index.ts +6 -0
  134. package/src/components/layout/Header.tsx +2 -2
  135. package/src/components/layout/Layout.tsx +197 -48
  136. package/src/components/ui/sidebar.tsx +22 -18
  137. package/src/hooks/use-navigation-state.ts +57 -8
  138. package/src/routes/index.ts +3 -2
  139. package/src/routes/pages/extensions.$id.tsx +5 -0
  140. package/src/routes/pages/extensions._index.tsx +5 -0
  141. package/src/routes/pages/metrics.tsx +667 -0
  142. package/src/routes/pages/overview.tsx +0 -10
  143. package/src/server/lib/pre-auth-routing.ts +10 -2
  144. package/src/server/lib/usage-metrics-store.ts +605 -0
  145. package/src/styles/dispatch-css.spec.ts +55 -0
  146. package/src/styles/dispatch.css +9 -0
@@ -0,0 +1,667 @@
1
+ import { useMemo, useState, type ReactNode } from "react";
2
+ import { useActionQuery } from "@agent-native/core/client";
3
+ import {
4
+ IconActivity,
5
+ IconAlertTriangle,
6
+ IconApps,
7
+ IconChartBar,
8
+ IconClockHour4,
9
+ IconCoin,
10
+ IconMessages,
11
+ IconUsersGroup,
12
+ } from "@tabler/icons-react";
13
+ import { DispatchShell } from "@/components/dispatch-shell";
14
+ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
15
+ import { Badge } from "@/components/ui/badge";
16
+ import { Button } from "@/components/ui/button";
17
+ import { Skeleton } from "@/components/ui/skeleton";
18
+ import { cn } from "@/lib/utils";
19
+
20
+ export function meta() {
21
+ return [{ title: "Metrics — Dispatch" }];
22
+ }
23
+
24
+ interface UsageMetricBucket {
25
+ key: string;
26
+ label: string;
27
+ costCents: number;
28
+ calls: number;
29
+ chatCalls: number;
30
+ inputTokens: number;
31
+ outputTokens: number;
32
+ activeUsers: number;
33
+ lastActiveAt: number | null;
34
+ }
35
+
36
+ interface UserUsageMetric extends UsageMetricBucket {
37
+ ownerEmail: string;
38
+ chatThreads: number;
39
+ chatMessages: number;
40
+ lastChatAt: number | null;
41
+ topApp: string | null;
42
+ role: string | null;
43
+ }
44
+
45
+ interface AppAccessMetric {
46
+ id: string;
47
+ name: string;
48
+ path: string;
49
+ status?: "ready" | "pending";
50
+ isDispatch: boolean;
51
+ accessLabel: string;
52
+ accessUsers: number;
53
+ usersWithUsage: number;
54
+ usageCalls: number;
55
+ chatCalls: number;
56
+ costCents: number;
57
+ lastActiveAt: number | null;
58
+ }
59
+
60
+ interface DailyUsageMetric {
61
+ date: string;
62
+ costCents: number;
63
+ calls: number;
64
+ chatCalls: number;
65
+ activeUsers: number;
66
+ }
67
+
68
+ interface RecentUsageMetric {
69
+ id: number;
70
+ createdAt: number;
71
+ ownerEmail: string;
72
+ app: string;
73
+ label: string;
74
+ model: string;
75
+ inputTokens: number;
76
+ outputTokens: number;
77
+ costCents: number;
78
+ }
79
+
80
+ interface DispatchUsageMetrics {
81
+ sinceDays: number;
82
+ access: {
83
+ viewerEmail: string;
84
+ orgId: string | null;
85
+ role: string | null;
86
+ scope: "organization" | "solo";
87
+ totalUsers: number;
88
+ };
89
+ totals: {
90
+ costCents: number;
91
+ calls: number;
92
+ chatCalls: number;
93
+ inputTokens: number;
94
+ outputTokens: number;
95
+ cacheReadTokens: number;
96
+ cacheWriteTokens: number;
97
+ activeUsers: number;
98
+ chatThreads: number;
99
+ chatMessages: number;
100
+ workspaceApps: number;
101
+ };
102
+ byApp: UsageMetricBucket[];
103
+ byUser: UserUsageMetric[];
104
+ byLabel: UsageMetricBucket[];
105
+ byModel: UsageMetricBucket[];
106
+ daily: DailyUsageMetric[];
107
+ appAccess: AppAccessMetric[];
108
+ recent: RecentUsageMetric[];
109
+ }
110
+
111
+ const RANGES = [7, 30, 90] as const;
112
+
113
+ function formatCost(cents: number): string {
114
+ if (!Number.isFinite(cents) || cents === 0) return "$0.00";
115
+ if (Math.abs(cents) < 1) return `${cents.toFixed(3)}¢`;
116
+ if (Math.abs(cents) < 100) return `${cents.toFixed(2)}¢`;
117
+ return (cents / 100).toLocaleString(undefined, {
118
+ style: "currency",
119
+ currency: "USD",
120
+ maximumFractionDigits: 2,
121
+ });
122
+ }
123
+
124
+ function formatNumber(value: number): string {
125
+ return new Intl.NumberFormat(undefined, {
126
+ notation: value >= 10_000 ? "compact" : "standard",
127
+ maximumFractionDigits: value >= 10_000 ? 1 : 0,
128
+ }).format(value);
129
+ }
130
+
131
+ function formatTokens(value: number): string {
132
+ return new Intl.NumberFormat(undefined, {
133
+ notation: "compact",
134
+ maximumFractionDigits: 1,
135
+ }).format(value);
136
+ }
137
+
138
+ function timeAgo(timestamp: number | null): string {
139
+ if (!timestamp) return "No activity";
140
+ const diff = Date.now() - timestamp;
141
+ if (diff < 60_000) return "just now";
142
+ if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;
143
+ if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`;
144
+ return `${Math.floor(diff / 86_400_000)}d ago`;
145
+ }
146
+
147
+ function displayApp(value: string | null | undefined): string {
148
+ const trimmed = value?.trim();
149
+ if (!trimmed || trimmed === "unattributed") return "Unattributed";
150
+ return trimmed;
151
+ }
152
+
153
+ function maxCost(rows: Array<{ costCents: number }>): number {
154
+ return rows.reduce((max, row) => Math.max(max, row.costCents), 0);
155
+ }
156
+
157
+ function barWidth(value: number, max: number): string {
158
+ if (max <= 0 || value <= 0) return "0%";
159
+ return `${Math.max(4, Math.round((value / max) * 100))}%`;
160
+ }
161
+
162
+ function RangeSelector({
163
+ value,
164
+ onChange,
165
+ }: {
166
+ value: number;
167
+ onChange: (value: number) => void;
168
+ }) {
169
+ return (
170
+ <div className="flex rounded-md border bg-card p-0.5">
171
+ {RANGES.map((range) => (
172
+ <Button
173
+ key={range}
174
+ type="button"
175
+ variant={value === range ? "secondary" : "ghost"}
176
+ size="sm"
177
+ className="h-7 px-3 text-xs"
178
+ onClick={() => onChange(range)}
179
+ >
180
+ {range}d
181
+ </Button>
182
+ ))}
183
+ </div>
184
+ );
185
+ }
186
+
187
+ function MetricCard({
188
+ label,
189
+ value,
190
+ detail,
191
+ icon,
192
+ }: {
193
+ label: string;
194
+ value: string;
195
+ detail: string;
196
+ icon: ReactNode;
197
+ }) {
198
+ return (
199
+ <div className="rounded-lg border bg-card p-4">
200
+ <div className="mb-3 flex items-center justify-between gap-3">
201
+ <span className="text-xs font-medium text-muted-foreground">
202
+ {label}
203
+ </span>
204
+ <span className="text-muted-foreground">{icon}</span>
205
+ </div>
206
+ <div className="text-2xl font-semibold tabular-nums text-foreground">
207
+ {value}
208
+ </div>
209
+ <div className="mt-1 truncate text-xs text-muted-foreground">
210
+ {detail}
211
+ </div>
212
+ </div>
213
+ );
214
+ }
215
+
216
+ function Panel({
217
+ title,
218
+ icon,
219
+ children,
220
+ action,
221
+ }: {
222
+ title: string;
223
+ icon: ReactNode;
224
+ children: ReactNode;
225
+ action?: ReactNode;
226
+ }) {
227
+ return (
228
+ <section className="rounded-lg border bg-card">
229
+ <div className="flex items-center justify-between gap-3 border-b px-4 py-3">
230
+ <div className="flex min-w-0 items-center gap-2">
231
+ <span className="text-muted-foreground">{icon}</span>
232
+ <h2 className="truncate text-sm font-semibold text-foreground">
233
+ {title}
234
+ </h2>
235
+ </div>
236
+ {action}
237
+ </div>
238
+ <div className="p-4">{children}</div>
239
+ </section>
240
+ );
241
+ }
242
+
243
+ function LoadingMetrics() {
244
+ return (
245
+ <div className="space-y-4">
246
+ <div className="grid gap-3 md:grid-cols-2 xl:grid-cols-5">
247
+ {Array.from({ length: 5 }).map((_, index) => (
248
+ <div key={index} className="rounded-lg border bg-card p-4">
249
+ <Skeleton className="mb-4 h-4 w-24" />
250
+ <Skeleton className="h-7 w-20" />
251
+ <Skeleton className="mt-3 h-3 w-28" />
252
+ </div>
253
+ ))}
254
+ </div>
255
+ <Skeleton className="h-80 rounded-lg" />
256
+ </div>
257
+ );
258
+ }
259
+
260
+ function AppSpendRows({ rows }: { rows: UsageMetricBucket[] }) {
261
+ const max = maxCost(rows);
262
+ if (rows.length === 0) {
263
+ return (
264
+ <div className="rounded-lg border border-dashed px-4 py-8 text-sm text-muted-foreground">
265
+ No LLM usage recorded for this window.
266
+ </div>
267
+ );
268
+ }
269
+ return (
270
+ <div className="space-y-3">
271
+ {rows.map((row) => (
272
+ <div key={row.key} className="space-y-1.5">
273
+ <div className="flex items-center justify-between gap-3 text-sm">
274
+ <div className="min-w-0">
275
+ <div className="truncate font-medium text-foreground">
276
+ {displayApp(row.key)}
277
+ </div>
278
+ <div className="text-xs text-muted-foreground">
279
+ {formatNumber(row.chatCalls)} chats ·{" "}
280
+ {formatNumber(row.activeUsers)} users
281
+ </div>
282
+ </div>
283
+ <div className="shrink-0 text-right">
284
+ <div className="font-medium tabular-nums text-foreground">
285
+ {formatCost(row.costCents)}
286
+ </div>
287
+ <div className="text-xs text-muted-foreground">
288
+ {formatNumber(row.calls)} calls
289
+ </div>
290
+ </div>
291
+ </div>
292
+ <div className="h-2 overflow-hidden rounded-full bg-muted">
293
+ <div
294
+ className="h-full rounded-full bg-foreground"
295
+ style={{ width: barWidth(row.costCents, max) }}
296
+ />
297
+ </div>
298
+ </div>
299
+ ))}
300
+ </div>
301
+ );
302
+ }
303
+
304
+ function DailyActivity({ rows }: { rows: DailyUsageMetric[] }) {
305
+ const max = Math.max(
306
+ 1,
307
+ rows.reduce((value, row) => Math.max(value, row.calls), 0),
308
+ );
309
+ if (rows.length === 0) {
310
+ return (
311
+ <div className="rounded-lg border border-dashed px-4 py-8 text-sm text-muted-foreground">
312
+ No activity in this window.
313
+ </div>
314
+ );
315
+ }
316
+ return (
317
+ <div className="flex h-44 items-end gap-1">
318
+ {rows.map((row) => (
319
+ <div
320
+ key={row.date}
321
+ className="group flex min-w-0 flex-1 flex-col items-center gap-2"
322
+ >
323
+ <div className="relative flex h-36 w-full items-end rounded-sm bg-muted/60">
324
+ <div
325
+ className="w-full rounded-sm bg-foreground transition group-hover:bg-primary"
326
+ style={{ height: `${Math.max(4, (row.calls / max) * 100)}%` }}
327
+ />
328
+ </div>
329
+ <div className="hidden text-[10px] text-muted-foreground sm:block">
330
+ {row.date.slice(5)}
331
+ </div>
332
+ </div>
333
+ ))}
334
+ </div>
335
+ );
336
+ }
337
+
338
+ function AppAccessTable({ rows }: { rows: AppAccessMetric[] }) {
339
+ const visibleRows = rows.filter((row) => !row.isDispatch);
340
+ if (visibleRows.length === 0) {
341
+ return (
342
+ <div className="rounded-lg border border-dashed px-4 py-8 text-sm text-muted-foreground">
343
+ No workspace apps discovered yet.
344
+ </div>
345
+ );
346
+ }
347
+ return (
348
+ <div className="overflow-x-auto">
349
+ <table className="w-full min-w-[720px] text-left text-xs">
350
+ <thead>
351
+ <tr className="border-b text-muted-foreground">
352
+ <th className="px-2 py-2 font-medium">App</th>
353
+ <th className="px-2 py-2 font-medium">Access</th>
354
+ <th className="px-2 py-2 text-right font-medium">Users</th>
355
+ <th className="px-2 py-2 text-right font-medium">Chats</th>
356
+ <th className="px-2 py-2 text-right font-medium">Cost</th>
357
+ <th className="px-2 py-2 text-right font-medium">Last activity</th>
358
+ </tr>
359
+ </thead>
360
+ <tbody>
361
+ {visibleRows.map((row) => (
362
+ <tr key={row.id} className="border-b last:border-0">
363
+ <td className="px-2 py-3">
364
+ <div className="font-medium text-foreground">{row.name}</div>
365
+ <div className="font-mono text-[11px] text-muted-foreground">
366
+ {row.path}
367
+ </div>
368
+ </td>
369
+ <td className="px-2 py-3">
370
+ <Badge
371
+ variant="outline"
372
+ className={cn(
373
+ row.status === "pending" &&
374
+ "border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300",
375
+ )}
376
+ >
377
+ {row.status === "pending" ? "Building" : row.accessLabel}
378
+ </Badge>
379
+ </td>
380
+ <td className="px-2 py-3 text-right tabular-nums">
381
+ {formatNumber(row.usersWithUsage)} /{" "}
382
+ {formatNumber(row.accessUsers)}
383
+ </td>
384
+ <td className="px-2 py-3 text-right tabular-nums">
385
+ {formatNumber(row.chatCalls)}
386
+ </td>
387
+ <td className="px-2 py-3 text-right tabular-nums">
388
+ {formatCost(row.costCents)}
389
+ </td>
390
+ <td className="px-2 py-3 text-right text-muted-foreground">
391
+ {timeAgo(row.lastActiveAt)}
392
+ </td>
393
+ </tr>
394
+ ))}
395
+ </tbody>
396
+ </table>
397
+ </div>
398
+ );
399
+ }
400
+
401
+ function UserTable({ rows }: { rows: UserUsageMetric[] }) {
402
+ if (rows.length === 0) {
403
+ return (
404
+ <div className="rounded-lg border border-dashed px-4 py-8 text-sm text-muted-foreground">
405
+ No users have triggered LLM usage in this window.
406
+ </div>
407
+ );
408
+ }
409
+ return (
410
+ <div className="overflow-x-auto">
411
+ <table className="w-full min-w-[760px] text-left text-xs">
412
+ <thead>
413
+ <tr className="border-b text-muted-foreground">
414
+ <th className="px-2 py-2 font-medium">User</th>
415
+ <th className="px-2 py-2 font-medium">Role</th>
416
+ <th className="px-2 py-2 font-medium">Top app</th>
417
+ <th className="px-2 py-2 text-right font-medium">Chats</th>
418
+ <th className="px-2 py-2 text-right font-medium">Threads</th>
419
+ <th className="px-2 py-2 text-right font-medium">Tokens</th>
420
+ <th className="px-2 py-2 text-right font-medium">Cost</th>
421
+ </tr>
422
+ </thead>
423
+ <tbody>
424
+ {rows.slice(0, 12).map((row) => (
425
+ <tr key={row.ownerEmail} className="border-b last:border-0">
426
+ <td className="max-w-64 px-2 py-3">
427
+ <div className="truncate font-medium text-foreground">
428
+ {row.ownerEmail}
429
+ </div>
430
+ <div className="text-muted-foreground">
431
+ {timeAgo(row.lastActiveAt ?? row.lastChatAt)}
432
+ </div>
433
+ </td>
434
+ <td className="px-2 py-3">
435
+ <Badge variant="secondary">{row.role ?? "user"}</Badge>
436
+ </td>
437
+ <td className="px-2 py-3 text-muted-foreground">
438
+ {displayApp(row.topApp)}
439
+ </td>
440
+ <td className="px-2 py-3 text-right tabular-nums">
441
+ {formatNumber(row.chatCalls)}
442
+ </td>
443
+ <td className="px-2 py-3 text-right tabular-nums">
444
+ {formatNumber(row.chatThreads)}
445
+ </td>
446
+ <td className="px-2 py-3 text-right tabular-nums">
447
+ {formatTokens(row.inputTokens + row.outputTokens)}
448
+ </td>
449
+ <td className="px-2 py-3 text-right tabular-nums">
450
+ {formatCost(row.costCents)}
451
+ </td>
452
+ </tr>
453
+ ))}
454
+ </tbody>
455
+ </table>
456
+ </div>
457
+ );
458
+ }
459
+
460
+ function CompactBreakdown({
461
+ rows,
462
+ empty,
463
+ }: {
464
+ rows: UsageMetricBucket[];
465
+ empty: string;
466
+ }) {
467
+ const max = maxCost(rows);
468
+ if (rows.length === 0) {
469
+ return <div className="text-sm text-muted-foreground">{empty}</div>;
470
+ }
471
+ return (
472
+ <div className="space-y-3">
473
+ {rows.slice(0, 6).map((row) => (
474
+ <div key={row.key} className="space-y-1">
475
+ <div className="flex items-center justify-between gap-3 text-xs">
476
+ <span className="truncate font-medium text-foreground">
477
+ {row.label}
478
+ </span>
479
+ <span className="shrink-0 tabular-nums text-muted-foreground">
480
+ {formatCost(row.costCents)}
481
+ </span>
482
+ </div>
483
+ <div className="h-1.5 overflow-hidden rounded-full bg-muted">
484
+ <div
485
+ className="h-full rounded-full bg-muted-foreground"
486
+ style={{ width: barWidth(row.costCents, max) }}
487
+ />
488
+ </div>
489
+ </div>
490
+ ))}
491
+ </div>
492
+ );
493
+ }
494
+
495
+ function RecentTable({ rows }: { rows: RecentUsageMetric[] }) {
496
+ if (rows.length === 0) {
497
+ return (
498
+ <div className="rounded-lg border border-dashed px-4 py-8 text-sm text-muted-foreground">
499
+ No recent LLM calls.
500
+ </div>
501
+ );
502
+ }
503
+ return (
504
+ <div className="overflow-x-auto">
505
+ <table className="w-full min-w-[760px] text-left text-xs">
506
+ <thead>
507
+ <tr className="border-b text-muted-foreground">
508
+ <th className="px-2 py-2 font-medium">When</th>
509
+ <th className="px-2 py-2 font-medium">User</th>
510
+ <th className="px-2 py-2 font-medium">App</th>
511
+ <th className="px-2 py-2 font-medium">Label</th>
512
+ <th className="px-2 py-2 font-medium">Model</th>
513
+ <th className="px-2 py-2 text-right font-medium">Cost</th>
514
+ </tr>
515
+ </thead>
516
+ <tbody>
517
+ {rows.slice(0, 10).map((row) => (
518
+ <tr key={row.id} className="border-b last:border-0">
519
+ <td className="px-2 py-3 text-muted-foreground">
520
+ {timeAgo(row.createdAt)}
521
+ </td>
522
+ <td className="max-w-56 px-2 py-3">
523
+ <div className="truncate text-foreground">{row.ownerEmail}</div>
524
+ </td>
525
+ <td className="px-2 py-3 text-muted-foreground">
526
+ {displayApp(row.app)}
527
+ </td>
528
+ <td className="px-2 py-3">
529
+ <Badge variant="outline">{row.label}</Badge>
530
+ </td>
531
+ <td className="max-w-48 px-2 py-3">
532
+ <div className="truncate text-muted-foreground">
533
+ {row.model}
534
+ </div>
535
+ </td>
536
+ <td className="px-2 py-3 text-right tabular-nums">
537
+ {formatCost(row.costCents)}
538
+ </td>
539
+ </tr>
540
+ ))}
541
+ </tbody>
542
+ </table>
543
+ </div>
544
+ );
545
+ }
546
+
547
+ export default function MetricsRoute() {
548
+ const [sinceDays, setSinceDays] = useState(30);
549
+ const { data, isLoading, error } = useActionQuery(
550
+ "list-dispatch-usage-metrics",
551
+ { sinceDays },
552
+ { refetchInterval: 30_000 },
553
+ );
554
+ const metrics = data as DispatchUsageMetrics | undefined;
555
+ const totalTokens = useMemo(() => {
556
+ if (!metrics) return 0;
557
+ return (
558
+ metrics.totals.inputTokens +
559
+ metrics.totals.outputTokens +
560
+ metrics.totals.cacheReadTokens +
561
+ metrics.totals.cacheWriteTokens
562
+ );
563
+ }, [metrics]);
564
+
565
+ return (
566
+ <DispatchShell
567
+ title="Metrics"
568
+ description="Workspace-wide LLM spend, chat volume, user activity, and app access."
569
+ >
570
+ <div className="space-y-4">
571
+ <div className="flex flex-wrap items-center justify-between gap-3">
572
+ <div className="text-sm text-muted-foreground">
573
+ {metrics?.access.scope === "organization"
574
+ ? `${metrics.access.totalUsers} workspace users`
575
+ : `${metrics?.access.totalUsers ?? 0} signed-in users`}
576
+ </div>
577
+ <RangeSelector value={sinceDays} onChange={setSinceDays} />
578
+ </div>
579
+
580
+ {error ? (
581
+ <Alert variant="destructive">
582
+ <IconAlertTriangle className="h-4 w-4" />
583
+ <AlertTitle>Metrics unavailable</AlertTitle>
584
+ <AlertDescription>
585
+ {error instanceof Error ? error.message : "Unable to load usage."}
586
+ </AlertDescription>
587
+ </Alert>
588
+ ) : null}
589
+
590
+ {isLoading && !metrics ? <LoadingMetrics /> : null}
591
+
592
+ {metrics ? (
593
+ <>
594
+ <div className="grid gap-3 md:grid-cols-2 xl:grid-cols-5">
595
+ <MetricCard
596
+ label="Estimated spend"
597
+ value={formatCost(metrics.totals.costCents)}
598
+ detail={`${formatTokens(totalTokens)} total tokens`}
599
+ icon={<IconCoin size={17} />}
600
+ />
601
+ <MetricCard
602
+ label="LLM calls"
603
+ value={formatNumber(metrics.totals.calls)}
604
+ detail={`${formatNumber(metrics.totals.chatCalls)} chat turns`}
605
+ icon={<IconActivity size={17} />}
606
+ />
607
+ <MetricCard
608
+ label="Active users"
609
+ value={formatNumber(metrics.totals.activeUsers)}
610
+ detail={`${formatNumber(metrics.access.totalUsers)} users with access`}
611
+ icon={<IconUsersGroup size={17} />}
612
+ />
613
+ <MetricCard
614
+ label="Workspace apps"
615
+ value={formatNumber(metrics.totals.workspaceApps)}
616
+ detail={`${formatNumber(metrics.byApp.length)} with usage`}
617
+ icon={<IconApps size={17} />}
618
+ />
619
+ <MetricCard
620
+ label="Chat threads"
621
+ value={formatNumber(metrics.totals.chatThreads)}
622
+ detail={`${formatNumber(metrics.totals.chatMessages)} messages`}
623
+ icon={<IconMessages size={17} />}
624
+ />
625
+ </div>
626
+
627
+ <div className="grid gap-4 xl:grid-cols-[minmax(0,1.35fr)_minmax(320px,0.65fr)]">
628
+ <Panel title="Spend By App" icon={<IconChartBar size={16} />}>
629
+ <AppSpendRows rows={metrics.byApp} />
630
+ </Panel>
631
+ <Panel title="Daily Activity" icon={<IconClockHour4 size={16} />}>
632
+ <DailyActivity rows={metrics.daily} />
633
+ </Panel>
634
+ </div>
635
+
636
+ <Panel title="Access By App" icon={<IconApps size={16} />}>
637
+ <AppAccessTable rows={metrics.appAccess} />
638
+ </Panel>
639
+
640
+ <Panel title="Users" icon={<IconUsersGroup size={16} />}>
641
+ <UserTable rows={metrics.byUser} />
642
+ </Panel>
643
+
644
+ <div className="grid gap-4 lg:grid-cols-2">
645
+ <Panel title="Models" icon={<IconChartBar size={16} />}>
646
+ <CompactBreakdown
647
+ rows={metrics.byModel}
648
+ empty="No model usage in this window."
649
+ />
650
+ </Panel>
651
+ <Panel title="Work Types" icon={<IconActivity size={16} />}>
652
+ <CompactBreakdown
653
+ rows={metrics.byLabel}
654
+ empty="No labeled usage in this window."
655
+ />
656
+ </Panel>
657
+ </div>
658
+
659
+ <Panel title="Recent LLM Calls" icon={<IconActivity size={16} />}>
660
+ <RecentTable rows={metrics.recent} />
661
+ </Panel>
662
+ </>
663
+ ) : null}
664
+ </div>
665
+ </DispatchShell>
666
+ );
667
+ }
@@ -2,9 +2,7 @@ import { useEffect, useMemo, useState } from "react";
2
2
  import { Link } from "react-router";
3
3
  import {
4
4
  PromptComposer,
5
- isInBuilderFrame,
6
5
  sendToAgentChat,
7
- sendToBuilderChat,
8
6
  useActionQuery,
9
7
  useChatModels,
10
8
  agentNativePath,
@@ -101,14 +99,6 @@ function HomeChatPanel() {
101
99
  const { selectedModel } = useChatModels();
102
100
 
103
101
  const send = (message: string) => {
104
- // When mounted inside builder.io's webview, the parent frame is the
105
- // code-writing agent. Route home-chat submissions up to Builder's chat
106
- // instead of the local dispatch agent — the local sidebar is in this
107
- // same iframe and would never receive a postMessage we send to parent.
108
- if (isInBuilderFrame()) {
109
- sendToBuilderChat({ message, submit: true });
110
- return;
111
- }
112
102
  sendToAgentChat({
113
103
  message,
114
104
  submit: true,