@saadnvd1/agent-os 0.3.761 → 0.3.765

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 (37) hide show
  1. package/app/api/artifacts/[id]/route.ts +13 -4
  2. package/app/api/artifacts/[id]/versions/route.ts +16 -0
  3. package/app/api/sessions/[id]/artifacts/route.ts +9 -1
  4. package/app/api/shares/route.test.ts +66 -1
  5. package/app/api/shares/route.ts +15 -0
  6. package/components/Artifacts/ArtifactActions.tsx +308 -0
  7. package/components/Artifacts/ArtifactFullscreen.tsx +58 -0
  8. package/components/Artifacts/ArtifactPreview.tsx +141 -18
  9. package/components/Artifacts/ArtifactRow.tsx +5 -0
  10. package/components/Artifacts/ArtifactsPanel.tsx +2 -0
  11. package/components/Artifacts/VersionSwitcher.tsx +216 -0
  12. package/components/Chat/Artifact.tsx +129 -89
  13. package/components/Share/DownloadMenu.tsx +63 -38
  14. package/components/Share/ShareDialog.tsx +47 -0
  15. package/components/Shares/SharedLinksPanel.tsx +7 -0
  16. package/data/session-artifacts/keys.ts +3 -0
  17. package/data/session-artifacts/queries.ts +25 -0
  18. package/lib/artifacts/actions.ts +75 -0
  19. package/lib/artifacts/store.ts +105 -10
  20. package/lib/artifacts/tools.ts +33 -6
  21. package/lib/artifacts/versions.ts +73 -0
  22. package/lib/chat/events.ts +4 -0
  23. package/lib/chat/worker/host.ts +2 -0
  24. package/lib/connect/control-api.ts +15 -0
  25. package/lib/db/migrations/20261029T1200_artifact_versions.ts +28 -0
  26. package/lib/db/migrations/20261029T1300_shared_links_follow.ts +28 -0
  27. package/lib/security/demo.ts +1 -0
  28. package/lib/session-artifacts/collect.ts +28 -6
  29. package/lib/session-artifacts/derive.ts +3 -1
  30. package/lib/session-artifacts/types.ts +9 -0
  31. package/lib/share/connect.ts +10 -0
  32. package/lib/share/follow.ts +76 -0
  33. package/lib/share/service.ts +6 -0
  34. package/lib/share/store.ts +34 -2
  35. package/lib/share/view.ts +6 -0
  36. package/package.json +1 -1
  37. package/stores/shareUi.ts +6 -2
@@ -1,14 +1,23 @@
1
1
  import { NextResponse } from "next/server";
2
- import { getArtifact, readArtifactHtml } from "@/lib/artifacts/store";
2
+ import {
3
+ getArtifact,
4
+ latestVersion,
5
+ readArtifactHtml,
6
+ } from "@/lib/artifacts/store";
3
7
  import { ARTIFACT_HEADERS, prepareArtifact } from "@/lib/artifacts/serve";
4
8
 
