@agent-native/dispatch 0.24.0 → 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 (161) hide show
  1. package/dist/actions/provider-api-register.d.ts +12 -12
  2. package/dist/components/admin-navigation.d.ts.map +1 -1
  3. package/dist/components/admin-navigation.js +21 -5
  4. package/dist/components/admin-navigation.js.map +1 -1
  5. package/dist/components/app-list-row.d.ts +1 -1
  6. package/dist/components/app-list-row.d.ts.map +1 -1
  7. package/dist/components/app-list-row.js +1 -1
  8. package/dist/components/app-list-row.js.map +1 -1
  9. package/dist/components/app-open-actions.d.ts +1 -17
  10. package/dist/components/app-open-actions.d.ts.map +1 -1
  11. package/dist/components/app-open-actions.js +1 -20
  12. package/dist/components/app-open-actions.js.map +1 -1
  13. package/dist/components/app-row-settings.d.ts +6 -0
  14. package/dist/components/app-row-settings.d.ts.map +1 -0
  15. package/dist/components/app-row-settings.js +11 -0
  16. package/dist/components/app-row-settings.js.map +1 -0
  17. package/dist/components/automation-run-history-dialog.d.ts.map +1 -1
  18. package/dist/components/automation-run-history-dialog.js +3 -2
  19. package/dist/components/automation-run-history-dialog.js.map +1 -1
  20. package/dist/components/connected-app-card.d.ts.map +1 -1
  21. package/dist/components/connected-app-card.js +1 -1
  22. package/dist/components/connected-app-card.js.map +1 -1
  23. package/dist/components/create-app-popover.js +2 -2
  24. package/dist/components/create-app-popover.js.map +1 -1
  25. package/dist/components/dispatch-control-plane.d.ts.map +1 -1
  26. package/dist/components/dispatch-control-plane.js +23 -7
  27. package/dist/components/dispatch-control-plane.js.map +1 -1
  28. package/dist/components/dispatch-shell.d.ts.map +1 -1
  29. package/dist/components/dispatch-shell.js +5 -1
  30. package/dist/components/dispatch-shell.js.map +1 -1
  31. package/dist/components/docs-link.d.ts +16 -0
  32. package/dist/components/docs-link.d.ts.map +1 -0
  33. package/dist/components/docs-link.js +34 -0
  34. package/dist/components/docs-link.js.map +1 -0
  35. package/dist/components/layout/Layout.d.ts +1 -0
  36. package/dist/components/layout/Layout.d.ts.map +1 -1
  37. package/dist/components/layout/Layout.js +37 -28
  38. package/dist/components/layout/Layout.js.map +1 -1
  39. package/dist/components/messaging-setup-panel.d.ts.map +1 -1
  40. package/dist/components/messaging-setup-panel.js +3 -2
  41. package/dist/components/messaging-setup-panel.js.map +1 -1
  42. package/dist/components/usage-alerts-panel.d.ts +11 -0
  43. package/dist/components/usage-alerts-panel.d.ts.map +1 -0
  44. package/dist/components/usage-alerts-panel.js +125 -0
  45. package/dist/components/usage-alerts-panel.js.map +1 -0
  46. package/dist/components/workspace-app-card.d.ts +3 -1
  47. package/dist/components/workspace-app-card.d.ts.map +1 -1
  48. package/dist/components/workspace-app-card.js +70 -29
  49. package/dist/components/workspace-app-card.js.map +1 -1
  50. package/dist/components/workspace-app-search.d.ts +10 -0
  51. package/dist/components/workspace-app-search.d.ts.map +1 -0
  52. package/dist/components/workspace-app-search.js +27 -0
  53. package/dist/components/workspace-app-search.js.map +1 -0
  54. package/dist/components/workspace-resource-effective-stack.d.ts.map +1 -1
  55. package/dist/components/workspace-resource-effective-stack.js +3 -2
  56. package/dist/components/workspace-resource-effective-stack.js.map +1 -1
  57. package/dist/components/workspace-template-card.d.ts.map +1 -1
  58. package/dist/components/workspace-template-card.js +2 -1
  59. package/dist/components/workspace-template-card.js.map +1 -1
  60. package/dist/lib/workspace-app-layout.d.ts +19 -0
  61. package/dist/lib/workspace-app-layout.d.ts.map +1 -0
  62. package/dist/lib/workspace-app-layout.js +75 -0
  63. package/dist/lib/workspace-app-layout.js.map +1 -0
  64. package/dist/lib/workspace-apps.d.ts +10 -0
  65. package/dist/lib/workspace-apps.d.ts.map +1 -1
  66. package/dist/lib/workspace-apps.js +59 -0
  67. package/dist/lib/workspace-apps.js.map +1 -1
  68. package/dist/lib/workspace-apps.test.js +18 -1
  69. package/dist/lib/workspace-apps.test.js.map +1 -1
  70. package/dist/routes/pages/$appId.d.ts.map +1 -1
  71. package/dist/routes/pages/$appId.js +4 -2
  72. package/dist/routes/pages/$appId.js.map +1 -1
  73. package/dist/routes/pages/agents.d.ts.map +1 -1
  74. package/dist/routes/pages/agents.js +2 -3
  75. package/dist/routes/pages/agents.js.map +1 -1
  76. package/dist/routes/pages/apps.$appId.d.ts.map +1 -1
  77. package/dist/routes/pages/apps.$appId.js +8 -3
  78. package/dist/routes/pages/apps.$appId.js.map +1 -1
  79. package/dist/routes/pages/apps.d.ts.map +1 -1
  80. package/dist/routes/pages/apps.js +31 -16
  81. package/dist/routes/pages/apps.js.map +1 -1
  82. package/dist/routes/pages/audit.d.ts.map +1 -1
  83. package/dist/routes/pages/audit.js +2 -1
  84. package/dist/routes/pages/audit.js.map +1 -1
  85. package/dist/routes/pages/dreams.d.ts.map +1 -1
  86. package/dist/routes/pages/dreams.js +14 -18
  87. package/dist/routes/pages/dreams.js.map +1 -1
  88. package/dist/routes/pages/integrations.d.ts.map +1 -1
  89. package/dist/routes/pages/integrations.js +51 -14
  90. package/dist/routes/pages/integrations.js.map +1 -1
  91. package/dist/routes/pages/metrics.d.ts.map +1 -1
  92. package/dist/routes/pages/metrics.js +4 -1
  93. package/dist/routes/pages/metrics.js.map +1 -1
  94. package/dist/routes/pages/operations.js +1 -1
  95. package/dist/routes/pages/operations.js.map +1 -1
  96. package/dist/routes/pages/settings.d.ts.map +1 -1
  97. package/dist/routes/pages/settings.js +7 -2
  98. package/dist/routes/pages/settings.js.map +1 -1
  99. package/dist/routes/pages/thread-debug.d.ts.map +1 -1
  100. package/dist/routes/pages/thread-debug.js +5 -8
  101. package/dist/routes/pages/thread-debug.js.map +1 -1
  102. package/dist/routes/pages/vault.d.ts.map +1 -1
  103. package/dist/routes/pages/vault.js +1 -1
  104. package/dist/routes/pages/vault.js.map +1 -1
  105. package/dist/routes/pages/workspace.d.ts.map +1 -1
  106. package/dist/routes/pages/workspace.js +13 -10
  107. package/dist/routes/pages/workspace.js.map +1 -1
  108. package/dist/server/index.d.ts.map +1 -1
  109. package/dist/server/index.js +3 -0
  110. package/dist/server/index.js.map +1 -1
  111. package/dist/server/lib/app-creation-store.d.ts.map +1 -1
  112. package/dist/server/lib/app-creation-store.js +12 -52
  113. package/dist/server/lib/app-creation-store.js.map +1 -1
  114. package/dist/shared/app-roles.d.ts +9 -0
  115. package/dist/shared/app-roles.d.ts.map +1 -0
  116. package/dist/shared/app-roles.js +7 -0
  117. package/dist/shared/app-roles.js.map +1 -0
  118. package/package.json +4 -3
  119. package/src/components/admin-navigation.tsx +57 -11
  120. package/src/components/app-list-row.tsx +1 -2
  121. package/src/components/app-open-actions.tsx +5 -114
  122. package/src/components/app-row-settings.tsx +54 -0
  123. package/src/components/automation-run-history-dialog.tsx +8 -4
  124. package/src/components/connected-app-card.tsx +0 -1
  125. package/src/components/create-app-popover.spec.tsx +1 -1
  126. package/src/components/create-app-popover.tsx +2 -2
  127. package/src/components/dispatch-control-plane.spec.tsx +36 -2
  128. package/src/components/dispatch-control-plane.tsx +83 -27
  129. package/src/components/dispatch-shell.tsx +7 -1
  130. package/src/components/docs-link.tsx +52 -0
  131. package/src/components/layout/Layout.spec.tsx +10 -0
  132. package/src/components/layout/Layout.tsx +50 -23
  133. package/src/components/messaging-setup-panel.tsx +10 -3
  134. package/src/components/usage-alerts-panel.tsx +445 -0
  135. package/src/components/workspace-app-card.spec.tsx +130 -9
  136. package/src/components/workspace-app-card.tsx +128 -71
  137. package/src/components/workspace-app-search.tsx +96 -0
  138. package/src/components/workspace-resource-effective-stack.tsx +10 -5
  139. package/src/components/workspace-template-card.spec.tsx +52 -23
  140. package/src/components/workspace-template-card.tsx +7 -10
  141. package/src/lib/workspace-app-layout.spec.ts +47 -0
  142. package/src/lib/workspace-app-layout.ts +121 -0
  143. package/src/lib/workspace-apps.test.ts +45 -1
  144. package/src/lib/workspace-apps.ts +72 -0
  145. package/src/routes/pages/$appId.tsx +3 -6
  146. package/src/routes/pages/agents.tsx +6 -3
  147. package/src/routes/pages/apps.$appId.tsx +6 -6
  148. package/src/routes/pages/apps.tsx +116 -62
  149. package/src/routes/pages/audit.tsx +28 -8
  150. package/src/routes/pages/dreams.tsx +92 -142
  151. package/src/routes/pages/integrations.tsx +147 -68
  152. package/src/routes/pages/metrics.tsx +8 -0
  153. package/src/routes/pages/operations.tsx +2 -2
  154. package/src/routes/pages/settings.tsx +14 -9
  155. package/src/routes/pages/thread-debug.tsx +18 -36
  156. package/src/routes/pages/vault.tsx +7 -2
  157. package/src/routes/pages/workspace.tsx +92 -56
  158. package/src/server/index.ts +4 -0
  159. package/src/server/lib/app-creation-store.spec.ts +90 -43
  160. package/src/server/lib/app-creation-store.ts +15 -61
  161. package/src/shared/app-roles.ts +8 -0
