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..2770402 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, diff modes, artifact switching, raw toggle. Edit-and-reshare emits a new static fragment link rather than updating the stored UUID payload, and generation fails if the edited artifact exceeds the fragment budget. 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..199e96d 100644 --- a/src/components/viewer-shell.tsx +++ b/src/components/viewer-shell.tsx @@ -303,13 +303,16 @@ export function ViewerShell() { useEffect(() => { rendererReadyKeyRef.current = rendererReadyKey; - if (!activeArtifact) { + if (!rendererReadyKey) { setRendererReady(true); return; } + // Reset only when the hash/artifact-id key changes. A later decode that only + // replaces artifact contents (same id, new fragment) must not clear a ready + // signal the remounted renderer already reported for that key. setRendererReady(false); - }, [activeArtifact, rendererReadyKey]); + }, [rendererReadyKey]); const markRendererReady = useCallback((readyKey: string) => { if (rendererReadyKeyRef.current === readyKey) { @@ -415,6 +418,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..a2becaf --- /dev/null +++ b/src/components/viewer/artifact-editor.tsx @@ -0,0 +1,530 @@ +"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 normalizePageBase(value: string) { + return value.replace(/\/$/, ""); +} + +function isOnShareBase(shareBase: string) { + const current = new URL(window.location.href); + current.hash = ""; + return normalizePageBase(current.toString()) === normalizePageBase(shareBase); +} + +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 copyTokenRef = useRef(0); + const markdownCopyTokenRef = useRef(0); + const resultRef = useRef(null); + const isGeneratedLinkStale = + Boolean(generatedLink) && draftVersion !== generatedVersion; + const usesPairDiff = draft.kind === "diff" && draft.diffSource === "pair"; + const contentFieldLabel = getBodyFieldLabel(draft.kind); + + useEffect(() => { + copyTokenRef.current += 1; + markdownCopyTokenRef.current += 1; + 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); + copyTokenRef.current += 1; + markdownCopyTokenRef.current += 1; + 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 || isGeneratedLinkStale) { + return; + } + + const requestToken = ++copyTokenRef.current; + const expectedHash = generatedLink.hash; + + try { + await copyTextToClipboard(generatedLink.url); + if (copyTokenRef.current !== requestToken || generatedLink.hash !== expectedHash) { + return; + } + setCopyState("copied"); + } catch { + if (copyTokenRef.current !== requestToken || generatedLink.hash !== expectedHash) { + return; + } + setCopyState("failed"); + } + }; + + const handleCopyMarkdownLink = async () => { + if (!generatedLink || isGeneratedLinkStale) { + return; + } + + const requestToken = ++markdownCopyTokenRef.current; + const expectedHash = generatedLink.hash; + + try { + await copyTextToClipboard(generatedLink.markdownLink); + if ( + markdownCopyTokenRef.current !== requestToken || + generatedLink.hash !== expectedHash + ) { + return; + } + setMarkdownLinkCopyState("copied"); + } catch { + if ( + markdownCopyTokenRef.current !== requestToken || + generatedLink.hash !== expectedHash + ) { + return; + } + setMarkdownLinkCopyState("failed"); + } + }; + + const handlePreview = () => { + if (!generatedLink || isGeneratedLinkStale) { + return; + } + + const shareBase = getShareBaseUrl(); + if (shareBase && !isOnShareBase(shareBase)) { + window.location.assign(generatedLink.url); + return; + } + + onPreviewHash(generatedLink.hash); + }; + + 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 ? ( + <> +