Skip to content

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.

Search well and the five filter axes
project · active · recency
Spaces: the switcher and a space's glyph
Work
The pinned main row and a project's icon picker
zidane

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.

The whole sidebar: groups, a favorite, a run, a pending ask, environments, the footer
The footer: sign in, compacting, an update dot
Compacting...€$B8f045@e122CB~12.0k10s
Settings › Spaces over the settings host
TSX
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.

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

TSX
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. mergeSidebarSpaces puts the user's spaces first, in their order, then the extensions' by order. An extension space is read-only (readonly, extensionId). A spaceList entry with an extension space's id only places it, or hides it with hidden: true; an entry whose extension no longer adds the space is dropped. state.switcherSpaces is the switcher's list: the order and hidden spaces the user set (moveSpace, setSpaceHidden, kept per device under zg:sidebar-switcher:v1) over state.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 query also 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 own groupBy and sortBy are its defaults (resolveGrouping, resolveSort). useSessionDirectoryView(directory).query holds the result for the shown space: the space, its view, the grouping and sort that apply, and the contributions.
  • Rows. createDecorator merges the decorations that match a session: the first icon, subtitle and component, every chip up to 8. itemsForSpace and claimChildSessions place the items and the sessions their children query lists. Under a query grouping an item leads the group its group names; under a built-in grouping, items lead the list.
  • Drawing them. Pass query from useSessionDirectoryView to SessionSidebar: 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); onOpenItem replaces that.
  • Save view as space. With saveSpace, directory.saveViewAsSpace(name) turns the shown view into a user space, adds it through saveSpace and shows it. The query comes from viewQuery (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 as issues and nothing is saved. The kit offers the action whenever saveSpace is 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:

MemberWhat it enables
queryEvery host's sessions under an archive filter, with folder, host and space
setSpaceMoving a session (or a project) to a space
searchThe search well's content matches (titles always match locally)
onChangedRe-reading a host when its sessions change
viewsSharing the filter axes, the conversation last open per space and the shown space across windows and devices (else they stay in Storage)
removeResolves to { kept: true } when the session's environment stayed on disk
mainThe 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:

MemberWhat it enables
revealPathOpen in Finder, on a row of this machine and a project header
openWindowOpen in new window (the conversation with its panels, alone, or the panels alone)
environmentRetry a failed environment, and the environment's own commands (push, open a pull request)
badgesThe 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#

ExportWhat it is
SidebarSearchRowThe search well, Escape clears; the filter menu sits beside it (filter)
SidebarFilterMenuThe axes (view, status, project, activity, agent, sort) over a SidebarView, the extensions' filters, sorts and groupings (query), and Save view as space (onSaveView)
useSessionSearchTitle matches at once, content matches from ChatHost.sessions.search after a debounce
SpaceSwitcher, SpaceGlyphThe 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
MainSessionRowThe 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, useProjectIconsA folder's emoji or image icon, kept in localStorage
usePinnedProjectsFolders pinned to the top of a space's project view
useSessionActivity, useSessionBadgesA row's live line: what the run is doing, its badges, over createSessionActivity / createSessionBadges
SessionSidebarThe whole sidebar (above)
SidebarFrame, useSidebarFrameThe aside on its own: presence, width, the resize handle, the chrome inset
createSidebarLiveA row's dot, trailing slot and badge line over your hooks and stores, built once
SidebarSessionRow, SidebarGroupHeader, NewSessionButtonOne row with its menu, a group header with its project menu, the New session row
SidebarDecoration, SidebarChipWhat extensions put on a session row: the merged icon and chips, or a decoration's component with them as its fallback
SidebarRowViewAn 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, useEnvironmentCommandsA conversation's menu, for any surface that offers it (a session header)
useConfirmDeleteSession, useProjectSessionActionsDelete a session, archive or delete a project's, after a confirmation
EnvironmentChip, ProjectChipThe chips a row's badge line leads with, after the host's HostBadge (zidane-ui/chat)
useSidebarKeyboardNavThe list's ↑ / ↓ and ⌘N on your own list
SidebarFooter, SidebarSignInPrompt, SidebarUsageIndicator, SidebarCompactingIndicatorThe strip under the list and its status pieces
useCompactionProgressA 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
SidebarToggleButtonThe one toggle, named after the state (a budget-forced collapse included)
SpacesSectionSettings › 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.