@@ -1,10 +1,10 @@
1
+ import { APP_ACTION_MENU_CONTENT_CLASS } from "@agent-native/core/client/chat-first";
1
2
  import {
2
3
  useActionMutation,
3
4
  useActionQuery,
4
5
  } from "@agent-native/core/client/hooks";
5
- import { useFormatters, useT } from "@agent-native/core/client/i18n";
6
+ import { useT } from "@agent-native/core/client/i18n";
6
7
  import {
7
- IconCalendar,
8
8
  IconChevronDown,
9
9
  IconChevronRight,
10
10
  IconClockHour4,
@@ -13,6 +13,7 @@ import {
13
13
  IconEyeOff,
14
14
  IconFileText,
15
15
  IconKey,
16
+ IconPin,
16
17
  IconSettings,
17
18
  IconTrash,
18
19
  IconUser,
@@ -64,15 +65,21 @@ const APP_CARD_ACTION_CLASS =
64
65
  export function WorkspaceAppCard({
65
66
  app,
66
67
  className,
68
+ isPinned = false,
69
+ onTogglePinned,
67
70
  }: {
68
71
  app: WorkspaceAppSummary;
69
72
  className?: string;
73
+ isPinned?: boolean;
74
+ onTogglePinned?: () => void;
70
75
  }) {
71
76
  const t = useT();
72
- const { formatDate } = useFormatters();
73
77
  const href = workspaceAppHref(app);
74
78
  const isPending = app.status === "pending";
75
79
  const pendingLabel = app.statusLabel || "Builder branch";
80
+ const pendingOpenLabel = t("dispatch.pages.openBuilderBranch", {
81
+ defaultValue: "Open in Builder",
82
+ });
76
83
  const isArchived = !!app.archived;
77
84
  const audience = app.audience ?? "internal";
78
85
  const [editOpen, setEditOpen] = useState(false);
@@ -135,6 +142,13 @@ export function WorkspaceAppCard({
135
142
  description: draftDescription.trim(),
136
143
  });
137
144
  };
145
+ const pinLabel = t(
146
+ isPinned ? "dispatch.pages.unpinApp" : "dispatch.pages.pinApp",
147
+ {
148
+ name: app.name,
149
+ defaultValue: `${isPinned ? "Unpin" : "Pin"} ${app.name}`,
150
+ },
151
+ );
138
152
 
139
153
  return (
140
154
  <>
@@ -174,16 +188,19 @@ export function WorkspaceAppCard({
174
188
  Public
175
189
  </span>
176
190
  ) : null}
177
- {isPending && app.branchName ? (
178
- <span className="min-w-0 truncate">{app.branchName}</span>
179
- ) : null}
180
191
  </div>
181
192
  </div>
182
193
  <div className="flex shrink-0 items-center gap-2">
183
- <WorkspaceAppOpenActions app={app} href={href} />
194
+ <WorkspaceAppOpenActions
195
+ app={app}
196
+ href={href}
197
+ isPinned={isPinned}
198
+ pinLabel={pinLabel}
199
+ pendingOpenLabel={pendingOpenLabel}
200
+ onTogglePinned={onTogglePinned}
201
+ />
184
202
  <WorkspaceAppSettings
185
203
  app={app}
186
- formatDate={formatDate}
187
204
  isArchived={isArchived}
188
205
  isPending={isPending}
189
206
  onEdit={() => setEditOpen(true)}
@@ -193,11 +210,9 @@ export function WorkspaceAppCard({
193
210
  onUnarchive={handleUnarchive}
194
211
  onRemovePending={handleRemovePending}
195
212
  labels={{
196
- created: t("agents.dashboardMetadataCreated"),
197
213
  createdBy: t("agents.dashboardMetadataCreatedBy"),
198
214
  owner: t("dispatch.pages.appMetadataOwner"),
199
215
  teams: t("dispatch.pages.appMetadataTeams"),
200
- notTracked: t("agents.notTracked"),
201
216
  }}
202
217
  />
203
218
  </div>
@@ -270,12 +285,35 @@ export function WorkspaceAppCard({
270
285
  function WorkspaceAppOpenActions({
271
286
  app,
272
287
  href,
288
+ isPinned,
289
+ pinLabel,
290
+ pendingOpenLabel,
291
+ onTogglePinned,
273
292
  }: {
274
293
  app: WorkspaceAppSummary;
275
294
  href: string | null;
295
+ isPinned: boolean;
296
+ pinLabel: string;
297
+ pendingOpenLabel: string;
298
+ onTogglePinned?: () => void;
276
299
  }) {
277
300
  const navigate = useNavigate();
278
301
  const appRoute = workspaceAppRoute(app.id);
302
+ const isPending = app.status === "pending";
303
+
304
+ if (isPending) {
305
+ return href ? (
306
+ <Button asChild size="sm" variant="outline">
307
+ <a href={href} target="_blank" rel="noreferrer">
308
+ {pendingOpenLabel}
309
+ </a>
310
+ </Button>
311
+ ) : (
312
+ <Button size="sm" variant="outline" disabled>
313
+ {pendingOpenLabel}
314
+ </Button>
315
+ );
316
+ }
279
317
 
280
318
  if (!href) {
281
319
  return (
@@ -289,27 +327,34 @@ function WorkspaceAppOpenActions({
289
327
  <AppOpenActions
290
328
  name={app.name}
291
329
  href={href}
292
- target="_blank"
293
- rel="noreferrer"
294
- showInlineOption
295
- onOpenInline={() => {
330
+ showNewTabOption
331
+ onOpen={() => {
296
332
  navigate(appRoute);
297
333
  }}
334
+ menuItems={
335
+ onTogglePinned
336
+ ? [
337
+ {
338
+ id: "pin",
339
+ label: pinLabel,
340
+ icon: <IconPin size={14} strokeWidth={isPinned ? 2.2 : 1.7} />,
341
+ onSelect: onTogglePinned,
342
+ },
343
+ ]
344
+ : undefined
345
+ }
298
346
  />
299
347
  );
300
348
  }
301
349
 
302
350
  type WorkspaceAppMetadataLabels = {
303
- created: string;
304
351
  createdBy: string;
305
352
  owner: string;
306
353
  teams: string;
307
- notTracked: string;
308
354
  };
309
355
 
310
356
  function WorkspaceAppSettings({
311
357
  app,
312
- formatDate,
313
358
  isArchived,
314
359
  isPending,
315
360
  onEdit,
@@ -321,7 +366,6 @@ function WorkspaceAppSettings({
321
366
  labels,
322
367
  }: {
323
368
  app: WorkspaceAppSummary;
324
- formatDate: ReturnType<typeof useFormatters>["formatDate"];
325
369
  isArchived: boolean;
326
370
  isPending: boolean;
327
371
  onEdit: () => void;
@@ -350,19 +394,22 @@ function WorkspaceAppSettings({
350
394
  </TooltipTrigger>
351
395
  <TooltipContent>App settings</TooltipContent>
352
396
  </Tooltip>
353
- <DropdownMenuContent align="end" className="w-72">
397
+ <DropdownMenuContent
398
+ align="end"
399
+ className={APP_ACTION_MENU_CONTENT_CLASS}
400
+ >
354
401
  <DropdownMenuItem onSelect={onEdit}>
355
- <IconEdit size={14} className="mr-2" />
402
+ <IconEdit size={14} aria-hidden="true" />
356
403
  Edit details
357
404
  </DropdownMenuItem>
358
405
  {!isPending && !isArchived ? (
359
406
  <>
360
407
  <DropdownMenuItem onSelect={onKeys}>
361
- <IconKey size={14} className="mr-2" />
408
+ <IconKey size={14} aria-hidden="true" />
362
409
  Manage keys
363
410
  </DropdownMenuItem>
364
411
  <DropdownMenuItem onSelect={onResources}>
365
- <IconFileText size={14} className="mr-2" />
412
+ <IconFileText size={14} aria-hidden="true" />
366
413
  Agent resources
367
414
  </DropdownMenuItem>
368
415
  </>
@@ -372,28 +419,21 @@ function WorkspaceAppSettings({
372
419
  onSelect={onRemovePending}
373
420
  className="text-destructive focus:text-destructive"
374
421
  >
375
- <IconTrash size={14} className="mr-2" />
422
+ <IconTrash size={14} aria-hidden="true" />
376
423
  Remove from list
377
424
  </DropdownMenuItem>
378
425
  ) : isArchived ? (
379
426
  <DropdownMenuItem onSelect={onUnarchive}>
380
- <IconEye size={14} className="mr-2" />
427
+ <IconEye size={14} aria-hidden="true" />
381
428
  Restore to list
382
429
  </DropdownMenuItem>
383
430
  ) : (
384
431
  <DropdownMenuItem onSelect={onArchive}>
385
- <IconEyeOff size={14} className="mr-2" />
432
+ <IconEyeOff size={14} aria-hidden="true" />
386
433
  Hide from list
387
434
  </DropdownMenuItem>
388
435
  )}
389
- <DropdownMenuSeparator />
390
- <DropdownMenuLabel className="font-normal">
391
- <WorkspaceAppMetadata
392
- app={app}
393
- formatDate={formatDate}
394
- labels={labels}
395
- />
396
- </DropdownMenuLabel>
436
+ <WorkspaceAppMetadata app={app} labels={labels} />
397
437
  </DropdownMenuContent>
398
438
  </DropdownMenu>
399
439
  );
@@ -401,51 +441,68 @@ function WorkspaceAppSettings({
401
441
 
402
442
  function WorkspaceAppMetadata({
403
443
  app,
404
- formatDate,
405
444
  labels,
406
445
  }: {
407
446
  app: WorkspaceAppSummary;
408
- formatDate: ReturnType<typeof useFormatters>["formatDate"];
409
447
  labels: WorkspaceAppMetadataLabels;
410
448
  }) {
411
- function formatMetadataDate(value: string | null | undefined): string {
412
- if (!value) return labels.notTracked;
413
- try {
414
- return formatDate(value, {
415
- year: "numeric",
416
- month: "short",
417
- day: "numeric",
418
- hour: "numeric",
419
- minute: "2-digit",
420
- });
421
- } catch {
422
- return value;
423
- }
449
+ const trackedText = (value: string | null | undefined): string | null => {
450
+ const normalized = value?.trim();
451
+ return normalized && normalized.toLowerCase() !== "not tracked"
452
+ ? normalized
453
+ : null;
454
+ };
455
+ const rows: Array<{
456
+ id: string;
457
+ icon: typeof IconUser;
458
+ label: string;
459
+ value: string;
460
+ }> = [];
461
+
462
+ const createdBy = trackedText(app.createdBy);
463
+ if (createdBy) {
464
+ rows.push({
465
+ id: "created-by",
466
+ icon: IconUser,
467
+ label: labels.createdBy,
468
+ value: createdBy,
469
+ });
470
+ }
471
+ const owner = trackedText(app.owner);
472
+ if (owner) {
473
+ rows.push({
474
+ id: "owner",
475
+ icon: IconUser,
476
+ label: labels.owner,
477
+ value: owner,
478
+ });
479
+ }
480
+ const teams = app.teams
481
+ ?.map(trackedText)
482
+ .filter((team): team is string => Boolean(team))
483
+ .join(", ");
484
+ if (teams) {
485
+ rows.push({
486
+ id: "teams",
487
+ icon: IconUsersGroup,
488
+ label: labels.teams,
489
+ value: teams,
490
+ });
424
491
  }
425
492
 
493
+ if (rows.length === 0) return null;
494
+
426
495
  return (
427
- <div className="flex flex-col gap-1.5 text-xs text-muted-foreground">
428
- <WorkspaceAppMetadataRow
429
- icon={IconCalendar}
430
- label={labels.created}
431
- value={formatMetadataDate(app.createdAt)}
432
- />
433
- <WorkspaceAppMetadataRow
434
- icon={IconUser}
435
- label={labels.createdBy}
436
- value={app.createdBy || labels.notTracked}
437
- />
438
- <WorkspaceAppMetadataRow
439
- icon={IconUser}
440
- label={labels.owner}
441
- value={app.owner || labels.notTracked}
442
- />
443
- <WorkspaceAppMetadataRow
444
- icon={IconUsersGroup}
445
- label={labels.teams}
446
- value={app.teams?.join(", ") || labels.notTracked}
447
- />
448
- </div>
496
+ <>
497
+ <DropdownMenuSeparator />
498
+ <DropdownMenuLabel className="font-normal">
499
+ <div className="flex flex-col gap-1.5 text-xs text-muted-foreground">
500
+ {rows.map((row) => (
501
+ <WorkspaceAppMetadataRow key={row.id} {...row} />
502
+ ))}
503
+ </div>
504
+ </DropdownMenuLabel>
505
+ </>
449
506
  );
450
507
  }
451
508
 
@@ -454,7 +511,7 @@ function WorkspaceAppMetadataRow({
454
511
  label,
455
512
  value,
456
513
  }: {
457
- icon: typeof IconCalendar;
514
+ icon: typeof IconUser;
458
515
  label: string;
459
516
  value: string;
460
517
  }) {
@@ -0,0 +1,96 @@
1
+ import { useT } from "@agent-native/core/client/i18n";
2
+ import { IconSearch, IconX } from "@tabler/icons-react";
3
+ import { useId } from "react";
4
+
5
+ import { cn } from "../lib/utils";
6
+ import { Button } from "./ui/button";
7
+ import { Input } from "./ui/input";
8
+
9
+ export function WorkspaceAppSearch({
10
+ query,
11
+ onQueryChange,
12
+ className,
13
+ }: {
14
+ query: string;
15
+ onQueryChange: (query: string) => void;
16
+ className?: string;
17
+ }) {
18
+ const t = useT();
19
+ const inputId = useId();
20
+ const label = t("dispatch.pages.searchApps", {
21
+ defaultValue: "Search apps",
22
+ });
23
+ const placeholder = t("dispatch.pages.searchAppsPlaceholder", {
24
+ defaultValue: "Search by name or description",
25
+ });
26
+
27
+ return (
28
+ <div role="search" className={cn("flex items-center gap-2", className)}>
29
+ <div className="relative min-w-0 flex-1">
30
+ <label htmlFor={inputId} className="sr-only">
31
+ {label}
32
+ </label>
33
+ <IconSearch
34
+ size={16}
35
+ aria-hidden="true"
36
+ className="pointer-events-none absolute start-3 top-1/2 -translate-y-1/2 text-muted-foreground"
37
+ />
38
+ <Input
39
+ id={inputId}
40
+ value={query}
41
+ onChange={(event) => onQueryChange(event.target.value)}
42
+ placeholder={placeholder}
43
+ className="h-9 bg-background ps-9"
44
+ autoComplete="off"
45
+ />
46
+ </div>
47
+ {query ? (
48
+ <Button
49
+ type="button"
50
+ variant="ghost"
51
+ size="sm"
52
+ className="shrink-0 gap-1.5 text-muted-foreground"
53
+ onClick={() => onQueryChange("")}
54
+ >
55
+ <IconX size={14} aria-hidden="true" />
56
+ <span>
57
+ {t("dispatch.pages.clearAppSearch", {
58
+ defaultValue: "Clear",
59
+ })}
60
+ </span>
61
+ </Button>
62
+ ) : null}
63
+ </div>
64
+ );
65
+ }
66
+
67
+ export function WorkspaceAppSearchEmpty({
68
+ query,
69
+ onClear,
70
+ }: {
71
+ query: string;
72
+ onClear: () => void;
73
+ }) {
74
+ const t = useT();
75
+ return (
76
+ <div className="rounded-lg border border-dashed bg-card px-4 py-8 text-center">
77
+ <p className="text-sm font-medium text-foreground">
78
+ {t("dispatch.pages.noAppsMatch", {
79
+ defaultValue: "No apps match your search",
80
+ })}
81
+ </p>
82
+ <p className="mx-auto mt-1 max-w-md truncate text-xs text-muted-foreground">
83
+ “{query}”
84
+ </p>
85
+ <Button
86
+ type="button"
87
+ variant="outline"
88
+ size="sm"
89
+ className="mt-4"
90
+ onClick={onClear}
91
+ >
92
+ {t("dispatch.pages.clearAppSearch", { defaultValue: "Clear search" })}
93
+ </Button>
94
+ </div>
95
+ );
96
+ }
@@ -3,6 +3,7 @@ import { useActionQuery } from "@agent-native/core/client/hooks";
3
3
  import { cn } from "../lib/utils";
4
4
  import { ActionQueryError } from "./action-query-error";
5
5
  import { Badge } from "./ui/badge";
6
+ import { Skeleton } from "./ui/skeleton";
6
7
 
7
8
  export function appAvailabilityLabel(value?: string) {
8
9
  switch (value) {
@@ -68,11 +69,11 @@ export function AppResourceEffectiveStack({
68
69
  if (isLoading && !context) {
69
70
  return (
70
71
  <div className="mt-3 rounded-lg border bg-muted/20 p-3">
71
- <div className="h-3 w-44 animate-pulse rounded bg-muted-foreground/10" />
72
+ <Skeleton className="h-3 w-44" />
72
73
  <div className="mt-3 grid gap-2 sm:grid-cols-3">
73
- <div className="h-20 animate-pulse rounded-md bg-muted-foreground/10" />
74
- <div className="h-20 animate-pulse rounded-md bg-muted-foreground/10" />
75
- <div className="h-20 animate-pulse rounded-md bg-muted-foreground/10" />
74
+ <Skeleton className="h-20 rounded-md" />
75
+ <Skeleton className="h-20 rounded-md" />
76
+ <Skeleton className="h-20 rounded-md" />
76
77
  </div>
77
78
  </div>
78
79
  );
@@ -99,7 +100,11 @@ export function AppResourceEffectiveStack({
99
100
  {resource.path}
100
101
  </div>
101
102
  </div>
102
- <Badge variant="outline">{appAvailabilityLabel(availability)}</Badge>
103
+ {isLoading ? (
104
+ <Skeleton className="h-6 w-24 rounded-full" />
105
+ ) : (
106
+ <Badge variant="outline">{appAvailabilityLabel(availability)}</Badge>
107
+ )}
103
108
  </div>
104
109
 
105
110
  <div className="mt-3 grid gap-2 sm:grid-cols-3">
@@ -3,6 +3,7 @@ import React, { act } from "react";
3
3
  import { createRoot, type Root } from "react-dom/client";
4
4
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
5
 
6
+ import { TooltipProvider } from "./ui/tooltip";
6
7
  import {
7
8
  WorkspaceTemplateCard,
8
9
  WorkspaceTemplatesSection,
@@ -68,7 +69,11 @@ describe("WorkspaceTemplateCard", () => {
68
69
 
69
70
  it("keeps template cards concise and moves setup context into the add flow", async () => {
70
71
  await act(async () => {
71
- root.render(<WorkspaceTemplateCard template={template} />);
72
+ root.render(
73
+ <TooltipProvider>
74
+ <WorkspaceTemplateCard template={template} />
75
+ </TooltipProvider>,
76
+ );
72
77
  });
73
78
 
74
79
  expect(container.textContent).toContain("Weekly report");
@@ -94,9 +99,11 @@ describe("WorkspaceTemplateCard", () => {
94
99
 
95
100
  await act(async () => {
96
101
  root.render(
97
- <WorkspaceTemplateCard
98
- template={{ ...template, liveUrl: null, productUrl: null }}
99
- />,
102
+ <TooltipProvider>
103
+ <WorkspaceTemplateCard
104
+ template={{ ...template, liveUrl: null, productUrl: null }}
105
+ />
106
+ </TooltipProvider>,
100
107
  );
101
108
  });
102
109
  expect(
@@ -107,7 +114,9 @@ describe("WorkspaceTemplateCard", () => {
107
114
  it("creates an app with the default app id and allows an override", async () => {
108
115
  await act(async () => {
109
116
  root.render(
110
- <WorkspaceTemplateCard template={template} defaultAppId="pipeline" />,
117
+ <TooltipProvider>
118
+ <WorkspaceTemplateCard template={template} defaultAppId="pipeline" />
119
+ </TooltipProvider>,
111
120
  );
112
121
  });
113
122
 
@@ -155,14 +164,32 @@ describe("WorkspaceTemplateCard", () => {
155
164
 
156
165
  it("keeps add app available in catalog cards", async () => {
157
166
  await act(async () => {
158
- root.render(<WorkspaceTemplateCard template={template} catalog />);
167
+ root.render(
168
+ <TooltipProvider>
169
+ <WorkspaceTemplateCard template={template} catalog />
170
+ </TooltipProvider>,
171
+ );
159
172
  });
160
173
 
161
- const addButton = Array.from(container.querySelectorAll("button")).find(
162
- (button) => button.textContent?.includes("Add app"),
174
+ const settingsButton = container.querySelector<HTMLButtonElement>(
175
+ 'button[aria-label="Settings for Weekly report"]',
163
176
  );
164
- expect(addButton).toBeDefined();
165
- await act(async () => addButton?.click());
177
+ expect(settingsButton).not.toBeNull();
178
+ await act(async () => {
179
+ settingsButton?.dispatchEvent(
180
+ new MouseEvent("pointerdown", { bubbles: true, button: 0 }),
181
+ );
182
+ });
183
+ const addItem = Array.from(
184
+ document.querySelectorAll<HTMLElement>('[role="menuitem"]'),
185
+ ).find((item) => item.textContent?.includes("Add app"));
186
+ expect(addItem).not.toBeUndefined();
187
+ const settingsMenu = document.querySelector<HTMLElement>('[role="menu"]');
188
+ expect(settingsMenu?.className).toContain("w-48");
189
+ expect(settingsMenu?.className).toContain("bg-popover");
190
+ expect(settingsMenu?.className).toContain("shadow-md");
191
+ expect(addItem?.querySelector("svg")).not.toBeNull();
192
+ await act(async () => addItem?.click());
166
193
  expect(document.body.textContent).toContain("Choose the URL-safe id");
167
194
  expect(
168
195
  container.querySelector('a[href="https://reports.example.test"]'),
@@ -172,19 +199,21 @@ describe("WorkspaceTemplateCard", () => {
172
199
  it("accepts the list action envelope and renders installed state", async () => {
173
200
  await act(async () => {
174
201
  root.render(
175
- <WorkspaceTemplatesSection
176
- templates={{
177
- templates: [
178
- {
179
- ...template,
180
- installed: true,
181
- liveUrl: null,
182
- productUrl: null,
183
- },
184
- ],
185
- }}
186
- title="Curated templates"
187
- />,
202
+ <TooltipProvider>
203
+ <WorkspaceTemplatesSection
204
+ templates={{
205
+ templates: [
206
+ {
207
+ ...template,
208
+ installed: true,
209
+ liveUrl: null,
210
+ productUrl: null,
211
+ },
212
+ ],
213
+ }}
214
+ title="Curated templates"
215
+ />
216
+ </TooltipProvider>,
188
217
  );
189
218
  });
190
219
 
@@ -13,6 +13,7 @@ import { cn } from "../lib/utils";
13
13
  import { AppIcon } from "./app-icon";
14
14
  import { AppListRow } from "./app-list-row";
15
15
  import { AppOpenActions } from "./app-open-actions";
16
+ import { AppRowSettings } from "./app-row-settings";
16
17
  import { Button } from "./ui/button";
17
18
  import {
18
19
  Dialog,
@@ -211,20 +212,16 @@ export function WorkspaceTemplateCard({
211
212
  <AppOpenActions
212
213
  name={template.name}
213
214
  href={liveUrl}
214
- target="_blank"
215
215
  rel="noreferrer"
216
216
  labels={{ openApp: labels.openApp }}
217
+ showNewTabOption
217
218
  />
218
219
  ) : null}
219
- <Button
220
- type="button"
221
- variant="outline"
222
- size="sm"
223
- onClick={() => setOpen(true)}
224
- >
225
- <IconPlus />
226
- {labels.remix}
227
- </Button>
220
+ <AppRowSettings
221
+ name={template.name}
222
+ addAppLabel={labels.remix}
223
+ onAddApp={() => setOpen(true)}
224
+ />
228
225
  </>
229
226
  ) : liveUrl ? (
230
227
  <Button variant="ghost" size="sm" className="shrink-0" asChild>
@@ -0,0 +1,47 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import {
4
+ normalizeWorkspaceAppLayout,
5
+ orderWorkspaceApps,
6
+ toggleWorkspaceAppPinned,
7
+ workspaceAppMatchesQuery,
8
+ } from "./workspace-app-layout";
9
+
10
+ describe("workspace app layout", () => {
11
+ it("keeps pinned apps first without changing the remaining catalog order", () => {
12
+ const apps = [
13
+ { id: "mail", name: "Mail" },
14
+ { id: "analytics", name: "Analytics" },
15
+ { id: "calendar", name: "Calendar" },
16
+ ];
17
+
18
+ expect(
19
+ orderWorkspaceApps(apps, {
20
+ pinnedIds: ["calendar", "calendar"],
21
+ orderedIds: ["mail", "analytics", "calendar"],
22
+ }).map((app) => app.id),
23
+ ).toEqual(["calendar", "mail", "analytics"]);
24
+ });
25
+
26
+ it("matches app names and descriptions case-insensitively", () => {
27
+ expect(
28
+ workspaceAppMatchesQuery(
29
+ { name: "Analytics", description: "Explore product health" },
30
+ "PRODUCT",
31
+ ),
32
+ ).toBe(true);
33
+ expect(workspaceAppMatchesQuery({ name: "Mail" }, "calendar")).toBe(false);
34
+ });
35
+
36
+ it("normalizes persisted layout ids and toggles pin state", () => {
37
+ const layout = normalizeWorkspaceAppLayout({
38
+ pinnedIds: ["mail", "mail", 42],
39
+ orderedIds: ["calendar", "", "calendar"],
40
+ });
41
+
42
+ expect(toggleWorkspaceAppPinned(layout, "calendar")).toEqual({
43
+ pinnedIds: ["calendar", "mail"],
44
+ orderedIds: ["calendar"],
45
+ });
46
+ });
47
+ });