feat(webui): add tabbed pane workbench (#5322)
This commit is contained in:
@@ -530,6 +530,10 @@ class WebSocketChannel(BaseChannel):
|
||||
except Exception as e:
|
||||
self.logger.warning("failed to send {} event: {}", event, e)
|
||||
|
||||
async def _broadcast_webui_event(self, event: str, **fields: Any) -> None:
|
||||
for connection in tuple(self._webui_connections):
|
||||
await self._send_event(connection, event, **fields)
|
||||
|
||||
@classmethod
|
||||
def default_config(cls) -> dict[str, Any]:
|
||||
return WebSocketConfig().model_dump(by_alias=True)
|
||||
@@ -848,7 +852,7 @@ class WebSocketChannel(BaseChannel):
|
||||
)
|
||||
return
|
||||
try:
|
||||
await asyncio.to_thread(
|
||||
saved_state = await asyncio.to_thread(
|
||||
write_webui_sidebar_state,
|
||||
cast(dict[str, Any], state),
|
||||
)
|
||||
@@ -858,6 +862,11 @@ class WebSocketChannel(BaseChannel):
|
||||
"error",
|
||||
detail="invalid_sidebar_state",
|
||||
)
|
||||
return
|
||||
await self._broadcast_webui_event(
|
||||
"sidebar_state_updated",
|
||||
state=saved_state,
|
||||
)
|
||||
return
|
||||
if t == "set_workspace_scope":
|
||||
cid = envelope.get("chat_id")
|
||||
@@ -1207,6 +1216,11 @@ class WebSocketChannel(BaseChannel):
|
||||
message="WebUI mutation returned an invalid response",
|
||||
)
|
||||
return
|
||||
if action == "sidebar.update" and isinstance(result, dict):
|
||||
await self._broadcast_webui_event(
|
||||
"sidebar_state_updated",
|
||||
state=result,
|
||||
)
|
||||
await self._send_webui_response(
|
||||
connection,
|
||||
request_id,
|
||||
|
||||
@@ -1037,6 +1037,63 @@ async def test_webui_persists_sidebar_state_larger_than_http_request_line(
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_webui_sidebar_state_update_broadcasts_workbench_to_other_devices(
|
||||
bus: MagicMock,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
||||
channel = _ch(bus)
|
||||
source = AsyncMock()
|
||||
source.request = SimpleNamespace(headers=Headers())
|
||||
other_device = AsyncMock()
|
||||
channel._webui_connections.update({source, other_device})
|
||||
request_id = "sidebar-workbench-state"
|
||||
|
||||
await channel._dispatch_envelope(
|
||||
source,
|
||||
"webui-client",
|
||||
{
|
||||
"type": "webui_request",
|
||||
"request_id": request_id,
|
||||
"action": "sidebar.update",
|
||||
"payload": {
|
||||
"state": {
|
||||
"workbench": {
|
||||
"version": 1,
|
||||
"tabs": {
|
||||
"tab:websocket:a": {
|
||||
"explicit": True,
|
||||
"title": "Research",
|
||||
"paneKeys": ["websocket:a", "websocket:b"],
|
||||
"layoutPaneKeys": ["websocket:b", "websocket:a"],
|
||||
"layout": "columns",
|
||||
"splitRatios": [0.35],
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
await asyncio.gather(*tuple(channel._webui_request_tasks.values()))
|
||||
|
||||
event = json.loads(other_device.send.await_args.args[0])
|
||||
assert event["event"] == "sidebar_state_updated"
|
||||
assert event["state"]["workbench"]["tabs"]["tab:websocket:a"]["paneKeys"] == [
|
||||
"websocket:a",
|
||||
"websocket:b",
|
||||
]
|
||||
assert event["state"]["workbench"]["tabs"]["tab:websocket:a"]["layoutPaneKeys"] == [
|
||||
"websocket:b",
|
||||
"websocket:a",
|
||||
]
|
||||
assert event["state"]["workbench"]["tabs"]["tab:websocket:a"]["splitRatios"] == [
|
||||
0.35
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_client_cannot_self_assert_webui_quote_context(bus: MagicMock) -> None:
|
||||
channel = _ch(bus)
|
||||
|
||||
@@ -8,7 +8,9 @@ does not modify agent sessions.
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
@@ -24,8 +26,11 @@ _MAX_MAP_ITEMS = 2_000
|
||||
_MAX_KEY_LEN = 512
|
||||
_MAX_TITLE_LEN = 160
|
||||
_MAX_TAG_LEN = 40
|
||||
_MAX_WORKBENCH_PANES = 4
|
||||
_ALLOWED_DENSITIES = {"comfortable", "compact"}
|
||||
_ALLOWED_SORTS = {"updated_desc", "created_desc", "title_asc", "manual"}
|
||||
_ALLOWED_WORKBENCH_LAYOUTS = {"columns", "rows", "grid", "bsp", "main-stack"}
|
||||
_SIDEBAR_STATE_WRITE_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def webui_sidebar_state_path() -> Path:
|
||||
@@ -42,6 +47,7 @@ def default_webui_sidebar_state() -> dict[str, Any]:
|
||||
"project_name_overrides": {},
|
||||
"tags_by_key": {},
|
||||
"collapsed_groups": {},
|
||||
"workbench": {"version": 1, "tabs": {}},
|
||||
"view": {
|
||||
"density": "comfortable",
|
||||
"show_previews": False,
|
||||
@@ -76,6 +82,20 @@ def _clean_string_list(value: Any, *, max_len: int = _MAX_KEY_LEN) -> list[str]:
|
||||
return out
|
||||
|
||||
|
||||
def _clean_split_ratios(value: Any) -> list[float]:
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
ratios: list[float] = []
|
||||
for raw_ratio in cast(list[Any], value)[: _MAX_WORKBENCH_PANES - 1]:
|
||||
if isinstance(raw_ratio, bool) or not isinstance(raw_ratio, (int, float)):
|
||||
continue
|
||||
ratio = float(raw_ratio)
|
||||
if not math.isfinite(ratio):
|
||||
continue
|
||||
ratios.append(round(min(0.95, max(0.05, ratio)), 4))
|
||||
return ratios
|
||||
|
||||
|
||||
def _clean_bool_map(value: Any) -> dict[str, bool]:
|
||||
if not isinstance(value, dict):
|
||||
return {}
|
||||
@@ -131,8 +151,56 @@ def _clean_view(value: Any) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _clean_workbench(value: Any) -> dict[str, Any]:
|
||||
if not isinstance(value, dict):
|
||||
return {"version": 1, "tabs": {}}
|
||||
workbench = cast(dict[str, Any], value)
|
||||
if workbench.get("version") != 1:
|
||||
return {"version": 1, "tabs": {}}
|
||||
raw_tabs = workbench.get("tabs")
|
||||
if not isinstance(raw_tabs, dict):
|
||||
return {"version": 1, "tabs": {}}
|
||||
|
||||
tabs: dict[str, dict[str, Any]] = {}
|
||||
claimed_panes: set[str] = set()
|
||||
for raw_tab_key, raw_tab in list(cast(dict[Any, Any], raw_tabs).items())[:_MAX_MAP_ITEMS]:
|
||||
tab_key = _clean_string(raw_tab_key)
|
||||
if tab_key is None or not isinstance(raw_tab, dict):
|
||||
continue
|
||||
tab = cast(dict[str, Any], raw_tab)
|
||||
pane_keys = [
|
||||
key
|
||||
for key in _clean_string_list(tab.get("paneKeys"))
|
||||
if key not in claimed_panes
|
||||
][:_MAX_WORKBENCH_PANES]
|
||||
if not pane_keys:
|
||||
continue
|
||||
explicit = tab.get("explicit") is True
|
||||
if not explicit and len(pane_keys) == 1:
|
||||
continue
|
||||
requested_layout_pane_keys = [
|
||||
key for key in _clean_string_list(tab.get("layoutPaneKeys")) if key in pane_keys
|
||||
]
|
||||
layout_pane_keys = requested_layout_pane_keys + [
|
||||
key for key in pane_keys if key not in requested_layout_pane_keys
|
||||
]
|
||||
claimed_panes.update(pane_keys)
|
||||
raw_layout = tab.get("layout")
|
||||
layout = raw_layout if raw_layout in _ALLOWED_WORKBENCH_LAYOUTS else "columns"
|
||||
title = _clean_string(tab.get("title"), max_len=_MAX_TITLE_LEN)
|
||||
tabs[tab_key] = {
|
||||
"explicit": explicit,
|
||||
"title": title,
|
||||
"paneKeys": pane_keys,
|
||||
"layoutPaneKeys": layout_pane_keys,
|
||||
"layout": layout,
|
||||
"splitRatios": _clean_split_ratios(tab.get("splitRatios")),
|
||||
}
|
||||
return {"version": 1, "tabs": tabs}
|
||||
|
||||
|
||||
def normalize_webui_sidebar_state(raw: Any) -> dict[str, Any]:
|
||||
"""Return a schema-v1 sidebar state from any older/partial input."""
|
||||
"""Return a validated canonical sidebar state."""
|
||||
if not isinstance(raw, dict):
|
||||
raw = {}
|
||||
raw = cast(dict[str, Any], raw)
|
||||
@@ -146,6 +214,7 @@ def normalize_webui_sidebar_state(raw: Any) -> dict[str, Any]:
|
||||
)
|
||||
state["tags_by_key"] = _clean_tags_by_key(raw.get("tags_by_key"))
|
||||
state["collapsed_groups"] = _clean_bool_map(raw.get("collapsed_groups"))
|
||||
state["workbench"] = _clean_workbench(raw.get("workbench"))
|
||||
state["view"] = _clean_view(raw.get("view"))
|
||||
updated_at = raw.get("updated_at")
|
||||
state["updated_at"] = updated_at if isinstance(updated_at, str) else None
|
||||
@@ -169,6 +238,11 @@ def read_webui_sidebar_state() -> dict[str, Any]:
|
||||
|
||||
|
||||
def write_webui_sidebar_state(raw: dict[str, Any]) -> dict[str, Any]:
|
||||
with _SIDEBAR_STATE_WRITE_LOCK:
|
||||
return _write_webui_sidebar_state(raw)
|
||||
|
||||
|
||||
def _write_webui_sidebar_state(raw: dict[str, Any]) -> dict[str, Any]:
|
||||
state = normalize_webui_sidebar_state(raw)
|
||||
state["updated_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||
encoded = json.dumps(
|
||||
|
||||
Reference in New Issue
Block a user