Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions docs/pi-web-extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,36 @@ pi-web-only extensions are loaded from:

These are separate from regular pi extension locations on purpose. A pi-web extension can use HTML and browser-specific APIs without promising that the same UI works in the terminal TUI.

## Contribution API

`ctx.ui.web.contribute(key, spec)` is the canonical API for browser surfaces. Specs use an explicit `slot` and `kind`; pi-web normalizes them immediately into versioned descriptors. Passing `undefined` clears every contribution registered under that key. Keys are identities across slots, so prefix them with your extension name (for example, `acme-notes.panel`) to avoid collisions with other extensions and convenience wrappers.

```ts
ctx.ui.web.contribute("worker-status", {
slot: "panel",
kind: "rendered",
title: "Worker status",
render: async (event) => ({
html: `<button data-web-action="refresh">Refresh</button>`,
}),
});
```

Rendered contributions receive the shared `{ action, payload, fields, context }` event envelope. Static contributions currently support the `footer` and `fab` slots; rendered contributions support `header-action`, `artifact-action`, `git-tab`, and `panel`.

Independently distributed extensions should inspect `ctx.ui.web.capabilities` before using newer facilities. It reports the additive runtime contract: `apiVersion`, `slots`, `kinds`, and `effects`.

When backing data changes without a browser interaction, call `ctx.ui.web.update(key)`. pi-web emits a lightweight invalidation and an active panel or Git tab pulls a fresh render. Updates for hidden surfaces do no work; they render when next opened.

```ts
revision += 1;
ctx.ui.web.update("worker-status");
```

The typed `setFooter`, `setHeaderAction`, `setArtifactAction`, `setGitTab`, `setPanel`, and `setFabAction` methods remain supported convenience wrappers over this registry.

The [global notepad example](../examples/pi-web-extensions/notepad.ts) demonstrates a rendered panel, explicit FAB launcher, persisted cross-session data, and `update()` invalidation across every live session.

## Footer API

`ctx.ui.web.setFooter(key, footer)` sets a footer region between the composer and pinned session tabs. Multiple extensions can set independent footer regions by using different keys.
Expand Down
666 changes: 666 additions & 0 deletions examples/pi-web-extensions/notepad.ts

Large diffs are not rendered by default.