5
- // GET /api/artifacts/[id] - a page an agent showed, sandboxed
9
+ // GET /api/artifacts/[id] - a page an agent showed, sandboxed. That exact
10
+ // version, or with ?v=latest the page's newest.
6
11
  export async function GET(
7
- _request: Request,
12
+ request: Request,
8
13
  { params }: { params: Promise<{ id: string }> }
9
14
  ) {
10
15
  const { id } = await params;
11
- const artifact = getArtifact(id);
16
+ const asked = getArtifact(id);
17
+ const artifact =
18
+ asked && new URL(request.url).searchParams.get("v") === "latest"
19
+ ? latestVersion(asked.page_id)
20
+ : asked;
12
21
  const html = artifact ? await readArtifactHtml(artifact) : null;
13
22
  if (html === null)
14
23
  return NextResponse.json({ error: "Artifact not found" }, { status: 404 });
@@ -0,0 +1,16 @@
1
+ import { NextResponse } from "next/server";
2
+ import { pageVersions } from "@/lib/artifacts/versions";
3
+ import { pdfAvailable } from "@/lib/share/pdf";
4
+
5
+ // GET /api/artifacts/[id]/versions - every version of the page this is one
6
+ // of, oldest first, and what can be done with it
7
+ export async function GET(
8
+ _request: Request,
9
+ { params }: { params: Promise<{ id: string }> }
10
+ ) {
11
+ const { id } = await params;
12
+ const page = pageVersions(id, { pdf: await pdfAvailable() });
13
+ if (!page)
14
+ return NextResponse.json({ error: "Artifact not found" }, { status: 404 });
15
+ return NextResponse.json(page);
16
+ }
@@ -1,4 +1,6 @@
1
1
  import { NextResponse } from "next/server";
2
+ import { artifactActions } from "@/lib/artifacts/actions";
3
+ import { pdfAvailable } from "@/lib/share/pdf";
2
4
  import { peerLinkOf } from "@/lib/hosts/peer-actions";
3
5
  import { collectArtifacts, getSession } from "@/lib/session-artifacts/collect";
4
6
  import { peerArtifacts } from "@/lib/session-artifacts/peer";
@@ -29,5 +31,11 @@ export async function GET(
29
31
  }
30
32
  }
31
33
  const entries = await collectArtifacts(session);
32
- return NextResponse.json({ artifacts: entries.map((e) => e.artifact) });
34
+ const ctx = { pdf: await pdfAvailable() };
35
+ return NextResponse.json({
36
+ artifacts: entries.map((e) => ({
37
+ ...e.artifact,
38
+ actions: artifactActions(e.artifact, ctx),
39
+ })),
40
+ });
33
41
  }
@@ -117,7 +117,7 @@ const one = await import("./[id]/route");
117
117
  const { saveItem } = await import("@/lib/chat/store");
118
118
  const { seedSession } = await import("@/lib/orchestrator/testing");
119
119
  const { createProject } = await import("@/lib/projects");
120
- const { createArtifact } = await import("@/lib/artifacts/store");
120
+ const { createArtifact, saveArtifact } = await import("@/lib/artifacts/store");
121
121
  const { getDb } = await import("@/lib/db");
122
122
 
123
123
  const secret = ["gh", "p_", "b".repeat(36)].join("");
@@ -341,6 +341,71 @@ describe("create", () => {
341
341
  expect(html).toContain("noindex");
342
342
  });
343
343
 
