diff --git a/src/apps/CLAUDE.md b/src/apps/CLAUDE.md index e8b179ea..4c8945a2 100644 --- a/src/apps/CLAUDE.md +++ b/src/apps/CLAUDE.md @@ -202,6 +202,7 @@ const { jobs, isLoading } = useJobs(); | Shared UI state | `useGlobal` | | URL-driven state (filters, pagination) | `useQueryState` | | Component-only state | `useState` | +| Shared UI state that must survive a refresh, per tab | `useSessionState` | | Persisted to localStorage | `useLocalStorageState` | ## Event Handlers diff --git a/src/apps/officer-web/state/usePageTitle.ts b/src/apps/officer-web/state/usePageTitle.ts index 7db4ce2c..a80019f8 100644 --- a/src/apps/officer-web/state/usePageTitle.ts +++ b/src/apps/officer-web/state/usePageTitle.ts @@ -1,6 +1,6 @@ -import { useCallback, useEffect, useRef } from 'react'; +import { useCallback, useEffect } from 'react'; import { useLocation } from 'react-router'; -import { useGlobal } from 'hooks/useGlobal'; +import { useSessionState, writeSessionValue } from 'hooks/useSessionState'; type TitleRule = { match: (p: string) => boolean; title: string }; @@ -51,12 +51,16 @@ export function titleForPath(pathname: string): string { // **sessionStorage is already the per-tab store.** It is separate per tab, survives a refresh and // in-place navigation, and is discarded when the tab closes, which is exactly the lifetime of a tab // name. localStorage would be wrong in the obvious way: every tab would share one name. +// +// `useSessionState` is that pairing as a hook (see `hooks/useSessionState`); the name is one of its +// consumers. The tab *id* below stays on raw storage because it is claimed at module load, before any +// component exists to hold it. const TAB_LABEL_KEY = 'OFFICER_TAB_LABEL'; const TAB_ID_KEY = 'OFFICER_TAB_ID'; const IDENTITY_CHANNEL = 'officer-tab-identity'; -/** Storage can throw outright (private mode, storage disabled). A tab name is not worth a crash. */ +/** Storage can throw outright (private mode, storage disabled). A tab id is not worth a crash. */ function readStored(key: string): string | null { try { return sessionStorage.getItem(key); @@ -65,18 +69,14 @@ function readStored(key: string): string | null { } } -function writeStored(key: string, value: string | null): void { +function writeStored(key: string, value: string): void { try { - if (value) sessionStorage.setItem(key, value); - else sessionStorage.removeItem(key); + sessionStorage.setItem(key, value); } catch { /* the value just doesn't persist */ } } -const readTabLabel = (): string | null => readStored(TAB_LABEL_KEY); -const writeTabLabel = (label: string | null): void => writeStored(TAB_LABEL_KEY, label); - const labelDroppedHandlers = new Set<() => void>(); /** The clone check answers late, so React may already be showing the inherited name when it lands. */ @@ -127,7 +127,7 @@ function claimTabIdentity(): void { // Someone alive is already this tab, so we are the copy. Take a new id and give up the name. tabId = newTabId(); writeStored(TAB_ID_KEY, tabId); - writeTabLabel(null); + writeSessionValue(TAB_LABEL_KEY, null); for (const handler of labelDroppedHandlers) handler(); }; channel.postMessage({ kind: 'claim', tabId }); @@ -151,23 +151,11 @@ claimTabIdentity(); */ export function usePageTitle() { const { pathname } = useLocation(); - const [label, setLabel] = useGlobal('TAB_LABEL', readTabLabel); + const [label, setLabel] = useSessionState(TAB_LABEL_KEY, null); - // `setLabel` is rebuilt every render, so it is read through a ref rather than listed as a dependency — - // as a dependency it would tear the subscription down and rebuild it on every render. - const setLabelRef = useRef(setLabel); - setLabelRef.current = setLabel; - useEffect(() => onTabLabelDropped(() => setLabelRef.current(null)), []); + useEffect(() => onTabLabelDropped(() => setLabel(null)), [setLabel]); - // The React Query copy is what re-renders every consumer; sessionStorage is what survives the reload. - const rename = useCallback( - (next: string) => { - const trimmed = next.trim() || null; - writeTabLabel(trimmed); - setLabel(trimmed); - }, - [setLabel], - ); + const rename = useCallback((next: string) => setLabel(next.trim() || null), [setLabel]); return [label ?? titleForPath(pathname), rename] as const; } diff --git a/src/workspaces/hooks/src/useSessionState.ts b/src/workspaces/hooks/src/useSessionState.ts new file mode 100644 index 00000000..5fcf7053 --- /dev/null +++ b/src/workspaces/hooks/src/useSessionState.ts @@ -0,0 +1,97 @@ +import { useCallback, useMemo, useRef } from 'react'; +import { useGlobal } from './useGlobal'; + +// ── State that belongs to *this tab*, and survives a refresh ── +// `useGlobal` is the shared in-memory store: every consumer of a key re-renders together, with no +// provider. What it cannot do is outlive the document — a reload starts from the initial value again. +// `useLocalStorageState` outlives it but is shared by every tab at once, which is wrong for anything +// describing *this window* (which panel you maximised, what you named the tab): two windows would fight +// over one value. +// +// sessionStorage is the per-tab store — separate per tab, survives a reload and in-place navigation, +// discarded when the tab closes. So: `useGlobal` for the live value, sessionStorage as the write-through +// copy that the next document reads back. Storage can throw outright (private mode, storage disabled), +// and no piece of UI state is worth a crash, so every access fails soft to "not persisted". + +function readStored(key: string): { hit: true; value: T } | { hit: false } { + try { + const raw = sessionStorage.getItem(key); + if (raw === null) return { hit: false }; + try { + return { hit: true, value: JSON.parse(raw) as T }; + } catch { + // A value written before this key was JSON-encoded. Strings round-trip; anything else was garbage + // already. + return { hit: true, value: raw as unknown as T }; + } + } catch { + return { hit: false }; + } +} + +/** Write a session value from outside React — for module-level code that has no hook to call. */ +export function writeSessionValue(key: string, value: T): void { + try { + sessionStorage.setItem(key, JSON.stringify(value)); + } catch { + /* the value just doesn't persist */ + } +} + +/** Read a session value from outside React. Returns `fallback` when nothing is stored. */ +export function readSessionValue(key: string, fallback: T): T { + const stored = readStored(key); + return stored.hit ? stored.value : fallback; +} + +export function clearSessionValue(key: string): void { + try { + sessionStorage.removeItem(key); + } catch { + /* nothing to clear */ + } +} + +/** + * `useGlobal`, but the value comes back after a refresh. + * + * Same shape as `useState`: a value, a setter that accepts an updater, and a `reset` that forgets the + * stored copy and returns to `initialValue`. `key` is the sessionStorage key verbatim — scope it + * yourself (`` `MAXIMIZED_PANEL:${dashboardId}` ``) when one screen needs one value per thing. + * + * `null` is a real stored value, not an absence: setting it persists "explicitly nothing", which is the + * difference between a panel you un-maximised and one you never maximised. + */ +export function useSessionState(key: string, initialValue: T) { + // Read the stored value once per mount rather than on every render — `useGlobal` re-evaluates its + // initialData each time, and only the first result is ever used. `initialValue` is deliberately not a + // dependency: it is the value for a key with nothing stored, and re-reading on a new identity of the + // same default would only churn. + const initial = useMemo(() => readSessionValue(key, initialValue), [key]); + const [value, setGlobal] = useGlobal(['SESSION_STATE', key], initial); + + // Both are rebuilt every render, so they are read through refs — as dependencies they would give the + // setter a new identity on every render and defeat every memo downstream. + const valueRef = useRef(value); + valueRef.current = value; + const setGlobalRef = useRef(setGlobal); + setGlobalRef.current = setGlobal; + + const setValue = useCallback( + (arg: React.SetStateAction) => { + const next = typeof arg === 'function' ? (arg as (prev: T) => T)(valueRef.current) : arg; + writeSessionValue(key, next); + setGlobalRef.current(next); + }, + [key], + ); + + const reset = useCallback(() => { + clearSessionValue(key); + setGlobalRef.current(initialValue); + // `initialValue` is read at reset time on purpose: resetting means "whatever the default is now". + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [key]); + + return [value, setValue, reset] as const; +} diff --git a/src/workspaces/officerdev/src/components/Workspace/WorkspaceView.tsx b/src/workspaces/officerdev/src/components/Workspace/WorkspaceView.tsx index 20e3c3be..adf60298 100644 --- a/src/workspaces/officerdev/src/components/Workspace/WorkspaceView.tsx +++ b/src/workspaces/officerdev/src/components/Workspace/WorkspaceView.tsx @@ -2,6 +2,7 @@ import { useState, useCallback, useEffect, useRef, useMemo, type ComponentRef } 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 { DefaultFileSort } from './WorkspaceContext'; import type { DropPosition } from './layout-utils'; @@ -34,7 +35,12 @@ export const WorkspaceView = ({ workspace, locked, cwd = '~', root, initialFileP const onLayoutChange = workspace.setValue; const [swapSourceId, setSwapSourceId] = useState(null); const [dragSourceId, setDragSourceId] = useState(null); - const [maximizedPanelId, setMaximizedPanelId] = useState(null); + // Per tab and per dashboard: maximising the chat panel and refreshing should come back maximised, while + // a second window on the same screen keeps its own idea of what is maximised. A stored id whose panel + // has since been removed simply maximises nothing — maximize is a style toggle on the panel itself, so + // there is nothing to strand. + const maximizedKey = `MAXIMIZED_PANEL:${workspace.key}`; + const [maximizedPanelId, setMaximizedPanelId] = useSessionState(maximizedKey, null); const [transitioningPanelId, setTransitioningPanelId] = useState(null); const setMaximizedAnimated = useCallback((id: string | null) => {