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",
|
"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],
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
));
|
));
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,31 +169,41 @@ 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;
|
||||||
tab.explicit ? tab : { ...tab, explicit: true }
|
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(
|
export function detachWorkbenchPane(
|
||||||
@@ -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 };
|
||||||
|
const nextTab = {
|
||||||
|
...tab,
|
||||||
|
paneKeys,
|
||||||
|
layoutPaneKeys,
|
||||||
|
splitRatios: [],
|
||||||
|
};
|
||||||
|
if (nextTab.explicit || paneKeys.length > 1) tabs[tabKey] = nextTab;
|
||||||
|
else delete tabs[tabKey];
|
||||||
return {
|
return {
|
||||||
version: 1,
|
version: 1,
|
||||||
tabs: {
|
tabs,
|
||||||
...state.tabs,
|
|
||||||
[tabKey]: {
|
|
||||||
...tab,
|
|
||||||
paneKeys,
|
|
||||||
layoutPaneKeys,
|
|
||||||
activePaneKey: tab.activePaneKey === paneKey
|
|
||||||
? paneKeys[Math.min(index, paneKeys.length - 1)]
|
|
||||||
: tab.activePaneKey,
|
|
||||||
splitRatios: [],
|
|
||||||
},
|
|
||||||
[nextTabKey]: defaultWorkbenchTab(paneKey),
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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));
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user