@agent-native/dispatch 0.23.3 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (139) hide show
  1. package/dist/actions/list-connected-agents.d.ts +1 -0
  2. package/dist/actions/list-connected-agents.js +5 -1
  3. package/dist/actions/list-connected-agents.js.map +1 -1
  4. package/dist/actions/list-dispatch-usage-metrics.d.ts +2 -0
  5. package/dist/actions/list-dispatch-usage-metrics.js +11 -1
  6. package/dist/actions/list-dispatch-usage-metrics.js.map +1 -1
  7. package/dist/actions/provider-api-register.d.ts +11 -11
  8. package/dist/actions/remix-workspace-template.js +4 -3
  9. package/dist/actions/remix-workspace-template.js.map +1 -1
  10. package/dist/actions/scaffold-workspace-app.js +3 -2
  11. package/dist/actions/scaffold-workspace-app.js.map +1 -1
  12. package/dist/actions/start-workspace-app-creation.js +3 -2
  13. package/dist/actions/start-workspace-app-creation.js.map +1 -1
  14. package/dist/actions/view-screen.js +11 -1
  15. package/dist/actions/view-screen.js.map +1 -1
  16. package/dist/components/app-icon.js +1 -1
  17. package/dist/components/app-icon.js.map +1 -1
  18. package/dist/components/app-list-row.d.ts +1 -1
  19. package/dist/components/app-list-row.d.ts.map +1 -1
  20. package/dist/components/app-list-row.js +2 -2
  21. package/dist/components/app-list-row.js.map +1 -1
  22. package/dist/components/connected-app-card.d.ts.map +1 -1
  23. package/dist/components/connected-app-card.js +2 -1
  24. package/dist/components/connected-app-card.js.map +1 -1
  25. package/dist/components/create-app-popover.d.ts.map +1 -1
  26. package/dist/components/create-app-popover.js +11 -0
  27. package/dist/components/create-app-popover.js.map +1 -1
  28. package/dist/components/dispatch-control-plane.d.ts.map +1 -1
  29. package/dist/components/dispatch-control-plane.js +3 -2
  30. package/dist/components/dispatch-control-plane.js.map +1 -1
  31. package/dist/components/layout/Layout.d.ts +1 -0
  32. package/dist/components/layout/Layout.d.ts.map +1 -1
  33. package/dist/components/layout/Layout.js +47 -10
  34. package/dist/components/layout/Layout.js.map +1 -1
  35. package/dist/components/layout/workspace-apps-rail.d.ts.map +1 -1
  36. package/dist/components/layout/workspace-apps-rail.js +2 -2
  37. package/dist/components/layout/workspace-apps-rail.js.map +1 -1
  38. package/dist/components/other-apps-section.d.ts.map +1 -1
  39. package/dist/components/other-apps-section.js +5 -1
  40. package/dist/components/other-apps-section.js.map +1 -1
  41. package/dist/components/workspace-template-card.d.ts.map +1 -1
  42. package/dist/components/workspace-template-card.js +8 -14
  43. package/dist/components/workspace-template-card.js.map +1 -1
  44. package/dist/config.d.ts +2 -0
  45. package/dist/config.d.ts.map +1 -1
  46. package/dist/config.js.map +1 -1
  47. package/dist/hooks/use-navigation-state.d.ts +2 -0
  48. package/dist/hooks/use-navigation-state.d.ts.map +1 -1
  49. package/dist/hooks/use-navigation-state.js +10 -0
  50. package/dist/hooks/use-navigation-state.js.map +1 -1
  51. package/dist/lib/embed-session-recovery.d.ts +3 -0
  52. package/dist/lib/embed-session-recovery.d.ts.map +1 -0
  53. package/dist/lib/embed-session-recovery.js +18 -0
  54. package/dist/lib/embed-session-recovery.js.map +1 -0
  55. package/dist/lib/other-apps.d.ts +2 -0
  56. package/dist/lib/other-apps.d.ts.map +1 -1
  57. package/dist/lib/other-apps.js +2 -0
  58. package/dist/lib/other-apps.js.map +1 -1
  59. package/dist/lib/workspace-apps.d.ts +7 -0
  60. package/dist/lib/workspace-apps.d.ts.map +1 -1
  61. package/dist/lib/workspace-apps.js +17 -0
  62. package/dist/lib/workspace-apps.js.map +1 -1
  63. package/dist/lib/workspace-apps.test.d.ts +2 -0
  64. package/dist/lib/workspace-apps.test.d.ts.map +1 -0
  65. package/dist/lib/workspace-apps.test.js +16 -0
  66. package/dist/lib/workspace-apps.test.js.map +1 -0
  67. package/dist/routes/pages/apps.$appId.d.ts.map +1 -1
  68. package/dist/routes/pages/apps.$appId.js +10 -11
  69. package/dist/routes/pages/apps.$appId.js.map +1 -1
  70. package/dist/routes/pages/apps.d.ts.map +1 -1
  71. package/dist/routes/pages/apps.js +2 -1
  72. package/dist/routes/pages/apps.js.map +1 -1
  73. package/dist/routes/pages/chat.d.ts.map +1 -1
  74. package/dist/routes/pages/chat.js +7 -2
  75. package/dist/routes/pages/chat.js.map +1 -1
  76. package/dist/routes/pages/metrics.d.ts.map +1 -1
  77. package/dist/routes/pages/metrics.js +143 -17
  78. package/dist/routes/pages/metrics.js.map +1 -1
  79. package/dist/routes/pages/settings.d.ts.map +1 -1
  80. package/dist/routes/pages/settings.js +13 -1
  81. package/dist/routes/pages/settings.js.map +1 -1
  82. package/dist/server/lib/usage-metrics-store.d.ts +17 -0
  83. package/dist/server/lib/usage-metrics-store.d.ts.map +1 -1
  84. package/dist/server/lib/usage-metrics-store.js +187 -30
  85. package/dist/server/lib/usage-metrics-store.js.map +1 -1
  86. package/dist/server/plugins/agent-chat.js +2 -0
  87. package/dist/server/plugins/agent-chat.js.map +1 -1
  88. package/dist/server/plugins/auth.d.ts.map +1 -1
  89. package/dist/server/plugins/auth.js +1 -0
  90. package/dist/server/plugins/auth.js.map +1 -1
  91. package/dist/server/plugins/integrations.d.ts.map +1 -1
  92. package/dist/server/plugins/integrations.js +1 -0
  93. package/dist/server/plugins/integrations.js.map +1 -1
  94. package/package.json +3 -3
  95. package/src/actions/list-connected-agents.spec.ts +73 -0
  96. package/src/actions/list-connected-agents.ts +6 -2
  97. package/src/actions/list-dispatch-usage-metrics.ts +11 -1
  98. package/src/actions/remix-workspace-template.spec.ts +29 -0
  99. package/src/actions/remix-workspace-template.ts +13 -6
  100. package/src/actions/scaffold-workspace-app.ts +12 -5
  101. package/src/actions/start-workspace-app-creation.ts +8 -2
  102. package/src/actions/view-screen.ts +13 -1
  103. package/src/components/app-icon.spec.tsx +34 -0
  104. package/src/components/app-icon.tsx +1 -1
  105. package/src/components/app-list-row.tsx +3 -2
  106. package/src/components/connected-app-card.tsx +3 -1
  107. package/src/components/create-app-popover.spec.tsx +26 -2
  108. package/src/components/create-app-popover.tsx +10 -0
  109. package/src/components/dispatch-control-plane.spec.tsx +9 -1
  110. package/src/components/dispatch-control-plane.tsx +8 -2
  111. package/src/components/layout/Layout.spec.tsx +12 -0
  112. package/src/components/layout/Layout.tsx +84 -13
  113. package/src/components/layout/workspace-apps-rail.tsx +2 -1
  114. package/src/components/other-apps-section.tsx +8 -1
  115. package/src/components/workspace-template-card.spec.tsx +1 -1
  116. package/src/components/workspace-template-card.tsx +9 -16
  117. package/src/config.ts +2 -0
  118. package/src/hooks/use-navigation-state.ts +12 -0
  119. package/src/lib/embed-session-recovery.spec.ts +63 -0
  120. package/src/lib/embed-session-recovery.ts +26 -0
  121. package/src/lib/other-apps.spec.ts +26 -0
  122. package/src/lib/other-apps.ts +5 -0
  123. package/src/lib/workspace-apps.spec.ts +24 -1
  124. package/src/lib/workspace-apps.test.ts +20 -0
  125. package/src/lib/workspace-apps.ts +25 -0
  126. package/src/routes/pages/apps.$appId.tsx +10 -11
  127. package/src/routes/pages/apps.tsx +5 -2
  128. package/src/routes/pages/chat.spec.tsx +10 -0
  129. package/src/routes/pages/chat.tsx +8 -0
  130. package/src/routes/pages/metrics.tsx +452 -102
  131. package/src/routes/pages/settings.tsx +13 -1
  132. package/src/server/lib/usage-metrics-store.spec.ts +117 -0
  133. package/src/server/lib/usage-metrics-store.ts +249 -30
  134. package/src/server/plugins/agent-chat.spec.ts +4 -0
  135. package/src/server/plugins/agent-chat.ts +2 -0
  136. package/src/server/plugins/auth.spec.ts +45 -0
  137. package/src/server/plugins/auth.ts +1 -0
  138. package/src/server/plugins/integrations.ts +1 -0
  139. package/src/styles/dispatch.css +9 -0
