@agent-native/dispatch 0.5.1 → 0.6.1

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 (48) hide show
  1. package/dist/components/app-keys-popover.d.ts.map +1 -1
  2. package/dist/components/app-keys-popover.js +2 -1
  3. package/dist/components/app-keys-popover.js.map +1 -1
  4. package/dist/components/create-app-popover.js +1 -1
  5. package/dist/components/create-app-popover.js.map +1 -1
  6. package/dist/components/layout/Layout.js +3 -3
  7. package/dist/components/layout/Layout.js.map +1 -1
  8. package/dist/routes/index.d.ts.map +1 -1
  9. package/dist/routes/index.js +5 -0
  10. package/dist/routes/index.js.map +1 -1
  11. package/dist/routes/pages/$appId.d.ts +8 -0
  12. package/dist/routes/pages/$appId.d.ts.map +1 -0
  13. package/dist/routes/pages/$appId.js +91 -0
  14. package/dist/routes/pages/$appId.js.map +1 -0
  15. package/dist/routes/pages/approval.d.ts.map +1 -1
  16. package/dist/routes/pages/approval.js +2 -1
  17. package/dist/routes/pages/approval.js.map +1 -1
  18. package/dist/routes/pages/apps.$appId.d.ts.map +1 -1
  19. package/dist/routes/pages/apps.$appId.js +2 -1
  20. package/dist/routes/pages/apps.$appId.js.map +1 -1
  21. package/dist/routes/pages/integrations.d.ts +1 -1
  22. package/dist/routes/pages/integrations.d.ts.map +1 -1
  23. package/dist/routes/pages/integrations.js +131 -30
  24. package/dist/routes/pages/integrations.js.map +1 -1
  25. package/dist/routes/pages/overview.d.ts.map +1 -1
  26. package/dist/routes/pages/overview.js +10 -1
  27. package/dist/routes/pages/overview.js.map +1 -1
  28. package/dist/routes/pages/vault.d.ts.map +1 -1
  29. package/dist/routes/pages/vault.js +4 -3
  30. package/dist/routes/pages/vault.js.map +1 -1
  31. package/dist/routes/pages/workspace.d.ts.map +1 -1
  32. package/dist/routes/pages/workspace.js +5 -3
  33. package/dist/routes/pages/workspace.js.map +1 -1
  34. package/dist/server/plugins/integrations.js +1 -1
  35. package/dist/server/plugins/integrations.js.map +1 -1
  36. package/package.json +2 -2
  37. package/src/components/app-keys-popover.tsx +15 -1
  38. package/src/components/create-app-popover.tsx +1 -1
  39. package/src/components/layout/Layout.tsx +3 -3
  40. package/src/routes/index.ts +5 -0
  41. package/src/routes/pages/$appId.tsx +178 -0
  42. package/src/routes/pages/approval.tsx +33 -3
  43. package/src/routes/pages/apps.$appId.tsx +6 -1
  44. package/src/routes/pages/integrations.tsx +348 -215
  45. package/src/routes/pages/overview.tsx +58 -26
  46. package/src/routes/pages/vault.tsx +25 -12
  47. package/src/routes/pages/workspace.tsx +21 -3
  48. package/src/server/plugins/integrations.ts +1 -1
@@ -1,277 +1,410 @@
1
+ import { useMemo, useState } from "react";
1
2
  import { useActionMutation, useActionQuery } from "@agent-native/core/client";
3
+ import { useQueryClient } from "@tanstack/react-query";
2
4
  import { toast } from "sonner";
3
5
  import {
4
6
  IconCheck,
7
+ IconChevronRight,
5
8
  IconCircleDashed,
6
9
  IconKey,
7
- IconRefresh,
8
- IconShieldCheck,
9
- IconWifi,
10
- IconWifiOff,
10
+ IconLink,
11
+ IconPlugConnected,
11
12
  } from "@tabler/icons-react";
12
13
  import { DispatchShell } from "@/components/dispatch-shell";
13
14
  import { Badge } from "@/components/ui/badge";
14
15
  import { Button } from "@/components/ui/button";
15
- import { Progress } from "@/components/ui/progress";
16
+ import {
17
+ Collapsible,
18
+ CollapsibleContent,
19
+ CollapsibleTrigger,
20
+ } from "@/components/ui/collapsible";
21
+ import {
22
+ Dialog,
23
+ DialogContent,
24
+ DialogDescription,
25
+ DialogFooter,
26
+ DialogHeader,
27
+ DialogTitle,
28
+ } from "@/components/ui/dialog";
29
+ import { Input } from "@/components/ui/input";
30
+ import { Label } from "@/components/ui/label";
16
31
 
