Compare commits

...
38 changed files with 5454 additions and 528 deletions
+15 -1
View File
@@ -530,6 +530,10 @@ class WebSocketChannel(BaseChannel):
except Exception as e: except Exception as e:
self.logger.warning("failed to send {} event: {}", event, 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 @classmethod
def default_config(cls) -> dict[str, Any]: def default_config(cls) -> dict[str, Any]:
return WebSocketConfig().model_dump(by_alias=True) return WebSocketConfig().model_dump(by_alias=True)
@@ -848,7 +852,7 @@ class WebSocketChannel(BaseChannel):
) )
return return
try: try:
await asyncio.to_thread( saved_state = await asyncio.to_thread(
write_webui_sidebar_state, write_webui_sidebar_state,
cast(dict[str, Any], state), cast(dict[str, Any], state),
) )
@@ -859,6 +863,11 @@ class WebSocketChannel(BaseChannel):
detail="invalid_sidebar_state", detail="invalid_sidebar_state",
) )
return return
await self._broadcast_webui_event(
"sidebar_state_updated",
state=saved_state,
)
return
if t == "set_workspace_scope": if t == "set_workspace_scope":
cid = envelope.get("chat_id") cid = envelope.get("chat_id")
if not _is_valid_chat_id(cid): if not _is_valid_chat_id(cid):
@@ -1207,6 +1216,11 @@ class WebSocketChannel(BaseChannel):
message="WebUI mutation returned an invalid response", message="WebUI mutation returned an invalid response",
) )
return return
if action == "sidebar.update" and isinstance(result, dict):
await self._broadcast_webui_event(
"sidebar_state_updated",
state=result,
)
await self._send_webui_response( await self._send_webui_response(
connection, connection,
request_id, request_id,
@@ -1029,6 +1029,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 @pytest.mark.asyncio
async def test_client_cannot_self_assert_webui_quote_context(bus: MagicMock) -> None: async def test_client_cannot_self_assert_webui_quote_context(bus: MagicMock) -> None:
channel = _ch(bus) channel = _ch(bus)
+75 -1
View File
@@ -8,7 +8,9 @@ does not modify agent sessions.
from __future__ import annotations from __future__ import annotations
import json import json
import math
import os import os
import threading
import time import time
from pathlib import Path from pathlib import Path
from typing import Any, cast from typing import Any, cast
@@ -24,8 +26,11 @@ _MAX_MAP_ITEMS = 2_000
_MAX_KEY_LEN = 512 _MAX_KEY_LEN = 512
_MAX_TITLE_LEN = 160 _MAX_TITLE_LEN = 160
_MAX_TAG_LEN = 40 _MAX_TAG_LEN = 40
_MAX_WORKBENCH_PANES = 4
_ALLOWED_DENSITIES = {"comfortable", "compact"} _ALLOWED_DENSITIES = {"comfortable", "compact"}
_ALLOWED_SORTS = {"updated_desc", "created_desc", "title_asc", "manual"} _ALLOWED_SORTS = {"updated_desc", "created_desc", "title_asc", "manual"}
_ALLOWED_WORKBENCH_LAYOUTS = {"columns", "rows", "grid", "bsp", "main-stack"}
_SIDEBAR_STATE_WRITE_LOCK = threading.Lock()
def webui_sidebar_state_path() -> Path: def webui_sidebar_state_path() -> Path:
@@ -42,6 +47,7 @@ def default_webui_sidebar_state() -> dict[str, Any]:
"project_name_overrides": {}, "project_name_overrides": {},
"tags_by_key": {}, "tags_by_key": {},
"collapsed_groups": {}, "collapsed_groups": {},
"workbench": {"version": 1, "tabs": {}},
"view": { "view": {
"density": "comfortable", "density": "comfortable",
"show_previews": False, "show_previews": False,
@@ -76,6 +82,20 @@ def _clean_string_list(value: Any, *, max_len: int = _MAX_KEY_LEN) -> list[str]:
return out 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]: def _clean_bool_map(value: Any) -> dict[str, bool]:
if not isinstance(value, dict): if not isinstance(value, dict):
return {} 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]: 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): if not isinstance(raw, dict):
raw = {} raw = {}
raw = cast(dict[str, Any], 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["tags_by_key"] = _clean_tags_by_key(raw.get("tags_by_key"))
state["collapsed_groups"] = _clean_bool_map(raw.get("collapsed_groups")) state["collapsed_groups"] = _clean_bool_map(raw.get("collapsed_groups"))
state["workbench"] = _clean_workbench(raw.get("workbench"))
state["view"] = _clean_view(raw.get("view")) state["view"] = _clean_view(raw.get("view"))
updated_at = raw.get("updated_at") updated_at = raw.get("updated_at")
state["updated_at"] = updated_at if isinstance(updated_at, str) else None 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]: def write_webui_sidebar_state(raw: dict[str, Any]) -> dict[str, Any]:
with _SIDEBAR_STATE_WRITE_LOCK:
return _write_webui_sidebar_state(raw)
def _write_webui_sidebar_state(raw: dict[str, Any]) -> dict[str, Any]:
state = normalize_webui_sidebar_state(raw) state = normalize_webui_sidebar_state(raw)
state["updated_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) state["updated_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
encoded = json.dumps( encoded = json.dumps(
+99 -1
View File
@@ -1,5 +1,9 @@
import json import json
import threading
import time
from concurrent.futures import ThreadPoolExecutor
import nanobot.webui.sidebar_state as sidebar_state
from nanobot.webui.sidebar_state import ( from nanobot.webui.sidebar_state import (
default_webui_sidebar_state, default_webui_sidebar_state,
read_webui_sidebar_state, read_webui_sidebar_state,
@@ -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" 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) monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
path = webui_sidebar_state_path() path = webui_sidebar_state_path()
path.parent.mkdir(parents=True, exist_ok=True) 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": ""}, "project_name_overrides": {"/repo": " Core ", "bad": ""},
"tags_by_key": {"websocket:a": ["work", "work", ""]}, "tags_by_key": {"websocket:a": ["work", "work", ""]},
"collapsed_groups": {"Earlier": 1}, "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"}, "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["project_name_overrides"] == {"/repo": "Core"}
assert state["tags_by_key"] == {"websocket:a": ["work"]} assert state["tags_by_key"] == {"websocket:a": ["work"]}
assert state["collapsed_groups"] == {"Earlier": True} 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"] == { assert state["view"] == {
"density": "comfortable", "density": "comfortable",
"show_previews": False, "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 state["view"]["sort"] == "manual"
assert webui_sidebar_state_path().is_file() assert webui_sidebar_state_path().is_file()
assert read_webui_sidebar_state()["pinned_keys"] == ["websocket:a"] assert read_webui_sidebar_state()["pinned_keys"] == ["websocket:a"]
def test_sidebar_state_persists_only_visible_workbench_groups(tmp_path, monkeypatch) -> None:
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
tabs = {
f"tab:websocket:{index}": {
"explicit": False,
"paneKeys": [f"websocket:{index}"],
"layoutPaneKeys": [f"websocket:{index}"],
"layout": "columns",
"splitRatios": [],
}
for index in range(2_000)
}
state = write_webui_sidebar_state({"workbench": {"version": 1, "tabs": tabs}})
assert state["workbench"] == {"version": 1, "tabs": {}}
assert webui_sidebar_state_path().stat().st_size < 2_048
def test_sidebar_state_requires_supported_workbench_version(tmp_path, monkeypatch) -> None:
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
state = write_webui_sidebar_state(
{
"workbench": {
"version": 2,
"tabs": {
"tab:websocket:a": {
"explicit": True,
"paneKeys": ["websocket:a"],
}
},
}
}
)
assert state["workbench"] == {"version": 1, "tabs": {}}
def test_sidebar_state_serializes_concurrent_writes(monkeypatch) -> None:
counter_lock = threading.Lock()
active_writes = 0
peak_writes = 0
def fake_write(raw: dict[str, object]) -> dict[str, object]:
nonlocal active_writes, peak_writes
with counter_lock:
active_writes += 1
peak_writes = max(peak_writes, active_writes)
time.sleep(0.01)
with counter_lock:
active_writes -= 1
return raw
monkeypatch.setattr(sidebar_state, "_write_webui_sidebar_state", fake_write)
payloads = [{"write": index} for index in range(12)]
with ThreadPoolExecutor(max_workers=4) as executor:
results = list(executor.map(sidebar_state.write_webui_sidebar_state, payloads))
assert results == payloads
assert peak_writes == 1
+527 -54
View File
@@ -12,8 +12,27 @@ import { Eye, EyeOff, Moon, PanelLeft, ShieldCheck, Sun, X } from "lucide-react"
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { channelUiPresentation } from "@/channel-plugins/registry"; import { channelUiPresentation } from "@/channel-plugins/registry";
import { Sidebar } from "@/components/Sidebar"; import { Sidebar } from "@/components/Sidebar";
import type { SidebarDeleteItem } from "@/components/ChatList";
import type { SettingsSectionKey } from "@/components/settings/SettingsView"; import type { SettingsSectionKey } from "@/components/settings/SettingsView";
import { ThreadShell } from "@/components/thread/ThreadShell"; import { ThreadShell } from "@/components/thread/ThreadShell";
import { PaneWorkbench } from "@/components/workbench/PaneWorkbench";
import {
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 { floatingSurfaceElevationClassName } from "@/components/ui/floating-surface";
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet"; import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
@@ -35,7 +54,7 @@ import {
loadSavedSecret, loadSavedSecret,
saveSecret, saveSecret,
} from "@/lib/bootstrap"; } from "@/lib/bootstrap";
import { displayTitle } from "@/lib/chat-groups"; import { displayTitle, sortSessions } from "@/lib/chat-groups";
import { deriveTitle } from "@/lib/format"; import { deriveTitle } from "@/lib/format";
import { NanobotClient } from "@/lib/nanobot-client"; import { NanobotClient } from "@/lib/nanobot-client";
import { ClientProvider, useClient } from "@/providers/ClientProvider"; import { ClientProvider, useClient } from "@/providers/ClientProvider";
@@ -1017,7 +1036,11 @@ function Shell({
deleteChat, deleteChat,
getSessionAutomations, getSessionAutomations,
} = useSessions(); } = useSessions();
const { state: sidebarState, update: updateSidebarState } = const {
state: sidebarState,
loading: sidebarStateLoading,
update: updateSidebarState,
} =
useSidebarState(sessions, !loading); useSidebarState(sessions, !loading);
const initialRouteRef = useRef<ShellRoute | null>(null); const initialRouteRef = useRef<ShellRoute | null>(null);
if (!initialRouteRef.current) initialRouteRef.current = readShellRoute(); if (!initialRouteRef.current) initialRouteRef.current = readShellRoute();
@@ -1034,15 +1057,30 @@ function Shell({
const [hostSidebarPreviewOpen, setHostSidebarPreviewOpen] = useState(false); const [hostSidebarPreviewOpen, setHostSidebarPreviewOpen] = useState(false);
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
const [sessionSearchOpen, setSessionSearchOpen] = useState(false); const [sessionSearchOpen, setSessionSearchOpen] = useState(false);
const workbenchState = sidebarState.workbench;
const updateWorkbenchState = useCallback((
updater: (current: WorkbenchState) => WorkbenchState,
) => {
void updateSidebarState((current) => {
const next = updater(current.workbench);
return next === current.workbench ? current : { ...current, workbench: next };
});
}, [updateSidebarState]);
const lastActivePaneByTabRef = useRef(new Map<string, string>());
const [creatingPane, setCreatingPane] = useState(false);
const topicSessions = sessions;
const [pendingDelete, setPendingDelete] = useState<{ const [pendingDelete, setPendingDelete] = useState<{
key: string; items: SidebarDeleteItem[];
label: string;
automations?: SessionAutomationJob[]; automations?: SessionAutomationJob[];
} | null>(null); } | null>(null);
const [pendingRename, setPendingRename] = useState<{ const [pendingRename, setPendingRename] = useState<{
key: string; key: string;
label: string; label: string;
} | null>(null); } | null>(null);
const [pendingTabRename, setPendingTabRename] = useState<{
key: string;
label: string;
} | null>(null);
const [pendingProjectRename, setPendingProjectRename] = useState<{ const [pendingProjectRename, setPendingProjectRename] = useState<{
key: string; key: string;
label: string; label: string;
@@ -1220,9 +1258,21 @@ function Shell({
if (temporarySessions[activeKey]) return temporarySessions[activeKey]; if (temporarySessions[activeKey]) return temporarySessions[activeKey];
return sessions.find((s) => s.key === activeKey) ?? null; return sessions.find((s) => s.key === activeKey) ?? null;
}, [sessions, activeKey, temporarySessions]); }, [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 runningChatIdList = useMemo(() => Array.from(runningChatIds), [runningChatIds]);
const updatedChatIdList = useMemo(() => Array.from(updatedChatIds), [updatedChatIds]); const updatedChatIdList = useMemo(() => Array.from(updatedChatIds), [updatedChatIds]);
const activeChatId = activeSession?.chatId ?? null; const activeChatId = activePaneSession?.chatId ?? null;
useEffect(() => { useEffect(() => {
activeChatIdRef.current = activeChatId; activeChatIdRef.current = activeChatId;
if (!activeChatId) return; if (!activeChatId) return;
@@ -1242,13 +1292,13 @@ function Shell({
if (activeChatId && workspaceOverrides[activeChatId]) { if (activeChatId && workspaceOverrides[activeChatId]) {
return workspaceOverrides[activeChatId]; return workspaceOverrides[activeChatId];
} }
if (activeSession?.workspaceScope) { if (activePaneSession?.workspaceScope) {
return activeSession.workspaceScope; return activePaneSession.workspaceScope;
} }
return draftWorkspaceScope ?? workspaces?.default_scope ?? null; return draftWorkspaceScope ?? workspaces?.default_scope ?? null;
}, [ }, [
activeChatId, activeChatId,
activeSession?.workspaceScope, activePaneSession?.workspaceScope,
draftWorkspaceScope, draftWorkspaceScope,
temporaryChatRequested, temporaryChatRequested,
workspaceOverrides, workspaceOverrides,
@@ -1284,6 +1334,19 @@ function Shell({
}); });
}, [loading, sessions]); }, [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(() => { useEffect(() => {
if (loading) return; if (loading) return;
const pendingCreatedKey = pendingCreatedSessionKeyRef.current; const pendingCreatedKey = pendingCreatedSessionKeyRef.current;
@@ -1715,6 +1778,18 @@ function Shell({
[pendingRename, updateSidebarState], [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( const onToggleGroup = useCallback(
(groupId: string) => { (groupId: string) => {
void updateSidebarState((current) => { void updateSidebarState((current) => {
@@ -1788,7 +1863,7 @@ function Shell({
}); });
if (activeKey === key && !sidebarState.archived_keys.includes(key)) { if (activeKey === key && !sidebarState.archived_keys.includes(key)) {
const archived = new Set([...sidebarState.archived_keys, 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({ navigate({
view: "chat", view: "chat",
activeKey: next?.key ?? null, activeKey: next?.key ?? null,
@@ -1796,18 +1871,7 @@ function Shell({
}); });
} }
}, },
[activeKey, navigate, sessions, sidebarState.archived_keys, updateSidebarState], [activeKey, navigate, sidebarState.archived_keys, topicSessions, updateSidebarState],
);
const onReorderSessions = useCallback(
(sessionOrder: string[]) => {
void updateSidebarState((current) => ({
...current,
session_order: sessionOrder,
view: { ...current.view, sort: "manual" },
}));
},
[updateSidebarState],
); );
const onToggleArchived = useCallback(() => { const onToggleArchived = useCallback(() => {
@@ -1825,6 +1889,57 @@ function Shell({
setSessionSearchOpen(true); 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(() => { useEffect(() => {
const handleKeyDown = (event: globalThis.KeyboardEvent) => { const handleKeyDown = (event: globalThis.KeyboardEvent) => {
if (event.defaultPrevented) return; if (event.defaultPrevented) return;
@@ -1902,15 +2017,15 @@ function Shell({
setMobileSidebarOpen(false); setMobileSidebarOpen(false);
const nextKey = (() => { const nextKey = (() => {
if (!activeKey) return null; if (!activeKey) return null;
if (sessions.some((session) => session.key === activeKey)) return activeKey; if (topicSessions.some((session) => session.key === activeKey)) return activeKey;
return sessions[0]?.key ?? null; return topicSessions[0]?.key ?? null;
})(); })();
navigate({ navigate({
view: "chat", view: "chat",
activeKey: nextKey, activeKey: nextKey,
settingsSection: "overview", settingsSection: "overview",
}); });
}, [activeKey, navigate, sessions]); }, [activeKey, navigate, topicSessions]);
const onRestart = useCallback(() => { const onRestart = useCallback(() => {
const chatId = activeSession?.chatId ?? client.defaultChatId; const chatId = activeSession?.chatId ?? client.defaultChatId;
@@ -2017,31 +2132,43 @@ function Shell({
}, [client, t]); }, [client, t]);
const onTurnEnd = useDeferredTitleRefresh( const onTurnEnd = useDeferredTitleRefresh(
temporaryChatActive ? null : activeSession, temporaryChatActive ? null : activePaneSession,
refresh, refresh,
); );
const onConfirmDelete = useCallback(async () => { const onConfirmDelete = useCallback(async () => {
if (!pendingDelete) return; 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 hasAutomations = (pendingDelete.automations?.length ?? 0) > 0;
const deletingActive = activeKey === key; const deletingActive = activeKey !== null && deletingKeys.has(activeKey);
const currentIndex = sessions.findIndex((s) => s.key === key); const currentIndex = topicSessions.findIndex((s) => s.key === activeKey);
const fallbackKey = deletingActive 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; : activeKey;
try { try {
for (let index = 0; index < items.length; index += 1) {
const item = items[index];
const result = await deleteChat( const result = await deleteChat(
key, item.key,
hasAutomations ? { deleteAutomations: true } : undefined, hasAutomations ? { deleteAutomations: true } : undefined,
); );
if (result.blocked_by_automations) { if (result.blocked_by_automations) {
setPendingDelete({ setPendingDelete({
...pendingDelete, items: items.slice(index),
automations: result.automations ?? [], automations: result.automations ?? [],
}); });
return; return;
} }
}
setPendingDelete(null); setPendingDelete(null);
if (deletingActive) { if (deletingActive) {
navigate({ navigate({
@@ -2053,18 +2180,24 @@ function Shell({
} catch (e) { } catch (e) {
console.error("Failed to delete session", 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) => { const onRequestDeleteMany = useCallback(async (items: SidebarDeleteItem[]) => {
let automations: SessionAutomationJob[] = []; const uniqueItems = Array.from(new Map(items.map((item) => [item.key, item])).values());
try { if (uniqueItems.length === 0) return;
automations = await getSessionAutomations(key); const automationResults = await Promise.allSettled(
} catch { uniqueItems.map((item) => getSessionAutomations(item.key)),
// Delete remains protected by the backend block; prefetch only improves the first prompt. );
} const automations = automationResults.flatMap((result) => (
setPendingDelete({ key, label, automations }); result.status === "fulfilled" ? result.value : []
));
setPendingDelete({ items: uniqueItems, automations });
}, [getSessionAutomations]); }, [getSessionAutomations]);
const onRequestDelete = useCallback((key: string, label: string) => {
void onRequestDeleteMany([{ key, label }]);
}, [onRequestDeleteMany]);
const visiblePairingRequests = useMemo( const visiblePairingRequests = useMemo(
() => { () => {
const now = Date.now(); const now = Date.now();
@@ -2109,13 +2242,214 @@ 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 const headerTitle = temporaryChatActive
? deriveTemporaryChatTitle(activeSession?.preview, t("temporaryChat.title")) ? deriveTemporaryChatTitle(activeSession?.preview, t("temporaryChat.title"))
: activeSession : activeSession
? sidebarState.title_overrides[activeSession.key] || ? titleForSession(activeSession)
activeSession.title ||
deriveTitle(activeSession.preview, t("chat.newChat"))
: t("app.brand"); : 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)) return current;
return attachWorkbenchPane(current, tabKey, paneKey);
});
}, [updateWorkbenchState]);
useEffect(() => { useEffect(() => {
if (view === "settings") { if (view === "settings") {
@@ -2147,20 +2481,49 @@ function Shell({
: t("app.documentTitle.base"); : t("app.documentTitle.base");
}, [activeSession, headerTitle, i18n.resolvedLanguage, t, view]); }, [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 = { const sidebarProps = {
sessions, sessions: sidebarTopicSessions,
temporarySessions: temporarySessionList, temporarySessions: temporarySessionList,
activeKey: view === "chat" ? activeKey : null, activeKey: view === "chat"
? (temporaryChatActive ? activeKey : activeSidebarKey)
: null,
loading, loading,
newChatActive: view === "chat" && activeKey === null, newChatActive: view === "chat" && activeKey === null,
onNewChat, onNewChat,
onSelect: onSelectChat, onSelect: onSelectSidebarItem,
onCloseTemporaryChat, onCloseTemporaryChat,
onRequestDelete, onRequestDelete,
onRequestDeleteMany,
onTogglePin, onTogglePin,
onRequestRename, onRequestRename,
onToggleArchive, onToggleArchive,
onReorderSessions, onRequestRenameTab,
paneGroups: sidebarPaneGroups,
onSelectPane: onSelectSidebarPane,
onCreateTab: onCreateWorkbenchTab,
onDetachPane: onDetachWorkbenchPane,
onDissolveTab: onDissolveWorkbenchTab,
onAttachPane: onAttachWorkbenchPane,
onToggleGroup, onToggleGroup,
onRequestRenameProject, onRequestRenameProject,
onNewChatInProject, onNewChatInProject,
@@ -2172,17 +2535,19 @@ function Shell({
onOpenSearch: onOpenSessionSearch, onOpenSearch: onOpenSessionSearch,
activeUtility: view === "apps" || view === "automations" || view === "skills" ? view : null, activeUtility: view === "apps" || view === "automations" || view === "skills" ? view : null,
onToggleArchived, onToggleArchived,
pinnedKeys: sidebarState.pinned_keys, pinnedKeys: sidebarPinnedTabKeys,
archivedKeys: sidebarState.archived_keys, archivedKeys: sidebarArchivedTabKeys,
pinnedPaneKeys: sidebarState.pinned_keys,
archivedPaneKeys: sidebarState.archived_keys,
sessionOrder: sidebarState.session_order, sessionOrder: sidebarState.session_order,
titleOverrides: sidebarState.title_overrides, titleOverrides: sidebarState.title_overrides,
projectNameOverrides: sidebarState.project_name_overrides, projectNameOverrides: sidebarState.project_name_overrides,
collapsedGroups: sidebarState.collapsed_groups, collapsedGroups: sidebarState.collapsed_groups,
runningChatIds: runningChatIdList, runningChatIds: runningChatIdList,
updatedChatIds: updatedChatIdList, updatedChatIds: updatedChatIdList,
viewState: sidebarState.view, viewState: { ...sidebarState.view, sort: automaticSidebarSort },
showArchived: sidebarState.view.show_archived, showArchived: sidebarState.view.show_archived,
archivedCount: sidebarState.archived_keys.length, archivedCount: sidebarArchivedTabKeys.length,
defaultWorkspacePath: workspaces?.default_scope.project_path ?? null, defaultWorkspacePath: workspaces?.default_scope.project_path ?? null,
}; };
const hostSidebarCollapsed = showHostChrome && !hostSidebarOpen; const hostSidebarCollapsed = showHostChrome && !hostSidebarOpen;
@@ -2318,7 +2683,7 @@ function Shell({
<SessionSearchDialog <SessionSearchDialog
open open
onOpenChange={setSessionSearchOpen} onOpenChange={setSessionSearchOpen}
sessions={sessions} sessions={topicSessions}
activeKey={activeKey} activeKey={activeKey}
loading={loading} loading={loading}
titleOverrides={sidebarState.title_overrides} titleOverrides={sidebarState.title_overrides}
@@ -2337,6 +2702,37 @@ function Shell({
view !== "chat" && "hidden", view !== "chat" && "hidden",
)} )}
> >
<PaneWorkbench
panes={renderedWorkbenchPanes}
activePaneKey={renderedActivePaneKey}
layout={renderedWorkbenchLayout}
splitRatios={renderedWorkbenchSplitRatios}
chrome={paneChromeEnabled}
showLayoutControl={activeTabVisible}
addPaneDisabled={creatingPane || activePaneLimitReached}
onActivatePane={onActivateWorkbenchPane}
onAddPane={onAddPane}
onLayoutChange={(layout) => {
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 (
<ThreadShell <ThreadShell
session={activeSession} session={activeSession}
sessions={sessions} sessions={sessions}
@@ -2349,7 +2745,9 @@ function Shell({
} }
onToggleSidebar={toggleSidebar} onToggleSidebar={toggleSidebar}
onNewChat={onNewChat} onNewChat={onNewChat}
onCreateChat={temporaryChatEnabled ? onCreateTemporaryChat : onCreateChat} onCreateChat={
temporaryChatEnabled ? onCreateTemporaryChat : onCreateChat
}
onForkChat={temporaryChatActive ? undefined : onForkChat} onForkChat={temporaryChatActive ? undefined : onForkChat}
onTurnEnd={onTurnEnd} onTurnEnd={onTurnEnd}
theme={theme} theme={theme}
@@ -2367,6 +2765,67 @@ function Shell({
onOpenModelSettings={onOpenModelSettings} onOpenModelSettings={onOpenModelSettings}
skills={skills} skills={skills}
/> />
);
}
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 (
<ThreadShell
session={paneSession}
sessions={sessions}
title={pane.title}
onToggleSidebar={toggleSidebar}
onNewChat={onNewChat}
onCreateChat={onCreateChat}
onForkChat={onForkChat}
onTurnEnd={context.active ? onTurnEnd : () => 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}
/>
);
}}
/>
</div> </div>
{view !== "chat" && ( {view !== "chat" && (
<div className="absolute inset-0 flex flex-col"> <div className="absolute inset-0 flex flex-col">
@@ -2398,7 +2857,8 @@ function Shell({
<Suspense fallback={null}> <Suspense fallback={null}>
<DeleteConfirm <DeleteConfirm
open open
title={pendingDelete.label} title={pendingDelete.items[0]?.label ?? ""}
count={pendingDelete.items.length}
automations={pendingDelete.automations} automations={pendingDelete.automations}
onCancel={() => setPendingDelete(null)} onCancel={() => setPendingDelete(null)}
onConfirm={onConfirmDelete} onConfirm={onConfirmDelete}
@@ -2415,6 +2875,19 @@ function Shell({
/> />
</Suspense> </Suspense>
) : null} ) : null}
{pendingTabRename ? (
<Suspense fallback={null}>
<RenameChatDialog
open
title={pendingTabRename.label}
dialogTitle={t("workbench.renameTabTitle")}
description={t("workbench.renameTabDescription")}
placeholder={t("workbench.renameTabPlaceholder")}
onCancel={() => setPendingTabRename(null)}
onConfirm={onConfirmTabRename}
/>
</Suspense>
) : null}
{pendingProjectRename ? ( {pendingProjectRename ? (
<Suspense fallback={null}> <Suspense fallback={null}>
<RenameChatDialog <RenameChatDialog
File diff suppressed because it is too large Load Diff
+18 -2
View File
@@ -18,6 +18,7 @@ import type { SessionAutomationJob } from "@/lib/types";
interface DeleteConfirmProps { interface DeleteConfirmProps {
open: boolean; open: boolean;
title: string; title: string;
count?: number;
automations?: SessionAutomationJob[]; automations?: SessionAutomationJob[];
onCancel: () => void; onCancel: () => void;
onConfirm: () => void; onConfirm: () => void;
@@ -26,6 +27,7 @@ interface DeleteConfirmProps {
export function DeleteConfirm({ export function DeleteConfirm({
open, open,
title, title,
count = 1,
automations = [], automations = [],
onCancel, onCancel,
onConfirm, onConfirm,
@@ -33,6 +35,7 @@ export function DeleteConfirm({
const { t } = useTranslation(); const { t } = useTranslation();
const locale = currentLocale(); const locale = currentLocale();
const hasAutomations = automations.length > 0; const hasAutomations = automations.length > 0;
const multiple = count > 1;
const visibleAutomations = automations.slice(0, 4); const visibleAutomations = automations.slice(0, 4);
const hiddenCount = Math.max(0, automations.length - visibleAutomations.length); const hiddenCount = Math.max(0, automations.length - visibleAutomations.length);
return ( return (
@@ -47,11 +50,24 @@ export function DeleteConfirm({
</div> </div>
</div> </div>
<AlertDialogTitle className="text-center text-[20px] font-semibold leading-tight tracking-[-0.02em] text-foreground"> <AlertDialogTitle className="text-center text-[20px] font-semibold leading-tight tracking-[-0.02em] text-foreground">
{t("deleteConfirm.title", { title })} {multiple
? t("deleteConfirm.titleMany", {
defaultValue: "Delete {{count}} topics and panes?",
count,
})
: t("deleteConfirm.title", { title })}
</AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription className="mt-3 max-w-[17rem] text-center text-[14px] leading-6 text-muted-foreground"> <AlertDialogDescription className="mt-3 max-w-[17rem] text-center text-[14px] leading-6 text-muted-foreground">
{hasAutomations {hasAutomations
? t("deleteConfirm.automationsDescription") ? 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")} : t("deleteConfirm.description")}
</AlertDialogDescription> </AlertDialogDescription>
{hasAutomations ? ( {hasAutomations ? (
+28 -3
View File
@@ -16,7 +16,11 @@ import {
} from "lucide-react"; } from "lucide-react";
import { useTranslation } from "react-i18next"; 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 { ConnectionBadge } from "@/components/ConnectionBadge";
import { import {
SIDEBAR_SELECTION_ACTION_ITEM_CLASS, SIDEBAR_SELECTION_ACTION_ITEM_CLASS,
@@ -39,10 +43,20 @@ interface SidebarProps {
onSelect: (key: string) => void; onSelect: (key: string) => void;
onCloseTemporaryChat?: (key: string) => void; onCloseTemporaryChat?: (key: string) => void;
onRequestDelete: (key: string, label: string) => void; onRequestDelete: (key: string, label: string) => void;
onRequestDeleteMany?: (items: SidebarDeleteItem[]) => void;
onTogglePin: (key: string) => void; onTogglePin: (key: string) => void;
onRequestRename: (key: string, label: string) => void; onRequestRename: (key: string, label: string) => void;
onRequestRenameTab?: (key: string, label: string) => void;
onToggleArchive: (key: string) => void; onToggleArchive: (key: string) => void;
onReorderSessions: (keys: string[]) => void; paneGroups?: Record<string, SidebarPaneGroup>;
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; onToggleGroup: (groupId: string) => void;
onRequestRenameProject: (projectKey: string, label: string) => void; onRequestRenameProject: (projectKey: string, label: string) => void;
onNewChatInProject: (projectPath: string, projectName: string) => void; onNewChatInProject: (projectPath: string, projectName: string) => void;
@@ -60,6 +74,8 @@ interface SidebarProps {
collapsed?: boolean; collapsed?: boolean;
pinnedKeys?: string[]; pinnedKeys?: string[];
archivedKeys?: string[]; archivedKeys?: string[];
pinnedPaneKeys?: string[];
archivedPaneKeys?: string[];
sessionOrder?: string[]; sessionOrder?: string[];
titleOverrides?: Record<string, string>; titleOverrides?: Record<string, string>;
projectNameOverrides?: Record<string, string>; projectNameOverrides?: Record<string, string>;
@@ -230,15 +246,24 @@ export function Sidebar(props: SidebarProps) {
onSelect={props.onSelect} onSelect={props.onSelect}
onCloseTemporaryChat={props.onCloseTemporaryChat} onCloseTemporaryChat={props.onCloseTemporaryChat}
onRequestDelete={props.onRequestDelete} onRequestDelete={props.onRequestDelete}
onRequestDeleteMany={props.onRequestDeleteMany}
onTogglePin={props.onTogglePin} onTogglePin={props.onTogglePin}
onRequestRename={props.onRequestRename} onRequestRename={props.onRequestRename}
onRequestRenameTab={props.onRequestRenameTab}
onToggleArchive={props.onToggleArchive} 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} onToggleGroup={props.onToggleGroup}
onRequestRenameProject={props.onRequestRenameProject} onRequestRenameProject={props.onRequestRenameProject}
onNewChatInProject={props.onNewChatInProject} onNewChatInProject={props.onNewChatInProject}
pinnedKeys={props.pinnedKeys} pinnedKeys={props.pinnedKeys}
archivedKeys={props.archivedKeys} archivedKeys={props.archivedKeys}
pinnedPaneKeys={props.pinnedPaneKeys}
archivedPaneKeys={props.archivedPaneKeys}
sessionOrder={props.sessionOrder} sessionOrder={props.sessionOrder}
titleOverrides={props.titleOverrides} titleOverrides={props.titleOverrides}
projectNameOverrides={props.projectNameOverrides} projectNameOverrides={props.projectNameOverrides}
@@ -186,6 +186,7 @@ interface ThreadComposerProps {
) => boolean | void | Promise<boolean | void>; ) => boolean | void | Promise<boolean | void>;
disabled?: boolean; disabled?: boolean;
placeholder?: string; placeholder?: string;
inputAriaLabel?: string;
isStreaming?: boolean; isStreaming?: boolean;
modelLabel?: string | null; modelLabel?: string | null;
modelDetail?: string | null; modelDetail?: string | null;
@@ -940,6 +941,7 @@ export function ThreadComposer({
onSend, onSend,
disabled, disabled,
placeholder, placeholder,
inputAriaLabel,
isStreaming = false, isStreaming = false,
modelLabel = null, modelLabel = null,
modelDetail = null, modelDetail = null,
@@ -2400,7 +2402,7 @@ export function ThreadComposer({
rows={1} rows={1}
placeholder={sessionDragPreview ? "" : resolvedPlaceholder} placeholder={sessionDragPreview ? "" : resolvedPlaceholder}
disabled={interactionDisabled} disabled={interactionDisabled}
aria-label={t("thread.composer.inputAria")} aria-label={inputAriaLabel ?? t("thread.composer.inputAria")}
className={cn( className={cn(
inputTextClasses, inputTextClasses,
"relative z-10 caret-foreground placeholder:text-muted-foreground/70", "relative z-10 caret-foreground placeholder:text-muted-foreground/70",
+10 -1
View File
@@ -17,8 +17,11 @@ interface ThreadHeaderProps {
theme: "light" | "dark"; theme: "light" | "dark";
onToggleTheme: () => void; onToggleTheme: () => void;
hideSidebarToggleForHostChrome?: boolean; hideSidebarToggleForHostChrome?: boolean;
hideSidebarToggle?: boolean;
hostChromeTitleInset?: boolean; hostChromeTitleInset?: boolean;
hideThemeButton?: boolean; hideThemeButton?: boolean;
hideTitle?: boolean;
actions?: ReactNode;
minimal?: boolean; minimal?: boolean;
promptNavigatorAction?: ReactNode; promptNavigatorAction?: ReactNode;
sessionInfoAction?: ReactNode; sessionInfoAction?: ReactNode;
@@ -33,8 +36,11 @@ export function ThreadHeader({
theme, theme,
onToggleTheme, onToggleTheme,
hideSidebarToggleForHostChrome = false, hideSidebarToggleForHostChrome = false,
hideSidebarToggle = false,
hostChromeTitleInset = false, hostChromeTitleInset = false,
hideThemeButton = false, hideThemeButton = false,
hideTitle = false,
actions,
minimal = false, minimal = false,
promptNavigatorAction, promptNavigatorAction,
sessionInfoAction, sessionInfoAction,
@@ -54,6 +60,7 @@ export function ThreadHeader({
)} )}
> >
<div className="relative flex min-w-0 items-center gap-2"> <div className="relative flex min-w-0 items-center gap-2">
{!hideSidebarToggle ? (
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@@ -66,7 +73,8 @@ export function ThreadHeader({
> >
<Menu className="h-3.5 w-3.5" /> <Menu className="h-3.5 w-3.5" />
</Button> </Button>
{!minimal ? ( ) : null}
{!minimal && !hideTitle ? (
<div className="flex min-w-0 items-center rounded-md px-1.5 py-1 text-[12px] font-medium text-muted-foreground"> <div className="flex min-w-0 items-center rounded-md px-1.5 py-1 text-[12px] font-medium text-muted-foreground">
<span className="max-w-[min(60vw,32rem)] truncate">{title}</span> <span className="max-w-[min(60vw,32rem)] truncate">{title}</span>
</div> </div>
@@ -76,6 +84,7 @@ export function ThreadHeader({
<div className="ml-auto flex shrink-0 items-center gap-1"> <div className="ml-auto flex shrink-0 items-center gap-1">
{sessionInfoAction} {sessionInfoAction}
{promptNavigatorAction} {promptNavigatorAction}
{actions}
{onTemporaryChatEnabledChange ? ( {onTemporaryChatEnabledChange ? (
<TooltipProvider delayDuration={700} skipDelayDuration={0}> <TooltipProvider delayDuration={700} skipDelayDuration={0}>
<Tooltip> <Tooltip>
+49 -9
View File
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; 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 { useTranslation } from "react-i18next";
import { FilePreviewAvailabilityProvider } from "@/components/FilePreviewAvailabilityContext"; import { FilePreviewAvailabilityProvider } from "@/components/FilePreviewAvailabilityContext";
@@ -311,9 +312,18 @@ interface ThreadShellProps {
theme?: "light" | "dark"; theme?: "light" | "dark";
onToggleTheme?: () => void; onToggleTheme?: () => void;
hideSidebarToggleForHostChrome?: boolean; hideSidebarToggleForHostChrome?: boolean;
hideSidebarToggle?: boolean;
hostChromeTitleInset?: boolean; hostChromeTitleInset?: boolean;
hideThemeButton?: boolean; hideThemeButton?: boolean;
hideHeaderTitle?: boolean;
hideHeader?: boolean; hideHeader?: boolean;
headerActions?: ReactNode;
headerPortalTarget?: HTMLElement | null;
headerActive?: boolean;
composerPortalTarget?: HTMLElement | null;
composerActive?: boolean;
composerInputAriaLabel?: string;
emptyComposerVariant?: "hero" | "thread";
workspaceScope?: WorkspaceScopePayload | null; workspaceScope?: WorkspaceScopePayload | null;
workspaceDefaultScope?: WorkspaceScopePayload | null; workspaceDefaultScope?: WorkspaceScopePayload | null;
workspaceControls?: WorkspacesPayload["controls"] | null; workspaceControls?: WorkspacesPayload["controls"] | null;
@@ -598,9 +608,18 @@ export function ThreadShell({
theme = "light", theme = "light",
onToggleTheme = () => {}, onToggleTheme = () => {},
hideSidebarToggleForHostChrome = false, hideSidebarToggleForHostChrome = false,
hideSidebarToggle = false,
hostChromeTitleInset = false, hostChromeTitleInset = false,
hideThemeButton = false, hideThemeButton = false,
hideHeaderTitle = false,
hideHeader = false, hideHeader = false,
headerActions,
headerPortalTarget,
headerActive = true,
composerPortalTarget,
composerActive = true,
composerInputAriaLabel,
emptyComposerVariant = "hero",
workspaceScope = null, workspaceScope = null,
workspaceDefaultScope = null, workspaceDefaultScope = null,
workspaceControls = null, workspaceControls = null,
@@ -843,6 +862,7 @@ export function ThreadShell({
]); ]);
const showHeroComposer = displayMessages.length === 0 && !loading; const showHeroComposer = displayMessages.length === 0 && !loading;
const composerVariant = showHeroComposer ? emptyComposerVariant : "thread";
const wasShowingHeroComposerRef = useRef(showHeroComposer); const wasShowingHeroComposerRef = useRef(showHeroComposer);
const sessionModelPreset = session?.modelPreset?.trim() || null; const sessionModelPreset = session?.modelPreset?.trim() || null;
const [localModelPreset, setLocalModelPreset] = useState<string | null>(null); const [localModelPreset, setLocalModelPreset] = useState<string | null>(null);
@@ -1405,9 +1425,10 @@ export function ThreadShell({
<ThreadComposer <ThreadComposer
onSend={handleThreadSend} onSend={handleThreadSend}
disabled={!chatId} disabled={!chatId}
inputAriaLabel={composerInputAriaLabel}
isStreaming={turnActive} isStreaming={turnActive}
placeholder={ placeholder={
showHeroComposer composerVariant === "hero"
? t("thread.composer.placeholderHero") ? t("thread.composer.placeholderHero")
: t("thread.composer.placeholderThread") : t("thread.composer.placeholderThread")
} }
@@ -1421,7 +1442,7 @@ export function ThreadShell({
modelNeedsSetup={modelBadge.needsSetup} modelNeedsSetup={modelBadge.needsSetup}
fallbackModelName={fallbackModelName} fallbackModelName={fallbackModelName}
onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined} onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined}
variant={showHeroComposer ? "hero" : "thread"} variant={composerVariant}
slashCommands={availableSlashCommands} slashCommands={availableSlashCommands}
cliApps={cliApps} cliApps={cliApps}
mcpPresets={mcpPresets} mcpPresets={mcpPresets}
@@ -1449,6 +1470,7 @@ export function ThreadShell({
<ThreadComposer <ThreadComposer
onSend={handleWelcomeSend} onSend={handleWelcomeSend}
disabled={booting} disabled={booting}
inputAriaLabel={composerInputAriaLabel}
isStreaming={turnActive} isStreaming={turnActive}
placeholder={ placeholder={
booting booting
@@ -1508,18 +1530,18 @@ export function ThreadShell({
/> />
) : undefined; ) : undefined;
return ( const threadHeader = !hideHeader ? (
<section ref={shellRef} className="relative flex min-h-0 flex-1 overflow-hidden">
<div className="relative flex min-w-0 flex-1 flex-col overflow-hidden">
{!hideHeader ? (
<ThreadHeader <ThreadHeader
title={title} title={title}
onToggleSidebar={onToggleSidebar} onToggleSidebar={onToggleSidebar}
theme={theme} theme={theme}
onToggleTheme={onToggleTheme} onToggleTheme={onToggleTheme}
hideSidebarToggleForHostChrome={hideSidebarToggleForHostChrome} hideSidebarToggleForHostChrome={hideSidebarToggleForHostChrome}
hideSidebarToggle={hideSidebarToggle}
hostChromeTitleInset={hostChromeTitleInset} hostChromeTitleInset={hostChromeTitleInset}
hideThemeButton={hideThemeButton} hideThemeButton={hideThemeButton}
hideTitle={hideHeaderTitle}
actions={headerActions}
minimal={!session && !loading} minimal={!session && !loading}
promptNavigatorAction={promptNavigatorAction} promptNavigatorAction={promptNavigatorAction}
sessionInfoAction={sessionInfoAction} sessionInfoAction={sessionInfoAction}
@@ -1529,7 +1551,12 @@ export function ThreadShell({
showTemporaryChatControl ? onTemporaryChatEnabledChange : undefined showTemporaryChatControl ? onTemporaryChatEnabledChange : undefined
} }
/> />
) : null} ) : null;
return (
<section ref={shellRef} className="relative flex min-h-0 flex-1 overflow-hidden">
<div className="relative flex min-w-0 flex-1 flex-col overflow-hidden">
{headerPortalTarget === undefined ? threadHeader : null}
<FilePreviewAvailabilityProvider <FilePreviewAvailabilityProvider
resolve={historyKey ? resolveFilePreviewAvailability : undefined} resolve={historyKey ? resolveFilePreviewAvailability : undefined}
> >
@@ -1539,7 +1566,7 @@ export function ThreadShell({
temporary={temporary} temporary={temporary}
isStreaming={turnActive} isStreaming={turnActive}
emptyState={emptyState} emptyState={emptyState}
composer={composer} composer={composerPortalTarget === undefined ? composer : null}
activeTurnId={viewportTurnId} activeTurnId={viewportTurnId}
activeTurnStartedHere={activeTurnStartedHere} activeTurnStartedHere={activeTurnStartedHere}
conversationKey={historyKey} conversationKey={historyKey}
@@ -1559,6 +1586,19 @@ export function ThreadShell({
/> />
</FilePreviewAvailabilityProvider> </FilePreviewAvailabilityProvider>
</div> </div>
{headerPortalTarget && headerActive
? createPortal(threadHeader, headerPortalTarget)
: null}
{composerPortalTarget ? createPortal(
<div
hidden={!composerActive}
aria-hidden={!composerActive}
data-testid={composerActive ? "active-pane-composer" : undefined}
>
{composer}
</div>,
composerPortalTarget,
) : null}
{filePreviewPath && historyKey ? ( {filePreviewPath && historyKey ? (
<FilePreviewPanel <FilePreviewPanel
sessionKey={historyKey} sessionKey={historyKey}
+17 -4
View File
@@ -37,7 +37,7 @@ interface ThreadViewportProps {
messages: UIMessage[]; messages: UIMessage[];
temporary?: boolean; temporary?: boolean;
isStreaming: boolean; isStreaming: boolean;
composer: ReactNode; composer?: ReactNode;
emptyState?: ReactNode; emptyState?: ReactNode;
scrollToBottomSignal?: number; scrollToBottomSignal?: number;
activeTurnId?: string | null; activeTurnId?: string | null;
@@ -61,6 +61,7 @@ interface ThreadViewportProps {
const NEAR_BOTTOM_PX = 48; const NEAR_BOTTOM_PX = 48;
const NEAR_TOP_PX = 96; const NEAR_TOP_PX = 96;
const DEFAULT_SCROLL_BUTTON_BOTTOM_PX = 192; const DEFAULT_SCROLL_BUTTON_BOTTOM_PX = 192;
const EXTERNAL_COMPOSER_SCROLL_BUTTON_BOTTOM_PX = 16;
const SCROLL_BUTTON_COMPOSER_GAP_PX = 16; const SCROLL_BUTTON_COMPOSER_GAP_PX = 16;
const SOFT_KEYBOARD_MIN_INSET_PX = 80; const SOFT_KEYBOARD_MIN_INSET_PX = 80;
export const INITIAL_HISTORY_WINDOW = 160; export const INITIAL_HISTORY_WINDOW = 160;
@@ -266,11 +267,14 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
forkBoundaryMessageCount !== null && forkBoundaryMessageCount > hiddenMessageCount forkBoundaryMessageCount !== null && forkBoundaryMessageCount > hiddenMessageCount
? forkBoundaryMessageCount - hiddenMessageCount ? forkBoundaryMessageCount - hiddenMessageCount
: null; : null;
const hasComposer = composer !== null && composer !== undefined;
const scrollButtonBottom = const scrollButtonBottom =
keyboardInsetBottom keyboardInsetBottom
+ (composerDockHeight > 0 + (composerDockHeight > 0
? composerDockHeight + SCROLL_BUTTON_COMPOSER_GAP_PX ? 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 = const scrollViewportStyle =
keyboardInsetBottom > 0 ? { bottom: keyboardInsetBottom } : undefined; keyboardInsetBottom > 0 ? { bottom: keyboardInsetBottom } : undefined;
@@ -661,7 +665,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
<div <div
ref={contentRef} ref={contentRef}
data-testid={!hasMessages ? "thread-welcome-layout" : undefined} data-testid={!hasMessages ? "thread-welcome-layout" : undefined}
data-layout={hasMessages ? "thread" : "hero"} data-layout={hasComposer ? (hasMessages ? "thread" : "hero") : "external"}
className={cn( className={cn(
"thread-layout mx-auto grid min-h-full w-full", "thread-layout mx-auto grid min-h-full w-full",
hasMessages hasMessages
@@ -699,11 +703,17 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
<div ref={bottomRef} aria-hidden className="h-px shrink-0" /> <div ref={bottomRef} aria-hidden className="h-px shrink-0" />
</div> </div>
) : ( ) : (
<div className="row-start-1 flex min-h-0 min-w-0 w-full items-center justify-center sm:items-end sm:pb-8"> <div
className={cn(
"row-start-1 flex min-h-0 min-w-0 w-full items-center justify-center",
hasComposer && "sm:items-end sm:pb-8",
)}
>
{emptyState} {emptyState}
</div> </div>
)} )}
{hasComposer ? (
<div <div
ref={composerDockRef} ref={composerDockRef}
data-testid="thread-composer-dock" data-testid="thread-composer-dock"
@@ -746,11 +756,14 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
</div> </div>
</div> </div>
</div> </div>
) : null}
{hasComposer ? (
<div <div
aria-hidden aria-hidden
className="thread-layout-spacer row-start-3 min-h-0 overflow-hidden" className="thread-layout-spacer row-start-3 min-h-0 overflow-hidden"
/> />
) : null}
</div> </div>
{!hasMessages ? <div ref={bottomRef} aria-hidden className="h-px" /> : null} {!hasMessages ? <div ref={bottomRef} aria-hidden className="h-px" /> : null}
</div> </div>
+40 -1
View File
@@ -1,6 +1,6 @@
import * as React from "react"; import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { Circle } from "lucide-react"; import { ChevronRight, Circle } from "lucide-react";
import { import {
floatingItemClassName, floatingItemClassName,
@@ -13,6 +13,7 @@ import { cn } from "@/lib/utils";
const DropdownMenu = DropdownMenuPrimitive.Root; const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const menuItemClassName = const menuItemClassName =
`${floatingItemClassName} ${floatingItemFocusClassName} cursor-default data-[disabled]:pointer-events-none data-[disabled]:opacity-50`; `${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; DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(menuItemClassName, inset && "pl-8", className)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-3.5 w-3.5 text-muted-foreground" aria-hidden />
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, sideOffset = 6, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
sideOffset={sideOffset}
className={cn(
floatingSurfaceClassName,
floatingSurfaceMotionClassName,
"max-h-[min(var(--radix-dropdown-menu-content-available-height),22rem)] min-w-[11rem] overflow-y-auto",
className,
)}
{...props}
/>
));
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
export { export {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -123,5 +159,8 @@ export {
DropdownMenuRadioGroup, DropdownMenuRadioGroup,
DropdownMenuRadioItem, DropdownMenuRadioItem,
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger, DropdownMenuTrigger,
}; };
@@ -0,0 +1,819 @@
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,
type EffectiveWorkbenchLayout,
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;
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<string, DOMRect>,
paneKey: string,
direction: "left" | "right" | "up" | "down",
): string | null {
const source = rects.get(paneKey);
if (!source) return null;
const sourceX = source.left + source.width / 2;
const sourceY = source.top + source.height / 2;
let best: { key: string; score: number } | null = null;
for (const [key, rect] of rects) {
if (key === paneKey) continue;
const deltaX = rect.left + rect.width / 2 - sourceX;
const deltaY = rect.top + rect.height / 2 - sourceY;
const primary = direction === "left"
? -deltaX
: direction === "right"
? deltaX
: direction === "up"
? -deltaY
: deltaY;
if (primary <= 1) continue;
const cross = direction === "left" || direction === "right"
? Math.abs(deltaY)
: Math.abs(deltaX);
const score = primary + cross * 0.35;
if (!best || score < best.score) best = { key, score };
}
return best?.key ?? null;
}
function HeaderIconButton({
disabled,
icon: Icon,
label,
onClick,
}: {
disabled?: boolean;
icon: LucideIcon;
label: string;
onClick: () => void;
}) {
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
disabled={disabled}
aria-label={label}
onClick={onClick}
className="host-no-drag h-8 w-8 shrink-0 rounded-full text-muted-foreground/85 hover:bg-accent/40 hover:text-foreground"
>
<Icon className="h-4 w-4" aria-hidden />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{label}</TooltipContent>
</Tooltip>
);
}
export function PaneWorkbench({
panes,
activePaneKey,
layout,
chrome = true,
showLayoutControl,
addPaneDisabled = false,
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: EffectiveWorkbenchLayout = compact ? "compact" : layout;
const [headerPortalTarget, setHeaderPortalTarget] = useState<HTMLElement | null>(null);
const [composerPortalTarget, setComposerPortalTarget] = useState<HTMLElement | null>(null);
const gridRef = useRef<HTMLDivElement | null>(null);
const paneRefs = useRef(new Map<string, HTMLElement>());
const lastRectsRef = useRef(new Map<string, DOMRect>());
const pendingRectsRef = useRef<Map<string, DOMRect> | null>(null);
const animationsRef = useRef(new Map<string, Animation>());
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<number | null>(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<string | null>(null);
const displayedPanes = useMemo(() => {
const byKey = new Map(panes.map((pane) => [pane.key, pane]));
return [
...previewPaneKeys.map((key) => byKey.get(key)).filter(
(pane): pane is WorkbenchPane => pane !== undefined,
),
...panes.filter((pane) => !previewPaneKeys.includes(pane.key)),
];
}, [panes, previewPaneKeys]);
const paneOrder = displayedPanes.map((pane) => pane.key).join("\u0000");
useEffect(() => {
if (dragGestureRef.current) return;
previewPaneKeysRef.current = sourcePaneOrder;
setPreviewPaneKeys(sourcePaneOrder);
}, [sourcePaneOrder, sourcePaneOrderKey]);
useEffect(() => {
if (resizeGestureRef.current) return;
previewSplitRatiosRef.current = splitRatios;
setPreviewSplitRatios(splitRatios);
}, [sourceSplitRatiosKey, splitRatios]);
const measurePanes = useCallback(() => {
const rects = new Map<string, DOMRect>();
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<HTMLElement>,
) => {
activatePane(key, event.target);
}, [activatePane]);
const handlePaneFocus = useCallback((key: string, event: FocusEvent<HTMLElement>) => {
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<HTMLButtonElement>,
) => {
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<HTMLButtonElement>,
) => {
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,
panes.length,
previewSplitRatios,
), [effectiveLayout, panes.length, previewSplitRatios]);
const handleResizePointerDown = useCallback((
handle: WorkbenchResizeHandle,
event: ReactPointerEvent<HTMLButtonElement>,
) => {
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<HTMLButtonElement>,
) => {
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 ? (
<div
data-workbench-pane-action
className="host-no-drag flex items-center gap-0.5"
>
{showLayoutControl ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
aria-label={t("workbench.layout", {
defaultValue: "Pane layout",
})}
className="host-no-drag h-8 w-8 rounded-full text-muted-foreground/85 hover:bg-accent/40 hover:text-foreground"
>
<currentLayout.icon className="h-4 w-4" aria-hidden />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
onCloseAutoFocus={(event) => event.preventDefault()}
>
<DropdownMenuLabel>
{t("workbench.layout", { defaultValue: "Pane layout" })}
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuRadioGroup
value={layout}
onValueChange={(value) => {
const next = value as WorkbenchLayout;
if (next === layout) return;
captureLayout();
onLayoutChange(next);
}}
>
{LAYOUT_CONTROLS.map((control) => (
<DropdownMenuRadioItem
key={control.layout}
value={control.layout}
>
<control.icon aria-hidden />
{t(`workbench.layouts.${control.layout}`, {
defaultValue: control.label,
})}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
) : null}
<HeaderIconButton
disabled={addPaneDisabled}
icon={Plus}
label={t("workbench.addPane", { defaultValue: "Add pane" })}
onClick={() => {
captureLayout();
onAddPane();
}}
/>
</div>
) : null;
return (
<section
aria-label={t("workbench.aria", { defaultValue: "Conversation workbench" })}
className="flex h-full min-h-0 flex-col overflow-hidden bg-background"
>
<TooltipProvider delayDuration={500} skipDelayDuration={100}>
{chrome ? (
<header className="shrink-0 bg-background">
<div
ref={setHeaderPortalTarget}
data-testid="workbench-header-host"
/>
</header>
) : null}
<div className="relative min-h-0 flex-1 bg-background">
<div
ref={gridRef}
data-testid="pane-grid"
data-layout={effectiveLayout}
className={cn(
"grid h-full min-h-0 min-w-0 overflow-hidden",
chrome && panes.length > 1 && "gap-px bg-border/55",
)}
style={gridStyle}
>
{displayedPanes.map((pane, index) => {
const active = pane.key === activePaneKey;
const hidden = effectiveLayout === "compact" && !active;
return (
<section
key={pane.reactKey ?? pane.key}
ref={(element) => {
if (element) paneRefs.current.set(pane.key, element);
else paneRefs.current.delete(pane.key);
}}
hidden={hidden}
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 ? (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
tabIndex={active ? 0 : -1}
aria-hidden={active ? undefined : true}
aria-label={active ? t("workbench.movePane", {
defaultValue: "Move {{title}} pane",
title: pane.title,
}) : undefined}
aria-keyshortcuts={active ? "ArrowLeft ArrowRight ArrowUp ArrowDown" : undefined}
data-workbench-pane-action
data-workbench-move-handle
data-testid={`pane-move-handle-${pane.key}`}
onPointerDown={(event) => handleMovePointerDown(pane.key, event)}
onKeyDown={(event) => handleMoveKeyDown(pane.key, event)}
className={cn(
"group host-no-drag absolute bottom-0 left-1/2 z-30 flex h-6 w-12 -translate-x-1/2 touch-none items-center justify-center rounded-full",
"cursor-grab focus-visible:outline-none active:cursor-grabbing",
"transition-opacity duration-100 motion-reduce:transition-none",
active ? "pointer-events-auto opacity-100" : "pointer-events-none opacity-0",
)}
>
<span
aria-hidden
className={cn(
"h-[3px] w-9 rounded-full bg-foreground/35 transition-[height,background-color] duration-100 motion-reduce:transition-none",
"group-focus-visible:h-[5px] group-focus-visible:bg-foreground/70",
draggingPaneKey === pane.key
? "bg-foreground/65"
: "group-hover:bg-foreground/50",
)}
/>
</button>
</TooltipTrigger>
{active ? (
<TooltipContent side="top">
{t("workbench.movePaneHint", {
defaultValue: "Drag to move · Arrow keys also work",
})}
</TooltipContent>
) : null}
</Tooltip>
) : null}
</section>
);
})}
</div>
{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 (
<button
key={`${handle.axis}-${handle.ratioIndex}`}
type="button"
role="separator"
aria-orientation={handle.axis}
aria-label={t("workbench.resizePaneBoundary", {
defaultValue: "Resize pane boundary {{index}}",
index: index + 1,
})}
aria-valuemin={Math.round(bounds.min * 100)}
aria-valuemax={Math.round(bounds.max * 100)}
aria-valuenow={Math.round(ratio * 100)}
aria-keyshortcuts={vertical
? "ArrowLeft ArrowRight Home End"
: "ArrowUp ArrowDown Home End"}
data-workbench-pane-action
data-workbench-resize-handle={handle.ratioIndex}
onPointerDown={(event) => handleResizePointerDown(handle, event)}
onKeyDown={(event) => handleResizeKeyDown(handle, event)}
className={cn(
"group host-no-drag absolute z-20 touch-none border-0 bg-transparent p-0 focus-visible:outline-none",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
vertical ? "w-3 cursor-col-resize" : "h-3 cursor-row-resize",
)}
style={resizeHandleStyle(handle)}
>
<span
aria-hidden
className={cn(
"pointer-events-none absolute bg-transparent transition-colors duration-100 motion-reduce:transition-none",
"group-hover:bg-foreground/20 group-focus-visible:bg-foreground/25",
resizingRatioIndex === handle.ratioIndex && "bg-foreground/30",
vertical
? "inset-y-0 left-1/2 w-px -translate-x-1/2"
: "inset-x-0 top-1/2 h-px -translate-y-1/2",
)}
/>
</button>
);
},
) : null}
</div>
{chrome ? (
<footer className="shrink-0 bg-background px-3 pb-[calc(0.75rem+env(safe-area-inset-bottom))] sm:px-4">
<div
ref={setComposerPortalTarget}
data-testid="workbench-composer-host"
className="mx-auto w-full max-w-[58rem]"
/>
</footer>
) : null}
</TooltipProvider>
</section>
);
}
@@ -0,0 +1,434 @@
import type { CSSProperties } from "react";
import type { WorkbenchLayout } from "@/components/workbench/workbench-model";
export type EffectiveWorkbenchLayout = WorkbenchLayout | "compact";
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<CSSProperties | undefined>;
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: EffectiveWorkbenchLayout,
paneCount: number,
splitRatios: readonly number[],
): WorkbenchLayoutGeometry {
const count = Math.max(1, paneCount);
if (layout === "compact" || 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%)",
};
}
@@ -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<WorkbenchTabState>
: {};
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<Record<string, WorkbenchTabState>>,
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<string>,
): WorkbenchState {
const tabs: Record<string, WorkbenchTabState> = {};
const claimedPaneKeys = new Set<string>();
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<string, string | null | undefined>,
): 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<string | null>((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;
}
+15
View File
@@ -40,6 +40,7 @@
--radius: 0.4375rem; --radius: 0.4375rem;
--sidebar: 40 8% 96.8%; --sidebar: 40 8% 96.8%;
--sidebar-foreground: 0 0% 3.9%; --sidebar-foreground: 0 0% 3.9%;
--sidebar-selected: 40 1% 89.4%;
--sidebar-accent: 0 0% 95.8%; --sidebar-accent: 0 0% 95.8%;
--sidebar-accent-foreground: 0 0% 9%; --sidebar-accent-foreground: 0 0% 9%;
--sidebar-border: 40 8% 90.5%; --sidebar-border: 40 8% 90.5%;
@@ -77,6 +78,7 @@
--temporary-border: 27 96% 61%; --temporary-border: 27 96% 61%;
--sidebar: var(--card); --sidebar: var(--card);
--sidebar-foreground: 0 0% 98%; --sidebar-foreground: 0 0% 98%;
--sidebar-selected: 0 0% 29.8%;
--sidebar-accent: var(--background); --sidebar-accent: var(--background);
--sidebar-accent-foreground: 0 0% 98%; --sidebar-accent-foreground: 0 0% 98%;
--sidebar-border: var(--border); --sidebar-border: var(--border);
@@ -360,6 +362,9 @@
.thread-layout[data-layout="thread"] { .thread-layout[data-layout="thread"] {
grid-template-rows: minmax(0, 1fr) auto 0fr; grid-template-rows: minmax(0, 1fr) auto 0fr;
} }
.thread-layout[data-layout="external"] {
grid-template-rows: minmax(0, 1fr);
}
@media (min-width: 640px) { @media (min-width: 640px) {
.thread-layout[data-layout="hero"] { .thread-layout[data-layout="hero"] {
grid-template-rows: minmax(min-content, 1fr) auto 1fr; 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). */ /** Goal halo: pale sky blue (not ``--primary``, which often reads as neutral gray). */
@keyframes goal-shell-glow-breathe { @keyframes goal-shell-glow-breathe {
0%, 0%,
@@ -858,3 +867,9 @@
min-height: 2rem; min-height: 2rem;
} }
} }
@media (forced-colors: active) {
[data-workbench-move-handle]:focus-visible {
outline: 2px solid CanvasText;
outline-offset: 2px;
}
}
+47 -10
View File
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useClient } from "@/providers/ClientProvider"; import { useClient } from "@/providers/ClientProvider";
import { normalizeWorkbenchState } from "@/components/workbench/workbench-model";
import { fetchSidebarState } from "@/lib/api"; import { fetchSidebarState } from "@/lib/api";
import type { ChatSummary, SidebarStatePayload } from "@/lib/types"; import type { ChatSummary, SidebarStatePayload } from "@/lib/types";
@@ -13,6 +14,7 @@ export const DEFAULT_SIDEBAR_STATE: SidebarStatePayload = {
project_name_overrides: {}, project_name_overrides: {},
tags_by_key: {}, tags_by_key: {},
collapsed_groups: {}, collapsed_groups: {},
workbench: { version: 1, tabs: {} },
view: { view: {
density: "comfortable", density: "comfortable",
show_previews: false, show_previews: false,
@@ -93,6 +95,7 @@ export function normalizeSidebarState(raw: unknown): SidebarStatePayload {
project_name_overrides: stringMap(value.project_name_overrides), project_name_overrides: stringMap(value.project_name_overrides),
tags_by_key: tagsMap(value.tags_by_key), tags_by_key: tagsMap(value.tags_by_key),
collapsed_groups: boolMap(value.collapsed_groups), collapsed_groups: boolMap(value.collapsed_groups),
workbench: normalizeWorkbenchState(value.workbench),
view: { view: {
density, density,
show_previews: Boolean(view.show_previews), show_previews: Boolean(view.show_previews),
@@ -146,6 +149,8 @@ export function useSidebarState(
const stateRef = useRef(DEFAULT_SIDEBAR_STATE); const stateRef = useRef(DEFAULT_SIDEBAR_STATE);
const connectionOpenRef = useRef(client.status === "open"); const connectionOpenRef = useRef(client.status === "open");
const pendingPersistenceRef = useRef<SidebarStatePayload | null>(null); const pendingPersistenceRef = useRef<SidebarStatePayload | null>(null);
const persistenceInFlightRef = useRef(false);
const flushPersistenceRef = useRef<() => void>(() => {});
const [state, setState] = useState<SidebarStatePayload>(DEFAULT_SIDEBAR_STATE); const [state, setState] = useState<SidebarStatePayload>(DEFAULT_SIDEBAR_STATE);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
tokenRef.current = token; tokenRef.current = token;
@@ -173,27 +178,59 @@ export function useSidebarState(
}; };
}, []); }, []);
const persist = useCallback((next: SidebarStatePayload) => { const flushPersistence = useCallback(() => {
if (!connectionOpenRef.current) { 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; pendingPersistenceRef.current = next;
return;
} }
void client.setSidebarState(next).catch(() => {
// Sidebar persistence is best-effort; the optimistic local state remains usable.
}); });
}, [client]); }, [client]);
flushPersistenceRef.current = flushPersistence;
const persist = useCallback((next: SidebarStatePayload) => {
pendingPersistenceRef.current = next;
flushPersistence();
}, [flushPersistence]);
useEffect(() => client.onStatus((status) => { useEffect(() => client.onStatus((status) => {
connectionOpenRef.current = status === "open"; connectionOpenRef.current = status === "open";
if (status !== "open" || pendingPersistenceRef.current === null) return; if (status === "open") flushPersistence();
const pending = pendingPersistenceRef.current; }), [client, flushPersistence]);
pendingPersistenceRef.current = null;
persist(pending); useEffect(() => client.onSidebarStateUpdate((incoming) => {
}), [client, persist]); if (
persistenceInFlightRef.current
|| pendingPersistenceRef.current !== null
) return;
const loaded = normalizeSidebarState(incoming);
stateRef.current = loaded;
setState(loaded);
}), [client]);
const update = useCallback( const update = useCallback(
async (updater: (current: SidebarStatePayload) => SidebarStatePayload) => { async (updater: (current: SidebarStatePayload) => SidebarStatePayload) => {
const next = normalizeSidebarState(updater(stateRef.current)); const next = normalizeSidebarState(updater(stateRef.current));
if (sameState(next, stateRef.current)) return;
stateRef.current = next; stateRef.current = next;
setState(next); setState(next);
persist(next); persist(next);
+38
View File
@@ -986,6 +986,10 @@
"unarchive": "Unarchive", "unarchive": "Unarchive",
"showArchived": "Show archived", "showArchived": "Show archived",
"hideArchived": "Hide archived", "hideArchived": "Hide archived",
"select": "Select",
"cancelSelection": "Cancel selection",
"selectedCount": "{{count}} selected",
"deleteSelected": "Delete",
"delete": "Delete", "delete": "Delete",
"newChat": "New topic", "newChat": "New topic",
"groups": { "groups": {
@@ -1000,10 +1004,13 @@
}, },
"deleteConfirm": { "deleteConfirm": {
"title": "Delete this topic?", "title": "Delete this topic?",
"titleMany": "Delete {{count}} topics and panes?",
"description": "This action cannot be undone.", "description": "This action cannot be undone.",
"descriptionMany": "This action cannot be undone.",
"cancel": "Cancel", "cancel": "Cancel",
"confirm": "Delete", "confirm": "Delete",
"automationsDescription": "This chat has scheduled automations. Deleting it will also delete them.", "automationsDescription": "This chat has scheduled automations. Deleting it will also delete them.",
"automationsDescriptionMany": "Linked automations will also be deleted.",
"moreAutomations": "+ {{count}} more", "moreAutomations": "+ {{count}} more",
"confirmWithAutomations": "Delete", "confirmWithAutomations": "Delete",
"schedule": { "schedule": {
@@ -1399,6 +1406,37 @@
"copy": "Copy", "copy": "Copy",
"copied": "Copied" "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",
"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": { "common": {
"dismiss": "Dismiss", "dismiss": "Dismiss",
"close": "Close", "close": "Close",
+38
View File
@@ -973,6 +973,10 @@
"unarchive": "Desarchivar", "unarchive": "Desarchivar",
"showArchived": "Mostrar archivados", "showArchived": "Mostrar archivados",
"hideArchived": "Ocultar archivados", "hideArchived": "Ocultar archivados",
"select": "Seleccionar",
"cancelSelection": "Cancelar selección",
"selectedCount": "{{count}} seleccionados",
"deleteSelected": "Eliminar",
"delete": "Eliminar", "delete": "Eliminar",
"newChat": "Nuevo tema", "newChat": "Nuevo tema",
"groups": { "groups": {
@@ -987,10 +991,13 @@
}, },
"deleteConfirm": { "deleteConfirm": {
"title": "¿Eliminar este chat?", "title": "¿Eliminar este chat?",
"titleMany": "¿Eliminar {{count}} chats y paneles?",
"description": "Esta acción no se puede deshacer.", "description": "Esta acción no se puede deshacer.",
"descriptionMany": "Esta acción no se puede deshacer.",
"cancel": "Cancelar", "cancel": "Cancelar",
"confirm": "Eliminar", "confirm": "Eliminar",
"automationsDescription": "Este chat tiene automatizaciones programadas. Al eliminarlo también se eliminarán.", "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", "moreAutomations": "+ {{count}} más",
"confirmWithAutomations": "Eliminar", "confirmWithAutomations": "Eliminar",
"schedule": { "schedule": {
@@ -1386,6 +1393,37 @@
"copy": "Copiar", "copy": "Copiar",
"copied": "Copiado" "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",
"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": { "common": {
"dismiss": "Cerrar", "dismiss": "Cerrar",
"close": "Cerrar", "close": "Cerrar",
+38
View File
@@ -972,6 +972,10 @@
"unarchive": "Désarchiver", "unarchive": "Désarchiver",
"showArchived": "Afficher les archives", "showArchived": "Afficher les archives",
"hideArchived": "Masquer les archives", "hideArchived": "Masquer les archives",
"select": "Sélectionner",
"cancelSelection": "Annuler la sélection",
"selectedCount": "{{count}} sélectionnés",
"deleteSelected": "Supprimer",
"delete": "Supprimer", "delete": "Supprimer",
"newChat": "Nouveau sujet", "newChat": "Nouveau sujet",
"groups": { "groups": {
@@ -986,10 +990,13 @@
}, },
"deleteConfirm": { "deleteConfirm": {
"title": "Supprimer cette discussion ?", "title": "Supprimer cette discussion ?",
"titleMany": "Supprimer {{count}} discussions et volets ?",
"description": "Cette action est irréversible.", "description": "Cette action est irréversible.",
"descriptionMany": "Cette action est irréversible.",
"cancel": "Annuler", "cancel": "Annuler",
"confirm": "Supprimer", "confirm": "Supprimer",
"automationsDescription": "Cette discussion contient des automatisations planifiées. La supprimer les supprimera aussi.", "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", "moreAutomations": "+ {{count}} autres",
"confirmWithAutomations": "Supprimer", "confirmWithAutomations": "Supprimer",
"schedule": { "schedule": {
@@ -1385,6 +1392,37 @@
"copy": "Copier", "copy": "Copier",
"copied": "Copié" "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 longlet",
"renameTabDescription": "Donnez un nom à cet onglet pour organiser ses volets.",
"renameTabPlaceholder": "Nom de longlet",
"dissolveTab": "Dissoudre le groupe",
"layout": "Disposition des volets",
"addPane": "Ajouter un volet",
"movePane": "Déplacer le volet {{title}}",
"movePaneHint": "Faites glisser pour déplacer · Les flèches fonctionnent aussi",
"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": { "common": {
"dismiss": "Fermer", "dismiss": "Fermer",
"close": "Fermer", "close": "Fermer",
+38
View File
@@ -972,6 +972,10 @@
"unarchive": "Batalkan arsip", "unarchive": "Batalkan arsip",
"showArchived": "Tampilkan yang diarsipkan", "showArchived": "Tampilkan yang diarsipkan",
"hideArchived": "Sembunyikan yang diarsipkan", "hideArchived": "Sembunyikan yang diarsipkan",
"select": "Pilih",
"cancelSelection": "Batalkan pilihan",
"selectedCount": "{{count}} dipilih",
"deleteSelected": "Hapus",
"delete": "Hapus", "delete": "Hapus",
"newChat": "Topik baru", "newChat": "Topik baru",
"groups": { "groups": {
@@ -986,10 +990,13 @@
}, },
"deleteConfirm": { "deleteConfirm": {
"title": "Hapus obrolan ini?", "title": "Hapus obrolan ini?",
"titleMany": "Hapus {{count}} obrolan dan panel?",
"description": "Tindakan ini tidak dapat dibatalkan.", "description": "Tindakan ini tidak dapat dibatalkan.",
"descriptionMany": "Tindakan ini tidak dapat dibatalkan.",
"cancel": "Batal", "cancel": "Batal",
"confirm": "Hapus", "confirm": "Hapus",
"automationsDescription": "Obrolan ini memiliki automasi terjadwal. Menghapusnya juga akan menghapus automasi tersebut.", "automationsDescription": "Obrolan ini memiliki automasi terjadwal. Menghapusnya juga akan menghapus automasi tersebut.",
"automationsDescriptionMany": "Automasi terkait juga akan dihapus.",
"moreAutomations": "+ {{count}} lagi", "moreAutomations": "+ {{count}} lagi",
"confirmWithAutomations": "Hapus", "confirmWithAutomations": "Hapus",
"schedule": { "schedule": {
@@ -1385,6 +1392,37 @@
"copy": "Salin", "copy": "Salin",
"copied": "Tersalin" "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",
"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": { "common": {
"dismiss": "Tutup", "dismiss": "Tutup",
"close": "Tutup", "close": "Tutup",
+38
View File
@@ -972,6 +972,10 @@
"unarchive": "アーカイブを解除", "unarchive": "アーカイブを解除",
"showArchived": "アーカイブ済みを表示", "showArchived": "アーカイブ済みを表示",
"hideArchived": "アーカイブ済みを隠す", "hideArchived": "アーカイブ済みを隠す",
"select": "選択",
"cancelSelection": "選択を解除",
"selectedCount": "{{count}} 件を選択中",
"deleteSelected": "削除",
"delete": "削除", "delete": "削除",
"newChat": "新しいトピック", "newChat": "新しいトピック",
"groups": { "groups": {
@@ -986,10 +990,13 @@
}, },
"deleteConfirm": { "deleteConfirm": {
"title": "このチャットを削除しますか?", "title": "このチャットを削除しますか?",
"titleMany": "{{count}} 件のチャットとペインを削除しますか?",
"description": "この操作は元に戻せません。", "description": "この操作は元に戻せません。",
"descriptionMany": "この操作は元に戻せません。",
"cancel": "キャンセル", "cancel": "キャンセル",
"confirm": "削除", "confirm": "削除",
"automationsDescription": "このチャットにはスケジュール済みの自動タスクがあります。削除するとそれらも削除されます。", "automationsDescription": "このチャットにはスケジュール済みの自動タスクがあります。削除するとそれらも削除されます。",
"automationsDescriptionMany": "関連する自動タスクも削除されます。",
"moreAutomations": "他 {{count}} 件", "moreAutomations": "他 {{count}} 件",
"confirmWithAutomations": "削除", "confirmWithAutomations": "削除",
"schedule": { "schedule": {
@@ -1385,6 +1392,37 @@
"copy": "コピー", "copy": "コピー",
"copied": "コピーしました" "copied": "コピーしました"
}, },
"workbench": {
"aria": "会話ワークベンチ",
"panes": "ペイン",
"tabAria": "タブ:{{title}}",
"panesInTab": "{{title}} のペイン",
"collapseTabGroup": "{{title}} のペインを折りたたむ",
"expandTabGroup": "{{title}} のペインを展開する",
"dropPane": "{{pane}} を {{tab}} に移動",
"createGroup": "グループを作成",
"moveTo": "移動先",
"renameTabTitle": "タブ名を変更",
"renameTabDescription": "ペインを整理するため、このタブに名前を付けます。",
"renameTabPlaceholder": "タブ名",
"dissolveTab": "グループを解除",
"layout": "ペインレイアウト",
"addPane": "ペインを追加",
"movePane": "{{title}} ペインを移動",
"movePaneHint": "ドラッグで移動 · 矢印キーでも移動できます",
"resizePaneBoundary": "ペイン境界 {{index}} のサイズを変更",
"promotePane": "{{title}} をメインペインにする",
"paneActions": "{{title}} ペインの操作",
"detachPane": "外す",
"composerAria": "{{title}} へのメッセージ",
"layouts": {
"columns": "列",
"rows": "行",
"grid": "グリッド",
"bsp": "BSP",
"main-stack": "メインとスタック"
}
},
"common": { "common": {
"dismiss": "閉じる", "dismiss": "閉じる",
"close": "閉じる", "close": "閉じる",
+38
View File
@@ -972,6 +972,10 @@
"unarchive": "보관 해제", "unarchive": "보관 해제",
"showArchived": "보관된 항목 표시", "showArchived": "보관된 항목 표시",
"hideArchived": "보관된 항목 숨기기", "hideArchived": "보관된 항목 숨기기",
"select": "선택",
"cancelSelection": "선택 취소",
"selectedCount": "{{count}}개 선택됨",
"deleteSelected": "삭제",
"delete": "삭제", "delete": "삭제",
"newChat": "새 주제", "newChat": "새 주제",
"groups": { "groups": {
@@ -986,10 +990,13 @@
}, },
"deleteConfirm": { "deleteConfirm": {
"title": "이 채팅을 삭제할까요?", "title": "이 채팅을 삭제할까요?",
"titleMany": "채팅과 창 {{count}}개를 삭제할까요?",
"description": "이 작업은 되돌릴 수 없습니다.", "description": "이 작업은 되돌릴 수 없습니다.",
"descriptionMany": "이 작업은 되돌릴 수 없습니다.",
"cancel": "취소", "cancel": "취소",
"confirm": "삭제", "confirm": "삭제",
"automationsDescription": "이 채팅에는 예약된 자동화가 있습니다. 채팅을 삭제하면 자동화도 함께 삭제됩니다.", "automationsDescription": "이 채팅에는 예약된 자동화가 있습니다. 채팅을 삭제하면 자동화도 함께 삭제됩니다.",
"automationsDescriptionMany": "연결된 자동화도 함께 삭제됩니다.",
"moreAutomations": "+ {{count}}개 더", "moreAutomations": "+ {{count}}개 더",
"confirmWithAutomations": "삭제", "confirmWithAutomations": "삭제",
"schedule": { "schedule": {
@@ -1385,6 +1392,37 @@
"copy": "복사", "copy": "복사",
"copied": "복사됨" "copied": "복사됨"
}, },
"workbench": {
"aria": "대화 워크벤치",
"panes": "창",
"tabAria": "탭: {{title}}",
"panesInTab": "{{title}}의 창",
"collapseTabGroup": "{{title}}의 창 접기",
"expandTabGroup": "{{title}}의 창 펼치기",
"dropPane": "{{pane}}을(를) {{tab}}으로 이동",
"createGroup": "그룹 만들기",
"moveTo": "이동",
"renameTabTitle": "탭 이름 바꾸기",
"renameTabDescription": "창을 정리할 수 있도록 이 탭에 이름을 지정하세요.",
"renameTabPlaceholder": "탭 이름",
"dissolveTab": "그룹 해제",
"layout": "창 레이아웃",
"addPane": "창 추가",
"movePane": "{{title}} 창 이동",
"movePaneHint": "드래그하여 이동 · 방향키로도 이동 가능",
"resizePaneBoundary": "창 경계 {{index}} 크기 조절",
"promotePane": "{{title}}을(를) 기본 창으로 설정",
"paneActions": "{{title}} 창 작업",
"detachPane": "제거",
"composerAria": "{{title}}에 메시지 보내기",
"layouts": {
"columns": "열",
"rows": "행",
"grid": "그리드",
"bsp": "BSP",
"main-stack": "기본 창과 스택"
}
},
"common": { "common": {
"dismiss": "닫기", "dismiss": "닫기",
"close": "닫기", "close": "닫기",
+38
View File
@@ -986,6 +986,10 @@
"unarchive": "Desarquivar", "unarchive": "Desarquivar",
"showArchived": "Mostrar arquivadas", "showArchived": "Mostrar arquivadas",
"hideArchived": "Ocultar arquivadas", "hideArchived": "Ocultar arquivadas",
"select": "Selecionar",
"cancelSelection": "Cancelar seleção",
"selectedCount": "{{count}} selecionados",
"deleteSelected": "Excluir",
"delete": "Excluir", "delete": "Excluir",
"newChat": "Novo tópico", "newChat": "Novo tópico",
"groups": { "groups": {
@@ -1000,10 +1004,13 @@
}, },
"deleteConfirm": { "deleteConfirm": {
"title": "Excluir esta conversa?", "title": "Excluir esta conversa?",
"titleMany": "Excluir {{count}} conversas e painéis?",
"description": "Esta ação não pode ser desfeita.", "description": "Esta ação não pode ser desfeita.",
"descriptionMany": "Esta ação não pode ser desfeita.",
"cancel": "Cancelar", "cancel": "Cancelar",
"confirm": "Excluir", "confirm": "Excluir",
"automationsDescription": "Esta conversa possui automações agendadas. Excluí-la também excluirá essas automações.", "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", "moreAutomations": "+ {{count}} a mais",
"confirmWithAutomations": "Excluir", "confirmWithAutomations": "Excluir",
"schedule": { "schedule": {
@@ -1399,6 +1406,37 @@
"copy": "Copiar", "copy": "Copiar",
"copied": "Copiado" "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",
"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": { "common": {
"dismiss": "Descartar", "dismiss": "Descartar",
"close": "Fechar", "close": "Fechar",
+38
View File
@@ -972,6 +972,10 @@
"unarchive": "Bỏ lưu trữ", "unarchive": "Bỏ lưu trữ",
"showArchived": "Hiện mục đã lưu trữ", "showArchived": "Hiện mục đã lưu trữ",
"hideArchived": "Ẩ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", "delete": "Xóa",
"newChat": "Chủ đề mới", "newChat": "Chủ đề mới",
"groups": { "groups": {
@@ -986,10 +990,13 @@
}, },
"deleteConfirm": { "deleteConfirm": {
"title": "Xóa cuộc trò chuyện này?", "title": "Xóa cuộc trò chuyện này?",
"titleMany": "Xóa {{count}} cuộc trò chuyện và khung?",
"description": "Không thể hoàn tác thao tác này.", "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", "cancel": "Hủy",
"confirm": "Xóa", "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.", "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", "moreAutomations": "+ {{count}} mục nữa",
"confirmWithAutomations": "Xóa", "confirmWithAutomations": "Xóa",
"schedule": { "schedule": {
@@ -1385,6 +1392,37 @@
"copy": "Sao chép", "copy": "Sao chép",
"copied": "Đã 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",
"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": { "common": {
"dismiss": "Đóng", "dismiss": "Đóng",
"close": "Đóng", "close": "Đóng",
+38
View File
@@ -986,6 +986,10 @@
"unarchive": "取消归档", "unarchive": "取消归档",
"showArchived": "显示归档", "showArchived": "显示归档",
"hideArchived": "隐藏归档", "hideArchived": "隐藏归档",
"select": "选择",
"cancelSelection": "取消选择",
"selectedCount": "已选择 {{count}} 项",
"deleteSelected": "删除",
"delete": "删除", "delete": "删除",
"newChat": "新建话题", "newChat": "新建话题",
"groups": { "groups": {
@@ -1000,10 +1004,13 @@
}, },
"deleteConfirm": { "deleteConfirm": {
"title": "删除这个话题?", "title": "删除这个话题?",
"titleMany": "删除这 {{count}} 个话题和窗格?",
"description": "此操作无法撤销。", "description": "此操作无法撤销。",
"descriptionMany": "此操作无法撤销。",
"cancel": "取消", "cancel": "取消",
"confirm": "删除", "confirm": "删除",
"automationsDescription": "这个话题有关联的自动任务。删除话题也会删除这些自动任务。", "automationsDescription": "这个话题有关联的自动任务。删除话题也会删除这些自动任务。",
"automationsDescriptionMany": "关联的自动任务也会一并删除。",
"moreAutomations": "另有 {{count}} 个", "moreAutomations": "另有 {{count}} 个",
"confirmWithAutomations": "删除", "confirmWithAutomations": "删除",
"schedule": { "schedule": {
@@ -1399,6 +1406,37 @@
"copy": "复制", "copy": "复制",
"copied": "已复制" "copied": "已复制"
}, },
"workbench": {
"aria": "会话工作台",
"panes": "窗格",
"tabAria": "标签页:{{title}}",
"panesInTab": "{{title}} 中的窗格",
"collapseTabGroup": "折叠 {{title}} 中的窗格",
"expandTabGroup": "展开 {{title}} 中的窗格",
"dropPane": "将 {{pane}} 移入 {{tab}}",
"createGroup": "创建分组",
"moveTo": "移动到",
"renameTabTitle": "重命名标签页",
"renameTabDescription": "为这个标签页命名,以便组织其中的窗格。",
"renameTabPlaceholder": "标签页名称",
"dissolveTab": "解散分组",
"layout": "窗格布局",
"addPane": "添加窗格",
"movePane": "移动 {{title}} 窗格",
"movePaneHint": "拖动换位 · 也可以使用方向键",
"resizePaneBoundary": "调整窗格边界 {{index}}",
"promotePane": "将 {{title}} 设为主窗格",
"paneActions": "{{title}} 窗格操作",
"detachPane": "移出",
"composerAria": "向 {{title}} 发送消息",
"layouts": {
"columns": "列布局",
"rows": "行布局",
"grid": "网格",
"bsp": "BSP",
"main-stack": "主窗格与堆栈"
}
},
"common": { "common": {
"dismiss": "关闭", "dismiss": "关闭",
"close": "关闭", "close": "关闭",
+38
View File
@@ -972,6 +972,10 @@
"unarchive": "取消封存", "unarchive": "取消封存",
"showArchived": "顯示封存", "showArchived": "顯示封存",
"hideArchived": "隱藏封存", "hideArchived": "隱藏封存",
"select": "選取",
"cancelSelection": "取消選取",
"selectedCount": "已選取 {{count}} 項",
"deleteSelected": "刪除",
"delete": "刪除", "delete": "刪除",
"newChat": "新增話題", "newChat": "新增話題",
"groups": { "groups": {
@@ -986,10 +990,13 @@
}, },
"deleteConfirm": { "deleteConfirm": {
"title": "刪除這個話題?", "title": "刪除這個話題?",
"titleMany": "刪除這 {{count}} 個話題和窗格?",
"description": "此操作無法復原。", "description": "此操作無法復原。",
"descriptionMany": "此操作無法復原。",
"cancel": "取消", "cancel": "取消",
"confirm": "刪除", "confirm": "刪除",
"automationsDescription": "這個話題含有已排程的自動任務。刪除話題時也會一併刪除這些任務。", "automationsDescription": "這個話題含有已排程的自動任務。刪除話題時也會一併刪除這些任務。",
"automationsDescriptionMany": "關聯的自動任務也會一併刪除。",
"moreAutomations": "另有 {{count}} 個", "moreAutomations": "另有 {{count}} 個",
"confirmWithAutomations": "刪除", "confirmWithAutomations": "刪除",
"schedule": { "schedule": {
@@ -1385,6 +1392,37 @@
"copy": "複製", "copy": "複製",
"copied": "已複製" "copied": "已複製"
}, },
"workbench": {
"aria": "對話工作台",
"panes": "窗格",
"tabAria": "標籤頁:{{title}}",
"panesInTab": "{{title}} 中的窗格",
"collapseTabGroup": "收合 {{title}} 中的窗格",
"expandTabGroup": "展開 {{title}} 中的窗格",
"dropPane": "將 {{pane}} 移入 {{tab}}",
"createGroup": "建立群組",
"moveTo": "移動到",
"renameTabTitle": "重新命名分頁",
"renameTabDescription": "為這個分頁命名,以便整理其中的窗格。",
"renameTabPlaceholder": "分頁名稱",
"dissolveTab": "解散群組",
"layout": "窗格佈局",
"addPane": "新增窗格",
"movePane": "移動 {{title}} 窗格",
"movePaneHint": "拖曳換位 · 也可以使用方向鍵",
"resizePaneBoundary": "調整窗格邊界 {{index}}",
"promotePane": "將 {{title}} 設為主窗格",
"paneActions": "{{title}} 窗格操作",
"detachPane": "移出",
"composerAria": "傳送訊息給 {{title}}",
"layouts": {
"columns": "欄佈局",
"rows": "列佈局",
"grid": "網格",
"bsp": "BSP",
"main-stack": "主窗格與堆疊"
}
},
"common": { "common": {
"dismiss": "關閉", "dismiss": "關閉",
"close": "關閉", "close": "關閉",
+1 -1
View File
@@ -339,7 +339,7 @@ function sortProjectSessions(
}); });
} }
function sortSessions( export function sortSessions(
sessions: ChatSummary[], sessions: ChatSummary[],
sort: SidebarSortMode, sort: SidebarSortMode,
titleOverrides: Record<string, string>, titleOverrides: Record<string, string>,
+20
View File
@@ -73,6 +73,7 @@ type SessionUpdateHandler = (
scope?: SessionUpdateScope, scope?: SessionUpdateScope,
workspaceScope?: WorkspaceScopePayload, workspaceScope?: WorkspaceScopePayload,
) => void; ) => void;
type SidebarStateUpdateHandler = (state: SidebarStatePayload) => void;
type RunStatusHandler = (chatId: string, startedAt: number | null) => void; type RunStatusHandler = (chatId: string, startedAt: number | null) => void;
/** Structured errors surfaced to the UI. /** Structured errors surfaced to the UI.
@@ -178,6 +179,7 @@ export class NanobotClient {
private statusHandlers = new Set<StatusHandler>(); private statusHandlers = new Set<StatusHandler>();
private runtimeModelHandlers = new Set<RuntimeModelHandler>(); private runtimeModelHandlers = new Set<RuntimeModelHandler>();
private sessionUpdateHandlers = new Set<SessionUpdateHandler>(); private sessionUpdateHandlers = new Set<SessionUpdateHandler>();
private sidebarStateUpdateHandlers = new Set<SidebarStateUpdateHandler>();
private runStatusHandlers = new Set<RunStatusHandler>(); private runStatusHandlers = new Set<RunStatusHandler>();
private errorHandlers = new Set<ErrorHandler>(); private errorHandlers = new Set<ErrorHandler>();
// chat_id -> handlers listening on it // 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 { onRunStatus(handler: RunStatusHandler): Unsubscribe {
this.runStatusHandlers.add(handler); this.runStatusHandlers.add(handler);
for (const [chatId, startedAt] of this.runStartedAtByChatId) { for (const [chatId, startedAt] of this.runStartedAtByChatId) {
@@ -1149,6 +1158,11 @@ export class NanobotClient {
return; return;
} }
if (parsed.event === "sidebar_state_updated") {
this.emitSidebarStateUpdate(parsed.state);
return;
}
if (parsed.event === "error" && parsed.detail === "workspace_scope_rejected") { if (parsed.event === "error" && parsed.detail === "workspace_scope_rejected") {
this.emitError({ this.emitError({
kind: "workspace_scope_rejected", 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 { private emitRunStatus(chatId: string, startedAt: number | null): void {
for (const handler of this.runStatusHandlers) { for (const handler of this.runStatusHandlers) {
handler(chatId, startedAt); handler(chatId, startedAt);
+20
View File
@@ -368,6 +368,21 @@ export interface WorkspacesPayload {
export type SidebarDensity = "comfortable" | "compact"; export type SidebarDensity = "comfortable" | "compact";
export type SidebarSortMode = "updated_desc" | "created_desc" | "title_asc" | "manual"; 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<string, WorkbenchTabState>;
}
export interface SidebarViewState { export interface SidebarViewState {
density: SidebarDensity; density: SidebarDensity;
@@ -386,6 +401,7 @@ export interface SidebarStatePayload {
project_name_overrides: Record<string, string>; project_name_overrides: Record<string, string>;
tags_by_key: Record<string, string[]>; tags_by_key: Record<string, string[]>;
collapsed_groups: Record<string, boolean>; collapsed_groups: Record<string, boolean>;
workbench: WorkbenchState;
view: SidebarViewState; view: SidebarViewState;
updated_at?: string | null; updated_at?: string | null;
} }
@@ -1279,6 +1295,10 @@ export type InboundEvent =
scope?: "metadata" | "thread" | string; scope?: "metadata" | "thread" | string;
workspace_scope?: WorkspaceScopePayload; workspace_scope?: WorkspaceScopePayload;
} }
| {
event: "sidebar_state_updated";
state: SidebarStatePayload;
}
| { event: "transcription_result"; request_id: string; text: string } | { event: "transcription_result"; request_id: string; text: string }
| { | {
event: "transcription_error"; event: "transcription_error";
+387 -3
View File
@@ -7,6 +7,7 @@ import type {
ChatSummary, ChatSummary,
ConnectionStatus, ConnectionStatus,
SessionAutomationJob, SessionAutomationJob,
SidebarStatePayload,
WorkspaceScopePayload, WorkspaceScopePayload,
} from "@/lib/types"; } from "@/lib/types";
@@ -30,6 +31,7 @@ const sessionUpdateHandlers = new Set<(
scope?: string, scope?: string,
workspaceScope?: WorkspaceScopePayload, workspaceScope?: WorkspaceScopePayload,
) => void>(); ) => void>();
const sidebarStateUpdateHandlers = new Set<(state: SidebarStatePayload) => void>();
let mockSessions: ChatSummary[] = []; let mockSessions: ChatSummary[] = [];
const HERO_GREETING_PATTERN = const HERO_GREETING_PATTERN =
/What should we work on\?|Where should we start\?|What are we building today\?|What should we tackle together\?/; /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, loading: false,
error: null, error: null,
refresh: refreshSpy, 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", forkChat: async () => "fork-chat",
getSessionAutomations: getSessionAutomationsSpy, getSessionAutomations: getSessionAutomationsSpy,
deleteChat: async (key: string, options?: { deleteAutomations?: boolean }) => { deleteChat: async (key: string, options?: { deleteAutomations?: boolean }) => {
@@ -232,6 +251,10 @@ vi.mock("@/lib/nanobot-client", async (importOriginal) => {
sessionUpdateHandlers.add(handler); sessionUpdateHandlers.add(handler);
return () => sessionUpdateHandlers.delete(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) => { onRunStatus = (handler: (chatId: string, startedAt: number | null) => void) => {
runStatusHandlers.add(handler); runStatusHandlers.add(handler);
return () => runStatusHandlers.delete(handler); return () => runStatusHandlers.delete(handler);
@@ -272,7 +295,9 @@ describe("App layout", () => {
getSessionAutomationsSpy.mockReset().mockResolvedValue([]); getSessionAutomationsSpy.mockReset().mockResolvedValue([]);
toggleThemeSpy.mockReset(); toggleThemeSpy.mockReset();
attachSpy.mockReset(); attachSpy.mockReset();
setSidebarStateSpy.mockReset().mockResolvedValue({}); setSidebarStateSpy.mockReset().mockImplementation(
async (state: SidebarStatePayload) => state,
);
requestMutationSpy.mockReset(); requestMutationSpy.mockReset();
discardTemporaryChatSpy.mockReset(); discardTemporaryChatSpy.mockReset();
let temporaryChatCounter = 0; let temporaryChatCounter = 0;
@@ -283,6 +308,7 @@ describe("App layout", () => {
statusHandlers.clear(); statusHandlers.clear();
runStatusHandlers.clear(); runStatusHandlers.clear();
sessionUpdateHandlers.clear(); sessionUpdateHandlers.clear();
sidebarStateUpdateHandlers.clear();
window.history.replaceState(null, "", "/"); window.history.replaceState(null, "", "/");
setNavigatorPlatform("Linux x86_64"); setNavigatorPlatform("Linux x86_64");
localStorage.removeItem("nanobot-webui.sidebar"); localStorage.removeItem("nanobot-webui.sidebar");
@@ -485,8 +511,9 @@ describe("App layout", () => {
render(<App />); render(<App />);
await waitFor(() => expect(connectSpy).toHaveBeenCalled()); await waitFor(() => expect(connectSpy).toHaveBeenCalled());
const firstMessage = "keep this first turn visible";
fireEvent.change(screen.getByRole("textbox", { name: "Message input" }), { fireEvent.change(screen.getByRole("textbox", { name: "Message input" }), {
target: { value: "/model" }, target: { value: firstMessage },
}); });
fireEvent.click(screen.getByRole("button", { name: "Send message" })); fireEvent.click(screen.getByRole("button", { name: "Send message" }));
@@ -496,6 +523,7 @@ describe("App layout", () => {
`#/chat/${encodeURIComponent("websocket:chat-1")}`, `#/chat/${encodeURIComponent("websocket:chat-1")}`,
), ),
); );
expect(await screen.findByText(firstMessage)).toBeInTheDocument();
}); });
it("creates a new temporary chat from the hero each time", async () => { it("creates a new temporary chat from the hero each time", async () => {
@@ -1649,6 +1677,60 @@ describe("App layout", () => {
expect(document.body.style.pointerEvents).not.toBe("none"); expect(document.body.style.pointerEvents).not.toBe("none");
}, 15_000); }, 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(<App />);
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 topics and panes?")).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 () => { it("shows localized bound automations in the first delete confirmation", async () => {
mockSessions = [ mockSessions = [
{ {
@@ -2943,6 +3025,308 @@ 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(<App />);
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
const alphaTab = await within(sidebar).findByRole("button", { name: "Tab: Alpha tab" });
const betaTab = within(sidebar).getByRole("button", { name: "Beta tab" });
expect(alphaTab.compareDocumentPosition(betaTab) & Node.DOCUMENT_POSITION_FOLLOWING)
.toBeTruthy();
const alphaGroup = alphaTab.closest("[data-sidebar-tab-group]") as HTMLElement;
const paneTitles = within(alphaGroup)
.getAllByRole("button")
.filter((button) => (
button.closest("[data-sidebar-pane]") && button.hasAttribute("title")
))
.map((button) => button.getAttribute("title"));
expect(paneTitles).toEqual(["Alpha child", "Alpha tab"]);
});
it("materializes a singleton tab without linking it to another pane", async () => {
mockSessions = [
{
key: "websocket:solo",
channel: "websocket",
chatId: "solo",
createdAt: "2026-08-05T10:00:00Z",
updatedAt: "2026-08-05T10:00:00Z",
title: "Solo pane",
preview: "",
},
{
key: "websocket:other",
channel: "websocket",
chatId: "other",
createdAt: "2026-08-04T10:00:00Z",
updatedAt: "2026-08-04T10:00:00Z",
title: "Other pane",
preview: "",
},
];
render(<App />);
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(<App />);
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(<App />);
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(<App />);
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 () => { it("opens search from the keyboard shortcut", async () => {
mockSessions = [ mockSessions = [
{ {
+365 -181
View File
@@ -2,7 +2,6 @@ import { fireEvent, render, screen, within } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { ChatList } from "@/components/ChatList"; import { ChatList } from "@/components/ChatList";
import { SESSION_DRAG_TYPE } from "@/lib/session-drag";
import type { ChatSummary } from "@/lib/types"; import type { ChatSummary } from "@/lib/types";
function session(overrides: Partial<ChatSummary>): ChatSummary { function session(overrides: Partial<ChatSummary>): ChatSummary {
@@ -18,48 +17,93 @@ function session(overrides: Partial<ChatSummary>): 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", () => { describe("ChatList", () => {
afterEach(() => { afterEach(() => {
vi.restoreAllMocks(); vi.restoreAllMocks();
vi.unstubAllGlobals(); vi.unstubAllGlobals();
}); });
it("exposes chats as drag sources", () => { it("keeps tabs and panes outside every drag-and-drop protocol", () => {
const dataTransfer = { render(
effectAllowed: "", <ChatList
setData: vi.fn(), sessions={[session({ chatId: "root", title: "Root topic" })]}
}; activeKey="websocket:root"
paneGroups={{
"websocket:root": {
tabKey: "websocket:root",
title: "Root topic",
activePaneKey: "websocket:child",
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()}
/>,
);
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( render(
<ChatList <ChatList
sessions={[ sessions={[
session({ chatId: "active", title: "Active chat" }), session({ chatId: "solo", title: "Solo pane" }),
session({ chatId: "reference", title: "Reference chat" }), session({ chatId: "target", title: "Target pane" }),
session({ key: "tab:existing", chatId: "group", title: "Existing group" }),
session({ key: "tab:fine", chatId: "fine-group", title: "Fine group" }),
]} ]}
activeKey="websocket:active" activeKey="websocket:solo"
paneGroups={{
"websocket:solo": {
tabKey: "tab:solo",
title: "Solo pane",
activePaneKey: "websocket:solo",
visible: false,
panes: [{ key: "websocket:solo", chatId: "solo", title: "Solo pane" }],
},
"websocket:target": {
tabKey: "tab:target",
title: "Target pane",
activePaneKey: "websocket:target",
visible: false,
panes: [{ key: "websocket:target", chatId: "target", title: "Target pane" }],
},
"tab:existing": {
tabKey: "tab:existing",
title: "Existing group",
activePaneKey: "websocket:group-a",
visible: true,
panes: [
{ key: "websocket:group-a", chatId: "group-a", title: "Group A" },
{ key: "websocket:group-b", chatId: "group-b", title: "Group B" },
{ key: "websocket:group-c", chatId: "group-c", title: "Group C" },
{ key: "websocket:group-d", chatId: "group-d", title: "Group D" },
],
},
"tab:fine": {
tabKey: "tab:fine",
title: "Fine group",
activePaneKey: "websocket:fine",
visible: true,
panes: [{ key: "websocket:fine", chatId: "fine", title: "Fine pane" }],
},
}}
onCreateTab={onCreateTab}
onAttachPane={onAttachPane}
onSelect={vi.fn()} onSelect={vi.fn()}
onRequestDelete={vi.fn()} onRequestDelete={vi.fn()}
onTogglePin={vi.fn()} onTogglePin={vi.fn()}
@@ -68,88 +112,297 @@ describe("ChatList", () => {
/>, />,
); );
expect(screen.getByRole("button", { name: "Active chat" })) expect(screen.queryByRole("button", { name: "Tab: Solo pane" }))
.toHaveAttribute("draggable", "true"); .not.toBeInTheDocument();
const reference = screen.getByRole("button", { name: "Reference chat" }); expect(screen.getAllByText("Solo pane")).toHaveLength(1);
expect(reference).toHaveAttribute("draggable", "true"); 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( fireEvent.pointerDown(screen.getByRole("button", {
SESSION_DRAG_TYPE, name: "Topic actions for Solo pane",
"websocket:reference", }), { button: 0, ctrlKey: false });
); const moveTo = await screen.findByRole("menuitem", { name: "Move to" });
fireEvent.dragEnd(reference, { dataTransfer }); 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", () => { it("shows every tab's pane membership in a sidebar tab group", async () => {
const onReorderSessions = vi.fn(); const onSelect = vi.fn();
const sessions = [ const onSelectPane = vi.fn();
session({ chatId: "alpha", title: "Alpha" }), const onDetachPane = vi.fn();
session({ chatId: "bravo", title: "Bravo" }), const onDissolveTab = vi.fn();
session({ chatId: "charlie", title: "Charlie" }), const onRequestRename = vi.fn();
session({ chatId: "old-a", title: "Old A" }), const onAttachPane = vi.fn();
session({ chatId: "old-b", title: "Old B" }),
];
const { rerender } = render(
<ChatList
sessions={sessions}
activeKey={null}
onSelect={vi.fn()}
onRequestDelete={vi.fn()}
onTogglePin={vi.fn()}
onRequestRename={vi.fn()}
onToggleArchive={vi.fn()}
onReorderSessions={onReorderSessions}
archivedKeys={["websocket:old-a", "websocket:old-b"]}
sessionOrder={sessions.map((item) => 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 });
expect(onReorderSessions).toHaveBeenCalledWith([ render(
"websocket:bravo",
"websocket:charlie",
"websocket:alpha",
"websocket:old-a",
"websocket:old-b",
]);
rerender(
<ChatList <ChatList
sessions={sessions} sessions={[
activeKey={null} session({ chatId: "root", title: "Root topic" }),
onSelect={vi.fn()} session({ chatId: "target", title: "Target tab" }),
onRequestDelete={vi.fn()}
onTogglePin={vi.fn()}
onRequestRename={vi.fn()}
onToggleArchive={vi.fn()}
onReorderSessions={onReorderSessions}
archivedKeys={["websocket:old-a", "websocket:old-b"]}
sessionOrder={[
"websocket:bravo",
"websocket:charlie",
"websocket:alpha",
"websocket:old-a",
"websocket:old-b",
]} ]}
sort="manual" activeKey="websocket:root"
paneGroups={{
"websocket:root": {
tabKey: "websocket:root",
title: "Root topic",
activePaneKey: "websocket:child",
panes: [
{ key: "websocket:root", chatId: "root", title: "Root topic" },
{ key: "websocket:child", chatId: "child", title: "Research pane" },
],
},
"websocket:target": {
tabKey: "websocket:target",
title: "Target tab",
activePaneKey: "websocket:target-child",
panes: [
{ key: "websocket:target", chatId: "target", title: "Target tab" },
{
key: "websocket:target-child",
chatId: "target-child",
title: "Target research",
},
],
},
}}
onSelect={onSelect}
onSelectPane={onSelectPane}
onDetachPane={onDetachPane}
onDissolveTab={onDissolveTab}
onAttachPane={onAttachPane}
onRequestDelete={vi.fn()}
onTogglePin={vi.fn()}
onRequestRename={onRequestRename}
onToggleArchive={vi.fn()}
/>, />,
); );
const section = screen.getByRole("region", { name: "Topics" });
const text = section.textContent ?? ""; const child = screen.getByRole("button", { name: "Research pane" });
expect(text.indexOf("Bravo")).toBeLessThan(text.indexOf("Charlie")); expect(child.closest("[data-sidebar-pane]"))
expect(text.indexOf("Charlie")).toBeLessThan(text.indexOf("Alpha")); .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.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(
<ChatList
sessions={[session({
chatId: "root",
title: "Root topic",
workspaceScope: {
project_path: "/Users/me/nanobot",
project_name: "nanobot",
access_mode: "restricted",
},
})]}
activeKey="websocket:root"
paneGroups={{
"websocket:root": {
tabKey: "websocket:root",
title: "Root topic",
activePaneKey: "websocket:child",
panes: [
{ key: "websocket:root", chatId: "root", title: "Root topic" },
{ key: "websocket:child", chatId: "child", title: "Research pane" },
],
},
}}
onSelect={vi.fn()}
onSelectPane={vi.fn()}
onRequestDelete={vi.fn()}
onTogglePin={vi.fn()}
onRequestRename={vi.fn()}
onToggleArchive={vi.fn()}
/>,
);
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(
<ChatList
sessions={[
session({ chatId: "root", title: "Root topic" }),
session({ chatId: "target", title: "Target tab" }),
]}
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()}
onRequestDeleteMany={onRequestDeleteMany}
onTogglePin={vi.fn()}
onRequestRename={vi.fn()}
onToggleArchive={vi.fn()}
/>,
);
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 () => { it("shows temporary chats separately and lets the user reopen or close them", async () => {
@@ -359,40 +612,7 @@ describe("ChatList", () => {
expect(within(chatsSection).queryByText("Project chat")).not.toBeInTheDocument(); expect(within(chatsSection).queryByText("Project chat")).not.toBeInTheDocument();
}); });
it("positions one background highlight and resets it across hidden targets", () => { it("switches row-owned tab highlights without a moving selection surface", () => {
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 });
},
);
const props = { const props = {
sessions: [ sessions: [
session({ chatId: "active", title: "Active topic" }), session({ chatId: "active", title: "Active topic" }),
@@ -412,45 +632,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"); const activeButton = screen.getByTitle("Active topic");
expect(activeButton).toHaveAttribute("aria-current", "page"); expect(activeButton).toHaveAttribute("aria-current", "page");
expect(activeButton.parentElement).toHaveClass("transition-[color]"); expect(activeButton.closest("[data-sidebar-tab]")).toHaveClass(
expect(activeButton.parentElement).not.toHaveClass("transition-colors"); "bg-sidebar-selected",
expect(activeButton.parentElement).not.toHaveClass(
"bg-sidebar-accent",
"shadow-[inset_0_0_0_1px_hsl(var(--sidebar-border)/0.55)]",
); );
expect(highlight).toHaveClass( expect(screen.queryByTestId("sessions-selection-highlight")).not.toBeInTheDocument();
"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);
rerender( rerender(
<ChatList <ChatList
@@ -461,12 +648,9 @@ describe("ChatList", () => {
expect(screen.getByTitle("Active topic")).not.toHaveAttribute("aria-current"); expect(screen.getByTitle("Active topic")).not.toHaveAttribute("aria-current");
expect(screen.getByTitle("Inactive topic")).toHaveAttribute("aria-current", "page"); expect(screen.getByTitle("Inactive topic")).toHaveAttribute("aria-current", "page");
expect(highlight).toHaveStyle( expect(screen.getByTitle("Inactive topic").closest("[data-sidebar-tab]")).toHaveClass(
"width: 284px; height: 40px; transform: translate3d(8px, 48px, 0)", "bg-sidebar-selected",
); );
rerender(<ChatList {...props} activeKey={null} />);
expect(highlight).toHaveStyle("opacity: 0");
}); });
it("can collapse a project group and keeps project rename separate from chat titles", async () => { it("can collapse a project group and keeps project rename separate from chat titles", async () => {
+46
View File
@@ -1206,6 +1206,7 @@ describe("NanobotClient", () => {
project_name_overrides: {}, project_name_overrides: {},
tags_by_key: {}, tags_by_key: {},
collapsed_groups: {}, collapsed_groups: {},
workbench: { version: 1, tabs: {} },
view: { view: {
density: "comfortable", density: "comfortable",
show_previews: false, show_previews: false,
@@ -1243,6 +1244,51 @@ describe("NanobotClient", () => {
await expect(pending).resolves.toEqual(state); 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 () => { it("does not correlate a new-chat scope rejection to an unrelated sent turn", async () => {
const client = new NanobotClient({ const client = new NanobotClient({
url: "ws://test", url: "ws://test",
+329
View File
@@ -0,0 +1,329 @@
import { createPortal } from "react-dom";
import { 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<string, string> = { alpha: "Alpha", beta: "Beta" };
return (
<PaneWorkbench
panes={tab.layoutPaneKeys.map((key) => ({ 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) => (
<>
<button type="button">Focus {pane.title}</button>
{context.headerPortalTarget && context.active ? createPortal(
context.headerActions,
context.headerPortalTarget,
) : null}
{context.composerPortalTarget ? createPortal(
<div hidden={!context.active}>
<textarea aria-label={`Composer ${pane.title}`} />
</div>,
context.composerPortalTarget,
) : null}
</>
)}
/>
);
}
function BspWorkbenchHarness() {
const panes = ["alpha", "beta", "gamma", "delta"].map((key) => ({
key,
title: key,
}));
return (
<PaneWorkbench
panes={panes}
activePaneKey="delta"
layout="bsp"
splitRatios={[]}
showLayoutControl
onActivatePane={vi.fn()}
onAddPane={vi.fn()}
onLayoutChange={vi.fn()}
onPaneOrderChange={vi.fn()}
onSplitRatiosChange={vi.fn()}
renderPane={(pane) => <span>{pane.title}</span>}
/>
);
}
describe("PaneWorkbench", () => {
const originalGetBoundingClientRect = HTMLElement.prototype.getBoundingClientRect;
const originalAnimate = HTMLElement.prototype.animate;
const animate = vi.fn(() => ({
addEventListener: vi.fn(),
cancel: vi.fn(),
}) as unknown as Animation);
beforeEach(() => {
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(),
})));
HTMLElement.prototype.animate = animate;
HTMLElement.prototype.getBoundingClientRect = function getBoundingClientRect() {
if (this.dataset.testid === "pane-grid") return rect(0, 0, 1000, 1000);
if (!this.classList.contains("workbench-pane")) {
return originalGetBoundingClientRect.call(this);
}
const layout = this.parentElement?.dataset.layout;
const index = Array.from(this.parentElement?.children ?? []).indexOf(this);
return layout === "rows"
? rect(0, index * 500, 1000, 500)
: rect(index * 500, 0, 500, 1000);
};
});
afterEach(() => {
HTMLElement.prototype.animate = originalAnimate;
HTMLElement.prototype.getBoundingClientRect = originalGetBoundingClientRect;
vi.unstubAllGlobals();
vi.clearAllMocks();
});
it("focuses without reordering and keeps only the focused composer visible", () => {
render(<WorkbenchHarness />);
const grid = screen.getByTestId("pane-grid");
expect(Array.from(grid.children).map((pane) => pane.getAttribute("aria-label")))
.toEqual(["Alpha", "Beta"]);
expect(screen.getByLabelText("Composer Beta")).toBeVisible();
expect(screen.getByLabelText("Composer Alpha")).not.toBeVisible();
fireEvent.pointerDown(
within(screen.getByRole("region", { name: "Alpha" }))
.getByRole("button", { name: "Focus Alpha" }),
);
expect(Array.from(grid.children).map((pane) => pane.getAttribute("aria-label")))
.toEqual(["Alpha", "Beta"]);
expect(screen.getByLabelText("Composer Alpha")).toBeVisible();
expect(screen.getByLabelText("Composer Beta")).not.toBeVisible();
});
it("moves the focused pane between workspace slots from its bottom handle", () => {
const onPaneOrderChange = vi.fn();
render(<WorkbenchHarness onPaneOrderChange={onPaneOrderChange} />);
const grid = screen.getByTestId("pane-grid");
const handle = screen.getByRole("button", { name: "Move Beta pane" });
expect(Array.from(grid.children).map((pane) => pane.getAttribute("aria-label")))
.toEqual(["Alpha", "Beta"]);
fireEvent.pointerDown(handle, { button: 0, pointerId: 1, clientX: 750, clientY: 990 });
fireEvent.pointerMove(window, {
buttons: 1,
pointerId: 1,
clientX: 250,
clientY: 500,
});
expect(Array.from(grid.children).map((pane) => pane.getAttribute("aria-label")))
.toEqual(["Beta", "Alpha"]);
expect(onPaneOrderChange).toHaveBeenCalledOnce();
expect(onPaneOrderChange).toHaveBeenCalledWith(["beta", "alpha"]);
fireEvent.pointerMove(window, {
buttons: 1,
pointerId: 1,
clientX: 750,
clientY: 500,
});
expect(Array.from(grid.children).map((pane) => pane.getAttribute("aria-label")))
.toEqual(["Alpha", "Beta"]);
expect(onPaneOrderChange).toHaveBeenLastCalledWith(["alpha", "beta"]);
fireEvent.pointerUp(window, { pointerId: 1, clientX: 750, clientY: 500 });
expect(Array.from(grid.children).map((pane) => pane.getAttribute("aria-label")))
.toEqual(["Alpha", "Beta"]);
expect(onPaneOrderChange).toHaveBeenCalledTimes(2);
});
it("moves panes up and down through stable workspace slots", () => {
const onPaneOrderChange = vi.fn();
render(
<WorkbenchHarness initialLayout="rows" onPaneOrderChange={onPaneOrderChange} />,
);
const grid = screen.getByTestId("pane-grid");
const handle = screen.getByRole("button", { name: "Move Beta pane" });
fireEvent.pointerDown(handle, { button: 0, pointerId: 1, clientX: 500, clientY: 990 });
fireEvent.pointerMove(window, {
buttons: 1,
pointerId: 1,
clientX: 500,
clientY: 250,
});
expect(Array.from(grid.children).map((pane) => pane.getAttribute("aria-label")))
.toEqual(["Beta", "Alpha"]);
fireEvent.pointerMove(window, {
buttons: 1,
pointerId: 1,
clientX: 500,
clientY: 750,
});
expect(Array.from(grid.children).map((pane) => pane.getAttribute("aria-label")))
.toEqual(["Alpha", "Beta"]);
expect(onPaneOrderChange.mock.calls).toEqual([
[["beta", "alpha"]],
[["alpha", "beta"]],
]);
fireEvent.pointerUp(window, { pointerId: 1, clientX: 500, clientY: 750 });
});
it("moves the focused pane between workspace slots with arrow keys", () => {
render(<WorkbenchHarness />);
const handle = screen.getByRole("button", { name: "Move Beta pane" });
act(() => handle.focus());
expect(handle).toHaveFocus();
fireEvent.keyDown(handle, { key: "ArrowLeft" });
expect(Array.from(screen.getByTestId("pane-grid").children)
.map((pane) => pane.getAttribute("aria-label")))
.toEqual(["Beta", "Alpha"]);
});
it("previews edge resizing locally and commits one ratio when dragging ends", () => {
const onSplitRatiosChange = vi.fn();
render(<WorkbenchHarness onSplitRatiosChange={onSplitRatiosChange} />);
const grid = screen.getByTestId("pane-grid");
const separator = screen.getByRole("separator", {
name: "Resize pane boundary 1",
});
fireEvent.pointerDown(separator, {
button: 0,
pointerId: 7,
clientX: 500,
clientY: 400,
});
fireEvent.pointerMove(window, {
buttons: 1,
pointerId: 7,
clientX: 700,
clientY: 400,
});
expect(grid.style.gridTemplateColumns)
.toBe("minmax(0, 700fr) minmax(0, 300fr)");
expect(onSplitRatiosChange).not.toHaveBeenCalled();
fireEvent.pointerUp(window, { pointerId: 7, clientX: 700, clientY: 400 });
expect(onSplitRatiosChange).toHaveBeenCalledOnce();
expect(onSplitRatiosChange).toHaveBeenCalledWith([0.7]);
});
it("resizes a pane boundary with the matching arrow keys", () => {
const onSplitRatiosChange = vi.fn();
render(<WorkbenchHarness onSplitRatiosChange={onSplitRatiosChange} />);
const separator = screen.getByRole("separator", {
name: "Resize pane boundary 1",
});
fireEvent.keyDown(separator, { key: "ArrowLeft" });
expect(onSplitRatiosChange).toHaveBeenCalledWith([0.47]);
});
it("keeps one shared layout control and animates geometry changes", async () => {
render(<WorkbenchHarness />);
const header = screen.getByTestId("workbench-header-host");
expect(within(header).getAllByRole("button", { name: "Pane layout" })).toHaveLength(1);
fireEvent.pointerDown(within(header).getByRole("button", { name: "Pane layout" }), {
button: 0,
ctrlKey: false,
});
fireEvent.click(screen.getByRole("menuitemradio", { name: "Rows" }));
expect(screen.getByTestId("pane-grid")).toHaveAttribute("data-layout", "rows");
await waitFor(() => expect(animate).toHaveBeenCalledTimes(2));
fireEvent.pointerDown(within(header).getByRole("button", { name: "Pane layout" }), {
button: 0,
ctrlKey: false,
});
fireEvent.click(screen.getByRole("menuitemradio", { name: "BSP" }));
expect(screen.getByTestId("pane-grid")).toHaveAttribute("data-layout", "bsp");
await waitFor(() => expect(animate).toHaveBeenCalledTimes(4));
});
it("fills the workbench through alternating binary splits", () => {
render(<BspWorkbenchHarness />);
const alpha = screen.getByTestId("workbench-pane-alpha");
const beta = screen.getByTestId("workbench-pane-beta");
const gamma = screen.getByTestId("workbench-pane-gamma");
const delta = screen.getByTestId("workbench-pane-delta");
expect([alpha.style.gridColumn, alpha.style.gridRow]).toEqual(["1 / 2", "1 / 3"]);
expect([beta.style.gridColumn, beta.style.gridRow]).toEqual(["2 / 4", "1 / 2"]);
expect([gamma.style.gridColumn, gamma.style.gridRow]).toEqual(["2 / 3", "2 / 3"]);
expect([delta.style.gridColumn, delta.style.gridRow]).toEqual(["3 / 4", "2 / 3"]);
});
});
+69
View File
@@ -0,0 +1,69 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import type { ReactNode } from "react";
import { describe, expect, it, vi } from "vitest";
import { useSidebarState } from "@/hooks/useSidebarState";
import type { NanobotClient } from "@/lib/nanobot-client";
import type { SidebarStatePayload } from "@/lib/types";
import { ClientProvider } from "@/providers/ClientProvider";
describe("useSidebarState", () => {
it("serializes full-state writes so an older request cannot overwrite a newer update", async () => {
let resolveFirstWrite: (() => void) | null = null;
let sidebarStateUpdateHandler: ((state: SidebarStatePayload) => void) | null = null;
const setSidebarState = vi.fn()
.mockImplementationOnce((state: SidebarStatePayload) => new Promise<SidebarStatePayload>(
(resolve) => {
resolveFirstWrite = () => resolve(state);
},
))
.mockImplementation(async (state: SidebarStatePayload) => state);
const client = {
status: "open" as const,
onStatus: () => () => {},
onSidebarStateUpdate: (handler: (state: SidebarStatePayload) => void) => {
sidebarStateUpdateHandler = handler;
return () => {
sidebarStateUpdateHandler = null;
};
},
setSidebarState,
} as unknown as NanobotClient;
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({}),
}));
const wrapper = ({ children }: { children: ReactNode }) => (
<ClientProvider client={client} token="token">
{children}
</ClientProvider>
);
const { result } = renderHook(() => useSidebarState([], false), { wrapper });
await waitFor(() => expect(result.current.loading).toBe(false));
act(() => {
void result.current.update((current) => ({
...current,
title_overrides: { "websocket:a": "First" },
}));
void result.current.update((current) => ({
...current,
title_overrides: { "websocket:a": "Second" },
}));
});
expect(setSidebarState).toHaveBeenCalledTimes(1);
act(() => {
sidebarStateUpdateHandler?.(setSidebarState.mock.calls[0]?.[0]);
});
expect(result.current.state.title_overrides).toEqual({
"websocket:a": "Second",
});
act(() => resolveFirstWrite?.());
await waitFor(() => expect(setSidebarState).toHaveBeenCalledTimes(2));
expect(setSidebarState.mock.calls[1]?.[0]).toEqual(expect.objectContaining({
title_overrides: { "websocket:a": "Second" },
}));
});
});
+220
View File
@@ -0,0 +1,220 @@
import { describe, expect, it } from "vitest";
import {
EMPTY_WORKBENCH_STATE,
MAX_WORKBENCH_PANES,
addWorkbenchPane,
attachWorkbenchPane,
createWorkbenchTab,
detachWorkbenchPane,
dissolveWorkbenchTab,
normalizeWorkbenchState,
orderWorkbenchTabs,
reconcileWorkbench,
renameWorkbenchTab,
setWorkbenchLayout,
setWorkbenchPaneLayoutOrder,
setWorkbenchSplitRatios,
workbenchTab,
workbenchTabForPane,
} from "@/components/workbench/workbench-model";
describe("workbench model", () => {
it("derives standalone panes without persisting virtual tabs", () => {
const match = workbenchTabForPane(EMPTY_WORKBENCH_STATE, "pane-a");
expect(match.tabKey).not.toBe("pane-a");
expect(match.tab).toEqual({
explicit: false,
title: null,
paneKeys: ["pane-a"],
layoutPaneKeys: ["pane-a"],
layout: "columns",
splitRatios: [],
});
expect(EMPTY_WORKBENCH_STATE.tabs).toEqual({});
});
it("persists only a visible singleton group", () => {
const state = createWorkbenchTab(EMPTY_WORKBENCH_STATE, "pane-a");
const match = workbenchTabForPane(state, "pane-a");
expect(workbenchTab(state, match.tabKey)).toMatchObject({
explicit: true,
paneKeys: ["pane-a"],
});
expect(detachWorkbenchPane(state, match.tabKey, "pane-a").tabs).toEqual({});
});
it("materializes a group when a pane is added", () => {
let state = addWorkbenchPane(EMPTY_WORKBENCH_STATE, "pane-a", "pane-b");
const tabKey = workbenchTabForPane(state, "pane-a").tabKey;
state = setWorkbenchLayout(state, tabKey, "main-stack");
state = renameWorkbenchTab(state, tabKey, "Research");
expect(workbenchTab(state, tabKey)).toEqual({
explicit: false,
title: "Research",
paneKeys: ["pane-a", "pane-b"],
layoutPaneKeys: ["pane-a", "pane-b"],
layout: "main-stack",
splitRatios: [],
});
});
it("detaches a pane without persisting its standalone projection", () => {
let state = addWorkbenchPane(EMPTY_WORKBENCH_STATE, "pane-a", "pane-b");
const tabKey = workbenchTabForPane(state, "pane-a").tabKey;
state = createWorkbenchTab(state, "pane-a");
state = addWorkbenchPane(state, "pane-a", "pane-c");
state = detachWorkbenchPane(state, tabKey, "pane-a");
expect(workbenchTab(state, tabKey)?.paneKeys).toEqual(["pane-b", "pane-c"]);
expect(workbenchTabForPane(state, "pane-a").tab.paneKeys).toEqual(["pane-a"]);
expect(Object.values(state.tabs).some((tab) => tab.paneKeys.includes("pane-a"))).toBe(false);
});
it("dissolves a group into derived standalone panes", () => {
const grouped = addWorkbenchPane(EMPTY_WORKBENCH_STATE, "pane-a", "pane-b");
const tabKey = workbenchTabForPane(grouped, "pane-a").tabKey;
const state = dissolveWorkbenchTab(grouped, tabKey);
expect(state.tabs).toEqual({});
expect(workbenchTabForPane(state, "pane-a").tab.paneKeys).toEqual(["pane-a"]);
expect(workbenchTabForPane(state, "pane-b").tab.paneKeys).toEqual(["pane-b"]);
});
it("moves panes symmetrically and removes an implicit singleton source", () => {
let state = addWorkbenchPane(EMPTY_WORKBENCH_STATE, "pane-a", "pane-b");
const sourceTabKey = workbenchTabForPane(state, "pane-a").tabKey;
state = createWorkbenchTab(state, "pane-c");
const targetTabKey = workbenchTabForPane(state, "pane-c").tabKey;
state = attachWorkbenchPane(state, targetTabKey, "pane-a");
expect(workbenchTab(state, sourceTabKey)).toBeNull();
expect(workbenchTab(state, targetTabKey)?.paneKeys).toEqual(["pane-c", "pane-a"]);
state = attachWorkbenchPane(state, targetTabKey, "pane-b");
expect(workbenchTab(state, targetTabKey)?.paneKeys).toEqual([
"pane-c",
"pane-a",
"pane-b",
]);
});
it("keeps membership independent from workspace pane order", () => {
let state = addWorkbenchPane(EMPTY_WORKBENCH_STATE, "pane-a", "pane-b");
state = addWorkbenchPane(state, "pane-a", "pane-c");
const tabKey = workbenchTabForPane(state, "pane-a").tabKey;
const [ordered] = orderWorkbenchTabs(
state,
["pane-c", "pane-a", "pane-b"],
new Map(),
);
expect(workbenchTab(state, tabKey)?.paneKeys).toEqual(["pane-a", "pane-b", "pane-c"]);
expect(ordered.paneKeys).toEqual(["pane-c", "pane-a", "pane-b"]);
state = setWorkbenchPaneLayoutOrder(state, tabKey, ["pane-b", "pane-c", "pane-a"]);
expect(workbenchTab(state, tabKey)?.layoutPaneKeys).toEqual([
"pane-b",
"pane-c",
"pane-a",
]);
});
it("stores resize ratios and resets them when geometry changes", () => {
let state = addWorkbenchPane(EMPTY_WORKBENCH_STATE, "pane-a", "pane-b");
const tabKey = workbenchTabForPane(state, "pane-a").tabKey;
state = setWorkbenchSplitRatios(state, tabKey, [0.35]);
expect(workbenchTab(state, tabKey)?.splitRatios).toEqual([0.35]);
state = setWorkbenchLayout(state, tabKey, "rows");
expect(workbenchTab(state, tabKey)?.splitRatios).toEqual([]);
});
it("keeps groups contiguous and ranks them by their latest pane", () => {
let state = addWorkbenchPane(EMPTY_WORKBENCH_STATE, "pane-a", "pane-c");
state = addWorkbenchPane(state, "pane-b", "pane-d");
const alphaTabKey = workbenchTabForPane(state, "pane-a").tabKey;
const betaTabKey = workbenchTabForPane(state, "pane-b").tabKey;
const tabs = orderWorkbenchTabs(
state,
["pane-d", "pane-c", "pane-b", "pane-a", "pane-e"],
new Map([
["pane-a", "2026-08-01T10:00:00Z"],
["pane-b", "2026-08-03T10:00:00Z"],
["pane-c", "2026-08-05T10:00:00Z"],
["pane-d", "2026-08-04T10:00:00Z"],
["pane-e", "2026-08-02T10:00:00Z"],
]),
);
expect(tabs.map(({ tabKey, paneKeys }) => ({ tabKey, paneKeys }))).toEqual([
{ tabKey: alphaTabKey, paneKeys: ["pane-c", "pane-a"] },
{ tabKey: betaTabKey, paneKeys: ["pane-d", "pane-b"] },
{ tabKey: workbenchTabForPane(state, "pane-e").tabKey, paneKeys: ["pane-e"] },
]);
expect(Object.keys(state.tabs)).toHaveLength(2);
});
it("caps a group at four panes", () => {
let state = addWorkbenchPane(EMPTY_WORKBENCH_STATE, "pane-a", "pane-1");
for (let index = 2; index <= MAX_WORKBENCH_PANES; index += 1) {
state = addWorkbenchPane(state, "pane-a", `pane-${index}`);
}
const tabKey = workbenchTabForPane(state, "pane-a").tabKey;
expect(workbenchTab(state, tabKey)?.paneKeys).toEqual([
"pane-a",
"pane-1",
"pane-2",
"pane-3",
]);
});
it("repairs persisted groups without materializing missing sessions", () => {
const state = normalizeWorkbenchState({
version: 1,
tabs: {
alpha: {
title: "Alpha",
paneKeys: ["pane-a", "pane-b", "pane-b", 9],
layout: "unknown",
},
duplicate: {
paneKeys: ["pane-b", "deleted"],
layout: "grid",
},
invisible: {
paneKeys: ["pane-c"],
layout: "columns",
},
},
});
const reconciled = reconcileWorkbench(
state,
new Set(["pane-a", "pane-b", "pane-c"]),
);
expect(workbenchTab(reconciled, "alpha")).toEqual({
explicit: false,
title: "Alpha",
paneKeys: ["pane-a", "pane-b"],
layoutPaneKeys: ["pane-a", "pane-b"],
layout: "columns",
splitRatios: [],
});
expect(Object.keys(reconciled.tabs)).toEqual(["alpha"]);
expect(workbenchTabForPane(reconciled, "pane-c").tab.paneKeys).toEqual(["pane-c"]);
});
it("keeps persisted state sparse with thousands of standalone sessions", () => {
const sessionKeys = Array.from({ length: 2_000 }, (_, index) => `pane-${index}`);
const reconciled = reconcileWorkbench(EMPTY_WORKBENCH_STATE, new Set(sessionKeys));
const ordered = orderWorkbenchTabs(reconciled, sessionKeys, new Map());
expect(reconciled.tabs).toEqual({});
expect(ordered).toHaveLength(2_000);
});
});
+1
View File
@@ -89,6 +89,7 @@ export default {
sidebar: { sidebar: {
DEFAULT: "hsl(var(--sidebar))", DEFAULT: "hsl(var(--sidebar))",
foreground: "hsl(var(--sidebar-foreground))", foreground: "hsl(var(--sidebar-foreground))",
selected: "hsl(var(--sidebar-selected))",
accent: "hsl(var(--sidebar-accent))", accent: "hsl(var(--sidebar-accent))",
"accent-foreground": "hsl(var(--sidebar-accent-foreground))", "accent-foreground": "hsl(var(--sidebar-accent-foreground))",
border: "hsl(var(--sidebar-border))", border: "hsl(var(--sidebar-border))",