mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-12 15:19:16 +03:00
feat(webui): add tabbed pane workbench (#5322)
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -1037,6 +1037,63 @@ async def test_webui_persists_sidebar_state_larger_than_http_request_line(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_webui_sidebar_state_update_broadcasts_workbench_to_other_devices(
|
||||||
|
bus: MagicMock,
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
||||||
|
channel = _ch(bus)
|
||||||
|
source = AsyncMock()
|
||||||
|
source.request = SimpleNamespace(headers=Headers())
|
||||||
|
other_device = AsyncMock()
|
||||||
|
channel._webui_connections.update({source, other_device})
|
||||||
|
request_id = "sidebar-workbench-state"
|
||||||
|
|
||||||
|
await channel._dispatch_envelope(
|
||||||
|
source,
|
||||||
|
"webui-client",
|
||||||
|
{
|
||||||
|
"type": "webui_request",
|
||||||
|
"request_id": request_id,
|
||||||
|
"action": "sidebar.update",
|
||||||
|
"payload": {
|
||||||
|
"state": {
|
||||||
|
"workbench": {
|
||||||
|
"version": 1,
|
||||||
|
"tabs": {
|
||||||
|
"tab:websocket:a": {
|
||||||
|
"explicit": True,
|
||||||
|
"title": "Research",
|
||||||
|
"paneKeys": ["websocket:a", "websocket:b"],
|
||||||
|
"layoutPaneKeys": ["websocket:b", "websocket:a"],
|
||||||
|
"layout": "columns",
|
||||||
|
"splitRatios": [0.35],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await asyncio.gather(*tuple(channel._webui_request_tasks.values()))
|
||||||
|
|
||||||
|
event = json.loads(other_device.send.await_args.args[0])
|
||||||
|
assert event["event"] == "sidebar_state_updated"
|
||||||
|
assert event["state"]["workbench"]["tabs"]["tab:websocket:a"]["paneKeys"] == [
|
||||||
|
"websocket:a",
|
||||||
|
"websocket:b",
|
||||||
|
]
|
||||||
|
assert event["state"]["workbench"]["tabs"]["tab:websocket:a"]["layoutPaneKeys"] == [
|
||||||
|
"websocket:b",
|
||||||
|
"websocket:a",
|
||||||
|
]
|
||||||
|
assert event["state"]["workbench"]["tabs"]["tab:websocket:a"]["splitRatios"] == [
|
||||||
|
0.35
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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)
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ does not modify agent sessions.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import math
|
||||||
import os
|
import os
|
||||||
|
import threading
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, cast
|
from typing import Any, cast
|
||||||
@@ -24,8 +26,11 @@ _MAX_MAP_ITEMS = 2_000
|
|||||||
_MAX_KEY_LEN = 512
|
_MAX_KEY_LEN = 512
|
||||||
_MAX_TITLE_LEN = 160
|
_MAX_TITLE_LEN = 160
|
||||||
_MAX_TAG_LEN = 40
|
_MAX_TAG_LEN = 40
|
||||||
|
_MAX_WORKBENCH_PANES = 4
|
||||||
_ALLOWED_DENSITIES = {"comfortable", "compact"}
|
_ALLOWED_DENSITIES = {"comfortable", "compact"}
|
||||||
_ALLOWED_SORTS = {"updated_desc", "created_desc", "title_asc", "manual"}
|
_ALLOWED_SORTS = {"updated_desc", "created_desc", "title_asc", "manual"}
|
||||||
|
_ALLOWED_WORKBENCH_LAYOUTS = {"columns", "rows", "grid", "bsp", "main-stack"}
|
||||||
|
_SIDEBAR_STATE_WRITE_LOCK = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
def webui_sidebar_state_path() -> Path:
|
def webui_sidebar_state_path() -> Path:
|
||||||
@@ -42,6 +47,7 @@ def default_webui_sidebar_state() -> dict[str, Any]:
|
|||||||
"project_name_overrides": {},
|
"project_name_overrides": {},
|
||||||
"tags_by_key": {},
|
"tags_by_key": {},
|
||||||
"collapsed_groups": {},
|
"collapsed_groups": {},
|
||||||
|
"workbench": {"version": 1, "tabs": {}},
|
||||||
"view": {
|
"view": {
|
||||||
"density": "comfortable",
|
"density": "comfortable",
|
||||||
"show_previews": False,
|
"show_previews": False,
|
||||||
@@ -76,6 +82,20 @@ def _clean_string_list(value: Any, *, max_len: int = _MAX_KEY_LEN) -> list[str]:
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_split_ratios(value: Any) -> list[float]:
|
||||||
|
if not isinstance(value, list):
|
||||||
|
return []
|
||||||
|
ratios: list[float] = []
|
||||||
|
for raw_ratio in cast(list[Any], value)[: _MAX_WORKBENCH_PANES - 1]:
|
||||||
|
if isinstance(raw_ratio, bool) or not isinstance(raw_ratio, (int, float)):
|
||||||
|
continue
|
||||||
|
ratio = float(raw_ratio)
|
||||||
|
if not math.isfinite(ratio):
|
||||||
|
continue
|
||||||
|
ratios.append(round(min(0.95, max(0.05, ratio)), 4))
|
||||||
|
return ratios
|
||||||
|
|
||||||
|
|
||||||
def _clean_bool_map(value: Any) -> dict[str, bool]:
|
def _clean_bool_map(value: Any) -> dict[str, bool]:
|
||||||
if not isinstance(value, dict):
|
if not isinstance(value, dict):
|
||||||
return {}
|
return {}
|
||||||
@@ -131,8 +151,56 @@ def _clean_view(value: Any) -> dict[str, Any]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_workbench(value: Any) -> dict[str, Any]:
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
return {"version": 1, "tabs": {}}
|
||||||
|
workbench = cast(dict[str, Any], value)
|
||||||
|
if workbench.get("version") != 1:
|
||||||
|
return {"version": 1, "tabs": {}}
|
||||||
|
raw_tabs = workbench.get("tabs")
|
||||||
|
if not isinstance(raw_tabs, dict):
|
||||||
|
return {"version": 1, "tabs": {}}
|
||||||
|
|
||||||
|
tabs: dict[str, dict[str, Any]] = {}
|
||||||
|
claimed_panes: set[str] = set()
|
||||||
|
for raw_tab_key, raw_tab in list(cast(dict[Any, Any], raw_tabs).items())[:_MAX_MAP_ITEMS]:
|
||||||
|
tab_key = _clean_string(raw_tab_key)
|
||||||
|
if tab_key is None or not isinstance(raw_tab, dict):
|
||||||
|
continue
|
||||||
|
tab = cast(dict[str, Any], raw_tab)
|
||||||
|
pane_keys = [
|
||||||
|
key
|
||||||
|
for key in _clean_string_list(tab.get("paneKeys"))
|
||||||
|
if key not in claimed_panes
|
||||||
|
][:_MAX_WORKBENCH_PANES]
|
||||||
|
if not pane_keys:
|
||||||
|
continue
|
||||||
|
explicit = tab.get("explicit") is True
|
||||||
|
if not explicit and len(pane_keys) == 1:
|
||||||
|
continue
|
||||||
|
requested_layout_pane_keys = [
|
||||||
|
key for key in _clean_string_list(tab.get("layoutPaneKeys")) if key in pane_keys
|
||||||
|
]
|
||||||
|
layout_pane_keys = requested_layout_pane_keys + [
|
||||||
|
key for key in pane_keys if key not in requested_layout_pane_keys
|
||||||
|
]
|
||||||
|
claimed_panes.update(pane_keys)
|
||||||
|
raw_layout = tab.get("layout")
|
||||||
|
layout = raw_layout if raw_layout in _ALLOWED_WORKBENCH_LAYOUTS else "columns"
|
||||||
|
title = _clean_string(tab.get("title"), max_len=_MAX_TITLE_LEN)
|
||||||
|
tabs[tab_key] = {
|
||||||
|
"explicit": explicit,
|
||||||
|
"title": title,
|
||||||
|
"paneKeys": pane_keys,
|
||||||
|
"layoutPaneKeys": layout_pane_keys,
|
||||||
|
"layout": layout,
|
||||||
|
"splitRatios": _clean_split_ratios(tab.get("splitRatios")),
|
||||||
|
}
|
||||||
|
return {"version": 1, "tabs": tabs}
|
||||||
|
|
||||||
|
|
||||||
def normalize_webui_sidebar_state(raw: Any) -> dict[str, Any]:
|
def normalize_webui_sidebar_state(raw: Any) -> dict[str, Any]:
|
||||||
"""Return a schema-v1 sidebar state from any older/partial input."""
|
"""Return a validated canonical sidebar state."""
|
||||||
if not isinstance(raw, dict):
|
if not isinstance(raw, dict):
|
||||||
raw = {}
|
raw = {}
|
||||||
raw = cast(dict[str, Any], raw)
|
raw = cast(dict[str, Any], raw)
|
||||||
@@ -146,6 +214,7 @@ def normalize_webui_sidebar_state(raw: Any) -> dict[str, Any]:
|
|||||||
)
|
)
|
||||||
state["tags_by_key"] = _clean_tags_by_key(raw.get("tags_by_key"))
|
state["tags_by_key"] = _clean_tags_by_key(raw.get("tags_by_key"))
|
||||||
state["collapsed_groups"] = _clean_bool_map(raw.get("collapsed_groups"))
|
state["collapsed_groups"] = _clean_bool_map(raw.get("collapsed_groups"))
|
||||||
|
state["workbench"] = _clean_workbench(raw.get("workbench"))
|
||||||
state["view"] = _clean_view(raw.get("view"))
|
state["view"] = _clean_view(raw.get("view"))
|
||||||
updated_at = raw.get("updated_at")
|
updated_at = raw.get("updated_at")
|
||||||
state["updated_at"] = updated_at if isinstance(updated_at, str) else None
|
state["updated_at"] = updated_at if isinstance(updated_at, str) else None
|
||||||
@@ -169,6 +238,11 @@ def read_webui_sidebar_state() -> dict[str, Any]:
|
|||||||
|
|
||||||
|
|
||||||
def write_webui_sidebar_state(raw: dict[str, Any]) -> dict[str, Any]:
|
def write_webui_sidebar_state(raw: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
with _SIDEBAR_STATE_WRITE_LOCK:
|
||||||
|
return _write_webui_sidebar_state(raw)
|
||||||
|
|
||||||
|
|
||||||
|
def _write_webui_sidebar_state(raw: dict[str, Any]) -> dict[str, Any]:
|
||||||
state = normalize_webui_sidebar_state(raw)
|
state = normalize_webui_sidebar_state(raw)
|
||||||
state["updated_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
state["updated_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||||
encoded = json.dumps(
|
encoded = json.dumps(
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import json
|
import json
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
|
||||||
|
import nanobot.webui.sidebar_state as sidebar_state
|
||||||
from nanobot.webui.sidebar_state import (
|
from nanobot.webui.sidebar_state import (
|
||||||
default_webui_sidebar_state,
|
default_webui_sidebar_state,
|
||||||
read_webui_sidebar_state,
|
read_webui_sidebar_state,
|
||||||
@@ -17,7 +21,7 @@ def test_sidebar_state_defaults_when_file_missing(tmp_path, monkeypatch) -> None
|
|||||||
assert webui_sidebar_state_path() == tmp_path / "webui" / "sidebar-state.json"
|
assert webui_sidebar_state_path() == tmp_path / "webui" / "sidebar-state.json"
|
||||||
|
|
||||||
|
|
||||||
def test_sidebar_state_normalizes_old_or_partial_payload(tmp_path, monkeypatch) -> None:
|
def test_sidebar_state_normalizes_partial_payload(tmp_path, monkeypatch) -> None:
|
||||||
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
||||||
path = webui_sidebar_state_path()
|
path = webui_sidebar_state_path()
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -31,6 +35,23 @@ def test_sidebar_state_normalizes_old_or_partial_payload(tmp_path, monkeypatch)
|
|||||||
"project_name_overrides": {"/repo": " Core ", "bad": ""},
|
"project_name_overrides": {"/repo": " Core ", "bad": ""},
|
||||||
"tags_by_key": {"websocket:a": ["work", "work", ""]},
|
"tags_by_key": {"websocket:a": ["work", "work", ""]},
|
||||||
"collapsed_groups": {"Earlier": 1},
|
"collapsed_groups": {"Earlier": 1},
|
||||||
|
"workbench": {
|
||||||
|
"version": 1,
|
||||||
|
"tabs": {
|
||||||
|
"tab:websocket:a": {
|
||||||
|
"explicit": True,
|
||||||
|
"title": " Research ",
|
||||||
|
"paneKeys": ["websocket:a", "websocket:b", "websocket:a"],
|
||||||
|
"layoutPaneKeys": ["websocket:b", "missing", "websocket:a"],
|
||||||
|
"layout": "invalid-layout",
|
||||||
|
"splitRatios": [0.4, 2, "bad", float("nan")],
|
||||||
|
},
|
||||||
|
"tab:websocket:b": {
|
||||||
|
"paneKeys": ["websocket:b", "websocket:c"],
|
||||||
|
"layout": "bsp",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
"view": {"density": "tiny", "show_archived": True, "sort": "nope"},
|
"view": {"density": "tiny", "show_archived": True, "sort": "nope"},
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
@@ -47,6 +68,19 @@ def test_sidebar_state_normalizes_old_or_partial_payload(tmp_path, monkeypatch)
|
|||||||
assert state["project_name_overrides"] == {"/repo": "Core"}
|
assert state["project_name_overrides"] == {"/repo": "Core"}
|
||||||
assert state["tags_by_key"] == {"websocket:a": ["work"]}
|
assert state["tags_by_key"] == {"websocket:a": ["work"]}
|
||||||
assert state["collapsed_groups"] == {"Earlier": True}
|
assert state["collapsed_groups"] == {"Earlier": True}
|
||||||
|
assert state["workbench"] == {
|
||||||
|
"version": 1,
|
||||||
|
"tabs": {
|
||||||
|
"tab:websocket:a": {
|
||||||
|
"explicit": True,
|
||||||
|
"title": "Research",
|
||||||
|
"paneKeys": ["websocket:a", "websocket:b"],
|
||||||
|
"layoutPaneKeys": ["websocket:b", "websocket:a"],
|
||||||
|
"layout": "columns",
|
||||||
|
"splitRatios": [0.4, 0.95],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
assert state["view"] == {
|
assert state["view"] == {
|
||||||
"density": "comfortable",
|
"density": "comfortable",
|
||||||
"show_previews": False,
|
"show_previews": False,
|
||||||
@@ -80,3 +114,67 @@ def test_sidebar_state_write_is_scoped_to_config_data_dir(tmp_path, monkeypatch)
|
|||||||
assert state["view"]["sort"] == "manual"
|
assert state["view"]["sort"] == "manual"
|
||||||
assert webui_sidebar_state_path().is_file()
|
assert webui_sidebar_state_path().is_file()
|
||||||
assert read_webui_sidebar_state()["pinned_keys"] == ["websocket:a"]
|
assert read_webui_sidebar_state()["pinned_keys"] == ["websocket:a"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_sidebar_state_persists_only_visible_workbench_groups(tmp_path, monkeypatch) -> None:
|
||||||
|
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
||||||
|
tabs = {
|
||||||
|
f"tab:websocket:{index}": {
|
||||||
|
"explicit": False,
|
||||||
|
"paneKeys": [f"websocket:{index}"],
|
||||||
|
"layoutPaneKeys": [f"websocket:{index}"],
|
||||||
|
"layout": "columns",
|
||||||
|
"splitRatios": [],
|
||||||
|
}
|
||||||
|
for index in range(2_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
state = write_webui_sidebar_state({"workbench": {"version": 1, "tabs": tabs}})
|
||||||
|
|
||||||
|
assert state["workbench"] == {"version": 1, "tabs": {}}
|
||||||
|
assert webui_sidebar_state_path().stat().st_size < 2_048
|
||||||
|
|
||||||
|
|
||||||
|
def test_sidebar_state_requires_supported_workbench_version(tmp_path, monkeypatch) -> None:
|
||||||
|
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
||||||
|
|
||||||
|
state = write_webui_sidebar_state(
|
||||||
|
{
|
||||||
|
"workbench": {
|
||||||
|
"version": 2,
|
||||||
|
"tabs": {
|
||||||
|
"tab:websocket:a": {
|
||||||
|
"explicit": True,
|
||||||
|
"paneKeys": ["websocket:a"],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert state["workbench"] == {"version": 1, "tabs": {}}
|
||||||
|
|
||||||
|
|
||||||
|
def test_sidebar_state_serializes_concurrent_writes(monkeypatch) -> None:
|
||||||
|
counter_lock = threading.Lock()
|
||||||
|
active_writes = 0
|
||||||
|
peak_writes = 0
|
||||||
|
|
||||||
|
def fake_write(raw: dict[str, object]) -> dict[str, object]:
|
||||||
|
nonlocal active_writes, peak_writes
|
||||||
|
with counter_lock:
|
||||||
|
active_writes += 1
|
||||||
|
peak_writes = max(peak_writes, active_writes)
|
||||||
|
time.sleep(0.01)
|
||||||
|
with counter_lock:
|
||||||
|
active_writes -= 1
|
||||||
|
return raw
|
||||||
|
|
||||||
|
monkeypatch.setattr(sidebar_state, "_write_webui_sidebar_state", fake_write)
|
||||||
|
payloads = [{"write": index} for index in range(12)]
|
||||||
|
|
||||||
|
with ThreadPoolExecutor(max_workers=4) as executor:
|
||||||
|
results = list(executor.map(sidebar_state.write_webui_sidebar_state, payloads))
|
||||||
|
|
||||||
|
assert results == payloads
|
||||||
|
assert peak_writes == 1
|
||||||
|
|||||||
+575
-90
@@ -12,8 +12,27 @@ import { Eye, EyeOff, Moon, PanelLeft, ShieldCheck, Sun, X } from "lucide-react"
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { channelUiPresentation } from "@/channel-plugins/registry";
|
import { channelUiPresentation } from "@/channel-plugins/registry";
|
||||||
import { Sidebar } from "@/components/Sidebar";
|
import { Sidebar } from "@/components/Sidebar";
|
||||||
|
import type { SidebarDeleteItem } from "@/components/ChatList";
|
||||||
import type { SettingsSectionKey } from "@/components/settings/SettingsView";
|
import type { SettingsSectionKey } from "@/components/settings/SettingsView";
|
||||||
import { ThreadShell } from "@/components/thread/ThreadShell";
|
import { ThreadShell } from "@/components/thread/ThreadShell";
|
||||||
|
import { PaneWorkbench } from "@/components/workbench/PaneWorkbench";
|
||||||
|
import {
|
||||||
|
MAX_WORKBENCH_PANES,
|
||||||
|
addWorkbenchPane,
|
||||||
|
attachWorkbenchPane,
|
||||||
|
createWorkbenchTab,
|
||||||
|
detachWorkbenchPane,
|
||||||
|
dissolveWorkbenchTab,
|
||||||
|
orderWorkbenchTabs,
|
||||||
|
reconcileWorkbench,
|
||||||
|
renameWorkbenchTab,
|
||||||
|
setWorkbenchLayout,
|
||||||
|
setWorkbenchPaneLayoutOrder,
|
||||||
|
setWorkbenchSplitRatios,
|
||||||
|
workbenchTab,
|
||||||
|
workbenchTabForPane,
|
||||||
|
type WorkbenchState,
|
||||||
|
} from "@/components/workbench/workbench-model";
|
||||||
import { floatingSurfaceElevationClassName } from "@/components/ui/floating-surface";
|
import { floatingSurfaceElevationClassName } from "@/components/ui/floating-surface";
|
||||||
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
|
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
|
||||||
|
|
||||||
@@ -23,6 +42,7 @@ import { useSidebarState } from "@/hooks/useSidebarState";
|
|||||||
import { useSkills } from "@/hooks/useSkills";
|
import { useSkills } from "@/hooks/useSkills";
|
||||||
import { useLogoFallback } from "@/hooks/useLogoFallback";
|
import { useLogoFallback } from "@/hooks/useLogoFallback";
|
||||||
import { usePageVisibility } from "@/hooks/usePageVisibility";
|
import { usePageVisibility } from "@/hooks/usePageVisibility";
|
||||||
|
import { useMediaQuery } from "@/hooks/useMediaQuery";
|
||||||
import { ThemeProvider, useTheme } from "@/hooks/useTheme";
|
import { ThemeProvider, useTheme } from "@/hooks/useTheme";
|
||||||
import { logoFallbackUrls } from "@/lib/provider-brand";
|
import { logoFallbackUrls } from "@/lib/provider-brand";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -35,7 +55,7 @@ import {
|
|||||||
loadSavedSecret,
|
loadSavedSecret,
|
||||||
saveSecret,
|
saveSecret,
|
||||||
} from "@/lib/bootstrap";
|
} from "@/lib/bootstrap";
|
||||||
import { displayTitle } from "@/lib/chat-groups";
|
import { displayTitle, sortSessions } from "@/lib/chat-groups";
|
||||||
import { deriveTitle } from "@/lib/format";
|
import { deriveTitle } from "@/lib/format";
|
||||||
import { NanobotClient } from "@/lib/nanobot-client";
|
import { NanobotClient } from "@/lib/nanobot-client";
|
||||||
import { ClientProvider, useClient } from "@/providers/ClientProvider";
|
import { ClientProvider, useClient } from "@/providers/ClientProvider";
|
||||||
@@ -1017,7 +1037,11 @@ function Shell({
|
|||||||
deleteChat,
|
deleteChat,
|
||||||
getSessionAutomations,
|
getSessionAutomations,
|
||||||
} = useSessions();
|
} = useSessions();
|
||||||
const { state: sidebarState, update: updateSidebarState } =
|
const {
|
||||||
|
state: sidebarState,
|
||||||
|
loading: sidebarStateLoading,
|
||||||
|
update: updateSidebarState,
|
||||||
|
} =
|
||||||
useSidebarState(sessions, !loading);
|
useSidebarState(sessions, !loading);
|
||||||
const initialRouteRef = useRef<ShellRoute | null>(null);
|
const initialRouteRef = useRef<ShellRoute | null>(null);
|
||||||
if (!initialRouteRef.current) initialRouteRef.current = readShellRoute();
|
if (!initialRouteRef.current) initialRouteRef.current = readShellRoute();
|
||||||
@@ -1034,15 +1058,31 @@ 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 mobileWorkbench = useMediaQuery("(max-width: 767px)");
|
||||||
|
const workbenchState = sidebarState.workbench;
|
||||||
|
const updateWorkbenchState = useCallback((
|
||||||
|
updater: (current: WorkbenchState) => WorkbenchState,
|
||||||
|
) => {
|
||||||
|
void updateSidebarState((current) => {
|
||||||
|
const next = updater(current.workbench);
|
||||||
|
return next === current.workbench ? current : { ...current, workbench: next };
|
||||||
|
});
|
||||||
|
}, [updateSidebarState]);
|
||||||
|
const lastActivePaneByTabRef = useRef(new Map<string, string>());
|
||||||
|
const [creatingPane, setCreatingPane] = useState(false);
|
||||||
|
const topicSessions = sessions;
|
||||||
const [pendingDelete, setPendingDelete] = useState<{
|
const [pendingDelete, setPendingDelete] = useState<{
|
||||||
key: string;
|
items: SidebarDeleteItem[];
|
||||||
label: string;
|
|
||||||
automations?: SessionAutomationJob[];
|
automations?: SessionAutomationJob[];
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [pendingRename, setPendingRename] = useState<{
|
const [pendingRename, setPendingRename] = useState<{
|
||||||
key: string;
|
key: string;
|
||||||
label: string;
|
label: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
const [pendingTabRename, setPendingTabRename] = useState<{
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
} | null>(null);
|
||||||
const [pendingProjectRename, setPendingProjectRename] = useState<{
|
const [pendingProjectRename, setPendingProjectRename] = useState<{
|
||||||
key: string;
|
key: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -1220,9 +1260,21 @@ function Shell({
|
|||||||
if (temporarySessions[activeKey]) return temporarySessions[activeKey];
|
if (temporarySessions[activeKey]) return temporarySessions[activeKey];
|
||||||
return sessions.find((s) => s.key === activeKey) ?? null;
|
return sessions.find((s) => s.key === activeKey) ?? null;
|
||||||
}, [sessions, activeKey, temporarySessions]);
|
}, [sessions, activeKey, temporarySessions]);
|
||||||
|
const activeTabMatch = useMemo(() => (
|
||||||
|
activeKey && !temporarySessions[activeKey]
|
||||||
|
? workbenchTabForPane(workbenchState, activeKey)
|
||||||
|
: null
|
||||||
|
), [activeKey, temporarySessions, workbenchState]);
|
||||||
|
const activeTabKey = activeTabMatch?.tabKey ?? null;
|
||||||
|
const activeTabState = activeTabMatch?.tab ?? null;
|
||||||
|
const activePaneSession = activeSession;
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeTabKey || !activeKey || !activeTabState?.paneKeys.includes(activeKey)) return;
|
||||||
|
lastActivePaneByTabRef.current.set(activeTabKey, activeKey);
|
||||||
|
}, [activeKey, activeTabKey, activeTabState]);
|
||||||
const runningChatIdList = useMemo(() => Array.from(runningChatIds), [runningChatIds]);
|
const runningChatIdList = useMemo(() => Array.from(runningChatIds), [runningChatIds]);
|
||||||
const updatedChatIdList = useMemo(() => Array.from(updatedChatIds), [updatedChatIds]);
|
const updatedChatIdList = useMemo(() => Array.from(updatedChatIds), [updatedChatIds]);
|
||||||
const activeChatId = activeSession?.chatId ?? null;
|
const activeChatId = activePaneSession?.chatId ?? null;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
activeChatIdRef.current = activeChatId;
|
activeChatIdRef.current = activeChatId;
|
||||||
if (!activeChatId) return;
|
if (!activeChatId) return;
|
||||||
@@ -1242,13 +1294,13 @@ function Shell({
|
|||||||
if (activeChatId && workspaceOverrides[activeChatId]) {
|
if (activeChatId && workspaceOverrides[activeChatId]) {
|
||||||
return workspaceOverrides[activeChatId];
|
return workspaceOverrides[activeChatId];
|
||||||
}
|
}
|
||||||
if (activeSession?.workspaceScope) {
|
if (activePaneSession?.workspaceScope) {
|
||||||
return activeSession.workspaceScope;
|
return activePaneSession.workspaceScope;
|
||||||
}
|
}
|
||||||
return draftWorkspaceScope ?? workspaces?.default_scope ?? null;
|
return draftWorkspaceScope ?? workspaces?.default_scope ?? null;
|
||||||
}, [
|
}, [
|
||||||
activeChatId,
|
activeChatId,
|
||||||
activeSession?.workspaceScope,
|
activePaneSession?.workspaceScope,
|
||||||
draftWorkspaceScope,
|
draftWorkspaceScope,
|
||||||
temporaryChatRequested,
|
temporaryChatRequested,
|
||||||
workspaceOverrides,
|
workspaceOverrides,
|
||||||
@@ -1284,6 +1336,19 @@ function Shell({
|
|||||||
});
|
});
|
||||||
}, [loading, sessions]);
|
}, [loading, sessions]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (loading || sidebarStateLoading) return;
|
||||||
|
const validKeys = new Set(sessions.map((session) => session.key));
|
||||||
|
updateWorkbenchState((current) => {
|
||||||
|
return reconcileWorkbench(current, validKeys);
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
loading,
|
||||||
|
sidebarStateLoading,
|
||||||
|
sessions,
|
||||||
|
updateWorkbenchState,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (loading) return;
|
if (loading) return;
|
||||||
const pendingCreatedKey = pendingCreatedSessionKeyRef.current;
|
const pendingCreatedKey = pendingCreatedSessionKeyRef.current;
|
||||||
@@ -1715,6 +1780,18 @@ function Shell({
|
|||||||
[pendingRename, updateSidebarState],
|
[pendingRename, updateSidebarState],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const onRequestRenameTab = useCallback((key: string, label: string) => {
|
||||||
|
setPendingTabRename({ key, label });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onConfirmTabRename = useCallback((title: string) => {
|
||||||
|
if (!pendingTabRename) return;
|
||||||
|
updateWorkbenchState((current) => (
|
||||||
|
renameWorkbenchTab(current, pendingTabRename.key, title)
|
||||||
|
));
|
||||||
|
setPendingTabRename(null);
|
||||||
|
}, [pendingTabRename, updateWorkbenchState]);
|
||||||
|
|
||||||
const onToggleGroup = useCallback(
|
const onToggleGroup = useCallback(
|
||||||
(groupId: string) => {
|
(groupId: string) => {
|
||||||
void updateSidebarState((current) => {
|
void updateSidebarState((current) => {
|
||||||
@@ -1788,7 +1865,7 @@ function Shell({
|
|||||||
});
|
});
|
||||||
if (activeKey === key && !sidebarState.archived_keys.includes(key)) {
|
if (activeKey === key && !sidebarState.archived_keys.includes(key)) {
|
||||||
const archived = new Set([...sidebarState.archived_keys, key]);
|
const archived = new Set([...sidebarState.archived_keys, key]);
|
||||||
const next = sessions.find((session) => !archived.has(session.key));
|
const next = topicSessions.find((session) => !archived.has(session.key));
|
||||||
navigate({
|
navigate({
|
||||||
view: "chat",
|
view: "chat",
|
||||||
activeKey: next?.key ?? null,
|
activeKey: next?.key ?? null,
|
||||||
@@ -1796,18 +1873,7 @@ function Shell({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[activeKey, navigate, sessions, sidebarState.archived_keys, updateSidebarState],
|
[activeKey, navigate, sidebarState.archived_keys, topicSessions, updateSidebarState],
|
||||||
);
|
|
||||||
|
|
||||||
const onReorderSessions = useCallback(
|
|
||||||
(sessionOrder: string[]) => {
|
|
||||||
void updateSidebarState((current) => ({
|
|
||||||
...current,
|
|
||||||
session_order: sessionOrder,
|
|
||||||
view: { ...current.view, sort: "manual" },
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
[updateSidebarState],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const onToggleArchived = useCallback(() => {
|
const onToggleArchived = useCallback(() => {
|
||||||
@@ -1825,6 +1891,57 @@ function Shell({
|
|||||||
setSessionSearchOpen(true);
|
setSessionSearchOpen(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const onAddPane = useCallback(async () => {
|
||||||
|
const tabKey = activeTabKey;
|
||||||
|
if (
|
||||||
|
!tabKey
|
||||||
|
|| !activeKey
|
||||||
|
|| !activeSession
|
||||||
|
|| creatingPane
|
||||||
|
|| (activeTabState?.paneKeys.length ?? 0) >= MAX_WORKBENCH_PANES
|
||||||
|
|| temporarySessionsRef.current[activeKey]
|
||||||
|
) return;
|
||||||
|
setMobileSidebarOpen(false);
|
||||||
|
setSessionSearchOpen(false);
|
||||||
|
setCreatingPane(true);
|
||||||
|
try {
|
||||||
|
const scope = activeWorkspaceScope;
|
||||||
|
const chatId = await createChat(scope);
|
||||||
|
const paneKey = `websocket:${chatId}`;
|
||||||
|
pendingCreatedSessionKeyRef.current = paneKey;
|
||||||
|
updateWorkbenchState((current) => addWorkbenchPane(current, activeKey, paneKey));
|
||||||
|
navigate({
|
||||||
|
view: "chat",
|
||||||
|
activeKey: paneKey,
|
||||||
|
settingsSection: "overview",
|
||||||
|
});
|
||||||
|
if (scope) {
|
||||||
|
setWorkspaceOverrides((current) => ({
|
||||||
|
...current,
|
||||||
|
[chatId]: normalizeWorkspaceScope(scope),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to create pane", error);
|
||||||
|
if (error instanceof Error && error.message.startsWith("workspace_scope_rejected:")) {
|
||||||
|
setWorkspaceError(t("errors.workspaceScopeRejected.body"));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setCreatingPane(false);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
activeKey,
|
||||||
|
activeSession,
|
||||||
|
activeTabKey,
|
||||||
|
activeTabState,
|
||||||
|
activeWorkspaceScope,
|
||||||
|
createChat,
|
||||||
|
creatingPane,
|
||||||
|
navigate,
|
||||||
|
t,
|
||||||
|
updateWorkbenchState,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (event: globalThis.KeyboardEvent) => {
|
const handleKeyDown = (event: globalThis.KeyboardEvent) => {
|
||||||
if (event.defaultPrevented) return;
|
if (event.defaultPrevented) return;
|
||||||
@@ -1902,15 +2019,15 @@ function Shell({
|
|||||||
setMobileSidebarOpen(false);
|
setMobileSidebarOpen(false);
|
||||||
const nextKey = (() => {
|
const nextKey = (() => {
|
||||||
if (!activeKey) return null;
|
if (!activeKey) return null;
|
||||||
if (sessions.some((session) => session.key === activeKey)) return activeKey;
|
if (topicSessions.some((session) => session.key === activeKey)) return activeKey;
|
||||||
return sessions[0]?.key ?? null;
|
return topicSessions[0]?.key ?? null;
|
||||||
})();
|
})();
|
||||||
navigate({
|
navigate({
|
||||||
view: "chat",
|
view: "chat",
|
||||||
activeKey: nextKey,
|
activeKey: nextKey,
|
||||||
settingsSection: "overview",
|
settingsSection: "overview",
|
||||||
});
|
});
|
||||||
}, [activeKey, navigate, sessions]);
|
}, [activeKey, navigate, topicSessions]);
|
||||||
|
|
||||||
const onRestart = useCallback(() => {
|
const onRestart = useCallback(() => {
|
||||||
const chatId = activeSession?.chatId ?? client.defaultChatId;
|
const chatId = activeSession?.chatId ?? client.defaultChatId;
|
||||||
@@ -2017,30 +2134,42 @@ function Shell({
|
|||||||
}, [client, t]);
|
}, [client, t]);
|
||||||
|
|
||||||
const onTurnEnd = useDeferredTitleRefresh(
|
const onTurnEnd = useDeferredTitleRefresh(
|
||||||
temporaryChatActive ? null : activeSession,
|
temporaryChatActive ? null : activePaneSession,
|
||||||
refresh,
|
refresh,
|
||||||
);
|
);
|
||||||
|
|
||||||
const onConfirmDelete = useCallback(async () => {
|
const onConfirmDelete = useCallback(async () => {
|
||||||
if (!pendingDelete) return;
|
if (!pendingDelete) return;
|
||||||
const key = pendingDelete.key;
|
const items = pendingDelete.items;
|
||||||
|
const deletingKeys = new Set(items.map((item) => item.key));
|
||||||
const hasAutomations = (pendingDelete.automations?.length ?? 0) > 0;
|
const hasAutomations = (pendingDelete.automations?.length ?? 0) > 0;
|
||||||
const deletingActive = activeKey === key;
|
const deletingActive = activeKey !== null && deletingKeys.has(activeKey);
|
||||||
const currentIndex = sessions.findIndex((s) => s.key === key);
|
const currentIndex = topicSessions.findIndex((s) => s.key === activeKey);
|
||||||
const fallbackKey = deletingActive
|
const fallbackKey = deletingActive
|
||||||
? (sessions[currentIndex + 1]?.key ?? sessions[currentIndex - 1]?.key ?? null)
|
? (
|
||||||
|
topicSessions.slice(currentIndex + 1).find((session) => (
|
||||||
|
!deletingKeys.has(session.key)
|
||||||
|
))?.key
|
||||||
|
?? topicSessions.slice(0, Math.max(0, currentIndex)).reverse().find((session) => (
|
||||||
|
!deletingKeys.has(session.key)
|
||||||
|
))?.key
|
||||||
|
?? null
|
||||||
|
)
|
||||||
: activeKey;
|
: activeKey;
|
||||||
try {
|
try {
|
||||||
const result = await deleteChat(
|
for (let index = 0; index < items.length; index += 1) {
|
||||||
key,
|
const item = items[index];
|
||||||
hasAutomations ? { deleteAutomations: true } : undefined,
|
const result = await deleteChat(
|
||||||
);
|
item.key,
|
||||||
if (result.blocked_by_automations) {
|
hasAutomations ? { deleteAutomations: true } : undefined,
|
||||||
setPendingDelete({
|
);
|
||||||
...pendingDelete,
|
if (result.blocked_by_automations) {
|
||||||
automations: result.automations ?? [],
|
setPendingDelete({
|
||||||
});
|
items: items.slice(index),
|
||||||
return;
|
automations: result.automations ?? [],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
setPendingDelete(null);
|
setPendingDelete(null);
|
||||||
if (deletingActive) {
|
if (deletingActive) {
|
||||||
@@ -2053,18 +2182,24 @@ function Shell({
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to delete session", e);
|
console.error("Failed to delete session", e);
|
||||||
}
|
}
|
||||||
}, [pendingDelete, deleteChat, activeKey, navigate, sessions]);
|
}, [pendingDelete, deleteChat, activeKey, navigate, topicSessions]);
|
||||||
|
|
||||||
const onRequestDelete = useCallback(async (key: string, label: string) => {
|
const onRequestDeleteMany = useCallback(async (items: SidebarDeleteItem[]) => {
|
||||||
let automations: SessionAutomationJob[] = [];
|
const uniqueItems = Array.from(new Map(items.map((item) => [item.key, item])).values());
|
||||||
try {
|
if (uniqueItems.length === 0) return;
|
||||||
automations = await getSessionAutomations(key);
|
const automationResults = await Promise.allSettled(
|
||||||
} catch {
|
uniqueItems.map((item) => getSessionAutomations(item.key)),
|
||||||
// Delete remains protected by the backend block; prefetch only improves the first prompt.
|
);
|
||||||
}
|
const automations = automationResults.flatMap((result) => (
|
||||||
setPendingDelete({ key, label, automations });
|
result.status === "fulfilled" ? result.value : []
|
||||||
|
));
|
||||||
|
setPendingDelete({ items: uniqueItems, automations });
|
||||||
}, [getSessionAutomations]);
|
}, [getSessionAutomations]);
|
||||||
|
|
||||||
|
const onRequestDelete = useCallback((key: string, label: string) => {
|
||||||
|
void onRequestDeleteMany([{ key, label }]);
|
||||||
|
}, [onRequestDeleteMany]);
|
||||||
|
|
||||||
const visiblePairingRequests = useMemo(
|
const visiblePairingRequests = useMemo(
|
||||||
() => {
|
() => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@@ -2109,13 +2244,218 @@ function Shell({
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const titleForSession = useCallback((session: ChatSummary) => (
|
||||||
|
sidebarState.title_overrides[session.key]
|
||||||
|
|| session.title
|
||||||
|
|| deriveTitle(session.preview, t("chat.newChat"))
|
||||||
|
), [sidebarState.title_overrides, t]);
|
||||||
|
|
||||||
|
const automaticSidebarSort = sidebarState.view.sort === "manual"
|
||||||
|
? "updated_desc"
|
||||||
|
: sidebarState.view.sort;
|
||||||
|
const orderedWorkbenchTabs = useMemo(() => {
|
||||||
|
const orderedSessions = sortSessions(
|
||||||
|
sessions,
|
||||||
|
automaticSidebarSort,
|
||||||
|
sidebarState.title_overrides,
|
||||||
|
sidebarState.session_order,
|
||||||
|
);
|
||||||
|
const updatedAtByKey = new Map(sessions.map((session) => [
|
||||||
|
session.key,
|
||||||
|
session.updatedAt ?? session.createdAt,
|
||||||
|
]));
|
||||||
|
return orderWorkbenchTabs(
|
||||||
|
workbenchState,
|
||||||
|
orderedSessions.map((session) => session.key),
|
||||||
|
updatedAtByKey,
|
||||||
|
);
|
||||||
|
}, [
|
||||||
|
automaticSidebarSort,
|
||||||
|
sessions,
|
||||||
|
sidebarState.session_order,
|
||||||
|
sidebarState.title_overrides,
|
||||||
|
workbenchState,
|
||||||
|
]);
|
||||||
|
const orderedWorkbenchTabsByKey = useMemo(
|
||||||
|
() => new Map(orderedWorkbenchTabs.map((tab) => [tab.tabKey, tab])),
|
||||||
|
[orderedWorkbenchTabs],
|
||||||
|
);
|
||||||
|
const sidebarTabPresentations = useMemo(() => {
|
||||||
|
const sessionsByKey = new Map(sessions.map((session) => [session.key, session]));
|
||||||
|
return orderedWorkbenchTabs.flatMap((tab) => {
|
||||||
|
const anchorKey = tab.tab.paneKeys.find((key) => sessionsByKey.has(key))
|
||||||
|
?? tab.paneKeys[0];
|
||||||
|
const anchor = sessionsByKey.get(anchorKey);
|
||||||
|
if (!anchor) return [];
|
||||||
|
const title = tab.tab.title ?? titleForSession(anchor);
|
||||||
|
const visible = tab.tab.explicit || tab.paneKeys.length > 1;
|
||||||
|
const rowKey = visible ? tab.tabKey : tab.paneKeys[0];
|
||||||
|
return [{
|
||||||
|
orderedTab: tab,
|
||||||
|
rowKey,
|
||||||
|
title,
|
||||||
|
session: visible
|
||||||
|
? {
|
||||||
|
...anchor,
|
||||||
|
key: tab.tabKey,
|
||||||
|
chatId: `workbench-tab:${tab.tabKey}`,
|
||||||
|
title,
|
||||||
|
preview: "",
|
||||||
|
updatedAt: tab.updatedAt,
|
||||||
|
}
|
||||||
|
: anchor,
|
||||||
|
}];
|
||||||
|
});
|
||||||
|
}, [orderedWorkbenchTabs, sessions, titleForSession]);
|
||||||
|
const sidebarTopicSessions = useMemo(
|
||||||
|
() => sidebarTabPresentations.map((presentation) => presentation.session),
|
||||||
|
[sidebarTabPresentations],
|
||||||
|
);
|
||||||
|
|
||||||
const headerTitle = temporaryChatActive
|
const headerTitle = temporaryChatActive
|
||||||
? deriveTemporaryChatTitle(activeSession?.preview, t("temporaryChat.title"))
|
? deriveTemporaryChatTitle(activeSession?.preview, t("temporaryChat.title"))
|
||||||
: activeSession
|
: activeSession
|
||||||
? sidebarState.title_overrides[activeSession.key] ||
|
? titleForSession(activeSession)
|
||||||
activeSession.title ||
|
|
||||||
deriveTitle(activeSession.preview, t("chat.newChat"))
|
|
||||||
: t("app.brand");
|
: t("app.brand");
|
||||||
|
const workbenchPaneSessions = useMemo(() => {
|
||||||
|
if (!activeTabState) return [];
|
||||||
|
const byKey = new Map(sessions.map((session) => [session.key, session]));
|
||||||
|
const sortedPaneKeys = activeTabKey
|
||||||
|
? orderedWorkbenchTabsByKey.get(activeTabKey)?.paneKeys ?? activeTabState.paneKeys
|
||||||
|
: activeTabState.paneKeys;
|
||||||
|
const paneKeys = [
|
||||||
|
...activeTabState.layoutPaneKeys.filter((key) => byKey.has(key)),
|
||||||
|
...sortedPaneKeys.filter((key) => !activeTabState.layoutPaneKeys.includes(key)),
|
||||||
|
];
|
||||||
|
return paneKeys
|
||||||
|
.map((key) => byKey.get(key))
|
||||||
|
.filter((session): session is ChatSummary => session !== undefined);
|
||||||
|
}, [activeTabKey, activeTabState, orderedWorkbenchTabsByKey, sessions]);
|
||||||
|
const paneChromeEnabled = Boolean(
|
||||||
|
activeKey && activeSession && !temporaryChatActive && activeTabState,
|
||||||
|
);
|
||||||
|
const activeTabVisible = Boolean(
|
||||||
|
activeTabState
|
||||||
|
&& (activeTabState.explicit || activeTabState.paneKeys.length > 1),
|
||||||
|
);
|
||||||
|
const renderedWorkbenchPanes = useMemo(() => {
|
||||||
|
if (paneChromeEnabled) {
|
||||||
|
return workbenchPaneSessions.map((session) => ({
|
||||||
|
key: session.key,
|
||||||
|
reactKey: session.key === activeTabState?.paneKeys[0]
|
||||||
|
? "tab-root"
|
||||||
|
: `pane:${session.key}`,
|
||||||
|
title: titleForSession(session),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return [{
|
||||||
|
key: activeKey ?? "new-topic",
|
||||||
|
reactKey: "tab-root",
|
||||||
|
title: headerTitle,
|
||||||
|
}];
|
||||||
|
}, [
|
||||||
|
activeKey,
|
||||||
|
activeTabState?.paneKeys,
|
||||||
|
headerTitle,
|
||||||
|
paneChromeEnabled,
|
||||||
|
titleForSession,
|
||||||
|
workbenchPaneSessions,
|
||||||
|
]);
|
||||||
|
const renderedActivePaneKey = activeKey ?? renderedWorkbenchPanes[0].key;
|
||||||
|
const renderedWorkbenchLayout = paneChromeEnabled && activeTabState
|
||||||
|
? activeTabState.layout
|
||||||
|
: "columns";
|
||||||
|
const renderedWorkbenchSplitRatios = paneChromeEnabled && activeTabState
|
||||||
|
? activeTabState.splitRatios
|
||||||
|
: [];
|
||||||
|
const sidebarPaneGroups = useMemo(() => {
|
||||||
|
const sessionsByKey = new Map(sessions.map((session) => [session.key, session]));
|
||||||
|
return Object.fromEntries(sidebarTabPresentations.map((presentation) => {
|
||||||
|
const orderedTab = presentation.orderedTab;
|
||||||
|
const panes = orderedTab.paneKeys
|
||||||
|
.map((key) => sessionsByKey.get(key))
|
||||||
|
.filter((session): session is ChatSummary => session !== undefined)
|
||||||
|
.map((session) => ({
|
||||||
|
key: session.key,
|
||||||
|
chatId: session.chatId,
|
||||||
|
title: titleForSession(session),
|
||||||
|
}));
|
||||||
|
return [presentation.rowKey, {
|
||||||
|
tabKey: orderedTab.tabKey,
|
||||||
|
title: presentation.title,
|
||||||
|
activePaneKey: activeKey && orderedTab.paneKeys.includes(activeKey)
|
||||||
|
? activeKey
|
||||||
|
: orderedTab.paneKeys[0],
|
||||||
|
visible: orderedTab.tab.explicit || orderedTab.paneKeys.length > 1,
|
||||||
|
panes,
|
||||||
|
}];
|
||||||
|
}));
|
||||||
|
}, [
|
||||||
|
activeKey,
|
||||||
|
sessions,
|
||||||
|
sidebarTabPresentations,
|
||||||
|
titleForSession,
|
||||||
|
]);
|
||||||
|
const activePaneLimitReached = Boolean(
|
||||||
|
activeTabState && activeTabState.paneKeys.length >= MAX_WORKBENCH_PANES,
|
||||||
|
);
|
||||||
|
|
||||||
|
const onActivateWorkbenchPane = useCallback((paneKey: string) => {
|
||||||
|
onSelectChat(paneKey);
|
||||||
|
}, [onSelectChat]);
|
||||||
|
|
||||||
|
const onSelectSidebarTab = useCallback((tabKey: string) => {
|
||||||
|
const tab = workbenchTab(workbenchState, tabKey);
|
||||||
|
if (!tab) return;
|
||||||
|
const rememberedPaneKey = lastActivePaneByTabRef.current.get(tabKey);
|
||||||
|
onSelectChat(
|
||||||
|
rememberedPaneKey && tab.paneKeys.includes(rememberedPaneKey)
|
||||||
|
? rememberedPaneKey
|
||||||
|
: tab.paneKeys[0],
|
||||||
|
);
|
||||||
|
}, [onSelectChat, workbenchState]);
|
||||||
|
|
||||||
|
const onSelectSidebarItem = useCallback((key: string) => {
|
||||||
|
if (
|
||||||
|
temporarySessionsRef.current[key]
|
||||||
|
|| sessions.some((session) => session.key === key)
|
||||||
|
) {
|
||||||
|
onSelectChat(key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onSelectSidebarTab(key);
|
||||||
|
}, [onSelectChat, onSelectSidebarTab, sessions]);
|
||||||
|
|
||||||
|
const onSelectSidebarPane = useCallback((_tabKey: string, paneKey: string) => {
|
||||||
|
onSelectChat(paneKey);
|
||||||
|
}, [onSelectChat]);
|
||||||
|
|
||||||
|
const onDetachWorkbenchPane = useCallback((tabKey: string, paneKey: string) => {
|
||||||
|
updateWorkbenchState((current) => detachWorkbenchPane(current, tabKey, paneKey));
|
||||||
|
}, [updateWorkbenchState]);
|
||||||
|
|
||||||
|
const onCreateWorkbenchTab = useCallback((paneKey: string) => {
|
||||||
|
updateWorkbenchState((current) => createWorkbenchTab(current, paneKey));
|
||||||
|
}, [updateWorkbenchState]);
|
||||||
|
|
||||||
|
const onDissolveWorkbenchTab = useCallback((tabKey: string) => {
|
||||||
|
updateWorkbenchState((current) => dissolveWorkbenchTab(current, tabKey));
|
||||||
|
}, [updateWorkbenchState]);
|
||||||
|
|
||||||
|
const onAttachWorkbenchPane = useCallback((
|
||||||
|
paneKey: string,
|
||||||
|
tabKey: string,
|
||||||
|
) => {
|
||||||
|
updateWorkbenchState((current) => {
|
||||||
|
const target = workbenchTab(current, tabKey);
|
||||||
|
if (
|
||||||
|
!target
|
||||||
|
|| (!target.explicit && target.paneKeys.length < 2)
|
||||||
|
|| (!target.paneKeys.includes(paneKey) && target.paneKeys.length >= MAX_WORKBENCH_PANES)
|
||||||
|
) return current;
|
||||||
|
return attachWorkbenchPane(current, tabKey, paneKey);
|
||||||
|
});
|
||||||
|
}, [updateWorkbenchState]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (view === "settings") {
|
if (view === "settings") {
|
||||||
@@ -2147,20 +2487,49 @@ function Shell({
|
|||||||
: t("app.documentTitle.base");
|
: t("app.documentTitle.base");
|
||||||
}, [activeSession, headerTitle, i18n.resolvedLanguage, t, view]);
|
}, [activeSession, headerTitle, i18n.resolvedLanguage, t, view]);
|
||||||
|
|
||||||
|
const pinnedPaneKeys = useMemo(
|
||||||
|
() => new Set(sidebarState.pinned_keys),
|
||||||
|
[sidebarState.pinned_keys],
|
||||||
|
);
|
||||||
|
const archivedPaneKeys = useMemo(
|
||||||
|
() => new Set(sidebarState.archived_keys),
|
||||||
|
[sidebarState.archived_keys],
|
||||||
|
);
|
||||||
|
const sidebarPinnedTabKeys = useMemo(() => sidebarTabPresentations
|
||||||
|
.filter(({ orderedTab }) => orderedTab.paneKeys.some((key) => pinnedPaneKeys.has(key)))
|
||||||
|
.map(({ rowKey }) => rowKey), [pinnedPaneKeys, sidebarTabPresentations]);
|
||||||
|
const sidebarArchivedTabKeys = useMemo(() => sidebarTabPresentations
|
||||||
|
.filter(({ orderedTab }) => orderedTab.paneKeys.every((key) => archivedPaneKeys.has(key)))
|
||||||
|
.map(({ rowKey }) => rowKey), [archivedPaneKeys, sidebarTabPresentations]);
|
||||||
|
const activeSidebarKey = activeTabKey
|
||||||
|
? sidebarTabPresentations.find(({ orderedTab }) => (
|
||||||
|
orderedTab.tabKey === activeTabKey
|
||||||
|
))?.rowKey ?? activeKey
|
||||||
|
: activeKey;
|
||||||
|
|
||||||
const sidebarProps = {
|
const sidebarProps = {
|
||||||
sessions,
|
sessions: sidebarTopicSessions,
|
||||||
temporarySessions: temporarySessionList,
|
temporarySessions: temporarySessionList,
|
||||||
activeKey: view === "chat" ? activeKey : null,
|
activeKey: view === "chat"
|
||||||
|
? (temporaryChatActive ? activeKey : activeSidebarKey)
|
||||||
|
: null,
|
||||||
loading,
|
loading,
|
||||||
newChatActive: view === "chat" && activeKey === null,
|
newChatActive: view === "chat" && activeKey === null,
|
||||||
onNewChat,
|
onNewChat,
|
||||||
onSelect: onSelectChat,
|
onSelect: onSelectSidebarItem,
|
||||||
onCloseTemporaryChat,
|
onCloseTemporaryChat,
|
||||||
onRequestDelete,
|
onRequestDelete,
|
||||||
|
onRequestDeleteMany,
|
||||||
onTogglePin,
|
onTogglePin,
|
||||||
onRequestRename,
|
onRequestRename,
|
||||||
onToggleArchive,
|
onToggleArchive,
|
||||||
onReorderSessions,
|
onRequestRenameTab,
|
||||||
|
paneGroups: sidebarPaneGroups,
|
||||||
|
onSelectPane: onSelectSidebarPane,
|
||||||
|
onCreateTab: mobileWorkbench ? undefined : onCreateWorkbenchTab,
|
||||||
|
onDetachPane: mobileWorkbench ? undefined : onDetachWorkbenchPane,
|
||||||
|
onDissolveTab: mobileWorkbench ? undefined : onDissolveWorkbenchTab,
|
||||||
|
onAttachPane: mobileWorkbench ? undefined : onAttachWorkbenchPane,
|
||||||
onToggleGroup,
|
onToggleGroup,
|
||||||
onRequestRenameProject,
|
onRequestRenameProject,
|
||||||
onNewChatInProject,
|
onNewChatInProject,
|
||||||
@@ -2172,17 +2541,19 @@ function Shell({
|
|||||||
onOpenSearch: onOpenSessionSearch,
|
onOpenSearch: onOpenSessionSearch,
|
||||||
activeUtility: view === "apps" || view === "automations" || view === "skills" ? view : null,
|
activeUtility: view === "apps" || view === "automations" || view === "skills" ? view : null,
|
||||||
onToggleArchived,
|
onToggleArchived,
|
||||||
pinnedKeys: sidebarState.pinned_keys,
|
pinnedKeys: sidebarPinnedTabKeys,
|
||||||
archivedKeys: sidebarState.archived_keys,
|
archivedKeys: sidebarArchivedTabKeys,
|
||||||
|
pinnedPaneKeys: sidebarState.pinned_keys,
|
||||||
|
archivedPaneKeys: sidebarState.archived_keys,
|
||||||
sessionOrder: sidebarState.session_order,
|
sessionOrder: sidebarState.session_order,
|
||||||
titleOverrides: sidebarState.title_overrides,
|
titleOverrides: sidebarState.title_overrides,
|
||||||
projectNameOverrides: sidebarState.project_name_overrides,
|
projectNameOverrides: sidebarState.project_name_overrides,
|
||||||
collapsedGroups: sidebarState.collapsed_groups,
|
collapsedGroups: sidebarState.collapsed_groups,
|
||||||
runningChatIds: runningChatIdList,
|
runningChatIds: runningChatIdList,
|
||||||
updatedChatIds: updatedChatIdList,
|
updatedChatIds: updatedChatIdList,
|
||||||
viewState: sidebarState.view,
|
viewState: { ...sidebarState.view, sort: automaticSidebarSort },
|
||||||
showArchived: sidebarState.view.show_archived,
|
showArchived: sidebarState.view.show_archived,
|
||||||
archivedCount: sidebarState.archived_keys.length,
|
archivedCount: sidebarArchivedTabKeys.length,
|
||||||
defaultWorkspacePath: workspaces?.default_scope.project_path ?? null,
|
defaultWorkspacePath: workspaces?.default_scope.project_path ?? null,
|
||||||
};
|
};
|
||||||
const hostSidebarCollapsed = showHostChrome && !hostSidebarOpen;
|
const hostSidebarCollapsed = showHostChrome && !hostSidebarOpen;
|
||||||
@@ -2318,7 +2689,7 @@ function Shell({
|
|||||||
<SessionSearchDialog
|
<SessionSearchDialog
|
||||||
open
|
open
|
||||||
onOpenChange={setSessionSearchOpen}
|
onOpenChange={setSessionSearchOpen}
|
||||||
sessions={sessions}
|
sessions={topicSessions}
|
||||||
activeKey={activeKey}
|
activeKey={activeKey}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
titleOverrides={sidebarState.title_overrides}
|
titleOverrides={sidebarState.title_overrides}
|
||||||
@@ -2337,35 +2708,135 @@ function Shell({
|
|||||||
view !== "chat" && "hidden",
|
view !== "chat" && "hidden",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<ThreadShell
|
<PaneWorkbench
|
||||||
session={activeSession}
|
panes={renderedWorkbenchPanes}
|
||||||
sessions={sessions}
|
activePaneKey={renderedActivePaneKey}
|
||||||
title={headerTitle}
|
layout={renderedWorkbenchLayout}
|
||||||
temporary={temporaryChatRequested}
|
splitRatios={renderedWorkbenchSplitRatios}
|
||||||
temporaryChatIds={temporaryChatIds}
|
chrome={paneChromeEnabled}
|
||||||
temporaryChatEnabled={temporaryChatEnabled}
|
showLayoutControl={activeTabVisible}
|
||||||
onTemporaryChatEnabledChange={
|
addPaneDisabled={creatingPane || activePaneLimitReached}
|
||||||
!activeKey ? onTemporaryChatEnabledChange : undefined
|
addPaneDisabledLabel={activePaneLimitReached
|
||||||
}
|
? t("workbench.paneLimit", {
|
||||||
onToggleSidebar={toggleSidebar}
|
defaultValue: "Maximum {{count}} panes",
|
||||||
onNewChat={onNewChat}
|
count: MAX_WORKBENCH_PANES,
|
||||||
onCreateChat={temporaryChatEnabled ? onCreateTemporaryChat : onCreateChat}
|
})
|
||||||
onForkChat={temporaryChatActive ? undefined : onForkChat}
|
: undefined}
|
||||||
onTurnEnd={onTurnEnd}
|
onActivatePane={onActivateWorkbenchPane}
|
||||||
theme={theme}
|
onAddPane={onAddPane}
|
||||||
onToggleTheme={toggle}
|
onLayoutChange={(layout) => {
|
||||||
hideSidebarToggleForHostChrome
|
if (!activeTabKey) return;
|
||||||
hostChromeTitleInset={hostSidebarCollapsed}
|
updateWorkbenchState((current) => (
|
||||||
hideHeader={false}
|
setWorkbenchLayout(current, activeTabKey, layout)
|
||||||
workspaceScope={activeWorkspaceScope}
|
));
|
||||||
workspaceDefaultScope={workspaces?.default_scope ?? null}
|
}}
|
||||||
workspaceControls={workspaces?.controls ?? null}
|
onPaneOrderChange={(paneKeys) => {
|
||||||
workspaceScopeDisabled={activeChatRunning}
|
if (!activeTabKey) return;
|
||||||
workspaceError={workspaceError}
|
updateWorkbenchState((current) => (
|
||||||
onWorkspaceScopeChange={applyWorkspaceScope}
|
setWorkbenchPaneLayoutOrder(current, activeTabKey, paneKeys)
|
||||||
settingsSnapshot={settingsSnapshot}
|
));
|
||||||
onOpenModelSettings={onOpenModelSettings}
|
}}
|
||||||
skills={skills}
|
onSplitRatiosChange={(splitRatios) => {
|
||||||
|
if (!activeTabKey) return;
|
||||||
|
updateWorkbenchState((current) => (
|
||||||
|
setWorkbenchSplitRatios(current, activeTabKey, splitRatios)
|
||||||
|
));
|
||||||
|
}}
|
||||||
|
renderPane={(pane, context) => {
|
||||||
|
if (!paneChromeEnabled) {
|
||||||
|
return (
|
||||||
|
<ThreadShell
|
||||||
|
session={activeSession}
|
||||||
|
sessions={sessions}
|
||||||
|
title={headerTitle}
|
||||||
|
temporary={temporaryChatRequested}
|
||||||
|
temporaryChatIds={temporaryChatIds}
|
||||||
|
temporaryChatEnabled={temporaryChatEnabled}
|
||||||
|
onTemporaryChatEnabledChange={
|
||||||
|
!activeKey ? onTemporaryChatEnabledChange : undefined
|
||||||
|
}
|
||||||
|
onToggleSidebar={toggleSidebar}
|
||||||
|
onNewChat={onNewChat}
|
||||||
|
onCreateChat={
|
||||||
|
temporaryChatEnabled ? onCreateTemporaryChat : onCreateChat
|
||||||
|
}
|
||||||
|
onForkChat={temporaryChatActive ? undefined : onForkChat}
|
||||||
|
onTurnEnd={onTurnEnd}
|
||||||
|
theme={theme}
|
||||||
|
onToggleTheme={toggle}
|
||||||
|
hideSidebarToggleForHostChrome
|
||||||
|
hostChromeTitleInset={hostSidebarCollapsed}
|
||||||
|
hideHeader={false}
|
||||||
|
workspaceScope={activeWorkspaceScope}
|
||||||
|
workspaceDefaultScope={workspaces?.default_scope ?? null}
|
||||||
|
workspaceControls={workspaces?.controls ?? null}
|
||||||
|
workspaceScopeDisabled={activeChatRunning}
|
||||||
|
workspaceError={workspaceError}
|
||||||
|
onWorkspaceScopeChange={applyWorkspaceScope}
|
||||||
|
settingsSnapshot={settingsSnapshot}
|
||||||
|
onOpenModelSettings={onOpenModelSettings}
|
||||||
|
skills={skills}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const paneSession = workbenchPaneSessions.find(
|
||||||
|
(session) => session.key === pane.key,
|
||||||
|
);
|
||||||
|
if (!paneSession) return null;
|
||||||
|
const paneScope = workspaceOverrides[paneSession.chatId]
|
||||||
|
?? paneSession.workspaceScope
|
||||||
|
?? workspaces?.default_scope
|
||||||
|
?? null;
|
||||||
|
const paneRunning = runningChatIds.has(paneSession.chatId);
|
||||||
|
return (
|
||||||
|
<ThreadShell
|
||||||
|
session={paneSession}
|
||||||
|
sessions={sessions}
|
||||||
|
title={pane.title}
|
||||||
|
onToggleSidebar={toggleSidebar}
|
||||||
|
onNewChat={onNewChat}
|
||||||
|
onCreateChat={onCreateChat}
|
||||||
|
onForkChat={onForkChat}
|
||||||
|
onTurnEnd={context.active ? onTurnEnd : () => void refresh()}
|
||||||
|
theme={theme}
|
||||||
|
onToggleTheme={toggle}
|
||||||
|
hideSidebarToggle={!context.active}
|
||||||
|
hideSidebarToggleForHostChrome={context.active}
|
||||||
|
hostChromeTitleInset={hostSidebarCollapsed}
|
||||||
|
hideThemeButton={!context.active}
|
||||||
|
hideHeaderTitle
|
||||||
|
headerActions={context.headerActions}
|
||||||
|
headerPortalTarget={context.headerPortalTarget}
|
||||||
|
headerActive={context.active}
|
||||||
|
composerPortalTarget={context.composerPortalTarget}
|
||||||
|
composerActive={context.active}
|
||||||
|
composerInputAriaLabel={t("workbench.composerAria", {
|
||||||
|
defaultValue: "Message {{title}}",
|
||||||
|
title: pane.title,
|
||||||
|
})}
|
||||||
|
emptyComposerVariant="thread"
|
||||||
|
workspaceScope={paneScope}
|
||||||
|
workspaceDefaultScope={workspaces?.default_scope ?? null}
|
||||||
|
workspaceControls={workspaces?.controls ?? null}
|
||||||
|
workspaceScopeDisabled={paneRunning}
|
||||||
|
workspaceError={context.active ? workspaceError : null}
|
||||||
|
onWorkspaceScopeChange={(scope) => {
|
||||||
|
if (paneRunning) return;
|
||||||
|
const next = normalizeWorkspaceScope(scope);
|
||||||
|
setWorkspaceError(null);
|
||||||
|
setWorkspaceOverrides((current) => ({
|
||||||
|
...current,
|
||||||
|
[paneSession.chatId]: next,
|
||||||
|
}));
|
||||||
|
client.setWorkspaceScope(paneSession.chatId, next);
|
||||||
|
}}
|
||||||
|
settingsSnapshot={settingsSnapshot}
|
||||||
|
onOpenModelSettings={onOpenModelSettings}
|
||||||
|
skills={skills}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{view !== "chat" && (
|
{view !== "chat" && (
|
||||||
@@ -2398,7 +2869,8 @@ function Shell({
|
|||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<DeleteConfirm
|
<DeleteConfirm
|
||||||
open
|
open
|
||||||
title={pendingDelete.label}
|
title={pendingDelete.items[0]?.label ?? ""}
|
||||||
|
count={pendingDelete.items.length}
|
||||||
automations={pendingDelete.automations}
|
automations={pendingDelete.automations}
|
||||||
onCancel={() => setPendingDelete(null)}
|
onCancel={() => setPendingDelete(null)}
|
||||||
onConfirm={onConfirmDelete}
|
onConfirm={onConfirmDelete}
|
||||||
@@ -2415,6 +2887,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
|
||||||
|
|||||||
+861
-151
File diff suppressed because it is too large
Load Diff
@@ -18,6 +18,7 @@ import type { SessionAutomationJob } from "@/lib/types";
|
|||||||
interface DeleteConfirmProps {
|
interface DeleteConfirmProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
title: string;
|
title: string;
|
||||||
|
count?: number;
|
||||||
automations?: SessionAutomationJob[];
|
automations?: SessionAutomationJob[];
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
onConfirm: () => void;
|
onConfirm: () => void;
|
||||||
@@ -26,6 +27,7 @@ interface DeleteConfirmProps {
|
|||||||
export function DeleteConfirm({
|
export function DeleteConfirm({
|
||||||
open,
|
open,
|
||||||
title,
|
title,
|
||||||
|
count = 1,
|
||||||
automations = [],
|
automations = [],
|
||||||
onCancel,
|
onCancel,
|
||||||
onConfirm,
|
onConfirm,
|
||||||
@@ -33,6 +35,7 @@ export function DeleteConfirm({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const locale = currentLocale();
|
const locale = currentLocale();
|
||||||
const hasAutomations = automations.length > 0;
|
const hasAutomations = automations.length > 0;
|
||||||
|
const multiple = count > 1;
|
||||||
const visibleAutomations = automations.slice(0, 4);
|
const visibleAutomations = automations.slice(0, 4);
|
||||||
const hiddenCount = Math.max(0, automations.length - visibleAutomations.length);
|
const hiddenCount = Math.max(0, automations.length - visibleAutomations.length);
|
||||||
return (
|
return (
|
||||||
@@ -47,12 +50,25 @@ export function DeleteConfirm({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<AlertDialogTitle className="text-center text-[20px] font-semibold leading-tight tracking-[-0.02em] text-foreground">
|
<AlertDialogTitle className="text-center text-[20px] font-semibold leading-tight tracking-[-0.02em] text-foreground">
|
||||||
{t("deleteConfirm.title", { title })}
|
{multiple
|
||||||
|
? t("deleteConfirm.titleMany", {
|
||||||
|
defaultValue: "Delete {{count}} conversations?",
|
||||||
|
count,
|
||||||
|
})
|
||||||
|
: t("deleteConfirm.title", { title })}
|
||||||
</AlertDialogTitle>
|
</AlertDialogTitle>
|
||||||
<AlertDialogDescription className="mt-3 max-w-[17rem] text-center text-[14px] leading-6 text-muted-foreground">
|
<AlertDialogDescription className="mt-3 max-w-[17rem] text-center text-[14px] leading-6 text-muted-foreground">
|
||||||
{hasAutomations
|
{hasAutomations
|
||||||
? t("deleteConfirm.automationsDescription")
|
? multiple
|
||||||
: t("deleteConfirm.description")}
|
? t("deleteConfirm.automationsDescriptionMany", {
|
||||||
|
defaultValue: "Linked automations will also be deleted.",
|
||||||
|
})
|
||||||
|
: t("deleteConfirm.automationsDescription")
|
||||||
|
: multiple
|
||||||
|
? t("deleteConfirm.descriptionMany", {
|
||||||
|
defaultValue: "This action cannot be undone.",
|
||||||
|
})
|
||||||
|
: t("deleteConfirm.description")}
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
{hasAutomations ? (
|
{hasAutomations ? (
|
||||||
<div className="mt-4 max-h-40 w-full overflow-y-auto rounded-2xl bg-muted/55 px-3 py-2 text-left">
|
<div className="mt-4 max-h-40 w-full overflow-y-auto rounded-2xl bg-muted/55 px-3 py-2 text-left">
|
||||||
|
|||||||
@@ -16,7 +16,11 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { ChatList } from "@/components/ChatList";
|
import {
|
||||||
|
ChatList,
|
||||||
|
type SidebarDeleteItem,
|
||||||
|
type SidebarPaneGroup,
|
||||||
|
} from "@/components/ChatList";
|
||||||
import { ConnectionBadge } from "@/components/ConnectionBadge";
|
import { ConnectionBadge } from "@/components/ConnectionBadge";
|
||||||
import {
|
import {
|
||||||
SIDEBAR_SELECTION_ACTION_ITEM_CLASS,
|
SIDEBAR_SELECTION_ACTION_ITEM_CLASS,
|
||||||
@@ -39,10 +43,20 @@ interface SidebarProps {
|
|||||||
onSelect: (key: string) => void;
|
onSelect: (key: string) => void;
|
||||||
onCloseTemporaryChat?: (key: string) => void;
|
onCloseTemporaryChat?: (key: string) => void;
|
||||||
onRequestDelete: (key: string, label: string) => void;
|
onRequestDelete: (key: string, label: string) => void;
|
||||||
|
onRequestDeleteMany?: (items: SidebarDeleteItem[]) => void;
|
||||||
onTogglePin: (key: string) => void;
|
onTogglePin: (key: string) => void;
|
||||||
onRequestRename: (key: string, label: string) => void;
|
onRequestRename: (key: string, label: string) => void;
|
||||||
|
onRequestRenameTab?: (key: string, label: string) => void;
|
||||||
onToggleArchive: (key: string) => void;
|
onToggleArchive: (key: string) => void;
|
||||||
onReorderSessions: (keys: string[]) => void;
|
paneGroups?: Record<string, SidebarPaneGroup>;
|
||||||
|
onSelectPane?: (tabKey: string, paneKey: string) => void;
|
||||||
|
onCreateTab?: (paneKey: string) => void;
|
||||||
|
onDetachPane?: (tabKey: string, paneKey: string) => void;
|
||||||
|
onDissolveTab?: (tabKey: string) => void;
|
||||||
|
onAttachPane?: (
|
||||||
|
paneKey: string,
|
||||||
|
tabKey: string,
|
||||||
|
) => void;
|
||||||
onToggleGroup: (groupId: string) => void;
|
onToggleGroup: (groupId: string) => void;
|
||||||
onRequestRenameProject: (projectKey: string, label: string) => void;
|
onRequestRenameProject: (projectKey: string, label: string) => void;
|
||||||
onNewChatInProject: (projectPath: string, projectName: string) => void;
|
onNewChatInProject: (projectPath: string, projectName: string) => void;
|
||||||
@@ -60,6 +74,8 @@ interface SidebarProps {
|
|||||||
collapsed?: boolean;
|
collapsed?: boolean;
|
||||||
pinnedKeys?: string[];
|
pinnedKeys?: string[];
|
||||||
archivedKeys?: string[];
|
archivedKeys?: string[];
|
||||||
|
pinnedPaneKeys?: string[];
|
||||||
|
archivedPaneKeys?: string[];
|
||||||
sessionOrder?: string[];
|
sessionOrder?: string[];
|
||||||
titleOverrides?: Record<string, string>;
|
titleOverrides?: Record<string, string>;
|
||||||
projectNameOverrides?: Record<string, string>;
|
projectNameOverrides?: Record<string, string>;
|
||||||
@@ -230,15 +246,24 @@ export function Sidebar(props: SidebarProps) {
|
|||||||
onSelect={props.onSelect}
|
onSelect={props.onSelect}
|
||||||
onCloseTemporaryChat={props.onCloseTemporaryChat}
|
onCloseTemporaryChat={props.onCloseTemporaryChat}
|
||||||
onRequestDelete={props.onRequestDelete}
|
onRequestDelete={props.onRequestDelete}
|
||||||
|
onRequestDeleteMany={props.onRequestDeleteMany}
|
||||||
onTogglePin={props.onTogglePin}
|
onTogglePin={props.onTogglePin}
|
||||||
onRequestRename={props.onRequestRename}
|
onRequestRename={props.onRequestRename}
|
||||||
|
onRequestRenameTab={props.onRequestRenameTab}
|
||||||
onToggleArchive={props.onToggleArchive}
|
onToggleArchive={props.onToggleArchive}
|
||||||
onReorderSessions={props.onReorderSessions}
|
paneGroups={props.paneGroups}
|
||||||
|
onSelectPane={props.onSelectPane}
|
||||||
|
onCreateTab={props.onCreateTab}
|
||||||
|
onDetachPane={props.onDetachPane}
|
||||||
|
onDissolveTab={props.onDissolveTab}
|
||||||
|
onAttachPane={props.onAttachPane}
|
||||||
onToggleGroup={props.onToggleGroup}
|
onToggleGroup={props.onToggleGroup}
|
||||||
onRequestRenameProject={props.onRequestRenameProject}
|
onRequestRenameProject={props.onRequestRenameProject}
|
||||||
onNewChatInProject={props.onNewChatInProject}
|
onNewChatInProject={props.onNewChatInProject}
|
||||||
pinnedKeys={props.pinnedKeys}
|
pinnedKeys={props.pinnedKeys}
|
||||||
archivedKeys={props.archivedKeys}
|
archivedKeys={props.archivedKeys}
|
||||||
|
pinnedPaneKeys={props.pinnedPaneKeys}
|
||||||
|
archivedPaneKeys={props.archivedPaneKeys}
|
||||||
sessionOrder={props.sessionOrder}
|
sessionOrder={props.sessionOrder}
|
||||||
titleOverrides={props.titleOverrides}
|
titleOverrides={props.titleOverrides}
|
||||||
projectNameOverrides={props.projectNameOverrides}
|
projectNameOverrides={props.projectNameOverrides}
|
||||||
|
|||||||
@@ -186,6 +186,7 @@ interface ThreadComposerProps {
|
|||||||
) => boolean | void | Promise<boolean | void>;
|
) => boolean | void | Promise<boolean | void>;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
|
inputAriaLabel?: string;
|
||||||
isStreaming?: boolean;
|
isStreaming?: boolean;
|
||||||
modelLabel?: string | null;
|
modelLabel?: string | null;
|
||||||
modelDetail?: string | null;
|
modelDetail?: string | null;
|
||||||
@@ -940,6 +941,7 @@ export function ThreadComposer({
|
|||||||
onSend,
|
onSend,
|
||||||
disabled,
|
disabled,
|
||||||
placeholder,
|
placeholder,
|
||||||
|
inputAriaLabel,
|
||||||
isStreaming = false,
|
isStreaming = false,
|
||||||
modelLabel = null,
|
modelLabel = null,
|
||||||
modelDetail = null,
|
modelDetail = null,
|
||||||
@@ -2400,7 +2402,7 @@ export function ThreadComposer({
|
|||||||
rows={1}
|
rows={1}
|
||||||
placeholder={sessionDragPreview ? "" : resolvedPlaceholder}
|
placeholder={sessionDragPreview ? "" : resolvedPlaceholder}
|
||||||
disabled={interactionDisabled}
|
disabled={interactionDisabled}
|
||||||
aria-label={t("thread.composer.inputAria")}
|
aria-label={inputAriaLabel ?? t("thread.composer.inputAria")}
|
||||||
className={cn(
|
className={cn(
|
||||||
inputTextClasses,
|
inputTextClasses,
|
||||||
"relative z-10 caret-foreground placeholder:text-muted-foreground/70",
|
"relative z-10 caret-foreground placeholder:text-muted-foreground/70",
|
||||||
|
|||||||
@@ -17,8 +17,11 @@ interface ThreadHeaderProps {
|
|||||||
theme: "light" | "dark";
|
theme: "light" | "dark";
|
||||||
onToggleTheme: () => void;
|
onToggleTheme: () => void;
|
||||||
hideSidebarToggleForHostChrome?: boolean;
|
hideSidebarToggleForHostChrome?: boolean;
|
||||||
|
hideSidebarToggle?: boolean;
|
||||||
hostChromeTitleInset?: boolean;
|
hostChromeTitleInset?: boolean;
|
||||||
hideThemeButton?: boolean;
|
hideThemeButton?: boolean;
|
||||||
|
hideTitle?: boolean;
|
||||||
|
actions?: ReactNode;
|
||||||
minimal?: boolean;
|
minimal?: boolean;
|
||||||
promptNavigatorAction?: ReactNode;
|
promptNavigatorAction?: ReactNode;
|
||||||
sessionInfoAction?: ReactNode;
|
sessionInfoAction?: ReactNode;
|
||||||
@@ -33,8 +36,11 @@ export function ThreadHeader({
|
|||||||
theme,
|
theme,
|
||||||
onToggleTheme,
|
onToggleTheme,
|
||||||
hideSidebarToggleForHostChrome = false,
|
hideSidebarToggleForHostChrome = false,
|
||||||
|
hideSidebarToggle = false,
|
||||||
hostChromeTitleInset = false,
|
hostChromeTitleInset = false,
|
||||||
hideThemeButton = false,
|
hideThemeButton = false,
|
||||||
|
hideTitle = false,
|
||||||
|
actions,
|
||||||
minimal = false,
|
minimal = false,
|
||||||
promptNavigatorAction,
|
promptNavigatorAction,
|
||||||
sessionInfoAction,
|
sessionInfoAction,
|
||||||
@@ -54,19 +60,21 @@ export function ThreadHeader({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="relative flex min-w-0 items-center gap-2">
|
<div className="relative flex min-w-0 items-center gap-2">
|
||||||
<Button
|
{!hideSidebarToggle ? (
|
||||||
variant="ghost"
|
<Button
|
||||||
size="icon"
|
variant="ghost"
|
||||||
aria-label={t("thread.header.toggleSidebar")}
|
size="icon"
|
||||||
onClick={onToggleSidebar}
|
aria-label={t("thread.header.toggleSidebar")}
|
||||||
className={cn(
|
onClick={onToggleSidebar}
|
||||||
"h-7 w-7 rounded-md text-muted-foreground hover:bg-accent/35 hover:text-foreground",
|
className={cn(
|
||||||
hideSidebarToggleForHostChrome && "lg:hidden",
|
"h-7 w-7 rounded-md text-muted-foreground hover:bg-accent/35 hover:text-foreground",
|
||||||
)}
|
hideSidebarToggleForHostChrome && "lg:hidden",
|
||||||
>
|
)}
|
||||||
<Menu className="h-3.5 w-3.5" />
|
>
|
||||||
</Button>
|
<Menu className="h-3.5 w-3.5" />
|
||||||
{!minimal ? (
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{!minimal && !hideTitle ? (
|
||||||
<div className="flex min-w-0 items-center rounded-md px-1.5 py-1 text-[12px] font-medium text-muted-foreground">
|
<div className="flex min-w-0 items-center rounded-md px-1.5 py-1 text-[12px] font-medium text-muted-foreground">
|
||||||
<span className="max-w-[min(60vw,32rem)] truncate">{title}</span>
|
<span className="max-w-[min(60vw,32rem)] truncate">{title}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -76,6 +84,7 @@ export function ThreadHeader({
|
|||||||
<div className="ml-auto flex shrink-0 items-center gap-1">
|
<div className="ml-auto flex shrink-0 items-center gap-1">
|
||||||
{sessionInfoAction}
|
{sessionInfoAction}
|
||||||
{promptNavigatorAction}
|
{promptNavigatorAction}
|
||||||
|
{actions}
|
||||||
{onTemporaryChatEnabledChange ? (
|
{onTemporaryChatEnabledChange ? (
|
||||||
<TooltipProvider delayDuration={700} skipDelayDuration={0}>
|
<TooltipProvider delayDuration={700} skipDelayDuration={0}>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||||
import type { PointerEvent as ReactPointerEvent } from "react";
|
import type { PointerEvent as ReactPointerEvent, ReactNode } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { FilePreviewAvailabilityProvider } from "@/components/FilePreviewAvailabilityContext";
|
import { FilePreviewAvailabilityProvider } from "@/components/FilePreviewAvailabilityContext";
|
||||||
@@ -311,9 +312,18 @@ interface ThreadShellProps {
|
|||||||
theme?: "light" | "dark";
|
theme?: "light" | "dark";
|
||||||
onToggleTheme?: () => void;
|
onToggleTheme?: () => void;
|
||||||
hideSidebarToggleForHostChrome?: boolean;
|
hideSidebarToggleForHostChrome?: boolean;
|
||||||
|
hideSidebarToggle?: boolean;
|
||||||
hostChromeTitleInset?: boolean;
|
hostChromeTitleInset?: boolean;
|
||||||
hideThemeButton?: boolean;
|
hideThemeButton?: boolean;
|
||||||
|
hideHeaderTitle?: boolean;
|
||||||
hideHeader?: boolean;
|
hideHeader?: boolean;
|
||||||
|
headerActions?: ReactNode;
|
||||||
|
headerPortalTarget?: HTMLElement | null;
|
||||||
|
headerActive?: boolean;
|
||||||
|
composerPortalTarget?: HTMLElement | null;
|
||||||
|
composerActive?: boolean;
|
||||||
|
composerInputAriaLabel?: string;
|
||||||
|
emptyComposerVariant?: "hero" | "thread";
|
||||||
workspaceScope?: WorkspaceScopePayload | null;
|
workspaceScope?: WorkspaceScopePayload | null;
|
||||||
workspaceDefaultScope?: WorkspaceScopePayload | null;
|
workspaceDefaultScope?: WorkspaceScopePayload | null;
|
||||||
workspaceControls?: WorkspacesPayload["controls"] | null;
|
workspaceControls?: WorkspacesPayload["controls"] | null;
|
||||||
@@ -598,9 +608,18 @@ export function ThreadShell({
|
|||||||
theme = "light",
|
theme = "light",
|
||||||
onToggleTheme = () => {},
|
onToggleTheme = () => {},
|
||||||
hideSidebarToggleForHostChrome = false,
|
hideSidebarToggleForHostChrome = false,
|
||||||
|
hideSidebarToggle = false,
|
||||||
hostChromeTitleInset = false,
|
hostChromeTitleInset = false,
|
||||||
hideThemeButton = false,
|
hideThemeButton = false,
|
||||||
|
hideHeaderTitle = false,
|
||||||
hideHeader = false,
|
hideHeader = false,
|
||||||
|
headerActions,
|
||||||
|
headerPortalTarget,
|
||||||
|
headerActive = true,
|
||||||
|
composerPortalTarget,
|
||||||
|
composerActive = true,
|
||||||
|
composerInputAriaLabel,
|
||||||
|
emptyComposerVariant = "hero",
|
||||||
workspaceScope = null,
|
workspaceScope = null,
|
||||||
workspaceDefaultScope = null,
|
workspaceDefaultScope = null,
|
||||||
workspaceControls = null,
|
workspaceControls = null,
|
||||||
@@ -843,6 +862,7 @@ export function ThreadShell({
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const showHeroComposer = displayMessages.length === 0 && !loading;
|
const showHeroComposer = displayMessages.length === 0 && !loading;
|
||||||
|
const composerVariant = showHeroComposer ? emptyComposerVariant : "thread";
|
||||||
const wasShowingHeroComposerRef = useRef(showHeroComposer);
|
const wasShowingHeroComposerRef = useRef(showHeroComposer);
|
||||||
const sessionModelPreset = session?.modelPreset?.trim() || null;
|
const sessionModelPreset = session?.modelPreset?.trim() || null;
|
||||||
const [localModelPreset, setLocalModelPreset] = useState<string | null>(null);
|
const [localModelPreset, setLocalModelPreset] = useState<string | null>(null);
|
||||||
@@ -1405,9 +1425,10 @@ export function ThreadShell({
|
|||||||
<ThreadComposer
|
<ThreadComposer
|
||||||
onSend={handleThreadSend}
|
onSend={handleThreadSend}
|
||||||
disabled={!chatId}
|
disabled={!chatId}
|
||||||
|
inputAriaLabel={composerInputAriaLabel}
|
||||||
isStreaming={turnActive}
|
isStreaming={turnActive}
|
||||||
placeholder={
|
placeholder={
|
||||||
showHeroComposer
|
composerVariant === "hero"
|
||||||
? t("thread.composer.placeholderHero")
|
? t("thread.composer.placeholderHero")
|
||||||
: t("thread.composer.placeholderThread")
|
: t("thread.composer.placeholderThread")
|
||||||
}
|
}
|
||||||
@@ -1421,7 +1442,7 @@ export function ThreadShell({
|
|||||||
modelNeedsSetup={modelBadge.needsSetup}
|
modelNeedsSetup={modelBadge.needsSetup}
|
||||||
fallbackModelName={fallbackModelName}
|
fallbackModelName={fallbackModelName}
|
||||||
onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined}
|
onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined}
|
||||||
variant={showHeroComposer ? "hero" : "thread"}
|
variant={composerVariant}
|
||||||
slashCommands={availableSlashCommands}
|
slashCommands={availableSlashCommands}
|
||||||
cliApps={cliApps}
|
cliApps={cliApps}
|
||||||
mcpPresets={mcpPresets}
|
mcpPresets={mcpPresets}
|
||||||
@@ -1449,6 +1470,7 @@ export function ThreadShell({
|
|||||||
<ThreadComposer
|
<ThreadComposer
|
||||||
onSend={handleWelcomeSend}
|
onSend={handleWelcomeSend}
|
||||||
disabled={booting}
|
disabled={booting}
|
||||||
|
inputAriaLabel={composerInputAriaLabel}
|
||||||
isStreaming={turnActive}
|
isStreaming={turnActive}
|
||||||
placeholder={
|
placeholder={
|
||||||
booting
|
booting
|
||||||
@@ -1508,28 +1530,33 @@ export function ThreadShell({
|
|||||||
/>
|
/>
|
||||||
) : undefined;
|
) : undefined;
|
||||||
|
|
||||||
|
const threadHeader = !hideHeader ? (
|
||||||
|
<ThreadHeader
|
||||||
|
title={title}
|
||||||
|
onToggleSidebar={onToggleSidebar}
|
||||||
|
theme={theme}
|
||||||
|
onToggleTheme={onToggleTheme}
|
||||||
|
hideSidebarToggleForHostChrome={hideSidebarToggleForHostChrome}
|
||||||
|
hideSidebarToggle={hideSidebarToggle}
|
||||||
|
hostChromeTitleInset={hostChromeTitleInset}
|
||||||
|
hideThemeButton={hideThemeButton}
|
||||||
|
hideTitle={hideHeaderTitle}
|
||||||
|
actions={headerActions}
|
||||||
|
minimal={!session && !loading}
|
||||||
|
promptNavigatorAction={promptNavigatorAction}
|
||||||
|
sessionInfoAction={sessionInfoAction}
|
||||||
|
temporaryChatEnabled={temporaryChatEnabled}
|
||||||
|
temporaryChatDisabled={booting || turnActive}
|
||||||
|
onTemporaryChatEnabledChange={
|
||||||
|
showTemporaryChatControl ? onTemporaryChatEnabledChange : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section ref={shellRef} className="relative flex min-h-0 flex-1 overflow-hidden">
|
<section ref={shellRef} className="relative flex min-h-0 flex-1 overflow-hidden">
|
||||||
<div className="relative flex min-w-0 flex-1 flex-col overflow-hidden">
|
<div className="relative flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||||
{!hideHeader ? (
|
{headerPortalTarget === undefined ? threadHeader : null}
|
||||||
<ThreadHeader
|
|
||||||
title={title}
|
|
||||||
onToggleSidebar={onToggleSidebar}
|
|
||||||
theme={theme}
|
|
||||||
onToggleTheme={onToggleTheme}
|
|
||||||
hideSidebarToggleForHostChrome={hideSidebarToggleForHostChrome}
|
|
||||||
hostChromeTitleInset={hostChromeTitleInset}
|
|
||||||
hideThemeButton={hideThemeButton}
|
|
||||||
minimal={!session && !loading}
|
|
||||||
promptNavigatorAction={promptNavigatorAction}
|
|
||||||
sessionInfoAction={sessionInfoAction}
|
|
||||||
temporaryChatEnabled={temporaryChatEnabled}
|
|
||||||
temporaryChatDisabled={booting || turnActive}
|
|
||||||
onTemporaryChatEnabledChange={
|
|
||||||
showTemporaryChatControl ? onTemporaryChatEnabledChange : undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
<FilePreviewAvailabilityProvider
|
<FilePreviewAvailabilityProvider
|
||||||
resolve={historyKey ? resolveFilePreviewAvailability : undefined}
|
resolve={historyKey ? resolveFilePreviewAvailability : undefined}
|
||||||
>
|
>
|
||||||
@@ -1539,7 +1566,7 @@ export function ThreadShell({
|
|||||||
temporary={temporary}
|
temporary={temporary}
|
||||||
isStreaming={turnActive}
|
isStreaming={turnActive}
|
||||||
emptyState={emptyState}
|
emptyState={emptyState}
|
||||||
composer={composer}
|
composer={composerPortalTarget === undefined ? composer : null}
|
||||||
activeTurnId={viewportTurnId}
|
activeTurnId={viewportTurnId}
|
||||||
activeTurnStartedHere={activeTurnStartedHere}
|
activeTurnStartedHere={activeTurnStartedHere}
|
||||||
conversationKey={historyKey}
|
conversationKey={historyKey}
|
||||||
@@ -1559,6 +1586,19 @@ export function ThreadShell({
|
|||||||
/>
|
/>
|
||||||
</FilePreviewAvailabilityProvider>
|
</FilePreviewAvailabilityProvider>
|
||||||
</div>
|
</div>
|
||||||
|
{headerPortalTarget && headerActive
|
||||||
|
? createPortal(threadHeader, headerPortalTarget)
|
||||||
|
: null}
|
||||||
|
{composerPortalTarget ? createPortal(
|
||||||
|
<div
|
||||||
|
hidden={!composerActive}
|
||||||
|
aria-hidden={!composerActive}
|
||||||
|
data-testid={composerActive ? "active-pane-composer" : undefined}
|
||||||
|
>
|
||||||
|
{composer}
|
||||||
|
</div>,
|
||||||
|
composerPortalTarget,
|
||||||
|
) : null}
|
||||||
{filePreviewPath && historyKey ? (
|
{filePreviewPath && historyKey ? (
|
||||||
<FilePreviewPanel
|
<FilePreviewPanel
|
||||||
sessionKey={historyKey}
|
sessionKey={historyKey}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ interface ThreadViewportProps {
|
|||||||
messages: UIMessage[];
|
messages: UIMessage[];
|
||||||
temporary?: boolean;
|
temporary?: boolean;
|
||||||
isStreaming: boolean;
|
isStreaming: boolean;
|
||||||
composer: ReactNode;
|
composer?: ReactNode;
|
||||||
emptyState?: ReactNode;
|
emptyState?: ReactNode;
|
||||||
scrollToBottomSignal?: number;
|
scrollToBottomSignal?: number;
|
||||||
activeTurnId?: string | null;
|
activeTurnId?: string | null;
|
||||||
@@ -61,6 +61,7 @@ interface ThreadViewportProps {
|
|||||||
const NEAR_BOTTOM_PX = 48;
|
const NEAR_BOTTOM_PX = 48;
|
||||||
const NEAR_TOP_PX = 96;
|
const NEAR_TOP_PX = 96;
|
||||||
const DEFAULT_SCROLL_BUTTON_BOTTOM_PX = 192;
|
const DEFAULT_SCROLL_BUTTON_BOTTOM_PX = 192;
|
||||||
|
const EXTERNAL_COMPOSER_SCROLL_BUTTON_BOTTOM_PX = 16;
|
||||||
const SCROLL_BUTTON_COMPOSER_GAP_PX = 16;
|
const SCROLL_BUTTON_COMPOSER_GAP_PX = 16;
|
||||||
const SOFT_KEYBOARD_MIN_INSET_PX = 80;
|
const SOFT_KEYBOARD_MIN_INSET_PX = 80;
|
||||||
export const INITIAL_HISTORY_WINDOW = 160;
|
export const INITIAL_HISTORY_WINDOW = 160;
|
||||||
@@ -266,11 +267,14 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
|||||||
forkBoundaryMessageCount !== null && forkBoundaryMessageCount > hiddenMessageCount
|
forkBoundaryMessageCount !== null && forkBoundaryMessageCount > hiddenMessageCount
|
||||||
? forkBoundaryMessageCount - hiddenMessageCount
|
? forkBoundaryMessageCount - hiddenMessageCount
|
||||||
: null;
|
: null;
|
||||||
|
const hasComposer = composer !== null && composer !== undefined;
|
||||||
const scrollButtonBottom =
|
const scrollButtonBottom =
|
||||||
keyboardInsetBottom
|
keyboardInsetBottom
|
||||||
+ (composerDockHeight > 0
|
+ (composerDockHeight > 0
|
||||||
? composerDockHeight + SCROLL_BUTTON_COMPOSER_GAP_PX
|
? composerDockHeight + SCROLL_BUTTON_COMPOSER_GAP_PX
|
||||||
: DEFAULT_SCROLL_BUTTON_BOTTOM_PX);
|
: hasComposer
|
||||||
|
? DEFAULT_SCROLL_BUTTON_BOTTOM_PX
|
||||||
|
: EXTERNAL_COMPOSER_SCROLL_BUTTON_BOTTOM_PX);
|
||||||
const scrollViewportStyle =
|
const scrollViewportStyle =
|
||||||
keyboardInsetBottom > 0 ? { bottom: keyboardInsetBottom } : undefined;
|
keyboardInsetBottom > 0 ? { bottom: keyboardInsetBottom } : undefined;
|
||||||
|
|
||||||
@@ -661,7 +665,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
|||||||
<div
|
<div
|
||||||
ref={contentRef}
|
ref={contentRef}
|
||||||
data-testid={!hasMessages ? "thread-welcome-layout" : undefined}
|
data-testid={!hasMessages ? "thread-welcome-layout" : undefined}
|
||||||
data-layout={hasMessages ? "thread" : "hero"}
|
data-layout={hasComposer ? (hasMessages ? "thread" : "hero") : "external"}
|
||||||
className={cn(
|
className={cn(
|
||||||
"thread-layout mx-auto grid min-h-full w-full",
|
"thread-layout mx-auto grid min-h-full w-full",
|
||||||
hasMessages
|
hasMessages
|
||||||
@@ -699,58 +703,67 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
|||||||
<div ref={bottomRef} aria-hidden className="h-px shrink-0" />
|
<div ref={bottomRef} aria-hidden className="h-px shrink-0" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="row-start-1 flex min-h-0 min-w-0 w-full items-center justify-center sm:items-end sm:pb-8">
|
<div
|
||||||
|
className={cn(
|
||||||
|
"row-start-1 flex min-h-0 min-w-0 w-full items-center justify-center",
|
||||||
|
hasComposer && "sm:items-end sm:pb-8",
|
||||||
|
)}
|
||||||
|
>
|
||||||
{emptyState}
|
{emptyState}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div
|
{hasComposer ? (
|
||||||
ref={composerDockRef}
|
|
||||||
data-testid="thread-composer-dock"
|
|
||||||
onInputCapture={(event) => {
|
|
||||||
if (event.target instanceof HTMLTextAreaElement) {
|
|
||||||
composerInputScrollTopRef.current = scrollRef.current?.scrollTop ?? null;
|
|
||||||
threadMotionRef.current?.handleComposerInput();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onInput={(event) => {
|
|
||||||
if (!(event.target instanceof HTMLTextAreaElement)) return;
|
|
||||||
const previousScrollTop = composerInputScrollTopRef.current;
|
|
||||||
composerInputScrollTopRef.current = null;
|
|
||||||
const scrollEl = scrollRef.current;
|
|
||||||
if (scrollEl && previousScrollTop !== null) {
|
|
||||||
// Textarea autosizing briefly collapses to `height: auto` while
|
|
||||||
// measuring. Chrome can clamp the sibling thread scrollport in
|
|
||||||
// that intermediate layout; restore it before paint, then let
|
|
||||||
// ResizeObserver handle any real final composer height change.
|
|
||||||
scrollEl.scrollTop = previousScrollTop;
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className={cn(
|
|
||||||
"row-start-2 z-10 w-full",
|
|
||||||
hasMessages ? "relative bg-background" : "relative self-center",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
|
ref={composerDockRef}
|
||||||
|
data-testid="thread-composer-dock"
|
||||||
|
onInputCapture={(event) => {
|
||||||
|
if (event.target instanceof HTMLTextAreaElement) {
|
||||||
|
composerInputScrollTopRef.current = scrollRef.current?.scrollTop ?? null;
|
||||||
|
threadMotionRef.current?.handleComposerInput();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onInput={(event) => {
|
||||||
|
if (!(event.target instanceof HTMLTextAreaElement)) return;
|
||||||
|
const previousScrollTop = composerInputScrollTopRef.current;
|
||||||
|
composerInputScrollTopRef.current = null;
|
||||||
|
const scrollEl = scrollRef.current;
|
||||||
|
if (scrollEl && previousScrollTop !== null) {
|
||||||
|
// Textarea autosizing briefly collapses to `height: auto` while
|
||||||
|
// measuring. Chrome can clamp the sibling thread scrollport in
|
||||||
|
// that intermediate layout; restore it before paint, then let
|
||||||
|
// ResizeObserver handle any real final composer height change.
|
||||||
|
scrollEl.scrollTop = previousScrollTop;
|
||||||
|
}
|
||||||
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
hasMessages
|
"row-start-2 z-10 w-full",
|
||||||
? "px-3 pb-[calc(0.75rem+env(safe-area-inset-bottom))] sm:px-4"
|
hasMessages ? "relative bg-background" : "relative self-center",
|
||||||
: "",
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
data-testid="thread-composer-motion"
|
className={cn(
|
||||||
className="mx-auto w-full max-w-[58rem]"
|
hasMessages
|
||||||
|
? "px-3 pb-[calc(0.75rem+env(safe-area-inset-bottom))] sm:px-4"
|
||||||
|
: "",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{composer}
|
<div
|
||||||
|
data-testid="thread-composer-motion"
|
||||||
|
className="mx-auto w-full max-w-[58rem]"
|
||||||
|
>
|
||||||
|
{composer}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : null}
|
||||||
|
|
||||||
<div
|
{hasComposer ? (
|
||||||
aria-hidden
|
<div
|
||||||
className="thread-layout-spacer row-start-3 min-h-0 overflow-hidden"
|
aria-hidden
|
||||||
/>
|
className="thread-layout-spacer row-start-3 min-h-0 overflow-hidden"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{!hasMessages ? <div ref={bottomRef} aria-hidden className="h-px" /> : null}
|
{!hasMessages ? <div ref={bottomRef} aria-hidden className="h-px" /> : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||||
import { Circle } from "lucide-react";
|
import { ChevronRight, Circle } from "lucide-react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
floatingItemClassName,
|
floatingItemClassName,
|
||||||
@@ -13,6 +13,7 @@ import { cn } from "@/lib/utils";
|
|||||||
const DropdownMenu = DropdownMenuPrimitive.Root;
|
const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
||||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
||||||
|
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
|
||||||
|
|
||||||
const menuItemClassName =
|
const menuItemClassName =
|
||||||
`${floatingItemClassName} ${floatingItemFocusClassName} cursor-default data-[disabled]:pointer-events-none data-[disabled]:opacity-50`;
|
`${floatingItemClassName} ${floatingItemFocusClassName} cursor-default data-[disabled]:pointer-events-none data-[disabled]:opacity-50`;
|
||||||
@@ -115,6 +116,41 @@ const DropdownMenuSeparator = React.forwardRef<
|
|||||||
));
|
));
|
||||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuSubTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||||
|
inset?: boolean;
|
||||||
|
}
|
||||||
|
>(({ className, inset, children, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(menuItemClassName, inset && "pl-8", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRight className="ml-auto h-3.5 w-3.5 text-muted-foreground" aria-hidden />
|
||||||
|
</DropdownMenuPrimitive.SubTrigger>
|
||||||
|
));
|
||||||
|
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuSubContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||||
|
>(({ className, sideOffset = 6, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.SubContent
|
||||||
|
ref={ref}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
floatingSurfaceClassName,
|
||||||
|
floatingSurfaceMotionClassName,
|
||||||
|
"max-h-[min(var(--radix-dropdown-menu-content-available-height),22rem)] min-w-[11rem] overflow-y-auto",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
|
||||||
|
|
||||||
export {
|
export {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -123,5 +159,8 @@ export {
|
|||||||
DropdownMenuRadioGroup,
|
DropdownMenuRadioGroup,
|
||||||
DropdownMenuRadioItem,
|
DropdownMenuRadioItem,
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,830 @@
|
|||||||
|
import {
|
||||||
|
Columns2,
|
||||||
|
Grid2X2,
|
||||||
|
PanelLeft,
|
||||||
|
PanelsTopLeft,
|
||||||
|
Plus,
|
||||||
|
Rows2,
|
||||||
|
type LucideIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
type FocusEvent,
|
||||||
|
type KeyboardEvent,
|
||||||
|
type PointerEvent as ReactPointerEvent,
|
||||||
|
type ReactNode,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
import {
|
||||||
|
createWorkbenchLayoutGeometry,
|
||||||
|
resizeHandleRatio,
|
||||||
|
resizeHandleStyle,
|
||||||
|
splitRatioBounds,
|
||||||
|
type WorkbenchResizeHandle,
|
||||||
|
} from "@/components/workbench/workbench-layout";
|
||||||
|
import type { WorkbenchLayout } from "@/components/workbench/workbench-model";
|
||||||
|
import { useMediaQuery } from "@/hooks/useMediaQuery";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export interface WorkbenchPane {
|
||||||
|
key: string;
|
||||||
|
reactKey?: string;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PaneRenderContext {
|
||||||
|
active: boolean;
|
||||||
|
headerPortalTarget: HTMLElement | null | undefined;
|
||||||
|
composerPortalTarget: HTMLElement | null | undefined;
|
||||||
|
headerActions: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PaneWorkbenchProps {
|
||||||
|
panes: WorkbenchPane[];
|
||||||
|
activePaneKey: string;
|
||||||
|
layout: WorkbenchLayout;
|
||||||
|
chrome?: boolean;
|
||||||
|
showLayoutControl: boolean;
|
||||||
|
addPaneDisabled?: boolean;
|
||||||
|
addPaneDisabledLabel?: string;
|
||||||
|
onActivatePane: (key: string) => void;
|
||||||
|
onAddPane: () => void;
|
||||||
|
onLayoutChange: (layout: WorkbenchLayout) => void;
|
||||||
|
onPaneOrderChange: (paneKeys: string[]) => void;
|
||||||
|
splitRatios?: number[];
|
||||||
|
onSplitRatiosChange?: (splitRatios: number[]) => void;
|
||||||
|
renderPane: (pane: WorkbenchPane, context: PaneRenderContext) => ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LAYOUT_MOTION_DURATION_MS = 260;
|
||||||
|
const LAYOUT_MOTION_EASING = "cubic-bezier(0.2, 0, 0, 1)";
|
||||||
|
const EMPTY_SPLIT_RATIOS: number[] = [];
|
||||||
|
const IGNORE_SPLIT_RATIO_CHANGE = () => {};
|
||||||
|
|
||||||
|
const LAYOUT_CONTROLS: Array<{
|
||||||
|
icon: LucideIcon;
|
||||||
|
layout: WorkbenchLayout;
|
||||||
|
label: string;
|
||||||
|
}> = [
|
||||||
|
{ icon: Columns2, layout: "columns", label: "Columns" },
|
||||||
|
{ icon: Rows2, layout: "rows", label: "Rows" },
|
||||||
|
{ icon: Grid2X2, layout: "grid", label: "Grid" },
|
||||||
|
{ icon: PanelsTopLeft, layout: "bsp", label: "BSP" },
|
||||||
|
{ icon: PanelLeft, layout: "main-stack", label: "Main and stack" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function isPaneAction(target: EventTarget | null): boolean {
|
||||||
|
return target instanceof Element
|
||||||
|
&& target.closest("[data-workbench-pane-action]") !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function movePaneToSlot(
|
||||||
|
paneKeys: readonly string[],
|
||||||
|
paneKey: string,
|
||||||
|
targetKey: string,
|
||||||
|
): string[] {
|
||||||
|
const fromIndex = paneKeys.indexOf(paneKey);
|
||||||
|
const targetIndex = paneKeys.indexOf(targetKey);
|
||||||
|
if (fromIndex < 0 || targetIndex < 0 || fromIndex === targetIndex) return [...paneKeys];
|
||||||
|
const next = paneKeys.filter((key) => key !== paneKey);
|
||||||
|
next.splice(targetIndex, 0, paneKey);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function paneSlotAtPoint(
|
||||||
|
rects: readonly DOMRect[],
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
): number | null {
|
||||||
|
for (const [index, rect] of rects.entries()) {
|
||||||
|
if (
|
||||||
|
x >= rect.left
|
||||||
|
&& x <= rect.right
|
||||||
|
&& y >= rect.top
|
||||||
|
&& y <= rect.bottom
|
||||||
|
) return index;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function paneInDirection(
|
||||||
|
rects: ReadonlyMap<string, DOMRect>,
|
||||||
|
paneKey: string,
|
||||||
|
direction: "left" | "right" | "up" | "down",
|
||||||
|
): string | null {
|
||||||
|
const source = rects.get(paneKey);
|
||||||
|
if (!source) return null;
|
||||||
|
const sourceX = source.left + source.width / 2;
|
||||||
|
const sourceY = source.top + source.height / 2;
|
||||||
|
let best: { key: string; score: number } | null = null;
|
||||||
|
for (const [key, rect] of rects) {
|
||||||
|
if (key === paneKey) continue;
|
||||||
|
const deltaX = rect.left + rect.width / 2 - sourceX;
|
||||||
|
const deltaY = rect.top + rect.height / 2 - sourceY;
|
||||||
|
const primary = direction === "left"
|
||||||
|
? -deltaX
|
||||||
|
: direction === "right"
|
||||||
|
? deltaX
|
||||||
|
: direction === "up"
|
||||||
|
? -deltaY
|
||||||
|
: deltaY;
|
||||||
|
if (primary <= 1) continue;
|
||||||
|
const cross = direction === "left" || direction === "right"
|
||||||
|
? Math.abs(deltaY)
|
||||||
|
: Math.abs(deltaX);
|
||||||
|
const score = primary + cross * 0.35;
|
||||||
|
if (!best || score < best.score) best = { key, score };
|
||||||
|
}
|
||||||
|
return best?.key ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function HeaderIconButton({
|
||||||
|
disabled,
|
||||||
|
disabledLabel,
|
||||||
|
icon: Icon,
|
||||||
|
label,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
disabled?: boolean;
|
||||||
|
disabledLabel?: string;
|
||||||
|
icon: LucideIcon;
|
||||||
|
label: string;
|
||||||
|
onClick: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<span className="inline-flex">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label={label}
|
||||||
|
title={disabled ? disabledLabel : undefined}
|
||||||
|
onClick={onClick}
|
||||||
|
className="host-no-drag h-8 w-8 shrink-0 rounded-full text-muted-foreground/85 hover:bg-accent/40 hover:text-foreground"
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4" aria-hidden />
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom">{disabled ? disabledLabel ?? label : label}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PaneWorkbench({
|
||||||
|
panes,
|
||||||
|
activePaneKey,
|
||||||
|
layout,
|
||||||
|
chrome = true,
|
||||||
|
showLayoutControl,
|
||||||
|
addPaneDisabled = false,
|
||||||
|
addPaneDisabledLabel,
|
||||||
|
onActivatePane,
|
||||||
|
onAddPane,
|
||||||
|
onLayoutChange,
|
||||||
|
onPaneOrderChange,
|
||||||
|
splitRatios = EMPTY_SPLIT_RATIOS,
|
||||||
|
onSplitRatiosChange = IGNORE_SPLIT_RATIO_CHANGE,
|
||||||
|
renderPane,
|
||||||
|
}: PaneWorkbenchProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const compact = useMediaQuery("(max-width: 767px)");
|
||||||
|
const effectiveLayout = layout;
|
||||||
|
const [headerPortalTarget, setHeaderPortalTarget] = useState<HTMLElement | null>(null);
|
||||||
|
const [composerPortalTarget, setComposerPortalTarget] = useState<HTMLElement | null>(null);
|
||||||
|
const gridRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const paneRefs = useRef(new Map<string, HTMLElement>());
|
||||||
|
const lastRectsRef = useRef(new Map<string, DOMRect>());
|
||||||
|
const pendingRectsRef = useRef<Map<string, DOMRect> | null>(null);
|
||||||
|
const animationsRef = useRef(new Map<string, Animation>());
|
||||||
|
const sourceSplitRatiosKey = splitRatios.join("\u0000");
|
||||||
|
const [previewSplitRatios, setPreviewSplitRatios] = useState(splitRatios);
|
||||||
|
const previewSplitRatiosRef = useRef(splitRatios);
|
||||||
|
const resizeGestureRef = useRef<{
|
||||||
|
pointerId: number;
|
||||||
|
handle: WorkbenchResizeHandle;
|
||||||
|
changed: boolean;
|
||||||
|
} | null>(null);
|
||||||
|
const [resizingRatioIndex, setResizingRatioIndex] = useState<number | null>(null);
|
||||||
|
const sourcePaneOrder = useMemo(
|
||||||
|
() => panes.map((pane) => pane.key),
|
||||||
|
[panes],
|
||||||
|
);
|
||||||
|
const sourcePaneOrderKey = sourcePaneOrder.join("\u0000");
|
||||||
|
const [previewPaneKeys, setPreviewPaneKeys] = useState(sourcePaneOrder);
|
||||||
|
const previewPaneKeysRef = useRef(sourcePaneOrder);
|
||||||
|
const dragGestureRef = useRef<{
|
||||||
|
pointerId: number;
|
||||||
|
paneKey: string;
|
||||||
|
startX: number;
|
||||||
|
startY: number;
|
||||||
|
slotRects: DOMRect[];
|
||||||
|
started: boolean;
|
||||||
|
} | null>(null);
|
||||||
|
const [draggingPaneKey, setDraggingPaneKey] = useState<string | null>(null);
|
||||||
|
const orderedPanes = useMemo(() => {
|
||||||
|
const byKey = new Map(panes.map((pane) => [pane.key, pane]));
|
||||||
|
return [
|
||||||
|
...previewPaneKeys.map((key) => byKey.get(key)).filter(
|
||||||
|
(pane): pane is WorkbenchPane => pane !== undefined,
|
||||||
|
),
|
||||||
|
...panes.filter((pane) => !previewPaneKeys.includes(pane.key)),
|
||||||
|
];
|
||||||
|
}, [panes, previewPaneKeys]);
|
||||||
|
const displayedPanes = useMemo(() => {
|
||||||
|
if (!compact) return orderedPanes;
|
||||||
|
const activePane = orderedPanes.find((pane) => pane.key === activePaneKey)
|
||||||
|
?? orderedPanes[0];
|
||||||
|
return activePane ? [activePane] : [];
|
||||||
|
}, [activePaneKey, compact, orderedPanes]);
|
||||||
|
const paneOrder = displayedPanes.map((pane) => pane.key).join("\u0000");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (dragGestureRef.current) return;
|
||||||
|
previewPaneKeysRef.current = sourcePaneOrder;
|
||||||
|
setPreviewPaneKeys(sourcePaneOrder);
|
||||||
|
}, [sourcePaneOrder, sourcePaneOrderKey]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (resizeGestureRef.current) return;
|
||||||
|
previewSplitRatiosRef.current = splitRatios;
|
||||||
|
setPreviewSplitRatios(splitRatios);
|
||||||
|
}, [sourceSplitRatiosKey, splitRatios]);
|
||||||
|
|
||||||
|
const measurePanes = useCallback(() => {
|
||||||
|
const rects = new Map<string, DOMRect>();
|
||||||
|
for (const [key, element] of paneRefs.current) {
|
||||||
|
if (!element.hidden) rects.set(key, element.getBoundingClientRect());
|
||||||
|
}
|
||||||
|
return rects;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const captureLayout = useCallback(() => {
|
||||||
|
pendingRectsRef.current = measurePanes();
|
||||||
|
for (const animation of animationsRef.current.values()) animation.cancel();
|
||||||
|
animationsRef.current.clear();
|
||||||
|
}, [measurePanes]);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const previousRects = pendingRectsRef.current ?? lastRectsRef.current;
|
||||||
|
pendingRectsRef.current = null;
|
||||||
|
const nextRects = measurePanes();
|
||||||
|
const reduceMotion = typeof window.matchMedia === "function"
|
||||||
|
&& window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||||
|
|
||||||
|
if (!reduceMotion) {
|
||||||
|
for (const [key, nextRect] of nextRects) {
|
||||||
|
const previousRect = previousRects.get(key);
|
||||||
|
const element = paneRefs.current.get(key);
|
||||||
|
if (!element) continue;
|
||||||
|
if (!previousRect) {
|
||||||
|
if (previousRects.size === 0 || typeof element.animate !== "function") continue;
|
||||||
|
const animation = element.animate(
|
||||||
|
[
|
||||||
|
{ opacity: 0, transform: "translateY(5px) scale(0.995)" },
|
||||||
|
{ opacity: 1, transform: "translateY(0) scale(1)" },
|
||||||
|
],
|
||||||
|
{
|
||||||
|
duration: 180,
|
||||||
|
easing: LAYOUT_MOTION_EASING,
|
||||||
|
fill: "backwards",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
animationsRef.current.set(key, animation);
|
||||||
|
animation.addEventListener("finish", () => {
|
||||||
|
if (animationsRef.current.get(key) === animation) {
|
||||||
|
animationsRef.current.delete(key);
|
||||||
|
}
|
||||||
|
}, { once: true });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (previousRect.width === 0 || previousRect.height === 0) continue;
|
||||||
|
const deltaX = previousRect.left - nextRect.left;
|
||||||
|
const deltaY = previousRect.top - nextRect.top;
|
||||||
|
const scaleX = previousRect.width / nextRect.width;
|
||||||
|
const scaleY = previousRect.height / nextRect.height;
|
||||||
|
if (
|
||||||
|
Math.abs(deltaX) < 0.5
|
||||||
|
&& Math.abs(deltaY) < 0.5
|
||||||
|
&& Math.abs(scaleX - 1) < 0.002
|
||||||
|
&& Math.abs(scaleY - 1) < 0.002
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (typeof element.animate !== "function") continue;
|
||||||
|
const animation = element.animate(
|
||||||
|
[
|
||||||
|
{ transform: `translate(${deltaX}px, ${deltaY}px) scale(${scaleX}, ${scaleY})` },
|
||||||
|
{ transform: "translate(0, 0) scale(1, 1)" },
|
||||||
|
],
|
||||||
|
{
|
||||||
|
duration: LAYOUT_MOTION_DURATION_MS,
|
||||||
|
easing: LAYOUT_MOTION_EASING,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
animationsRef.current.set(key, animation);
|
||||||
|
animation.addEventListener("finish", () => {
|
||||||
|
if (animationsRef.current.get(key) === animation) {
|
||||||
|
animationsRef.current.delete(key);
|
||||||
|
}
|
||||||
|
}, { once: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lastRectsRef.current = nextRects;
|
||||||
|
}, [activePaneKey, effectiveLayout, measurePanes, paneOrder]);
|
||||||
|
|
||||||
|
useEffect(() => () => {
|
||||||
|
for (const animation of animationsRef.current.values()) animation.cancel();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const activatePane = useCallback((key: string, target: EventTarget | null) => {
|
||||||
|
if (key === activePaneKey || isPaneAction(target)) return;
|
||||||
|
captureLayout();
|
||||||
|
onActivatePane(key);
|
||||||
|
}, [activePaneKey, captureLayout, onActivatePane]);
|
||||||
|
|
||||||
|
const handlePanePointerDown = useCallback((
|
||||||
|
key: string,
|
||||||
|
event: ReactPointerEvent<HTMLElement>,
|
||||||
|
) => {
|
||||||
|
activatePane(key, event.target);
|
||||||
|
}, [activatePane]);
|
||||||
|
|
||||||
|
const handlePaneFocus = useCallback((key: string, event: FocusEvent<HTMLElement>) => {
|
||||||
|
activatePane(key, event.target);
|
||||||
|
}, [activatePane]);
|
||||||
|
|
||||||
|
const applyPaneOrder = useCallback((paneKey: string, targetKey: string) => {
|
||||||
|
const next = movePaneToSlot(previewPaneKeysRef.current, paneKey, targetKey);
|
||||||
|
if (next.every((key, index) => previewPaneKeysRef.current[index] === key)) return;
|
||||||
|
captureLayout();
|
||||||
|
previewPaneKeysRef.current = next;
|
||||||
|
setPreviewPaneKeys(next);
|
||||||
|
onPaneOrderChange(next);
|
||||||
|
}, [captureLayout, onPaneOrderChange]);
|
||||||
|
|
||||||
|
const handleMovePointerDown = useCallback((
|
||||||
|
paneKey: string,
|
||||||
|
event: ReactPointerEvent<HTMLButtonElement>,
|
||||||
|
) => {
|
||||||
|
if (event.button !== 0 || compact || panes.length < 2) return;
|
||||||
|
const paneRects = measurePanes();
|
||||||
|
dragGestureRef.current = {
|
||||||
|
pointerId: event.pointerId,
|
||||||
|
paneKey,
|
||||||
|
startX: event.clientX,
|
||||||
|
startY: event.clientY,
|
||||||
|
slotRects: previewPaneKeysRef.current
|
||||||
|
.map((key) => paneRects.get(key))
|
||||||
|
.filter((rect): rect is DOMRect => rect !== undefined),
|
||||||
|
started: false,
|
||||||
|
};
|
||||||
|
setDraggingPaneKey(paneKey);
|
||||||
|
}, [compact, measurePanes, panes.length]);
|
||||||
|
|
||||||
|
const handleMovePointerMove = useCallback((event: globalThis.PointerEvent) => {
|
||||||
|
const gesture = dragGestureRef.current;
|
||||||
|
if (!gesture || gesture.pointerId !== event.pointerId) return;
|
||||||
|
const distance = Math.hypot(
|
||||||
|
event.clientX - gesture.startX,
|
||||||
|
event.clientY - gesture.startY,
|
||||||
|
);
|
||||||
|
if (!gesture.started) {
|
||||||
|
if (distance < 8) return;
|
||||||
|
gesture.started = true;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
const targetIndex = paneSlotAtPoint(gesture.slotRects, event.clientX, event.clientY);
|
||||||
|
const targetKey = targetIndex === null
|
||||||
|
? null
|
||||||
|
: previewPaneKeysRef.current[targetIndex] ?? null;
|
||||||
|
if (targetKey) applyPaneOrder(gesture.paneKey, targetKey);
|
||||||
|
}, [applyPaneOrder]);
|
||||||
|
|
||||||
|
const finishMoveGesture = useCallback(() => {
|
||||||
|
if (!dragGestureRef.current) return;
|
||||||
|
dragGestureRef.current = null;
|
||||||
|
setDraggingPaneKey(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!draggingPaneKey) return;
|
||||||
|
|
||||||
|
const handlePointerMove = (event: globalThis.PointerEvent) => {
|
||||||
|
const gesture = dragGestureRef.current;
|
||||||
|
if (!gesture || gesture.pointerId !== event.pointerId) return;
|
||||||
|
if ((event.buttons & 1) === 0) {
|
||||||
|
finishMoveGesture();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handleMovePointerMove(event);
|
||||||
|
};
|
||||||
|
const handlePointerEnd = (event: globalThis.PointerEvent) => {
|
||||||
|
if (dragGestureRef.current?.pointerId === event.pointerId) finishMoveGesture();
|
||||||
|
};
|
||||||
|
const root = document.documentElement;
|
||||||
|
const previousCursor = root.style.cursor;
|
||||||
|
root.style.cursor = "grabbing";
|
||||||
|
window.addEventListener("pointermove", handlePointerMove, { passive: false });
|
||||||
|
window.addEventListener("pointerup", handlePointerEnd);
|
||||||
|
window.addEventListener("pointercancel", handlePointerEnd);
|
||||||
|
window.addEventListener("blur", finishMoveGesture);
|
||||||
|
return () => {
|
||||||
|
root.style.cursor = previousCursor;
|
||||||
|
window.removeEventListener("pointermove", handlePointerMove);
|
||||||
|
window.removeEventListener("pointerup", handlePointerEnd);
|
||||||
|
window.removeEventListener("pointercancel", handlePointerEnd);
|
||||||
|
window.removeEventListener("blur", finishMoveGesture);
|
||||||
|
};
|
||||||
|
}, [draggingPaneKey, finishMoveGesture, handleMovePointerMove]);
|
||||||
|
|
||||||
|
const handleMoveKeyDown = useCallback((
|
||||||
|
paneKey: string,
|
||||||
|
event: KeyboardEvent<HTMLButtonElement>,
|
||||||
|
) => {
|
||||||
|
const direction = (() => {
|
||||||
|
switch (event.key) {
|
||||||
|
case "ArrowLeft": return "left";
|
||||||
|
case "ArrowRight": return "right";
|
||||||
|
case "ArrowUp": return "up";
|
||||||
|
case "ArrowDown": return "down";
|
||||||
|
default: return null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
if (!direction) return;
|
||||||
|
const targetKey = paneInDirection(measurePanes(), paneKey, direction);
|
||||||
|
if (!targetKey) return;
|
||||||
|
event.preventDefault();
|
||||||
|
const next = movePaneToSlot(previewPaneKeysRef.current, paneKey, targetKey);
|
||||||
|
captureLayout();
|
||||||
|
previewPaneKeysRef.current = next;
|
||||||
|
setPreviewPaneKeys(next);
|
||||||
|
onPaneOrderChange(next);
|
||||||
|
}, [captureLayout, measurePanes, onPaneOrderChange]);
|
||||||
|
|
||||||
|
const layoutGeometry = useMemo(() => createWorkbenchLayoutGeometry(
|
||||||
|
effectiveLayout,
|
||||||
|
displayedPanes.length,
|
||||||
|
previewSplitRatios,
|
||||||
|
), [displayedPanes.length, effectiveLayout, previewSplitRatios]);
|
||||||
|
|
||||||
|
const handleResizePointerDown = useCallback((
|
||||||
|
handle: WorkbenchResizeHandle,
|
||||||
|
event: ReactPointerEvent<HTMLButtonElement>,
|
||||||
|
) => {
|
||||||
|
if (event.button !== 0 || compact) return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
previewSplitRatiosRef.current = layoutGeometry.splitRatios;
|
||||||
|
setPreviewSplitRatios(layoutGeometry.splitRatios);
|
||||||
|
resizeGestureRef.current = {
|
||||||
|
pointerId: event.pointerId,
|
||||||
|
handle,
|
||||||
|
changed: false,
|
||||||
|
};
|
||||||
|
setResizingRatioIndex(handle.ratioIndex);
|
||||||
|
}, [compact, layoutGeometry.splitRatios]);
|
||||||
|
|
||||||
|
const handleResizePointerMove = useCallback((event: globalThis.PointerEvent) => {
|
||||||
|
const gesture = resizeGestureRef.current;
|
||||||
|
const grid = gridRef.current;
|
||||||
|
if (!gesture || !grid || gesture.pointerId !== event.pointerId) return;
|
||||||
|
const rect = grid.getBoundingClientRect();
|
||||||
|
const axisExtent = gesture.handle.axis === "vertical" ? rect.width : rect.height;
|
||||||
|
if (axisExtent <= 0) return;
|
||||||
|
const normalizedPosition = gesture.handle.axis === "vertical"
|
||||||
|
? (event.clientX - rect.left) / rect.width
|
||||||
|
: (event.clientY - rect.top) / rect.height;
|
||||||
|
const ratio = resizeHandleRatio(gesture.handle, normalizedPosition, axisExtent);
|
||||||
|
const current = previewSplitRatiosRef.current;
|
||||||
|
if (Math.abs((current[gesture.handle.ratioIndex] ?? 0) - ratio) < 0.0001) return;
|
||||||
|
event.preventDefault();
|
||||||
|
const next = [...current];
|
||||||
|
next[gesture.handle.ratioIndex] = ratio;
|
||||||
|
gesture.changed = true;
|
||||||
|
previewSplitRatiosRef.current = next;
|
||||||
|
setPreviewSplitRatios(next);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const finishResizeGesture = useCallback(() => {
|
||||||
|
const gesture = resizeGestureRef.current;
|
||||||
|
if (!gesture) return;
|
||||||
|
resizeGestureRef.current = null;
|
||||||
|
setResizingRatioIndex(null);
|
||||||
|
if (gesture.changed) onSplitRatiosChange([...previewSplitRatiosRef.current]);
|
||||||
|
}, [onSplitRatiosChange]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (resizingRatioIndex === null) return;
|
||||||
|
const handlePointerMove = (event: globalThis.PointerEvent) => {
|
||||||
|
const gesture = resizeGestureRef.current;
|
||||||
|
if (!gesture || gesture.pointerId !== event.pointerId) return;
|
||||||
|
if ((event.buttons & 1) === 0) {
|
||||||
|
finishResizeGesture();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handleResizePointerMove(event);
|
||||||
|
};
|
||||||
|
const handlePointerEnd = (event: globalThis.PointerEvent) => {
|
||||||
|
if (resizeGestureRef.current?.pointerId === event.pointerId) finishResizeGesture();
|
||||||
|
};
|
||||||
|
const root = document.documentElement;
|
||||||
|
const previousCursor = root.style.cursor;
|
||||||
|
const previousUserSelect = root.style.userSelect;
|
||||||
|
root.style.cursor = resizeGestureRef.current?.handle.axis === "vertical"
|
||||||
|
? "col-resize"
|
||||||
|
: "row-resize";
|
||||||
|
root.style.userSelect = "none";
|
||||||
|
window.addEventListener("pointermove", handlePointerMove, { passive: false });
|
||||||
|
window.addEventListener("pointerup", handlePointerEnd);
|
||||||
|
window.addEventListener("pointercancel", handlePointerEnd);
|
||||||
|
window.addEventListener("blur", finishResizeGesture);
|
||||||
|
return () => {
|
||||||
|
root.style.cursor = previousCursor;
|
||||||
|
root.style.userSelect = previousUserSelect;
|
||||||
|
window.removeEventListener("pointermove", handlePointerMove);
|
||||||
|
window.removeEventListener("pointerup", handlePointerEnd);
|
||||||
|
window.removeEventListener("pointercancel", handlePointerEnd);
|
||||||
|
window.removeEventListener("blur", finishResizeGesture);
|
||||||
|
};
|
||||||
|
}, [finishResizeGesture, handleResizePointerMove, resizingRatioIndex]);
|
||||||
|
|
||||||
|
const handleResizeKeyDown = useCallback((
|
||||||
|
handle: WorkbenchResizeHandle,
|
||||||
|
event: KeyboardEvent<HTMLButtonElement>,
|
||||||
|
) => {
|
||||||
|
const decreasing = handle.axis === "vertical" ? event.key === "ArrowLeft" : event.key === "ArrowUp";
|
||||||
|
const increasing = handle.axis === "vertical" ? event.key === "ArrowRight" : event.key === "ArrowDown";
|
||||||
|
if (!decreasing && !increasing && event.key !== "Home" && event.key !== "End") return;
|
||||||
|
const rect = gridRef.current?.getBoundingClientRect();
|
||||||
|
const axisExtent = handle.axis === "vertical" ? rect?.width : rect?.height;
|
||||||
|
const bounds = splitRatioBounds(handle, axisExtent && axisExtent > 0 ? axisExtent : 1000);
|
||||||
|
const current = layoutGeometry.splitRatios[handle.ratioIndex] ?? 0.5;
|
||||||
|
const step = event.shiftKey ? 0.1 : 0.03;
|
||||||
|
const nextRatio = event.key === "Home"
|
||||||
|
? bounds.min
|
||||||
|
: event.key === "End"
|
||||||
|
? bounds.max
|
||||||
|
: Math.min(bounds.max, Math.max(bounds.min, current + (increasing ? step : -step)));
|
||||||
|
event.preventDefault();
|
||||||
|
const next = [...layoutGeometry.splitRatios];
|
||||||
|
next[handle.ratioIndex] = nextRatio;
|
||||||
|
previewSplitRatiosRef.current = next;
|
||||||
|
setPreviewSplitRatios(next);
|
||||||
|
onSplitRatiosChange(next);
|
||||||
|
}, [layoutGeometry.splitRatios, onSplitRatiosChange]);
|
||||||
|
|
||||||
|
const gridStyle = layoutGeometry.gridStyle;
|
||||||
|
const currentLayout = LAYOUT_CONTROLS.find((control) => control.layout === layout)
|
||||||
|
?? LAYOUT_CONTROLS[0];
|
||||||
|
const headerActions = chrome && !compact ? (
|
||||||
|
<div
|
||||||
|
data-workbench-pane-action
|
||||||
|
className="host-no-drag flex items-center gap-0.5"
|
||||||
|
>
|
||||||
|
{showLayoutControl ? (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
aria-label={t("workbench.layout", {
|
||||||
|
defaultValue: "Pane layout",
|
||||||
|
})}
|
||||||
|
className="host-no-drag h-8 w-8 rounded-full text-muted-foreground/85 hover:bg-accent/40 hover:text-foreground"
|
||||||
|
>
|
||||||
|
<currentLayout.icon className="h-4 w-4" aria-hidden />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent
|
||||||
|
align="end"
|
||||||
|
onCloseAutoFocus={(event) => event.preventDefault()}
|
||||||
|
>
|
||||||
|
<DropdownMenuLabel>
|
||||||
|
{t("workbench.layout", { defaultValue: "Pane layout" })}
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuRadioGroup
|
||||||
|
value={layout}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
const next = value as WorkbenchLayout;
|
||||||
|
if (next === layout) return;
|
||||||
|
captureLayout();
|
||||||
|
onLayoutChange(next);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{LAYOUT_CONTROLS.map((control) => (
|
||||||
|
<DropdownMenuRadioItem
|
||||||
|
key={control.layout}
|
||||||
|
value={control.layout}
|
||||||
|
>
|
||||||
|
<control.icon aria-hidden />
|
||||||
|
{t(`workbench.layouts.${control.layout}`, {
|
||||||
|
defaultValue: control.label,
|
||||||
|
})}
|
||||||
|
</DropdownMenuRadioItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuRadioGroup>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
) : null}
|
||||||
|
<HeaderIconButton
|
||||||
|
disabled={addPaneDisabled}
|
||||||
|
disabledLabel={addPaneDisabledLabel}
|
||||||
|
icon={Plus}
|
||||||
|
label={t("workbench.addPane", { defaultValue: "Add pane" })}
|
||||||
|
onClick={() => {
|
||||||
|
captureLayout();
|
||||||
|
onAddPane();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
aria-label={t("workbench.aria", { defaultValue: "Conversation workbench" })}
|
||||||
|
className="flex h-full min-h-0 flex-col overflow-hidden bg-background"
|
||||||
|
>
|
||||||
|
<TooltipProvider delayDuration={500} skipDelayDuration={100}>
|
||||||
|
{chrome ? (
|
||||||
|
<header className="shrink-0 bg-background">
|
||||||
|
<div
|
||||||
|
ref={setHeaderPortalTarget}
|
||||||
|
data-testid="workbench-header-host"
|
||||||
|
/>
|
||||||
|
</header>
|
||||||
|
) : null}
|
||||||
|
<div className="relative min-h-0 flex-1 bg-background">
|
||||||
|
<div
|
||||||
|
ref={gridRef}
|
||||||
|
data-testid="pane-grid"
|
||||||
|
data-layout={effectiveLayout}
|
||||||
|
className={cn(
|
||||||
|
"grid h-full min-h-0 min-w-0 overflow-hidden",
|
||||||
|
chrome && displayedPanes.length > 1 && "gap-px bg-border/55",
|
||||||
|
)}
|
||||||
|
style={gridStyle}
|
||||||
|
>
|
||||||
|
{displayedPanes.map((pane, index) => {
|
||||||
|
const active = pane.key === activePaneKey;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
key={pane.reactKey ?? pane.key}
|
||||||
|
ref={(element) => {
|
||||||
|
if (element) paneRefs.current.set(pane.key, element);
|
||||||
|
else paneRefs.current.delete(pane.key);
|
||||||
|
}}
|
||||||
|
aria-label={pane.title}
|
||||||
|
data-active={active ? "true" : "false"}
|
||||||
|
data-dragging={draggingPaneKey === pane.key ? "true" : undefined}
|
||||||
|
data-testid={`workbench-pane-${pane.key}`}
|
||||||
|
onPointerDownCapture={(event) => handlePanePointerDown(pane.key, event)}
|
||||||
|
onFocusCapture={(event) => handlePaneFocus(pane.key, event)}
|
||||||
|
className="workbench-pane relative flex min-h-0 min-w-0 overflow-hidden bg-background"
|
||||||
|
style={layoutGeometry.paneStyles[index]}
|
||||||
|
>
|
||||||
|
{renderPane(pane, {
|
||||||
|
active,
|
||||||
|
headerPortalTarget: chrome ? headerPortalTarget : undefined,
|
||||||
|
composerPortalTarget: chrome ? composerPortalTarget : undefined,
|
||||||
|
headerActions,
|
||||||
|
})}
|
||||||
|
{chrome && panes.length > 1 && !compact ? (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
tabIndex={active ? 0 : -1}
|
||||||
|
aria-hidden={active ? undefined : true}
|
||||||
|
aria-label={active ? t("workbench.movePane", {
|
||||||
|
defaultValue: "Move {{title}} pane",
|
||||||
|
title: pane.title,
|
||||||
|
}) : undefined}
|
||||||
|
aria-keyshortcuts={active ? "ArrowLeft ArrowRight ArrowUp ArrowDown" : undefined}
|
||||||
|
data-workbench-pane-action
|
||||||
|
data-workbench-move-handle
|
||||||
|
data-testid={`pane-move-handle-${pane.key}`}
|
||||||
|
onPointerDown={(event) => handleMovePointerDown(pane.key, event)}
|
||||||
|
onKeyDown={(event) => handleMoveKeyDown(pane.key, event)}
|
||||||
|
className={cn(
|
||||||
|
"group host-no-drag absolute bottom-0 left-1/2 z-30 flex h-6 w-12 -translate-x-1/2 touch-none items-center justify-center rounded-full",
|
||||||
|
"cursor-grab focus-visible:outline-none active:cursor-grabbing",
|
||||||
|
"transition-opacity duration-100 motion-reduce:transition-none",
|
||||||
|
active ? "pointer-events-auto opacity-100" : "pointer-events-none opacity-0",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className={cn(
|
||||||
|
"h-[3px] w-9 rounded-full bg-foreground/35 transition-[height,background-color] duration-100 motion-reduce:transition-none",
|
||||||
|
"group-focus-visible:h-[5px] group-focus-visible:bg-foreground/70",
|
||||||
|
draggingPaneKey === pane.key
|
||||||
|
? "bg-foreground/65"
|
||||||
|
: "group-hover:bg-foreground/50",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
{active ? (
|
||||||
|
<TooltipContent side="top">
|
||||||
|
{t("workbench.movePaneHint", {
|
||||||
|
defaultValue: "Drag to move · Arrow keys also work",
|
||||||
|
})}
|
||||||
|
</TooltipContent>
|
||||||
|
) : null}
|
||||||
|
</Tooltip>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{chrome && panes.length > 1 && !compact ? layoutGeometry.resizeHandles.map(
|
||||||
|
(handle, index) => {
|
||||||
|
const ratio = layoutGeometry.splitRatios[handle.ratioIndex] ?? 0.5;
|
||||||
|
const vertical = handle.axis === "vertical";
|
||||||
|
const bounds = splitRatioBounds(handle, 1000);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={`${handle.axis}-${handle.ratioIndex}`}
|
||||||
|
type="button"
|
||||||
|
role="separator"
|
||||||
|
aria-orientation={handle.axis}
|
||||||
|
aria-label={t("workbench.resizePaneBoundary", {
|
||||||
|
defaultValue: "Resize pane boundary {{index}}",
|
||||||
|
index: index + 1,
|
||||||
|
})}
|
||||||
|
aria-valuemin={Math.round(bounds.min * 100)}
|
||||||
|
aria-valuemax={Math.round(bounds.max * 100)}
|
||||||
|
aria-valuenow={Math.round(ratio * 100)}
|
||||||
|
aria-keyshortcuts={vertical
|
||||||
|
? "ArrowLeft ArrowRight Home End"
|
||||||
|
: "ArrowUp ArrowDown Home End"}
|
||||||
|
data-workbench-pane-action
|
||||||
|
data-workbench-resize-handle={handle.ratioIndex}
|
||||||
|
onPointerDown={(event) => handleResizePointerDown(handle, event)}
|
||||||
|
onKeyDown={(event) => handleResizeKeyDown(handle, event)}
|
||||||
|
className={cn(
|
||||||
|
"group host-no-drag absolute z-20 touch-none border-0 bg-transparent p-0 focus-visible:outline-none",
|
||||||
|
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
|
||||||
|
vertical ? "w-3 cursor-col-resize" : "h-3 cursor-row-resize",
|
||||||
|
)}
|
||||||
|
style={resizeHandleStyle(handle)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-none absolute bg-transparent transition-colors duration-100 motion-reduce:transition-none",
|
||||||
|
"group-hover:bg-foreground/20 group-focus-visible:bg-foreground/25",
|
||||||
|
resizingRatioIndex === handle.ratioIndex && "bg-foreground/30",
|
||||||
|
vertical
|
||||||
|
? "inset-y-0 left-1/2 w-px -translate-x-1/2"
|
||||||
|
: "inset-x-0 top-1/2 h-px -translate-y-1/2",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{chrome ? (
|
||||||
|
<footer className="shrink-0 bg-background px-3 pb-[calc(0.75rem+env(safe-area-inset-bottom))] sm:px-4">
|
||||||
|
<div
|
||||||
|
ref={setComposerPortalTarget}
|
||||||
|
data-testid="workbench-composer-host"
|
||||||
|
className="mx-auto w-full max-w-[58rem]"
|
||||||
|
/>
|
||||||
|
</footer>
|
||||||
|
) : null}
|
||||||
|
</TooltipProvider>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,432 @@
|
|||||||
|
import type { CSSProperties } from "react";
|
||||||
|
|
||||||
|
import type { WorkbenchLayout } from "@/components/workbench/workbench-model";
|
||||||
|
|
||||||
|
interface PaneCell {
|
||||||
|
xStart: number;
|
||||||
|
xEnd: number;
|
||||||
|
yStart: number;
|
||||||
|
yEnd: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Track {
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkbenchResizeHandle {
|
||||||
|
axis: "horizontal" | "vertical";
|
||||||
|
ratioIndex: number;
|
||||||
|
position: number;
|
||||||
|
crossStart: number;
|
||||||
|
crossEnd: number;
|
||||||
|
localStart: number;
|
||||||
|
localEnd: number;
|
||||||
|
beforeUnitCount: number;
|
||||||
|
afterUnitCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkbenchLayoutGeometry {
|
||||||
|
gridStyle: CSSProperties;
|
||||||
|
paneStyles: Array<CSSProperties | undefined>;
|
||||||
|
resizeHandles: WorkbenchResizeHandle[];
|
||||||
|
splitRatios: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const MIN_RATIO = 0.05;
|
||||||
|
const MAX_RATIO = 0.95;
|
||||||
|
const MIN_PANE_EXTENT_PX = 160;
|
||||||
|
const MAIN_PANE_RATIO = 1.65 / 2.65;
|
||||||
|
|
||||||
|
function clamp(value: number, min: number, max: number): number {
|
||||||
|
return Math.min(max, Math.max(min, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function ratioAt(
|
||||||
|
ratios: readonly number[],
|
||||||
|
index: number,
|
||||||
|
fallback: number,
|
||||||
|
): number {
|
||||||
|
const value = ratios[index];
|
||||||
|
return Number.isFinite(value) ? clamp(value, MIN_RATIO, MAX_RATIO) : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tracksTemplate(tracks: readonly Track[]): string {
|
||||||
|
return tracks
|
||||||
|
.map((track) => {
|
||||||
|
const weight = Number(((track.end - track.start) * 1000).toFixed(6));
|
||||||
|
return `minmax(0, ${weight}fr)`;
|
||||||
|
})
|
||||||
|
.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function sequentialTracks(
|
||||||
|
count: number,
|
||||||
|
ratios: readonly number[],
|
||||||
|
ratioOffset: number,
|
||||||
|
): { tracks: Track[]; handles: WorkbenchResizeHandle[]; resolvedRatios: number[] } {
|
||||||
|
const tracks: Track[] = [];
|
||||||
|
const handles: WorkbenchResizeHandle[] = [];
|
||||||
|
const resolvedRatios: number[] = [];
|
||||||
|
let start = 0;
|
||||||
|
|
||||||
|
for (let index = 0; index < count - 1; index += 1) {
|
||||||
|
const remainingPaneCount = count - index;
|
||||||
|
const ratio = ratioAt(ratios, ratioOffset + index, 1 / remainingPaneCount);
|
||||||
|
const end = start + (1 - start) * ratio;
|
||||||
|
tracks.push({ start, end });
|
||||||
|
handles.push({
|
||||||
|
axis: "vertical",
|
||||||
|
ratioIndex: ratioOffset + index,
|
||||||
|
position: end,
|
||||||
|
crossStart: 0,
|
||||||
|
crossEnd: 1,
|
||||||
|
localStart: start,
|
||||||
|
localEnd: 1,
|
||||||
|
beforeUnitCount: 1,
|
||||||
|
afterUnitCount: remainingPaneCount - 1,
|
||||||
|
});
|
||||||
|
resolvedRatios.push(ratio);
|
||||||
|
start = end;
|
||||||
|
}
|
||||||
|
tracks.push({ start, end: 1 });
|
||||||
|
return { tracks, handles, resolvedRatios };
|
||||||
|
}
|
||||||
|
|
||||||
|
function cellStyle(
|
||||||
|
columnStart: number,
|
||||||
|
columnEnd: number,
|
||||||
|
rowStart: number,
|
||||||
|
rowEnd: number,
|
||||||
|
): CSSProperties {
|
||||||
|
return {
|
||||||
|
gridColumn: `${columnStart} / ${columnEnd}`,
|
||||||
|
gridRow: `${rowStart} / ${rowEnd}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function singlePaneGeometry(): WorkbenchLayoutGeometry {
|
||||||
|
return {
|
||||||
|
gridStyle: {
|
||||||
|
gridTemplateColumns: "minmax(0, 1fr)",
|
||||||
|
gridTemplateRows: "minmax(0, 1fr)",
|
||||||
|
},
|
||||||
|
paneStyles: [undefined],
|
||||||
|
resizeHandles: [],
|
||||||
|
splitRatios: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function columnsGeometry(
|
||||||
|
paneCount: number,
|
||||||
|
ratios: readonly number[],
|
||||||
|
): WorkbenchLayoutGeometry {
|
||||||
|
const split = sequentialTracks(paneCount, ratios, 0);
|
||||||
|
return {
|
||||||
|
gridStyle: {
|
||||||
|
gridTemplateColumns: tracksTemplate(split.tracks),
|
||||||
|
gridTemplateRows: "minmax(0, 1fr)",
|
||||||
|
},
|
||||||
|
paneStyles: split.tracks.map((_, index) => cellStyle(index + 1, index + 2, 1, 2)),
|
||||||
|
resizeHandles: split.handles,
|
||||||
|
splitRatios: split.resolvedRatios,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowsGeometry(
|
||||||
|
paneCount: number,
|
||||||
|
ratios: readonly number[],
|
||||||
|
): WorkbenchLayoutGeometry {
|
||||||
|
const split = sequentialTracks(paneCount, ratios, 0);
|
||||||
|
return {
|
||||||
|
gridStyle: {
|
||||||
|
gridTemplateColumns: "minmax(0, 1fr)",
|
||||||
|
gridTemplateRows: tracksTemplate(split.tracks),
|
||||||
|
},
|
||||||
|
paneStyles: split.tracks.map((_, index) => cellStyle(1, 2, index + 1, index + 2)),
|
||||||
|
resizeHandles: split.handles.map((handle) => ({
|
||||||
|
...handle,
|
||||||
|
axis: "horizontal",
|
||||||
|
})),
|
||||||
|
splitRatios: split.resolvedRatios,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function gridGeometry(
|
||||||
|
paneCount: number,
|
||||||
|
ratios: readonly number[],
|
||||||
|
): WorkbenchLayoutGeometry {
|
||||||
|
const columnCount = Math.ceil(Math.sqrt(paneCount));
|
||||||
|
const rowCount = Math.ceil(paneCount / columnCount);
|
||||||
|
const columns = sequentialTracks(columnCount, ratios, 0);
|
||||||
|
const rows = sequentialTracks(rowCount, ratios, columnCount - 1);
|
||||||
|
const paneCells = Array.from({ length: paneCount }, (_, index) => ({
|
||||||
|
column: index % columnCount,
|
||||||
|
row: Math.floor(index / columnCount),
|
||||||
|
}));
|
||||||
|
const verticalHandles = columns.handles.map((handle, boundaryIndex) => ({
|
||||||
|
...handle,
|
||||||
|
beforeUnitCount: boundaryIndex + 1,
|
||||||
|
afterUnitCount: columnCount - boundaryIndex - 1,
|
||||||
|
})).filter((handle) => handle.beforeUnitCount > 0 && handle.afterUnitCount > 0);
|
||||||
|
const horizontalHandles = rows.handles.map((handle, boundaryIndex) => ({
|
||||||
|
...handle,
|
||||||
|
axis: "horizontal" as const,
|
||||||
|
beforeUnitCount: boundaryIndex + 1,
|
||||||
|
afterUnitCount: rowCount - boundaryIndex - 1,
|
||||||
|
})).filter((handle) => handle.beforeUnitCount > 0 && handle.afterUnitCount > 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
gridStyle: {
|
||||||
|
gridTemplateColumns: tracksTemplate(columns.tracks),
|
||||||
|
gridTemplateRows: tracksTemplate(rows.tracks),
|
||||||
|
},
|
||||||
|
paneStyles: paneCells.map((cell) => cellStyle(
|
||||||
|
cell.column + 1,
|
||||||
|
cell.column + 2,
|
||||||
|
cell.row + 1,
|
||||||
|
cell.row + 2,
|
||||||
|
)),
|
||||||
|
resizeHandles: [...verticalHandles, ...horizontalHandles],
|
||||||
|
splitRatios: [...columns.resolvedRatios, ...rows.resolvedRatios],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mainStackGeometry(
|
||||||
|
paneCount: number,
|
||||||
|
ratios: readonly number[],
|
||||||
|
): WorkbenchLayoutGeometry {
|
||||||
|
const columnRatio = ratioAt(ratios, 0, MAIN_PANE_RATIO);
|
||||||
|
const stack = sequentialTracks(paneCount - 1, ratios, 1);
|
||||||
|
const verticalHandle: WorkbenchResizeHandle = {
|
||||||
|
axis: "vertical",
|
||||||
|
ratioIndex: 0,
|
||||||
|
position: columnRatio,
|
||||||
|
crossStart: 0,
|
||||||
|
crossEnd: 1,
|
||||||
|
localStart: 0,
|
||||||
|
localEnd: 1,
|
||||||
|
beforeUnitCount: 1,
|
||||||
|
afterUnitCount: 1,
|
||||||
|
};
|
||||||
|
const stackHandles = stack.handles.map((handle) => ({
|
||||||
|
...handle,
|
||||||
|
axis: "horizontal" as const,
|
||||||
|
crossStart: columnRatio,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
gridStyle: {
|
||||||
|
gridTemplateColumns: tracksTemplate([
|
||||||
|
{ start: 0, end: columnRatio },
|
||||||
|
{ start: columnRatio, end: 1 },
|
||||||
|
]),
|
||||||
|
gridTemplateRows: tracksTemplate(stack.tracks),
|
||||||
|
},
|
||||||
|
paneStyles: [
|
||||||
|
cellStyle(1, 2, 1, paneCount),
|
||||||
|
...stack.tracks.map((_, index) => cellStyle(2, 3, index + 1, index + 2)),
|
||||||
|
],
|
||||||
|
resizeHandles: [verticalHandle, ...stackHandles],
|
||||||
|
splitRatios: [columnRatio, ...stack.resolvedRatios],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueBoundaries(values: readonly number[]): number[] {
|
||||||
|
return Array.from(new Set(values.map((value) => value.toFixed(8))))
|
||||||
|
.map(Number)
|
||||||
|
.sort((left, right) => left - right);
|
||||||
|
}
|
||||||
|
|
||||||
|
function boundaryIndex(boundaries: readonly number[], value: number): number {
|
||||||
|
return boundaries.findIndex((candidate) => Math.abs(candidate - value) < 0.0000001);
|
||||||
|
}
|
||||||
|
|
||||||
|
function axisUnitCount(cells: readonly PaneCell[], axis: "horizontal" | "vertical"): number {
|
||||||
|
const boundaries = uniqueBoundaries(cells.flatMap((cell) => axis === "vertical"
|
||||||
|
? [cell.xStart, cell.xEnd]
|
||||||
|
: [cell.yStart, cell.yEnd]));
|
||||||
|
return Math.max(1, boundaries.length - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function bspGeometry(
|
||||||
|
paneCount: number,
|
||||||
|
ratios: readonly number[],
|
||||||
|
): WorkbenchLayoutGeometry {
|
||||||
|
const cells: PaneCell[] = [{ xStart: 0, xEnd: 1, yStart: 0, yEnd: 1 }];
|
||||||
|
const handles: WorkbenchResizeHandle[] = [];
|
||||||
|
const resolvedRatios: number[] = [];
|
||||||
|
|
||||||
|
for (let paneIndex = 1; paneIndex < paneCount; paneIndex += 1) {
|
||||||
|
const leaf = cells.pop();
|
||||||
|
if (!leaf) break;
|
||||||
|
const ratioIndex = paneIndex - 1;
|
||||||
|
const ratio = ratioAt(ratios, ratioIndex, 0.5);
|
||||||
|
resolvedRatios.push(ratio);
|
||||||
|
if (paneIndex % 2 === 1) {
|
||||||
|
const position = leaf.xStart + (leaf.xEnd - leaf.xStart) * ratio;
|
||||||
|
cells.push(
|
||||||
|
{ ...leaf, xEnd: position },
|
||||||
|
{ ...leaf, xStart: position },
|
||||||
|
);
|
||||||
|
handles.push({
|
||||||
|
axis: "vertical",
|
||||||
|
ratioIndex,
|
||||||
|
position,
|
||||||
|
crossStart: leaf.yStart,
|
||||||
|
crossEnd: leaf.yEnd,
|
||||||
|
localStart: leaf.xStart,
|
||||||
|
localEnd: leaf.xEnd,
|
||||||
|
beforeUnitCount: 0,
|
||||||
|
afterUnitCount: 0,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const position = leaf.yStart + (leaf.yEnd - leaf.yStart) * ratio;
|
||||||
|
cells.push(
|
||||||
|
{ ...leaf, yEnd: position },
|
||||||
|
{ ...leaf, yStart: position },
|
||||||
|
);
|
||||||
|
handles.push({
|
||||||
|
axis: "horizontal",
|
||||||
|
ratioIndex,
|
||||||
|
position,
|
||||||
|
crossStart: leaf.xStart,
|
||||||
|
crossEnd: leaf.xEnd,
|
||||||
|
localStart: leaf.yStart,
|
||||||
|
localEnd: leaf.yEnd,
|
||||||
|
beforeUnitCount: 0,
|
||||||
|
afterUnitCount: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const handle of handles) {
|
||||||
|
if (handle.axis === "vertical") {
|
||||||
|
const beforeCells = cells.filter((cell) => (
|
||||||
|
cell.xStart >= handle.localStart
|
||||||
|
&& cell.xEnd <= handle.position + Number.EPSILON
|
||||||
|
&& cell.yStart >= handle.crossStart
|
||||||
|
&& cell.yEnd <= handle.crossEnd
|
||||||
|
));
|
||||||
|
const afterCells = cells.filter((cell) => (
|
||||||
|
cell.xStart >= handle.position - Number.EPSILON
|
||||||
|
&& cell.xEnd <= handle.localEnd
|
||||||
|
&& cell.yStart >= handle.crossStart
|
||||||
|
&& cell.yEnd <= handle.crossEnd
|
||||||
|
));
|
||||||
|
handle.beforeUnitCount = axisUnitCount(beforeCells, handle.axis);
|
||||||
|
handle.afterUnitCount = axisUnitCount(afterCells, handle.axis);
|
||||||
|
} else {
|
||||||
|
const beforeCells = cells.filter((cell) => (
|
||||||
|
cell.yStart >= handle.localStart
|
||||||
|
&& cell.yEnd <= handle.position + Number.EPSILON
|
||||||
|
&& cell.xStart >= handle.crossStart
|
||||||
|
&& cell.xEnd <= handle.crossEnd
|
||||||
|
));
|
||||||
|
const afterCells = cells.filter((cell) => (
|
||||||
|
cell.yStart >= handle.position - Number.EPSILON
|
||||||
|
&& cell.yEnd <= handle.localEnd
|
||||||
|
&& cell.xStart >= handle.crossStart
|
||||||
|
&& cell.xEnd <= handle.crossEnd
|
||||||
|
));
|
||||||
|
handle.beforeUnitCount = axisUnitCount(beforeCells, handle.axis);
|
||||||
|
handle.afterUnitCount = axisUnitCount(afterCells, handle.axis);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const columnBoundaries = uniqueBoundaries(cells.flatMap((cell) => [cell.xStart, cell.xEnd]));
|
||||||
|
const rowBoundaries = uniqueBoundaries(cells.flatMap((cell) => [cell.yStart, cell.yEnd]));
|
||||||
|
const columnTracks = columnBoundaries.slice(0, -1).map((start, index) => ({
|
||||||
|
start,
|
||||||
|
end: columnBoundaries[index + 1],
|
||||||
|
}));
|
||||||
|
const rowTracks = rowBoundaries.slice(0, -1).map((start, index) => ({
|
||||||
|
start,
|
||||||
|
end: rowBoundaries[index + 1],
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
gridStyle: {
|
||||||
|
gridTemplateColumns: tracksTemplate(columnTracks),
|
||||||
|
gridTemplateRows: tracksTemplate(rowTracks),
|
||||||
|
},
|
||||||
|
paneStyles: cells.map((cell) => cellStyle(
|
||||||
|
boundaryIndex(columnBoundaries, cell.xStart) + 1,
|
||||||
|
boundaryIndex(columnBoundaries, cell.xEnd) + 1,
|
||||||
|
boundaryIndex(rowBoundaries, cell.yStart) + 1,
|
||||||
|
boundaryIndex(rowBoundaries, cell.yEnd) + 1,
|
||||||
|
)),
|
||||||
|
resizeHandles: handles,
|
||||||
|
splitRatios: resolvedRatios,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createWorkbenchLayoutGeometry(
|
||||||
|
layout: WorkbenchLayout,
|
||||||
|
paneCount: number,
|
||||||
|
splitRatios: readonly number[],
|
||||||
|
): WorkbenchLayoutGeometry {
|
||||||
|
const count = Math.max(1, paneCount);
|
||||||
|
if (count === 1) return singlePaneGeometry();
|
||||||
|
switch (layout) {
|
||||||
|
case "columns":
|
||||||
|
return columnsGeometry(count, splitRatios);
|
||||||
|
case "rows":
|
||||||
|
return rowsGeometry(count, splitRatios);
|
||||||
|
case "grid":
|
||||||
|
return gridGeometry(count, splitRatios);
|
||||||
|
case "main-stack":
|
||||||
|
return mainStackGeometry(count, splitRatios);
|
||||||
|
case "bsp":
|
||||||
|
return bspGeometry(count, splitRatios);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function splitRatioBounds(
|
||||||
|
handle: WorkbenchResizeHandle,
|
||||||
|
axisExtentPx: number,
|
||||||
|
): { min: number; max: number } {
|
||||||
|
const localExtentPx = Math.max(
|
||||||
|
1,
|
||||||
|
axisExtentPx * Math.max(0.01, handle.localEnd - handle.localStart),
|
||||||
|
);
|
||||||
|
const paneCount = Math.max(2, handle.beforeUnitCount + handle.afterUnitCount);
|
||||||
|
const paneExtentPx = Math.min(
|
||||||
|
MIN_PANE_EXTENT_PX,
|
||||||
|
localExtentPx * 0.8 / paneCount,
|
||||||
|
);
|
||||||
|
const min = Math.max(MIN_RATIO, paneExtentPx * handle.beforeUnitCount / localExtentPx);
|
||||||
|
const max = Math.min(
|
||||||
|
MAX_RATIO,
|
||||||
|
1 - paneExtentPx * handle.afterUnitCount / localExtentPx,
|
||||||
|
);
|
||||||
|
return min <= max ? { min, max } : { min: 0.4, max: 0.6 };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resizeHandleRatio(
|
||||||
|
handle: WorkbenchResizeHandle,
|
||||||
|
normalizedPosition: number,
|
||||||
|
axisExtentPx: number,
|
||||||
|
): number {
|
||||||
|
const localRatio = (normalizedPosition - handle.localStart)
|
||||||
|
/ Math.max(0.01, handle.localEnd - handle.localStart);
|
||||||
|
const bounds = splitRatioBounds(handle, axisExtentPx);
|
||||||
|
return Number(clamp(localRatio, bounds.min, bounds.max).toFixed(4));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resizeHandleStyle(handle: WorkbenchResizeHandle): CSSProperties {
|
||||||
|
if (handle.axis === "vertical") {
|
||||||
|
return {
|
||||||
|
left: `${handle.position * 100}%`,
|
||||||
|
top: `${handle.crossStart * 100}%`,
|
||||||
|
height: `${(handle.crossEnd - handle.crossStart) * 100}%`,
|
||||||
|
transform: "translateX(-50%)",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
top: `${handle.position * 100}%`,
|
||||||
|
left: `${handle.crossStart * 100}%`,
|
||||||
|
width: `${(handle.crossEnd - handle.crossStart) * 100}%`,
|
||||||
|
transform: "translateY(-50%)",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,431 @@
|
|||||||
|
import type {
|
||||||
|
WorkbenchLayout,
|
||||||
|
WorkbenchState,
|
||||||
|
WorkbenchTabState,
|
||||||
|
} from "@/lib/types";
|
||||||
|
|
||||||
|
export type {
|
||||||
|
WorkbenchLayout,
|
||||||
|
WorkbenchState,
|
||||||
|
WorkbenchTabState,
|
||||||
|
} from "@/lib/types";
|
||||||
|
|
||||||
|
export const MAX_WORKBENCH_PANES = 4;
|
||||||
|
|
||||||
|
export const WORKBENCH_LAYOUTS = [
|
||||||
|
"columns",
|
||||||
|
"rows",
|
||||||
|
"grid",
|
||||||
|
"bsp",
|
||||||
|
"main-stack",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export interface WorkbenchTabMatch {
|
||||||
|
tabKey: string;
|
||||||
|
tab: WorkbenchTabState;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OrderedWorkbenchTab extends WorkbenchTabMatch {
|
||||||
|
paneKeys: string[];
|
||||||
|
updatedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EMPTY_WORKBENCH_STATE: WorkbenchState = {
|
||||||
|
version: 1,
|
||||||
|
tabs: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
function isLayout(value: unknown): value is WorkbenchLayout {
|
||||||
|
return typeof value === "string"
|
||||||
|
&& (WORKBENCH_LAYOUTS as readonly string[]).includes(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueKeys(value: unknown): string[] {
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
return Array.from(new Set(
|
||||||
|
value.filter((key): key is string => typeof key === "string" && key.length > 0),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTitle(value: unknown): string | null {
|
||||||
|
if (typeof value !== "string") return null;
|
||||||
|
const title = value.trim();
|
||||||
|
return title || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSplitRatios(value: unknown): number[] {
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
return value
|
||||||
|
.filter((ratio): ratio is number => typeof ratio === "number" && Number.isFinite(ratio))
|
||||||
|
.slice(0, MAX_WORKBENCH_PANES - 1)
|
||||||
|
.map((ratio) => Number(Math.min(0.95, Math.max(0.05, ratio)).toFixed(4)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTab(value: unknown): WorkbenchTabState {
|
||||||
|
const candidate = value && typeof value === "object"
|
||||||
|
? value as Partial<WorkbenchTabState>
|
||||||
|
: {};
|
||||||
|
const paneKeys = uniqueKeys(candidate.paneKeys).slice(0, MAX_WORKBENCH_PANES);
|
||||||
|
const requestedLayoutPaneKeys = uniqueKeys(candidate.layoutPaneKeys)
|
||||||
|
.filter((key) => paneKeys.includes(key));
|
||||||
|
const layoutPaneKeys = [
|
||||||
|
...requestedLayoutPaneKeys,
|
||||||
|
...paneKeys.filter((key) => !requestedLayoutPaneKeys.includes(key)),
|
||||||
|
];
|
||||||
|
return {
|
||||||
|
explicit: candidate.explicit === true,
|
||||||
|
title: normalizeTitle(candidate.title),
|
||||||
|
paneKeys,
|
||||||
|
layoutPaneKeys,
|
||||||
|
layout: isLayout(candidate.layout) ? candidate.layout : "columns",
|
||||||
|
splitRatios: normalizeSplitRatios(candidate.splitRatios),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function standaloneTabKeyBase(paneKey: string): string {
|
||||||
|
return `tab:${paneKey}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function availableStandaloneTabKey(
|
||||||
|
tabs: Readonly<Record<string, WorkbenchTabState>>,
|
||||||
|
paneKey: string,
|
||||||
|
): string {
|
||||||
|
const base = standaloneTabKeyBase(paneKey);
|
||||||
|
if (!tabs[base]) return base;
|
||||||
|
let suffix = 2;
|
||||||
|
while (tabs[`${base}:${suffix}`]) suffix += 1;
|
||||||
|
return `${base}:${suffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultWorkbenchTab(
|
||||||
|
paneKey: string,
|
||||||
|
title: string | null = null,
|
||||||
|
): WorkbenchTabState {
|
||||||
|
return {
|
||||||
|
explicit: false,
|
||||||
|
title: normalizeTitle(title),
|
||||||
|
paneKeys: [paneKey],
|
||||||
|
layoutPaneKeys: [paneKey],
|
||||||
|
layout: "columns",
|
||||||
|
splitRatios: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeWorkbenchState(raw: unknown): WorkbenchState {
|
||||||
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
||||||
|
return EMPTY_WORKBENCH_STATE;
|
||||||
|
}
|
||||||
|
const parsed = raw as { version?: unknown; tabs?: unknown };
|
||||||
|
if (parsed.version !== 1 || !parsed.tabs
|
||||||
|
|| typeof parsed.tabs !== "object" || Array.isArray(parsed.tabs)) {
|
||||||
|
return EMPTY_WORKBENCH_STATE;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
tabs: Object.fromEntries(
|
||||||
|
Object.entries(parsed.tabs)
|
||||||
|
.map(([tabKey, tab]) => [tabKey, normalizeTab(tab)] as const)
|
||||||
|
.filter(([, tab]) => tab.paneKeys.length > 1 || tab.explicit),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function workbenchTab(
|
||||||
|
state: WorkbenchState,
|
||||||
|
tabKey: string,
|
||||||
|
): WorkbenchTabState | null {
|
||||||
|
return state.tabs[tabKey] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function workbenchTabForPane(
|
||||||
|
state: WorkbenchState,
|
||||||
|
paneKey: string,
|
||||||
|
): WorkbenchTabMatch {
|
||||||
|
const match = Object.entries(state.tabs).find(([, tab]) => tab.paneKeys.includes(paneKey));
|
||||||
|
if (match) return { tabKey: match[0], tab: match[1] };
|
||||||
|
return {
|
||||||
|
tabKey: availableStandaloneTabKey(state.tabs, paneKey),
|
||||||
|
tab: defaultWorkbenchTab(paneKey),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTab(
|
||||||
|
state: WorkbenchState,
|
||||||
|
tabKey: string,
|
||||||
|
update: (tab: WorkbenchTabState) => WorkbenchTabState,
|
||||||
|
): WorkbenchState {
|
||||||
|
const current = state.tabs[tabKey];
|
||||||
|
if (!current) return state;
|
||||||
|
const next = update(current);
|
||||||
|
if (next === current) return state;
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
tabs: {
|
||||||
|
...state.tabs,
|
||||||
|
[tabKey]: next,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addWorkbenchPane(
|
||||||
|
state: WorkbenchState,
|
||||||
|
anchorPaneKey: string,
|
||||||
|
paneKey: string,
|
||||||
|
): WorkbenchState {
|
||||||
|
if (!anchorPaneKey || !paneKey || anchorPaneKey === paneKey) return state;
|
||||||
|
const target = workbenchTabForPane(state, anchorPaneKey);
|
||||||
|
if (state.tabs[target.tabKey]) return attachWorkbenchPane(state, target.tabKey, paneKey);
|
||||||
|
const withTarget = {
|
||||||
|
version: 1 as const,
|
||||||
|
tabs: {
|
||||||
|
...state.tabs,
|
||||||
|
[target.tabKey]: target.tab,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return attachWorkbenchPane(withTarget, target.tabKey, paneKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createWorkbenchTab(
|
||||||
|
state: WorkbenchState,
|
||||||
|
paneKey: string,
|
||||||
|
): WorkbenchState {
|
||||||
|
if (!paneKey) return state;
|
||||||
|
const match = workbenchTabForPane(state, paneKey);
|
||||||
|
const persisted = state.tabs[match.tabKey];
|
||||||
|
if (persisted) {
|
||||||
|
return updateTab(state, match.tabKey, (tab) => (
|
||||||
|
tab.explicit ? tab : { ...tab, explicit: true }
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
tabs: {
|
||||||
|
...state.tabs,
|
||||||
|
[match.tabKey]: { ...match.tab, explicit: true },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function detachWorkbenchPane(
|
||||||
|
state: WorkbenchState,
|
||||||
|
tabKey: string,
|
||||||
|
paneKey: string,
|
||||||
|
): WorkbenchState {
|
||||||
|
const tab = state.tabs[tabKey];
|
||||||
|
if (!tab || !tab.paneKeys.includes(paneKey)) return state;
|
||||||
|
if (tab.paneKeys.length === 1) {
|
||||||
|
const tabs = { ...state.tabs };
|
||||||
|
delete tabs[tabKey];
|
||||||
|
return { version: 1, tabs };
|
||||||
|
}
|
||||||
|
|
||||||
|
const paneKeys = tab.paneKeys.filter((key) => key !== paneKey);
|
||||||
|
const layoutPaneKeys = tab.layoutPaneKeys.filter((key) => key !== paneKey);
|
||||||
|
const tabs = { ...state.tabs };
|
||||||
|
const nextTab = {
|
||||||
|
...tab,
|
||||||
|
paneKeys,
|
||||||
|
layoutPaneKeys,
|
||||||
|
splitRatios: [],
|
||||||
|
};
|
||||||
|
if (nextTab.explicit || paneKeys.length > 1) tabs[tabKey] = nextTab;
|
||||||
|
else delete tabs[tabKey];
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
tabs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dissolveWorkbenchTab(
|
||||||
|
state: WorkbenchState,
|
||||||
|
tabKey: string,
|
||||||
|
): WorkbenchState {
|
||||||
|
const tab = state.tabs[tabKey];
|
||||||
|
if (!tab) return state;
|
||||||
|
const tabs = { ...state.tabs };
|
||||||
|
delete tabs[tabKey];
|
||||||
|
return { version: 1, tabs };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function attachWorkbenchPane(
|
||||||
|
state: WorkbenchState,
|
||||||
|
targetTabKey: string,
|
||||||
|
paneKey: string,
|
||||||
|
): WorkbenchState {
|
||||||
|
if (!targetTabKey || !paneKey) return state;
|
||||||
|
const target = state.tabs[targetTabKey];
|
||||||
|
if (!target) return state;
|
||||||
|
|
||||||
|
const sourceEntry = Object.entries(state.tabs).find(([, tab]) => (
|
||||||
|
tab.paneKeys.includes(paneKey)
|
||||||
|
));
|
||||||
|
const sourceTabKey = sourceEntry?.[0];
|
||||||
|
const sourceTab = sourceEntry?.[1];
|
||||||
|
if (sourceTabKey === targetTabKey) {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
if (!target.paneKeys.includes(paneKey) && target.paneKeys.length >= MAX_WORKBENCH_PANES) {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabs = { ...state.tabs };
|
||||||
|
if (sourceTabKey && sourceTab) {
|
||||||
|
const sourcePaneKeys = sourceTab.paneKeys.filter((key) => key !== paneKey);
|
||||||
|
const sourceLayoutPaneKeys = sourceTab.layoutPaneKeys.filter((key) => key !== paneKey);
|
||||||
|
if (sourcePaneKeys.length === 0 || (!sourceTab.explicit && sourcePaneKeys.length === 1)) {
|
||||||
|
delete tabs[sourceTabKey];
|
||||||
|
} else {
|
||||||
|
tabs[sourceTabKey] = {
|
||||||
|
...sourceTab,
|
||||||
|
paneKeys: sourcePaneKeys,
|
||||||
|
layoutPaneKeys: sourceLayoutPaneKeys,
|
||||||
|
splitRatios: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextTarget = tabs[targetTabKey];
|
||||||
|
if (!nextTarget) return state;
|
||||||
|
const paneKeys = nextTarget.paneKeys.includes(paneKey)
|
||||||
|
? nextTarget.paneKeys
|
||||||
|
: [...nextTarget.paneKeys, paneKey];
|
||||||
|
const layoutPaneKeys = nextTarget.layoutPaneKeys.includes(paneKey)
|
||||||
|
? nextTarget.layoutPaneKeys
|
||||||
|
: [...nextTarget.layoutPaneKeys, paneKey];
|
||||||
|
tabs[targetTabKey] = {
|
||||||
|
...nextTarget,
|
||||||
|
paneKeys,
|
||||||
|
layoutPaneKeys,
|
||||||
|
splitRatios: [],
|
||||||
|
};
|
||||||
|
return { version: 1, tabs };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renameWorkbenchTab(
|
||||||
|
state: WorkbenchState,
|
||||||
|
tabKey: string,
|
||||||
|
title: string,
|
||||||
|
): WorkbenchState {
|
||||||
|
const normalized = normalizeTitle(title);
|
||||||
|
if (!normalized) return state;
|
||||||
|
return updateTab(state, tabKey, (tab) => (
|
||||||
|
tab.title === normalized ? tab : { ...tab, title: normalized }
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setWorkbenchLayout(
|
||||||
|
state: WorkbenchState,
|
||||||
|
tabKey: string,
|
||||||
|
layout: WorkbenchLayout,
|
||||||
|
): WorkbenchState {
|
||||||
|
return updateTab(state, tabKey, (tab) => (
|
||||||
|
tab.layout === layout ? tab : { ...tab, layout, splitRatios: [] }
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setWorkbenchSplitRatios(
|
||||||
|
state: WorkbenchState,
|
||||||
|
tabKey: string,
|
||||||
|
splitRatios: readonly number[],
|
||||||
|
): WorkbenchState {
|
||||||
|
return updateTab(state, tabKey, (tab) => {
|
||||||
|
const normalized = normalizeSplitRatios(splitRatios);
|
||||||
|
return normalized.length === tab.splitRatios.length
|
||||||
|
&& normalized.every((ratio, index) => ratio === tab.splitRatios[index])
|
||||||
|
? tab
|
||||||
|
: { ...tab, splitRatios: normalized };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setWorkbenchPaneLayoutOrder(
|
||||||
|
state: WorkbenchState,
|
||||||
|
tabKey: string,
|
||||||
|
paneKeys: readonly string[],
|
||||||
|
): WorkbenchState {
|
||||||
|
return updateTab(state, tabKey, (tab) => {
|
||||||
|
const requested = uniqueKeys(paneKeys).filter((key) => tab.paneKeys.includes(key));
|
||||||
|
const layoutPaneKeys = [
|
||||||
|
...requested,
|
||||||
|
...tab.paneKeys.filter((key) => !requested.includes(key)),
|
||||||
|
];
|
||||||
|
return layoutPaneKeys.every((key, index) => tab.layoutPaneKeys[index] === key)
|
||||||
|
? tab
|
||||||
|
: { ...tab, layoutPaneKeys };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reconcileWorkbench(
|
||||||
|
state: WorkbenchState,
|
||||||
|
validKeys: ReadonlySet<string>,
|
||||||
|
): WorkbenchState {
|
||||||
|
const tabs: Record<string, WorkbenchTabState> = {};
|
||||||
|
const claimedPaneKeys = new Set<string>();
|
||||||
|
|
||||||
|
for (const [tabKey, tab] of Object.entries(state.tabs)) {
|
||||||
|
const paneKeys = tab.paneKeys
|
||||||
|
.filter((key) => validKeys.has(key) && !claimedPaneKeys.has(key))
|
||||||
|
.slice(0, MAX_WORKBENCH_PANES);
|
||||||
|
if (paneKeys.length === 0) continue;
|
||||||
|
const nextTab = {
|
||||||
|
...tab,
|
||||||
|
paneKeys,
|
||||||
|
layoutPaneKeys: [
|
||||||
|
...tab.layoutPaneKeys.filter((key) => paneKeys.includes(key)),
|
||||||
|
...paneKeys.filter((key) => !tab.layoutPaneKeys.includes(key)),
|
||||||
|
],
|
||||||
|
splitRatios: paneKeys.length === tab.paneKeys.length
|
||||||
|
&& paneKeys.every((key, index) => key === tab.paneKeys[index])
|
||||||
|
? tab.splitRatios
|
||||||
|
: [],
|
||||||
|
};
|
||||||
|
if (!nextTab.explicit && paneKeys.length === 1) continue;
|
||||||
|
for (const paneKey of paneKeys) claimedPaneKeys.add(paneKey);
|
||||||
|
tabs[tabKey] = nextTab;
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.stringify(state.tabs) === JSON.stringify(tabs)
|
||||||
|
? state
|
||||||
|
: { version: 1, tabs };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function orderWorkbenchTabs(
|
||||||
|
state: WorkbenchState,
|
||||||
|
orderedSessionKeys: readonly string[],
|
||||||
|
updatedAtByKey: ReadonlyMap<string, string | null | undefined>,
|
||||||
|
): OrderedWorkbenchTab[] {
|
||||||
|
const rank = new Map(orderedSessionKeys.map((key, index) => [key, index]));
|
||||||
|
const validKeys = new Set(orderedSessionKeys);
|
||||||
|
const reconciled = reconcileWorkbench(state, validKeys);
|
||||||
|
const projectedTabs = { ...reconciled.tabs };
|
||||||
|
const claimedPaneKeys = new Set(
|
||||||
|
Object.values(projectedTabs).flatMap((tab) => tab.paneKeys),
|
||||||
|
);
|
||||||
|
for (const paneKey of orderedSessionKeys) {
|
||||||
|
if (claimedPaneKeys.has(paneKey)) continue;
|
||||||
|
const tabKey = availableStandaloneTabKey(projectedTabs, paneKey);
|
||||||
|
projectedTabs[tabKey] = defaultWorkbenchTab(paneKey);
|
||||||
|
}
|
||||||
|
const tabs = Object.entries(projectedTabs).map(([tabKey, tab]) => {
|
||||||
|
const paneKeys = tab.paneKeys
|
||||||
|
.filter((key) => validKeys.has(key))
|
||||||
|
.sort((left, right) => (rank.get(left) ?? Infinity) - (rank.get(right) ?? Infinity));
|
||||||
|
const updatedAt = paneKeys.reduce<string | null>((latest, paneKey) => {
|
||||||
|
const candidate = updatedAtByKey.get(paneKey) ?? null;
|
||||||
|
return dateToTime(candidate) > dateToTime(latest) ? candidate : latest;
|
||||||
|
}, null);
|
||||||
|
return { tabKey, tab, paneKeys, updatedAt };
|
||||||
|
});
|
||||||
|
|
||||||
|
return tabs.sort((left, right) => {
|
||||||
|
const updateOrder = dateToTime(right.updatedAt) - dateToTime(left.updatedAt);
|
||||||
|
if (updateOrder !== 0) return updateOrder;
|
||||||
|
const leftRank = Math.min(...left.paneKeys.map((key) => rank.get(key) ?? Infinity));
|
||||||
|
const rightRank = Math.min(...right.paneKeys.map((key) => rank.get(key) ?? Infinity));
|
||||||
|
return leftRank - rightRank;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function dateToTime(value: string | null | undefined): number {
|
||||||
|
const timestamp = Date.parse(value ?? "");
|
||||||
|
return Number.isFinite(timestamp) ? timestamp : 0;
|
||||||
|
}
|
||||||
@@ -40,6 +40,7 @@
|
|||||||
--radius: 0.4375rem;
|
--radius: 0.4375rem;
|
||||||
--sidebar: 40 8% 96.8%;
|
--sidebar: 40 8% 96.8%;
|
||||||
--sidebar-foreground: 0 0% 3.9%;
|
--sidebar-foreground: 0 0% 3.9%;
|
||||||
|
--sidebar-selected: 40 1% 89.4%;
|
||||||
--sidebar-accent: 0 0% 95.8%;
|
--sidebar-accent: 0 0% 95.8%;
|
||||||
--sidebar-accent-foreground: 0 0% 9%;
|
--sidebar-accent-foreground: 0 0% 9%;
|
||||||
--sidebar-border: 40 8% 90.5%;
|
--sidebar-border: 40 8% 90.5%;
|
||||||
@@ -77,6 +78,7 @@
|
|||||||
--temporary-border: 27 96% 61%;
|
--temporary-border: 27 96% 61%;
|
||||||
--sidebar: var(--card);
|
--sidebar: var(--card);
|
||||||
--sidebar-foreground: 0 0% 98%;
|
--sidebar-foreground: 0 0% 98%;
|
||||||
|
--sidebar-selected: 0 0% 29.8%;
|
||||||
--sidebar-accent: var(--background);
|
--sidebar-accent: var(--background);
|
||||||
--sidebar-accent-foreground: 0 0% 98%;
|
--sidebar-accent-foreground: 0 0% 98%;
|
||||||
--sidebar-border: var(--border);
|
--sidebar-border: var(--border);
|
||||||
@@ -360,6 +362,9 @@
|
|||||||
.thread-layout[data-layout="thread"] {
|
.thread-layout[data-layout="thread"] {
|
||||||
grid-template-rows: minmax(0, 1fr) auto 0fr;
|
grid-template-rows: minmax(0, 1fr) auto 0fr;
|
||||||
}
|
}
|
||||||
|
.thread-layout[data-layout="external"] {
|
||||||
|
grid-template-rows: minmax(0, 1fr);
|
||||||
|
}
|
||||||
@media (min-width: 640px) {
|
@media (min-width: 640px) {
|
||||||
.thread-layout[data-layout="hero"] {
|
.thread-layout[data-layout="hero"] {
|
||||||
grid-template-rows: minmax(min-content, 1fr) auto 1fr;
|
grid-template-rows: minmax(min-content, 1fr) auto 1fr;
|
||||||
@@ -564,6 +569,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.workbench-pane {
|
||||||
|
transform-origin: top left;
|
||||||
|
}
|
||||||
|
|
||||||
/** Goal halo: pale sky blue (not ``--primary``, which often reads as neutral gray). */
|
/** Goal halo: pale sky blue (not ``--primary``, which often reads as neutral gray). */
|
||||||
@keyframes goal-shell-glow-breathe {
|
@keyframes goal-shell-glow-breathe {
|
||||||
0%,
|
0%,
|
||||||
@@ -858,3 +867,9 @@
|
|||||||
min-height: 2rem;
|
min-height: 2rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@media (forced-colors: active) {
|
||||||
|
[data-workbench-move-handle]:focus-visible {
|
||||||
|
outline: 2px solid CanvasText;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
import { useClient } from "@/providers/ClientProvider";
|
import { useClient } from "@/providers/ClientProvider";
|
||||||
|
import { normalizeWorkbenchState } from "@/components/workbench/workbench-model";
|
||||||
import { fetchSidebarState } from "@/lib/api";
|
import { fetchSidebarState } from "@/lib/api";
|
||||||
import type { ChatSummary, SidebarStatePayload } from "@/lib/types";
|
import type { ChatSummary, SidebarStatePayload } from "@/lib/types";
|
||||||
|
|
||||||
@@ -13,6 +14,7 @@ export const DEFAULT_SIDEBAR_STATE: SidebarStatePayload = {
|
|||||||
project_name_overrides: {},
|
project_name_overrides: {},
|
||||||
tags_by_key: {},
|
tags_by_key: {},
|
||||||
collapsed_groups: {},
|
collapsed_groups: {},
|
||||||
|
workbench: { version: 1, tabs: {} },
|
||||||
view: {
|
view: {
|
||||||
density: "comfortable",
|
density: "comfortable",
|
||||||
show_previews: false,
|
show_previews: false,
|
||||||
@@ -93,6 +95,7 @@ export function normalizeSidebarState(raw: unknown): SidebarStatePayload {
|
|||||||
project_name_overrides: stringMap(value.project_name_overrides),
|
project_name_overrides: stringMap(value.project_name_overrides),
|
||||||
tags_by_key: tagsMap(value.tags_by_key),
|
tags_by_key: tagsMap(value.tags_by_key),
|
||||||
collapsed_groups: boolMap(value.collapsed_groups),
|
collapsed_groups: boolMap(value.collapsed_groups),
|
||||||
|
workbench: normalizeWorkbenchState(value.workbench),
|
||||||
view: {
|
view: {
|
||||||
density,
|
density,
|
||||||
show_previews: Boolean(view.show_previews),
|
show_previews: Boolean(view.show_previews),
|
||||||
@@ -146,6 +149,8 @@ export function useSidebarState(
|
|||||||
const stateRef = useRef(DEFAULT_SIDEBAR_STATE);
|
const stateRef = useRef(DEFAULT_SIDEBAR_STATE);
|
||||||
const connectionOpenRef = useRef(client.status === "open");
|
const connectionOpenRef = useRef(client.status === "open");
|
||||||
const pendingPersistenceRef = useRef<SidebarStatePayload | null>(null);
|
const pendingPersistenceRef = useRef<SidebarStatePayload | null>(null);
|
||||||
|
const persistenceInFlightRef = useRef(false);
|
||||||
|
const flushPersistenceRef = useRef<() => void>(() => {});
|
||||||
const [state, setState] = useState<SidebarStatePayload>(DEFAULT_SIDEBAR_STATE);
|
const [state, setState] = useState<SidebarStatePayload>(DEFAULT_SIDEBAR_STATE);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
tokenRef.current = token;
|
tokenRef.current = token;
|
||||||
@@ -173,27 +178,59 @@ export function useSidebarState(
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const persist = useCallback((next: SidebarStatePayload) => {
|
const flushPersistence = useCallback(() => {
|
||||||
if (!connectionOpenRef.current) {
|
if (
|
||||||
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) => {
|
||||||
const next = normalizeSidebarState(updater(stateRef.current));
|
const next = normalizeSidebarState(updater(stateRef.current));
|
||||||
|
if (sameState(next, stateRef.current)) return;
|
||||||
stateRef.current = next;
|
stateRef.current = next;
|
||||||
setState(next);
|
setState(next);
|
||||||
persist(next);
|
persist(next);
|
||||||
|
|||||||
@@ -989,6 +989,10 @@
|
|||||||
"unarchive": "Unarchive",
|
"unarchive": "Unarchive",
|
||||||
"showArchived": "Show archived",
|
"showArchived": "Show archived",
|
||||||
"hideArchived": "Hide archived",
|
"hideArchived": "Hide archived",
|
||||||
|
"select": "Select",
|
||||||
|
"cancelSelection": "Cancel selection",
|
||||||
|
"selectedCount": "{{count}} selected",
|
||||||
|
"deleteSelected": "Delete",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"newChat": "New topic",
|
"newChat": "New topic",
|
||||||
"groups": {
|
"groups": {
|
||||||
@@ -1003,10 +1007,13 @@
|
|||||||
},
|
},
|
||||||
"deleteConfirm": {
|
"deleteConfirm": {
|
||||||
"title": "Delete this topic?",
|
"title": "Delete this topic?",
|
||||||
|
"titleMany": "Delete {{count}} conversations?",
|
||||||
"description": "This action cannot be undone.",
|
"description": "This action cannot be undone.",
|
||||||
|
"descriptionMany": "This action cannot be undone.",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"confirm": "Delete",
|
"confirm": "Delete",
|
||||||
"automationsDescription": "This chat has scheduled automations. Deleting it will also delete them.",
|
"automationsDescription": "This chat has scheduled automations. Deleting it will also delete them.",
|
||||||
|
"automationsDescriptionMany": "Linked automations will also be deleted.",
|
||||||
"moreAutomations": "+ {{count}} more",
|
"moreAutomations": "+ {{count}} more",
|
||||||
"confirmWithAutomations": "Delete",
|
"confirmWithAutomations": "Delete",
|
||||||
"schedule": {
|
"schedule": {
|
||||||
@@ -1402,6 +1409,39 @@
|
|||||||
"copy": "Copy",
|
"copy": "Copy",
|
||||||
"copied": "Copied"
|
"copied": "Copied"
|
||||||
},
|
},
|
||||||
|
"workbench": {
|
||||||
|
"aria": "Conversation workbench",
|
||||||
|
"panes": "Panes",
|
||||||
|
"tabAria": "Tab: {{title}}",
|
||||||
|
"panesInTab": "Panes in {{title}}",
|
||||||
|
"collapseTabGroup": "Collapse panes in {{title}}",
|
||||||
|
"expandTabGroup": "Expand panes in {{title}}",
|
||||||
|
"dropPane": "Move {{pane}} into {{tab}}",
|
||||||
|
"createGroup": "Create group",
|
||||||
|
"moveTo": "Move to",
|
||||||
|
"renameTabTitle": "Rename tab",
|
||||||
|
"renameTabDescription": "Give this tab a name for organizing its panes.",
|
||||||
|
"renameTabPlaceholder": "Tab name",
|
||||||
|
"dissolveTab": "Dissolve group",
|
||||||
|
"layout": "Pane layout",
|
||||||
|
"addPane": "Add pane",
|
||||||
|
"paneLimit": "Maximum {{count}} panes",
|
||||||
|
"deleteConversations": "Delete all chats",
|
||||||
|
"movePane": "Move {{title}} pane",
|
||||||
|
"movePaneHint": "Drag to move · Arrow keys also work",
|
||||||
|
"resizePaneBoundary": "Resize pane boundary {{index}}",
|
||||||
|
"promotePane": "Make {{title}} the primary pane",
|
||||||
|
"paneActions": "{{title}} pane actions",
|
||||||
|
"detachPane": "Remove",
|
||||||
|
"composerAria": "Message {{title}}",
|
||||||
|
"layouts": {
|
||||||
|
"columns": "Columns",
|
||||||
|
"rows": "Rows",
|
||||||
|
"grid": "Grid",
|
||||||
|
"bsp": "BSP",
|
||||||
|
"main-stack": "Main and stack"
|
||||||
|
}
|
||||||
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"dismiss": "Dismiss",
|
"dismiss": "Dismiss",
|
||||||
"close": "Close",
|
"close": "Close",
|
||||||
|
|||||||
@@ -976,6 +976,10 @@
|
|||||||
"unarchive": "Desarchivar",
|
"unarchive": "Desarchivar",
|
||||||
"showArchived": "Mostrar archivados",
|
"showArchived": "Mostrar archivados",
|
||||||
"hideArchived": "Ocultar archivados",
|
"hideArchived": "Ocultar archivados",
|
||||||
|
"select": "Seleccionar",
|
||||||
|
"cancelSelection": "Cancelar selección",
|
||||||
|
"selectedCount": "{{count}} seleccionados",
|
||||||
|
"deleteSelected": "Eliminar",
|
||||||
"delete": "Eliminar",
|
"delete": "Eliminar",
|
||||||
"newChat": "Nuevo tema",
|
"newChat": "Nuevo tema",
|
||||||
"groups": {
|
"groups": {
|
||||||
@@ -990,10 +994,13 @@
|
|||||||
},
|
},
|
||||||
"deleteConfirm": {
|
"deleteConfirm": {
|
||||||
"title": "¿Eliminar este chat?",
|
"title": "¿Eliminar este chat?",
|
||||||
|
"titleMany": "¿Eliminar {{count}} conversaciones?",
|
||||||
"description": "Esta acción no se puede deshacer.",
|
"description": "Esta acción no se puede deshacer.",
|
||||||
|
"descriptionMany": "Esta acción no se puede deshacer.",
|
||||||
"cancel": "Cancelar",
|
"cancel": "Cancelar",
|
||||||
"confirm": "Eliminar",
|
"confirm": "Eliminar",
|
||||||
"automationsDescription": "Este chat tiene automatizaciones programadas. Al eliminarlo también se eliminarán.",
|
"automationsDescription": "Este chat tiene automatizaciones programadas. Al eliminarlo también se eliminarán.",
|
||||||
|
"automationsDescriptionMany": "También se eliminarán las automatizaciones vinculadas.",
|
||||||
"moreAutomations": "+ {{count}} más",
|
"moreAutomations": "+ {{count}} más",
|
||||||
"confirmWithAutomations": "Eliminar",
|
"confirmWithAutomations": "Eliminar",
|
||||||
"schedule": {
|
"schedule": {
|
||||||
@@ -1389,6 +1396,39 @@
|
|||||||
"copy": "Copiar",
|
"copy": "Copiar",
|
||||||
"copied": "Copiado"
|
"copied": "Copiado"
|
||||||
},
|
},
|
||||||
|
"workbench": {
|
||||||
|
"aria": "Área de conversaciones",
|
||||||
|
"panes": "Paneles",
|
||||||
|
"tabAria": "Pestaña: {{title}}",
|
||||||
|
"panesInTab": "Paneles de {{title}}",
|
||||||
|
"collapseTabGroup": "Contraer los paneles de {{title}}",
|
||||||
|
"expandTabGroup": "Expandir los paneles de {{title}}",
|
||||||
|
"dropPane": "Mover {{pane}} a {{tab}}",
|
||||||
|
"createGroup": "Crear grupo",
|
||||||
|
"moveTo": "Mover a",
|
||||||
|
"renameTabTitle": "Renombrar pestaña",
|
||||||
|
"renameTabDescription": "Ponle un nombre a esta pestaña para organizar sus paneles.",
|
||||||
|
"renameTabPlaceholder": "Nombre de la pestaña",
|
||||||
|
"dissolveTab": "Disolver grupo",
|
||||||
|
"layout": "Diseño de paneles",
|
||||||
|
"addPane": "Añadir panel",
|
||||||
|
"paneLimit": "Máximo {{count}} paneles",
|
||||||
|
"deleteConversations": "Eliminar todo",
|
||||||
|
"movePane": "Mover panel {{title}}",
|
||||||
|
"movePaneHint": "Arrastra para mover · También puedes usar las flechas",
|
||||||
|
"resizePaneBoundary": "Redimensionar límite de panel {{index}}",
|
||||||
|
"promotePane": "Convertir {{title}} en el panel principal",
|
||||||
|
"paneActions": "Acciones del panel {{title}}",
|
||||||
|
"detachPane": "Quitar",
|
||||||
|
"composerAria": "Mensaje para {{title}}",
|
||||||
|
"layouts": {
|
||||||
|
"columns": "Columnas",
|
||||||
|
"rows": "Filas",
|
||||||
|
"grid": "Cuadrícula",
|
||||||
|
"bsp": "BSP",
|
||||||
|
"main-stack": "Principal y pila"
|
||||||
|
}
|
||||||
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"dismiss": "Cerrar",
|
"dismiss": "Cerrar",
|
||||||
"close": "Cerrar",
|
"close": "Cerrar",
|
||||||
|
|||||||
@@ -975,6 +975,10 @@
|
|||||||
"unarchive": "Désarchiver",
|
"unarchive": "Désarchiver",
|
||||||
"showArchived": "Afficher les archives",
|
"showArchived": "Afficher les archives",
|
||||||
"hideArchived": "Masquer les archives",
|
"hideArchived": "Masquer les archives",
|
||||||
|
"select": "Sélectionner",
|
||||||
|
"cancelSelection": "Annuler la sélection",
|
||||||
|
"selectedCount": "{{count}} sélectionnés",
|
||||||
|
"deleteSelected": "Supprimer",
|
||||||
"delete": "Supprimer",
|
"delete": "Supprimer",
|
||||||
"newChat": "Nouveau sujet",
|
"newChat": "Nouveau sujet",
|
||||||
"groups": {
|
"groups": {
|
||||||
@@ -989,10 +993,13 @@
|
|||||||
},
|
},
|
||||||
"deleteConfirm": {
|
"deleteConfirm": {
|
||||||
"title": "Supprimer cette discussion ?",
|
"title": "Supprimer cette discussion ?",
|
||||||
|
"titleMany": "Supprimer {{count}} conversations ?",
|
||||||
"description": "Cette action est irréversible.",
|
"description": "Cette action est irréversible.",
|
||||||
|
"descriptionMany": "Cette action est irréversible.",
|
||||||
"cancel": "Annuler",
|
"cancel": "Annuler",
|
||||||
"confirm": "Supprimer",
|
"confirm": "Supprimer",
|
||||||
"automationsDescription": "Cette discussion contient des automatisations planifiées. La supprimer les supprimera aussi.",
|
"automationsDescription": "Cette discussion contient des automatisations planifiées. La supprimer les supprimera aussi.",
|
||||||
|
"automationsDescriptionMany": "Les automatisations liées seront également supprimées.",
|
||||||
"moreAutomations": "+ {{count}} autres",
|
"moreAutomations": "+ {{count}} autres",
|
||||||
"confirmWithAutomations": "Supprimer",
|
"confirmWithAutomations": "Supprimer",
|
||||||
"schedule": {
|
"schedule": {
|
||||||
@@ -1388,6 +1395,39 @@
|
|||||||
"copy": "Copier",
|
"copy": "Copier",
|
||||||
"copied": "Copié"
|
"copied": "Copié"
|
||||||
},
|
},
|
||||||
|
"workbench": {
|
||||||
|
"aria": "Espace de conversations",
|
||||||
|
"panes": "Volets",
|
||||||
|
"tabAria": "Onglet : {{title}}",
|
||||||
|
"panesInTab": "Volets dans {{title}}",
|
||||||
|
"collapseTabGroup": "Réduire les volets de {{title}}",
|
||||||
|
"expandTabGroup": "Développer les volets de {{title}}",
|
||||||
|
"dropPane": "Déplacer {{pane}} dans {{tab}}",
|
||||||
|
"createGroup": "Créer un groupe",
|
||||||
|
"moveTo": "Déplacer vers",
|
||||||
|
"renameTabTitle": "Renommer l’onglet",
|
||||||
|
"renameTabDescription": "Donnez un nom à cet onglet pour organiser ses volets.",
|
||||||
|
"renameTabPlaceholder": "Nom de l’onglet",
|
||||||
|
"dissolveTab": "Dissoudre le groupe",
|
||||||
|
"layout": "Disposition des volets",
|
||||||
|
"addPane": "Ajouter un volet",
|
||||||
|
"paneLimit": "{{count}} volets maximum",
|
||||||
|
"deleteConversations": "Tout supprimer",
|
||||||
|
"movePane": "Déplacer le volet {{title}}",
|
||||||
|
"movePaneHint": "Faites glisser pour déplacer · Les flèches fonctionnent aussi",
|
||||||
|
"resizePaneBoundary": "Redimensionner la séparation {{index}}",
|
||||||
|
"promotePane": "Définir {{title}} comme volet principal",
|
||||||
|
"paneActions": "Actions du volet {{title}}",
|
||||||
|
"detachPane": "Retirer",
|
||||||
|
"composerAria": "Message à {{title}}",
|
||||||
|
"layouts": {
|
||||||
|
"columns": "Colonnes",
|
||||||
|
"rows": "Lignes",
|
||||||
|
"grid": "Grille",
|
||||||
|
"bsp": "BSP",
|
||||||
|
"main-stack": "Principal et pile"
|
||||||
|
}
|
||||||
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"dismiss": "Fermer",
|
"dismiss": "Fermer",
|
||||||
"close": "Fermer",
|
"close": "Fermer",
|
||||||
|
|||||||
@@ -975,6 +975,10 @@
|
|||||||
"unarchive": "Batalkan arsip",
|
"unarchive": "Batalkan arsip",
|
||||||
"showArchived": "Tampilkan yang diarsipkan",
|
"showArchived": "Tampilkan yang diarsipkan",
|
||||||
"hideArchived": "Sembunyikan yang diarsipkan",
|
"hideArchived": "Sembunyikan yang diarsipkan",
|
||||||
|
"select": "Pilih",
|
||||||
|
"cancelSelection": "Batalkan pilihan",
|
||||||
|
"selectedCount": "{{count}} dipilih",
|
||||||
|
"deleteSelected": "Hapus",
|
||||||
"delete": "Hapus",
|
"delete": "Hapus",
|
||||||
"newChat": "Topik baru",
|
"newChat": "Topik baru",
|
||||||
"groups": {
|
"groups": {
|
||||||
@@ -989,10 +993,13 @@
|
|||||||
},
|
},
|
||||||
"deleteConfirm": {
|
"deleteConfirm": {
|
||||||
"title": "Hapus obrolan ini?",
|
"title": "Hapus obrolan ini?",
|
||||||
|
"titleMany": "Hapus {{count}} percakapan?",
|
||||||
"description": "Tindakan ini tidak dapat dibatalkan.",
|
"description": "Tindakan ini tidak dapat dibatalkan.",
|
||||||
|
"descriptionMany": "Tindakan ini tidak dapat dibatalkan.",
|
||||||
"cancel": "Batal",
|
"cancel": "Batal",
|
||||||
"confirm": "Hapus",
|
"confirm": "Hapus",
|
||||||
"automationsDescription": "Obrolan ini memiliki automasi terjadwal. Menghapusnya juga akan menghapus automasi tersebut.",
|
"automationsDescription": "Obrolan ini memiliki automasi terjadwal. Menghapusnya juga akan menghapus automasi tersebut.",
|
||||||
|
"automationsDescriptionMany": "Automasi terkait juga akan dihapus.",
|
||||||
"moreAutomations": "+ {{count}} lagi",
|
"moreAutomations": "+ {{count}} lagi",
|
||||||
"confirmWithAutomations": "Hapus",
|
"confirmWithAutomations": "Hapus",
|
||||||
"schedule": {
|
"schedule": {
|
||||||
@@ -1388,6 +1395,39 @@
|
|||||||
"copy": "Salin",
|
"copy": "Salin",
|
||||||
"copied": "Tersalin"
|
"copied": "Tersalin"
|
||||||
},
|
},
|
||||||
|
"workbench": {
|
||||||
|
"aria": "Ruang kerja percakapan",
|
||||||
|
"panes": "Panel",
|
||||||
|
"tabAria": "Tab: {{title}}",
|
||||||
|
"panesInTab": "Panel di {{title}}",
|
||||||
|
"collapseTabGroup": "Ciutkan panel di {{title}}",
|
||||||
|
"expandTabGroup": "Luaskan panel di {{title}}",
|
||||||
|
"dropPane": "Pindahkan {{pane}} ke {{tab}}",
|
||||||
|
"createGroup": "Buat grup",
|
||||||
|
"moveTo": "Pindahkan ke",
|
||||||
|
"renameTabTitle": "Ganti nama tab",
|
||||||
|
"renameTabDescription": "Beri nama tab ini untuk mengatur panelnya.",
|
||||||
|
"renameTabPlaceholder": "Nama tab",
|
||||||
|
"dissolveTab": "Bubarkan grup",
|
||||||
|
"layout": "Tata letak panel",
|
||||||
|
"addPane": "Tambah panel",
|
||||||
|
"paneLimit": "Maksimal {{count}} panel",
|
||||||
|
"deleteConversations": "Hapus semua",
|
||||||
|
"movePane": "Pindahkan panel {{title}}",
|
||||||
|
"movePaneHint": "Seret untuk memindahkan · Tombol panah juga dapat digunakan",
|
||||||
|
"resizePaneBoundary": "Ubah ukuran batas panel {{index}}",
|
||||||
|
"promotePane": "Jadikan {{title}} panel utama",
|
||||||
|
"paneActions": "Tindakan panel {{title}}",
|
||||||
|
"detachPane": "Keluarkan",
|
||||||
|
"composerAria": "Pesan untuk {{title}}",
|
||||||
|
"layouts": {
|
||||||
|
"columns": "Kolom",
|
||||||
|
"rows": "Baris",
|
||||||
|
"grid": "Kisi",
|
||||||
|
"bsp": "BSP",
|
||||||
|
"main-stack": "Utama dan tumpukan"
|
||||||
|
}
|
||||||
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"dismiss": "Tutup",
|
"dismiss": "Tutup",
|
||||||
"close": "Tutup",
|
"close": "Tutup",
|
||||||
|
|||||||
@@ -975,6 +975,10 @@
|
|||||||
"unarchive": "アーカイブを解除",
|
"unarchive": "アーカイブを解除",
|
||||||
"showArchived": "アーカイブ済みを表示",
|
"showArchived": "アーカイブ済みを表示",
|
||||||
"hideArchived": "アーカイブ済みを隠す",
|
"hideArchived": "アーカイブ済みを隠す",
|
||||||
|
"select": "選択",
|
||||||
|
"cancelSelection": "選択を解除",
|
||||||
|
"selectedCount": "{{count}} 件を選択中",
|
||||||
|
"deleteSelected": "削除",
|
||||||
"delete": "削除",
|
"delete": "削除",
|
||||||
"newChat": "新しいトピック",
|
"newChat": "新しいトピック",
|
||||||
"groups": {
|
"groups": {
|
||||||
@@ -989,10 +993,13 @@
|
|||||||
},
|
},
|
||||||
"deleteConfirm": {
|
"deleteConfirm": {
|
||||||
"title": "このチャットを削除しますか?",
|
"title": "このチャットを削除しますか?",
|
||||||
|
"titleMany": "{{count}} 件の会話を削除しますか?",
|
||||||
"description": "この操作は元に戻せません。",
|
"description": "この操作は元に戻せません。",
|
||||||
|
"descriptionMany": "この操作は元に戻せません。",
|
||||||
"cancel": "キャンセル",
|
"cancel": "キャンセル",
|
||||||
"confirm": "削除",
|
"confirm": "削除",
|
||||||
"automationsDescription": "このチャットにはスケジュール済みの自動タスクがあります。削除するとそれらも削除されます。",
|
"automationsDescription": "このチャットにはスケジュール済みの自動タスクがあります。削除するとそれらも削除されます。",
|
||||||
|
"automationsDescriptionMany": "関連する自動タスクも削除されます。",
|
||||||
"moreAutomations": "他 {{count}} 件",
|
"moreAutomations": "他 {{count}} 件",
|
||||||
"confirmWithAutomations": "削除",
|
"confirmWithAutomations": "削除",
|
||||||
"schedule": {
|
"schedule": {
|
||||||
@@ -1388,6 +1395,39 @@
|
|||||||
"copy": "コピー",
|
"copy": "コピー",
|
||||||
"copied": "コピーしました"
|
"copied": "コピーしました"
|
||||||
},
|
},
|
||||||
|
"workbench": {
|
||||||
|
"aria": "会話ワークベンチ",
|
||||||
|
"panes": "ペイン",
|
||||||
|
"tabAria": "タブ:{{title}}",
|
||||||
|
"panesInTab": "{{title}} のペイン",
|
||||||
|
"collapseTabGroup": "{{title}} のペインを折りたたむ",
|
||||||
|
"expandTabGroup": "{{title}} のペインを展開する",
|
||||||
|
"dropPane": "{{pane}} を {{tab}} に移動",
|
||||||
|
"createGroup": "グループを作成",
|
||||||
|
"moveTo": "移動先",
|
||||||
|
"renameTabTitle": "タブ名を変更",
|
||||||
|
"renameTabDescription": "ペインを整理するため、このタブに名前を付けます。",
|
||||||
|
"renameTabPlaceholder": "タブ名",
|
||||||
|
"dissolveTab": "グループを解除",
|
||||||
|
"layout": "ペインレイアウト",
|
||||||
|
"addPane": "ペインを追加",
|
||||||
|
"paneLimit": "最大 {{count}} ペイン",
|
||||||
|
"deleteConversations": "すべて削除",
|
||||||
|
"movePane": "{{title}} ペインを移動",
|
||||||
|
"movePaneHint": "ドラッグで移動 · 矢印キーでも移動できます",
|
||||||
|
"resizePaneBoundary": "ペイン境界 {{index}} のサイズを変更",
|
||||||
|
"promotePane": "{{title}} をメインペインにする",
|
||||||
|
"paneActions": "{{title}} ペインの操作",
|
||||||
|
"detachPane": "外す",
|
||||||
|
"composerAria": "{{title}} へのメッセージ",
|
||||||
|
"layouts": {
|
||||||
|
"columns": "列",
|
||||||
|
"rows": "行",
|
||||||
|
"grid": "グリッド",
|
||||||
|
"bsp": "BSP",
|
||||||
|
"main-stack": "メインとスタック"
|
||||||
|
}
|
||||||
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"dismiss": "閉じる",
|
"dismiss": "閉じる",
|
||||||
"close": "閉じる",
|
"close": "閉じる",
|
||||||
|
|||||||
@@ -975,6 +975,10 @@
|
|||||||
"unarchive": "보관 해제",
|
"unarchive": "보관 해제",
|
||||||
"showArchived": "보관된 항목 표시",
|
"showArchived": "보관된 항목 표시",
|
||||||
"hideArchived": "보관된 항목 숨기기",
|
"hideArchived": "보관된 항목 숨기기",
|
||||||
|
"select": "선택",
|
||||||
|
"cancelSelection": "선택 취소",
|
||||||
|
"selectedCount": "{{count}}개 선택됨",
|
||||||
|
"deleteSelected": "삭제",
|
||||||
"delete": "삭제",
|
"delete": "삭제",
|
||||||
"newChat": "새 주제",
|
"newChat": "새 주제",
|
||||||
"groups": {
|
"groups": {
|
||||||
@@ -989,10 +993,13 @@
|
|||||||
},
|
},
|
||||||
"deleteConfirm": {
|
"deleteConfirm": {
|
||||||
"title": "이 채팅을 삭제할까요?",
|
"title": "이 채팅을 삭제할까요?",
|
||||||
|
"titleMany": "대화 {{count}}개를 삭제할까요?",
|
||||||
"description": "이 작업은 되돌릴 수 없습니다.",
|
"description": "이 작업은 되돌릴 수 없습니다.",
|
||||||
|
"descriptionMany": "이 작업은 되돌릴 수 없습니다.",
|
||||||
"cancel": "취소",
|
"cancel": "취소",
|
||||||
"confirm": "삭제",
|
"confirm": "삭제",
|
||||||
"automationsDescription": "이 채팅에는 예약된 자동화가 있습니다. 채팅을 삭제하면 자동화도 함께 삭제됩니다.",
|
"automationsDescription": "이 채팅에는 예약된 자동화가 있습니다. 채팅을 삭제하면 자동화도 함께 삭제됩니다.",
|
||||||
|
"automationsDescriptionMany": "연결된 자동화도 함께 삭제됩니다.",
|
||||||
"moreAutomations": "+ {{count}}개 더",
|
"moreAutomations": "+ {{count}}개 더",
|
||||||
"confirmWithAutomations": "삭제",
|
"confirmWithAutomations": "삭제",
|
||||||
"schedule": {
|
"schedule": {
|
||||||
@@ -1388,6 +1395,39 @@
|
|||||||
"copy": "복사",
|
"copy": "복사",
|
||||||
"copied": "복사됨"
|
"copied": "복사됨"
|
||||||
},
|
},
|
||||||
|
"workbench": {
|
||||||
|
"aria": "대화 워크벤치",
|
||||||
|
"panes": "창",
|
||||||
|
"tabAria": "탭: {{title}}",
|
||||||
|
"panesInTab": "{{title}}의 창",
|
||||||
|
"collapseTabGroup": "{{title}}의 창 접기",
|
||||||
|
"expandTabGroup": "{{title}}의 창 펼치기",
|
||||||
|
"dropPane": "{{pane}}을(를) {{tab}}으로 이동",
|
||||||
|
"createGroup": "그룹 만들기",
|
||||||
|
"moveTo": "이동",
|
||||||
|
"renameTabTitle": "탭 이름 바꾸기",
|
||||||
|
"renameTabDescription": "창을 정리할 수 있도록 이 탭에 이름을 지정하세요.",
|
||||||
|
"renameTabPlaceholder": "탭 이름",
|
||||||
|
"dissolveTab": "그룹 해제",
|
||||||
|
"layout": "창 레이아웃",
|
||||||
|
"addPane": "창 추가",
|
||||||
|
"paneLimit": "최대 {{count}}개 창",
|
||||||
|
"deleteConversations": "모두 삭제",
|
||||||
|
"movePane": "{{title}} 창 이동",
|
||||||
|
"movePaneHint": "드래그하여 이동 · 방향키로도 이동 가능",
|
||||||
|
"resizePaneBoundary": "창 경계 {{index}} 크기 조절",
|
||||||
|
"promotePane": "{{title}}을(를) 기본 창으로 설정",
|
||||||
|
"paneActions": "{{title}} 창 작업",
|
||||||
|
"detachPane": "제거",
|
||||||
|
"composerAria": "{{title}}에 메시지 보내기",
|
||||||
|
"layouts": {
|
||||||
|
"columns": "열",
|
||||||
|
"rows": "행",
|
||||||
|
"grid": "그리드",
|
||||||
|
"bsp": "BSP",
|
||||||
|
"main-stack": "기본 창과 스택"
|
||||||
|
}
|
||||||
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"dismiss": "닫기",
|
"dismiss": "닫기",
|
||||||
"close": "닫기",
|
"close": "닫기",
|
||||||
|
|||||||
@@ -989,6 +989,10 @@
|
|||||||
"unarchive": "Desarquivar",
|
"unarchive": "Desarquivar",
|
||||||
"showArchived": "Mostrar arquivadas",
|
"showArchived": "Mostrar arquivadas",
|
||||||
"hideArchived": "Ocultar arquivadas",
|
"hideArchived": "Ocultar arquivadas",
|
||||||
|
"select": "Selecionar",
|
||||||
|
"cancelSelection": "Cancelar seleção",
|
||||||
|
"selectedCount": "{{count}} selecionados",
|
||||||
|
"deleteSelected": "Excluir",
|
||||||
"delete": "Excluir",
|
"delete": "Excluir",
|
||||||
"newChat": "Novo tópico",
|
"newChat": "Novo tópico",
|
||||||
"groups": {
|
"groups": {
|
||||||
@@ -1003,10 +1007,13 @@
|
|||||||
},
|
},
|
||||||
"deleteConfirm": {
|
"deleteConfirm": {
|
||||||
"title": "Excluir esta conversa?",
|
"title": "Excluir esta conversa?",
|
||||||
|
"titleMany": "Excluir {{count}} conversas?",
|
||||||
"description": "Esta ação não pode ser desfeita.",
|
"description": "Esta ação não pode ser desfeita.",
|
||||||
|
"descriptionMany": "Esta ação não pode ser desfeita.",
|
||||||
"cancel": "Cancelar",
|
"cancel": "Cancelar",
|
||||||
"confirm": "Excluir",
|
"confirm": "Excluir",
|
||||||
"automationsDescription": "Esta conversa possui automações agendadas. Excluí-la também excluirá essas automações.",
|
"automationsDescription": "Esta conversa possui automações agendadas. Excluí-la também excluirá essas automações.",
|
||||||
|
"automationsDescriptionMany": "As automações vinculadas também serão excluídas.",
|
||||||
"moreAutomations": "+ {{count}} a mais",
|
"moreAutomations": "+ {{count}} a mais",
|
||||||
"confirmWithAutomations": "Excluir",
|
"confirmWithAutomations": "Excluir",
|
||||||
"schedule": {
|
"schedule": {
|
||||||
@@ -1402,6 +1409,39 @@
|
|||||||
"copy": "Copiar",
|
"copy": "Copiar",
|
||||||
"copied": "Copiado"
|
"copied": "Copiado"
|
||||||
},
|
},
|
||||||
|
"workbench": {
|
||||||
|
"aria": "Área de conversas",
|
||||||
|
"panes": "Painéis",
|
||||||
|
"tabAria": "Aba: {{title}}",
|
||||||
|
"panesInTab": "Painéis em {{title}}",
|
||||||
|
"collapseTabGroup": "Recolher os painéis em {{title}}",
|
||||||
|
"expandTabGroup": "Expandir os painéis em {{title}}",
|
||||||
|
"dropPane": "Mover {{pane}} para {{tab}}",
|
||||||
|
"createGroup": "Criar grupo",
|
||||||
|
"moveTo": "Mover para",
|
||||||
|
"renameTabTitle": "Renomear aba",
|
||||||
|
"renameTabDescription": "Dê um nome a esta aba para organizar seus painéis.",
|
||||||
|
"renameTabPlaceholder": "Nome da aba",
|
||||||
|
"dissolveTab": "Desfazer grupo",
|
||||||
|
"layout": "Layout de painéis",
|
||||||
|
"addPane": "Adicionar painel",
|
||||||
|
"paneLimit": "Máximo de {{count}} painéis",
|
||||||
|
"deleteConversations": "Excluir tudo",
|
||||||
|
"movePane": "Mover painel {{title}}",
|
||||||
|
"movePaneHint": "Arraste para mover · As setas também funcionam",
|
||||||
|
"resizePaneBoundary": "Redimensionar limite do painel {{index}}",
|
||||||
|
"promotePane": "Tornar {{title}} o painel principal",
|
||||||
|
"paneActions": "Ações do painel {{title}}",
|
||||||
|
"detachPane": "Remover",
|
||||||
|
"composerAria": "Mensagem para {{title}}",
|
||||||
|
"layouts": {
|
||||||
|
"columns": "Colunas",
|
||||||
|
"rows": "Linhas",
|
||||||
|
"grid": "Grade",
|
||||||
|
"bsp": "BSP",
|
||||||
|
"main-stack": "Principal e pilha"
|
||||||
|
}
|
||||||
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"dismiss": "Descartar",
|
"dismiss": "Descartar",
|
||||||
"close": "Fechar",
|
"close": "Fechar",
|
||||||
|
|||||||
@@ -975,6 +975,10 @@
|
|||||||
"unarchive": "Bỏ lưu trữ",
|
"unarchive": "Bỏ lưu trữ",
|
||||||
"showArchived": "Hiện mục đã lưu trữ",
|
"showArchived": "Hiện mục đã lưu trữ",
|
||||||
"hideArchived": "Ẩn mục đã lưu trữ",
|
"hideArchived": "Ẩn mục đã lưu trữ",
|
||||||
|
"select": "Chọn",
|
||||||
|
"cancelSelection": "Hủy chọn",
|
||||||
|
"selectedCount": "Đã chọn {{count}} mục",
|
||||||
|
"deleteSelected": "Xóa",
|
||||||
"delete": "Xóa",
|
"delete": "Xóa",
|
||||||
"newChat": "Chủ đề mới",
|
"newChat": "Chủ đề mới",
|
||||||
"groups": {
|
"groups": {
|
||||||
@@ -989,10 +993,13 @@
|
|||||||
},
|
},
|
||||||
"deleteConfirm": {
|
"deleteConfirm": {
|
||||||
"title": "Xóa cuộc trò chuyện này?",
|
"title": "Xóa cuộc trò chuyện này?",
|
||||||
|
"titleMany": "Xóa {{count}} cuộc trò chuyện?",
|
||||||
"description": "Không thể hoàn tác thao tác này.",
|
"description": "Không thể hoàn tác thao tác này.",
|
||||||
|
"descriptionMany": "Không thể hoàn tác thao tác này.",
|
||||||
"cancel": "Hủy",
|
"cancel": "Hủy",
|
||||||
"confirm": "Xóa",
|
"confirm": "Xóa",
|
||||||
"automationsDescription": "Cuộc trò chuyện này có các tự động hóa đã lên lịch. Xóa cuộc trò chuyện cũng sẽ xóa chúng.",
|
"automationsDescription": "Cuộc trò chuyện này có các tự động hóa đã lên lịch. Xóa cuộc trò chuyện cũng sẽ xóa chúng.",
|
||||||
|
"automationsDescriptionMany": "Các tự động hóa liên kết cũng sẽ bị xóa.",
|
||||||
"moreAutomations": "+ {{count}} mục nữa",
|
"moreAutomations": "+ {{count}} mục nữa",
|
||||||
"confirmWithAutomations": "Xóa",
|
"confirmWithAutomations": "Xóa",
|
||||||
"schedule": {
|
"schedule": {
|
||||||
@@ -1388,6 +1395,39 @@
|
|||||||
"copy": "Sao chép",
|
"copy": "Sao chép",
|
||||||
"copied": "Đã sao chép"
|
"copied": "Đã sao chép"
|
||||||
},
|
},
|
||||||
|
"workbench": {
|
||||||
|
"aria": "Không gian hội thoại",
|
||||||
|
"panes": "Khung",
|
||||||
|
"tabAria": "Thẻ: {{title}}",
|
||||||
|
"panesInTab": "Các khung trong {{title}}",
|
||||||
|
"collapseTabGroup": "Thu gọn các khung trong {{title}}",
|
||||||
|
"expandTabGroup": "Mở rộng các khung trong {{title}}",
|
||||||
|
"dropPane": "Di chuyển {{pane}} vào {{tab}}",
|
||||||
|
"createGroup": "Tạo nhóm",
|
||||||
|
"moveTo": "Di chuyển đến",
|
||||||
|
"renameTabTitle": "Đổi tên thẻ",
|
||||||
|
"renameTabDescription": "Đặt tên cho thẻ này để sắp xếp các khung.",
|
||||||
|
"renameTabPlaceholder": "Tên thẻ",
|
||||||
|
"dissolveTab": "Giải tán nhóm",
|
||||||
|
"layout": "Bố cục khung",
|
||||||
|
"addPane": "Thêm khung",
|
||||||
|
"paneLimit": "Tối đa {{count}} khung",
|
||||||
|
"deleteConversations": "Xóa tất cả",
|
||||||
|
"movePane": "Di chuyển khung {{title}}",
|
||||||
|
"movePaneHint": "Kéo để di chuyển · Cũng có thể dùng các phím mũi tên",
|
||||||
|
"resizePaneBoundary": "Đổi kích thước ranh giới khung {{index}}",
|
||||||
|
"promotePane": "Đặt {{title}} làm khung chính",
|
||||||
|
"paneActions": "Thao tác cho khung {{title}}",
|
||||||
|
"detachPane": "Gỡ",
|
||||||
|
"composerAria": "Nhắn tin cho {{title}}",
|
||||||
|
"layouts": {
|
||||||
|
"columns": "Cột",
|
||||||
|
"rows": "Hàng",
|
||||||
|
"grid": "Lưới",
|
||||||
|
"bsp": "BSP",
|
||||||
|
"main-stack": "Khung chính và ngăn xếp"
|
||||||
|
}
|
||||||
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"dismiss": "Đóng",
|
"dismiss": "Đóng",
|
||||||
"close": "Đóng",
|
"close": "Đóng",
|
||||||
|
|||||||
@@ -989,6 +989,10 @@
|
|||||||
"unarchive": "取消归档",
|
"unarchive": "取消归档",
|
||||||
"showArchived": "显示归档",
|
"showArchived": "显示归档",
|
||||||
"hideArchived": "隐藏归档",
|
"hideArchived": "隐藏归档",
|
||||||
|
"select": "选择",
|
||||||
|
"cancelSelection": "取消选择",
|
||||||
|
"selectedCount": "已选择 {{count}} 项",
|
||||||
|
"deleteSelected": "删除",
|
||||||
"delete": "删除",
|
"delete": "删除",
|
||||||
"newChat": "新建话题",
|
"newChat": "新建话题",
|
||||||
"groups": {
|
"groups": {
|
||||||
@@ -1003,10 +1007,13 @@
|
|||||||
},
|
},
|
||||||
"deleteConfirm": {
|
"deleteConfirm": {
|
||||||
"title": "删除这个话题?",
|
"title": "删除这个话题?",
|
||||||
|
"titleMany": "删除这 {{count}} 个对话?",
|
||||||
"description": "此操作无法撤销。",
|
"description": "此操作无法撤销。",
|
||||||
|
"descriptionMany": "此操作无法撤销。",
|
||||||
"cancel": "取消",
|
"cancel": "取消",
|
||||||
"confirm": "删除",
|
"confirm": "删除",
|
||||||
"automationsDescription": "这个话题有关联的自动任务。删除话题也会删除这些自动任务。",
|
"automationsDescription": "这个话题有关联的自动任务。删除话题也会删除这些自动任务。",
|
||||||
|
"automationsDescriptionMany": "关联的自动任务也会一并删除。",
|
||||||
"moreAutomations": "另有 {{count}} 个",
|
"moreAutomations": "另有 {{count}} 个",
|
||||||
"confirmWithAutomations": "删除",
|
"confirmWithAutomations": "删除",
|
||||||
"schedule": {
|
"schedule": {
|
||||||
@@ -1402,6 +1409,39 @@
|
|||||||
"copy": "复制",
|
"copy": "复制",
|
||||||
"copied": "已复制"
|
"copied": "已复制"
|
||||||
},
|
},
|
||||||
|
"workbench": {
|
||||||
|
"aria": "会话工作台",
|
||||||
|
"panes": "窗格",
|
||||||
|
"tabAria": "标签页:{{title}}",
|
||||||
|
"panesInTab": "{{title}} 中的窗格",
|
||||||
|
"collapseTabGroup": "折叠 {{title}} 中的窗格",
|
||||||
|
"expandTabGroup": "展开 {{title}} 中的窗格",
|
||||||
|
"dropPane": "将 {{pane}} 移入 {{tab}}",
|
||||||
|
"createGroup": "创建分组",
|
||||||
|
"moveTo": "移动到",
|
||||||
|
"renameTabTitle": "重命名标签页",
|
||||||
|
"renameTabDescription": "为这个标签页命名,以便组织其中的窗格。",
|
||||||
|
"renameTabPlaceholder": "标签页名称",
|
||||||
|
"dissolveTab": "解散分组",
|
||||||
|
"layout": "窗格布局",
|
||||||
|
"addPane": "添加窗格",
|
||||||
|
"paneLimit": "最多 {{count}} 个窗格",
|
||||||
|
"deleteConversations": "删除所有对话",
|
||||||
|
"movePane": "移动 {{title}} 窗格",
|
||||||
|
"movePaneHint": "拖动换位 · 也可以使用方向键",
|
||||||
|
"resizePaneBoundary": "调整窗格边界 {{index}}",
|
||||||
|
"promotePane": "将 {{title}} 设为主窗格",
|
||||||
|
"paneActions": "{{title}} 窗格操作",
|
||||||
|
"detachPane": "移出",
|
||||||
|
"composerAria": "向 {{title}} 发送消息",
|
||||||
|
"layouts": {
|
||||||
|
"columns": "列布局",
|
||||||
|
"rows": "行布局",
|
||||||
|
"grid": "网格",
|
||||||
|
"bsp": "BSP",
|
||||||
|
"main-stack": "主窗格与堆栈"
|
||||||
|
}
|
||||||
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"dismiss": "关闭",
|
"dismiss": "关闭",
|
||||||
"close": "关闭",
|
"close": "关闭",
|
||||||
|
|||||||
@@ -975,6 +975,10 @@
|
|||||||
"unarchive": "取消封存",
|
"unarchive": "取消封存",
|
||||||
"showArchived": "顯示封存",
|
"showArchived": "顯示封存",
|
||||||
"hideArchived": "隱藏封存",
|
"hideArchived": "隱藏封存",
|
||||||
|
"select": "選取",
|
||||||
|
"cancelSelection": "取消選取",
|
||||||
|
"selectedCount": "已選取 {{count}} 項",
|
||||||
|
"deleteSelected": "刪除",
|
||||||
"delete": "刪除",
|
"delete": "刪除",
|
||||||
"newChat": "新增話題",
|
"newChat": "新增話題",
|
||||||
"groups": {
|
"groups": {
|
||||||
@@ -989,10 +993,13 @@
|
|||||||
},
|
},
|
||||||
"deleteConfirm": {
|
"deleteConfirm": {
|
||||||
"title": "刪除這個話題?",
|
"title": "刪除這個話題?",
|
||||||
|
"titleMany": "刪除這 {{count}} 個對話?",
|
||||||
"description": "此操作無法復原。",
|
"description": "此操作無法復原。",
|
||||||
|
"descriptionMany": "此操作無法復原。",
|
||||||
"cancel": "取消",
|
"cancel": "取消",
|
||||||
"confirm": "刪除",
|
"confirm": "刪除",
|
||||||
"automationsDescription": "這個話題含有已排程的自動任務。刪除話題時也會一併刪除這些任務。",
|
"automationsDescription": "這個話題含有已排程的自動任務。刪除話題時也會一併刪除這些任務。",
|
||||||
|
"automationsDescriptionMany": "關聯的自動任務也會一併刪除。",
|
||||||
"moreAutomations": "另有 {{count}} 個",
|
"moreAutomations": "另有 {{count}} 個",
|
||||||
"confirmWithAutomations": "刪除",
|
"confirmWithAutomations": "刪除",
|
||||||
"schedule": {
|
"schedule": {
|
||||||
@@ -1388,6 +1395,39 @@
|
|||||||
"copy": "複製",
|
"copy": "複製",
|
||||||
"copied": "已複製"
|
"copied": "已複製"
|
||||||
},
|
},
|
||||||
|
"workbench": {
|
||||||
|
"aria": "對話工作台",
|
||||||
|
"panes": "窗格",
|
||||||
|
"tabAria": "標籤頁:{{title}}",
|
||||||
|
"panesInTab": "{{title}} 中的窗格",
|
||||||
|
"collapseTabGroup": "收合 {{title}} 中的窗格",
|
||||||
|
"expandTabGroup": "展開 {{title}} 中的窗格",
|
||||||
|
"dropPane": "將 {{pane}} 移入 {{tab}}",
|
||||||
|
"createGroup": "建立群組",
|
||||||
|
"moveTo": "移動到",
|
||||||
|
"renameTabTitle": "重新命名分頁",
|
||||||
|
"renameTabDescription": "為這個分頁命名,以便整理其中的窗格。",
|
||||||
|
"renameTabPlaceholder": "分頁名稱",
|
||||||
|
"dissolveTab": "解散群組",
|
||||||
|
"layout": "窗格佈局",
|
||||||
|
"addPane": "新增窗格",
|
||||||
|
"paneLimit": "最多 {{count}} 個窗格",
|
||||||
|
"deleteConversations": "刪除所有對話",
|
||||||
|
"movePane": "移動 {{title}} 窗格",
|
||||||
|
"movePaneHint": "拖曳換位 · 也可以使用方向鍵",
|
||||||
|
"resizePaneBoundary": "調整窗格邊界 {{index}}",
|
||||||
|
"promotePane": "將 {{title}} 設為主窗格",
|
||||||
|
"paneActions": "{{title}} 窗格操作",
|
||||||
|
"detachPane": "移出",
|
||||||
|
"composerAria": "傳送訊息給 {{title}}",
|
||||||
|
"layouts": {
|
||||||
|
"columns": "欄佈局",
|
||||||
|
"rows": "列佈局",
|
||||||
|
"grid": "網格",
|
||||||
|
"bsp": "BSP",
|
||||||
|
"main-stack": "主窗格與堆疊"
|
||||||
|
}
|
||||||
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"dismiss": "關閉",
|
"dismiss": "關閉",
|
||||||
"close": "關閉",
|
"close": "關閉",
|
||||||
|
|||||||
@@ -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>,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -368,6 +368,21 @@ export interface WorkspacesPayload {
|
|||||||
|
|
||||||
export type SidebarDensity = "comfortable" | "compact";
|
export type SidebarDensity = "comfortable" | "compact";
|
||||||
export type SidebarSortMode = "updated_desc" | "created_desc" | "title_asc" | "manual";
|
export type SidebarSortMode = "updated_desc" | "created_desc" | "title_asc" | "manual";
|
||||||
|
export type WorkbenchLayout = "columns" | "rows" | "grid" | "bsp" | "main-stack";
|
||||||
|
|
||||||
|
export interface WorkbenchTabState {
|
||||||
|
explicit: boolean;
|
||||||
|
title: string | null;
|
||||||
|
paneKeys: string[];
|
||||||
|
layoutPaneKeys: string[];
|
||||||
|
layout: WorkbenchLayout;
|
||||||
|
splitRatios: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkbenchState {
|
||||||
|
version: 1;
|
||||||
|
tabs: Record<string, WorkbenchTabState>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SidebarViewState {
|
export interface SidebarViewState {
|
||||||
density: SidebarDensity;
|
density: SidebarDensity;
|
||||||
@@ -386,6 +401,7 @@ export interface SidebarStatePayload {
|
|||||||
project_name_overrides: Record<string, string>;
|
project_name_overrides: Record<string, string>;
|
||||||
tags_by_key: Record<string, string[]>;
|
tags_by_key: Record<string, string[]>;
|
||||||
collapsed_groups: Record<string, boolean>;
|
collapsed_groups: Record<string, boolean>;
|
||||||
|
workbench: WorkbenchState;
|
||||||
view: SidebarViewState;
|
view: SidebarViewState;
|
||||||
updated_at?: string | null;
|
updated_at?: string | null;
|
||||||
}
|
}
|
||||||
@@ -1281,6 +1297,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";
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type {
|
|||||||
ChatSummary,
|
ChatSummary,
|
||||||
ConnectionStatus,
|
ConnectionStatus,
|
||||||
SessionAutomationJob,
|
SessionAutomationJob,
|
||||||
|
SidebarStatePayload,
|
||||||
WorkspaceScopePayload,
|
WorkspaceScopePayload,
|
||||||
} from "@/lib/types";
|
} from "@/lib/types";
|
||||||
|
|
||||||
@@ -30,6 +31,7 @@ const sessionUpdateHandlers = new Set<(
|
|||||||
scope?: string,
|
scope?: string,
|
||||||
workspaceScope?: WorkspaceScopePayload,
|
workspaceScope?: WorkspaceScopePayload,
|
||||||
) => void>();
|
) => void>();
|
||||||
|
const sidebarStateUpdateHandlers = new Set<(state: SidebarStatePayload) => void>();
|
||||||
let mockSessions: ChatSummary[] = [];
|
let mockSessions: ChatSummary[] = [];
|
||||||
const HERO_GREETING_PATTERN =
|
const HERO_GREETING_PATTERN =
|
||||||
/What should we work on\?|Where should we start\?|What are we building today\?|What should we tackle together\?/;
|
/What should we work on\?|Where should we start\?|What are we building today\?|What should we tackle together\?/;
|
||||||
@@ -164,7 +166,24 @@ vi.mock("@/hooks/useSessions", async (importOriginal) => {
|
|||||||
loading: false,
|
loading: false,
|
||||||
error: null,
|
error: null,
|
||||||
refresh: refreshSpy,
|
refresh: refreshSpy,
|
||||||
createChat: createChatSpy,
|
createChat: async (scope?: WorkspaceScopePayload | null) => {
|
||||||
|
const chatId = await createChatSpy(scope);
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
setSessions((prev: ChatSummary[]) => [
|
||||||
|
{
|
||||||
|
key: `websocket:${chatId}`,
|
||||||
|
channel: "websocket",
|
||||||
|
chatId,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
title: "",
|
||||||
|
preview: "",
|
||||||
|
workspaceScope: scope ?? null,
|
||||||
|
},
|
||||||
|
...prev.filter((session) => session.chatId !== chatId),
|
||||||
|
]);
|
||||||
|
return chatId;
|
||||||
|
},
|
||||||
forkChat: async () => "fork-chat",
|
forkChat: async () => "fork-chat",
|
||||||
getSessionAutomations: getSessionAutomationsSpy,
|
getSessionAutomations: getSessionAutomationsSpy,
|
||||||
deleteChat: async (key: string, options?: { deleteAutomations?: boolean }) => {
|
deleteChat: async (key: string, options?: { deleteAutomations?: boolean }) => {
|
||||||
@@ -232,6 +251,10 @@ vi.mock("@/lib/nanobot-client", async (importOriginal) => {
|
|||||||
sessionUpdateHandlers.add(handler);
|
sessionUpdateHandlers.add(handler);
|
||||||
return () => sessionUpdateHandlers.delete(handler);
|
return () => sessionUpdateHandlers.delete(handler);
|
||||||
};
|
};
|
||||||
|
onSidebarStateUpdate = (handler: (state: SidebarStatePayload) => void) => {
|
||||||
|
sidebarStateUpdateHandlers.add(handler);
|
||||||
|
return () => sidebarStateUpdateHandlers.delete(handler);
|
||||||
|
};
|
||||||
onRunStatus = (handler: (chatId: string, startedAt: number | null) => void) => {
|
onRunStatus = (handler: (chatId: string, startedAt: number | null) => void) => {
|
||||||
runStatusHandlers.add(handler);
|
runStatusHandlers.add(handler);
|
||||||
return () => runStatusHandlers.delete(handler);
|
return () => runStatusHandlers.delete(handler);
|
||||||
@@ -272,7 +295,9 @@ describe("App layout", () => {
|
|||||||
getSessionAutomationsSpy.mockReset().mockResolvedValue([]);
|
getSessionAutomationsSpy.mockReset().mockResolvedValue([]);
|
||||||
toggleThemeSpy.mockReset();
|
toggleThemeSpy.mockReset();
|
||||||
attachSpy.mockReset();
|
attachSpy.mockReset();
|
||||||
setSidebarStateSpy.mockReset().mockResolvedValue({});
|
setSidebarStateSpy.mockReset().mockImplementation(
|
||||||
|
async (state: SidebarStatePayload) => state,
|
||||||
|
);
|
||||||
requestMutationSpy.mockReset();
|
requestMutationSpy.mockReset();
|
||||||
discardTemporaryChatSpy.mockReset();
|
discardTemporaryChatSpy.mockReset();
|
||||||
let temporaryChatCounter = 0;
|
let temporaryChatCounter = 0;
|
||||||
@@ -283,11 +308,13 @@ 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");
|
||||||
localStorage.removeItem("nanobot-webui.sidebar.completed-runs.v1");
|
localStorage.removeItem("nanobot-webui.sidebar.completed-runs.v1");
|
||||||
localStorage.removeItem("nanobot-webui.sidebar.session-updates.v1");
|
localStorage.removeItem("nanobot-webui.sidebar.session-updates.v1");
|
||||||
|
localStorage.removeItem("nanobot-webui.collapsed-pane-groups.v1");
|
||||||
localStorage.removeItem("nanobot-webui.restartStartedAt");
|
localStorage.removeItem("nanobot-webui.restartStartedAt");
|
||||||
localStorage.removeItem("nanobot-webui.restartRoute");
|
localStorage.removeItem("nanobot-webui.restartRoute");
|
||||||
vi.mocked(fetchBootstrap).mockReset().mockResolvedValue({
|
vi.mocked(fetchBootstrap).mockReset().mockResolvedValue({
|
||||||
@@ -309,6 +336,7 @@ describe("App layout", () => {
|
|||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows the auth form without an invalid-password error on first load", async () => {
|
it("shows the auth form without an invalid-password error on first load", async () => {
|
||||||
@@ -489,8 +517,9 @@ describe("App layout", () => {
|
|||||||
render(<App />);
|
render(<App />);
|
||||||
|
|
||||||
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
||||||
|
const firstMessage = "keep this first turn visible";
|
||||||
fireEvent.change(screen.getByRole("textbox", { name: "Message input" }), {
|
fireEvent.change(screen.getByRole("textbox", { name: "Message input" }), {
|
||||||
target: { value: "/model" },
|
target: { value: firstMessage },
|
||||||
});
|
});
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
|
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
|
||||||
|
|
||||||
@@ -500,6 +529,7 @@ describe("App layout", () => {
|
|||||||
`#/chat/${encodeURIComponent("websocket:chat-1")}`,
|
`#/chat/${encodeURIComponent("websocket:chat-1")}`,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
expect(await screen.findByText(firstMessage)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("creates a new temporary chat from the hero each time", async () => {
|
it("creates a new temporary chat from the hero each time", async () => {
|
||||||
@@ -1653,6 +1683,60 @@ describe("App layout", () => {
|
|||||||
expect(document.body.style.pointerEvents).not.toBe("none");
|
expect(document.body.style.pointerEvents).not.toBe("none");
|
||||||
}, 15_000);
|
}, 15_000);
|
||||||
|
|
||||||
|
it("deletes multiple selected topics through one confirmation", async () => {
|
||||||
|
mockSessions = [
|
||||||
|
{
|
||||||
|
key: "websocket:chat-a",
|
||||||
|
channel: "websocket",
|
||||||
|
chatId: "chat-a",
|
||||||
|
createdAt: "2026-04-16T10:00:00Z",
|
||||||
|
updatedAt: "2026-04-16T10:00:00Z",
|
||||||
|
preview: "First chat",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "websocket:chat-b",
|
||||||
|
channel: "websocket",
|
||||||
|
chatId: "chat-b",
|
||||||
|
createdAt: "2026-04-16T11:00:00Z",
|
||||||
|
updatedAt: "2026-04-16T11:00:00Z",
|
||||||
|
preview: "Second chat",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "websocket:chat-c",
|
||||||
|
channel: "websocket",
|
||||||
|
chatId: "chat-c",
|
||||||
|
createdAt: "2026-04-16T12:00:00Z",
|
||||||
|
updatedAt: "2026-04-16T12:00:00Z",
|
||||||
|
preview: "Third chat",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
render(<App />);
|
||||||
|
|
||||||
|
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
||||||
|
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
|
||||||
|
fireEvent.pointerDown(within(sidebar).getByLabelText(
|
||||||
|
"Topic actions for First chat",
|
||||||
|
), { button: 0 });
|
||||||
|
fireEvent.click(await screen.findByRole("menuitem", { name: "Select" }));
|
||||||
|
fireEvent.click(within(sidebar).getByRole("button", { name: "Second chat" }));
|
||||||
|
expect(within(sidebar).getByText("2 selected")).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(within(sidebar).getByRole("button", { name: "Delete" }));
|
||||||
|
expect(await screen.findByText("Delete 2 conversations?")).toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(deleteChatSpy).toHaveBeenCalledTimes(2));
|
||||||
|
expect(deleteChatSpy.mock.calls.map(([key]) => key)).toEqual([
|
||||||
|
"websocket:chat-a",
|
||||||
|
"websocket:chat-b",
|
||||||
|
]);
|
||||||
|
expect(getSessionAutomationsSpy).toHaveBeenCalledWith("websocket:chat-a");
|
||||||
|
expect(getSessionAutomationsSpy).toHaveBeenCalledWith("websocket:chat-b");
|
||||||
|
expect(within(sidebar).getByRole("button", { name: "Third chat" }))
|
||||||
|
.toBeInTheDocument();
|
||||||
|
}, 15_000);
|
||||||
|
|
||||||
it("shows localized bound automations in the first delete confirmation", async () => {
|
it("shows localized bound automations in the first delete confirmation", async () => {
|
||||||
mockSessions = [
|
mockSessions = [
|
||||||
{
|
{
|
||||||
@@ -2947,6 +3031,386 @@ describe("App layout", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps panes adjacent and orders tabs by their latest updated pane", async () => {
|
||||||
|
mockSessions = [
|
||||||
|
{
|
||||||
|
key: "websocket:alpha",
|
||||||
|
channel: "websocket",
|
||||||
|
chatId: "alpha",
|
||||||
|
createdAt: "2026-08-01T10:00:00Z",
|
||||||
|
updatedAt: "2026-08-01T10:00:00Z",
|
||||||
|
title: "Alpha tab",
|
||||||
|
preview: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "websocket:alpha-child",
|
||||||
|
channel: "websocket",
|
||||||
|
chatId: "alpha-child",
|
||||||
|
createdAt: "2026-08-05T10:00:00Z",
|
||||||
|
updatedAt: "2026-08-05T10:00:00Z",
|
||||||
|
title: "Alpha child",
|
||||||
|
preview: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "websocket:beta",
|
||||||
|
channel: "websocket",
|
||||||
|
chatId: "beta",
|
||||||
|
createdAt: "2026-08-04T10:00:00Z",
|
||||||
|
updatedAt: "2026-08-04T10:00:00Z",
|
||||||
|
title: "Beta tab",
|
||||||
|
preview: "",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
vi.stubGlobal("fetch", vi.fn().mockImplementation(async (url: string | URL | Request) => {
|
||||||
|
if (String(url) === "/api/webui/sidebar-state") {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
workbench: {
|
||||||
|
version: 1,
|
||||||
|
tabs: {
|
||||||
|
"tab:websocket:alpha": {
|
||||||
|
explicit: true,
|
||||||
|
title: "Alpha tab",
|
||||||
|
paneKeys: ["websocket:alpha", "websocket:alpha-child"],
|
||||||
|
layout: "columns",
|
||||||
|
},
|
||||||
|
"tab:websocket:beta": {
|
||||||
|
explicit: false,
|
||||||
|
title: null,
|
||||||
|
paneKeys: ["websocket:beta"],
|
||||||
|
layout: "columns",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { ok: false, status: 404 };
|
||||||
|
}));
|
||||||
|
|
||||||
|
render(<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("uses one active pane without workbench editing controls on mobile", async () => {
|
||||||
|
vi.stubGlobal("matchMedia", vi.fn((query: string) => ({
|
||||||
|
matches: query.includes("max-width: 767px"),
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addListener: vi.fn(),
|
||||||
|
removeListener: vi.fn(),
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
dispatchEvent: vi.fn(),
|
||||||
|
})));
|
||||||
|
mockSessions = [
|
||||||
|
{
|
||||||
|
key: "websocket:alpha",
|
||||||
|
channel: "websocket",
|
||||||
|
chatId: "alpha",
|
||||||
|
createdAt: "2026-08-01T10:00:00Z",
|
||||||
|
updatedAt: "2026-08-01T10:00:00Z",
|
||||||
|
title: "Alpha tab",
|
||||||
|
preview: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "websocket:alpha-child",
|
||||||
|
channel: "websocket",
|
||||||
|
chatId: "alpha-child",
|
||||||
|
createdAt: "2026-08-05T10:00:00Z",
|
||||||
|
updatedAt: "2026-08-05T10:00:00Z",
|
||||||
|
title: "Alpha child",
|
||||||
|
preview: "",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
window.history.replaceState(
|
||||||
|
null,
|
||||||
|
"",
|
||||||
|
"/#/chat/websocket%3Aalpha-child",
|
||||||
|
);
|
||||||
|
vi.stubGlobal("fetch", vi.fn().mockImplementation(async (url: string | URL | Request) => {
|
||||||
|
if (String(url) === "/api/webui/sidebar-state") {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
workbench: {
|
||||||
|
version: 1,
|
||||||
|
tabs: {
|
||||||
|
"tab:websocket:alpha": {
|
||||||
|
explicit: true,
|
||||||
|
title: "Alpha tab",
|
||||||
|
paneKeys: ["websocket:alpha", "websocket:alpha-child"],
|
||||||
|
layout: "bsp",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { ok: false, status: 404 };
|
||||||
|
}));
|
||||||
|
|
||||||
|
render(<App />);
|
||||||
|
|
||||||
|
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
||||||
|
const grid = await screen.findByTestId("pane-grid");
|
||||||
|
await waitFor(() => expect(Array.from(grid.children).map(
|
||||||
|
(pane) => pane.getAttribute("aria-label"),
|
||||||
|
)).toEqual(["Alpha child"]));
|
||||||
|
expect(screen.queryByRole("button", { name: "Pane layout" })).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("button", { name: "Add pane" })).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("separator")).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
|
||||||
|
fireEvent.pointerDown(within(sidebar).getByRole("button", {
|
||||||
|
name: "Alpha child pane actions",
|
||||||
|
}), { button: 0, ctrlKey: false });
|
||||||
|
expect(await screen.findByRole("menuitem", { name: "Delete" })).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("menuitem", { name: "Remove" })).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("menuitem", { name: "Move to" })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("materializes a singleton tab without linking it to another pane", async () => {
|
||||||
|
mockSessions = [
|
||||||
|
{
|
||||||
|
key: "websocket:solo",
|
||||||
|
channel: "websocket",
|
||||||
|
chatId: "solo",
|
||||||
|
createdAt: "2026-08-05T10:00:00Z",
|
||||||
|
updatedAt: "2026-08-05T10:00:00Z",
|
||||||
|
title: "Solo pane",
|
||||||
|
preview: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "websocket:other",
|
||||||
|
channel: "websocket",
|
||||||
|
chatId: "other",
|
||||||
|
createdAt: "2026-08-04T10:00:00Z",
|
||||||
|
updatedAt: "2026-08-04T10:00:00Z",
|
||||||
|
title: "Other pane",
|
||||||
|
preview: "",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
render(<App />);
|
||||||
|
|
||||||
|
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
||||||
|
act(() => {
|
||||||
|
statusHandlers.forEach((handler) => handler("open"));
|
||||||
|
});
|
||||||
|
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
|
||||||
|
expect(within(sidebar).queryByRole("button", { name: "Tab: Solo pane" }))
|
||||||
|
.not.toBeInTheDocument();
|
||||||
|
setSidebarStateSpy.mockClear();
|
||||||
|
|
||||||
|
fireEvent.pointerDown(within(sidebar).getByRole("button", {
|
||||||
|
name: "Topic actions for Solo pane",
|
||||||
|
}), { button: 0, ctrlKey: false });
|
||||||
|
fireEvent.click(await screen.findByRole("menuitem", { name: "Create group" }));
|
||||||
|
|
||||||
|
const tabButton = await within(sidebar).findByRole("button", {
|
||||||
|
name: "Tab: Solo pane",
|
||||||
|
});
|
||||||
|
const tabGroup = tabButton.closest("[data-sidebar-tab-group]") as HTMLElement;
|
||||||
|
expect(within(tabGroup).getByRole("list", { name: "Panes in Solo pane" }))
|
||||||
|
.toBeInTheDocument();
|
||||||
|
expect(within(tabGroup).getAllByRole("button", { name: "Solo pane" }))
|
||||||
|
.toHaveLength(1);
|
||||||
|
expect(within(sidebar).queryByRole("button", { name: "Tab: Other pane" }))
|
||||||
|
.not.toBeInTheDocument();
|
||||||
|
await waitFor(() => expect(setSidebarStateSpy).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
workbench: expect.objectContaining({
|
||||||
|
tabs: expect.objectContaining({
|
||||||
|
"tab:websocket:solo": expect.objectContaining({ explicit: true }),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restores a created pane group from gateway state after remount", async () => {
|
||||||
|
mockSessions = [
|
||||||
|
{
|
||||||
|
key: "websocket:solo",
|
||||||
|
channel: "websocket",
|
||||||
|
chatId: "solo",
|
||||||
|
createdAt: "2026-08-05T10:00:00Z",
|
||||||
|
updatedAt: "2026-08-05T10:00:00Z",
|
||||||
|
title: "Solo pane",
|
||||||
|
preview: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "websocket:other",
|
||||||
|
channel: "websocket",
|
||||||
|
chatId: "other",
|
||||||
|
createdAt: "2026-08-04T10:00:00Z",
|
||||||
|
updatedAt: "2026-08-04T10:00:00Z",
|
||||||
|
title: "Other pane",
|
||||||
|
preview: "",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
let persistedState: SidebarStatePayload | null = null;
|
||||||
|
vi.stubGlobal("fetch", vi.fn().mockImplementation(async (url: string | URL | Request) => {
|
||||||
|
if (String(url) === "/api/webui/sidebar-state") {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
json: async () => persistedState ?? {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { ok: false, status: 404 };
|
||||||
|
}));
|
||||||
|
setSidebarStateSpy.mockImplementation(async (state: SidebarStatePayload) => {
|
||||||
|
persistedState = state;
|
||||||
|
return state;
|
||||||
|
});
|
||||||
|
|
||||||
|
const firstRender = render(<App />);
|
||||||
|
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
||||||
|
act(() => {
|
||||||
|
statusHandlers.forEach((handler) => handler("open"));
|
||||||
|
});
|
||||||
|
const firstSidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
|
||||||
|
fireEvent.pointerDown(within(firstSidebar).getByRole("button", {
|
||||||
|
name: "Topic actions for Solo pane",
|
||||||
|
}), { button: 0, ctrlKey: false });
|
||||||
|
fireEvent.click(await screen.findByRole("menuitem", { name: "Create group" }));
|
||||||
|
await waitFor(() => expect(persistedState?.workbench.tabs["tab:websocket:solo"])
|
||||||
|
.toEqual(expect.objectContaining({ explicit: true })));
|
||||||
|
|
||||||
|
firstRender.unmount();
|
||||||
|
connectSpy.mockClear();
|
||||||
|
|
||||||
|
render(<App />);
|
||||||
|
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
||||||
|
const secondSidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
|
||||||
|
expect(await within(secondSidebar).findByRole("button", { name: "Tab: Solo pane" }))
|
||||||
|
.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps panes and layout scoped to the current topic tab", async () => {
|
||||||
|
vi.stubGlobal("matchMedia", vi.fn((query: string) => ({
|
||||||
|
matches: false,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addListener: vi.fn(),
|
||||||
|
removeListener: vi.fn(),
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
dispatchEvent: vi.fn(),
|
||||||
|
})));
|
||||||
|
createChatSpy.mockResolvedValueOnce("chat-pane");
|
||||||
|
mockSessions = [
|
||||||
|
{
|
||||||
|
key: "websocket:chat-alpha",
|
||||||
|
channel: "websocket",
|
||||||
|
chatId: "chat-alpha",
|
||||||
|
createdAt: "2026-04-16T10:00:00Z",
|
||||||
|
updatedAt: "2026-04-16T10:00:00Z",
|
||||||
|
title: "Alpha",
|
||||||
|
preview: "Alpha notes",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "websocket:chat-beta",
|
||||||
|
channel: "websocket",
|
||||||
|
chatId: "chat-beta",
|
||||||
|
createdAt: "2026-04-16T11:00:00Z",
|
||||||
|
updatedAt: "2026-04-16T11:00:00Z",
|
||||||
|
title: "Beta",
|
||||||
|
preview: "Beta notes",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
window.history.replaceState(
|
||||||
|
null,
|
||||||
|
"",
|
||||||
|
"/#/chat/websocket%3Achat-alpha",
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<App />);
|
||||||
|
|
||||||
|
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
||||||
|
const grid = await screen.findByTestId("pane-grid");
|
||||||
|
expect(Array.from(grid.children).map((pane) => pane.getAttribute("aria-label")))
|
||||||
|
.toEqual(["Alpha"]);
|
||||||
|
expect(screen.queryByRole("button", { name: "Pane layout" }))
|
||||||
|
.not.toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Add pane" }));
|
||||||
|
expect(screen.queryByRole("dialog", { name: "Search" })).not.toBeInTheDocument();
|
||||||
|
await waitFor(() => expect(createChatSpy).toHaveBeenCalledTimes(1));
|
||||||
|
|
||||||
|
await waitFor(() => expect(grid.children).toHaveLength(2));
|
||||||
|
expect(screen.getByRole("button", { name: "Pane layout" })).toBeInTheDocument();
|
||||||
|
expect(window.location.hash).toBe("#/chat/websocket%3Achat-pane");
|
||||||
|
expect(Array.from(grid.children).map((pane) => pane.getAttribute("aria-label")))
|
||||||
|
.toEqual(["Alpha", "New topic"]);
|
||||||
|
|
||||||
|
const activeComposer = screen.getByTestId("active-pane-composer");
|
||||||
|
const paneInput = within(activeComposer).getByRole("textbox", {
|
||||||
|
name: "Message New topic",
|
||||||
|
});
|
||||||
|
expect(paneInput).toHaveClass("min-h-[50px]");
|
||||||
|
fireEvent.change(paneInput, { target: { value: "route this to the new pane" } });
|
||||||
|
fireEvent.keyDown(paneInput, { key: "Enter" });
|
||||||
|
await waitFor(() => expect(sendMessageSpy).toHaveBeenCalled());
|
||||||
|
expect(sendMessageSpy.mock.calls.at(-1)?.[0]).toBe("chat-pane");
|
||||||
|
|
||||||
|
fireEvent.pointerDown(screen.getByRole("button", { name: "Pane layout" }), {
|
||||||
|
button: 0,
|
||||||
|
ctrlKey: false,
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByRole("menuitemradio", { name: "Rows" }));
|
||||||
|
expect(grid).toHaveAttribute("data-layout", "rows");
|
||||||
|
|
||||||
|
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
|
||||||
|
const paneTopicButton = within(sidebar)
|
||||||
|
.getAllByRole("button", { name: "New topic" })
|
||||||
|
.find((button) => button.closest("[data-sidebar-pane]"));
|
||||||
|
expect(paneTopicButton).toBeDefined();
|
||||||
|
expect(paneTopicButton?.closest("[data-sidebar-pane]"))
|
||||||
|
.toHaveAttribute("data-sidebar-pane", "websocket:chat-pane");
|
||||||
|
fireEvent.click(within(sidebar).getByRole("button", { name: "Beta" }));
|
||||||
|
await waitFor(() => {
|
||||||
|
const nextGrid = screen.getByTestId("pane-grid");
|
||||||
|
expect(Array.from(nextGrid.children).map((pane) => pane.getAttribute("aria-label")))
|
||||||
|
.toEqual(["Beta"]);
|
||||||
|
expect(nextGrid).toHaveAttribute("data-layout", "columns");
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(within(sidebar).getByRole("button", { name: "Alpha" }));
|
||||||
|
await waitFor(() => {
|
||||||
|
const restoredGrid = screen.getByTestId("pane-grid");
|
||||||
|
expect(Array.from(restoredGrid.children).map((pane) => pane.getAttribute("aria-label")))
|
||||||
|
.toEqual(["Alpha", "New topic"]);
|
||||||
|
expect(restoredGrid).toHaveAttribute("data-layout", "rows");
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.pointerDown(within(sidebar).getByRole("button", {
|
||||||
|
name: "New topic pane actions",
|
||||||
|
}), { button: 0, ctrlKey: false });
|
||||||
|
fireEvent.click(screen.getByRole("menuitem", {
|
||||||
|
name: "Remove",
|
||||||
|
}));
|
||||||
|
await waitFor(() => expect(screen.getByTestId("pane-grid").children).toHaveLength(1));
|
||||||
|
expect(within(sidebar).getAllByRole("button", { name: "New topic" })).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
it("opens search from the keyboard shortcut", async () => {
|
it("opens search from the keyboard shortcut", async () => {
|
||||||
mockSessions = [
|
mockSessions = [
|
||||||
{
|
{
|
||||||
|
|||||||
+400
-180
@@ -2,7 +2,6 @@ import { fireEvent, render, screen, within } from "@testing-library/react";
|
|||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import { ChatList } from "@/components/ChatList";
|
import { ChatList } from "@/components/ChatList";
|
||||||
import { SESSION_DRAG_TYPE } from "@/lib/session-drag";
|
|
||||||
import type { ChatSummary } from "@/lib/types";
|
import type { ChatSummary } from "@/lib/types";
|
||||||
|
|
||||||
function session(overrides: Partial<ChatSummary>): ChatSummary {
|
function session(overrides: Partial<ChatSummary>): ChatSummary {
|
||||||
@@ -18,48 +17,94 @@ function session(overrides: Partial<ChatSummary>): ChatSummary {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function rect({
|
|
||||||
left,
|
|
||||||
top,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
}: {
|
|
||||||
left: number;
|
|
||||||
top: number;
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
}): DOMRect {
|
|
||||||
return {
|
|
||||||
x: left,
|
|
||||||
y: top,
|
|
||||||
left,
|
|
||||||
top,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
right: left + width,
|
|
||||||
bottom: top + height,
|
|
||||||
toJSON: () => ({}),
|
|
||||||
} as DOMRect;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("ChatList", () => {
|
describe("ChatList", () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
localStorage.removeItem("nanobot-webui.collapsed-pane-groups.v1");
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("exposes chats as drag sources", () => {
|
it("keeps tabs and panes outside every drag-and-drop protocol", () => {
|
||||||
const dataTransfer = {
|
render(
|
||||||
effectAllowed: "",
|
<ChatList
|
||||||
setData: vi.fn(),
|
sessions={[session({ chatId: "root", title: "Root topic" })]}
|
||||||
};
|
activeKey="websocket:root"
|
||||||
|
paneGroups={{
|
||||||
|
"websocket:root": {
|
||||||
|
tabKey: "websocket:root",
|
||||||
|
title: "Root topic",
|
||||||
|
activePaneKey: "websocket:child",
|
||||||
|
panes: [
|
||||||
|
{ key: "websocket:root", chatId: "root", title: "Root topic" },
|
||||||
|
{ key: "websocket:child", chatId: "child", title: "Research pane" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
onSelect={vi.fn()}
|
||||||
|
onRequestDelete={vi.fn()}
|
||||||
|
onTogglePin={vi.fn()}
|
||||||
|
onRequestRename={vi.fn()}
|
||||||
|
onToggleArchive={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByRole("button", { name: "Tab: Root topic" }))
|
||||||
|
.toHaveAttribute("draggable", "false");
|
||||||
|
expect(screen.getByRole("button", { name: "Research pane" }))
|
||||||
|
.toHaveAttribute("draggable", "false");
|
||||||
|
expect(document.querySelector("[data-pane-drag-overlay]")).not.toBeInTheDocument();
|
||||||
|
expect(document.querySelector("[data-pane-snap-slot]")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates a visible tab in place and only moves panes into visible tabs", async () => {
|
||||||
|
const onAttachPane = vi.fn();
|
||||||
|
const onCreateTab = vi.fn();
|
||||||
render(
|
render(
|
||||||
<ChatList
|
<ChatList
|
||||||
sessions={[
|
sessions={[
|
||||||
session({ chatId: "active", title: "Active chat" }),
|
session({ chatId: "solo", title: "Solo pane" }),
|
||||||
session({ chatId: "reference", title: "Reference chat" }),
|
session({ chatId: "target", title: "Target pane" }),
|
||||||
|
session({ key: "tab:existing", chatId: "group", title: "Existing group" }),
|
||||||
|
session({ key: "tab:fine", chatId: "fine-group", title: "Fine group" }),
|
||||||
]}
|
]}
|
||||||
activeKey="websocket:active"
|
activeKey="websocket:solo"
|
||||||
|
paneGroups={{
|
||||||
|
"websocket:solo": {
|
||||||
|
tabKey: "tab:solo",
|
||||||
|
title: "Solo pane",
|
||||||
|
activePaneKey: "websocket:solo",
|
||||||
|
visible: false,
|
||||||
|
panes: [{ key: "websocket:solo", chatId: "solo", title: "Solo pane" }],
|
||||||
|
},
|
||||||
|
"websocket:target": {
|
||||||
|
tabKey: "tab:target",
|
||||||
|
title: "Target pane",
|
||||||
|
activePaneKey: "websocket:target",
|
||||||
|
visible: false,
|
||||||
|
panes: [{ key: "websocket:target", chatId: "target", title: "Target pane" }],
|
||||||
|
},
|
||||||
|
"tab:existing": {
|
||||||
|
tabKey: "tab:existing",
|
||||||
|
title: "Existing group",
|
||||||
|
activePaneKey: "websocket:group-a",
|
||||||
|
visible: true,
|
||||||
|
panes: [
|
||||||
|
{ key: "websocket:group-a", chatId: "group-a", title: "Group A" },
|
||||||
|
{ key: "websocket:group-b", chatId: "group-b", title: "Group B" },
|
||||||
|
{ key: "websocket:group-c", chatId: "group-c", title: "Group C" },
|
||||||
|
{ key: "websocket:group-d", chatId: "group-d", title: "Group D" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"tab:fine": {
|
||||||
|
tabKey: "tab:fine",
|
||||||
|
title: "Fine group",
|
||||||
|
activePaneKey: "websocket:fine",
|
||||||
|
visible: true,
|
||||||
|
panes: [{ key: "websocket:fine", chatId: "fine", title: "Fine pane" }],
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
onCreateTab={onCreateTab}
|
||||||
|
onAttachPane={onAttachPane}
|
||||||
onSelect={vi.fn()}
|
onSelect={vi.fn()}
|
||||||
onRequestDelete={vi.fn()}
|
onRequestDelete={vi.fn()}
|
||||||
onTogglePin={vi.fn()}
|
onTogglePin={vi.fn()}
|
||||||
@@ -68,88 +113,299 @@ describe("ChatList", () => {
|
|||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByRole("button", { name: "Active chat" }))
|
expect(screen.queryByRole("button", { name: "Tab: Solo pane" }))
|
||||||
.toHaveAttribute("draggable", "true");
|
.not.toBeInTheDocument();
|
||||||
const reference = screen.getByRole("button", { name: "Reference chat" });
|
expect(screen.getAllByText("Solo pane")).toHaveLength(1);
|
||||||
expect(reference).toHaveAttribute("draggable", "true");
|
expect(screen.queryByRole("list", { name: "Panes in Solo pane" }))
|
||||||
|
.not.toBeInTheDocument();
|
||||||
|
|
||||||
fireEvent.dragStart(reference, { dataTransfer });
|
fireEvent.pointerDown(screen.getByRole("button", {
|
||||||
|
name: "Topic actions for Solo pane",
|
||||||
|
}), { button: 0, ctrlKey: false });
|
||||||
|
fireEvent.click(await screen.findByRole("menuitem", { name: "Create group" }));
|
||||||
|
expect(onCreateTab).toHaveBeenCalledWith("websocket:solo");
|
||||||
|
|
||||||
expect(dataTransfer.setData).toHaveBeenCalledWith(
|
fireEvent.pointerDown(screen.getByRole("button", {
|
||||||
SESSION_DRAG_TYPE,
|
name: "Topic actions for Solo pane",
|
||||||
"websocket:reference",
|
}), { button: 0, ctrlKey: false });
|
||||||
);
|
const moveTo = await screen.findByRole("menuitem", { name: "Move to" });
|
||||||
fireEvent.dragEnd(reference, { dataTransfer });
|
fireEvent.pointerMove(moveTo, { pointerType: "mouse" });
|
||||||
|
expect(screen.queryByRole("menuitem", { name: "Target pane" }))
|
||||||
|
.not.toBeInTheDocument();
|
||||||
|
const fullTarget = await screen.findByRole("menuitem", {
|
||||||
|
name: "Existing group · 4/4",
|
||||||
|
});
|
||||||
|
expect(fullTarget).toHaveAttribute("aria-disabled", "true");
|
||||||
|
fireEvent.click(fullTarget);
|
||||||
|
expect(onAttachPane).not.toHaveBeenCalled();
|
||||||
|
fireEvent.click(await screen.findByRole("menuitem", { name: "Fine group · 1/4" }));
|
||||||
|
expect(onAttachPane).toHaveBeenCalledWith("websocket:solo", "tab:fine");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reorders chats around a Codex-style insertion line", () => {
|
it("shows every tab's pane membership in a sidebar tab group", async () => {
|
||||||
const onReorderSessions = vi.fn();
|
const onSelect = vi.fn();
|
||||||
const sessions = [
|
const onSelectPane = vi.fn();
|
||||||
session({ chatId: "alpha", title: "Alpha" }),
|
const onDetachPane = vi.fn();
|
||||||
session({ chatId: "bravo", title: "Bravo" }),
|
const onDissolveTab = vi.fn();
|
||||||
session({ chatId: "charlie", title: "Charlie" }),
|
const onRequestRename = vi.fn();
|
||||||
session({ chatId: "old-a", title: "Old A" }),
|
const onAttachPane = vi.fn();
|
||||||
session({ chatId: "old-b", title: "Old B" }),
|
|
||||||
];
|
|
||||||
const { rerender } = render(
|
|
||||||
<ChatList
|
|
||||||
sessions={sessions}
|
|
||||||
activeKey={null}
|
|
||||||
onSelect={vi.fn()}
|
|
||||||
onRequestDelete={vi.fn()}
|
|
||||||
onTogglePin={vi.fn()}
|
|
||||||
onRequestRename={vi.fn()}
|
|
||||||
onToggleArchive={vi.fn()}
|
|
||||||
onReorderSessions={onReorderSessions}
|
|
||||||
archivedKeys={["websocket:old-a", "websocket:old-b"]}
|
|
||||||
sessionOrder={sessions.map((item) => item.key)}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
const dataTransfer = {
|
|
||||||
effectAllowed: "",
|
|
||||||
dropEffect: "",
|
|
||||||
setData: vi.fn(),
|
|
||||||
};
|
|
||||||
fireEvent.dragStart(screen.getByRole("button", { name: "Alpha" }), { dataTransfer });
|
|
||||||
const charlieRow = screen.getByRole("button", { name: "Charlie" }).closest("li")!;
|
|
||||||
fireEvent.dragOver(charlieRow, { clientY: 1, dataTransfer });
|
|
||||||
expect(charlieRow.querySelector("[data-session-drop-edge='after']"))
|
|
||||||
.toBeInTheDocument();
|
|
||||||
fireEvent.drop(charlieRow, { clientY: 1, dataTransfer });
|
|
||||||
|
|
||||||
expect(onReorderSessions).toHaveBeenCalledWith([
|
render(
|
||||||
"websocket:bravo",
|
|
||||||
"websocket:charlie",
|
|
||||||
"websocket:alpha",
|
|
||||||
"websocket:old-a",
|
|
||||||
"websocket:old-b",
|
|
||||||
]);
|
|
||||||
|
|
||||||
rerender(
|
|
||||||
<ChatList
|
<ChatList
|
||||||
sessions={sessions}
|
sessions={[
|
||||||
activeKey={null}
|
session({ chatId: "root", title: "Root topic" }),
|
||||||
onSelect={vi.fn()}
|
session({ chatId: "target", title: "Target tab" }),
|
||||||
onRequestDelete={vi.fn()}
|
|
||||||
onTogglePin={vi.fn()}
|
|
||||||
onRequestRename={vi.fn()}
|
|
||||||
onToggleArchive={vi.fn()}
|
|
||||||
onReorderSessions={onReorderSessions}
|
|
||||||
archivedKeys={["websocket:old-a", "websocket:old-b"]}
|
|
||||||
sessionOrder={[
|
|
||||||
"websocket:bravo",
|
|
||||||
"websocket:charlie",
|
|
||||||
"websocket:alpha",
|
|
||||||
"websocket:old-a",
|
|
||||||
"websocket:old-b",
|
|
||||||
]}
|
]}
|
||||||
sort="manual"
|
activeKey="websocket:root"
|
||||||
|
paneGroups={{
|
||||||
|
"websocket:root": {
|
||||||
|
tabKey: "websocket:root",
|
||||||
|
title: "Root topic",
|
||||||
|
activePaneKey: "websocket:child",
|
||||||
|
panes: [
|
||||||
|
{ key: "websocket:root", chatId: "root", title: "Root topic" },
|
||||||
|
{ key: "websocket:child", chatId: "child", title: "Research pane" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"websocket:target": {
|
||||||
|
tabKey: "websocket:target",
|
||||||
|
title: "Target tab",
|
||||||
|
activePaneKey: "websocket:target-child",
|
||||||
|
panes: [
|
||||||
|
{ key: "websocket:target", chatId: "target", title: "Target tab" },
|
||||||
|
{
|
||||||
|
key: "websocket:target-child",
|
||||||
|
chatId: "target-child",
|
||||||
|
title: "Target research",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
onSelect={onSelect}
|
||||||
|
onSelectPane={onSelectPane}
|
||||||
|
onDetachPane={onDetachPane}
|
||||||
|
onDissolveTab={onDissolveTab}
|
||||||
|
onAttachPane={onAttachPane}
|
||||||
|
onRequestDelete={vi.fn()}
|
||||||
|
onTogglePin={vi.fn()}
|
||||||
|
onRequestRename={onRequestRename}
|
||||||
|
onToggleArchive={vi.fn()}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
const section = screen.getByRole("region", { name: "Topics" });
|
|
||||||
const text = section.textContent ?? "";
|
const child = screen.getByRole("button", { name: "Research pane" });
|
||||||
expect(text.indexOf("Bravo")).toBeLessThan(text.indexOf("Charlie"));
|
expect(child.closest("[data-sidebar-pane]"))
|
||||||
expect(text.indexOf("Charlie")).toBeLessThan(text.indexOf("Alpha"));
|
.toHaveAttribute("data-sidebar-pane", "websocket:child");
|
||||||
|
expect(child).toHaveAttribute("aria-current", "true");
|
||||||
|
const targetTabRow = screen.getByRole("button", { name: "Tab: Target tab" })
|
||||||
|
.closest("li")!;
|
||||||
|
const targetChild = within(targetTabRow).getByRole("button", {
|
||||||
|
name: "Target research",
|
||||||
|
});
|
||||||
|
expect(targetChild.closest("[data-sidebar-pane]"))
|
||||||
|
.toHaveAttribute("data-sidebar-pane", "websocket:target-child");
|
||||||
|
expect(targetChild).not.toHaveAttribute("aria-current");
|
||||||
|
fireEvent.click(targetChild);
|
||||||
|
expect(onSelectPane).toHaveBeenCalledWith(
|
||||||
|
"websocket:target",
|
||||||
|
"websocket:target-child",
|
||||||
|
);
|
||||||
|
fireEvent.click(child);
|
||||||
|
expect(onSelectPane).toHaveBeenCalledWith("websocket:root", "websocket:child");
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Root topic" }));
|
||||||
|
expect(onSelectPane).toHaveBeenCalledWith("websocket:root", "websocket:root");
|
||||||
|
expect(onSelect).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
onSelectPane.mockClear();
|
||||||
|
const rootTab = screen.getByRole("button", { name: "Tab: Root topic" });
|
||||||
|
fireEvent.click(rootTab);
|
||||||
|
expect(onSelectPane).not.toHaveBeenCalled();
|
||||||
|
expect(onSelect).not.toHaveBeenCalled();
|
||||||
|
expect(rootTab).toHaveAttribute("aria-expanded", "false");
|
||||||
|
fireEvent.click(rootTab);
|
||||||
|
expect(rootTab).toHaveAttribute("aria-expanded", "true");
|
||||||
|
|
||||||
|
fireEvent.pointerDown(screen.getByRole("button", {
|
||||||
|
name: "Topic actions for Root topic",
|
||||||
|
}), { button: 0, ctrlKey: false });
|
||||||
|
expect(await screen.findByRole("menuitem", { name: "Dissolve group" }))
|
||||||
|
.toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("menuitem", { name: "Delete all chats" }))
|
||||||
|
.toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("menuitem", { name: "Select" }))
|
||||||
|
.not.toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getByRole("menuitem", { name: "Dissolve group" }));
|
||||||
|
expect(onDissolveTab).toHaveBeenCalledWith("websocket:root");
|
||||||
|
|
||||||
|
fireEvent.pointerDown(screen.getByRole("button", {
|
||||||
|
name: "Research pane pane actions",
|
||||||
|
}), { button: 0, ctrlKey: false });
|
||||||
|
const moveTo = await screen.findByRole("menuitem", { name: "Move to" });
|
||||||
|
fireEvent.pointerMove(moveTo, { pointerType: "mouse" });
|
||||||
|
fireEvent.click(await screen.findByRole("menuitem", { name: "Target tab · 2/4" }));
|
||||||
|
expect(onAttachPane).toHaveBeenCalledWith("websocket:child", "websocket:target");
|
||||||
|
|
||||||
|
fireEvent.pointerDown(screen.getByRole("button", {
|
||||||
|
name: "Research pane pane actions",
|
||||||
|
}), { button: 0, ctrlKey: false });
|
||||||
|
fireEvent.click(await screen.findByRole("menuitem", {
|
||||||
|
name: "Remove",
|
||||||
|
}));
|
||||||
|
expect(onDetachPane).toHaveBeenCalledWith("websocket:root", "websocket:child");
|
||||||
|
|
||||||
|
fireEvent.pointerDown(screen.getByRole("button", {
|
||||||
|
name: "Root topic pane actions",
|
||||||
|
}), { button: 0, ctrlKey: false });
|
||||||
|
expect(screen.getByRole("menuitem", { name: "Move to" }))
|
||||||
|
.toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("menuitem", { name: "Remove" }))
|
||||||
|
.toBeInTheDocument();
|
||||||
|
fireEvent.keyDown(document, { key: "Escape" });
|
||||||
|
|
||||||
|
expect(child).toHaveAttribute("draggable", "false");
|
||||||
|
expect(screen.getByRole("button", { name: "Tab: Target tab" }))
|
||||||
|
.toHaveAttribute("draggable", "false");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("collapses a multi-pane tab into one Chrome-style group header", () => {
|
||||||
|
render(
|
||||||
|
<ChatList
|
||||||
|
sessions={[session({
|
||||||
|
chatId: "root",
|
||||||
|
title: "Root topic",
|
||||||
|
workspaceScope: {
|
||||||
|
project_path: "/Users/me/nanobot",
|
||||||
|
project_name: "nanobot",
|
||||||
|
access_mode: "restricted",
|
||||||
|
},
|
||||||
|
})]}
|
||||||
|
activeKey="websocket:root"
|
||||||
|
paneGroups={{
|
||||||
|
"websocket:root": {
|
||||||
|
tabKey: "websocket:root",
|
||||||
|
title: "Root topic",
|
||||||
|
activePaneKey: "websocket:child",
|
||||||
|
panes: [
|
||||||
|
{ key: "websocket:root", chatId: "root", title: "Root topic" },
|
||||||
|
{ key: "websocket:child", chatId: "child", title: "Research pane" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
onSelect={vi.fn()}
|
||||||
|
onSelectPane={vi.fn()}
|
||||||
|
onRequestDelete={vi.fn()}
|
||||||
|
onTogglePin={vi.fn()}
|
||||||
|
onRequestRename={vi.fn()}
|
||||||
|
onToggleArchive={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const tabButton = screen.getByRole("button", { name: "Tab: Root topic" });
|
||||||
|
const tabGroup = tabButton.closest("[data-sidebar-tab-group]")!;
|
||||||
|
const tabHeader = tabButton.closest("[data-workbench-tab]")!;
|
||||||
|
const tabSurface = tabButton.closest("[data-workbench-tab-surface]")!;
|
||||||
|
expect(tabGroup).toHaveAttribute("data-sidebar-tab-group", "true");
|
||||||
|
expect(tabHeader).not.toHaveAttribute("data-chat-row");
|
||||||
|
expect(tabHeader).not.toHaveAttribute("data-sidebar-pane");
|
||||||
|
expect(tabButton).not.toHaveAttribute("aria-current");
|
||||||
|
expect(tabButton.querySelector("svg")).not.toBeInTheDocument();
|
||||||
|
const paneList = within(tabGroup).getByRole("list", { name: "Panes in Root topic" });
|
||||||
|
expect(tabSurface).toContainElement(paneList);
|
||||||
|
const activePane = within(tabGroup).getByRole("button", { name: "Research pane" });
|
||||||
|
expect(activePane).toHaveAttribute("aria-current", "true");
|
||||||
|
expect(activePane.closest("[data-sidebar-pane]")).toHaveClass(
|
||||||
|
"bg-sidebar-selected",
|
||||||
|
"rounded-[0.65rem]",
|
||||||
|
);
|
||||||
|
expect(screen.getByRole("button", {
|
||||||
|
name: "Research pane pane actions",
|
||||||
|
})).toHaveClass("opacity-0");
|
||||||
|
expect(within(tabGroup).getByRole("button", { name: "Root topic" }))
|
||||||
|
.not.toHaveAttribute("aria-current");
|
||||||
|
expect(tabGroup).not.toHaveTextContent("2/4");
|
||||||
|
|
||||||
|
const collapse = within(tabGroup).getByRole("button", {
|
||||||
|
name: "Collapse panes in Root topic",
|
||||||
|
});
|
||||||
|
expect(collapse).toHaveAttribute("aria-expanded", "true");
|
||||||
|
fireEvent.click(collapse);
|
||||||
|
|
||||||
|
expect(tabGroup).toHaveAttribute("data-pane-group-collapsed", "true");
|
||||||
|
expect(within(tabGroup).queryByRole("button", { name: "Research pane" }))
|
||||||
|
.not.toBeInTheDocument();
|
||||||
|
expect(within(tabGroup).getByRole("button", {
|
||||||
|
name: "Expand panes in Root topic",
|
||||||
|
})).toHaveAttribute("aria-expanded", "false");
|
||||||
|
expect(within(tabGroup).getByRole("button", { name: "Tab: Root topic" }))
|
||||||
|
.not.toHaveAttribute("aria-current");
|
||||||
|
expect(tabSurface).toHaveClass("bg-sidebar-foreground/[0.045]");
|
||||||
|
|
||||||
|
fireEvent.click(within(tabGroup).getByRole("button", {
|
||||||
|
name: "Expand panes in Root topic",
|
||||||
|
}));
|
||||||
|
expect(within(tabGroup).getByRole("button", { name: "Research pane" }))
|
||||||
|
.toBeInTheDocument();
|
||||||
|
expect(within(tabGroup).getByRole("button", { name: "Root topic" }))
|
||||||
|
.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("selects a whole tab or individual panes for one bulk delete", async () => {
|
||||||
|
const onRequestDeleteMany = vi.fn();
|
||||||
|
render(
|
||||||
|
<ChatList
|
||||||
|
sessions={[
|
||||||
|
session({ chatId: "root", title: "Root topic" }),
|
||||||
|
session({ chatId: "target", title: "Target tab" }),
|
||||||
|
]}
|
||||||
|
activeKey="websocket:root"
|
||||||
|
paneGroups={{
|
||||||
|
"websocket:root": {
|
||||||
|
tabKey: "websocket:root",
|
||||||
|
title: "Root topic",
|
||||||
|
activePaneKey: "websocket:root",
|
||||||
|
panes: [
|
||||||
|
{ key: "websocket:root", chatId: "root", title: "Root topic" },
|
||||||
|
{ key: "websocket:child", chatId: "child", title: "Research pane" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
onSelect={vi.fn()}
|
||||||
|
onRequestDelete={vi.fn()}
|
||||||
|
onRequestDeleteMany={onRequestDeleteMany}
|
||||||
|
onTogglePin={vi.fn()}
|
||||||
|
onRequestRename={vi.fn()}
|
||||||
|
onToggleArchive={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.pointerDown(screen.getByRole("button", {
|
||||||
|
name: "Root topic pane actions",
|
||||||
|
}), { button: 0, ctrlKey: false });
|
||||||
|
fireEvent.click(await screen.findByRole("menuitem", { name: "Select" }));
|
||||||
|
|
||||||
|
expect(screen.getByText("1 selected")).toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Tab: Root topic" }));
|
||||||
|
expect(screen.getByRole("button", { name: "Tab: Root topic" }))
|
||||||
|
.toHaveAttribute("aria-pressed", "true");
|
||||||
|
expect(screen.getByRole("button", { name: "Root topic" }))
|
||||||
|
.toHaveAttribute("aria-pressed", "true");
|
||||||
|
expect(screen.getByRole("button", { name: "Research pane" }))
|
||||||
|
.toHaveAttribute("aria-pressed", "true");
|
||||||
|
expect(screen.getByText("2 selected")).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Target tab" }));
|
||||||
|
expect(screen.getByText("3 selected")).toBeInTheDocument();
|
||||||
|
fireEvent.click(within(screen.getByTestId("delete-selection-bar")).getByRole("button", {
|
||||||
|
name: "Delete",
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(onRequestDeleteMany).toHaveBeenCalledWith([
|
||||||
|
{ key: "websocket:root", label: "Root topic" },
|
||||||
|
{ key: "websocket:child", label: "Research pane" },
|
||||||
|
{ key: "websocket:target", label: "Target tab" },
|
||||||
|
]);
|
||||||
|
expect(screen.queryByTestId("delete-selection-bar")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows temporary chats separately and lets the user reopen or close them", async () => {
|
it("shows temporary chats separately and lets the user reopen or close them", async () => {
|
||||||
@@ -359,40 +615,7 @@ describe("ChatList", () => {
|
|||||||
expect(within(chatsSection).queryByText("Project chat")).not.toBeInTheDocument();
|
expect(within(chatsSection).queryByText("Project chat")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("positions one background highlight and resets it across hidden targets", () => {
|
it("switches row-owned tab highlights without a moving selection surface", () => {
|
||||||
let revealFrame: FrameRequestCallback | null = null;
|
|
||||||
let resizeObserverCallback: ResizeObserverCallback | null = null;
|
|
||||||
let activeTargetVisible = true;
|
|
||||||
class MockResizeObserver {
|
|
||||||
constructor(callback: ResizeObserverCallback) {
|
|
||||||
resizeObserverCallback = callback;
|
|
||||||
}
|
|
||||||
|
|
||||||
observe() {}
|
|
||||||
unobserve() {}
|
|
||||||
disconnect() {}
|
|
||||||
}
|
|
||||||
vi.stubGlobal("ResizeObserver", MockResizeObserver);
|
|
||||||
vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => {
|
|
||||||
revealFrame = callback;
|
|
||||||
return 1;
|
|
||||||
});
|
|
||||||
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(
|
|
||||||
function (this: HTMLElement) {
|
|
||||||
if (this.hasAttribute("data-chat-list-content")) {
|
|
||||||
return rect({ left: 0, top: 0, width: 300, height: 200 });
|
|
||||||
}
|
|
||||||
if (this.getAttribute("data-chat-row") === "websocket:active") {
|
|
||||||
return activeTargetVisible
|
|
||||||
? rect({ left: 8, top: 12, width: 284, height: 32 })
|
|
||||||
: rect({ left: 0, top: 0, width: 0, height: 0 });
|
|
||||||
}
|
|
||||||
if (this.getAttribute("data-chat-row") === "websocket:inactive") {
|
|
||||||
return rect({ left: 8, top: 48, width: 284, height: 40 });
|
|
||||||
}
|
|
||||||
return rect({ left: 0, top: 0, width: 0, height: 0 });
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const props = {
|
const props = {
|
||||||
sessions: [
|
sessions: [
|
||||||
session({ chatId: "active", title: "Active topic" }),
|
session({ chatId: "active", title: "Active topic" }),
|
||||||
@@ -412,45 +635,12 @@ describe("ChatList", () => {
|
|||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
const highlight = screen.getByTestId("sessions-selection-highlight");
|
|
||||||
expect(highlight).toHaveClass(
|
|
||||||
"bg-sidebar-foreground/[0.055]",
|
|
||||||
"transition-[transform,width,height]",
|
|
||||||
"motion-reduce:transition-none",
|
|
||||||
);
|
|
||||||
expect(screen.queryByTestId("sessions-selection-highlight-surface"))
|
|
||||||
.not.toBeInTheDocument();
|
|
||||||
expect(resizeObserverCallback).not.toBeNull();
|
|
||||||
|
|
||||||
const activeButton = screen.getByTitle("Active topic");
|
const activeButton = screen.getByTitle("Active topic");
|
||||||
expect(activeButton).toHaveAttribute("aria-current", "page");
|
expect(activeButton).toHaveAttribute("aria-current", "page");
|
||||||
expect(activeButton.parentElement).toHaveClass("transition-[color]");
|
expect(activeButton.closest("[data-sidebar-tab]")).toHaveClass(
|
||||||
expect(activeButton.parentElement).not.toHaveClass("transition-colors");
|
"bg-sidebar-selected",
|
||||||
expect(activeButton.parentElement).not.toHaveClass(
|
|
||||||
"bg-sidebar-accent",
|
|
||||||
"shadow-[inset_0_0_0_1px_hsl(var(--sidebar-border)/0.55)]",
|
|
||||||
);
|
);
|
||||||
expect(highlight).toHaveClass(
|
expect(screen.queryByTestId("sessions-selection-highlight")).not.toBeInTheDocument();
|
||||||
"transition-[transform,width,height]",
|
|
||||||
"motion-reduce:transition-none",
|
|
||||||
);
|
|
||||||
expect(highlight).toHaveStyle(
|
|
||||||
"width: 284px; height: 32px; transform: translate3d(8px, 12px, 0); opacity: 1; transition-property: none",
|
|
||||||
);
|
|
||||||
|
|
||||||
revealFrame?.(0);
|
|
||||||
expect(highlight.style.transitionProperty).toBe("");
|
|
||||||
|
|
||||||
activeTargetVisible = false;
|
|
||||||
resizeObserverCallback?.([], {} as ResizeObserver);
|
|
||||||
expect(highlight).toHaveStyle("opacity: 0");
|
|
||||||
|
|
||||||
activeTargetVisible = true;
|
|
||||||
resizeObserverCallback?.([], {} as ResizeObserver);
|
|
||||||
expect(highlight).toHaveStyle(
|
|
||||||
"width: 284px; height: 32px; transform: translate3d(8px, 12px, 0); opacity: 1; transition-property: none",
|
|
||||||
);
|
|
||||||
revealFrame?.(0);
|
|
||||||
|
|
||||||
rerender(
|
rerender(
|
||||||
<ChatList
|
<ChatList
|
||||||
@@ -461,12 +651,42 @@ describe("ChatList", () => {
|
|||||||
|
|
||||||
expect(screen.getByTitle("Active topic")).not.toHaveAttribute("aria-current");
|
expect(screen.getByTitle("Active topic")).not.toHaveAttribute("aria-current");
|
||||||
expect(screen.getByTitle("Inactive topic")).toHaveAttribute("aria-current", "page");
|
expect(screen.getByTitle("Inactive topic")).toHaveAttribute("aria-current", "page");
|
||||||
expect(highlight).toHaveStyle(
|
expect(screen.getByTitle("Inactive topic").closest("[data-sidebar-tab]")).toHaveClass(
|
||||||
"width: 284px; height: 40px; transform: translate3d(8px, 48px, 0)",
|
"bg-sidebar-selected",
|
||||||
);
|
);
|
||||||
|
});
|
||||||
|
|
||||||
rerender(<ChatList {...props} activeKey={null} />);
|
it("restores collapsed tabs from the local UI preference", () => {
|
||||||
expect(highlight).toHaveStyle("opacity: 0");
|
const props = {
|
||||||
|
sessions: [session({ chatId: "root", title: "Root topic" })],
|
||||||
|
activeKey: "websocket:root",
|
||||||
|
paneGroups: {
|
||||||
|
"websocket:root": {
|
||||||
|
tabKey: "websocket:root",
|
||||||
|
title: "Root topic",
|
||||||
|
activePaneKey: "websocket:root",
|
||||||
|
panes: [
|
||||||
|
{ key: "websocket:root", chatId: "root", title: "Root topic" },
|
||||||
|
{ key: "websocket:child", chatId: "child", title: "Research pane" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
onSelect: vi.fn(),
|
||||||
|
onRequestDelete: vi.fn(),
|
||||||
|
onTogglePin: vi.fn(),
|
||||||
|
onRequestRename: vi.fn(),
|
||||||
|
onToggleArchive: vi.fn(),
|
||||||
|
};
|
||||||
|
const firstRender = render(<ChatList {...props} />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Tab: Root topic" }));
|
||||||
|
expect(screen.queryByRole("button", { name: "Research pane" })).not.toBeInTheDocument();
|
||||||
|
firstRender.unmount();
|
||||||
|
|
||||||
|
render(<ChatList {...props} />);
|
||||||
|
expect(screen.getByRole("button", { name: "Tab: Root topic" }))
|
||||||
|
.toHaveAttribute("aria-expanded", "false");
|
||||||
|
expect(screen.queryByRole("button", { name: "Research pane" })).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("can collapse a project group and keeps project rename separate from chat titles", async () => {
|
it("can collapse a project group and keeps project rename separate from chat titles", async () => {
|
||||||
|
|||||||
@@ -1206,6 +1206,7 @@ describe("NanobotClient", () => {
|
|||||||
project_name_overrides: {},
|
project_name_overrides: {},
|
||||||
tags_by_key: {},
|
tags_by_key: {},
|
||||||
collapsed_groups: {},
|
collapsed_groups: {},
|
||||||
|
workbench: { version: 1, tabs: {} },
|
||||||
view: {
|
view: {
|
||||||
density: "comfortable",
|
density: "comfortable",
|
||||||
show_previews: false,
|
show_previews: false,
|
||||||
@@ -1243,6 +1244,51 @@ describe("NanobotClient", () => {
|
|||||||
await expect(pending).resolves.toEqual(state);
|
await expect(pending).resolves.toEqual(state);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("delivers backend sidebar state updates to every subscriber", () => {
|
||||||
|
const client = new NanobotClient({
|
||||||
|
url: "ws://test",
|
||||||
|
reconnect: false,
|
||||||
|
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
|
||||||
|
});
|
||||||
|
const handler = vi.fn();
|
||||||
|
const state: SidebarStatePayload = {
|
||||||
|
schema_version: 1,
|
||||||
|
pinned_keys: [],
|
||||||
|
archived_keys: [],
|
||||||
|
session_order: [],
|
||||||
|
title_overrides: {},
|
||||||
|
project_name_overrides: {},
|
||||||
|
tags_by_key: {},
|
||||||
|
collapsed_groups: {},
|
||||||
|
workbench: {
|
||||||
|
version: 1,
|
||||||
|
tabs: {
|
||||||
|
"tab:websocket:a": {
|
||||||
|
explicit: true,
|
||||||
|
title: "Research",
|
||||||
|
paneKeys: ["websocket:a", "websocket:b"],
|
||||||
|
layout: "columns",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
view: {
|
||||||
|
density: "comfortable",
|
||||||
|
show_previews: false,
|
||||||
|
show_timestamps: false,
|
||||||
|
show_archived: false,
|
||||||
|
sort: "updated_desc",
|
||||||
|
},
|
||||||
|
updated_at: "2026-08-11T08:00:00Z",
|
||||||
|
};
|
||||||
|
|
||||||
|
client.onSidebarStateUpdate(handler);
|
||||||
|
client.connect();
|
||||||
|
lastSocket().fakeOpen();
|
||||||
|
lastSocket().fakeMessage({ event: "sidebar_state_updated", state });
|
||||||
|
|
||||||
|
expect(handler).toHaveBeenCalledWith(state);
|
||||||
|
});
|
||||||
|
|
||||||
it("does not correlate a new-chat scope rejection to an unrelated sent turn", async () => {
|
it("does not correlate a new-chat scope rejection to an unrelated sent turn", async () => {
|
||||||
const client = new NanobotClient({
|
const client = new NanobotClient({
|
||||||
url: "ws://test",
|
url: "ws://test",
|
||||||
|
|||||||
@@ -0,0 +1,392 @@
|
|||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { type ReactNode, useState } from "react";
|
||||||
|
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { PaneWorkbench } from "@/components/workbench/PaneWorkbench";
|
||||||
|
import {
|
||||||
|
EMPTY_WORKBENCH_STATE,
|
||||||
|
addWorkbenchPane,
|
||||||
|
setWorkbenchLayout,
|
||||||
|
setWorkbenchPaneLayoutOrder,
|
||||||
|
workbenchTab,
|
||||||
|
workbenchTabForPane,
|
||||||
|
} from "@/components/workbench/workbench-model";
|
||||||
|
|
||||||
|
function rect(left: number, top: number, width: number, height: number): DOMRect {
|
||||||
|
return {
|
||||||
|
x: left,
|
||||||
|
y: top,
|
||||||
|
left,
|
||||||
|
top,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
right: left + width,
|
||||||
|
bottom: top + height,
|
||||||
|
toJSON: () => ({}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function WorkbenchHarness({
|
||||||
|
initialLayout = "columns",
|
||||||
|
onPaneOrderChange = () => {},
|
||||||
|
onSplitRatiosChange = () => {},
|
||||||
|
}: {
|
||||||
|
initialLayout?: "columns" | "rows";
|
||||||
|
onPaneOrderChange?: (paneKeys: string[]) => void;
|
||||||
|
onSplitRatiosChange?: (splitRatios: number[]) => void;
|
||||||
|
} = {}) {
|
||||||
|
const [activePaneKey, setActivePaneKey] = useState("beta");
|
||||||
|
const [state, setState] = useState(() => {
|
||||||
|
const initial = addWorkbenchPane(EMPTY_WORKBENCH_STATE, "alpha", "beta");
|
||||||
|
const tabKey = workbenchTabForPane(initial, "alpha").tabKey;
|
||||||
|
return setWorkbenchLayout(initial, tabKey, initialLayout);
|
||||||
|
});
|
||||||
|
const tabKey = workbenchTabForPane(state, "alpha").tabKey;
|
||||||
|
const tab = workbenchTab(state, tabKey);
|
||||||
|
if (!tab) return null;
|
||||||
|
const titles: Record<string, string> = { alpha: "Alpha", beta: "Beta" };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PaneWorkbench
|
||||||
|
panes={tab.layoutPaneKeys.map((key) => ({ key, title: titles[key] }))}
|
||||||
|
activePaneKey={activePaneKey}
|
||||||
|
layout={tab.layout}
|
||||||
|
splitRatios={tab.splitRatios}
|
||||||
|
showLayoutControl
|
||||||
|
onActivatePane={setActivePaneKey}
|
||||||
|
onAddPane={vi.fn()}
|
||||||
|
onLayoutChange={(layout) => setState((current) => (
|
||||||
|
setWorkbenchLayout(current, tabKey, layout)
|
||||||
|
))}
|
||||||
|
onPaneOrderChange={(paneKeys) => {
|
||||||
|
onPaneOrderChange(paneKeys);
|
||||||
|
setState((current) => (
|
||||||
|
setWorkbenchPaneLayoutOrder(current, tabKey, paneKeys)
|
||||||
|
));
|
||||||
|
}}
|
||||||
|
onSplitRatiosChange={onSplitRatiosChange}
|
||||||
|
renderPane={(pane, context) => (
|
||||||
|
<>
|
||||||
|
<button type="button">Focus {pane.title}</button>
|
||||||
|
{context.headerPortalTarget && context.active ? createPortal(
|
||||||
|
context.headerActions,
|
||||||
|
context.headerPortalTarget,
|
||||||
|
) : null}
|
||||||
|
{context.composerPortalTarget ? createPortal(
|
||||||
|
<div hidden={!context.active}>
|
||||||
|
<textarea aria-label={`Composer ${pane.title}`} />
|
||||||
|
</div>,
|
||||||
|
context.composerPortalTarget,
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BspWorkbenchHarness() {
|
||||||
|
const panes = ["alpha", "beta", "gamma", "delta"].map((key) => ({
|
||||||
|
key,
|
||||||
|
title: key,
|
||||||
|
}));
|
||||||
|
return (
|
||||||
|
<PaneWorkbench
|
||||||
|
panes={panes}
|
||||||
|
activePaneKey="delta"
|
||||||
|
layout="bsp"
|
||||||
|
splitRatios={[]}
|
||||||
|
showLayoutControl
|
||||||
|
onActivatePane={vi.fn()}
|
||||||
|
onAddPane={vi.fn()}
|
||||||
|
onLayoutChange={vi.fn()}
|
||||||
|
onPaneOrderChange={vi.fn()}
|
||||||
|
onSplitRatiosChange={vi.fn()}
|
||||||
|
renderPane={(pane) => <span>{pane.title}</span>}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("PaneWorkbench", () => {
|
||||||
|
const originalGetBoundingClientRect = HTMLElement.prototype.getBoundingClientRect;
|
||||||
|
const originalAnimate = HTMLElement.prototype.animate;
|
||||||
|
const animate = vi.fn(() => ({
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
cancel: vi.fn(),
|
||||||
|
}) as unknown as Animation);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubGlobal("matchMedia", vi.fn((query: string) => ({
|
||||||
|
matches: false,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addListener: vi.fn(),
|
||||||
|
removeListener: vi.fn(),
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
dispatchEvent: vi.fn(),
|
||||||
|
})));
|
||||||
|
HTMLElement.prototype.animate = animate;
|
||||||
|
HTMLElement.prototype.getBoundingClientRect = function getBoundingClientRect() {
|
||||||
|
if (this.dataset.testid === "pane-grid") return rect(0, 0, 1000, 1000);
|
||||||
|
if (!this.classList.contains("workbench-pane")) {
|
||||||
|
return originalGetBoundingClientRect.call(this);
|
||||||
|
}
|
||||||
|
const layout = this.parentElement?.dataset.layout;
|
||||||
|
const index = Array.from(this.parentElement?.children ?? []).indexOf(this);
|
||||||
|
return layout === "rows"
|
||||||
|
? rect(0, index * 500, 1000, 500)
|
||||||
|
: rect(index * 500, 0, 500, 1000);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
HTMLElement.prototype.animate = originalAnimate;
|
||||||
|
HTMLElement.prototype.getBoundingClientRect = originalGetBoundingClientRect;
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("focuses without reordering and keeps only the focused composer visible", () => {
|
||||||
|
render(<WorkbenchHarness />);
|
||||||
|
|
||||||
|
const grid = screen.getByTestId("pane-grid");
|
||||||
|
expect(Array.from(grid.children).map((pane) => pane.getAttribute("aria-label")))
|
||||||
|
.toEqual(["Alpha", "Beta"]);
|
||||||
|
expect(screen.getByLabelText("Composer Beta")).toBeVisible();
|
||||||
|
expect(screen.getByLabelText("Composer Alpha")).not.toBeVisible();
|
||||||
|
|
||||||
|
fireEvent.pointerDown(
|
||||||
|
within(screen.getByRole("region", { name: "Alpha" }))
|
||||||
|
.getByRole("button", { name: "Focus Alpha" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(Array.from(grid.children).map((pane) => pane.getAttribute("aria-label")))
|
||||||
|
.toEqual(["Alpha", "Beta"]);
|
||||||
|
expect(screen.getByLabelText("Composer Alpha")).toBeVisible();
|
||||||
|
expect(screen.getByLabelText("Composer Beta")).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves the focused pane between workspace slots from its bottom handle", () => {
|
||||||
|
const onPaneOrderChange = vi.fn();
|
||||||
|
render(<WorkbenchHarness onPaneOrderChange={onPaneOrderChange} />);
|
||||||
|
|
||||||
|
const grid = screen.getByTestId("pane-grid");
|
||||||
|
const handle = screen.getByRole("button", { name: "Move Beta pane" });
|
||||||
|
expect(Array.from(grid.children).map((pane) => pane.getAttribute("aria-label")))
|
||||||
|
.toEqual(["Alpha", "Beta"]);
|
||||||
|
|
||||||
|
fireEvent.pointerDown(handle, { button: 0, pointerId: 1, clientX: 750, clientY: 990 });
|
||||||
|
fireEvent.pointerMove(window, {
|
||||||
|
buttons: 1,
|
||||||
|
pointerId: 1,
|
||||||
|
clientX: 250,
|
||||||
|
clientY: 500,
|
||||||
|
});
|
||||||
|
expect(Array.from(grid.children).map((pane) => pane.getAttribute("aria-label")))
|
||||||
|
.toEqual(["Beta", "Alpha"]);
|
||||||
|
expect(onPaneOrderChange).toHaveBeenCalledOnce();
|
||||||
|
expect(onPaneOrderChange).toHaveBeenCalledWith(["beta", "alpha"]);
|
||||||
|
|
||||||
|
fireEvent.pointerMove(window, {
|
||||||
|
buttons: 1,
|
||||||
|
pointerId: 1,
|
||||||
|
clientX: 750,
|
||||||
|
clientY: 500,
|
||||||
|
});
|
||||||
|
expect(Array.from(grid.children).map((pane) => pane.getAttribute("aria-label")))
|
||||||
|
.toEqual(["Alpha", "Beta"]);
|
||||||
|
expect(onPaneOrderChange).toHaveBeenLastCalledWith(["alpha", "beta"]);
|
||||||
|
fireEvent.pointerUp(window, { pointerId: 1, clientX: 750, clientY: 500 });
|
||||||
|
|
||||||
|
expect(Array.from(grid.children).map((pane) => pane.getAttribute("aria-label")))
|
||||||
|
.toEqual(["Alpha", "Beta"]);
|
||||||
|
expect(onPaneOrderChange).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves panes up and down through stable workspace slots", () => {
|
||||||
|
const onPaneOrderChange = vi.fn();
|
||||||
|
render(
|
||||||
|
<WorkbenchHarness initialLayout="rows" onPaneOrderChange={onPaneOrderChange} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
const grid = screen.getByTestId("pane-grid");
|
||||||
|
const handle = screen.getByRole("button", { name: "Move Beta pane" });
|
||||||
|
fireEvent.pointerDown(handle, { button: 0, pointerId: 1, clientX: 500, clientY: 990 });
|
||||||
|
fireEvent.pointerMove(window, {
|
||||||
|
buttons: 1,
|
||||||
|
pointerId: 1,
|
||||||
|
clientX: 500,
|
||||||
|
clientY: 250,
|
||||||
|
});
|
||||||
|
expect(Array.from(grid.children).map((pane) => pane.getAttribute("aria-label")))
|
||||||
|
.toEqual(["Beta", "Alpha"]);
|
||||||
|
|
||||||
|
fireEvent.pointerMove(window, {
|
||||||
|
buttons: 1,
|
||||||
|
pointerId: 1,
|
||||||
|
clientX: 500,
|
||||||
|
clientY: 750,
|
||||||
|
});
|
||||||
|
expect(Array.from(grid.children).map((pane) => pane.getAttribute("aria-label")))
|
||||||
|
.toEqual(["Alpha", "Beta"]);
|
||||||
|
expect(onPaneOrderChange.mock.calls).toEqual([
|
||||||
|
[["beta", "alpha"]],
|
||||||
|
[["alpha", "beta"]],
|
||||||
|
]);
|
||||||
|
fireEvent.pointerUp(window, { pointerId: 1, clientX: 500, clientY: 750 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves the focused pane between workspace slots with arrow keys", () => {
|
||||||
|
render(<WorkbenchHarness />);
|
||||||
|
|
||||||
|
const handle = screen.getByRole("button", { name: "Move Beta pane" });
|
||||||
|
act(() => handle.focus());
|
||||||
|
expect(handle).toHaveFocus();
|
||||||
|
fireEvent.keyDown(handle, { key: "ArrowLeft" });
|
||||||
|
|
||||||
|
expect(Array.from(screen.getByTestId("pane-grid").children)
|
||||||
|
.map((pane) => pane.getAttribute("aria-label")))
|
||||||
|
.toEqual(["Beta", "Alpha"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("previews edge resizing locally and commits one ratio when dragging ends", () => {
|
||||||
|
const onSplitRatiosChange = vi.fn();
|
||||||
|
render(<WorkbenchHarness onSplitRatiosChange={onSplitRatiosChange} />);
|
||||||
|
|
||||||
|
const grid = screen.getByTestId("pane-grid");
|
||||||
|
const separator = screen.getByRole("separator", {
|
||||||
|
name: "Resize pane boundary 1",
|
||||||
|
});
|
||||||
|
fireEvent.pointerDown(separator, {
|
||||||
|
button: 0,
|
||||||
|
pointerId: 7,
|
||||||
|
clientX: 500,
|
||||||
|
clientY: 400,
|
||||||
|
});
|
||||||
|
fireEvent.pointerMove(window, {
|
||||||
|
buttons: 1,
|
||||||
|
pointerId: 7,
|
||||||
|
clientX: 700,
|
||||||
|
clientY: 400,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(grid.style.gridTemplateColumns)
|
||||||
|
.toBe("minmax(0, 700fr) minmax(0, 300fr)");
|
||||||
|
expect(onSplitRatiosChange).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
fireEvent.pointerUp(window, { pointerId: 7, clientX: 700, clientY: 400 });
|
||||||
|
expect(onSplitRatiosChange).toHaveBeenCalledOnce();
|
||||||
|
expect(onSplitRatiosChange).toHaveBeenCalledWith([0.7]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resizes a pane boundary with the matching arrow keys", () => {
|
||||||
|
const onSplitRatiosChange = vi.fn();
|
||||||
|
render(<WorkbenchHarness onSplitRatiosChange={onSplitRatiosChange} />);
|
||||||
|
|
||||||
|
const separator = screen.getByRole("separator", {
|
||||||
|
name: "Resize pane boundary 1",
|
||||||
|
});
|
||||||
|
fireEvent.keyDown(separator, { key: "ArrowLeft" });
|
||||||
|
|
||||||
|
expect(onSplitRatiosChange).toHaveBeenCalledWith([0.47]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps one shared layout control and animates geometry changes", async () => {
|
||||||
|
render(<WorkbenchHarness />);
|
||||||
|
|
||||||
|
const header = screen.getByTestId("workbench-header-host");
|
||||||
|
expect(within(header).getAllByRole("button", { name: "Pane layout" })).toHaveLength(1);
|
||||||
|
fireEvent.pointerDown(within(header).getByRole("button", { name: "Pane layout" }), {
|
||||||
|
button: 0,
|
||||||
|
ctrlKey: false,
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByRole("menuitemradio", { name: "Rows" }));
|
||||||
|
expect(screen.getByTestId("pane-grid")).toHaveAttribute("data-layout", "rows");
|
||||||
|
await waitFor(() => expect(animate).toHaveBeenCalledTimes(2));
|
||||||
|
|
||||||
|
fireEvent.pointerDown(within(header).getByRole("button", { name: "Pane layout" }), {
|
||||||
|
button: 0,
|
||||||
|
ctrlKey: false,
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByRole("menuitemradio", { name: "BSP" }));
|
||||||
|
expect(screen.getByTestId("pane-grid")).toHaveAttribute("data-layout", "bsp");
|
||||||
|
await waitFor(() => expect(animate).toHaveBeenCalledTimes(4));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders only the active pane and hides workbench controls on mobile", () => {
|
||||||
|
vi.stubGlobal("matchMedia", vi.fn((query: string) => ({
|
||||||
|
matches: query.includes("max-width: 767px"),
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addListener: vi.fn(),
|
||||||
|
removeListener: vi.fn(),
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
dispatchEvent: vi.fn(),
|
||||||
|
})));
|
||||||
|
const panes = ["alpha", "beta", "gamma", "delta"].map((key) => ({
|
||||||
|
key,
|
||||||
|
title: key,
|
||||||
|
}));
|
||||||
|
const props = {
|
||||||
|
panes,
|
||||||
|
layout: "bsp" as const,
|
||||||
|
showLayoutControl: true,
|
||||||
|
onActivatePane: vi.fn(),
|
||||||
|
onAddPane: vi.fn(),
|
||||||
|
onLayoutChange: vi.fn(),
|
||||||
|
onPaneOrderChange: vi.fn(),
|
||||||
|
renderPane: (pane: { key: string; title: string }, context: {
|
||||||
|
headerActions: ReactNode;
|
||||||
|
}) => <>{context.headerActions}<span>{pane.title}</span></>,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { rerender } = render(<PaneWorkbench {...props} activePaneKey="gamma" />);
|
||||||
|
|
||||||
|
expect(screen.getByTestId("pane-grid").children).toHaveLength(1);
|
||||||
|
expect(screen.getByTestId("workbench-pane-gamma")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("button", { name: "Pane layout" })).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("button", { name: "Add pane" })).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("separator")).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
rerender(<PaneWorkbench {...props} activePaneKey="delta" />);
|
||||||
|
expect(screen.getByTestId("pane-grid").children).toHaveLength(1);
|
||||||
|
expect(screen.getByTestId("workbench-pane-delta")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId("workbench-pane-gamma")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("explains why the desktop add-pane control is disabled", () => {
|
||||||
|
render(
|
||||||
|
<PaneWorkbench
|
||||||
|
panes={[{ key: "alpha", title: "Alpha" }]}
|
||||||
|
activePaneKey="alpha"
|
||||||
|
layout="columns"
|
||||||
|
showLayoutControl={false}
|
||||||
|
addPaneDisabled
|
||||||
|
addPaneDisabledLabel="Maximum 4 panes"
|
||||||
|
onActivatePane={vi.fn()}
|
||||||
|
onAddPane={vi.fn()}
|
||||||
|
onLayoutChange={vi.fn()}
|
||||||
|
onPaneOrderChange={vi.fn()}
|
||||||
|
renderPane={(_pane, context) => context.headerActions}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByRole("button", { name: "Add pane" }))
|
||||||
|
.toHaveAttribute("title", "Maximum 4 panes");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fills the workbench through alternating binary splits", () => {
|
||||||
|
render(<BspWorkbenchHarness />);
|
||||||
|
|
||||||
|
const alpha = screen.getByTestId("workbench-pane-alpha");
|
||||||
|
const beta = screen.getByTestId("workbench-pane-beta");
|
||||||
|
const gamma = screen.getByTestId("workbench-pane-gamma");
|
||||||
|
const delta = screen.getByTestId("workbench-pane-delta");
|
||||||
|
expect([alpha.style.gridColumn, alpha.style.gridRow]).toEqual(["1 / 2", "1 / 3"]);
|
||||||
|
expect([beta.style.gridColumn, beta.style.gridRow]).toEqual(["2 / 4", "1 / 2"]);
|
||||||
|
expect([gamma.style.gridColumn, gamma.style.gridRow]).toEqual(["2 / 3", "2 / 3"]);
|
||||||
|
expect([delta.style.gridColumn, delta.style.gridRow]).toEqual(["3 / 4", "2 / 3"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { useSidebarState } from "@/hooks/useSidebarState";
|
||||||
|
import type { NanobotClient } from "@/lib/nanobot-client";
|
||||||
|
import type { SidebarStatePayload } from "@/lib/types";
|
||||||
|
import { ClientProvider } from "@/providers/ClientProvider";
|
||||||
|
|
||||||
|
describe("useSidebarState", () => {
|
||||||
|
it("serializes full-state writes so an older request cannot overwrite a newer update", async () => {
|
||||||
|
let resolveFirstWrite: (() => void) | null = null;
|
||||||
|
let sidebarStateUpdateHandler: ((state: SidebarStatePayload) => void) | null = null;
|
||||||
|
const setSidebarState = vi.fn()
|
||||||
|
.mockImplementationOnce((state: SidebarStatePayload) => new Promise<SidebarStatePayload>(
|
||||||
|
(resolve) => {
|
||||||
|
resolveFirstWrite = () => resolve(state);
|
||||||
|
},
|
||||||
|
))
|
||||||
|
.mockImplementation(async (state: SidebarStatePayload) => state);
|
||||||
|
const client = {
|
||||||
|
status: "open" as const,
|
||||||
|
onStatus: () => () => {},
|
||||||
|
onSidebarStateUpdate: (handler: (state: SidebarStatePayload) => void) => {
|
||||||
|
sidebarStateUpdateHandler = handler;
|
||||||
|
return () => {
|
||||||
|
sidebarStateUpdateHandler = null;
|
||||||
|
};
|
||||||
|
},
|
||||||
|
setSidebarState,
|
||||||
|
} as unknown as NanobotClient;
|
||||||
|
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({}),
|
||||||
|
}));
|
||||||
|
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||||
|
<ClientProvider client={client} token="token">
|
||||||
|
{children}
|
||||||
|
</ClientProvider>
|
||||||
|
);
|
||||||
|
const { result } = renderHook(() => useSidebarState([], false), { wrapper });
|
||||||
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
void result.current.update((current) => ({
|
||||||
|
...current,
|
||||||
|
title_overrides: { "websocket:a": "First" },
|
||||||
|
}));
|
||||||
|
void result.current.update((current) => ({
|
||||||
|
...current,
|
||||||
|
title_overrides: { "websocket:a": "Second" },
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(setSidebarState).toHaveBeenCalledTimes(1);
|
||||||
|
act(() => {
|
||||||
|
sidebarStateUpdateHandler?.(setSidebarState.mock.calls[0]?.[0]);
|
||||||
|
});
|
||||||
|
expect(result.current.state.title_overrides).toEqual({
|
||||||
|
"websocket:a": "Second",
|
||||||
|
});
|
||||||
|
act(() => resolveFirstWrite?.());
|
||||||
|
await waitFor(() => expect(setSidebarState).toHaveBeenCalledTimes(2));
|
||||||
|
expect(setSidebarState.mock.calls[1]?.[0]).toEqual(expect.objectContaining({
|
||||||
|
title_overrides: { "websocket:a": "Second" },
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
EMPTY_WORKBENCH_STATE,
|
||||||
|
MAX_WORKBENCH_PANES,
|
||||||
|
addWorkbenchPane,
|
||||||
|
attachWorkbenchPane,
|
||||||
|
createWorkbenchTab,
|
||||||
|
detachWorkbenchPane,
|
||||||
|
dissolveWorkbenchTab,
|
||||||
|
normalizeWorkbenchState,
|
||||||
|
orderWorkbenchTabs,
|
||||||
|
reconcileWorkbench,
|
||||||
|
renameWorkbenchTab,
|
||||||
|
setWorkbenchLayout,
|
||||||
|
setWorkbenchPaneLayoutOrder,
|
||||||
|
setWorkbenchSplitRatios,
|
||||||
|
workbenchTab,
|
||||||
|
workbenchTabForPane,
|
||||||
|
} from "@/components/workbench/workbench-model";
|
||||||
|
|
||||||
|
describe("workbench model", () => {
|
||||||
|
it("derives standalone panes without persisting virtual tabs", () => {
|
||||||
|
const match = workbenchTabForPane(EMPTY_WORKBENCH_STATE, "pane-a");
|
||||||
|
|
||||||
|
expect(match.tabKey).not.toBe("pane-a");
|
||||||
|
expect(match.tab).toEqual({
|
||||||
|
explicit: false,
|
||||||
|
title: null,
|
||||||
|
paneKeys: ["pane-a"],
|
||||||
|
layoutPaneKeys: ["pane-a"],
|
||||||
|
layout: "columns",
|
||||||
|
splitRatios: [],
|
||||||
|
});
|
||||||
|
expect(EMPTY_WORKBENCH_STATE.tabs).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists only a visible singleton group", () => {
|
||||||
|
const state = createWorkbenchTab(EMPTY_WORKBENCH_STATE, "pane-a");
|
||||||
|
const match = workbenchTabForPane(state, "pane-a");
|
||||||
|
|
||||||
|
expect(workbenchTab(state, match.tabKey)).toMatchObject({
|
||||||
|
explicit: true,
|
||||||
|
paneKeys: ["pane-a"],
|
||||||
|
});
|
||||||
|
expect(detachWorkbenchPane(state, match.tabKey, "pane-a").tabs).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("materializes a group when a pane is added", () => {
|
||||||
|
let state = addWorkbenchPane(EMPTY_WORKBENCH_STATE, "pane-a", "pane-b");
|
||||||
|
const tabKey = workbenchTabForPane(state, "pane-a").tabKey;
|
||||||
|
state = setWorkbenchLayout(state, tabKey, "main-stack");
|
||||||
|
state = renameWorkbenchTab(state, tabKey, "Research");
|
||||||
|
|
||||||
|
expect(workbenchTab(state, tabKey)).toEqual({
|
||||||
|
explicit: false,
|
||||||
|
title: "Research",
|
||||||
|
paneKeys: ["pane-a", "pane-b"],
|
||||||
|
layoutPaneKeys: ["pane-a", "pane-b"],
|
||||||
|
layout: "main-stack",
|
||||||
|
splitRatios: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detaches a pane without persisting its standalone projection", () => {
|
||||||
|
let state = addWorkbenchPane(EMPTY_WORKBENCH_STATE, "pane-a", "pane-b");
|
||||||
|
const tabKey = workbenchTabForPane(state, "pane-a").tabKey;
|
||||||
|
state = createWorkbenchTab(state, "pane-a");
|
||||||
|
state = addWorkbenchPane(state, "pane-a", "pane-c");
|
||||||
|
state = detachWorkbenchPane(state, tabKey, "pane-a");
|
||||||
|
|
||||||
|
expect(workbenchTab(state, tabKey)?.paneKeys).toEqual(["pane-b", "pane-c"]);
|
||||||
|
expect(workbenchTabForPane(state, "pane-a").tab.paneKeys).toEqual(["pane-a"]);
|
||||||
|
expect(Object.values(state.tabs).some((tab) => tab.paneKeys.includes("pane-a"))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dissolves a group into derived standalone panes", () => {
|
||||||
|
const grouped = addWorkbenchPane(EMPTY_WORKBENCH_STATE, "pane-a", "pane-b");
|
||||||
|
const tabKey = workbenchTabForPane(grouped, "pane-a").tabKey;
|
||||||
|
const state = dissolveWorkbenchTab(grouped, tabKey);
|
||||||
|
|
||||||
|
expect(state.tabs).toEqual({});
|
||||||
|
expect(workbenchTabForPane(state, "pane-a").tab.paneKeys).toEqual(["pane-a"]);
|
||||||
|
expect(workbenchTabForPane(state, "pane-b").tab.paneKeys).toEqual(["pane-b"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves panes symmetrically and removes an implicit singleton source", () => {
|
||||||
|
let state = addWorkbenchPane(EMPTY_WORKBENCH_STATE, "pane-a", "pane-b");
|
||||||
|
const sourceTabKey = workbenchTabForPane(state, "pane-a").tabKey;
|
||||||
|
state = createWorkbenchTab(state, "pane-c");
|
||||||
|
const targetTabKey = workbenchTabForPane(state, "pane-c").tabKey;
|
||||||
|
|
||||||
|
state = attachWorkbenchPane(state, targetTabKey, "pane-a");
|
||||||
|
expect(workbenchTab(state, sourceTabKey)).toBeNull();
|
||||||
|
expect(workbenchTab(state, targetTabKey)?.paneKeys).toEqual(["pane-c", "pane-a"]);
|
||||||
|
|
||||||
|
state = attachWorkbenchPane(state, targetTabKey, "pane-b");
|
||||||
|
expect(workbenchTab(state, targetTabKey)?.paneKeys).toEqual([
|
||||||
|
"pane-c",
|
||||||
|
"pane-a",
|
||||||
|
"pane-b",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps membership independent from workspace pane order", () => {
|
||||||
|
let state = addWorkbenchPane(EMPTY_WORKBENCH_STATE, "pane-a", "pane-b");
|
||||||
|
state = addWorkbenchPane(state, "pane-a", "pane-c");
|
||||||
|
const tabKey = workbenchTabForPane(state, "pane-a").tabKey;
|
||||||
|
|
||||||
|
const [ordered] = orderWorkbenchTabs(
|
||||||
|
state,
|
||||||
|
["pane-c", "pane-a", "pane-b"],
|
||||||
|
new Map(),
|
||||||
|
);
|
||||||
|
expect(workbenchTab(state, tabKey)?.paneKeys).toEqual(["pane-a", "pane-b", "pane-c"]);
|
||||||
|
expect(ordered.paneKeys).toEqual(["pane-c", "pane-a", "pane-b"]);
|
||||||
|
|
||||||
|
state = setWorkbenchPaneLayoutOrder(state, tabKey, ["pane-b", "pane-c", "pane-a"]);
|
||||||
|
expect(workbenchTab(state, tabKey)?.layoutPaneKeys).toEqual([
|
||||||
|
"pane-b",
|
||||||
|
"pane-c",
|
||||||
|
"pane-a",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stores resize ratios and resets them when geometry changes", () => {
|
||||||
|
let state = addWorkbenchPane(EMPTY_WORKBENCH_STATE, "pane-a", "pane-b");
|
||||||
|
const tabKey = workbenchTabForPane(state, "pane-a").tabKey;
|
||||||
|
state = setWorkbenchSplitRatios(state, tabKey, [0.35]);
|
||||||
|
|
||||||
|
expect(workbenchTab(state, tabKey)?.splitRatios).toEqual([0.35]);
|
||||||
|
state = setWorkbenchLayout(state, tabKey, "rows");
|
||||||
|
expect(workbenchTab(state, tabKey)?.splitRatios).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps groups contiguous and ranks them by their latest pane", () => {
|
||||||
|
let state = addWorkbenchPane(EMPTY_WORKBENCH_STATE, "pane-a", "pane-c");
|
||||||
|
state = addWorkbenchPane(state, "pane-b", "pane-d");
|
||||||
|
const alphaTabKey = workbenchTabForPane(state, "pane-a").tabKey;
|
||||||
|
const betaTabKey = workbenchTabForPane(state, "pane-b").tabKey;
|
||||||
|
|
||||||
|
const tabs = orderWorkbenchTabs(
|
||||||
|
state,
|
||||||
|
["pane-d", "pane-c", "pane-b", "pane-a", "pane-e"],
|
||||||
|
new Map([
|
||||||
|
["pane-a", "2026-08-01T10:00:00Z"],
|
||||||
|
["pane-b", "2026-08-03T10:00:00Z"],
|
||||||
|
["pane-c", "2026-08-05T10:00:00Z"],
|
||||||
|
["pane-d", "2026-08-04T10:00:00Z"],
|
||||||
|
["pane-e", "2026-08-02T10:00:00Z"],
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(tabs.map(({ tabKey, paneKeys }) => ({ tabKey, paneKeys }))).toEqual([
|
||||||
|
{ tabKey: alphaTabKey, paneKeys: ["pane-c", "pane-a"] },
|
||||||
|
{ tabKey: betaTabKey, paneKeys: ["pane-d", "pane-b"] },
|
||||||
|
{ tabKey: workbenchTabForPane(state, "pane-e").tabKey, paneKeys: ["pane-e"] },
|
||||||
|
]);
|
||||||
|
expect(Object.keys(state.tabs)).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caps a group at four panes", () => {
|
||||||
|
let state = addWorkbenchPane(EMPTY_WORKBENCH_STATE, "pane-a", "pane-1");
|
||||||
|
for (let index = 2; index <= MAX_WORKBENCH_PANES; index += 1) {
|
||||||
|
state = addWorkbenchPane(state, "pane-a", `pane-${index}`);
|
||||||
|
}
|
||||||
|
const tabKey = workbenchTabForPane(state, "pane-a").tabKey;
|
||||||
|
expect(workbenchTab(state, tabKey)?.paneKeys).toEqual([
|
||||||
|
"pane-a",
|
||||||
|
"pane-1",
|
||||||
|
"pane-2",
|
||||||
|
"pane-3",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("repairs persisted groups without materializing missing sessions", () => {
|
||||||
|
const state = normalizeWorkbenchState({
|
||||||
|
version: 1,
|
||||||
|
tabs: {
|
||||||
|
alpha: {
|
||||||
|
title: "Alpha",
|
||||||
|
paneKeys: ["pane-a", "pane-b", "pane-b", 9],
|
||||||
|
layout: "unknown",
|
||||||
|
},
|
||||||
|
duplicate: {
|
||||||
|
paneKeys: ["pane-b", "deleted"],
|
||||||
|
layout: "grid",
|
||||||
|
},
|
||||||
|
invisible: {
|
||||||
|
paneKeys: ["pane-c"],
|
||||||
|
layout: "columns",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const reconciled = reconcileWorkbench(
|
||||||
|
state,
|
||||||
|
new Set(["pane-a", "pane-b", "pane-c"]),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(workbenchTab(reconciled, "alpha")).toEqual({
|
||||||
|
explicit: false,
|
||||||
|
title: "Alpha",
|
||||||
|
paneKeys: ["pane-a", "pane-b"],
|
||||||
|
layoutPaneKeys: ["pane-a", "pane-b"],
|
||||||
|
layout: "columns",
|
||||||
|
splitRatios: [],
|
||||||
|
});
|
||||||
|
expect(Object.keys(reconciled.tabs)).toEqual(["alpha"]);
|
||||||
|
expect(workbenchTabForPane(reconciled, "pane-c").tab.paneKeys).toEqual(["pane-c"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps persisted state sparse with thousands of standalone sessions", () => {
|
||||||
|
const sessionKeys = Array.from({ length: 2_000 }, (_, index) => `pane-${index}`);
|
||||||
|
const reconciled = reconcileWorkbench(EMPTY_WORKBENCH_STATE, new Set(sessionKeys));
|
||||||
|
const ordered = orderWorkbenchTabs(reconciled, sessionKeys, new Map());
|
||||||
|
|
||||||
|
expect(reconciled.tabs).toEqual({});
|
||||||
|
expect(ordered).toHaveLength(2_000);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -89,6 +89,7 @@ export default {
|
|||||||
sidebar: {
|
sidebar: {
|
||||||
DEFAULT: "hsl(var(--sidebar))",
|
DEFAULT: "hsl(var(--sidebar))",
|
||||||
foreground: "hsl(var(--sidebar-foreground))",
|
foreground: "hsl(var(--sidebar-foreground))",
|
||||||
|
selected: "hsl(var(--sidebar-selected))",
|
||||||
accent: "hsl(var(--sidebar-accent))",
|
accent: "hsl(var(--sidebar-accent))",
|
||||||
"accent-foreground": "hsl(var(--sidebar-accent-foreground))",
|
"accent-foreground": "hsl(var(--sidebar-accent-foreground))",
|
||||||
border: "hsl(var(--sidebar-border))",
|
border: "hsl(var(--sidebar-border))",
|
||||||
|
|||||||
Reference in New Issue
Block a user