17
32
  export function meta() {
18
- return [{ title: "Integrations — Dispatch" }];
33
+ return [{ title: "Connections — Dispatch" }];
19
34
  }
20
35
 
21
- function StatusBadge({
22
- configured,
23
- vaultGranted,
24
- }: {
36
+ interface AppRef {
37
+ appId: string;
38
+ appName: string;
39
+ color: string;
25
40
  configured: boolean;
26
41
  vaultGranted: boolean;
27
- }) {
28
- if (configured && vaultGranted) {
29
- return (
30
- <Badge
31
- variant="secondary"
32
- className="bg-green-500/10 text-green-700 dark:text-green-400"
33
- >
34
- <IconShieldCheck size={12} className="mr-1" />
35
- Vault
36
- </Badge>
37
- );
38
- }
39
- if (configured) {
40
- return (
41
- <Badge
42
- variant="secondary"
43
- className="bg-green-500/10 text-green-700 dark:text-green-400"
44
- >
45
- <IconCheck size={12} className="mr-1" />
46
- Configured
47
- </Badge>
48
- );
49
- }
50
- if (vaultGranted) {
51
- return (
52
- <Badge
53
- variant="secondary"
54
- className="bg-blue-500/10 text-blue-700 dark:text-blue-400"
55
- >
56
- <IconKey size={12} className="mr-1" />
57
- Granted (not synced)
58
- </Badge>
59
- );
42
+ vaultSecretId?: string;
43
+ }
44
+
45
+ interface Service {
46
+ /** Credential key shared across apps (e.g. `OPENAI_API_KEY`). */
47
+ key: string;
48
+ /** Human label from the first app that declares it (`"OpenAI"`, `"Stripe"`). */
49
+ label: string;
50
+ /** Apps in the workspace that declare this credential. */
51
+ apps: AppRef[];
52
+ }
53
+
54
+ interface CatalogApp {
55
+ appId: string;
56
+ appName: string;
57
+ color: string;
58
+ url: string;
59
+ reachable: boolean;
60
+ integrations?: Array<{
61
+ key: string;
62
+ label: string;
63
+ required: boolean;
64
+ configured: boolean;
65
+ vaultGranted: boolean;
66
+ vaultSecretId?: string;
67
+ }>;
68
+ }
69
+
70
+ function inferProviderFromKey(key: string, label: string): string {
71
+ const haystack = `${key} ${label}`.toLowerCase();
72
+ for (const provider of [
73
+ "google",
74
+ "slack",
75
+ "sendgrid",
76
+ "github",
77
+ "stripe",
78
+ "hubspot",
79
+ "jira",
80
+ "bigquery",
81
+ "anthropic",
82
+ "openai",
83
+ ]) {
84
+ if (haystack.includes(provider)) return provider;
60
85
  }
61
- return (
62
- <Badge
63
- variant="secondary"
64
- className="bg-amber-500/10 text-amber-700 dark:text-amber-400"
65
- >
66
- <IconCircleDashed size={12} className="mr-1" />
67
- Missing
68
- </Badge>
69
- );
86
+ return "other";
70
87
  }
71
88
 
72
- function AppCard({ app }: { app: any }) {
73
- const syncToApp = useActionMutation("sync-vault-to-app", {
74
- onSuccess: (data: any) =>
75
- toast.success(`Synced ${data.synced} key(s) to ${data.appId}`),
76
- onError: (err) => toast.error(String(err)),
77
- });
89
+ function ConnectDialog({
90
+ service,
91
+ open,
92
+ onOpenChange,
93
+ }: {
94
+ service: Service;
95
+ open: boolean;
96
+ onOpenChange: (next: boolean) => void;
97
+ }) {
98
+ const [value, setValue] = useState("");
99
+ const qc = useQueryClient();
100
+
101
+ const createSecret = useActionMutation("create-vault-secret", {});
102
+ const createGrant = useActionMutation("create-vault-grant", {});
103
+ const syncToApp = useActionMutation("sync-vault-to-app", {});
104
+
105
+ function reset() {
106
+ setValue("");
107
+ }
108
+
109
+ async function handleSave() {
110
+ const trimmed = value.trim();
111
+ if (!trimmed) {
112
+ toast.error("Enter a value to save");
113
+ return;
114
+ }
115
+ try {
116
+ // 1. Create the secret (or get the existing one — server treats key as
117
+ // the unique identifier). The server returns { secret: { id, ... } }.
118
+ const created = await createSecret.mutateAsync({
119
+ credentialKey: service.key,
120
+ name: service.label,
121
+ value: trimmed,
122
+ provider: inferProviderFromKey(service.key, service.label),
123
+ });
124
+ const secretId =
125
+ (created as { secret?: { id?: string } })?.secret?.id ??
126
+ (created as { id?: string })?.id;
127
+ if (!secretId) {
128
+ throw new Error("Secret created but id missing");
129
+ }
78
130
 
79
- const integrations = app.integrations || [];
80
- const configuredCount = integrations.filter((i: any) => i.configured).length;
81
- const total = integrations.length;
82
- const coverage = total > 0 ? Math.round((configuredCount / total) * 100) : 0;
131
+ // 2. Grant + sync to every app that declared this credential.
132
+ const targets = service.apps.filter((a) => !a.vaultGranted);
133
+ for (const app of targets) {
134
+ try {
135
+ await createGrant.mutateAsync({
136
+ secretId,
137
+ appId: app.appId,
138
+ });
139
+ } catch (err) {
140
+ console.warn(`grant to ${app.appId} failed`, err);
141
+ }
142
+ }
143
+ for (const app of service.apps) {
144
+ try {
145
+ await syncToApp.mutateAsync({ appId: app.appId });
146
+ } catch (err) {
147
+ console.warn(`sync to ${app.appId} failed`, err);
148
+ }
149
+ }
150
+
151
+ qc.invalidateQueries({
152
+ queryKey: ["action", "list-integrations-catalog"],
153
+ });
154
+ toast.success(`Connected ${service.label}`);
155
+ onOpenChange(false);
156
+ reset();
157
+ } catch (err: any) {
158
+ toast.error(err?.message ?? "Failed to save credential");
159
+ }
160
+ }
161
+
162
+ const pending =
163
+ createSecret.isPending || createGrant.isPending || syncToApp.isPending;
83
164
 
84
165
  return (
85
- <div className="rounded-2xl border bg-card">
86
- <div className="flex items-center justify-between border-b px-5 py-4">
87
- <div className="flex items-center gap-3">
88
- <div
89
- className="flex h-9 w-9 items-center justify-center rounded-xl text-white text-xs font-bold"
90
- style={{ backgroundColor: app.color }}
91
- >
92
- {app.appName.charAt(0).toUpperCase()}
166
+ <Dialog
167
+ open={open}
168
+ onOpenChange={(next) => {
169
+ if (!next) reset();
170
+ onOpenChange(next);
171
+ }}
172
+ >
173
+ <DialogContent>
174
+ <DialogHeader>
175
+ <DialogTitle>Connect {service.label}</DialogTitle>
176
+ <DialogDescription>
177
+ Used by{" "}
178
+ {service.apps.length === 1
179
+ ? service.apps[0].appName
180
+ : `${service.apps.length} apps`}
181
+ . Saved to the workspace vault and synced to every app that needs
182
+ it.
183
+ </DialogDescription>
184
+ </DialogHeader>
185
+ <div className="space-y-3">
186
+ <div>
187
+ <Label className="text-xs text-muted-foreground">Key</Label>
188
+ <div className="font-mono text-sm">{service.key}</div>
93
189
  </div>
94
190
  <div>
95
- <div className="flex items-center gap-2">
96
- <h3 className="text-sm font-semibold text-foreground">
97
- {app.appName}
98
- </h3>
99
- {app.reachable ? (
100
- <IconWifi size={14} className="text-green-500" />
101
- ) : (
102
- <IconWifiOff size={14} className="text-muted-foreground/50" />
103
- )}
104
- </div>
105
- <div className="text-xs text-muted-foreground">{app.appId}</div>
191
+ <Label htmlFor="connector-value">Value</Label>
192
+ <Input
193
+ id="connector-value"
194
+ type="password"
195
+ autoComplete="off"
196
+ value={value}
197
+ onChange={(e) => setValue(e.target.value)}
198
+ placeholder={`Paste your ${service.label} key…`}
199
+ autoFocus
200
+ />
106
201
  </div>
107
202
  </div>
108
- <Button
109
- variant="outline"
110
- size="sm"
111
- onClick={() => syncToApp.mutate({ appId: app.appId })}
112
- disabled={syncToApp.isPending || !app.reachable}
113
- >
114
- <IconRefresh
115
- size={14}
116
- className={syncToApp.isPending ? "animate-spin" : ""}
117
- />
118
- <span className="ml-1.5">Sync</span>
119
- </Button>
120
- </div>
203
+ <DialogFooter>
204
+ <Button
205
+ variant="outline"
206
+ onClick={() => onOpenChange(false)}
207
+ disabled={pending}
208
+ >
209
+ Cancel
210
+ </Button>
211
+ <Button onClick={handleSave} disabled={pending || !value.trim()}>
212
+ {pending ? "Saving…" : "Connect"}
213
+ </Button>
214
+ </DialogFooter>
215
+ </DialogContent>
216
+ </Dialog>
217
+ );
218
+ }
121
219
 
122
- <div className="px-5 py-4">
123
- {total > 0 ? (
124
- <>
125
- <div className="flex items-center justify-between text-xs text-muted-foreground">
126
- <span>
127
- {configuredCount}/{total} configured
128
- </span>
129
- <span>{coverage}%</span>
130
- </div>
131
- <Progress value={coverage} className="mt-2 h-1.5" />
220
+ function ConnectorCard({ service }: { service: Service }) {
221
+ const [open, setOpen] = useState(false);
222
+ const isConnected = service.apps.some((a) => a.configured);
223
+ const appCount = service.apps.length;
132
224
 
133
- <div className="mt-4 space-y-2">
134
- {integrations.map((integration: any) => (
135
- <div
136
- key={integration.key}
137
- className="flex items-center justify-between rounded-lg border px-3 py-2"
138
- >
139
- <div className="min-w-0">
140
- <div className="flex items-center gap-2">
141
- <span className="text-sm text-foreground">
142
- {integration.label}
143
- </span>
144
- {integration.required && (
145
- <span className="text-xs text-red-500">required</span>
146
- )}
147
- </div>
148
- <div className="font-mono text-xs text-muted-foreground">
149
- {integration.key}
150
- </div>
151
- </div>
152
- <StatusBadge
153
- configured={integration.configured}
154
- vaultGranted={integration.vaultGranted}
155
- />
156
- </div>
157
- ))}
158
- </div>
159
- </>
160
- ) : app.reachable ? (
161
- <div className="py-4 text-center text-sm text-muted-foreground">
162
- No declared integrations.
225
+ return (
226
+ <>
227
+ <button
228
+ type="button"
229
+ onClick={() => setOpen(true)}
230
+ className="group flex flex-col items-start gap-2 rounded-2xl border bg-card p-5 text-left transition hover:border-foreground/20 hover:bg-card/80 cursor-pointer"
231
+ >
232
+ <div className="flex w-full items-start justify-between gap-2">
233
+ <div className="flex h-9 w-9 items-center justify-center rounded-xl bg-muted">
234
+ <IconKey size={16} className="text-muted-foreground" />
163
235
  </div>
164
- ) : (
165
- <div className="py-4 text-center text-sm text-muted-foreground">
166
- App is not reachable. Start the app to see its integrations.
236
+ {isConnected ? (
237
+ <Badge
238
+ variant="secondary"
239
+ className="bg-green-500/10 text-green-700 dark:text-green-400 gap-1"
240
+ >
241
+ <IconCheck size={12} />
242
+ Connected
243
+ </Badge>
244
+ ) : (
245
+ <Badge
246
+ variant="secondary"
247
+ className="bg-amber-500/10 text-amber-700 dark:text-amber-400 gap-1"
248
+ >
249
+ <IconCircleDashed size={12} />
250
+ Connect
251
+ </Badge>
252
+ )}
253
+ </div>
254
+ <div className="min-w-0">
255
+ <div className="text-sm font-semibold text-foreground truncate">
256
+ {service.label}
257
+ </div>
258
+ <div className="font-mono text-xs text-muted-foreground/80 truncate">
259
+ {service.key}
167
260
  </div>
261
+ </div>
262
+ <div className="text-xs text-muted-foreground">
263
+ Used by {appCount} {appCount === 1 ? "app" : "apps"}
264
+ </div>
265
+ </button>
266
+ <ConnectDialog service={service} open={open} onOpenChange={setOpen} />
267
+ </>
268
+ );
269
+ }
270
+
271
+ function PerAppDetailRow({ app }: { app: CatalogApp }) {
272
+ const total = (app.integrations ?? []).length;
273
+ const ok = (app.integrations ?? []).filter((i) => i.configured).length;
274
+ return (
275
+ <div className="flex items-center justify-between border-t px-4 py-2.5 first:border-t-0">
276
+ <div className="flex items-center gap-2 min-w-0">
277
+ <div
278
+ className="h-5 w-5 rounded text-[10px] font-bold text-white flex items-center justify-center shrink-0"
279
+ style={{ backgroundColor: app.color }}
280
+ >
281
+ {app.appName.charAt(0).toUpperCase()}
282
+ </div>
283
+ <span className="text-sm truncate">{app.appName}</span>
284
+ {!app.reachable && (
285
+ <span className="text-xs text-muted-foreground">offline</span>
168
286
  )}
169
287
  </div>
288
+ <span className="text-xs text-muted-foreground">
289
+ {total === 0 ? "no integrations" : `${ok}/${total}`}
290
+ </span>
170
291
  </div>
171
292
  );
172
293
  }
173
294
 
174
- export default function IntegrationsRoute() {
295
+ export default function ConnectionsRoute() {
175
296
  const { data: catalog, isLoading } = useActionQuery(
176
297
  "list-integrations-catalog",
177
298
  {},
178
299
  );
300
+ const apps = (catalog as CatalogApp[]) || [];
179
301
 
180
- const apps = catalog || [];
181
- const reachableApps = apps.filter((a: any) => a.reachable);
182
- const unreachableApps = apps.filter((a: any) => !a.reachable);
302
+ const services = useMemo<Service[]>(() => {
303
+ const map = new Map<string, Service>();
304
+ for (const app of apps) {
305
+ for (const intg of app.integrations ?? []) {
306
+ if (!map.has(intg.key)) {
307
+ map.set(intg.key, {
308
+ key: intg.key,
309
+ label: intg.label,
310
+ apps: [],
311
+ });
312
+ }
313
+ map.get(intg.key)!.apps.push({
314
+ appId: app.appId,
315
+ appName: app.appName,
316
+ color: app.color,
317
+ configured: intg.configured,
318
+ vaultGranted: intg.vaultGranted,
319
+ vaultSecretId: intg.vaultSecretId,
320
+ });
321
+ }
322
+ }
323
+ return Array.from(map.values()).sort((a, b) =>
324
+ a.label.localeCompare(b.label),
325
+ );
326
+ }, [apps]);
183
327
 
184
- const totalIntegrations = apps.reduce(
185
- (sum: number, a: any) => sum + (a.integrations?.length || 0),
186
- 0,
187
- );
188
- const configuredIntegrations = apps.reduce(
189
- (sum: number, a: any) =>
190
- sum + (a.integrations?.filter((i: any) => i.configured)?.length || 0),
191
- 0,
192
- );
328
+ const available = services.filter((s) => !s.apps.some((a) => a.configured));
329
+ const connected = services.filter((s) => s.apps.some((a) => a.configured));
193
330
 
194
331
  return (
195
332
  <DispatchShell
196
- title="Integrations"
197
- description="See what credentials each app needs and their configuration status across the workspace."
333
+ title="Connections"
334
+ description="Connect services once. Apps that need them pick up the key automatically."
198
335
  >
199
- {!isLoading && apps.length > 0 && (
200
- <div className="grid gap-4 md:grid-cols-3">
201
- <div className="rounded-2xl border bg-card p-5">
202
- <div className="text-sm font-medium text-muted-foreground">
203
- Apps discovered
204
- </div>
205
- <div className="mt-2 text-3xl font-semibold text-foreground">
206
- {apps.length}
207
- </div>
208
- <div className="mt-1 text-xs text-muted-foreground">
209
- {reachableApps.length} reachable
210
- </div>
211
- </div>
212
- <div className="rounded-2xl border bg-card p-5">
213
- <div className="text-sm font-medium text-muted-foreground">
214
- Total integrations
215
- </div>
216
- <div className="mt-2 text-3xl font-semibold text-foreground">
217
- {totalIntegrations}
218
- </div>
219
- <div className="mt-1 text-xs text-muted-foreground">
220
- across all apps
221
- </div>
222
- </div>
223
- <div className="rounded-2xl border bg-card p-5">
224
- <div className="text-sm font-medium text-muted-foreground">
225
- Configured
226
- </div>
227
- <div className="mt-2 text-3xl font-semibold text-foreground">
228
- {configuredIntegrations}/{totalIntegrations}
229
- </div>
230
- <Progress
231
- value={
232
- totalIntegrations > 0
233
- ? Math.round(
234
- (configuredIntegrations / totalIntegrations) * 100,
235
- )
236
- : 0
237
- }
238
- className="mt-2 h-1.5"
239
- />
240
- </div>
336
+ {isLoading && services.length === 0 && (
337
+ <div className="rounded-2xl border border-dashed px-6 py-12 text-center text-sm text-muted-foreground">
338
+ Discovering apps and credentials…
241
339
  </div>
242
340
  )}
243
341
 
244
- {isLoading && (
342
+ {!isLoading && services.length === 0 && (
245
343
  <div className="rounded-2xl border border-dashed px-6 py-12 text-center text-sm text-muted-foreground">
246
- Discovering apps and fetching integration status...
344
+ No apps with declared integrations are reachable yet.
247
345
  </div>
248
346
  )}
249
347
 
250
- {reachableApps.length > 0 && (
251
- <div className="grid gap-4 xl:grid-cols-2">
252
- {reachableApps.map((app: any) => (
253
- <AppCard key={app.appId} app={app} />
254
- ))}
255
- </div>
348
+ {available.length > 0 && (
349
+ <section>
350
+ <div className="mb-3 flex items-baseline justify-between">
351
+ <h2 className="text-sm font-medium text-foreground">
352
+ Available to connect
353
+ </h2>
354
+ <span className="text-xs text-muted-foreground">
355
+ {available.length}
356
+ </span>
357
+ </div>
358
+ <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
359
+ {available.map((service) => (
360
+ <ConnectorCard key={service.key} service={service} />
361
+ ))}
362
+ </div>
363
+ </section>
256
364
  )}
257
365
 
258
- {unreachableApps.length > 0 && (
259
- <div>
260
- <h2 className="mb-3 text-sm font-medium text-muted-foreground">
261
- Offline apps
262
- </h2>
263
- <div className="grid gap-4 xl:grid-cols-2">
264
- {unreachableApps.map((app: any) => (
265
- <AppCard key={app.appId} app={app} />
366
+ {connected.length > 0 && (
367
+ <section>
368
+ <div className="mb-3 mt-2 flex items-baseline justify-between">
369
+ <h2 className="text-sm font-medium text-foreground">Connected</h2>
370
+ <span className="text-xs text-muted-foreground">
371
+ {connected.length}
372
+ </span>
373
+ </div>
374
+ <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
375
+ {connected.map((service) => (
376
+ <ConnectorCard key={service.key} service={service} />
266
377
  ))}
267
378
  </div>
268
- </div>
379
+ </section>
269
380
  )}
270
381
 
271
- {!isLoading && apps.length === 0 && (
272
- <div className="rounded-2xl border border-dashed px-6 py-12 text-center text-sm text-muted-foreground">
273
- No workspace apps found.
274
- </div>
382
+ {apps.length > 0 && (
383
+ <Collapsible className="mt-6 rounded-2xl border bg-card">
384
+ <CollapsibleTrigger className="flex w-full items-center justify-between px-4 py-3 text-sm">
385
+ <span className="flex items-center gap-2 text-muted-foreground">
386
+ <IconPlugConnected size={14} />
387
+ Per-app status
388
+ </span>
389
+ <IconChevronRight
390
+ size={14}
391
+ className="text-muted-foreground transition group-data-[state=open]:rotate-90"
392
+ />
393
+ </CollapsibleTrigger>
394
+ <CollapsibleContent>
395
+ <div className="border-t">
396
+ {apps.map((app) => (
397
+ <PerAppDetailRow key={app.appId} app={app} />
398
+ ))}
399
+ </div>
400
+ <div className="flex items-center justify-end gap-1.5 border-t px-4 py-2.5 text-xs text-muted-foreground">
401
+ <IconLink size={12} />
402
+ <a href="/vault" className="hover:underline">
403
+ Open vault for advanced sharing
404
+ </a>
405
+ </div>
406
+ </CollapsibleContent>
407
+ </Collapsible>
275
408
  )}
276
409
  </DispatchShell>
277
410
  );