add useSessionState and persist the maximized panel per tab
This commit is contained in:
@@ -202,6 +202,7 @@ const { jobs, isLoading } = useJobs();
|
|||||||
| Shared UI state | `useGlobal` |
|
| Shared UI state | `useGlobal` |
|
||||||
| URL-driven state (filters, pagination) | `useQueryState` |
|
| URL-driven state (filters, pagination) | `useQueryState` |
|
||||||
| Component-only state | `useState` |
|
| Component-only state | `useState` |
|
||||||
|
| Shared UI state that must survive a refresh, per tab | `useSessionState` |
|
||||||
| Persisted to localStorage | `useLocalStorageState` |
|
| Persisted to localStorage | `useLocalStorageState` |
|
||||||
|
|
||||||
## Event Handlers
|
## Event Handlers
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback, useEffect, useRef } from 'react';
|
import { useCallback, useEffect } from 'react';
|
||||||
import { useLocation } from 'react-router';
|
import { useLocation } from 'react-router';
|
||||||
import { useGlobal } from 'hooks/useGlobal';
|
import { useSessionState, writeSessionValue } from 'hooks/useSessionState';
|
||||||
|
|
||||||
type TitleRule = { match: (p: string) => boolean; title: string };
|
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
|
// **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
|
// 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.
|
// 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_LABEL_KEY = 'OFFICER_TAB_LABEL';
|
||||||
const TAB_ID_KEY = 'OFFICER_TAB_ID';
|
const TAB_ID_KEY = 'OFFICER_TAB_ID';
|
||||||
const IDENTITY_CHANNEL = 'officer-tab-identity';
|
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 {
|
function readStored(key: string): string | null {
|
||||||
try {
|
try {
|
||||||
return sessionStorage.getItem(key);
|
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 {
|
try {
|
||||||
if (value) sessionStorage.setItem(key, value);
|
sessionStorage.setItem(key, value);
|
||||||
else sessionStorage.removeItem(key);
|
|
||||||
} catch {
|
} catch {
|
||||||
/* the value just doesn't persist */
|
/* 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>();
|
const labelDroppedHandlers = new Set<() => void>();
|
||||||
|
|
||||||
/** The clone check answers late, so React may already be showing the inherited name when it lands. */
|
/** 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.
|
// Someone alive is already this tab, so we are the copy. Take a new id and give up the name.
|
||||||
tabId = newTabId();
|
tabId = newTabId();
|
||||||
writeStored(TAB_ID_KEY, tabId);
|
writeStored(TAB_ID_KEY, tabId);
|
||||||
writeTabLabel(null);
|
writeSessionValue<string | null>(TAB_LABEL_KEY, null);
|
||||||
for (const handler of labelDroppedHandlers) handler();
|
for (const handler of labelDroppedHandlers) handler();
|
||||||
};
|
};
|
||||||
channel.postMessage({ kind: 'claim', tabId });
|
channel.postMessage({ kind: 'claim', tabId });
|
||||||
@@ -151,23 +151,11 @@ claimTabIdentity();
|
|||||||
*/
|
*/
|
||||||
export function usePageTitle() {
|
export function usePageTitle() {
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
const [label, setLabel] = useGlobal<string | null>('TAB_LABEL', readTabLabel);
|
const [label, setLabel] = useSessionState<string | null>(TAB_LABEL_KEY, null);
|
||||||
|
|
||||||
// `setLabel` is rebuilt every render, so it is read through a ref rather than listed as a dependency —
|
useEffect(() => onTabLabelDropped(() => setLabel(null)), [setLabel]);
|
||||||
// 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)), []);
|
|
||||||
|
|
||||||
// The React Query copy is what re-renders every consumer; sessionStorage is what survives the reload.
|
const rename = useCallback((next: string) => setLabel(next.trim() || null), [setLabel]);
|
||||||
const rename = useCallback(
|
|
||||||
(next: string) => {
|
|
||||||
const trimmed = next.trim() || null;
|
|
||||||
writeTabLabel(trimmed);
|
|
||||||
setLabel(trimmed);
|
|
||||||
},
|
|
||||||
[setLabel],
|
|
||||||
);
|
|
||||||
|
|
||||||
return [label ?? titleForPath(pathname), rename] as const;
|
return [label ?? titleForPath(pathname), rename] as const;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<T>(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<T>(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<T>(key: string, fallback: T): T {
|
||||||
|
const stored = readStored<T>(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<T>(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<T>(['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<T>) => {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { useState, useCallback, useEffect, useRef, useMemo, type ComponentRef }
|
|||||||
import { flushSync } from 'react-dom';
|
import { flushSync } from 'react-dom';
|
||||||
import { ResizablePanel, ResizablePanelGroup, ResizableHandle } from '@/components/ui/resizable';
|
import { ResizablePanel, ResizablePanelGroup, ResizableHandle } from '@/components/ui/resizable';
|
||||||
import { useIsMobile } from 'hooks/useIsMobile';
|
import { useIsMobile } from 'hooks/useIsMobile';
|
||||||
|
import { useSessionState } from 'hooks/useSessionState';
|
||||||
import type { LayoutNode, DashboardState, EphemeralPanels, PanelComponents } from './types';
|
import type { LayoutNode, DashboardState, EphemeralPanels, PanelComponents } from './types';
|
||||||
import type { DefaultFileSort } from './WorkspaceContext';
|
import type { DefaultFileSort } from './WorkspaceContext';
|
||||||
import type { DropPosition } from './layout-utils';
|
import type { DropPosition } from './layout-utils';
|
||||||
@@ -34,7 +35,12 @@ export const WorkspaceView = ({ workspace, locked, cwd = '~', root, initialFileP
|
|||||||
const onLayoutChange = workspace.setValue;
|
const onLayoutChange = workspace.setValue;
|
||||||
const [swapSourceId, setSwapSourceId] = useState<string | null>(null);
|
const [swapSourceId, setSwapSourceId] = useState<string | null>(null);
|
||||||
const [dragSourceId, setDragSourceId] = useState<string | null>(null);
|
const [dragSourceId, setDragSourceId] = useState<string | null>(null);
|
||||||
const [maximizedPanelId, setMaximizedPanelId] = useState<string | null>(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<string | null>(maximizedKey, null);
|
||||||
const [transitioningPanelId, setTransitioningPanelId] = useState<string | null>(null);
|
const [transitioningPanelId, setTransitioningPanelId] = useState<string | null>(null);
|
||||||
|
|
||||||
const setMaximizedAnimated = useCallback((id: string | null) => {
|
const setMaximizedAnimated = useCallback((id: string | null) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user