diff --git a/cloudflare/gallery-tools.test.ts b/cloudflare/gallery-tools.test.ts new file mode 100644 index 0000000..fe1cf56 --- /dev/null +++ b/cloudflare/gallery-tools.test.ts @@ -0,0 +1,73 @@ +import { expect, test } from "bun:test"; +import Ajv from "ajv"; +import { CLOUD_MCP_TOOLS } from "./tool-contract"; +import { galleryAction, gallerySource } from "./gallery-tools"; +import { toolErrorResult } from "../src/mcp/tool-result"; +import { settingsTool } from "../src/mcp/settings-contract"; + +test("gallery source returns complete projects and selects the requested kind and workspace", async () => { + const source = Array.from({ length: 250 }, (_, index) => `// line ${index}\n`).join(""); + const project = { files: { "helper.ts": "export const value = 1;" }, dependencies: { example: "1.0.0" }, lock: { "node_modules/example/index.js": "export const dependency = 2;" } }; + const calls: unknown[] = []; + const service = { + async snapshot(selection: unknown) { calls.push(["artifact", selection]); return { name: "example", source, server_source: "server", project, revision_token: "artifact-revision" }; }, + async scriptReadSource(selection: unknown) { calls.push(["script", selection]); return { name: "example", source, project, revision_token: "script-revision" }; }, + }; + for (const kind of ["artifact", "script"]) { + const result = await gallerySource(service, { workspace: "research", kind, version_id: "historical" }); + expect(result.structuredContent).toEqual({ name: "example", kind, workspace: "research", source, server_source: kind === "artifact" ? "server" : null, project, revision_token: `${kind}-revision` }); + } + expect(calls).toEqual([["artifact", { workspace: "research", version_id: "historical" }], ["script", { workspace: "research", version_id: "historical" }]]); + await expect(gallerySource(service, { workspace: "research", kind: "artifact", name: "example", version_id: "historical" })).rejects.toThrow("not both"); +}); + +test("gallery action preserves CAS failures and committed validation diagnostics", async () => { + const calls: unknown[] = []; + const service = { async callTool(name: string, args: Record) { + calls.push({ name, args }); + if (args.expected_revision === "stale") throw new Error("Project changed since it was loaded. Reload the saved project or compare it with your edits before saving."); + const payload = { applied: true, ok: false, revision_token: "new-revision", diagnostics: [{ message: "Invalid source", severity: "error" }] }; + return { content: [{ type: "text" as const, text: JSON.stringify(payload) }], structuredContent: payload, isError: true }; + } }; + const failed = await galleryAction(service, { workspace: "research", tool: "artifact_write", arguments: { name: "example", contents: "invalid", expected_revision: "current" } }); + expect(failed).toMatchObject({ isError: true, structuredContent: { applied: true, revision_token: "new-revision", diagnostics: [{ message: "Invalid source" }] } }); + const conflict = await galleryAction(service, { workspace: "research", tool: "script_write", arguments: { name: "example", contents: "new", expected_revision: "stale" } }); + expect(conflict).toMatchObject({ isError: true, structuredContent: { status: 409, conflict: true, applied: false } }); + expect(calls).toEqual([ + { name: "artifact_write", args: { workspace: "research", name: "example", contents: "invalid", expected_revision: "current" } }, + { name: "script_write", args: { workspace: "research", name: "example", contents: "new", expected_revision: "stale" } }, + ]); + await expect(galleryAction(service, { workspace: "research", tool: "app_write", arguments: {} })).rejects.toThrow("Unsupported"); + await expect(galleryAction(service, { workspace: "research", tool: "artifact_write", arguments: { workspace: "elsewhere" } })).rejects.toThrow("envelope"); + expect(toolErrorResult(Object.assign(new Error("storage failed after save"), { status: 503, applied: true, revision_token: "saved" }))).toMatchObject({ structuredContent: { status: 503, applied: true, revision_token: "saved" } }); +}); + +test("published gallery proxy validates underlying tool schemas without granting blanket app visibility", () => { + const tool = CLOUD_MCP_TOOLS.find(tool => tool.name === "artifacts_tool")!; + const validate = new Ajv({ strict: false }).compile(tool.inputSchema); + expect(validate({ workspace: "research", tool: "artifact_write", arguments: { name: "example", contents: "source", expected_revision: null, project: { files: {}, dependencies: {} } } })).toBe(true); + for (const argumentsValue of [{ name: "example" }, { name: "example", contents: "source", workspace: "wrong" }, { name: "example", contents: "source", expected_revision: 3 }, { name: "example", contents: "source", project: { unexpected: "value" } }]) { + expect(validate({ workspace: "research", tool: "artifact_write", arguments: argumentsValue })).toBe(false); + } + expect(validate({ workspace: "research", tool: "app_write", arguments: {} })).toBe(false); + expect(tool._meta?.ui).toEqual({ visibility: ["app"] }); + expect((CLOUD_MCP_TOOLS.find(tool => tool.name === "artifact_write")!._meta?.ui as { visibility: string[] }).visibility).toEqual(["model"]); + const source = CLOUD_MCP_TOOLS.find(tool => tool.name === "artifacts_source")!; + const validateSource = new Ajv({ strict: false }).compile(source.inputSchema); + expect(validateSource({ workspace: "research", kind: "script", version_id: "historical" })).toBe(true); + expect(validateSource({ workspace: "research", kind: "script", version_id: "historical", name: "example" })).toBe(false); +}); + +test("native connection settings describe the real connection and check authorized access", async () => { + let reads = 0; + const service = { workspace: "research", productUrl: "https://artifacts.example/?workspace=research", async gallery(all: boolean) { + expect(all).toBe(true); reads++; + return { workspace: "research", artifacts: [], capabilities: { editing: true, scripts: true } }; + } }; + const settings = await settingsTool(service, "artifacts_settings_read", {}); + expect(settings.structuredContent).toMatchObject({ schema: { properties: {} }, values: {}, layout: [{ title: "Workspace: research", items: [{ tool: "artifacts_connection_check", description: expect.stringContaining(service.productUrl) }] }] }); + expect(reads).toBe(0); + expect(await settingsTool(service, "artifacts_connection_check", {})).toMatchObject({ structuredContent: { ok: true, workspace: "research", productUrl: service.productUrl, capabilities: { editing: true, scripts: true } } }); + expect(reads).toBe(1); + await expect(settingsTool(service, "artifacts_settings_update", { set: { workspace: "wrong" } })).rejects.toThrow("managed by the MCP host"); +}); diff --git a/cloudflare/gallery-tools.ts b/cloudflare/gallery-tools.ts new file mode 100644 index 0000000..1286f72 --- /dev/null +++ b/cloudflare/gallery-tools.ts @@ -0,0 +1,31 @@ +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { GALLERY_TOOL_NAMES } from "../src/mcp/gallery-contract"; +import { toolErrorResult } from "../src/mcp/tool-result"; + +type Selection = { workspace?: string; name?: string; version_id?: string }; +type Source = { name: string; source: string; server_source?: string | null }; +type SourceService = { + snapshot(selection: Selection): Promise; + scriptReadSource(selection: Selection): Promise; +}; + +export async function gallerySource(service: SourceService, args: Record): Promise { + if (typeof args.workspace !== "string" || !args.workspace.trim() || (args.kind !== "artifact" && args.kind !== "script")) throw new Error("Select a workspace and artifact or script kind"); + if ((typeof args.name === "string") === (typeof args.version_id === "string")) throw new Error("Select name or version_id, but not both"); + const selection = { workspace: args.workspace, ...(typeof args.name === "string" ? { name: args.name } : { version_id: args.version_id as string }) }; + const source = args.kind === "script" ? await service.scriptReadSource(selection) : await service.snapshot(selection); + const payload = { ...source, kind: args.kind, workspace: args.workspace, server_source: source.server_source ?? null }; + return { content: [{ type: "text", text: JSON.stringify(payload) }], structuredContent: payload }; +} + +export async function galleryAction(service: { callTool(name: string, args: Record): Promise }, args: Record): Promise { + if (!(GALLERY_TOOL_NAMES as readonly unknown[]).includes(args.tool)) throw new Error("Unsupported gallery action"); + if (typeof args.workspace !== "string" || !args.workspace.trim()) throw new Error("Select a workspace"); + if (!args.arguments || typeof args.arguments !== "object" || Array.isArray(args.arguments)) throw new Error("Gallery arguments must be an object"); + if (Object.hasOwn(args.arguments, "workspace")) throw new Error("Workspace must be selected in the gallery action envelope"); + try { + return await service.callTool(args.tool as string, { ...args.arguments, workspace: args.workspace }); + } catch (error) { + return toolErrorResult(error); + } +} diff --git a/cloudflare/http.test.ts b/cloudflare/http.test.ts new file mode 100644 index 0000000..717643b --- /dev/null +++ b/cloudflare/http.test.ts @@ -0,0 +1,16 @@ +import { expect, test } from "bun:test"; +import { readToolBody } from "./http"; + +test("gallery import envelopes retain the existing archive request allowance", async () => { + const archive = { large: "x".repeat(1024 * 1024) }; + for (const mcp of [false, true]) { + for (const tool of ["artifact_import", "script_import"]) { + const call = { name: "artifacts_tool", arguments: { workspace: "research", tool, arguments: { new_name: "copy", archive } } }; + const body = mcp ? { jsonrpc: "2.0", method: "tools/call", params: call } : call; + expect(await readToolBody(new Request("https://artifacts.example/", { method: "POST", body: JSON.stringify(body) }), mcp)).toEqual(body); + } + const call = { name: "artifacts_tool", arguments: { workspace: "research", tool: "artifact_write", arguments: { contents: archive.large } } }; + const body = mcp ? { jsonrpc: "2.0", method: "tools/call", params: call } : call; + await expect(readToolBody(new Request("https://artifacts.example/", { method: "POST", body: JSON.stringify(body) }), mcp)).rejects.toThrow("exceeds 1 MiB"); + } +}); diff --git a/cloudflare/http.ts b/cloudflare/http.ts index 775612f..669854d 100644 --- a/cloudflare/http.ts +++ b/cloudflare/http.ts @@ -4,10 +4,12 @@ import { PROJECT_IMPORT_REQUEST_BYTES } from "../src/project-archive-contract"; export async function readToolBody(request: Request, mcp = false): Promise { const text = await readRequestText(request, PROJECT_IMPORT_REQUEST_BYTES); const oversized = new TextEncoder().encode(text).byteLength > 1024 * 1024; - let value: { name?: string; method?: string; params?: { name?: string } } | null; + type ToolEnvelope = { name?: string; arguments?: { tool?: string } }; + let value: (ToolEnvelope & { method?: string; params?: ToolEnvelope }) | null; try { value = JSON.parse(text); } catch (error) { if (oversized) throw new RangeError("Request exceeds 1 MiB"); throw error; } - const name = mcp ? value?.method === "tools/call" ? value.params?.name : undefined : value?.name; + const call = mcp ? value?.method === "tools/call" ? value.params : undefined : value; + const name = call?.name === "artifacts_tool" ? call.arguments?.tool : call?.name; if (oversized && name !== "artifact_import" && name !== "script_import") throw new RangeError("Request exceeds 1 MiB"); return value; } diff --git a/cloudflare/managed-service.ts b/cloudflare/managed-service.ts index c475668..fcfbce4 100644 --- a/cloudflare/managed-service.ts +++ b/cloudflare/managed-service.ts @@ -9,6 +9,8 @@ import type { ArtifactFileRequest } from "../src/sdk/files"; import type { GalleryArtifact, GalleryData } from "../src/gallery/types"; import { CloudArtifactService } from "./service"; import { workspaceTool } from "./workspace-tools"; +import { galleryAction, gallerySource } from "./gallery-tools"; +import { ARTIFACTS_SETTINGS_TOOLS, settingsTool } from "../src/mcp/settings-contract"; import { parseProjectArchive } from "../src/project-archive"; import { ownershipName } from "./ownership"; @@ -17,6 +19,12 @@ type Selection = { workspace?: string; name?: string; version_id?: string; event /** Management requests select an owner; execution continues on immutable storage. */ export class ManagedArtifactService { + get productUrl() { + const url = new URL("/", this.origin); + url.searchParams.set("workspace", this.workspace); + if (this.libraryKey === "team") url.searchParams.set("library", "team"); + return url.href; + } readonly fileStorage; private readonly links; constructor(private readonly env: Env, readonly workspace: string, readonly libraryKey: string, @@ -67,6 +75,9 @@ export class ManagedArtifactService { } async callTool(name: string, args: Record): Promise { + if (ARTIFACTS_SETTINGS_TOOLS.some(tool => tool.name === name)) return settingsTool(this, name, args); + if (name === "artifacts_tool") return galleryAction(this, args); + if (name === "artifacts_source") return gallerySource(this, args); if (["artifacts_library", "artifacts_working", "artifacts_search", "artifacts_mentions"].includes(name)) return workspaceTool(this, name, args); if (name === "artifacts_preview") name = "artifact_open"; if (name.startsWith("app_")) return this.nativeTool(name, args); @@ -81,7 +92,7 @@ export class ManagedArtifactService { const selection = this.selection(kind, { name: args.name as string | undefined, version_id: args.version_id as string | undefined, ...(typeof args.workspace === "string" ? { workspace: args.workspace } : {}) }); if (name.endsWith("_import")) { parseProjectArchive(args.archive, kind); - const target = await this.links.admit(this.selection(kind, { name: args.new_name as string }), true); + const target = await this.links.admit(this.selection(kind, { name: args.new_name as string, ...(typeof args.workspace === "string" ? { workspace: args.workspace } : {}) }), true); return this.callSelectedTool(target, name, args); } const target = name.endsWith("_remix") @@ -177,7 +188,7 @@ export class ManagedArtifactService { ? await this.nativeController().list({ owner: this.libraryKey, workspace: all ? undefined : this.workspace, offset }) : null; hasMore ||= workers?.next_offset != null; - return { capabilities: { scripts: true, links: true, moves: true, nativeApps: !!this.env.NATIVE_APPS, subscriptions: true }, workspace: this.workspace, + return { libraryScope: this.libraryKey === "team" ? "team" : "private", capabilities: { editing: true, scripts: true, links: true, moves: true, nativeApps: !!this.env.NATIVE_APPS, subscriptions: true }, workspace: this.workspace, ...(workers ? { workerApps: workers.apps.map(app => ({ ...app, kind: "worker" as const, key: JSON.stringify(["worker", app.workspace, app.name]) })), nativeAppProviders: workers.providers } : {}), artifacts: [...artifacts.values()].sort((a, b) => a.name.localeCompare(b.name) || a.workspace.localeCompare(b.workspace)), nextOffset: hasMore ? offset + 100 : null }; } diff --git a/cloudflare/mcp.ts b/cloudflare/mcp.ts index e9fb25e..d8a7d40 100644 --- a/cloudflare/mcp.ts +++ b/cloudflare/mcp.ts @@ -5,37 +5,40 @@ import { ListResourceTemplatesRequestSchema, ReadResourceRequestSchema, ErrorCode, McpError, type CallToolResult, } from "@modelcontextprotocol/sdk/types.js"; -import { ARTIFACTS_APP_URI, ARTIFACTS_RESOURCE } from "../src/mcp/app-contract"; +import { isArtifactAppUri, ARTIFACTS_RESOURCE } from "../src/mcp/app-contract"; import shell from "../dist/cloudflare/mcp-app.json"; import * as validators from "../dist/cloudflare/tool-validators.js"; import { artifactFileResult } from "../src/mcp/file-contract"; import { clientSupportsApps, toolsForClient } from "../src/mcp/host-contract"; import { CLOUD_MCP_TOOLS } from "./tool-contract"; import type { CloudArtifactService } from "./service"; +import { parseArtifactResourceUri } from "../src/mcp/workspace-contract"; +import { toolErrorResult } from "../src/mcp/tool-result"; +import { ARTIFACTS_SETTINGS_CAPABILITY } from "../src/mcp/settings-contract"; -export async function handleCloudMcp(request: Request, service: Pick, parsedBody?: unknown): Promise { +export async function handleCloudMcp(request: Request, service: Pick, parsedBody?: unknown): Promise { // Low-level SDK registration lets local and hosted transports share the same // JSON schemas, with argument validators generated from them at build time. - const server = new Server({ name: "artifacts", version: "0.1.0" }, { capabilities: { tools: {}, resources: {} } }); + const server = new Server({ name: "artifacts", version: "0.1.0" }, { capabilities: { tools: {}, resources: {}, + extensions: { "openai/settings": ARTIFACTS_SETTINGS_CAPABILITY }, experimental: { "openai/settings": ARTIFACTS_SETTINGS_CAPABILITY }, + } }); server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: toolsForClient(CLOUD_MCP_TOOLS, server.getClientCapabilities() === undefined || clientSupportsApps(server.getClientCapabilities())) })); const resource = service.fileStorage ? { ...ARTIFACTS_RESOURCE, _meta: { ...ARTIFACTS_RESOURCE._meta, ui: { ...ARTIFACTS_RESOURCE._meta.ui, csp: { ...ARTIFACTS_RESOURCE._meta.ui.csp, connectDomains: [service.fileStorage.origin] }, } } } : ARTIFACTS_RESOURCE; server.setRequestHandler(ListResourcesRequestSchema, async () => ({ resources: [resource] })); - server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => ({ resourceTemplates: [{ uriTemplate: "artifact://project?workspace={workspace}&name={name}", name: "Artifact source", description: "Read an artifact source snapshot in the authenticated library.", mimeType: "application/json" }] })); + server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => ({ resourceTemplates: [{ uriTemplate: "artifact://project?workspace={workspace}&kind={kind}&name={name}", name: "Artifact or script project", description: "Read a complete artifact or script project in the authenticated library.", mimeType: "application/json" }] })); server.setRequestHandler(ReadResourceRequestSchema, async ({ params }) => { if (params.uri.startsWith("artifact://")) { - const uri = new URL(params.uri); - if (uri.hostname !== "project" || uri.pathname || !uri.searchParams.get("workspace") || !uri.searchParams.get("name")) throw new McpError(ErrorCode.InvalidParams, "Invalid artifact resource"); + const { kind, ...selection } = parseArtifactResourceUri(params.uri); // Admission is repeated for every read; a previously issued mention URI is // descriptive context, never an authorization capability. - const selection = { workspace: uri.searchParams.get("workspace")!, name: uri.searchParams.get("name")!, ...(uri.searchParams.has("version_id") ? { version_id: uri.searchParams.get("version_id")! } : {}) }; - const snapshot = await service.snapshot(selection); + const snapshot = kind === "script" ? await service.scriptReadSource(selection) : await service.snapshot(selection); if (snapshot.name !== selection.name) throw new McpError(ErrorCode.InvalidParams, "Artifact version does not match name"); - return { contents: [{ uri: params.uri, mimeType: "application/json", text: JSON.stringify({ name: snapshot.name, workspace: snapshot.workspace, version_id: snapshot.version_id ?? null, revision_token: snapshot.revision_token, description: `React artifact ${snapshot.name} in workspace ${snapshot.workspace}`, source: snapshot.source, server_source: snapshot.server_source, project: snapshot.project }) }] }; + return { contents: [{ uri: params.uri, mimeType: "application/json", text: JSON.stringify({ ...snapshot, kind, workspace: selection.workspace, description: `${kind} ${snapshot.name} in workspace ${selection.workspace}` }) }] }; } - if (params.uri !== ARTIFACTS_APP_URI) throw new McpError(ErrorCode.InvalidParams, "Unknown resource"); - return { contents: [{ ...resource, text: shell }] }; + if (!isArtifactAppUri(params.uri)) throw new McpError(ErrorCode.InvalidParams, "Unknown resource"); + return { contents: [{ ...resource, uri: params.uri, text: shell }] }; }); server.setRequestHandler(CallToolRequestSchema, async ({ params }) => { if (!Object.hasOwn(validators, params.name)) throw new McpError(ErrorCode.InvalidParams, "Unknown tool"); @@ -51,7 +54,7 @@ export async function handleCloudMcp(request: Request, service: Pick): Promise { - if (typeof args.workspace === "string" && args.workspace !== this.workspace && ["artifacts_preview", "artifact_request", "artifact_files", "artifact_read", "artifact_export"].includes(name)) { + if (ARTIFACTS_SETTINGS_TOOLS.some(tool => tool.name === name)) return settingsTool(this, name, args); + if (name === "artifacts_tool") return galleryAction(this, args); + if (name === "artifacts_source") return gallerySource(this, args); + if (typeof args.workspace === "string" && args.workspace !== this.workspace) { const { workspace, ...selectedArgs } = args; return new CloudArtifactService(this.library, workspace, this.backends, this.libraryKey, this.hosted, this.plugins, this.fileStorage).callTool(name, selectedArgs); } @@ -191,8 +197,9 @@ export class CloudArtifactService { pluginCall(request: PluginRequest) { return dispatchPlugin(request, this.plugins); } - snapshot(selection: { name?: string; version_id?: string; event_id?: string }): Promise { - return this.library.preview({ workspace: this.workspace, ...selection }); + snapshot(selection: { workspace?: string; name?: string; version_id?: string; event_id?: string }): Promise { + const { workspace = this.workspace, ...input } = selection; + return this.library.preview({ workspace, ...input }); } async preview(snapshot: Snapshot, compiled?: Compiled) { @@ -348,7 +355,10 @@ export class CloudArtifactService { return { ...text({ ...preview, ...await this.artifacts.linkDetails(target) }), ...(_meta ? { _meta } : {}) }; } - scriptReadSource(selection: { name?: string; version_id?: string }) { + scriptReadSource(selection: { workspace?: string; name?: string; version_id?: string }): ReturnType { + if (selection.workspace && selection.workspace !== this.workspace) { + return new CloudArtifactService(this.library, selection.workspace, this.backends, this.libraryKey, this.hosted, this.plugins, this.fileStorage).scriptReadSource({ name: selection.name, version_id: selection.version_id }); + } return this.scripts.readSource(selection); } @@ -391,7 +401,7 @@ export class CloudArtifactService { Object.assign(artifact, await this.artifacts.linkDetails({ libraryKey: this.libraryKey, workspace: artifact.workspace, name: artifact.name, kind: artifact.kind })); } } - return { ...(this.hosted ? { capabilities: { scripts: true, links: true } } : {}), workspace: this.workspace, artifacts: [...artifacts.values()].sort((a, b) => a.name.localeCompare(b.name) || a.workspace.localeCompare(b.workspace)), nextOffset: scriptsHaveMore || versions.length === 100 || drafts.length === 100 ? offset + 100 : null }; + return { capabilities: { editing: true, ...(this.hosted ? { scripts: true, links: true } : {}) }, workspace: this.workspace, artifacts: [...artifacts.values()].sort((a, b) => a.name.localeCompare(b.name) || a.workspace.localeCompare(b.workspace)), nextOffset: scriptsHaveMore || versions.length === 100 || drafts.length === 100 ? offset + 100 : null }; } } diff --git a/cloudflare/tool-contract.ts b/cloudflare/tool-contract.ts index 470f790..4057ec1 100644 --- a/cloudflare/tool-contract.ts +++ b/cloudflare/tool-contract.ts @@ -1,6 +1,8 @@ import { ARTIFACTS_FILE_TOOL } from "../src/mcp/file-contract"; import { applyToolVisibility } from "../src/mcp/host-contract"; import { ARTIFACTS_WORKSPACE_TOOLS } from "../src/mcp/workspace-contract"; +import { galleryToolDefinition } from "../src/mcp/gallery-contract"; +import { ARTIFACTS_SETTINGS_TOOLS } from "../src/mcp/settings-contract"; import type { Tool } from "@modelcontextprotocol/sdk/types.js"; import { MCP_TOOLS } from "../src/mcp/tools"; import { PROJECT_ARCHIVE_SCHEMA } from "../src/project-archive-contract"; @@ -169,3 +171,5 @@ for (const tool of CLOUD_MCP_TOOLS) { if (tool.name === "artifacts_mentions") tool.inputSchema.properties!.offset = { type: "integer", minimum: 0, description: "Continue the next_offset returned by a previous mention search." }; } applyToolVisibility(CLOUD_MCP_TOOLS); +CLOUD_MCP_TOOLS.push(galleryToolDefinition(CLOUD_MCP_TOOLS)); +CLOUD_MCP_TOOLS.push(...ARTIFACTS_SETTINGS_TOOLS); diff --git a/cloudflare/worker.test.ts b/cloudflare/worker.test.ts index 2e39364..061de57 100644 --- a/cloudflare/worker.test.ts +++ b/cloudflare/worker.test.ts @@ -138,6 +138,11 @@ test("official HTTP MCP client lists contracts, writes, edits, restores and retr version = artifact.versionId; const resource = await client.readResource({ uri: ARTIFACTS_APP_URI }); expect(resource.contents[0]!.mimeType).toContain("text/html"); + for (const uri of ["ui://artifacts/viewer.html", "ui://artifacts/v4/viewer.html", "ui://artifacts/v5/viewer.html"]) { + const legacy = await client.readResource({ uri }); + expect(legacy.contents[0]).toEqual({ ...resource.contents[0], uri }); + } + await expect(client.readResource({ uri: "ui://artifacts/v999/viewer.html" })).rejects.toThrow("Unknown resource"); expect(payload(await client.callTool({ name: "artifact_read", arguments: { name: "overview" } })).source).toBe(source); const failedEdit = await client.callTool({ name: "artifact_edit", arguments: { name: "overview", expected_hash: "0".repeat(64), edits: [{ old_text: "Hosted artifact", new_text: "Wrong" }] } }); expect(failedEdit.isError).toBe(true); @@ -938,7 +943,7 @@ test("MCP workspace tools admit cross-workspace previews and resources while sup const library = await client.callTool({ name: "artifacts_library", arguments: {} }); expect((library._meta as any).workspace.items).toContainEqual(expect.objectContaining({ name: "workspace-preview", workspace: "workspace-tools" })); const working = await client.callTool({ name: "artifacts_working", arguments: {} }); - expect((working._meta as any).workspace.items.some((item: any) => item.workspace === "workspace-tools")).toBe(false); + expect((working._meta as any).workspace.items.some((item: any) => item.workspace === "workspace-tools")).toBe(true); const mentions = await client.callTool({ name: "artifacts_mentions", arguments: { query: "WORKSPACE-PREVIEW" } }); const uri = (mentions.structuredContent as any).items[0].uri; const resource = await client.readResource({ uri }); @@ -952,3 +957,44 @@ test("MCP workspace tools admit cross-workspace previews and resources while sup expect(after.events).toHaveLength(1); } finally { await remote.close(); await team.close(); } }, 60_000); + +test("MCP gallery editor keeps full projects, workspace routing, CAS errors and script resource authorization", async () => { + const workspace = "gallery-editor", name = "gallery-source"; + const contents = source + Array.from({ length: 240 }, (_, index) => `// retained line ${index}\n`).join(""); + const project = { files: { "helper.ts": "export const retained = true;" }, dependencies: {} }; + const action = (tool: string, args: Record) => client.callTool({ name: "artifacts_tool", arguments: { workspace, tool, arguments: args } }); + const written = await action("artifact_write", { name, contents, project, expected_revision: null, preview: false }); + expect(written.isError).not.toBe(true); + const snapshotResult = await client.callTool({ name: "artifacts_source", arguments: { workspace, kind: "artifact", name } }); + const snapshot = snapshotResult.structuredContent as { source: string; revision_token: string }; + expect(snapshotResult.structuredContent).toMatchObject({ workspace, name, source: contents, server_source: null, project }); + expect(snapshot.revision_token).toMatch(/^[a-f0-9]{64}$/); + const invalid = await action("artifact_write", { name, contents: "export default function Artifact() { return ; }", project, expected_revision: snapshot.revision_token, preview: false }); + expect(invalid).toMatchObject({ isError: true, structuredContent: { applied: true, diagnostics: expect.any(Array), revision_token: expect.any(String) } }); + const stale = await action("artifact_write", { name, contents, expected_revision: snapshot.revision_token, preview: false }); + expect(stale).toMatchObject({ isError: true, structuredContent: { status: 409, conflict: true, applied: false } }); + const defaultList = payload(await client.callTool({ name: "artifact_list", arguments: {} })); + expect(defaultList.artifacts.some((item: { id: string }) => item.id === name)).toBe(false); + + const script = 'export default { fetch() { return new Response("ready"); } };\n' + contents.split("\n").slice(-240).join("\n"); + expect((await action("script_write", { name, contents: script, project, expected_revision: null })).isError).not.toBe(true); + const scriptSnapshot = await client.callTool({ name: "artifacts_source", arguments: { workspace, kind: "script", name } }); + expect(scriptSnapshot.structuredContent).toMatchObject({ workspace, name, kind: "script", source: script, server_source: null, project, revision_token: expect.any(String) }); + const library = await client.callTool({ name: "artifacts_working", arguments: {} }); + const gallery = (library._meta as { workspace: { gallery: { artifacts: { kind: string; name: string; workspace: string }[] } } }).workspace.gallery; + expect(gallery.artifacts.filter(item => item.workspace === workspace && item.name === name).map(item => item.kind).sort()).toEqual(["artifact", "script"]); + const mentions = await client.callTool({ name: "artifacts_mentions", arguments: { query: name } }); + const links = (mentions.structuredContent as { items: { uri: string }[] }).items; + const scriptUri = links.find(item => new URL(item.uri).searchParams.get("kind") === "script")!.uri; + const resource = await client.readResource({ uri: scriptUri }); + expect(JSON.parse((resource.contents[0] as { text: string }).text)).toMatchObject({ workspace, name, kind: "script", source: script, project }); + const team = new Client({ name: "gallery-denied", version: "1" }); + await team.connect(new StreamableHTTPClientTransport(new URL(`${origin}/mcp?workspace=${workspace}&library=team`), { fetch })); + try { + await expect(team.readResource({ uri: scriptUri })).rejects.toThrow(); + expect((await team.callTool({ name: "artifacts_source", arguments: { workspace, kind: "script", name } })).isError).toBe(true); + const deniedCatalog = await team.callTool({ name: "artifacts_library", arguments: {} }); + expect((deniedCatalog._meta as any).workspace.gallery.artifacts.some((item: { name: string }) => item.name === name)).toBe(false); + } finally { await team.close(); } + await expect(client.callTool({ name: "artifacts_tool", arguments: { workspace, tool: "artifact_write", arguments: { name } } })).rejects.toThrow("Invalid tool arguments"); +}, 60_000); diff --git a/cloudflare/workspace-tools.test.ts b/cloudflare/workspace-tools.test.ts index 4f5d1c3..fd7d868 100644 --- a/cloudflare/workspace-tools.test.ts +++ b/cloudflare/workspace-tools.test.ts @@ -16,3 +16,17 @@ test("mention search reaches matching archived artifacts after empty catalog pag expect(items[0]?.name).toBe("matching-revenue"); expect(new URL(items[0]!.uri).searchParams.get("version_id")).toBe("saved-version"); }); + +test("working and library views preserve scripts and the full authorized gallery fields", async () => { + const gallery: GalleryData = { workspace: "default", libraryScope: "team", capabilities: { editing: true, scripts: true, links: true }, nextOffset: 100, + artifacts: [{ key: "script-key", name: "refresh", kind: "script", workspace: "research", working: true, versions: [], slug: "refresh-data", access: "private", live: { id: "revision", revision: 2, revision_token: "token" } }] }; + for (const tool of ["artifacts_library", "artifacts_working", "artifacts_search"]) { + const result = await workspaceTool({ workspace: "default", productUrl: "https://artifacts.example/", async gallery(all) { expect(all).toBe(true); return gallery; } }, tool, {}); + expect(result._meta).toMatchObject({ workspace: { items: gallery.artifacts, gallery, productUrl: "https://artifacts.example/", nextOffset: 100 } }); + } + const mentions = await workspaceTool({ workspace: "default", async gallery() { return { ...gallery, nextOffset: null }; } }, "artifacts_mentions", { query: "refresh" }); + const items = mentions.structuredContent!.items as { uri: string; title: string }[]; + expect(new URL(items[0]!.uri).searchParams.get("kind")).toBe("script"); + expect(new URL(items[0]!.uri).searchParams.get("workspace")).toBe("research"); + expect(items[0]!.title).toContain("script"); +}); diff --git a/cloudflare/workspace-tools.ts b/cloudflare/workspace-tools.ts index 9060b8e..eb946c7 100644 --- a/cloudflare/workspace-tools.ts +++ b/cloudflare/workspace-tools.ts @@ -3,12 +3,14 @@ import type { GalleryData } from "../src/gallery/types"; import { artifactResourceUri, workspaceResult } from "../src/mcp/workspace-contract"; /** Offsets refer to catalog pages, including pages with zero matching names. */ -export async function workspaceTool(service: { workspace: string; gallery(all: boolean, offset?: number): Promise }, name: string, args: Record): Promise { +export async function workspaceTool(service: { workspace: string; productUrl?: string; gallery(all: boolean, offset?: number): Promise }, name: string, args: Record): Promise { const view = name === "artifacts_working" || args.view === "working" ? "working" : "library"; const offset = typeof args.offset === "number" ? args.offset : 0; const query = typeof args.query === "string" ? args.query.toLocaleLowerCase() : ""; - const gallery = await service.gallery(view === "library", offset); - const items = gallery.artifacts.filter(item => item.kind !== "script" && item.name.toLocaleLowerCase().includes(query)); + // The working view is a UI selection, not workspace membership. Both surfaces + // browse the same permission-filtered catalog, including scripts and archives. + const gallery = await service.gallery(true, offset); + const items = gallery.artifacts.filter(item => item.name.toLocaleLowerCase().includes(query)); if (name === "artifacts_mentions") { // Composer search has no pagination UI: scan catalog pages until enough // matches exist, while repeating the gallery's permission-filtered reads. @@ -16,10 +18,10 @@ export async function workspaceTool(service: { workspace: string; gallery(all: b const matches = [...items]; while (matches.length < 100 && page.nextOffset != null) { page = await service.gallery(true, page.nextOffset); - matches.push(...page.artifacts.filter(item => item.kind !== "script" && item.name.toLocaleLowerCase().includes(query))); + matches.push(...page.artifacts.filter(item => item.name.toLocaleLowerCase().includes(query))); } - const links = matches.slice(0, 100).map(item => ({ type: "resource_link" as const, uri: artifactResourceUri(item, item.working ? undefined : item.versions[0]?.id), name: item.name, mimeType: "application/json", description: `React artifact in workspace ${item.workspace}` })); + const links = matches.slice(0, 100).map(item => ({ type: "resource_link" as const, uri: artifactResourceUri(item, item.working ? undefined : item.versions[0]?.id), name: item.name, title: `${item.name} · ${item.kind ?? "artifact"} · ${item.workspace}`, mimeType: "application/json", description: `${item.kind === "script" ? "Script" : "React artifact"} project and revision in workspace ${item.workspace}` })); return { content: links, structuredContent: { items: links } }; } - return workspaceResult({ view, workspace: gallery.workspace, items, nextOffset: gallery.nextOffset ?? null }); + return workspaceResult({ view, workspace: gallery.workspace, items, nextOffset: gallery.nextOffset ?? null, gallery: { ...gallery, artifacts: items }, ...(service.productUrl ? { productUrl: service.productUrl } : {}) }); } diff --git a/docs/mcp-plugin.md b/docs/mcp-plugin.md index d81dcb7..6571a62 100644 --- a/docs/mcp-plugin.md +++ b/docs/mcp-plugin.md @@ -30,13 +30,15 @@ The server builds on MCP Apps and advertises optional OpenAI extensions. Actual | Surface | Purpose and boundary | | --- | --- | -| Global sidebar | Open the Artifacts library through an empty-argument entrypoint; list only the connected identity's accessible artifacts. | -| Conversation view | Browse working artifacts in the connected workspace and keep the selected artifact context available in the host view. There is no stored host conversation identifier or separate conversation membership list. | -| Mentions | Search accessible artifacts for composer selection. Mention results do not grant access. | +| Global sidebar | Open the shared Artifacts gallery: folders, search, artifacts and scripts, Preview/Source, saved revisions, Share and Remix. Worker apps open the existing product. The catalog remains permission-filtered. | +| Conversation view | Restore the explicit attached selection or choose an artifact from the library. Browsing does not attach context automatically. There is no stored host conversation identifier or separate conversation membership list. | +| Plugin settings | Native connection settings report the actual workspace and provide an authorized connection check. Endpoint configuration stays with the host; no fake editable preference is stored. | +| Mentions | Search accessible artifacts and scripts with workspace/kind identity; references resolve complete source projects. Mention results do not grant access. | | Deep links | Open `/artifact?workspace=WORKSPACE&name=NAME` or `/artifact?workspace=WORKSPACE&version_id=ID`, with optional `route` for internal navigation. Encode query values. Server authorization still applies; a link never bypasses owner/team checks. | | Model context | Publish selected artifact/revision, route, and filter context without automatically sending a prompt. Prompt actions are deliberate user actions. | | File editor | Open the owned `.artifact.tsx` format through opaque host resources. Writing requires writable capability and an ETag; conflicts require rereading before retrying. | | Display and theme | Use the host's supported display modes, theme, styles, locale, and timezone; hide unavailable controls and tolerate rejected requests. | +| Onboarding | Verify the configured connection without creating examples, then open the existing library or begin the user's own artifact request. | Tool visibility distinguishes model calls from app calls; it does not replace server authorization. Host context and file resource identifiers are untrusted input. UI selection, attachments, and cached state cannot expand library, owner, or team access. @@ -46,6 +48,10 @@ The author skill reads `artifact_guide`, guards source edits using `source_hash` Setup verifies connection using tool discovery, `artifact_guide`, and `artifact_list`. It does not create example artifacts or alter the user's library. Host file editing requires its own advertised resource and write capabilities; it is separate from hosted artifact file storage described in [files](files.md). -Rich OpenAI forms and migration to multi-round-trip requests (MRTR) are deliberately deferred. Packaging this plugin does not claim support for those protocol extensions or for every surface in every host. +Hosted project edits use complete snapshots, revision tokens and the existing service authorization. Stale saves preserve the draft for comparison/reload. Legacy local filesystem connections show source read-only; host-managed file editing remains a separate ETag-protected surface. Authored previews run in isolated, sandboxed inline frames so their CSS and React root cannot replace the gallery. + +The embedded gallery uses the host's theme variables and transparent outer chrome, a collapsible library, and one toolbar for the selected artifact. Revision selection stays visible; the actions menu contains conversation context, refresh, sharing, remix, and browser destinations. Previews own their spacing. Moving focus between the preview and gallery does not reload the artifact. + +Native rich forms require a transport change: the hosted MCP endpoint uses stateless JSON replies, and local stdio has no server-request response router. Neither currently retains the callback correlation needed for OpenAI elicitation. Remix therefore uses the real product form. Migration to bidirectional elicitation or MRTR is not claimed by this package. The portable manifest and connection shapes follow the [plugin schema](https://agent-plugins.org/schemas/1.0.0/plugin.schema.json) and [MCP schema](https://agent-plugins.org/schemas/1.0.0/mcp.schema.json). Optional host integration follows the [OpenAI MCP Extensions specification](https://github.com/openai/mcp-extensions/blob/main/docs/spec.md); the installed SDK and capability negotiation determine the implemented contract. diff --git a/e2e/gallery-drafts.test.ts b/e2e/gallery-drafts.test.ts index 60b739f..cb417a8 100644 --- a/e2e/gallery-drafts.test.ts +++ b/e2e/gallery-drafts.test.ts @@ -83,8 +83,8 @@ test("gallery retains complete unsaved projects and new scripts and offers confl await page.getByRole("button", { name: "Compare saved project" }).click(); await page.getByText("Saved project (your edits remain in the editor)").waitFor(); expect(await client.innerText()).toBe("unsaved client"); - page.once("dialog", dialog => void dialog.accept()); await page.getByRole("button", { name: "Reload saved project" }).click(); + await page.getByRole("button", { name: "Discard edits and reload" }).click(); await page.waitForFunction(() => document.querySelector('[role="textbox"][aria-label="report.artifact.tsx"]')?.textContent === "agent client"); await client.fill("merged client"); await page.getByRole("button", { name: "Save and deploy", exact: true }).click(); diff --git a/e2e/gallery-live.test.ts b/e2e/gallery-live.test.ts index 9a57c39..9501998 100644 --- a/e2e/gallery-live.test.ts +++ b/e2e/gallery-live.test.ts @@ -20,7 +20,8 @@ test("local gallery subscribes to external file changes without refreshing unrel await preview.getByRole("heading", { name: "First version" }).waitFor(); expect(await page.getByRole("button", { name: "Refresh", exact: true }).count()).toBe(0); expect(await page.getByPlaceholder("Search", { exact: true }).isVisible()).toBe(true); - expect(await page.locator(".library-heading").count()).toBe(0); + expect(await page.getByRole("button", { name: "Create or import", exact: true }).isVisible()).toBe(true); + expect(await page.getByRole("complementary").getByText("Artifacts", { exact: true }).count()).toBe(0); expect(await page.locator(".scope-control").count()).toBe(0); expect(await page.getByText("In this project", { exact: true }).count()).toBe(0); expect(await page.getByRole("img", { name: "Sidequery" }).count()).toBe(0); diff --git a/e2e/mcp-app-host.ts b/e2e/mcp-app-host.ts index 7f0e9f8..a3b3845 100644 --- a/e2e/mcp-app-host.ts +++ b/e2e/mcp-app-host.ts @@ -27,8 +27,10 @@ declare global { setTheme(theme: "dark" | "light"): void; configure(update: Partial): void; setInlineFrameLimit(limit?: number): void; + setFrameHeight(height: number): void; setRequestBehavior(behavior: RequestBehavior): void; setServerToolResult(result: CallToolResult): void; + setServerToolResults(results: Record): void; context(): McpUiHostContext; }; } @@ -49,6 +51,7 @@ let serverToolResult: CallToolResult = window.initialServerToolResult ?? { conte response: { status: 200, statusText: "OK", headers: [] }, } }; let requestBehavior: RequestBehavior = "accept"; +let serverToolResults: Record = {}; let lastRequestedHeight = 1; let inlineFrameLimit: number | undefined; let hostContext: McpUiHostContext = { @@ -116,12 +119,18 @@ window.mcpHost = { inlineFrameLimit = limit; applyFrameSize(); }, + setFrameHeight(height) { + // A native host can reparent the same view after its mode notification and + // restore a previously measured frame size without changing the context. + iframe.style.height = `${height}px`; + }, setRequestBehavior(behavior) { requestBehavior = behavior; }, setServerToolResult(result) { serverToolResult = result; }, + setServerToolResults(results) { serverToolResults = results; }, context() { return structuredClone(hostContext); }, @@ -144,6 +153,8 @@ bridge.onrequestdisplaymode = async ({ mode }) => { }; bridge.oncalltool = async params => { serverToolCalls.push(params); + const queued = serverToolResults[params.name]; + if (queued?.length) return queued.length > 1 ? queued.shift()! : queued[0]!; return serverToolResult; }; bridge.onupdatemodelcontext = async params => { @@ -171,7 +182,7 @@ await bridge.connect(new PostMessageTransport(iframe.contentWindow!, iframe.cont if (proxy) { bridge.onsandboxready = async () => { window.mcpHost!.sandboxReady = true; - await bridge.sendSandboxResourceReady({ html: window.artifactAppHtml, sandbox: "allow-scripts" }); + await bridge.sendSandboxResourceReady({ html: window.artifactAppHtml, sandbox: "allow-scripts allow-forms" }); }; iframe.src = "/sandbox-proxy"; } else iframe.srcdoc = window.artifactAppHtml; diff --git a/e2e/mcp-app.test.ts b/e2e/mcp-app.test.ts index 20b1276..b40e87c 100644 --- a/e2e/mcp-app.test.ts +++ b/e2e/mcp-app.test.ts @@ -1,5 +1,5 @@ import { afterAll, afterEach, beforeAll, expect, test } from "bun:test"; -import { readFileSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { chromium, type Browser, type FrameLocator, type Page } from "playwright"; @@ -19,6 +19,7 @@ export default function Interactive() { Count: {count} Theme: {theme.kind} +
{ event.preventDefault(); setCount(value => value + 1); }}>
@@ -76,13 +77,15 @@ const fixtureDirs = new Set(); const transferRequests: Array<{ method: string; body: string }> = []; const TRANSFER_FILE = { id: "one", name: "report.txt", size: 5, type: "text/plain", uploaded: "2026-10-03T00:00:00Z" }; // A web host's outer sandbox relays JSON-RPC and loads content only after the -// SDK resource-ready handshake. The app stays in its own opaque inner frame. -const SANDBOX_PROXY_HTML = ``; + hostHtml = ``; server = Bun.serve({ hostname: "127.0.0.1", port: 0, @@ -355,6 +358,48 @@ test("applies natural, capped, shrinking, narrow, and fixed inline sizes without await page.close(); }, 30_000); +test("returning from a native panel reasserts a bounded inline height after reparenting", async () => { + const source = `import { Button, useArtifactState } from "sidequery/artifacts"; +export default function ViewportArtifact() { + const [count, setCount] = useArtifactState("count", 0); + return ( +
+

Viewport artifact

+ +
+

Last content

+
+ ); +}`; + const meta = await resultFor(source, "viewport-artifact"); + const { page, app, errors } = await openHost(); + await page.evaluate(meta => window.mcpHost!.sendResult({ content: [], _meta: meta }), meta); + await app.getByRole("button", { name: "Count: 0", exact: true }).click(); + await app.getByRole("button", { name: "Expand artifact" }).click(); + await app.locator('html[data-display-mode="fullscreen"]').waitFor(); + // Codex has no maxHeight constraint for a normal inline card. Closing its tab + // changes the mode independently of the native view's subsequent resize. + await page.evaluate(() => window.mcpHost!.configure({ displayMode: "inline", containerDimensions: { maxWidth: 640 } })); + await app.locator('html[data-display-mode="inline"]').waitFor(); + await page.waitForFunction(() => document.querySelector("iframe")!.getBoundingClientRect().height === 600); + await page.waitForTimeout(150); + await page.evaluate(() => window.mcpHost!.setFrameHeight(6000)); + await page.waitForFunction(() => document.querySelector("iframe")!.getBoundingClientRect().height === 600); + await app.getByRole("button", { name: "Count: 1", exact: true }).waitFor(); + expect((await layout(app)).shellHeight).toBe(600); + const last = app.getByText("Last content", { exact: true }); + await last.scrollIntoViewIfNeeded(); + const lastBox = (await last.boundingBox())!; + const viewportBox = (await app.locator("#artifact-viewport").boundingBox())!; + expect(lastBox.y + lastBox.height).toBeLessThanOrEqual(viewportBox.y + viewportBox.height + 1); + await page.waitForTimeout(150); + const count = await page.evaluate(() => window.mcpHost!.sizeChanges.length); + await page.waitForTimeout(150); + expect(await page.evaluate(() => window.mcpHost!.sizeChanges.length)).toBe(count); + expect(await page.evaluate(() => window.mcpHost!.sizeChanges.every(change => change.height! <= 600))).toBe(true); + expect(errors).toEqual([]); +}, 30_000); + test("negotiates fullscreen while preserving state and survives external, declined, and failed changes", async () => { const meta = await resultFor(INTERACTIVE_ARTIFACT, "modes", { count: 4 }); const { page, app, errors } = await openHost(); @@ -628,10 +673,15 @@ export default function ContextArtifact() { return <>

Context artifact

Attached: {String(context.attached)}

Selected: {String(context.context?.selection?.row ?? "none")}

Locale: {environment.locale ?? "unset"}

Timezone: {environment.timeZone ?? "unset"}

Capabilities: {JSON.stringify(capabilities.actions)}

Model context: {String(capabilities.modelContext)}

Backend capabilities: {String(capabilities.server)}/{String(capabilities.files)}/{String(capabilities.plugins)}

; }`; +function workspaceFor(artifact: ArtifactAppPayload, view: "library" | "working" = "library") { + return { view, workspace: artifact.workspace ?? "default", nextOffset: null, + items: [{ name: artifact.name, workspace: artifact.workspace ?? "default", working: true, versions: [{ id: artifact.versionId, revision: 1, createdAt: "2026-10-03T00:00:00Z" }] }] }; +} + test("OpenAI model context reconciles user removal and remount without sending a prompt", async () => { const meta = await resultFor(CONTEXT_ARTIFACT, "context"); meta.artifact.workspace = "analysis"; - const { page, app, errors } = await openHost({ openai: true }); + const { page, app, errors, logs } = await openHost({ openai: true }); await page.evaluate(meta => window.mcpHost!.sendResult({ content: [], _meta: meta }), meta); await app.getByText("Attached: false", { exact: true }).waitFor(); await app.getByRole("button", { name: "Attach selection" }).click(); @@ -643,18 +693,23 @@ test("OpenAI model context reconciles user removal and remount without sending a expect(JSON.stringify(updates[0])).toContain('"row":42'); expect(JSON.stringify(updates[0])).toContain('"workspace":"analysis"'); expect(await page.evaluate(() => window.mcpHost!.messages)).toEqual([]); - await page.evaluate(meta => window.mcpHost!.setServerToolResult({ content: [], _meta: meta }), meta); - await page.evaluate(() => window.mcpHost!.sendResult({ content: [], _meta: { workspace: { view: "working", workspace: "analysis", items: [], nextOffset: null } } })); - await app.getByText("Attached: true", { exact: true }).waitFor(); + const workspace = workspaceFor(meta.artifact, "working"); + await page.evaluate(({ meta, workspace }) => window.mcpHost!.setServerToolResults({ artifacts_preview: [{ content: [], _meta: meta }], artifacts_search: [{ content: [], _meta: { workspace } }] }), { meta, workspace }); + await page.evaluate(workspace => window.mcpHost!.sendResult({ content: [], _meta: { workspace } }), workspace); + const preview = app.frameLocator(".preview-frame"); + await preview.getByText("Attached: true", { exact: true }).waitFor().catch(async error => { + const frames = await Promise.all(page.frames().map(async frame => ({ url: frame.url(), text: await frame.locator("body").innerText().catch(() => "") }))); + throw new Error(`${error}: ${JSON.stringify({ errors, frames })}`); + }); expect(await page.evaluate(() => window.mcpHost!.modelContexts.length)).toBe(1); await page.evaluate(() => window.mcpHost!.removeModelContext()); - await app.getByText("Attached: false", { exact: true }).waitFor(); + await preview.getByText("Attached: false", { exact: true }).waitFor(); await page.evaluate(meta => window.mcpHost!.sendResult({ content: [], _meta: meta }), meta); - await app.getByText("Attached: false", { exact: true }).waitFor(); + await preview.getByText("Attached: false", { exact: true }).waitFor(); expect(await page.evaluate(() => window.mcpHost!.modelContexts.length)).toBe(1); await page.evaluate(() => window.mcpHost!.configure({ locale: "fr-FR", timeZone: "Europe/Paris", styles: { variables: { "--color-text-primary": "rgb(10, 20, 30)" } } })); - await app.getByText("Locale: fr-FR", { exact: true }).waitFor(); - await app.getByText("Timezone: Europe/Paris", { exact: true }).waitFor(); + await preview.getByText("Locale: fr-FR", { exact: true }).waitFor(); + await preview.getByText("Timezone: Europe/Paris", { exact: true }).waitFor(); expect(await app.locator("html").getAttribute("lang")).toBe("fr-FR"); expect(await app.locator("html").evaluate(element => element.style.getPropertyValue("--color-text-primary"))).toBe("rgb(10, 20, 30)"); expect(errors).toEqual([]); @@ -674,30 +729,41 @@ test("missing host capabilities leave ordinary rendering usable and disable cont expect(errors).toEqual([]); }, 30_000); -test("workspace entrypoints search, paginate an empty match page, and open an authenticated revision", async () => { +test("workspace keeps search and pinned revisions while its sandboxed preview remains interactive", async () => { const meta = await resultFor(INTERACTIVE_ARTIFACT, "chosen"); meta.artifact.workspace = "research"; - const { page, app, errors, logs } = await openHost(); - const workspace = { view: "library" as const, workspace: "conversation", items: [], nextOffset: 100 }; - await page.evaluate(workspace => window.mcpHost!.sendResult({ content: [], _meta: { workspace: { ...workspace, view: "working" } } }), workspace); - await app.getByRole("heading", { name: "Working artifacts", exact: true }).waitFor(); - await page.evaluate(workspace => window.mcpHost!.sendResult({ content: [], _meta: { workspace } }), workspace); - await app.getByRole("heading", { name: "Artifacts", exact: true }).waitFor(); - await app.getByLabel("Search artifacts").fill("chosen"); - await page.evaluate(workspace => window.mcpHost!.setServerToolResult({ content: [], _meta: { workspace } }), workspace); - await app.getByRole("button", { name: "Search", exact: true }).click(); - await page.waitForFunction(() => window.mcpHost!.serverToolCalls.length === 1).catch(error => { throw new Error(`${error}: ${JSON.stringify(logs)}`); }); - expect(await page.evaluate(() => window.mcpHost!.serverToolCalls[0])).toMatchObject({ name: "artifacts_search", arguments: { query: "chosen", view: "library", offset: 0 } }); - const next = { ...workspace, nextOffset: null, items: [{ name: "chosen", workspace: "research", working: true, versions: [{ id: meta.artifact.versionId, revision: 1, createdAt: "2026-10-03T00:00:00Z" }] }] }; - await page.evaluate(workspace => window.mcpHost!.setServerToolResult({ content: [], _meta: { workspace } }), next); - await app.getByRole("button", { name: "Load more" }).click(); - await app.getByRole("button", { name: "Open chosen" }).waitFor(); - expect(await page.evaluate(() => window.mcpHost!.serverToolCalls[1])).toMatchObject({ name: "artifacts_search", arguments: { offset: 100 } }); + const { page, app, errors } = await openHost({ proxy: true }); + const workspace = workspaceFor(meta.artifact); + const empty = { ...workspace, items: [], nextOffset: 100 }; + await page.evaluate(({ meta, workspace, empty }) => { + window.mcpHost!.configure({ displayMode: "fullscreen" }); + window.mcpHost!.setServerToolResults({ + artifacts_preview: [{ content: [], _meta: meta }], + artifacts_search: [{ content: [], _meta: { workspace: empty } }, { content: [], _meta: { workspace } }], + }); + }, { meta, workspace, empty }); + await page.evaluate(empty => window.mcpHost!.sendResult({ content: [], _meta: { workspace: { ...empty, view: "working", nextOffset: null } } }), empty); + await app.getByRole("heading", { name: "Choose an artifact for this conversation" }).waitFor(); + expect(await app.getByRole("button", { name: "chosen", exact: true }).count()).toBe(0); + await app.getByRole("button", { name: "Show library" }).click(); + await app.getByRole("searchbox").fill("chosen"); + await app.getByRole("button", { name: "chosen", exact: true }).click(); + const preview = app.frameLocator(".preview-frame"); + await preview.getByRole("heading", { name: "Interactive artifact" }).waitFor(); + await preview.getByRole("button", { name: "Increment" }).click(); + await preview.getByText("Count: 1", { exact: true }).waitFor(); + await preview.getByRole("button", { name: "Submit count" }).click(); + await preview.getByText("Count: 2", { exact: true }).waitFor(); await app.getByLabel("Revision of chosen").selectOption(meta.artifact.versionId); - await page.evaluate(meta => window.mcpHost!.setServerToolResult({ content: [], _meta: meta }), meta); - await app.getByRole("button", { name: "Open chosen" }).click(); - await app.getByRole("heading", { name: "Interactive artifact" }).waitFor(); - expect(await page.evaluate(() => window.mcpHost!.serverToolCalls[2])).toMatchObject({ name: "artifacts_preview", arguments: { workspace: "research", version_id: meta.artifact.versionId } }); + await preview.getByRole("heading", { name: "Interactive artifact" }).waitFor(); + await app.getByRole("button", { name: "More actions" }).click(); + await app.getByRole("menuitem", { name: "Refresh", exact: true }).click(); + await page.waitForFunction(version => window.mcpHost!.serverToolCalls.filter(call => call.name === "artifacts_preview" && call.arguments?.version_id === version).length >= 2, meta.artifact.versionId); + const calls = await page.evaluate(() => window.mcpHost!.serverToolCalls); + expect(calls.some(call => call.name === "artifacts_search" && call.arguments?.offset === 100)).toBe(true); + await page.evaluate(() => window.mcpHost!.configure({ displayMode: "inline", containerDimensions: { width: 420, height: 600 } })); + await app.getByRole("button", { name: "Back to library" }).click(); + expect(await app.getByRole("searchbox").inputValue()).toBe("chosen"); expect(errors).toEqual([]); }, 30_000); @@ -715,6 +781,83 @@ test("OpenAI deep links select a revision through the server before entering its expect(errors).toEqual([]); }, 30_000); +test("MCP gallery edits complete projects and retains drafts across navigation and conflicts", async () => { + const meta = await resultFor(INTERACTIVE_ARTIFACT, "report"); + meta.artifact.workspace = "research"; + const workspace = workspaceFor(meta.artifact); + const project = { files: { "lib/value.ts": "export const value = 1;" }, dependencies: {}, lock: {} }; + const source = { source: INTERACTIVE_ARTIFACT, server_source: "export default {};", project, revision_token: "a".repeat(64) }; + const gallery = { + workspace: "research", nextOffset: null, capabilities: { editing: true, scripts: true, links: true }, + artifacts: [ + { ...workspace.items[0]!, key: "report", draftRevision: source.revision_token, versions: workspace.items[0]!.versions.map(version => ({ ...version, reason: "edit", serveCount: 0 })) }, + { key: "handler", kind: "script", name: "handler", workspace: "research", working: true, versions: [] }, + ], + }; + const payload = { ...workspace, gallery }; + const { page, app, errors, logs } = await openHost({ openai: true, proxy: true }); + await page.evaluate(({ meta, payload, source }) => { + window.mcpHost!.configure({ displayMode: "fullscreen" }); + window.mcpHost!.setServerToolResults({ + artifacts_search: [{ content: [], _meta: { workspace: payload } }], + artifacts_preview: [{ content: [], _meta: meta }], + artifacts_source: [{ content: [], structuredContent: source }], + artifacts_tool: [{ content: [], isError: true, structuredContent: { error: "Project changed since it was loaded.", status: 409, applied: false } }], + }); + }, { meta, payload, source }); + await page.evaluate(workspace => window.mcpHost!.sendResult({ content: [], _meta: { workspace } }), payload); + await app.getByRole("tab", { name: "Source", exact: true }).click(); + const client = app.getByRole("textbox", { name: "report.artifact.tsx", exact: true }); + await client.fill("unsaved client"); + await app.getByRole("button", { name: "report.artifact.server.ts", exact: true }).click(); + await app.getByRole("textbox", { name: "report.artifact.server.ts", exact: true }).fill("unsaved server"); + await app.getByRole("combobox", { name: "Helper file" }).selectOption("file:lib/value.ts"); + await app.getByRole("textbox", { name: "lib/value.ts", exact: true }).fill("unsaved helper"); + await app.getByTitle("research/handler", { exact: true }).click(); + await app.getByRole("textbox", { name: "script.ts", exact: true }).fill("unsaved script"); + await app.getByTitle("research/report", { exact: true }).click(); + await app.getByRole("tab", { name: "Source", exact: true }).click(); + expect(await client.innerText()).toBe("unsaved client"); + await app.getByRole("button", { name: "Save and deploy", exact: true }).click(); + await app.getByRole("button", { name: "Compare saved project" }).waitFor().catch(async error => { + throw new Error(`${error}: ${JSON.stringify({ text: await app.locator("body").innerText(), calls: await page.evaluate(() => window.mcpHost!.serverToolCalls), errors, logs })}`); + }); + const calls = await page.evaluate(() => window.mcpHost!.serverToolCalls); + const save = calls.find(call => call.name === "artifacts_tool"); + expect(save?.arguments).toEqual({ workspace: "research", tool: "artifact_write", arguments: { + name: "report", contents: "unsaved client", server: "unsaved server", + project: { files: { "lib/value.ts": "unsaved helper" }, dependencies: {} }, expected_revision: source.revision_token, + } }); + await app.getByRole("button", { name: "Compare saved project" }).click(); + await app.getByText("Saved project (your edits remain in the editor)").waitFor(); + expect(await client.innerText()).toBe("unsaved client"); + await app.getByRole("button", { name: "Reload saved project" }).click(); + await app.getByRole("button", { name: "Keep editing", exact: true }).click(); + expect(await client.innerText()).toBe("unsaved client"); + await app.getByRole("button", { name: "Reload saved project" }).click(); + await app.getByRole("button", { name: "Discard edits and reload" }).click(); + await client.filter({ hasText: "Interactive artifact" }).waitFor(); + await app.getByLabel("Revision of report").selectOption(meta.artifact.versionId); + await app.getByRole("button", { name: "Restore and deploy", exact: true }).waitFor(); + await client.and(app.locator('[aria-readonly="true"]')).waitFor(); + const historicalSource = await client.innerText(); + await client.focus(); + await page.keyboard.insertText("This must not change historical source"); + expect(await client.innerText()).toBe(historicalSource); + expect(await app.locator(".live-data-note").innerText()).toContain("Historical code uses the current database"); + const screenshots = process.env.ARTIFACTS_SCREENSHOT_DIR; + if (screenshots) { + mkdirSync(screenshots, { recursive: true }); + await page.screenshot({ path: join(screenshots, "artifacts-source-dark.png") }); + await page.evaluate(() => { + window.mcpHost!.setTheme("light"); + window.mcpHost!.configure({ displayMode: "inline", containerDimensions: { width: 420, height: 700 } }); + }); + await page.screenshot({ path: join(screenshots, "artifacts-source-narrow.png") }); + } + expect(errors).toEqual([]); +}, 30_000); + test("web sandbox proxy blocks unlisted network origins and permits granted artifact file transfers", async () => { const source = `import { Button, artifactFiles, useState } from "sidequery/artifacts"; export default function Transfer() { const [result,setResult]=useState("idle"); return <>

{result}

; }`; @@ -748,30 +891,52 @@ export default function RefreshArtifact() { const meta = await resultFor(source, "refresh"); meta.artifact.workspace = "research"; const { page, app, errors } = await openHost({ openai: true }); - const workspace = { view: "library", workspace: "conversation", nextOffset: null, items: [{ name: "refresh", workspace: "research", working: true, versions: [] }] }; + const workspace = workspaceFor(meta.artifact); + await page.evaluate(({meta, workspace}) => { + window.mcpHost!.configure({displayMode:"fullscreen"}); + window.mcpHost!.setServerToolResults({artifacts_preview:[{content:[],_meta:meta}],artifacts_search:[{content:[],_meta:{workspace}}]}); + }, {meta,workspace}); + const preview = app.frameLocator(".preview-frame"); await page.evaluate(workspace => window.mcpHost!.sendResult({ content: [], _meta: { workspace } }), workspace); - await page.evaluate(meta => window.mcpHost!.setServerToolResult({ content: [], _meta: meta }), meta); - await app.getByRole("button", { name: "Open refresh", exact: true }).click(); - await app.getByRole("button", { name: "Increment", exact: true }).click(); - await app.getByRole("link", { name: "Details", exact: true }).click(); - await app.getByRole("button", { name: "Attach row", exact: true }).click(); - await app.getByText("Selected: 42", { exact: true }).waitFor(); - await app.getByText("Route: /details", { exact: true }).waitFor(); - await app.getByText("Count: 1", { exact: true }).waitFor(); - const previousHeading = await app.getByRole("heading", { name: "Refresh artifact", exact: true }).elementHandle(); - await app.getByRole("button", { name: "Refresh artifact", exact: true }).click(); - await previousHeading!.waitForElementState("hidden"); - await page.waitForFunction(() => window.mcpHost!.serverToolCalls.length === 2); - await app.getByText("Selected: 42", { exact: true }).waitFor(); - await app.getByText("Route: /details", { exact: true }).waitFor(); - await app.getByText("Count: 1", { exact: true }).waitFor(); + await preview.getByRole("heading", { name: "Refresh artifact", exact: true }).waitFor(); + await preview.getByRole("button", { name: "Increment", exact: true }).click(); + await preview.getByRole("link", { name: "Details", exact: true }).click(); + await preview.getByRole("button", { name: "Attach row", exact: true }).click(); + await preview.getByText("Selected: 42", { exact: true }).waitFor(); + await preview.getByText("Route: /details", { exact: true }).waitFor(); + await preview.getByText("Count: 1", { exact: true }).waitFor(); + const previewsBeforeToggle = await page.evaluate(() => window.mcpHost!.serverToolCalls.filter(call => call.name === "artifacts_preview").length); + await app.getByRole("button", { name: "Hide library" }).click(); + await app.getByRole("searchbox").waitFor({ state: "hidden" }); + await preview.getByText("Count: 1", { exact: true }).waitFor(); + await app.getByRole("button", { name: "Show library" }).click(); + await app.getByRole("searchbox").waitFor(); + await preview.getByText("Count: 1", { exact: true }).waitFor(); + expect(await page.evaluate(() => window.mcpHost!.serverToolCalls.filter(call => call.name === "artifacts_preview").length)).toBe(previewsBeforeToggle); + await page.evaluate(() => window.mcpHost!.configure({ styles: { variables: { "--color-text-primary": "rgb(220, 225, 230)", "--color-background-primary": "rgb(35, 36, 37)" } } })); + const chrome = await app.locator(".detail-header").evaluate(element => ({ + height: element.getBoundingClientRect().height, + color: getComputedStyle(element).color, + bodyBackground: getComputedStyle(document.body).backgroundColor, + panelBorder: getComputedStyle(document.querySelector(".artifact-detail")!).borderTopWidth, + })); + expect(chrome.height).toBeLessThanOrEqual(48); + expect(chrome.color).toBe("rgb(220, 225, 230)"); + expect(chrome.bodyBackground).toBe("rgba(0, 0, 0, 0)"); + expect(chrome.panelBorder).toBe("0px"); + await app.getByRole("button", { name: "More actions" }).click(); + await app.getByRole("menuitem", { name: "Refresh", exact: true }).click(); + await page.waitForFunction(() => window.mcpHost!.serverToolCalls.filter(call => call.name === "artifacts_preview").length >= 2); + await preview.getByText("Selected: 42", { exact: true }).waitFor(); + await preview.getByText("Route: /details", { exact: true }).waitFor(); + await preview.getByText("Count: 1", { exact: true }).waitFor(); await page.evaluate(() => window.mcpHost!.sendResult({ content: [{ type: "text", text: "Saved without preview" }], structuredContent: { applied: true, ok: true, revision: 2 } })); - await app.getByText("Count: 1", { exact: true }).waitFor(); + await preview.getByText("Count: 1", { exact: true }).waitFor(); await page.evaluate(() => window.mcpHost!.sendResult({ content: [{ type: "text", text: "Type error in new draft" }], isError: true })); await app.getByRole("status").filter({ hasText: "Type error in new draft" }).waitFor(); - await app.getByText("Selected: 42", { exact: true }).waitFor(); - await app.getByText("Route: /details", { exact: true }).waitFor(); - await app.getByText("Count: 1", { exact: true }).waitFor(); + await preview.getByText("Selected: 42", { exact: true }).waitFor(); + await preview.getByText("Route: /details", { exact: true }).waitFor(); + await preview.getByText("Count: 1", { exact: true }).waitFor(); expect(await page.evaluate(() => window.mcpHost!.modelContexts.length)).toBe(1); expect(errors).toEqual([]); }, 30_000); diff --git a/e2e/mcp-file-editor.test.ts b/e2e/mcp-file-editor.test.ts index 42d7b3b..4285d66 100644 --- a/e2e/mcp-file-editor.test.ts +++ b/e2e/mcp-file-editor.test.ts @@ -60,7 +60,7 @@ test("owned file editor saves inside an allow-scripts sandbox and preserves conf expect(await editor.innerText()).toContain("my draft"); expect(await save.isDisabled()).toBe(true); await frame.getByRole("button", { name: "Discard draft and reload", exact: true }).click(); - await frame.getByText("No unsaved changes", { exact: true }).waitFor(); + await frame.getByText("Saved", { exact: true }).waitFor(); expect(await editor.innerText()).toContain("keyboard"); await sandbox.evaluate(() => (window as any).fileEditor.dispose()); expect(await frame.getByRole("textbox").count()).toBe(0); diff --git a/package.json b/package.json index 8e58900..f422c9a 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,10 @@ "src/gallery/remix.tsx", "src/gallery/server.ts", "src/gallery/types.ts", + "src/gallery/styles.ts", + "src/gallery/library-navigation.tsx", + "src/gallery/workspace.tsx", + "src/gallery/transport.tsx", "src/herdr.ts", "src/history.ts", "src/historyTypes.ts", @@ -82,6 +86,7 @@ "src/runtime/gallery-request.ts", "src/runtime/artifacts-entry.d.ts", "src/runtime/mcp-app.ts", + "src/runtime/mcp-preview.ts", "src/runtime/navigation-host.ts", "src/runtime/wrapper.tsx", "src/sandbox.ts", @@ -127,6 +132,9 @@ "plugins/artifacts/**", "src/mcp/host-contract.ts", "src/mcp/workspace-contract.ts", + "src/mcp/gallery-contract.ts", + "src/mcp/settings-contract.ts", + "src/mcp/tool-result.ts", "src/mcp/file-contract.ts", "src/mcp/local-workspace.ts", "src/runtime/mcp-host.ts", diff --git a/plugins/artifacts/plugin.json b/plugins/artifacts/plugin.json index caad7f7..9d8e844 100644 --- a/plugins/artifacts/plugin.json +++ b/plugins/artifacts/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "artifacts", - "version": "0.1.0", + "version": "0.1.5", "description": "Create, edit, and explore React artifacts in your configured Artifacts workspace.", "author": { "name": "Sidequery" }, "repository": "https://github.com/sidequery/artifacts", diff --git a/plugins/artifacts/skills/setup/SKILL.md b/plugins/artifacts/skills/setup/SKILL.md index 4a9f817..aff3427 100644 --- a/plugins/artifacts/skills/setup/SKILL.md +++ b/plugins/artifacts/skills/setup/SKILL.md @@ -9,6 +9,8 @@ For local stdio, Bun must be available to the host. An explicit `--dir` names th For remote MCP, use the endpoint of the user's existing Artifacts deployment. Require HTTPS except for an explicitly selected loopback HTTP development server. Never invent a public service URL or embed tokens in a manifest, URL, or skill. Complete authentication through normal host tooling and verify the current owner/team context. If authentication is unavailable, report the exact connection problem; do not hunt for credentials. -Verify by discovering the advertised tools and calling `artifact_guide` and `artifact_list` with their current schemas. Do not write, remix, restore, or create a demonstration artifact as a connection check. If tools are unavailable, inspect the host's connection error and configured transport before changing anything. +Verify by discovering the advertised tools and calling `artifact_guide` and `artifact_list` with their current schemas. When available, `artifacts_connection_check` reports the actual workspace and gallery capabilities without running artifact code. Do not write, remix, restore, or create a demonstration artifact as a connection check. If tools are unavailable, inspect the host's connection error and configured transport before changing anything. Report whether the connection succeeded, which workspace or deployment it uses, and which host surfaces are actually available. MCP Apps, sidebar and conversation views, mentions, deep links, and file editing depend on advertised capabilities and client support. Offer the author skill for the user's next artifact request; setup itself is complete after read-only verification. + +For an already connected host, lead the user to the Artifacts library or its conversation panel. The library shares the product's folder navigation, Preview, Source, and revision history; the conversation panel starts empty until an artifact is chosen or an attached selection is restored. Choosing a library item does not attach it automatically: Use in conversation adds its explicit context. Source edits use the connected service's capabilities and revision checks. Host file Save writes the opened file only; Save and deploy in a hosted project updates that project. Native connection settings provide a read-only connection check; endpoint configuration remains with the host. Remix uses the existing product form because these server transports do not yet implement native rich-form callbacks. diff --git a/src/gallery/client.tsx b/src/gallery/client.tsx index 84ff665..678872e 100644 --- a/src/gallery/client.tsx +++ b/src/gallery/client.tsx @@ -1,6 +1,6 @@ import { Tabs } from "@base-ui/react/tabs"; import { Menu } from "@base-ui/react/menu"; -import { ArtifactFolders } from "./folders"; +import { LibraryNavigation } from "./library-navigation"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { createRoot } from "react-dom/client"; import { authClient, signInUrl } from "../auth/client-api"; @@ -16,8 +16,7 @@ import { SourceEditor } from "./source-editor"; import { DraftProvider, confirmLeavingDrafts, useDrafts } from "./drafts"; import { ProjectImportPanel } from "./project-transfer"; import { NativeAppEditor, type NativeAppEditorHandle } from "./native-apps"; -import { fontFaces } from "./brand"; -import { themeStyles } from "./theme"; +import { galleryStyles } from "./styles"; import { subscribeGallery } from "./subscription"; type Scope = "current" | "all"; @@ -38,259 +37,6 @@ const WORKING_VERSION = "working"; type SessionUser = { id: string; name: string; email: string }; -const styles = ` - ${fontFaces} - ${themeStyles} - * { box-sizing: border-box; } - [hidden] { display: none !important; } - html, body, #root { width: 100%; height: 100%; } - body { margin: 0; background: var(--page); color: var(--text); font: 13px/1.5 "Geist Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; -webkit-font-smoothing: antialiased; } - button, input, select, textarea, a { font: inherit; color: inherit; touch-action: manipulation; } - button, select, .download-link { cursor: pointer; } - button, select, input { height: 32px; min-height: 32px; line-height: 20px; border: 1px solid transparent; border-radius: 8px; background: transparent; padding: 5px 10px; } - button { display: inline-flex; align-items: center; justify-content: center; gap: 6px; } - select, input { border-color: var(--line); min-width: 0; } - input { background: var(--page); } - button:disabled, select:disabled { opacity: .45; cursor: default; } - :is(button, input, select, textarea, summary, a):focus-visible { outline: 2px solid var(--focus); outline-offset: -2px; } - button, a { -webkit-tap-highlight-color: transparent; } - .select-control { position: relative; display: inline-flex; min-width: 0; vertical-align: middle; } - .select-control select { appearance: none; width: 100%; background: var(--panel); padding-right: 30px; text-overflow: ellipsis; } - .select-chevron { position: absolute; pointer-events: none; right: 12px; top: 50%; width: 6px; height: 6px; margin-top: -4px; border-right: 1px solid var(--muted); border-bottom: 1px solid var(--muted); transform: rotate(45deg); } - .select-control:has(select:disabled) .select-chevron { opacity: .45; } - @supports (appearance: base-select) { - .select-control select, .select-control select::picker(select) { appearance: base-select; } - .select-control select::picker-icon { display: none; } - .select-control select::picker(select) { margin: 4px 0; padding: 4px; border: 1px solid var(--line); border-radius: 10px; background: var(--panel); color: var(--text); box-shadow: 0 8px 28px #0002; font: 13px/1.5 "Geist Sans", sans-serif; } - .select-control option { padding: 6px 10px; border-radius: 6px; gap: 8px; min-height: 32px; } - .select-control option:checked { background: var(--selected); } - .select-control option:hover, .select-control option:focus { background: var(--raised); outline: none; } - .select-control option::checkmark { color: var(--muted); } - } - .gallery-app { display: flex; flex-direction: column; height: 100dvh; overflow: hidden; } - .app-header { display: flex; align-items: center; gap: 16px; min-height: 48px; padding: 8px 16px; flex-shrink: 0; } - .library-heading { display: flex; align-items: center; justify-content: space-between; width: 224px; flex-shrink: 0; } - .wordmark { font-size: 14px; font-weight: 600; letter-spacing: -.3px; } - .create-item { width: 32px; padding: 0; color: var(--muted); font-size: 22px; font-weight: 400; } - .create-item[data-popup-open] { background: var(--selected); color: var(--text); } - .theme-toggle { color: var(--muted); white-space: nowrap; } - .theme-icon { display: block; width: 15px; height: 15px; border-radius: 50%; } - .theme-icon.moon { box-shadow: inset 5px -3px 0 0 currentColor; transform: rotate(-15deg); } - .theme-icon.sun { width: 9px; height: 9px; margin: 3px; border: 1px solid currentColor; box-shadow: 0 -7px 0 -3px currentColor, 0 7px 0 -3px currentColor, 7px 0 0 -3px currentColor, -7px 0 0 -3px currentColor, 5px 5px 0 -3px currentColor, -5px -5px 0 -3px currentColor, 5px -5px 0 -3px currentColor, -5px 5px 0 -3px currentColor; } - .header-context { color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } - .header-context::before { content: "/"; color: var(--subtle); margin-right: 16px; } - .header-actions { display: flex; gap: 8px; align-items: center; margin-left: auto; } - .header-actions .select-control { max-width: 180px; } - .header-actions select { border-color: transparent; background: transparent; } - .account { display: flex; align-items: center; gap: 8px; margin-left: 8px; } - .account-name { max-width: 150px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--muted); } - .gallery-layout { display: grid; grid-template-columns: 256px minmax(0, 1fr); flex: 1; min-height: 0; } - .library-panel { display: flex; flex-direction: column; min-height: 0; background: var(--page); padding: 0 8px 8px; } - .connection-status { padding: 0 12px 8px; margin: 0; color: var(--muted); font-size: 12px; } - .library-search { position: relative; margin: 0 4px 8px; } - .search-input { width: 100%; padding-right: 32px; background: var(--panel); } - .search-input::placeholder { color: var(--subtle); } - .search-input::-webkit-search-cancel-button { display: none; } - .clear-search { position: absolute; right: 0; top: 0; bottom: 0; width: 32px; padding: 0; color: var(--muted); font-size: 18px; } - .share-link-row { display: flex; align-items: flex-end; gap: 8px; width: 100%; flex-wrap: wrap; } - .share-link-row label { flex: 1; min-width: 180px; } - .share-link-row input { width: 100%; } - .folder-row { width: 100%; justify-content: flex-start; gap: 10px; color: var(--muted); text-align: left; } - .folder-row:hover { background: var(--raised); } - .folder-chevron { width: 6px; height: 6px; margin: 0 5px 0 3px; flex-shrink: 0; border-right: 1px solid currentColor; border-bottom: 1px solid currentColor; transform: rotate(-45deg); } - .folder-row[aria-expanded="true"] .folder-chevron { transform: rotate(45deg); } - .file-icon { position: relative; width: 12px; height: 15px; border: 1px solid var(--subtle); border-radius: 2px; margin: 0 3px; flex-shrink: 0; } - .file-icon::after { content: ""; position: absolute; left: 2px; right: 2px; top: 5px; height: 1px; background: var(--subtle); box-shadow: 0 3px 0 var(--subtle); } - .library-filters { display: flex; gap: 2px; padding: 0 4px 10px; } - .library-filters button { height: 28px; min-height: 28px; padding: 3px 8px; font-size: 12px; color: var(--muted); } - .library-filters button[aria-pressed="true"] { color: var(--text); background: var(--selected); } - .artifact-list { overflow: auto; flex: 1; min-height: 0; padding: 0; } - .artifact-row { display: flex; justify-content: flex-start; align-items: center; gap: 8px; width: 100%; height: auto; min-height: 34px; text-align: left; padding: 7px 10px; border: 0; border-radius: 8px; margin-bottom: 2px; } - .artifact-row[aria-current="true"] { background: var(--selected); } - .artifact-row-copy { min-width: 0; flex: 1; } - .artifact-name { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 400; } - .row-kind { flex-shrink: 0; color: var(--subtle); font-size: 11px; } - .workspace-name { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--subtle); font-size: 11px; } - .library-count { padding: 8px 12px; color: var(--subtle); font-size: 11px; } - .artifact-detail { display: flex; flex-direction: column; min-width: 0; min-height: 0; margin: 0 6px 6px 0; background: var(--panel); border: 1px solid var(--line); border-radius: 12px; overflow: hidden; } - .detail-header { display: flex; gap: 16px; align-items: center; justify-content: space-between; padding: 10px 16px; flex-shrink: 0; flex-wrap: nowrap; border-bottom: 1px solid var(--line); } - .detail-title { min-width: 0; flex: 1; } - .detail-eyebrow { margin: 0 0 2px; color: var(--muted); font-size: 11px; } - .detail-title h1 { margin: 0; font-size: 15px; line-height: 1.3; font-weight: 600; letter-spacing: -.35px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } - .detail-title h1:focus { outline: none; } - .detail-actions { display: flex; align-items: center; gap: 4px; flex-wrap: nowrap; justify-content: flex-end; flex-shrink: 0; } - .detail-actions > :is(button, a) { white-space: nowrap; } - .download-link { display: inline-flex; align-items: center; justify-content: center; gap: 6px; height: 32px; min-height: 32px; line-height: 20px; padding: 5px 10px; border: 1px solid transparent; border-radius: 8px; text-decoration: none; } - .detail-actions .open-link { margin-left: 6px; border-color: var(--line); } - .view-control { display: flex; align-items: center; gap: 2px; overflow-x: auto; min-width: 0; } - .view-control button { padding: 5px 10px; color: var(--muted); flex-shrink: 0; border-radius: 0; border-bottom: 2px solid transparent; height: 40px; } - .view-control button[data-active] { color: var(--text); border-bottom-color: var(--text); background: transparent; } - .revision-control { display: flex; align-items: center; gap: 4px; font-size: 12px; color: var(--muted); } - .version-select { width: 140px; max-width: 140px; } - .version-select select { border-color: transparent; background: transparent; color: var(--text); } - .detail-actions button[aria-expanded="true"] { background: var(--raised); } - .back-library { display: none; } - .detail-disclosure { flex-shrink: 0; max-height: 42vh; overflow: auto; background: var(--panel); border-bottom: 1px solid var(--line); } - .link-settings, .script-fields { display: flex; flex-wrap: wrap; align-items: end; gap: 8px; } - .link-settings { padding: 12px 20px; } - .link-settings label, .script-fields label { display: flex; flex-direction: column; align-items: stretch; gap: 4px; min-width: 0; color: var(--muted); font-size: 12px; } - .link-settings :is(button, .download-link), .script-panel button { border-color: var(--line); background: var(--raised); } - .library-move { padding: 12px 20px; } - .library-move p { margin: 0 0 10px; max-width: 720px; } - .library-move button { border-color: var(--line); } - .link-note { flex-basis: 100%; margin: 0; color: var(--muted); font-size: 12px; } - .link-settings [role="status"] { align-self: center; } - .link-settings .muted { font-size: 12px; } - .script-panel { display: flex; flex-direction: column; flex: 1; width: 100%; min-width: 0; min-height: 0; overflow: hidden; } - .source-form { display: flex; flex-direction: column; flex: 1; min-width: 0; min-height: 0; } - .source-form > .script-fields { padding: 12px 16px; border-bottom: 1px solid var(--line); flex-shrink: 0; } - .source-actions { display: flex; align-items: center; gap: 8px; padding: 10px 16px; border-top: 1px solid var(--line); flex-shrink: 0; } - .source-actions p { margin: 0; color: var(--muted); } - .gallery-app button.primary-action { background: var(--primary); color: var(--primary-text); border-color: var(--primary); font-weight: 600; } - button.primary-action:disabled { opacity: .45; } - .deployment-status { margin: 6px 0 0; color: var(--muted); } - .save-feedback { padding: 8px 16px; flex-shrink: 0; max-height: 30vh; overflow: auto; } - .save-feedback p { margin: 0 0 6px; } - .save-feedback button { height: auto; text-align: left; text-decoration: underline; } - .save-feedback pre { white-space: pre-wrap; overflow-wrap: anywhere; } - .agent-onboarding { width: 100%; max-width: 600px; margin: auto; padding: 32px; } - .agent-onboarding h2 { font-size: 24px; letter-spacing: -.6px; margin: 0 0 8px; } - .agent-onboarding p { color: var(--muted); } - .agent-onboarding details { border: 1px solid var(--line); border-radius: 12px; padding: 16px; margin-top: 24px; } - .agent-onboarding textarea { border: 1px solid var(--line); border-radius: 8px; padding: 12px; resize: vertical; } - .agent-onboarding label { display: block; margin-top: 16px; } - .agent-onboarding input, .agent-onboarding textarea { display: block; width: 100%; background: var(--raised); } - .agent-onboarding textarea { min-height: 100px; } - .live-data-note { padding: 8px 16px; margin: 0; border-bottom: 1px solid var(--line); color: var(--muted); } - .artifact-execution fieldset { border: 0; padding: 0; margin: 0; min-width: 0; } - .project-import { padding: 16px 20px; max-height: 50vh; overflow: auto; } - .project-import label { display: block; margin: 8px 0; } - .project-import h2 { margin: 0 0 8px; font-size: 16px; } - .project-import input { background: var(--raised); } - .project-import input[type=file] { height: auto; } - .project-import pre { max-height: 160px; overflow: auto; white-space: pre-wrap; } - .source-actions .source-readonly { margin-right: auto; } - .save-conflict { flex-shrink: 0; padding: 8px 16px; max-height: 35vh; overflow: auto; } - .save-conflict button { border-color: var(--line); margin-right: 8px; } - .script-panel label { color: var(--muted); font-size: 12px; } - .script-panel :is(input, select) { color: var(--text); } - .project-editor { display: flex; flex-direction: column; flex: 1; min-width: 0; min-height: 0; } - .project-file-bar { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; padding: 6px 12px; border-bottom: 1px solid var(--line); flex-shrink: 0; } - .project-file-bar button { background: transparent; border-color: transparent; font: 12px/20px "SFMono-Regular", Consolas, monospace; } - .project-file-bar button[aria-pressed="true"] { background: var(--raised); } - .source-code-editor { display: flex; flex-direction: column; flex: 1; min-height: 180px; min-width: 0; } - .source-code-surface { flex: 1; min-height: 0; min-width: 0; } - .source-code-status { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 4px 16px; border-top: 1px solid var(--line); color: var(--subtle); font-size: 11px; flex-shrink: 0; } - .project-file-management, .project-dependencies { flex-shrink: 0; border-top: 1px solid var(--line); padding: 0 16px; max-height: 35vh; overflow: auto; } - .project-file-management > div { padding-bottom: 10px; } - .project-file-management details { margin: 0; } - .project-file-management p { margin: 6px 0; } - .script-panel textarea, .artifact-execution textarea { display: block; width: 100%; min-height: 80px; margin: 6px 0 12px; color: var(--text); background: var(--panel); border: 1px solid var(--line); border-radius: 8px; padding: 10px; font: 13px/1.6 "SFMono-Regular", Consolas, monospace; resize: vertical; } - summary { cursor: pointer; min-height: 32px; padding-block: 5px; color: var(--text); } - .script-panel pre { white-space: pre-wrap; overflow-wrap: anywhere; padding: 12px; border: 1px solid var(--line); background: var(--panel); } - .script-activity, .artifact-execution { flex: 1; min-height: 0; overflow: auto; padding: 16px 20px; } - .script-activity p { color: var(--muted); } - .script-activity label { display: block; } - .script-activity details + details { border-top: 1px solid var(--line); margin-top: 16px; padding-top: 8px; } - .artifact-execution label { display: inline-flex; flex-direction: column; gap: 4px; margin: 8px 12px 8px 0; } - .artifact-execution label:has(textarea) { display: flex; margin-right: 0; } - .artifact-execution button { border-color: var(--line); } - .artifact-execution table { border-collapse: collapse; font-variant-numeric: tabular-nums; font-size: 12px; width: 100%; } - .artifact-execution :is(td, th) { padding: 8px 12px; text-align: left; border-bottom: 1px solid var(--line); } - .artifact-execution p { color: var(--muted); } - .muted { color: var(--muted); } - .artifact-stage { position: relative; display: flex; flex: 1; min-width: 0; min-height: 0; overflow: hidden; } - .source-stage { display: flex; flex: 1; min-width: 0; min-height: 0; } - .preview-stage { position: relative; display: flex; flex: 1; min-width: 0; min-height: 0; background: var(--panel); } - .preview-frame { display: block; width: 100%; height: 100%; border: 0; background: var(--panel); } - .preview-loading { position: absolute; inset: 0; display: grid; place-items: center; background: var(--page); color: var(--muted); pointer-events: none; } - .state-message { margin: 0; padding: 12px; color: var(--muted); } - .library-empty { padding: 20px 12px; } - .library-empty p { margin: 0 0 8px; color: var(--muted); } - .library-empty button { border-color: var(--line); } - .empty-detail { margin: auto; max-width: 400px; padding: 24px; text-align: center; } - .empty-detail h2 { margin: 0 0 8px; font-size: 18px; font-weight: 500; } - .empty-detail p { margin: 0; color: var(--muted); } - .error-message { color: var(--error); } - .source-form > .error-message, .script-panel > .error-message { margin: 0; padding: 8px 16px; flex-shrink: 0; } - .refresh-error { padding: 10px 16px; margin: 0; border-bottom: 1px solid var(--line); background: var(--panel); } - @media (hover: hover) and (pointer: fine) { - button:hover:not(:disabled), .download-link:hover { background: var(--raised); } - .gallery-app button.primary-action:hover:not(:disabled) { background: var(--primary-hover); } - .artifact-row[aria-current="true"]:hover { background: var(--selected); } - .create-item:hover { background: var(--raised); color: var(--text); } - .view-control button:hover:not([data-active]), .library-filters button:hover:not([aria-pressed="true"]) { color: var(--text); background: transparent; } - } - @media (max-width: 1100px) { - .gallery-layout { grid-template-columns: 232px minmax(0, 1fr); } - .library-heading { width: 200px; } - .detail-header { gap: 8px; padding-inline: 12px; } - .detail-actions { max-width: 300px; } - .revision-control > span:not(.select-control) { display: none; } - .account-name { display: none; } - } - @media (max-width: 760px) { - .app-header { min-height: 52px; padding: 6px 12px; gap: 10px; } - .library-heading { width: auto; flex: 1; gap: 16px; } - .create-item { width: 44px; height: 44px; } - .app-header { flex-wrap: wrap; } - .header-actions { flex-wrap: wrap; } - .artifact-detail { margin: 0 4px 4px; } - .header-context { display: none; } - .header-actions { gap: 4px; } - .header-actions .select-control { max-width: 130px; } - .account { margin-left: 0; } - .gallery-layout { display: flex; flex: 1; } - .library-panel, .artifact-detail { width: 100%; flex: 1; } - .library-panel { border-right: 0; } - .artifact-row { min-height: 44px; } - .artifact-name { font-size: 14px; } - .back-library { display: inline-flex; padding: 0; width: 28px; flex-shrink: 0; color: var(--muted); } - .detail-header { padding: 4px 8px; gap: 4px; } - .detail-actions { max-width: none; } - .detail-title h1 { font-size: 13px; } - .detail-actions .open-link { display: none; } - .detail-header .view-control button { padding-inline: 6px; font-size: 12px; } - .detail-actions > button { padding-inline: 8px; } - .version-select { width: 150px; max-width: 150px; } - .version-select select { padding-left: 6px; } - .view-control { gap: 0; } - .view-control button { padding-inline: 8px; } - .revision-control { min-width: 0; margin-left: auto; } - .link-settings { padding: 12px; } - .script-fields > :is(input, label, .select-control) { max-width: 100%; } - .link-settings label { flex: 1; min-width: 0; } - .link-settings input { width: 100%; } - .detail-disclosure { max-height: 45dvh; } - .project-file-bar { padding-inline: 6px; } - .source-actions { padding: 8px 12px; } - .source-code-status { padding-inline: 12px; } - .source-code-status .editor-shortcuts { display: none; } - .script-activity, .artifact-execution { padding: 12px; } - } - .more-actions { width: 32px; padding: 0; font-size: 24px; line-height: 1; } - .mobile-open { display: none !important; } - @media (max-width: 760px) { .mobile-open { display: block !important; } } - .action-menu { padding: 4px; min-width: 170px; border: 1px solid var(--line); border-radius: 10px; background: var(--panel); color: var(--text); box-shadow: 0 8px 28px #0002; } - .action-menu [role=menuitem] { display: block; padding: 8px 12px; border-radius: 6px; text-decoration: none; cursor: pointer; } - .action-menu [data-highlighted] { background: var(--raised); outline: none; } - .settings-panel { width: 100%; overflow: auto; } - .settings-title { margin: 16px 20px 0; font-size: 15px; } - .schedule-summary { padding-bottom: 12px; border-bottom: 1px solid var(--line); } - .schedule-summary h2, .run-history h2 { margin: 0 0 8px; font-size: 15px; } - .schedule-form { padding-block: 12px; } - .run-history { margin-top: 24px; overflow-x: auto; } - @media (max-width: 760px) { - .detail-actions { margin-left: 0; } - .revision-control { padding-bottom: 4px; } - } - @media (pointer: coarse) { - button, input, select, .download-link, summary { height: auto; min-height: 44px; } - input, select, textarea, .script-panel textarea { font-size: 16px; } - .search-input { padding-block: 11px; } - .clear-search { height: 44px; } - } -`; function isGalleryResponse(value: unknown): value is GalleryData { if (!value || typeof value !== "object") return false; @@ -515,6 +261,9 @@ function App() { () => gallery?.artifacts.find((artifact) => artifact.key === selectedKey) ?? null, [gallery, selectedKey], ); + const selectedWorkerKey = selectedWorker + ? gallery?.workerApps?.find(worker => worker.workspace === selectedWorker.workspace && worker.name === selectedWorker.name)?.key ?? null + : null; const activeTab: DetailTab = selectedArtifact?.kind === "script" ? tab === "preview" ? "source" : tab @@ -776,7 +525,7 @@ function App() { return ( <> - +
@@ -811,42 +560,13 @@ function App() { {accountError ?

{accountError}

: null}
- + [...drafts.entries()].some(([key, draft]) => key.startsWith(artifact.key + ":") && draft.dirty)} + loading={loading} error={galleryError} onRetry={() => void loadGallery()} connected={liveConnected} + panelRef={libraryPanel} searchRef={searchInput} hidden={narrowLayout && mobileDetail} /> {selectedWorker && gallery ?
; } export function ScriptPanel({ artifact, workspace, version, sourceUrl, onSaved, onCancel, view = "source" }: { artifact?: GalleryArtifact; workspace: string; version?: string; sourceUrl?: string; onSaved: (name: string) => Promise; onCancel?: () => void; view?: ScriptView }) { + const { tool: galleryTool, loadSource: loadSourceSnapshot } = useGalleryTransport(); const buffer = useProjectDraft(artifact ? `${artifact.key}:${version}` : `new-script:${workspace}`); const { draft } = buffer; const name = artifact?.name ?? draft?.content.name ?? ""; @@ -249,6 +246,7 @@ export function ArtifactSourcePanel({ artifact, workspace = artifact?.workspace artifact?: GalleryArtifact; workspace?: string; hosted?: boolean; version?: string; sourceUrl?: string; onSaved: (name: string) => Promise; onCancel?: () => void; }) { + const { tool: galleryTool, loadSource: loadSourceSnapshot } = useGalleryTransport(); const buffer = useProjectDraft(artifact ? `${artifact.key}:${version}` : `new-artifact:${workspace}`); const snapshot = buffer.draft?.content; const name = artifact?.name ?? snapshot?.name ?? ""; diff --git a/src/gallery/library-navigation.tsx b/src/gallery/library-navigation.tsx new file mode 100644 index 0000000..9bbe999 --- /dev/null +++ b/src/gallery/library-navigation.tsx @@ -0,0 +1,55 @@ +import { useRef, type ReactNode, type RefObject } from "react"; +import { ArtifactFolders } from "./folders"; +import type { GalleryArtifact, GalleryData, GalleryItem, GalleryWorker } from "./types"; + +export type LibraryKindFilter = "all" | "artifact" | "script" | "worker"; + +/** Shared navigation for the web gallery and the chat workspace. */ +export function LibraryNavigation({ gallery, artifacts, query, onQueryChange, kindFilter, onKindFilterChange, + selectedKey, onSelect, onSelectWorker, isDirty, loading, error, onRetry, connected = true, panelRef, searchRef, hidden, footer, +}: { + gallery: GalleryData | null; artifacts: GalleryItem[]; query: string; onQueryChange: (query: string) => void; + kindFilter: LibraryKindFilter; onKindFilterChange: (kind: LibraryKindFilter) => void; + selectedKey: string | null; onSelect: (artifact: GalleryArtifact) => void; onSelectWorker?: (worker: GalleryWorker) => void; isDirty: (artifact: GalleryItem) => boolean; + loading: boolean; error?: string; onRetry: () => void; connected?: boolean; + panelRef?: RefObject; searchRef?: RefObject; hidden?: boolean; footer?: ReactNode; +}) { + const localSearch = useRef(null); + const input = searchRef ?? localSearch; + const workers = !!onSelectWorker; + const libraryItems = [...(gallery?.artifacts ?? []), ...(workers ? gallery?.workerApps ?? [] : [])]; + return ; +} diff --git a/src/gallery/remix.tsx b/src/gallery/remix.tsx index 8ae9aea..0642aad 100644 --- a/src/gallery/remix.tsx +++ b/src/gallery/remix.tsx @@ -1,10 +1,11 @@ import { useState } from "react"; import type { GalleryArtifact } from "./types"; -import { galleryTool } from "./hosted"; +import { useGalleryTransport } from "./transport"; export function RemixPanel({ artifact, version, onSaved, onCancel }: { artifact: GalleryArtifact; version: string; onSaved: (name: string) => Promise; onCancel: () => void; }) { + const { tool: galleryTool } = useGalleryTransport(); const [name, setName] = useState(`${artifact.name}-remix`); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); diff --git a/src/gallery/secrets.tsx b/src/gallery/secrets.tsx index 2b0f68d..7d0e594 100644 --- a/src/gallery/secrets.tsx +++ b/src/gallery/secrets.tsx @@ -1,7 +1,9 @@ +import { useGalleryTransport } from "./transport"; import { useState } from "react"; -import { GalleryToolError, galleryTool } from "./hosted"; +import { GalleryToolError } from "./hosted"; export function SecretControls({ workspace, name, kind, onSaved }: { workspace: string; name: string; kind: "script" | "artifact" | "app"; onSaved?: () => Promise }) { + const { tool: galleryTool } = useGalleryTransport(); const [secretName, setSecretName] = useState(""); const [secretValue, setSecretValue] = useState(""); const [names, setNames] = useState([]); diff --git a/src/gallery/styles.ts b/src/gallery/styles.ts new file mode 100644 index 0000000..3b76ffb --- /dev/null +++ b/src/gallery/styles.ts @@ -0,0 +1,257 @@ +import { fontFaces } from "./brand"; +import { themeStyles } from "./theme"; + +export const galleryStyles = ` + ${fontFaces} + ${themeStyles} + * { box-sizing: border-box; } + [hidden] { display: none !important; } + html, body, #root { width: 100%; height: 100%; } + body { margin: 0; background: var(--page); color: var(--text); font: 13px/1.5 "Geist Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; -webkit-font-smoothing: antialiased; } + button, input, select, textarea, a { font: inherit; color: inherit; touch-action: manipulation; } + button, select, .download-link { cursor: pointer; } + button, select, input { height: 32px; min-height: 32px; line-height: 20px; border: 1px solid transparent; border-radius: 8px; background: transparent; padding: 5px 10px; } + button { display: inline-flex; align-items: center; justify-content: center; gap: 6px; } + select, input { border-color: var(--line); min-width: 0; } + input { background: var(--page); } + button:disabled, select:disabled { opacity: .45; cursor: default; } + :is(button, input, select, textarea, summary, a):focus-visible { outline: 2px solid var(--focus); outline-offset: -2px; } + button, a { -webkit-tap-highlight-color: transparent; } + .select-control { position: relative; display: inline-flex; min-width: 0; vertical-align: middle; } + .select-control select { appearance: none; width: 100%; background: var(--panel); padding-right: 30px; text-overflow: ellipsis; } + .select-chevron { position: absolute; pointer-events: none; right: 12px; top: 50%; width: 6px; height: 6px; margin-top: -4px; border-right: 1px solid var(--muted); border-bottom: 1px solid var(--muted); transform: rotate(45deg); } + .select-control:has(select:disabled) .select-chevron { opacity: .45; } + @supports (appearance: base-select) { + .select-control select, .select-control select::picker(select) { appearance: base-select; } + .select-control select::picker-icon { display: none; } + .select-control select::picker(select) { margin: 4px 0; padding: 4px; border: 1px solid var(--line); border-radius: 10px; background: var(--panel); color: var(--text); box-shadow: 0 8px 28px #0002; font: 13px/1.5 "Geist Sans", sans-serif; } + .select-control option { padding: 6px 10px; border-radius: 6px; gap: 8px; min-height: 32px; } + .select-control option:checked { background: var(--selected); } + .select-control option:hover, .select-control option:focus { background: var(--raised); outline: none; } + .select-control option::checkmark { color: var(--muted); } + } + .gallery-app { display: flex; flex-direction: column; height: 100dvh; overflow: hidden; } + .app-header { display: flex; align-items: center; gap: 16px; min-height: 48px; padding: 8px 16px; flex-shrink: 0; } + .library-heading { display: flex; align-items: center; justify-content: space-between; width: 224px; flex-shrink: 0; } + .wordmark { font-size: 14px; font-weight: 600; letter-spacing: -.3px; } + .create-item { width: 32px; padding: 0; color: var(--muted); font-size: 22px; font-weight: 400; } + .create-item[data-popup-open] { background: var(--selected); color: var(--text); } + .theme-toggle { color: var(--muted); white-space: nowrap; } + .theme-icon { display: block; width: 15px; height: 15px; border-radius: 50%; } + .theme-icon.moon { box-shadow: inset 5px -3px 0 0 currentColor; transform: rotate(-15deg); } + .theme-icon.sun { width: 9px; height: 9px; margin: 3px; border: 1px solid currentColor; box-shadow: 0 -7px 0 -3px currentColor, 0 7px 0 -3px currentColor, 7px 0 0 -3px currentColor, -7px 0 0 -3px currentColor, 5px 5px 0 -3px currentColor, -5px -5px 0 -3px currentColor, 5px -5px 0 -3px currentColor, -5px 5px 0 -3px currentColor; } + .header-context { color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .header-context::before { content: "/"; color: var(--subtle); margin-right: 16px; } + .header-actions { display: flex; gap: 8px; align-items: center; margin-left: auto; } + .header-actions .select-control { max-width: 180px; } + .header-actions select { border-color: transparent; background: transparent; } + .account { display: flex; align-items: center; gap: 8px; margin-left: 8px; } + .account-name { max-width: 150px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--muted); } + .gallery-layout { display: grid; grid-template-columns: 256px minmax(0, 1fr); flex: 1; min-height: 0; } + .library-panel { display: flex; flex-direction: column; min-height: 0; background: var(--page); padding: 0 8px 8px; } + .connection-status { padding: 0 12px 8px; margin: 0; color: var(--muted); font-size: 12px; } + .library-search { position: relative; margin: 0 4px 8px; } + .search-input { width: 100%; padding-right: 32px; background: var(--panel); } + .search-input::placeholder { color: var(--subtle); } + .search-input::-webkit-search-cancel-button { display: none; } + .clear-search { position: absolute; right: 0; top: 0; bottom: 0; width: 32px; padding: 0; color: var(--muted); font-size: 18px; } + .share-link-row { display: flex; align-items: flex-end; gap: 8px; width: 100%; flex-wrap: wrap; } + .share-link-row label { flex: 1; min-width: 180px; } + .share-link-row input { width: 100%; } + .folder-row { width: 100%; justify-content: flex-start; gap: 10px; color: var(--muted); text-align: left; } + .folder-row:hover { background: var(--raised); } + .folder-chevron { width: 6px; height: 6px; margin: 0 5px 0 3px; flex-shrink: 0; border-right: 1px solid currentColor; border-bottom: 1px solid currentColor; transform: rotate(-45deg); } + .folder-row[aria-expanded="true"] .folder-chevron { transform: rotate(45deg); } + .file-icon { position: relative; width: 12px; height: 15px; border: 1px solid var(--subtle); border-radius: 2px; margin: 0 3px; flex-shrink: 0; } + .file-icon::after { content: ""; position: absolute; left: 2px; right: 2px; top: 5px; height: 1px; background: var(--subtle); box-shadow: 0 3px 0 var(--subtle); } + .library-filters { display: flex; gap: 2px; padding: 0 4px 10px; } + .library-filters button { height: 28px; min-height: 28px; padding: 3px 8px; font-size: 12px; color: var(--muted); } + .library-filters button[aria-pressed="true"] { color: var(--text); background: var(--selected); } + .artifact-list { overflow: auto; flex: 1; min-height: 0; padding: 0; } + .artifact-row { display: flex; justify-content: flex-start; align-items: center; gap: 8px; width: 100%; height: auto; min-height: 34px; text-align: left; padding: 7px 10px; border: 0; border-radius: 8px; margin-bottom: 2px; } + .artifact-row[aria-current="true"] { background: var(--selected); } + .artifact-row-copy { min-width: 0; flex: 1; } + .artifact-name { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 400; } + .row-kind { flex-shrink: 0; color: var(--subtle); font-size: 11px; } + .workspace-name { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--subtle); font-size: 11px; } + .library-footer { padding: 8px 12px; color: var(--subtle); font-size: 11px; } + .library-count { padding: 8px 12px; color: var(--subtle); font-size: 11px; } + .artifact-detail { display: flex; flex-direction: column; min-width: 0; min-height: 0; margin: 0 6px 6px 0; background: var(--panel); border: 1px solid var(--line); border-radius: 12px; overflow: hidden; } + .detail-header { display: flex; gap: 16px; align-items: center; justify-content: space-between; padding: 10px 16px; flex-shrink: 0; flex-wrap: nowrap; border-bottom: 1px solid var(--line); } + .detail-title { min-width: 0; flex: 1; } + .detail-eyebrow { margin: 0 0 2px; color: var(--muted); font-size: 11px; } + .detail-title h1 { margin: 0; font-size: 15px; line-height: 1.3; font-weight: 600; letter-spacing: -.35px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .detail-title h1:focus { outline: none; } + .detail-actions { display: flex; align-items: center; gap: 4px; flex-wrap: nowrap; justify-content: flex-end; flex-shrink: 0; } + .detail-actions > :is(button, a) { white-space: nowrap; } + .download-link { display: inline-flex; align-items: center; justify-content: center; gap: 6px; height: 32px; min-height: 32px; line-height: 20px; padding: 5px 10px; border: 1px solid transparent; border-radius: 8px; text-decoration: none; } + .detail-actions .open-link { margin-left: 6px; border-color: var(--line); } + .view-control { display: flex; align-items: center; gap: 2px; overflow-x: auto; min-width: 0; } + .view-control button { padding: 5px 10px; color: var(--muted); flex-shrink: 0; border-radius: 0; border-bottom: 2px solid transparent; height: 40px; } + .view-control button[data-active] { color: var(--text); border-bottom-color: var(--text); background: transparent; } + .revision-control { display: flex; align-items: center; gap: 4px; font-size: 12px; color: var(--muted); } + .version-select { width: 140px; max-width: 140px; } + .version-select select { border-color: transparent; background: transparent; color: var(--text); } + .detail-actions button[aria-expanded="true"] { background: var(--raised); } + .back-library { display: none; } + .detail-disclosure { flex-shrink: 0; max-height: 42vh; overflow: auto; background: var(--panel); border-bottom: 1px solid var(--line); } + .link-settings, .script-fields { display: flex; flex-wrap: wrap; align-items: end; gap: 8px; } + .link-settings { padding: 12px 20px; } + .link-settings label, .script-fields label { display: flex; flex-direction: column; align-items: stretch; gap: 4px; min-width: 0; color: var(--muted); font-size: 12px; } + .link-settings :is(button, .download-link), .script-panel button { border-color: var(--line); background: var(--raised); } + .library-move { padding: 12px 20px; } + .library-move p { margin: 0 0 10px; max-width: 720px; } + .library-move button { border-color: var(--line); } + .link-note { flex-basis: 100%; margin: 0; color: var(--muted); font-size: 12px; } + .link-settings [role="status"] { align-self: center; } + .link-settings .muted { font-size: 12px; } + .script-panel { display: flex; flex-direction: column; flex: 1; width: 100%; min-width: 0; min-height: 0; overflow: hidden; } + .source-form { display: flex; flex-direction: column; flex: 1; min-width: 0; min-height: 0; } + .source-form > .script-fields { padding: 12px 16px; border-bottom: 1px solid var(--line); flex-shrink: 0; } + .source-actions { display: flex; align-items: center; gap: 8px; padding: 10px 16px; border-top: 1px solid var(--line); flex-shrink: 0; } + .source-actions p { margin: 0; color: var(--muted); } + .gallery-app button.primary-action { background: var(--primary); color: var(--primary-text); border-color: var(--primary); font-weight: 600; } + button.primary-action:disabled { opacity: .45; } + .deployment-status { margin: 6px 0 0; color: var(--muted); } + .save-feedback { padding: 8px 16px; flex-shrink: 0; max-height: 30vh; overflow: auto; } + .save-feedback p { margin: 0 0 6px; } + .save-feedback button { height: auto; text-align: left; text-decoration: underline; } + .save-feedback pre { white-space: pre-wrap; overflow-wrap: anywhere; } + .agent-onboarding { width: 100%; max-width: 600px; margin: auto; padding: 32px; } + .agent-onboarding h2 { font-size: 24px; letter-spacing: -.6px; margin: 0 0 8px; } + .agent-onboarding p { color: var(--muted); } + .agent-onboarding details { border: 1px solid var(--line); border-radius: 12px; padding: 16px; margin-top: 24px; } + .agent-onboarding textarea { border: 1px solid var(--line); border-radius: 8px; padding: 12px; resize: vertical; } + .agent-onboarding label { display: block; margin-top: 16px; } + .agent-onboarding input, .agent-onboarding textarea { display: block; width: 100%; background: var(--raised); } + .agent-onboarding textarea { min-height: 100px; } + .live-data-note { padding: 8px 16px; margin: 0; border-bottom: 1px solid var(--line); color: var(--muted); } + .artifact-execution fieldset { border: 0; padding: 0; margin: 0; min-width: 0; } + .project-import { padding: 16px 20px; max-height: 50vh; overflow: auto; } + .project-import label { display: block; margin: 8px 0; } + .project-import h2 { margin: 0 0 8px; font-size: 16px; } + .project-import input { background: var(--raised); } + .project-import input[type=file] { height: auto; } + .project-import pre { max-height: 160px; overflow: auto; white-space: pre-wrap; } + .source-actions .source-readonly { margin-right: auto; } + .save-conflict { flex-shrink: 0; padding: 8px 16px; max-height: 35vh; overflow: auto; } + .save-conflict button { border-color: var(--line); margin-right: 8px; } + .script-panel label { color: var(--muted); font-size: 12px; } + .script-panel :is(input, select) { color: var(--text); } + .project-editor { display: flex; flex-direction: column; flex: 1; min-width: 0; min-height: 0; } + .project-file-bar { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; padding: 6px 12px; border-bottom: 1px solid var(--line); flex-shrink: 0; } + .project-file-bar button { background: transparent; border-color: transparent; font: 12px/20px "SFMono-Regular", Consolas, monospace; } + .project-file-bar button[aria-pressed="true"] { background: var(--raised); } + .source-code-editor { display: flex; flex-direction: column; flex: 1; min-height: 180px; min-width: 0; } + .source-code-surface { flex: 1; min-height: 0; min-width: 0; } + .source-code-status { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 4px 16px; border-top: 1px solid var(--line); color: var(--subtle); font-size: 11px; flex-shrink: 0; } + .project-file-management, .project-dependencies { flex-shrink: 0; border-top: 1px solid var(--line); padding: 0 16px; max-height: 35vh; overflow: auto; } + .project-file-management > div { padding-bottom: 10px; } + .project-file-management details { margin: 0; } + .project-file-management p { margin: 6px 0; } + .script-panel textarea, .artifact-execution textarea { display: block; width: 100%; min-height: 80px; margin: 6px 0 12px; color: var(--text); background: var(--panel); border: 1px solid var(--line); border-radius: 8px; padding: 10px; font: 13px/1.6 "SFMono-Regular", Consolas, monospace; resize: vertical; } + summary { cursor: pointer; min-height: 32px; padding-block: 5px; color: var(--text); } + .script-panel pre { white-space: pre-wrap; overflow-wrap: anywhere; padding: 12px; border: 1px solid var(--line); background: var(--panel); } + .script-activity, .artifact-execution { flex: 1; min-height: 0; overflow: auto; padding: 16px 20px; } + .script-activity p { color: var(--muted); } + .script-activity label { display: block; } + .script-activity details + details { border-top: 1px solid var(--line); margin-top: 16px; padding-top: 8px; } + .artifact-execution label { display: inline-flex; flex-direction: column; gap: 4px; margin: 8px 12px 8px 0; } + .artifact-execution label:has(textarea) { display: flex; margin-right: 0; } + .artifact-execution button { border-color: var(--line); } + .artifact-execution table { border-collapse: collapse; font-variant-numeric: tabular-nums; font-size: 12px; width: 100%; } + .artifact-execution :is(td, th) { padding: 8px 12px; text-align: left; border-bottom: 1px solid var(--line); } + .artifact-execution p { color: var(--muted); } + .muted { color: var(--muted); } + .artifact-stage { position: relative; display: flex; flex: 1; min-width: 0; min-height: 0; overflow: hidden; } + .source-stage { display: flex; flex: 1; min-width: 0; min-height: 0; } + .preview-stage { position: relative; display: flex; flex: 1; min-width: 0; min-height: 0; background: var(--panel); } + .preview-frame { display: block; width: 100%; height: 100%; border: 0; background: var(--panel); } + .preview-loading { position: absolute; inset: 0; display: grid; place-items: center; background: var(--page); color: var(--muted); pointer-events: none; } + .state-message { margin: 0; padding: 12px; color: var(--muted); } + .library-empty { padding: 20px 12px; } + .library-empty p { margin: 0 0 8px; color: var(--muted); } + .library-empty button { border-color: var(--line); } + .empty-detail { margin: auto; max-width: 400px; padding: 24px; text-align: center; } + .empty-detail h2 { margin: 0 0 8px; font-size: 18px; font-weight: 500; } + .empty-detail p { margin: 0; color: var(--muted); } + .error-message { color: var(--error); } + .source-form > .error-message, .script-panel > .error-message { margin: 0; padding: 8px 16px; flex-shrink: 0; } + .refresh-error { padding: 10px 16px; margin: 0; border-bottom: 1px solid var(--line); background: var(--panel); } + @media (hover: hover) and (pointer: fine) { + button:hover:not(:disabled), .download-link:hover { background: var(--raised); } + .gallery-app button.primary-action:hover:not(:disabled) { background: var(--primary-hover); } + .artifact-row[aria-current="true"]:hover { background: var(--selected); } + .create-item:hover { background: var(--raised); color: var(--text); } + .view-control button:hover:not([data-active]), .library-filters button:hover:not([aria-pressed="true"]) { color: var(--text); background: transparent; } + } + @media (max-width: 1100px) { + .gallery-layout { grid-template-columns: 232px minmax(0, 1fr); } + .library-heading { width: 200px; } + .detail-header { gap: 8px; padding-inline: 12px; } + .detail-actions { max-width: 300px; } + .revision-control > span:not(.select-control) { display: none; } + .account-name { display: none; } + } + @media (max-width: 760px) { + .app-header { min-height: 52px; padding: 6px 12px; gap: 10px; } + .library-heading { width: auto; flex: 1; gap: 16px; } + .create-item { width: 44px; height: 44px; } + .app-header { flex-wrap: wrap; } + .header-actions { flex-wrap: wrap; } + .artifact-detail { margin: 0 4px 4px; } + .header-context { display: none; } + .header-actions { gap: 4px; } + .header-actions .select-control { max-width: 130px; } + .account { margin-left: 0; } + .gallery-layout { display: flex; flex: 1; } + .library-panel, .artifact-detail { width: 100%; flex: 1; } + .library-panel { border-right: 0; } + .artifact-row { min-height: 44px; } + .artifact-name { font-size: 14px; } + .back-library { display: inline-flex; padding: 0; width: 28px; flex-shrink: 0; color: var(--muted); } + .detail-header { padding: 4px 8px; gap: 4px; } + .detail-actions { max-width: none; } + .detail-title h1 { font-size: 13px; } + .detail-actions .open-link { display: none; } + .detail-header .view-control button { padding-inline: 6px; font-size: 12px; } + .detail-actions > button { padding-inline: 8px; } + .version-select { width: 150px; max-width: 150px; } + .version-select select { padding-left: 6px; } + .view-control { gap: 0; } + .view-control button { padding-inline: 8px; } + .revision-control { min-width: 0; margin-left: auto; } + .link-settings { padding: 12px; } + .script-fields > :is(input, label, .select-control) { max-width: 100%; } + .link-settings label { flex: 1; min-width: 0; } + .link-settings input { width: 100%; } + .detail-disclosure { max-height: 45dvh; } + .project-file-bar { padding-inline: 6px; } + .source-actions { padding: 8px 12px; } + .source-code-status { padding-inline: 12px; } + .source-code-status .editor-shortcuts { display: none; } + .script-activity, .artifact-execution { padding: 12px; } + } + .more-actions { width: 32px; padding: 0; font-size: 24px; line-height: 1; } + .mobile-open { display: none !important; } + @media (max-width: 760px) { .mobile-open { display: block !important; } } + .action-menu { padding: 4px; min-width: 170px; border: 1px solid var(--line); border-radius: 10px; background: var(--panel); color: var(--text); box-shadow: 0 8px 28px #0002; } + .action-menu [role=menuitem] { display: block; padding: 8px 12px; border-radius: 6px; text-decoration: none; cursor: pointer; } + .action-menu [data-highlighted] { background: var(--raised); outline: none; } + .settings-panel { width: 100%; overflow: auto; } + .settings-title { margin: 16px 20px 0; font-size: 15px; } + .schedule-summary { padding-bottom: 12px; border-bottom: 1px solid var(--line); } + .schedule-summary h2, .run-history h2 { margin: 0 0 8px; font-size: 15px; } + .schedule-form { padding-block: 12px; } + .run-history { margin-top: 24px; overflow-x: auto; } + @media (max-width: 760px) { + .detail-actions { margin-left: 0; } + .revision-control { padding-bottom: 4px; } + } + @media (pointer: coarse) { + button, input, select, .download-link, summary { height: auto; min-height: 44px; } + input, select, textarea, .script-panel textarea { font-size: 16px; } + .search-input { padding-block: 11px; } + .clear-search { height: 44px; } + } +`; diff --git a/src/gallery/transport.tsx b/src/gallery/transport.tsx new file mode 100644 index 0000000..7ae4d03 --- /dev/null +++ b/src/gallery/transport.tsx @@ -0,0 +1,34 @@ +import { createContext, useContext, type ReactNode } from "react"; +import { loadSourceSnapshot, type SourceSnapshot } from "./drafts"; +import type { Diagnostic } from "../diagnostics"; + +type ToolResult = { error?: string; isError?: boolean; content?: { type: string; text?: string }[]; structuredContent?: unknown }; +export type MutationResult = { applied?: boolean; ok?: boolean; revision_token?: string; error?: string; check?: string; diagnostics?: Diagnostic[] }; +export class GalleryToolError extends Error { + constructor(message: string, readonly status: number, readonly result?: MutationResult) { super(message); } +} +export async function galleryTool(workspace: string, name: string, args: Record): Promise { + const params = new URLSearchParams({ workspace }); + const library = new URLSearchParams(window.location.search).get("library"); + if (library) params.set("library", library); + const response = await fetch(`/api/tools?${params}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ name, arguments: args }) }); + const result = await response.json() as ToolResult; + const text = result.content?.filter(item => item.type === "text").map(item => item.text ?? "").join("\n"); + if (!response.ok || result.isError) { + let detail = result.structuredContent as MutationResult | undefined; + if (!detail && text) { try { detail = JSON.parse(text); } catch {} } + throw new GalleryToolError(result.error || text || `Request failed (${response.status})`, response.status, detail); + } + return result.structuredContent ?? text ?? result; +} + +export type GalleryTransport = { + tool(workspace: string, name: string, args: Record): Promise; + loadSource(sourceUrl: string, signal?: AbortSignal): Promise; +}; +const httpTransport: GalleryTransport = { tool: galleryTool, loadSource: loadSourceSnapshot }; +const TransportContext = createContext(httpTransport); +export function GalleryTransportProvider({ transport, children }: { transport: GalleryTransport; children: ReactNode }) { + return {children}; +} +export function useGalleryTransport(): GalleryTransport { return useContext(TransportContext); } diff --git a/src/gallery/types.ts b/src/gallery/types.ts index c2bd6c5..a6610c6 100644 --- a/src/gallery/types.ts +++ b/src/gallery/types.ts @@ -2,4 +2,4 @@ export type GalleryVersion = { id: string; revision: number; createdAt: string; export type GalleryArtifact = { key: string; name: string; workspace: string; working: boolean; versions: GalleryVersion[]; kind?: "artifact" | "script"; slug?: string; url?: string; access?: "private" | "public"; live?: { id: string; revision: number; revision_token: string }; liveId?: string; draftRevision?: string }; export type GalleryWorker = { key: string; kind: "worker"; name: string; workspace: string; provider: string; status: string; revision_token: string }; export type GalleryItem = GalleryArtifact | GalleryWorker; -export type GalleryData = { workspace: string; artifacts: GalleryArtifact[]; workerApps?: GalleryWorker[]; nativeAppProviders?: string[]; libraryScope?: "private" | "team"; nextOffset?: number | null; capabilities?: { scripts?: boolean; links?: boolean; moves?: boolean; nativeApps?: boolean; subscriptions?: boolean } }; +export type GalleryData = { workspace: string; artifacts: GalleryArtifact[]; workerApps?: GalleryWorker[]; nativeAppProviders?: string[]; libraryScope?: "private" | "team"; nextOffset?: number | null; capabilities?: { scripts?: boolean; links?: boolean; moves?: boolean; nativeApps?: boolean; subscriptions?: boolean; editing?: boolean } }; diff --git a/src/gallery/workspace.test.tsx b/src/gallery/workspace.test.tsx new file mode 100644 index 0000000..7cc3612 --- /dev/null +++ b/src/gallery/workspace.test.tsx @@ -0,0 +1,91 @@ +import { expect, test } from "bun:test"; +import { renderToStaticMarkup } from "react-dom/server"; +import { GalleryWorkspace, mergeGalleryItems, readWorkspaceCatalog, resolveWorkspaceVersion, selectedGalleryArtifact, workspaceSourceUrl } from "./workspace"; +import { GalleryToolError, GalleryTransportProvider, useGalleryTransport, type GalleryTransport } from "./transport"; +import type { GalleryArtifact, GalleryData } from "./types"; + +const older = { id: "old-version", revision: 1, createdAt: "2026-10-01T00:00:00Z", reason: "save", serveCount: 1 }; +const latest = { ...older, id: "latest-version", revision: 2 }; +const artifact: GalleryArtifact = { key: "alpha:chart", name: "chart", workspace: "alpha", working: true, versions: [latest] }; +const script: GalleryArtifact = { ...artifact, key: "alpha:script:chart", kind: "script" }; +const gallery: GalleryData = { workspace: "alpha", artifacts: [artifact, script], capabilities: { scripts: true, editing: true, nativeApps: true } }; +const transport: GalleryTransport = { + async tool() { return {}; }, + async loadSource() { return { source: "export default () => null", project: { files: {}, dependencies: {} }, revision_token: "revision" }; }, +}; + +test("catalog pagination merges history without losing working source or content kind", () => { + const merged = mergeGalleryItems([artifact, script], [{ ...artifact, working: false, versions: [older, latest] }]); + expect(merged).toHaveLength(2); + expect(merged.find(item => item.key === artifact.key)).toMatchObject({ working: true, versions: [latest, older] }); + expect(merged.find(item => item.kind === "script")).toEqual(script); +}); + +test("selection disambiguates same names across kinds and workspaces", () => { + const other = { ...artifact, key: "beta:chart", workspace: "beta" }; + expect(selectedGalleryArtifact([artifact, script, other], { name: "chart", workspace: "alpha", kind: "script" })).toBe(script); + expect(selectedGalleryArtifact([artifact, other], { name: "chart", workspace: "beta" })).toBe(other); + expect(selectedGalleryArtifact([artifact], { version_id: "missing" })).toBeUndefined(); +}); + +test("deep-link lookup finds an authorized revision beyond the first catalog page", async () => { + const calls: number[] = []; + const full = await readWorkspaceCatalog(async (_query, offset) => { + calls.push(offset); + return offset === 0 ? { ...gallery, artifacts: [], nextOffset: 100 } + : { ...gallery, artifacts: [{ ...artifact, versions: [older] }], nextOffset: null }; + }, ""); + expect(calls).toEqual([0, 100]); + expect(selectedGalleryArtifact(full!.artifacts, { workspace: "alpha", version_id: older.id })?.name).toBe("chart"); + expect(selectedGalleryArtifact(full!.artifacts, { version_id: "inaccessible" })).toBeUndefined(); +}); + +test("obsolete selection lookup stops before requesting more catalog pages", async () => { + let active = true; + const calls: number[] = []; + const result = await readWorkspaceCatalog(async (_query, offset) => { + calls.push(offset); active = false; + return { ...gallery, nextOffset: 100 }; + }, "", () => active); + expect(result).toBeUndefined(); + expect(calls).toEqual([0]); +}); + +test("unresolved conversation deep link starts with loading, never another catalog item", () => { + const markup = renderToStaticMarkup( gallery} renderPreview={async () => () => {}} attach={async () => {}} />); + expect(markup).toContain("Opening selected artifact…"); + expect(markup).not.toContain('aria-label="Revision of chart"'); +}); + +test("refresh retains an explicitly pinned version even if a catalog page omits it", () => { + expect(resolveWorkspaceVersion(artifact, older.id)).toBe(older.id); + expect(resolveWorkspaceVersion(artifact, "working")).toBe("working"); + expect(resolveWorkspaceVersion(artifact, null)).toBe("working"); + expect(resolveWorkspaceVersion({ ...artifact, working: false, versions: [older, latest] }, null)).toBe(latest.id); +}); + +test("source requests preserve exact workspace, kind, and current versus pinned identity", () => { + expect(workspaceSourceUrl(script, "working")).toBe("/api/source?workspace=alpha&name=chart&kind=script"); + expect(workspaceSourceUrl(artifact, older.id)).toBe("/api/source?workspace=alpha&version_id=old-version"); +}); + +test("thread entry does not select a global catalog item without an explicit selection", () => { + const markup = renderToStaticMarkup( gallery} renderPreview={async () => () => {}} attach={async () => {}} openProduct={async () => {}} />); + expect(markup).toContain("Choose an artifact for this conversation"); + expect(markup).toContain('aria-label="More actions"'); + expect(markup).not.toContain('aria-label="Revision of chart"'); +}); + +test("transport provider uses supplied source and tool operations without changing conflict data", async () => { + const conflict = new GalleryToolError("Project changed", 409, { applied: false, revision_token: "newer" }); + const provided = { ...transport, async tool() { throw conflict; } }; + let selected: GalleryTransport | undefined; + function Consumer() { selected = useGalleryTransport(); return null; } + renderToStaticMarkup(); + expect(selected).toBe(provided); + try { await selected!.tool("alpha", "artifact_write", {}); throw new Error("Expected conflict"); } + catch (error) { expect(error).toBe(conflict); expect((error as GalleryToolError).result?.revision_token).toBe("newer"); } +}); diff --git a/src/gallery/workspace.tsx b/src/gallery/workspace.tsx new file mode 100644 index 0000000..ea01631 --- /dev/null +++ b/src/gallery/workspace.tsx @@ -0,0 +1,415 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Tabs } from "@base-ui/react/tabs"; +import { Menu } from "@base-ui/react/menu"; +import { DraftProvider, useDrafts, type SourceSnapshot } from "./drafts"; +import { ArtifactSourcePanel, LinkSettings, ScriptPanel, type ScriptView } from "./hosted"; +import { LibraryNavigation, type LibraryKindFilter } from "./library-navigation"; +import { ProjectEditor } from "./project-editor"; +import { RemixPanel } from "./remix"; +import { Select } from "./select"; +import { galleryStyles } from "./styles"; +import { GalleryTransportProvider, useGalleryTransport, type GalleryTransport } from "./transport"; +import type { GalleryArtifact, GalleryData } from "./types"; + +export type WorkspaceSelection = { name?: string; workspace?: string; kind?: "artifact" | "script"; version_id?: string; route?: string; tab?: "preview" | "source" }; +export type GalleryWorkspaceProps = { + initial: GalleryData; + view: "library" | "working"; + transport: GalleryTransport; + search: (query: string, offset: number) => Promise; + renderPreview: (container: HTMLElement, artifact: GalleryArtifact, version: string, route?: string) => Promise<() => void>; + attach: (artifact: GalleryArtifact, version: string) => Promise; + attachedVersionId?: string; + selection?: WorkspaceSelection; + onSelectionChange?: (selection: WorkspaceSelection) => void; + openProduct?: () => Promise; + openLink?: (url: string) => Promise; + askCreate?: () => Promise; + refreshKey?: number; +}; + +/** Catalog pages may contain different revisions of the same project. */ +export function mergeGalleryItems(previous: GalleryArtifact[], incoming: GalleryArtifact[]): GalleryArtifact[] { + const items = new Map(previous.map(item => [item.key, item])); + for (const item of incoming) { + const before = items.get(item.key); + const versions = new Map([...(before?.versions ?? []), ...item.versions].map(version => [version.id, version])); + items.set(item.key, { ...before, ...item, working: item.working || !!before?.working, versions: [...versions.values()].sort((a, b) => b.revision - a.revision) }); + } + return [...items.values()].sort((a, b) => a.name.localeCompare(b.name) || a.workspace.localeCompare(b.workspace)); +} + +export function selectedGalleryArtifact(items: GalleryArtifact[], selection?: WorkspaceSelection): GalleryArtifact | undefined { + if (!selection) return undefined; + return items.find(item => (!selection.workspace || item.workspace === selection.workspace) + && (!selection.kind || (item.kind ?? "artifact") === selection.kind) + && (selection.version_id ? item.versions.some(version => version.id === selection.version_id) : item.name === selection.name)); +} + +/** Finish pagination even when a filtered page contains no matching items. */ +export async function readWorkspaceCatalog(search: GalleryWorkspaceProps["search"], query: string, isCurrent: () => boolean = () => true): Promise { + let offset = 0; + let items: GalleryArtifact[] = []; + let page: GalleryData; + do { + page = await search(query, offset); + if (!isCurrent()) return; + items = mergeGalleryItems(items, page.artifacts); + if (page.nextOffset == null) break; + if (!Number.isSafeInteger(page.nextOffset) || page.nextOffset <= offset) throw new Error("Invalid library pagination."); + offset = page.nextOffset; + } while (true); + return { ...page, artifacts: items, nextOffset: null }; +} + +export function workspaceSourceUrl(artifact: GalleryArtifact, version: string): string { + const params = new URLSearchParams({ workspace: artifact.workspace }); + if (version === "working") params.set("name", artifact.name); + else params.set("version_id", version); + if (artifact.kind === "script") params.set("kind", "script"); + return `/api/source?${params}`; +} + +function currentVersion(artifact: GalleryArtifact): string | null { + return artifact.working ? "working" : [...artifact.versions].sort((a, b) => b.revision - a.revision)[0]?.id ?? null; +} + +/** A pinned revision remains pinned even when a refreshed catalog is incomplete. */ +export function resolveWorkspaceVersion(artifact: GalleryArtifact, requested: string | null): string | null { + return requested ?? currentVersion(artifact); +} + +function ReadOnlyProject({ artifact, version }: { artifact: GalleryArtifact; version: string }) { + const { loadSource } = useGalleryTransport(); + const [snapshot, setSnapshot] = useState(); + const [error, setError] = useState(""); + useEffect(() => { + const controller = new AbortController(); + setSnapshot(undefined); setError(""); + void loadSource(workspaceSourceUrl(artifact, version), controller.signal).then(value => { + if (!controller.signal.aborted) setSnapshot(value); + }).catch(error => { if (!controller.signal.aborted) setError(String(error)); }); + return () => controller.abort(); + }, [artifact.key, artifact.draftRevision, version, loadSource]); + if (error) return

{error}

; + if (!snapshot) return

Loading source…

; + return
{}} onEntryChange={() => {}} onValidityChange={() => {}} readOnly /> +
Read-only source · Edit this project in its connected workspace.
+
; +} + +function Preview({ artifact, version, route, render, refresh }: { + artifact: GalleryArtifact; version: string; route?: string; render: GalleryWorkspaceProps["renderPreview"]; refresh: number; +}) { + const container = useRef(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + useEffect(() => { + let cancelled = false; + let dispose: (() => void) | undefined; + setLoading(true); setError(""); + void render(container.current!, artifact, version, route).then(cleanup => { + if (cancelled) cleanup(); + else { dispose = cleanup; setLoading(false); } + }).catch(error => { if (!cancelled) { setError(String(error)); setLoading(false); } }); + return () => { cancelled = true; dispose?.(); }; + }, [artifact.key, artifact.draftRevision, version, route, render, refresh]); + return
+ {loading ?

Loading preview…

: null} + {error ?

{error}

: null} +
+
; +} + +export function GalleryWorkspace(props: GalleryWorkspaceProps) { + return ; +} + +function Workspace({ initial, view, search, renderPreview, attach, attachedVersionId, selection, onSelectionChange, openProduct, openLink, askCreate, refreshKey }: GalleryWorkspaceProps) { + const { drafts } = useDrafts(); + const initialSelection = selectedGalleryArtifact(initial.artifacts, selection); + const [gallery, setGallery] = useState(initial); + const [catalog, setCatalog] = useState(initial.artifacts); + const [query, setQuery] = useState(""); + const [kindFilter, setKindFilter] = useState("all"); + const [selectedKey, setSelectedKey] = useState(initialSelection?.key ?? (view === "library" && !selection ? initial.artifacts[0]?.key ?? null : null)); + const [version, setVersion] = useState(selection?.version_id ?? null); + const [route, setRoute] = useState(selection?.route ?? "/"); + const [tab, setTab] = useState<"preview" | ScriptView>(selection?.tab ?? "preview"); + const [sourceVisited, setSourceVisited] = useState(null); + const [choosing, setChoosing] = useState(view === "library"); + const [libraryCollapsed, setLibraryCollapsed] = useState(false); + const [mobileDetail, setMobileDetail] = useState(!!initialSelection || view === "working"); + const [narrow, setNarrow] = useState(() => typeof window !== "undefined" && window.matchMedia("(max-width: 760px)").matches); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + const [notice, setNotice] = useState(""); + const [attaching, setAttaching] = useState(false); + const [refresh, setRefresh] = useState(0); + const [disclosure, setDisclosure] = useState<"share" | "remix" | null>(null); + const [selectionLookup, setSelectionLookup] = useState<{ key: string; state: "loading" | "missing" | "error" }>(); + const searchGeneration = useRef(0); + const lookupGeneration = useRef(0); + const searchRef = useRef(null); + const headingRef = useRef(null); + const selectionKey = JSON.stringify(selection ?? null); + const appliedSelection = useRef(undefined); + const lastRefreshKey = useRef(refreshKey); + const activeSelection = useRef(selectedKey); + activeSelection.current = selectedKey; + const currentQuery = useRef(query); + currentQuery.current = query; + + useEffect(() => { + const media = window.matchMedia("(max-width: 760px)"); + const changed = () => setNarrow(media.matches); + media.addEventListener("change", changed); + return () => media.removeEventListener("change", changed); + }, []); + useEffect(() => { + setCatalog(previous => mergeGalleryItems(previous, initial.artifacts)); + if (!query) setGallery(initial); + }, [initial]); + useEffect(() => { setNotice(""); }, [attachedVersionId]); + useEffect(() => { + if (!selection || appliedSelection.current === selectionKey) return; + const item = selectedGalleryArtifact(catalog, selection); + if (!item) return; + appliedSelection.current = selectionKey; + setDisclosure(null); + setSelectedKey(item.key); setVersion(selection.version_id ?? currentVersion(item)); + setRoute(selection.route ?? "/"); setTab(selection.tab ?? (item.kind === "script" ? "source" : "preview")); + setMobileDetail(true); if (view === "working") setChoosing(false); + }, [catalog, selectionKey, view]); + useEffect(() => { + if (!selection || appliedSelection.current === selectionKey || selectedGalleryArtifact(catalog, selection)) return; + const generation = ++lookupGeneration.current; + setSelectedKey(null); setMobileDetail(true); setDisclosure(null); + setSelectionLookup({ key: selectionKey, state: "loading" }); + void readWorkspaceCatalog(search, "", () => generation === lookupGeneration.current).then(page => { + if (!page || generation !== lookupGeneration.current) return; + setCatalog(previous => mergeGalleryItems(previous, page.artifacts)); + if (!currentQuery.current) setGallery(page); + const found = selectedGalleryArtifact(page.artifacts, selection); + setSelectionLookup(found ? undefined : { key: selectionKey, state: "missing" }); + }).catch(error => { + if (generation !== lookupGeneration.current) return; + setError(error instanceof Error ? error.message : String(error)); + setSelectionLookup({ key: selectionKey, state: "error" }); + }); + return () => { if (generation === lookupGeneration.current) ++lookupGeneration.current; }; + }, [selectionKey, search]); + + const load = useCallback(async (searchQuery: string) => { + const generation = ++searchGeneration.current; + setLoading(true); setError(""); + try { + const page = await readWorkspaceCatalog(search, searchQuery, () => generation === searchGeneration.current); + if (!page) return; + setGallery(page); + setCatalog(previous => mergeGalleryItems(previous, page.artifacts)); + return page; + } catch (error) { + if (generation === searchGeneration.current) setError(error instanceof Error ? error.message : String(error)); + } finally { if (generation === searchGeneration.current) setLoading(false); } + }, [search]); + useEffect(() => { + if (lastRefreshKey.current === refreshKey) return; + lastRefreshKey.current = refreshKey; + setRefresh(value => value + 1); + void load(query); + }, [refreshKey, load, query]); + useEffect(() => { + if (!choosing) return; + if (selection && appliedSelection.current !== selectionKey && !selectedGalleryArtifact(catalog, selection)) return; + const timer = setTimeout(() => void load(query), 150); + return () => { clearTimeout(timer); ++searchGeneration.current; }; + }, [query, choosing, load]); + useEffect(() => () => { ++searchGeneration.current; }, []); + + const artifact = catalog.find(item => item.key === selectedKey); + const resolvedVersion = artifact ? resolveWorkspaceVersion(artifact, version) : null; + const activeTab = artifact?.kind === "script" && tab === "preview" ? "source" : artifact?.kind !== "script" && tab === "requests" ? "preview" : tab; + const versions = useMemo(() => [...(artifact?.versions ?? [])].sort((a, b) => b.revision - a.revision), [artifact]); + const filtered = gallery.artifacts.filter(item => (kindFilter === "all" || (item.kind ?? "artifact") === kindFilter) + && (!query || `${item.name}\n${item.workspace}`.toLocaleLowerCase().includes(query.toLocaleLowerCase()))); + const detailId = artifact && resolvedVersion ? `${artifact.key}:${resolvedVersion}` : null; + useEffect(() => { if (activeTab === "source") setSourceVisited(detailId); }, [activeTab, detailId]); + useEffect(() => { if (mobileDetail) headingRef.current?.focus(); }, [mobileDetail]); + const notifySelection = (item: GalleryArtifact, selectedVersion: string, selectedRoute = "/", selectedTab = activeTab) => { + onSelectionChange?.({ workspace: item.workspace, kind: item.kind ?? "artifact", ...(selectedVersion === "working" ? { name: item.name } : { version_id: selectedVersion }), route: selectedRoute, tab: selectedTab === "preview" ? "preview" : "source" }); + }; + function selectArtifact(item: GalleryArtifact, preferredTab?: "preview" | "source") { + ++lookupGeneration.current; + appliedSelection.current = selectionKey; + setSelectionLookup(undefined); setDisclosure(null); + const nextVersion = currentVersion(item); + setSelectedKey(item.key); setVersion(nextVersion); setRoute("/"); setNotice(""); + const nextTab = preferredTab ?? (item.kind === "script" ? "source" : "preview"); + setTab(nextTab); setMobileDetail(true); + if (view === "working") setChoosing(false); + if (nextVersion) notifySelection(item, nextVersion, "/", nextTab); + } + function chooseLibrary() { + ++lookupGeneration.current; + appliedSelection.current = selectionKey; + setSelectionLookup(undefined); setChoosing(true); setLibraryCollapsed(false); setMobileDetail(false); + if (choosing) void load(query); + } + function toggleLibrary() { + if (showLibrary && !narrow) { setLibraryCollapsed(true); setMobileDetail(true); } + else chooseLibrary(); + } + async function perform(action: () => Promise) { + setError(""); + try { await action(); } catch (error) { setError(error instanceof Error ? error.message : String(error)); } + } + async function saved() { + if (artifact && activeSelection.current === artifact.key) { + setVersion("working"); setRefresh(value => value + 1); + notifySelection(artifact, "working", route); + } + await load(query); + } + const showLibrary = choosing && !libraryCollapsed && (!narrow || !mobileDetail); + const showDetail = !narrow || mobileDetail || !choosing; + const attachedRevision = artifact?.versions.find(item => item.id === attachedVersionId); + const editing = gallery.capabilities?.editing === true; + const findingSelection = !!selection && !artifact && appliedSelection.current !== selectionKey + && (!selectionLookup || selectionLookup.key !== selectionKey || selectionLookup.state === "loading"); + + return <> + +
+ {error ?

{error}

: null} + {notice ?

{notice}

: null} + {!showDetail ?
Library{artifact ? : null}
: null} +
+ [...drafts.entries()].some(([key, draft]) => key.startsWith(`${item.key}:`) && draft.dirty)} + loading={loading} error={error} onRetry={() => void load(query)} searchRef={searchRef} hidden={!showLibrary} + footer={} /> + setTab(value as typeof tab)} className="artifact-detail" hidden={!showDetail}> +
+ +

{artifact?.name ?? (view === "working" ? "Working artifact" : "Your library")}

+ {artifact && resolvedVersion ? + {(artifact.kind === "script" ? [["source", "Source"], ...(editing ? [["requests", "Requests"]] : [])] : [["preview", "Preview"], ["source", "Source"]]).map(([value, label]) => {label})} + : null} + {artifact && resolvedVersion ?
+ +
: null} + {artifact && resolvedVersion || openProduct ?
+ + + {artifact && resolvedVersion ? <> + {artifact.kind !== "script" ? { + setAttaching(true); void perform(async () => { await attach(artifact, resolvedVersion); setNotice("Artifact context added to this conversation."); }).finally(() => setAttaching(false)); + }}>{attaching ? "Attaching…" : attachedVersionId === resolvedVersion ? "Update conversation context" : "Use in conversation"} : null} + { setRefresh(value => value + 1); void load(query); }}>Refresh + {gallery.capabilities?.links ? setDisclosure("share")}>Share : null} + {editing ? setDisclosure("remix")}>Remix : null} + : null} + {openProduct ? void perform(openProduct)}>Open library in browser : null} + {gallery.capabilities?.nativeApps && openProduct ? void perform(openProduct)}>Worker apps : null} + +
: null} +
+ {attachedRevision ?

Revision {attachedRevision.revision} attached

: attachedVersionId && attachedVersionId === resolvedVersion ?

Revision attached

: null} + {artifact && resolvedVersion && disclosure === "share" ?
+
Share {artifact.name}
+ { await load(query); }} openLink={openLink} /> +
: null} + {artifact && resolvedVersion && disclosure === "remix" ?
+ setDisclosure(null)} onSaved={async name => { + const page = await load(""); + setQuery(""); setKindFilter("all"); + const created = page?.artifacts.find(item => item.name === name && item.workspace === artifact.workspace && (item.kind ?? "artifact") === (artifact.kind ?? "artifact")); + if (created) selectArtifact(created, "source"); + else { setDisclosure(null); setNotice(`Created ${name}. Refresh the library to open it.`); } + }} /> +
: null} + {artifact && resolvedVersion !== "working" && gallery.capabilities?.links && artifact.kind !== "script" ?

Live data · Historical code uses the current database and files and can change them. Restore deploys this code while keeping current data.

: null} + + {!artifact ?
+

{findingSelection ? "Opening selected artifact…" : selectionLookup?.key === selectionKey && selectionLookup.state === "missing" ? "Artifact unavailable" : view === "working" ? "Choose an artifact for this conversation" : "Your library"}

+

{findingSelection ? "Searching the accessible library for this artifact and revision." : selectionLookup?.key === selectionKey && selectionLookup.state === "missing" ? "This artifact or revision is missing or you no longer have access. Choose an accessible item to continue." : selectionLookup?.key === selectionKey && selectionLookup.state === "error" ? "The library could not be loaded. Choose from the library to retry." : view === "working" ? "Open an artifact from your library, then choose Use in conversation to share its context." : "Select an artifact or script to open its preview, source, and history."}

+ {view === "working" ? : null} + {askCreate ? : null} +
+ : !resolvedVersion ?

No readable version

This item has no saved source available.

+ : <> + {artifact.kind !== "script" && activeTab === "preview" ? : null} + {artifact.kind === "script" && editing ? + : sourceVisited === detailId || activeTab === "source" ? : null} + } +
+
+
+
+ ; +} + +const workspaceStyles = ` + :root[data-view="workspace"] { + --page: transparent; + --panel: var(--color-background-primary, light-dark(#fff, #212121)); + --raised: var(--color-background-secondary, light-dark(#f4f4f4, #2c2c2c)); + --selected: var(--color-background-secondary, light-dark(#ededed, #303030)); + --line: var(--color-border-tertiary, light-dark(#e5e5e5, #383838)); + --text: var(--color-text-primary, light-dark(#171717, #f5f5f5)); + --muted: var(--color-text-secondary, light-dark(#666, #aaa)); + --subtle: var(--color-text-tertiary, light-dark(#777, #999)); + --focus: var(--color-ring-primary, currentColor); + } + :root[data-view="workspace"] body { background: transparent; font-family: var(--font-sans, system-ui, sans-serif); } + :root[data-view="workspace"] #artifact-viewport { scrollbar-gutter: auto; } + .mcp-gallery { height: 100%; min-height: 360px; } + .mcp-gallery .gallery-layout { grid-template-columns: 216px minmax(0, 1fr); } + .mcp-gallery .workspace-focused { grid-template-columns: minmax(0, 1fr); } + .mcp-gallery .library-panel { padding: 10px 8px 8px; border-right: 1px solid var(--line); } + .mcp-gallery .library-footer { border: 0; } + .mcp-gallery .artifact-detail { margin: 0; border: 0; border-radius: 0; background: transparent; } + .mcp-gallery .detail-header { min-height: 48px; padding: 4px 12px; gap: 8px; background: transparent; } + .mcp-gallery .detail-title h1 { font-size: 13px; letter-spacing: normal; } + .mcp-gallery .view-control { gap: 2px; } + .mcp-gallery .view-control button { height: 32px; border: 0; border-radius: 6px; font-size: 12px; } + .mcp-gallery .view-control button[data-active] { background: var(--raised); } + .mcp-gallery .workspace-library-toggle { width: 32px; padding: 0; flex-shrink: 0; color: var(--muted); } + .library-toggle-icon { display: block; width: 16px; height: 14px; border: 1.5px solid currentColor; border-radius: 3px; position: relative; } + .library-toggle-icon::before { content: ""; position: absolute; top: 0; bottom: 0; left: 4px; border-left: 1.5px solid currentColor; } + .workspace-revision { min-width: 0; } + .workspace-revision .select-control { max-width: 180px; } + .workspace-revision select { background: transparent; border-color: transparent; color: var(--muted); font-size: 12px; white-space: nowrap; overflow: hidden; } + .workspace-library-header { display: flex; align-items: center; justify-content: space-between; padding: 8px 12px; min-height: 48px; } + .workspace-notice { padding: 4px 16px; margin: 0; color: var(--muted); } + .workspace-disclosure-header { display: flex; justify-content: space-between; align-items: center; padding: 8px 16px 0; } + .workspace-preview { flex: 1; width: 100%; height: 100%; min-height: 0; } + .workspace-preview iframe { display: block; width: 100%; height: 100%; border: 0; } + .mcp-gallery .empty-detail button { margin: 8px 4px; } + @media (max-width: 760px) { + .mcp-gallery .detail-header { padding-inline: 6px; gap: 4px; } + .mcp-gallery .detail-title { flex: 1; } + .mcp-gallery .library-panel { border: 0; } + .workspace-revision .select-control { max-width: 115px; } + } + @media (max-width: 480px) { + .mcp-gallery .detail-header { flex-wrap: wrap; } + .mcp-gallery .detail-title { flex-basis: calc(100% - 44px); } + .mcp-gallery .view-control { margin-left: 36px; } + .workspace-revision { margin-left: auto; } + } +`; diff --git a/src/mcp/app-contract.ts b/src/mcp/app-contract.ts index 19a212d..a43b56a 100644 --- a/src/mcp/app-contract.ts +++ b/src/mcp/app-contract.ts @@ -1,11 +1,20 @@ -// Bump this resource version for incompatible shell or payload changes. Hosts cache by URI. -export const ARTIFACTS_APP_URI = "ui://artifacts/v2/viewer.html"; +// Bump to refresh cached shells; keep older tool-result payloads readable. +const ARTIFACTS_APP_VERSION = 6; +export const ARTIFACTS_APP_URI = `ui://artifacts/v${ARTIFACTS_APP_VERSION}/viewer.html`; + +/** Saved chat cards keep their original URI across compatible shell updates. */ +export function isArtifactAppUri(uri: unknown): uri is string { + if (uri === "ui://artifacts/viewer.html") return true; + if (typeof uri !== "string") return false; + const version = /^ui:\/\/artifacts\/v([1-9]\d*)\/viewer\.html$/.exec(uri)?.[1]; + return version !== undefined && Number(version) <= ARTIFACTS_APP_VERSION; +} export const ARTIFACTS_APP_MIME = "text/html;profile=mcp-app"; export const ARTIFACTS_APP_META = { ui: { resourceUri: ARTIFACTS_APP_URI } }; export const ARTIFACTS_RESOURCE = { uri: ARTIFACTS_APP_URI, name: "Artifacts", mimeType: ARTIFACTS_APP_MIME, _meta: { - ui: { prefersBorder: false, csp: { connectDomains: [], resourceDomains: [] } }, + ui: { prefersBorder: false, csp: { connectDomains: [], resourceDomains: [], frameDomains: [] } }, "openai/ui": { availableDisplayModes: ["inline", "fullscreen"] }, }, }; diff --git a/src/mcp/app.test.ts b/src/mcp/app.test.ts index 8d82d12..18157a7 100644 --- a/src/mcp/app.test.ts +++ b/src/mcp/app.test.ts @@ -124,6 +124,10 @@ test("official MCP client can initialize, discover UI, create and show over stan const resource = await client.readResource({ uri: ARTIFACTS_APP_URI }); expect(resource.contents[0]?.mimeType).toBe(ARTIFACTS_APP_MIME); expect(resource.contents[0]).toHaveProperty("text"); + for (const uri of ["ui://artifacts/viewer.html", ...[1, 2, 3, 4, 5].map(version => `ui://artifacts/v${version}/viewer.html`)]) { + const legacy = await client.readResource({ uri }); + expect(legacy.contents[0]).toEqual({ ...resource.contents[0], uri }); + } const created = await client.callTool({ name: "artifact_write", arguments: { name: "overview", contents: VALID_ARTIFACT } }); expect(created.isError).toBe(false); expect(created._meta).toHaveProperty("artifact"); @@ -131,5 +135,6 @@ test("official MCP client can initialize, discover UI, create and show over stan expect(shown.isError).toBe(false); expect(shown._meta).toHaveProperty("artifact"); await expect(client.readResource({ uri: "ui://artifacts/missing" })).rejects.toThrow("unknown resource"); + await expect(client.readResource({ uri: "ui://artifacts/v999/viewer.html" })).rejects.toThrow("unknown resource"); } finally { await client.close(); } }); diff --git a/src/mcp/gallery-contract.ts b/src/mcp/gallery-contract.ts new file mode 100644 index 0000000..1421155 --- /dev/null +++ b/src/mcp/gallery-contract.ts @@ -0,0 +1,34 @@ +import type { Tool } from "@modelcontextprotocol/sdk/types.js"; + +/** Operations used by the existing artifact/script gallery panels, not a proxy + * for arbitrary MCP tools. The model-facing tools keep their existing visibility. */ +export const GALLERY_TOOL_NAMES = [ + "artifact_write", "artifact_restore", "artifact_remix", "artifact_import", "artifact_export", "artifact_link", + "script_write", "script_restore", "script_remix", "script_import", "script_export", "script_run", "script_logs", + "script_schedule", "script_runs", "artifact_schedule", "artifact_runs", "artifact_secrets", "script_secrets", +] as const; + +export function galleryToolDefinition(tools: readonly Tool[]): Tool { + const operations = GALLERY_TOOL_NAMES.map(name => { + const tool = tools.find(tool => tool.name === name); + if (!tool) throw new Error(`Missing gallery operation: ${name}`); + // Reuse the authoritative schema so the proxy cannot skip field validation. + // Workspace belongs to the outer envelope and cannot be overridden inside it. + const schema = structuredClone(tool.inputSchema); + delete schema.properties?.workspace; + return { properties: { tool: { const: name }, arguments: schema }, required: ["tool", "arguments"] }; + }); + return { + name: "artifacts_tool", title: "Artifact workspace action", + description: "Run an existing artifact or script gallery action in the selected authorized workspace. Validates the original tool schema and preserves save diagnostics and revision conflicts.", + annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true }, + _meta: { ui: { visibility: ["app"] } }, + inputSchema: { + type: "object", properties: { + workspace: { type: "string", minLength: 1 }, + tool: { type: "string", enum: [...GALLERY_TOOL_NAMES] }, + arguments: { type: "object" }, + }, required: ["workspace", "tool", "arguments"], additionalProperties: false, oneOf: operations, + }, + }; +} diff --git a/src/mcp/local-tools.ts b/src/mcp/local-tools.ts index 7450e05..f1abc57 100644 --- a/src/mcp/local-tools.ts +++ b/src/mcp/local-tools.ts @@ -1,8 +1,9 @@ import { ArtifactService, type ArtifactEdit, type ReadOptions } from "../service"; +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import { artifactIdFromFile } from "../artifactFile"; import { artifactGuideResult } from "./guide"; import type { JsonRpcRequest, JsonRpcResponse } from "./protocol"; -import { ARTIFACTS_APP_URI, ARTIFACTS_RESOURCE, artifactAppHtml, artifactAppResult } from "./app"; +import { isArtifactAppUri, ARTIFACTS_RESOURCE, artifactAppHtml, artifactAppResult } from "./app"; const PROTOCOL_VERSION = "2025-06-18"; @@ -10,9 +11,10 @@ export { MCP_TOOLS } from "./tools"; import { MCP_TOOLS } from "./tools"; import { parseProjectArchive } from "../project-archive"; import { ARTIFACTS_WORKSPACE_TOOLS, workspaceResult } from "./workspace-contract"; -import { localWorkspace, localMentions, localResource } from "./local-workspace"; +import { localWorkspace, localMentions, localResource, localSource } from "./local-workspace"; import { ARTIFACTS_FILE_TOOL, artifactFileResult } from "./file-contract"; import { clientSupportsApps, toolsForClient } from "./host-contract"; +import { ARTIFACTS_SETTINGS_CAPABILITY, ARTIFACTS_SETTINGS_TOOLS, settingsTool } from "./settings-contract"; const appClients = new WeakMap(); @@ -30,7 +32,7 @@ export async function handleMcpRequest( appClients.set(service, clientSupportsApps(params?.capabilities)); return ok(request.id, { protocolVersion: PROTOCOL_VERSION, - capabilities: { tools: {}, resources: {} }, + capabilities: { tools: {}, resources: {}, extensions: { "openai/settings": ARTIFACTS_SETTINGS_CAPABILITY }, experimental: { "openai/settings": ARTIFACTS_SETTINGS_CAPABILITY } }, serverInfo: { name: "artifacts", version: "0.1.0" }, }); } @@ -38,15 +40,15 @@ export async function handleMcpRequest( return ok(request.id, {}); } if (request.method === "tools/list") { - return ok(request.id, { tools: toolsForClient([...MCP_TOOLS, ...ARTIFACTS_WORKSPACE_TOOLS, ARTIFACTS_FILE_TOOL], appClients.get(service) ?? true) }); + return ok(request.id, { tools: toolsForClient([...MCP_TOOLS, ...ARTIFACTS_WORKSPACE_TOOLS, ARTIFACTS_FILE_TOOL, ...ARTIFACTS_SETTINGS_TOOLS], appClients.get(service) ?? true) }); } if (request.method === "resources/list") return ok(request.id, { resources: [ARTIFACTS_RESOURCE] }); if (request.method === "resources/templates/list") return ok(request.id, { resourceTemplates: [] }); if (request.method === "resources/read") { const params = request.params as { uri?: string } | undefined; if (params?.uri?.startsWith("artifact:")) return ok(request.id, { contents: [{ uri: params.uri, mimeType: "application/json", text: JSON.stringify(localResource(service, params.uri)) }] }); - if (params?.uri !== ARTIFACTS_APP_URI) return error(request.id, -32002, "unknown resource"); - return ok(request.id, { contents: [{ ...ARTIFACTS_RESOURCE, text: await artifactAppHtml() }] }); + if (!isArtifactAppUri(params?.uri)) return error(request.id, -32002, "unknown resource"); + return ok(request.id, { contents: [{ ...ARTIFACTS_RESOURCE, uri: params.uri, text: await artifactAppHtml() }] }); } if (request.method === "tools/call") { const params = (request.params ?? {}) as { name?: string; arguments?: Record }; @@ -66,8 +68,13 @@ async function callTool( service: ArtifactService, name: string, args: Record, -): Promise<{ content: Array<{ type: "text"; text: string }>; isError?: boolean; structuredContent?: Record; _meta?: Record }> { +): Promise { if (name === "artifacts_file") return artifactFileResult(args); + if (ARTIFACTS_SETTINGS_TOOLS.some(tool => tool.name === name)) return settingsTool({ workspace: service.artifactsDir, async gallery() { return localWorkspace(service, "library").gallery!; } }, name, args); + if (name === "artifacts_source") { + const payload = localSource(service, args); + return { ...text(JSON.stringify(payload)), structuredContent: payload }; + } if (name === "artifacts_library" || name === "artifacts_working" || name === "artifacts_search") { const view = name === "artifacts_working" || args.view === "working" ? "working" : "library"; return workspaceResult(localWorkspace(service, view, args.query as string | undefined, args.offset as number | undefined)); diff --git a/src/mcp/local-workspace.ts b/src/mcp/local-workspace.ts index 66b4d21..c8ad8a5 100644 --- a/src/mcp/local-workspace.ts +++ b/src/mcp/local-workspace.ts @@ -1,44 +1,55 @@ import { resolve } from "node:path"; import type { ArtifactService } from "../service"; -import { artifactResourceUri, type ArtifactWorkspaceItem, type ArtifactWorkspacePayload } from "./workspace-contract"; +import { artifactResourceUri, parseArtifactResourceUri, type ArtifactWorkspaceItem, type ArtifactWorkspacePayload } from "./workspace-contract"; +import { readLocalServer } from "../localProject"; +import { projectRevision, emptyProject } from "../../cloudflare/project"; export function localWorkspace(service: ArtifactService, view: "library" | "working", query = "", offset = 0): ArtifactWorkspacePayload { if (!Number.isSafeInteger(offset) || offset < 0) throw new Error("offset must be a nonnegative integer"); if (typeof query !== "string" || query.length > 200) throw new Error("query must be at most 200 characters"); const workspace = resolve(service.artifactsDir); const items = new Map(); - for (const file of service.list()) items.set(file.id, { name: file.id, workspace, working: true, versions: [] }); + for (const file of service.list()) items.set(file.id, { name: file.id, workspace, kind: "artifact", working: true, versions: [] }); for (const version of service.history()) { - const item = items.get(version.name) ?? { name: version.name, workspace, working: false, versions: [] }; - item.versions.push({ id: version.version_id, revision: version.revision, createdAt: version.created_at }); + const item = items.get(version.name) ?? { name: version.name, workspace, kind: "artifact", working: false, versions: [] }; + item.versions.push({ id: version.version_id, revision: version.revision, createdAt: version.created_at, reason: version.reason, serveCount: version.serve_count }); items.set(item.name, item); } const filtered = [...items.values()].filter(item => item.name.toLocaleLowerCase().includes(query.toLocaleLowerCase())) .sort((a, b) => a.name.localeCompare(b.name)); - return { view, workspace, items: filtered.slice(offset, offset + 100), nextOffset: filtered.length > offset + 100 ? offset + 100 : null }; + const page = filtered.slice(offset, offset + 100); + const nextOffset = filtered.length > offset + 100 ? offset + 100 : null; + return { view, workspace, items: page, nextOffset, gallery: { workspace, nextOffset, capabilities: { editing: false }, artifacts: page.map(item => ({ ...item, key: JSON.stringify([workspace, item.name]), versions: item.versions.map(version => ({ ...version, reason: version.reason ?? "saved", serveCount: version.serveCount ?? 0 })) })) } }; } export function localMentions(service: ArtifactService, query: string) { return localWorkspace(service, "library", query).items.map(item => ({ type: "resource_link" as const, uri: artifactResourceUri(item, item.working ? undefined : item.versions[0]?.id), - name: item.name, title: item.name, mimeType: "application/json", + name: item.name, title: `${item.name} · artifact · ${item.workspace}`, mimeType: "application/json", description: "React artifact source and revision metadata in the connected workspace", })); } export function localResource(service: ArtifactService, uri: string) { - const url = new URL(uri); - if (url.protocol !== "artifact:" || url.hostname !== "project" || url.pathname || url.hash - || [...url.searchParams.keys()].some(key => !["workspace", "name", "version_id"].includes(key))) throw new Error("unknown artifact resource"); - const workspace = url.searchParams.get("workspace"); - const name = url.searchParams.get("name"); - const versionId = url.searchParams.get("version_id"); - if (workspace !== resolve(service.artifactsDir) || !name) throw new Error("artifact resource is outside this workspace"); - if (versionId) { - const saved = service.version(versionId); + const { workspace, name, kind, version_id } = parseArtifactResourceUri(uri); + if (workspace !== resolve(service.artifactsDir)) throw new Error("artifact resource is outside this workspace"); + if (kind !== "artifact") throw new Error("Scripts require the hosted artifact service"); + if (version_id) { + const saved = service.version(version_id); if (saved.name !== name) throw new Error("artifact resource version does not match its name"); - return { name, workspace, version_id: saved.id, revision: saved.revision, source: saved.source, source_hash: saved.source_hash }; + const server_source = saved.server_source ?? null; + const project = saved.project ?? emptyProject(); + return { ...saved, name, workspace, kind, version_id: saved.id, server_source, project, revision_token: projectRevision(saved.source, server_source, project) }; } const current = service.readRange(name, { end_line: Number.MAX_SAFE_INTEGER }); - return { name, workspace, source: current.source, source_hash: current.source_hash }; + const server_source = readLocalServer(current.path); + return { ...current, name, workspace, kind, server_source, revision_token: projectRevision(current.source, server_source, current.project) }; +} + +export function localSource(service: ArtifactService, args: Record) { + if (args.workspace !== resolve(service.artifactsDir)) throw new Error("artifact resource is outside this workspace"); + if (args.kind !== "artifact") throw new Error("Scripts require the hosted artifact service"); + if ((typeof args.name === "string") === (typeof args.version_id === "string")) throw new Error("Select name or version_id, but not both"); + const name = typeof args.name === "string" ? args.name : service.version(args.version_id as string).name; + return localResource(service, artifactResourceUri({ name, workspace: args.workspace as string }, args.version_id as string | undefined)); } diff --git a/src/mcp/settings-contract.ts b/src/mcp/settings-contract.ts new file mode 100644 index 0000000..f486147 --- /dev/null +++ b/src/mcp/settings-contract.ts @@ -0,0 +1,37 @@ +import type { CallToolResult, Tool } from "@modelcontextprotocol/sdk/types.js"; +import type { GalleryData } from "../gallery/types"; + +export const ARTIFACTS_SETTINGS_CAPABILITY = { readTool: "artifacts_settings_read", updateTool: "artifacts_settings_update" }; +const emptySchema = { type: "object" as const, properties: {}, additionalProperties: false }; +const appOnly = { ui: { visibility: ["app"] } }; +const readOnly = { readOnlyHint: true, destructiveHint: false, openWorldHint: false }; + +/** Connection configuration belongs to the configured MCP server. Native + * settings expose that context and real actions without pretending to edit it. */ +export const ARTIFACTS_SETTINGS_TOOLS: Tool[] = [ + { name: "artifacts_settings_read", title: "Artifacts connection", description: "Read the current connection and workspace settings actions.", + annotations: readOnly, _meta: appOnly, inputSchema: emptySchema, + outputSchema: { type: "object", properties: { schema: { type: "object" }, layout: { type: "array" }, values: { type: "object" } }, required: ["schema", "values"] } }, + { name: "artifacts_settings_update", description: "Acknowledge native settings updates. Connection configuration is managed by the MCP host.", + annotations: readOnly, _meta: appOnly, inputSchema: { type: "object", properties: { set: emptySchema }, required: ["set"], additionalProperties: false }, + outputSchema: { type: "object", properties: { values: { type: "object" } }, required: ["values"] } }, + { name: "artifacts_connection_check", title: "Check connection", description: "Check authorized gallery access and report the connected workspace and enabled capabilities. Does not execute artifacts or scripts.", + annotations: readOnly, _meta: appOnly, inputSchema: emptySchema }, +]; + +export async function settingsTool(service: { workspace: string; productUrl?: string; gallery(all: boolean): Promise }, name: string, args: Record): Promise { + let payload: Record; + if (name === "artifacts_settings_read") { + payload = { schema: emptySchema, values: {}, layout: [{ kind: "group", title: `Workspace: ${service.workspace}`, items: [{ + kind: "tool", tool: "artifacts_connection_check", title: "Check connection", + description: service.productUrl ? `Connected to ${service.productUrl}. Connection and workspace are managed by the MCP host.` : "Connected to the local filesystem. Workspace is managed by the MCP host.", + }] }] }; + } else if (name === "artifacts_settings_update") { + if (!args.set || typeof args.set !== "object" || Array.isArray(args.set) || Object.keys(args.set).length) throw new Error("Connection settings are managed by the MCP host"); + payload = { values: {} }; + } else if (name === "artifacts_connection_check") { + const gallery = await service.gallery(true); + payload = { ok: true, workspace: service.workspace, ...(service.productUrl ? { productUrl: service.productUrl } : {}), capabilities: gallery.capabilities ?? {}, visibleEntriesOnFirstPage: gallery.artifacts.length }; + } else throw new Error("Unknown settings tool"); + return { content: [{ type: "text", text: JSON.stringify(payload) }], structuredContent: payload }; +} diff --git a/src/mcp/tool-result.ts b/src/mcp/tool-result.ts new file mode 100644 index 0000000..a7a8742 --- /dev/null +++ b/src/mcp/tool-result.ts @@ -0,0 +1,17 @@ +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; + +/** Keep operation failures machine-readable across JSON-RPC and HTTP wrappers. */ +export function toolErrorResult(error: unknown): CallToolResult { + const message = error instanceof Error ? error.message : String(error); + const details = error && typeof error === "object" ? error as Record : {}; + const conflict = message.startsWith("Project changed since") || details.status === 409; + const payload = { + ok: false, error: message, + ...(typeof details.status === "number" ? { status: details.status } : { status: conflict ? 409 : /not found/.test(message) ? 404 : 400 }), + ...(conflict ? { conflict: true, applied: false } : {}), + ...("applied" in details ? { applied: details.applied } : {}), + ...(Array.isArray(details.diagnostics) ? { diagnostics: details.diagnostics } : {}), + ...(typeof details.revision_token === "string" ? { revision_token: details.revision_token } : {}), + }; + return { content: [{ type: "text", text: JSON.stringify(payload) }], structuredContent: payload, isError: true }; +} diff --git a/src/mcp/workspace-contract.ts b/src/mcp/workspace-contract.ts index 0e39aea..cfee892 100644 --- a/src/mcp/workspace-contract.ts +++ b/src/mcp/workspace-contract.ts @@ -1,16 +1,20 @@ import { ARTIFACTS_APP_URI } from "./app-contract"; +import type { GalleryData } from "../gallery/types"; export type ArtifactWorkspaceItem = { name: string; workspace: string; working: boolean; - versions: Array<{ id: string; revision: number; createdAt: string }>; + kind?: "artifact" | "script"; + versions: Array<{ id: string; revision: number; createdAt: string; reason?: string; serveCount?: number }>; }; export type ArtifactWorkspacePayload = { view: "library" | "working"; workspace: string; items: ArtifactWorkspaceItem[]; nextOffset: number | null; + gallery?: GalleryData; + productUrl?: string; }; const ui = { resourceUri: ARTIFACTS_APP_URI, visibility: ["app"] }; @@ -20,7 +24,7 @@ const emptySchema = { type: "object", properties: {}, additionalProperties: fals /** Entrypoints never mutate the library and accept the host's empty arguments. */ export const ARTIFACTS_WORKSPACE_TOOLS = [ { - name: "artifacts_library", title: "Artifacts", description: "Browse accessible React artifacts and their saved versions.", + name: "artifacts_library", title: "Artifacts", description: "Browse accessible artifacts, scripts and their saved versions.", annotations: readOnly, _meta: { ui, "openai/ui": { entrypoints: [{ type: "global" }] } }, inputSchema: emptySchema, @@ -32,29 +36,49 @@ export const ARTIFACTS_WORKSPACE_TOOLS = [ inputSchema: emptySchema, }, { - name: "artifacts_search", description: "Browse accessible artifacts. Used by the library UI for filtering and pagination.", + name: "artifacts_search", description: "Browse accessible artifact and script projects. Used by the library UI for filtering and pagination.", annotations: readOnly, _meta: { ui: { visibility: ["app"] } }, inputSchema: { type: "object", properties: { query: { type: "string", maxLength: 200 }, offset: { type: "integer", minimum: 0 }, view: { type: "string", enum: ["library", "working"] } }, additionalProperties: false }, }, + { + name: "artifacts_source", description: "Read the complete authorized artifact or script project for the editor, including client, server, helper files, dependencies and revision token. Never executes source.", + annotations: readOnly, _meta: { ui: { visibility: ["app"] } }, + inputSchema: { type: "object", properties: { workspace: { type: "string", minLength: 1 }, kind: { type: "string", enum: ["artifact", "script"] }, name: { type: "string", minLength: 1 }, version_id: { type: "string", minLength: 1 } }, required: ["workspace", "kind"], oneOf: [{ required: ["name"] }, { required: ["version_id"] }], additionalProperties: false }, + }, { name: "artifacts_preview", description: "Open an accessible React artifact revision in the working view. Does not change source or live data.", annotations: readOnly, _meta: { ui }, inputSchema: { type: "object", properties: { name: { type: "string", minLength: 1 }, workspace: { type: "string", minLength: 1 }, version_id: { type: "string", minLength: 1 } }, oneOf: [{ required: ["name"] }, { required: ["version_id"] }], additionalProperties: false }, }, { - name: "artifacts_mentions", description: "Search accessible artifacts for composer mentions.", + name: "artifacts_mentions", description: "Search accessible artifact and script projects for composer mentions.", annotations: readOnly, _meta: { ui: { visibility: ["app"] }, "openai/extensions": { "mentions/search": {} } }, inputSchema: { type: "object", properties: { query: { type: "string", maxLength: 200 } }, required: ["query"], additionalProperties: false }, }, ]; -export function artifactResourceUri(item: Pick, versionId?: string): string { - const query = new URLSearchParams({ workspace: item.workspace, name: item.name }); +export function artifactResourceUri(item: Pick, versionId?: string): string { + const query = new URLSearchParams({ workspace: item.workspace, kind: item.kind ?? "artifact", name: item.name }); if (versionId) query.set("version_id", versionId); return `artifact://project?${query}`; } +/** Resource identity is descriptive; every read must repeat service authorization. */ +export function parseArtifactResourceUri(uri: string) { + const url = new URL(uri); + const allowed = new Set(["workspace", "kind", "name", "version_id"]); + if (url.protocol !== "artifact:" || url.hostname !== "project" || url.pathname || url.hash + || [...url.searchParams.keys()].some(key => !allowed.has(key) || url.searchParams.getAll(key).length !== 1)) throw new Error("unknown artifact resource"); + const workspace = url.searchParams.get("workspace"); + const name = url.searchParams.get("name"); + const kind = url.searchParams.get("kind") ?? "artifact"; + if (!workspace || !name || (kind !== "artifact" && kind !== "script")) throw new Error("invalid artifact resource"); + const version_id = url.searchParams.get("version_id"); + if (version_id === "") throw new Error("invalid artifact version"); + return { workspace, kind, name, ...(version_id ? { version_id } : {}) } as { workspace: string; kind: "artifact" | "script"; name: string; version_id?: string }; +} + export function workspaceResult(payload: ArtifactWorkspacePayload) { return { content: [{ type: "text" as const, text: JSON.stringify({ workspace: payload.workspace, artifacts: payload.items, next_offset: payload.nextOffset }) }], diff --git a/src/mcp/workspace.test.ts b/src/mcp/workspace.test.ts index 865604d..a7b56af 100644 --- a/src/mcp/workspace.test.ts +++ b/src/mcp/workspace.test.ts @@ -17,7 +17,7 @@ test("library entrypoints accept empty args and mention resources stay in the co const { dir, service, request } = fixture(); service.write("revenue", VALID_ARTIFACT); const response = await request("tools/call", { name: "artifacts_library", arguments: {} }); - expect(response?.result).toMatchObject({ _meta: { workspace: { view: "library", items: [{ name: "revenue", workspace: dir, working: true }] } } }); + expect(response?.result).toMatchObject({ _meta: { workspace: { view: "library", items: [{ name: "revenue", workspace: dir, working: true }], gallery: { capabilities: { editing: false } } } } }); const mentions = (await request("tools/call", { name: "artifacts_mentions", arguments: { query: "reven" } }))?.result as { structuredContent: { items: { uri: string }[] } }; const uri = mentions.structuredContent.items[0]!.uri; expect(localResource(service, uri)).toMatchObject({ name: "revenue", source: VALID_ARTIFACT }); diff --git a/src/runtime/mcp-app.ts b/src/runtime/mcp-app.ts index b6994ec..bedfee4 100644 --- a/src/runtime/mcp-app.ts +++ b/src/runtime/mcp-app.ts @@ -1,6 +1,6 @@ import { App, type McpUiHostContext } from "@modelcontextprotocol/ext-apps"; import type { ArtifactAppPayload } from "../mcp/app"; -import type { ArtifactAction, HostBridge } from "../sdk/hooks"; +import type { HostBridge } from "../sdk/hooks"; import type { ArtifactHttpRequest, ArtifactHttpResponse } from "../sdk/server"; import { artifactFileTransferUrl } from "../sdk/files"; import { createMcpHost } from "./mcp-host"; @@ -9,6 +9,8 @@ import { fetchArtifactPreview, mountWorkspace, parseArtifactDeepLink, type Artif import type { ArtifactWorkspacePayload } from "../mcp/workspace-contract"; import type { ArtifactModelContext } from "../sdk/hooks"; import { ARTIFACTS_DISPLAY_MODES } from "../mcp/host-contract"; +import { mountMcpPreview } from "./mcp-preview"; +import type { GalleryArtifact } from "../gallery/types"; // Measure intrinsic content rather than the iframe's document height, so views // can grow and shrink even when the host clamps or ignores a resize request. @@ -23,7 +25,6 @@ const toolbar = document.getElementById("artifact-toolbar")!; const displayButton = document.getElementById("display-mode") as HTMLButtonElement; let script: HTMLScriptElement | undefined; let activeArtifact: ArtifactAppPayload | undefined; -let disposeView: (() => void) | undefined; let viewDisposal: Promise = Promise.resolve(); let fileEditor: ReturnType | undefined; let activeDeepLink: string | undefined; @@ -31,6 +32,11 @@ let pendingContextId: string | undefined; let opening = 0; let contextEpoch = 0; let lastWorkspace: ArtifactWorkspacePayload | undefined; +let workspaceView: ReturnType | undefined; +let selectedArtifact: ArtifactSelection | undefined; +const activeBridges = new Set(); +const previews = new Set>(); +const previewState = new Map>(); const controls = document.createElement("nav"); controls.setAttribute("aria-label", "Working artifact controls"); controls.hidden = true; @@ -56,7 +62,7 @@ function reportSize() { if (sizeFrame) return; sizeFrame = requestAnimationFrame(() => { sizeFrame = 0; - // Fullscreen/PiP dimensions belong to the host, not the artifact content. + // Fullscreen dimensions belong to the host, not the artifact content. if (!connected || (context.displayMode && context.displayMode !== "inline")) return; const contentHeight = Math.max(root.getBoundingClientRect().height, root.scrollHeight) + status.getBoundingClientRect().height + (controls.hidden ? 0 : controls.getBoundingClientRect().height) @@ -72,11 +78,20 @@ function reportSize() { const resizeObserver = new ResizeObserver(reportSize); for (const element of [shell, viewport, root, status, toolbar, controls]) resizeObserver.observe(element); +function reportViewportSize() { + // Moving a native view from its tab back into the chat can resize the frame + // after the mode notification. Reassert our inline size even if the content + // did not change; the host may have restored an older intrinsic height. + lastHeight = undefined; + reportSize(); +} +window.addEventListener("resize", reportViewportSize); + function updateDisplay() { const mode = context.displayMode ?? "inline"; document.documentElement.dataset.displayMode = mode; const target = mode === "fullscreen" ? "inline" : "fullscreen"; - toolbar.hidden = !(hasArtifact || mode === "fullscreen") || !context.availableDisplayModes?.includes(target); + toolbar.hidden = document.documentElement.dataset.view === "workspace" || document.documentElement.dataset.view === "file" || !(hasArtifact || mode === "fullscreen") || !context.availableDisplayModes?.includes(target); displayButton.title = mode === "fullscreen" ? "Exit fullscreen" : "Expand artifact"; displayButton.setAttribute("aria-label", mode === "fullscreen" ? "Exit fullscreen" : "Expand artifact"); displayButton.disabled = changingMode; @@ -97,25 +112,25 @@ function applyContext(update: McpUiHostContext) { context = { ...context, ...update }; host.applyContext(context); const attachment = host.extensions.modelContext?.getCurrent(); - if (hostWindow.__artifacts && attachment !== undefined) { - const bridge = hostWindow.__artifacts; + for (const bridge of activeBridges) { + if (attachment === undefined) continue; const identity = attachment?.structuredContent?.artifact as { version_id?: string } | undefined; - bridge.contextAttached = attachment !== null && identity?.version_id === activeArtifact?.versionId; + bridge.contextAttached = attachment !== null && identity?.version_id === bridgeVersions.get(bridge); if (!attachment) { contextEpoch++; bridge.modelContext = null; pendingContextId = undefined; } else if (!pendingContextId || attachment.updateId === pendingContextId) { bridge.modelContext = (bridge.contextAttached ? attachment.structuredContent?.view ?? null : null) as ArtifactModelContext | null; } } + const attachedIdentity = attachment?.structuredContent?.artifact as { version_id?: string } | undefined; + workspaceView?.update({ attachedVersionId: attachedIdentity?.version_id }); const deepLink = host.extensions.deepLink.getCurrent()?.url; if (deepLink && deepLink !== activeDeepLink) { activeDeepLink = deepLink; const selection = parseArtifactDeepLink(deepLink); if (selection) void openSelection(selection).catch(error => { status.textContent = String(error); }); } - if (hostWindow.__artifacts) { - hostWindow.__artifacts.environment = { locale: context.locale, timeZone: context.timeZone }; - window.dispatchEvent(new Event("artifact-host-context-change")); - } + for (const bridge of activeBridges) bridge.environment = { locale: context.locale, timeZone: context.timeZone }; + window.dispatchEvent(new Event("artifact-host-context-change")); if (previousMode !== context.displayMode) lastHeight = undefined; theme(context.theme); updateDisplay(); @@ -141,21 +156,15 @@ displayButton.addEventListener("click", async () => { function theme(kind?: string) { const value = kind === "light" ? "light" : "dark"; + document.documentElement.dataset.theme = value; document.documentElement.style.setProperty("--artifact-background", value === "light" ? "#ffffff" : "#181818"); document.documentElement.style.setProperty("--artifact-foreground", value === "light" ? "#181818" : "#f0f0f0"); - if (hostWindow.__artifacts) hostWindow.__artifacts.theme = { kind: value }; + for (const bridge of activeBridges) bridge.theme = { kind: value }; + for (const preview of previews) preview.update(); window.dispatchEvent(new Event("artifact-theme-change")); window.dispatchEvent(new Event("canvas-theme-change")); } -async function action(value: ArtifactAction) { - try { - const result: { isError?: boolean } = await host.action(value); - if (result.isError) throw new Error("The chat host declined this artifact action."); - status.textContent = ""; - } catch (error) { status.textContent = error instanceof Error ? error.message : String(error); } -} - async function serverRequest(artifact: ArtifactAppPayload, request: ArtifactHttpRequest): Promise { const result = await app.callServerTool({ name: "artifact_request", @@ -171,8 +180,11 @@ async function serverRequest(artifact: ArtifactAppPayload, request: ArtifactHttp } function clear() { - disposeView?.(); - disposeView = undefined; + workspaceView?.dispose(); + workspaceView = undefined; + for (const preview of previews) preview.dispose(); + previews.clear(); + activeBridges.clear(); if (fileEditor) { const disposal = fileEditor.dispose(); viewDisposal = Promise.all([viewDisposal, disposal]).then(() => {}); @@ -189,47 +201,60 @@ function clear() { root.replaceChildren(); viewport.scrollTo(0, 0); hasArtifact = false; + document.documentElement.dataset.view = "artifact"; + root.style.removeProperty("padding"); + root.style.removeProperty("height"); + viewport.style.removeProperty("overflow"); updateDisplay(); } async function openSelection(selection: ArtifactSelection) { if (fileEditor?.hasUnsavedChanges()) throw new Error("Save or discard the file draft before opening another view."); if (!host.capabilities().serverTools) throw new Error("Browsing artifacts is unavailable in this host."); + selectedArtifact = selection; + if (workspaceView) { workspaceView.update({ selection }); return; } const request = ++opening; const artifact = await fetchArtifactPreview(app, selection); if (request !== opening) return; renderArtifact(artifact, selection.route, true); } -function renderArtifact(artifact: ArtifactAppPayload, route?: string, preserve = false) { - const previous = hostWindow.__artifacts; - const compatible = preserve && activeArtifact?.name === artifact.name && activeArtifact?.workspace === artifact.workspace; - const state = compatible ? { ...artifact.state, ...previous?.state } : artifact.state; +const bridgeVersions = new WeakMap(); + +function createArtifactBridge(artifact: ArtifactAppPayload, route?: string, previous?: Pick) { const restored = host.extensions.modelContext?.getCurrent(); - const restoredIdentity = restored?.structuredContent?.artifact as { version_id?: string } | undefined; - const restore = restoredIdentity?.version_id === artifact.versionId; - const modelContext = compatible ? previous?.modelContext : restore ? (restored?.structuredContent?.view ?? null) as ArtifactModelContext | null : null; - const contextAttached = compatible ? previous?.contextAttached && activeArtifact?.versionId === artifact.versionId : restore; - const previousRoute = compatible ? previous?.route?.path : undefined; - clear(); - activeArtifact = artifact; + const identity = restored?.structuredContent?.artifact as { version_id?: string } | undefined; + const attached = identity?.version_id === artifact.versionId; const capabilities = host.capabilities(); - const bridge: HostBridge & { canvasId: string } = { - canvasId: artifact.name, artifactId: artifact.name, state, - route: { path: route ?? previousRoute ?? "/", basePath: "", external: true, transport: "mcp" }, - modelContext, contextAttached, - actions: capabilities.actions, environment: { locale: context.locale, timeZone: context.timeZone }, - onAction: value => { void action(value); }, + const bridge: HostBridge = { + artifactId: artifact.name, + state: { ...artifact.state, ...previous?.state }, + route: { path: route ?? previous?.route?.path ?? "/", basePath: "", external: true, transport: "mcp" }, + modelContext: attached ? (restored?.structuredContent?.view ?? null) as ArtifactModelContext | null : null, + contextAttached: attached, + theme: { kind: context.theme === "light" ? "light" : "dark" }, + actions: capabilities.actions, + environment: { locale: context.locale, timeZone: context.timeZone }, + onAction: value => host.action(value).then(result => { + if (result.isError) throw new Error("The chat host declined this artifact action."); + }), }; + activeBridges.add(bridge); + bridgeVersions.set(bridge, artifact.versionId); if (capabilities.modelContext) bridge.onModelContext = async view => { const epoch = contextEpoch; const snapshot = view === null ? undefined : { artifact: { name: artifact.name, workspace: artifact.workspace, version_id: artifact.versionId, revision: artifact.revision }, view }; - const result = await host.updateContext(snapshot ? { structuredContent: snapshot, content: [{ type: "text", text: JSON.stringify(snapshot) }] } : { content: [], structuredContent: {} }); - // A late acknowledgement cannot attach context to a replacement view. - if (hostWindow.__artifacts !== bridge || epoch !== contextEpoch) return; + const result = await host.updateContext(snapshot ? { + structuredContent: snapshot, + content: [{ type: "text", text: JSON.stringify(snapshot), _meta: { "openai/title": artifact.name } }], + } : { content: [], structuredContent: {} }); + if (!activeBridges.has(bridge) || epoch !== contextEpoch) return; pendingContextId = result?.updateId; bridge.modelContext = view; bridge.contextAttached = view !== null; + workspaceView?.update({ attachedVersionId: view !== null ? artifact.versionId : undefined }); + attachButton.textContent = view !== null ? "Update conversation context" : "Use in conversation"; + for (const preview of previews) preview.update(); window.dispatchEvent(new Event("artifact-host-context-change")); }; if (artifact.server === true && capabilities.serverTools) bridge.onRequest = request => serverRequest(artifact, request); @@ -252,48 +277,110 @@ function renderArtifact(artifact: ArtifactAppPayload, route?: string, preserve = if (!structured || !Object.hasOwn(structured, "result")) throw new Error("Plugin result was missing"); return structured.result; }; + return bridge; +} + +function renderArtifact(artifact: ArtifactAppPayload, route?: string, preserve = false) { + const previous = preserve && activeArtifact?.name === artifact.name && activeArtifact?.workspace === artifact.workspace ? hostWindow.__artifacts : undefined; + clear(); + activeArtifact = artifact; + const bridge = createArtifactBridge(artifact, route, previous); hostWindow.__artifacts = hostWindow.__herdrCanvas = bridge; hasArtifact = true; controls.hidden = !preserve; libraryButton.hidden = !lastWorkspace; - attachButton.hidden = !capabilities.modelContext; + attachButton.hidden = !host.capabilities().modelContext; theme(context.theme); updateDisplay(); status.textContent = ""; - // The host's MCP Apps sandbox/CSP contains this locally authored artifact. - // Inline modules avoid eval, external assets, loopback fetches and blob URLs. + // Inline tool results render the authored artifact immediately. script = document.createElement("script"); script.type = "module"; script.textContent = artifact.js; document.body.append(script); } + +async function renderWorkspacePreview(container: HTMLElement, item: GalleryArtifact, version: string, route?: string) { + const selection = { workspace: item.workspace, ...(version === "working" ? { name: item.name } : { version_id: version }) }; + const artifact = await fetchArtifactPreview(app, selection); + if (!container.isConnected) return () => {}; + const key = JSON.stringify([item.key, version]); + const previous = previewState.get(key); + const bridge = createArtifactBridge(artifact, previous?.route?.path ?? route, previous); + const preview = mountMcpPreview(container, artifact.js, bridge, message => { status.textContent = message; }); + previews.add(preview); + return () => { + previewState.set(key, { state: bridge.state, route: bridge.route }); + activeBridges.delete(bridge); previews.delete(preview); preview.dispose(); + }; +} + +async function attachWorkspaceArtifact(item: GalleryArtifact, version: string) { + const bridge = [...activeBridges].find(value => value.artifactId === item.name && (version === "working" || bridgeVersions.get(value) === version)); + if (bridge?.onModelContext) { + await bridge.onModelContext({ ...bridge.modelContext, route: bridge.route?.path ?? "/" }); + return; + } + const artifact = await fetchArtifactPreview(app, { workspace: item.workspace, ...(version === "working" ? { name: item.name } : { version_id: version }) }); + const temporary = createArtifactBridge(artifact); + try { + if (!temporary.onModelContext) throw new Error("Adding context is unavailable in this chat"); + await temporary.onModelContext({ route: "/" }); + } finally { activeBridges.delete(temporary); } +} + +function showWorkspace(workspace: ArtifactWorkspacePayload) { + clear(); + status.textContent = ""; + hasArtifact = true; + lastWorkspace = workspace; + document.documentElement.dataset.view = "workspace"; + root.style.padding = "0"; + root.style.height = "100%"; + viewport.style.overflow = "hidden"; + const attached = host.extensions.modelContext?.getCurrent(); + const identity = attached?.structuredContent?.artifact as ArtifactSelection | undefined; + const view = attached?.structuredContent?.view as ArtifactModelContext | undefined; + const restored = workspace.view === "working" && identity?.version_id ? { ...identity, route: view?.route ?? "/" } : undefined; + workspaceView = mountWorkspace(root, app, workspace, { + selection: selectedArtifact ?? restored, + attachedVersionId: identity?.version_id, + renderPreview: renderWorkspacePreview, + attach: attachWorkspaceArtifact, + onSelectionChange: selection => { selectedArtifact = selection; }, + openLink: async url => { + const result = await app.openLink({ url }); + if (result.isError) throw new Error("The host could not open the link"); + }, + ...(workspace.productUrl ? { openProduct: async () => { + const result = await app.openLink({ url: workspace.productUrl! }); + if (result.isError) throw new Error("The host could not open the library"); + } } : {}), + ...(host.capabilities().actions.promptAgent ? { askCreate: async () => { + const result = await host.action({ type: "promptAgent", prompt: "Help me create a new artifact in my connected Artifacts library. Ask what I want to build before creating it." }); + if (result.isError) throw new Error("The host could not send the request"); + } } : {}), + }); + updateDisplay(); +} app.ontoolresult = result => { if (fileEditor?.hasUnsavedChanges()) { status.textContent = "Save or discard the file draft before replacing this view."; return; } if (result.structuredContent?.file && !result.isError) return; ++opening; if (result.isError) { status.textContent = result.content?.filter(item => item.type === "text").map(item => item.text).join("\n") || "Artifact could not be rendered."; return; } const artifact = result._meta?.artifact as ArtifactAppPayload | undefined; - if (artifact?.js) { renderArtifact(artifact); return; } + if (artifact?.js) { + if (workspaceView) { workspaceView.refresh(); return; } + renderArtifact(artifact); return; + } const workspace = result._meta?.workspace as ArtifactWorkspacePayload | undefined; if (workspace) { - clear(); - status.textContent = ""; - hasArtifact = true; - lastWorkspace = workspace; - disposeView = mountWorkspace(root, app, workspace, openSelection); - updateDisplay(); - // Each thread gets its own host instance. Reopen its explicit attachment, - // with fresh authorization, without inventing a server-side chat identity. - const attached = host.extensions.modelContext?.getCurrent(); - const selected = attached?.structuredContent?.artifact as { name?: unknown; workspace?: unknown; version_id?: unknown } | undefined; - if (workspace.view === "working" && typeof selected?.version_id === "string" && typeof selected.workspace === "string") { - const view = attached?.structuredContent?.view as ArtifactModelContext | undefined; - const route = typeof view?.route === "string" && view.route.startsWith("/") && !view.route.startsWith("//") ? view.route : "/"; - void openSelection({ workspace: selected.workspace, version_id: selected.version_id, route }).catch(error => { status.textContent = String(error); }); - } + if (workspaceView && lastWorkspace?.view === workspace.view) workspaceView.refresh(); + else showWorkspace(workspace); return; } // Diagnostics-only mutations deliberately leave the working view mounted. + workspaceView?.refresh(); }; app.ontoolinput = async ({ arguments: args }) => { const file = args?.file as { resourceUri?: string; name?: string } | undefined; @@ -304,6 +391,10 @@ app.ontoolinput = async ({ arguments: args }) => { await viewDisposal; if (request !== opening) return; fileEditor = mountArtifactFileEditor(root, host.extensions, { file: { resourceUri: file.resourceUri, name: file.name } }); + document.documentElement.dataset.view = "file"; + root.style.padding = "0"; + root.style.height = "100%"; + viewport.style.overflow = "hidden"; hasArtifact = true; status.textContent = ""; updateDisplay(); @@ -314,13 +405,10 @@ window.addEventListener("artifact/navigate", event => { }); libraryButton.addEventListener("click", () => { if (!lastWorkspace) return; - clear(); - hasArtifact = true; - disposeView = mountWorkspace(root, app, lastWorkspace, openSelection); - updateDisplay(); + showWorkspace(lastWorkspace); }); refreshButton.addEventListener("click", () => { - if (activeArtifact) void openSelection({ workspace: activeArtifact.workspace, name: activeArtifact.name }).catch(error => { status.textContent = String(error); }); + if (activeArtifact) void openSelection(selectedArtifact ?? { workspace: activeArtifact.workspace, version_id: activeArtifact.versionId }).catch(error => { status.textContent = String(error); }); }); attachButton.addEventListener("click", () => { const bridge = hostWindow.__artifacts; @@ -329,13 +417,22 @@ attachButton.addEventListener("click", () => { app.ontoolcancelled = () => { ++opening; if (fileEditor?.hasUnsavedChanges()) { status.textContent = "Request cancelled. Your unsaved file draft is preserved."; return; } clear(); status.textContent = "Artifact request cancelled."; }; app.onteardown = async () => { ++opening; + connected = false; resizeObserver.disconnect(); + window.removeEventListener("resize", reportViewportSize); if (sizeFrame) cancelAnimationFrame(sizeFrame); clear(); await viewDisposal; return {}; }; app.onhostcontextchanged = applyContext; +// Moving focus from the preview frame to its toolbar must not reload the artifact. +document.addEventListener("visibilitychange", () => { + if (document.visibilityState === "visible") { + reportViewportSize(); + workspaceView?.refresh(); + } +}); window.addEventListener("error", event => { status.textContent = `Artifact error: ${event.message}`; }); window.addEventListener("unhandledrejection", event => { status.textContent = `Artifact error: ${String(event.reason)}`; }); app.connect().then(() => { diff --git a/src/runtime/mcp-file-editor.tsx b/src/runtime/mcp-file-editor.tsx index a400cb6..e9acef2 100644 --- a/src/runtime/mcp-file-editor.tsx +++ b/src/runtime/mcp-file-editor.tsx @@ -2,6 +2,7 @@ import { useSyncExternalStore } from "react"; import { createRoot } from "react-dom/client"; import type { OpenAIExtensions, OpenAIFileEntrypointInput, OpenAIResources } from "@openai/mcp-extensions/app"; import { SourceEditor } from "../gallery/source-editor"; +import { galleryStyles } from "../gallery/styles"; export type ArtifactFileState = { draft: string; baseline: string; loaded: boolean; writable: boolean; etag?: string; @@ -119,21 +120,21 @@ export function mountArtifactFileEditor(root: HTMLElement, extensions: OpenAIExt function Editor() { const state = useSyncExternalStore(session.subscribe, session.getSnapshot); const canSave = state.loaded && state.writable && !state.busy && !state.conflict && session.hasUnsavedChanges(); - return
{ if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "s") { event.preventDefault(); void session.save(); } }} - style={{ display: "flex", flexDirection: "column", height: "100%", minHeight: 300, color: "var(--text, inherit)" }}> - -
- {input.file.name} - {session.hasUnsavedChanges() ? "Unsaved changes" : state.loaded ? "No unsaved changes" : ""} - + > + +
+

Artifact file

{input.file.name}

+ {session.hasUnsavedChanges() ? "Unsaved changes" : state.loaded ? state.writable ? "Saved" : "Read only" : ""} +
-

{state.message}

- {state.subscriptionWarning &&

{state.subscriptionWarning}

} - {state.loaded &&
} +

{state.message}

+ {state.subscriptionWarning &&

{state.subscriptionWarning}

} + {state.loaded &&
}
; } reactRoot.render(); @@ -142,10 +143,10 @@ export function mountArtifactFileEditor(root: HTMLElement, extensions: OpenAIExt } const fileEditorStyles = ` -.mcp-file-editor { --text: var(--color-text-primary, #202124); --subtle: var(--color-text-secondary, #656b73); --line: var(--color-border-primary, #ddd); --panel: var(--color-background-primary, #fff); --raised: var(--color-background-secondary, #f5f5f5); --selected: var(--color-background-tertiary, #dce6ef); --syntax-keyword: var(--text); --syntax-string: var(--text); --syntax-number: var(--text); --syntax-type: var(--text); --syntax-function: var(--text); background: var(--panel); font: 14px/1.5 var(--font-sans, system-ui, sans-serif); } -.mcp-file-editor button { background: var(--raised); color: var(--text); border: 1px solid var(--line); border-radius: 6px; padding: 5px 10px; font: inherit; cursor: pointer; } -.mcp-file-editor button:disabled { opacity: .5; cursor: default; } -.mcp-file-editor button:focus-visible { outline: 2px solid var(--text); outline-offset: 2px; } +.mcp-file-editor { height: 100%; min-height: 300px; background: var(--panel); color: var(--text); } +.mcp-file-editor .detail-header { flex-wrap: wrap; } +.mcp-file-editor .file-status { margin: 8px 16px; color: var(--muted); } +.mcp-file-editor .file-source { flex: 1; min-height: 0; overflow: auto; } .mcp-file-editor .source-code-editor { display: flex; flex-direction: column; height: 100%; min-height: 180px; min-width: 0; } .mcp-file-editor .source-code-surface { flex: 1; min-height: 0; min-width: 0; } .mcp-file-editor .source-code-status { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 4px 12px; border-top: 1px solid var(--line); color: var(--subtle); font-size: 11px; flex-shrink: 0; } diff --git a/src/runtime/mcp-preview.ts b/src/runtime/mcp-preview.ts new file mode 100644 index 0000000..cc338b0 --- /dev/null +++ b/src/runtime/mcp-preview.ts @@ -0,0 +1,95 @@ +import type { HostBridge } from "../sdk/hooks"; + +type PreviewContext = Pick; +type PreviewMethod = "onAction" | "onRequest" | "onPluginCall" | "onFileRequest" | "onFileDownload" | "onModelContext"; + +// This function is serialized into the opaque preview document. Keep it +// self-contained: only scoped bridge operations cross the frame boundary. +function previewBootstrap(initial: PreviewContext, methods: PreviewMethod[]) { + const pending = new Map }>(); + let sequence = 0; + const bridge: HostBridge = { ...initial }; + let state = initial.state; + Object.defineProperty(bridge, "state", { get: () => state, set(value) { + state = value; + parent.postMessage({ type: "artifacts/preview-state", state: value }, "*"); + } }); + const call = (method: PreviewMethod, value: unknown) => new Promise((resolve, reject) => { + if (pending.size >= 32) { reject(new Error("Too many pending artifact requests")); return; } + const id = ++sequence; + const timer = setTimeout(() => { pending.delete(id); reject(new Error("Artifact request timed out")); }, 30_000); + pending.set(id, { resolve, reject, timer }); + parent.postMessage({ type: "artifacts/preview-call", id, method, value }, "*"); + }); + for (const method of methods) { + Object.assign(bridge, { [method]: (value: unknown) => call(method, value) }); + } + Object.assign(window, { __artifacts: bridge, __herdrCanvas: bridge }); + addEventListener("message", event => { + if (event.source !== parent) return; + const data = event.data; + if (data?.type === "artifacts/preview-context") { + Object.assign(bridge, data.context); + dispatchEvent(new Event("artifact-host-context-change")); + dispatchEvent(new Event("artifact-theme-change")); + return; + } + if (data?.type !== "artifacts/preview-result") return; + const request = pending.get(data.id); + if (!request) return; + clearTimeout(request.timer); + pending.delete(data.id); + if (data.error) request.reject(new Error(data.error)); + else request.resolve(data.result); + }); + addEventListener("artifact/navigate", event => { + const { path } = (event as CustomEvent<{ path: string }>).detail; + if (bridge.route) bridge.route.path = path; + parent.postMessage({ type: "artifacts/preview-route", path }, "*"); + }); + const report = (message: string) => parent.postMessage({ type: "artifacts/preview-error", message }, "*"); + window.addEventListener("error", event => report(event.message)); + window.addEventListener("unhandledrejection", event => report(String(event.reason))); +} + +/** Isolate authored CSS and its #root from the shared gallery React tree. */ +export function mountMcpPreview(container: HTMLElement, javascript: string, bridge: HostBridge, onError: (message: string) => void) { + const frame = document.createElement("iframe"); + frame.className = "preview-frame"; + frame.title = `Preview of ${bridge.artifactId ?? "artifact"}`; + frame.setAttribute("sandbox", "allow-scripts allow-forms"); + const methods = (["onAction", "onRequest", "onPluginCall", "onFileRequest", "onFileDownload", "onModelContext"] as const).filter(method => typeof bridge[method] === "function"); + const context = (): PreviewContext => ({ artifactId: bridge.artifactId, state: bridge.state, route: bridge.route, theme: bridge.theme, actions: bridge.actions, environment: bridge.environment, modelContext: bridge.modelContext, contextAttached: bridge.contextAttached }); + const json = (value: unknown) => JSON.stringify(value).replaceAll("<", "\\u003c"); + const html = `
`; + let disposed = false; + const receive = async (event: MessageEvent) => { + if (disposed || event.source !== frame.contentWindow) return; + const data = event.data; + if (data?.type === "artifacts/preview-state") { bridge.state = data.state; return; } + if (data?.type === "artifacts/preview-route") { + if (bridge.route && typeof data.path === "string" && data.path.startsWith("/") && !data.path.startsWith("//")) bridge.route.path = data.path; + return; + } + if (data?.type === "artifacts/preview-error") { onError(String(data.message)); return; } + if (data?.type !== "artifacts/preview-call" || !methods.includes(data.method) || !Number.isSafeInteger(data.id)) return; + try { + const handler = bridge[data.method as PreviewMethod] as (value: unknown) => unknown; + const result = await handler(data.value); + if (!disposed) frame.contentWindow?.postMessage({ type: "artifacts/preview-result", id: data.id, result }, "*"); + } catch (error) { + if (!disposed) frame.contentWindow?.postMessage({ type: "artifacts/preview-result", id: data.id, error: error instanceof Error ? error.message : String(error) }, "*"); + } + }; + window.addEventListener("message", receive); + frame.srcdoc = html; + container.append(frame); + return { + bridge, + update() { + const { state: _state, route: _route, ...value } = context(); + frame.contentWindow?.postMessage({ type: "artifacts/preview-context", context: value }, "*"); + }, + dispose() { disposed = true; window.removeEventListener("message", receive); frame.remove(); }, + }; +} diff --git a/src/runtime/mcp-workspace.ts b/src/runtime/mcp-workspace.ts index 4be642a..b36e8ef 100644 --- a/src/runtime/mcp-workspace.ts +++ b/src/runtime/mcp-workspace.ts @@ -1,11 +1,19 @@ +import { createElement } from "react"; +import { createRoot } from "react-dom/client"; import type { App } from "@modelcontextprotocol/ext-apps"; import type { ArtifactWorkspacePayload } from "../mcp/workspace-contract"; import type { ArtifactAppPayload } from "../mcp/app-contract"; +import type { GalleryData } from "../gallery/types"; +import { GalleryWorkspace } from "../gallery/workspace"; +import { GalleryToolError, type GalleryTransport, type MutationResult } from "../gallery/transport"; +import type { SourceSnapshot } from "../gallery/drafts"; +import { editableProject } from "../gallery/project-editor"; -export type ArtifactSelection = { name?: string; workspace?: string; version_id?: string; route?: string }; +export type ArtifactSelection = { name?: string; workspace?: string; version_id?: string; route?: string; kind?: "artifact" | "script"; tab?: "preview" | "source" }; /** Deep links contain authenticated selectors, never file-transfer credentials. */ export function parseArtifactDeepLink(value: string): ArtifactSelection | null { + if (/[\\\s#]/.test(value)) return null; let url: URL; try { url = new URL(value, "https://artifacts.invalid"); } catch { return null; } if (url.origin !== "https://artifacts.invalid" || url.pathname !== "/artifact") return null; @@ -13,12 +21,14 @@ export function parseArtifactDeepLink(value: string): ArtifactSelection | null { const version_id = url.searchParams.get("version_id") ?? undefined; const workspace = url.searchParams.get("workspace") ?? undefined; const route = url.searchParams.get("route") ?? "/"; - if ((!name && !version_id) || !route.startsWith("/") || route.startsWith("//")) return null; - return { name: version_id ? undefined : name, workspace, version_id, route }; + const kind = url.searchParams.get("kind"); + const tab = url.searchParams.get("tab"); + if ((!name && !version_id) || !route.startsWith("/") || route.startsWith("//") || route.includes("\\") || (kind && !["artifact", "script"].includes(kind)) || (tab && !["preview", "source"].includes(tab))) return null; + return { name: version_id ? undefined : name, workspace, version_id, route, ...(kind ? { kind: kind as "artifact" | "script" } : {}), ...(tab ? { tab: tab as "preview" | "source" } : {}) }; } export async function fetchArtifactPreview(app: App, selection: ArtifactSelection): Promise { - const { route, ...args } = selection; + const { route: _route, kind: _kind, tab: _tab, ...args } = selection; const result = await app.callServerTool({ name: "artifacts_preview", arguments: args }); if (result.isError) throw new Error(result.content?.filter(item => item.type === "text").map(item => item.text).join("\n") || "Unable to open artifact"); const artifact = result._meta?.artifact as ArtifactAppPayload | undefined; @@ -26,71 +36,65 @@ export async function fetchArtifactPreview(app: App, selection: ArtifactSelectio return artifact; } -export function mountWorkspace(root: HTMLElement, app: App, initial: ArtifactWorkspacePayload, open: (selection: ArtifactSelection) => Promise) { - let disposed = false; - let generation = 0; - let query = ""; - const section = document.createElement("section"); - const title = document.createElement("h1"); - title.textContent = initial.view === "library" ? "Artifacts" : "Working artifacts"; - const label = document.createElement("label"); - label.textContent = "Search artifacts "; - const input = document.createElement("input"); - input.type = "search"; - input.maxLength = 200; - const form = document.createElement("form"); - const search = document.createElement("button"); - search.textContent = "Search"; - search.type = "button"; - label.append(input); - form.append(label, search); - const list = document.createElement("ul"); - const notice = document.createElement("p"); - notice.setAttribute("role", "status"); - const more = document.createElement("button"); - more.textContent = "Load more"; - let nextOffset = initial.nextOffset; - section.append(title, form, list, notice, more); - root.append(section); - function render(payload: ArtifactWorkspacePayload, append: boolean) { - if (!append) list.replaceChildren(); - for (const item of payload.items) { - const row = document.createElement("li"); - const button = document.createElement("button"); - button.textContent = `Open ${item.name}`; - const versions = document.createElement("select"); - versions.setAttribute("aria-label", `Revision of ${item.name}`); - if (item.working) versions.add(new Option("Working source", "")); - for (const version of item.versions) versions.add(new Option(`Revision ${version.revision}`, version.id)); - button.addEventListener("click", () => { - const version_id = versions.value || undefined; - void open({ workspace: item.workspace, name: version_id ? undefined : item.name, version_id }).catch(error => { notice.textContent = String(error); }); - }); - row.append(button, versions); - list.append(row); - } - nextOffset = payload.nextOffset; - more.hidden = nextOffset === null; - notice.textContent = list.children.length ? "" : "No artifacts found."; - } - async function load(append: boolean) { - const current = ++generation; - search.disabled = more.disabled = true; - try { - const result = await app.callServerTool({ name: "artifacts_search", arguments: { query, view: initial.view, offset: append ? nextOffset : 0 } }); - if (disposed || current !== generation) return; - if (result.isError) throw new Error(result.content?.filter(item => item.type === "text").map(item => item.text).join("\n") || "Search failed"); - const payload = result._meta?.workspace as ArtifactWorkspacePayload | undefined; - if (!payload) throw new Error("Workspace response was missing"); - render(payload, append); - } catch (error) { if (!disposed && current === generation) notice.textContent = String(error); } - finally { if (!disposed && current === generation) search.disabled = more.disabled = false; } +/** Older server pages remain readable during a server/plugin rollout. */ +export function workspaceGallery(payload: ArtifactWorkspacePayload): GalleryData { + if (payload.gallery) return payload.gallery; + return { workspace: payload.workspace, nextOffset: payload.nextOffset, artifacts: payload.items.map(item => ({ + ...item, key: JSON.stringify([item.workspace, item.name]), + versions: item.versions.map(version => ({ reason: "saved", serveCount: 0, ...version })), + })) }; +} + +function resultValue(result: Awaited>) { + const text = result.content?.filter(item => item.type === "text").map(item => item.text).join("\n"); + let value: unknown = result.structuredContent; + if (value === undefined && text) { try { value = JSON.parse(text); } catch { value = text; } } + if (result.isError) { + const detail = typeof value === "object" && value !== null ? value as MutationResult & { status?: number } : undefined; + throw new GalleryToolError(detail?.error ?? text ?? "Artifact operation failed", detail?.status ?? 500, detail); } - const searchNow = () => { query = input.value; void load(false); }; - search.addEventListener("click", searchNow); - input.addEventListener("keydown", event => { if (event.key === "Enter") { event.preventDefault(); searchNow(); } }); - form.addEventListener("submit", event => { event.preventDefault(); }); - more.addEventListener("click", () => { void load(true); }); - render(initial, false); - return () => { disposed = true; generation++; section.remove(); }; + return value; +} + +export function createGalleryTransport(app: App): GalleryTransport { + return { + async tool(workspace, tool, args) { + return resultValue(await app.callServerTool({ name: "artifacts_tool", arguments: { workspace, tool, arguments: args } })); + }, + async loadSource(sourceUrl, signal) { + signal?.throwIfAborted(); + const params = new URL(sourceUrl, "https://artifacts.invalid").searchParams; + const selection = params.get("version_id") ? { version_id: params.get("version_id")! } : { name: params.get("name")! }; + const result = await app.callServerTool({ name: "artifacts_source", arguments: { workspace: params.get("workspace")!, kind: params.get("kind") === "script" ? "script" : "artifact", ...selection } }); + signal?.throwIfAborted(); + const snapshot = resultValue(result) as SourceSnapshot; + if (typeof snapshot?.source !== "string" || !snapshot.project) throw new Error("The server returned an incomplete project snapshot"); + return { ...snapshot, project: editableProject(snapshot.project) }; + }, + }; +} + +type WorkspaceProps = Parameters[0]; +export type WorkspaceOptions = Pick; + +export function mountWorkspace(root: HTMLElement, app: App, initial: ArtifactWorkspacePayload, options: WorkspaceOptions) { + const reactRoot = createRoot(root); + const transport = createGalleryTransport(app); + const gallery = workspaceGallery(initial); + let current = options; + let refreshKey = 0; + const search = async (query: string, offset: number) => { + const result = await app.callServerTool({ name: "artifacts_search", arguments: { query, offset, view: "library" } }); + if (result.isError) resultValue(result); + const payload = result._meta?.workspace as ArtifactWorkspacePayload | undefined; + if (!payload) throw new Error("The server returned no library catalog"); + return workspaceGallery(payload); + }; + const render = () => reactRoot.render(createElement(GalleryWorkspace, { initial: gallery, view: initial.view, transport, search, ...current, refreshKey })); + render(); + return { + update(value: Partial) { current = { ...current, ...value }; render(); }, + refresh() { refreshKey++; render(); }, + dispose() { reactRoot.unmount(); }, + }; }