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, WorkspaceTab, WorkspaceTabClose, WorkspaceTabTitle } 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}
tabComponent={DeskTab}
watermark="No panels."
seed={(api) => {
const orders = api.addPanel({ kind: "orders", title: "Orders" })
api.addPanel({ kind: "positions", title: "Positions", position: { reference: orders, direction: "right" } })
}}
/>
)
}
export function DeskTab() {
return (
<WorkspaceTab>
<WorkspaceTabTitle />
<WorkspaceTabClose>×</WorkspaceTabClose>
</WorkspaceTab>
)
}
Installation
pnpm dlx shadcn@latest add tradecn/ui/workspace#v2.0.0
npx shadcn@latest add tradecn/ui/workspace#v2.0.0
yarn dlx shadcn@latest add tradecn/ui/workspace#v2.0.0
bunx --bun shadcn@latest add tradecn/ui/workspace#v2.0.0
-
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, type SyntheticEvent } from "react" import { useMaybeHotkeys } from "@/hooks/use-hotkeys" import { mirrorRoot } from "@/hooks/use-popout" import { createWorkspacePanelStore, debounce, isReservedPanelId, 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, which an id naming an `Object.prototype` member (`constructor`, `__proto__`, …) gets too. An id that is already open is not opened twice: it is focused, unless `focus: false` leaves everything as it stands. */ 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 tabComponent: ComponentType /** 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) interface WorkspaceTabContextValue { panelApi: IDockviewPanelHeaderProps["api"] tabLocation: IDockviewPanelHeaderProps["tabLocation"] } const WorkspaceTabContext = createContext<WorkspaceTabContextValue | 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 } // Dockview 8.3.1 removes overflow DOM without disposing its tab renderers. Keep the actual // renderer lifetime tied to that popup so closing it also unmounts consumer effects and portals. function manageOverflowTabs(dock: DockviewApi) { type DockPanel = DockviewApi["panels"][number] type Renderer = ReturnType<DockPanel["view"]["createTabRenderer"]> const panels = new Map<DockPanel, () => void>() const popups = new Map<Element, { observer: MutationObserver; renderers: Set<Renderer> }>() const attach = (panel: DockPanel) => { if (panels.has(panel)) return const view = panel.view const create = view.createTabRenderer const owned = new Set<Renderer>() const wrapped: typeof create = (location) => { const renderer = create.call(view, location) if (location !== "headerOverflow") return renderer owned.add(renderer) let disposed = false let detach = () => {} const dispose = renderer.dispose?.bind(renderer) renderer.dispose = () => { if (disposed) return disposed = true owned.delete(renderer) detach() dispose?.() } // The dock inserts the renderer synchronously after this factory returns. queueMicrotask(() => { if (disposed) return const anchor = renderer.element.closest(".dv-popover-anchor") if (!anchor || !renderer.element.isConnected) { renderer.dispose?.(); return } let popup = popups.get(anchor) if (!popup) { const renderers = new Set<Renderer>() const Observer = anchor.ownerDocument.defaultView?.MutationObserver ?? MutationObserver const observer = new Observer(() => { for (const item of [...renderers]) if (!anchor.contains(item.element)) item.dispose?.() }) observer.observe(anchor, { childList: true, subtree: true }) popup = { observer, renderers } popups.set(anchor, popup) } popup.renderers.add(renderer) const current = popup detach = () => { current.renderers.delete(renderer) if (current.renderers.size === 0) { current.observer.disconnect(); popups.delete(anchor) } } }) return renderer } view.createTabRenderer = wrapped panels.set(panel, () => { if (view.createTabRenderer === wrapped) view.createTabRenderer = create for (const renderer of [...owned]) renderer.dispose?.() }) } const added = dock.onDidAddPanel(attach) const removed = dock.onDidRemovePanel((panel) => { panels.get(panel)?.(); panels.delete(panel) }) for (const panel of dock.panels) attach(panel) return { dispose() { added.dispose() removed.dispose() for (const cleanup of panels.values()) cleanup() panels.clear() } } } 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>() let focusRepair: { id: string; dispose(): void } | null = null const focusHost = (id: string) => { const target = hosts.get(id)?.firstElementChild // Popouts adopt this element into another document without changing its original prototype. if (target && "focus" in target && typeof target.focus === "function") { target.focus({ preventScroll: true }) return target } return null } const repairPointerFocus = (id: string, target: Element) => { const doc = target.ownerDocument const renderer = dv.getPanel(id)?.view.content.element const parent = renderer?.parentElement if (!doc.defaultView || doc.activeElement !== target || !renderer || !parent || !renderer.contains(target)) return // Dockview's pending pointer frame reparents this renderer. Watch only that removal // until our frame runs, and relinquish focus when a caller deliberately blurs it. let removed = false const rememberRemoval = (records: MutationRecord[]) => { for (const record of records) for (const node of record.removedNodes) if (node === renderer) removed = true } const observer = new doc.defaultView.MutationObserver(rememberRemoval) const stop = () => pending.dispose() const blur = (event: FocusEvent) => { if (event.target !== target) return queueMicrotask(() => { if (focusRepair !== pending) return rememberRemoval(observer.takeRecords()) if (!removed) pending.dispose() }) } const pending = { id, dispose() { cancelAnimationFrame(frame) observer.disconnect() doc.removeEventListener("focusin", stop, true) doc.removeEventListener("focusout", blur, true) if (focusRepair === pending) focusRepair = null } } // Use Dockview's module-realm queue even when the renderer has been adopted into a popout. const frame = requestAnimationFrame(() => { if (focusRepair !== pending) return rememberRemoval(observer.takeRecords()) pending.dispose() if (removed && dv.activePanel?.id === id && hosts.get(id)?.firstElementChild === target && target.isConnected && target.ownerDocument === doc && !doc.defaultView?.closed && doc.activeElement === doc.body) focusHost(id) }) focusRepair = pending observer.observe(parent, { childList: true }) doc.addEventListener("focusin", stop, true) doc.addEventListener("focusout", blur, true) } const toLayout = (): WorkspaceLayout => { const dockview = dv.toJSON() const records = store.snapshot() const panels = Object.fromEntries(Object.keys(dockview.panels).flatMap((id) => (Object.prototype.hasOwnProperty.call(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 }) { // An id that names an Object.prototype member breaks the dock, which keys its panels by plain objects: such a // panel opens under its default id instead, and the id returned says which. const id = wanted != null && !isReservedPanelId(wanted) ? wanted : nextPanelId(kind, dv.panels.map((panel) => panel.id)) if (dv.getPanel(id)) { // An ensure-open call with focus: false must not steal the keyboard from // the panel the trader is in. if (focus) api.focusPanel(id) return id } const record = { kind, title: title ?? kind, state: state ?? {} } store.set(id, record) const earlierFocus = wantsFocus 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 = earlierFocus throw reason } return id }, closePanel(id) { dv.getPanel(id)?.api.close() }, focusPanel(id) { const previous = focusRepair?.id focusRepair?.dispose() const panel = dv.getPanel(id) if (!panel) return const inactive = dv.activePanel?.id !== id panel.api.setActive() const target = focusHost(id) if (target && (inactive || previous === id)) repairPointerFocus(id, target) }, 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 = [ manageOverflowTabs(dv), 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() { focusRepair?.dispose() save.flush() unsubscribe() for (const d of disposables) d.dispose() }, } } export interface WorkspaceProps extends Omit<ComponentProps<"div">, "children"> { /** 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>> /** Contents of each tab, including overflow entries. Define the component outside render to preserve its state. */ tabComponent?: ComponentType /** 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 /** Disables grid splitter resizing. Use dockview.updateOptions for drag policy. */ 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 } // A delay a timer can wait out: NaN or a negative number fires at once, which would save on every pointer move of a // drag, so those read as the default; one past 2^31 - 1 ms fires at once too, so it is capped there. const saveDelay = (ms: number) => (Number.isFinite(ms) && ms >= 0 ? Math.min(ms, 2_147_483_647) : 250) export function Workspace({ panels, tabComponent = DefaultWorkspaceTab, 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, saveDelay(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, tabComponent, registerHost: (id, element) => internals?.registerHost(id, element) ?? (() => {}) }), [store, panels, internals, watermark, tabComponent], ) 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={WorkspaceTabHost} 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 // Own keys only: a layout naming "constructor" or "__proto__" must hit the // placeholder, not an Object.prototype member. The long spelling keeps installed // source compiling for consumers whose TypeScript lib predates ES2022. const Component = Object.prototype.hasOwnProperty.call(kinds, handle.kind) ? kinds[handle.kind] : undefined 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 WorkspaceTabHost({ api: panelApi, tabLocation }: IDockviewPanelHeaderProps) { const { tabComponent: Component } = useWorkspaceContext() const context = useMemo(() => ({ panelApi, tabLocation }), [panelApi, tabLocation]) return <WorkspaceTabContext.Provider value={context}><Component /></WorkspaceTabContext.Provider> } function useWorkspaceTabContext() { const context = useContext(WorkspaceTabContext) if (!context) throw new Error("workspace tab parts must be rendered by a <Workspace> tabComponent") return context } function useWorkspaceTabTitle() { const { panelApi } = useWorkspaceTabContext() const subscribe = useCallback( (cb: () => void) => { const d = panelApi.onDidTitleChange(cb) return () => d.dispose() }, [panelApi], ) return useSyncExternalStore( subscribe, () => panelApi.title ?? panelApi.id, () => panelApi.id, ) } export interface WorkspaceTabHandle extends Omit<WorkspacePanelHandle, "kind" | "state"> { /** Undefined for a panel added directly through the raw Dockview API without a workspace record. */ kind: string | undefined state: WorkspacePanelState | undefined /** The same panel can have a tab in both places at once. */ tabLocation: IDockviewPanelHeaderProps["tabLocation"] /** Requires a workspace record. Raw panels ignore this command; use their native panel API instead. */ setTitle(title: string): void /** Requires a workspace record. Raw panels ignore the patch without calling an updater. */ setState: WorkspacePanelHandle["setState"] /** Activates this panel; moves body focus only when a workspace host is registered. */ focus(): void } /** Opt into this panel's state and dock events. Static tab markup needs no subscription. */ export function useWorkspaceTab(): WorkspaceTabHandle { const { panelApi, tabLocation } = useWorkspaceTabContext() const { store, api } = useWorkspaceContext() const id = panelApi.id const getRecord = useCallback(() => store.get(id), [store, id]) const record = useSyncExternalStore(store.subscribe, getRecord, getRecord) const title = useWorkspaceTabTitle() const subscribe = useCallback((cb: () => void) => { const listeners = [panelApi.onDidActiveChange(cb), panelApi.onDidActiveGroupChange(cb), panelApi.onDidLocationChange(cb)] return () => { for (const listener of listeners) listener.dispose() } }, [panelApi]) const active = useSyncExternalStore(subscribe, () => panelApi.isActive && panelApi.isGroupActive, () => false) const location = useSyncExternalStore(subscribe, () => locationOf(panelApi), () => "grid" as const) return { id, kind: record?.kind, state: record?.state, title, active, location, tabLocation, setTitle: (value) => api?.setTitle(id, value), setState: (patch) => api?.setState(id, patch), close: () => panelApi.close(), float: (box) => api?.float(id, box), popout: () => api?.popout(id) ?? Promise.resolve(false), toggleMaximize: () => api?.toggleMaximize(id), focus: () => api?.focusPanel(id), } } export interface WorkspaceTabProps extends ComponentProps<"div"> { children: ReactNode } /** Inner tab contents. Dockview owns the outer tab's role, name, keyboard navigation and drag. */ export function WorkspaceTab({ className, onClick, ...props }: WorkspaceTabProps) { const { panelApi } = useWorkspaceTabContext() const { api } = useWorkspaceContext() return <div data-workspace-tab={panelApi.id} className={cn("flex h-full items-center gap-1.5 text-xs", className)} {...props} onClick={(event) => { onClick?.(event) if (!event.defaultPrevented && event.currentTarget.contains(event.target as Node)) api?.focusPanel(panelApi.id) }} /> } export function WorkspaceTabTitle({ className, ...props }: Omit<ComponentProps<"span">, "children"> & { children?: never }) { const title = useWorkspaceTabTitle() return <span data-slot="tradecn-workspace-tab-title" className={cn("truncate", className)} {...props}>{title}</span> } export interface WorkspaceTabCloseProps extends ComponentProps<"button"> { children: ReactNode } export function WorkspaceTabClose({ className, onClick, type = "button", "aria-label": label, "aria-labelledby": labelledBy, ...props }: WorkspaceTabCloseProps) { const { panelApi, tabLocation } = useWorkspaceTabContext() const title = useWorkspaceTabTitle() return <button type={type} data-slot="tradecn-workspace-tab-close" aria-label={label ?? (labelledBy ? undefined : `Close ${title}`)} aria-labelledby={labelledBy} className={cn("-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", className)} {...props} onClick={(event) => { onClick?.(event) if (event.defaultPrevented) { event.stopPropagation(); return } // The native overflow row dismisses on click; prevent it from activating the removed panel. if (tabLocation === "headerOverflow") event.preventDefault() else event.stopPropagation() panelApi.close() }} /> } export interface WorkspaceTabActionsProps extends ComponentProps<"div"> { children: ReactNode } /** Controls that keep their own focus and do not activate or drag the surrounding tab. */ export function WorkspaceTabActions({ className, onMouseDownCapture, onPointerDown, onMouseDown, onTouchStart, onClick, onDragStart, onKeyDown, ...props }: WorkspaceTabActionsProps) { const { tabLocation } = useWorkspaceTabContext() const release = useRef<(() => void) | null>(null) useEffect(() => () => release.current?.(), []) const isolateTabEvent = (event: SyntheticEvent<HTMLDivElement>) => { // Header portals need document listeners for outside dismissal. Overflow portals also need // isolation from the dock's window-level popup dismissal. if (tabLocation === "headerOverflow" || event.currentTarget.contains(event.target as Node)) event.stopPropagation() } return <div data-slot="tradecn-workspace-tab-actions" className={cn("flex shrink-0 items-center gap-1.5", className)} {...props} onMouseDownCapture={(event) => { onMouseDownCapture?.(event) release.current?.() // Arm the drag guard before child controls can stop bubbling. Capture never stops their handlers. // Portaled menu content belongs to this React tree, but not to the tab's drag surface. if (!event.currentTarget.contains(event.target as Node)) return const tab = event.currentTarget.closest(".dv-tab") if (!tab) return const doc = tab.ownerDocument const stopDrag = (drag: DragEvent) => { // HTML5 drag starts on the draggable ancestor, bypassing the inner control's handlers. if (drag.target === tab) { drag.preventDefault(); drag.stopPropagation() } } const cleanup = () => { doc.removeEventListener("dragstart", stopDrag, true) doc.removeEventListener("dragend", cleanup, true) doc.removeEventListener("mousedown", cleanup, true) doc.removeEventListener("mouseup", cleanup, true) doc.defaultView?.removeEventListener("blur", cleanup) release.current = null } doc.addEventListener("dragstart", stopDrag, true) doc.addEventListener("dragend", cleanup, true) doc.addEventListener("mousedown", cleanup, true) doc.addEventListener("mouseup", cleanup, true) doc.defaultView?.addEventListener("blur", cleanup) release.current = cleanup }} onPointerDown={(event) => { onPointerDown?.(event); isolateTabEvent(event) }} onMouseDown={(event) => { onMouseDown?.(event); isolateTabEvent(event) }} onTouchStart={(event) => { onTouchStart?.(event); isolateTabEvent(event) }} onClick={(event) => { onClick?.(event); isolateTabEvent(event) }} onDragStart={(event) => { onDragStart?.(event) // Draggable children own their data and default action; only the surrounding dock is excluded. if (event.currentTarget.contains(event.target as Node)) event.stopPropagation() }} onKeyDown={(event) => { onKeyDown?.(event) // The dock dismisses at the window even when a nested control handled the key. A portaled // menu gets its own Escape; Escape on an ordinary overflow control still closes the dock popup. if (tabLocation === "headerOverflow" && (event.key === "Enter" || (event.key === "Escape" && (event.defaultPrevented || !event.currentTarget.contains(event.target as Node))))) event.stopPropagation() }} /> } function DefaultWorkspaceTab() { return <WorkspaceTab><WorkspaceTabTitle /><WorkspaceTabClose>×</WorkspaceTabClose></WorkspaceTab> } 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 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 a key. Palette actions registered with that scope are offered when // the palette opens from inside a book; with several books, register them with // `within: useHotkeyScope()` so the book that held focus is the one that runs. 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() } // The consumer's handler runs first, and cancelling it lets the press reach the header's drag. // Only a cancellation the handler itself adds opts out: a press an ancestor capture already // prevented still stays away from the drag. function guardFromDrag<E extends SyntheticEvent>(own?: (event: E) => void) { return (event: E) => { const alreadyPrevented = event.defaultPrevented own?.(event) if (alreadyPrevented || !event.defaultPrevented) 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 unless your handler cancels. */ export function PanelActions({ className, onPointerDown, onMouseDown, onTouchStart, ...props }: ComponentProps<"div">) { return <div className={cn("ml-auto flex items-center gap-0.5", className)} {...props} onPointerDown={guardFromDrag(onPointerDown)} onMouseDown={guardFromDrag(onMouseDown)} onTouchStart={guardFromDrag(onTouchStart)} /> } 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, onPointerDown, onMouseDown, onTouchStart, ...props }: LinkGroupDotProps) { const name = group === null ? "Not linked" : `Link group ${group}` return ( <button type="button" onPointerDown={guardFromDrag(onPointerDown)} onMouseDown={guardFromDrag(onMouseDown)} onTouchStart={guardFromDrag(onTouchStart)} 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, the opener going away, or the reveal after an `Activity` hide closed the window. */ 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) { // A window that died between renders reads as closed: the host goes back to the // slot instead of into a dead document. const target = popout && !popout.closed ? popout : null host.dataset.popout = target ? "open" : "closed" host.style.cssText = target ? "display:block;height:100vh" : "display:contents" if (target) target.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]) // Every window whose close has been reported, so no path reports one twice — // a close in the opening commit, or a close-open-close chain ahead of the // reconcile, included. const closeReported = useRef<WeakSet<Window>>(new WeakSet()) const finish = useCallback((target: Window) => { if (live.current !== target) return live.current = null closeReported.current.add(target) 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 }, [], ) // Effect cleanup is not unmount: an Activity hide (or Fast Refresh) runs the cleanup // above and closes the window while this hook's state survives. Reconcile on revival, // so isOpen tells the truth and close() stops returning early on a null handle. Only a // null live handle reconciles — an open() that ran earlier in the same revival, from a // child effect or a keypress in the transition gap, already owns live and must not be // clobbered — and a dead window that is still the live one goes through finish, the // same path its close listener would take. useEffect(() => { if (!popout) return if (live.current === null) { // The hide's cleanup closed the window without the close listener firing: // complete the lifecycle so every onOpen still meets its onClose. setPopout(null) if (!closeReported.current.has(popout)) { closeReported.current.add(popout) latest.current.onClose?.() } } else if (live.current === popout && popout.closed) finish(popout) else if (live.current !== popout && popout.closed && !closeReported.current.has(popout)) { // A newer window took over during the reveal — a child's effect or a keypress in // the gap — and state will settle on it. Complete the dead one's pair here, once. closeReported.current.add(popout) latest.current.onClose?.() } }, [popout, finish]) 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, useInsertionEffect, useLayoutEffect, 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) const EMPTY_DECLARED: ReadonlySet<string> = new Set() const EMPTY_DECLARED_MAP: ReadonlyMap<HotkeyRegistry, ReadonlySet<string>> = new Map() const DeclaredContext = createContext<ReadonlyMap<HotkeyRegistry, ReadonlySet<string>>>(EMPTY_DECLARED_MAP) 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 // Visible during render, before any effect runs: a child that supplies a default for an id // the consumer declares here must see the declaration on its first pass, not an effect later. // Keyed by registry, so a nested provider with its own registry inherits nothing it doesn't own. const parentDeclared = useContext(DeclaredContext) const declared = useMemo(() => { if (!bindings?.length) return parentDeclared const next = new Map(parentDeclared) next.set(value, new Set([...(parentDeclared.get(value) ?? EMPTY_DECLARED), ...bindings.map((binding) => binding.id)])) return next }, [parentDeclared, bindings, value]) 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}><DeclaredContext.Provider value={declared}>{children}</DeclaredContext.Provider></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) } /** * Ids the surrounding providers declare through `bindings` for one registry — the nearest * provider's unless another is given — visible during render before any effect registers them. * Returns an empty set without a provider. */ export function useDeclaredHotkeyIds(registry?: HotkeyRegistry | null): ReadonlySet<string> { const map = useContext(DeclaredContext) const nearest = useContext(RegistryContext) const key = registry ?? nearest return (key && map.get(key)) || EMPTY_DECLARED } 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) // A commit no key caused, such as a push that revokes an action, runs every layout effect before the passive ones, // and a key dispatched in between must reach the handler that commit rendered, or none. So the handler is swapped // before any layout effect runs, and the binding detaches in the commit that disables or unmounts it. useInsertionEffect(() => { latest.current = handler }) useLayoutEffect(() => { 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) } /** * The nearest HotkeyScope's element getter, or null outside one. Hand it to a scoped * palette action's `within`, so the panel that answers is the one containing the * captured focus — the scope element itself included, since clicking a panel focuses it. */ export function useHotkeyScope(): (() => Element | null) | null { const scope = useContext(ScopeContext) return scope ? scope.element : null } 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. Default ids are * reused once a panel closes, so give a panel that keeps such data an id of its own. */ 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 a top-level toJSON decides what is stored, as it does for JSON. When JSON cannot walk the whole value, what it can walk is kept key by key — a cycle or a throwing getter drops alone and never wipes the rest of the state — unless the value has a top-level toJSON at all, since its raw fields are what it chose to hide. */ export function toPanelState(value: unknown): WorkspacePanelState { if (!isObject(value)) return {} try { const text = JSON.stringify(value) // A top-level toJSON that returns undefined, a function, or a symbol stores nothing. if (text === undefined) return {} const stored: unknown = JSON.parse(text) return isObject(stored) ? (stored as WorkspacePanelState) : {} } catch { // A value with a toJSON, own or inherited, owns its serialization: when JSON // could not use it, store nothing rather than the fields it hides. Existence is // checked, not the value, so a stateful getter never gets a second, different say. try { if ("toJSON" in value) return {} } catch { return {} } } let keys: string[] try { keys = Object.keys(value) } catch { return {} } const pairs: Array<[string, WorkspaceJson]> = [] for (const key of keys) { try { // The read runs inside the guard: a throwing getter drops its own key only. const entry: unknown = (value as Record<string, unknown>)[key] // Serialized under its own key, so a nested toJSON receives the key JSON's // whole pass would give it; a value JSON omits leaves the wrapper empty. const stored = JSON.parse(JSON.stringify({ [key]: entry })) as Record<string, WorkspaceJson> if (!Object.prototype.hasOwnProperty.call(stored, key)) continue // Collected as pairs: assigning out[key] would hand a "__proto__" key to the // prototype setter instead of keeping it an ordinary own key, as JSON does. pairs.push([key, stored[key] as WorkspaceJson]) } catch { continue } } return Object.fromEntries(pairs) } 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 // The dock keys its panels by plain objects, so an id that names an Object.prototype member breaks it, and a record // under `__proto__` would set this object's prototype: such a layout is not one the workspace can show. if (Object.keys(dock.panels).some(isReservedPanelId)) 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() } /** Whether an id names an `Object.prototype` member (`__proto__`, `constructor`, `toString`, …), which no panel can carry: the dock keys its panels by plain objects. */ export function isReservedPanelId(id: string): boolean { return id in Object.prototype } /** `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 while focus is inside it: only scopes declared // inside it are active. Keys typed in the gap before a dialog takes focus land on the page, // where single-key bindings still run — the command palette's early capture shows the fix. 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 a readable one, else the binding's own. */ keys: string defaultKeys: string /** The keys exactly as the binding declared them, before normalization: `"mod+k"` stays `"mod+k"`. */ declaredKeys: string /** True while an override is stored for the id, one that fails to parse or matches the defaults included. */ 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 /** * Declare a component's built-in binding. It lists and dispatches like a registration until a * consumer registers the id — the registration shadows it, and unregistering brings it back — * and it stands while any declarer holds it. Returns the release. */ declareDefault(binding: HotkeyBinding): () => 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: "" } // The canonical form writes the + key as itself, so "ctrl++" and a bare "+" must read back. // Only the canonical shape qualifies: a malformed "ctrl+++" still fails instead of quietly // becoming a live binding from a corrupted stored override. let rest = part let plusKey = false if (rest === "+") { plusKey = true rest = "" } else if (rest.endsWith("++") && rest.length > 2 && rest.slice(0, -2).split("+").every(Boolean)) { plusKey = true rest = rest.slice(0, -2) } for (const token of rest ? rest.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 (plusKey) { if (step.key) throw new Error(`hotkeys: "${part}" names two keys; separate the steps of a chord with a space`) step.key = "+" } 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() // A timer runs at once for 0, NaN, Infinity, or anything past 2^31 - 1 ms, and a chord could never finish: those read as the default. const given = options.chordTimeoutMs const chordTimeoutMs = typeof given === "number" && given >= 1 && given <= 2_147_483_647 ? given : 1000 const recs = new Map<string, Rec>() // Component defaults: each declaration is held individually beside the live records, installed // only while no consumer registration shadows the id — the earliest held declaration is the // one in force. Tokens, not the bindings, carry identity: two declarers may pass one shared // module constant. `defaultIds` marks the ids whose live record is the default. const defaults = new Map<string, { binding: HotkeyBinding }[]>() const defaultIds = new Set<string>() 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 declaredSteps = parseKeys(binding.keys, platform) let steps = declaredSteps 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 = declaredSteps } } 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 { // 229 is a key an input method takes, which Safari sends before isComposing says so. if (event.defaultPrevented || event.isComposing || event.keyCode === 229 || 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 — though a // consumer registering over a default claims the id either way. if (previous && !handler && sameBinding(previous.binding, binding)) { defaultIds.delete(binding.id) return conflictsFor(binding.id) } // Built before any marker moves: a throw on malformed keys must leave ownership untouched. const next = build(binding, handler ?? previous?.handler) defaultIds.delete(binding.id) recs.set(binding.id, next) clearPending() emit() return conflictsFor(binding.id) }, unregister(id) { // A default is not removable here — only shadowed or released; `remap(id, "")` unbinds // its keys. Removing-and-reinstalling would wake subscribers with an unchanged list, and // a subscriber that unregisters whatever it sees listed would recurse forever. if (!recs.has(id) || defaultIds.has(id)) return // The earliest held default resurfaces when the consumer registration leaves — replaced // in place, since the record's position carries conflict resolution and list order. const held = defaults.get(id) if (held?.[0]) { recs.set(id, build(held[0].binding)) defaultIds.add(id) } else { recs.delete(id) } clearPending() emit() }, declareDefault(binding) { if (!binding.id) throw new Error("hotkeys: a binding needs an id") // Built before the declaration is held — shadowed or not — so malformed keys throw here // and never wait inside the held list to break a later resurface. const rec = build(binding) const installing = !recs.has(binding.id) const token = { binding } const held = defaults.get(binding.id) if (held) held.push(token) else defaults.set(binding.id, [token]) if (installing) { recs.set(binding.id, rec) defaultIds.add(binding.id) clearPending() emit() } return () => { const current = defaults.get(binding.id) if (!current) return const at = current.indexOf(token) if (at < 0) return current.splice(at, 1) if (current.length === 0) defaults.delete(binding.id) if (!defaultIds.has(binding.id)) return const next = current[0] if (next === undefined) { defaultIds.delete(binding.id) recs.delete(binding.id) clearPending() emit() } else if (!sameBinding(recs.get(binding.id)!.binding, next.binding)) { // A remaining declarer's own binding takes over from the released one's. recs.set(binding.id, build(next.binding)) 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) // Remapped means an override is stored, not that the resolved keys differ: a corrupt // override falls back to the default keys, and Reset must still be able to clear it. return { ...rec.binding, keys, defaultKeys, declaredKeys: rec.binding.keys, remapped: overrides.has(rec.binding.id) } })) }, 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.75rem; --dv-tab-group-line-height: 2px; --dv-tab-group-line-opacity: 0.6; } .dockview-theme-tradecn .dv-resize-container { --dv-overlay-z-index: inherit !important; } } :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, WorkspaceTab, WorkspaceTabClose, WorkspaceTabTitle } 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}
tabComponent={DeskTab}
watermark="No panels."
seed={(api) => {
const orders = api.addPanel({ kind: "orders", title: "Orders" })
api.addPanel({ kind: "positions", title: "Positions", position: { reference: orders, direction: "right" } })
}}
/>
)
}
export function DeskTab() {
return (
<WorkspaceTab>
<WorkspaceTabTitle />
<WorkspaceTabClose>×</WorkspaceTabClose>
</WorkspaceTab>
)
}
Pass a tabComponent to arrange tab contents. Omitting it keeps the same title-and-close composition. Save this example as an application workspace.tsx outside components/ui; the examples below import its DeskTab export.
Keep panels stable with a module constant or useMemo; replacing it re-renders every panel. Define tabComponent outside render so its local state survives parent updates. Give the workspace a height and let it fill the available width. workspace.tsx imports dockview's stylesheet.
Composition
Workspace
├── WorkspaceTab
│ ├── WorkspaceTabTitle
│ ├── WorkspaceTabClose
│ └── WorkspaceTabActions
└── 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.
Tab controls
Use WorkspaceTabActions for inputs and menus that keep their own focus. useWorkspaceTab reads the same saved state as the panel body. This layout places a menu before the title and a symbol field after it; overflow entries omit the field.
Install shadcn dropdown-menu alongside Workspace before copying this example. The example is self-contained. For menus in separate windows, see the popout portal limits.
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
import { PanelContent } from "@/components/ui/panel"
import { Workspace, WorkspaceTab, WorkspaceTabActions, WorkspaceTabTitle, useWorkspacePanel, useWorkspaceTab } from "@/components/ui/workspace"
function Quotes() {
const panel = useWorkspacePanel()
return <PanelContent className="p-3">Following {String(panel.state.symbol ?? "no symbol")}.</PanelContent>
}
const PANELS = { quotes: Quotes }
export default function WorkspaceTabControlsDemo() {
return (
<Workspace
className="isolate h-64 rounded-md border border-border"
panels={PANELS}
tabComponent={QuoteTab}
watermark="No panels."
seed={(api) => {
const first = api.addPanel({ kind: "quotes", title: "Treasuries", state: { symbol: "ZN" }, focus: false })
api.addPanel({ kind: "quotes", title: "Equities", state: { symbol: "ES" }, position: { reference: first, direction: "within" }, focus: false })
}}
/>
)
}
export function QuoteTab() {
const panel = useWorkspaceTab()
return (
<WorkspaceTab className="gap-2">
<WorkspaceTabActions>
<DropdownMenu>
<DropdownMenuTrigger aria-label={`Actions for ${panel.title}`} className="inline-flex size-5 items-center justify-center rounded-sm outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring/50">…</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem disabled={panel.location !== "grid"} onClick={() => panel.float()}>Float panel</DropdownMenuItem>
<DropdownMenuItem disabled={panel.location !== "grid"} onClick={panel.toggleMaximize}>Maximize / restore</DropdownMenuItem>
<DropdownMenuItem onClick={panel.close}>Close panel</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</WorkspaceTabActions>
<WorkspaceTabTitle />
{panel.tabLocation === "header" && <WorkspaceTabActions>
<input
aria-label={`Symbol for ${panel.title}`}
className="h-6 w-12 rounded border border-input bg-background px-1 text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
value={String(panel.state?.symbol ?? "")}
onChange={(event) => panel.setState({ symbol: event.target.value || undefined })}
/>
</WorkspaceTabActions>}
</WorkspaceTab>
)
}
Saved layouts
Use defaultLayout to restore the dock arrangement and saved panel state. This example saves to browser storage and imports DeskTab from the Usage file.
Mount this example on the client; it reads localStorage. Drag tabs to an edge to split panels, or onto another tab to combine them. After editing a symbol or arrangement, wait for the save count to increase before reloading. See Saving is yours for debounce and page-close limits.
import { DeskTab } from "./workspace"
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
tabComponent={DeskTab}
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. This example accepts ZN, ZB, and ES and imports DeskTab from the Usage file.
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 { DeskTab } from "./workspace"
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
tabComponent={DeskTab}
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
Bind application commands through HotkeysProvider. Here, r refreshes the focused panel, ] and [ change panels, n q adds one, and w closes it. Copy DeskTab from the Usage file first.
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 { DeskTab } from "./workspace"
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
tabComponent={DeskTab}
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
Use the panel handle to float, pop out, or maximize a panel. Its local note survives these moves; resetting the layout creates fresh panels. This example imports DeskTab from the Usage file.
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 { DeskTab } from "./workspace"
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 tabComponent={DeskTab} 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 |
Panel components | Required | Components indexed by panel kind, read as own keys — an inherited or proxied kind draws the unregistered placeholder. |
tabComponent |
ComponentType |
Title and close | Contents of each tab, including overflow entries. |
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. A value that isn't a finite number at or above zero reads as the default, and one above 2,147,483,647, the longest delay a timer takes, reads as that. |
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. Includes the outer div ref. |
Tab parts
Import tab parts and useWorkspaceTab from @/components/ui/workspace. Render them inside the component passed to tabComponent.
| Part | Element | Purpose |
|---|---|---|
WorkspaceTab |
div |
Required children; clicking focuses the panel unless onClick calls preventDefault(). |
WorkspaceTabTitle |
span |
Current dock title; accepts no children. |
WorkspaceTabClose |
button |
Required children; closes the panel unless onClick calls preventDefault(). Defaults to type="button" and the name Close <title>. |
WorkspaceTabActions |
div |
Required children; prevents nested controls from activating, focusing, or dragging the tab. |
Each part accepts its native props, ref and class name. An explicit aria-label or aria-labelledby replaces the close button's generated name. Wrap custom inputs and menu triggers in WorkspaceTabActions; child handlers run before its bubbling event guards. A plain WorkspaceTabClose preserves the default tab's pointer activation before closing. Put it inside Actions to keep an inactive panel inactive.
Actions stops contained pointer-down, mouse-down, touch-start, click and drag-start events before they reach the dock. Document and window bubble listeners also miss those events; capture listeners still receive them. Clicking a header action leaves an existing dock overflow popup open.
In overflow, Actions also stops pointer-down, mouse-down, touch-start and click events from portaled content. It stops Enter bubbling, and stops Escape when a control has prevented it or it comes from a portal. An unhandled Escape from an ordinary overflow control can close the dock popup.
Dockview owns the outer role="tab", its id, roving focus, selection and tabpanel association. WorkspaceTab props and ref address the inner div. Rename the actual tab with setTitle; an aria-label on that inner div does not rename the outer tab. Ordinary title and close parts subscribe only to title changes.
Roving focus applies to the outer tabs. Close buttons and controls inside WorkspaceTabActions keep their native Tab stops, including on inactive tabs; Actions isolates events, not keyboard navigation. For the APG tabs pattern, where Tab leaves the tablist from its active tab, omit inline controls and put panel actions or fields in PanelHeader or the panel body.
useWorkspaceTab
The hook returns WorkspaceTabHandle: the panel handle, plus focus(): void and tabLocation: "header" | "headerOverflow". focus() activates the panel and moves focus into its registered workspace body. The hook subscribes to this panel's record, title, activity and location; static tab markup needs no hook.
Create panels with WorkspaceApi.addPanel to register their kind, saved state and body. A panel added directly through the raw Dockview API has no workspace record: kind and state are undefined, setTitle and setState do nothing, and a state updater is not called. Its focus() activates the panel but does not move keyboard focus into a body. Dock readings and close, float, popout and maximize commands still work; use the raw panel's api.setTitle to rename it.
A panel can have header and overflow renderers mounted at once. Use useId() for control/label ids, and keep shared values in panel state. Component-local state belongs to each rendered tab and resets when that instance unmounts. Closing overflow disposes its renderers, subscriptions and consumer effects. For managed panels, use setTitle to keep the record, dock, tab name and overflow title synchronized; a direct raw Dockview title write is overwritten by the next workspace store write.
In Dockview 8.3.1, removing a panel leaves an empty overflow row until the popup closes. A standalone WorkspaceTabClose lets the native row dismiss the popup. Use Space to activate that button in overflow. Dockview handles Enter by dismissing the popup before the button can act.
Panels by kind
panels is a Record<string, ComponentType<WorkspacePanelProps>>, indexed 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. The default tab shows 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 and uses the same tab component, so older layouts still open. The default tab retains its close button. 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 the id you pass is already open, it leaves that panel's title and state unchanged and focuses it — unless focus: false, which leaves both the active panel and the keyboard alone, so an ensure-open call cannot steal from the panel the trader is in. It throws when the dock rejects the options — an unknown reference, say — and discards the new record. A throw inside seed is not caught: it propagates out of the dock's ready callback, and onReady never fires. With the default focus: true, the last panel added during seed that asks for focus takes keyboard focus when it mounts, and a failing addPanel leaves the request it found in place; pass focus: false while seeding to leave focus where it is, as the tab-controls demo does.
| Option | Type | Default | Purpose |
|---|---|---|---|
kind |
string |
Required | Selects the registered component. |
id |
string |
Lowest free <kind>-N, starting at 1 |
Identifies the panel. An id that names an Object.prototype member, such as constructor or __proto__, gets the default instead, since the dock keys its panels by plain objects. Keep the id addPanel returns: asking for such an id again opens another 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. Without reference, the direction is relative to the whole dock. |
floating |
boolean | WorkspaceBox |
false |
Opens floating; takes precedence over position. |
focus |
boolean |
true |
Moves keyboard focus into the new panel after rendering; the dock still makes a new panel active either way. On an id that is already open, false changes nothing at all. |
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, whole when it walks and key by key when it does not. Function-valued object properties disappear, dates become strings, and a value serialization rejects — a cycle or a throwing getter inside a value, say — is dropped alone, keeping the rest of the state. A top-level toJSON decides what is stored: when it returns anything but an object JSON can store, or throws, nothing is stored rather than the fields it hides. setState spreads its patch into the current state before cleaning it, so the rule applies to the merged object: a throwing getter on the patch object itself throws from setState, a toJSON of the patch's own decides the whole merged state, and an inherited one, such as a class method, is skipped by the spread. 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. |
Default ids are reused: once ticket-1 closes, the next ticket opened is ticket-1 again, so data you store by panel id outlives its panel and reaches the next one. Give a panel that keeps data outside the layout an id of its own when you open it, such as ticket-${orderId}.
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, refuses a panel id that names an Object.prototype member (isReservedPanelId(id) tells you whether an id does, and the dock's own addPanel throws on one), 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. The dock reports a resize drag on every pointer move, and the debounce folds those into one save unless the drag pauses longer than layoutChangeDelay. 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 ordinary header tab contents does the same. For a popped-out panel it focuses the panel's element in the popout but doesn't raise that window. 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.
For newly created panels, addPanel's default focus: true waits for the panel to render. Explicit focus calls in onReady or a child's first layout effect can activate a panel before its body has registered a focus target.
When the outer tab itself has focus, arrows and Home/End move focus without selecting; Enter/Space select, and Delete/Backspace close and focus a neighbor. Nested controls keep their own keys. Closing with the ordinary close button or selecting the already-active panel from overflow can leave focus on the document body. Omitting a close button does not disable keyboard or API closing. Dockview 8.3.1's overflow opener is not a keyboard button; tab composition does not replace that control.
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 its record's layoutId — several windows can share one layout, which then needs coordinated writes; see Desktop shells and window-set, which provides the window tracking. The shell 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 start at z-index 30; floating groups increase that value, and their overflow popups use twice the group's value. The tab-controls example adds isolate to Workspace, keeping those native layers in the workspace's stacking context while its portaled menus render above them. Use the same setup when composing tab menus, and choose the workspace's position in your application's layers.
Set --dv-overlay-z-index on .dockview-theme-tradecn to change the base. The floating-container rule replaces Dockview's self-referencing declaration with the root value. 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, with the mono font token, the accessible pair, and the hyperlegible remap.
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. Raw panels are not listed. |
activePanel() |
string | null |
Returns the active id, a raw panel's included, 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. Create every persisted panel with WorkspaceApi.addPanel: a raw panel has no workspace record, but still appears in toLayout()'s dock tree. That incomplete layout fails parseWorkspaceLayout and cannot be restored; load clears the workspace and reports failure. Raw panels need application-owned persistence.
Workspace owns the dock's lifetime. Unmount it to dispose the dock and its subscriptions; do not call api.dockview.dispose() while the component remains mounted.
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 v2.0.0.