diff --git a/docs/pi-web-extensions.md b/docs/pi-web-extensions.md index adc7083..ba48a80 100644 --- a/docs/pi-web-extensions.md +++ b/docs/pi-web-extensions.md @@ -120,6 +120,65 @@ ctx.ui.web.setHeaderAction("recap", undefined); The repo includes a recap example at [`examples/pi-web-extensions/recap.ts`](../examples/pi-web-extensions/recap.ts). +## FAB and right-panel API + +`ctx.ui.web.setPanel(key, panel)` registers an extension **panel surface**: trusted +extension-provided HTML rendered in pi-web's shared right panel. A panel has **no +implicit entry point** — registering one contributes nothing to the FAB. Entry points +are explicit and may be anything that references the panel: + +- `ctx.ui.web.setFabAction(key, { title, icon, opens })` — a mascot-FAB launcher entry +- a header action returning an `open-panel` effect from `invoke()` +- future affordances (links from other extension views, etc.) + +The panel can call back into the extension with `data-web-action`, optional JSON +in `data-web-payload`, and ordinary HTML forms. Successful form controls are sent +as `event.fields`. + +```ts +ctx.ui.web.setPanel("notes", { + title: "Notes", + label: "Notepad", + icon: "notebook-pen", + render: async (event) => { + if (event?.action === "save") { + const content = event.fields?.content; + // Persist content here. + } + return { + html: `
+ + +
`, + }; + }, +}); +``` + +Panel HTML is trusted and uses the same local extension trust model as custom +footer and Git-tab HTML. Scripts inserted through `innerHTML` do not execute; +use action attributes for interaction. Clear the contribution with: + +```ts +ctx.ui.web.setPanel("notes", undefined); +ctx.ui.web.setFabAction("notes-launcher", undefined); +``` + +Register a FAB launcher for a panel, or open it from a header action — or both: + +```ts +ctx.ui.web.setPanel("notes", { title: "Notes", render: ... }); +ctx.ui.web.setFabAction("notes-launcher", { + title: "Notes", icon: "notebook-pen", opens: "notes", +}); +ctx.ui.web.setHeaderAction("open-notes", { + icon: "scroll-text", + title: "Open notes", + invoke: () => ({ effects: [{ type: "open-panel", key: "notes" }] }), +}); +``` + + ## Artifact preview action API `ctx.ui.web.setArtifactAction(key, action)` adds an action to matching Markdown, HTML, or video artifact preview cards. Match by preview kind, filename extension, or both. The handler receives the artifact's name, `/api/artifacts/...` path, and kind, and may return Markdown or a plain-text message shown in the card. @@ -158,7 +217,7 @@ ctx.ui.web.setArtifactAction("publish", undefined); `ctx.ui.web.setGitTab(key, tab)` contributes a provider-specific tab to pi-web's built-in Git side panel. Core pi-web owns the Git drawer; extensions own provider detection, data fetching, and trusted HTML rendering. -Elements inside the HTML can call back into the extension by using `data-web-git-tab-action` and optional JSON in `data-web-git-tab-payload`. An action can also return `composerContext` without `html`; pi-web keeps the current tab visible and adds the plain-text context as a removable composer pill. The context content is included with the next prompt. +Elements inside the HTML can call back into the extension by using `data-web-action` and optional JSON in `data-web-payload` (the legacy Git-tab names remain accepted). An action can also return `composerContext` without `html`; pi-web keeps the current tab visible and adds the plain-text context as a removable composer pill. The context content is included with the next prompt. ```ts ctx.ui.web.setGitTab("github", { @@ -176,7 +235,7 @@ ctx.ui.web.setGitTab("github", { }; } return { - html: ``, + html: ``, }; }, }); diff --git a/server.ts b/server.ts index 100c246..7030f8f 100644 --- a/server.ts +++ b/server.ts @@ -631,13 +631,30 @@ const server = createServer(async (req, res) => { }); } + if (method === "POST" && url.pathname === "/api/web-contributions/invoke") { + const body = await readBody(req) as { sessionId?: unknown } & Record; + try { + return sendJson(res, 200, { ok: true, ...await sessionService.invokeContribution(resolveSessionId(body.sessionId), body) }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const status = error instanceof SessionServiceError ? error.status + : message === "key is required" || message.includes("returned no") || message.includes("returned unknown panel") || message === "Contribution is not invokable" ? 400 + : message.includes("not found") ? 404 + : 500; + return sendJson(res, status, { ok: false, error: message }); + } + } + if (method === "POST" && url.pathname === "/api/web-header-action/invoke") { const body = await readBody(req) as { sessionId?: unknown; key?: unknown }; try { return sendJson(res, 200, { ok: true, ...await sessionService.invokeHeaderAction(resolveSessionId(body.sessionId), body.key) }); } catch (error) { const message = error instanceof Error ? error.message : String(error); - const status = error instanceof SessionServiceError ? error.status : message === "key is required" || message === "Header action returned no markdown" ? 400 : message === "Header action not found" ? 404 : 500; + const status = error instanceof SessionServiceError ? error.status + : message === "key is required" || message === "Header action returned no markdown" || message === "Header action returned no result" || message.includes("Header action returned unknown panel") ? 400 + : message === "Header action not found" ? 404 + : 500; return sendJson(res, status, { ok: false, error: message }); } } @@ -664,6 +681,17 @@ const server = createServer(async (req, res) => { } } + if (method === "POST" && url.pathname === "/api/web-panel/invoke") { + const body = await readBody(req) as { sessionId?: unknown; key?: unknown; action?: unknown; payload?: unknown; fields?: unknown }; + try { + return sendJson(res, 200, { ok: true, ...await sessionService.invokePanel(resolveSessionId(body.sessionId), body) }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const status = error instanceof SessionServiceError ? error.status : message === "key is required" || message === "Panel returned no HTML" ? 400 : message === "Panel not found" ? 404 : 500; + return sendJson(res, status, { ok: false, error: message }); + } + } + if (method === "GET" && url.pathname === "/api/session/stats") { return sendJson(res, 200, { ok: true, ...await sessionService.stats(resolveSessionId(url.searchParams.get("sessionId"))) }); } diff --git a/server/extensions/webUi.ts b/server/extensions/webUi.ts index 3d836bd..1f43785 100644 --- a/server/extensions/webUi.ts +++ b/server/extensions/webUi.ts @@ -1,6 +1,6 @@ import { randomUUID } from "node:crypto"; import type { ExtensionUIDialogOptions, ExtensionUIContext } from "@earendil-works/pi-coding-agent"; -import type { PiWebArtifactAction, PiWebFooter, PiWebGitTab, PiWebHeaderAction, PiWebRegisterSettingsResult, PiWebSettingsRegistration, PiWebStoredSettings, PiWebUi } from "../../src/extensions.js"; +import type { PiWebArtifactAction, PiWebFabAction, PiWebFooter, PiWebGitTab, PiWebHeaderAction, PiWebPanel, PiWebRegisterSettingsResult, PiWebSettingsRegistration, PiWebStoredSettings, PiWebUi } from "../../src/extensions.js"; import type { createSettingsStore } from "../settings.js"; import { ExtensionRevisionConflictError, isValidExtensionOwnerId } from "../settings.js"; import { canonicalSchemaKey, defaultSettingsValues, validateSettingsValues } from "../extensionSettings.js"; @@ -43,7 +43,9 @@ 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 }; + | { version: 1; key: string; slot: "git-tab"; kind: "rendered"; source: PiWebGitTab } + | { version: 1; key: string; slot: "panel"; kind: "rendered"; source: PiWebPanel } + | { version: 1; key: string; slot: "fab"; kind: "static"; source: PiWebFabAction }; /** Canonical per-runtime registry. Legacy surfaces below are wire adapters over it. */ const webContributionStates = new WeakMap>(); @@ -369,48 +371,48 @@ function normalizePiWebFooter(value: unknown): PiWebFooter | undefined { return undefined; } +const cleanIcon = (value: unknown) => cleanHeaderActionText(value, 80); +const cleanArtifactExtensions = (value: unknown) => Array.isArray(value) ? value.flatMap((extension) => { + const cleaned = cleanHeaderActionText(extension, 30)?.toLowerCase(); + return cleaned && /^\.[a-z0-9]+$/.test(cleaned) ? [cleaned] : []; +}).slice(0, 20) : undefined; + 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", - }, + footer: { descriptor: (entry: Extract) => ({ view: entry.view }) }, + "header-action": { descriptor: (_entry: Extract) => ({}) }, + "artifact-action": { descriptor: (entry: Extract) => ({ match: { + kinds: Array.isArray(entry.source.kinds) ? entry.source.kinds.filter((kind) => kind === "markdown" || kind === "html" || kind === "video") : undefined, + extensions: cleanArtifactExtensions(entry.source.extensions), + } }) }, + "git-tab": { descriptor: (_entry: Extract) => ({}) }, + panel: { descriptor: (_entry: Extract) => ({}) }, + fab: { descriptor: (entry: Extract) => ({ opens: cleanContributionKey(entry.source.opens) }) }, } 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 webContributionEntries(value: any) { + return Array.from(contributionState(value).values()).flatMap((entry) => { + const source = "source" in entry ? entry.source : undefined; + const descriptor = contributionPolicies[entry.slot].descriptor(entry as never); + if (entry.slot === "fab" && !(descriptor as { opens?: string }).opens) return []; + return [{ + version: entry.version, + key: entry.key, + slot: entry.slot, + kind: entry.kind, + ...(source && "title" in source ? { title: cleanHeaderActionText(source.title) || entry.key } : {}), + ...(source && "label" in source ? { label: cleanHeaderActionText(source.label, 80) } : {}), + ...(source && "icon" in source ? { icon: cleanIcon(source.icon) } : {}), + ...descriptor, + }]; + }); +} + +function broadcastContributions(value: any) { + const webContributions = webContributionEntries(value); + deps.emit({ type: "web_contributions_changed", sessionId: value.sessionId, sessionFile: value.sessionFile, webContributions }); + return webContributions; } function setContribution( @@ -425,7 +427,7 @@ function setContribution( const id = contributionId(slot, key); if (contribution) contributionState(value).set(id, contribution); else contributionState(value).delete(id); - broadcastContributions(value, slot); + broadcastContributions(value); } function createPiWebUi(value: any): PiWebUi { @@ -457,6 +459,20 @@ function createPiWebUi(value: any): PiWebUi { : undefined )); }, + setPanel(key, panel) { + setContribution(value, "panel", key, (cleanKey) => ( + panel && typeof panel === "object" && typeof panel.render === "function" + ? { version: 1, key: cleanKey, slot: "panel", kind: "rendered", source: panel } + : undefined + )); + }, + setFabAction(key, action) { + setContribution(value, "fab", key, (cleanKey) => ( + action && typeof action === "object" && cleanContributionKey(action.opens) + ? { version: 1, key: cleanKey, slot: "fab", kind: "static", source: action } + : undefined + )); + }, async registerSettings(schema) { return registerSessionSettings(value, schema); }, async getSettings(id) { return getExtensionSettings(id); }, }; @@ -636,7 +652,7 @@ async function bindWebExtensions(value: any) { } - function renderedContribution(value: any, slot: S, keyValue: unknown) { + function renderedContribution(value: any, slot: S, keyValue: unknown) { const key = cleanContributionKey(keyValue); if (!key) throw new Error("key is required"); const contribution = contributionState(value).get(contributionId(slot, key)); @@ -649,8 +665,17 @@ async function bindWebExtensions(value: any) { 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"); - return { label: cleanHeaderActionText(action.label) || cleanHeaderActionText(action.title) || key, markdown }; + const openPanelEffect = Array.isArray(result?.effects) + ? result.effects.find((effect) => effect?.type === "open-panel") + : undefined; + const openPanel = cleanContributionKey(openPanelEffect?.key); + if (openPanel && !contributionState(value).has(contributionId("panel", openPanel))) throw new Error(`Header action returned unknown panel "${openPanel}"`); + if (!markdown && !openPanel) throw new Error("Header action returned no result"); + return { + label: cleanHeaderActionText(action.label) || cleanHeaderActionText(action.title) || key, + ...(markdown ? { markdown } : {}), + ...(openPanel ? { effects: [{ type: "open-panel", key: openPanel }] } : {}), + }; } async function invokeArtifactAction(value: any, input: { key?: unknown; name?: unknown; path?: unknown; kind?: unknown }) { @@ -714,6 +739,49 @@ async function bindWebExtensions(value: any) { }; } + async function invokePanel(value: any, input: { key?: unknown; action?: unknown; payload?: unknown; fields?: unknown }) { + const { contribution } = renderedContribution(value, "panel", input.key); + if (!contribution) throw new Error("Panel not found"); + const rawFields = input.fields && typeof input.fields === "object" && !Array.isArray(input.fields) + ? input.fields as Record + : undefined; + const cleanFieldValue = (field: string) => field + .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, "") + .slice(0, 100_000); + const fields = rawFields ? Object.entries(rawFields).slice(0, 128).reduce>((cleaned, [name, field]) => { + const cleanName = cleanHeaderActionText(name, 200); + if (!cleanName) return cleaned; + if (typeof field === "string") cleaned[cleanName] = cleanFieldValue(field); + else if (Array.isArray(field)) cleaned[cleanName] = field.flatMap((item) => typeof item === "string" ? [cleanFieldValue(item)] : []).slice(0, 100); + return cleaned; + }, {}) : undefined; + const result = await contribution.source.render({ + action: cleanHeaderActionText(input.action, 200), + payload: input.payload, + fields, + }); + const html = cleanFooterText(result?.html, 500_000); + if (!html) throw new Error("Panel returned no HTML"); + return { title: cleanHeaderActionText(result?.title), html }; + } + + async function invokeContribution(value: any, input: { slot?: unknown; key?: unknown; event?: unknown }) { + const slot = input.slot; + const event = input.event && typeof input.event === "object" ? input.event as Record : {}; + if (slot === "header-action") return invokeHeaderAction(value, input.key); + if (slot === "artifact-action") { + const context = event.context && typeof event.context === "object" ? event.context as Record : {}; + return invokeArtifactAction(value, { ...context, key: input.key }); + } + if (slot === "git-tab") { + return invokeGitTab(value, { key: input.key, action: event.action, payload: event.payload, repo: event.context }); + } + if (slot === "panel") { + return invokePanel(value, { key: input.key, action: event.action, payload: event.payload, fields: event.fields }); + } + throw new Error("Contribution is not invokable"); + } + function respond(id: string, response: Record): boolean { const pending = pendingExtensionUiRequests.get(id); if (!pending) return false; @@ -723,10 +791,12 @@ async function bindWebExtensions(value: any) { return { bind: bindWebExtensions, - 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) }), + entries: (value: any) => ({ webContributions: webContributionEntries(value) }), + invokeContribution, invokeHeaderAction, invokeArtifactAction, invokeGitTab, + invokePanel, respond, registerSettings: (session: any, schema: PiWebSettingsRegistration) => registerSessionSettings(session, schema), settingsSchemas: activeSettingsSchemaList, diff --git a/server/session/dto.ts b/server/session/dto.ts index ee33366..499d637 100644 --- a/server/session/dto.ts +++ b/server/session/dto.ts @@ -163,9 +163,11 @@ export interface SessionService { abortBranchSummary(sessionId: string): Promise<{ sessionId: string }>; rename(sessionId: string, name: string): Promise; navigate(sessionId: string, targetId: string, options: Record): Promise; + invokeContribution(sessionId: string, input: Record): Promise>; invokeHeaderAction(sessionId: string, key: unknown): Promise>; invokeArtifactAction(sessionId: string, input: Record): Promise>; invokeGitTab(sessionId: string, input: Record): Promise>; + invokePanel(sessionId: string, input: Record): Promise>; list(extraCwds?: string[]): Promise; create(previousSessionId: string | undefined, cwd?: string): Promise; open(sessionId: string, cwd?: string): Promise; diff --git a/server/session/hostEvents.ts b/server/session/hostEvents.ts index e8a2832..6337d37 100644 --- a/server/session/hostEvents.ts +++ b/server/session/hostEvents.ts @@ -6,10 +6,7 @@ export type HostSessionStateDecoration = { runtimeStartedAt?: string; runtimeLastActivityAt?: string; runtime: ReturnType; - webFooters: unknown[]; - webHeaderActions: unknown[]; - webArtifactActions: unknown[]; - webGitTabs: unknown[]; + webContributions: unknown[]; }; export type DecoratedSessionState = BaseSessionStateDto & HostSessionStateDecoration; export type WireSessionState = Omit & { thinkingLevels?: string[] }; @@ -17,7 +14,7 @@ export type WireSessionState = Omit & { type HostEventDependencies = { sessionForId(sessionId: string): PiWebSession | undefined; projectState(session: PiWebSession): BaseSessionStateDto; - webUiEntries(session: PiWebSession): Pick; + webUiEntries(session: PiWebSession): Pick; sessionActivity: SessionActivity; broadcast(value: unknown): void; markSessionUnreadCompleted(sessionId: string): void; diff --git a/server/session/service.ts b/server/session/service.ts index 491aecf..492478b 100644 --- a/server/session/service.ts +++ b/server/session/service.ts @@ -393,6 +393,10 @@ export class LocalSessionService implements SessionService { } } + invokeContribution(sessionId: string | undefined, input: Record) { + return this.require(sessionId).then((value) => this.webUiBridge.invokeContribution(value, input)); + } + invokeHeaderAction(sessionId: string | undefined, key: unknown) { return this.require(sessionId).then((value) => this.webUiBridge.invokeHeaderAction(value, key)); } @@ -405,6 +409,10 @@ export class LocalSessionService implements SessionService { return this.require(sessionId).then((value) => this.webUiBridge.invokeGitTab(value, input)); } + invokePanel(sessionId: string | undefined, input: Record) { + return this.require(sessionId).then((value) => this.webUiBridge.invokePanel(value, input)); + } + respondExtensionUi(id: string, response: Record) { return this.webUiBridge.respond(id, response); } extensionStatus(sessionId: string) { diff --git a/src/app/actionLauncher.ts b/src/app/actionLauncher.ts index 598d439..0634d6c 100644 --- a/src/app/actionLauncher.ts +++ b/src/app/actionLauncher.ts @@ -1,13 +1,45 @@ import type { AppElements } from "./elements.js"; -import { iconElement, type IconName } from "./icons.js"; +import { iconElement, isIconName, type IconName } from "./icons.js"; type LauncherAction = { label: string; icon: IconName; - target: HTMLButtonElement; + run: () => void; }; -export function initActionLauncher(elements: AppElements) { +type ExtensionLauncherAction = { + key: string; + label: string; + icon: IconName; + opens: string; +}; + +/** FAB entries are explicit launcher registrations; each must reference a panel. */ +function normalizeExtensionActions(value: unknown): ExtensionLauncherAction[] { + if (!Array.isArray(value)) return []; + const seen = new Set(); + return value.flatMap((raw): ExtensionLauncherAction[] => { + if (!raw || typeof raw !== "object") return []; + const entry = raw as Record; + const key = typeof entry.key === "string" ? entry.key.trim() : ""; + const opens = typeof entry.opens === "string" ? entry.opens.trim() : ""; + if (!key || !opens || seen.has(key)) return []; + seen.add(key); + const title = typeof entry.title === "string" && entry.title.trim() ? entry.title.trim() : key; + const label = typeof entry.label === "string" && entry.label.trim() ? entry.label.trim() : title; + const icon = typeof entry.icon === "string" && isIconName(entry.icon) ? entry.icon : "square-pen"; + return [{ key, label, icon, opens }]; + }); +} + +export type ActionLauncherController = { + setExtensionActions(value: unknown): void; +}; + +export function initActionLauncher( + elements: AppElements, + options: { onExtensionAction?: (opensPanelKey: string) => void } = {}, +): ActionLauncherController { const root = document.createElement("div"); root.className = "actionLauncher"; @@ -17,49 +49,14 @@ export function initActionLauncher(elements: AppElements) { menu.setAttribute("role", "menu"); menu.hidden = true; - const actions: LauncherAction[] = [ - { label: "Git", icon: "git-branch", target: elements.gitButton }, - { label: "Settings", icon: "settings", target: elements.settingsButton }, - { label: "File explorer", icon: "folder-tree", target: elements.filesButton }, - { label: "Conversation tree", icon: "git-fork", target: elements.conversationTreeButton }, - { label: "New session", icon: "square-pen", target: elements.newSessionHeaderButton }, + const builtInActions: LauncherAction[] = [ + { label: "Git", icon: "git-branch", run: () => elements.gitButton.click() }, + { label: "Settings", icon: "settings", run: () => elements.settingsButton.click() }, + { label: "File explorer", icon: "folder-tree", run: () => elements.filesButton.click() }, + { label: "Conversation tree", icon: "git-fork", run: () => elements.conversationTreeButton.click() }, + { label: "New session", icon: "square-pen", run: () => elements.newSessionHeaderButton.click() }, ]; - - for (const action of actions) { - const button = document.createElement("button"); - button.className = "actionLauncherItem"; - button.type = "button"; - button.setAttribute("role", "menuitem"); - button.title = action.label; - button.append(iconElement(action.icon)); - const label = document.createElement("span"); - label.textContent = action.label; - button.append(label); - // The launcher lives inside the form, so do not let an action take focus - // and temporarily activate/expand the composer before opening its panel. - button.addEventListener("pointerdown", (event) => event.preventDefault()); - button.addEventListener("click", () => { - button.blur(); - elements.promptEl.blur(); - elements.formEl.classList.add("compactInactive"); - setOpen(false); - action.target.click(); - }); - menu.append(button); - } - - const toggle = document.createElement("button"); - toggle.className = "actionLauncherToggle"; - toggle.type = "button"; - toggle.setAttribute("aria-label", "Open app tools"); - toggle.setAttribute("aria-controls", menu.id); - toggle.setAttribute("aria-expanded", "false"); - toggle.title = "App tools"; - const mascot = document.createElement("img"); - mascot.src = "/pi-mascot-avatar.png"; - mascot.alt = ""; - toggle.append(mascot); - + let extensionActions: ExtensionLauncherAction[] = []; let menuHideTimer: number | undefined; function setOpen(open: boolean) { @@ -80,6 +77,68 @@ export function initActionLauncher(elements: AppElements) { toggle.setAttribute("aria-label", open ? "Close app tools" : "Open app tools"); } + function renderActions() { + menu.textContent = ""; + const actions: LauncherAction[] = [ + ...builtInActions, + ...extensionActions.map((action) => ({ + label: action.label, + icon: action.icon, + run: () => options.onExtensionAction?.(action.opens), + })), + ]; + const lastIndex = Math.max(0, actions.length - 1); + actions.forEach((action, index) => { + const button = document.createElement("button"); + button.className = "actionLauncherItem"; + button.type = "button"; + button.setAttribute("role", "menuitem"); + button.title = action.label; + button.append(iconElement(action.icon)); + const label = document.createElement("span"); + label.textContent = action.label; + button.append(label); + + const fromBottom = lastIndex - index; + const arc = lastIndex > 0 ? fromBottom / lastIndex : 0; + const defaultX = [-5, -18, -36, -51, -59]; + const defaultY = [-254, -204, -154, -103, -52]; + const defaultFocusedY = [-202, -152, -102, -51, 0]; + const fanX = actions.length === 5 ? defaultX[index] : Math.round(-59 + 54 * arc); + const fanY = actions.length === 5 ? defaultY[index] : Math.round(-52 - 50.5 * fromBottom); + const focusedY = actions.length === 5 ? defaultFocusedY[index] : Math.round(-50.5 * fromBottom); + button.style.setProperty("--fan-x", `${fanX}px`); + button.style.setProperty("--fan-y", `${fanY}px`); + button.style.setProperty("--fan-focused-y", `${focusedY}px`); + button.style.setProperty("--fan-open-delay", `${(fromBottom * 0.035).toFixed(3)}s`); + button.style.setProperty("--fan-close-delay", `${(index * 0.035).toFixed(3)}s`); + + // The launcher lives inside the form, so do not let an action take focus + // and temporarily activate/expand the composer before opening its panel. + button.addEventListener("pointerdown", (event) => event.preventDefault()); + button.addEventListener("click", () => { + button.blur(); + elements.promptEl.blur(); + elements.formEl.classList.add("compactInactive"); + setOpen(false); + action.run(); + }); + menu.append(button); + }); + } + + const toggle = document.createElement("button"); + toggle.className = "actionLauncherToggle"; + toggle.type = "button"; + toggle.setAttribute("aria-label", "Open app tools"); + toggle.setAttribute("aria-controls", menu.id); + toggle.setAttribute("aria-expanded", "false"); + toggle.title = "App tools"; + const mascot = document.createElement("img"); + mascot.src = "/pi-mascot-avatar.png"; + mascot.alt = ""; + toggle.append(mascot); + // Keep the launcher from putting the composer into its focus-within state. // Keyboard activation still works; focus is returned to the page after toggling. toggle.addEventListener("pointerdown", (event) => event.preventDefault()); @@ -100,6 +159,14 @@ export function initActionLauncher(elements: AppElements) { } }); + renderActions(); root.append(menu, toggle); elements.formEl.append(root); + + return { + setExtensionActions(value) { + extensionActions = normalizeExtensionActions(value); + renderActions(); + }, + }; } diff --git a/src/app/icons.ts b/src/app/icons.ts index 4c3f8ba..67bd6d8 100644 --- a/src/app/icons.ts +++ b/src/app/icons.ts @@ -1,4 +1,4 @@ -import { ArrowLeft, Bell, Bookmark, Brain, Check, Copy, CornerDownRight, createElement, Flag, FolderTree, Funnel, GitBranch, GitFork, Hourglass, Info, KeyRound, Maximize2, Minimize2, Menu, MoreVertical, Paperclip, Pin, RotateCcw, Route, ScrollText, SendHorizontal, Settings, Square, SquarePen, Star, Trash2, X } from "lucide"; +import { ArrowLeft, Bell, Bookmark, Brain, Check, Copy, CornerDownRight, createElement, Flag, FolderTree, Funnel, GitBranch, GitFork, Hourglass, Info, KeyRound, Maximize2, Minimize2, Menu, MoreVertical, NotebookPen, Paperclip, Pin, RotateCcw, Route, ScrollText, SendHorizontal, Settings, Square, SquarePen, Star, Trash2, X } from "lucide"; const iconNodes = { "arrow-left": ArrowLeft, @@ -18,6 +18,7 @@ const iconNodes = { "key-round": KeyRound, menu: Menu, "more-vertical": MoreVertical, + "notebook-pen": NotebookPen, paperclip: Paperclip, pin: Pin, "rotate-ccw": RotateCcw, @@ -36,6 +37,10 @@ const iconNodes = { export type IconName = keyof typeof iconNodes; +export function isIconName(value: string): value is IconName { + return Object.prototype.hasOwnProperty.call(iconNodes, value); +} + export function iconElement(name: IconName) { return createElement(iconNodes[name], { "aria-hidden": "true" }); } diff --git a/src/app/sessionState.ts b/src/app/sessionState.ts index 69d4e44..9107f5f 100644 --- a/src/app/sessionState.ts +++ b/src/app/sessionState.ts @@ -170,7 +170,7 @@ export function reduceSessionSnapshot(state: AppState, value: unknown, fallbackS next.queue = { steering: [], followUp: [] }; } if (runtime) next.runtime = runtime; - for (const key of ["webFooters", "webHeaderActions", "webArtifactActions", "webGitTabs"] as const) { + for (const key of ["webContributions"] as const) { if (hasOwn(data, key)) next[key] = data[key]; } if (completeSnapshot) next.snapshotLoaded = true; diff --git a/src/app/types.ts b/src/app/types.ts index f319992..3ff990f 100644 --- a/src/app/types.ts +++ b/src/app/types.ts @@ -358,10 +358,7 @@ export type SessionViewState = SessionRecord & { thinkingLevels?: string[]; stats?: SessionStats; queue?: SessionQueueState; - webFooters?: unknown; - webHeaderActions?: unknown; - webArtifactActions?: unknown; - webGitTabs?: unknown; + webContributions?: unknown; }; export type AppState = { diff --git a/src/extensions.ts b/src/extensions.ts index 3db3c32..7962ab8 100644 --- a/src/extensions.ts +++ b/src/extensions.ts @@ -36,11 +36,21 @@ export type PiWebFooter = | { kind: "text"; lines: string[] } | { kind: "html"; html: string }; +export type PiWebEffect = + | { type: "open-panel"; key: string }; + +export type PiWebHeaderActionResult = { + /** Markdown rendered in the shared dismissible popover. */ + markdown?: string; + /** Typed host side effects, applied after a successful invocation. */ + effects?: PiWebEffect[]; +}; + export type PiWebHeaderAction = { icon?: string; title: string; label?: string; - invoke: () => Promise<{ markdown: string }> | { markdown: string }; + invoke: () => Promise | PiWebHeaderActionResult; }; export type PiWebArtifactContext = { @@ -100,6 +110,38 @@ export type PiWebGitTab = { render: (event?: PiWebGitTabEvent) => Promise | PiWebGitTabView; }; +export type PiWebPanelEvent = { + /** Action declared by data-web-action on a form or interactive element. */ + action?: string; + /** Optional JSON declared by data-web-payload. */ + payload?: unknown; + /** Successful form controls, grouped by name. */ + fields?: Record; +}; + +export type PiWebPanelView = { + title?: string; + /** Trusted extension-provided HTML rendered in the shared right panel. */ + html: string; +}; + +export type PiWebPanel = { + title: string; + label?: string; + /** lucide icon name; unsupported names fall back to square-pen. */ + icon?: string; + render: (event?: PiWebPanelEvent) => Promise | PiWebPanelView; +}; + +export type PiWebFabAction = { + /** lucide icon name; unsupported names fall back to square-pen. */ + icon?: string; + title: string; + label?: string; + /** Key of the panel surface (setPanel) this launcher opens. */ + opens: string; +}; + // --- Extension-contributed settings (generic platform) --- export type PiWebFieldType = "toggle" | "text" | "textarea" | "number" | "select" | "list"; @@ -183,6 +225,16 @@ export type PiWebUi = { /** Set or clear a provider-specific tab in the built-in Git panel. */ setGitTab(key: string, tab: PiWebGitTab | undefined): void; + /** + * Set or clear an extension panel surface (shared right panel). Panels have + * NO implicit entry point: register a FAB launcher with setFabAction, open + * from a header action via an `open-panel` effect, or any future affordance. + */ + setPanel(key: string, panel: PiWebPanel | undefined): void; + + /** Set or clear a mascot-FAB launcher entry that opens a registered panel. */ + setFabAction(key: string, action: PiWebFabAction | undefined): void; + /** * Register a settings schema contributed by this extension. Idempotent per * session (re-registering the same canonical schema just refreshes callbacks). diff --git a/src/extensions/webHeaderActions.ts b/src/extensions/webHeaderActions.ts index 06ba681..e8cb202 100644 --- a/src/extensions/webHeaderActions.ts +++ b/src/extensions/webHeaderActions.ts @@ -9,6 +9,8 @@ type Options = { headers: ApiHeaders; getSessionId: () => string; markdown: MarkdownRenderer; + /** Open an extension panel by key (header actions may return `openPanel`). */ + openPanel?: (key: string) => void; }; const knownIcons = new Set([ @@ -16,7 +18,7 @@ const knownIcons = new Set([ "paperclip", "pin", "route", "scroll-text", "send-horizontal", "settings", "square", "square-pen", "star", "trash-2", "maximize-2", "minimize-2", "x", ]); -export function createWebHeaderActions({ container, headers, getSessionId, markdown }: Options) { +export function createWebHeaderActions({ container, headers, getSessionId, markdown, openPanel }: Options) { let activeKey: string | undefined; let popover: HTMLDivElement | undefined; @@ -55,16 +57,27 @@ export function createWebHeaderActions({ container, headers, getSessionId, markd activeKey = action.key; button.classList.add("active"); showPopover(action.label || action.title, "Loading…"); + const invokedSessionId = getSessionId(); try { - const res = await fetch("/api/web-header-action/invoke", { + const res = await fetch("/api/web-contributions/invoke", { method: "POST", headers: headers(), - body: JSON.stringify({ sessionId: getSessionId(), key: action.key }), + body: JSON.stringify({ sessionId: invokedSessionId, slot: "header-action", key: action.key }), }); const data = await res.json().catch(() => ({})); + if (getSessionId() !== invokedSessionId) return; if (!res.ok || !data.ok) throw new Error(data.error || res.statusText); - showPopover(String(data.label || action.label || action.title), String(data.markdown || ""), true); + const openPanelEffect = Array.isArray(data.effects) + ? data.effects.find((effect: any) => effect?.type === "open-panel" && typeof effect.key === "string") + : undefined; + const responseMarkdown = typeof data.markdown === "string" && data.markdown ? data.markdown : undefined; + if (openPanelEffect) { + close(); + openPanel?.(openPanelEffect.key); + } + if (responseMarkdown) showPopover(String(data.label || action.label || action.title), responseMarkdown, true); } catch (error) { + if (getSessionId() !== invokedSessionId) return; showPopover(action.label || action.title, error instanceof Error ? error.message : String(error)); } } diff --git a/src/extensions/webPanels.ts b/src/extensions/webPanels.ts new file mode 100644 index 0000000..49b8f3f --- /dev/null +++ b/src/extensions/webPanels.ts @@ -0,0 +1,209 @@ +import { iconElement, isIconName } from "../app/icons.js"; +import type { RightPanelHandle, RightPanelManager } from "../layout/rightPanel.js"; + +type WebPanelEntry = { + key: string; + title: string; + label: string; + icon: string; +}; + +type WebPanelView = { + title?: string; + html?: string; +}; + +export type WebPanelsController = { + setPanels(value: unknown, sessionId: string): void; + entries(): WebPanelEntry[]; + open(key: string): void; + isOpen(): boolean; +}; + +function normalizePanels(value: unknown): WebPanelEntry[] { + if (!Array.isArray(value)) return []; + const seen = new Set(); + return value.flatMap((raw): WebPanelEntry[] => { + if (!raw || typeof raw !== "object") return []; + const entry = raw as Record; + const key = typeof entry.key === "string" ? entry.key.trim() : ""; + if (!key || seen.has(key)) return []; + seen.add(key); + const title = typeof entry.title === "string" && entry.title.trim() ? entry.title.trim() : key; + const label = typeof entry.label === "string" && entry.label.trim() ? entry.label.trim() : title; + const icon = typeof entry.icon === "string" && isIconName(entry.icon) ? entry.icon : "square-pen"; + return [{ key, title, label, icon }]; + }); +} + +function parsePayload(value: string | undefined) { + if (!value) return undefined; + try { return JSON.parse(value); } catch { return value; } +} + +function formFields(form: HTMLFormElement | null) { + if (!form) return undefined; + const fields: Record = {}; + for (const [key, value] of new FormData(form)) { + if (typeof value !== "string") continue; + const current = fields[key]; + if (current === undefined) fields[key] = value; + else if (Array.isArray(current)) current.push(value); + else fields[key] = [current, value]; + } + return fields; +} + +export function createWebPanels(options: { + rightPanels: RightPanelManager; + apiHeaders: () => HeadersInit; + getSessionId: () => string; +}): WebPanelsController { + const { rightPanels, apiHeaders, getSessionId } = options; + const panel = document.createElement("section"); + panel.className = "webPanel"; + panel.id = "webExtensionPanel"; + panel.hidden = true; + panel.setAttribute("aria-labelledby", "webExtensionPanelTitle"); + + const header = document.createElement("header"); + header.className = "webPanelHeader"; + const heading = document.createElement("h2"); + heading.id = "webExtensionPanelTitle"; + const headingIcon = document.createElement("span"); + headingIcon.className = "webPanelTitleIcon"; + const headingText = document.createElement("span"); + heading.append(headingIcon, headingText); + const close = document.createElement("button"); + close.className = "webPanelClose"; + close.type = "button"; + close.title = "Close panel"; + close.setAttribute("aria-label", "Close panel"); + close.append(iconElement("x")); + header.append(heading, close); + + const body = document.createElement("div"); + body.className = "webPanelBody"; + panel.append(header, body); + document.body.append(panel); + + let panels: WebPanelEntry[] = []; + let sessionId = ""; + let activeKey = ""; + let requestGeneration = 0; + let panelHandle: RightPanelHandle; + + function activePanel() { + return panels.find((entry) => entry.key === activeKey); + } + + function renderHeading(entry: WebPanelEntry, title?: string) { + headingIcon.textContent = ""; + headingIcon.append(iconElement(isIconName(entry.icon) ? entry.icon : "square-pen")); + headingText.textContent = title?.trim() || entry.title; + } + + function renderError(error: unknown) { + body.textContent = ""; + const message = document.createElement("div"); + message.className = "webPanelError"; + message.setAttribute("role", "alert"); + message.textContent = error instanceof Error ? error.message : String(error); + body.append(message); + } + + async function invoke(event?: { action?: string; payload?: unknown; fields?: Record }) { + const entry = activePanel(); + if (!entry) return; + const generation = ++requestGeneration; + panel.setAttribute("aria-busy", "true"); + if (!event) body.textContent = "Loading…"; + try { + const res = await fetch("/api/web-contributions/invoke", { + method: "POST", + headers: apiHeaders(), + body: JSON.stringify({ sessionId: getSessionId(), slot: "panel", key: entry.key, event }), + }); + const data = await res.json().catch(() => ({})) as { ok?: boolean; error?: string } & WebPanelView; + if (!res.ok || !data.ok) throw new Error(data.error || res.statusText || "Panel request failed"); + if (generation !== requestGeneration || activeKey !== entry.key) return; + if (typeof data.html !== "string") throw new Error("Panel returned no content"); + renderHeading(entry, data.title); + body.innerHTML = data.html; + const autofocus = body.querySelector("[autofocus]"); + autofocus?.focus({ preventScroll: true }); + } catch (error) { + if (generation === requestGeneration && activeKey === entry.key) renderError(error); + } finally { + if (generation === requestGeneration) panel.removeAttribute("aria-busy"); + } + } + + function open(key: string) { + const entry = panels.find((candidate) => candidate.key === key); + if (!entry) return; + activeKey = key; + renderHeading(entry); + panelHandle.open(); + void invoke(); + } + + function actionTarget(event: Event) { + return event.target instanceof Element + ? event.target.closest("[data-web-action], [data-web-panel-action]") + : null; + } + + body.addEventListener("click", (event) => { + const target = actionTarget(event); + if (!target || !body.contains(target)) return; + if ((target instanceof HTMLButtonElement || target instanceof HTMLInputElement) + && target.type === "submit" && target.form) return; + event.preventDefault(); + void invoke({ + action: target.dataset.webAction || target.dataset.webPanelAction || "", + payload: parsePayload(target.dataset.webPayload || target.dataset.webPanelPayload), + fields: formFields(target.closest("form")), + }); + }); + + body.addEventListener("submit", (event) => { + if (!(event.target instanceof HTMLFormElement)) return; + event.preventDefault(); + const submitter = event.submitter instanceof HTMLElement ? event.submitter : undefined; + void invoke({ + action: submitter?.dataset.webAction || submitter?.dataset.webPanelAction || event.target.dataset.webAction || event.target.dataset.webPanelAction || "", + payload: parsePayload(submitter?.dataset.webPayload || submitter?.dataset.webPanelPayload || event.target.dataset.webPayload || event.target.dataset.webPanelPayload), + fields: formFields(event.target), + }); + }); + + panelHandle = rightPanels.register({ + id: "web-extension", + side: "right", + panel, + closeButton: close, + width: "480px", + minWidth: 320, + maxWidth: 900, + focusOnOpen: close, + onClose: () => { requestGeneration += 1; }, + }); + + return { + setPanels(value, nextSessionId) { + const changedSession = sessionId !== nextSessionId; + sessionId = nextSessionId; + panels = normalizePanels(value); + if (changedSession || (activeKey && !activePanel())) { + requestGeneration += 1; + activeKey = ""; + body.textContent = ""; + if (panelHandle.isOpen()) panelHandle.close(false); + } + }, + entries: () => [...panels], + open, + isOpen: () => panelHandle.isOpen(), + }; +} diff --git a/src/git/panel.ts b/src/git/panel.ts index 2b654df..04acd1c 100644 --- a/src/git/panel.ts +++ b/src/git/panel.ts @@ -261,15 +261,14 @@ export function initGitPanel(options: { panel.setAttribute("aria-busy", "true"); render(); try { - const res = await fetch("/api/web-git-tab/invoke", { + const res = await fetch("/api/web-contributions/invoke", { method: "POST", headers: apiHeaders(), body: JSON.stringify({ sessionId: getSessionId?.(), + slot: "git-tab", key, - action: event?.action, - payload: event?.payload, - repo: selectedRepoContext(), + event: { ...event, context: selectedRepoContext() }, }), }); const data = await res.json().catch(() => ({})); @@ -453,8 +452,8 @@ export function initGitPanel(options: { function invokeExtensionAction(activeKey: string, target: HTMLElement) { void loadExtensionTab(activeKey, { - action: target.dataset.webGitTabAction || "", - payload: parsePayload(target.dataset.webGitTabPayload), + action: target.dataset.webAction || target.dataset.webGitTabAction || "", + payload: parsePayload(target.dataset.webPayload || target.dataset.webGitTabPayload), }); } @@ -462,7 +461,7 @@ export function initGitPanel(options: { const activeKey = extensionKeyFromView(); if (!activeKey) return; const target = event.target instanceof Element - ? event.target.closest("[data-web-git-tab-action]") + ? event.target.closest("[data-web-action], [data-web-git-tab-action]") : undefined; if (!target || !primary.contains(target)) return; event.preventDefault(); @@ -474,7 +473,7 @@ export function initGitPanel(options: { const activeKey = extensionKeyFromView(); if (!activeKey) return; const target = event.target instanceof Element - ? event.target.closest("[data-web-git-tab-action]") + ? event.target.closest("[data-web-action], [data-web-git-tab-action]") : undefined; if (!target || !primary.contains(target)) return; event.preventDefault(); diff --git a/src/main.ts b/src/main.ts index 7ce7fb0..5476ae2 100644 --- a/src/main.ts +++ b/src/main.ts @@ -32,10 +32,11 @@ import { type SessionStateController, } from "./app/sessionState.js"; import { createComposer, type ComposerController } from "./composer/composer.js"; -import { initActionLauncher } from "./app/actionLauncher.js"; +import { initActionLauncher, type ActionLauncherController } from "./app/actionLauncher.js"; import { createContextMeter, type ContextMeterController } from "./composer/contextMeter.js"; import { createWebHeaderActions } from "./extensions/webHeaderActions.js"; import { renderWebFooters } from "./extensions/webFooter.js"; +import { createWebPanels, type WebPanelsController } from "./extensions/webPanels.js"; import { initGitPanel, type GitPanelController } from "./git/panel.js"; import { initFilesPanel, type FilesPanelController } from "./files/panel.js"; import { configureArtifactPreviewActions, createMarkdownRenderer, setArtifactPreviewActions } from "./markdown/render.js"; @@ -69,6 +70,8 @@ let statusBar: StatusBar; let conversationTree: ConversationTreeController; let gitPanel: GitPanelController; let filesPanel: FilesPanelController; +let webPanels: WebPanelsController; +let actionLauncher: ActionLauncherController; let realtime: RealtimeController; async function submitPromptFromMessageAction(message: string) { const promptText = message.trim(); @@ -148,6 +151,7 @@ const webHeaderActions = createWebHeaderActions({ headers: api.headers, getSessionId: () => state.currentSessionId, markdown, + openPanel: (key) => webPanels?.open(key), }); function setSessionInfoOpen(open: boolean) { @@ -253,10 +257,14 @@ function renderActiveSessionMetadata() { state.currentCwd = view?.cwd || ""; filesPanel?.sessionChanged(); - renderWebFooters(elements.extensionFooterEl, view?.webFooters ?? []); - webHeaderActions.render(view?.webHeaderActions ?? []); - setArtifactPreviewActions(view?.webArtifactActions ?? []); - gitPanel?.setExtensionTabs(view?.webGitTabs ?? []); + const contributions = Array.isArray(view?.webContributions) ? view.webContributions as Array> : []; + const inSlot = (slot: string) => contributions.filter((entry) => entry?.version === 1 && entry.slot === slot); + renderWebFooters(elements.extensionFooterEl, inSlot("footer").map(({ key, view: footer }) => ({ key, footer }))); + webHeaderActions.render(inSlot("header-action")); + setArtifactPreviewActions(inSlot("artifact-action").map((entry) => ({ ...entry, ...entry.match }))); + gitPanel?.setExtensionTabs(inSlot("git-tab")); + webPanels?.setPanels(inSlot("panel"), state.currentSessionId); + actionLauncher?.setExtensionActions(inSlot("fab")); statusBar?.setStatusTitle(view?.name?.trim() || view?.title?.trim() || "New session"); elements.statusPathEl.textContent = state.currentCwd; const idValue = elements.sessionInfoId.querySelector("strong"); @@ -313,7 +321,7 @@ function applySessionSnapshot(value: unknown, options: ApplySessionSnapshotOptio const includesRuntimeView = Boolean(data && ["runtime", "isStreaming", "isRetrying", "isCompacting", "stats", "queue"].some((key) => key in data)); const includesMetadataView = Boolean(data && [ "cwd", "model", "thinkingLevel", "sessionName", "sessionTitle", - "webFooters", "webHeaderActions", "webArtifactActions", "webGitTabs", + "webContributions", ].some((key) => key in data)); if (activatesSession || includesMetadataView) renderActiveSessionMetadata(); if (activatesSession || includesRuntimeView) { @@ -512,7 +520,8 @@ realtime = createRealtime({ }); initStaticIcons(); -initActionLauncher(elements); +webPanels = createWebPanels({ rightPanels, apiHeaders: api.headers, getSessionId: () => state.currentSessionId }); +actionLauncher = initActionLauncher(elements, { onExtensionAction: (opensPanelKey) => webPanels.open(opensPanelKey) }); statusBar.init(); sessions.init(); contextMeter.init(); diff --git a/src/markdown/render.ts b/src/markdown/render.ts index 163e32b..752b9d9 100644 --- a/src/markdown/render.ts +++ b/src/markdown/render.ts @@ -359,7 +359,7 @@ function renderArtifactActions(card: HTMLElement, name: string, path: string, ki const original = button.textContent; button.textContent = "Working…"; try { - const res = await fetch("/api/web-artifact-action/invoke", { method: "POST", headers: artifactActionHeaders(), body: JSON.stringify({ sessionId: artifactActionSessionId(), key: action.key, name, path, kind }) }); + const res = await fetch("/api/web-contributions/invoke", { method: "POST", headers: artifactActionHeaders(), body: JSON.stringify({ sessionId: artifactActionSessionId(), slot: "artifact-action", key: action.key, event: { context: { name, path, kind } } }) }); const data = await res.json().catch(() => ({})); if (!res.ok || !data.ok) throw new Error(data.error || res.statusText); if (data.download && typeof data.download.path === "string") { diff --git a/src/realtime/realtime.ts b/src/realtime/realtime.ts index 9a290d9..5f69b17 100644 --- a/src/realtime/realtime.ts +++ b/src/realtime/realtime.ts @@ -778,7 +778,7 @@ export function createRealtime(options: { handleExtensionUiRequest(data); return; } - if (["web_footer_changed", "web_header_actions_changed", "web_artifact_actions_changed", "web_git_tabs_changed"].includes(data.type)) { + if (data.type === "web_contributions_changed") { sessionState.applySnapshot(data); return; } diff --git a/src/style.css b/src/style.css index 4cbf14e..222351d 100644 --- a/src/style.css +++ b/src/style.css @@ -11,4 +11,5 @@ @import "./styles/responsive.css"; @import "./styles/token.css"; @import "./styles/actionLauncher.css"; +@import "./styles/webPanels.css"; @import "./styles/shortcutHelp.css"; diff --git a/src/styles/actionLauncher.css b/src/styles/actionLauncher.css index 54abb90..68b17a5 100644 --- a/src/styles/actionLauncher.css +++ b/src/styles/actionLauncher.css @@ -97,16 +97,9 @@ pointer-events: auto; transition-delay: var(--fan-open-delay, 0s), var(--fan-open-delay, 0s), 0s, 0s; } -.actionLauncherItem:nth-child(1) { --fan-open-delay: .14s; --fan-close-delay: 0s; } -.actionLauncherItem:nth-child(2) { --fan-open-delay: .105s; --fan-close-delay: .035s; } -.actionLauncherItem:nth-child(3) { --fan-open-delay: .07s; --fan-close-delay: .07s; } -.actionLauncherItem:nth-child(4) { --fan-open-delay: .035s; --fan-close-delay: .105s; } -.actionLauncherItem:nth-child(5) { --fan-open-delay: 0s; --fan-close-delay: .14s; } -.actionLauncher.open .actionLauncherItem:nth-child(1) { transform: translate(-5px, -254px) scale(1); } -.actionLauncher.open .actionLauncherItem:nth-child(2) { transform: translate(-18px, -204px) scale(1); } -.actionLauncher.open .actionLauncherItem:nth-child(3) { transform: translate(-36px, -154px) scale(1); } -.actionLauncher.open .actionLauncherItem:nth-child(4) { transform: translate(-51px, -103px) scale(1); } -.actionLauncher.open .actionLauncherItem:nth-child(5) { transform: translate(-59px, -52px) scale(1); } +.actionLauncher.open .actionLauncherItem { + transform: translate(var(--fan-x, -59px), var(--fan-y, -52px)) scale(1); +} .composer.compactInactive:not(.expanded) .actionLauncher, .composer:not(.compactInactive):not(.expanded) .actionLauncher { @@ -129,11 +122,9 @@ .composer:not(.compactInactive):not(.expanded) .actionLauncher:not(.open) .actionLauncherToggle { transform: scale(1.02); } -.composer:not(.compactInactive):not(.expanded) .actionLauncher.open .actionLauncherItem:nth-child(1) { transform: translate(-5px, -202px) scale(1); } -.composer:not(.compactInactive):not(.expanded) .actionLauncher.open .actionLauncherItem:nth-child(2) { transform: translate(-18px, -152px) scale(1); } -.composer:not(.compactInactive):not(.expanded) .actionLauncher.open .actionLauncherItem:nth-child(3) { transform: translate(-36px, -102px) scale(1); } -.composer:not(.compactInactive):not(.expanded) .actionLauncher.open .actionLauncherItem:nth-child(4) { transform: translate(-51px, -51px) scale(1); } -.composer:not(.compactInactive):not(.expanded) .actionLauncher.open .actionLauncherItem:nth-child(5) { transform: translate(-59px, 0) scale(1); } +.composer:not(.compactInactive):not(.expanded) .actionLauncher.open .actionLauncherItem { + transform: translate(var(--fan-x, -59px), var(--fan-focused-y, 0px)) scale(1); +} .composer.expanded .actionLauncher { display: none; } diff --git a/src/styles/webPanels.css b/src/styles/webPanels.css new file mode 100644 index 0000000..5aff434 --- /dev/null +++ b/src/styles/webPanels.css @@ -0,0 +1,141 @@ +.webPanel { + display: grid; + grid-template-rows: auto minmax(0, 1fr); + color: var(--text); +} + +.webPanelHeader { + display: flex; + align-items: center; + justify-content: space-between; + min-height: 56px; + padding: 10px 12px 10px 16px; + border-bottom: 1px solid var(--border); + background: color-mix(in srgb, var(--panel-2) 78%, transparent); +} + +.webPanelHeader h2 { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; + margin: 0; + font-size: 15px; + font-weight: 650; +} + +.webPanelHeader h2 > span:last-child { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.webPanelTitleIcon { + display: grid; + flex: 0 0 auto; + place-items: center; + color: var(--accent); +} + +.webPanelTitleIcon svg, +.webPanelClose svg { width: 18px; height: 18px; } + +.webPanelClose { + display: grid; + flex: 0 0 auto; + place-items: center; + width: 36px; + height: 36px; + padding: 0; + border: 1px solid transparent; + border-radius: 10px; + background: transparent; + color: var(--muted); + cursor: pointer; +} + +.webPanelClose:hover, +.webPanelClose:focus-visible { + border-color: var(--border); + background: var(--panel-2); + color: var(--text); +} + +.webPanelBody { + min-height: 0; + overflow: auto; + padding: 16px; + overscroll-behavior: contain; +} + +.webPanel[aria-busy="true"] .webPanelBody { color: var(--muted); } + +.webPanelError { + padding: 12px; + border: 1px solid color-mix(in srgb, var(--danger) 42%, var(--border)); + border-radius: 10px; + background: color-mix(in srgb, var(--danger) 8%, transparent); + color: var(--danger); + white-space: pre-wrap; +} + +/* Optional primitives for extension-provided forms. Extension HTML remains trusted. */ +.webPanelForm { + display: grid; + grid-template-rows: minmax(220px, 1fr) auto; + gap: 12px; + min-height: 100%; +} + +.webPanelTextarea { + box-sizing: border-box; + width: 100%; + min-height: min(62vh, 680px); + resize: vertical; + padding: 14px; + border: 1px solid var(--border); + border-radius: 12px; + outline: none; + background: color-mix(in srgb, var(--panel-2) 82%, black); + color: var(--text); + font: 14px/1.55 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + tab-size: 2; +} + +.webPanelTextarea:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 16%, transparent); +} + +.webPanelFormActions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 10px; +} + +.webPanelFormStatus { + margin-right: auto; + color: var(--muted); + font-size: 12px; +} + +.webPanelButton { + min-height: 36px; + padding: 7px 14px; + border: 1px solid color-mix(in srgb, var(--accent) 50%, var(--border)); + border-radius: 9px; + background: color-mix(in srgb, var(--accent) 18%, var(--panel-2)); + color: var(--text); + font: inherit; + font-weight: 600; + cursor: pointer; +} + +.webPanelButton:hover, +.webPanelButton:focus-visible { border-color: var(--accent); } + +@media (max-width: 640px) { + .webPanelBody { padding: 12px; } + .webPanelTextarea { min-height: calc(var(--app-height) - 150px); } +} diff --git a/tests/api.test.ts b/tests/api.test.ts index 253f5b4..fb1abc0 100644 --- a/tests/api.test.ts +++ b/tests/api.test.ts @@ -530,6 +530,7 @@ describe("pi-web mock API", () => { [`/api/messages?sessionId=${missing}`, {}, 404], ["/api/sessions/open", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ id: missing }) }, 404], ["/api/session/cwd", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ sessionId: missing, cwd: "/tmp" }) }, 404], + ["/api/web-contributions/invoke", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ sessionId: missing, slot: "panel", key: "notes" }) }, 404], ["/api/web-header-action/invoke", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ sessionId: missing, key: "recap" }) }, 404], ["/api/web-git-tab/invoke", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ sessionId: missing, key: "status" }) }, 404], [`/api/session/tree?sessionId=${missing}`, {}, 404], diff --git a/tests/e2e/git.spec.ts b/tests/e2e/git.spec.ts index 4b90509..cab71b3 100644 --- a/tests/e2e/git.spec.ts +++ b/tests/e2e/git.spec.ts @@ -6,11 +6,11 @@ test.beforeEach(async ({ page }) => { test("GitHub issue numbers attach issue details to the composer context", async ({ page }) => { await page.request.post("/api/mock/state", { data: { - webGitTabs: [{ key: "github", title: "GitHub issues", label: "GitHub" }], + webContributions: [{ version: 1, key: "github", slot: "git-tab", kind: "rendered", title: "GitHub issues", label: "GitHub" }], } }); - await page.route("**/api/web-git-tab/invoke", async (route) => { + await page.route("**/api/web-contributions/invoke", async (route) => { const request = route.request().postDataJSON(); - if (request.action === "attach-context") { + if (request.event?.action === "attach-context") { await route.fulfill({ json: { ok: true, title: "GitHub", @@ -55,9 +55,9 @@ test("GitHub issue numbers attach issue details to the composer context", async test("extension tabs remain available in split view with a reduced viewport height", async ({ page }) => { await page.setViewportSize({ width: 900, height: 500 }); await page.request.post("/api/mock/state", { data: { - webGitTabs: [{ key: "github", title: "GitHub issues", label: "GitHub" }], + webContributions: [{ version: 1, key: "github", slot: "git-tab", kind: "rendered", title: "GitHub issues", label: "GitHub" }], } }); - await page.route("**/api/web-git-tab/invoke", (route) => route.fulfill({ json: { + await page.route("**/api/web-contributions/invoke", (route) => route.fulfill({ json: { ok: true, title: "GitHub", html: "
GitHub extension content
", diff --git a/tests/e2e/session-info.spec.ts b/tests/e2e/session-info.spec.ts index 41ba589..5f73726 100644 --- a/tests/e2e/session-info.spec.ts +++ b/tests/e2e/session-info.spec.ts @@ -7,7 +7,7 @@ test.beforeEach(async ({ page }) => { test("session info shows copyable metadata and real Git stats while header actions stay in the header", async ({ page, context }) => { await context.grantPermissions(["clipboard-read", "clipboard-write"]); await page.request.post("/api/mock/state", { data: { - webHeaderActions: [{ key: "recap", icon: "scroll-text", title: "Session recap", label: "Recap" }], + webContributions: [{ version: 1, key: "recap", slot: "header-action", kind: "rendered", icon: "scroll-text", title: "Session recap", label: "Recap" }], } }); await page.route("**/api/git/status**", (route) => route.fulfill({ json: { ok: true, diff --git a/tests/e2e/web-footer.spec.ts b/tests/e2e/web-footer.spec.ts index 4d7a242..b030917 100644 --- a/tests/e2e/web-footer.spec.ts +++ b/tests/e2e/web-footer.spec.ts @@ -12,9 +12,9 @@ test("renders extension footers from session snapshots without queue data", asyn const response = await route.fetch(); const state = await response.json(); delete state.queue; - state.webFooters = [{ - key: "test-footer", - footer: { kind: "text", lines: ["Extension footer is active"] }, + state.webContributions = [{ + version: 1, key: "test-footer", slot: "footer", kind: "static", + view: { kind: "text", lines: ["Extension footer is active"] }, }]; await route.fulfill({ response, json: state }); }); diff --git a/tests/e2e/web-panel.spec.ts b/tests/e2e/web-panel.spec.ts new file mode 100644 index 0000000..16dac83 --- /dev/null +++ b/tests/e2e/web-panel.spec.ts @@ -0,0 +1,73 @@ +import { expect, test } from "@playwright/test"; + +test.beforeEach(async ({ page }) => { + await page.request.post("/api/mock/reset"); +}); + +test("opens an extension panel from the FAB and submits its form", async ({ page }) => { + await page.route("**/api/state**", async (route) => { + const response = await route.fetch(); + const state = await response.json(); + state.webContributions = [ + { version: 1, key: "notes", slot: "panel", kind: "rendered", title: "Global notepad", label: "Notepad", icon: "notebook-pen" }, + { version: 1, key: "quiet", slot: "panel", kind: "rendered", title: "No launcher", label: "Quiet", icon: "notebook-pen" }, + { version: 1, key: "notes-launcher", slot: "fab", kind: "static", title: "Global notepad", label: "Notepad", icon: "notebook-pen", opens: "notes" }, + { version: 1, key: "open-notes", slot: "header-action", kind: "rendered", title: "Open notes", label: "Open notes", icon: "scroll-text" }, + ]; + await route.fulfill({ response, json: state }); + }); + + const invocations: any[] = []; + await page.route("**/api/web-contributions/invoke", async (route) => { + const input = route.request().postDataJSON(); + invocations.push(input); + if (input.slot === "header-action") { + await route.fulfill({ json: { + ok: true, + label: "Open notes", + markdown: "Panel opened from the header.", + effects: [{ type: "open-panel", key: "notes" }], + } }); + return; + } + const value = input.event?.fields?.content || "Initial global note"; + const status = input.event?.action === "save" ? "Saved globally" : "Shared with every conversation"; + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + ok: true, + title: "Global notepad", + html: `
${status}
`, + }), + }); + }); + + await page.goto("/"); + await page.locator("#prompt").blur(); + await page.locator(".actionLauncherToggle").click(); + await expect(page.locator(".actionLauncherItem", { hasText: "Notepad" })).toBeVisible(); + await expect(page.locator(".actionLauncherItem", { hasText: "Quiet" })).toHaveCount(0); + await page.locator(".actionLauncherItem", { hasText: "Notepad" }).click(); + + const panel = page.locator("#webExtensionPanel"); + await expect(panel).toBeVisible(); await expect(panel.locator("h2")).toHaveText("Global notepad"); + await expect(panel.locator("textarea")).toHaveValue("Initial global note"); + + await panel.locator("textarea").fill("Remember this everywhere"); + await panel.getByRole("button", { name: "Save" }).click(); + + await expect(panel.getByRole("status")).toHaveText("Saved globally"); + expect(invocations.at(-1)).toMatchObject({ + slot: "panel", + key: "notes", + event: { action: "save", fields: { content: "Remember this everywhere" } }, + }); + + await panel.getByRole("button", { name: "Close panel" }).click(); + await expect(panel).toBeHidden(); + await page.locator('.webHeaderActionButton[title="Open notes"]').click(); + await expect.poll(() => invocations.some((input) => input.slot === "header-action")).toBe(true); + await expect(panel).toBeVisible(); + await expect(page.locator(".webHeaderActionPopoverBody")).toContainText("Panel opened from the header."); +}); diff --git a/tests/extensions.test.ts b/tests/extensions.test.ts index 492ec9b..ee81478 100644 --- a/tests/extensions.test.ts +++ b/tests/extensions.test.ts @@ -98,12 +98,17 @@ describe("bundled extension path discovery", () => { invoke: ({ name }: { name: string }) => ({ download: { filename: `saved-${name}` } }), }); - expect(bridge.entries(session).webArtifactActions).toEqual([{ - key: "download", title: "Download", label: undefined, kinds: ["html"], extensions: [".html"], + expect(bridge.entries(session).webContributions).toEqual([{ + version: 1, key: "download", slot: "artifact-action", kind: "rendered", title: "Download", label: undefined, + match: { kinds: ["html"], extensions: [".html"] }, }]); - expect(emitted.at(-1)).toMatchObject({ type: "web_artifact_actions_changed", sessionId: "session" }); + expect(emitted.at(-1)).toMatchObject({ type: "web_contributions_changed", sessionId: "session" }); await expect(bridge.invokeArtifactAction(session, { key: "download", name: "page.html", path: "/api/artifacts/page.html", kind: "html" })) .resolves.toMatchObject({ download: { path: "/api/artifacts/page.html", filename: "saved-page.html" } }); + await expect(bridge.invokeContribution(session, { + slot: "artifact-action", key: "download", + event: { context: { key: "another-action", name: "page.html", path: "/api/artifacts/page.html", kind: "html" } }, + })).resolves.toMatchObject({ download: { filename: "saved-page.html" } }); await expect(bridge.invokeArtifactAction(session, { key: "download", name: "notes.md", path: "/api/artifacts/notes.md", kind: "markdown" })) .rejects.toThrow("does not match this artifact"); await expect(bridge.invokeArtifactAction(session, { key: "download", name: "page.html", path: "/api/artifacts/other.html", kind: "html" })) @@ -141,24 +146,75 @@ describe("bundled extension path discovery", () => { 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" }], - }); + const contributions = () => bridge.entries(session).webContributions; + expect(contributions().filter((entry: any) => entry.slot === "footer").map(({ key }: { key: string }) => key)).toEqual(["first", "shared", "last"]); + expect(contributions()).toEqual(expect.arrayContaining([ + expect.objectContaining({ key: "first", slot: "footer", view: { kind: "text", lines: ["one"] } }), + expect.objectContaining({ key: "shared", slot: "footer", view: { kind: "text", lines: ["updated"] } }), + expect.objectContaining({ key: "last", slot: "footer", view: { kind: "text", lines: ["three"] } }), + expect.objectContaining({ key: "shared", slot: "header-action", title: "Summary" }), + expect.objectContaining({ key: "shared", slot: "git-tab", 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: [] }); + expect(contributions().filter((entry: any) => entry.slot === "header-action")).toEqual([]); + expect(contributions().filter((entry: any) => entry.slot === "footer")).toHaveLength(3); + expect(contributions().filter((entry: any) => entry.slot === "git-tab")).toHaveLength(1); + expect(emitted.at(-1)).toMatchObject({ type: "web_contributions_changed" }); + }); + + it("serializes and invokes FAB-backed web panels through the web bridge", 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: () => ({}), + } as any); + const session = { + sessionId: "session", sessionFile: "/tmp/session.jsonl", agent: { waitForIdle: async () => undefined }, + bindExtensions: async (options: any) => { ui = options.uiContext; }, + }; + await bridge.bind(session); + let lastPanelEvent: any; + ui.web.setPanel("notes", { + title: "Global notes", label: "Notepad", icon: "notebook-pen", + render: (event: any) => { + lastPanelEvent = event; + return { title: event?.action === "save" ? "Saved notes" : undefined, html: `

