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:
| 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.
A first space#
A space for the sessions that ran in the last day on a big model, grouped by model:
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 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
querymatches 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.
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
spaceListsetting with the space's id places it, andhidden: trueleaves 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:
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. 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):
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:
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.
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, andrefresh()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.
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: { 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.
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) |
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.
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.commandor an action'scommand) runs the extension's own command by that id when it has one (mergerunsprs.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, notprs/pr-412). open.panelandopen.screenname 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 astodos).
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.
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 (CtrlF 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. CtrlShiftX 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 readagentId, and a new chat lists before any facet is computed. - The
botIdfacet names the bot for queries in any space: the Bots only filter and the By bot grouping. - The
taskIdfacet reads the session's own storage. The run command writes it, then callsrefresh, since thecreatedtrigger 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.
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:
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
createdcan run beforectx.sessions.createresolves. A value that needs the new id in your own state needs arefresh(id)after you store it. - A facet that throws or times out keeps its old value. A facet that answers
undefinedclears 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 throughescapeTemplate, 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:
ctx.sessions.listand the rest ofctx.sessions. - UI:
ctx.add, handles and bound fields. - Session sidebar: the UI kit's sidebar that draws these parts.