diff --git a/nanobot/channels/websocket/tests/test_websocket_channel.py b/nanobot/channels/websocket/tests/test_websocket_channel.py index 1a19b0e21..a0bdd8410 100644 --- a/nanobot/channels/websocket/tests/test_websocket_channel.py +++ b/nanobot/channels/websocket/tests/test_websocket_channel.py @@ -1060,7 +1060,6 @@ async def test_webui_sidebar_state_update_broadcasts_workbench_to_other_devices( "title": "Research", "paneKeys": ["websocket:a", "websocket:b"], "layoutPaneKeys": ["websocket:b", "websocket:a"], - "activePaneKey": "websocket:a", "layout": "columns", "splitRatios": [0.35], } diff --git a/nanobot/webui/sidebar_state.py b/nanobot/webui/sidebar_state.py index 1a0b80f53..c28b95610 100644 --- a/nanobot/webui/sidebar_state.py +++ b/nanobot/webui/sidebar_state.py @@ -10,6 +10,7 @@ from __future__ import annotations import json import math import os +import threading import time from pathlib import Path from typing import Any, cast @@ -29,6 +30,7 @@ _MAX_WORKBENCH_PANES = 4 _ALLOWED_DENSITIES = {"comfortable", "compact"} _ALLOWED_SORTS = {"updated_desc", "created_desc", "title_asc", "manual"} _ALLOWED_WORKBENCH_LAYOUTS = {"columns", "rows", "grid", "bsp", "main-stack"} +_SIDEBAR_STATE_WRITE_LOCK = threading.Lock() def webui_sidebar_state_path() -> Path: @@ -152,7 +154,10 @@ def _clean_view(value: Any) -> dict[str, Any]: def _clean_workbench(value: Any) -> dict[str, Any]: if not isinstance(value, dict): return {"version": 1, "tabs": {}} - raw_tabs = cast(dict[str, Any], value).get("tabs") + workbench = cast(dict[str, Any], value) + if workbench.get("version") != 1: + return {"version": 1, "tabs": {}} + raw_tabs = workbench.get("tabs") if not isinstance(raw_tabs, dict): return {"version": 1, "tabs": {}} @@ -170,6 +175,9 @@ def _clean_workbench(value: Any) -> dict[str, Any]: ][:_MAX_WORKBENCH_PANES] if not pane_keys: continue + explicit = tab.get("explicit") is True + if not explicit and len(pane_keys) == 1: + continue requested_layout_pane_keys = [ key for key in _clean_string_list(tab.get("layoutPaneKeys")) if key in pane_keys ] @@ -180,15 +188,11 @@ def _clean_workbench(value: Any) -> dict[str, Any]: raw_layout = tab.get("layout") layout = raw_layout if raw_layout in _ALLOWED_WORKBENCH_LAYOUTS else "columns" title = _clean_string(tab.get("title"), max_len=_MAX_TITLE_LEN) - active_pane_key = _clean_string(tab.get("activePaneKey")) tabs[tab_key] = { - "explicit": tab.get("explicit") is True, + "explicit": explicit, "title": title, "paneKeys": pane_keys, "layoutPaneKeys": layout_pane_keys, - "activePaneKey": ( - active_pane_key if active_pane_key in pane_keys else pane_keys[0] - ), "layout": layout, "splitRatios": _clean_split_ratios(tab.get("splitRatios")), } @@ -234,6 +238,11 @@ def read_webui_sidebar_state() -> dict[str, Any]: def write_webui_sidebar_state(raw: dict[str, Any]) -> dict[str, Any]: + with _SIDEBAR_STATE_WRITE_LOCK: + return _write_webui_sidebar_state(raw) + + +def _write_webui_sidebar_state(raw: dict[str, Any]) -> dict[str, Any]: state = normalize_webui_sidebar_state(raw) state["updated_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) encoded = json.dumps( diff --git a/tests/utils/test_webui_sidebar_state.py b/tests/utils/test_webui_sidebar_state.py index 43779f157..c61ff52a2 100644 --- a/tests/utils/test_webui_sidebar_state.py +++ b/tests/utils/test_webui_sidebar_state.py @@ -1,5 +1,9 @@ import json +import threading +import time +from concurrent.futures import ThreadPoolExecutor +import nanobot.webui.sidebar_state as sidebar_state from nanobot.webui.sidebar_state import ( default_webui_sidebar_state, read_webui_sidebar_state, @@ -39,13 +43,11 @@ def test_sidebar_state_normalizes_partial_payload(tmp_path, monkeypatch) -> None "title": " Research ", "paneKeys": ["websocket:a", "websocket:b", "websocket:a"], "layoutPaneKeys": ["websocket:b", "missing", "websocket:a"], - "activePaneKey": "missing", "layout": "invalid-layout", "splitRatios": [0.4, 2, "bad", float("nan")], }, "tab:websocket:b": { "paneKeys": ["websocket:b", "websocket:c"], - "activePaneKey": "websocket:c", "layout": "bsp", }, }, @@ -74,19 +76,9 @@ def test_sidebar_state_normalizes_partial_payload(tmp_path, monkeypatch) -> None "title": "Research", "paneKeys": ["websocket:a", "websocket:b"], "layoutPaneKeys": ["websocket:b", "websocket:a"], - "activePaneKey": "websocket:a", "layout": "columns", "splitRatios": [0.4, 0.95], }, - "tab:websocket:b": { - "explicit": False, - "title": None, - "paneKeys": ["websocket:c"], - "layoutPaneKeys": ["websocket:c"], - "activePaneKey": "websocket:c", - "layout": "bsp", - "splitRatios": [], - }, }, } assert state["view"] == { @@ -122,3 +114,67 @@ def test_sidebar_state_write_is_scoped_to_config_data_dir(tmp_path, monkeypatch) assert state["view"]["sort"] == "manual" assert webui_sidebar_state_path().is_file() assert read_webui_sidebar_state()["pinned_keys"] == ["websocket:a"] + + +def test_sidebar_state_persists_only_visible_workbench_groups(tmp_path, monkeypatch) -> None: + monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path) + tabs = { + f"tab:websocket:{index}": { + "explicit": False, + "paneKeys": [f"websocket:{index}"], + "layoutPaneKeys": [f"websocket:{index}"], + "layout": "columns", + "splitRatios": [], + } + for index in range(2_000) + } + + state = write_webui_sidebar_state({"workbench": {"version": 1, "tabs": tabs}}) + + assert state["workbench"] == {"version": 1, "tabs": {}} + assert webui_sidebar_state_path().stat().st_size < 2_048 + + +def test_sidebar_state_requires_supported_workbench_version(tmp_path, monkeypatch) -> None: + monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path) + + state = write_webui_sidebar_state( + { + "workbench": { + "version": 2, + "tabs": { + "tab:websocket:a": { + "explicit": True, + "paneKeys": ["websocket:a"], + } + }, + } + } + ) + + assert state["workbench"] == {"version": 1, "tabs": {}} + + +def test_sidebar_state_serializes_concurrent_writes(monkeypatch) -> None: + counter_lock = threading.Lock() + active_writes = 0 + peak_writes = 0 + + def fake_write(raw: dict[str, object]) -> dict[str, object]: + nonlocal active_writes, peak_writes + with counter_lock: + active_writes += 1 + peak_writes = max(peak_writes, active_writes) + time.sleep(0.01) + with counter_lock: + active_writes -= 1 + return raw + + monkeypatch.setattr(sidebar_state, "_write_webui_sidebar_state", fake_write) + payloads = [{"write": index} for index in range(12)] + + with ThreadPoolExecutor(max_workers=4) as executor: + results = list(executor.map(sidebar_state.write_webui_sidebar_state, payloads)) + + assert results == payloads + assert peak_writes == 1 diff --git a/webui/src/App.tsx b/webui/src/App.tsx index 58c7db163..e2e7939c3 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -17,15 +17,12 @@ import type { SettingsSectionKey } from "@/components/settings/SettingsView"; import { ThreadShell } from "@/components/thread/ThreadShell"; import { PaneWorkbench } from "@/components/workbench/PaneWorkbench"; import { - EMPTY_WORKBENCH_STATE, MAX_WORKBENCH_PANES, addWorkbenchPane, attachWorkbenchPane, createWorkbenchTab, detachWorkbenchPane, dissolveWorkbenchTab, - ensureWorkbenchPaneTab, - focusWorkbenchPane, orderWorkbenchTabs, reconcileWorkbench, renameWorkbenchTab, @@ -1060,12 +1057,16 @@ function Shell({ const [hostSidebarPreviewOpen, setHostSidebarPreviewOpen] = useState(false); const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); const [sessionSearchOpen, setSessionSearchOpen] = useState(false); - const [workbenchState, setWorkbenchState] = useState( - EMPTY_WORKBENCH_STATE, - ); - const workbenchServerHydratedRef = useRef(false); - const lastServerWorkbenchRef = useRef(""); - const skipWorkbenchPersistenceRef = useRef(false); + const workbenchState = sidebarState.workbench; + const updateWorkbenchState = useCallback(( + updater: (current: WorkbenchState) => WorkbenchState, + ) => { + void updateSidebarState((current) => { + const next = updater(current.workbench); + return next === current.workbench ? current : { ...current, workbench: next }; + }); + }, [updateSidebarState]); + const lastActivePaneByTabRef = useRef(new Map()); const [creatingPane, setCreatingPane] = useState(false); const topicSessions = sessions; const [pendingDelete, setPendingDelete] = useState<{ @@ -1183,19 +1184,6 @@ function Shell({ }; }, [getToken]); - useEffect(() => { - if (sidebarStateLoading) return; - const serialized = JSON.stringify(sidebarState.workbench); - if ( - workbenchServerHydratedRef.current - && lastServerWorkbenchRef.current === serialized - ) return; - workbenchServerHydratedRef.current = true; - lastServerWorkbenchRef.current = serialized; - skipWorkbenchPersistenceRef.current = true; - setWorkbenchState(sidebarState.workbench); - }, [sidebarState.workbench, sidebarStateLoading]); - useEffect(() => { try { window.localStorage.setItem( @@ -1207,21 +1195,6 @@ function Shell({ } }, [hostSidebarOpen]); - useEffect(() => { - if (!workbenchServerHydratedRef.current || sidebarStateLoading) return; - if (skipWorkbenchPersistenceRef.current) { - skipWorkbenchPersistenceRef.current = false; - return; - } - const serialized = JSON.stringify(workbenchState); - if (serialized === JSON.stringify(sidebarState.workbench)) return; - lastServerWorkbenchRef.current = serialized; - void updateSidebarState((current) => ({ - ...current, - workbench: workbenchState, - })); - }, [sidebarState.workbench, sidebarStateLoading, updateSidebarState, workbenchState]); - useEffect(() => { writeSessionUpdateChatIds(updatedChatIds); }, [updatedChatIds]); @@ -1292,11 +1265,11 @@ function Shell({ ), [activeKey, temporarySessions, workbenchState]); const activeTabKey = activeTabMatch?.tabKey ?? null; const activeTabState = activeTabMatch?.tab ?? null; - const activePaneSession = useMemo(() => { - if (!activeTabState) return activeSession; - return sessions.find((session) => session.key === activeTabState.activePaneKey) - ?? activeSession; - }, [activeSession, activeTabState, sessions]); + const activePaneSession = activeSession; + useEffect(() => { + if (!activeTabKey || !activeKey || !activeTabState?.paneKeys.includes(activeKey)) return; + lastActivePaneByTabRef.current.set(activeTabKey, activeKey); + }, [activeKey, activeTabKey, activeTabState]); const runningChatIdList = useMemo(() => Array.from(runningChatIds), [runningChatIds]); const updatedChatIdList = useMemo(() => Array.from(updatedChatIds), [updatedChatIds]); const activeChatId = activePaneSession?.chatId ?? null; @@ -1364,20 +1337,14 @@ function Shell({ useEffect(() => { if (loading || sidebarStateLoading) return; const validKeys = new Set(sessions.map((session) => session.key)); - setWorkbenchState((current) => { - const reconciled = reconcileWorkbench(current, validKeys); - if (!activeKey || temporarySessions[activeKey] || !validKeys.has(activeKey)) { - return reconciled; - } - const match = workbenchTabForPane(reconciled, activeKey); - return focusWorkbenchPane(reconciled, match.tabKey, activeKey); + updateWorkbenchState((current) => { + return reconcileWorkbench(current, validKeys); }); }, [ - activeKey, loading, sidebarStateLoading, sessions, - temporarySessions, + updateWorkbenchState, ]); useEffect(() => { @@ -1817,11 +1784,11 @@ function Shell({ const onConfirmTabRename = useCallback((title: string) => { if (!pendingTabRename) return; - setWorkbenchState((current) => ( + updateWorkbenchState((current) => ( renameWorkbenchTab(current, pendingTabRename.key, title) )); setPendingTabRename(null); - }, [pendingTabRename]); + }, [pendingTabRename, updateWorkbenchState]); const onToggleGroup = useCallback( (groupId: string) => { @@ -1940,11 +1907,7 @@ function Shell({ const chatId = await createChat(scope); const paneKey = `websocket:${chatId}`; pendingCreatedSessionKeyRef.current = paneKey; - setWorkbenchState((current) => { - const withTab = ensureWorkbenchPaneTab(current, activeKey); - const target = workbenchTabForPane(withTab, activeKey); - return addWorkbenchPane(withTab, target.tabKey, paneKey); - }); + updateWorkbenchState((current) => addWorkbenchPane(current, activeKey, paneKey)); navigate({ view: "chat", activeKey: paneKey, @@ -1974,6 +1937,7 @@ function Shell({ creatingPane, navigate, t, + updateWorkbenchState, ]); useEffect(() => { @@ -2395,9 +2359,7 @@ function Shell({ titleForSession, workbenchPaneSessions, ]); - const renderedActivePaneKey = paneChromeEnabled && activeTabState - ? activeTabState.activePaneKey - : renderedWorkbenchPanes[0].key; + const renderedActivePaneKey = activeKey ?? renderedWorkbenchPanes[0].key; const renderedWorkbenchLayout = paneChromeEnabled && activeTabState ? activeTabState.layout : "columns"; @@ -2419,12 +2381,15 @@ function Shell({ return [presentation.rowKey, { tabKey: orderedTab.tabKey, title: presentation.title, - activePaneKey: orderedTab.tab.activePaneKey, + activePaneKey: activeKey && orderedTab.paneKeys.includes(activeKey) + ? activeKey + : orderedTab.paneKeys[0], visible: orderedTab.tab.explicit || orderedTab.paneKeys.length > 1, panes, }]; })); }, [ + activeKey, sessions, sidebarTabPresentations, titleForSession, @@ -2434,14 +2399,18 @@ function Shell({ ); const onActivateWorkbenchPane = useCallback((paneKey: string) => { - if (!activeTabKey) return; - setWorkbenchState((current) => focusWorkbenchPane(current, activeTabKey, paneKey)); onSelectChat(paneKey); - }, [activeTabKey, onSelectChat]); + }, [onSelectChat]); const onSelectSidebarTab = useCallback((tabKey: string) => { const tab = workbenchTab(workbenchState, tabKey); - if (tab) onSelectChat(tab.activePaneKey); + if (!tab) return; + const rememberedPaneKey = lastActivePaneByTabRef.current.get(tabKey); + onSelectChat( + rememberedPaneKey && tab.paneKeys.includes(rememberedPaneKey) + ? rememberedPaneKey + : tab.paneKeys[0], + ); }, [onSelectChat, workbenchState]); const onSelectSidebarItem = useCallback((key: string) => { @@ -2455,33 +2424,32 @@ function Shell({ onSelectSidebarTab(key); }, [onSelectChat, onSelectSidebarTab, sessions]); - const onSelectSidebarPane = useCallback((tabKey: string, paneKey: string) => { - setWorkbenchState((current) => focusWorkbenchPane(current, tabKey, paneKey)); + const onSelectSidebarPane = useCallback((_tabKey: string, paneKey: string) => { onSelectChat(paneKey); }, [onSelectChat]); const onDetachWorkbenchPane = useCallback((tabKey: string, paneKey: string) => { - setWorkbenchState((current) => detachWorkbenchPane(current, tabKey, paneKey)); - }, []); + updateWorkbenchState((current) => detachWorkbenchPane(current, tabKey, paneKey)); + }, [updateWorkbenchState]); - const onCreateWorkbenchTab = useCallback((tabKey: string) => { - setWorkbenchState((current) => createWorkbenchTab(current, tabKey)); - }, []); + const onCreateWorkbenchTab = useCallback((paneKey: string) => { + updateWorkbenchState((current) => createWorkbenchTab(current, paneKey)); + }, [updateWorkbenchState]); const onDissolveWorkbenchTab = useCallback((tabKey: string) => { - setWorkbenchState((current) => dissolveWorkbenchTab(current, tabKey)); - }, []); + updateWorkbenchState((current) => dissolveWorkbenchTab(current, tabKey)); + }, [updateWorkbenchState]); const onAttachWorkbenchPane = useCallback(( paneKey: string, tabKey: string, ) => { - setWorkbenchState((current) => { + updateWorkbenchState((current) => { const target = workbenchTab(current, tabKey); if (!target || (!target.explicit && target.paneKeys.length < 2)) return current; return attachWorkbenchPane(current, tabKey, paneKey); }); - }, []); + }, [updateWorkbenchState]); useEffect(() => { if (view === "settings") { @@ -2746,19 +2714,19 @@ function Shell({ onAddPane={onAddPane} onLayoutChange={(layout) => { if (!activeTabKey) return; - setWorkbenchState((current) => ( + updateWorkbenchState((current) => ( setWorkbenchLayout(current, activeTabKey, layout) )); }} onPaneOrderChange={(paneKeys) => { if (!activeTabKey) return; - setWorkbenchState((current) => ( + updateWorkbenchState((current) => ( setWorkbenchPaneLayoutOrder(current, activeTabKey, paneKeys) )); }} onSplitRatiosChange={(splitRatios) => { if (!activeTabKey) return; - setWorkbenchState((current) => ( + updateWorkbenchState((current) => ( setWorkbenchSplitRatios(current, activeTabKey, splitRatios) )); }} diff --git a/webui/src/components/ChatList.tsx b/webui/src/components/ChatList.tsx index 913a304f4..f87005a30 100644 --- a/webui/src/components/ChatList.tsx +++ b/webui/src/components/ChatList.tsx @@ -100,7 +100,7 @@ interface ChatListProps { onToggleArchive: (key: string) => void; paneGroups?: Record; onSelectPane?: (tabKey: string, paneKey: string) => void; - onCreateTab?: (tabKey: string) => void; + onCreateTab?: (paneKey: string) => void; onDetachPane?: (tabKey: string, paneKey: string) => void; onDissolveTab?: (tabKey: string) => void; onAttachPane?: ( @@ -710,7 +710,7 @@ export const ChatList = memo(function ChatList({ {isArchived ? t("chat.unarchive") : t("chat.archive")} {paneGroup && onCreateTab ? ( - onCreateTab(paneGroup.tabKey)}> + onCreateTab(s.key)}> {t("workbench.createGroup", { defaultValue: "Create group", diff --git a/webui/src/components/Sidebar.tsx b/webui/src/components/Sidebar.tsx index a300d3e21..28e5f5cd2 100644 --- a/webui/src/components/Sidebar.tsx +++ b/webui/src/components/Sidebar.tsx @@ -50,7 +50,7 @@ interface SidebarProps { onToggleArchive: (key: string) => void; paneGroups?: Record; onSelectPane?: (tabKey: string, paneKey: string) => void; - onCreateTab?: (tabKey: string) => void; + onCreateTab?: (paneKey: string) => void; onDetachPane?: (tabKey: string, paneKey: string) => void; onDissolveTab?: (tabKey: string) => void; onAttachPane?: ( diff --git a/webui/src/components/workbench/workbench-model.ts b/webui/src/components/workbench/workbench-model.ts index 947938c77..6b8db2266 100644 --- a/webui/src/components/workbench/workbench-model.ts +++ b/webui/src/components/workbench/workbench-model.ts @@ -77,11 +77,6 @@ function normalizeTab(value: unknown): WorkbenchTabState { title: normalizeTitle(candidate.title), paneKeys, layoutPaneKeys, - activePaneKey: - typeof candidate.activePaneKey === "string" - && paneKeys.includes(candidate.activePaneKey) - ? candidate.activePaneKey - : paneKeys[0] ?? "", layout: isLayout(candidate.layout) ? candidate.layout : "columns", splitRatios: normalizeSplitRatios(candidate.splitRatios), }; @@ -111,7 +106,6 @@ function defaultWorkbenchTab( title: normalizeTitle(title), paneKeys: [paneKey], layoutPaneKeys: [paneKey], - activePaneKey: paneKey, layout: "columns", splitRatios: [], }; @@ -129,7 +123,9 @@ export function normalizeWorkbenchState(raw: unknown): WorkbenchState { return { version: 1, tabs: Object.fromEntries( - Object.entries(parsed.tabs).map(([tabKey, tab]) => [tabKey, normalizeTab(tab)]), + Object.entries(parsed.tabs) + .map(([tabKey, tab]) => [tabKey, normalizeTab(tab)] as const) + .filter(([, tab]) => tab.paneKeys.length > 1 || tab.explicit), ), }; } @@ -153,24 +149,6 @@ export function workbenchTabForPane( }; } -export function ensureWorkbenchPaneTab( - state: WorkbenchState, - paneKey: string, - title: string | null = null, -): WorkbenchState { - if (!paneKey || Object.values(state.tabs).some((tab) => tab.paneKeys.includes(paneKey))) { - return state; - } - const tabKey = availableStandaloneTabKey(state.tabs, paneKey); - return { - version: 1, - tabs: { - ...state.tabs, - [tabKey]: defaultWorkbenchTab(paneKey, title), - }, - }; -} - function updateTab( state: WorkbenchState, tabKey: string, @@ -191,31 +169,41 @@ function updateTab( export function addWorkbenchPane( state: WorkbenchState, - tabKey: string, + anchorPaneKey: string, paneKey: string, ): WorkbenchState { - return attachWorkbenchPane(state, tabKey, paneKey); -} - -export function focusWorkbenchPane( - state: WorkbenchState, - tabKey: string, - paneKey: string, -): WorkbenchState { - return updateTab(state, tabKey, (tab) => ( - tab.paneKeys.includes(paneKey) && tab.activePaneKey !== paneKey - ? { ...tab, activePaneKey: paneKey } - : tab - )); + if (!anchorPaneKey || !paneKey || anchorPaneKey === paneKey) return state; + const target = workbenchTabForPane(state, anchorPaneKey); + if (state.tabs[target.tabKey]) return attachWorkbenchPane(state, target.tabKey, paneKey); + const withTarget = { + version: 1 as const, + tabs: { + ...state.tabs, + [target.tabKey]: target.tab, + }, + }; + return attachWorkbenchPane(withTarget, target.tabKey, paneKey); } export function createWorkbenchTab( state: WorkbenchState, - tabKey: string, + paneKey: string, ): WorkbenchState { - return updateTab(state, tabKey, (tab) => ( - tab.explicit ? tab : { ...tab, explicit: true } - )); + if (!paneKey) return state; + const match = workbenchTabForPane(state, paneKey); + const persisted = state.tabs[match.tabKey]; + if (persisted) { + return updateTab(state, match.tabKey, (tab) => ( + tab.explicit ? tab : { ...tab, explicit: true } + )); + } + return { + version: 1, + tabs: { + ...state.tabs, + [match.tabKey]: { ...match.tab, explicit: true }, + }, + }; } export function detachWorkbenchPane( @@ -226,36 +214,25 @@ export function detachWorkbenchPane( const tab = state.tabs[tabKey]; if (!tab || !tab.paneKeys.includes(paneKey)) return state; if (tab.paneKeys.length === 1) { - return tab.explicit - ? updateTab(state, tabKey, (current) => ({ - ...current, - explicit: false, - title: null, - layout: "columns", - splitRatios: [], - })) - : state; + const tabs = { ...state.tabs }; + delete tabs[tabKey]; + return { version: 1, tabs }; } - const index = tab.paneKeys.indexOf(paneKey); const paneKeys = tab.paneKeys.filter((key) => key !== paneKey); const layoutPaneKeys = tab.layoutPaneKeys.filter((key) => key !== paneKey); - const nextTabKey = availableStandaloneTabKey(state.tabs, paneKey); + const tabs = { ...state.tabs }; + const nextTab = { + ...tab, + paneKeys, + layoutPaneKeys, + splitRatios: [], + }; + if (nextTab.explicit || paneKeys.length > 1) tabs[tabKey] = nextTab; + else delete tabs[tabKey]; return { version: 1, - tabs: { - ...state.tabs, - [tabKey]: { - ...tab, - paneKeys, - layoutPaneKeys, - activePaneKey: tab.activePaneKey === paneKey - ? paneKeys[Math.min(index, paneKeys.length - 1)] - : tab.activePaneKey, - splitRatios: [], - }, - [nextTabKey]: defaultWorkbenchTab(paneKey), - }, + tabs, }; } @@ -265,24 +242,8 @@ export function dissolveWorkbenchTab( ): WorkbenchState { const tab = state.tabs[tabKey]; if (!tab) return state; - if (tab.paneKeys.length === 1) { - return tab.explicit - ? updateTab(state, tabKey, (current) => ({ - ...current, - explicit: false, - title: null, - layout: "columns", - splitRatios: [], - })) - : state; - } - const tabs = { ...state.tabs }; delete tabs[tabKey]; - for (const paneKey of tab.paneKeys) { - const standaloneTabKey = availableStandaloneTabKey(tabs, paneKey); - tabs[standaloneTabKey] = defaultWorkbenchTab(paneKey); - } return { version: 1, tabs }; } @@ -301,7 +262,7 @@ export function attachWorkbenchPane( const sourceTabKey = sourceEntry?.[0]; const sourceTab = sourceEntry?.[1]; if (sourceTabKey === targetTabKey) { - return focusWorkbenchPane(state, targetTabKey, paneKey); + return state; } if (!target.paneKeys.includes(paneKey) && target.paneKeys.length >= MAX_WORKBENCH_PANES) { return state; @@ -309,19 +270,15 @@ export function attachWorkbenchPane( const tabs = { ...state.tabs }; if (sourceTabKey && sourceTab) { - const index = sourceTab.paneKeys.indexOf(paneKey); const sourcePaneKeys = sourceTab.paneKeys.filter((key) => key !== paneKey); const sourceLayoutPaneKeys = sourceTab.layoutPaneKeys.filter((key) => key !== paneKey); - if (sourcePaneKeys.length === 0) { + if (sourcePaneKeys.length === 0 || (!sourceTab.explicit && sourcePaneKeys.length === 1)) { delete tabs[sourceTabKey]; } else { tabs[sourceTabKey] = { ...sourceTab, paneKeys: sourcePaneKeys, layoutPaneKeys: sourceLayoutPaneKeys, - activePaneKey: sourceTab.activePaneKey === paneKey - ? sourcePaneKeys[Math.min(index, sourcePaneKeys.length - 1)] - : sourceTab.activePaneKey, splitRatios: [], }; } @@ -339,7 +296,6 @@ export function attachWorkbenchPane( ...nextTarget, paneKeys, layoutPaneKeys, - activePaneKey: paneKey, splitRatios: [], }; return { version: 1, tabs }; @@ -410,28 +366,21 @@ export function reconcileWorkbench( .filter((key) => validKeys.has(key) && !claimedPaneKeys.has(key)) .slice(0, MAX_WORKBENCH_PANES); if (paneKeys.length === 0) continue; - for (const paneKey of paneKeys) claimedPaneKeys.add(paneKey); - tabs[tabKey] = { + const nextTab = { ...tab, paneKeys, layoutPaneKeys: [ ...tab.layoutPaneKeys.filter((key) => paneKeys.includes(key)), ...paneKeys.filter((key) => !tab.layoutPaneKeys.includes(key)), ], - activePaneKey: paneKeys.includes(tab.activePaneKey) - ? tab.activePaneKey - : paneKeys[0], splitRatios: paneKeys.length === tab.paneKeys.length && paneKeys.every((key, index) => key === tab.paneKeys[index]) ? tab.splitRatios : [], }; - } - - for (const paneKey of validKeys) { - if (claimedPaneKeys.has(paneKey)) continue; - const tabKey = availableStandaloneTabKey(tabs, paneKey); - tabs[tabKey] = defaultWorkbenchTab(paneKey); + if (!nextTab.explicit && paneKeys.length === 1) continue; + for (const paneKey of paneKeys) claimedPaneKeys.add(paneKey); + tabs[tabKey] = nextTab; } return JSON.stringify(state.tabs) === JSON.stringify(tabs) @@ -447,7 +396,16 @@ export function orderWorkbenchTabs( const rank = new Map(orderedSessionKeys.map((key, index) => [key, index])); const validKeys = new Set(orderedSessionKeys); const reconciled = reconcileWorkbench(state, validKeys); - const tabs = Object.entries(reconciled.tabs).map(([tabKey, tab]) => { + const projectedTabs = { ...reconciled.tabs }; + const claimedPaneKeys = new Set( + Object.values(projectedTabs).flatMap((tab) => tab.paneKeys), + ); + for (const paneKey of orderedSessionKeys) { + if (claimedPaneKeys.has(paneKey)) continue; + const tabKey = availableStandaloneTabKey(projectedTabs, paneKey); + projectedTabs[tabKey] = defaultWorkbenchTab(paneKey); + } + const tabs = Object.entries(projectedTabs).map(([tabKey, tab]) => { const paneKeys = tab.paneKeys .filter((key) => validKeys.has(key)) .sort((left, right) => (rank.get(left) ?? Infinity) - (rank.get(right) ?? Infinity)); diff --git a/webui/src/hooks/useSidebarState.ts b/webui/src/hooks/useSidebarState.ts index b937432de..47a8f54f0 100644 --- a/webui/src/hooks/useSidebarState.ts +++ b/webui/src/hooks/useSidebarState.ts @@ -230,6 +230,7 @@ export function useSidebarState( const update = useCallback( async (updater: (current: SidebarStatePayload) => SidebarStatePayload) => { const next = normalizeSidebarState(updater(stateRef.current)); + if (sameState(next, stateRef.current)) return; stateRef.current = next; setState(next); persist(next); diff --git a/webui/src/lib/types.ts b/webui/src/lib/types.ts index 218b2dea5..9c056b29d 100644 --- a/webui/src/lib/types.ts +++ b/webui/src/lib/types.ts @@ -375,7 +375,6 @@ export interface WorkbenchTabState { title: string | null; paneKeys: string[]; layoutPaneKeys: string[]; - activePaneKey: string; layout: WorkbenchLayout; splitRatios: number[]; } diff --git a/webui/src/tests/app-layout.test.tsx b/webui/src/tests/app-layout.test.tsx index dfc2e8007..f25daa1ca 100644 --- a/webui/src/tests/app-layout.test.tsx +++ b/webui/src/tests/app-layout.test.tsx @@ -3067,14 +3067,12 @@ describe("App layout", () => { explicit: true, title: "Alpha tab", paneKeys: ["websocket:alpha", "websocket:alpha-child"], - activePaneKey: "websocket:alpha-child", layout: "columns", }, "tab:websocket:beta": { explicit: false, title: null, paneKeys: ["websocket:beta"], - activePaneKey: "websocket:beta", layout: "columns", }, }, diff --git a/webui/src/tests/chat-list.test.tsx b/webui/src/tests/chat-list.test.tsx index ee67baca8..d24f0327f 100644 --- a/webui/src/tests/chat-list.test.tsx +++ b/webui/src/tests/chat-list.test.tsx @@ -122,8 +122,7 @@ describe("ChatList", () => { name: "Topic actions for Solo pane", }), { button: 0, ctrlKey: false }); fireEvent.click(await screen.findByRole("menuitem", { name: "Create group" })); - expect(onCreateTab).toHaveBeenCalledWith("tab:solo"); - expect(onAttachPane).not.toHaveBeenCalled(); + expect(onCreateTab).toHaveBeenCalledWith("websocket:solo"); fireEvent.pointerDown(screen.getByRole("button", { name: "Topic actions for Solo pane", diff --git a/webui/src/tests/nanobot-client.test.ts b/webui/src/tests/nanobot-client.test.ts index c90bc6dbc..48113a995 100644 --- a/webui/src/tests/nanobot-client.test.ts +++ b/webui/src/tests/nanobot-client.test.ts @@ -1267,7 +1267,6 @@ describe("NanobotClient", () => { explicit: true, title: "Research", paneKeys: ["websocket:a", "websocket:b"], - activePaneKey: "websocket:a", layout: "columns", }, }, diff --git a/webui/src/tests/pane-workbench.test.tsx b/webui/src/tests/pane-workbench.test.tsx index c889e7b9c..c2a4fce04 100644 --- a/webui/src/tests/pane-workbench.test.tsx +++ b/webui/src/tests/pane-workbench.test.tsx @@ -7,8 +7,6 @@ import { PaneWorkbench } from "@/components/workbench/PaneWorkbench"; import { EMPTY_WORKBENCH_STATE, addWorkbenchPane, - ensureWorkbenchPaneTab, - focusWorkbenchPane, setWorkbenchLayout, setWorkbenchPaneLayoutOrder, workbenchTab, @@ -38,14 +36,11 @@ function WorkbenchHarness({ onPaneOrderChange?: (paneKeys: string[]) => void; onSplitRatiosChange?: (splitRatios: number[]) => void; } = {}) { + const [activePaneKey, setActivePaneKey] = useState("beta"); const [state, setState] = useState(() => { - const initial = ensureWorkbenchPaneTab(EMPTY_WORKBENCH_STATE, "alpha"); + const initial = addWorkbenchPane(EMPTY_WORKBENCH_STATE, "alpha", "beta"); const tabKey = workbenchTabForPane(initial, "alpha").tabKey; - return setWorkbenchLayout( - addWorkbenchPane(initial, tabKey, "beta"), - tabKey, - initialLayout, - ); + return setWorkbenchLayout(initial, tabKey, initialLayout); }); const tabKey = workbenchTabForPane(state, "alpha").tabKey; const tab = workbenchTab(state, tabKey); @@ -55,13 +50,11 @@ function WorkbenchHarness({ return ( ({ key, title: titles[key] }))} - activePaneKey={tab.activePaneKey} + activePaneKey={activePaneKey} layout={tab.layout} splitRatios={tab.splitRatios} showLayoutControl - onActivatePane={(key) => setState((current) => ( - focusWorkbenchPane(current, tabKey, key) - ))} + onActivatePane={setActivePaneKey} onAddPane={vi.fn()} onLayoutChange={(layout) => setState((current) => ( setWorkbenchLayout(current, tabKey, layout) diff --git a/webui/src/tests/workbench-model.test.ts b/webui/src/tests/workbench-model.test.ts index 84ef9556d..6b1511b66 100644 --- a/webui/src/tests/workbench-model.test.ts +++ b/webui/src/tests/workbench-model.test.ts @@ -8,8 +8,6 @@ import { createWorkbenchTab, detachWorkbenchPane, dissolveWorkbenchTab, - ensureWorkbenchPaneTab, - focusWorkbenchPane, normalizeWorkbenchState, orderWorkbenchTabs, reconcileWorkbench, @@ -19,152 +17,84 @@ import { setWorkbenchSplitRatios, workbenchTab, workbenchTabForPane, - type WorkbenchState, } from "@/components/workbench/workbench-model"; -function withPaneTab( - state: WorkbenchState, - paneKey: string, -): [WorkbenchState, string] { - const next = ensureWorkbenchPaneTab(state, paneKey); - return [next, workbenchTabForPane(next, paneKey).tabKey]; -} - describe("workbench model", () => { - it("creates a virtual tab whose identity is separate from its pane", () => { - const [state, tabKey] = withPaneTab(EMPTY_WORKBENCH_STATE, "pane-a"); + it("derives standalone panes without persisting virtual tabs", () => { + const match = workbenchTabForPane(EMPTY_WORKBENCH_STATE, "pane-a"); - expect(tabKey).not.toBe("pane-a"); - expect(workbenchTab(state, tabKey)).toEqual({ + expect(match.tabKey).not.toBe("pane-a"); + expect(match.tab).toEqual({ explicit: false, title: null, paneKeys: ["pane-a"], layoutPaneKeys: ["pane-a"], - activePaneKey: "pane-a", layout: "columns", splitRatios: [], }); + expect(EMPTY_WORKBENCH_STATE.tabs).toEqual({}); }); - it("keeps pane membership, focus, title, and layout scoped to a tab", () => { - let state = ensureWorkbenchPaneTab(EMPTY_WORKBENCH_STATE, "pane-a"); - const alphaTabKey = workbenchTabForPane(state, "pane-a").tabKey; - state = ensureWorkbenchPaneTab(state, "pane-b"); - const betaTabKey = workbenchTabForPane(state, "pane-b").tabKey; - state = addWorkbenchPane(state, alphaTabKey, "pane-c"); - state = setWorkbenchLayout(state, alphaTabKey, "main-stack"); - state = renameWorkbenchTab(state, alphaTabKey, "Research"); + it("persists only a visible singleton group", () => { + const state = createWorkbenchTab(EMPTY_WORKBENCH_STATE, "pane-a"); + const match = workbenchTabForPane(state, "pane-a"); - expect(workbenchTab(state, alphaTabKey)).toEqual({ + expect(workbenchTab(state, match.tabKey)).toMatchObject({ + explicit: true, + paneKeys: ["pane-a"], + }); + expect(detachWorkbenchPane(state, match.tabKey, "pane-a").tabs).toEqual({}); + }); + + it("materializes a group when a pane is added", () => { + let state = addWorkbenchPane(EMPTY_WORKBENCH_STATE, "pane-a", "pane-b"); + const tabKey = workbenchTabForPane(state, "pane-a").tabKey; + state = setWorkbenchLayout(state, tabKey, "main-stack"); + state = renameWorkbenchTab(state, tabKey, "Research"); + + expect(workbenchTab(state, tabKey)).toEqual({ explicit: false, title: "Research", - paneKeys: ["pane-a", "pane-c"], - layoutPaneKeys: ["pane-a", "pane-c"], - activePaneKey: "pane-c", + paneKeys: ["pane-a", "pane-b"], + layoutPaneKeys: ["pane-a", "pane-b"], layout: "main-stack", splitRatios: [], }); - expect(workbenchTab(state, betaTabKey)).toEqual({ - explicit: false, - title: null, - paneKeys: ["pane-b"], - layoutPaneKeys: ["pane-b"], - activePaneKey: "pane-b", - layout: "columns", - splitRatios: [], - }); }); - it("focuses a pane without rewriting membership", () => { - let state = ensureWorkbenchPaneTab(EMPTY_WORKBENCH_STATE, "pane-a"); + it("detaches a pane without persisting its standalone projection", () => { + let state = addWorkbenchPane(EMPTY_WORKBENCH_STATE, "pane-a", "pane-b"); const tabKey = workbenchTabForPane(state, "pane-a").tabKey; - state = addWorkbenchPane(state, tabKey, "pane-b"); - state = addWorkbenchPane(state, tabKey, "pane-c"); - state = focusWorkbenchPane(state, tabKey, "pane-b"); - - expect(workbenchTab(state, tabKey)?.paneKeys).toEqual([ - "pane-a", - "pane-b", - "pane-c", - ]); - expect(workbenchTab(state, tabKey)?.activePaneKey).toBe("pane-b"); - }); - - it("detaches any pane into a new virtual tab", () => { - let state = ensureWorkbenchPaneTab(EMPTY_WORKBENCH_STATE, "pane-a"); - const tabKey = workbenchTabForPane(state, "pane-a").tabKey; - state = addWorkbenchPane(state, tabKey, "pane-b"); - state = addWorkbenchPane(state, tabKey, "pane-c"); - state = focusWorkbenchPane(state, tabKey, "pane-a"); + state = createWorkbenchTab(state, "pane-a"); + state = addWorkbenchPane(state, "pane-a", "pane-c"); state = detachWorkbenchPane(state, tabKey, "pane-a"); - expect(workbenchTab(state, tabKey)).toMatchObject({ - paneKeys: ["pane-b", "pane-c"], - activePaneKey: "pane-b", - }); - const detached = workbenchTabForPane(state, "pane-a"); - expect(detached.tabKey).not.toBe(tabKey); - expect(detached.tab.paneKeys).toEqual(["pane-a"]); + expect(workbenchTab(state, tabKey)?.paneKeys).toEqual(["pane-b", "pane-c"]); + expect(workbenchTabForPane(state, "pane-a").tab.paneKeys).toEqual(["pane-a"]); + expect(Object.values(state.tabs).some((tab) => tab.paneKeys.includes("pane-a"))).toBe(false); }); - it("dissolves a tab into standalone panes without deleting them", () => { - let state = ensureWorkbenchPaneTab(EMPTY_WORKBENCH_STATE, "pane-a"); - const tabKey = workbenchTabForPane(state, "pane-a").tabKey; - state = addWorkbenchPane(state, tabKey, "pane-b"); - state = addWorkbenchPane(state, tabKey, "pane-c"); - state = dissolveWorkbenchTab(state, tabKey); + it("dissolves a group into derived standalone panes", () => { + const grouped = addWorkbenchPane(EMPTY_WORKBENCH_STATE, "pane-a", "pane-b"); + const tabKey = workbenchTabForPane(grouped, "pane-a").tabKey; + const state = dissolveWorkbenchTab(grouped, tabKey); - expect(workbenchTab(state, tabKey)).toEqual({ - explicit: false, - title: null, - paneKeys: ["pane-a"], - layoutPaneKeys: ["pane-a"], - activePaneKey: "pane-a", - layout: "columns", - splitRatios: [], - }); + expect(state.tabs).toEqual({}); expect(workbenchTabForPane(state, "pane-a").tab.paneKeys).toEqual(["pane-a"]); expect(workbenchTabForPane(state, "pane-b").tab.paneKeys).toEqual(["pane-b"]); - expect(workbenchTabForPane(state, "pane-c").tab.paneKeys).toEqual(["pane-c"]); - expect(Object.keys(state.tabs)).toHaveLength(3); }); - it("makes a singleton tab visible without changing pane membership", () => { - let state = ensureWorkbenchPaneTab(EMPTY_WORKBENCH_STATE, "pane-a"); - const tabKey = workbenchTabForPane(state, "pane-a").tabKey; - - state = createWorkbenchTab(state, tabKey); - expect(workbenchTab(state, tabKey)).toMatchObject({ - explicit: true, - paneKeys: ["pane-a"], - activePaneKey: "pane-a", - }); - - state = detachWorkbenchPane(state, tabKey, "pane-a"); - expect(workbenchTab(state, tabKey)).toEqual({ - explicit: false, - title: null, - paneKeys: ["pane-a"], - layoutPaneKeys: ["pane-a"], - activePaneKey: "pane-a", - layout: "columns", - splitRatios: [], - }); - }); - - it("moves every pane symmetrically and removes an empty source tab", () => { - let state = ensureWorkbenchPaneTab(EMPTY_WORKBENCH_STATE, "pane-a"); - const alphaTabKey = workbenchTabForPane(state, "pane-a").tabKey; - state = addWorkbenchPane(state, alphaTabKey, "pane-b"); - state = ensureWorkbenchPaneTab(state, "pane-c"); + it("moves panes symmetrically and removes an implicit singleton source", () => { + let state = addWorkbenchPane(EMPTY_WORKBENCH_STATE, "pane-a", "pane-b"); + const sourceTabKey = workbenchTabForPane(state, "pane-a").tabKey; + state = createWorkbenchTab(state, "pane-c"); const targetTabKey = workbenchTabForPane(state, "pane-c").tabKey; state = attachWorkbenchPane(state, targetTabKey, "pane-a"); - expect(workbenchTab(state, alphaTabKey)?.paneKeys).toEqual(["pane-b"]); + expect(workbenchTab(state, sourceTabKey)).toBeNull(); expect(workbenchTab(state, targetTabKey)?.paneKeys).toEqual(["pane-c", "pane-a"]); state = attachWorkbenchPane(state, targetTabKey, "pane-b"); - expect(workbenchTab(state, alphaTabKey)).toBeNull(); expect(workbenchTab(state, targetTabKey)?.paneKeys).toEqual([ "pane-c", "pane-a", @@ -172,11 +102,10 @@ describe("workbench model", () => { ]); }); - it("keeps membership independent from projected display order", () => { - let state = ensureWorkbenchPaneTab(EMPTY_WORKBENCH_STATE, "pane-a"); + it("keeps membership independent from workspace pane order", () => { + let state = addWorkbenchPane(EMPTY_WORKBENCH_STATE, "pane-a", "pane-b"); + state = addWorkbenchPane(state, "pane-a", "pane-c"); const tabKey = workbenchTabForPane(state, "pane-a").tabKey; - state = addWorkbenchPane(state, tabKey, "pane-b"); - state = addWorkbenchPane(state, tabKey, "pane-c"); const [ordered] = orderWorkbenchTabs( state, @@ -187,73 +116,55 @@ describe("workbench model", () => { expect(ordered.paneKeys).toEqual(["pane-c", "pane-a", "pane-b"]); state = setWorkbenchPaneLayoutOrder(state, tabKey, ["pane-b", "pane-c", "pane-a"]); - expect(workbenchTab(state, tabKey)?.paneKeys).toEqual(["pane-a", "pane-b", "pane-c"]); expect(workbenchTab(state, tabKey)?.layoutPaneKeys).toEqual([ "pane-b", "pane-c", "pane-a", ]); - expect(ordered.paneKeys).toEqual(["pane-c", "pane-a", "pane-b"]); }); - it("stores resize ratios in the tab and resets them when its geometry changes", () => { - let state = ensureWorkbenchPaneTab(EMPTY_WORKBENCH_STATE, "pane-a"); + it("stores resize ratios and resets them when geometry changes", () => { + let state = addWorkbenchPane(EMPTY_WORKBENCH_STATE, "pane-a", "pane-b"); const tabKey = workbenchTabForPane(state, "pane-a").tabKey; - state = addWorkbenchPane(state, tabKey, "pane-b"); state = setWorkbenchSplitRatios(state, tabKey, [0.35]); expect(workbenchTab(state, tabKey)?.splitRatios).toEqual([0.35]); state = setWorkbenchLayout(state, tabKey, "rows"); expect(workbenchTab(state, tabKey)?.splitRatios).toEqual([]); - - state = setWorkbenchSplitRatios(state, tabKey, [0.4]); - state = detachWorkbenchPane(state, tabKey, "pane-b"); - expect(workbenchTab(state, tabKey)?.splitRatios).toEqual([]); }); - it("keeps each tab contiguous and ranks it by its latest updated pane", () => { - let state = ensureWorkbenchPaneTab(EMPTY_WORKBENCH_STATE, "pane-a"); + it("keeps groups contiguous and ranks them by their latest pane", () => { + let state = addWorkbenchPane(EMPTY_WORKBENCH_STATE, "pane-a", "pane-c"); + state = addWorkbenchPane(state, "pane-b", "pane-d"); const alphaTabKey = workbenchTabForPane(state, "pane-a").tabKey; - state = addWorkbenchPane(state, alphaTabKey, "pane-c"); - state = ensureWorkbenchPaneTab(state, "pane-b"); const betaTabKey = workbenchTabForPane(state, "pane-b").tabKey; - state = addWorkbenchPane(state, betaTabKey, "pane-d"); const tabs = orderWorkbenchTabs( state, - ["pane-d", "pane-c", "pane-b", "pane-a"], + ["pane-d", "pane-c", "pane-b", "pane-a", "pane-e"], new Map([ ["pane-a", "2026-08-01T10:00:00Z"], ["pane-b", "2026-08-03T10:00:00Z"], ["pane-c", "2026-08-05T10:00:00Z"], ["pane-d", "2026-08-04T10:00:00Z"], + ["pane-e", "2026-08-02T10:00:00Z"], ]), ); - expect(tabs.map(({ tabKey, paneKeys, updatedAt }) => ({ - tabKey, - paneKeys, - updatedAt, - }))).toEqual([ - { - tabKey: alphaTabKey, - paneKeys: ["pane-c", "pane-a"], - updatedAt: "2026-08-05T10:00:00Z", - }, - { - tabKey: betaTabKey, - paneKeys: ["pane-d", "pane-b"], - updatedAt: "2026-08-04T10:00:00Z", - }, + expect(tabs.map(({ tabKey, paneKeys }) => ({ tabKey, paneKeys }))).toEqual([ + { tabKey: alphaTabKey, paneKeys: ["pane-c", "pane-a"] }, + { tabKey: betaTabKey, paneKeys: ["pane-d", "pane-b"] }, + { tabKey: workbenchTabForPane(state, "pane-e").tabKey, paneKeys: ["pane-e"] }, ]); + expect(Object.keys(state.tabs)).toHaveLength(2); }); - it("caps every virtual tab at four panes", () => { - let state = ensureWorkbenchPaneTab(EMPTY_WORKBENCH_STATE, "pane-a"); - const tabKey = workbenchTabForPane(state, "pane-a").tabKey; - for (let index = 1; index <= MAX_WORKBENCH_PANES; index += 1) { - state = addWorkbenchPane(state, tabKey, `pane-${index}`); + it("caps a group at four panes", () => { + let state = addWorkbenchPane(EMPTY_WORKBENCH_STATE, "pane-a", "pane-1"); + for (let index = 2; index <= MAX_WORKBENCH_PANES; index += 1) { + state = addWorkbenchPane(state, "pane-a", `pane-${index}`); } + const tabKey = workbenchTabForPane(state, "pane-a").tabKey; expect(workbenchTab(state, tabKey)?.paneKeys).toEqual([ "pane-a", "pane-1", @@ -262,21 +173,23 @@ describe("workbench model", () => { ]); }); - it("repairs duplicates, removes deleted panes, and creates missing tabs", () => { + it("repairs persisted groups without materializing missing sessions", () => { const state = normalizeWorkbenchState({ version: 1, tabs: { alpha: { title: "Alpha", paneKeys: ["pane-a", "pane-b", "pane-b", 9], - activePaneKey: "missing", layout: "unknown", }, duplicate: { paneKeys: ["pane-b", "deleted"], - activePaneKey: "pane-b", layout: "grid", }, + invisible: { + paneKeys: ["pane-c"], + layout: "columns", + }, }, }); const reconciled = reconcileWorkbench( @@ -289,12 +202,19 @@ describe("workbench model", () => { title: "Alpha", paneKeys: ["pane-a", "pane-b"], layoutPaneKeys: ["pane-a", "pane-b"], - activePaneKey: "pane-a", layout: "columns", splitRatios: [], }); - expect(workbenchTab(reconciled, "duplicate")).toBeNull(); + expect(Object.keys(reconciled.tabs)).toEqual(["alpha"]); expect(workbenchTabForPane(reconciled, "pane-c").tab.paneKeys).toEqual(["pane-c"]); }); + it("keeps persisted state sparse with thousands of standalone sessions", () => { + const sessionKeys = Array.from({ length: 2_000 }, (_, index) => `pane-${index}`); + const reconciled = reconcileWorkbench(EMPTY_WORKBENCH_STATE, new Set(sessionKeys)); + const ordered = orderWorkbenchTabs(reconciled, sessionKeys, new Map()); + + expect(reconciled.tabs).toEqual({}); + expect(ordered).toHaveLength(2_000); + }); });