refactor(webui): simplify workbench state model

This commit is contained in:
chengyongru
2026-08-12 11:32:45 +08:00
parent 3822db3e2a
commit 1802bcd790
14 changed files with 276 additions and 377 deletions
@@ -1060,7 +1060,6 @@ async def test_webui_sidebar_state_update_broadcasts_workbench_to_other_devices(
"title": "Research",
"paneKeys": ["websocket:a", "websocket:b"],
"layoutPaneKeys": ["websocket:b", "websocket:a"],
"activePaneKey": "websocket:a",
"layout": "columns",
"splitRatios": [0.35],
}
+15 -6
View File
@@ -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(
+68 -12
View File
@@ -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
View File
@@ -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)
));
}}
+2 -2
View File
@@ -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",
+1 -1
View File
@@ -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));
+1
View File
@@ -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);
-1
View File
@@ -375,7 +375,6 @@ export interface WorkbenchTabState {
title: string | null;
paneKeys: string[];
layoutPaneKeys: string[];
activePaneKey: string;
layout: WorkbenchLayout;
splitRatios: number[];
}
-2
View File
@@ -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",
},
},
+1 -2
View File
@@ -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",
-1
View File
@@ -1267,7 +1267,6 @@ describe("NanobotClient", () => {
explicit: true,
title: "Research",
paneKeys: ["websocket:a", "websocket:b"],
activePaneKey: "websocket:a",
layout: "columns",
},
},
+5 -12
View File
@@ -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)
+75 -155
View File
@@ -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);
});
});