blob: d4d63ca23d84df41e906c8011ceee044b3eb24f2 [file]
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useResizable } from '@astryxdesign/core/Resizable';
import { readSessionListCollapsed, readSessionListWidth } from './session-list-layout';
import {
readSessionBottomPanelHeight,
readSessionBottomPanelOpen,
readSessionWorkbarCollapsed,
readSessionWorkbarWidth,
SESSION_BOTTOM_PANEL_MAX_HEIGHT,
SESSION_BOTTOM_PANEL_MIN_HEIGHT,
SESSION_WORKBAR_MAX_WIDTH,
SESSION_WORKBAR_MIN_WIDTH,
} from './session-workbar-layout';
import {
activateSessionWorkbarPanelTab,
closeSessionWorkbarPanelTabs,
moveSessionWorkbarPanelTab,
moveSessionWorkbarTabToPanel,
openSessionWorkbarPanelLauncher,
openSessionWorkbarPanelTab,
pinSessionWorkbarPanelTab,
readSessionWorkbarPanels,
reorderSessionWorkbarPanelTab,
titleSessionWorkbarPanelTab,
type SessionWorkbarTab,
type SessionWorkbarTabKind,
type SessionWorkbarPlacement,
} from './session-workbar-tabs';
/**
* Owns the shell layout state (issue #1043): widths, collapse flags and the
* workbar tab controller, 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());
const [bottomPanelOpen, setBottomPanelOpen] = useState(() => readSessionBottomPanelOpen());
// 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,
});
const [bottomPanelDefaultHeight] = useState(() => readSessionBottomPanelHeight());
const bottomPanel = useResizable({
defaultSize: bottomPanelDefaultHeight,
minSizePx: SESSION_BOTTOM_PANEL_MIN_HEIGHT,
maxSizePx: SESSION_BOTTOM_PANEL_MAX_HEIGHT,
});
// `_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],
);
const bottomPanelResizable = useMemo(
() => ({
...bottomPanel.props,
_onResizeMove: (delta: number) =>
bottomPanel.props._onResizeMove(Math.round(delta)),
}),
[bottomPanel.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 [workbarPanelsState, setWorkbarPanelsState] = useState(() =>
readSessionWorkbarPanels(),
);
const workbarPanelsStateRef = useRef(workbarPanelsState);
workbarPanelsStateRef.current = workbarPanelsState;
const pendingPanelAutoCloseRef = useRef(new Set<SessionWorkbarPlacement>());
const markPanelForAutoClose = useCallback(
(placement: SessionWorkbarPlacement, tabIds: readonly string[]) => {
const closing = new Set(tabIds);
if (
workbarPanelsStateRef.current[placement].tabs.some((tab) =>
closing.has(tab.id),
)
) {
pendingPanelAutoCloseRef.current.add(placement);
}
},
[],
);
const openWorkbarTab = useCallback(
(
kind: Exclude<SessionWorkbarTabKind, 'side-chat'>,
placement: SessionWorkbarPlacement = 'right',
options: { preview?: boolean } = {},
) =>
setWorkbarPanelsState((current) =>
openSessionWorkbarPanelTab(current, placement, {
id: `workbar:${kind}`,
kind,
...(options.preview ? { preview: true } : {}),
}),
),
[],
);
const openDynamicWorkbarTab = useCallback(
(tab: SessionWorkbarTab, placement: SessionWorkbarPlacement = 'right') =>
setWorkbarPanelsState((current) =>
openSessionWorkbarPanelTab(current, placement, tab),
),
[],
);
const activateWorkbarTab = useCallback(
(placement: SessionWorkbarPlacement, tabId: string) =>
setWorkbarPanelsState((current) =>
activateSessionWorkbarPanelTab(current, placement, tabId),
),
[],
);
const closeWorkbarTab = useCallback(
(placement: SessionWorkbarPlacement, tabId: string) => {
markPanelForAutoClose(placement, [tabId]);
setWorkbarPanelsState((current) =>
closeSessionWorkbarPanelTabs(current, placement, [tabId]),
);
},
[markPanelForAutoClose],
);
const closeWorkbarTabs = useCallback(
(placement: SessionWorkbarPlacement, tabIds: readonly string[]) => {
markPanelForAutoClose(placement, tabIds);
setWorkbarPanelsState((current) =>
closeSessionWorkbarPanelTabs(current, placement, tabIds),
);
},
[markPanelForAutoClose],
);
const reorderWorkbarTab = useCallback(
(placement: SessionWorkbarPlacement, tabId: string, targetTabId: string) =>
setWorkbarPanelsState((current) =>
reorderSessionWorkbarPanelTab(current, placement, tabId, targetTabId),
),
[],
);
const moveWorkbarTab = useCallback(
(
placement: SessionWorkbarPlacement,
tabId: string,
direction: 'left' | 'right',
) =>
setWorkbarPanelsState((current) =>
moveSessionWorkbarPanelTab(current, placement, tabId, direction),
),
[],
);
const openWorkbarLauncher = useCallback(
(placement: SessionWorkbarPlacement = 'right') =>
setWorkbarPanelsState((current) =>
openSessionWorkbarPanelLauncher(current, placement),
),
[],
);
const moveWorkbarTabToPanel = useCallback(
(tabId: string, target: SessionWorkbarPlacement) => {
setWorkbarPanelsState((current) =>
moveSessionWorkbarTabToPanel(current, tabId, target),
);
if (target === 'right') setWorkbarCollapsed(false);
else setBottomPanelOpen(true);
},
[],
);
const titleWorkbarTab = useCallback((tabId: string, title: string) => {
setWorkbarPanelsState((current) =>
titleSessionWorkbarPanelTab(current, tabId, title),
);
}, []);
const pinWorkbarTab = useCallback((tabId: string) => {
setWorkbarPanelsState((current) =>
pinSessionWorkbarPanelTab(current, tabId),
);
}, []);
useEffect(() => {
for (const placement of ['right', 'bottom'] as const) {
if (!pendingPanelAutoCloseRef.current.delete(placement)) continue;
if (workbarPanelsState[placement].tabs.length > 0) continue;
if (placement === 'right') setWorkbarCollapsed(true);
else setBottomPanelOpen(false);
}
}, [workbarPanelsState]);
return {
sessionListWidth,
setSessionListWidth,
sessionListCollapsed,
setSessionListCollapsed,
workbarCollapsed,
setWorkbarCollapsed,
bottomPanelOpen,
setBottomPanelOpen,
workbarWidth: workbar.size,
workbarResizable,
bottomPanelHeight: bottomPanel.size,
bottomPanelResizable,
workbarPanelsState,
openWorkbarTab,
openDynamicWorkbarTab,
activateWorkbarTab,
closeWorkbarTab,
closeWorkbarTabs,
reorderWorkbarTab,
moveWorkbarTab,
moveWorkbarTabToPanel,
titleWorkbarTab,
pinWorkbarTab,
openWorkbarLauncher,
};
}