@agent-native/dispatch 0.19.2 → 0.20.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 (92) hide show
  1. package/dist/actions/provider-api-register.d.ts +5 -5
  2. package/dist/actions/start-workspace-app-creation.js +1 -1
  3. package/dist/actions/start-workspace-app-creation.js.map +1 -1
  4. package/dist/components/admin-navigation.d.ts +2 -1
  5. package/dist/components/admin-navigation.d.ts.map +1 -1
  6. package/dist/components/admin-navigation.js +10 -7
  7. package/dist/components/admin-navigation.js.map +1 -1
  8. package/dist/components/app-icon.d.ts +9 -0
  9. package/dist/components/app-icon.d.ts.map +1 -0
  10. package/dist/components/app-icon.js +85 -0
  11. package/dist/components/app-icon.js.map +1 -0
  12. package/dist/components/app-list-row.d.ts +12 -0
  13. package/dist/components/app-list-row.d.ts.map +1 -0
  14. package/dist/components/app-list-row.js +11 -0
  15. package/dist/components/app-list-row.js.map +1 -0
  16. package/dist/components/app-open-actions.d.ts +17 -0
  17. package/dist/components/app-open-actions.d.ts.map +1 -0
  18. package/dist/components/app-open-actions.js +20 -0
  19. package/dist/components/app-open-actions.js.map +1 -0
  20. package/dist/components/connected-app-card.d.ts +2 -1
  21. package/dist/components/connected-app-card.d.ts.map +1 -1
  22. package/dist/components/connected-app-card.js +8 -8
  23. package/dist/components/connected-app-card.js.map +1 -1
  24. package/dist/components/dispatch-control-plane.d.ts.map +1 -1
  25. package/dist/components/dispatch-control-plane.js +21 -14
  26. package/dist/components/dispatch-control-plane.js.map +1 -1
  27. package/dist/components/layout/Layout.d.ts.map +1 -1
  28. package/dist/components/layout/Layout.js +40 -19
  29. package/dist/components/layout/Layout.js.map +1 -1
  30. package/dist/components/layout/workspace-apps-rail.d.ts.map +1 -1
  31. package/dist/components/layout/workspace-apps-rail.js +10 -10
  32. package/dist/components/layout/workspace-apps-rail.js.map +1 -1
  33. package/dist/components/other-apps-section.d.ts +31 -0
  34. package/dist/components/other-apps-section.d.ts.map +1 -0
  35. package/dist/components/other-apps-section.js +59 -0
  36. package/dist/components/other-apps-section.js.map +1 -0
  37. package/dist/components/workspace-app-card.d.ts.map +1 -1
  38. package/dist/components/workspace-app-card.js +38 -28
  39. package/dist/components/workspace-app-card.js.map +1 -1
  40. package/dist/components/workspace-template-card.d.ts +8 -2
  41. package/dist/components/workspace-template-card.d.ts.map +1 -1
  42. package/dist/components/workspace-template-card.js +17 -13
  43. package/dist/components/workspace-template-card.js.map +1 -1
  44. package/dist/lib/other-apps.d.ts.map +1 -1
  45. package/dist/lib/other-apps.js +5 -1
  46. package/dist/lib/other-apps.js.map +1 -1
  47. package/dist/lib/workspace-apps.d.ts +1 -0
  48. package/dist/lib/workspace-apps.d.ts.map +1 -1
  49. package/dist/lib/workspace-apps.js +3 -0
  50. package/dist/lib/workspace-apps.js.map +1 -1
  51. package/dist/routes/index.d.ts.map +1 -1
  52. package/dist/routes/index.js +0 -2
  53. package/dist/routes/index.js.map +1 -1
  54. package/dist/routes/pages/admin._index.d.ts.map +1 -1
  55. package/dist/routes/pages/admin._index.js +32 -7
  56. package/dist/routes/pages/admin._index.js.map +1 -1
  57. package/dist/routes/pages/apps.$appId.d.ts.map +1 -1
  58. package/dist/routes/pages/apps.$appId.js +62 -13
  59. package/dist/routes/pages/apps.$appId.js.map +1 -1
  60. package/dist/routes/pages/apps.d.ts +1 -1
  61. package/dist/routes/pages/apps.d.ts.map +1 -1
  62. package/dist/routes/pages/apps.js +21 -30
  63. package/dist/routes/pages/apps.js.map +1 -1
  64. package/dist/server/plugins/agent-chat.js +6 -1
  65. package/dist/server/plugins/agent-chat.js.map +1 -1
  66. package/package.json +2 -2
  67. package/src/actions/start-workspace-app-creation.ts +1 -1
  68. package/src/components/admin-navigation.tsx +21 -16
  69. package/src/components/app-icon.tsx +149 -0
  70. package/src/components/app-list-row.tsx +40 -0
  71. package/src/components/app-open-actions.tsx +111 -0
  72. package/src/components/connected-app-card.tsx +30 -34
  73. package/src/components/dispatch-control-plane.spec.tsx +28 -1
  74. package/src/components/dispatch-control-plane.tsx +122 -48
  75. package/src/components/layout/Layout.spec.tsx +4 -4
  76. package/src/components/layout/Layout.tsx +86 -55
  77. package/src/components/layout/workspace-apps-rail.tsx +20 -21
  78. package/src/components/other-apps-section.tsx +195 -0
  79. package/src/components/workspace-app-card.spec.tsx +58 -32
  80. package/src/components/workspace-app-card.tsx +222 -158
  81. package/src/components/workspace-template-card.spec.tsx +45 -7
  82. package/src/components/workspace-template-card.tsx +74 -81
  83. package/src/lib/other-apps.spec.ts +31 -0
  84. package/src/lib/other-apps.ts +9 -1
  85. package/src/lib/workspace-apps.ts +4 -0
  86. package/src/routes/index.spec.ts +2 -1
  87. package/src/routes/index.ts +0 -2
  88. package/src/routes/pages/admin._index.tsx +85 -17
  89. package/src/routes/pages/apps.$appId.tsx +144 -57
  90. package/src/routes/pages/apps.tsx +76 -147
  91. package/src/server/plugins/agent-chat.spec.ts +5 -0
  92. package/src/server/plugins/agent-chat.ts +6 -1
