@agent-native/dispatch 0.14.14 → 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/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/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/workspace-template-card.spec.tsx +174 -0
- package/src/components/workspace-template-card.tsx +361 -0
- 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
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { useActionQuery, useT } from "@agent-native/core/client";
|
|
2
2
|
import {
|
|
3
3
|
IconApps,
|
|
4
|
+
IconArrowUpRight,
|
|
4
5
|
IconChevronDown,
|
|
5
6
|
IconEyeOff,
|
|
6
7
|
IconPlus,
|
|
8
|
+
IconStack2,
|
|
7
9
|
} from "@tabler/icons-react";
|
|
8
10
|
import { useState } from "react";
|
|
9
11
|
|
|
@@ -18,6 +20,15 @@ import {
|
|
|
18
20
|
} from "../../components/ui/collapsible";
|
|
19
21
|
import { Skeleton } from "../../components/ui/skeleton";
|
|
20
22
|
import { WorkspaceAppCard } from "../../components/workspace-app-card";
|
|
23
|
+
import {
|
|
24
|
+
WorkspaceTemplatesSection,
|
|
25
|
+
type CuratedWorkspaceTemplatesResult,
|
|
26
|
+
type WorkspaceTemplateLabels,
|
|
27
|
+
} from "../../components/workspace-template-card";
|
|
28
|
+
import {
|
|
29
|
+
filterOtherApps,
|
|
30
|
+
type ConnectedAppSummary,
|
|
31
|
+
} from "../../lib/other-apps";
|
|
21
32
|
import { cn } from "../../lib/utils";
|
|
22
33
|
import type { WorkspaceAppSummary } from "../../lib/workspace-apps";
|
|
23
34
|
|
|
@@ -38,6 +49,11 @@ export default function AppsRoute() {
|
|
|
38
49
|
includeAgentCards: false,
|
|
39
50
|
includeArchived: true,
|
|
40
51
|
});
|
|
52
|
+
const connectedAppsQuery = useActionQuery("list-connected-agents", {});
|
|
53
|
+
const curatedTemplatesQuery = useActionQuery(
|
|
54
|
+
"list-curated-workspace-templates",
|
|
55
|
+
{},
|
|
56
|
+
);
|
|
41
57
|
const { data: apps = [], isLoading: appsLoading } = appsQuery;
|
|
42
58
|
const { data: workspace } = useActionQuery(
|
|
43
59
|
"get-workspace-info",
|
|
@@ -51,7 +67,25 @@ export default function AppsRoute() {
|
|
|
51
67
|
);
|
|
52
68
|
const visibleApps = allApps.filter((app) => !app.archived);
|
|
53
69
|
const archivedApps = allApps.filter((app) => app.archived);
|
|
70
|
+
const otherApps = filterOtherApps(
|
|
71
|
+
(connectedAppsQuery.data || []) as ConnectedAppSummary[],
|
|
72
|
+
allApps,
|
|
73
|
+
);
|
|
54
74
|
const showAppSkeletons = appsLoading && allApps.length === 0;
|
|
75
|
+
const templateLabels: WorkspaceTemplateLabels = {
|
|
76
|
+
appId: t("dispatch.pages.remixAppIdLabel"),
|
|
77
|
+
appIdDescription: t("dispatch.pages.remixAppIdDescription"),
|
|
78
|
+
cancel: t("dispatch.pages.cancel"),
|
|
79
|
+
integrationSetup: t("dispatch.pages.integrationSetup"),
|
|
80
|
+
installed: t("dispatch.pages.alreadyInWorkspace"),
|
|
81
|
+
remix: t("dispatch.pages.remix"),
|
|
82
|
+
remixing: t("dispatch.pages.remixing"),
|
|
83
|
+
remixSuccess: t("dispatch.pages.remixSuccess"),
|
|
84
|
+
remixError: t("dispatch.pages.remixError"),
|
|
85
|
+
appIdRequired: t("dispatch.pages.appIdRequired"),
|
|
86
|
+
source: t("dispatch.pages.source"),
|
|
87
|
+
viewLiveApp: t("dispatch.pages.viewLiveApp"),
|
|
88
|
+
};
|
|
55
89
|
|
|
56
90
|
return (
|
|
57
91
|
<DispatchShell
|
|
@@ -123,6 +157,65 @@ export default function AppsRoute() {
|
|
|
123
157
|
)}
|
|
124
158
|
</section>
|
|
125
159
|
|
|
160
|
+
{curatedTemplatesQuery.isError ? (
|
|
161
|
+
<section className="space-y-3 border-t pt-4">
|
|
162
|
+
<ActionQueryError
|
|
163
|
+
error={curatedTemplatesQuery.error}
|
|
164
|
+
onRetry={() => void curatedTemplatesQuery.refetch()}
|
|
165
|
+
/>
|
|
166
|
+
</section>
|
|
167
|
+
) : curatedTemplatesQuery.data ? (
|
|
168
|
+
<section className="space-y-3 border-t pt-4">
|
|
169
|
+
<div className="flex min-w-0 items-start gap-2">
|
|
170
|
+
<IconStack2
|
|
171
|
+
size={16}
|
|
172
|
+
className="mt-0.5 shrink-0 text-muted-foreground"
|
|
173
|
+
/>
|
|
174
|
+
<div className="min-w-0">
|
|
175
|
+
<h2 className="truncate text-sm font-semibold text-foreground">
|
|
176
|
+
{t("dispatch.pages.curatedTemplates")}
|
|
177
|
+
</h2>
|
|
178
|
+
<p className="mt-0.5 text-xs text-muted-foreground">
|
|
179
|
+
{t("dispatch.pages.curatedTemplatesDescription")}
|
|
180
|
+
</p>
|
|
181
|
+
</div>
|
|
182
|
+
</div>
|
|
183
|
+
<WorkspaceTemplatesSection
|
|
184
|
+
templates={
|
|
185
|
+
curatedTemplatesQuery.data as CuratedWorkspaceTemplatesResult
|
|
186
|
+
}
|
|
187
|
+
labels={templateLabels}
|
|
188
|
+
onRemixSuccess={() => {
|
|
189
|
+
void appsQuery.refetch();
|
|
190
|
+
void curatedTemplatesQuery.refetch();
|
|
191
|
+
}}
|
|
192
|
+
/>
|
|
193
|
+
</section>
|
|
194
|
+
) : null}
|
|
195
|
+
|
|
196
|
+
{connectedAppsQuery.isError ? (
|
|
197
|
+
<section className="space-y-3 border-t pt-4">
|
|
198
|
+
<OtherAppsHeading count={0} />
|
|
199
|
+
<ActionQueryError
|
|
200
|
+
error={connectedAppsQuery.error}
|
|
201
|
+
onRetry={() => void connectedAppsQuery.refetch()}
|
|
202
|
+
/>
|
|
203
|
+
</section>
|
|
204
|
+
) : connectedAppsQuery.isLoading || otherApps.length > 0 ? (
|
|
205
|
+
<section className="space-y-3 border-t pt-4">
|
|
206
|
+
<OtherAppsHeading count={otherApps.length} />
|
|
207
|
+
{connectedAppsQuery.isLoading ? (
|
|
208
|
+
<OtherAppsSkeletonGrid />
|
|
209
|
+
) : (
|
|
210
|
+
<div className="grid auto-rows-fr gap-3 md:grid-cols-2 xl:grid-cols-3">
|
|
211
|
+
{otherApps.map((app) => (
|
|
212
|
+
<OtherAppCard key={app.id} app={app} />
|
|
213
|
+
))}
|
|
214
|
+
</div>
|
|
215
|
+
)}
|
|
216
|
+
</section>
|
|
217
|
+
) : null}
|
|
218
|
+
|
|
126
219
|
{archivedApps.length > 0 ? (
|
|
127
220
|
<Collapsible open={showHidden} onOpenChange={setShowHidden}>
|
|
128
221
|
<section className="space-y-3">
|
|
@@ -182,6 +275,78 @@ export default function AppsRoute() {
|
|
|
182
275
|
);
|
|
183
276
|
}
|
|
184
277
|
|
|
278
|
+
function OtherAppsHeading({ count }: { count: number }) {
|
|
279
|
+
const t = useT();
|
|
280
|
+
return (
|
|
281
|
+
<div className="flex min-w-0 items-start gap-2">
|
|
282
|
+
<IconStack2 size={16} className="mt-0.5 shrink-0 text-muted-foreground" />
|
|
283
|
+
<div className="min-w-0">
|
|
284
|
+
<h2 className="truncate text-sm font-semibold text-foreground">
|
|
285
|
+
{t("dispatch.pages.otherApps")}
|
|
286
|
+
</h2>
|
|
287
|
+
<p className="mt-0.5 text-xs text-muted-foreground">
|
|
288
|
+
{t("dispatch.pages.otherAppsDescription")}
|
|
289
|
+
{count > 0
|
|
290
|
+
? ` · ${t("dispatch.pages.availableCount", { count })}`
|
|
291
|
+
: ""}
|
|
292
|
+
</p>
|
|
293
|
+
</div>
|
|
294
|
+
</div>
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function OtherAppsSkeletonGrid() {
|
|
299
|
+
return (
|
|
300
|
+
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
|
301
|
+
{Array.from({ length: 3 }).map((_, index) => (
|
|
302
|
+
<div key={index} className="rounded-lg border bg-card p-4">
|
|
303
|
+
<div className="flex items-start gap-3">
|
|
304
|
+
<Skeleton className="size-8 rounded-md" />
|
|
305
|
+
<div className="flex-1 space-y-3">
|
|
306
|
+
<Skeleton className="h-4 w-32" />
|
|
307
|
+
<Skeleton className="h-3 w-full" />
|
|
308
|
+
<Skeleton className="h-3 w-2/3" />
|
|
309
|
+
</div>
|
|
310
|
+
</div>
|
|
311
|
+
</div>
|
|
312
|
+
))}
|
|
313
|
+
</div>
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function OtherAppCard({ app }: { app: ConnectedAppSummary }) {
|
|
318
|
+
const t = useT();
|
|
319
|
+
return (
|
|
320
|
+
<a
|
|
321
|
+
href={app.url}
|
|
322
|
+
target="_blank"
|
|
323
|
+
rel="noopener noreferrer"
|
|
324
|
+
className="group flex min-h-[116px] items-start gap-3 rounded-xl border border-border/60 bg-card/40 p-4 transition-[background-color,border-color] hover:border-foreground/20 hover:bg-accent/15 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
325
|
+
>
|
|
326
|
+
<span className="flex size-8 shrink-0 items-center justify-center rounded-md bg-muted text-xs font-semibold text-muted-foreground">
|
|
327
|
+
{app.name.charAt(0).toUpperCase()}
|
|
328
|
+
</span>
|
|
329
|
+
<span className="min-w-0 flex-1">
|
|
330
|
+
<span className="flex items-start justify-between gap-3">
|
|
331
|
+
<span className="truncate text-sm font-semibold text-foreground">
|
|
332
|
+
{app.name}
|
|
333
|
+
</span>
|
|
334
|
+
<IconArrowUpRight
|
|
335
|
+
size={15}
|
|
336
|
+
className="shrink-0 text-muted-foreground transition-transform group-hover:-translate-y-0.5 group-hover:translate-x-0.5"
|
|
337
|
+
/>
|
|
338
|
+
</span>
|
|
339
|
+
<span className="mt-2 line-clamp-2 block text-[13px] leading-5 text-muted-foreground">
|
|
340
|
+
{app.description || app.url}
|
|
341
|
+
</span>
|
|
342
|
+
<span className="mt-3 block text-xs font-medium text-foreground">
|
|
343
|
+
{t("dispatch.pages.openApp")}
|
|
344
|
+
</span>
|
|
345
|
+
</span>
|
|
346
|
+
</a>
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
|
|
185
350
|
function AppsSkeletonGrid() {
|
|
186
351
|
return (
|
|
187
352
|
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
|
@@ -1522,7 +1522,7 @@ function slugify(value: string): string {
|
|
|
1522
1522
|
.slice(0, 64);
|
|
1523
1523
|
}
|
|
1524
1524
|
|
|
1525
|
-
function isLocalAppCreationRuntime(): boolean {
|
|
1525
|
+
export function isLocalAppCreationRuntime(): boolean {
|
|
1526
1526
|
if (process.env.NODE_ENV === "production") return false;
|
|
1527
1527
|
if (
|
|
1528
1528
|
process.env.NETLIFY ||
|