diff --git a/nanobot/channels/websocket/runtime.py b/nanobot/channels/websocket/runtime.py index 4af84e78f..68c0f1e26 100644 --- a/nanobot/channels/websocket/runtime.py +++ b/nanobot/channels/websocket/runtime.py @@ -530,6 +530,10 @@ class WebSocketChannel(BaseChannel): except Exception as e: self.logger.warning("failed to send {} event: {}", event, e) + async def _broadcast_webui_event(self, event: str, **fields: Any) -> None: + for connection in tuple(self._webui_connections): + await self._send_event(connection, event, **fields) + @classmethod def default_config(cls) -> dict[str, Any]: return WebSocketConfig().model_dump(by_alias=True) @@ -848,7 +852,7 @@ class WebSocketChannel(BaseChannel): ) return try: - await asyncio.to_thread( + saved_state = await asyncio.to_thread( write_webui_sidebar_state, cast(dict[str, Any], state), ) @@ -858,6 +862,11 @@ class WebSocketChannel(BaseChannel): "error", detail="invalid_sidebar_state", ) + return + await self._broadcast_webui_event( + "sidebar_state_updated", + state=saved_state, + ) return if t == "set_workspace_scope": cid = envelope.get("chat_id") @@ -1207,6 +1216,11 @@ class WebSocketChannel(BaseChannel): message="WebUI mutation returned an invalid response", ) return + if action == "sidebar.update" and isinstance(result, dict): + await self._broadcast_webui_event( + "sidebar_state_updated", + state=result, + ) await self._send_webui_response( connection, request_id, diff --git a/nanobot/channels/websocket/tests/test_websocket_channel.py b/nanobot/channels/websocket/tests/test_websocket_channel.py index 72da015cb..9c8e6d874 100644 --- a/nanobot/channels/websocket/tests/test_websocket_channel.py +++ b/nanobot/channels/websocket/tests/test_websocket_channel.py @@ -1029,6 +1029,60 @@ async def test_webui_persists_sidebar_state_larger_than_http_request_line( } +@pytest.mark.asyncio +async def test_webui_sidebar_state_update_broadcasts_workbench_to_other_devices( + bus: MagicMock, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path) + channel = _ch(bus) + source = AsyncMock() + source.request = SimpleNamespace(headers=Headers()) + other_device = AsyncMock() + channel._webui_connections.update({source, other_device}) + request_id = "sidebar-workbench-state" + + await channel._dispatch_envelope( + source, + "webui-client", + { + "type": "webui_request", + "request_id": request_id, + "action": "sidebar.update", + "payload": { + "state": { + "workbench": { + "version": 1, + "tabs": { + "tab:websocket:a": { + "explicit": True, + "title": "Research", + "paneKeys": ["websocket:a", "websocket:b"], + "layoutPaneKeys": ["websocket:b", "websocket:a"], + "activePaneKey": "websocket:a", + "layout": "columns", + } + }, + } + } + }, + }, + ) + await asyncio.gather(*tuple(channel._webui_request_tasks.values())) + + event = json.loads(other_device.send.await_args.args[0]) + assert event["event"] == "sidebar_state_updated" + assert event["state"]["workbench"]["tabs"]["tab:websocket:a"]["paneKeys"] == [ + "websocket:a", + "websocket:b", + ] + assert event["state"]["workbench"]["tabs"]["tab:websocket:a"]["layoutPaneKeys"] == [ + "websocket:b", + "websocket:a", + ] + + @pytest.mark.asyncio async def test_client_cannot_self_assert_webui_quote_context(bus: MagicMock) -> None: channel = _ch(bus) diff --git a/nanobot/webui/sidebar_state.py b/nanobot/webui/sidebar_state.py index 4667dcf01..049494ae4 100644 --- a/nanobot/webui/sidebar_state.py +++ b/nanobot/webui/sidebar_state.py @@ -24,8 +24,10 @@ _MAX_MAP_ITEMS = 2_000 _MAX_KEY_LEN = 512 _MAX_TITLE_LEN = 160 _MAX_TAG_LEN = 40 +_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"} def webui_sidebar_state_path() -> Path: @@ -42,6 +44,7 @@ def default_webui_sidebar_state() -> dict[str, Any]: "project_name_overrides": {}, "tags_by_key": {}, "collapsed_groups": {}, + "workbench": {"version": 1, "tabs": {}}, "view": { "density": "comfortable", "show_previews": False, @@ -131,8 +134,53 @@ 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") + if not isinstance(raw_tabs, dict): + return {"version": 1, "tabs": {}} + + tabs: dict[str, dict[str, Any]] = {} + claimed_panes: set[str] = set() + for raw_tab_key, raw_tab in list(cast(dict[Any, Any], raw_tabs).items())[:_MAX_MAP_ITEMS]: + tab_key = _clean_string(raw_tab_key) + if tab_key is None or not isinstance(raw_tab, dict): + continue + tab = cast(dict[str, Any], raw_tab) + pane_keys = [ + key + for key in _clean_string_list(tab.get("paneKeys")) + if key not in claimed_panes + ][:_MAX_WORKBENCH_PANES] + if not pane_keys: + continue + requested_layout_pane_keys = [ + key for key in _clean_string_list(tab.get("layoutPaneKeys")) if key in pane_keys + ] + layout_pane_keys = requested_layout_pane_keys + [ + key for key in pane_keys if key not in requested_layout_pane_keys + ] + claimed_panes.update(pane_keys) + 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, + "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, + } + return {"version": 1, "tabs": tabs} + + def normalize_webui_sidebar_state(raw: Any) -> dict[str, Any]: - """Return a schema-v1 sidebar state from any older/partial input.""" + """Return a validated canonical sidebar state.""" if not isinstance(raw, dict): raw = {} raw = cast(dict[str, Any], raw) @@ -146,6 +194,7 @@ def normalize_webui_sidebar_state(raw: Any) -> dict[str, Any]: ) state["tags_by_key"] = _clean_tags_by_key(raw.get("tags_by_key")) state["collapsed_groups"] = _clean_bool_map(raw.get("collapsed_groups")) + state["workbench"] = _clean_workbench(raw.get("workbench")) state["view"] = _clean_view(raw.get("view")) updated_at = raw.get("updated_at") state["updated_at"] = updated_at if isinstance(updated_at, str) else None diff --git a/tests/utils/test_webui_sidebar_state.py b/tests/utils/test_webui_sidebar_state.py index d835e9b3e..831b566ad 100644 --- a/tests/utils/test_webui_sidebar_state.py +++ b/tests/utils/test_webui_sidebar_state.py @@ -17,7 +17,7 @@ def test_sidebar_state_defaults_when_file_missing(tmp_path, monkeypatch) -> None assert webui_sidebar_state_path() == tmp_path / "webui" / "sidebar-state.json" -def test_sidebar_state_normalizes_old_or_partial_payload(tmp_path, monkeypatch) -> None: +def test_sidebar_state_normalizes_partial_payload(tmp_path, monkeypatch) -> None: monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path) path = webui_sidebar_state_path() path.parent.mkdir(parents=True, exist_ok=True) @@ -31,6 +31,24 @@ def test_sidebar_state_normalizes_old_or_partial_payload(tmp_path, monkeypatch) "project_name_overrides": {"/repo": " Core ", "bad": ""}, "tags_by_key": {"websocket:a": ["work", "work", ""]}, "collapsed_groups": {"Earlier": 1}, + "workbench": { + "version": 1, + "tabs": { + "tab:websocket:a": { + "explicit": True, + "title": " Research ", + "paneKeys": ["websocket:a", "websocket:b", "websocket:a"], + "layoutPaneKeys": ["websocket:b", "missing", "websocket:a"], + "activePaneKey": "missing", + "layout": "invalid-layout", + }, + "tab:websocket:b": { + "paneKeys": ["websocket:b", "websocket:c"], + "activePaneKey": "websocket:c", + "layout": "bsp", + }, + }, + }, "view": {"density": "tiny", "show_archived": True, "sort": "nope"}, } ), @@ -47,6 +65,27 @@ def test_sidebar_state_normalizes_old_or_partial_payload(tmp_path, monkeypatch) assert state["project_name_overrides"] == {"/repo": "Core"} assert state["tags_by_key"] == {"websocket:a": ["work"]} assert state["collapsed_groups"] == {"Earlier": True} + assert state["workbench"] == { + "version": 1, + "tabs": { + "tab:websocket:a": { + "explicit": True, + "title": "Research", + "paneKeys": ["websocket:a", "websocket:b"], + "layoutPaneKeys": ["websocket:b", "websocket:a"], + "activePaneKey": "websocket:a", + "layout": "columns", + }, + "tab:websocket:b": { + "explicit": False, + "title": None, + "paneKeys": ["websocket:c"], + "layoutPaneKeys": ["websocket:c"], + "activePaneKey": "websocket:c", + "layout": "bsp", + }, + }, + } assert state["view"] == { "density": "comfortable", "show_previews": False, diff --git a/webui/src/App.tsx b/webui/src/App.tsx index 59dda9337..ab8575267 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -17,19 +17,22 @@ import type { SettingsSectionKey } from "@/components/settings/SettingsView"; import { ThreadShell } from "@/components/thread/ThreadShell"; import { PaneWorkbench } from "@/components/workbench/PaneWorkbench"; import { - WORKBENCH_STORAGE_KEY, + EMPTY_WORKBENCH_STATE, MAX_WORKBENCH_PANES, addWorkbenchPane, attachWorkbenchPane, + createWorkbenchTab, detachWorkbenchPane, - ensureWorkbenchTab, + dissolveWorkbenchTab, + ensureWorkbenchPaneTab, focusWorkbenchPane, - parseWorkbenchState, - promoteWorkbenchPane, + orderWorkbenchTabs, reconcileWorkbench, + renameWorkbenchTab, setWorkbenchLayout, - workbenchChildPaneKeys, + setWorkbenchPaneLayoutOrder, workbenchTab, + workbenchTabForPane, type WorkbenchState, } from "@/components/workbench/workbench-model"; import { floatingSurfaceElevationClassName } from "@/components/ui/floating-surface"; @@ -53,7 +56,7 @@ import { loadSavedSecret, saveSecret, } from "@/lib/bootstrap"; -import { displayTitle } from "@/lib/chat-groups"; +import { displayTitle, sortSessions } from "@/lib/chat-groups"; import { deriveTitle } from "@/lib/format"; import { NanobotClient } from "@/lib/nanobot-client"; import { ClientProvider, useClient } from "@/providers/ClientProvider"; @@ -138,14 +141,6 @@ const RenameChatDialog = lazy(async () => { return { default: module.RenameChatDialog }; }); -function readWorkbenchState(): WorkbenchState { - try { - return parseWorkbenchState(window.localStorage.getItem(WORKBENCH_STORAGE_KEY)); - } catch { - return parseWorkbenchState(null); - } -} - function SurfaceLoadingFallback() { const { t } = useTranslation(); return ( @@ -1043,7 +1038,11 @@ function Shell({ deleteChat, getSessionAutomations, } = useSessions(); - const { state: sidebarState, update: updateSidebarState } = + const { + state: sidebarState, + loading: sidebarStateLoading, + update: updateSidebarState, + } = useSidebarState(sessions, !loading); const initialRouteRef = useRef(null); if (!initialRouteRef.current) initialRouteRef.current = readShellRoute(); @@ -1060,16 +1059,14 @@ function Shell({ const [hostSidebarPreviewOpen, setHostSidebarPreviewOpen] = useState(false); const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); const [sessionSearchOpen, setSessionSearchOpen] = useState(false); - const [workbenchState, setWorkbenchState] = useState(readWorkbenchState); + const [workbenchState, setWorkbenchState] = useState( + EMPTY_WORKBENCH_STATE, + ); + const workbenchServerHydratedRef = useRef(false); + const lastServerWorkbenchRef = useRef(""); + const skipWorkbenchPersistenceRef = useRef(false); const [creatingPane, setCreatingPane] = useState(false); - const childPaneKeys = useMemo( - () => workbenchChildPaneKeys(workbenchState), - [workbenchState], - ); - const topicSessions = useMemo( - () => sessions.filter((session) => !childPaneKeys.has(session.key)), - [childPaneKeys, sessions], - ); + const topicSessions = sessions; const [pendingDelete, setPendingDelete] = useState<{ items: SidebarDeleteItem[]; automations?: SessionAutomationJob[]; @@ -1078,6 +1075,10 @@ function Shell({ key: string; label: string; } | null>(null); + const [pendingTabRename, setPendingTabRename] = useState<{ + key: string; + label: string; + } | null>(null); const [pendingProjectRename, setPendingProjectRename] = useState<{ key: string; label: string; @@ -1181,6 +1182,19 @@ 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( @@ -1193,15 +1207,19 @@ function Shell({ }, [hostSidebarOpen]); useEffect(() => { - try { - window.localStorage.setItem( - WORKBENCH_STORAGE_KEY, - JSON.stringify(workbenchState), - ); - } catch { - // ignore storage errors (private mode, etc.) + if (!workbenchServerHydratedRef.current || sidebarStateLoading) return; + if (skipWorkbenchPersistenceRef.current) { + skipWorkbenchPersistenceRef.current = false; + return; } - }, [workbenchState]); + 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); @@ -1266,11 +1284,13 @@ function Shell({ if (temporarySessions[activeKey]) return temporarySessions[activeKey]; return sessions.find((s) => s.key === activeKey) ?? null; }, [sessions, activeKey, temporarySessions]); - const activeTabState = useMemo(() => ( + const activeTabMatch = useMemo(() => ( activeKey && !temporarySessions[activeKey] - ? workbenchTab(workbenchState, activeKey) + ? workbenchTabForPane(workbenchState, activeKey) : null ), [activeKey, temporarySessions, workbenchState]); + const activeTabKey = activeTabMatch?.tabKey ?? null; + const activeTabState = activeTabMatch?.tab ?? null; const activePaneSession = useMemo(() => { if (!activeTabState) return activeSession; return sessions.find((session) => session.key === activeTabState.activePaneKey) @@ -1341,16 +1361,23 @@ function Shell({ }, [loading, sessions]); useEffect(() => { - if (loading) return; + 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; } - return ensureWorkbenchTab(reconciled, activeKey); + const match = workbenchTabForPane(reconciled, activeKey); + return focusWorkbenchPane(reconciled, match.tabKey, activeKey); }); - }, [activeKey, loading, sessions, temporarySessions]); + }, [ + activeKey, + loading, + sidebarStateLoading, + sessions, + temporarySessions, + ]); useEffect(() => { if (loading) return; @@ -1783,6 +1810,18 @@ function Shell({ [pendingRename, updateSidebarState], ); + const onRequestRenameTab = useCallback((key: string, label: string) => { + setPendingTabRename({ key, label }); + }, []); + + const onConfirmTabRename = useCallback((title: string) => { + if (!pendingTabRename) return; + setWorkbenchState((current) => ( + renameWorkbenchTab(current, pendingTabRename.key, title) + )); + setPendingTabRename(null); + }, [pendingTabRename]); + const onToggleGroup = useCallback( (groupId: string) => { void updateSidebarState((current) => { @@ -1867,17 +1906,6 @@ function Shell({ [activeKey, navigate, sidebarState.archived_keys, topicSessions, updateSidebarState], ); - const onReorderSessions = useCallback( - (sessionOrder: string[]) => { - void updateSidebarState((current) => ({ - ...current, - session_order: sessionOrder, - view: { ...current.view, sort: "manual" }, - })); - }, - [updateSidebarState], - ); - const onToggleArchived = useCallback(() => { void updateSidebarState((current) => ({ ...current, @@ -1894,13 +1922,14 @@ function Shell({ }, []); const onAddPane = useCallback(async () => { - const tabKey = activeKey; + const tabKey = activeTabKey; if ( !tabKey + || !activeKey || !activeSession || creatingPane || (activeTabState?.paneKeys.length ?? 0) >= MAX_WORKBENCH_PANES - || temporarySessionsRef.current[tabKey] + || temporarySessionsRef.current[activeKey] ) return; setMobileSidebarOpen(false); setSessionSearchOpen(false); @@ -1909,7 +1938,17 @@ function Shell({ const scope = activeWorkspaceScope; const chatId = await createChat(scope); const paneKey = `websocket:${chatId}`; - setWorkbenchState((current) => addWorkbenchPane(current, tabKey, paneKey)); + pendingCreatedSessionKeyRef.current = paneKey; + setWorkbenchState((current) => { + const withTab = ensureWorkbenchPaneTab(current, activeKey); + const target = workbenchTabForPane(withTab, activeKey); + return addWorkbenchPane(withTab, target.tabKey, paneKey); + }); + navigate({ + view: "chat", + activeKey: paneKey, + settingsSection: "overview", + }); if (scope) { setWorkspaceOverrides((current) => ({ ...current, @@ -1927,10 +1966,12 @@ function Shell({ }, [ activeKey, activeSession, + activeTabKey, activeTabState, activeWorkspaceScope, createChat, creatingPane, + navigate, t, ]); @@ -2242,6 +2283,68 @@ function Shell({ || deriveTitle(session.preview, t("chat.newChat")) ), [sidebarState.title_overrides, t]); + const automaticSidebarSort = sidebarState.view.sort === "manual" + ? "updated_desc" + : sidebarState.view.sort; + const orderedWorkbenchTabs = useMemo(() => { + const orderedSessions = sortSessions( + sessions, + automaticSidebarSort, + sidebarState.title_overrides, + sidebarState.session_order, + ); + const updatedAtByKey = new Map(sessions.map((session) => [ + session.key, + session.updatedAt ?? session.createdAt, + ])); + return orderWorkbenchTabs( + workbenchState, + orderedSessions.map((session) => session.key), + updatedAtByKey, + ); + }, [ + automaticSidebarSort, + sessions, + sidebarState.session_order, + sidebarState.title_overrides, + workbenchState, + ]); + const orderedWorkbenchTabsByKey = useMemo( + () => new Map(orderedWorkbenchTabs.map((tab) => [tab.tabKey, tab])), + [orderedWorkbenchTabs], + ); + const sidebarTabPresentations = useMemo(() => { + const sessionsByKey = new Map(sessions.map((session) => [session.key, session])); + return orderedWorkbenchTabs.flatMap((tab) => { + const anchorKey = tab.tab.paneKeys.find((key) => sessionsByKey.has(key)) + ?? tab.paneKeys[0]; + const anchor = sessionsByKey.get(anchorKey); + if (!anchor) return []; + const title = tab.tab.title ?? titleForSession(anchor); + const visible = tab.tab.explicit || tab.paneKeys.length > 1; + const rowKey = visible ? tab.tabKey : tab.paneKeys[0]; + return [{ + orderedTab: tab, + rowKey, + title, + session: visible + ? { + ...anchor, + key: tab.tabKey, + chatId: `workbench-tab:${tab.tabKey}`, + title, + preview: "", + updatedAt: tab.updatedAt, + } + : anchor, + }]; + }); + }, [orderedWorkbenchTabs, sessions, titleForSession]); + const sidebarTopicSessions = useMemo( + () => sidebarTabPresentations.map((presentation) => presentation.session), + [sidebarTabPresentations], + ); + const headerTitle = temporaryChatActive ? deriveTemporaryChatTitle(activeSession?.preview, t("temporaryChat.title")) : activeSession @@ -2250,18 +2353,31 @@ function Shell({ const workbenchPaneSessions = useMemo(() => { if (!activeTabState) return []; const byKey = new Map(sessions.map((session) => [session.key, session])); - return activeTabState.paneKeys + const sortedPaneKeys = activeTabKey + ? orderedWorkbenchTabsByKey.get(activeTabKey)?.paneKeys ?? activeTabState.paneKeys + : activeTabState.paneKeys; + const paneKeys = [ + ...activeTabState.layoutPaneKeys.filter((key) => byKey.has(key)), + ...sortedPaneKeys.filter((key) => !activeTabState.layoutPaneKeys.includes(key)), + ]; + return paneKeys .map((key) => byKey.get(key)) .filter((session): session is ChatSummary => session !== undefined); - }, [activeTabState, sessions]); + }, [activeTabKey, activeTabState, orderedWorkbenchTabsByKey, sessions]); const paneChromeEnabled = Boolean( activeKey && activeSession && !temporaryChatActive && activeTabState, ); + const activeTabVisible = Boolean( + activeTabState + && (activeTabState.explicit || activeTabState.paneKeys.length > 1), + ); const renderedWorkbenchPanes = useMemo(() => { - if (paneChromeEnabled && activeKey) { + if (paneChromeEnabled) { return workbenchPaneSessions.map((session) => ({ key: session.key, - reactKey: session.key === activeKey ? "tab-root" : `pane:${session.key}`, + reactKey: session.key === activeTabState?.paneKeys[0] + ? "tab-root" + : `pane:${session.key}`, title: titleForSession(session), })); } @@ -2270,7 +2386,14 @@ function Shell({ reactKey: "tab-root", title: headerTitle, }]; - }, [activeKey, headerTitle, paneChromeEnabled, titleForSession, workbenchPaneSessions]); + }, [ + activeKey, + activeTabState?.paneKeys, + headerTitle, + paneChromeEnabled, + titleForSession, + workbenchPaneSessions, + ]); const renderedActivePaneKey = paneChromeEnabled && activeTabState ? activeTabState.activePaneKey : renderedWorkbenchPanes[0].key; @@ -2279,9 +2402,9 @@ function Shell({ : "columns"; const sidebarPaneGroups = useMemo(() => { const sessionsByKey = new Map(sessions.map((session) => [session.key, session])); - return Object.fromEntries(topicSessions.map((topic) => { - const tab = workbenchTab(workbenchState, topic.key); - const panes = tab.paneKeys + return Object.fromEntries(sidebarTabPresentations.map((presentation) => { + const orderedTab = presentation.orderedTab; + const panes = orderedTab.paneKeys .map((key) => sessionsByKey.get(key)) .filter((session): session is ChatSummary => session !== undefined) .map((session) => ({ @@ -2289,73 +2412,72 @@ function Shell({ chatId: session.chatId, title: titleForSession(session), })); - return [topic.key, { - topicKey: topic.key, - activePaneKey: tab.activePaneKey, + return [presentation.rowKey, { + tabKey: orderedTab.tabKey, + title: presentation.title, + activePaneKey: orderedTab.tab.activePaneKey, + visible: orderedTab.tab.explicit || orderedTab.paneKeys.length > 1, panes, }]; })); - }, [sessions, titleForSession, topicSessions, workbenchState]); - const attachableTabKeys = useMemo(() => ( - topicSessions - .filter((session) => workbenchTab(workbenchState, session.key).paneKeys.length === 1) - .map((session) => session.key) - ), [topicSessions, workbenchState]); - const paneAcceptingTabKeys = useMemo(() => ( - topicSessions - .filter((session) => ( - workbenchTab(workbenchState, session.key).paneKeys.length < MAX_WORKBENCH_PANES - )) - .map((session) => session.key) - ), [topicSessions, workbenchState]); + }, [ + sessions, + sidebarTabPresentations, + titleForSession, + ]); const activePaneLimitReached = Boolean( activeTabState && activeTabState.paneKeys.length >= MAX_WORKBENCH_PANES, ); const onActivateWorkbenchPane = useCallback((paneKey: string) => { - if (!activeKey) return; - setWorkbenchState((current) => focusWorkbenchPane(current, activeKey, paneKey)); - }, [activeKey]); + if (!activeTabKey) return; + setWorkbenchState((current) => focusWorkbenchPane(current, activeTabKey, paneKey)); + onSelectChat(paneKey); + }, [activeTabKey, onSelectChat]); + + const onSelectSidebarTab = useCallback((tabKey: string) => { + const tab = workbenchTab(workbenchState, tabKey); + if (tab) onSelectChat(tab.activePaneKey); + }, [onSelectChat, workbenchState]); + + const onSelectSidebarItem = useCallback((key: string) => { + if ( + temporarySessionsRef.current[key] + || sessions.some((session) => session.key === key) + ) { + onSelectChat(key); + return; + } + onSelectSidebarTab(key); + }, [onSelectChat, onSelectSidebarTab, sessions]); const onSelectSidebarPane = useCallback((tabKey: string, paneKey: string) => { setWorkbenchState((current) => focusWorkbenchPane(current, tabKey, paneKey)); - if (activeKey !== tabKey) { - navigate({ - view: "chat", - activeKey: tabKey, - settingsSection: "overview", - }); - } - }, [activeKey, navigate]); + onSelectChat(paneKey); + }, [onSelectChat]); const onDetachWorkbenchPane = useCallback((tabKey: string, paneKey: string) => { setWorkbenchState((current) => detachWorkbenchPane(current, tabKey, paneKey)); }, []); - const onPromoteWorkbenchPane = useCallback((tabKey: string, paneKey: string) => { - setWorkbenchState((current) => promoteWorkbenchPane(current, tabKey, paneKey)); + const onCreateWorkbenchTab = useCallback((tabKey: string) => { + setWorkbenchState((current) => createWorkbenchTab(current, tabKey)); + }, []); + + const onDissolveWorkbenchTab = useCallback((tabKey: string) => { + setWorkbenchState((current) => dissolveWorkbenchTab(current, tabKey)); }, []); const onAttachWorkbenchPane = useCallback(( paneKey: string, tabKey: string, - beforePaneKey?: string | null, ) => { - if (paneKey === tabKey) return; - setWorkbenchState((current) => attachWorkbenchPane( - current, - tabKey, - paneKey, - beforePaneKey, - )); - if (activeKey === paneKey) { - navigate({ - view: "chat", - activeKey: tabKey, - settingsSection: "overview", - }); - } - }, [activeKey, navigate]); + setWorkbenchState((current) => { + const target = workbenchTab(current, tabKey); + if (!target || (!target.explicit && target.paneKeys.length < 2)) return current; + return attachWorkbenchPane(current, tabKey, paneKey); + }); + }, []); useEffect(() => { if (view === "settings") { @@ -2387,28 +2509,49 @@ function Shell({ : t("app.documentTitle.base"); }, [activeSession, headerTitle, i18n.resolvedLanguage, t, view]); + const pinnedPaneKeys = useMemo( + () => new Set(sidebarState.pinned_keys), + [sidebarState.pinned_keys], + ); + const archivedPaneKeys = useMemo( + () => new Set(sidebarState.archived_keys), + [sidebarState.archived_keys], + ); + const sidebarPinnedTabKeys = useMemo(() => sidebarTabPresentations + .filter(({ orderedTab }) => orderedTab.paneKeys.some((key) => pinnedPaneKeys.has(key))) + .map(({ rowKey }) => rowKey), [pinnedPaneKeys, sidebarTabPresentations]); + const sidebarArchivedTabKeys = useMemo(() => sidebarTabPresentations + .filter(({ orderedTab }) => orderedTab.paneKeys.every((key) => archivedPaneKeys.has(key))) + .map(({ rowKey }) => rowKey), [archivedPaneKeys, sidebarTabPresentations]); + const activeSidebarKey = activeTabKey + ? sidebarTabPresentations.find(({ orderedTab }) => ( + orderedTab.tabKey === activeTabKey + ))?.rowKey ?? activeKey + : activeKey; + const sidebarProps = { - sessions: topicSessions, + sessions: sidebarTopicSessions, temporarySessions: temporarySessionList, - activeKey: view === "chat" ? activeKey : null, + activeKey: view === "chat" + ? (temporaryChatActive ? activeKey : activeSidebarKey) + : null, loading, newChatActive: view === "chat" && activeKey === null, onNewChat, - onSelect: onSelectChat, + onSelect: onSelectSidebarItem, onCloseTemporaryChat, onRequestDelete, onRequestDeleteMany, onTogglePin, onRequestRename, onToggleArchive, + onRequestRenameTab, paneGroups: sidebarPaneGroups, onSelectPane: onSelectSidebarPane, + onCreateTab: onCreateWorkbenchTab, onDetachPane: onDetachWorkbenchPane, - onPromotePane: onPromoteWorkbenchPane, - attachableTabKeys, - paneAcceptingTabKeys, + onDissolveTab: onDissolveWorkbenchTab, onAttachPane: onAttachWorkbenchPane, - onReorderSessions, onToggleGroup, onRequestRenameProject, onNewChatInProject, @@ -2420,19 +2563,19 @@ function Shell({ onOpenSearch: onOpenSessionSearch, activeUtility: view === "apps" || view === "automations" || view === "skills" ? view : null, onToggleArchived, - pinnedKeys: sidebarState.pinned_keys, - archivedKeys: sidebarState.archived_keys, + pinnedKeys: sidebarPinnedTabKeys, + archivedKeys: sidebarArchivedTabKeys, + pinnedPaneKeys: sidebarState.pinned_keys, + archivedPaneKeys: sidebarState.archived_keys, sessionOrder: sidebarState.session_order, titleOverrides: sidebarState.title_overrides, projectNameOverrides: sidebarState.project_name_overrides, collapsedGroups: sidebarState.collapsed_groups, runningChatIds: runningChatIdList, updatedChatIds: updatedChatIdList, - viewState: sidebarState.view, + viewState: { ...sidebarState.view, sort: automaticSidebarSort }, showArchived: sidebarState.view.show_archived, - archivedCount: topicSessions.filter( - (session) => sidebarState.archived_keys.includes(session.key), - ).length, + archivedCount: sidebarArchivedTabKeys.length, defaultWorkspacePath: workspaces?.default_scope.project_path ?? null, }; const hostSidebarCollapsed = showHostChrome && !hostSidebarOpen; @@ -2592,13 +2735,20 @@ function Shell({ activePaneKey={renderedActivePaneKey} layout={renderedWorkbenchLayout} chrome={paneChromeEnabled} + showLayoutControl={activeTabVisible} addPaneDisabled={creatingPane || activePaneLimitReached} onActivatePane={onActivateWorkbenchPane} onAddPane={onAddPane} onLayoutChange={(layout) => { - if (!activeKey) return; + if (!activeTabKey) return; setWorkbenchState((current) => ( - setWorkbenchLayout(current, activeKey, layout) + setWorkbenchLayout(current, activeTabKey, layout) + )); + }} + onPaneOrderChange={(paneKeys) => { + if (!activeTabKey) return; + setWorkbenchState((current) => ( + setWorkbenchPaneLayoutOrder(current, activeTabKey, paneKeys) )); }} renderPane={(pane, context) => { @@ -2674,6 +2824,7 @@ function Shell({ defaultValue: "Message {{title}}", title: pane.title, })} + emptyComposerVariant="thread" workspaceScope={paneScope} workspaceDefaultScope={workspaces?.default_scope ?? null} workspaceControls={workspaces?.controls ?? null} @@ -2745,6 +2896,19 @@ function Shell({ /> ) : null} + {pendingTabRename ? ( + + setPendingTabRename(null)} + onConfirm={onConfirmTabRename} + /> + + ) : null} {pendingProjectRename ? ( canvas.remove(), 0); -} - interface ChatListProps { sessions: ChatSummary[]; temporarySessions?: ChatSummary[]; @@ -156,24 +96,24 @@ interface ChatListProps { onRequestDeleteMany?: (items: SidebarDeleteItem[]) => void; onTogglePin: (key: string) => void; onRequestRename: (key: string, label: string) => void; + onRequestRenameTab?: (key: string, label: string) => void; onToggleArchive: (key: string) => void; paneGroups?: Record; onSelectPane?: (tabKey: string, paneKey: string) => void; + onCreateTab?: (tabKey: string) => void; onDetachPane?: (tabKey: string, paneKey: string) => void; - onPromotePane?: (tabKey: string, paneKey: string) => void; - attachableTabKeys?: string[]; - paneAcceptingTabKeys?: string[]; + onDissolveTab?: (tabKey: string) => void; onAttachPane?: ( paneKey: string, tabKey: string, - beforePaneKey?: string | null, ) => void; - onReorderSessions?: (keys: string[]) => void; onToggleGroup?: (groupId: string) => void; onRequestRenameProject?: (projectKey: string, label: string) => void; onNewChatInProject?: (projectPath: string, projectName: string) => void; pinnedKeys?: string[]; archivedKeys?: string[]; + pinnedPaneKeys?: string[]; + archivedPaneKeys?: string[]; sessionOrder?: string[]; titleOverrides?: Record; projectNameOverrides?: Record; @@ -201,20 +141,21 @@ export const ChatList = memo(function ChatList({ onRequestDeleteMany, onTogglePin, onRequestRename, + onRequestRenameTab, onToggleArchive, paneGroups = {}, onSelectPane, + onCreateTab, onDetachPane, - onPromotePane, - attachableTabKeys = [], - paneAcceptingTabKeys = [], + onDissolveTab, onAttachPane, - onReorderSessions, onToggleGroup, onRequestRenameProject, onNewChatInProject, pinnedKeys = [], archivedKeys = [], + pinnedPaneKeys = [], + archivedPaneKeys = [], sessionOrder = [], titleOverrides = {}, projectNameOverrides = {}, @@ -233,15 +174,9 @@ export const ChatList = memo(function ChatList({ }: ChatListProps) { const { t } = useTranslation(); const [visibleLimit, setVisibleLimit] = useState(INITIAL_VISIBLE_SESSIONS); - const [sessionDropTarget, setSessionDropTarget] = useState<{ - edge: "before" | "after"; - key: string; - } | null>(null); - const [paneDrag, setPaneDrag] = useState(null); const tabRowRefs = useRef(new Map()); const pendingTabRectsRef = useRef | null>(null); const tabLayoutAnimationsRef = useRef(new Map()); - const paneDragMotionRef = useRef(null); const [collapsedPaneGroups, setCollapsedPaneGroups] = useState>( () => new Set(), ); @@ -249,15 +184,6 @@ export const ChatList = memo(function ChatList({ const [selectedDeleteKeys, setSelectedDeleteKeys] = useState>( () => new Set(), ); - const draggedSessionKey = paneDrag?.origin === "tab" - ? paneDrag.item.paneKey - : null; - const draggedSessionHeight = paneDrag?.origin === "tab" ? paneDrag.height : 0; - const attachableTabs = useMemo(() => new Set(attachableTabKeys), [attachableTabKeys]); - const paneAcceptingTabs = useMemo( - () => new Set(paneAcceptingTabKeys), - [paneAcceptingTabKeys], - ); const deleteItemsByKey = useMemo(() => { const items = new Map(); for (const group of Object.values(paneGroups)) { @@ -274,12 +200,16 @@ export const ChatList = memo(function ChatList({ } return items; }, [paneGroups, sessions, t, titleOverrides]); - const paneMoveTargets = useMemo(() => sessions - .filter((session) => paneAcceptingTabs.has(session.key)) - .map((session) => ({ - key: session.key, - title: deleteItemsByKey.get(session.key)?.label ?? session.title ?? session.chatId, - })), [deleteItemsByKey, paneAcceptingTabs, sessions]); + const paneGroupTargets = useMemo(() => Array.from(new Map( + Object.values(paneGroups) + .filter((group) => group.visible ?? group.panes.length > 1) + .map((group) => [group.tabKey, { + key: group.tabKey, + title: group.title, + paneCount: group.panes.length, + atCapacity: group.panes.length >= MAX_WORKBENCH_PANES, + }]), + ).values()), [paneGroups]); const labels = useMemo(() => ({ pinned: t("chat.groups.pinned"), all: t("chat.groups.all"), @@ -332,19 +262,8 @@ export const ChatList = memo(function ChatList({ ); const pinned = useMemo(() => new Set(pinnedKeys), [pinnedKeys]); const archived = useMemo(() => new Set(archivedKeys), [archivedKeys]); - const sessionLanes = useMemo(() => { - const lanes = new Map(); - for (const group of groups) { - const scope = group.id.startsWith("date:") ? "timeline" : group.id; - for (const session of group.sessions) { - const status = pinned.has(session.key) - ? "pinned" - : archived.has(session.key) ? "archived" : "normal"; - lanes.set(session.key, `${scope}:${status}`); - } - } - return lanes; - }, [archived, groups, pinned]); + const pinnedPanes = useMemo(() => new Set(pinnedPaneKeys), [pinnedPaneKeys]); + const archivedPanes = useMemo(() => new Set(archivedPaneKeys), [archivedPaneKeys]); const hiddenSessionCount = Math.max(0, totalSessionCount - visibleSessionCount); useEffect(() => { @@ -388,12 +307,6 @@ export const ChatList = memo(function ChatList({ pendingTabRectsRef.current = measureTabRows(); }, [measureTabRows]); - const updatePaneDropSlot = useCallback((next: PaneDropSlot | null) => { - if (!paneDrag || samePaneDropSlot(paneDrag.slot, next)) return; - captureTabLayout(); - setPaneDrag({ ...paneDrag, slot: next }); - }, [captureTabLayout, paneDrag]); - const togglePaneGroup = useCallback((key: string) => { captureTabLayout(); setCollapsedPaneGroups((current) => { @@ -404,100 +317,6 @@ export const ChatList = memo(function ChatList({ }); }, [captureTabLayout]); - const updatePaneDragMotion = useCallback((clientX: number, clientY: number) => { - const motion = paneDragMotionRef.current; - if (!motion || (clientX === 0 && clientY === 0)) return; - motion.pointerX = clientX; - motion.pointerY = clientY; - if (motion.frame !== null) return; - motion.frame = window.requestAnimationFrame(() => { - const current = paneDragMotionRef.current; - if (!current) return; - current.frame = null; - if (current.snapLeft !== null) { - const slot = document.querySelector("[data-pane-snap-slot]"); - if (slot) updatePaneDragSnap(current, slot); - } - positionPaneDragMotion(current); - }); - }, []); - - const beginPaneDragMotion = useCallback((event: DragEvent) => { - const element = event.currentTarget.closest("li"); - if (!element) return; - const visual = element.querySelector( - "[data-sidebar-pane], [data-sidebar-tab]", - ) ?? element; - const rect = visual.getBoundingClientRect(); - const overlay = visual.cloneNode(true) as HTMLElement; - overlay.removeAttribute("data-chat-row"); - overlay.removeAttribute("data-sidebar-pane"); - overlay.removeAttribute("data-sidebar-tab"); - overlay.setAttribute("data-pane-drag-overlay", "true"); - overlay.setAttribute("aria-hidden", "true"); - overlay.classList.add( - "!bg-sidebar-selected", - "!shadow-none", - ); - overlay.querySelectorAll("button, [tabindex]").forEach((child) => { - child.tabIndex = -1; - }); - Object.assign(overlay.style, { - position: "fixed", - left: `${rect.left}px`, - top: `${rect.top}px`, - width: `${rect.width}px`, - height: `${rect.height}px`, - margin: "0", - opacity: "1", - visibility: "visible", - pointerEvents: "none", - zIndex: "2147483647", - transform: "translate3d(0, 0, 0)", - transition: "none", - boxShadow: "none", - }); - document.body.append(overlay); - const pointerX = event.clientX || rect.left + rect.width / 2; - const pointerY = event.clientY || rect.top + rect.height / 2; - paneDragMotionRef.current = { - frame: null, - grabOffsetX: pointerX - rect.left, - grabOffsetY: pointerY - rect.top, - originHeight: rect.height, - originLeft: rect.left, - originTop: rect.top, - originWidth: rect.width, - overlay, - pointerX, - pointerY, - snapHeight: null, - snapLeft: null, - snapTop: null, - snapWidth: null, - }; - hideNativeDragPreview(event.dataTransfer); - }, []); - - const clearPaneDragMotion = useCallback(() => { - const motion = paneDragMotionRef.current; - if (!motion) return; - if (motion.frame !== null) window.cancelAnimationFrame(motion.frame); - motion.overlay.remove(); - paneDragMotionRef.current = null; - }, []); - - const resetDragState = useCallback(() => { - clearPaneDragMotion(); - clearDraggedSession(); - setPaneDrag(null); - setSessionDropTarget(null); - }, [clearPaneDragMotion]); - - const finishDragState = useCallback(() => { - resetDragState(); - }, [resetDragState]); - useLayoutEffect(() => { const previousRects = pendingTabRectsRef.current; if (!previousRects) return; @@ -529,38 +348,11 @@ export const ChatList = memo(function ChatList({ } }, { once: true }); } - }, [ - collapsedPaneGroups, - measureTabRows, - paneDrag?.slot?.beforePaneKey, - paneDrag?.slot?.tabKey, - ]); - - useLayoutEffect(() => { - const motion = paneDragMotionRef.current; - if (!motion) return; - const slot = paneDrag?.slot - ? Array.from(document.querySelectorAll("[data-pane-snap-slot]")) - .find((element) => element.dataset.paneSnapTab === paneDrag.slot?.tabKey) - : null; - if (slot) { - updatePaneDragSnap(motion, slot); - const reduceMotion = typeof window.matchMedia === "function" - && window.matchMedia("(prefers-reduced-motion: reduce)").matches; - motion.overlay.style.transition = reduceMotion - ? "none" - : "transform 140ms cubic-bezier(0.2, 0, 0, 1), width 140ms cubic-bezier(0.2, 0, 0, 1), height 140ms cubic-bezier(0.2, 0, 0, 1)"; - } else { - updatePaneDragSnap(motion, null); - motion.overlay.style.transition = "none"; - } - positionPaneDragMotion(motion); - }, [paneDrag?.slot?.beforePaneKey, paneDrag?.slot?.tabKey]); + }, [collapsedPaneGroups, measureTabRows]); useEffect(() => () => { - clearPaneDragMotion(); for (const animation of tabLayoutAnimationsRef.current.values()) animation.cancel(); - }, [clearPaneDragMotion]); + }, []); if (loading && sessions.length === 0 && temporarySessions.length === 0) { return ( @@ -583,12 +375,6 @@ export const ChatList = memo(function ChatList({ const compact = density === "compact"; const firstProjectGroupIndex = limitedGroups.findIndex((group) => group.kind === "project"); - const canReorderSession = (targetKey: string) => ( - !deleteSelectionMode - && !!draggedSessionKey - && draggedSessionKey !== targetKey - && sessionLanes.get(draggedSessionKey) === sessionLanes.get(targetKey) - ); const beginDeleteSelection = (keys: string[]) => { setDeleteSelectionMode(true); setSelectedDeleteKeys(new Set(keys.filter((key) => deleteItemsByKey.has(key)))); @@ -623,26 +409,8 @@ export const ChatList = memo(function ChatList({ requestDeleteKeys(Array.from(selectedDeleteKeys)); closeDeleteSelection(); }; - const reorderSession = (targetKey: string, edge: "before" | "after") => { - if (!draggedSessionKey || !canReorderSession(targetKey) || !onReorderSessions) return; - const keys = groups.flatMap((group) => group.sessions.map((session) => session.key)); - const reordered = keys.filter((key) => key !== draggedSessionKey); - const targetIndex = reordered.indexOf(targetKey); - if (targetIndex < 0) return; - reordered.splice(targetIndex + (edge === "after" ? 1 : 0), 0, draggedSessionKey); - const groupedKeys = new Set(keys); - onReorderSessions([ - ...reordered, - ...sessionOrder.filter((key) => !groupedKeys.has(key)), - ]); - }; - return ( -
updatePaneDragMotion(event.clientX, event.clientY)} - onDragOverCapture={(event) => updatePaneDragMotion(event.clientX, event.clientY)} - > +
COLLAPSED_CHATS_VISIBLE_COUNT; - const reorderOffsets = sessionReorderOffsets( - visibleSessions.map((session) => session.key), - draggedSessionKey, - sessionDropTarget, - draggedSessionHeight, - ); - return (
{index === firstProjectGroupIndex ? ( @@ -707,22 +468,17 @@ export const ChatList = memo(function ChatList({ {visibleSessions.map((s) => { const topicActive = s.key === activeKey; const paneGroup = paneGroups[s.key]; - const fallbackTitle = t("chat.fallbackTitle", { - id: s.chatId.slice(0, 6), - }); - const generatedTitle = s.title?.trim() || ""; const title = displayTitle(s, titleOverrides, t("chat.newChat")); const resolvedPaneGroup = paneGroup ?? { - topicKey: s.key, + tabKey: s.key, + title, activePaneKey: s.key, panes: [{ key: s.key, chatId: s.chatId, title }], }; - const paneCount = resolvedPaneGroup.panes.length; - const draggingTab = draggedSessionKey === s.key; - const paneGroupCollapsed = paneCount > 1 + const isWorkbenchTab = paneGroup?.visible + ?? ((paneGroup?.panes.length ?? 0) > 1); + const paneGroupCollapsed = isWorkbenchTab && collapsedPaneGroups.has(s.key); - const paneGroupExpanded = !paneGroupCollapsed; - const active = topicActive && (paneCount === 1 || paneGroupCollapsed); const paneGroupId = `sidebar-pane-group-${s.key.replace( /[^a-zA-Z0-9_-]/g, "-", @@ -734,6 +490,86 @@ export const ChatList = memo(function ChatList({ const tabPartiallySelected = !tabSelected && tabDeleteKeys.some((key) => ( selectedDeleteKeys.has(key) )); + const projectMode = group.kind === "project"; + + if (isWorkbenchTab) { + return ( +
  • { + if (element) tabRowRefs.current.set(s.key, element); + else tabRowRefs.current.delete(s.key); + }} + data-sidebar-tab-group="true" + data-pane-group-collapsed={paneGroupCollapsed ? "true" : undefined} + className="relative my-1.5 min-w-0" + > +
    +
    + togglePaneGroup(s.key)} + onToggleSelection={() => toggleDeleteSelection(tabDeleteKeys)} + onRequestRename={onRequestRenameTab + ? () => onRequestRenameTab(s.key, title) + : undefined} + onDissolve={onDissolveTab + ? () => onDissolveTab(resolvedPaneGroup.tabKey) + : undefined} + onRequestDelete={() => requestDeleteKeys(tabDeleteKeys)} + actionMenuPortalContainer={actionMenuPortalContainer} + /> + {!paneGroupCollapsed ? ( + ( + target.key !== resolvedPaneGroup.tabKey + ))} + onAttachPane={onAttachPane} + deleteSelectionMode={deleteSelectionMode} + selectedDeleteKeys={selectedDeleteKeys} + onToggleDeleteSelection={toggleDeleteSelection} + onBeginDeleteSelection={beginDeleteSelection} + actionMenuPortalContainer={actionMenuPortalContainer} + /> + ) : null} +
    +
    +
  • + ); + } + + const fallbackTitle = t("chat.fallbackTitle", { + id: s.chatId.slice(0, 6), + }); + const generatedTitle = s.title?.trim() || ""; const tooltipTitle = titleOverrides[s.key]?.trim() || generatedTitle || @@ -745,22 +581,11 @@ export const ChatList = memo(function ChatList({ const timestamp = showTimestamps ? relativeTime(s.updatedAt ?? s.createdAt) : ""; - const projectMode = group.kind === "project"; const activityState = running.has(s.chatId) ? "running" : updated.has(s.chatId) && !topicActive ? "updated" : null; - const tabActivityState = resolvedPaneGroup.panes.some((pane) => ( - running.has(pane.chatId) - )) - ? "running" - : resolvedPaneGroup.panes.some((pane) => ( - updated.has(pane.chatId) - && (!topicActive || pane.key !== resolvedPaneGroup.activePaneKey) - )) - ? "updated" - : activityState; return (
  • 1 ? "true" : undefined} - data-pane-group-collapsed={paneGroupCollapsed ? "true" : undefined} - className={cn( - "relative min-w-0 rounded-[0.7rem] transition-transform duration-200 [transition-timing-function:cubic-bezier(0.2,0,0,1)] motion-reduce:transition-none", - paneCount > 1 && "my-1", - )} - style={{ - transform: reorderOffsets.has(s.key) - ? `translateY(${reorderOffsets.get(s.key)}px)` - : undefined, - }} - onDragOver={(event) => { - updatePaneDropSlot(null); - const rect = event.currentTarget - .querySelector(":scope > [data-sidebar-tab]") - ?.getBoundingClientRect() - ?? event.currentTarget.getBoundingClientRect(); - if (!canReorderSession(s.key)) return; - event.preventDefault(); - event.dataTransfer.dropEffect = "move"; - const nextTarget = { - key: s.key, - edge: event.clientY < rect.top + rect.height / 2 ? "before" : "after", - } as const; - setSessionDropTarget((current) => ( - current?.key === nextTarget.key && current.edge === nextTarget.edge - ? current - : nextTarget - )); - }} - onDrop={(event) => { - if (!canReorderSession(s.key)) return; - event.preventDefault(); - const rect = event.currentTarget - .querySelector(":scope > [data-sidebar-tab]") - ?.getBoundingClientRect() - ?? event.currentTarget.getBoundingClientRect(); - const edge = event.clientY < rect.top + rect.height / 2 - ? "before" - : "after"; - reorderSession(s.key, edge); - resetDragState(); - }} + className="relative min-w-0" >
    1 && !active - && "bg-sidebar-foreground/[0.05] dark:bg-white/[0.065]", - active + topicActive ? "bg-sidebar-selected text-sidebar-accent-foreground" : "text-sidebar-foreground/82 hover:bg-sidebar-foreground/[0.075] hover:text-sidebar-foreground dark:hover:bg-white/[0.09]", deleteSelectionMode && (tabSelected || tabPartiallySelected) && "bg-sidebar-accent/55 text-sidebar-accent-foreground", - draggingTab - && "!bg-transparent !text-transparent !shadow-none [&_*]:!text-transparent", )} > - {draggedSessionKey !== s.key - && (paneCount === 1 || paneGroupCollapsed) ? ( - - ) : null} - {!deleteSelectionMode && draggedSessionKey !== s.key ? ( + + {!deleteSelectionMode ? ( 1 ? "opacity-0" : "opacity-40", + "inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-muted-foreground/75 opacity-0 transition-opacity", "hover:bg-sidebar-accent hover:text-sidebar-foreground group-hover:opacity-100", - "focus-visible:opacity-100", - active && "opacity-100", + "focus-visible:opacity-100 data-[state=open]:opacity-100", )} aria-label={t("chat.actions", { title })} > @@ -949,9 +687,7 @@ export const ChatList = memo(function ChatList({ portalContainer={actionMenuPortalContainer} onCloseAutoFocus={(event) => event.preventDefault()} > - onTogglePin(s.key)} - > + onTogglePin(s.key)}> {isPinned ? ( ) : ( @@ -965,9 +701,7 @@ export const ChatList = memo(function ChatList({ {t("chat.rename")} - onToggleArchive(s.key)} - > + onToggleArchive(s.key)}> {isArchived ? ( ) : ( @@ -975,9 +709,19 @@ export const ChatList = memo(function ChatList({ )} {isArchived ? t("chat.unarchive") : t("chat.archive")} - {attachableTabs.has(s.key) && onAttachPane ? ( - target.key !== s.key)} + {paneGroup && onCreateTab ? ( + onCreateTab(paneGroup.tabKey)}> + + {t("workbench.createGroup", { + defaultValue: "Create group", + })} + + ) : null} + {paneGroup && onAttachPane ? ( + ( + target.key !== paneGroup.tabKey + ))} onMove={(targetKey) => onAttachPane(s.key, targetKey)} /> ) : null} @@ -999,85 +743,7 @@ export const ChatList = memo(function ChatList({ ) : null} - {!deleteSelectionMode - && draggedSessionKey !== s.key - && paneCount > 1 ? ( - - ) : null}
    - {paneCount > 1 && paneGroupExpanded ? ( - ( - target.key !== resolvedPaneGroup.topicKey - ))} - onAttachPane={onAttachPane} - deleteSelectionMode={deleteSelectionMode} - selectedDeleteKeys={selectedDeleteKeys} - onToggleDeleteSelection={toggleDeleteSelection} - onBeginDeleteSelection={beginDeleteSelection} - paneDrag={paneDrag} - onPaneDropSlotChange={(slot) => { - updatePaneDropSlot(slot); - }} - onPaneDragStart={(event, pane) => { - beginPaneDragMotion(event); - setSessionDropTarget(null); - const measuredHeight = event.currentTarget.closest("li") - ?.getBoundingClientRect().height - ?? event.currentTarget.getBoundingClientRect().height; - const height = measuredHeight > 0 - ? measuredHeight - : DEFAULT_PANE_ROW_HEIGHT; - setPaneDrag({ origin: "pane", item: pane, height, slot: null }); - writeDraggedPane(event.dataTransfer, pane); - }} - onPaneDragEnd={finishDragState} - actionMenuPortalContainer={actionMenuPortalContainer} - /> - ) : null}
  • ); })} @@ -1145,33 +811,135 @@ export const ChatList = memo(function ChatList({ ); }); -function sessionReorderOffsets( - keys: string[], - draggedKey: string | null, - target: { edge: "before" | "after"; key: string } | null, - draggedHeight: number, -): Map { - const offsets = new Map(); - if (!draggedKey || !target || draggedHeight <= 0) return offsets; - const sourceIndex = keys.indexOf(draggedKey); - if (sourceIndex < 0 || target.key === draggedKey) return offsets; - const remaining = keys.filter((key) => key !== draggedKey); - const targetIndex = remaining.indexOf(target.key); - if (targetIndex < 0) return offsets; - const finalIndex = targetIndex + (target.edge === "after" ? 1 : 0); +function WorkbenchTabHeader({ + title, + controlsId, + collapsed, + deleteSelectionMode, + selected, + partiallySelected, + onToggle, + onToggleSelection, + onRequestRename, + onDissolve, + onRequestDelete, + actionMenuPortalContainer, +}: { + title: string; + controlsId: string; + collapsed: boolean; + deleteSelectionMode: boolean; + selected: boolean; + partiallySelected: boolean; + onToggle: () => void; + onToggleSelection: () => void; + onRequestRename?: () => void; + onDissolve?: () => void; + onRequestDelete: () => void; + actionMenuPortalContainer?: HTMLElement | null; +}) { + const { t } = useTranslation(); + const disclosureLabel = t( + collapsed ? "workbench.expandTabGroup" : "workbench.collapseTabGroup", + { title }, + ); - if (sourceIndex < finalIndex) { - offsets.set(draggedKey, (finalIndex - sourceIndex) * draggedHeight); - for (let index = sourceIndex + 1; index <= finalIndex; index += 1) { - offsets.set(keys[index], -draggedHeight); - } - } else if (sourceIndex > finalIndex) { - offsets.set(draggedKey, (finalIndex - sourceIndex) * draggedHeight); - for (let index = finalIndex; index < sourceIndex; index += 1) { - offsets.set(keys[index], draggedHeight); - } - } - return offsets; + return ( +
    + + {!deleteSelectionMode ? ( + <> + + + + + event.preventDefault()} + > + {onRequestRename ? ( + + + {t("chat.rename")} + + ) : null} + {onDissolve ? ( + + + {t("workbench.dissolveTab", { defaultValue: "Dissolve group" })} + + ) : null} + window.setTimeout(onRequestDelete, 0)} + > + + {t("chat.delete")} + + + + + + ) : null} +
    + ); } function ActivePaneRows({ @@ -1179,78 +947,53 @@ function ActivePaneRows({ group, tabTitle, tabActive, + compact, running, updated, onSelectPane, onRequestDelete, onRequestRename, + onTogglePin, + onToggleArchive, + pinned, + archived, onDetachPane, - onPromotePane, moveTargets, onAttachPane, deleteSelectionMode, selectedDeleteKeys, onToggleDeleteSelection, onBeginDeleteSelection, - paneDrag, - onPaneDropSlotChange, - onPaneDragStart, - onPaneDragEnd, actionMenuPortalContainer, }: { id: string; group: SidebarPaneGroup; tabTitle: string; tabActive: boolean; + compact: boolean; running: ReadonlySet; updated: ReadonlySet; onSelectPane?: (tabKey: string, paneKey: string) => void; onRequestDelete: (key: string, label: string) => void; onRequestRename: (key: string, label: string) => void; + onTogglePin: (key: string) => void; + onToggleArchive: (key: string) => void; + pinned: ReadonlySet; + archived: ReadonlySet; onDetachPane?: (tabKey: string, paneKey: string) => void; - onPromotePane?: (tabKey: string, paneKey: string) => void; - moveTargets: Array<{ key: string; title: string }>; + moveTargets: PaneGroupTarget[]; onAttachPane?: ( paneKey: string, tabKey: string, - beforePaneKey?: string | null, ) => void; deleteSelectionMode: boolean; selectedDeleteKeys: ReadonlySet; onToggleDeleteSelection: (keys: string[]) => void; onBeginDeleteSelection: (keys: string[]) => void; - paneDrag: PaneTabDragState | null; - onPaneDropSlotChange: (slot: PaneDropSlot) => void; - onPaneDragStart: (event: DragEvent, pane: DraggedPane) => void; - onPaneDragEnd: () => void; actionMenuPortalContainer?: HTMLElement | null; }) { const { t } = useTranslation(); const panes = group.panes; - const paneKeys = panes.map((pane) => pane.key); - const draggedPane = paneDrag?.item ?? null; - const ownsDraggedPane = paneDrag?.origin === "pane" - && draggedPane?.sourceTabKey === group.topicKey; - const activeDropSlot = ownsDraggedPane && paneDrag?.slot?.tabKey === group.topicKey - ? paneDrag.slot - : null; - const dragLayout = paneTabDragLayout( - paneKeys, - group.topicKey, - paneDrag, - ); - const commitPaneDrop = () => { - if (!draggedPane || !activeDropSlot || !onAttachPane) { - return; - } - onAttachPane( - draggedPane.paneKey, - group.topicKey, - activeDropSlot.beforePaneKey, - ); - onPaneDragEnd(); - }; - return (
      { - if (!activeDropSlot) return; - event.preventDefault(); - event.dataTransfer.dropEffect = "move"; - }} - onDropCapture={(event) => { - if (!draggedPane || !activeDropSlot || !onAttachPane) return; - event.preventDefault(); - event.stopPropagation(); - commitPaneDrop(); - }} + className="mt-0.5 space-y-0.5" > - {activeDropSlot && paneDrag && dragLayout.slotIndex >= 0 ? ( -
    • { - if (!draggedPane || !ownsDraggedPane || draggedPane.paneKey === pane.key) { - return; - } - event.preventDefault(); - event.stopPropagation(); - event.dataTransfer.dropEffect = "move"; - const rect = event.currentTarget.getBoundingClientRect(); - onPaneDropSlotChange(paneDropSlotForRow( - group.topicKey, - paneKeys, - draggedPane.paneKey, - pane.key, - event.clientY < rect.top + rect.height / 2 ? "before" : "after", - )); - }} + className="relative min-w-0" >
      - {!dragging ? : null} - {!deleteSelectionMode && !dragging ? + + {!deleteSelectionMode ? @@ -1403,32 +1080,39 @@ function ActivePaneRows({ portalContainer={actionMenuPortalContainer} onCloseAutoFocus={(event) => event.preventDefault()} > - {index > 0 && onPromotePane ? ( - onPromotePane(group.topicKey, pane.key)}> - - {t("workbench.promotePane", { - defaultValue: "Make {{title}} the primary pane", - title: pane.title, - })} - - ) : null} + onTogglePin(pane.key)}> + {isPinned ? ( + + ) : ( + + )} + {isPinned ? t("chat.unpin") : t("chat.pin")} + onRequestRename(pane.key, pane.title)} > {t("chat.rename")} - {pane.key !== group.topicKey && onDetachPane ? ( - onDetachPane(group.topicKey, pane.key)}> + onToggleArchive(pane.key)}> + {isArchived ? ( + + ) : ( + + )} + {isArchived ? t("chat.unarchive") : t("chat.archive")} + + {onDetachPane ? ( + onDetachPane(group.tabKey, pane.key)}> {t("workbench.detachPane", { - defaultValue: "Move {{title}} to a new tab", + defaultValue: "Remove", title: pane.title, })} ) : null} - {pane.key !== group.topicKey && onAttachPane ? ( - onAttachPane(pane.key, targetKey)} /> @@ -1477,11 +1161,11 @@ function SelectionIndicator({ ); } -function MoveToTabSubmenu({ +function MoveToGroupSubmenu({ targets, onMove, }: { - targets: Array<{ key: string; title: string }>; + targets: PaneGroupTarget[]; onMove: (targetKey: string) => void; }) { const { t } = useTranslation(); @@ -1489,13 +1173,20 @@ function MoveToTabSubmenu({ return ( - - {t("workbench.moveToTab", { defaultValue: "Move to tab" })} + + {t("workbench.moveTo", { defaultValue: "Move to" })} {targets.map((target) => ( - onMove(target.key)}> - {target.title} + onMove(target.key)} + > + {target.title} + + · {target.paneCount}/{MAX_WORKBENCH_PANES} + ))} @@ -1616,8 +1307,9 @@ function ProjectGroupHeader({ event.stopPropagation()} @@ -1647,7 +1339,7 @@ function ProjectGroupHeader({ onNewChat(); }} className={cn( - "inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-muted-foreground/70 opacity-40 transition-opacity", + "inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-muted-foreground/70 opacity-0 transition-opacity", "hover:bg-sidebar-accent hover:text-sidebar-foreground group-hover:opacity-100 focus-visible:opacity-100", )} > diff --git a/webui/src/components/Sidebar.tsx b/webui/src/components/Sidebar.tsx index 52cf58f45..a300d3e21 100644 --- a/webui/src/components/Sidebar.tsx +++ b/webui/src/components/Sidebar.tsx @@ -46,19 +46,17 @@ interface SidebarProps { onRequestDeleteMany?: (items: SidebarDeleteItem[]) => void; onTogglePin: (key: string) => void; onRequestRename: (key: string, label: string) => void; + onRequestRenameTab?: (key: string, label: string) => void; onToggleArchive: (key: string) => void; paneGroups?: Record; onSelectPane?: (tabKey: string, paneKey: string) => void; + onCreateTab?: (tabKey: string) => void; onDetachPane?: (tabKey: string, paneKey: string) => void; - onPromotePane?: (tabKey: string, paneKey: string) => void; - attachableTabKeys?: string[]; - paneAcceptingTabKeys?: string[]; + onDissolveTab?: (tabKey: string) => void; onAttachPane?: ( paneKey: string, tabKey: string, - beforePaneKey?: string | null, ) => void; - onReorderSessions: (keys: string[]) => void; onToggleGroup: (groupId: string) => void; onRequestRenameProject: (projectKey: string, label: string) => void; onNewChatInProject: (projectPath: string, projectName: string) => void; @@ -76,6 +74,8 @@ interface SidebarProps { collapsed?: boolean; pinnedKeys?: string[]; archivedKeys?: string[]; + pinnedPaneKeys?: string[]; + archivedPaneKeys?: string[]; sessionOrder?: string[]; titleOverrides?: Record; projectNameOverrides?: Record; @@ -249,20 +249,21 @@ export function Sidebar(props: SidebarProps) { onRequestDeleteMany={props.onRequestDeleteMany} onTogglePin={props.onTogglePin} onRequestRename={props.onRequestRename} + onRequestRenameTab={props.onRequestRenameTab} onToggleArchive={props.onToggleArchive} paneGroups={props.paneGroups} onSelectPane={props.onSelectPane} + onCreateTab={props.onCreateTab} onDetachPane={props.onDetachPane} - onPromotePane={props.onPromotePane} - attachableTabKeys={props.attachableTabKeys} - paneAcceptingTabKeys={props.paneAcceptingTabKeys} + onDissolveTab={props.onDissolveTab} onAttachPane={props.onAttachPane} - onReorderSessions={props.onReorderSessions} onToggleGroup={props.onToggleGroup} onRequestRenameProject={props.onRequestRenameProject} onNewChatInProject={props.onNewChatInProject} pinnedKeys={props.pinnedKeys} archivedKeys={props.archivedKeys} + pinnedPaneKeys={props.pinnedPaneKeys} + archivedPaneKeys={props.archivedPaneKeys} sessionOrder={props.sessionOrder} titleOverrides={props.titleOverrides} projectNameOverrides={props.projectNameOverrides} diff --git a/webui/src/components/pane-tab-drag.ts b/webui/src/components/pane-tab-drag.ts deleted file mode 100644 index 75107cacc..000000000 --- a/webui/src/components/pane-tab-drag.ts +++ /dev/null @@ -1,81 +0,0 @@ -import type { DraggedPane } from "@/lib/session-drag"; - -export interface PaneDropSlot { - beforePaneKey: string | null; - tabKey: string; -} - -export interface PaneTabDragState { - height: number; - item: DraggedPane; - origin: "pane" | "tab"; - slot: PaneDropSlot | null; -} - -export interface PaneTabDragLayout { - offsets: Map; - slotIndex: number; -} - -export function samePaneDropSlot( - current: PaneDropSlot | null, - next: PaneDropSlot | null, -): boolean { - return current?.tabKey === next?.tabKey - && current?.beforePaneKey === next?.beforePaneKey; -} - -export function paneDropSlotForRow( - tabKey: string, - paneKeys: string[], - draggedPaneKey: string, - targetPaneKey: string, - edge: "before" | "after", -): PaneDropSlot { - const remaining = paneKeys.filter((key) => key !== draggedPaneKey); - const targetIndex = remaining.indexOf(targetPaneKey); - const insertionIndex = targetIndex < 0 - ? remaining.length - : targetIndex + (edge === "after" ? 1 : 0); - return { - tabKey, - beforePaneKey: remaining[insertionIndex] ?? null, - }; -} - -export function paneTabDragLayout( - paneKeys: string[], - tabKey: string, - drag: PaneTabDragState | null, -): PaneTabDragLayout { - const offsets = new Map(); - if (!drag || drag.height <= 0) { - return { offsets, slotIndex: -1 }; - } - const distance = drag.height + 2; - const sourceIndex = paneKeys.indexOf(drag.item.paneKey); - if ( - sourceIndex < 0 - || drag.item.sourceTabKey !== tabKey - || drag.slot?.tabKey !== tabKey - ) { - return { offsets, slotIndex: -1 }; - } - - const remaining = paneKeys.filter((key) => key !== drag.item.paneKey); - const requestedIndex = drag.slot.beforePaneKey - ? remaining.indexOf(drag.slot.beforePaneKey) - : remaining.length; - const slotIndex = requestedIndex < 0 ? remaining.length : requestedIndex; - - if (sourceIndex < slotIndex) { - for (let index = sourceIndex + 1; index <= slotIndex; index += 1) { - offsets.set(paneKeys[index], -distance); - } - } else if (sourceIndex > slotIndex) { - for (let index = slotIndex; index < sourceIndex; index += 1) { - offsets.set(paneKeys[index], distance); - } - } - return { offsets, slotIndex }; -} diff --git a/webui/src/components/thread/ThreadShell.tsx b/webui/src/components/thread/ThreadShell.tsx index 164e73387..edd97a5f6 100644 --- a/webui/src/components/thread/ThreadShell.tsx +++ b/webui/src/components/thread/ThreadShell.tsx @@ -323,6 +323,7 @@ interface ThreadShellProps { composerPortalTarget?: HTMLElement | null; composerActive?: boolean; composerInputAriaLabel?: string; + emptyComposerVariant?: "hero" | "thread"; workspaceScope?: WorkspaceScopePayload | null; workspaceDefaultScope?: WorkspaceScopePayload | null; workspaceControls?: WorkspacesPayload["controls"] | null; @@ -618,6 +619,7 @@ export function ThreadShell({ composerPortalTarget, composerActive = true, composerInputAriaLabel, + emptyComposerVariant = "hero", workspaceScope = null, workspaceDefaultScope = null, workspaceControls = null, @@ -860,6 +862,7 @@ export function ThreadShell({ ]); const showHeroComposer = displayMessages.length === 0 && !loading; + const composerVariant = showHeroComposer ? emptyComposerVariant : "thread"; const wasShowingHeroComposerRef = useRef(showHeroComposer); const sessionModelPreset = session?.modelPreset?.trim() || null; const [localModelPreset, setLocalModelPreset] = useState(null); @@ -1425,7 +1428,7 @@ export function ThreadShell({ inputAriaLabel={composerInputAriaLabel} isStreaming={turnActive} placeholder={ - showHeroComposer + composerVariant === "hero" ? t("thread.composer.placeholderHero") : t("thread.composer.placeholderThread") } @@ -1439,7 +1442,7 @@ export function ThreadShell({ modelNeedsSetup={modelBadge.needsSetup} fallbackModelName={fallbackModelName} onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined} - variant={showHeroComposer ? "hero" : "thread"} + variant={composerVariant} slashCommands={availableSlashCommands} cliApps={cliApps} mcpPresets={mcpPresets} diff --git a/webui/src/components/workbench/PaneWorkbench.tsx b/webui/src/components/workbench/PaneWorkbench.tsx index d47734c44..b98f8bb01 100644 --- a/webui/src/components/workbench/PaneWorkbench.tsx +++ b/webui/src/components/workbench/PaneWorkbench.tsx @@ -2,15 +2,16 @@ import { Columns2, Grid2X2, PanelLeft, + PanelsTopLeft, Plus, Rows2, - Square, type LucideIcon, } from "lucide-react"; import { type CSSProperties, type FocusEvent, - type PointerEvent, + type KeyboardEvent, + type PointerEvent as ReactPointerEvent, type ReactNode, useCallback, useEffect, @@ -59,10 +60,12 @@ interface PaneWorkbenchProps { activePaneKey: string; layout: WorkbenchLayout; chrome?: boolean; + showLayoutControl: boolean; addPaneDisabled?: boolean; onActivatePane: (key: string) => void; onAddPane: () => void; onLayoutChange: (layout: WorkbenchLayout) => void; + onPaneOrderChange: (paneKeys: string[]) => void; renderPane: (pane: WorkbenchPane, context: PaneRenderContext) => ReactNode; } @@ -77,11 +80,13 @@ const LAYOUT_CONTROLS: Array<{ { icon: Columns2, layout: "columns", label: "Columns" }, { icon: Rows2, layout: "rows", label: "Rows" }, { icon: Grid2X2, layout: "grid", label: "Grid" }, + { icon: PanelsTopLeft, layout: "bsp", label: "BSP" }, { icon: PanelLeft, layout: "main-stack", label: "Main and stack" }, - { icon: Square, layout: "monocle", label: "Monocle" }, ]; -function paneGridStyle(layout: WorkbenchLayout, paneCount: number): CSSProperties { +type EffectiveWorkbenchLayout = WorkbenchLayout | "compact"; + +function paneGridStyle(layout: EffectiveWorkbenchLayout, paneCount: number): CSSProperties { const count = Math.max(1, paneCount); switch (layout) { case "columns": @@ -102,6 +107,11 @@ function paneGridStyle(layout: WorkbenchLayout, paneCount: number): CSSPropertie gridTemplateRows: `repeat(${rows}, minmax(0, 1fr))`, }; } + case "bsp": + return { + gridTemplateColumns: "repeat(4, minmax(0, 1fr))", + gridTemplateRows: "repeat(4, minmax(0, 1fr))", + }; case "main-stack": return count === 1 ? { @@ -112,7 +122,7 @@ function paneGridStyle(layout: WorkbenchLayout, paneCount: number): CSSPropertie gridTemplateColumns: "minmax(0, 1.65fr) minmax(0, 1fr)", gridTemplateRows: `repeat(${count - 1}, minmax(0, 1fr))`, }; - case "monocle": + case "compact": return { gridTemplateColumns: "minmax(0, 1fr)", gridTemplateRows: "minmax(0, 1fr)", @@ -120,15 +130,60 @@ function paneGridStyle(layout: WorkbenchLayout, paneCount: number): CSSPropertie } } +interface BspCell { + columnStart: number; + columnEnd: number; + rowStart: number; + rowEnd: number; +} + +function bspPaneCells(paneCount: number): BspCell[] { + const cells: BspCell[] = [{ + columnStart: 1, + columnEnd: 5, + rowStart: 1, + rowEnd: 5, + }]; + for (let paneIndex = 1; paneIndex < paneCount; paneIndex += 1) { + const leaf = cells.pop(); + if (!leaf) break; + if (paneIndex % 2 === 1) { + const midpoint = (leaf.columnStart + leaf.columnEnd) / 2; + cells.push( + { ...leaf, columnEnd: midpoint }, + { ...leaf, columnStart: midpoint }, + ); + } else { + const midpoint = (leaf.rowStart + leaf.rowEnd) / 2; + cells.push( + { ...leaf, rowEnd: midpoint }, + { ...leaf, rowStart: midpoint }, + ); + } + } + return cells; +} + function paneCellStyle( - layout: WorkbenchLayout, + layout: EffectiveWorkbenchLayout, paneCount: number, index: number, ): CSSProperties | undefined { - if (layout !== "main-stack" || paneCount < 2) return undefined; - return index === 0 - ? { gridColumn: 1, gridRow: `1 / span ${paneCount - 1}` } - : { gridColumn: 2, gridRow: index }; + if (layout === "bsp") { + const cell = bspPaneCells(paneCount)[index]; + return cell + ? { + gridColumn: `${cell.columnStart} / ${cell.columnEnd}`, + gridRow: `${cell.rowStart} / ${cell.rowEnd}`, + } + : undefined; + } + if (layout === "main-stack" && paneCount >= 2) { + return index === 0 + ? { gridColumn: 1, gridRow: `1 / span ${paneCount - 1}` } + : { gridColumn: 2, gridRow: index }; + } + return undefined; } function isPaneAction(target: EventTarget | null): boolean { @@ -136,6 +191,66 @@ function isPaneAction(target: EventTarget | null): boolean { && target.closest("[data-workbench-pane-action]") !== null; } +function movePaneToSlot( + paneKeys: readonly string[], + paneKey: string, + targetKey: string, +): string[] { + const fromIndex = paneKeys.indexOf(paneKey); + const targetIndex = paneKeys.indexOf(targetKey); + if (fromIndex < 0 || targetIndex < 0 || fromIndex === targetIndex) return [...paneKeys]; + const next = paneKeys.filter((key) => key !== paneKey); + next.splice(targetIndex, 0, paneKey); + return next; +} + +function paneSlotAtPoint( + rects: readonly DOMRect[], + x: number, + y: number, +): number | null { + for (const [index, rect] of rects.entries()) { + if ( + x >= rect.left + && x <= rect.right + && y >= rect.top + && y <= rect.bottom + ) return index; + } + return null; +} + +function paneInDirection( + rects: ReadonlyMap, + paneKey: string, + direction: "left" | "right" | "up" | "down", +): string | null { + const source = rects.get(paneKey); + if (!source) return null; + const sourceX = source.left + source.width / 2; + const sourceY = source.top + source.height / 2; + let best: { key: string; score: number } | null = null; + for (const [key, rect] of rects) { + if (key === paneKey) continue; + const deltaX = rect.left + rect.width / 2 - sourceX; + const deltaY = rect.top + rect.height / 2 - sourceY; + const primary = direction === "left" + ? -deltaX + : direction === "right" + ? deltaX + : direction === "up" + ? -deltaY + : deltaY; + if (primary <= 1) continue; + const cross = direction === "left" || direction === "right" + ? Math.abs(deltaY) + : Math.abs(deltaX); + const score = primary + cross * 0.35; + if (!best || score < best.score) best = { key, score }; + } + return best?.key ?? null; +} + function HeaderIconButton({ disabled, icon: Icon, @@ -172,22 +287,55 @@ export function PaneWorkbench({ activePaneKey, layout, chrome = true, + showLayoutControl, addPaneDisabled = false, onActivatePane, onAddPane, onLayoutChange, + onPaneOrderChange, renderPane, }: PaneWorkbenchProps) { const { t } = useTranslation(); const compact = useMediaQuery("(max-width: 767px)"); - const effectiveLayout = compact ? "monocle" : layout; + const effectiveLayout: EffectiveWorkbenchLayout = compact ? "compact" : layout; const [headerPortalTarget, setHeaderPortalTarget] = useState(null); const [composerPortalTarget, setComposerPortalTarget] = useState(null); const paneRefs = useRef(new Map()); const lastRectsRef = useRef(new Map()); const pendingRectsRef = useRef | null>(null); const animationsRef = useRef(new Map()); - const paneOrder = useMemo(() => panes.map((pane) => pane.key).join("\u0000"), [panes]); + const sourcePaneOrder = useMemo( + () => panes.map((pane) => pane.key), + [panes], + ); + const sourcePaneOrderKey = sourcePaneOrder.join("\u0000"); + const [previewPaneKeys, setPreviewPaneKeys] = useState(sourcePaneOrder); + const previewPaneKeysRef = useRef(sourcePaneOrder); + const dragGestureRef = useRef<{ + pointerId: number; + paneKey: string; + startX: number; + startY: number; + slotRects: DOMRect[]; + started: boolean; + } | null>(null); + const [draggingPaneKey, setDraggingPaneKey] = useState(null); + const displayedPanes = useMemo(() => { + const byKey = new Map(panes.map((pane) => [pane.key, pane])); + return [ + ...previewPaneKeys.map((key) => byKey.get(key)).filter( + (pane): pane is WorkbenchPane => pane !== undefined, + ), + ...panes.filter((pane) => !previewPaneKeys.includes(pane.key)), + ]; + }, [panes, previewPaneKeys]); + const paneOrder = displayedPanes.map((pane) => pane.key).join("\u0000"); + + useEffect(() => { + if (dragGestureRef.current) return; + previewPaneKeysRef.current = sourcePaneOrder; + setPreviewPaneKeys(sourcePaneOrder); + }, [sourcePaneOrder, sourcePaneOrderKey]); const measurePanes = useCallback(() => { const rects = new Map(); @@ -283,7 +431,7 @@ export function PaneWorkbench({ const handlePanePointerDown = useCallback(( key: string, - event: PointerEvent, + event: ReactPointerEvent, ) => { activatePane(key, event.target); }, [activatePane]); @@ -292,6 +440,114 @@ export function PaneWorkbench({ activatePane(key, event.target); }, [activatePane]); + const applyPaneOrder = useCallback((paneKey: string, targetKey: string) => { + const next = movePaneToSlot(previewPaneKeysRef.current, paneKey, targetKey); + if (next.every((key, index) => previewPaneKeysRef.current[index] === key)) return; + captureLayout(); + previewPaneKeysRef.current = next; + setPreviewPaneKeys(next); + onPaneOrderChange(next); + }, [captureLayout, onPaneOrderChange]); + + const handleMovePointerDown = useCallback(( + paneKey: string, + event: ReactPointerEvent, + ) => { + if (event.button !== 0 || compact || panes.length < 2) return; + const paneRects = measurePanes(); + dragGestureRef.current = { + pointerId: event.pointerId, + paneKey, + startX: event.clientX, + startY: event.clientY, + slotRects: previewPaneKeysRef.current + .map((key) => paneRects.get(key)) + .filter((rect): rect is DOMRect => rect !== undefined), + started: false, + }; + setDraggingPaneKey(paneKey); + }, [compact, measurePanes, panes.length]); + + const handleMovePointerMove = useCallback((event: globalThis.PointerEvent) => { + const gesture = dragGestureRef.current; + if (!gesture || gesture.pointerId !== event.pointerId) return; + const distance = Math.hypot( + event.clientX - gesture.startX, + event.clientY - gesture.startY, + ); + if (!gesture.started) { + if (distance < 8) return; + gesture.started = true; + } + event.preventDefault(); + const targetIndex = paneSlotAtPoint(gesture.slotRects, event.clientX, event.clientY); + const targetKey = targetIndex === null + ? null + : previewPaneKeysRef.current[targetIndex] ?? null; + if (targetKey) applyPaneOrder(gesture.paneKey, targetKey); + }, [applyPaneOrder]); + + const finishMoveGesture = useCallback(() => { + if (!dragGestureRef.current) return; + dragGestureRef.current = null; + setDraggingPaneKey(null); + }, []); + + useEffect(() => { + if (!draggingPaneKey) return; + + const handlePointerMove = (event: globalThis.PointerEvent) => { + const gesture = dragGestureRef.current; + if (!gesture || gesture.pointerId !== event.pointerId) return; + if ((event.buttons & 1) === 0) { + finishMoveGesture(); + return; + } + handleMovePointerMove(event); + }; + const handlePointerEnd = (event: globalThis.PointerEvent) => { + if (dragGestureRef.current?.pointerId === event.pointerId) finishMoveGesture(); + }; + const root = document.documentElement; + const previousCursor = root.style.cursor; + root.style.cursor = "grabbing"; + window.addEventListener("pointermove", handlePointerMove, { passive: false }); + window.addEventListener("pointerup", handlePointerEnd); + window.addEventListener("pointercancel", handlePointerEnd); + window.addEventListener("blur", finishMoveGesture); + return () => { + root.style.cursor = previousCursor; + window.removeEventListener("pointermove", handlePointerMove); + window.removeEventListener("pointerup", handlePointerEnd); + window.removeEventListener("pointercancel", handlePointerEnd); + window.removeEventListener("blur", finishMoveGesture); + }; + }, [draggingPaneKey, finishMoveGesture, handleMovePointerMove]); + + const handleMoveKeyDown = useCallback(( + paneKey: string, + event: KeyboardEvent, + ) => { + const direction = (() => { + switch (event.key) { + case "ArrowLeft": return "left"; + case "ArrowRight": return "right"; + case "ArrowUp": return "up"; + case "ArrowDown": return "down"; + default: return null; + } + })(); + if (!direction) return; + const targetKey = paneInDirection(measurePanes(), paneKey, direction); + if (!targetKey) return; + event.preventDefault(); + const next = movePaneToSlot(previewPaneKeysRef.current, paneKey, targetKey); + captureLayout(); + previewPaneKeysRef.current = next; + setPreviewPaneKeys(next); + onPaneOrderChange(next); + }, [captureLayout, measurePanes, onPaneOrderChange]); + const gridStyle = paneGridStyle(effectiveLayout, panes.length); const currentLayout = LAYOUT_CONTROLS.find((control) => control.layout === layout) ?? LAYOUT_CONTROLS[0]; @@ -300,51 +556,53 @@ export function PaneWorkbench({ data-workbench-pane-action className="host-no-drag flex items-center gap-0.5" > - - - + + event.preventDefault()} > - - - - event.preventDefault()} - > - - {t("workbench.layout", { defaultValue: "Pane layout" })} - - - { - const next = value as WorkbenchLayout; - if (next === layout) return; - captureLayout(); - onLayoutChange(next); - }} - > - {LAYOUT_CONTROLS.map((control) => ( - - - {t(`workbench.layouts.${control.layout}`, { - defaultValue: control.label, - })} - - ))} - - - + + {t("workbench.layout", { defaultValue: "Pane layout" })} + + + { + const next = value as WorkbenchLayout; + if (next === layout) return; + captureLayout(); + onLayoutChange(next); + }} + > + {LAYOUT_CONTROLS.map((control) => ( + + + {t(`workbench.layouts.${control.layout}`, { + defaultValue: control.label, + })} + + ))} + + + + ) : null} - {panes.map((pane, index) => { + {displayedPanes.map((pane, index) => { const active = pane.key === activePaneKey; - const hidden = effectiveLayout === "monocle" && !active; + const hidden = effectiveLayout === "compact" && !active; return ( ); })} diff --git a/webui/src/components/workbench/workbench-model.ts b/webui/src/components/workbench/workbench-model.ts index 5d959d09e..ba456ee4b 100644 --- a/webui/src/components/workbench/workbench-model.ts +++ b/webui/src/components/workbench/workbench-model.ts @@ -1,29 +1,37 @@ -export const WORKBENCH_STORAGE_KEY = "nanobot.webui.workbench.v2"; +import type { + WorkbenchLayout, + WorkbenchState, + WorkbenchTabState, +} from "@/lib/types"; + +export type { + WorkbenchLayout, + WorkbenchState, + WorkbenchTabState, +} from "@/lib/types"; + export const MAX_WORKBENCH_PANES = 4; export const WORKBENCH_LAYOUTS = [ "columns", "rows", "grid", + "bsp", "main-stack", - "monocle", ] as const; -export type WorkbenchLayout = (typeof WORKBENCH_LAYOUTS)[number]; - -export interface WorkbenchTabState { - paneKeys: string[]; - activePaneKey: string; - layout: WorkbenchLayout; +export interface WorkbenchTabMatch { + tabKey: string; + tab: WorkbenchTabState; } -export interface WorkbenchState { - version: 2; - tabs: Record; +export interface OrderedWorkbenchTab extends WorkbenchTabMatch { + paneKeys: string[]; + updatedAt: string | null; } export const EMPTY_WORKBENCH_STATE: WorkbenchState = { - version: 2, + version: 1, tabs: {}, }; @@ -39,72 +47,118 @@ function uniqueKeys(value: unknown): string[] { )); } -function insertPaneBefore( - paneKeys: string[], - paneKey: string, - beforePaneKey?: string | null, -): string[] { - const next = paneKeys.filter((key) => key !== paneKey); - const requestedIndex = beforePaneKey && beforePaneKey !== paneKey - ? next.indexOf(beforePaneKey) - : -1; - next.splice(requestedIndex < 0 ? next.length : requestedIndex, 0, paneKey); - return next; +function normalizeTitle(value: unknown): string | null { + if (typeof value !== "string") return null; + const title = value.trim(); + return title || null; } -function normalizeTab(value: unknown, tabKey: string): WorkbenchTabState { +function normalizeTab(value: unknown): WorkbenchTabState { const candidate = value && typeof value === "object" ? value as Partial : {}; - const paneKeys = uniqueKeys(candidate.paneKeys); - const normalizedPaneKeys = (paneKeys.includes(tabKey) - ? paneKeys - : [tabKey, ...paneKeys]).slice(0, MAX_WORKBENCH_PANES); + const paneKeys = uniqueKeys(candidate.paneKeys).slice(0, MAX_WORKBENCH_PANES); + const requestedLayoutPaneKeys = uniqueKeys(candidate.layoutPaneKeys) + .filter((key) => paneKeys.includes(key)); + const layoutPaneKeys = [ + ...requestedLayoutPaneKeys, + ...paneKeys.filter((key) => !requestedLayoutPaneKeys.includes(key)), + ]; return { - paneKeys: normalizedPaneKeys, + explicit: candidate.explicit === true, + title: normalizeTitle(candidate.title), + paneKeys, + layoutPaneKeys, activePaneKey: typeof candidate.activePaneKey === "string" - && normalizedPaneKeys.includes(candidate.activePaneKey) + && paneKeys.includes(candidate.activePaneKey) ? candidate.activePaneKey - : normalizedPaneKeys[0], + : paneKeys[0] ?? "", layout: isLayout(candidate.layout) ? candidate.layout : "columns", }; } -export function parseWorkbenchState(serialized: string | null): WorkbenchState { - if (!serialized) return EMPTY_WORKBENCH_STATE; - try { - const parsed = JSON.parse(serialized) as { version?: unknown; tabs?: unknown }; - if ( - parsed.version !== 2 - || !parsed.tabs - || typeof parsed.tabs !== "object" - || Array.isArray(parsed.tabs) - ) { - return EMPTY_WORKBENCH_STATE; - } - const tabs = Object.fromEntries( - Object.entries(parsed.tabs).map(([tabKey, tab]) => [tabKey, normalizeTab(tab, tabKey)]), - ); - return { version: 2, tabs }; - } catch { - return EMPTY_WORKBENCH_STATE; - } +function standaloneTabKeyBase(paneKey: string): string { + return `tab:${paneKey}`; } -export function defaultWorkbenchTab(tabKey: string): WorkbenchTabState { +function availableStandaloneTabKey( + tabs: Readonly>, + paneKey: string, +): string { + const base = standaloneTabKeyBase(paneKey); + if (!tabs[base]) return base; + let suffix = 2; + while (tabs[`${base}:${suffix}`]) suffix += 1; + return `${base}:${suffix}`; +} + +function defaultWorkbenchTab( + paneKey: string, + title: string | null = null, +): WorkbenchTabState { return { - paneKeys: [tabKey], - activePaneKey: tabKey, + explicit: false, + title: normalizeTitle(title), + paneKeys: [paneKey], + layoutPaneKeys: [paneKey], + activePaneKey: paneKey, layout: "columns", }; } +export function normalizeWorkbenchState(raw: unknown): WorkbenchState { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + return EMPTY_WORKBENCH_STATE; + } + const parsed = raw as { version?: unknown; tabs?: unknown }; + if (parsed.version !== 1 || !parsed.tabs + || typeof parsed.tabs !== "object" || Array.isArray(parsed.tabs)) { + return EMPTY_WORKBENCH_STATE; + } + return { + version: 1, + tabs: Object.fromEntries( + Object.entries(parsed.tabs).map(([tabKey, tab]) => [tabKey, normalizeTab(tab)]), + ), + }; +} + export function workbenchTab( state: WorkbenchState, tabKey: string, -): WorkbenchTabState { - return state.tabs[tabKey] ?? defaultWorkbenchTab(tabKey); +): WorkbenchTabState | null { + return state.tabs[tabKey] ?? null; +} + +export function workbenchTabForPane( + state: WorkbenchState, + paneKey: string, +): WorkbenchTabMatch { + const match = Object.entries(state.tabs).find(([, tab]) => tab.paneKeys.includes(paneKey)); + if (match) return { tabKey: match[0], tab: match[1] }; + return { + tabKey: availableStandaloneTabKey(state.tabs, paneKey), + tab: defaultWorkbenchTab(paneKey), + }; +} + +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( @@ -112,11 +166,12 @@ function updateTab( tabKey: string, update: (tab: WorkbenchTabState) => WorkbenchTabState, ): WorkbenchState { - const current = workbenchTab(state, tabKey); + const current = state.tabs[tabKey]; + if (!current) return state; const next = update(current); - if (state.tabs[tabKey] === next) return state; + if (next === current) return state; return { - version: 2, + version: 1, tabs: { ...state.tabs, [tabKey]: next, @@ -124,31 +179,12 @@ function updateTab( }; } -export function ensureWorkbenchTab( - state: WorkbenchState, - tabKey: string, -): WorkbenchState { - if (state.tabs[tabKey]) return state; - return updateTab(state, tabKey, (tab) => tab); -} - export function addWorkbenchPane( state: WorkbenchState, tabKey: string, paneKey: string, ): WorkbenchState { - return updateTab(state, tabKey, (tab) => { - if (tab.paneKeys.includes(paneKey)) { - if (tab.activePaneKey === paneKey) return tab; - return { ...tab, activePaneKey: paneKey }; - } - if (tab.paneKeys.length >= MAX_WORKBENCH_PANES) return tab; - return { - ...tab, - paneKeys: [...tab.paneKeys, paneKey], - activePaneKey: paneKey, - }; - }); + return attachWorkbenchPane(state, tabKey, paneKey); } export function focusWorkbenchPane( @@ -163,29 +199,88 @@ export function focusWorkbenchPane( )); } +export function createWorkbenchTab( + state: WorkbenchState, + tabKey: string, +): WorkbenchState { + return updateTab(state, tabKey, (tab) => ( + tab.explicit ? tab : { ...tab, explicit: true } + )); +} + export function detachWorkbenchPane( state: WorkbenchState, tabKey: string, paneKey: string, ): WorkbenchState { - return updateTab(state, tabKey, (tab) => { - const index = tab.paneKeys.indexOf(paneKey); - if (index < 0 || paneKey === tabKey || tab.paneKeys.length === 1) return tab; - const paneKeys = tab.paneKeys.filter((key) => key !== paneKey); - const activePaneKey = tab.activePaneKey === paneKey - ? paneKeys[Math.min(index, paneKeys.length - 1)] - : tab.activePaneKey; - return { ...tab, paneKeys, activePaneKey }; - }); + 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", + })) + : state; + } + + 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]: { + ...tab, + paneKeys, + layoutPaneKeys, + activePaneKey: tab.activePaneKey === paneKey + ? paneKeys[Math.min(index, paneKeys.length - 1)] + : tab.activePaneKey, + }, + [nextTabKey]: defaultWorkbenchTab(paneKey), + }, + }; +} + +export function dissolveWorkbenchTab( + state: WorkbenchState, + tabKey: string, +): 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", + })) + : 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 }; } export function attachWorkbenchPane( state: WorkbenchState, targetTabKey: string, paneKey: string, - beforePaneKey?: string | null, ): WorkbenchState { - if (!targetTabKey || !paneKey || targetTabKey === paneKey) return state; + if (!targetTabKey || !paneKey) return state; + const target = state.tabs[targetTabKey]; + if (!target) return state; const sourceEntry = Object.entries(state.tabs).find(([, tab]) => ( tab.paneKeys.includes(paneKey) @@ -193,71 +288,58 @@ export function attachWorkbenchPane( const sourceTabKey = sourceEntry?.[0]; const sourceTab = sourceEntry?.[1]; if (sourceTabKey === targetTabKey) { - if (beforePaneKey === undefined) { - return focusWorkbenchPane(state, targetTabKey, paneKey); - } - if (!sourceTab) return state; - const paneKeys = insertPaneBefore(sourceTab.paneKeys, paneKey, beforePaneKey); - if (paneKeys.every((key, index) => key === sourceTab.paneKeys[index])) return state; - return { - version: 2, - tabs: { - ...state.tabs, - [targetTabKey]: { ...sourceTab, paneKeys }, - }, - }; + return focusWorkbenchPane(state, targetTabKey, paneKey); } - if (sourceTabKey === paneKey && sourceTab && sourceTab.paneKeys.length > 1) { - return state; - } - const targetBeforeMove = state.tabs[targetTabKey] ?? defaultWorkbenchTab(targetTabKey); - if ( - !targetBeforeMove.paneKeys.includes(paneKey) - && targetBeforeMove.paneKeys.length >= MAX_WORKBENCH_PANES - ) { + if (!target.paneKeys.includes(paneKey) && target.paneKeys.length >= MAX_WORKBENCH_PANES) { return state; } const tabs = { ...state.tabs }; if (sourceTabKey && sourceTab) { - if (sourceTabKey === paneKey) { + 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) { delete tabs[sourceTabKey]; } else { - const index = sourceTab.paneKeys.indexOf(paneKey); - const paneKeys = sourceTab.paneKeys.filter((key) => key !== paneKey); tabs[sourceTabKey] = { ...sourceTab, - paneKeys, + paneKeys: sourcePaneKeys, + layoutPaneKeys: sourceLayoutPaneKeys, activePaneKey: sourceTab.activePaneKey === paneKey - ? paneKeys[Math.min(index, paneKeys.length - 1)] + ? sourcePaneKeys[Math.min(index, sourcePaneKeys.length - 1)] : sourceTab.activePaneKey, }; } } - const targetTab = tabs[targetTabKey] ?? defaultWorkbenchTab(targetTabKey); - const paneKeys = insertPaneBefore(targetTab.paneKeys, paneKey, beforePaneKey); + const nextTarget = tabs[targetTabKey]; + if (!nextTarget) return state; + const paneKeys = nextTarget.paneKeys.includes(paneKey) + ? nextTarget.paneKeys + : [...nextTarget.paneKeys, paneKey]; + const layoutPaneKeys = nextTarget.layoutPaneKeys.includes(paneKey) + ? nextTarget.layoutPaneKeys + : [...nextTarget.layoutPaneKeys, paneKey]; tabs[targetTabKey] = { - ...targetTab, + ...nextTarget, paneKeys, + layoutPaneKeys, activePaneKey: paneKey, }; - return { version: 2, tabs }; + return { version: 1, tabs }; } -export function promoteWorkbenchPane( +export function renameWorkbenchTab( state: WorkbenchState, tabKey: string, - paneKey: string, + title: string, ): WorkbenchState { - return updateTab(state, tabKey, (tab) => { - const index = tab.paneKeys.indexOf(paneKey); - if (index <= 0) return tab; - return { - ...tab, - paneKeys: [paneKey, ...tab.paneKeys.filter((key) => key !== paneKey)], - }; - }); + const normalized = normalizeTitle(title); + if (!normalized) return state; + return updateTab(state, tabKey, (tab) => ( + tab.title === normalized ? tab : { ...tab, title: normalized } + )); } export function setWorkbenchLayout( @@ -270,36 +352,89 @@ export function setWorkbenchLayout( )); } +export function setWorkbenchPaneLayoutOrder( + state: WorkbenchState, + tabKey: string, + paneKeys: readonly string[], +): WorkbenchState { + return updateTab(state, tabKey, (tab) => { + const requested = uniqueKeys(paneKeys).filter((key) => tab.paneKeys.includes(key)); + const layoutPaneKeys = [ + ...requested, + ...tab.paneKeys.filter((key) => !requested.includes(key)), + ]; + return layoutPaneKeys.every((key, index) => tab.layoutPaneKeys[index] === key) + ? tab + : { ...tab, layoutPaneKeys }; + }); +} + export function reconcileWorkbench( state: WorkbenchState, validKeys: ReadonlySet, ): WorkbenchState { const tabs: Record = {}; + const claimedPaneKeys = new Set(); + for (const [tabKey, tab] of Object.entries(state.tabs)) { - if (!validKeys.has(tabKey)) continue; - const paneKeys = tab.paneKeys.filter((key) => validKeys.has(key)); - const normalizedPaneKeys = (paneKeys.includes(tabKey) - ? paneKeys - : [tabKey, ...paneKeys]).slice(0, MAX_WORKBENCH_PANES); + const paneKeys = tab.paneKeys + .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] = { ...tab, - paneKeys: normalizedPaneKeys, - activePaneKey: normalizedPaneKeys.includes(tab.activePaneKey) + paneKeys, + layoutPaneKeys: [ + ...tab.layoutPaneKeys.filter((key) => paneKeys.includes(key)), + ...paneKeys.filter((key) => !tab.layoutPaneKeys.includes(key)), + ], + activePaneKey: paneKeys.includes(tab.activePaneKey) ? tab.activePaneKey - : normalizedPaneKeys[0], + : paneKeys[0], }; } - const serializedCurrent = JSON.stringify(state.tabs); - const serializedNext = JSON.stringify(tabs); - return serializedCurrent === serializedNext ? state : { version: 2, tabs }; + + for (const paneKey of validKeys) { + if (claimedPaneKeys.has(paneKey)) continue; + const tabKey = availableStandaloneTabKey(tabs, paneKey); + tabs[tabKey] = defaultWorkbenchTab(paneKey); + } + + return JSON.stringify(state.tabs) === JSON.stringify(tabs) + ? state + : { version: 1, tabs }; } -export function workbenchChildPaneKeys(state: WorkbenchState): Set { - const childKeys = new Set(); - for (const [tabKey, tab] of Object.entries(state.tabs)) { - for (const paneKey of tab.paneKeys) { - if (paneKey !== tabKey) childKeys.add(paneKey); - } - } - return childKeys; +export function orderWorkbenchTabs( + state: WorkbenchState, + orderedSessionKeys: readonly string[], + updatedAtByKey: ReadonlyMap, +): OrderedWorkbenchTab[] { + 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 paneKeys = tab.paneKeys + .filter((key) => validKeys.has(key)) + .sort((left, right) => (rank.get(left) ?? Infinity) - (rank.get(right) ?? Infinity)); + const updatedAt = paneKeys.reduce((latest, paneKey) => { + const candidate = updatedAtByKey.get(paneKey) ?? null; + return dateToTime(candidate) > dateToTime(latest) ? candidate : latest; + }, null); + return { tabKey, tab, paneKeys, updatedAt }; + }); + + return tabs.sort((left, right) => { + const updateOrder = dateToTime(right.updatedAt) - dateToTime(left.updatedAt); + if (updateOrder !== 0) return updateOrder; + const leftRank = Math.min(...left.paneKeys.map((key) => rank.get(key) ?? Infinity)); + const rightRank = Math.min(...right.paneKeys.map((key) => rank.get(key) ?? Infinity)); + return leftRank - rightRank; + }); +} + +function dateToTime(value: string | null | undefined): number { + const timestamp = Date.parse(value ?? ""); + return Number.isFinite(timestamp) ? timestamp : 0; } diff --git a/webui/src/hooks/useSidebarState.ts b/webui/src/hooks/useSidebarState.ts index cf90a36e1..b937432de 100644 --- a/webui/src/hooks/useSidebarState.ts +++ b/webui/src/hooks/useSidebarState.ts @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useClient } from "@/providers/ClientProvider"; +import { normalizeWorkbenchState } from "@/components/workbench/workbench-model"; import { fetchSidebarState } from "@/lib/api"; import type { ChatSummary, SidebarStatePayload } from "@/lib/types"; @@ -13,6 +14,7 @@ export const DEFAULT_SIDEBAR_STATE: SidebarStatePayload = { project_name_overrides: {}, tags_by_key: {}, collapsed_groups: {}, + workbench: { version: 1, tabs: {} }, view: { density: "comfortable", show_previews: false, @@ -93,6 +95,7 @@ export function normalizeSidebarState(raw: unknown): SidebarStatePayload { project_name_overrides: stringMap(value.project_name_overrides), tags_by_key: tagsMap(value.tags_by_key), collapsed_groups: boolMap(value.collapsed_groups), + workbench: normalizeWorkbenchState(value.workbench), view: { density, show_previews: Boolean(view.show_previews), @@ -146,6 +149,8 @@ export function useSidebarState( const stateRef = useRef(DEFAULT_SIDEBAR_STATE); const connectionOpenRef = useRef(client.status === "open"); const pendingPersistenceRef = useRef(null); + const persistenceInFlightRef = useRef(false); + const flushPersistenceRef = useRef<() => void>(() => {}); const [state, setState] = useState(DEFAULT_SIDEBAR_STATE); const [loading, setLoading] = useState(true); tokenRef.current = token; @@ -173,23 +178,54 @@ export function useSidebarState( }; }, []); - const persist = useCallback((next: SidebarStatePayload) => { - if (!connectionOpenRef.current) { - pendingPersistenceRef.current = next; - return; - } - void client.setSidebarState(next).catch(() => { - // Sidebar persistence is best-effort; the optimistic local state remains usable. - }); + const flushPersistence = useCallback(() => { + if ( + persistenceInFlightRef.current + || !connectionOpenRef.current + || pendingPersistenceRef.current === null + ) return; + + const next = pendingPersistenceRef.current; + pendingPersistenceRef.current = null; + persistenceInFlightRef.current = true; + void client.setSidebarState(next) + .then((saved) => { + persistenceInFlightRef.current = false; + if (pendingPersistenceRef.current === null) { + const canonical = normalizeSidebarState(saved); + stateRef.current = canonical; + setState(canonical); + } + flushPersistenceRef.current(); + }) + .catch(() => { + persistenceInFlightRef.current = false; + if (pendingPersistenceRef.current === null) { + pendingPersistenceRef.current = next; + } + }); }, [client]); + flushPersistenceRef.current = flushPersistence; + + const persist = useCallback((next: SidebarStatePayload) => { + pendingPersistenceRef.current = next; + flushPersistence(); + }, [flushPersistence]); useEffect(() => client.onStatus((status) => { connectionOpenRef.current = status === "open"; - if (status !== "open" || pendingPersistenceRef.current === null) return; - const pending = pendingPersistenceRef.current; - pendingPersistenceRef.current = null; - persist(pending); - }), [client, persist]); + if (status === "open") flushPersistence(); + }), [client, flushPersistence]); + + useEffect(() => client.onSidebarStateUpdate((incoming) => { + if ( + persistenceInFlightRef.current + || pendingPersistenceRef.current !== null + ) return; + const loaded = normalizeSidebarState(incoming); + stateRef.current = loaded; + setState(loaded); + }), [client]); const update = useCallback( async (updater: (current: SidebarStatePayload) => SidebarStatePayload) => { diff --git a/webui/src/i18n/locales/en/common.json b/webui/src/i18n/locales/en/common.json index 64f0c4e7d..26a0f5454 100644 --- a/webui/src/i18n/locales/en/common.json +++ b/webui/src/i18n/locales/en/common.json @@ -1414,19 +1414,26 @@ "collapseTabGroup": "Collapse panes in {{title}}", "expandTabGroup": "Expand panes in {{title}}", "dropPane": "Move {{pane}} into {{tab}}", - "moveToTab": "Move to tab", + "createGroup": "Create group", + "moveTo": "Move to", + "renameTabTitle": "Rename tab", + "renameTabDescription": "Give this tab a name for organizing its panes.", + "renameTabPlaceholder": "Tab name", + "dissolveTab": "Dissolve group", "layout": "Pane layout", "addPane": "Add pane", + "movePane": "Move {{title}} pane", + "movePaneHint": "Drag to move · Arrow keys also work", "promotePane": "Make {{title}} the primary pane", "paneActions": "{{title}} pane actions", - "detachPane": "Move {{title}} to a new tab", + "detachPane": "Remove", "composerAria": "Message {{title}}", "layouts": { "columns": "Columns", "rows": "Rows", "grid": "Grid", - "main-stack": "Main and stack", - "monocle": "Monocle" + "bsp": "BSP", + "main-stack": "Main and stack" } }, "common": { diff --git a/webui/src/i18n/locales/es/common.json b/webui/src/i18n/locales/es/common.json index c9fa3bb02..78d927633 100644 --- a/webui/src/i18n/locales/es/common.json +++ b/webui/src/i18n/locales/es/common.json @@ -1401,19 +1401,26 @@ "collapseTabGroup": "Contraer los paneles de {{title}}", "expandTabGroup": "Expandir los paneles de {{title}}", "dropPane": "Mover {{pane}} a {{tab}}", - "moveToTab": "Mover a una pestaña", + "createGroup": "Crear grupo", + "moveTo": "Mover a", + "renameTabTitle": "Renombrar pestaña", + "renameTabDescription": "Ponle un nombre a esta pestaña para organizar sus paneles.", + "renameTabPlaceholder": "Nombre de la pestaña", + "dissolveTab": "Disolver grupo", "layout": "Diseño de paneles", "addPane": "Añadir panel", + "movePane": "Mover panel {{title}}", + "movePaneHint": "Arrastra para mover · También puedes usar las flechas", "promotePane": "Convertir {{title}} en el panel principal", "paneActions": "Acciones del panel {{title}}", - "detachPane": "Mover {{title}} a una pestaña nueva", + "detachPane": "Quitar", "composerAria": "Mensaje para {{title}}", "layouts": { "columns": "Columnas", "rows": "Filas", "grid": "Cuadrícula", - "main-stack": "Principal y pila", - "monocle": "Monóculo" + "bsp": "BSP", + "main-stack": "Principal y pila" } }, "common": { diff --git a/webui/src/i18n/locales/fr/common.json b/webui/src/i18n/locales/fr/common.json index 04d1f4d36..526c873ed 100644 --- a/webui/src/i18n/locales/fr/common.json +++ b/webui/src/i18n/locales/fr/common.json @@ -1400,19 +1400,26 @@ "collapseTabGroup": "Réduire les volets de {{title}}", "expandTabGroup": "Développer les volets de {{title}}", "dropPane": "Déplacer {{pane}} dans {{tab}}", - "moveToTab": "Déplacer vers un onglet", + "createGroup": "Créer un groupe", + "moveTo": "Déplacer vers", + "renameTabTitle": "Renommer l’onglet", + "renameTabDescription": "Donnez un nom à cet onglet pour organiser ses volets.", + "renameTabPlaceholder": "Nom de l’onglet", + "dissolveTab": "Dissoudre le groupe", "layout": "Disposition des volets", "addPane": "Ajouter un volet", + "movePane": "Déplacer le volet {{title}}", + "movePaneHint": "Faites glisser pour déplacer · Les flèches fonctionnent aussi", "promotePane": "Définir {{title}} comme volet principal", "paneActions": "Actions du volet {{title}}", - "detachPane": "Déplacer {{title}} vers un nouvel onglet", + "detachPane": "Retirer", "composerAria": "Message à {{title}}", "layouts": { "columns": "Colonnes", "rows": "Lignes", "grid": "Grille", - "main-stack": "Principal et pile", - "monocle": "Monocle" + "bsp": "BSP", + "main-stack": "Principal et pile" } }, "common": { diff --git a/webui/src/i18n/locales/id/common.json b/webui/src/i18n/locales/id/common.json index 9be1151df..ba259996a 100644 --- a/webui/src/i18n/locales/id/common.json +++ b/webui/src/i18n/locales/id/common.json @@ -1400,19 +1400,26 @@ "collapseTabGroup": "Ciutkan panel di {{title}}", "expandTabGroup": "Luaskan panel di {{title}}", "dropPane": "Pindahkan {{pane}} ke {{tab}}", - "moveToTab": "Pindahkan ke tab", + "createGroup": "Buat grup", + "moveTo": "Pindahkan ke", + "renameTabTitle": "Ganti nama tab", + "renameTabDescription": "Beri nama tab ini untuk mengatur panelnya.", + "renameTabPlaceholder": "Nama tab", + "dissolveTab": "Bubarkan grup", "layout": "Tata letak panel", "addPane": "Tambah panel", + "movePane": "Pindahkan panel {{title}}", + "movePaneHint": "Seret untuk memindahkan · Tombol panah juga dapat digunakan", "promotePane": "Jadikan {{title}} panel utama", "paneActions": "Tindakan panel {{title}}", - "detachPane": "Pindahkan {{title}} ke tab baru", + "detachPane": "Keluarkan", "composerAria": "Pesan untuk {{title}}", "layouts": { "columns": "Kolom", "rows": "Baris", "grid": "Kisi", - "main-stack": "Utama dan tumpukan", - "monocle": "Panel tunggal" + "bsp": "BSP", + "main-stack": "Utama dan tumpukan" } }, "common": { diff --git a/webui/src/i18n/locales/ja/common.json b/webui/src/i18n/locales/ja/common.json index b7ff364bd..5a9d1af1b 100644 --- a/webui/src/i18n/locales/ja/common.json +++ b/webui/src/i18n/locales/ja/common.json @@ -1400,19 +1400,26 @@ "collapseTabGroup": "{{title}} のペインを折りたたむ", "expandTabGroup": "{{title}} のペインを展開する", "dropPane": "{{pane}} を {{tab}} に移動", - "moveToTab": "タブへ移動", + "createGroup": "グループを作成", + "moveTo": "移動先", + "renameTabTitle": "タブ名を変更", + "renameTabDescription": "ペインを整理するため、このタブに名前を付けます。", + "renameTabPlaceholder": "タブ名", + "dissolveTab": "グループを解除", "layout": "ペインレイアウト", "addPane": "ペインを追加", + "movePane": "{{title}} ペインを移動", + "movePaneHint": "ドラッグで移動 · 矢印キーでも移動できます", "promotePane": "{{title}} をメインペインにする", "paneActions": "{{title}} ペインの操作", - "detachPane": "{{title}} を新しいタブに移動", + "detachPane": "外す", "composerAria": "{{title}} へのメッセージ", "layouts": { "columns": "列", "rows": "行", "grid": "グリッド", - "main-stack": "メインとスタック", - "monocle": "モノクル" + "bsp": "BSP", + "main-stack": "メインとスタック" } }, "common": { diff --git a/webui/src/i18n/locales/ko/common.json b/webui/src/i18n/locales/ko/common.json index f1d3f91a6..840f48016 100644 --- a/webui/src/i18n/locales/ko/common.json +++ b/webui/src/i18n/locales/ko/common.json @@ -1400,19 +1400,26 @@ "collapseTabGroup": "{{title}}의 창 접기", "expandTabGroup": "{{title}}의 창 펼치기", "dropPane": "{{pane}}을(를) {{tab}}으로 이동", - "moveToTab": "탭으로 이동", + "createGroup": "그룹 만들기", + "moveTo": "이동", + "renameTabTitle": "탭 이름 바꾸기", + "renameTabDescription": "창을 정리할 수 있도록 이 탭에 이름을 지정하세요.", + "renameTabPlaceholder": "탭 이름", + "dissolveTab": "그룹 해제", "layout": "창 레이아웃", "addPane": "창 추가", + "movePane": "{{title}} 창 이동", + "movePaneHint": "드래그하여 이동 · 방향키로도 이동 가능", "promotePane": "{{title}}을(를) 기본 창으로 설정", "paneActions": "{{title}} 창 작업", - "detachPane": "{{title}}을(를) 새 탭으로 이동", + "detachPane": "제거", "composerAria": "{{title}}에 메시지 보내기", "layouts": { "columns": "열", "rows": "행", "grid": "그리드", - "main-stack": "기본 창과 스택", - "monocle": "단일 창" + "bsp": "BSP", + "main-stack": "기본 창과 스택" } }, "common": { diff --git a/webui/src/i18n/locales/pt-BR/common.json b/webui/src/i18n/locales/pt-BR/common.json index 0139b15ba..a39f09214 100644 --- a/webui/src/i18n/locales/pt-BR/common.json +++ b/webui/src/i18n/locales/pt-BR/common.json @@ -1414,19 +1414,26 @@ "collapseTabGroup": "Recolher os painéis em {{title}}", "expandTabGroup": "Expandir os painéis em {{title}}", "dropPane": "Mover {{pane}} para {{tab}}", - "moveToTab": "Mover para uma aba", + "createGroup": "Criar grupo", + "moveTo": "Mover para", + "renameTabTitle": "Renomear aba", + "renameTabDescription": "Dê um nome a esta aba para organizar seus painéis.", + "renameTabPlaceholder": "Nome da aba", + "dissolveTab": "Desfazer grupo", "layout": "Layout de painéis", "addPane": "Adicionar painel", + "movePane": "Mover painel {{title}}", + "movePaneHint": "Arraste para mover · As setas também funcionam", "promotePane": "Tornar {{title}} o painel principal", "paneActions": "Ações do painel {{title}}", - "detachPane": "Mover {{title}} para uma nova aba", + "detachPane": "Remover", "composerAria": "Mensagem para {{title}}", "layouts": { "columns": "Colunas", "rows": "Linhas", "grid": "Grade", - "main-stack": "Principal e pilha", - "monocle": "Monóculo" + "bsp": "BSP", + "main-stack": "Principal e pilha" } }, "common": { diff --git a/webui/src/i18n/locales/vi/common.json b/webui/src/i18n/locales/vi/common.json index d9c3ba6e2..4e667accc 100644 --- a/webui/src/i18n/locales/vi/common.json +++ b/webui/src/i18n/locales/vi/common.json @@ -1400,19 +1400,26 @@ "collapseTabGroup": "Thu gọn các khung trong {{title}}", "expandTabGroup": "Mở rộng các khung trong {{title}}", "dropPane": "Di chuyển {{pane}} vào {{tab}}", - "moveToTab": "Di chuyển vào thẻ", + "createGroup": "Tạo nhóm", + "moveTo": "Di chuyển đến", + "renameTabTitle": "Đổi tên thẻ", + "renameTabDescription": "Đặt tên cho thẻ này để sắp xếp các khung.", + "renameTabPlaceholder": "Tên thẻ", + "dissolveTab": "Giải tán nhóm", "layout": "Bố cục khung", "addPane": "Thêm khung", + "movePane": "Di chuyển khung {{title}}", + "movePaneHint": "Kéo để di chuyển · Cũng có thể dùng các phím mũi tên", "promotePane": "Đặt {{title}} làm khung chính", "paneActions": "Thao tác cho khung {{title}}", - "detachPane": "Chuyển {{title}} sang thẻ mới", + "detachPane": "Gỡ", "composerAria": "Nhắn tin cho {{title}}", "layouts": { "columns": "Cột", "rows": "Hàng", "grid": "Lưới", - "main-stack": "Khung chính và ngăn xếp", - "monocle": "Một khung" + "bsp": "BSP", + "main-stack": "Khung chính và ngăn xếp" } }, "common": { diff --git a/webui/src/i18n/locales/zh-CN/common.json b/webui/src/i18n/locales/zh-CN/common.json index 28b04e27a..deb66aae4 100644 --- a/webui/src/i18n/locales/zh-CN/common.json +++ b/webui/src/i18n/locales/zh-CN/common.json @@ -1414,19 +1414,26 @@ "collapseTabGroup": "折叠 {{title}} 中的窗格", "expandTabGroup": "展开 {{title}} 中的窗格", "dropPane": "将 {{pane}} 移入 {{tab}}", - "moveToTab": "移动到标签页", + "createGroup": "创建分组", + "moveTo": "移动到", + "renameTabTitle": "重命名标签页", + "renameTabDescription": "为这个标签页命名,以便组织其中的窗格。", + "renameTabPlaceholder": "标签页名称", + "dissolveTab": "解散分组", "layout": "窗格布局", "addPane": "添加窗格", + "movePane": "移动 {{title}} 窗格", + "movePaneHint": "拖动换位 · 也可以使用方向键", "promotePane": "将 {{title}} 设为主窗格", "paneActions": "{{title}} 窗格操作", - "detachPane": "将 {{title}} 移至新标签页", + "detachPane": "移出", "composerAria": "向 {{title}} 发送消息", "layouts": { "columns": "列布局", "rows": "行布局", "grid": "网格", - "main-stack": "主窗格与堆栈", - "monocle": "单窗格" + "bsp": "BSP", + "main-stack": "主窗格与堆栈" } }, "common": { diff --git a/webui/src/i18n/locales/zh-TW/common.json b/webui/src/i18n/locales/zh-TW/common.json index 6db8e55d9..60d12eddf 100644 --- a/webui/src/i18n/locales/zh-TW/common.json +++ b/webui/src/i18n/locales/zh-TW/common.json @@ -1400,19 +1400,26 @@ "collapseTabGroup": "收合 {{title}} 中的窗格", "expandTabGroup": "展開 {{title}} 中的窗格", "dropPane": "將 {{pane}} 移入 {{tab}}", - "moveToTab": "移動到分頁", + "createGroup": "建立群組", + "moveTo": "移動到", + "renameTabTitle": "重新命名分頁", + "renameTabDescription": "為這個分頁命名,以便整理其中的窗格。", + "renameTabPlaceholder": "分頁名稱", + "dissolveTab": "解散群組", "layout": "窗格佈局", "addPane": "新增窗格", + "movePane": "移動 {{title}} 窗格", + "movePaneHint": "拖曳換位 · 也可以使用方向鍵", "promotePane": "將 {{title}} 設為主窗格", "paneActions": "{{title}} 窗格操作", - "detachPane": "將 {{title}} 移至新標籤頁", + "detachPane": "移出", "composerAria": "傳送訊息給 {{title}}", "layouts": { "columns": "欄佈局", "rows": "列佈局", "grid": "網格", - "main-stack": "主窗格與堆疊", - "monocle": "單窗格" + "bsp": "BSP", + "main-stack": "主窗格與堆疊" } }, "common": { diff --git a/webui/src/lib/chat-groups.ts b/webui/src/lib/chat-groups.ts index 008182f17..9a0120587 100644 --- a/webui/src/lib/chat-groups.ts +++ b/webui/src/lib/chat-groups.ts @@ -339,7 +339,7 @@ function sortProjectSessions( }); } -function sortSessions( +export function sortSessions( sessions: ChatSummary[], sort: SidebarSortMode, titleOverrides: Record, diff --git a/webui/src/lib/nanobot-client.ts b/webui/src/lib/nanobot-client.ts index fcf0e07d7..c6cd6bdbd 100644 --- a/webui/src/lib/nanobot-client.ts +++ b/webui/src/lib/nanobot-client.ts @@ -73,6 +73,7 @@ type SessionUpdateHandler = ( scope?: SessionUpdateScope, workspaceScope?: WorkspaceScopePayload, ) => void; +type SidebarStateUpdateHandler = (state: SidebarStatePayload) => void; type RunStatusHandler = (chatId: string, startedAt: number | null) => void; /** Structured errors surfaced to the UI. @@ -178,6 +179,7 @@ export class NanobotClient { private statusHandlers = new Set(); private runtimeModelHandlers = new Set(); private sessionUpdateHandlers = new Set(); + private sidebarStateUpdateHandlers = new Set(); private runStatusHandlers = new Set(); private errorHandlers = new Set(); // chat_id -> handlers listening on it @@ -275,6 +277,13 @@ export class NanobotClient { }; } + onSidebarStateUpdate(handler: SidebarStateUpdateHandler): Unsubscribe { + this.sidebarStateUpdateHandlers.add(handler); + return () => { + this.sidebarStateUpdateHandlers.delete(handler); + }; + } + onRunStatus(handler: RunStatusHandler): Unsubscribe { this.runStatusHandlers.add(handler); for (const [chatId, startedAt] of this.runStartedAtByChatId) { @@ -1149,6 +1158,11 @@ export class NanobotClient { return; } + if (parsed.event === "sidebar_state_updated") { + this.emitSidebarStateUpdate(parsed.state); + return; + } + if (parsed.event === "error" && parsed.detail === "workspace_scope_rejected") { this.emitError({ kind: "workspace_scope_rejected", @@ -1198,6 +1212,12 @@ export class NanobotClient { } } + private emitSidebarStateUpdate(state: SidebarStatePayload): void { + for (const handler of this.sidebarStateUpdateHandlers) { + handler(state); + } + } + private emitRunStatus(chatId: string, startedAt: number | null): void { for (const handler of this.runStatusHandlers) { handler(chatId, startedAt); diff --git a/webui/src/lib/session-drag.ts b/webui/src/lib/session-drag.ts index c089beaea..0a4339cc9 100644 --- a/webui/src/lib/session-drag.ts +++ b/webui/src/lib/session-drag.ts @@ -1,13 +1,6 @@ export const SESSION_DRAG_TYPE = "application/x-nanobot-session-key"; -export const PANE_DRAG_TYPE = "application/x-nanobot-pane"; - -export interface DraggedPane { - paneKey: string; - sourceTabKey: string; -} let activeSessionKey: string | null = null; -let activePane: DraggedPane | null = null; export function hasDraggedSession(dataTransfer: DataTransfer): boolean { return Array.from(dataTransfer.types).includes(SESSION_DRAG_TYPE); @@ -20,7 +13,6 @@ export function readDraggedSession(dataTransfer: DataTransfer): string | null { export function clearDraggedSession(): void { activeSessionKey = null; - activePane = null; } export function writeDraggedSession( @@ -31,27 +23,3 @@ export function writeDraggedSession( dataTransfer.effectAllowed = "copyMove"; dataTransfer.setData(SESSION_DRAG_TYPE, sessionKey); } - -export function readDraggedPane(dataTransfer: DataTransfer): DraggedPane | null { - const serialized = dataTransfer.getData(PANE_DRAG_TYPE).trim(); - if (serialized) { - try { - const parsed = JSON.parse(serialized) as Partial; - if (parsed.paneKey && parsed.sourceTabKey) { - return { paneKey: parsed.paneKey, sourceTabKey: parsed.sourceTabKey }; - } - } catch { - // Fall through to the in-memory payload used while the native drag is active. - } - } - return activePane; -} - -export function writeDraggedPane( - dataTransfer: DataTransfer, - pane: DraggedPane, -): void { - activePane = pane; - writeDraggedSession(dataTransfer, pane.paneKey); - dataTransfer.setData(PANE_DRAG_TYPE, JSON.stringify(pane)); -} diff --git a/webui/src/lib/types.ts b/webui/src/lib/types.ts index 6841d86ad..4b557ea96 100644 --- a/webui/src/lib/types.ts +++ b/webui/src/lib/types.ts @@ -368,6 +368,21 @@ export interface WorkspacesPayload { export type SidebarDensity = "comfortable" | "compact"; export type SidebarSortMode = "updated_desc" | "created_desc" | "title_asc" | "manual"; +export type WorkbenchLayout = "columns" | "rows" | "grid" | "bsp" | "main-stack"; + +export interface WorkbenchTabState { + explicit: boolean; + title: string | null; + paneKeys: string[]; + layoutPaneKeys: string[]; + activePaneKey: string; + layout: WorkbenchLayout; +} + +export interface WorkbenchState { + version: 1; + tabs: Record; +} export interface SidebarViewState { density: SidebarDensity; @@ -386,6 +401,7 @@ export interface SidebarStatePayload { project_name_overrides: Record; tags_by_key: Record; collapsed_groups: Record; + workbench: WorkbenchState; view: SidebarViewState; updated_at?: string | null; } @@ -1279,6 +1295,10 @@ export type InboundEvent = scope?: "metadata" | "thread" | string; workspace_scope?: WorkspaceScopePayload; } + | { + event: "sidebar_state_updated"; + state: SidebarStatePayload; + } | { event: "transcription_result"; request_id: string; text: string } | { event: "transcription_error"; diff --git a/webui/src/tests/app-layout.test.tsx b/webui/src/tests/app-layout.test.tsx index ce37d03dd..dfc2e8007 100644 --- a/webui/src/tests/app-layout.test.tsx +++ b/webui/src/tests/app-layout.test.tsx @@ -7,6 +7,7 @@ import type { ChatSummary, ConnectionStatus, SessionAutomationJob, + SidebarStatePayload, WorkspaceScopePayload, } from "@/lib/types"; @@ -30,6 +31,7 @@ const sessionUpdateHandlers = new Set<( scope?: string, workspaceScope?: WorkspaceScopePayload, ) => void>(); +const sidebarStateUpdateHandlers = new Set<(state: SidebarStatePayload) => void>(); let mockSessions: ChatSummary[] = []; const HERO_GREETING_PATTERN = /What should we work on\?|Where should we start\?|What are we building today\?|What should we tackle together\?/; @@ -249,6 +251,10 @@ vi.mock("@/lib/nanobot-client", async (importOriginal) => { sessionUpdateHandlers.add(handler); return () => sessionUpdateHandlers.delete(handler); }; + onSidebarStateUpdate = (handler: (state: SidebarStatePayload) => void) => { + sidebarStateUpdateHandlers.add(handler); + return () => sidebarStateUpdateHandlers.delete(handler); + }; onRunStatus = (handler: (chatId: string, startedAt: number | null) => void) => { runStatusHandlers.add(handler); return () => runStatusHandlers.delete(handler); @@ -289,7 +295,9 @@ describe("App layout", () => { getSessionAutomationsSpy.mockReset().mockResolvedValue([]); toggleThemeSpy.mockReset(); attachSpy.mockReset(); - setSidebarStateSpy.mockReset().mockResolvedValue({}); + setSidebarStateSpy.mockReset().mockImplementation( + async (state: SidebarStatePayload) => state, + ); requestMutationSpy.mockReset(); discardTemporaryChatSpy.mockReset(); let temporaryChatCounter = 0; @@ -300,6 +308,7 @@ describe("App layout", () => { statusHandlers.clear(); runStatusHandlers.clear(); sessionUpdateHandlers.clear(); + sidebarStateUpdateHandlers.clear(); window.history.replaceState(null, "", "/"); setNavigatorPlatform("Linux x86_64"); localStorage.removeItem("nanobot-webui.sidebar"); @@ -307,8 +316,6 @@ describe("App layout", () => { localStorage.removeItem("nanobot-webui.sidebar.session-updates.v1"); localStorage.removeItem("nanobot-webui.restartStartedAt"); localStorage.removeItem("nanobot-webui.restartRoute"); - localStorage.removeItem("nanobot.webui.workbench.v1"); - localStorage.removeItem("nanobot.webui.workbench.v2"); vi.mocked(fetchBootstrap).mockReset().mockResolvedValue({ token: "tok", api_token: "api-tok", @@ -3018,6 +3025,203 @@ describe("App layout", () => { ); }); + it("keeps panes adjacent and orders tabs by their latest updated pane", async () => { + mockSessions = [ + { + key: "websocket:alpha", + channel: "websocket", + chatId: "alpha", + createdAt: "2026-08-01T10:00:00Z", + updatedAt: "2026-08-01T10:00:00Z", + title: "Alpha tab", + preview: "", + }, + { + key: "websocket:alpha-child", + channel: "websocket", + chatId: "alpha-child", + createdAt: "2026-08-05T10:00:00Z", + updatedAt: "2026-08-05T10:00:00Z", + title: "Alpha child", + preview: "", + }, + { + key: "websocket:beta", + channel: "websocket", + chatId: "beta", + createdAt: "2026-08-04T10:00:00Z", + updatedAt: "2026-08-04T10:00:00Z", + title: "Beta tab", + preview: "", + }, + ]; + vi.stubGlobal("fetch", vi.fn().mockImplementation(async (url: string | URL | Request) => { + if (String(url) === "/api/webui/sidebar-state") { + return { + ok: true, + json: async () => ({ + workbench: { + version: 1, + tabs: { + "tab:websocket:alpha": { + 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", + }, + }, + }, + }), + }; + } + return { ok: false, status: 404 }; + })); + + render(); + + await waitFor(() => expect(connectSpy).toHaveBeenCalled()); + const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" }); + const alphaTab = await within(sidebar).findByRole("button", { name: "Tab: Alpha tab" }); + const betaTab = within(sidebar).getByRole("button", { name: "Beta tab" }); + expect(alphaTab.compareDocumentPosition(betaTab) & Node.DOCUMENT_POSITION_FOLLOWING) + .toBeTruthy(); + + const alphaGroup = alphaTab.closest("[data-sidebar-tab-group]") as HTMLElement; + const paneTitles = within(alphaGroup) + .getAllByRole("button") + .filter((button) => ( + button.closest("[data-sidebar-pane]") && button.hasAttribute("title") + )) + .map((button) => button.getAttribute("title")); + expect(paneTitles).toEqual(["Alpha child", "Alpha tab"]); + }); + + it("materializes a singleton tab without linking it to another pane", async () => { + mockSessions = [ + { + key: "websocket:solo", + channel: "websocket", + chatId: "solo", + createdAt: "2026-08-05T10:00:00Z", + updatedAt: "2026-08-05T10:00:00Z", + title: "Solo pane", + preview: "", + }, + { + key: "websocket:other", + channel: "websocket", + chatId: "other", + createdAt: "2026-08-04T10:00:00Z", + updatedAt: "2026-08-04T10:00:00Z", + title: "Other pane", + preview: "", + }, + ]; + + render(); + + await waitFor(() => expect(connectSpy).toHaveBeenCalled()); + act(() => { + statusHandlers.forEach((handler) => handler("open")); + }); + const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" }); + expect(within(sidebar).queryByRole("button", { name: "Tab: Solo pane" })) + .not.toBeInTheDocument(); + setSidebarStateSpy.mockClear(); + + fireEvent.pointerDown(within(sidebar).getByRole("button", { + name: "Topic actions for Solo pane", + }), { button: 0, ctrlKey: false }); + fireEvent.click(await screen.findByRole("menuitem", { name: "Create group" })); + + const tabButton = await within(sidebar).findByRole("button", { + name: "Tab: Solo pane", + }); + const tabGroup = tabButton.closest("[data-sidebar-tab-group]") as HTMLElement; + expect(within(tabGroup).getByRole("list", { name: "Panes in Solo pane" })) + .toBeInTheDocument(); + expect(within(tabGroup).getAllByRole("button", { name: "Solo pane" })) + .toHaveLength(1); + expect(within(sidebar).queryByRole("button", { name: "Tab: Other pane" })) + .not.toBeInTheDocument(); + await waitFor(() => expect(setSidebarStateSpy).toHaveBeenCalledWith( + expect.objectContaining({ + workbench: expect.objectContaining({ + tabs: expect.objectContaining({ + "tab:websocket:solo": expect.objectContaining({ explicit: true }), + }), + }), + }), + )); + }); + + it("restores a created pane group from gateway state after remount", async () => { + mockSessions = [ + { + key: "websocket:solo", + channel: "websocket", + chatId: "solo", + createdAt: "2026-08-05T10:00:00Z", + updatedAt: "2026-08-05T10:00:00Z", + title: "Solo pane", + preview: "", + }, + { + key: "websocket:other", + channel: "websocket", + chatId: "other", + createdAt: "2026-08-04T10:00:00Z", + updatedAt: "2026-08-04T10:00:00Z", + title: "Other pane", + preview: "", + }, + ]; + let persistedState: SidebarStatePayload | null = null; + vi.stubGlobal("fetch", vi.fn().mockImplementation(async (url: string | URL | Request) => { + if (String(url) === "/api/webui/sidebar-state") { + return { + ok: true, + json: async () => persistedState ?? {}, + }; + } + return { ok: false, status: 404 }; + })); + setSidebarStateSpy.mockImplementation(async (state: SidebarStatePayload) => { + persistedState = state; + return state; + }); + + const firstRender = render(); + await waitFor(() => expect(connectSpy).toHaveBeenCalled()); + act(() => { + statusHandlers.forEach((handler) => handler("open")); + }); + const firstSidebar = screen.getByRole("navigation", { name: "Sidebar navigation" }); + fireEvent.pointerDown(within(firstSidebar).getByRole("button", { + name: "Topic actions for Solo pane", + }), { button: 0, ctrlKey: false }); + fireEvent.click(await screen.findByRole("menuitem", { name: "Create group" })); + await waitFor(() => expect(persistedState?.workbench.tabs["tab:websocket:solo"]) + .toEqual(expect.objectContaining({ explicit: true }))); + + firstRender.unmount(); + connectSpy.mockClear(); + + render(); + await waitFor(() => expect(connectSpy).toHaveBeenCalled()); + const secondSidebar = screen.getByRole("navigation", { name: "Sidebar navigation" }); + expect(await within(secondSidebar).findByRole("button", { name: "Tab: Solo pane" })) + .toBeInTheDocument(); + }); + it("keeps panes and layout scoped to the current topic tab", async () => { vi.stubGlobal("matchMedia", vi.fn((query: string) => ({ matches: false, @@ -3062,13 +3266,16 @@ describe("App layout", () => { const grid = await screen.findByTestId("pane-grid"); expect(Array.from(grid.children).map((pane) => pane.getAttribute("aria-label"))) .toEqual(["Alpha"]); + expect(screen.queryByRole("button", { name: "Pane layout" })) + .not.toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "Add pane" })); expect(screen.queryByRole("dialog", { name: "Search" })).not.toBeInTheDocument(); await waitFor(() => expect(createChatSpy).toHaveBeenCalledTimes(1)); await waitFor(() => expect(grid.children).toHaveLength(2)); - expect(window.location.hash).toBe("#/chat/websocket%3Achat-alpha"); + expect(screen.getByRole("button", { name: "Pane layout" })).toBeInTheDocument(); + expect(window.location.hash).toBe("#/chat/websocket%3Achat-pane"); expect(Array.from(grid.children).map((pane) => pane.getAttribute("aria-label"))) .toEqual(["Alpha", "New topic"]); @@ -3076,6 +3283,7 @@ describe("App layout", () => { const paneInput = within(activeComposer).getByRole("textbox", { name: "Message New topic", }); + expect(paneInput).toHaveClass("min-h-[50px]"); fireEvent.change(paneInput, { target: { value: "route this to the new pane" } }); fireEvent.keyDown(paneInput, { key: "Enter" }); await waitFor(() => expect(sendMessageSpy).toHaveBeenCalled()); @@ -3115,7 +3323,7 @@ describe("App layout", () => { name: "New topic pane actions", }), { button: 0, ctrlKey: false }); fireEvent.click(screen.getByRole("menuitem", { - name: "Move New topic to a new tab", + name: "Remove", })); await waitFor(() => expect(screen.getByTestId("pane-grid").children).toHaveLength(1)); expect(within(sidebar).getAllByRole("button", { name: "New topic" })).toHaveLength(2); diff --git a/webui/src/tests/chat-list.test.tsx b/webui/src/tests/chat-list.test.tsx index 92fd58ec3..ee67baca8 100644 --- a/webui/src/tests/chat-list.test.tsx +++ b/webui/src/tests/chat-list.test.tsx @@ -1,8 +1,7 @@ -import { createEvent, fireEvent, render, screen, within } from "@testing-library/react"; +import { fireEvent, render, screen, within } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { ChatList } from "@/components/ChatList"; -import { PANE_DRAG_TYPE, SESSION_DRAG_TYPE } from "@/lib/session-drag"; import type { ChatSummary } from "@/lib/types"; function session(overrides: Partial): ChatSummary { @@ -18,69 +17,93 @@ function session(overrides: Partial): ChatSummary { }; } -function rect({ - left, - top, - width, - height, -}: { - left: number; - top: number; - width: number; - height: number; -}): DOMRect { - return { - x: left, - y: top, - left, - top, - width, - height, - right: left + width, - bottom: top + height, - toJSON: () => ({}), - } as DOMRect; -} - -function dragOverAt( - element: Element, - clientY: number, - dataTransfer: Record, -): void { - const event = createEvent.dragOver(element, { dataTransfer }); - Object.defineProperty(event, "clientY", { value: clientY }); - fireEvent(element, event); -} - -function dropAt( - element: Element, - clientY: number, - dataTransfer: Record, -): void { - const event = createEvent.drop(element, { dataTransfer }); - Object.defineProperty(event, "clientY", { value: clientY }); - fireEvent(element, event); -} - describe("ChatList", () => { afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); }); - it("exposes chats as drag sources", () => { - const dataTransfer = { - effectAllowed: "", - setData: vi.fn(), - setDragImage: vi.fn(), - }; + it("keeps tabs and panes outside every drag-and-drop protocol", () => { + render( + , + ); + + expect(screen.getByRole("button", { name: "Tab: Root topic" })) + .toHaveAttribute("draggable", "false"); + expect(screen.getByRole("button", { name: "Research pane" })) + .toHaveAttribute("draggable", "false"); + expect(document.querySelector("[data-pane-drag-overlay]")).not.toBeInTheDocument(); + expect(document.querySelector("[data-pane-snap-slot]")).not.toBeInTheDocument(); + }); + + it("creates a visible tab in place and only moves panes into visible tabs", async () => { + const onAttachPane = vi.fn(); + const onCreateTab = vi.fn(); render( { />, ); - expect(screen.getByRole("button", { name: "Active chat" })) - .toHaveAttribute("draggable", "true"); - const reference = screen.getByRole("button", { name: "Reference chat" }); - expect(reference).toHaveAttribute("draggable", "true"); - - fireEvent.dragStart(reference, { dataTransfer }); - - expect(dataTransfer.setData).toHaveBeenCalledWith( - SESSION_DRAG_TYPE, - "websocket:reference", - ); - expect(dataTransfer.setDragImage).toHaveBeenCalled(); - expect(document.querySelector("[data-pane-drag-overlay]")) - .toHaveTextContent("Reference chat"); - expect(reference.closest("li")).not.toHaveClass("opacity-0"); - expect(document.querySelector("[data-tab-drag-placeholder]")) + expect(screen.queryByRole("button", { name: "Tab: Solo pane" })) + .not.toBeInTheDocument(); + expect(screen.getAllByText("Solo pane")).toHaveLength(1); + expect(screen.queryByRole("list", { name: "Panes in Solo pane" })) .not.toBeInTheDocument(); - fireEvent.dragEnd(reference, { dataTransfer }); - expect(document.querySelector("[data-pane-drag-overlay]")).not.toBeInTheDocument(); - }); - it("reorders topics with a displaced-neighbor preview instead of an insertion line", () => { - vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockReturnValue(rect({ - left: 0, - top: 0, - width: 284, - height: 32, - })); - const onReorderSessions = vi.fn(); - const sessions = [ - session({ chatId: "alpha", title: "Alpha" }), - session({ chatId: "bravo", title: "Bravo" }), - session({ chatId: "charlie", title: "Charlie" }), - session({ chatId: "old-a", title: "Old A" }), - session({ chatId: "old-b", title: "Old B" }), - ]; - const { rerender } = render( - item.key)} - />, - ); - const dataTransfer = { - effectAllowed: "", - dropEffect: "", - setData: vi.fn(), - }; - fireEvent.dragStart(screen.getByRole("button", { name: "Alpha" }), { dataTransfer }); - const charlieRow = screen.getByRole("button", { name: "Charlie" }).closest("li")!; - dragOverAt(charlieRow, 24, dataTransfer); - expect(document.querySelector("[data-session-drop-edge]")).not.toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Bravo" }).closest("li")) - .toHaveAttribute("data-session-displaced", "true"); - expect(charlieRow).toHaveStyle({ transform: "translateY(-32px)" }); - expect(screen.getByRole("button", { name: "Alpha" }).closest("li")) - .toHaveAttribute("data-session-dragging", "true"); - dropAt(charlieRow, 24, dataTransfer); + fireEvent.pointerDown(screen.getByRole("button", { + 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(onReorderSessions).toHaveBeenCalledWith([ - "websocket:bravo", - "websocket:charlie", - "websocket:alpha", - "websocket:old-a", - "websocket:old-b", - ]); - - rerender( - , - ); - const section = screen.getByRole("region", { name: "Topics" }); - const text = section.textContent ?? ""; - expect(text.indexOf("Bravo")).toBeLessThan(text.indexOf("Charlie")); - expect(text.indexOf("Charlie")).toBeLessThan(text.indexOf("Alpha")); + fireEvent.pointerDown(screen.getByRole("button", { + name: "Topic actions for Solo pane", + }), { button: 0, ctrlKey: false }); + const moveTo = await screen.findByRole("menuitem", { name: "Move to" }); + fireEvent.pointerMove(moveTo, { pointerType: "mouse" }); + expect(screen.queryByRole("menuitem", { name: "Target pane" })) + .not.toBeInTheDocument(); + const fullTarget = await screen.findByRole("menuitem", { + name: "Existing group · 4/4", + }); + expect(fullTarget).toHaveAttribute("aria-disabled", "true"); + fireEvent.click(fullTarget); + expect(onAttachPane).not.toHaveBeenCalled(); + fireEvent.click(await screen.findByRole("menuitem", { name: "Fine group · 1/4" })); + expect(onAttachPane).toHaveBeenCalledWith("websocket:solo", "tab:fine"); }); it("shows every tab's pane membership in a sidebar tab group", async () => { const onSelect = vi.fn(); const onSelectPane = vi.fn(); const onDetachPane = vi.fn(); - const onPromotePane = vi.fn(); + const onDissolveTab = vi.fn(); const onRequestRename = vi.fn(); const onAttachPane = vi.fn(); @@ -207,7 +159,8 @@ describe("ChatList", () => { activeKey="websocket:root" paneGroups={{ "websocket:root": { - topicKey: "websocket:root", + tabKey: "websocket:root", + title: "Root topic", activePaneKey: "websocket:child", panes: [ { key: "websocket:root", chatId: "root", title: "Root topic" }, @@ -215,7 +168,8 @@ describe("ChatList", () => { ], }, "websocket:target": { - topicKey: "websocket:target", + tabKey: "websocket:target", + title: "Target tab", activePaneKey: "websocket:target-child", panes: [ { key: "websocket:target", chatId: "target", title: "Target tab" }, @@ -230,8 +184,7 @@ describe("ChatList", () => { onSelect={onSelect} onSelectPane={onSelectPane} onDetachPane={onDetachPane} - onPromotePane={onPromotePane} - paneAcceptingTabKeys={["websocket:target"]} + onDissolveTab={onDissolveTab} onAttachPane={onAttachPane} onRequestDelete={vi.fn()} onTogglePin={vi.fn()} @@ -265,69 +218,71 @@ describe("ChatList", () => { expect(onSelect).not.toHaveBeenCalled(); onSelectPane.mockClear(); - fireEvent.click(screen.getByRole("button", { name: "Tab: Root topic" })); + const rootTab = screen.getByRole("button", { name: "Tab: Root topic" }); + fireEvent.click(rootTab); expect(onSelectPane).not.toHaveBeenCalled(); + expect(onSelect).not.toHaveBeenCalled(); + expect(rootTab).toHaveAttribute("aria-expanded", "false"); + fireEvent.click(rootTab); + expect(rootTab).toHaveAttribute("aria-expanded", "true"); + + fireEvent.pointerDown(screen.getByRole("button", { + name: "Topic actions for Root topic", + }), { button: 0, ctrlKey: false }); + expect(await screen.findByRole("menuitem", { name: "Dissolve group" })) + .toBeInTheDocument(); + expect(screen.queryByRole("menuitem", { name: "Select" })) + .not.toBeInTheDocument(); + fireEvent.click(screen.getByRole("menuitem", { name: "Dissolve group" })); + expect(onDissolveTab).toHaveBeenCalledWith("websocket:root"); fireEvent.pointerDown(screen.getByRole("button", { name: "Research pane pane actions", }), { button: 0, ctrlKey: false }); - const moveToTab = await screen.findByRole("menuitem", { name: "Move to tab" }); - fireEvent.pointerMove(moveToTab, { pointerType: "mouse" }); - fireEvent.click(await screen.findByRole("menuitem", { name: "Target tab" })); + const moveTo = await screen.findByRole("menuitem", { name: "Move to" }); + fireEvent.pointerMove(moveTo, { pointerType: "mouse" }); + fireEvent.click(await screen.findByRole("menuitem", { name: "Target tab · 2/4" })); expect(onAttachPane).toHaveBeenCalledWith("websocket:child", "websocket:target"); fireEvent.pointerDown(screen.getByRole("button", { name: "Research pane pane actions", }), { button: 0, ctrlKey: false }); fireEvent.click(await screen.findByRole("menuitem", { - name: "Move Research pane to a new tab", + name: "Remove", })); expect(onDetachPane).toHaveBeenCalledWith("websocket:root", "websocket:child"); fireEvent.pointerDown(screen.getByRole("button", { name: "Root topic pane actions", }), { button: 0, ctrlKey: false }); - expect(screen.queryByRole("menuitem", { name: "Move to tab" })) - .not.toBeInTheDocument(); - expect(screen.queryByRole("menuitem", { name: "Move Root topic to a new tab" })) - .not.toBeInTheDocument(); + expect(screen.getByRole("menuitem", { name: "Move to" })) + .toBeInTheDocument(); + expect(screen.getByRole("menuitem", { name: "Remove" })) + .toBeInTheDocument(); fireEvent.keyDown(document, { key: "Escape" }); - const dataTransfer = { - effectAllowed: "", - dropEffect: "", - setData: vi.fn(), - }; - onAttachPane.mockClear(); - fireEvent.dragStart(child, { dataTransfer }); - expect(child.closest("li")).toHaveAttribute("data-pane-dragging", "true"); - expect(child.closest("li")).not.toHaveClass("opacity-0"); - const targetTab = screen.getByRole("button", { name: "Tab: Target tab" }); - dragOverAt(targetTab.closest("li")!, 0, dataTransfer); - expect(targetTab.closest("li")) - .not.toHaveAttribute("data-tab-attach-target"); - expect(targetTab.closest("li")!.querySelector("[data-pane-snap-slot]")) - .not.toBeInTheDocument(); - dropAt(targetTab.closest("li")!, 0, dataTransfer); - expect(dataTransfer.setData).toHaveBeenCalledWith( - PANE_DRAG_TYPE, - JSON.stringify({ - paneKey: "websocket:child", - sourceTabKey: "websocket:root", - }), - ); - expect(onAttachPane).not.toHaveBeenCalled(); - fireEvent.dragEnd(child, { dataTransfer }); + expect(child).toHaveAttribute("draggable", "false"); + expect(screen.getByRole("button", { name: "Tab: Target tab" })) + .toHaveAttribute("draggable", "false"); }); it("collapses a multi-pane tab into one Chrome-style group header", () => { render( { />, ); - const tabGroup = screen.getByRole("button", { name: "Tab: Root topic" }) - .closest("[data-sidebar-tab-group]")!; + const tabButton = screen.getByRole("button", { name: "Tab: Root topic" }); + const tabGroup = tabButton.closest("[data-sidebar-tab-group]")!; + const tabHeader = tabButton.closest("[data-workbench-tab]")!; + const tabSurface = tabButton.closest("[data-workbench-tab-surface]")!; expect(tabGroup).toHaveAttribute("data-sidebar-tab-group", "true"); - expect(within(tabGroup).getByRole("list", { name: "Panes in Root topic" })) - .toBeInTheDocument(); - expect(within(tabGroup).getByRole("button", { name: "Research pane" })) - .toHaveAttribute("aria-current", "true"); + expect(tabHeader).not.toHaveAttribute("data-chat-row"); + expect(tabHeader).not.toHaveAttribute("data-sidebar-pane"); + expect(tabButton).not.toHaveAttribute("aria-current"); + expect(tabButton.querySelector("svg")).not.toBeInTheDocument(); + const paneList = within(tabGroup).getByRole("list", { name: "Panes in Root topic" }); + expect(tabSurface).toContainElement(paneList); + const activePane = within(tabGroup).getByRole("button", { name: "Research pane" }); + expect(activePane).toHaveAttribute("aria-current", "true"); + expect(activePane.closest("[data-sidebar-pane]")).toHaveClass( + "bg-sidebar-selected", + "rounded-[0.65rem]", + ); + expect(screen.getByRole("button", { + name: "Research pane pane actions", + })).toHaveClass("opacity-0"); expect(within(tabGroup).getByRole("button", { name: "Root topic" })) .not.toHaveAttribute("aria-current"); expect(tabGroup).not.toHaveTextContent("2/4"); @@ -367,9 +335,9 @@ describe("ChatList", () => { expect(within(tabGroup).getByRole("button", { name: "Expand panes in Root topic", })).toHaveAttribute("aria-expanded", "false"); - expect(within(tabGroup).getByRole("button", { name: "Tab: Root topic" }) - .closest("[data-sidebar-tab]")) - .toHaveClass("bg-sidebar-selected"); + expect(within(tabGroup).getByRole("button", { name: "Tab: Root topic" })) + .not.toHaveAttribute("aria-current"); + expect(tabSurface).toHaveClass("bg-sidebar-foreground/[0.045]"); fireEvent.click(within(tabGroup).getByRole("button", { name: "Expand panes in Root topic", @@ -380,155 +348,6 @@ describe("ChatList", () => { .toBeInTheDocument(); }); - it("keeps the Pane opaque without exposing a slot in another tab", () => { - vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockReturnValue(rect({ - left: 0, - top: 0, - width: 240, - height: 28, - })); - vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => { - callback(0); - return 1; - }); - const dataTransfer = { - effectAllowed: "", - dropEffect: "", - setData: vi.fn(), - setDragImage: vi.fn(), - }; - const onAttachPane = vi.fn(); - - render( - , - ); - - const pane = screen.getByRole("button", { name: "Research pane" }); - fireEvent.dragStart(pane, { clientX: 40, clientY: 40, dataTransfer }); - expect(dataTransfer.setDragImage).toHaveBeenCalled(); - - const dragOver = createEvent.dragOver( - screen.getByRole("button", { name: "Target tab" }).closest("li")!, - { dataTransfer }, - ); - Object.defineProperties(dragOver, { - clientX: { value: 160 }, - clientY: { value: 120 }, - }); - fireEvent(screen.getByRole("button", { name: "Target tab" }).closest("li")!, dragOver); - - const paneRow = pane.closest("li")!; - const overlay = document.querySelector("[data-pane-drag-overlay]")!; - expect(overlay).toHaveStyle({ - opacity: "1", - height: "28px", - transform: "translate3d(40px, 106px, 0)", - visibility: "visible", - width: "240px", - }); - expect(overlay).toHaveTextContent("Research pane"); - expect(overlay).toHaveClass( - "!bg-sidebar-selected", - "!shadow-none", - ); - expect(overlay).toHaveStyle({ boxShadow: "none" }); - expect(document.querySelector("[data-pane-snap-slot]")).not.toBeInTheDocument(); - expect(paneRow.querySelector("[data-sidebar-pane]")) - .toHaveClass("!bg-transparent", "!text-transparent", "!shadow-none"); - expect(paneRow.style.transform).toBe(""); - expect(paneRow).not.toHaveClass("opacity-0"); - - dataTransfer.dropEffect = "none"; - fireEvent.dragEnd(pane, { clientX: 160, clientY: 120, dataTransfer }); - expect(onAttachPane).not.toHaveBeenCalled(); - expect(document.querySelector("[data-pane-drag-overlay]")).not.toBeInTheDocument(); - }); - - it("repels sibling Panes and snaps the dragged Pane into the selected slot", () => { - vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockReturnValue(rect({ - left: 0, - top: 0, - width: 240, - height: 28, - })); - const onAttachPane = vi.fn(); - const dataTransfer = { - effectAllowed: "", - dropEffect: "", - setData: vi.fn(), - setDragImage: vi.fn(), - }; - - render( - , - ); - - const first = screen.getByRole("button", { name: "First pane" }); - const secondRow = screen.getByRole("button", { name: "Second pane" }).closest("li")!; - fireEvent.dragStart(first, { clientX: 40, clientY: 14, dataTransfer }); - dragOverAt(secondRow, 20, dataTransfer); - - expect(secondRow).toHaveAttribute("data-pane-displaced", "true"); - expect(secondRow).toHaveStyle("transform: translateY(-30px)"); - expect((first.closest("li") as HTMLElement).style.transform).toBe(""); - const snapSlot = screen.getByRole("list", { name: "Panes in Root topic" }) - .querySelector("[data-pane-snap-slot]")!; - expect(snapSlot).toHaveStyle("height: 28px; transform: translateY(60px)"); - expect(snapSlot).toHaveClass("absolute", "bg-transparent"); - expect(first.closest("li")).not.toHaveClass("opacity-0"); - - dropAt(snapSlot, 20, dataTransfer); - expect(onAttachPane).toHaveBeenCalledWith( - "websocket:first", - "websocket:root", - "websocket:third", - ); - }); - it("selects a whole tab or individual panes for one bulk delete", async () => { const onRequestDeleteMany = vi.fn(); render( @@ -540,7 +359,8 @@ describe("ChatList", () => { activeKey="websocket:root" paneGroups={{ "websocket:root": { - topicKey: "websocket:root", + tabKey: "websocket:root", + title: "Root topic", activePaneKey: "websocket:root", panes: [ { key: "websocket:root", chatId: "root", title: "Root topic" }, @@ -558,10 +378,12 @@ describe("ChatList", () => { ); fireEvent.pointerDown(screen.getByRole("button", { - name: "Topic actions for Root topic", + name: "Root topic pane actions", }), { button: 0, ctrlKey: false }); fireEvent.click(await screen.findByRole("menuitem", { name: "Select" })); + expect(screen.getByText("1 selected")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Tab: Root topic" })); expect(screen.getByRole("button", { name: "Tab: Root topic" })) .toHaveAttribute("aria-pressed", "true"); expect(screen.getByRole("button", { name: "Root topic" })) @@ -584,60 +406,6 @@ describe("ChatList", () => { expect(screen.queryByTestId("delete-selection-bar")).not.toBeInTheDocument(); }); - it("reorders one-pane tabs instead of attaching them through drag", () => { - vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockReturnValue(rect({ - left: 0, - top: 0, - width: 284, - height: 32, - })); - const onAttachPane = vi.fn(); - const onReorderSessions = vi.fn(); - const dataTransfer = { - effectAllowed: "", - dropEffect: "", - setData: vi.fn(), - }; - - render( - , - ); - - const detached = screen.getByRole("button", { name: "Detached pane" }); - fireEvent.dragStart(detached, { - dataTransfer, - }); - expect(detached.closest("li")) - .toHaveAttribute("data-session-dragging", "true"); - const target = screen.getByRole("button", { name: "Target tab" }).closest("li")!; - dragOverAt(target, 16, dataTransfer); - expect(target).not.toHaveAttribute("data-tab-attach-target"); - expect(target).toHaveAttribute("data-session-displaced", "true"); - expect(document.querySelector("[data-pane-snap-slot]")).not.toBeInTheDocument(); - dropAt(target, 16, dataTransfer); - - expect(onAttachPane).not.toHaveBeenCalled(); - expect(onReorderSessions).toHaveBeenCalledWith([ - "websocket:target", - "websocket:detached", - ]); - }); - it("shows temporary chats separately and lets the user reopen or close them", async () => { const temporarySession = session({ key: "temporary:temporary-one", @@ -866,14 +634,10 @@ describe("ChatList", () => { ); const activeButton = screen.getByTitle("Active topic"); - const inactiveButton = screen.getByTitle("Inactive topic"); expect(activeButton).toHaveAttribute("aria-current", "page"); expect(activeButton.closest("[data-sidebar-tab]")).toHaveClass( "bg-sidebar-selected", ); - expect(inactiveButton.closest("[data-sidebar-tab]")).not.toHaveClass( - "bg-sidebar-selected", - ); expect(screen.queryByTestId("sessions-selection-highlight")).not.toBeInTheDocument(); rerender( @@ -885,17 +649,9 @@ describe("ChatList", () => { expect(screen.getByTitle("Active topic")).not.toHaveAttribute("aria-current"); expect(screen.getByTitle("Inactive topic")).toHaveAttribute("aria-current", "page"); - expect(screen.getByTitle("Active topic").closest("[data-sidebar-tab]")).not.toHaveClass( - "bg-sidebar-selected", - ); expect(screen.getByTitle("Inactive topic").closest("[data-sidebar-tab]")).toHaveClass( "bg-sidebar-selected", ); - - rerender(); - expect(screen.getByTitle("Inactive topic").closest("[data-sidebar-tab]")).not.toHaveClass( - "bg-sidebar-selected", - ); }); it("can collapse a project group and keeps project rename separate from chat titles", async () => { diff --git a/webui/src/tests/nanobot-client.test.ts b/webui/src/tests/nanobot-client.test.ts index 3be628302..c90bc6dbc 100644 --- a/webui/src/tests/nanobot-client.test.ts +++ b/webui/src/tests/nanobot-client.test.ts @@ -1206,6 +1206,7 @@ describe("NanobotClient", () => { project_name_overrides: {}, tags_by_key: {}, collapsed_groups: {}, + workbench: { version: 1, tabs: {} }, view: { density: "comfortable", show_previews: false, @@ -1243,6 +1244,52 @@ describe("NanobotClient", () => { await expect(pending).resolves.toEqual(state); }); + it("delivers backend sidebar state updates to every subscriber", () => { + const client = new NanobotClient({ + url: "ws://test", + reconnect: false, + socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket, + }); + const handler = vi.fn(); + const state: SidebarStatePayload = { + schema_version: 1, + pinned_keys: [], + archived_keys: [], + session_order: [], + title_overrides: {}, + project_name_overrides: {}, + tags_by_key: {}, + collapsed_groups: {}, + workbench: { + version: 1, + tabs: { + "tab:websocket:a": { + explicit: true, + title: "Research", + paneKeys: ["websocket:a", "websocket:b"], + activePaneKey: "websocket:a", + layout: "columns", + }, + }, + }, + view: { + density: "comfortable", + show_previews: false, + show_timestamps: false, + show_archived: false, + sort: "updated_desc", + }, + updated_at: "2026-08-11T08:00:00Z", + }; + + client.onSidebarStateUpdate(handler); + client.connect(); + lastSocket().fakeOpen(); + lastSocket().fakeMessage({ event: "sidebar_state_updated", state }); + + expect(handler).toHaveBeenCalledWith(state); + }); + it("does not correlate a new-chat scope rejection to an unrelated sent turn", async () => { const client = new NanobotClient({ url: "ws://test", diff --git a/webui/src/tests/pane-tab-drag.test.ts b/webui/src/tests/pane-tab-drag.test.ts deleted file mode 100644 index aa69a21c5..000000000 --- a/webui/src/tests/pane-tab-drag.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { - paneDropSlotForRow, - paneTabDragLayout, - samePaneDropSlot, - type PaneTabDragState, -} from "@/components/pane-tab-drag"; - -function drag(overrides: Partial = {}): PaneTabDragState { - return { - origin: "pane", - item: { paneKey: "pane-a", sourceTabKey: "tab-a" }, - height: 32, - slot: null, - ...overrides, - }; -} - -describe("Pane tab drag state", () => { - it("turns a pointer edge into one stable insertion slot", () => { - const before = paneDropSlotForRow( - "tab-a", - ["pane-a", "pane-b", "pane-c"], - "pane-a", - "pane-b", - "before", - ); - const after = paneDropSlotForRow( - "tab-a", - ["pane-a", "pane-b", "pane-c"], - "pane-a", - "pane-b", - "after", - ); - - expect(before).toEqual({ tabKey: "tab-a", beforePaneKey: "pane-b" }); - expect(after).toEqual({ tabKey: "tab-a", beforePaneKey: "pane-c" }); - expect(samePaneDropSlot(after, { ...after })).toBe(true); - }); - - it("moves the dragged slot and repels siblings inside one tab", () => { - const layout = paneTabDragLayout( - ["pane-a", "pane-b", "pane-c"], - "tab-a", - drag({ slot: { tabKey: "tab-a", beforePaneKey: "pane-c" } }), - ); - - expect(layout.slotIndex).toBe(1); - expect(Object.fromEntries(layout.offsets)).toEqual({ - "pane-b": -34, - }); - }); - - it("does not expose a slot in another tab", () => { - const layout = paneTabDragLayout( - ["pane-x", "pane-y"], - "tab-b", - drag({ slot: { tabKey: "tab-b", beforePaneKey: "pane-y" } }), - ); - - expect(layout.slotIndex).toBe(-1); - expect(Object.fromEntries(layout.offsets)).toEqual({}); - }); -}); diff --git a/webui/src/tests/pane-workbench.test.tsx b/webui/src/tests/pane-workbench.test.tsx index 1f4988b67..d8816a631 100644 --- a/webui/src/tests/pane-workbench.test.tsx +++ b/webui/src/tests/pane-workbench.test.tsx @@ -1,15 +1,18 @@ import { createPortal } from "react-dom"; import { useState } from "react"; -import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { PaneWorkbench } from "@/components/workbench/PaneWorkbench"; import { EMPTY_WORKBENCH_STATE, addWorkbenchPane, + ensureWorkbenchPaneTab, focusWorkbenchPane, setWorkbenchLayout, + setWorkbenchPaneLayoutOrder, workbenchTab, + workbenchTabForPane, } from "@/components/workbench/workbench-model"; function rect(left: number, top: number, width: number, height: number): DOMRect { @@ -26,25 +29,46 @@ function rect(left: number, top: number, width: number, height: number): DOMRect }; } -function WorkbenchHarness() { - const [state, setState] = useState(() => ( - addWorkbenchPane(EMPTY_WORKBENCH_STATE, "alpha", "beta") - )); - const tab = workbenchTab(state, "alpha"); +function WorkbenchHarness({ + initialLayout = "columns", + onPaneOrderChange = () => {}, +}: { + initialLayout?: "columns" | "rows"; + onPaneOrderChange?: (paneKeys: string[]) => void; +} = {}) { + const [state, setState] = useState(() => { + const initial = ensureWorkbenchPaneTab(EMPTY_WORKBENCH_STATE, "alpha"); + const tabKey = workbenchTabForPane(initial, "alpha").tabKey; + return setWorkbenchLayout( + addWorkbenchPane(initial, tabKey, "beta"), + tabKey, + initialLayout, + ); + }); + const tabKey = workbenchTabForPane(state, "alpha").tabKey; + const tab = workbenchTab(state, tabKey); + if (!tab) return null; const titles: Record = { alpha: "Alpha", beta: "Beta" }; return ( ({ key, title: titles[key] }))} + panes={tab.layoutPaneKeys.map((key) => ({ key, title: titles[key] }))} activePaneKey={tab.activePaneKey} layout={tab.layout} + showLayoutControl onActivatePane={(key) => setState((current) => ( - focusWorkbenchPane(current, "alpha", key) + focusWorkbenchPane(current, tabKey, key) ))} onAddPane={vi.fn()} onLayoutChange={(layout) => setState((current) => ( - setWorkbenchLayout(current, "alpha", layout) + setWorkbenchLayout(current, tabKey, layout) ))} + onPaneOrderChange={(paneKeys) => { + onPaneOrderChange(paneKeys); + setState((current) => ( + setWorkbenchPaneLayoutOrder(current, tabKey, paneKeys) + )); + }} renderPane={(pane, context) => ( <> @@ -64,6 +88,26 @@ function WorkbenchHarness() { ); } +function BspWorkbenchHarness() { + const panes = ["alpha", "beta", "gamma", "delta"].map((key) => ({ + key, + title: key, + })); + return ( + {pane.title}} + /> + ); +} + describe("PaneWorkbench", () => { const originalGetBoundingClientRect = HTMLElement.prototype.getBoundingClientRect; const originalAnimate = HTMLElement.prototype.animate; @@ -123,6 +167,89 @@ describe("PaneWorkbench", () => { expect(screen.getByLabelText("Composer Beta")).not.toBeVisible(); }); + it("moves the focused pane between workspace slots from its bottom handle", () => { + const onPaneOrderChange = vi.fn(); + render(); + + const grid = screen.getByTestId("pane-grid"); + const handle = screen.getByRole("button", { name: "Move Beta pane" }); + expect(Array.from(grid.children).map((pane) => pane.getAttribute("aria-label"))) + .toEqual(["Alpha", "Beta"]); + + fireEvent.pointerDown(handle, { button: 0, pointerId: 1, clientX: 750, clientY: 990 }); + fireEvent.pointerMove(window, { + buttons: 1, + pointerId: 1, + clientX: 250, + clientY: 500, + }); + expect(Array.from(grid.children).map((pane) => pane.getAttribute("aria-label"))) + .toEqual(["Beta", "Alpha"]); + expect(onPaneOrderChange).toHaveBeenCalledOnce(); + expect(onPaneOrderChange).toHaveBeenCalledWith(["beta", "alpha"]); + + fireEvent.pointerMove(window, { + buttons: 1, + pointerId: 1, + clientX: 750, + clientY: 500, + }); + expect(Array.from(grid.children).map((pane) => pane.getAttribute("aria-label"))) + .toEqual(["Alpha", "Beta"]); + expect(onPaneOrderChange).toHaveBeenLastCalledWith(["alpha", "beta"]); + fireEvent.pointerUp(window, { pointerId: 1, clientX: 750, clientY: 500 }); + + expect(Array.from(grid.children).map((pane) => pane.getAttribute("aria-label"))) + .toEqual(["Alpha", "Beta"]); + expect(onPaneOrderChange).toHaveBeenCalledTimes(2); + }); + + it("moves panes up and down through stable workspace slots", () => { + const onPaneOrderChange = vi.fn(); + render( + , + ); + + const grid = screen.getByTestId("pane-grid"); + const handle = screen.getByRole("button", { name: "Move Beta pane" }); + fireEvent.pointerDown(handle, { button: 0, pointerId: 1, clientX: 500, clientY: 990 }); + fireEvent.pointerMove(window, { + buttons: 1, + pointerId: 1, + clientX: 500, + clientY: 250, + }); + expect(Array.from(grid.children).map((pane) => pane.getAttribute("aria-label"))) + .toEqual(["Beta", "Alpha"]); + + fireEvent.pointerMove(window, { + buttons: 1, + pointerId: 1, + clientX: 500, + clientY: 750, + }); + expect(Array.from(grid.children).map((pane) => pane.getAttribute("aria-label"))) + .toEqual(["Alpha", "Beta"]); + expect(onPaneOrderChange.mock.calls).toEqual([ + [["beta", "alpha"]], + [["alpha", "beta"]], + ]); + fireEvent.pointerUp(window, { pointerId: 1, clientX: 500, clientY: 750 }); + }); + + it("moves the focused pane between workspace slots with arrow keys", () => { + render(); + + const handle = screen.getByRole("button", { name: "Move Beta pane" }); + act(() => handle.focus()); + expect(handle).toHaveFocus(); + fireEvent.keyDown(handle, { key: "ArrowLeft" }); + + expect(Array.from(screen.getByTestId("pane-grid").children) + .map((pane) => pane.getAttribute("aria-label"))) + .toEqual(["Beta", "Alpha"]); + }); + it("keeps one shared layout control and animates geometry changes", async () => { render(); @@ -135,5 +262,26 @@ describe("PaneWorkbench", () => { fireEvent.click(screen.getByRole("menuitemradio", { name: "Rows" })); expect(screen.getByTestId("pane-grid")).toHaveAttribute("data-layout", "rows"); await waitFor(() => expect(animate).toHaveBeenCalledTimes(2)); + + fireEvent.pointerDown(within(header).getByRole("button", { name: "Pane layout" }), { + button: 0, + ctrlKey: false, + }); + fireEvent.click(screen.getByRole("menuitemradio", { name: "BSP" })); + expect(screen.getByTestId("pane-grid")).toHaveAttribute("data-layout", "bsp"); + await waitFor(() => expect(animate).toHaveBeenCalledTimes(4)); + }); + + it("fills the workbench through alternating binary splits", () => { + render(); + + const alpha = screen.getByTestId("workbench-pane-alpha"); + const beta = screen.getByTestId("workbench-pane-beta"); + const gamma = screen.getByTestId("workbench-pane-gamma"); + const delta = screen.getByTestId("workbench-pane-delta"); + expect([alpha.style.gridColumn, alpha.style.gridRow]).toEqual(["1 / 3", "1 / 5"]); + expect([beta.style.gridColumn, beta.style.gridRow]).toEqual(["3 / 5", "1 / 3"]); + expect([gamma.style.gridColumn, gamma.style.gridRow]).toEqual(["3 / 4", "3 / 5"]); + expect([delta.style.gridColumn, delta.style.gridRow]).toEqual(["4 / 5", "3 / 5"]); }); }); diff --git a/webui/src/tests/useSidebarState.test.tsx b/webui/src/tests/useSidebarState.test.tsx new file mode 100644 index 000000000..dbfaffdff --- /dev/null +++ b/webui/src/tests/useSidebarState.test.tsx @@ -0,0 +1,69 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { describe, expect, it, vi } from "vitest"; + +import { useSidebarState } from "@/hooks/useSidebarState"; +import type { NanobotClient } from "@/lib/nanobot-client"; +import type { ConnectionStatus, SidebarStatePayload } from "@/lib/types"; +import { ClientProvider } from "@/providers/ClientProvider"; + +describe("useSidebarState", () => { + it("serializes full-state writes so an older request cannot overwrite a newer update", async () => { + let resolveFirstWrite: (() => void) | null = null; + let sidebarStateUpdateHandler: ((state: SidebarStatePayload) => void) | null = null; + const setSidebarState = vi.fn() + .mockImplementationOnce((state: SidebarStatePayload) => new Promise( + (resolve) => { + resolveFirstWrite = () => resolve(state); + }, + )) + .mockImplementation(async (state: SidebarStatePayload) => state); + const client = { + status: "open" as const, + onStatus: (_handler: (status: ConnectionStatus) => void) => () => {}, + onSidebarStateUpdate: (handler: (state: SidebarStatePayload) => void) => { + sidebarStateUpdateHandler = handler; + return () => { + sidebarStateUpdateHandler = null; + }; + }, + setSidebarState, + } as unknown as NanobotClient; + vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({}), + })); + const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ); + const { result } = renderHook(() => useSidebarState([], false), { wrapper }); + await waitFor(() => expect(result.current.loading).toBe(false)); + + act(() => { + void result.current.update((current) => ({ + ...current, + title_overrides: { "websocket:a": "First" }, + })); + void result.current.update((current) => ({ + ...current, + title_overrides: { "websocket:a": "Second" }, + })); + }); + + expect(setSidebarState).toHaveBeenCalledTimes(1); + act(() => { + sidebarStateUpdateHandler?.(setSidebarState.mock.calls[0]?.[0]); + }); + expect(result.current.state.title_overrides).toEqual({ + "websocket:a": "Second", + }); + act(() => resolveFirstWrite?.()); + await waitFor(() => expect(setSidebarState).toHaveBeenCalledTimes(2)); + expect(setSidebarState.mock.calls[1]?.[0]).toEqual(expect.objectContaining({ + title_overrides: { "websocket:a": "Second" }, + })); + }); +}); diff --git a/webui/src/tests/workbench-model.test.ts b/webui/src/tests/workbench-model.test.ts index 273f1f4dc..a58b6ef2f 100644 --- a/webui/src/tests/workbench-model.test.ts +++ b/webui/src/tests/workbench-model.test.ts @@ -5,197 +5,274 @@ import { MAX_WORKBENCH_PANES, addWorkbenchPane, attachWorkbenchPane, + createWorkbenchTab, detachWorkbenchPane, - ensureWorkbenchTab, + dissolveWorkbenchTab, + ensureWorkbenchPaneTab, focusWorkbenchPane, - parseWorkbenchState, - promoteWorkbenchPane, + normalizeWorkbenchState, + orderWorkbenchTabs, reconcileWorkbench, + renameWorkbenchTab, setWorkbenchLayout, - workbenchChildPaneKeys, + setWorkbenchPaneLayoutOrder, workbenchTab, + workbenchTabForPane, + type WorkbenchState, } from "@/components/workbench/workbench-model"; -describe("workbench model", () => { - it("gives every topic its own one-pane tab by default", () => { - const state = ensureWorkbenchTab(EMPTY_WORKBENCH_STATE, "topic-a"); +function withPaneTab( + state: WorkbenchState, + paneKey: string, +): [WorkbenchState, string] { + const next = ensureWorkbenchPaneTab(state, paneKey); + return [next, workbenchTabForPane(next, paneKey).tabKey]; +} - expect(workbenchTab(state, "topic-a")).toEqual({ - paneKeys: ["topic-a"], - activePaneKey: "topic-a", +describe("workbench model", () => { + it("creates a virtual tab whose identity is separate from its pane", () => { + const [state, tabKey] = withPaneTab(EMPTY_WORKBENCH_STATE, "pane-a"); + + expect(tabKey).not.toBe("pane-a"); + expect(workbenchTab(state, tabKey)).toEqual({ + explicit: false, + title: null, + paneKeys: ["pane-a"], + layoutPaneKeys: ["pane-a"], + activePaneKey: "pane-a", layout: "columns", }); - expect(state.tabs["topic-b"]).toBeUndefined(); }); - it("keeps pane membership, focus, and layout scoped to a tab", () => { - let state = ensureWorkbenchTab(EMPTY_WORKBENCH_STATE, "topic-a"); - state = ensureWorkbenchTab(state, "topic-b"); - state = addWorkbenchPane(state, "topic-a", "topic-c"); - state = setWorkbenchLayout(state, "topic-a", "main-stack"); + 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"); - expect(workbenchTab(state, "topic-a")).toEqual({ - paneKeys: ["topic-a", "topic-c"], - activePaneKey: "topic-c", + expect(workbenchTab(state, alphaTabKey)).toEqual({ + explicit: false, + title: "Research", + paneKeys: ["pane-a", "pane-c"], + layoutPaneKeys: ["pane-a", "pane-c"], + activePaneKey: "pane-c", layout: "main-stack", }); - expect(workbenchTab(state, "topic-b")).toEqual({ - paneKeys: ["topic-b"], - activePaneKey: "topic-b", + expect(workbenchTab(state, betaTabKey)).toEqual({ + explicit: false, + title: null, + paneKeys: ["pane-b"], + layoutPaneKeys: ["pane-b"], + activePaneKey: "pane-b", layout: "columns", }); }); - it("focuses without reordering and promotes only when asked", () => { - let state = addWorkbenchPane(EMPTY_WORKBENCH_STATE, "topic-a", "topic-b"); - state = addWorkbenchPane(state, "topic-a", "topic-c"); - state = focusWorkbenchPane(state, "topic-a", "topic-b"); + it("focuses a pane without rewriting membership", () => { + 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-b"); - expect(workbenchTab(state, "topic-a").paneKeys).toEqual([ - "topic-a", - "topic-b", - "topic-c", + expect(workbenchTab(state, tabKey)?.paneKeys).toEqual([ + "pane-a", + "pane-b", + "pane-c", ]); - - state = promoteWorkbenchPane(state, "topic-a", "topic-b"); - expect(workbenchTab(state, "topic-a")).toMatchObject({ - paneKeys: ["topic-b", "topic-a", "topic-c"], - activePaneKey: "topic-b", - }); + expect(workbenchTab(state, tabKey)?.activePaneKey).toBe("pane-b"); }); - it("detaches child panes, keeps the root, and chooses the adjacent focus", () => { - let state = addWorkbenchPane(EMPTY_WORKBENCH_STATE, "topic-a", "topic-b"); - state = addWorkbenchPane(state, "topic-a", "topic-c"); - state = focusWorkbenchPane(state, "topic-a", "topic-b"); - state = detachWorkbenchPane(state, "topic-a", "topic-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"); - expect(workbenchTab(state, "topic-a")).toMatchObject({ - paneKeys: ["topic-a", "topic-c"], - activePaneKey: "topic-c", + expect(workbenchTab(state, tabKey)).toMatchObject({ + paneKeys: ["pane-b", "pane-c"], + activePaneKey: "pane-b", }); - - state = detachWorkbenchPane(state, "topic-a", "topic-c"); - state = detachWorkbenchPane(state, "topic-a", "topic-a"); - expect(workbenchTab(state, "topic-a").paneKeys).toEqual(["topic-a"]); + const detached = workbenchTabForPane(state, "pane-a"); + expect(detached.tabKey).not.toBe(tabKey); + expect(detached.tab.paneKeys).toEqual(["pane-a"]); }); - it("moves a pane between tabs and can reattach a one-pane tab", () => { - let state = addWorkbenchPane(EMPTY_WORKBENCH_STATE, "topic-a", "pane-a"); - state = ensureWorkbenchTab(state, "topic-b"); - state = attachWorkbenchPane(state, "topic-b", "pane-a"); + 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); - expect(workbenchTab(state, "topic-a")).toMatchObject({ - paneKeys: ["topic-a"], - activePaneKey: "topic-a", + expect(workbenchTab(state, tabKey)).toEqual({ + explicit: false, + title: null, + paneKeys: ["pane-a"], + layoutPaneKeys: ["pane-a"], + activePaneKey: "pane-a", + layout: "columns", }); - expect(workbenchTab(state, "topic-b")).toMatchObject({ - paneKeys: ["topic-b", "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-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 = ensureWorkbenchTab(state, "topic-c"); - state = attachWorkbenchPane(state, "topic-b", "topic-c"); - expect(state.tabs["topic-c"]).toBeUndefined(); - expect(workbenchTab(state, "topic-b").paneKeys).toEqual([ - "topic-b", - "pane-a", - "topic-c", - ]); + 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", + }); }); - it("places a moved pane into an exact tab slot", () => { - let state = addWorkbenchPane(EMPTY_WORKBENCH_STATE, "topic-a", "pane-a"); - state = addWorkbenchPane(state, "topic-a", "pane-b"); - state = addWorkbenchPane(state, "topic-a", "pane-c"); + 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; - state = attachWorkbenchPane(state, "topic-a", "pane-c", "pane-a"); - expect(workbenchTab(state, "topic-a").paneKeys).toEqual([ - "topic-a", + state = attachWorkbenchPane(state, targetTabKey, "pane-a"); + expect(workbenchTab(state, alphaTabKey)?.paneKeys).toEqual(["pane-b"]); + 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", "pane-b", ]); + }); - state = ensureWorkbenchTab(state, "topic-b"); - state = addWorkbenchPane(state, "topic-b", "pane-d"); - state = attachWorkbenchPane(state, "topic-b", "pane-a", "pane-d"); - expect(workbenchTab(state, "topic-a").paneKeys).toEqual([ - "topic-a", - "pane-c", + it("keeps membership independent from projected display order", () => { + 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"); + + const [ordered] = orderWorkbenchTabs( + state, + ["pane-c", "pane-a", "pane-b"], + new Map(), + ); + expect(workbenchTab(state, tabKey)?.paneKeys).toEqual(["pane-a", "pane-b", "pane-c"]); + 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", - ]); - expect(workbenchTab(state, "topic-b").paneKeys).toEqual([ - "topic-b", + "pane-c", "pane-a", - "pane-d", + ]); + expect(ordered.paneKeys).toEqual(["pane-c", "pane-a", "pane-b"]); + }); + + it("keeps each tab contiguous and ranks it by its latest updated pane", () => { + let state = ensureWorkbenchPaneTab(EMPTY_WORKBENCH_STATE, "pane-a"); + 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"], + 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"], + ]), + ); + + 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", + }, ]); }); - it("does not collapse a multi-pane tab into another tab", () => { - let state = addWorkbenchPane(EMPTY_WORKBENCH_STATE, "topic-a", "pane-a"); - state = ensureWorkbenchTab(state, "topic-b"); - - expect(attachWorkbenchPane(state, "topic-b", "topic-a")).toBe(state); - }); - - it("caps every tab at four panes", () => { - let state = EMPTY_WORKBENCH_STATE; + 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, "topic-a", `pane-${index}`); + state = addWorkbenchPane(state, tabKey, `pane-${index}`); } - expect(workbenchTab(state, "topic-a").paneKeys).toEqual([ - "topic-a", + expect(workbenchTab(state, tabKey)?.paneKeys).toEqual([ + "pane-a", "pane-1", "pane-2", "pane-3", ]); - - const beforeAttach = state; - state = attachWorkbenchPane(state, "topic-a", "standalone"); - expect(state).toBe(beforeAttach); }); - it("identifies only sessions attached beneath another topic", () => { - let state = addWorkbenchPane(EMPTY_WORKBENCH_STATE, "topic-a", "pane-a"); - state = addWorkbenchPane(state, "topic-b", "pane-b"); - - expect(workbenchChildPaneKeys(state)).toEqual(new Set(["pane-a", "pane-b"])); - - state = detachWorkbenchPane(state, "topic-a", "pane-a"); - expect(workbenchChildPaneKeys(state)).toEqual(new Set(["pane-b"])); - }); - - it("repairs persisted state and removes deleted sessions", () => { - const parsed = parseWorkbenchState(JSON.stringify({ - version: 2, + it("repairs duplicates, removes deleted panes, and creates missing tabs", () => { + const state = normalizeWorkbenchState({ + version: 1, tabs: { - "topic-a": { - paneKeys: ["topic-a", "topic-b", "topic-b", 9], + alpha: { + title: "Alpha", + paneKeys: ["pane-a", "pane-b", "pane-b", 9], activePaneKey: "missing", layout: "unknown", }, - deleted: { - paneKeys: ["deleted"], - activePaneKey: "deleted", + duplicate: { + paneKeys: ["pane-b", "deleted"], + activePaneKey: "pane-b", layout: "grid", }, }, - })); - const reconciled = reconcileWorkbench(parsed, new Set(["topic-a"])); - - expect(reconciled).toEqual({ - version: 2, - tabs: { - "topic-a": { - paneKeys: ["topic-a"], - activePaneKey: "topic-a", - layout: "columns", - }, - }, }); - expect(parseWorkbenchState(JSON.stringify({ version: 1, tabs: {} }))) - .toEqual(EMPTY_WORKBENCH_STATE); - expect(parseWorkbenchState("not-json")).toEqual(EMPTY_WORKBENCH_STATE); + const reconciled = reconcileWorkbench( + state, + new Set(["pane-a", "pane-b", "pane-c"]), + ); + + expect(workbenchTab(reconciled, "alpha")).toEqual({ + explicit: false, + title: "Alpha", + paneKeys: ["pane-a", "pane-b"], + layoutPaneKeys: ["pane-a", "pane-b"], + activePaneKey: "pane-a", + layout: "columns", + }); + expect(workbenchTab(reconciled, "duplicate")).toBeNull(); + expect(workbenchTabForPane(reconciled, "pane-c").tab.paneKeys).toEqual(["pane-c"]); }); + });