From f2446c78aded8794a3052446d8621adc5f112baf Mon Sep 17 00:00:00 2001 From: jamesrochabrun Date: Wed, 19 Aug 2026 09:35:51 -0700 Subject: [PATCH] Add Studio: agent-rendered artifacts and design canvases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A side panel where agents render things that do not exist in the codebase yet — one HTML document (agenthub_artifact) or N variants of a component on an infinite canvas (agenthub_design) — and the user points, comments, crops, tweaks, edits, and picks one. Works for Claude and Codex. Nothing is written into the project: the agent renders into a scratch surface served from an in-process loopback server, and everything the user does becomes a panel-local edit or a prompt. - Tool boundary: per-variant CSS scoping (StudioCSSScoper), fragment normalization, payload limits, shared tweak props validated and required to be referenced. - Tweaks: one shared props schema per canvas exposed as CSS custom properties; Save defaults writes into the payload; agent actions re-file. - Edit mode bakes canvas edits into the variant (quiet, with Revert); documents keep the send-to-agent path. - Implement (header menu + per-artboard pill) is the only action that asks for code and always carries the edited variant; agenthub_get_artifact lets a variant chosen in chat resolve to the same content. - Agents are steered by a system-prompt nudge (default-on, Settings toggle) and a bundled agenthub-studio skill for both providers. - Codex 0.148 per-tool MCP approval: mark the bundled server auto, and XcodeBuildMCP auto under never/full-auto (own setting or global config); annotate every tool (readOnlyHint on list/get/planning). - SQLite v18 studio_artifacts; served documents are a cache reconciled on launch; SidePanelContent.artifact renamed .claudeArtifact. See AgentHubStudio.md. --- AgentHubStudio.md | 308 +++++ CLAUDE.md | 6 +- README.md | 14 + .../AgentHubCLI/AgentHubMCPServer.swift | 619 ++++++++- .../AgentHubCLIKit/StudioArtifactModels.swift | 266 ++++ .../AgentHubCLIKit/StudioArtifactQueue.swift | 117 ++ .../AgentHubCLIKit/StudioCSSScoper.swift | 470 +++++++ .../StudioFragmentNormalizer.swift | 113 ++ .../AgentHubCLIKit/StudioIndexStore.swift | 123 ++ .../AgentHubCLIKit/StudioTweakProps.swift | 285 ++++ .../StudioArtifactQueueTests.swift | 87 ++ .../StudioCSSScoperTests.swift | 154 +++ .../StudioFragmentNormalizerTests.swift | 64 + .../StudioIndexStoreTests.swift | 39 + .../StudioTweakPropsTests.swift | 141 ++ app/modules/AgentHubCore/Package.swift | 1 + .../Configuration/AgentHubDefaults.swift | 2 + .../Configuration/AgentHubProvider.swift | 56 +- .../CLICommandConfiguration.swift | 31 +- .../CodexGlobalConfigReader.swift | 74 + .../Configuration/StudioAgentGuidance.swift | 42 + .../Models/StudioArtifactRecord.swift | 94 ++ .../Resources/AgentHubStudioSkill/SKILL.md | 39 + .../AgentHubStudioSkill/agents/openai.yaml | 7 + .../AgentHubStudioSkillInstaller.swift | 64 + .../AgentHubWorktreeSkillInstaller.swift | 21 + .../Services/SessionMetadataStore.swift | 70 +- .../Services/StudioArtifactHandler.swift | 67 + .../Services/StudioArtifactMonitor.swift | 91 ++ .../Services/StudioDocumentWriter.swift | 533 ++++++++ .../StudioFeedbackPromptBuilder.swift | 124 ++ .../AgentHub/Services/StudioLibrary.swift | 344 +++++ .../StudioPromotionPromptBuilder.swift | 47 + .../Services/StudioStaticServer.swift | 252 ++++ .../Services/StudioStorageReconciler.swift | 78 ++ .../Services/StudioTweaksPromptBuilder.swift | 59 + .../UI/EmbeddedTerminalLaunchBuilder.swift | 14 + .../AgentHub/UI/MonitoringCardView.swift | 35 + .../UI/MultiProviderMonitoringPanelView.swift | 43 +- .../Sources/AgentHub/UI/SettingsView.swift | 5 + .../AgentHub/UI/StudioDesignEditState.swift | 160 +++ .../AgentHub/UI/StudioPanelState.swift | 79 ++ .../AgentHub/UI/StudioSettingsView.swift | 174 +++ .../AgentHub/UI/StudioSidePanelView.swift | 1188 +++++++++++++++++ .../ViewModels/CLISessionsViewModel.swift | 71 + .../ViewModels/StudioSettingsViewModel.swift | 62 + .../AgentHubTests/AIConfigSettingsTests.swift | 38 + .../CLISessionsViewModelStudioTests.swift | 123 ++ .../CodexGlobalConfigReaderTests.swift | 59 + .../EmbeddedTerminalLaunchBuilderTests.swift | 96 +- .../MultiSessionLaunchContextTests.swift | 14 + .../StudioArtifactHandlerTests.swift | 76 ++ .../StudioArtifactStoreTests.swift | 82 ++ .../StudioDesignEditStateTests.swift | 104 ++ .../StudioDocumentWriterTests.swift | 146 ++ .../AgentHubTests/StudioLibraryTests.swift | 236 ++++ .../AgentHubTests/StudioPanelStateTests.swift | 72 + .../StudioPromptBuilderTests.swift | 115 ++ .../StudioStaticServerTests.swift | 97 ++ .../StudioStorageReconcilerTests.swift | 43 + .../AgentHubTests/StudioTestSupport.swift | 65 + .../AgentHubTests/StudioTweaksTests.swift | 132 ++ 62 files changed, 8202 insertions(+), 29 deletions(-) create mode 100644 AgentHubStudio.md create mode 100644 app/modules/AgentHubCLI/Sources/AgentHubCLIKit/StudioArtifactModels.swift create mode 100644 app/modules/AgentHubCLI/Sources/AgentHubCLIKit/StudioArtifactQueue.swift create mode 100644 app/modules/AgentHubCLI/Sources/AgentHubCLIKit/StudioCSSScoper.swift create mode 100644 app/modules/AgentHubCLI/Sources/AgentHubCLIKit/StudioFragmentNormalizer.swift create mode 100644 app/modules/AgentHubCLI/Sources/AgentHubCLIKit/StudioIndexStore.swift create mode 100644 app/modules/AgentHubCLI/Sources/AgentHubCLIKit/StudioTweakProps.swift create mode 100644 app/modules/AgentHubCLI/Tests/AgentHubCLIKitTests/StudioArtifactQueueTests.swift create mode 100644 app/modules/AgentHubCLI/Tests/AgentHubCLIKitTests/StudioCSSScoperTests.swift create mode 100644 app/modules/AgentHubCLI/Tests/AgentHubCLIKitTests/StudioFragmentNormalizerTests.swift create mode 100644 app/modules/AgentHubCLI/Tests/AgentHubCLIKitTests/StudioIndexStoreTests.swift create mode 100644 app/modules/AgentHubCLI/Tests/AgentHubCLIKitTests/StudioTweakPropsTests.swift create mode 100644 app/modules/AgentHubCore/Sources/AgentHub/Configuration/CodexGlobalConfigReader.swift create mode 100644 app/modules/AgentHubCore/Sources/AgentHub/Configuration/StudioAgentGuidance.swift create mode 100644 app/modules/AgentHubCore/Sources/AgentHub/Models/StudioArtifactRecord.swift create mode 100644 app/modules/AgentHubCore/Sources/AgentHub/Resources/AgentHubStudioSkill/SKILL.md create mode 100644 app/modules/AgentHubCore/Sources/AgentHub/Resources/AgentHubStudioSkill/agents/openai.yaml create mode 100644 app/modules/AgentHubCore/Sources/AgentHub/Services/AgentHubStudioSkillInstaller.swift create mode 100644 app/modules/AgentHubCore/Sources/AgentHub/Services/StudioArtifactHandler.swift create mode 100644 app/modules/AgentHubCore/Sources/AgentHub/Services/StudioArtifactMonitor.swift create mode 100644 app/modules/AgentHubCore/Sources/AgentHub/Services/StudioDocumentWriter.swift create mode 100644 app/modules/AgentHubCore/Sources/AgentHub/Services/StudioFeedbackPromptBuilder.swift create mode 100644 app/modules/AgentHubCore/Sources/AgentHub/Services/StudioLibrary.swift create mode 100644 app/modules/AgentHubCore/Sources/AgentHub/Services/StudioPromotionPromptBuilder.swift create mode 100644 app/modules/AgentHubCore/Sources/AgentHub/Services/StudioStaticServer.swift create mode 100644 app/modules/AgentHubCore/Sources/AgentHub/Services/StudioStorageReconciler.swift create mode 100644 app/modules/AgentHubCore/Sources/AgentHub/Services/StudioTweaksPromptBuilder.swift create mode 100644 app/modules/AgentHubCore/Sources/AgentHub/UI/StudioDesignEditState.swift create mode 100644 app/modules/AgentHubCore/Sources/AgentHub/UI/StudioPanelState.swift create mode 100644 app/modules/AgentHubCore/Sources/AgentHub/UI/StudioSettingsView.swift create mode 100644 app/modules/AgentHubCore/Sources/AgentHub/UI/StudioSidePanelView.swift create mode 100644 app/modules/AgentHubCore/Sources/AgentHub/ViewModels/StudioSettingsViewModel.swift create mode 100644 app/modules/AgentHubCore/Tests/AgentHubTests/CLISessionsViewModelStudioTests.swift create mode 100644 app/modules/AgentHubCore/Tests/AgentHubTests/CodexGlobalConfigReaderTests.swift create mode 100644 app/modules/AgentHubCore/Tests/AgentHubTests/StudioArtifactHandlerTests.swift create mode 100644 app/modules/AgentHubCore/Tests/AgentHubTests/StudioArtifactStoreTests.swift create mode 100644 app/modules/AgentHubCore/Tests/AgentHubTests/StudioDesignEditStateTests.swift create mode 100644 app/modules/AgentHubCore/Tests/AgentHubTests/StudioDocumentWriterTests.swift create mode 100644 app/modules/AgentHubCore/Tests/AgentHubTests/StudioLibraryTests.swift create mode 100644 app/modules/AgentHubCore/Tests/AgentHubTests/StudioPanelStateTests.swift create mode 100644 app/modules/AgentHubCore/Tests/AgentHubTests/StudioPromptBuilderTests.swift create mode 100644 app/modules/AgentHubCore/Tests/AgentHubTests/StudioStaticServerTests.swift create mode 100644 app/modules/AgentHubCore/Tests/AgentHubTests/StudioStorageReconcilerTests.swift create mode 100644 app/modules/AgentHubCore/Tests/AgentHubTests/StudioTestSupport.swift create mode 100644 app/modules/AgentHubCore/Tests/AgentHubTests/StudioTweaksTests.swift 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")) + } +}