refactor(webui): simplify workbench state model

This commit is contained in:
chengyongru
2026-08-12 11:32:45 +08:00
parent 3822db3e2a
commit 1802bcd790
14 changed files with 276 additions and 377 deletions
@@ -1060,7 +1060,6 @@ async def test_webui_sidebar_state_update_broadcasts_workbench_to_other_devices(
"title": "Research", "title": "Research",
"paneKeys": ["websocket:a", "websocket:b"], "paneKeys": ["websocket:a", "websocket:b"],
"layoutPaneKeys": ["websocket:b", "websocket:a"], "layoutPaneKeys": ["websocket:b", "websocket:a"],
"activePaneKey": "websocket:a",
"layout": "columns", "layout": "columns",
"splitRatios": [0.35], "splitRatios": [0.35],
} }
+15 -6
View File
@@ -10,6 +10,7 @@ from __future__ import annotations
import json import json
import math import math
import os import os
import threading
import time import time
from pathlib import Path from pathlib import Path
from typing import Any, cast from typing import Any, cast
@@ -29,6 +30,7 @@ _MAX_WORKBENCH_PANES = 4
_ALLOWED_DENSITIES = {"comfortable", "compact"} _ALLOWED_DENSITIES = {"comfortable", "compact"}
_ALLOWED_SORTS = {"updated_desc", "created_desc", "title_asc", "manual"} _ALLOWED_SORTS = {"updated_desc", "created_desc", "title_asc", "manual"}
_ALLOWED_WORKBENCH_LAYOUTS = {"columns", "rows", "grid", "bsp", "main-stack"} _ALLOWED_WORKBENCH_LAYOUTS = {"columns", "rows", "grid", "bsp", "main-stack"}
_SIDEBAR_STATE_WRITE_LOCK = threading.Lock()
def webui_sidebar_state_path() -> Path: 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]: def _clean_workbench(value: Any) -> dict[str, Any]:
if not isinstance(value, dict): if not isinstance(value, dict):
return {"version": 1, "tabs": {}} 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): if not isinstance(raw_tabs, dict):
return {"version": 1, "tabs": {}} return {"version": 1, "tabs": {}}
@@ -170,6 +175,9 @@ def _clean_workbench(value: Any) -> dict[str, Any]:
][:_MAX_WORKBENCH_PANES] ][:_MAX_WORKBENCH_PANES]
if not pane_keys: if not pane_keys:
continue continue
explicit = tab.get("explicit") is True
if not explicit and len(pane_keys) == 1:
continue
requested_layout_pane_keys = [ requested_layout_pane_keys = [
key for key in _clean_string_list(tab.get("layoutPaneKeys")) if key in 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") raw_layout = tab.get("layout")
layout = raw_layout if raw_layout in _ALLOWED_WORKBENCH_LAYOUTS else "columns" layout = raw_layout if raw_layout in _ALLOWED_WORKBENCH_LAYOUTS else "columns"
title = _clean_string(tab.get("title"), max_len=_MAX_TITLE_LEN) title = _clean_string(tab.get("title"), max_len=_MAX_TITLE_LEN)
active_pane_key = _clean_string(tab.get("activePaneKey"))
tabs[tab_key] = { tabs[tab_key] = {
"explicit": tab.get("explicit") is True, "explicit": explicit,
"title": title, "title": title,
"paneKeys": pane_keys, "paneKeys": pane_keys,
"layoutPaneKeys": layout_pane_keys, "layoutPaneKeys": layout_pane_keys,
"activePaneKey": (
active_pane_key if active_pane_key in pane_keys else pane_keys[0]
),
"layout": layout, "layout": layout,
"splitRatios": _clean_split_ratios(tab.get("splitRatios")), "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]: 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 = normalize_webui_sidebar_state(raw)
state["updated_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) state["updated_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
encoded = json.dumps( encoded = json.dumps(
+68 -12
View File
@@ -1,5 +1,9 @@
import json 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 ( from nanobot.webui.sidebar_state import (
default_webui_sidebar_state, default_webui_sidebar_state,
read_webui_sidebar_state, read_webui_sidebar_state,
@@ -39,13 +43,11 @@ def test_sidebar_state_normalizes_partial_payload(tmp_path, monkeypatch) -> None
"title": " Research ", "title": " Research ",
"paneKeys": ["websocket:a", "websocket:b", "websocket:a"], "paneKeys": ["websocket:a", "websocket:b", "websocket:a"],
"layoutPaneKeys": ["websocket:b", "missing", "websocket:a"], "layoutPaneKeys": ["websocket:b", "missing", "websocket:a"],
"activePaneKey": "missing",
"layout": "invalid-layout", "layout": "invalid-layout",
"splitRatios": [0.4, 2, "bad", float("nan")], "splitRatios": [0.4, 2, "bad", float("nan")],
}, },
"tab:websocket:b": { "tab:websocket:b": {
"paneKeys": ["websocket:b", "websocket:c"], "paneKeys": ["websocket:b", "websocket:c"],
"activePaneKey": "websocket:c",
"layout": "bsp", "layout": "bsp",
}, },
}, },
@@ -74,19 +76,9 @@ def test_sidebar_state_normalizes_partial_payload(tmp_path, monkeypatch) -> None
"title": "Research", "title": "Research",
"paneKeys": ["websocket:a", "websocket:b"], "paneKeys": ["websocket:a", "websocket:b"],
"layoutPaneKeys": ["websocket:b", "websocket:a"], "layoutPaneKeys": ["websocket:b", "websocket:a"],
"activePaneKey": "websocket:a",
"layout": "columns", "layout": "columns",
"splitRatios": [0.4, 0.95], "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"] == { 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 state["view"]["sort"] == "manual"
assert webui_sidebar_state_path().is_file() assert webui_sidebar_state_path().is_file()
assert read_webui_sidebar_state()["pinned_keys"] == ["websocket:a"] 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
+48 -80
View File
@@ -17,15 +17,12 @@ import type { SettingsSectionKey } from "@/components/settings/SettingsView";
import { ThreadShell } from "@/components/thread/ThreadShell"; import { ThreadShell } from "@/components/thread/ThreadShell";
import { PaneWorkbench } from "@/components/workbench/PaneWorkbench"; import { PaneWorkbench } from "@/components/workbench/PaneWorkbench";
import { import {
EMPTY_WORKBENCH_STATE,
MAX_WORKBENCH_PANES, MAX_WORKBENCH_PANES,
addWorkbenchPane, addWorkbenchPane,
attachWorkbenchPane, attachWorkbenchPane,
createWorkbenchTab, createWorkbenchTab,
detachWorkbenchPane, detachWorkbenchPane,
dissolveWorkbenchTab, dissolveWorkbenchTab,
ensureWorkbenchPaneTab,
focusWorkbenchPane,
orderWorkbenchTabs, orderWorkbenchTabs,
reconcileWorkbench, reconcileWorkbench,
renameWorkbenchTab, renameWorkbenchTab,
@@ -1060,12 +1057,16 @@ function Shell({
const [hostSidebarPreviewOpen, setHostSidebarPreviewOpen] = useState(false); const [hostSidebarPreviewOpen, setHostSidebarPreviewOpen] = useState(false);
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
const [sessionSearchOpen, setSessionSearchOpen] = useState(false); const [sessionSearchOpen, setSessionSearchOpen] = useState(false);
const [workbenchState, setWorkbenchState] = useState<WorkbenchState>( const workbenchState = sidebarState.workbench;
EMPTY_WORKBENCH_STATE, const updateWorkbenchState = useCallback((
); updater: (current: WorkbenchState) => WorkbenchState,
const workbenchServerHydratedRef = useRef(false); ) => {
const lastServerWorkbenchRef = useRef(""); void updateSidebarState((current) => {
const skipWorkbenchPersistenceRef = useRef(false); const next = updater(current.workbench);
return next === current.workbench ? current : { ...current, workbench: next };
});
}, [updateSidebarState]);
const lastActivePaneByTabRef = useRef(new Map<string, string>());
const [creatingPane, setCreatingPane] = useState(false); const [creatingPane, setCreatingPane] = useState(false);
const topicSessions = sessions; const topicSessions = sessions;
const [pendingDelete, setPendingDelete] = useState<{ const [pendingDelete, setPendingDelete] = useState<{
@@ -1183,19 +1184,6 @@ function Shell({
}; };
}, [getToken]); }, [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(() => { useEffect(() => {
try { try {
window.localStorage.setItem( window.localStorage.setItem(
@@ -1207,21 +1195,6 @@ function Shell({
} }
}, [hostSidebarOpen]); }, [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(() => { useEffect(() => {
writeSessionUpdateChatIds(updatedChatIds); writeSessionUpdateChatIds(updatedChatIds);
}, [updatedChatIds]); }, [updatedChatIds]);
@@ -1292,11 +1265,11 @@ function Shell({
), [activeKey, temporarySessions, workbenchState]); ), [activeKey, temporarySessions, workbenchState]);
const activeTabKey = activeTabMatch?.tabKey ?? null; const activeTabKey = activeTabMatch?.tabKey ?? null;
const activeTabState = activeTabMatch?.tab ?? null; const activeTabState = activeTabMatch?.tab ?? null;
const activePaneSession = useMemo<ChatSummary | null>(() => { const activePaneSession = activeSession;
if (!activeTabState) return activeSession; useEffect(() => {
return sessions.find((session) => session.key === activeTabState.activePaneKey) if (!activeTabKey || !activeKey || !activeTabState?.paneKeys.includes(activeKey)) return;
?? activeSession; lastActivePaneByTabRef.current.set(activeTabKey, activeKey);
}, [activeSession, activeTabState, sessions]); }, [activeKey, activeTabKey, activeTabState]);
const runningChatIdList = useMemo(() => Array.from(runningChatIds), [runningChatIds]); const runningChatIdList = useMemo(() => Array.from(runningChatIds), [runningChatIds]);
const updatedChatIdList = useMemo(() => Array.from(updatedChatIds), [updatedChatIds]); const updatedChatIdList = useMemo(() => Array.from(updatedChatIds), [updatedChatIds]);
const activeChatId = activePaneSession?.chatId ?? null; const activeChatId = activePaneSession?.chatId ?? null;
@@ -1364,20 +1337,14 @@ function Shell({
useEffect(() => { useEffect(() => {
if (loading || sidebarStateLoading) return; if (loading || sidebarStateLoading) return;
const validKeys = new Set(sessions.map((session) => session.key)); const validKeys = new Set(sessions.map((session) => session.key));
setWorkbenchState((current) => { updateWorkbenchState((current) => {
const reconciled = reconcileWorkbench(current, validKeys); return reconcileWorkbench(current, validKeys);
if (!activeKey || temporarySessions[activeKey] || !validKeys.has(activeKey)) {
return reconciled;
}
const match = workbenchTabForPane(reconciled, activeKey);
return focusWorkbenchPane(reconciled, match.tabKey, activeKey);
}); });
}, [ }, [
activeKey,
loading, loading,
sidebarStateLoading, sidebarStateLoading,
sessions, sessions,
temporarySessions, updateWorkbenchState,
]); ]);
useEffect(() => { useEffect(() => {
@@ -1817,11 +1784,11 @@ function Shell({
const onConfirmTabRename = useCallback((title: string) => { const onConfirmTabRename = useCallback((title: string) => {
if (!pendingTabRename) return; if (!pendingTabRename) return;
setWorkbenchState((current) => ( updateWorkbenchState((current) => (
renameWorkbenchTab(current, pendingTabRename.key, title) renameWorkbenchTab(current, pendingTabRename.key, title)
)); ));
setPendingTabRename(null); setPendingTabRename(null);
}, [pendingTabRename]); }, [pendingTabRename, updateWorkbenchState]);
const onToggleGroup = useCallback( const onToggleGroup = useCallback(
(groupId: string) => { (groupId: string) => {
@@ -1940,11 +1907,7 @@ function Shell({
const chatId = await createChat(scope); const chatId = await createChat(scope);
const paneKey = `websocket:${chatId}`; const paneKey = `websocket:${chatId}`;
pendingCreatedSessionKeyRef.current = paneKey; pendingCreatedSessionKeyRef.current = paneKey;
setWorkbenchState((current) => { updateWorkbenchState((current) => addWorkbenchPane(current, activeKey, paneKey));
const withTab = ensureWorkbenchPaneTab(current, activeKey);
const target = workbenchTabForPane(withTab, activeKey);
return addWorkbenchPane(withTab, target.tabKey, paneKey);
});
navigate({ navigate({
view: "chat", view: "chat",
activeKey: paneKey, activeKey: paneKey,
@@ -1974,6 +1937,7 @@ function Shell({
creatingPane, creatingPane,
navigate, navigate,
t, t,
updateWorkbenchState,
]); ]);
useEffect(() => { useEffect(() => {
@@ -2395,9 +2359,7 @@ function Shell({
titleForSession, titleForSession,
workbenchPaneSessions, workbenchPaneSessions,
]); ]);
const renderedActivePaneKey = paneChromeEnabled && activeTabState const renderedActivePaneKey = activeKey ?? renderedWorkbenchPanes[0].key;
? activeTabState.activePaneKey
: renderedWorkbenchPanes[0].key;
const renderedWorkbenchLayout = paneChromeEnabled && activeTabState const renderedWorkbenchLayout = paneChromeEnabled && activeTabState
? activeTabState.layout ? activeTabState.layout
: "columns"; : "columns";
@@ -2419,12 +2381,15 @@ function Shell({
return [presentation.rowKey, { return [presentation.rowKey, {
tabKey: orderedTab.tabKey, tabKey: orderedTab.tabKey,
title: presentation.title, 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, visible: orderedTab.tab.explicit || orderedTab.paneKeys.length > 1,
panes, panes,
}]; }];
})); }));
}, [ }, [
activeKey,
sessions, sessions,
sidebarTabPresentations, sidebarTabPresentations,
titleForSession, titleForSession,
@@ -2434,14 +2399,18 @@ function Shell({
); );
const onActivateWorkbenchPane = useCallback((paneKey: string) => { const onActivateWorkbenchPane = useCallback((paneKey: string) => {
if (!activeTabKey) return;
setWorkbenchState((current) => focusWorkbenchPane(current, activeTabKey, paneKey));
onSelectChat(paneKey); onSelectChat(paneKey);
}, [activeTabKey, onSelectChat]); }, [onSelectChat]);
const onSelectSidebarTab = useCallback((tabKey: string) => { const onSelectSidebarTab = useCallback((tabKey: string) => {
const tab = workbenchTab(workbenchState, tabKey); 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]); }, [onSelectChat, workbenchState]);
const onSelectSidebarItem = useCallback((key: string) => { const onSelectSidebarItem = useCallback((key: string) => {
@@ -2455,33 +2424,32 @@ function Shell({
onSelectSidebarTab(key); onSelectSidebarTab(key);
}, [onSelectChat, onSelectSidebarTab, sessions]); }, [onSelectChat, onSelectSidebarTab, sessions]);
const onSelectSidebarPane = useCallback((tabKey: string, paneKey: string) => { const onSelectSidebarPane = useCallback((_tabKey: string, paneKey: string) => {
setWorkbenchState((current) => focusWorkbenchPane(current, tabKey, paneKey));
onSelectChat(paneKey); onSelectChat(paneKey);
}, [onSelectChat]); }, [onSelectChat]);
const onDetachWorkbenchPane = useCallback((tabKey: string, paneKey: string) => { 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) => { const onCreateWorkbenchTab = useCallback((paneKey: string) => {
setWorkbenchState((current) => createWorkbenchTab(current, tabKey)); updateWorkbenchState((current) => createWorkbenchTab(current, paneKey));
}, []); }, [updateWorkbenchState]);
const onDissolveWorkbenchTab = useCallback((tabKey: string) => { const onDissolveWorkbenchTab = useCallback((tabKey: string) => {
setWorkbenchState((current) => dissolveWorkbenchTab(current, tabKey)); updateWorkbenchState((current) => dissolveWorkbenchTab(current, tabKey));
}, []); }, [updateWorkbenchState]);
const onAttachWorkbenchPane = useCallback(( const onAttachWorkbenchPane = useCallback((
paneKey: string, paneKey: string,
tabKey: string, tabKey: string,
) => { ) => {
setWorkbenchState((current) => { updateWorkbenchState((current) => {
const target = workbenchTab(current, tabKey); const target = workbenchTab(current, tabKey);
if (!target || (!target.explicit && target.paneKeys.length < 2)) return current; if (!target || (!target.explicit && target.paneKeys.length < 2)) return current;
return attachWorkbenchPane(current, tabKey, paneKey); return attachWorkbenchPane(current, tabKey, paneKey);
}); });
}, []); }, [updateWorkbenchState]);
useEffect(() => { useEffect(() => {
if (view === "settings") { if (view === "settings") {
@@ -2746,19 +2714,19 @@ function Shell({
onAddPane={onAddPane} onAddPane={onAddPane}
onLayoutChange={(layout) => { onLayoutChange={(layout) => {
if (!activeTabKey) return; if (!activeTabKey) return;
setWorkbenchState((current) => ( updateWorkbenchState((current) => (
setWorkbenchLayout(current, activeTabKey, layout) setWorkbenchLayout(current, activeTabKey, layout)
)); ));
}} }}
onPaneOrderChange={(paneKeys) => { onPaneOrderChange={(paneKeys) => {
if (!activeTabKey) return; if (!activeTabKey) return;
setWorkbenchState((current) => ( updateWorkbenchState((current) => (
setWorkbenchPaneLayoutOrder(current, activeTabKey, paneKeys) setWorkbenchPaneLayoutOrder(current, activeTabKey, paneKeys)
)); ));
}} }}
onSplitRatiosChange={(splitRatios) => { onSplitRatiosChange={(splitRatios) => {
if (!activeTabKey) return; if (!activeTabKey) return;
setWorkbenchState((current) => ( updateWorkbenchState((current) => (
setWorkbenchSplitRatios(current, activeTabKey, splitRatios) setWorkbenchSplitRatios(current, activeTabKey, splitRatios)
)); ));
}} }}
+2 -2
View File
@@ -100,7 +100,7 @@ interface ChatListProps {
onToggleArchive: (key: string) => void; onToggleArchive: (key: string) => void;
paneGroups?: Record<string, SidebarPaneGroup>; paneGroups?: Record<string, SidebarPaneGroup>;
onSelectPane?: (tabKey: string, paneKey: string) => void; onSelectPane?: (tabKey: string, paneKey: string) => void;
onCreateTab?: (tabKey: string) => void; onCreateTab?: (paneKey: string) => void;
onDetachPane?: (tabKey: string, paneKey: string) => void; onDetachPane?: (tabKey: string, paneKey: string) => void;
onDissolveTab?: (tabKey: string) => void; onDissolveTab?: (tabKey: string) => void;
onAttachPane?: ( onAttachPane?: (
@@ -710,7 +710,7 @@ export const ChatList = memo(function ChatList({
{isArchived ? t("chat.unarchive") : t("chat.archive")} {isArchived ? t("chat.unarchive") : t("chat.archive")}
</DropdownMenuItem> </DropdownMenuItem>
{paneGroup && onCreateTab ? ( {paneGroup && onCreateTab ? (
<DropdownMenuItem onSelect={() => onCreateTab(paneGroup.tabKey)}> <DropdownMenuItem onSelect={() => onCreateTab(s.key)}>
<PanelsTopLeft className="h-4 w-4 shrink-0" aria-hidden /> <PanelsTopLeft className="h-4 w-4 shrink-0" aria-hidden />
{t("workbench.createGroup", { {t("workbench.createGroup", {
defaultValue: "Create group", defaultValue: "Create group",
+1 -1
View File
@@ -50,7 +50,7 @@ interface SidebarProps {
onToggleArchive: (key: string) => void; onToggleArchive: (key: string) => void;
paneGroups?: Record<string, SidebarPaneGroup>; paneGroups?: Record<string, SidebarPaneGroup>;
onSelectPane?: (tabKey: string, paneKey: string) => void; onSelectPane?: (tabKey: string, paneKey: string) => void;
onCreateTab?: (tabKey: string) => void; onCreateTab?: (paneKey: string) => void;
onDetachPane?: (tabKey: string, paneKey: string) => void; onDetachPane?: (tabKey: string, paneKey: string) => void;
onDissolveTab?: (tabKey: string) => void; onDissolveTab?: (tabKey: string) => void;
onAttachPane?: ( onAttachPane?: (
@@ -77,11 +77,6 @@ function normalizeTab(value: unknown): WorkbenchTabState {
title: normalizeTitle(candidate.title), title: normalizeTitle(candidate.title),
paneKeys, paneKeys,
layoutPaneKeys, layoutPaneKeys,
activePaneKey:
typeof candidate.activePaneKey === "string"
&& paneKeys.includes(candidate.activePaneKey)
? candidate.activePaneKey
: paneKeys[0] ?? "",
layout: isLayout(candidate.layout) ? candidate.layout : "columns", layout: isLayout(candidate.layout) ? candidate.layout : "columns",
splitRatios: normalizeSplitRatios(candidate.splitRatios), splitRatios: normalizeSplitRatios(candidate.splitRatios),
}; };
@@ -111,7 +106,6 @@ function defaultWorkbenchTab(
title: normalizeTitle(title), title: normalizeTitle(title),
paneKeys: [paneKey], paneKeys: [paneKey],
layoutPaneKeys: [paneKey], layoutPaneKeys: [paneKey],
activePaneKey: paneKey,
layout: "columns", layout: "columns",
splitRatios: [], splitRatios: [],
}; };
@@ -129,7 +123,9 @@ export function normalizeWorkbenchState(raw: unknown): WorkbenchState {
return { return {
version: 1, version: 1,
tabs: Object.fromEntries( 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( function updateTab(
state: WorkbenchState, state: WorkbenchState,
tabKey: string, tabKey: string,
@@ -191,32 +169,42 @@ function updateTab(
export function addWorkbenchPane( export function addWorkbenchPane(
state: WorkbenchState, state: WorkbenchState,
tabKey: string, anchorPaneKey: string,
paneKey: string, paneKey: string,
): WorkbenchState { ): WorkbenchState {
return attachWorkbenchPane(state, tabKey, paneKey); if (!anchorPaneKey || !paneKey || anchorPaneKey === paneKey) return state;
} const target = workbenchTabForPane(state, anchorPaneKey);
if (state.tabs[target.tabKey]) return attachWorkbenchPane(state, target.tabKey, paneKey);
export function focusWorkbenchPane( const withTarget = {
state: WorkbenchState, version: 1 as const,
tabKey: string, tabs: {
paneKey: string, ...state.tabs,
): WorkbenchState { [target.tabKey]: target.tab,
return updateTab(state, tabKey, (tab) => ( },
tab.paneKeys.includes(paneKey) && tab.activePaneKey !== paneKey };
? { ...tab, activePaneKey: paneKey } return attachWorkbenchPane(withTarget, target.tabKey, paneKey);
: tab
));
} }
export function createWorkbenchTab( export function createWorkbenchTab(
state: WorkbenchState, state: WorkbenchState,
tabKey: string, paneKey: string,
): WorkbenchState { ): WorkbenchState {
return updateTab(state, tabKey, (tab) => ( 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 } tab.explicit ? tab : { ...tab, explicit: true }
)); ));
} }
return {
version: 1,
tabs: {
...state.tabs,
[match.tabKey]: { ...match.tab, explicit: true },
},
};
}
export function detachWorkbenchPane( export function detachWorkbenchPane(
state: WorkbenchState, state: WorkbenchState,
@@ -226,36 +214,25 @@ export function detachWorkbenchPane(
const tab = state.tabs[tabKey]; const tab = state.tabs[tabKey];
if (!tab || !tab.paneKeys.includes(paneKey)) return state; if (!tab || !tab.paneKeys.includes(paneKey)) return state;
if (tab.paneKeys.length === 1) { if (tab.paneKeys.length === 1) {
return tab.explicit const tabs = { ...state.tabs };
? updateTab(state, tabKey, (current) => ({ delete tabs[tabKey];
...current, return { version: 1, tabs };
explicit: false,
title: null,
layout: "columns",
splitRatios: [],
}))
: state;
} }
const index = tab.paneKeys.indexOf(paneKey);
const paneKeys = tab.paneKeys.filter((key) => key !== paneKey); const paneKeys = tab.paneKeys.filter((key) => key !== paneKey);
const layoutPaneKeys = tab.layoutPaneKeys.filter((key) => key !== paneKey); const layoutPaneKeys = tab.layoutPaneKeys.filter((key) => key !== paneKey);
const nextTabKey = availableStandaloneTabKey(state.tabs, paneKey); const tabs = { ...state.tabs };
return { const nextTab = {
version: 1,
tabs: {
...state.tabs,
[tabKey]: {
...tab, ...tab,
paneKeys, paneKeys,
layoutPaneKeys, layoutPaneKeys,
activePaneKey: tab.activePaneKey === paneKey
? paneKeys[Math.min(index, paneKeys.length - 1)]
: tab.activePaneKey,
splitRatios: [], splitRatios: [],
}, };
[nextTabKey]: defaultWorkbenchTab(paneKey), if (nextTab.explicit || paneKeys.length > 1) tabs[tabKey] = nextTab;
}, else delete tabs[tabKey];
return {
version: 1,
tabs,
}; };
} }
@@ -265,24 +242,8 @@ export function dissolveWorkbenchTab(
): WorkbenchState { ): WorkbenchState {
const tab = state.tabs[tabKey]; const tab = state.tabs[tabKey];
if (!tab) return state; 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 }; const tabs = { ...state.tabs };
delete tabs[tabKey]; delete tabs[tabKey];
for (const paneKey of tab.paneKeys) {
const standaloneTabKey = availableStandaloneTabKey(tabs, paneKey);
tabs[standaloneTabKey] = defaultWorkbenchTab(paneKey);
}
return { version: 1, tabs }; return { version: 1, tabs };
} }
@@ -301,7 +262,7 @@ export function attachWorkbenchPane(
const sourceTabKey = sourceEntry?.[0]; const sourceTabKey = sourceEntry?.[0];
const sourceTab = sourceEntry?.[1]; const sourceTab = sourceEntry?.[1];
if (sourceTabKey === targetTabKey) { if (sourceTabKey === targetTabKey) {
return focusWorkbenchPane(state, targetTabKey, paneKey); return state;
} }
if (!target.paneKeys.includes(paneKey) && target.paneKeys.length >= MAX_WORKBENCH_PANES) { if (!target.paneKeys.includes(paneKey) && target.paneKeys.length >= MAX_WORKBENCH_PANES) {
return state; return state;
@@ -309,19 +270,15 @@ export function attachWorkbenchPane(
const tabs = { ...state.tabs }; const tabs = { ...state.tabs };
if (sourceTabKey && sourceTab) { if (sourceTabKey && sourceTab) {
const index = sourceTab.paneKeys.indexOf(paneKey);
const sourcePaneKeys = sourceTab.paneKeys.filter((key) => key !== paneKey); const sourcePaneKeys = sourceTab.paneKeys.filter((key) => key !== paneKey);
const sourceLayoutPaneKeys = sourceTab.layoutPaneKeys.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]; delete tabs[sourceTabKey];
} else { } else {
tabs[sourceTabKey] = { tabs[sourceTabKey] = {
...sourceTab, ...sourceTab,
paneKeys: sourcePaneKeys, paneKeys: sourcePaneKeys,
layoutPaneKeys: sourceLayoutPaneKeys, layoutPaneKeys: sourceLayoutPaneKeys,
activePaneKey: sourceTab.activePaneKey === paneKey
? sourcePaneKeys[Math.min(index, sourcePaneKeys.length - 1)]
: sourceTab.activePaneKey,
splitRatios: [], splitRatios: [],
}; };
} }
@@ -339,7 +296,6 @@ export function attachWorkbenchPane(
...nextTarget, ...nextTarget,
paneKeys, paneKeys,
layoutPaneKeys, layoutPaneKeys,
activePaneKey: paneKey,
splitRatios: [], splitRatios: [],
}; };
return { version: 1, tabs }; return { version: 1, tabs };
@@ -410,28 +366,21 @@ export function reconcileWorkbench(
.filter((key) => validKeys.has(key) && !claimedPaneKeys.has(key)) .filter((key) => validKeys.has(key) && !claimedPaneKeys.has(key))
.slice(0, MAX_WORKBENCH_PANES); .slice(0, MAX_WORKBENCH_PANES);
if (paneKeys.length === 0) continue; if (paneKeys.length === 0) continue;
for (const paneKey of paneKeys) claimedPaneKeys.add(paneKey); const nextTab = {
tabs[tabKey] = {
...tab, ...tab,
paneKeys, paneKeys,
layoutPaneKeys: [ layoutPaneKeys: [
...tab.layoutPaneKeys.filter((key) => paneKeys.includes(key)), ...tab.layoutPaneKeys.filter((key) => paneKeys.includes(key)),
...paneKeys.filter((key) => !tab.layoutPaneKeys.includes(key)), ...paneKeys.filter((key) => !tab.layoutPaneKeys.includes(key)),
], ],
activePaneKey: paneKeys.includes(tab.activePaneKey)
? tab.activePaneKey
: paneKeys[0],
splitRatios: paneKeys.length === tab.paneKeys.length splitRatios: paneKeys.length === tab.paneKeys.length
&& paneKeys.every((key, index) => key === tab.paneKeys[index]) && paneKeys.every((key, index) => key === tab.paneKeys[index])
? tab.splitRatios ? tab.splitRatios
: [], : [],
}; };
} if (!nextTab.explicit && paneKeys.length === 1) continue;
for (const paneKey of paneKeys) claimedPaneKeys.add(paneKey);
for (const paneKey of validKeys) { tabs[tabKey] = nextTab;
if (claimedPaneKeys.has(paneKey)) continue;
const tabKey = availableStandaloneTabKey(tabs, paneKey);
tabs[tabKey] = defaultWorkbenchTab(paneKey);
} }
return JSON.stringify(state.tabs) === JSON.stringify(tabs) 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 rank = new Map(orderedSessionKeys.map((key, index) => [key, index]));
const validKeys = new Set(orderedSessionKeys); const validKeys = new Set(orderedSessionKeys);
const reconciled = reconcileWorkbench(state, validKeys); 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 const paneKeys = tab.paneKeys
.filter((key) => validKeys.has(key)) .filter((key) => validKeys.has(key))
.sort((left, right) => (rank.get(left) ?? Infinity) - (rank.get(right) ?? Infinity)); .sort((left, right) => (rank.get(left) ?? Infinity) - (rank.get(right) ?? Infinity));
+1
View File
@@ -230,6 +230,7 @@ export function useSidebarState(
const update = useCallback( const update = useCallback(
async (updater: (current: SidebarStatePayload) => SidebarStatePayload) => { async (updater: (current: SidebarStatePayload) => SidebarStatePayload) => {
const next = normalizeSidebarState(updater(stateRef.current)); const next = normalizeSidebarState(updater(stateRef.current));
if (sameState(next, stateRef.current)) return;
stateRef.current = next; stateRef.current = next;
setState(next); setState(next);
persist(next); persist(next);
-1
View File
@@ -375,7 +375,6 @@ export interface WorkbenchTabState {
title: string | null; title: string | null;
paneKeys: string[]; paneKeys: string[];
layoutPaneKeys: string[]; layoutPaneKeys: string[];
activePaneKey: string;
layout: WorkbenchLayout; layout: WorkbenchLayout;
splitRatios: number[]; splitRatios: number[];
} }
-2
View File
@@ -3067,14 +3067,12 @@ describe("App layout", () => {
explicit: true, explicit: true,
title: "Alpha tab", title: "Alpha tab",
paneKeys: ["websocket:alpha", "websocket:alpha-child"], paneKeys: ["websocket:alpha", "websocket:alpha-child"],
activePaneKey: "websocket:alpha-child",
layout: "columns", layout: "columns",
}, },
"tab:websocket:beta": { "tab:websocket:beta": {
explicit: false, explicit: false,
title: null, title: null,
paneKeys: ["websocket:beta"], paneKeys: ["websocket:beta"],
activePaneKey: "websocket:beta",
layout: "columns", layout: "columns",
}, },
}, },
+1 -2
View File
@@ -122,8 +122,7 @@ describe("ChatList", () => {
name: "Topic actions for Solo pane", name: "Topic actions for Solo pane",
}), { button: 0, ctrlKey: false }); }), { button: 0, ctrlKey: false });
fireEvent.click(await screen.findByRole("menuitem", { name: "Create group" })); fireEvent.click(await screen.findByRole("menuitem", { name: "Create group" }));
expect(onCreateTab).toHaveBeenCalledWith("tab:solo"); expect(onCreateTab).toHaveBeenCalledWith("websocket:solo");
expect(onAttachPane).not.toHaveBeenCalled();
fireEvent.pointerDown(screen.getByRole("button", { fireEvent.pointerDown(screen.getByRole("button", {
name: "Topic actions for Solo pane", name: "Topic actions for Solo pane",
-1
View File
@@ -1267,7 +1267,6 @@ describe("NanobotClient", () => {
explicit: true, explicit: true,
title: "Research", title: "Research",
paneKeys: ["websocket:a", "websocket:b"], paneKeys: ["websocket:a", "websocket:b"],
activePaneKey: "websocket:a",
layout: "columns", layout: "columns",
}, },
}, },
+5 -12
View File
@@ -7,8 +7,6 @@ import { PaneWorkbench } from "@/components/workbench/PaneWorkbench";
import { import {
EMPTY_WORKBENCH_STATE, EMPTY_WORKBENCH_STATE,
addWorkbenchPane, addWorkbenchPane,
ensureWorkbenchPaneTab,
focusWorkbenchPane,
setWorkbenchLayout, setWorkbenchLayout,
setWorkbenchPaneLayoutOrder, setWorkbenchPaneLayoutOrder,
workbenchTab, workbenchTab,
@@ -38,14 +36,11 @@ function WorkbenchHarness({
onPaneOrderChange?: (paneKeys: string[]) => void; onPaneOrderChange?: (paneKeys: string[]) => void;
onSplitRatiosChange?: (splitRatios: number[]) => void; onSplitRatiosChange?: (splitRatios: number[]) => void;
} = {}) { } = {}) {
const [activePaneKey, setActivePaneKey] = useState("beta");
const [state, setState] = useState(() => { 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; const tabKey = workbenchTabForPane(initial, "alpha").tabKey;
return setWorkbenchLayout( return setWorkbenchLayout(initial, tabKey, initialLayout);
addWorkbenchPane(initial, tabKey, "beta"),
tabKey,
initialLayout,
);
}); });
const tabKey = workbenchTabForPane(state, "alpha").tabKey; const tabKey = workbenchTabForPane(state, "alpha").tabKey;
const tab = workbenchTab(state, tabKey); const tab = workbenchTab(state, tabKey);
@@ -55,13 +50,11 @@ function WorkbenchHarness({
return ( return (
<PaneWorkbench <PaneWorkbench
panes={tab.layoutPaneKeys.map((key) => ({ key, title: titles[key] }))} panes={tab.layoutPaneKeys.map((key) => ({ key, title: titles[key] }))}
activePaneKey={tab.activePaneKey} activePaneKey={activePaneKey}
layout={tab.layout} layout={tab.layout}
splitRatios={tab.splitRatios} splitRatios={tab.splitRatios}
showLayoutControl showLayoutControl
onActivatePane={(key) => setState((current) => ( onActivatePane={setActivePaneKey}
focusWorkbenchPane(current, tabKey, key)
))}
onAddPane={vi.fn()} onAddPane={vi.fn()}
onLayoutChange={(layout) => setState((current) => ( onLayoutChange={(layout) => setState((current) => (
setWorkbenchLayout(current, tabKey, layout) setWorkbenchLayout(current, tabKey, layout)
+75 -155
View File
@@ -8,8 +8,6 @@ import {
createWorkbenchTab, createWorkbenchTab,
detachWorkbenchPane, detachWorkbenchPane,
dissolveWorkbenchTab, dissolveWorkbenchTab,
ensureWorkbenchPaneTab,
focusWorkbenchPane,
normalizeWorkbenchState, normalizeWorkbenchState,
orderWorkbenchTabs, orderWorkbenchTabs,
reconcileWorkbench, reconcileWorkbench,
@@ -19,152 +17,84 @@ import {
setWorkbenchSplitRatios, setWorkbenchSplitRatios,
workbenchTab, workbenchTab,
workbenchTabForPane, workbenchTabForPane,
type WorkbenchState,
} from "@/components/workbench/workbench-model"; } 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", () => { describe("workbench model", () => {
it("creates a virtual tab whose identity is separate from its pane", () => { it("derives standalone panes without persisting virtual tabs", () => {
const [state, tabKey] = withPaneTab(EMPTY_WORKBENCH_STATE, "pane-a"); const match = workbenchTabForPane(EMPTY_WORKBENCH_STATE, "pane-a");
expect(tabKey).not.toBe("pane-a"); expect(match.tabKey).not.toBe("pane-a");
expect(workbenchTab(state, tabKey)).toEqual({ expect(match.tab).toEqual({
explicit: false, explicit: false,
title: null, title: null,
paneKeys: ["pane-a"], paneKeys: ["pane-a"],
layoutPaneKeys: ["pane-a"], layoutPaneKeys: ["pane-a"],
activePaneKey: "pane-a",
layout: "columns", layout: "columns",
splitRatios: [], splitRatios: [],
}); });
expect(EMPTY_WORKBENCH_STATE.tabs).toEqual({});
}); });
it("keeps pane membership, focus, title, and layout scoped to a tab", () => { it("persists only a visible singleton group", () => {
let state = ensureWorkbenchPaneTab(EMPTY_WORKBENCH_STATE, "pane-a"); const state = createWorkbenchTab(EMPTY_WORKBENCH_STATE, "pane-a");
const alphaTabKey = workbenchTabForPane(state, "pane-a").tabKey; const match = workbenchTabForPane(state, "pane-a");
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");
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, explicit: false,
title: "Research", title: "Research",
paneKeys: ["pane-a", "pane-c"], paneKeys: ["pane-a", "pane-b"],
layoutPaneKeys: ["pane-a", "pane-c"], layoutPaneKeys: ["pane-a", "pane-b"],
activePaneKey: "pane-c",
layout: "main-stack", layout: "main-stack",
splitRatios: [], 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", () => { it("detaches a pane without persisting its standalone projection", () => {
let state = ensureWorkbenchPaneTab(EMPTY_WORKBENCH_STATE, "pane-a"); let state = addWorkbenchPane(EMPTY_WORKBENCH_STATE, "pane-a", "pane-b");
const tabKey = workbenchTabForPane(state, "pane-a").tabKey; const tabKey = workbenchTabForPane(state, "pane-a").tabKey;
state = addWorkbenchPane(state, tabKey, "pane-b"); state = createWorkbenchTab(state, "pane-a");
state = addWorkbenchPane(state, tabKey, "pane-c"); state = addWorkbenchPane(state, "pane-a", "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 = detachWorkbenchPane(state, tabKey, "pane-a"); state = detachWorkbenchPane(state, tabKey, "pane-a");
expect(workbenchTab(state, tabKey)).toMatchObject({ expect(workbenchTab(state, tabKey)?.paneKeys).toEqual(["pane-b", "pane-c"]);
paneKeys: ["pane-b", "pane-c"], expect(workbenchTabForPane(state, "pane-a").tab.paneKeys).toEqual(["pane-a"]);
activePaneKey: "pane-b", expect(Object.values(state.tabs).some((tab) => tab.paneKeys.includes("pane-a"))).toBe(false);
});
const detached = workbenchTabForPane(state, "pane-a");
expect(detached.tabKey).not.toBe(tabKey);
expect(detached.tab.paneKeys).toEqual(["pane-a"]);
}); });
it("dissolves a tab into standalone panes without deleting them", () => { it("dissolves a group into derived standalone panes", () => {
let state = ensureWorkbenchPaneTab(EMPTY_WORKBENCH_STATE, "pane-a"); const grouped = addWorkbenchPane(EMPTY_WORKBENCH_STATE, "pane-a", "pane-b");
const tabKey = workbenchTabForPane(state, "pane-a").tabKey; const tabKey = workbenchTabForPane(grouped, "pane-a").tabKey;
state = addWorkbenchPane(state, tabKey, "pane-b"); const state = dissolveWorkbenchTab(grouped, tabKey);
state = addWorkbenchPane(state, tabKey, "pane-c");
state = dissolveWorkbenchTab(state, tabKey);
expect(workbenchTab(state, tabKey)).toEqual({ expect(state.tabs).toEqual({});
explicit: false,
title: null,
paneKeys: ["pane-a"],
layoutPaneKeys: ["pane-a"],
activePaneKey: "pane-a",
layout: "columns",
splitRatios: [],
});
expect(workbenchTabForPane(state, "pane-a").tab.paneKeys).toEqual(["pane-a"]); expect(workbenchTabForPane(state, "pane-a").tab.paneKeys).toEqual(["pane-a"]);
expect(workbenchTabForPane(state, "pane-b").tab.paneKeys).toEqual(["pane-b"]); 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", () => { it("moves panes symmetrically and removes an implicit singleton source", () => {
let state = ensureWorkbenchPaneTab(EMPTY_WORKBENCH_STATE, "pane-a"); let state = addWorkbenchPane(EMPTY_WORKBENCH_STATE, "pane-a", "pane-b");
const tabKey = workbenchTabForPane(state, "pane-a").tabKey; const sourceTabKey = workbenchTabForPane(state, "pane-a").tabKey;
state = createWorkbenchTab(state, "pane-c");
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");
const targetTabKey = workbenchTabForPane(state, "pane-c").tabKey; const targetTabKey = workbenchTabForPane(state, "pane-c").tabKey;
state = attachWorkbenchPane(state, targetTabKey, "pane-a"); 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"]); expect(workbenchTab(state, targetTabKey)?.paneKeys).toEqual(["pane-c", "pane-a"]);
state = attachWorkbenchPane(state, targetTabKey, "pane-b"); state = attachWorkbenchPane(state, targetTabKey, "pane-b");
expect(workbenchTab(state, alphaTabKey)).toBeNull();
expect(workbenchTab(state, targetTabKey)?.paneKeys).toEqual([ expect(workbenchTab(state, targetTabKey)?.paneKeys).toEqual([
"pane-c", "pane-c",
"pane-a", "pane-a",
@@ -172,11 +102,10 @@ describe("workbench model", () => {
]); ]);
}); });
it("keeps membership independent from projected display order", () => { it("keeps membership independent from workspace pane order", () => {
let state = ensureWorkbenchPaneTab(EMPTY_WORKBENCH_STATE, "pane-a"); let state = addWorkbenchPane(EMPTY_WORKBENCH_STATE, "pane-a", "pane-b");
state = addWorkbenchPane(state, "pane-a", "pane-c");
const tabKey = workbenchTabForPane(state, "pane-a").tabKey; const tabKey = workbenchTabForPane(state, "pane-a").tabKey;
state = addWorkbenchPane(state, tabKey, "pane-b");
state = addWorkbenchPane(state, tabKey, "pane-c");
const [ordered] = orderWorkbenchTabs( const [ordered] = orderWorkbenchTabs(
state, state,
@@ -187,73 +116,55 @@ describe("workbench model", () => {
expect(ordered.paneKeys).toEqual(["pane-c", "pane-a", "pane-b"]); expect(ordered.paneKeys).toEqual(["pane-c", "pane-a", "pane-b"]);
state = setWorkbenchPaneLayoutOrder(state, tabKey, ["pane-b", "pane-c", "pane-a"]); 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([ expect(workbenchTab(state, tabKey)?.layoutPaneKeys).toEqual([
"pane-b", "pane-b",
"pane-c", "pane-c",
"pane-a", "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", () => { it("stores resize ratios and resets them when geometry changes", () => {
let state = ensureWorkbenchPaneTab(EMPTY_WORKBENCH_STATE, "pane-a"); let state = addWorkbenchPane(EMPTY_WORKBENCH_STATE, "pane-a", "pane-b");
const tabKey = workbenchTabForPane(state, "pane-a").tabKey; const tabKey = workbenchTabForPane(state, "pane-a").tabKey;
state = addWorkbenchPane(state, tabKey, "pane-b");
state = setWorkbenchSplitRatios(state, tabKey, [0.35]); state = setWorkbenchSplitRatios(state, tabKey, [0.35]);
expect(workbenchTab(state, tabKey)?.splitRatios).toEqual([0.35]); expect(workbenchTab(state, tabKey)?.splitRatios).toEqual([0.35]);
state = setWorkbenchLayout(state, tabKey, "rows"); state = setWorkbenchLayout(state, tabKey, "rows");
expect(workbenchTab(state, tabKey)?.splitRatios).toEqual([]); 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", () => { it("keeps groups contiguous and ranks them by their latest pane", () => {
let state = ensureWorkbenchPaneTab(EMPTY_WORKBENCH_STATE, "pane-a"); let state = addWorkbenchPane(EMPTY_WORKBENCH_STATE, "pane-a", "pane-c");
state = addWorkbenchPane(state, "pane-b", "pane-d");
const alphaTabKey = workbenchTabForPane(state, "pane-a").tabKey; 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; const betaTabKey = workbenchTabForPane(state, "pane-b").tabKey;
state = addWorkbenchPane(state, betaTabKey, "pane-d");
const tabs = orderWorkbenchTabs( const tabs = orderWorkbenchTabs(
state, state,
["pane-d", "pane-c", "pane-b", "pane-a"], ["pane-d", "pane-c", "pane-b", "pane-a", "pane-e"],
new Map([ new Map([
["pane-a", "2026-08-01T10:00:00Z"], ["pane-a", "2026-08-01T10:00:00Z"],
["pane-b", "2026-08-03T10:00:00Z"], ["pane-b", "2026-08-03T10:00:00Z"],
["pane-c", "2026-08-05T10:00:00Z"], ["pane-c", "2026-08-05T10:00:00Z"],
["pane-d", "2026-08-04T10:00:00Z"], ["pane-d", "2026-08-04T10:00:00Z"],
["pane-e", "2026-08-02T10:00:00Z"],
]), ]),
); );
expect(tabs.map(({ tabKey, paneKeys, updatedAt }) => ({ expect(tabs.map(({ tabKey, paneKeys }) => ({ tabKey, paneKeys }))).toEqual([
tabKey, { tabKey: alphaTabKey, paneKeys: ["pane-c", "pane-a"] },
paneKeys, { tabKey: betaTabKey, paneKeys: ["pane-d", "pane-b"] },
updatedAt, { tabKey: workbenchTabForPane(state, "pane-e").tabKey, paneKeys: ["pane-e"] },
}))).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(Object.keys(state.tabs)).toHaveLength(2);
}); });
it("caps every virtual tab at four panes", () => { it("caps a group at four panes", () => {
let state = ensureWorkbenchPaneTab(EMPTY_WORKBENCH_STATE, "pane-a"); let state = addWorkbenchPane(EMPTY_WORKBENCH_STATE, "pane-a", "pane-1");
const tabKey = workbenchTabForPane(state, "pane-a").tabKey; for (let index = 2; index <= MAX_WORKBENCH_PANES; index += 1) {
for (let index = 1; index <= MAX_WORKBENCH_PANES; index += 1) { state = addWorkbenchPane(state, "pane-a", `pane-${index}`);
state = addWorkbenchPane(state, tabKey, `pane-${index}`);
} }
const tabKey = workbenchTabForPane(state, "pane-a").tabKey;
expect(workbenchTab(state, tabKey)?.paneKeys).toEqual([ expect(workbenchTab(state, tabKey)?.paneKeys).toEqual([
"pane-a", "pane-a",
"pane-1", "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({ const state = normalizeWorkbenchState({
version: 1, version: 1,
tabs: { tabs: {
alpha: { alpha: {
title: "Alpha", title: "Alpha",
paneKeys: ["pane-a", "pane-b", "pane-b", 9], paneKeys: ["pane-a", "pane-b", "pane-b", 9],
activePaneKey: "missing",
layout: "unknown", layout: "unknown",
}, },
duplicate: { duplicate: {
paneKeys: ["pane-b", "deleted"], paneKeys: ["pane-b", "deleted"],
activePaneKey: "pane-b",
layout: "grid", layout: "grid",
}, },
invisible: {
paneKeys: ["pane-c"],
layout: "columns",
},
}, },
}); });
const reconciled = reconcileWorkbench( const reconciled = reconcileWorkbench(
@@ -289,12 +202,19 @@ describe("workbench model", () => {
title: "Alpha", title: "Alpha",
paneKeys: ["pane-a", "pane-b"], paneKeys: ["pane-a", "pane-b"],
layoutPaneKeys: ["pane-a", "pane-b"], layoutPaneKeys: ["pane-a", "pane-b"],
activePaneKey: "pane-a",
layout: "columns", layout: "columns",
splitRatios: [], splitRatios: [],
}); });
expect(workbenchTab(reconciled, "duplicate")).toBeNull(); expect(Object.keys(reconciled.tabs)).toEqual(["alpha"]);
expect(workbenchTabForPane(reconciled, "pane-c").tab.paneKeys).toEqual(["pane-c"]); 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);
});
}); });