# Workspace

> The desktop app's side panel as React components, tab groups, drag and drop between strips, splits and a resizable edge, over a host that keeps each scope's layout.

`zidane-ui/workspace` holds the desktop app's side panel: tab groups drawn side by side or stacked, a strip per group with its pills, the drag and drop that moves a tab between strips or splits a group, the dividers between groups and the panel's resizable edge. What each tab shows is yours. The panel reads a `WorkspaceHost` for where each scope's layout is kept and for extension panels. The previews below run on the fixture host. Pick a tab, drag one onto another strip or onto the edge of a body, or drag a divider.

## Mount it

Wrap the panel in a `WorkspaceProvider` with the scope on screen and the tabs you offer, and give `SidePanel` a registry that turns each tab into a React node.

```tsx
import 'zidane-ui/styles.css'
import { SidePanel, WorkspaceProvider } from 'zidane-ui/workspace'

const TABS = [
  { id: 'files', label: 'Files', icon: { gui: 'i-ph-folder-simple' }, builtin: true },
  { id: 'notes', label: 'Notes', icon: { gui: 'i-ph-note' }, builtin: true },
]

const registry = {
  render: tab => (tab.id === 'files' ? <FilesTab /> : <NotesTab />),
  keepAlive: () => true,
}

export function Panel({ host, sessionId }) {
  return (
    <WorkspaceProvider host={host} scope={{ key: sessionId }} tabs={TABS}>
      <SidePanel registry={registry} width={480} minWidth={320} maxWidth={900} onWidthCommit={saveWidth} controls={{ onClose: close }} />
    </WorkspaceProvider>
  )
}
```

`AppHostProvider` (`zidane-ui/app`) puts `host.workspace` in context, so a `WorkspaceProvider` below it can leave `host` out.

## Your tabs

A tab is `{ id, label, icon?, builtin }`. Your own kinds are `builtin: true` and render through the registry. `builtin: false` is an extension panel: its content loads from the host, and its pill shows the badge the content carries.

| Registry member | Does |
| - | - |
| `render(tab)` | The tab's body, any React node |
| `keepAlive(tab)` | Keeps the body mounted behind `hidden` once visited (the three most recent). Leave out a body that claims something when it mounts |
| `snap(tab)` | The group showing this tab snaps instead of animating, for a native view drawn over its slot |
| `wrap(tab, body)` | Wraps each body, for example in your error boundary |

A body reads `usePanelVisible()` to pause work while it is hidden. Bodies are rendered once and parked into their group's slot, so a tab moved to another group keeps its state.

## Pick a host

| Host | From | Keeps |
| - | - | - |
| `createBrowserWorkspaceHost({ client })` | `zidane-ui/adapters` | Layouts in `localStorage`, for the web app served by zidane-server. Another tab's change shows through the `storage` event. With `client`, the server's extension panels |
| `createFixtureWorkspaceHost(options)` | `zidane-ui/workspace/testing` | Memory, for demos and tests, with extension panels |
| Your own | `zidane-ui/workspace/headless` | Anything. `createStorageLayouts` and `createMemoryLayouts` are ready-made layout stores |

On zidane-server, extension panels run on the server: `panels.list`, `panels.load`, `panels.action`, `panels.watch` while a panel is shown (watched again after a reconnect) and `panels.unwatch` once it is hidden, with `panel:changed` and `catalog:changed` for the updates.

## What a host offers

Every member is optional. Without `layouts` a layout lasts as long as the page. Without `panels` the strip shows only your tabs.

| Member | Gives |
| - | - |
| `layouts.get(scope)`, `layouts.subscribe` | The scope's layout: the panel open, the tab groups (`PanelTree`), its width |
| `layouts.update(scope, change)` | Applies a change to the layout stored now, so a window never writes back a stale copy |
| `panels.list`, `panels.load`, `panels.action` | Extension panels in a folder, their content and their actions |
| `panels.watch`, `panels.unwatch`, `panels.subscribe`, `panels.subscribeCatalog` | Live updates while a panel is shown, and their release, a changed panel, a changed catalog |

`useWorkspaceView()` reads the strips and the open state, `useWorkspaceActions()` the actions (pick, close, move, split, drop, resize, cycle, attention), and `useWorkspaceContent()` the extension content and badges. Each re-renders only for its own part.

## Extension panel content

An extension panel ships data, not markup: `PanelContent` (`zidane/chat/pure`), sections of rows, prose and key / value facts, plus footer actions. `PanelContentView` paints it in the Artifacts panel's vocabulary: a row is a glyph, a title, a subtitle, a status, chips (`lead`, `diff`, `facts`) and badges, and a row with `details` collapses to a two-line preview. Feed it `contentFor(id)` from `useWorkspaceContent()`. `onAction` runs a row's or a footer's action (`act(id, actionId, payload)`); `onOpenPath` opens a row whose `href` is a path, and without it such rows are not links. Keep both stable: the view is memoised.

`SidePanel` draws the panel's own buttons on its top-right strip when you pass `controls`. To draw them yourself over the band instead, leave `controls` out, render `PanelCorner` (the toggle, then pop out and full screen while the panel shows) at the top-right of the area, and pass ``endInset={`${panelCornerWidth(n)}px`}`` so the strip keeps room for it. `expanded` makes the panel cover its parent: the aside keeps its width in the row and only its clip opens, so the content beside it never reflows. `startInset` keeps room at the strip's start for window controls.

