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.
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.
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 member | Does |
|---|---|
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#
| Host | From | Keeps |
|---|---|---|
createBrowserWorkspaceHost({ client }) | zidane-ui/adapters | Layouts 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/testing | Memory, for demos and tests, with extension panels |
| Your own | zidane-ui/workspace/headless | Anything. 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.
| Member | Gives |
|---|---|
layouts.get(scope), layouts.subscribe | The 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.action | Extension panels in a folder, their content and their actions |
panels.watch, panels.unwatch, panels.subscribe, panels.subscribeCatalog | Live 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.
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.
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} />
}- Runner
- Cache
Keys and hand-overs#
| Piece | Does |
|---|---|
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.
import { describeWorkspaceHostContract } from 'zidane-ui/workspace/testing/contract'
describeWorkspaceHostContract('my host', backend => makeHarness(backend), ['layouts'])| Entry point | Holds |
|---|---|
zidane-ui/workspace | WorkspaceProvider, the hooks, SidePanel, PanelCorner, usePanelVisible, LayoutBudgetProvider, PanelContentView, the tab scopes, usePanelGroupHandlers, useWorkspaceKeyCommands |
zidane-ui/workspace/headless | WorkspaceHost, the layout document and its operations, the layout stores, resolveLayout, createOpenTarget, the reveal hand-over, createPanelFocusStore, the labels |
zidane-ui/workspace/testing | createFixtureWorkspaceHost and its demo data |
zidane-ui/workspace/testing/contract | describeWorkspaceHostContract |