@agent-native/dispatch 0.14.13 → 0.15.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 (53) hide show
  1. package/dist/actions/index.d.ts.map +1 -1
  2. package/dist/actions/index.js +4 -0
  3. package/dist/actions/index.js.map +1 -1
  4. package/dist/actions/list-curated-workspace-templates.d.ts +3 -0
  5. package/dist/actions/list-curated-workspace-templates.d.ts.map +1 -0
  6. package/dist/actions/list-curated-workspace-templates.js +10 -0
  7. package/dist/actions/list-curated-workspace-templates.js.map +1 -0
  8. package/dist/actions/remix-workspace-template.d.ts +60 -0
  9. package/dist/actions/remix-workspace-template.d.ts.map +1 -0
  10. package/dist/actions/remix-workspace-template.js +78 -0
  11. package/dist/actions/remix-workspace-template.js.map +1 -0
  12. package/dist/actions/upsert-destination.d.ts +12 -12
  13. package/dist/components/layout/Layout.d.ts.map +1 -1
  14. package/dist/components/layout/Layout.js +1 -0
  15. package/dist/components/layout/Layout.js.map +1 -1
  16. package/dist/components/workspace-template-card.d.ts +52 -0
  17. package/dist/components/workspace-template-card.d.ts.map +1 -0
  18. package/dist/components/workspace-template-card.js +104 -0
  19. package/dist/components/workspace-template-card.js.map +1 -0
  20. package/dist/hooks/use-navigation-state.d.ts.map +1 -1
  21. package/dist/hooks/use-navigation-state.js +4 -2
  22. package/dist/hooks/use-navigation-state.js.map +1 -1
  23. package/dist/lib/other-apps.d.ts +14 -0
  24. package/dist/lib/other-apps.d.ts.map +1 -0
  25. package/dist/lib/other-apps.js +30 -0
  26. package/dist/lib/other-apps.js.map +1 -0
  27. package/dist/routes/pages/apps.d.ts.map +1 -1
  28. package/dist/routes/pages/apps.js +37 -2
  29. package/dist/routes/pages/apps.js.map +1 -1
  30. package/dist/server/lib/app-creation-store.d.ts +1 -0
  31. package/dist/server/lib/app-creation-store.d.ts.map +1 -1
  32. package/dist/server/lib/app-creation-store.js +1 -1
  33. package/dist/server/lib/app-creation-store.js.map +1 -1
  34. package/dist/server/lib/curated-workspace-templates.d.ts +22 -0
  35. package/dist/server/lib/curated-workspace-templates.d.ts.map +1 -0
  36. package/dist/server/lib/curated-workspace-templates.js +137 -0
  37. package/dist/server/lib/curated-workspace-templates.js.map +1 -0
  38. package/package.json +2 -2
  39. package/src/actions/index.ts +4 -0
  40. package/src/actions/list-curated-workspace-templates.spec.ts +36 -0
  41. package/src/actions/list-curated-workspace-templates.ts +12 -0
  42. package/src/actions/remix-workspace-template.spec.ts +116 -0
  43. package/src/actions/remix-workspace-template.ts +99 -0
  44. package/src/components/layout/Layout.tsx +1 -0
  45. package/src/components/workspace-template-card.spec.tsx +174 -0
  46. package/src/components/workspace-template-card.tsx +361 -0
  47. package/src/hooks/use-navigation-state.ts +4 -1
  48. package/src/lib/other-apps.spec.ts +76 -0
  49. package/src/lib/other-apps.ts +44 -0
  50. package/src/routes/pages/apps.tsx +165 -0
  51. package/src/server/lib/app-creation-store.ts +1 -1
  52. package/src/server/lib/curated-workspace-templates.spec.ts +56 -0
  53. package/src/server/lib/curated-workspace-templates.ts +187 -0
