@agent-native/dispatch 0.19.1 → 0.20.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 (85) hide show
  1. package/dist/actions/provider-api-register.d.ts +9 -9
  2. package/dist/components/admin-navigation.d.ts +2 -1
  3. package/dist/components/admin-navigation.d.ts.map +1 -1
  4. package/dist/components/admin-navigation.js +10 -7
  5. package/dist/components/admin-navigation.js.map +1 -1
  6. package/dist/components/app-icon.d.ts +9 -0
  7. package/dist/components/app-icon.d.ts.map +1 -0
  8. package/dist/components/app-icon.js +85 -0
  9. package/dist/components/app-icon.js.map +1 -0
  10. package/dist/components/app-list-row.d.ts +12 -0
  11. package/dist/components/app-list-row.d.ts.map +1 -0
  12. package/dist/components/app-list-row.js +11 -0
  13. package/dist/components/app-list-row.js.map +1 -0
  14. package/dist/components/app-open-actions.d.ts +17 -0
  15. package/dist/components/app-open-actions.d.ts.map +1 -0
  16. package/dist/components/app-open-actions.js +20 -0
  17. package/dist/components/app-open-actions.js.map +1 -0
  18. package/dist/components/connected-app-card.d.ts +2 -1
  19. package/dist/components/connected-app-card.d.ts.map +1 -1
  20. package/dist/components/connected-app-card.js +8 -8
  21. package/dist/components/connected-app-card.js.map +1 -1
  22. package/dist/components/dispatch-control-plane.d.ts.map +1 -1
  23. package/dist/components/dispatch-control-plane.js +21 -14
  24. package/dist/components/dispatch-control-plane.js.map +1 -1
  25. package/dist/components/layout/Layout.d.ts.map +1 -1
  26. package/dist/components/layout/Layout.js +40 -19
  27. package/dist/components/layout/Layout.js.map +1 -1
  28. package/dist/components/layout/workspace-apps-rail.d.ts.map +1 -1
  29. package/dist/components/layout/workspace-apps-rail.js +10 -10
  30. package/dist/components/layout/workspace-apps-rail.js.map +1 -1
  31. package/dist/components/other-apps-section.d.ts +31 -0
  32. package/dist/components/other-apps-section.d.ts.map +1 -0
  33. package/dist/components/other-apps-section.js +59 -0
  34. package/dist/components/other-apps-section.js.map +1 -0
  35. package/dist/components/workspace-app-card.d.ts.map +1 -1
  36. package/dist/components/workspace-app-card.js +38 -28
  37. package/dist/components/workspace-app-card.js.map +1 -1
  38. package/dist/components/workspace-template-card.d.ts +8 -2
  39. package/dist/components/workspace-template-card.d.ts.map +1 -1
  40. package/dist/components/workspace-template-card.js +17 -13
  41. package/dist/components/workspace-template-card.js.map +1 -1
  42. package/dist/lib/other-apps.d.ts.map +1 -1
  43. package/dist/lib/other-apps.js +5 -1
  44. package/dist/lib/other-apps.js.map +1 -1
  45. package/dist/lib/workspace-apps.d.ts +1 -0
  46. package/dist/lib/workspace-apps.d.ts.map +1 -1
  47. package/dist/lib/workspace-apps.js +3 -0
  48. package/dist/lib/workspace-apps.js.map +1 -1
  49. package/dist/routes/index.d.ts.map +1 -1
  50. package/dist/routes/index.js +0 -2
  51. package/dist/routes/index.js.map +1 -1
  52. package/dist/routes/pages/admin._index.d.ts.map +1 -1
  53. package/dist/routes/pages/admin._index.js +32 -7
  54. package/dist/routes/pages/admin._index.js.map +1 -1
  55. package/dist/routes/pages/apps.$appId.d.ts.map +1 -1
  56. package/dist/routes/pages/apps.$appId.js +62 -13
  57. package/dist/routes/pages/apps.$appId.js.map +1 -1
  58. package/dist/routes/pages/apps.d.ts +1 -1
  59. package/dist/routes/pages/apps.d.ts.map +1 -1
  60. package/dist/routes/pages/apps.js +21 -30
  61. package/dist/routes/pages/apps.js.map +1 -1
  62. package/package.json +3 -3
  63. package/src/components/admin-navigation.tsx +21 -16
  64. package/src/components/app-icon.tsx +149 -0
  65. package/src/components/app-list-row.tsx +40 -0
  66. package/src/components/app-open-actions.tsx +111 -0
  67. package/src/components/connected-app-card.tsx +30 -34
  68. package/src/components/dispatch-control-plane.spec.tsx +28 -1
  69. package/src/components/dispatch-control-plane.tsx +122 -48
  70. package/src/components/layout/Layout.spec.tsx +4 -4
  71. package/src/components/layout/Layout.tsx +86 -55
  72. package/src/components/layout/workspace-apps-rail.tsx +20 -21
  73. package/src/components/other-apps-section.tsx +195 -0
  74. package/src/components/workspace-app-card.spec.tsx +58 -32
  75. package/src/components/workspace-app-card.tsx +222 -158
  76. package/src/components/workspace-template-card.spec.tsx +45 -7
  77. package/src/components/workspace-template-card.tsx +74 -81
  78. package/src/lib/other-apps.spec.ts +31 -0
  79. package/src/lib/other-apps.ts +9 -1
  80. package/src/lib/workspace-apps.ts +4 -0
  81. package/src/routes/index.spec.ts +2 -1
  82. package/src/routes/index.ts +0 -2
  83. package/src/routes/pages/admin._index.tsx +85 -17
  84. package/src/routes/pages/apps.$appId.tsx +144 -57
  85. package/src/routes/pages/apps.tsx +76 -147
