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: `#123 `,
+ html: `#123 `,
};
},
});
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: `${value} ${status} Save
`,
+ }),
+ });
+ });
+
+ 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", () => {