Content can also carry big-number `stats`, a `heatmap`, stacked `bars`, rows with a `meter`, rows that stand for a `session` (`onOpenSession` opens them) and segmented `controls` in its header: pass `onControl` to keep the picked option as the panel's `view[id]` and reload it, or the controls stay hidden. `variant="block"` paints the same content as a card body on the project home (`ProjectHome`, `zidane-ui/session`): as tall as its content, sections split by a line. Under a `PanelSessionRowProvider row={YourRow}`, a row that stands for a `session` is drawn with your component (`{ item, session, fallback }`): the desktop app gives its sidebar row, so the project home's Recent sessions block looks and moves like the session list. Draw `fallback` for a session you don't know.

```tsx
function ExtensionTab({ id }: { id: string }) {
  const { contentFor } = useWorkspaceContent()
  const { act } = useWorkspaceActions()
  const entry = contentFor(id)
  const onAction = useCallback((actionId: string, payload?: unknown) => void act(id, actionId, payload), [act, id])
  return <PanelContentView content={entry.content} loading={entry.loading} error={entry.error} onAction={onAction} />
}
```

## Keys and hand-overs

| Piece | Does |
| - | - |
| `registerTabScope(el, scope)`, `useTabScope(ref, scope)` | A surface says what ⌥⌘← / ⌥⌘→, ctrl+w and ⌘F do inside it (`step`, `close`, `find`). Kept-alive bodies register too; a hidden one never takes the keys. |
| `tabTarget()`, `focusedTabScope()`, `panelTabScope()`, `hoveredTabScope()` | Where the keys act: the scope holding focus, else the one shown in the focus region (`TAB_REGION_PROPS` marks one). `trackTabRegions()` follows the region the user is in, `keepFocusIn(region)` puts focus back when what held it goes away. |
| `usePanelGroupHandlers(enabled)` | `splitTabRight` / `splitTabDown` for the keybindings, offered only while the focused group has a second tab |
| `useWorkspaceKeyCommands({ scope, session, toggle?, actions?, spaces?, closeOutside? })` | The panel's key commands for `useKeyCommands`: `openArtifacts` (show or hide), `openTodos`, `panelNext` / `panelPrev`, `closeTab` (the focused scope first, then `closeOutside`, then the visible tab), `tabNext` / `tabPrev` (where the focus is, else your `spaces`). Each only while it can act |
| `revealFile(path)`, `showWorkingTree()` | Hand-overs to the Files and Diffs tabs: they open the panel on the tab, which reads `revealRequest` / `workingTreeRequest` from `useWorkspaceRequests()` and takes them (`takeRevealRequest`, `takeWorkingTreeRequest`) |
| `createOpenTarget({ setting, system, revealFile, openProactive })` | One decision for every file, folder and diff click: your "open in" setting picks the editor (`FilesHost.system`) or the panel, ⌘ / Ctrl flips it, a sandbox file always opens in the panel |
| `setPendingReveal(path, at)`, `takePendingReveal(path)` | A line to reveal once the Files tab opens a file handed over (a terminal link's `src/app.ts:12:5`) |
| `createPanelFocusStore()` | What a scope's panel was opened on (a sidebar badge), for the panel's `load` and the built-in tabs; every open bumps `seq` |

## The horizontal budget

`LayoutBudgetProvider` fits the sidebar, the chat and the panel into the window. It keeps the chat at its floor by shrinking the panel, then the sidebar, then floating the panel over the chat, then collapsing the sidebar. Read the result with `useLayoutBudget()`. `resolveLayout` (`zidane-ui/workspace/headless`) is the same rule as a pure function.

## Per-scope layout memory

`createLayoutMemory({ storage })` (`zidane-ui/workspace/headless`) remembers how each conversation's window was left: the panel's tree, width and share, the Files tab (open files, tree column, filters), the terminal drawer and tab order, and the composer badges' order. A new session copies its project's last-used layout once, then owns its own, so another session's change never moves it. `storage` is read on each access (`() => localStorage`); a write that fails keeps the layout for this window. Other windows' writes arrive as `storage` events.

Feed it the session → scope mapping (`setSessionScopes`), read a scope with `useScopeLayout(memory, scope)` (`zidane-ui/workspace`), change it with `updateScopeLayout(scope, patch)` (a function `patch` reads what is stored now), and call `sweepScopeLayouts(listLocalSessions)` once per launch to drop records nothing can show again. A scope is `{ root, cwd, session?, draft? }`: `layoutScopeKey` and `layoutProjectKey` are its keys.

## Test your host

`zidane-ui/workspace/testing/contract` is the suite every host runs, in your own vitest. Build your host over a backend that holds the layouts and panels it is given, and list the members it offers.

```ts
import { describeWorkspaceHostContract } from 'zidane-ui/workspace/testing/contract'

describeWorkspaceHostContract('my host', backend => makeHarness(backend), ['layouts'])
```

| Entry point | Holds |
| - | - |
| `zidane-ui/workspace` | `WorkspaceProvider`, the hooks, `SidePanel`, `PanelCorner`, `usePanelVisible`, `LayoutBudgetProvider`, `PanelContentView`, the tab scopes, `usePanelGroupHandlers`, `useWorkspaceKeyCommands` |
| `zidane-ui/workspace/headless` | `WorkspaceHost`, the layout document and its operations, the layout stores, `resolveLayout`, `createOpenTarget`, the reveal hand-over, `createPanelFocusStore`, the labels |
| `zidane-ui/workspace/testing` | `createFixtureWorkspaceHost` and its demo data |
| `zidane-ui/workspace/testing/contract` | `describeWorkspaceHostContract` |
