diff --git a/WORKSPACES-NOTES.md b/WORKSPACES-NOTES.md new file mode 100644 index 00000000..3a05828c --- /dev/null +++ b/WORKSPACES-NOTES.md @@ -0,0 +1,78 @@ +# Workspaces extraction — phase 1 + +Branch `workspaces-extraction`. The Workspace/Panel framework is now `src/workspaces/workspaces`, a package +that knows nothing about Officer. + +## Where it got to + +**The package renders under a bare React tree.** Its own test suite (114 tests) mounts `WorkspaceView` with +no provider of any kind — no react-query, no router, no Officer hooks. That is the proof: a host needs to +supply a registry and a layout, and nothing else. + +**Two halves.** `core/` is the tree and every operation on it — pure, dependency-free, no React. `react/` +is one renderer. Phase 2 is a second renderer over the same core. + +**Officer is unchanged.** All 24 `WorkspaceView` sites and 8 `WorkspaceLayout` sites still call exactly +what they called before. `officerdev/.../Workspace/index.ts` is now an adapter that supplies the three +things that are Officer's: the app registry, fullscreen persistence in sessionStorage, and the handshake +that hides the nav header for a fullscreen panel. + +**Persistence was already injected.** `WorkspaceView` always received `{ key, value, setValue, isLoaded }` +as a prop and called `setValue` with an updater. The headline requirement of phase 1 was mostly already +true — the coupling was elsewhere. + +**~175 lines of dead code deleted**: `DragOverlay`, `LayoutEditor`, `WorkspaceHeader`, referenced by +nothing. + +## What actually had to move + +| Coupling | Resolution | +|---|---| +| `useAppRegistry` | `registry` is now a required prop | +| `useSessionState` (fullscreen) | controlled/uncontrolled pair; Officer passes its sessionStorage state | +| `panel-fullscreen` / `useGlobal` | the package reports via callback; Officer acts | +| `use-panel-memory` / `useDashboardState` | stayed in Officer — it is host storage, not layout | +| `@/components/{Card,ErrorBoundary,ui/*}` | vendored into `react/internal/` | +| `hooks/useIsMobile` | vendored (27 lines) | +| `helpers/cn`, `@/lib/utils` | vendored (2 lines) | + +## Two traps, both worth knowing before touching dependencies + +**A second React.** Declaring `react`/`react-dom` in the package produced a second copy under +`src/workspaces/workspaces/node_modules`. Two React instances break hooks at runtime and are invisible +until something renders. While this lives in the monorepo the package declares almost nothing and inherits +the root's; publishing standalone means declaring them as peers, aligned with the host. + +**Two versions of `react-resizable-panels`.** The root asks for `^4.0.15` (4.6.5); `src/workspaces/components` +asks for `^2.1.7` (2.1.9). This code was written against 2.x, whose API differs — v4 has no `PanelGroup` +export, no `order` prop, no `ref` on `Panel`. Inheriting the root's resolution produced dozens of type +errors that read like a broken extraction and were a version mismatch. The package pins `^2.1.7`. + +Worth resolving properly at some point: one repository, two majors of the same library. + +## Open + +**1. Panel-to-panel communication.** `usePanelChannel` is a two-line wrapper over `useGlobal`, so it is +react-query underneath. Six channels exist; three are genuine refresh signals (`files:refresh-signal`, +`preview:refresh`, `background-job:changed`) and two are SELECTION (`chat:selected-session`, +`chat:active-session`), which `docs/navigation-audit.md` says belongs in the URL. Deferred by decision: the +mechanics will live in the package, configured from outside, probably as callbacks. The real requirement +may be smaller than it looks. + +**2. The package still speaks Officer's theme.** `PanelSlot` uses `duck-teal` classes and `--panel-*` CSS +variables. Harmless inside Officer, wrong for a standalone module: a consumer would need those variables +defined. Options are to accept a class-name map, ship a minimal stylesheet, or document the variables as +part of the contract. + +**3. `cwd`/`root` are in the context.** They are Officer concepts (a workspace points at a directory) that +the package currently carries. Not harmful, not general. + +**4. Phase 2.** A non-React renderer over `core`. What will not port is the resize *interaction* — drag +handles, pointer maths, constraints — supplied today by `react-resizable-panels`. Either find a vanilla +equivalent or write one; the tree logic comes across untouched, which was the point of the split. + +## Verifying + +`bun test src/workspaces/workspaces/` — 114 tests, no host. +Full suite unchanged at 950 pass / 7 fail. Screenshots: 9 of 16 byte-identical to before the extraction, +the rest being live data (chat sessions, system metrics) and the rotating wallpaper. diff --git a/bun.lock b/bun.lock index c8fd65d1..eca1e943 100644 --- a/bun.lock +++ b/bun.lock @@ -135,6 +135,7 @@ "varuint-bitcoin": "^2.0.0", "vaul": "^1.1.2", "widgets": "workspace:*", + "workspaces": "workspace:*", "ws": "^8.18.1", "zod": "^4.2.1", }, @@ -298,6 +299,13 @@ "src/workspaces/widgets": { "name": "widgets", }, + "src/workspaces/workspaces": { + "name": "workspaces", + "version": "0.1.0", + "dependencies": { + "react-resizable-panels": "^2.1.7", + }, + }, }, "packages": { "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], @@ -2360,6 +2368,8 @@ "wif": ["wif@5.0.0", "", { "dependencies": { "bs58check": "^4.0.0" } }, "sha512-iFzrC/9ne740qFbNjTZ2FciSRJlHIXoxqk/Y5EnE08QOXu1WjJyCCswwDTYbohAOEnlCtLaAAQBhyaLRFh2hMA=="], + "workspaces": ["workspaces@workspace:src/workspaces/workspaces"], + "wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], "wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], @@ -2640,6 +2650,8 @@ "wif/bs58check": ["bs58check@4.0.0", "", { "dependencies": { "@noble/hashes": "^1.2.0", "bs58": "^6.0.0" } }, "sha512-FsGDOnFg9aVI9erdriULkd/JjEWONV/lQE5aYziB5PoBsXRind56lh8doIZIc9X4HoxT5x4bLjMWN1/NB8Zp5g=="], + "workspaces/react-resizable-panels": ["react-resizable-panels@2.1.9", "", { "peerDependencies": { "react": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-z77+X08YDIrgAes4jl8xhnUu1LNIRp4+E7cv4xHmLOxxUPO/ML7PSrE813b90vj7xvQ1lcf7g2uA9GeMZonjhQ=="], + "wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "wrap-ansi-cjs/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], diff --git a/package.json b/package.json index f1dd1328..c46e626b 100644 --- a/package.json +++ b/package.json @@ -158,6 +158,7 @@ "varuint-bitcoin": "^2.0.0", "vaul": "^1.1.2", "widgets": "workspace:*", + "workspaces": "workspace:*", "ws": "^8.18.1", "zod": "^4.2.1" }, diff --git a/plugins/example b/plugins/example new file mode 120000 index 00000000..c084a6df --- /dev/null +++ b/plugins/example @@ -0,0 +1 @@ +/home/pastilhas/officer-dev/platform/plugins/example \ No newline at end of file diff --git a/plugins/music b/plugins/music new file mode 120000 index 00000000..408d95d6 --- /dev/null +++ b/plugins/music @@ -0,0 +1 @@ +/home/pastilhas/officer-dev/platform/plugins/music \ No newline at end of file diff --git a/plugins/offscale b/plugins/offscale new file mode 120000 index 00000000..9f9e305f --- /dev/null +++ b/plugins/offscale @@ -0,0 +1 @@ +/home/pastilhas/officer-dev/platform/plugins/offscale \ No newline at end of file diff --git a/src/workspaces/officerdev/src/apps/Terminal/state-key.test.ts b/src/workspaces/officerdev/src/apps/Terminal/state-key.test.ts new file mode 100644 index 00000000..14cc7f48 --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Terminal/state-key.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, test } from 'bun:test'; +import { parseWorkspaceKey } from 'workspaces/core'; +import { terminalStateKey } from './state-key'; + +// Split out of the workspace identity tests when the layout framework became its own package. Parsing a +// workspace key is the package's; what a TERMINAL does with the result is Officer's, and a package test +// that imports an Officer app is a package that is not extracted. + +describe('terminalStateKey', () => { + // The regression this rule exists for: a wrapper that passed the raw key through produced + // `ws-host-terminals-ws-layout-my-dash`, which the server parsed back out as a dashboard id and created. + test('strips the layout prefix rather than nesting it', () => { + const key = terminalStateKey('host-terminals', parseWorkspaceKey('ws-layout-my-dash')); + expect(key).toBe('ws-host-terminals-my-dash'); + expect(key).not.toContain('ws-layout'); + }); + + test('screens and unknown workspaces share the defaults row', () => { + expect(terminalStateKey('terminals', parseWorkspaceKey('screens/chat'))).toBe('ws-terminals-default'); + expect(terminalStateKey('terminals', null)).toBe('ws-terminals-default'); + }); + + test('carries the panel-state prefixes the dispatcher allow-lists', () => { + const dashboard = parseWorkspaceKey('ws-layout-d1'); + expect(terminalStateKey('tmux', dashboard)).toBe('ws-tmux-d1'); + expect(terminalStateKey('nvim', dashboard)).toBe('ws-nvim-d1'); + expect(terminalStateKey('claude-code', dashboard)).toBe('ws-claude-code-d1'); + }); +}); diff --git a/src/workspaces/officerdev/src/components/Workspace/DragOverlay.tsx b/src/workspaces/officerdev/src/components/Workspace/DragOverlay.tsx deleted file mode 100644 index 0de1dff7..00000000 --- a/src/workspaces/officerdev/src/components/Workspace/DragOverlay.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; -import type { DropPosition } from './layout-utils'; -import { useWorkspace } from './WorkspaceContext'; - -type DropTarget = { - panelId: string; - zone: DropPosition; - rect: DOMRect; -}; - -function getDropZone(rect: DOMRect, x: number, y: number): DropPosition { - const fx = (x - rect.left) / rect.width; - const fy = (y - rect.top) / rect.height; - - if (fx > 0.3 && fx < 0.7 && fy > 0.3 && fy < 0.7) return 'center'; - - const edges: [DropPosition, number][] = [ - ['left', fx], - ['right', 1 - fx], - ['top', fy], - ['bottom', 1 - fy], - ]; - - return edges.reduce((min, cur) => (cur[1] < min[1] ? cur : min))[0]; -} - -function getIndicatorStyle(rect: DOMRect, zone: DropPosition): React.CSSProperties { - switch (zone) { - case 'center': - return { left: rect.left, top: rect.top, width: rect.width, height: rect.height }; - case 'top': - return { left: rect.left, top: rect.top, width: rect.width, height: rect.height / 2 }; - case 'bottom': - return { left: rect.left, top: rect.top + rect.height / 2, width: rect.width, height: rect.height / 2 }; - case 'left': - return { left: rect.left, top: rect.top, width: rect.width / 2, height: rect.height }; - case 'right': - return { left: rect.left + rect.width / 2, top: rect.top, width: rect.width / 2, height: rect.height }; - } -} - -export const DragOverlay = () => { - const { dragSourceId, setDragSourceId, onMove } = useWorkspace(); - const [target, setTarget] = useState(null); - const panelRectsRef = useRef>(new Map()); - - useEffect(() => { - if (!dragSourceId) { - panelRectsRef.current.clear(); - setTarget(null); - return; - } - const panels = document.querySelectorAll('[data-panel-id]'); - const map = new Map(); - panels.forEach((el) => { - const id = el.dataset.panelId!; - if (id !== dragSourceId) map.set(id, el.getBoundingClientRect()); - }); - panelRectsRef.current = map; - }, [dragSourceId]); - - const handlePointerMove = useCallback((ev: React.PointerEvent) => { - const { clientX: x, clientY: y } = ev; - let found: DropTarget | null = null; - - for (const [panelId, rect] of panelRectsRef.current) { - if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) { - found = { panelId, zone: getDropZone(rect, x, y), rect }; - break; - } - } - - setTarget(found); - }, []); - - const handlePointerUp = useCallback(() => { - if (dragSourceId && target) { - onMove(dragSourceId, target.panelId, target.zone); - } - setDragSourceId(null); - }, [dragSourceId, target, onMove, setDragSourceId]); - - if (!dragSourceId) return null; - - return ( -
- {target && ( -
- )} -
- ); -}; diff --git a/src/workspaces/officerdev/src/components/Workspace/LayoutEditor.tsx b/src/workspaces/officerdev/src/components/Workspace/LayoutEditor.tsx deleted file mode 100644 index 617478a1..00000000 --- a/src/workspaces/officerdev/src/components/Workspace/LayoutEditor.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { Columns, Rows, Trash2, X } from 'lucide-react'; - -type LayoutEditorProps = { - panelId: string; - hasWidget: boolean; - isLastPanel: boolean; - onSplit: (panelId: string, direction: 'horizontal' | 'vertical') => void; - onRemove: (panelId: string) => void; - onClearApp: () => void; -}; - -export const LayoutEditor = ({ panelId, hasWidget, isLastPanel, onSplit, onRemove, onClearApp }: LayoutEditorProps) => ( -
- - - {hasWidget && ( - - )} - {!isLastPanel && ( - - )} -
-); diff --git a/src/workspaces/officerdev/src/components/Workspace/WorkspaceHeader.tsx b/src/workspaces/officerdev/src/components/Workspace/WorkspaceHeader.tsx deleted file mode 100644 index 7d318d08..00000000 --- a/src/workspaces/officerdev/src/components/Workspace/WorkspaceHeader.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { Pencil, Check } from 'lucide-react'; - -type WorkspaceHeaderProps = { - name: string; - editing: boolean; - onToggleEdit: () => void; -}; - -export const WorkspaceHeader = ({ name, editing, onToggleEdit }: WorkspaceHeaderProps) => ( -
-

{name}

- -
-); diff --git a/src/workspaces/officerdev/src/components/Workspace/WorkspaceLayout.tsx b/src/workspaces/officerdev/src/components/Workspace/WorkspaceLayout.tsx index 8eab7a52..4b8797a2 100644 --- a/src/workspaces/officerdev/src/components/Workspace/WorkspaceLayout.tsx +++ b/src/workspaces/officerdev/src/components/Workspace/WorkspaceLayout.tsx @@ -1,53 +1,18 @@ -import { useCallback } from 'react'; -import type { LayoutNode, AppRegistryMap, PanelComponents } from './types'; -import { updateSizes, collectPanelConfigs } from './layout-utils'; -import { WorkspaceProvider, inertInteraction } from './WorkspaceContext'; -import { WorkspaceRenderer } from './WorkspaceRenderer'; +import { WorkspaceLayout as BaseWorkspaceLayout } from 'workspaces/react'; +import type { ComponentProps } from 'react'; import { useAppRegistry } from '../../AppRegistry/useAppRegistry'; -type WorkspaceLayoutProps = { - layout: LayoutNode; - onLayoutChange: (layout: LayoutNode | ((prev: LayoutNode) => LayoutNode)) => void; - registry?: AppRegistryMap; - components?: PanelComponents; - cwd?: string; - noHeader?: boolean; +/** + * The inert workspace — same tree, no interaction — with Officer's registry supplied. + * + * `registry` stays overridable because `DashboardPreview` passes a curated one: a preview shows a template, + * not whatever this install happens to have. + */ +type OfficerWorkspaceLayoutProps = Omit, 'registry'> & { + registry?: ComponentProps['registry']; }; -const noop = () => {}; - -// No mobile props. This renderer had `isMobile`/`mobilePanelId`/`onMobileBack` pass-throughs that none of -// its ten call sites ever set, so the mobile collapse they feed was permanently off here — and being -// optional, that read as a feature rather than the dead wiring it was. `WorkspaceView` is where mobile -// collapse actually happens, and it passes those to `WorkspaceRenderer` itself. -export const WorkspaceLayout = ({ layout, onLayoutChange, registry: registryProp, components, cwd, noHeader }: WorkspaceLayoutProps) => { - const { registry: globalRegistry } = useAppRegistry(); - const registry = registryProp ?? globalRegistry; - - // An updater, for the same reason `WorkspaceView`'s eight mutations are: `WorkspaceRenderer` debounces - // this by 500 ms, so a tree computed here is 500 ms stale by the time it is written. - const handleResized = useCallback( - (groupId: string, sizes: number[]) => { - onLayoutChange((prev) => updateSizes(prev, groupId, sizes)); - }, - [onLayoutChange], - ); - - // No workspace identity: this is the locked/preview renderer, used for job detail panes, settings and - // dashboard previews. It is not a dashboard and not a screen, and a panel that asks should be told so - // rather than handed something that parses. - return ( - - - - ); +export const WorkspaceLayout = ({ registry: registryProp, ...props }: OfficerWorkspaceLayoutProps) => { + const { registry } = useAppRegistry(); + return ; }; diff --git a/src/workspaces/officerdev/src/components/Workspace/WorkspaceView.tsx b/src/workspaces/officerdev/src/components/Workspace/WorkspaceView.tsx index ba0e9620..b5984377 100644 --- a/src/workspaces/officerdev/src/components/Workspace/WorkspaceView.tsx +++ b/src/workspaces/officerdev/src/components/Workspace/WorkspaceView.tsx @@ -1,390 +1,46 @@ -import { useState, useCallback, useEffect, useRef, useMemo, type ComponentRef } from 'react'; -import { flushSync } from 'react-dom'; -import { ResizablePanel, ResizablePanelGroup, ResizableHandle } from '@/components/ui/resizable'; -import { useIsMobile } from 'hooks/useIsMobile'; +import { WorkspaceView as BaseWorkspaceView, type WorkspaceViewProps } from 'workspaces/react'; import { useSessionState } from 'hooks/useSessionState'; -import type { LayoutNode, DashboardState, EphemeralPanels, PanelComponents, PanelConfig } from './types'; -import type { DropPosition } from './layout-utils'; -import { - splitPanel, - removePanel, - setApp, - updateSizes, - swapPanels, - movePanel, - countPanels, - setZoom, - setPanelConfig, - collectPanelConfigs, - findPanelApp, - normalizeLayout, -} from './layout-utils'; -import type { WorkspaceContextValue } from './WorkspaceContext'; -import { WorkspaceProvider } from './WorkspaceContext'; -import { parseWorkspaceKey } from './workspace-identity'; -import { WorkspaceRenderer } from './WorkspaceRenderer'; -import { usePublishPanelFullscreen } from './panel-fullscreen'; import { useAppRegistry } from '../../AppRegistry/useAppRegistry'; +import { usePublishPanelFullscreen } from './panel-fullscreen'; -type WorkspaceViewProps = { - workspace: DashboardState; - locked?: boolean; - /** - * Pin every panel to one of these app types, replacing anything else with `fallback` and writing the - * repair back. A locked screen should always pass this: its layout is persisted user state that - * outlives the code, and an appType the screen no longer renders becomes an empty box the user cannot - * get out of, because a locked screen has no app picker. - * - * This used to be fourteen character-identical `normalizeLayout` functions in fourteen screens, each - * with its own `useMemo` and persist-back `useEffect` — which is how nine screens ended up with no - * guard at all and one with a guard that re-ran forever because it never wrote the correction back. - */ - appTypes?: { allowed: readonly string[]; fallback: string }; - cwd?: string; - root?: string; - components?: PanelComponents; - ephemeral?: EphemeralPanels | null; - mobilePanelId?: string; - onMobilePanelChange?: (id: string | null) => void; -}; +/** + * Officer's workspace: the package, plus the three things it deliberately does not know. + * + * The `workspaces` package renders a layout and reports changes. It has no registry, no storage and no + * opinion about the rest of the page — which is what makes it usable outside Officer. Everything that IS + * about Officer is wired here, once, so all 24 screens keep calling `` + * exactly as before. + * + * Three couplings, and why each is ours rather than the package's: + * + * - **The registry.** `appType` -> component is a fact about what Officer ships, including plugins + * discovered at build time. A layout system cannot know that list. + * - **Fullscreen persistence.** Kept per tab in sessionStorage under `FULLSCREEN_PANEL:`. + * That is a decision about Officer; another host might persist it to a server or not at all. + * - **Standing the shell down.** The nav header hides for a fullscreen panel, because a panel cannot + * paint over it from inside the content region's stacking context. The package reports; we act. + */ +type OfficerWorkspaceViewProps = Omit; -const noop = () => {}; - -export const WorkspaceView = ({ - workspace, - locked, - appTypes, - cwd = '~', - root, - components, - ephemeral, - mobilePanelId, - onMobilePanelChange, -}: WorkspaceViewProps) => { +export const WorkspaceView = (props: OfficerWorkspaceViewProps) => { const { registry } = useAppRegistry(); - const isMobile = useIsMobile(); - // Deliberately not memoised. `normalizeLayout` returns its input by reference when nothing needed - // fixing, so the identity everything downstream depends on is already stable; a `useMemo` here would - // only add a dependency array to get wrong, and the callers pass a fresh object literal for `appTypes` - // anyway, which would defeat it. The walk is a handful of nodes. - const layout = appTypes ? normalizeLayout(workspace.value, appTypes.allowed, appTypes.fallback) : workspace.value; - const onLayoutChange = workspace.setValue; - - // Write the repair back, so a layout is fixed once rather than re-fixed on every mount for the rest of - // its life. Terminates because the normalised tree normalises to itself. - const rawLayout = workspace.value; - useEffect(() => { - if (!workspace.isLoaded || layout === rawLayout) return; - onLayoutChange(layout); - }, [workspace.isLoaded, layout, rawLayout, onLayoutChange]); - const [swapSourceId, setSwapSourceId] = useState(null); - const [dragSourceId, setDragSourceId] = useState(null); - // Per tab and per dashboard: putting the chat panel full screen and refreshing should come back full - // screen, while a second window on the same screen keeps its own idea of what is blown up. A stored id - // whose panel has since been removed simply fills nothing — this is a style toggle on the panel itself, - // so there is nothing to strand. - // // Keyed `FULLSCREEN_PANEL`, not the `MAXIMIZED_PANEL` this used to be. The old key briefly held a - // `{ id, mode }` pair for the two depths; renaming it means a tab open across that change reads nothing - // here rather than reading an object where a string is expected. - const fullscreenKey = `FULLSCREEN_PANEL:${workspace.key}`; - const [fullscreenPanelId, setFullscreenPanelId] = useSessionState(fullscreenKey, null); - const [transitioningPanelId, setTransitioningPanelId] = useState(null); + // `{ id, mode }` pair for two depths; renaming it means a tab open across that change reads nothing here + // rather than reading an object where a string is expected. + const [fullscreenPanelId, setFullscreenPanelId] = useSessionState( + `FULLSCREEN_PANEL:${props.workspace.key}`, + null, + ); - // The shell hides the nav header for it — a panel cannot paint over that from inside the content - // region's stacking context. See `panel-fullscreen.ts`. usePublishPanelFullscreen(fullscreenPanelId !== null); - const setFullscreenAnimated = useCallback( - (id: string | null) => { - const doc = document as Document & { startViewTransition?: (cb: () => void) => { finished: Promise } }; - const panelId = fullscreenPanelId ?? id; - if (doc.startViewTransition && panelId) { - setTransitioningPanelId(panelId); - requestAnimationFrame(() => { - const transition = doc.startViewTransition(() => flushSync(() => setFullscreenPanelId(id))); - transition.finished.finally(() => setTransitioningPanelId(null)); - }); - } else { - setFullscreenPanelId(id); - } - }, - [fullscreenPanelId, setFullscreenPanelId], - ); - - // What each panel wants done when it is closed, by panel id, stamped with the workspace it was - // registered on. A ref rather than state: nothing renders from it, and a panel registering during its - // own mount effect must not schedule a render of the whole workspace. - // - // Nothing is ever unregistered. A handler is dropped when it fires and replaced when the same panel id - // registers again; a panel that merely unmounted keeps its handler, because "unmounted" is precisely - // the signal this exists to avoid trusting. The stamp is what keeps that from leaking across - // dashboards — the same panel id can exist on two of them, and a handler from the one you navigated - // away from must not fire for the one you are on. - const closeHandlers = useRef(new Map void }>()); - const workspaceKeyRef = useRef(workspace.key); - workspaceKeyRef.current = workspace.key; - - const registerPanelClose = useCallback((panelId: string, run: () => void) => { - closeHandlers.current.set(panelId, { workspaceKey: workspaceKeyRef.current, run }); - }, []); - - const firePanelClose = useCallback((panelId: string) => { - const entry = closeHandlers.current.get(panelId); - if (!entry) return; - closeHandlers.current.delete(panelId); - if (entry.workspaceKey !== workspaceKeyRef.current) return; - // A handler that throws must not stop the panel from closing — the layout write is the user's - // instruction, and the cleanup is a best effort on top of it. - try { - entry.run(); - } catch (err) { - console.error(`panel close handler for ${panelId} threw`, err); - } - }, []); - - // Every mutation goes through `onLayoutChange` as an UPDATER, never as a computed tree. - // - // Each of these used to close over `layout` as it was when the callback was made, and two of the paths - // are not immediate: the resize debounce fires 500 ms after the drag began, and a window resize fires - // `onLayout` on every group at once. So the second write was computed from a tree that predated the - // first and silently undid it — remove a panel just after dragging a splitter and it came back. That is - // worse than it sounds now that panel identity lives in the layout: the resurrected tree carries an - // older `config`, so a panel that was just given an agent's name reverts to anonymous, and the agent it - // addresses is no longer reachable through it. - // - // The guards below still test the rendered `layout` — they only decide whether a write is worth making, - // and being one render stale there costs a redundant no-op write at worst. - // - // `handleSetApp` and `handleRemove` additionally fire the closing panel's `usePanelClose` handler. - // They are the only two places that do, and they are the only two that know a close was *asked for* — - // a drag, a swap and a mobile panel switch all tear a panel's component down without closing anything. - const handleSetApp = useCallback( - (panelId: string, appType: string | null) => { - // Re-picking the app that is already there is not a close: `setApp` keeps the config and the app - // never goes away. Every other transition retires whatever was running. - if (findPanelApp(layout, panelId) !== appType) firePanelClose(panelId); - onLayoutChange((prev) => setApp(prev, panelId, appType)); - }, - [layout, onLayoutChange, firePanelClose], - ); - - const handleSplit = useCallback( - (panelId: string, direction: 'horizontal' | 'vertical') => { - onLayoutChange((prev) => splitPanel(prev, panelId, direction)); - }, - [onLayoutChange], - ); - - const handleRemove = useCallback( - (panelId: string) => { - if (countPanels(layout) <= 1) return; - // Fired before the write, from the rendered tree, rather than from inside the updater — an updater - // has to stay pure and may run twice. The updater's own guard can therefore decline a removal we - // already announced, if a concurrent write shrank the tree to one panel in between. That costs a - // cleanup for a panel still on screen; it needs two writes to race inside one debounce window, and - // the alternative is a side effect in an updater. - firePanelClose(panelId); - onLayoutChange((prev) => (countPanels(prev) <= 1 ? prev : removePanel(prev, panelId))); - }, - [layout, onLayoutChange, firePanelClose], - ); - - const handleResized = useCallback( - (groupId: string, sizes: number[]) => { - onLayoutChange((prev) => updateSizes(prev, groupId, sizes)); - }, - [onLayoutChange], - ); - - const handleSetZoom = useCallback( - (panelId: string, zoom: number) => { - if (setZoom(layout, panelId, zoom) === layout) return; - onLayoutChange((prev) => setZoom(prev, panelId, zoom)); - }, - [layout, onLayoutChange], - ); - - // Derived, not stored: the layout is the single copy, so a panel's config cannot drift from the panel. - const panelConfigs = useMemo(() => collectPanelConfigs(layout), [layout]); - - // Memoised on the key, not recomputed inline: the provider value is a fresh object every render - // anyway, but this one ends up in consumers' dependency arrays (`useAgentPanel`'s query key, the - // terminal state key). A new identity per render is how `useClient()` silently disabled every - // playback report in the Jellyfin player — same shape, so don't reproduce it here. - const identity = useMemo(() => parseWorkspaceKey(workspace.key), [workspace.key]); - - const handleSetPanelConfig = useCallback( - (panelId: string, config: PanelConfig | undefined) => { - if (setPanelConfig(layout, panelId, config) === layout) return; - onLayoutChange((prev) => setPanelConfig(prev, panelId, config)); - }, - [layout, onLayoutChange], - ); - - const handleSwap = useCallback( - (sourceId: string, targetId: string) => { - onLayoutChange((prev) => swapPanels(prev, sourceId, targetId)); - setSwapSourceId(null); - }, - [onLayoutChange], - ); - - const handleMove = useCallback( - (sourceId: string, targetId: string, position: DropPosition) => { - onLayoutChange((prev) => movePanel(prev, sourceId, targetId, position)); - setDragSourceId(null); - }, - [onLayoutChange], - ); - - const startDrag = useCallback((id: string | null) => { - setSwapSourceId(null); - setDragSourceId(id); - }, []); - - // Escape cancels a *pending* swap or drag — modes you are halfway through, where it is the only way - // out. Maximize is not one of those: it is a state you chose and that now outlives a refresh, so a key - // press meant for whatever you are typing in should not throw it away. Restore is the panel's own - // button. - useEffect(() => { - if (!swapSourceId && !dragSourceId) return; - const onKeyDown = (ev: KeyboardEvent) => { - if (ev.key === 'Escape') { - setSwapSourceId(null); - setDragSourceId(null); - } - }; - window.addEventListener('keydown', onKeyDown); - return () => window.removeEventListener('keydown', onKeyDown); - }, [swapSourceId, dragSourceId]); - - const ephemeralPanelRef = useRef>(null); - const isEphemeralOpen = !!ephemeral; - - useEffect(() => { - const panel = ephemeralPanelRef.current; - if (!panel) return; - - // `react-resizable-panels` asserts rather than no-ops when it is asked to resize a group it has not - // laid out yet — and an assert thrown from an effect aborts the commit, which here means the whole - // dashboard rather than the pane that could not be sized. The pane is a file preview; the panels - // behind it are a night's work. Only the ephemeral pane's own geometry is at stake, and the next - // render tries again. - try { - if (isEphemeralOpen) { - panel.resize(100 - (ephemeral.defaultBaseSize ?? 40)); - } else { - panel.collapse(); - } - } catch (err) { - console.warn('[workspace] could not size the ephemeral pane yet', err); - } - }, [isEphemeralOpen, ephemeral?.defaultBaseSize]); - - const onMobileBack = useMemo(() => { - if (!isMobile) return null; - if (mobilePanelId && onMobilePanelChange) return () => onMobilePanelChange(null); - if (isEphemeralOpen && ephemeral?.onClose) return ephemeral.onClose; - return null; - }, [isMobile, mobilePanelId, onMobilePanelChange, isEphemeralOpen, ephemeral?.onClose]); - - // A fresh object literal here re-rendered every `useWorkspace()` consumer in every panel on every - // `WorkspaceView` render — including each frame of a maximize animation, and including renders that - // changed nothing a panel can see (the ephemeral pane opening, a mobile panel switch). Memoised, a panel - // only re-renders when something it is actually given changes. - const contextValue = useMemo( - () => ({ - workspace: identity, - cwd, - root, - panelConfigs, - setPanelConfig: locked ? noop : handleSetPanelConfig, - registerPanelClose, - swapSourceId, - setSwapSourceId, - onSwap: handleSwap, - dragSourceId, - setDragSourceId: startDrag, - onMove: handleMove, - onSetZoom: handleSetZoom, - fullscreenPanelId, - setFullscreenPanelId: setFullscreenAnimated, - transitioningPanelId, - isMobile, - onMobileBack, - }), - [ - identity, - cwd, - root, - panelConfigs, - locked, - handleSetPanelConfig, - registerPanelClose, - swapSourceId, - handleSwap, - dragSourceId, - startDrag, - handleMove, - handleSetZoom, - fullscreenPanelId, - setFullscreenAnimated, - transitioningPanelId, - isMobile, - onMobileBack, - ], - ); - - if (!workspace.isLoaded) return null; - return ( - - {isMobile && isEphemeralOpen && ephemeral ? ( - - ) : ( - - - - - - - {isEphemeralOpen ? ( - - ) : null} - - - )} - + ); }; diff --git a/src/workspaces/officerdev/src/components/Workspace/index.ts b/src/workspaces/officerdev/src/components/Workspace/index.ts index 28342807..763cca4c 100644 --- a/src/workspaces/officerdev/src/components/Workspace/index.ts +++ b/src/workspaces/officerdev/src/components/Workspace/index.ts @@ -1,18 +1,16 @@ -export type { - LayoutNode, - LayoutGroup, - LayoutPanel, - DashboardDefinition, - DashboardState, - AppRegistryMap, - AppRegistryEntry, - PanelComponents, - PanelComponentEntry, - EphemeralPanels, - HomeRoot, - PanelConfig, -} from './types'; -export type { DropPosition } from './layout-utils'; +/** + * Officer's workspace surface, unchanged for its 24 screens. + * + * The framework itself now lives in the `workspaces` package, which knows nothing about Officer. This + * barrel keeps the old import paths working and adds back the three things that are ours: the app + * registry, fullscreen persistence, and per-panel memory in dashboard state. + * + * New code inside Officer can import from `workspaces` directly. Anything needing the registry or + * persistence should come through here instead. + */ + +// The layout model and its operations — pure, framework-free, and no longer Officer's to own. +export type { LayoutNode, LayoutGroup, LayoutPanel, PanelConfig, DropPosition } from 'workspaces/core'; export { uid, createDefaultLayout, @@ -22,21 +20,48 @@ export { updateSizes, swapPanels, movePanel, - pruneEmptyPanels, + setZoom, + setPanelConfig, + findPanelApp, + collectPanelConfigs, countPanels, hasAnyApp, - setPanelConfig, - collectPanelConfigs, - findPanelApp, normalizeLayout, -} from './layout-utils'; -export type { WorkspaceIdentity } from './workspace-identity'; -export { parseWorkspaceKey } from './workspace-identity'; -export { WorkspaceProvider, useWorkspace } from './WorkspaceContext'; -export { cwdToPath } from './cwd-path'; -export { usePanelFullscreen } from './panel-fullscreen'; -export { usePanelConfig } from './usePanelConfig'; -export { usePanelClose } from './usePanelClose'; -export { usePanelMemory, panelStateKey } from './use-panel-memory'; + pruneEmptyPanels, + ZOOM_MIN, + ZOOM_MAX, + ZOOM_STEP, +} from 'workspaces/core'; +export type { WorkspaceIdentity } from 'workspaces/core'; +export { parseWorkspaceKey } from 'workspaces/core'; + +// The renderer, and the hooks a panel is allowed to use. +export { + WorkspaceRenderer, + PanelSlot, + WorkspaceProvider, + useWorkspace, + usePanelConfig, + usePanelClose, +} from 'workspaces/react'; +export type { WorkspaceContextValue } from 'workspaces/react'; + +// Officer's wrappers — the package plus the registry, fullscreen persistence and the shell handshake. export { WorkspaceView } from './WorkspaceView'; export { WorkspaceLayout } from './WorkspaceLayout'; + +// Officer-only, and staying: what a dashboard is, where a panel's scrap state lives, how the shell learns +// a panel went fullscreen, and how `~` becomes a path. +export type { + DashboardDefinition, + DashboardState, + HomeRoot, + AppRegistryEntry, + AppRegistryMap, + PanelComponents, + PanelComponentEntry, + EphemeralPanels, +} from './types'; +export { usePanelMemory, panelStateKey } from './use-panel-memory'; +export { usePanelFullscreen, usePublishPanelFullscreen } from './panel-fullscreen'; +export { cwdToPath } from './cwd-path'; diff --git a/src/workspaces/officerdev/src/components/Workspace/types.ts b/src/workspaces/officerdev/src/components/Workspace/types.ts index 63f2606a..b393b9f8 100644 --- a/src/workspaces/officerdev/src/components/Workspace/types.ts +++ b/src/workspaces/officerdev/src/components/Workspace/types.ts @@ -1,38 +1,21 @@ -import type { ComponentType, ReactNode } from 'react'; -import type { LucideIcon } from 'lucide-react'; - -export type LayoutGroup = { - type: 'group'; - id: string; - direction: 'horizontal' | 'vertical'; - children: { node: LayoutNode; size: number }[]; -}; - -export type LayoutPanel = { - type: 'panel'; - id: string; - appType: string | null; - fitContent?: boolean; - // Per-panel content scale (1 = 100%). Lives on the node rather than in its own useDashboardState key - // because the layout is already persisted per panel — a separate key would seed a row per panel on - // mount (useDashboardState seeds defaults to the server), and would need its own dispatcher branch. - zoom?: number; - // Opaque per-panel settings owned by whichever app the panel is running. The framework stores, moves - // and deletes it and never reads a field — the shape is the app's business (see `usePanelConfig`). - // - // Here for the same reason `zoom` is: the layout is the one thing already persisted per panel, and it - // is persisted server-side, which is what makes a panel's configuration outlive the tab. It also means - // the config is deleted exactly when the panel is, with no separate lifecycle to get wrong. - // - // It travels with the panel through swap and move — see `swapPanels`/`movePanel`, which carry - // `{ appType, config }` as one unit precisely because an app's identity is both. - config?: PanelConfig; -}; - -/** Opaque to the framework. Each app defines and validates its own shape. */ -export type PanelConfig = Record; - -export type LayoutNode = LayoutGroup | LayoutPanel; +/** + * Officer's workspace types. + * + * The layout model itself moved to the `workspaces` package — it is no longer Officer's to define. What + * remains here is what only Officer has: what a dashboard IS, as opposed to what a layout is. + */ +export type { + LayoutNode, + LayoutGroup, + LayoutPanel, + PanelConfig, + DashboardState, + AppRegistryEntry, + AppRegistryMap, + PanelComponents, + PanelComponentEntry, + EphemeralPanels, +} from 'workspaces/react'; export type HomeRoot = 'home' | '~' | 'officer.dev'; @@ -44,47 +27,3 @@ export type DashboardDefinition = { description?: string; templateIdx?: number; }; - -export type DashboardState = { - key: string; - value: LayoutNode; - setValue: (value: LayoutNode | ((prev: LayoutNode) => LayoutNode)) => void; - isLoaded: boolean; -}; - -export type AppRegistryEntry = { - name: string; - icon: LucideIcon; - component: ComponentType<{ panelId: string }>; - header?: ComponentType<{ panelId: string }>; - provider?: ComponentType<{ panelId: string; children: ReactNode }>; - transparent?: boolean; - availableOnPanel?: boolean; - // Opt out of the panel zoom control. For apps that scale badly under a CSS transform: a terminal - // (its own font sizing, and scale blurs the glyph grid) or a remote-desktop canvas (noVNC does its - // own autoscale, and a wrapping transform desyncs its pointer mapping). - zoomable?: boolean; -}; - -export type AppRegistryMap = Record; - -// A panel supplied by a screen rather than by the registry. `PanelSlot` renders all three of these with -// `panelId`, exactly as it does the registry's — they were typed with no props at all, so a -// `components`-supplied panel was handed an id it could not see, and a screen wanting one had to reach -// for `useParams` or a channel instead. A component that takes no props is still assignable here, so the -// existing screens are unaffected. -export type PanelComponentEntry = { - component: ComponentType<{ panelId: string }>; - header?: ComponentType<{ panelId: string }>; - provider?: ComponentType<{ panelId: string; children: ReactNode }>; - onClose?: () => void; -}; - -export type PanelComponents = Record | PanelComponentEntry>; - -export type EphemeralPanels = { - layout: LayoutNode; - components: PanelComponents; - defaultBaseSize?: number; - onClose?: () => void; -}; diff --git a/src/workspaces/officerdev/src/components/Workspace/use-panel-memory.ts b/src/workspaces/officerdev/src/components/Workspace/use-panel-memory.ts index aba44d4e..9c4171d0 100644 --- a/src/workspaces/officerdev/src/components/Workspace/use-panel-memory.ts +++ b/src/workspaces/officerdev/src/components/Workspace/use-panel-memory.ts @@ -1,7 +1,7 @@ import { useRef } from 'react'; import { useDashboardState } from 'state/useDashboardState'; -import { usePanelClose } from './usePanelClose'; -import type { WorkspaceIdentity } from './workspace-identity'; +import { usePanelClose } from 'workspaces/react'; +import type { WorkspaceIdentity } from 'workspaces/core'; /** * A panel's own scrap of state, remembered across a reload. diff --git a/src/workspaces/officerdev/src/components/Workspace/workspace-identity.test.ts b/src/workspaces/officerdev/src/components/Workspace/workspace-identity.test.ts deleted file mode 100644 index c19861e4..00000000 --- a/src/workspaces/officerdev/src/components/Workspace/workspace-identity.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { describe, expect, test } from 'bun:test'; -import { parseWorkspaceKey } from './workspace-identity'; -import { terminalStateKey } from '../../apps/Terminal/state-key'; - -describe('parseWorkspaceKey', () => { - test('reads a dashboard key', () => { - expect(parseWorkspaceKey('ws-layout-agent-mvp')).toEqual({ - kind: 'dashboard', - id: 'agent-mvp', - key: 'ws-layout-agent-mvp', - }); - }); - - test('reads a screen key', () => { - expect(parseWorkspaceKey('screens/email')).toEqual({ kind: 'screen', id: 'email', key: 'screens/email' }); - }); - - // Both halves may contain dashes and slashes; only the first prefix is structural. - test('keeps the whole remainder as the id', () => { - expect(parseWorkspaceKey('ws-layout-my-dash-2')?.id).toBe('my-dash-2'); - expect(parseWorkspaceKey('screens/soulseek-v2')?.id).toBe('soulseek-v2'); - }); - - test('is null for anything that is neither', () => { - expect(parseWorkspaceKey(null)).toBeNull(); - expect(parseWorkspaceKey(undefined)).toBeNull(); - expect(parseWorkspaceKey('')).toBeNull(); - expect(parseWorkspaceKey('email')).toBeNull(); - // The prefixes alone name nothing. - expect(parseWorkspaceKey('ws-layout-')).toBeNull(); - expect(parseWorkspaceKey('screens/')).toBeNull(); - }); -}); - -describe('terminalStateKey', () => { - // The regression this rule exists for: a wrapper that passed the raw key through produced - // `ws-host-terminals-ws-layout-my-dash`, which the server parsed back out as a dashboard id and created. - test('strips the layout prefix rather than nesting it', () => { - const key = terminalStateKey('host-terminals', parseWorkspaceKey('ws-layout-my-dash')); - expect(key).toBe('ws-host-terminals-my-dash'); - expect(key).not.toContain('ws-layout'); - }); - - test('screens and unknown workspaces share the defaults row', () => { - expect(terminalStateKey('terminals', parseWorkspaceKey('screens/chat'))).toBe('ws-terminals-default'); - expect(terminalStateKey('terminals', null)).toBe('ws-terminals-default'); - }); - - test('carries the panel-state prefixes the dispatcher allow-lists', () => { - const dashboard = parseWorkspaceKey('ws-layout-d1'); - expect(terminalStateKey('tmux', dashboard)).toBe('ws-tmux-d1'); - expect(terminalStateKey('nvim', dashboard)).toBe('ws-nvim-d1'); - expect(terminalStateKey('claude-code', dashboard)).toBe('ws-claude-code-d1'); - }); -}); diff --git a/src/workspaces/workspaces/README.md b/src/workspaces/workspaces/README.md new file mode 100644 index 00000000..de510d51 --- /dev/null +++ b/src/workspaces/workspaces/README.md @@ -0,0 +1,71 @@ +# workspaces + +A tiling workspace and panel layout system. **Layout in, changes out.** + +It renders a tree of panels and lets a person split, resize, move, close and fullscreen them. It performs +no IO, imports no panel, and has no opinion about the rest of your page. Where the layout is stored — a +server, localStorage, nowhere — is entirely yours. + +```tsx + +``` + +That is the whole contract. `setValue` receives an **updater** — `(prev) => next` — so overlapping writes +compose instead of clobbering each other. Two paths are not immediate (the resize debounce fires 500ms +after a drag, and a window resize fires every group at once), so a write computed from a stale tree would +silently undo the write before it. + +## Two halves + +| | | +|---|---| +| `workspaces/core` | the tree and every operation on it. Pure, no dependencies, **no React** | +| `workspaces/react` | one renderer for it | + +The split is deliberate. Every mutation is `(tree, args) => tree` — `splitPanel`, `removePanel`, +`movePanel`, `swapPanels`, `setApp`, `setZoom`, `setPanelConfig`, `updateSizes`, `normalizeLayout` — and +none of them knows what a component is. A second renderer reuses all of it untouched. + +## The layout + +```ts +type LayoutGroup = { type: 'group'; id: string; direction: 'horizontal' | 'vertical'; + children: { node: LayoutNode; size: number }[] }; +type LayoutPanel = { type: 'panel'; id: string; appType: string | null; + fitContent?: boolean; zoom?: number; config?: PanelConfig }; +``` + +`size` is a percentage within its group. `config` is opaque — the framework stores, moves and deletes it +and never reads a field. It travels with the panel through move and swap, because an app's identity is +both what it is and how it is configured. + +## What the host supplies + +**`registry`** — `appType` → component. This package never imports a panel. + +**`workspace`** — the layout and a way to change it. Persist it however you like, or not at all. + +**`fullscreenPanelId` / `onFullscreenPanelChange`** — optional. Fullscreen IS layout state, so whether it +survives a reload is your decision; leave them out and it lives in local state. Officer keeps it in +sessionStorage per tab, which is a fact about Officer rather than about workspaces. + +## Panel-facing hooks + +`useWorkspace()`, `usePanelConfig(panelId)`, `usePanelClose(panelId, handler)`. + +`usePanelClose` fires when a close was **asked for** — not on the unmounts a drag, a swap or a mobile +switch cause. + +## Not here, on purpose + +No storage, no network, no auth, no routing, no state library. Panel-to-panel communication is currently +the host's too; if it moves in, it will be an in-memory bus configured from outside, never a store. + +## Phase 2 + +A framework-agnostic renderer over the same `core`. What will not port for free is the resize +*interaction* — drag handles, pointer maths, constraints — which `react-resizable-panels` supplies today. +That is the bounded cost, and it is why the tree logic was kept out of the components. diff --git a/src/workspaces/workspaces/package.json b/src/workspaces/workspaces/package.json new file mode 100644 index 00000000..61871496 --- /dev/null +++ b/src/workspaces/workspaces/package.json @@ -0,0 +1,16 @@ +{ + "name": "workspaces", + "version": "0.1.0", + "license": "MIT", + "type": "module", + "description": "A tiling workspace and panel layout system. Layout in, changes out \u2014 persistence is the host's.", + "exports": { + ".": "./src/index.ts", + "./core": "./src/core/index.ts", + "./react": "./src/react/index.ts" + }, + "_dependencies_note": "Only the one that had to be pinned. This repository carries TWO versions of react-resizable-panels \u2014 the root asks for ^4.0.15 (4.6.5) and src/workspaces/components for ^2.1.7 (2.1.9) \u2014 and this code was written against 2.x, whose API differs: v4 has no PanelGroup export, no `order` prop and no `ref` on Panel. Inheriting the root resolution produced dozens of type errors that looked like a broken extraction and were a version mismatch. Everything else (react, react-dom, radix, lucide, clsx, tailwind-merge) is deliberately NOT declared while this lives in the monorepo: declaring them at versions that did not match the root produced a second copy of react under this package, which breaks hooks at runtime and is invisible until something renders. Publishing standalone means declaring them all, aligned with the host.", + "dependencies": { + "react-resizable-panels": "^2.1.7" + } +} diff --git a/src/workspaces/workspaces/src/core/identity.test.ts b/src/workspaces/workspaces/src/core/identity.test.ts new file mode 100644 index 00000000..24c5f8d8 --- /dev/null +++ b/src/workspaces/workspaces/src/core/identity.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, test } from 'bun:test'; +import { parseWorkspaceKey } from './identity'; + +describe('parseWorkspaceKey', () => { + test('reads a dashboard key', () => { + expect(parseWorkspaceKey('ws-layout-agent-mvp')).toEqual({ + kind: 'dashboard', + id: 'agent-mvp', + key: 'ws-layout-agent-mvp', + }); + }); + + test('reads a screen key', () => { + expect(parseWorkspaceKey('screens/email')).toEqual({ kind: 'screen', id: 'email', key: 'screens/email' }); + }); + + // Both halves may contain dashes and slashes; only the first prefix is structural. + test('keeps the whole remainder as the id', () => { + expect(parseWorkspaceKey('ws-layout-my-dash-2')?.id).toBe('my-dash-2'); + expect(parseWorkspaceKey('screens/soulseek-v2')?.id).toBe('soulseek-v2'); + }); + + test('is null for anything that is neither', () => { + expect(parseWorkspaceKey(null)).toBeNull(); + expect(parseWorkspaceKey(undefined)).toBeNull(); + expect(parseWorkspaceKey('')).toBeNull(); + expect(parseWorkspaceKey('email')).toBeNull(); + // The prefixes alone name nothing. + expect(parseWorkspaceKey('ws-layout-')).toBeNull(); + expect(parseWorkspaceKey('screens/')).toBeNull(); + }); +}); diff --git a/src/workspaces/officerdev/src/components/Workspace/workspace-identity.ts b/src/workspaces/workspaces/src/core/identity.ts similarity index 100% rename from src/workspaces/officerdev/src/components/Workspace/workspace-identity.ts rename to src/workspaces/workspaces/src/core/identity.ts diff --git a/src/workspaces/workspaces/src/core/index.ts b/src/workspaces/workspaces/src/core/index.ts new file mode 100644 index 00000000..9251f172 --- /dev/null +++ b/src/workspaces/workspaces/src/core/index.ts @@ -0,0 +1,10 @@ +/** + * The framework-agnostic half: the tree and every operation on it. + * + * Nothing here imports React, and nothing here performs IO. A change is `(tree, args) => tree`, which is + * what lets a host persist it however it likes — and what will let a non-React renderer reuse all of it + * untouched. + */ +export type { LayoutNode, LayoutGroup, LayoutPanel, PanelConfig } from './types'; +export * from './layout'; +export * from './identity'; diff --git a/src/workspaces/officerdev/src/components/Workspace/layout-utils.test.ts b/src/workspaces/workspaces/src/core/layout.test.ts similarity index 99% rename from src/workspaces/officerdev/src/components/Workspace/layout-utils.test.ts rename to src/workspaces/workspaces/src/core/layout.test.ts index 6597da9a..61d54f14 100644 --- a/src/workspaces/officerdev/src/components/Workspace/layout-utils.test.ts +++ b/src/workspaces/workspaces/src/core/layout.test.ts @@ -16,7 +16,7 @@ import { splitPanel, swapPanels, updateSizes, -} from './layout-utils'; +} from './layout'; // These functions are the whole of the layout's behaviour, and since `e588524` they also carry a panel's // *identity* — the opaque `config` an app uses to remember what it is. `apps/Chat` stores an agent's name diff --git a/src/workspaces/officerdev/src/components/Workspace/layout-utils.ts b/src/workspaces/workspaces/src/core/layout.ts similarity index 100% rename from src/workspaces/officerdev/src/components/Workspace/layout-utils.ts rename to src/workspaces/workspaces/src/core/layout.ts diff --git a/src/workspaces/workspaces/src/core/types.ts b/src/workspaces/workspaces/src/core/types.ts new file mode 100644 index 00000000..aace1bb5 --- /dev/null +++ b/src/workspaces/workspaces/src/core/types.ts @@ -0,0 +1,36 @@ +/** + * The layout tree. Plain data, no dependencies, no framework. + * + * This is what a host persists and what phase 2 will render without React. Everything else in this package + * is a way of drawing it or changing it; a workspace IS one of these. + */ + +export type LayoutGroup = { + type: 'group'; + id: string; + direction: 'horizontal' | 'vertical'; + children: { node: LayoutNode; size: number }[]; +}; + +export type LayoutPanel = { + type: 'panel'; + id: string; + /** Which app this panel is running. Resolved by the HOST — this package never imports a panel. */ + appType: string | null; + fitContent?: boolean; + /** Per-panel content scale (1 = 100%). On the node because the node is the thing already persisted. */ + zoom?: number; + /** + * Opaque per-panel settings owned by whichever app the panel is running. This package stores, moves and + * deletes it and never reads a field — the shape is the app's business. + * + * It travels with the panel through swap and move, which carry `{ appType, config }` as one unit + * precisely because an app's identity is both. + */ + config?: PanelConfig; +}; + +/** Opaque to the framework. Each app defines and validates its own shape. */ +export type PanelConfig = Record; + +export type LayoutNode = LayoutGroup | LayoutPanel; diff --git a/src/workspaces/workspaces/src/index.ts b/src/workspaces/workspaces/src/index.ts new file mode 100644 index 00000000..9d67f4ae --- /dev/null +++ b/src/workspaces/workspaces/src/index.ts @@ -0,0 +1,12 @@ +/** + * A tiling workspace and panel layout system. + * + * Layout in, changes out. It renders a tree of panels, lets a person split, resize, move, close and + * fullscreen them, and calls back with the new tree. Where that tree is stored — a server, localStorage, + * nowhere — is entirely the host's business. + * + * `workspaces/core` is the tree and its operations: pure, dependency-free, framework-free. + * `workspaces/react` is one renderer for it. + */ +export * from './core'; +export * from './react'; diff --git a/src/workspaces/officerdev/src/components/Workspace/AppPicker.tsx b/src/workspaces/workspaces/src/react/AppPicker.tsx similarity index 100% rename from src/workspaces/officerdev/src/components/Workspace/AppPicker.tsx rename to src/workspaces/workspaces/src/react/AppPicker.tsx diff --git a/src/workspaces/officerdev/src/components/Workspace/PanelErrorFallback.tsx b/src/workspaces/workspaces/src/react/PanelErrorFallback.tsx similarity index 100% rename from src/workspaces/officerdev/src/components/Workspace/PanelErrorFallback.tsx rename to src/workspaces/workspaces/src/react/PanelErrorFallback.tsx diff --git a/src/workspaces/officerdev/src/components/Workspace/PanelSlot.test.tsx b/src/workspaces/workspaces/src/react/PanelSlot.test.tsx similarity index 99% rename from src/workspaces/officerdev/src/components/Workspace/PanelSlot.test.tsx rename to src/workspaces/workspaces/src/react/PanelSlot.test.tsx index 3ed0d1cc..8188ce57 100644 --- a/src/workspaces/officerdev/src/components/Workspace/PanelSlot.test.tsx +++ b/src/workspaces/workspaces/src/react/PanelSlot.test.tsx @@ -4,7 +4,7 @@ import { render, fireEvent, screen } from '@testing-library/react'; import type { AppRegistryMap, LayoutPanel, PanelComponents } from './types'; import type { WorkspaceContextValue } from './WorkspaceContext'; import { WorkspaceProvider, inertInteraction } from './WorkspaceContext'; -import { ZOOM_MAX, ZOOM_MIN, ZOOM_STEP } from './layout-utils'; +import { ZOOM_MAX, ZOOM_MIN, ZOOM_STEP } from '../core/layout'; import { PanelSlot } from './PanelSlot'; /** diff --git a/src/workspaces/officerdev/src/components/Workspace/PanelSlot.tsx b/src/workspaces/workspaces/src/react/PanelSlot.tsx similarity index 99% rename from src/workspaces/officerdev/src/components/Workspace/PanelSlot.tsx rename to src/workspaces/workspaces/src/react/PanelSlot.tsx index 79bce366..a0c8ea96 100644 --- a/src/workspaces/officerdev/src/components/Workspace/PanelSlot.tsx +++ b/src/workspaces/workspaces/src/react/PanelSlot.tsx @@ -3,9 +3,9 @@ import { useCallback } from 'react'; import { ArrowLeftRight, ChevronLeft, X, Maximize2, Minimize2, ZoomIn, ZoomOut } from 'lucide-react'; import type { LayoutPanel, AppRegistryMap, PanelComponents, PanelComponentEntry } from './types'; import { useWorkspace } from './WorkspaceContext'; -import { ZOOM_MIN, ZOOM_MAX, ZOOM_STEP } from './layout-utils'; -import { Card } from '@/components/Card'; -import { ErrorBoundary } from '@/components/ErrorBoundary'; +import { ZOOM_MIN, ZOOM_MAX, ZOOM_STEP } from '../core/layout'; +import { Card } from './internal/Card'; +import { ErrorBoundary } from './internal/ErrorBoundary'; import { AppPicker } from './AppPicker'; import { PanelErrorFallback } from './PanelErrorFallback'; import { @@ -14,7 +14,7 @@ import { ContextMenuContent, ContextMenuItem, ContextMenuSeparator, -} from '@/components/ui/context-menu'; +} from './internal/context-menu'; type PanelSlotProps = { panel: LayoutPanel; diff --git a/src/workspaces/officerdev/src/components/Workspace/WorkspaceContext.ts b/src/workspaces/workspaces/src/react/WorkspaceContext.ts similarity index 96% rename from src/workspaces/officerdev/src/components/Workspace/WorkspaceContext.ts rename to src/workspaces/workspaces/src/react/WorkspaceContext.ts index f4c8b60e..1e0ba67c 100644 --- a/src/workspaces/officerdev/src/components/Workspace/WorkspaceContext.ts +++ b/src/workspaces/workspaces/src/react/WorkspaceContext.ts @@ -1,8 +1,8 @@ import { createContext, useContext } from 'react'; -import type { DropPosition } from './layout-utils'; +import type { DropPosition } from '../core/layout'; import type { PanelConfig } from './types'; -import type { WorkspaceIdentity } from './workspace-identity'; +import type { WorkspaceIdentity } from '../core/identity'; export type WorkspaceContextValue = { /** Which dashboard or screen this panel is on. Null in a preview or a settings pane. */ diff --git a/src/workspaces/workspaces/src/react/WorkspaceLayout.tsx b/src/workspaces/workspaces/src/react/WorkspaceLayout.tsx new file mode 100644 index 00000000..409953a9 --- /dev/null +++ b/src/workspaces/workspaces/src/react/WorkspaceLayout.tsx @@ -0,0 +1,66 @@ +import { useCallback } from 'react'; +import type { LayoutNode, AppRegistryMap, PanelComponents } from './types'; +import { updateSizes, collectPanelConfigs } from '../core/layout'; +import { WorkspaceProvider, inertInteraction } from './WorkspaceContext'; +import { WorkspaceRenderer } from './WorkspaceRenderer'; + +type WorkspaceLayoutProps = { + layout: LayoutNode; + onLayoutChange: (layout: LayoutNode | ((prev: LayoutNode) => LayoutNode)) => void; + /** `appType` -> component, from the host. See `WorkspaceView`. */ + registry: AppRegistryMap; + components?: PanelComponents; + cwd?: string; + noHeader?: boolean; +}; + +const noop = () => {}; + +// No mobile props. This renderer had `isMobile`/`mobilePanelId`/`onMobileBack` pass-throughs that none of +// its ten call sites ever set, so the mobile collapse they feed was permanently off here — and being +// optional, that read as a feature rather than the dead wiring it was. `WorkspaceView` is where mobile +// collapse actually happens, and it passes those to `WorkspaceRenderer` itself. +export const WorkspaceLayout = ({ + layout, + onLayoutChange, + registry, + components, + cwd, + noHeader, +}: WorkspaceLayoutProps) => { + // An updater, for the same reason `WorkspaceView`'s eight mutations are: `WorkspaceRenderer` debounces + // this by 500 ms, so a tree computed here is 500 ms stale by the time it is written. + const handleResized = useCallback( + (groupId: string, sizes: number[]) => { + onLayoutChange((prev) => updateSizes(prev, groupId, sizes)); + }, + [onLayoutChange], + ); + + // No workspace identity: this is the locked/preview renderer, used for job detail panes, settings and + // dashboard previews. It is not a dashboard and not a screen, and a panel that asks should be told so + // rather than handed something that parses. + return ( + + + + ); +}; diff --git a/src/workspaces/officerdev/src/components/Workspace/WorkspaceRenderer.test.tsx b/src/workspaces/workspaces/src/react/WorkspaceRenderer.test.tsx similarity index 99% rename from src/workspaces/officerdev/src/components/Workspace/WorkspaceRenderer.test.tsx rename to src/workspaces/workspaces/src/react/WorkspaceRenderer.test.tsx index 5d7b8320..905e3894 100644 --- a/src/workspaces/officerdev/src/components/Workspace/WorkspaceRenderer.test.tsx +++ b/src/workspaces/workspaces/src/react/WorkspaceRenderer.test.tsx @@ -2,7 +2,7 @@ import { describe, expect, test } from 'bun:test'; import { useEffect } from 'react'; import { render, cleanup } from '@testing-library/react'; import type { AppRegistryMap, LayoutGroup, LayoutNode, LayoutPanel } from './types'; -import { movePanel, removePanel, splitPanel, updateSizes } from './layout-utils'; +import { movePanel, removePanel, splitPanel, updateSizes } from '../core/layout'; import { WorkspaceRenderer } from './WorkspaceRenderer'; import { WorkspaceProvider, inertInteraction } from './WorkspaceContext'; diff --git a/src/workspaces/officerdev/src/components/Workspace/WorkspaceRenderer.tsx b/src/workspaces/workspaces/src/react/WorkspaceRenderer.tsx similarity index 99% rename from src/workspaces/officerdev/src/components/Workspace/WorkspaceRenderer.tsx rename to src/workspaces/workspaces/src/react/WorkspaceRenderer.tsx index 5c708635..64666ab2 100644 --- a/src/workspaces/officerdev/src/components/Workspace/WorkspaceRenderer.tsx +++ b/src/workspaces/workspaces/src/react/WorkspaceRenderer.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useRef } from 'react'; -import { ResizablePanel, ResizablePanelGroup, ResizableHandle } from '@/components/ui/resizable'; +import { ResizablePanel, ResizablePanelGroup, ResizableHandle } from './internal/resizable'; import type { LayoutNode, AppRegistryMap, PanelComponents } from './types'; -import { countPanels } from './layout-utils'; +import { countPanels } from '../core/layout'; import { PanelSlot } from './PanelSlot'; type WorkspaceRendererProps = { diff --git a/src/workspaces/officerdev/src/components/Workspace/WorkspaceView.test.tsx b/src/workspaces/workspaces/src/react/WorkspaceView.test.tsx similarity index 92% rename from src/workspaces/officerdev/src/components/Workspace/WorkspaceView.test.tsx rename to src/workspaces/workspaces/src/react/WorkspaceView.test.tsx index d8b862c8..33a8a347 100644 --- a/src/workspaces/officerdev/src/components/Workspace/WorkspaceView.test.tsx +++ b/src/workspaces/workspaces/src/react/WorkspaceView.test.tsx @@ -1,9 +1,7 @@ import { describe, expect, test, beforeEach } from 'bun:test'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { render, fireEvent, screen } from '@testing-library/react'; -import { globalQueryKey } from 'hooks/useGlobal'; import type { AppRegistryMap, DashboardState, LayoutGroup, LayoutNode, LayoutPanel } from './types'; -import { countPanels, findPanelApp } from './layout-utils'; +import { countPanels, findPanelApp } from '../core/layout'; import { WorkspaceView } from './WorkspaceView'; import { usePanelClose } from './usePanelClose'; @@ -58,7 +56,6 @@ const registry: AppRegistryMap = { other: { name: 'Other', icon: ProbeIcon, component: Probe }, }; -let queryClient: QueryClient; let writes: Recorded[]; /** @@ -86,11 +83,9 @@ function mount(initial: LayoutNode, props: Partial ( - - - - ); + // No provider of any kind. The registry is a prop now, so this package renders under a bare React tree — + // which is also the proof that a host needs nothing from us but a registry and a layout. + const tree = () => ; view = render(tree()); return { layout: () => current, rerender: () => view?.rerender(tree()) }; @@ -99,10 +94,6 @@ function mount(initial: LayoutNode, props: Partial { closeBehaviour.clear(); closesFired.length = 0; - queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); - // `useAppRegistry` reads a `useGlobal` key that `seedAppRegistry` writes before anything renders. Seeding - // the cache directly is the same thing without dragging in the real app list. - queryClient.setQueryData(globalQueryKey('APP_REGISTRY'), registry); }); describe('every write is an updater, not a computed tree', () => { diff --git a/src/workspaces/workspaces/src/react/WorkspaceView.tsx b/src/workspaces/workspaces/src/react/WorkspaceView.tsx new file mode 100644 index 00000000..7b80fbf7 --- /dev/null +++ b/src/workspaces/workspaces/src/react/WorkspaceView.tsx @@ -0,0 +1,426 @@ +import { useState, useCallback, useEffect, useRef, useMemo, type ComponentRef } from 'react'; +import { flushSync } from 'react-dom'; +import { ResizablePanel, ResizablePanelGroup, ResizableHandle } from './internal/resizable'; +import { useIsMobile } from './internal/useIsMobile'; +import type { + LayoutNode, + DashboardState, + EphemeralPanels, + PanelComponents, + PanelConfig, + AppRegistryMap, +} from './types'; +import type { DropPosition } from '../core/layout'; +import { + splitPanel, + removePanel, + setApp, + updateSizes, + swapPanels, + movePanel, + countPanels, + setZoom, + setPanelConfig, + collectPanelConfigs, + findPanelApp, + normalizeLayout, +} from '../core/layout'; +import type { WorkspaceContextValue } from './WorkspaceContext'; +import { WorkspaceProvider } from './WorkspaceContext'; +import { parseWorkspaceKey } from '../core/identity'; +import { WorkspaceRenderer } from './WorkspaceRenderer'; + +export type WorkspaceViewProps = { + workspace: DashboardState; + locked?: boolean; + /** + * Pin every panel to one of these app types, replacing anything else with `fallback` and writing the + * repair back. A locked screen should always pass this: its layout is persisted user state that + * outlives the code, and an appType the screen no longer renders becomes an empty box the user cannot + * get out of, because a locked screen has no app picker. + * + * This used to be fourteen character-identical `normalizeLayout` functions in fourteen screens, each + * with its own `useMemo` and persist-back `useEffect` — which is how nine screens ended up with no + * guard at all and one with a guard that re-ran forever because it never wrote the correction back. + */ + appTypes?: { allowed: readonly string[]; fallback: string }; + cwd?: string; + root?: string; + components?: PanelComponents; + ephemeral?: EphemeralPanels | null; + mobilePanelId?: string; + onMobilePanelChange?: (id: string | null) => void; + /** + * `appType` -> component. Supplied by the host: this package never imports a panel and has no idea what + * apps exist. Officer passes its `AppRegistry`; another host passes whatever it likes. + */ + registry: AppRegistryMap; + /** + * Which panel is fullscreen, when the host wants to own it — the controlled half of a + * controlled/uncontrolled pair. Left out, the workspace keeps it in local state. + * + * Offered because fullscreen IS layout state: whether it survives a reload, and where it is kept, is the + * host's decision. Officer puts it in sessionStorage per tab, which is a fact about Officer. + */ + fullscreenPanelId?: string | null; + onFullscreenPanelChange?: (id: string | null) => void; +}; + +const noop = () => {}; + +export const WorkspaceView = ({ + workspace, + locked, + appTypes, + cwd = '~', + root, + components, + ephemeral, + mobilePanelId, + onMobilePanelChange, + registry, + fullscreenPanelId: controlledFullscreen, + onFullscreenPanelChange, +}: WorkspaceViewProps) => { + const isMobile = useIsMobile(); + + // Deliberately not memoised. `normalizeLayout` returns its input by reference when nothing needed + // fixing, so the identity everything downstream depends on is already stable; a `useMemo` here would + // only add a dependency array to get wrong, and the callers pass a fresh object literal for `appTypes` + // anyway, which would defeat it. The walk is a handful of nodes. + const layout = appTypes ? normalizeLayout(workspace.value, appTypes.allowed, appTypes.fallback) : workspace.value; + const onLayoutChange = workspace.setValue; + + // Write the repair back, so a layout is fixed once rather than re-fixed on every mount for the rest of + // its life. Terminates because the normalised tree normalises to itself. + const rawLayout = workspace.value; + useEffect(() => { + if (!workspace.isLoaded || layout === rawLayout) return; + onLayoutChange(layout); + }, [workspace.isLoaded, layout, rawLayout, onLayoutChange]); + const [swapSourceId, setSwapSourceId] = useState(null); + const [dragSourceId, setDragSourceId] = useState(null); + // Per tab and per dashboard: putting the chat panel full screen and refreshing should come back full + // screen, while a second window on the same screen keeps its own idea of what is blown up. A stored id + // whose panel has since been removed simply fills nothing — this is a style toggle on the panel itself, + // so there is nothing to strand. + // + /** + * Controlled when the host passes it, local otherwise. + * + * Officer keeps it in sessionStorage under `FULLSCREEN_PANEL:`, per tab — a decision + * about Officer, not about workspaces, which is exactly why it moved out of here. A host that does not + * care gets working fullscreen that dies with the component, and never has to know the key existed. + */ + const [uncontrolledFullscreen, setUncontrolledFullscreen] = useState(null); + const fullscreenPanelId = controlledFullscreen !== undefined ? controlledFullscreen : uncontrolledFullscreen; + const setFullscreenPanelId = useCallback( + (id: string | null) => { + if (onFullscreenPanelChange) onFullscreenPanelChange(id); + if (controlledFullscreen === undefined) setUncontrolledFullscreen(id); + }, + [onFullscreenPanelChange, controlledFullscreen], + ); + const [transitioningPanelId, setTransitioningPanelId] = useState(null); + + // A host may need to stand its own chrome down — Officer hides the nav header, because a panel cannot + // paint over it from inside the content region's stacking context. Reported, not performed: this package + // has no opinion about what else is on the page. + useEffect(() => { + onFullscreenPanelChange?.(fullscreenPanelId); + // Only when the VALUE changes; `onFullscreenPanelChange` is often an inline arrow. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [fullscreenPanelId]); + + const setFullscreenAnimated = useCallback( + (id: string | null) => { + const doc = document as Document & { startViewTransition?: (cb: () => void) => { finished: Promise } }; + const panelId = fullscreenPanelId ?? id; + if (doc.startViewTransition && panelId) { + setTransitioningPanelId(panelId); + requestAnimationFrame(() => { + const transition = doc.startViewTransition(() => flushSync(() => setFullscreenPanelId(id))); + transition.finished.finally(() => setTransitioningPanelId(null)); + }); + } else { + setFullscreenPanelId(id); + } + }, + [fullscreenPanelId, setFullscreenPanelId], + ); + + // What each panel wants done when it is closed, by panel id, stamped with the workspace it was + // registered on. A ref rather than state: nothing renders from it, and a panel registering during its + // own mount effect must not schedule a render of the whole workspace. + // + // Nothing is ever unregistered. A handler is dropped when it fires and replaced when the same panel id + // registers again; a panel that merely unmounted keeps its handler, because "unmounted" is precisely + // the signal this exists to avoid trusting. The stamp is what keeps that from leaking across + // dashboards — the same panel id can exist on two of them, and a handler from the one you navigated + // away from must not fire for the one you are on. + const closeHandlers = useRef(new Map void }>()); + const workspaceKeyRef = useRef(workspace.key); + workspaceKeyRef.current = workspace.key; + + const registerPanelClose = useCallback((panelId: string, run: () => void) => { + closeHandlers.current.set(panelId, { workspaceKey: workspaceKeyRef.current, run }); + }, []); + + const firePanelClose = useCallback((panelId: string) => { + const entry = closeHandlers.current.get(panelId); + if (!entry) return; + closeHandlers.current.delete(panelId); + if (entry.workspaceKey !== workspaceKeyRef.current) return; + // A handler that throws must not stop the panel from closing — the layout write is the user's + // instruction, and the cleanup is a best effort on top of it. + try { + entry.run(); + } catch (err) { + console.error(`panel close handler for ${panelId} threw`, err); + } + }, []); + + // Every mutation goes through `onLayoutChange` as an UPDATER, never as a computed tree. + // + // Each of these used to close over `layout` as it was when the callback was made, and two of the paths + // are not immediate: the resize debounce fires 500 ms after the drag began, and a window resize fires + // `onLayout` on every group at once. So the second write was computed from a tree that predated the + // first and silently undid it — remove a panel just after dragging a splitter and it came back. That is + // worse than it sounds now that panel identity lives in the layout: the resurrected tree carries an + // older `config`, so a panel that was just given an agent's name reverts to anonymous, and the agent it + // addresses is no longer reachable through it. + // + // The guards below still test the rendered `layout` — they only decide whether a write is worth making, + // and being one render stale there costs a redundant no-op write at worst. + // + // `handleSetApp` and `handleRemove` additionally fire the closing panel's `usePanelClose` handler. + // They are the only two places that do, and they are the only two that know a close was *asked for* — + // a drag, a swap and a mobile panel switch all tear a panel's component down without closing anything. + const handleSetApp = useCallback( + (panelId: string, appType: string | null) => { + // Re-picking the app that is already there is not a close: `setApp` keeps the config and the app + // never goes away. Every other transition retires whatever was running. + if (findPanelApp(layout, panelId) !== appType) firePanelClose(panelId); + onLayoutChange((prev) => setApp(prev, panelId, appType)); + }, + [layout, onLayoutChange, firePanelClose], + ); + + const handleSplit = useCallback( + (panelId: string, direction: 'horizontal' | 'vertical') => { + onLayoutChange((prev) => splitPanel(prev, panelId, direction)); + }, + [onLayoutChange], + ); + + const handleRemove = useCallback( + (panelId: string) => { + if (countPanels(layout) <= 1) return; + // Fired before the write, from the rendered tree, rather than from inside the updater — an updater + // has to stay pure and may run twice. The updater's own guard can therefore decline a removal we + // already announced, if a concurrent write shrank the tree to one panel in between. That costs a + // cleanup for a panel still on screen; it needs two writes to race inside one debounce window, and + // the alternative is a side effect in an updater. + firePanelClose(panelId); + onLayoutChange((prev) => (countPanels(prev) <= 1 ? prev : removePanel(prev, panelId))); + }, + [layout, onLayoutChange, firePanelClose], + ); + + const handleResized = useCallback( + (groupId: string, sizes: number[]) => { + onLayoutChange((prev) => updateSizes(prev, groupId, sizes)); + }, + [onLayoutChange], + ); + + const handleSetZoom = useCallback( + (panelId: string, zoom: number) => { + if (setZoom(layout, panelId, zoom) === layout) return; + onLayoutChange((prev) => setZoom(prev, panelId, zoom)); + }, + [layout, onLayoutChange], + ); + + // Derived, not stored: the layout is the single copy, so a panel's config cannot drift from the panel. + const panelConfigs = useMemo(() => collectPanelConfigs(layout), [layout]); + + // Memoised on the key, not recomputed inline: the provider value is a fresh object every render + // anyway, but this one ends up in consumers' dependency arrays (`useAgentPanel`'s query key, the + // terminal state key). A new identity per render is how `useClient()` silently disabled every + // playback report in the Jellyfin player — same shape, so don't reproduce it here. + const identity = useMemo(() => parseWorkspaceKey(workspace.key), [workspace.key]); + + const handleSetPanelConfig = useCallback( + (panelId: string, config: PanelConfig | undefined) => { + if (setPanelConfig(layout, panelId, config) === layout) return; + onLayoutChange((prev) => setPanelConfig(prev, panelId, config)); + }, + [layout, onLayoutChange], + ); + + const handleSwap = useCallback( + (sourceId: string, targetId: string) => { + onLayoutChange((prev) => swapPanels(prev, sourceId, targetId)); + setSwapSourceId(null); + }, + [onLayoutChange], + ); + + const handleMove = useCallback( + (sourceId: string, targetId: string, position: DropPosition) => { + onLayoutChange((prev) => movePanel(prev, sourceId, targetId, position)); + setDragSourceId(null); + }, + [onLayoutChange], + ); + + const startDrag = useCallback((id: string | null) => { + setSwapSourceId(null); + setDragSourceId(id); + }, []); + + // Escape cancels a *pending* swap or drag — modes you are halfway through, where it is the only way + // out. Maximize is not one of those: it is a state you chose and that now outlives a refresh, so a key + // press meant for whatever you are typing in should not throw it away. Restore is the panel's own + // button. + useEffect(() => { + if (!swapSourceId && !dragSourceId) return; + const onKeyDown = (ev: KeyboardEvent) => { + if (ev.key === 'Escape') { + setSwapSourceId(null); + setDragSourceId(null); + } + }; + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, [swapSourceId, dragSourceId]); + + const ephemeralPanelRef = useRef>(null); + const isEphemeralOpen = !!ephemeral; + + useEffect(() => { + const panel = ephemeralPanelRef.current; + if (!panel) return; + + // `react-resizable-panels` asserts rather than no-ops when it is asked to resize a group it has not + // laid out yet — and an assert thrown from an effect aborts the commit, which here means the whole + // dashboard rather than the pane that could not be sized. The pane is a file preview; the panels + // behind it are a night's work. Only the ephemeral pane's own geometry is at stake, and the next + // render tries again. + try { + if (isEphemeralOpen) { + panel.resize(100 - (ephemeral.defaultBaseSize ?? 40)); + } else { + panel.collapse(); + } + } catch (err) { + console.warn('[workspace] could not size the ephemeral pane yet', err); + } + }, [isEphemeralOpen, ephemeral?.defaultBaseSize]); + + const onMobileBack = useMemo(() => { + if (!isMobile) return null; + if (mobilePanelId && onMobilePanelChange) return () => onMobilePanelChange(null); + if (isEphemeralOpen && ephemeral?.onClose) return ephemeral.onClose; + return null; + }, [isMobile, mobilePanelId, onMobilePanelChange, isEphemeralOpen, ephemeral?.onClose]); + + // A fresh object literal here re-rendered every `useWorkspace()` consumer in every panel on every + // `WorkspaceView` render — including each frame of a maximize animation, and including renders that + // changed nothing a panel can see (the ephemeral pane opening, a mobile panel switch). Memoised, a panel + // only re-renders when something it is actually given changes. + const contextValue = useMemo( + () => ({ + workspace: identity, + cwd, + root, + panelConfigs, + setPanelConfig: locked ? noop : handleSetPanelConfig, + registerPanelClose, + swapSourceId, + setSwapSourceId, + onSwap: handleSwap, + dragSourceId, + setDragSourceId: startDrag, + onMove: handleMove, + onSetZoom: handleSetZoom, + fullscreenPanelId, + setFullscreenPanelId: setFullscreenAnimated, + transitioningPanelId, + isMobile, + onMobileBack, + }), + [ + identity, + cwd, + root, + panelConfigs, + locked, + handleSetPanelConfig, + registerPanelClose, + swapSourceId, + handleSwap, + dragSourceId, + startDrag, + handleMove, + handleSetZoom, + fullscreenPanelId, + setFullscreenAnimated, + transitioningPanelId, + isMobile, + onMobileBack, + ], + ); + + if (!workspace.isLoaded) return null; + + return ( + + {isMobile && isEphemeralOpen && ephemeral ? ( + + ) : ( + + + + + + + {isEphemeralOpen ? ( + + ) : null} + + + )} + + ); +}; diff --git a/src/workspaces/workspaces/src/react/index.ts b/src/workspaces/workspaces/src/react/index.ts new file mode 100644 index 00000000..e16d795c --- /dev/null +++ b/src/workspaces/workspaces/src/react/index.ts @@ -0,0 +1,27 @@ +/** + * The React renderer for a workspace. + * + * Everything here is a component or a hook; the tree and its operations are in `workspaces/core` and have + * no idea React exists. That split is deliberate — a second renderer reuses the core untouched. + * + * The host supplies two things and owns both: a `registry` mapping `appType` to a component, and the + * layout with a way to change it. This package performs no IO and imports no panel. + */ +export { WorkspaceView } from './WorkspaceView'; +export type { WorkspaceViewProps } from './WorkspaceView'; +export { WorkspaceLayout } from './WorkspaceLayout'; +export { WorkspaceRenderer } from './WorkspaceRenderer'; +export { PanelSlot } from './PanelSlot'; +export { WorkspaceProvider, useWorkspace, inertInteraction } from './WorkspaceContext'; +export type { WorkspaceContextValue } from './WorkspaceContext'; +export { usePanelConfig } from './usePanelConfig'; +export { usePanelClose } from './usePanelClose'; +export type { + DashboardState, + AppRegistryEntry, + AppRegistryMap, + PanelComponents, + PanelComponentEntry, + EphemeralPanels, +} from './types'; +export type { LayoutNode, LayoutGroup, LayoutPanel, PanelConfig } from '../core/types'; diff --git a/src/workspaces/workspaces/src/react/internal/Card.tsx b/src/workspaces/workspaces/src/react/internal/Card.tsx new file mode 100644 index 00000000..c9de944c --- /dev/null +++ b/src/workspaces/workspaces/src/react/internal/Card.tsx @@ -0,0 +1,24 @@ +import type { CSSProperties, ComponentPropsWithRef } from 'react'; +import { cn } from './cn'; + +export const cardStyle = (overrides?: CSSProperties): CSSProperties => ({ + backgroundColor: 'var(--card-bg)', + backgroundImage: ` + linear-gradient(to right, var(--card-grid-color) 1px, transparent 1px), + linear-gradient(to bottom, var(--card-grid-color) 1px, transparent 1px) + `, + backgroundSize: '20px 20px', + ...overrides, +}); + +type CardProps = ComponentPropsWithRef<'div'>; + +export const Card = ({ className, style, ...props }: CardProps) => { + return ( +
+ ); +}; diff --git a/src/workspaces/workspaces/src/react/internal/ErrorBoundary.tsx b/src/workspaces/workspaces/src/react/internal/ErrorBoundary.tsx new file mode 100644 index 00000000..088d02d0 --- /dev/null +++ b/src/workspaces/workspaces/src/react/internal/ErrorBoundary.tsx @@ -0,0 +1,55 @@ +import { Component } from 'react'; +import type { ErrorInfo, ReactNode } from 'react'; + +type FallbackArgs = { error: Error; reset: () => void }; + +type ErrorBoundaryProps = { + children: ReactNode; + /** Rendered in place of the children once something below has thrown. */ + fallback: (args: FallbackArgs) => ReactNode; + /** + * Clear the error whenever any of these changes — pass the pathname to recover on navigation, or the + * id of the thing being rendered. Without it a boundary latches: the screen stays broken until reload + * even after the user has navigated somewhere that would render fine. + */ + resetKeys?: readonly unknown[]; + onError?: (error: Error, info: ErrorInfo) => void; +}; + +type ErrorBoundaryState = { error: Error | null }; + +/** + * The repo had none of these. A single malformed value anywhere below the router took the whole app to a + * white screen, and the only recovery from a bad stored layout was SQL — which is the wrong thing to ask + * of someone who has just opened the dashboard to read what happened overnight. + * + * Deliberately a render-prop rather than a fixed panel: what "recover" means is different for a screen + * (go somewhere else) and for one panel inside a workspace (clear the app, keep the rest running). + */ +export class ErrorBoundary extends Component { + override state: ErrorBoundaryState = { error: null }; + + static getDerivedStateFromError(error: Error): ErrorBoundaryState { + return { error }; + } + + override componentDidCatch(error: Error, info: ErrorInfo): void { + console.error('[error-boundary]', error, info.componentStack); + this.props.onError?.(error, info); + } + + override componentDidUpdate(prev: ErrorBoundaryProps): void { + if (!this.state.error) return; + const a = prev.resetKeys ?? []; + const b = this.props.resetKeys ?? []; + if (a.length !== b.length || a.some((key, i) => !Object.is(key, b[i]))) this.setState({ error: null }); + } + + reset = (): void => this.setState({ error: null }); + + override render(): ReactNode { + const { error } = this.state; + if (error) return this.props.fallback({ error, reset: this.reset }); + return this.props.children; + } +} diff --git a/src/workspaces/workspaces/src/react/internal/cn.ts b/src/workspaces/workspaces/src/react/internal/cn.ts new file mode 100644 index 00000000..465ea3cd --- /dev/null +++ b/src/workspaces/workspaces/src/react/internal/cn.ts @@ -0,0 +1,5 @@ +import { clsx, type ClassValue } from 'clsx'; +import { twMerge } from 'tailwind-merge'; + +/** Merge class names, last Tailwind utility winning. Vendored so this package needs no host aliases. */ +export const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs)); diff --git a/src/workspaces/workspaces/src/react/internal/context-menu.tsx b/src/workspaces/workspaces/src/react/internal/context-menu.tsx new file mode 100644 index 00000000..3bad8afb --- /dev/null +++ b/src/workspaces/workspaces/src/react/internal/context-menu.tsx @@ -0,0 +1,178 @@ +import * as React from 'react'; +import * as ContextMenuPrimitive from '@radix-ui/react-context-menu'; +import { Check, ChevronRight, Circle } from 'lucide-react'; + +import { cn } from './cn'; + +const ContextMenu = ContextMenuPrimitive.Root; + +const ContextMenuTrigger = ContextMenuPrimitive.Trigger; + +const ContextMenuGroup = ContextMenuPrimitive.Group; + +const ContextMenuPortal = ContextMenuPrimitive.Portal; + +const ContextMenuSub = ContextMenuPrimitive.Sub; + +const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup; + +const ContextMenuSubTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)); +ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName; + +const ContextMenuSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName; + +const ContextMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName; + +const ContextMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + +)); +ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName; + +const ContextMenuCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)); +ContextMenuCheckboxItem.displayName = ContextMenuPrimitive.CheckboxItem.displayName; + +const ContextMenuRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)); +ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName; + +const ContextMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + +)); +ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName; + +const ContextMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName; + +const ContextMenuShortcut = ({ className, ...props }: React.HTMLAttributes) => { + return ; +}; +ContextMenuShortcut.displayName = 'ContextMenuShortcut'; + +export { + ContextMenu, + ContextMenuTrigger, + ContextMenuContent, + ContextMenuItem, + ContextMenuCheckboxItem, + ContextMenuRadioItem, + ContextMenuLabel, + ContextMenuSeparator, + ContextMenuShortcut, + ContextMenuGroup, + ContextMenuPortal, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, + ContextMenuRadioGroup, +}; diff --git a/src/workspaces/workspaces/src/react/internal/resizable.tsx b/src/workspaces/workspaces/src/react/internal/resizable.tsx new file mode 100644 index 00000000..0a4e0303 --- /dev/null +++ b/src/workspaces/workspaces/src/react/internal/resizable.tsx @@ -0,0 +1,37 @@ +import { GripVertical } from 'lucide-react'; +import * as ResizablePrimitive from 'react-resizable-panels'; + +import { cn } from './cn'; + +const ResizablePanelGroup = ({ className, ...props }: React.ComponentProps) => ( + +); + +const ResizablePanel = ResizablePrimitive.Panel; + +const ResizableHandle = ({ + withHandle, + className, + ...props +}: React.ComponentProps & { + withHandle?: boolean; +}) => ( + div]:rotate-90', + className, + )} + {...props} + > + {withHandle && ( +
+ +
+ )} +
+); + +export { ResizablePanelGroup, ResizablePanel, ResizableHandle }; diff --git a/src/workspaces/workspaces/src/react/internal/useIsMobile.ts b/src/workspaces/workspaces/src/react/internal/useIsMobile.ts new file mode 100644 index 00000000..d69b0962 --- /dev/null +++ b/src/workspaces/workspaces/src/react/internal/useIsMobile.ts @@ -0,0 +1,27 @@ +import * as React from 'react'; + +const MOBILE_BREAKPOINT = 768; + +export function useIsMobile() { + const [isMobile, setIsMobile] = React.useState( + typeof window !== 'undefined' ? window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`).matches : false, + ); + + React.useEffect(() => { + const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`); + + const onChange = (event: MediaQueryListEvent) => { + setIsMobile(event.matches); + }; + + setIsMobile(mql.matches); + + mql.addEventListener('change', onChange); + + return () => mql.removeEventListener('change', onChange); + }, []); + + return isMobile; +} + +export type UseIsMobileReturn = ReturnType; diff --git a/src/workspaces/workspaces/src/react/types.ts b/src/workspaces/workspaces/src/react/types.ts new file mode 100644 index 00000000..abb1fb41 --- /dev/null +++ b/src/workspaces/workspaces/src/react/types.ts @@ -0,0 +1,55 @@ +import type { ComponentType, ReactNode } from 'react'; +import type { LayoutNode, PanelConfig } from '../core/types'; + +/** + * The React-facing half of the model. The tree itself lives in `../core/types` and knows nothing about + * components — that split is what phase 2 inherits. + */ + +export type { LayoutNode, LayoutGroup, LayoutPanel, PanelConfig } from '../core/types'; + +/** + * The layout, and how to change it. The host owns storage entirely: this package reads `value` and calls + * `setValue`, and has no idea whether that reaches a server, localStorage or nothing at all. + */ +export type DashboardState = { + key: string; + value: LayoutNode; + setValue: (value: LayoutNode | ((prev: LayoutNode) => LayoutNode)) => void; + isLoaded: boolean; +}; + +/** + * One app the host offers. `icon` is a component rather than a `LucideIcon` so the package does not force + * an icon library on anyone — Officer's lucide icons satisfy it without changing. + */ +export type AppRegistryEntry = { + name: string; + icon: ComponentType<{ className?: string; size?: number | string }>; + component: ComponentType<{ panelId: string }>; + header?: ComponentType<{ panelId: string }>; + provider?: ComponentType<{ panelId: string; children: ReactNode }>; + transparent?: boolean; + availableOnPanel?: boolean; + /** Opt out of the zoom control — a terminal or a canvas that scales badly under a CSS transform. */ + zoomable?: boolean; +}; + +export type AppRegistryMap = Record; + +/** A panel supplied by a screen rather than by the registry. Rendered with `panelId`, exactly as those are. */ +export type PanelComponentEntry = { + component: ComponentType<{ panelId: string }>; + header?: ComponentType<{ panelId: string }>; + provider?: ComponentType<{ panelId: string; children: ReactNode }>; + onClose?: () => void; +}; + +export type PanelComponents = Record | PanelComponentEntry>; + +export type EphemeralPanels = { + layout: LayoutNode; + components: PanelComponents; + defaultBaseSize?: number; + onClose?: () => void; +}; diff --git a/src/workspaces/officerdev/src/components/Workspace/usePanelClose.ts b/src/workspaces/workspaces/src/react/usePanelClose.ts similarity index 100% rename from src/workspaces/officerdev/src/components/Workspace/usePanelClose.ts rename to src/workspaces/workspaces/src/react/usePanelClose.ts diff --git a/src/workspaces/officerdev/src/components/Workspace/usePanelConfig.ts b/src/workspaces/workspaces/src/react/usePanelConfig.ts similarity index 100% rename from src/workspaces/officerdev/src/components/Workspace/usePanelConfig.ts rename to src/workspaces/workspaces/src/react/usePanelConfig.ts