diff --git a/.claude/skills/visual-plan/SKILL.md b/.claude/skills/visual-plan/SKILL.md new file mode 100644 index 00000000000..8863d199d9a --- /dev/null +++ b/.claude/skills/visual-plan/SKILL.md @@ -0,0 +1,51 @@ +--- +name: visual-plan +description: Use when the user asks you to plan a feature, change, or task and wants to review it visually — renders an interactive, structured plan in the GITS visual plan side panel instead of a wall of chat text. Trigger on "plan", "visual plan", "render a plan", or when entering plan mode in GITS. +--- + +# Visual Plan + +GITS hosts a native visual-plan MCP server (`gits-visual-plan`). Instead of dumping a +plan into chat, author a **structured block document** that renders live in the GITS +visual plan side panel, where the user can read, edit, and comment on it. The document +is the source of truth, not the chat. + +## Tools + +All tools are exposed by the `gits-visual-plan` MCP server (call them as +`mcp__gits-visual-plan__`): + +- `get-plan-blocks` — the authoritative block catalog. **Always call this first.** +- `create-visual-plan` — create/replace the plan: `{ title?, brief?, content }`. +- `update-visual-plan` — apply `{ contentPatches }` (targeted edits). +- `get-visual-plan` — read the current plan JSON. +- `get-plan-feedback` — read the user's anchored comments. **Call before editing.** +- `export-visual-plan` — get the plan + open comments as one markdown document. + +## Workflow + +1. **Research first.** Inspect the real files, symbols, and schema you'll touch. Name + them concretely in the plan — never plan against imagined code. +2. **Call `get-plan-blocks`** to load the current block catalog. +3. **Author `content`** as `{ version: 1, title, brief, blocks: [...] }` and call + `create-visual-plan`. Lead with the outcome (a `rich-text` block), then break the + work into the right blocks: + - `rich-text` for prose/rationale (GFM markdown). + - `checklist` for the step-by-step task breakdown. + - `annotated-code` / `implementation-map` / `file-tree` for the files you'll change. + - `api-endpoint` / `data-model` for contracts and schema. + - `callout` with `tone: "decision"` for hard-to-reverse choices; `tone: "risk"` for risks. + - `question-form` (single block, at the end) for open questions needing the user's call. +3. **Tell the user** the plan is rendering in the GITS visual plan panel and ask them to + review, edit, and comment there. **Do not start implementing** until they approve. +4. **Before revising,** call `get-plan-feedback`. Act on comments whose + `resolutionTarget` is `"agent"`; apply targeted `update-visual-plan` patches rather + than recreating the whole plan. +5. When the user approves, implement from the plan (and their edits/comments). + +## Discipline + +- Planning is read-only — make no source edits until the user approves the plan. +- Decide the load-bearing bets up front (wire formats, public ids, data-model shape, + auth boundaries) and record them as `decision` callouts. +- Don't ship a single-step plan; if the work is trivial, just do it. diff --git a/apps/server/src/crit/critHttp.test.ts b/apps/server/src/crit/critHttp.test.ts index 2c0ed1a944e..2ae5806ee1d 100644 --- a/apps/server/src/crit/critHttp.test.ts +++ b/apps/server/src/crit/critHttp.test.ts @@ -70,6 +70,7 @@ const make_thread = (overrides: Partial = {}): Orchestratio deletedAt: null, messages: [], proposedPlans: [], + visualPlans: [], activities: [], checkpoints: [], session: null, diff --git a/apps/server/src/gits/mcp/VisualPlanMcpRegistry.ts b/apps/server/src/gits/mcp/VisualPlanMcpRegistry.ts new file mode 100644 index 00000000000..7faf087637f --- /dev/null +++ b/apps/server/src/gits/mcp/VisualPlanMcpRegistry.ts @@ -0,0 +1,49 @@ +/** + * Process-global registry backing the native visual-plan MCP endpoint. + * + * Maps per-session bearer tokens to their thread, and caches the latest plan + * state per thread so the `update-visual-plan` tool can apply patches against + * the current content. Implemented as a module-level singleton (not an Effect + * service) so the MCP route and the provider adapters share one instance + * without threading a layer requirement through the entire provider stack. + */ +import { randomUUID } from "node:crypto"; +import type { PlanComment, PlanContent, ThreadId } from "@t3tools/contracts"; + +/** HTTP path of the native visual-plan MCP endpoint. */ +export const VISUAL_PLAN_MCP_PATH = "/api/gits/visual-plan/mcp"; + +export interface VisualPlanState { + readonly planId: string; + readonly content: PlanContent; + readonly comments: ReadonlyArray; + readonly createdAt: string; +} + +const tokensToThread = new Map(); +const threadToToken = new Map(); +const threadState = new Map(); + +/** Mint (or reuse) a bearer token for a thread's MCP session. */ +export function issueVisualPlanToken(threadId: ThreadId): string { + const existing = threadToToken.get(threadId); + if (existing) { + return existing; + } + const token = `vpmcp_${randomUUID()}`; + tokensToThread.set(token, threadId); + threadToToken.set(threadId, token); + return token; +} + +export function resolveVisualPlanThread(token: string): ThreadId | undefined { + return tokensToThread.get(token); +} + +export function getVisualPlanState(threadId: ThreadId): VisualPlanState | undefined { + return threadState.get(threadId); +} + +export function setVisualPlanState(threadId: ThreadId, state: VisualPlanState): void { + threadState.set(threadId, state); +} diff --git a/apps/server/src/gits/mcp/http.ts b/apps/server/src/gits/mcp/http.ts new file mode 100644 index 00000000000..a68149f1e0c --- /dev/null +++ b/apps/server/src/gits/mcp/http.ts @@ -0,0 +1,270 @@ +/** + * Native visual-plan MCP endpoint. + * + * A minimal Streamable-HTTP MCP server (JSON-RPC 2.0 over POST) that any agent + * provider (Claude / Codex / Cursor) connects to with a per-session bearer + * token. Tool calls are resolved to the owning thread via the token and + * dispatched into the orchestration engine as `thread.visual-plan.upsert` + * commands, so the plan renders live in the GITS visual plan panel. + */ +import { + CommandId, + type OrchestrationVisualPlan, + PlanComment, + PlanContent, + PlanContentPatch, + type ThreadId, +} from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; + +import { browserApiCorsHeaders } from "../../httpCors.ts"; +import { OrchestrationEngineService } from "../../orchestration/Services/OrchestrationEngine.ts"; +import { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; +import { + getVisualPlanState, + resolveVisualPlanThread, + setVisualPlanState, + VISUAL_PLAN_MCP_PATH, + type VisualPlanState, +} from "./VisualPlanMcpRegistry.ts"; +import { + applyPlanPatches, + buildBlockCatalog, + exportPlanToMarkdown, + VISUAL_PLAN_TOOLS, +} from "./visualPlanModel.ts"; + +const PROTOCOL_VERSION = "2025-06-18"; + +const decodeContent = Schema.decodeUnknownEffect(PlanContent); +const decodePatches = Schema.decodeUnknownEffect(Schema.Array(PlanContentPatch)); +const encodeJson = Schema.encodeSync(Schema.UnknownFromJsonString); +const jsonString = (value: unknown): string => encodeJson(value); + +interface JsonRpcMessage { + readonly jsonrpc?: string; + readonly id?: string | number | null; + readonly method?: string; + readonly params?: Record; +} + +function jsonRpcResult(id: string | number | null, result: unknown) { + return HttpServerResponse.jsonUnsafe( + { jsonrpc: "2.0", id, result }, + { status: 200, headers: browserApiCorsHeaders }, + ); +} + +function jsonRpcError(id: string | number | null, code: number, message: string) { + return HttpServerResponse.jsonUnsafe( + { jsonrpc: "2.0", id, error: { code, message } }, + { status: 200, headers: browserApiCorsHeaders }, + ); +} + +function toolText(text: string, isError = false) { + return { content: [{ type: "text", text }], ...(isError ? { isError: true } : {}) }; +} + +const bearerFromRequest = (request: HttpServerRequest.HttpServerRequest): Option.Option => { + const header = request.headers["authorization"] ?? request.headers["Authorization"]; + if (!header) { + return Option.none(); + } + const match = /^Bearer\s+(.+)$/i.exec(header); + const value = match?.[1]; + return value ? Option.some(value.trim()) : Option.none(); +}; + +const nowIso = Effect.map(DateTime.now, DateTime.formatIso); + +/** Load the current plan state from cache, falling back to the persisted read model. */ +const loadState = (threadId: ThreadId) => + Effect.gen(function* () { + const cached = getVisualPlanState(threadId); + if (cached) { + return Option.some(cached); + } + const snapshot = yield* ProjectionSnapshotQuery; + const detail = yield* snapshot + .getThreadDetailById(threadId) + .pipe(Effect.orElseSucceed(() => Option.none())); + if (Option.isNone(detail)) { + return Option.none(); + } + const plans = detail.value.visualPlans; + const latest = plans.length > 0 ? plans[plans.length - 1] : undefined; + if (!latest) { + return Option.none(); + } + return Option.some({ + planId: latest.id, + content: latest.content, + comments: latest.comments, + createdAt: latest.createdAt, + }); + }); + +const upsertVisualPlan = (threadId: ThreadId, next: VisualPlanState) => + Effect.gen(function* () { + const engine = yield* OrchestrationEngineService; + const crypto = yield* Crypto.Crypto; + const at = yield* nowIso; + const uuid = yield* crypto.randomUUIDv4; + const visualPlan: OrchestrationVisualPlan = { + id: next.planId, + turnId: null, + content: next.content, + comments: next.comments, + createdAt: next.createdAt, + updatedAt: at, + }; + yield* engine + .dispatch({ + type: "thread.visual-plan.upsert", + commandId: CommandId.make(`visual-plan:${threadId}:${uuid}`), + threadId, + visualPlan, + createdAt: at, + }) + .pipe( + Effect.catch((cause: unknown) => Effect.logError("visual-plan dispatch failed", cause)), + ); + setVisualPlanState(threadId, next); + }); + +const callTool = (threadId: ThreadId, name: string, args: Record) => + Effect.gen(function* () { + switch (name) { + case "get-plan-blocks": + return toolText(jsonString(buildBlockCatalog())); + + case "get-visual-plan": { + const state = yield* loadState(threadId); + return Option.isSome(state) + ? toolText(jsonString(state.value.content)) + : toolText("No visual plan exists yet for this session."); + } + + case "get-plan-feedback": { + const state = yield* loadState(threadId); + const comments = Option.isSome(state) ? state.value.comments : []; + return toolText(jsonString(comments)); + } + + case "export-visual-plan": { + const state = yield* loadState(threadId); + return Option.isSome(state) + ? toolText(exportPlanToMarkdown(state.value.content, state.value.comments)) + : toolText("No visual plan exists yet for this session."); + } + + case "create-visual-plan": { + const decoded = yield* Effect.option(decodeContent(args.content)); + if (Option.isNone(decoded)) { + return toolText("Invalid plan content. Call get-plan-blocks and retry.", true); + } + const existing = yield* loadState(threadId); + const at = yield* nowIso; + const next: VisualPlanState = { + planId: Option.isSome(existing) ? existing.value.planId : `vp_${threadId}`, + content: decoded.value, + comments: Option.isSome(existing) ? existing.value.comments : [], + createdAt: Option.isSome(existing) ? existing.value.createdAt : at, + }; + yield* upsertVisualPlan(threadId, next); + return toolText( + "Visual plan created. It is now rendering in the GITS visual plan side panel.", + ); + } + + case "update-visual-plan": { + const decoded = yield* Effect.option(decodePatches(args.contentPatches)); + if (Option.isNone(decoded)) { + return toolText("Invalid contentPatches. Call get-plan-blocks and retry.", true); + } + const existing = yield* loadState(threadId); + if (Option.isNone(existing)) { + return toolText("No visual plan to update. Call create-visual-plan first.", true); + } + const nextContent = applyPlanPatches(existing.value.content, decoded.value); + yield* upsertVisualPlan(threadId, { ...existing.value, content: nextContent }); + return toolText("Visual plan updated."); + } + + default: + return toolText(`Unknown tool: ${name}`, true); + } + }); + +export const visualPlanMcpRouteLayer = HttpRouter.add( + "POST", + VISUAL_PLAN_MCP_PATH, + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + + const token = bearerFromRequest(request); + if (Option.isNone(token)) { + return HttpServerResponse.jsonUnsafe( + { jsonrpc: "2.0", id: null, error: { code: -32001, message: "Missing bearer token" } }, + { status: 401, headers: browserApiCorsHeaders }, + ); + } + const threadId = resolveVisualPlanThread(token.value); + if (!threadId) { + return HttpServerResponse.jsonUnsafe( + { jsonrpc: "2.0", id: null, error: { code: -32001, message: "Invalid session token" } }, + { status: 401, headers: browserApiCorsHeaders }, + ); + } + + const body = yield* Effect.option(HttpServerRequest.schemaBodyJson(Schema.Unknown)); + const message = (Option.getOrElse(body, () => ({})) ?? {}) as JsonRpcMessage; + + const method = message.method ?? ""; + const id = message.id ?? null; + + if (method.startsWith("notifications/")) { + return HttpServerResponse.empty({ status: 202 }); + } + + switch (method) { + case "initialize": { + const requested = + (message.params?.protocolVersion as string | undefined) ?? PROTOCOL_VERSION; + return jsonRpcResult(id, { + protocolVersion: requested, + capabilities: { tools: { listChanged: false } }, + serverInfo: { name: "gits-visual-plan", version: "0.1.0" }, + }); + } + case "ping": + return jsonRpcResult(id, {}); + case "tools/list": + return jsonRpcResult(id, { tools: VISUAL_PLAN_TOOLS }); + case "tools/call": { + const name = (message.params?.name as string | undefined) ?? ""; + const args = (message.params?.arguments as Record | undefined) ?? {}; + const result = yield* callTool(threadId, name, args); + return jsonRpcResult(id, result); + } + default: + return jsonRpcError(id, -32601, `Method not found: ${method}`); + } + }).pipe( + Effect.catch((cause: unknown) => + Effect.gen(function* () { + yield* Effect.logError("visual-plan MCP route failed", cause); + return HttpServerResponse.jsonUnsafe( + { jsonrpc: "2.0", id: null, error: { code: -32603, message: "Internal error" } }, + { status: 200, headers: browserApiCorsHeaders }, + ); + }), + ), + ), +); diff --git a/apps/server/src/gits/mcp/visualPlanModel.test.ts b/apps/server/src/gits/mcp/visualPlanModel.test.ts new file mode 100644 index 00000000000..7be7a86fd4d --- /dev/null +++ b/apps/server/src/gits/mcp/visualPlanModel.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "vitest"; +import type { PlanContent } from "@t3tools/contracts"; +import { applyPlanPatches, buildBlockCatalog, exportPlanToMarkdown } from "./visualPlanModel.ts"; + +const baseContent: PlanContent = { + version: 1, + title: "Add login", + brief: "Wire up auth", + blocks: [ + { id: "b1", type: "rich-text", data: { markdown: "Outcome: users can log in." } }, + { + id: "b2", + type: "checklist", + title: "Steps", + data: { + items: [ + { id: "s1", label: "Add form", checked: true }, + { id: "s2", label: "Wire API" }, + ], + }, + }, + ], +}; + +describe("applyPlanPatches", () => { + it("set-metadata replaces title/brief", () => { + const next = applyPlanPatches(baseContent, [{ op: "set-metadata", title: "Renamed" }]); + expect(next.title).toBe("Renamed"); + expect(next.brief).toBe("Wire up auth"); + }); + + it("update-rich-text edits the markdown of the targeted block only", () => { + const next = applyPlanPatches(baseContent, [ + { op: "update-rich-text", blockId: "b1", markdown: "New outcome." }, + ]); + const block = next.blocks.find((b) => b.id === "b1"); + expect(block?.type).toBe("rich-text"); + expect(block?.type === "rich-text" && block.data.markdown).toBe("New outcome."); + expect(next.blocks[1]).toBe(baseContent.blocks[1]); + }); + + it("append-block inserts after the given block", () => { + const next = applyPlanPatches(baseContent, [ + { + op: "append-block", + afterBlockId: "b1", + block: { id: "b3", type: "rich-text", data: { markdown: "inserted" } }, + }, + ]); + expect(next.blocks.map((b) => b.id)).toEqual(["b1", "b3", "b2"]); + }); + + it("remove-block drops the block", () => { + const next = applyPlanPatches(baseContent, [{ op: "remove-block", blockId: "b2" }]); + expect(next.blocks.map((b) => b.id)).toEqual(["b1"]); + }); +}); + +describe("exportPlanToMarkdown", () => { + it("renders title, brief, and blocks", () => { + const md = exportPlanToMarkdown(baseContent, []); + expect(md).toContain("# Add login"); + expect(md).toContain("Wire up auth"); + expect(md).toContain("Outcome: users can log in."); + expect(md).toContain("- [x] Add form"); + expect(md).toContain("- [ ] Wire API"); + }); + + it("appends open reviewer comments", () => { + const md = exportPlanToMarkdown(baseContent, [ + { + id: "c1", + anchor: { textQuote: "users can log in" }, + message: "clarify SSO", + createdBy: "human", + resolutionTarget: "agent", + createdAt: "2026-06-20T00:00:00.000Z", + updatedAt: "2026-06-20T00:00:00.000Z", + }, + ]); + expect(md).toContain("## Reviewer comments"); + expect(md).toContain("clarify SSO"); + }); +}); + +describe("buildBlockCatalog", () => { + it("includes the v1 block types", () => { + const catalog = buildBlockCatalog(); + expect(catalog.blockTypes).toContain("rich-text"); + expect(catalog.blockTypes).toContain("question-form"); + }); +}); diff --git a/apps/server/src/gits/mcp/visualPlanModel.ts b/apps/server/src/gits/mcp/visualPlanModel.ts new file mode 100644 index 00000000000..6e3776efc8d --- /dev/null +++ b/apps/server/src/gits/mcp/visualPlanModel.ts @@ -0,0 +1,297 @@ +/** + * Pure helpers for the native visual-plan MCP server: applying content + * patches, exporting a plan to markdown (for sending back to the chat + * session), and describing the block registry / tool surface. + */ +import type { PlanBlock, PlanComment, PlanContent, PlanContentPatch } from "@t3tools/contracts"; +import { PLAN_BLOCK_TYPES } from "@t3tools/contracts"; + +export function applyPlanPatches( + content: PlanContent, + patches: ReadonlyArray, +): PlanContent { + let next = content; + for (const patch of patches) { + next = applyPlanPatch(next, patch); + } + return next; +} + +function applyPlanPatch(content: PlanContent, patch: PlanContentPatch): PlanContent { + switch (patch.op) { + case "set-metadata": + return { + ...content, + ...(patch.title !== undefined ? { title: patch.title } : {}), + ...(patch.brief !== undefined ? { brief: patch.brief } : {}), + }; + case "replace-blocks": + return { ...content, blocks: patch.blocks }; + case "replace-block": + return { + ...content, + blocks: content.blocks.map((block) => (block.id === patch.blockId ? patch.block : block)), + }; + case "remove-block": + return { ...content, blocks: content.blocks.filter((block) => block.id !== patch.blockId) }; + case "append-block": { + if (patch.afterBlockId) { + const index = content.blocks.findIndex((block) => block.id === patch.afterBlockId); + if (index >= 0) { + const blocks = [...content.blocks]; + blocks.splice(index + 1, 0, patch.block); + return { ...content, blocks }; + } + } + return { ...content, blocks: [...content.blocks, patch.block] }; + } + case "update-rich-text": + return { + ...content, + blocks: content.blocks.map((block) => { + if (block.id !== patch.blockId || block.type !== "rich-text") { + return block; + } + return { + ...block, + ...(patch.title !== undefined ? { title: patch.title } : {}), + data: { + ...block.data, + ...(patch.markdown !== undefined ? { markdown: patch.markdown } : {}), + }, + }; + }), + }; + case "update-block": + return { + ...content, + blocks: content.blocks.map((block) => { + if (block.id !== patch.blockId) { + return block; + } + const merged = { + ...block, + ...(patch.patch.title !== undefined ? { title: patch.patch.title } : {}), + ...(patch.patch.summary !== undefined ? { summary: patch.patch.summary } : {}), + ...(patch.patch.editable !== undefined ? { editable: patch.patch.editable } : {}), + ...(patch.patch.data !== undefined + ? { data: { ...((block as { data: unknown }).data as object), ...patch.patch.data } } + : {}), + }; + return merged as PlanBlock; + }), + }; + } +} + +/** Serialize a plan + reviewer comments to a single markdown document. */ +export function exportPlanToMarkdown( + content: PlanContent, + comments: ReadonlyArray, +): string { + const lines: string[] = []; + if (content.title) { + lines.push(`# ${content.title}`, ""); + } + if (content.brief) { + lines.push(content.brief, ""); + } + for (const block of content.blocks) { + lines.push(...blockToMarkdown(block)); + lines.push(""); + } + const open = comments.filter((comment) => !comment.resolvedAt); + if (open.length > 0) { + lines.push("## Reviewer comments", ""); + for (const comment of open) { + const target = describeAnchor(comment); + const routing = comment.resolutionTarget ? ` _(for: ${comment.resolutionTarget})_` : ""; + lines.push(`- **${target}**${routing}: ${comment.message}`); + } + lines.push(""); + } + return lines.join("\n").trim() + "\n"; +} + +function describeAnchor(comment: PlanComment): string { + const anchor = comment.anchor; + if (anchor.textQuote) { + return `on “${truncate(anchor.textQuote, 80)}”`; + } + if (anchor.blockId) { + return `on block ${anchor.blockId}`; + } + if (anchor.sectionId) { + return `on section ${anchor.sectionId}`; + } + return "general"; +} + +function truncate(value: string, max: number): string { + return value.length > max ? `${value.slice(0, max - 1)}…` : value; +} + +function blockToMarkdown(block: PlanBlock): string[] { + const heading = block.title ? [`### ${block.title}`, ""] : []; + switch (block.type) { + case "rich-text": + return [...heading, block.data.markdown]; + case "callout": + return [ + ...heading, + `> ${block.data.tone ? `**${block.data.tone.toUpperCase()}** ` : ""}${block.data.body}`, + ]; + case "checklist": + return [ + ...heading, + ...block.data.items.map( + (item) => + `- [${item.checked ? "x" : " "}] ${item.label}${item.note ? ` — ${item.note}` : ""}`, + ), + ]; + case "table": + return [ + ...heading, + `| ${block.data.columns.join(" | ")} |`, + `| ${block.data.columns.map(() => "---").join(" | ")} |`, + ...block.data.rows.map((row) => `| ${row.join(" | ")} |`), + ]; + case "code": + return [...heading, "```" + (block.data.language ?? ""), block.data.code, "```"]; + case "annotated-code": + return [ + ...heading, + block.data.filename ? `_${block.data.filename}_` : "", + "```" + (block.data.language ?? ""), + block.data.code, + "```", + ...(block.data.annotations ?? []).map( + (a) => `- lines ${a.lines}: ${a.label ? `**${a.label}** ` : ""}${a.note}`, + ), + ].filter((line) => line !== ""); + case "file-tree": + return [ + ...heading, + ...block.data.entries.map( + (e) => + `- ${e.change ? `[${e.change}] ` : ""}\`${e.path}\`${e.note ? ` — ${e.note}` : ""}`, + ), + ]; + case "implementation-map": + return [ + ...heading, + ...block.data.files.map( + (f) => `- \`${f.path}\`${f.title ? ` (${f.title})` : ""} — ${f.note}`, + ), + ]; + case "api-endpoint": + return [ + ...heading, + `\`${block.data.method} ${block.data.path}\`${block.data.summary ? ` — ${block.data.summary}` : ""}`, + ]; + case "data-model": + return [ + ...heading, + ...block.data.entities.flatMap((entity) => [ + `**${entity.name}**`, + ...entity.fields.map( + (field) => + `- ${field.name}${field.type ? `: ${field.type}` : ""}${field.pk ? " (pk)" : ""}`, + ), + ]), + ]; + case "question-form": + return [ + ...heading, + ...block.data.questions.map( + (question) => + `**${question.title}** (${question.mode})${question.options ? `: ${question.options.map((o) => o.label).join(", ")}` : ""}`, + ), + ]; + case "diagram": + case "custom-html": + return [...heading, block.data.caption ?? "_(visual block)_"]; + case "tabs": + return [ + ...heading, + ...block.data.tabs.flatMap((tab) => [ + `**${tab.label}**`, + ...tab.blocks.flatMap(blockToMarkdown), + ]), + ]; + case "columns": + return [ + ...heading, + ...block.data.columns.flatMap((column) => column.blocks.flatMap(blockToMarkdown)), + ]; + } +} + +/** The `get-plan-blocks` payload — the authoritative block catalog. */ +export function buildBlockCatalog(): { blockTypes: ReadonlyArray; notes: string } { + return { + blockTypes: PLAN_BLOCK_TYPES, + notes: + "Each block is { id, type, title?, summary?, editable?, data }. `tabs`/`columns` nest leaf blocks (no further nesting). `rich-text`.data.markdown holds GFM prose; `callout`.data.tone is info|decision|risk|warning|success; `checklist`.data.items[{id,label,checked?,note?}]; `table`.data{columns,rows}; `annotated-code`.data{filename?,language?,code,annotations?[{lines,label?,note}]}; `api-endpoint`.data{method,path,params?,responses?}; `data-model`.data{entities[{id,name,fields[{name,type?,pk?,fk?}]}],relations?}; `question-form`.data.questions[{id,title,mode:single|multi|freeform,options?}]. `diagram`/`custom-html` render inert HTML/CSS (Phase 2).", + }; +} + +/** MCP `tools/list` definitions. Content is validated server-side against the + * PlanContent schema, so input schemas stay loose and the agent is told to + * call `get-plan-blocks` first. */ +export const VISUAL_PLAN_TOOLS = [ + { + name: "get-plan-blocks", + description: + "Return the authoritative visual-plan block catalog. ALWAYS call this before authoring or editing a plan.", + inputSchema: { type: "object", properties: {}, additionalProperties: false }, + }, + { + name: "create-visual-plan", + description: + "Create (or replace) the visual plan for this session. Renders live in the GITS visual plan side panel. Provide a structured `content` document.", + inputSchema: { + type: "object", + properties: { + title: { type: "string" }, + brief: { type: "string" }, + content: { + type: "object", + description: "PlanContent: { version:number, title?, brief?, blocks: PlanBlock[] }", + }, + }, + required: ["content"], + additionalProperties: false, + }, + }, + { + name: "update-visual-plan", + description: + "Apply targeted content patches to the current visual plan. Call get-plan-feedback first to read reviewer comments.", + inputSchema: { + type: "object", + properties: { + contentPatches: { type: "array", items: { type: "object" } }, + }, + required: ["contentPatches"], + additionalProperties: false, + }, + }, + { + name: "get-visual-plan", + description: "Return the current visual plan content as JSON.", + inputSchema: { type: "object", properties: {}, additionalProperties: false }, + }, + { + name: "get-plan-feedback", + description: + "Return reviewer comments on the current visual plan (with anchors and routing). Act on comments whose resolutionTarget is 'agent'.", + inputSchema: { type: "object", properties: {}, additionalProperties: false }, + }, + { + name: "export-visual-plan", + description: + "Return the current visual plan (plus open reviewer comments) serialized as a single markdown document.", + inputSchema: { type: "object", properties: {}, additionalProperties: false }, + }, +] as const; diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index e11720d72ca..2bce74b42ef 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -149,6 +149,7 @@ describe("OrchestrationEngine", () => { deletedAt: null, messages: [], proposedPlans: [], + visualPlans: [], activities: [], checkpoints: [], session: null, @@ -161,6 +162,7 @@ describe("OrchestrationEngine", () => { ...thread, messages: [], proposedPlans: [], + visualPlans: [], activities: [], checkpoints: [], })), diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index c201e1d9f9e..c85616fdd7f 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -27,6 +27,7 @@ import { type ProjectionThreadProposedPlan, ProjectionThreadProposedPlanRepository, } from "../../persistence/Services/ProjectionThreadProposedPlans.ts"; +import { ProjectionThreadVisualPlanRepository } from "../../persistence/Services/ProjectionThreadVisualPlans.ts"; import { ProjectionThreadSessionRepository } from "../../persistence/Services/ProjectionThreadSessions.ts"; import { type ProjectionTurn, @@ -39,6 +40,7 @@ import { ProjectionStateRepositoryLive } from "../../persistence/Layers/Projecti import { ProjectionThreadActivityRepositoryLive } from "../../persistence/Layers/ProjectionThreadActivities.ts"; import { ProjectionThreadMessageRepositoryLive } from "../../persistence/Layers/ProjectionThreadMessages.ts"; import { ProjectionThreadProposedPlanRepositoryLive } from "../../persistence/Layers/ProjectionThreadProposedPlans.ts"; +import { ProjectionThreadVisualPlanRepositoryLive } from "../../persistence/Layers/ProjectionThreadVisualPlans.ts"; import { ProjectionThreadSessionRepositoryLive } from "../../persistence/Layers/ProjectionThreadSessions.ts"; import { ProjectionTurnRepositoryLive } from "../../persistence/Layers/ProjectionTurns.ts"; import { ProjectionThreadRepositoryLive } from "../../persistence/Layers/ProjectionThreads.ts"; @@ -59,6 +61,7 @@ export const ORCHESTRATION_PROJECTOR_NAMES = { threads: "projection.threads", threadMessages: "projection.thread-messages", threadProposedPlans: "projection.thread-proposed-plans", + threadVisualPlans: "projection.thread-visual-plans", threadActivities: "projection.thread-activities", threadSessions: "projection.thread-sessions", threadTurns: "projection.thread-turns", @@ -452,6 +455,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti const projectionThreadRepository = yield* ProjectionThreadRepository; const projectionThreadMessageRepository = yield* ProjectionThreadMessageRepository; const projectionThreadProposedPlanRepository = yield* ProjectionThreadProposedPlanRepository; + const projectionThreadVisualPlanRepository = yield* ProjectionThreadVisualPlanRepository; const projectionThreadActivityRepository = yield* ProjectionThreadActivityRepository; const projectionThreadSessionRepository = yield* ProjectionThreadSessionRepository; const projectionTurnRepository = yield* ProjectionTurnRepository; @@ -913,6 +917,29 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti } }); + const applyThreadVisualPlansProjection: ProjectorDefinition["apply"] = Effect.fn( + "applyThreadVisualPlansProjection", + )(function* (event, _attachmentSideEffects) { + switch (event.type) { + case "thread.visual-plan-upserted": + yield* projectionThreadVisualPlanRepository.upsert({ + planId: event.payload.visualPlan.id, + threadId: event.payload.threadId, + turnId: event.payload.visualPlan.turnId, + content: event.payload.visualPlan.content, + comments: event.payload.visualPlan.comments, + createdAt: event.payload.visualPlan.createdAt, + updatedAt: event.payload.visualPlan.updatedAt, + }); + return; + + // Visual plans are review documents rather than per-turn outputs, so a + // checkpoint revert intentionally leaves them intact. + default: + return; + } + }); + const applyThreadActivitiesProjection: ProjectorDefinition["apply"] = Effect.fn( "applyThreadActivitiesProjection", )(function* (event, _attachmentSideEffects) { @@ -1378,6 +1405,10 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti name: ORCHESTRATION_PROJECTOR_NAMES.threadProposedPlans, apply: applyThreadProposedPlansProjection, }, + { + name: ORCHESTRATION_PROJECTOR_NAMES.threadVisualPlans, + apply: applyThreadVisualPlansProjection, + }, { name: ORCHESTRATION_PROJECTOR_NAMES.threadActivities, apply: applyThreadActivitiesProjection, @@ -1500,6 +1531,7 @@ export const OrchestrationProjectionPipelineLive = Layer.effect( Layer.provideMerge(ProjectionThreadRepositoryLive), Layer.provideMerge(ProjectionThreadMessageRepositoryLive), Layer.provideMerge(ProjectionThreadProposedPlanRepositoryLive), + Layer.provideMerge(ProjectionThreadVisualPlanRepositoryLive), Layer.provideMerge(ProjectionThreadActivityRepositoryLive), Layer.provideMerge(ProjectionThreadSessionRepositoryLive), Layer.provideMerge(ProjectionTurnRepositoryLive), diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 7db2a23e5ec..058a3555e80 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -332,6 +332,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { updatedAt: "2026-02-24T00:00:05.500Z", }, ], + visualPlans: [], activities: [ { id: asEventId("activity-1"), diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index e629d1604b3..60f97b717ca 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -20,6 +20,9 @@ import { type OrchestrationSession, type OrchestrationThreadActivity, type OrchestrationThreadShell, + type OrchestrationVisualPlan, + PlanComment, + PlanContent, ModelSelection, ProjectId, ThreadId, @@ -46,6 +49,7 @@ import { ProjectionState } from "../../persistence/Services/ProjectionState.ts"; import { ProjectionThreadActivity } from "../../persistence/Services/ProjectionThreadActivities.ts"; import { ProjectionThreadMessage } from "../../persistence/Services/ProjectionThreadMessages.ts"; import { ProjectionThreadProposedPlan } from "../../persistence/Services/ProjectionThreadProposedPlans.ts"; +import { ProjectionThreadVisualPlan } from "../../persistence/Services/ProjectionThreadVisualPlans.ts"; import { ProjectionThreadSession } from "../../persistence/Services/ProjectionThreadSessions.ts"; import { ProjectionThread } from "../../persistence/Services/ProjectionThreads.ts"; import { RepositoryIdentityResolver } from "../../project/Services/RepositoryIdentityResolver.ts"; @@ -74,6 +78,12 @@ const ProjectionThreadMessageDbRowSchema = ProjectionThreadMessage.mapFields( }), ); const ProjectionThreadProposedPlanDbRowSchema = ProjectionThreadProposedPlan; +const ProjectionThreadVisualPlanDbRowSchema = ProjectionThreadVisualPlan.mapFields( + Struct.assign({ + content: Schema.fromJsonString(PlanContent), + comments: Schema.fromJsonString(Schema.Array(PlanComment)), + }), +); const ProjectionThreadDbRowSchema = ProjectionThread.mapFields( Struct.assign({ modelSelection: Schema.fromJsonString(ModelSelection), @@ -253,6 +263,19 @@ function mapProposedPlanRow( }; } +function mapVisualPlanRow( + row: Schema.Schema.Type, +): OrchestrationVisualPlan { + return { + id: row.planId, + turnId: row.turnId, + content: row.content, + comments: row.comments, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; +} + function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { return (cause: unknown): ProjectionRepositoryError => Schema.isSchemaError(cause) @@ -442,6 +465,24 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { `, }); + const listThreadVisualPlanRows = SqlSchema.findAll({ + Request: Schema.Void, + Result: ProjectionThreadVisualPlanDbRowSchema, + execute: () => + sql` + SELECT + plan_id AS "planId", + thread_id AS "threadId", + turn_id AS "turnId", + content_json AS "content", + comments_json AS "comments", + created_at AS "createdAt", + updated_at AS "updatedAt" + FROM projection_thread_visual_plans + ORDER BY thread_id ASC, created_at ASC, plan_id ASC + `, + }); + const listThreadActivityRows = SqlSchema.findAll({ Request: Schema.Void, Result: ProjectionThreadActivityDbRowSchema, @@ -807,6 +848,25 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { `, }); + const listThreadVisualPlanRowsByThread = SqlSchema.findAll({ + Request: ThreadIdLookupInput, + Result: ProjectionThreadVisualPlanDbRowSchema, + execute: ({ threadId }) => + sql` + SELECT + plan_id AS "planId", + thread_id AS "threadId", + turn_id AS "turnId", + content_json AS "content", + comments_json AS "comments", + created_at AS "createdAt", + updated_at AS "updatedAt" + FROM projection_thread_visual_plans + WHERE thread_id = ${threadId} + ORDER BY created_at ASC, plan_id ASC + `, + }); + const listThreadActivityRowsByThread = SqlSchema.findAll({ Request: ThreadIdLookupInput, Result: ProjectionThreadActivityDbRowSchema, @@ -966,6 +1026,14 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ), ), ), + listThreadVisualPlanRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getSnapshot:listThreadVisualPlans:query", + "ProjectionSnapshotQuery.getSnapshot:listThreadVisualPlans:decodeRows", + ), + ), + ), listThreadActivityRows(undefined).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( @@ -1015,6 +1083,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { threadRows, messageRows, proposedPlanRows, + visualPlanRows, activityRows, sessionRows, checkpointRows, @@ -1024,6 +1093,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { Effect.gen(function* () { const messagesByThread = new Map>(); const proposedPlansByThread = new Map>(); + const visualPlansByThread = new Map>(); const activitiesByThread = new Map>(); const checkpointsByThread = new Map>(); const sessionsByThread = new Map(); @@ -1072,6 +1142,13 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { proposedPlansByThread.set(row.threadId, threadProposedPlans); } + for (const row of visualPlanRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); + const threadVisualPlans = visualPlansByThread.get(row.threadId) ?? []; + threadVisualPlans.push(mapVisualPlanRow(row)); + visualPlansByThread.set(row.threadId, threadVisualPlans); + } + for (const row of activityRows) { updatedAt = maxIso(updatedAt, row.createdAt); const threadActivities = activitiesByThread.get(row.threadId) ?? []; @@ -1188,6 +1265,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { deletedAt: row.deletedAt, messages: messagesByThread.get(row.threadId) ?? [], proposedPlans: proposedPlansByThread.get(row.threadId) ?? [], + visualPlans: visualPlansByThread.get(row.threadId) ?? [], activities: activitiesByThread.get(row.threadId) ?? [], checkpoints: checkpointsByThread.get(row.threadId) ?? [], session: sessionsByThread.get(row.threadId) ?? null, @@ -1243,6 +1321,14 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ), ), ), + listThreadVisualPlanRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getCommandReadModel:listThreadVisualPlans:query", + "ProjectionSnapshotQuery.getCommandReadModel:listThreadVisualPlans:decodeRows", + ), + ), + ), listThreadSessionRows(undefined).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( @@ -1271,7 +1357,15 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ) .pipe( Effect.flatMap( - ([projectRows, threadRows, proposedPlanRows, sessionRows, latestTurnRows, stateRows]) => + ([ + projectRows, + threadRows, + proposedPlanRows, + visualPlanRows, + sessionRows, + latestTurnRows, + stateRows, + ]) => Effect.sync(() => { let updatedAt: string | null = null; const projects: OrchestrationProject[] = []; @@ -1345,6 +1439,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { latestTurnByThread.set(row.threadId, mapLatestTurn(row)); } const proposedPlansByThread = new Map>(); + const visualPlansByThread = new Map>(); const sessionByThread = new Map(); for (let index = 0; index < sessionRows.length; index += 1) { @@ -1365,6 +1460,16 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { proposedPlansByThread.set(row.threadId, threadProposedPlans); } + for (let index = 0; index < visualPlanRows.length; index += 1) { + const row = visualPlanRows[index]; + if (!row) { + continue; + } + const threadVisualPlans = visualPlansByThread.get(row.threadId) ?? []; + threadVisualPlans.push(mapVisualPlanRow(row)); + visualPlansByThread.set(row.threadId, threadVisualPlans); + } + for (let index = 0; index < threadRows.length; index += 1) { const row = threadRows[index]; if (!row) { @@ -1386,6 +1491,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { deletedAt: row.deletedAt, messages: [], proposedPlans: proposedPlansByThread.get(row.threadId) ?? [], + visualPlans: visualPlansByThread.get(row.threadId) ?? [], activities: [], checkpoints: [], session: sessionByThread.get(row.threadId) ?? null, @@ -1900,6 +2006,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { threadRow, messageRows, proposedPlanRows, + visualPlanRows, activityRows, checkpointRows, latestTurnRow, @@ -1929,6 +2036,14 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ), ), ), + listThreadVisualPlanRowsByThread({ threadId }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadDetailById:listVisualPlans:query", + "ProjectionSnapshotQuery.getThreadDetailById:listVisualPlans:decodeRows", + ), + ), + ), listThreadActivityRowsByThread({ threadId }).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( @@ -1997,6 +2112,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { return message; }), proposedPlans: proposedPlanRows.map(mapProposedPlanRow), + visualPlans: visualPlanRows.map(mapVisualPlanRow), activities: activityRows.map((row) => { const activity = { id: row.activityId, diff --git a/apps/server/src/orchestration/Schemas.ts b/apps/server/src/orchestration/Schemas.ts index f7ebf693440..b0b2f0cd1ad 100644 --- a/apps/server/src/orchestration/Schemas.ts +++ b/apps/server/src/orchestration/Schemas.ts @@ -11,6 +11,7 @@ import { ThreadUnarchivedPayload as ContractsThreadUnarchivedPayloadSchema, ThreadMessageSentPayload as ContractsThreadMessageSentPayloadSchema, ThreadProposedPlanUpsertedPayload as ContractsThreadProposedPlanUpsertedPayloadSchema, + ThreadVisualPlanUpsertedPayload as ContractsThreadVisualPlanUpsertedPayloadSchema, ThreadSessionSetPayload as ContractsThreadSessionSetPayloadSchema, ThreadTurnDiffCompletedPayload as ContractsThreadTurnDiffCompletedPayloadSchema, ThreadRevertedPayload as ContractsThreadRevertedPayloadSchema, @@ -37,6 +38,7 @@ export const ThreadUnarchivedPayload = ContractsThreadUnarchivedPayloadSchema; export const MessageSentPayloadSchema = ContractsThreadMessageSentPayloadSchema; export const ThreadProposedPlanUpsertedPayload = ContractsThreadProposedPlanUpsertedPayloadSchema; +export const ThreadVisualPlanUpsertedPayload = ContractsThreadVisualPlanUpsertedPayloadSchema; export const ThreadSessionSetPayload = ContractsThreadSessionSetPayloadSchema; export const ThreadTurnDiffCompletedPayload = ContractsThreadTurnDiffCompletedPayloadSchema; export const ThreadRevertedPayload = ContractsThreadRevertedPayloadSchema; diff --git a/apps/server/src/orchestration/commandInvariants.test.ts b/apps/server/src/orchestration/commandInvariants.test.ts index d52f0535fbb..ab05370e451 100644 --- a/apps/server/src/orchestration/commandInvariants.test.ts +++ b/apps/server/src/orchestration/commandInvariants.test.ts @@ -73,6 +73,7 @@ const readModel: OrchestrationReadModel = { session: null, activities: [], proposedPlans: [], + visualPlans: [], checkpoints: [], deletedAt: null, }, @@ -96,6 +97,7 @@ const readModel: OrchestrationReadModel = { session: null, activities: [], proposedPlans: [], + visualPlans: [], checkpoints: [], deletedAt: null, }, diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 0d4af771ca8..76dbde24063 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -675,6 +675,27 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }; } + case "thread.visual-plan.upsert": { + yield* requireThread({ + readModel, + command, + threadId: command.threadId, + }); + return { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + })), + type: "thread.visual-plan-upserted", + payload: { + threadId: command.threadId, + visualPlan: command.visualPlan, + }, + }; + } + case "thread.turn.diff.complete": { yield* requireThread({ readModel, diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index 0c92f965433..3b7bf642a3c 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -21,6 +21,7 @@ import { ThreadInteractionModeSetPayload, ThreadMetaUpdatedPayload, ThreadProposedPlanUpsertedPayload, + ThreadVisualPlanUpsertedPayload, ThreadRuntimeModeSetPayload, ThreadUnarchivedPayload, ThreadRevertedPayload, @@ -499,6 +500,38 @@ export function projectEvent( }; }); + case "thread.visual-plan-upserted": + return Effect.gen(function* () { + const payload = yield* decodeForEvent( + ThreadVisualPlanUpsertedPayload, + event.payload, + event.type, + "payload", + ); + const thread = nextBase.threads.find((entry) => entry.id === payload.threadId); + if (!thread) { + return nextBase; + } + + const visualPlans = [ + ...thread.visualPlans.filter((entry) => entry.id !== payload.visualPlan.id), + payload.visualPlan, + ] + .toSorted( + (left, right) => + left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id), + ) + .slice(-50); + + return { + ...nextBase, + threads: updateThread(nextBase.threads, payload.threadId, { + visualPlans, + updatedAt: event.occurredAt, + }), + }; + }); + case "thread.turn-diff-completed": return Effect.gen(function* () { const payload = yield* decodeForEvent( diff --git a/apps/server/src/persistence/Layers/ProjectionThreadVisualPlans.ts b/apps/server/src/persistence/Layers/ProjectionThreadVisualPlans.ts new file mode 100644 index 00000000000..1d2c34560f5 --- /dev/null +++ b/apps/server/src/persistence/Layers/ProjectionThreadVisualPlans.ts @@ -0,0 +1,125 @@ +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as SqlSchema from "effect/unstable/sql/SqlSchema"; +import { PlanComment, PlanContent } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as Struct from "effect/Struct"; + +import { toPersistenceDecodeError, toPersistenceSqlError } from "../Errors.ts"; +import { + DeleteProjectionThreadVisualPlansInput, + ListProjectionThreadVisualPlansInput, + ProjectionThreadVisualPlan, + ProjectionThreadVisualPlanRepository, + type ProjectionThreadVisualPlanRepositoryShape, +} from "../Services/ProjectionThreadVisualPlans.ts"; + +const ProjectionThreadVisualPlanDbRowSchema = ProjectionThreadVisualPlan.mapFields( + Struct.assign({ + content: Schema.fromJsonString(PlanContent), + comments: Schema.fromJsonString(Schema.Array(PlanComment)), + }), +); + +function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { + return (cause: unknown) => + Schema.isSchemaError(cause) + ? toPersistenceDecodeError(decodeOperation)(cause) + : toPersistenceSqlError(sqlOperation)(cause); +} + +const makeProjectionThreadVisualPlanRepository = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const upsertProjectionThreadVisualPlanRow = SqlSchema.void({ + Request: ProjectionThreadVisualPlan, + execute: (row) => sql` + INSERT INTO projection_thread_visual_plans ( + plan_id, + thread_id, + turn_id, + content_json, + comments_json, + created_at, + updated_at + ) + VALUES ( + ${row.planId}, + ${row.threadId}, + ${row.turnId}, + ${JSON.stringify(row.content)}, + ${JSON.stringify(row.comments)}, + ${row.createdAt}, + ${row.updatedAt} + ) + ON CONFLICT (plan_id) + DO UPDATE SET + thread_id = excluded.thread_id, + turn_id = excluded.turn_id, + content_json = excluded.content_json, + comments_json = excluded.comments_json, + created_at = excluded.created_at, + updated_at = excluded.updated_at + `, + }); + + const listProjectionThreadVisualPlanRows = SqlSchema.findAll({ + Request: ListProjectionThreadVisualPlansInput, + Result: ProjectionThreadVisualPlanDbRowSchema, + execute: ({ threadId }) => sql` + SELECT + plan_id AS "planId", + thread_id AS "threadId", + turn_id AS "turnId", + content_json AS "content", + comments_json AS "comments", + created_at AS "createdAt", + updated_at AS "updatedAt" + FROM projection_thread_visual_plans + WHERE thread_id = ${threadId} + ORDER BY created_at ASC, plan_id ASC + `, + }); + + const deleteProjectionThreadVisualPlanRows = SqlSchema.void({ + Request: DeleteProjectionThreadVisualPlansInput, + execute: ({ threadId }) => sql` + DELETE FROM projection_thread_visual_plans + WHERE thread_id = ${threadId} + `, + }); + + const upsert: ProjectionThreadVisualPlanRepositoryShape["upsert"] = (row) => + upsertProjectionThreadVisualPlanRow(row).pipe( + Effect.mapError(toPersistenceSqlError("ProjectionThreadVisualPlanRepository.upsert:query")), + ); + + const listByThreadId: ProjectionThreadVisualPlanRepositoryShape["listByThreadId"] = (input) => + listProjectionThreadVisualPlanRows(input).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionThreadVisualPlanRepository.listByThreadId:query", + "ProjectionThreadVisualPlanRepository.listByThreadId:decode", + ), + ), + ); + + const deleteByThreadId: ProjectionThreadVisualPlanRepositoryShape["deleteByThreadId"] = (input) => + deleteProjectionThreadVisualPlanRows(input).pipe( + Effect.mapError( + toPersistenceSqlError("ProjectionThreadVisualPlanRepository.deleteByThreadId:query"), + ), + ); + + return { + upsert, + listByThreadId, + deleteByThreadId, + } satisfies ProjectionThreadVisualPlanRepositoryShape; +}); + +export const ProjectionThreadVisualPlanRepositoryLive = Layer.effect( + ProjectionThreadVisualPlanRepository, + makeProjectionThreadVisualPlanRepository, +); diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index cc5024d5f51..8f0e1ad345a 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -43,6 +43,7 @@ import Migration0027 from "./Migrations/027_ProviderSessionRuntimeInstanceId.ts" import Migration0028 from "./Migrations/028_ProjectionThreadSessionInstanceId.ts"; import Migration0029 from "./Migrations/029_ProjectionThreadDetailOrderingIndexes.ts"; import Migration0030 from "./Migrations/030_ProjectionThreadShellArchiveIndexes.ts"; +import Migration0031 from "./Migrations/031_ProjectionThreadVisualPlans.ts"; /** * Migration loader with all migrations defined inline. @@ -85,6 +86,7 @@ export const migrationEntries = [ [28, "ProjectionThreadSessionInstanceId", Migration0028], [29, "ProjectionThreadDetailOrderingIndexes", Migration0029], [30, "ProjectionThreadShellArchiveIndexes", Migration0030], + [31, "ProjectionThreadVisualPlans", Migration0031], ] as const; export const makeMigrationLoader = (throughId?: number) => diff --git a/apps/server/src/persistence/Migrations/031_ProjectionThreadVisualPlans.ts b/apps/server/src/persistence/Migrations/031_ProjectionThreadVisualPlans.ts new file mode 100644 index 00000000000..0bb874ef8ad --- /dev/null +++ b/apps/server/src/persistence/Migrations/031_ProjectionThreadVisualPlans.ts @@ -0,0 +1,23 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + CREATE TABLE IF NOT EXISTS projection_thread_visual_plans ( + plan_id TEXT PRIMARY KEY, + thread_id TEXT NOT NULL, + turn_id TEXT, + content_json TEXT NOT NULL, + comments_json TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + `; + + yield* sql` + CREATE INDEX IF NOT EXISTS idx_projection_thread_visual_plans_thread_created + ON projection_thread_visual_plans(thread_id, created_at) + `; +}); diff --git a/apps/server/src/persistence/Services/ProjectionThreadVisualPlans.ts b/apps/server/src/persistence/Services/ProjectionThreadVisualPlans.ts new file mode 100644 index 00000000000..1d537373e3d --- /dev/null +++ b/apps/server/src/persistence/Services/ProjectionThreadVisualPlans.ts @@ -0,0 +1,62 @@ +/** + * ProjectionThreadVisualPlanRepository - Projection repository interface for + * thread visual plans (the GITS-native builder.io-style visual plan documents). + * + * Owns persistence for the per-thread `PlanContent` document + comments + * projected from `thread.visual-plan-upserted` orchestration events. Content + * and comments are stored as JSON columns. + * + * @module ProjectionThreadVisualPlanRepository + */ +import { + IsoDateTime, + OrchestrationVisualPlanId, + PlanComment, + PlanContent, + ThreadId, + TurnId, +} from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +import type { ProjectionRepositoryError } from "../Errors.ts"; + +export const ProjectionThreadVisualPlan = Schema.Struct({ + planId: OrchestrationVisualPlanId, + threadId: ThreadId, + turnId: Schema.NullOr(TurnId), + content: PlanContent, + comments: Schema.Array(PlanComment), + createdAt: IsoDateTime, + updatedAt: IsoDateTime, +}); +export type ProjectionThreadVisualPlan = typeof ProjectionThreadVisualPlan.Type; + +export const ListProjectionThreadVisualPlansInput = Schema.Struct({ + threadId: ThreadId, +}); +export type ListProjectionThreadVisualPlansInput = typeof ListProjectionThreadVisualPlansInput.Type; + +export const DeleteProjectionThreadVisualPlansInput = Schema.Struct({ + threadId: ThreadId, +}); +export type DeleteProjectionThreadVisualPlansInput = + typeof DeleteProjectionThreadVisualPlansInput.Type; + +export interface ProjectionThreadVisualPlanRepositoryShape { + readonly upsert: ( + visualPlan: ProjectionThreadVisualPlan, + ) => Effect.Effect; + readonly listByThreadId: ( + input: ListProjectionThreadVisualPlansInput, + ) => Effect.Effect, ProjectionRepositoryError>; + readonly deleteByThreadId: ( + input: DeleteProjectionThreadVisualPlansInput, + ) => Effect.Effect; +} + +export class ProjectionThreadVisualPlanRepository extends Context.Service< + ProjectionThreadVisualPlanRepository, + ProjectionThreadVisualPlanRepositoryShape +>()("t3/persistence/Services/ProjectionThreadVisualPlans/ProjectionThreadVisualPlanRepository") {} diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index cdad2556e4a..da3705d9994 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -67,6 +67,10 @@ import * as Stream from "effect/Stream"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; +import { + issueVisualPlanToken, + VISUAL_PLAN_MCP_PATH, +} from "../../gits/mcp/VisualPlanMcpRegistry.ts"; import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts"; import { getClaudeModelCapabilities, @@ -2935,6 +2939,14 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( "full-access": "bypassPermissions", }; const permissionMode = runtimeModeToPermission[input.runtimeMode]; + const visualPlanToken = issueVisualPlanToken(threadId); + const visualPlanMcpServers = { + "gits-visual-plan": { + type: "http" as const, + url: `http://127.0.0.1:${serverConfig.port}${VISUAL_PLAN_MCP_PATH}`, + headers: { Authorization: `Bearer ${visualPlanToken}` }, + }, + }; const settings = { ...(typeof thinking === "boolean" ? { alwaysThinkingEnabled: thinking } : {}), ...(fastMode ? { fastMode: true } : {}), @@ -2962,6 +2974,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ...(newSessionId ? { sessionId: newSessionId } : {}), includePartialMessages: true, canUseTool, + mcpServers: visualPlanMcpServers, env: claudeEnvironment, ...(input.cwd ? { additionalDirectories: [input.cwd] } : {}), ...(Object.keys(extraArgs).length > 0 ? { extraArgs } : {}), diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 9893cf6c149..860ed0a4ee6 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -53,6 +53,7 @@ import { import { type CodexAdapterShape } from "../Services/CodexAdapter.ts"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; +import { VISUAL_PLAN_MCP_PATH } from "../../gits/mcp/VisualPlanMcpRegistry.ts"; import { CodexResumeCursorSchema, CodexSessionRuntimeThreadIdMissingError, @@ -1385,6 +1386,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( providerInstanceId: boundInstanceId, cwd: input.cwd ?? process.cwd(), binaryPath: codexConfig.binaryPath, + visualPlanMcpUrl: `http://127.0.0.1:${serverConfig.port}${VISUAL_PLAN_MCP_PATH}`, ...(options?.environment ? { environment: options.environment } : {}), ...(codexConfig.homePath ? { homePath: codexConfig.homePath } : {}), ...(isCodexResumeCursorSchema(input.resumeCursor) diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts index f453737ef62..7af41fdb0e8 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts @@ -246,6 +246,7 @@ describe("openCodexThread", () => { requestedModel: "gpt-5.3-codex", serviceTier: undefined, resumeThreadId: "stale-thread", + visualPlanMcpUrl: undefined, }), ); @@ -286,6 +287,7 @@ describe("openCodexThread", () => { requestedModel: "gpt-5.3-codex", serviceTier: undefined, resumeThreadId: "stale-thread", + visualPlanMcpUrl: undefined, }), ), (error: unknown) => diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index f9b9c6ab4fb..d1e58841fa6 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -36,6 +36,7 @@ import * as CodexRpc from "effect-codex-app-server/rpc"; import * as EffectCodexSchema from "effect-codex-app-server/schema"; import { buildCodexInitializeParams } from "./CodexProvider.ts"; +import { issueVisualPlanToken } from "../../gits/mcp/VisualPlanMcpRegistry.ts"; import { expandHomePath } from "../../pathExpansion.ts"; import { CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS, @@ -103,6 +104,7 @@ export interface CodexSessionRuntimeOptions { readonly model?: string; readonly serviceTier?: CodexServiceTier | undefined; readonly resumeCursor?: CodexResumeCursor; + readonly visualPlanMcpUrl?: string; } export interface CodexSessionRuntimeSendTurnInput { @@ -287,6 +289,7 @@ function buildThreadStartParams(input: { readonly runtimeMode: RuntimeMode; readonly model: string | undefined; readonly serviceTier: CodexServiceTier | undefined; + readonly visualPlanMcp: { readonly url: string; readonly token: string } | undefined; }): EffectCodexSchema.V2ThreadStartParams { const config = runtimeModeToThreadConfig(input.runtimeMode); return { @@ -295,6 +298,20 @@ function buildThreadStartParams(input: { sandbox: config.sandbox, ...(input.model ? { model: input.model } : {}), ...(input.serviceTier ? { serviceTier: input.serviceTier } : {}), + // Per-thread config override (mirrors config.toml). Registers the GITS + // visual-plan MCP as a streamable-HTTP server scoped to this thread's token. + ...(input.visualPlanMcp + ? { + config: { + mcp_servers: { + "gits-visual-plan": { + url: input.visualPlanMcp.url, + bearer_token: input.visualPlanMcp.token, + }, + }, + }, + } + : {}), }; } @@ -437,6 +454,7 @@ export const openCodexThread = (input: { readonly requestedModel: string | undefined; readonly serviceTier: CodexServiceTier | undefined; readonly resumeThreadId: string | undefined; + readonly visualPlanMcpUrl: string | undefined; }): Effect.Effect => { const resumeThreadId = input.resumeThreadId; const startParams = buildThreadStartParams({ @@ -444,6 +462,9 @@ export const openCodexThread = (input: { runtimeMode: input.runtimeMode, model: input.requestedModel, serviceTier: input.serviceTier, + visualPlanMcp: input.visualPlanMcpUrl + ? { url: input.visualPlanMcpUrl, token: issueVisualPlanToken(input.threadId) } + : undefined, }); if (resumeThreadId === undefined) { @@ -1202,6 +1223,7 @@ export const makeCodexSessionRuntime = ( requestedModel, serviceTier: options.serviceTier, resumeThreadId: readResumeCursorThreadId(options.resumeCursor), + visualPlanMcpUrl: options.visualPlanMcpUrl, }); const providerThreadId = opened.thread.id; diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts index 016feeb79a4..e6238a3d83b 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -42,6 +42,10 @@ import type * as EffectAcpSchema from "effect-acp/schema"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; +import { + issueVisualPlanToken, + VISUAL_PLAN_MCP_PATH, +} from "../../gits/mcp/VisualPlanMcpRegistry.ts"; import { ProviderAdapterProcessError, ProviderAdapterRequestError, @@ -526,6 +530,7 @@ export function makeCursorAdapter( ? yield* options.resolveSettings : cursorSettings; + const visualPlanMcpToken = issueVisualPlanToken(input.threadId); const acp = yield* makeCursorAcpRuntime({ cursorSettings: effectiveCursorSettings, ...(options?.environment ? { environment: options.environment } : {}), @@ -533,6 +538,14 @@ export function makeCursorAdapter( cwd, ...(resumeSessionId ? { resumeSessionId } : {}), clientInfo: { name: "t3-code", version: "0.0.0" }, + mcpServers: [ + { + type: "http", + name: "gits-visual-plan", + url: `http://127.0.0.1:${serverConfig.port}${VISUAL_PLAN_MCP_PATH}`, + headers: [{ name: "Authorization", value: `Bearer ${visualPlanMcpToken}` }], + }, + ], ...acpNativeLoggers, }).pipe( Effect.provideService(Scope.Scope, sessionScope), diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index 8652b2cfeaf..e82222f875a 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -41,6 +41,7 @@ export interface AcpSessionRuntimeOptions { readonly spawn: AcpSpawnInput; readonly cwd: string; readonly resumeSessionId?: string; + readonly mcpServers?: ReadonlyArray; readonly clientCapabilities?: EffectAcpSchema.InitializeRequest["clientCapabilities"]; readonly clientInfo: { readonly name: string; @@ -397,7 +398,7 @@ const makeAcpSessionRuntime = ( const loadPayload = { sessionId: options.resumeSessionId, cwd: options.cwd, - mcpServers: [], + mcpServers: options.mcpServers ?? [], } satisfies EffectAcpSchema.LoadSessionRequest; const resumed = yield* runLoggedRequest( "session/load", @@ -410,7 +411,7 @@ const makeAcpSessionRuntime = ( } else { const createPayload = { cwd: options.cwd, - mcpServers: [], + mcpServers: options.mcpServers ?? [], } satisfies EffectAcpSchema.NewSessionRequest; const created = yield* runLoggedRequest( "session/new", @@ -423,7 +424,7 @@ const makeAcpSessionRuntime = ( } else { const createPayload = { cwd: options.cwd, - mcpServers: [], + mcpServers: options.mcpServers ?? [], } satisfies EffectAcpSchema.NewSessionRequest; const created = yield* runLoggedRequest( "session/new", diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 4ed78d1998d..dc68ae965da 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -124,6 +124,7 @@ import { type GitsCapacityMonitorShape, } from "./gits/Services/GitsCapacityMonitor.ts"; import { HermesAdapter, type HermesAdapterShape } from "./gits/Services/HermesAdapter.ts"; +import { issueVisualPlanToken } from "./gits/mcp/VisualPlanMcpRegistry.ts"; import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite.ts"; import { PersistenceSqlError } from "./persistence/Errors.ts"; import { @@ -582,6 +583,7 @@ const makeDefaultOrchestrationReadModel = () => { session: null, activities: [], proposedPlans: [], + visualPlans: [], checkpoints: [], deletedAt: null, }, @@ -1777,6 +1779,73 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("visual-plan MCP completes the connect handshake for an authenticated client", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + + // A provider session would mint this token; drive it directly here. + const token = issueVisualPlanToken(ThreadId.make("vp-handshake-thread")); + const authHeaders = { authorization: `Bearer ${token}`, "content-type": "application/json" }; + + const postMcp = (payload: unknown) => + Effect.gen(function* () { + const response = yield* HttpClient.post("/api/gits/visual-plan/mcp", { + headers: authHeaders, + body: yield* HttpBody.json(payload), + }); + return (yield* response.json) as { + readonly result?: Record; + readonly error?: { readonly code: number; readonly message: string }; + }; + }); + + // initialize → serverInfo + const init = yield* postMcp({ jsonrpc: "2.0", id: 1, method: "initialize" }); + assert.equal((init.result?.serverInfo as { name: string }).name, "gits-visual-plan"); + + // tools/list → the six visual-plan tools + const list = yield* postMcp({ jsonrpc: "2.0", id: 2, method: "tools/list" }); + const toolNames = (list.result?.tools as Array<{ name: string }>).map((t) => t.name); + assert.deepEqual([...toolNames].sort(), [ + "create-visual-plan", + "export-visual-plan", + "get-plan-blocks", + "get-plan-feedback", + "get-visual-plan", + "update-visual-plan", + ]); + + // tools/call get-plan-blocks → the block catalog + const blocks = yield* postMcp({ + jsonrpc: "2.0", + id: 3, + method: "tools/call", + params: { name: "get-plan-blocks", arguments: {} }, + }); + const text = (blocks.result?.content as Array<{ text: string }>)[0]?.text ?? ""; + assert.ok(text.includes("rich-text"), "catalog should list the rich-text block"); + assert.ok(text.includes("question-form"), "catalog should list the question-form block"); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("visual-plan MCP rejects calls without a valid session token", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + + const response = yield* HttpClient.post("/api/gits/visual-plan/mcp", { + headers: { + authorization: "Bearer vpmcp_not_a_real_token", + "content-type": "application/json", + }, + body: yield* HttpBody.json({ jsonrpc: "2.0", id: 1, method: "tools/list" }), + }); + const body = (yield* response.json) as { readonly error?: { readonly message: string } }; + + assert.equal(response.status, 401); + assert.equal(body.error?.message, "Invalid session token"); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("reports unauthenticated session state without requiring auth", () => Effect.gen(function* () { yield* buildAppUnderTest(); @@ -4691,6 +4760,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { session: null, activities: [], proposedPlans: [], + visualPlans: [], checkpoints: [], deletedAt: null, }, diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index f6d4ef4a71d..f8161894f33 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -116,6 +116,7 @@ import { gitsMcpInventoryRouteLayer, gitsSkillInventoryRouteLayer, } from "./gits/http.ts"; +import { visualPlanMcpRouteLayer } from "./gits/mcp/http.ts"; const PtyAdapterLive = Layer.unwrap( Effect.gen(function* () { @@ -423,6 +424,7 @@ export const makeRoutesLayer = Layer.mergeAll( gitsBuildInfoRouteLayer, gitsSkillInventoryRouteLayer, gitsMcpInventoryRouteLayer, + visualPlanMcpRouteLayer, orchestrationDispatchRouteLayer, orchestrationSnapshotRouteLayer, otlpTracesProxyRouteLayer, diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 79d1d3866f9..189a43f8622 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -133,6 +133,7 @@ function isThreadDetailEvent(event: OrchestrationEvent): event is Extract< type: | "thread.message-sent" | "thread.proposed-plan-upserted" + | "thread.visual-plan-upserted" | "thread.activity-appended" | "thread.turn-diff-completed" | "thread.reverted" @@ -142,6 +143,7 @@ function isThreadDetailEvent(event: OrchestrationEvent): event is Extract< return ( event.type === "thread.message-sent" || event.type === "thread.proposed-plan-upserted" || + event.type === "thread.visual-plan-upserted" || event.type === "thread.activity-appended" || event.type === "thread.turn-diff-completed" || event.type === "thread.reverted" || diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 0198fde1098..236a03d4e7a 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -397,6 +397,7 @@ function createSnapshotForTargetUser(options: { messages, activities: [], proposedPlans: [], + visualPlans: [], checkpoints: [], session: { threadId: THREAD_ID, @@ -463,6 +464,7 @@ function addThreadToSnapshot( messages: [], activities: [], proposedPlans: [], + visualPlans: [], checkpoints: [], session: { threadId, @@ -797,6 +799,7 @@ function createSnapshotWithSecondaryProject(options?: { messages: [], activities: [], proposedPlans: [], + visualPlans: [], checkpoints: [], session: { threadId: "thread-secondary-project" as ThreadId, @@ -829,6 +832,7 @@ function createSnapshotWithSecondaryProject(options?: { messages: [], activities: [], proposedPlans: [], + visualPlans: [], checkpoints: [], session: { threadId: ARCHIVED_SECONDARY_THREAD_ID, diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 83c90edaddc..ea2261f5757 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -233,6 +233,7 @@ const makeThread = (input?: { session: null, messages: [], proposedPlans: [], + visualPlans: [], error: null, createdAt: "2026-03-29T00:00:00.000Z", archivedAt: null, @@ -332,6 +333,9 @@ function setStoreThreads(threads: ReadonlyArray>) Object.fromEntries(thread.proposedPlans.map((plan) => [plan.id, plan])), ]), ), + visualPlanByThreadId: Object.fromEntries( + threads.map((thread) => [thread.id, thread.visualPlans]), + ), turnDiffIdsByThreadId: Object.fromEntries( threads.map((thread) => [ thread.id, @@ -479,6 +483,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => { session: previousSession, messages: [], proposedPlans: [], + visualPlans: [], error: null, createdAt: "2026-03-29T00:00:00.000Z", archivedAt: null, @@ -516,6 +521,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => { session: previousSession, messages: [], proposedPlans: [], + visualPlans: [], error: null, createdAt: "2026-03-29T00:00:00.000Z", archivedAt: null, @@ -562,6 +568,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => { session: previousSession, messages: [], proposedPlans: [], + visualPlans: [], error: null, createdAt: "2026-03-29T00:00:00.000Z", archivedAt: null, @@ -605,6 +612,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => { session: previousSession, messages: [], proposedPlans: [], + visualPlans: [], error: null, createdAt: "2026-03-29T00:00:00.000Z", archivedAt: null, @@ -648,6 +656,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => { session: previousSession, messages: [], proposedPlans: [], + visualPlans: [], error: null, createdAt: "2026-03-29T00:00:00.000Z", archivedAt: null, @@ -698,6 +707,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => { session: previousSession, messages: [], proposedPlans: [], + visualPlans: [], error: null, createdAt: "2026-03-29T00:00:00.000Z", archivedAt: null, diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index bf87add28d9..e5f8d41e3c7 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -50,6 +50,7 @@ export function buildLocalDraftThread( turnDiffSummaries: [], activities: [], proposedPlans: [], + visualPlans: [], }; } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 44e365ba98e..cda618a540c 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -109,6 +109,7 @@ import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings" // don't need to sit in the eager ChatView chunk. const DelamainSidebar = lazy(() => import("./DelamainSidebar")); const PlanSidebar = lazy(() => import("./PlanSidebar")); +const VisualPlanPanel = lazy(() => import("./VisualPlanPanel")); // Lazy-loaded: pulls xterm.js (+addons) and the terminal CSS into a separate // chunk that only downloads when a user actually opens a terminal drawer. const ThreadTerminalDrawer = lazy(() => import("./ThreadTerminalDrawer")); @@ -876,6 +877,7 @@ export default function ChatView(props: ChatViewProps) { useState>({}); const [planSidebarOpen, setPlanSidebarOpen] = useState(false); const [delamainSidebarOpen, setDelamainSidebarOpen] = useState(false); + const [visualPlanOpen, setVisualPlanOpen] = useState(false); const shouldUsePlanSidebarSheet = useMediaQuery(RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY); // Tracks whether the user explicitly dismissed the sidebar for the active turn. const planSidebarDismissedForTurnRef = useRef(null); @@ -1551,6 +1553,10 @@ export default function ChatView(props: ChatViewProps) { () => deriveActivePlanState(threadActivities, activeLatestTurn?.turnId ?? undefined), [activeLatestTurn?.turnId, threadActivities], ); + const activeVisualPlan = useMemo(() => { + const plans = activeThread?.visualPlans ?? []; + return plans.length > 0 ? (plans[plans.length - 1] ?? null) : null; + }, [activeThread?.visualPlans]); const planSidebarLabel = sidebarProposedPlan || interactionMode === "plan" ? "Plan" : "Tasks"; const delamainPeersQuery = useQuery({ queryKey: ["gits", "delamain", "peers", environmentId], @@ -2429,10 +2435,20 @@ export default function ChatView(props: ChatViewProps) { activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__"; } else { planSidebarDismissedForTurnRef.current = null; + setVisualPlanOpen(false); } return !open; }); }, [activePlan?.turnId, sidebarProposedPlan?.turnId]); + const toggleVisualPlan = useCallback(() => { + setVisualPlanOpen((open) => { + if (!open) { + setPlanSidebarOpen(false); + } + return !open; + }); + }, []); + const closeVisualPlan = useCallback(() => setVisualPlanOpen(false), []); const closePlanSidebar = useCallback(() => { setPlanSidebarOpen(false); planSidebarDismissedForTurnRef.current = @@ -3926,6 +3942,8 @@ export default function ChatView(props: ChatViewProps) { sidebarProposedPlan={sidebarProposedPlan as { turnId?: TurnId } | null} planSidebarLabel={planSidebarLabel} planSidebarOpen={planSidebarOpen} + visualPlanOpen={visualPlanOpen} + hasVisualPlan={Boolean(activeVisualPlan)} hasDeployedDelamainPeers={hasDeployedDelamainPeers} delamainSidebarOpen={delamainSidebarOpen} runtimeMode={runtimeMode} @@ -3963,6 +3981,7 @@ export default function ChatView(props: ChatViewProps) { handleInteractionModeChange={handleInteractionModeChange} togglePlanSidebar={togglePlanSidebar} toggleDelamainSidebar={toggleDelamainSidebar} + toggleVisualPlan={toggleVisualPlan} focusComposer={focusComposer} scheduleComposerFocus={scheduleComposerFocus} setThreadError={setThreadError} @@ -4036,6 +4055,13 @@ export default function ChatView(props: ChatViewProps) { onClose={closeDelamainSidebar} /> ) : null} + {visualPlanOpen && !shouldUsePlanSidebarSheet ? ( + + ) : null} {/* end horizontal flex container */} @@ -4087,6 +4113,12 @@ export default function ChatView(props: ChatViewProps) { ) : null} + {shouldUsePlanSidebarSheet ? ( + + + + ) : null} + {expandedImage && ( )} diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index 38b44f3f6a7..f0446c422ec 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -23,6 +23,7 @@ function makeThread(overrides: Partial = {}): Thread { session: null, messages: [], proposedPlans: [], + visualPlans: [], error: null, createdAt: "2026-03-01T00:00:00.000Z", archivedAt: null, diff --git a/apps/web/src/components/KeybindingsToast.browser.tsx b/apps/web/src/components/KeybindingsToast.browser.tsx index 4320f6ecf4a..0cef76c63ec 100644 --- a/apps/web/src/components/KeybindingsToast.browser.tsx +++ b/apps/web/src/components/KeybindingsToast.browser.tsx @@ -210,6 +210,7 @@ function createMinimalSnapshot(): OrchestrationReadModel { ], activities: [], proposedPlans: [], + visualPlans: [], checkpoints: [], session: { threadId: THREAD_ID, diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 926c117c1c0..4f5c3af1e23 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -733,6 +733,7 @@ function makeThread(overrides: Partial = {}): Thread { session: null, messages: [], proposedPlans: [], + visualPlans: [], error: null, createdAt: "2026-03-09T10:00:00.000Z", archivedAt: null, diff --git a/apps/web/src/components/VisualPlanPanel.tsx b/apps/web/src/components/VisualPlanPanel.tsx new file mode 100644 index 00000000000..6973eb74450 --- /dev/null +++ b/apps/web/src/components/VisualPlanPanel.tsx @@ -0,0 +1,364 @@ +import { memo, useMemo } from "react"; +import type { + OrchestrationVisualPlan, + PlanBlock, + PlanContent, + PlanLeafBlock, +} from "@t3tools/contracts"; +import { PanelRightCloseIcon } from "lucide-react"; +import { Badge } from "./ui/badge"; +import { Button } from "./ui/button"; +import { ScrollArea } from "./ui/scroll-area"; +import ChatMarkdown from "./ChatMarkdown"; +import { cn } from "~/lib/utils"; + +interface VisualPlanPanelProps { + readonly visualPlan: OrchestrationVisualPlan | null; + readonly mode: "sidebar" | "sheet"; + readonly onClose: () => void; +} + +const TONE_STYLES: Record = { + info: "border-blue-500/30 bg-blue-500/5", + decision: "border-violet-500/40 bg-violet-500/5", + risk: "border-rose-500/40 bg-rose-500/5", + warning: "border-amber-500/40 bg-amber-500/5", + success: "border-emerald-500/40 bg-emerald-500/5", +}; + +function BlockTitle({ + title, + summary, +}: { + title?: string | undefined; + summary?: string | undefined; +}) { + if (!title && !summary) return null; + return ( +
+ {title ?
{title}
: null} + {summary ?
{summary}
: null} +
+ ); +} + +function LeafBlockView({ block }: { block: PlanLeafBlock }) { + switch (block.type) { + case "rich-text": + return ; + + case "callout": + return ( +
+ {block.data.tone ? ( + + {block.data.tone} + + ) : null} + {block.data.body} +
+ ); + + case "checklist": + return ( +
    + {block.data.items.map((item) => ( +
  • + + {item.checked ? "✓" : ""} + + + {item.label} + {item.note ? ( + — {item.note} + ) : null} + +
  • + ))} +
+ ); + + case "table": + return ( +
+ + + + {block.data.columns.map((col, i) => ( + + ))} + + + + {block.data.rows.map((row, ri) => ( + + {row.map((cell, ci) => ( + + ))} + + ))} + +
+ {col} +
+ {cell} +
+
+ ); + + case "code": + return ( + + ); + + case "annotated-code": + return ( +
+ {block.data.filename ? ( +
+ {block.data.filename} +
+ ) : null} + + {block.data.annotations && block.data.annotations.length > 0 ? ( +
    + {block.data.annotations.map((a, i) => ( +
  • + L{a.lines} + {a.label ? {a.label} : null} — {a.note} +
  • + ))} +
+ ) : null} +
+ ); + + case "file-tree": + return ( +
    + {block.data.entries.map((entry, i) => ( +
  • + {entry.change ? ( + + {entry.change} + + ) : null} + {entry.path} + {entry.note ? — {entry.note} : null} +
  • + ))} +
+ ); + + case "implementation-map": + return ( +
    + {block.data.files.map((file, i) => ( +
  • + {file.path} + {file.title ? ( + ({file.title}) + ) : null} +
    {file.note}
    +
  • + ))} +
+ ); + + case "api-endpoint": + return ( +
+
+ {block.data.method}{" "} + {block.data.path} +
+ {block.data.summary ? ( +
{block.data.summary}
+ ) : null} +
+ ); + + case "data-model": + return ( +
+ {block.data.entities.map((entity) => ( +
+
{entity.name}
+
    + {entity.fields.map((field, i) => ( +
  • + {field.name} + {field.type ? `: ${field.type}` : ""} + {field.pk ? " (pk)" : ""} + {field.fk ? " (fk)" : ""} +
  • + ))} +
+
+ ))} +
+ ); + + case "question-form": + return ( +
+ {block.data.questions.map((q) => ( +
+
{q.title}
+ {q.subtitle ? ( +
{q.subtitle}
+ ) : null} + {q.options && q.options.length > 0 ? ( +
    + {q.options.map((opt) => ( +
  • + {opt.label} +
  • + ))} +
+ ) : null} +
+ ))} +
+ ); + + case "diagram": + case "custom-html": + // Inert HTML/CSS is rendered in Phase 2 (sanitized). For now show a caption. + return ( +
+ {block.data.caption ?? "Visual block (renders in a later phase)"} +
+ ); + } +} + +function BlockView({ block }: { block: PlanBlock }) { + if (block.type === "tabs") { + return ( +
+ {block.data.tabs.map((tab) => ( +
+
{tab.label}
+
+ {tab.blocks.map((child) => ( + + ))} +
+
+ ))} +
+ ); + } + if (block.type === "columns") { + return ( +
+ {block.data.columns.map((column) => ( +
+ {column.label ? ( +
{column.label}
+ ) : null} + {column.blocks.map((child) => ( + + ))} +
+ ))} +
+ ); + } + return ; +} + +function VisualPlanDocument({ content }: { content: PlanContent }) { + return ( +
+ {content.title ? ( +

{content.title}

+ ) : null} + {content.brief ? ( +

{content.brief}

+ ) : null} + {content.blocks.map((block) => ( +
+ + +
+ ))} +
+ ); +} + +function VisualPlanPanel({ visualPlan, mode, onClose }: VisualPlanPanelProps) { + const content = useMemo(() => visualPlan?.content ?? null, [visualPlan]); + + return ( +
+
+ + Visual Plan + + +
+ + + {content ? ( + + ) : ( +
+
No visual plan yet
+

+ Ask the agent to “render a visual plan” — it will appear here, ready to review. +

+
+ )} +
+
+ ); +} + +export default memo(VisualPlanPanel); +export type { VisualPlanPanelProps }; diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 6290657ca61..ec1014d9d54 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -86,6 +86,7 @@ import { toastManager } from "../ui/toast"; import { BotIcon, CircleAlertIcon, + LayoutDashboardIcon, ListTodoIcon, type LucideIcon, LockIcon, @@ -188,10 +189,13 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop planSidebarLabel: string; planSidebarOpen: boolean; delamainSidebarOpen: boolean; + visualPlanOpen: boolean; + hasVisualPlan: boolean; onToggleInteractionMode: () => void; onRuntimeModeChange: (mode: RuntimeMode) => void; onTogglePlanSidebar: () => void; onToggleDelamainSidebar: () => void; + onToggleVisualPlan: () => void; }) { const runtimeModeOption = runtimeModeConfig[props.runtimeMode]; const RuntimeModeIcon = runtimeModeOption.icon; @@ -306,6 +310,25 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop ) : null} + + {props.hasVisualPlan ? ( + + ) : null} ); }); @@ -449,6 +472,8 @@ export interface ChatComposerProps { sidebarProposedPlan: { turnId?: TurnId } | null; planSidebarLabel: string; planSidebarOpen: boolean; + visualPlanOpen: boolean; + hasVisualPlan: boolean; hasDeployedDelamainPeers: boolean; delamainSidebarOpen: boolean; @@ -507,6 +532,7 @@ export interface ChatComposerProps { handleInteractionModeChange: (mode: ProviderInteractionMode) => void; togglePlanSidebar: () => void; toggleDelamainSidebar: () => void; + toggleVisualPlan: () => void; focusComposer: () => void; scheduleComposerFocus: () => void; @@ -550,6 +576,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) sidebarProposedPlan, planSidebarLabel, planSidebarOpen, + visualPlanOpen, + hasVisualPlan, hasDeployedDelamainPeers, delamainSidebarOpen, runtimeMode, @@ -584,6 +612,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) handleInteractionModeChange, togglePlanSidebar, toggleDelamainSidebar, + toggleVisualPlan, focusComposer, scheduleComposerFocus, setThreadError, @@ -2431,10 +2460,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) planSidebarLabel={planSidebarLabel} planSidebarOpen={planSidebarOpen} delamainSidebarOpen={delamainSidebarOpen} + visualPlanOpen={visualPlanOpen} + hasVisualPlan={hasVisualPlan} onToggleInteractionMode={toggleInteractionMode} onRuntimeModeChange={handleRuntimeModeChange} onTogglePlanSidebar={togglePlanSidebar} onToggleDelamainSidebar={toggleDelamainSidebar} + onToggleVisualPlan={toggleVisualPlan} /> )} diff --git a/apps/web/src/environmentGrouping.test.ts b/apps/web/src/environmentGrouping.test.ts index 8dd5eb89a08..f2248fe7b3b 100644 --- a/apps/web/src/environmentGrouping.test.ts +++ b/apps/web/src/environmentGrouping.test.ts @@ -92,6 +92,7 @@ function makeEmptyEnvironmentState(): EnvironmentState { activityByThreadId: {}, proposedPlanIdsByThreadId: {}, proposedPlanByThreadId: {}, + visualPlanByThreadId: {}, turnDiffIdsByThreadId: {}, turnDiffSummaryByThreadId: {}, sidebarThreadSummaryById: {}, diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 358a754bbf6..1ca57e7d98f 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -595,3 +595,19 @@ label:has(> select#reasoning-effort) select { .dark .model-picker-list::-webkit-scrollbar-thumb:hover { background: rgba(255, 255, 255, 0.15); } + +/* Visual plan wireframe tokens — alias the semantic theme so wireframe HTML + (Phase 3) themes for free in light/dark. */ +.visual-plan-surface { + --wf-ink: var(--foreground); + --wf-muted: var(--muted-foreground); + --wf-line: var(--border); + --wf-paper: var(--background); + --wf-card: var(--card); + --wf-accent: var(--primary); + --wf-accent-fg: var(--primary-foreground); + --wf-accent-soft: color-mix(in oklch, var(--primary) 12%, transparent); + --wf-warn: var(--warning); + --wf-ok: var(--success); + --wf-radius: var(--radius); +} diff --git a/apps/web/src/lib/threadSort.test.ts b/apps/web/src/lib/threadSort.test.ts index 88e9617862b..81f109af2c9 100644 --- a/apps/web/src/lib/threadSort.test.ts +++ b/apps/web/src/lib/threadSort.test.ts @@ -25,6 +25,7 @@ function makeThread(overrides: Partial = {}): Thread { session: null, messages: [], proposedPlans: [], + visualPlans: [], error: null, createdAt: "2026-03-09T10:00:00.000Z", archivedAt: null, diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts index 59ebd0cea0c..5488cb7ad40 100644 --- a/apps/web/src/store.test.ts +++ b/apps/web/src/store.test.ts @@ -77,6 +77,7 @@ function makeThread(overrides: Partial = {}): Thread { turnDiffSummaries: [], activities: [], proposedPlans: [], + visualPlans: [], error: null, createdAt: "2026-02-13T00:00:00.000Z", archivedAt: null, @@ -165,6 +166,9 @@ function makeState(thread: Thread): AppState { thread.proposedPlans.map((plan) => [plan.id, plan] as const), ) as EnvironmentState["proposedPlanByThreadId"][ThreadId], }, + visualPlanByThreadId: { + [thread.id]: thread.visualPlans, + }, turnDiffIdsByThreadId: { [thread.id]: thread.turnDiffSummaries.map((summary) => summary.turnId), }, @@ -196,6 +200,7 @@ function makeEmptyState(overrides: Partial = {}): A activityByThreadId: {}, proposedPlanIdsByThreadId: {}, proposedPlanByThreadId: {}, + visualPlanByThreadId: {}, turnDiffIdsByThreadId: {}, turnDiffSummaryByThreadId: {}, sidebarThreadSummaryById: {}, diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index 7d995b5ea75..6b1cfe365b6 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -14,6 +14,7 @@ import type { OrchestrationThread, OrchestrationThreadShell, OrchestrationThreadActivity, + OrchestrationVisualPlan, ProjectId, ScopedProjectRef, ScopedThreadRef, @@ -81,6 +82,7 @@ export interface EnvironmentState { activityByThreadId: Record>; proposedPlanIdsByThreadId: Record; proposedPlanByThreadId: Record>; + visualPlanByThreadId: Record; turnDiffIdsByThreadId: Record; turnDiffSummaryByThreadId: Record>; @@ -115,6 +117,7 @@ const initialEnvironmentState: EnvironmentState = { activityByThreadId: {}, proposedPlanIdsByThreadId: {}, proposedPlanByThreadId: {}, + visualPlanByThreadId: {}, turnDiffIdsByThreadId: {}, turnDiffSummaryByThreadId: {}, sidebarThreadSummaryById: {}, @@ -249,6 +252,7 @@ function mapThread(thread: OrchestrationThread, environmentId: EnvironmentId): T session: thread.session ? mapSession(thread.session) : null, messages: thread.messages.map((message) => mapMessage(environmentId, message)), proposedPlans: thread.proposedPlans.map(mapProposedPlan), + visualPlans: thread.visualPlans.map((plan) => ({ ...plan })), error: sanitizeThreadErrorMessage(thread.session?.lastError), createdAt: thread.createdAt, archivedAt: thread.archivedAt, @@ -668,6 +672,16 @@ function writeThreadState( }; } + if (previousThread?.visualPlans !== nextThread.visualPlans) { + nextState = { + ...nextState, + visualPlanByThreadId: { + ...nextState.visualPlanByThreadId, + [nextThread.id]: nextThread.visualPlans, + }, + }; + } + if (previousThread?.turnDiffSummaries !== nextThread.turnDiffSummaries) { const nextTurnDiffSlice = buildTurnDiffSlice(nextThread); nextState = { @@ -808,6 +822,7 @@ function removeThreadState(state: EnvironmentState, threadId: ThreadId): Environ const { [threadId]: _removedPlanIds, ...proposedPlanIdsByThreadId } = state.proposedPlanIdsByThreadId; const { [threadId]: _removedPlans, ...proposedPlanByThreadId } = state.proposedPlanByThreadId; + const { [threadId]: _removedVisualPlans, ...visualPlanByThreadId } = state.visualPlanByThreadId; const { [threadId]: _removedTurnDiffIds, ...turnDiffIdsByThreadId } = state.turnDiffIdsByThreadId; const { [threadId]: _removedTurnDiffs, ...turnDiffSummaryByThreadId } = state.turnDiffSummaryByThreadId; @@ -827,6 +842,7 @@ function removeThreadState(state: EnvironmentState, threadId: ThreadId): Environ activityByThreadId, proposedPlanIdsByThreadId, proposedPlanByThreadId, + visualPlanByThreadId, turnDiffIdsByThreadId, turnDiffSummaryByThreadId, sidebarThreadSummaryById, @@ -1278,6 +1294,7 @@ function applyEnvironmentOrchestrationEvent( deletedAt: null, messages: [], proposedPlans: [], + visualPlans: [], activities: [], checkpoints: [], session: null, @@ -1525,6 +1542,25 @@ function applyEnvironmentOrchestrationEvent( }; }); + case "thread.visual-plan-upserted": + return updateThreadState(state, event.payload.threadId, (thread) => { + const visualPlan = event.payload.visualPlan; + const visualPlans = [ + ...thread.visualPlans.filter((entry) => entry.id !== visualPlan.id), + visualPlan, + ] + .toSorted( + (left, right) => + left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id), + ) + .slice(-MAX_THREAD_PROPOSED_PLANS); + return { + ...thread, + visualPlans, + updatedAt: event.occurredAt, + }; + }); + case "thread.turn-diff-completed": return updateThreadState(state, event.payload.threadId, (thread) => { const checkpoint = mapTurnDiffSummary({ diff --git a/apps/web/src/threadDerivation.ts b/apps/web/src/threadDerivation.ts index 0766f0c8e13..a25ea70ad06 100644 --- a/apps/web/src/threadDerivation.ts +++ b/apps/web/src/threadDerivation.ts @@ -13,6 +13,7 @@ import type { const EMPTY_MESSAGES: ChatMessage[] = []; const EMPTY_ACTIVITIES: Thread["activities"] = []; const EMPTY_PROPOSED_PLANS: ProposedPlan[] = []; +const EMPTY_VISUAL_PLANS: Thread["visualPlans"] = []; const EMPTY_TURN_DIFF_SUMMARIES: TurnDiffSummary[] = []; const EMPTY_MESSAGE_MAP: Record = {}; const EMPTY_ACTIVITY_MAP: Record = {}; @@ -28,6 +29,7 @@ const threadCache = new WeakMap< messages: Thread["messages"]; activities: Thread["activities"]; proposedPlans: Thread["proposedPlans"]; + visualPlans: Thread["visualPlans"]; turnDiffSummaries: Thread["turnDiffSummaries"]; thread: Thread; } @@ -87,6 +89,13 @@ function selectThreadProposedPlans( ); } +function selectThreadVisualPlans( + state: EnvironmentState, + threadId: ThreadId, +): Thread["visualPlans"] { + return state.visualPlanByThreadId[threadId] ?? EMPTY_VISUAL_PLANS; +} + function selectThreadTurnDiffSummaries( state: EnvironmentState, threadId: ThreadId, @@ -112,6 +121,7 @@ export function getThreadFromEnvironmentState( const messages = selectThreadMessages(state, threadId); const activities = selectThreadActivities(state, threadId); const proposedPlans = selectThreadProposedPlans(state, threadId); + const visualPlans = selectThreadVisualPlans(state, threadId); const turnDiffSummaries = selectThreadTurnDiffSummaries(state, threadId); const cached = threadCache.get(shell); @@ -122,6 +132,7 @@ export function getThreadFromEnvironmentState( cached.messages === messages && cached.activities === activities && cached.proposedPlans === proposedPlans && + cached.visualPlans === visualPlans && cached.turnDiffSummaries === turnDiffSummaries ) { return cached.thread; @@ -135,6 +146,7 @@ export function getThreadFromEnvironmentState( messages, activities, proposedPlans, + visualPlans, turnDiffSummaries, }; @@ -144,6 +156,7 @@ export function getThreadFromEnvironmentState( messages, activities, proposedPlans, + visualPlans, turnDiffSummaries, thread, }); diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts index c2e4b235e21..a510ad0f11c 100644 --- a/apps/web/src/types.ts +++ b/apps/web/src/types.ts @@ -2,6 +2,7 @@ import type { EnvironmentId, ModelSelection, OrchestrationLatestTurn, + OrchestrationVisualPlan, OrchestrationProposedPlanId, RepositoryIdentity, OrchestrationSessionStatus, @@ -105,6 +106,7 @@ export interface Thread { session: ThreadSession | null; messages: ChatMessage[]; proposedPlans: ProposedPlan[]; + visualPlans: OrchestrationVisualPlan[]; error: string | null; createdAt: string; archivedAt: string | null; diff --git a/apps/web/src/worktreeCleanup.test.ts b/apps/web/src/worktreeCleanup.test.ts index 9c833c03c74..8d32cc43bca 100644 --- a/apps/web/src/worktreeCleanup.test.ts +++ b/apps/web/src/worktreeCleanup.test.ts @@ -24,6 +24,7 @@ function makeThread(overrides: Partial = {}): Thread { turnDiffSummaries: [], activities: [], proposedPlans: [], + visualPlans: [], error: null, createdAt: "2026-02-13T00:00:00.000Z", archivedAt: null, diff --git a/packages/client-runtime/src/threadDetailReducer.test.ts b/packages/client-runtime/src/threadDetailReducer.test.ts index e8014ebd6a7..750b91d021d 100644 --- a/packages/client-runtime/src/threadDetailReducer.test.ts +++ b/packages/client-runtime/src/threadDetailReducer.test.ts @@ -37,6 +37,7 @@ const baseThread: OrchestrationThread = { deletedAt: null, messages: [], proposedPlans: [], + visualPlans: [], activities: [], checkpoints: [], session: null, diff --git a/packages/client-runtime/src/threadDetailReducer.ts b/packages/client-runtime/src/threadDetailReducer.ts index 125f39428a6..ba0d03761cd 100644 --- a/packages/client-runtime/src/threadDetailReducer.ts +++ b/packages/client-runtime/src/threadDetailReducer.ts @@ -40,6 +40,11 @@ const proposedPlanOrder = O.combine p.id), ); +const visualPlanOrder = O.combine( + O.mapInput(O.String, (p) => p.createdAt), + O.mapInput(O.String, (p) => p.id), +); + const checkpointOrder = O.mapInput( O.Number, (cp: OrchestrationThread["checkpoints"][number]) => @@ -93,6 +98,7 @@ export function applyThreadDetailEvent( deletedAt: null, messages: [], proposedPlans: [], + visualPlans: [], activities: [], checkpoints: [], session: null, @@ -354,6 +360,24 @@ export function applyThreadDetailEvent( }; } + // ── Visual plans ──────────────────────────────────────────────── + case "thread.visual-plan-upserted": { + const visualPlan = event.payload.visualPlan; + + const visualPlans = pipe( + thread.visualPlans, + Arr.filter((entry) => entry.id !== visualPlan.id), + Arr.append(visualPlan), + Arr.sort(visualPlanOrder), + Arr.takeRight(limits.maxProposedPlans), + ); + + return { + kind: "updated", + thread: { ...thread, visualPlans, updatedAt: event.occurredAt }, + }; + } + // ── Checkpoints / turn diffs ──────────────────────────────────── case "thread.turn-diff-completed": { const checkpoint: OrchestrationCheckpointSummary = { diff --git a/packages/client-runtime/src/threadDetailState.test.ts b/packages/client-runtime/src/threadDetailState.test.ts index 9545e0729c1..d8c70f8407c 100644 --- a/packages/client-runtime/src/threadDetailState.test.ts +++ b/packages/client-runtime/src/threadDetailState.test.ts @@ -53,6 +53,7 @@ const BASE_THREAD: OrchestrationThread = { deletedAt: null, messages: [], proposedPlans: [], + visualPlans: [], activities: [], checkpoints: [], session: null, diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 7f8b51aeaf6..a5f173bc362 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -15,6 +15,7 @@ export * from "./settings.ts"; export * from "./git.ts"; export * from "./vcs.ts"; export * from "./sourceControl.ts"; +export * from "./visualPlan.ts"; export * from "./orchestration.ts"; export * from "./editor.ts"; export * from "./project.ts"; diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 401928171c8..4071baa5cc0 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -21,6 +21,7 @@ import { TurnId, } from "./baseSchemas.ts"; import { ProviderInstanceId } from "./providerInstance.ts"; +import { PlanComment, PlanContent } from "./visualPlan.ts"; export const ORCHESTRATION_WS_METHODS = { dispatchCommand: "orchestration.dispatchCommand", @@ -246,6 +247,19 @@ const SourceProposedPlanReference = Schema.Struct({ planId: OrchestrationProposedPlanId, }); +export const OrchestrationVisualPlanId = TrimmedNonEmptyString; +export type OrchestrationVisualPlanId = typeof OrchestrationVisualPlanId.Type; + +export const OrchestrationVisualPlan = Schema.Struct({ + id: OrchestrationVisualPlanId, + turnId: Schema.NullOr(TurnId), + content: PlanContent, + comments: Schema.Array(PlanComment).pipe(Schema.withDecodingDefault(Effect.succeed([]))), + createdAt: IsoDateTime, + updatedAt: IsoDateTime, +}); +export type OrchestrationVisualPlan = typeof OrchestrationVisualPlan.Type; + export const OrchestrationSessionStatus = Schema.Literals([ "idle", "starting", @@ -350,6 +364,9 @@ export const OrchestrationThread = Schema.Struct({ proposedPlans: Schema.Array(OrchestrationProposedPlan).pipe( Schema.withDecodingDefault(Effect.succeed([])), ), + visualPlans: Schema.Array(OrchestrationVisualPlan).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + ), activities: Schema.Array(OrchestrationThreadActivity), checkpoints: Schema.Array(OrchestrationCheckpointSummary), session: Schema.NullOr(OrchestrationSession), @@ -721,6 +738,14 @@ const ThreadProposedPlanUpsertCommand = Schema.Struct({ createdAt: IsoDateTime, }); +const ThreadVisualPlanUpsertCommand = Schema.Struct({ + type: Schema.Literal("thread.visual-plan.upsert"), + commandId: CommandId, + threadId: ThreadId, + visualPlan: OrchestrationVisualPlan, + createdAt: IsoDateTime, +}); + const ThreadTurnDiffCompleteCommand = Schema.Struct({ type: Schema.Literal("thread.turn.diff.complete"), commandId: CommandId, @@ -756,6 +781,7 @@ const InternalOrchestrationCommand = Schema.Union([ ThreadMessageAssistantDeltaCommand, ThreadMessageAssistantCompleteCommand, ThreadProposedPlanUpsertCommand, + ThreadVisualPlanUpsertCommand, ThreadTurnDiffCompleteCommand, ThreadActivityAppendCommand, ThreadRevertCompleteCommand, @@ -789,6 +815,7 @@ export const OrchestrationEventType = Schema.Literals([ "thread.session-stop-requested", "thread.session-set", "thread.proposed-plan-upserted", + "thread.visual-plan-upserted", "thread.turn-diff-completed", "thread.activity-appended", ]); @@ -949,6 +976,11 @@ export const ThreadProposedPlanUpsertedPayload = Schema.Struct({ proposedPlan: OrchestrationProposedPlan, }); +export const ThreadVisualPlanUpsertedPayload = Schema.Struct({ + threadId: ThreadId, + visualPlan: OrchestrationVisualPlan, +}); + export const ThreadTurnDiffCompletedPayload = Schema.Struct({ threadId: ThreadId, turnId: TurnId, @@ -1087,6 +1119,11 @@ export const OrchestrationEvent = Schema.Union([ type: Schema.Literal("thread.proposed-plan-upserted"), payload: ThreadProposedPlanUpsertedPayload, }), + Schema.Struct({ + ...EventBaseFields, + type: Schema.Literal("thread.visual-plan-upserted"), + payload: ThreadVisualPlanUpsertedPayload, + }), Schema.Struct({ ...EventBaseFields, type: Schema.Literal("thread.turn-diff-completed"), diff --git a/packages/contracts/src/visualPlan.ts b/packages/contracts/src/visualPlan.ts new file mode 100644 index 00000000000..c9f0824caf6 --- /dev/null +++ b/packages/contracts/src/visualPlan.ts @@ -0,0 +1,407 @@ +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import { IsoDateTime, TrimmedNonEmptyString } from "./baseSchemas.ts"; + +/** + * Visual plan content model — a GITS-native reimplementation of the + * builder.io `visual-plan` block registry. The agent authors a `PlanContent` + * document through the visual-plan MCP tools; the GITS web app renders it as + * an interactive side panel. + * + * v1 renders the document blocks below. `diagram`/`custom-html` are defined + * here but rendered in Phase 2; `wireframe`/`prototype`/`canvas` arrive in + * Phase 3 and are intentionally not part of the v1 union yet. + */ + +export const PlanBlockId = TrimmedNonEmptyString; +export type PlanBlockId = typeof PlanBlockId.Type; + +const blockBase = { + id: PlanBlockId, + title: Schema.optional(Schema.String), + summary: Schema.optional(Schema.String), + editable: Schema.optional(Schema.Boolean), +}; + +export const PlanRichTextBlock = Schema.Struct({ + ...blockBase, + type: Schema.Literal("rich-text"), + data: Schema.Struct({ markdown: Schema.String }), +}); + +export const PlanCalloutTone = Schema.Literals(["info", "decision", "risk", "warning", "success"]); +export type PlanCalloutTone = typeof PlanCalloutTone.Type; +export const PlanCalloutBlock = Schema.Struct({ + ...blockBase, + type: Schema.Literal("callout"), + data: Schema.Struct({ + tone: Schema.optional(PlanCalloutTone), + body: Schema.String, + }), +}); + +export const PlanChecklistItem = Schema.Struct({ + id: PlanBlockId, + label: Schema.String, + checked: Schema.optional(Schema.Boolean), + note: Schema.optional(Schema.String), +}); +export const PlanChecklistBlock = Schema.Struct({ + ...blockBase, + type: Schema.Literal("checklist"), + data: Schema.Struct({ items: Schema.Array(PlanChecklistItem) }), +}); + +export const PlanTableDensity = Schema.Literals(["compact", "normal", "relaxed"]); +export const PlanTableBlock = Schema.Struct({ + ...blockBase, + type: Schema.Literal("table"), + data: Schema.Struct({ + columns: Schema.Array(Schema.String), + rows: Schema.Array(Schema.Array(Schema.String)), + density: Schema.optional(PlanTableDensity), + }), +}); + +export const PlanCodeBlock = Schema.Struct({ + ...blockBase, + type: Schema.Literal("code"), + data: Schema.Struct({ + language: Schema.optional(Schema.String), + code: Schema.String, + filename: Schema.optional(Schema.String), + caption: Schema.optional(Schema.String), + }), +}); + +export const PlanCodeAnnotation = Schema.Struct({ + lines: Schema.String, + label: Schema.optional(Schema.String), + note: Schema.String, +}); +export const PlanAnnotatedCodeBlock = Schema.Struct({ + ...blockBase, + type: Schema.Literal("annotated-code"), + data: Schema.Struct({ + filename: Schema.optional(Schema.String), + language: Schema.optional(Schema.String), + code: Schema.String, + annotations: Schema.optional(Schema.Array(PlanCodeAnnotation)), + }), +}); + +export const PlanChangeKind = Schema.Literals(["added", "modified", "removed", "renamed"]); +export const PlanFileTreeEntry = Schema.Struct({ + path: Schema.String, + change: Schema.optional(PlanChangeKind), + note: Schema.optional(Schema.String), +}); +export const PlanFileTreeBlock = Schema.Struct({ + ...blockBase, + type: Schema.Literal("file-tree"), + data: Schema.Struct({ entries: Schema.Array(PlanFileTreeEntry) }), +}); + +export const PlanImplementationMapFile = Schema.Struct({ + path: Schema.String, + title: Schema.optional(Schema.String), + note: Schema.String, + language: Schema.optional(Schema.String), + snippet: Schema.optional(Schema.String), +}); +export const PlanImplementationMapBlock = Schema.Struct({ + ...blockBase, + type: Schema.Literal("implementation-map"), + data: Schema.Struct({ files: Schema.Array(PlanImplementationMapFile) }), +}); + +export const PlanApiMethod = Schema.Literals([ + "GET", + "POST", + "PUT", + "PATCH", + "DELETE", + "HEAD", + "OPTIONS", +]); +export const PlanApiParam = Schema.Struct({ + name: Schema.String, + in: Schema.Literals(["path", "query", "header", "body"]), + type: Schema.optional(Schema.String), + required: Schema.optional(Schema.Boolean), + description: Schema.optional(Schema.String), +}); +export const PlanApiResponse = Schema.Struct({ + status: Schema.String, + description: Schema.optional(Schema.String), + example: Schema.optional(Schema.String), +}); +export const PlanApiEndpointBlock = Schema.Struct({ + ...blockBase, + type: Schema.Literal("api-endpoint"), + data: Schema.Struct({ + method: PlanApiMethod, + path: Schema.String, + summary: Schema.optional(Schema.String), + description: Schema.optional(Schema.String), + params: Schema.optional(Schema.Array(PlanApiParam)), + responses: Schema.optional(Schema.Array(PlanApiResponse)), + }), +}); + +export const PlanDataModelField = Schema.Struct({ + name: Schema.String, + type: Schema.optional(Schema.String), + pk: Schema.optional(Schema.Boolean), + fk: Schema.optional(Schema.Boolean), + nullable: Schema.optional(Schema.Boolean), + note: Schema.optional(Schema.String), +}); +export const PlanDataModelEntity = Schema.Struct({ + id: PlanBlockId, + name: Schema.String, + note: Schema.optional(Schema.String), + fields: Schema.Array(PlanDataModelField), +}); +export const PlanDataModelRelation = Schema.Struct({ + from: Schema.String, + to: Schema.String, + kind: Schema.optional(Schema.Literals(["1-1", "1-n", "n-n"])), + label: Schema.optional(Schema.String), +}); +export const PlanDataModelBlock = Schema.Struct({ + ...blockBase, + type: Schema.Literal("data-model"), + data: Schema.Struct({ + entities: Schema.Array(PlanDataModelEntity), + relations: Schema.optional(Schema.Array(PlanDataModelRelation)), + }), +}); + +export const PlanQuestionMode = Schema.Literals(["single", "multi", "freeform"]); +export const PlanQuestionOption = Schema.Struct({ + id: PlanBlockId, + label: Schema.String, + recommended: Schema.optional(Schema.Boolean), +}); +export const PlanQuestion = Schema.Struct({ + id: PlanBlockId, + title: Schema.String, + subtitle: Schema.optional(Schema.String), + mode: PlanQuestionMode, + options: Schema.optional(Schema.Array(PlanQuestionOption)), + allowOther: Schema.optional(Schema.Boolean), + placeholder: Schema.optional(Schema.String), + required: Schema.optional(Schema.Boolean), +}); +export type PlanQuestion = typeof PlanQuestion.Type; +export const PlanQuestionFormBlock = Schema.Struct({ + ...blockBase, + type: Schema.Literal("question-form"), + data: Schema.Struct({ + questions: Schema.Array(PlanQuestion), + submitLabel: Schema.optional(Schema.String), + }), +}); + +// Inert scoped HTML/CSS — rendered in Phase 2 (no