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/.changeset/design-open-dialog.md b/.changeset/design-open-dialog.md
new file mode 100644
index 000000000000..5c230a539ca1
--- /dev/null
+++ b/.changeset/design-open-dialog.md
@@ -0,0 +1,7 @@
+---
+"@reddb-io/redcode": patch
+---
+
+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).
@@ -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
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/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/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/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
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)