@@ -0,0 +1,361 @@
1
+ import { useActionMutation } from "@agent-native/core/client";
2
+ import {
3
+ IconArrowUpRight,
4
+ IconCircleCheck,
5
+ IconCopy,
6
+ IconExternalLink,
7
+ IconFileText,
8
+ IconPlugConnected,
9
+ } from "@tabler/icons-react";
10
+ import { useEffect, useMemo, useState, type ReactNode } from "react";
11
+ import { toast } from "sonner";
12
+
13
+ import { cn } from "../lib/utils";
14
+ import { Alert, AlertDescription } from "./ui/alert";
15
+ import { Badge } from "./ui/badge";
16
+ import { Button } from "./ui/button";
17
+ import {
18
+ Card,
19
+ CardContent,
20
+ CardDescription,
21
+ CardFooter,
22
+ CardHeader,
23
+ CardTitle,
24
+ } from "./ui/card";
25
+ import {
26
+ Dialog,
27
+ DialogContent,
28
+ DialogDescription,
29
+ DialogFooter,
30
+ DialogHeader,
31
+ DialogTitle,
32
+ DialogTrigger,
33
+ } from "./ui/dialog";
34
+ import { Input } from "./ui/input";
35
+ import { Label } from "./ui/label";
36
+ import { Spinner } from "./ui/spinner";
37
+
38
+ export interface CuratedWorkspaceTemplate {
39
+ id?: string | null;
40
+ templateId?: string | null;
41
+ appId?: string | null;
42
+ name: string;
43
+ description?: string | null;
44
+ source?: string | null;
45
+ sourceDescription?: string | null;
46
+ integrationSetup?: string | null;
47
+ setupNote?: string | null;
48
+ installed?: boolean | null;
49
+ installedAppId?: string | null;
50
+ liveUrl?: string | null;
51
+ productUrl?: string | null;
52
+ }
53
+
54
+ export type CuratedWorkspaceTemplatesResult =
55
+ | CuratedWorkspaceTemplate[]
56
+ | {
57
+ templates: CuratedWorkspaceTemplate[];
58
+ };
59
+
60
+ export interface WorkspaceTemplateLabels {
61
+ appId: string;
62
+ appIdDescription: string;
63
+ cancel: string;
64
+ integrationSetup: string;
65
+ installed: string;
66
+ remix: string;
67
+ remixing: string;
68
+ remixSuccess: string;
69
+ remixError: string;
70
+ appIdRequired: string;
71
+ source: string;
72
+ viewLiveApp: string;
73
+ }
74
+
75
+ const DEFAULT_LABELS: WorkspaceTemplateLabels = {
76
+ appId: "App ID",
77
+ appIdDescription: "Choose the URL-safe id for the new workspace app.",
78
+ cancel: "Cancel",
79
+ integrationSetup: "Integration setup",
80
+ installed: "Installed",
81
+ remix: "Remix into workspace",
82
+ remixing: "Remixing…",
83
+ remixSuccess: "Template remixed into your workspace.",
84
+ remixError: "Could not remix this template",
85
+ appIdRequired: "App ID is required.",
86
+ source: "Source",
87
+ viewLiveApp: "View the live app",
88
+ };
89
+
90
+ export interface WorkspaceTemplateCardProps {
91
+ template: CuratedWorkspaceTemplate;
92
+ defaultAppId?: string;
93
+ labels?: Partial<WorkspaceTemplateLabels>;
94
+ className?: string;
95
+ onRemixSuccess?: (
96
+ result: unknown,
97
+ template: CuratedWorkspaceTemplate,
98
+ ) => void;
99
+ }
100
+
101
+ function slugifyAppId(value: string): string {
102
+ return (
103
+ value
104
+ .trim()
105
+ .toLowerCase()
106
+ .replace(/[^a-z0-9]+/g, "-")
107
+ .replace(/^-+|-+$/g, "")
108
+ .slice(0, 48) || "new-app"
109
+ );
110
+ }
111
+
112
+ function templateIdFor(template: CuratedWorkspaceTemplate): string {
113
+ return template.templateId || template.id || template.appId || template.name;
114
+ }
115
+
116
+ function defaultAppIdFor(
117
+ template: CuratedWorkspaceTemplate,
118
+ defaultAppId?: string,
119
+ ): string {
120
+ if (defaultAppId) return slugifyAppId(defaultAppId);
121
+ const sourceId = slugifyAppId(template.appId || template.name);
122
+ return `${sourceId}-remix`;
123
+ }
124
+
125
+ function stringifyError(error: unknown): string {
126
+ if (error instanceof Error && error.message) return error.message;
127
+ if (typeof error === "string") return error;
128
+ return "Unknown error";
129
+ }
130
+
131
+ function mergeLabels(
132
+ labels?: Partial<WorkspaceTemplateLabels>,
133
+ ): WorkspaceTemplateLabels {
134
+ return { ...DEFAULT_LABELS, ...labels };
135
+ }
136
+
137
+ export function WorkspaceTemplateCard({
138
+ template,
139
+ defaultAppId,
140
+ labels: labelOverrides,
141
+ className,
142
+ onRemixSuccess,
143
+ }: WorkspaceTemplateCardProps) {
144
+ const labels = useMemo(() => mergeLabels(labelOverrides), [labelOverrides]);
145
+ const [open, setOpen] = useState(false);
146
+ const [appId, setAppId] = useState(() =>
147
+ defaultAppIdFor(template, defaultAppId),
148
+ );
149
+ const isInstalled = Boolean(template.installed || template.installedAppId);
150
+ const liveUrl = template.liveUrl || template.productUrl;
151
+ const setupNote = template.integrationSetup || template.setupNote;
152
+ const remix = useActionMutation("remix-workspace-template", {
153
+ onSuccess: (result) => {
154
+ toast.success(labels.remixSuccess);
155
+ setOpen(false);
156
+ onRemixSuccess?.(result, template);
157
+ },
158
+ onError: (error) =>
159
+ toast.error(`${labels.remixError}: ${stringifyError(error)}`),
160
+ });
161
+
162
+ useEffect(() => {
163
+ if (open) {
164
+ setAppId(defaultAppIdFor(template, defaultAppId));
165
+ }
166
+ }, [defaultAppId, open, template]);
167
+
168
+ function submitRemix() {
169
+ const trimmedAppId = appId.trim();
170
+ if (!trimmedAppId) {
171
+ toast.error(labels.appIdRequired);
172
+ return;
173
+ }
174
+
175
+ remix.mutate({
176
+ templateId: templateIdFor(template),
177
+ appId: trimmedAppId,
178
+ });
179
+ }
180
+
181
+ return (
182
+ <Card
183
+ className={cn(
184
+ "flex h-full flex-col border-border/60 bg-card/40 shadow-none transition-[background-color,border-color] hover:border-foreground/20 hover:bg-accent/15",
185
+ className,
186
+ )}
187
+ >
188
+ <CardHeader className="gap-2 p-4">
189
+ <div className="flex items-start justify-between gap-3">
190
+ <div className="flex min-w-0 items-start gap-2.5">
191
+ <span className="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-md border border-border/70 bg-muted/40 text-muted-foreground">
192
+ <IconFileText size={16} />
193
+ </span>
194
+ <div className="min-w-0">
195
+ <CardTitle className="truncate text-sm font-semibold">
196
+ {template.name}
197
+ </CardTitle>
198
+ {template.source || template.sourceDescription ? (
199
+ <CardDescription className="mt-1 truncate text-xs">
200
+ <span className="font-medium text-foreground/70">
201
+ {labels.source}:
202
+ </span>{" "}
203
+ {template.sourceDescription || template.source}
204
+ </CardDescription>
205
+ ) : null}
206
+ </div>
207
+ </div>
208
+ {isInstalled ? (
209
+ <Badge
210
+ variant="outline"
211
+ className="shrink-0 gap-1 border-primary/30 bg-primary/5 text-primary"
212
+ >
213
+ <IconCircleCheck size={13} />
214
+ {labels.installed}
215
+ </Badge>
216
+ ) : null}
217
+ </div>
218
+ </CardHeader>
219
+
220
+ <CardContent className="flex flex-1 flex-col gap-3 p-4 pt-0">
221
+ {template.description ? (
222
+ <p className="line-clamp-3 text-[13px] leading-5 text-muted-foreground">
223
+ {template.description}
224
+ </p>
225
+ ) : null}
226
+
227
+ {setupNote ? (
228
+ <Alert className="border-border/60 bg-muted/25 px-3 py-2 [&>svg]:left-3 [&>svg]:top-2.5">
229
+ <IconPlugConnected size={15} />
230
+ <AlertDescription className="text-xs leading-5">
231
+ <span className="font-medium text-foreground/80">
232
+ {labels.integrationSetup}:
233
+ </span>{" "}
234
+ {setupNote}
235
+ </AlertDescription>
236
+ </Alert>
237
+ ) : null}
238
+ </CardContent>
239
+
240
+ <CardFooter className="flex flex-wrap justify-between gap-2 p-4 pt-0">
241
+ {liveUrl ? (
242
+ <Button variant="link" size="sm" className="h-8 px-0" asChild>
243
+ <a href={liveUrl} target="_blank" rel="noreferrer">
244
+ {labels.viewLiveApp}
245
+ <IconExternalLink />
246
+ </a>
247
+ </Button>
248
+ ) : (
249
+ <span />
250
+ )}
251
+
252
+ <Dialog
253
+ open={open}
254
+ onOpenChange={(nextOpen) => {
255
+ if (!remix.isPending) setOpen(nextOpen);
256
+ }}
257
+ >
258
+ <DialogTrigger asChild>
259
+ <Button type="button" size="sm">
260
+ <IconCopy />
261
+ {labels.remix}
262
+ </Button>
263
+ </DialogTrigger>
264
+ <DialogContent className="max-w-md">
265
+ <DialogHeader>
266
+ <DialogTitle>{template.name}</DialogTitle>
267
+ <DialogDescription>{labels.appIdDescription}</DialogDescription>
268
+ </DialogHeader>
269
+ <form
270
+ className="flex flex-col gap-4"
271
+ onSubmit={(event) => {
272
+ event.preventDefault();
273
+ submitRemix();
274
+ }}
275
+ >
276
+ <div className="flex flex-col gap-2">
277
+ <Label
278
+ htmlFor={`workspace-template-app-id-${templateIdFor(template)}`}
279
+ >
280
+ {labels.appId}
281
+ </Label>
282
+ <Input
283
+ id={`workspace-template-app-id-${templateIdFor(template)}`}
284
+ value={appId}
285
+ autoComplete="off"
286
+ onChange={(event) => setAppId(event.target.value)}
287
+ disabled={remix.isPending}
288
+ />
289
+ </div>
290
+ <DialogFooter>
291
+ <Button
292
+ type="button"
293
+ variant="outline"
294
+ onClick={() => setOpen(false)}
295
+ disabled={remix.isPending}
296
+ >
297
+ {labels.cancel}
298
+ </Button>
299
+ <Button type="submit" disabled={remix.isPending}>
300
+ {remix.isPending ? <Spinner /> : <IconArrowUpRight />}
301
+ {remix.isPending ? labels.remixing : labels.remix}
302
+ </Button>
303
+ </DialogFooter>
304
+ </form>
305
+ </DialogContent>
306
+ </Dialog>
307
+ </CardFooter>
308
+ </Card>
309
+ );
310
+ }
311
+
312
+ export interface WorkspaceTemplatesSectionProps {
313
+ templates: CuratedWorkspaceTemplatesResult;
314
+ title?: ReactNode;
315
+ defaultAppId?: string;
316
+ labels?: Partial<WorkspaceTemplateLabels>;
317
+ className?: string;
318
+ cardClassName?: string;
319
+ onRemixSuccess?: (
320
+ result: unknown,
321
+ template: CuratedWorkspaceTemplate,
322
+ ) => void;
323
+ }
324
+
325
+ function getTemplateItems(
326
+ result: CuratedWorkspaceTemplatesResult,
327
+ ): CuratedWorkspaceTemplate[] {
328
+ return Array.isArray(result) ? result : result.templates;
329
+ }
330
+
331
+ export function WorkspaceTemplatesSection({
332
+ templates: result,
333
+ title,
334
+ defaultAppId,
335
+ labels,
336
+ className,
337
+ cardClassName,
338
+ onRemixSuccess,
339
+ }: WorkspaceTemplatesSectionProps) {
340
+ const templates = getTemplateItems(result);
341
+
342
+ if (templates.length === 0) return null;
343
+
344
+ return (
345
+ <section className={cn("flex flex-col gap-3", className)}>
346
+ {title ? <div className="text-sm font-semibold">{title}</div> : null}
347
+ <div className="grid gap-3 md:grid-cols-2">
348
+ {templates.map((template) => (
349
+ <WorkspaceTemplateCard
350
+ key={templateIdFor(template)}
351
+ template={template}
352
+ defaultAppId={defaultAppId}
353
+ labels={labels}
354
+ className={cardClassName}
355
+ onRemixSuccess={onRemixSuccess}
356
+ />
357
+ ))}
358
+ </div>
359
+ </section>
360
+ );
361
+ }
@@ -2,6 +2,7 @@ import {
2
2
  agentNativePath,
3
3
  appBasePath,
4
4
  appPath,
5
+ isAgentChatHomeHandoffActive,
5
6
  markAgentChatHomeHandoff,
6
7
  } from "@agent-native/core/client";
