@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
@@ -7,26 +7,38 @@ import {
7
7
  Copy,
8
8
  ExternalLink,
9
9
  FolderOpen,
10
+ History,
10
11
  } from "lucide-react";
11
12
  import { Button } from "@/components/ui/button";
12
13
  import { Markdown } from "@/components/Chat/Markdown";
13
14
  import { ArtifactFrame } from "@/components/Chat/ArtifactFrame";
14
- import { artifactContentUrl, useArtifactText } from "@/data/session-artifacts";
15
+ import {
16
+ artifactContentUrl,
17
+ useArtifactText,
18
+ useArtifactVersions,
19
+ } from "@/data/session-artifacts";
20
+ import { artifactActions, type ArtifactAction } from "@/lib/artifacts/actions";
15
21
  import { formatBytes } from "@/lib/share/view";
16
22
  import { relativeTime } from "@/lib/schedules/when";
17
23
  import { openable, type SessionArtifact } from "@/lib/session-artifacts/types";
18
24
  import { cn } from "@/lib/utils";
19
25
  import { ArtifactIcon } from "./ArtifactRow";
20
26
  import { stateLabel } from "./helpers";
27
+ import { ArtifactActions } from "./ArtifactActions";
28
+ import { ArtifactFullscreen } from "./ArtifactFullscreen";
29
+ import { VersionSwitcher } from "./VersionSwitcher";
21
30
 
