diff --git a/AgentHubStudio.md b/AgentHubStudio.md new file mode 100644 index 00000000..6fe5be04 --- /dev/null +++ b/AgentHubStudio.md @@ -0,0 +1,308 @@ +# AgentHub Studio + +AgentHub renders **artifacts an agent generated during a session** — an HTML document, or a set of design variants laid out on an infinite canvas — in a dedicated side panel where the user can point at any element, comment, tweak it, and send that feedback back to the agent. The guiding principle: + +> **The agent renders into a scratch surface, never into the project. Everything the user does on that surface becomes a prompt, never a file write.** + +This is the "try it before you build it" counterpart to the web preview panel: web preview shows you the real app, Studio shows you things that do not exist in the codebase yet — and must not, until the user says so. + +## On the name + +`SidePanelContent.artifact` is already taken by the **claude.ai artifact** panel (a WKWebView over a published `claude.ai/public/artifacts/…` page — a *detection* feature, read-only, Claude-only). That feature and this one share a noun and nothing else. To keep them from being confused forever: + +- The existing claude.ai case is renamed `.claudeArtifact` (mechanical; the model is already `ClaudeArtifact` and the detector `ClaudeArtifactURLDetector`). +- This feature is **Studio**, with one panel case, `.studio`: the panel lists every artifact and canvas for the project and the item's `kind` decides how it renders. + +The four agent-facing tools: `agenthub_artifact`, `agenthub_design`, `agenthub_list_artifacts`, `agenthub_get_artifact`. + +## The tools + +| | `agenthub_artifact` | `agenthub_design` | +|---|---|---| +| Renders | one self-contained HTML **document**, served verbatim | N **fragments** of one component, side by side | +| Surface | single scrolling page | infinite pan/zoom canvas of artboards | +| For | reports, diagrams, mockups, dashboards, "show me what this would look like" | "render 6 versions of this button" | +| Panel | `.studio` (kind `.document`) | `.studio` (kind `.canvas`) | +| Re-file | same `id` replaces in place, bumps `revision` | same `id` replaces the whole variant set | +| Inputs | `id?`, `title`, `html` | `id?`, `component`, `sourcePath?`, `props?`, `variants[{name, html, css?, notes?, width?, height?}]` | +| Tweaks | page calls `dc_set_props(...)` itself | one shared `props` schema for the whole canvas | + +`agenthub_list_artifacts` returns the project's index (see *Discovery*) so an agent refines an existing item instead of filing a near-duplicate. Both filing tools' descriptions instruct: *list first; when refining, pass the existing `id`.* + +`sourcePath` on `agenthub_design` is the real component the variants are exploring (e.g. `src/components/Button.tsx`). It is only used by *Promote* (below) so the promotion prompt can name the file; it never causes AgentHub to read or write it. + +### Tweaks — one schema per canvas, not per variant + +The Tweaks panel from web preview is ported, with one deliberate difference: on a canvas the schema is **shared by every variant**. The point of a canvas is comparing variants under the same knobs — radius 12 across all four buttons — so per-variant tweaks would defeat it, and sharing is also simpler. + +- `agenthub_design` accepts `props`: `{ name: { type: slider|color|select|toggle|text, value, label?, min?, max?, step?, unit?, options? } }` (or an ordered array of `{ name, … }`), validated by `StudioTweakPropParser` at the tool boundary (`maxProps` 24, CSS-identifier-safe names, value matches type, select value ∈ options, slider within range). +- Every prop is exposed to every artboard as the CSS custom property `--`; variants write `var(--radius)`. **A declared prop no variant references is a tool error** (`StudioTweakPropParser.unusedProps`): a control that moves nothing is exactly what a user reports as "tweaks don't work". Sliders carry their `unit`; toggles become `1`/`0`; text/select values are also written into elements marked `data-prop=""`, so copy is tweakable without JS. +- The host page renders defaults into a static ` + + + + + + """) + #expect(out.html == "") + #expect(out.css == ".btn { color: red; }") + #expect(out.warnings.isEmpty) + } + + @Test("Scripts are stripped and reported") + func scriptsAreStripped() { + let out = StudioFragmentNormalizer.normalize("
a
") + #expect(out.html == "
a
") + #expect(out.warnings.count == 1) + #expect(out.warnings[0].contains("2 + + + """ + } + + /// A prop value as it may appear inside the static `` would end the element. CSS + /// hex escapes keep the token stream intact and decode to the same characters + /// (`\3c ` → `<`), so `var(--x)` sees what the live `setProperty` path sets. + static func cssBlockValue(_ value: String) -> String { + var out = "" + for scalar in value.unicodeScalars { + switch scalar { + case "<", ">", "{", "}", ";", "\\", "\n", "\r", "\u{2028}", "\u{2029}": + out += String(format: "\\%x ", scalar.value) + default: + out.unicodeScalars.append(scalar) + } + } + return out + } + + /// The ordered schema the host page hands to `dc_set_props`, as a JSON array + /// literal safe to inline in a `")), + StudioTweakProp(name: "shadow", type: .toggle, value: .boolean(true)), + ] + + @Test("The canvas host page exposes props as CSS variables and declares the schema") + func hostPageExposesProps() throws { + let artifact = makeStudioCanvas().withContent(props: props) + let html = try StudioDocumentWriter.render(artifact) + + #expect(html.contains("") == "a \\7d b\\3b \\3c /style\\3e ") + #expect(html.contains("window.dc_set_props(schema)")) + #expect(html.contains("window.dc_on_props_changed = function")) + } + + @Test("A canvas without props emits no props block and an empty schema") + func hostPageWithoutProps() throws { + let html = try StudioDocumentWriter.render(makeStudioCanvas()) + #expect(!html.contains("studio-props")) + #expect(html.contains("window.__studioProps = [];")) + } + + @MainActor + @Test("Save defaults on a canvas updates the shared schema and re-stores with a revision bump") + func saveDefaultsCanvas() async throws { + let root = try temporaryStudioRoot() + defer { try? FileManager.default.removeItem(at: root) } + let persistence = StudioPersistenceMock() + let library = StudioLibrary( + persistence: persistence, + documents: StudioDocumentWriter(rootURL: root.appendingPathComponent("docs")), + server: StudioServerMock(), + index: StudioIndexStore(directoryURL: root.appendingPathComponent("index")) + ) + await library.store(makeStudioCanvas(id: "c1").withContent(props: props), projectKey: "/repo", sessionId: "s1", aliasPaths: []) + + try await library.saveTweakDefaults( + artifactId: "c1", + values: ["radius": .number(20), "shadow": .boolean(false)], + projectKey: "/repo", + sessionId: "s1", + aliasPaths: [] + ) + + let stored = try #require(library.artifact(id: "c1", projectKey: "/repo")) + #expect(stored.revision == 2) + #expect(stored.props.first { $0.name == "radius" }?.value == .number(20)) + #expect(stored.props.first { $0.name == "shadow" }?.value == .boolean(false)) + #expect(stored.props.first { $0.name == "accent" }?.value == .string("#0a84ff")) + #expect(stored.variants == makeStudioCanvas().variants) + let html = try String(contentsOf: library.documentURL(for: stored, projectKey: "/repo"), encoding: .utf8) + #expect(html.contains("--radius: 20px;")) + #expect(try persistence.saved.first?.decodedArtifact().props.first { $0.name == "radius" }?.value == .number(20)) + + await #expect(throws: StudioLibrary.TweakDefaultsError.self) { + try await library.saveTweakDefaults(artifactId: "c1", values: ["nope": .number(1)], projectKey: "/repo", sessionId: "s1", aliasPaths: []) + } + } + + @MainActor + @Test("Save defaults on a document splices the dc_set_props call, never the whole file") + func saveDefaultsDocument() async throws { + let root = try temporaryStudioRoot() + defer { try? FileManager.default.removeItem(at: root) } + let library = StudioLibrary( + persistence: StudioPersistenceMock(), + documents: StudioDocumentWriter(rootURL: root.appendingPathComponent("docs")), + server: StudioServerMock(), + index: StudioIndexStore(directoryURL: root.appendingPathComponent("index")) + ) + let html = """ +

