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 .github/workflows/checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 12 additions & 1 deletion content/solid/composer.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,19 @@ import { Composer } from "@/components/ui/composer"
```tsx
const [text, setText] = React.useState("")

<Composer value={text} onValueChange={setText} onSubmit={send} />
<Composer
value={text}
onValueChange={setText}
onSubmit={send}
textareaProps={{ "aria-label": "Message" }}
/>
```

`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.
Expand All @@ -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<ComponentProps<"textarea">, "id" \| "aria-label" \| "aria-labelledby" \| "aria-describedby">` | - |
| `running` | `boolean` | `false` |
| `leading` | `ReactNode` | - |
| `tags` | `ReactNode` | - |
Expand Down
4 changes: 4 additions & 0 deletions content/solid/diff-view.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,7 @@ import { DiffView } from "@/components/ui/diff-view"
```tsx
<DiffView patch={patch} />
```

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.
2 changes: 1 addition & 1 deletion registry.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
],
Expand Down
55 changes: 55 additions & 0 deletions scripts/block-metadata.mjs
Original file line number Diff line number Diff line change
@@ -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
}
73 changes: 73 additions & 0 deletions scripts/block-metadata.test.mjs
Original file line number Diff line number Diff line change
@@ -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/)
}
})
10 changes: 6 additions & 4 deletions scripts/build-registry.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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" }],
}
Expand Down
1 change: 1 addition & 0 deletions src/blocks/agent-workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -564,6 +564,7 @@ export default function AgentWorkspace() {
<div className="flex shrink-0 justify-center px-4 pb-4 md:px-6">
<div className="w-full max-w-[600px]">
<Composer
textareaProps={{ "aria-label": "Message" }}
value={value}
onValueChange={setValue}
onSubmit={send}
Expand Down
11 changes: 10 additions & 1 deletion src/blocks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,16 @@ function filesOf(name: string, own?: boolean) {
path: "shared/overview.tsx",
source: sources["/src/blocks/shared/overview.tsx"],
});
return files;
// Previews use the site's block folders; copied files live at the paths shown above.
return files.map((file) => ({
...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";
Expand Down
18 changes: 18 additions & 0 deletions src/components/ui/composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ function Composer({
onStop,
running = false,
placeholder,
textareaProps,
leading,
tags,
above,
Expand All @@ -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<React.ComponentProps<"textarea">, "id" | "aria-label" | "aria-labelledby" | "aria-describedby">
leading?: React.ReactNode
tags?: React.ReactNode
/** Stands above the line of text: pictures going with the message. */
Expand All @@ -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
Expand Down Expand Up @@ -101,6 +118,7 @@ function Composer({
mark is never left on a line of its own. */}
<div className={cn("flex min-w-48 flex-1 items-end gap-1", Boolean(tags) && "ml-1.5", !leading && !tags && "pl-1.5")}>
<textarea
{...textareaProps}
ref={textarea}
rows={1}
value={value}
Expand Down
37 changes: 31 additions & 6 deletions src/components/ui/diff-view.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,39 @@
import { cn } from "@/lib/utils"

/** File headers and changed text can have the same prefix, even "+++ ". */
function patchLines(patch: string): string[] {
const lines: string[] = []
let inHunk = false
let oldLines = 0
let newLines = 0
for (const line of patch.replace(/\n$/, "").split("\n")) {
if (line.startsWith("diff --git ")) inHunk = false
const hunk = /^@@ -\d+(?:,(\d+))? \+\d+(?:,(\d+))? @@/.exec(line)
if (hunk) {
oldLines = Number(hunk[1] ?? 1)
newLines = Number(hunk[2] ?? 1)
inHunk = oldLines > 0 || newLines > 0
} else if (line.startsWith("@@")) {
// Also accept abbreviated hunks, as used in compact tool output.
inHunk = true
oldLines = newLines = Infinity
} else if (!inHunk && /^(---|\+\+\+) /.test(line)) {
continue
} else if (inHunk) {
if (line.startsWith("-") || line.startsWith(" ")) oldLines--
if (line.startsWith("+") || line.startsWith(" ")) newLines--
if (oldLines <= 0 && newLines <= 0) inHunk = false
}
lines.push(line)
}
return lines
}

/** Lines added and removed by a unified patch, headers aside. */
function patchStats(patch: string): { added: number; removed: number } {
let added = 0
let removed = 0
for (const line of patch.split("\n")) {
if (line.startsWith("+++") || line.startsWith("---")) continue
for (const line of patchLines(patch)) {
if (line.startsWith("+")) added++
else if (line.startsWith("-")) removed++
}
Expand All @@ -23,10 +51,7 @@ function DiffStats({ added, removed, className }: { added: number; removed: numb

/** A patch as a diff: hunks with added and removed lines coloured, headers dropped. */
function DiffView({ patch, className }: { patch: string; className?: string }) {
const lines = patch
.replace(/\n$/, "")
.split("\n")
.filter((line) => !line.startsWith("+++") && !line.startsWith("---"))
const lines = patchLines(patch)
return (
<div
data-slot="diff-view"
Expand Down
Loading
Loading