# Session panels

> The desktop app's session surfaces as React components (the Todos, Queue and Artifacts panels, the session header, the welcome screen and the session dialogs) over a host for plans, the queue, artifacts, running work, turns and usage.

`zidane-ui/session` holds the desktop app's session surfaces: the Todos, Queue and Artifacts panels, the read-only todo list a `todowrite` call shows, the Diffs panel's changed-files tree, the session header, the welcome screen, and the dialogs for session details, turn details, running work and usage. They read a `SessionHost`. The Todos panel shows the session's plan: the one the agent applies, else the one that stopped, else the last that closed (a proposal waiting for a decision shows in the Inbox). The previews below run on the fixture host. Click a step to rename it, cancel or retry it, add one, or stop and drop the plan.

## Mount it

Wrap the panels in a `SessionProvider` and pass the session id. The data and the actions come from the host.

```tsx
import 'zidane-ui/styles.css'
import { SessionProvider, TodosPanel } from 'zidane-ui/session'

export function Todos({ host, sessionId }) {
  return (
    <SessionProvider host={host}>
      <TodosPanel sessionId={sessionId} />
    </SessionProvider>
  )
}
```

`AppHostProvider` (`zidane-ui/app`) puts `host.session` in a `SessionProvider` for you.

## Pick a host

| Host | From | Talks to |
| - | - | - |
| `createServerSessionHost({ client })` | `zidane-ui/adapters` | zidane-server. Pass the client of your chat host (`createServerHost(…).client`). |
| `createFixtureSessionHost(fixture)` | `zidane-ui/session/testing` | Memory, for demos and tests. `DEMO_SESSION_FIXTURE` is a session mid-task. |
| Your own | `zidane-ui/session/headless` | Anything. `createPlansState`, `createQueueState` and `createArtifactsState` keep one store per session and follow the backend's pushes. |

zidane-server answers stored artifacts, not rendered rows, so the server host shows each artifact's label under its raw kind. The server renders a transcript export (`sessions.export`) and the server host hands it to `saveExport` (a browser download, say), offering `details.export` only with one. The side chat is `sessions.sideSession`, `sessions.create` with `sideOf` and `sessions.delete`. A row re-resolves on the server (`artifacts.refresh`, with the extensions of the artifact's folder), a title's status is the server's (`sessions.titleStatus`) the welcome screen sums the server's store (`usage.summary`, the server's local days) and the usage panel shows the providers' subscription windows, probed with the server's credentials (`usage.providers`), and GitHub's API limits as the server sees them (`usage.github`); each is offered when the connection may call it.

## What a host offers

Every member is optional. A panel hides what needs a member the host leaves out.

