# Web app

> Zidane in the browser, served by zidane-server itself. Turn it on, pair a browser with a one-time code, and what each scope can do.

zidane-server can serve Zidane's web app at `/` on its own port. You get the desktop app's layout in a browser: the session sidebar, the transcript and composer, the side panel, the command palette and settings. Nothing else runs: the server serves the page, and the page talks to the server on the same origin.

The app is built only on the published packages, the same `zidane-ui` entries and `zidane/ipc/browser` client an npm user installs. Its source is `web/` in the repository.

## Turn it on

The web app is off by default. Start the server with `--web`, or set `ZIDANE_SERVER_WEB=1`, or `"web": true` in `server.json`:

```sh
zidane-server start --web
```

The Docker image and the release archives bundle the app. With the `zidane` command or a build of your own, pass `--web-root <dir>`. [Install](https://zidane.si/docs/server-install.md#web-app) lists where each install keeps it, and [Deploy](https://zidane.si/docs/server-deploy.md#web-app) shows the reverse proxy setup.

## Pair a browser

A browser logs in with a pairing code instead of a token you copy by hand. On the server, run:

```sh
zidane-server pair                       # chat scope, valid 10 minutes
zidane-server pair --scope admin --name "work laptop" --ttl 30m
```

It prints a code like `7KQM-3XFD`. Open the server's address in the browser, type the code, and choose **Pair**. An admin client can also ask for a code over the protocol (`tokens.pair`).

| Rule | Value |
| - | - |
| Code | 8 characters, no `0`, `O`, `1` or `I`, shown as `XXXX-XXXX` |
| Lifetime | 10 minutes by default, 1 hour at most (`--ttl`) |
| Use | Once. 5 wrong attempts burn the code |
| Attempts | 10 a minute per client address |
| Token | A normal device token, named `browser: <name>` in `zidane-server token list` |

The pairing screen says why a code failed: not valid, expired, already used, or too many attempts. [Security](https://zidane.si/docs/server-security.md#pairing) has the full rules.

The browser keeps its token in `localStorage` (`zidane-web:token`). Revoke it like any other token (`zidane-server token revoke`, or Settings › the server's tokens): the page goes back to the pairing screen and says the server no longer accepts its token.

**Sign out** (the sidebar's footer, or the palette) deletes the token from the browser. With an `admin` token it also revokes it on the server, by the token id the pairing returned. A token of a lower scope stays live on the server: revoke it from the token list.

## Scopes

The code's scope is the token's scope. The app shows what the scope allows and hides the rest.

| Scope | What the web app does |
| - | - |
| `chat` | Sessions, prompts, questions, tool approvals, files, diffs, todos, the queue and artifacts. The admin parts of Settings show greyed out, with an *Admin only* tooltip |
| `admin` | Everything: settings, accounts and keys, MCP servers, installs (when the server allows them), the terminal, the server's update and its tokens |

With a `chat` code, the server's card in Settings says **Chat access**, and **Get admin** shows the server's own pairing command (`pair --scope admin`, with its binary, its user and its data dir). Run it on the server, sign out, and log in again with the code it prints.

## What differs from the desktop app

The web app runs the same components as the desktop app, over zidane-server's protocol. What the protocol does not offer yet is hidden. The main differences:

- No browser tab, no native notifications or window effects: they need the desktop app.
- No language servers in Files: they run where the editor is. The project's own linters and typecheckers run on the server, and the Editor settings list them for the session's folder (`files.diagnosticsTools`).
- Appearance, keybindings and the side panel's layout live in this browser. Unsent drafts, the sidebar's filters and the pickers' remembered picks live on the server, so every browser finds them.

The sidebar groups, searches and filters the sessions, and its menus rename, star, archive, delete and move them between spaces. The composer is the desktop's: `/` commands and skills, the extensions' completions, ghost text, the agent, model and effort pickers, the run chip and context orb, the badge strip, and the approval docked above it. `/side <question>` asks in the Side tab, and `?` searches the conversation.

Click a session's title for its details (rename, generate a title, compact, export, its folder on the server, delete). Right-click a message for its turn's details (copy, fork, edit, delete). The palette lists the sessions and imports a repository into the server's workspace (an `admin` token), and the server's section in Settings lists its machines. Under the composer, the git bar shows the session folder's branch, its changes and pull request, with the branch menu and sync (switching and syncing need an `admin` token).

[docs/WEB-PARITY.md](https://github.com/Tahul/zidane/blob/main/docs/WEB-PARITY.md) lists every desktop feature and its state in the web app.

## Build your own remote app

Every area of the web app runs on one connection. Your app can do the same with the published packages: one `createServerClient`, every server host from `zidane-ui/adapters` on it, the browser hosts for what lives in the page, and one `AppHostProvider`. [`web/src/hosts.ts`](https://github.com/Tahul/zidane/blob/main/web/src/hosts.ts) (`createWebHosts`) is the full version.

```tsx
import type { AppHost } from 'zidane-ui/app/headless'
import {
  createBrowserAppearanceHost,
  createBrowserKeybindingsHost,
  createBrowserWorkspaceHost,
  createServerApprovalsHost,
  createServerClient,
  createServerComposerHost,
  createServerFilesHost,
  createServerGitHost,
  createServerHost,
  createServerMachinesHost,
  createServerOnboardingHost,
  createServerPaletteHost,
  createServerSessionHost,
  createServerSettingsHost,
  createServerSidebarHost,
  createServerTerminalHost,
} from 'zidane-ui/adapters'
import { AppHostProvider } from 'zidane-ui/app'

const client = createServerClient({ url: 'wss://box.example', token })
// Build after the handshake: the hosts read what the token may call from it.
await client.ready

const chat = createServerHost({ client })
const session = createServerSessionHost({ client })
const palette = createServerPaletteHost({ client })
const onboarding = createServerOnboardingHost({ client }) // configures only with an admin token
const machines = createServerMachinesHost({ client })
// read-only below admin; MCP sign-ins come back to the server at this page's origin
const settings = createServerSettingsHost({ client, oauthCallbackUrl: new URL('/zidane/oauth/callback', location.origin).toString() })
const composer = createServerComposerHost({ client })

const host: AppHost = {
  chat,
  session,
  palette,
  onboarding,
  machines,
  settings,
  composer,
  sidebar: createServerSidebarHost({ client }),
  approvals: createServerApprovalsHost({ client }), // answers with a chat token
  git: createServerGitHost({ client }),
  // These two don't read the handshake: gate them yourself.
  ...(client.can('sessions.workspaceListFiles')
    ? { files: createServerFilesHost({ client, sessionOf: cwd => openSessionFor(cwd), storage: localStorage }) }
    : {}),
  ...(client.can('terminal.state') ? { terminal: createServerTerminalHost({ client }) } : {}),
  workspace: createBrowserWorkspaceHost({ key: 'my-app:workspace', client }), // the server's extension panels
  appearance: createBrowserAppearanceHost({ key: 'my-app:appearance' }),
  keybindings: createBrowserKeybindingsHost({ key: 'my-app:keybindings' }),
}

export function App() {
  return <AppHostProvider host={host}>{/* your layout */}</AppHostProvider>
}

// Teardown: stop every host that listens for good, then the connection.
export function dispose() {
  for (const h of [chat, session, palette, onboarding, machines, settings, composer]) h.close()
  client.close()
}
```

What each host does with the token:

| Host | Gating |
| - | - |
| Composer | Offers the extensions' completions, the agent profiles, the remembered picks and the extensions' strip when the handshake lists their methods |
| Settings, machines, terminal | Read `client.can` themselves: settings show the admin parts greyed out (*Admin only*), machines hide what the scope refuses, the terminal reads as unavailable |
| Approvals, onboarding | Follow `client.can('approvals.resolve')` and `client.can('auth.setApiKey')` when built after the handshake. `canApprove` / `canConfigure` override it |
| Files | Never gated: leave it out unless `client.can('sessions.workspaceListFiles')` ([Files](https://zidane.si/docs/ui-files.md#pick-a-host)) |
| Chat, session, palette, git, sidebar | Calls the token can't make are refused one by one; the components show the error |

Teardown is `close()` on the hosts that have one (chat, session, palette, onboarding, machines, settings, composer): each stops listening to the client and leaves it open, so close them before you build new hosts on the same client. Approvals, files, git and terminal listen only while a component reads them, and the sidebar host not at all: they need no teardown. `client.close()` ends the connection.

Pair a browser with [`pairBrowser`](https://zidane.si/docs/server-security.md#pairing) to get its token.

## Develop the app

```sh
cd web
bun install                                          # links zidane and zidane-ui to this checkout
ZIDANE_SERVER_URL=http://127.0.0.1:4280 bun run dev  # Vite on :5190, proxying the WebSocket and /zidane/* to that server
bun run build                                        # web/dist, what --web-root serves
bun run test && bun run e2e                          # unit tests; Playwright on the build, in parallel, each test on its own scripted zidane-server
```

`bun run server:web` at the repository root builds `web/` into `server/web-dist/`, the app the server bundles.
