Pi‑Web Context Menu Extension Hook: How Downstream Integrations Work

Pi‑Web exposes a cancelable custom DOM event named pi‑web:session‑row‑contextmenu that lets extensions add, modify, or completely replace the context menu for session rows without any compile‑time coupling to the core application.

The agegr/pi-web repository implements a loosely coupled extension system for the session sidebar's context menu. Rather than exposing a rigid plugin API, it fires a standard browser CustomEvent that downstream code can intercept. This approach keeps the core UI lightweight while giving integrations full control over the user experience.

How the Extension Hook Is Architected

The Core Event Contract

All communication flows through a single event type defined in lib/session-row-context-menu.ts. The SessionRowContextMenuDetail interface carries everything an extension needs:

Property Purpose
id Unique identifier for the session row
path Absolute filesystem path to the session file
cwd Working directory of the session
name? Optional display name
clientX / clientY Click coordinates for positioning custom UI
refresh Callback to force the menu to re‑render

The dispatchSessionRowContextMenu() helper creates a cancelable CustomEvent and returns a boolean indicating whether any listener claimed the menu:

// lib/session-row-context-menu.ts
export function dispatchSessionRowContextMenu(
  detail: SessionRowContextMenuDetail,
  target: EventTarget = window,
): boolean {
  const event = new CustomEvent<SessionRowContextMenuDetail>(SESSION_ROW_CONTEXT_MENU_EVENT, {
    cancelable: true,
    detail,
  });
  return !target.dispatchEvent(event);
}

A return value of true means event.preventDefault() was called—a downstream listener has taken full responsibility.

Where the Hook Fires

In components/SessionSidebar.tsx, each session row attaches a native onContextMenu handler. The flow is:

  1. Build the SessionRowContextMenuDetail payload from row data
  2. Call dispatchSessionRowContextMenu()
  3. If the result is false, render Pi‑Web's default menu
  4. If true, skip rendering—the extension owns the experience

This logic lives entirely in the presentation layer. No business logic changes are required to support new integrations.

Downstream Integration Patterns

Pattern 1: Augmenting the Native Menu

Extensions can push items into a shared array and trigger a refresh. The UI merges these contributions before rendering.

// src/plugins/my-plugin.ts
import { SESSION_ROW_CONTEXT_MENU_EVENT, SessionRowContextMenuDetail } from '@/lib/session-row-context-menu';

window.addEventListener(SESSION_ROW_CONTEXT_MENU_EVENT, (e) => {
  const ev = e as CustomEvent<SessionRowContextMenuDetail>;
  const { path, clientX, clientY } = ev.detail;

  extraMenuItems.push({
    label: 'Open in external editor',
    click: () => window.open(`my-editor://open?file=${encodeURIComponent(path)}`),
  });

  ev.detail.refresh();
});

Pattern 2: Complete Menu Replacement

Call event.preventDefault() and render your own DOM element at the supplied coordinates.

window.addEventListener(SESSION_ROW_CONTEXT_MENU_EVENT, (e) => {
  const ev = e as CustomEvent<SessionRowContextMenuDetail>;

  const menu = document.createElement('div');
  menu.style.cssText = `position:absolute;left:${ev.detail.clientX}px;top:${ev.detail.clientY}px`;
  menu.className = 'custom-context-menu';
  menu.innerHTML = `
    <button data-action="delete">Delete Session</button>
    <button data-action="duplicate">Duplicate Session</button>
  `;
  document.body.appendChild(menu);

  const cleanup = () => {
    menu.remove();
    document.removeEventListener('click', cleanup);
  };
  document.addEventListener('click', cleanup);

  e.preventDefault(); // Claim the menu
});

Pattern 3: Async Data Loading

The refresh callback enables asynchronous workflows. Fetch remote data, update the menu state, then re‑render.

window.addEventListener(SESSION_ROW_CONTEXT_MENU_EVENT, async (e) => {
  const ev = e as CustomEvent<SessionRowContextMenuDetail>;

  const tags = await fetch(`/api/tags?session=${ev.detail.id}`).then(r => r.json());

  extraMenuItems.push({
    label: `Latest tag: ${tags[0]}`,
    action: () => applyTag(tags[0]),
  });

  ev.detail.refresh();
});

Key Source Files for Extension Developers

File What It Contains
lib/session-row-context-menu.ts Event name constant, SessionRowContextMenuDetail interface, and dispatchSessionRowContextMenu() helper
components/SessionSidebar.tsx UI component that triggers the hook and respects cancellation
components/ExtensionWidgets.tsx Reference implementation showing how Pi‑Web structures refreshable UI extensions
lib/types.ts Type definitions for extension‑contributed items

Summary

  • Pi‑Web's context menu extension hook uses a standard DOM event (pi‑web:session‑row‑contextmenu) for zero‑dependency integrations
  • Downstream code receives row metadata, click coordinates, and a refresh callback via SessionRowContextMenuDetail
  • Calling event.preventDefault() claims the menu and suppresses Pi‑Web's native UI
  • All integration points are runtime‑discoverable—extensions load via the plugin system and attach listeners without recompiling core code

Frequently Asked Questions

How does Pi‑Web know when to skip its default context menu?

The dispatchSessionRowContextMenu() function in lib/session-row-context-menu.ts returns true when any listener invokes event.preventDefault(). The SessionSidebar component checks this return value and renders its built‑in menu only when the result is false.

Can multiple extensions modify the same context menu?

Yes. Multiple listeners can attach to SESSION_ROW_CONTEXT_MENU_EVENT. Each can push to shared state arrays or call refresh(). However, only one listener should call event.preventDefault() if they want to replace rather than augment the menu.

What coordinate system do clientX and clientY use?

These are standard viewport coordinates from the underlying MouseEvent. Position absolutely‑placed custom menus using these values directly, or transform them if your UI uses a different coordinate space.

Is there compile‑time type safety for extension authors?

Yes. Extensions can import SessionRowContextMenuDetail from @/lib/session-row-context-menu to get full TypeScript intellisense for the payload shape, even though the event itself is dispatched and received at runtime.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →