give a panel its own settings, and carry them when it moves

A panel can now hold an opaque config blob that the framework stores, moves and deletes
but never reads. It lives on the layout node for the same reason zoom does: the layout is
already persisted per panel and server-side, so a panel's configuration outlives the tab
and is deleted exactly when the panel is.

swapPanels and movePanel now exchange { appType, config } as one unit. They used to carry
only the app type, which would have silently reset a configured panel to defaults on a drag.

Apps read it through usePanelConfig(panelId); PanelSlot already passes panelId to every
registry app, so nothing else in the framework had to change.
This commit is contained in:
2026-08-07 08:20:24 +00:00
parent 7c99429872
commit e58852412a
7 changed files with 152 additions and 30 deletions
@@ -1,6 +1,7 @@
import { createContext, useContext } from 'react';
import type { DropPosition } from './layout-utils';
import type { PanelConfig } from './types';
export type DefaultFileSort = {
field: 'name' | 'size' | 'type' | 'date';
@@ -14,6 +15,10 @@ type WorkspaceContextValue = {
initialFilePath?: string;
defaultFileSort?: DefaultFileSort;
promptPrefix?: string;
// Per-panel app settings, by panel id. A map rather than a getter so a config change re-renders the
// panel that owns it — apps read this through `usePanelConfig`, not directly.
panelConfigs: Record<string, PanelConfig>;
setPanelConfig: (panelId: string, config: PanelConfig | undefined) => void;
swapSourceId: string | null;
setSwapSourceId: (id: string | null) => void;
onSwap: (sourceId: string, targetId: string) => void;
@@ -33,6 +38,8 @@ const noop = () => {};
const WorkspaceContext = createContext<WorkspaceContextValue>({
dashboardId: null,
cwd: '~',
panelConfigs: {},
setPanelConfig: noop,
swapSourceId: null,
setSwapSourceId: noop,
onSwap: noop,
@@ -1,6 +1,6 @@
import { useCallback } from 'react';
import type { LayoutNode, AppRegistryMap, PanelComponents } from './types';
import { updateSizes } from './layout-utils';
import { updateSizes, collectPanelConfigs } from './layout-utils';
import { WorkspaceProvider } from './WorkspaceContext';
import { WorkspaceRenderer } from './WorkspaceRenderer';
import { useAppRegistry } from '../../AppRegistry/useAppRegistry';
@@ -33,7 +33,7 @@ export const WorkspaceLayout = ({ layout, onLayoutChange, registry: registryProp
);
return (
<WorkspaceProvider value={{ dashboardId: dashboardId ?? null, cwd: cwd ?? '~', promptPrefix, swapSourceId: null, setSwapSourceId: noop, onSwap: noop, dragSourceId: null, setDragSourceId: noop, onMove: noop, onSetZoom: noop, maximizedPanelId: null, setMaximizedPanelId: noop, transitioningPanelId: null, isMobile: isMobile ?? false, onMobileBack: onMobileBack ?? null }}>
<WorkspaceProvider value={{ dashboardId: dashboardId ?? null, cwd: cwd ?? '~', promptPrefix, panelConfigs: collectPanelConfigs(layout), setPanelConfig: noop, swapSourceId: null, setSwapSourceId: noop, onSwap: noop, dragSourceId: null, setDragSourceId: noop, onMove: noop, onSetZoom: noop, maximizedPanelId: null, setMaximizedPanelId: noop, transitioningPanelId: null, isMobile: isMobile ?? false, onMobileBack: onMobileBack ?? null }}>
<WorkspaceRenderer
layout={layout}
registry={registry}
@@ -3,10 +3,10 @@ import { flushSync } from 'react-dom';
import { ResizablePanel, ResizablePanelGroup, ResizableHandle } from '@/components/ui/resizable';
import { useIsMobile } from 'hooks/useIsMobile';
import { useSessionState } from 'hooks/useSessionState';
import type { LayoutNode, DashboardState, EphemeralPanels, PanelComponents } from './types';
import type { LayoutNode, DashboardState, EphemeralPanels, PanelComponents, PanelConfig } from './types';
import type { DefaultFileSort } from './WorkspaceContext';
import type { DropPosition } from './layout-utils';
import { splitPanel, removePanel, setApp, updateSizes, swapPanels, movePanel, countPanels, setZoom } from './layout-utils';
import { splitPanel, removePanel, setApp, updateSizes, swapPanels, movePanel, countPanels, setZoom, setPanelConfig, collectPanelConfigs } from './layout-utils';
import { WorkspaceProvider } from './WorkspaceContext';
import { WorkspaceRenderer } from './WorkspaceRenderer';
import { useAppRegistry } from '../../AppRegistry/useAppRegistry';
@@ -94,6 +94,17 @@ export const WorkspaceView = ({ workspace, locked, cwd = '~', root, initialFileP
[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]);
const handleSetPanelConfig = useCallback(
(panelId: string, config: PanelConfig | undefined) => {
const next = setPanelConfig(layout, panelId, config);
if (next !== layout) onLayoutChange(next);
},
[layout, onLayoutChange],
);
const handleSwap = useCallback(
(sourceId: string, targetId: string) => {
onLayoutChange(swapPanels(layout, sourceId, targetId));
@@ -166,6 +177,8 @@ export const WorkspaceView = ({ workspace, locked, cwd = '~', root, initialFileP
initialFilePath,
defaultFileSort,
promptPrefix,
panelConfigs,
setPanelConfig: locked ? noop : handleSetPanelConfig,
swapSourceId,
setSwapSourceId,
onSwap: handleSwap,
@@ -10,6 +10,7 @@ export type {
PanelComponentEntry,
EphemeralPanels,
HomeRoot,
PanelConfig,
} from './types';
export type { DropPosition } from './layout-utils';
export {
@@ -23,8 +24,11 @@ export {
pruneEmptyPanels,
countPanels,
hasAnyApp,
setPanelConfig,
collectPanelConfigs,
} from './layout-utils';
export type { DefaultFileSort } from './WorkspaceContext';
export { WorkspaceProvider, useWorkspace } from './WorkspaceContext';
export { usePanelConfig } from './usePanelConfig';
export { WorkspaceView } from './WorkspaceView';
export { WorkspaceLayout } from './WorkspaceLayout';
@@ -1,4 +1,4 @@
import type { LayoutNode, LayoutPanel, LayoutGroup } from './types';
import type { LayoutNode, LayoutPanel, LayoutGroup, PanelConfig } from './types';
let counter = 0;
const uid = () => `p-${Date.now()}-${++counter}`;
@@ -91,17 +91,6 @@ export function removePanel(root: LayoutNode, panelId: string): LayoutNode {
}
}
export function setApp(root: LayoutNode, panelId: string, appType: string | null): LayoutNode {
if (root.type === 'panel') {
return root.id === panelId ? { ...root, appType } : root;
}
const newChildren = root.children.map((child) => ({
...child,
node: setApp(child.node, panelId, appType),
}));
return { ...root, children: newChildren };
}
export const ZOOM_MIN = 0.7;
export const ZOOM_MAX = 1.6;
export const ZOOM_STEP = 0.1;
@@ -171,25 +160,32 @@ export function movePanel(root: LayoutNode, sourceId: string, targetId: string,
if (sourceId === targetId) return root;
if (position === 'center') return swapPanels(root, sourceId, targetId);
const sourceApp = findPanelApp(root, sourceId);
if (sourceApp === undefined) return root;
const contents = findPanelContents(root, sourceId);
if (!contents) return root;
let result = removePanel(root, sourceId);
const direction = position === 'top' || position === 'bottom' ? 'vertical' : 'horizontal';
const before = position === 'top' || position === 'left';
return insertPanel(result, targetId, direction, before, sourceApp);
return insertPanel(result, targetId, direction, before, contents);
}
const newPanelFrom = (contents: PanelContents): LayoutPanel => ({
type: 'panel',
id: uid(),
appType: contents.appType,
...(contents.config && Object.keys(contents.config).length > 0 ? { config: contents.config } : {}),
});
function insertPanel(
node: LayoutNode,
targetId: string,
direction: 'horizontal' | 'vertical',
before: boolean,
appType: string | null,
contents: PanelContents,
): LayoutNode {
if (node.type === 'panel') {
if (node.id !== targetId) return node;
const newPanel: LayoutPanel = { type: 'panel', id: uid(), appType };
const newPanel = newPanelFrom(contents);
const children = before
? [
{ node: newPanel, size: 50 },
@@ -204,7 +200,7 @@ function insertPanel(
const childIdx = node.children.findIndex((c) => c.node.type === 'panel' && c.node.id === targetId);
if (childIdx !== -1 && node.direction === direction) {
const newPanel: LayoutPanel = { type: 'panel', id: uid(), appType };
const newPanel = newPanelFrom(contents);
const insertIdx = before ? childIdx : childIdx + 1;
const newChildren = [
...node.children.slice(0, insertIdx),
@@ -217,7 +213,7 @@ function insertPanel(
const newChildren = node.children.map((child) => ({
...child,
node: insertPanel(child.node, targetId, direction, before, appType),
node: insertPanel(child.node, targetId, direction, before, contents),
}));
if (newChildren.some((c, i) => c.node !== node.children[i]!.node)) {
return { ...node, children: newChildren };
@@ -225,17 +221,70 @@ function insertPanel(
return node;
}
// Swap and move exchange the panel's *contents*, not its node: ids are positions in the tree and stay
// where they are. What moves is `{ appType, config }` together — an app and the settings it was given
// are one thing, and carrying only the app type would silently reset a configured panel to defaults on
// a drag. (That is what used to happen: the two mutators below were written when `appType` was the
// whole of a panel's identity.)
type PanelContents = { appType: string | null; config?: PanelConfig };
export function swapPanels(root: LayoutNode, idA: string, idB: string): LayoutNode {
const appA = findPanelApp(root, idA);
const appB = findPanelApp(root, idB);
if (appA === undefined || appB === undefined) return root;
return setApp(setApp(root, idA, appB), idB, appA);
const a = findPanelContents(root, idA);
const b = findPanelContents(root, idB);
if (!a || !b) return root;
return setContents(setContents(root, idA, b), idB, a);
}
function findPanelApp(node: LayoutNode, panelId: string): string | null | undefined {
if (node.type === 'panel') return node.id === panelId ? node.appType : undefined;
export function setApp(root: LayoutNode, panelId: string, appType: string | null): LayoutNode {
// Clearing the app clears its config with it — the next app to occupy the panel would not understand
// the old one's settings, and leaving them would resurrect them if the same app came back later.
return setContents(root, panelId, appType === null ? { appType: null } : { appType, config: findPanelContents(root, panelId)?.config });
}
/**
* Replace an app's opaque settings blob. Storing `undefined` or an empty object drops the key entirely,
* so an unconfigured panel adds nothing to the persisted layout.
*/
export function setPanelConfig(root: LayoutNode, panelId: string, config: PanelConfig | undefined): LayoutNode {
const current = findPanelContents(root, panelId);
if (!current) return root;
return setContents(root, panelId, { appType: current.appType, config });
}
/** Every configured panel in the tree, by id — the reactive view apps read through `usePanelConfig`. */
export function collectPanelConfigs(node: LayoutNode): Record<string, PanelConfig> {
const out: Record<string, PanelConfig> = {};
walk(node);
return out;
function walk(n: LayoutNode): void {
if (n.type === 'panel') {
if (n.config) out[n.id] = n.config;
return;
}
for (const child of n.children) walk(child.node);
}
}
function setContents(root: LayoutNode, panelId: string, contents: PanelContents): LayoutNode {
if (root.type === 'panel') {
if (root.id !== panelId) return root;
const { config: _drop, ...rest } = root;
const next: LayoutPanel = { ...rest, appType: contents.appType };
if (contents.config && Object.keys(contents.config).length > 0) next.config = contents.config;
return next;
}
const newChildren = root.children.map((child) => {
const node = setContents(child.node, panelId, contents);
return node === child.node ? child : { ...child, node };
});
return newChildren.some((c, i) => c !== root.children[i]) ? { ...root, children: newChildren } : root;
}
function findPanelContents(node: LayoutNode, panelId: string): PanelContents | undefined {
if (node.type === 'panel') return node.id === panelId ? { appType: node.appType, config: node.config } : undefined;
for (const child of node.children) {
const result = findPanelApp(child.node, panelId);
const result = findPanelContents(child.node, panelId);
if (result !== undefined) return result;
}
return undefined;
@@ -17,8 +17,21 @@ export type LayoutPanel = {
// 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;
export type HomeRoot = 'home' | '~' | 'officer.dev';
@@ -0,0 +1,36 @@
import { useCallback, useMemo } from 'react';
import { useWorkspace } from './WorkspaceContext';
import type { PanelConfig } from './types';
/**
* Read and write one panel's own settings.
*
* The blob lives on the panel node in the dashboard layout, which means it is persisted server-side,
* survives a reload and a restart, travels with the panel through swap and drag, and is deleted exactly
* when the panel is. The framework never looks inside it — `T` is a promise the calling app makes to
* itself, so validate on read if the shape has changed since a config was written.
*
* `setConfig` takes a whole object or an updater, like `useState`. Writing `undefined` — or an object
* with no keys — removes the config entirely, so an untouched panel adds nothing to the persisted layout.
*
* Returns a no-op setter in a locked or preview workspace (`WorkspaceLayout`, the dashboard-creation
* preview): those render a layout they do not own, and a write would have nowhere to go.
*/
export function usePanelConfig<T extends PanelConfig = PanelConfig>(
panelId: string,
): [T | undefined, (next: T | undefined | ((prev: T | undefined) => T | undefined)) => void] {
const { panelConfigs, setPanelConfig } = useWorkspace();
const config = panelConfigs[panelId] as T | undefined;
const setConfig = useCallback(
(next: T | undefined | ((prev: T | undefined) => T | undefined)) => {
const value = typeof next === 'function' ? next(config) : next;
setPanelConfig(panelId, value);
},
[panelId, config, setPanelConfig],
);
return useMemo(() => [config, setConfig], [config, setConfig]);
}