| Member | Gives |
| - | - |
| `plans.snapshot(id)`, `plans.refresh(id)` | The session's plans (`PlansSnapshot`: every kept plan and the one worth a glance) |
| `plans.edit` | Step edits through zidane's plan gate: add, rename, cancel (a step is never deleted). Rejects with the gate's reason. Without it the panel is read-only. |
| `plans.control` | Stop and drop a plan (and the run applying it) |
| `plans.retry` | A step's retry and a stopped plan's resume, sent as a message into the run |
| `queue.items(id)`, `queue.refresh(id)` | The messages sent while the agent works, in delivery order |
| `queue.add`, `queue.remove`, `queue.update`, `queue.attachments`, `queue.pushNow` | Queue a message, drop it, edit it, read its attachments, send it now |
| `artifacts.groups(id)`, `artifacts.refresh(id)` | What the session produced, by kind: pull requests, branches, commits, files |
| `artifacts.remove` | Remove an artifact |
| `artifacts.refreshRow` | Re-resolve a row when the panel opens and on hover (is the pull request merged?) |
| `runningWork.items(id)`, `runningWork.refresh(id)`, `runningWork.ensure(id)` | The session's cancellable tool calls and background tasks, live |
| `runningWork.cancel` | Cancel one item, or all |
| `turns.get` | A stored turn, for the turn details dialog |
| `turns.fork`, `turns.editText`, `turns.delete` | Fork from a turn, edit its text, delete it |
| `turns.rewind` | Cut the history after a turn (`inclusive`) or just before it: the turn menu's Rewind and Edit message (`useTurnCommands`) |
| `details.generateTitle`, `details.titleStatus` | Generate a title, and say how the title came about |
| `details.compact`, `details.export`, `details.setFolder` | Compact the context, export the transcript, attach or detach a folder |
| `details.cacheReport` | What the prompt cache did for the last requests. `useSessionCacheReport(id, updatedAt)` reads it, `SessionCacheFacts` shows it in the details dialog's `cache` slot |
| `usage.summary` | Token spend over a range, for the welcome screen |
| `usage.providers`, `usage.github` | Subscription windows and GitHub's API limits, for the usage panel |
| `side.lookup`, `side.create`, `side.remove` | The `/side` chat about a session: find it, create it on the first send (on the parent's model), delete it on `/reset` |
| `side.subscribe` | The side chat's pickers follow a model pick made elsewhere |

`useSessionPlans(id)`, `useSessionQueue(id)`, `useSessionArtifacts(id)` and `useSessionRunningWork(id)` read one session and re-render only when that session changes. `useUsageSummary(range)` keeps the last summary across mounts (and in `localStorage`), so the welcome screen paints its numbers on the first frame. `useProviderUsage()` reads the subscription windows; a `ProviderUsageProvider` above it probes every minute while the page is visible and focused, and catches up when it gets focus back (`autoLoad={false}` leaves only the manual refresh).

## Parts

| Part | Does |
| - | - |
| `TodosPanel` | A session's plan: its steps with rename, retry and cancel, the add box, the tally, stop, resume and drop. A closed plan is read-only. |
| `TodoListView` | A todo list, read-only, one row per item with its status glyph |
| `TodoTally` | One chip per status, zeros left out |
| `DiffFileTree` | Changed files as folders and files with their +/- counts and project findings. `buildDiffTree` and `visibleRows` (`zidane-ui/session/headless`) make its rows. |
| `QueueTab` | The queued messages, edited in place (↵ save, ⌘↵ send now). The `attachments` slot brings your attachment tiles and previews. |
| `ArtifactsPanel` | What the session produced, by kind. `onOpenPath` and `onRevealPath` open a path your way; `focus` brings a group or a row into view. |
| `ArtifactRowChips`, `DiffFacts` | An artifact row's id, diff and fact chips; a change size. `badgeToneClass(tone)` and `badgeColorStyle(color)` color an extension's badge or icon; `BADGE_ICONS` lists the icon classes an extension may name |
| `SessionHeader` | The folder chip, the server chip, the title (renamable in place) and your `toolbar` |
| `WelcomeScreen` | The greeting and the usage card: totals, streaks, a 26-week heatmap, tokens by model, spend by provider |
| `ProjectHome` | A folder's new-chat screen: the project (or the greeting), then every panel on the `'home'` surface as a block card loaded through a `WorkspacePanels` host (`panels`). Put your composer below it, where a conversation has it, so the first send moves nothing. `defaultBlocks` names the blocks that show at first (without it, every block shows); the user adds the others. With `switcher`, a click on the project's name opens the composer's folder menu (`ProjectMenu`) to switch projects. Customize turns on edit mode: the user drags blocks or moves them with arrow buttons, sets each one full or half width, hides it, and brings hidden blocks back from Add block. Outside edit mode, each card's menu has Refresh and Hide. The arrangement and each block's controls are kept per `projectKey`. `blocksUnavailable` says blocks do not load here instead. |
| `SessionDetailsDialog` | Rename, generate a title, the facts, compact, export, delete. You pass the facts resolved (model and agent names). |
| `TurnDetailsDialog` | Copy, fork, edit or delete a turn. `onForked` and `onChanged` say what follows. |
| `RunningWorkDialog` | The running work with Cancel and Cancel all. `showTerminal` adds Show terminal to a row. |
| `UsagePanel` | Subscription windows and credits per provider, then GitHub's limits |
| `SessionShell` | The dialogs' default frame. Pass your modal shell as `renderShell` instead. |
| `SideChat` | A `/side` chat about a session, over `SessionHost.side` and the `ChatHost` (`send`, `interrupt`, `transcript`). Its composer offers `/reset`. You draw its transcript (`renderTranscript`) and, if you like, the model pickers (`renderPickers`, whose `commitTo` creates the side chat first). `createSideRequests()` and `parseSideCommand` (`zidane-ui/session/headless`) hand a `/side question` typed in the conversation's composer to it. |
| `InboxAttention`, `TodosAttention`, `QueueAttention` | Render nothing: they badge the Inbox, Todos and Queue tabs (`WorkspaceActions.setAttention`) for the active session, and a live request opens the Inbox (`requestAttention`; `reveal={false}` only badges). Pass `actions` to route them through your own panel actions. |

The Inbox is the chat area's `InboxPanel` (`zidane-ui/chat/interactions`): what the agent waits on in a session (`ChatHost.interactions`), the head request open as a card, the older ones collapsed under it. A resolved card leaves on the `exit` motion. `focus` hands keyboard focus to the head card once, and `onEscape` gives the keyboard back. `useInteractionHead(id)`, `usePendingInteractions(id)` and `useAllInteractions()` read the same requests for badges and dots; `useAllInteractions` needs `interactions.all`, which the server host leaves out.

`collectSessionDiffs` turns a transcript's edit events into one patch per change, and `groupByPath` groups them by file. The desktop app's Diffs tab reads both.

## Reminders

`RemindersPanel` is the desktop app's Reminders tab: the [reminders](https://zidane.si/docs/reminders.md) of the session beside it, or all of them. It has the create form (the time words, read in the browser's time zone, with a preview of when it sends), and each row's actions: pause and resume, run now, delete, keep a repeat the agent set, and its fire history. It says so when Reminders is off, and renders nothing when the host has no `schedules`.

