@saadnvd1/agent-os 0.3.768 → 0.3.775
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/sessions/[id]/artifacts/[artifactId]/compare/route.ts +41 -0
- package/app/api/sessions/resolve/route.test.ts +81 -0
- package/app/api/sessions/resolve/route.ts +14 -0
- package/app/api/shares/preview/route.ts +6 -1
- package/app/api/shares/route.test.ts +51 -0
- package/app/page.tsx +1 -0
- package/components/Artifacts/ArtifactActions.tsx +27 -3
- package/components/Artifacts/ArtifactPreview.tsx +27 -12
- package/components/Artifacts/Compare/DiffView.tsx +116 -0
- package/components/Artifacts/Compare/SideBySide.tsx +60 -0
- package/components/Artifacts/Compare/VersionPicker.tsx +47 -0
- package/components/Artifacts/Compare/index.tsx +191 -0
- package/components/Chat/ChatPanel.tsx +3 -9
- package/components/Chat/EventRow.tsx +4 -4
- package/components/Chat/Messages.tsx +12 -6
- package/components/Chat/SessionAnchor.tsx +22 -0
- package/components/Chat/ShareFromHere.tsx +39 -0
- package/components/Chat/session-link.ts +21 -4
- package/components/CommandPalette/CommandPalette.tsx +5 -4
- package/components/CommandPalette/CommandsBody.tsx +97 -131
- package/components/CommandPalette/PaletteRows.tsx +79 -0
- package/components/CommandPalette/PaletteTabs.tsx +51 -0
- package/components/CommandPalette/conversations/ConversationSearch.tsx +13 -11
- package/components/CommandPalette/useAppCommands.ts +14 -0
- package/components/CommandPalette/useConversationRows.ts +55 -0
- package/components/Health/EnvFailures/SessionList.tsx +2 -2
- package/components/Pane/PaneMenu.tsx +1 -1
- package/components/SessionList/QueuedRow.tsx +4 -4
- package/components/SessionList/SessionRowMenu.tsx +1 -1
- package/components/SessionList/useSessionSwitching.ts +2 -2
- package/components/Share/MessageList.tsx +87 -0
- package/components/Share/RangePicker.tsx +59 -0
- package/components/Share/ShareDialog.tsx +113 -52
- package/components/Shares/SharedLinksPanel.tsx +3 -5
- package/data/session-artifacts/keys.ts +2 -0
- package/data/session-artifacts/queries.ts +25 -0
- package/data/shares/queries.ts +11 -0
- package/hooks/useCopyToClipboard.ts +5 -2
- package/hooks/usePaletteCommands.ts +4 -1
- package/hooks/useSessionHref.ts +20 -0
- package/hooks/useSessionUrl.ts +40 -11
- package/lib/artifacts/actions.ts +11 -1
- package/lib/artifacts/compare/compareVersions.ts +66 -0
- package/lib/artifacts/compare/diff.ts +178 -0
- package/lib/artifacts/compare/extractText.ts +64 -0
- package/lib/artifacts/compare/index.ts +11 -0
- package/lib/artifacts/compare/types.ts +44 -0
- package/lib/artifacts/match.ts +64 -0
- package/lib/artifacts/store.ts +39 -10
- package/lib/artifacts/tools.ts +9 -4
- package/lib/artifacts/versions.ts +2 -1
- package/lib/away/deploy.ts +39 -2
- package/lib/palette/registry.ts +71 -17
- package/lib/palette/sections.ts +143 -0
- package/lib/palette/tabs.ts +21 -0
- package/lib/palette/usage.ts +81 -0
- package/lib/reports/evidence/build.ts +4 -3
- package/lib/security/demo.ts +3 -0
- package/lib/session-audit/render.ts +2 -1
- package/lib/session-logs/gather.ts +2 -2
- package/lib/session-slug.ts +29 -0
- package/lib/session-url.ts +162 -40
- package/lib/sessions/resolve.ts +55 -0
- package/lib/settings-url.ts +1 -1
- package/lib/share/markdown.ts +3 -0
- package/lib/share/range.ts +189 -0
- package/lib/share/render.tsx +3 -0
- package/lib/share/service.ts +64 -19
- package/lib/share/snapshot.ts +39 -2
- package/lib/share/styles.ts +2 -0
- package/lib/share/view.ts +11 -0
- package/lib/welcome-url.ts +1 -1
- package/next.config.ts +4 -0
- package/package.json +1 -1
- package/stores/chatJump.ts +5 -1
- package/stores/palette.ts +13 -11
- package/stores/shareUi.ts +6 -1
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { compareVersions } from "@/lib/artifacts/compare";
|
|
4
|
+
import { getSession } from "@/lib/session-artifacts/collect";
|
|
5
|
+
|
|
6
|
+
const Query = z.object({
|
|
7
|
+
to: z.string().min(1).max(64),
|
|
8
|
+
mode: z.enum(["changes", "source"]).default("changes"),
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
// GET /api/sessions/[id]/artifacts/[artifactId]/compare?to=<id>&mode= - what
|
|
12
|
+
// changed between two versions of one page: the visible text (changes) or the
|
|
13
|
+
// HTML (source). Both versions must be this session's, of the same page.
|
|
14
|
+
export async function GET(
|
|
15
|
+
request: Request,
|
|
16
|
+
{ params }: { params: Promise<{ id: string; artifactId: string }> }
|
|
17
|
+
) {
|
|
18
|
+
const { id, artifactId } = await params;
|
|
19
|
+
const query = Query.safeParse(
|
|
20
|
+
Object.fromEntries(new URL(request.url).searchParams)
|
|
21
|
+
);
|
|
22
|
+
if (!query.success)
|
|
23
|
+
return NextResponse.json(
|
|
24
|
+
{ error: "Name the version: ?to=" },
|
|
25
|
+
{ status: 400 }
|
|
26
|
+
);
|
|
27
|
+
if (!getSession(id))
|
|
28
|
+
return NextResponse.json({ error: "Session not found" }, { status: 404 });
|
|
29
|
+
const result = await compareVersions(
|
|
30
|
+
id,
|
|
31
|
+
artifactId,
|
|
32
|
+
query.data.to,
|
|
33
|
+
query.data.mode
|
|
34
|
+
);
|
|
35
|
+
if (!result.ok)
|
|
36
|
+
return NextResponse.json(
|
|
37
|
+
{ error: result.error },
|
|
38
|
+
{ status: result.status }
|
|
39
|
+
);
|
|
40
|
+
return NextResponse.json(result);
|
|
41
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { randomUUID } from "crypto";
|
|
2
|
+
import { beforeEach, describe, expect, it } from "vitest";
|
|
3
|
+
import { NextRequest } from "next/server";
|
|
4
|
+
import { getDb } from "@/lib/db";
|
|
5
|
+
import { GET } from "./route";
|
|
6
|
+
|
|
7
|
+
const get = (query: string) =>
|
|
8
|
+
GET(new NextRequest(`http://localhost/api/sessions/resolve?${query}`));
|
|
9
|
+
|
|
10
|
+
function insert(
|
|
11
|
+
id: string,
|
|
12
|
+
name: string,
|
|
13
|
+
over: { archived?: boolean; created?: string } = {}
|
|
14
|
+
) {
|
|
15
|
+
getDb()
|
|
16
|
+
.prepare(
|
|
17
|
+
`INSERT INTO sessions (id, name, tmux_name, created_at, archived_at)
|
|
18
|
+
VALUES (?, ?, ?, ?, ?)`
|
|
19
|
+
)
|
|
20
|
+
.run(
|
|
21
|
+
id,
|
|
22
|
+
name,
|
|
23
|
+
`claude-${id}`,
|
|
24
|
+
over.created ?? "2026-10-01 10:00:00",
|
|
25
|
+
over.archived ? "2026-10-02 10:00:00" : null
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Prefixes no random uuid in the shared test database starts with.
|
|
30
|
+
const P = "fffe0001";
|
|
31
|
+
const Q = "fffe0002";
|
|
32
|
+
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
getDb().prepare(`DELETE FROM sessions WHERE id LIKE 'fffe000%'`).run();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("GET /api/sessions/resolve", () => {
|
|
38
|
+
it("resolves a short id, archived sessions included", async () => {
|
|
39
|
+
const id = `${P}-1111-4111-8111-111111111111`;
|
|
40
|
+
insert(id, "Web perf audit", { archived: true });
|
|
41
|
+
const res = await get(`ref=${P}&slug=whatever`);
|
|
42
|
+
expect(res.status).toBe(200);
|
|
43
|
+
expect(await res.json()).toEqual({
|
|
44
|
+
id,
|
|
45
|
+
name: "Web perf audit",
|
|
46
|
+
archived: true,
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("resolves a whole id", async () => {
|
|
51
|
+
const id = randomUUID();
|
|
52
|
+
insert(id, "Plain");
|
|
53
|
+
const res = await get(`ref=${id}`);
|
|
54
|
+
expect((await res.json()).id).toBe(id);
|
|
55
|
+
getDb().prepare(`DELETE FROM sessions WHERE id = ?`).run(id);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("breaks a prefix clash by slug, then by newest", async () => {
|
|
59
|
+
const older = `${Q}-1111-4111-8111-111111111111`;
|
|
60
|
+
const newer = `${Q}-2222-4222-8222-222222222222`;
|
|
61
|
+
insert(older, "Fix login", { created: "2026-10-01 10:00:00" });
|
|
62
|
+
insert(newer, "Ship docs", { created: "2026-10-03 10:00:00" });
|
|
63
|
+
expect((await (await get(`ref=${Q}&slug=fix-login`)).json()).id).toBe(
|
|
64
|
+
older
|
|
65
|
+
);
|
|
66
|
+
expect((await (await get(`ref=${Q}&slug=ship-docs`)).json()).id).toBe(
|
|
67
|
+
newer
|
|
68
|
+
);
|
|
69
|
+
expect((await (await get(`ref=${Q}&slug=gone-name`)).json()).id).toBe(
|
|
70
|
+
newer
|
|
71
|
+
);
|
|
72
|
+
expect((await (await get(`ref=${Q}`)).json()).id).toBe(newer);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("is 404 for an unknown id and 400 for a malformed or short ref", async () => {
|
|
76
|
+
expect((await get(`ref=fffe0009`)).status).toBe(404);
|
|
77
|
+
for (const bad of ["", "ref=", "ref=fffe", "ref=fffe00", "ref=<script>"]) {
|
|
78
|
+
expect((await get(bad)).status).toBe(400);
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { refFromParams, resolveSessionRef } from "@/lib/sessions/resolve";
|
|
3
|
+
|
|
4
|
+
// GET /api/sessions/resolve?ref=<id8|id>[&slug=] - the session a link names,
|
|
5
|
+
// archived ones included: {id, name, archived}.
|
|
6
|
+
export async function GET(request: NextRequest) {
|
|
7
|
+
const params = request.nextUrl.searchParams;
|
|
8
|
+
const ref = refFromParams(params.get("ref"), params.get("slug"));
|
|
9
|
+
if (!ref) return NextResponse.json({ error: "bad ref" }, { status: 400 });
|
|
10
|
+
const session = resolveSessionRef(ref);
|
|
11
|
+
if (!session)
|
|
12
|
+
return NextResponse.json({ error: "not found" }, { status: 404 });
|
|
13
|
+
return NextResponse.json(session);
|
|
14
|
+
}
|
|
@@ -3,7 +3,7 @@ import { pdfAvailable } from "@/lib/share/pdf";
|
|
|
3
3
|
import { linked } from "@/lib/share/connect";
|
|
4
4
|
import { readJson, refuseDemo, toResponse } from "@/lib/share/route";
|
|
5
5
|
import type { ShareFormat } from "@/lib/share/view";
|
|
6
|
-
import { buildPage, parseRequest } from "@/lib/share/service";
|
|
6
|
+
import { buildPage, listChatItems, parseRequest } from "@/lib/share/service";
|
|
7
7
|
|
|
8
8
|
// POST /api/shares/preview - exactly what a share would upload, redacted.
|
|
9
9
|
// Local work only: nothing goes to Connect until a share is created.
|
|
@@ -14,6 +14,11 @@ export async function POST(request: Request) {
|
|
|
14
14
|
if ("refused" in read) return read.refused;
|
|
15
15
|
try {
|
|
16
16
|
const req = parseRequest(read.body);
|
|
17
|
+
if (
|
|
18
|
+
req.kind === "chat" &&
|
|
19
|
+
(read.body as { listOnly?: unknown }).listOnly === true
|
|
20
|
+
)
|
|
21
|
+
return NextResponse.json({ items: await listChatItems(req) });
|
|
17
22
|
const page = await buildPage(req);
|
|
18
23
|
const formats: ShareFormat[] = ["html", "md"];
|
|
19
24
|
if (await pdfAvailable()) formats.unshift("pdf");
|
|
@@ -316,6 +316,57 @@ describe("create", () => {
|
|
|
316
316
|
expect(JSON.parse(seen[0].body).expires_in).toBe("never");
|
|
317
317
|
});
|
|
318
318
|
|
|
319
|
+
it("uploads exactly the previewed range, and refuses another range's hash", async () => {
|
|
320
|
+
const id = chat();
|
|
321
|
+
const range = { fromId: "a1", toId: "a1" };
|
|
322
|
+
const p = await previewOf(id, range);
|
|
323
|
+
expect(p.html).not.toContain("[secret]");
|
|
324
|
+
expect(p.html).toContain("Earlier messages not shared");
|
|
325
|
+
expect(p.markdown).toContain("Earlier messages not shared");
|
|
326
|
+
const body = {
|
|
327
|
+
kind: "chat",
|
|
328
|
+
sessionId: id,
|
|
329
|
+
includeTools: false,
|
|
330
|
+
sharedAt: p.sharedAt,
|
|
331
|
+
hash: p.hash,
|
|
332
|
+
expiresIn: "7d",
|
|
333
|
+
};
|
|
334
|
+
const other = await shares.POST(
|
|
335
|
+
post("/api/shares", { ...body, fromId: "u1", toId: "a1" })
|
|
336
|
+
);
|
|
337
|
+
expect(other.status).toBe(409);
|
|
338
|
+
expect(seen).toHaveLength(0);
|
|
339
|
+
const res = await shares.POST(post("/api/shares", { ...body, ...range }));
|
|
340
|
+
expect(res.status).toBe(201);
|
|
341
|
+
expect(JSON.parse(seen[0].body).html).toContain(
|
|
342
|
+
"Earlier messages not shared"
|
|
343
|
+
);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it("lists every message with its time, and refuses a range that's gone", async () => {
|
|
347
|
+
const id = chat();
|
|
348
|
+
const res = await preview.POST(
|
|
349
|
+
post("/api/shares/preview", {
|
|
350
|
+
kind: "chat",
|
|
351
|
+
sessionId: id,
|
|
352
|
+
includeTools: false,
|
|
353
|
+
listOnly: true,
|
|
354
|
+
})
|
|
355
|
+
);
|
|
356
|
+
const { items } = await res.json();
|
|
357
|
+
expect(items.map((i: { id: string }) => i.id)).toEqual(["u1", "a1"]);
|
|
358
|
+
expect(items[0].createdAt).toBeGreaterThan(0);
|
|
359
|
+
const gone = await preview.POST(
|
|
360
|
+
post("/api/shares/preview", {
|
|
361
|
+
kind: "chat",
|
|
362
|
+
sessionId: id,
|
|
363
|
+
includeTools: false,
|
|
364
|
+
fromId: "nope",
|
|
365
|
+
})
|
|
366
|
+
);
|
|
367
|
+
expect(gone.status).toBe(409);
|
|
368
|
+
});
|
|
369
|
+
|
|
319
370
|
it("shares an artifact", async () => {
|
|
320
371
|
const id = chat();
|
|
321
372
|
const art = createArtifact(
|
package/app/page.tsx
CHANGED
|
@@ -5,6 +5,7 @@ import type { ReactNode } from "react";
|
|
|
5
5
|
import {
|
|
6
6
|
Download,
|
|
7
7
|
ExternalLink,
|
|
8
|
+
GitCompare,
|
|
8
9
|
Loader2,
|
|
9
10
|
Maximize2,
|
|
10
11
|
MoreHorizontal,
|
|
@@ -47,6 +48,8 @@ interface Props {
|
|
|
47
48
|
// bar: the Artifacts view's bottom bar on a phone.
|
|
48
49
|
variant: "icons" | "labelled" | "bar";
|
|
49
50
|
onFull?: () => void;
|
|
51
|
+
// Offered only by the owner's viewer, for a page with 2+ versions.
|
|
52
|
+
onCompare?: () => void;
|
|
50
53
|
}
|
|
51
54
|
|
|
52
55
|
// A rendered page's actions: full screen, share, open, download. One place
|
|
@@ -58,10 +61,12 @@ export function ArtifactActions({
|
|
|
58
61
|
actions,
|
|
59
62
|
variant,
|
|
60
63
|
onFull,
|
|
64
|
+
onCompare,
|
|
61
65
|
}: Props) {
|
|
62
66
|
const has = (id: ArtifactAction["id"]) => actions.some((a) => a.id === id);
|
|
63
67
|
const formats = actions.find((a) => a.id === "download")?.formats ?? [];
|
|
64
68
|
const full = has("fullscreen") && onFull;
|
|
69
|
+
const compare = has("compare") ? onCompare : undefined;
|
|
65
70
|
const request: PreviewRequest = {
|
|
66
71
|
kind: "artifact",
|
|
67
72
|
artifactId,
|
|
@@ -96,8 +101,13 @@ export function ArtifactActions({
|
|
|
96
101
|
<Maximize2 className="h-4 w-4" />
|
|
97
102
|
</Button>
|
|
98
103
|
)}
|
|
99
|
-
{formats.length > 0 && (
|
|
100
|
-
<More
|
|
104
|
+
{(formats.length > 0 || compare) && (
|
|
105
|
+
<More
|
|
106
|
+
request={request}
|
|
107
|
+
title={title}
|
|
108
|
+
formats={formats}
|
|
109
|
+
onCompare={compare}
|
|
110
|
+
>
|
|
101
111
|
{(pending) => (
|
|
102
112
|
<Button
|
|
103
113
|
variant="outline"
|
|
@@ -131,6 +141,12 @@ export function ArtifactActions({
|
|
|
131
141
|
<span className={label}>Full screen</span>
|
|
132
142
|
</Button>
|
|
133
143
|
)}
|
|
144
|
+
{compare && (
|
|
145
|
+
<Button variant="outline" size="sm" onClick={compare} title="Compare">
|
|
146
|
+
<GitCompare className="h-3.5 w-3.5" />
|
|
147
|
+
<span className={label}>Compare</span>
|
|
148
|
+
</Button>
|
|
149
|
+
)}
|
|
134
150
|
{has("share") && (
|
|
135
151
|
<Button variant="outline" size="sm" onClick={share} title="Share">
|
|
136
152
|
<Share2 className="h-3.5 w-3.5" />
|
|
@@ -238,6 +254,7 @@ export function ArtifactActions({
|
|
|
238
254
|
title={title}
|
|
239
255
|
formats={formats}
|
|
240
256
|
onFull={full ? onFull : undefined}
|
|
257
|
+
onCompare={compare}
|
|
241
258
|
href={has("open") ? href : undefined}
|
|
242
259
|
>
|
|
243
260
|
{(pending) => (
|
|
@@ -265,6 +282,7 @@ function More({
|
|
|
265
282
|
title,
|
|
266
283
|
formats,
|
|
267
284
|
onFull,
|
|
285
|
+
onCompare,
|
|
268
286
|
href,
|
|
269
287
|
children,
|
|
270
288
|
}: {
|
|
@@ -272,11 +290,12 @@ function More({
|
|
|
272
290
|
title: string;
|
|
273
291
|
formats: DownloadFormat[];
|
|
274
292
|
onFull?: () => void;
|
|
293
|
+
onCompare?: () => void;
|
|
275
294
|
href?: string;
|
|
276
295
|
children: (pending: boolean) => ReactNode;
|
|
277
296
|
}) {
|
|
278
297
|
const { choose, pending } = useShareDownload(request, title);
|
|
279
|
-
if (!onFull && !href && !formats.length) return null;
|
|
298
|
+
if (!onFull && !onCompare && !href && !formats.length) return null;
|
|
280
299
|
return (
|
|
281
300
|
<DropdownMenu>
|
|
282
301
|
<DropdownMenuTrigger asChild>{children(pending)}</DropdownMenuTrigger>
|
|
@@ -286,6 +305,11 @@ function More({
|
|
|
286
305
|
<Maximize2 className="h-4 w-4" /> Full screen
|
|
287
306
|
</DropdownMenuItem>
|
|
288
307
|
)}
|
|
308
|
+
{onCompare && (
|
|
309
|
+
<DropdownMenuItem className="min-h-11 gap-2" onSelect={onCompare}>
|
|
310
|
+
<GitCompare className="h-4 w-4" /> Compare versions
|
|
311
|
+
</DropdownMenuItem>
|
|
312
|
+
)}
|
|
289
313
|
{href && (
|
|
290
314
|
<DropdownMenuItem className="min-h-11 gap-2" asChild>
|
|
291
315
|
<a href={href} target="_blank" rel="noopener noreferrer">
|
|
@@ -26,6 +26,7 @@ import { ArtifactIcon } from "./ArtifactRow";
|
|
|
26
26
|
import { stateLabel } from "./helpers";
|
|
27
27
|
import { ArtifactActions } from "./ArtifactActions";
|
|
28
28
|
import { ArtifactFullscreen } from "./ArtifactFullscreen";
|
|
29
|
+
import { ArtifactCompare } from "./Compare";
|
|
29
30
|
import { VersionSwitcher } from "./VersionSwitcher";
|
|
30
31
|
|
|
31
32
|
function Body({
|
|
@@ -131,6 +132,7 @@ export function ArtifactPreview({
|
|
|
131
132
|
}) {
|
|
132
133
|
const [copied, setCopied] = useState(false);
|
|
133
134
|
const [full, setFull] = useState(false);
|
|
135
|
+
const [comparing, setComparing] = useState(false);
|
|
134
136
|
// The version picked; none means the latest, which follows new versions.
|
|
135
137
|
const [picked, setPicked] = useState<string | null>(null);
|
|
136
138
|
const page =
|
|
@@ -151,6 +153,7 @@ export function ArtifactPreview({
|
|
|
151
153
|
const many = versions.length > 1;
|
|
152
154
|
const older = many && !!shown && shown.id !== latest?.id;
|
|
153
155
|
|
|
156
|
+
const showCompare = comparing && many;
|
|
154
157
|
const label = stateLabel(artifact, pr);
|
|
155
158
|
// Inside the worktree, the path from its top; the full one is copied.
|
|
156
159
|
const where =
|
|
@@ -247,6 +250,7 @@ export function ArtifactPreview({
|
|
|
247
250
|
actions={pageList}
|
|
248
251
|
variant={variant}
|
|
249
252
|
onFull={() => setFull(true)}
|
|
253
|
+
onCompare={() => setComparing(true)}
|
|
250
254
|
/>
|
|
251
255
|
);
|
|
252
256
|
const actions = isPage
|
|
@@ -297,15 +301,15 @@ export function ArtifactPreview({
|
|
|
297
301
|
<div className="flex shrink-0 gap-1.5">{actions}</div>
|
|
298
302
|
)}
|
|
299
303
|
</div>
|
|
300
|
-
{mobile && switcher && (
|
|
304
|
+
{mobile && switcher && !showCompare && (
|
|
301
305
|
<div className="bg-card border-b px-1">{switcher}</div>
|
|
302
306
|
)}
|
|
303
|
-
{!mobile && switcher && (
|
|
307
|
+
{!mobile && switcher && !showCompare && (
|
|
304
308
|
<div className="flex border-b px-3 py-1.5 sm:px-4 @3xl:hidden">
|
|
305
309
|
{switcher}
|
|
306
310
|
</div>
|
|
307
311
|
)}
|
|
308
|
-
{older && latest && (
|
|
312
|
+
{older && latest && !showCompare && (
|
|
309
313
|
<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
314
|
<History className="h-3.5 w-3.5 shrink-0 text-amber-600 dark:text-amber-400" />
|
|
311
315
|
<span className="min-w-0 flex-1 truncate">
|
|
@@ -324,15 +328,26 @@ export function ArtifactPreview({
|
|
|
324
328
|
</Button>
|
|
325
329
|
</div>
|
|
326
330
|
)}
|
|
327
|
-
|
|
328
|
-
<
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
331
|
+
{showCompare && shownId ? (
|
|
332
|
+
<div className="min-h-0 flex-1">
|
|
333
|
+
<ArtifactCompare
|
|
334
|
+
sessionId={sessionId}
|
|
335
|
+
versions={versions}
|
|
336
|
+
shownId={shownId}
|
|
337
|
+
onClose={() => setComparing(false)}
|
|
338
|
+
/>
|
|
339
|
+
</div>
|
|
340
|
+
) : (
|
|
341
|
+
<div className="min-h-0 flex-1 overflow-auto">
|
|
342
|
+
<Body
|
|
343
|
+
sessionId={sessionId}
|
|
344
|
+
artifact={artifact}
|
|
345
|
+
pr={pr}
|
|
346
|
+
versionId={shownId ?? undefined}
|
|
347
|
+
/>
|
|
348
|
+
</div>
|
|
349
|
+
)}
|
|
350
|
+
{mobile && hasActions && !showCompare && (
|
|
336
351
|
<div className="flex gap-2 border-t px-3 pt-2.5 pb-[max(0.75rem,env(safe-area-inset-bottom))]">
|
|
337
352
|
{isPage ? <div className="flex-1">{actions}</div> : actions}
|
|
338
353
|
</div>
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useMemo, useState } from "react";
|
|
4
|
+
import { cn } from "@/lib/utils";
|
|
5
|
+
import { foldUnchanged } from "@/lib/artifacts/compare/diff";
|
|
6
|
+
import type { DiffLine, WordPart } from "@/lib/artifacts/compare/types";
|
|
7
|
+
|
|
8
|
+
const ROW = {
|
|
9
|
+
eq: "",
|
|
10
|
+
add: "bg-emerald-500/10",
|
|
11
|
+
del: "bg-red-500/10",
|
|
12
|
+
} as const;
|
|
13
|
+
const SIGN = {
|
|
14
|
+
eq: " ",
|
|
15
|
+
add: "+",
|
|
16
|
+
del: "−",
|
|
17
|
+
} as const;
|
|
18
|
+
const SIGN_TONE = {
|
|
19
|
+
eq: "text-muted-foreground",
|
|
20
|
+
add: "text-emerald-700 dark:text-emerald-400",
|
|
21
|
+
del: "text-red-700 dark:text-red-400",
|
|
22
|
+
} as const;
|
|
23
|
+
const WORD = {
|
|
24
|
+
add: "bg-emerald-500/30",
|
|
25
|
+
del: "bg-red-500/30 line-through decoration-red-500/60",
|
|
26
|
+
} as const;
|
|
27
|
+
|
|
28
|
+
function Words({ parts }: { parts: WordPart[] }) {
|
|
29
|
+
return (
|
|
30
|
+
<>
|
|
31
|
+
{parts.map((p, i) =>
|
|
32
|
+
p.t === "eq" ? (
|
|
33
|
+
<span key={i}>{p.s}</span>
|
|
34
|
+
) : (
|
|
35
|
+
<mark key={i} className={cn("rounded-sm text-inherit", WORD[p.t])}>
|
|
36
|
+
{p.s}
|
|
37
|
+
</mark>
|
|
38
|
+
)
|
|
39
|
+
)}
|
|
40
|
+
</>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function Row({ line, wrap }: { line: DiffLine; wrap: boolean }) {
|
|
45
|
+
return (
|
|
46
|
+
<div
|
|
47
|
+
className={cn(
|
|
48
|
+
"grid grid-cols-[1.75rem_1fr] font-mono text-xs leading-relaxed",
|
|
49
|
+
ROW[line.t]
|
|
50
|
+
)}
|
|
51
|
+
>
|
|
52
|
+
<span
|
|
53
|
+
aria-hidden
|
|
54
|
+
className={cn("text-center select-none", SIGN_TONE[line.t])}
|
|
55
|
+
>
|
|
56
|
+
{SIGN[line.t]}
|
|
57
|
+
</span>
|
|
58
|
+
<span
|
|
59
|
+
className={cn(
|
|
60
|
+
"min-w-0 pr-3",
|
|
61
|
+
wrap ? "break-words whitespace-pre-wrap" : "whitespace-pre"
|
|
62
|
+
)}
|
|
63
|
+
>
|
|
64
|
+
{line.words ? <Words parts={line.words} /> : line.s}
|
|
65
|
+
</span>
|
|
66
|
+
</div>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// A unified diff: removed lines, then added, with unchanged stretches folded.
|
|
71
|
+
export function DiffView({
|
|
72
|
+
lines,
|
|
73
|
+
wrap,
|
|
74
|
+
}: {
|
|
75
|
+
lines: DiffLine[];
|
|
76
|
+
// Text wraps; source scrolls sideways.
|
|
77
|
+
wrap: boolean;
|
|
78
|
+
}) {
|
|
79
|
+
const [all, setAll] = useState(false);
|
|
80
|
+
const shown = useMemo(
|
|
81
|
+
() => (all ? lines : foldUnchanged(lines)),
|
|
82
|
+
[all, lines]
|
|
83
|
+
);
|
|
84
|
+
const folded = shown.some((s) => "gap" in s);
|
|
85
|
+
return (
|
|
86
|
+
<div className="flex flex-col">
|
|
87
|
+
<div className={cn("py-2", !wrap && "overflow-x-auto")}>
|
|
88
|
+
<div className={cn(!wrap && "w-max min-w-full")}>
|
|
89
|
+
{shown.map((s, i) =>
|
|
90
|
+
"gap" in s ? (
|
|
91
|
+
<button
|
|
92
|
+
key={i}
|
|
93
|
+
type="button"
|
|
94
|
+
onClick={() => setAll(true)}
|
|
95
|
+
className="text-muted-foreground hover:text-foreground bg-muted/50 my-1 flex min-h-11 w-full items-center justify-center text-xs md:min-h-7"
|
|
96
|
+
>
|
|
97
|
+
{s.gap} unchanged {s.gap === 1 ? "line" : "lines"} · show all
|
|
98
|
+
</button>
|
|
99
|
+
) : (
|
|
100
|
+
<Row key={i} line={s} wrap={wrap} />
|
|
101
|
+
)
|
|
102
|
+
)}
|
|
103
|
+
</div>
|
|
104
|
+
</div>
|
|
105
|
+
{all && folded === false && lines.some((l) => l.t !== "eq") && (
|
|
106
|
+
<button
|
|
107
|
+
type="button"
|
|
108
|
+
onClick={() => setAll(false)}
|
|
109
|
+
className="text-muted-foreground hover:text-foreground min-h-11 text-xs md:min-h-9"
|
|
110
|
+
>
|
|
111
|
+
Fold unchanged lines
|
|
112
|
+
</button>
|
|
113
|
+
)}
|
|
114
|
+
</div>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { Segmented } from "@/components/ui/segmented";
|
|
5
|
+
import { ArtifactFrame } from "@/components/Chat/ArtifactFrame";
|
|
6
|
+
import type { ArtifactVersion } from "@/lib/artifacts/actions";
|
|
7
|
+
import { cn } from "@/lib/utils";
|
|
8
|
+
|
|
9
|
+
// Two versions in the viewer's sandboxed frames: next to each other when
|
|
10
|
+
// there's room, otherwise one at a time with a toggle.
|
|
11
|
+
export function SideBySide({
|
|
12
|
+
from,
|
|
13
|
+
to,
|
|
14
|
+
}: {
|
|
15
|
+
from: ArtifactVersion;
|
|
16
|
+
to: ArtifactVersion;
|
|
17
|
+
}) {
|
|
18
|
+
const [side, setSide] = useState<"from" | "to">("to");
|
|
19
|
+
const panes = [
|
|
20
|
+
{ key: "from", v: from },
|
|
21
|
+
{ key: "to", v: to },
|
|
22
|
+
] as const;
|
|
23
|
+
return (
|
|
24
|
+
<div className="flex h-full min-h-[60dvh] flex-col gap-2 p-2 @2xl:p-3">
|
|
25
|
+
<div className="@2xl:hidden">
|
|
26
|
+
<Segmented
|
|
27
|
+
label="Version shown"
|
|
28
|
+
value={side}
|
|
29
|
+
onChange={setSide}
|
|
30
|
+
options={panes.map((p) => ({
|
|
31
|
+
value: p.key,
|
|
32
|
+
label: `v${p.v.version}${p.key === "from" ? " (before)" : " (after)"}`,
|
|
33
|
+
}))}
|
|
34
|
+
/>
|
|
35
|
+
</div>
|
|
36
|
+
<div className="grid min-h-0 flex-1 gap-3 @2xl:grid-cols-2">
|
|
37
|
+
{panes.map(({ key, v }) => (
|
|
38
|
+
<div
|
|
39
|
+
key={key}
|
|
40
|
+
className={cn(
|
|
41
|
+
"min-h-0 flex-col gap-1",
|
|
42
|
+
side === key ? "flex" : "hidden @2xl:flex"
|
|
43
|
+
)}
|
|
44
|
+
>
|
|
45
|
+
<div className="text-muted-foreground hidden px-1 text-xs @2xl:block">
|
|
46
|
+
v{v.version}
|
|
47
|
+
{key === "from" ? " · before" : " · after"}
|
|
48
|
+
</div>
|
|
49
|
+
<ArtifactFrame
|
|
50
|
+
artifactId={v.id}
|
|
51
|
+
title={`${v.title} v${v.version}`}
|
|
52
|
+
fill
|
|
53
|
+
className="bg-background min-h-[50dvh] rounded-lg shadow-sm"
|
|
54
|
+
/>
|
|
55
|
+
</div>
|
|
56
|
+
))}
|
|
57
|
+
</div>
|
|
58
|
+
</div>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
Select,
|
|
5
|
+
SelectContent,
|
|
6
|
+
SelectItem,
|
|
7
|
+
SelectTrigger,
|
|
8
|
+
SelectValue,
|
|
9
|
+
} from "@/components/ui/select";
|
|
10
|
+
import type { ArtifactVersion } from "@/lib/artifacts/actions";
|
|
11
|
+
|
|
12
|
+
const label = (v: ArtifactVersion, latest: boolean) =>
|
|
13
|
+
`v${v.version}${latest ? " · latest" : ""}`;
|
|
14
|
+
|
|
15
|
+
export function VersionPicker({
|
|
16
|
+
name,
|
|
17
|
+
value,
|
|
18
|
+
versions,
|
|
19
|
+
onChange,
|
|
20
|
+
}: {
|
|
21
|
+
name: string;
|
|
22
|
+
value: string;
|
|
23
|
+
versions: ArtifactVersion[];
|
|
24
|
+
onChange: (id: string) => void;
|
|
25
|
+
}) {
|
|
26
|
+
const last = versions[versions.length - 1];
|
|
27
|
+
return (
|
|
28
|
+
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
|
29
|
+
<span className="text-muted-foreground text-[11px] font-medium tracking-wider uppercase">
|
|
30
|
+
{name}
|
|
31
|
+
</span>
|
|
32
|
+
<Select value={value} onValueChange={onChange}>
|
|
33
|
+
<SelectTrigger aria-label={`${name} version`} className="h-11 md:h-9">
|
|
34
|
+
<SelectValue />
|
|
35
|
+
</SelectTrigger>
|
|
36
|
+
<SelectContent>
|
|
37
|
+
{[...versions].reverse().map((v) => (
|
|
38
|
+
<SelectItem key={v.id} value={v.id} className="min-h-11 md:min-h-8">
|
|
39
|
+
{label(v, v.id === last?.id)}
|
|
40
|
+
{v.note ? ` · ${v.note}` : ""}
|
|
41
|
+
</SelectItem>
|
|
42
|
+
))}
|
|
43
|
+
</SelectContent>
|
|
44
|
+
</Select>
|
|
45
|
+
</div>
|
|
46
|
+
);
|
|
47
|
+
}
|