Skip to content

Workspace

The desktop app's side panel as React components, tab groups, drag and drop between strips, splits and a resizable edge, over a host that keeps each scope's layout.

zidane-ui/workspace holds the desktop app's side panel: tab groups drawn side by side or stacked, a strip per group with its pills, the drag and drop that moves a tab between strips or splits a group, the dividers between groups and the panel's resizable edge. What each tab shows is yours. The panel reads a WorkspaceHost for where each scope's layout is kept and for extension panels. The previews below run on the fixture host. Pick a tab, drag one onto another strip or onto the edge of a body, or drag a divider.

Two groups: drag a tab between strips or onto a body's edge, drag the divider
An extension panel with its badge, and a tab waiting on the user

Mount it#

Wrap the panel in a WorkspaceProvider with the scope on screen and the tabs you offer, and give SidePanel a registry that turns each tab into a React node.

TSX
import 'zidane-ui/styles.css'
import { SidePanel, WorkspaceProvider } from 'zidane-ui/workspace'

const TABS = [
  { id: 'files', label: 'Files', icon: { gui: 'i-ph-folder-simple' }, builtin: true },
  { id: 'notes', label: 'Notes', icon: { gui: 'i-ph-note' }, builtin: true },
]

const registry = {
  render: tab => (tab.id === 'files' ? <FilesTab /> : <NotesTab />),
  keepAlive: () => true,
}

export function Panel({ host, sessionId }) {
  return (
    <WorkspaceProvider host={host} scope={{ key: sessionId }} tabs={TABS}>
      <SidePanel registry={registry} width={480} minWidth={320} maxWidth={900} onWidthCommit={saveWidth} controls={{ onClose: close }} />
    </WorkspaceProvider>
  )
}

AppHostProvider (zidane-ui/app) puts host.workspace in context, so a WorkspaceProvider below it can leave host out.

Your tabs#

A tab is { id, label, icon?, builtin }. Your own kinds are builtin: true and render through the registry. builtin: false is an extension panel: its content loads from the host, and its pill shows the badge the content carries.

Registry memberDoes
render(tab)The tab's body, any React node
keepAlive(tab)Keeps the body mounted behind hidden once visited (the three most recent). Leave out a body that claims something when it mounts
snap(tab)The group showing this tab snaps instead of animating, for a native view drawn over its slot
wrap(tab, body)Wraps each body, for example in your error boundary

A body reads usePanelVisible() to pause work while it is hidden. Bodies are rendered once and parked into their group's slot, so a tab moved to another group keeps its state.

Pick a host#

HostFromKeeps
createBrowserWorkspaceHost({ client })zidane-ui/adaptersLayouts in localStorage, for the web app served by zidane-server. Another tab's change shows through the storage event. With client, the server's extension panels
createFixtureWorkspaceHost(options)zidane-ui/workspace/testingMemory, for demos and tests, with extension panels
Your ownzidane-ui/workspace/headlessAnything. createStorageLayouts and createMemoryLayouts are ready-made layout stores

On zidane-server, extension panels run on the server: panels.list, panels.load, panels.action, panels.watch while a panel is shown (watched again after a reconnect) and panels.unwatch once it is hidden, with panel:changed and catalog:changed for the updates.

What a host offers#

Every member is optional. Without layouts a layout lasts as long as the page. Without panels the strip shows only your tabs.

MemberGives
layouts.get(scope), layouts.subscribeThe scope's layout: the panel open, the tab groups (PanelTree), its width
layouts.update(scope, change)Applies a change to the layout stored now, so a window never writes back a stale copy
panels.list, panels.load, panels.actionExtension panels in a folder, their content and their actions
panels.watch, panels.unwatch, panels.subscribe, panels.subscribeCatalogLive updates while a panel is shown, and their release, a changed panel, a changed catalog

useWorkspaceView() reads the strips and the open state, useWorkspaceActions() the actions (pick, close, move, split, drop, resize, cycle, attention), and useWorkspaceContent() the extension content and badges. Each re-renders only for its own part.

No tab open: + lists every panel
Floating and slid away: the edge tab brings it back

Extension panel content#

An extension panel ships data, not markup: PanelContent (zidane/chat/pure), sections of rows, prose and key / value facts, plus footer actions. PanelContentView paints it in the Artifacts panel's vocabulary: a row is a glyph, a title, a subtitle, a status, chips (lead, diff, facts) and badges, and a row with details collapses to a two-line preview. Feed it contentFor(id) from useWorkspaceContent(). onAction runs a row's or a footer's action (act(id, actionId, payload)); onOpenPath opens a row whose href is a path, and without it such rows are not links. Keep both stable: the view is memoised.

SidePanel draws the panel's own buttons on its top-right strip when you pass controls. To draw them yourself over the band instead, leave controls out, render PanelCorner (the toggle, then pop out and full screen while the panel shows) at the top-right of the area, and pass endInset={`${panelCornerWidth(n)}px`} so the strip keeps room for it. expanded makes the panel cover its parent: the aside keeps its width in the row and only its clip opens, so the content beside it never reflows. startInset keeps room at the strip's start for window controls.

