Skip to content

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:

TokenItemAdds
SpaceSpaceItemA space: a named view of the sessions a query matches, with its own grouping, sort and new-session defaults
SidebarFilterSidebarFilterItemA filter the user turns on: a toggle, one option, or several
SidebarSortSidebarSortItemA sort order the user can pick
SidebarGroupSidebarGroupItemA grouping the user can pick
SidebarDecorationSidebarDecorationItemAn icon, a subtitle, chips or a component on the rows of matching sessions
SidebarRowSidebarRowItemA 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.

A first space#

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

TypeScript
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#

FieldTypeDoes
idstringRequired. Unique per extension. default is the built-in space's id and is refused
nameBindable<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
queryBindable<SessionQuery>Required. The sessions the space lists, besides those tagged with it and the Main session
groupByBindable<SessionGroupBy>How the space groups its sessions until the user picks a grouping. Absent: one flat list
sortByBindable<SessionSort[]>How the space sorts its sessions until the user picks a sort. Absent: the app's order
newSessionBindable<{ agentId?, providerKey?, modelId?, effort? }>What a session started in the space runs with. Each field left out takes the app's default
itemsbooleanLets rows list in the space, this extension's and others'. Default false
ordernumberLower 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 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.

TypeScript
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.

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:

TypeScript
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/' }] },
  ],
}
FormHolds 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#

FieldTypeHolds
idtextThe session id
titletextThe title, or the id when it has none
projectRoottextThe project root. No value for a chat with no folder
projectNametextThe folder name (last path segment) of projectRoot, or of cwd without one. Computed from those two
cwdtextThe working directory
agentIdtextThe agent profile id
statustextidle, running, blocked (waits on the user), completed or error
createdAttimeWhen it was created
updatedAttimeIts last change
runStartedAttimeWhen the run going on started
archivedbooleanArchived. false when absent
favoritebooleanMarked favorite. false when absent
isMainbooleanThe orchestration Main session. false when absent
spaceIdtextThe space it was started in. No value in the default space
providerKeytextThe provider registry key
modelIdtextThe model id
accounttextThe provider account it uses
efforttextThe thinking effort
hostIdtextThe host that serves it
repoKeytextThe repository its folder is a checkout of, such as github.com/owner/repo
environment.kindtextWhere it runs: local, worktree, docker, a sandbox provider. local without an environment
environment.statetextThe environment's state
environment.branchtextThe environment's branch
facet.<extension>.<key>anyA facet. Add .<property> steps to read into an object, a number step to index a list

A time is epoch ms.

Operators#

OperatorTakesHolds when
eqtext, number, boolean or nullThe value equals it. eq: null matches a session with no value
nethe sameThe value does not equal it. A session with no value matches, unless the value is null
ina list of values, up to 256The value equals one of them. An empty list matches no session
ninthe sameThe value equals none of them. An empty list matches every session
gt, gte, lt, ltea number, a relative time or a dateThe value is a number past the bound. A session with no number never matches
containstextThe value contains the text, ignoring case
existsbooleantrue: 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:

BoundMeans
1767225600000Epoch 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):

TypeScript
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.

FieldTypeDoes
fieldSessionFieldPathRequired. The field or facet to group on
labelsRecord<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:

TokenAt most
Space32
SidebarFilter64
SidebarSort32
SidebarGroup32
SidebarDecoration64
SidebarRow500

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:

TypeScript
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 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.

TypeScript
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.

FieldTypeDoes
keystringRequired. 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#

TriggerWhen
createdA session was created
settledA run of the session ended
metadataThe 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.

computeThe stored value
Returns a valueReplaced
Returns null or undefinedCleared
Throws, takes more than 5 s, or returns more than 8 KiB of JSON or a value that is not JSONKept. 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.

TypeScript
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 fieldTypeDoes
idstringRequired. Unique per extension
labelBindable<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
queryBindable<SessionQuery>toggle: the sessions it keeps
optionsBindable<{ 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 fieldTypeDoes
idstringRequired. Unique per extension
labelBindable<string>Required. The choice in the menu
sortByBindable<SessionSort[]>Required. 1 to 8 keys
SidebarGroup fieldTypeDoes
idstringRequired. Unique per extension
labelBindable<string>Required. The choice in the menu
groupByBindable<SessionGroupBy>Required. A grouping: { 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.

TypeScript
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' }],
})
FieldTypeDoes
idstringRequired. Unique per extension
whereBindable<SessionQuery>The sessions it applies to. Absent: every session
iconBindable<{ gui?: string, tui?: string }>An icon on the row
toneBindable<'muted' | 'subtle' | 'accent' | 'success' | 'warn' | 'danger'>Tints the icon, and the chips without their own tone
subtitleBindable<string | { template }>Text drawn under the title: a string as given, or a template
chipsBindable<({ 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
componentComponentRefDrawn on the row where the app can, with sessionId added to its props (components)

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.

TypeScript
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 },
})
FieldTypeDoes
idstringRequired. Unique per extension
spacestringRequired. The space it lists in: default, your own space id, or <extension>/<id>
groupstringThe 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
titleBindable<string>Required. The row's title
subtitleBindable<string>Text under the title, drawn as given
iconBindable<{ gui?, tui? }>Drawn on the row, when it has no status
toneBindable<tone>Tints the row's icon
statusBindable<'idle' | 'running' | 'blocked' | 'completed' | 'error'>A run state, drawn like a session's dot
timeBindable<number>Epoch ms. Sorts the row as a session's updatedAt, and shows as its time
badgesBindable<{ 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
childrenBindable<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).
TypeScript
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 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 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.

TypeScript
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 appDesktop and web apps
sidebar.topOn the sessions screen, between the search box and the listBetween the search well and the list
sidebar.bottomOn the sessions screen, right under the listBetween the list and the footer strip
HeightAt most 30% of the screen; the rest is clippedAt most 30% of the sidebar each, scrolling on its own past that
EmptyTakes no roomTakes no room

In each app#

Terminal appDesktop appWeb app
Extension spacesIn the sessions list's spaces, even with Spaces off. ← → switchIn the space switcher of the sidebar footer, with a small badge. Needs Settings › General › Spaces onAs in the desktop app
Hide and reorderThrough the spaceList settingRight-click a space icon: Move left, Move right, Hide from switcher, and Show for a hidden space. Kept on this deviceAs in the desktop app
Filters, sorts, groupingsThe view modal (CtrlF on the sessions list): each row cycles with ← →The filter menu beside the search boxAs in the desktop app
By agent and the agent filterIn the view modalView › By agent, and the Agent filterAs in the desktop app
Save view as spaceIn the view modal. It turns Spaces onIn the filter menu, with Spaces onIn the filter menu, with Spaces on, when the server lets the page change settings
Decorationsicon.tui before the title, then the subtitle and chips on a line under the counts. component is not drawnicon.gui and chips on the row's badge line, the subtitle under the title. component replaces the icon and chips, which stay its fallbackAs in the desktop app
RowsAmong the sessions by time, or leading their group. Enter or a click opens. CtrlShiftX shows the actionsAt 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 foldAs in the desktop app
New facet valuesThe row reads them again at onceAt onceAt 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.
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 computes facets the way a host does, and lists the sidebar items as the apps get them, ids resolved:

TypeScript
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.
  • Sessions: ctx.sessions.list and the rest of ctx.sessions.
  • UI: ctx.add, handles and bound fields.
  • Session sidebar: the UI kit's sidebar that draws these parts.