From 65beca5026386f7799123f2f8783c8ed884faf8c Mon Sep 17 00:00:00 2001 From: Filipe Forattini Date: Tue, 8 Sep 2026 17:44:29 -0300 Subject: [PATCH 1/3] fix(design): expose document actions in provider tool schema --- .changeset/design-document-schema.md | 5 ++ packages/core/src/design/document-tool.ts | 33 +++++++++++ packages/core/src/tool/design.ts | 12 +--- .../core/test/design-document-input.test.ts | 35 ++++++++++++ packages/redcode/src/tool/design.ts | 11 +--- .../redcode/test/design/tui-studio.test.ts | 57 +++++++++++++++++++ 6 files changed, 136 insertions(+), 17 deletions(-) create mode 100644 .changeset/design-document-schema.md create mode 100644 packages/core/src/design/document-tool.ts create mode 100644 packages/core/test/design-document-input.test.ts diff --git a/.changeset/design-document-schema.md b/.changeset/design-document-schema.md new file mode 100644 index 000000000000..63381c7368b7 --- /dev/null +++ b/.changeset/design-document-schema.md @@ -0,0 +1,5 @@ +--- +"@reddb-io/redcode": patch +--- + +Expose design_document arguments as a provider-compatible object with a required action and explicit usage guidance, while preserving validation of each operation. diff --git a/packages/core/src/design/document-tool.ts b/packages/core/src/design/document-tool.ts new file mode 100644 index 000000000000..16590310b0f0 --- /dev/null +++ b/packages/core/src/design/document-tool.ts @@ -0,0 +1,33 @@ +export * as DesignDocumentTool from "./document-tool" + +import { Schema } from "effect" +import { Design } from "@reddb-io/redcode-schema/design" + +export const description = + 'Create, inspect or update a design in this conversation. Always supply action. Use {"action":"list"} to inspect designs. To create, supply action="create" and input with name, journey (new/existing), engine (html/react/solid), and kind (screen/flow/comparison/deck); identify application for an existing project. Update requires id and input; reopen and refresh require id. Edit only the returned root. Persist briefing, decisions and scenarios.' + +// Providers need an object at the root. Decode into the discriminated union +// afterwards so exposing conditional fields does not weaken execution validation. +export const Input = Schema.Struct({ + action: Schema.Literals(["list", "create", "update", "reopen", "refresh"]), + id: Schema.optional(Design.ID).annotate({ description: "Required for update, reopen and refresh." }), + input: Schema.optional( + Schema.Struct({ + ...Design.Update.fields, + journey: Schema.optional(Design.Journey), + engine: Schema.optional(Design.Engine), + kind: Schema.optional(Design.Kind), + application: Schema.optional(Schema.String), + }), + ).annotate({ description: "Required for create and update. Create requires name, journey, engine and kind." }), +}).pipe( + Schema.decodeTo( + Schema.Union([ + Schema.Struct({ action: Schema.Literal("list") }), + Schema.Struct({ action: Schema.Literal("create"), input: Design.Create }), + Schema.Struct({ action: Schema.Literal("update"), id: Design.ID, input: Design.Update }), + Schema.Struct({ action: Schema.Literal("reopen"), id: Design.ID }), + Schema.Struct({ action: Schema.Literal("refresh"), id: Design.ID }), + ]), + ), +) diff --git a/packages/core/src/tool/design.ts b/packages/core/src/tool/design.ts index b3eb2aa4c4b0..d29df5cbbcc2 100644 --- a/packages/core/src/tool/design.ts +++ b/packages/core/src/tool/design.ts @@ -14,6 +14,7 @@ import { SessionMessage } from "../session/message" import { SessionGoal } from "../session/goal" import { makeLocationNode } from "../effect/app-node" import { DesignStore } from "../design/store" +import { DesignDocumentTool } from "../design/document-tool" import { DesignRenderer } from "../design/renderer" import { DesignPlaybooks } from "../design/playbooks" import { LocationMutation } from "../location-mutation" @@ -167,15 +168,8 @@ const layer = Layer.effectDiscard( }).pipe(Effect.catchTag("Design.Error", fail)), }), design_document: Tool.make({ - description: - "Create, inspect or update a design. The returned root is the only directory Design may edit. Persist briefing, decisions and exercised scenarios here.", - input: Schema.Union([ - Schema.Struct({ action: Schema.Literal("list") }), - Schema.Struct({ action: Schema.Literal("create"), input: Design.Create }), - Schema.Struct({ action: Schema.Literal("update"), id: Design.ID, input: Design.Update }), - Schema.Struct({ action: Schema.Literal("reopen"), id: Design.ID }), - Schema.Struct({ action: Schema.Literal("refresh"), id: Design.ID }), - ]), + description: DesignDocumentTool.description, + input: DesignDocumentTool.Input, output: Schema.Array(Design.Info), toModelOutput: ({ output }) => [ { diff --git a/packages/core/test/design-document-input.test.ts b/packages/core/test/design-document-input.test.ts new file mode 100644 index 000000000000..a43e354248cb --- /dev/null +++ b/packages/core/test/design-document-input.test.ts @@ -0,0 +1,35 @@ +import { expect, test } from "bun:test" +import { Schema } from "effect" +import { DesignDocumentTool } from "../src/design/document-tool" + +test("Design document keeps every operation and its payload through decoding", () => { + const inputs: unknown[] = [ + { action: "list" }, + { + action: "create", + input: { name: "Dark mode", journey: "existing", engine: "react", kind: "screen", application: "apps/admin" }, + }, + { action: "update", id: "design_fixture", input: { questions: ["Which theme?"], entry: "src/main.tsx" } }, + { action: "reopen", id: "design_fixture" }, + { action: "refresh", id: "design_fixture" }, + ] + inputs.forEach((input) => expect(input).toEqual(Schema.decodeUnknownSync(DesignDocumentTool.Input)(input))) +}) + +test("Design document rejects missing actions and incomplete conditional arguments", () => { + const inputs = [ + {}, + { action: "unknown" }, + { action: "create" }, + { action: "create", input: {} }, + { action: "create", input: { name: "Dark mode", journey: "existing", engine: "react" } }, + { action: "create", input: { name: "", journey: "existing", engine: "react", kind: "screen" } }, + { action: "update", input: {} }, + { action: "update", id: "design_fixture" }, + { action: "update", id: "design_fixture", input: { questions: [42] } }, + { action: "reopen" }, + { action: "refresh" }, + { action: "refresh", id: "invalid" }, + ] + inputs.forEach((input) => expect(() => Schema.decodeUnknownSync(DesignDocumentTool.Input)(input)).toThrow()) +}) diff --git a/packages/redcode/src/tool/design.ts b/packages/redcode/src/tool/design.ts index 672cf569831b..2c3078fb6397 100644 --- a/packages/redcode/src/tool/design.ts +++ b/packages/redcode/src/tool/design.ts @@ -1,3 +1,4 @@ +import { DesignDocumentTool } from "@reddb-io/redcode-core/design/document-tool" import { DesignReviewServer } from "@/design/review-server" import { DesignLegacy } from "@/design/legacy" import { DesignRead } from "@/design/read" @@ -166,14 +167,8 @@ export const DesignTools = Effect.gen(function* () { ), }), define("design_document", { - description: - "Create, inspect or update a design in this TUI conversation. Edit only the returned root. Persist briefing, decisions and scenarios.", - parameters: Schema.Union([ - Schema.Struct({ action: Schema.Literal("list") }), - Schema.Struct({ action: Schema.Literal("create"), input: Design.Create }), - Schema.Struct({ action: Schema.Literal("update"), id: Design.ID, input: Design.Update }), - Schema.Struct({ action: Schema.Literals(["reopen", "refresh"]), id: Design.ID }), - ]), + description: DesignDocumentTool.description, + parameters: DesignDocumentTool.Input, execute: (input, ctx) => run( "design_document", diff --git a/packages/redcode/test/design/tui-studio.test.ts b/packages/redcode/test/design/tui-studio.test.ts index 9ec35804769b..b796ebb19d5d 100644 --- a/packages/redcode/test/design/tui-studio.test.ts +++ b/packages/redcode/test/design/tui-studio.test.ts @@ -6,6 +6,8 @@ import { EventV2Bridge } from "../../src/event-v2-bridge" import { SessionEvent } from "@reddb-io/redcode-core/session/event" import { DesignLegacy } from "../../src/design/legacy" import { TestInstance } from "../fixture/fixture" +import { createOpenRouter } from "@openrouter/ai-sdk-provider" +import { ToolJsonSchema } from "../../src/tool/json-schema" import { ToolRegistry } from "../../src/tool/registry" import { MessageID } from "../../src/session/schema" import type { Tool } from "../../src/tool/tool" @@ -91,6 +93,61 @@ it.instance("the existing TUI session owns new revisions and SVG assets without }), ) +it.instance("Design document exposes its required action in the OpenRouter request", () => + Effect.gen(function* () { + const registry = yield* ToolRegistry.Service + const document = (yield* registry.all()).find((tool) => tool.id === "design_document")! + yield* Effect.promise(async () => { + const requests: unknown[] = [] + await using server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + async fetch(request) { + requests.push(await request.json()) + return Response.json({ + id: "fixture", + created: 0, + model: "z-ai/glm-5.3-flash", + choices: [{ index: 0, message: { role: "assistant", content: "OK" }, finish_reason: "stop" }], + }) + }, + }) + await createOpenRouter({ apiKey: "fixture", baseURL: server.url.href }) + .chat("z-ai/glm-5.3-flash") + .doGenerate({ + prompt: [{ role: "user", content: [{ type: "text", text: "Design a dark mode" }] }], + tools: [ + { + type: "function", + name: document.id, + description: document.description, + inputSchema: ToolJsonSchema.fromTool(document), + }, + ], + }) + expect(requests).toHaveLength(1) + expect(requests[0]).toMatchObject({ + tools: [ + { + function: { + parameters: { + type: "object", + required: ["action"], + properties: { + action: { type: "string", enum: ["list", "create", "update", "reopen", "refresh"] }, + id: { type: "string" }, + input: { type: "object" }, + }, + }, + }, + }, + ], + }) + expect(requests[0]).not.toHaveProperty("tools.0.function.parameters.anyOf") + }) + }), +) + it.instance("the TUI model receives and executes the new Design toolset", () => Effect.gen(function* () { const registry = yield* ToolRegistry.Service From 7b3b4bd9d65b81c79ea9a3481da793ff9892def9 Mon Sep 17 00:00:00 2001 From: Filipe Forattini Date: Tue, 8 Sep 2026 18:07:10 -0300 Subject: [PATCH 2/3] feat(tui): resume Design conversations from a picker --- .changeset/design-open-dialog.md | 5 + bun.lock | 1 + packages/redcode/src/server/shared/design.ts | 58 ++++++++- .../redcode/test/server/design-tui.test.ts | 56 +++++++++ packages/schema/src/design.ts | 16 +++ packages/tui/package.json | 1 + packages/tui/src/app.tsx | 10 ++ .../tui/src/component/dialog-design-list.tsx | 92 ++++++++++++++ packages/tui/test/design-open.test.tsx | 118 ++++++++++++++++++ 9 files changed, 354 insertions(+), 3 deletions(-) create mode 100644 .changeset/design-open-dialog.md create mode 100644 packages/tui/src/component/dialog-design-list.tsx create mode 100644 packages/tui/test/design-open.test.tsx diff --git a/.changeset/design-open-dialog.md b/.changeset/design-open-dialog.md new file mode 100644 index 000000000000..8eea7048a918 --- /dev/null +++ b/.changeset/design-open-dialog.md @@ -0,0 +1,5 @@ +--- +"@reddb-io/redcode": patch +--- + +Add /design-open to search existing Design conversations in the current workspace and resume their history from a session picker. diff --git a/bun.lock b/bun.lock index be364d279b6b..6779bc2cd997 100644 --- a/bun.lock +++ b/bun.lock @@ -1010,6 +1010,7 @@ "@opentui/solid": "catalog:", "@reddb-io/redcode-core": "workspace:*", "@reddb-io/redcode-plugin": "workspace:*", + "@reddb-io/redcode-schema": "workspace:*", "@reddb-io/redcode-sdk": "workspace:*", "@reddb-io/redcode-ui": "workspace:*", "clipboardy": "4.0.0", diff --git a/packages/redcode/src/server/shared/design.ts b/packages/redcode/src/server/shared/design.ts index 391b8f1de9d4..41f84523fcb2 100644 --- a/packages/redcode/src/server/shared/design.ts +++ b/packages/redcode/src/server/shared/design.ts @@ -4,10 +4,12 @@ import { DesignFeedback } from "@/design/feedback" import { DesignRead } from "@/design/read" import { DesignHandoff } from "@/design/handoff" import { Effect, Schema, FileSystem } from "effect" -import { eq } from "drizzle-orm" +import { and, eq, isNotNull, isNull, or } from "drizzle-orm" +import { FSUtil } from "@reddb-io/redcode-core/fs-util" import { HttpIncomingMessage, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" import { Design } from "@reddb-io/redcode-schema/design" import { DesignStore } from "@reddb-io/redcode-core/design/store" +import { DesignTable } from "@reddb-io/redcode-core/design/sql" import { DesignRenderer } from "@reddb-io/redcode-core/design/renderer" import { DesignExport } from "@reddb-io/redcode-core/design/export" import { DesignWhiteboard } from "@reddb-io/redcode-core/design/whiteboard" @@ -27,7 +29,58 @@ export function serveDesignEffect(request: HttpServerRequest.HttpServerRequest) const url = new URL(request.url, "http://localhost") if (!DesignHost.allowed(request.headers.host)) return HttpServerResponse.empty({ status: 403 }) const parts = url.pathname.split("/").filter(Boolean) - if (parts[0] !== "design" || parts[1] !== "session") return HttpServerResponse.empty({ status: 404 }) + if (parts[0] !== "design") return HttpServerResponse.empty({ status: 404 }) + const db = yield* Database.Service + if (request.method === "GET" && parts[1] === "list" && parts.length === 2) { + const directory = url.searchParams.get("directory") + if (!directory) return HttpServerResponse.empty({ status: 400 }) + const rows = yield* db.db + .select({ + sessionID: SessionTable.id, + title: SessionTable.title, + updated: SessionTable.time_updated, + design: DesignTable.data, + }) + .from(SessionTable) + .leftJoin( + DesignTable, + and(eq(SessionTable.id, DesignTable.session_id), eq(DesignTable.directory, FSUtil.resolve(directory))), + ) + .where( + and( + eq(SessionTable.directory, FSUtil.resolve(directory)), + isNull(SessionTable.time_archived), + or(isNotNull(DesignTable.id), eq(SessionTable.agent, "design")), + ), + ) + .all() + .pipe(Effect.orDie) + const conversations = rows.reduce((result, row) => { + const current = result.get(row.sessionID) + result.set(row.sessionID, { + sessionID: row.sessionID, + title: row.title, + updated: Math.max(current?.updated ?? 0, row.updated, row.design?.updated ?? 0), + designs: [ + ...(current?.designs ?? []), + ...(row.design + ? [ + { + id: row.design.id, + name: row.design.name, + revision: row.design.revision, + approvedRevision: row.design.approvedRevision, + ended: row.design.ended, + }, + ] + : []), + ], + }) + return result + }, new Map()) + return HttpServerResponse.jsonUnsafe([...conversations.values()].sort((a, b) => b.updated - a.updated)) + } + if (parts[1] !== "session") return HttpServerResponse.empty({ status: 404 }) const sessionID = yield* Schema.decodeUnknownEffect(SessionID)(parts[2]) if (request.method !== "GET") { const origin = request.headers.origin @@ -37,7 +90,6 @@ export function serveDesignEffect(request: HttpServerRequest.HttpServerRequest) ) return HttpServerResponse.empty({ status: 403 }) } - const db = yield* Database.Service const row = yield* db.db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get().pipe(Effect.orDie) if (!row) return HttpServerResponse.empty({ status: 404 }) const instances = yield* InstanceStore.Service diff --git a/packages/redcode/test/server/design-tui.test.ts b/packages/redcode/test/server/design-tui.test.ts index a505eb4d9238..7302c219813a 100644 --- a/packages/redcode/test/server/design-tui.test.ts +++ b/packages/redcode/test/server/design-tui.test.ts @@ -145,3 +145,59 @@ test("TUI session creates, reviews and approves the new Design artifacts in the await model.stop(true) } }, 60000) + +test("Design picker groups prototypes by conversation and excludes other workspaces and deleted sessions", async () => { + await using first = await tmpdir({ git: true }) + await using second = await tmpdir({ git: true }) + const server = HttpRouter.toWebHandler(HttpApiApp.createRoutes(), { disableLogger: true }) + const request = (directory: string, route: string, method = "GET", body?: unknown) => + server.handler( + new Request(`http://localhost${route}`, { + method, + headers: { "content-type": "application/json", "x-opencode-directory": directory }, + body: body === undefined ? undefined : JSON.stringify(body), + }), + HttpApiApp.context, + ) + try { + const session = await (await request(first.path, "/session", "POST", { agent: "design" })).json() + await request(first.path, "/session", "POST", {}) + const pending = await (await request(first.path, "/session", "POST", { agent: "design" })).json() + for (const name of ["Dark mode", "Mobile layout"]) { + const response = await request(first.path, `/design/session/${session.id}`, "POST", { + name, + engine: "html", + journey: "new", + kind: "screen", + }) + expect(response.status).toBe(200) + } + expect((await request(first.path, "/design/list")).status).toBe(400) + const list = `/design/list?directory=${encodeURIComponent(first.path)}` + const response = await request(first.path, list) + expect(response.status).toBe(200) + const conversations = await response.json() + expect(conversations).toHaveLength(2) + expect(conversations).toContainEqual({ + sessionID: pending.id, + title: pending.title, + updated: expect.any(Number), + designs: [], + }) + const conversation = conversations.find((item: { sessionID: string }) => item.sessionID === session.id) + expect(conversation.designs.map((design: { name: string }) => design.name).sort()).toEqual([ + "Dark mode", + "Mobile layout", + ]) + expect(conversation.designs[0]).not.toHaveProperty("root") + expect( + await (await request(second.path, `/design/list?directory=${encodeURIComponent(second.path)}`)).json(), + ).toEqual([]) + expect((await request(first.path, `/session/${session.id}`, "DELETE")).status).toBe(200) + expect(await (await request(first.path, list)).json()).toMatchObject([{ sessionID: pending.id, designs: [] }]) + expect((await request(first.path, `/session/${pending.id}`, "DELETE")).status).toBe(200) + expect(await (await request(first.path, list)).json()).toEqual([]) + } finally { + await server.dispose() + } +}, 30000) diff --git a/packages/schema/src/design.ts b/packages/schema/src/design.ts index b6055d309874..68be932057b8 100644 --- a/packages/schema/src/design.ts +++ b/packages/schema/src/design.ts @@ -101,6 +101,22 @@ export const Info = Schema.Struct({ }).annotate({ identifier: "Design.Info" }) export interface Info extends Schema.Schema.Type {} +export const Conversation = Schema.Struct({ + sessionID: Session.ID, + title: Schema.String, + updated: Schema.Number, + designs: Schema.Array( + Schema.Struct({ + id: ID, + name: Schema.String, + revision: Schema.NullOr(Schema.String), + approvedRevision: Schema.NullOr(Schema.String), + ended: Schema.Boolean, + }), + ), +}) +export interface Conversation extends Schema.Schema.Type {} + export const Revision = Schema.Struct({ id: Schema.String, designID: ID, diff --git a/packages/tui/package.json b/packages/tui/package.json index 3bd9f98476e7..af875892050b 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -51,6 +51,7 @@ "dependencies": { "@reddb-io/redcode-core": "workspace:*", "@reddb-io/redcode-plugin": "workspace:*", + "@reddb-io/redcode-schema": "workspace:*", "@reddb-io/redcode-sdk": "workspace:*", "@reddb-io/redcode-ui": "workspace:*", "@opentui/core": "catalog:", diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index d7b6ff0a820c..e4a2e830a60a 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -49,6 +49,7 @@ import { DialogThemeList } from "./component/dialog-theme-list" import { DialogHelp } from "./ui/dialog-help" import { DialogAgent } from "./component/dialog-agent" import { DialogSessionList } from "./component/dialog-session-list" +import { DialogDesignList } from "./component/dialog-design-list" import { DialogWorkspaceList } from "./component/dialog-workspace-list" import { DialogConsoleOrg } from "./component/dialog-console-org" import { ThemeProvider, useTheme } from "./context/theme" @@ -694,6 +695,15 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi dialog.clear() }, }, + { + name: "design.open", + title: "Resume Design conversation", + category: "Session", + slashName: "design-open", + run: () => { + dialog.replace(() => ) + }, + }, { name: "agent.list", title: "Switch agent", diff --git a/packages/tui/src/component/dialog-design-list.tsx b/packages/tui/src/component/dialog-design-list.tsx new file mode 100644 index 000000000000..d119c57494cd --- /dev/null +++ b/packages/tui/src/component/dialog-design-list.tsx @@ -0,0 +1,92 @@ +import { createMemo, createResource, createSignal, onCleanup } from "solid-js" +import { Schema } from "effect" +import { Design } from "@reddb-io/redcode-schema/design" +import { useDialog } from "../ui/dialog" +import { DialogSelect } from "../ui/dialog-select" +import { useSDK } from "../context/sdk" +import { useSync } from "../context/sync" +import { useRoute } from "../context/route" +import { useTheme } from "../context/theme" +import { errorMessage } from "../util/error" + +export function DialogDesignList() { + const dialog = useDialog() + const sdk = useSDK() + const sync = useSync() + const route = useRoute() + const theme = useTheme() + const [error, setError] = createSignal() + const [search, setSearch] = createSignal("") + const abort = new AbortController() + onCleanup(() => abort.abort()) + dialog.setSize("large") + + const [conversations] = createResource(async () => { + const url = new URL("/design/list", sdk.url) + url.searchParams.set("directory", sync.path.directory) + return sdk + .fetch(url, { headers: sdk.headers, signal: abort.signal }) + .then(async (response) => { + if (!response.ok) throw new Error(`Could not load Design conversations (${response.status})`) + return Schema.decodeUnknownSync(Schema.Array(Design.Conversation))(await response.json()) + }) + .catch((error) => { + if (!abort.signal.aborted) setError(error) + return [] + }) + }) + + const options = createMemo(() => + (conversations() ?? []) + .filter((conversation) => + [conversation.title, ...conversation.designs.map((design) => design.name)].some((text) => + text.toLowerCase().includes(search().trim().toLowerCase()), + ), + ) + .map((conversation) => ({ + title: conversation.designs.map((design) => design.name).join(" · ") || conversation.title, + description: conversation.designs.length ? conversation.title : undefined, + value: conversation.sessionID, + footer: !conversation.designs.length + ? "Not started" + : conversation.designs.every((design) => design.ended) + ? "Closed" + : conversation.designs.every((design) => design.revision && design.approvedRevision === design.revision) + ? "Approved" + : conversation.designs.some((design) => design.revision) + ? "In review" + : "Draft", + })), + ) + + return ( + + + {conversations.loading + ? "Loading Design conversations…" + : error() + ? errorMessage(error()) + : !conversations()?.length + ? "No designs in this workspace. Use /design and describe what you want to explore." + : "No matching designs."} + + + } + footer={ + Resume the conversation. Use /design-review there to open its preview. + } + onSelect={(option) => { + route.navigate({ type: "session", sessionID: option.value }) + dialog.clear() + }} + /> + ) +} diff --git a/packages/tui/test/design-open.test.tsx b/packages/tui/test/design-open.test.tsx new file mode 100644 index 000000000000..8a195024ac68 --- /dev/null +++ b/packages/tui/test/design-open.test.tsx @@ -0,0 +1,118 @@ +import { expect, mock, test } from "bun:test" +import type { TuiPluginApi } from "@reddb-io/redcode-plugin/tui" +import { InputRenderable } from "@opentui/core" +import { wait } from "./cli/cmd/tui/sync-fixture" +import { createTestRenderer } from "@opentui/core/testing" +import { Effect } from "effect" +import { AppNodeBuilder } from "@reddb-io/redcode-core/effect/app-node-builder" +import { Global } from "@reddb-io/redcode-core/global" +import { createTuiResolvedConfig } from "./fixture/tui-runtime" +import { createEventSource, createFetch, directory, json } from "./fixture/tui-sdk" + +test("design-open searches prototypes and resumes their existing conversation", async () => { + const setup = await createTestRenderer({ width: 100, height: 30, useThread: false }) + const core = await import("@opentui/core") + mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer })) + const ready = Promise.withResolvers() + const sessions = [ + { + id: "ses_original", + title: "Original session", + slug: "original", + projectID: "proj_test", + directory, + version: "0.0.0-test", + time: { created: 0, updated: 0 }, + }, + ] + sessions.push({ ...sessions[0]!, id: "ses_design", title: "Admin redesign" }) + let response: "designs" | "empty" | "error" = "designs" + const requests: string[] = [] + const calls = createFetch((url) => { + requests.push(url.pathname) + if (url.pathname === "/design/list") { + expect(url.searchParams.get("directory")).toBe(directory) + if (response === "empty") return json([]) + if (response === "error") return json({}, { status: 503 }) + return json([ + { sessionID: "ses_original", title: "Unfinished exploration", updated: 0, designs: [] }, + { + sessionID: "ses_design", + title: "Admin redesign", + updated: 1, + designs: [{ id: "design_fixture", name: "Dark mode", revision: null, approvedRevision: null, ended: false }], + }, + ]) + } + if (url.pathname === "/session") return json(sessions) + const session = sessions.find((item) => url.pathname === `/session/${item.id}`) + if (session) return json(session) + if (/^\/session\/[^/]+\/(message|todo|diff)$/.test(url.pathname)) return json([]) + }) + const { run } = await import("../src/app") + const task = Effect.runPromise( + run({ + url: "http://test", + directory, + config: createTuiResolvedConfig({ plugin_enabled: {} }), + fetch: calls.fetch, + events: createEventSource().source, + args: { sessionID: "ses_original" }, + pluginHost: { + async start(input) { + ready.resolve(input.api) + }, + async dispose() {}, + }, + }).pipe(Effect.provide(AppNodeBuilder.build(Global.node))), + ) + async function frame(text: string) { + for (let attempt = 0; attempt < 200; attempt++) { + await setup.renderOnce() + if (setup.captureCharFrame().includes(text)) return + await Bun.sleep(10) + } + throw new Error(`Expected frame to contain ${text}: ${setup.captureCharFrame()}`) + } + try { + const api = await ready.promise + await setup.renderOnce() + expect(api.keymap.getCommands().find((command) => command.name === "design.open")).toMatchObject({ + slashName: "design-open", + }) + api.keymap.dispatchCommand("design.open") + await frame("Dark mode") + expect(setup.captureCharFrame()).toContain("Resume Design") + expect(setup.captureCharFrame()).toContain("Draft") + expect(setup.captureCharFrame()).toContain("Not started") + const input = setup.renderer.currentFocusedEditor + if (!(input instanceof InputRenderable)) throw new Error("Design search not focused") + input.value = "missing design" + await frame("No matching designs") + input.value = "admin" + await frame("Dark mode") + setup.mockInput.pressEnter() + await wait(() => { + const route = api.route.current + return "params" in route && route.params?.sessionID === "ses_design" + }) + await setup.renderOnce() + expect(api.route.current).toMatchObject({ name: "session", params: { sessionID: "ses_design" } }) + expect(requests).not.toContain("/session/ses_design/prompt_async") + expect(sessions).toHaveLength(2) + response = "empty" + api.keymap.dispatchCommand("design.open") + await frame("No designs in this workspace") + setup.mockInput.pressKey("escape") + await setup.renderOnce() + response = "error" + api.keymap.dispatchCommand("design.open") + await frame("Could not load Design conversations (503)") + } finally { + const api = await ready.promise + api.keymap.dispatchCommand("app.exit") + await task + if (!setup.renderer.isDestroyed) setup.renderer.destroy() + mock.restore() + } +}, 30000) From 91f029c003a87b0c79d5c0e3c3cf42fd8c18a724 Mon Sep 17 00:00:00 2001 From: Filipe Forattini Date: Tue, 8 Sep 2026 18:17:07 -0300 Subject: [PATCH 3/3] docs(design): explain the complete prototype workflow --- .changeset/design-open-dialog.md | 2 + README.md | 173 ++++++++++++++++++++++--------- 2 files changed, 126 insertions(+), 49 deletions(-) diff --git a/.changeset/design-open-dialog.md b/.changeset/design-open-dialog.md index 8eea7048a918..5c230a539ca1 100644 --- a/.changeset/design-open-dialog.md +++ b/.changeset/design-open-dialog.md @@ -3,3 +3,5 @@ --- Add /design-open to search existing Design conversations in the current workspace and resume their history from a session picker. + +Document the complete Design-to-review-to-implementation workflow, command availability, and the differences between the regular TUI and optional Design terminal. diff --git a/README.md b/README.md index 5dd5c822da7b..0609b48078f6 100644 --- a/README.md +++ b/README.md @@ -246,15 +246,10 @@ HTTP endpoint. Set `REDCODE_RPC_URL` to the printed URL. It reuses ## Modes -Build, Plan and Design are the three primary modes in the full-screen TUI. Press `Tab` -to cycle between them: Build is red, Plan is gold and Design is cyan. `/design` selects -Design in the current conversation; `/design-review` reopens its browser review. Prototype -changes, generated assets, browser feedback and approved handoffs remain in that same -conversation. Design edits only its prototype work directory. - -The web app and the optional `redcode design` terminal also use the shared Design -storage and rendering services. Existing TUI sessions keep their history and execution -runtime; using Design does not require moving to another terminal. +Build, Plan and Design are the three primary modes in the regular `redcode` TUI. +`Tab` cycles forward and `Shift+Tab` cycles backward: Build is red, Plan is gold and Design +is cyan. Switching mode changes how the agent handles your next message; it keeps the current +conversation. For a complete UI design walkthrough, see [Design Mode](#design-mode). Build mode @@ -276,26 +271,93 @@ reuse application components and design-system evidence through authorized reads ## Design Mode -Design combines the full-screen TUI conversation with a browser review surface. It supports HTML, -React and Solid prototypes, versioned assets, editable SVG-to-GIF exports, and recorded approval. - -### Start - -```sh -redcode -# Press Tab to select Design, or use /design. -# Use /design-review to reopen the current conversation’s browser review. +Use Design to **see and try a proposed interface before implementing it in your app**. +The terminal conversation, prototype and browser review belong to the same work: you ask in +chat, inspect the proposal in the browser, and send feedback back to that chat. During Design, +the agent edits the prototype work directory; product implementation happens in Build. + +**Start with the regular `redcode` TUI.** You do not need to launch `redcode design` or move to +the web app to use this workflow. + +```mermaid +flowchart LR + Ask[Describe the interface in Design] --> Prototype[Agent creates a prototype] + Prototype --> Review[Try it in the browser] + Review --> Feedback[Send feedback to the same conversation] + Feedback --> Prototype + Review --> Approve[Approve a published revision] + Approve --> Plan[Review the implementation plan] + Plan --> Authorize[Authorize implementation] + Authorize --> Build[Build changes and verifies the app] ``` -Use `/design-review` inside the terminal to open the browser. Choose a starting point, the target -application, an engine and an objective. The agent publishes revisions with `design_preview`. -The web app opens the same review implementation in its **Design** tab. - -React and Solid prototypes resolve their framework from the target application's installed -dependencies. Project Vite configurations and arbitrary build plugins are not executed; supply -local fixtures for routing, data providers or other application services. Prototype frames are -sandboxed, and their assets must be local. First use of browser rendering or component building -may need network access to prepare its runtime dependencies and Chromium. +### Walkthrough: explore dark mode for app-admin + +1. **Open your project.** Run `redcode` from the repository directory. Use the current + conversation, or `/new` if you want a separate conversation for this design. +2. **Select Design.** Type `/design`, or cycle with `Tab` / `Shift+Tab` until the prompt shows + Design in cyan. This selects the agent; it does not create a prototype by itself. +3. **Describe the outcome in chat.** For example: + + > Explore dark mode for app-admin. Reuse its components and design tokens. Show the dashboard + > and settings screen, including empty and error states, so I can try them before implementation. + + The agent identifies the target application, asks for missing information, and creates a + design document and prototype. You do not need to choose an engine or write tool arguments + to start this conversation. + +4. **Try the first preview.** When the agent publishes a revision, the browser review opens and + its URL appears in the tool output. Click through the prototype and try different widths. + Until a revision has been published, there may be no preview to display; opening the browser + alone does not build one. +5. **Send changes from the browser or chat.** For example, annotate the background with + “This is too dark; keep more contrast between cards and the page” and click **Send feedback**. + Unsent notes remain drafts in the browser. Submitted feedback names the revision and returns + to the same terminal conversation. The agent adjusts the prototype and publishes another + revision. Repeat until you are satisfied. Feedback sent during an active turn is handled at + a safe turn boundary. +6. **Approve the proposal.** Click **Approve this revision** in the browser, or tell the agent + “I am happy with this version; finish the design and prepare the implementation plan.” The + agent asks for approval before recording the handoff. Approval freezes the chosen revision + as the implementation reference and normally moves the conversation to Plan. +7. **Review the plan, then authorize Build.** The plan explains how to apply the approved design + to the actual app. Approve that implementation before Build changes product files. A working + prototype and an approved design are not, by themselves, an implemented feature. After the + implementation, ask the agent to verify the interactions and compare the app with the approved + revision. + +### Commands in the regular TUI + +These are the current behaviors. `/design` selects a mode; it does not currently combine the +conversation picker and browser preview into one command. + +| What you want to do | Command or control | What happens | +| ---------------------------------------------- | -------------------------------- | --------------------------------------------------------------------------------------------------------------------- | +| Explore a UI in the current conversation | `/design` or `Tab` / `Shift+Tab` | Selects Design; send a message describing the work | +| Start a separate conversation for a design | `/new`, then `/design` | Creates a fresh conversation, then selects Design | +| Find an existing Design conversation | `/design-open` | Opens a searchable modal with prototype names, conversation titles and state; Enter resumes the selected conversation | +| Find any existing conversation | `/sessions` or `/resume` | Opens the regular session picker | +| Open the current conversation's browser review | `/design-review` | Opens its review page; does not change mode or create a prototype | + +**Version availability:** `/design-open` is introduced in [PR #178](https://github.com/reddb-io/redcode/pull/178) +and is not in v0.23.1. Until you install a release containing it, use `/sessions` or `/resume` to +select the conversation, then `/design-review` to open its preview. + +Names such as `design_document`, `design_preview` and `design_exit` in the transcript are +**tools called by the agent**, not slash commands you need to run. They create the document, +publish a revision and request approval, respectively. + +### Return to a design later + +Open `/design-open`, search by prototype or conversation name, and select the conversation. +The picker only lists the current workspace. Several prototypes in one conversation appear +together, and Design conversations without a document yet are included as **Not started**. + +Resuming preserves the conversation's history and current mode. It does not send a message, +start model execution, open a browser or reopen an ended review automatically. Use +`/design-review` to see its preview. If you want to explore more changes after a handoff to Plan +or Build, select `/design` again and describe the changes. If the conversation has several +prototypes, name the one you want to continue. Explicitly ask to reopen a review that you ended. ### Review and assets @@ -316,39 +378,52 @@ may need network access to prepare its runtime dependencies and Chromium. CSS/SMIL animation locally. Export jobs expose progress, cancellation and downloads. Standalone HTML export embeds local resources; unsupported external references must be localized first. -### Approve and resume +React and Solid prototypes resolve their framework from the target application's installed +dependencies. Project Vite configurations and arbitrary build plugins are not executed; supply +local fixtures for routing, data providers or other application services. Prototype frames are +sandboxed, and their assets must be local. First use of browser rendering or component building +may need network access to prepare its runtime dependencies and Chromium. -Approve a published revision to freeze its source, asset metadata and feedback into an approval -package. The handoff updates only the Design-owned section of `plan.md`, preserving manual work, -and selects Plan. Build begins through an authorized Plan handoff. +### Approval and implementation -`/status` shows the session ID, mode, activity, Goal and pending requests. `/stop` or Ctrl+C -interrupts execution while keeping the terminal open. `/quit` exits. Reopen with -`redcode design --session ses_existing_v2`; `/resume` explicitly continues provider work. -Adoption and reconnection do not restart paid work automatically. +Approval freezes the published source, asset metadata and feedback into an approval package. +The handoff updates only the Design-owned section of `plan.md`, preserving manual work. A Goal +configured to stop after Design records approval and stays in Design; otherwise the normal +handoff selects Plan. Build begins through an authorized Plan handoff. -The terminal prints completed text and tool output as durable events arrive. Provisional token -streaming and migration of the full-screen TUI renderer remain future work. +### Other interfaces (optional) + +The web app displays the review in its **Design** tab. The optional `redcode design` command +starts a separate SessionV2 terminal with its own command set. It is not required for the +regular TUI workflow above. + +**Only inside the `redcode design` terminal:** `/review` opens the browser, `/status` shows the +session and pending requests, `/stop` or Ctrl+C interrupts execution, and `/quit` exits. +Reopen that terminal with `redcode design --session ses_existing_v2`. There, `/resume` +explicitly continues model execution; in the regular TUI, `/resume` opens the session picker. +Opening an existing session or reconnecting does not restart execution automatically. + +The optional terminal prints completed text and tool output as durable events arrive. +Provisional token streaming and migration of the full-screen TUI renderer remain future work. ### Files and compatibility -| Location | Contents | -| --- | --- | -| `.red/code/design//work/` | Editable prototype source and local assets | +| Location | Contents | +| ---------------------------------------------------- | -------------------------------------------------------------------- | +| `.red/code/design//work/` | Editable prototype source and local assets | | Design storage in SQLite and content-addressed blobs | Revision history, feedback receipts, asset provenance and job status | -| `approvals/.json` in the design storage | Frozen approval package | -| The plan's marked Design section | Reviewed scope and evidence for the implementation handoff | -| `DESIGN.md` or `.red/DESIGN.md` | Project design guidance used as source evidence | - -Design is available in the existing full-screen TUI again. New documents use the shared -revision and asset store. When continuing a pre-0.22 prototype, `design_preview` still -accepts its original `path`: it imports the source into a new document, keeps private -review files out of the published snapshot, and preserves the original directory. +| `approvals/.json` in the design storage | Frozen approval package | +| The plan's marked Design section | Reviewed scope and evidence for the implementation handoff | +| `DESIGN.md` or `.red/DESIGN.md` | Project design guidance used as source evidence | + +New documents use the shared revision and asset store. When continuing a pre-0.22 prototype, +`design_preview` still accepts its original `path`: it imports the source into a new document, +keeps private review files out of the published snapshot, and preserves the original directory. TUI feedback and approvals return through `/design/session/:sessionID`; web-app sessions use `/api/session/:sessionID/design`. See [Design Studio](specs/design/studio.md) for storage, permissions, exports and MCP configuration, -and [Design terminal](specs/design/terminal.md) for connection and interaction commands. +and [Design terminal](specs/design/terminal.md) for the optional terminal's connection and interaction commands. ## Goal