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.
+12
View File
@@ -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=="],
+1
View File
@@ -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"
},
+1
View File
@@ -0,0 +1 @@
/home/pastilhas/officer-dev/platform/plugins/example
+1
View File
@@ -0,0 +1 @@
/home/pastilhas/officer-dev/platform/plugins/music
+1
View File
@@ -0,0 +1 @@
/home/pastilhas/officer-dev/platform/plugins/offscale
@@ -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');
});
});
@@ -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<DropTarget | null>(null);
const panelRectsRef = useRef<Map<string, DOMRect>>(new Map());
useEffect(() => {
if (!dragSourceId) {
panelRectsRef.current.clear();
setTarget(null);
return;
}
const panels = document.querySelectorAll<HTMLElement>('[data-panel-id]');
const map = new Map<string, DOMRect>();
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 (
<div
className="fixed inset-0 z-50 cursor-grabbing"
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
>
{target && (
<div
className="absolute rounded-lg border-2 border-duck-teal/60 bg-duck-teal/15 pointer-events-none"
style={{ ...getIndicatorStyle(target.rect, target.zone), transition: 'all 75ms ease-out' }}
/>
)}
</div>
);
};
@@ -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) => (
<div className="absolute top-1 right-1 flex gap-1 z-10">
<button
type="button"
className="p-1 rounded bg-duck-teal/10 border border-duck-teal/20 text-duck-teal/70 hover:text-duck-teal hover:bg-duck-teal/20 cursor-pointer"
onClick={() => onSplit(panelId, 'horizontal')}
title="Split Horizontal"
>
<Columns size={14} />
</button>
<button
type="button"
className="p-1 rounded bg-duck-teal/10 border border-duck-teal/20 text-duck-teal/70 hover:text-duck-teal hover:bg-duck-teal/20 cursor-pointer"
onClick={() => onSplit(panelId, 'vertical')}
title="Split Vertical"
>
<Rows size={14} />
</button>
{hasWidget && (
<button
type="button"
className="p-1 rounded bg-duck-teal/10 border border-duck-teal/20 text-duck-teal/70 hover:text-duck-teal hover:bg-duck-teal/20 cursor-pointer"
onClick={onClearApp}
title="Clear Widget"
>
<X size={14} />
</button>
)}
{!isLastPanel && (
<button
type="button"
className="p-1 rounded bg-red-400/10 border border-red-400/30 text-red-400/70 hover:text-red-400 hover:bg-red-400/20 cursor-pointer"
onClick={() => onRemove(panelId)}
title="Remove Panel"
>
<Trash2 size={14} />
</button>
)}
</div>
);
@@ -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) => (
<div className="flex items-center gap-3 px-4 py-2 border-b border-duck-teal/10">
<h2 className="text-sm font-bold text-duck-teal">{name}</h2>
<button
type="button"
className={`ml-auto flex items-center gap-1.5 px-2.5 py-1 rounded-md text-xs font-medium transition-colors cursor-pointer ${
editing
? 'bg-duck-teal/15 text-duck-teal'
: 'text-duck-teal/60 hover:text-duck-teal hover:bg-duck-teal/10'
}`}
onClick={onToggleEdit}
>
{editing ? <Check size={14} /> : <Pencil size={14} />}
{editing ? 'Done' : 'Edit'}
</button>
</div>
);
@@ -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<ComponentProps<typeof BaseWorkspaceLayout>, 'registry'> & {
registry?: ComponentProps<typeof BaseWorkspaceLayout>['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 (
<WorkspaceProvider value={{ workspace: null, cwd: cwd ?? '~', panelConfigs: collectPanelConfigs(layout), ...inertInteraction, isMobile: false, onMobileBack: null }}>
<WorkspaceRenderer
layout={layout}
registry={registry}
components={components}
noHeader={noHeader}
onSetApp={noop}
onSplit={noop}
onRemove={noop}
onResized={handleResized}
/>
</WorkspaceProvider>
);
export const WorkspaceLayout = ({ registry: registryProp, ...props }: OfficerWorkspaceLayoutProps) => {
const { registry } = useAppRegistry();
return <BaseWorkspaceLayout {...props} registry={registryProp ?? registry} />;
};
@@ -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.
/**
* Officer's workspace: the package, plus the three things it deliberately does not know.
*
* 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.
* 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 `<WorkspaceView workspace={...} />`
* 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:<workspace key>`.
* 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.
*/
appTypes?: { allowed: readonly string[]; fallback: string };
cwd?: string;
root?: string;
components?: PanelComponents;
ephemeral?: EphemeralPanels | null;
mobilePanelId?: string;
onMobilePanelChange?: (id: string | null) => void;
};
type OfficerWorkspaceViewProps = Omit<WorkspaceViewProps, 'registry' | 'fullscreenPanelId' | 'onFullscreenPanelChange'>;
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<string | null>(null);
const [dragSourceId, setDragSourceId] = useState<string | null>(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<string | null>(fullscreenKey, null);
const [transitioningPanelId, setTransitioningPanelId] = useState<string | null>(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<string | null>(
`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<void> } };
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<string, { workspaceKey: string; run: () => 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<ComponentRef<typeof ResizablePanel>>(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<WorkspaceContextValue>(
() => ({
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 (
<WorkspaceProvider value={contextValue}>
{isMobile && isEphemeralOpen && ephemeral ? (
<WorkspaceRenderer
layout={ephemeral.layout}
<BaseWorkspaceView
{...props}
registry={registry}
components={ephemeral.components}
isMobile={isMobile}
onSetApp={noop}
onSplit={noop}
onRemove={noop}
onResized={noop}
fullscreenPanelId={fullscreenPanelId}
onFullscreenPanelChange={setFullscreenPanelId}
/>
) : (
<ResizablePanelGroup direction="horizontal" className="h-full w-full">
<ResizablePanel defaultSize={100} minSize={15}>
<WorkspaceRenderer
layout={layout}
registry={registry}
components={components}
interactive
locked={locked}
isMobile={isMobile}
mobilePanelId={mobilePanelId}
onSetApp={locked ? noop : handleSetApp}
onSplit={locked ? noop : handleSplit}
onRemove={locked ? noop : handleRemove}
onResized={handleResized}
/>
</ResizablePanel>
<ResizableHandle className="bg-transparent after:bg-transparent" disabled={!isEphemeralOpen} />
<ResizablePanel ref={ephemeralPanelRef} collapsible collapsedSize={0} defaultSize={0} minSize={15}>
{isEphemeralOpen ? (
<WorkspaceRenderer
layout={ephemeral.layout}
registry={registry}
components={ephemeral.components}
onSetApp={noop}
onSplit={noop}
onRemove={noop}
onResized={noop}
/>
) : null}
</ResizablePanel>
</ResizablePanelGroup>
)}
</WorkspaceProvider>
);
};
@@ -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';
@@ -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<string, unknown>;
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<string, AppRegistryEntry>;
// 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<string, ComponentType<{ panelId: string }> | PanelComponentEntry>;
export type EphemeralPanels = {
layout: LayoutNode;
components: PanelComponents;
defaultBaseSize?: number;
onClose?: () => void;
};
@@ -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.
@@ -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');
});
});
+71
View File
@@ -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
<WorkspaceView
workspace={{ key: 'my-layout', value: layout, setValue: setLayout, isLoaded: true }}
registry={{ notes: { name: 'Notes', icon: NotesIcon, component: NotesPanel } }}
/>
```
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.
+16
View File
@@ -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"
}
}
@@ -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();
});
});
@@ -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';
@@ -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
@@ -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<string, unknown>;
export type LayoutNode = LayoutGroup | LayoutPanel;
+12
View File
@@ -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';
@@ -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';
/**
@@ -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;
@@ -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. */
@@ -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 (
<WorkspaceProvider
value={{
workspace: null,
cwd: cwd ?? '~',
panelConfigs: collectPanelConfigs(layout),
...inertInteraction,
isMobile: false,
onMobileBack: null,
}}
>
<WorkspaceRenderer
layout={layout}
registry={registry}
components={components}
noHeader={noHeader}
onSetApp={noop}
onSplit={noop}
onRemove={noop}
onResized={handleResized}
/>
</WorkspaceProvider>
);
};
@@ -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';
@@ -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 = {
@@ -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<Parameters<typeof WorkspaceVi
},
});
const tree = () => (
<QueryClientProvider client={queryClient}>
<WorkspaceView {...props} workspace={workspace()} />
</QueryClientProvider>
);
// 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 = () => <WorkspaceView registry={registry} {...props} workspace={workspace()} />;
view = render(tree());
return { layout: () => current, rerender: () => view?.rerender(tree()) };
@@ -99,10 +94,6 @@ function mount(initial: LayoutNode, props: Partial<Parameters<typeof WorkspaceVi
beforeEach(() => {
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', () => {
@@ -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<string | null>(null);
const [dragSourceId, setDragSourceId] = useState<string | null>(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:<workspace key>`, 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<string | null>(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<string | null>(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<void> } };
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<string, { workspaceKey: string; run: () => 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<ComponentRef<typeof ResizablePanel>>(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<WorkspaceContextValue>(
() => ({
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 (
<WorkspaceProvider value={contextValue}>
{isMobile && isEphemeralOpen && ephemeral ? (
<WorkspaceRenderer
layout={ephemeral.layout}
registry={registry}
components={ephemeral.components}
isMobile={isMobile}
onSetApp={noop}
onSplit={noop}
onRemove={noop}
onResized={noop}
/>
) : (
<ResizablePanelGroup direction="horizontal" className="h-full w-full">
<ResizablePanel defaultSize={100} minSize={15}>
<WorkspaceRenderer
layout={layout}
registry={registry}
components={components}
interactive
locked={locked}
isMobile={isMobile}
mobilePanelId={mobilePanelId}
onSetApp={locked ? noop : handleSetApp}
onSplit={locked ? noop : handleSplit}
onRemove={locked ? noop : handleRemove}
onResized={handleResized}
/>
</ResizablePanel>
<ResizableHandle className="bg-transparent after:bg-transparent" disabled={!isEphemeralOpen} />
<ResizablePanel ref={ephemeralPanelRef} collapsible collapsedSize={0} defaultSize={0} minSize={15}>
{isEphemeralOpen ? (
<WorkspaceRenderer
layout={ephemeral.layout}
registry={registry}
components={ephemeral.components}
onSetApp={noop}
onSplit={noop}
onRemove={noop}
onResized={noop}
/>
) : null}
</ResizablePanel>
</ResizablePanelGroup>
)}
</WorkspaceProvider>
);
};
@@ -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';
@@ -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 (
<div
className={cn('rounded-xl border-2 border-duck-dark/30 shadow-lg p-5', className)}
style={cardStyle(style)}
{...props}
/>
);
};
@@ -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<ErrorBoundaryProps, ErrorBoundaryState> {
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;
}
}
@@ -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));
@@ -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<typeof ContextMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<ContextMenuPrimitive.SubTrigger
ref={ref}
className={cn(
'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground',
inset && 'pl-8',
className,
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</ContextMenuPrimitive.SubTrigger>
));
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName;
const ContextMenuSubContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.SubContent
ref={ref}
className={cn(
'z-50 min-w-32 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className,
)}
{...props}
/>
));
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName;
const ContextMenuContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
ref={ref}
className={cn(
'z-50 min-w-32 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in fade-in-80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className,
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
));
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName;
const ContextMenuItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50',
inset && 'pl-8',
className,
)}
{...props}
/>
));
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName;
const ContextMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<ContextMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50',
className,
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
));
ContextMenuCheckboxItem.displayName = ContextMenuPrimitive.CheckboxItem.displayName;
const ContextMenuRadioItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<ContextMenuPrimitive.RadioItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50',
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
));
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName;
const ContextMenuLabel = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Label
ref={ref}
className={cn('px-2 py-1.5 text-sm font-semibold text-foreground', inset && 'pl-8', className)}
{...props}
/>
));
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName;
const ContextMenuSeparator = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Separator ref={ref} className={cn('-mx-1 my-1 h-px bg-border', className)} {...props} />
));
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName;
const ContextMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return <span className={cn('ml-auto text-xs tracking-widest text-muted-foreground', className)} {...props} />;
};
ContextMenuShortcut.displayName = 'ContextMenuShortcut';
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
};
@@ -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<typeof ResizablePrimitive.PanelGroup>) => (
<ResizablePrimitive.PanelGroup
className={cn('flex h-full w-full data-[panel-group-direction=vertical]:flex-col', className)}
{...props}
/>
);
const ResizablePanel = ResizablePrimitive.Panel;
const ResizableHandle = ({
withHandle,
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
withHandle?: boolean;
}) => (
<ResizablePrimitive.PanelResizeHandle
className={cn(
'relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90',
className,
)}
{...props}
>
{withHandle && (
<div className="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border">
<GripVertical className="h-2.5 w-2.5" />
</div>
)}
</ResizablePrimitive.PanelResizeHandle>
);
export { ResizablePanelGroup, ResizablePanel, ResizableHandle };
@@ -0,0 +1,27 @@
import * as React from 'react';
const MOBILE_BREAKPOINT = 768;
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean>(
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<typeof useIsMobile>;
@@ -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<string, AppRegistryEntry>;
/** 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<string, ComponentType<{ panelId: string }> | PanelComponentEntry>;
export type EphemeralPanels = {
layout: LayoutNode;
components: PanelComponents;
defaultBaseSize?: number;
onClose?: () => void;
};