@agent-native/dispatch 0.23.5 → 0.24.2

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 (224) 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 +12 -12
  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/admin-navigation.d.ts.map +1 -1
  17. package/dist/components/admin-navigation.js +21 -5
  18. package/dist/components/admin-navigation.js.map +1 -1
  19. package/dist/components/app-icon.js +1 -1
  20. package/dist/components/app-icon.js.map +1 -1
  21. package/dist/components/app-list-row.d.ts +1 -1
  22. package/dist/components/app-list-row.d.ts.map +1 -1
  23. package/dist/components/app-list-row.js +1 -1
  24. package/dist/components/app-list-row.js.map +1 -1
  25. package/dist/components/app-open-actions.d.ts +1 -17
  26. package/dist/components/app-open-actions.d.ts.map +1 -1
  27. package/dist/components/app-open-actions.js +1 -20
  28. package/dist/components/app-open-actions.js.map +1 -1
  29. package/dist/components/app-row-settings.d.ts +6 -0
  30. package/dist/components/app-row-settings.d.ts.map +1 -0
  31. package/dist/components/app-row-settings.js +11 -0
  32. package/dist/components/app-row-settings.js.map +1 -0
  33. package/dist/components/automation-run-history-dialog.d.ts.map +1 -1
  34. package/dist/components/automation-run-history-dialog.js +3 -2
  35. package/dist/components/automation-run-history-dialog.js.map +1 -1
  36. package/dist/components/connected-app-card.d.ts.map +1 -1
  37. package/dist/components/connected-app-card.js +3 -2
  38. package/dist/components/connected-app-card.js.map +1 -1
  39. package/dist/components/create-app-popover.js +2 -2
  40. package/dist/components/create-app-popover.js.map +1 -1
  41. package/dist/components/dispatch-control-plane.d.ts.map +1 -1
  42. package/dist/components/dispatch-control-plane.js +23 -7
  43. package/dist/components/dispatch-control-plane.js.map +1 -1
  44. package/dist/components/dispatch-shell.d.ts.map +1 -1
  45. package/dist/components/dispatch-shell.js +5 -1
  46. package/dist/components/dispatch-shell.js.map +1 -1
  47. package/dist/components/docs-link.d.ts +16 -0
  48. package/dist/components/docs-link.d.ts.map +1 -0
  49. package/dist/components/docs-link.js +34 -0
  50. package/dist/components/docs-link.js.map +1 -0
  51. package/dist/components/layout/Layout.d.ts +1 -0
  52. package/dist/components/layout/Layout.d.ts.map +1 -1
  53. package/dist/components/layout/Layout.js +43 -35
  54. package/dist/components/layout/Layout.js.map +1 -1
  55. package/dist/components/messaging-setup-panel.d.ts.map +1 -1
  56. package/dist/components/messaging-setup-panel.js +3 -2
  57. package/dist/components/messaging-setup-panel.js.map +1 -1
  58. package/dist/components/usage-alerts-panel.d.ts +11 -0
  59. package/dist/components/usage-alerts-panel.d.ts.map +1 -0
  60. package/dist/components/usage-alerts-panel.js +125 -0
  61. package/dist/components/usage-alerts-panel.js.map +1 -0
  62. package/dist/components/workspace-app-card.d.ts +3 -1
  63. package/dist/components/workspace-app-card.d.ts.map +1 -1
  64. package/dist/components/workspace-app-card.js +70 -29
  65. package/dist/components/workspace-app-card.js.map +1 -1
  66. package/dist/components/workspace-app-search.d.ts +10 -0
  67. package/dist/components/workspace-app-search.d.ts.map +1 -0
  68. package/dist/components/workspace-app-search.js +27 -0
  69. package/dist/components/workspace-app-search.js.map +1 -0
  70. package/dist/components/workspace-resource-effective-stack.d.ts.map +1 -1
  71. package/dist/components/workspace-resource-effective-stack.js +3 -2
  72. package/dist/components/workspace-resource-effective-stack.js.map +1 -1
  73. package/dist/components/workspace-template-card.d.ts.map +1 -1
  74. package/dist/components/workspace-template-card.js +10 -15
  75. package/dist/components/workspace-template-card.js.map +1 -1
  76. package/dist/hooks/use-navigation-state.d.ts +2 -0
  77. package/dist/hooks/use-navigation-state.d.ts.map +1 -1
  78. package/dist/hooks/use-navigation-state.js +10 -0
  79. package/dist/hooks/use-navigation-state.js.map +1 -1
  80. package/dist/lib/embed-session-recovery.d.ts +3 -0
  81. package/dist/lib/embed-session-recovery.d.ts.map +1 -0
  82. package/dist/lib/embed-session-recovery.js +18 -0
  83. package/dist/lib/embed-session-recovery.js.map +1 -0
  84. package/dist/lib/other-apps.d.ts +2 -0
  85. package/dist/lib/other-apps.d.ts.map +1 -1
  86. package/dist/lib/other-apps.js.map +1 -1
  87. package/dist/lib/workspace-app-layout.d.ts +19 -0
  88. package/dist/lib/workspace-app-layout.d.ts.map +1 -0
  89. package/dist/lib/workspace-app-layout.js +75 -0
  90. package/dist/lib/workspace-app-layout.js.map +1 -0
  91. package/dist/lib/workspace-apps.d.ts +14 -0
  92. package/dist/lib/workspace-apps.d.ts.map +1 -1
  93. package/dist/lib/workspace-apps.js +66 -0
  94. package/dist/lib/workspace-apps.js.map +1 -1
  95. package/dist/lib/workspace-apps.test.d.ts +2 -0
  96. package/dist/lib/workspace-apps.test.d.ts.map +1 -0
  97. package/dist/lib/workspace-apps.test.js +33 -0
  98. package/dist/lib/workspace-apps.test.js.map +1 -0
  99. package/dist/routes/pages/$appId.d.ts.map +1 -1
  100. package/dist/routes/pages/$appId.js +4 -2
  101. package/dist/routes/pages/$appId.js.map +1 -1
  102. package/dist/routes/pages/agents.d.ts.map +1 -1
  103. package/dist/routes/pages/agents.js +2 -3
  104. package/dist/routes/pages/agents.js.map +1 -1
  105. package/dist/routes/pages/apps.$appId.d.ts.map +1 -1
  106. package/dist/routes/pages/apps.$appId.js +17 -13
  107. package/dist/routes/pages/apps.$appId.js.map +1 -1
  108. package/dist/routes/pages/apps.d.ts.map +1 -1
  109. package/dist/routes/pages/apps.js +31 -16
  110. package/dist/routes/pages/apps.js.map +1 -1
  111. package/dist/routes/pages/audit.d.ts.map +1 -1
  112. package/dist/routes/pages/audit.js +2 -1
  113. package/dist/routes/pages/audit.js.map +1 -1
  114. package/dist/routes/pages/chat.d.ts.map +1 -1
  115. package/dist/routes/pages/chat.js +7 -2
  116. package/dist/routes/pages/chat.js.map +1 -1
  117. package/dist/routes/pages/dreams.d.ts.map +1 -1
  118. package/dist/routes/pages/dreams.js +14 -18
  119. package/dist/routes/pages/dreams.js.map +1 -1
  120. package/dist/routes/pages/integrations.d.ts.map +1 -1
  121. package/dist/routes/pages/integrations.js +51 -14
  122. package/dist/routes/pages/integrations.js.map +1 -1
  123. package/dist/routes/pages/metrics.d.ts.map +1 -1
  124. package/dist/routes/pages/metrics.js +146 -17
  125. package/dist/routes/pages/metrics.js.map +1 -1
  126. package/dist/routes/pages/operations.js +1 -1
  127. package/dist/routes/pages/operations.js.map +1 -1
  128. package/dist/routes/pages/settings.d.ts.map +1 -1
  129. package/dist/routes/pages/settings.js +19 -2
  130. package/dist/routes/pages/settings.js.map +1 -1
  131. package/dist/routes/pages/thread-debug.d.ts.map +1 -1
  132. package/dist/routes/pages/thread-debug.js +5 -8
  133. package/dist/routes/pages/thread-debug.js.map +1 -1
  134. package/dist/routes/pages/vault.d.ts.map +1 -1
  135. package/dist/routes/pages/vault.js +1 -1
  136. package/dist/routes/pages/vault.js.map +1 -1
  137. package/dist/routes/pages/workspace.d.ts.map +1 -1
  138. package/dist/routes/pages/workspace.js +13 -10
  139. package/dist/routes/pages/workspace.js.map +1 -1
  140. package/dist/server/index.d.ts.map +1 -1
  141. package/dist/server/index.js +3 -0
  142. package/dist/server/index.js.map +1 -1
  143. package/dist/server/lib/app-creation-store.d.ts.map +1 -1
  144. package/dist/server/lib/app-creation-store.js +12 -52
  145. package/dist/server/lib/app-creation-store.js.map +1 -1
  146. package/dist/server/lib/usage-metrics-store.d.ts +17 -0
  147. package/dist/server/lib/usage-metrics-store.d.ts.map +1 -1
  148. package/dist/server/lib/usage-metrics-store.js +187 -30
  149. package/dist/server/lib/usage-metrics-store.js.map +1 -1
  150. package/dist/server/plugins/agent-chat.js +2 -0
  151. package/dist/server/plugins/agent-chat.js.map +1 -1
  152. package/dist/server/plugins/integrations.d.ts.map +1 -1
  153. package/dist/server/plugins/integrations.js +1 -0
  154. package/dist/server/plugins/integrations.js.map +1 -1
  155. package/dist/shared/app-roles.d.ts +9 -0
  156. package/dist/shared/app-roles.d.ts.map +1 -0
  157. package/dist/shared/app-roles.js +7 -0
  158. package/dist/shared/app-roles.js.map +1 -0
  159. package/package.json +4 -3
  160. package/src/actions/list-connected-agents.spec.ts +73 -0
  161. package/src/actions/list-connected-agents.ts +6 -2
  162. package/src/actions/list-dispatch-usage-metrics.ts +11 -1
  163. package/src/actions/remix-workspace-template.spec.ts +29 -0
  164. package/src/actions/remix-workspace-template.ts +13 -6
  165. package/src/actions/scaffold-workspace-app.ts +12 -5
  166. package/src/actions/start-workspace-app-creation.ts +8 -2
  167. package/src/actions/view-screen.ts +13 -1
  168. package/src/components/admin-navigation.tsx +57 -11
  169. package/src/components/app-icon.spec.tsx +34 -0
  170. package/src/components/app-icon.tsx +1 -1
  171. package/src/components/app-list-row.tsx +1 -2
  172. package/src/components/app-open-actions.tsx +5 -114
  173. package/src/components/app-row-settings.tsx +54 -0
  174. package/src/components/automation-run-history-dialog.tsx +8 -4
  175. package/src/components/connected-app-card.tsx +3 -2
  176. package/src/components/create-app-popover.spec.tsx +1 -1
  177. package/src/components/create-app-popover.tsx +2 -2
  178. package/src/components/dispatch-control-plane.spec.tsx +44 -3
  179. package/src/components/dispatch-control-plane.tsx +83 -27
  180. package/src/components/dispatch-shell.tsx +7 -1
  181. package/src/components/docs-link.tsx +52 -0
  182. package/src/components/layout/Layout.spec.tsx +13 -0
  183. package/src/components/layout/Layout.tsx +63 -32
  184. package/src/components/messaging-setup-panel.tsx +10 -3
  185. package/src/components/usage-alerts-panel.tsx +445 -0
  186. package/src/components/workspace-app-card.spec.tsx +130 -9
  187. package/src/components/workspace-app-card.tsx +128 -71
  188. package/src/components/workspace-app-search.tsx +96 -0
  189. package/src/components/workspace-resource-effective-stack.tsx +10 -5
  190. package/src/components/workspace-template-card.spec.tsx +53 -24
  191. package/src/components/workspace-template-card.tsx +16 -26
  192. package/src/hooks/use-navigation-state.ts +12 -0
  193. package/src/lib/embed-session-recovery.spec.ts +63 -0
  194. package/src/lib/embed-session-recovery.ts +26 -0
  195. package/src/lib/other-apps.ts +2 -0
  196. package/src/lib/workspace-app-layout.spec.ts +47 -0
  197. package/src/lib/workspace-app-layout.ts +121 -0
  198. package/src/lib/workspace-apps.test.ts +64 -0
  199. package/src/lib/workspace-apps.ts +82 -0
  200. package/src/routes/pages/$appId.tsx +3 -6
  201. package/src/routes/pages/agents.tsx +6 -3
  202. package/src/routes/pages/apps.$appId.tsx +16 -17
  203. package/src/routes/pages/apps.tsx +116 -62
  204. package/src/routes/pages/audit.tsx +28 -8
  205. package/src/routes/pages/chat.spec.tsx +10 -0
  206. package/src/routes/pages/chat.tsx +8 -0
  207. package/src/routes/pages/dreams.tsx +92 -142
  208. package/src/routes/pages/integrations.tsx +147 -68
  209. package/src/routes/pages/metrics.tsx +460 -102
  210. package/src/routes/pages/operations.tsx +2 -2
  211. package/src/routes/pages/settings.tsx +27 -10
  212. package/src/routes/pages/thread-debug.tsx +18 -36
  213. package/src/routes/pages/vault.tsx +7 -2
  214. package/src/routes/pages/workspace.tsx +92 -56
  215. package/src/server/index.ts +4 -0
  216. package/src/server/lib/app-creation-store.spec.ts +90 -43
  217. package/src/server/lib/app-creation-store.ts +15 -61
  218. package/src/server/lib/usage-metrics-store.spec.ts +117 -0
  219. package/src/server/lib/usage-metrics-store.ts +249 -30
  220. package/src/server/plugins/agent-chat.spec.ts +4 -0
  221. package/src/server/plugins/agent-chat.ts +2 -0
  222. package/src/server/plugins/integrations.ts +1 -0
  223. package/src/shared/app-roles.ts +8 -0
  224. package/src/styles/dispatch.css +9 -0