7
8
  import { extensionIdFromPathname } from "@agent-native/core/client/extensions";
@@ -86,7 +87,9 @@ export function useNavigationState(extensions?: DispatchExtensionConfig) {
86
87
  isChatPath(routerPath(location.pathname)) &&
87
88
  !isChatPath(pathnameFromPath(nextPath))
88
89
  ) {
89
- markAgentChatHomeHandoff("dispatch");
90
+ if (isAgentChatHomeHandoffActive("dispatch")) {
91
+ markAgentChatHomeHandoff("dispatch");
92
+ }
90
93
  }
91
94
  navigate(nextPath);
92
95
  qc.setQueryData(["navigate-command"], null);
@@ -0,0 +1,76 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { filterOtherApps } from "./other-apps.js";
4
+
5
+ describe("filterOtherApps", () => {
6
+ it("keeps available linked apps while excluding workspace apps", () => {
7
+ expect(
8
+ filterOtherApps(
9
+ [
10
+ {
11
+ id: "slides",
12
+ name: "Slides",
13
+ url: "https://slides.agent-native.com",
14
+ },
15
+ {
16
+ id: "analytics",
17
+ name: "Analytics",
18
+ description: "Explore data",
19
+ url: "https://analytics.agent-native.com",
20
+ },
21
+ {
22
+ id: "coach",
23
+ name: "Coach",
24
+ url: "https://workspace.example.com/coach",
25
+ source: "workspace",
26
+ },
27
+ {
28
+ id: "dispatch",
29
+ name: "Dispatch",
30
+ url: "https://dispatch.agent-native.com",
31
+ },
32
+ ],
33
+ [{ id: "coach" }],
34
+ ),
35
+ ).toEqual([
36
+ {
37
+ id: "analytics",
38
+ name: "Analytics",
39
+ description: "Explore data",
40
+ url: "https://analytics.agent-native.com",
41
+ },
42
+ {
43
+ id: "slides",
44
+ name: "Slides",
45
+ url: "https://slides.agent-native.com",
46
+ },
47
+ ]);
48
+ });
49
+
50
+ it("drops invalid URLs and duplicate app ids", () => {
51
+ expect(
52
+ filterOtherApps(
53
+ [
54
+ {
55
+ id: "analytics",
56
+ name: "Analytics",
57
+ url: "https://analytics.agent-native.com",
58
+ },
59
+ {
60
+ id: "Analytics",
61
+ name: "Analytics duplicate",
62
+ url: "https://duplicate.example.com",
63
+ },
64
+ { id: "relative", name: "Relative", url: "/relative" },
65
+ ],
66
+ [],
67
+ ),
68
+ ).toEqual([
69
+ {
70
+ id: "analytics",
71
+ name: "Analytics",
72
+ url: "https://analytics.agent-native.com",
73
+ },
74
+ ]);
75
+ });
76
+ });
@@ -0,0 +1,44 @@
1
+ export interface ConnectedAppSummary {
2
+ id: string;
3
+ name: string;
4
+ description?: string;
5
+ url: string;
6
+ color?: string;
7
+ source?: "builtin" | "custom" | "workspace";
8
+ }
9
+
10
+ export interface WorkspaceAppId {
11
+ id: string;
12
+ isDispatch?: boolean;
13
+ }
14
+
15
+ function isHttpUrl(value: string): boolean {
16
+ try {
17
+ const url = new URL(value);
18
+ return url.protocol === "http:" || url.protocol === "https:";
19
+ } catch {
20
+ return false;
21
+ }
22
+ }
23
+
24
+ export function filterOtherApps(
25
+ connectedApps: ConnectedAppSummary[],
26
+ workspaceApps: WorkspaceAppId[],
27
+ ): ConnectedAppSummary[] {
28
+ const workspaceAppIds = new Set([
29
+ "dispatch",
30
+ ...workspaceApps.map((app) => app.id.trim().toLowerCase()),
31
+ ]);
32
+ const seen = new Set<string>();
33
+
34
+ return connectedApps
35
+ .filter((app) => {
36
+ const id = app.id.trim().toLowerCase();
37
+ if (!id || workspaceAppIds.has(id) || seen.has(id)) return false;
38
+ if (app.source === "workspace") return false;
39
+ if (!isHttpUrl(app.url)) return false;
40
+ seen.add(id);
41
+ return true;
42
+ })
43
+ .sort((a, b) => a.name.localeCompare(b.name));
44
+ }