diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 22bf37a..39def38 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -26,6 +26,7 @@ jobs: node-version: 22 cache: npm - run: npm ci + - run: node --test scripts/block-metadata.test.mjs # the documentation and the registry are generated: the types of what renders them depend on them - run: npm run docs - name: The registry is as committed diff --git a/content/solid/composer.mdx b/content/solid/composer.mdx index 879f51a..86ea7fe 100644 --- a/content/solid/composer.mdx +++ b/content/solid/composer.mdx @@ -31,9 +31,19 @@ import { Composer } from "@/components/ui/composer" ```tsx const [text, setText] = React.useState("") - + ``` +`textareaProps` names and describes the input itself. It accepts `id`, `aria-label`, +`aria-labelledby`, and `aria-describedby`; use its `id` with an external label's +`htmlFor`. Other HTML attributes, including a top-level `id` or `aria-label`, +continue to apply to the outer wrapper. + ## While a turn runs The send mark has no block behind it in any state: its brightness says what it will do. With `running`, it is the quiet square that stops the turn, until something is typed, which is then sent behind it. @@ -52,6 +62,7 @@ The send mark has no block behind it in any state: its brightness says what it w | `onValueChange` | `(value: string) => void` | - | | `onSubmit` | `(value: string) => void` | - | | `onStop` | `() => void` | - | +| `textareaProps` | `Pick, "id" \| "aria-label" \| "aria-labelledby" \| "aria-describedby">` | - | | `running` | `boolean` | `false` | | `leading` | `ReactNode` | - | | `tags` | `ReactNode` | - | diff --git a/content/solid/diff-view.mdx b/content/solid/diff-view.mdx index 0f92a6c..4956563 100644 --- a/content/solid/diff-view.mdx +++ b/content/solid/diff-view.mdx @@ -27,3 +27,7 @@ import { DiffView } from "@/components/ui/diff-view" ```tsx ``` + +Pass a unified diff with complete hunk ranges. Compact or truncated hunks are +also supported; keep the `diff --git` separators when combining them across +multiple files so file headers can be distinguished from changed text. diff --git a/registry.json b/registry.json index 41828e1..f28095f 100644 --- a/registry.json +++ b/registry.json @@ -213,7 +213,7 @@ "name": "agent-workspace", "type": "registry:block", "title": "Agent workspace", - "description": "A dashboard with sidebar, charts and data table", + "description": "A workspace where an agent works: sessions, the thread, files and background tasks", "dependencies": [ "lucide-react" ], diff --git a/scripts/block-metadata.mjs b/scripts/block-metadata.mjs new file mode 100644 index 0000000..bac0f2c --- /dev/null +++ b/scripts/block-metadata.mjs @@ -0,0 +1,55 @@ +import ts from "typescript" + +/** Read each literal block on its own: formatting and neighbouring entries must not change its metadata. */ +export function blockMetadata(source) { + const file = ts.createSourceFile("src/blocks/index.ts", source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS) + if (file.parseDiagnostics.length) throw new Error("Invalid TypeScript in src/blocks/index.ts") + const declaration = file.statements + .filter(ts.isVariableStatement) + .flatMap((statement) => [...statement.declarationList.declarations]) + .find((item) => ts.isIdentifier(item.name) && item.name.text === "blocks") + if (!declaration?.initializer || !ts.isArrayLiteralExpression(declaration.initializer)) { + throw new Error("Expected a literal blocks array in src/blocks/index.ts") + } + + const metadata = new Map() + for (const entry of declaration.initializer.elements) { + if (!ts.isCallExpression(entry) || !ts.isIdentifier(entry.expression) || entry.expression.text !== "block" || + entry.arguments.length !== 1 || !ts.isObjectLiteralExpression(entry.arguments[0])) { + throw new Error("Expected block({...}) entries in src/blocks/index.ts") + } + const line = file.getLineAndCharacterOfPosition(entry.getStart(file)).line + 1 + const keys = ["name", "title", "description"] + const keyOf = (property) => { + if (ts.isSpreadAssignment(property)) return undefined + const name = ts.isComputedPropertyName(property.name) ? property.name.expression : property.name + return ts.isIdentifier(property.name) || ts.isStringLiteralLike(name) || ts.isNumericLiteral(name) ? name.text : undefined + } + const unrelated = (property) => ts.isSpreadAssignment(property) + ? ts.isObjectLiteralExpression(property.expression) && property.expression.properties.every(unrelated) + : keyOf(property) !== undefined && !keys.includes(keyOf(property)) + const properties = new Map() + for (const property of entry.arguments[0].properties) { + // Either can replace an earlier literal at runtime, including its name. Do not emit stale metadata. + const key = keyOf(property) + if (key === undefined) { + if (unrelated(property)) continue + throw new Error(`Expected static block metadata properties at line ${line}`) + } + if (!keys.includes(key)) continue + if (properties.has(key)) throw new Error(`Duplicate block metadata property: ${key} at line ${line}`) + properties.set(key, property) + } + const string = (key) => { + const property = properties.get(key) + if (!property || !ts.isPropertyAssignment(property) || !ts.isStringLiteralLike(property.initializer)) { + throw new Error(`Expected a literal ${key} in block metadata at line ${line}`) + } + return property.initializer.text + } + const name = string("name") + if (metadata.has(name)) throw new Error(`Duplicate block metadata: ${name}`) + metadata.set(name, { title: string("title"), description: string("description") }) + } + return metadata +} diff --git a/scripts/block-metadata.test.mjs b/scripts/block-metadata.test.mjs new file mode 100644 index 0000000..e071065 --- /dev/null +++ b/scripts/block-metadata.test.mjs @@ -0,0 +1,73 @@ +import assert from "node:assert/strict" +import { readFileSync } from "node:fs" +import test from "node:test" +import { blockMetadata } from "./block-metadata.mjs" + +test("the existing workspace keeps its own multiline description", () => { + const source = readFileSync(new URL("../src/blocks/index.ts", import.meta.url), "utf8") + const metadata = blockMetadata(source) + assert.equal(metadata.get("agent-workspace").description, + "A workspace where an agent works: sessions, the thread, files and background tasks") + assert.equal(metadata.get("dashboard-01").description, "A dashboard with sidebar, charts and data table") +}) + +test("metadata tolerates whitespace, comments, quotes, escapes and nested unrelated properties", () => { + const source = `export const blocks = [block({ + name: 'first', title: /* description: "wrong" */ "First", + description:\n 'A \\'quoted\\' description', + other: { description: "nested", text: "})" }, + }), block({name: "second", title: "Second", description: "Own description"})]` + assert.deepEqual([...blockMetadata(source)], [ + ["first", { title: "First", description: "A 'quoted' description" }], + ["second", { title: "Second", description: "Own description" }], + ]) +}) + +test("a missing description cannot consume the next entry", () => { + assert.throws(() => blockMetadata(`const blocks = [ + block({name: "first", title: "First"}), + block({name: "second", title: "Second", description: "Not first"}), + ]`), /literal description/) +}) + +test("missing arrays, dynamic metadata and duplicate names fail with a useful error", () => { + assert.throws(() => blockMetadata("const other = []"), /literal blocks array/) + assert.throws(() => blockMetadata(`const blocks = [block({name: "x", title: "X", description: other})]`), /literal description/) + assert.throws(() => blockMetadata(`const blocks = [ + block({name: "x", title: "X", description: "One"}), + block({name: "x", title: "X", description: "Two"}), + ]`), /Duplicate block metadata: x/) +}) + +test("spreads and computed properties cannot silently replace literal metadata", () => { + for (const override of ["...extra", '[key]: "Other"', '...{ description: "Other" }']) { + assert.throws(() => blockMetadata(`const blocks = [block({ + name: "x", title: "X", description: "Original", ${override}, + })]`), /static block metadata properties at line 1/) + } +}) + +test("repeated metadata keys cannot make the registry differ from the runtime block", () => { + for (const key of ["name", "title", "description"]) { + assert.throws(() => blockMetadata(`const blocks = [block({ + name: "x", title: "X", description: "Original", "${key}": "Replacement", + })]`), new RegExp(`Duplicate block metadata property: ${key}`)) + } + assert.throws(() => blockMetadata(`const blocks = [block({ + name: "x", title: "X", description: "Original", ["description"]: "Replacement", + })]`), /Duplicate block metadata property: description at line 1/) +}) + +test("provably unrelated computed keys and literal spreads remain supported", () => { + assert.deepEqual([...blockMetadata(`const blocks = [block({ + name: "x", title: "X", description: "Original", + ["other"]: dynamic, ...{ another: dynamic, ...{ final: dynamic } }, + })]`)], [["x", { title: "X", description: "Original" }]]) +}) + +test("invalid TypeScript and nonliteral metadata members are rejected", () => { + assert.throws(() => blockMetadata("const blocks = [block({"), /Invalid TypeScript/) + for (const description of ['get description() { return "Other" }', "description"]) { + assert.throws(() => blockMetadata(`const blocks = [block({name: "x", title: "X", ${description}})]`), /literal description/) + } +}) diff --git a/scripts/build-registry.mjs b/scripts/build-registry.mjs index 189b805..fa362b8 100644 --- a/scripts/build-registry.mjs +++ b/scripts/build-registry.mjs @@ -20,6 +20,7 @@ import { execFileSync } from "node:child_process" import { copyFileSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs" import { dirname, join } from "node:path" import { fileURLToPath } from "node:url" +import { blockMetadata } from "./block-metadata.mjs" const root = join(dirname(fileURLToPath(import.meta.url)), "..") const out = join(root, "public/r") @@ -46,17 +47,18 @@ function needs(content) { } // The blocks: whole screens, one file each. Their titles and descriptions are in src/blocks/index.ts. -const blockIndex = read("src/blocks/index.ts") +const blockIndex = blockMetadata(read("src/blocks/index.ts")) const blocks = readdirSync(join(root, "src/blocks")) .filter((file) => file.endsWith(".tsx")) .map((file) => { const name = file.replace(/\.tsx$/, "") - const entry = blockIndex.slice(blockIndex.indexOf(`name: "${name}"`)) + const entry = blockIndex.get(name) + if (!entry) throw new Error(`Missing block metadata: ${name}`) return { name, type: "registry:block", - title: entry.match(/title: "([^"]*)"/)?.[1], - description: entry.match(/description: "([^"]*)"/)?.[1], + title: entry.title, + description: entry.description, ...needs(read(`src/blocks/${file}`)), files: [{ path: `src/blocks/${file}`, type: "registry:component" }], } diff --git a/src/blocks/agent-workspace.tsx b/src/blocks/agent-workspace.tsx index 00da0d1..3466cee 100644 --- a/src/blocks/agent-workspace.tsx +++ b/src/blocks/agent-workspace.tsx @@ -564,6 +564,7 @@ export default function AgentWorkspace() {
({ + ...file, + // Upstream preview metadata is not a supported Next App Router page export. + source: (file.path === "page.tsx" + ? file.source.replace(/^export const (?:iframeHeight|description) = [^\n]*\n\n?/gm, "") + : file.source) + .replaceAll(`@/blocks/${name}/`, "@/") + .replaceAll("@/blocks/shared/", "@/shared/"), + })); } const OVERVIEW = "@/blocks/shared/overview"; diff --git a/src/components/ui/composer.tsx b/src/components/ui/composer.tsx index 697e3ca..db1ab76 100644 --- a/src/components/ui/composer.tsx +++ b/src/components/ui/composer.tsx @@ -32,6 +32,7 @@ function Composer({ onStop, running = false, placeholder, + textareaProps, leading, tags, above, @@ -48,6 +49,8 @@ function Composer({ onStop?: () => void running?: boolean placeholder?: string + /** Names and describes the input; other HTML attributes belong to the wrapper. */ + textareaProps?: Pick, "id" | "aria-label" | "aria-labelledby" | "aria-describedby"> leading?: React.ReactNode tags?: React.ReactNode /** Stands above the line of text: pictures going with the message. */ @@ -73,6 +76,20 @@ function Composer({ el.style.height = `${el.scrollHeight}px` }, []) React.useLayoutEffect(resize, [value, resize]) + React.useLayoutEffect(() => { + const el = textarea.current + if (!el || typeof ResizeObserver === "undefined") return + // Side panels and attachments change wrapping without changing the text. + // Observe width alone so writing the new height cannot feed back into it. + let width = -1 + const observer = new ResizeObserver(([entry]) => { + if (entry.contentRect.width === width) return + width = entry.contentRect.width + resize() + }) + observer.observe(el) + return () => observer.disconnect() + }, [resize]) const submit = () => { if (!ready) return @@ -101,6 +118,7 @@ function Composer({ mark is never left on a line of its own. */}