# Session sidebar

> The desktop app's whole session sidebar — every host's sessions, spaces, filters, search, live rows, menus, extension spaces and rows, and the footer — over any ChatHost.

`zidane-ui/chat/sidebar` holds the pieces of the desktop app's sidebar, and `zidane-ui/chat/headless` the model under them: a session directory that lists every host's sessions and keeps them current, the filter axes kept per space, what extensions add to the list, the rows with their groups, and the live stores a row reads (what a run is doing, its badges). The preview below is live: type in the search well, open the filter menu, switch spaces, pick an icon.

## The whole sidebar

`SessionSidebar` is the desktop app's sidebar, whole: the frame that animates open and shut and resizes from its edge, the top band (the orchestration Main, New session, the search well with the filter menu), the list (sections whose headers stick, the search results, one list per space that slides on a switch), the rows with their menus, and the footer you hand it. ↑ and ↓ walk the list as shown (the cursor moves in the DOM, the session opens once the keys stop), ⌘N starts a new session. The preview below is live: open a row's menu, rename one, star one, switch spaces.

```tsx
import { SessionSidebar, SidebarFooter, SidebarHostProvider, createSidebarLive } from 'zidane-ui/chat/sidebar'

// Once, at module scope: where each row's live signals come from.
const live = createSidebarLive({
  useRunning: (id) => useRunState(id),           // your stream store
  useRunElapsedSec: (id, live, ms) => useRunClock(id, live, ms),
  activity: createSessionActivity({ subscribe: onStreamSignal }),
  badges: createSessionBadges({ read: (ids) => readBadges(ids) }),
})

function Sidebar() {
  const view = useSessionDirectoryView(directory)
  return (
    <SidebarHostProvider host={sidebarHost}>
      <SessionSidebar
        {...view}
        hosts={hosts}
        live={live}
        onActivate={directory.select}
        orchestration={false}
        onNewSession={startDraft}
        onNewSessionIn={startDraftIn}
        onRename={directory.rename}
        onSetArchived={directory.setArchived}
        onSetFavorite={directory.setFavorite}
        onDelete={confirmDelete}            // useConfirmDeleteSession(directory.drop)
        onArchiveProject={project.archive}  // useProjectSessionActions(…)
        onDeleteProject={project.remove}
        collapsed={collapsed}
        width={width}
        onWidthCommit={saveWidth}
        footer={<SidebarFooter onOpenSettings={openSettings} />}
      />
    </SidebarHostProvider>
  )
}
```

A window with native controls over its top-left corner (macOS traffic lights) passes `chromeInset` (`{ height, width }`, CSS lengths): a drag strip that clears them. Under a `LayoutBudgetProvider` (`zidane-ui/workspace`) the width and the drag cap follow the window's budget.

Under an `ExtensionsProvider` (`zidane-ui/extensions`), the sidebar also draws the extensions' `sidebar.top` items between the search well and the list, and their `sidebar.bottom` items between the list and your footer: full-width rows (`ExtensionSlot`) that never scroll with the list, each block at most 30% of the sidebar's height and scrolling on its own past that. They show the open session's items (`activeSessionId`) and the unscoped ones, and draw nothing when there are none. `SidebarFooter` draws the `sidebar.footer` and `app.statusbar` chips.

## The session directory

`createSessionDirectory` keeps the list outside React: one read per host under the archive filter (only the newest read lands), the rows' run state reconciled with your stream store, debounced re-reads on change signals, a notification on a session whose run ended while it wasn't open, the shown space and the open session following each other, and the actions (create in the shown space, rename, an optimistic favorite, archive, move a session or a whole project to a space, delete). `useSessionDirectoryView` reads it in React with the shown space's filters applied.

```tsx
import { createSessionDirectory, createSidebarViews, writable } from 'zidane-ui/chat/headless'
import { useSessionDirectoryView } from 'zidane-ui/chat/sidebar'

const views = createSidebarViews({ storage: localStorage, host: host.sessions?.views })
const directory = createSessionDirectory({
  sessions: { ...host.sessions!, query: host.sessions!.query! },
  streams,                       // your session stream store's run-state port
  loadEvents: (id) => loadPage(id),
  isRunning: (id) => isRunning(id),
  servers: writable([]),         // remote hosts listed next to this machine
  settings: writable({ spaces: false, spaceList: [], orchestration: false }),
  views,
  serverOf: () => null,
})
directory.start()

function Sidebar() {
  const { sessions, groupBy, sortBy, activeSessionId } = useSessionDirectoryView(directory)
  // build the rows with buildHostSidebarRows(sessions, hosts, groupBy, sortBy) and render them
}
```

## Extensions in the sidebar

