@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,112 +1,152 @@
1
1
  "use client";
2
2
 
3
- import { useState } from "react";
4
- import { ExternalLink, Maximize2, Share2, X } from "lucide-react";
3
+ import { useEffect, useState } from "react";
4
+ import { History } from "lucide-react";
5
5
  import type { ChatItem } from "@/lib/chat/events";
6
- import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
7
- import { shareUiActions } from "@/stores/shareUi";
8
- import { ArtifactFrame, artifactUrl } from "./ArtifactFrame";
6
+ import { pageActions } from "@/lib/artifacts/actions";
7
+ import { useArtifactVersions } from "@/data/session-artifacts";
8
+ import { useViewport } from "@/hooks/useViewport";
9
+ import { cn } from "@/lib/utils";
10
+ import {
11
+ DropdownMenu,
12
+ DropdownMenuContent,
13
+ DropdownMenuItem,
14
+ DropdownMenuTrigger,
15
+ } from "@/components/ui/dropdown-menu";
16
+ import { ArtifactActions } from "@/components/Artifacts/ArtifactActions";
17
+ import { ArtifactFullscreen } from "@/components/Artifacts/ArtifactFullscreen";
18
+ import { versionDetail } from "@/components/Artifacts/VersionSwitcher";
19
+ import { ArtifactFrame } from "./ArtifactFrame";
9
20
 
10
21
  type ArtifactItem = Extract<ChatItem, { kind: "artifact" }>;
11
22
 
12
- const ICON_BUTTON =
13
- "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";
23
+ // Until the server answers: no PDF, which it may not be able to print.
24
+ const FALLBACK_ACTIONS = pageActions({ pdf: false });
14
25
 
