From aabf4cdceaac315a0a4dccca5f2e7c24be3f6f91 Mon Sep 17 00:00:00 2001 From: Tamim Bin Hakim <184807161+tamimbinhakim@users.noreply.github.com> Date: Fri, 3 Jul 2026 23:57:16 +0600 Subject: [PATCH] Render Mosaic artifacts as native interactive UI Detect a ```mosaic fence in an agent reply and draw it as a live, interactive interface using T3 Code's own components. The artifact is data, not code: a bounded expression language drives derived values on the client, and anything beyond local state leaves as a named host intent. Web: the chat/mosaic renderer (streaming, onIntent, validate -> autocorrect, raw-source fallback), the full host block set drawn through the ui kit, and ChatMarkdown fence routing. Server: the mosaic_ls/cat/validate MCP tools and the Mosaic agent skill provisioned into each provider's skills directory (Claude, Codex). Adds @mosaicjs/{core,react} to apps/web and @mosaicjs/ai to apps/server. --- apps/server/package.json | 1 + apps/server/src/mcp/McpHttpServer.ts | 7 + .../src/mcp/toolkits/mosaic/handlers.ts | 13 + .../src/mcp/toolkits/mosaic/tools.test.ts | 32 + apps/server/src/mcp/toolkits/mosaic/tools.ts | 52 + .../src/provider/Drivers/ClaudeDriver.ts | 17 +- .../src/provider/Drivers/CodexDriver.ts | 11 + apps/server/src/provider/MosaicSkill.ts | 260 +++ apps/web/package.json | 2 + apps/web/src/components/ChatMarkdown.tsx | 14 + .../chat/mosaic/MosaicArtifact.test.tsx | 263 +++ .../components/chat/mosaic/MosaicArtifact.tsx | 98 + .../src/components/chat/mosaic/autocorrect.ts | 50 + .../web/src/components/chat/mosaic/blocks.tsx | 1923 +++++++++++++++++ apps/web/src/components/chat/mosaic/index.ts | 15 + .../web/src/components/chat/mosaic/intent.tsx | 43 + pnpm-lock.yaml | 47 + 17 files changed, 2847 insertions(+), 1 deletion(-) create mode 100644 apps/server/src/mcp/toolkits/mosaic/handlers.ts create mode 100644 apps/server/src/mcp/toolkits/mosaic/tools.test.ts create mode 100644 apps/server/src/mcp/toolkits/mosaic/tools.ts create mode 100644 apps/server/src/provider/MosaicSkill.ts create mode 100644 apps/web/src/components/chat/mosaic/MosaicArtifact.test.tsx create mode 100644 apps/web/src/components/chat/mosaic/MosaicArtifact.tsx create mode 100644 apps/web/src/components/chat/mosaic/autocorrect.ts create mode 100644 apps/web/src/components/chat/mosaic/blocks.tsx create mode 100644 apps/web/src/components/chat/mosaic/index.ts create mode 100644 apps/web/src/components/chat/mosaic/intent.tsx diff --git a/apps/server/package.json b/apps/server/package.json index d0903c77d75..66802625bff 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -28,6 +28,7 @@ "@effect/platform-node-shared": "catalog:", "@effect/sql-sqlite-bun": "catalog:", "@ff-labs/fff-node": "0.9.4", + "@mosaicjs/ai": "^0.8.0", "@opencode-ai/sdk": "^1.3.15", "@pierre/diffs": "catalog:", "effect": "catalog:", diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index 6774731a73e..3f4ab97dffd 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -22,6 +22,8 @@ import { PreviewSnapshotToolkit, PreviewStandardToolkit, } from "./toolkits/preview/tools.ts"; +import { MosaicToolkitHandlersLive } from "./toolkits/mosaic/handlers.ts"; +import { MosaicToolkit } from "./toolkits/mosaic/tools.ts"; const unauthorized = HttpServerResponse.jsonUnsafe( { @@ -203,9 +205,14 @@ const PreviewSnapshotRegistrationLive = Layer.effectDiscard(registerPreviewSnaps Layer.provide(PreviewSnapshotToolkitHandlersLive), ); +const MosaicToolkitRegistrationLive = McpServer.toolkit(MosaicToolkit).pipe( + Layer.provide(MosaicToolkitHandlersLive), +); + export const PreviewToolkitRegistrationLive = Layer.mergeAll( PreviewStandardToolkitRegistrationLive, PreviewSnapshotRegistrationLive, + MosaicToolkitRegistrationLive, ); const McpTransportLive = McpServer.layerHttp({ diff --git a/apps/server/src/mcp/toolkits/mosaic/handlers.ts b/apps/server/src/mcp/toolkits/mosaic/handlers.ts new file mode 100644 index 00000000000..9a52c015361 --- /dev/null +++ b/apps/server/src/mcp/toolkits/mosaic/handlers.ts @@ -0,0 +1,13 @@ +import { catBlock, lsBlocks, validateSource } from "@mosaicjs/ai"; +import * as Effect from "effect/Effect"; + +import { MosaicToolkit } from "./tools.ts"; + +// The Mosaic introspection handlers are pure functions over @mosaicjs/core, so +// each tool just wraps a synchronous result in Effect.succeed - no dependencies, +// no capability gate, no I/O. +export const MosaicToolkitHandlersLive = MosaicToolkit.toLayer({ + mosaic_ls: (input) => Effect.succeed(lsBlocks(input.kind)), + mosaic_cat: (input) => Effect.succeed(catBlock(input.block)), + mosaic_validate: (input) => Effect.succeed(validateSource(input.source)), +}); diff --git a/apps/server/src/mcp/toolkits/mosaic/tools.test.ts b/apps/server/src/mcp/toolkits/mosaic/tools.test.ts new file mode 100644 index 00000000000..7e8d648b8f4 --- /dev/null +++ b/apps/server/src/mcp/toolkits/mosaic/tools.test.ts @@ -0,0 +1,32 @@ +import { expect, it } from "@effect/vitest"; +import { Tool } from "effect/unstable/ai"; + +import { MosaicToolkit } from "./tools.ts"; + +// A provider (Anthropic/OpenAI) rejects an MCP tool whose parameters are not a +// top-level `{ type: "object" }` schema - which is exactly what an empty +// Schema.Struct({}) produced for mosaic_ls. Pin that every Mosaic tool exports a +// provider-compatible object schema so the tools actually load into the model. +it("exports provider-compatible object schemas for every Mosaic tool", () => { + const tools = Object.values(MosaicToolkit.tools); + expect(tools.length).toBe(3); + for (const tool of tools) { + const schema = Tool.getJsonSchema(tool) as { + readonly type?: unknown; + readonly properties?: Readonly>; + readonly anyOf?: unknown; + readonly oneOf?: unknown; + }; + expect(schema.type, `${tool.name} must export a top-level object schema`).toBe("object"); + expect(schema.anyOf, `${tool.name} must not export a root anyOf`).toBeUndefined(); + expect(schema.oneOf, `${tool.name} must not export a root oneOf`).toBeUndefined(); + expect( + schema.properties, + `${tool.name} must expose a properties object`, + ).toBeTypeOf("object"); + expect( + tool.description?.length ?? 0, + `${tool.name} should carry a useful description`, + ).toBeGreaterThan(40); + } +}); diff --git a/apps/server/src/mcp/toolkits/mosaic/tools.ts b/apps/server/src/mcp/toolkits/mosaic/tools.ts new file mode 100644 index 00000000000..02bb91f449d --- /dev/null +++ b/apps/server/src/mcp/toolkits/mosaic/tools.ts @@ -0,0 +1,52 @@ +import * as Schema from "effect/Schema"; +import { Tool, Toolkit } from "effect/unstable/ai"; + +// Mosaic introspection tools, surfaced to the agent over T3 Code's MCP host so +// it can learn a block's exact schema (and validate a draft) before emitting a +// ```mosaic artifact - instead of guessing prop shapes. The tool logic lives in +// @mosaicjs/ai; these are the read-only, side-effect-free MCP wrappers. + +const introspectionTool = (tool: T): T => + tool + .annotate(Tool.Readonly, true) + .annotate(Tool.Destructive, false) + .annotate(Tool.Idempotent, true) as T; + +export const MosaicLsTool = introspectionTool( + Tool.make("mosaic_ls", { + description: + "List every Mosaic block you can compose a ```mosaic artifact from, grouped by kind. Call this first when building a Mosaic artifact to see what is available. Pass kind to narrow to one group.", + parameters: Schema.Struct({ + kind: Schema.optional( + Schema.String.annotate({ + description: "Optional: one of layout, content, control, structure, data.", + }), + ), + }), + success: Schema.String, + }).annotate(Tool.Title, "List Mosaic blocks"), +); + +export const MosaicCatTool = introspectionTool( + Tool.make("mosaic_cat", { + description: + "Show one Mosaic block's full prop schema - prop names, types, enum values, nested shapes, which are required - plus a minimal example. Call before using a block you are unsure about (DataTable, Chart, Diagram, Timeline, Tabs) so you write the exact schema instead of guessing.", + parameters: Schema.Struct({ + block: Schema.String.annotate({ description: 'The block name, e.g. "DataTable".' }), + }), + success: Schema.String, + }).annotate(Tool.Title, "Show a Mosaic block schema"), +); + +export const MosaicValidateTool = introspectionTool( + Tool.make("mosaic_validate", { + description: + "Compile and validate a Mosaic artifact (the mosaic-jsx inside a ```mosaic fence, or the bare source) and return every error, or confirm it is sound. Run this on your draft before emitting so mistakes are caught and fixed first.", + parameters: Schema.Struct({ + source: Schema.String.annotate({ description: "The mosaic-jsx source to validate." }), + }), + success: Schema.String, + }).annotate(Tool.Title, "Validate a Mosaic artifact"), +); + +export const MosaicToolkit = Toolkit.make(MosaicLsTool, MosaicCatTool, MosaicValidateTool); diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index f2b04b3a282..1573415ced5 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -53,7 +53,12 @@ import { makeProviderSnapshotSettingsSource, type ProviderSnapshotSettings, } from "../providerUpdateSettings.ts"; -import { makeClaudeCapabilitiesCacheKey, makeClaudeContinuationGroupKey } from "./ClaudeHome.ts"; +import { + makeClaudeCapabilitiesCacheKey, + makeClaudeContinuationGroupKey, + resolveClaudeHomePath, +} from "./ClaudeHome.ts"; +import { provisionMosaicSkill } from "../MosaicSkill.ts"; const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings); const DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); @@ -128,6 +133,16 @@ export const ClaudeDriver: ProviderDriver = { instanceId, }); const effectiveConfig = { ...config, enabled } satisfies ClaudeSettings; + + // Deliver the Mosaic skill natively into /.claude/skills, where the + // Claude Code SDK discovers it (settingSources includes "user"). Best + // effort: a skill-write failure must never keep the provider from coming + // up. + const claudeHome = yield* resolveClaudeHomePath(effectiveConfig); + yield* provisionMosaicSkill(path.join(claudeHome, ".claude", "skills")).pipe( + Effect.catch((cause) => Effect.logWarning("Failed to provision Mosaic skill.", { cause })), + ); + const maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(UPDATE, { binaryPath: effectiveConfig.binaryPath, env: processEnv, diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index ffcc94ca77d..75f892201e2 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -57,6 +57,7 @@ import { materializeCodexShadowHome, resolveCodexHomeLayout, } from "./CodexHomeLayout.ts"; +import { provisionMosaicSkill } from "../MosaicSkill.ts"; const decodeCodexSettings = Schema.decodeSync(CodexSettings); const DRIVER_KIND = ProviderDriverKind.make("codex"); @@ -139,6 +140,16 @@ export const CodexDriver: ProviderDriver = { }), ), ); + + // Deliver the Mosaic skill natively into the Codex home's skills dir, + // where Codex's `skills/list` discovers it (KNOWN_SHARED_DIRECTORIES + // symlinks it into the shadow home too). Best effort: a skill-write + // failure must never keep the provider from coming up. + const codexSkillsDir = (yield* Path.Path).join(homeLayout.sharedHomePath, "skills"); + yield* provisionMosaicSkill(codexSkillsDir).pipe( + Effect.catch((cause) => Effect.logWarning("Failed to provision Mosaic skill.", { cause })), + ); + const effectiveConfig = { ...config, enabled, diff --git a/apps/server/src/provider/MosaicSkill.ts b/apps/server/src/provider/MosaicSkill.ts new file mode 100644 index 00000000000..b4932de0cfc --- /dev/null +++ b/apps/server/src/provider/MosaicSkill.ts @@ -0,0 +1,260 @@ +import * as Effect from "effect/Effect"; +import * as Path from "effect/Path"; + +import { writeFileStringAtomically } from "../atomicWrite.ts"; + +// The Mosaic agent skill (the "with MCP" variant): written into each provider's +// own skills directory so the agent runtime discovers it natively and +// auto-activates from the description - no always-on prompt injection. +// Provisioned once per provider instance (idempotent). +// +// This variant references T3 Code's mosaic_ls/cat/validate MCP tools and gates +// emission on mosaic_validate; the portable format-only variant lives upstream +// in the mosaic repo (skills/mosaic). Every code snippet in the skill compiles +// through the real @mosaicjs parser. Keep in sync with the upstream skill and +// the web block kit (apps/web/src/components/chat/mosaic/blocks.tsx). + +export const MOSAIC_SKILL_NAME = "mosaic"; + +export const MOSAIC_SKILL_MD = `--- +name: mosaic +description: Render the reply as a Mosaic artifact - a live, interactive interface the host app draws natively in its own design - instead of prose. Use when the answer is spatial or interactive: visualizing an architecture or flow, mocking a screen, comparing options, laying out a plan, charting data, or building an estimator or dashboard - or when the user says mosaic or asks to see an interface. +--- + +# Emitting Mosaic + +You write standard JSX from a fixed block vocabulary; the host draws every block with its own components, and interaction runs locally (a slider drives a total with no round-trip to you). +The JSX is **data, never code**: expressions run in a bounded, pure interpreter, and events become named **intents** the host brokers. + +If prose reads just as well linearly, write prose. +One artifact per reply, commentary outside the fence. +Fence it as \` \`\`\`mosaic v=1 id=kebab-id \` and reuse the same \`id\` when regenerating so the host replaces the artifact in place. + +## Compose + +1. **Bake the state.** + Declare every value interaction touches in the root's \`state={{…}}\` (a literal object). + Everything the artifact shows lives in its props at emit time; nothing fetches or refreshes later - never a spinner. + Done when no expression reads a name missing from \`state\` or a \`.map\` binding. +2. **Lay out from blocks.** + Nest \`Stack\` / \`Grid\` / \`Card\`; pick from the vocabulary below. + There is no \`\` tag - a plan is \`Steps\` + \`Timeline\` + a \`DataTable\`. + Done when every tag is in the block list and every repeated shape is one \`.map\`, not copy-paste. + Unsure about props? \`mosaic_cat\` takes one or more blocks ("DataTable, Chart, Stack") and returns exact schemas; \`mosaic_ls\` lists every block. + Your client may namespace these tools (e.g. \`mcp____mosaic_cat\`); if a bare name is not found, search your tool registry for \`mosaic\` once and use the full names it returns. +3. **Wire the interaction.** + \`value={path}\` / \`checked={path}\` two-way binds a control (a computed expression there is read-only). + \`{cond && }\` and ternaries render conditionally; \`{list.map((item) => )}\` repeats. + \`onClick={saveDraft({ total: seats * 16 })}\` hands the host an intent with computed args; \`onClick={toggle(open)}\` / \`onClick={set(count, count + 1)}\` mutate locally - \`set\` takes any expression, evaluated against current state at click time, so counters and calculators work. + Done when everything computable computes locally and every event is an intent or a local mutation. +4. **Validate, then emit.** + Run \`mosaic_validate\` on the draft and fix every reported error. + Done when it returns VALID. + +## Boundaries + +Each of these is a compile error: + +- **Blocks only.** No HTML tags. The host owns the design: no \`className\`, no \`style\`, no raw colors - speak in semantic tokens (\`tone="warn"\`). +- **Bounded expressions.** Arithmetic, comparisons, \`&& || !\`, ternary, template literals, and array methods (\`.map .filter .reduce .sort .slice .join .includes .length\`) work; the function catalog is \`abs min max round floor ceil clamp · len lower upper trim concat substr replace split join contains · formatCurrency formatNumber toFixed · map filter reduce sum count any all sort sortBy slice · has coalesce\`. No assignments, no \`new\`, no regex, no other functions. Unknown names evaluate to null. +- **\`alt\` required** on \`Chart\` and \`Diagram\`. +- **Host chrome stays the host's.** Modals, toasts, and navigation are requested through an intent, never drawn. + +## Blocks + +**Layout.** \`Box\` \`Stack\` \`Grid\` \`Divider\` \`Card\` +**Content.** \`Text\` \`Heading\` \`Markdown\` \`Image\` \`Icon\` \`Link\` \`Badge\` \`Tag\` \`Avatar\` \`AvatarGroup\` \`Code\` \`Callout\` +**Controls.** \`Button\` \`Input\` \`Select\` \`MultiSelect\` \`Autocomplete\` \`Checkbox\` \`Radio\` \`Toggle\` \`Slider\` \`DatePicker\` \`ColorPicker\` \`FilePicker\` \`Rating\` \`TagInput\` \`Field\` \`Disclosure\` \`Accordion\` +**Structure.** \`Tabs\` \`Steps\` \`SegmentedControl\` \`Progress\` \`Empty\` +**Media.** \`Video\` \`Audio\` \`Carousel\` +**Data & viz.** \`DataTable\` \`List\` \`Tree\` \`Board\` \`Timeline\` \`Calendar\` \`Stat\` \`Chart\` \`VegaChart\` \`Diagram\` \`Canvas\` \`Embed\` + +\`mosaic_ls\` marks blocks this host adds beyond the built-ins with \`(host)\`; those exist only in this host, so recompose from primitives if you need to move an artifact elsewhere. + +Structure, not style. +The format carries meaning and structure; the host owns spacing, typography, density, and chrome. +Express structure - sections, rows, groupings - and let the host render it dense. +There is no gap, padding, size, or weight to set: say what a thing *is*, not how big or how far apart. + +- \`Stack\` - \`direction="horizontal"\` for rows; \`justify="between"\` puts text left and actions right on one row; \`align\` sets the cross axis. +- \`Card\` - \`tone\` tints it into an inset status panel (a green "handled" section). +- \`Text\` - \`variant="label"\` is a section micro-label; \`variant="caption"\` is secondary supporting text. Inline emphasis (bold, italic) belongs to \`Markdown\`. +- \`Button\` - \`variant\` is an intent hierarchy: \`primary\` (one per view), \`secondary\`, \`subtle\` (inline row actions), \`danger\` (destructive). +- **Icons are Lucide** (lucide.dev), kebab-case names: \`\`, or a leading icon on a block - \`\`, \`\`, \`\`. Use real Lucide names (\`circle-check\`, \`triangle-alert\`, \`arrow-up-right\`); an unknown name renders nothing. + +Exact shapes for the data blocks: + +- \`DataTable\` - \`columns={["A","B"]}\` and \`rows={[["1","2"],["3","4"]]}\` (positional string arrays, never objects). +- \`Chart\` - \`type\` (\`bar line area donut radar gauge scatter\`), \`data={[{ label, value }]}\`, \`alt\`. +- \`Timeline\` - \`items={[{ date, title, description?, tone? }]}\`. +- \`Stat\` - \`label\` + \`value\`; \`tone\` for the verdict color. +- \`Tabs\` - \`items={["Overview","Docs"]}\` + one child panel per item. +- Tones: \`ok warn bad primary subtle\`. + +## Example + +\`\`\`mosaic v=1 id=seat-estimator + + + + + + + {seats >= 100 && Above 100 seats, Enterprise usually wins.} + + +\`\`\` + +For interaction patterns (per-row selection, detail-on-click diagrams, live filters) and Chart/Diagram sizing, read [REFERENCE.md](REFERENCE.md) before composing anything beyond a simple card. +`; + +export const MOSAIC_SKILL_REFERENCE_MD = `# Mosaic patterns + +Interaction patterns and sizing notes for [the skill](SKILL.md). + +## Action rows (text left, buttons right) + +The approval row - one line, actions right-aligned: + +\`\`\`jsx + + Needs a quick yes + + Pay £340 invoice to Studio Kern + + + + + + + Send reply to Meridian's CEO + + + + + + +\`\`\` + +A toned card makes an inset status panel: + +\`\`\`jsx + + Handled while you slept + Sorted 38 emails, archived 22 newsletters + Drafted 2 replies, waiting in your outbox + +\`\`\` + +## Per-row state (selection lists, checklists) + +Bind through a path with the map index; fold over the same array for the summary: + +\`\`\`jsx + + {files.map((f, i) => ( + + + + ))} + {\`\${files.filter((f) => f.checked).length} of \${files.length} selected\`} + + +\`\`\` + +## Detail-on-click diagram + +\`value={selected}\` on a \`Diagram\` holds the clicked node id (null on background); a \`&&\` sibling swaps the detail panel: + +\`\`\`jsx + + + {selected == "db" && ( + + Postgres + Connection pool saturates under load; the dashed edge is the async path. + + )} + +\`\`\` + +Ids must be unique; every \`edges[].from\`/\`to\` must name one. +Node \`kind\`: \`service store queue client external concept code\`. + +## Scenario switch (no round-trip) + +\`\`\`jsx + + + {audience == "SaaS" && Zep wins: point-in-time recall, no graph to operate.} + {audience == "Bank" && Neither survives an audit alone.} + +\`\`\` + +## Live-filtered list + +Map over a derived array; the source stays baked in: + +\`\`\`jsx + +