Workspace
Workspace
The desktop-shell lane, with split and dock
The lane
Statuses come from .agents/slop/COMPONENTS.md.
Desktop workspace · 24 rows in .agents/slop/COMPONENTS.md · 23 shipped · 1 to build
| Component | Status | GPUix | Built on, and its limits |
|---|---|---|---|
| Resizable split panes | done | yes (measured) | @gpuixui/dock (SplitPane).Pointer events + motion; col-resize/row-resize cursors are provided. Feasibility measured in the playground (panel-resize, docking-nested): pointer-captured dividers drag and clamp, and nested levels keep independent widths. A divider must listen to both `onMouseDown` and `onMouseMove` — pointer-capture measured that the runtime arms capture only for an element subscribed to the pair (a move-only sample kept 1 move of 8 and the sibling took the rest), and split-pane-lifecycle kept all eight moves 80px outside a 6px divider's box. The divider is also a control: the same probe now measures a role="separator" tab stop that declares keyboardFocusRing at rest (a pressed divider paints no ring; Tab paints it), announces aria-valuetext (no aria-valuenow/aria-orientation in 0.11.0), steps 16px on the runtime's left/right keys, reaches the caller's bounds on home/end, and keeps the focus and the painted pane in step — so a shipped divider must be operable without a pointer (WCAG 2.1.1), not only draggable. dock-reflow measured the re-clamp signal: onLayout fires again when a sibling takes 100px, the settled payload equals the painted bounds, a stale pass arrives first and a pane whose box did not change is still reported to — so re-clamp from the width the payload carries, idempotently. window-resize-reflow measured the window channel: getWindowSize() is the box the layout fills (900×520 opened), useWindowSize() reports the same pair and lags a programmatic zoom by its 100ms sample, and zoomWindow() to 1680×923 reflows a flexible pane (780 → 1560) with onLayout reporting the new width, so a split's caller-owned max can take the window's width and the same re-clamp covers a window resize. A pane owns its own scroller: nested-scroll-lifecycle measured one wheel scrolling every scrollable ancestor on the hit path by the same delta, so a splitter beside an outer scroll root moves the page too — size the dock to the window and let the panes carry the overflow. |
| Panel groups | done | yes (measured) | @gpuixui/dock (PanelGroup, and shadcn's ResizablePanelGroup/ResizablePanel/ResizableHandle over it: one divider, one persisted PanelLayout).Composition over split panes. The live inspector-lifecycle probe measured a group of two panes in one split: the 6 px divider is the group's whole gap, both panes start at one y, the group's right edge is the inspector pane's own, and the inspector's own scroller moves without moving its pane or the preview beside it. The headless panel-group-core mounts the shipped three-pane group: each pane paints its fraction of the space between the dividers (125.6 / 345.4 / 157 of 640), the panes plus both dividers are the whole width, the collapse toggle is the group's first stop and the focused divider trades exactly 16 px with one neighbour while the third pane keeps its share. The same group is measured in a window — panel-group-lifecycle (48 checks, 0.11.0) mounts PanelGroup at 640×320 with the 20/55/25 workspace fractions: each pane paints its fraction of the 628-point pane space and its own fill, both dividers sit on the edges those panes end at, the native traversal's first stop is the sidebar header's toggle (Enter collapses it, Enter expands it), the focused divider answers the arrows with the package's 16-point step, and the freed share paints the page, not a pane. Measured gap: a collapsed pane leaves its freed share blank (113 points at this width) because the runtime treats flexGrow as a share of the free space rather than a weight over the items present; the probe derives the strip from the collapsed box it measured, so normalising the remaining shares fails its geometry and capture together. The pointer drag is not repeated here: the group's dividers share the Divider highlight layer recorded as blocked under *Resizable split panes*. |
| Collapsible sidebars | done (composed) | yes (measured) | A sidebar is composed from @gpuixui/dock's layout and @gpuixui/motion's width, on an outer clipping box. docking-nested measured a caller-owned width collapsing to 0 and reopening at the width it held, and motion-geometry the animated box's height, but a layout read cannot tell a hidden panel from one painting over the document beside it: sidebar-clip-lifecycle (47 checks, 0.11.0) measured the paint and the press. A 240pt content (a 200pt spacer then a 40pt marker) inside a tweened box with overflow: "hidden", over a document: open paints the marker red and a press runs it (marker=1); travelling (≤176) paints 0 red and the press runs the document (main=1); closed paints 0 and the press runs the document (main=2); reopened paints red and runs the marker again. The content reports 240 in every state, so the inner box never reflows. Three rules: animate the outer box and clip it (motion width plus overflow: "hidden"); guard the hidden controls yourself with tabIndex={-1} or by unmounting the pane, because a clipped control keeps its tab stop (dock-focus-lifecycle); and let the press follow the paint — a press at a clipped child's centre runs the document underneath, so hit-testing needs no separate mask. Limits: the probe drives a declared 0.32s tween on the renderer's motion clock (the runtime reads seconds, so the travelling frame is found rather than assumed), and a translucent content fill is not measured by the exact-colour crop. Evidence: apps/playground/README.md §sidebar-clip-lifecycle, .artifacts/live/sidebar-{open,mid,closed,again}.png, and the fixture's negative control (dropping overflow leaks 25 600 red pixels at 2×). |
| Tabbed panels | done | yes (measured) | @gpuixui/dock (DockLayout). Tabs + panels. The live dock-layout-lifecycle (32 checks, 0.11.0) mounts the shipped layout — one horizontal split whose children are two tab groups — and measures the split's own share arithmetic (0.35 of the 634-point pane space, the second group 6 points after the first), each group painting its active tab and not the tab it did not select, each body's own fill in the capture, and a press on a trigger it could only address by the words it paints (clickText). One contract gap is measured, not hidden: onLayoutChange is documented as "the next tree", and a group reports setActiveTab(node, node.id, tab.id) — the node it changed — so onLayoutChange={setLayout} (the package README's own call) replaces the whole tree with that group: pressing Logs reports layout=tabs:editor reports=tabs:editor and drops explorer, and a press on the tab already shown reports a node too. The component also forwards no testId to its triggers or divider and answers no key on the strip; all three are recorded for the dock owner in .agents/slop/TODO.md → Interlocks. The previous citation, docking-nested, composes the playground's own SplitPane and never mounts this package. |
| Detachable / movable tabs | done (composed) | partial | blocked by a second OS window (unfiled) — a tab moves between strips with a drag or Cmd+Shift+Arrow inside the one window, and a detached tab needs a second process. @gpuixui/dock's strips plus the app's drag state: the move half is measured by movable-tabs-lifecycle (31 checks, 0.11.0) — the source selects the tab that took its slot, the destination inserts at the split the drop names, the moved panel keeps its draft because it changed parent (which is also why the app has to restore the focus the remount dropped), Cmd+Shift+Right/Left step it without wrapping or passing the ends, a release below both strips is a miss, and a tab dragged back lands in the source. The detached half is refused by the runtime: second-window-lifecycle measured a second GpuixRenderer.init() refused (A GPUI application already exists on this thread, the refused renderer reporting isInitialized() === false) while the running window kept painting and taking presses, and portal-layer measured createPortal painting inside its target's box and outside an ancestor's clip — the route for a tab moved to another layer or pane inside that window. |
| Docking layouts | done | yes (measured) | @gpuixui/dock (DockLayout).Composition over split panes + tabs; docking-nested composes sidebar + nested split + tabbed pane in one layout, and the package's own layout is measured in a window by dock-layout-lifecycle (a split of two tab groups, its shares and its measured report gap). A split's max is the caller's number, not its container's width, so a dock passes its own bounds — dock-reflow measured that onLayout re-fires on a sibling reflow, reports the painted box, and also reports a stale pass and to unchanged panes (so the handler is idempotent). The dock itself must not scroll: nested-scroll-lifecycle measured one wheel moving every scrollable ancestor on the hit path by the same delta (a -100 wheel over an inner list moved the list *and* the page by 100 while the list had 240px of travel left), a <virtual-list> pane handing its wheel up the same way, and no wheel handler or preventDefault in the host props — so a pane cannot keep a wheel from its page. Size the dock to the window, give each pane the overflow and read each pane's own onScrollChange offset rather than an ancestor's onScroll. |
| Nested panel arrangements | done | yes | @gpuixui/dock (DockNode). Composition; docking-nested measures an outer drag moving the inner split's origin while its width holds, and an inner drag leaving the sidebar untouched. The shipped layout composes its tree and reports the node it changed: dock-layout-lifecycle mounts a split of two tab groups and measures that a tab press inside a group reports tabs:<group> rather than the tree, so a caller that assigns the report as the tree loses the sibling — the same gap the *Tabbed panels* row records, and the reason a nested workspace needs the report merged by node id until the component is fixed. |
| Workspace layouts | done | yes (measured) | @gpuixui/dock composition + app-owned persistence, and the restore half was measured by workspace-restore (a sidebar width, a caller-owned document width, a chosen document and an inspector scroll saved as a string and handed to a re-mounted workspace): the re-mount painted mount=2 source=stored at 0 / 360 / output / −120, the pane's layout pass reported the restored 360, a string that is not a layout opened the default at 160 / 240 / explorer / 0, and a stored sidebar:-5 document:9999 scroll:-30 restored clamped to 0 / 360 / 0. second-window-lifecycle measured that the binding refuses a second GpuixRenderer.init() (A GPUI application already exists on this thread, the refused renderer reporting isInitialized() === false while the running window keeps painting and taking presses), so a re-mount is the honest stand-in for a relaunch. window-resize-reflow measured the window channel a layout derives its widths from (getWindowSize() is the box the layout fills; zoomWindow() reflows and re-reports through onLayout). The move half a workspace asks for is movable-tabs-lifecycle's measurement on the tab strip beside it. |
| Persistent panel state | done | yes (measured) | @gpuixui/dock (PanelLayout).App-owned serialisation, because the runtime publishes no storage; workspace-restore measured the contract a dock owes: decode the persisted string at the app's boundary into a typed layout, open the default on a decode failure rather than a partial arrangement (a corrupt not a workspace opened mount=3 source=default at 160 / 240 / explorer / 0), clamp every restored width and scroll to the pane's own bounds (a stored sidebar:-5 document:9999 scroll:-30 restored 0 / 360 / 0), and apply the saved scroll on the first layout pass, since a pane has no scroll offset before it lays out. |
| Keyboard navigation | done (composed) | yes (measured) | Real native focus handles; the roving-focus model is the app's. A window measured the model: the playground's grid-focus-lifecycle (20 checks on 0.11.0) mounts a 3×3 grid whose remembered cell carries tabIndex: 0 and whose other eight carry -1 — Tab enters at the remembered cell rather than the first, arrows/Home/End move real native focus and the remembered cell follows, an edge clamps instead of wrapping, Tab leaves to the control after the grid in one press (nine cells are not nine stops), a press activates the cell and re-entry lands on it, and Shift+Tab from the remembered stop leaves backwards. A negative index orders the traversal rather than removing a cell: focusElement reaches the excluded gridfocus-r2c2, and Tab from it lands on grid-focus-before because the runtime treats -1 as a sort key ordered before the zero stops — so a component that programmatically focuses a non-roving cell must expect Tab to leave for the widget's first zero stop, and onFocus cannot be the re-entry channel (a traversal focuses without running it, the recorded 0.11.0 gap). The shipped List, Tabs, DropdownMenu/ContextMenu, RadioGroup, Rating, Carousel and the data package's tables and trees each carry the roving index rather than waiting for a runtime hook. |
| Command palette | done | yes (measured) | <input> + FloatingLayer + <virtual-list>. Variant merged here: Spotlight (adabraka spotlight). The shipped CommandMenu filters by typing, highlights without moving focus and runs the highlighted action (command-palette), and window-shortcut-lifecycle measured an app-level cmd-k opening it from any focus and Escape closing it. A palette restored from a saved state reads both lists at the boundary: an actions value that is not a list paints the empty state, a recents value that is not a list carries no recents, and an entry that is not an id is dropped (menu/fuzzy.ts's commandListOf/recentIdsOf). |
| Application menus | done | partial | blocked by a native menu API (#109) — the shipped @gpuixui/shell (AppMenu) draws the menu in-window (app-chrome-lifecycle); no native item callback. |
| Status bars | done | yes (measured) | @gpuixui/shell (StatusBar).div + <text>. The chrome is the app's to paint: window-chrome (14 checks, 0.11.0, both titlebar modes) measured safeArea/ime/effective all 0,0,0,0 on this host, the app channel agreeing with the renderer after a poll, a bar at top: effective.top painting there (±1) and the app's own bar as the capture's first row — so the layout starts at the window's top edge, a bar is a row like any other, and a transparent titlebar changes neither the insets nor that origin. The OS titlebar is not app-visible; an IME inset is unmeasurable on this host. The shipped bar is measured in the same probe: both groups paint in one row, and the destructive and success roles paint inside its trailing 200 points and nowhere in its leading 200 (app-chrome-lifecycle, 47 checks, 0.11.0). The strip is full width and owns its row: no radius, one top border on the frame, the chrome's muted fill, mono text-xs items and hairline separators between items. |
| Toolbars | done | yes (measured) | @gpuixui/shell (Toolbar).div + Button groups. The app paints its own chrome: window-chrome measured that the runtime reserves no titlebar — safeArea/ime/effective are 0,0,0,0 on this host, the app's own bar is the capture's first row, and a bar placed at top: effective.top paints where it says — so a toolbar owns its row, and trafficLightX/trafficLightY are window-init inputs rather than a reported safe area. A transparent titlebar changes neither the insets nor the origin, and the OS titlebar is not app-visible. The shipped strip is measured in the same probe: max: 4 keeps three actions plus an overflow trigger, right moves the window's native focus with a disabled action skipped, and the overflow menu paints and runs its tail (app-chrome-lifecycle, 47 checks, 0.11.0). The strip composes the primitives — a ghost, sm Button per action, an IconButton for the overflow, a ToggleGroup for the caller's modes and a Separator between groups — and lays out the real anatomy: the leading action group, a flexible spacer, then the trailing mode group and overflow trigger. One measured gap: escape leaves the overflow menu open because its surface never takes the focus (.agents/slop/TODO.md → Interlocks, owner: shell). |
| Native window | done | yes (behaviour) | @gpuixui/shell (Window, WindowToolbar, nativeWindowOptions; plan 722). The sidebar runs from the window's top edge under the traffic lights and paints the theme's sidebar material, the unified 52-point (compact 38) toolbar sits over the content column and paints titlebar, content and inspector are opaque; without a sidebar the toolbar reserves the lights' 78-point box. nativeWindowOptions opens darwin with titlebarTransparent, the theme's windowBackground and the lights centred on the toolbar's line, and nothing elsewhere. Measured on the renderer (src/behaviour/window.test.tsx): toolbar at (220, 0) 52 high, first sidebar row under it, content under the toolbar. Limits: fixed pane widths (compose a SplitPane for a resizable sidebar); whether the lights sit where asked and the blur shows is by-eye on macOS — the app's capture shows neither. |
| Source list | done | yes (behaviour) | @gpuixui/shell (SourceList; plan 722). 28-point rows (icon, label, count), titled sections, one tab stop; ↑/↓/Home/End move the choice and stop at the ends. No hover wash, and the chosen row is emphasized (primary) only while the list holds focus, active otherwise — measured in src/behaviour/window.test.tsx. Section folding and type-ahead are not built. |
| Breadcrumb navigation | done (composed) | yes (measured) | Breadcrumbs + navigation; the trail is the shipped Breadcrumb and routing is the app's. The live breadcrumb-navigation-lifecycle probe (48 checks, 0.11.0) measured the desktop composition: the list is flex-wrap, so an unbudgeted trail wraps (16 → 36) in a 280-wide pane instead of clipping, while a budget derived from the pane's onLayout keeps it one row (28, its trigger's height); the levels a budget hides are reachable only through a trigger the app composes — the trail's next Tab stop, a real menu whose rows are focus stops (a press chose page=Overlays, a keyboard open landed on the first hidden level and Down/Enter chose another) — and a path that fits unpaints the trigger in the same commit. The maxItems ellipsis is decorative by design. Docs packages/components/docs/breadcrumb.mdx; example breadcrumb.example.tsx; tests breadcrumb.test.tsx, breadcrumb/logic.test.ts and the probe. |
| Inspector panels | done | yes (measured) | @gpuixui/files (PropertyPanel).Property panels + panels. The live inspector-lifecycle probe measured the inspector as its own pane in a split: its 30 property rows scroll inside it (offset −200) while the pane and the preview caption keep their exact y, and the preview reports no scroll handle at all. |
| Property editors | done | yes (measured) | @gpuixui/files (PropertyPanel).Fieldset + inputs. The live inspector-lifecycle probe measured an editing field inside the scrolled inspector: typing ab keeps the platform text session in the field (getFocusedElementId stays on it) and repaints the preview, the tag key-value row and the readout from the one object; Reset size restores the width and keeps the typed tag. A number row's label is its scrub handle (NumberFieldScrubHandle): a horizontal drag steps the value, Shift is ten steps, the row's min/max clamp it and a press without travel leaves the field's typing untouched (src/behaviour/property-panel.test.tsx). |
| File trees | done | yes (measured) | @gpuixui/files (FileTree) over @gpuixui/data's tree. The surface is shadcn's AI Elements file tree: FileTree reads FileTreeFolder / FileTreeFile declarations into the FileNode model, paints one flat row list with one guide cell per ancestor and a chevron that swaps chevron-right / chevron-down (0.11.0 has no transform), and holds one selected file path. A folder press flips only its subtree and reports no selection; a file press calls onSelect once. expanded / defaultExpanded read a stored list through expandedSet, selected through storedText, and onExpandedChange reports sorted paths. The tab stop is the selected file while it paints, else the first row; useTree gives the arrows, Left/Right and type-ahead one native focus path. Measured in a window by file-tree-lifecycle (26 checks, 0.11.0): the four-row height is the tree's own bound (4 × 32 = 128, the rows scrolling to −64), a folder press opens only src while assets stays folded and the readout reports sel=0, a file press reports sel=1 keys=readme.md, the arrow keys move native focus row by row from one tab stop, Enter selects the focused file, a typed prefix moves the focus, and the panels' FileTree is mounted through its own parts. Limits: no virtualisation (compose VirtualTree), no multi-select (the reference has one selected path), and no inline rename (the reference has none; a custom FileTreeFile row is the editor slot). The runtime half is still virtual-tree-lifecycle for the windowed virtual list, which the tree composes rather than replaces. The old leading-checkbox gap is closed by removing the checkbox, so the TODO Interlocks row is resolved. |
| Log viewers | done | yes (measured) | @gpuixui/files (LogViewer).<virtual-list> + <text>; the viewer is composition. The shipped component is measured in a window — log-viewer-lifecycle (35 checks, 0.11.0) mounts LogViewer over 240 lines of 24-point rows in a 240-point viewport: rows 230–239 paint at rest with the tail on screen, an append follows it (270–279), a wheel up leaves the reader on 260–269 while two appends land (the anchor stays on 260), flipping the controlled followTail on returns to the tail in the same press (anchor 360, line 359 painted), and the level filter keeps one level's rows (350, 355, … 395) while dropping the trace row 399. The headless log-viewer measures the runtime's <virtual-list> composition: a bottom-anchored list that starts at the tail, follows appends and does not snap back after the reader scrolls up. A find bar over the log is search-virtual-document: 400 rows, 676 matches, each mounted row declares its own highlight ranges and the matchIndexOffset its row carries, so the cursor lands on the right row (active=541 in row 320, whose offset is 540). |
| Terminal surfaces | unsupported (no host element) | partial | blocked by a PTY host element (unfiled) — use the terminal runtime mode (terminal-render, terminal-input). |
| Search and replace | done | yes (measured) | @gpuixui/editor (SearchReplace) is the find/replace bar over the package's own literal scan (findRanges/replaceAll); the runtime's <text highlight> is how a document paints those matches. search-highlights measured the host's spec (query, case, whole word, explicit ranges, one activeIndex) and search-virtual-document measured it against a virtualized document, where the runtime only numbers the matches it paints: matchIndexOffset is the match count above a row — not a row index, which marks no cursor at all — an off-by-one prefix sum marks the match before the named one, and a negative offset drops the whole spec. Its replace-all rewrites every occurrence across the unmounted rows (676 → 0, and the replacement counts 676 again). The bar carries the options the host does: Aa (match case, on by default) and W (whole word, off) toggle findRanges, so the count and a replace-all agree on both; a case-insensitive scan is the escaped query as a literal pattern, not a lowered copy of the text (lowering can change a string's length and therefore its indices), and a query's regex characters stay literal. src/search/logic.test.ts (18 cases) holds the matcher, its options and the readout (matchReadout paints nothing before a query, so the bar's one named status cannot announce a search that was never made), including a whole-word match that begins inside an occurrence the word rule rejects; src/search/meta.test.ts (3 cases) holds the exported record to the toggles the bar ships and the files it names; src/behaviour/search-replace.test.tsx (10 native behaviour tests) drives the toggles, reads the replacement back through the caller's onValueChange, steps the match with F3 from either field, holds a disabled bar inert on the same key (a read-only host still receives keys, so F3 must not move the match), holds a stored document that is not text as an empty one (the bar reports No matches where it used to throw Cannot read properties of null (reading 'indexOf')), holds every name to its default when a blank or non-text one arrives, so the two fields and the four actions are never announced unnamed, holds the readout to a named `role="status"` carrying 1 / 2 or No matches, and starts a changed query or option at its first match: the active index numbers one range set, so a refined query that shrank the count from six to three reported 3 / 3 — a match the reader never stepped to — while the two native cases read 1 / 3 and then 2 / 3 after Next (a document edit keeps the index, so a single replace stays on the match that moved into the slot). The readout is the bar's only state, and role="presentation" dropped it from the accessibility tree with a nameless Label left where the count paints. Docs packages/editor/docs/search-replace.mdx; example search-replace.example.tsx. |
| Multi-pane editing layouts | done (composed) | yes (measured) | Composition over split panes and editors; editor-overlay settles that a pane is a field and a view (no caret, no offset). The live multi-pane-editor probe (31 checks, 0.11.0) measured the session a split shares: both buffers paint side by side (380, a 6px divider, the rest of the row), a press moves the one platform text session and typing reaches only the pressed pane, a +60 splitter drag reaches 440 (clamping at 460/220) and changes neither buffer, a closed pane keeps its buffer and the survivor takes the whole row, and a reopen paints the width it held and accepts a key. The divider is a control, so its press parks the session on it: SplitPane's divider is a role="separator" tab stop (split-pane-lifecycle), so the drag leaves the focus on mp-divider and the next key reaches no buffer — a press on the pane returns the session and typing continues there. A keyboard-operable divider and a session that never silently edits a file are both true; the probe asserts each. Evidence: apps/playground/README.md §multi-pane-editor, .artifacts/live/multi-pane-{split,closed,reopened}.png. |
docksplit-pane.tsxreviewpanel-group.tsxdock-layout.tsxlogic.ts
split-pane.tsx
size={312}
Inspector
orientation
"horizontal"size
312min / max
220 / 380The divider's 6px box announces its size through aria-valuetext; a focused
divider steps 16px with the arrow keys.
What composes a window
| Part of the shell | Compose it from | Page |
|---|---|---|
| The frame | Box + Stack + Separator + ScrollArea | Stack |
| Resizable split panes | SplitPane | Split pane |
| Panel groups | PanelGroup | Panel groups |
| A docked arrangement | DockLayout | Docking layouts |
| Tabbed panels | Tabs | Tabs |
| Command palette | CommandMenu | Command menu |
| Application menus | AppMenu | Application menu |
| Breadcrumb navigation | Breadcrumb | Breadcrumb |
| Toolbars | Toolbar | Toolbar |
| Status bars | StatusBar | Status bar |
| Notifications | Toast provider and viewport | Toast |
| A whole window | The Dashboard block | Blocks |
The frames themselves are on Primitives; the dock and shell pages stay in Workspace, and the rest are under Components.
What ships today
19 pages in 5 packages · generated from each package's docs folder
| Page | What it ships |
|---|---|
| Docking layouts | A named tree of split and tab groups, rendered from a schema |
| Panel groups | N panes in a row or column, each resizable |
| Split pane | Two panes and the divider between them |
| Page | What it ships |
|---|---|
| Code editor | An editable code surface with tokenized highlighting |
| Diff viewer | Two sources compared, tinted by change |
| MarkdownEditor | Markdown editing with the rendered result beside it |
| Search and replace | Find, match count, next/previous and replace-all |
| Page | What it ships |
|---|---|
| File trees | A file tree with declarative folders and one selection |
| Log viewers | A tail-anchored log list with a level filter |
| Property panels | A key/value editor for text, number, boolean and select |
| Page | What it ships |
|---|---|
| Application menu | The in-window menu bar, with keyboard traversal |
| Status bar | The bottom strip of the application chrome — left, centre and right groups, tones, hairline separators, truncation and a mono type scale |
| Toolbar | A horizontal action strip with an overflow menu |
| Page | What it ships |
|---|---|
| CropArea | A picture with a controlled crop hole and drag handles |
| DropZone | A drop target that reports files and offers a picker |
| FileUpload | A queue of files with progress, retry and remove |
| ImagePreview | An image surface with loading, error and pointer zoom |
| MediaPlayer | A clip's poster and Mux's transport, on the app's clock |
| QrCode | A QR symbol from the package's own encoder |
Persistence
Decode a stored DockNode or PanelLayout with its JSON schema; open the
default when the decode fails:
import { DockNodeJson } from "@gpuixui/dock";
import * as Schema from "effect/Schema";
const saved = Schema.encodeUnknownSync(DockNodeJson)(layout);
const restored = Schema.decodeUnknownSync(DockNodeJson)(saved);Moving a tab
A tab strip is Tabs plus the app's own drag state; Cmd+Shift+Left/Right
step it without wrapping.
- A dragged tab leaves its strip and lands at the dropped slot; the source selects the tab that took its place.
- A moved panel's draft survives the remount; restore the focus it drops.
- A release outside both strips misses.
- A detached tab is not available. The window holds one process; move a tab to another pane or layer with
createPortal.
Edit this page on GitHubapps/docs/content/docs/workspace/index.mdx