15
- function Actions({
16
- item,
17
- onFull,
18
- }: {
19
- item: ArtifactItem;
20
- onFull?: () => void;
21
- }) {
22
- return (
23
- <div className="flex shrink-0 items-center">
24
- {onFull && (
25
- <button
26
- type="button"
27
- onClick={onFull}
28
- className={ICON_BUTTON}
29
- aria-label="Full screen"
30
- title="Full screen"
31
- >
32
- <Maximize2 className="h-4 w-4" />
33
- </button>
34
- )}
35
- <button
36
- type="button"
37
- onClick={() =>
38
- shareUiActions.open({
39
- kind: "artifact",
40
- artifactId: item.artifactId,
41
- title: item.title,
42
- })
43
- }
44
- className={ICON_BUTTON}
45
- aria-label="Share"
46
- title="Share"
47
- >
48
- <Share2 className="h-4 w-4" />
49
- </button>
50
- <a
51
- href={artifactUrl(item.artifactId)}
52
- target="_blank"
53
- rel="noopener noreferrer"
54
- className={ICON_BUTTON}
55
- aria-label="Open in new tab"
56
- title="Open in new tab"
57
- >
58
- <ExternalLink className="h-4 w-4" />
59
- </a>
60
- </div>
61
- );
62
- }
63
-
64
- // A page the agent showed with html_render, live, in its own card.
26
+ // A page the agent showed with html_render, live, in its own card. It shows
27
+ // the version this turn made; a later one is a tap away.
65
28
  export function ArtifactCard({ item }: { item: ArtifactItem }) {
66
29
  const [full, setFull] = useState(false);
30
+ const [shownId, setShownId] = useState(item.artifactId);
31
+ const { isMobile } = useViewport();
32
+ const pageId = item.pageId ?? item.artifactId;
33
+ const { data, refetch } = useArtifactVersions(pageId, item.artifactId);
34
+ // A newer card for the same page: what's cached predates it.
35
+ const stale = !!data && !data.versions.some((v) => v.id === item.artifactId);
36
+ useEffect(() => {
37
+ if (stale) void refetch();
38
+ }, [stale, refetch]);
39
+
40
+ const versions = data?.versions ?? [];
41
+ const shown = versions.find((v) => v.id === shownId);
42
+ const latest = versions[versions.length - 1];
43
+ const many = versions.length > 1;
44
+ const title = shown?.title ?? item.title;
45
+ const actions = (
46
+ <ArtifactActions
47
+ artifactId={shownId}
48
+ title={title}
49
+ version={many ? shown?.version : undefined}
50
+ actions={data?.actions ?? FALLBACK_ACTIONS}
51
+ variant="icons"
52
+ onFull={() => setFull(true)}
53
+ />
54
+ );
55
+
67
56
  return (
68
57
  <div className="bg-foreground/[0.03] overflow-hidden rounded-xl shadow-sm">
69
58
  <div className="flex items-center gap-2 py-0.5 pr-1 pl-3.5">
70
59
  <span className="min-w-0 flex-1 truncate text-sm font-medium">
71
- {item.title}
60
+ {title}
72
61
  </span>
73
- <Actions item={item} onFull={() => setFull(true)} />
62
+ {many && shown && (
63
+ <DropdownMenu>
64
+ <DropdownMenuTrigger asChild>
65
+ <button
66
+ type="button"
67
+ className={cn(
68
+ "inline-flex h-6 shrink-0 items-center rounded-full px-2 text-[11.5px]",
69
+ shown.id === latest.id
70
+ ? "bg-primary/15 text-primary"
71
+ : "bg-muted text-muted-foreground"
72
+ )}
73
+ aria-label="Versions"
74
+ >
75
+ {shown.id === latest.id
76
+ ? `v${shown.version} · latest`
77
+ : isMobile
78
+ ? `v${shown.version}/${versions.length}`
79
+ : `v${shown.version} of ${versions.length}`}
80
+ </button>
81
+ </DropdownMenuTrigger>
82
+ <DropdownMenuContent align="end" className="w-80 max-w-[90vw]">
83
+ {[...versions].reverse().map((v) => (
84
+ <DropdownMenuItem
85
+ key={v.id}
86
+ className="min-h-11 flex-col items-start gap-0"
87
+ onSelect={() => setShownId(v.id)}
88
+ >
89
+ <span className="text-[13px]">
90
+ v{v.version}
91
+ {v.note ? ` · ${v.note}` : ""}
92
+ </span>
93
+ <span className="text-muted-foreground text-[11.5px]">
94
+ {versionDetail(v, false)}
95
+ </span>
96
+ </DropdownMenuItem>
97
+ ))}
98
+ </DropdownMenuContent>
99
+ </DropdownMenu>
100
+ )}
101
+ {actions}
74
102
  </div>
75
103
  <ArtifactFrame
76
- artifactId={item.artifactId}
77
- title={item.title}
104
+ artifactId={shownId}
105
+ title={title}
78
106
  maxHeight={item.height}
79
107
  />
80
- <Dialog open={full} onOpenChange={setFull}>
81
- <DialogContent
82
- showCloseButton={false}
83
- className="flex h-[100dvh] w-screen max-w-none flex-col gap-0 rounded-none p-0 sm:max-w-none"
108
+ {many && shown && latest && shown.id !== latest.id && (
109
+ <button
110
+ type="button"
111
+ className="text-muted-foreground flex min-h-11 w-full items-center gap-1.5 border-t px-3.5 text-left text-[12.5px] md:min-h-9"
112
+ onClick={() => setShownId(latest.id)}
113
+ >
114
+ <History className="h-3.5 w-3.5" />
115
+ {latest.version - shown.version === 1
116
+ ? "Updated since."
117
+ : `Updated ${latest.version - shown.version} times since.`}
118
+ <span className="text-primary">See v{latest.version}</span>
119
+ </button>
120
+ )}
121
+ {many && shownId !== item.artifactId && shown?.id === latest?.id && (
122
+ <button
123
+ type="button"
124
+ className="text-muted-foreground flex min-h-11 w-full items-center gap-1.5 border-t px-3.5 text-left text-[12.5px] md:min-h-9"
125
+ onClick={() => setShownId(item.artifactId)}
84
126
  >
85
- <div className="flex items-center gap-2 py-1 pt-[max(0.25rem,env(safe-area-inset-top))] pr-1 pl-4">
86
- <DialogTitle className="min-w-0 flex-1 truncate text-sm font-medium">
87
- {item.title}
88
- </DialogTitle>
89
- <Actions item={item} />
90
- <button
91
- type="button"
92
- onClick={() => setFull(false)}
93
- className={ICON_BUTTON}
94
- aria-label="Close"
95
- >
96
- <X className="h-4 w-4" />
97
- </button>
98
- </div>
99
- <div className="min-h-0 flex-1 pb-[env(safe-area-inset-bottom)]">
100
- {full && (
101
- <ArtifactFrame
102
- artifactId={item.artifactId}
103
- title={item.title}
104
- fill
105
- />
127
+ <History className="h-3.5 w-3.5" />
128
+ <span className="text-primary">
129
+ Back to v{item.version ?? 1}, from this turn
130
+ </span>
131
+ </button>
132
+ )}
133
+ <ArtifactFullscreen
134
+ open={full}
135
+ onOpenChange={setFull}
136
+ artifactId={shownId}
137
+ title={title}
138
+ actions={
139
+ <ArtifactActions
140
+ artifactId={shownId}
141
+ title={title}
142
+ version={many ? shown?.version : undefined}
143
+ actions={(data?.actions ?? FALLBACK_ACTIONS).filter(
144
+ (a) => a.id !== "fullscreen"
106
145
  )}
107
- </div>
108
- </DialogContent>
109
- </Dialog>
146
+ variant="icons"
147
+ />
148
+ }
149
+ />
110
150
  </div>
111
151
  );
112
152
  }
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import { useState } from "react";
3
+ import { useState, type ReactNode } from "react";
4
4
  import { toast } from "sonner";
5
5
  import {
6
6
  ChevronDown,
@@ -19,6 +19,7 @@ import {
19
19
  } from "@/components/ui/dropdown-menu";
20
20
  import {
21
21
  useSharePdf,
22
+ useSharePreview,
22
23
  type PreviewRequest,
23
24
  type SharePreview,
24
25
  } from "@/data/shares";
@@ -39,60 +40,84 @@ function save(blob: Blob, name: string) {
39
40
  URL.revokeObjectURL(url);
40
41
  }
41
42
 
43
+ // Saves the share in a format: PDF printed by the server, HTML and Markdown
44
+ // from the preview, fetched now when there isn't one in hand.
45
+ export function useShareDownload(
46
+ request: PreviewRequest,
47
+ title: string,
48
+ preview?: SharePreview | null
49
+ ) {
50
+ const pdf = useSharePdf();
51
+ const fetchPreview = useSharePreview();
52
+ const choose = async (format: ShareFormat) => {
53
+ try {
54
+ if (format === "pdf") {
55
+ save(await pdf.mutateAsync(request), fileName(title, "pdf"));
56
+ return;
57
+ }
58
+ const p = preview ?? (await fetchPreview.mutateAsync(request));
59
+ const md = format === "md";
60
+ save(
61
+ new Blob([md ? p.markdown : p.html], {
62
+ type: md ? "text/markdown" : "text/html",
63
+ }),
64
+ fileName(title, format)
65
+ );
66
+ } catch (err) {
67
+ toast.error(
68
+ (err as Error).message ||
69
+ (format === "pdf" ? "Couldn't make the PDF" : "Couldn't download it")
70
+ );
71
+ }
72
+ };
73
+ return { choose, pending: pdf.isPending || fetchPreview.isPending };
74
+ }
75
+
42
76
  // One Download button for every format the server says it can make. PDF is
43
77
  // printed server-side, so it shows a spinner; HTML and Markdown are in hand.
44
78
  export function DownloadMenu({
45
79
  preview,
80
+ formats = preview?.formats ?? [],
46
81
  request,
47
82
  title,
48
83
  disabled,
84
+ trigger,
85
+ align = "end",
49
86
  }: {
50
- preview: SharePreview | null;
87
+ preview?: SharePreview | null;
88
+ formats?: ShareFormat[];
51
89
  request: PreviewRequest;
52
90
  title: string;
53
91
  disabled: boolean;
92
+ // The button that opens the menu, given whether a download is under way.
93
+ trigger?: (pending: boolean) => ReactNode;
94
+ align?: "start" | "end";
54
95
  }) {
55
- const pdf = useSharePdf();
56
96
  const [open, setOpen] = useState(false);
57
- const formats = preview?.formats ?? [];
58
-
59
- const choose = async (format: ShareFormat) => {
60
- if (!preview) return;
61
- if (format === "pdf") {
62
- try {
63
- save(await pdf.mutateAsync(request), fileName(title, "pdf"));
64
- } catch (err) {
65
- toast.error((err as Error).message || "Couldn't make the PDF");
66
- }
67
- return;
68
- }
69
- const md = format === "md";
70
- save(
71
- new Blob([md ? preview.markdown : preview.html], {
72
- type: md ? "text/markdown" : "text/html",
73
- }),
74
- fileName(title, format)
75
- );
76
- };
97
+ const { choose, pending } = useShareDownload(request, title, preview);
77
98
 
78
99
  return (
79
100
  <DropdownMenu open={open} onOpenChange={setOpen}>
80
- <DropdownMenuTrigger asChild>
81
- <Button
82
- variant="outline"
83
- className="min-h-11 flex-1 sm:flex-none"
84
- disabled={disabled || !formats.length || pdf.isPending}
85
- >
86
- {pdf.isPending ? (
87
- <Loader2 className="h-4 w-4 animate-spin" />
88
- ) : (
89
- <Download className="h-4 w-4" />
90
- )}
91
- {pdf.isPending ? "Making PDF…" : "Download"}
92
- <ChevronDown className="h-4 w-4 opacity-60" />
93
- </Button>
101
+ <DropdownMenuTrigger asChild disabled={disabled || !formats.length}>
102
+ {trigger ? (
103
+ trigger(pending)
104
+ ) : (
105
+ <Button
106
+ variant="outline"
107
+ className="min-h-11 flex-1 sm:flex-none"
108
+ disabled={disabled || !formats.length || pending}
109
+ >
110
+ {pending ? (
111
+ <Loader2 className="h-4 w-4 animate-spin" />
112
+ ) : (
113
+ <Download className="h-4 w-4" />
114
+ )}
115
+ {pending ? "Preparing…" : "Download"}
116
+ <ChevronDown className="h-4 w-4 opacity-60" />
117
+ </Button>
118
+ )}
94
119
  </DropdownMenuTrigger>
95
- <DropdownMenuContent align="end" className="min-w-44">
120
+ <DropdownMenuContent align={align} className="min-w-44">
96
121
  {formats.map((f) => {
97
122
  const { label, Icon } = FORMAT[f];
98
123
  return (
@@ -19,6 +19,7 @@ import {
19
19
  SelectValue,
20
20
  } from "@/components/ui/select";
21
21
  import { DownloadMenu } from "./DownloadMenu";
22
+ import { cn } from "@/lib/utils";
22
23
  import { Switch } from "@/components/ui/switch";
23
24
  import { writeClipboard } from "@/hooks/useCopyToClipboard";
24
25
  import {
@@ -67,6 +68,7 @@ function ShareBody({
67
68
  sessionId?: string;
68
69
  artifactId?: string;
69
70
  title: string;
71
+ version?: number;
70
72
  };
71
73
  }) {
72
74
  const [tools, setTools] = useState(false);
@@ -75,6 +77,7 @@ function ShareBody({
75
77
  const [preview, setPreview] = useState<SharePreview | null>(null);
76
78
  const [error, setError] = useState<string | null>(null);
77
79
  const [created, setCreated] = useState<string | null>(null);
80
+ const [follow, setFollow] = useState(false);
78
81
  const [copied, setCopied] = useState(false);
79
82
  const [tab, setTab] = useState<"page" | "markdown">("page");
80
83
  const previewReq = useSharePreview();
@@ -120,6 +123,7 @@ function ShareBody({
120
123
  sharedAt: preview.sharedAt,
121
124
  hash: preview.hash,
122
125
  expiresIn,
126
+ follow: follow ? "latest" : undefined,
123
127
  });
124
128
  setCreated(res.url);
125
129
  try {
@@ -154,6 +158,7 @@ function ShareBody({
154
158
  <div>
155
159
  <DialogTitle>
156
160
  Share {kind === "chat" ? "this chat" : "this page"}
161
+ {target.version ? ` · v${target.version}` : ""}
157
162
  </DialogTitle>
158
163
  <DialogDescription>
159
164
  A read-only snapshot, with secrets, home paths and machine names
@@ -233,6 +238,48 @@ function ShareBody({
233
238
  </>
234
239
  )}
235
240
 
241
+ {linked && kind === "artifact" && (
242
+ <fieldset className="space-y-2">
243
+ <legend className="text-muted-foreground mb-1 text-xs font-medium">
244
+ What the link shows
245
+ </legend>
246
+ {[
247
+ {
248
+ value: false,
249
+ title: `This version${target.version ? ` (v${target.version})` : ""}`,
250
+ hint: "Never changes. You review it above before it goes out.",
251
+ },
252
+ {
253
+ value: true,
254
+ title: "Always the latest",
255
+ hint: "Updates when the agent makes a new version. New versions are redacted the same way but publish without a fresh preview.",
256
+ },
257
+ ].map((o) => (
258
+ <label
259
+ key={o.title}
260
+ className={cn(
261
+ "flex min-h-11 cursor-pointer items-start gap-3 rounded-lg border px-3 py-2 text-sm",
262
+ follow === o.value && "border-primary bg-primary/5"
263
+ )}
264
+ >
265
+ <input
266
+ type="radio"
267
+ name="share-follow"
268
+ className="mt-1 h-4 w-4 shrink-0"
269
+ checked={follow === o.value}
270
+ onChange={() => setFollow(o.value)}
271
+ />
272
+ <span className="min-w-0">
273
+ <span className="block font-medium">{o.title}</span>
274
+ <span className="text-muted-foreground block text-xs">
275
+ {o.hint}
276
+ </span>
277
+ </span>
278
+ </label>
279
+ ))}
280
+ </fieldset>
281
+ )}
282
+
236
283
  {linked && (
237
284
  <label className="flex min-h-11 items-center justify-between gap-3 text-sm">
238
285
  <span>Link expires after</span>
@@ -60,6 +60,13 @@ function Row({ share }: { share: SharedLinkRow }) {
60
60
  <div className="truncate text-sm font-medium">{title}</div>
61
61
  <div className="text-muted-foreground flex flex-wrap gap-x-3 text-xs">
62
62
  <span>{share.kind === "chat" ? "Chat" : "Page"}</span>
63
+ {share.kind === "artifact" && (
64
+ <span>
65
+ {share.follow
66
+ ? `Follows latest${share.version ? ` · now v${share.version}` : ""}`
67
+ : `pinned${share.version ? ` v${share.version}` : ""}`}
68
+ </span>
69
+ )}
63
70
  <span>Created {when(share.createdAt)}</span>
64
71
  <span>
65
72
  {share.status === "revoked"
@@ -4,4 +4,7 @@ export const sessionArtifactKeys = {
4
4
  [...sessionArtifactKeys.all, sessionId] as const,
5
5
  content: (url: string) =>
6
6
  [...sessionArtifactKeys.all, "content", url] as const,
7
+ // A page's versions, by any version's id; pageId keys the invalidation.
8
+ versions: (pageId: string) =>
9
+ [...sessionArtifactKeys.all, "versions", pageId] as const,
7
10
  };
@@ -1,5 +1,6 @@
1
1
  import { useQuery } from "@tanstack/react-query";
2
2
  import type { SessionArtifact } from "@/lib/session-artifacts/types";
3
+ import type { PageVersions } from "@/lib/artifacts/actions";
3
4
  import { sessionArtifactKeys } from "./keys";
4
5
 
5
6
  export type { SessionArtifact } from "@/lib/session-artifacts/types";
@@ -51,3 +52,27 @@ export function useArtifactText(url: string | null) {
51
52
  staleTime: 5_000,
52
53
  });
53
54
  }
55
+
56
+ /**
57
+ * Every version of a page, with its note and the message that asked for it.
58
+ * Keyed by page, so any version's card shares one fetch.
59
+ */
60
+ export function useArtifactVersions(
61
+ pageId: string,
62
+ artifactId: string,
63
+ enabled = true
64
+ ) {
65
+ return useQuery({
66
+ queryKey: sessionArtifactKeys.versions(pageId),
67
+ queryFn: async (): Promise<PageVersions> => {
68
+ const res = await fetch(
69
+ `/api/artifacts/${encodeURIComponent(artifactId)}/versions`
70
+ );
71
+ const data = await res.json().catch(() => ({}));
72
+ if (!res.ok) throw new Error(data.error || "Couldn't load versions");
73
+ return data;
74
+ },
75
+ enabled,
76
+ staleTime: 30_000,
77
+ });
78
+ }
@@ -0,0 +1,75 @@
1
+ // What you can do with an artifact, decided by the server so every client
2
+ // (the web app, the native app) shows the same actions without hardcoding
3
+ // them. Pure and import-free so the native app can sync it.
4
+
5
+ export type ArtifactActionId =
6
+ | "fullscreen"
7
+ | "share"
8
+ | "open"
9
+ | "download"
10
+ | "open-pr"
11
+ | "copy-path"
12
+ | "open-in-files";
13
+
14
+ export type DownloadFormat = "pdf" | "html" | "md";
15
+
16
+ export interface ArtifactAction {
17
+ id: ArtifactActionId;
18
+ label: string;
19
+ // download: the formats this machine can make.
20
+ formats?: DownloadFormat[];
21
+ }
22
+
23
+ // One version of a page, as the version switcher lists it.
24
+ export interface ArtifactVersion {
25
+ id: string;
26
+ version: number;
27
+ title: string;
28
+ note?: string;
29
+ // ms
30
+ createdAt: number;
31
+ bytes?: number;
32
+ // The message that asked for it: its place among the chat's messages
33
+ // (1-based) and how it starts.
34
+ turn?: { itemId: string; index: number; prompt: string };
35
+ }
36
+
37
+ export interface PageVersions {
38
+ pageId: string;
39
+ latestId: string;
40
+ versions: ArtifactVersion[];
41
+ actions: ArtifactAction[];
42
+ }
43
+
44
+ export function pageActions(ctx: { pdf: boolean }): ArtifactAction[] {
45
+ return [
46
+ { id: "fullscreen", label: "Full screen" },
47
+ { id: "share", label: "Share" },
48
+ { id: "open", label: "Open" },
49
+ {
50
+ id: "download",
51
+ label: "Download",
52
+ formats: ctx.pdf ? ["pdf", "html", "md"] : ["html", "md"],
53
+ },
54
+ ];
55
+ }
56
+
57
+ // The actions for anything in a session's Artifacts view.
58
+ export function artifactActions(
59
+ a: {
60
+ kind: string;
61
+ state: string;
62
+ path?: string;
63
+ url?: string;
64
+ },
65
+ ctx: { pdf: boolean }
66
+ ): ArtifactAction[] {
67
+ if (a.kind === "page")
68
+ return a.state === "live" || a.state === "kept" ? pageActions(ctx) : [];
69
+ const out: ArtifactAction[] = [];
70
+ if (a.url) out.push({ id: "open-pr", label: "Open PR" });
71
+ if (a.path) out.push({ id: "copy-path", label: "Copy path" });
72
+ if (a.path && a.state === "live" && (a.kind === "file" || a.kind === "plan"))
73
+ out.push({ id: "open-in-files", label: "Open in Files" });
74
+ return out;
75
+ }