@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.
- 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/app/api/shares/route.test.ts +66 -1
- package/app/api/shares/route.ts +15 -0
- 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/Share/DownloadMenu.tsx +63 -38
- package/components/Share/ShareDialog.tsx +47 -0
- package/components/Shares/SharedLinksPanel.tsx +7 -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 +33 -6
- package/lib/artifacts/versions.ts +73 -0
- package/lib/chat/events.ts +4 -0
- package/lib/chat/worker/host.ts +2 -0
- package/lib/connect/control-api.ts +15 -0
- package/lib/db/migrations/20261029T1200_artifact_versions.ts +28 -0
- package/lib/db/migrations/20261029T1300_shared_links_follow.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/lib/share/connect.ts +10 -0
- package/lib/share/follow.ts +76 -0
- package/lib/share/service.ts +6 -0
- package/lib/share/store.ts +34 -2
- package/lib/share/view.ts +6 -0
- package/package.json +1 -1
- 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
|
}
|
|
@@ -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 (
|
|
@@ -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
|
+
}
|