From 9557a03af8dd9289c8ddef4dbe90bdde5e8d2a75 Mon Sep 17 00:00:00 2001 From: Nico Ritschel Date: Sun, 4 Oct 2026 08:31:13 -0700 Subject: [PATCH 1/5] Integrate Codex entrypoints with the Artifacts gallery --- cloudflare/gallery-tools.test.ts | 73 +++++ cloudflare/gallery-tools.ts | 31 ++ cloudflare/http.test.ts | 16 + cloudflare/http.ts | 6 +- cloudflare/managed-service.ts | 15 +- cloudflare/mcp.ts | 21 +- cloudflare/service.ts | 20 +- cloudflare/tool-contract.ts | 4 + cloudflare/worker.test.ts | 43 ++- cloudflare/workspace-tools.test.ts | 14 + cloudflare/workspace-tools.ts | 14 +- docs/mcp-plugin.md | 12 +- e2e/gallery-drafts.test.ts | 2 +- e2e/mcp-app-host.ts | 7 +- e2e/mcp-app.test.ts | 212 +++++++++---- e2e/mcp-file-editor.test.ts | 2 +- package.json | 8 + plugins/artifacts/plugin.json | 2 +- plugins/artifacts/skills/setup/SKILL.md | 4 +- src/gallery/client.tsx | 306 +------------------ src/gallery/execution-controls.tsx | 4 +- src/gallery/hosted.tsx | 46 ++- src/gallery/library-navigation.tsx | 55 ++++ src/gallery/remix.tsx | 3 +- src/gallery/secrets.tsx | 4 +- src/gallery/styles.ts | 259 ++++++++++++++++ src/gallery/transport.tsx | 34 +++ src/gallery/types.ts | 2 +- src/gallery/workspace.test.tsx | 91 ++++++ src/gallery/workspace.tsx | 385 ++++++++++++++++++++++++ src/mcp/app-contract.ts | 4 +- src/mcp/gallery-contract.ts | 34 +++ src/mcp/local-tools.ts | 15 +- src/mcp/local-workspace.ts | 45 +-- src/mcp/settings-contract.ts | 37 +++ src/mcp/tool-result.ts | 17 ++ src/mcp/workspace-contract.ts | 36 ++- src/mcp/workspace.test.ts | 2 +- src/runtime/mcp-app.ts | 212 +++++++++---- src/runtime/mcp-file-editor.tsx | 29 +- src/runtime/mcp-preview.ts | 96 ++++++ src/runtime/mcp-workspace.ts | 144 ++++----- 42 files changed, 1771 insertions(+), 595 deletions(-) create mode 100644 cloudflare/gallery-tools.test.ts create mode 100644 cloudflare/gallery-tools.ts create mode 100644 cloudflare/http.test.ts create mode 100644 src/gallery/library-navigation.tsx create mode 100644 src/gallery/styles.ts create mode 100644 src/gallery/transport.tsx create mode 100644 src/gallery/workspace.test.tsx create mode 100644 src/gallery/workspace.tsx create mode 100644 src/mcp/gallery-contract.ts create mode 100644 src/mcp/settings-contract.ts create mode 100644 src/mcp/tool-result.ts create mode 100644 src/runtime/mcp-preview.ts 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 4886516..6636278 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") @@ -169,7 +180,7 @@ export class ManagedArtifactService { : await this.env.LIBRARIES.getByName(target.libraryKey).draftRevision({ workspace: item.workspace, name: item.name }); } } - 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, 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..0a4db4c 100644 --- a/cloudflare/mcp.ts +++ b/cloudflare/mcp.ts @@ -12,27 +12,30 @@ 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 }] }; @@ -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..82f9423 100644 --- a/cloudflare/worker.test.ts +++ b/cloudflare/worker.test.ts @@ -938,7 +938,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 +952,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..847cb24 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,8 @@ 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 blob frames so their CSS and React root cannot replace the gallery. + +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 3b072e6..a4e809e 100644 --- a/e2e/gallery-drafts.test.ts +++ b/e2e/gallery-drafts.test.ts @@ -81,8 +81,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/mcp-app-host.ts b/e2e/mcp-app-host.ts index 7f0e9f8..af4251e 100644 --- a/e2e/mcp-app-host.ts +++ b/e2e/mcp-app-host.ts @@ -29,6 +29,7 @@ declare global { setInlineFrameLimit(limit?: number): void; setRequestBehavior(behavior: RequestBehavior): void; setServerToolResult(result: CallToolResult): void; + setServerToolResults(results: Record): void; context(): McpUiHostContext; }; } @@ -49,6 +50,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 = { @@ -122,6 +124,7 @@ window.mcpHost = { setServerToolResult(result) { serverToolResult = result; }, + setServerToolResults(results) { serverToolResults = results; }, context() { return structuredClone(hostContext); }, @@ -144,6 +147,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 +176,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..06a8c07 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, @@ -628,10 +631,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 +651,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 +687,40 @@ 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.locator(".app-header").getByRole("button", { name: "Choose from 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: "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 +738,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 +848,32 @@ 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(); + await app.getByRole("button", { 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 bd7e1f8..1b54b16 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..ff3b54b 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.2", "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 be6b7b7..52c11ad 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 { NativeAppsPanel } from "./native-apps"; -import { fontFaces } from "./brand"; -import { themeStyles } from "./theme"; +import { galleryStyles } from "./styles"; import { subscribeGallery } from "./subscription"; type Scope = "current" | "all"; @@ -33,262 +32,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; } - .wordmark { display: flex; align-items: center; gap: 10px; width: 224px; flex-shrink: 0; font-size: 14px; font-weight: 600; letter-spacing: -.3px; } - .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; } - .new-script { border-color: var(--line); white-space: nowrap; } - .new-script span { color: inherit; } - .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:empty { display: none; } - .library-footer { padding: 8px 12px; border-top: 1px solid var(--line); color: var(--subtle); font-size: 11px; } - .library-footer details { margin-top: 4px; } - .library-footer summary { color: var(--muted); min-height: 24px; padding: 2px 0; } - .library-footer p { margin: 6px 0; font-size: 12px; } - .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, .gallery-app .new-script { 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); } - .gallery-app .new-script:hover { background: var(--primary-hover); } - .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); } - .wordmark { 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; } - .wordmark { font-size: 14px; width: auto; } - .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; } - .new-script { font-size: 12px; } - .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; @@ -702,7 +445,7 @@ function App() { return ( <> - +
Artifacts @@ -730,44 +473,11 @@ function App() { {accountError ?

{accountError}

: null}