```tsx
import { RemindersPanel, SessionProvider } from 'zidane-ui/session'

<SessionProvider host={host}>
  <RemindersPanel sessionId={sessionId} />
</SessionProvider>
```

| Member | Gives |
| - | - |
| `schedules.status`, `schedules.refresh()`, `schedules.ensure()` | Whether Reminders is on (`{ enabled, supported }`). Nothing about reminders shows unless it is `enabled`. |
| `schedules.all`, `schedules.forSession(id)` | Every reminder, and those whose prompt goes to one session |
| `schedules.create` | The create form |
| `schedules.update` | Pause, resume, and keep for good (`expiresAt: null`) |
| `schedules.remove`, `schedules.runNow`, `schedules.fires` | Delete, run now, and the fire history |

`useSchedulesStatus()`, `useSessionSchedules(id)` and `useAllSchedules()` read them. `createSchedulesState` (`zidane-ui/session/headless`) is the store a host builds on, and `readReminderWhen(text, now)` reads the time words with a typed reason when it fails. `createServerSessionHost` offers `schedules` over `schedules.*` and reads again on `schedules:changed`. On the fixture host, `control.setSchedules(list)` stands in for the agent setting one, and `control.setSchedulesEnabled(on)` for the setting.

Two more places show reminders. The composer's `reminders` badge counts the session's reminders that will fire and shows the next time (`Next 09:00`). It opens the `reminders` tab when your app has one. A sidebar row with `scheduleId` (`SessionRow.scheduleId`) shows a clock mark, and `SidebarFilterMenu`'s `origin` prop adds the Origin row (All, Scheduled, Not scheduled). Pass `origin` while Reminders is on.

## Session actions

`createSessionActions(deps)` (`zidane-ui/session/headless`) is what the keys, menus and palette do to a session: `generateTitle`, `compact`, `exportMarkdown` / `exportJson`, `remove` (asks `confirm` first, in the sidebar's wording), `toggleFavorite`, `copyId`, and `fork(sessionId, turnId)`. It runs over `session` (`SessionHost.details`, `turns`) and `sessions` (`ChatHost.sessions` or your directory); each action whose capability is missing is absent. `refresh`, `open`, `copyText`, `idOf` and `onError` are your app's. Actions never reject except `fork`: a failure goes to `onError`.

`sessionKeyCommands(actions, session)` binds them to the catalog's session keys for `useKeyCommands` (`zidane-ui/keybindings`): none without a session, no favorite on the main session.

```tsx
const actions = createSessionActions({ session: host.session, sessions: host.chat.sessions, confirm, copyText: (t) => navigator.clipboard.writeText(t) })
useKeyCommands(sessionKeyCommands(actions, session))
```

## Test your host

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

```ts
import { describeSessionHostContract } from 'zidane-ui/session/testing/contract'

describeSessionHostContract('my host', backend => makeHarness(backend), ['plans', 'plans.edit', 'plans.control', 'queue', 'artifacts'])
```

| Entry point | Holds |
| - | - |
| `zidane-ui/session` | `SessionProvider`, the hooks, the panels, the header, the welcome screen, the dialogs, `SideChat` and the attention drivers |
| `zidane-ui/session/headless` | `SessionHost`, the stores (plans, queue, artifacts, running work, usage), the usage formatting, the diff models, the `/side` hand-over (`createSideRequests`, `parseSideCommand`), the session actions (`createSessionActions`, `sessionKeyCommands`) and the labels |
| `zidane-ui/session/testing` | `createFixtureSessionHost` and its demo data |
| `zidane-ui/session/testing/contract` | `describeSessionHostContract` |
