diff --git a/server/extensions/webUi.ts b/server/extensions/webUi.ts index f2b9a85..3d836bd 100644 --- a/server/extensions/webUi.ts +++ b/server/extensions/webUi.ts @@ -39,26 +39,34 @@ type PendingExtensionUiRequest = { }; const pendingExtensionUiRequests = new Map(); -type WebFooterState = { - footers: Map; -}; +type WebContribution = + | { version: 1; key: string; slot: "footer"; kind: "static"; view: PiWebFooter } + | { version: 1; key: string; slot: "header-action"; kind: "rendered"; source: PiWebHeaderAction } + | { version: 1; key: string; slot: "artifact-action"; kind: "rendered"; source: PiWebArtifactAction } + | { version: 1; key: string; slot: "git-tab"; kind: "rendered"; source: PiWebGitTab }; -type WebHeaderActionState = { - actions: Map; -}; +/** Canonical per-runtime registry. Legacy surfaces below are wire adapters over it. */ +const webContributionStates = new WeakMap>(); -type WebGitTabState = { - tabs: Map; -}; +function contributionId(slot: WebContribution["slot"], key: string) { + return `${slot}\0${key}`; +} -type WebArtifactActionState = { - actions: Map; -}; +function contributionState(value: any) { + const session = value as object; + let state = webContributionStates.get(session); + if (!state) { + state = new Map(); + webContributionStates.set(session, state); + } + return state; +} -const webFooterStates = new WeakMap(); -const webHeaderActionStates = new WeakMap(); -const webGitTabStates = new WeakMap(); -const webArtifactActionStates = new WeakMap(); +function contributionsFor(value: any, slot: S) { + return Array.from(contributionState(value).values()).filter( + (entry): entry is Extract => entry.slot === slot, + ); +} // Process-global extension-settings schema registry: decoupled from any one // session. Values live in settingsStore (global). A schema stays registered @@ -325,61 +333,18 @@ async function getExtensionSettings(id: string): Promise { return { schemaVersion: 0, values: {} }; } -function getWebFooterState(value: any): WebFooterState { - const key = value as object; - let state = webFooterStates.get(key); - if (!state) { - state = { footers: new Map() }; - webFooterStates.set(key, state); - } - return state; -} - -function cleanFooterKey(value: unknown) { +function cleanContributionKey(value: unknown) { if (typeof value !== "string") return undefined; const cleaned = value.trim().slice(0, 80).replace(/[^a-zA-Z0-9_.:-]/g, "-"); return cleaned || undefined; } -const cleanHeaderActionKey = cleanFooterKey; -const cleanGitTabKey = cleanFooterKey; - function cleanHeaderActionText(value: unknown, maxLength = 200) { if (typeof value !== "string") return undefined; const cleaned = value.replace(/[\u0000-\u001F\u007F]/g, "").trim(); return cleaned ? cleaned.slice(0, maxLength) : undefined; } -function getWebHeaderActionState(value: any): WebHeaderActionState { - const key = value as object; - let state = webHeaderActionStates.get(key); - if (!state) { - state = { actions: new Map() }; - webHeaderActionStates.set(key, state); - } - return state; -} - -function getWebArtifactActionState(value: any): WebArtifactActionState { - const key = value as object; - let state = webArtifactActionStates.get(key); - if (!state) { - state = { actions: new Map() }; - webArtifactActionStates.set(key, state); - } - return state; -} - -function getWebGitTabState(value: any): WebGitTabState { - const key = value as object; - let state = webGitTabStates.get(key); - if (!state) { - state = { tabs: new Map() }; - webGitTabStates.set(key, state); - } - return state; -} - function cleanFooterText(value: unknown, maxLength = 2_000) { if (typeof value !== "string") return undefined; const cleaned = value.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, "").trimEnd(); @@ -404,126 +369,96 @@ function normalizePiWebFooter(value: unknown): PiWebFooter | undefined { return undefined; } -function webFooterEntries(value: any) { - return Array.from(getWebFooterState(value).footers.entries()).map(([key, footer]) => ({ key, footer })); -} - -function broadcastWebFooters(value: any) { - const webFooters = webFooterEntries(value); - deps.emit({ - type: "web_footer_changed", - sessionId: value.sessionId, - sessionFile: value.sessionFile, - webFooters, - }); - return webFooters; -} - -function webHeaderActionEntries(value: any) { - return Array.from(getWebHeaderActionState(value).actions.entries()).map(([key, action]) => ({ - key, - icon: cleanHeaderActionText(action.icon, 80), - title: cleanHeaderActionText(action.title) || key, - label: cleanHeaderActionText(action.label), - })); -} - -function broadcastWebHeaderActions(value: any) { - const webHeaderActions = webHeaderActionEntries(value); - deps.emit({ - type: "web_header_actions_changed", - sessionId: value.sessionId, - sessionFile: value.sessionFile, - webHeaderActions, - }); - return webHeaderActions; -} - -function webArtifactActionEntries(value: any) { - return Array.from(getWebArtifactActionState(value).actions.entries()).map(([key, action]) => ({ - key, - title: cleanHeaderActionText(action.title) || key, - label: cleanHeaderActionText(action.label, 80), - kinds: Array.isArray(action.kinds) ? action.kinds.filter((kind) => kind === "markdown" || kind === "html" || kind === "video") : undefined, - extensions: Array.isArray(action.extensions) ? action.extensions.flatMap((extension) => { - const cleaned = cleanHeaderActionText(extension, 30)?.toLowerCase(); - return cleaned && /^\.[a-z0-9]+$/.test(cleaned) ? [cleaned] : []; - }).slice(0, 20) : undefined, - })); -} - -function broadcastWebArtifactActions(value: any) { - const webArtifactActions = webArtifactActionEntries(value); - deps.emit({ type: "web_artifact_actions_changed", sessionId: value.sessionId, sessionFile: value.sessionFile, webArtifactActions }); - return webArtifactActions; -} - -function webGitTabEntries(value: any) { - return Array.from(getWebGitTabState(value).tabs.entries()).map(([key, tab]) => ({ - key, - title: cleanHeaderActionText(tab.title) || key, - label: cleanHeaderActionText(tab.label, 80), - })); -} - -function broadcastWebGitTabs(value: any) { - const webGitTabs = webGitTabEntries(value); - deps.emit({ - type: "web_git_tabs_changed", - sessionId: value.sessionId, - sessionFile: value.sessionFile, - webGitTabs, - }); - return webGitTabs; +const contributionPolicies = { + footer: { + entries: (value: any) => contributionsFor(value, "footer").map(({ key, view: footer }) => ({ key, footer })), + event: "web_footer_changed", + field: "webFooters", + }, + "header-action": { + entries: (value: any) => contributionsFor(value, "header-action").map(({ key, source }) => ({ + key, icon: cleanHeaderActionText(source.icon, 80), title: cleanHeaderActionText(source.title) || key, + label: cleanHeaderActionText(source.label), + })), + event: "web_header_actions_changed", + field: "webHeaderActions", + }, + "artifact-action": { + entries: (value: any) => contributionsFor(value, "artifact-action").map(({ key, source }) => ({ + key, title: cleanHeaderActionText(source.title) || key, label: cleanHeaderActionText(source.label, 80), + kinds: Array.isArray(source.kinds) ? source.kinds.filter((kind) => kind === "markdown" || kind === "html" || kind === "video") : undefined, + extensions: Array.isArray(source.extensions) ? source.extensions.flatMap((extension) => { + const cleaned = cleanHeaderActionText(extension, 30)?.toLowerCase(); + return cleaned && /^\.[a-z0-9]+$/.test(cleaned) ? [cleaned] : []; + }).slice(0, 20) : undefined, + })), + event: "web_artifact_actions_changed", + field: "webArtifactActions", + }, + "git-tab": { + entries: (value: any) => contributionsFor(value, "git-tab").map(({ key, source }) => ({ + key, title: cleanHeaderActionText(source.title) || key, label: cleanHeaderActionText(source.label, 80), + })), + event: "web_git_tabs_changed", + field: "webGitTabs", + }, +} as const; + +type ContributionSlot = keyof typeof contributionPolicies; + +function broadcastContributions(value: any, slot: ContributionSlot) { + const policy = contributionPolicies[slot]; + const entries = policy.entries(value); + deps.emit({ type: policy.event, sessionId: value.sessionId, sessionFile: value.sessionFile, [policy.field]: entries }); + return entries; +} + +function setContribution( + value: any, + slot: ContributionSlot, + keyValue: unknown, + create: (key: string) => WebContribution | undefined, +) { + const key = cleanContributionKey(keyValue); + if (!key) return; + const contribution = create(key); + const id = contributionId(slot, key); + if (contribution) contributionState(value).set(id, contribution); + else contributionState(value).delete(id); + broadcastContributions(value, slot); } function createPiWebUi(value: any): PiWebUi { return { setFooter(key, footer) { - const footerKey = cleanFooterKey(key); - if (!footerKey) return; - const footerState = getWebFooterState(value); - const normalized = normalizePiWebFooter(footer); - if (normalized) footerState.footers.set(footerKey, normalized); - else footerState.footers.delete(footerKey); - broadcastWebFooters(value); + setContribution(value, "footer", key, (cleanKey) => { + const view = normalizePiWebFooter(footer); + return view ? { version: 1, key: cleanKey, slot: "footer", kind: "static", view } : undefined; + }); }, setHeaderAction(key, action) { - const actionKey = cleanHeaderActionKey(key); - if (!actionKey) return; - const actionState = getWebHeaderActionState(value); - if (action && typeof action === "object" && typeof action.invoke === "function") { - actionState.actions.set(actionKey, action); - } else { - actionState.actions.delete(actionKey); - } - broadcastWebHeaderActions(value); + setContribution(value, "header-action", key, (cleanKey) => ( + action && typeof action === "object" && typeof action.invoke === "function" + ? { version: 1, key: cleanKey, slot: "header-action", kind: "rendered", source: action } + : undefined + )); }, setArtifactAction(key, action) { - const actionKey = cleanHeaderActionKey(key); - if (!actionKey) return; - const state = getWebArtifactActionState(value); - if (action && typeof action === "object" && typeof action.invoke === "function") state.actions.set(actionKey, action); - else state.actions.delete(actionKey); - broadcastWebArtifactActions(value); + setContribution(value, "artifact-action", key, (cleanKey) => ( + action && typeof action === "object" && typeof action.invoke === "function" + ? { version: 1, key: cleanKey, slot: "artifact-action", kind: "rendered", source: action } + : undefined + )); }, setGitTab(key, tab) { - const tabKey = cleanGitTabKey(key); - if (!tabKey) return; - const tabState = getWebGitTabState(value); - if (tab && typeof tab === "object" && typeof tab.render === "function") { - tabState.tabs.set(tabKey, tab); - } else { - tabState.tabs.delete(tabKey); - } - broadcastWebGitTabs(value); - }, - async registerSettings(schema) { - return registerSessionSettings(value, schema); - }, - async getSettings(id) { - return getExtensionSettings(id); + setContribution(value, "git-tab", key, (cleanKey) => ( + tab && typeof tab === "object" && typeof tab.render === "function" + ? { version: 1, key: cleanKey, slot: "git-tab", kind: "rendered", source: tab } + : undefined + )); }, + async registerSettings(schema) { return registerSessionSettings(value, schema); }, + async getSettings(id) { return getExtensionSettings(id); }, }; } @@ -701,11 +636,17 @@ async function bindWebExtensions(value: any) { } - async function invokeHeaderAction(value: any, keyValue: unknown) { - const key = cleanHeaderActionKey(keyValue); + function renderedContribution(value: any, slot: S, keyValue: unknown) { + const key = cleanContributionKey(keyValue); if (!key) throw new Error("key is required"); - const action = getWebHeaderActionState(value).actions.get(key); - if (!action) throw new Error("Header action not found"); + const contribution = contributionState(value).get(contributionId(slot, key)); + return { key, contribution: contribution?.slot === slot ? contribution as Extract : undefined }; + } + + async function invokeHeaderAction(value: any, keyValue: unknown) { + const { key, contribution } = renderedContribution(value, "header-action", keyValue); + if (!contribution) throw new Error("Header action not found"); + const action = contribution.source; const result = await action.invoke(); const markdown = cleanFooterText(result?.markdown, 200_000); if (!markdown) throw new Error("Header action returned no markdown"); @@ -713,10 +654,9 @@ async function bindWebExtensions(value: any) { } async function invokeArtifactAction(value: any, input: { key?: unknown; name?: unknown; path?: unknown; kind?: unknown }) { - const key = cleanHeaderActionKey(input.key); - if (!key) throw new Error("key is required"); - const action = getWebArtifactActionState(value).actions.get(key); - if (!action) throw new Error("Artifact action not found"); + const { key, contribution } = renderedContribution(value, "artifact-action", input.key); + if (!contribution) throw new Error("Artifact action not found"); + const action = contribution.source; const name = cleanHeaderActionText(input.name, 500); const path = cleanHeaderActionText(input.path, 2_000); const kind = input.kind === "markdown" || input.kind === "html" || input.kind === "video" ? input.kind : undefined; @@ -730,18 +670,20 @@ async function bindWebExtensions(value: any) { const result = await action.invoke({ name, path, kind }); const markdown = cleanFooterText(result?.markdown, 200_000); const message = cleanHeaderActionText(result?.message, 2_000); - const download = result?.download && typeof result.download === "object" - ? { path, filename: cleanHeaderActionText(result.download.filename, 500) || name } - : undefined; + const download = result?.download && typeof result.download === "object" ? { path, filename: cleanHeaderActionText(result.download.filename, 500) || name } : undefined; if (!markdown && !message && !download) throw new Error("Artifact action returned no result"); - return { label: cleanHeaderActionText(action.label) || cleanHeaderActionText(action.title) || key, ...(markdown ? { markdown } : {}), ...(message ? { message } : {}), ...(download ? { download } : {}) }; + return { + label: cleanHeaderActionText(action.label) || cleanHeaderActionText(action.title) || key, + ...(markdown ? { markdown } : {}), + ...(message ? { message } : {}), + ...(download ? { download } : {}), + }; } async function invokeGitTab(value: any, input: { key?: unknown; action?: unknown; payload?: unknown; repo?: unknown }) { - const key = cleanGitTabKey(input.key); - if (!key) throw new Error("key is required"); - const tab = getWebGitTabState(value).tabs.get(key); - if (!tab) throw new Error("Git tab not found"); + const { key, contribution } = renderedContribution(value, "git-tab", input.key); + if (!contribution) throw new Error("Git tab not found"); + const tab = contribution.source; const repo = input.repo && typeof input.repo === "object" ? input.repo as Record : undefined; const result = await tab.render({ action: typeof input.action === "string" ? input.action : undefined, @@ -781,7 +723,7 @@ async function bindWebExtensions(value: any) { return { bind: bindWebExtensions, - entries: (value: any) => ({ webFooters: webFooterEntries(value), webHeaderActions: webHeaderActionEntries(value), webArtifactActions: webArtifactActionEntries(value), webGitTabs: webGitTabEntries(value) }), + entries: (value: any) => ({ webFooters: contributionPolicies.footer.entries(value), webHeaderActions: contributionPolicies["header-action"].entries(value), webArtifactActions: contributionPolicies["artifact-action"].entries(value), webGitTabs: contributionPolicies["git-tab"].entries(value) }), invokeHeaderAction, invokeArtifactAction, invokeGitTab, diff --git a/tests/extensions.test.ts b/tests/extensions.test.ts index 6bfe6c6..492ec9b 100644 --- a/tests/extensions.test.ts +++ b/tests/extensions.test.ts @@ -108,6 +108,57 @@ describe("bundled extension path discovery", () => { .rejects.toThrow("does not match this artifact"); await expect(bridge.invokeArtifactAction(session, { key: "download", name: "page.html", path: "/api/artifacts/other.html", kind: "html" })) .rejects.toThrow("Invalid artifact context"); + + ui.web.setArtifactAction("malformed", { + title: "Malformed filters", kinds: "html", extensions: ".html", + invoke: () => ({ message: "invoked" }), + } as any); + await expect(bridge.invokeArtifactAction(session, { key: "malformed", name: "notes.md", path: "/api/artifacts/notes.md", kind: "markdown" })) + .resolves.toMatchObject({ message: "invoked" }); + }); + + it("keeps legacy surfaces isolated over one contribution registry", async () => { + let ui: any; + const emitted: any[] = []; + const bridge = createWebUiBridge({ + emit: (value) => emitted.push(value), clientCount: () => 1, acquireWorkLease: () => () => undefined, + createNewSession: async () => ({}), sessionCwd: () => process.cwd(), state: () => ({}), + }); + const session = { + sessionId: "session", sessionFile: "/tmp/session.jsonl", agent: { waitForIdle: async () => undefined }, + bindExtensions: async (options: any) => { ui = options.uiContext; }, + }; + await bridge.bind(session); + + ui.web.setFooter("first", "one"); + ui.web.setFooter("shared", "ready"); + ui.web.setFooter("last", "three"); + ui.web.setFooter("shared", "updated"); + ui.web.setHeaderAction("shared", { title: "Summary", invoke: () => ({ markdown: "# Done" }) }); + ui.web.setGitTab("shared", { title: "Issues", render: () => ({ html: "