Content can also carry big-number stats, a heatmap, stacked bars, rows with a meter, rows that stand for a session (onOpenSession opens them) and segmented controls in its header: pass onControl to keep the picked option as the panel's view[id] and reload it, or the controls stay hidden. variant="block" paints the same content as a card body on the project home (ProjectHome, zidane-ui/session): as tall as its content, sections split by a line. Under a PanelSessionRowProvider row={YourRow}, a row that stands for a session is drawn with your component ({ item, session, fallback }): the desktop app gives its sidebar row, so the project home's Recent sessions block looks and moves like the session list. Draw fallback for a session you don't know.

TSX
function ExtensionTab({ id }: { id: string }) {
  const { contentFor } = useWorkspaceContent()
  const { act } = useWorkspaceActions()
  const entry = contentFor(id)
  const onAction = useCallback((actionId: string, payload?: unknown) => void act(id, actionId, payload), [act, id])
  return <PanelContentView content={entry.content} loading={entry.loading} error={entry.error} onAction={onAction} />
}
PanelContentView: rows with chips and details, facts, actions
deploy: production
passed
main · 2 minutes ago
#128+42−7
failed
fix/drawer-jumpflaky
/srv/work/app/notes.md
Runner
linux-x64
Cache
warm
PanelContentView: nothing yet
Nothing yet
PanelContentView: the load failed
The CI token expired.

Keys and hand-overs#

PieceDoes
registerTabScope(el, scope), useTabScope(ref, scope)A surface says what ⌥⌘← / ⌥⌘→, ctrl+w and ⌘F do inside it (step, close, find). Kept-alive bodies register too; a hidden one never takes the keys.
tabTarget(), focusedTabScope(), panelTabScope(), hoveredTabScope()Where the keys act: the scope holding focus, else the one shown in the focus region (TAB_REGION_PROPS marks one). trackTabRegions() follows the region the user is in, keepFocusIn(region) puts focus back when what held it goes away.
usePanelGroupHandlers(enabled)splitTabRight / splitTabDown for the keybindings, offered only while the focused group has a second tab
useWorkspaceKeyCommands({ scope, session, toggle?, actions?, spaces?, closeOutside? })The panel's key commands for useKeyCommands: openArtifacts (show or hide), openTodos, panelNext / panelPrev, closeTab (the focused scope first, then closeOutside, then the visible tab), tabNext / tabPrev (where the focus is, else your spaces). Each only while it can act
revealFile(path), showWorkingTree()Hand-overs to the Files and Diffs tabs: they open the panel on the tab, which reads revealRequest / workingTreeRequest from useWorkspaceRequests() and takes them (takeRevealRequest, takeWorkingTreeRequest)
createOpenTarget({ setting, system, revealFile, openProactive })One decision for every file, folder and diff click: your "open in" setting picks the editor (FilesHost.system) or the panel, ⌘ / Ctrl flips it, a sandbox file always opens in the panel
setPendingReveal(path, at), takePendingReveal(path)A line to reveal once the Files tab opens a file handed over (a terminal link's src/app.ts:12:5)
createPanelFocusStore()What a scope's panel was opened on (a sidebar badge), for the panel's load and the built-in tabs; every open bumps seq

The horizontal budget#

LayoutBudgetProvider fits the sidebar, the chat and the panel into the window. It keeps the chat at its floor by shrinking the panel, then the sidebar, then floating the panel over the chat, then collapsing the sidebar. Read the result with useLayoutBudget(). resolveLayout (zidane-ui/workspace/headless) is the same rule as a pure function.

Per-scope layout memory#

createLayoutMemory({ storage }) (zidane-ui/workspace/headless) remembers how each conversation's window was left: the panel's tree, width and share, the Files tab (open files, tree column, filters), the terminal drawer and tab order, and the composer badges' order. A new session copies its project's last-used layout once, then owns its own, so another session's change never moves it. storage is read on each access (() => localStorage); a write that fails keeps the layout for this window. Other windows' writes arrive as storage events.

Feed it the session → scope mapping (setSessionScopes), read a scope with useScopeLayout(memory, scope) (zidane-ui/workspace), change it with updateScopeLayout(scope, patch) (a function patch reads what is stored now), and call sweepScopeLayouts(listLocalSessions) once per launch to drop records nothing can show again. A scope is { root, cwd, session?, draft? }: layoutScopeKey and layoutProjectKey are its keys.

Test your host#

zidane-ui/workspace/testing/contract is the suite every host runs, in your own vitest. Build your host over a backend that holds the layouts and panels it is given, and list the members it offers.

TypeScript
import { describeWorkspaceHostContract } from 'zidane-ui/workspace/testing/contract'

describeWorkspaceHostContract('my host', backend => makeHarness(backend), ['layouts'])
Entry pointHolds
zidane-ui/workspaceWorkspaceProvider, the hooks, SidePanel, PanelCorner, usePanelVisible, LayoutBudgetProvider, PanelContentView, the tab scopes, usePanelGroupHandlers, useWorkspaceKeyCommands
zidane-ui/workspace/headlessWorkspaceHost, the layout document and its operations, the layout stores, resolveLayout, createOpenTarget, the reveal hand-over, createPanelFocusStore, the labels
zidane-ui/workspace/testingcreateFixtureWorkspaceHost and its demo data
zidane-ui/workspace/testing/contractdescribeWorkspaceHostContract