@@ -8,15 +8,16 @@ import {
8
8
  IconChevronDown,
9
9
  IconChevronRight,
10
10
  IconClockHour4,
11
- IconDots,
12
11
  IconEdit,
13
12
  IconEye,
14
13
  IconEyeOff,
15
14
  IconFileText,
16
- IconWorld,
15
+ IconKey,
16
+ IconSettings,
17
17
  IconTrash,
18
18
  IconUser,
19
19
  IconUsersGroup,
20
+ IconWorld,
20
21
  } from "@tabler/icons-react";
21
22
  import { useEffect, useState, type FormEvent } from "react";
22
23
  import { toast } from "sonner";
@@ -28,7 +29,10 @@ import {
28
29
  type WorkspaceAppSummary,
29
30
  } from "../lib/workspace-apps";
30
31
  import { ActionQueryError } from "./action-query-error";
31
- import { AppKeysPopover } from "./app-keys-popover";
32
+ import { AppIcon } from "./app-icon";
33
+ import { AppKeysPanel } from "./app-keys-popover";
34
+ import { AppListRow } from "./app-list-row";
35
+ import { AppOpenActions } from "./app-open-actions";
32
36
  import { Badge } from "./ui/badge";
33
37
  import { Button } from "./ui/button";
34
38
  import {
@@ -38,7 +42,6 @@ import {
38
42
  DialogFooter,
39
43
  DialogHeader,
40
44
  DialogTitle,
41
- DialogTrigger,
42
45
  } from "./ui/dialog";
43
46
  import {
44
47
  DropdownMenu,
@@ -67,12 +70,13 @@ export function WorkspaceAppCard({
67
70
  const t = useT();
68
71
  const { formatDate } = useFormatters();
69
72
  const href = workspaceAppHref(app);
70
- const openInNewTab = isPendingBuilderHref(app);
71
73
  const isPending = app.status === "pending";
72
74
  const pendingLabel = app.statusLabel || "Builder branch";
73
75
  const isArchived = !!app.archived;
74
76
  const audience = app.audience ?? "internal";
75
77
  const [editOpen, setEditOpen] = useState(false);
78
+ const [keysOpen, setKeysOpen] = useState(false);
79
+ const [resourcesOpen, setResourcesOpen] = useState(false);
76
80
  const [draftName, setDraftName] = useState(app.name);
77
81
  const [draftDescription, setDraftDescription] = useState(
78
82
  app.description || "",
@@ -132,145 +136,71 @@ export function WorkspaceAppCard({
132
136
  };
133
137
 
134
138
  return (
135
- <div
136
- aria-disabled={!href}
137
- className={cn(
138
- "group relative rounded-xl bg-card/40 p-4 transition-[background-color] hover:bg-accent/15 focus-within:bg-accent/15 aria-disabled:opacity-60",
139
- isArchived && "opacity-70",
140
- className,
141
- )}
142
- >
143
- {href ? (
144
- <a
145
- href={href}
146
- target={openInNewTab ? "_blank" : undefined}
147
- rel={openInNewTab ? "noreferrer" : undefined}
148
- aria-label={`Open ${app.name}`}
149
- className="absolute inset-0 z-0 rounded-xl outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
150
- />
151
- ) : null}
152
-
153
- <div className="pointer-events-none relative z-10 flex h-full min-w-0 items-start justify-between gap-3">
139
+ <>
140
+ <AppListRow
141
+ className={cn(
142
+ !href && "opacity-60",
143
+ isArchived && "opacity-70",
144
+ className,
145
+ )}
146
+ >
147
+ <AppIcon id={app.id} name={app.name} size="sm" />
154
148
  <div className="min-w-0 flex-1">
155
- <div className="flex min-w-0 flex-wrap items-center gap-1.5">
156
- <h3 className="min-w-0 flex-1 truncate text-sm font-semibold text-foreground">
157
- {app.name}
158
- </h3>
149
+ <h3 className="truncate text-sm font-semibold text-foreground">
150
+ {app.name}
151
+ </h3>
152
+ {app.description || isPending ? (
153
+ <p className="mt-0.5 truncate text-xs text-muted-foreground">
154
+ {app.description || pendingLabel}
155
+ </p>
156
+ ) : null}
157
+ <div className="mt-1.5 flex min-w-0 flex-wrap items-center gap-2 text-[11px] text-muted-foreground">
159
158
  {isPending ? (
160
- <Badge
161
- variant="outline"
162
- className="shrink-0 gap-1 border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300"
163
- >
159
+ <span className="inline-flex min-w-0 items-center gap-1 rounded-full bg-muted px-2 py-0.5">
164
160
  <IconClockHour4 size={12} />
165
- {pendingLabel}
166
- </Badge>
161
+ <span className="truncate">{pendingLabel}</span>
162
+ </span>
167
163
  ) : null}
168
164
  {isArchived ? (
169
- <Badge variant="outline" className="shrink-0 gap-1">
165
+ <span className="inline-flex items-center gap-1 rounded-full bg-muted px-2 py-0.5">
170
166
  <IconEyeOff size={12} />
171
167
  Hidden
172
- </Badge>
168
+ </span>
173
169
  ) : null}
174
170
  {audience === "public" ? (
175
- <Badge variant="outline" className="shrink-0 gap-1">
171
+ <span className="inline-flex items-center gap-1 rounded-full bg-muted px-2 py-0.5">
176
172
  <IconWorld size={12} />
177
173
  Public
178
- </Badge>
174
+ </span>
175
+ ) : null}
176
+ {isPending && app.branchName ? (
177
+ <span className="min-w-0 truncate">{app.branchName}</span>
179
178
  ) : null}
180
179
  </div>
181
- <p className="mt-1 truncate font-mono text-[11px] text-muted-foreground">
182
- {app.path}
183
- </p>
184
- {isPending && app.branchName ? (
185
- <p className="mt-1 truncate text-xs text-muted-foreground">
186
- Builder branch: {app.branchName}
187
- </p>
188
- ) : null}
189
- {app.description ? (
190
- <p className="mt-2 min-h-10 line-clamp-2 text-[13px] leading-5 text-muted-foreground">
191
- {app.description}
192
- </p>
193
- ) : null}
194
180
  </div>
195
- <div className="flex shrink-0 items-center gap-1">
196
- {!isPending && !isArchived ? (
197
- <div className="pointer-events-auto">
198
- <AppResourcesDialog app={app} />
199
- </div>
200
- ) : null}
201
- {!isPending && !isArchived ? (
202
- <div className="pointer-events-auto">
203
- <AppKeysPopover appId={app.id} appName={app.name} />
204
- </div>
205
- ) : null}
206
- <div className="pointer-events-auto">
207
- <DropdownMenu>
208
- <Tooltip>
209
- <TooltipTrigger asChild>
210
- <DropdownMenuTrigger asChild>
211
- <button
212
- type="button"
213
- aria-label={`More actions for ${app.name}`}
214
- className={cn(
215
- APP_CARD_ACTION_CLASS,
216
- "inline-flex cursor-pointer items-center justify-center",
217
- )}
218
- onClick={(e) => e.stopPropagation()}
219
- >
220
- <IconDots size={15} />
221
- </button>
222
- </DropdownMenuTrigger>
223
- </TooltipTrigger>
224
- <TooltipContent>More actions</TooltipContent>
225
- </Tooltip>
226
- <DropdownMenuContent align="end" className="w-72">
227
- <DropdownMenuItem
228
- onSelect={(event) => {
229
- event.preventDefault();
230
- setEditOpen(true);
231
- }}
232
- >
233
- <IconEdit size={14} className="mr-2" />
234
- Edit details
235
- </DropdownMenuItem>
236
- {isPending ? (
237
- <DropdownMenuItem
238
- onSelect={handleRemovePending}
239
- className="text-red-600 focus:text-red-600 dark:text-red-400 dark:focus:text-red-400"
240
- >
241
- <IconTrash size={14} className="mr-2" />
242
- Remove from list
243
- </DropdownMenuItem>
244
- ) : isArchived ? (
245
- <DropdownMenuItem onSelect={handleUnarchive}>
246
- <IconEye size={14} className="mr-2" />
247
- Restore to list
248
- </DropdownMenuItem>
249
- ) : (
250
- <DropdownMenuItem onSelect={handleArchive}>
251
- <IconEyeOff size={14} className="mr-2" />
252
- Hide from list
253
- </DropdownMenuItem>
254
- )}
255
- <DropdownMenuSeparator />
256
- <DropdownMenuLabel className="font-normal">
257
- <WorkspaceAppMetadata
258
- app={app}
259
- formatDate={formatDate}
260
- labels={{
261
- created: t("agents.dashboardMetadataCreated"),
262
- createdBy: t("agents.dashboardMetadataCreatedBy"),
263
- owner: t("dispatch.pages.appMetadataOwner"),
264
- teams: t("dispatch.pages.appMetadataTeams"),
265
- notTracked: t("agents.notTracked"),
266
- }}
267
- />
268
- </DropdownMenuLabel>
269
- </DropdownMenuContent>
270
- </DropdownMenu>
271
- </div>
181
+ <div className="flex shrink-0 items-center gap-2">
182
+ <WorkspaceAppOpenActions app={app} href={href} />
183
+ <WorkspaceAppSettings
184
+ app={app}
185
+ formatDate={formatDate}
186
+ isArchived={isArchived}
187
+ isPending={isPending}
188
+ onEdit={() => setEditOpen(true)}
189
+ onKeys={() => setKeysOpen(true)}
190
+ onResources={() => setResourcesOpen(true)}
191
+ onArchive={handleArchive}
192
+ onUnarchive={handleUnarchive}
193
+ onRemovePending={handleRemovePending}
194
+ labels={{
195
+ created: t("agents.dashboardMetadataCreated"),
196
+ createdBy: t("agents.dashboardMetadataCreatedBy"),
197
+ owner: t("dispatch.pages.appMetadataOwner"),
198
+ teams: t("dispatch.pages.appMetadataTeams"),
199
+ notTracked: t("agents.notTracked"),
200
+ }}
201
+ />
272
202
  </div>
273
- </div>
203
+ </AppListRow>
274
204
  <Dialog open={editOpen} onOpenChange={setEditOpen}>
275
205
  <DialogContent>
276
206
  <DialogHeader>
@@ -311,7 +241,157 @@ export function WorkspaceAppCard({
311
241
  </form>
312
242
  </DialogContent>
313
243
  </Dialog>
314
- </div>
244
+ <Dialog open={keysOpen} onOpenChange={setKeysOpen}>
245
+ <DialogContent className="max-w-md">
246
+ <DialogHeader>
247
+ <DialogTitle>Manage keys for {app.name}</DialogTitle>
248
+ <DialogDescription>
249
+ Choose which workspace credentials this app can use.
250
+ </DialogDescription>
251
+ </DialogHeader>
252
+ <AppKeysPanel appId={app.id} appName={app.name} />
253
+ <DialogFooter>
254
+ <Button type="button" onClick={() => setKeysOpen(false)}>
255
+ Done
256
+ </Button>
257
+ </DialogFooter>
258
+ </DialogContent>
259
+ </Dialog>
260
+ <AppResourcesDialog
261
+ app={app}
262
+ open={resourcesOpen}
263
+ onOpenChange={setResourcesOpen}
264
+ />
265
+ </>
266
+ );
267
+ }
268
+
269
+ function WorkspaceAppOpenActions({
270
+ app,
271
+ href,
272
+ }: {
273
+ app: WorkspaceAppSummary;
274
+ href: string | null;
275
+ }) {
276
+ const openInNewTab = isPendingBuilderHref(app);
277
+
278
+ if (!href) {
279
+ return (
280
+ <Button size="sm" variant="outline" disabled>
281
+ Open app
282
+ </Button>
283
+ );
284
+ }
285
+
286
+ return (
287
+ <AppOpenActions
288
+ name={app.name}
289
+ href={href}
290
+ target={openInNewTab ? "_blank" : undefined}
291
+ rel={openInNewTab ? "noreferrer" : undefined}
292
+ showInlineOption
293
+ showNewTabOption
294
+ />
295
+ );
296
+ }
297
+
298
+ type WorkspaceAppMetadataLabels = {
299
+ created: string;
300
+ createdBy: string;
301
+ owner: string;
302
+ teams: string;
303
+ notTracked: string;
304
+ };
305
+
306
+ function WorkspaceAppSettings({
307
+ app,
308
+ formatDate,
309
+ isArchived,
310
+ isPending,
311
+ onEdit,
312
+ onKeys,
313
+ onResources,
314
+ onArchive,
315
+ onUnarchive,
316
+ onRemovePending,
317
+ labels,
318
+ }: {
319
+ app: WorkspaceAppSummary;
320
+ formatDate: ReturnType<typeof useFormatters>["formatDate"];
321
+ isArchived: boolean;
322
+ isPending: boolean;
323
+ onEdit: () => void;
324
+ onKeys: () => void;
325
+ onResources: () => void;
326
+ onArchive: () => void;
327
+ onUnarchive: () => void;
328
+ onRemovePending: () => void;
329
+ labels: WorkspaceAppMetadataLabels;
330
+ }) {
331
+ return (
332
+ <DropdownMenu>
333
+ <Tooltip>
334
+ <TooltipTrigger asChild>
335
+ <DropdownMenuTrigger asChild>
336
+ <Button
337
+ type="button"
338
+ variant="ghost"
339
+ size="sm"
340
+ aria-label={`Settings for ${app.name}`}
341
+ className={APP_CARD_ACTION_CLASS}
342
+ >
343
+ <IconSettings size={15} />
344
+ </Button>
345
+ </DropdownMenuTrigger>
346
+ </TooltipTrigger>
347
+ <TooltipContent>App settings</TooltipContent>
348
+ </Tooltip>
349
+ <DropdownMenuContent align="end" className="w-72">
350
+ <DropdownMenuItem onSelect={onEdit}>
351
+ <IconEdit size={14} className="mr-2" />
352
+ Edit details
353
+ </DropdownMenuItem>
354
+ {!isPending && !isArchived ? (
355
+ <>
356
+ <DropdownMenuItem onSelect={onKeys}>
357
+ <IconKey size={14} className="mr-2" />
358
+ Manage keys
359
+ </DropdownMenuItem>
360
+ <DropdownMenuItem onSelect={onResources}>
361
+ <IconFileText size={14} className="mr-2" />
362
+ Agent resources
363
+ </DropdownMenuItem>
364
+ </>
365
+ ) : null}
366
+ {isPending ? (
367
+ <DropdownMenuItem
368
+ onSelect={onRemovePending}
369
+ className="text-destructive focus:text-destructive"
370
+ >
371
+ <IconTrash size={14} className="mr-2" />
372
+ Remove from list
373
+ </DropdownMenuItem>
374
+ ) : isArchived ? (
375
+ <DropdownMenuItem onSelect={onUnarchive}>
376
+ <IconEye size={14} className="mr-2" />
377
+ Restore to list
378
+ </DropdownMenuItem>
379
+ ) : (
380
+ <DropdownMenuItem onSelect={onArchive}>
381
+ <IconEyeOff size={14} className="mr-2" />
382
+ Hide from list
383
+ </DropdownMenuItem>
384
+ )}
385
+ <DropdownMenuSeparator />
386
+ <DropdownMenuLabel className="font-normal">
387
+ <WorkspaceAppMetadata
388
+ app={app}
389
+ formatDate={formatDate}
390
+ labels={labels}
391
+ />
392
+ </DropdownMenuLabel>
393
+ </DropdownMenuContent>
394
+ </DropdownMenu>
315
395
  );
316
396
  }
317
397
 
@@ -322,13 +402,7 @@ function WorkspaceAppMetadata({
322
402
  }: {
323
403
  app: WorkspaceAppSummary;
324
404
  formatDate: ReturnType<typeof useFormatters>["formatDate"];
325
- labels: {
326
- created: string;
327
- createdBy: string;
328
- owner: string;
329
- teams: string;
330
- notTracked: string;
331
- };
405
+ labels: WorkspaceAppMetadataLabels;
332
406
  }) {
333
407
  function formatMetadataDate(value: string | null | undefined): string {
334
408
  if (!value) return labels.notTracked;
@@ -389,8 +463,15 @@ function WorkspaceAppMetadataRow({
389
463
  );
390
464
  }
391
465
 
392
- function AppResourcesDialog({ app }: { app: WorkspaceAppSummary }) {
393
- const [open, setOpen] = useState(false);
466
+ function AppResourcesDialog({
467
+ app,
468
+ open,
469
+ onOpenChange,
470
+ }: {
471
+ app: WorkspaceAppSummary;
472
+ open: boolean;
473
+ onOpenChange: (open: boolean) => void;
474
+ }) {
394
475
  const [inspectedResourceId, setInspectedResourceId] = useState<string | null>(
395
476
  null,
396
477
  );
@@ -408,27 +489,10 @@ function AppResourcesDialog({ app }: { app: WorkspaceAppSummary }) {
408
489
  <Dialog
409
490
  open={open}
410
491
  onOpenChange={(nextOpen) => {
411
- setOpen(nextOpen);
492
+ onOpenChange(nextOpen);
412
493
  if (!nextOpen) setInspectedResourceId(null);
413
494
  }}
414
495
  >
415
- <Tooltip>
416
- <TooltipTrigger asChild>
417
- <DialogTrigger asChild>
418
- <Button
419
- type="button"
420
- variant="ghost"
421
- size="sm"
422
- aria-label={`View agent resources for ${app.name}`}
423
- className={APP_CARD_ACTION_CLASS}
424
- onClick={(e) => e.stopPropagation()}
425
- >
426
- <IconFileText size={14} />
427
- </Button>
428
- </DialogTrigger>
429
- </TooltipTrigger>
430
- <TooltipContent>View agent resources</TooltipContent>
431
- </Tooltip>
432
496
  <DialogContent className="max-w-2xl">
433
497
  <DialogHeader>
434
498
  <DialogTitle>{app.name} agent resources</DialogTitle>
@@ -546,7 +610,7 @@ function AppResourcesDialog({ app }: { app: WorkspaceAppSummary }) {
546
610
  )}
547
611
  </div>
548
612
  <DialogFooter>
549
- <Button type="button" onClick={() => setOpen(false)}>
613
+ <Button type="button" onClick={() => onOpenChange(false)}>
550
614
  Done
551
615
  </Button>
552
616
  </DialogFooter>
@@ -66,14 +66,23 @@ describe("WorkspaceTemplateCard", () => {
66
66
  vi.unstubAllGlobals();
67
67
  });
68
68
 
69
- it("shows template context and only links to a live app when supplied", async () => {
69
+ it("keeps template cards concise and moves setup context into the add flow", async () => {
70
70
  await act(async () => {
71
71
  root.render(<WorkspaceTemplateCard template={template} />);
72
72
  });
73
73
 
74
74
  expect(container.textContent).toContain("Weekly report");
75
- expect(container.textContent).toContain("RevOps");
76
- expect(container.textContent).toContain(
75
+ expect(container.textContent).not.toContain("RevOps");
76
+ expect(container.textContent).not.toContain(
77
+ "Connect your CRM after installing the app.",
78
+ );
79
+
80
+ const trigger = Array.from(container.querySelectorAll("button")).find(
81
+ (button) => button.textContent?.includes("Add app"),
82
+ );
83
+ expect(trigger).not.toBeUndefined();
84
+ await act(async () => trigger?.click());
85
+ expect(document.body.textContent).toContain(
77
86
  "Connect your CRM after installing the app.",
78
87
  );
79
88
 
@@ -103,7 +112,7 @@ describe("WorkspaceTemplateCard", () => {
103
112
  });
104
113
 
105
114
  const trigger = Array.from(container.querySelectorAll("button")).find(
106
- (button) => button.textContent?.includes("Create from template"),
115
+ (button) => button.textContent?.includes("Add app"),
107
116
  );
108
117
  expect(trigger).not.toBeUndefined();
109
118
 
@@ -144,6 +153,35 @@ describe("WorkspaceTemplateCard", () => {
144
153
  );
145
154
  });
146
155
 
156
+ it("keeps add app inside the catalog split-button menu", async () => {
157
+ await act(async () => {
158
+ root.render(<WorkspaceTemplateCard template={template} catalog />);
159
+ });
160
+
161
+ expect(
162
+ Array.from(container.querySelectorAll("button")).some((button) =>
163
+ button.textContent?.includes("Add app"),
164
+ ),
165
+ ).toBe(false);
166
+
167
+ const optionsButton = container.querySelector<HTMLButtonElement>(
168
+ 'button[aria-label="Open options for Weekly report"]',
169
+ );
170
+ expect(optionsButton).not.toBeNull();
171
+ await act(async () => {
172
+ optionsButton?.dispatchEvent(
173
+ new MouseEvent("pointerdown", { bubbles: true, button: 0 }),
174
+ );
175
+ });
176
+
177
+ const addItem = Array.from(
178
+ document.querySelectorAll<HTMLElement>('[role="menuitem"]'),
179
+ ).find((item) => item.textContent?.includes("Add app"));
180
+ expect(addItem).not.toBeUndefined();
181
+ await act(async () => addItem?.click());
182
+ expect(document.body.textContent).toContain("Choose the URL-safe id");
183
+ });
184
+
147
185
  it("accepts the list action envelope and renders installed state", async () => {
148
186
  await act(async () => {
149
187
  root.render(
@@ -166,9 +204,9 @@ describe("WorkspaceTemplateCard", () => {
166
204
  expect(container.textContent).toContain("Curated templates");
167
205
  expect(container.textContent).toContain("Installed");
168
206
  expect(container.querySelector('a[href^="https://"]')).toBeNull();
169
- const remixButton = Array.from(container.querySelectorAll("button")).find(
170
- (button) => button.textContent?.includes("Create from template"),
207
+ const addButton = Array.from(container.querySelectorAll("button")).find(
208
+ (button) => button.textContent?.includes("Add app"),
171
209
  );
172
- expect(remixButton).not.toHaveProperty("disabled", true);
210
+ expect(addButton).not.toHaveProperty("disabled", true);
173
211
  });
174
212
  });