From 4b5319b7604bcf45b688b2b916185119f3ed9340 Mon Sep 17 00:00:00 2001 From: chengyongru <61816729+chengyongru@users.noreply.github.com> Date: Wed, 12 Aug 2026 14:45:12 +0800 Subject: [PATCH] feat(webui): add tabbed pane workbench (#5322) --- nanobot/channels/websocket/runtime.py | 16 +- .../websocket/tests/test_websocket_channel.py | 57 + nanobot/webui/sidebar_state.py | 76 +- tests/utils/test_webui_sidebar_state.py | 100 +- webui/src/App.tsx | 665 +++++++++-- webui/src/components/ChatList.tsx | 1012 ++++++++++++++--- webui/src/components/DeleteConfirm.tsx | 22 +- webui/src/components/Sidebar.tsx | 31 +- .../src/components/thread/ThreadComposer.tsx | 4 +- webui/src/components/thread/ThreadHeader.tsx | 35 +- webui/src/components/thread/ThreadShell.tsx | 86 +- .../src/components/thread/ThreadViewport.tsx | 97 +- webui/src/components/ui/dropdown-menu.tsx | 41 +- .../components/workbench/PaneWorkbench.tsx | 830 ++++++++++++++ .../components/workbench/workbench-layout.ts | 432 +++++++ .../components/workbench/workbench-model.ts | 431 +++++++ webui/src/globals.css | 15 + webui/src/hooks/useSidebarState.ts | 63 +- webui/src/i18n/locales/en/common.json | 40 + webui/src/i18n/locales/es/common.json | 40 + webui/src/i18n/locales/fr/common.json | 40 + webui/src/i18n/locales/id/common.json | 40 + webui/src/i18n/locales/ja/common.json | 40 + webui/src/i18n/locales/ko/common.json | 40 + webui/src/i18n/locales/pt-BR/common.json | 40 + webui/src/i18n/locales/vi/common.json | 40 + webui/src/i18n/locales/zh-CN/common.json | 40 + webui/src/i18n/locales/zh-TW/common.json | 40 + webui/src/lib/chat-groups.ts | 2 +- webui/src/lib/nanobot-client.ts | 20 + webui/src/lib/types.ts | 20 + webui/src/tests/app-layout.test.tsx | 470 +++++++- webui/src/tests/chat-list.test.tsx | 580 +++++++--- webui/src/tests/nanobot-client.test.ts | 46 + webui/src/tests/pane-workbench.test.tsx | 392 +++++++ webui/src/tests/useSidebarState.test.tsx | 69 ++ webui/src/tests/workbench-model.test.ts | 220 ++++ webui/tailwind.config.js | 1 + 38 files changed, 5706 insertions(+), 527 deletions(-) create mode 100644 webui/src/components/workbench/PaneWorkbench.tsx create mode 100644 webui/src/components/workbench/workbench-layout.ts create mode 100644 webui/src/components/workbench/workbench-model.ts create mode 100644 webui/src/tests/pane-workbench.test.tsx create mode 100644 webui/src/tests/useSidebarState.test.tsx create mode 100644 webui/src/tests/workbench-model.test.ts 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 073aafdf6..298cd8f7f 100644 --- a/nanobot/channels/websocket/tests/test_websocket_channel.py +++ b/nanobot/channels/websocket/tests/test_websocket_channel.py @@ -1037,6 +1037,63 @@ 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"], + "layout": "columns", + "splitRatios": [0.35], + } + }, + } + } + }, + }, + ) + 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", + ] + assert event["state"]["workbench"]["tabs"]["tab:websocket:a"]["splitRatios"] == [ + 0.35 + ] + + @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..c28b95610 100644 --- a/nanobot/webui/sidebar_state.py +++ b/nanobot/webui/sidebar_state.py @@ -8,7 +8,9 @@ does not modify agent sessions. from __future__ import annotations import json +import math import os +import threading import time from pathlib import Path from typing import Any, cast @@ -24,8 +26,11 @@ _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"} +_SIDEBAR_STATE_WRITE_LOCK = threading.Lock() def webui_sidebar_state_path() -> Path: @@ -42,6 +47,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, @@ -76,6 +82,20 @@ def _clean_string_list(value: Any, *, max_len: int = _MAX_KEY_LEN) -> list[str]: return out +def _clean_split_ratios(value: Any) -> list[float]: + if not isinstance(value, list): + return [] + ratios: list[float] = [] + for raw_ratio in cast(list[Any], value)[: _MAX_WORKBENCH_PANES - 1]: + if isinstance(raw_ratio, bool) or not isinstance(raw_ratio, (int, float)): + continue + ratio = float(raw_ratio) + if not math.isfinite(ratio): + continue + ratios.append(round(min(0.95, max(0.05, ratio)), 4)) + return ratios + + def _clean_bool_map(value: Any) -> dict[str, bool]: if not isinstance(value, dict): return {} @@ -131,8 +151,56 @@ 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": {}} + workbench = cast(dict[str, Any], value) + if workbench.get("version") != 1: + return {"version": 1, "tabs": {}} + raw_tabs = workbench.get("tabs") + if not isinstance(raw_tabs, dict): + return {"version": 1, "tabs": {}} + + 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 + explicit = tab.get("explicit") is True + if not explicit and len(pane_keys) == 1: + continue + requested_layout_pane_keys = [ + key for key in _clean_string_list(tab.get("layoutPaneKeys")) if key in pane_keys + ] + 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) + tabs[tab_key] = { + "explicit": explicit, + "title": title, + "paneKeys": pane_keys, + "layoutPaneKeys": layout_pane_keys, + "layout": layout, + "splitRatios": _clean_split_ratios(tab.get("splitRatios")), + } + 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 +214,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 @@ -169,6 +238,11 @@ def read_webui_sidebar_state() -> dict[str, Any]: def write_webui_sidebar_state(raw: dict[str, Any]) -> dict[str, Any]: + with _SIDEBAR_STATE_WRITE_LOCK: + return _write_webui_sidebar_state(raw) + + +def _write_webui_sidebar_state(raw: dict[str, Any]) -> dict[str, Any]: state = normalize_webui_sidebar_state(raw) state["updated_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) encoded = json.dumps( diff --git a/tests/utils/test_webui_sidebar_state.py b/tests/utils/test_webui_sidebar_state.py index d835e9b3e..c61ff52a2 100644 --- a/tests/utils/test_webui_sidebar_state.py +++ b/tests/utils/test_webui_sidebar_state.py @@ -1,5 +1,9 @@ import json +import threading +import time +from concurrent.futures import ThreadPoolExecutor +import nanobot.webui.sidebar_state as sidebar_state from nanobot.webui.sidebar_state import ( default_webui_sidebar_state, read_webui_sidebar_state, @@ -17,7 +21,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 +35,23 @@ 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"], + "layout": "invalid-layout", + "splitRatios": [0.4, 2, "bad", float("nan")], + }, + "tab:websocket:b": { + "paneKeys": ["websocket:b", "websocket:c"], + "layout": "bsp", + }, + }, + }, "view": {"density": "tiny", "show_archived": True, "sort": "nope"}, } ), @@ -47,6 +68,19 @@ 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"], + "layout": "columns", + "splitRatios": [0.4, 0.95], + }, + }, + } assert state["view"] == { "density": "comfortable", "show_previews": False, @@ -80,3 +114,67 @@ def test_sidebar_state_write_is_scoped_to_config_data_dir(tmp_path, monkeypatch) assert state["view"]["sort"] == "manual" assert webui_sidebar_state_path().is_file() assert read_webui_sidebar_state()["pinned_keys"] == ["websocket:a"] + + +def test_sidebar_state_persists_only_visible_workbench_groups(tmp_path, monkeypatch) -> None: + monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path) + tabs = { + f"tab:websocket:{index}": { + "explicit": False, + "paneKeys": [f"websocket:{index}"], + "layoutPaneKeys": [f"websocket:{index}"], + "layout": "columns", + "splitRatios": [], + } + for index in range(2_000) + } + + state = write_webui_sidebar_state({"workbench": {"version": 1, "tabs": tabs}}) + + assert state["workbench"] == {"version": 1, "tabs": {}} + assert webui_sidebar_state_path().stat().st_size < 2_048 + + +def test_sidebar_state_requires_supported_workbench_version(tmp_path, monkeypatch) -> None: + monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path) + + state = write_webui_sidebar_state( + { + "workbench": { + "version": 2, + "tabs": { + "tab:websocket:a": { + "explicit": True, + "paneKeys": ["websocket:a"], + } + }, + } + } + ) + + assert state["workbench"] == {"version": 1, "tabs": {}} + + +def test_sidebar_state_serializes_concurrent_writes(monkeypatch) -> None: + counter_lock = threading.Lock() + active_writes = 0 + peak_writes = 0 + + def fake_write(raw: dict[str, object]) -> dict[str, object]: + nonlocal active_writes, peak_writes + with counter_lock: + active_writes += 1 + peak_writes = max(peak_writes, active_writes) + time.sleep(0.01) + with counter_lock: + active_writes -= 1 + return raw + + monkeypatch.setattr(sidebar_state, "_write_webui_sidebar_state", fake_write) + payloads = [{"write": index} for index in range(12)] + + with ThreadPoolExecutor(max_workers=4) as executor: + results = list(executor.map(sidebar_state.write_webui_sidebar_state, payloads)) + + assert results == payloads + assert peak_writes == 1 diff --git a/webui/src/App.tsx b/webui/src/App.tsx index 316d373e6..5fdfddf47 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -12,8 +12,27 @@ import { Eye, EyeOff, Moon, PanelLeft, ShieldCheck, Sun, X } from "lucide-react" import { useTranslation } from "react-i18next"; import { channelUiPresentation } from "@/channel-plugins/registry"; import { Sidebar } from "@/components/Sidebar"; +import type { SidebarDeleteItem } from "@/components/ChatList"; import type { SettingsSectionKey } from "@/components/settings/SettingsView"; import { ThreadShell } from "@/components/thread/ThreadShell"; +import { PaneWorkbench } from "@/components/workbench/PaneWorkbench"; +import { + MAX_WORKBENCH_PANES, + addWorkbenchPane, + attachWorkbenchPane, + createWorkbenchTab, + detachWorkbenchPane, + dissolveWorkbenchTab, + orderWorkbenchTabs, + reconcileWorkbench, + renameWorkbenchTab, + setWorkbenchLayout, + setWorkbenchPaneLayoutOrder, + setWorkbenchSplitRatios, + workbenchTab, + workbenchTabForPane, + type WorkbenchState, +} from "@/components/workbench/workbench-model"; import { floatingSurfaceElevationClassName } from "@/components/ui/floating-surface"; import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet"; @@ -23,6 +42,7 @@ import { useSidebarState } from "@/hooks/useSidebarState"; import { useSkills } from "@/hooks/useSkills"; import { useLogoFallback } from "@/hooks/useLogoFallback"; import { usePageVisibility } from "@/hooks/usePageVisibility"; +import { useMediaQuery } from "@/hooks/useMediaQuery"; import { ThemeProvider, useTheme } from "@/hooks/useTheme"; import { logoFallbackUrls } from "@/lib/provider-brand"; import { cn } from "@/lib/utils"; @@ -35,7 +55,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"; @@ -1017,7 +1037,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(); @@ -1034,15 +1058,31 @@ function Shell({ const [hostSidebarPreviewOpen, setHostSidebarPreviewOpen] = useState(false); const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); const [sessionSearchOpen, setSessionSearchOpen] = useState(false); + const mobileWorkbench = useMediaQuery("(max-width: 767px)"); + const workbenchState = sidebarState.workbench; + const updateWorkbenchState = useCallback(( + updater: (current: WorkbenchState) => WorkbenchState, + ) => { + void updateSidebarState((current) => { + const next = updater(current.workbench); + return next === current.workbench ? current : { ...current, workbench: next }; + }); + }, [updateSidebarState]); + const lastActivePaneByTabRef = useRef(new Map()); + const [creatingPane, setCreatingPane] = useState(false); + const topicSessions = sessions; const [pendingDelete, setPendingDelete] = useState<{ - key: string; - label: string; + items: SidebarDeleteItem[]; automations?: SessionAutomationJob[]; } | null>(null); const [pendingRename, setPendingRename] = useState<{ key: string; label: string; } | null>(null); + const [pendingTabRename, setPendingTabRename] = useState<{ + key: string; + label: string; + } | null>(null); const [pendingProjectRename, setPendingProjectRename] = useState<{ key: string; label: string; @@ -1220,9 +1260,21 @@ function Shell({ if (temporarySessions[activeKey]) return temporarySessions[activeKey]; return sessions.find((s) => s.key === activeKey) ?? null; }, [sessions, activeKey, temporarySessions]); + const activeTabMatch = useMemo(() => ( + activeKey && !temporarySessions[activeKey] + ? workbenchTabForPane(workbenchState, activeKey) + : null + ), [activeKey, temporarySessions, workbenchState]); + const activeTabKey = activeTabMatch?.tabKey ?? null; + const activeTabState = activeTabMatch?.tab ?? null; + const activePaneSession = activeSession; + useEffect(() => { + if (!activeTabKey || !activeKey || !activeTabState?.paneKeys.includes(activeKey)) return; + lastActivePaneByTabRef.current.set(activeTabKey, activeKey); + }, [activeKey, activeTabKey, activeTabState]); const runningChatIdList = useMemo(() => Array.from(runningChatIds), [runningChatIds]); const updatedChatIdList = useMemo(() => Array.from(updatedChatIds), [updatedChatIds]); - const activeChatId = activeSession?.chatId ?? null; + const activeChatId = activePaneSession?.chatId ?? null; useEffect(() => { activeChatIdRef.current = activeChatId; if (!activeChatId) return; @@ -1242,13 +1294,13 @@ function Shell({ if (activeChatId && workspaceOverrides[activeChatId]) { return workspaceOverrides[activeChatId]; } - if (activeSession?.workspaceScope) { - return activeSession.workspaceScope; + if (activePaneSession?.workspaceScope) { + return activePaneSession.workspaceScope; } return draftWorkspaceScope ?? workspaces?.default_scope ?? null; }, [ activeChatId, - activeSession?.workspaceScope, + activePaneSession?.workspaceScope, draftWorkspaceScope, temporaryChatRequested, workspaceOverrides, @@ -1284,6 +1336,19 @@ function Shell({ }); }, [loading, sessions]); + useEffect(() => { + if (loading || sidebarStateLoading) return; + const validKeys = new Set(sessions.map((session) => session.key)); + updateWorkbenchState((current) => { + return reconcileWorkbench(current, validKeys); + }); + }, [ + loading, + sidebarStateLoading, + sessions, + updateWorkbenchState, + ]); + useEffect(() => { if (loading) return; const pendingCreatedKey = pendingCreatedSessionKeyRef.current; @@ -1715,6 +1780,18 @@ function Shell({ [pendingRename, updateSidebarState], ); + const onRequestRenameTab = useCallback((key: string, label: string) => { + setPendingTabRename({ key, label }); + }, []); + + const onConfirmTabRename = useCallback((title: string) => { + if (!pendingTabRename) return; + updateWorkbenchState((current) => ( + renameWorkbenchTab(current, pendingTabRename.key, title) + )); + setPendingTabRename(null); + }, [pendingTabRename, updateWorkbenchState]); + const onToggleGroup = useCallback( (groupId: string) => { void updateSidebarState((current) => { @@ -1788,7 +1865,7 @@ function Shell({ }); if (activeKey === key && !sidebarState.archived_keys.includes(key)) { const archived = new Set([...sidebarState.archived_keys, key]); - const next = sessions.find((session) => !archived.has(session.key)); + const next = topicSessions.find((session) => !archived.has(session.key)); navigate({ view: "chat", activeKey: next?.key ?? null, @@ -1796,18 +1873,7 @@ function Shell({ }); } }, - [activeKey, navigate, sessions, sidebarState.archived_keys, updateSidebarState], - ); - - const onReorderSessions = useCallback( - (sessionOrder: string[]) => { - void updateSidebarState((current) => ({ - ...current, - session_order: sessionOrder, - view: { ...current.view, sort: "manual" }, - })); - }, - [updateSidebarState], + [activeKey, navigate, sidebarState.archived_keys, topicSessions, updateSidebarState], ); const onToggleArchived = useCallback(() => { @@ -1825,6 +1891,57 @@ function Shell({ setSessionSearchOpen(true); }, []); + const onAddPane = useCallback(async () => { + const tabKey = activeTabKey; + if ( + !tabKey + || !activeKey + || !activeSession + || creatingPane + || (activeTabState?.paneKeys.length ?? 0) >= MAX_WORKBENCH_PANES + || temporarySessionsRef.current[activeKey] + ) return; + setMobileSidebarOpen(false); + setSessionSearchOpen(false); + setCreatingPane(true); + try { + const scope = activeWorkspaceScope; + const chatId = await createChat(scope); + const paneKey = `websocket:${chatId}`; + pendingCreatedSessionKeyRef.current = paneKey; + updateWorkbenchState((current) => addWorkbenchPane(current, activeKey, paneKey)); + navigate({ + view: "chat", + activeKey: paneKey, + settingsSection: "overview", + }); + if (scope) { + setWorkspaceOverrides((current) => ({ + ...current, + [chatId]: normalizeWorkspaceScope(scope), + })); + } + } catch (error) { + console.error("Failed to create pane", error); + if (error instanceof Error && error.message.startsWith("workspace_scope_rejected:")) { + setWorkspaceError(t("errors.workspaceScopeRejected.body")); + } + } finally { + setCreatingPane(false); + } + }, [ + activeKey, + activeSession, + activeTabKey, + activeTabState, + activeWorkspaceScope, + createChat, + creatingPane, + navigate, + t, + updateWorkbenchState, + ]); + useEffect(() => { const handleKeyDown = (event: globalThis.KeyboardEvent) => { if (event.defaultPrevented) return; @@ -1902,15 +2019,15 @@ function Shell({ setMobileSidebarOpen(false); const nextKey = (() => { if (!activeKey) return null; - if (sessions.some((session) => session.key === activeKey)) return activeKey; - return sessions[0]?.key ?? null; + if (topicSessions.some((session) => session.key === activeKey)) return activeKey; + return topicSessions[0]?.key ?? null; })(); navigate({ view: "chat", activeKey: nextKey, settingsSection: "overview", }); - }, [activeKey, navigate, sessions]); + }, [activeKey, navigate, topicSessions]); const onRestart = useCallback(() => { const chatId = activeSession?.chatId ?? client.defaultChatId; @@ -2017,30 +2134,42 @@ function Shell({ }, [client, t]); const onTurnEnd = useDeferredTitleRefresh( - temporaryChatActive ? null : activeSession, + temporaryChatActive ? null : activePaneSession, refresh, ); const onConfirmDelete = useCallback(async () => { if (!pendingDelete) return; - const key = pendingDelete.key; + const items = pendingDelete.items; + const deletingKeys = new Set(items.map((item) => item.key)); const hasAutomations = (pendingDelete.automations?.length ?? 0) > 0; - const deletingActive = activeKey === key; - const currentIndex = sessions.findIndex((s) => s.key === key); + const deletingActive = activeKey !== null && deletingKeys.has(activeKey); + const currentIndex = topicSessions.findIndex((s) => s.key === activeKey); const fallbackKey = deletingActive - ? (sessions[currentIndex + 1]?.key ?? sessions[currentIndex - 1]?.key ?? null) + ? ( + topicSessions.slice(currentIndex + 1).find((session) => ( + !deletingKeys.has(session.key) + ))?.key + ?? topicSessions.slice(0, Math.max(0, currentIndex)).reverse().find((session) => ( + !deletingKeys.has(session.key) + ))?.key + ?? null + ) : activeKey; try { - const result = await deleteChat( - key, - hasAutomations ? { deleteAutomations: true } : undefined, - ); - if (result.blocked_by_automations) { - setPendingDelete({ - ...pendingDelete, - automations: result.automations ?? [], - }); - return; + for (let index = 0; index < items.length; index += 1) { + const item = items[index]; + const result = await deleteChat( + item.key, + hasAutomations ? { deleteAutomations: true } : undefined, + ); + if (result.blocked_by_automations) { + setPendingDelete({ + items: items.slice(index), + automations: result.automations ?? [], + }); + return; + } } setPendingDelete(null); if (deletingActive) { @@ -2053,18 +2182,24 @@ function Shell({ } catch (e) { console.error("Failed to delete session", e); } - }, [pendingDelete, deleteChat, activeKey, navigate, sessions]); + }, [pendingDelete, deleteChat, activeKey, navigate, topicSessions]); - const onRequestDelete = useCallback(async (key: string, label: string) => { - let automations: SessionAutomationJob[] = []; - try { - automations = await getSessionAutomations(key); - } catch { - // Delete remains protected by the backend block; prefetch only improves the first prompt. - } - setPendingDelete({ key, label, automations }); + const onRequestDeleteMany = useCallback(async (items: SidebarDeleteItem[]) => { + const uniqueItems = Array.from(new Map(items.map((item) => [item.key, item])).values()); + if (uniqueItems.length === 0) return; + const automationResults = await Promise.allSettled( + uniqueItems.map((item) => getSessionAutomations(item.key)), + ); + const automations = automationResults.flatMap((result) => ( + result.status === "fulfilled" ? result.value : [] + )); + setPendingDelete({ items: uniqueItems, automations }); }, [getSessionAutomations]); + const onRequestDelete = useCallback((key: string, label: string) => { + void onRequestDeleteMany([{ key, label }]); + }, [onRequestDeleteMany]); + const visiblePairingRequests = useMemo( () => { const now = Date.now(); @@ -2109,13 +2244,218 @@ function Shell({ }); }, []); + const titleForSession = useCallback((session: ChatSummary) => ( + sidebarState.title_overrides[session.key] + || session.title + || 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 - ? sidebarState.title_overrides[activeSession.key] || - activeSession.title || - deriveTitle(activeSession.preview, t("chat.newChat")) + ? titleForSession(activeSession) : t("app.brand"); + const workbenchPaneSessions = useMemo(() => { + if (!activeTabState) return []; + const byKey = new Map(sessions.map((session) => [session.key, session])); + 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); + }, [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) { + return workbenchPaneSessions.map((session) => ({ + key: session.key, + reactKey: session.key === activeTabState?.paneKeys[0] + ? "tab-root" + : `pane:${session.key}`, + title: titleForSession(session), + })); + } + return [{ + key: activeKey ?? "new-topic", + reactKey: "tab-root", + title: headerTitle, + }]; + }, [ + activeKey, + activeTabState?.paneKeys, + headerTitle, + paneChromeEnabled, + titleForSession, + workbenchPaneSessions, + ]); + const renderedActivePaneKey = activeKey ?? renderedWorkbenchPanes[0].key; + const renderedWorkbenchLayout = paneChromeEnabled && activeTabState + ? activeTabState.layout + : "columns"; + const renderedWorkbenchSplitRatios = paneChromeEnabled && activeTabState + ? activeTabState.splitRatios + : []; + const sidebarPaneGroups = useMemo(() => { + const sessionsByKey = new Map(sessions.map((session) => [session.key, session])); + 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) => ({ + key: session.key, + chatId: session.chatId, + title: titleForSession(session), + })); + return [presentation.rowKey, { + tabKey: orderedTab.tabKey, + title: presentation.title, + activePaneKey: activeKey && orderedTab.paneKeys.includes(activeKey) + ? activeKey + : orderedTab.paneKeys[0], + visible: orderedTab.tab.explicit || orderedTab.paneKeys.length > 1, + panes, + }]; + })); + }, [ + activeKey, + sessions, + sidebarTabPresentations, + titleForSession, + ]); + const activePaneLimitReached = Boolean( + activeTabState && activeTabState.paneKeys.length >= MAX_WORKBENCH_PANES, + ); + + const onActivateWorkbenchPane = useCallback((paneKey: string) => { + onSelectChat(paneKey); + }, [onSelectChat]); + + const onSelectSidebarTab = useCallback((tabKey: string) => { + const tab = workbenchTab(workbenchState, tabKey); + if (!tab) return; + const rememberedPaneKey = lastActivePaneByTabRef.current.get(tabKey); + onSelectChat( + rememberedPaneKey && tab.paneKeys.includes(rememberedPaneKey) + ? rememberedPaneKey + : tab.paneKeys[0], + ); + }, [onSelectChat, workbenchState]); + + const onSelectSidebarItem = useCallback((key: string) => { + 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) => { + onSelectChat(paneKey); + }, [onSelectChat]); + + const onDetachWorkbenchPane = useCallback((tabKey: string, paneKey: string) => { + updateWorkbenchState((current) => detachWorkbenchPane(current, tabKey, paneKey)); + }, [updateWorkbenchState]); + + const onCreateWorkbenchTab = useCallback((paneKey: string) => { + updateWorkbenchState((current) => createWorkbenchTab(current, paneKey)); + }, [updateWorkbenchState]); + + const onDissolveWorkbenchTab = useCallback((tabKey: string) => { + updateWorkbenchState((current) => dissolveWorkbenchTab(current, tabKey)); + }, [updateWorkbenchState]); + + const onAttachWorkbenchPane = useCallback(( + paneKey: string, + tabKey: string, + ) => { + updateWorkbenchState((current) => { + const target = workbenchTab(current, tabKey); + if ( + !target + || (!target.explicit && target.paneKeys.length < 2) + || (!target.paneKeys.includes(paneKey) && target.paneKeys.length >= MAX_WORKBENCH_PANES) + ) return current; + return attachWorkbenchPane(current, tabKey, paneKey); + }); + }, [updateWorkbenchState]); useEffect(() => { if (view === "settings") { @@ -2147,20 +2487,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, + 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, - onReorderSessions, + onRequestRenameTab, + paneGroups: sidebarPaneGroups, + onSelectPane: onSelectSidebarPane, + onCreateTab: mobileWorkbench ? undefined : onCreateWorkbenchTab, + onDetachPane: mobileWorkbench ? undefined : onDetachWorkbenchPane, + onDissolveTab: mobileWorkbench ? undefined : onDissolveWorkbenchTab, + onAttachPane: mobileWorkbench ? undefined : onAttachWorkbenchPane, onToggleGroup, onRequestRenameProject, onNewChatInProject, @@ -2172,17 +2541,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: sidebarState.archived_keys.length, + archivedCount: sidebarArchivedTabKeys.length, defaultWorkspacePath: workspaces?.default_scope.project_path ?? null, }; const hostSidebarCollapsed = showHostChrome && !hostSidebarOpen; @@ -2318,7 +2689,7 @@ function Shell({ - { + if (!activeTabKey) return; + updateWorkbenchState((current) => ( + setWorkbenchLayout(current, activeTabKey, layout) + )); + }} + onPaneOrderChange={(paneKeys) => { + if (!activeTabKey) return; + updateWorkbenchState((current) => ( + setWorkbenchPaneLayoutOrder(current, activeTabKey, paneKeys) + )); + }} + onSplitRatiosChange={(splitRatios) => { + if (!activeTabKey) return; + updateWorkbenchState((current) => ( + setWorkbenchSplitRatios(current, activeTabKey, splitRatios) + )); + }} + renderPane={(pane, context) => { + if (!paneChromeEnabled) { + return ( + + ); + } + + const paneSession = workbenchPaneSessions.find( + (session) => session.key === pane.key, + ); + if (!paneSession) return null; + const paneScope = workspaceOverrides[paneSession.chatId] + ?? paneSession.workspaceScope + ?? workspaces?.default_scope + ?? null; + const paneRunning = runningChatIds.has(paneSession.chatId); + return ( + void refresh()} + theme={theme} + onToggleTheme={toggle} + hideSidebarToggle={!context.active} + hideSidebarToggleForHostChrome={context.active} + hostChromeTitleInset={hostSidebarCollapsed} + hideThemeButton={!context.active} + hideHeaderTitle + headerActions={context.headerActions} + headerPortalTarget={context.headerPortalTarget} + headerActive={context.active} + composerPortalTarget={context.composerPortalTarget} + composerActive={context.active} + composerInputAriaLabel={t("workbench.composerAria", { + defaultValue: "Message {{title}}", + title: pane.title, + })} + emptyComposerVariant="thread" + workspaceScope={paneScope} + workspaceDefaultScope={workspaces?.default_scope ?? null} + workspaceControls={workspaces?.controls ?? null} + workspaceScopeDisabled={paneRunning} + workspaceError={context.active ? workspaceError : null} + onWorkspaceScopeChange={(scope) => { + if (paneRunning) return; + const next = normalizeWorkspaceScope(scope); + setWorkspaceError(null); + setWorkspaceOverrides((current) => ({ + ...current, + [paneSession.chatId]: next, + })); + client.setWorkspaceScope(paneSession.chatId, next); + }} + settingsSnapshot={settingsSnapshot} + onOpenModelSettings={onOpenModelSettings} + skills={skills} + /> + ); + }} /> {view !== "chat" && ( @@ -2398,7 +2869,8 @@ function Shell({ setPendingDelete(null)} onConfirm={onConfirmDelete} @@ -2415,6 +2887,19 @@ function Shell({ /> ) : null} + {pendingTabRename ? ( + + setPendingTabRename(null)} + onConfirm={onConfirmTabRename} + /> + + ) : null} {pendingProjectRename ? ( { + try { + const value = JSON.parse(window.localStorage.getItem( + COLLAPSED_PANE_GROUPS_STORAGE_KEY, + ) ?? "[]") as unknown; + return new Set(Array.isArray(value) + ? value.filter((key): key is string => typeof key === "string") + : []); + } catch { + return new Set(); + } +} + +function writeCollapsedPaneGroups(groups: ReadonlySet): void { + try { + window.localStorage.setItem( + COLLAPSED_PANE_GROUPS_STORAGE_KEY, + JSON.stringify(Array.from(groups)), + ); + } catch { + // Local UI preferences should not block the sidebar. + } +} + +interface PaneGroupTarget { + key: string; + title: string; + paneCount: number; + atCapacity: boolean; +} + +export interface SidebarPaneGroup { + tabKey: string; + title: string; + activePaneKey: string; + visible?: boolean; + panes: Array<{ + key: string; + chatId: string; + title: string; + }>; +} + +export interface SidebarDeleteItem { + key: string; + label: string; +} interface ChatListProps { sessions: ChatSummary[]; @@ -59,15 +118,27 @@ interface ChatListProps { onSelect: (key: string) => void; onCloseTemporaryChat?: (key: string) => void; onRequestDelete: (key: string, label: string) => void; + onRequestDeleteMany?: (items: SidebarDeleteItem[]) => void; onTogglePin: (key: string) => void; onRequestRename: (key: string, label: string) => void; + onRequestRenameTab?: (key: string, label: string) => void; onToggleArchive: (key: string) => void; - onReorderSessions?: (keys: string[]) => void; + paneGroups?: Record; + onSelectPane?: (tabKey: string, paneKey: string) => void; + onCreateTab?: (paneKey: string) => void; + onDetachPane?: (tabKey: string, paneKey: string) => void; + onDissolveTab?: (tabKey: string) => void; + onAttachPane?: ( + paneKey: string, + tabKey: 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; @@ -92,15 +163,24 @@ export const ChatList = memo(function ChatList({ onSelect, onCloseTemporaryChat, onRequestDelete, + onRequestDeleteMany, onTogglePin, onRequestRename, + onRequestRenameTab, onToggleArchive, - onReorderSessions, + paneGroups = {}, + onSelectPane, + onCreateTab, + onDetachPane, + onDissolveTab, + onAttachPane, onToggleGroup, onRequestRenameProject, onNewChatInProject, pinnedKeys = [], archivedKeys = [], + pinnedPaneKeys = [], + archivedPaneKeys = [], sessionOrder = [], titleOverrides = {}, projectNameOverrides = {}, @@ -119,12 +199,42 @@ export const ChatList = memo(function ChatList({ }: ChatListProps) { const { t } = useTranslation(); const [visibleLimit, setVisibleLimit] = useState(INITIAL_VISIBLE_SESSIONS); - const [draggedSessionKey, setDraggedSessionKey] = useState(null); - const [sessionDropTarget, setSessionDropTarget] = useState<{ - edge: "before" | "after"; - key: string; - } | null>(null); - const activeRowRef = useRef(null); + const tabRowRefs = useRef(new Map()); + const pendingTabRectsRef = useRef | null>(null); + const tabLayoutAnimationsRef = useRef(new Map()); + const [collapsedPaneGroups, setCollapsedPaneGroups] = useState>( + readCollapsedPaneGroups, + ); + const [deleteSelectionMode, setDeleteSelectionMode] = useState(false); + const [selectedDeleteKeys, setSelectedDeleteKeys] = useState>( + () => new Set(), + ); + const deleteItemsByKey = useMemo(() => { + const items = new Map(); + for (const group of Object.values(paneGroups)) { + for (const pane of group.panes) { + items.set(pane.key, { key: pane.key, label: pane.title }); + } + } + for (const session of sessions) { + if (items.has(session.key)) continue; + items.set(session.key, { + key: session.key, + label: displayTitle(session, titleOverrides, t("chat.newChat")), + }); + } + return items; + }, [paneGroups, sessions, t, titleOverrides]); + 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"), @@ -177,25 +287,103 @@ 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(() => { setVisibleLimit(INITIAL_VISIBLE_SESSIONS); }, [showArchived, sort]); + useEffect(() => { + if (!deleteSelectionMode) return; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Escape") return; + setDeleteSelectionMode(false); + setSelectedDeleteKeys(new Set()); + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [deleteSelectionMode]); + + useEffect(() => { + writeCollapsedPaneGroups(collapsedPaneGroups); + }, [collapsedPaneGroups]); + + useEffect(() => { + if (loading) return; + setCollapsedPaneGroups((current) => { + const next = new Set(Array.from(current).filter((key) => ( + paneGroups[key]?.visible ?? ((paneGroups[key]?.panes.length ?? 0) > 1) + ))); + if (next.size === current.size && Array.from(next).every((key) => current.has(key))) { + return current; + } + return next; + }); + }, [loading, paneGroups]); + + const measureTabRows = useCallback(() => { + const rects = new Map(); + for (const [key, row] of tabRowRefs.current) { + rects.set(key, row.getBoundingClientRect()); + } + return rects; + }, []); + + const captureTabLayout = useCallback(() => { + for (const animation of tabLayoutAnimationsRef.current.values()) animation.cancel(); + tabLayoutAnimationsRef.current.clear(); + pendingTabRectsRef.current = measureTabRows(); + }, [measureTabRows]); + + const togglePaneGroup = useCallback((key: string) => { + captureTabLayout(); + setCollapsedPaneGroups((current) => { + const next = new Set(current); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + }, [captureTabLayout]); + + useLayoutEffect(() => { + const previousRects = pendingTabRectsRef.current; + if (!previousRects) return; + pendingTabRectsRef.current = null; + const nextRects = measureTabRows(); + const reduceMotion = typeof window.matchMedia === "function" + && window.matchMedia("(prefers-reduced-motion: reduce)").matches; + if (reduceMotion) return; + for (const [key, nextRect] of nextRects) { + const previousRect = previousRects.get(key); + const row = tabRowRefs.current.get(key); + if (!previousRect || !row || typeof row.animate !== "function") continue; + const deltaY = previousRect.top - nextRect.top; + if (Math.abs(deltaY) < 0.5) continue; + const animation = row.animate( + [ + { transform: `translateY(${deltaY}px)` }, + { transform: "translateY(0)" }, + ], + { + duration: 180, + easing: "cubic-bezier(0.2, 0, 0, 1)", + }, + ); + tabLayoutAnimationsRef.current.set(key, animation); + animation.addEventListener("finish", () => { + if (tabLayoutAnimationsRef.current.get(key) === animation) { + tabLayoutAnimationsRef.current.delete(key); + } + }, { once: true }); + } + }, [collapsedPaneGroups, measureTabRows]); + + useEffect(() => () => { + for (const animation of tabLayoutAnimationsRef.current.values()) animation.cancel(); + }, []); + if (loading && sessions.length === 0 && temporarySessions.length === 0) { return (
@@ -217,31 +405,43 @@ export const ChatList = memo(function ChatList({ const compact = density === "compact"; const firstProjectGroupIndex = limitedGroups.findIndex((group) => group.kind === "project"); - const canReorderSession = (targetKey: string) => ( - !!draggedSessionKey - && draggedSessionKey !== targetKey - && sessionLanes.get(draggedSessionKey) === sessionLanes.get(targetKey) - ); - 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)), - ]); + const beginDeleteSelection = (keys: string[]) => { + setDeleteSelectionMode(true); + setSelectedDeleteKeys(new Set(keys.filter((key) => deleteItemsByKey.has(key)))); + }; + const toggleDeleteSelection = (keys: string[]) => { + setSelectedDeleteKeys((current) => { + const next = new Set(current); + const validKeys = keys.filter((key) => deleteItemsByKey.has(key)); + const remove = validKeys.length > 0 && validKeys.every((key) => next.has(key)); + for (const key of validKeys) { + if (remove) next.delete(key); + else next.add(key); + } + return next; + }); + }; + const closeDeleteSelection = () => { + setDeleteSelectionMode(false); + setSelectedDeleteKeys(new Set()); + }; + const requestDeleteItems = (items: SidebarDeleteItem[]) => { + if (items.length === 0) return; + if (onRequestDeleteMany) onRequestDeleteMany(items); + else if (items.length === 1) onRequestDelete(items[0].key, items[0].label); + }; + const requestDeleteKeys = (keys: string[]) => { + requestDeleteItems(keys + .map((key) => deleteItemsByKey.get(key)) + .filter((item): item is SidebarDeleteItem => item !== undefined)); + }; + const confirmDeleteSelection = () => { + requestDeleteKeys(Array.from(selectedDeleteKeys)); + closeDeleteSelection(); }; - return (
- @@ -249,7 +449,6 @@ export const ChatList = memo(function ChatList({ COLLAPSED_CHATS_VISIBLE_COUNT; - return (
{index === firstProjectGroupIndex ? ( @@ -298,12 +496,110 @@ export const ChatList = memo(function ChatList({ {group.kind === "project" && collapsedGroups[group.id] ? null : (
    {visibleSessions.map((s) => { - const active = s.key === activeKey; + const topicActive = s.key === activeKey; + const paneGroup = paneGroups[s.key]; + const title = displayTitle(s, titleOverrides, t("chat.newChat")); + const resolvedPaneGroup = paneGroup ?? { + tabKey: s.key, + title, + activePaneKey: s.key, + panes: [{ key: s.key, chatId: s.chatId, title }], + }; + const isWorkbenchTab = paneGroup?.visible + ?? ((paneGroup?.panes.length ?? 0) > 1); + const paneGroupCollapsed = isWorkbenchTab + && collapsedPaneGroups.has(s.key); + const paneGroupId = `sidebar-pane-group-${s.key.replace( + /[^a-zA-Z0-9_-]/g, + "-", + )}`; + const tabDeleteKeys = resolvedPaneGroup.panes.map((pane) => pane.key); + const tabSelected = tabDeleteKeys.every((key) => ( + selectedDeleteKeys.has(key) + )); + 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 title = displayTitle(s, titleOverrides, t("chat.newChat")); const tooltipTitle = titleOverrides[s.key]?.trim() || generatedTitle || @@ -315,122 +611,101 @@ 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) && !active + : updated.has(s.chatId) && !topicActive ? "updated" : null; return (
  • { + if (element) tabRowRefs.current.set(s.key, element); + else tabRowRefs.current.delete(s.key); + }} className="relative min-w-0" - onDragOver={(event) => { - if (!canReorderSession(s.key)) return; - event.preventDefault(); - event.dataTransfer.dropEffect = "move"; - const rect = event.currentTarget.getBoundingClientRect(); - setSessionDropTarget({ - key: s.key, - edge: event.clientY < rect.top + rect.height / 2 ? "before" : "after", - }); - }} - onDrop={(event) => { - if (!canReorderSession(s.key)) return; - event.preventDefault(); - const rect = event.currentTarget.getBoundingClientRect(); - const edge = event.clientY < rect.top + rect.height / 2 - ? "before" - : "after"; - reorderSession(s.key, edge); - setDraggedSessionKey(null); - setSessionDropTarget(null); - }} > - {sessionDropTarget?.key === s.key ? ( - - ) : null}
    - + {!deleteSelectionMode ? ( + @@ -442,9 +717,7 @@ export const ChatList = memo(function ChatList({ portalContainer={actionMenuPortalContainer} onCloseAutoFocus={(event) => event.preventDefault()} > - onTogglePin(s.key)} - > + onTogglePin(s.key)}> {isPinned ? ( ) : ( @@ -458,9 +731,7 @@ export const ChatList = memo(function ChatList({ {t("chat.rename")} - onToggleArchive(s.key)} - > + onToggleArchive(s.key)}> {isArchived ? ( ) : ( @@ -468,10 +739,32 @@ export const ChatList = memo(function ChatList({ )} {isArchived ? t("chat.unarchive") : t("chat.archive")} + {paneGroup && onCreateTab ? ( + onCreateTab(s.key)}> + + {t("workbench.createGroup", { + defaultValue: "Create group", + })} + + ) : null} + {paneGroup && onAttachPane ? ( + ( + target.key !== paneGroup.tabKey + ))} + onMove={(targetKey) => onAttachPane(s.key, targetKey)} + /> + ) : null} + beginDeleteSelection(tabDeleteKeys)} + > + + {t("chat.select", { defaultValue: "Select" })} + { - window.setTimeout(() => onRequestDelete(s.key, title), 0); + window.setTimeout(() => requestDeleteKeys(tabDeleteKeys), 0); }} > @@ -479,6 +772,7 @@ export const ChatList = memo(function ChatList({ + ) : null}
  • ); @@ -510,22 +804,438 @@ export const ChatList = memo(function ChatList({
) : null} - + {deleteSelectionMode ? ( +
+ + + {t("chat.selectedCount", { + defaultValue: "{{count}} selected", + count: selectedDeleteKeys.size, + })} + + +
+ ) : null} +
); }); +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 }, + ); + + return ( +
+ + {!deleteSelectionMode ? ( + <> + + + + + event.preventDefault()} + > + {onRequestRename ? ( + + + {t("chat.rename")} + + ) : null} + {onDissolve ? ( + + + {t("workbench.dissolveTab", { defaultValue: "Dissolve group" })} + + ) : null} + window.setTimeout(onRequestDelete, 0)} + className="whitespace-nowrap" + > + + {t("workbench.deleteConversations", { + defaultValue: "Delete all chats", + })} + + + + + + ) : null} +
+ ); +} + +function ActivePaneRows({ + id, + group, + tabTitle, + tabActive, + compact, + running, + updated, + onSelectPane, + onRequestDelete, + onRequestRename, + onTogglePin, + onToggleArchive, + pinned, + archived, + onDetachPane, + moveTargets, + onAttachPane, + deleteSelectionMode, + selectedDeleteKeys, + onToggleDeleteSelection, + onBeginDeleteSelection, + 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; + moveTargets: PaneGroupTarget[]; + onAttachPane?: ( + paneKey: string, + tabKey: string, + ) => void; + deleteSelectionMode: boolean; + selectedDeleteKeys: ReadonlySet; + onToggleDeleteSelection: (keys: string[]) => void; + onBeginDeleteSelection: (keys: string[]) => void; + actionMenuPortalContainer?: HTMLElement | null; +}) { + const { t } = useTranslation(); + const panes = group.panes; + return ( +
    + {panes.map((pane) => { + const active = tabActive && pane.key === group.activePaneKey; + const activityState = running.has(pane.chatId) + ? "running" + : updated.has(pane.chatId) && !active + ? "updated" + : null; + const paneActionsLabel = t("workbench.paneActions", { + defaultValue: "{{title}} pane actions", + title: pane.title, + }); + const selected = selectedDeleteKeys.has(pane.key); + const isPinned = pinned.has(pane.key); + const isArchived = archived.has(pane.key); + + return ( +
  • +
    + + + {!deleteSelectionMode ? + + + + event.preventDefault()} + > + onTogglePin(pane.key)}> + {isPinned ? ( + + ) : ( + + )} + {isPinned ? t("chat.unpin") : t("chat.pin")} + + onRequestRename(pane.key, pane.title)} + > + + {t("chat.rename")} + + onToggleArchive(pane.key)}> + {isArchived ? ( + + ) : ( + + )} + {isArchived ? t("chat.unarchive") : t("chat.archive")} + + {onDetachPane ? ( + onDetachPane(group.tabKey, pane.key)}> + + {t("workbench.detachPane", { + defaultValue: "Remove", + title: pane.title, + })} + + ) : null} + {onAttachPane ? ( + onAttachPane(pane.key, targetKey)} + /> + ) : null} + onBeginDeleteSelection([pane.key])} + > + + {t("chat.select", { defaultValue: "Select" })} + + { + window.setTimeout(() => onRequestDelete(pane.key, pane.title), 0); + }} + > + + {t("chat.delete")} + + + : null} +
    +
  • + ); + })} +
+ ); +} + +function SelectionIndicator({ + checked, + partial, +}: { + checked: boolean; + partial: boolean; +}) { + const Icon = partial ? SquareMinus : checked ? SquareCheckBig : Square; + return ( + + ); +} + +function MoveToGroupSubmenu({ + targets, + onMove, +}: { + targets: PaneGroupTarget[]; + onMove: (targetKey: string) => void; +}) { + const { t } = useTranslation(); + if (targets.length === 0) return null; + return ( + + + + {t("workbench.moveTo", { defaultValue: "Move to" })} + + + {targets.map((target) => ( + onMove(target.key)} + > + {target.title} + + · {target.paneCount}/{MAX_WORKBENCH_PANES} + + + ))} + + + ); +} + function TemporaryChatSection({ sessions, activeKey, - activeRowRef, running, onSelect, onClose, }: { sessions: ChatSummary[]; activeKey: string | null; - activeRowRef: RefObject; running: ReadonlySet; onSelect: (key: string) => void; onClose?: (key: string) => void; @@ -542,13 +1252,12 @@ function TemporaryChatSection({ return (
  • @@ -631,8 +1340,9 @@ function ProjectGroupHeader({ event.stopPropagation()} @@ -662,7 +1372,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/DeleteConfirm.tsx b/webui/src/components/DeleteConfirm.tsx index f7ccb5c2a..0a01a7f71 100644 --- a/webui/src/components/DeleteConfirm.tsx +++ b/webui/src/components/DeleteConfirm.tsx @@ -18,6 +18,7 @@ import type { SessionAutomationJob } from "@/lib/types"; interface DeleteConfirmProps { open: boolean; title: string; + count?: number; automations?: SessionAutomationJob[]; onCancel: () => void; onConfirm: () => void; @@ -26,6 +27,7 @@ interface DeleteConfirmProps { export function DeleteConfirm({ open, title, + count = 1, automations = [], onCancel, onConfirm, @@ -33,6 +35,7 @@ export function DeleteConfirm({ const { t } = useTranslation(); const locale = currentLocale(); const hasAutomations = automations.length > 0; + const multiple = count > 1; const visibleAutomations = automations.slice(0, 4); const hiddenCount = Math.max(0, automations.length - visibleAutomations.length); return ( @@ -47,12 +50,25 @@ export function DeleteConfirm({
    - {t("deleteConfirm.title", { title })} + {multiple + ? t("deleteConfirm.titleMany", { + defaultValue: "Delete {{count}} conversations?", + count, + }) + : t("deleteConfirm.title", { title })} {hasAutomations - ? t("deleteConfirm.automationsDescription") - : t("deleteConfirm.description")} + ? multiple + ? t("deleteConfirm.automationsDescriptionMany", { + defaultValue: "Linked automations will also be deleted.", + }) + : t("deleteConfirm.automationsDescription") + : multiple + ? t("deleteConfirm.descriptionMany", { + defaultValue: "This action cannot be undone.", + }) + : t("deleteConfirm.description")} {hasAutomations ? (
    diff --git a/webui/src/components/Sidebar.tsx b/webui/src/components/Sidebar.tsx index f41ebcdf6..28e5f5cd2 100644 --- a/webui/src/components/Sidebar.tsx +++ b/webui/src/components/Sidebar.tsx @@ -16,7 +16,11 @@ import { } from "lucide-react"; import { useTranslation } from "react-i18next"; -import { ChatList } from "@/components/ChatList"; +import { + ChatList, + type SidebarDeleteItem, + type SidebarPaneGroup, +} from "@/components/ChatList"; import { ConnectionBadge } from "@/components/ConnectionBadge"; import { SIDEBAR_SELECTION_ACTION_ITEM_CLASS, @@ -39,10 +43,20 @@ interface SidebarProps { onSelect: (key: string) => void; onCloseTemporaryChat?: (key: string) => void; onRequestDelete: (key: string, label: string) => void; + onRequestDeleteMany?: (items: SidebarDeleteItem[]) => void; onTogglePin: (key: string) => void; onRequestRename: (key: string, label: string) => void; + onRequestRenameTab?: (key: string, label: string) => void; onToggleArchive: (key: string) => void; - onReorderSessions: (keys: string[]) => void; + paneGroups?: Record; + onSelectPane?: (tabKey: string, paneKey: string) => void; + onCreateTab?: (paneKey: string) => void; + onDetachPane?: (tabKey: string, paneKey: string) => void; + onDissolveTab?: (tabKey: string) => void; + onAttachPane?: ( + paneKey: string, + tabKey: string, + ) => void; onToggleGroup: (groupId: string) => void; onRequestRenameProject: (projectKey: string, label: string) => void; onNewChatInProject: (projectPath: string, projectName: string) => void; @@ -60,6 +74,8 @@ interface SidebarProps { collapsed?: boolean; pinnedKeys?: string[]; archivedKeys?: string[]; + pinnedPaneKeys?: string[]; + archivedPaneKeys?: string[]; sessionOrder?: string[]; titleOverrides?: Record; projectNameOverrides?: Record; @@ -230,15 +246,24 @@ export function Sidebar(props: SidebarProps) { onSelect={props.onSelect} onCloseTemporaryChat={props.onCloseTemporaryChat} onRequestDelete={props.onRequestDelete} + onRequestDeleteMany={props.onRequestDeleteMany} onTogglePin={props.onTogglePin} onRequestRename={props.onRequestRename} + onRequestRenameTab={props.onRequestRenameTab} onToggleArchive={props.onToggleArchive} - onReorderSessions={props.onReorderSessions} + paneGroups={props.paneGroups} + onSelectPane={props.onSelectPane} + onCreateTab={props.onCreateTab} + onDetachPane={props.onDetachPane} + onDissolveTab={props.onDissolveTab} + onAttachPane={props.onAttachPane} 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/thread/ThreadComposer.tsx b/webui/src/components/thread/ThreadComposer.tsx index 50861de6d..439e9c385 100644 --- a/webui/src/components/thread/ThreadComposer.tsx +++ b/webui/src/components/thread/ThreadComposer.tsx @@ -186,6 +186,7 @@ interface ThreadComposerProps { ) => boolean | void | Promise; disabled?: boolean; placeholder?: string; + inputAriaLabel?: string; isStreaming?: boolean; modelLabel?: string | null; modelDetail?: string | null; @@ -940,6 +941,7 @@ export function ThreadComposer({ onSend, disabled, placeholder, + inputAriaLabel, isStreaming = false, modelLabel = null, modelDetail = null, @@ -2400,7 +2402,7 @@ export function ThreadComposer({ rows={1} placeholder={sessionDragPreview ? "" : resolvedPlaceholder} disabled={interactionDisabled} - aria-label={t("thread.composer.inputAria")} + aria-label={inputAriaLabel ?? t("thread.composer.inputAria")} className={cn( inputTextClasses, "relative z-10 caret-foreground placeholder:text-muted-foreground/70", diff --git a/webui/src/components/thread/ThreadHeader.tsx b/webui/src/components/thread/ThreadHeader.tsx index ef43e96bb..f669f0f33 100644 --- a/webui/src/components/thread/ThreadHeader.tsx +++ b/webui/src/components/thread/ThreadHeader.tsx @@ -17,8 +17,11 @@ interface ThreadHeaderProps { theme: "light" | "dark"; onToggleTheme: () => void; hideSidebarToggleForHostChrome?: boolean; + hideSidebarToggle?: boolean; hostChromeTitleInset?: boolean; hideThemeButton?: boolean; + hideTitle?: boolean; + actions?: ReactNode; minimal?: boolean; promptNavigatorAction?: ReactNode; sessionInfoAction?: ReactNode; @@ -33,8 +36,11 @@ export function ThreadHeader({ theme, onToggleTheme, hideSidebarToggleForHostChrome = false, + hideSidebarToggle = false, hostChromeTitleInset = false, hideThemeButton = false, + hideTitle = false, + actions, minimal = false, promptNavigatorAction, sessionInfoAction, @@ -54,19 +60,21 @@ export function ThreadHeader({ )} >
    - - {!minimal ? ( + {!hideSidebarToggle ? ( + + ) : null} + {!minimal && !hideTitle ? (
    {title}
    @@ -76,6 +84,7 @@ export function ThreadHeader({
    {sessionInfoAction} {promptNavigatorAction} + {actions} {onTemporaryChatEnabledChange ? ( diff --git a/webui/src/components/thread/ThreadShell.tsx b/webui/src/components/thread/ThreadShell.tsx index f53463d1f..edd97a5f6 100644 --- a/webui/src/components/thread/ThreadShell.tsx +++ b/webui/src/components/thread/ThreadShell.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; -import type { PointerEvent as ReactPointerEvent } from "react"; +import type { PointerEvent as ReactPointerEvent, ReactNode } from "react"; +import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; import { FilePreviewAvailabilityProvider } from "@/components/FilePreviewAvailabilityContext"; @@ -311,9 +312,18 @@ interface ThreadShellProps { theme?: "light" | "dark"; onToggleTheme?: () => void; hideSidebarToggleForHostChrome?: boolean; + hideSidebarToggle?: boolean; hostChromeTitleInset?: boolean; hideThemeButton?: boolean; + hideHeaderTitle?: boolean; hideHeader?: boolean; + headerActions?: ReactNode; + headerPortalTarget?: HTMLElement | null; + headerActive?: boolean; + composerPortalTarget?: HTMLElement | null; + composerActive?: boolean; + composerInputAriaLabel?: string; + emptyComposerVariant?: "hero" | "thread"; workspaceScope?: WorkspaceScopePayload | null; workspaceDefaultScope?: WorkspaceScopePayload | null; workspaceControls?: WorkspacesPayload["controls"] | null; @@ -598,9 +608,18 @@ export function ThreadShell({ theme = "light", onToggleTheme = () => {}, hideSidebarToggleForHostChrome = false, + hideSidebarToggle = false, hostChromeTitleInset = false, hideThemeButton = false, + hideHeaderTitle = false, hideHeader = false, + headerActions, + headerPortalTarget, + headerActive = true, + composerPortalTarget, + composerActive = true, + composerInputAriaLabel, + emptyComposerVariant = "hero", workspaceScope = null, workspaceDefaultScope = null, workspaceControls = null, @@ -843,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); @@ -1405,9 +1425,10 @@ export function ThreadShell({ ) : undefined; + const threadHeader = !hideHeader ? ( + + ) : null; + return (
    - {!hideHeader ? ( - - ) : null} + {headerPortalTarget === undefined ? threadHeader : null} @@ -1539,7 +1566,7 @@ export function ThreadShell({ temporary={temporary} isStreaming={turnActive} emptyState={emptyState} - composer={composer} + composer={composerPortalTarget === undefined ? composer : null} activeTurnId={viewportTurnId} activeTurnStartedHere={activeTurnStartedHere} conversationKey={historyKey} @@ -1559,6 +1586,19 @@ export function ThreadShell({ />
    + {headerPortalTarget && headerActive + ? createPortal(threadHeader, headerPortalTarget) + : null} + {composerPortalTarget ? createPortal( + , + composerPortalTarget, + ) : null} {filePreviewPath && historyKey ? ( hiddenMessageCount ? forkBoundaryMessageCount - hiddenMessageCount : null; + const hasComposer = composer !== null && composer !== undefined; const scrollButtonBottom = keyboardInsetBottom + (composerDockHeight > 0 ? composerDockHeight + SCROLL_BUTTON_COMPOSER_GAP_PX - : DEFAULT_SCROLL_BUTTON_BOTTOM_PX); + : hasComposer + ? DEFAULT_SCROLL_BUTTON_BOTTOM_PX + : EXTERNAL_COMPOSER_SCROLL_BUTTON_BOTTOM_PX); const scrollViewportStyle = keyboardInsetBottom > 0 ? { bottom: keyboardInsetBottom } : undefined; @@ -661,7 +665,7 @@ export const ThreadViewport = forwardRef
    ) : ( -
    +
    {emptyState}
    )} -
    { - if (event.target instanceof HTMLTextAreaElement) { - composerInputScrollTopRef.current = scrollRef.current?.scrollTop ?? null; - threadMotionRef.current?.handleComposerInput(); - } - }} - onInput={(event) => { - if (!(event.target instanceof HTMLTextAreaElement)) return; - const previousScrollTop = composerInputScrollTopRef.current; - composerInputScrollTopRef.current = null; - const scrollEl = scrollRef.current; - if (scrollEl && previousScrollTop !== null) { - // Textarea autosizing briefly collapses to `height: auto` while - // measuring. Chrome can clamp the sibling thread scrollport in - // that intermediate layout; restore it before paint, then let - // ResizeObserver handle any real final composer height change. - scrollEl.scrollTop = previousScrollTop; - } - }} - className={cn( - "row-start-2 z-10 w-full", - hasMessages ? "relative bg-background" : "relative self-center", - )} - > + {hasComposer ? (
    { + if (event.target instanceof HTMLTextAreaElement) { + composerInputScrollTopRef.current = scrollRef.current?.scrollTop ?? null; + threadMotionRef.current?.handleComposerInput(); + } + }} + onInput={(event) => { + if (!(event.target instanceof HTMLTextAreaElement)) return; + const previousScrollTop = composerInputScrollTopRef.current; + composerInputScrollTopRef.current = null; + const scrollEl = scrollRef.current; + if (scrollEl && previousScrollTop !== null) { + // Textarea autosizing briefly collapses to `height: auto` while + // measuring. Chrome can clamp the sibling thread scrollport in + // that intermediate layout; restore it before paint, then let + // ResizeObserver handle any real final composer height change. + scrollEl.scrollTop = previousScrollTop; + } + }} className={cn( - hasMessages - ? "px-3 pb-[calc(0.75rem+env(safe-area-inset-bottom))] sm:px-4" - : "", + "row-start-2 z-10 w-full", + hasMessages ? "relative bg-background" : "relative self-center", )} >
    - {composer} +
    + {composer} +
    -
    + ) : null} -
    + {hasComposer ? ( +
    + ) : null}
    {!hasMessages ?
    : null}
    diff --git a/webui/src/components/ui/dropdown-menu.tsx b/webui/src/components/ui/dropdown-menu.tsx index f52b879d2..c6cbec579 100644 --- a/webui/src/components/ui/dropdown-menu.tsx +++ b/webui/src/components/ui/dropdown-menu.tsx @@ -1,6 +1,6 @@ import * as React from "react"; import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; -import { Circle } from "lucide-react"; +import { ChevronRight, Circle } from "lucide-react"; import { floatingItemClassName, @@ -13,6 +13,7 @@ import { cn } from "@/lib/utils"; const DropdownMenu = DropdownMenuPrimitive.Root; const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; +const DropdownMenuSub = DropdownMenuPrimitive.Sub; const menuItemClassName = `${floatingItemClassName} ${floatingItemFocusClassName} cursor-default data-[disabled]:pointer-events-none data-[disabled]:opacity-50`; @@ -115,6 +116,41 @@ const DropdownMenuSeparator = React.forwardRef< )); DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; +const DropdownMenuSubTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)); +DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName; + +const DropdownMenuSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 6, ...props }, ref) => ( + +)); +DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName; + export { DropdownMenu, DropdownMenuContent, @@ -123,5 +159,8 @@ export { DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, DropdownMenuTrigger, }; diff --git a/webui/src/components/workbench/PaneWorkbench.tsx b/webui/src/components/workbench/PaneWorkbench.tsx new file mode 100644 index 000000000..a4ee78f53 --- /dev/null +++ b/webui/src/components/workbench/PaneWorkbench.tsx @@ -0,0 +1,830 @@ +import { + Columns2, + Grid2X2, + PanelLeft, + PanelsTopLeft, + Plus, + Rows2, + type LucideIcon, +} from "lucide-react"; +import { + type FocusEvent, + type KeyboardEvent, + type PointerEvent as ReactPointerEvent, + type ReactNode, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; +import { useTranslation } from "react-i18next"; + +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuLabel, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { + createWorkbenchLayoutGeometry, + resizeHandleRatio, + resizeHandleStyle, + splitRatioBounds, + type WorkbenchResizeHandle, +} from "@/components/workbench/workbench-layout"; +import type { WorkbenchLayout } from "@/components/workbench/workbench-model"; +import { useMediaQuery } from "@/hooks/useMediaQuery"; +import { cn } from "@/lib/utils"; + +export interface WorkbenchPane { + key: string; + reactKey?: string; + title: string; +} + +interface PaneRenderContext { + active: boolean; + headerPortalTarget: HTMLElement | null | undefined; + composerPortalTarget: HTMLElement | null | undefined; + headerActions: ReactNode; +} + +interface PaneWorkbenchProps { + panes: WorkbenchPane[]; + activePaneKey: string; + layout: WorkbenchLayout; + chrome?: boolean; + showLayoutControl: boolean; + addPaneDisabled?: boolean; + addPaneDisabledLabel?: string; + onActivatePane: (key: string) => void; + onAddPane: () => void; + onLayoutChange: (layout: WorkbenchLayout) => void; + onPaneOrderChange: (paneKeys: string[]) => void; + splitRatios?: number[]; + onSplitRatiosChange?: (splitRatios: number[]) => void; + renderPane: (pane: WorkbenchPane, context: PaneRenderContext) => ReactNode; +} + +const LAYOUT_MOTION_DURATION_MS = 260; +const LAYOUT_MOTION_EASING = "cubic-bezier(0.2, 0, 0, 1)"; +const EMPTY_SPLIT_RATIOS: number[] = []; +const IGNORE_SPLIT_RATIO_CHANGE = () => {}; + +const LAYOUT_CONTROLS: Array<{ + icon: LucideIcon; + layout: WorkbenchLayout; + label: string; +}> = [ + { 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" }, +]; + +function isPaneAction(target: EventTarget | null): boolean { + return target instanceof Element + && 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, + disabledLabel, + icon: Icon, + label, + onClick, +}: { + disabled?: boolean; + disabledLabel?: string; + icon: LucideIcon; + label: string; + onClick: () => void; +}) { + return ( + + + + + + + {disabled ? disabledLabel ?? label : label} + + ); +} + +export function PaneWorkbench({ + panes, + activePaneKey, + layout, + chrome = true, + showLayoutControl, + addPaneDisabled = false, + addPaneDisabledLabel, + onActivatePane, + onAddPane, + onLayoutChange, + onPaneOrderChange, + splitRatios = EMPTY_SPLIT_RATIOS, + onSplitRatiosChange = IGNORE_SPLIT_RATIO_CHANGE, + renderPane, +}: PaneWorkbenchProps) { + const { t } = useTranslation(); + const compact = useMediaQuery("(max-width: 767px)"); + const effectiveLayout = layout; + const [headerPortalTarget, setHeaderPortalTarget] = useState(null); + const [composerPortalTarget, setComposerPortalTarget] = useState(null); + const gridRef = useRef(null); + const paneRefs = useRef(new Map()); + const lastRectsRef = useRef(new Map()); + const pendingRectsRef = useRef | null>(null); + const animationsRef = useRef(new Map()); + const sourceSplitRatiosKey = splitRatios.join("\u0000"); + const [previewSplitRatios, setPreviewSplitRatios] = useState(splitRatios); + const previewSplitRatiosRef = useRef(splitRatios); + const resizeGestureRef = useRef<{ + pointerId: number; + handle: WorkbenchResizeHandle; + changed: boolean; + } | null>(null); + const [resizingRatioIndex, setResizingRatioIndex] = useState(null); + 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 orderedPanes = 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 displayedPanes = useMemo(() => { + if (!compact) return orderedPanes; + const activePane = orderedPanes.find((pane) => pane.key === activePaneKey) + ?? orderedPanes[0]; + return activePane ? [activePane] : []; + }, [activePaneKey, compact, orderedPanes]); + const paneOrder = displayedPanes.map((pane) => pane.key).join("\u0000"); + + useEffect(() => { + if (dragGestureRef.current) return; + previewPaneKeysRef.current = sourcePaneOrder; + setPreviewPaneKeys(sourcePaneOrder); + }, [sourcePaneOrder, sourcePaneOrderKey]); + + useEffect(() => { + if (resizeGestureRef.current) return; + previewSplitRatiosRef.current = splitRatios; + setPreviewSplitRatios(splitRatios); + }, [sourceSplitRatiosKey, splitRatios]); + + const measurePanes = useCallback(() => { + const rects = new Map(); + for (const [key, element] of paneRefs.current) { + if (!element.hidden) rects.set(key, element.getBoundingClientRect()); + } + return rects; + }, []); + + const captureLayout = useCallback(() => { + pendingRectsRef.current = measurePanes(); + for (const animation of animationsRef.current.values()) animation.cancel(); + animationsRef.current.clear(); + }, [measurePanes]); + + useLayoutEffect(() => { + const previousRects = pendingRectsRef.current ?? lastRectsRef.current; + pendingRectsRef.current = null; + const nextRects = measurePanes(); + const reduceMotion = typeof window.matchMedia === "function" + && window.matchMedia("(prefers-reduced-motion: reduce)").matches; + + if (!reduceMotion) { + for (const [key, nextRect] of nextRects) { + const previousRect = previousRects.get(key); + const element = paneRefs.current.get(key); + if (!element) continue; + if (!previousRect) { + if (previousRects.size === 0 || typeof element.animate !== "function") continue; + const animation = element.animate( + [ + { opacity: 0, transform: "translateY(5px) scale(0.995)" }, + { opacity: 1, transform: "translateY(0) scale(1)" }, + ], + { + duration: 180, + easing: LAYOUT_MOTION_EASING, + fill: "backwards", + }, + ); + animationsRef.current.set(key, animation); + animation.addEventListener("finish", () => { + if (animationsRef.current.get(key) === animation) { + animationsRef.current.delete(key); + } + }, { once: true }); + continue; + } + if (previousRect.width === 0 || previousRect.height === 0) continue; + const deltaX = previousRect.left - nextRect.left; + const deltaY = previousRect.top - nextRect.top; + const scaleX = previousRect.width / nextRect.width; + const scaleY = previousRect.height / nextRect.height; + if ( + Math.abs(deltaX) < 0.5 + && Math.abs(deltaY) < 0.5 + && Math.abs(scaleX - 1) < 0.002 + && Math.abs(scaleY - 1) < 0.002 + ) { + continue; + } + if (typeof element.animate !== "function") continue; + const animation = element.animate( + [ + { transform: `translate(${deltaX}px, ${deltaY}px) scale(${scaleX}, ${scaleY})` }, + { transform: "translate(0, 0) scale(1, 1)" }, + ], + { + duration: LAYOUT_MOTION_DURATION_MS, + easing: LAYOUT_MOTION_EASING, + }, + ); + animationsRef.current.set(key, animation); + animation.addEventListener("finish", () => { + if (animationsRef.current.get(key) === animation) { + animationsRef.current.delete(key); + } + }, { once: true }); + } + } + lastRectsRef.current = nextRects; + }, [activePaneKey, effectiveLayout, measurePanes, paneOrder]); + + useEffect(() => () => { + for (const animation of animationsRef.current.values()) animation.cancel(); + }, []); + + const activatePane = useCallback((key: string, target: EventTarget | null) => { + if (key === activePaneKey || isPaneAction(target)) return; + captureLayout(); + onActivatePane(key); + }, [activePaneKey, captureLayout, onActivatePane]); + + const handlePanePointerDown = useCallback(( + key: string, + event: ReactPointerEvent, + ) => { + activatePane(key, event.target); + }, [activatePane]); + + const handlePaneFocus = useCallback((key: string, event: FocusEvent) => { + 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 layoutGeometry = useMemo(() => createWorkbenchLayoutGeometry( + effectiveLayout, + displayedPanes.length, + previewSplitRatios, + ), [displayedPanes.length, effectiveLayout, previewSplitRatios]); + + const handleResizePointerDown = useCallback(( + handle: WorkbenchResizeHandle, + event: ReactPointerEvent, + ) => { + if (event.button !== 0 || compact) return; + event.preventDefault(); + event.stopPropagation(); + previewSplitRatiosRef.current = layoutGeometry.splitRatios; + setPreviewSplitRatios(layoutGeometry.splitRatios); + resizeGestureRef.current = { + pointerId: event.pointerId, + handle, + changed: false, + }; + setResizingRatioIndex(handle.ratioIndex); + }, [compact, layoutGeometry.splitRatios]); + + const handleResizePointerMove = useCallback((event: globalThis.PointerEvent) => { + const gesture = resizeGestureRef.current; + const grid = gridRef.current; + if (!gesture || !grid || gesture.pointerId !== event.pointerId) return; + const rect = grid.getBoundingClientRect(); + const axisExtent = gesture.handle.axis === "vertical" ? rect.width : rect.height; + if (axisExtent <= 0) return; + const normalizedPosition = gesture.handle.axis === "vertical" + ? (event.clientX - rect.left) / rect.width + : (event.clientY - rect.top) / rect.height; + const ratio = resizeHandleRatio(gesture.handle, normalizedPosition, axisExtent); + const current = previewSplitRatiosRef.current; + if (Math.abs((current[gesture.handle.ratioIndex] ?? 0) - ratio) < 0.0001) return; + event.preventDefault(); + const next = [...current]; + next[gesture.handle.ratioIndex] = ratio; + gesture.changed = true; + previewSplitRatiosRef.current = next; + setPreviewSplitRatios(next); + }, []); + + const finishResizeGesture = useCallback(() => { + const gesture = resizeGestureRef.current; + if (!gesture) return; + resizeGestureRef.current = null; + setResizingRatioIndex(null); + if (gesture.changed) onSplitRatiosChange([...previewSplitRatiosRef.current]); + }, [onSplitRatiosChange]); + + useEffect(() => { + if (resizingRatioIndex === null) return; + const handlePointerMove = (event: globalThis.PointerEvent) => { + const gesture = resizeGestureRef.current; + if (!gesture || gesture.pointerId !== event.pointerId) return; + if ((event.buttons & 1) === 0) { + finishResizeGesture(); + return; + } + handleResizePointerMove(event); + }; + const handlePointerEnd = (event: globalThis.PointerEvent) => { + if (resizeGestureRef.current?.pointerId === event.pointerId) finishResizeGesture(); + }; + const root = document.documentElement; + const previousCursor = root.style.cursor; + const previousUserSelect = root.style.userSelect; + root.style.cursor = resizeGestureRef.current?.handle.axis === "vertical" + ? "col-resize" + : "row-resize"; + root.style.userSelect = "none"; + window.addEventListener("pointermove", handlePointerMove, { passive: false }); + window.addEventListener("pointerup", handlePointerEnd); + window.addEventListener("pointercancel", handlePointerEnd); + window.addEventListener("blur", finishResizeGesture); + return () => { + root.style.cursor = previousCursor; + root.style.userSelect = previousUserSelect; + window.removeEventListener("pointermove", handlePointerMove); + window.removeEventListener("pointerup", handlePointerEnd); + window.removeEventListener("pointercancel", handlePointerEnd); + window.removeEventListener("blur", finishResizeGesture); + }; + }, [finishResizeGesture, handleResizePointerMove, resizingRatioIndex]); + + const handleResizeKeyDown = useCallback(( + handle: WorkbenchResizeHandle, + event: KeyboardEvent, + ) => { + const decreasing = handle.axis === "vertical" ? event.key === "ArrowLeft" : event.key === "ArrowUp"; + const increasing = handle.axis === "vertical" ? event.key === "ArrowRight" : event.key === "ArrowDown"; + if (!decreasing && !increasing && event.key !== "Home" && event.key !== "End") return; + const rect = gridRef.current?.getBoundingClientRect(); + const axisExtent = handle.axis === "vertical" ? rect?.width : rect?.height; + const bounds = splitRatioBounds(handle, axisExtent && axisExtent > 0 ? axisExtent : 1000); + const current = layoutGeometry.splitRatios[handle.ratioIndex] ?? 0.5; + const step = event.shiftKey ? 0.1 : 0.03; + const nextRatio = event.key === "Home" + ? bounds.min + : event.key === "End" + ? bounds.max + : Math.min(bounds.max, Math.max(bounds.min, current + (increasing ? step : -step))); + event.preventDefault(); + const next = [...layoutGeometry.splitRatios]; + next[handle.ratioIndex] = nextRatio; + previewSplitRatiosRef.current = next; + setPreviewSplitRatios(next); + onSplitRatiosChange(next); + }, [layoutGeometry.splitRatios, onSplitRatiosChange]); + + const gridStyle = layoutGeometry.gridStyle; + const currentLayout = LAYOUT_CONTROLS.find((control) => control.layout === layout) + ?? LAYOUT_CONTROLS[0]; + const headerActions = chrome && !compact ? ( +
    + {showLayoutControl ? ( + + + + + 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, + })} + + ))} + + + + ) : null} + { + captureLayout(); + onAddPane(); + }} + /> +
    + ) : null; + + return ( +
    + + {chrome ? ( +
    +
    +
    + ) : null} +
    +
    1 && "gap-px bg-border/55", + )} + style={gridStyle} + > + {displayedPanes.map((pane, index) => { + const active = pane.key === activePaneKey; + + return ( +
    { + if (element) paneRefs.current.set(pane.key, element); + else paneRefs.current.delete(pane.key); + }} + aria-label={pane.title} + data-active={active ? "true" : "false"} + data-dragging={draggingPaneKey === pane.key ? "true" : undefined} + data-testid={`workbench-pane-${pane.key}`} + onPointerDownCapture={(event) => handlePanePointerDown(pane.key, event)} + onFocusCapture={(event) => handlePaneFocus(pane.key, event)} + className="workbench-pane relative flex min-h-0 min-w-0 overflow-hidden bg-background" + style={layoutGeometry.paneStyles[index]} + > + {renderPane(pane, { + active, + headerPortalTarget: chrome ? headerPortalTarget : undefined, + composerPortalTarget: chrome ? composerPortalTarget : undefined, + headerActions, + })} + {chrome && panes.length > 1 && !compact ? ( + + + + + {active ? ( + + {t("workbench.movePaneHint", { + defaultValue: "Drag to move · Arrow keys also work", + })} + + ) : null} + + ) : null} +
    + ); + })} +
    + {chrome && panes.length > 1 && !compact ? layoutGeometry.resizeHandles.map( + (handle, index) => { + const ratio = layoutGeometry.splitRatios[handle.ratioIndex] ?? 0.5; + const vertical = handle.axis === "vertical"; + const bounds = splitRatioBounds(handle, 1000); + return ( + + ); + }, + ) : null} +
    + + {chrome ? ( +
    +
    +
    + ) : null} +
    +
    + ); +} diff --git a/webui/src/components/workbench/workbench-layout.ts b/webui/src/components/workbench/workbench-layout.ts new file mode 100644 index 000000000..bccab0c5a --- /dev/null +++ b/webui/src/components/workbench/workbench-layout.ts @@ -0,0 +1,432 @@ +import type { CSSProperties } from "react"; + +import type { WorkbenchLayout } from "@/components/workbench/workbench-model"; + +interface PaneCell { + xStart: number; + xEnd: number; + yStart: number; + yEnd: number; +} + +interface Track { + start: number; + end: number; +} + +export interface WorkbenchResizeHandle { + axis: "horizontal" | "vertical"; + ratioIndex: number; + position: number; + crossStart: number; + crossEnd: number; + localStart: number; + localEnd: number; + beforeUnitCount: number; + afterUnitCount: number; +} + +export interface WorkbenchLayoutGeometry { + gridStyle: CSSProperties; + paneStyles: Array; + resizeHandles: WorkbenchResizeHandle[]; + splitRatios: number[]; +} + +const MIN_RATIO = 0.05; +const MAX_RATIO = 0.95; +const MIN_PANE_EXTENT_PX = 160; +const MAIN_PANE_RATIO = 1.65 / 2.65; + +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} + +function ratioAt( + ratios: readonly number[], + index: number, + fallback: number, +): number { + const value = ratios[index]; + return Number.isFinite(value) ? clamp(value, MIN_RATIO, MAX_RATIO) : fallback; +} + +function tracksTemplate(tracks: readonly Track[]): string { + return tracks + .map((track) => { + const weight = Number(((track.end - track.start) * 1000).toFixed(6)); + return `minmax(0, ${weight}fr)`; + }) + .join(" "); +} + +function sequentialTracks( + count: number, + ratios: readonly number[], + ratioOffset: number, +): { tracks: Track[]; handles: WorkbenchResizeHandle[]; resolvedRatios: number[] } { + const tracks: Track[] = []; + const handles: WorkbenchResizeHandle[] = []; + const resolvedRatios: number[] = []; + let start = 0; + + for (let index = 0; index < count - 1; index += 1) { + const remainingPaneCount = count - index; + const ratio = ratioAt(ratios, ratioOffset + index, 1 / remainingPaneCount); + const end = start + (1 - start) * ratio; + tracks.push({ start, end }); + handles.push({ + axis: "vertical", + ratioIndex: ratioOffset + index, + position: end, + crossStart: 0, + crossEnd: 1, + localStart: start, + localEnd: 1, + beforeUnitCount: 1, + afterUnitCount: remainingPaneCount - 1, + }); + resolvedRatios.push(ratio); + start = end; + } + tracks.push({ start, end: 1 }); + return { tracks, handles, resolvedRatios }; +} + +function cellStyle( + columnStart: number, + columnEnd: number, + rowStart: number, + rowEnd: number, +): CSSProperties { + return { + gridColumn: `${columnStart} / ${columnEnd}`, + gridRow: `${rowStart} / ${rowEnd}`, + }; +} + +function singlePaneGeometry(): WorkbenchLayoutGeometry { + return { + gridStyle: { + gridTemplateColumns: "minmax(0, 1fr)", + gridTemplateRows: "minmax(0, 1fr)", + }, + paneStyles: [undefined], + resizeHandles: [], + splitRatios: [], + }; +} + +function columnsGeometry( + paneCount: number, + ratios: readonly number[], +): WorkbenchLayoutGeometry { + const split = sequentialTracks(paneCount, ratios, 0); + return { + gridStyle: { + gridTemplateColumns: tracksTemplate(split.tracks), + gridTemplateRows: "minmax(0, 1fr)", + }, + paneStyles: split.tracks.map((_, index) => cellStyle(index + 1, index + 2, 1, 2)), + resizeHandles: split.handles, + splitRatios: split.resolvedRatios, + }; +} + +function rowsGeometry( + paneCount: number, + ratios: readonly number[], +): WorkbenchLayoutGeometry { + const split = sequentialTracks(paneCount, ratios, 0); + return { + gridStyle: { + gridTemplateColumns: "minmax(0, 1fr)", + gridTemplateRows: tracksTemplate(split.tracks), + }, + paneStyles: split.tracks.map((_, index) => cellStyle(1, 2, index + 1, index + 2)), + resizeHandles: split.handles.map((handle) => ({ + ...handle, + axis: "horizontal", + })), + splitRatios: split.resolvedRatios, + }; +} + +function gridGeometry( + paneCount: number, + ratios: readonly number[], +): WorkbenchLayoutGeometry { + const columnCount = Math.ceil(Math.sqrt(paneCount)); + const rowCount = Math.ceil(paneCount / columnCount); + const columns = sequentialTracks(columnCount, ratios, 0); + const rows = sequentialTracks(rowCount, ratios, columnCount - 1); + const paneCells = Array.from({ length: paneCount }, (_, index) => ({ + column: index % columnCount, + row: Math.floor(index / columnCount), + })); + const verticalHandles = columns.handles.map((handle, boundaryIndex) => ({ + ...handle, + beforeUnitCount: boundaryIndex + 1, + afterUnitCount: columnCount - boundaryIndex - 1, + })).filter((handle) => handle.beforeUnitCount > 0 && handle.afterUnitCount > 0); + const horizontalHandles = rows.handles.map((handle, boundaryIndex) => ({ + ...handle, + axis: "horizontal" as const, + beforeUnitCount: boundaryIndex + 1, + afterUnitCount: rowCount - boundaryIndex - 1, + })).filter((handle) => handle.beforeUnitCount > 0 && handle.afterUnitCount > 0); + + return { + gridStyle: { + gridTemplateColumns: tracksTemplate(columns.tracks), + gridTemplateRows: tracksTemplate(rows.tracks), + }, + paneStyles: paneCells.map((cell) => cellStyle( + cell.column + 1, + cell.column + 2, + cell.row + 1, + cell.row + 2, + )), + resizeHandles: [...verticalHandles, ...horizontalHandles], + splitRatios: [...columns.resolvedRatios, ...rows.resolvedRatios], + }; +} + +function mainStackGeometry( + paneCount: number, + ratios: readonly number[], +): WorkbenchLayoutGeometry { + const columnRatio = ratioAt(ratios, 0, MAIN_PANE_RATIO); + const stack = sequentialTracks(paneCount - 1, ratios, 1); + const verticalHandle: WorkbenchResizeHandle = { + axis: "vertical", + ratioIndex: 0, + position: columnRatio, + crossStart: 0, + crossEnd: 1, + localStart: 0, + localEnd: 1, + beforeUnitCount: 1, + afterUnitCount: 1, + }; + const stackHandles = stack.handles.map((handle) => ({ + ...handle, + axis: "horizontal" as const, + crossStart: columnRatio, + })); + + return { + gridStyle: { + gridTemplateColumns: tracksTemplate([ + { start: 0, end: columnRatio }, + { start: columnRatio, end: 1 }, + ]), + gridTemplateRows: tracksTemplate(stack.tracks), + }, + paneStyles: [ + cellStyle(1, 2, 1, paneCount), + ...stack.tracks.map((_, index) => cellStyle(2, 3, index + 1, index + 2)), + ], + resizeHandles: [verticalHandle, ...stackHandles], + splitRatios: [columnRatio, ...stack.resolvedRatios], + }; +} + +function uniqueBoundaries(values: readonly number[]): number[] { + return Array.from(new Set(values.map((value) => value.toFixed(8)))) + .map(Number) + .sort((left, right) => left - right); +} + +function boundaryIndex(boundaries: readonly number[], value: number): number { + return boundaries.findIndex((candidate) => Math.abs(candidate - value) < 0.0000001); +} + +function axisUnitCount(cells: readonly PaneCell[], axis: "horizontal" | "vertical"): number { + const boundaries = uniqueBoundaries(cells.flatMap((cell) => axis === "vertical" + ? [cell.xStart, cell.xEnd] + : [cell.yStart, cell.yEnd])); + return Math.max(1, boundaries.length - 1); +} + +function bspGeometry( + paneCount: number, + ratios: readonly number[], +): WorkbenchLayoutGeometry { + const cells: PaneCell[] = [{ xStart: 0, xEnd: 1, yStart: 0, yEnd: 1 }]; + const handles: WorkbenchResizeHandle[] = []; + const resolvedRatios: number[] = []; + + for (let paneIndex = 1; paneIndex < paneCount; paneIndex += 1) { + const leaf = cells.pop(); + if (!leaf) break; + const ratioIndex = paneIndex - 1; + const ratio = ratioAt(ratios, ratioIndex, 0.5); + resolvedRatios.push(ratio); + if (paneIndex % 2 === 1) { + const position = leaf.xStart + (leaf.xEnd - leaf.xStart) * ratio; + cells.push( + { ...leaf, xEnd: position }, + { ...leaf, xStart: position }, + ); + handles.push({ + axis: "vertical", + ratioIndex, + position, + crossStart: leaf.yStart, + crossEnd: leaf.yEnd, + localStart: leaf.xStart, + localEnd: leaf.xEnd, + beforeUnitCount: 0, + afterUnitCount: 0, + }); + } else { + const position = leaf.yStart + (leaf.yEnd - leaf.yStart) * ratio; + cells.push( + { ...leaf, yEnd: position }, + { ...leaf, yStart: position }, + ); + handles.push({ + axis: "horizontal", + ratioIndex, + position, + crossStart: leaf.xStart, + crossEnd: leaf.xEnd, + localStart: leaf.yStart, + localEnd: leaf.yEnd, + beforeUnitCount: 0, + afterUnitCount: 0, + }); + } + } + + for (const handle of handles) { + if (handle.axis === "vertical") { + const beforeCells = cells.filter((cell) => ( + cell.xStart >= handle.localStart + && cell.xEnd <= handle.position + Number.EPSILON + && cell.yStart >= handle.crossStart + && cell.yEnd <= handle.crossEnd + )); + const afterCells = cells.filter((cell) => ( + cell.xStart >= handle.position - Number.EPSILON + && cell.xEnd <= handle.localEnd + && cell.yStart >= handle.crossStart + && cell.yEnd <= handle.crossEnd + )); + handle.beforeUnitCount = axisUnitCount(beforeCells, handle.axis); + handle.afterUnitCount = axisUnitCount(afterCells, handle.axis); + } else { + const beforeCells = cells.filter((cell) => ( + cell.yStart >= handle.localStart + && cell.yEnd <= handle.position + Number.EPSILON + && cell.xStart >= handle.crossStart + && cell.xEnd <= handle.crossEnd + )); + const afterCells = cells.filter((cell) => ( + cell.yStart >= handle.position - Number.EPSILON + && cell.yEnd <= handle.localEnd + && cell.xStart >= handle.crossStart + && cell.xEnd <= handle.crossEnd + )); + handle.beforeUnitCount = axisUnitCount(beforeCells, handle.axis); + handle.afterUnitCount = axisUnitCount(afterCells, handle.axis); + } + } + + const columnBoundaries = uniqueBoundaries(cells.flatMap((cell) => [cell.xStart, cell.xEnd])); + const rowBoundaries = uniqueBoundaries(cells.flatMap((cell) => [cell.yStart, cell.yEnd])); + const columnTracks = columnBoundaries.slice(0, -1).map((start, index) => ({ + start, + end: columnBoundaries[index + 1], + })); + const rowTracks = rowBoundaries.slice(0, -1).map((start, index) => ({ + start, + end: rowBoundaries[index + 1], + })); + + return { + gridStyle: { + gridTemplateColumns: tracksTemplate(columnTracks), + gridTemplateRows: tracksTemplate(rowTracks), + }, + paneStyles: cells.map((cell) => cellStyle( + boundaryIndex(columnBoundaries, cell.xStart) + 1, + boundaryIndex(columnBoundaries, cell.xEnd) + 1, + boundaryIndex(rowBoundaries, cell.yStart) + 1, + boundaryIndex(rowBoundaries, cell.yEnd) + 1, + )), + resizeHandles: handles, + splitRatios: resolvedRatios, + }; +} + +export function createWorkbenchLayoutGeometry( + layout: WorkbenchLayout, + paneCount: number, + splitRatios: readonly number[], +): WorkbenchLayoutGeometry { + const count = Math.max(1, paneCount); + if (count === 1) return singlePaneGeometry(); + switch (layout) { + case "columns": + return columnsGeometry(count, splitRatios); + case "rows": + return rowsGeometry(count, splitRatios); + case "grid": + return gridGeometry(count, splitRatios); + case "main-stack": + return mainStackGeometry(count, splitRatios); + case "bsp": + return bspGeometry(count, splitRatios); + } +} + +export function splitRatioBounds( + handle: WorkbenchResizeHandle, + axisExtentPx: number, +): { min: number; max: number } { + const localExtentPx = Math.max( + 1, + axisExtentPx * Math.max(0.01, handle.localEnd - handle.localStart), + ); + const paneCount = Math.max(2, handle.beforeUnitCount + handle.afterUnitCount); + const paneExtentPx = Math.min( + MIN_PANE_EXTENT_PX, + localExtentPx * 0.8 / paneCount, + ); + const min = Math.max(MIN_RATIO, paneExtentPx * handle.beforeUnitCount / localExtentPx); + const max = Math.min( + MAX_RATIO, + 1 - paneExtentPx * handle.afterUnitCount / localExtentPx, + ); + return min <= max ? { min, max } : { min: 0.4, max: 0.6 }; +} + +export function resizeHandleRatio( + handle: WorkbenchResizeHandle, + normalizedPosition: number, + axisExtentPx: number, +): number { + const localRatio = (normalizedPosition - handle.localStart) + / Math.max(0.01, handle.localEnd - handle.localStart); + const bounds = splitRatioBounds(handle, axisExtentPx); + return Number(clamp(localRatio, bounds.min, bounds.max).toFixed(4)); +} + +export function resizeHandleStyle(handle: WorkbenchResizeHandle): CSSProperties { + if (handle.axis === "vertical") { + return { + left: `${handle.position * 100}%`, + top: `${handle.crossStart * 100}%`, + height: `${(handle.crossEnd - handle.crossStart) * 100}%`, + transform: "translateX(-50%)", + }; + } + return { + top: `${handle.position * 100}%`, + left: `${handle.crossStart * 100}%`, + width: `${(handle.crossEnd - handle.crossStart) * 100}%`, + transform: "translateY(-50%)", + }; +} diff --git a/webui/src/components/workbench/workbench-model.ts b/webui/src/components/workbench/workbench-model.ts new file mode 100644 index 000000000..6b8db2266 --- /dev/null +++ b/webui/src/components/workbench/workbench-model.ts @@ -0,0 +1,431 @@ +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", +] as const; + +export interface WorkbenchTabMatch { + tabKey: string; + tab: WorkbenchTabState; +} + +export interface OrderedWorkbenchTab extends WorkbenchTabMatch { + paneKeys: string[]; + updatedAt: string | null; +} + +export const EMPTY_WORKBENCH_STATE: WorkbenchState = { + version: 1, + tabs: {}, +}; + +function isLayout(value: unknown): value is WorkbenchLayout { + return typeof value === "string" + && (WORKBENCH_LAYOUTS as readonly string[]).includes(value); +} + +function uniqueKeys(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return Array.from(new Set( + value.filter((key): key is string => typeof key === "string" && key.length > 0), + )); +} + +function normalizeTitle(value: unknown): string | null { + if (typeof value !== "string") return null; + const title = value.trim(); + return title || null; +} + +function normalizeSplitRatios(value: unknown): number[] { + if (!Array.isArray(value)) return []; + return value + .filter((ratio): ratio is number => typeof ratio === "number" && Number.isFinite(ratio)) + .slice(0, MAX_WORKBENCH_PANES - 1) + .map((ratio) => Number(Math.min(0.95, Math.max(0.05, ratio)).toFixed(4))); +} + +function normalizeTab(value: unknown): WorkbenchTabState { + const candidate = value && typeof value === "object" + ? value as Partial + : {}; + 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 { + explicit: candidate.explicit === true, + title: normalizeTitle(candidate.title), + paneKeys, + layoutPaneKeys, + layout: isLayout(candidate.layout) ? candidate.layout : "columns", + splitRatios: normalizeSplitRatios(candidate.splitRatios), + }; +} + +function standaloneTabKeyBase(paneKey: string): string { + return `tab:${paneKey}`; +} + +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 { + explicit: false, + title: normalizeTitle(title), + paneKeys: [paneKey], + layoutPaneKeys: [paneKey], + layout: "columns", + splitRatios: [], + }; +} + +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)] as const) + .filter(([, tab]) => tab.paneKeys.length > 1 || tab.explicit), + ), + }; +} + +export function workbenchTab( + state: WorkbenchState, + tabKey: string, +): 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), + }; +} + +function updateTab( + state: WorkbenchState, + tabKey: string, + update: (tab: WorkbenchTabState) => WorkbenchTabState, +): WorkbenchState { + const current = state.tabs[tabKey]; + if (!current) return state; + const next = update(current); + if (next === current) return state; + return { + version: 1, + tabs: { + ...state.tabs, + [tabKey]: next, + }, + }; +} + +export function addWorkbenchPane( + state: WorkbenchState, + anchorPaneKey: string, + paneKey: string, +): WorkbenchState { + if (!anchorPaneKey || !paneKey || anchorPaneKey === paneKey) return state; + const target = workbenchTabForPane(state, anchorPaneKey); + if (state.tabs[target.tabKey]) return attachWorkbenchPane(state, target.tabKey, paneKey); + const withTarget = { + version: 1 as const, + tabs: { + ...state.tabs, + [target.tabKey]: target.tab, + }, + }; + return attachWorkbenchPane(withTarget, target.tabKey, paneKey); +} + +export function createWorkbenchTab( + state: WorkbenchState, + paneKey: string, +): WorkbenchState { + if (!paneKey) return state; + const match = workbenchTabForPane(state, paneKey); + const persisted = state.tabs[match.tabKey]; + if (persisted) { + return updateTab(state, match.tabKey, (tab) => ( + tab.explicit ? tab : { ...tab, explicit: true } + )); + } + return { + version: 1, + tabs: { + ...state.tabs, + [match.tabKey]: { ...match.tab, explicit: true }, + }, + }; +} + +export function detachWorkbenchPane( + state: WorkbenchState, + tabKey: string, + paneKey: string, +): WorkbenchState { + const tab = state.tabs[tabKey]; + if (!tab || !tab.paneKeys.includes(paneKey)) return state; + if (tab.paneKeys.length === 1) { + const tabs = { ...state.tabs }; + delete tabs[tabKey]; + return { version: 1, tabs }; + } + + const paneKeys = tab.paneKeys.filter((key) => key !== paneKey); + const layoutPaneKeys = tab.layoutPaneKeys.filter((key) => key !== paneKey); + const tabs = { ...state.tabs }; + const nextTab = { + ...tab, + paneKeys, + layoutPaneKeys, + splitRatios: [], + }; + if (nextTab.explicit || paneKeys.length > 1) tabs[tabKey] = nextTab; + else delete tabs[tabKey]; + return { + version: 1, + tabs, + }; +} + +export function dissolveWorkbenchTab( + state: WorkbenchState, + tabKey: string, +): WorkbenchState { + const tab = state.tabs[tabKey]; + if (!tab) return state; + const tabs = { ...state.tabs }; + delete tabs[tabKey]; + return { version: 1, tabs }; +} + +export function attachWorkbenchPane( + state: WorkbenchState, + targetTabKey: string, + paneKey: string, +): WorkbenchState { + 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) + )); + const sourceTabKey = sourceEntry?.[0]; + const sourceTab = sourceEntry?.[1]; + if (sourceTabKey === targetTabKey) { + return state; + } + if (!target.paneKeys.includes(paneKey) && target.paneKeys.length >= MAX_WORKBENCH_PANES) { + return state; + } + + const tabs = { ...state.tabs }; + if (sourceTabKey && sourceTab) { + const sourcePaneKeys = sourceTab.paneKeys.filter((key) => key !== paneKey); + const sourceLayoutPaneKeys = sourceTab.layoutPaneKeys.filter((key) => key !== paneKey); + if (sourcePaneKeys.length === 0 || (!sourceTab.explicit && sourcePaneKeys.length === 1)) { + delete tabs[sourceTabKey]; + } else { + tabs[sourceTabKey] = { + ...sourceTab, + paneKeys: sourcePaneKeys, + layoutPaneKeys: sourceLayoutPaneKeys, + splitRatios: [], + }; + } + } + + 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] = { + ...nextTarget, + paneKeys, + layoutPaneKeys, + splitRatios: [], + }; + return { version: 1, tabs }; +} + +export function renameWorkbenchTab( + state: WorkbenchState, + tabKey: string, + title: string, +): WorkbenchState { + const normalized = normalizeTitle(title); + if (!normalized) return state; + return updateTab(state, tabKey, (tab) => ( + tab.title === normalized ? tab : { ...tab, title: normalized } + )); +} + +export function setWorkbenchLayout( + state: WorkbenchState, + tabKey: string, + layout: WorkbenchLayout, +): WorkbenchState { + return updateTab(state, tabKey, (tab) => ( + tab.layout === layout ? tab : { ...tab, layout, splitRatios: [] } + )); +} + +export function setWorkbenchSplitRatios( + state: WorkbenchState, + tabKey: string, + splitRatios: readonly number[], +): WorkbenchState { + return updateTab(state, tabKey, (tab) => { + const normalized = normalizeSplitRatios(splitRatios); + return normalized.length === tab.splitRatios.length + && normalized.every((ratio, index) => ratio === tab.splitRatios[index]) + ? tab + : { ...tab, splitRatios: normalized }; + }); +} + +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)) { + const paneKeys = tab.paneKeys + .filter((key) => validKeys.has(key) && !claimedPaneKeys.has(key)) + .slice(0, MAX_WORKBENCH_PANES); + if (paneKeys.length === 0) continue; + const nextTab = { + ...tab, + paneKeys, + layoutPaneKeys: [ + ...tab.layoutPaneKeys.filter((key) => paneKeys.includes(key)), + ...paneKeys.filter((key) => !tab.layoutPaneKeys.includes(key)), + ], + splitRatios: paneKeys.length === tab.paneKeys.length + && paneKeys.every((key, index) => key === tab.paneKeys[index]) + ? tab.splitRatios + : [], + }; + if (!nextTab.explicit && paneKeys.length === 1) continue; + for (const paneKey of paneKeys) claimedPaneKeys.add(paneKey); + tabs[tabKey] = nextTab; + } + + return JSON.stringify(state.tabs) === JSON.stringify(tabs) + ? state + : { version: 1, tabs }; +} + +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 projectedTabs = { ...reconciled.tabs }; + const claimedPaneKeys = new Set( + Object.values(projectedTabs).flatMap((tab) => tab.paneKeys), + ); + for (const paneKey of orderedSessionKeys) { + if (claimedPaneKeys.has(paneKey)) continue; + const tabKey = availableStandaloneTabKey(projectedTabs, paneKey); + projectedTabs[tabKey] = defaultWorkbenchTab(paneKey); + } + const tabs = Object.entries(projectedTabs).map(([tabKey, tab]) => { + const paneKeys = tab.paneKeys + .filter((key) => validKeys.has(key)) + .sort((left, right) => (rank.get(left) ?? Infinity) - (rank.get(right) ?? Infinity)); + 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/globals.css b/webui/src/globals.css index 215ed1d51..c67e05d2b 100644 --- a/webui/src/globals.css +++ b/webui/src/globals.css @@ -40,6 +40,7 @@ --radius: 0.4375rem; --sidebar: 40 8% 96.8%; --sidebar-foreground: 0 0% 3.9%; + --sidebar-selected: 40 1% 89.4%; --sidebar-accent: 0 0% 95.8%; --sidebar-accent-foreground: 0 0% 9%; --sidebar-border: 40 8% 90.5%; @@ -77,6 +78,7 @@ --temporary-border: 27 96% 61%; --sidebar: var(--card); --sidebar-foreground: 0 0% 98%; + --sidebar-selected: 0 0% 29.8%; --sidebar-accent: var(--background); --sidebar-accent-foreground: 0 0% 98%; --sidebar-border: var(--border); @@ -360,6 +362,9 @@ .thread-layout[data-layout="thread"] { grid-template-rows: minmax(0, 1fr) auto 0fr; } + .thread-layout[data-layout="external"] { + grid-template-rows: minmax(0, 1fr); + } @media (min-width: 640px) { .thread-layout[data-layout="hero"] { grid-template-rows: minmax(min-content, 1fr) auto 1fr; @@ -564,6 +569,10 @@ } } + .workbench-pane { + transform-origin: top left; + } + /** Goal halo: pale sky blue (not ``--primary``, which often reads as neutral gray). */ @keyframes goal-shell-glow-breathe { 0%, @@ -858,3 +867,9 @@ min-height: 2rem; } } +@media (forced-colors: active) { + [data-workbench-move-handle]:focus-visible { + outline: 2px solid CanvasText; + outline-offset: 2px; + } +} diff --git a/webui/src/hooks/useSidebarState.ts b/webui/src/hooks/useSidebarState.ts index cf90a36e1..47a8f54f0 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,27 +178,59 @@ 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) => { const next = normalizeSidebarState(updater(stateRef.current)); + if (sameState(next, stateRef.current)) return; stateRef.current = next; setState(next); persist(next); diff --git a/webui/src/i18n/locales/en/common.json b/webui/src/i18n/locales/en/common.json index a66379d35..bd874b282 100644 --- a/webui/src/i18n/locales/en/common.json +++ b/webui/src/i18n/locales/en/common.json @@ -989,6 +989,10 @@ "unarchive": "Unarchive", "showArchived": "Show archived", "hideArchived": "Hide archived", + "select": "Select", + "cancelSelection": "Cancel selection", + "selectedCount": "{{count}} selected", + "deleteSelected": "Delete", "delete": "Delete", "newChat": "New topic", "groups": { @@ -1003,10 +1007,13 @@ }, "deleteConfirm": { "title": "Delete this topic?", + "titleMany": "Delete {{count}} conversations?", "description": "This action cannot be undone.", + "descriptionMany": "This action cannot be undone.", "cancel": "Cancel", "confirm": "Delete", "automationsDescription": "This chat has scheduled automations. Deleting it will also delete them.", + "automationsDescriptionMany": "Linked automations will also be deleted.", "moreAutomations": "+ {{count}} more", "confirmWithAutomations": "Delete", "schedule": { @@ -1402,6 +1409,39 @@ "copy": "Copy", "copied": "Copied" }, + "workbench": { + "aria": "Conversation workbench", + "panes": "Panes", + "tabAria": "Tab: {{title}}", + "panesInTab": "Panes in {{title}}", + "collapseTabGroup": "Collapse panes in {{title}}", + "expandTabGroup": "Expand panes in {{title}}", + "dropPane": "Move {{pane}} into {{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", + "paneLimit": "Maximum {{count}} panes", + "deleteConversations": "Delete all chats", + "movePane": "Move {{title}} pane", + "movePaneHint": "Drag to move · Arrow keys also work", + "resizePaneBoundary": "Resize pane boundary {{index}}", + "promotePane": "Make {{title}} the primary pane", + "paneActions": "{{title}} pane actions", + "detachPane": "Remove", + "composerAria": "Message {{title}}", + "layouts": { + "columns": "Columns", + "rows": "Rows", + "grid": "Grid", + "bsp": "BSP", + "main-stack": "Main and stack" + } + }, "common": { "dismiss": "Dismiss", "close": "Close", diff --git a/webui/src/i18n/locales/es/common.json b/webui/src/i18n/locales/es/common.json index b5ccab76c..449472581 100644 --- a/webui/src/i18n/locales/es/common.json +++ b/webui/src/i18n/locales/es/common.json @@ -976,6 +976,10 @@ "unarchive": "Desarchivar", "showArchived": "Mostrar archivados", "hideArchived": "Ocultar archivados", + "select": "Seleccionar", + "cancelSelection": "Cancelar selección", + "selectedCount": "{{count}} seleccionados", + "deleteSelected": "Eliminar", "delete": "Eliminar", "newChat": "Nuevo tema", "groups": { @@ -990,10 +994,13 @@ }, "deleteConfirm": { "title": "¿Eliminar este chat?", + "titleMany": "¿Eliminar {{count}} conversaciones?", "description": "Esta acción no se puede deshacer.", + "descriptionMany": "Esta acción no se puede deshacer.", "cancel": "Cancelar", "confirm": "Eliminar", "automationsDescription": "Este chat tiene automatizaciones programadas. Al eliminarlo también se eliminarán.", + "automationsDescriptionMany": "También se eliminarán las automatizaciones vinculadas.", "moreAutomations": "+ {{count}} más", "confirmWithAutomations": "Eliminar", "schedule": { @@ -1389,6 +1396,39 @@ "copy": "Copiar", "copied": "Copiado" }, + "workbench": { + "aria": "Área de conversaciones", + "panes": "Paneles", + "tabAria": "Pestaña: {{title}}", + "panesInTab": "Paneles de {{title}}", + "collapseTabGroup": "Contraer los paneles de {{title}}", + "expandTabGroup": "Expandir los paneles de {{title}}", + "dropPane": "Mover {{pane}} a {{tab}}", + "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", + "paneLimit": "Máximo {{count}} paneles", + "deleteConversations": "Eliminar todo", + "movePane": "Mover panel {{title}}", + "movePaneHint": "Arrastra para mover · También puedes usar las flechas", + "resizePaneBoundary": "Redimensionar límite de panel {{index}}", + "promotePane": "Convertir {{title}} en el panel principal", + "paneActions": "Acciones del panel {{title}}", + "detachPane": "Quitar", + "composerAria": "Mensaje para {{title}}", + "layouts": { + "columns": "Columnas", + "rows": "Filas", + "grid": "Cuadrícula", + "bsp": "BSP", + "main-stack": "Principal y pila" + } + }, "common": { "dismiss": "Cerrar", "close": "Cerrar", diff --git a/webui/src/i18n/locales/fr/common.json b/webui/src/i18n/locales/fr/common.json index a305e8de1..844431460 100644 --- a/webui/src/i18n/locales/fr/common.json +++ b/webui/src/i18n/locales/fr/common.json @@ -975,6 +975,10 @@ "unarchive": "Désarchiver", "showArchived": "Afficher les archives", "hideArchived": "Masquer les archives", + "select": "Sélectionner", + "cancelSelection": "Annuler la sélection", + "selectedCount": "{{count}} sélectionnés", + "deleteSelected": "Supprimer", "delete": "Supprimer", "newChat": "Nouveau sujet", "groups": { @@ -989,10 +993,13 @@ }, "deleteConfirm": { "title": "Supprimer cette discussion ?", + "titleMany": "Supprimer {{count}} conversations ?", "description": "Cette action est irréversible.", + "descriptionMany": "Cette action est irréversible.", "cancel": "Annuler", "confirm": "Supprimer", "automationsDescription": "Cette discussion contient des automatisations planifiées. La supprimer les supprimera aussi.", + "automationsDescriptionMany": "Les automatisations liées seront également supprimées.", "moreAutomations": "+ {{count}} autres", "confirmWithAutomations": "Supprimer", "schedule": { @@ -1388,6 +1395,39 @@ "copy": "Copier", "copied": "Copié" }, + "workbench": { + "aria": "Espace de conversations", + "panes": "Volets", + "tabAria": "Onglet : {{title}}", + "panesInTab": "Volets dans {{title}}", + "collapseTabGroup": "Réduire les volets de {{title}}", + "expandTabGroup": "Développer les volets de {{title}}", + "dropPane": "Déplacer {{pane}} dans {{tab}}", + "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", + "paneLimit": "{{count}} volets maximum", + "deleteConversations": "Tout supprimer", + "movePane": "Déplacer le volet {{title}}", + "movePaneHint": "Faites glisser pour déplacer · Les flèches fonctionnent aussi", + "resizePaneBoundary": "Redimensionner la séparation {{index}}", + "promotePane": "Définir {{title}} comme volet principal", + "paneActions": "Actions du volet {{title}}", + "detachPane": "Retirer", + "composerAria": "Message à {{title}}", + "layouts": { + "columns": "Colonnes", + "rows": "Lignes", + "grid": "Grille", + "bsp": "BSP", + "main-stack": "Principal et pile" + } + }, "common": { "dismiss": "Fermer", "close": "Fermer", diff --git a/webui/src/i18n/locales/id/common.json b/webui/src/i18n/locales/id/common.json index 0f1eecb22..bca59a019 100644 --- a/webui/src/i18n/locales/id/common.json +++ b/webui/src/i18n/locales/id/common.json @@ -975,6 +975,10 @@ "unarchive": "Batalkan arsip", "showArchived": "Tampilkan yang diarsipkan", "hideArchived": "Sembunyikan yang diarsipkan", + "select": "Pilih", + "cancelSelection": "Batalkan pilihan", + "selectedCount": "{{count}} dipilih", + "deleteSelected": "Hapus", "delete": "Hapus", "newChat": "Topik baru", "groups": { @@ -989,10 +993,13 @@ }, "deleteConfirm": { "title": "Hapus obrolan ini?", + "titleMany": "Hapus {{count}} percakapan?", "description": "Tindakan ini tidak dapat dibatalkan.", + "descriptionMany": "Tindakan ini tidak dapat dibatalkan.", "cancel": "Batal", "confirm": "Hapus", "automationsDescription": "Obrolan ini memiliki automasi terjadwal. Menghapusnya juga akan menghapus automasi tersebut.", + "automationsDescriptionMany": "Automasi terkait juga akan dihapus.", "moreAutomations": "+ {{count}} lagi", "confirmWithAutomations": "Hapus", "schedule": { @@ -1388,6 +1395,39 @@ "copy": "Salin", "copied": "Tersalin" }, + "workbench": { + "aria": "Ruang kerja percakapan", + "panes": "Panel", + "tabAria": "Tab: {{title}}", + "panesInTab": "Panel di {{title}}", + "collapseTabGroup": "Ciutkan panel di {{title}}", + "expandTabGroup": "Luaskan panel di {{title}}", + "dropPane": "Pindahkan {{pane}} 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", + "paneLimit": "Maksimal {{count}} panel", + "deleteConversations": "Hapus semua", + "movePane": "Pindahkan panel {{title}}", + "movePaneHint": "Seret untuk memindahkan · Tombol panah juga dapat digunakan", + "resizePaneBoundary": "Ubah ukuran batas panel {{index}}", + "promotePane": "Jadikan {{title}} panel utama", + "paneActions": "Tindakan panel {{title}}", + "detachPane": "Keluarkan", + "composerAria": "Pesan untuk {{title}}", + "layouts": { + "columns": "Kolom", + "rows": "Baris", + "grid": "Kisi", + "bsp": "BSP", + "main-stack": "Utama dan tumpukan" + } + }, "common": { "dismiss": "Tutup", "close": "Tutup", diff --git a/webui/src/i18n/locales/ja/common.json b/webui/src/i18n/locales/ja/common.json index 67022ae30..b5331e574 100644 --- a/webui/src/i18n/locales/ja/common.json +++ b/webui/src/i18n/locales/ja/common.json @@ -975,6 +975,10 @@ "unarchive": "アーカイブを解除", "showArchived": "アーカイブ済みを表示", "hideArchived": "アーカイブ済みを隠す", + "select": "選択", + "cancelSelection": "選択を解除", + "selectedCount": "{{count}} 件を選択中", + "deleteSelected": "削除", "delete": "削除", "newChat": "新しいトピック", "groups": { @@ -989,10 +993,13 @@ }, "deleteConfirm": { "title": "このチャットを削除しますか?", + "titleMany": "{{count}} 件の会話を削除しますか?", "description": "この操作は元に戻せません。", + "descriptionMany": "この操作は元に戻せません。", "cancel": "キャンセル", "confirm": "削除", "automationsDescription": "このチャットにはスケジュール済みの自動タスクがあります。削除するとそれらも削除されます。", + "automationsDescriptionMany": "関連する自動タスクも削除されます。", "moreAutomations": "他 {{count}} 件", "confirmWithAutomations": "削除", "schedule": { @@ -1388,6 +1395,39 @@ "copy": "コピー", "copied": "コピーしました" }, + "workbench": { + "aria": "会話ワークベンチ", + "panes": "ペイン", + "tabAria": "タブ:{{title}}", + "panesInTab": "{{title}} のペイン", + "collapseTabGroup": "{{title}} のペインを折りたたむ", + "expandTabGroup": "{{title}} のペインを展開する", + "dropPane": "{{pane}} を {{tab}} に移動", + "createGroup": "グループを作成", + "moveTo": "移動先", + "renameTabTitle": "タブ名を変更", + "renameTabDescription": "ペインを整理するため、このタブに名前を付けます。", + "renameTabPlaceholder": "タブ名", + "dissolveTab": "グループを解除", + "layout": "ペインレイアウト", + "addPane": "ペインを追加", + "paneLimit": "最大 {{count}} ペイン", + "deleteConversations": "すべて削除", + "movePane": "{{title}} ペインを移動", + "movePaneHint": "ドラッグで移動 · 矢印キーでも移動できます", + "resizePaneBoundary": "ペイン境界 {{index}} のサイズを変更", + "promotePane": "{{title}} をメインペインにする", + "paneActions": "{{title}} ペインの操作", + "detachPane": "外す", + "composerAria": "{{title}} へのメッセージ", + "layouts": { + "columns": "列", + "rows": "行", + "grid": "グリッド", + "bsp": "BSP", + "main-stack": "メインとスタック" + } + }, "common": { "dismiss": "閉じる", "close": "閉じる", diff --git a/webui/src/i18n/locales/ko/common.json b/webui/src/i18n/locales/ko/common.json index 11ee620f6..6ea9523ae 100644 --- a/webui/src/i18n/locales/ko/common.json +++ b/webui/src/i18n/locales/ko/common.json @@ -975,6 +975,10 @@ "unarchive": "보관 해제", "showArchived": "보관된 항목 표시", "hideArchived": "보관된 항목 숨기기", + "select": "선택", + "cancelSelection": "선택 취소", + "selectedCount": "{{count}}개 선택됨", + "deleteSelected": "삭제", "delete": "삭제", "newChat": "새 주제", "groups": { @@ -989,10 +993,13 @@ }, "deleteConfirm": { "title": "이 채팅을 삭제할까요?", + "titleMany": "대화 {{count}}개를 삭제할까요?", "description": "이 작업은 되돌릴 수 없습니다.", + "descriptionMany": "이 작업은 되돌릴 수 없습니다.", "cancel": "취소", "confirm": "삭제", "automationsDescription": "이 채팅에는 예약된 자동화가 있습니다. 채팅을 삭제하면 자동화도 함께 삭제됩니다.", + "automationsDescriptionMany": "연결된 자동화도 함께 삭제됩니다.", "moreAutomations": "+ {{count}}개 더", "confirmWithAutomations": "삭제", "schedule": { @@ -1388,6 +1395,39 @@ "copy": "복사", "copied": "복사됨" }, + "workbench": { + "aria": "대화 워크벤치", + "panes": "창", + "tabAria": "탭: {{title}}", + "panesInTab": "{{title}}의 창", + "collapseTabGroup": "{{title}}의 창 접기", + "expandTabGroup": "{{title}}의 창 펼치기", + "dropPane": "{{pane}}을(를) {{tab}}으로 이동", + "createGroup": "그룹 만들기", + "moveTo": "이동", + "renameTabTitle": "탭 이름 바꾸기", + "renameTabDescription": "창을 정리할 수 있도록 이 탭에 이름을 지정하세요.", + "renameTabPlaceholder": "탭 이름", + "dissolveTab": "그룹 해제", + "layout": "창 레이아웃", + "addPane": "창 추가", + "paneLimit": "최대 {{count}}개 창", + "deleteConversations": "모두 삭제", + "movePane": "{{title}} 창 이동", + "movePaneHint": "드래그하여 이동 · 방향키로도 이동 가능", + "resizePaneBoundary": "창 경계 {{index}} 크기 조절", + "promotePane": "{{title}}을(를) 기본 창으로 설정", + "paneActions": "{{title}} 창 작업", + "detachPane": "제거", + "composerAria": "{{title}}에 메시지 보내기", + "layouts": { + "columns": "열", + "rows": "행", + "grid": "그리드", + "bsp": "BSP", + "main-stack": "기본 창과 스택" + } + }, "common": { "dismiss": "닫기", "close": "닫기", diff --git a/webui/src/i18n/locales/pt-BR/common.json b/webui/src/i18n/locales/pt-BR/common.json index e34e61551..19b1ef338 100644 --- a/webui/src/i18n/locales/pt-BR/common.json +++ b/webui/src/i18n/locales/pt-BR/common.json @@ -989,6 +989,10 @@ "unarchive": "Desarquivar", "showArchived": "Mostrar arquivadas", "hideArchived": "Ocultar arquivadas", + "select": "Selecionar", + "cancelSelection": "Cancelar seleção", + "selectedCount": "{{count}} selecionados", + "deleteSelected": "Excluir", "delete": "Excluir", "newChat": "Novo tópico", "groups": { @@ -1003,10 +1007,13 @@ }, "deleteConfirm": { "title": "Excluir esta conversa?", + "titleMany": "Excluir {{count}} conversas?", "description": "Esta ação não pode ser desfeita.", + "descriptionMany": "Esta ação não pode ser desfeita.", "cancel": "Cancelar", "confirm": "Excluir", "automationsDescription": "Esta conversa possui automações agendadas. Excluí-la também excluirá essas automações.", + "automationsDescriptionMany": "As automações vinculadas também serão excluídas.", "moreAutomations": "+ {{count}} a mais", "confirmWithAutomations": "Excluir", "schedule": { @@ -1402,6 +1409,39 @@ "copy": "Copiar", "copied": "Copiado" }, + "workbench": { + "aria": "Área de conversas", + "panes": "Painéis", + "tabAria": "Aba: {{title}}", + "panesInTab": "Painéis em {{title}}", + "collapseTabGroup": "Recolher os painéis em {{title}}", + "expandTabGroup": "Expandir os painéis em {{title}}", + "dropPane": "Mover {{pane}} para {{tab}}", + "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", + "paneLimit": "Máximo de {{count}} painéis", + "deleteConversations": "Excluir tudo", + "movePane": "Mover painel {{title}}", + "movePaneHint": "Arraste para mover · As setas também funcionam", + "resizePaneBoundary": "Redimensionar limite do painel {{index}}", + "promotePane": "Tornar {{title}} o painel principal", + "paneActions": "Ações do painel {{title}}", + "detachPane": "Remover", + "composerAria": "Mensagem para {{title}}", + "layouts": { + "columns": "Colunas", + "rows": "Linhas", + "grid": "Grade", + "bsp": "BSP", + "main-stack": "Principal e pilha" + } + }, "common": { "dismiss": "Descartar", "close": "Fechar", diff --git a/webui/src/i18n/locales/vi/common.json b/webui/src/i18n/locales/vi/common.json index 875280a25..f7daf6292 100644 --- a/webui/src/i18n/locales/vi/common.json +++ b/webui/src/i18n/locales/vi/common.json @@ -975,6 +975,10 @@ "unarchive": "Bỏ lưu trữ", "showArchived": "Hiện mục đã lưu trữ", "hideArchived": "Ẩn mục đã lưu trữ", + "select": "Chọn", + "cancelSelection": "Hủy chọn", + "selectedCount": "Đã chọn {{count}} mục", + "deleteSelected": "Xóa", "delete": "Xóa", "newChat": "Chủ đề mới", "groups": { @@ -989,10 +993,13 @@ }, "deleteConfirm": { "title": "Xóa cuộc trò chuyện này?", + "titleMany": "Xóa {{count}} cuộc trò chuyện?", "description": "Không thể hoàn tác thao tác này.", + "descriptionMany": "Không thể hoàn tác thao tác này.", "cancel": "Hủy", "confirm": "Xóa", "automationsDescription": "Cuộc trò chuyện này có các tự động hóa đã lên lịch. Xóa cuộc trò chuyện cũng sẽ xóa chúng.", + "automationsDescriptionMany": "Các tự động hóa liên kết cũng sẽ bị xóa.", "moreAutomations": "+ {{count}} mục nữa", "confirmWithAutomations": "Xóa", "schedule": { @@ -1388,6 +1395,39 @@ "copy": "Sao chép", "copied": "Đã sao chép" }, + "workbench": { + "aria": "Không gian hội thoại", + "panes": "Khung", + "tabAria": "Thẻ: {{title}}", + "panesInTab": "Các khung trong {{title}}", + "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}}", + "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", + "paneLimit": "Tối đa {{count}} khung", + "deleteConversations": "Xóa tất cả", + "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", + "resizePaneBoundary": "Đổi kích thước ranh giới khung {{index}}", + "promotePane": "Đặt {{title}} làm khung chính", + "paneActions": "Thao tác cho khung {{title}}", + "detachPane": "Gỡ", + "composerAria": "Nhắn tin cho {{title}}", + "layouts": { + "columns": "Cột", + "rows": "Hàng", + "grid": "Lưới", + "bsp": "BSP", + "main-stack": "Khung chính và ngăn xếp" + } + }, "common": { "dismiss": "Đóng", "close": "Đóng", diff --git a/webui/src/i18n/locales/zh-CN/common.json b/webui/src/i18n/locales/zh-CN/common.json index 95ec90dd5..45b48c889 100644 --- a/webui/src/i18n/locales/zh-CN/common.json +++ b/webui/src/i18n/locales/zh-CN/common.json @@ -989,6 +989,10 @@ "unarchive": "取消归档", "showArchived": "显示归档", "hideArchived": "隐藏归档", + "select": "选择", + "cancelSelection": "取消选择", + "selectedCount": "已选择 {{count}} 项", + "deleteSelected": "删除", "delete": "删除", "newChat": "新建话题", "groups": { @@ -1003,10 +1007,13 @@ }, "deleteConfirm": { "title": "删除这个话题?", + "titleMany": "删除这 {{count}} 个对话?", "description": "此操作无法撤销。", + "descriptionMany": "此操作无法撤销。", "cancel": "取消", "confirm": "删除", "automationsDescription": "这个话题有关联的自动任务。删除话题也会删除这些自动任务。", + "automationsDescriptionMany": "关联的自动任务也会一并删除。", "moreAutomations": "另有 {{count}} 个", "confirmWithAutomations": "删除", "schedule": { @@ -1402,6 +1409,39 @@ "copy": "复制", "copied": "已复制" }, + "workbench": { + "aria": "会话工作台", + "panes": "窗格", + "tabAria": "标签页:{{title}}", + "panesInTab": "{{title}} 中的窗格", + "collapseTabGroup": "折叠 {{title}} 中的窗格", + "expandTabGroup": "展开 {{title}} 中的窗格", + "dropPane": "将 {{pane}} 移入 {{tab}}", + "createGroup": "创建分组", + "moveTo": "移动到", + "renameTabTitle": "重命名标签页", + "renameTabDescription": "为这个标签页命名,以便组织其中的窗格。", + "renameTabPlaceholder": "标签页名称", + "dissolveTab": "解散分组", + "layout": "窗格布局", + "addPane": "添加窗格", + "paneLimit": "最多 {{count}} 个窗格", + "deleteConversations": "删除所有对话", + "movePane": "移动 {{title}} 窗格", + "movePaneHint": "拖动换位 · 也可以使用方向键", + "resizePaneBoundary": "调整窗格边界 {{index}}", + "promotePane": "将 {{title}} 设为主窗格", + "paneActions": "{{title}} 窗格操作", + "detachPane": "移出", + "composerAria": "向 {{title}} 发送消息", + "layouts": { + "columns": "列布局", + "rows": "行布局", + "grid": "网格", + "bsp": "BSP", + "main-stack": "主窗格与堆栈" + } + }, "common": { "dismiss": "关闭", "close": "关闭", diff --git a/webui/src/i18n/locales/zh-TW/common.json b/webui/src/i18n/locales/zh-TW/common.json index 69ae9fd42..d6375bd11 100644 --- a/webui/src/i18n/locales/zh-TW/common.json +++ b/webui/src/i18n/locales/zh-TW/common.json @@ -975,6 +975,10 @@ "unarchive": "取消封存", "showArchived": "顯示封存", "hideArchived": "隱藏封存", + "select": "選取", + "cancelSelection": "取消選取", + "selectedCount": "已選取 {{count}} 項", + "deleteSelected": "刪除", "delete": "刪除", "newChat": "新增話題", "groups": { @@ -989,10 +993,13 @@ }, "deleteConfirm": { "title": "刪除這個話題?", + "titleMany": "刪除這 {{count}} 個對話?", "description": "此操作無法復原。", + "descriptionMany": "此操作無法復原。", "cancel": "取消", "confirm": "刪除", "automationsDescription": "這個話題含有已排程的自動任務。刪除話題時也會一併刪除這些任務。", + "automationsDescriptionMany": "關聯的自動任務也會一併刪除。", "moreAutomations": "另有 {{count}} 個", "confirmWithAutomations": "刪除", "schedule": { @@ -1388,6 +1395,39 @@ "copy": "複製", "copied": "已複製" }, + "workbench": { + "aria": "對話工作台", + "panes": "窗格", + "tabAria": "標籤頁:{{title}}", + "panesInTab": "{{title}} 中的窗格", + "collapseTabGroup": "收合 {{title}} 中的窗格", + "expandTabGroup": "展開 {{title}} 中的窗格", + "dropPane": "將 {{pane}} 移入 {{tab}}", + "createGroup": "建立群組", + "moveTo": "移動到", + "renameTabTitle": "重新命名分頁", + "renameTabDescription": "為這個分頁命名,以便整理其中的窗格。", + "renameTabPlaceholder": "分頁名稱", + "dissolveTab": "解散群組", + "layout": "窗格佈局", + "addPane": "新增窗格", + "paneLimit": "最多 {{count}} 個窗格", + "deleteConversations": "刪除所有對話", + "movePane": "移動 {{title}} 窗格", + "movePaneHint": "拖曳換位 · 也可以使用方向鍵", + "resizePaneBoundary": "調整窗格邊界 {{index}}", + "promotePane": "將 {{title}} 設為主窗格", + "paneActions": "{{title}} 窗格操作", + "detachPane": "移出", + "composerAria": "傳送訊息給 {{title}}", + "layouts": { + "columns": "欄佈局", + "rows": "列佈局", + "grid": "網格", + "bsp": "BSP", + "main-stack": "主窗格與堆疊" + } + }, "common": { "dismiss": "關閉", "close": "關閉", 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/types.ts b/webui/src/lib/types.ts index b9f6910e3..6187ae77d 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[]; + layout: WorkbenchLayout; + splitRatios: number[]; +} + +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; } @@ -1281,6 +1297,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 2ff477b02..cc192b7b8 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\?/; @@ -164,7 +166,24 @@ vi.mock("@/hooks/useSessions", async (importOriginal) => { loading: false, error: null, refresh: refreshSpy, - createChat: createChatSpy, + createChat: async (scope?: WorkspaceScopePayload | null) => { + const chatId = await createChatSpy(scope); + const now = new Date().toISOString(); + setSessions((prev: ChatSummary[]) => [ + { + key: `websocket:${chatId}`, + channel: "websocket", + chatId, + createdAt: now, + updatedAt: now, + title: "", + preview: "", + workspaceScope: scope ?? null, + }, + ...prev.filter((session) => session.chatId !== chatId), + ]); + return chatId; + }, forkChat: async () => "fork-chat", getSessionAutomations: getSessionAutomationsSpy, deleteChat: async (key: string, options?: { deleteAutomations?: boolean }) => { @@ -232,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); @@ -272,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; @@ -283,11 +308,13 @@ describe("App layout", () => { statusHandlers.clear(); runStatusHandlers.clear(); sessionUpdateHandlers.clear(); + sidebarStateUpdateHandlers.clear(); window.history.replaceState(null, "", "/"); setNavigatorPlatform("Linux x86_64"); localStorage.removeItem("nanobot-webui.sidebar"); localStorage.removeItem("nanobot-webui.sidebar.completed-runs.v1"); localStorage.removeItem("nanobot-webui.sidebar.session-updates.v1"); + localStorage.removeItem("nanobot-webui.collapsed-pane-groups.v1"); localStorage.removeItem("nanobot-webui.restartStartedAt"); localStorage.removeItem("nanobot-webui.restartRoute"); vi.mocked(fetchBootstrap).mockReset().mockResolvedValue({ @@ -309,6 +336,7 @@ describe("App layout", () => { afterEach(() => { cleanup(); vi.useRealTimers(); + vi.unstubAllGlobals(); }); it("shows the auth form without an invalid-password error on first load", async () => { @@ -489,8 +517,9 @@ describe("App layout", () => { render(); await waitFor(() => expect(connectSpy).toHaveBeenCalled()); + const firstMessage = "keep this first turn visible"; fireEvent.change(screen.getByRole("textbox", { name: "Message input" }), { - target: { value: "/model" }, + target: { value: firstMessage }, }); fireEvent.click(screen.getByRole("button", { name: "Send message" })); @@ -500,6 +529,7 @@ describe("App layout", () => { `#/chat/${encodeURIComponent("websocket:chat-1")}`, ), ); + expect(await screen.findByText(firstMessage)).toBeInTheDocument(); }); it("creates a new temporary chat from the hero each time", async () => { @@ -1653,6 +1683,60 @@ describe("App layout", () => { expect(document.body.style.pointerEvents).not.toBe("none"); }, 15_000); + it("deletes multiple selected topics through one confirmation", async () => { + mockSessions = [ + { + key: "websocket:chat-a", + channel: "websocket", + chatId: "chat-a", + createdAt: "2026-04-16T10:00:00Z", + updatedAt: "2026-04-16T10:00:00Z", + preview: "First chat", + }, + { + key: "websocket:chat-b", + channel: "websocket", + chatId: "chat-b", + createdAt: "2026-04-16T11:00:00Z", + updatedAt: "2026-04-16T11:00:00Z", + preview: "Second chat", + }, + { + key: "websocket:chat-c", + channel: "websocket", + chatId: "chat-c", + createdAt: "2026-04-16T12:00:00Z", + updatedAt: "2026-04-16T12:00:00Z", + preview: "Third chat", + }, + ]; + + render(); + + await waitFor(() => expect(connectSpy).toHaveBeenCalled()); + const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" }); + fireEvent.pointerDown(within(sidebar).getByLabelText( + "Topic actions for First chat", + ), { button: 0 }); + fireEvent.click(await screen.findByRole("menuitem", { name: "Select" })); + fireEvent.click(within(sidebar).getByRole("button", { name: "Second chat" })); + expect(within(sidebar).getByText("2 selected")).toBeInTheDocument(); + + fireEvent.click(within(sidebar).getByRole("button", { name: "Delete" })); + expect(await screen.findByText("Delete 2 conversations?")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Delete" })); + + await waitFor(() => expect(deleteChatSpy).toHaveBeenCalledTimes(2)); + expect(deleteChatSpy.mock.calls.map(([key]) => key)).toEqual([ + "websocket:chat-a", + "websocket:chat-b", + ]); + expect(getSessionAutomationsSpy).toHaveBeenCalledWith("websocket:chat-a"); + expect(getSessionAutomationsSpy).toHaveBeenCalledWith("websocket:chat-b"); + expect(within(sidebar).getByRole("button", { name: "Third chat" })) + .toBeInTheDocument(); + }, 15_000); + it("shows localized bound automations in the first delete confirmation", async () => { mockSessions = [ { @@ -2947,6 +3031,386 @@ 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"], + layout: "columns", + }, + "tab:websocket:beta": { + explicit: false, + title: null, + paneKeys: ["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("uses one active pane without workbench editing controls on mobile", async () => { + vi.stubGlobal("matchMedia", vi.fn((query: string) => ({ + matches: query.includes("max-width: 767px"), + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + }))); + 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: "", + }, + ]; + window.history.replaceState( + null, + "", + "/#/chat/websocket%3Aalpha-child", + ); + 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"], + layout: "bsp", + }, + }, + }, + }), + }; + } + return { ok: false, status: 404 }; + })); + + render(); + + await waitFor(() => expect(connectSpy).toHaveBeenCalled()); + const grid = await screen.findByTestId("pane-grid"); + await waitFor(() => expect(Array.from(grid.children).map( + (pane) => pane.getAttribute("aria-label"), + )).toEqual(["Alpha child"])); + expect(screen.queryByRole("button", { name: "Pane layout" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Add pane" })).not.toBeInTheDocument(); + expect(screen.queryByRole("separator")).not.toBeInTheDocument(); + + const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" }); + fireEvent.pointerDown(within(sidebar).getByRole("button", { + name: "Alpha child pane actions", + }), { button: 0, ctrlKey: false }); + expect(await screen.findByRole("menuitem", { name: "Delete" })).toBeInTheDocument(); + expect(screen.queryByRole("menuitem", { name: "Remove" })).not.toBeInTheDocument(); + expect(screen.queryByRole("menuitem", { name: "Move to" })).not.toBeInTheDocument(); + }); + + 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, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + }))); + createChatSpy.mockResolvedValueOnce("chat-pane"); + mockSessions = [ + { + key: "websocket:chat-alpha", + channel: "websocket", + chatId: "chat-alpha", + createdAt: "2026-04-16T10:00:00Z", + updatedAt: "2026-04-16T10:00:00Z", + title: "Alpha", + preview: "Alpha notes", + }, + { + key: "websocket:chat-beta", + channel: "websocket", + chatId: "chat-beta", + createdAt: "2026-04-16T11:00:00Z", + updatedAt: "2026-04-16T11:00:00Z", + title: "Beta", + preview: "Beta notes", + }, + ]; + window.history.replaceState( + null, + "", + "/#/chat/websocket%3Achat-alpha", + ); + + render(); + + await waitFor(() => expect(connectSpy).toHaveBeenCalled()); + 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(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"]); + + const activeComposer = screen.getByTestId("active-pane-composer"); + 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()); + expect(sendMessageSpy.mock.calls.at(-1)?.[0]).toBe("chat-pane"); + + fireEvent.pointerDown(screen.getByRole("button", { name: "Pane layout" }), { + button: 0, + ctrlKey: false, + }); + fireEvent.click(screen.getByRole("menuitemradio", { name: "Rows" })); + expect(grid).toHaveAttribute("data-layout", "rows"); + + const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" }); + const paneTopicButton = within(sidebar) + .getAllByRole("button", { name: "New topic" }) + .find((button) => button.closest("[data-sidebar-pane]")); + expect(paneTopicButton).toBeDefined(); + expect(paneTopicButton?.closest("[data-sidebar-pane]")) + .toHaveAttribute("data-sidebar-pane", "websocket:chat-pane"); + fireEvent.click(within(sidebar).getByRole("button", { name: "Beta" })); + await waitFor(() => { + const nextGrid = screen.getByTestId("pane-grid"); + expect(Array.from(nextGrid.children).map((pane) => pane.getAttribute("aria-label"))) + .toEqual(["Beta"]); + expect(nextGrid).toHaveAttribute("data-layout", "columns"); + }); + + fireEvent.click(within(sidebar).getByRole("button", { name: "Alpha" })); + await waitFor(() => { + const restoredGrid = screen.getByTestId("pane-grid"); + expect(Array.from(restoredGrid.children).map((pane) => pane.getAttribute("aria-label"))) + .toEqual(["Alpha", "New topic"]); + expect(restoredGrid).toHaveAttribute("data-layout", "rows"); + }); + + fireEvent.pointerDown(within(sidebar).getByRole("button", { + name: "New topic pane actions", + }), { button: 0, ctrlKey: false }); + fireEvent.click(screen.getByRole("menuitem", { + name: "Remove", + })); + await waitFor(() => expect(screen.getByTestId("pane-grid").children).toHaveLength(1)); + expect(within(sidebar).getAllByRole("button", { name: "New topic" })).toHaveLength(2); + }); + it("opens search from the keyboard shortcut", async () => { mockSessions = [ { diff --git a/webui/src/tests/chat-list.test.tsx b/webui/src/tests/chat-list.test.tsx index fa2e3cb79..c67e35b9f 100644 --- a/webui/src/tests/chat-list.test.tsx +++ b/webui/src/tests/chat-list.test.tsx @@ -2,7 +2,6 @@ import { fireEvent, render, screen, within } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { ChatList } from "@/components/ChatList"; -import { SESSION_DRAG_TYPE } from "@/lib/session-drag"; import type { ChatSummary } from "@/lib/types"; function session(overrides: Partial): ChatSummary { @@ -18,48 +17,94 @@ 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; -} - describe("ChatList", () => { afterEach(() => { + localStorage.removeItem("nanobot-webui.collapsed-pane-groups.v1"); vi.restoreAllMocks(); vi.unstubAllGlobals(); }); - it("exposes chats as drag sources", () => { - const dataTransfer = { - effectAllowed: "", - setData: 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"); + 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.dragStart(reference, { 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("websocket:solo"); - expect(dataTransfer.setData).toHaveBeenCalledWith( - SESSION_DRAG_TYPE, - "websocket:reference", - ); - fireEvent.dragEnd(reference, { dataTransfer }); + 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("reorders chats around a Codex-style insertion line", () => { - 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")!; - fireEvent.dragOver(charlieRow, { clientY: 1, dataTransfer }); - expect(charlieRow.querySelector("[data-session-drop-edge='after']")) - .toBeInTheDocument(); - fireEvent.drop(charlieRow, { clientY: 1, dataTransfer }); + 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 onDissolveTab = vi.fn(); + const onRequestRename = vi.fn(); + const onAttachPane = vi.fn(); - expect(onReorderSessions).toHaveBeenCalledWith([ - "websocket:bravo", - "websocket:charlie", - "websocket:alpha", - "websocket:old-a", - "websocket:old-b", - ]); - - rerender( + render( , ); - 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")); + + const child = screen.getByRole("button", { name: "Research pane" }); + expect(child.closest("[data-sidebar-pane]")) + .toHaveAttribute("data-sidebar-pane", "websocket:child"); + expect(child).toHaveAttribute("aria-current", "true"); + const targetTabRow = screen.getByRole("button", { name: "Tab: Target tab" }) + .closest("li")!; + const targetChild = within(targetTabRow).getByRole("button", { + name: "Target research", + }); + expect(targetChild.closest("[data-sidebar-pane]")) + .toHaveAttribute("data-sidebar-pane", "websocket:target-child"); + expect(targetChild).not.toHaveAttribute("aria-current"); + fireEvent.click(targetChild); + expect(onSelectPane).toHaveBeenCalledWith( + "websocket:target", + "websocket:target-child", + ); + fireEvent.click(child); + expect(onSelectPane).toHaveBeenCalledWith("websocket:root", "websocket:child"); + + fireEvent.click(screen.getByRole("button", { name: "Root topic" })); + expect(onSelectPane).toHaveBeenCalledWith("websocket:root", "websocket:root"); + expect(onSelect).not.toHaveBeenCalled(); + + onSelectPane.mockClear(); + 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.getByRole("menuitem", { name: "Delete all chats" })) + .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 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: "Remove", + })); + expect(onDetachPane).toHaveBeenCalledWith("websocket:root", "websocket:child"); + + fireEvent.pointerDown(screen.getByRole("button", { + name: "Root topic pane actions", + }), { button: 0, ctrlKey: false }); + expect(screen.getByRole("menuitem", { name: "Move to" })) + .toBeInTheDocument(); + expect(screen.getByRole("menuitem", { name: "Remove" })) + .toBeInTheDocument(); + fireEvent.keyDown(document, { key: "Escape" }); + + 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 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(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"); + + const collapse = within(tabGroup).getByRole("button", { + name: "Collapse panes in Root topic", + }); + expect(collapse).toHaveAttribute("aria-expanded", "true"); + fireEvent.click(collapse); + + expect(tabGroup).toHaveAttribute("data-pane-group-collapsed", "true"); + expect(within(tabGroup).queryByRole("button", { name: "Research pane" })) + .not.toBeInTheDocument(); + expect(within(tabGroup).getByRole("button", { + name: "Expand panes in Root topic", + })).toHaveAttribute("aria-expanded", "false"); + 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", + })); + expect(within(tabGroup).getByRole("button", { name: "Research pane" })) + .toBeInTheDocument(); + expect(within(tabGroup).getByRole("button", { name: "Root topic" })) + .toBeInTheDocument(); + }); + + it("selects a whole tab or individual panes for one bulk delete", async () => { + const onRequestDeleteMany = vi.fn(); + render( + , + ); + + fireEvent.pointerDown(screen.getByRole("button", { + 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" })) + .toHaveAttribute("aria-pressed", "true"); + expect(screen.getByRole("button", { name: "Research pane" })) + .toHaveAttribute("aria-pressed", "true"); + expect(screen.getByText("2 selected")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Target tab" })); + expect(screen.getByText("3 selected")).toBeInTheDocument(); + fireEvent.click(within(screen.getByTestId("delete-selection-bar")).getByRole("button", { + name: "Delete", + })); + + expect(onRequestDeleteMany).toHaveBeenCalledWith([ + { key: "websocket:root", label: "Root topic" }, + { key: "websocket:child", label: "Research pane" }, + { key: "websocket:target", label: "Target tab" }, + ]); + expect(screen.queryByTestId("delete-selection-bar")).not.toBeInTheDocument(); }); it("shows temporary chats separately and lets the user reopen or close them", async () => { @@ -359,40 +615,7 @@ describe("ChatList", () => { expect(within(chatsSection).queryByText("Project chat")).not.toBeInTheDocument(); }); - it("positions one background highlight and resets it across hidden targets", () => { - let revealFrame: FrameRequestCallback | null = null; - let resizeObserverCallback: ResizeObserverCallback | null = null; - let activeTargetVisible = true; - class MockResizeObserver { - constructor(callback: ResizeObserverCallback) { - resizeObserverCallback = callback; - } - - observe() {} - unobserve() {} - disconnect() {} - } - vi.stubGlobal("ResizeObserver", MockResizeObserver); - vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => { - revealFrame = callback; - return 1; - }); - vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation( - function (this: HTMLElement) { - if (this.hasAttribute("data-chat-list-content")) { - return rect({ left: 0, top: 0, width: 300, height: 200 }); - } - if (this.getAttribute("data-chat-row") === "websocket:active") { - return activeTargetVisible - ? rect({ left: 8, top: 12, width: 284, height: 32 }) - : rect({ left: 0, top: 0, width: 0, height: 0 }); - } - if (this.getAttribute("data-chat-row") === "websocket:inactive") { - return rect({ left: 8, top: 48, width: 284, height: 40 }); - } - return rect({ left: 0, top: 0, width: 0, height: 0 }); - }, - ); + it("switches row-owned tab highlights without a moving selection surface", () => { const props = { sessions: [ session({ chatId: "active", title: "Active topic" }), @@ -412,45 +635,12 @@ describe("ChatList", () => { />, ); - const highlight = screen.getByTestId("sessions-selection-highlight"); - expect(highlight).toHaveClass( - "bg-sidebar-foreground/[0.055]", - "transition-[transform,width,height]", - "motion-reduce:transition-none", - ); - expect(screen.queryByTestId("sessions-selection-highlight-surface")) - .not.toBeInTheDocument(); - expect(resizeObserverCallback).not.toBeNull(); - const activeButton = screen.getByTitle("Active topic"); expect(activeButton).toHaveAttribute("aria-current", "page"); - expect(activeButton.parentElement).toHaveClass("transition-[color]"); - expect(activeButton.parentElement).not.toHaveClass("transition-colors"); - expect(activeButton.parentElement).not.toHaveClass( - "bg-sidebar-accent", - "shadow-[inset_0_0_0_1px_hsl(var(--sidebar-border)/0.55)]", + expect(activeButton.closest("[data-sidebar-tab]")).toHaveClass( + "bg-sidebar-selected", ); - expect(highlight).toHaveClass( - "transition-[transform,width,height]", - "motion-reduce:transition-none", - ); - expect(highlight).toHaveStyle( - "width: 284px; height: 32px; transform: translate3d(8px, 12px, 0); opacity: 1; transition-property: none", - ); - - revealFrame?.(0); - expect(highlight.style.transitionProperty).toBe(""); - - activeTargetVisible = false; - resizeObserverCallback?.([], {} as ResizeObserver); - expect(highlight).toHaveStyle("opacity: 0"); - - activeTargetVisible = true; - resizeObserverCallback?.([], {} as ResizeObserver); - expect(highlight).toHaveStyle( - "width: 284px; height: 32px; transform: translate3d(8px, 12px, 0); opacity: 1; transition-property: none", - ); - revealFrame?.(0); + expect(screen.queryByTestId("sessions-selection-highlight")).not.toBeInTheDocument(); rerender( { expect(screen.getByTitle("Active topic")).not.toHaveAttribute("aria-current"); expect(screen.getByTitle("Inactive topic")).toHaveAttribute("aria-current", "page"); - expect(highlight).toHaveStyle( - "width: 284px; height: 40px; transform: translate3d(8px, 48px, 0)", + expect(screen.getByTitle("Inactive topic").closest("[data-sidebar-tab]")).toHaveClass( + "bg-sidebar-selected", ); + }); - rerender(); - expect(highlight).toHaveStyle("opacity: 0"); + it("restores collapsed tabs from the local UI preference", () => { + const props = { + sessions: [session({ chatId: "root", title: "Root topic" })], + activeKey: "websocket:root", + paneGroups: { + "websocket:root": { + tabKey: "websocket:root", + title: "Root topic", + activePaneKey: "websocket:root", + panes: [ + { key: "websocket:root", chatId: "root", title: "Root topic" }, + { key: "websocket:child", chatId: "child", title: "Research pane" }, + ], + }, + }, + onSelect: vi.fn(), + onRequestDelete: vi.fn(), + onTogglePin: vi.fn(), + onRequestRename: vi.fn(), + onToggleArchive: vi.fn(), + }; + const firstRender = render(); + + fireEvent.click(screen.getByRole("button", { name: "Tab: Root topic" })); + expect(screen.queryByRole("button", { name: "Research pane" })).not.toBeInTheDocument(); + firstRender.unmount(); + + render(); + expect(screen.getByRole("button", { name: "Tab: Root topic" })) + .toHaveAttribute("aria-expanded", "false"); + expect(screen.queryByRole("button", { name: "Research pane" })).not.toBeInTheDocument(); }); 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..48113a995 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,51 @@ 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"], + 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-workbench.test.tsx b/webui/src/tests/pane-workbench.test.tsx new file mode 100644 index 000000000..38c556c57 --- /dev/null +++ b/webui/src/tests/pane-workbench.test.tsx @@ -0,0 +1,392 @@ +import { createPortal } from "react-dom"; +import { type ReactNode, useState } from "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, + setWorkbenchLayout, + setWorkbenchPaneLayoutOrder, + workbenchTab, + workbenchTabForPane, +} from "@/components/workbench/workbench-model"; + +function rect(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: () => ({}), + }; +} + +function WorkbenchHarness({ + initialLayout = "columns", + onPaneOrderChange = () => {}, + onSplitRatiosChange = () => {}, +}: { + initialLayout?: "columns" | "rows"; + onPaneOrderChange?: (paneKeys: string[]) => void; + onSplitRatiosChange?: (splitRatios: number[]) => void; +} = {}) { + const [activePaneKey, setActivePaneKey] = useState("beta"); + const [state, setState] = useState(() => { + const initial = addWorkbenchPane(EMPTY_WORKBENCH_STATE, "alpha", "beta"); + const tabKey = workbenchTabForPane(initial, "alpha").tabKey; + return setWorkbenchLayout(initial, 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] }))} + activePaneKey={activePaneKey} + layout={tab.layout} + splitRatios={tab.splitRatios} + showLayoutControl + onActivatePane={setActivePaneKey} + onAddPane={vi.fn()} + onLayoutChange={(layout) => setState((current) => ( + setWorkbenchLayout(current, tabKey, layout) + ))} + onPaneOrderChange={(paneKeys) => { + onPaneOrderChange(paneKeys); + setState((current) => ( + setWorkbenchPaneLayoutOrder(current, tabKey, paneKeys) + )); + }} + onSplitRatiosChange={onSplitRatiosChange} + renderPane={(pane, context) => ( + <> + + {context.headerPortalTarget && context.active ? createPortal( + context.headerActions, + context.headerPortalTarget, + ) : null} + {context.composerPortalTarget ? createPortal( +