@@ -0,0 +1,445 @@
1
+ import type { UsageAlertRule } from "@agent-native/core";
2
+ import {
3
+ useActionMutation,
4
+ useActionQuery,
5
+ } from "@agent-native/core/client/hooks";
6
+ import { IconBell, IconMail, IconPencil, IconX } from "@tabler/icons-react";
7
+ import { useMemo, useState } from "react";
8
+ import { toast } from "sonner";
9
+
10
+ import { Badge } from "./ui/badge";
11
+ import { Button } from "./ui/button";
12
+ import { Checkbox } from "./ui/checkbox";
13
+ import { Input } from "./ui/input";
14
+ import {
15
+ Select,
16
+ SelectContent,
17
+ SelectItem,
18
+ SelectTrigger,
19
+ SelectValue,
20
+ } from "./ui/select";
21
+ import { Skeleton } from "./ui/skeleton";
22
+ import { Switch } from "./ui/switch";
23
+
24
+ export interface UsageAlertAppOption {
25
+ id: string;
26
+ label: string;
27
+ }
28
+
29
+ interface UsageAlertsPanelProps {
30
+ scope: "user" | "workspace";
31
+ appOptions?: UsageAlertAppOption[];
32
+ }
33
+
34
+ type AlertUnit = "usd" | "builder-credits" | "tokens";
35
+ type AlertChannel = "in-app" | "email";
36
+
37
+ interface AlertDraft {
38
+ ruleId?: string;
39
+ appId: string;
40
+ unit: AlertUnit;
41
+ period: "day" | "month";
42
+ limit: string;
43
+ inApp: boolean;
44
+ email: boolean;
45
+ }
46
+
47
+ function asRules(value: unknown): UsageAlertRule[] {
48
+ if (Array.isArray(value)) return value as UsageAlertRule[];
49
+ if (value && typeof value === "object") {
50
+ const rules = (value as { rules?: unknown }).rules;
51
+ if (Array.isArray(rules)) return rules as UsageAlertRule[];
52
+ }
53
+ return [];
54
+ }
55
+
56
+ function formatAlertValue(rule: UsageAlertRule, value: number): string {
57
+ if (rule.unit === "usd") {
58
+ return value.toLocaleString(undefined, {
59
+ style: "currency",
60
+ currency: "USD",
61
+ maximumFractionDigits: 2,
62
+ });
63
+ }
64
+ return (
65
+ value.toLocaleString(undefined, {
66
+ maximumFractionDigits: rule.unit === "builder-credits" ? 2 : 0,
67
+ }) +
68
+ " " +
69
+ (rule.unit === "builder-credits" ? "credits" : "tokens")
70
+ );
71
+ }
72
+
73
+ function draftFromRule(rule: UsageAlertRule): AlertDraft {
74
+ return {
75
+ ruleId: rule.id,
76
+ appId: rule.appId ?? "",
77
+ unit: rule.unit,
78
+ period: rule.period,
79
+ limit: String(rule.limit),
80
+ inApp: rule.channels.includes("in-app"),
81
+ email: rule.channels.includes("email"),
82
+ };
83
+ }
84
+
85
+ function defaultDraft(): AlertDraft {
86
+ return {
87
+ appId: "",
88
+ unit: "usd",
89
+ period: "day",
90
+ limit: "100",
91
+ inApp: true,
92
+ email: true,
93
+ };
94
+ }
95
+
96
+ function AlertEditor({
97
+ draft,
98
+ appOptions,
99
+ isExisting,
100
+ onChange,
101
+ onSave,
102
+ onCancel,
103
+ isPending,
104
+ }: {
105
+ draft: AlertDraft;
106
+ appOptions: UsageAlertAppOption[];
107
+ isExisting: boolean;
108
+ onChange: (patch: Partial<AlertDraft>) => void;
109
+ onSave: () => void;
110
+ onCancel: () => void;
111
+ isPending: boolean;
112
+ }) {
113
+ return (
114
+ <div className="grid gap-3 rounded-md border bg-background/60 p-3 sm:grid-cols-2 lg:grid-cols-5">
115
+ <label className="space-y-1.5 text-xs text-muted-foreground">
116
+ <span>App</span>
117
+ <Select
118
+ value={draft.appId || "all"}
119
+ onValueChange={(value) =>
120
+ onChange({ appId: value === "all" ? "" : value })
121
+ }
122
+ disabled={isExisting}
123
+ >
124
+ <SelectTrigger className="h-9 text-sm">
125
+ <SelectValue />
126
+ </SelectTrigger>
127
+ <SelectContent>
128
+ <SelectItem value="all">All apps</SelectItem>
129
+ {appOptions.map((app) => (
130
+ <SelectItem key={app.id} value={app.id}>
131
+ {app.label}
132
+ </SelectItem>
133
+ ))}
134
+ </SelectContent>
135
+ </Select>
136
+ </label>
137
+ <label className="space-y-1.5 text-xs text-muted-foreground">
138
+ <span>Unit</span>
139
+ <Select
140
+ value={draft.unit}
141
+ onValueChange={(value) => onChange({ unit: value as AlertUnit })}
142
+ >
143
+ <SelectTrigger className="h-9 text-sm">
144
+ <SelectValue />
145
+ </SelectTrigger>
146
+ <SelectContent>
147
+ <SelectItem value="usd">Dollars</SelectItem>
148
+ <SelectItem value="builder-credits">Credits</SelectItem>
149
+ <SelectItem value="tokens">Tokens</SelectItem>
150
+ </SelectContent>
151
+ </Select>
152
+ </label>
153
+ <label className="space-y-1.5 text-xs text-muted-foreground">
154
+ <span>Limit</span>
155
+ <Input
156
+ type="number"
157
+ min="0.01"
158
+ step={draft.unit === "tokens" ? "1" : "0.01"}
159
+ value={draft.limit}
160
+ onChange={(event) => onChange({ limit: event.target.value })}
161
+ className="h-9 text-sm"
162
+ />
163
+ </label>
164
+ <label className="space-y-1.5 text-xs text-muted-foreground">
165
+ <span>Period</span>
166
+ <Select
167
+ value={draft.period}
168
+ onValueChange={(value) =>
169
+ onChange({ period: value as "day" | "month" })
170
+ }
171
+ >
172
+ <SelectTrigger className="h-9 text-sm">
173
+ <SelectValue />
174
+ </SelectTrigger>
175
+ <SelectContent>
176
+ <SelectItem value="day">Per day</SelectItem>
177
+ <SelectItem value="month">Per month</SelectItem>
178
+ </SelectContent>
179
+ </Select>
180
+ </label>
181
+ <div className="flex flex-wrap items-end gap-3 pb-1 text-xs text-muted-foreground">
182
+ <label className="inline-flex items-center gap-2">
183
+ <Checkbox
184
+ checked={draft.inApp}
185
+ onCheckedChange={(checked) => onChange({ inApp: checked === true })}
186
+ />
187
+ In app
188
+ </label>
189
+ <label className="inline-flex items-center gap-2">
190
+ <Checkbox
191
+ checked={draft.email}
192
+ onCheckedChange={(checked) => onChange({ email: checked === true })}
193
+ />
194
+ Email
195
+ </label>
196
+ <Button
197
+ type="button"
198
+ size="sm"
199
+ className="ms-auto h-8"
200
+ onClick={onSave}
201
+ disabled={isPending}
202
+ >
203
+ Save
204
+ </Button>
205
+ <Button
206
+ type="button"
207
+ variant="ghost"
208
+ size="sm"
209
+ className="h-8"
210
+ onClick={onCancel}
211
+ >
212
+ Cancel
213
+ </Button>
214
+ </div>
215
+ </div>
216
+ );
217
+ }
218
+
219
+ export function UsageAlertsPanel({
220
+ scope,
221
+ appOptions = [],
222
+ }: UsageAlertsPanelProps) {
223
+ const query = useActionQuery("get-usage-alerts", { scope });
224
+ const mutation = useActionMutation("manage-usage-alert", {
225
+ onSuccess: () => {
226
+ setEditing(null);
227
+ toast.success("Usage alert updated");
228
+ },
229
+ onError: (error) => toast.error(String(error)),
230
+ });
231
+ const [editing, setEditing] = useState<AlertDraft | null>(null);
232
+ const rules = asRules(query.data);
233
+ const appLabels = useMemo(
234
+ () => new Map(appOptions.map((app) => [app.id, app.label])),
235
+ [appOptions],
236
+ );
237
+
238
+ function saveDraft() {
239
+ if (!editing) return;
240
+ const limit = Number(editing.limit);
241
+ const channels: AlertChannel[] = [];
242
+ if (editing.inApp) channels.push("in-app");
243
+ if (editing.email) channels.push("email");
244
+ if (!Number.isFinite(limit) || limit <= 0) {
245
+ toast.error("Enter a positive threshold.");
246
+ return;
247
+ }
248
+ if (channels.length === 0) {
249
+ toast.error("Choose at least one delivery channel.");
250
+ return;
251
+ }
252
+ mutation.mutate({
253
+ operation: "save",
254
+ scope,
255
+ ruleId: editing.ruleId,
256
+ appId: editing.appId || null,
257
+ unit: editing.unit,
258
+ period: editing.period,
259
+ limit,
260
+ channels,
261
+ enabled: true,
262
+ });
263
+ }
264
+
265
+ return (
266
+ <section className="rounded-lg bg-card">
267
+ <div className="flex items-start justify-between gap-3 border-b px-4 py-3">
268
+ <div>
269
+ <h2 className="text-sm font-semibold text-foreground">
270
+ Usage alerts
271
+ </h2>
272
+ <p className="mt-1 max-w-2xl text-xs leading-5 text-muted-foreground">
273
+ Get a quiet in-app and email notice when usage crosses a daily or
274
+ monthly limit.
275
+ </p>
276
+ </div>
277
+ <Button
278
+ type="button"
279
+ variant="outline"
280
+ size="sm"
281
+ onClick={() => setEditing(defaultDraft())}
282
+ disabled={mutation.isPending}
283
+ >
284
+ Add alert
285
+ </Button>
286
+ </div>
287
+
288
+ <div className="divide-y">
289
+ {query.isLoading && rules.length === 0 ? (
290
+ <div className="space-y-3 px-4 py-5">
291
+ {Array.from({ length: 3 }).map((_, index) => (
292
+ <div key={index} className="space-y-2">
293
+ <Skeleton className="h-4 w-2/3 max-w-sm" />
294
+ <Skeleton className="h-3 w-1/2 max-w-xs" />
295
+ </div>
296
+ ))}
297
+ </div>
298
+ ) : null}
299
+ {query.isError ? (
300
+ <div className="px-4 py-6 text-sm text-destructive">
301
+ Usage alerts are unavailable for this scope.{" "}
302
+ {query.error instanceof Error ? query.error.message : "Try again."}
303
+ </div>
304
+ ) : null}
305
+ {!query.isLoading && !query.isError && rules.length === 0 ? (
306
+ <div className="px-4 py-6 text-sm text-muted-foreground">
307
+ No alerts yet. The recommended default is $100 per day across all
308
+ apps.
309
+ </div>
310
+ ) : null}
311
+ {rules.map((rule) => {
312
+ const isEditing = editing?.ruleId === rule.id;
313
+ const target = rule.appId
314
+ ? appLabels.get(rule.appId) || rule.appId
315
+ : "All apps";
316
+ return (
317
+ <div key={rule.id} className="px-4 py-4">
318
+ <div className="flex flex-wrap items-start justify-between gap-3">
319
+ <div className="min-w-0">
320
+ <div className="flex flex-wrap items-center gap-2">
321
+ <span className="text-sm font-medium text-foreground">
322
+ {target}
323
+ </span>
324
+ {rule.isDefault ? (
325
+ <Badge variant="secondary">Default</Badge>
326
+ ) : null}
327
+ <Badge
328
+ variant={
329
+ rule.status === "triggered" ? "destructive" : "outline"
330
+ }
331
+ >
332
+ {rule.status === "triggered"
333
+ ? "Over limit"
334
+ : rule.status === "dismissed"
335
+ ? "Dismissed"
336
+ : "On track"}
337
+ </Badge>
338
+ </div>
339
+ <div className="mt-1 text-xs text-muted-foreground">
340
+ {formatAlertValue(rule, rule.current)} of{" "}
341
+ {formatAlertValue(rule, rule.limit)} · per {rule.period}
342
+ {rule.percent > 0 ? " · " + rule.percent + "%" : ""}
343
+ </div>
344
+ </div>
345
+ <div className="flex items-center gap-2">
346
+ <Switch
347
+ checked={rule.enabled}
348
+ onCheckedChange={(checked) =>
349
+ mutation.mutate({
350
+ operation: "set-enabled",
351
+ scope,
352
+ ruleId: rule.id,
353
+ enabled: checked,
354
+ })
355
+ }
356
+ aria-label={
357
+ (rule.enabled ? "Disable " : "Enable ") +
358
+ target +
359
+ " usage alert"
360
+ }
361
+ disabled={mutation.isPending}
362
+ />
363
+ <Button
364
+ type="button"
365
+ variant="ghost"
366
+ size="icon"
367
+ onClick={() => setEditing(draftFromRule(rule))}
368
+ aria-label={"Edit " + target + " usage alert"}
369
+ disabled={mutation.isPending}
370
+ >
371
+ <IconPencil className="size-4" />
372
+ </Button>
373
+ {rule.status === "triggered" ? (
374
+ <Button
375
+ type="button"
376
+ variant="ghost"
377
+ size="icon"
378
+ onClick={() =>
379
+ mutation.mutate({
380
+ operation: "dismiss",
381
+ scope,
382
+ ruleId: rule.id,
383
+ })
384
+ }
385
+ aria-label={"Dismiss " + target + " usage alert"}
386
+ disabled={mutation.isPending}
387
+ >
388
+ <IconX className="size-4" />
389
+ </Button>
390
+ ) : null}
391
+ </div>
392
+ </div>
393
+ <div className="mt-2 flex flex-wrap items-center gap-3 text-xs text-muted-foreground">
394
+ {rule.channels.includes("in-app") ? (
395
+ <span className="inline-flex items-center gap-1">
396
+ <IconBell className="size-3.5" /> In app
397
+ </span>
398
+ ) : null}
399
+ {rule.channels.includes("email") ? (
400
+ <span className="inline-flex items-center gap-1">
401
+ <IconMail className="size-3.5" /> Email
402
+ </span>
403
+ ) : null}
404
+ </div>
405
+ {isEditing && editing ? (
406
+ <div className="mt-4">
407
+ <AlertEditor
408
+ draft={editing}
409
+ appOptions={appOptions}
410
+ isExisting
411
+ onChange={(patch) =>
412
+ setEditing((current) =>
413
+ current ? { ...current, ...patch } : current,
414
+ )
415
+ }
416
+ onSave={saveDraft}
417
+ onCancel={() => setEditing(null)}
418
+ isPending={mutation.isPending}
419
+ />
420
+ </div>
421
+ ) : null}
422
+ </div>
423
+ );
424
+ })}
425
+ {editing && !editing.ruleId ? (
426
+ <div className="px-4 py-4">
427
+ <AlertEditor
428
+ draft={editing}
429
+ appOptions={appOptions}
430
+ isExisting={false}
431
+ onChange={(patch) =>
432
+ setEditing((current) =>
433
+ current ? { ...current, ...patch } : current,
434
+ )
435
+ }
436
+ onSave={saveDraft}
437
+ onCancel={() => setEditing(null)}
438
+ isPending={mutation.isPending}
439
+ />
440
+ </div>
441
+ ) : null}
442
+ </div>
443
+ </section>
444
+ );
445
+ }
@@ -22,7 +22,8 @@ vi.mock("@agent-native/core/client/i18n", () => ({
22
22
  useFormatters: () => ({
23
23
  formatDate: (value: string) => value,
24
24
  }),
25
- useT: () => (key: string) => key,
25
+ useT: () => (key: string, values?: Record<string, unknown>) =>
26
+ String(values?.defaultValue ?? key),
26
27
  }));
