# Extension sidebar

> Add spaces, filters, sorts, groupings, row decorations and rows of your own to the session sidebar, with session queries and per-session facets.

An extension can shape the session list in every app. It adds spaces (named views of the sessions a query matches), filters, sorts and groupings the user picks, extra data on session rows, and rows of its own beside the sessions. All of it is data: one session query language says which sessions each part applies to, and every app evaluates it the same way.

Six tokens add the parts, through `ctx.add` like any other [UI item](https://zidane.si/docs/extensions-ui.md):

| Token | Item | Adds |
| - | - | - |
| `Space` | `SpaceItem` | A space: a named view of the sessions a query matches, with its own grouping, sort and new-session defaults |
| `SidebarFilter` | `SidebarFilterItem` | A filter the user turns on: a toggle, one option, or several |
| `SidebarSort` | `SidebarSortItem` | A sort order the user can pick |
| `SidebarGroup` | `SidebarGroupItem` | A grouping the user can pick |
| `SidebarDecoration` | `SidebarDecorationItem` | An icon, a subtitle, chips or a component on the rows of matching sessions |
| `SidebarRow` | `SidebarRowItem` | A row of the extension's own (a ticket, a job, a pull request) among a space's sessions |

A query reads the fields every session has (its agent, model, status, folder, times) and facets: values the extension computes per session with `ctx.sessions.facets`. The same queries drive [`ctx.sessions.list`](https://zidane.si/docs/extensions-sessions.md#read-sessions).

## A first space

A space for the sessions that ran in the last day on a big model, grouped by model:

```ts
import { Space } from 'zidane'

ctx.add(Space, {
  id: 'recent-opus',
  name: 'Opus today',
  icon: { gui: 'i-ph-lightning', tui: '*' },
  query: {
    all: [
      { field: 'modelId', contains: 'opus' },
      { field: 'updatedAt', gte: '-1d' },
    ],
  },
  groupBy: { field: 'modelId' },
  sortBy: [{ field: 'updatedAt', dir: 'desc' }],
})
```

The space shows in the space switcher of every app, next to the user's own spaces. Its published id is `<extension>/<id>`.

## Spaces

| Field | Type | Does |
| - | - | - |
| `id` | `string` | Required. Unique per extension. `default` is the built-in space's id and is refused |
| `name` | `Bindable<string>` | Required. The name in the switcher and its tooltip |
| `icon` | `{ gui?: string, tui?: string }` | An icon class for the desktop and web apps, a glyph for the terminal app |
| `query` | `Bindable<SessionQuery>` | Required. The sessions the space lists, besides those tagged with it and the Main session |
| `groupBy` | `Bindable<SessionGroupBy>` | How the space groups its sessions until the user picks a grouping. Absent: one flat list |
| `sortBy` | `Bindable<SessionSort[]>` | How the space sorts its sessions until the user picks a sort. Absent: the app's order |
| `newSession` | `Bindable<{ agentId?, providerKey?, modelId?, effort? }>` | What a session started in the space runs with. Each field left out takes the app's default |
| `items` | `boolean` | Lets [rows](#rows) list in the space, this extension's and others'. Default `false` |
| `order` | `number` | Lower first among extension spaces. Default 100 |

### Bound fields

The sidebar tokens share one rule. A field is `Bindable` when it is data that follows the extension's state: names, labels, queries, sorts, groupings, new-session defaults, a row's state, a decoration's content. Bind it to a `Readable` (a store or `store.select`) and the apps get the new value when it changes. Ids, `kind`, placement (`space`, `spaces`, `group`, `order`, `items`), actions, `open` and components stay fixed: change them with `handle.update`. A bound value is read whole: to change one filter option or one chip, bind the whole `options` or `chips` list.

### Which sessions a space lists

A session is in a space when one of these holds:

- It is tagged with the space. A new session started while the space shows is tagged with the space's id (`<extension>/<id>`), and keeps the tag.
- It is the orchestration Main session.
- The space's `query` matches it, wherever it was started.

A query space is a view, not a move: a session it matches also stays in the space it was started in. A session tagged with an extension space shows only there, not in Default. When the extension unloads or withdraws the space, its tagged sessions go back to Default.

The [Main session](https://zidane.si/docs/daemon.md#main-session) shows in every space.

### New sessions

`newSession` sets the agent, the model (`providerKey` with `modelId`) and the thinking effort of a session started from the space. The host fills only what the request leaves out, so a user who picks another model in the composer keeps their pick.

```ts
import { Space } from 'zidane'

ctx.add(Space, {
  id: 'reviews',
  name: 'Reviews',
  query: { field: 'agentId', eq: 'reviewer' },
  newSession: { agentId: 'reviewer', effort: 'high' },
})
```

Sessions an extension creates with `ctx.sessions.create` are not tagged with a space. Make the space's `query` match them, for example through a [facet](#facets).

### What users can change

An extension space is read-only for the user: its name, query and defaults stay the extension's. The user can:

- Hide it from the switcher, show it again, and move it among the other spaces. Settings keep its place: an entry in the `spaceList` setting with the space's id places it, and `hidden: true` leaves it out.
- Pick a grouping, a sort and filters in it, as in any space. Each space keeps its own choices.
- Save the current view as a space of their own (Save view as space). The saved space holds a query built from the view: the space's sessions (tagged, Main, or matched by its query), then the project (by folder name, `projectName`), archive, activity, agent and extension filters. Every app builds it the same way. It stays after the extension goes, and the user can rename or delete it in Settings › Spaces.

## The session query

A `SessionQuery` is JSON. It is a condition on one field, or a combination of queries:

```ts
import type { SessionQuery } from 'zidane'

const stale: SessionQuery = {
  all: [
    { field: 'status', in: ['idle', 'completed'] },
    { field: 'updatedAt', lt: '-7d' },
    { not: { field: 'favorite', eq: true } },
    { any: [{ field: 'environment.kind', eq: 'worktree' }, { field: 'repoKey', contains: 'github.com/acme/' }] },
  ],
}
```

| Form | Holds when |
| - | - |
| `{ field, ...operators }` | Every operator given holds for the field's value |
| `{ all: [...] }` | Every sub-query holds. An empty list matches every session |
| `{ any: [...] }` | At least one sub-query holds. An empty list matches none |
| `{ not: query }` | The sub-query does not hold |

### Fields

| Field | Type | Holds |
| - | - | - |
| `id` | text | The session id |
| `title` | text | The title, or the id when it has none |
| `projectRoot` | text | The project root. No value for a chat with no folder |
| `projectName` | text | The folder name (last path segment) of `projectRoot`, or of `cwd` without one. Computed from those two |
| `cwd` | text | The working directory |
| `agentId` | text | The agent profile id |
| `status` | text | `idle`, `running`, `blocked` (waits on the user), `completed` or `error` |
| `createdAt` | time | When it was created |
| `updatedAt` | time | Its last change |
| `runStartedAt` | time | When the run going on started |
| `archived` | boolean | Archived. `false` when absent |
| `favorite` | boolean | Marked favorite. `false` when absent |
| `isMain` | boolean | The orchestration Main session. `false` when absent |
| `spaceId` | text | The space it was started in. No value in the default space |
| `providerKey` | text | The provider registry key |
| `modelId` | text | The model id |
| `account` | text | The provider account it uses |
| `effort` | text | The thinking effort |
| `hostId` | text | The host that serves it |
| `repoKey` | text | The repository its folder is a checkout of, such as `github.com/owner/repo` |
| `environment.kind` | text | Where it runs: `local`, `worktree`, `docker`, a sandbox provider. `local` without an environment |
| `environment.state` | text | The environment's state |
| `environment.branch` | text | The environment's branch |
| `facet.<extension>.<key>` | any | A [facet](#facets). Add `.<property>` steps to read into an object, a number step to index a list |

A time is epoch ms.

### Operators

| Operator | Takes | Holds when |
| - | - | - |
| `eq` | text, number, boolean or `null` | The value equals it. `eq: null` matches a session with no value |
| `ne` | the same | The value does not equal it. A session with no value matches, unless the value is `null` |
| `in` | a list of values, up to 256 | The value equals one of them. An empty list matches no session |
| `nin` | the same | The value equals none of them. An empty list matches every session |
| `gt`, `gte`, `lt`, `lte` | a number, a relative time or a date | The value is a number past the bound. A session with no number never matches |
| `contains` | text | The value contains the text, ignoring case |
| `exists` | boolean | `true`: the session has a value. `false`: it has none |

All operators of one condition must hold: `{ field: 'updatedAt', gte: '-7d', lt: '-1d' }` is a range.

An empty `in` is how a query built from data matches nothing: `{ field: 'agentId', in: bots.map(b => b.agentId) }` lists no session while there is no bot. `{ any: [] }` matches nothing too.

A missing value and `null` are the same: no value. A facet that holds a list matches `eq`, `in` and `contains` when one element does, and an empty list has no value.

### Times

On time fields and facets, a bound can be:

| Bound | Means |
| - | - |
| `1767225600000` | Epoch ms |
| `'now'` | The moment the query runs |
| `'-7d'`, `'+12h'` | That long before or after now. Units: `s`, `m`, `h`, `d`, `w` |
| `'2026-01-31'`, `'2026-01-31T09:00:00Z'` | An ISO date. A date without a zone is UTC |

Relative bounds resolve each time the apps evaluate the query, so a space for `{ field: 'updatedAt', gte: '-1d' }` stays current without an update.

### Sort and group

A sort is a list of keys, most significant first (1 to 8):

```ts
import type { SessionSort } from 'zidane'

const sort: SessionSort[] = [{ field: 'facet.bots.priority', dir: 'desc' }, { field: 'updatedAt', dir: 'desc' }]
```

`dir` defaults to `'asc'`. Sessions without a value sort last in both directions. Text sorts in natural order, ignoring case, on its first 256 characters. Only text, numbers and booleans sort: a facet that holds a list or an object sorts as a session with no value. Sessions that tie on every key keep a fixed order, by id.

A grouping is `{ field, labels?, order? }`. Each distinct value is one group, and a session whose value is a list is in one group per element. Sessions with no value go to a last group.

| Field | Type | Does |
| - | - | - |
| `field` | `SessionFieldPath` | Required. The field or facet to group on |
| `labels` | `Record<string, string>` | The header text per group key. Default: the key |
| `order` | `'asc' \| 'desc' \| string[]` | Groups by label, ascending or descending, or these keys first and the rest by label. Default `'asc'` |

### Limits and errors

A query nests at most 16 levels and holds at most 512 nodes. One `in` or `nin` lists at most 256 values, and a whole query at most 1024. A text value is at most 1024 characters.

`ctx.add` and `update` check every query, sort, grouping and template, and throw a `UiQueryError` that names the path of each problem from the item's field: `space: query.all[1].gte: "yesterday" is not a time`, `sidebar-filter: options[2].query.field: …`, `sidebar-decoration: chips[0].template: …`. Its `token` names the token and its `issues` list the problems. It is a `SessionQueryError`, the error `ctx.sessions.list` throws, so one `instanceof SessionQueryError` catches both. A bound value that turns invalid keeps the item's last valid form.

An extension shows at most:

| Token | At most |
| - | - |
| `Space` | 32 |
| `SidebarFilter` | 64 |
| `SidebarSort` | 32 |
| `SidebarGroup` | 32 |
| `SidebarDecoration` | 64 |
| `SidebarRow` | 500 |

One more throws a `RangeError`. `SIDEBAR_CAPS` holds these numbers.

### Query from code

`ctx.sessions.list` takes the same query, with `sort`, `limit` (1 to 100, default 30), `archived` (`false` by default, `true`, or `'all'`) and a `cursor` for the next page:

```ts
let cursor: string | undefined
do {
  const page = await ctx.sessions.list({
    where: { field: 'facet.bots.botId', exists: true },
    sort: [{ field: 'createdAt', dir: 'desc' }],
    limit: 100,
    ...(cursor ? { cursor } : {}),
  })
  for (const row of page.rows)
    ctx.logger.info(`${row.title}: ${String(row.facets['bots.botId'])}`)
  cursor = page.cursor
} while (cursor)
```

Pass the same `where`, `sort` and `archived` with each cursor: a cursor made for others is refused with a `SessionQueryError`. A cursor holds the last row's place in the sort (its sort keys, then its id), and the next page starts after that place. Sessions added or removed between two calls do not shift the next page. A session whose sort key changes between two calls (an update, under the default `updatedAt` sort) is read where it sorts at the time of each call, so it can show on both pages or on neither. Each row carries its facets under `<extension>.<key>`, and `worker: true` on the sessions this extension created. [Sessions](https://zidane.si/docs/extensions-sessions.md#read-sessions) has the rest of `ctx.sessions`.

## Facets

A facet is a value the extension computes for each session. Queries read it as `facet.<extension>.<key>`: in spaces, filters, sorts, groupings, decorations, item children and `ctx.sessions.list`.

```ts
ctx.sessions.facets.register({
  key: 'branch',
  compute: session => (typeof session.metadata.branch === 'string' ? session.metadata.branch : null),
  on: ['created', 'metadata'],
})
```

`compute` gets a frozen snapshot of the session: every field above, plus its `metadata` and its `turns` count. It returns any JSON value, or a promise of one. `register` returns a `Disposable`.

| Field | Type | Does |
| - | - | - |
| `key` | `string` | Required. Unique per extension: a letter or `_`, then letters, digits, `_` or `-`, 64 at most. No dots |
| `compute` | `(session) => JsonValue \| undefined \| Promise<…>` | Required. The value for one session |
| `on` | `('created' \| 'settled' \| 'metadata')[]` | When the host computes it again. Default: all three |

### When the host computes

| Trigger | When |
| - | - |
| `created` | A session was created |
| `settled` | A run of the session ended |
| `metadata` | The session's metadata changed: a rename, a model switch, a move to a space, an archive |

The host also computes:

- The newest 50 sessions that lack one of the extension's facets, when the extension first registers facets with the host.
- Every session the extension's facets were computed for, when a facet is added or withdrawn.
- On demand: `ctx.sessions.facets.refresh(sessionId)` computes the extension's facets for one session, and `refresh()` for the newest 500. Both resolve once the host did it.

Call `refresh` when the value depends on something the session does not show, such as a store the extension writes after it creates the session.

### Values and failures

The host stores the last value per session, in the session's metadata, and lists read it. Showing a list computes nothing.

| `compute` | The stored value |
| - | - |
| Returns a value | Replaced |
| Returns `null` or `undefined` | Cleared |
| Throws, takes more than 5 s, or returns more than 8 KiB of JSON or a value that is not JSON | Kept. The failure counts against the extension's error budget |

An extension registers at most 32 facets. One session stores at most 16 KiB of one extension's facets, and 64 KiB of facets in all. A value that would pass either limit is not stored: the last value stays, and the host writes a warning to the extension's log.

The host deletes stored values in two cases only:

- You dispose a facet's registration (or the scope that holds it) while the extension stays loaded. The host deletes that facet's values, and queries stop matching them.
- The user uninstalls the extension. The host deletes all of its values.

In both cases, the host deletes the values from the sessions it computed with that runtime and from the 500 newest sessions. Older sessions keep them.

If the user disables the extension, or it fails or reloads, its values stay and queries still read them. When the extension loads again, the host computes its facets only for the 50 newest sessions that miss one. Older sessions keep their last values until a trigger or `refresh()` computes them again.

Facets work on every host, also where the rest of `ctx.sessions` is not available. A host with no sessions never computes them.

## Filters, sorts and groupings

These three add choices to the sidebar's filter menu (the view menu in the terminal app). The user picks them; nothing applies until they do.

```ts
import { SidebarFilter, SidebarGroup, SidebarSort } from 'zidane'

ctx.add(SidebarFilter, { id: 'failed', label: 'Failed runs', kind: 'toggle', query: { field: 'status', eq: 'error' } })
ctx.add(SidebarFilter, {
  id: 'where',
  label: 'Runs in',
  kind: 'multi',
  options: [
    { id: 'local', label: 'This computer', query: { field: 'environment.kind', eq: 'local' } },
    { id: 'worktree', label: 'Worktree', query: { field: 'environment.kind', eq: 'worktree' } },
    { id: 'sandbox', label: 'Sandbox', query: { field: 'environment.kind', nin: ['local', 'worktree'] } },
  ],
})
ctx.add(SidebarSort, { id: 'oldest', label: 'Oldest first', sortBy: [{ field: 'createdAt', dir: 'asc' }] })
ctx.add(SidebarGroup, { id: 'by-repo', label: 'By repository', groupBy: { field: 'repoKey' } })
```

| `SidebarFilter` field | Type | Does |
| - | - | - |
| `id` | `string` | Required. Unique per extension |
| `label` | `Bindable<string>` | Required. The filter's name in the menu |
| `icon` | `{ gui?: string, tui?: string }` | Drawn with the label |
| `kind` | `'toggle' \| 'select' \| 'multi'` | Required. `toggle` keeps the sessions `query` matches while on. `select` keeps those of the one option picked. `multi` keeps those any picked option matches |
| `query` | `Bindable<SessionQuery>` | `toggle`: the sessions it keeps |
| `options` | `Bindable<{ id, label, query }[]>` | `select` and `multi`: 1 to 32 options. Bind the whole list to change an option |
| `spaces` | `'all' \| string[]` | The spaces that offer it: `default`, your own space ids, or `<extension>/<id>`. Default `'all'` |

A filter narrows the space's sessions; it never adds any. With several filters on, a session must pass each.

| `SidebarSort` field | Type | Does |
| - | - | - |
| `id` | `string` | Required. Unique per extension |
| `label` | `Bindable<string>` | Required. The choice in the menu |
| `sortBy` | `Bindable<SessionSort[]>` | Required. 1 to 8 keys |

| `SidebarGroup` field | Type | Does |
| - | - | - |
| `id` | `string` | Required. Unique per extension |
| `label` | `Bindable<string>` | Required. The choice in the menu |
| `groupBy` | `Bindable<SessionGroupBy>` | Required. A [grouping](#sort-and-group): `{ field, labels?, order? }` |

A space's own `groupBy` and `sortBy` are its defaults: the menu shows them as Space default, and the user can pick another. The built-in choices stay: the apps add By agent to the groupings and an agent filter, with the agent's name as the label.

## Decorations

A decoration adds data to the rows of the sessions it matches: an icon, a tint, a subtitle, chips, or a component.

```ts
import { SidebarDecoration } from 'zidane'

ctx.add(SidebarDecoration, {
  id: 'branch',
  where: { field: 'facet.git-extras.branch', exists: true },
  icon: { gui: 'i-ph-git-branch', tui: '⎇' },
  subtitle: { template: '{facet.git-extras.branch} on {modelId}' },
  chips: [{ facet: 'facet.git-extras.labels', tone: 'accent' }, { template: '{effort}' }, { text: 'git' }],
})
```

| Field | Type | Does |
| - | - | - |
| `id` | `string` | Required. Unique per extension |
| `where` | `Bindable<SessionQuery>` | The sessions it applies to. Absent: every session |
| `icon` | `Bindable<{ gui?: string, tui?: string }>` | An icon on the row |
| `tone` | `Bindable<'muted' \| 'subtle' \| 'accent' \| 'success' \| 'warn' \| 'danger'>` | Tints the icon, and the chips without their own tone |
| `subtitle` | `Bindable<string \| { template }>` | Text drawn under the title: a string as given, or a template |
| `chips` | `Bindable<({ text } \| { template } \| { facet })[]>` | Up to 8, each with an optional `tone` and `icon`. `text` is drawn as given, `template` is a template, and a `facet` chip shows the facet's value, one chip per element of a list |
| `component` | `ComponentRef` | Drawn on the row where the app can, with `sessionId` added to its props ([components](https://zidane.si/docs/extensions-components.md)) |

A decoration needs at least one of `icon`, `tone`, `subtitle`, `chips` or `component`.

Only `{ template }` values are templates. A plain string and a `{ text }` chip are drawn as given, so user text (a name, a title) is safe there, braces and all. A template is text with field paths in braces: `'{facet.git-extras.branch} on {modelId}'`. A missing value prints nothing, a list prints its elements joined with commas, and runs of spaces collapse. `{{` and `}}` print a brace: `escapeTemplate(text)` (from `zidane`) doubles the braces of text you put in a template. A time prints as epoch ms. A template is at most 500 characters, and an invalid one (a lone brace, an unknown field) throws when the item is added.

When several decorations match one session, the first one with an icon gives the icon, the first with a subtitle gives the subtitle, the first with a component gives the component, and every one adds its chips, up to 8 per row. Decorations apply in load order, then in the order they were added.

## Rows

A row is the extension's own entry in a space's list: a ticket, a scheduled job, a pull request. It can list sessions under it.

```ts
import { SidebarRow } from 'zidane'

ctx.add(SidebarRow, {
  id: 'pr-412',
  space: 'default',
  title: 'Fix the login redirect',
  subtitle: 'acme/web#412',
  status: 'running',
  time: Date.now(),
  badges: [{ text: 'review', tone: 'warn' }],
  open: { command: 'open' },
  actions: [{ id: 'merge', label: 'Merge', command: 'merge' }, { id: 'close', label: 'Close' }],
  children: { field: 'facet.prs.number', eq: 412 },
})
```

| Field | Type | Does |
| - | - | - |
| `id` | `string` | Required. Unique per extension |
| `space` | `string` | Required. The space it lists in: `default`, your own space id, or `<extension>/<id>` |
| `group` | `string` | The group it leads, by group key, when the space's list is grouped by a query grouping. Absent: the group of sessions without a value |
| `title` | `Bindable<string>` | Required. The row's title |
| `subtitle` | `Bindable<string>` | Text under the title, drawn as given |
| `icon` | `Bindable<{ gui?, tui? }>` | Drawn on the row, when it has no `status` |
| `tone` | `Bindable<tone>` | Tints the row's icon |
| `status` | `Bindable<'idle' \| 'running' \| 'blocked' \| 'completed' \| 'error'>` | A run state, drawn like a session's dot |
| `time` | `Bindable<number>` | Epoch ms. Sorts the row as a session's `updatedAt`, and shows as its time |
| `badges` | `Bindable<{ text, tone?, icon? }[]>` | Up to 8 labels on the row |
| `actions` | `{ id, label, command? }[]` | Up to 8 entries in the row's menu. An action without a `command` shows disabled |
| `open` | `{ command } \| { panel } \| { screen } \| { session }` | What a click does. Absent: no target, and the desktop and web apps fold or unfold its sessions on a click |
| `children` | `Bindable<SessionQuery>` | The sessions listed under it. They leave the top level of the list |

A row lists only in a space that allows rows: the default space, and an extension space with `items: true`. A row that names another space, or a space that does not exist, does not show. A session two rows claim goes to the first one. An extension shows at most 500 rows.

The registry resolves a row's ids before any app sees it, so every app runs them the same way:

- A command (`open.command` or an action's `command`) runs the extension's own command by that id when it has one (`merge` runs `prs.merge`), else the id as written (another extension's `<extension>.<id>`, or a built-in). A command added later resolves then.
- The command gets the row's own id as its `args`, as the extension gave it (`pr-412`, not `prs/pr-412`).
- `open.panel` and `open.screen` name the extension's own panel or screen by that id when it has one, else the id as written (`<extension>/<id>`, or a built-in panel such as `todos`).

```ts
ctx.commands.register({
  id: 'merge',
  title: 'Merge the pull request',
  run: ({ args }) => ctx.logger.info(`merge ${args}`),
})
```

## Top and bottom of the sidebar

Two [slots](https://zidane.si/docs/extensions-placements.md#slots) pin rows of your own around the session list: `sidebar.top` between the search box and the list, `sidebar.bottom` between the list and the footer. They stay put while the list scrolls. Each item is one full-width row: its icon (or a spinner while `loader`), text, count at the end, tone and highlight, its `title` on hover, and a click that runs its `command` or opens its `panel`. An item that names a [component](https://zidane.si/docs/extensions-components.md) draws it in the row, with the data as its fallback. With `sessionId` an item shows only while that session is open; without it, always.

```ts
import { Slot } from 'zidane'

const inbox = ctx.add(Slot, {
  at: 'sidebar.top',
  text: 'Inbox',
  icon: { gui: 'i-ph-tray', tui: '▤' },
  count: 0,
  title: 'Open the inbox',
  panel: 'inbox',
})
ctx.add(Slot, { at: 'sidebar.bottom', text: 'Synced 2 min ago', tone: 'muted', component: { component: 'SyncState' } })

inbox.update({ count: 3, highlight: true })
```

`sidebar.footer` is the other sidebar slot: small chips inside the footer strip, beside the app's status. Use the top and bottom for rows the user reads or clicks often, the footer for a short status.

| | Terminal app | Desktop and web apps |
| - | - | - |
| `sidebar.top` | On the sessions screen, between the search box and the list | Between the search well and the list |
| `sidebar.bottom` | On the sessions screen, right under the list | Between the list and the footer strip |
| Height | At most 30% of the screen; the rest is clipped | At most 30% of the sidebar each, scrolling on its own past that |
| Empty | Takes no room | Takes no room |

## In each app

| | Terminal app | Desktop app | Web app |
| - | - | - | - |
| Extension spaces | In the sessions list's spaces, even with Spaces off. `←` `→` switch | In the space switcher of the sidebar footer, with a small badge. Needs Settings › General › Spaces on | As in the desktop app |
| Hide and reorder | Through the `spaceList` setting | Right-click a space icon: Move left, Move right, Hide from switcher, and Show for a hidden space. Kept on this device | As in the desktop app |
| Filters, sorts, groupings | The view modal (`Ctrl`+`F` on the sessions list): each row cycles with `←` `→` | The filter menu beside the search box | As in the desktop app |
| By agent and the agent filter | In the view modal | View › By agent, and the Agent filter | As in the desktop app |
| Save view as space | In the view modal. It turns Spaces on | In the filter menu, with Spaces on | In the filter menu, with Spaces on, when the server lets the page change settings |
| Decorations | `icon.tui` before the title, then the subtitle and chips on a line under the counts. `component` is not drawn | `icon.gui` and chips on the row's badge line, the subtitle under the title. `component` replaces the icon and chips, which stay its fallback | As in the desktop app |
| Rows | Among the sessions by `time`, or leading their group. `Enter` or a click opens. `Ctrl`+`Shift`+`X` shows the actions | At the top of the list under the built-in groupings, leading their group under a query grouping. A click opens, the row menu holds the actions, and the children fold | As in the desktop app |
| New facet values | The row reads them again at once | At once | At once |

Each app keeps the user's choices (grouping, sort, filters) per space.

## A full example: bots

`examples/extension-bots` gives named agents (bots) a space of their own, with tasks each bot runs on demand. It keeps bots and tasks in a store, follows it with handles, and draws a bot avatar from `ui.tsx`. Below is its sidebar part in one file, with a fixed list of bots and tasks:

- Each bot is an agent profile (`bot-<id>`), so its sessions carry its agent id from the start. The space's query and grouping read `agentId`, and a new chat lists before any facet is computed.
- The `botId` facet names the bot for queries in any space: the Bots only filter and the By bot grouping.
- The `taskId` facet reads the session's own storage. The run command writes it, then calls `refresh`, since the `created` trigger can run before the storage holds it.
- Each bot's row leads its group. Each task's row sits in its bot's group, with its runs nested under it, and a chip marks those runs in every space.

```ts title="index.ts"
import type { SessionQuery } from 'zidane'
import { defineExtension, SidebarDecoration, SidebarFilter, SidebarGroup, SidebarRow, Space } from 'zidane'

const BOTS = [
  { id: 'reviewer', name: 'Reviewer', instructions: 'You review diffs. Point at bugs first, style last.' },
  { id: 'digest', name: 'Digest', instructions: 'You summarize what changed in the repository.' },
]
const TASKS = [{ id: 'morning', botId: 'digest', title: 'Morning digest', prompt: 'Summarize what changed since yesterday.' }]

const ROBOT = { gui: 'i-ph-robot', tui: '@' }
const agentIdOf = (botId: string) => `bot-${botId}`
/** The bot id in a bot row's `args` (`bot-reviewer`), and the task id in a task row's (`task-morning`). */
const botIdOf = (args: string) => args.replace(/^bot-/, '')
const taskIdOf = (args: string) => args.replace(/^task-/, '')

export default defineExtension({
  apiVersion: 1,
  id: 'bots',
  setup(ctx) {
    for (const bot of BOTS)
      ctx.agents.register({ id: agentIdOf(bot.id), label: bot.name, description: bot.instructions, preset: { name: agentIdOf(bot.id), system: bot.instructions } })

    ctx.sessions.facets.register({
      key: 'botId',
      on: ['created', 'metadata'],
      compute: session => BOTS.find(b => agentIdOf(b.id) === session.agentId)?.id ?? null,
    })
    ctx.sessions.facets.register({
      key: 'taskId',
      on: ['created'],
      compute: async (session) => {
        const kv = ctx.storage.kv('session', session.id)
        await kv.ready()
        return kv.get<string>('taskId') ?? null
      },
    })

    const ofBots: SessionQuery = { field: 'agentId', in: BOTS.map(b => agentIdOf(b.id)) }
    ctx.add(Space, {
      id: 'bots',
      name: 'Bots',
      icon: ROBOT,
      query: ofBots,
      groupBy: { field: 'agentId', labels: Object.fromEntries(BOTS.map(b => [agentIdOf(b.id), b.name])) },
      sortBy: [{ field: 'updatedAt', dir: 'desc' }],
      newSession: { agentId: agentIdOf(BOTS[0]!.id) },
      items: true,
    })

    for (const bot of BOTS) {
      ctx.add(SidebarRow, {
        id: `bot-${bot.id}`,
        space: 'bots',
        group: agentIdOf(bot.id),
        title: bot.name,
        icon: ROBOT,
        // `chat` is this extension's own command: it runs `bots.chat` with `args` = 'bot-<id>'.
        open: { command: 'chat' },
        actions: [{ id: 'chat', label: `New chat with ${bot.name}`, command: 'chat' }],
      })
      ctx.add(SidebarDecoration, {
        id: `bot-${bot.id}`,
        where: { field: 'agentId', eq: agentIdOf(bot.id) },
        icon: ROBOT,
        subtitle: bot.name,
        component: { component: 'BotAvatar', props: { name: bot.name } },
      })
    }

    for (const task of TASKS) {
      ctx.add(SidebarRow, {
        id: `task-${task.id}`,
        space: 'bots',
        group: agentIdOf(task.botId),
        title: task.title,
        subtitle: task.prompt,
        icon: { gui: 'i-ph-flag', tui: '!' },
        actions: [{ id: 'run', label: 'Run', command: 'run-task' }],
        children: { field: 'facet.bots.taskId', eq: task.id },
      })
      ctx.add(SidebarDecoration, { id: `task-${task.id}`, where: { field: 'facet.bots.taskId', eq: task.id }, chips: [{ text: task.title, tone: 'accent' }] })
    }

    ctx.add(SidebarFilter, { id: 'bots-only', label: 'Bots only', icon: ROBOT, kind: 'toggle', query: { field: 'facet.bots.botId', exists: true } })
    ctx.add(SidebarGroup, { id: 'by-bot', label: 'By bot', groupBy: { field: 'facet.bots.botId', labels: Object.fromEntries(BOTS.map(b => [b.id, b.name])) } })

    const cwd = () => ctx.paths.projectRoot ?? ctx.paths.cwd
    ctx.commands.register({
      id: 'chat',
      title: 'New chat with a bot',
      run: async ({ args }) => {
        const bot = BOTS.find(b => b.id === botIdOf(args))
        if (bot)
          await ctx.ui.navigate({ session: await ctx.sessions.create({ cwd: cwd(), agentId: agentIdOf(bot.id) }) })
      },
    })
    ctx.commands.register({
      id: 'run-task',
      title: 'Run a bot task',
      run: async ({ args }) => {
        const task = TASKS.find(t => t.id === taskIdOf(args))
        if (!task)
          return
        const id = await ctx.sessions.create({ cwd: cwd(), agentId: agentIdOf(task.botId), title: task.title })
        await ctx.storage.kv('session', id).set('taskId', task.id)
        await ctx.sessions.facets.refresh(id)
        await ctx.sessions.send(id, task.prompt)
      },
    })
  },
})
```

A chat the user starts in the Bots space runs on the first bot, through `newSession`. The bot's name goes in as a plain string (`subtitle`, the `text` chip), so a name with a brace draws as typed. The example in the repository binds the space's `query`, `groupBy` and `newSession` to its store with `store.select`, so the space changes when the bots do. The `BotAvatar` component draws in the desktop and web apps; the terminal app shows the decoration's icon and subtitle in its place.

## Test it

The [test host](https://zidane.si/docs/extensions-testing.md) computes facets the way a host does, and lists the sidebar items as the apps get them, ids resolved:

```ts
import { expect, test } from 'bun:test'
import { createTestHost } from 'zidane/extensions/testing'
import extension from './index'

test('a bot session gets its bot, and no task', async () => {
  const host = await createTestHost({ extensions: [extension] })
  expect(await host.facets({ id: 's1', agentId: 'bot-reviewer' }, 'created')).toEqual({ 'bots.botId': 'reviewer', 'bots.taskId': null })
  expect(await host.facets({ id: 's1', agentId: 'bot-reviewer' }, 'settled')).toEqual({})
  expect(host.ui.items('space').map(entry => entry.item.id)).toEqual(['bots/bots'])
  expect(host.ui.items('sidebar-row')).toHaveLength(3)
  expect(host.ui.items('sidebar-row')[0]?.item).toMatchObject({ args: 'bot-reviewer', open: { command: 'bots.chat' } })
  await host.dispose()
})
```

`host.facets(session, trigger?)` fills the snapshot fields you leave out, runs the facets that compute on that trigger, and resolves to their values by `<extension>.<key>`. Neither facet computes on `settled`, so the second call is empty. `examples/extension-bots/index.test.ts` tests the whole extension this way.

## Pitfalls

- A facet computed on `created` can run before `ctx.sessions.create` resolves. A value that needs the new id in your own state needs a `refresh(id)` after you store it.
- A facet that throws or times out keeps its old value. A facet that answers `undefined` clears it. Return the old value yourself when you mean to keep it.
- A space's query is a view: sessions it matches stay in their own space too. Only sessions started in the space are tagged with it.
- Rows need a space that allows them. In an extension space, set `items: true`, also for your own rows.
- A plain string is drawn as given; only `{ template }` reads field paths. Put user text in a template only through `escapeTemplate`, or a lone brace in it throws.
- In the desktop and web apps, extension spaces show only with Spaces on. Put the same sessions behind a filter too if every user must reach them.
- Templates print times as epoch ms, and groupings have no date buckets. Compute a facet with the text you want to show.

## Related

- [Sessions](https://zidane.si/docs/extensions-sessions.md): `ctx.sessions.list` and the rest of `ctx.sessions`.
- [UI](https://zidane.si/docs/extensions-ui.md): `ctx.add`, handles and bound fields.
- [Session sidebar](https://zidane.si/docs/ui-sidebar.md): the UI kit's sidebar that draws these parts.
