@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.
- package/dist/actions/index.d.ts.map +1 -1
- package/dist/actions/index.js +4 -0
- package/dist/actions/index.js.map +1 -1
- package/dist/actions/list-curated-workspace-templates.d.ts +3 -0
- package/dist/actions/list-curated-workspace-templates.d.ts.map +1 -0
- package/dist/actions/list-curated-workspace-templates.js +10 -0
- package/dist/actions/list-curated-workspace-templates.js.map +1 -0
- package/dist/actions/remix-workspace-template.d.ts +60 -0
- package/dist/actions/remix-workspace-template.d.ts.map +1 -0
- package/dist/actions/remix-workspace-template.js +78 -0
- package/dist/actions/remix-workspace-template.js.map +1 -0
- package/dist/actions/upsert-destination.d.ts +12 -12
- package/dist/components/layout/Layout.d.ts.map +1 -1
- package/dist/components/layout/Layout.js +1 -0
- package/dist/components/layout/Layout.js.map +1 -1
- package/dist/components/workspace-template-card.d.ts +52 -0
- package/dist/components/workspace-template-card.d.ts.map +1 -0
- package/dist/components/workspace-template-card.js +104 -0
- package/dist/components/workspace-template-card.js.map +1 -0
- package/dist/hooks/use-navigation-state.d.ts.map +1 -1
- package/dist/hooks/use-navigation-state.js +4 -2
- package/dist/hooks/use-navigation-state.js.map +1 -1
- package/dist/lib/other-apps.d.ts +14 -0
- package/dist/lib/other-apps.d.ts.map +1 -0
- package/dist/lib/other-apps.js +30 -0
- package/dist/lib/other-apps.js.map +1 -0
- package/dist/routes/pages/apps.d.ts.map +1 -1
- package/dist/routes/pages/apps.js +37 -2
- package/dist/routes/pages/apps.js.map +1 -1
- package/dist/server/lib/app-creation-store.d.ts +1 -0
- package/dist/server/lib/app-creation-store.d.ts.map +1 -1
- package/dist/server/lib/app-creation-store.js +1 -1
- package/dist/server/lib/app-creation-store.js.map +1 -1
- package/dist/server/lib/curated-workspace-templates.d.ts +22 -0
- package/dist/server/lib/curated-workspace-templates.d.ts.map +1 -0
- package/dist/server/lib/curated-workspace-templates.js +137 -0
- package/dist/server/lib/curated-workspace-templates.js.map +1 -0
- package/package.json +2 -2
- package/src/actions/index.ts +4 -0
- package/src/actions/list-curated-workspace-templates.spec.ts +36 -0
- package/src/actions/list-curated-workspace-templates.ts +12 -0
- package/src/actions/remix-workspace-template.spec.ts +116 -0
- package/src/actions/remix-workspace-template.ts +99 -0
- package/src/components/layout/Layout.tsx +1 -0
- package/src/components/workspace-template-card.spec.tsx +174 -0
- package/src/components/workspace-template-card.tsx +361 -0
- package/src/hooks/use-navigation-state.ts +4 -1
- package/src/lib/other-apps.spec.ts +76 -0
- package/src/lib/other-apps.ts +44 -0
- package/src/routes/pages/apps.tsx +165 -0
- package/src/server/lib/app-creation-store.ts +1 -1
- package/src/server/lib/curated-workspace-templates.spec.ts +56 -0
- 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
|
-
|
|
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
|
+
}
|