From a707844622e906944f04152678c37c5b38ea6e03 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 14 Aug 2026 18:40:28 +0000 Subject: [PATCH 1/4] Allow editing an open artifact and resharing it as a new link Open artifacts can now be corrected in the viewer and re-exported as a fresh fragment link, including the other artifacts in a bundle. Co-authored-by: aanishbhirud --- AGENTS.md | 2 +- CHANGELOG.md | 6 + README.md | 2 +- docs/architecture.md | 2 +- docs/testing.md | 2 +- skills/selfhosted-agent-render/SKILL.md | 2 +- src/app/globals.css | 12 + src/components/viewer-shell.tsx | 1 + src/components/viewer/artifact-editor.tsx | 487 +++++++++++++++++++ src/components/viewer/artifact-stage.tsx | 180 ++++--- src/lib/payload/link-creator.ts | 211 +++++++- tests/artifact-edit.test.ts | 230 +++++++++ tests/components/artifact-editor.test.tsx | 120 +++++ tests/components/artifact-stage-raw.test.tsx | 18 + tests/e2e/viewer.spec.ts | 88 ++++ 15 files changed, 1285 insertions(+), 78 deletions(-) create mode 100644 src/components/viewer/artifact-editor.tsx create mode 100644 tests/artifact-edit.test.ts create mode 100644 tests/components/artifact-editor.test.tsx diff --git a/AGENTS.md b/AGENTS.md index 1c40b0f..61f3ea8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,7 +47,7 @@ Describe and preserve what is already true in the repo today. - The empty state explains the product and exposes sample fragment presets. - A built-in link creator can generate fragment-based links locally in the browser. - When a valid fragment is present, the app switches to a viewer-first artifact layout. -- The artifact stage toolbar exposes copy-to-clipboard, file download, and (for markdown) browser print-to-PDF. +- The artifact stage toolbar exposes copy-to-clipboard, file download, (for markdown) browser print-to-PDF, and an editor that regenerates a new fragment link from the currently open artifact. - `activeArtifactId` controls which artifact opens first. - Internal diff file navigation stays in UI state and does not repurpose the fragment. diff --git a/CHANGELOG.md b/CHANGELOG.md index f81345c..affba4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to `agent-render` will be documented in this file. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project uses semantic versioning while it is published as tagged releases. +## [Unreleased] + +### Added + +- In-viewer edit-and-reshare: open any artifact, correct its content, and generate a new fragment link without leaving the viewer. + ## [0.1.0] - 2026-05-05 ### Added diff --git a/README.md b/README.md index 36bdc21..61ebfc7 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ Built for the OpenClaw ecosystem, `agent-render` focuses on fragment-based shari - Markdown, code, diff, CSV, and JSON all render in the static shell - Fragment transport supports `plain`, `lz`, `deflate`, `arx`, `arx2`, `arx3`, and `arx4`, with automatic shortest-fragment selection across available wire formats - The `arx` substitution dictionary is served at `/arx-dictionary.json` with a pre-compressed `/arx-dictionary.json.br` variant; the `arx2` tuple-envelope overlay is served at `/arx2-dictionary.json` with a pre-compressed `/arx2-dictionary.json.br` variant; `arx3` reuses those proven bytes and optimizes for compact visible Unicode fragments; `arx4` adds the curated context-mixer priors at `/arx4-priors.json` with a pre-compressed `/arx4-priors.json.br` variant -- The viewer toolbar copies artifact bodies to the clipboard, downloads them as files, and (for markdown) supports browser print-to-PDF +- The viewer toolbar copies artifact bodies to the clipboard, downloads them as files, (for markdown) supports browser print-to-PDF, and can edit the open artifact then reshare it as a new fragment link - Deployment target: static hosting, including Cloudflare Pages ## Included Renderers diff --git a/docs/architecture.md b/docs/architecture.md index 1986f30..e327b76 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -29,7 +29,7 @@ The static export also emits `sitemap.xml` at the site root (and under `NEXT_PUB The viewer shell now routes all five artifact kinds through dynamically imported client-only renderers so the landing shell stays light and static-host friendly. -When a valid fragment is present, the shell switches into a viewer-first layout with bundle navigation beside the active artifact. The active artifact header includes copy, download, and markdown print actions. The landing/samples experience is only the empty state. +When a valid fragment is present, the shell switches into a viewer-first layout with bundle navigation beside the active artifact. The active artifact header includes copy, download, markdown print, and edit-and-reshare actions. Edit regenerates a new fragment link from the current artifact (or the current artifact inside a bundle) without writing anything to a server. The landing/samples experience is only the empty state. Diff file navigation is intentionally internal UI state now. The URL fragment remains reserved for payload transport and active-artifact selection instead of being reused as an in-page file anchor system. diff --git a/docs/testing.md b/docs/testing.md index ed1857c..22f4639 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -43,7 +43,7 @@ Playwright visual tests live in `tests/e2e/visual.spec.ts`. The suite is intentionally split by responsibility: -- browser tests protect exported-app behavior, fragment-driven rendering, downloads, clipboard copy, print flow, themes, and layout hierarchy (including mobile toolbar and default code-wrap checks in `tests/e2e/viewer.spec.ts`) +- browser tests protect exported-app behavior, fragment-driven rendering, downloads, clipboard copy, print flow, in-viewer edit-and-reshare, themes, and layout hierarchy (including mobile toolbar and default code-wrap checks in `tests/e2e/viewer.spec.ts`) - visual tests protect empty state, artifact views, theme presentation, and compact-content spacing - component tests protect selector/disclosure UI contracts - unit tests protect transport codecs, envelope validation, diff parsing, and language inference diff --git a/skills/selfhosted-agent-render/SKILL.md b/skills/selfhosted-agent-render/SKILL.md index 316d078..0845d4a 100644 --- a/skills/selfhosted-agent-render/SKILL.md +++ b/skills/selfhosted-agent-render/SKILL.md @@ -109,7 +109,7 @@ Returns `200 { "status": "ok" }` when the server is up and the database is reach ## Viewer links -When a user visits `/{uuid}`, the server looks up the stored payload, injects it into the viewer page, and renders the same UI as the fragment-based product. All viewer features work: copy, download, print-to-PDF, diff modes, artifact switching, raw toggle. +When a user visits `/{uuid}`, the server looks up the stored payload, injects it into the viewer page, and renders the same UI as the fragment-based product. All viewer features work: copy, download, print-to-PDF, edit-and-reshare (which emits a new fragment link), diff modes, artifact switching, raw toggle. Construct viewer links as: diff --git a/src/app/globals.css b/src/app/globals.css index 4c3d819..9ccd331 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -928,6 +928,18 @@ select { padding: 0.95rem; } +.artifact-editor { + display: grid; + gap: 1.15rem; + padding: 1.5rem; +} + +@media (min-width: 768px) { + .artifact-editor { + padding: 2rem 2.5rem; + } +} + .viewer-toolbar { display: flex; flex-wrap: wrap; diff --git a/src/components/viewer-shell.tsx b/src/components/viewer-shell.tsx index 56a9dcc..93826c7 100644 --- a/src/components/viewer-shell.tsx +++ b/src/components/viewer-shell.tsx @@ -415,6 +415,7 @@ export function ViewerShell() { fragmentLength={fragmentLength} hash={hash} onArtifactSelect={handleArtifactSelect} + onPreviewHash={setFragmentHash} onRendererReady={markRendererReady} rendererReadyKey={rendererReadyKey} statusTone={statusTone} diff --git a/src/components/viewer/artifact-editor.tsx b/src/components/viewer/artifact-editor.tsx new file mode 100644 index 0000000..188fe0b --- /dev/null +++ b/src/components/viewer/artifact-editor.tsx @@ -0,0 +1,487 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { ArrowUpRight, Check, Copy, ExternalLink, Link2 } from "lucide-react"; +import { copyTextToClipboard } from "@/lib/copy-text"; +import { numberFormatter } from "@/lib/format"; +import { + applyArtifactEditDraft, + createArtifactEditDraft, + createGeneratedEnvelopeLinkAsync, + type ArtifactEditDraft, + type GeneratedArtifactLink, +} from "@/lib/payload/link-creator"; +import { + codecs, + type ArtifactKind, + type ArtifactPayload, + type PayloadEnvelope, +} from "@/lib/payload/schema"; +import { withBasePath } from "@/lib/site/base-path"; +import { cn } from "@/lib/utils"; + +type ArtifactEditorProps = { + artifact: ArtifactPayload; + envelope: PayloadEnvelope; + onPreviewHash: (hash: string) => void; +}; + +const fieldHints: Record = { + markdown: "Edit the markdown, then generate a new shareable link.", + code: "Edit the snippet and keep the language hint when it helps.", + diff: "Edit the unified git patch, then generate a new shareable link.", + csv: "Edit the raw CSV, then generate a new shareable link.", + json: "Edit the JSON, then generate a new shareable link.", +}; + +const codecOptions = ["auto", ...codecs] as const; + +function getShareBaseUrl() { + if (typeof window === "undefined") { + return undefined; + } + + return new URL(withBasePath("/"), window.location.origin).toString(); +} + +function getBodyFieldLabel(kind: ArtifactKind) { + return kind === "diff" ? "Patch" : "Content"; +} + +/** + * In-viewer editor for the currently open artifact. + * + * Starts from the decoded artifact, lets the user correct title/body fields, and generates a new + * fragment link without writing anything back to a server. Preview replaces the current hash so the + * edited artifact renders immediately. + */ +export function ArtifactEditor({ + artifact, + envelope, + onPreviewHash, +}: ArtifactEditorProps) { + const [{ draft, version: draftVersion }, setDraftState] = useState(() => ({ + draft: createArtifactEditDraft(artifact), + version: 0, + })); + const [generatedLink, setGeneratedLink] = + useState(null); + const [generatedVersion, setGeneratedVersion] = useState(-1); + const [error, setError] = useState(null); + const [copyState, setCopyState] = useState<"idle" | "copied" | "failed">( + "idle", + ); + const [markdownLinkCopyState, setMarkdownLinkCopyState] = useState< + "idle" | "copied" | "failed" + >("idle"); + const [isGenerating, setIsGenerating] = useState(false); + const generationRequestRef = useRef(0); + const markdownCopyTokenRef = useRef(0); + const generatedLinkRef = useRef(null); + const resultRef = useRef(null); + const isGeneratedLinkStale = + Boolean(generatedLink) && draftVersion !== generatedVersion; + const usesPairDiff = draft.kind === "diff" && draft.diffSource === "pair"; + const contentFieldLabel = getBodyFieldLabel(draft.kind); + + generatedLinkRef.current = generatedLink; + + useEffect(() => { + setCopyState("idle"); + setMarkdownLinkCopyState("idle"); + setError(null); + }, [draftVersion]); + + const updateDraft = ( + field: K, + value: ArtifactEditDraft[K], + ) => { + setDraftState((current) => { + if (Object.is(current.draft[field], value)) { + return current; + } + + return { + draft: { + ...current.draft, + [field]: value, + }, + version: current.version + 1, + }; + }); + }; + + const handleGenerate = async () => { + const requestId = generationRequestRef.current + 1; + generationRequestRef.current = requestId; + setIsGenerating(true); + + try { + const nextGeneratedLink = await createGeneratedEnvelopeLinkAsync( + applyArtifactEditDraft(envelope, draft), + getShareBaseUrl(), + draft.codec, + ); + if (generationRequestRef.current !== requestId) { + return; + } + + setGeneratedLink(nextGeneratedLink); + setGeneratedVersion(draftVersion); + setError(null); + setCopyState("idle"); + setMarkdownLinkCopyState("idle"); + window.requestAnimationFrame(() => { + resultRef.current?.scrollIntoView({ block: "nearest" }); + }); + } catch (generationError) { + if (generationRequestRef.current !== requestId) { + return; + } + + setGeneratedLink(null); + setGeneratedVersion(-1); + setCopyState("idle"); + setMarkdownLinkCopyState("idle"); + setError( + generationError instanceof Error + ? generationError.message + : "The link could not be generated.", + ); + } finally { + if (generationRequestRef.current === requestId) { + setIsGenerating(false); + } + } + }; + + const handleCopy = async () => { + if (!generatedLink) { + return; + } + + try { + await copyTextToClipboard(generatedLink.url); + setCopyState("copied"); + } catch { + setCopyState("failed"); + } + }; + + const handleCopyMarkdownLink = async () => { + const link = generatedLinkRef.current; + if (!link) { + return; + } + + const requestToken = ++markdownCopyTokenRef.current; + const expectedHash = link.hash; + + try { + await copyTextToClipboard(link.markdownLink); + if ( + markdownCopyTokenRef.current !== requestToken || + generatedLinkRef.current?.hash !== expectedHash + ) { + return; + } + setMarkdownLinkCopyState("copied"); + } catch { + if ( + markdownCopyTokenRef.current !== requestToken || + generatedLinkRef.current?.hash !== expectedHash + ) { + return; + } + setMarkdownLinkCopyState("failed"); + } + }; + + return ( +
+

+ Editing creates a new shareable link. The current URL stays put until + you preview or copy the new one. +

+ +
{ + event.preventDefault(); + void handleGenerate(); + }} + > + + + + + {draft.kind === "code" ? ( + + ) : null} + + {draft.kind === "diff" ? ( + + ) : null} + + {usesPairDiff ? ( + <> +