Session sidebar
The desktop app's whole session sidebar — every host's sessions, spaces, filters, search, live rows, menus, extension spaces and rows, and the footer — over any ChatHost.
zidane-ui/chat/sidebar holds the pieces of the desktop app's sidebar, and zidane-ui/chat/headless the model under them: a session directory that lists every host's sessions and keeps them current, the filter axes kept per space, what extensions add to the list, the rows with their groups, and the live stores a row reads (what a run is doing, its badges). The preview below is live: type in the search well, open the filter menu, switch spaces, pick an icon.
The whole sidebar#
SessionSidebar is the desktop app's sidebar, whole: the frame that animates open and shut and resizes from its edge, the top band (the orchestration Main, New session, the search well with the filter menu), the list (sections whose headers stick, the search results, one list per space that slides on a switch), the rows with their menus, and the footer you hand it. ↑ and ↓ walk the list as shown (the cursor moves in the DOM, the session opens once the keys stop), ⌘N starts a new session. The preview below is live: open a row's menu, rename one, star one, switch spaces.
import { SessionSidebar, SidebarFooter, SidebarHostProvider, createSidebarLive } from 'zidane-ui/chat/sidebar'
// Once, at module scope: where each row's live signals come from.
const live = createSidebarLive({
useRunning: (id) => useRunState(id), // your stream store
useRunElapsedSec: (id, live, ms) => useRunClock(id, live, ms),
activity: createSessionActivity({ subscribe: onStreamSignal }),
badges: createSessionBadges({ read: (ids) => readBadges(ids) }),
})
function Sidebar() {
const view = useSessionDirectoryView(directory)
return (
<SidebarHostProvider host={sidebarHost}>
<SessionSidebar
{...view}
hosts={hosts}
live={live}
onActivate={directory.select}
orchestration={false}
onNewSession={startDraft}
onNewSessionIn={startDraftIn}
onRename={directory.rename}
onSetArchived={directory.setArchived}
onSetFavorite={directory.setFavorite}
onDelete={confirmDelete} // useConfirmDeleteSession(directory.drop)
onArchiveProject={project.archive} // useProjectSessionActions(…)
onDeleteProject={project.remove}
collapsed={collapsed}
width={width}
onWidthCommit={saveWidth}
footer={<SidebarFooter onOpenSettings={openSettings} />}
/>
</SidebarHostProvider>
)
}A window with native controls over its top-left corner (macOS traffic lights) passes chromeInset ({ height, width }, CSS lengths): a drag strip that clears them. Under a LayoutBudgetProvider (zidane-ui/workspace) the width and the drag cap follow the window's budget.
Under an ExtensionsProvider (zidane-ui/extensions), the sidebar also draws the extensions' sidebar.top items between the search well and the list, and their sidebar.bottom items between the list and your footer: full-width rows (ExtensionSlot) that never scroll with the list, each block at most 30% of the sidebar's height and scrolling on its own past that. They show the open session's items (activeSessionId) and the unscoped ones, and draw nothing when there are none. SidebarFooter draws the sidebar.footer and app.statusbar chips.
The session directory#
createSessionDirectory keeps the list outside React: one read per host under the archive filter (only the newest read lands), the rows' run state reconciled with your stream store, debounced re-reads on change signals, a notification on a session whose run ended while it wasn't open, the shown space and the open session following each other, and the actions (create in the shown space, rename, an optimistic favorite, archive, move a session or a whole project to a space, delete). useSessionDirectoryView reads it in React with the shown space's filters applied.
import { createSessionDirectory, createSidebarViews, writable } from 'zidane-ui/chat/headless'
import { useSessionDirectoryView } from 'zidane-ui/chat/sidebar'
const views = createSidebarViews({ storage: localStorage, host: host.sessions?.views })
const directory = createSessionDirectory({
sessions: { ...host.sessions!, query: host.sessions!.query! },
streams, // your session stream store's run-state port
loadEvents: (id) => loadPage(id),
isRunning: (id) => isRunning(id),
servers: writable([]), // remote hosts listed next to this machine
settings: writable({ spaces: false, spaceList: [], orchestration: false }),
views,
serverOf: () => null,
})
directory.start()
function Sidebar() {
const { sessions, groupBy, sortBy, activeSessionId } = useSessionDirectoryView(directory)
// build the rows with buildHostSidebarRows(sessions, hosts, groupBy, sortBy) and render them
}Extensions in the sidebar#
Extensions add spaces, filters, sorts, groupings, row decorations and rows of their own (extension sidebar). The directory reads them from the extensions host's six sidebar stores, through sidebarContributionsOf, and evaluates them with zidane's session query (zidane/chat/pure), so every app lists the same rows for the same view.
import { createSessionDirectory, sidebarContributionsOf } from 'zidane-ui/chat/headless'
const directory = createSessionDirectory({
// …the options above
contributions: sidebarContributionsOf(extensionsHost.items),
saveSpace: async (space) => settings.update({ spaceList: [...currentSpaceList(), space] }),
})
// Or later, once the extensions host is up: directory.attachContributions(sidebarContributionsOf(host.items))- Spaces.
mergeSidebarSpacesputs the user's spaces first, in their order, then the extensions' byorder. An extension space is read-only (readonly,extensionId). AspaceListentry with an extension space's id only places it, or hides it withhidden: true; an entry whose extension no longer adds the space is dropped.state.switcherSpacesis the switcher's list: the order and hidden spaces the user set (moveSpace,setSpaceHidden, kept per device underzg:sidebar-switcher:v1) overstate.spaces. The default space never hides. - Membership. A session tagged with a space is in it, the orchestration Main session is in every space, and a space with a
queryalso lists the sessions it matches (spaceMembership). A tag naming a space that is gone reads as the default space. - The view layer. The agent filter and the contributed filters the space offers (
filtersForSpace) narrow its rows. A contributed sort or grouping the user picked replaces the built-in one, and the space's owngroupByandsortByare its defaults (resolveGrouping,resolveSort).useSessionDirectoryView(directory).queryholds the result for the shown space: the space, its view, the grouping and sort that apply, and the contributions. - Rows.
createDecoratormerges the decorations that match a session: the first icon, subtitle and component, every chip up to 8.itemsForSpaceandclaimChildSessionsplace the items and the sessions theirchildrenquery lists. Under a query grouping an item leads the group itsgroupnames; under a built-in grouping, items lead the list. - Drawing them. Pass
queryfromuseSessionDirectoryViewtoSessionSidebar: its rows then draw the decorations and the items. An item's click opens its target through the extensions host (a command, a panel, a screen, a session);onOpenItemreplaces that. - Save view as space. With
saveSpace,directory.saveViewAsSpace(name)turns the shown view into a user space, adds it throughsaveSpaceand shows it. The query comes fromviewQuery(zidane/chat/pure), the builder the terminal app uses too: the space's membership (tagged, Main, or matched by its query), then the project, activity, agent and contributed filters. The archive filter is not part of the query: the new space's view copies it, as a view setting. The project filter keeps the sessions whose folder name (projectName) equals the view's project exactly, so another folder with the same name matches too. A query too large to save comes back asissuesand nothing is saved. The kit offers the action wheneversaveSpaceis given; the desktop and web apps show it only with Spaces on.
Agent grouping and filter#
Every space has two agent choices, extensions or not: View › By agent groups by agentId (AGENT_GROUPING, headers named from the agent catalog, Default agent for sessions without one), and the Agent filter keeps one agent's sessions. query.agents lists the agents the space's sessions run; SidebarFilterMenu offers the filter when it is not empty. Names come from agentNames, else the composer host's agent catalog (useAgentNames), else the id.
The conversation last open per space#
The views store also remembers, per space, the conversation last open in it: opening a session (a click, the palette, a link) records it under its space. setActiveSpace (the switcher, stepSpace's ⌥⌘← / ⌥⌘→) reopens that conversation when it is still listed and still in the space; otherwise it does what it did before (the open session closes when it belongs elsewhere, and the app picks what to show). Deleting a session or moving it (or its project) to another space forgets it in the space it left. Read and write it with views.lastSessionOf(spaceId), views.rememberSession(spaceId, sessionId) and views.forgetSession(sessionId).
It is kept in the space's view record ({ ...view, session }), in the same Storage key and through the same views.setView, so a host that keeps the views keeps it too. Like a view, the host's record wins over this device's copy at sync(), except for a space whose conversation changed in this run.
Host capabilities#
The directory reads ChatHost.sessions. Each member it needs beyond the list is optional, and leaving one out hides what needs it:
| Member | What it enables |
|---|---|
query | Every host's sessions under an archive filter, with folder, host and space |
setSpace | Moving a session (or a project) to a space |
search | The search well's content matches (titles always match locally) |
onChanged | Re-reading a host when its sessions change |
views | Sharing the filter axes, the conversation last open per space and the shown space across windows and devices (else they stay in Storage) |
remove | Resolves to { kept: true } when the session's environment stayed on disk |
main | The orchestration Main session's id, created when missing (null while orchestration is off): useMainSession |
The desktop app implements them over IPC; createServerHost (zidane-ui/adapters) over zidane-server, with views in the server's state.json (read with sidebar.getViews and sidebar.getSpace, saved with sidebar.setView and sidebar.setSpace), so a page's createSidebarViews finds them in any browser. Both run the ChatHost contract (zidane-ui/chat/testing/contract).
What the menus ask of the platform comes from a SidebarHost (SidebarHostProvider, or AppHost.sidebar under AppHostProvider). Every member is optional; leaving one out hides its rows:
| Member | What it enables |
|---|---|
revealPath | Open in Finder, on a row of this machine and a project header |
openWindow | Open in new window (the conversation with its panels, alone, or the panels alone) |
environment | Retry a failed environment, and the environment's own commands (push, open a pull request) |
badges | The rows' badges and when they change: the source for createSessionBadges |
The desktop app implements the first three over IPC, and reads the badges through its own source. createServerSidebarHost (zidane-ui/adapters) offers environment over zidane-server (sessions.environmentActions, sessions.runEnvironmentAction with an admin token, sessions.retryEnvironment) and badges (artifacts.badges, read again on a session's artifact:recorded / artifact:changed and on catalog:changed); a browser has no Finder, and opens its own tabs. Both run the SidebarHost contract (describeSidebarHostContract, zidane-ui/chat/testing/contract); createFixtureSidebarHost (zidane-ui/chat/testing) holds one in memory.
The pieces#
| Export | What it is |
|---|---|
SidebarSearchRow | The search well, Escape clears; the filter menu sits beside it (filter) |
SidebarFilterMenu | The axes (view, status, project, activity, agent, sort) over a SidebarView, the extensions' filters, sorts and groupings (query), and Save view as space (onSaveView) |
useSessionSearch | Title matches at once, content matches from ChatHost.sessions.search after a debounce |
SpaceSwitcher, SpaceGlyph | The spaces toolbar (arrows, Home, End) and a space's icon. With onMove and onHide, a right-click on a space moves it left or right, hides it, or shows a hidden one again. An extension's space wears a badge and names its extension |
MainSessionRow | The orchestration main session's pinned row |
useMainSession(enabled, open) | Creates the Main as the orchestration setting turns on (ChatHost.sessions.main) and returns the ⌃⇧M opener, a no-op while off |
ProjectIconButton, IconPicker, useProjectIcons | A folder's emoji or image icon, kept in localStorage |
usePinnedProjects | Folders pinned to the top of a space's project view |
useSessionActivity, useSessionBadges | A row's live line: what the run is doing, its badges, over createSessionActivity / createSessionBadges |
SessionSidebar | The whole sidebar (above) |
SidebarFrame, useSidebarFrame | The aside on its own: presence, width, the resize handle, the chrome inset |
createSidebarLive | A row's dot, trailing slot and badge line over your hooks and stores, built once |
SidebarSessionRow, SidebarGroupHeader, NewSessionButton | One row with its menu, a group header with its project menu, the New session row |
SidebarDecoration, SidebarChip | What extensions put on a session row: the merged icon and chips, or a decoration's component with them as its fallback |
SidebarRowView | An extension's item among the sessions: status or icon, title, badges, time, its actions in the row menu, its sessions folded under it |
sessionMenuItems, useSessionMenuItems, useEnvironmentActions, useEnvironmentCommands | A conversation's menu, for any surface that offers it (a session header) |
useConfirmDeleteSession, useProjectSessionActions | Delete a session, archive or delete a project's, after a confirmation |
EnvironmentChip, ProjectChip | The chips a row's badge line leads with, after the host's HostBadge (zidane-ui/chat) |
useSidebarKeyboardNav | The list's ↑ / ↓ and ⌘N on your own list |
SidebarFooter, SidebarSignInPrompt, SidebarUsageIndicator, SidebarCompactingIndicator | The strip under the list and its status pieces |
useCompactionProgress | A session's compaction in progress (what SidebarCompactingIndicator shows), over createCompactionProgress({ subscribe }) fed every session's stream signals, so a compaction already under way shows when you switch to its session |
SidebarToggleButton | The one toggle, named after the state (a budget-forced collapse included) |
SpacesSection | Settings › Spaces over the settings host (zidane-ui/settings), each space's own rows from renderDefaults |
Every string comes from labels (DEFAULT_SIDEBAR_LABELS); override any of them with LabelsProvider.