From 5b00a90fc32fe395c5e61cde67224b4d232dafad Mon Sep 17 00:00:00 2001 From: Brad Harris Date: Fri, 10 Jul 2026 12:20:15 -0600 Subject: [PATCH 1/6] Add per-agent whiteboard with Excalidraw thin relay architecture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agents construct raw Excalidraw element JSON directly via MCP tools (whiteboard_update, whiteboard_get, whiteboard_clear). The server is a thin relay — it merges elements by id, persists to Postgres with optimistic locking, and pushes updates via SSE. The frontend renders an interactive Excalidraw canvas with live sync in both directions. Key pieces: - Migration 0030: whiteboards table (agent_id PK, JSONB scene, version) - MCP tools with embedded Excalidraw element format cheat sheet (~200 lines) - REST endpoints for user-side scene save with optimistic locking - Whiteboard tab in center pane with lazy-loaded Excalidraw component - Self-hosted Excalidraw fonts via Vite plugin (excluding CJK for 12MB savings) - SSE-driven React Query invalidation with "agent drew" dot indicator - 31 unit tests + 4 E2E tests covering REST API, MCP tools, and UI Co-Authored-By: Claude Opus 4.6 --- .../src/db/migrations/0030_whiteboards.sql | 7 + apps/server/src/routes/mcp.ts | 9 + apps/server/src/routes/whiteboard.ts | 143 ++++++ apps/server/src/server.ts | 11 + apps/server/src/server/mcp-handlers.ts | 9 + .../src/server/mcp-whiteboard-handlers.ts | 199 ++++++++ apps/server/src/server/ui-events.ts | 6 + apps/server/src/shared/mcp/server.ts | 26 + .../server/src/shared/mcp/whiteboard-tools.ts | 350 +++++++++++++ apps/server/src/shared/whiteboard-store.ts | 59 +++ apps/server/src/shared/whiteboard.ts | 87 ++++ apps/server/test/whiteboard.test.ts | 459 ++++++++++++++++++ apps/web/package.json | 1 + apps/web/src/components/app/agents-view.tsx | 28 +- .../components/app/center-pane-tab-bar.tsx | 11 + .../src/components/app/whiteboard-pane.tsx | 44 ++ .../web/src/components/app/whiteboard-tab.tsx | 288 +++++++++++ apps/web/src/hooks/use-agents-view-routing.ts | 14 +- apps/web/src/hooks/use-sse.ts | 26 +- apps/web/src/hooks/use-theme.ts | 4 + apps/web/src/hooks/use-whiteboard.ts | 22 + apps/web/src/lib/agent-routes.ts | 4 + apps/web/src/lib/store.ts | 6 +- apps/web/vite.config.ts | 41 +- e2e/whiteboard.spec.ts | 231 +++++++++ 25 files changed, 2076 insertions(+), 9 deletions(-) create mode 100644 apps/server/src/db/migrations/0030_whiteboards.sql create mode 100644 apps/server/src/routes/whiteboard.ts create mode 100644 apps/server/src/server/mcp-whiteboard-handlers.ts create mode 100644 apps/server/src/shared/mcp/whiteboard-tools.ts create mode 100644 apps/server/src/shared/whiteboard-store.ts create mode 100644 apps/server/src/shared/whiteboard.ts create mode 100644 apps/server/test/whiteboard.test.ts create mode 100644 apps/web/src/components/app/whiteboard-pane.tsx create mode 100644 apps/web/src/components/app/whiteboard-tab.tsx create mode 100644 apps/web/src/hooks/use-whiteboard.ts create mode 100644 e2e/whiteboard.spec.ts diff --git a/apps/server/src/db/migrations/0030_whiteboards.sql b/apps/server/src/db/migrations/0030_whiteboards.sql new file mode 100644 index 00000000..3927b8f7 --- /dev/null +++ b/apps/server/src/db/migrations/0030_whiteboards.sql @@ -0,0 +1,7 @@ +CREATE TABLE IF NOT EXISTS whiteboards ( + agent_id TEXT PRIMARY KEY, + scene JSONB NOT NULL DEFAULT '{"elements":[]}', + version INTEGER NOT NULL DEFAULT 1, + updated_by TEXT NOT NULL DEFAULT 'user', + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); diff --git a/apps/server/src/routes/mcp.ts b/apps/server/src/routes/mcp.ts index 1af3a1c8..1be697da 100644 --- a/apps/server/src/routes/mcp.ts +++ b/apps/server/src/routes/mcp.ts @@ -49,6 +49,9 @@ type McpRouteDeps = { mcpListMedia: unknown; mcpDeleteMedia: unknown; mcpListPins: unknown; + mcpGetWhiteboard: unknown; + mcpUpdateWhiteboard: unknown; + mcpClearWhiteboard: unknown; mcpListPersonas: unknown; mcpLaunchPersona: unknown; mcpLaunchAgent: unknown; @@ -200,6 +203,9 @@ export async function registerMcpRoutes( shareMedia: deps.mcpShareMedia, listMedia: deps.mcpListMedia, deleteMedia: deps.mcpDeleteMedia, + getWhiteboard: deps.mcpGetWhiteboard, + updateWhiteboard: deps.mcpUpdateWhiteboard, + clearWhiteboard: deps.mcpClearWhiteboard, upsertPin: deps.mcpUpsertPin, deletePin: deps.mcpDeletePin, deletePinByLabel: deps.mcpDeletePinByLabel, @@ -286,6 +292,9 @@ export async function registerMcpRoutes( shareMedia: deps.mcpShareMedia, listMedia: deps.mcpListMedia, deleteMedia: deps.mcpDeleteMedia, + getWhiteboard: deps.mcpGetWhiteboard, + updateWhiteboard: deps.mcpUpdateWhiteboard, + clearWhiteboard: deps.mcpClearWhiteboard, listPersonas: deps.mcpListPersonas, launchPersona: deps.mcpLaunchPersona, launchAgent: deps.mcpLaunchAgent, diff --git a/apps/server/src/routes/whiteboard.ts b/apps/server/src/routes/whiteboard.ts new file mode 100644 index 00000000..74e50d47 --- /dev/null +++ b/apps/server/src/routes/whiteboard.ts @@ -0,0 +1,143 @@ +import path from "node:path"; +import { mkdir, rm, writeFile } from "node:fs/promises"; + +import type { FastifyInstance } from "fastify"; +import type { Pool } from "pg"; + +import type { AgentManager } from "../agents/manager.js"; +import { resolveMediaDir } from "../shared/media.js"; +import { + EMPTY_SCENE, + isValidScene, + loadWhiteboard, + MAX_ELEMENTS, + saveWhiteboard, + WHITEBOARD_SNAPSHOT_FILENAME, +} from "../shared/whiteboard-store.js"; + +const SCENE_BODY_LIMIT = 8 * 1024 * 1024; + +type WhiteboardRouteDeps = { + pool: Pool; + mediaRoot: string; + agentManager: AgentManager; + publishUiEvent: (event: unknown) => void; +}; + +export async function registerWhiteboardRoutes( + app: FastifyInstance, + deps: WhiteboardRouteDeps +): Promise { + app.get("/api/v1/agents/:id/whiteboard", async (request, reply) => { + const params = request.params as { id?: string }; + const id = params.id ?? ""; + const agent = await deps.agentManager.getAgent(id); + if (!agent) { + return reply.code(404).send({ error: "Agent not found." }); + } + + const row = await loadWhiteboard(deps.pool, id); + if (!row) { + return { scene: EMPTY_SCENE, version: 0, updatedAt: null }; + } + return { + scene: row.scene, + version: Number(row.version), + updatedAt: row.updated_at.toISOString(), + }; + }); + + app.put( + "/api/v1/agents/:id/whiteboard", + { bodyLimit: SCENE_BODY_LIMIT }, + async (request, reply) => { + const params = request.params as { id?: string }; + const id = params.id ?? ""; + const agent = await deps.agentManager.getAgent(id); + if (!agent) { + return reply.code(404).send({ error: "Agent not found." }); + } + + const body = request.body as + | { scene?: unknown; baseVersion?: unknown } + | undefined; + if (!isValidScene(body?.scene)) { + return reply.code(400).send({ + error: `scene must be an object with an elements array (max ${MAX_ELEMENTS}).`, + }); + } + const baseVersion = + typeof body?.baseVersion === "number" && + Number.isInteger(body.baseVersion) && + body.baseVersion >= 0 + ? body.baseVersion + : null; + if (baseVersion === null) { + return reply + .code(400) + .send({ error: "baseVersion must be a non-negative integer." }); + } + + const saved = await saveWhiteboard( + deps.pool, + id, + body.scene, + baseVersion, + "user" + ); + if (!saved) { + const current = await loadWhiteboard(deps.pool, id); + return reply.code(409).send({ + error: "Whiteboard was modified by someone else.", + scene: current?.scene ?? EMPTY_SCENE, + version: current ? Number(current.version) : 0, + }); + } + + deps.publishUiEvent({ + type: "whiteboard.changed", + agentId: id, + version: saved.version, + source: "user", + }); + return { ok: true, version: saved.version }; + } + ); + + app.post("/api/v1/agents/:id/whiteboard/snapshot", async (request, reply) => { + const params = request.params as { id?: string }; + const id = params.id ?? ""; + const agent = await deps.agentManager.getAgent(id); + if (!agent) { + return reply.code(404).send({ error: "Agent not found." }); + } + + const data = await request.file(); + if (!data || data.mimetype !== "image/png") { + return reply.code(400).send({ error: "A PNG file field is required." }); + } + + const mediaDir = resolveMediaDir(id, agent.mediaDir, deps.mediaRoot); + await mkdir(mediaDir, { recursive: true }); + const buffer = await data.toBuffer(); + await writeFile(path.join(mediaDir, WHITEBOARD_SNAPSHOT_FILENAME), buffer); + return { ok: true, sizeBytes: buffer.length }; + }); + + app.delete( + "/api/v1/agents/:id/whiteboard/snapshot", + async (request, reply) => { + const params = request.params as { id?: string }; + const id = params.id ?? ""; + const agent = await deps.agentManager.getAgent(id); + if (!agent) { + return reply.code(404).send({ error: "Agent not found." }); + } + const mediaDir = resolveMediaDir(id, agent.mediaDir, deps.mediaRoot); + await rm(path.join(mediaDir, WHITEBOARD_SNAPSHOT_FILENAME), { + force: true, + }); + return { ok: true }; + } + ); +} diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 69b3896c..7f4e40d9 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -107,6 +107,7 @@ import { registerJobRoutes } from "./routes/jobs.js"; import { registerTemplateRoutes } from "./routes/templates.js"; import { registerMediaRoutes } from "./routes/media.js"; import { registerMessagesRoutes } from "./routes/messages.js"; +import { registerWhiteboardRoutes } from "./routes/whiteboard.js"; import { registerMcpRoutes } from "./routes/mcp.js"; import { registerPersonaRoutes } from "./routes/personas.js"; import { registerPersonalityRoutes } from "./routes/personalities.js"; @@ -593,6 +594,9 @@ async function registerRoutes() { mcpShareMedia: mcpHandlers.shareMedia, mcpListMedia: mcpHandlers.listMedia, mcpDeleteMedia: mcpHandlers.deleteMedia, + mcpGetWhiteboard: mcpHandlers.getWhiteboard, + mcpUpdateWhiteboard: mcpHandlers.updateWhiteboard, + mcpClearWhiteboard: mcpHandlers.clearWhiteboard, mcpListPersonas: mcpHandlers.listPersonas, mcpLaunchPersona: mcpHandlers.launchPersona, mcpLaunchAgent: mcpHandlers.launchAgent, @@ -702,6 +706,13 @@ async function registerRoutes() { publishUiEvent: (event) => uiEventBroker.publish(event as UiEvent), }); + await registerWhiteboardRoutes(app, { + pool, + mediaRoot: config.mediaRoot, + agentManager, + publishUiEvent: (event) => uiEventBroker.publish(event as UiEvent), + }); + await registerAgentRoutes(app, { pool, appLog: app.log, diff --git a/apps/server/src/server/mcp-handlers.ts b/apps/server/src/server/mcp-handlers.ts index b38b02f5..a38bd1aa 100644 --- a/apps/server/src/server/mcp-handlers.ts +++ b/apps/server/src/server/mcp-handlers.ts @@ -27,6 +27,7 @@ import { isMediaFile, isTextFile, resolveMediaDir } from "../shared/media.js"; import type { PublishUiEvent, SendAgentPrompt } from "./mcp-handler-types.js"; import { createReviewHandlers } from "./mcp-review-handlers.js"; import { MessageStore } from "../messages/store.js"; +import { createWhiteboardHandlers } from "./mcp-whiteboard-handlers.js"; function buildChildAgentInitialPrompt( parentAgentId: string, @@ -708,8 +709,16 @@ export function createMcpHandlers(deps: CreateMcpHandlersDeps) { appLog: deps.appLog, }); + const whiteboardHandlers = createWhiteboardHandlers({ + pool, + mediaRoot, + agentManager, + publishUiEvent, + }); + return { ...reviewHandlers, + ...whiteboardHandlers, upsertEvent: ( agentId: string, diff --git a/apps/server/src/server/mcp-whiteboard-handlers.ts b/apps/server/src/server/mcp-whiteboard-handlers.ts new file mode 100644 index 00000000..d5ccd369 --- /dev/null +++ b/apps/server/src/server/mcp-whiteboard-handlers.ts @@ -0,0 +1,199 @@ +import path from "node:path"; +import { rm, stat } from "node:fs/promises"; + +import type { Pool } from "pg"; + +import type { AgentManager } from "../agents/manager.js"; +import { resolveMediaDir } from "../shared/media.js"; +import { + simplifyElements, + type WhiteboardGetResult, + type WhiteboardUpdateResult, +} from "../shared/whiteboard.js"; +import { + EMPTY_SCENE, + loadWhiteboard, + MAX_ELEMENTS, + saveWhiteboard, + WHITEBOARD_SNAPSHOT_FILENAME, +} from "../shared/whiteboard-store.js"; +import type { PublishUiEvent } from "./mcp-handler-types.js"; + +type CreateWhiteboardHandlersDeps = { + pool: Pool; + mediaRoot: string; + agentManager: AgentManager; + publishUiEvent: PublishUiEvent; +}; + +type RawElement = Record; + +function hasRequiredFields( + el: unknown +): el is RawElement & { id: string; type: string } { + if (typeof el !== "object" || el === null) return false; + const obj = el as RawElement; + return typeof obj.id === "string" && typeof obj.type === "string"; +} + +function mergeElements( + existing: unknown[], + incoming: unknown[], + deleteIds: string[] +): { elements: unknown[]; addedIds: string[]; updatedIds: string[] } { + const deleteSet = new Set(deleteIds); + const existingMap = new Map(); + for (const el of existing) { + if (typeof el === "object" && el !== null) { + const id = (el as RawElement).id; + if (typeof id === "string") { + existingMap.set(id, el); + } + } + } + + const addedIds: string[] = []; + const updatedIds: string[] = []; + + for (const el of incoming) { + if (!hasRequiredFields(el)) continue; + if (existingMap.has(el.id)) { + updatedIds.push(el.id); + } else { + addedIds.push(el.id); + } + existingMap.set(el.id, el); + } + + for (const id of deleteSet) { + if (existingMap.has(id)) { + existingMap.delete(id); + } + } + + return { + elements: Array.from(existingMap.values()), + addedIds, + updatedIds, + }; +} + +export function createWhiteboardHandlers(deps: CreateWhiteboardHandlersDeps) { + const { pool, mediaRoot, agentManager, publishUiEvent } = deps; + + return { + async getWhiteboard(agentId: string): Promise { + const agent = await agentManager.getAgent(agentId); + if (!agent) throw new Error("Agent not found."); + + const row = await loadWhiteboard(pool, agentId); + const snapshotFile = path.join( + resolveMediaDir(agentId, agent.mediaDir, mediaRoot), + WHITEBOARD_SNAPSHOT_FILENAME + ); + const snapshotStat = await stat(snapshotFile).catch(() => null); + const snapshotPath = snapshotStat?.isFile() ? snapshotFile : null; + return { + elements: row ? simplifyElements(row.scene.elements) : [], + version: row ? Number(row.version) : 0, + updatedAt: row ? row.updated_at.toISOString() : null, + updatedBy: row ? row.updated_by : null, + snapshotPath, + snapshotStale: + snapshotPath !== null && + row !== null && + snapshotStat !== null && + row.updated_at.getTime() > snapshotStat.mtime.getTime(), + }; + }, + + async updateWhiteboard( + agentId: string, + elements: unknown[], + deleteIds: string[] + ): Promise { + const agent = await agentManager.getAgent(agentId); + if (!agent) throw new Error("Agent not found."); + + for (let attempt = 0; attempt < 3; attempt++) { + const row = await loadWhiteboard(pool, agentId); + const baseVersion = row ? Number(row.version) : 0; + const existing = row ? row.scene.elements : []; + + const merged = mergeElements(existing, elements, deleteIds); + + if (merged.elements.length > MAX_ELEMENTS) { + throw new Error(`Board is full (max ${MAX_ELEMENTS} elements).`); + } + + const saved = await saveWhiteboard( + pool, + agentId, + { elements: merged.elements }, + baseVersion, + "agent" + ); + if (saved) { + publishUiEvent({ + type: "whiteboard.changed", + agentId, + version: saved.version, + source: "agent", + }); + return { + version: saved.version, + elementCount: merged.elements.length, + addedIds: merged.addedIds, + updatedIds: merged.updatedIds, + deletedIds: deleteIds.filter((id) => + existing.some( + (el) => + typeof el === "object" && + el !== null && + (el as RawElement).id === id + ) + ), + elements: simplifyElements(merged.elements), + }; + } + } + throw new Error( + "Whiteboard is being edited concurrently; try again in a moment." + ); + }, + + async clearWhiteboard(agentId: string): Promise { + const agent = await agentManager.getAgent(agentId); + if (!agent) throw new Error("Agent not found."); + + for (let attempt = 0; attempt < 3; attempt++) { + const row = await loadWhiteboard(pool, agentId); + const baseVersion = row ? Number(row.version) : 0; + const saved = await saveWhiteboard( + pool, + agentId, + EMPTY_SCENE, + baseVersion, + "agent" + ); + if (saved) { + publishUiEvent({ + type: "whiteboard.changed", + agentId, + version: saved.version, + source: "agent", + }); + const snapshotFile = path.join( + resolveMediaDir(agentId, agent.mediaDir, mediaRoot), + WHITEBOARD_SNAPSHOT_FILENAME + ); + await rm(snapshotFile, { force: true }); + return; + } + } + throw new Error( + "Whiteboard is being edited concurrently; try again in a moment." + ); + }, + }; +} diff --git a/apps/server/src/server/ui-events.ts b/apps/server/src/server/ui-events.ts index 21b29fea..37486d83 100644 --- a/apps/server/src/server/ui-events.ts +++ b/apps/server/src/server/ui-events.ts @@ -19,6 +19,12 @@ export type UiEvent = } | { type: "agent.deleted"; agentId: string } | { type: "media.changed"; agentId: string } + | { + type: "whiteboard.changed"; + agentId: string; + version: number; + source: "user" | "agent"; + } | { type: "media.seen"; agentId: string; keys: string[] } | { type: "message.created"; diff --git a/apps/server/src/shared/mcp/server.ts b/apps/server/src/shared/mcp/server.ts index 85bf64e5..58523d45 100644 --- a/apps/server/src/shared/mcp/server.ts +++ b/apps/server/src/shared/mcp/server.ts @@ -16,6 +16,11 @@ import { registerBrainTools } from "./brain-tools.js"; import { registerCrudTools, type CrudToolCallbacks } from "./crud-tools.js"; import { registerJobTools, type JobTools } from "./job-tools.js"; import { registerMessagingTools } from "./messaging-tools.js"; +import { registerWhiteboardTools } from "./whiteboard-tools.js"; +import type { + WhiteboardGetResult, + WhiteboardUpdateResult, +} from "../whiteboard.js"; import { registerPersonaInteractionTools, type LaunchPersonaAgentType, @@ -69,6 +74,9 @@ const AGENT_TOOLS = new Set([ "get_activity_summary", "get_agent_history", "get_feedback_summary", + "whiteboard_get", + "whiteboard_update", + "whiteboard_clear", "brain_get_object", "brain_store_object", "brain_list_objects", @@ -159,6 +167,7 @@ const REVIEW_AGENT_TOOLS = new Set([ "dispatch_review_add_message", "dispatch_review_resolve", "get_parent_context", + "whiteboard_get", ]); type AgentCapabilityType = "agent" | "job" | "review"; @@ -352,6 +361,13 @@ export type McpRequestContext = { ) => Promise; deletePin?: (agentId: string, pinId: string) => Promise; deletePinByLabel?: (agentId: string, label: string) => Promise; + getWhiteboard?: (agentId: string) => Promise; + updateWhiteboard?: ( + agentId: string, + elements: unknown[], + deleteIds: string[] + ) => Promise; + clearWhiteboard?: (agentId: string) => Promise; getParentContext?: (parentAgentId: string) => Promise; sendMessage?: ( agentId: string, @@ -482,6 +498,16 @@ async function createDispatchMcpServer( }); } + // ── Whiteboard tools ────────────────────────────────────────────── + if (context.agent) { + registerWhiteboardTools(server, allowed, { + agentId: context.agent.id, + getWhiteboard: context.getWhiteboard, + updateWhiteboard: context.updateWhiteboard, + clearWhiteboard: context.clearWhiteboard, + }); + } + // ── Inter-agent messaging tools ─────────────────────────────────── if (context.agent) { registerMessagingTools(server, allowed, { diff --git a/apps/server/src/shared/mcp/whiteboard-tools.ts b/apps/server/src/shared/mcp/whiteboard-tools.ts new file mode 100644 index 00000000..36e4e6ee --- /dev/null +++ b/apps/server/src/shared/mcp/whiteboard-tools.ts @@ -0,0 +1,350 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import * as z from "zod/v4"; + +import { toToolError } from "./tool-error.js"; +import type { + WhiteboardGetResult, + WhiteboardUpdateResult, +} from "../whiteboard.js"; + +export type WhiteboardToolsContext = { + agentId: string; + getWhiteboard?: (agentId: string) => Promise; + updateWhiteboard?: ( + agentId: string, + elements: unknown[], + deleteIds: string[] + ) => Promise; + clearWhiteboard?: (agentId: string) => Promise; +}; + +// ── Excalidraw element format cheat sheet ────────────────────────────── +// Included in the whiteboard_update tool description so agents can +// construct valid Excalidraw element JSON without importing the library. +const EXCALIDRAW_CHEAT_SHEET = ` + +## Excalidraw Element Format Reference + +Every element is a JSON object. Fields marked (required) must be present; all others have sensible defaults the editor will apply if omitted. + +### Common fields (all element types) + +| Field | Type | Required | Default | Notes | +|-------|------|----------|---------|-------| +| id | string | YES | — | Unique id. Use readable slugs like "api-box", "db-node". | +| type | string | YES | — | One of: rectangle, ellipse, diamond, text, arrow, line, frame, freedraw, image | +| x | number | YES | — | Left edge, canvas pixels | +| y | number | YES | — | Top edge, canvas pixels | +| width | number | YES | — | Element width (use 0 for arrows/lines) | +| height | number | YES | — | Element height (use 0 for arrows/lines) | +| angle | number | no | 0 | Rotation in radians | +| strokeColor | string | no | "#1e1e1e" | Stroke/outline color (hex) | +| backgroundColor | string | no | "transparent" | Fill color (hex or "transparent") | +| fillStyle | string | no | "solid" | One of: solid, hachure, cross-hatch | +| strokeWidth | number | no | 2 | Stroke thickness in px | +| strokeStyle | string | no | "solid" | One of: solid, dashed, dotted | +| roughness | number | no | 1 | 0=smooth, 1=normal, 2=rough (hand-drawn look) | +| opacity | number | no | 100 | 0–100 | +| groupIds | string[] | no | [] | Group membership | +| frameId | string\\|null | no | null | Parent frame id | +| roundness | object\\|null | no | null | { type: 3 } for rounded corners on rectangles | +| seed | number | no | random | Random seed for roughness rendering | +| version | number | no | 1 | Bump on each edit | +| versionNonce | number | no | random | Random nonce, changes with version | +| isDeleted | boolean | no | false | Soft-delete flag | +| boundElements | array\\|null | no | null | Back-references: [{ id, type }] | +| updated | number | no | Date.now() | Timestamp ms | +| link | string\\|null | no | null | URL link | +| locked | boolean | no | false | Prevent editing | + +### Shape elements: rectangle, ellipse, diamond + +\`\`\`json +{ + "id": "api-box", + "type": "rectangle", + "x": 100, "y": 100, + "width": 160, "height": 70, + "strokeColor": "#1e1e1e", + "backgroundColor": "#a5d8ff", + "fillStyle": "solid", + "roundness": { "type": 3 } +} +\`\`\` + +Ellipse and diamond use the same fields, just change \`type\`. + +### Text elements + +\`\`\`json +{ + "id": "title-text", + "type": "text", + "x": 100, "y": 50, + "width": 200, "height": 25, + "text": "API Server", + "fontSize": 20, + "fontFamily": 5, + "textAlign": "center", + "verticalAlign": "middle", + "originalText": "API Server" +} +\`\`\` + +**Bound text (label inside a shape):** Create a text element with \`containerId\` pointing to the shape, and add a back-reference in the shape's \`boundElements\`: + +\`\`\`json +[ + { + "id": "box1", + "type": "rectangle", + "x": 100, "y": 100, "width": 160, "height": 70, + "boundElements": [{ "id": "box1-label", "type": "text" }] + }, + { + "id": "box1-label", + "type": "text", + "x": 110, "y": 120, "width": 140, "height": 25, + "text": "API", + "originalText": "API", + "fontSize": 20, + "fontFamily": 5, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "box1" + } +] +\`\`\` + +Text font families: 1=Virgil (handwritten), 3=Cascadia (monospace), 5=Excalifont (default). + +### Arrow and line elements + +\`\`\`json +{ + "id": "flow-arrow", + "type": "arrow", + "x": 260, "y": 135, + "width": 140, "height": 0, + "points": [[0, 0], [140, 0]], + "startArrowhead": null, + "endArrowhead": "arrow", + "startBinding": { + "elementId": "api-box", + "focus": 0, + "gap": 1, + "fixedPoint": [1, 0.5] + }, + "endBinding": { + "elementId": "db-node", + "focus": 0, + "gap": 1, + "fixedPoint": [0, 0.5] + }, + "elbowed": false +} +\`\`\` + +**Points:** Array of [x, y] offsets relative to the element's x, y. First point is always [0, 0]. Add intermediate points for bends. + +**Arrowheads:** \`startArrowhead\` and \`endArrowhead\` can be: null, "arrow", "bar", "dot", "triangle", "diamond". + +**Bindings:** Connect arrows to shapes. \`fixedPoint\` is [proportionX, proportionY] on the target shape (0-1 range): [0.5, 0] = top center, [1, 0.5] = right center, [0.5, 1] = bottom center, [0, 0.5] = left center. + +When binding an arrow, also add a back-reference in the target shape's \`boundElements\`: +\`\`\`json +{ "id": "flow-arrow", "type": "arrow" } +\`\`\` + +**Elbow routing:** Set \`elbowed: true\` for right-angle connector routing (auto-computed path). The \`points\` array will be overridden by the editor. + +**Lines** use the same format but \`type: "line"\` and no arrowheads. + +### Frame elements + +\`\`\`json +{ + "id": "frame1", + "type": "frame", + "x": 50, "y": 50, + "width": 400, "height": 300, + "name": "Backend Services" +} +\`\`\` + +Children are assigned to frames by setting their \`frameId\` to the frame's id. + +### Color reference + +**Stroke colors:** #1e1e1e (black/default), #e03131 (red), #2f9e44 (green), #1971c2 (blue), #f08c00 (orange), #6741d9 (violet), #0c8599 (cyan), #e8590c (dark orange), #868e96 (gray) + +**Pastel fills (good for shape backgrounds):** #a5d8ff (light blue), #b2f2bb (light green), #ffd8a8 (light orange), #d0bfff (light purple), #ffc9c9 (light red), #fff3bf (light yellow), #c3fae8 (light teal), #eebefa (light pink), #e5dbff (light violet) + +### Layout tips + +- Typical box: width 160, height 70 +- Leave ~80px gaps between shapes +- Center labels inside shapes using \`containerId\` + \`boundElements\` binding +- Keep labels short (2–5 words) — text wraps to shape width +- For arrows: set x, y to the start point, compute width/height from the last point offset +- Arrow width = last point's x offset, height = last point's y offset (can be negative) + +### Important notes + +- The editor auto-heals many issues (null fields, missing indices). Don't over-validate. +- Always provide \`id\`, \`type\`, \`x\`, \`y\` at minimum. Width and height default to 0 if omitted. +- Use readable, descriptive ids — you'll reference them in bindings and future updates. +- Elements are merged by id: sending an element with an existing id replaces it entirely. +`; + +export function registerWhiteboardTools( + server: McpServer, + allowed: Set, + context: WhiteboardToolsContext +): void { + if (allowed.has("whiteboard_get") && context.getWhiteboard) { + const agentId = context.agentId; + const getWhiteboard = context.getWhiteboard; + + server.registerTool( + "whiteboard_get", + { + description: + "Get the current state of this agent's shared whiteboard — a canvas the user sketches on " + + "(architecture diagrams, flows, ideas). Returns a simplified element list (geometry, text, " + + "arrow connections via from/to element ids) plus snapshotPath: a PNG rendering of the board. " + + "Read the snapshot file to SEE the drawing — freehand sketches are hard to interpret from " + + "elements alone. Use this whenever the user refers to the whiteboard/board/drawing.", + inputSchema: {}, + }, + async () => { + try { + const board = await getWhiteboard(agentId); + const summary = { + elementCount: board.elements.length, + version: board.version, + updatedAt: board.updatedAt, + updatedBy: board.updatedBy, + snapshotPath: board.snapshotPath, + snapshotStale: board.snapshotStale, + elements: board.elements, + }; + const snapshotNote = board.snapshotPath + ? board.snapshotStale + ? `\n\nNote: ${board.snapshotPath} was rendered BEFORE the latest edits — trust the element list over the image until a browser re-exports it.` + : `\n\nTip: Read ${board.snapshotPath} to view the board visually.` + : "\n\nNo snapshot has been rendered yet (the board may be empty or never opened)."; + return { + content: [ + { + type: "text", + text: JSON.stringify(summary, null, 2) + snapshotNote, + }, + ], + structuredContent: summary, + }; + } catch (error) { + return toToolError(error); + } + } + ); + } + + if (allowed.has("whiteboard_update") && context.updateWhiteboard) { + const agentId = context.agentId; + const updateWhiteboard = context.updateWhiteboard; + + server.registerTool( + "whiteboard_update", + { + description: + "Draw on the shared whiteboard — the user sees your edits live. You construct raw " + + "Excalidraw element JSON directly. Elements are merged by id: if an element with that " + + "id already exists on the board, it is replaced entirely; otherwise it is added. Use " + + "`deleteIds` to remove elements." + + "\n\n" + + "**Workflow:** Call `whiteboard_get` first to see current elements, their ids, and where " + + "free space is. Then construct your elements and send them here. Give elements readable " + + "ids (e.g. 'api-box', 'db-node') so you can reference them in arrow bindings." + + "\n\n" + + "**Labels:** To put text inside a shape, create BOTH a shape element (with a `boundElements` " + + "back-reference to the text) AND a text element (with `containerId` pointing to the shape). " + + "See the format reference below." + + "\n\n" + + "**Arrows:** Use `startBinding`/`endBinding` with `elementId` and `fixedPoint` to connect " + + "arrows to shapes. Add back-references in each target shape's `boundElements` array. " + + "Set `elbowed: true` for right-angle routing." + + "\n\n" + + "**Layout:** Typical box w=160, h=70 with ~80px gaps. Keep labels short (2–5 words)." + + EXCALIDRAW_CHEAT_SHEET, + inputSchema: { + elements: z + .array(z.record(z.string(), z.any())) + .max(500) + .describe( + "Array of Excalidraw element objects to add or update. Each must have at least " + + "'id' and 'type'. Elements are merged by id (upsert). See the format reference in " + + "the tool description for the full element schema." + ), + deleteIds: z + .array(z.string()) + .max(500) + .optional() + .describe("Array of element ids to remove from the board."), + }, + }, + async (args) => { + try { + const result = await updateWhiteboard( + agentId, + args.elements, + args.deleteIds ?? [] + ); + const summary = { + ok: true, + version: result.version, + elementCount: result.elementCount, + addedIds: result.addedIds, + updatedIds: result.updatedIds, + deletedIds: result.deletedIds, + elements: result.elements, + }; + return { + content: [{ type: "text", text: JSON.stringify(summary, null, 2) }], + structuredContent: summary, + }; + } catch (error) { + return toToolError(error); + } + } + ); + } + + if (allowed.has("whiteboard_clear") && context.clearWhiteboard) { + const agentId = context.agentId; + const clearWhiteboard = context.clearWhiteboard; + + server.registerTool( + "whiteboard_clear", + { + description: "Clear the whiteboard entirely, removing all elements.", + inputSchema: {}, + }, + async () => { + try { + await clearWhiteboard(agentId); + return { + content: [ + { + type: "text", + text: '{"ok": true, "message": "Whiteboard cleared."}', + }, + ], + }; + } catch (error) { + return toToolError(error); + } + } + ); + } +} diff --git a/apps/server/src/shared/whiteboard-store.ts b/apps/server/src/shared/whiteboard-store.ts new file mode 100644 index 00000000..742bb70d --- /dev/null +++ b/apps/server/src/shared/whiteboard-store.ts @@ -0,0 +1,59 @@ +import type { Pool } from "pg"; + +export const WHITEBOARD_SNAPSHOT_FILENAME = "whiteboard.png"; + +export const MAX_ELEMENTS = 20_000; + +export const EMPTY_SCENE = { elements: [] as unknown[] }; + +export type WhiteboardRow = { + scene: { elements: unknown[] }; + version: string; + updated_by: string; + updated_at: Date; +}; + +export async function loadWhiteboard( + pool: Pool, + agentId: string +): Promise { + const result = await pool.query( + "SELECT scene, version, updated_by, updated_at FROM whiteboards WHERE agent_id = $1", + [agentId] + ); + return result.rows[0] ?? null; +} + +export function isValidScene(scene: unknown): scene is { elements: unknown[] } { + return ( + typeof scene === "object" && + scene !== null && + Array.isArray((scene as { elements?: unknown }).elements) && + (scene as { elements: unknown[] }).elements.length <= MAX_ELEMENTS + ); +} + +export async function saveWhiteboard( + pool: Pool, + agentId: string, + scene: { elements: unknown[] }, + baseVersion: number, + updatedBy: "user" | "agent" +): Promise<{ version: number } | null> { + const result = await pool.query<{ version: string }>( + `INSERT INTO whiteboards (agent_id, scene, version, updated_by) + VALUES ($1, $2::jsonb, 1, $3) + ON CONFLICT (agent_id) DO UPDATE + SET scene = EXCLUDED.scene, + version = whiteboards.version + 1, + updated_by = EXCLUDED.updated_by, + updated_at = NOW() + WHERE whiteboards.version = $4 + RETURNING version`, + [agentId, JSON.stringify(scene), updatedBy, baseVersion] + ); + if (result.rows.length === 0) { + return null; + } + return { version: Number(result.rows[0].version) }; +} diff --git a/apps/server/src/shared/whiteboard.ts b/apps/server/src/shared/whiteboard.ts new file mode 100644 index 00000000..c59a9bc5 --- /dev/null +++ b/apps/server/src/shared/whiteboard.ts @@ -0,0 +1,87 @@ +type RawElement = Record; + +export type SimplifiedElement = { + id: string; + type: string; + x: number; + y: number; + width: number; + height: number; + angle?: number; + text?: string; + containerId?: string; + from?: string; + to?: string; + frameId?: string; + strokeColor?: string; + backgroundColor?: string; +}; + +export type WhiteboardGetResult = { + elements: SimplifiedElement[]; + version: number; + updatedAt: string | null; + updatedBy: string | null; + snapshotPath: string | null; + snapshotStale: boolean; +}; + +export type WhiteboardUpdateResult = { + version: number; + elementCount: number; + addedIds: string[]; + updatedIds: string[]; + deletedIds: string[]; + elements: SimplifiedElement[]; +}; + +function num(value: unknown): number { + return typeof value === "number" && Number.isFinite(value) + ? Math.round(value) + : 0; +} + +function str(value: unknown): string | undefined { + return typeof value === "string" && value ? value : undefined; +} + +export function simplifyElements(elements: unknown[]): SimplifiedElement[] { + const out: SimplifiedElement[] = []; + for (const raw of elements) { + if (typeof raw !== "object" || raw === null) continue; + const el = raw as RawElement; + if (el.isDeleted === true) continue; + + const simplified: SimplifiedElement = { + id: str(el.id) ?? "", + type: str(el.type) ?? "unknown", + x: num(el.x), + y: num(el.y), + width: num(el.width), + height: num(el.height), + }; + if (typeof el.angle === "number" && Math.abs(el.angle) > 0.01) { + simplified.angle = Number((el.angle as number).toFixed(2)); + } + const text = str(el.text) ?? str(el.originalText); + if (text) simplified.text = text; + const containerId = str((el as { containerId?: unknown }).containerId); + if (containerId) simplified.containerId = containerId; + const startBinding = el.startBinding as { elementId?: unknown } | null; + const endBinding = el.endBinding as { elementId?: unknown } | null; + const from = str(startBinding?.elementId); + const to = str(endBinding?.elementId); + if (from) simplified.from = from; + if (to) simplified.to = to; + const frameId = str(el.frameId); + if (frameId) simplified.frameId = frameId; + const strokeColor = str(el.strokeColor); + if (strokeColor) simplified.strokeColor = strokeColor; + const backgroundColor = str(el.backgroundColor); + if (backgroundColor && backgroundColor !== "transparent") { + simplified.backgroundColor = backgroundColor; + } + out.push(simplified); + } + return out; +} diff --git a/apps/server/test/whiteboard.test.ts b/apps/server/test/whiteboard.test.ts new file mode 100644 index 00000000..c8cc3e04 --- /dev/null +++ b/apps/server/test/whiteboard.test.ts @@ -0,0 +1,459 @@ +import { describe, expect, it, vi } from "vitest"; + +import { simplifyElements } from "../src/shared/whiteboard.js"; +import { isValidScene, MAX_ELEMENTS } from "../src/shared/whiteboard-store.js"; + +// ── mergeElements is not exported, so we test it indirectly through +// createWhiteboardHandlers. Import the module and extract the merge +// logic by testing the handlers with mocked deps. ── + +import { createWhiteboardHandlers } from "../src/server/mcp-whiteboard-handlers.js"; + +function rect(id: string, x = 0, y = 0) { + return { id, type: "rectangle", x, y, width: 100, height: 50 }; +} + +function text(id: string, t: string) { + return { + id, + type: "text", + x: 0, + y: 0, + width: 80, + height: 20, + text: t, + originalText: t, + }; +} + +function arrow(id: string, from: string, to: string) { + return { + id, + type: "arrow", + x: 100, + y: 50, + width: 100, + height: 0, + points: [ + [0, 0], + [100, 0], + ], + startBinding: { elementId: from, focus: 0, gap: 1 }, + endBinding: { elementId: to, focus: 0, gap: 1 }, + }; +} + +// ── simplifyElements ── + +describe("simplifyElements", () => { + it("converts raw elements to simplified format", () => { + const result = simplifyElements([ + rect("box1", 10, 20), + text("label1", "Hello"), + ]); + expect(result).toEqual([ + { id: "box1", type: "rectangle", x: 10, y: 20, width: 100, height: 50 }, + { + id: "label1", + type: "text", + x: 0, + y: 0, + width: 80, + height: 20, + text: "Hello", + }, + ]); + }); + + it("skips deleted elements", () => { + const result = simplifyElements([ + { ...rect("a"), isDeleted: true }, + rect("b"), + ]); + expect(result).toHaveLength(1); + expect(result[0].id).toBe("b"); + }); + + it("skips non-object values", () => { + const result = simplifyElements([ + null, + undefined, + 42, + "str", + rect("ok"), + ] as unknown[]); + expect(result).toHaveLength(1); + expect(result[0].id).toBe("ok"); + }); + + it("extracts arrow bindings as from/to", () => { + const result = simplifyElements([arrow("a1", "box1", "box2")]); + expect(result[0].from).toBe("box1"); + expect(result[0].to).toBe("box2"); + }); + + it("extracts containerId for bound text", () => { + const el = { ...text("lbl", "Hi"), containerId: "box1" }; + const result = simplifyElements([el]); + expect(result[0].containerId).toBe("box1"); + }); + + it("extracts frameId", () => { + const el = { ...rect("child"), frameId: "frame1" }; + const result = simplifyElements([el]); + expect(result[0].frameId).toBe("frame1"); + }); + + it("rounds angle and includes when non-zero", () => { + const el = { ...rect("rotated"), angle: 1.5708 }; + const result = simplifyElements([el]); + expect(result[0].angle).toBe(1.57); + }); + + it("omits angle when near zero", () => { + const el = { ...rect("flat"), angle: 0.005 }; + const result = simplifyElements([el]); + expect(result[0].angle).toBeUndefined(); + }); + + it("extracts colors (skips transparent bg)", () => { + const el = { + ...rect("colored"), + strokeColor: "#e03131", + backgroundColor: "transparent", + }; + const result = simplifyElements([el]); + expect(result[0].strokeColor).toBe("#e03131"); + expect(result[0].backgroundColor).toBeUndefined(); + }); + + it("includes non-transparent backgroundColor", () => { + const el = { ...rect("filled"), backgroundColor: "#a5d8ff" }; + const result = simplifyElements([el]); + expect(result[0].backgroundColor).toBe("#a5d8ff"); + }); +}); + +// ── isValidScene ── + +describe("isValidScene", () => { + it("accepts valid scene", () => { + expect(isValidScene({ elements: [rect("a")] })).toBe(true); + }); + + it("accepts empty scene", () => { + expect(isValidScene({ elements: [] })).toBe(true); + }); + + it("rejects null", () => { + expect(isValidScene(null)).toBe(false); + }); + + it("rejects non-object", () => { + expect(isValidScene("string")).toBe(false); + }); + + it("rejects missing elements", () => { + expect(isValidScene({ foo: "bar" })).toBe(false); + }); + + it("rejects non-array elements", () => { + expect(isValidScene({ elements: "not-array" })).toBe(false); + }); + + it("rejects oversized elements array", () => { + const elements = Array.from({ length: MAX_ELEMENTS + 1 }, (_, i) => + rect(`e${i}`) + ); + expect(isValidScene({ elements })).toBe(false); + }); + + it("accepts exactly MAX_ELEMENTS", () => { + const elements = Array.from({ length: MAX_ELEMENTS }, (_, i) => + rect(`e${i}`) + ); + expect(isValidScene({ elements })).toBe(true); + }); +}); + +// ── createWhiteboardHandlers (mergeElements + handler logic) ── + +describe("createWhiteboardHandlers", () => { + function createMockDeps() { + const publishedEvents: unknown[] = []; + return { + pool: { + query: vi.fn(), + } as unknown as import("pg").Pool, + mediaRoot: "/tmp/test-media", + agentManager: { + getAgent: vi.fn().mockResolvedValue({ id: "agt_test", mediaDir: null }), + } as unknown as import("../src/agents/manager.js").AgentManager, + publishUiEvent: vi.fn((e: unknown) => publishedEvents.push(e)), + publishedEvents, + }; + } + + function mockLoadReturn( + pool: { query: ReturnType }, + scene: { elements: unknown[] }, + version = 1 + ) { + pool.query.mockResolvedValueOnce({ + rows: [ + { + scene, + version: String(version), + updated_by: "agent", + updated_at: new Date(), + }, + ], + }); + } + + function mockSaveReturn( + pool: { query: ReturnType }, + version: number + ) { + pool.query.mockResolvedValueOnce({ + rows: [{ version: String(version) }], + }); + } + + function mockEmptyLoad(pool: { query: ReturnType }) { + pool.query.mockResolvedValueOnce({ rows: [] }); + } + + describe("updateWhiteboard", () => { + it("adds new elements to an empty board", async () => { + const deps = createMockDeps(); + const handlers = createWhiteboardHandlers(deps); + + mockEmptyLoad( + deps.pool as unknown as { query: ReturnType } + ); + mockSaveReturn( + deps.pool as unknown as { query: ReturnType }, + 1 + ); + + const result = await handlers.updateWhiteboard( + "agt_test", + [rect("a"), rect("b")], + [] + ); + expect(result.addedIds).toEqual(["a", "b"]); + expect(result.updatedIds).toEqual([]); + expect(result.elementCount).toBe(2); + expect(result.version).toBe(1); + }); + + it("upserts existing elements", async () => { + const deps = createMockDeps(); + const handlers = createWhiteboardHandlers(deps); + const pool = deps.pool as unknown as { query: ReturnType }; + + mockLoadReturn(pool, { elements: [rect("a", 0, 0)] }, 1); + mockSaveReturn(pool, 2); + + const result = await handlers.updateWhiteboard( + "agt_test", + [rect("a", 50, 50), rect("b")], + [] + ); + expect(result.addedIds).toEqual(["b"]); + expect(result.updatedIds).toEqual(["a"]); + expect(result.elementCount).toBe(2); + }); + + it("deletes specified elements", async () => { + const deps = createMockDeps(); + const handlers = createWhiteboardHandlers(deps); + const pool = deps.pool as unknown as { query: ReturnType }; + + mockLoadReturn(pool, { elements: [rect("a"), rect("b"), rect("c")] }, 1); + mockSaveReturn(pool, 2); + + const result = await handlers.updateWhiteboard( + "agt_test", + [], + ["a", "c"] + ); + expect(result.deletedIds).toEqual(["a", "c"]); + expect(result.elementCount).toBe(1); + }); + + it("publishes whiteboard.changed event", async () => { + const deps = createMockDeps(); + const handlers = createWhiteboardHandlers(deps); + const pool = deps.pool as unknown as { query: ReturnType }; + + mockEmptyLoad(pool); + mockSaveReturn(pool, 1); + + await handlers.updateWhiteboard("agt_test", [rect("a")], []); + expect(deps.publishUiEvent).toHaveBeenCalledWith({ + type: "whiteboard.changed", + agentId: "agt_test", + version: 1, + source: "agent", + }); + }); + + it("retries on optimistic lock conflict", async () => { + const deps = createMockDeps(); + const handlers = createWhiteboardHandlers(deps); + const pool = deps.pool as unknown as { query: ReturnType }; + + // First attempt: load version 1, save fails (conflict) + mockLoadReturn(pool, { elements: [] }, 1); + pool.query.mockResolvedValueOnce({ rows: [] }); // save fails + + // Second attempt: load version 2, save succeeds + mockLoadReturn(pool, { elements: [] }, 2); + mockSaveReturn(pool, 3); + + const result = await handlers.updateWhiteboard( + "agt_test", + [rect("a")], + [] + ); + expect(result.version).toBe(3); + }); + + it("throws after 3 failed attempts", async () => { + const deps = createMockDeps(); + const handlers = createWhiteboardHandlers(deps); + const pool = deps.pool as unknown as { query: ReturnType }; + + for (let i = 0; i < 3; i++) { + mockLoadReturn(pool, { elements: [] }, i + 1); + pool.query.mockResolvedValueOnce({ rows: [] }); // save fails + } + + await expect( + handlers.updateWhiteboard("agt_test", [rect("a")], []) + ).rejects.toThrow("concurrently"); + }); + + it("throws for unknown agent", async () => { + const deps = createMockDeps(); + ( + deps.agentManager.getAgent as ReturnType + ).mockResolvedValueOnce(null); + const handlers = createWhiteboardHandlers(deps); + + await expect( + handlers.updateWhiteboard("agt_missing", [rect("a")], []) + ).rejects.toThrow("Agent not found"); + }); + + it("throws when merged result exceeds MAX_ELEMENTS", async () => { + const deps = createMockDeps(); + const handlers = createWhiteboardHandlers(deps); + const pool = deps.pool as unknown as { query: ReturnType }; + + const hugeScene = { + elements: Array.from({ length: MAX_ELEMENTS }, (_, i) => rect(`e${i}`)), + }; + mockLoadReturn(pool, hugeScene, 1); + + await expect( + handlers.updateWhiteboard("agt_test", [rect("new-one")], []) + ).rejects.toThrow("full"); + }); + + it("ignores incoming elements without required fields", async () => { + const deps = createMockDeps(); + const handlers = createWhiteboardHandlers(deps); + const pool = deps.pool as unknown as { query: ReturnType }; + + mockEmptyLoad(pool); + mockSaveReturn(pool, 1); + + const result = await handlers.updateWhiteboard( + "agt_test", + [ + { id: "valid", type: "rectangle", x: 0, y: 0 }, + { type: "rectangle" }, // missing id + { id: "no-type" }, // missing type + null as unknown as Record, + ], + [] + ); + expect(result.addedIds).toEqual(["valid"]); + expect(result.elementCount).toBe(1); + }); + }); + + describe("clearWhiteboard", () => { + it("clears board and publishes event", async () => { + const deps = createMockDeps(); + const handlers = createWhiteboardHandlers(deps); + const pool = deps.pool as unknown as { query: ReturnType }; + + mockLoadReturn(pool, { elements: [rect("a")] }, 1); + mockSaveReturn(pool, 2); + + await handlers.clearWhiteboard("agt_test"); + expect(deps.publishUiEvent).toHaveBeenCalledWith({ + type: "whiteboard.changed", + agentId: "agt_test", + version: 2, + source: "agent", + }); + }); + + it("throws for unknown agent", async () => { + const deps = createMockDeps(); + ( + deps.agentManager.getAgent as ReturnType + ).mockResolvedValueOnce(null); + const handlers = createWhiteboardHandlers(deps); + + await expect(handlers.clearWhiteboard("agt_missing")).rejects.toThrow( + "Agent not found" + ); + }); + }); + + describe("getWhiteboard", () => { + it("returns empty state for nonexistent whiteboard", async () => { + const deps = createMockDeps(); + const handlers = createWhiteboardHandlers(deps); + const pool = deps.pool as unknown as { query: ReturnType }; + + mockEmptyLoad(pool); + + const result = await handlers.getWhiteboard("agt_test"); + expect(result.elements).toEqual([]); + expect(result.version).toBe(0); + expect(result.updatedAt).toBeNull(); + }); + + it("returns simplified elements for existing board", async () => { + const deps = createMockDeps(); + const handlers = createWhiteboardHandlers(deps); + const pool = deps.pool as unknown as { query: ReturnType }; + + const now = new Date(); + pool.query.mockResolvedValueOnce({ + rows: [ + { + scene: { elements: [rect("a", 10, 20)] }, + version: "3", + updated_by: "agent", + updated_at: now, + }, + ], + }); + + const result = await handlers.getWhiteboard("agt_test"); + expect(result.elements).toEqual([ + { id: "a", type: "rectangle", x: 10, y: 20, width: 100, height: 50 }, + ]); + expect(result.version).toBe(3); + expect(result.updatedAt).toBe(now.toISOString()); + }); + }); +}); diff --git a/apps/web/package.json b/apps/web/package.json index df11d5bf..1ddd5d4f 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -17,6 +17,7 @@ "test:coverage": "vitest run --coverage" }, "dependencies": { + "@excalidraw/excalidraw": "^0.18.1", "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-dropdown-menu": "^2.1.16", diff --git a/apps/web/src/components/app/agents-view.tsx b/apps/web/src/components/app/agents-view.tsx index 4daadb21..10209ce3 100644 --- a/apps/web/src/components/app/agents-view.tsx +++ b/apps/web/src/components/app/agents-view.tsx @@ -9,8 +9,12 @@ import { import { createPortal } from "react-dom"; import { Routes, Route, useNavigate, useParams } from "react-router-dom"; import { PanelLeftOpen, PanelRightOpen } from "lucide-react"; +import { useAtomValue } from "jotai"; + +import { whiteboardAgentDrewAtomFamily } from "@/lib/store"; import { ChangesTab } from "@/components/app/changes-tab"; +import { WhiteboardPane } from "@/components/app/whiteboard-pane"; import { ChangesSettingsPopover } from "@/components/app/changes-settings-popover"; import { CenterPaneTabBar, @@ -128,7 +132,7 @@ export function AgentsView({ routeAgentId ?? null ); - const { changesMatch, onTabChange } = useAgentsViewRouting({ + const { changesMatch, whiteboardMatch, onTabChange } = useAgentsViewRouting({ routeAgentId, agentsLoaded, validatedSelectedAgentId, @@ -221,6 +225,10 @@ export function AgentsView({ ? (agents.find((agent) => agent.id === focusedAgentId) ?? null) : null; + const whiteboardAgentDrew = useAtomValue( + whiteboardAgentDrewAtomFamily(focusedAgentId ?? "") + ); + const { splitState, isSplit, exitSplit, updateSizes, handleTabDrop } = useSplitPane(focusedAgentId, isMobile); @@ -688,7 +696,13 @@ export function AgentsView({ {focusedAgent.name} { if (isSplit) { exitSplit(); @@ -696,6 +710,7 @@ export function AgentsView({ onTabChange(tab); }} diffStats={focusedDiffStats} + whiteboardAgentDrew={whiteboardAgentDrew} isSplit={isSplit} splitState={splitState} isMobile={isMobile} @@ -752,11 +767,18 @@ export function AgentsView({ <>
+ )} {stableTerminalContainerRef.current diff --git a/apps/web/src/components/app/center-pane-tab-bar.tsx b/apps/web/src/components/app/center-pane-tab-bar.tsx index e9a774bb..43e436a6 100644 --- a/apps/web/src/components/app/center-pane-tab-bar.tsx +++ b/apps/web/src/components/app/center-pane-tab-bar.tsx @@ -15,6 +15,7 @@ type TabDef = { const TABS: TabDef[] = [ { id: "terminal", label: "Terminal" }, { id: "changes", label: "Changes" }, + { id: "whiteboard", label: "Whiteboard" }, ]; const compactDiffCountFormatter = new Intl.NumberFormat("en-US", { @@ -30,6 +31,7 @@ type CenterPaneTabBarProps = { activeTab: CenterTab; onTabChange: (tab: CenterTab) => void; diffStats: DiffStats | null | undefined; + whiteboardAgentDrew?: boolean; isSplit: boolean; splitState: SplitPaneState; isMobile: boolean; @@ -39,6 +41,7 @@ export const CenterPaneTabBar = memo(function CenterPaneTabBar({ activeTab, onTabChange, diffStats, + whiteboardAgentDrew = false, isSplit, splitState, isMobile, @@ -94,6 +97,14 @@ export const CenterPaneTabBar = memo(function CenterPaneTabBar({ > {tab.label} + {tab.id === "whiteboard" && + whiteboardAgentDrew && + activeTab !== "whiteboard" ? ( + + ) : null} {activeTab === tab.id && !isSplit ? ( ) : null} diff --git a/apps/web/src/components/app/whiteboard-pane.tsx b/apps/web/src/components/app/whiteboard-pane.tsx new file mode 100644 index 00000000..3996cc16 --- /dev/null +++ b/apps/web/src/components/app/whiteboard-pane.tsx @@ -0,0 +1,44 @@ +import { lazy, Suspense, useEffect, useState } from "react"; +import { useAtom } from "jotai"; + +import { whiteboardAgentDrewAtomFamily } from "@/lib/store"; +import { cn } from "@/lib/utils"; + +const WhiteboardTab = lazy(() => import("@/components/app/whiteboard-tab")); + +type WhiteboardPaneProps = { + agentId: string | null; + active: boolean; +}; + +export function WhiteboardPane({ + agentId, + active, +}: WhiteboardPaneProps): JSX.Element | null { + const [opened, setOpened] = useState(false); + useEffect(() => { + if (active) setOpened(true); + }, [active]); + + const [agentDrew, setAgentDrew] = useAtom( + whiteboardAgentDrewAtomFamily(agentId ?? "") + ); + useEffect(() => { + if (active && agentId && agentDrew) setAgentDrew(false); + }, [active, agentId, agentDrew, setAgentDrew]); + + if (!opened || !agentId) return null; + return ( +
+ + Loading whiteboard… +
+ } + > + + +
+ ); +} diff --git a/apps/web/src/components/app/whiteboard-tab.tsx b/apps/web/src/components/app/whiteboard-tab.tsx new file mode 100644 index 00000000..e60fbd19 --- /dev/null +++ b/apps/web/src/components/app/whiteboard-tab.tsx @@ -0,0 +1,288 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { + Excalidraw, + exportToBlob, + getSceneVersion, +} from "@excalidraw/excalidraw"; +import type { + ExcalidrawImperativeAPI, + ExcalidrawInitialDataState, +} from "@excalidraw/excalidraw/types"; +import type { ExcalidrawElement } from "@excalidraw/excalidraw/element/types"; +import "@excalidraw/excalidraw/index.css"; + +import { + useWhiteboard, + whiteboardQueryKey, + type WhiteboardData, +} from "@/hooks/use-whiteboard"; +import { getThemeMode, useTheme } from "@/hooks/use-theme"; +import { api } from "@/lib/api"; + +declare global { + interface Window { + EXCALIDRAW_ASSET_PATH?: string; + } +} +window.EXCALIDRAW_ASSET_PATH = "/excalidraw/"; + +const SAVE_DEBOUNCE_MS = 1000; +const SNAPSHOT_DEBOUNCE_MS = 4000; + +type WhiteboardTabProps = { + agentId: string; + visible: boolean; +}; + +export default function WhiteboardTab({ + agentId, + visible, +}: WhiteboardTabProps): JSX.Element { + const { data, isLoading, isError } = useWhiteboard(agentId); + + if (isLoading || (!data && !isError)) { + return ( +
+ Loading whiteboard… +
+ ); + } + if (isError || !data) { + return ( +
+ Could not load the whiteboard. +
+ ); + } + return ( + + ); +} + +function WhiteboardCanvas({ + agentId, + initial, + visible, +}: { + agentId: string; + initial: WhiteboardData; + visible: boolean; +}): JSX.Element { + const queryClient = useQueryClient(); + const { theme } = useTheme(); + const [excalidrawAPI, setExcalidrawAPI] = + useState(null); + const [boardEmpty, setBoardEmpty] = useState( + initial.scene.elements.length === 0 + ); + const { data } = useWhiteboard(agentId); + + const versionRef = useRef(initial.version); + const sceneVersionRef = useRef( + getSceneVersion(initial.scene.elements as readonly ExcalidrawElement[]) + ); + const saveTimerRef = useRef(undefined); + const snapshotTimerRef = useRef(undefined); + const savingRef = useRef(false); + const pointerDownRef = useRef(false); + const pendingRemoteRef = useRef(null); + + const initialData = useMemo( + () => ({ + elements: initial.scene.elements as readonly ExcalidrawElement[], + scrollToContent: true, + }), + [initial] + ); + + const persistSnapshot = useCallback(async () => { + if (!excalidrawAPI) return; + const elements = excalidrawAPI.getSceneElements(); + if (elements.length === 0) { + try { + await api(`/api/v1/agents/${agentId}/whiteboard/snapshot`, { + method: "DELETE", + }); + } catch {} + return; + } + try { + const blob = await exportToBlob({ + elements, + appState: { + ...excalidrawAPI.getAppState(), + exportBackground: true, + }, + files: excalidrawAPI.getFiles(), + mimeType: "image/png", + }); + const form = new FormData(); + form.append("file", blob, "whiteboard.png"); + await api(`/api/v1/agents/${agentId}/whiteboard/snapshot`, { + method: "POST", + body: form, + }); + } catch { + // Snapshot is best-effort; the scene itself is already persisted. + } + }, [agentId, excalidrawAPI]); + + const persistScene = useCallback(async () => { + if (!excalidrawAPI || savingRef.current) return; + const elements = excalidrawAPI.getSceneElements(); + const sceneVersion = getSceneVersion(elements); + if (sceneVersion === sceneVersionRef.current) return; + savingRef.current = true; + try { + const res = await api<{ version: number }>( + `/api/v1/agents/${agentId}/whiteboard`, + { + method: "PUT", + body: JSON.stringify({ + scene: { elements }, + baseVersion: versionRef.current, + }), + } + ); + versionRef.current = res.version; + sceneVersionRef.current = sceneVersion; + queryClient.setQueryData( + whiteboardQueryKey(agentId), + (old) => + old + ? { + ...old, + scene: { elements: [...elements] }, + version: res.version, + } + : old + ); + if (snapshotTimerRef.current !== undefined) { + window.clearTimeout(snapshotTimerRef.current); + } + snapshotTimerRef.current = window.setTimeout(() => { + void persistSnapshot(); + }, SNAPSHOT_DEBOUNCE_MS); + } catch { + void queryClient.invalidateQueries({ + queryKey: whiteboardQueryKey(agentId), + exact: true, + }); + } finally { + savingRef.current = false; + } + }, [agentId, excalidrawAPI, persistSnapshot, queryClient]); + + const scheduleSave = useCallback(() => { + if (excalidrawAPI) { + setBoardEmpty(excalidrawAPI.getSceneElements().length === 0); + } + if (saveTimerRef.current !== undefined) { + window.clearTimeout(saveTimerRef.current); + } + saveTimerRef.current = window.setTimeout(() => { + saveTimerRef.current = undefined; + void persistScene(); + }, SAVE_DEBOUNCE_MS); + }, [excalidrawAPI, persistScene]); + + const applyRemote = useCallback( + (remote: WhiteboardData) => { + if (!excalidrawAPI) return; + versionRef.current = remote.version; + sceneVersionRef.current = getSceneVersion( + remote.scene.elements as readonly ExcalidrawElement[] + ); + excalidrawAPI.updateScene({ + elements: remote.scene.elements as ExcalidrawElement[], + }); + setBoardEmpty(remote.scene.elements.length === 0); + if (snapshotTimerRef.current !== undefined) { + window.clearTimeout(snapshotTimerRef.current); + } + snapshotTimerRef.current = window.setTimeout(() => { + void persistSnapshot(); + }, SNAPSHOT_DEBOUNCE_MS); + }, + [excalidrawAPI, persistSnapshot] + ); + + useEffect(() => { + if (!data || data.version <= versionRef.current) return; + if (pointerDownRef.current) { + pendingRemoteRef.current = data; + return; + } + applyRemote(data); + }, [data, applyRemote]); + + useEffect(() => { + const onPointerUp = () => { + pointerDownRef.current = false; + const pending = pendingRemoteRef.current; + if (pending) { + pendingRemoteRef.current = null; + applyRemote(pending); + } + }; + window.addEventListener("pointerup", onPointerUp); + return () => window.removeEventListener("pointerup", onPointerUp); + }, [applyRemote]); + + useEffect(() => { + if (visible) return; + if (saveTimerRef.current !== undefined) { + window.clearTimeout(saveTimerRef.current); + saveTimerRef.current = undefined; + void persistScene(); + } + }, [visible, persistScene]); + + useEffect(() => { + return () => { + if (saveTimerRef.current !== undefined) { + window.clearTimeout(saveTimerRef.current); + void persistScene(); + } + if (snapshotTimerRef.current !== undefined) { + window.clearTimeout(snapshotTimerRef.current); + void persistSnapshot(); + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( +
{ + pointerDownRef.current = true; + }} + > + {boardEmpty ? ( +
+

+ Sketch here — your agent can see this board. Ask it to “look + at the whiteboard” in the terminal. +

+
+ ) : null} + +
+ ); +} diff --git a/apps/web/src/hooks/use-agents-view-routing.ts b/apps/web/src/hooks/use-agents-view-routing.ts index cc7611ee..271a33b7 100644 --- a/apps/web/src/hooks/use-agents-view-routing.ts +++ b/apps/web/src/hooks/use-agents-view-routing.ts @@ -1,7 +1,11 @@ import { useCallback, useEffect } from "react"; import { useMatch, useNavigate } from "react-router-dom"; -import { agentChangesRoute, agentRoute } from "@/lib/agent-routes"; +import { + agentChangesRoute, + agentRoute, + agentWhiteboardRoute, +} from "@/lib/agent-routes"; type UseAgentsViewRoutingOptions = { routeAgentId: string | undefined; @@ -18,6 +22,7 @@ export function useAgentsViewRouting({ const feedbackMatch = useMatch("/agents/:agentId/feedback/:itemId"); const reviewMatch = useMatch("/agents/:agentId/review/:summaryAgentId"); const changesMatch = useMatch("/agents/:agentId/changes"); + const whiteboardMatch = useMatch("/agents/:agentId/whiteboard"); useEffect(() => { if (!routeAgentId) return; @@ -35,12 +40,14 @@ export function useAgentsViewRouting({ }, [agentsLoaded, feedbackMatch, navigate, reviewMatch, routeAgentId]); const onTabChange = useCallback( - (tab: "terminal" | "changes") => { + (tab: "terminal" | "changes" | "whiteboard") => { if (!routeAgentId) return; navigate( tab === "changes" ? agentChangesRoute(routeAgentId) - : agentRoute(routeAgentId), + : tab === "whiteboard" + ? agentWhiteboardRoute(routeAgentId) + : agentRoute(routeAgentId), { replace: true } ); }, @@ -49,6 +56,7 @@ export function useAgentsViewRouting({ return { changesMatch: !!changesMatch, + whiteboardMatch: !!whiteboardMatch, onTabChange, }; } diff --git a/apps/web/src/hooks/use-sse.ts b/apps/web/src/hooks/use-sse.ts index d1007dd1..157c159c 100644 --- a/apps/web/src/hooks/use-sse.ts +++ b/apps/web/src/hooks/use-sse.ts @@ -1,5 +1,6 @@ import { useEffect, useRef } from "react"; import { type QueryClient, useQueryClient } from "@tanstack/react-query"; +import { useStore } from "jotai"; import { type Agent, type AuthState, @@ -11,6 +12,7 @@ import { agentDiffQueryKey } from "@/hooks/use-agent-diff"; import { diffStatsQueryKey } from "@/hooks/use-agent-diff-stats"; import { sortAgentsByCreatedAtDesc } from "@/lib/agent-sort"; import { recordSSEEvent, recordSSEReconnect } from "@/lib/energy-metrics"; +import { whiteboardAgentDrewAtomFamily } from "@/lib/store"; import { showWebNotification } from "@/lib/web-notifications"; import { CACHED_RELEASE_INFO_QUERY_KEY, @@ -32,6 +34,12 @@ type UiEvent = } | { type: "agent.deleted"; agentId: string } | { type: "media.changed"; agentId: string } + | { + type: "whiteboard.changed"; + agentId: string; + version: number; + source: "user" | "agent"; + } | { type: "media.seen"; agentId: string; keys: string[] } | { type: "stream.started"; agentId: string } | { type: "stream.stopped"; agentId: string } @@ -132,6 +140,7 @@ export function applyReviewCreated( export function useSSE(authState: AuthState): void { const queryClient = useQueryClient(); + const jotaiStore = useStore(); const eventSourceRef = useRef(null); useEffect(() => { @@ -154,6 +163,7 @@ export function useSSE(authState: AuthState): void { void queryClient.invalidateQueries({ queryKey: ["jobs"] }); void queryClient.invalidateQueries({ queryKey: ["templates"] }); void queryClient.invalidateQueries({ queryKey: ["brain"] }); + void queryClient.invalidateQueries({ queryKey: ["whiteboard"] }); void queryClient.invalidateQueries({ queryKey: CACHED_RELEASE_INFO_QUERY_KEY, }); @@ -210,6 +220,20 @@ export function useSSE(authState: AuthState): void { return; } + if (payload.type === "whiteboard.changed") { + if (payload.source === "agent") { + void queryClient.invalidateQueries({ + queryKey: ["whiteboard", payload.agentId], + exact: true, + }); + jotaiStore.set( + whiteboardAgentDrewAtomFamily(payload.agentId), + true + ); + } + return; + } + if (payload.type === "media.seen") { const seen = new Set(payload.keys); queryClient.setQueryData( @@ -345,5 +369,5 @@ export function useSSE(authState: AuthState): void { document.removeEventListener("visibilitychange", onVisChange); closeSSE(); }; - }, [authState, queryClient]); + }, [authState, queryClient, jotaiStore]); } diff --git a/apps/web/src/hooks/use-theme.ts b/apps/web/src/hooks/use-theme.ts index df5085a2..36399a56 100644 --- a/apps/web/src/hooks/use-theme.ts +++ b/apps/web/src/hooks/use-theme.ts @@ -417,6 +417,10 @@ function applyTheme(themeId: ThemeId): void { } } +export function getThemeMode(themeId: ThemeId): "light" | "dark" { + return THEMES.find((t) => t.id === themeId)?.mode ?? "dark"; +} + export function useTheme(): { theme: ThemeId; setTheme: (id: ThemeId) => void; diff --git a/apps/web/src/hooks/use-whiteboard.ts b/apps/web/src/hooks/use-whiteboard.ts new file mode 100644 index 00000000..2150cc5f --- /dev/null +++ b/apps/web/src/hooks/use-whiteboard.ts @@ -0,0 +1,22 @@ +import { useQuery } from "@tanstack/react-query"; + +import { api } from "@/lib/api"; + +export type WhiteboardData = { + scene: { elements: unknown[] }; + version: number; + updatedAt: string | null; +}; + +export function whiteboardQueryKey(agentId: string): [string, string] { + return ["whiteboard", agentId]; +} + +export function useWhiteboard(agentId: string | null) { + return useQuery({ + queryKey: whiteboardQueryKey(agentId ?? ""), + queryFn: () => api(`/api/v1/agents/${agentId}/whiteboard`), + enabled: !!agentId, + staleTime: Infinity, + }); +} diff --git a/apps/web/src/lib/agent-routes.ts b/apps/web/src/lib/agent-routes.ts index 5b053e63..ab56e458 100644 --- a/apps/web/src/lib/agent-routes.ts +++ b/apps/web/src/lib/agent-routes.ts @@ -5,3 +5,7 @@ export function agentRoute(agentId: string): string { export function agentChangesRoute(agentId: string): string { return `/agents/${agentId}/changes`; } + +export function agentWhiteboardRoute(agentId: string): string { + return `/agents/${agentId}/whiteboard`; +} diff --git a/apps/web/src/lib/store.ts b/apps/web/src/lib/store.ts index 0ea3739e..b975dfe0 100644 --- a/apps/web/src/lib/store.ts +++ b/apps/web/src/lib/store.ts @@ -92,6 +92,10 @@ export const dismissedReleaseToastAtomFamily = atomFamily((tag: string) => atomWithLocalStorage(`dispatch:dismissedReleaseToast:${tag}`, false) ); +export const whiteboardAgentDrewAtomFamily = atomFamily((_agentId: string) => + atom(false) +); + export type DiffViewType = "unified" | "split"; export const diffViewTypeAtom = atomWithLocalStorage( @@ -289,7 +293,7 @@ export function reconcileDiffViewStateStorage( // Split pane state — per-agent split/single mode and pane sizes // --------------------------------------------------------------------------- -export type CenterTab = "terminal" | "changes"; +export type CenterTab = "terminal" | "changes" | "whiteboard"; export type SplitPaneState = { mode: "single" | "split"; diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 99a97d6f..588a7d4c 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -3,7 +3,7 @@ import type { Plugin } from "vite"; import react from "@vitejs/plugin-react"; import { VitePWA } from "vite-plugin-pwa"; import path from "node:path"; -import { existsSync, readFileSync } from "node:fs"; +import { existsSync, readFileSync, cpSync } from "node:fs"; const isProd = process.env.NODE_ENV === "production"; const browserExtensionArchiveName = "dispatch-browser-feedback.zip"; @@ -55,6 +55,44 @@ function browserExtensionArchivePlugin(): Plugin { }; } +function excalidrawAssets(): Plugin { + const fontsDir = path.resolve( + __dirname, + "node_modules/@excalidraw/excalidraw/dist/prod/fonts" + ); + const publicPrefix = "/excalidraw/fonts/"; + const skipFamilies = new Set(["Xiaolai"]); + return { + name: "excalidraw-assets", + configureServer(server) { + server.middlewares.use((req, res, next) => { + const url = (req.url ?? "").split("?")[0]; + if (!url.startsWith(publicPrefix)) return next(); + const rel = decodeURIComponent(url.slice(publicPrefix.length)); + const file = path.join(fontsDir, rel); + if (!file.startsWith(fontsDir) || !existsSync(file)) { + res.statusCode = 404; + return res.end(); + } + res.setHeader( + "Content-Type", + file.endsWith(".woff2") ? "font/woff2" : "font/woff" + ); + return res.end(readFileSync(file)); + }); + }, + writeBundle(options) { + const outDir = options.dir ?? path.resolve(__dirname, "dist"); + cpSync(fontsDir, path.join(outDir, "excalidraw/fonts"), { + recursive: true, + filter: (src) => + !skipFamilies.has(path.basename(path.dirname(src))) && + !skipFamilies.has(path.basename(src)), + }); + }, + }; +} + // Bake the workspace version into the bundle. The web client compares // this against the `X-Dispatch-Version` response header to detect a // stale bundle after a server self-update. @@ -85,6 +123,7 @@ export default defineConfig({ plugins: [ react(), browserExtensionArchivePlugin(), + excalidrawAssets(), isProd && VitePWA({ registerType: "prompt", diff --git a/e2e/whiteboard.spec.ts b/e2e/whiteboard.spec.ts new file mode 100644 index 00000000..8ef4fdb0 --- /dev/null +++ b/e2e/whiteboard.spec.ts @@ -0,0 +1,231 @@ +import { test, expect } from "@playwright/test"; +import { + cleanupE2EAgents, + clickAgentRow, + createAgentViaAPI, + loadApp, +} from "./helpers"; + +const AUTH_HEADER = { + Authorization: `Bearer ${process.env.AUTH_TOKEN ?? "dev-token"}`, +}; + +async function callMcpTool( + request: Parameters[1]>[0]["request"], + agentId: string, + toolName: string, + args: Record +): Promise> { + const res = await request.fetch(`/api/mcp/${agentId}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + }, + data: { + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { name: toolName, arguments: args }, + }, + }); + const text = await res.text(); + const dataLine = text.split("\n").find((l) => l.startsWith("data: ")); + if (!dataLine) throw new Error(`No data line in MCP response: ${text}`); + return JSON.parse(dataLine.slice("data: ".length)) as Record; +} + +test.describe("Whiteboard", () => { + test.afterEach(async ({ request }) => { + await cleanupE2EAgents(request); + }); + + test("whiteboard tab is visible and navigates", async ({ page, request }) => { + const agent = await createAgentViaAPI(request, { + name: `e2e-agent-wb-${Date.now()}`, + }); + await loadApp(page); + await clickAgentRow(page, agent.id); + + const wbTab = page.getByTestId("center-tab-whiteboard"); + await expect(wbTab).toBeVisible(); + await wbTab.click(); + + await page.waitForURL(/\/agents\/[^/]+\/whiteboard/); + }); + + test("whiteboard REST API: PUT persists and GET retrieves scene", async ({ + request, + }) => { + const agent = await createAgentViaAPI(request, { + name: `e2e-agent-wb-api-${Date.now()}`, + }); + + // GET should return empty scene initially + const getRes1 = await request.get(`/api/v1/agents/${agent.id}/whiteboard`, { + headers: AUTH_HEADER, + }); + expect(getRes1.ok()).toBe(true); + const data1 = (await getRes1.json()) as { + scene: { elements: unknown[] }; + version: number; + }; + expect(data1.scene.elements).toEqual([]); + expect(data1.version).toBe(0); + + // PUT a scene + const scene = { + elements: [ + { + id: "test-box", + type: "rectangle", + x: 100, + y: 100, + width: 160, + height: 70, + }, + ], + }; + const putRes = await request.put(`/api/v1/agents/${agent.id}/whiteboard`, { + headers: { ...AUTH_HEADER, "content-type": "application/json" }, + data: { scene, baseVersion: 0 }, + }); + expect(putRes.ok()).toBe(true); + const putData = (await putRes.json()) as { ok: boolean; version: number }; + expect(putData.ok).toBe(true); + expect(putData.version).toBe(1); + + // GET should now return the scene + const getRes2 = await request.get(`/api/v1/agents/${agent.id}/whiteboard`, { + headers: AUTH_HEADER, + }); + const data2 = (await getRes2.json()) as { + scene: { elements: unknown[] }; + version: number; + }; + expect(data2.scene.elements).toHaveLength(1); + expect((data2.scene.elements[0] as { id: string }).id).toBe("test-box"); + expect(data2.version).toBe(1); + }); + + test("whiteboard REST API: PUT rejects stale baseVersion with 409", async ({ + request, + }) => { + const agent = await createAgentViaAPI(request, { + name: `e2e-agent-wb-conflict-${Date.now()}`, + }); + + const scene = { + elements: [ + { id: "a", type: "rectangle", x: 0, y: 0, width: 100, height: 50 }, + ], + }; + + // First PUT succeeds + const putRes1 = await request.put(`/api/v1/agents/${agent.id}/whiteboard`, { + headers: { ...AUTH_HEADER, "content-type": "application/json" }, + data: { scene, baseVersion: 0 }, + }); + expect(putRes1.ok()).toBe(true); + + // Second PUT with stale baseVersion=0 should get 409 + const putRes2 = await request.put(`/api/v1/agents/${agent.id}/whiteboard`, { + headers: { ...AUTH_HEADER, "content-type": "application/json" }, + data: { scene, baseVersion: 0 }, + }); + expect(putRes2.status()).toBe(409); + const conflictData = (await putRes2.json()) as { + error: string; + version: number; + }; + expect(conflictData.error).toContain("modified"); + expect(conflictData.version).toBe(1); + }); + + test("whiteboard MCP tool: agent can update and read whiteboard", async ({ + request, + }) => { + const agent = await createAgentViaAPI(request, { + name: `e2e-agent-wb-mcp-${Date.now()}`, + }); + + // Call whiteboard_update via MCP + const updateJson = await callMcpTool( + request, + agent.id, + "whiteboard_update", + { + elements: [ + { + id: "api-box", + type: "rectangle", + x: 100, + y: 100, + width: 160, + height: 70, + backgroundColor: "#a5d8ff", + }, + { + id: "api-label", + type: "text", + x: 110, + y: 120, + width: 140, + height: 25, + text: "API Server", + originalText: "API Server", + containerId: "api-box", + }, + ], + } + ); + + const updateResult = updateJson.result as { + content?: Array<{ text?: string }>; + }; + const updateContent = JSON.parse( + updateResult?.content?.[0]?.text ?? "{}" + ) as { + ok: boolean; + addedIds: string[]; + elementCount: number; + }; + expect(updateContent.ok).toBe(true); + expect(updateContent.addedIds).toEqual(["api-box", "api-label"]); + expect(updateContent.elementCount).toBe(2); + + // Call whiteboard_get via MCP + const getJson = await callMcpTool(request, agent.id, "whiteboard_get", {}); + const getResult = getJson.result as { + structuredContent?: { + elementCount: number; + elements: Array<{ id: string }>; + }; + }; + expect(getResult?.structuredContent?.elementCount).toBe(2); + expect( + getResult?.structuredContent?.elements?.map((e) => e.id).sort() + ).toEqual(["api-box", "api-label"]); + + // Call whiteboard_clear via MCP + const clearJson = await callMcpTool( + request, + agent.id, + "whiteboard_clear", + {} + ); + const clearResult = clearJson.result as { + content?: Array<{ text?: string }>; + }; + expect(clearResult?.content?.[0]?.text).toContain("ok"); + + // Verify board is empty via REST + const getRes2 = await request.get(`/api/v1/agents/${agent.id}/whiteboard`, { + headers: AUTH_HEADER, + }); + const data2 = (await getRes2.json()) as { + scene: { elements: unknown[] }; + }; + expect(data2.scene.elements).toEqual([]); + }); +}); From 0933aed9dee425b7fdca091be8a29b2c9c68d2a2 Mon Sep 17 00:00:00 2001 From: Brad Harris Date: Fri, 10 Jul 2026 16:04:16 -0600 Subject: [PATCH 2/6] Fix whiteboard not repainting after tab switch When the whiteboard tab is hidden (display:none) and an agent updates the scene via MCP, Excalidraw's updateScene() updates internal state but the canvas can't repaint at zero dimensions. Call refresh() when the tab becomes visible so the canvas repaints with the latest content. Co-Authored-By: Claude Opus 4.6 --- apps/web/src/components/app/whiteboard-tab.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/web/src/components/app/whiteboard-tab.tsx b/apps/web/src/components/app/whiteboard-tab.tsx index e60fbd19..33cb3086 100644 --- a/apps/web/src/components/app/whiteboard-tab.tsx +++ b/apps/web/src/components/app/whiteboard-tab.tsx @@ -230,6 +230,12 @@ function WhiteboardCanvas({ return () => window.removeEventListener("pointerup", onPointerUp); }, [applyRemote]); + useEffect(() => { + if (visible && excalidrawAPI) { + excalidrawAPI.refresh(); + } + }, [visible, excalidrawAPI]); + useEffect(() => { if (visible) return; if (saveTimerRef.current !== undefined) { From 40340c32efbf4aa1d95f42785aac0a9ae1d65969 Mon Sep 17 00:00:00 2001 From: Brad Harris Date: Sat, 11 Jul 2026 13:33:32 -0600 Subject: [PATCH 3/6] Fix whiteboard going blank after agent updates via restoreElements hydration Raw server elements lack Excalidraw internal properties (seed, version, roughness, opacity, strokeWidth, etc.) needed for rendering. Use restoreElements() to hydrate sparse server elements before updateScene() so the canvas renders correctly without requiring a page reload. Co-Authored-By: Claude Opus 4.6 --- apps/server/src/server/mcp-handlers.ts | 8 +- .../web/src/components/app/whiteboard-tab.tsx | 16 +- pnpm-lock.yaml | 1370 ++++++++++++++++- 3 files changed, 1330 insertions(+), 64 deletions(-) diff --git a/apps/server/src/server/mcp-handlers.ts b/apps/server/src/server/mcp-handlers.ts index a38bd1aa..97ef66d1 100644 --- a/apps/server/src/server/mcp-handlers.ts +++ b/apps/server/src/server/mcp-handlers.ts @@ -710,10 +710,10 @@ export function createMcpHandlers(deps: CreateMcpHandlersDeps) { }); const whiteboardHandlers = createWhiteboardHandlers({ - pool, - mediaRoot, - agentManager, - publishUiEvent, + pool: deps.pool, + mediaRoot: deps.mediaRoot, + agentManager: deps.agentManager, + publishUiEvent: deps.publishUiEvent, }); return { diff --git a/apps/web/src/components/app/whiteboard-tab.tsx b/apps/web/src/components/app/whiteboard-tab.tsx index 33cb3086..7186f803 100644 --- a/apps/web/src/components/app/whiteboard-tab.tsx +++ b/apps/web/src/components/app/whiteboard-tab.tsx @@ -4,6 +4,7 @@ import { Excalidraw, exportToBlob, getSceneVersion, + restoreElements, } from "@excalidraw/excalidraw"; import type { ExcalidrawImperativeAPI, @@ -190,14 +191,15 @@ function WhiteboardCanvas({ const applyRemote = useCallback( (remote: WhiteboardData) => { if (!excalidrawAPI) return; - versionRef.current = remote.version; - sceneVersionRef.current = getSceneVersion( - remote.scene.elements as readonly ExcalidrawElement[] + const hydrated = restoreElements( + remote.scene.elements as ExcalidrawElement[], + excalidrawAPI.getSceneElements(), + { repairBindings: true } ); - excalidrawAPI.updateScene({ - elements: remote.scene.elements as ExcalidrawElement[], - }); - setBoardEmpty(remote.scene.elements.length === 0); + versionRef.current = remote.version; + sceneVersionRef.current = getSceneVersion(hydrated); + excalidrawAPI.updateScene({ elements: hydrated }); + setBoardEmpty(hydrated.length === 0); if (snapshotTimerRef.current !== undefined) { window.clearTimeout(snapshotTimerRef.current); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 21262d3b..0d4e684b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -46,10 +46,10 @@ importers: version: 5.9.3 vite: specifier: ^6.4.1 - version: 6.4.1(@types/node@24.12.0)(jiti@1.21.7)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + version: 6.4.1(@types/node@24.12.0)(jiti@1.21.7)(sass@1.51.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) vitest: specifier: ^4.1.2 - version: 4.1.2(@types/node@24.12.0)(happy-dom@18.0.1)(jsdom@29.1.1)(vite@6.4.1(@types/node@24.12.0)(jiti@1.21.7)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.2(@types/node@24.12.0)(happy-dom@18.0.1)(jsdom@29.1.1)(vite@6.4.1(@types/node@24.12.0)(jiti@1.21.7)(sass@1.51.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) apps/server: dependencies: @@ -107,16 +107,19 @@ importers: version: 8.18.1 "@vitest/coverage-v8": specifier: 4.1.2 - version: 4.1.2(vitest@4.1.2(@types/node@24.12.0)(happy-dom@18.0.1)(jsdom@29.1.1)(vite@6.4.1(@types/node@24.12.0)(jiti@1.21.7)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))) + version: 4.1.2(vitest@4.1.2(@types/node@24.12.0)(happy-dom@18.0.1)(jsdom@29.1.1)(vite@6.4.1(@types/node@24.12.0)(jiti@1.21.7)(sass@1.51.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))) vite: specifier: ^6.0.0 - version: 6.4.1(@types/node@24.12.0)(jiti@1.21.7)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + version: 6.4.1(@types/node@24.12.0)(jiti@1.21.7)(sass@1.51.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) vitest: specifier: ^4.0.18 - version: 4.1.2(@types/node@24.12.0)(happy-dom@18.0.1)(jsdom@29.1.1)(vite@6.4.1(@types/node@24.12.0)(jiti@1.21.7)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.2(@types/node@24.12.0)(happy-dom@18.0.1)(jsdom@29.1.1)(vite@6.4.1(@types/node@24.12.0)(jiti@1.21.7)(sass@1.51.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) apps/web: dependencies: + "@excalidraw/excalidraw": + specifier: ^0.18.1 + version: 0.18.1(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(immer@11.1.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) "@radix-ui/react-checkbox": specifier: ^1.3.3 version: 1.3.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -249,10 +252,10 @@ importers: version: 18.3.7(@types/react@18.3.28) "@vitejs/plugin-react": specifier: ^4.7.0 - version: 4.7.0(vite@5.4.21(@types/node@24.12.0)(terser@5.46.1)) + version: 4.7.0(vite@5.4.21(@types/node@24.12.0)(sass@1.51.0)(terser@5.46.1)) "@vitest/coverage-v8": specifier: 2.1.9 - version: 2.1.9(vitest@2.1.9(@types/node@24.12.0)(happy-dom@18.0.1)(jsdom@29.1.1)(terser@5.46.1)) + version: 2.1.9(vitest@2.1.9(@types/node@24.12.0)(happy-dom@18.0.1)(jsdom@29.1.1)(sass@1.51.0)(terser@5.46.1)) autoprefixer: specifier: ^10.4.20 version: 10.4.27(postcss@8.5.8) @@ -285,13 +288,13 @@ importers: version: 8.57.2(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) vite: specifier: ^5.4.21 - version: 5.4.21(@types/node@24.12.0)(terser@5.46.1) + version: 5.4.21(@types/node@24.12.0)(sass@1.51.0)(terser@5.46.1) vite-plugin-pwa: specifier: ^1.2.0 - version: 1.2.0(vite@5.4.21(@types/node@24.12.0)(terser@5.46.1))(workbox-build@7.4.0(@types/babel__core@7.20.5))(workbox-window@7.4.0) + version: 1.2.0(vite@5.4.21(@types/node@24.12.0)(sass@1.51.0)(terser@5.46.1))(workbox-build@7.4.0(@types/babel__core@7.20.5))(workbox-window@7.4.0) vitest: specifier: ^2.1.0 - version: 2.1.9(@types/node@24.12.0)(happy-dom@18.0.1)(jsdom@29.1.1)(terser@5.46.1) + version: 2.1.9(@types/node@24.12.0)(happy-dom@18.0.1)(jsdom@29.1.1)(sass@1.51.0)(terser@5.46.1) packages: "@alloc/quick-lru@5.2.0": @@ -1154,6 +1157,12 @@ packages: } engines: { node: ">=18" } + "@braintree/sanitize-url@6.0.2": + resolution: + { + integrity: sha512-Tbsj02wXCbqGmzdnXNk0SOF19ChhRU70BsroIi4Pm6Ehp56in6vch94mfbdQ17DozxkL3BAVjbZ4Qc1a0HFRAg==, + } + "@braintree/sanitize-url@7.1.2": resolution: { @@ -1167,12 +1176,42 @@ packages: } hasBin: true + "@chevrotain/cst-dts-gen@11.0.3": + resolution: + { + integrity: sha512-BvIKpRLeS/8UbfxXxgC33xOumsacaeCKAjAeLyOn7Pcp95HiRbrpl14S+9vaZLolnbssPIUuiUd8IvgkRyt6NQ==, + } + + "@chevrotain/gast@11.0.3": + resolution: + { + integrity: sha512-+qNfcoNk70PyS/uxmj3li5NiECO+2YKZZQMbmjTqRI3Qchu8Hig/Q9vgkHpI3alNjr7M+a2St5pw5w5F6NL5/Q==, + } + + "@chevrotain/regexp-to-ast@11.0.3": + resolution: + { + integrity: sha512-1fMHaBZxLFvWI067AVbGJav1eRY7N8DDvYCTwGBiE/ytKBgP8azTdgyrKyWZ9Mfh09eHWb5PgTSO8wi7U824RA==, + } + + "@chevrotain/types@11.0.3": + resolution: + { + integrity: sha512-gsiM3G8b58kZC2HaWR50gu6Y1440cHiJ+i3JUvcp/35JchYejb2+5MVeJK0iKThYpAa/P2PYFV4hoi44HD+aHQ==, + } + "@chevrotain/types@11.1.2": resolution: { integrity: sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==, } + "@chevrotain/utils@11.0.3": + resolution: + { + integrity: sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ==, + } + "@csstools/color-helpers@6.0.2": resolution: { @@ -1979,6 +2018,40 @@ packages: } engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + "@excalidraw/excalidraw@0.18.1": + resolution: + { + integrity: sha512-6i5Gt7IDTOH//qa0Z315Ly5iVRhjWpu2whrlQFqkuwrkKUWgRsMk0P5qdE7bpyDpai7jeLeWYkyj1eVAfni1lw==, + } + peerDependencies: + react: ^17.0.2 || ^18.2.0 || ^19.0.0 + react-dom: ^17.0.2 || ^18.2.0 || ^19.0.0 + + "@excalidraw/laser-pointer@1.3.1": + resolution: + { + integrity: sha512-psA1z1N2qeAfsORdXc9JmD2y4CmDwmuMRxnNdJHZexIcPwaNEyIpNcelw+QkL9rz9tosaN9krXuKaRqYpRAR6g==, + } + + "@excalidraw/markdown-to-text@0.1.2": + resolution: + { + integrity: sha512-1nDXBNAojfi3oSFwJswKREkFm5wrSjqay81QlyRv2pkITG/XYB5v+oChENVBQLcxQwX4IUATWvXM5BcaNhPiIg==, + } + + "@excalidraw/mermaid-to-excalidraw@2.2.2": + resolution: + { + integrity: sha512-5VKQq5CdRocC82vOIUpQ5ufJOVV9FpBTdHGA+ULqazeIVV+cr299877omQCibsdS3Bpitz2fsnTwnIXEmLVDSg==, + } + + "@excalidraw/random-username@1.1.0": + resolution: + { + integrity: sha512-nULYsQxkWHnbmHvcs+efMkJ4/9TtvNyFeLyHdeGxW0zHs6P+jYVqcRff9A6Vq9w9JXeDRnRh2VKvTtS19GW2qA==, + } + engines: { node: ">=10" } + "@exodus/bytes@1.15.0": resolution: { @@ -2416,6 +2489,12 @@ packages: } engines: { node: ">=8" } + "@mermaid-js/parser@0.6.3": + resolution: + { + integrity: sha512-lnjOhe7zyHjc+If7yT4zoedx2vo4sHaTmtkl1+or8BRTnCtDmcTpAjpzDSfCZrshM5bCoz0GyidzadJAH1xobA==, + } + "@mermaid-js/parser@1.1.1": resolution: { @@ -2483,12 +2562,40 @@ packages: integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==, } + "@radix-ui/primitive@1.0.0": + resolution: + { + integrity: sha512-3e7rn8FDMin4CgeL7Z/49smCA3rFYY3Ha2rUQ7HRWFadS5iCRw08ZgVT1LaNTCNqgvrUiyczLflrVrF0SRQtNA==, + } + + "@radix-ui/primitive@1.1.1": + resolution: + { + integrity: sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA==, + } + "@radix-ui/primitive@1.1.3": resolution: { integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==, } + "@radix-ui/react-arrow@1.1.2": + resolution: + { + integrity: sha512-G+KcpzXHq24iH0uGG/pF8LyzpFJYGD4RfLjCIBfGdSLXvjLHST31RUiRVrupIBMvIppMgSzQ6l66iAxl03tdlg==, + } + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + "@radix-ui/react-arrow@1.1.7": resolution: { @@ -2521,6 +2628,15 @@ packages: "@types/react-dom": optional: true + "@radix-ui/react-collection@1.0.1": + resolution: + { + integrity: sha512-uuiFbs+YCKjn3X1DTSx9G7BHApu4GHbi3kgiwsnFUbOKCrwejAJv4eE4Vc8C0Oaxt9T0aV4ox0WCOdx+39Xo+g==, + } + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + "@radix-ui/react-collection@1.1.7": resolution: { @@ -2537,6 +2653,26 @@ packages: "@types/react-dom": optional: true + "@radix-ui/react-compose-refs@1.0.0": + resolution: + { + integrity: sha512-0KaSv6sx787/hK3eF53iOkiSLwAGlFMx5lotrqD2pTjB18KbybKoEIgkNZTKC60YECDQTKGTRcDBILwZVqVKvA==, + } + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 + + "@radix-ui/react-compose-refs@1.1.1": + resolution: + { + integrity: sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw==, + } + peerDependencies: + "@types/react": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@radix-ui/react-compose-refs@1.1.2": resolution: { @@ -2549,6 +2685,26 @@ packages: "@types/react": optional: true + "@radix-ui/react-context@1.0.0": + resolution: + { + integrity: sha512-1pVM9RfOQ+n/N5PJK33kRSKsr1glNxomxONs5c49MliinBY6Yw2Q995qfBUUo0/Mbg05B/sGA0gkgPI7kmSHBg==, + } + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 + + "@radix-ui/react-context@1.1.1": + resolution: + { + integrity: sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==, + } + peerDependencies: + "@types/react": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@radix-ui/react-context@1.1.2": resolution: { @@ -2577,6 +2733,14 @@ packages: "@types/react-dom": optional: true + "@radix-ui/react-direction@1.0.0": + resolution: + { + integrity: sha512-2HV05lGUgYcA6xgLQ4BKPDmtL+QbIZYH5fCOTAOOcJ5O0QbWS3i9lKaurLzliYUDhORI2Qr3pyjhJh44lKA3rQ==, + } + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 + "@radix-ui/react-direction@1.1.1": resolution: { @@ -2605,6 +2769,22 @@ packages: "@types/react-dom": optional: true + "@radix-ui/react-dismissable-layer@1.1.5": + resolution: + { + integrity: sha512-E4TywXY6UsXNRhFrECa5HAvE5/4BFcGyfTyK36gP+pAW1ed7UTK4vKwdr53gAJYwqbfCWC6ATvJa3J3R/9+Qrg==, + } + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + "@radix-ui/react-dropdown-menu@2.1.16": resolution: { @@ -2621,6 +2801,18 @@ packages: "@types/react-dom": optional: true + "@radix-ui/react-focus-guards@1.1.1": + resolution: + { + integrity: sha512-pSIwfrT1a6sIoDASCSpFwOasEwKTZWDw/iBdtnqKO7v6FeOzYJ7U53cPzYFVR3geGGXgVHaH+CdngrrAzqUGxg==, + } + peerDependencies: + "@types/react": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@radix-ui/react-focus-guards@1.1.3": resolution: { @@ -2633,6 +2825,22 @@ packages: "@types/react": optional: true + "@radix-ui/react-focus-scope@1.1.2": + resolution: + { + integrity: sha512-zxwE80FCU7lcXUGWkdt6XpTTCKPitG1XKOwViTxHVKIJhZl9MvIl2dVHeZENCWD9+EdWv05wlaEkRXUykU27RA==, + } + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + "@radix-ui/react-focus-scope@1.1.7": resolution: { @@ -2649,6 +2857,26 @@ packages: "@types/react-dom": optional: true + "@radix-ui/react-id@1.0.0": + resolution: + { + integrity: sha512-Q6iAB/U7Tq3NTolBBQbHTgclPmGWE3OlktGGqrClPozSw4vkQ1DfQAOtzgRPecKsMdJINE05iaoDUG8tRzCBjw==, + } + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 + + "@radix-ui/react-id@1.1.0": + resolution: + { + integrity: sha512-EJUrI8yYh7WOjNOqpoJaf1jlFIH2LvtgAl+YcFqNCa+4hj64ZXmPkAKOFs/ukjz3byN6bdb/AVUqHkI8/uWWMA==, + } + peerDependencies: + "@types/react": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@radix-ui/react-id@1.1.1": resolution: { @@ -2693,6 +2921,38 @@ packages: "@types/react-dom": optional: true + "@radix-ui/react-popover@1.1.6": + resolution: + { + integrity: sha512-NQouW0x4/GnkFJ/pRqsIS3rM/k97VzKnVb2jB7Gq7VEGPy5g7uNV1ykySFt7eWSp3i2uSGFwaJcvIRJBAHmmFg==, + } + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + + "@radix-ui/react-popper@1.2.2": + resolution: + { + integrity: sha512-Rvqc3nOpwseCyj/rgjlJDYAgyfw7OC1tTkKn2ivhaMGcYt8FSBlahHOZak2i3QwkRXUXgGgzeEe2RuqeEHuHgA==, + } + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + "@radix-ui/react-popper@1.2.8": resolution: { @@ -2709,6 +2969,22 @@ packages: "@types/react-dom": optional: true + "@radix-ui/react-portal@1.1.4": + resolution: + { + integrity: sha512-sn2O9k1rPFYVyKd5LAJfo96JlSGVFpa1fS6UuBJfrZadudiw5tAmru+n1x7aMRQ84qDM71Zh1+SzK5QwU0tJfA==, + } + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + "@radix-ui/react-portal@1.1.9": resolution: { @@ -2725,6 +3001,31 @@ packages: "@types/react-dom": optional: true + "@radix-ui/react-presence@1.0.0": + resolution: + { + integrity: sha512-A+6XEvN01NfVWiKu38ybawfHsBjWum42MRPnEuqPsBZ4eV7e/7K321B5VgYMPv3Xx5An6o1/l9ZuDBgmcmWK3w==, + } + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + + "@radix-ui/react-presence@1.1.2": + resolution: + { + integrity: sha512-18TFr80t5EVgL9x1SwF/YGtfG+l0BS0PRAlCWBDoBEiDQjeKgnNZRVJp/oVBl24sr3Gbfwc/Qpj4OcWTQMsAEg==, + } + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + "@radix-ui/react-presence@1.1.5": resolution: { @@ -2741,6 +3042,31 @@ packages: "@types/react-dom": optional: true + "@radix-ui/react-primitive@1.0.1": + resolution: + { + integrity: sha512-fHbmislWVkZaIdeF6GZxF0A/NH/3BjrGIYj+Ae6eTmTCr7EB0RQAAVEiqsXK6p3/JcRqVSBQoceZroj30Jj3XA==, + } + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + + "@radix-ui/react-primitive@2.0.2": + resolution: + { + integrity: sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w==, + } + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + "@radix-ui/react-primitive@2.1.3": resolution: { @@ -2773,6 +3099,15 @@ packages: "@types/react-dom": optional: true + "@radix-ui/react-roving-focus@1.0.2": + resolution: + { + integrity: sha512-HLK+CqD/8pN6GfJm3U+cqpqhSKYAWiOJDe+A+8MfxBnOue39QEeMa43csUn2CXCHQT0/mewh1LrrG4tfkM9DMA==, + } + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + "@radix-ui/react-roving-focus@1.1.11": resolution: { @@ -2821,6 +3156,26 @@ packages: "@types/react-dom": optional: true + "@radix-ui/react-slot@1.0.1": + resolution: + { + integrity: sha512-avutXAFL1ehGvAXtPquu0YK5oz6ctS474iM3vNGQIkswrVhdrS52e3uoMQBzZhNRAIE0jBnUyXWNmSjGHhCFcw==, + } + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 + + "@radix-ui/react-slot@1.1.2": + resolution: + { + integrity: sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==, + } + peerDependencies: + "@types/react": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@radix-ui/react-slot@1.2.3": resolution: { @@ -2861,6 +3216,15 @@ packages: "@types/react-dom": optional: true + "@radix-ui/react-tabs@1.0.2": + resolution: + { + integrity: sha512-gOUwh+HbjCuL0UCo8kZ+kdUEG8QtpdO4sMQduJ34ZEz0r4922g9REOBM+vIsfwtGxSug4Yb1msJMJYN2Bk8TpQ==, + } + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + "@radix-ui/react-tooltip@1.2.8": resolution: { @@ -2877,6 +3241,26 @@ packages: "@types/react-dom": optional: true + "@radix-ui/react-use-callback-ref@1.0.0": + resolution: + { + integrity: sha512-GZtyzoHz95Rhs6S63D2t/eqvdFCm7I+yHMLVQheKM7nBD8mbZIt+ct1jz4536MDnaOGKIxynJ8eHTkVGVVkoTg==, + } + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 + + "@radix-ui/react-use-callback-ref@1.1.0": + resolution: + { + integrity: sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw==, + } + peerDependencies: + "@types/react": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@radix-ui/react-use-callback-ref@1.1.1": resolution: { @@ -2889,10 +3273,86 @@ packages: "@types/react": optional: true - "@radix-ui/react-use-controllable-state@1.2.2": + "@radix-ui/react-use-controllable-state@1.0.0": + resolution: + { + integrity: sha512-FohDoZvk3mEXh9AWAVyRTYR4Sq7/gavuofglmiXB2g1aKyboUD4YtgWxKj8O5n+Uak52gXQ4wKz5IFST4vtJHg==, + } + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 + + "@radix-ui/react-use-controllable-state@1.1.0": + resolution: + { + integrity: sha512-MtfMVJiSr2NjzS0Aa90NPTnvTSg6C/JLCV7ma0W6+OMV78vd8OyRpID+Ng9LxzsPbLeuBnWBA1Nq30AtBIDChw==, + } + peerDependencies: + "@types/react": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + + "@radix-ui/react-use-controllable-state@1.2.2": + resolution: + { + integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==, + } + peerDependencies: + "@types/react": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + + "@radix-ui/react-use-effect-event@0.0.2": + resolution: + { + integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==, + } + peerDependencies: + "@types/react": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + + "@radix-ui/react-use-escape-keydown@1.1.0": + resolution: + { + integrity: sha512-L7vwWlR1kTTQ3oh7g1O0CBF3YCyyTj8NmhLR+phShpyA50HCfBFKVJTpshm9PzLiKmehsrQzTYTpX9HvmC9rhw==, + } + peerDependencies: + "@types/react": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + + "@radix-ui/react-use-escape-keydown@1.1.1": + resolution: + { + integrity: sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==, + } + peerDependencies: + "@types/react": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + + "@radix-ui/react-use-layout-effect@1.0.0": resolution: { - integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==, + integrity: sha512-6Tpkq+R6LOlmQb1R5NNETLG0B4YP0wc+klfXafpUCj6JGyaUc8il7/kUZ7m59rGbXGczE9Bs+iz2qloqsZBduQ==, + } + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 + + "@radix-ui/react-use-layout-effect@1.1.0": + resolution: + { + integrity: sha512-+FPE0rOdziWSrH9athwI1R0HDVbWlEhd+FR+aSDk4uWGmSJ9Z54sdZVDQPZAinJhJXwfT+qnj969mCsT2gfm5w==, } peerDependencies: "@types/react": "*" @@ -2901,10 +3361,10 @@ packages: "@types/react": optional: true - "@radix-ui/react-use-effect-event@0.0.2": + "@radix-ui/react-use-layout-effect@1.1.1": resolution: { - integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==, + integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==, } peerDependencies: "@types/react": "*" @@ -2913,10 +3373,10 @@ packages: "@types/react": optional: true - "@radix-ui/react-use-escape-keydown@1.1.1": + "@radix-ui/react-use-previous@1.1.1": resolution: { - integrity: sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==, + integrity: sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==, } peerDependencies: "@types/react": "*" @@ -2925,10 +3385,10 @@ packages: "@types/react": optional: true - "@radix-ui/react-use-layout-effect@1.1.1": + "@radix-ui/react-use-rect@1.1.0": resolution: { - integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==, + integrity: sha512-0Fmkebhr6PiseyZlYAOtLS+nb7jLmpqTrJyv61Pe68MKYW6OWdRE2kI70TaYY27u7H0lajqM3hSMMLFq18Z7nQ==, } peerDependencies: "@types/react": "*" @@ -2937,10 +3397,10 @@ packages: "@types/react": optional: true - "@radix-ui/react-use-previous@1.1.1": + "@radix-ui/react-use-rect@1.1.1": resolution: { - integrity: sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==, + integrity: sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==, } peerDependencies: "@types/react": "*" @@ -2949,10 +3409,10 @@ packages: "@types/react": optional: true - "@radix-ui/react-use-rect@1.1.1": + "@radix-ui/react-use-size@1.1.0": resolution: { - integrity: sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==, + integrity: sha512-XW3/vWuIXHa+2Uwcc2ABSfcCledmXhhQPlGbfcRXbiUQI5Icjcg19BGCZVKKInYbvUCut/ufbbLLPFC5cbb1hw==, } peerDependencies: "@types/react": "*" @@ -2989,6 +3449,12 @@ packages: "@types/react-dom": optional: true + "@radix-ui/rect@1.1.0": + resolution: + { + integrity: sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg==, + } + "@radix-ui/rect@1.1.1": resolution: { @@ -4358,6 +4824,12 @@ packages: } engines: { node: ">=8" } + browser-fs-access@0.29.1: + resolution: + { + integrity: sha512-LSvVX5e21LRrXqVMhqtAwj5xPgDb+fXAIH80NsnCQ9xuZPs2xWsOREi24RKgZa1XOiQRbcmVrv87+ulOKsgjxw==, + } + browserslist@4.28.1: resolution: { @@ -4427,6 +4899,12 @@ packages: integrity: sha512-dZcaJLJeDMh4rELYFw1tvSn1bhZWYFOt468FcbHHxx/Z/dFidd1I6ciyFdi3iwfQCyOjqo9upF6lGQYtMiJWxw==, } + canvas-roundrect-polyfill@0.0.1: + resolution: + { + integrity: sha512-yWq+R3U3jE+coOeEb3a3GgE2j/0MMiDKM/QpLb6h9ihf5fGY9UXtvK9o4vNqjWXoZz7/3EaSVU3IX53TvFFUOw==, + } + ccount@2.0.1: resolution: { @@ -4485,6 +4963,20 @@ packages: } engines: { node: ">= 16" } + chevrotain-allstar@0.3.1: + resolution: + { + integrity: sha512-b7g+y9A0v4mxCW1qUhf3BSVPg+/NvGErk/dOkrDaHA0nQIQGAtrOjlX//9OQtRlSCy+x9rfB5N8yC71lH1nvMw==, + } + peerDependencies: + chevrotain: ^11.0.0 + + chevrotain@11.0.3: + resolution: + { + integrity: sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw==, + } + chokidar@3.6.0: resolution: { @@ -4525,6 +5017,13 @@ packages: } engines: { node: ">=12" } + clsx@1.1.1: + resolution: + { + integrity: sha512-6/bPho624p3S2pMyvP5kKBPXnI3ufHLObBFCfgx+LkeR5lg2XYy2hqZqUf45ypD8COn2bhgGJSUE+l5dhNBieA==, + } + engines: { node: ">=6" } + clsx@2.1.1: resolution: { @@ -4679,6 +5178,13 @@ packages: integrity: sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==, } + crc-32@0.3.0: + resolution: + { + integrity: sha512-kucVIjOmMc1f0tv53BJ/5WIX+MGLcKuoBhnGqQrgKJNqLByb/sVMWfW/Aw6hw0jgcqjJ2pi9E5y32zOIpaUlsA==, + } + engines: { node: ">=0.8" } + croner@10.0.1: resolution: { @@ -4693,6 +5199,14 @@ packages: } hasBin: true + cross-env@7.0.3: + resolution: + { + integrity: sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==, + } + engines: { node: ">=10.14", npm: ">=6", yarn: ">=1" } + hasBin: true + cross-spawn@7.0.6: resolution: { @@ -5358,6 +5872,13 @@ packages: integrity: sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==, } + es6-promise-pool@2.5.0: + resolution: + { + integrity: sha512-VHErXfzR/6r/+yyzPKeBvO0lgjfC5cbDCQWjWwMZWSb6YU39TGIl51OUmCfWCq4ylMdJSB8zkz2vIuIeIxXApA==, + } + engines: { node: ">=0.10.0" } + esbuild@0.21.5: resolution: { @@ -5751,6 +6272,13 @@ packages: integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==, } + fractional-indexing@3.2.0: + resolution: + { + integrity: sha512-PcOxmqwYCW7O2ovKRU8OoQQj2yqTfEB/yeTYk4gPid6dN5ODRfU1hXd9tTVZzax/0NkO7AxpHykvZnT1aYp/BQ==, + } + engines: { node: ^14.13.1 || >=16.0.0 } + framer-motion@12.38.0: resolution: { @@ -5817,6 +6345,13 @@ packages: integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==, } + fuzzy@0.1.3: + resolution: + { + integrity: sha512-/gZffu4ykarLrCiP3Ygsa86UAo1E5vEVlvTrpkKywXSbP9Xhln3oSp9QSV57gEq3JFFpGJ4GZ+5zdEp3FcUh4w==, + } + engines: { node: ">= 0.6.0" } + generator-function@2.0.1: resolution: { @@ -5943,6 +6478,12 @@ packages: } engines: { node: ">= 0.4" } + glur@1.1.2: + resolution: + { + integrity: sha512-l+8esYHTKOx2G/Aao4lEQ0bnHWg4fWtJbVoZZT9Knxi01pB8C80BR85nONLFwkkQoFRCmXY+BUcGZN3yZ2QsRA==, + } + gopd@1.2.0: resolution: { @@ -6123,6 +6664,12 @@ packages: } engines: { node: ">= 4" } + image-blob-reduce@3.0.1: + resolution: + { + integrity: sha512-/VmmWgIryG/wcn4TVrV7cC4mlfUC/oyiKIfSg5eVM3Ten/c1c34RJhMYKCWTnoSMHSqXLt3tsrBR4Q2HInvN+Q==, + } + immer@10.2.0: resolution: { @@ -6135,6 +6682,12 @@ packages: integrity: sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==, } + immutable@4.3.9: + resolution: + { + integrity: sha512-ObHy4YN7ycwZOUCLI1/6svfyAFu7vL8RhAvVu/bh/RZW9EPlOyDaQ9jDQWCtdqzaXUjgXZCW1migtHE7YI7UGQ==, + } + import-fresh@3.3.1: resolution: { @@ -6555,6 +7108,30 @@ packages: integrity: sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==, } + jotai-scope@0.7.2: + resolution: + { + integrity: sha512-Gwed97f3dDObrO43++2lRcgOqw4O2sdr4JCjP/7eHK1oPACDJ7xKHGScpJX9XaflU+KBHXF+VhwECnzcaQiShg==, + } + peerDependencies: + jotai: ">=2.9.2" + react: ">=17.0.0" + + jotai@2.11.0: + resolution: + { + integrity: sha512-zKfoBBD1uDw3rljwHkt0fWuja1B76R7CjznuBO+mSX6jpsO1EBeWNRKpeaQho9yPI/pvCv4recGfgOXGxwPZvQ==, + } + engines: { node: ">=12.20.0" } + peerDependencies: + "@types/react": ">=17.0.0" + react: ">=17.0.0" + peerDependenciesMeta: + "@types/react": + optional: true + react: + optional: true + jotai@2.19.0: resolution: { @@ -6704,6 +7281,13 @@ packages: integrity: sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==, } + langium@3.3.1: + resolution: + { + integrity: sha512-QJv/h939gDpvT+9SiLVlY7tZC3xB2qK57v0J04Sh9wpMb6MP1q8gB21L3WIo8T5P1MSMg3Ep14L7KkDCFG3y4w==, + } + engines: { node: ">=16.0.0" } + layout-base@1.0.2: resolution: { @@ -6771,6 +7355,12 @@ packages: } engines: { node: ">=10" } + lodash-es@4.17.21: + resolution: + { + integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==, + } + lodash-es@4.18.1: resolution: { @@ -6795,6 +7385,12 @@ packages: integrity: sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==, } + lodash.throttle@4.1.1: + resolution: + { + integrity: sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==, + } + lodash@4.17.23: resolution: { @@ -7291,6 +7887,12 @@ packages: integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==, } + multimath@2.0.0: + resolution: + { + integrity: sha512-toRx66cAMJ+Ccz7pMIg38xSIrtnbozk0dchXezwQDMgQmbGpfxjtv68H+L00iFL8hxDaVjrmwAFSb3I6bg8Q2g==, + } + mz@2.7.0: resolution: { @@ -7305,6 +7907,22 @@ packages: engines: { node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1 } hasBin: true + nanoid@3.3.3: + resolution: + { + integrity: sha512-p1sjXuopFs0xg+fPASzQ28agW1oHD7xDsd9Xkf3T15H3c/cifrFHVwrh74PdoklAPi+i7MdRsE47vm2r6JoB+w==, + } + engines: { node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1 } + hasBin: true + + nanoid@4.0.2: + resolution: + { + integrity: sha512-7ZtY5KTCNheRGfEFxnedV5zFiORN1+Y1N6zvPTnHQd8ENUvfaDBeuJDZb2bN/oXwXxu3qkTXDzy57W5vAmDTBw==, + } + engines: { node: ^14 || ^16 || >=18 } + hasBin: true + natural-compare@1.4.0: resolution: { @@ -7441,6 +8059,12 @@ packages: } engines: { node: ">=18" } + open-color@1.9.1: + resolution: + { + integrity: sha512-vCseG/EQ6/RcvxhUcGJiHViOgrtz4x0XbZepXvKik66TMGkvbmjeJrKFyBEx6daG5rNyyd14zYXhz0hZVwQFOw==, + } + optionator@0.9.4: resolution: { @@ -7481,6 +8105,12 @@ packages: integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==, } + pako@2.0.3: + resolution: + { + integrity: sha512-WjR1hOeg+kki3ZIOjaf4b5WVcay1jaliKSYiEaB1XzwhMQZJxRdQRv0V31EKBYlxb4T7SK3hjfc/jxyU64BoSw==, + } + parent-module@1.0.1: resolution: { @@ -7572,6 +8202,12 @@ packages: } engines: { node: ">= 14.16" } + perfect-freehand@1.2.0: + resolution: + { + integrity: sha512-h/0ikF1M3phW7CwpZ5MMvKnfpHficWoOEyr//KVNTxV4F6deRK1eYMtHyBKEAKFK0aXIEUK9oBvlF6PNXMDsAw==, + } + pg-cloudflare@1.3.0: resolution: { @@ -7630,6 +8266,12 @@ packages: integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==, } + pica@7.1.1: + resolution: + { + integrity: sha512-WY73tMvNzXWEld2LicT9Y260L43isrZ85tPuqRyvtkljSDLmnNFQmZICt4xUJMVulmcc6L9O7jbBrtx3DOz/YQ==, + } + picocolors@1.1.1: resolution: { @@ -7706,12 +8348,36 @@ packages: engines: { node: ">=18" } hasBin: true + png-chunk-text@1.0.0: + resolution: + { + integrity: sha512-DEROKU3SkkLGWNMzru3xPVgxyd48UGuMSZvioErCure6yhOc/pRH2ZV+SEn7nmaf7WNf3NdIpH+UTrRdKyq9Lw==, + } + + png-chunks-encode@1.0.0: + resolution: + { + integrity: sha512-J1jcHgbQRsIIgx5wxW9UmCymV3wwn4qCCJl6KYgEU/yHCh/L2Mwq/nMOkRPtmV79TLxRZj5w3tH69pvygFkDqA==, + } + + png-chunks-extract@1.0.0: + resolution: + { + integrity: sha512-ZiVwF5EJ0DNZyzAqld8BP1qyJBaGOFaq9zl579qfbkcmOwWLLO4I9L8i2O4j3HkI6/35i0nKG2n+dZplxiT89Q==, + } + points-on-curve@0.2.0: resolution: { integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==, } + points-on-curve@1.0.1: + resolution: + { + integrity: sha512-3nmX4/LIiyuwGLwuUrfhTlDeQFlAhi7lyK/zcRNGhalwapDWgAGR82bUpmn2mA03vII3fvNCG8jAONzKXwpxAg==, + } + points-on-path@0.2.1: resolution: { @@ -7902,6 +8568,12 @@ packages: } engines: { node: ">=6" } + pwacompat@2.0.17: + resolution: + { + integrity: sha512-6Du7IZdIy7cHiv7AhtDy4X2QRM8IAD5DII69mt5qWibC2d15ZU8DmBG1WdZKekG11cChSu4zkSUGPF9sweOl6w==, + } + qs@6.15.0: resolution: { @@ -8324,6 +8996,12 @@ packages: engines: { node: ">=18.0.0", npm: ">=8.0.0" } hasBin: true + roughjs@4.6.4: + resolution: + { + integrity: sha512-s6EZ0BntezkFYMf/9mGn7M8XGIoaav9QQBCnJROWB3brUWQ683Q2LbRD/hq0Z3bAJ/9NVpU/5LpiTWvQMyLDhw==, + } + roughjs@4.6.6: resolution: { @@ -8396,6 +9074,14 @@ packages: integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==, } + sass@1.51.0: + resolution: + { + integrity: sha512-haGdpTgywJTvHC2b91GSq+clTKGbtkkZmVAb82jZQN/wTy6qs8DdFm2lhEQbEwrY0QDRgSQ3xDurqM977C3noA==, + } + engines: { node: ">=12.0.0" } + hasBin: true + saxes@6.0.0: resolution: { @@ -8572,6 +9258,13 @@ packages: } engines: { node: ">=20" } + sliced@1.0.1: + resolution: + { + integrity: sha512-VZBmZP8WU3sMOZm1bdgTadsQbcscK0UM8oKxKVBs4XAhUo2Xxzm/OFMGBkPusxw9xL3Uy8LrzEqGqJhclsr0yA==, + } + deprecated: Unsupported + smob@1.6.1: resolution: { @@ -9066,6 +9759,12 @@ packages: engines: { node: ">=18.0.0" } hasBin: true + tunnel-rat@0.1.2: + resolution: + { + integrity: sha512-lR5VHmkPhzdhrM092lI2nACsLO4QubF0/yoOhzX7c+wIpbN1GjHNzCc91QlpxBi+cnx8vVJ+Ur6vL5cEoQPFpQ==, + } + type-check@0.4.0: resolution: { @@ -9504,6 +10203,44 @@ packages: jsdom: optional: true + vscode-jsonrpc@8.2.0: + resolution: + { + integrity: sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==, + } + engines: { node: ">=14.0.0" } + + vscode-languageserver-protocol@3.17.5: + resolution: + { + integrity: sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==, + } + + vscode-languageserver-textdocument@1.0.12: + resolution: + { + integrity: sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==, + } + + vscode-languageserver-types@3.17.5: + resolution: + { + integrity: sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==, + } + + vscode-languageserver@9.0.1: + resolution: + { + integrity: sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==, + } + hasBin: true + + vscode-uri@3.0.8: + resolution: + { + integrity: sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==, + } + w3c-xmlserializer@5.0.0: resolution: { @@ -9530,6 +10267,12 @@ packages: } engines: { node: ">=20" } + webworkify@1.5.0: + resolution: + { + integrity: sha512-AMcUeyXAhbACL8S2hqqdqOLqvJ8ylmIbNwUIqQujRSouf4+eUFaXbG6F1Rbu+srlJMmxQWsiU7mOJi0nMBfM1g==, + } + whatwg-mimetype@3.0.0: resolution: { @@ -9823,6 +10566,24 @@ packages: integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==, } + zustand@4.5.7: + resolution: + { + integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==, + } + engines: { node: ">=12.7.0" } + peerDependencies: + "@types/react": ">=16.8" + immer: ">=9.0.6" + react: ">=16.8" + peerDependenciesMeta: + "@types/react": + optional: true + immer: + optional: true + react: + optional: true + zwitch@2.0.4: resolution: { @@ -10540,14 +11301,33 @@ snapshots: "@bcoe/v8-coverage@1.0.2": {} + "@braintree/sanitize-url@6.0.2": {} + "@braintree/sanitize-url@7.1.2": {} "@bramus/specificity@2.4.2": dependencies: css-tree: 3.2.1 + "@chevrotain/cst-dts-gen@11.0.3": + dependencies: + "@chevrotain/gast": 11.0.3 + "@chevrotain/types": 11.0.3 + lodash-es: 4.17.21 + + "@chevrotain/gast@11.0.3": + dependencies: + "@chevrotain/types": 11.0.3 + lodash-es: 4.17.21 + + "@chevrotain/regexp-to-ast@11.0.3": {} + + "@chevrotain/types@11.0.3": {} + "@chevrotain/types@11.1.2": {} + "@chevrotain/utils@11.0.3": {} + "@csstools/color-helpers@6.0.2": {} "@csstools/css-calc@3.2.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)": @@ -10850,6 +11630,59 @@ snapshots: "@eslint/core": 0.17.0 levn: 0.4.1 + "@excalidraw/excalidraw@0.18.1(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(immer@11.1.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)": + dependencies: + "@braintree/sanitize-url": 6.0.2 + "@excalidraw/laser-pointer": 1.3.1 + "@excalidraw/mermaid-to-excalidraw": 2.2.2 + "@excalidraw/random-username": 1.1.0 + "@radix-ui/react-popover": 1.1.6(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + "@radix-ui/react-tabs": 1.0.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + browser-fs-access: 0.29.1 + canvas-roundrect-polyfill: 0.0.1 + clsx: 1.1.1 + cross-env: 7.0.3 + es6-promise-pool: 2.5.0 + fractional-indexing: 3.2.0 + fuzzy: 0.1.3 + image-blob-reduce: 3.0.1 + jotai: 2.11.0(@types/react@18.3.28)(react@18.3.1) + jotai-scope: 0.7.2(jotai@2.11.0(@types/react@18.3.28)(react@18.3.1))(react@18.3.1) + lodash.debounce: 4.0.8 + lodash.throttle: 4.1.1 + nanoid: 3.3.3 + open-color: 1.9.1 + pako: 2.0.3 + perfect-freehand: 1.2.0 + pica: 7.1.1 + png-chunk-text: 1.0.0 + png-chunks-encode: 1.0.0 + png-chunks-extract: 1.0.0 + points-on-curve: 1.0.1 + pwacompat: 2.0.17 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + roughjs: 4.6.4 + sass: 1.51.0 + tunnel-rat: 0.1.2(@types/react@18.3.28)(immer@11.1.4)(react@18.3.1) + transitivePeerDependencies: + - "@types/react" + - "@types/react-dom" + - immer + + "@excalidraw/laser-pointer@1.3.1": {} + + "@excalidraw/markdown-to-text@0.1.2": {} + + "@excalidraw/mermaid-to-excalidraw@2.2.2": + dependencies: + "@excalidraw/markdown-to-text": 0.1.2 + "@mermaid-js/parser": 0.6.3 + mermaid: 11.15.0 + nanoid: 4.0.2 + + "@excalidraw/random-username@1.1.0": {} + "@exodus/bytes@1.15.0": {} "@fastify/ajv-compiler@4.0.5": @@ -11082,6 +11915,10 @@ snapshots: "@lukeed/ms@2.0.2": {} + "@mermaid-js/parser@0.6.3": + dependencies: + langium: 3.3.1 + "@mermaid-js/parser@1.1.1": dependencies: "@chevrotain/types": 11.1.2 @@ -11131,8 +11968,23 @@ snapshots: "@radix-ui/number@1.1.1": {} + "@radix-ui/primitive@1.0.0": + dependencies: + "@babel/runtime": 7.29.2 + + "@radix-ui/primitive@1.1.1": {} + "@radix-ui/primitive@1.1.3": {} + "@radix-ui/react-arrow@1.1.2(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)": + dependencies: + "@radix-ui/react-primitive": 2.0.2(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + "@types/react": 18.3.28 + "@types/react-dom": 18.3.7(@types/react@18.3.28) + "@radix-ui/react-arrow@1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)": dependencies: "@radix-ui/react-primitive": 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -11158,6 +12010,16 @@ snapshots: "@types/react": 18.3.28 "@types/react-dom": 18.3.7(@types/react@18.3.28) + "@radix-ui/react-collection@1.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)": + dependencies: + "@babel/runtime": 7.29.2 + "@radix-ui/react-compose-refs": 1.0.0(react@18.3.1) + "@radix-ui/react-context": 1.0.0(react@18.3.1) + "@radix-ui/react-primitive": 1.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + "@radix-ui/react-slot": 1.0.1(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + "@radix-ui/react-collection@1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)": dependencies: "@radix-ui/react-compose-refs": 1.1.2(@types/react@18.3.28)(react@18.3.1) @@ -11170,12 +12032,34 @@ snapshots: "@types/react": 18.3.28 "@types/react-dom": 18.3.7(@types/react@18.3.28) + "@radix-ui/react-compose-refs@1.0.0(react@18.3.1)": + dependencies: + "@babel/runtime": 7.29.2 + react: 18.3.1 + + "@radix-ui/react-compose-refs@1.1.1(@types/react@18.3.28)(react@18.3.1)": + dependencies: + react: 18.3.1 + optionalDependencies: + "@types/react": 18.3.28 + "@radix-ui/react-compose-refs@1.1.2(@types/react@18.3.28)(react@18.3.1)": dependencies: react: 18.3.1 optionalDependencies: "@types/react": 18.3.28 + "@radix-ui/react-context@1.0.0(react@18.3.1)": + dependencies: + "@babel/runtime": 7.29.2 + react: 18.3.1 + + "@radix-ui/react-context@1.1.1(@types/react@18.3.28)(react@18.3.1)": + dependencies: + react: 18.3.1 + optionalDependencies: + "@types/react": 18.3.28 + "@radix-ui/react-context@1.1.2(@types/react@18.3.28)(react@18.3.1)": dependencies: react: 18.3.1 @@ -11204,6 +12088,11 @@ snapshots: "@types/react": 18.3.28 "@types/react-dom": 18.3.7(@types/react@18.3.28) + "@radix-ui/react-direction@1.0.0(react@18.3.1)": + dependencies: + "@babel/runtime": 7.29.2 + react: 18.3.1 + "@radix-ui/react-direction@1.1.1(@types/react@18.3.28)(react@18.3.1)": dependencies: react: 18.3.1 @@ -11223,6 +12112,19 @@ snapshots: "@types/react": 18.3.28 "@types/react-dom": 18.3.7(@types/react@18.3.28) + "@radix-ui/react-dismissable-layer@1.1.5(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)": + dependencies: + "@radix-ui/primitive": 1.1.1 + "@radix-ui/react-compose-refs": 1.1.1(@types/react@18.3.28)(react@18.3.1) + "@radix-ui/react-primitive": 2.0.2(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + "@radix-ui/react-use-callback-ref": 1.1.0(@types/react@18.3.28)(react@18.3.1) + "@radix-ui/react-use-escape-keydown": 1.1.0(@types/react@18.3.28)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + "@types/react": 18.3.28 + "@types/react-dom": 18.3.7(@types/react@18.3.28) + "@radix-ui/react-dropdown-menu@2.1.16(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)": dependencies: "@radix-ui/primitive": 1.1.3 @@ -11238,12 +12140,29 @@ snapshots: "@types/react": 18.3.28 "@types/react-dom": 18.3.7(@types/react@18.3.28) + "@radix-ui/react-focus-guards@1.1.1(@types/react@18.3.28)(react@18.3.1)": + dependencies: + react: 18.3.1 + optionalDependencies: + "@types/react": 18.3.28 + "@radix-ui/react-focus-guards@1.1.3(@types/react@18.3.28)(react@18.3.1)": dependencies: react: 18.3.1 optionalDependencies: "@types/react": 18.3.28 + "@radix-ui/react-focus-scope@1.1.2(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)": + dependencies: + "@radix-ui/react-compose-refs": 1.1.1(@types/react@18.3.28)(react@18.3.1) + "@radix-ui/react-primitive": 2.0.2(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + "@radix-ui/react-use-callback-ref": 1.1.0(@types/react@18.3.28)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + "@types/react": 18.3.28 + "@types/react-dom": 18.3.7(@types/react@18.3.28) + "@radix-ui/react-focus-scope@1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)": dependencies: "@radix-ui/react-compose-refs": 1.1.2(@types/react@18.3.28)(react@18.3.1) @@ -11255,6 +12174,19 @@ snapshots: "@types/react": 18.3.28 "@types/react-dom": 18.3.7(@types/react@18.3.28) + "@radix-ui/react-id@1.0.0(react@18.3.1)": + dependencies: + "@babel/runtime": 7.29.2 + "@radix-ui/react-use-layout-effect": 1.0.0(react@18.3.1) + react: 18.3.1 + + "@radix-ui/react-id@1.1.0(@types/react@18.3.28)(react@18.3.1)": + dependencies: + "@radix-ui/react-use-layout-effect": 1.1.0(@types/react@18.3.28)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + "@types/react": 18.3.28 + "@radix-ui/react-id@1.1.1(@types/react@18.3.28)(react@18.3.1)": dependencies: "@radix-ui/react-use-layout-effect": 1.1.1(@types/react@18.3.28)(react@18.3.1) @@ -11311,6 +12243,47 @@ snapshots: "@types/react": 18.3.28 "@types/react-dom": 18.3.7(@types/react@18.3.28) + "@radix-ui/react-popover@1.1.6(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)": + dependencies: + "@radix-ui/primitive": 1.1.1 + "@radix-ui/react-compose-refs": 1.1.1(@types/react@18.3.28)(react@18.3.1) + "@radix-ui/react-context": 1.1.1(@types/react@18.3.28)(react@18.3.1) + "@radix-ui/react-dismissable-layer": 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + "@radix-ui/react-focus-guards": 1.1.1(@types/react@18.3.28)(react@18.3.1) + "@radix-ui/react-focus-scope": 1.1.2(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + "@radix-ui/react-id": 1.1.0(@types/react@18.3.28)(react@18.3.1) + "@radix-ui/react-popper": 1.2.2(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + "@radix-ui/react-portal": 1.1.4(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + "@radix-ui/react-presence": 1.1.2(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + "@radix-ui/react-primitive": 2.0.2(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + "@radix-ui/react-slot": 1.1.2(@types/react@18.3.28)(react@18.3.1) + "@radix-ui/react-use-controllable-state": 1.1.0(@types/react@18.3.28)(react@18.3.1) + aria-hidden: 1.2.6 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-remove-scroll: 2.7.2(@types/react@18.3.28)(react@18.3.1) + optionalDependencies: + "@types/react": 18.3.28 + "@types/react-dom": 18.3.7(@types/react@18.3.28) + + "@radix-ui/react-popper@1.2.2(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)": + dependencies: + "@floating-ui/react-dom": 2.1.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + "@radix-ui/react-arrow": 1.1.2(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + "@radix-ui/react-compose-refs": 1.1.1(@types/react@18.3.28)(react@18.3.1) + "@radix-ui/react-context": 1.1.1(@types/react@18.3.28)(react@18.3.1) + "@radix-ui/react-primitive": 2.0.2(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + "@radix-ui/react-use-callback-ref": 1.1.0(@types/react@18.3.28)(react@18.3.1) + "@radix-ui/react-use-layout-effect": 1.1.0(@types/react@18.3.28)(react@18.3.1) + "@radix-ui/react-use-rect": 1.1.0(@types/react@18.3.28)(react@18.3.1) + "@radix-ui/react-use-size": 1.1.0(@types/react@18.3.28)(react@18.3.1) + "@radix-ui/rect": 1.1.0 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + "@types/react": 18.3.28 + "@types/react-dom": 18.3.7(@types/react@18.3.28) + "@radix-ui/react-popper@1.2.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)": dependencies: "@floating-ui/react-dom": 2.1.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -11329,6 +12302,16 @@ snapshots: "@types/react": 18.3.28 "@types/react-dom": 18.3.7(@types/react@18.3.28) + "@radix-ui/react-portal@1.1.4(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)": + dependencies: + "@radix-ui/react-primitive": 2.0.2(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + "@radix-ui/react-use-layout-effect": 1.1.0(@types/react@18.3.28)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + "@types/react": 18.3.28 + "@types/react-dom": 18.3.7(@types/react@18.3.28) + "@radix-ui/react-portal@1.1.9(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)": dependencies: "@radix-ui/react-primitive": 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -11339,6 +12322,24 @@ snapshots: "@types/react": 18.3.28 "@types/react-dom": 18.3.7(@types/react@18.3.28) + "@radix-ui/react-presence@1.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)": + dependencies: + "@babel/runtime": 7.29.2 + "@radix-ui/react-compose-refs": 1.0.0(react@18.3.1) + "@radix-ui/react-use-layout-effect": 1.0.0(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + "@radix-ui/react-presence@1.1.2(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)": + dependencies: + "@radix-ui/react-compose-refs": 1.1.1(@types/react@18.3.28)(react@18.3.1) + "@radix-ui/react-use-layout-effect": 1.1.0(@types/react@18.3.28)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + "@types/react": 18.3.28 + "@types/react-dom": 18.3.7(@types/react@18.3.28) + "@radix-ui/react-presence@1.1.5(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)": dependencies: "@radix-ui/react-compose-refs": 1.1.2(@types/react@18.3.28)(react@18.3.1) @@ -11349,6 +12350,22 @@ snapshots: "@types/react": 18.3.28 "@types/react-dom": 18.3.7(@types/react@18.3.28) + "@radix-ui/react-primitive@1.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)": + dependencies: + "@babel/runtime": 7.29.2 + "@radix-ui/react-slot": 1.0.1(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + "@radix-ui/react-primitive@2.0.2(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)": + dependencies: + "@radix-ui/react-slot": 1.1.2(@types/react@18.3.28)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + "@types/react": 18.3.28 + "@types/react-dom": 18.3.7(@types/react@18.3.28) + "@radix-ui/react-primitive@2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)": dependencies: "@radix-ui/react-slot": 1.2.3(@types/react@18.3.28)(react@18.3.1) @@ -11367,6 +12384,21 @@ snapshots: "@types/react": 18.3.28 "@types/react-dom": 18.3.7(@types/react@18.3.28) + "@radix-ui/react-roving-focus@1.0.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)": + dependencies: + "@babel/runtime": 7.29.2 + "@radix-ui/primitive": 1.0.0 + "@radix-ui/react-collection": 1.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + "@radix-ui/react-compose-refs": 1.0.0(react@18.3.1) + "@radix-ui/react-context": 1.0.0(react@18.3.1) + "@radix-ui/react-direction": 1.0.0(react@18.3.1) + "@radix-ui/react-id": 1.0.0(react@18.3.1) + "@radix-ui/react-primitive": 1.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + "@radix-ui/react-use-callback-ref": 1.0.0(react@18.3.1) + "@radix-ui/react-use-controllable-state": 1.0.0(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + "@radix-ui/react-roving-focus@1.1.11(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)": dependencies: "@radix-ui/primitive": 1.1.3 @@ -11430,6 +12462,19 @@ snapshots: "@types/react": 18.3.28 "@types/react-dom": 18.3.7(@types/react@18.3.28) + "@radix-ui/react-slot@1.0.1(react@18.3.1)": + dependencies: + "@babel/runtime": 7.29.2 + "@radix-ui/react-compose-refs": 1.0.0(react@18.3.1) + react: 18.3.1 + + "@radix-ui/react-slot@1.1.2(@types/react@18.3.28)(react@18.3.1)": + dependencies: + "@radix-ui/react-compose-refs": 1.1.1(@types/react@18.3.28)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + "@types/react": 18.3.28 + "@radix-ui/react-slot@1.2.3(@types/react@18.3.28)(react@18.3.1)": dependencies: "@radix-ui/react-compose-refs": 1.1.2(@types/react@18.3.28)(react@18.3.1) @@ -11459,6 +12504,20 @@ snapshots: "@types/react": 18.3.28 "@types/react-dom": 18.3.7(@types/react@18.3.28) + "@radix-ui/react-tabs@1.0.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)": + dependencies: + "@babel/runtime": 7.29.2 + "@radix-ui/primitive": 1.0.0 + "@radix-ui/react-context": 1.0.0(react@18.3.1) + "@radix-ui/react-direction": 1.0.0(react@18.3.1) + "@radix-ui/react-id": 1.0.0(react@18.3.1) + "@radix-ui/react-presence": 1.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + "@radix-ui/react-primitive": 1.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + "@radix-ui/react-roving-focus": 1.0.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + "@radix-ui/react-use-controllable-state": 1.0.0(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + "@radix-ui/react-tooltip@1.2.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)": dependencies: "@radix-ui/primitive": 1.1.3 @@ -11479,12 +12538,36 @@ snapshots: "@types/react": 18.3.28 "@types/react-dom": 18.3.7(@types/react@18.3.28) + "@radix-ui/react-use-callback-ref@1.0.0(react@18.3.1)": + dependencies: + "@babel/runtime": 7.29.2 + react: 18.3.1 + + "@radix-ui/react-use-callback-ref@1.1.0(@types/react@18.3.28)(react@18.3.1)": + dependencies: + react: 18.3.1 + optionalDependencies: + "@types/react": 18.3.28 + "@radix-ui/react-use-callback-ref@1.1.1(@types/react@18.3.28)(react@18.3.1)": dependencies: react: 18.3.1 optionalDependencies: "@types/react": 18.3.28 + "@radix-ui/react-use-controllable-state@1.0.0(react@18.3.1)": + dependencies: + "@babel/runtime": 7.29.2 + "@radix-ui/react-use-callback-ref": 1.0.0(react@18.3.1) + react: 18.3.1 + + "@radix-ui/react-use-controllable-state@1.1.0(@types/react@18.3.28)(react@18.3.1)": + dependencies: + "@radix-ui/react-use-callback-ref": 1.1.0(@types/react@18.3.28)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + "@types/react": 18.3.28 + "@radix-ui/react-use-controllable-state@1.2.2(@types/react@18.3.28)(react@18.3.1)": dependencies: "@radix-ui/react-use-effect-event": 0.0.2(@types/react@18.3.28)(react@18.3.1) @@ -11500,6 +12583,13 @@ snapshots: optionalDependencies: "@types/react": 18.3.28 + "@radix-ui/react-use-escape-keydown@1.1.0(@types/react@18.3.28)(react@18.3.1)": + dependencies: + "@radix-ui/react-use-callback-ref": 1.1.0(@types/react@18.3.28)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + "@types/react": 18.3.28 + "@radix-ui/react-use-escape-keydown@1.1.1(@types/react@18.3.28)(react@18.3.1)": dependencies: "@radix-ui/react-use-callback-ref": 1.1.1(@types/react@18.3.28)(react@18.3.1) @@ -11507,6 +12597,17 @@ snapshots: optionalDependencies: "@types/react": 18.3.28 + "@radix-ui/react-use-layout-effect@1.0.0(react@18.3.1)": + dependencies: + "@babel/runtime": 7.29.2 + react: 18.3.1 + + "@radix-ui/react-use-layout-effect@1.1.0(@types/react@18.3.28)(react@18.3.1)": + dependencies: + react: 18.3.1 + optionalDependencies: + "@types/react": 18.3.28 + "@radix-ui/react-use-layout-effect@1.1.1(@types/react@18.3.28)(react@18.3.1)": dependencies: react: 18.3.1 @@ -11519,6 +12620,13 @@ snapshots: optionalDependencies: "@types/react": 18.3.28 + "@radix-ui/react-use-rect@1.1.0(@types/react@18.3.28)(react@18.3.1)": + dependencies: + "@radix-ui/rect": 1.1.0 + react: 18.3.1 + optionalDependencies: + "@types/react": 18.3.28 + "@radix-ui/react-use-rect@1.1.1(@types/react@18.3.28)(react@18.3.1)": dependencies: "@radix-ui/rect": 1.1.1 @@ -11526,6 +12634,13 @@ snapshots: optionalDependencies: "@types/react": 18.3.28 + "@radix-ui/react-use-size@1.1.0(@types/react@18.3.28)(react@18.3.1)": + dependencies: + "@radix-ui/react-use-layout-effect": 1.1.0(@types/react@18.3.28)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + "@types/react": 18.3.28 + "@radix-ui/react-use-size@1.1.1(@types/react@18.3.28)(react@18.3.1)": dependencies: "@radix-ui/react-use-layout-effect": 1.1.1(@types/react@18.3.28)(react@18.3.1) @@ -11542,6 +12657,8 @@ snapshots: "@types/react": 18.3.28 "@types/react-dom": 18.3.7(@types/react@18.3.28) + "@radix-ui/rect@1.1.0": {} + "@radix-ui/rect@1.1.1": {} "@reduxjs/toolkit@2.11.2(react-redux@9.2.0(@types/react@18.3.28)(react@18.3.1)(redux@5.0.1))(react@18.3.1)": @@ -11920,7 +13037,6 @@ snapshots: "@types/node@20.19.39": dependencies: undici-types: 6.21.0 - optional: true "@types/node@24.12.0": dependencies: @@ -11955,8 +13071,7 @@ snapshots: "@types/use-sync-external-store@0.0.6": {} - "@types/whatwg-mimetype@3.0.2": - optional: true + "@types/whatwg-mimetype@3.0.2": {} "@types/ws@8.18.1": dependencies: @@ -12060,7 +13175,7 @@ snapshots: d3-selection: 3.0.0 d3-transition: 3.0.1(d3-selection@3.0.0) - "@vitejs/plugin-react@4.7.0(vite@5.4.21(@types/node@24.12.0)(terser@5.46.1))": + "@vitejs/plugin-react@4.7.0(vite@5.4.21(@types/node@24.12.0)(sass@1.51.0)(terser@5.46.1))": dependencies: "@babel/core": 7.29.0 "@babel/plugin-transform-react-jsx-self": 7.27.1(@babel/core@7.29.0) @@ -12068,11 +13183,11 @@ snapshots: "@rolldown/pluginutils": 1.0.0-beta.27 "@types/babel__core": 7.20.5 react-refresh: 0.17.0 - vite: 5.4.21(@types/node@24.12.0)(terser@5.46.1) + vite: 5.4.21(@types/node@24.12.0)(sass@1.51.0)(terser@5.46.1) transitivePeerDependencies: - supports-color - "@vitest/coverage-v8@2.1.9(vitest@2.1.9(@types/node@24.12.0)(happy-dom@18.0.1)(jsdom@29.1.1)(terser@5.46.1))": + "@vitest/coverage-v8@2.1.9(vitest@2.1.9(@types/node@24.12.0)(happy-dom@18.0.1)(jsdom@29.1.1)(sass@1.51.0)(terser@5.46.1))": dependencies: "@ampproject/remapping": 2.3.0 "@bcoe/v8-coverage": 0.2.3 @@ -12086,11 +13201,11 @@ snapshots: std-env: 3.10.0 test-exclude: 7.0.2 tinyrainbow: 1.2.0 - vitest: 2.1.9(@types/node@24.12.0)(happy-dom@18.0.1)(jsdom@29.1.1)(terser@5.46.1) + vitest: 2.1.9(@types/node@24.12.0)(happy-dom@18.0.1)(jsdom@29.1.1)(sass@1.51.0)(terser@5.46.1) transitivePeerDependencies: - supports-color - "@vitest/coverage-v8@4.1.2(vitest@4.1.2(@types/node@24.12.0)(happy-dom@18.0.1)(jsdom@29.1.1)(vite@6.4.1(@types/node@24.12.0)(jiti@1.21.7)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))": + "@vitest/coverage-v8@4.1.2(vitest@4.1.2(@types/node@24.12.0)(happy-dom@18.0.1)(jsdom@29.1.1)(vite@6.4.1(@types/node@24.12.0)(jiti@1.21.7)(sass@1.51.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))": dependencies: "@bcoe/v8-coverage": 1.0.2 "@vitest/utils": 4.1.2 @@ -12102,7 +13217,7 @@ snapshots: obug: 2.1.1 std-env: 4.0.0 tinyrainbow: 3.1.0 - vitest: 4.1.2(@types/node@24.12.0)(happy-dom@18.0.1)(jsdom@29.1.1)(vite@6.4.1(@types/node@24.12.0)(jiti@1.21.7)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + vitest: 4.1.2(@types/node@24.12.0)(happy-dom@18.0.1)(jsdom@29.1.1)(vite@6.4.1(@types/node@24.12.0)(jiti@1.21.7)(sass@1.51.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) "@vitest/expect@2.1.9": dependencies: @@ -12120,21 +13235,21 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - "@vitest/mocker@2.1.9(vite@5.4.21(@types/node@24.12.0)(terser@5.46.1))": + "@vitest/mocker@2.1.9(vite@5.4.21(@types/node@24.12.0)(sass@1.51.0)(terser@5.46.1))": dependencies: "@vitest/spy": 2.1.9 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 5.4.21(@types/node@24.12.0)(terser@5.46.1) + vite: 5.4.21(@types/node@24.12.0)(sass@1.51.0)(terser@5.46.1) - "@vitest/mocker@4.1.2(vite@6.4.1(@types/node@24.12.0)(jiti@1.21.7)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))": + "@vitest/mocker@4.1.2(vite@6.4.1(@types/node@24.12.0)(jiti@1.21.7)(sass@1.51.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))": dependencies: "@vitest/spy": 4.1.2 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 6.4.1(@types/node@24.12.0)(jiti@1.21.7)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + vite: 6.4.1(@types/node@24.12.0)(jiti@1.21.7)(sass@1.51.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) "@vitest/pretty-format@2.1.9": dependencies: @@ -12423,6 +13538,8 @@ snapshots: dependencies: fill-range: 7.1.1 + browser-fs-access@0.29.1: {} + browserslist@4.28.1: dependencies: baseline-browser-mapping: 2.10.12 @@ -12460,6 +13577,8 @@ snapshots: caniuse-lite@1.0.30001782: {} + canvas-roundrect-polyfill@0.0.1: {} + ccount@2.0.1: {} chai@5.3.3: @@ -12487,6 +13606,20 @@ snapshots: check-error@2.1.3: {} + chevrotain-allstar@0.3.1(chevrotain@11.0.3): + dependencies: + chevrotain: 11.0.3 + lodash-es: 4.18.1 + + chevrotain@11.0.3: + dependencies: + "@chevrotain/cst-dts-gen": 11.0.3 + "@chevrotain/gast": 11.0.3 + "@chevrotain/regexp-to-ast": 11.0.3 + "@chevrotain/types": 11.0.3 + "@chevrotain/utils": 11.0.3 + lodash-es: 4.17.21 + chokidar@3.6.0: dependencies: anymatch: 3.1.3 @@ -12520,6 +13653,8 @@ snapshots: strip-ansi: 6.0.1 wrap-ansi: 7.0.0 + clsx@1.1.1: {} + clsx@2.1.1: {} cmdk@1.1.1(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): @@ -12587,10 +13722,16 @@ snapshots: dependencies: layout-base: 2.0.1 + crc-32@0.3.0: {} + croner@10.0.1: {} cronstrue@3.14.0: {} + cross-env@7.0.3: + dependencies: + cross-spawn: 7.0.6 + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -13036,6 +14177,8 @@ snapshots: es-toolkit@1.45.1: {} + es6-promise-pool@2.5.0: {} + esbuild@0.21.5: optionalDependencies: "@esbuild/aix-ppc64": 0.21.5 @@ -13404,6 +14547,8 @@ snapshots: fraction.js@5.3.4: {} + fractional-indexing@3.2.0: {} + framer-motion@12.38.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: motion-dom: 12.38.0 @@ -13441,6 +14586,8 @@ snapshots: functions-have-names@1.2.3: {} + fuzzy@0.1.3: {} + generator-function@2.0.1: {} gensync@1.0.0-beta.2: {} @@ -13519,6 +14666,8 @@ snapshots: define-properties: 1.2.1 gopd: 1.2.0 + glur@1.1.2: {} + gopd@1.2.0: {} graceful-fs@4.2.11: {} @@ -13530,7 +14679,6 @@ snapshots: "@types/node": 20.19.39 "@types/whatwg-mimetype": 3.0.2 whatwg-mimetype: 3.0.0 - optional: true has-bigints@1.1.0: {} @@ -13628,10 +14776,16 @@ snapshots: ignore@7.0.5: {} + image-blob-reduce@3.0.1: + dependencies: + pica: 7.1.1 + immer@10.2.0: {} immer@11.1.4: {} + immutable@4.3.9: {} + import-fresh@3.3.1: dependencies: parent-module: 1.0.1 @@ -13858,6 +15012,16 @@ snapshots: jose@6.2.2: {} + jotai-scope@0.7.2(jotai@2.11.0(@types/react@18.3.28)(react@18.3.1))(react@18.3.1): + dependencies: + jotai: 2.11.0(@types/react@18.3.28)(react@18.3.1) + react: 18.3.1 + + jotai@2.11.0(@types/react@18.3.28)(react@18.3.1): + optionalDependencies: + "@types/react": 18.3.28 + react: 18.3.1 + jotai@2.19.0(@babel/core@7.29.0)(@babel/template@7.28.6)(@types/react@18.3.28)(react@18.3.1): optionalDependencies: "@babel/core": 7.29.0 @@ -13944,6 +15108,14 @@ snapshots: khroma@2.1.0: {} + langium@3.3.1: + dependencies: + chevrotain: 11.0.3 + chevrotain-allstar: 0.3.1(chevrotain@11.0.3) + vscode-languageserver: 9.0.1 + vscode-languageserver-textdocument: 1.0.12 + vscode-uri: 3.0.8 + layout-base@1.0.2: {} layout-base@2.0.1: {} @@ -13987,6 +15159,8 @@ snapshots: dependencies: p-locate: 5.0.0 + lodash-es@4.17.21: {} + lodash-es@4.18.1: {} lodash.debounce@4.0.8: {} @@ -13995,6 +15169,8 @@ snapshots: lodash.sortby@4.7.0: {} + lodash.throttle@4.1.1: {} + lodash@4.17.23: {} log-update@6.1.0: @@ -14474,6 +15650,11 @@ snapshots: ms@2.1.3: {} + multimath@2.0.0: + dependencies: + glur: 1.1.2 + object-assign: 4.1.1 + mz@2.7.0: dependencies: any-promise: 1.3.0 @@ -14482,6 +15663,10 @@ snapshots: nanoid@3.3.11: {} + nanoid@3.3.3: {} + + nanoid@4.0.2: {} + natural-compare@1.4.0: {} negotiator@1.0.0: {} @@ -14559,6 +15744,8 @@ snapshots: dependencies: mimic-function: 5.0.1 + open-color@1.9.1: {} + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -14586,6 +15773,8 @@ snapshots: package-manager-detector@1.6.0: {} + pako@2.0.3: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -14632,6 +15821,8 @@ snapshots: pathval@2.0.1: {} + perfect-freehand@1.2.0: {} + pg-cloudflare@1.3.0: optional: true @@ -14667,6 +15858,14 @@ snapshots: dependencies: split2: 4.2.0 + pica@7.1.1: + dependencies: + glur: 1.1.2 + inherits: 2.0.4 + multimath: 2.0.0 + object-assign: 4.1.1 + webworkify: 1.5.0 + picocolors@1.1.1: {} picomatch@2.3.2: {} @@ -14707,8 +15906,21 @@ snapshots: optionalDependencies: fsevents: 2.3.2 + png-chunk-text@1.0.0: {} + + png-chunks-encode@1.0.0: + dependencies: + crc-32: 0.3.0 + sliced: 1.0.1 + + png-chunks-extract@1.0.0: + dependencies: + crc-32: 0.3.0 + points-on-curve@0.2.0: {} + points-on-curve@1.0.1: {} + points-on-path@0.2.1: dependencies: path-data-parser: 0.1.0 @@ -14803,6 +16015,8 @@ snapshots: punycode@2.3.1: {} + pwacompat@2.0.17: {} + qs@6.15.0: dependencies: side-channel: 1.1.0 @@ -15134,6 +16348,13 @@ snapshots: "@rollup/rollup-win32-x64-msvc": 4.60.1 fsevents: 2.3.3 + roughjs@4.6.4: + dependencies: + hachure-fill: 0.5.2 + path-data-parser: 0.1.0 + points-on-curve: 0.2.0 + points-on-path: 0.2.1 + roughjs@4.6.6: dependencies: hachure-fill: 0.5.2 @@ -15186,6 +16407,12 @@ snapshots: safer-buffer@2.1.2: {} + sass@1.51.0: + dependencies: + chokidar: 3.6.0 + immutable: 4.3.9 + source-map-js: 1.2.1 + saxes@6.0.0: dependencies: xmlchars: 2.2.0 @@ -15338,6 +16565,8 @@ snapshots: ansi-styles: 6.2.3 is-fullwidth-code-point: 5.1.0 + sliced@1.0.1: {} + smob@1.6.1: {} sonic-boom@4.2.1: @@ -15640,6 +16869,14 @@ snapshots: fsevents: 2.3.3 optional: true + tunnel-rat@0.1.2(@types/react@18.3.28)(immer@11.1.4)(react@18.3.1): + dependencies: + zustand: 4.5.7(@types/react@18.3.28)(immer@11.1.4)(react@18.3.1) + transitivePeerDependencies: + - "@types/react" + - immer + - react + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 @@ -15705,8 +16942,7 @@ snapshots: has-symbols: 1.1.0 which-boxed-primitive: 1.1.1 - undici-types@6.21.0: - optional: true + undici-types@6.21.0: {} undici-types@7.16.0: {} @@ -15828,13 +17064,13 @@ snapshots: d3-time: 3.1.0 d3-timer: 3.0.1 - vite-node@2.1.9(@types/node@24.12.0)(terser@5.46.1): + vite-node@2.1.9(@types/node@24.12.0)(sass@1.51.0)(terser@5.46.1): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 1.1.2 - vite: 5.4.21(@types/node@24.12.0)(terser@5.46.1) + vite: 5.4.21(@types/node@24.12.0)(sass@1.51.0)(terser@5.46.1) transitivePeerDependencies: - "@types/node" - less @@ -15846,18 +17082,18 @@ snapshots: - supports-color - terser - vite-plugin-pwa@1.2.0(vite@5.4.21(@types/node@24.12.0)(terser@5.46.1))(workbox-build@7.4.0(@types/babel__core@7.20.5))(workbox-window@7.4.0): + vite-plugin-pwa@1.2.0(vite@5.4.21(@types/node@24.12.0)(sass@1.51.0)(terser@5.46.1))(workbox-build@7.4.0(@types/babel__core@7.20.5))(workbox-window@7.4.0): dependencies: debug: 4.4.3 pretty-bytes: 6.1.1 tinyglobby: 0.2.15 - vite: 5.4.21(@types/node@24.12.0)(terser@5.46.1) + vite: 5.4.21(@types/node@24.12.0)(sass@1.51.0)(terser@5.46.1) workbox-build: 7.4.0(@types/babel__core@7.20.5) workbox-window: 7.4.0 transitivePeerDependencies: - supports-color - vite@5.4.21(@types/node@24.12.0)(terser@5.46.1): + vite@5.4.21(@types/node@24.12.0)(sass@1.51.0)(terser@5.46.1): dependencies: esbuild: 0.21.5 postcss: 8.5.8 @@ -15865,9 +17101,10 @@ snapshots: optionalDependencies: "@types/node": 24.12.0 fsevents: 2.3.3 + sass: 1.51.0 terser: 5.46.1 - vite@6.4.1(@types/node@24.12.0)(jiti@1.21.7)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3): + vite@6.4.1(@types/node@24.12.0)(jiti@1.21.7)(sass@1.51.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3): dependencies: esbuild: 0.25.12 fdir: 6.5.0(picomatch@4.0.4) @@ -15879,14 +17116,15 @@ snapshots: "@types/node": 24.12.0 fsevents: 2.3.3 jiti: 1.21.7 + sass: 1.51.0 terser: 5.46.1 tsx: 4.21.0 yaml: 2.8.3 - vitest@2.1.9(@types/node@24.12.0)(happy-dom@18.0.1)(jsdom@29.1.1)(terser@5.46.1): + vitest@2.1.9(@types/node@24.12.0)(happy-dom@18.0.1)(jsdom@29.1.1)(sass@1.51.0)(terser@5.46.1): dependencies: "@vitest/expect": 2.1.9 - "@vitest/mocker": 2.1.9(vite@5.4.21(@types/node@24.12.0)(terser@5.46.1)) + "@vitest/mocker": 2.1.9(vite@5.4.21(@types/node@24.12.0)(sass@1.51.0)(terser@5.46.1)) "@vitest/pretty-format": 2.1.9 "@vitest/runner": 2.1.9 "@vitest/snapshot": 2.1.9 @@ -15902,8 +17140,8 @@ snapshots: tinyexec: 0.3.2 tinypool: 1.1.1 tinyrainbow: 1.2.0 - vite: 5.4.21(@types/node@24.12.0)(terser@5.46.1) - vite-node: 2.1.9(@types/node@24.12.0)(terser@5.46.1) + vite: 5.4.21(@types/node@24.12.0)(sass@1.51.0)(terser@5.46.1) + vite-node: 2.1.9(@types/node@24.12.0)(sass@1.51.0)(terser@5.46.1) why-is-node-running: 2.3.0 optionalDependencies: "@types/node": 24.12.0 @@ -15920,10 +17158,10 @@ snapshots: - supports-color - terser - vitest@4.1.2(@types/node@24.12.0)(happy-dom@18.0.1)(jsdom@29.1.1)(vite@6.4.1(@types/node@24.12.0)(jiti@1.21.7)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): + vitest@4.1.2(@types/node@24.12.0)(happy-dom@18.0.1)(jsdom@29.1.1)(vite@6.4.1(@types/node@24.12.0)(jiti@1.21.7)(sass@1.51.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): dependencies: "@vitest/expect": 4.1.2 - "@vitest/mocker": 4.1.2(vite@6.4.1(@types/node@24.12.0)(jiti@1.21.7)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + "@vitest/mocker": 4.1.2(vite@6.4.1(@types/node@24.12.0)(jiti@1.21.7)(sass@1.51.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) "@vitest/pretty-format": 4.1.2 "@vitest/runner": 4.1.2 "@vitest/snapshot": 4.1.2 @@ -15940,7 +17178,7 @@ snapshots: tinyexec: 1.0.4 tinyglobby: 0.2.15 tinyrainbow: 3.1.0 - vite: 6.4.1(@types/node@24.12.0)(jiti@1.21.7)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + vite: 6.4.1(@types/node@24.12.0)(jiti@1.21.7)(sass@1.51.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) why-is-node-running: 2.3.0 optionalDependencies: "@types/node": 24.12.0 @@ -15949,6 +17187,23 @@ snapshots: transitivePeerDependencies: - msw + vscode-jsonrpc@8.2.0: {} + + vscode-languageserver-protocol@3.17.5: + dependencies: + vscode-jsonrpc: 8.2.0 + vscode-languageserver-types: 3.17.5 + + vscode-languageserver-textdocument@1.0.12: {} + + vscode-languageserver-types@3.17.5: {} + + vscode-languageserver@9.0.1: + dependencies: + vscode-languageserver-protocol: 3.17.5 + + vscode-uri@3.0.8: {} + w3c-xmlserializer@5.0.0: dependencies: xml-name-validator: 5.0.0 @@ -15961,8 +17216,9 @@ snapshots: webidl-conversions@8.0.1: {} - whatwg-mimetype@3.0.0: - optional: true + webworkify@1.5.0: {} + + whatwg-mimetype@3.0.0: {} whatwg-mimetype@5.0.0: {} @@ -16199,4 +17455,12 @@ snapshots: zod@4.3.6: {} + zustand@4.5.7(@types/react@18.3.28)(immer@11.1.4)(react@18.3.1): + dependencies: + use-sync-external-store: 1.6.0(react@18.3.1) + optionalDependencies: + "@types/react": 18.3.28 + immer: 11.1.4 + react: 18.3.1 + zwitch@2.0.4: {} From 4e298626eb285b7e9ca23dbd19bda6794a337092 Mon Sep 17 00:00:00 2001 From: Brad Harris Date: Mon, 27 Jul 2026 13:03:53 -0600 Subject: [PATCH 4/6] Fix whiteboard review findings: migration collision, SSE sync, conflict merge, split pane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename migration 0030_whiteboards → 0037_whiteboards to avoid collision with 0030_agent-messages - Invalidate whiteboard query for all SSE sources, not just agent-originated - Handle 409 save conflicts with local-first element merge and retry - Merge remote elements into local scene when user has unsaved edits - Prevent whiteboard tab from being dragged into split pane mode Co-Authored-By: Claude Opus 4.6 --- ...0_whiteboards.sql => 0037_whiteboards.sql} | 0 .../components/app/center-pane-tab-bar.tsx | 4 +- .../web/src/components/app/whiteboard-tab.tsx | 127 +++++++++++++----- apps/web/src/hooks/use-split-pane.ts | 2 + apps/web/src/hooks/use-sse.ts | 8 +- 5 files changed, 105 insertions(+), 36 deletions(-) rename apps/server/src/db/migrations/{0030_whiteboards.sql => 0037_whiteboards.sql} (100%) diff --git a/apps/server/src/db/migrations/0030_whiteboards.sql b/apps/server/src/db/migrations/0037_whiteboards.sql similarity index 100% rename from apps/server/src/db/migrations/0030_whiteboards.sql rename to apps/server/src/db/migrations/0037_whiteboards.sql diff --git a/apps/web/src/components/app/center-pane-tab-bar.tsx b/apps/web/src/components/app/center-pane-tab-bar.tsx index 43e436a6..4c102473 100644 --- a/apps/web/src/components/app/center-pane-tab-bar.tsx +++ b/apps/web/src/components/app/center-pane-tab-bar.tsx @@ -78,7 +78,9 @@ export const CenterPaneTabBar = memo(function CenterPaneTabBar({ role="tab" aria-selected={activeTab === tab.id} data-testid={`center-tab-${tab.id}`} - draggable={!isMobile && activeTab !== tab.id} + draggable={ + !isMobile && activeTab !== tab.id && tab.id !== "whiteboard" + } onDragStart={(e) => handleDragStart(e, tab.id)} className={cn( "relative flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold uppercase tracking-wide transition-colors", diff --git a/apps/web/src/components/app/whiteboard-tab.tsx b/apps/web/src/components/app/whiteboard-tab.tsx index 7186f803..edfbaff0 100644 --- a/apps/web/src/components/app/whiteboard-tab.tsx +++ b/apps/web/src/components/app/whiteboard-tab.tsx @@ -31,6 +31,20 @@ window.EXCALIDRAW_ASSET_PATH = "/excalidraw/"; const SAVE_DEBOUNCE_MS = 1000; const SNAPSHOT_DEBOUNCE_MS = 4000; +function mergeElements( + local: ExcalidrawElement[], + remote: ExcalidrawElement[] +): ExcalidrawElement[] { + const localById = new Map(local.map((el) => [el.id, el])); + const merged: ExcalidrawElement[] = [...local]; + for (const el of remote) { + if (!localById.has(el.id)) { + merged.push(el); + } + } + return merged; +} + type WhiteboardTabProps = { agentId: string; visible: boolean; @@ -136,29 +150,68 @@ function WhiteboardCanvas({ if (sceneVersion === sceneVersionRef.current) return; savingRef.current = true; try { - const res = await api<{ version: number }>( - `/api/v1/agents/${agentId}/whiteboard`, - { - method: "PUT", - body: JSON.stringify({ - scene: { elements }, - baseVersion: versionRef.current, - }), - } - ); - versionRef.current = res.version; - sceneVersionRef.current = sceneVersion; - queryClient.setQueryData( - whiteboardQueryKey(agentId), - (old) => - old - ? { - ...old, - scene: { elements: [...elements] }, - version: res.version, - } - : old - ); + const res = await fetch(`/api/v1/agents/${agentId}/whiteboard`, { + method: "PUT", + credentials: "include", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + scene: { elements }, + baseVersion: versionRef.current, + }), + }); + if (res.ok) { + const body = (await res.json()) as { version: number }; + versionRef.current = body.version; + sceneVersionRef.current = sceneVersion; + queryClient.setQueryData( + whiteboardQueryKey(agentId), + (old) => + old + ? { + ...old, + scene: { elements: [...elements] }, + version: body.version, + } + : old + ); + } else if (res.status === 409) { + const conflict = (await res.json()) as { + scene: { elements: ExcalidrawElement[] }; + version: number; + }; + const merged = mergeElements( + elements as ExcalidrawElement[], + conflict.scene.elements + ); + const retryRes = await api<{ version: number }>( + `/api/v1/agents/${agentId}/whiteboard`, + { + method: "PUT", + body: JSON.stringify({ + scene: { elements: merged }, + baseVersion: conflict.version, + }), + } + ); + versionRef.current = retryRes.version; + sceneVersionRef.current = getSceneVersion( + merged as readonly ExcalidrawElement[] + ); + excalidrawAPI.updateScene({ elements: merged }); + queryClient.setQueryData( + whiteboardQueryKey(agentId), + (old) => + old + ? { + ...old, + scene: { elements: merged }, + version: retryRes.version, + } + : old + ); + } else { + throw new Error(`Save failed: ${res.status}`); + } if (snapshotTimerRef.current !== undefined) { window.clearTimeout(snapshotTimerRef.current); } @@ -191,15 +244,27 @@ function WhiteboardCanvas({ const applyRemote = useCallback( (remote: WhiteboardData) => { if (!excalidrawAPI) return; - const hydrated = restoreElements( - remote.scene.elements as ExcalidrawElement[], - excalidrawAPI.getSceneElements(), - { repairBindings: true } - ); + const localElements = excalidrawAPI.getSceneElements(); + const hasPendingSave = saveTimerRef.current !== undefined; + let merged: ExcalidrawElement[]; + if (hasPendingSave) { + merged = mergeElements( + localElements as ExcalidrawElement[], + remote.scene.elements as ExcalidrawElement[] + ); + } else { + merged = restoreElements( + remote.scene.elements as ExcalidrawElement[], + localElements, + { repairBindings: true } + ); + } versionRef.current = remote.version; - sceneVersionRef.current = getSceneVersion(hydrated); - excalidrawAPI.updateScene({ elements: hydrated }); - setBoardEmpty(hydrated.length === 0); + sceneVersionRef.current = getSceneVersion( + merged as readonly ExcalidrawElement[] + ); + excalidrawAPI.updateScene({ elements: merged }); + setBoardEmpty(merged.length === 0); if (snapshotTimerRef.current !== undefined) { window.clearTimeout(snapshotTimerRef.current); } diff --git a/apps/web/src/hooks/use-split-pane.ts b/apps/web/src/hooks/use-split-pane.ts index ba86a54d..8c97ae40 100644 --- a/apps/web/src/hooks/use-split-pane.ts +++ b/apps/web/src/hooks/use-split-pane.ts @@ -24,6 +24,7 @@ export function useSplitPane(agentId: string | null, isMobile: boolean) { (draggedTab: CenterTab, side: "left" | "right", activeTab: CenterTab) => { if (isMobile || !agentId) return; if (draggedTab === activeTab) return; + if (draggedTab === "whiteboard" || activeTab === "whiteboard") return; const left = side === "left" ? draggedTab : activeTab; const right = side === "right" ? draggedTab : activeTab; @@ -62,6 +63,7 @@ export function useSplitPane(agentId: string | null, isMobile: boolean) { const handleTabDrop = useCallback( (draggedTab: CenterTab, side: "left" | "right", activeTab: CenterTab) => { if (isMobile || !agentId) return; + if (draggedTab === "whiteboard" || activeTab === "whiteboard") return; if (splitState.mode === "split") { const otherSide = side === "left" ? "right" : "left"; diff --git a/apps/web/src/hooks/use-sse.ts b/apps/web/src/hooks/use-sse.ts index 157c159c..65ed2e04 100644 --- a/apps/web/src/hooks/use-sse.ts +++ b/apps/web/src/hooks/use-sse.ts @@ -221,11 +221,11 @@ export function useSSE(authState: AuthState): void { } if (payload.type === "whiteboard.changed") { + void queryClient.invalidateQueries({ + queryKey: ["whiteboard", payload.agentId], + exact: true, + }); if (payload.source === "agent") { - void queryClient.invalidateQueries({ - queryKey: ["whiteboard", payload.agentId], - exact: true, - }); jotaiStore.set( whiteboardAgentDrewAtomFamily(payload.agentId), true From e8f079140ca3667fd15f6c152b30e7a7e580a646 Mon Sep 17 00:00:00 2001 From: Brad Harris Date: Mon, 27 Jul 2026 13:05:35 -0600 Subject: [PATCH 5/6] Fix applyRemote: keep merged scene dirty so pending save persists it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When applyRemote merges local + remote elements (dirty path), don't update sceneVersionRef — the rescheduled save must still detect a diff to persist the merged scene. Clean path (no pending edits) continues to update sceneVersionRef normally. Co-Authored-By: Claude Opus 4.6 --- .../web/src/components/app/whiteboard-tab.tsx | 31 +++++++++++++------ 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/app/whiteboard-tab.tsx b/apps/web/src/components/app/whiteboard-tab.tsx index edfbaff0..bb87b9d3 100644 --- a/apps/web/src/components/app/whiteboard-tab.tsx +++ b/apps/web/src/components/app/whiteboard-tab.tsx @@ -260,19 +260,32 @@ function WhiteboardCanvas({ ); } versionRef.current = remote.version; - sceneVersionRef.current = getSceneVersion( - merged as readonly ExcalidrawElement[] - ); excalidrawAPI.updateScene({ elements: merged }); setBoardEmpty(merged.length === 0); - if (snapshotTimerRef.current !== undefined) { - window.clearTimeout(snapshotTimerRef.current); + if (hasPendingSave) { + // Don't update sceneVersionRef — the pending save must still see a + // diff so it persists the merged (local + remote) scene. Cancel the + // existing timer and reschedule so the merged scene is saved promptly. + if (saveTimerRef.current !== undefined) { + window.clearTimeout(saveTimerRef.current); + } + saveTimerRef.current = window.setTimeout(() => { + saveTimerRef.current = undefined; + void persistScene(); + }, SAVE_DEBOUNCE_MS); + } else { + sceneVersionRef.current = getSceneVersion( + merged as readonly ExcalidrawElement[] + ); + if (snapshotTimerRef.current !== undefined) { + window.clearTimeout(snapshotTimerRef.current); + } + snapshotTimerRef.current = window.setTimeout(() => { + void persistSnapshot(); + }, SNAPSHOT_DEBOUNCE_MS); } - snapshotTimerRef.current = window.setTimeout(() => { - void persistSnapshot(); - }, SNAPSHOT_DEBOUNCE_MS); }, - [excalidrawAPI, persistSnapshot] + [excalidrawAPI, persistScene, persistSnapshot] ); useEffect(() => { From b29dec7ad08b6a53b9fadc20055c941f79c16669 Mon Sep 17 00:00:00 2001 From: Brad Harris Date: Mon, 27 Jul 2026 18:17:50 -0600 Subject: [PATCH 6/6] Polish whiteboard UI: consistent tab spacing, split pane support, hide library/hints/help, add tip - Remove diffStats from tab bar, relocate diff count badge to agents-view header - Enable whiteboard tab drag for split pane; add whiteboard render path to CenterPaneSplit - Remove whiteboard guards from use-split-pane enterSplit/handleTabDrop - Hide Excalidraw library UI, toolbar hints, and help button via CSS overrides - Add whiteboard inline + ambient tip (since 0.30.1) Co-Authored-By: Claude Opus 4.6 --- apps/web/src/components/app/agents-view.tsx | 34 +++++++-- .../src/components/app/center-pane-split.tsx | 18 ++++- .../app/center-pane-tab-bar.test.tsx | 48 +++++-------- .../components/app/center-pane-tab-bar.tsx | 70 +++++++++---------- .../web/src/components/app/whiteboard-tab.tsx | 2 +- apps/web/src/hooks/use-split-pane.ts | 2 - apps/web/src/index.css | 12 ++++ apps/web/src/lib/tips/tips.ts | 8 +++ 8 files changed, 119 insertions(+), 75 deletions(-) diff --git a/apps/web/src/components/app/agents-view.tsx b/apps/web/src/components/app/agents-view.tsx index 10209ce3..b7b952fc 100644 --- a/apps/web/src/components/app/agents-view.tsx +++ b/apps/web/src/components/app/agents-view.tsx @@ -19,6 +19,7 @@ import { ChangesSettingsPopover } from "@/components/app/changes-settings-popove import { CenterPaneTabBar, TAB_DRAG_MIME, + formatDiffCount, } from "@/components/app/center-pane-tab-bar"; import { SplitDropZones } from "@/components/app/split-drop-zones"; import { CenterPaneSplit } from "@/components/app/center-pane-split"; @@ -580,6 +581,15 @@ export function AgentsView({ /> ) : null; + const whiteboardVisible = + (isSplit && + (splitState.left === "whiteboard" || + splitState.right === "whiteboard")) || + (!isSplit && whiteboardMatch); + const whiteboardElement = whiteboardVisible ? ( + + ) : null; + return (
@@ -685,6 +695,23 @@ export function AgentsView({ focusTerminal={focusTerminal} /> + {focusedDiffStats && + (focusedDiffStats.added > 0 || + focusedDiffStats.deleted > 0) ? ( + + + +{formatDiffCount(focusedDiffStats.added)} + + + {"−"} + {formatDiffCount(focusedDiffStats.deleted)} + + + ) : null}
{focusedAgent?.name ? ( @@ -709,7 +736,6 @@ export function AgentsView({ } onTabChange(tab); }} - diffStats={focusedDiffStats} whiteboardAgentDrew={whiteboardAgentDrew} isSplit={isSplit} splitState={splitState} @@ -759,6 +785,7 @@ export function AgentsView({ splitButtonRef={splitButtonRef} splitTerminalSlotRef={splitTerminalSlotRef} changesElement={changesElement} + whiteboardElement={whiteboardElement} isMobile={isMobile} onLayoutChange={handleSplitLayoutChange} onExitSplit={exitSplit} @@ -775,10 +802,7 @@ export function AgentsView({ - + {whiteboardElement} )} {stableTerminalContainerRef.current diff --git a/apps/web/src/components/app/center-pane-split.tsx b/apps/web/src/components/app/center-pane-split.tsx index 0b2d84d4..7412867d 100644 --- a/apps/web/src/components/app/center-pane-split.tsx +++ b/apps/web/src/components/app/center-pane-split.tsx @@ -6,7 +6,13 @@ import { ResizablePanel, ResizablePanelGroup, } from "@/components/ui/resizable"; -import { type SplitPaneState } from "@/lib/store"; +import { type CenterTab, type SplitPaneState } from "@/lib/store"; + +const TAB_LABELS: Record = { + terminal: "Terminal", + changes: "Changes", + whiteboard: "Whiteboard", +}; type CenterPaneSplitProps = { splitState: SplitPaneState; @@ -14,6 +20,7 @@ type CenterPaneSplitProps = { splitButtonRef: React.RefObject; splitTerminalSlotRef: React.RefObject; changesElement: React.ReactNode; + whiteboardElement: React.ReactNode; isMobile: boolean; onLayoutChange: (layout: Record) => void; onExitSplit: () => void; @@ -31,6 +38,7 @@ export function CenterPaneSplit({ splitButtonRef, splitTerminalSlotRef, changesElement, + whiteboardElement, isMobile, onLayoutChange, onExitSplit, @@ -50,7 +58,7 @@ export function CenterPaneSplit({
- {splitState.left === "terminal" ? "Terminal" : "Changes"} + {TAB_LABELS[splitState.left]} {splitState.left === "changes" && !isMobile ? ( @@ -59,6 +67,8 @@ export function CenterPaneSplit({
{splitState.left === "terminal" ? (
+ ) : splitState.left === "whiteboard" ? ( + whiteboardElement ) : ( changesElement )} @@ -74,7 +84,7 @@ export function CenterPaneSplit({
- {splitState.right === "terminal" ? "Terminal" : "Changes"} + {TAB_LABELS[splitState.right]} {splitState.right === "changes" && !isMobile ? ( @@ -83,6 +93,8 @@ export function CenterPaneSplit({
{splitState.right === "terminal" ? (
+ ) : splitState.right === "whiteboard" ? ( + whiteboardElement ) : ( changesElement )} diff --git a/apps/web/src/components/app/center-pane-tab-bar.test.tsx b/apps/web/src/components/app/center-pane-tab-bar.test.tsx index 48233c75..6fb31d80 100644 --- a/apps/web/src/components/app/center-pane-tab-bar.test.tsx +++ b/apps/web/src/components/app/center-pane-tab-bar.test.tsx @@ -1,41 +1,31 @@ // @vitest-environment jsdom import { render, screen } from "@testing-library/react"; +import { MemoryRouter } from "react-router"; import { describe, expect, it, vi } from "vitest"; import { CenterPaneTabBar } from "./center-pane-tab-bar"; describe("CenterPaneTabBar", () => { - it("compacts large diff counts while exposing the exact values", () => { + it("renders all three tabs with consistent spacing", () => { render( - + + + ); - const badge = screen.getByTitle("12,345 additions, 98,765 deletions"); - expect(badge.textContent).toBe("+12k−99k"); - expect(badge.getAttribute("aria-label")).toBe( - "12,345 additions, 98,765 deletions" - ); - expect(badge.className).toContain("hidden"); - expect(badge.className).toContain("sm:inline-flex"); - expect(screen.getByRole("tab", { name: /changes/i }).className).toContain( - "sm:w-36" - ); + expect(screen.getByRole("tab", { name: /terminal/i })).toBeTruthy(); + expect(screen.getByRole("tab", { name: /changes/i })).toBeTruthy(); + expect(screen.getByRole("tab", { name: /whiteboard/i })).toBeTruthy(); }); }); diff --git a/apps/web/src/components/app/center-pane-tab-bar.tsx b/apps/web/src/components/app/center-pane-tab-bar.tsx index 4c102473..617a8e82 100644 --- a/apps/web/src/components/app/center-pane-tab-bar.tsx +++ b/apps/web/src/components/app/center-pane-tab-bar.tsx @@ -1,6 +1,5 @@ import { memo, useCallback } from "react"; -import type { DiffStats } from "@/components/app/types"; import { TipSpot } from "@/components/tips/tip-spot"; import { type CenterTab, type SplitPaneState } from "@/lib/store"; import { cn } from "@/lib/utils"; @@ -30,7 +29,6 @@ export function formatDiffCount(count: number): string { type CenterPaneTabBarProps = { activeTab: CenterTab; onTabChange: (tab: CenterTab) => void; - diffStats: DiffStats | null | undefined; whiteboardAgentDrew?: boolean; isSplit: boolean; splitState: SplitPaneState; @@ -40,18 +38,11 @@ type CenterPaneTabBarProps = { export const CenterPaneTabBar = memo(function CenterPaneTabBar({ activeTab, onTabChange, - diffStats, whiteboardAgentDrew = false, isSplit, splitState, isMobile, }: CenterPaneTabBarProps): JSX.Element { - const hasChanges = - diffStats && (diffStats.added > 0 || diffStats.deleted > 0); - const diffStatsLabel = diffStats - ? `${diffStats.added.toLocaleString("en-US")} additions, ${diffStats.deleted.toLocaleString("en-US")} deletions` - : undefined; - const splitTabs = isSplit ? new Set([splitState.left, splitState.right]) : new Set(); @@ -78,13 +69,10 @@ export const CenterPaneTabBar = memo(function CenterPaneTabBar({ role="tab" aria-selected={activeTab === tab.id} data-testid={`center-tab-${tab.id}`} - draggable={ - !isMobile && activeTab !== tab.id && tab.id !== "whiteboard" - } + draggable={!isMobile && activeTab !== tab.id} onDragStart={(e) => handleDragStart(e, tab.id)} className={cn( "relative flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold uppercase tracking-wide transition-colors", - tab.id === "changes" && "sm:w-36", activeTab === tab.id ? "text-foreground" : "cursor-grab text-muted-foreground hover:text-foreground/80 active:cursor-grabbing" @@ -110,33 +98,45 @@ export const CenterPaneTabBar = memo(function CenterPaneTabBar({ {activeTab === tab.id && !isSplit ? ( ) : null} - {tab.id === "changes" && hasChanges ? ( - - - - - ) : null} ); - if (tab.id !== "changes" || activeTab === tab.id || isMobile || isSplit) - return button; + if ( + tab.id === "changes" && + activeTab !== tab.id && + !isMobile && + !isSplit + ) + return ( + + {button} + + ); - return ( - - {button} - - ); + if ( + tab.id === "whiteboard" && + activeTab !== tab.id && + !isMobile && + !isSplit + ) + return ( + + {button} + + ); + + return button; })}
); diff --git a/apps/web/src/components/app/whiteboard-tab.tsx b/apps/web/src/components/app/whiteboard-tab.tsx index bb87b9d3..adf8b3eb 100644 --- a/apps/web/src/components/app/whiteboard-tab.tsx +++ b/apps/web/src/components/app/whiteboard-tab.tsx @@ -341,7 +341,7 @@ function WhiteboardCanvas({ return (
{ pointerDownRef.current = true; diff --git a/apps/web/src/hooks/use-split-pane.ts b/apps/web/src/hooks/use-split-pane.ts index 8c97ae40..ba86a54d 100644 --- a/apps/web/src/hooks/use-split-pane.ts +++ b/apps/web/src/hooks/use-split-pane.ts @@ -24,7 +24,6 @@ export function useSplitPane(agentId: string | null, isMobile: boolean) { (draggedTab: CenterTab, side: "left" | "right", activeTab: CenterTab) => { if (isMobile || !agentId) return; if (draggedTab === activeTab) return; - if (draggedTab === "whiteboard" || activeTab === "whiteboard") return; const left = side === "left" ? draggedTab : activeTab; const right = side === "right" ? draggedTab : activeTab; @@ -63,7 +62,6 @@ export function useSplitPane(agentId: string | null, isMobile: boolean) { const handleTabDrop = useCallback( (draggedTab: CenterTab, side: "left" | "right", activeTab: CenterTab) => { if (isMobile || !agentId) return; - if (draggedTab === "whiteboard" || activeTab === "whiteboard") return; if (splitState.mode === "split") { const otherSide = side === "left" ? "right" : "left"; diff --git a/apps/web/src/index.css b/apps/web/src/index.css index d38e516f..4d394dc5 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1382,3 +1382,15 @@ html[data-hidden] .animate-sidebar-nav-pulse { .token.interpolation { color: #cf222e; } + +/* Excalidraw overrides — hide library, help button, and toolbar hints */ +.dispatch-whiteboard .default-sidebar-trigger, +.dispatch-whiteboard .layer-ui__library { + display: none !important; +} +.dispatch-whiteboard .HintViewer { + display: none !important; +} +.dispatch-whiteboard .help-icon { + display: none !important; +} diff --git a/apps/web/src/lib/tips/tips.ts b/apps/web/src/lib/tips/tips.ts index 97af26aa..e9af6f66 100644 --- a/apps/web/src/lib/tips/tips.ts +++ b/apps/web/src/lib/tips/tips.ts @@ -220,6 +220,14 @@ export const tips: Tip[] = [ since: "0.30.0", surfaces: ["ambient"], }, + { + id: "whiteboard", + title: "Whiteboard", + body: "Agents and users share a live whiteboard. Draw diagrams, sketch ideas, or annotate what an agent has started.", + docsSection: "agents", + since: "0.30.1", + surfaces: ["inline", "ambient"], + }, { id: "job-webhook-trigger", title: "Job Webhook Triggers",