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
10 changes: 7 additions & 3 deletions examples/pi-web-extensions/download-artifact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,18 @@ const ACTION_KEY = "download-artifact";

export default function downloadArtifactExtension(pi: PiWebExtensionAPI) {
const add = (_event: unknown, ctx: PiWebExtensionContext) => {
ctx.ui.web.setArtifactAction(ACTION_KEY, {
ctx.ui.web.contribute(ACTION_KEY, {
slot: "artifact-action",
kind: "rendered",
title: "Download artifact to this device",
label: "Download",
invoke: ({ name }) => ({ download: { filename: name } }),
render: (event) => ({
download: { filename: typeof event?.context?.name === "string" ? event.context.name : undefined },
}),
});
};

pi.on("session_start", add);
pi.on("session_before_switch", add);
pi.on("session_shutdown", (_event, ctx) => ctx.ui.web.setArtifactAction(ACTION_KEY, undefined));
pi.on("session_shutdown", (_event, ctx) => ctx.ui.web.contribute(ACTION_KEY, undefined));
}
8 changes: 6 additions & 2 deletions examples/pi-web-extensions/git-footer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,11 @@ export function createGitFooterExtension(options: {
|| sessions.get(key) !== state
|| state.lastHtml === html) return;
state.lastHtml = html;
ctx.ui.web.setFooter(FOOTER_KEY, { kind: "html", html });
ctx.ui.web.contribute(FOOTER_KEY, {
slot: "footer",
kind: "static",
view: { kind: "html", html },
});
})
.finally(() => {
state.refreshing = undefined;
Expand Down Expand Up @@ -189,7 +193,7 @@ export function createGitFooterExtension(options: {
state.stopped = true;
clearInterval(state.interval);
sessions.delete(key);
ctx.ui.web.setFooter(FOOTER_KEY, undefined);
ctx.ui.web.contribute(FOOTER_KEY, undefined);
}

return function gitFooterExtension(pi: PiWebExtensionAPI) {
Expand Down
14 changes: 10 additions & 4 deletions examples/pi-web-extensions/github-repo-panel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -573,17 +573,23 @@ async function updateGitTab(pi: PiWebExtensionAPI, ctx: PiWebExtensionContext) {

if (!repo) {
if (installedSessions.has(key)) {
web.setGitTab(GIT_TAB_KEY, undefined);
web.contribute(GIT_TAB_KEY, undefined);
installedSessions.delete(key);
}
return;
}

installedSessions.add(key);
web.setGitTab(GIT_TAB_KEY, {
web.contribute(GIT_TAB_KEY, {
slot: "git-tab",
kind: "rendered",
title: `GitHub issues and pull requests for ${repo.nameWithOwner}`,
label: "GitHub",
render: (event) => renderGitTab(pi, ctx, event),
render: (event) => renderGitTab(pi, ctx, {
action: event?.action,
payload: event?.payload,
repo: event?.context as PiWebGitTabEvent["repo"],
}),
});
}

Expand All @@ -605,7 +611,7 @@ export default function githubRepoPanel(pi: PiWebExtensionAPI) {
pi.on("session_shutdown", (_event, ctx) => {
const key = sessionKey(ctx);
if (installedSessions.has(key)) {
ctx.ui.web.setGitTab(GIT_TAB_KEY, undefined);
ctx.ui.web.contribute(GIT_TAB_KEY, undefined);
installedSessions.delete(key);
}
});
Expand Down
8 changes: 5 additions & 3 deletions examples/pi-web-extensions/recap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,15 +24,17 @@ async function buildRecap(ctx: PiWebExtensionContext) {

export default function recapExtension(pi: PiWebExtensionAPI) {
const add = (_event: unknown, ctx: PiWebExtensionContext) => {
ctx.ui.web.setHeaderAction("recap", {
ctx.ui.web.contribute("recap", {
slot: "header-action",
kind: "rendered",
icon: "scroll-text",
title: "Session recap",
label: "Recap",
invoke: () => buildRecap(ctx),
render: () => buildRecap(ctx),
});
};

pi.on("session_start", add);
pi.on("session_before_switch", add);
pi.on("session_shutdown", (_event, ctx) => ctx.ui.web.setHeaderAction("recap", undefined));
pi.on("session_shutdown", (_event, ctx) => ctx.ui.web.contribute("recap", undefined));
}
11 changes: 10 additions & 1 deletion server/session/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -618,7 +618,16 @@ export class LocalSessionService implements SessionService {
this.knownSessionCwds.add(resolve(resolvedCwd));
await this.ensureStorage(resolvedCwd);
const contextPath = fileURLToPath(new URL("../../contexts/web-ui.md", import.meta.url));
const webUiContext = existsSync(contextPath) ? readFileSync(contextPath, "utf8") : "";
const appDir = dirname(dirname(contextPath));
const extensionAuthoringContext = [
"pi-web extension documentation (read when asked to build pi-web extensions or browser UI):",
`- API + slots: ${join(appDir, "docs/pi-web-extensions.md")}`,
`- Examples: ${join(appDir, "examples/pi-web-extensions")} (notepad.ts shows the current contribute() API)`,
].join("\n");
const webUiContext = [
existsSync(contextPath) ? readFileSync(contextPath, "utf8") : "",
extensionAuthoringContext,
].filter(Boolean).join("\n\n");
const loader = new ResilientResourceLoader({
loadTimeoutMs: envMs("PI_WEB_EXTENSION_LOAD_TIMEOUT_MS", 8_000),
fetchTimeoutMs: envMs("PI_WEB_EXTENSION_FETCH_TIMEOUT_MS", 3_000),
Expand Down
13 changes: 13 additions & 0 deletions tests/context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,23 @@ describe("agent context organization", () => {
expect(service).toContain('new URL("../../contexts/web-ui.md", import.meta.url)');
expect(service).toContain("appendSystemPromptOverride");
expect(service).toContain("webUiContext");
expect(service).toContain("pi-web extension documentation (read when asked to build pi-web extensions or browser UI)");
expect(service).toContain('join(appDir, "docs/pi-web-extensions.md")');
expect(service).toContain('join(appDir, "examples/pi-web-extensions")');
expect(service).toContain("notepad.ts shows the current contribute() API");

expect(service).not.toContain("piWebDevelopmentContextFile");
expect(service).not.toContain("pi-web-development.md");
expect(service).not.toContain("piCwd === appDir ?");
expect(service).not.toContain("pi-web-agent-context.md");
});

it("teaches the canonical contribution API throughout the first-party UI examples", async () => {
const uiExamples = ["git-footer.ts", "recap.ts", "download-artifact.ts", "github-repo-panel.ts", "notepad.ts"];
for (const filename of uiExamples) {
const example = await text(`examples/pi-web-extensions/${filename}`);
expect(example).toContain(".contribute(");
expect(example).not.toMatch(/\.set(?:Footer|HeaderAction|ArtifactAction|GitTab|Panel|FabAction)\(/);
}
});
});
22 changes: 14 additions & 8 deletions tests/extensions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,13 +69,19 @@ describe("bundled extension path discovery", () => {
const handlers = new Map<string, (event: unknown, context: any) => unknown>();
downloadArtifactExtension({ on: (event: string, handler: (event: unknown, context: any) => unknown) => handlers.set(event, handler) } as any);
const calls: Array<[string, any]> = [];
const context = { ui: { web: { setArtifactAction: (key: string, action: unknown) => calls.push([key, action]) } } };
const context = { ui: { web: { contribute: (key: string, contribution: unknown) => calls.push([key, contribution]) } } };

await handlers.get("session_start")?.({}, context);
const action = calls.at(-1)?.[1];
expect(calls.at(-1)?.[0]).toBe("download-artifact");
expect(action).toMatchObject({ title: "Download artifact to this device", label: "Download" });
expect(await action.invoke({ name: "report.md", path: "/api/artifacts/report.md", kind: "markdown" })).toEqual({ download: { filename: "report.md" } });
expect(action).toMatchObject({
slot: "artifact-action",
kind: "rendered",
title: "Download artifact to this device",
label: "Download",
});
expect(await action.render({ context: { name: "report.md", path: "/api/artifacts/report.md", kind: "markdown" } }))
.toEqual({ download: { filename: "report.md" } });

await handlers.get("session_shutdown")?.({}, context);
expect(calls.at(-1)).toEqual(["download-artifact", undefined]);
Expand Down Expand Up @@ -305,7 +311,7 @@ describe("bundled extension path discovery", () => {
context: {
cwd: process.cwd(),
sessionManager,
ui: { web: { setFooter: (key: string, footer: unknown) => calls.push([key, footer]) } },
ui: { web: { contribute: (key: string, contribution: unknown) => calls.push([key, contribution]) } },
},
};
};
Expand All @@ -318,11 +324,11 @@ describe("bundled extension path discovery", () => {
await vi.advanceTimersByTimeAsync(0);
await start({}, replacement.context);
await vi.advanceTimersByTimeAsync(0);
expect(first.calls.at(-1)?.[1]).toMatchObject({ kind: "html" });
expect(replacement.calls.at(-1)?.[1]).toMatchObject({ kind: "html" });
expect(first.calls.at(-1)?.[1]).toMatchObject({ slot: "footer", kind: "static", view: { kind: "html" } });
expect(replacement.calls.at(-1)?.[1]).toMatchObject({ slot: "footer", kind: "static", view: { kind: "html" } });

await shutdown({}, first.context);
expect(replacement.calls.at(-1)?.[1]).toMatchObject({ kind: "html" });
expect(replacement.calls.at(-1)?.[1]).toMatchObject({ slot: "footer", kind: "static", view: { kind: "html" } });
await shutdown({}, replacement.context);
expect(replacement.calls.at(-1)).toEqual(["local-git-footer", undefined]);
} finally {
Expand All @@ -343,7 +349,7 @@ describe("bundled extension path discovery", () => {
const context = {
cwd: process.cwd(),
sessionManager,
ui: { web: { setFooter: (key: string, footer: unknown) => calls.push([key, footer]) } },
ui: { web: { contribute: (key: string, contribution: unknown) => calls.push([key, contribution]) } },
};

handlers.get("session_start")?.({}, context);
Expand Down