9 changes: 9 additions & 0 deletions server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -454,6 +454,15 @@ const server = createServer(async (req, res) => {
return sendJson(res, 200, { ok: true, ...state });
}

if (mockMode && method === "POST" && url.pathname === "/api/mock/event") {
const body = await readBody(req);
if (!body || typeof body !== "object" || Array.isArray(body) || typeof (body as any).type !== "string") {
return sendJson(res, 400, { ok: false, error: "Mock event requires a type" });
}
broadcast(body);
return sendJson(res, 200, { ok: true });
}

if (mockMode && method === "GET" && url.pathname === "/api/mock/live-sessions") {
return sendJson(res, 200, { ok: true, ...sessionService.lifecycleSnapshot(), lifecycle: getMockLifecycle() });
}
Expand Down
184 changes: 129 additions & 55 deletions server/extensions/webUi.ts

Large diffs are not rendered by default.

16 changes: 14 additions & 2 deletions server/session/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -415,11 +415,23 @@ export class LocalSessionService implements SessionService {

respondExtensionUi(id: string, response: Record<string, unknown>) { return this.webUiBridge.respond(id, response); }

private extensionStatusFor(value: PiWebSession, loader: ResilientResourceLoader) {
const status = loader.getStatus();
const runtimeErrors = this.webUiBridge.runtimeErrors(value);
if (!runtimeErrors.length) return { ...status, runtimeErrors };
return {
...status,
state: status.state === "loading" ? status.state : "degraded" as const,
runtimeErrors,
message: `${status.message} ${runtimeErrors.length} recent runtime error${runtimeErrors.length === 1 ? "" : "s"}.`,
};
}

extensionStatus(sessionId: string) {
const value = this.openExtensionSession(sessionId);
const loader = this.extensionLoaders.get(value);
if (!loader) throw new SessionServiceError("Extension status is not available for this session.", 404);
return loader.getStatus();
return this.extensionStatusFor(value, loader);
}

async reloadExtensions(sessionId: string) {
Expand All @@ -429,7 +441,7 @@ export class LocalSessionService implements SessionService {
const loader = this.extensionLoaders.get(value);
if (!loader || typeof value.reload !== "function") throw new SessionServiceError("Extension reload is not available for this session.", 404);
await value.reload();
const status = loader.getStatus();
const status = this.extensionStatusFor(value, loader);
this.emit({ type: "wire", value: { type: "extensions_reloaded", sessionId: value.sessionId, status } as JsonValue });
return status;
}
Expand Down
46 changes: 46 additions & 0 deletions src/extensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,36 @@ export type PiWebFooter =
export type PiWebEffect =
| { type: "open-panel"; key: string };

export type PiWebContributionEvent = {
action?: string;
payload?: unknown;
fields?: Record<string, string | string[]>;
context?: Record<string, unknown>;
};

export type PiWebContributionView = {
title?: string;
html?: string;
markdown?: string;
message?: string;
composerContext?: PiWebComposerContext;
download?: { filename?: string };
effects?: PiWebEffect[];
};

export type PiWebContribution =
| { slot: "footer"; kind: "static"; view: PiWebFooter }
| { slot: "fab"; kind: "static"; title: string; label?: string; icon?: string; opens: string }
| {
slot: "header-action" | "artifact-action" | "git-tab" | "panel";
kind: "rendered";
title: string;
label?: string;
icon?: string;
match?: { kinds?: PiWebArtifactContext["kind"][]; extensions?: string[] };
render: (event?: PiWebContributionEvent) => PiWebContributionView | Promise<PiWebContributionView>;
};

export type PiWebHeaderActionResult = {
/** Markdown rendered in the shared dismissible popover. */
markdown?: string;
Expand Down Expand Up @@ -204,7 +234,23 @@ export type PiWebRegisterSettingsResult = {
error?: string;
};

export type PiWebCapabilities = Readonly<{
apiVersion: 1;
slots: readonly string[];
kinds: readonly string[];
effects: readonly string[];
}>;

export type PiWebUi = {
/** Runtime feature discovery for independently distributed extensions. */
readonly capabilities: PiWebCapabilities;

/** Register or clear a normalized browser contribution. */
contribute(key: string, contribution: PiWebContribution | undefined): void;

/** Notify the active host that a rendered contribution should be pulled again. */
update(key: string): void;

/**
* Set or clear a pi-web footer region.
*
Expand Down
24 changes: 23 additions & 1 deletion src/extensions/webPanels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export type WebPanelsController = {
setPanels(value: unknown, sessionId: string): void;
entries(): WebPanelEntry[];
open(key: string): void;
update(key: string): void;
isOpen(): boolean;
};

Expand Down Expand Up @@ -91,8 +92,15 @@ export function createWebPanels(options: {
let sessionId = "";
let activeKey = "";
let requestGeneration = 0;
let updatePending = false;
let panelHandle: RightPanelHandle;

function formControlIsFocused() {
const active = document.activeElement;
return active instanceof HTMLElement && body.contains(active)
&& active.matches("input, textarea, select, button, [contenteditable]:not([contenteditable='false'])");
}

function activePanel() {
return panels.find((entry) => entry.key === activeKey);
}
Expand Down Expand Up @@ -160,6 +168,7 @@ export function createWebPanels(options: {
if ((target instanceof HTMLButtonElement || target instanceof HTMLInputElement)
&& target.type === "submit" && target.form) return;
event.preventDefault();
updatePending = false;
void invoke({
action: target.dataset.webAction || target.dataset.webPanelAction || "",
payload: parsePayload(target.dataset.webPayload || target.dataset.webPanelPayload),
Expand All @@ -170,6 +179,7 @@ export function createWebPanels(options: {
body.addEventListener("submit", (event) => {
if (!(event.target instanceof HTMLFormElement)) return;
event.preventDefault();
updatePending = false;
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 || "",
Expand All @@ -178,6 +188,12 @@ export function createWebPanels(options: {
});
});

body.addEventListener("focusout", () => queueMicrotask(() => {
if (!updatePending || formControlIsFocused() || !panelHandle.isOpen()) return;
updatePending = false;
void invoke();
}));

panelHandle = rightPanels.register({
id: "web-extension",
side: "right",
Expand All @@ -187,7 +203,7 @@ export function createWebPanels(options: {
minWidth: 320,
maxWidth: 900,
focusOnOpen: close,
onClose: () => { requestGeneration += 1; },
onClose: () => { requestGeneration += 1; updatePending = false; },
});

return {
Expand All @@ -197,13 +213,19 @@ export function createWebPanels(options: {
panels = normalizePanels(value);
if (changedSession || (activeKey && !activePanel())) {
requestGeneration += 1;
updatePending = false;
activeKey = "";
body.textContent = "";
if (panelHandle.isOpen()) panelHandle.close(false);
}
},
entries: () => [...panels],
open,
update: (key) => {
if (key !== activeKey || !panelHandle.isOpen()) return;
if (formControlIsFocused()) updatePending = true;
else void invoke();
},
isOpen: () => panelHandle.isOpen(),
};
}
17 changes: 14 additions & 3 deletions src/git/panel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ type GitExtensionTabView = { key: string; loading: boolean; title?: string; html

export type GitPanelController = {
setExtensionTabs(tabs: unknown): void;
updateExtensionTab(key: string): void;
isOpen(): boolean;
};

Expand All @@ -35,6 +36,7 @@ export function initGitPanel(options: {
let panelHandle: RightPanelHandle | undefined;
let extensionTabs: GitExtensionTab[] = [];
let extensionTabView: GitExtensionTabView | undefined;
let extensionRequestGeneration = 0;

const state: GitState = {
isOpen: false,
Expand Down Expand Up @@ -255,6 +257,7 @@ export function initGitPanel(options: {
}

async function loadExtensionTab(key: string, event?: { action?: string; payload?: unknown }) {
const generation = ++extensionRequestGeneration;
state.primaryView = extensionViewKey(key);
state.mobileView = extensionViewKey(key);
if (!event) extensionTabView = { key, loading: true };
Expand All @@ -273,6 +276,7 @@ export function initGitPanel(options: {
});
const data = await res.json().catch(() => ({}));
if (!res.ok || !data.ok) throw new Error(data.error || res.statusText);
if (generation !== extensionRequestGeneration || extensionKeyFromView() !== key) return;
if (data.composerContext && typeof data.composerContext === "object") {
if (panelHandle) panelHandle.close(false);
else setOpen(false);
Expand All @@ -284,10 +288,14 @@ export function initGitPanel(options: {
throw new Error("Git tab returned no content");
}
} catch (error) {
extensionTabView = { key, loading: false, error: error instanceof Error ? error.message : String(error) };
if (generation === extensionRequestGeneration && extensionKeyFromView() === key) {
extensionTabView = { key, loading: false, error: error instanceof Error ? error.message : String(error) };
}
} finally {
panel.removeAttribute("aria-busy");
render();
if (generation === extensionRequestGeneration) {
panel.removeAttribute("aria-busy");
render();
}
}
}

Expand Down Expand Up @@ -503,6 +511,9 @@ export function initGitPanel(options: {

return {
setExtensionTabs,
updateExtensionTab: (key) => {
if (extensionKeyFromView() === key && (panelHandle?.isOpen() ?? state.isOpen)) void loadExtensionTab(key);
},
Comment thread
ashwin-pc marked this conversation as resolved.
isOpen: () => panelHandle?.isOpen() ?? state.isOpen,
};
}
4 changes: 4 additions & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -516,6 +516,10 @@ realtime = createRealtime({
sessionState,
refreshMessages,
refreshState,
updateWebContribution: (key) => {
webPanels?.update(key);
gitPanel?.updateExtensionTab(key);
},
addMessage: messages.addMessage,
});

Expand Down
9 changes: 8 additions & 1 deletion src/realtime/realtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,10 @@ export function createRealtime(options: {
sessionState: SessionStateController;
refreshMessages: () => Promise<void>;
refreshState: () => Promise<void>;
updateWebContribution?: (key: string, sessionId: string) => void;
addMessage: (role: "system", text: string, extraClass?: string) => HTMLDivElement;
}): RealtimeController {
const { state, elements, api, composer, messages, models, sessions, settings, status, tools, conversationTree, sessionState, refreshMessages, refreshState, addMessage } = options;
const { state, elements, api, composer, messages, models, sessions, settings, status, tools, conversationTree, sessionState, refreshMessages, refreshState, updateWebContribution, addMessage } = options;
let compactionMessage: HTMLDivElement | null = null;
let retryErrorCard: HTMLDivElement | null = null;
let terminalFailureCard: HTMLDivElement | null = null;
Expand Down Expand Up @@ -782,6 +783,12 @@ export function createRealtime(options: {
sessionState.applySnapshot(data);
return;
}
if (data.type === "web_contribution_updated") {
const sessionId = String(data.sessionId || "");
const key = typeof data.key === "string" ? data.key : "";
if (key && sessionId === state.currentSessionId) updateWebContribution?.(key, sessionId);
return;
}
if (data.type === "committed_message") {
const appliesToCurrentSession = !data.sessionId || data.sessionId === state.currentSessionId;
if (isReplay && appliesToCurrentSession) {
Expand Down
10 changes: 9 additions & 1 deletion src/settings/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ type ExtensionLoadStatus = {
durationMs?: number;
extensionCount: number;
errors: Array<{ path: string; error: string }>;
runtimeErrors?: Array<{ path: string; event: string; error: string; timestamp: string }>;
message: string;
};

Expand Down Expand Up @@ -261,7 +262,14 @@ export function createSettings(options: {
row.textContent = `${error.path}: ${error.error}`;
elements.extensionStatusDetails.append(row);
}
elements.extensionStatusDetails.hidden = status.state === "ready" && status.errors.length === 0;
for (const error of status.runtimeErrors || []) {
const row = document.createElement("div");
row.className = "extensionStatusError";
const time = Number.isNaN(Date.parse(error.timestamp)) ? error.timestamp : new Date(error.timestamp).toLocaleString();
row.textContent = `${error.path} · ${error.event} · ${time}: ${error.error}`;
elements.extensionStatusDetails.append(row);
}
elements.extensionStatusDetails.hidden = status.state === "ready" && status.errors.length === 0 && !status.runtimeErrors?.length;
}

function renderExtensionStatusError(error: unknown) {
Expand Down
29 changes: 29 additions & 0 deletions tests/e2e/git.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,35 @@ test("GitHub issue numbers attach issue details to the composer context", async
await page.unrouteAll({ behavior: "wait" });
});

test("Git-tab invalidation ignores an older in-flight response", async ({ page }) => {
await page.request.post("/api/mock/state", { data: {
webContributions: [{ version: 1, key: "github", slot: "git-tab", kind: "rendered", title: "GitHub issues", label: "GitHub" }],
} });
let requestCount = 0;
let releaseOld!: () => void;
const oldPending = new Promise<void>((resolve) => { releaseOld = resolve; });
await page.route("**/api/web-contributions/invoke", async (route) => {
requestCount += 1;
if (requestCount === 1) {
await oldPending;
await route.fulfill({ json: { ok: true, html: '<div class="gitRevision">old</div>' } });
return;
}
await route.fulfill({ json: { ok: true, html: '<div class="gitRevision">fresh</div>' } });
});

await page.goto("/");
await page.locator("#sessionInfoButton").click();
await page.locator("#sessionInfoGit").click();
await page.locator(".gitExtensionTab", { hasText: "GitHub" }).click();
await expect.poll(() => requestCount).toBe(1);
await page.request.post("/api/mock/event", { data: { type: "web_contribution_updated", sessionId: "mock-current", key: "github" } });
await expect(page.locator(".gitRevision")).toHaveText("fresh");
releaseOld();
await expect.poll(() => requestCount).toBe(2);
await expect(page.locator(".gitRevision")).toHaveText("fresh");
});

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: {
Expand Down
4 changes: 3 additions & 1 deletion tests/e2e/pi-web.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1435,7 +1435,9 @@ test.describe("code block copy button", () => {
await pre.hover();
const copyBtn = pre.locator(".copyCode");
await copyBtn.evaluate((el) => (el as HTMLElement).focus());
await copyBtn.click();
// This test exercises the timer, not hover visibility (covered above). On
// touch projects Playwright may clear synthetic hover before the click.
await copyBtn.click({ force: true });
await expect(copyBtn).toHaveAttribute("data-icon", "check");

await page.waitForTimeout(2000);
Expand Down
Loading