Open

" }) }); + const broadcastsBeforeInvalidKey = emitted.length; + ui.web.setFooter("", "ignored"); + ui.web.setFooter("", undefined); + + expect(emitted).toHaveLength(broadcastsBeforeInvalidKey); + expect(bridge.entries(session).webFooters.map(({ key }: { key: string }) => key)).toEqual(["first", "shared", "last"]); + expect(bridge.entries(session)).toMatchObject({ + webFooters: [ + { key: "first", footer: { kind: "text", lines: ["one"] } }, + { key: "shared", footer: { kind: "text", lines: ["updated"] } }, + { key: "last", footer: { kind: "text", lines: ["three"] } }, + ], + webHeaderActions: [{ key: "shared", title: "Summary" }], + webGitTabs: [{ key: "shared", title: "Issues" }], + }); + await expect(bridge.invokeHeaderAction(session, "shared")).resolves.toMatchObject({ markdown: "# Done" }); + await expect(bridge.invokeGitTab(session, { key: "shared" })).resolves.toMatchObject({ html: "

Open

" }); + + ui.web.setHeaderAction("shared", undefined); + expect(bridge.entries(session).webHeaderActions).toEqual([]); + expect(bridge.entries(session).webFooters).toHaveLength(3); + expect(bridge.entries(session).webGitTabs).toHaveLength(1); + expect(emitted.at(-1)).toMatchObject({ type: "web_header_actions_changed", webHeaderActions: [] }); }); it("re-emits a footer when the same session id gets a new runtime", async () => {