Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +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.
- `activeArtifactId` controls which artifact opens first.
- Internal diff file navigation stays in UI state and does not repurpose the fragment.

Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,12 @@ 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`, and `arx`, with automatic shortest-fragment selection across packed/non-packed wire formats
- The `arx` substitution dictionary is served at `/arx-dictionary.json` so agents can fetch it for local compression
- Markdown supports download plus browser print-to-PDF
- The viewer toolbar copies artifact bodies to the clipboard, downloads them as files, and (for markdown) supports browser print-to-PDF
- Deployment target: static hosting, including Cloudflare Pages

## Included Renderers

- `markdown` - GFM rendering with safe sanitization, download, print flow, and premium code fences that reuse the CodeMirror viewer stack
- `markdown` - GFM rendering with safe sanitization, copy/download/print flows from the shell, and premium code fences that reuse the CodeMirror viewer stack
- `code` - read-only CodeMirror view with line numbers, wrap toggle, syntax-tree-aware rainbow brackets, and maintained indentation markers
- `diff` - review-style multi-file git patch viewer with unified and split modes
- `csv` - parsed table view with sticky headers and horizontal overflow handling
Expand Down
4 changes: 2 additions & 2 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,15 @@ GitHub Pages is strongest when the application behaves like a static shell inste

## Renderer implementation

- `markdown` - formatted document view with download and print-to-PDF flow plus embedded premium code fences
- `markdown` - formatted document view with shell copy, download, and print-to-PDF flows plus embedded premium code fences
- `code` - read-only CodeMirror view with syntax-aware rendering and code affordances
- `diff` - review-style diff view with unified and split modes
- `csv` - table-focused data grid built from parsed rows and dynamic columns
- `json` - lightweight read-only tree view plus a raw CodeMirror view

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 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, and markdown print actions. 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.

Expand Down
4 changes: 2 additions & 2 deletions docs/payload-format.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ Two sample envelopes live in `src/lib/payload/examples.ts` for local development
}
```

Markdown artifacts use the `content` field and currently support client-side download and browser print-to-PDF from the viewer shell.
Markdown artifacts use the `content` field and currently support client-side clipboard copy, file download, and browser print-to-PDF from the viewer shell.

### Code artifact example

