blob: 698302a9185ee444ec940408e072a32f3b8fc501 [file]
import { useEffect, useMemo, useState } from 'react';
import { useResizable } from '@astryxdesign/core/Resizable';
import { readSessionListCollapsed, readSessionListWidth } from './session-list-layout';
import {
readSessionWorkbarCollapsed,
readSessionWorkbarTab,
readSessionWorkbarWidth,
SESSION_WORKBAR_MAX_WIDTH,
SESSION_WORKBAR_MIN_WIDTH,
} from './session-workbar-layout';
/**
* Owns the shell layout state (issue #1043): widths, collapse flags and the
* active workbar tab, each hydrated from localStorage on first render and
* persisted by `useAppShellPersistenceEffects`.
*
* `useResizable` owns the workbar's clamping and its pointer/keyboard resizing
* (issue #1861), but not its persistence: `autoSaveId` writes synchronously on
* every committed size, which is ~90 localStorage writes for one drag. Storage
* stays on the same debounced effect as the session list, so seeding
* `defaultSize` from storage is what closes the loop.
*/
export function useShellLayout() {
const [sessionListWidth, setSessionListWidth] = useState(() => readSessionListWidth());
const [sessionListCollapsed, setSessionListCollapsed] = useState(() => readSessionListCollapsed());
const [workbarCollapsed, setWorkbarCollapsed] = useState(() => readSessionWorkbarCollapsed());
// Read once: useResizable only consumes defaultSize on its first render, and
// AppShell re-renders on every stream tick.
const [workbarDefaultWidth] = useState(() => readSessionWorkbarWidth());
const workbar = useResizable({
defaultSize: workbarDefaultWidth,
minSizePx: SESSION_WORKBAR_MIN_WIDTH,
maxSizePx: SESSION_WORKBAR_MAX_WIDTH,
});
// `_onResizeMove` takes the distance from where the pointer went down, not a
// per-move increment, so rounding here quantises the drag to whole pixels
// without accumulating drift. Every other delta the handle sends (arrow keys,
// Home/End) is already integral. Keeping the hook's own state integral is what
// lets `aria-valuenow`, the CSS variable and storage agree on one number —
// rounding at the consumer only fixed the last two.
const workbarResizable = useMemo(
() => ({
...workbar.props,
_onResizeMove: (delta: number) => workbar.props._onResizeMove(Math.round(delta)),
}),
[workbar.props],
);
// Astryx ends a drag on pointerup, pointercancel or unmount, but not on focus
// loss: Cmd+Tab mid-drag and release outside the app leaves the listeners
// attached and `body` stuck at `cursor: col-resize; user-select: none`. The
// deleted hand-rolled resize cleaned up on blur; this routes blur back into
// Astryx's own cancel path rather than re-growing a second teardown.
useEffect(() => {
const cancelDrag = () => window.dispatchEvent(new PointerEvent('pointercancel'));
window.addEventListener('blur', cancelDrag);
return () => window.removeEventListener('blur', cancelDrag);
}, []);
const [workbarTab, setWorkbarTab] = useState(() => readSessionWorkbarTab());
return {
sessionListWidth,
setSessionListWidth,
sessionListCollapsed,
setSessionListCollapsed,
workbarCollapsed,
setWorkbarCollapsed,
workbarWidth: workbar.size,
workbarResizable,
workbarTab,
setWorkbarTab,
};
}