Pages and windows
There are two ways your React code appears on screen: a page, which is a top-level surface with its own entry in the sidebar, or a view, which is a component mounted into a named slot somewhere in WAMP’s own UI. A page can either sit inside the shell or own an entire window. After this page you can choose between them, register one correctly, navigate to it, and style it without spending an afternoon on rules that never take effect.
Three surfaces
Section titled “Three surfaces”| 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 |
Registering a page
Section titled “Registering a page”Three strings have to agree. The page id in the manifest, the key in your
views export, and — if you also declare a view — the view id.
{ "contributes": { "pages": [ { "id": "notes", "title": "Notes", "icon": "NotebookPen", "context": "both" } ] }}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 |
'global', 'project', 'both' |
'both' |
presentation |
'docked', 'app' |
'docked' |
placement |
'top', 'footer' |
'top' |
context decides where the page is offered: 'project' pages appear only
inside a workspace, 'global' ones only outside, 'both' everywhere.
Docked pages
Section titled “Docked pages”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.
App pages own a window
Section titled “App pages own a window”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
placementis 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 four subcomponents — TitleBar, Title, Content, and
BackButton. 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.
Extra windows for a docked page
Section titled “Extra windows for a docked page”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.
Views in slots
Section titled “Views in slots”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};Seven slots exist in the vocabulary. Four have a host rendering them today:
| 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 tab in the right-hand dock strip, beside Chat, Files, and Preview | { sessionId, context, hydrated, activeTabId, requestFocus } |
statusbar.left |
A row in the sidebar footer’s status popover | { compact } |
statusbar.right |
The same popover — the left/right distinction has no visual effect today | { compact } |
chatInput.attachments |
A strip between the chat text area and its toolbar | { draftText, onInsert, onAttachmentRemoved } |
sidebar.primary and overlay.global are in the slot vocabulary and pass
validation, but nothing renders them at present. A view contributed to either
one will not appear.
A session.dock view is a singleton bound to whichever session is on screen,
and it must render a labelled state for every combination it can be handed:
context === undefined (no project chosen), hydrated === false (project
loading), sessionId === null (the new-session surface, no session record
yet). There is no “assume there is a folder” path.
Navigating between surfaces
Section titled “Navigating between surfaces”From the window:
import { pluginAPI } from '@wamp/plugin-api';
pluginAPI.navigation.go('notes', { noteId: id }); // navigate in this realmconst { noteId } = pluginAPI.navigation.params(); // read what go() passedpluginAPI.navigation.back(); // pop historygo routes through the host’s navigation store — the same path the sidebar
takes — so history and the back affordance come for free. 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
gocall 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.
Where your styles apply
Section titled “Where your styles apply”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.
Utility classes come from a stylesheet the host generated in advance. Your
bundle does not run Tailwind — it resolves class names against the CSS the host
already ships. The standard utility set is there, because the host’s own UI
uses it. Arbitrary-value classes are not: w-[137px] and
[--background:220_20%_8%] are compiled from source at build time, and your
source was not part of that build. Use the style prop for anything you cannot
express with a plain utility, and check unusual classes visually rather than
assuming.
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.
When nothing appears
Section titled “When nothing appears”| 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 | The slot is sidebar.primary or overlay.global, which have no host today |
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
pagesandviewssit among the other contributions. - The plugin API —
navigation,ui, and everything else the window can call.