Workspace
Dock, tab, float, and pop out panels with dockview. Each panel keeps its kind, title, and JSON state; you save and restore the layout.
import { PanelContent } from "@/components/ui/panel"
import { Workspace } from "@/components/ui/workspace"
function Orders() {
return <PanelContent className="p-3">No open orders.</PanelContent>
}
function Positions() {
return <PanelContent className="p-3">No positions.</PanelContent>
}
const PANELS = { orders: Orders, positions: Positions }
export default function WorkspaceDemo() {
return (
<Workspace
className="h-64 rounded-md border border-border"
panels={PANELS}
watermark="No panels."
seed={(api) => {
const orders = api.addPanel({ kind: "orders", title: "Orders" })
api.addPanel({ kind: "positions", title: "Positions", position: { reference: orders, direction: "right" } })
}}
/>
)
}
Installation
pnpm dlx shadcn@latest add tradecn/ui/workspace#v1.4.13
npx shadcn@latest add tradecn/ui/workspace#v1.4.13
yarn dlx shadcn@latest add tradecn/ui/workspace#v1.4.13
bunx --bun shadcn@latest add tradecn/ui/workspace#v1.4.13
-
Install the dependencies:
pnpm add cn dockview-reactnpm install cn dockview-reactyarn add cn dockview-reactbun add cn dockview-react -
Add the shadcn components it composes:
pnpm dlx shadcn@latest add inputnpx shadcn@latest add inputyarn dlx shadcn@latest add inputbunx --bun shadcn@latest add input -
Copy the files into your project:
components/ui/ workspace.tsx import { cn } from "cn" import { DockviewReact, type DockviewApi, type DockviewReadyEvent, type DockviewTheme, type IDockviewPanelHeaderProps, type IDockviewPanelProps, type SerializedDockview } from "dockview-react" import "dockview-react/dist/styles/dockview.css" import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore, type ComponentProps, type ComponentType, type ReactNode } from "react" import { useMaybeHotkeys } from "@/hooks/use-hotkeys" import { mirrorRoot } from "@/hooks/use-popout" import { createWorkspacePanelStore, debounce, nextPanelId, parseWorkspaceLayout, WORKSPACE_LAYOUT_KIND, WORKSPACE_LAYOUT_VERSION, WORKSPACE_PERSISTENCE_BOUNDARIES, type WorkspaceDockLayout, type WorkspaceLayout, type WorkspacePanelState, type WorkspacePanelStatePatch, type WorkspacePanelStore, } from "@/lib/workspace-layout" import { Panel } from "@/components/ui/panel" // Panels that dock, tab, float, and pop out, on dockview. // // The dock owns where things sit. tradecn owns what each panel is: a kind, a title, and a little // JSON the panel asked to keep. Every docked panel is wrapped in a `Panel`, so it is the hotkey // scope `panel:<kind>` and wears the active border, and the dock decides which one is active. // // Nothing is persisted here. `onLayoutChange` hands over a layout some milliseconds after the last // change, and `defaultLayout` takes one back. // The class the registry item's `css` maps onto your shadcn tokens. No `colorScheme`: that is your app's to say. const THEME: DockviewTheme = { name: "tradecn", className: "dockview-theme-tradecn" } const PANEL_COMPONENT = "tradecn-panel" export type WorkspacePanelLocation = "grid" | "floating" | "popout" | "edge" export interface WorkspacePanelProps { id: string kind: string } export interface WorkspacePanelInfo { id: string kind: string title: string active: boolean location: WorkspacePanelLocation } export interface WorkspaceBox { x?: number y?: number width?: number height?: number } export interface WorkspaceAddPanelOptions { kind: string /** `book-1`, `book-2` by default. An id that is already open is focused, not opened twice. */ id?: string /** The kind by default. */ title?: string state?: WorkspacePanelState /** Beside another panel, or as a tab with it (`within`). Left out, it joins the active group. */ position?: { reference?: string; direction: "left" | "right" | "above" | "below" | "within" } /** Open it floating instead. */ floating?: boolean | WorkspaceBox /** Move the keyboard to it once it has rendered. Default true. */ focus?: boolean } export interface WorkspaceApi { /** Returns the panel's id. */ addPanel(options: WorkspaceAddPanelOptions): string closePanel(id: string): void /** Makes it the active panel and puts the keyboard inside it, so the keys of `panel:<kind>` answer. */ focusPanel(id: string): void /** The next panel in the dock's own order, or the one before with -1. */ focusNext(step?: 1 | -1): void panels(): WorkspacePanelInfo[] activePanel(): string | null setTitle(id: string, title: string): void getState(id: string): WorkspacePanelState | undefined setState(id: string, patch: WorkspacePanelStatePatch | ((state: WorkspacePanelState) => WorkspacePanelStatePatch)): void float(id: string, box?: WorkspaceBox): void /** A window of its own. False when the browser blocked it. Has to run inside a click or a key press. */ popout(id: string): Promise<boolean> toggleMaximize(id: string): void toLayout(): WorkspaceLayout /** Replaces everything with a stored layout. False, and an empty workspace, when it does not parse or the dock refuses it. */ load(layout: unknown): boolean clear(): void /** The dock's own API, for what this one does not cover. */ readonly dockview: DockviewApi } export interface WorkspacePanelHandle extends WorkspacePanelInfo { state: WorkspacePanelState setTitle(title: string): void /** Merges into what the panel keeps across a reload. Small and JSON. */ setState(patch: WorkspacePanelStatePatch | ((state: WorkspacePanelState) => WorkspacePanelStatePatch)): void close(): void float(box?: WorkspaceBox): void popout(): Promise<boolean> toggleMaximize(): void } interface WorkspaceContextValue { store: WorkspacePanelStore kinds: Record<string, ComponentType<WorkspacePanelProps>> api: WorkspaceApi | null watermark: ReactNode /** The panel's element, once it has one. Returns the unregister. */ registerHost(id: string, element: HTMLElement): () => void } const WorkspaceContext = createContext<WorkspaceContextValue | null>(null) const WorkspacePanelContext = createContext<WorkspacePanelHandle | null>(null) /** The panel this component is drawn in: its id, kind, title, kept state, and what the dock can do with it. */ export function useWorkspacePanel(): WorkspacePanelHandle { const panel = useContext(WorkspacePanelContext) if (!panel) throw new Error("useWorkspacePanel must be called inside a panel of a <Workspace>") return panel } function locationOf(api: IDockviewPanelProps["api"]): WorkspacePanelLocation { return api.location.type } interface Internals { api: WorkspaceApi registerHost: WorkspaceContextValue["registerHost"] dispose(): void } interface Callbacks { onLayoutChange?: (layout: WorkspaceLayout) => void onLayoutError?: (reason: unknown) => void popoutUrl?: string } function connect(dv: DockviewApi, store: WorkspacePanelStore, callbacks: () => Callbacks, delay: number): Internals { // Loading replaces every panel, and the dock reports each removal and each change as it goes. let loading = false let wantsFocus: string | null = null const hosts = new Map<string, HTMLElement>() const focusHost = (id: string) => { const target = hosts.get(id)?.firstElementChild if (target instanceof (target?.ownerDocument.defaultView?.HTMLElement ?? HTMLElement)) target.focus({ preventScroll: true }) } const toLayout = (): WorkspaceLayout => { const dockview = dv.toJSON() const records = store.snapshot() const panels = Object.fromEntries(Object.keys(dockview.panels).flatMap((id) => (records[id] ? [[id, records[id]] as const] : []))) return { version: WORKSPACE_LAYOUT_VERSION, kind: WORKSPACE_LAYOUT_KIND, dockview: dockview as unknown as WorkspaceDockLayout, panels, boundaries: WORKSPACE_PERSISTENCE_BOUNDARIES } } // What was last handed over or loaded, so an event that changed nothing, such as the dock's own // pass after a restore, is not a save. let written = "" const save = debounce(() => { const { onLayoutChange } = callbacks() if (!onLayoutChange) return // The dock may already be gone when the last change is flushed on the way out. try { const layout = toLayout() const text = JSON.stringify(layout) if (text === written) return written = text onLayoutChange(layout) } catch (reason) { callbacks().onLayoutError?.(reason) } }, delay) const changed = () => { if (!loading) save() } const info = (id: string): WorkspacePanelInfo | null => { const panel = dv.getPanel(id) const record = store.get(id) if (!panel || !record) return null return { id, kind: record.kind, title: record.title, active: dv.activePanel?.id === id, location: locationOf(panel.api) } } const api: WorkspaceApi = { addPanel({ kind, id: wanted, title, state, position, floating, focus = true }) { const id = wanted ?? nextPanelId(kind, dv.panels.map((panel) => panel.id)) if (dv.getPanel(id)) { api.focusPanel(id) return id } const record = { kind, title: title ?? kind, state: state ?? {} } store.set(id, record) if (focus) wantsFocus = id try { const base = { id, component: PANEL_COMPONENT, title: record.title } if (floating) dv.addPanel({ ...base, floating: floating === true ? true : floating }) else if (position) dv.addPanel({ ...base, position: position.reference ? { referencePanel: position.reference, direction: position.direction } : { direction: position.direction } }) else dv.addPanel(base) } catch (reason) { store.delete(id) wantsFocus = null throw reason } return id }, closePanel(id) { dv.getPanel(id)?.api.close() }, focusPanel(id) { const panel = dv.getPanel(id) if (!panel) return panel.api.setActive() focusHost(id) }, focusNext(step = 1) { if (step === 1) dv.moveToNext({ includePanel: true }) else dv.moveToPrevious({ includePanel: true }) const id = dv.activePanel?.id if (id) focusHost(id) }, panels: () => dv.panels.flatMap((panel) => info(panel.id) ?? []), activePanel: () => dv.activePanel?.id ?? null, setTitle(id, title) { store.setTitle(id, title) }, getState: (id) => store.get(id)?.state, setState(id, patch) { store.setState(id, patch) }, float(id, box) { const panel = dv.getPanel(id) if (panel) dv.addFloatingGroup(panel, box) }, async popout(id) { const panel = dv.getPanel(id) if (!panel) return false return dv.addPopoutGroup(panel, { popoutUrl: callbacks().popoutUrl }) }, toggleMaximize(id) { const panel = dv.getPanel(id) if (!panel) return if (panel.api.isMaximized()) panel.api.exitMaximized() else panel.api.maximize() }, toLayout, load(value) { const layout = parseWorkspaceLayout(value) loading = true try { if (!layout) throw new Error("not a version 1 tradecn workspace layout") store.replace(layout.panels) dv.fromJSON(layout.dockview as unknown as SerializedDockview) written = JSON.stringify(toLayout()) return true } catch (reason) { dv.clear() store.replace({}) callbacks().onLayoutError?.(reason) return false } finally { loading = false } }, clear() { dv.clear() }, dockview: dv, } // The store is where a title lives; the dock draws it in places this file does not render, such as the overflow list. const syncTitles = () => { for (const panel of dv.panels) { const title = store.get(panel.id)?.title if (title && panel.title !== title) panel.api.setTitle(title) } } const disposables = [ dv.onDidLayoutChange(changed), dv.onDidRemovePanel((panel) => { if (loading) return hosts.delete(panel.id) store.delete(panel.id) }), dv.onDidLayoutFromJSON(syncTitles), ] const unsubscribe = store.subscribe(() => { syncTitles() changed() }) return { api, registerHost(id, element) { hosts.set(id, element) if (wantsFocus === id) { wantsFocus = null focusHost(id) } return () => { if (hosts.get(id) === element) hosts.delete(id) } }, dispose() { save.flush() unsubscribe() for (const d of disposables) d.dispose() }, } } export interface WorkspaceProps extends Omit<ComponentProps<"div">, "children" | "ref"> { /** What draws each kind of panel. Keep it the same object between renders: every panel re-renders when it changes. */ panels: Record<string, ComponentType<WorkspacePanelProps>> /** A stored layout, read once as the workspace mounts. Anything that does not parse falls through to `seed`. */ defaultLayout?: unknown /** Builds the starting layout when there is none to restore. */ seed?: (api: WorkspaceApi) => void /** The layout, some milliseconds after the last change to it. Saving it is yours. */ onLayoutChange?: (layout: WorkspaceLayout) => void /** How long a burst of changes has to go quiet. Default 250. */ layoutChangeDelay?: number /** A stored layout was refused, or a layout could not be written out. */ onLayoutError?: (reason: unknown) => void onReady?: (api: WorkspaceApi) => void /** Shown while there are no panels. */ watermark?: ReactNode /** No dragging and no resizing. */ locked?: boolean disableFloating?: boolean /** The same-origin page a popout opens. The dock's default is `/popout.html`, and it has to exist. */ popoutUrl?: string } export function Workspace({ panels, defaultLayout, seed, onLayoutChange, layoutChangeDelay = 250, onLayoutError, onReady, watermark = null, locked, disableFloating, popoutUrl, className, ...props }: WorkspaceProps) { const [store] = useState(createWorkspacePanelStore) const [internals, setInternals] = useState<Internals | null>(null) const latest = useRef({ defaultLayout, seed, onLayoutChange, onLayoutError, onReady, layoutChangeDelay, popoutUrl }) useEffect(() => { latest.current = { defaultLayout, seed, onLayoutChange, onLayoutError, onReady, layoutChangeDelay, popoutUrl } }) const live = useRef<Internals | null>(null) // The last change is written out before the dock goes: a parent's cleanup runs ahead of its child's. useEffect( () => () => { live.current?.dispose() live.current = null }, [], ) const ready = useCallback( (event: DockviewReadyEvent) => { live.current?.dispose() const next = connect(event.api, store, () => latest.current, latest.current.layoutChangeDelay) live.current = next setInternals(next) const { defaultLayout: stored, seed: build, onReady: tell } = latest.current const restored = stored !== undefined && stored !== null && next.api.load(stored) if (!restored) build?.(next.api) tell?.(next.api) }, [store], ) const context = useMemo<WorkspaceContextValue>( () => ({ store, kinds: panels, api: internals?.api ?? null, watermark, registerHost: (id, element) => internals?.registerHost(id, element) ?? (() => {}) }), [store, panels, internals, watermark], ) return ( <WorkspaceContext.Provider value={context}> <div data-slot="tradecn-workspace" className={cn("relative h-full min-h-0 w-full min-w-0 lining-nums tabular-nums", className)} {...props}> <DockviewReact components={COMPONENTS} defaultTabComponent={WorkspaceTab} watermarkComponent={WorkspaceWatermark} theme={THEME} locked={locked} disableFloatingGroups={disableFloating} popoutUrl={popoutUrl} onReady={ready} /> </div> </WorkspaceContext.Provider> ) } function useWorkspaceContext(): WorkspaceContextValue { const workspace = useContext(WorkspaceContext) if (!workspace) throw new Error("a workspace panel was rendered outside a <Workspace>") return workspace } function WorkspacePanelHost({ api: panelApi }: IDockviewPanelProps) { const { store, kinds, api, registerHost } = useWorkspaceContext() const hotkeys = useMaybeHotkeys() const id = panelApi.id const getRecord = useCallback(() => store.get(id), [store, id]) const record = useSyncExternalStore(store.subscribe, getRecord, getRecord) const subscribe = useCallback( (cb: () => void) => { const disposables = [panelApi.onDidActiveChange(cb), panelApi.onDidActiveGroupChange(cb), panelApi.onDidLocationChange(cb)] return () => { for (const d of disposables) d.dispose() } }, [panelApi], ) const active = useSyncExternalStore( subscribe, () => panelApi.isActive && panelApi.isGroupActive, () => false, ) const location = useSyncExternalStore( subscribe, () => locationOf(panelApi), () => "grid" as const, ) const [element, setElement] = useState<HTMLDivElement | null>(null) useEffect(() => (element ? registerHost(id, element) : undefined), [registerHost, id, element]) // A popout is another document in the same JavaScript: it needs the keys, and the root element's // attributes, which is where a theme class lives. The dock copies the stylesheets itself. const popoutDocument = location === "popout" && element && element.ownerDocument !== document ? element.ownerDocument : null useEffect(() => (hotkeys && popoutDocument ? hotkeys.attach(popoutDocument) : undefined), [hotkeys, popoutDocument]) useEffect(() => { if (!popoutDocument) return mirrorRoot(document, popoutDocument) if (typeof MutationObserver === "undefined") return const observer = new MutationObserver(() => mirrorRoot(document, popoutDocument)) observer.observe(document.documentElement, { attributes: true }) return () => observer.disconnect() }, [popoutDocument]) const handle = useMemo<WorkspacePanelHandle | null>( () => api && record ? { id, kind: record.kind, title: record.title, state: record.state, active, location, setTitle: (title) => api.setTitle(id, title), setState: (patch) => api.setState(id, patch), close: () => api.closePanel(id), float: (box) => api.float(id, box), popout: () => api.popout(id), toggleMaximize: () => api.toggleMaximize(id), } : null, [api, record, id, active, location], ) if (!handle) return null const Component = kinds[handle.kind] return ( <WorkspacePanelContext.Provider value={handle}> <div ref={setElement} data-workspace-panel={id} className="contents"> <Panel kind={handle.kind} active={active} aria-label={handle.title} className="h-full rounded-none border-transparent"> {Component ? <Component id={id} kind={handle.kind} /> : <p className="m-auto p-4 text-center text-xs text-muted-foreground">No panel is registered for the kind “{handle.kind}”.</p>} </Panel> </div> </WorkspacePanelContext.Provider> ) } function WorkspaceTab({ api: panelApi }: IDockviewPanelHeaderProps) { const { api } = useWorkspaceContext() const subscribe = useCallback( (cb: () => void) => { const d = panelApi.onDidTitleChange(cb) return () => d.dispose() }, [panelApi], ) const title = useSyncExternalStore( subscribe, () => panelApi.title ?? panelApi.id, () => panelApi.id, ) return ( // The dock made this panel active on pointer down. The click that follows aims the keyboard at it. <div data-workspace-tab={panelApi.id} className="flex h-full items-center gap-1.5 text-xs" onClick={() => api?.focusPanel(panelApi.id)}> <span className="truncate">{title}</span> <button type="button" aria-label={`Close ${title}`} className="-mr-1 inline-flex size-4 items-center justify-center rounded-sm leading-none text-muted-foreground outline-none hover:bg-accent hover:text-accent-foreground focus-visible:ring-2 focus-visible:ring-ring/50" onClick={(event) => { event.stopPropagation() panelApi.close() }} > × </button> </div> ) } function WorkspaceWatermark() { const { watermark } = useWorkspaceContext() return <div className="flex h-full items-center justify-center text-xs text-muted-foreground">{watermark}</div> } const COMPONENTS = { [PANEL_COMPONENT]: WorkspacePanelHost }components/ui/ panel.tsx import { cn } from "cn" import { createContext, useContext, useEffect, useId, useLayoutEffect, useRef, useState, type ComponentProps, type KeyboardEvent, type ReactNode, type SyntheticEvent } from "react" import { createPortal } from "react-dom" import { Input } from "@/components/ui/input" import { HotkeyScope, useMaybeHotkeys } from "@/hooks/use-hotkeys" import type { Popout } from "@/hooks/use-popout" import { cycleLinkGroup, type LinkGroup, type LinkGroupId } from "@/lib/link-group" // The frame around one thing on a trading screen: a book, a chart, a blotter. // // A panel is a hotkey scope, so its keys and its palette actions come with it: `kind="book"` is the // scope `panel:book`, shared by every book on the screen, and the book with focus is the one that // answers. The header is a drag handle for whatever lays panels out, and the things in it that take // a click keep that click from starting a drag. interface PanelContextValue { titleId: string } const PanelContext = createContext<PanelContextValue | null>(null) // The layout around a panel listens for one of these on the header to start a drag. const keepFromDrag = { onPointerDown: stop, onMouseDown: stop, onTouchStart: stop } function stop(event: SyntheticEvent) { event.stopPropagation() } export type PanelState = "error" | "drag-target" | "active" | "inactive" | "auto" export interface PanelProps extends Omit<ComponentProps<"div">, "ref"> { /** The kind of panel, `book` or `chart`. It is the hotkey scope `panel:<kind>`, shared by every instance. */ kind: string /** Draw the active border. Left out, the panel is active while focus is inside it. */ active?: boolean /** Something is being dragged over this panel. */ dragTarget?: boolean /** Draw the error border. It outranks the other two. */ error?: boolean } export function Panel({ kind, active, dragTarget = false, error = false, className, children, ...props }: PanelProps) { const titleId = useId() const state: PanelState = error ? "error" : dragTarget ? "drag-target" : active === undefined ? "auto" : active ? "active" : "inactive" return ( <PanelContext.Provider value={{ titleId }}> <HotkeyScope role="region" aria-labelledby={props["aria-label"] ? undefined : titleId} {...props} scope={`panel:${kind}`} data-slot="tradecn-panel" data-kind={kind} data-state={state} className={cn( "flex min-h-0 min-w-0 flex-col overflow-hidden rounded-md border border-border bg-card text-card-foreground outline-none lining-nums tabular-nums", "data-[state=active]:border-panel-active data-[state=auto]:focus-within:border-panel-active", "data-[state=drag-target]:border-panel-drag-target data-[state=drag-target]:bg-panel-drag-target/10", "data-[state=error]:border-panel-error", className, )} > {children} </HotkeyScope> </PanelContext.Provider> ) } /** The strip along the top, and the handle a layout drags the panel by: aim it at `[data-panel-handle]`. */ export function PanelHeader({ className, ...props }: ComponentProps<"div">) { return <div data-panel-handle="" className={cn("flex h-7 shrink-0 items-center gap-1.5 border-b border-border bg-muted/40 px-2 text-xs select-none", className)} {...props} /> } /** Names the panel, to the eye and to a screen reader. */ export function PanelTitle({ className, ...props }: ComponentProps<"div">) { const panel = useContext(PanelContext) return <div id={panel?.titleId} className={cn("truncate font-medium", className)} {...props} /> } /** Your buttons, pushed to the far end of the header. A press on one does not start a drag. */ export function PanelActions({ className, ...props }: ComponentProps<"div">) { return <div {...keepFromDrag} className={cn("ml-auto flex items-center gap-0.5", className)} {...props} /> } export function PanelContent({ className, ...props }: ComponentProps<"div">) { return <div className={cn("min-h-0 flex-1 overflow-auto", className)} {...props} /> } export interface SymbolTagProps { value: string | null /** Enter on a draft that is not blank and not what is already showing. */ onCommit: (symbol: string) => void /** Trims and upper-cases by default. Pass your own for symbols where case matters. */ normalize?: (raw: string) => string /** False refuses the draft: the field stays open and marks itself invalid. */ validate?: (symbol: string) => boolean /** Shown while there is no symbol. */ placeholder?: string /** Controlled edit mode, for a hotkey that opens the field. */ editing?: boolean onEditingChange?: (editing: boolean) => void disabled?: boolean label?: string className?: string } const defaultNormalize = (raw: string) => raw.trim().toUpperCase() /** * The panel's symbol. Click it, or press Enter on it, to type another: Enter commits, Escape and * clicking away put it back. When the symbol changes and it was not typed here, the tag rings once * in `panel-sync`, which is how a panel says it followed its link group. */ export function SymbolTag({ value, onCommit, normalize = defaultNormalize, validate, placeholder = "symbol", editing: editingProp, onEditingChange, disabled = false, label = "Symbol", className }: SymbolTagProps) { const [ownEditing, setOwnEditing] = useState(false) const editing = (editingProp ?? ownEditing) && !disabled const [draft, setDraft] = useState("") const [invalid, setInvalid] = useState(false) const [wasEditing, setWasEditing] = useState(false) const root = useRef<HTMLSpanElement>(null) const input = useRef<HTMLInputElement>(null) const button = useRef<HTMLButtonElement>(null) // Set by the keyboard exits, so focus goes back to the tag and stays in the panel's scope. Clicking away keeps the click's focus. const refocus = useRef(false) // The field is open and has not been left yet. Some engines blur an input as it is removed, and that must not count as clicking away. const open = useRef(false) const typedHere = useRef(value) // Opened from outside (a hotkey): start from what is showing, same as a click. if (editing !== wasEditing) { setWasEditing(editing) if (editing) { setDraft(value ?? "") setInvalid(false) } } function setEditing(next: boolean) { if (editingProp === undefined) setOwnEditing(next) onEditingChange?.(next) } function leave(restoreFocus: boolean) { if (!open.current) return open.current = false refocus.current = restoreFocus setEditing(false) } function commit() { const next = normalize(draft) if (!next || next === value) return leave(true) if (validate && !validate(next)) return setInvalid(true) typedHere.current = next onCommit(next) leave(true) } function onKeyDown(event: KeyboardEvent<HTMLInputElement>) { if (event.key !== "Enter" && event.key !== "Escape") return // Claimed, so a hotkey dispatcher listening further out leaves it alone. event.preventDefault() event.stopPropagation() if (event.key === "Enter") commit() else leave(true) } useLayoutEffect(() => { if (editing) { open.current = true input.current?.focus() input.current?.select() } else if (refocus.current) { refocus.current = false button.current?.focus() } }, [editing]) useLayoutEffect(() => { if (typedHere.current === value) return typedHere.current = value const el = root.current if (!el || typeof el.animate !== "function") return if (typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches) return el.animate([{ boxShadow: "0 0 0 1px var(--panel-sync)" }, { boxShadow: "0 0 0 1px transparent" }], { duration: 900, easing: "ease-out" }) }, [value]) return ( <span ref={root} {...keepFromDrag} data-symbol-tag="" data-editing={editing} className={cn("inline-flex h-5 items-center rounded-sm font-(family-name:--tradecn-font-mono) text-xs", className)}> {editing ? ( <Input ref={input} value={draft} onChange={(event) => { setDraft(event.target.value) setInvalid(false) }} onKeyDown={onKeyDown} onBlur={() => leave(false)} aria-label={label} aria-invalid={invalid || undefined} spellCheck={false} autoComplete="off" autoCapitalize="characters" className="h-5 w-24 rounded-sm px-1 py-0 font-(family-name:--tradecn-font-mono) text-xs md:text-xs" /> ) : ( <button ref={button} type="button" disabled={disabled} aria-label={value ? `${label} ${value}, change` : `${label}, none, set`} onClick={() => setEditing(true)} className={cn("h-5 rounded-sm px-1 font-semibold outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring/50 disabled:pointer-events-none", !value && "font-normal text-muted-foreground")} > {value ?? placeholder} </button> )} </span> ) } const LINK_CLASS: Record<LinkGroupId, string> = { 1: "bg-link-1", 2: "bg-link-2", 3: "bg-link-3", 4: "bg-link-4", } export interface LinkGroupDotProps extends Omit<ComponentProps<"button">, "onChange" | "children"> { group: LinkGroup /** Click moves to the next group, Shift+click to the one before. */ onGroupChange: (group: LinkGroup) => void } /** Which link group the panel is in, as a color and a number, because color is never the only channel. */ export function LinkGroupDot({ group, onGroupChange, className, onClick, ...props }: LinkGroupDotProps) { const name = group === null ? "Not linked" : `Link group ${group}` return ( <button type="button" {...keepFromDrag} aria-label={`${name}, change`} title={name} data-link-group={group ?? "none"} className={cn( "inline-flex size-4 shrink-0 items-center justify-center rounded-sm text-xs leading-none font-semibold text-black/85 outline-none focus-visible:ring-2 focus-visible:ring-ring/50", group === null ? "border border-dashed border-muted-foreground/60" : LINK_CLASS[group], className, )} {...props} onClick={(event) => { onClick?.(event) if (!event.defaultPrevented) onGroupChange(cycleLinkGroup(group, event.shiftKey ? -1 : 1)) }} > {group} </button> ) } export interface PanelPopoutProps { /** From `usePopout()`. */ popout: Popout /** What the page shows in the panel's place while it is out. */ placeholder?: ReactNode children?: ReactNode } /** * Renders its children in the page or in the popout's window, wherever `popout` says, without * remounting them. Inside a `HotkeysProvider` the popout's document gets the same keys. */ export function PanelPopout({ popout, placeholder, children }: PanelPopoutProps) { const { slotRef, isOpen, host, window: popoutWindow } = popout const hotkeys = useMaybeHotkeys() const target = popoutWindow?.document useEffect(() => (hotkeys && target ? hotkeys.attach(target) : undefined), [hotkeys, target]) return ( <> <div ref={slotRef} className="contents" /> {isOpen ? placeholder : null} {host ? createPortal(children, host) : null} </> ) }hooks/use-link-group.tsx import { createContext, useCallback, useContext, useEffect, useRef, useState, useSyncExternalStore, type ReactNode } from "react" import { createBroadcastChannelTransport, createLinkGroupStore, cycleLinkGroup, normalizeSymbol, type LinkGroup, type LinkGroupStore, type LinkTransport } from "@/lib/link-group" // React bindings for the link-group store: a provider that owns one and keeps it connected, and a // hook that gives a panel its group and its symbol. const StoreContext = createContext<LinkGroupStore | null>(null) export interface LinkGroupProviderProps { /** Bring your own, to seed it or to share one between roots. One is created otherwise, and `transport` and `channel` are ignored. */ store?: LinkGroupStore /** How a link reaches other windows. BroadcastChannel by default; `null` keeps links inside this window. Read once. */ transport?: LinkTransport | null /** The BroadcastChannel name for the default transport. Read once. */ channel?: string children?: ReactNode } export function LinkGroupProvider({ store, transport, channel, children }: LinkGroupProviderProps) { const [own] = useState(() => createLinkGroupStore({ transport: transport === undefined ? createBroadcastChannelTransport(channel) : transport })) const value = store ?? own useEffect(() => value.connect(), [value]) return <StoreContext.Provider value={value}>{children}</StoreContext.Provider> } /** The store from the nearest provider. */ export function useLinkGroupStore(): LinkGroupStore { const store = useContext(StoreContext) if (!store) throw new Error("useLinkGroup needs a <LinkGroupProvider> above it") return store } export interface UseLinkGroupOptions { /** Controlled group, for a layout that persists it. */ group?: LinkGroup defaultGroup?: LinkGroup onGroupChange?: (group: LinkGroup) => void /** What the panel shows before anyone sets anything. */ defaultSymbol?: string | null /** The symbol this panel shows changed, by its own hand or through its group. The moment to persist. */ onSymbolChange?: (symbol: string | null) => void /** Recorded on the group with every write from here: usually the panel's id. */ source?: string } export interface LinkGroupHandle { group: LinkGroup symbol: string | null /** Sets this panel's symbol and, when it is linked, its group's. */ setSymbol: (symbol: string | null) => void setGroup: (group: LinkGroup) => void /** Unlinked, 1, 2, 3, 4, unlinked. */ cycleGroup: (step?: 1 | -1) => void } /** * A panel's group and symbol. Unlinked, the symbol is the panel's own. Linked, it is the group's: * joining a group that has a symbol adopts it, joining an empty one gives it this panel's, and * leaving keeps whatever was showing. */ export function useLinkGroup(options: UseLinkGroupOptions = {}): LinkGroupHandle { const { group: controlled, defaultGroup = null, onGroupChange, defaultSymbol = null, onSymbolChange, source = null } = options const store = useLinkGroupStore() const [ownGroup, setOwnGroup] = useState<LinkGroup>(defaultGroup) const group = controlled !== undefined ? controlled : ownGroup const [symbol, setOwnSymbol] = useState<string | null>(() => normalizeSymbol(defaultSymbol)) const linked = useSyncExternalStore( store.subscribe, () => (group === null ? null : store.get(group)), () => null, ) // Follow the group once it has something to say, a clear included. State set during render, so the // panel never paints the symbol it just left behind. if (linked && (linked.version > 0 || linked.symbol !== null) && linked.symbol !== symbol) setOwnSymbol(linked.symbol) // A group nobody has written to takes the symbol of the first panel to join it. useEffect(() => { if (group !== null) store.seed(group, symbol, source) }, [store, group, symbol, source]) const notify = useRef(onSymbolChange) useEffect(() => { notify.current = onSymbolChange }) const told = useRef(symbol) useEffect(() => { if (told.current === symbol) return told.current = symbol notify.current?.(symbol) }, [symbol]) const setSymbol = useCallback( (next: string | null) => { const value = normalizeSymbol(next) setOwnSymbol(value) if (group !== null) store.set(group, value, source) }, [store, group, source], ) const setGroup = useCallback( (next: LinkGroup) => { if (controlled === undefined) setOwnGroup(next) onGroupChange?.(next) }, [controlled, onGroupChange], ) const cycleGroup = useCallback((step: 1 | -1 = 1) => setGroup(cycleLinkGroup(group, step)), [group, setGroup]) return { group, symbol, setSymbol, setGroup, cycleGroup } }hooks/use-popout.ts import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react" // A panel in a window of its own, without losing the panel. // // The content is rendered through a portal into one host element that never changes. The host sits // in a slot in the page while the popout is closed and in the popout's body while it is open, and // moving a DOM node does not remount what React rendered into it. State, subscriptions, and context // all carry over, in both directions. // // The popout runs in the opener's JavaScript, so it is one React tree and one set of stores. What // does not come along by itself is the page's CSS, which is copied in and kept in step with the // opener's root element, where a theme class lives. export interface PopoutOptions { title?: string /** Inner size in CSS pixels. Default 640 by 420. */ width?: number height?: number left?: number top?: number /** The window name. Opening a name that is already open reuses that window. A new window each time by default. */ name?: string /** Copy the page's stylesheets, and mirror the root element's attributes, into the popout. Default true. */ copyStyles?: boolean /** Instead of `window.open`, for a shell that makes its own windows. It must hand back a same-origin window synchronously. */ openWindow?: (features: string) => Window | null onOpen?: (popout: Window) => void /** Closed from either side: `close()`, the window's own close button, or the opener going away. */ onClose?: () => void /** The browser refused the window. `open()` has to run inside a click or a key press. */ onBlocked?: () => void } export interface Popout { isOpen: boolean window: Window | null /** Render into this with `createPortal`. Null on a server. */ host: HTMLElement | null /** Put this on an empty element where the content sits while the popout is closed. */ slotRef: (node: HTMLElement | null) => void /** False when the browser blocked it. */ open: () => boolean close: () => void } function features(options: PopoutOptions): string { const parts = ["popup=yes", `width=${Math.round(options.width ?? 640)}`, `height=${Math.round(options.height ?? 420)}`] if (options.left !== undefined) parts.push(`left=${Math.round(options.left)}`) if (options.top !== undefined) parts.push(`top=${Math.round(options.top)}`) return parts.join(",") } /** Makes `to`'s root element carry the attributes of `from`'s, which is where a theme class lives. */ export function mirrorRoot(from: Document, to: Document) { for (const name of to.documentElement.getAttributeNames()) if (!from.documentElement.hasAttribute(name)) to.documentElement.removeAttribute(name) for (const name of from.documentElement.getAttributeNames()) to.documentElement.setAttribute(name, from.documentElement.getAttribute(name) ?? "") } function copyStyles(from: Document, to: Document) { for (const node of from.querySelectorAll<HTMLElement>('link[rel="stylesheet"], style')) { const copy = to.importNode(node, true) // The property is the absolute URL; the attribute may be relative, and the popout is about:blank. if (copy.tagName === "LINK") (copy as HTMLLinkElement).href = (node as HTMLLinkElement).href to.head.appendChild(copy) } to.body.className = from.body.className mirrorRoot(from, to) } // In the page the host takes no box of its own; in the popout it is the page. function place(host: HTMLElement, slot: HTMLElement | null, popout: Window | null) { host.dataset.popout = popout ? "open" : "closed" host.style.cssText = popout ? "display:block;height:100vh" : "display:contents" if (popout) popout.document.body.appendChild(host) else if (slot) slot.appendChild(host) else host.remove() } export function usePopout(options: PopoutOptions = {}): Popout { const [host] = useState<HTMLElement | null>(() => (typeof document === "undefined" ? null : document.createElement("div"))) const [slot, setSlot] = useState<HTMLElement | null>(null) const [popout, setPopout] = useState<Window | null>(null) const live = useRef<Window | null>(null) const latest = useRef(options) useEffect(() => { latest.current = options }) useLayoutEffect(() => { if (host) place(host, slot, popout) }, [host, slot, popout]) const finish = useCallback((target: Window) => { if (live.current !== target) return live.current = null setPopout(null) latest.current.onClose?.() }, []) useEffect(() => { if (!popout) return // pagehide is also a reload, which leaves an empty about:blank behind: close that too. const closed = () => { finish(popout) popout.close() } const shut = () => popout.close() popout.addEventListener("pagehide", closed) window.addEventListener("pagehide", shut) let observer: MutationObserver | null = null if (latest.current.copyStyles !== false && typeof MutationObserver !== "undefined") { observer = new MutationObserver(() => mirrorRoot(document, popout.document)) observer.observe(document.documentElement, { attributes: true }) } return () => { popout.removeEventListener("pagehide", closed) window.removeEventListener("pagehide", shut) observer?.disconnect() } }, [popout, finish]) // Unmounting the panel takes its window with it. useEffect( () => () => { live.current?.close() live.current = null }, [], ) const open = useCallback(() => { if (live.current && !live.current.closed) { live.current.focus() return true } const o = latest.current const next = o.openWindow ? o.openWindow(features(o)) : window.open("", o.name ?? "_blank", features(o)) if (!next) { o.onBlocked?.() return false } if (o.title !== undefined) next.document.title = o.title if (o.copyStyles !== false) copyStyles(document, next.document) live.current = next setPopout(next) o.onOpen?.(next) return true }, []) const close = useCallback(() => { const target = live.current if (!target) return finish(target) target.close() }, [finish]) return { isOpen: popout !== null, window: popout, host, slotRef: setSlot, open, close } }hooks/use-hotkeys.tsx import { createContext, useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore, type ComponentProps, type ReactNode } from "react" import { createHotkeyRegistry, type HandlerScope, type HotkeyBinding, type HotkeyEntry, type HotkeyHandler, type HotkeyRegistry, type HotkeyScopeName, type HotkeyTarget } from "@/lib/hotkeys" // React bindings for the hotkey registry. The registry does the work and knows nothing about React; // this file puts one in context, attaches its listener, marks scopes in the DOM, and keeps a // component's handler attached for as long as the component is mounted. const RegistryContext = createContext<HotkeyRegistry | null>(null) const ScopeContext = createContext<HandlerScope | null>(null) export interface HotkeysProviderProps { /** Bring your own to declare bindings and load overrides before the first render. One is created otherwise. */ registry?: HotkeyRegistry /** Declared on mount, removed on unmount. Keep the array's identity stable: a module constant. */ bindings?: readonly HotkeyBinding[] /** Where to listen. `document` by default; a popout window's document for a second one. */ target?: HotkeyTarget children?: ReactNode } export function HotkeysProvider({ registry, bindings, target, children }: HotkeysProviderProps) { const [own] = useState(() => createHotkeyRegistry()) const value = registry ?? own useEffect(() => value.attach(target), [value, target]) useEffect(() => { if (!bindings) return for (const binding of bindings) value.register(binding) return () => { for (const binding of bindings) value.unregister(binding.id) } }, [value, bindings]) return <RegistryContext.Provider value={value}>{children}</RegistryContext.Provider> } /** The registry from the nearest provider. */ export function useHotkeys(): HotkeyRegistry { const registry = useContext(RegistryContext) if (!registry) throw new Error("useHotkeys needs a <HotkeysProvider> above it") return registry } /** The same, or null without a provider, for components that treat hotkeys as optional. */ export function useMaybeHotkeys(): HotkeyRegistry | null { return useContext(RegistryContext) } export interface UseHotkeyOptions { /** False detaches the handler; the binding stays declared and listed. */ enabled?: boolean } /** * Attach a handler to a declared binding while this component is mounted. Inside a `HotkeyScope` of * the binding's own scope, the handler answers only for events from that scope element, so two * instances of a panel each get their own keys. */ export function useHotkey(id: string, handler: HotkeyHandler, options: UseHotkeyOptions = {}): void { const registry = useHotkeys() const scope = useContext(ScopeContext) const enabled = options.enabled !== false const latest = useRef(handler) useEffect(() => { latest.current = handler }) useEffect(() => { if (!enabled) return return registry.bind(id, (event) => latest.current(event), scope) }, [registry, id, enabled, scope]) } /** Every binding with its keys in force, for a help overlay or a settings screen. Re-renders on declare, remap, and reset, and when a handler attaches or detaches. */ export function useHotkeyList(): readonly HotkeyEntry[] { const registry = useHotkeys() return useSyncExternalStore(registry.subscribe, registry.list, registry.list) } /** The chord typed so far (`"g"`), or null. For a "g …" hint in a status bar. */ export function usePendingChord(): string | null { const registry = useHotkeys() return useSyncExternalStore(registry.subscribe, registry.pending, registry.pending) } export interface HotkeyScopeProps extends Omit<ComponentProps<"div">, "ref"> { scope: HotkeyScopeName } /** * Marks a subtree as a scope. Keys pressed with focus inside it reach this scope's bindings first. * It takes focus on click (`tabIndex={-1}`) so that clicking a panel is enough to aim the keyboard at it. */ export function HotkeyScope({ scope, children, ...props }: HotkeyScopeProps) { const [element, setElement] = useState<HTMLDivElement | null>(null) const value = useMemo<HandlerScope>(() => ({ scope, element: () => element }), [scope, element]) return ( <ScopeContext.Provider value={value}> <div tabIndex={-1} {...props} ref={setElement} data-hotkey-scope={scope}> {children} </div> </ScopeContext.Provider> ) }lib/workspace-layout.ts // What a workspace saves, and what it leaves out on purpose. // // A layout is the dock arrangement plus one small record per panel: its kind, its title, and the // JSON the panel asked to keep, such as a symbol, a link group, or a view setting. It is meant to be // reused: saved as a template, handed to a colleague, restored next quarter. That only works when // nothing tied to a session is in it, so the boundaries travel in the payload and say, as data, what // the writer put in and what it kept out. // // Storage is the consumer's. Nothing here touches localStorage. A stored layout comes back through // `parseWorkspaceLayout`, which takes nothing on trust. export const WORKSPACE_LAYOUT_KIND = "tradecn-workspace" export const WORKSPACE_LAYOUT_VERSION = 1 export const WORKSPACE_PERSISTENCE_BOUNDARIES = { /** In the layout. `onLayoutChange` hands over a fresh one when any of these changes. */ autosave: ["dock-arrangement", "floating-and-popout-positions", "panel-kinds", "panel-titles", "panel-state"], /** Belongs to one workspace and is not in the layout. Store it beside the layout, keyed by panel id. */ workspaceScoped: ["grid-column-state", "selection", "scroll-position", "ticket-drafts"], /** Belongs to the person, whichever workspace is open. */ globalScoped: ["hotkey-remaps", "palette-recents", "theme", "instrument-conventions"], /** Stored by nobody. It comes from the server every time. */ excluded: ["market-data", "orders-and-status", "positions", "feed-health", "link-group-symbols"], } as const export type WorkspaceBoundary = keyof typeof WORKSPACE_PERSISTENCE_BOUNDARIES export type WorkspacePersistenceBoundaries = { readonly [K in WorkspaceBoundary]: readonly string[] } const BOUNDARY_KEYS = Object.keys(WORKSPACE_PERSISTENCE_BOUNDARIES) as WorkspaceBoundary[] export type WorkspaceJson = string | number | boolean | null | WorkspaceJson[] | { [key: string]: WorkspaceJson } /** What a panel keeps across a reload. Small and JSON: a symbol, a link group, a view setting. */ export type WorkspacePanelState = { [key: string]: WorkspaceJson } export interface WorkspacePanelRecord { /** Which of the workspace's `panels` draws it, and the hotkey scope `panel:<kind>`. */ kind: string title: string state: WorkspacePanelState } export interface WorkspaceLayout { version: typeof WORKSPACE_LAYOUT_VERSION kind: typeof WORKSPACE_LAYOUT_KIND /** The dock's own serialized form. Opaque here: read `panels` instead. */ dockview: WorkspaceDockLayout /** One record per panel id in `dockview`. */ panels: Record<string, WorkspacePanelRecord> /** What the writer included and what it kept out, at the time it wrote. */ boundaries: WorkspacePersistenceBoundaries } /** As much of the dock's format as this file checks. */ export interface WorkspaceDockLayout { grid: { root: unknown; [key: string]: unknown } panels: Record<string, unknown> [key: string]: unknown } function isObject(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null && !Array.isArray(value) } /** The value as JSON would store it: functions and undefined are gone, and anything that cannot be stored is an empty state. */ export function toPanelState(value: unknown): WorkspacePanelState { if (!isObject(value)) return {} try { const stored: unknown = JSON.parse(JSON.stringify(value)) return isObject(stored) ? (stored as WorkspacePanelState) : {} } catch { return {} } } function readBoundaries(value: unknown): WorkspacePersistenceBoundaries { if (!isObject(value)) return WORKSPACE_PERSISTENCE_BOUNDARIES const out = {} as Record<WorkspaceBoundary, readonly string[]> for (const key of BOUNDARY_KEYS) { const list = value[key] if (!Array.isArray(list) || !list.every((entry) => typeof entry === "string")) return WORKSPACE_PERSISTENCE_BOUNDARIES out[key] = [...(list as string[])] } return out } /** * A stored layout, checked. Takes the object or the JSON text of one, and returns null for anything * that is not a version 1 tradecn workspace whose every docked panel has a record saying what kind * it is. A record with no panel in the dock is dropped. What comes back is a copy. */ export function parseWorkspaceLayout(value: unknown): WorkspaceLayout | null { let raw = value if (typeof raw === "string") { try { raw = JSON.parse(raw) } catch { return null } } if (!isObject(raw) || raw.kind !== WORKSPACE_LAYOUT_KIND || raw.version !== WORKSPACE_LAYOUT_VERSION) return null const dock = raw.dockview if (!isObject(dock) || !isObject(dock.grid) || !("root" in dock.grid) || !isObject(dock.panels) || !isObject(raw.panels)) return null const panels: Record<string, WorkspacePanelRecord> = {} for (const id of Object.keys(dock.panels)) { const record = raw.panels[id] if (!isObject(record) || typeof record.kind !== "string" || !record.kind) return null panels[id] = { kind: record.kind, title: typeof record.title === "string" && record.title ? record.title : record.kind, state: toPanelState(record.state) } } let dockview: WorkspaceDockLayout try { dockview = JSON.parse(JSON.stringify(dock)) as WorkspaceDockLayout } catch { return null } return { version: WORKSPACE_LAYOUT_VERSION, kind: WORKSPACE_LAYOUT_KIND, dockview, panels, boundaries: readBoundaries(raw.boundaries) } } /** The kinds a layout asks for that are not among `kinds`. The workspace draws a placeholder for each; this is for saying so up front. */ export function unknownPanelKinds(layout: WorkspaceLayout, kinds: Iterable<string>): string[] { const known = new Set(kinds) return [...new Set(Object.values(layout.panels).map((record) => record.kind))].filter((kind) => !known.has(kind)).sort() } /** `book-1`, then `book-2`: the lowest number not taken. */ export function nextPanelId(kind: string, taken: Iterable<string>): string { const used = new Set(taken) let n = 1 while (used.has(`${kind}-${n}`)) n += 1 return `${kind}-${n}` } export type WorkspacePanelStatePatch = { [key: string]: WorkspaceJson | undefined } export interface WorkspacePanelStore { /** The same object until this panel changes, so it is safe as a `useSyncExternalStore` snapshot. */ get(id: string): WorkspacePanelRecord | undefined /** Adds the panel, or replaces its record. */ set(id: string, record: WorkspacePanelRecord): void setTitle(id: string, title: string): void /** Merges a patch into the panel's state. A key set to undefined is removed. A patch that changes nothing tells nobody. */ setState(id: string, patch: WorkspacePanelStatePatch | ((state: WorkspacePanelState) => WorkspacePanelStatePatch)): void delete(id: string): void /** Every record at once, for a layout being loaded. */ replace(records: Record<string, WorkspacePanelRecord>): void ids(): string[] /** Copies, for writing out. */ snapshot(): Record<string, WorkspacePanelRecord> subscribe(cb: () => void): () => void } export function createWorkspacePanelStore(): WorkspacePanelStore { let records = new Map<string, WorkspacePanelRecord>() const listeners = new Set<() => void>() const emit = () => { for (const cb of [...listeners]) cb() } const clean = (record: WorkspacePanelRecord): WorkspacePanelRecord => ({ kind: record.kind, title: record.title || record.kind, state: toPanelState(record.state) }) return { get: (id) => records.get(id), set(id, record) { records.set(id, clean(record)) emit() }, setTitle(id, title) { const record = records.get(id) if (!record || !title || record.title === title) return records.set(id, { ...record, title }) emit() }, setState(id, patch) { const record = records.get(id) if (!record) return const next = toPanelState({ ...record.state, ...(typeof patch === "function" ? patch(record.state) : patch) }) if (JSON.stringify(next) === JSON.stringify(record.state)) return records.set(id, { ...record, state: next }) emit() }, delete(id) { if (records.delete(id)) emit() }, replace(next) { records = new Map(Object.entries(next).map(([id, record]) => [id, clean(record)])) emit() }, ids: () => [...records.keys()], snapshot: () => Object.fromEntries([...records].map(([id, record]) => [id, { ...record, state: toPanelState(record.state) }])), subscribe(cb) { listeners.add(cb) return () => { listeners.delete(cb) } }, } } export interface Debounced { (): void /** Run now if a run is waiting. */ flush(): void cancel(): void pending(): boolean } /** One call `ms` after the last of a burst: a sash drag reports a change on every pointer move. */ export function debounce(fn: () => void, ms: number): Debounced { let timer: ReturnType<typeof setTimeout> | null = null const run = () => { timer = null fn() } const debounced = (() => { if (timer) clearTimeout(timer) timer = setTimeout(run, ms) }) as Debounced debounced.flush = () => { if (!timer) return clearTimeout(timer) run() } debounced.cancel = () => { if (timer) clearTimeout(timer) timer = null } debounced.pending = () => timer !== null return debounced }lib/link-group.ts // Link groups: panels that wear the same color show the same symbol. // // The store holds one symbol per group and knows nothing about React. A write bumps the group's // version and, while the store is connected, goes out on a transport so other windows follow. The // transport is an interface with two methods: BroadcastChannel here, a desktop shell's own events // in an app that has them. // // Windows come and go, so a store that connects says hello and the others answer with what they // hold: a window opened late shows the group's symbol instead of nothing. When two windows write at // once, the higher version wins, and on a tie the higher window id, so both sides pick the same one. export type LinkGroupId = 1 | 2 | 3 | 4 /** `null` is unlinked. */ export type LinkGroup = LinkGroupId | null export const LINK_GROUPS: readonly LinkGroupId[] = [1, 2, 3, 4] export interface LinkGroupState { group: LinkGroupId symbol: string | null /** Who wrote it: whatever the writer passed to `set`, usually a panel's id. */ source: string | null /** Counts writes. Between windows the higher version wins. 0 is never written, or only seeded. */ version: number } export type LinkMessage = | { kind: "tradecn-link"; type: "set"; origin: string; group: LinkGroupId; symbol: string | null; source: string | null; version: number } | { kind: "tradecn-link"; type: "hello"; origin: string } export interface LinkTransport { post(message: LinkMessage): void /** Deliver what other windows post. Returns the unsubscribe. */ subscribe(cb: (message: LinkMessage) => void): () => void } export interface LinkGroupStore { /** This store's id on the transport. */ readonly id: string /** The same object until the group changes, so it is safe as a `useSyncExternalStore` snapshot. */ get(group: LinkGroupId): LinkGroupState /** Blank is null. Writing the symbol a group already holds changes nothing. */ set(group: LinkGroupId, symbol: string | null, source?: string | null): void /** * Give a group that nobody has written to a first symbol, from a panel that joined it holding one. * A seed stays in this window and any real write replaces it, here or from another window, so a * symbol restored from storage never overrules the one people are looking at. */ seed(group: LinkGroupId, symbol: string | null, source?: string | null): void subscribe(cb: () => void): () => void /** Start listening to the transport and ask the other windows what they hold. Returns the disconnect. */ connect(): () => void } export interface LinkGroupStoreOptions { /** Omit or pass null to keep links inside this window. */ transport?: LinkTransport | null id?: string } /** Unlinked, then 1 to 4, then unlinked again. `step` -1 goes the other way. */ export function cycleLinkGroup(group: LinkGroup, step: 1 | -1 = 1): LinkGroup { const order: LinkGroup[] = [null, ...LINK_GROUPS] const at = Math.max(0, order.indexOf(group)) return order[(at + step + order.length) % order.length]! } export function normalizeSymbol(symbol: string | null | undefined): string | null { const next = typeof symbol === "string" ? symbol.trim() : "" return next ? next : null } function isGroupId(value: unknown): value is LinkGroupId { return LINK_GROUPS.includes(value as LinkGroupId) } /** Messages arrive from outside the page; take nothing on trust. */ export function isLinkMessage(value: unknown): value is LinkMessage { if (!value || typeof value !== "object") return false const m = value as Record<string, unknown> if (m.kind !== "tradecn-link" || typeof m.origin !== "string" || !m.origin) return false if (m.type === "hello") return true if (m.type !== "set") return false return isGroupId(m.group) && (m.symbol === null || typeof m.symbol === "string") && (m.source === null || typeof m.source === "string") && typeof m.version === "number" && Number.isFinite(m.version) } function makeId(): string { if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") return crypto.randomUUID() return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}` } export function createLinkGroupStore(options: LinkGroupStoreOptions = {}): LinkGroupStore { const id = options.id ?? makeId() const transport = options.transport ?? null const states = new Map<LinkGroupId, LinkGroupState>(LINK_GROUPS.map((group) => [group, { group, symbol: null, source: null, version: 0 }])) // Which store wrote each group's current state: the tie-break when two versions are equal. const origins = new Map<LinkGroupId, string>() const listeners = new Set<() => void>() let connections = 0 let disconnect: (() => void) | null = null function emit() { for (const cb of listeners) cb() } function announce(state: LinkGroupState) { if (connections > 0) transport?.post({ kind: "tradecn-link", type: "set", origin: origins.get(state.group) ?? id, group: state.group, symbol: state.symbol, source: state.source, version: state.version }) } function receive(message: LinkMessage) { if (!isLinkMessage(message) || message.origin === id) return if (message.type === "hello") { for (const state of states.values()) if (state.version > 0) announce(state) return } const current = states.get(message.group)! if (message.version < current.version) return if (message.version === current.version && (message.symbol === current.symbol || message.origin <= (origins.get(message.group) ?? ""))) return origins.set(message.group, message.origin) states.set(message.group, { group: message.group, symbol: normalizeSymbol(message.symbol), source: message.source, version: message.version }) emit() } return { id, get: (group) => states.get(group)!, set(group, symbol, source = null) { const current = states.get(group) if (!current) return const next = normalizeSymbol(symbol) // Writing a seed's own symbol still counts: it is the first real write, and other windows have not heard it. if (next === current.symbol && (current.version > 0 || next === null)) return const state = { group, symbol: next, source, version: current.version + 1 } origins.set(group, id) states.set(group, state) emit() announce(state) }, seed(group, symbol, source = null) { const current = states.get(group) const next = normalizeSymbol(symbol) if (!current || current.version > 0 || current.symbol !== null || next === null) return states.set(group, { group, symbol: next, source, version: 0 }) emit() }, subscribe(cb) { listeners.add(cb) return () => listeners.delete(cb) }, connect() { if (!transport) return () => {} if (connections++ === 0) { disconnect = transport.subscribe(receive) transport.post({ kind: "tradecn-link", type: "hello", origin: id }) } let done = false return () => { if (done) return done = true if (--connections > 0) return disconnect?.() disconnect = null } }, } } export interface CallbackTransportOptions { /** Send a message to the other windows: a desktop shell's event, a socket, a worker's port. */ send: (message: LinkMessage) => void /** * Deliver what the other windows send to `deliver`, and return the way to stop. Called once, for the * first subscriber; its return is called when the last subscriber leaves. What arrives is checked * before it reaches a store, so hand over whatever the channel carries. */ receive: (deliver: (message: unknown) => void) => () => void } /** * A transport over two functions, for a shell whose windows are separate JavaScript contexts and * cannot share a `BroadcastChannel`: each window runs its own provider, and the shell's own events * carry the links between them. */ export function createCallbackTransport({ send, receive }: CallbackTransportOptions): LinkTransport { const listeners = new Set<(message: LinkMessage) => void>() let stop: (() => void) | null = null return { post(message) { send(message) }, subscribe(cb) { listeners.add(cb) if (!stop) { stop = receive((message) => { if (isLinkMessage(message)) for (const l of [...listeners]) l(message) }) } return () => { listeners.delete(cb) if (listeners.size || !stop) return stop() stop = null } }, } } /** Every same-origin window and tab that opens this channel name shares links. The channel opens with the first subscriber and closes with the last. */ export function createBroadcastChannelTransport(name = "tradecn-link"): LinkTransport { const listeners = new Set<(message: LinkMessage) => void>() let channel: BroadcastChannel | null = null const onMessage = (event: MessageEvent<unknown>) => { if (isLinkMessage(event.data)) for (const cb of listeners) cb(event.data) } return { post(message) { channel?.postMessage(message) }, subscribe(cb) { listeners.add(cb) if (!channel && typeof BroadcastChannel !== "undefined") { channel = new BroadcastChannel(name) channel.addEventListener("message", onMessage) } return () => { listeners.delete(cb) if (listeners.size || !channel) return channel.removeEventListener("message", onMessage) channel.close() channel = null } }, } }lib/hotkeys.ts // A hotkey registry: bindings declared as data, one keydown listener, scopes read from the DOM. // // A binding is an id, keys, a scope, and a description. It is declared once, so the same list feeds // the dispatcher, a command palette's shortcut column, and a help overlay, and a remap changes all // three. Handlers attach separately and come and go with the components that own them, which is how // two instances of one panel share a binding: the handler whose scope element holds the event wins. // // Scope is where the key came from. The chain runs from the event target outward through every // `[data-hotkey-scope]` ancestor, then `editing`, then `global`, and the innermost match wins. // Typing is protected: inside an input only `editing` bindings run. Menus and listboxes own every // key while they have focus. A dialog is a wall: only scopes declared inside it are active, so a // single-key binding cannot fire under a modal confirmation. export type Platform = "mac" | "other" /** `global` runs anywhere outside inputs. `editing` runs anywhere, inputs included. `panel:<id>` runs inside `[data-hotkey-scope="panel:<id>"]`. */ export type HotkeyScopeName = "global" | "editing" | `panel:${string}` export type HotkeyHandler = (event: KeyboardEvent) => void export interface HotkeyBinding { id: string /** `"mod+k"`, `"shift+/"`, `"?"`, or a chord of steps separated by spaces, `"g h"`. `mod` is ⌘ on a Mac and Ctrl elsewhere. `""` is unbound. */ keys: string scope: HotkeyScopeName description: string /** A heading for overlays and palette rows. */ group?: string /** Checked at keydown. False skips this binding and lets an outer scope answer. */ when?: () => boolean /** Fire again while the key is held. Off by default: a held key should not cancel thirty orders. */ repeat?: boolean /** On by default. */ preventDefault?: boolean } export interface HotkeyEntry extends HotkeyBinding { /** The keys in force: the override when there is one, else the binding's own. */ keys: string defaultKeys: string remapped: boolean } export interface HotkeyConflict { /** `duplicate`: same keys where both can fire. `prefix`: one is the start of the other's chord. `shadow`: a panel binding hides a global or editing one while focus is in that panel. */ kind: "duplicate" | "prefix" | "shadow" /** The contested keys, normalized; for a prefix, the shorter of the two. */ keys: string ids: [string, string] } /** Binding id to keys. What a consumer persists. */ export type HotkeyOverrides = Record<string, string> export type HotkeyTarget = Pick<EventTarget, "addEventListener" | "removeEventListener"> /** Limits a handler to events from inside one scope element, when the binding lives in that scope. */ export interface HandlerScope { scope: string element: () => Element | null } export interface HotkeyRegistryOptions { /** How long a chord waits for its next key. Default 1000. */ chordTimeoutMs?: number platform?: Platform } export interface HotkeyRegistry { readonly platform: Platform /** Declare a binding, or replace the one with the same id. Returns the conflicts it takes part in. */ register(binding: HotkeyBinding, handler?: HotkeyHandler): HotkeyConflict[] unregister(id: string): void /** Attach a handler to a declared (or not yet declared) binding. Returns the detach. A fenced handler can settle a conflict, so attaching and detaching wake `subscribe` as a declaration does. */ bind(id: string, handler: HotkeyHandler, within?: HandlerScope | null): () => void /** Override a binding's keys; `""` unbinds it. Notifies `onChange`. Returns the conflicts the new keys take part in. */ remap(id: string, keys: string): HotkeyConflict[] /** Drop one override, or all of them. Notifies `onChange`. */ reset(id?: string): void /** Replace every override, from the consumer's persistence. Ids not declared yet apply when they are. Does not notify `onChange`. */ load(overrides: HotkeyOverrides): void overrides(): HotkeyOverrides /** Overrides changed through `remap` or `reset`: the moment to persist. */ onChange(cb: (overrides: HotkeyOverrides) => void): () => void /** Every binding with its keys in force. Stable array reference until a binding, an override, or a handler changes. */ list(): readonly HotkeyEntry[] /** Every conflict the declarations leave standing once their handlers are read, against the document as it is now. A fresh array each call. */ conflicts(): HotkeyConflict[] /** Wakes on any change to `list()` or `pending()`, and when a handler comes or goes. */ subscribe(cb: () => void): () => void /** Listen for keydown on a target, `document` by default. Attaching the same target twice adds one listener. */ attach(target?: HotkeyTarget): () => void /** The chord typed so far, normalized (`"g"`), or null. */ pending(): string | null /** Run one event through the dispatcher. `attach` calls this; it is public for events forwarded from elsewhere. True when the event was consumed. */ handle(event: KeyboardEvent): boolean } interface Step { ctrl: boolean alt: boolean shift: boolean meta: boolean key: string } const KEY_ALIASES: Record<string, string> = { " ": "space", spacebar: "space", esc: "escape", return: "enter", del: "delete", ins: "insert", arrowup: "up", arrowdown: "down", arrowleft: "left", arrowright: "right", pgup: "pageup", pgdn: "pagedown", plus: "+", } const CODE_KEYS: Record<string, string> = { Slash: "/", Backslash: "\\", Period: ".", Comma: ",", Semicolon: ";", Quote: "'", BracketLeft: "[", BracketRight: "]", Minus: "-", Equal: "=", Backquote: "`", } const IGNORED_KEYS = new Set(["Shift", "Control", "Alt", "Meta", "AltGraph", "CapsLock", "OS", "Dead", "Process", "Unidentified"]) const NON_TEXT_INPUTS = new Set(["button", "checkbox", "color", "file", "image", "radio", "range", "reset", "submit"]) const SCOPE_ATTR = "data-hotkey-scope" const WALLS = '[role="dialog"], [role="alertdialog"]' const MAC_LABELS = { ctrl: "⌃", alt: "⌥", shift: "⇧", meta: "⌘" } const OTHER_LABELS = { ctrl: "Ctrl", alt: "Alt", shift: "Shift", meta: "Win" } const KEY_LABELS: Record<string, string> = { enter: "↵", escape: "Esc", space: "Space", tab: "Tab", backspace: "⌫", delete: "Del", insert: "Ins", up: "↑", down: "↓", left: "←", right: "→", pageup: "PgUp", pagedown: "PgDn", home: "Home", end: "End", } export function detectPlatform(): Platform { if (typeof navigator === "undefined") return "other" const nav = navigator as Navigator & { userAgentData?: { platform?: string } } return /mac|iphone|ipad|ipod/i.test(nav.userAgentData?.platform ?? nav.platform ?? "") ? "mac" : "other" } function parseStep(part: string, platform: Platform): Step { const step: Step = { ctrl: false, alt: false, shift: false, meta: false, key: "" } for (const token of part.toLowerCase().split("+")) { if (token === "mod") step[platform === "mac" ? "meta" : "ctrl"] = true else if (token === "ctrl" || token === "control") step.ctrl = true else if (token === "alt" || token === "option" || token === "opt") step.alt = true else if (token === "shift") step.shift = true else if (token === "meta" || token === "cmd" || token === "command" || token === "win" || token === "super") step.meta = true else if (step.key) throw new Error(`hotkeys: "${part}" names two keys; separate the steps of a chord with a space`) else step.key = KEY_ALIASES[token] ?? token } if (!step.key) throw new Error(`hotkeys: "${part}" has no key (write the + key as "plus")`) return step } function parseKeys(keys: string, platform: Platform): Step[] { return keys.trim().split(/\s+/).filter(Boolean).map((part) => parseStep(part, platform)) } function stepToString(step: Step): string { return [step.ctrl && "ctrl", step.alt && "alt", step.shift && "shift", step.meta && "meta", step.key].filter(Boolean).join("+") } /** Canonical form for comparing and storing: `mod` resolved, aliases folded, modifiers in one order. Throws on keys that do not parse. */ export function normalizeKeys(keys: string, platform: Platform = detectPlatform()): string { return parseKeys(keys, platform).map(stepToString).join(" ") } /** Key caps for shadcn's `Kbd`, one array per chord step: `"mod+k"` is `[["⌘", "K"]]` on a Mac and `[["Ctrl", "K"]]` elsewhere. */ export function formatKeys(keys: string, platform: Platform = detectPlatform()): string[][] { const labels = platform === "mac" ? MAC_LABELS : OTHER_LABELS return parseKeys(keys, platform).map((step) => { const caps: string[] = [] if (step.ctrl) caps.push(labels.ctrl) if (step.alt) caps.push(labels.alt) if (step.shift) caps.push(labels.shift) if (step.meta) caps.push(labels.meta) caps.push(KEY_LABELS[step.key] ?? (/^(?:[a-z]|f\d{1,2})$/.test(step.key) ? step.key.toUpperCase() : step.key)) return caps }) } const isSymbol = (key: string) => key.length === 1 && !/[a-z0-9]/.test(key) function codeKey(code: string): string | null { if (/^Key[A-Z]$/.test(code)) return code.slice(3).toLowerCase() if (/^Digit\d$/.test(code)) return code.slice(5) return CODE_KEYS[code] ?? null } function matchesStep(step: Step, event: KeyboardEvent): boolean { if (event.ctrlKey !== step.ctrl || event.altKey !== step.alt || event.metaKey !== step.meta) return false const pressed = event.key.toLowerCase() const key = KEY_ALIASES[pressed] ?? pressed // "?" already says shift on the layouts that need it and nothing on the ones that do not. if (key === step.key) return isSymbol(key) && !step.shift ? true : event.shiftKey === step.shift // A modifier changed the character: shift+1 arrives as "!", option+k as "˚". Read the physical key, // unless the modifier is AltGr, which is how half of Europe types "@". if ((event.shiftKey || event.altKey) && isSymbol(key) && !event.getModifierState?.("AltGraph")) return event.shiftKey === step.shift && codeKey(event.code) === step.key return false } /** True for a single-step `keys` this event satisfies. For a component that must answer a binding itself, behind a wall the dispatcher will not cross. */ export function matchesKeys(event: KeyboardEvent, keys: string, platform: Platform = detectPlatform()): boolean { const steps = parseKeys(keys, platform) return steps.length === 1 && matchesStep(steps[0]!, event) } /** The step a keydown spells, in the form `remap` takes, or null for a bare modifier. For a "press the new shortcut" field. */ export function keysFromEvent(event: KeyboardEvent): string | null { if (IGNORED_KEYS.has(event.key)) return null const pressed = event.key.toLowerCase() let key = KEY_ALIASES[pressed] ?? pressed let shift = event.shiftKey if (isSymbol(key)) { // option+k arrives as "˚": store the physical key. A shifted symbol such as "?" already carries its shift. const physical = event.altKey && !event.getModifierState?.("AltGraph") ? codeKey(event.code) : null if (physical) key = physical else shift = false } return stepToString({ ctrl: event.ctrlKey, alt: event.altKey, shift, meta: event.metaKey, key }) } // Duck-typed so a target from a popout window's realm still counts. function asElement(target: EventTarget | null): Element | null { const el = target as Element | null return el && typeof el.closest === "function" ? el : null } /** Text inputs, textareas, selects, and anything contenteditable. A checkbox is not typing. */ export function isEditableTarget(target: EventTarget | null): boolean { const el = asElement(target) if (!el) return false if (el.tagName === "INPUT") return !NON_TEXT_INPUTS.has((el.getAttribute("type") ?? "text").toLowerCase()) if (el.tagName === "TEXTAREA" || el.tagName === "SELECT") return true return el.closest('[contenteditable]:not([contenteditable="false"]), [role="textbox"]') !== null } /** Inside a menu, menubar, or listbox, which own arrows, letters, Enter, and Escape while focused. */ export function isMenuTarget(target: EventTarget | null): boolean { return asElement(target)?.closest('[role="menu"], [role="menubar"], [role="listbox"]') != null } /** The scopes active for an event target, innermost first. Inside a dialog only the scopes declared within it count. */ export function scopeChain(target: EventTarget | null): string[] { const chain: string[] = [] let el = asElement(target) const wall = el?.closest(WALLS) ?? null while (el) { const hit = el.closest(`[${SCOPE_ATTR}]`) if (!hit || (wall && !wall.contains(hit))) break const name = hit.getAttribute(SCOPE_ATTR) if (name && !chain.includes(name)) chain.push(name) el = hit.parentElement } if (!wall) for (const name of ["editing", "global"]) if (!chain.includes(name)) chain.push(name) return chain } interface Rec { binding: HotkeyBinding steps: Step[] sequence: string[] handler?: HotkeyHandler } interface Bound { handler: HotkeyHandler within: HandlerScope | null } const isAmbient = (scope: string) => scope === "global" || scope === "editing" function sameBinding(a: HotkeyBinding, b: HotkeyBinding): boolean { return a.keys === b.keys && a.scope === b.scope && a.description === b.description && a.group === b.group && a.when === b.when && a.repeat === b.repeat && a.preventDefault === b.preventDefault } function conflictBetween(a: Rec, b: Rec): HotkeyConflict | null { if (!a.sequence.length || !b.sequence.length) return null const [short, long] = a.sequence.length <= b.sequence.length ? [a, b] : [b, a] if (!short.sequence.every((step, i) => long.sequence[i] === step)) return null const sa = a.binding.scope const sb = b.binding.scope const together = sa === sb || (isAmbient(sa) && isAmbient(sb)) if (!together && !isAmbient(sa) && !isAmbient(sb)) return null const kind = !together ? "shadow" : short.sequence.length === long.sequence.length ? "duplicate" : "prefix" return { kind, keys: short.sequence.join(" "), ids: [a.binding.id, b.binding.id] } } export function createHotkeyRegistry(options: HotkeyRegistryOptions = {}): HotkeyRegistry { const platform = options.platform ?? detectPlatform() const chordTimeoutMs = options.chordTimeoutMs ?? 1000 const recs = new Map<string, Rec>() const overrides = new Map<string, string>() const bound = new Map<string, Bound[]>() const listeners = new Set<() => void>() const changeListeners = new Set<(overrides: HotkeyOverrides) => void>() const attached = new Map<HotkeyTarget, { count: number; listener: (event: Event) => void }>() let snapshot: readonly HotkeyEntry[] | null = null // Chord state: how many steps of which bindings have matched so far. let progress = new Map<string, number>() let pendingSteps: string[] = [] let timer: ReturnType<typeof setTimeout> | null = null function emit() { snapshot = null for (const cb of listeners) cb() } function notifyChange() { const current = Object.fromEntries(overrides) for (const cb of changeListeners) cb(current) } function clearPending(): boolean { if (timer) clearTimeout(timer) timer = null if (!pendingSteps.length) return false progress = new Map() pendingSteps = [] return true } function build(binding: HotkeyBinding, handler?: HotkeyHandler): Rec { const defaults = parseKeys(binding.keys, platform) let steps = defaults const override = overrides.get(binding.id) if (override !== undefined) { // A stale override from storage must not take the app down: fall back to the default. try { steps = parseKeys(override, platform) } catch { steps = defaults } } return { binding, steps, sequence: steps.map(stepToString), handler } } function rebuild(id?: string) { for (const [key, rec] of recs) if (id === undefined || key === id) recs.set(key, build(rec.binding, rec.handler)) clearPending() emit() } // The elements a binding's handlers are fenced to, in the binding's own scope; null when any handler runs // unfenced, empty when nothing is bound. Mirrors what handlerFor lets through. function fencesOf(rec: Rec): (Element | null)[] | null { if (rec.handler) return null const out: (Element | null)[] = [] for (const { within } of bound.get(rec.binding.id) ?? []) { if (!within || within.scope !== rec.binding.scope) return null out.push(within.element()) } return out } // Two bindings whose every handler is fenced to boxes that do not hold one another cannot meet on one key, // which is how an order ticket and an RFQ ticket on one desk share mod+enter. Declarations with no handler, // or a handler that runs anywhere, are compared as declared. function apart(a: Rec, b: Rec): boolean { const fa = fencesOf(a) const fb = fencesOf(b) if (!fa?.length || !fb?.length) return false for (const ea of fa) for (const eb of fb) if (ea && eb && (ea === eb || ea.contains(eb) || eb.contains(ea))) return false return true } function conflictsFor(id: string): HotkeyConflict[] { const rec = recs.get(id) if (!rec) return [] const out: HotkeyConflict[] = [] for (const other of recs.values()) { const c = other === rec ? null : conflictBetween(rec, other) if (c && !apart(rec, other)) out.push(c) } return out } function handlerFor(rec: Rec, el: Element | null): HotkeyHandler | null { const list = bound.get(rec.binding.id) ?? [] for (let i = list.length - 1; i >= 0; i--) { const { handler, within } = list[i]! if (!within || within.scope !== rec.binding.scope) return handler const scopeEl = within.element() if (el && scopeEl?.contains(el)) return handler } return rec.handler ?? null } function attempt(event: KeyboardEvent, chain: string[], editable: boolean, el: Element | null): boolean { let complete: { rank: number; rec: Rec; handler: HotkeyHandler } | null = null let advanceRank = Infinity let advanceStep = "" const advanced = new Map<string, number>() for (const rec of recs.values()) { const at = pendingSteps.length ? progress.get(rec.binding.id) : 0 const step = at === undefined ? undefined : rec.steps[at] if (at === undefined || !step || !matchesStep(step, event)) continue const rank = chain.indexOf(rec.binding.scope) if (rank < 0 || (editable && rec.binding.scope !== "editing")) continue if (event.repeat && !rec.binding.repeat) continue if (rec.binding.when && !rec.binding.when()) continue const handler = handlerFor(rec, el) if (!handler) continue if (at === rec.steps.length - 1) { if (!complete || rank < complete.rank) complete = { rank, rec, handler } } else { advanced.set(rec.binding.id, at + 1) if (rank < advanceRank) { advanceRank = rank advanceStep = stepToString(step) } } } // The innermost scope decides. A chord that started closer to the target outranks a finished binding further out. if (complete && complete.rank <= advanceRank) { if (clearPending()) emit() if (complete.rec.binding.preventDefault !== false) event.preventDefault() complete.handler(event) return true } if (advanced.size) { if (timer) clearTimeout(timer) progress = advanced pendingSteps = [...pendingSteps, advanceStep] timer = setTimeout(() => { timer = null if (clearPending()) emit() }, chordTimeoutMs) event.preventDefault() emit() return true } return false } function handle(event: KeyboardEvent): boolean { if (event.defaultPrevented || event.isComposing || IGNORED_KEYS.has(event.key)) return false const target = event.target if (isMenuTarget(target)) { if (clearPending()) emit() return false } if (pendingSteps.length && event.key === "Escape") { clearPending() event.preventDefault() emit() return true } const chain = scopeChain(target) const editable = isEditableTarget(target) const el = asElement(target) if (attempt(event, chain, editable, el)) return true // Not a continuation of the chord in flight: drop the chord and read the key on its own. if (clearPending()) { emit() return attempt(event, chain, editable, el) } return false } return { platform, register(binding, handler) { if (!binding.id) throw new Error("hotkeys: a binding needs an id") const previous = recs.get(binding.id) // Declaring the same thing again (a remount, a hot reload) is not a change. if (previous && !handler && sameBinding(previous.binding, binding)) return conflictsFor(binding.id) recs.set(binding.id, build(binding, handler ?? previous?.handler)) clearPending() emit() return conflictsFor(binding.id) }, unregister(id) { if (!recs.delete(id)) return clearPending() emit() }, bind(id, handler, within = null) { const entry: Bound = { handler, within } const list = bound.get(id) if (list) list.push(entry) else bound.set(id, [entry]) // A fenced handler can settle a conflict, so a settings screen wants to hear about it. emit() return () => { const current = bound.get(id) if (!current) return const i = current.indexOf(entry) if (i < 0) return current.splice(i, 1) if (!current.length) bound.delete(id) emit() } }, remap(id, keys) { const rec = recs.get(id) if (!rec) throw new Error(`hotkeys: cannot remap "${id}", no such binding`) const next = normalizeKeys(keys, platform) if (next === normalizeKeys(rec.binding.keys, platform)) overrides.delete(id) else overrides.set(id, next) rebuild(id) notifyChange() return conflictsFor(id) }, reset(id) { if (id === undefined) overrides.clear() else overrides.delete(id) rebuild(id) notifyChange() }, load(next) { overrides.clear() for (const [id, keys] of Object.entries(next)) overrides.set(id, keys) rebuild() }, overrides: () => Object.fromEntries(overrides), onChange(cb) { changeListeners.add(cb) return () => changeListeners.delete(cb) }, list() { return (snapshot ??= [...recs.values()].map((rec) => { const keys = rec.sequence.join(" ") const defaultKeys = normalizeKeys(rec.binding.keys, platform) return { ...rec.binding, keys, defaultKeys, remapped: keys !== defaultKeys } })) }, conflicts() { const all = [...recs.values()] const out: HotkeyConflict[] = [] for (let i = 0; i < all.length; i++) { for (let j = i + 1; j < all.length; j++) { const c = conflictBetween(all[i]!, all[j]!) if (c && !apart(all[i]!, all[j]!)) out.push(c) } } return out }, subscribe(cb) { listeners.add(cb) return () => listeners.delete(cb) }, attach(target) { const t = target ?? (typeof document === "undefined" ? null : document) if (!t) return () => {} let entry = attached.get(t) if (!entry) { entry = { count: 0, listener: (event) => void handle(event as KeyboardEvent) } t.addEventListener("keydown", entry.listener) attached.set(t, entry) } entry.count++ let done = false return () => { if (done) return done = true entry.count-- if (entry.count > 0) return t.removeEventListener("keydown", entry.listener) attached.delete(t) } }, pending: () => (pendingSteps.length ? pendingSteps.join(" ") : null), handle, } } -
Add the tokens to your stylesheet:
:root { --link-1: oklch(0.53 0.2 25); --link-2: oklch(0.5 0.15 250); --link-3: oklch(0.5 0.13 150); --link-4: oklch(0.52 0.12 85); --panel-active: var(--color-primary); --panel-drag-target: var(--color-ring); --panel-error: var(--color-destructive); --panel-sync: oklch(0.5 0.12 220); --tradecn-font-accessible: 'Atkinson Hyperlegible Next', 'Atkinson Hyperlegible', ui-sans-serif, system-ui, sans-serif; --tradecn-font-accessible-mono: 'Atkinson Hyperlegible Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; --tradecn-font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } .dark { --link-1: oklch(0.7 0.19 25); --link-2: oklch(0.7 0.14 250); --link-3: oklch(0.74 0.14 150); --link-4: oklch(0.85 0.15 90); --panel-active: var(--color-primary); --panel-drag-target: var(--color-ring); --panel-error: var(--color-destructive); --panel-sync: oklch(0.78 0.12 220); --tradecn-font-accessible: 'Atkinson Hyperlegible Next', 'Atkinson Hyperlegible', ui-sans-serif, system-ui, sans-serif; --tradecn-font-accessible-mono: 'Atkinson Hyperlegible Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; --tradecn-font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } -
Append this to your stylesheet:
@layer components { .dockview-theme-tradecn { --dv-group-view-background-color: var(--background); --dv-tabs-and-actions-container-background-color: var(--muted); --dv-tabs-and-actions-container-font-size: 0.75rem; --dv-tabs-and-actions-container-height: 1.75rem; --dv-activegroup-visiblepanel-tab-background-color: var(--background); --dv-activegroup-hiddenpanel-tab-background-color: var(--muted); --dv-inactivegroup-visiblepanel-tab-background-color: var(--background); --dv-inactivegroup-hiddenpanel-tab-background-color: var(--muted); --dv-activegroup-visiblepanel-tab-color: var(--foreground); --dv-activegroup-hiddenpanel-tab-color: var(--muted-foreground); --dv-inactivegroup-visiblepanel-tab-color: var(--muted-foreground); --dv-inactivegroup-hiddenpanel-tab-color: var(--muted-foreground); --dv-tab-divider-color: var(--border); --dv-tab-font-size: inherit; --dv-tab-margin: 0; --dv-tab-border-radius: 0px; --dv-tab-close-icon-size: inherit; --dv-tabs-container-scrollbar-color: var(--border); --dv-separator-border: var(--border); --dv-border-radius: 0px; --dv-spacing-padding: 0px; --dv-sash-color: transparent; --dv-sash-border-radius: 0px; --dv-active-sash-color: var(--ring); --dv-active-sash-transition-duration: 0.1s; --dv-active-sash-transition-delay: 0.3s; --dv-drag-over-background-color: color-mix(in oklab, var(--ring) 20%, transparent); --dv-drag-over-border-color: var(--ring); --dv-drag-over-border: 1px solid var(--ring); --dv-edge-dock-indicator-color: var(--ring); --dv-icon-hover-background-color: var(--accent); --dv-scrollbar-background-color: var(--border); --dv-paneview-active-outline-color: var(--ring); --dv-paneview-header-border-color: var(--border); --dv-dropdown-border-radius: var(--radius); --dv-overlay-z-index: 30; --dv-floating-box-shadow: 0 8px 32px rgb(0 0 0 / 0.25), 0 2px 8px rgb(0 0 0 / 0.15); --dv-floating-border: 1px solid var(--border); --dv-floating-group-border: 1px solid var(--border); --dv-floating-group-dragging-opacity: 0.6; --dv-floating-titlebar-height: 1.75rem; --dv-floating-titlebar-background-color: var(--muted); --dv-floating-titlebar-border-bottom: 1px solid var(--border); --dv-tab-group-color-grey: var(--muted-foreground); --dv-tab-group-color-blue: var(--chart-1); --dv-tab-group-color-red: var(--chart-2); --dv-tab-group-color-yellow: var(--chart-3); --dv-tab-group-color-green: var(--chart-4); --dv-tab-group-color-pink: var(--chart-5); --dv-tab-group-color-purple: var(--chart-1); --dv-tab-group-color-cyan: var(--chart-2); --dv-tab-group-color-orange: var(--chart-3); --dv-tab-group-chip-padding: 0 0.375rem; --dv-tab-group-chip-border-radius: var(--radius); --dv-tab-group-chip-font-size: 0.6875rem; --dv-tab-group-line-height: 2px; --dv-tab-group-line-opacity: 0.6; } } :root[data-accessibility="hyperlegible"], [data-accessibility="hyperlegible"] { --tradecn-font-sans: var(--tradecn-font-accessible); --tradecn-font-mono: var(--tradecn-font-accessible-mono); }
Usage
import { PanelContent } from "@/components/ui/panel"
import { Workspace } from "@/components/ui/workspace"
function Orders() {
return <PanelContent className="p-3">No open orders.</PanelContent>
}
function Positions() {
return <PanelContent className="p-3">No positions.</PanelContent>
}
const PANELS = { orders: Orders, positions: Positions }
export default function WorkspaceDemo() {
return (
<Workspace
className="h-64 rounded-md border border-border"
panels={PANELS}
watermark="No panels."
seed={(api) => {
const orders = api.addPanel({ kind: "orders", title: "Orders" })
api.addPanel({ kind: "positions", title: "Positions", position: { reference: orders, direction: "right" } })
}}
/>
)
}
Drag either tab beside the other panel to split the space, or onto its tab strip to combine them. The close button removes a panel.
Keep panels stable with a module constant or useMemo; replacing it re-renders every panel. Give the workspace a height and let it fill the available width. workspace.tsx imports dockview's stylesheet.
Composition
Workspace
└── Panel
├── PanelHeader
│ ├── SymbolTag
│ ├── LinkGroupDot
│ └── PanelActions
└── PanelContent
Workspace supplies the Panel wrapper. Your registered component renders its header and content and calls useWorkspacePanel() for state and actions.
Saved layouts
Use defaultLayout to restore the dock arrangement and each panel's JSON state. Here, changing a starting symbol, moving a tab, or closing a panel saves to browser storage. Wait for the save count to increase, then reload the page to restore it. Reset layout puts both panels and their initial symbols back.
This example reads localStorage, so mount it on the client. The save callback runs after changes settle; see Saving is yours for debounce and page-close limits.
import { useState } from "react"
import { PanelContent } from "@/components/ui/panel"
import { Workspace, useWorkspacePanel, type WorkspaceApi } from "@/components/ui/workspace"
const STORAGE_KEY = "tradecn-workspace-example"
function Watch() {
const panel = useWorkspacePanel()
return (
<PanelContent className="p-3">
<label className="flex flex-col gap-2">
Starting symbol
<input className="min-w-0 rounded border border-input bg-background px-2 py-1" value={String(panel.state.symbol ?? "")} onChange={(event) => panel.setState({ symbol: event.target.value })} />
</label>
</PanelContent>
)
}
const PANELS = { watch: Watch }
function seed(api: WorkspaceApi) {
const first = api.addPanel({ kind: "watch", title: "Treasuries", state: { symbol: "ZN" } })
api.addPanel({ kind: "watch", title: "Equities", state: { symbol: "ES" }, position: { reference: first, direction: "right" } })
}
export default function WorkspaceSavedLayoutDemo() {
const [api, setApi] = useState<WorkspaceApi | null>(null)
const [saves, setSaves] = useState(0)
const [error, setError] = useState("")
const [layout] = useState(() => {
try {
return localStorage.getItem(STORAGE_KEY)
} catch {
return null
}
})
return (
<>
<div data-demo-controls className="flex flex-wrap items-center gap-3">
<button type="button" disabled={!api} className="rounded border border-border px-2 py-1 text-xs hover:bg-muted" onClick={() => {
if (!api) return
api.clear()
seed(api)
}}>Reset layout</button>
<span className="text-xs text-muted-foreground lining-nums tabular-nums" role="status">{error || `Saves: ${saves}`}</span>
</div>
<Workspace
className="h-64 rounded-md border border-border"
panels={PANELS}
seed={seed}
defaultLayout={layout}
onReady={setApi}
onLayoutChange={(next) => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(next))
setSaves((count) => count + 1)
setError("")
}}
onLayoutError={() => setError("Could not save or restore the layout.")}
watermark="No panels. Reset the layout to start again."
/>
</>
)
}
Linked panels
Put panels in the same link group to share a symbol. Edit either symbol to ZN, ZB, or ES and press Enter; both panels follow. Shift-click a group numbered 1 to unlink that panel, then change its symbol independently.
useWorkspacePanel supplies each panel's identity and starting state. useLinkGroup handles live updates and writes symbol and group changes back to that state. transport={null} keeps this example's links within its provider; omit it to use the default cross-window channel.
import { LinkGroupProvider, useLinkGroup } from "@/hooks/use-link-group"
import type { LinkGroup } from "@/lib/link-group"
import { LinkGroupDot, PanelContent, PanelHeader, SymbolTag } from "@/components/ui/panel"
import { Workspace, useWorkspacePanel } from "@/components/ui/workspace"
const SYMBOLS = new Set(["ZN", "ZB", "ES"])
function Instrument() {
const panel = useWorkspacePanel()
const link = useLinkGroup({
source: panel.id,
defaultGroup: (panel.state.group as LinkGroup | undefined) ?? null,
defaultSymbol: (panel.state.symbol as string | undefined) ?? "ZN",
onGroupChange: (group) => panel.setState({ group }),
onSymbolChange: (symbol) => panel.setState({ symbol }),
})
return (
<>
<PanelHeader>
<SymbolTag value={link.symbol} onCommit={link.setSymbol} validate={(symbol) => SYMBOLS.has(symbol)} />
<LinkGroupDot group={link.group} onGroupChange={link.setGroup} />
</PanelHeader>
<PanelContent className="p-3">Following {link.symbol}.</PanelContent>
</>
)
}
const PANELS = { instrument: Instrument }
export default function WorkspaceLinkedPanelsDemo() {
return (
<LinkGroupProvider transport={null}>
<Workspace
className="h-64 rounded-md border border-border"
panels={PANELS}
seed={(api) => {
const first = api.addPanel({ kind: "instrument", title: "Book", state: { symbol: "ZN", group: 1 } })
api.addPanel({ kind: "instrument", title: "Trades", state: { symbol: "ZN", group: 1 }, position: { reference: first, direction: "right" } })
}}
watermark="No panels."
/>
</LinkGroupProvider>
)
}
Keyboard actions
Click a tab to focus its panel. Press r to increment that panel's refresh-request counter, ] or [ to focus the next or previous panel, n then q to add one, and w to close the active panel. Refresh is a local counter here; a real handler would request quotes from your feed.
Declare bindings on HotkeysProvider, then attach handlers with useHotkey. Workspace supplies each panel's panel:quotes scope, so the two instances share a binding without sharing its handler. The global handlers use the API received by onReady.
import { useState } from "react"
import { HotkeysProvider, useHotkey } from "@/hooks/use-hotkeys"
import type { HotkeyBinding } from "@/lib/hotkeys"
import { PanelContent } from "@/components/ui/panel"
import { Workspace, type WorkspaceApi } from "@/components/ui/workspace"
const BINDINGS: HotkeyBinding[] = [
{ id: "workspace.next", keys: "]", scope: "global", description: "Next panel" },
{ id: "workspace.previous", keys: "[", scope: "global", description: "Previous panel" },
{ id: "workspace.new", keys: "n q", scope: "global", description: "New quotes panel" },
{ id: "workspace.close", keys: "w", scope: "global", description: "Close the active panel" },
{ id: "quotes.refresh", keys: "r", scope: "panel:quotes", description: "Refresh quotes" },
]
function Quotes() {
const [requests, setRequests] = useState(0)
const refresh = () => setRequests((count) => count + 1)
useHotkey("quotes.refresh", refresh)
return (
<PanelContent className="space-y-2 p-3">
<button type="button" className="rounded border border-border px-2 py-1 hover:bg-muted" onClick={refresh}>Refresh</button>
<p role="status">Refresh requests: {requests}</p>
</PanelContent>
)
}
const PANELS = { quotes: Quotes }
function Keys({ api }: { api: WorkspaceApi | null }) {
useHotkey("workspace.next", () => api?.focusNext())
useHotkey("workspace.previous", () => api?.focusNext(-1))
useHotkey("workspace.new", () => api?.addPanel({ kind: "quotes", title: "Quotes" }))
useHotkey("workspace.close", () => {
const id = api?.activePanel()
if (id) api?.closePanel(id)
})
return null
}
export default function WorkspaceKeyboardDemo() {
const [api, setApi] = useState<WorkspaceApi | null>(null)
return (
<HotkeysProvider bindings={BINDINGS}>
<Keys api={api} />
<Workspace
className="h-64 rounded-md border border-border"
panels={PANELS}
onReady={setApi}
seed={(api) => {
const first = api.addPanel({ kind: "quotes", title: "Treasury quotes" })
api.addPanel({ kind: "quotes", title: "Equity quotes", position: { reference: first, direction: "right" } })
}}
watermark="No panels. Press n q to add one."
/>
</HotkeysProvider>
)
}
Panel actions
Float moves a panel over the dock. Pop out moves it into a separate window; closing that window returns it. Maximize / restore expands a docked group and puts it back. Type a note before moving a panel to see its React state survive; Reset layout creates fresh panels.
Serve an empty same-origin /popout.html page, such as public/popout.html in Vite, before using Pop out. The preview supplies this file. See Floating, popout, maximize for window, styling, and restoration limits.
import { useState } from "react"
import { PanelActions, PanelContent, PanelHeader } from "@/components/ui/panel"
import { Workspace, useWorkspacePanel, type WorkspaceApi } from "@/components/ui/workspace"
function Notes() {
const panel = useWorkspacePanel()
const [note, setNote] = useState("")
const [error, setError] = useState("")
return (
<>
<PanelHeader className="h-auto min-h-7 flex-wrap py-1">
<PanelActions className="flex-wrap gap-2">
<button type="button" className="hover:underline disabled:opacity-50" disabled={panel.location !== "grid"} onClick={() => panel.float({ x: 8, y: 136, width: 240, height: 240 })}>Float</button>
<button type="button" className="hover:underline disabled:opacity-50" disabled={panel.location === "popout"} onClick={async () => {
setError("")
if (!await panel.popout()) setError("Popout blocked. Allow popups and try again.")
}}>Pop out</button>
<button type="button" className="hover:underline disabled:opacity-50" disabled={panel.location !== "grid"} onClick={panel.toggleMaximize}>Maximize / restore</button>
</PanelActions>
</PanelHeader>
<PanelContent className="space-y-2 p-3">
<p>Location: {panel.location}</p>
<label className="flex flex-col gap-2">
Note
<input className="min-w-0 rounded border border-input bg-background px-2 py-1" value={note} onChange={(event) => setNote(event.target.value)} />
</label>
{error && <p role="alert">{error}</p>}
</PanelContent>
</>
)
}
const PANELS = { notes: Notes }
function seed(api: WorkspaceApi) {
const first = api.addPanel({ kind: "notes", title: "Desk notes" })
api.addPanel({ kind: "notes", title: "Handoff notes", position: { reference: first, direction: "below" } })
}
export default function WorkspacePanelActionsDemo() {
const [api, setApi] = useState<WorkspaceApi | null>(null)
return (
<>
<div data-demo-controls>
<button type="button" disabled={!api} className="rounded border border-border px-2 py-1 text-xs hover:bg-muted" onClick={() => {
if (!api) return
api.clear()
seed(api)
}}>Reset layout</button>
</div>
<Workspace className="h-96 rounded-md border border-border" panels={PANELS} seed={seed} onReady={setApi} watermark="No docked panels. Reset the layout to start again." />
</>
)
}
API Reference
Workspace
| Prop | Type | Default | Purpose |
|---|---|---|---|
panels |
Record<string, ComponentType<WorkspacePanelProps>> |
Required | Components indexed by panel kind. |
defaultLayout |
unknown |
None | Layout object or JSON text to restore at initialization. Use api.load for later changes. |
seed |
(api: WorkspaceApi) => void |
None | Builds the initial layout when none can be restored. |
onReady |
(api: WorkspaceApi) => void |
None | Receives the API after the initial load or seed call returns. |
onLayoutChange |
(layout: WorkspaceLayout) => void |
None | Receives layouts for you to save. |
layoutChangeDelay |
number |
250 |
Debounce interval in milliseconds, captured at initialization. |
onLayoutError |
(reason: unknown) => void |
None | Receives load failures and synchronous errors while producing or saving a layout. |
watermark |
ReactNode |
null |
Content shown when the main grid has no visible groups, including when all panels float or pop out. |
locked |
boolean |
false |
Disables resizing with grid splitters. |
disableFloating |
boolean |
false |
Disables the Shift-drag gesture for floating. |
popoutUrl |
string |
"/popout.html" |
Same-origin page served for popouts. |
className |
string |
None | Styles the outer wrapper; give the workspace a height. |
| Other div props | ComponentProps<"div"> |
None | Forwarded to the wrapper, except children and ref. |
Panels by kind
Each panel is a region named by its title, with hotkey scope panel:<kind> and an active border controlled by the dock. Registered components receive id and kind as string props. Inside them, useWorkspacePanel() returns:
| Member | Type | Purpose |
|---|---|---|
id, kind, title |
string |
Identity, registered kind, and display title. |
state |
WorkspacePanelState |
The panel's saved JSON object. |
active |
boolean |
Whether this is the dock's active panel. |
location |
"grid" | "floating" | "popout" | "edge" |
Current location; edge groups require direct dockview API use. |
setTitle |
(title: string) => void |
Updates the title; ignores empty strings. |
setState |
(patch: WorkspacePanelStatePatch | ((state: WorkspacePanelState) => WorkspacePanelStatePatch)) => void |
Merges a state patch. |
close, toggleMaximize |
() => void |
Close this panel, or maximize/restore its grid group. |
float |
(box?: WorkspaceBox) => void |
Moves this panel into a floating group. |
popout |
() => Promise<boolean> |
Opens a popout; see the window requirements below. |
Mount LinkGroupProvider for linked symbols and HotkeysProvider for keyboard actions. Neither provider is needed for basic docking. See panel for header composition and use-hotkeys for binding behavior.
Drag panels by their tabs. PanelHeader holds controls but is not a workspace drag handle. Tabs show the title and a close button. No tab context menu is enabled; use your shadcn context-menu or configure dockview's own menu through api.dockview.
An unregistered kind renders a placeholder with a working close button, so older layouts still open. unknownPanelKinds(layout, Object.keys(PANELS)) lists missing kinds up front. Import it from @/lib/workspace-layout.
Adding panels
api.addPanel(options) returns the panel id. If that id is already open, it focuses the existing panel and leaves its title and state unchanged.
| Option | Type | Default | Purpose |
|---|---|---|---|
kind |
string |
Required | Selects the registered component. |
id |
string |
Lowest free <kind>-N, starting at 1 |
Identifies the panel. |
title |
string |
kind |
Names the panel and tab. |
state |
WorkspacePanelState |
{} |
Initial JSON state. |
position |
{ reference?: string; direction: "left" | "right" | "above" | "below" | "within" } |
Active group | Places the panel beside a reference panel, or tabs it with within. |
floating |
boolean | WorkspaceBox |
false |
Opens floating; takes precedence over position. |
focus |
boolean |
true |
Moves keyboard focus into the new panel after rendering. |
WorkspaceBox has optional numeric x, y, width, and height fields in CSS pixels. Omitted fields use dockview's defaults: position (100, 100), size 300 × 300 in 8.3.1.
What a panel keeps
Keep small settings in state: a symbol, link group, or view option. WorkspacePanelState is a string-keyed object of JSON values; WorkspacePanelStatePatch also accepts undefined to remove a key. setState shallow-merges a patch or a patch returned from a function of the previous state. An identical serialized result triggers no store update or save.
Runtime values are cleaned through JSON serialization. Function-valued object properties disappear, dates become strings, and an unserializable object becomes {}. These non-JSON values are outside the declared input type.
In Linked panels, useLinkGroup treats the starting symbol as a seed. A real group write, including one from another window, takes precedence.
The layout, and what it leaves out
onLayoutChange receives a WorkspaceLayout:
{
version: 1,
kind: "tradecn-workspace",
dockview: { ... }, // Arrangement, including floating and popout positions.
panels: { "book-1": { kind: "book", title: "Order book", state: { symbol: "ZN", group: 1 } }, ... },
boundaries: WORKSPACE_PERSISTENCE_BOUNDARIES,
}
Read panel records from panels. Treat dockview as opaque data owned by the installed dockview version. Layout types, parseWorkspaceLayout, and WORKSPACE_PERSISTENCE_BOUNDARIES are exported from @/lib/workspace-layout.
The payload records these persistence boundaries so saved templates can travel between workspaces:
| Boundary | Contents | Storage |
|---|---|---|
autosave |
Dock arrangement, floating/popout positions, panel kinds, titles, and state. | Included in the layout. |
workspaceScoped |
Column widths and sort, selection, scroll position, ticket drafts. | Store separately by panel id if needed. |
globalScoped |
Hotkey remaps, palette recents, theme, instrument conventions. | Store as the person's preferences. |
excluded |
Market data, orders and status, positions, feed health, live link-group symbols. | Keep out of saved state. |
These boundaries document a policy; they do not filter panel state. You must keep session data out of state to make a layout portable. A panel's saved symbol is a starting value, separate from a link group's live symbol.
parseWorkspaceLayout(value) accepts an object or JSON text and returns a copy, or null. It checks the version-1 envelope and basic dock structure, requires a nonempty kind for every panel, drops orphan records, defaults missing or empty titles to the kind, and cleans state. Valid recorded boundaries are preserved; missing or malformed boundaries use the current defaults. It does not fully validate dockview's internal layout. Only version 1 is currently supported.
Both defaultLayout and api.load use this parser. If parsing fails or dockview refuses the layout, api.load clears the workspace, calls onLayoutError, and returns false. At initialization, a missing or failed layout falls back to seed(api); later api.load calls do not seed.
Saving is yours
onLayoutChange runs after changes stop for layoutChangeDelay milliseconds. Resize drags can report on every pointer move. Seeding schedules a save; a successful synchronous restore establishes the saved baseline, and unchanged snapshots do not save again. Delayed popout restoration can produce further changes.
Unmounting flushes a pending callback before disposing the dock. This does not guarantee a write when a page closes: page unload need not unmount React, and asynchronous writes are not awaited. Choose storage in your callback: localStorage, a desktop file, or a server. onLayoutError receives synchronous serialization or callback errors; handle rejected asynchronous writes yourself.
Keys
Receive the API through onReady and bind its actions through the hotkey registry. The workspace declares no bindings:
const BINDINGS: HotkeyBinding[] = [
{ id: "workspace.next", keys: "]", scope: "global", description: "Next panel" },
{ id: "workspace.previous", keys: "[", scope: "global", description: "Previous panel" },
{ id: "workspace.book", keys: "n b", scope: "global", description: "New book" },
]
useHotkey("workspace.next", () => api.focusNext())
focusPanel(id) activates the panel and moves keyboard focus inside it; clicking a tab does the same. Its panel:<kind> bindings can then answer. Use focusNext() or focusNext(-1) to move in dockview's order, and bind addPanel or closePanel as needed. Dockview's optional enterprise keymap is not enabled; the registry supplies the binding list for your palette or help overlay.
Floating, popout, maximize
api.float(id) creates a floating group over the workspace. Shift-dragging a tab does the same unless disableFloating is set; direct float calls and addPanel({ floating: true, ... }) remain available. api.toggleMaximize(id) maximizes or restores the panel's group, only while it is in the grid.
locked disables grid splitter resizing. To also stop drag and drop, use api.dockview.updateOptions({ disableDnd: true }). See dockview's locking behavior.
api.popout(id) opens a separate window in the same JavaScript context: one React tree and shared stores, as with PanelPopout. Link groups need no transport between the main page and its popouts. Dockview copies stylesheets when the window loads; the workspace attaches an available hotkey registry and mirrors root-element attributes, including theme-class changes.
Popouts have the same limits as PanelPopout: components that portal to the main document.body open there, and later stylesheet changes are not copied. Separate JavaScript contexts need the desktop-shell approach below.
Serve an empty same-origin page at /popout.html (public/popout.html in Vite), or set popoutUrl. Call popout() from a click or key press. It resolves to false if the panel is missing or the browser blocks the window. Closing the window returns its panels to the main workspace; a panel popped out from a floating group can return there.
Saved popouts are reopened during restoration, often without a user gesture. If blocked, dockview returns their panels to the grid and logs an error to the console. Close popouts before saving a reusable template.
One window per JavaScript context
For a desktop shell with separate webviews, mount one Workspace per window, each with its own stores and layout saved under the window id. The shell tracks windows and their layouts and opens new windows itself; omit workspace popout actions.
Connect link groups through a LinkTransport built with createCallbackTransport and the shell's events (see panel). Mount a HotkeysProvider in each window.
The theme
The registry appends .dockview-theme-tradecn to your stylesheet, mapping dockview variables to your shadcn tokens:
| Dock surface | Token |
|---|---|
| Group background | --background |
| Tab strip | --muted |
| Tab text | --foreground, --muted-foreground |
| Separators | --border |
| Active splitter and drop indicator | --ring |
| Dropdown radius | --radius |
These mappings follow light, dark, and tradecn themes. scripts/workspace-theme.test.ts checks coverage against the installed dockview light theme, including variables added by upgrades.
Dock overlays use z-index 30, below shadcn menus and dialogs at 50. Optional tab-group colors configured through api.dockview use --chart-1 through --chart-5, with grey using --muted-foreground. Installation also adds panel-active, panel-drag-target, panel-error, panel-sync, and link-1 through link-4 if missing.
The dock's own API
onReady and seed receive a WorkspaceApi:
| Method | Returns | Purpose |
|---|---|---|
addPanel(options) |
string |
Adds or focuses a panel; see the options above. |
closePanel(id) |
void |
Closes the panel and removes its saved record. |
focusPanel(id) |
void |
Activates and focuses the panel. |
focusNext(step = 1) |
void |
Moves forward (1) or backward (-1). |
panels() |
WorkspacePanelInfo[] |
Lists each panel's id, kind, title, active, and location. |
activePanel() |
string | null |
Returns the active id, or null. |
getState(id) |
WorkspacePanelState | undefined |
Reads a panel's state. |
setTitle(id, title) |
void |
Updates a nonempty title. |
setState(id, patch) |
void |
Applies the same patch or updater accepted by the panel handle. |
float(id, box?) |
void |
Moves a panel into a floating group. |
popout(id) |
Promise<boolean> |
Opens a popout. |
toggleMaximize(id) |
void |
Maximizes or restores the panel's grid group. |
toLayout() |
WorkspaceLayout |
Takes a layout snapshot immediately. |
load(layout: unknown) |
boolean |
Replaces the workspace; failure leaves it empty. |
clear() |
void |
Removes all panels. |
api.dockview exposes DockviewApi from dockview-react for features outside this wrapper. Its layout changes still schedule saves. Panels added directly with a different component name have no workspace record and are outside this item's persistence contract.
The dependency
Dependency baseline: dockview-react 8.3.1, checked 2026-09-21. It brings dockview (a re-export) and dockview-core: three MIT packages from one repository, with no further runtime dependencies beyond React peers. Dockview supplies docking, tabs, floating groups, popouts, and JSON layouts. shadcn's resizable supplies resizable panels; a grid library supplies tiles rather than docking.
This item uses no dockview-enterprise features. The separately licensed package adds the keyboard keymap, drag compass, multi-row tabs, smart guides, and auto-hide/dock-to-edge behavior; basic edge groups are free. See the feature and license comparison.
If you already installed panel, both items reference the same shared source files. Local edits to installed files still need the usual update review.
What it does not do
The workspace provides no storage backend, data fetching, or close confirmation. Closing a tab also discards any unsaved draft held by that panel. Minimum panel sizes, tab context menus, and dockview's own keyboard navigation are not exposed as workspace props; configure them through api.dockview where supported.
Built on shadcn's input.
This page is docs/workspace.md at v1.4.13.