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

> Discover how the Pi-Web context menu extension hook enables downstream integrations. Learn to extend, modify, or replace session row context menus without core application coupling. agegr/pi-web.

- Repository: [Alex Yang/pi-web](https://github.com/agegr/pi-web)
- Tags: how-to-guide
- Published: 2026-08-18

---

**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`](https://github.com/agegr/pi-web/blob/main/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:

```typescript
// 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`](https://github.com/agegr/pi-web/blob/main/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.

```tsx
// 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.

```tsx
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.

```typescript
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`](https://github.com/agegr/pi-web/blob/main/lib/session-row-context-menu.ts) | Event name constant, `SessionRowContextMenuDetail` interface, and `dispatchSessionRowContextMenu()` helper |
| [`components/SessionSidebar.tsx`](https://github.com/agegr/pi-web/blob/main/components/SessionSidebar.tsx) | UI component that triggers the hook and respects cancellation |
| [`components/ExtensionWidgets.tsx`](https://github.com/agegr/pi-web/blob/main/components/ExtensionWidgets.tsx) | Reference implementation showing how Pi‑Web structures refreshable UI extensions |
| [`lib/types.ts`](https://github.com/agegr/pi-web/blob/main/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`](https://github.com/agegr/pi-web/blob/main/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.