27
28
 
28
29
  describe("WorkspaceAppCard", () => {
@@ -42,7 +43,7 @@ describe("WorkspaceAppCard", () => {
42
43
  vi.unstubAllGlobals();
43
44
  });
44
45
 
45
- it("uses one explicit open action and one settings control", async () => {
46
+ it("opens ready apps inline and keeps new-tab opening in the chevron menu", async () => {
46
47
  await act(async () => {
47
48
  root.render(
48
49
  <MemoryRouter>
@@ -61,14 +62,12 @@ describe("WorkspaceAppCard", () => {
61
62
  );
62
63
  });
63
64
 
64
- const appLink = container.querySelector<HTMLAnchorElement>(
65
- 'a[href="/analytics"]',
65
+ const openButton = container.querySelector<HTMLButtonElement>(
66
+ ".app-open-actions__primary",
66
67
  );
67
- expect(appLink).not.toBeNull();
68
- expect(appLink?.textContent).toContain("Open app");
69
- expect(appLink?.getAttribute("target")).toBe("_blank");
70
- expect(appLink?.getAttribute("rel")).toBe("noreferrer");
71
- expect(appLink?.querySelector("svg")).toBeNull();
68
+ expect(openButton).not.toBeNull();
69
+ expect(openButton?.textContent).toContain("Open app");
70
+ expect(container.querySelector('a[href="/analytics"]')).toBeNull();
72
71
 
73
72
  expect(
74
73
  container.querySelector(
@@ -78,6 +77,27 @@ describe("WorkspaceAppCard", () => {
78
77
  expect(document.body.textContent).not.toContain("Open in new tab");
79
78
  expect(document.body.textContent).not.toContain("Add app");
80
79
 
80
+ await act(async () => {
81
+ container
82
+ .querySelector<HTMLButtonElement>(
83
+ 'button[aria-label="Open options for Analytics"]',
84
+ )
85
+ ?.dispatchEvent(
86
+ new MouseEvent("pointerdown", { bubbles: true, button: 0 }),
87
+ );
88
+ });
89
+ const newTabItem = Array.from(
90
+ document.querySelectorAll<HTMLElement>('[role="menuitem"]'),
91
+ ).find((item) => item.textContent?.includes("Open in new tab"));
92
+ expect(newTabItem).not.toBeUndefined();
93
+ expect(newTabItem?.getAttribute("href")).toBe("/analytics");
94
+ expect(newTabItem?.getAttribute("target")).toBe("_blank");
95
+ const openMenu = document.querySelector<HTMLElement>('[role="menu"]');
96
+ expect(openMenu?.className).toContain("w-48");
97
+ expect(openMenu?.className).toContain("bg-popover");
98
+ expect(openMenu?.className).toContain("shadow-md");
99
+ expect(newTabItem?.querySelector("svg")).not.toBeNull();
100
+
81
101
  const settingsButton = container.querySelector<HTMLButtonElement>(
82
102
  'button[aria-label="Settings for Analytics"]',
83
103
  );
@@ -95,6 +115,47 @@ describe("WorkspaceAppCard", () => {
95
115
  ).toBeNull();
96
116
  });
97
117
 
118
+ it("keeps pinning in the app open menu", async () => {
119
+ const onTogglePinned = vi.fn();
120
+ await act(async () => {
121
+ root.render(
122
+ <MemoryRouter>
123
+ <TooltipProvider>
124
+ <WorkspaceAppCard
125
+ app={{
126
+ id: "analytics",
127
+ name: "Analytics",
128
+ path: "/analytics",
129
+ status: "ready",
130
+ }}
131
+ isPinned
132
+ onTogglePinned={onTogglePinned}
133
+ />
134
+ </TooltipProvider>
135
+ </MemoryRouter>,
136
+ );
137
+ });
138
+
139
+ const optionsButton = container.querySelector<HTMLButtonElement>(
140
+ 'button[aria-label="Open options for Analytics"]',
141
+ );
142
+ expect(optionsButton).not.toBeNull();
143
+
144
+ await act(async () => {
145
+ optionsButton?.dispatchEvent(
146
+ new MouseEvent("pointerdown", { bubbles: true, button: 0 }),
147
+ );
148
+ });
149
+
150
+ const pinItem = Array.from(
151
+ document.querySelectorAll<HTMLElement>('[role="menuitem"]'),
152
+ ).find((item) => item.textContent?.includes("Unpin Analytics"));
153
+ expect(pinItem).not.toBeUndefined();
154
+
155
+ await act(async () => pinItem?.click());
156
+ expect(onTogglePinned).toHaveBeenCalledOnce();
157
+ });
158
+
98
159
  it("labels per-app context as agent resources while preserving workspace scope", async () => {
99
160
  await act(async () => {
100
161
  root.render(
@@ -127,6 +188,11 @@ describe("WorkspaceAppCard", () => {
127
188
  document.querySelectorAll<HTMLElement>('[role="menuitem"]'),
128
189
  ).find((item) => item.textContent?.includes("Agent resources"));
129
190
  expect(resourcesItem).not.toBeUndefined();
191
+ const settingsMenu = document.querySelector<HTMLElement>('[role="menu"]');
192
+ expect(settingsMenu?.className).toContain("w-48");
193
+ expect(settingsMenu?.className).toContain("bg-popover");
194
+ expect(settingsMenu?.className).toContain("shadow-md");
195
+ expect(document.querySelectorAll('[role="menuitem"] svg').length).toBe(4);
130
196
 
131
197
  await act(async () => resourcesItem?.click());
132
198
 
@@ -150,6 +216,7 @@ describe("WorkspaceAppCard", () => {
150
216
  name: "New app",
151
217
  path: "/new-app",
152
218
  builderUrl: "https://builder.example.com/projects/new-app",
219
+ branchName: "pending-branch-id",
153
220
  status: "pending",
154
221
  }}
155
222
  />
@@ -167,6 +234,12 @@ describe("WorkspaceAppCard", () => {
167
234
  );
168
235
  expect(appLink?.getAttribute("target")).toBe("_blank");
169
236
  expect(appLink?.getAttribute("rel")).toBe("noreferrer");
237
+ expect(container.textContent).toContain("Open in Builder");
238
+ expect(container.textContent).not.toContain("pending-branch-id");
239
+ expect(container.querySelector(".app-open-actions")).toBeNull();
240
+ expect(
241
+ container.querySelector('button[aria-label="Open options for New app"]'),
242
+ ).toBeNull();
170
243
  });
171
244
 
172
245
  it("shows ownership metadata in the settings menu", async () => {
@@ -205,5 +278,53 @@ describe("WorkspaceAppCard", () => {
205
278
  expect(document.body.textContent).toContain("creator@example.com");
206
279
  expect(document.body.textContent).toContain("owner@example.com");
207
280
  expect(document.body.textContent).toContain("Growth, Operations");
281
+ const renderedLabels = Array.from(
282
+ document.querySelectorAll("span"),
283
+ (span) => span.textContent,
284
+ );
285
+ expect(renderedLabels).not.toContain("agents.dashboardMetadataCreated");
286
+ expect(renderedLabels).not.toContain("agents.notTracked");
287
+ });
288
+
289
+ it("hides metadata rows without tracked values", async () => {
290
+ await act(async () => {
291
+ root.render(
292
+ <MemoryRouter>
293
+ <TooltipProvider>
294
+ <WorkspaceAppCard
295
+ app={{
296
+ id: "analytics",
297
+ name: "Analytics",
298
+ path: "/analytics",
299
+ createdAt: "2026-07-28T12:00:00.000Z",
300
+ createdBy: "Not tracked",
301
+ teams: ["Not tracked"],
302
+ status: "ready",
303
+ }}
304
+ />
305
+ </TooltipProvider>
306
+ </MemoryRouter>,
307
+ );
308
+ });
309
+
310
+ const settingsButton = container.querySelector<HTMLButtonElement>(
311
+ 'button[aria-label="Settings for Analytics"]',
312
+ );
313
+ expect(settingsButton).not.toBeNull();
314
+
315
+ await act(async () => {
316
+ settingsButton?.dispatchEvent(
317
+ new MouseEvent("pointerdown", { bubbles: true, button: 0 }),
318
+ );
319
+ });
320
+
321
+ const renderedLabels = Array.from(
322
+ document.querySelectorAll("span"),
323
+ (span) => span.textContent,
324
+ );
325
+ expect(renderedLabels).not.toContain("agents.notTracked");
326
+ expect(renderedLabels).not.toContain("agents.dashboardMetadataCreated");
327
+ expect(renderedLabels).not.toContain("dispatch.pages.appMetadataOwner");
328
+ expect(renderedLabels).not.toContain("dispatch.pages.appMetadataTeams");
208
329
  });
209
330
  });