extract the workspace framework into its own package

Phase 1: the Workspace/Panel system is `src/workspaces/workspaces`, and it no longer
knows Officer exists.

The proof is its test suite. 114 tests mount `WorkspaceView` under a BARE React tree —
no react-query provider, no router, no Officer hook — which is only possible because
the host now supplies everything: a registry mapping `appType` to a component, and a
layout with a way to change it.

Officer is untouched. All 24 `WorkspaceView` sites and 8 `WorkspaceLayout` sites call
exactly what they called before; `Workspace/index.ts` became an adapter that adds back
the three things that are genuinely ours — the app registry, fullscreen persistence in
sessionStorage, and the handshake that stands the nav header down for a fullscreen
panel.

── The shape, and why it is this shape ──

`core/` is the tree and every operation on it: pure, no dependencies, no React. `react/`
is one renderer. That split is phase 2's whole cost saving — a second renderer reuses
`splitPanel`, `movePanel`, `normalizeLayout` and the rest untouched, and what will not
port is the resize INTERACTION rather than the layout engine.

It was already half done: `layout-utils.ts` imported only `./types`, and `WorkspaceView`
already received `{ key, value, setValue, isLoaded }` as a prop and always wrote through
an updater. The real coupling was the registry, fullscreen, and four UI imports.

Also deleted ~175 lines of dead code — `DragOverlay`, `LayoutEditor`, `WorkspaceHeader`
— referenced by nothing.

── Two dependency traps ──

**A second React.** Declaring react/react-dom in the package produced a second copy
under its own node_modules. Two React instances break hooks at runtime and are invisible
until something renders. The package now declares almost nothing while it lives here.

**Two versions of react-resizable-panels.** The root asks ^4.0.15 (4.6.5),
`src/workspaces/components` asks ^2.1.7 (2.1.9), and this code targets 2.x — v4 has no
`PanelGroup`, no `order`, no `ref` on Panel. Inheriting the root resolution produced a
screenful of type errors that read like a broken extraction and were a version
mismatch. Pinned; the duplication itself is worth fixing one day.

── Verified ──

tsgo clean. Package suite 114/114. Full suite 950 pass / 7 fail, unchanged. Frontend
builds, and the running app was screenshotted against the pre-extraction baseline: 9 of
16 byte-identical, the rest live data and the rotating wallpaper. Panel chrome, headers,
splitters and the grid all render as before.

Docs written as part of the work: the package README is the contract, and
WORKSPACES-NOTES.md carries the open items — panel-to-panel channels, the theme
classes the package still assumes, and phase 2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-17 00:15:28 +00:00
co-authored by Claude Opus 5
parent f977d26fef
commit 4c97e69ecd
45 changed files with 1334 additions and 788 deletions
+78
View File
@@ -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.