@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
@@ -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
  });
@@ -2,26 +2,17 @@ import { useActionMutation } from "@agent-native/core/client/hooks";
2
2
  import {
3
3
  IconArrowUpRight,
4
4
  IconCircleCheck,
5
- IconCopy,
6
- IconExternalLink,
7
- IconFileText,
5
+ IconPlus,
8
6
  IconPlugConnected,
9
7
  } from "@tabler/icons-react";
10
8
  import { useEffect, useMemo, useState, type ReactNode } from "react";
11
9
  import { toast } from "sonner";
12
10
 
13
11
  import { cn } from "../lib/utils";
14
- import { Alert, AlertDescription } from "./ui/alert";
15
- import { Badge } from "./ui/badge";
12
+ import { AppIcon } from "./app-icon";
13
+ import { AppListRow } from "./app-list-row";
14
+ import { AppOpenActions } from "./app-open-actions";
16
15
  import { Button } from "./ui/button";
17
- import {
18
- Card,
19
- CardContent,
20
- CardDescription,
21
- CardFooter,
22
- CardHeader,
23
- CardTitle,
24
- } from "./ui/card";
25
16
  import {
26
17
  Dialog,
27
18
  DialogContent,
@@ -43,6 +34,8 @@ export interface CuratedWorkspaceTemplate {
43
34
  description?: string | null;
44
35
  source?: string | null;
45
36
  sourceDescription?: string | null;
37
+ icon?: string | null;
38
+ color?: string | null;
46
39
  integrationSetup?: string | null;
47
40
  setupNote?: string | null;
48
41
  installed?: boolean | null;
@@ -69,6 +62,7 @@ export interface WorkspaceTemplateLabels {
69
62
  remixError: string;
70
63
  appIdRequired: string;
71
64
  source: string;
65
+ openApp: string;
72
66
  viewLiveApp: string;
73
67
  }
74
68
 
@@ -78,12 +72,13 @@ const DEFAULT_LABELS: WorkspaceTemplateLabels = {
78
72
  cancel: "Cancel",
79
73
  integrationSetup: "Integration setup",
80
74
  installed: "Installed",
81
- remix: "Create from template",
75
+ remix: "Add app",
82
76
  remixing: "Creating app…",
83
77
  remixSuccess: "Template app creation started.",
84
- remixError: "Could not create an app from this template",
78
+ remixError: "Could not add this app",
85
79
  appIdRequired: "App ID is required.",
86
80
  source: "Source",
81
+ openApp: "Open app",
87
82
  viewLiveApp: "View the live app",
88
83
  };
89
84
 
@@ -92,6 +87,7 @@ export interface WorkspaceTemplateCardProps {
92
87
  defaultAppId?: string;
93
88
  labels?: Partial<WorkspaceTemplateLabels>;
94
89
  className?: string;
90
+ catalog?: boolean;
95
91
  onRemixSuccess?: (
96
92
  result: unknown,
97
93
  template: CuratedWorkspaceTemplate,
@@ -119,7 +115,7 @@ function defaultAppIdFor(
119
115
  ): string {
120
116
  if (defaultAppId) return slugifyAppId(defaultAppId);
121
117
  const sourceId = slugifyAppId(template.appId || template.name);
122
- return `${sourceId}-remix`;
118
+ return `${sourceId}-app`;
123
119
  }
124
120
 
125
121
  function stringifyError(error: unknown): string {
@@ -139,6 +135,7 @@ export function WorkspaceTemplateCard({
139
135
  defaultAppId,
140
136
  labels: labelOverrides,
141
137
  className,
138
+ catalog = false,
142
139
  onRemixSuccess,
143
140
  }: WorkspaceTemplateCardProps) {
144
141
  const labels = useMemo(() => mergeLabels(labelOverrides), [labelOverrides]);
@@ -189,75 +186,53 @@ export function WorkspaceTemplateCard({
189
186
  }
190
187
 
191
188
  return (
192
- <Card
193
- className={cn(
194
- "flex h-full flex-col bg-card/40 shadow-none transition-[background-color] hover:bg-accent/15",
195
- className,
196
- )}
197
- >
198
- <CardHeader className="gap-2 p-4">
199
- <div className="flex items-start justify-between gap-3">
200
- <div className="flex min-w-0 items-start gap-2.5">
201
- <span className="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-md bg-muted/40 text-muted-foreground">
202
- <IconFileText size={16} />
203
- </span>
204
- <div className="min-w-0">
205
- <CardTitle className="truncate text-sm font-semibold">
206
- {template.name}
207
- </CardTitle>
208
- {template.source || template.sourceDescription ? (
209
- <CardDescription className="mt-1 truncate text-xs">
210
- <span className="font-medium text-foreground/70">
211
- {labels.source}:
212
- </span>{" "}
213
- {template.sourceDescription || template.source}
214
- </CardDescription>
215
- ) : null}
216
- </div>
217
- </div>
189
+ <AppListRow className={cn("items-center", className)}>
190
+ <AppIcon
191
+ id={template.id || template.templateId || template.name}
192
+ name={template.name}
193
+ icon={template.icon || undefined}
194
+ color={template.color || undefined}
195
+ size="sm"
196
+ />
197
+ <div className="min-w-0 flex-1">
198
+ <div className="flex min-w-0 items-center gap-2">
199
+ <h3 className="truncate text-sm font-semibold text-foreground">
200
+ {template.name}
201
+ </h3>
218
202
  {isInstalled ? (
219
- <Badge
220
- variant="outline"
221
- className="shrink-0 gap-1 border-primary/30 bg-primary/5 text-primary"
222
- >
203
+ <span className="inline-flex shrink-0 items-center gap-1 text-xs text-primary">
223
204
  <IconCircleCheck size={13} />
224
205
  {labels.installed}
225
- </Badge>
206
+ </span>
226
207
  ) : null}
227
208
  </div>
228
- </CardHeader>
229
-
230
- <CardContent className="flex flex-1 flex-col gap-3 p-4 pt-0">
231
209
  {template.description ? (
232
- <p className="line-clamp-3 text-[13px] leading-5 text-muted-foreground">
210
+ <p className="mt-0.5 truncate text-xs text-muted-foreground">
233
211
  {template.description}
234
212
  </p>
235
213
  ) : null}
236
-
237
- {setupNote ? (
238
- <Alert className="border-border/60 bg-muted/25 px-3 py-2 [&>svg]:left-3 [&>svg]:top-2.5">
239
- <IconPlugConnected size={15} />
240
- <AlertDescription className="text-xs leading-5">
241
- <span className="font-medium text-foreground/80">
242
- {labels.integrationSetup}:
243
- </span>{" "}
244
- {setupNote}
245
- </AlertDescription>
246
- </Alert>
247
- ) : null}
248
- </CardContent>
249
-
250
- <CardFooter className="flex flex-wrap justify-between gap-2 p-4 pt-0">
251
- {liveUrl ? (
252
- <Button variant="link" size="sm" className="h-8 px-0" asChild>
214
+ </div>
215
+ <div className="flex shrink-0 items-center gap-2">
216
+ {catalog ? (
217
+ <AppOpenActions
218
+ name={template.name}
219
+ href={liveUrl ?? null}
220
+ target="_blank"
221
+ rel="noreferrer"
222
+ labels={{
223
+ addApp: labels.remix,
224
+ openApp: labels.openApp,
225
+ }}
226
+ onAddApp={() => setOpen(true)}
227
+ />
228
+ ) : liveUrl ? (
229
+ <Button variant="ghost" size="sm" className="shrink-0" asChild>
253
230
  <a href={liveUrl} target="_blank" rel="noreferrer">
254
231
  {labels.viewLiveApp}
255
- <IconExternalLink />
232
+ <IconArrowUpRight />
256
233
  </a>
257
234
  </Button>
258
- ) : (
259
- <span />
260
- )}
235
+ ) : null}
261
236
 
262
237
  <Dialog
263
238
  open={open}
@@ -265,12 +240,14 @@ export function WorkspaceTemplateCard({
265
240
  if (!remix.isPending) setOpen(nextOpen);
266
241
  }}
267
242
  >
268
- <DialogTrigger asChild>
269
- <Button type="button" size="sm">
270
- <IconCopy />
271
- {labels.remix}
272
- </Button>
273
- </DialogTrigger>
243
+ {!catalog ? (
244
+ <DialogTrigger asChild>
245
+ <Button type="button" variant="outline" size="sm">
246
+ <IconPlus />
247
+ {labels.remix}
248
+ </Button>
249
+ </DialogTrigger>
250
+ ) : null}
274
251
  <DialogContent className="max-w-md">
275
252
  <DialogHeader>
276
253
  <DialogTitle>{template.name}</DialogTitle>
@@ -297,6 +274,17 @@ export function WorkspaceTemplateCard({
297
274
  disabled={remix.isPending}
298
275
  />
299
276
  </div>
277
+ {setupNote ? (
278
+ <div className="flex items-start gap-2 rounded-lg bg-muted px-3 py-2 text-xs leading-5 text-muted-foreground">
279
+ <IconPlugConnected className="mt-0.5 shrink-0" size={15} />
280
+ <span>
281
+ <span className="font-medium text-foreground">
282
+ {labels.integrationSetup}:
283
+ </span>{" "}
284
+ {setupNote}
285
+ </span>
286
+ </div>
287
+ ) : null}
300
288
  <DialogFooter>
301
289
  <Button
302
290
  type="button"
@@ -314,8 +302,8 @@ export function WorkspaceTemplateCard({
314
302
  </form>
315
303
  </DialogContent>
316
304
  </Dialog>
317
- </CardFooter>
318
- </Card>
305
+ </div>
306
+ </AppListRow>
319
307
  );
320
308
  }
321
309
 
@@ -326,6 +314,8 @@ export interface WorkspaceTemplatesSectionProps {
326
314
  labels?: Partial<WorkspaceTemplateLabels>;
327
315
  className?: string;
328
316
  cardClassName?: string;
317
+ listClassName?: string;
318
+ catalog?: boolean;
329
319
  onRemixSuccess?: (
330
320
  result: unknown,
331
321
  template: CuratedWorkspaceTemplate,
@@ -345,6 +335,8 @@ export function WorkspaceTemplatesSection({
345
335
  labels,
346
336
  className,
347
337
  cardClassName,
338
+ listClassName,
339
+ catalog = false,
348
340
  onRemixSuccess,
349
341
  }: WorkspaceTemplatesSectionProps) {
350
342
  const templates = getTemplateItems(result);
@@ -354,13 +346,14 @@ export function WorkspaceTemplatesSection({
354
346
  return (
355
347
  <section className={cn("flex flex-col gap-3", className)}>
356
348
  {title ? <div className="text-sm font-semibold">{title}</div> : null}
357
- <div className="grid gap-3 md:grid-cols-2">
349
+ <div className={cn("overflow-hidden rounded-2xl bg-card", listClassName)}>
358
350
  {templates.map((template) => (
359
351
  <WorkspaceTemplateCard
360
352
  key={templateIdFor(template)}
361
353
  template={template}
362
354
  defaultAppId={defaultAppId}
363
355
  labels={labels}
356
+ catalog={catalog}
364
357
  className={cardClassName}
365
358
  onRemixSuccess={onRemixSuccess}
366
359
  />
@@ -73,4 +73,35 @@ describe("filterOtherApps", () => {
73
73
  },
74
74
  ]);
75
75
  });
76
+
77
+ it("hides the unsupported CRM and Research entries", () => {
78
+ expect(
79
+ filterOtherApps(
80
+ [
81
+ {
82
+ id: "crm",
83
+ name: "CRM",
84
+ url: "https://crm.example.com",
85
+ },
86
+ {
87
+ id: "research",
88
+ name: "Research",
89
+ url: "https://research.example.com",
90
+ },
91
+ {
92
+ id: "mail",
93
+ name: "Mail",
94
+ url: "https://mail.agent-native.com",
95
+ },
96
+ ],
97
+ [],
98
+ ),
99
+ ).toEqual([
100
+ {
101
+ id: "mail",
102
+ name: "Mail",
103
+ url: "https://mail.agent-native.com",
104
+ },
105
+ ]);
106
+ });
76
107
  });
@@ -12,6 +12,8 @@ export interface WorkspaceAppId {
12
12
  isDispatch?: boolean;
13
13
  }
14
14
 
15
+ const HIDDEN_OTHER_APP_IDS = new Set(["crm", "research"]);
16
+
15
17
  function isHttpUrl(value: string): boolean {
16
18
  try {
17
19
  const url = new URL(value);
@@ -34,7 +36,13 @@ export function filterOtherApps(
34
36
  return connectedApps
35
37
  .filter((app) => {
36
38
  const id = app.id.trim().toLowerCase();
37
- if (!id || workspaceAppIds.has(id) || seen.has(id)) return false;
39
+ if (
40
+ !id ||
41
+ HIDDEN_OTHER_APP_IDS.has(id) ||
42
+ workspaceAppIds.has(id) ||
43
+ seen.has(id)
44
+ )
45
+ return false;
38
46
  if (app.source === "workspace") return false;
39
47
  if (!isHttpUrl(app.url)) return false;
40
48
  seen.add(id);
@@ -26,6 +26,10 @@ export interface WorkspaceAppSummary {
26
26
  archived?: boolean;
27
27
  }
28
28
 
29
+ export function workspaceAppRoute(appId: string): string {
30
+ return `/apps/${encodeURIComponent(appId)}`;
31
+ }
32
+
29
33
  export function workspaceAppHref(app: WorkspaceAppSummary): string | null {
30
34
  if (app.status === "pending") {
31
35
  return app.builderUrl
@@ -31,7 +31,8 @@ describe("Dispatch route registration", () => {
31
31
  const admin = routes.find((route) => route.path === "admin");
32
32
  const adminPaths = admin?.children?.map((route) => route.path) ?? [];
33
33
  expect(adminPaths).toContain("operations");
34
- expect(adminPaths).toContain("apps/:appId");
34
+ expect(adminPaths).not.toContain("apps");
35
+ expect(adminPaths).not.toContain("apps/:appId");
35
36
  expect(adminPaths.indexOf("operations")).toBeGreaterThanOrEqual(0);
36
37
  });
37
38
  });
@@ -38,8 +38,6 @@ export const dispatchRoutes: RouteConfig = [
38
38
  route("overview", "./pages/overview.js"),
39
39
  route("admin", "./pages/admin.js", [
40
40
  index("./pages/admin._index.js"),
41
- route("apps", "./pages/apps.js"),
42
- route("apps/:appId", "./pages/apps.$appId.js"),
43
41
  route("new-app", "./pages/new-app.js"),
44
42
  route("operations", "./pages/operations.js"),
45
43
  route("metrics", "./pages/metrics.js"),
@@ -1,5 +1,11 @@
1
1
  import { useT } from "@agent-native/core/client/i18n";
2
+ import { IconChevronRight } from "@tabler/icons-react";
3
+ import { Link } from "react-router";
2
4
 
5
+ import {
6
+ useAdminNavGroups,
7
+ type AdminNavGroup,
8
+ } from "../../components/admin-navigation";
3
9
  import { DispatchShell } from "../../components/dispatch-shell";
4
10
 
5
11
  export function meta() {
@@ -8,6 +14,7 @@ export function meta() {
8
14
 
9
15
  export default function AdminOverviewRoute() {
10
16
  const t = useT();
17
+ const groups = useAdminNavGroups();
11
18
 
12
19
  return (
13
20
  <DispatchShell
@@ -16,24 +23,85 @@ export default function AdminOverviewRoute() {
16
23
  defaultValue: "Workspace controls and operations",
17
24
  })}
18
25
  >
19
- <section className="max-w-2xl space-y-2">
20
- <h2 className="text-base font-semibold text-foreground">
21
- {t("dispatch.pages.adminWelcome", {
22
- defaultValue: "Workspace control plane",
23
- })}
24
- </h2>
25
- <p className="text-sm leading-6 text-muted-foreground">
26
- {t("dispatch.pages.adminChooseArea", {
27
- defaultValue:
28
- "Manage apps, connections, automations, and operational tools from one place.",
29
- })}
30
- </p>
31
- <p className="text-sm leading-6 text-muted-foreground">
32
- {t("dispatch.pages.adminChooseAreaHint", {
33
- defaultValue: "Choose an area from the Admin navigation.",
34
- })}
35
- </p>
26
+ <section className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
27
+ {groups.map((group, index) => (
28
+ <AdminAreaCard key={group.id} group={group} index={index} />
29
+ ))}
36
30
  </section>
37
31
  </DispatchShell>
38
32
  );
39
33
  }
34
+
35
+ const GROUP_DESCRIPTIONS: Record<string, string> = {
36
+ workspace: "Apps, resources, and workspace setup",
37
+ operations: "Monitor runs, metrics, and audit history",
38
+ automation: "Control scheduled work and delivery",
39
+ connections: "Manage integrations, secrets, and messaging",
40
+ "agent-platform": "Manage connected agents and dreams",
41
+ "workspace-extensions": "Tools added by this workspace",
42
+ };
43
+
44
+ const GROUP_TONES = [
45
+ "bg-primary/10 text-primary",
46
+ "bg-secondary text-secondary-foreground",
47
+ "bg-accent text-accent-foreground",
48
+ "bg-muted text-foreground",
49
+ ] as const;
50
+
51
+ function AdminAreaCard({
52
+ group,
53
+ index,
54
+ }: {
55
+ group: AdminNavGroup;
56
+ index: number;
57
+ }) {
58
+ const t = useT();
59
+ const GroupIcon = group.items[0]?.icon;
60
+ const tone = GROUP_TONES[index % GROUP_TONES.length];
61
+
62
+ return (
63
+ <div className="rounded-2xl border border-transparent bg-card p-4 transition-[border-color,background-color] hover:border-border hover:bg-card/80">
64
+ <div className="flex items-start gap-3">
65
+ <span
66
+ className={`flex size-10 shrink-0 items-center justify-center rounded-xl ${tone}`}
67
+ >
68
+ {GroupIcon ? <GroupIcon size={19} /> : null}
69
+ </span>
70
+ <div className="min-w-0">
71
+ <h2 className="text-sm font-semibold text-foreground">
72
+ {t(group.labelKey, { defaultValue: group.label })}
73
+ </h2>
74
+ <p className="mt-1 text-xs leading-5 text-muted-foreground">
75
+ {GROUP_DESCRIPTIONS[group.id] || "Workspace tools and controls"}
76
+ </p>
77
+ </div>
78
+ </div>
79
+
80
+ <div className="mt-4 space-y-1">
81
+ {group.items
82
+ .filter((item) => item.id !== "admin-overview")
83
+ .map((item) => {
84
+ const ItemIcon = item.icon;
85
+ return (
86
+ <Link
87
+ key={item.id}
88
+ to={item.adminTo ?? item.to}
89
+ className="group flex min-h-9 items-center gap-2 rounded-lg px-2 text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
90
+ >
91
+ {ItemIcon ? <ItemIcon size={15} className="shrink-0" /> : null}
92
+ <span className="min-w-0 flex-1 truncate">
93
+ {t(`dispatch.nav.${item.id}`, {
94
+ defaultValue: item.label,
95
+ })}
96
+ </span>
97
+ <IconChevronRight
98
+ size={15}
99
+ className="shrink-0 opacity-0 transition-opacity group-hover:opacity-100"
100
+ />
101
+ </Link>
102
+ );
103
+ })}
104
+ </div>
105
+ </div>
106
+ );
107
+ }