${event?.fields?.content || "empty"}

` }; + }, + }); + + // Panels are pure surfaces: registering one contributes no FAB entry. + expect(bridge.entries(session).webContributions).toEqual([ + { version: 1, key: "notes", slot: "panel", kind: "rendered", title: "Global notes", label: "Notepad", icon: "notebook-pen" }, + ]); + expect(emitted.at(-1)).toMatchObject({ type: "web_contributions_changed", sessionId: "session" }); + + // Entry points are explicit registrations that reference a panel. + ui.web.setFabAction("notes-launcher", { title: "Notes", icon: "notebook-pen", opens: "notes" }); + expect(bridge.entries(session).webContributions).toContainEqual( + { version: 1, key: "notes-launcher", slot: "fab", kind: "static", title: "Notes", label: undefined, icon: "notebook-pen", opens: "notes" }, + ); + expect(emitted.at(-1)).toMatchObject({ type: "web_contributions_changed", sessionId: "session" }); + ui.web.setFabAction("notes-launcher", undefined); + expect(bridge.entries(session).webContributions.filter((entry: any) => entry.slot === "fab")).toEqual([]); + + const manyFields = Object.fromEntries(Array.from({ length: 130 }, (_, index) => [`field-${index}`, "value"])); + await expect(bridge.invokeContribution(session, { + slot: "panel", key: "notes", event: { action: "save", fields: { content: "remember me\n", ...manyFields } }, + })).resolves.toEqual({ title: "Saved notes", html: "

remember me\n

" }); + expect(lastPanelEvent.fields.content).toBe("remember me\n"); + expect(Object.keys(lastPanelEvent.fields)).toHaveLength(128); + await expect(bridge.invokePanel(session, { key: "missing" })).rejects.toThrow("Panel not found"); + + // Launchers are decoupled from panels: a header action can open one. + ui.web.setHeaderAction("open-notes", { title: "Open notes", invoke: () => ({ effects: [{ type: "open-panel", key: "notes" }] }) }); + await expect(bridge.invokeContribution(session, { slot: "header-action", key: "open-notes" })) + .resolves.toEqual({ label: "Open notes", effects: [{ type: "open-panel", key: "notes" }] }); + ui.web.setHeaderAction("open-missing", { title: "Broken", invoke: () => ({ effects: [{ type: "open-panel", key: "nope" }] }) }); + await expect(bridge.invokeHeaderAction(session, "open-missing")).rejects.toThrow('unknown panel "nope"'); }); it("re-emits a footer when the same session id gets a new runtime", async () => { diff --git a/tests/icons.test.ts b/tests/icons.test.ts new file mode 100644 index 0000000..2ed8a56 --- /dev/null +++ b/tests/icons.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from "vitest"; +import { isIconName } from "../src/app/icons.js"; + +describe("icon registry", () => { + it("accepts only own icon registry keys", () => { + expect(isIconName("square-pen")).toBe(true); + expect(isIconName("toString")).toBe(false); + expect(isIconName("constructor")).toBe(false); + expect(isIconName("__proto__")).toBe(false); + }); +}); diff --git a/tests/session-service.test.ts b/tests/session-service.test.ts index 3c27b7b..7b9cb47 100644 --- a/tests/session-service.test.ts +++ b/tests/session-service.test.ts @@ -262,7 +262,7 @@ describe("LocalSessionService contract", () => { runtimeStartedAt: startedAt, runtimeLastActivityAt: firstActivityAt, runtime: activity.runtimeForPath(initial.sessionFile), - webFooters: [], webHeaderActions: [], webArtifactActions: [], webGitTabs: [], + webContributions: [], }, ]); diff --git a/tests/session-state.test.ts b/tests/session-state.test.ts index 672378d..7074731 100644 --- a/tests/session-state.test.ts +++ b/tests/session-state.test.ts @@ -74,12 +74,15 @@ describe("session state store", () => { reduceSessionSnapshot(state, { ...legacySnapshot, - webFooters: [{ key: "footer", footer: { kind: "text", lines: ["ready"] } }], + webContributions: [ + { version: 1, key: "footer", slot: "footer", kind: "static", view: { kind: "text", lines: ["ready"] } }, + { version: 1, key: "notes", slot: "panel", kind: "rendered", title: "Notes", label: "Notepad", icon: "notebook-pen" }, + ], }); expect(state.sessionsById["session-a"].snapshotLoaded).toBe(true); expect(state.sessionsById["session-a"].queue).toEqual({ steering: [], followUp: [] }); - expect(state.sessionsById["session-a"].webFooters).toHaveLength(1); + expect(state.sessionsById["session-a"].webContributions).toHaveLength(2); }); it("updates a background runtime without changing the active projection", () => {