feat(webui): refine pane groups and workspace layout

This commit is contained in:
chengyongru
2026-08-11 18:06:09 +08:00
parent 32c5a10947
commit 495a06f21a
33 changed files with 2520 additions and 1794 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),
) )
@@ -858,6 +862,11 @@ class WebSocketChannel(BaseChannel):
"error", "error",
detail="invalid_sidebar_state", detail="invalid_sidebar_state",
) )
return
await self._broadcast_webui_event(
"sidebar_state_updated",
state=saved_state,
)
return return
if t == "set_workspace_scope": if t == "set_workspace_scope":
cid = envelope.get("chat_id") cid = envelope.get("chat_id")
@@ -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,60 @@ async def test_webui_persists_sidebar_state_larger_than_http_request_line(
} }
@pytest.mark.asyncio
async def test_webui_sidebar_state_update_broadcasts_workbench_to_other_devices(
bus: MagicMock,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
channel = _ch(bus)
source = AsyncMock()
source.request = SimpleNamespace(headers=Headers())
other_device = AsyncMock()
channel._webui_connections.update({source, other_device})
request_id = "sidebar-workbench-state"
await channel._dispatch_envelope(
source,
"webui-client",
{
"type": "webui_request",
"request_id": request_id,
"action": "sidebar.update",
"payload": {
"state": {
"workbench": {
"version": 1,
"tabs": {
"tab:websocket:a": {
"explicit": True,
"title": "Research",
"paneKeys": ["websocket:a", "websocket:b"],
"layoutPaneKeys": ["websocket:b", "websocket:a"],
"activePaneKey": "websocket:a",
"layout": "columns",
}
},
}
}
},
},
)
await asyncio.gather(*tuple(channel._webui_request_tasks.values()))
event = json.loads(other_device.send.await_args.args[0])
assert event["event"] == "sidebar_state_updated"
assert event["state"]["workbench"]["tabs"]["tab:websocket:a"]["paneKeys"] == [
"websocket:a",
"websocket:b",
]
assert event["state"]["workbench"]["tabs"]["tab:websocket:a"]["layoutPaneKeys"] == [
"websocket:b",
"websocket:a",
]
@pytest.mark.asyncio @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)
+50 -1
View File
@@ -24,8 +24,10 @@ _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"}
def webui_sidebar_state_path() -> Path: def webui_sidebar_state_path() -> Path:
@@ -42,6 +44,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,
@@ -131,8 +134,53 @@ def _clean_view(value: Any) -> dict[str, Any]:
} }
def _clean_workbench(value: Any) -> dict[str, Any]:
if not isinstance(value, dict):
return {"version": 1, "tabs": {}}
raw_tabs = cast(dict[str, Any], value).get("tabs")
if not isinstance(raw_tabs, dict):
return {"version": 1, "tabs": {}}
tabs: dict[str, dict[str, Any]] = {}
claimed_panes: set[str] = set()
for raw_tab_key, raw_tab in list(cast(dict[Any, Any], raw_tabs).items())[:_MAX_MAP_ITEMS]:
tab_key = _clean_string(raw_tab_key)
if tab_key is None or not isinstance(raw_tab, dict):
continue
tab = cast(dict[str, Any], raw_tab)
pane_keys = [
key
for key in _clean_string_list(tab.get("paneKeys"))
if key not in claimed_panes
][:_MAX_WORKBENCH_PANES]
if not pane_keys:
continue
requested_layout_pane_keys = [
key for key in _clean_string_list(tab.get("layoutPaneKeys")) if key in pane_keys
]
layout_pane_keys = requested_layout_pane_keys + [
key for key in pane_keys if key not in requested_layout_pane_keys
]
claimed_panes.update(pane_keys)
raw_layout = tab.get("layout")
layout = raw_layout if raw_layout in _ALLOWED_WORKBENCH_LAYOUTS else "columns"
title = _clean_string(tab.get("title"), max_len=_MAX_TITLE_LEN)
active_pane_key = _clean_string(tab.get("activePaneKey"))
tabs[tab_key] = {
"explicit": tab.get("explicit") is True,
"title": title,
"paneKeys": pane_keys,
"layoutPaneKeys": layout_pane_keys,
"activePaneKey": (
active_pane_key if active_pane_key in pane_keys else pane_keys[0]
),
"layout": layout,
}
return {"version": 1, "tabs": tabs}
def normalize_webui_sidebar_state(raw: Any) -> dict[str, Any]: 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 +194,7 @@ def normalize_webui_sidebar_state(raw: Any) -> dict[str, Any]:
) )
state["tags_by_key"] = _clean_tags_by_key(raw.get("tags_by_key")) state["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
+40 -1
View File
@@ -17,7 +17,7 @@ def test_sidebar_state_defaults_when_file_missing(tmp_path, monkeypatch) -> None
assert webui_sidebar_state_path() == tmp_path / "webui" / "sidebar-state.json" 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 +31,24 @@ 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"],
"activePaneKey": "missing",
"layout": "invalid-layout",
},
"tab:websocket:b": {
"paneKeys": ["websocket:b", "websocket:c"],
"activePaneKey": "websocket:c",
"layout": "bsp",
},
},
},
"view": {"density": "tiny", "show_archived": True, "sort": "nope"}, "view": {"density": "tiny", "show_archived": True, "sort": "nope"},
} }
), ),
@@ -47,6 +65,27 @@ def test_sidebar_state_normalizes_old_or_partial_payload(tmp_path, monkeypatch)
assert state["project_name_overrides"] == {"/repo": "Core"} assert state["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"],
"activePaneKey": "websocket:a",
"layout": "columns",
},
"tab:websocket:b": {
"explicit": False,
"title": None,
"paneKeys": ["websocket:c"],
"layoutPaneKeys": ["websocket:c"],
"activePaneKey": "websocket:c",
"layout": "bsp",
},
},
}
assert state["view"] == { assert state["view"] == {
"density": "comfortable", "density": "comfortable",
"show_previews": False, "show_previews": False,
+283 -119
View File
@@ -17,19 +17,22 @@ import type { SettingsSectionKey } from "@/components/settings/SettingsView";
import { ThreadShell } from "@/components/thread/ThreadShell"; import { ThreadShell } from "@/components/thread/ThreadShell";
import { PaneWorkbench } from "@/components/workbench/PaneWorkbench"; import { PaneWorkbench } from "@/components/workbench/PaneWorkbench";
import { import {
WORKBENCH_STORAGE_KEY, EMPTY_WORKBENCH_STATE,
MAX_WORKBENCH_PANES, MAX_WORKBENCH_PANES,
addWorkbenchPane, addWorkbenchPane,
attachWorkbenchPane, attachWorkbenchPane,
createWorkbenchTab,
detachWorkbenchPane, detachWorkbenchPane,
ensureWorkbenchTab, dissolveWorkbenchTab,
ensureWorkbenchPaneTab,
focusWorkbenchPane, focusWorkbenchPane,
parseWorkbenchState, orderWorkbenchTabs,
promoteWorkbenchPane,
reconcileWorkbench, reconcileWorkbench,
renameWorkbenchTab,
setWorkbenchLayout, setWorkbenchLayout,
workbenchChildPaneKeys, setWorkbenchPaneLayoutOrder,
workbenchTab, workbenchTab,
workbenchTabForPane,
type WorkbenchState, type WorkbenchState,
} from "@/components/workbench/workbench-model"; } from "@/components/workbench/workbench-model";
import { floatingSurfaceElevationClassName } from "@/components/ui/floating-surface"; import { floatingSurfaceElevationClassName } from "@/components/ui/floating-surface";
@@ -53,7 +56,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";
@@ -138,14 +141,6 @@ const RenameChatDialog = lazy(async () => {
return { default: module.RenameChatDialog }; return { default: module.RenameChatDialog };
}); });
function readWorkbenchState(): WorkbenchState {
try {
return parseWorkbenchState(window.localStorage.getItem(WORKBENCH_STORAGE_KEY));
} catch {
return parseWorkbenchState(null);
}
}
function SurfaceLoadingFallback() { function SurfaceLoadingFallback() {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
@@ -1043,7 +1038,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();
@@ -1060,16 +1059,14 @@ function Shell({
const [hostSidebarPreviewOpen, setHostSidebarPreviewOpen] = useState(false); const [hostSidebarPreviewOpen, setHostSidebarPreviewOpen] = useState(false);
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
const [sessionSearchOpen, setSessionSearchOpen] = useState(false); const [sessionSearchOpen, setSessionSearchOpen] = useState(false);
const [workbenchState, setWorkbenchState] = useState(readWorkbenchState); const [workbenchState, setWorkbenchState] = useState<WorkbenchState>(
EMPTY_WORKBENCH_STATE,
);
const workbenchServerHydratedRef = useRef(false);
const lastServerWorkbenchRef = useRef("");
const skipWorkbenchPersistenceRef = useRef(false);
const [creatingPane, setCreatingPane] = useState(false); const [creatingPane, setCreatingPane] = useState(false);
const childPaneKeys = useMemo( const topicSessions = sessions;
() => workbenchChildPaneKeys(workbenchState),
[workbenchState],
);
const topicSessions = useMemo(
() => sessions.filter((session) => !childPaneKeys.has(session.key)),
[childPaneKeys, sessions],
);
const [pendingDelete, setPendingDelete] = useState<{ const [pendingDelete, setPendingDelete] = useState<{
items: SidebarDeleteItem[]; items: SidebarDeleteItem[];
automations?: SessionAutomationJob[]; automations?: SessionAutomationJob[];
@@ -1078,6 +1075,10 @@ function Shell({
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;
@@ -1181,6 +1182,19 @@ function Shell({
}; };
}, [getToken]); }, [getToken]);
useEffect(() => {
if (sidebarStateLoading) return;
const serialized = JSON.stringify(sidebarState.workbench);
if (
workbenchServerHydratedRef.current
&& lastServerWorkbenchRef.current === serialized
) return;
workbenchServerHydratedRef.current = true;
lastServerWorkbenchRef.current = serialized;
skipWorkbenchPersistenceRef.current = true;
setWorkbenchState(sidebarState.workbench);
}, [sidebarState.workbench, sidebarStateLoading]);
useEffect(() => { useEffect(() => {
try { try {
window.localStorage.setItem( window.localStorage.setItem(
@@ -1193,15 +1207,19 @@ function Shell({
}, [hostSidebarOpen]); }, [hostSidebarOpen]);
useEffect(() => { useEffect(() => {
try { if (!workbenchServerHydratedRef.current || sidebarStateLoading) return;
window.localStorage.setItem( if (skipWorkbenchPersistenceRef.current) {
WORKBENCH_STORAGE_KEY, skipWorkbenchPersistenceRef.current = false;
JSON.stringify(workbenchState), return;
);
} catch {
// ignore storage errors (private mode, etc.)
} }
}, [workbenchState]); const serialized = JSON.stringify(workbenchState);
if (serialized === JSON.stringify(sidebarState.workbench)) return;
lastServerWorkbenchRef.current = serialized;
void updateSidebarState((current) => ({
...current,
workbench: workbenchState,
}));
}, [sidebarState.workbench, sidebarStateLoading, updateSidebarState, workbenchState]);
useEffect(() => { useEffect(() => {
writeSessionUpdateChatIds(updatedChatIds); writeSessionUpdateChatIds(updatedChatIds);
@@ -1266,11 +1284,13 @@ 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 activeTabState = useMemo(() => ( const activeTabMatch = useMemo(() => (
activeKey && !temporarySessions[activeKey] activeKey && !temporarySessions[activeKey]
? workbenchTab(workbenchState, activeKey) ? workbenchTabForPane(workbenchState, activeKey)
: null : null
), [activeKey, temporarySessions, workbenchState]); ), [activeKey, temporarySessions, workbenchState]);
const activeTabKey = activeTabMatch?.tabKey ?? null;
const activeTabState = activeTabMatch?.tab ?? null;
const activePaneSession = useMemo<ChatSummary | null>(() => { const activePaneSession = useMemo<ChatSummary | null>(() => {
if (!activeTabState) return activeSession; if (!activeTabState) return activeSession;
return sessions.find((session) => session.key === activeTabState.activePaneKey) return sessions.find((session) => session.key === activeTabState.activePaneKey)
@@ -1341,16 +1361,23 @@ function Shell({
}, [loading, sessions]); }, [loading, sessions]);
useEffect(() => { useEffect(() => {
if (loading) return; if (loading || sidebarStateLoading) return;
const validKeys = new Set(sessions.map((session) => session.key)); const validKeys = new Set(sessions.map((session) => session.key));
setWorkbenchState((current) => { setWorkbenchState((current) => {
const reconciled = reconcileWorkbench(current, validKeys); const reconciled = reconcileWorkbench(current, validKeys);
if (!activeKey || temporarySessions[activeKey] || !validKeys.has(activeKey)) { if (!activeKey || temporarySessions[activeKey] || !validKeys.has(activeKey)) {
return reconciled; return reconciled;
} }
return ensureWorkbenchTab(reconciled, activeKey); const match = workbenchTabForPane(reconciled, activeKey);
return focusWorkbenchPane(reconciled, match.tabKey, activeKey);
}); });
}, [activeKey, loading, sessions, temporarySessions]); }, [
activeKey,
loading,
sidebarStateLoading,
sessions,
temporarySessions,
]);
useEffect(() => { useEffect(() => {
if (loading) return; if (loading) return;
@@ -1783,6 +1810,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;
setWorkbenchState((current) => (
renameWorkbenchTab(current, pendingTabRename.key, title)
));
setPendingTabRename(null);
}, [pendingTabRename]);
const onToggleGroup = useCallback( const onToggleGroup = useCallback(
(groupId: string) => { (groupId: string) => {
void updateSidebarState((current) => { void updateSidebarState((current) => {
@@ -1867,17 +1906,6 @@ function Shell({
[activeKey, navigate, sidebarState.archived_keys, topicSessions, 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(() => {
void updateSidebarState((current) => ({ void updateSidebarState((current) => ({
...current, ...current,
@@ -1894,13 +1922,14 @@ function Shell({
}, []); }, []);
const onAddPane = useCallback(async () => { const onAddPane = useCallback(async () => {
const tabKey = activeKey; const tabKey = activeTabKey;
if ( if (
!tabKey !tabKey
|| !activeKey
|| !activeSession || !activeSession
|| creatingPane || creatingPane
|| (activeTabState?.paneKeys.length ?? 0) >= MAX_WORKBENCH_PANES || (activeTabState?.paneKeys.length ?? 0) >= MAX_WORKBENCH_PANES
|| temporarySessionsRef.current[tabKey] || temporarySessionsRef.current[activeKey]
) return; ) return;
setMobileSidebarOpen(false); setMobileSidebarOpen(false);
setSessionSearchOpen(false); setSessionSearchOpen(false);
@@ -1909,7 +1938,17 @@ function Shell({
const scope = activeWorkspaceScope; const scope = activeWorkspaceScope;
const chatId = await createChat(scope); const chatId = await createChat(scope);
const paneKey = `websocket:${chatId}`; const paneKey = `websocket:${chatId}`;
setWorkbenchState((current) => addWorkbenchPane(current, tabKey, paneKey)); pendingCreatedSessionKeyRef.current = paneKey;
setWorkbenchState((current) => {
const withTab = ensureWorkbenchPaneTab(current, activeKey);
const target = workbenchTabForPane(withTab, activeKey);
return addWorkbenchPane(withTab, target.tabKey, paneKey);
});
navigate({
view: "chat",
activeKey: paneKey,
settingsSection: "overview",
});
if (scope) { if (scope) {
setWorkspaceOverrides((current) => ({ setWorkspaceOverrides((current) => ({
...current, ...current,
@@ -1927,10 +1966,12 @@ function Shell({
}, [ }, [
activeKey, activeKey,
activeSession, activeSession,
activeTabKey,
activeTabState, activeTabState,
activeWorkspaceScope, activeWorkspaceScope,
createChat, createChat,
creatingPane, creatingPane,
navigate,
t, t,
]); ]);
@@ -2242,6 +2283,68 @@ function Shell({
|| deriveTitle(session.preview, t("chat.newChat")) || deriveTitle(session.preview, t("chat.newChat"))
), [sidebarState.title_overrides, t]); ), [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
@@ -2250,18 +2353,31 @@ function Shell({
const workbenchPaneSessions = useMemo(() => { const workbenchPaneSessions = useMemo(() => {
if (!activeTabState) return []; if (!activeTabState) return [];
const byKey = new Map(sessions.map((session) => [session.key, session])); const byKey = new Map(sessions.map((session) => [session.key, session]));
return activeTabState.paneKeys const sortedPaneKeys = activeTabKey
? orderedWorkbenchTabsByKey.get(activeTabKey)?.paneKeys ?? activeTabState.paneKeys
: activeTabState.paneKeys;
const paneKeys = [
...activeTabState.layoutPaneKeys.filter((key) => byKey.has(key)),
...sortedPaneKeys.filter((key) => !activeTabState.layoutPaneKeys.includes(key)),
];
return paneKeys
.map((key) => byKey.get(key)) .map((key) => byKey.get(key))
.filter((session): session is ChatSummary => session !== undefined); .filter((session): session is ChatSummary => session !== undefined);
}, [activeTabState, sessions]); }, [activeTabKey, activeTabState, orderedWorkbenchTabsByKey, sessions]);
const paneChromeEnabled = Boolean( const paneChromeEnabled = Boolean(
activeKey && activeSession && !temporaryChatActive && activeTabState, activeKey && activeSession && !temporaryChatActive && activeTabState,
); );
const activeTabVisible = Boolean(
activeTabState
&& (activeTabState.explicit || activeTabState.paneKeys.length > 1),
);
const renderedWorkbenchPanes = useMemo(() => { const renderedWorkbenchPanes = useMemo(() => {
if (paneChromeEnabled && activeKey) { if (paneChromeEnabled) {
return workbenchPaneSessions.map((session) => ({ return workbenchPaneSessions.map((session) => ({
key: session.key, key: session.key,
reactKey: session.key === activeKey ? "tab-root" : `pane:${session.key}`, reactKey: session.key === activeTabState?.paneKeys[0]
? "tab-root"
: `pane:${session.key}`,
title: titleForSession(session), title: titleForSession(session),
})); }));
} }
@@ -2270,7 +2386,14 @@ function Shell({
reactKey: "tab-root", reactKey: "tab-root",
title: headerTitle, title: headerTitle,
}]; }];
}, [activeKey, headerTitle, paneChromeEnabled, titleForSession, workbenchPaneSessions]); }, [
activeKey,
activeTabState?.paneKeys,
headerTitle,
paneChromeEnabled,
titleForSession,
workbenchPaneSessions,
]);
const renderedActivePaneKey = paneChromeEnabled && activeTabState const renderedActivePaneKey = paneChromeEnabled && activeTabState
? activeTabState.activePaneKey ? activeTabState.activePaneKey
: renderedWorkbenchPanes[0].key; : renderedWorkbenchPanes[0].key;
@@ -2279,9 +2402,9 @@ function Shell({
: "columns"; : "columns";
const sidebarPaneGroups = useMemo(() => { const sidebarPaneGroups = useMemo(() => {
const sessionsByKey = new Map(sessions.map((session) => [session.key, session])); const sessionsByKey = new Map(sessions.map((session) => [session.key, session]));
return Object.fromEntries(topicSessions.map((topic) => { return Object.fromEntries(sidebarTabPresentations.map((presentation) => {
const tab = workbenchTab(workbenchState, topic.key); const orderedTab = presentation.orderedTab;
const panes = tab.paneKeys const panes = orderedTab.paneKeys
.map((key) => sessionsByKey.get(key)) .map((key) => sessionsByKey.get(key))
.filter((session): session is ChatSummary => session !== undefined) .filter((session): session is ChatSummary => session !== undefined)
.map((session) => ({ .map((session) => ({
@@ -2289,73 +2412,72 @@ function Shell({
chatId: session.chatId, chatId: session.chatId,
title: titleForSession(session), title: titleForSession(session),
})); }));
return [topic.key, { return [presentation.rowKey, {
topicKey: topic.key, tabKey: orderedTab.tabKey,
activePaneKey: tab.activePaneKey, title: presentation.title,
activePaneKey: orderedTab.tab.activePaneKey,
visible: orderedTab.tab.explicit || orderedTab.paneKeys.length > 1,
panes, panes,
}]; }];
})); }));
}, [sessions, titleForSession, topicSessions, workbenchState]); }, [
const attachableTabKeys = useMemo(() => ( sessions,
topicSessions sidebarTabPresentations,
.filter((session) => workbenchTab(workbenchState, session.key).paneKeys.length === 1) titleForSession,
.map((session) => session.key) ]);
), [topicSessions, workbenchState]);
const paneAcceptingTabKeys = useMemo(() => (
topicSessions
.filter((session) => (
workbenchTab(workbenchState, session.key).paneKeys.length < MAX_WORKBENCH_PANES
))
.map((session) => session.key)
), [topicSessions, workbenchState]);
const activePaneLimitReached = Boolean( const activePaneLimitReached = Boolean(
activeTabState && activeTabState.paneKeys.length >= MAX_WORKBENCH_PANES, activeTabState && activeTabState.paneKeys.length >= MAX_WORKBENCH_PANES,
); );
const onActivateWorkbenchPane = useCallback((paneKey: string) => { const onActivateWorkbenchPane = useCallback((paneKey: string) => {
if (!activeKey) return; if (!activeTabKey) return;
setWorkbenchState((current) => focusWorkbenchPane(current, activeKey, paneKey)); setWorkbenchState((current) => focusWorkbenchPane(current, activeTabKey, paneKey));
}, [activeKey]); onSelectChat(paneKey);
}, [activeTabKey, onSelectChat]);
const onSelectSidebarTab = useCallback((tabKey: string) => {
const tab = workbenchTab(workbenchState, tabKey);
if (tab) onSelectChat(tab.activePaneKey);
}, [onSelectChat, workbenchState]);
const onSelectSidebarItem = useCallback((key: string) => {
if (
temporarySessionsRef.current[key]
|| sessions.some((session) => session.key === key)
) {
onSelectChat(key);
return;
}
onSelectSidebarTab(key);
}, [onSelectChat, onSelectSidebarTab, sessions]);
const onSelectSidebarPane = useCallback((tabKey: string, paneKey: string) => { const onSelectSidebarPane = useCallback((tabKey: string, paneKey: string) => {
setWorkbenchState((current) => focusWorkbenchPane(current, tabKey, paneKey)); setWorkbenchState((current) => focusWorkbenchPane(current, tabKey, paneKey));
if (activeKey !== tabKey) { onSelectChat(paneKey);
navigate({ }, [onSelectChat]);
view: "chat",
activeKey: tabKey,
settingsSection: "overview",
});
}
}, [activeKey, navigate]);
const onDetachWorkbenchPane = useCallback((tabKey: string, paneKey: string) => { const onDetachWorkbenchPane = useCallback((tabKey: string, paneKey: string) => {
setWorkbenchState((current) => detachWorkbenchPane(current, tabKey, paneKey)); setWorkbenchState((current) => detachWorkbenchPane(current, tabKey, paneKey));
}, []); }, []);
const onPromoteWorkbenchPane = useCallback((tabKey: string, paneKey: string) => { const onCreateWorkbenchTab = useCallback((tabKey: string) => {
setWorkbenchState((current) => promoteWorkbenchPane(current, tabKey, paneKey)); setWorkbenchState((current) => createWorkbenchTab(current, tabKey));
}, []);
const onDissolveWorkbenchTab = useCallback((tabKey: string) => {
setWorkbenchState((current) => dissolveWorkbenchTab(current, tabKey));
}, []); }, []);
const onAttachWorkbenchPane = useCallback(( const onAttachWorkbenchPane = useCallback((
paneKey: string, paneKey: string,
tabKey: string, tabKey: string,
beforePaneKey?: string | null,
) => { ) => {
if (paneKey === tabKey) return; setWorkbenchState((current) => {
setWorkbenchState((current) => attachWorkbenchPane( const target = workbenchTab(current, tabKey);
current, if (!target || (!target.explicit && target.paneKeys.length < 2)) return current;
tabKey, return attachWorkbenchPane(current, tabKey, paneKey);
paneKey, });
beforePaneKey, }, []);
));
if (activeKey === paneKey) {
navigate({
view: "chat",
activeKey: tabKey,
settingsSection: "overview",
});
}
}, [activeKey, navigate]);
useEffect(() => { useEffect(() => {
if (view === "settings") { if (view === "settings") {
@@ -2387,28 +2509,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: topicSessions, 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, onRequestDeleteMany,
onTogglePin, onTogglePin,
onRequestRename, onRequestRename,
onToggleArchive, onToggleArchive,
onRequestRenameTab,
paneGroups: sidebarPaneGroups, paneGroups: sidebarPaneGroups,
onSelectPane: onSelectSidebarPane, onSelectPane: onSelectSidebarPane,
onCreateTab: onCreateWorkbenchTab,
onDetachPane: onDetachWorkbenchPane, onDetachPane: onDetachWorkbenchPane,
onPromotePane: onPromoteWorkbenchPane, onDissolveTab: onDissolveWorkbenchTab,
attachableTabKeys,
paneAcceptingTabKeys,
onAttachPane: onAttachWorkbenchPane, onAttachPane: onAttachWorkbenchPane,
onReorderSessions,
onToggleGroup, onToggleGroup,
onRequestRenameProject, onRequestRenameProject,
onNewChatInProject, onNewChatInProject,
@@ -2420,19 +2563,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: topicSessions.filter( archivedCount: sidebarArchivedTabKeys.length,
(session) => sidebarState.archived_keys.includes(session.key),
).length,
defaultWorkspacePath: workspaces?.default_scope.project_path ?? null, defaultWorkspacePath: workspaces?.default_scope.project_path ?? null,
}; };
const hostSidebarCollapsed = showHostChrome && !hostSidebarOpen; const hostSidebarCollapsed = showHostChrome && !hostSidebarOpen;
@@ -2592,13 +2735,20 @@ function Shell({
activePaneKey={renderedActivePaneKey} activePaneKey={renderedActivePaneKey}
layout={renderedWorkbenchLayout} layout={renderedWorkbenchLayout}
chrome={paneChromeEnabled} chrome={paneChromeEnabled}
showLayoutControl={activeTabVisible}
addPaneDisabled={creatingPane || activePaneLimitReached} addPaneDisabled={creatingPane || activePaneLimitReached}
onActivatePane={onActivateWorkbenchPane} onActivatePane={onActivateWorkbenchPane}
onAddPane={onAddPane} onAddPane={onAddPane}
onLayoutChange={(layout) => { onLayoutChange={(layout) => {
if (!activeKey) return; if (!activeTabKey) return;
setWorkbenchState((current) => ( setWorkbenchState((current) => (
setWorkbenchLayout(current, activeKey, layout) setWorkbenchLayout(current, activeTabKey, layout)
));
}}
onPaneOrderChange={(paneKeys) => {
if (!activeTabKey) return;
setWorkbenchState((current) => (
setWorkbenchPaneLayoutOrder(current, activeTabKey, paneKeys)
)); ));
}} }}
renderPane={(pane, context) => { renderPane={(pane, context) => {
@@ -2674,6 +2824,7 @@ function Shell({
defaultValue: "Message {{title}}", defaultValue: "Message {{title}}",
title: pane.title, title: pane.title,
})} })}
emptyComposerVariant="thread"
workspaceScope={paneScope} workspaceScope={paneScope}
workspaceDefaultScope={workspaces?.default_scope ?? null} workspaceDefaultScope={workspaces?.default_scope ?? null}
workspaceControls={workspaces?.controls ?? null} workspaceControls={workspaces?.controls ?? null}
@@ -2745,6 +2896,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
+10 -9
View File
@@ -46,19 +46,17 @@ interface SidebarProps {
onRequestDeleteMany?: (items: SidebarDeleteItem[]) => 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;
paneGroups?: Record<string, SidebarPaneGroup>; paneGroups?: Record<string, SidebarPaneGroup>;
onSelectPane?: (tabKey: string, paneKey: string) => void; onSelectPane?: (tabKey: string, paneKey: string) => void;
onCreateTab?: (tabKey: string) => void;
onDetachPane?: (tabKey: string, paneKey: string) => void; onDetachPane?: (tabKey: string, paneKey: string) => void;
onPromotePane?: (tabKey: string, paneKey: string) => void; onDissolveTab?: (tabKey: string) => void;
attachableTabKeys?: string[];
paneAcceptingTabKeys?: string[];
onAttachPane?: ( onAttachPane?: (
paneKey: string, paneKey: string,
tabKey: string, tabKey: string,
beforePaneKey?: string | null,
) => void; ) => void;
onReorderSessions: (keys: 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;
@@ -76,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>;
@@ -249,20 +249,21 @@ export function Sidebar(props: SidebarProps) {
onRequestDeleteMany={props.onRequestDeleteMany} onRequestDeleteMany={props.onRequestDeleteMany}
onTogglePin={props.onTogglePin} onTogglePin={props.onTogglePin}
onRequestRename={props.onRequestRename} onRequestRename={props.onRequestRename}
onRequestRenameTab={props.onRequestRenameTab}
onToggleArchive={props.onToggleArchive} onToggleArchive={props.onToggleArchive}
paneGroups={props.paneGroups} paneGroups={props.paneGroups}
onSelectPane={props.onSelectPane} onSelectPane={props.onSelectPane}
onCreateTab={props.onCreateTab}
onDetachPane={props.onDetachPane} onDetachPane={props.onDetachPane}
onPromotePane={props.onPromotePane} onDissolveTab={props.onDissolveTab}
attachableTabKeys={props.attachableTabKeys}
paneAcceptingTabKeys={props.paneAcceptingTabKeys}
onAttachPane={props.onAttachPane} onAttachPane={props.onAttachPane}
onReorderSessions={props.onReorderSessions}
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}
-81
View File
@@ -1,81 +0,0 @@
import type { DraggedPane } from "@/lib/session-drag";
export interface PaneDropSlot {
beforePaneKey: string | null;
tabKey: string;
}
export interface PaneTabDragState {
height: number;
item: DraggedPane;
origin: "pane" | "tab";
slot: PaneDropSlot | null;
}
export interface PaneTabDragLayout {
offsets: Map<string, number>;
slotIndex: number;
}
export function samePaneDropSlot(
current: PaneDropSlot | null,
next: PaneDropSlot | null,
): boolean {
return current?.tabKey === next?.tabKey
&& current?.beforePaneKey === next?.beforePaneKey;
}
export function paneDropSlotForRow(
tabKey: string,
paneKeys: string[],
draggedPaneKey: string,
targetPaneKey: string,
edge: "before" | "after",
): PaneDropSlot {
const remaining = paneKeys.filter((key) => key !== draggedPaneKey);
const targetIndex = remaining.indexOf(targetPaneKey);
const insertionIndex = targetIndex < 0
? remaining.length
: targetIndex + (edge === "after" ? 1 : 0);
return {
tabKey,
beforePaneKey: remaining[insertionIndex] ?? null,
};
}
export function paneTabDragLayout(
paneKeys: string[],
tabKey: string,
drag: PaneTabDragState | null,
): PaneTabDragLayout {
const offsets = new Map<string, number>();
if (!drag || drag.height <= 0) {
return { offsets, slotIndex: -1 };
}
const distance = drag.height + 2;
const sourceIndex = paneKeys.indexOf(drag.item.paneKey);
if (
sourceIndex < 0
|| drag.item.sourceTabKey !== tabKey
|| drag.slot?.tabKey !== tabKey
) {
return { offsets, slotIndex: -1 };
}
const remaining = paneKeys.filter((key) => key !== drag.item.paneKey);
const requestedIndex = drag.slot.beforePaneKey
? remaining.indexOf(drag.slot.beforePaneKey)
: remaining.length;
const slotIndex = requestedIndex < 0 ? remaining.length : requestedIndex;
if (sourceIndex < slotIndex) {
for (let index = sourceIndex + 1; index <= slotIndex; index += 1) {
offsets.set(paneKeys[index], -distance);
}
} else if (sourceIndex > slotIndex) {
for (let index = slotIndex; index < sourceIndex; index += 1) {
offsets.set(paneKeys[index], distance);
}
}
return { offsets, slotIndex };
}
+5 -2
View File
@@ -323,6 +323,7 @@ interface ThreadShellProps {
composerPortalTarget?: HTMLElement | null; composerPortalTarget?: HTMLElement | null;
composerActive?: boolean; composerActive?: boolean;
composerInputAriaLabel?: string; 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;
@@ -618,6 +619,7 @@ export function ThreadShell({
composerPortalTarget, composerPortalTarget,
composerActive = true, composerActive = true,
composerInputAriaLabel, composerInputAriaLabel,
emptyComposerVariant = "hero",
workspaceScope = null, workspaceScope = null,
workspaceDefaultScope = null, workspaceDefaultScope = null,
workspaceControls = null, workspaceControls = null,
@@ -860,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);
@@ -1425,7 +1428,7 @@ export function ThreadShell({
inputAriaLabel={composerInputAriaLabel} 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")
} }
@@ -1439,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}
+361 -59
View File
@@ -2,15 +2,16 @@ import {
Columns2, Columns2,
Grid2X2, Grid2X2,
PanelLeft, PanelLeft,
PanelsTopLeft,
Plus, Plus,
Rows2, Rows2,
Square,
type LucideIcon, type LucideIcon,
} from "lucide-react"; } from "lucide-react";
import { import {
type CSSProperties, type CSSProperties,
type FocusEvent, type FocusEvent,
type PointerEvent, type KeyboardEvent,
type PointerEvent as ReactPointerEvent,
type ReactNode, type ReactNode,
useCallback, useCallback,
useEffect, useEffect,
@@ -59,10 +60,12 @@ interface PaneWorkbenchProps {
activePaneKey: string; activePaneKey: string;
layout: WorkbenchLayout; layout: WorkbenchLayout;
chrome?: boolean; chrome?: boolean;
showLayoutControl: boolean;
addPaneDisabled?: boolean; addPaneDisabled?: boolean;
onActivatePane: (key: string) => void; onActivatePane: (key: string) => void;
onAddPane: () => void; onAddPane: () => void;
onLayoutChange: (layout: WorkbenchLayout) => void; onLayoutChange: (layout: WorkbenchLayout) => void;
onPaneOrderChange: (paneKeys: string[]) => void;
renderPane: (pane: WorkbenchPane, context: PaneRenderContext) => ReactNode; renderPane: (pane: WorkbenchPane, context: PaneRenderContext) => ReactNode;
} }
@@ -77,11 +80,13 @@ const LAYOUT_CONTROLS: Array<{
{ icon: Columns2, layout: "columns", label: "Columns" }, { icon: Columns2, layout: "columns", label: "Columns" },
{ icon: Rows2, layout: "rows", label: "Rows" }, { icon: Rows2, layout: "rows", label: "Rows" },
{ icon: Grid2X2, layout: "grid", label: "Grid" }, { icon: Grid2X2, layout: "grid", label: "Grid" },
{ icon: PanelsTopLeft, layout: "bsp", label: "BSP" },
{ icon: PanelLeft, layout: "main-stack", label: "Main and stack" }, { icon: PanelLeft, layout: "main-stack", label: "Main and stack" },
{ icon: Square, layout: "monocle", label: "Monocle" },
]; ];
function paneGridStyle(layout: WorkbenchLayout, paneCount: number): CSSProperties { type EffectiveWorkbenchLayout = WorkbenchLayout | "compact";
function paneGridStyle(layout: EffectiveWorkbenchLayout, paneCount: number): CSSProperties {
const count = Math.max(1, paneCount); const count = Math.max(1, paneCount);
switch (layout) { switch (layout) {
case "columns": case "columns":
@@ -102,6 +107,11 @@ function paneGridStyle(layout: WorkbenchLayout, paneCount: number): CSSPropertie
gridTemplateRows: `repeat(${rows}, minmax(0, 1fr))`, gridTemplateRows: `repeat(${rows}, minmax(0, 1fr))`,
}; };
} }
case "bsp":
return {
gridTemplateColumns: "repeat(4, minmax(0, 1fr))",
gridTemplateRows: "repeat(4, minmax(0, 1fr))",
};
case "main-stack": case "main-stack":
return count === 1 return count === 1
? { ? {
@@ -112,7 +122,7 @@ function paneGridStyle(layout: WorkbenchLayout, paneCount: number): CSSPropertie
gridTemplateColumns: "minmax(0, 1.65fr) minmax(0, 1fr)", gridTemplateColumns: "minmax(0, 1.65fr) minmax(0, 1fr)",
gridTemplateRows: `repeat(${count - 1}, minmax(0, 1fr))`, gridTemplateRows: `repeat(${count - 1}, minmax(0, 1fr))`,
}; };
case "monocle": case "compact":
return { return {
gridTemplateColumns: "minmax(0, 1fr)", gridTemplateColumns: "minmax(0, 1fr)",
gridTemplateRows: "minmax(0, 1fr)", gridTemplateRows: "minmax(0, 1fr)",
@@ -120,15 +130,60 @@ function paneGridStyle(layout: WorkbenchLayout, paneCount: number): CSSPropertie
} }
} }
interface BspCell {
columnStart: number;
columnEnd: number;
rowStart: number;
rowEnd: number;
}
function bspPaneCells(paneCount: number): BspCell[] {
const cells: BspCell[] = [{
columnStart: 1,
columnEnd: 5,
rowStart: 1,
rowEnd: 5,
}];
for (let paneIndex = 1; paneIndex < paneCount; paneIndex += 1) {
const leaf = cells.pop();
if (!leaf) break;
if (paneIndex % 2 === 1) {
const midpoint = (leaf.columnStart + leaf.columnEnd) / 2;
cells.push(
{ ...leaf, columnEnd: midpoint },
{ ...leaf, columnStart: midpoint },
);
} else {
const midpoint = (leaf.rowStart + leaf.rowEnd) / 2;
cells.push(
{ ...leaf, rowEnd: midpoint },
{ ...leaf, rowStart: midpoint },
);
}
}
return cells;
}
function paneCellStyle( function paneCellStyle(
layout: WorkbenchLayout, layout: EffectiveWorkbenchLayout,
paneCount: number, paneCount: number,
index: number, index: number,
): CSSProperties | undefined { ): CSSProperties | undefined {
if (layout !== "main-stack" || paneCount < 2) return undefined; if (layout === "bsp") {
return index === 0 const cell = bspPaneCells(paneCount)[index];
? { gridColumn: 1, gridRow: `1 / span ${paneCount - 1}` } return cell
: { gridColumn: 2, gridRow: index }; ? {
gridColumn: `${cell.columnStart} / ${cell.columnEnd}`,
gridRow: `${cell.rowStart} / ${cell.rowEnd}`,
}
: undefined;
}
if (layout === "main-stack" && paneCount >= 2) {
return index === 0
? { gridColumn: 1, gridRow: `1 / span ${paneCount - 1}` }
: { gridColumn: 2, gridRow: index };
}
return undefined;
} }
function isPaneAction(target: EventTarget | null): boolean { function isPaneAction(target: EventTarget | null): boolean {
@@ -136,6 +191,66 @@ function isPaneAction(target: EventTarget | null): boolean {
&& target.closest("[data-workbench-pane-action]") !== null; && 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({ function HeaderIconButton({
disabled, disabled,
icon: Icon, icon: Icon,
@@ -172,22 +287,55 @@ export function PaneWorkbench({
activePaneKey, activePaneKey,
layout, layout,
chrome = true, chrome = true,
showLayoutControl,
addPaneDisabled = false, addPaneDisabled = false,
onActivatePane, onActivatePane,
onAddPane, onAddPane,
onLayoutChange, onLayoutChange,
onPaneOrderChange,
renderPane, renderPane,
}: PaneWorkbenchProps) { }: PaneWorkbenchProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const compact = useMediaQuery("(max-width: 767px)"); const compact = useMediaQuery("(max-width: 767px)");
const effectiveLayout = compact ? "monocle" : layout; const effectiveLayout: EffectiveWorkbenchLayout = compact ? "compact" : layout;
const [headerPortalTarget, setHeaderPortalTarget] = useState<HTMLElement | null>(null); const [headerPortalTarget, setHeaderPortalTarget] = useState<HTMLElement | null>(null);
const [composerPortalTarget, setComposerPortalTarget] = useState<HTMLElement | null>(null); const [composerPortalTarget, setComposerPortalTarget] = useState<HTMLElement | null>(null);
const paneRefs = useRef(new Map<string, HTMLElement>()); const paneRefs = useRef(new Map<string, HTMLElement>());
const lastRectsRef = useRef(new Map<string, DOMRect>()); const lastRectsRef = useRef(new Map<string, DOMRect>());
const pendingRectsRef = useRef<Map<string, DOMRect> | null>(null); const pendingRectsRef = useRef<Map<string, DOMRect> | null>(null);
const animationsRef = useRef(new Map<string, Animation>()); const animationsRef = useRef(new Map<string, Animation>());
const paneOrder = useMemo(() => panes.map((pane) => pane.key).join("\u0000"), [panes]); const sourcePaneOrder = useMemo(
() => panes.map((pane) => pane.key),
[panes],
);
const sourcePaneOrderKey = sourcePaneOrder.join("\u0000");
const [previewPaneKeys, setPreviewPaneKeys] = useState(sourcePaneOrder);
const previewPaneKeysRef = useRef(sourcePaneOrder);
const dragGestureRef = useRef<{
pointerId: number;
paneKey: string;
startX: number;
startY: number;
slotRects: DOMRect[];
started: boolean;
} | null>(null);
const [draggingPaneKey, setDraggingPaneKey] = useState<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]);
const measurePanes = useCallback(() => { const measurePanes = useCallback(() => {
const rects = new Map<string, DOMRect>(); const rects = new Map<string, DOMRect>();
@@ -283,7 +431,7 @@ export function PaneWorkbench({
const handlePanePointerDown = useCallback(( const handlePanePointerDown = useCallback((
key: string, key: string,
event: PointerEvent<HTMLElement>, event: ReactPointerEvent<HTMLElement>,
) => { ) => {
activatePane(key, event.target); activatePane(key, event.target);
}, [activatePane]); }, [activatePane]);
@@ -292,6 +440,114 @@ export function PaneWorkbench({
activatePane(key, event.target); activatePane(key, event.target);
}, [activatePane]); }, [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 gridStyle = paneGridStyle(effectiveLayout, panes.length); const gridStyle = paneGridStyle(effectiveLayout, panes.length);
const currentLayout = LAYOUT_CONTROLS.find((control) => control.layout === layout) const currentLayout = LAYOUT_CONTROLS.find((control) => control.layout === layout)
?? LAYOUT_CONTROLS[0]; ?? LAYOUT_CONTROLS[0];
@@ -300,51 +556,53 @@ export function PaneWorkbench({
data-workbench-pane-action data-workbench-pane-action
className="host-no-drag flex items-center gap-0.5" className="host-no-drag flex items-center gap-0.5"
> >
<DropdownMenu> {showLayoutControl ? (
<DropdownMenuTrigger asChild> <DropdownMenu>
<Button <DropdownMenuTrigger asChild>
type="button" <Button
variant="ghost" type="button"
size="icon" variant="ghost"
aria-label={t("workbench.layout", { size="icon"
defaultValue: "Pane layout", 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" })}
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()}
> >
<currentLayout.icon className="h-4 w-4" aria-hidden /> <DropdownMenuLabel>
</Button> {t("workbench.layout", { defaultValue: "Pane layout" })}
</DropdownMenuTrigger> </DropdownMenuLabel>
<DropdownMenuContent <DropdownMenuSeparator />
align="end" <DropdownMenuRadioGroup
onCloseAutoFocus={(event) => event.preventDefault()} value={layout}
> onValueChange={(value) => {
<DropdownMenuLabel> const next = value as WorkbenchLayout;
{t("workbench.layout", { defaultValue: "Pane layout" })} if (next === layout) return;
</DropdownMenuLabel> captureLayout();
<DropdownMenuSeparator /> onLayoutChange(next);
<DropdownMenuRadioGroup }}
value={layout} >
onValueChange={(value) => { {LAYOUT_CONTROLS.map((control) => (
const next = value as WorkbenchLayout; <DropdownMenuRadioItem
if (next === layout) return; key={control.layout}
captureLayout(); value={control.layout}
onLayoutChange(next); >
}} <control.icon aria-hidden />
> {t(`workbench.layouts.${control.layout}`, {
{LAYOUT_CONTROLS.map((control) => ( defaultValue: control.label,
<DropdownMenuRadioItem })}
key={control.layout} </DropdownMenuRadioItem>
value={control.layout} ))}
> </DropdownMenuRadioGroup>
<control.icon aria-hidden /> </DropdownMenuContent>
{t(`workbench.layouts.${control.layout}`, { </DropdownMenu>
defaultValue: control.label, ) : null}
})}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
<HeaderIconButton <HeaderIconButton
disabled={addPaneDisabled} disabled={addPaneDisabled}
icon={Plus} icon={Plus}
@@ -381,9 +639,9 @@ export function PaneWorkbench({
)} )}
style={gridStyle} style={gridStyle}
> >
{panes.map((pane, index) => { {displayedPanes.map((pane, index) => {
const active = pane.key === activePaneKey; const active = pane.key === activePaneKey;
const hidden = effectiveLayout === "monocle" && !active; const hidden = effectiveLayout === "compact" && !active;
return ( return (
<section <section
@@ -395,6 +653,7 @@ export function PaneWorkbench({
hidden={hidden} hidden={hidden}
aria-label={pane.title} aria-label={pane.title}
data-active={active ? "true" : "false"} data-active={active ? "true" : "false"}
data-dragging={draggingPaneKey === pane.key ? "true" : undefined}
data-testid={`workbench-pane-${pane.key}`} data-testid={`workbench-pane-${pane.key}`}
onPointerDownCapture={(event) => handlePanePointerDown(pane.key, event)} onPointerDownCapture={(event) => handlePanePointerDown(pane.key, event)}
onFocusCapture={(event) => handlePaneFocus(pane.key, event)} onFocusCapture={(event) => handlePaneFocus(pane.key, event)}
@@ -407,6 +666,49 @@ export function PaneWorkbench({
composerPortalTarget: chrome ? composerPortalTarget : undefined, composerPortalTarget: chrome ? composerPortalTarget : undefined,
headerActions, 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-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 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background 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-colors duration-100 motion-reduce:transition-none",
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> </section>
); );
})} })}
+283 -148
View File
@@ -1,29 +1,37 @@
export const WORKBENCH_STORAGE_KEY = "nanobot.webui.workbench.v2"; import type {
WorkbenchLayout,
WorkbenchState,
WorkbenchTabState,
} from "@/lib/types";
export type {
WorkbenchLayout,
WorkbenchState,
WorkbenchTabState,
} from "@/lib/types";
export const MAX_WORKBENCH_PANES = 4; export const MAX_WORKBENCH_PANES = 4;
export const WORKBENCH_LAYOUTS = [ export const WORKBENCH_LAYOUTS = [
"columns", "columns",
"rows", "rows",
"grid", "grid",
"bsp",
"main-stack", "main-stack",
"monocle",
] as const; ] as const;
export type WorkbenchLayout = (typeof WORKBENCH_LAYOUTS)[number]; export interface WorkbenchTabMatch {
tabKey: string;
export interface WorkbenchTabState { tab: WorkbenchTabState;
paneKeys: string[];
activePaneKey: string;
layout: WorkbenchLayout;
} }
export interface WorkbenchState { export interface OrderedWorkbenchTab extends WorkbenchTabMatch {
version: 2; paneKeys: string[];
tabs: Record<string, WorkbenchTabState>; updatedAt: string | null;
} }
export const EMPTY_WORKBENCH_STATE: WorkbenchState = { export const EMPTY_WORKBENCH_STATE: WorkbenchState = {
version: 2, version: 1,
tabs: {}, tabs: {},
}; };
@@ -39,72 +47,118 @@ function uniqueKeys(value: unknown): string[] {
)); ));
} }
function insertPaneBefore( function normalizeTitle(value: unknown): string | null {
paneKeys: string[], if (typeof value !== "string") return null;
paneKey: string, const title = value.trim();
beforePaneKey?: string | null, return title || null;
): string[] {
const next = paneKeys.filter((key) => key !== paneKey);
const requestedIndex = beforePaneKey && beforePaneKey !== paneKey
? next.indexOf(beforePaneKey)
: -1;
next.splice(requestedIndex < 0 ? next.length : requestedIndex, 0, paneKey);
return next;
} }
function normalizeTab(value: unknown, tabKey: string): WorkbenchTabState { function normalizeTab(value: unknown): WorkbenchTabState {
const candidate = value && typeof value === "object" const candidate = value && typeof value === "object"
? value as Partial<WorkbenchTabState> ? value as Partial<WorkbenchTabState>
: {}; : {};
const paneKeys = uniqueKeys(candidate.paneKeys); const paneKeys = uniqueKeys(candidate.paneKeys).slice(0, MAX_WORKBENCH_PANES);
const normalizedPaneKeys = (paneKeys.includes(tabKey) const requestedLayoutPaneKeys = uniqueKeys(candidate.layoutPaneKeys)
? paneKeys .filter((key) => paneKeys.includes(key));
: [tabKey, ...paneKeys]).slice(0, MAX_WORKBENCH_PANES); const layoutPaneKeys = [
...requestedLayoutPaneKeys,
...paneKeys.filter((key) => !requestedLayoutPaneKeys.includes(key)),
];
return { return {
paneKeys: normalizedPaneKeys, explicit: candidate.explicit === true,
title: normalizeTitle(candidate.title),
paneKeys,
layoutPaneKeys,
activePaneKey: activePaneKey:
typeof candidate.activePaneKey === "string" typeof candidate.activePaneKey === "string"
&& normalizedPaneKeys.includes(candidate.activePaneKey) && paneKeys.includes(candidate.activePaneKey)
? candidate.activePaneKey ? candidate.activePaneKey
: normalizedPaneKeys[0], : paneKeys[0] ?? "",
layout: isLayout(candidate.layout) ? candidate.layout : "columns", layout: isLayout(candidate.layout) ? candidate.layout : "columns",
}; };
} }
export function parseWorkbenchState(serialized: string | null): WorkbenchState { function standaloneTabKeyBase(paneKey: string): string {
if (!serialized) return EMPTY_WORKBENCH_STATE; return `tab:${paneKey}`;
try {
const parsed = JSON.parse(serialized) as { version?: unknown; tabs?: unknown };
if (
parsed.version !== 2
|| !parsed.tabs
|| typeof parsed.tabs !== "object"
|| Array.isArray(parsed.tabs)
) {
return EMPTY_WORKBENCH_STATE;
}
const tabs = Object.fromEntries(
Object.entries(parsed.tabs).map(([tabKey, tab]) => [tabKey, normalizeTab(tab, tabKey)]),
);
return { version: 2, tabs };
} catch {
return EMPTY_WORKBENCH_STATE;
}
} }
export function defaultWorkbenchTab(tabKey: string): WorkbenchTabState { 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 { return {
paneKeys: [tabKey], explicit: false,
activePaneKey: tabKey, title: normalizeTitle(title),
paneKeys: [paneKey],
layoutPaneKeys: [paneKey],
activePaneKey: paneKey,
layout: "columns", layout: "columns",
}; };
} }
export function normalizeWorkbenchState(raw: unknown): WorkbenchState {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
return EMPTY_WORKBENCH_STATE;
}
const parsed = raw as { version?: unknown; tabs?: unknown };
if (parsed.version !== 1 || !parsed.tabs
|| typeof parsed.tabs !== "object" || Array.isArray(parsed.tabs)) {
return EMPTY_WORKBENCH_STATE;
}
return {
version: 1,
tabs: Object.fromEntries(
Object.entries(parsed.tabs).map(([tabKey, tab]) => [tabKey, normalizeTab(tab)]),
),
};
}
export function workbenchTab( export function workbenchTab(
state: WorkbenchState, state: WorkbenchState,
tabKey: string, tabKey: string,
): WorkbenchTabState { ): WorkbenchTabState | null {
return state.tabs[tabKey] ?? defaultWorkbenchTab(tabKey); return state.tabs[tabKey] ?? null;
}
export function workbenchTabForPane(
state: WorkbenchState,
paneKey: string,
): WorkbenchTabMatch {
const match = Object.entries(state.tabs).find(([, tab]) => tab.paneKeys.includes(paneKey));
if (match) return { tabKey: match[0], tab: match[1] };
return {
tabKey: availableStandaloneTabKey(state.tabs, paneKey),
tab: defaultWorkbenchTab(paneKey),
};
}
export function ensureWorkbenchPaneTab(
state: WorkbenchState,
paneKey: string,
title: string | null = null,
): WorkbenchState {
if (!paneKey || Object.values(state.tabs).some((tab) => tab.paneKeys.includes(paneKey))) {
return state;
}
const tabKey = availableStandaloneTabKey(state.tabs, paneKey);
return {
version: 1,
tabs: {
...state.tabs,
[tabKey]: defaultWorkbenchTab(paneKey, title),
},
};
} }
function updateTab( function updateTab(
@@ -112,11 +166,12 @@ function updateTab(
tabKey: string, tabKey: string,
update: (tab: WorkbenchTabState) => WorkbenchTabState, update: (tab: WorkbenchTabState) => WorkbenchTabState,
): WorkbenchState { ): WorkbenchState {
const current = workbenchTab(state, tabKey); const current = state.tabs[tabKey];
if (!current) return state;
const next = update(current); const next = update(current);
if (state.tabs[tabKey] === next) return state; if (next === current) return state;
return { return {
version: 2, version: 1,
tabs: { tabs: {
...state.tabs, ...state.tabs,
[tabKey]: next, [tabKey]: next,
@@ -124,31 +179,12 @@ function updateTab(
}; };
} }
export function ensureWorkbenchTab(
state: WorkbenchState,
tabKey: string,
): WorkbenchState {
if (state.tabs[tabKey]) return state;
return updateTab(state, tabKey, (tab) => tab);
}
export function addWorkbenchPane( export function addWorkbenchPane(
state: WorkbenchState, state: WorkbenchState,
tabKey: string, tabKey: string,
paneKey: string, paneKey: string,
): WorkbenchState { ): WorkbenchState {
return updateTab(state, tabKey, (tab) => { return attachWorkbenchPane(state, tabKey, paneKey);
if (tab.paneKeys.includes(paneKey)) {
if (tab.activePaneKey === paneKey) return tab;
return { ...tab, activePaneKey: paneKey };
}
if (tab.paneKeys.length >= MAX_WORKBENCH_PANES) return tab;
return {
...tab,
paneKeys: [...tab.paneKeys, paneKey],
activePaneKey: paneKey,
};
});
} }
export function focusWorkbenchPane( export function focusWorkbenchPane(
@@ -163,29 +199,88 @@ export function focusWorkbenchPane(
)); ));
} }
export function createWorkbenchTab(
state: WorkbenchState,
tabKey: string,
): WorkbenchState {
return updateTab(state, tabKey, (tab) => (
tab.explicit ? tab : { ...tab, explicit: true }
));
}
export function detachWorkbenchPane( export function detachWorkbenchPane(
state: WorkbenchState, state: WorkbenchState,
tabKey: string, tabKey: string,
paneKey: string, paneKey: string,
): WorkbenchState { ): WorkbenchState {
return updateTab(state, tabKey, (tab) => { const tab = state.tabs[tabKey];
const index = tab.paneKeys.indexOf(paneKey); if (!tab || !tab.paneKeys.includes(paneKey)) return state;
if (index < 0 || paneKey === tabKey || tab.paneKeys.length === 1) return tab; if (tab.paneKeys.length === 1) {
const paneKeys = tab.paneKeys.filter((key) => key !== paneKey); return tab.explicit
const activePaneKey = tab.activePaneKey === paneKey ? updateTab(state, tabKey, (current) => ({
? paneKeys[Math.min(index, paneKeys.length - 1)] ...current,
: tab.activePaneKey; explicit: false,
return { ...tab, paneKeys, activePaneKey }; title: null,
}); layout: "columns",
}))
: state;
}
const index = tab.paneKeys.indexOf(paneKey);
const paneKeys = tab.paneKeys.filter((key) => key !== paneKey);
const layoutPaneKeys = tab.layoutPaneKeys.filter((key) => key !== paneKey);
const nextTabKey = availableStandaloneTabKey(state.tabs, paneKey);
return {
version: 1,
tabs: {
...state.tabs,
[tabKey]: {
...tab,
paneKeys,
layoutPaneKeys,
activePaneKey: tab.activePaneKey === paneKey
? paneKeys[Math.min(index, paneKeys.length - 1)]
: tab.activePaneKey,
},
[nextTabKey]: defaultWorkbenchTab(paneKey),
},
};
}
export function dissolveWorkbenchTab(
state: WorkbenchState,
tabKey: string,
): WorkbenchState {
const tab = state.tabs[tabKey];
if (!tab) return state;
if (tab.paneKeys.length === 1) {
return tab.explicit
? updateTab(state, tabKey, (current) => ({
...current,
explicit: false,
title: null,
layout: "columns",
}))
: state;
}
const tabs = { ...state.tabs };
delete tabs[tabKey];
for (const paneKey of tab.paneKeys) {
const standaloneTabKey = availableStandaloneTabKey(tabs, paneKey);
tabs[standaloneTabKey] = defaultWorkbenchTab(paneKey);
}
return { version: 1, tabs };
} }
export function attachWorkbenchPane( export function attachWorkbenchPane(
state: WorkbenchState, state: WorkbenchState,
targetTabKey: string, targetTabKey: string,
paneKey: string, paneKey: string,
beforePaneKey?: string | null,
): WorkbenchState { ): WorkbenchState {
if (!targetTabKey || !paneKey || targetTabKey === paneKey) return state; if (!targetTabKey || !paneKey) return state;
const target = state.tabs[targetTabKey];
if (!target) return state;
const sourceEntry = Object.entries(state.tabs).find(([, tab]) => ( const sourceEntry = Object.entries(state.tabs).find(([, tab]) => (
tab.paneKeys.includes(paneKey) tab.paneKeys.includes(paneKey)
@@ -193,71 +288,58 @@ export function attachWorkbenchPane(
const sourceTabKey = sourceEntry?.[0]; const sourceTabKey = sourceEntry?.[0];
const sourceTab = sourceEntry?.[1]; const sourceTab = sourceEntry?.[1];
if (sourceTabKey === targetTabKey) { if (sourceTabKey === targetTabKey) {
if (beforePaneKey === undefined) { return focusWorkbenchPane(state, targetTabKey, paneKey);
return focusWorkbenchPane(state, targetTabKey, paneKey);
}
if (!sourceTab) return state;
const paneKeys = insertPaneBefore(sourceTab.paneKeys, paneKey, beforePaneKey);
if (paneKeys.every((key, index) => key === sourceTab.paneKeys[index])) return state;
return {
version: 2,
tabs: {
...state.tabs,
[targetTabKey]: { ...sourceTab, paneKeys },
},
};
} }
if (sourceTabKey === paneKey && sourceTab && sourceTab.paneKeys.length > 1) { if (!target.paneKeys.includes(paneKey) && target.paneKeys.length >= MAX_WORKBENCH_PANES) {
return state;
}
const targetBeforeMove = state.tabs[targetTabKey] ?? defaultWorkbenchTab(targetTabKey);
if (
!targetBeforeMove.paneKeys.includes(paneKey)
&& targetBeforeMove.paneKeys.length >= MAX_WORKBENCH_PANES
) {
return state; return state;
} }
const tabs = { ...state.tabs }; const tabs = { ...state.tabs };
if (sourceTabKey && sourceTab) { if (sourceTabKey && sourceTab) {
if (sourceTabKey === paneKey) { const index = sourceTab.paneKeys.indexOf(paneKey);
const sourcePaneKeys = sourceTab.paneKeys.filter((key) => key !== paneKey);
const sourceLayoutPaneKeys = sourceTab.layoutPaneKeys.filter((key) => key !== paneKey);
if (sourcePaneKeys.length === 0) {
delete tabs[sourceTabKey]; delete tabs[sourceTabKey];
} else { } else {
const index = sourceTab.paneKeys.indexOf(paneKey);
const paneKeys = sourceTab.paneKeys.filter((key) => key !== paneKey);
tabs[sourceTabKey] = { tabs[sourceTabKey] = {
...sourceTab, ...sourceTab,
paneKeys, paneKeys: sourcePaneKeys,
layoutPaneKeys: sourceLayoutPaneKeys,
activePaneKey: sourceTab.activePaneKey === paneKey activePaneKey: sourceTab.activePaneKey === paneKey
? paneKeys[Math.min(index, paneKeys.length - 1)] ? sourcePaneKeys[Math.min(index, sourcePaneKeys.length - 1)]
: sourceTab.activePaneKey, : sourceTab.activePaneKey,
}; };
} }
} }
const targetTab = tabs[targetTabKey] ?? defaultWorkbenchTab(targetTabKey); const nextTarget = tabs[targetTabKey];
const paneKeys = insertPaneBefore(targetTab.paneKeys, paneKey, beforePaneKey); 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] = { tabs[targetTabKey] = {
...targetTab, ...nextTarget,
paneKeys, paneKeys,
layoutPaneKeys,
activePaneKey: paneKey, activePaneKey: paneKey,
}; };
return { version: 2, tabs }; return { version: 1, tabs };
} }
export function promoteWorkbenchPane( export function renameWorkbenchTab(
state: WorkbenchState, state: WorkbenchState,
tabKey: string, tabKey: string,
paneKey: string, title: string,
): WorkbenchState { ): WorkbenchState {
return updateTab(state, tabKey, (tab) => { const normalized = normalizeTitle(title);
const index = tab.paneKeys.indexOf(paneKey); if (!normalized) return state;
if (index <= 0) return tab; return updateTab(state, tabKey, (tab) => (
return { tab.title === normalized ? tab : { ...tab, title: normalized }
...tab, ));
paneKeys: [paneKey, ...tab.paneKeys.filter((key) => key !== paneKey)],
};
});
} }
export function setWorkbenchLayout( export function setWorkbenchLayout(
@@ -270,36 +352,89 @@ export function setWorkbenchLayout(
)); ));
} }
export function setWorkbenchPaneLayoutOrder(
state: WorkbenchState,
tabKey: string,
paneKeys: readonly string[],
): WorkbenchState {
return updateTab(state, tabKey, (tab) => {
const requested = uniqueKeys(paneKeys).filter((key) => tab.paneKeys.includes(key));
const layoutPaneKeys = [
...requested,
...tab.paneKeys.filter((key) => !requested.includes(key)),
];
return layoutPaneKeys.every((key, index) => tab.layoutPaneKeys[index] === key)
? tab
: { ...tab, layoutPaneKeys };
});
}
export function reconcileWorkbench( export function reconcileWorkbench(
state: WorkbenchState, state: WorkbenchState,
validKeys: ReadonlySet<string>, validKeys: ReadonlySet<string>,
): WorkbenchState { ): WorkbenchState {
const tabs: Record<string, WorkbenchTabState> = {}; const tabs: Record<string, WorkbenchTabState> = {};
const claimedPaneKeys = new Set<string>();
for (const [tabKey, tab] of Object.entries(state.tabs)) { for (const [tabKey, tab] of Object.entries(state.tabs)) {
if (!validKeys.has(tabKey)) continue; const paneKeys = tab.paneKeys
const paneKeys = tab.paneKeys.filter((key) => validKeys.has(key)); .filter((key) => validKeys.has(key) && !claimedPaneKeys.has(key))
const normalizedPaneKeys = (paneKeys.includes(tabKey) .slice(0, MAX_WORKBENCH_PANES);
? paneKeys if (paneKeys.length === 0) continue;
: [tabKey, ...paneKeys]).slice(0, MAX_WORKBENCH_PANES); for (const paneKey of paneKeys) claimedPaneKeys.add(paneKey);
tabs[tabKey] = { tabs[tabKey] = {
...tab, ...tab,
paneKeys: normalizedPaneKeys, paneKeys,
activePaneKey: normalizedPaneKeys.includes(tab.activePaneKey) layoutPaneKeys: [
...tab.layoutPaneKeys.filter((key) => paneKeys.includes(key)),
...paneKeys.filter((key) => !tab.layoutPaneKeys.includes(key)),
],
activePaneKey: paneKeys.includes(tab.activePaneKey)
? tab.activePaneKey ? tab.activePaneKey
: normalizedPaneKeys[0], : paneKeys[0],
}; };
} }
const serializedCurrent = JSON.stringify(state.tabs);
const serializedNext = JSON.stringify(tabs); for (const paneKey of validKeys) {
return serializedCurrent === serializedNext ? state : { version: 2, tabs }; if (claimedPaneKeys.has(paneKey)) continue;
const tabKey = availableStandaloneTabKey(tabs, paneKey);
tabs[tabKey] = defaultWorkbenchTab(paneKey);
}
return JSON.stringify(state.tabs) === JSON.stringify(tabs)
? state
: { version: 1, tabs };
} }
export function workbenchChildPaneKeys(state: WorkbenchState): Set<string> { export function orderWorkbenchTabs(
const childKeys = new Set<string>(); state: WorkbenchState,
for (const [tabKey, tab] of Object.entries(state.tabs)) { orderedSessionKeys: readonly string[],
for (const paneKey of tab.paneKeys) { updatedAtByKey: ReadonlyMap<string, string | null | undefined>,
if (paneKey !== tabKey) childKeys.add(paneKey); ): OrderedWorkbenchTab[] {
} const rank = new Map(orderedSessionKeys.map((key, index) => [key, index]));
} const validKeys = new Set(orderedSessionKeys);
return childKeys; const reconciled = reconcileWorkbench(state, validKeys);
const tabs = Object.entries(reconciled.tabs).map(([tabKey, tab]) => {
const paneKeys = tab.paneKeys
.filter((key) => validKeys.has(key))
.sort((left, right) => (rank.get(left) ?? Infinity) - (rank.get(right) ?? Infinity));
const updatedAt = paneKeys.reduce<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;
} }
+49 -13
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,23 +178,54 @@ export function useSidebarState(
}; };
}, []); }, []);
const persist = useCallback((next: SidebarStatePayload) => { const flushPersistence = useCallback(() => {
if (!connectionOpenRef.current) { if (
pendingPersistenceRef.current = next; persistenceInFlightRef.current
return; || !connectionOpenRef.current
} || pendingPersistenceRef.current === null
void client.setSidebarState(next).catch(() => { ) return;
// Sidebar persistence is best-effort; the optimistic local state remains usable.
}); const next = pendingPersistenceRef.current;
pendingPersistenceRef.current = null;
persistenceInFlightRef.current = true;
void client.setSidebarState(next)
.then((saved) => {
persistenceInFlightRef.current = false;
if (pendingPersistenceRef.current === null) {
const canonical = normalizeSidebarState(saved);
stateRef.current = canonical;
setState(canonical);
}
flushPersistenceRef.current();
})
.catch(() => {
persistenceInFlightRef.current = false;
if (pendingPersistenceRef.current === null) {
pendingPersistenceRef.current = next;
}
});
}, [client]); }, [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) => {
+11 -4
View File
@@ -1414,19 +1414,26 @@
"collapseTabGroup": "Collapse panes in {{title}}", "collapseTabGroup": "Collapse panes in {{title}}",
"expandTabGroup": "Expand panes in {{title}}", "expandTabGroup": "Expand panes in {{title}}",
"dropPane": "Move {{pane}} into {{tab}}", "dropPane": "Move {{pane}} into {{tab}}",
"moveToTab": "Move to tab", "createGroup": "Create group",
"moveTo": "Move to",
"renameTabTitle": "Rename tab",
"renameTabDescription": "Give this tab a name for organizing its panes.",
"renameTabPlaceholder": "Tab name",
"dissolveTab": "Dissolve group",
"layout": "Pane layout", "layout": "Pane layout",
"addPane": "Add pane", "addPane": "Add pane",
"movePane": "Move {{title}} pane",
"movePaneHint": "Drag to move · Arrow keys also work",
"promotePane": "Make {{title}} the primary pane", "promotePane": "Make {{title}} the primary pane",
"paneActions": "{{title}} pane actions", "paneActions": "{{title}} pane actions",
"detachPane": "Move {{title}} to a new tab", "detachPane": "Remove",
"composerAria": "Message {{title}}", "composerAria": "Message {{title}}",
"layouts": { "layouts": {
"columns": "Columns", "columns": "Columns",
"rows": "Rows", "rows": "Rows",
"grid": "Grid", "grid": "Grid",
"main-stack": "Main and stack", "bsp": "BSP",
"monocle": "Monocle" "main-stack": "Main and stack"
} }
}, },
"common": { "common": {
+11 -4
View File
@@ -1401,19 +1401,26 @@
"collapseTabGroup": "Contraer los paneles de {{title}}", "collapseTabGroup": "Contraer los paneles de {{title}}",
"expandTabGroup": "Expandir los paneles de {{title}}", "expandTabGroup": "Expandir los paneles de {{title}}",
"dropPane": "Mover {{pane}} a {{tab}}", "dropPane": "Mover {{pane}} a {{tab}}",
"moveToTab": "Mover a una pestaña", "createGroup": "Crear grupo",
"moveTo": "Mover a",
"renameTabTitle": "Renombrar pestaña",
"renameTabDescription": "Ponle un nombre a esta pestaña para organizar sus paneles.",
"renameTabPlaceholder": "Nombre de la pestaña",
"dissolveTab": "Disolver grupo",
"layout": "Diseño de paneles", "layout": "Diseño de paneles",
"addPane": "Añadir panel", "addPane": "Añadir panel",
"movePane": "Mover panel {{title}}",
"movePaneHint": "Arrastra para mover · También puedes usar las flechas",
"promotePane": "Convertir {{title}} en el panel principal", "promotePane": "Convertir {{title}} en el panel principal",
"paneActions": "Acciones del panel {{title}}", "paneActions": "Acciones del panel {{title}}",
"detachPane": "Mover {{title}} a una pestaña nueva", "detachPane": "Quitar",
"composerAria": "Mensaje para {{title}}", "composerAria": "Mensaje para {{title}}",
"layouts": { "layouts": {
"columns": "Columnas", "columns": "Columnas",
"rows": "Filas", "rows": "Filas",
"grid": "Cuadrícula", "grid": "Cuadrícula",
"main-stack": "Principal y pila", "bsp": "BSP",
"monocle": "Monóculo" "main-stack": "Principal y pila"
} }
}, },
"common": { "common": {
+11 -4
View File
@@ -1400,19 +1400,26 @@
"collapseTabGroup": "Réduire les volets de {{title}}", "collapseTabGroup": "Réduire les volets de {{title}}",
"expandTabGroup": "Développer les volets de {{title}}", "expandTabGroup": "Développer les volets de {{title}}",
"dropPane": "Déplacer {{pane}} dans {{tab}}", "dropPane": "Déplacer {{pane}} dans {{tab}}",
"moveToTab": "Déplacer vers un onglet", "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", "layout": "Disposition des volets",
"addPane": "Ajouter un volet", "addPane": "Ajouter un volet",
"movePane": "Déplacer le volet {{title}}",
"movePaneHint": "Faites glisser pour déplacer · Les flèches fonctionnent aussi",
"promotePane": "Définir {{title}} comme volet principal", "promotePane": "Définir {{title}} comme volet principal",
"paneActions": "Actions du volet {{title}}", "paneActions": "Actions du volet {{title}}",
"detachPane": "Déplacer {{title}} vers un nouvel onglet", "detachPane": "Retirer",
"composerAria": "Message à {{title}}", "composerAria": "Message à {{title}}",
"layouts": { "layouts": {
"columns": "Colonnes", "columns": "Colonnes",
"rows": "Lignes", "rows": "Lignes",
"grid": "Grille", "grid": "Grille",
"main-stack": "Principal et pile", "bsp": "BSP",
"monocle": "Monocle" "main-stack": "Principal et pile"
} }
}, },
"common": { "common": {
+11 -4
View File
@@ -1400,19 +1400,26 @@
"collapseTabGroup": "Ciutkan panel di {{title}}", "collapseTabGroup": "Ciutkan panel di {{title}}",
"expandTabGroup": "Luaskan panel di {{title}}", "expandTabGroup": "Luaskan panel di {{title}}",
"dropPane": "Pindahkan {{pane}} ke {{tab}}", "dropPane": "Pindahkan {{pane}} ke {{tab}}",
"moveToTab": "Pindahkan ke tab", "createGroup": "Buat grup",
"moveTo": "Pindahkan ke",
"renameTabTitle": "Ganti nama tab",
"renameTabDescription": "Beri nama tab ini untuk mengatur panelnya.",
"renameTabPlaceholder": "Nama tab",
"dissolveTab": "Bubarkan grup",
"layout": "Tata letak panel", "layout": "Tata letak panel",
"addPane": "Tambah panel", "addPane": "Tambah panel",
"movePane": "Pindahkan panel {{title}}",
"movePaneHint": "Seret untuk memindahkan · Tombol panah juga dapat digunakan",
"promotePane": "Jadikan {{title}} panel utama", "promotePane": "Jadikan {{title}} panel utama",
"paneActions": "Tindakan panel {{title}}", "paneActions": "Tindakan panel {{title}}",
"detachPane": "Pindahkan {{title}} ke tab baru", "detachPane": "Keluarkan",
"composerAria": "Pesan untuk {{title}}", "composerAria": "Pesan untuk {{title}}",
"layouts": { "layouts": {
"columns": "Kolom", "columns": "Kolom",
"rows": "Baris", "rows": "Baris",
"grid": "Kisi", "grid": "Kisi",
"main-stack": "Utama dan tumpukan", "bsp": "BSP",
"monocle": "Panel tunggal" "main-stack": "Utama dan tumpukan"
} }
}, },
"common": { "common": {
+11 -4
View File
@@ -1400,19 +1400,26 @@
"collapseTabGroup": "{{title}} のペインを折りたたむ", "collapseTabGroup": "{{title}} のペインを折りたたむ",
"expandTabGroup": "{{title}} のペインを展開する", "expandTabGroup": "{{title}} のペインを展開する",
"dropPane": "{{pane}} を {{tab}} に移動", "dropPane": "{{pane}} を {{tab}} に移動",
"moveToTab": "タブへ移動", "createGroup": "グループを作成",
"moveTo": "移動先",
"renameTabTitle": "タブ名を変更",
"renameTabDescription": "ペインを整理するため、このタブに名前を付けます。",
"renameTabPlaceholder": "タブ名",
"dissolveTab": "グループを解除",
"layout": "ペインレイアウト", "layout": "ペインレイアウト",
"addPane": "ペインを追加", "addPane": "ペインを追加",
"movePane": "{{title}} ペインを移動",
"movePaneHint": "ドラッグで移動 · 矢印キーでも移動できます",
"promotePane": "{{title}} をメインペインにする", "promotePane": "{{title}} をメインペインにする",
"paneActions": "{{title}} ペインの操作", "paneActions": "{{title}} ペインの操作",
"detachPane": "{{title}} を新しいタブに移動", "detachPane": "外す",
"composerAria": "{{title}} へのメッセージ", "composerAria": "{{title}} へのメッセージ",
"layouts": { "layouts": {
"columns": "列", "columns": "列",
"rows": "行", "rows": "行",
"grid": "グリッド", "grid": "グリッド",
"main-stack": "メインとスタック", "bsp": "BSP",
"monocle": "モノクル" "main-stack": "メインとスタック"
} }
}, },
"common": { "common": {
+11 -4
View File
@@ -1400,19 +1400,26 @@
"collapseTabGroup": "{{title}}의 창 접기", "collapseTabGroup": "{{title}}의 창 접기",
"expandTabGroup": "{{title}}의 창 펼치기", "expandTabGroup": "{{title}}의 창 펼치기",
"dropPane": "{{pane}}을(를) {{tab}}으로 이동", "dropPane": "{{pane}}을(를) {{tab}}으로 이동",
"moveToTab": "탭으로 이동", "createGroup": "그룹 만들기",
"moveTo": "이동",
"renameTabTitle": "탭 이름 바꾸기",
"renameTabDescription": "창을 정리할 수 있도록 이 탭에 이름을 지정하세요.",
"renameTabPlaceholder": "탭 이름",
"dissolveTab": "그룹 해제",
"layout": "창 레이아웃", "layout": "창 레이아웃",
"addPane": "창 추가", "addPane": "창 추가",
"movePane": "{{title}} 창 이동",
"movePaneHint": "드래그하여 이동 · 방향키로도 이동 가능",
"promotePane": "{{title}}을(를) 기본 창으로 설정", "promotePane": "{{title}}을(를) 기본 창으로 설정",
"paneActions": "{{title}} 창 작업", "paneActions": "{{title}} 창 작업",
"detachPane": "{{title}}을(를) 새 탭으로 이동", "detachPane": "제거",
"composerAria": "{{title}}에 메시지 보내기", "composerAria": "{{title}}에 메시지 보내기",
"layouts": { "layouts": {
"columns": "열", "columns": "열",
"rows": "행", "rows": "행",
"grid": "그리드", "grid": "그리드",
"main-stack": "기본 창과 스택", "bsp": "BSP",
"monocle": "단일 창" "main-stack": "기본 창과 스택"
} }
}, },
"common": { "common": {
+11 -4
View File
@@ -1414,19 +1414,26 @@
"collapseTabGroup": "Recolher os painéis em {{title}}", "collapseTabGroup": "Recolher os painéis em {{title}}",
"expandTabGroup": "Expandir os painéis em {{title}}", "expandTabGroup": "Expandir os painéis em {{title}}",
"dropPane": "Mover {{pane}} para {{tab}}", "dropPane": "Mover {{pane}} para {{tab}}",
"moveToTab": "Mover para uma aba", "createGroup": "Criar grupo",
"moveTo": "Mover para",
"renameTabTitle": "Renomear aba",
"renameTabDescription": "Dê um nome a esta aba para organizar seus painéis.",
"renameTabPlaceholder": "Nome da aba",
"dissolveTab": "Desfazer grupo",
"layout": "Layout de painéis", "layout": "Layout de painéis",
"addPane": "Adicionar painel", "addPane": "Adicionar painel",
"movePane": "Mover painel {{title}}",
"movePaneHint": "Arraste para mover · As setas também funcionam",
"promotePane": "Tornar {{title}} o painel principal", "promotePane": "Tornar {{title}} o painel principal",
"paneActions": "Ações do painel {{title}}", "paneActions": "Ações do painel {{title}}",
"detachPane": "Mover {{title}} para uma nova aba", "detachPane": "Remover",
"composerAria": "Mensagem para {{title}}", "composerAria": "Mensagem para {{title}}",
"layouts": { "layouts": {
"columns": "Colunas", "columns": "Colunas",
"rows": "Linhas", "rows": "Linhas",
"grid": "Grade", "grid": "Grade",
"main-stack": "Principal e pilha", "bsp": "BSP",
"monocle": "Monóculo" "main-stack": "Principal e pilha"
} }
}, },
"common": { "common": {
+11 -4
View File
@@ -1400,19 +1400,26 @@
"collapseTabGroup": "Thu gọn các khung trong {{title}}", "collapseTabGroup": "Thu gọn các khung trong {{title}}",
"expandTabGroup": "Mở rộng các khung trong {{title}}", "expandTabGroup": "Mở rộng các khung trong {{title}}",
"dropPane": "Di chuyển {{pane}} vào {{tab}}", "dropPane": "Di chuyển {{pane}} vào {{tab}}",
"moveToTab": "Di chuyển vào thẻ", "createGroup": "Tạo nhóm",
"moveTo": "Di chuyển đến",
"renameTabTitle": "Đổi tên thẻ",
"renameTabDescription": "Đặt tên cho thẻ này để sắp xếp các khung.",
"renameTabPlaceholder": "Tên thẻ",
"dissolveTab": "Giải tán nhóm",
"layout": "Bố cục khung", "layout": "Bố cục khung",
"addPane": "Thêm khung", "addPane": "Thêm khung",
"movePane": "Di chuyển khung {{title}}",
"movePaneHint": "Kéo để di chuyển · Cũng có thể dùng các phím mũi tên",
"promotePane": "Đặt {{title}} làm khung chính", "promotePane": "Đặt {{title}} làm khung chính",
"paneActions": "Thao tác cho khung {{title}}", "paneActions": "Thao tác cho khung {{title}}",
"detachPane": "Chuyển {{title}} sang thẻ mới", "detachPane": "Gỡ",
"composerAria": "Nhắn tin cho {{title}}", "composerAria": "Nhắn tin cho {{title}}",
"layouts": { "layouts": {
"columns": "Cột", "columns": "Cột",
"rows": "Hàng", "rows": "Hàng",
"grid": "Lưới", "grid": "Lưới",
"main-stack": "Khung chính và ngăn xếp", "bsp": "BSP",
"monocle": "Một khung" "main-stack": "Khung chính và ngăn xếp"
} }
}, },
"common": { "common": {
+11 -4
View File
@@ -1414,19 +1414,26 @@
"collapseTabGroup": "折叠 {{title}} 中的窗格", "collapseTabGroup": "折叠 {{title}} 中的窗格",
"expandTabGroup": "展开 {{title}} 中的窗格", "expandTabGroup": "展开 {{title}} 中的窗格",
"dropPane": "将 {{pane}} 移入 {{tab}}", "dropPane": "将 {{pane}} 移入 {{tab}}",
"moveToTab": "移动到标签页", "createGroup": "创建分组",
"moveTo": "移动到",
"renameTabTitle": "重命名标签页",
"renameTabDescription": "为这个标签页命名,以便组织其中的窗格。",
"renameTabPlaceholder": "标签页名称",
"dissolveTab": "解散分组",
"layout": "窗格布局", "layout": "窗格布局",
"addPane": "添加窗格", "addPane": "添加窗格",
"movePane": "移动 {{title}} 窗格",
"movePaneHint": "拖动换位 · 也可以使用方向键",
"promotePane": "将 {{title}} 设为主窗格", "promotePane": "将 {{title}} 设为主窗格",
"paneActions": "{{title}} 窗格操作", "paneActions": "{{title}} 窗格操作",
"detachPane": "将 {{title}} 移至新标签页", "detachPane": "移出",
"composerAria": "向 {{title}} 发送消息", "composerAria": "向 {{title}} 发送消息",
"layouts": { "layouts": {
"columns": "列布局", "columns": "列布局",
"rows": "行布局", "rows": "行布局",
"grid": "网格", "grid": "网格",
"main-stack": "主窗格与堆栈", "bsp": "BSP",
"monocle": "窗格" "main-stack": "窗格与堆栈"
} }
}, },
"common": { "common": {
+11 -4
View File
@@ -1400,19 +1400,26 @@
"collapseTabGroup": "收合 {{title}} 中的窗格", "collapseTabGroup": "收合 {{title}} 中的窗格",
"expandTabGroup": "展開 {{title}} 中的窗格", "expandTabGroup": "展開 {{title}} 中的窗格",
"dropPane": "將 {{pane}} 移入 {{tab}}", "dropPane": "將 {{pane}} 移入 {{tab}}",
"moveToTab": "移動到分頁", "createGroup": "建立群組",
"moveTo": "移動到",
"renameTabTitle": "重新命名分頁",
"renameTabDescription": "為這個分頁命名,以便整理其中的窗格。",
"renameTabPlaceholder": "分頁名稱",
"dissolveTab": "解散群組",
"layout": "窗格佈局", "layout": "窗格佈局",
"addPane": "新增窗格", "addPane": "新增窗格",
"movePane": "移動 {{title}} 窗格",
"movePaneHint": "拖曳換位 · 也可以使用方向鍵",
"promotePane": "將 {{title}} 設為主窗格", "promotePane": "將 {{title}} 設為主窗格",
"paneActions": "{{title}} 窗格操作", "paneActions": "{{title}} 窗格操作",
"detachPane": "將 {{title}} 移至新標籤頁", "detachPane": "移出",
"composerAria": "傳送訊息給 {{title}}", "composerAria": "傳送訊息給 {{title}}",
"layouts": { "layouts": {
"columns": "欄佈局", "columns": "欄佈局",
"rows": "列佈局", "rows": "列佈局",
"grid": "網格", "grid": "網格",
"main-stack": "主窗格與堆疊", "bsp": "BSP",
"monocle": "窗格" "main-stack": "窗格與堆疊"
} }
}, },
"common": { "common": {
+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);
-32
View File
@@ -1,13 +1,6 @@
export const SESSION_DRAG_TYPE = "application/x-nanobot-session-key"; export const SESSION_DRAG_TYPE = "application/x-nanobot-session-key";
export const PANE_DRAG_TYPE = "application/x-nanobot-pane";
export interface DraggedPane {
paneKey: string;
sourceTabKey: string;
}
let activeSessionKey: string | null = null; let activeSessionKey: string | null = null;
let activePane: DraggedPane | null = null;
export function hasDraggedSession(dataTransfer: DataTransfer): boolean { export function hasDraggedSession(dataTransfer: DataTransfer): boolean {
return Array.from(dataTransfer.types).includes(SESSION_DRAG_TYPE); return Array.from(dataTransfer.types).includes(SESSION_DRAG_TYPE);
@@ -20,7 +13,6 @@ export function readDraggedSession(dataTransfer: DataTransfer): string | null {
export function clearDraggedSession(): void { export function clearDraggedSession(): void {
activeSessionKey = null; activeSessionKey = null;
activePane = null;
} }
export function writeDraggedSession( export function writeDraggedSession(
@@ -31,27 +23,3 @@ export function writeDraggedSession(
dataTransfer.effectAllowed = "copyMove"; dataTransfer.effectAllowed = "copyMove";
dataTransfer.setData(SESSION_DRAG_TYPE, sessionKey); dataTransfer.setData(SESSION_DRAG_TYPE, sessionKey);
} }
export function readDraggedPane(dataTransfer: DataTransfer): DraggedPane | null {
const serialized = dataTransfer.getData(PANE_DRAG_TYPE).trim();
if (serialized) {
try {
const parsed = JSON.parse(serialized) as Partial<DraggedPane>;
if (parsed.paneKey && parsed.sourceTabKey) {
return { paneKey: parsed.paneKey, sourceTabKey: parsed.sourceTabKey };
}
} catch {
// Fall through to the in-memory payload used while the native drag is active.
}
}
return activePane;
}
export function writeDraggedPane(
dataTransfer: DataTransfer,
pane: DraggedPane,
): void {
activePane = pane;
writeDraggedSession(dataTransfer, pane.paneKey);
dataTransfer.setData(PANE_DRAG_TYPE, JSON.stringify(pane));
}
+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[];
activePaneKey: string;
layout: WorkbenchLayout;
}
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";
+213 -5
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\?/;
@@ -249,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);
@@ -289,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;
@@ -300,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");
@@ -307,8 +316,6 @@ describe("App layout", () => {
localStorage.removeItem("nanobot-webui.sidebar.session-updates.v1"); localStorage.removeItem("nanobot-webui.sidebar.session-updates.v1");
localStorage.removeItem("nanobot-webui.restartStartedAt"); localStorage.removeItem("nanobot-webui.restartStartedAt");
localStorage.removeItem("nanobot-webui.restartRoute"); localStorage.removeItem("nanobot-webui.restartRoute");
localStorage.removeItem("nanobot.webui.workbench.v1");
localStorage.removeItem("nanobot.webui.workbench.v2");
vi.mocked(fetchBootstrap).mockReset().mockResolvedValue({ vi.mocked(fetchBootstrap).mockReset().mockResolvedValue({
token: "tok", token: "tok",
api_token: "api-tok", api_token: "api-tok",
@@ -3018,6 +3025,203 @@ describe("App layout", () => {
); );
}); });
it("keeps panes adjacent and orders tabs by their latest updated pane", async () => {
mockSessions = [
{
key: "websocket:alpha",
channel: "websocket",
chatId: "alpha",
createdAt: "2026-08-01T10:00:00Z",
updatedAt: "2026-08-01T10:00:00Z",
title: "Alpha tab",
preview: "",
},
{
key: "websocket:alpha-child",
channel: "websocket",
chatId: "alpha-child",
createdAt: "2026-08-05T10:00:00Z",
updatedAt: "2026-08-05T10:00:00Z",
title: "Alpha child",
preview: "",
},
{
key: "websocket:beta",
channel: "websocket",
chatId: "beta",
createdAt: "2026-08-04T10:00:00Z",
updatedAt: "2026-08-04T10:00:00Z",
title: "Beta tab",
preview: "",
},
];
vi.stubGlobal("fetch", vi.fn().mockImplementation(async (url: string | URL | Request) => {
if (String(url) === "/api/webui/sidebar-state") {
return {
ok: true,
json: async () => ({
workbench: {
version: 1,
tabs: {
"tab:websocket:alpha": {
explicit: true,
title: "Alpha tab",
paneKeys: ["websocket:alpha", "websocket:alpha-child"],
activePaneKey: "websocket:alpha-child",
layout: "columns",
},
"tab:websocket:beta": {
explicit: false,
title: null,
paneKeys: ["websocket:beta"],
activePaneKey: "websocket:beta",
layout: "columns",
},
},
},
}),
};
}
return { ok: false, status: 404 };
}));
render(<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 () => { it("keeps panes and layout scoped to the current topic tab", async () => {
vi.stubGlobal("matchMedia", vi.fn((query: string) => ({ vi.stubGlobal("matchMedia", vi.fn((query: string) => ({
matches: false, matches: false,
@@ -3062,13 +3266,16 @@ describe("App layout", () => {
const grid = await screen.findByTestId("pane-grid"); const grid = await screen.findByTestId("pane-grid");
expect(Array.from(grid.children).map((pane) => pane.getAttribute("aria-label"))) expect(Array.from(grid.children).map((pane) => pane.getAttribute("aria-label")))
.toEqual(["Alpha"]); .toEqual(["Alpha"]);
expect(screen.queryByRole("button", { name: "Pane layout" }))
.not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Add pane" })); fireEvent.click(screen.getByRole("button", { name: "Add pane" }));
expect(screen.queryByRole("dialog", { name: "Search" })).not.toBeInTheDocument(); expect(screen.queryByRole("dialog", { name: "Search" })).not.toBeInTheDocument();
await waitFor(() => expect(createChatSpy).toHaveBeenCalledTimes(1)); await waitFor(() => expect(createChatSpy).toHaveBeenCalledTimes(1));
await waitFor(() => expect(grid.children).toHaveLength(2)); await waitFor(() => expect(grid.children).toHaveLength(2));
expect(window.location.hash).toBe("#/chat/websocket%3Achat-alpha"); expect(screen.getByRole("button", { name: "Pane layout" })).toBeInTheDocument();
expect(window.location.hash).toBe("#/chat/websocket%3Achat-pane");
expect(Array.from(grid.children).map((pane) => pane.getAttribute("aria-label"))) expect(Array.from(grid.children).map((pane) => pane.getAttribute("aria-label")))
.toEqual(["Alpha", "New topic"]); .toEqual(["Alpha", "New topic"]);
@@ -3076,6 +3283,7 @@ describe("App layout", () => {
const paneInput = within(activeComposer).getByRole("textbox", { const paneInput = within(activeComposer).getByRole("textbox", {
name: "Message New topic", name: "Message New topic",
}); });
expect(paneInput).toHaveClass("min-h-[50px]");
fireEvent.change(paneInput, { target: { value: "route this to the new pane" } }); fireEvent.change(paneInput, { target: { value: "route this to the new pane" } });
fireEvent.keyDown(paneInput, { key: "Enter" }); fireEvent.keyDown(paneInput, { key: "Enter" });
await waitFor(() => expect(sendMessageSpy).toHaveBeenCalled()); await waitFor(() => expect(sendMessageSpy).toHaveBeenCalled());
@@ -3115,7 +3323,7 @@ describe("App layout", () => {
name: "New topic pane actions", name: "New topic pane actions",
}), { button: 0, ctrlKey: false }); }), { button: 0, ctrlKey: false });
fireEvent.click(screen.getByRole("menuitem", { fireEvent.click(screen.getByRole("menuitem", {
name: "Move New topic to a new tab", name: "Remove",
})); }));
await waitFor(() => expect(screen.getByTestId("pane-grid").children).toHaveLength(1)); await waitFor(() => expect(screen.getByTestId("pane-grid").children).toHaveLength(1));
expect(within(sidebar).getAllByRole("button", { name: "New topic" })).toHaveLength(2); expect(within(sidebar).getAllByRole("button", { name: "New topic" })).toHaveLength(2);
+174 -418
View File
@@ -1,8 +1,7 @@
import { createEvent, fireEvent, render, screen, within } from "@testing-library/react"; import { fireEvent, render, screen, within } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { ChatList } from "@/components/ChatList"; import { ChatList } from "@/components/ChatList";
import { PANE_DRAG_TYPE, 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,69 +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;
}
function dragOverAt(
element: Element,
clientY: number,
dataTransfer: Record<string, unknown>,
): void {
const event = createEvent.dragOver(element, { dataTransfer });
Object.defineProperty(event, "clientY", { value: clientY });
fireEvent(element, event);
}
function dropAt(
element: Element,
clientY: number,
dataTransfer: Record<string, unknown>,
): void {
const event = createEvent.drop(element, { dataTransfer });
Object.defineProperty(event, "clientY", { value: clientY });
fireEvent(element, event);
}
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" })]}
setDragImage: vi.fn(), 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()}
@@ -89,112 +112,41 @@ 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" }))
fireEvent.dragStart(reference, { dataTransfer });
expect(dataTransfer.setData).toHaveBeenCalledWith(
SESSION_DRAG_TYPE,
"websocket:reference",
);
expect(dataTransfer.setDragImage).toHaveBeenCalled();
expect(document.querySelector("[data-pane-drag-overlay]"))
.toHaveTextContent("Reference chat");
expect(reference.closest("li")).not.toHaveClass("opacity-0");
expect(document.querySelector("[data-tab-drag-placeholder]"))
.not.toBeInTheDocument(); .not.toBeInTheDocument();
fireEvent.dragEnd(reference, { dataTransfer });
expect(document.querySelector("[data-pane-drag-overlay]")).not.toBeInTheDocument();
});
it("reorders topics with a displaced-neighbor preview instead of an insertion line", () => { fireEvent.pointerDown(screen.getByRole("button", {
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockReturnValue(rect({ name: "Topic actions for Solo pane",
left: 0, }), { button: 0, ctrlKey: false });
top: 0, fireEvent.click(await screen.findByRole("menuitem", { name: "Create group" }));
width: 284, expect(onCreateTab).toHaveBeenCalledWith("tab:solo");
height: 32, expect(onAttachPane).not.toHaveBeenCalled();
}));
const onReorderSessions = vi.fn();
const sessions = [
session({ chatId: "alpha", title: "Alpha" }),
session({ chatId: "bravo", title: "Bravo" }),
session({ chatId: "charlie", title: "Charlie" }),
session({ chatId: "old-a", title: "Old A" }),
session({ chatId: "old-b", title: "Old B" }),
];
const { rerender } = render(
<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")!;
dragOverAt(charlieRow, 24, dataTransfer);
expect(document.querySelector("[data-session-drop-edge]")).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Bravo" }).closest("li"))
.toHaveAttribute("data-session-displaced", "true");
expect(charlieRow).toHaveStyle({ transform: "translateY(-32px)" });
expect(screen.getByRole("button", { name: "Alpha" }).closest("li"))
.toHaveAttribute("data-session-dragging", "true");
dropAt(charlieRow, 24, dataTransfer);
expect(onReorderSessions).toHaveBeenCalledWith([ fireEvent.pointerDown(screen.getByRole("button", {
"websocket:bravo", name: "Topic actions for Solo pane",
"websocket:charlie", }), { button: 0, ctrlKey: false });
"websocket:alpha", const moveTo = await screen.findByRole("menuitem", { name: "Move to" });
"websocket:old-a", fireEvent.pointerMove(moveTo, { pointerType: "mouse" });
"websocket:old-b", expect(screen.queryByRole("menuitem", { name: "Target pane" }))
]); .not.toBeInTheDocument();
const fullTarget = await screen.findByRole("menuitem", {
rerender( name: "Existing group · 4/4",
<ChatList });
sessions={sessions} expect(fullTarget).toHaveAttribute("aria-disabled", "true");
activeKey={null} fireEvent.click(fullTarget);
onSelect={vi.fn()} expect(onAttachPane).not.toHaveBeenCalled();
onRequestDelete={vi.fn()} fireEvent.click(await screen.findByRole("menuitem", { name: "Fine group · 1/4" }));
onTogglePin={vi.fn()} expect(onAttachPane).toHaveBeenCalledWith("websocket:solo", "tab:fine");
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"
/>,
);
const section = screen.getByRole("region", { name: "Topics" });
const text = section.textContent ?? "";
expect(text.indexOf("Bravo")).toBeLessThan(text.indexOf("Charlie"));
expect(text.indexOf("Charlie")).toBeLessThan(text.indexOf("Alpha"));
}); });
it("shows every tab's pane membership in a sidebar tab group", async () => { it("shows every tab's pane membership in a sidebar tab group", async () => {
const onSelect = vi.fn(); const onSelect = vi.fn();
const onSelectPane = vi.fn(); const onSelectPane = vi.fn();
const onDetachPane = vi.fn(); const onDetachPane = vi.fn();
const onPromotePane = vi.fn(); const onDissolveTab = vi.fn();
const onRequestRename = vi.fn(); const onRequestRename = vi.fn();
const onAttachPane = vi.fn(); const onAttachPane = vi.fn();
@@ -207,7 +159,8 @@ describe("ChatList", () => {
activeKey="websocket:root" activeKey="websocket:root"
paneGroups={{ paneGroups={{
"websocket:root": { "websocket:root": {
topicKey: "websocket:root", tabKey: "websocket:root",
title: "Root topic",
activePaneKey: "websocket:child", activePaneKey: "websocket:child",
panes: [ panes: [
{ key: "websocket:root", chatId: "root", title: "Root topic" }, { key: "websocket:root", chatId: "root", title: "Root topic" },
@@ -215,7 +168,8 @@ describe("ChatList", () => {
], ],
}, },
"websocket:target": { "websocket:target": {
topicKey: "websocket:target", tabKey: "websocket:target",
title: "Target tab",
activePaneKey: "websocket:target-child", activePaneKey: "websocket:target-child",
panes: [ panes: [
{ key: "websocket:target", chatId: "target", title: "Target tab" }, { key: "websocket:target", chatId: "target", title: "Target tab" },
@@ -230,8 +184,7 @@ describe("ChatList", () => {
onSelect={onSelect} onSelect={onSelect}
onSelectPane={onSelectPane} onSelectPane={onSelectPane}
onDetachPane={onDetachPane} onDetachPane={onDetachPane}
onPromotePane={onPromotePane} onDissolveTab={onDissolveTab}
paneAcceptingTabKeys={["websocket:target"]}
onAttachPane={onAttachPane} onAttachPane={onAttachPane}
onRequestDelete={vi.fn()} onRequestDelete={vi.fn()}
onTogglePin={vi.fn()} onTogglePin={vi.fn()}
@@ -265,69 +218,71 @@ describe("ChatList", () => {
expect(onSelect).not.toHaveBeenCalled(); expect(onSelect).not.toHaveBeenCalled();
onSelectPane.mockClear(); onSelectPane.mockClear();
fireEvent.click(screen.getByRole("button", { name: "Tab: Root topic" })); const rootTab = screen.getByRole("button", { name: "Tab: Root topic" });
fireEvent.click(rootTab);
expect(onSelectPane).not.toHaveBeenCalled(); expect(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", { fireEvent.pointerDown(screen.getByRole("button", {
name: "Research pane pane actions", name: "Research pane pane actions",
}), { button: 0, ctrlKey: false }); }), { button: 0, ctrlKey: false });
const moveToTab = await screen.findByRole("menuitem", { name: "Move to tab" }); const moveTo = await screen.findByRole("menuitem", { name: "Move to" });
fireEvent.pointerMove(moveToTab, { pointerType: "mouse" }); fireEvent.pointerMove(moveTo, { pointerType: "mouse" });
fireEvent.click(await screen.findByRole("menuitem", { name: "Target tab" })); fireEvent.click(await screen.findByRole("menuitem", { name: "Target tab · 2/4" }));
expect(onAttachPane).toHaveBeenCalledWith("websocket:child", "websocket:target"); expect(onAttachPane).toHaveBeenCalledWith("websocket:child", "websocket:target");
fireEvent.pointerDown(screen.getByRole("button", { fireEvent.pointerDown(screen.getByRole("button", {
name: "Research pane pane actions", name: "Research pane pane actions",
}), { button: 0, ctrlKey: false }); }), { button: 0, ctrlKey: false });
fireEvent.click(await screen.findByRole("menuitem", { fireEvent.click(await screen.findByRole("menuitem", {
name: "Move Research pane to a new tab", name: "Remove",
})); }));
expect(onDetachPane).toHaveBeenCalledWith("websocket:root", "websocket:child"); expect(onDetachPane).toHaveBeenCalledWith("websocket:root", "websocket:child");
fireEvent.pointerDown(screen.getByRole("button", { fireEvent.pointerDown(screen.getByRole("button", {
name: "Root topic pane actions", name: "Root topic pane actions",
}), { button: 0, ctrlKey: false }); }), { button: 0, ctrlKey: false });
expect(screen.queryByRole("menuitem", { name: "Move to tab" })) expect(screen.getByRole("menuitem", { name: "Move to" }))
.not.toBeInTheDocument(); .toBeInTheDocument();
expect(screen.queryByRole("menuitem", { name: "Move Root topic to a new tab" })) expect(screen.getByRole("menuitem", { name: "Remove" }))
.not.toBeInTheDocument(); .toBeInTheDocument();
fireEvent.keyDown(document, { key: "Escape" }); fireEvent.keyDown(document, { key: "Escape" });
const dataTransfer = { expect(child).toHaveAttribute("draggable", "false");
effectAllowed: "", expect(screen.getByRole("button", { name: "Tab: Target tab" }))
dropEffect: "", .toHaveAttribute("draggable", "false");
setData: vi.fn(),
};
onAttachPane.mockClear();
fireEvent.dragStart(child, { dataTransfer });
expect(child.closest("li")).toHaveAttribute("data-pane-dragging", "true");
expect(child.closest("li")).not.toHaveClass("opacity-0");
const targetTab = screen.getByRole("button", { name: "Tab: Target tab" });
dragOverAt(targetTab.closest("li")!, 0, dataTransfer);
expect(targetTab.closest("li"))
.not.toHaveAttribute("data-tab-attach-target");
expect(targetTab.closest("li")!.querySelector("[data-pane-snap-slot]"))
.not.toBeInTheDocument();
dropAt(targetTab.closest("li")!, 0, dataTransfer);
expect(dataTransfer.setData).toHaveBeenCalledWith(
PANE_DRAG_TYPE,
JSON.stringify({
paneKey: "websocket:child",
sourceTabKey: "websocket:root",
}),
);
expect(onAttachPane).not.toHaveBeenCalled();
fireEvent.dragEnd(child, { dataTransfer });
}); });
it("collapses a multi-pane tab into one Chrome-style group header", () => { it("collapses a multi-pane tab into one Chrome-style group header", () => {
render( render(
<ChatList <ChatList
sessions={[session({ chatId: "root", title: "Root topic" })]} sessions={[session({
chatId: "root",
title: "Root topic",
workspaceScope: {
project_path: "/Users/me/nanobot",
project_name: "nanobot",
access_mode: "restricted",
},
})]}
activeKey="websocket:root" activeKey="websocket:root"
paneGroups={{ paneGroups={{
"websocket:root": { "websocket:root": {
topicKey: "websocket:root", tabKey: "websocket:root",
title: "Root topic",
activePaneKey: "websocket:child", activePaneKey: "websocket:child",
panes: [ panes: [
{ key: "websocket:root", chatId: "root", title: "Root topic" }, { key: "websocket:root", chatId: "root", title: "Root topic" },
@@ -344,13 +299,26 @@ describe("ChatList", () => {
/>, />,
); );
const tabGroup = screen.getByRole("button", { name: "Tab: Root topic" }) const tabButton = screen.getByRole("button", { name: "Tab: Root topic" });
.closest("[data-sidebar-tab-group]")!; 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(tabGroup).toHaveAttribute("data-sidebar-tab-group", "true");
expect(within(tabGroup).getByRole("list", { name: "Panes in Root topic" })) expect(tabHeader).not.toHaveAttribute("data-chat-row");
.toBeInTheDocument(); expect(tabHeader).not.toHaveAttribute("data-sidebar-pane");
expect(within(tabGroup).getByRole("button", { name: "Research pane" })) expect(tabButton).not.toHaveAttribute("aria-current");
.toHaveAttribute("aria-current", "true"); 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" })) expect(within(tabGroup).getByRole("button", { name: "Root topic" }))
.not.toHaveAttribute("aria-current"); .not.toHaveAttribute("aria-current");
expect(tabGroup).not.toHaveTextContent("2/4"); expect(tabGroup).not.toHaveTextContent("2/4");
@@ -367,9 +335,9 @@ describe("ChatList", () => {
expect(within(tabGroup).getByRole("button", { expect(within(tabGroup).getByRole("button", {
name: "Expand panes in Root topic", name: "Expand panes in Root topic",
})).toHaveAttribute("aria-expanded", "false"); })).toHaveAttribute("aria-expanded", "false");
expect(within(tabGroup).getByRole("button", { name: "Tab: Root topic" }) expect(within(tabGroup).getByRole("button", { name: "Tab: Root topic" }))
.closest("[data-sidebar-tab]")) .not.toHaveAttribute("aria-current");
.toHaveClass("bg-sidebar-selected"); expect(tabSurface).toHaveClass("bg-sidebar-foreground/[0.045]");
fireEvent.click(within(tabGroup).getByRole("button", { fireEvent.click(within(tabGroup).getByRole("button", {
name: "Expand panes in Root topic", name: "Expand panes in Root topic",
@@ -380,155 +348,6 @@ describe("ChatList", () => {
.toBeInTheDocument(); .toBeInTheDocument();
}); });
it("keeps the Pane opaque without exposing a slot in another tab", () => {
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockReturnValue(rect({
left: 0,
top: 0,
width: 240,
height: 28,
}));
vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => {
callback(0);
return 1;
});
const dataTransfer = {
effectAllowed: "",
dropEffect: "",
setData: vi.fn(),
setDragImage: vi.fn(),
};
const onAttachPane = vi.fn();
render(
<ChatList
sessions={[
session({ chatId: "root", title: "Root topic" }),
session({ chatId: "target", title: "Target tab" }),
]}
activeKey="websocket:root"
paneGroups={{
"websocket:root": {
topicKey: "websocket:root",
activePaneKey: "websocket:child",
panes: [
{ key: "websocket:root", chatId: "root", title: "Root topic" },
{ key: "websocket:child", chatId: "child", title: "Research pane" },
],
},
}}
paneAcceptingTabKeys={["websocket:target"]}
onAttachPane={onAttachPane}
onSelect={vi.fn()}
onRequestDelete={vi.fn()}
onTogglePin={vi.fn()}
onRequestRename={vi.fn()}
onToggleArchive={vi.fn()}
/>,
);
const pane = screen.getByRole("button", { name: "Research pane" });
fireEvent.dragStart(pane, { clientX: 40, clientY: 40, dataTransfer });
expect(dataTransfer.setDragImage).toHaveBeenCalled();
const dragOver = createEvent.dragOver(
screen.getByRole("button", { name: "Target tab" }).closest("li")!,
{ dataTransfer },
);
Object.defineProperties(dragOver, {
clientX: { value: 160 },
clientY: { value: 120 },
});
fireEvent(screen.getByRole("button", { name: "Target tab" }).closest("li")!, dragOver);
const paneRow = pane.closest("li")!;
const overlay = document.querySelector<HTMLElement>("[data-pane-drag-overlay]")!;
expect(overlay).toHaveStyle({
opacity: "1",
height: "28px",
transform: "translate3d(40px, 106px, 0)",
visibility: "visible",
width: "240px",
});
expect(overlay).toHaveTextContent("Research pane");
expect(overlay).toHaveClass(
"!bg-sidebar-selected",
"!shadow-none",
);
expect(overlay).toHaveStyle({ boxShadow: "none" });
expect(document.querySelector("[data-pane-snap-slot]")).not.toBeInTheDocument();
expect(paneRow.querySelector("[data-sidebar-pane]"))
.toHaveClass("!bg-transparent", "!text-transparent", "!shadow-none");
expect(paneRow.style.transform).toBe("");
expect(paneRow).not.toHaveClass("opacity-0");
dataTransfer.dropEffect = "none";
fireEvent.dragEnd(pane, { clientX: 160, clientY: 120, dataTransfer });
expect(onAttachPane).not.toHaveBeenCalled();
expect(document.querySelector("[data-pane-drag-overlay]")).not.toBeInTheDocument();
});
it("repels sibling Panes and snaps the dragged Pane into the selected slot", () => {
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockReturnValue(rect({
left: 0,
top: 0,
width: 240,
height: 28,
}));
const onAttachPane = vi.fn();
const dataTransfer = {
effectAllowed: "",
dropEffect: "",
setData: vi.fn(),
setDragImage: vi.fn(),
};
render(
<ChatList
sessions={[session({ chatId: "root", title: "Root topic" })]}
activeKey="websocket:root"
paneGroups={{
"websocket:root": {
topicKey: "websocket:root",
activePaneKey: "websocket:first",
panes: [
{ key: "websocket:root", chatId: "root", title: "Root topic" },
{ key: "websocket:first", chatId: "first", title: "First pane" },
{ key: "websocket:second", chatId: "second", title: "Second pane" },
{ key: "websocket:third", chatId: "third", title: "Third pane" },
],
},
}}
onAttachPane={onAttachPane}
onSelect={vi.fn()}
onRequestDelete={vi.fn()}
onTogglePin={vi.fn()}
onRequestRename={vi.fn()}
onToggleArchive={vi.fn()}
/>,
);
const first = screen.getByRole("button", { name: "First pane" });
const secondRow = screen.getByRole("button", { name: "Second pane" }).closest("li")!;
fireEvent.dragStart(first, { clientX: 40, clientY: 14, dataTransfer });
dragOverAt(secondRow, 20, dataTransfer);
expect(secondRow).toHaveAttribute("data-pane-displaced", "true");
expect(secondRow).toHaveStyle("transform: translateY(-30px)");
expect((first.closest("li") as HTMLElement).style.transform).toBe("");
const snapSlot = screen.getByRole("list", { name: "Panes in Root topic" })
.querySelector("[data-pane-snap-slot]")!;
expect(snapSlot).toHaveStyle("height: 28px; transform: translateY(60px)");
expect(snapSlot).toHaveClass("absolute", "bg-transparent");
expect(first.closest("li")).not.toHaveClass("opacity-0");
dropAt(snapSlot, 20, dataTransfer);
expect(onAttachPane).toHaveBeenCalledWith(
"websocket:first",
"websocket:root",
"websocket:third",
);
});
it("selects a whole tab or individual panes for one bulk delete", async () => { it("selects a whole tab or individual panes for one bulk delete", async () => {
const onRequestDeleteMany = vi.fn(); const onRequestDeleteMany = vi.fn();
render( render(
@@ -540,7 +359,8 @@ describe("ChatList", () => {
activeKey="websocket:root" activeKey="websocket:root"
paneGroups={{ paneGroups={{
"websocket:root": { "websocket:root": {
topicKey: "websocket:root", tabKey: "websocket:root",
title: "Root topic",
activePaneKey: "websocket:root", activePaneKey: "websocket:root",
panes: [ panes: [
{ key: "websocket:root", chatId: "root", title: "Root topic" }, { key: "websocket:root", chatId: "root", title: "Root topic" },
@@ -558,10 +378,12 @@ describe("ChatList", () => {
); );
fireEvent.pointerDown(screen.getByRole("button", { fireEvent.pointerDown(screen.getByRole("button", {
name: "Topic actions for Root topic", name: "Root topic pane actions",
}), { button: 0, ctrlKey: false }); }), { button: 0, ctrlKey: false });
fireEvent.click(await screen.findByRole("menuitem", { name: "Select" })); 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" })) expect(screen.getByRole("button", { name: "Tab: Root topic" }))
.toHaveAttribute("aria-pressed", "true"); .toHaveAttribute("aria-pressed", "true");
expect(screen.getByRole("button", { name: "Root topic" })) expect(screen.getByRole("button", { name: "Root topic" }))
@@ -584,60 +406,6 @@ describe("ChatList", () => {
expect(screen.queryByTestId("delete-selection-bar")).not.toBeInTheDocument(); expect(screen.queryByTestId("delete-selection-bar")).not.toBeInTheDocument();
}); });
it("reorders one-pane tabs instead of attaching them through drag", () => {
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockReturnValue(rect({
left: 0,
top: 0,
width: 284,
height: 32,
}));
const onAttachPane = vi.fn();
const onReorderSessions = vi.fn();
const dataTransfer = {
effectAllowed: "",
dropEffect: "",
setData: vi.fn(),
};
render(
<ChatList
sessions={[
session({ chatId: "detached", title: "Detached pane" }),
session({ chatId: "target", title: "Target tab" }),
]}
activeKey={null}
attachableTabKeys={["websocket:detached", "websocket:target"]}
paneAcceptingTabKeys={["websocket:detached", "websocket:target"]}
onAttachPane={onAttachPane}
onSelect={vi.fn()}
onRequestDelete={vi.fn()}
onTogglePin={vi.fn()}
onRequestRename={vi.fn()}
onToggleArchive={vi.fn()}
onReorderSessions={onReorderSessions}
/>,
);
const detached = screen.getByRole("button", { name: "Detached pane" });
fireEvent.dragStart(detached, {
dataTransfer,
});
expect(detached.closest("li"))
.toHaveAttribute("data-session-dragging", "true");
const target = screen.getByRole("button", { name: "Target tab" }).closest("li")!;
dragOverAt(target, 16, dataTransfer);
expect(target).not.toHaveAttribute("data-tab-attach-target");
expect(target).toHaveAttribute("data-session-displaced", "true");
expect(document.querySelector("[data-pane-snap-slot]")).not.toBeInTheDocument();
dropAt(target, 16, dataTransfer);
expect(onAttachPane).not.toHaveBeenCalled();
expect(onReorderSessions).toHaveBeenCalledWith([
"websocket:target",
"websocket:detached",
]);
});
it("shows temporary chats separately and lets the user reopen or close them", async () => { it("shows temporary chats separately and lets the user reopen or close them", async () => {
const temporarySession = session({ const temporarySession = session({
key: "temporary:temporary-one", key: "temporary:temporary-one",
@@ -866,14 +634,10 @@ describe("ChatList", () => {
); );
const activeButton = screen.getByTitle("Active topic"); const activeButton = screen.getByTitle("Active topic");
const inactiveButton = screen.getByTitle("Inactive topic");
expect(activeButton).toHaveAttribute("aria-current", "page"); expect(activeButton).toHaveAttribute("aria-current", "page");
expect(activeButton.closest("[data-sidebar-tab]")).toHaveClass( expect(activeButton.closest("[data-sidebar-tab]")).toHaveClass(
"bg-sidebar-selected", "bg-sidebar-selected",
); );
expect(inactiveButton.closest("[data-sidebar-tab]")).not.toHaveClass(
"bg-sidebar-selected",
);
expect(screen.queryByTestId("sessions-selection-highlight")).not.toBeInTheDocument(); expect(screen.queryByTestId("sessions-selection-highlight")).not.toBeInTheDocument();
rerender( rerender(
@@ -885,17 +649,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(screen.getByTitle("Active topic").closest("[data-sidebar-tab]")).not.toHaveClass(
"bg-sidebar-selected",
);
expect(screen.getByTitle("Inactive topic").closest("[data-sidebar-tab]")).toHaveClass( expect(screen.getByTitle("Inactive topic").closest("[data-sidebar-tab]")).toHaveClass(
"bg-sidebar-selected", "bg-sidebar-selected",
); );
rerender(<ChatList {...props} activeKey={null} />);
expect(screen.getByTitle("Inactive topic").closest("[data-sidebar-tab]")).not.toHaveClass(
"bg-sidebar-selected",
);
}); });
it("can collapse a project group and keeps project rename separate from chat titles", async () => { it("can collapse a project group and keeps project rename separate from chat titles", async () => {
+47
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,52 @@ 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"],
activePaneKey: "websocket:a",
layout: "columns",
},
},
},
view: {
density: "comfortable",
show_previews: false,
show_timestamps: false,
show_archived: false,
sort: "updated_desc",
},
updated_at: "2026-08-11T08:00:00Z",
};
client.onSidebarStateUpdate(handler);
client.connect();
lastSocket().fakeOpen();
lastSocket().fakeMessage({ event: "sidebar_state_updated", state });
expect(handler).toHaveBeenCalledWith(state);
});
it("does not correlate a new-chat scope rejection to an unrelated sent turn", async () => { 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",
-65
View File
@@ -1,65 +0,0 @@
import { describe, expect, it } from "vitest";
import {
paneDropSlotForRow,
paneTabDragLayout,
samePaneDropSlot,
type PaneTabDragState,
} from "@/components/pane-tab-drag";
function drag(overrides: Partial<PaneTabDragState> = {}): PaneTabDragState {
return {
origin: "pane",
item: { paneKey: "pane-a", sourceTabKey: "tab-a" },
height: 32,
slot: null,
...overrides,
};
}
describe("Pane tab drag state", () => {
it("turns a pointer edge into one stable insertion slot", () => {
const before = paneDropSlotForRow(
"tab-a",
["pane-a", "pane-b", "pane-c"],
"pane-a",
"pane-b",
"before",
);
const after = paneDropSlotForRow(
"tab-a",
["pane-a", "pane-b", "pane-c"],
"pane-a",
"pane-b",
"after",
);
expect(before).toEqual({ tabKey: "tab-a", beforePaneKey: "pane-b" });
expect(after).toEqual({ tabKey: "tab-a", beforePaneKey: "pane-c" });
expect(samePaneDropSlot(after, { ...after })).toBe(true);
});
it("moves the dragged slot and repels siblings inside one tab", () => {
const layout = paneTabDragLayout(
["pane-a", "pane-b", "pane-c"],
"tab-a",
drag({ slot: { tabKey: "tab-a", beforePaneKey: "pane-c" } }),
);
expect(layout.slotIndex).toBe(1);
expect(Object.fromEntries(layout.offsets)).toEqual({
"pane-b": -34,
});
});
it("does not expose a slot in another tab", () => {
const layout = paneTabDragLayout(
["pane-x", "pane-y"],
"tab-b",
drag({ slot: { tabKey: "tab-b", beforePaneKey: "pane-y" } }),
);
expect(layout.slotIndex).toBe(-1);
expect(Object.fromEntries(layout.offsets)).toEqual({});
});
});
+157 -9
View File
@@ -1,15 +1,18 @@
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { useState } from "react"; import { useState } from "react";
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { PaneWorkbench } from "@/components/workbench/PaneWorkbench"; import { PaneWorkbench } from "@/components/workbench/PaneWorkbench";
import { import {
EMPTY_WORKBENCH_STATE, EMPTY_WORKBENCH_STATE,
addWorkbenchPane, addWorkbenchPane,
ensureWorkbenchPaneTab,
focusWorkbenchPane, focusWorkbenchPane,
setWorkbenchLayout, setWorkbenchLayout,
setWorkbenchPaneLayoutOrder,
workbenchTab, workbenchTab,
workbenchTabForPane,
} from "@/components/workbench/workbench-model"; } from "@/components/workbench/workbench-model";
function rect(left: number, top: number, width: number, height: number): DOMRect { function rect(left: number, top: number, width: number, height: number): DOMRect {
@@ -26,25 +29,46 @@ function rect(left: number, top: number, width: number, height: number): DOMRect
}; };
} }
function WorkbenchHarness() { function WorkbenchHarness({
const [state, setState] = useState(() => ( initialLayout = "columns",
addWorkbenchPane(EMPTY_WORKBENCH_STATE, "alpha", "beta") onPaneOrderChange = () => {},
)); }: {
const tab = workbenchTab(state, "alpha"); initialLayout?: "columns" | "rows";
onPaneOrderChange?: (paneKeys: string[]) => void;
} = {}) {
const [state, setState] = useState(() => {
const initial = ensureWorkbenchPaneTab(EMPTY_WORKBENCH_STATE, "alpha");
const tabKey = workbenchTabForPane(initial, "alpha").tabKey;
return setWorkbenchLayout(
addWorkbenchPane(initial, tabKey, "beta"),
tabKey,
initialLayout,
);
});
const tabKey = workbenchTabForPane(state, "alpha").tabKey;
const tab = workbenchTab(state, tabKey);
if (!tab) return null;
const titles: Record<string, string> = { alpha: "Alpha", beta: "Beta" }; const titles: Record<string, string> = { alpha: "Alpha", beta: "Beta" };
return ( return (
<PaneWorkbench <PaneWorkbench
panes={tab.paneKeys.map((key) => ({ key, title: titles[key] }))} panes={tab.layoutPaneKeys.map((key) => ({ key, title: titles[key] }))}
activePaneKey={tab.activePaneKey} activePaneKey={tab.activePaneKey}
layout={tab.layout} layout={tab.layout}
showLayoutControl
onActivatePane={(key) => setState((current) => ( onActivatePane={(key) => setState((current) => (
focusWorkbenchPane(current, "alpha", key) focusWorkbenchPane(current, tabKey, key)
))} ))}
onAddPane={vi.fn()} onAddPane={vi.fn()}
onLayoutChange={(layout) => setState((current) => ( onLayoutChange={(layout) => setState((current) => (
setWorkbenchLayout(current, "alpha", layout) setWorkbenchLayout(current, tabKey, layout)
))} ))}
onPaneOrderChange={(paneKeys) => {
onPaneOrderChange(paneKeys);
setState((current) => (
setWorkbenchPaneLayoutOrder(current, tabKey, paneKeys)
));
}}
renderPane={(pane, context) => ( renderPane={(pane, context) => (
<> <>
<button type="button">Focus {pane.title}</button> <button type="button">Focus {pane.title}</button>
@@ -64,6 +88,26 @@ function WorkbenchHarness() {
); );
} }
function BspWorkbenchHarness() {
const panes = ["alpha", "beta", "gamma", "delta"].map((key) => ({
key,
title: key,
}));
return (
<PaneWorkbench
panes={panes}
activePaneKey="delta"
layout="bsp"
showLayoutControl
onActivatePane={vi.fn()}
onAddPane={vi.fn()}
onLayoutChange={vi.fn()}
onPaneOrderChange={vi.fn()}
renderPane={(pane) => <span>{pane.title}</span>}
/>
);
}
describe("PaneWorkbench", () => { describe("PaneWorkbench", () => {
const originalGetBoundingClientRect = HTMLElement.prototype.getBoundingClientRect; const originalGetBoundingClientRect = HTMLElement.prototype.getBoundingClientRect;
const originalAnimate = HTMLElement.prototype.animate; const originalAnimate = HTMLElement.prototype.animate;
@@ -123,6 +167,89 @@ describe("PaneWorkbench", () => {
expect(screen.getByLabelText("Composer Beta")).not.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("keeps one shared layout control and animates geometry changes", async () => { it("keeps one shared layout control and animates geometry changes", async () => {
render(<WorkbenchHarness />); render(<WorkbenchHarness />);
@@ -135,5 +262,26 @@ describe("PaneWorkbench", () => {
fireEvent.click(screen.getByRole("menuitemradio", { name: "Rows" })); fireEvent.click(screen.getByRole("menuitemradio", { name: "Rows" }));
expect(screen.getByTestId("pane-grid")).toHaveAttribute("data-layout", "rows"); expect(screen.getByTestId("pane-grid")).toHaveAttribute("data-layout", "rows");
await waitFor(() => expect(animate).toHaveBeenCalledTimes(2)); 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 / 3", "1 / 5"]);
expect([beta.style.gridColumn, beta.style.gridRow]).toEqual(["3 / 5", "1 / 3"]);
expect([gamma.style.gridColumn, gamma.style.gridRow]).toEqual(["3 / 4", "3 / 5"]);
expect([delta.style.gridColumn, delta.style.gridRow]).toEqual(["4 / 5", "3 / 5"]);
}); });
}); });
+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 { ConnectionStatus, SidebarStatePayload } from "@/lib/types";
import { ClientProvider } from "@/providers/ClientProvider";
describe("useSidebarState", () => {
it("serializes full-state writes so an older request cannot overwrite a newer update", async () => {
let resolveFirstWrite: (() => void) | null = null;
let sidebarStateUpdateHandler: ((state: SidebarStatePayload) => void) | null = null;
const setSidebarState = vi.fn()
.mockImplementationOnce((state: SidebarStatePayload) => new Promise<SidebarStatePayload>(
(resolve) => {
resolveFirstWrite = () => resolve(state);
},
))
.mockImplementation(async (state: SidebarStatePayload) => state);
const client = {
status: "open" as const,
onStatus: (_handler: (status: ConnectionStatus) => void) => () => {},
onSidebarStateUpdate: (handler: (state: SidebarStatePayload) => void) => {
sidebarStateUpdateHandler = handler;
return () => {
sidebarStateUpdateHandler = null;
};
},
setSidebarState,
} as unknown as NanobotClient;
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({}),
}));
const wrapper = ({ children }: { children: ReactNode }) => (
<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" },
}));
});
});
+208 -131
View File
@@ -5,197 +5,274 @@ import {
MAX_WORKBENCH_PANES, MAX_WORKBENCH_PANES,
addWorkbenchPane, addWorkbenchPane,
attachWorkbenchPane, attachWorkbenchPane,
createWorkbenchTab,
detachWorkbenchPane, detachWorkbenchPane,
ensureWorkbenchTab, dissolveWorkbenchTab,
ensureWorkbenchPaneTab,
focusWorkbenchPane, focusWorkbenchPane,
parseWorkbenchState, normalizeWorkbenchState,
promoteWorkbenchPane, orderWorkbenchTabs,
reconcileWorkbench, reconcileWorkbench,
renameWorkbenchTab,
setWorkbenchLayout, setWorkbenchLayout,
workbenchChildPaneKeys, setWorkbenchPaneLayoutOrder,
workbenchTab, workbenchTab,
workbenchTabForPane,
type WorkbenchState,
} from "@/components/workbench/workbench-model"; } from "@/components/workbench/workbench-model";
describe("workbench model", () => { function withPaneTab(
it("gives every topic its own one-pane tab by default", () => { state: WorkbenchState,
const state = ensureWorkbenchTab(EMPTY_WORKBENCH_STATE, "topic-a"); paneKey: string,
): [WorkbenchState, string] {
const next = ensureWorkbenchPaneTab(state, paneKey);
return [next, workbenchTabForPane(next, paneKey).tabKey];
}
expect(workbenchTab(state, "topic-a")).toEqual({ describe("workbench model", () => {
paneKeys: ["topic-a"], it("creates a virtual tab whose identity is separate from its pane", () => {
activePaneKey: "topic-a", const [state, tabKey] = withPaneTab(EMPTY_WORKBENCH_STATE, "pane-a");
expect(tabKey).not.toBe("pane-a");
expect(workbenchTab(state, tabKey)).toEqual({
explicit: false,
title: null,
paneKeys: ["pane-a"],
layoutPaneKeys: ["pane-a"],
activePaneKey: "pane-a",
layout: "columns", layout: "columns",
}); });
expect(state.tabs["topic-b"]).toBeUndefined();
}); });
it("keeps pane membership, focus, and layout scoped to a tab", () => { it("keeps pane membership, focus, title, and layout scoped to a tab", () => {
let state = ensureWorkbenchTab(EMPTY_WORKBENCH_STATE, "topic-a"); let state = ensureWorkbenchPaneTab(EMPTY_WORKBENCH_STATE, "pane-a");
state = ensureWorkbenchTab(state, "topic-b"); const alphaTabKey = workbenchTabForPane(state, "pane-a").tabKey;
state = addWorkbenchPane(state, "topic-a", "topic-c"); state = ensureWorkbenchPaneTab(state, "pane-b");
state = setWorkbenchLayout(state, "topic-a", "main-stack"); const betaTabKey = workbenchTabForPane(state, "pane-b").tabKey;
state = addWorkbenchPane(state, alphaTabKey, "pane-c");
state = setWorkbenchLayout(state, alphaTabKey, "main-stack");
state = renameWorkbenchTab(state, alphaTabKey, "Research");
expect(workbenchTab(state, "topic-a")).toEqual({ expect(workbenchTab(state, alphaTabKey)).toEqual({
paneKeys: ["topic-a", "topic-c"], explicit: false,
activePaneKey: "topic-c", title: "Research",
paneKeys: ["pane-a", "pane-c"],
layoutPaneKeys: ["pane-a", "pane-c"],
activePaneKey: "pane-c",
layout: "main-stack", layout: "main-stack",
}); });
expect(workbenchTab(state, "topic-b")).toEqual({ expect(workbenchTab(state, betaTabKey)).toEqual({
paneKeys: ["topic-b"], explicit: false,
activePaneKey: "topic-b", title: null,
paneKeys: ["pane-b"],
layoutPaneKeys: ["pane-b"],
activePaneKey: "pane-b",
layout: "columns", layout: "columns",
}); });
}); });
it("focuses without reordering and promotes only when asked", () => { it("focuses a pane without rewriting membership", () => {
let state = addWorkbenchPane(EMPTY_WORKBENCH_STATE, "topic-a", "topic-b"); let state = ensureWorkbenchPaneTab(EMPTY_WORKBENCH_STATE, "pane-a");
state = addWorkbenchPane(state, "topic-a", "topic-c"); const tabKey = workbenchTabForPane(state, "pane-a").tabKey;
state = focusWorkbenchPane(state, "topic-a", "topic-b"); state = addWorkbenchPane(state, tabKey, "pane-b");
state = addWorkbenchPane(state, tabKey, "pane-c");
state = focusWorkbenchPane(state, tabKey, "pane-b");
expect(workbenchTab(state, "topic-a").paneKeys).toEqual([ expect(workbenchTab(state, tabKey)?.paneKeys).toEqual([
"topic-a", "pane-a",
"topic-b", "pane-b",
"topic-c", "pane-c",
]); ]);
expect(workbenchTab(state, tabKey)?.activePaneKey).toBe("pane-b");
state = promoteWorkbenchPane(state, "topic-a", "topic-b");
expect(workbenchTab(state, "topic-a")).toMatchObject({
paneKeys: ["topic-b", "topic-a", "topic-c"],
activePaneKey: "topic-b",
});
}); });
it("detaches child panes, keeps the root, and chooses the adjacent focus", () => { it("detaches any pane into a new virtual tab", () => {
let state = addWorkbenchPane(EMPTY_WORKBENCH_STATE, "topic-a", "topic-b"); let state = ensureWorkbenchPaneTab(EMPTY_WORKBENCH_STATE, "pane-a");
state = addWorkbenchPane(state, "topic-a", "topic-c"); const tabKey = workbenchTabForPane(state, "pane-a").tabKey;
state = focusWorkbenchPane(state, "topic-a", "topic-b"); state = addWorkbenchPane(state, tabKey, "pane-b");
state = detachWorkbenchPane(state, "topic-a", "topic-b"); state = addWorkbenchPane(state, tabKey, "pane-c");
state = focusWorkbenchPane(state, tabKey, "pane-a");
state = detachWorkbenchPane(state, tabKey, "pane-a");
expect(workbenchTab(state, "topic-a")).toMatchObject({ expect(workbenchTab(state, tabKey)).toMatchObject({
paneKeys: ["topic-a", "topic-c"], paneKeys: ["pane-b", "pane-c"],
activePaneKey: "topic-c", activePaneKey: "pane-b",
}); });
const detached = workbenchTabForPane(state, "pane-a");
state = detachWorkbenchPane(state, "topic-a", "topic-c"); expect(detached.tabKey).not.toBe(tabKey);
state = detachWorkbenchPane(state, "topic-a", "topic-a"); expect(detached.tab.paneKeys).toEqual(["pane-a"]);
expect(workbenchTab(state, "topic-a").paneKeys).toEqual(["topic-a"]);
}); });
it("moves a pane between tabs and can reattach a one-pane tab", () => { it("dissolves a tab into standalone panes without deleting them", () => {
let state = addWorkbenchPane(EMPTY_WORKBENCH_STATE, "topic-a", "pane-a"); let state = ensureWorkbenchPaneTab(EMPTY_WORKBENCH_STATE, "pane-a");
state = ensureWorkbenchTab(state, "topic-b"); const tabKey = workbenchTabForPane(state, "pane-a").tabKey;
state = attachWorkbenchPane(state, "topic-b", "pane-a"); state = addWorkbenchPane(state, tabKey, "pane-b");
state = addWorkbenchPane(state, tabKey, "pane-c");
state = dissolveWorkbenchTab(state, tabKey);
expect(workbenchTab(state, "topic-a")).toMatchObject({ expect(workbenchTab(state, tabKey)).toEqual({
paneKeys: ["topic-a"], explicit: false,
activePaneKey: "topic-a", title: null,
paneKeys: ["pane-a"],
layoutPaneKeys: ["pane-a"],
activePaneKey: "pane-a",
layout: "columns",
}); });
expect(workbenchTab(state, "topic-b")).toMatchObject({ expect(workbenchTabForPane(state, "pane-a").tab.paneKeys).toEqual(["pane-a"]);
paneKeys: ["topic-b", "pane-a"], expect(workbenchTabForPane(state, "pane-b").tab.paneKeys).toEqual(["pane-b"]);
expect(workbenchTabForPane(state, "pane-c").tab.paneKeys).toEqual(["pane-c"]);
expect(Object.keys(state.tabs)).toHaveLength(3);
});
it("makes a singleton tab visible without changing pane membership", () => {
let state = ensureWorkbenchPaneTab(EMPTY_WORKBENCH_STATE, "pane-a");
const tabKey = workbenchTabForPane(state, "pane-a").tabKey;
state = createWorkbenchTab(state, tabKey);
expect(workbenchTab(state, tabKey)).toMatchObject({
explicit: true,
paneKeys: ["pane-a"],
activePaneKey: "pane-a", activePaneKey: "pane-a",
}); });
state = ensureWorkbenchTab(state, "topic-c"); state = detachWorkbenchPane(state, tabKey, "pane-a");
state = attachWorkbenchPane(state, "topic-b", "topic-c"); expect(workbenchTab(state, tabKey)).toEqual({
expect(state.tabs["topic-c"]).toBeUndefined(); explicit: false,
expect(workbenchTab(state, "topic-b").paneKeys).toEqual([ title: null,
"topic-b", paneKeys: ["pane-a"],
"pane-a", layoutPaneKeys: ["pane-a"],
"topic-c", activePaneKey: "pane-a",
]); layout: "columns",
});
}); });
it("places a moved pane into an exact tab slot", () => { it("moves every pane symmetrically and removes an empty source tab", () => {
let state = addWorkbenchPane(EMPTY_WORKBENCH_STATE, "topic-a", "pane-a"); let state = ensureWorkbenchPaneTab(EMPTY_WORKBENCH_STATE, "pane-a");
state = addWorkbenchPane(state, "topic-a", "pane-b"); const alphaTabKey = workbenchTabForPane(state, "pane-a").tabKey;
state = addWorkbenchPane(state, "topic-a", "pane-c"); state = addWorkbenchPane(state, alphaTabKey, "pane-b");
state = ensureWorkbenchPaneTab(state, "pane-c");
const targetTabKey = workbenchTabForPane(state, "pane-c").tabKey;
state = attachWorkbenchPane(state, "topic-a", "pane-c", "pane-a"); state = attachWorkbenchPane(state, targetTabKey, "pane-a");
expect(workbenchTab(state, "topic-a").paneKeys).toEqual([ expect(workbenchTab(state, alphaTabKey)?.paneKeys).toEqual(["pane-b"]);
"topic-a", expect(workbenchTab(state, targetTabKey)?.paneKeys).toEqual(["pane-c", "pane-a"]);
state = attachWorkbenchPane(state, targetTabKey, "pane-b");
expect(workbenchTab(state, alphaTabKey)).toBeNull();
expect(workbenchTab(state, targetTabKey)?.paneKeys).toEqual([
"pane-c", "pane-c",
"pane-a", "pane-a",
"pane-b", "pane-b",
]); ]);
});
state = ensureWorkbenchTab(state, "topic-b"); it("keeps membership independent from projected display order", () => {
state = addWorkbenchPane(state, "topic-b", "pane-d"); let state = ensureWorkbenchPaneTab(EMPTY_WORKBENCH_STATE, "pane-a");
state = attachWorkbenchPane(state, "topic-b", "pane-a", "pane-d"); const tabKey = workbenchTabForPane(state, "pane-a").tabKey;
expect(workbenchTab(state, "topic-a").paneKeys).toEqual([ state = addWorkbenchPane(state, tabKey, "pane-b");
"topic-a", state = addWorkbenchPane(state, tabKey, "pane-c");
"pane-c",
const [ordered] = orderWorkbenchTabs(
state,
["pane-c", "pane-a", "pane-b"],
new Map(),
);
expect(workbenchTab(state, tabKey)?.paneKeys).toEqual(["pane-a", "pane-b", "pane-c"]);
expect(ordered.paneKeys).toEqual(["pane-c", "pane-a", "pane-b"]);
state = setWorkbenchPaneLayoutOrder(state, tabKey, ["pane-b", "pane-c", "pane-a"]);
expect(workbenchTab(state, tabKey)?.paneKeys).toEqual(["pane-a", "pane-b", "pane-c"]);
expect(workbenchTab(state, tabKey)?.layoutPaneKeys).toEqual([
"pane-b", "pane-b",
]); "pane-c",
expect(workbenchTab(state, "topic-b").paneKeys).toEqual([
"topic-b",
"pane-a", "pane-a",
"pane-d", ]);
expect(ordered.paneKeys).toEqual(["pane-c", "pane-a", "pane-b"]);
});
it("keeps each tab contiguous and ranks it by its latest updated pane", () => {
let state = ensureWorkbenchPaneTab(EMPTY_WORKBENCH_STATE, "pane-a");
const alphaTabKey = workbenchTabForPane(state, "pane-a").tabKey;
state = addWorkbenchPane(state, alphaTabKey, "pane-c");
state = ensureWorkbenchPaneTab(state, "pane-b");
const betaTabKey = workbenchTabForPane(state, "pane-b").tabKey;
state = addWorkbenchPane(state, betaTabKey, "pane-d");
const tabs = orderWorkbenchTabs(
state,
["pane-d", "pane-c", "pane-b", "pane-a"],
new Map([
["pane-a", "2026-08-01T10:00:00Z"],
["pane-b", "2026-08-03T10:00:00Z"],
["pane-c", "2026-08-05T10:00:00Z"],
["pane-d", "2026-08-04T10:00:00Z"],
]),
);
expect(tabs.map(({ tabKey, paneKeys, updatedAt }) => ({
tabKey,
paneKeys,
updatedAt,
}))).toEqual([
{
tabKey: alphaTabKey,
paneKeys: ["pane-c", "pane-a"],
updatedAt: "2026-08-05T10:00:00Z",
},
{
tabKey: betaTabKey,
paneKeys: ["pane-d", "pane-b"],
updatedAt: "2026-08-04T10:00:00Z",
},
]); ]);
}); });
it("does not collapse a multi-pane tab into another tab", () => { it("caps every virtual tab at four panes", () => {
let state = addWorkbenchPane(EMPTY_WORKBENCH_STATE, "topic-a", "pane-a"); let state = ensureWorkbenchPaneTab(EMPTY_WORKBENCH_STATE, "pane-a");
state = ensureWorkbenchTab(state, "topic-b"); const tabKey = workbenchTabForPane(state, "pane-a").tabKey;
expect(attachWorkbenchPane(state, "topic-b", "topic-a")).toBe(state);
});
it("caps every tab at four panes", () => {
let state = EMPTY_WORKBENCH_STATE;
for (let index = 1; index <= MAX_WORKBENCH_PANES; index += 1) { for (let index = 1; index <= MAX_WORKBENCH_PANES; index += 1) {
state = addWorkbenchPane(state, "topic-a", `pane-${index}`); state = addWorkbenchPane(state, tabKey, `pane-${index}`);
} }
expect(workbenchTab(state, "topic-a").paneKeys).toEqual([ expect(workbenchTab(state, tabKey)?.paneKeys).toEqual([
"topic-a", "pane-a",
"pane-1", "pane-1",
"pane-2", "pane-2",
"pane-3", "pane-3",
]); ]);
const beforeAttach = state;
state = attachWorkbenchPane(state, "topic-a", "standalone");
expect(state).toBe(beforeAttach);
}); });
it("identifies only sessions attached beneath another topic", () => { it("repairs duplicates, removes deleted panes, and creates missing tabs", () => {
let state = addWorkbenchPane(EMPTY_WORKBENCH_STATE, "topic-a", "pane-a"); const state = normalizeWorkbenchState({
state = addWorkbenchPane(state, "topic-b", "pane-b"); version: 1,
expect(workbenchChildPaneKeys(state)).toEqual(new Set(["pane-a", "pane-b"]));
state = detachWorkbenchPane(state, "topic-a", "pane-a");
expect(workbenchChildPaneKeys(state)).toEqual(new Set(["pane-b"]));
});
it("repairs persisted state and removes deleted sessions", () => {
const parsed = parseWorkbenchState(JSON.stringify({
version: 2,
tabs: { tabs: {
"topic-a": { alpha: {
paneKeys: ["topic-a", "topic-b", "topic-b", 9], title: "Alpha",
paneKeys: ["pane-a", "pane-b", "pane-b", 9],
activePaneKey: "missing", activePaneKey: "missing",
layout: "unknown", layout: "unknown",
}, },
deleted: { duplicate: {
paneKeys: ["deleted"], paneKeys: ["pane-b", "deleted"],
activePaneKey: "deleted", activePaneKey: "pane-b",
layout: "grid", layout: "grid",
}, },
}, },
}));
const reconciled = reconcileWorkbench(parsed, new Set(["topic-a"]));
expect(reconciled).toEqual({
version: 2,
tabs: {
"topic-a": {
paneKeys: ["topic-a"],
activePaneKey: "topic-a",
layout: "columns",
},
},
}); });
expect(parseWorkbenchState(JSON.stringify({ version: 1, tabs: {} }))) const reconciled = reconcileWorkbench(
.toEqual(EMPTY_WORKBENCH_STATE); state,
expect(parseWorkbenchState("not-json")).toEqual(EMPTY_WORKBENCH_STATE); new Set(["pane-a", "pane-b", "pane-c"]),
);
expect(workbenchTab(reconciled, "alpha")).toEqual({
explicit: false,
title: "Alpha",
paneKeys: ["pane-a", "pane-b"],
layoutPaneKeys: ["pane-a", "pane-b"],
activePaneKey: "pane-a",
layout: "columns",
});
expect(workbenchTab(reconciled, "duplicate")).toBeNull();
expect(workbenchTabForPane(reconciled, "pane-c").tab.paneKeys).toEqual(["pane-c"]);
}); });
}); });