Expand All @@ -160,7 +160,7 @@ Markdown artifacts use the `content` field and currently support client-side dow
}
```

Code artifacts use the same `content` transport, plus optional `language` and `filename` hints for syntax-aware rendering and download naming.
Code artifacts use the same `content` transport, plus optional `language` and `filename` hints for syntax-aware rendering, download naming, and clipboard copy of the source text.

### Diff artifact example

Expand Down
2 changes: 1 addition & 1 deletion docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,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, print flow, themes, and layout hierarchy
- browser tests protect exported-app behavior, fragment-driven rendering, downloads, clipboard copy, print flow, themes, and layout hierarchy
- 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
Expand Down
26 changes: 2 additions & 24 deletions src/components/home/link-creator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { useEffect, useMemo, useState } from "react";
import type { LucideIcon } from "lucide-react";
import { ArrowUpRight, Check, Copy, ExternalLink, FileCode2, FileDiff, FileJson2, FileSpreadsheet, FileText, Link2 } from "lucide-react";
import { copyTextToClipboard } from "@/lib/copy-text";
import { createGeneratedArtifactLinkAsync, defaultLinkCreatorDraft, getBodyFieldLabel, type GeneratedArtifactLink, type LinkCreatorDraft } from "@/lib/payload/link-creator";
import { artifactKinds, codecs, type ArtifactKind } from "@/lib/payload/schema";
import { cn } from "@/lib/utils";
Expand Down Expand Up @@ -35,29 +36,6 @@ const fieldPlaceholders: Record<ArtifactKind, string> = {
json: '{\n "status": "ready",\n "artifacts": 1\n}',
};

async function copyText(value: string) {
if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(value);
return;
}

const textarea = document.createElement("textarea");
textarea.value = value;
textarea.setAttribute("readonly", "true");
textarea.style.position = "absolute";
textarea.style.left = "-9999px";
document.body.appendChild(textarea);
textarea.select();

try {
if (!document.execCommand("copy")) {
throw new Error("Copy command was rejected.");
}
} finally {
document.body.removeChild(textarea);
}
}

function getBaseUrl() {
if (typeof window === "undefined") {
return undefined;
Expand Down Expand Up @@ -121,7 +99,7 @@ export function LinkCreator({ onPreviewHash }: LinkCreatorProps) {
}

try {
await copyText(generatedLink.url);
await copyTextToClipboard(generatedLink.url);
setCopyState("copied");
} catch {
setCopyState("failed");
Expand Down
62 changes: 60 additions & 2 deletions src/components/viewer-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@

import dynamic from "next/dynamic";
import Image from "next/image";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { CSSProperties } from "react";
import type { LucideIcon } from "lucide-react";
import {
ArrowUpRight,
Check,
Copy,
Download,
FileCode2,
FileDiff,
Expand Down Expand Up @@ -34,6 +36,7 @@ import {
type MarkdownArtifact,
type PayloadEnvelope,
} from "@/lib/payload/schema";
import { copyTextToClipboard } from "@/lib/copy-text";
import { cn } from "@/lib/utils";
import { LinkCreator } from "@/components/home/link-creator";
import { ArtifactSelector } from "@/components/viewer/artifact-selector";
Expand Down Expand Up @@ -232,13 +235,17 @@ function getAnimationStyle(delay: number): CSSProperties {
* Render the main viewer shell for decoding and displaying artifact fragments from the URL hash.
*
* Manages fragment decoding and ARX dictionary loading, synchronizes component state with the browser hash,
* and provides UI and handlers for selecting, downloading, printing, and navigating artifacts or clearing the fragment.
* and provides UI and handlers for selecting, copying, downloading, printing, and navigating artifacts or clearing the fragment.
*
* @returns The root React element for the viewer shell UI
*/
export function ViewerShell() {
const [hash, setHash] = useState("");
const [rendererReady, setRendererReady] = useState(true);
const [artifactCopyState, setArtifactCopyState] = useState<"idle" | "copied" | "failed">("idle");
const activeArtifactRef = useRef<ArtifactPayload | null>(null);
/** Incremented on each copy click so stale async completions cannot overwrite state from a newer request. */
const artifactCopyTokenRef = useRef(0);

Comment thread
coderabbitai[bot] marked this conversation as resolved.
useEffect(() => {
const syncHash = () => {
Expand Down Expand Up @@ -272,6 +279,7 @@ export function ViewerShell() {
const fragmentLength = hash.startsWith("#") ? hash.length - 1 : hash.length;
const envelope = parsed.ok ? parsed.envelope : null;
const activeArtifact = envelope ? getActiveArtifact(envelope) : null;
activeArtifactRef.current = activeArtifact;
const markdownArtifact: MarkdownArtifact | null = activeArtifact?.kind === "markdown" ? activeArtifact : null;
const codeArtifact: CodeArtifact | null = activeArtifact?.kind === "code" ? activeArtifact : null;
const diffArtifact: DiffArtifact | null = activeArtifact?.kind === "diff" ? activeArtifact : null;
Expand Down Expand Up @@ -301,6 +309,24 @@ export function ViewerShell() {
setRendererReady(true);
}, []);

useEffect(() => {
setArtifactCopyState("idle");
}, [activeArtifact?.id]);

useEffect(() => {
if (artifactCopyState !== "copied" && artifactCopyState !== "failed") {
return;
}

const timer = window.setTimeout(() => {
setArtifactCopyState("idle");
}, 2000);

return () => {
window.clearTimeout(timer);
};
}, [artifactCopyState]);

const setFragmentHash = useCallback((nextHash: string) => {
if (window.location.hash === nextHash) {
return;
Expand Down Expand Up @@ -329,6 +355,30 @@ export function ViewerShell() {
[envelope, setFragmentHash],
);

const handleArtifactCopy = useCallback(async () => {
const artifact = activeArtifactRef.current;
if (!artifact) {
return;
}

const requestArtifactId = artifact.id;
const requestToken = ++artifactCopyTokenRef.current;
const body = getArtifactBody(artifact);

try {
await copyTextToClipboard(body);
if (activeArtifactRef.current?.id !== requestArtifactId || artifactCopyTokenRef.current !== requestToken) {
return;
}
setArtifactCopyState("copied");
Comment on lines +368 to +373

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Ignore stale copy completions after switching artifacts

Because clipboard writes are async, a user can click Copy on artifact A and switch to artifact B before the promise resolves. When A's copy eventually succeeds, this unconditionally sets the shared toolbar state to copied, so B now advertises Copied even though its contents were never copied. The success/failure update needs to be tied to the artifact that initiated the request (or discarded once activeArtifact changes).

Useful? React with 👍 / 👎.

} catch {
if (activeArtifactRef.current?.id !== requestArtifactId || artifactCopyTokenRef.current !== requestToken) {
return;
}
setArtifactCopyState("failed");
}
}, []);

const handleArtifactDownload = useCallback(() => {
if (!activeArtifact) {
return;
Expand Down Expand Up @@ -455,6 +505,14 @@ export function ViewerShell() {
</div>

<div className="viewer-toolbar">
<button
type="button"
className={cn("artifact-action", artifactCopyState === "copied" && "is-primary")}
onClick={handleArtifactCopy}
>
{artifactCopyState === "copied" ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
{artifactCopyState === "copied" ? "Copied" : artifactCopyState === "failed" ? "Copy failed" : "Copy"}
</button>
<button type="button" className="artifact-action is-primary" onClick={handleArtifactDownload}>
<Download className="h-3.5 w-3.5" />
Download
Expand Down
31 changes: 31 additions & 0 deletions src/lib/copy-text.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/**
* Copies a string to the clipboard using the Async Clipboard API when available,
* with a `document.execCommand("copy")` fallback when `navigator.clipboard` is missing
* or `navigator.clipboard.writeText` rejects (e.g. permission errors).
*/
export async function copyTextToClipboard(value: string): Promise<void> {
if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(value);
return;
} catch {
// Fall through to document.execCommand("copy") below.
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const textarea = document.createElement("textarea");
textarea.value = value;
textarea.setAttribute("readonly", "true");
textarea.style.position = "absolute";
textarea.style.left = "-9999px";
document.body.appendChild(textarea);
textarea.select();

try {
if (!document.execCommand("copy")) {
throw new Error("Copy command was rejected.");
}
} finally {
document.body.removeChild(textarea);
}
}
48 changes: 48 additions & 0 deletions tests/e2e/viewer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,54 @@ test("download action emits a file", async ({ page }) => {
await expect(download.suggestedFilename()).toContain("viewer-shell.tsx");
});

test("copy action copies artifact body to clipboard", async ({ page }) => {
await goToHash(page, getFragmentHash("Viewer bootstrap"));
await waitForViewerState(page, "artifact");

await page.evaluate(() => {
window.localStorage.removeItem("copied-artifact-body");
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: {
writeText: (value: string) => {
window.localStorage.setItem("copied-artifact-body", value);
return Promise.resolve();
},
},
});
});

await page.getByRole("button", { name: "Copy" }).click();
await expect(page.getByRole("button", { name: "Copied" })).toBeVisible();
await expect
.poll(() => page.evaluate(() => window.localStorage.getItem("copied-artifact-body")))
.toBe('export function ViewerShell() {\n return <main>Fragment-powered artifact viewer shell</main>;\n}');
});

test("copy action shows failure when clipboard API and execCommand fallback fail", async ({ page }) => {
await goToHash(page, getFragmentHash("Viewer bootstrap"));
await waitForViewerState(page, "artifact");

await page.evaluate(() => {
const origExec = document.execCommand.bind(document);
document.execCommand = (commandId: string, showUI?: boolean, value?: string | null) => {
if (commandId === "copy") {
return false;
}
return origExec(commandId, showUI, value ?? undefined);
};
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: {
writeText: () => Promise.reject(new Error("denied")),
},
});
});

await page.getByRole("button", { name: "Copy" }).click();
await expect(page.getByRole("button", { name: "Copy failed" })).toBeVisible();
});

test("invalid payloads fail gracefully", async ({ page }) => {
const decodeErrorMessage = "The fragment payload could not be decoded as valid JSON.";
await goToHash(page, invalidFragments.malformed);
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/e2e/visual.spec.ts-snapshots/code-light-chromium.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/e2e/visual.spec.ts-snapshots/diff-light-chromium.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/e2e/visual.spec.ts-snapshots/json-light-chromium.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/e2e/visual.spec.ts-snapshots/markdown-dark-chromium.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/e2e/visual.spec.ts-snapshots/markdown-light-chromium.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading