Skip to content

Pages and windows

Choose a page for a top-level app surface and a slot view to extend the host workbench or composer. This guide covers registration, Desktop windows, Cloud panels, and the lifecycle a view must handle.

Window behavior in this table is specific to Desktop. The Cloud mapping follows below.

Surface Declared as Where it renders
Docked page contributes.pages[], presentation omitted or 'docked' The main content pane, inside the shell — sidebar, dock, and chrome all present. Can also be popped into extra windows
App page contributes.pages[], presentation: 'app' Its own window, one per page id. No host chrome at all. Never rendered in the main window
Slot view contributes.views[] A specific spot in WAMP’s UI — a dock tab, a chat-input strip

The page id in the manifest and the key in your views export must agree.

{
"contributes": {
"pages": [
{ "id": "notes", "title": "Notes", "icon": "NotebookPen", "context": "both" }
]
}
}
ui/index.tsx
export const views = {
notes: NotesPage, // key === page id
};

That is the whole registration. The page’s surface is wired up for you: each contributes.pages[] entry auto-registers a workspace.main view whose view id and type are both the page id, resolved from views[page.id]. You do not add a contributes.views[] entry for a page’s own surface — and if you do, with slot workspace.main and the same id, it is silently ignored in favor of the page’s own registration.

One convenience worth knowing: a single-page extension whose bundle has only a default export is wired up from that default. As soon as you have two pages you must key each one explicitly in views, because a default export cannot say which page it is.

Page field Values Default
id string required
title string required
icon lucide-react name none
context 'project', 'both' 'both'
presentation 'docked', 'app' 'docked'
placement 'top', 'footer' 'top'

context says whether the page needs a project: 'project' pages are offered only while a workspace is open, 'both' pages everywhere.

A docked page renders in the main content pane with everything else still around it. Its sidebar row is reorderable by the user.

placement: 'footer' pins the row to the fixed group at the bottom of the sidebar instead — where WAMP puts Docs. It has one side effect that is easy to trip over: declaring a footer page means that extension’s other pages are dropped from the sidebar, on the assumption that the footer row is the extension’s front door. If you want several rows visible, do not use footer.

Your component is mounted inside a full-height host element, so a docked page should size itself to its container rather than to the viewport:

function NotesPage() {
return (
<div className="h-full flex flex-col bg-background text-foreground">
<header className="flex items-center justify-between p-3 border-b border-border">
<h1 className="text-sm font-semibold">Notes</h1>
</header>
<div className="flex-1 overflow-y-auto p-4">{/* body */}</div>
</div>
);
}

The pane’s width is fluid — it shrinks when the user opens the dock and grows when they collapse the sidebar. Never assume a fixed width.

presentation: 'app' is not “a docked page with the chrome hidden”. An app page is a window:

  • It opens in its own window. There is exactly one window per app page id: asking for it again focuses the existing window (restoring it first if it was minimized) rather than creating a second.
  • The main window never renders it. If navigation lands on an app page in the main window, the host opens the app’s window and steps the main window back to what it was showing.
  • Its sidebar entry lives in the Apps group, and placement is ignored.
  • That window has no sidebar, no dock, and no host title bar. Your component is the entire contents.

Because you own the whole surface, you own the title bar — which is what AppShell from @wamp/ui provides, matched to host styling, including the inset the macOS traffic lights need:

import { AppShell } from '@wamp/ui';
function TasksApp() {
return (
<AppShell>
<AppShell.TitleBar>
<AppShell.Title>
<span className="text-sm font-semibold">Tasks</span>
</AppShell.Title>
</AppShell.TitleBar>
<AppShell.Content className="overflow-y-auto">
{/* body */}
</AppShell.Content>
</AppShell>
);
}
export const views = { tasks: TasksApp };

AppShell has exactly three subcomponents — TitleBar, Title, and Content. There is no Footer, Sidebar, or Header.

On relaunch, app windows the user had open are reopened. Docked pages opened in extra windows are not.