22
31
  function Body({
23
32
  sessionId,
24
33
  artifact,
25
34
  pr,
35
+ versionId,
26
36
  }: {
27
37
  sessionId: string;
28
38
  artifact: SessionArtifact;
29
39
  pr: string | null;
40
+ // A page's version to show, served by its own id.
41
+ versionId?: string;
30
42
  }) {
31
43
  const url = artifactContentUrl(sessionId, artifact.id);
32
44
  const textual =
@@ -50,8 +62,8 @@ function Body({
50
62
  if (artifact.preview === "html")
51
63
  return (
52
64
  <ArtifactFrame
53
- artifactId={artifact.id}
54
- src={url}
65
+ artifactId={versionId ?? artifact.id}
66
+ src={versionId ? undefined : url}
55
67
  title={artifact.name}
56
68
  fill
57
69
  className="bg-background"
@@ -103,6 +115,7 @@ export function ArtifactPreview({
103
115
  artifact,
104
116
  pr,
105
117
  mobile,
118
+ remote = false,
106
119
  onBack,
107
120
  onOpenInFiles,
108
121
  }: {
@@ -110,10 +123,34 @@ export function ArtifactPreview({
110
123
  artifact: SessionArtifact;
111
124
  pr: string | null;
112
125
  mobile: boolean;
126
+ // On a linked machine: its pages are read through the session, one
127
+ // version only.
128
+ remote?: boolean;
113
129
  onBack?: () => void;
114
130
  onOpenInFiles?: (path: string) => void;
115
131
  }) {
116
132
  const [copied, setCopied] = useState(false);
133
+ const [full, setFull] = useState(false);
134
+ // The version picked; none means the latest, which follows new versions.
135
+ const [picked, setPicked] = useState<string | null>(null);
136
+ const page =
137
+ artifact.kind === "page" && !!artifact.artifactId && !remote
138
+ ? artifact.artifactId
139
+ : null;
140
+ // Keyed by the latest version, so a new one refetches.
141
+ const versionsQuery = useArtifactVersions(
142
+ page ?? "",
143
+ page ?? "",
144
+ !!page && openable(artifact)
145
+ );
146
+ const versions = versionsQuery.data?.versions ?? [];
147
+ const shownId =
148
+ (picked && versions.some((v) => v.id === picked) && picked) || page;
149
+ const shown = versions.find((v) => v.id === shownId);
150
+ const latest = versions[versions.length - 1];
151
+ const many = versions.length > 1;
152
+ const older = many && !!shown && shown.id !== latest?.id;
153
+
117
154
  const label = stateLabel(artifact, pr);
118
155
  // Inside the worktree, the path from its top; the full one is copied.
119
156
  const where =
@@ -125,8 +162,13 @@ export function ArtifactPreview({
125
162
  const meta = [
126
163
  where,
127
164
  label,
128
- artifact.size !== undefined ? formatBytes(artifact.size) : null,
129
- mobile && artifact.at ? relativeTime(artifact.at) : null,
165
+ many && shown && `v${shown.version} of ${versions.length}`,
166
+ shown?.turn && `turn ${shown.turn.index}`,
167
+ !shown && artifact.size !== undefined ? formatBytes(artifact.size) : null,
168
+ shown?.bytes !== undefined ? formatBytes(shown.bytes) : null,
169
+ (mobile || shown) &&
170
+ (shown?.createdAt || artifact.at) &&
171
+ relativeTime(shown?.createdAt || artifact.at),
130
172
  ]
131
173
  .filter(Boolean)
132
174
  .join(" · ");
@@ -137,14 +179,16 @@ export function ArtifactPreview({
137
179
  setTimeout(() => setCopied(false), 1500);
138
180
  });
139
181
  };
140
- const canOpenInFiles =
141
- !!onOpenInFiles &&
142
- !!artifact.path &&
143
- artifact.state === "live" &&
144
- (artifact.kind === "file" || artifact.kind === "plan");
145
- const actions = (
182
+ const list = artifact.actions ?? artifactActions(artifact, { pdf: false });
183
+ const can = (id: ArtifactAction["id"]) => list.some((a) => a.id === id);
184
+ // A linked machine's page is read through the session: only full screen.
185
+ const pageList = page
186
+ ? (versionsQuery.data?.actions ?? list)
187
+ : list.filter((a) => a.id === "fullscreen");
188
+ const canOpenInFiles = !!onOpenInFiles && can("open-in-files");
189
+ const fileActions = (
146
190
  <>
147
- {artifact.url && (
191
+ {artifact.url && can("open-pr") && (
148
192
  <Button
149
193
  variant="outline"
150
194
  size="sm"
@@ -162,7 +206,7 @@ export function ArtifactPreview({
162
206
  </a>
163
207
  </Button>
164
208
  )}
165
- {artifact.path && (
209
+ {artifact.path && can("copy-path") && (
166
210
  <Button
167
211
  variant="outline"
168
212
  size="sm"
@@ -178,7 +222,7 @@ export function ArtifactPreview({
178
222
  <span className={cn(!mobile && "hidden @lg:inline")}>Copy path</span>
179
223
  </Button>
180
224
  )}
181
- {canOpenInFiles && (
225
+ {canOpenInFiles && artifact.path && (
182
226
  <Button
183
227
  variant="outline"
184
228
  size="sm"
@@ -194,7 +238,31 @@ export function ArtifactPreview({
194
238
  )}
195
239
  </>
196
240
  );
197
- const hasActions = !!(artifact.url || artifact.path);
241
+ const isPage = artifact.kind === "page" && openable(artifact);
242
+ const pageActionsFor = (variant: "labelled" | "bar") => (
243
+ <ArtifactActions
244
+ artifactId={shownId ?? artifact.id}
245
+ title={shown?.title ?? artifact.name}
246
+ version={many ? shown?.version : undefined}
247
+ actions={pageList}
248
+ variant={variant}
249
+ onFull={() => setFull(true)}
250
+ />
251
+ );
252
+ const actions = isPage
253
+ ? pageActionsFor(mobile ? "bar" : "labelled")
254
+ : fileActions;
255
+ const hasActions = isPage
256
+ ? pageList.length > 0
257
+ : list.some((a) => a.id !== "open-in-files" || canOpenInFiles);
258
+ const switcher = many && shownId && (
259
+ <VersionSwitcher
260
+ versions={versions}
261
+ currentId={shownId}
262
+ onPick={(id) => setPicked(id === latest?.id ? null : id)}
263
+ mobile={mobile}
264
+ />
265
+ );
198
266
 
199
267
  return (
200
268
  <div className="@container flex h-full min-w-0 flex-col">
@@ -214,7 +282,7 @@ export function ArtifactPreview({
214
282
  )}
215
283
  <div className="min-w-0 flex-1">
216
284
  <div className="truncate text-[13px] font-medium">
217
- {artifact.name}
285
+ {shown?.title ?? artifact.name}
218
286
  </div>
219
287
  {meta && (
220
288
  <div className="text-muted-foreground truncate font-mono text-[11px]">
@@ -222,18 +290,73 @@ export function ArtifactPreview({
222
290
  </div>
223
291
  )}
224
292
  </div>
293
+ {!mobile && switcher && (
294
+ <div className="hidden @3xl:flex">{switcher}</div>
295
+ )}
225
296
  {!mobile && hasActions && (
226
297
  <div className="flex shrink-0 gap-1.5">{actions}</div>
227
298
  )}
228
299
  </div>
300
+ {mobile && switcher && (
301
+ <div className="bg-card border-b px-1">{switcher}</div>
302
+ )}
303
+ {!mobile && switcher && (
304
+ <div className="flex border-b px-3 py-1.5 sm:px-4 @3xl:hidden">
305
+ {switcher}
306
+ </div>
307
+ )}
308
+ {older && latest && (
309
+ <div className="flex min-h-11 items-center gap-2 border-b border-amber-500/25 bg-amber-500/[0.08] px-3 text-[12.5px] sm:px-4">
310
+ <History className="h-3.5 w-3.5 shrink-0 text-amber-600 dark:text-amber-400" />
311
+ <span className="min-w-0 flex-1 truncate">
312
+ Older version
313
+ <span className="hidden @md:inline">
314
+ . The agent changed this page since.
315
+ </span>
316
+ </span>
317
+ <Button
318
+ variant="ghost"
319
+ size="sm"
320
+ className="h-11 shrink-0 md:h-7"
321
+ onClick={() => setPicked(null)}
322
+ >
323
+ Go to latest (v{latest.version})
324
+ </Button>
325
+ </div>
326
+ )}
229
327
  <div className="min-h-0 flex-1 overflow-auto">
230
- <Body sessionId={sessionId} artifact={artifact} pr={pr} />
328
+ <Body
329
+ sessionId={sessionId}
330
+ artifact={artifact}
331
+ pr={pr}
332
+ versionId={shownId ?? undefined}
333
+ />
231
334
  </div>
232
335
  {mobile && hasActions && (
233
336
  <div className="flex gap-2 border-t px-3 pt-2.5 pb-[max(0.75rem,env(safe-area-inset-bottom))]">
234
- {actions}
337
+ {isPage ? <div className="flex-1">{actions}</div> : actions}
235
338
  </div>
236
339
  )}
340
+ {isPage && (
341
+ <ArtifactFullscreen
342
+ open={full}
343
+ onOpenChange={setFull}
344
+ artifactId={shownId ?? artifact.id}
345
+ src={shownId ? undefined : artifactContentUrl(sessionId, artifact.id)}
346
+ title={shown?.title ?? artifact.name}
347
+ actions={
348
+ page ? (
349
+ <ArtifactActions
350
+ artifactId={shownId ?? artifact.id}
351
+ title={shown?.title ?? artifact.name}
352
+ version={many ? shown?.version : undefined}
353
+ actions={pageList.filter((a) => a.id !== "fullscreen")}
354
+ variant="icons"
355
+ />
356
+ ) : undefined
357
+ }
358
+ />
359
+ )}
237
360
  </div>
238
361
  );
239
362
  }
@@ -106,6 +106,11 @@ export function ArtifactRow({
106
106
  )}
107
107
  >
108
108
  {artifact.name}
109
+ {(artifact.versionCount ?? 1) > 1 && (
110
+ <span className="bg-primary/15 text-primary ml-1.5 rounded-full px-1.5 py-px align-[1px] font-sans text-[10.5px]">
111
+ v{artifact.version}
112
+ </span>
113
+ )}
109
114
  </span>
110
115
  <span
111
116
  className={cn(
@@ -121,6 +121,7 @@ export function ArtifactsPanel({
121
121
  artifact={selected}
122
122
  pr={pr}
123
123
  mobile
124
+ remote={!!data?.host}
124
125
  onBack={() => setSelectedId(null)}
125
126
  onOpenInFiles={onOpenInFiles}
126
127
  />
@@ -231,6 +232,7 @@ export function ArtifactsPanel({
231
232
  artifact={selected}
232
233
  pr={pr}
233
234
  mobile={false}
235
+ remote={!!data?.host}
234
236
  onOpenInFiles={onOpenInFiles}
235
237
  />
236
238
  ) : (
@@ -0,0 +1,216 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import {
5
+ Check,
6
+ ChevronDown,
7
+ ChevronLeft,
8
+ ChevronRight,
9
+ History,
10
+ } from "lucide-react";
11
+ import {
12
+ DropdownMenu,
13
+ DropdownMenuContent,
14
+ DropdownMenuItem,
15
+ DropdownMenuTrigger,
16
+ } from "@/components/ui/dropdown-menu";
17
+ import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
18
+ import { SHEET } from "@/components/Hosts/sheet";
19
+ import type { ArtifactVersion } from "@/lib/artifacts/actions";
20
+ import { formatBytes } from "@/lib/share/view";
21
+ import { relativeTime } from "@/lib/schedules/when";
22
+ import { cn } from "@/lib/utils";
23
+
24
+ // "turn 9 · "split by plan" · 31m ago · 16 KB"
25
+ export function versionDetail(v: ArtifactVersion, full = true): string {
26
+ return [
27
+ v.turn && `turn ${v.turn.index}`,
28
+ full && v.turn?.prompt && `“${v.turn.prompt}”`,
29
+ relativeTime(v.createdAt),
30
+ full && v.bytes !== undefined && formatBytes(v.bytes),
31
+ ]
32
+ .filter(Boolean)
33
+ .join(" · ");
34
+ }
35
+
36
+ const summary = (v: ArtifactVersion) =>
37
+ v.note || (v.version === 1 ? "First version" : `Version ${v.version}`);
38
+
39
+ function Row({
40
+ v,
41
+ latest,
42
+ current,
43
+ full,
44
+ }: {
45
+ v: ArtifactVersion;
46
+ latest: boolean;
47
+ current: boolean;
48
+ full: boolean;
49
+ }) {
50
+ return (
51
+ <>
52
+ <span
53
+ className={cn(
54
+ "mt-0.5 inline-flex h-5 shrink-0 items-center rounded-full px-2 text-[11px]",
55
+ latest
56
+ ? "bg-primary/15 text-primary"
57
+ : "bg-muted text-muted-foreground"
58
+ )}
59
+ >
60
+ v{v.version}
61
+ </span>
62
+ <span className="min-w-0 flex-1">
63
+ <span className="block truncate text-[13px]">
64
+ {summary(v)}
65
+ {latest && <span className="text-muted-foreground"> · latest</span>}
66
+ </span>
67
+ <span className="text-muted-foreground block truncate text-[11.5px]">
68
+ {versionDetail(v, full)}
69
+ </span>
70
+ </span>
71
+ {current && <Check className="text-primary h-4 w-4 shrink-0" />}
72
+ </>
73
+ );
74
+ }
75
+
76
+ // ‹ v2 of 3 › with the list of versions: a menu on a wide screen, a sheet
77
+ // on a phone. Hidden for a page with one version.
78
+ export function VersionSwitcher({
79
+ versions,
80
+ currentId,
81
+ onPick,
82
+ mobile,
83
+ className,
84
+ }: {
85
+ versions: ArtifactVersion[];
86
+ currentId: string;
87
+ onPick: (id: string) => void;
88
+ mobile: boolean;
89
+ className?: string;
90
+ }) {
91
+ const [sheet, setSheet] = useState(false);
92
+ if (versions.length < 2) return null;
93
+ const i = Math.max(
94
+ 0,
95
+ versions.findIndex((v) => v.id === currentId)
96
+ );
97
+ const current = versions[i];
98
+ const last = versions.length - 1;
99
+ const ordered = [...versions].reverse();
100
+ const step = (d: number) => {
101
+ const next = versions[i + d];
102
+ if (next) onPick(next.id);
103
+ };
104
+ const arrow = mobile ? "h-11 w-11" : "h-[30px] w-[30px]";
105
+ const label = (
106
+ <>
107
+ {!mobile && <History className="h-3.5 w-3.5" />}
108
+ <span>
109
+ <b className="font-semibold">v{current.version}</b>
110
+ <span className="text-muted-foreground"> of {versions.length}</span>
111
+ </span>
112
+ <ChevronDown className="h-3.5 w-3.5 opacity-60" />
113
+ </>
114
+ );
115
+
116
+ return (
117
+ <div
118
+ className={cn(
119
+ "flex items-center",
120
+ mobile ? "w-full" : "h-8 shrink-0 rounded-lg border",
121
+ className
122
+ )}
123
+ >
124
+ <button
125
+ type="button"
126
+ className={cn(
127
+ "text-muted-foreground hover:text-foreground inline-flex items-center justify-center disabled:opacity-30",
128
+ arrow
129
+ )}
130
+ onClick={() => step(-1)}
131
+ disabled={i === 0}
132
+ aria-label="Older version"
133
+ >
134
+ <ChevronLeft className="h-4 w-4" />
135
+ </button>
136
+ {mobile ? (
137
+ <>
138
+ <button
139
+ type="button"
140
+ className="flex h-11 flex-1 items-center justify-center gap-1 text-[13px]"
141
+ onClick={() => setSheet(true)}
142
+ aria-label="Versions"
143
+ >
144
+ {label}
145
+ </button>
146
+ <Dialog open={sheet} onOpenChange={setSheet}>
147
+ <DialogContent className={cn(SHEET, "gap-1 p-2 pt-4")}>
148
+ <DialogTitle className="px-3 pb-1 text-sm">Versions</DialogTitle>
149
+ {ordered.map((v) => (
150
+ <button
151
+ key={v.id}
152
+ type="button"
153
+ className={cn(
154
+ "hover:bg-accent flex min-h-14 items-start gap-2.5 rounded-lg px-3 py-2 text-left",
155
+ v.id === current.id && "bg-accent"
156
+ )}
157
+ onClick={() => {
158
+ onPick(v.id);
159
+ setSheet(false);
160
+ }}
161
+ >
162
+ <Row
163
+ v={v}
164
+ latest={v.id === versions[last].id}
165
+ current={v.id === current.id}
166
+ full={false}
167
+ />
168
+ </button>
169
+ ))}
170
+ </DialogContent>
171
+ </Dialog>
172
+ </>
173
+ ) : (
174
+ <DropdownMenu>
175
+ <DropdownMenuTrigger asChild>
176
+ <button
177
+ type="button"
178
+ className="flex h-full items-center gap-1 border-x px-2 text-[12.5px]"
179
+ aria-label="Versions"
180
+ >
181
+ {label}
182
+ </button>
183
+ </DropdownMenuTrigger>
184
+ <DropdownMenuContent align="end" className="w-[22rem] max-w-[90vw]">
185
+ {ordered.map((v) => (
186
+ <DropdownMenuItem
187
+ key={v.id}
188
+ className="items-start gap-2.5 py-2"
189
+ onSelect={() => onPick(v.id)}
190
+ >
191
+ <Row
192
+ v={v}
193
+ latest={v.id === versions[last].id}
194
+ current={v.id === current.id}
195
+ full
196
+ />
197
+ </DropdownMenuItem>
198
+ ))}
199
+ </DropdownMenuContent>
200
+ </DropdownMenu>
201
+ )}
202
+ <button
203
+ type="button"
204
+ className={cn(
205
+ "text-muted-foreground hover:text-foreground inline-flex items-center justify-center disabled:opacity-30",
206
+ arrow
207
+ )}
208
+ onClick={() => step(1)}
209
+ disabled={i === last}
210
+ aria-label="Newer version"
211
+ >
212
+ <ChevronRight className="h-4 w-4" />
213
+ </button>
214
+ </div>
215
+ );
216
+ }