@@ -10,6 +10,7 @@ import {
10
10
  SettingsTabsPage,
11
11
  useAgentSettingsTabs,
12
12
  } from "@agent-native/core/client/settings";
13
+ import { IconShield } from "@tabler/icons-react";
13
14
  import { useState } from "react";
14
15
  import { Link } from "react-router";
15
16
 
@@ -33,6 +34,17 @@ export function meta() {
33
34
  export default function SettingsRoute() {
34
35
  const t = useT();
35
36
  const agentSettingsTabs = useAgentSettingsTabs();
37
+ const settingsTabs = [
38
+ ...agentSettingsTabs.filter((tab) => tab.id !== "integrations"),
39
+ {
40
+ id: "admin",
41
+ label: t("dispatch.nav.admin", { defaultValue: "Admin" }),
42
+ icon: IconShield,
43
+ group: "Admin",
44
+ href: "/admin",
45
+ content: null,
46
+ },
47
+ ];
36
48
  const [chatFirstModeState] = useState(() => readChatFirstModeState());
37
49
  const [chatFirstMode, setChatFirstMode] = useState(
38
50
  () => chatFirstModeState.enabled,
@@ -68,7 +80,7 @@ export default function SettingsRoute() {
68
80
  description={t("settings.description")}
69
81
  >
70
82
  <SettingsTabsPage
71
- extraTabs={agentSettingsTabs.filter((tab) => tab.id !== "integrations")}
83
+ extraTabs={settingsTabs}
72
84
  general={
73
85
  <div className="mx-auto w-full max-w-3xl space-y-6">
74
86
  <Card>
@@ -120,4 +120,121 @@ describe("listDispatchUsageMetrics", () => {
120
120
  expect(metrics.recent).toEqual([]);
121
121
  expect(metrics.appAccess).toHaveLength(1);
122
122
  });
123
+
124
+ it("allows personal scope and hydrates a linked thread prompt", async () => {
125
+ mocks.currentOrgId.mockReturnValue("org-a");
126
+ mocks.currentOwnerEmail.mockReturnValue("owner@example.test");
127
+ mocks.getUsageSummary.mockResolvedValue(null);
128
+ mocks.listWorkspaceApps.mockResolvedValue([]);
129
+ mocks.execute.mockImplementation(async ({ sql }: { sql: string }) => {
130
+ if (sql.includes("SELECT id, created_at")) {
131
+ return {
132
+ rows: [
133
+ {
134
+ id: 7,
135
+ created_at: Date.now(),
136
+ owner_email: "owner@example.test",
137
+ app: "dispatch",
138
+ label: "chat",
139
+ model: "test-model",
140
+ input_tokens: 10,
141
+ output_tokens: 20,
142
+ cache_read_tokens: 0,
143
+ cache_write_tokens: 0,
144
+ cost_cents_x100: 250,
145
+ thread_id: "thread-1",
146
+ run_id: "run-1",
147
+ task_id: null,
148
+ source_platform: "dispatch",
149
+ source_id: "dispatch",
150
+ },
151
+ ],
152
+ };
153
+ }
154
+ if (sql.includes("FROM chat_threads WHERE id IN")) {
155
+ return {
156
+ rows: [
157
+ {
158
+ id: "thread-1",
159
+ preview: "Fallback preview",
160
+ thread_data: JSON.stringify({
161
+ messages: [
162
+ {
163
+ message: {
164
+ role: "user",
165
+ content: "Find the repeated background work.",
166
+ },
167
+ },
168
+ ],
169
+ }),
170
+ },
171
+ ],
172
+ };
173
+ }
174
+ return { rows: [] };
175
+ });
176
+
177
+ const metrics = await listDispatchUsageMetrics({
178
+ sinceDays: 7,
179
+ scope: "me",
180
+ });
181
+
182
+ expect(metrics.viewScope).toBe("me");
183
+ expect(metrics.recent[0]).toMatchObject({
184
+ prompt: "Find the repeated background work.",
185
+ promptSource: "thread",
186
+ threadId: "thread-1",
187
+ runId: "run-1",
188
+ });
189
+ expect(
190
+ mocks.execute.mock.calls.some(([query]) =>
191
+ String((query as { sql?: string }).sql).includes(
192
+ "LOWER(owner_email) = ?",
193
+ ),
194
+ ),
195
+ ).toBe(true);
196
+ });
197
+
198
+ it("filters workspace usage and prompts to a selected member", async () => {
199
+ mocks.currentOrgId.mockReturnValue("org-a");
200
+ mocks.currentOwnerEmail.mockReturnValue("owner@example.test");
201
+ mocks.getUsageSummary.mockResolvedValue(null);
202
+ mocks.listWorkspaceApps.mockResolvedValue([]);
203
+ mocks.execute.mockImplementation(async ({ sql }: { sql: string }) => {
204
+ if (sql.includes("SELECT role FROM org_members")) {
205
+ return { rows: [{ role: "owner" }] };
206
+ }
207
+ if (sql.includes("SELECT email, role, joined_at")) {
208
+ return {
209
+ rows: [
210
+ { email: "owner@example.test", role: "owner", joined_at: null },
211
+ { email: "member@example.test", role: "member", joined_at: null },
212
+ ],
213
+ };
214
+ }
215
+ return { rows: [] };
216
+ });
217
+
218
+ const metrics = await listDispatchUsageMetrics({
219
+ sinceDays: 30,
220
+ scope: "workspace",
221
+ userEmail: "member@example.test",
222
+ });
223
+
224
+ expect(metrics.selectedUserEmail).toBe("member@example.test");
225
+ expect(metrics.availableUsers).toEqual([
226
+ { email: "member@example.test", role: "member" },
227
+ { email: "owner@example.test", role: "owner" },
228
+ ]);
229
+ expect(
230
+ mocks.execute.mock.calls.some(([query]) => {
231
+ const sql = String((query as { sql?: string }).sql);
232
+ const args = (query as { args?: unknown[] }).args ?? [];
233
+ return (
234
+ sql.includes("LOWER(owner_email) = ?") &&
235
+ args.includes("member@example.test")
236
+ );
237
+ }),
238
+ ).toBe(true);
239
+ });
123
240
  });
@@ -85,10 +85,27 @@ export interface RecentUsageMetric {
85
85
  cacheReadTokens: number;
86
86
  cacheWriteTokens: number;
87
87
  costCents: number;
88
+ prompt: string | null;
89
+ promptSource: "thread" | "thread-preview" | "not-captured" | "unavailable";
90
+ threadId: string | null;
91
+ runId: string | null;
92
+ taskId: string | null;
93
+ sourcePlatform: string | null;
94
+ sourceId: string | null;
95
+ }
96
+
97
+ export type UsageMetricsScope = "me" | "workspace";
98
+
99
+ export interface UsageUserOption {
100
+ email: string;
101
+ role: string | null;
88
102
  }
89
103
 
90
104
  export interface DispatchUsageMetrics {
91
105
  billing: UsageBillingMode;
106
+ viewScope: UsageMetricsScope;
107
+ selectedUserEmail: string | null;
108
+ availableUsers: UsageUserOption[];
92
109
  sinceMs: number;
93
110
  sinceDays: number;
94
111
  generatedAt: number;
@@ -151,6 +168,148 @@ function nullableNumberField(
151
168
  return Number.isFinite(numberValue) ? numberValue : null;
152
169
  }
153
170
 
171
+ function nullableStringField(
172
+ row: Record<string, unknown>,
173
+ key: string,
174
+ ): string | null {
175
+ const value = row[key];
176
+ if (typeof value !== "string") return null;
177
+ const trimmed = value.trim();
178
+ return trimmed || null;
179
+ }
180
+
181
+ type ParsedThreadData =
182
+ | { status: "absent"; value: null }
183
+ | { status: "invalid"; value: null }
184
+ | { status: "parsed"; value: Record<string, unknown> };
185
+
186
+ function parseJson(value: unknown): ParsedThreadData {
187
+ if (typeof value !== "string" || !value.trim()) {
188
+ return { status: "absent", value: null };
189
+ }
190
+ try {
191
+ const parsed = JSON.parse(value);
192
+ return parsed && typeof parsed === "object"
193
+ ? { status: "parsed", value: parsed as Record<string, unknown> }
194
+ : { status: "invalid", value: null };
195
+ } catch {
196
+ return { status: "invalid", value: null };
197
+ }
198
+ }
199
+
200
+ function textFromPromptContent(value: unknown): string {
201
+ if (typeof value === "string") return value.trim();
202
+ if (!Array.isArray(value)) return "";
203
+ return value
204
+ .map((part) => {
205
+ if (!part || typeof part !== "object") return "";
206
+ const item = part as Record<string, unknown>;
207
+ return item.type === "text" && typeof item.text === "string"
208
+ ? item.text.trim()
209
+ : "";
210
+ })
211
+ .filter(Boolean)
212
+ .join("\n")
213
+ .trim();
214
+ }
215
+
216
+ function truncatePrompt(value: string, maxLength = 360): string {
217
+ if (value.length <= maxLength) return value;
218
+ return `${value.slice(0, maxLength - 1).trimEnd()}…`;
219
+ }
220
+
221
+ function firstUserPrompt(threadData: unknown): string | null {
222
+ const parsed = parseJson(threadData);
223
+ if (parsed.status !== "parsed") return null;
224
+ const messages = parsed.value.messages;
225
+ if (!Array.isArray(messages)) return null;
226
+
227
+ for (const entry of messages) {
228
+ if (!entry || typeof entry !== "object") continue;
229
+ const record = entry as Record<string, unknown>;
230
+ const message =
231
+ record.message && typeof record.message === "object"
232
+ ? (record.message as Record<string, unknown>)
233
+ : record;
234
+ const role = typeof message.role === "string" ? message.role : "";
235
+ if (role !== "user" && role !== "human") continue;
236
+ const text = textFromPromptContent(message.content);
237
+ if (text) return truncatePrompt(text);
238
+ }
239
+ return null;
240
+ }
241
+
242
+ interface ThreadPromptRow {
243
+ id?: unknown;
244
+ preview?: unknown;
245
+ thread_data?: unknown;
246
+ }
247
+
248
+ async function hydrateRecentPrompts(
249
+ rows: Array<Record<string, unknown>>,
250
+ ): Promise<RecentUsageMetric[]> {
251
+ const threadIds = [
252
+ ...new Set(
253
+ rows
254
+ .map((row) => nullableStringField(row, "thread_id"))
255
+ .filter((value): value is string => Boolean(value)),
256
+ ),
257
+ ];
258
+ const threads = new Map<string, ThreadPromptRow>();
259
+ let threadQueryUnavailable = false;
260
+
261
+ if (threadIds.length > 0) {
262
+ try {
263
+ const result = await getDbExec().execute({
264
+ sql: `SELECT id, preview, thread_data FROM chat_threads WHERE id IN (${threadIds.map(() => "?").join(", ")})`,
265
+ args: threadIds,
266
+ });
267
+ for (const row of result.rows as ThreadPromptRow[]) {
268
+ const id = typeof row.id === "string" ? row.id : "";
269
+ if (id) threads.set(id, row);
270
+ }
271
+ } catch {
272
+ threadQueryUnavailable = true;
273
+ }
274
+ }
275
+
276
+ return rows.map((row) => {
277
+ const threadId = nullableStringField(row, "thread_id");
278
+ const thread = threadId ? threads.get(threadId) : undefined;
279
+ const prompt = thread ? firstUserPrompt(thread.thread_data) : null;
280
+ const preview =
281
+ typeof thread?.preview === "string" ? thread.preview.trim() : "";
282
+ const promptSource = prompt
283
+ ? "thread"
284
+ : preview
285
+ ? "thread-preview"
286
+ : threadQueryUnavailable && threadId
287
+ ? "unavailable"
288
+ : "not-captured";
289
+
290
+ return {
291
+ id: numberField(row, "id"),
292
+ createdAt: numberField(row, "created_at"),
293
+ ownerEmail: stringField(row, "owner_email"),
294
+ app: stringField(row, "app") || "unattributed",
295
+ label: stringField(row, "label") || "chat",
296
+ model: stringField(row, "model") || "unknown",
297
+ inputTokens: numberField(row, "input_tokens"),
298
+ outputTokens: numberField(row, "output_tokens"),
299
+ cacheReadTokens: numberField(row, "cache_read_tokens"),
300
+ cacheWriteTokens: numberField(row, "cache_write_tokens"),
301
+ costCents: numberField(row, "cost_cents_x100") / 100,
302
+ prompt: prompt ?? (preview ? truncatePrompt(preview) : null),
303
+ promptSource,
304
+ threadId,
305
+ runId: nullableStringField(row, "run_id"),
306
+ taskId: nullableStringField(row, "task_id"),
307
+ sourcePlatform: nullableStringField(row, "source_platform"),
308
+ sourceId: nullableStringField(row, "source_id"),
309
+ } satisfies RecentUsageMetric;
310
+ });
311
+ }
312
+
154
313
  function labelForKey(value: string): string {
155
314
  const trimmed = value.trim();
156
315
  return trimmed || "Unattributed";
@@ -293,8 +452,8 @@ function usageScope(
293
452
  }
294
453
  const placeholders = memberEmails.map(() => "?").join(", ");
295
454
  return {
296
- where: `created_at >= ? AND owner_email IN (${placeholders})`,
297
- args: [sinceMs, ...memberEmails],
455
+ where: `created_at >= ? AND LOWER(owner_email) IN (${placeholders})`,
456
+ args: [sinceMs, ...memberEmails.map((email) => email.toLowerCase())],
298
457
  };
299
458
  }
300
459
 
@@ -307,8 +466,34 @@ function threadScope(
307
466
  }
308
467
  const placeholders = memberEmails.map(() => "?").join(", ");
309
468
  return {
310
- where: `updated_at >= ? AND owner_email IN (${placeholders})`,
311
- args: [sinceMs, ...memberEmails],
469
+ where: `updated_at >= ? AND LOWER(owner_email) IN (${placeholders})`,
470
+ args: [sinceMs, ...memberEmails.map((email) => email.toLowerCase())],
471
+ };
472
+ }
473
+
474
+ function ownerScope(
475
+ sinceMs: number,
476
+ ownerEmail: string,
477
+ ): {
478
+ where: string;
479
+ args: unknown[];
480
+ } {
481
+ return {
482
+ where: "created_at >= ? AND LOWER(owner_email) = ?",
483
+ args: [sinceMs, ownerEmail.toLowerCase()],
484
+ };
485
+ }
486
+
487
+ function ownerThreadScope(
488
+ sinceMs: number,
489
+ ownerEmail: string,
490
+ ): {
491
+ where: string;
492
+ args: unknown[];
493
+ } {
494
+ return {
495
+ where: "updated_at >= ? AND LOWER(owner_email) = ?",
496
+ args: [sinceMs, ownerEmail.toLowerCase()],
312
497
  };
313
498
  }
314
499
 
@@ -382,7 +567,7 @@ async function loadChatStats(
382
567
  );
383
568
  }
384
569
 
385
- async function assertCanViewMetrics(): Promise<{
570
+ async function assertCanViewMetrics(viewScope: UsageMetricsScope): Promise<{
386
571
  viewerEmail: string;
387
572
  orgId: string | null;
388
573
  role: string | null;
@@ -390,7 +575,12 @@ async function assertCanViewMetrics(): Promise<{
390
575
  const viewerEmail = currentOwnerEmail();
391
576
  const orgId = currentOrgId();
392
577
  const role = await getViewerOrgRole(orgId, viewerEmail);
393
- if (isEnvAdmin(viewerEmail) || role === "owner" || role === "admin") {
578
+ if (
579
+ viewScope === "me" ||
580
+ isEnvAdmin(viewerEmail) ||
581
+ role === "owner" ||
582
+ role === "admin"
583
+ ) {
394
584
  return { viewerEmail, orgId, role };
395
585
  }
396
586
  if (!orgId) {
@@ -403,27 +593,57 @@ async function assertCanViewMetrics(): Promise<{
403
593
 
404
594
  export async function listDispatchUsageMetrics(input: {
405
595
  sinceDays?: number;
596
+ scope?: UsageMetricsScope;
597
+ userEmail?: string | null;
406
598
  }): Promise<DispatchUsageMetrics> {
407
- const { viewerEmail, orgId, role } = await assertCanViewMetrics();
599
+ const viewScope: UsageMetricsScope =
600
+ input.scope === "me" ? "me" : "workspace";
601
+ const { viewerEmail, orgId, role } = await assertCanViewMetrics(viewScope);
408
602
  const sinceDays = Math.max(1, Math.min(365, input.sinceDays ?? 30));
409
603
  const sinceMs = Date.now() - sinceDays * DAY_MS;
410
604
  const billing = usageBillingForEngine(await detectUsageEngineName());
411
605
 
412
606
  await initializeUsageMetricsTable(sinceMs);
413
607
 
414
- const rawMembers = orgId
415
- ? await listOrgMembers(orgId)
416
- : await listSignedInUsers();
608
+ const rawMembers =
609
+ viewScope === "me"
610
+ ? [{ email: viewerEmail, role, joinedAt: null }]
611
+ : orgId
612
+ ? await listOrgMembers(orgId)
613
+ : await listSignedInUsers();
417
614
  const members =
418
- orgId && rawMembers.length === 0
615
+ viewScope === "workspace" && orgId && rawMembers.length === 0
419
616
  ? [{ email: viewerEmail, role, joinedAt: null }]
420
617
  : rawMembers;
421
- const memberEmails = orgId ? members.map((member) => member.email) : [];
618
+ const requestedUserEmail = input.userEmail?.trim() || null;
619
+ const selectedUserEmail =
620
+ viewScope === "me"
621
+ ? viewerEmail
622
+ : requestedUserEmail
623
+ ? (members.find(
624
+ (member) =>
625
+ member.email.toLowerCase() === requestedUserEmail.toLowerCase(),
626
+ )?.email ?? null)
627
+ : null;
628
+ if (viewScope === "workspace" && requestedUserEmail && !selectedUserEmail) {
629
+ throw new ForbiddenError(
630
+ "The selected user is not available in this workspace.",
631
+ );
632
+ }
633
+ const memberEmails = selectedUserEmail
634
+ ? [selectedUserEmail]
635
+ : orgId
636
+ ? members.map((member) => member.email)
637
+ : [];
422
638
  const memberByEmail = new Map(
423
639
  members.map((member) => [member.email.toLowerCase(), member]),
424
640
  );
425
- const usage = usageScope(sinceMs, memberEmails);
426
- const threads = threadScope(sinceMs, memberEmails);
641
+ const usage = selectedUserEmail
642
+ ? ownerScope(sinceMs, selectedUserEmail)
643
+ : usageScope(sinceMs, memberEmails);
644
+ const threads = selectedUserEmail
645
+ ? ownerThreadScope(sinceMs, selectedUserEmail)
646
+ : threadScope(sinceMs, memberEmails);
427
647
 
428
648
  const [apps, totalsRows, byApp, byUserBase, byLabel, byModel, chatStats] =
429
649
  await Promise.all([
@@ -565,33 +785,27 @@ export async function listDispatchUsageMetrics(input: {
565
785
  const recentRows = await queryRows<Record<string, unknown>>(
566
786
  `SELECT id, created_at, owner_email, app, label, model,
567
787
  input_tokens, output_tokens, cache_read_tokens, cache_write_tokens,
568
- cost_cents_x100
788
+ cost_cents_x100, thread_id, run_id, task_id, source_platform, source_id
569
789
  FROM token_usage
570
790
  WHERE ${usage.where}
571
791
  ORDER BY created_at DESC
572
792
  LIMIT 50`,
573
793
  usage.args,
574
794
  );
575
- const recent = recentRows.map((row) => ({
576
- id: numberField(row, "id"),
577
- createdAt: numberField(row, "created_at"),
578
- ownerEmail: stringField(row, "owner_email"),
579
- app: stringField(row, "app") || "unattributed",
580
- label: stringField(row, "label") || "chat",
581
- model: stringField(row, "model") || "unknown",
582
- inputTokens: numberField(row, "input_tokens"),
583
- outputTokens: numberField(row, "output_tokens"),
584
- cacheReadTokens: numberField(row, "cache_read_tokens"),
585
- cacheWriteTokens: numberField(row, "cache_write_tokens"),
586
- costCents: numberField(row, "cost_cents_x100") / 100,
587
- }));
795
+ const recent = await hydrateRecentPrompts(recentRows);
588
796
 
589
797
  const appUsageByKey = new Map(
590
798
  byApp.map((bucket) => [normalizeAppKey(bucket.key), bucket]),
591
799
  );
592
800
  const accessUsers = members.length || byUserMap.size;
593
- const accessModel = orgId ? "workspace" : "solo";
594
- const accessLabel = orgId ? "Workspace members" : "Signed-in users";
801
+ const accessModel =
802
+ viewScope === "me" ? "solo" : orgId ? "workspace" : "solo";
803
+ const accessLabel =
804
+ viewScope === "me"
805
+ ? "Your account"
806
+ : orgId
807
+ ? "Workspace members"
808
+ : "Signed-in users";
595
809
  const appAccess = apps.map((app) => {
596
810
  const usageBucket = appUsageByKey.get(normalizeAppKey(app.id));
597
811
  return {
@@ -623,6 +837,11 @@ export async function listDispatchUsageMetrics(input: {
623
837
 
624
838
  return {
625
839
  billing,
840
+ viewScope,
841
+ selectedUserEmail,
842
+ availableUsers: members
843
+ .map(({ email, role }) => ({ email, role }))
844
+ .sort((a, b) => a.email.localeCompare(b.email)),
626
845
  sinceMs,
627
846
  sinceDays,
628
847
  generatedAt: Date.now(),
@@ -24,6 +24,10 @@ describe("Dispatch agent chat plugin", () => {
24
24
  expect.objectContaining({
25
25
  appId: "dispatch",
26
26
  durableBackgroundRuns: true,
27
+ initialToolNames: expect.arrayContaining([
28
+ "list-workspace-apps",
29
+ "update-workspace-app-metadata",
30
+ ]),
27
31
  mcp: {
28
32
  connectorCatalog: expect.arrayContaining([
29
33
  "start-workspace-app-creation",
@@ -6,6 +6,7 @@ import { dispatchActions } from "../../actions/index.js";
6
6
  const INITIAL_TOOL_NAMES = [
7
7
  "view-screen",
8
8
  "list-workspace-apps",
9
+ "update-workspace-app-metadata",
9
10
  "list-connected-agents",
10
11
  "ask_app",
11
12
  "open_app",
@@ -65,6 +66,7 @@ Use the standard workspace primitives:
65
66
  - Hosted/connected A2A neighbors such as Analytics and Content come from the available-apps context or list-connected-agents. list-workspace-apps only inventories apps mounted inside this workspace deployment; never use a missing row there to conclude that a connected agent is unavailable.
66
67
  - When answering whether a mounted workspace app exposes an agent card or A2A endpoint, call list-workspace-apps with includeAgentCards=true. If you have not requested that probe, absence of agent-card fields means unchecked, not unavailable.
67
68
  - When creating a new workspace app, create a separate app under apps/<app-id> with apps/<app-id>/package.json including a concise generated description, mount it at /<app-id>, use relative /<app-id> links, never hardcode localhost or dev ports, use shadcn/ui with @tabler/icons-react rather than lucide-react, and ensure the React Router client entry preserves APP_BASE_PATH/VITE_APP_BASE_PATH via appBasePath(). There is no separate workspace app registry to edit.
69
+ - When a user asks to rename an existing workspace app or change its Dispatch title/description, call update-workspace-app-metadata with the existing appId from list-workspace-apps. This is a metadata-only edit — never call start-workspace-app-creation, which creates a new app and Builder branch.
68
70
  - When an explicit app-creation request already includes a source brief or a concrete repeatable workflow, call start-workspace-app-creation without asking non-blocking product or UX questions. Choose recommended defaults, let the Builder handoff record assumptions, and ask only for authorization, credentials, a destructive action, an ambiguous target workspace, or a genuinely missing workflow.
69
71
  - If the chat template is used, treat it as scaffolding only: the finished app must be branded as the requested app with its own home screen/navigation/package metadata/manifest, and must not leave visible "Chat", "Starter", "Blank app", or "New app" UI behind.
70
72
  - Treat first-party apps such as Mail, Calendar, Analytics, Brain, Assets, and Dispatch as existing hosted/connected neighbors available through links and A2A/default connected agents. Do not create wrapper apps, child apps, nested routes, or cloned template copies just to give a new app access to them; build only the genuinely new workflow and delegate cross-app work to those existing apps.
@@ -0,0 +1,45 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ const mocks = vi.hoisted(() => ({
4
+ authPlugin: vi.fn(),
5
+ createAuthPlugin: vi.fn(),
6
+ getDispatchConfig: vi.fn(),
7
+ }));
8
+
9
+ vi.mock("@agent-native/core/server", () => ({
10
+ createAuthPlugin: mocks.createAuthPlugin,
11
+ }));
12
+
13
+ vi.mock("../index.js", () => ({
14
+ getDispatchConfig: mocks.getDispatchConfig,
15
+ }));
16
+
17
+ describe("dispatchAuthPlugin", () => {
18
+ beforeEach(() => {
19
+ vi.resetModules();
20
+ mocks.authPlugin.mockReset();
21
+ mocks.createAuthPlugin.mockReset();
22
+ mocks.createAuthPlugin.mockReturnValue(mocks.authPlugin);
23
+ mocks.getDispatchConfig.mockReset();
24
+ });
25
+
26
+ it("installs template public routes on the primary auth guard", async () => {
27
+ const { default: dispatchAuthPlugin } = await import("./auth.js");
28
+ const nitroApp = {};
29
+ const publicPaths = [
30
+ "/_agent-native/identity/authorize",
31
+ "/_agent-native/org/apps",
32
+ ];
33
+
34
+ mocks.getDispatchConfig.mockReturnValue({
35
+ auth: { googleOnly: true, publicPaths },
36
+ });
37
+ await dispatchAuthPlugin(nitroApp);
38
+
39
+ expect(mocks.createAuthPlugin).toHaveBeenCalledOnce();
40
+ expect(mocks.createAuthPlugin).toHaveBeenCalledWith(
41
+ expect.objectContaining({ googleOnly: true, publicPaths }),
42
+ );
43
+ expect(mocks.authPlugin).toHaveBeenCalledWith(nitroApp);
44
+ });
45
+ });
@@ -28,6 +28,7 @@ const dispatchAuthPlugin = async (nitroApp: any) => {
28
28
  const plugin = createAuthPlugin({
29
29
  googleOnly,
30
30
  marketing: marketing as any,
31
+ publicPaths: authConfig.publicPaths,
31
32
  });
32
33
  return plugin(nitroApp);
33
34
  };
@@ -40,6 +40,7 @@ When a user asks for something:
40
40
  - After call-agent returns an answer, RELAY IT DIRECTLY to the user with at most a one-line preface — do not rephrase, summarize, or add commentary. The downstream agent already crafted the answer; your job is delivery, not editing. This minimizes round-trips and keeps the user-visible reply fast.
41
41
  - Exception: if the downstream agent reports a missing model/provider credential, do not name exact env vars, Vault keys, tokens, or secrets. Say the target app needs an LLM connection and recommend connecting Builder/managed LLM for that app; keep bring-your-own provider keys as a secondary option only if the user asks.
42
42
  - If the user asks to create, build, make, scaffold, or generate an "agent" from Dispatch chat or by tagging @agent-native in Slack, email, or Telegram, first classify the ask. If it is a simple Dispatch-native behavior like a reminder, digest, monitor, routing rule, saved instruction, or recurring workflow, create or update the recurring job/resource/destination in Dispatch. If it is a robust unique product or teammate that needs its own UI, data model, actions, integrations, or domain workflow, treat it as a new workspace app and call start-workspace-app-creation.
43
+ - If a user asks to rename an existing workspace app or change its Dispatch title/description, call update-workspace-app-metadata with the existing appId from list-workspace-apps. Do not call start-workspace-app-creation for a metadata-only edit because that starts a new app and Builder branch.
43
44
  - If a new-app prompt asks for access to Mail, Calendar, Analytics, Brain, Assets, or similar first-party app data/agents, keep using the existing hosted/connected app and A2A path. Do not ask Builder to scaffold those apps as children of the new app unless the user explicitly asks for a customized app from that template.
44
45
  - If the chat template is used, treat it as scaffolding only: the finished app must be branded as the requested app with its own home screen/navigation/package metadata/manifest, and must not leave visible "Chat", "Starter", "Blank app", or "New app" UI behind.
45
46
  - If the user explicitly asks for a new app or workspace app, call start-workspace-app-creation with their prompt and include a concise generated description by default. Do not satisfy a new-app request by adding a route, page, component, or file inside apps/chat or another existing app unless the user explicitly asks to modify that existing app. When the prompt contains a source brief or concrete repeatable workflow, choose recommended defaults and do not ask non-blocking product or UX questions; ask only for authorization, credentials, a destructive action, an ambiguous target workspace, or a genuinely missing workflow. If the request is too vague to classify, ask one concise follow-up. If the action returns mode "builder", reply with the Builder branch URL; Builder is responsible for creating the separate workspace app under apps/<app-id>, mounting it at /<app-id>, ensuring apps/<app-id>/package.json exists with name/displayName and description so Dispatch discovers it, using relative /<app-id> links instead of hardcoded localhost/dev ports, and preserving APP_BASE_PATH/VITE_APP_BASE_PATH in the React Router client entry. The new app lives at the workspace root /<app-id>, NOT under /dispatch/<app-id>, /apps/<app-id>, or any other Dispatch tab — when telling the user where to find it, link to /<app-id> only. There is no separate workspace app registry to edit. If it returns mode "local-agent", tell the user it is ready for the local code agent and include the returned app path/prompt summary. Dispatch reuses the connected workspace Builder project or provisions it through the Builder Projects API when an organization owner/admin starts the first app; members need an owner/admin to configure the project first. Do not ask the user to use Builder org/beta settings. If it returns mode "builder-unavailable", the action also returns a \`reason\` code plus a user-facing \`message\` and (for some reasons) an operator-facing \`detail\`. Relay the \`message\` to the user as-is; only surface \`detail\` when the user is clearly an operator debugging the deployment (e.g. troubleshooting a broken Builder connection for the workspace), never as routine detail for an end user.
@@ -9,6 +9,15 @@
9
9
  @source "../routes/**/*.{js,mjs,ts,tsx}";
10
10
  @source "../../../core/src/client/chat-first/**/*.{ts,tsx}";
11
11
 
12
+ /* Keep Dispatch data visualizations aligned with Analytics' blue chart accent. */
13
+ :root {
14
+ --dispatch-brand-blue: 200 97% 39%;
15
+ }
16
+
17
+ .dark {
18
+ --dispatch-brand-blue: 197 100% 50%;
19
+ }
20
+
12
21
  .dispatch-chat-first-chats .an-chat-history-rail__new-chat {
13
22
  display: none;
14
23
  }