344
+ describe("follow latest", () => {
345
+ const page = (html: string) =>
346
+ `<html><head></head><body>${html}</body></html>`;
347
+ async function create(artifactId: string, extra: object = {}) {
348
+ const body = { kind: "artifact", artifactId, ...extra };
349
+ const p = await (
350
+ await preview.POST(post("/api/shares/preview", body))
351
+ ).json();
352
+ return shares.POST(
353
+ post("/api/shares", {
354
+ ...body,
355
+ sharedAt: p.sharedAt,
356
+ hash: p.hash,
357
+ expiresIn: "7d",
358
+ })
359
+ );
360
+ }
361
+ const stored = () =>
362
+ getDb().prepare(`SELECT follow_latest, version FROM shared_links`).get();
363
+
364
+ it("pins the shown version by default", async () => {
365
+ const sid = chat();
366
+ saveArtifact(sid, "Doc", page("one"), { sameTitle: true });
367
+ const v2 = saveArtifact(sid, "Doc", page("two"), {
368
+ sameTitle: true,
369
+ }).artifact;
370
+ expect((await create(v2.id)).status).toBe(201);
371
+ expect(stored()).toEqual({ follow_latest: 0, version: 2 });
372
+ const listed = await (await shares.GET()).json();
373
+ expect(listed.shares[0]).toMatchObject({ follow: false, version: 2 });
374
+ });
375
+
376
+ it("keeps follow: latest on the link and lists it", async () => {
377
+ const sid = chat();
378
+ const v1 = saveArtifact(sid, "Doc", page("one"), {
379
+ sameTitle: true,
380
+ }).artifact;
381
+ expect((await create(v1.id, { follow: "latest" })).status).toBe(201);
382
+ expect(stored()).toEqual({ follow_latest: 1, version: 1 });
383
+ const listed = await (await shares.GET()).json();
384
+ expect(listed.shares[0]).toMatchObject({ follow: true, version: 1 });
385
+ });
386
+
387
+ it("refuses to follow from an older version, and for a chat", async () => {
388
+ const sid = chat();
389
+ const v1 = saveArtifact(sid, "Doc", page("one"), {
390
+ sameTitle: true,
391
+ }).artifact;
392
+ saveArtifact(sid, "Doc", page("two"), { sameTitle: true });
393
+ const old = await create(v1.id, { follow: "latest" });
394
+ expect(old.status).toBe(409);
395
+ expect((await old.json()).error).toMatch(/share v2/);
396
+ const res = await shares.POST(
397
+ post("/api/shares", {
398
+ kind: "chat",
399
+ sessionId: sid,
400
+ follow: "latest",
401
+ hash: "x",
402
+ })
403
+ );
404
+ expect(res.status).toBe(400);
405
+ expect(seen).toHaveLength(0);
406
+ });
407
+ });
408
+
344
409
  it("says so when Connect isn't linked, and for a bad expiry or hash", async () => {
345
410
  const id = chat();
346
411
  const p = await previewOf(id);
@@ -12,6 +12,7 @@ import {
12
12
  buildPage,
13
13
  parseRequest,
14
14
  } from "@/lib/share/service";
15
+ import { getArtifact, latestVersion } from "@/lib/artifacts/store";
15
16
  import { insertSharedLink, listSharedLinks } from "@/lib/share/store";
16
17
  import type { ShareExpiry } from "@/lib/connect/control-api";
17
18
 
@@ -31,6 +32,16 @@ export async function POST(request: Request) {
31
32
  if (typeof body?.hash !== "string") return fail("hash is required.", 400);
32
33
  if (!linked()) return fail(NOT_LINKED, 409);
33
34
 
35
+ const artifact = req.artifactId ? getArtifact(req.artifactId) : undefined;
36
+ if (req.follow && artifact) {
37
+ const latest = latestVersion(artifact.page_id);
38
+ if (latest && latest.id !== artifact.id)
39
+ return fail(
40
+ `v${artifact.version} isn't the latest; share v${latest.version} to follow the page.`,
41
+ 409
42
+ );
43
+ }
44
+
34
45
  const page = await buildPage(req);
35
46
  if (page.tooBig) return fail(TOO_BIG, 413);
36
47
  if (page.hash !== body.hash)
@@ -53,6 +64,8 @@ export async function POST(request: Request) {
53
64
  expires_at: share.expires_at,
54
65
  created_at: share.created_at,
55
66
  revoked_at: null,
67
+ follow_latest: req.follow ? 1 : 0,
68
+ version: artifact?.version ?? null,
56
69
  });
57
70
  return NextResponse.json(
58
71
  { id: share.id, url: share.url, expiresAt: share.expires_at },
@@ -96,6 +109,8 @@ export async function GET() {
96
109
  status,
97
110
  views: r ? r.view_count : null,
98
111
  lastViewedAt: r?.last_viewed_at ?? null,
112
+ follow: l.follow_latest === 1,
113
+ version: l.version,
99
114
  };
100
115
  }),
101
116
  });
