Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// A markdown source document shows the FORMATTED document in the Rendered view
// (no raw "##" or backticks) and the editable raw source in the Code view. A
// non-markdown source document keeps the plain editable surface in both.
import { describe, expect, test } from "bun:test";
import { renderToStaticMarkup } from "react-dom/server";

import type { WorkpieceEditorController } from "./artifact-editor-state";
import { WorkpieceSurfaces } from "./artifact-editor-surfaces";

const SOURCE = "## Pull requests\n\n**bold** text and `code`\n\n- item one";

function editor(overrides: Partial<WorkpieceEditorController>): WorkpieceEditorController {
return {
artifactId: "a1",
pdfEmbed: false,
isMarkdown: false,
isRich: false,
isSheetGrid: false,
isSlidesEditor: false,
label: "Document source",
loading: false,
value: SOURCE,
sizeBytes: 10,
pdfPreviewUrl: "",
canonicalSource: () => SOURCE,
setSource: () => {},
...overrides,
} as unknown as WorkpieceEditorController;
}

describe("markdown workpiece surfaces", () => {
test("Rendered view formats markdown instead of showing the raw source", () => {
const html = renderToStaticMarkup(
<WorkpieceSurfaces editor={editor({ isMarkdown: true })} viewMode="rendered" />,
);
// The heading and emphasis are real elements, not literal markers.
expect(html).toContain("Pull requests");
expect(html).toContain("<strong>");
expect(html).not.toContain("## Pull requests");
expect(html).not.toContain("**bold**");
// It is a read-only preview, not the editable textarea.
expect(html).not.toContain("<textarea");
expect(html).toContain("Switch to Code to edit");
});

test("Code view holds the editable raw markdown source", () => {
const html = renderToStaticMarkup(
<WorkpieceSurfaces editor={editor({ isMarkdown: true })} viewMode="code" />,
);
expect(html).toContain("<textarea");
expect(html).toContain("## Pull requests");
});

test("a non-markdown source document keeps the plain editable surface", () => {
const html = renderToStaticMarkup(
<WorkpieceSurfaces editor={editor({ isMarkdown: false })} viewMode="rendered" />,
);
expect(html).toContain("<textarea");
expect(html).toContain("## Pull requests");
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
} from "@useagent/artifact-workspace";
import {
artifactEditorMode,
isMarkdownArtifact,
parseCsv,
richDocumentTemplate,
sanitizeRichHtml,
Expand All @@ -30,6 +31,17 @@ describe("artifact editor model", () => {
expect(artifactEditorMode(artifact("pdf", "Report.pdf"))).toBe("pdf-text");
});

test("recognizes markdown source documents by extension", () => {
expect(isMarkdownArtifact("AGENTS.md")).toBe(true);
expect(isMarkdownArtifact("README.markdown")).toBe(true);
expect(isMarkdownArtifact("Notes.MD")).toBe(true);
expect(isMarkdownArtifact("guide.mdx")).toBe(true);
expect(isMarkdownArtifact("script.py")).toBe(false);
expect(isMarkdownArtifact("data.json")).toBe(false);
expect(isMarkdownArtifact("plain.txt")).toBe(false);
expect(isMarkdownArtifact("Makefile")).toBe(false);
});

test("round-trips quoted CSV cells", () => {
const rows = parseCsv('name,notes\n"ACME, Inc.","line 1\nline 2"\nquote,"a ""b"""');
expect(rows).toEqual([
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,15 @@ export type ArtifactEditorMode =
| "slides-json"
| "pdf-text";

/** Text documents whose source is markdown: the Rendered view formats them
* (headings, bold, lists, code) instead of showing the raw source. */
const MARKDOWN_EXTENSIONS = new Set(["md", "markdown", "mdx"]);

/** True when a source document should render as formatted markdown. */
export function isMarkdownArtifact(name: string): boolean {
return MARKDOWN_EXTENSIONS.has(artifactFileExtension(name));
}

export function artifactEditorMode(
artifact: Pick<ArtifactDescriptor, "content_type" | "name" | "size_bytes"> &
Readonly<{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import type { OrgChange } from "@/lib/org-changes";
import {
type ArtifactEditorMode,
artifactEditorMode,
isMarkdownArtifact,
presentationTemplate,
richDocumentTemplate,
sanitizeRichHtml,
Expand Down Expand Up @@ -171,6 +172,9 @@ export interface WorkpieceEditorController {
readonly isRich: boolean;
readonly isSlidesEditor: boolean;
readonly isSheetGrid: boolean;
/** A markdown source document: the Rendered view shows formatted markdown and
* the Code view holds the editable source. */
readonly isMarkdown: boolean;
/** A byte-authoritative (published) PDF: render the embedded preview, never
* the text editor or raw bytes. */
readonly pdfEmbed: boolean;
Expand Down Expand Up @@ -237,6 +241,7 @@ export function useWorkpieceEditor(
const isRich = editorMode === "rich-document";
const isSlidesEditor = editorMode === "slides-json";
const isSheetGrid = editorMode === "sheet-grid";
const isMarkdown = editorMode === "source-document" && isMarkdownArtifact(artifact.name);

const normalizeValue = useCallback(
(next: string) => (isRich ? sanitizeRichHtml(next) : next),
Expand Down Expand Up @@ -478,6 +483,7 @@ export function useWorkpieceEditor(
isRich,
isSlidesEditor,
isSheetGrid,
isMarkdown,
pdfEmbed,
pdfPreviewUrl: pdfEmbed ? withCacheBust(artifact.preview_url, reloadNonce) : artifact.preview_url,
sizeBytes: artifact.size_bytes,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import {
import { type CSSProperties, type RefObject, useId, useState } from "react";
import { Select, SelectItem } from "@/components/base/select/select";
import type { WorkpieceEditorController } from "./artifact-editor-state";
import { MarkdownWorkpieceSurface, SourceSurface } from "./artifact-editor-text-surfaces";
import { sanitizeRichHtml } from "./artifact-editor-model";
import { DeckSlideCanvas } from "./deck-canvas";
import { SheetGridSurface } from "./sheet-grid";
Expand Down Expand Up @@ -923,33 +924,6 @@ export function DeckSurface({
);
}

export function SourceSurface({
label,
loading,
value,
onChange,
}: {
readonly label: string;
readonly loading: boolean;
readonly value: string;
readonly onChange: (value: string) => void;
}) {
return (
<>
<label htmlFor="workpiece-source" className="text-body-2-medium text-text-primary">
{label}
</label>
<textarea
id="workpiece-source"
value={value}
onChange={(event) => onChange(event.currentTarget.value)}
disabled={loading}
className="mt-2 min-h-[420px] w-full flex-1 resize-y rounded-xl border border-border-button-default bg-background-primary-default p-4 font-mono text-body-2-regular text-text-primary outline-none focus:border-foreground-icon-primary focus:ring-2 focus:ring-border-button-default disabled:opacity-50"
/>
</>
);
}

/** The default honest note for a byte-authoritative (published) PDF embed. */
const PUBLISHED_PDF_NOTE =
"Published PDF. Page reorder and delete are the supported revisions; the text and visual content are not editable here.";
Expand Down Expand Up @@ -1042,6 +1016,11 @@ export function WorkpieceSurfaces({
<PdfEmbedSurface url={editor.pdfPreviewUrl} />
);
}
// Markdown: Rendered shows the formatted document; Code holds the editable
// source, so the Rendered view never shows "##" or backticks.
if (editor.isMarkdown) {
return <MarkdownWorkpieceSurface editor={editor} viewMode={viewMode} />;
}
if (viewMode === "code") {
return <WorkpieceCodeView label={editor.label} source={editor.canonicalSource()} />;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
"use client";

// Text-document surfaces for the workpiece editor, split out of
// artifact-editor-surfaces.tsx: the plain editable source textarea, and the
// markdown surfaces (a formatted Rendered preview plus the editable Code
// source). Kept here so the shared surface switcher stays under its size cap.

import { Markdown } from "@/components/prompt-kit/markdown";
import type { WorkpieceEditorController } from "./artifact-editor-state";

/** The plain editable source surface for a text document, and the Code view of
* a markdown document: a monospace textarea bound to the canonical source. */
export function SourceSurface({
label,
loading,
value,
onChange,
}: {
readonly label: string;
readonly loading: boolean;
readonly value: string;
readonly onChange: (value: string) => void;
}) {
return (
<>
<label htmlFor="workpiece-source" className="text-body-2-medium text-text-primary">
{label}
</label>
<textarea
id="workpiece-source"
value={value}
onChange={(event) => onChange(event.currentTarget.value)}
disabled={loading}
className="mt-2 min-h-[420px] w-full flex-1 resize-y rounded-xl border border-border-button-default bg-background-primary-default p-4 font-mono text-body-2-regular text-text-primary outline-none focus:border-foreground-icon-primary focus:ring-2 focus:ring-border-button-default disabled:opacity-50"
/>
</>
);
}

/** Markdown document surfaces: Rendered shows the formatted document, read-only,
* with the same renderer the conversation uses, so headings, bold, lists, code
* and tables look the way they do in a reply. Code holds the editable raw
* source, so the Rendered view never shows "##" or backticks. */
export function MarkdownWorkpieceSurface({
editor,
viewMode,
}: {
readonly editor: WorkpieceEditorController;
readonly viewMode: "rendered" | "code";
}) {
if (viewMode === "code") {
return (
<SourceSurface
label={editor.label}
loading={editor.loading}
value={editor.value}
onChange={editor.setSource}
/>
);
}
return (
<div className="mt-4 flex min-h-0 flex-1 flex-col">
<p className="text-caption-1-regular text-text-tertiary">
Rendered preview. Switch to Code to edit the markdown.
</p>
<div className="mt-2 min-h-[420px] flex-1 overflow-auto rounded-xl border border-border-button-default bg-background-primary-default p-5">
{editor.loading ? (
<p className="text-body-2-regular text-text-tertiary">Loading…</p>
) : editor.value.trim() ? (
<Markdown>{editor.value}</Markdown>
) : (
<p className="text-body-2-regular text-text-tertiary">This document is empty.</p>
)}
</div>
</div>
);
}
2 changes: 1 addition & 1 deletion frontend/components/foundations/ratchet.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ function lineCount(text: string): number {
// Recorded baselines for the files that are ALREADY over the 800-line cap. A
// number here may only SHRINK; new files get no baseline and are capped at 800.
const FRONTEND_SIZE_BASELINE: Record<string, number> = {
"app/(library)/agent/artifacts/[id]/artifact-editor-surfaces.tsx": 1075,
"app/(library)/agent/artifacts/[id]/artifact-editor-surfaces.tsx": 1054,
"components/agent-ui/rich-approval-card.tsx": 880,
"components/chat/session-view.tsx": 990,
};
Expand Down
Loading