Extensions add spaces, filters, sorts, groupings, row decorations and rows of their own ([extension sidebar](https://zidane.si/docs/extensions-sidebar.md)). The directory reads them from the extensions host's six sidebar stores, through `sidebarContributionsOf`, and evaluates them with zidane's session query (`zidane/chat/pure`), so every app lists the same rows for the same view.

```tsx
import { createSessionDirectory, sidebarContributionsOf } from 'zidane-ui/chat/headless'

const directory = createSessionDirectory({
  // …the options above
  contributions: sidebarContributionsOf(extensionsHost.items),
  saveSpace: async (space) => settings.update({ spaceList: [...currentSpaceList(), space] }),
})
// Or later, once the extensions host is up: directory.attachContributions(sidebarContributionsOf(host.items))
```

- **Spaces.** `mergeSidebarSpaces` puts the user's spaces first, in their order, then the extensions' by `order`. An extension space is read-only (`readonly`, `extensionId`). A `spaceList` entry with an extension space's id only places it, or hides it with `hidden: true`; an entry whose extension no longer adds the space is dropped. `state.switcherSpaces` is the switcher's list: the order and hidden spaces the user set (`moveSpace`, `setSpaceHidden`, kept per device under `zg:sidebar-switcher:v1`) over `state.spaces`. The default space never hides.
- **Membership.** A session tagged with a space is in it, the orchestration Main session is in every space, and a space with a `query` also lists the sessions it matches (`spaceMembership`). A tag naming a space that is gone reads as the default space.
- **The view layer.** The agent filter and the contributed filters the space offers (`filtersForSpace`) narrow its rows. A contributed sort or grouping the user picked replaces the built-in one, and the space's own `groupBy` and `sortBy` are its defaults (`resolveGrouping`, `resolveSort`). `useSessionDirectoryView(directory).query` holds the result for the shown space: the space, its view, the grouping and sort that apply, and the contributions.
- **Rows.** `createDecorator` merges the decorations that match a session: the first icon, subtitle and component, every chip up to 8. `itemsForSpace` and `claimChildSessions` place the items and the sessions their `children` query lists. Under a query grouping an item leads the group its `group` names; under a built-in grouping, items lead the list.
- **Drawing them.** Pass `query` from `useSessionDirectoryView` to `SessionSidebar`: its rows then draw the decorations and the items. An item's click opens its target through the extensions host (a command, a panel, a screen, a session); `onOpenItem` replaces that.
- **Save view as space.** With `saveSpace`, `directory.saveViewAsSpace(name)` turns the shown view into a user space, adds it through `saveSpace` and shows it. The query comes from `viewQuery` (`zidane/chat/pure`), the builder the terminal app uses too: the space's membership (tagged, Main, or matched by its query), then the project, activity, agent and contributed filters. The archive filter is not part of the query: the new space's view copies it, as a view setting. The project filter keeps the sessions whose folder name (`projectName`) equals the view's project exactly, so another folder with the same name matches too. A query too large to save comes back as `issues` and nothing is saved. The kit offers the action whenever `saveSpace` is given; the desktop and web apps show it only with Spaces on.

### Agent grouping and filter

Every space has two agent choices, extensions or not: View › By agent groups by `agentId` (`AGENT_GROUPING`, headers named from the agent catalog, `Default agent` for sessions without one), and the Agent filter keeps one agent's sessions. `query.agents` lists the agents the space's sessions run; `SidebarFilterMenu` offers the filter when it is not empty. Names come from `agentNames`, else the composer host's agent catalog (`useAgentNames`), else the id.

### The conversation last open per space

The views store also remembers, per space, the conversation last open in it: opening a session (a click, the palette, a link) records it under its space. `setActiveSpace` (the switcher, `stepSpace`'s ⌥⌘← / ⌥⌘→) reopens that conversation when it is still listed and still in the space; otherwise it does what it did before (the open session closes when it belongs elsewhere, and the app picks what to show). Deleting a session or moving it (or its project) to another space forgets it in the space it left. Read and write it with `views.lastSessionOf(spaceId)`, `views.rememberSession(spaceId, sessionId)` and `views.forgetSession(sessionId)`.

It is kept in the space's view record (`{ ...view, session }`), in the same Storage key and through the same `views.setView`, so a host that keeps the views keeps it too. Like a view, the host's record wins over this device's copy at `sync()`, except for a space whose conversation changed in this run.

## Host capabilities

The directory reads `ChatHost.sessions`. Each member it needs beyond the list is optional, and leaving one out hides what needs it:

| Member | What it enables |
| - | - |
| `query` | Every host's sessions under an archive filter, with folder, host and space |
| `setSpace` | Moving a session (or a project) to a space |
| `search` | The search well's content matches (titles always match locally) |
| `onChanged` | Re-reading a host when its sessions change |
| `views` | Sharing the filter axes, the conversation last open per space and the shown space across windows and devices (else they stay in Storage) |
| `remove` | Resolves to `{ kept: true }` when the session's environment stayed on disk |
| `main` | The orchestration Main session's id, created when missing (`null` while orchestration is off): `useMainSession` |

The desktop app implements them over IPC; `createServerHost` (`zidane-ui/adapters`) over zidane-server, with `views` in the server's `state.json` (read with `sidebar.getViews` and `sidebar.getSpace`, saved with `sidebar.setView` and `sidebar.setSpace`), so a page's `createSidebarViews` finds them in any browser. Both run the ChatHost contract (`zidane-ui/chat/testing/contract`).

What the menus ask of the platform comes from a `SidebarHost` (`SidebarHostProvider`, or `AppHost.sidebar` under `AppHostProvider`). Every member is optional; leaving one out hides its rows:

| Member | What it enables |
| - | - |
| `revealPath` | Open in Finder, on a row of this machine and a project header |
| `openWindow` | Open in new window (the conversation with its panels, alone, or the panels alone) |
| `environment` | Retry a failed environment, and the environment's own commands (push, open a pull request) |
| `badges` | The rows' badges and when they change: the source for `createSessionBadges` |

The desktop app implements the first three over IPC, and reads the badges through its own source. `createServerSidebarHost` (`zidane-ui/adapters`) offers `environment` over zidane-server (`sessions.environmentActions`, `sessions.runEnvironmentAction` with an `admin` token, `sessions.retryEnvironment`) and `badges` (`artifacts.badges`, read again on a session's `artifact:recorded` / `artifact:changed` and on `catalog:changed`); a browser has no Finder, and opens its own tabs. Both run the SidebarHost contract (`describeSidebarHostContract`, `zidane-ui/chat/testing/contract`); `createFixtureSidebarHost` (`zidane-ui/chat/testing`) holds one in memory.

## The pieces

| Export | What it is |
| - | - |
| `SidebarSearchRow` | The search well, Escape clears; the filter menu sits beside it (`filter`) |
| `SidebarFilterMenu` | The axes (view, status, project, activity, agent, sort) over a `SidebarView`, the extensions' filters, sorts and groupings (`query`), and Save view as space (`onSaveView`) |
| `useSessionSearch` | Title matches at once, content matches from `ChatHost.sessions.search` after a debounce |
| `SpaceSwitcher`, `SpaceGlyph` | The spaces toolbar (arrows, Home, End) and a space's icon. With `onMove` and `onHide`, a right-click on a space moves it left or right, hides it, or shows a `hidden` one again. An extension's space wears a badge and names its extension |
| `MainSessionRow` | The orchestration main session's pinned row |
| `useMainSession(enabled, open)` | Creates the Main as the `orchestration` setting turns on (`ChatHost.sessions.main`) and returns the ⌃⇧M opener, a no-op while off |
| `ProjectIconButton`, `IconPicker`, `useProjectIcons` | A folder's emoji or image icon, kept in localStorage |
| `usePinnedProjects` | Folders pinned to the top of a space's project view |
| `useSessionActivity`, `useSessionBadges` | A row's live line: what the run is doing, its badges, over `createSessionActivity` / `createSessionBadges` |
| `SessionSidebar` | The whole sidebar (above) |
| `SidebarFrame`, `useSidebarFrame` | The aside on its own: presence, width, the resize handle, the chrome inset |
| `createSidebarLive` | A row's dot, trailing slot and badge line over your hooks and stores, built once |
| `SidebarSessionRow`, `SidebarGroupHeader`, `NewSessionButton` | One row with its menu, a group header with its project menu, the New session row |
| `SidebarDecoration`, `SidebarChip` | What extensions put on a session row: the merged icon and chips, or a decoration's component with them as its fallback |
| `SidebarRowView` | An extension's item among the sessions: status or icon, title, badges, time, its actions in the row menu, its sessions folded under it |
| `sessionMenuItems`, `useSessionMenuItems`, `useEnvironmentActions`, `useEnvironmentCommands` | A conversation's menu, for any surface that offers it (a session header) |
| `useConfirmDeleteSession`, `useProjectSessionActions` | Delete a session, archive or delete a project's, after a confirmation |
| `EnvironmentChip`, `ProjectChip` | The chips a row's badge line leads with, after the host's `HostBadge` (`zidane-ui/chat`) |
| `useSidebarKeyboardNav` | The list's ↑ / ↓ and ⌘N on your own list |
| `SidebarFooter`, `SidebarSignInPrompt`, `SidebarUsageIndicator`, `SidebarCompactingIndicator` | The strip under the list and its status pieces |
| `useCompactionProgress` | A session's compaction in progress (what `SidebarCompactingIndicator` shows), over `createCompactionProgress({ subscribe })` fed every session's stream signals, so a compaction already under way shows when you switch to its session |
| `SidebarToggleButton` | The one toggle, named after the state (a budget-forced collapse included) |
| `SpacesSection` | Settings › Spaces over the settings host (`zidane-ui/settings`), each space's own rows from `renderDefaults` |

Every string comes from labels (`DEFAULT_SIDEBAR_LABELS`); override any of them with `LabelsProvider`.