@@ -0,0 +1,149 @@
1
+ import {
2
+ IconActivity,
3
+ IconApps,
4
+ IconBrain,
5
+ IconBrush,
6
+ IconCalendar,
7
+ IconChartBar,
8
+ IconClipboardList,
9
+ IconFileText,
10
+ IconMail,
11
+ IconPhoto,
12
+ IconPlugConnected,
13
+ IconPresentation,
14
+ IconPuzzle,
15
+ IconScreenShare,
16
+ } from "@tabler/icons-react";
17
+ import type { CSSProperties } from "react";
18
+
19
+ import { cn } from "../lib/utils";
20
+
21
+ type AppIconComponent = typeof IconApps;
22
+
23
+ const FALLBACK_ICONS: AppIconComponent[] = [
24
+ IconApps,
25
+ IconPuzzle,
26
+ IconActivity,
27
+ IconPlugConnected,
28
+ ];
29
+
30
+ const APP_ICON_TONES = [
31
+ "bg-primary/10 text-primary",
32
+ "bg-secondary text-secondary-foreground",
33
+ "bg-accent text-accent-foreground",
34
+ "bg-muted text-foreground",
35
+ ] as const;
36
+
37
+ const ICONS_BY_KEY: Record<string, AppIconComponent> = {
38
+ barchart2: IconChartBar,
39
+ brain: IconBrain,
40
+ brush: IconBrush,
41
+ calendardays: IconCalendar,
42
+ chartbar: IconChartBar,
43
+ clipboardlist: IconClipboardList,
44
+ filetext: IconFileText,
45
+ galleryhorizontal: IconPresentation,
46
+ mail: IconMail,
47
+ photo: IconPhoto,
48
+ presentation: IconPresentation,
49
+ screenshare: IconScreenShare,
50
+ };
51
+
52
+ function appIconComponent(
53
+ id: string,
54
+ name: string,
55
+ iconKey?: string,
56
+ ): AppIconComponent {
57
+ const normalizedIconKey = iconKey?.trim().toLowerCase();
58
+ if (normalizedIconKey && ICONS_BY_KEY[normalizedIconKey]) {
59
+ return ICONS_BY_KEY[normalizedIconKey];
60
+ }
61
+
62
+ const haystack = `${id} ${name}`.toLowerCase();
63
+
64
+ if (haystack.includes("mail") || haystack.includes("email")) {
65
+ return IconMail;
66
+ }
67
+ if (
68
+ haystack.includes("analytics") ||
69
+ haystack.includes("metric") ||
70
+ haystack.includes("indicator") ||
71
+ haystack.includes("gtm")
72
+ ) {
73
+ return IconChartBar;
74
+ }
75
+ if (haystack.includes("coach") || haystack.includes("agent")) {
76
+ return IconBrain;
77
+ }
78
+
79
+ let hash = 0;
80
+ for (const character of `${id}:${name}`) {
81
+ hash = (hash * 31 + character.charCodeAt(0)) | 0;
82
+ }
83
+ return FALLBACK_ICONS[Math.abs(hash) % FALLBACK_ICONS.length] ?? IconApps;
84
+ }
85
+
86
+ function safeHexColor(color: string | undefined): string | null {
87
+ const normalized = color?.trim();
88
+ return normalized && /^#[0-9a-f]{6}$/i.test(normalized) ? normalized : null;
89
+ }
90
+
91
+ export function AppIcon({
92
+ id,
93
+ name,
94
+ icon,
95
+ color,
96
+ className,
97
+ size = "md",
98
+ }: {
99
+ id: string;
100
+ name: string;
101
+ icon?: string;
102
+ color?: string;
103
+ className?: string;
104
+ size?: "sm" | "md";
105
+ }) {
106
+ const Icon = appIconComponent(id, name, icon);
107
+ const customColor = safeHexColor(color);
108
+ const tone =
109
+ APP_ICON_TONES[Math.abs(hashForApp(id, name)) % APP_ICON_TONES.length];
110
+ const style = customColor
111
+ ? ({
112
+ "--dispatch-app-icon-color": customColor,
113
+ backgroundColor:
114
+ "color-mix(in srgb, var(--dispatch-app-icon-color) 14%, transparent)",
115
+ color: "var(--dispatch-app-icon-color)",
116
+ } as CSSProperties)
117
+ : ({
118
+ "--dispatch-app-icon-hue": `${appHue(id, name)} 72% 44%`,
119
+ backgroundColor: "hsl(var(--dispatch-app-icon-hue) / 0.14)",
120
+ color: "hsl(var(--dispatch-app-icon-hue))",
121
+ } as CSSProperties);
122
+
123
+ return (
124
+ <span
125
+ aria-hidden="true"
126
+ className={cn(
127
+ "flex shrink-0 items-center justify-center rounded-xl",
128
+ size === "sm" ? "size-8" : "size-10",
129
+ !customColor && tone,
130
+ className,
131
+ )}
132
+ style={style}
133
+ >
134
+ <Icon size={size === "sm" ? 16 : 19} stroke={1.8} />
135
+ </span>
136
+ );
137
+ }
138
+
139
+ function hashForApp(id: string, name: string): number {
140
+ let hash = 0;
141
+ for (const character of `${id}:${name}`) {
142
+ hash = (hash * 31 + character.charCodeAt(0)) | 0;
143
+ }
144
+ return hash;
145
+ }
146
+
147
+ function appHue(id: string, name: string): number {
148
+ return 20 + (Math.abs(hashForApp(id, name)) % 320);
149
+ }
@@ -0,0 +1,40 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ import { cn } from "../lib/utils";
4
+
5
+ export const APP_LIST_GRID_CLASS =
6
+ "xl:grid xl:grid-cols-2 xl:gap-3 xl:overflow-visible xl:rounded-none xl:bg-transparent";
7
+ export const APP_LIST_GRID_ROW_CLASS = "xl:rounded-2xl xl:border-0 xl:bg-card";
8
+
9
+ export function AppList({
10
+ children,
11
+ className,
12
+ }: {
13
+ children: ReactNode;
14
+ className?: string;
15
+ }) {
16
+ return (
17
+ <div className={cn("overflow-hidden rounded-2xl bg-card", className)}>
18
+ {children}
19
+ </div>
20
+ );
21
+ }
22
+
23
+ export function AppListRow({
24
+ children,
25
+ className,
26
+ }: {
27
+ children: ReactNode;
28
+ className?: string;
29
+ }) {
30
+ return (
31
+ <div
32
+ className={cn(
33
+ "group flex min-w-0 items-center gap-3 border-b px-4 py-3.5 last:border-b-0 transition-[background-color] hover:bg-muted/30 focus-within:bg-muted/30",
34
+ className,
35
+ )}
36
+ >
37
+ {children}
38
+ </div>
39
+ );
40
+ }
@@ -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>