@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
@@ -0,0 +1,111 @@
1
+ import { IconChevronDown, IconPlus } from "@tabler/icons-react";
2
+
3
+ import { Button } from "./ui/button";
4
+ import {
5
+ DropdownMenu,
6
+ DropdownMenuContent,
7
+ DropdownMenuItem,
8
+ DropdownMenuTrigger,
9
+ } from "./ui/dropdown-menu";
10
+
11
+ export interface AppOpenActionLabels {
12
+ addApp: string;
13
+ openApp: string;
14
+ openInline: string;
15
+ openInNewTab: string;
16
+ }
17
+
18
+ const DEFAULT_LABELS: AppOpenActionLabels = {
19
+ addApp: "Add app",
20
+ openApp: "Open app",
21
+ openInline: "Open inline",
22
+ openInNewTab: "Open in new tab",
23
+ };
24
+
25
+ export function AppOpenActions({
26
+ name,
27
+ href,
28
+ target,
29
+ rel,
30
+ labels: labelOverrides,
31
+ onAddApp,
32
+ showInlineOption = false,
33
+ showNewTabOption = false,
34
+ }: {
35
+ name: string;
36
+ href: string | null;
37
+ target?: "_blank";
38
+ rel?: string;
39
+ labels?: Partial<AppOpenActionLabels>;
40
+ onAddApp?: () => void;
41
+ showInlineOption?: boolean;
42
+ showNewTabOption?: boolean;
43
+ }) {
44
+ const labels = { ...DEFAULT_LABELS, ...labelOverrides };
45
+ const hasMenu =
46
+ Boolean(onAddApp) ||
47
+ (Boolean(href) && (showInlineOption || showNewTabOption));
48
+
49
+ if (!href && !hasMenu) {
50
+ return (
51
+ <Button size="sm" variant="outline" disabled>
52
+ {labels.openApp}
53
+ </Button>
54
+ );
55
+ }
56
+
57
+ return (
58
+ <div className="flex shrink-0 overflow-hidden rounded-md border border-border focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2">
59
+ <Button
60
+ asChild={Boolean(href)}
61
+ size="sm"
62
+ variant="outline"
63
+ className="rounded-none border-0 border-e border-border"
64
+ disabled={!href}
65
+ >
66
+ {href ? (
67
+ <a href={href} target={target} rel={rel}>
68
+ {labels.openApp}
69
+ </a>
70
+ ) : (
71
+ <span>{labels.openApp}</span>
72
+ )}
73
+ </Button>
74
+ {hasMenu ? (
75
+ <DropdownMenu>
76
+ <DropdownMenuTrigger asChild>
77
+ <Button
78
+ type="button"
79
+ size="sm"
80
+ variant="outline"
81
+ className="size-9 rounded-none border-0 p-0"
82
+ aria-label={`Open options for ${name}`}
83
+ >
84
+ <IconChevronDown size={15} />
85
+ </Button>
86
+ </DropdownMenuTrigger>
87
+ <DropdownMenuContent align="end" className="min-w-40">
88
+ {onAddApp ? (
89
+ <DropdownMenuItem onSelect={onAddApp}>
90
+ <IconPlus size={14} className="mr-2" />
91
+ {labels.addApp}
92
+ </DropdownMenuItem>
93
+ ) : null}
94
+ {showInlineOption && href ? (
95
+ <DropdownMenuItem asChild>
96
+ <a href={href}>{labels.openInline}</a>
97
+ </DropdownMenuItem>
98
+ ) : null}
99
+ {showNewTabOption && href ? (
100
+ <DropdownMenuItem asChild>
101
+ <a href={href} target="_blank" rel="noreferrer">
102
+ {labels.openInNewTab}
103
+ </a>
104
+ </DropdownMenuItem>
105
+ ) : null}
106
+ </DropdownMenuContent>
107
+ </DropdownMenu>
108
+ ) : null}
109
+ </div>
110
+ );
111
+ }
@@ -1,42 +1,38 @@
1
- import { useT } from "@agent-native/core/client/i18n";
2
1
  import { withBuilderUtmTrackingParams } from "@agent-native/core/shared/builder-link-tracking";