Hi

+ + """ + await library.store(makeStudioDocument(id: "d1", html: html), projectKey: "/repo", sessionId: "s1", aliasPaths: []) + + try await library.saveTweakDefaults(artifactId: "d1", values: ["radius": .number(24)], projectKey: "/repo", sessionId: "s1", aliasPaths: []) + + let stored = try #require(library.artifact(id: "d1", projectKey: "/repo")) + #expect(stored.revision == 2) + #expect(stored.html?.contains("radius: { type: \"slider\", value: 24, min: 0, max: 40 }") == true) + #expect(stored.html?.contains("accent: { type: \"color\", value: \"#0a84ff\" }") == true) + #expect(stored.html?.hasPrefix("

Hi

") == true) + } + + @Test("Tweaks prompts re-file by id and forbid project edits") + func prompts() { + let canvas = makeStudioCanvas(id: "c9") + let existing = [TweakProp(name: "radius", label: "Radius", type: .slider, value: .number(12))] + let ideas = StudioTweaksPromptBuilder.ideasPrompt(artifact: canvas, existingProps: existing) + #expect(ideas.contains("design canvas \"Primary button\" (id c9)")) + #expect(ideas.contains("radius (slider)")) + #expect(ideas.contains("agenthub_design using id c9")) + #expect(ideas.contains("var(--)")) + #expect(ideas.contains("do not edit project files")) + + let custom = StudioTweaksPromptBuilder.customPrompt(artifact: makeStudioDocument(id: "d9"), instruction: "a density knob") + #expect(custom.contains("artifact \"Q3 report\" (id d9): a density knob")) + #expect(custom.contains("agenthub_artifact using id d9")) + #expect(custom.contains("dc_set_props")) + + let delete = StudioTweaksPromptBuilder.deleteAllPrompt(artifact: canvas) + #expect(delete.contains("with no `props`")) + #expect(delete.contains("do not edit project files")) + } +}