Any docked page can also be opened in its own window, and this is where the mental model matters: opening a page in a window copies it, it does not move it.

  • The docked instance in the main window is untouched and keeps rendering.
  • Each request mints a new window, so the same page can be live in three windows at once — that is the design, not a bug. Three chats in three windows is the motivating case.
  • Closing one of those windows does nothing else. Nothing re-docks, nothing navigates, and the main window never notices.
  • A detached window is a peer, not a child. It loads the same bundle and mounts the same component; it is not a screenshot of the docked one, and the two do not share React state. They share everything that lives outside React — typed data, storage, cloud records — because those are per-extension, not per-window.

Windows are identified by a window id, not by the page they host, precisely because several can host the same page. The default size for a programmatically opened window is 1200×800.

A view is for contributing to a surface WAMP already owns. Declare the slot:

{
"contributes": {
"views": [
{ "id": "notes.dock", "slot": "session.dock", "title": "Notes", "icon": "NotebookPen" }
]
}
}
export const views = {
'notes.dock': NotesDockPanel, // key === view id
};

Five slots exist in the vocabulary, and each has a Desktop host:

Slot What the user sees Props your component receives
workspace.main The main content pane. This is what a page uses { paneId, type }
session.dock A workbench view; the shell manages tabs, groups, splits, and instances SessionDockContext — authority, session, instance, visibility, and resource tab
chat.newSessionControls A compact control beside the local new-chat destination pill { workspace, activeWorkspacePath, draftGeneration, setSubmissionPending }
chat.composerStatus Status and the next action above the active chat composer { chatId, visible, workspace, activeWorkspacePath, isStreaming, onInsert }
chatInput.attachments A strip between the chat text area and its toolbar { draftText, onInsert, onAttachmentRemoved }

A dock view can mount more than once. instanceId distinguishes a rendered instance from a hidden provider controller:

  • A controller has no instanceId. It publishes resource tabs and renders nothing. Hosts without a controller mount can register a provider from the bundle’s activate(host) instead.
  • An instance has instanceId and renders its own activeTabId. A null tab means render the default surface, not an empty panel. Do not republish the provider’s tab catalog from each instance.
  • visible: false means keep state and idle. Moving, splitting, or hiding a view does not imply disconnecting its resource. restoredTabIds names saved references. Extension providers should restore those references without implicitly starting processes or connections.

The core Desktop Terminal has explicit startup restoration behavior: it starts fresh local shells for saved terminal tabs whose processes no longer exist. Previous process output is not reconstructed. This is specific to the core Terminal; a restored tab id is not a general instruction to recreate resources.

Use workspace.authority before accessing files. engine carries a directory reachable by this host’s engine. cloud means Desktop is brokering a Cloud Session and the sandbox root is private; do not treat it as a local path. The Cloud browser uses engine, since its attached engine owns the sandbox.

A minimal dock component that supports engine workspaces:

ui/index.tsx
import type { SlotProps } from '@wamp/extension-sdk';
function WorkspaceInfo(props: SlotProps<'session.dock'>) {
if (props.instanceId === undefined) return null;
const { workspace } = props;
if (workspace.authority === 'cloud') {
return <p className="p-4">This view requires direct engine workspace access.</p>;
}
if (!workspace.context) return <p className="p-4">Choose a project.</p>;
if (!workspace.hydrated) return <p className="p-4">Loading workspace…</p>;
return (
<div className="h-full p-4 bg-background text-foreground">
<p>{workspace.context.path}</p>
<p>{props.sessionId ? 'Session workspace' : 'New session workspace'}</p>
</div>
);
}
export const views = { 'notes.dock': WorkspaceInfo };

The manifest above supplies the matching view declaration. See the slot reference for every prop.

The same renderer bundle can run in a Cloud workspace when installed in that session’s engine:

Declaration Cloud web result
requiresElectron: true UI omitted, including when a server half exists
Page with context: 'project' or 'both' Workspace panel; presentation: 'app' does not create a native window
session.dock view Workspace panel with a rendered instanceId and engine authority
Other view slots No matching surface

