@saadnvd1/agent-os 0.3.758 → 0.3.762
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/app/api/artifacts/[id]/route.ts +13 -4
- package/app/api/artifacts/[id]/versions/route.ts +16 -0
- package/app/api/sessions/[id]/artifacts/route.ts +9 -1
- package/components/Artifacts/ArtifactActions.tsx +308 -0
- package/components/Artifacts/ArtifactFullscreen.tsx +58 -0
- package/components/Artifacts/ArtifactPreview.tsx +141 -18
- package/components/Artifacts/ArtifactRow.tsx +5 -0
- package/components/Artifacts/ArtifactsPanel.tsx +2 -0
- package/components/Artifacts/VersionSwitcher.tsx +216 -0
- package/components/Chat/Artifact.tsx +129 -89
- package/components/Chat/ComposerMenu.tsx +14 -5
- package/components/Share/DownloadMenu.tsx +63 -38
- package/components/Share/ShareDialog.tsx +2 -0
- package/data/session-artifacts/keys.ts +3 -0
- package/data/session-artifacts/queries.ts +25 -0
- package/lib/artifacts/actions.ts +75 -0
- package/lib/artifacts/store.ts +105 -10
- package/lib/artifacts/tools.ts +29 -6
- package/lib/artifacts/versions.ts +73 -0
- package/lib/chat/commands.ts +9 -0
- package/lib/chat/drivers/claude-mapper.ts +2 -2
- package/lib/chat/events.ts +4 -0
- package/lib/chat/worker/host.ts +2 -0
- package/lib/db/migrations/20261029T1200_artifact_versions.ts +28 -0
- package/lib/security/demo.ts +1 -0
- package/lib/session-artifacts/collect.ts +28 -6
- package/lib/session-artifacts/derive.ts +3 -1
- package/lib/session-artifacts/types.ts +9 -0
- package/next.config.ts +6 -0
- package/package.json +3 -3
- package/stores/shareUi.ts +6 -2
|
@@ -1,112 +1,152 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useState } from "react";
|
|
4
|
-
import {
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import { History } from "lucide-react";
|
|
5
5
|
import type { ChatItem } from "@/lib/chat/events";
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
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
|
-
|
|
13
|
-
|
|
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
|
-
|
|
16
|
-
|
|
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
|
-
{
|
|
60
|
+
{title}
|
|
72
61
|
</span>
|
|
73
|
-
|
|
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={
|
|
77
|
-
title={
|
|
104
|
+
artifactId={shownId}
|
|
105
|
+
title={title}
|
|
78
106
|
maxHeight={item.height}
|
|
79
107
|
/>
|
|
80
|
-
|
|
81
|
-
<
|
|
82
|
-
|
|
83
|
-
className="flex h-
|
|
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
|
-
<
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
{
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
146
|
+
variant="icons"
|
|
147
|
+
/>
|
|
148
|
+
}
|
|
149
|
+
/>
|
|
110
150
|
</div>
|
|
111
151
|
);
|
|
112
152
|
}
|
|
@@ -66,10 +66,13 @@ export function ComposerMenu({
|
|
|
66
66
|
ref={listRef}
|
|
67
67
|
role="listbox"
|
|
68
68
|
aria-label={label}
|
|
69
|
-
className=
|
|
69
|
+
className={cn(
|
|
70
|
+
"popover-surface absolute right-0 bottom-full left-0 mb-2 max-h-[min(55vh,340px)] overflow-x-hidden overflow-y-auto rounded-xl px-1.5 pb-1.5",
|
|
71
|
+
!refresh && "pt-1.5"
|
|
72
|
+
)}
|
|
70
73
|
>
|
|
71
74
|
{refresh && (
|
|
72
|
-
<div className="flex items-center justify-between pl-2.5">
|
|
75
|
+
<div className="bg-popover sticky top-0 z-20 flex items-center justify-between pt-1.5 pl-2.5">
|
|
73
76
|
<span className="text-muted-foreground text-xs">{label}</span>
|
|
74
77
|
<button
|
|
75
78
|
type="button"
|
|
@@ -92,7 +95,13 @@ export function ComposerMenu({
|
|
|
92
95
|
options.map((o, i) => (
|
|
93
96
|
<Fragment key={o.key}>
|
|
94
97
|
{o.group && o.group !== options[i - 1]?.group && (
|
|
95
|
-
<div
|
|
98
|
+
<div
|
|
99
|
+
className={cn(
|
|
100
|
+
"bg-popover text-muted-foreground sticky z-10 px-2.5 pt-2 pb-0.5 text-[11px] tracking-wide uppercase",
|
|
101
|
+
// Sit below the pinned header row (6px + h-11 / md:h-7).
|
|
102
|
+
refresh ? "top-[50px] md:top-[34px]" : "top-0"
|
|
103
|
+
)}
|
|
104
|
+
>
|
|
96
105
|
{o.group}
|
|
97
106
|
</div>
|
|
98
107
|
)}
|
|
@@ -114,7 +123,7 @@ export function ComposerMenu({
|
|
|
114
123
|
>
|
|
115
124
|
<span className="flex w-full min-w-0 items-center gap-2">
|
|
116
125
|
{o.icon}
|
|
117
|
-
<span className="max-w-
|
|
126
|
+
<span className="max-w-full min-w-0 shrink-0 font-mono text-sm break-all">
|
|
118
127
|
{o.icon ? (
|
|
119
128
|
o.label
|
|
120
129
|
) : (
|
|
@@ -122,7 +131,7 @@ export function ComposerMenu({
|
|
|
122
131
|
)}
|
|
123
132
|
</span>
|
|
124
133
|
{o.hint && (
|
|
125
|
-
<span className="text-muted-foreground/70 min-w-0 truncate font-mono text-xs">
|
|
134
|
+
<span className="text-muted-foreground/70 min-w-0 flex-1 truncate font-mono text-xs">
|
|
126
135
|
{o.hint}
|
|
127
136
|
</span>
|
|
128
137
|
)}
|
|
@@ -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
|
|
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
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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=
|
|
120
|
+
<DropdownMenuContent align={align} className="min-w-44">
|
|
96
121
|
{formats.map((f) => {
|
|
97
122
|
const { label, Icon } = FORMAT[f];
|
|
98
123
|
return (
|
|
@@ -67,6 +67,7 @@ function ShareBody({
|
|
|
67
67
|
sessionId?: string;
|
|
68
68
|
artifactId?: string;
|
|
69
69
|
title: string;
|
|
70
|
+
version?: number;
|
|
70
71
|
};
|
|
71
72
|
}) {
|
|
72
73
|
const [tools, setTools] = useState(false);
|
|
@@ -154,6 +155,7 @@ function ShareBody({
|
|
|
154
155
|
<div>
|
|
155
156
|
<DialogTitle>
|
|
156
157
|
Share {kind === "chat" ? "this chat" : "this page"}
|
|
158
|
+
{target.version ? ` · v${target.version}` : ""}
|
|
157
159
|
</DialogTitle>
|
|
158
160
|
<DialogDescription>
|
|
159
161
|
A read-only snapshot, with secrets, home paths and machine names
|
|
@@ -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
|
+
}
|