mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-12 07:09:19 +03:00
refactor(webui): simplify workbench state model
This commit is contained in:
@@ -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],
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
+48
-80
@@ -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<WorkbenchState>(
|
||||
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<string, string>());
|
||||
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<ChatSummary | null>(() => {
|
||||
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)
|
||||
));
|
||||
}}
|
||||
|
||||
@@ -100,7 +100,7 @@ interface ChatListProps {
|
||||
onToggleArchive: (key: string) => void;
|
||||
paneGroups?: Record<string, SidebarPaneGroup>;
|
||||
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")}
|
||||
</DropdownMenuItem>
|
||||
{paneGroup && onCreateTab ? (
|
||||
<DropdownMenuItem onSelect={() => onCreateTab(paneGroup.tabKey)}>
|
||||
<DropdownMenuItem onSelect={() => onCreateTab(s.key)}>
|
||||
<PanelsTopLeft className="h-4 w-4 shrink-0" aria-hidden />
|
||||
{t("workbench.createGroup", {
|
||||
defaultValue: "Create group",
|
||||
|
||||
@@ -50,7 +50,7 @@ interface SidebarProps {
|
||||
onToggleArchive: (key: string) => void;
|
||||
paneGroups?: Record<string, SidebarPaneGroup>;
|
||||
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?: (
|
||||
|
||||
@@ -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) => (
|
||||
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);
|
||||
return {
|
||||
version: 1,
|
||||
tabs: {
|
||||
...state.tabs,
|
||||
[tabKey]: {
|
||||
const tabs = { ...state.tabs };
|
||||
const nextTab = {
|
||||
...tab,
|
||||
paneKeys,
|
||||
layoutPaneKeys,
|
||||
activePaneKey: tab.activePaneKey === paneKey
|
||||
? paneKeys[Math.min(index, paneKeys.length - 1)]
|
||||
: tab.activePaneKey,
|
||||
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 {
|
||||
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));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -375,7 +375,6 @@ export interface WorkbenchTabState {
|
||||
title: string | null;
|
||||
paneKeys: string[];
|
||||
layoutPaneKeys: string[];
|
||||
activePaneKey: string;
|
||||
layout: WorkbenchLayout;
|
||||
splitRatios: number[];
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1267,7 +1267,6 @@ describe("NanobotClient", () => {
|
||||
explicit: true,
|
||||
title: "Research",
|
||||
paneKeys: ["websocket:a", "websocket:b"],
|
||||
activePaneKey: "websocket:a",
|
||||
layout: "columns",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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 (
|
||||
<PaneWorkbench
|
||||
panes={tab.layoutPaneKeys.map((key) => ({ 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)
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user