# 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 (

Notes

{/* body */}
); } ``` 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.