@@ -0,0 +1,308 @@
1
+ "use client";
2
+
3
+ import type { ReactNode } from "react";
4
+
5
+ import {
6
+ Download,
7
+ ExternalLink,
8
+ Loader2,
9
+ Maximize2,
10
+ MoreHorizontal,
11
+ Share2,
12
+ } from "lucide-react";
13
+ import { Button } from "@/components/ui/button";
14
+ import {
15
+ DropdownMenu,
16
+ DropdownMenuContent,
17
+ DropdownMenuItem,
18
+ DropdownMenuTrigger,
19
+ } from "@/components/ui/dropdown-menu";
20
+ import { artifactUrl } from "@/components/Chat/ArtifactFrame";
21
+ import {
22
+ DownloadMenu,
23
+ useShareDownload,
24
+ } from "@/components/Share/DownloadMenu";
25
+ import type { PreviewRequest } from "@/data/shares";
26
+ import type { ArtifactAction, DownloadFormat } from "@/lib/artifacts/actions";
27
+ import { shareUiActions } from "@/stores/shareUi";
28
+ import { cn } from "@/lib/utils";
29
+
30
+ export const ICON_BUTTON =
31
+ "text-muted-foreground hover:text-foreground hover:bg-foreground/[0.06] inline-flex h-11 w-11 items-center justify-center rounded-lg md:h-8 md:w-8";
32
+
33
+ const FORMAT_LABEL: Record<DownloadFormat, string> = {
34
+ pdf: "Download PDF",
35
+ html: "Download HTML page",
36
+ md: "Download Markdown",
37
+ };
38
+
39
+ interface Props {
40
+ // The exact version every action acts on.
41
+ artifactId: string;
42
+ title: string;
43
+ // Named in the share dialog when the page has more than one.
44
+ version?: number;
45
+ actions: ArtifactAction[];
46
+ // icons: the chat card's title bar. labelled: the Artifacts view's header.
47
+ // bar: the Artifacts view's bottom bar on a phone.
48
+ variant: "icons" | "labelled" | "bar";
49
+ onFull?: () => void;
50
+ }
51
+
52
+ // A rendered page's actions: full screen, share, open, download. One place
53
+ // for the chat card and the Artifacts view, from the server's list.
54
+ export function ArtifactActions({
55
+ artifactId,
56
+ title,
57
+ version,
58
+ actions,
59
+ variant,
60
+ onFull,
61
+ }: Props) {
62
+ const has = (id: ArtifactAction["id"]) => actions.some((a) => a.id === id);
63
+ const formats = actions.find((a) => a.id === "download")?.formats ?? [];
64
+ const full = has("fullscreen") && onFull;
65
+ const request: PreviewRequest = {
66
+ kind: "artifact",
67
+ artifactId,
68
+ includeTools: false,
69
+ };
70
+ const href = artifactUrl(artifactId);
71
+ const share = () =>
72
+ shareUiActions.open({ kind: "artifact", artifactId, title, version });
73
+
74
+ if (variant === "bar")
75
+ return (
76
+ <div className="flex gap-2">
77
+ {has("share") && (
78
+ <Button className="h-11 flex-1" onClick={share}>
79
+ <Share2 className="h-4 w-4" /> Share
80
+ </Button>
81
+ )}
82
+ {has("open") && (
83
+ <Button variant="outline" className="h-11 flex-1" asChild>
84
+ <a href={href} target="_blank" rel="noopener noreferrer">
85
+ <ExternalLink className="h-4 w-4" /> Open
86
+ </a>
87
+ </Button>
88
+ )}
89
+ {full && (
90
+ <Button
91
+ variant="outline"
92
+ className="h-11 w-11 shrink-0"
93
+ onClick={onFull}
94
+ aria-label="Full screen"
95
+ >
96
+ <Maximize2 className="h-4 w-4" />
97
+ </Button>
98
+ )}
99
+ {formats.length > 0 && (
100
+ <More request={request} title={title} formats={formats}>
101
+ {(pending) => (
102
+ <Button
103
+ variant="outline"
104
+ className="h-11 w-11 shrink-0"
105
+ aria-label="More"
106
+ >
107
+ {pending ? (
108
+ <Loader2 className="h-4 w-4 animate-spin" />
109
+ ) : (
110
+ <MoreHorizontal className="h-4 w-4" />
111
+ )}
112
+ </Button>
113
+ )}
114
+ </More>
115
+ )}
116
+ </div>
117
+ );
118
+
119
+ if (variant === "labelled") {
120
+ const label = "hidden @lg:inline";
121
+ return (
122
+ <div className="flex shrink-0 gap-1.5">
123
+ {full && (
124
+ <Button
125
+ variant="outline"
126
+ size="sm"
127
+ onClick={onFull}
128
+ title="Full screen"
129
+ >
130
+ <Maximize2 className="h-3.5 w-3.5" />
131
+ <span className={label}>Full screen</span>
132
+ </Button>
133
+ )}
134
+ {has("share") && (
135
+ <Button variant="outline" size="sm" onClick={share} title="Share">
136
+ <Share2 className="h-3.5 w-3.5" />
137
+ <span className={label}>Share</span>
138
+ </Button>
139
+ )}
140
+ {has("open") && (
141
+ <Button variant="outline" size="sm" asChild>
142
+ <a
143
+ href={href}
144
+ target="_blank"
145
+ rel="noopener noreferrer"
146
+ title="Open in new tab"
147
+ >
148
+ <ExternalLink className="h-3.5 w-3.5" />
149
+ <span className={label}>Open</span>
150
+ </a>
151
+ </Button>
152
+ )}
153
+ {formats.length > 0 && (
154
+ <DownloadMenu
155
+ formats={formats}
156
+ request={request}
157
+ title={title}
158
+ disabled={false}
159
+ trigger={(pending) => (
160
+ <Button variant="outline" size="sm" title="Download">
161
+ {pending ? (
162
+ <Loader2 className="h-3.5 w-3.5 animate-spin" />
163
+ ) : (
164
+ <Download className="h-3.5 w-3.5" />
165
+ )}
166
+ <span className={label}>Download</span>
167
+ </Button>
168
+ )}
169
+ />
170
+ )}
171
+ </div>
172
+ );
173
+ }
174
+
175
+ // The card: every action as an icon from md up; on a phone, Share and a
176
+ // More menu, so the title keeps its room.
177
+ const wide = "hidden md:inline-flex";
178
+ return (
179
+ <div className="flex shrink-0 items-center">
180
+ {full && (
181
+ <button
182
+ type="button"
183
+ onClick={onFull}
184
+ className={cn(ICON_BUTTON, wide)}
185
+ aria-label="Full screen"
186
+ title="Full screen"
187
+ >
188
+ <Maximize2 className="h-4 w-4" />
189
+ </button>
190
+ )}
191
+ {has("share") && (
192
+ <button
193
+ type="button"
194
+ onClick={share}
195
+ className={ICON_BUTTON}
196
+ aria-label="Share"
197
+ title="Share"
198
+ >
199
+ <Share2 className="h-4 w-4" />
200
+ </button>
201
+ )}
202
+ {has("open") && (
203
+ <a
204
+ href={href}
205
+ target="_blank"
206
+ rel="noopener noreferrer"
207
+ className={cn(ICON_BUTTON, wide)}
208
+ aria-label="Open in new tab"
209
+ title="Open in new tab"
210
+ >
211
+ <ExternalLink className="h-4 w-4" />
212
+ </a>
213
+ )}
214
+ {formats.length > 0 && (
215
+ <DownloadMenu
216
+ formats={formats}
217
+ request={request}
218
+ title={title}
219
+ disabled={false}
220
+ trigger={(pending) => (
221
+ <button
222
+ type="button"
223
+ className={cn(ICON_BUTTON, wide)}
224
+ aria-label="Download"
225
+ title="Download"
226
+ >
227
+ {pending ? (
228
+ <Loader2 className="h-4 w-4 animate-spin" />
229
+ ) : (
230
+ <Download className="h-4 w-4" />
231
+ )}
232
+ </button>
233
+ )}
234
+ />
235
+ )}
236
+ <More
237
+ request={request}
238
+ title={title}
239
+ formats={formats}
240
+ onFull={full ? onFull : undefined}
241
+ href={has("open") ? href : undefined}
242
+ >
243
+ {(pending) => (
244
+ <button
245
+ type="button"
246
+ className={cn(ICON_BUTTON, "md:hidden")}
247
+ aria-label="More"
248
+ >
249
+ {pending ? (
250
+ <Loader2 className="h-4 w-4 animate-spin" />
251
+ ) : (
252
+ <MoreHorizontal className="h-4 w-4" />
253
+ )}
254
+ </button>
255
+ )}
256
+ </More>
257
+ </div>
258
+ );
259
+ }
260
+
261
+ // The phone's overflow menu: full screen and open when they didn't fit, then
262
+ // each download format.
263
+ function More({
264
+ request,
265
+ title,
266
+ formats,
267
+ onFull,
268
+ href,
269
+ children,
270
+ }: {
271
+ request: PreviewRequest;
272
+ title: string;
273
+ formats: DownloadFormat[];
274
+ onFull?: () => void;
275
+ href?: string;
276
+ children: (pending: boolean) => ReactNode;
277
+ }) {
278
+ const { choose, pending } = useShareDownload(request, title);
279
+ if (!onFull && !href && !formats.length) return null;
280
+ return (
281
+ <DropdownMenu>
282
+ <DropdownMenuTrigger asChild>{children(pending)}</DropdownMenuTrigger>
283
+ <DropdownMenuContent align="end" className="min-w-52">
284
+ {onFull && (
285
+ <DropdownMenuItem className="min-h-11 gap-2" onSelect={onFull}>
286
+ <Maximize2 className="h-4 w-4" /> Full screen
287
+ </DropdownMenuItem>
288
+ )}
289
+ {href && (
290
+ <DropdownMenuItem className="min-h-11 gap-2" asChild>
291
+ <a href={href} target="_blank" rel="noopener noreferrer">
292
+ <ExternalLink className="h-4 w-4" /> Open in new tab
293
+ </a>
294
+ </DropdownMenuItem>
295
+ )}
296
+ {formats.map((f) => (
297
+ <DropdownMenuItem
298
+ key={f}
299
+ className="min-h-11 gap-2"
300
+ onSelect={() => void choose(f)}
301
+ >
302
+ <Download className="h-4 w-4" /> {FORMAT_LABEL[f]}
303
+ </DropdownMenuItem>
304
+ ))}
305
+ </DropdownMenuContent>
306
+ </DropdownMenu>
307
+ );
308
+ }
@@ -0,0 +1,58 @@
1
+ "use client";
2
+
3
+ import type { ReactNode } from "react";
4
+ import { X } from "lucide-react";
5
+ import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
6
+ import { ArtifactFrame } from "@/components/Chat/ArtifactFrame";
7
+ import { ICON_BUTTON } from "./ArtifactActions";
8
+
9
+ // A rendered page over the whole screen, its actions in the bar.
10
+ export function ArtifactFullscreen({
11
+ open,
12
+ onOpenChange,
13
+ artifactId,
14
+ src,
15
+ title,
16
+ actions,
17
+ }: {
18
+ open: boolean;
19
+ onOpenChange: (open: boolean) => void;
20
+ artifactId: string;
21
+ src?: string;
22
+ title: string;
23
+ actions?: ReactNode;
24
+ }) {
25
+ return (
26
+ <Dialog open={open} onOpenChange={onOpenChange}>
27
+ <DialogContent
28
+ showCloseButton={false}
29
+ className="flex h-[100dvh] w-screen max-w-none flex-col gap-0 rounded-none p-0 sm:max-w-none"
30
+ >
31
+ <div className="flex items-center gap-2 py-1 pt-[max(0.25rem,env(safe-area-inset-top))] pr-1 pl-4">
32
+ <DialogTitle className="min-w-0 flex-1 truncate text-sm font-medium">
33
+ {title}
34
+ </DialogTitle>
35
+ {actions}
36
+ <button
37
+ type="button"
38
+ onClick={() => onOpenChange(false)}
39
+ className={ICON_BUTTON}
40
+ aria-label="Close"
41
+ >
42
+ <X className="h-4 w-4" />
43
+ </button>
44
+ </div>
45
+ <div className="min-h-0 flex-1 pb-[env(safe-area-inset-bottom)]">
46
+ {open && (
47
+ <ArtifactFrame
48
+ artifactId={artifactId}
49
+ src={src}
50
+ title={title}
51
+ fill
52
+ />
53
+ )}
54
+ </div>
55
+ </DialogContent>
56
+ </Dialog>
57
+ );
58
+ }