Bundles and their CSS load on first open. Renderer activate(host) runs then and can publish dock tabs and resource openers; a workbench catalog request can also activate dock providers. The Cloud host has no chat or tools registration namespaces. Check optional host capabilities before using them, and return a disposable for registrations you own. Details: renderer activation host.

Compose portable panels from the shared UI primitives. Desktop composites such as ModelPicker are not available in this host; see Interface kit.

From the window:

import { pluginAPI } from '@wamp/plugin-api';
pluginAPI.navigation.go('notes', { noteId: id }); // navigate in this realm
const { noteId } = pluginAPI.navigation.params(); // read what go() passed
pluginAPI.navigation.back(); // pop history

go routes through the Desktop host’s navigation store — the same path the sidebar takes — so history and the back affordance come for free. back() restores the previous page and its params. Behavior at the edges, all verified:

  • An unregistered page id logs a warning and does nothing. It is not an error and not a crash; if a go call appears to be ignored, check the id against the page you actually registered.
  • back() on empty history is a no-op. History is capped at ten entries.
  • params() is session-scoped and not persisted — a relaunch loses them, so never make a page’s correctness depend on params being present.
  • Navigating in the main window does not change what a detached window shows. A detached window renders the page it was opened with, permanently.

pluginAPI.ui.showExtension(id) surfaces another extension’s primary page, picking the right mechanism for you — an app page opens its window, a docked page navigates.

This is the part that wastes the most time, so here are the rules that hold.

Rules on body and html do not work. Your component is mounted inside host elements that paint their own background from the theme token, so a rule on the document root compiles cleanly and is occluded. A “successful” style edit with no visible change is almost always this.

Style your own root element instead. Everything your component renders is yours. Give your outermost element the classes you want, and for an app page pass className to AppShell — its root accepts every standard div attribute. To retint a whole app surface, override the design token on your own root rather than on the document, and set it inline so nothing has to compile it:

<AppShell style={{ '--background': '220 20% 8%' } as React.CSSProperties}>

Token values are HSL channels without the hsl() wrapper — that is the format the theme uses, and every surface inside your app that reads the token follows.

Use theme tokens, not literal colors. bg-background, text-foreground, bg-card, bg-muted, text-muted-foreground, border-border, bg-primary, text-primary-foreground, bg-secondary, bg-accent. A hardcoded bg-slate-900 or #1e191a looks right in one theme and wrong in the other, and the user can switch at any time.

Host-built apps receive their own generated utility stylesheet. The host scans the extension’s TS/JS source, applies the WAMP Tailwind preset, and writes dist/plugin.css on each build. Literal arbitrary classes such as w-[137px] work. Dynamically assembled class names are not discoverable; use complete strings or inline styles. A utility-generation failure appears as a build warning and can leave only the host’s global classes available. Extensions that own their build must also produce their needed CSS.

Your CSS file is not scoped. A stylesheet imported by your bundle is injected into the window’s document head as-is. It is not wrapped, prefixed, or sandboxed, so a bare button { … } rule reaches WAMP’s own buttons and every other extension’s. Scope your selectors yourself — a class prefix or a root class you control. Tailwind utility classes in your JSX avoid the problem entirely, which is why they are the recommended path.

Assume a fluid box. Host wrappers are full-height, the pane width changes with the user’s layout, and a detached window can be resized to anything. Use h-full and flexbox; do not use viewport units for layout.

Symptom Cause
Sidebar row exists, pane is blank The views key does not match the page id exactly
Extension flips to faulted with a mount error No component registered for the page within five seconds of mount — usually the same key mismatch, or a bundle that failed to build
Extension will not load, error names a page id Another extension or WAMP itself already registered that id
A contributed view never appears Check the loader warning for a retired slot or a missing renderer registration
navigation.go seems ignored The page id is not registered; check the console warning
A style change compiles but nothing looks different The rule is on body, html, or an invented root id. Move it to your own root element
  • Interface kit — the components and hooks available in a page.
  • The manifest — where pages and views sit among the other contributions.
  • The plugin API — navigation, ui, and everything else the window can call.