3
- import { IconArrowUpRight } from "@tabler/icons-react";
4
2
 
5
3
  import type { ConnectedAppSummary } from "../lib/other-apps";
4
+ import { AppIcon } from "./app-icon";
5
+ import { AppListRow } from "./app-list-row";
6
+ import { AppOpenActions } from "./app-open-actions";
6
7
 
7
- export function ConnectedAppCard({ app }: { app: ConnectedAppSummary }) {
8
- const t = useT();
9
-
8
+ export function ConnectedAppCard({
9
+ app,
10
+ className,
11
+ }: {
12
+ app: ConnectedAppSummary;
13
+ className?: string;
14
+ }) {
10
15
  return (
11
- <a
12
- href={withBuilderUtmTrackingParams(app.url, {
13
- campaign: "product",
14
- content: "dispatch_app",
15
- })}
16
- target="_blank"
17
- rel="noopener noreferrer"
18
- className="group flex min-h-[116px] items-start gap-3 rounded-xl bg-card/40 p-4 transition-[background-color] hover:bg-accent/15 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
19
- >
20
- <span className="flex size-8 shrink-0 items-center justify-center rounded-md bg-muted text-xs font-semibold text-muted-foreground">
21
- {app.name.charAt(0).toUpperCase()}
22
- </span>
23
- <span className="min-w-0 flex-1">
24
- <span className="flex items-start justify-between gap-3">
25
- <span className="truncate text-sm font-semibold text-foreground">
26
- {app.name}
27
- </span>
28
- <IconArrowUpRight
29
- size={15}
30
- className="shrink-0 text-muted-foreground transition-transform group-hover:-translate-y-0.5 group-hover:translate-x-0.5"
31
- />
32
- </span>
33
- <span className="mt-2 line-clamp-2 block text-[13px] leading-5 text-muted-foreground">
16
+ <AppListRow className={className}>
17
+ <AppIcon id={app.id} name={app.name} color={app.color} size="sm" />
18
+ <div className="min-w-0 flex-1">
19
+ <div className="truncate text-sm font-semibold text-foreground">
20
+ {app.name}
21
+ </div>
22
+ <div className="mt-0.5 truncate text-xs text-muted-foreground">
34
23
  {app.description || app.url}
35
- </span>
36
- <span className="mt-3 block text-xs font-medium text-foreground">
37
- {t("dispatch.pages.openApp")}
38
- </span>
39
- </span>
40
- </a>
24
+ </div>
25
+ </div>
26
+ <AppOpenActions
27
+ name={app.name}
28
+ href={withBuilderUtmTrackingParams(app.url, {
29
+ campaign: "product",
30
+ content: "dispatch_app",
31
+ })}
32
+ target="_blank"
33
+ rel="noopener noreferrer"
34
+ showNewTabOption
35
+ />
36
+ </AppListRow>
41
37
  );
42
38
  }
@@ -12,6 +12,7 @@ const clientState = vi.hoisted(() => ({
12
12
  promptComposerProps: null as Record<string, unknown> | null,
13
13
  workspaceApps: [] as Array<Record<string, unknown>>,
14
14
  connectedApps: [] as Array<Record<string, unknown>>,
15
+ curatedTemplates: [] as Array<Record<string, unknown>>,
15
16
  useChatModels: vi.fn(() => ({
16
17
  availableModels: [],
17
18
  defaultModel: "auto",
@@ -56,7 +57,9 @@ vi.mock("@agent-native/core/client/hooks", () => ({
56
57
  data:
57
58
  name === "list-connected-agents"
58
59
  ? clientState.connectedApps
59
- : clientState.workspaceApps,
60
+ : name === "list-curated-workspace-templates"
61
+ ? clientState.curatedTemplates
62
+ : clientState.workspaceApps,
60
63
  isLoading: false,
61
64
  isError: false,
62
65
  error: null,
@@ -89,6 +92,7 @@ describe("DispatchControlPlane", () => {
89
92
  clientState.promptComposerProps = null;
90
93
  clientState.workspaceApps = [];
91
94
  clientState.connectedApps = [];
95
+ clientState.curatedTemplates = [];
92
96
  clientState.useChatModels.mockClear();
93
97
  container = document.createElement("div");
94
98
  document.body.appendChild(container);
@@ -195,6 +199,22 @@ describe("DispatchControlPlane", () => {
195
199
  url: "https://duplicate.example.com",
196
200
  },
197
201
  ];
202
+ clientState.curatedTemplates = [
203
+ {
204
+ id: "mail",
205
+ name: "Mail",
206
+ description: "Email client",
207
+ liveUrl: "https://mail.agent-native.com",
208
+ installed: false,
209
+ },
210
+ {
211
+ id: "analytics",
212
+ name: "Analytics",
213
+ description: "Workspace insights",
214
+ liveUrl: "https://analytics.agent-native.com",
215
+ installed: false,
216
+ },
217
+ ];
198
218
 
199
219
  await act(async () => {
200
220
  root.render(
@@ -209,8 +229,15 @@ describe("DispatchControlPlane", () => {
209
229
  expect(container.textContent).toContain("Onboarding");
210
230
  expect(container.textContent).toContain("Mail");
211
231
  expect(container.textContent).toContain("Clips");
232
+ expect(container.textContent).toContain("Analytics");
233
+ expect(container.textContent).toContain("Your apps");
234
+ expect(container.textContent).toContain("Other apps");
235
+ expect(container.textContent?.indexOf("Your apps")).toBeLessThan(
236
+ container.textContent?.indexOf("Other apps") ?? -1,
237
+ );
212
238
  expect(container.textContent).not.toContain("Archived app");
213
239
  expect(container.textContent).not.toContain("Duplicate onboarding");
240
+ expect(container.textContent).not.toContain("CRM");
214
241
  expect(
215
242
  Array.from(container.querySelectorAll("a")).filter((anchor) =>
216
243
  anchor.getAttribute("href")?.includes("onboarding"),
@@ -10,18 +10,25 @@ import {
10
10
  IconArrowUpRight,
11
11
  IconChevronDown,
12
12
  IconClockHour4,
13
+ IconApps,
13
14
  } from "@tabler/icons-react";
14
15
  import type { ReactNode } from "react";
15
16
  import { useState } from "react";
16
17
  import { Link, useNavigate } from "react-router";
17
18
 
18
- import { filterOtherApps, type ConnectedAppSummary } from "../lib/other-apps";
19
+ import type { ConnectedAppSummary } from "../lib/other-apps";
19
20
  import { submitOverviewPrompt } from "../lib/overview-chat";
21
+ import { cn } from "../lib/utils";
20
22
  import type { WorkspaceAppSummary } from "../lib/workspace-apps";
21
23
  import { ActionQueryError } from "./action-query-error";
22
- import { ConnectedAppCard } from "./connected-app-card";
24
+ import {
25
+ APP_LIST_GRID_CLASS,
26
+ AppList,
27
+ APP_LIST_GRID_ROW_CLASS,
28
+ } from "./app-list-row";
23
29
  import { CreateAppPopover } from "./create-app-popover";
24
30
  import { useSetPageTitle } from "./layout/HeaderActions";
31
+ import { OtherAppsSection } from "./other-apps-section";
25
32
  import { Button } from "./ui/button";
26
33
  import {
27
34
  Collapsible,
@@ -30,6 +37,7 @@ import {
30
37
  } from "./ui/collapsible";
31
38
  import { Skeleton } from "./ui/skeleton";
32
39
  import { WorkspaceAppCard } from "./workspace-app-card";
40
+ import type { CuratedWorkspaceTemplatesResult } from "./workspace-template-card";
33
41
 
34
42
  function SectionHeader({
35
43
  title,
@@ -48,6 +56,26 @@ function SectionHeader({
48
56
  );
49
57
  }
50
58
 
59
+ function AppGroupHeader({
60
+ title,
61
+ description,
62
+ }: {
63
+ title: string;
64
+ description: string;
65
+ }) {
66
+ return (
67
+ <div className="flex min-w-0 items-start gap-2">
68
+ <IconApps size={16} className="mt-0.5 shrink-0 text-muted-foreground" />
69
+ <div className="min-w-0">
70
+ <h3 className="truncate text-sm font-semibold text-foreground">
71
+ {title}
72
+ </h3>
73
+ <p className="mt-0.5 text-xs text-muted-foreground">{description}</p>
74
+ </div>
75
+ </div>
76
+ );
77
+ }
78
+
51
79
  function CommandPanel() {
52
80
  const t = useT();
53
81
  const {
@@ -143,29 +171,33 @@ function CommandPanel() {
143
171
  function AppsPanel({
144
172
  apps,
145
173
  isLoading,
146
- otherApps,
147
- otherAppsError,
148
- otherAppsLoading,
149
- onRetryOtherApps,
174
+ connectedApps,
175
+ connectedAppsError,
176
+ connectedAppsLoading,
177
+ onRetryConnectedApps,
178
+ curatedTemplates,
179
+ curatedTemplatesError,
180
+ curatedTemplatesLoading,
181
+ onRetryCuratedTemplates,
150
182
  }: {
151
183
  apps: WorkspaceAppSummary[];
152
184
  isLoading: boolean;
153
- otherApps: ConnectedAppSummary[];
154
- otherAppsError?: Error | null;
155
- otherAppsLoading: boolean;
156
- onRetryOtherApps: () => void;
185
+ connectedApps: ConnectedAppSummary[];
186
+ connectedAppsError?: Error | null;
187
+ connectedAppsLoading: boolean;
188
+ onRetryConnectedApps: () => void;
189
+ curatedTemplates?: CuratedWorkspaceTemplatesResult;
190
+ curatedTemplatesError?: Error | null;
191
+ curatedTemplatesLoading: boolean;
192
+ onRetryCuratedTemplates: () => void;
157
193
  }) {
158
194
  const t = useT();
159
195
  const [showPending, setShowPending] = useState(false);
160
196
  const visibleApps = apps.filter((app) => !app.isDispatch && !app.archived);
161
197
  const activeApps = visibleApps.filter((app) => app.status !== "pending");
162
198
  const pendingApps = visibleApps.filter((app) => app.status === "pending");
163
- const hasActiveApps = activeApps.length > 0 || otherApps.length > 0;
164
199
  const showSkeletons =
165
- (isLoading || otherAppsLoading) &&
166
- activeApps.length === 0 &&
167
- otherApps.length === 0 &&
168
- pendingApps.length === 0;
200
+ isLoading && activeApps.length === 0 && pendingApps.length === 0;
169
201
 
170
202
  return (
171
203
  <section className="flex flex-col gap-3">
@@ -181,30 +213,45 @@ function AppsPanel({
181
213
  }
182
214
  />
183
215
  {showSkeletons ? (
184
- <div className="grid gap-3 md:grid-cols-2">
185
- {Array.from({ length: 4 }).map((_, index) => (
186
- <div key={index} className="rounded-xl bg-card/40 p-4">
187
- <Skeleton className="h-4 w-32" />
188
- <Skeleton className="mt-3 h-3 w-24" />
189
- <Skeleton className="mt-3 h-3 w-full" />
190
- </div>
191
- ))}
192
- </div>
193
- ) : hasActiveApps ? (
194
- <div className="grid gap-3 md:grid-cols-2">
195
- {activeApps.map((app) => (
196
- <WorkspaceAppCard key={app.id} app={app} className="min-h-32" />
197
- ))}
198
- {otherApps.map((app) => (
199
- <ConnectedAppCard key={app.id} app={app} />
200
- ))}
201
- </div>
216
+ <OverviewAppsSkeleton />
202
217
  ) : (
203
- <CreateAppPopover />
218
+ <>
219
+ {activeApps.length > 0 ? (
220
+ <div className="space-y-3">
221
+ <AppGroupHeader
222
+ title={t("dispatch.pages.yourApps", {
223
+ defaultValue: "Your apps",
224
+ })}
225
+ description={t("dispatch.pages.activeCount", {
226
+ count: activeApps.length,
227
+ })}
228
+ />
229
+ <AppList className={APP_LIST_GRID_CLASS}>
230
+ {activeApps.map((app) => (
231
+ <WorkspaceAppCard
232
+ key={app.id}
233
+ app={app}
234
+ className={APP_LIST_GRID_ROW_CLASS}
235
+ />
236
+ ))}
237
+ </AppList>
238
+ </div>
239
+ ) : (
240
+ <CreateAppPopover />
241
+ )}
242
+ <OtherAppsSection
243
+ templates={curatedTemplates}
244
+ connectedApps={connectedApps}
245
+ workspaceApps={apps}
246
+ templatesLoading={curatedTemplatesLoading}
247
+ connectedAppsLoading={connectedAppsLoading}
248
+ templatesError={curatedTemplatesError}
249
+ connectedAppsError={connectedAppsError}
250
+ onRetryTemplates={onRetryCuratedTemplates}
251
+ onRetryConnectedApps={onRetryConnectedApps}
252
+ />
253
+ </>
204
254
  )}
205
- {otherAppsError ? (
206
- <ActionQueryError error={otherAppsError} onRetry={onRetryOtherApps} />
207
- ) : null}
208
255
  {pendingApps.length > 0 ? (
209
256
  <Collapsible open={showPending} onOpenChange={setShowPending}>
210
257
  <div className="space-y-3 border-t pt-3">
@@ -243,15 +290,15 @@ function AppsPanel({
243
290
  </CollapsibleTrigger>
244
291
  </div>
245
292
  <CollapsibleContent>
246
- <div className="grid gap-3 md:grid-cols-2">
293
+ <AppList className={APP_LIST_GRID_CLASS}>
247
294
  {pendingApps.map((app) => (
248
295
  <WorkspaceAppCard
249
296
  key={app.id}
250
297
  app={app}
251
- className="min-h-32"
298
+ className={APP_LIST_GRID_ROW_CLASS}
252
299
  />
253
300
  ))}
254
- </div>
301
+ </AppList>
255
302
  </CollapsibleContent>
256
303
  </div>
257
304
  </Collapsible>
@@ -260,6 +307,29 @@ function AppsPanel({
260
307
  );
261
308
  }
262
309
 
310
+ function OverviewAppsSkeleton() {
311
+ return (
312
+ <AppList className={APP_LIST_GRID_CLASS}>
313
+ {Array.from({ length: 4 }).map((_, index) => (
314
+ <div
315
+ key={index}
316
+ className={cn(
317
+ "flex min-w-0 items-center gap-3 border-b px-4 py-3.5 last:border-b-0",
318
+ APP_LIST_GRID_ROW_CLASS,
319
+ )}
320
+ >
321
+ <Skeleton className="size-8 shrink-0 rounded-xl" />
322
+ <div className="min-w-0 flex-1 space-y-2">
323
+ <Skeleton className="h-4 w-32" />
324
+ <Skeleton className="h-3 w-2/3" />
325
+ </div>
326
+ <Skeleton className="h-9 w-24 shrink-0 rounded-md" />
327
+ </div>
328
+ ))}
329
+ </AppList>
330
+ );
331
+ }
332
+
263
333
  export function DispatchControlPlane() {
264
334
  useSetPageTitle(<span aria-hidden />);
265
335
  const appsQuery = useActionQuery<WorkspaceAppSummary[]>(
@@ -270,11 +340,11 @@ export function DispatchControlPlane() {
270
340
  "list-connected-agents",
271
341
  {},
272
342
  );
273
- const { data: workspaceApps = [], isLoading: appsLoading } = appsQuery;
274
- const otherApps = filterOtherApps(
275
- connectedAppsQuery.data ?? [],
276
- workspaceApps ?? [],
343
+ const curatedTemplatesQuery = useActionQuery<CuratedWorkspaceTemplatesResult>(
344
+ "list-curated-workspace-templates",
345
+ {},
277
346
  );
347
+ const { data: workspaceApps = [], isLoading: appsLoading } = appsQuery;
278
348
 
279
349
  return (
280
350
  <div className="flex flex-col gap-8">
@@ -288,10 +358,14 @@ export function DispatchControlPlane() {
288
358
  <AppsPanel
289
359
  apps={workspaceApps ?? []}
290
360
  isLoading={appsLoading}
291
- otherApps={otherApps}
292
- otherAppsError={connectedAppsQuery.error}
293
- otherAppsLoading={connectedAppsQuery.isLoading}
294
- onRetryOtherApps={() => void connectedAppsQuery.refetch()}
361
+ connectedApps={connectedAppsQuery.data ?? []}
362
+ connectedAppsError={connectedAppsQuery.error}
363
+ connectedAppsLoading={connectedAppsQuery.isLoading}
364
+ onRetryConnectedApps={() => void connectedAppsQuery.refetch()}
365
+ curatedTemplates={curatedTemplatesQuery.data}
366
+ curatedTemplatesError={curatedTemplatesQuery.error}
367
+ curatedTemplatesLoading={curatedTemplatesQuery.isLoading}
368
+ onRetryCuratedTemplates={() => void curatedTemplatesQuery.refetch()}
295
369
  />
296
370
  )}
297
371
  </div>
@@ -173,7 +173,7 @@ describe("Dispatch NavContent", () => {
173
173
  expect(lists[1].className).toContain("gap-1");
174
174
  expect(lists[1].querySelector('a[href="/admin"]')).not.toBeNull();
175
175
  expect(lists[1].querySelector('a[href="/settings"]')).not.toBeNull();
176
- expect(lists[0].querySelector("a")?.className).toContain("h-8 w-8");
176
+ expect(lists[0].querySelector("a")?.className).toContain("size-9");
177
177
  });
178
178
 
179
179
  it("keeps management routes out of the primary navigation", async () => {
@@ -218,7 +218,7 @@ describe("Dispatch NavContent", () => {
218
218
  expect(shell?.querySelector('a[href="/admin/apps"]')).toBeNull();
219
219
  });
220
220
 
221
- it("shows ready workspace apps as direct links and highlights the active app", async () => {
221
+ it("shows ready workspace apps as host links and highlights the active app", async () => {
222
222
  clientState.workspaceApps = [
223
223
  { id: "calendar", name: "Calendar", path: "/calendar", status: "ready" },
224
224
  { id: "pending", name: "Pending", path: "/pending", status: "pending" },
@@ -226,7 +226,7 @@ describe("Dispatch NavContent", () => {
226
226
 
227
227
  await act(async () => {
228
228
  root.render(
229
- <MemoryRouter initialEntries={["/calendar/events"]}>
229
+ <MemoryRouter initialEntries={["/apps/calendar"]}>
230
230
  <TooltipProvider>
231
231
  <NavContent />
232
232
  </TooltipProvider>
@@ -239,7 +239,7 @@ describe("Dispatch NavContent", () => {
239
239
  expect(rail?.textContent).toContain("Calendar");
240
240
  expect(rail?.textContent).not.toContain("Pending");
241
241
 
242
- const calendarLink = rail?.querySelector('a[href="/calendar"]');
242
+ const calendarLink = rail?.querySelector('a[href="/apps/calendar"]');
243
243
  expect(calendarLink).not.toBeNull();
244
244
  expect(calendarLink?.getAttribute("aria-current")).toBe("page");
245
245
  });