From 1e33061b74960ad1045604ab28721949fa29a53a Mon Sep 17 00:00:00 2001 From: Ryan Dombrowski Date: Tue, 4 Aug 2026 17:17:53 -0400 Subject: [PATCH] =?UTF-8?q?feat(composer):=20Build=20=E2=80=94=20chat-driv?= =?UTF-8?q?en=20creation=20from=20approved=20components?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Phase 3 vertical slice, exactly as ratified: select a governed project, describe an interface in chat, generate it from the approved design-system vocabulary (dspack-gen 0.2.0 under the project-scoped contract), watch S1-S3 + bounded repair + emit stream per turn (AG-UI SSE), see it render through the trusted registry, refine it conversationally (the prior surface + the new instruction, regenerated COMPLETELY through every gate, prior turns kept for audit), and accept the result as a contract worked example — which immediately joins that intent's few-shot corpus, so accepted results compound generation. Agent: /project/run gains HttpAgent-shaped bodies + the conversation seed (validated, 400 on malformed) and deterministic scripted twins — a fresh run scripts a contract-derived S2 violation then the intent's LATEST worked example (the governed fail->repair->pass loop with zero models; accepting visibly changes what scripted plays next), a refinement returns the prior surface with a deterministic textual change ONLY when the seed is present (the ratified non-vacuous proof). New /project/save-example: server-side fail-closed acceptance — S1-S3 re-linted on the agent, unknown intents refused, harness + ledger preserved; a disabled client button is a courtesy, this gate is the contract. Governance is never written by builds. Composer: composer-core buildReadiness (exact remaining-setup reasons) + foldBuildEvents + vocabularyGap (S2-evidence only — a gap is named, never invented; the future Component Workshop hook); the Build view (thread, pipeline strip, trusted-registry canvas via the in-browser emit twin, provider/privacy statement, Accept/Refine with locks and focus management); Build-first IA (first nav item, default view when ready, disabled with the exact reason otherwise, Start-building CTA, "Set up your design system, then build with it"). Evidence: agent 40/40 (7 new, fail-first); composer-core 40/40 (6 new, fail-first); DOM suite 34/34 incl. 13 new Build specs (disabled-with- reason, unlock+default, streamed fail->repair->pass, refine differs and prior persists, accept->reload->few-shot round-trip via scripted playback, vocabulary gap, direct-route 422, double-submit/accept locks, live-region announcements, accessible names, keyboard+focus, axe over pending/failed/success/refinement). Real-model evidence (gemma4:e4b over the scoped demo contract): fresh ask passed S1-S3 first attempt; seeded refinement passed and applied exactly the requested changes (Degraded/ amber/us-east), conversation recorded in the audit report. Exhibit e2e 106 passed/4 skips; hosted composer smoke 13/13; typecheck clean; one dspack-gen version (0.2.0) across the tree. Co-Authored-By: Claude Fable 5 --- apps/agent/package.json | 14 +- apps/agent/src/project.test.ts | 151 +++++++++++ apps/agent/src/project.ts | 165 +++++++++++- apps/composer/app/agent-client.ts | 61 +++++ apps/composer/app/composer.tsx | 29 +- apps/composer/app/state.tsx | 185 ++++++++++++- apps/composer/app/views/build-view.tsx | 255 ++++++++++++++++++ apps/composer/app/views/project-view.tsx | 8 + apps/composer/package.json | 7 +- apps/web/package.json | 2 +- docs/COMPOSER.md | 39 +++ e2e/composer-agent.spec.ts | 4 + e2e/composer-build-a11y.spec.ts | 96 +++++++ e2e/composer-build.spec.ts | 183 +++++++++++++ e2e/support/agent-project.ts | 15 ++ packages/composer-core/src/build.ts | 145 ++++++++++ .../composer-core/src/composer-core.test.ts | 89 ++++++ packages/composer-core/src/index.ts | 9 + playwright.composer-agent.config.ts | 2 +- playwright.config.ts | 2 +- pnpm-lock.yaml | 21 +- 21 files changed, 1450 insertions(+), 32 deletions(-) create mode 100644 apps/composer/app/views/build-view.tsx create mode 100644 e2e/composer-build-a11y.spec.ts create mode 100644 e2e/composer-build.spec.ts create mode 100644 packages/composer-core/src/build.ts diff --git a/apps/agent/package.json b/apps/agent/package.json index fead71b..947e395 100644 --- a/apps/agent/package.json +++ b/apps/agent/package.json @@ -13,20 +13,20 @@ "record:catch": "tsx src/record-catch.ts" }, "dependencies": { - "@aestheticfunction/dspack-gen": "^0.1.3", - "@dspack-studio/agui-bridge": "workspace:*", - "@dspack-studio/contracts": "workspace:*", - "@dspack-studio/replay": "workspace:*", "@aestheticfunction/dspack-emit": "^0.4.1", "@aestheticfunction/dspack-export": "^0.5.0", + "@aestheticfunction/dspack-gen": "^0.2.0", "@aestheticfunction/dspack-spec": "^0.4.2", - "@dspack-studio/composer-core": "workspace:*" + "@dspack-studio/agui-bridge": "workspace:*", + "@dspack-studio/composer-core": "workspace:*", + "@dspack-studio/contracts": "workspace:*", + "@dspack-studio/replay": "workspace:*" }, "devDependencies": { + "@dspack-studio/scenarios": "workspace:*", "@types/node": "^22.10.2", "tsx": "^4.19.2", "typescript": "^5.7.2", - "vitest": "^3.0.0", - "@dspack-studio/scenarios": "workspace:*" + "vitest": "^3.0.0" } } diff --git a/apps/agent/src/project.test.ts b/apps/agent/src/project.test.ts index fe2372b..d323ed1 100644 --- a/apps/agent/src/project.test.ts +++ b/apps/agent/src/project.test.ts @@ -303,3 +303,154 @@ describe("save", () => { expect(payload.findings[0].path ?? payload.findings[0].target).toContain("propMap"); }); }); + +/** + * Phase 3 (Build): /project/run streaming + conversation refinement + + * server-side fail-closed example acceptance. SSE runs are captured through + * a minimal ServerResponse mock; every pipeline event line is parsed back. + */ +function sseCall(route: string, body: Record): Promise<{ status: number; events: any[] }> { + return new Promise((resolve, reject) => { + let status = 0; + const chunks: string[] = []; + const res = { + writeHead(code: number) { + status = code; + return res; + }, + write(chunk: string) { + chunks.push(String(chunk)); + return true; + }, + end() { + const events = chunks + .join("") + .split("\n\n") + .map((block) => block.split("\n").find((l) => l.startsWith("data:"))) + .filter((l): l is string => !!l) + .map((l) => JSON.parse(l.slice(5))); + resolve({ status, events }); + }, + } as unknown as ServerResponse; + handleProjectRoute(`/project/${route}`, body, res, {}, "text/event-stream", ((r: unknown, code: number, payload: unknown) => { + // JSON reply instead of a stream (a refusal): surface it for asserts. + resolve({ status: code, events: [payload] }); + }) as never).catch(reject); + }); +} + +const surfaceOfRun = (events: any[]) => { + const audit = events.find((e) => e.type === "CUSTOM" && e.name === "dspack.audit"); + return { audit: audit?.value, surface: audit?.value?.report?.attempts?.at(-1)?.surface }; +}; + +describe("build runs (/project/run, scripted)", () => { + it("streams a deterministic fail->repair->pass run scoped to the project", async () => { + const { status, events } = await sseCall("run", { path: root, prompt: "a status screen", intent: "status-report", modelRef: "scripted" }); + expect(status).toBe(200); + const names = events.map((e) => e.type + (e.name ? `:${e.name}` : "")); + expect(names[0]).toBe("RUN_STARTED"); + expect(names).toContain("CUSTOM:dspack.gates"); // per-attempt gate results + expect(names).toContain("CUSTOM:dspack.repair"); // the visible repair turn + expect(names.at(-1)).toBe("RUN_FINISHED"); + const { audit, surface } = surfaceOfRun(events); + expect(audit.outcome).toBe("passed"); + expect(audit.report.attempts.length).toBe(2); // violation, then the worked example + expect(surface.root.component).toBe("info-card"); + }); + + it("accepts HttpAgent-shaped bodies (RunAgentInput.forwardedProps)", async () => { + const { status, events } = await sseCall("run", { + threadId: "t", + runId: "r", + forwardedProps: { path: root, prompt: "a status screen", intent: "status-report", modelRef: "scripted" }, + }); + expect(status).toBe(200); + expect(surfaceOfRun(events).audit.outcome).toBe("passed"); + }); + + it("refinement is non-vacuous under scripted: the refined surface differs ONLY when the prior surface is supplied", async () => { + const fresh = await sseCall("run", { path: root, prompt: "a status screen", intent: "status-report", modelRef: "scripted" }); + const freshSurface = surfaceOfRun(fresh.events).surface; + + const again = await sseCall("run", { path: root, prompt: "make the title clearer", intent: "status-report", modelRef: "scripted" }); + expect(JSON.stringify(surfaceOfRun(again.events).surface)).toBe(JSON.stringify(freshSurface)); // no seed -> same + + const refined = await sseCall("run", { + path: root, + prompt: "make the title clearer", + intent: "status-report", + modelRef: "scripted", + conversation: [ + { role: "user", content: "a status screen" }, + { role: "assistant", content: JSON.stringify(freshSurface) }, + ], + }); + const refinedSurface = surfaceOfRun(refined.events).surface; + expect(surfaceOfRun(refined.events).audit.outcome).toBe("passed"); + expect(JSON.stringify(refinedSurface)).not.toBe(JSON.stringify(freshSurface)); // seed -> visibly different + expect(JSON.stringify(refinedSurface)).toContain("(refined)"); // the deterministic transform marker + }); + + it("refuses a malformed conversation with 400 before running anything", async () => { + const bad = await sseCall("run", { path: root, prompt: "x", intent: "status-report", modelRef: "scripted", conversation: [{ role: "narrator", content: 1 }] }); + expect(bad.status).toBe(400); + expect(String((bad.events[0] as any).error)).toContain("conversation"); + }); +}); + +describe("accepting a build result (/project/save-example, fail-closed)", () => { + const freshExample = () => JSON.parse(readFileSync(join(root, "acme-ui.dspack.json"), "utf8")).examples[0]; + + it("rejects lint-invalid surfaces server-side with the gate findings", async () => { + const surface = structuredClone(freshExample().surface); + surface.root.children[0].component = "not-a-component"; // S2 violation + const { status, payload } = await call("save-example", { + path: root, + example: { id: "ex.chat-bad", intent: "status-report", prompt: "bad", surface }, + }); + expect(status).toBe(422); + expect(payload.findings.some((f: any) => f.gate === "S2")).toBe(true); + // Nothing was written. + const doc = JSON.parse(readFileSync(join(root, "acme-ui.dspack.json"), "utf8")); + expect(doc.examples.some((e: any) => e.id === "ex.chat-bad")).toBe(false); + }); + + it("rejects unknown intents and malformed ids", async () => { + const surface = freshExample().surface; + expect((await call("save-example", { path: root, example: { id: "ex.x", intent: "not-an-intent", prompt: "p", surface } })).status).toBe(422); + expect((await call("save-example", { path: root, example: { id: "chat", intent: "status-report", prompt: "p", surface } })).status).toBe(400); + expect((await call("save-example", { path: root, example: { id: "ex.x", intent: "status-report", prompt: "p", surface: "nope" } })).status).toBe(400); + }); + + it("accepts a governed surface, preserves the ledger, and feeds the next run's few-shot + scripted playback", async () => { + // The accepted surface: the deterministic refinement of the worked example. + const refined = structuredClone(freshExample().surface); + const title = refined.root.children[0].children[0]; + title.text = `${title.text} (refined)`; + const { status, payload } = await call("save-example", { + path: root, + example: { id: "ex.chat-refined", intent: "status-report", name: "Chat: refined status", prompt: "make the title clearer", surface: refined }, + }); + expect(status).toBe(200); + expect(payload.ok).toBe(true); + expect(payload.ledger.hasLedger).toBe(true); + + const doc = JSON.parse(readFileSync(join(root, "acme-ui.dspack.json"), "utf8")); + const saved = doc.examples.find((e: any) => e.id === "ex.chat-refined"); + expect(saved.intent).toBe("status-report"); + expect(JSON.stringify(saved.surface)).toBe(JSON.stringify(refined)); + expect(doc.metadata["x-bootstrap"]).toBeDefined(); // ledger intact + + // Few-shot proof against the REAL saved file: the compiler now includes it. + const { compileContext } = await import("@aestheticfunction/dspack-gen/core"); + const context = compileContext(doc, "status-report"); + const pair = context.fewshot.find((m: any) => m.role === "assistant" && m.content.includes("(refined)")); + expect(pair).toBeDefined(); + + // Scripted playback proof: a fresh scripted run now converges on the + // LATEST accepted example — the accept loop visibly compounds. + const next = await sseCall("run", { path: root, prompt: "again", intent: "status-report", modelRef: "scripted" }); + expect(JSON.stringify(surfaceOfRun(next.events).surface)).toBe(JSON.stringify(refined)); + }); +}); diff --git a/apps/agent/src/project.ts b/apps/agent/src/project.ts index c24e348..fb125b1 100644 --- a/apps/agent/src/project.ts +++ b/apps/agent/src/project.ts @@ -429,23 +429,99 @@ function ollamaAdapterWithWindow(modelRef: string) { }); } +/** A conversation seed: prior chat turns for a refinement run (gen 0.2.0). */ +type ConversationTurn = { role: "user" | "assistant"; content: string }; + +function parseConversation(raw: unknown): ConversationTurn[] | undefined { + if (raw === undefined) return undefined; + if ( + !Array.isArray(raw) || + raw.some((m) => !m || typeof m !== "object" || !["user", "assistant"].includes((m as { role?: unknown }).role as string) || typeof (m as { content?: unknown }).content !== "string") + ) { + throw new ProjectError(400, "conversation must be an array of { role: 'user' | 'assistant', content: string } turns"); + } + return raw as ConversationTurn[]; +} + +/** Deep-walk a surface and return the first node carrying visible text. */ +function firstTextNode(node: unknown): { text: string } | null { + if (!node || typeof node !== "object") return null; + const record = node as Record; + if (typeof record.text === "string") return record as { text: string }; + for (const value of Object.values(record)) { + if (Array.isArray(value)) { + for (const child of value) { + const found = firstTextNode(child); + if (found) return found; + } + } else if (value && typeof value === "object") { + const found = firstTextNode(value); + if (found) return found; + } + } + return null; +} + +/** + * Scripted mode is the deterministic zero-model twin of a real chat run: + * - a FRESH run scripts a contract-derived S2 violation first, then the + * intent's LATEST worked example — so every scripted run demonstrates the + * governed fail -> repair -> pass loop honestly, and accepting a chat + * result visibly changes what scripted plays next (the example corpus is + * the product's memory); + * - a REFINEMENT run (conversation present) replays the prior surface from + * the seed with a deterministic, gate-neutral textual change — different + * output exists ONLY when the prior surface was supplied, which is the + * ratified non-vacuous-refinement proof, executable with zero models. + */ +function scriptedRunAdapter(example: { surface: unknown }, conversation: ConversationTurn[] | undefined): ScriptedAdapter { + if (conversation && conversation.length > 0) { + const priorRaw = [...conversation].reverse().find((m) => m.role === "assistant")?.content; + if (priorRaw) { + try { + const refined = JSON.parse(priorRaw) as Record; + const textNode = firstTextNode(refined); + if (textNode && !textNode.text.endsWith(" (refined)")) textNode.text = `${textNode.text} (refined)`; + else if (!textNode) (refined as { id?: string }).id = "refined"; + return new ScriptedAdapter([{ output: refined }]); + } catch { + // Fall through: an unparseable prior surface behaves like a fresh run. + } + } + } + const violating = structuredClone(example.surface) as { root?: { children?: Array> } }; + if (violating.root?.children?.[0]) violating.root.children[0] = { ...violating.root.children[0], component: "not-a-component" }; + // Three entries cover maxRepairs=2 (≤3 generations): the run always ends + // in a real outcome — passed when the example is clean, or an honest + // failed-lint-exhausted when the corpus itself violates — never a script + // exhaustion error. + return new ScriptedAdapter([{ output: violating }, { output: example.surface }, { output: example.surface }]); +} + /** AG-UI SSE generation under the PROJECT contract + profile. */ async function runProject(ctx: ProjectContext, body: Record, res: ServerResponse, cors: Record, accept: string | undefined) { const contract = readJson(ctx.contractPath) as Record; const profile = loadProfile(readJson(ctx.profilePath)); - const prompt = String(body.prompt ?? ""); + // HttpAgent posts RunAgentInput with the run parameters in forwardedProps; + // plain JSON bodies keep working (the test surface and curl). + const props = ((body.forwardedProps as Record | undefined) ?? body) as Record; + const prompt = String(props.prompt ?? ""); const intents = (contract.intents as Array<{ id: string }> | undefined) ?? []; - const intent = String(body.intent ?? intents[0]?.id ?? ""); - const modelRef = String(body.modelRef ?? "scripted"); + const intent = String(props.intent ?? intents[0]?.id ?? ""); + const modelRef = String(props.modelRef ?? "scripted"); + const conversation = parseConversation(props.conversation); const examples = (contract.examples as Array<{ intent: string; surface: unknown }> | undefined) ?? []; - const example = examples.find((e) => e.intent === intent) ?? examples[0]; + // LAST match: accepted chat results join the corpus at the end, and the + // deterministic twin plays the owner's latest accepted example. + const matching = examples.filter((e) => e.intent === intent); + const example = matching.at(-1) ?? examples.at(-1); if (modelRef === "scripted" && !example) { throw new ProjectError(400, "scripted mode needs at least one worked example in the contract"); } const adapter = modelRef === "scripted" - ? new ScriptedAdapter([{ output: example!.surface }]) + ? scriptedRunAdapter(example!, conversation) : modelRef.startsWith("ollama:") ? ollamaAdapterWithWindow(modelRef) : adapterFor(modelRef); @@ -463,6 +539,7 @@ async function runProject(ctx: ProjectContext, body: Record, re adapter, maxRepairs: 2, emitProfile: profile, + ...(conversation && conversation.length > 0 ? { conversation } : {}), onEvent: (event) => { // The bridge's PipelineEvent is a structural mirror of dspack-gen's // union (retired once dspack-gen#48 re-exports the type). @@ -475,6 +552,77 @@ async function runProject(ctx: ProjectContext, body: Record, re res.end(); } + +/** + * Accept a build result as a governed worked example — the ONLY save format + * for chat-accepted surfaces, and fail-closed SERVER-SIDE: a disabled + * client button is a courtesy, this gate is the contract. Refuses unless + * the surface passes S1-S3 for the project contract and the intent is one + * the owner authored. Never touches intents, rules, mappings, casualty + * declarations, or any other governance; writes through the same + * ledger-preserving, harness-gated path as every contract save. + */ +async function saveExample(ctx: ProjectContext, body: Record) { + const raw = body.example as Record | undefined; + if (!raw || typeof raw !== "object") throw new ProjectError(400, "example is required"); + const id = String(raw.id ?? ""); + if (!/^ex\.[a-z0-9][a-z0-9-]*$/.test(id)) { + throw new ProjectError(400, "example.id must be kebab-case with the 'ex.' prefix"); + } + if (!raw.surface || typeof raw.surface !== "object") throw new ProjectError(400, "example.surface must be a surface document"); + const prompt = String(raw.prompt ?? ""); + if (!prompt) throw new ProjectError(400, "example.prompt is required (the ask that produced this surface)"); + + const contract = readJson(ctx.contractPath) as Record; + const intents = ((contract.intents as Array<{ id: string }> | undefined) ?? []).map((i) => i.id); + const intent = String(raw.intent ?? ""); + if (!intents.includes(intent)) { + return { + status: 422, + payload: { ok: false, findings: [finding("document", "unknown-intent", "error", "example.intent", `'${intent}' is not an intent this contract's owner authored (${intents.join(", ") || "none"})`)] }, + }; + } + + // The server-side gate: S1-S3 over the project contract, zero errors. + const lint = lintSurface(raw.surface as Parameters[0], contract as Parameters[1]); + const findings: ComposerFinding[] = []; + for (const gate of lint.gates) { + if (gate.status === "FAIL") { + findings.push(finding(gate.gate as "S1", gate.name, "error", id, (gate.errors ?? []).join("; ") || gate.name)); + } + } + for (const f of lint.findings ?? []) { + if (f.level === "error") findings.push(finding("S3", f.ruleId, "error", `${id} ${f.location.path}`, `${f.message} — ${f.rationale}`)); + } + if (findings.length > 0) return { status: 422, payload: { ok: false, findings } }; + + const entry = { + id, + intent, + ...(raw.name ? { name: String(raw.name) } : {}), + prompt, + ...(raw.description ? { description: String(raw.description) } : {}), + surface: raw.surface, + }; + const document = structuredClone(contract); + const examples = ((document.examples as unknown[] | undefined) ?? []) as Array<{ id: string }>; + const at = examples.findIndex((e) => e.id === id); + if (at >= 0) examples[at] = entry as never; + else examples.push(entry as never); + document.examples = examples; + + // The same guarded write as /project/save: ledger preserved, harness clean. + if (!preservesLedger(contract, document)) { + return { status: 200, payload: { ok: false, findings: [finding("ledger", "ledger-dropped", "error", 'metadata["x-bootstrap"]', "a save may not remove the bootstrap ledger")] } }; + } + const report = documentReport(document, specValidators()); + if (!report.valid) { + return { status: 422, payload: { ok: false, findings: report.errors.map((e) => finding("document", "harness", "error", "", e)) } }; + } + atomicWriteJson(ctx.contractPath, document); + return { status: 200, payload: { ok: true, findings: [], example: entry, ledger: await ledgerStatus(document) } }; +} + // --------------------------------------------------------------------------- /** @@ -492,7 +640,7 @@ export async function handleProjectRoute( if (!path.startsWith("/project/")) return false; const route = path.slice("/project/".length); try { - const ctx = openProject(body.path); + const ctx = openProject(body.path ?? (body.forwardedProps as Record | undefined)?.path); switch (route) { case "connect": json(res, 200, await connect(ctx), cors); @@ -512,6 +660,11 @@ export async function handleProjectRoute( case "save": json(res, 200, await save(ctx, body), cors); return true; + case "save-example": { + const result = await saveExample(ctx, body); + json(res, result.status, result.payload, cors); + return true; + } case "run": await runProject(ctx, body, res, cors, accept); return true; diff --git a/apps/composer/app/agent-client.ts b/apps/composer/app/agent-client.ts index 5134ff0..31d17fc 100644 --- a/apps/composer/app/agent-client.ts +++ b/apps/composer/app/agent-client.ts @@ -3,6 +3,7 @@ * every helper resolves to a typed error the UI states plainly ("requires the * local agent") instead of simulating results. */ +import { HttpAgent, type BaseEvent } from "@dspack-studio/agui-bridge"; import type { ComposerFinding, LedgerStatus, ProjectManifest } from "@dspack-studio/composer-core"; export interface EmitPayload { @@ -98,3 +99,63 @@ export const agentEmit = (path: string) => post("/project/emit", { export const agentValidate = (path: string) => post("/project/validate", { path }); export const agentSave = (path: string, kind: "contract" | "profile", document: unknown) => post<{ ok: boolean; findings: Array<{ path?: string; target?: string; message: string }>; ledger?: LedgerStatus }>("/project/save", { path, kind, document }); + +/** Model refs the local agent can run right now ("scripted" + local Ollama tags). */ +export async function agentModels(): Promise { + try { + const res = await fetch(`${agentUrl()}/models`, { signal: AbortSignal.timeout(3000) }); + const body = (await res.json()) as { models?: string[] }; + return Array.isArray(body.models) && body.models.length ? body.models : ["scripted"]; + } catch { + return ["scripted"]; + } +} + +export interface BuildRunInput { + path: string; + prompt: string; + intent: string; + modelRef: string; + /** Refinement seed: the prior turn's ask + generated surface, verbatim. */ + conversation?: Array<{ role: "user" | "assistant"; content: string }>; +} + +/** + * Stream a project-scoped generation run (AG-UI SSE over /project/run). + * Events arrive as plain mapper-shaped JSON for composer-core's fold; the + * returned handle cancels the subscription. + */ +export function streamProjectRun( + input: BuildRunInput, + handlers: { onEvent(event: Record): void; onError(message: string): void; onComplete(): void }, +): { cancel(): void } { + const agent = new HttpAgent({ url: `${agentUrl()}/project/run` }); + const observable = agent.run({ + threadId: `build-${input.path}`, + runId: `build-${Date.now()}`, + messages: [], + tools: [], + context: [], + state: {}, + forwardedProps: input, + } as never); + const subscription = (observable as { subscribe(o: object): { unsubscribe(): void } }).subscribe({ + next: (event: BaseEvent) => handlers.onEvent(event as unknown as Record), + error: (err: unknown) => handlers.onError(err instanceof Error ? err.message : String(err)), + complete: () => handlers.onComplete(), + }); + return { cancel: () => subscription.unsubscribe() }; +} + +export interface AcceptedExample { + id: string; + intent: string; + name?: string; + prompt: string; + description?: string; + surface: Record; +} + +/** Server-side fail-closed acceptance of a build result as a worked example. */ +export const agentSaveExample = (path: string, example: AcceptedExample) => + post<{ ok: boolean; findings: ComposerFinding[]; example?: AcceptedExample; ledger?: LedgerStatus }>("/project/save-example", { path, example }); diff --git a/apps/composer/app/composer.tsx b/apps/composer/app/composer.tsx index d7ab94a..96ab75a 100644 --- a/apps/composer/app/composer.tsx +++ b/apps/composer/app/composer.tsx @@ -1,7 +1,8 @@ "use client"; -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { ComposerProvider, useComposer } from "./state"; +import { BuildView } from "./views/build-view"; import { ProjectView } from "./views/project-view"; import { InventoryView } from "./views/inventory-view"; import { ComponentView } from "./views/component-view"; @@ -11,9 +12,10 @@ import { ScenarioView } from "./views/scenario-view"; import { PreviewView } from "./views/preview-view"; import { ValidateView } from "./views/validate-view"; -export type View = "project" | "inventory" | "component" | "mapper" | "governance" | "scenarios" | "preview" | "validate"; +export type View = "build" | "project" | "inventory" | "component" | "mapper" | "governance" | "scenarios" | "preview" | "validate"; const VIEWS: Array<{ id: View; label: string }> = [ + { id: "build", label: "Build" }, { id: "project", label: "Project" }, { id: "inventory", label: "Inventory" }, { id: "component", label: "Component" }, @@ -27,6 +29,18 @@ const VIEWS: Array<{ id: View; label: string }> = [ function Shell() { const [view, setView] = useState("project"); const state = useComposer(); + // Build-first: once a connected project's setup passes, Build is the + // default working view (the catalog is setup FOR building). + const autoOpened = useRef(false); + useEffect(() => { + if (state.mode === "agent" && state.readiness.ready && !autoOpened.current) { + autoOpened.current = true; + setView("build"); + } + if (state.mode !== "agent") autoOpened.current = false; + }, [state.mode, state.readiness.ready]); + // Build stays visible when setup is incomplete, but says exactly why. + const buildBlocked = state.mode === "agent" && !state.readiness.ready ? state.readiness.reason : undefined; return (
@@ -46,7 +60,15 @@ function Shell() {