# Pages and windows
How extension UI reaches the screen — docked pages, app windows, slot views, how a page is routed to, and where your styles actually apply.
Source: https://docs.vampikez.fun/build/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.
## Three surfaces
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 |
## Registering a page
The page id in the manifest and the key in your `views` export must agree.
```json
{
"contributes": {
"pages": [
{ "id": "notes", "title": "Notes", "icon": "NotebookPen", "context": "both" }
]
}
}
```
```tsx
// 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.
Page ids live in one registry shared with WAMP's own pages. Registering an id
that already exists throws, rolls back every page the extension declared, and
faults the extension. Prefix page ids with your extension id to avoid generic
names such as `settings` or `docs`.
## 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:
```tsx
function NotesPage() {
return (
);
}
```
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
`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:
```tsx
function TasksApp() {
return (
Tasks
{/* body */}
);
}
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.
## 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.
`ctx.api.ipc.broadcast` fans out to *all* windows,
which is what makes multi-instance workable: one window's write can tell the
others to refetch. Design for it — an event handler that assumes it is the only
listener will run three times when the user has three windows open.
## Views in slots
A view is for contributing to a surface WAMP already owns. Declare the slot:
```json
{
"contributes": {
"views": [
{ "id": "notes.dock", "slot": "session.dock", "title": "Notes", "icon": "NotebookPen" }
]
}
}
```
```tsx
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:
```tsx check title="ui/index.tsx"
function WorkspaceInfo(props: SlotProps<'session.dock'>) {
if (props.instanceId === undefined) return null;
const { workspace } = props;
if (workspace.authority === 'cloud') {
return This view requires direct engine workspace access.
;
}
if (!workspace.context) return Choose a project.
;
if (!workspace.hydrated) return Loading workspace…
;
return (
{workspace.context.path}
{props.sessionId ? 'Session workspace' : 'New session workspace'}
);
}
export const views = { 'notes.dock': WorkspaceInfo };
```
The manifest above supplies the matching view declaration. See the
[slot reference](/reference/manifest/#contributesviews) for every prop.
## Cloud web panels
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](/reference/plugin-api/#the-windows-activate-host).
Compose portable panels from the shared UI primitives. Desktop composites such
as `ModelPicker` are not available in this host; see
[Interface kit](/build/ui/#what-the-kit-exports).
## Navigating between surfaces
From the window:
```tsx
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.
`ui.showExtension()` is answered by a subscriber that exists only in the main
window; called from inside a detached or app window, the request is sent and
nothing listens. `ui.exitAppMode()` has the same shape of problem: a page
window renders the page it was given, so mutating navigation state there cannot
change what that window shows. Both are safe to call and do nothing.
Build in-window navigation with your own state, not with the host's.
## 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:
```tsx
```
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.
## 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 | 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 |
## Next
- [Interface kit](/build/ui/) — the components and hooks available in a page.
- [The manifest](/build/manifest/) — where `pages` and `views` sit among the
other contributions.
- [The plugin API](/build/plugin-api/) — `navigation`, `ui`, and everything
else the window can call.