Compare commits

..
98 changed files with 946 additions and 2757 deletions
+1 -2
View File
@@ -133,8 +133,7 @@ or a result you must retain.
Use the workspace picker before starting project-specific work. This gives the
agent the right project context for file paths, shell commands, and session
metadata. A locally hosted WebUI opens the operating system's folder chooser
when one is available; remote deployments keep the manual absolute path entry.
metadata.
Selecting a project does not replace the configured agent workspace. The two
paths have different responsibilities:
+11 -8
View File
@@ -769,24 +769,27 @@ class MemoryStore:
return f"{prefix}\n\n{diff_body}"
@staticmethod
def prune_dream_sessions(sessions: SessionManager, *, keep: int = 10) -> None:
def prune_dream_sessions(sessions_dir: Path, *, keep: int = 10) -> None:
"""Remove the oldest Dream session files, keeping only the N most recent.
Only current base64url-encoded Dream session keys are considered.
Non-dream session files are never touched.
"""
with sessions.locked_session_files() as sessions_dir:
dream_files: list[tuple[Path, str]] = []
dream_files: list[Path] = []
for path in sessions_dir.glob("*.jsonl"):
decoded_key = SessionManager.decode_storage_key(path.stem)
if decoded_key is not None and decoded_key.startswith("dream:"):
dream_files.append((path, decoded_key))
dream_files.sort(key=lambda item: item[0].stat().st_mtime)
dream_files.append(path)
dream_files.sort(key=lambda p: p.stat().st_mtime)
if len(dream_files) <= keep:
return
for path, key in dream_files[: max(0, len(dream_files) - keep)]:
if sessions.delete_session(key):
to_remove = dream_files[: len(dream_files) - keep]
for path in to_remove:
try:
path.unlink()
logger.debug("Pruned old dream session: {}", path.stem)
else:
except OSError:
logger.warning("Failed to prune dream session {}", path)
@@ -59,7 +59,7 @@ export function FeishuAssistantsPanel({
/>
),
footer: (
<div className="mt-4 overflow-hidden rounded-floating border border-border/70 bg-background px-4 py-4">
<div className="mt-4 overflow-hidden rounded-[16px] border border-border/70 bg-background px-4 py-4">
<div className="text-[13px] font-semibold text-foreground">
{tx("custom.createAnother", "Create another assistant")}
</div>
@@ -144,7 +144,7 @@ function FeishuInstanceAction({
</Button>
</div>
{error ? (
<div className="mt-3 rounded-control border border-destructive/20 px-3 py-2 text-[12px] leading-5 text-destructive">
<div className="mt-3 rounded-[12px] border border-destructive/20 px-3 py-2 text-[12px] leading-5 text-destructive">
{error}
</div>
) : null}
@@ -3266,85 +3266,6 @@ async def _webui_mutate(
)
@pytest.mark.asyncio
async def test_workspace_folder_picker_is_local_authenticated_mutation(
bus: MagicMock,
tmp_path: Path,
monkeypatch,
) -> None:
selected = tmp_path / "project"
selected.mkdir()
pick_folder = AsyncMock(return_value=str(selected))
monkeypatch.setattr(
"nanobot.webui.ws_http.native_folder_picker_available",
lambda: True,
)
monkeypatch.setattr("nanobot.webui.ws_http.pick_native_folder", pick_folder)
channel = _ch(bus)
response = await _webui_mutate(channel, "workspace.pick_folder")
assert response.status_code == 200
assert response.json() == {"path": str(selected)}
pick_folder.assert_awaited_once_with()
@pytest.mark.asyncio
async def test_workspace_folder_picker_rejects_direct_http(
bus: MagicMock,
monkeypatch: pytest.MonkeyPatch,
) -> None:
pick_folder = AsyncMock(return_value="/tmp")
monkeypatch.setattr(
"nanobot.webui.ws_http.native_folder_picker_available",
lambda: True,
)
monkeypatch.setattr("nanobot.webui.ws_http.pick_native_folder", pick_folder)
channel = _ch(bus)
response = await channel.gateway.http.dispatch(
_LOCAL,
_FakeReq(
{"Host": "127.0.0.1:8765"},
path="/api/workspaces/pick-folder",
),
)
assert response is not None
assert response.status_code == 405
assert b"authenticated WebSocket" in response.body
pick_folder.assert_not_awaited()
@pytest.mark.asyncio
@pytest.mark.parametrize(
("connection", "host"),
[(_REMOTE, "127.0.0.1"), (_LOCAL, "0.0.0.0")],
)
async def test_workspace_folder_picker_rejects_nonlocal_surfaces(
bus: MagicMock,
monkeypatch,
connection: _FakeConn,
host: str,
) -> None:
pick_folder = AsyncMock(return_value="/tmp")
monkeypatch.setattr(
"nanobot.webui.ws_http.native_folder_picker_available",
lambda: True,
)
monkeypatch.setattr("nanobot.webui.ws_http.pick_native_folder", pick_folder)
channel = _ch(bus, host=host, token="test-token" if host == "0.0.0.0" else "")
response = await _webui_mutate(
channel,
"workspace.pick_folder",
connection=connection,
)
assert response.status_code == 403
pick_folder.assert_not_awaited()
def test_local_browser_request_requires_loopback_host_and_forwarded_origin() -> None:
from nanobot.webui.http_utils import is_local_browser_request
@@ -197,7 +197,7 @@ export function WeixinPanel({
});
return (
<aside className="min-h-full rounded-panel bg-settings-surface p-5">
<aside className="min-h-full rounded-[20px] bg-settings-surface p-5">
<div className="flex items-start justify-between gap-4">
<div className="flex min-w-0 items-start gap-3">
<WeixinLogo showBrandLogos={showBrandLogos} />
@@ -251,7 +251,7 @@ export function WeixinPanel({
</div>
{runtimeError ? (
<div className="mt-4 rounded-control border border-destructive/20 bg-destructive/5 px-3 py-2 text-[12px] leading-5 text-destructive">
<div className="mt-4 rounded-[12px] border border-destructive/20 bg-destructive/5 px-3 py-2 text-[12px] leading-5 text-destructive">
{runtimeError}
</div>
) : null}
@@ -304,7 +304,7 @@ export function WeixinPanel({
{saveError ? (
<div
role="alert"
className="rounded-control border border-destructive/20 bg-destructive/5 px-3 py-2 text-[12px] leading-5 text-destructive"
className="rounded-[12px] border border-destructive/20 bg-destructive/5 px-3 py-2 text-[12px] leading-5 text-destructive"
>
{saveError}
</div>
@@ -413,7 +413,7 @@ function WeixinLogo({ showBrandLogos }: { showBrandLogos: boolean }) {
const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(logoUrls);
if (showBrandLogos && logoUrl) {
return (
<span className="grid h-10 w-10 shrink-0 place-items-center rounded-control bg-background">
<span className="grid h-10 w-10 shrink-0 place-items-center rounded-[12px] bg-background">
<img
src={logoUrl}
alt=""
@@ -428,7 +428,7 @@ function WeixinLogo({ showBrandLogos }: { showBrandLogos: boolean }) {
}
return (
<span
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-control bg-background text-[11px] font-bold"
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-[12px] bg-background text-[11px] font-bold"
style={{ color: "#07C160" }}
aria-hidden
>
+1 -1
View File
@@ -566,7 +566,7 @@ def _run_gateway(
if sha:
logger.info("Dream commit: {}", sha)
store.compact_history()
prune_dream_sessions(agent.sessions)
prune_dream_sessions(agent.sessions.sessions_dir)
return None
# Heartbeat is a system job that checks HEARTBEAT.md for active tasks.
+1 -1
View File
@@ -490,7 +490,7 @@ async def cmd_dream(ctx: CommandContext) -> OutboundMessage:
if sha:
content += f" (commit {sha})"
store.compact_history()
prune_dream_sessions(loop.sessions)
prune_dream_sessions(loop.sessions.sessions_dir)
await loop.bus.publish_outbound(OutboundMessage(
channel=msg.channel, chat_id=msg.chat_id, content=content,
))
+11 -70
View File
@@ -9,12 +9,12 @@ import re
import secrets
import stat
from collections import OrderedDict
from contextlib import contextmanager, suppress
from contextlib import suppress
from copy import deepcopy
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from typing import Any, Callable, Collection, Generator, Protocol, TypedDict, cast
from typing import Any, Callable, Collection, Protocol, TypedDict, cast
from weakref import WeakValueDictionary
from filelock import FileLock
@@ -65,7 +65,6 @@ _WORKSPACE_STATE_DIR = ".nanobot"
_WORKSPACE_ID_FILE = "workspace-id"
_WORKSPACE_ID_RE = re.compile(r"^[0-9a-f]{32}$")
_SESSION_MIGRATION_LOCK_TIMEOUT_SECONDS = 30
_SESSION_FILES_LOCK_FILENAME = ".session-files.lock"
_COPY_CHUNK_SIZE = 1024 * 1024
@@ -473,28 +472,13 @@ class Session:
if limit <= 0 or len(self.messages) <= limit:
return
original_messages = self.messages
original_last_consolidated = self.last_consolidated
original_provider_state = self.provider_state
original_updated_at = self.updated_at
result = self.retain_recent_legal_suffix(limit)
if not result.dropped:
return
archive_chunk = result.dropped[result.already_consolidated_count:]
if archive_chunk and on_archive:
try:
on_archive(archive_chunk)
except BaseException:
# Retention runs before the archive callback so the callback can
# receive the exact dropped prefix. Restore the in-memory session
# if archival fails; otherwise a later save would persist the
# trimmed state and make that prefix impossible to retry.
self.messages = original_messages
self.last_consolidated = original_last_consolidated
self.provider_state = original_provider_state
self.updated_at = original_updated_at
raise
logger.info(
"Session file cap hit for {}: dropped {}, raw-archived {}, kept {}",
self.key,
@@ -592,18 +576,8 @@ class JsonlSessionStore:
)
self.sessions_dir = ensure_dir(root / workspace_id)
self.legacy_sessions_dir = get_legacy_sessions_dir()
self._session_files_lock = FileLock(
str(self.sessions_dir / _SESSION_FILES_LOCK_FILENAME)
)
with self._session_files_lock:
self._migrate_from_workspace(canonical_workspace)
@contextmanager
def locked_session_files(self) -> Generator[Path, None, None]:
"""Guard direct access to canonical session files in this directory."""
with self._session_files_lock:
yield self.sessions_dir
@staticmethod
def _fsync_directory(path: Path) -> None:
with suppress(PermissionError, NotImplementedError):
@@ -985,7 +959,7 @@ class JsonlSessionStore:
raise RuntimeError(f"refusing to restore into symlinked sessions directory: {old_dir}")
ensure_dir(old_dir)
with self._migration_lock, self._session_files_lock:
with self._migration_lock:
for src in self.sessions_dir.glob("*.jsonl"):
if self.session_key_from_path(src) is None:
continue
@@ -1047,10 +1021,6 @@ class JsonlSessionStore:
return self.legacy_sessions_dir / f"{self.safe_key(key)}.jsonl"
def load(self, key: str) -> Session | None:
with self._session_files_lock:
return self._load_unlocked(key)
def _load_unlocked(self, key: str) -> Session | None:
path = self.get_session_path(key)
if not path.exists():
return None
@@ -1116,7 +1086,7 @@ class JsonlSessionStore:
)
except _SESSION_DATA_ERRORS as e:
logger.warning("Failed to load session {}: {}", key, e)
repaired = self._repair_unlocked(key)
repaired = self.repair(key)
if repaired is not None:
logger.info(
"Recovered session {} from corrupt file ({} messages)",
@@ -1126,10 +1096,6 @@ class JsonlSessionStore:
return repaired
def repair(self, key: str, *, path: Path | None = None) -> Session | None:
with self._session_files_lock:
return self._repair_unlocked(key, path=path)
def _repair_unlocked(self, key: str, *, path: Path | None = None) -> Session | None:
if path is None:
path = self.get_session_path(key)
if not path.exists():
@@ -1222,15 +1188,11 @@ class JsonlSessionStore:
}
def save(self, session: Session, *, fsync: bool = False) -> None:
with self._session_files_lock:
self._save_unlocked(session, fsync=fsync)
def _save_unlocked(self, session: Session, *, fsync: bool = False) -> None:
path = self.get_session_path(session.key)
tmp_path = path.with_name(f".{path.name}.{secrets.token_hex(8)}.tmp")
tmp_path = path.with_suffix(".jsonl.tmp")
try:
with open(tmp_path, "x", encoding="utf-8") as f:
with open(tmp_path, "w", encoding="utf-8") as f:
metadata_line = {
"_type": "metadata",
"key": session.key,
@@ -1264,14 +1226,11 @@ class JsonlSessionStore:
raise
finally:
os.close(fd)
finally:
except BaseException:
tmp_path.unlink(missing_ok=True)
raise
def delete(self, key: str) -> bool:
with self._session_files_lock:
return self._delete_unlocked(key)
def _delete_unlocked(self, key: str) -> bool:
paths = [
self.get_session_path(key),
self.get_legacy_lossy_path(key),
@@ -1289,10 +1248,6 @@ class JsonlSessionStore:
return deleted
def read(self, key: str) -> SessionPayload | None:
with self._session_files_lock:
return self._read_unlocked(key)
def _read_unlocked(self, key: str) -> SessionPayload | None:
path = self.get_session_path(key)
if not path.exists():
return None
@@ -1342,17 +1297,13 @@ class JsonlSessionStore:
}
except _SESSION_DATA_ERRORS as e:
logger.warning("Failed to read session {}: {}", key, e)
repaired = self._repair_unlocked(key, path=path)
repaired = self.repair(key, path=path)
if repaired is not None:
logger.info("Recovered read-only session view {} from corrupt file", key)
return self.session_payload(repaired)
return None
def read_metadata(self, key: str) -> SessionMetadataPayload | None:
with self._session_files_lock:
return self._read_metadata_unlocked(key)
def _read_metadata_unlocked(self, key: str) -> SessionMetadataPayload | None:
path = self.get_session_path(key)
if not path.exists():
return None
@@ -1387,7 +1338,7 @@ class JsonlSessionStore:
return None
except _SESSION_DATA_ERRORS as e:
logger.warning("Failed to read session metadata {}: {}", key, e)
repaired = self._repair_unlocked(key, path=path)
repaired = self.repair(key, path=path)
if repaired is not None:
logger.info("Recovered read-only session metadata {} from corrupt file", key)
return {
@@ -1399,10 +1350,6 @@ class JsonlSessionStore:
return None
def list_sessions(self) -> list[SessionInfo]:
with self._session_files_lock:
return self._list_sessions_unlocked()
def _list_sessions_unlocked(self) -> list[SessionInfo]:
sessions: list[SessionInfo] = []
for path in self.sessions_dir.glob("*.jsonl"):
@@ -1480,7 +1427,7 @@ class JsonlSessionStore:
except FileNotFoundError:
continue
except _SESSION_DATA_ERRORS:
repaired = self._repair_unlocked(storage_key, path=path)
repaired = self.repair(storage_key, path=path)
if repaired is not None:
sessions.append(
{
@@ -1589,12 +1536,6 @@ class SessionManager:
"""Legacy global session path (~/.nanobot/sessions/)."""
return self._jsonl_store.get_legacy_session_path(key)
@contextmanager
def locked_session_files(self) -> Generator[Path, None, None]:
"""Guard exceptional direct access to canonical JSONL files."""
with self._jsonl_store.locked_session_files() as sessions_dir:
yield sessions_dir
def get_or_create(self, key: str) -> Session:
"""
Get an existing session or create a new one.
-211
View File
@@ -1,211 +0,0 @@
"""Native directory picker used by a locally hosted WebUI."""
from __future__ import annotations
import asyncio
import os
import shutil
import sys
from contextlib import suppress
from dataclasses import dataclass
from pathlib import Path
_PICKER_TIMEOUT_SECONDS = 300
_COMMON_ENV_KEYS = (
"HOME",
"LANG",
"LANGUAGE",
"LC_ALL",
"LC_CTYPE",
"LC_MESSAGES",
"LOGNAME",
"PATH",
"SHELL",
"TMPDIR",
"USER",
)
_LINUX_GUI_ENV_KEYS = (
"DBUS_SESSION_BUS_ADDRESS",
"DESKTOP_SESSION",
"DISPLAY",
"WAYLAND_DISPLAY",
"XAUTHORITY",
"XDG_CURRENT_DESKTOP",
"XDG_RUNTIME_DIR",
)
_MACOS_GUI_ENV_KEYS = ("SECURITYSESSIONID", "__CF_USER_TEXT_ENCODING")
_WINDOWS_GUI_ENV_KEYS = (
"APPDATA",
"COMSPEC",
"HOMEDRIVE",
"HOMEPATH",
"LOCALAPPDATA",
"PATHEXT",
"ProgramData",
"ProgramFiles",
"ProgramFiles(x86)",
"ProgramW6432",
"SESSIONNAME",
"SYSTEMROOT",
"TEMP",
"TMP",
"USERDOMAIN",
"USERNAME",
"USERPROFILE",
)
class NativeFolderPickerError(RuntimeError):
"""Raised when an available native folder picker cannot complete."""
@dataclass(frozen=True)
class _PickerCommand:
argv: tuple[str, ...]
cancel_codes: frozenset[int]
cancel_markers: tuple[str, ...] = ()
def _picker_command() -> _PickerCommand | None:
if sys.platform == "darwin":
executable = shutil.which("osascript")
if executable is None:
return None
return _PickerCommand(
argv=(
executable,
"-e",
'set selectedFolder to choose folder with prompt "Select Workspace Directory"',
"-e",
"POSIX path of selectedFolder",
),
cancel_codes=frozenset({1}),
cancel_markers=("user canceled", "(-128)"),
)
if sys.platform == "win32":
executable = shutil.which("powershell.exe") or shutil.which("powershell")
if executable is None:
return None
script = (
"Add-Type -AssemblyName System.Windows.Forms;"
"$dialog=New-Object System.Windows.Forms.FolderBrowserDialog;"
"$dialog.Description='Select Workspace Directory';"
"$dialog.ShowNewFolderButton=$true;"
"if($dialog.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK){"
"[Console]::OutputEncoding=[System.Text.UTF8Encoding]::new();"
"[Console]::Out.Write($dialog.SelectedPath)}"
)
return _PickerCommand(
argv=(
executable,
"-NoProfile",
"-NonInteractive",
"-STA",
"-Command",
script,
),
cancel_codes=frozenset(),
)
if sys.platform.startswith("linux"):
if not (os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY")):
return None
zenity = shutil.which("zenity")
if zenity is not None:
return _PickerCommand(
argv=(
zenity,
"--file-selection",
"--directory",
"--title=Select Workspace Directory",
),
cancel_codes=frozenset({1}),
)
kdialog = shutil.which("kdialog")
if kdialog is not None:
return _PickerCommand(
argv=(kdialog, "--getexistingdirectory", str(Path.home())),
cancel_codes=frozenset({1}),
)
return None
def native_folder_picker_available() -> bool:
"""Return whether this host can display a native directory picker."""
return _picker_command() is not None
def _picker_environment() -> dict[str, str]:
"""Pass only host UI/runtime variables, never provider or gateway secrets."""
keys: list[str] = list(_COMMON_ENV_KEYS)
if sys.platform == "darwin":
keys.extend(_MACOS_GUI_ENV_KEYS)
elif sys.platform == "win32":
keys.extend(_WINDOWS_GUI_ENV_KEYS)
elif sys.platform.startswith("linux"):
keys.extend(_LINUX_GUI_ENV_KEYS)
return {
key: value
for key in keys
if (value := os.environ.get(key)) is not None
}
async def _stop_process(process: asyncio.subprocess.Process) -> None:
if process.returncode is not None:
return
with suppress(ProcessLookupError):
process.terminate()
try:
await asyncio.wait_for(process.wait(), timeout=2)
except TimeoutError:
with suppress(ProcessLookupError):
process.kill()
await process.wait()
async def pick_native_folder() -> str | None:
"""Open the platform directory picker and return an existing absolute path."""
command = _picker_command()
if command is None:
raise NativeFolderPickerError("native folder picker is unavailable on this host")
try:
process = await asyncio.create_subprocess_exec(
*command.argv,
env=_picker_environment(),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
except OSError as exc:
raise NativeFolderPickerError("native folder picker failed to start") from exc
try:
stdout, stderr = await asyncio.wait_for(
process.communicate(),
timeout=_PICKER_TIMEOUT_SECONDS,
)
except asyncio.CancelledError:
await _stop_process(process)
raise
except TimeoutError as exc:
await _stop_process(process)
raise NativeFolderPickerError("native folder picker timed out") from exc
error_text = stderr.decode("utf-8", errors="replace").strip()
normalized_error = error_text.lower()
if process.returncode != 0:
if process.returncode in command.cancel_codes and (
not command.cancel_markers
or any(marker in normalized_error for marker in command.cancel_markers)
):
return None
raise NativeFolderPickerError("native folder picker failed")
selected = stdout.decode("utf-8", errors="replace").strip()
if not selected:
return None
path = Path(selected).expanduser()
if not path.is_absolute() or not path.is_dir():
raise NativeFolderPickerError("native folder picker returned an invalid directory")
return str(path)
+4 -6
View File
@@ -10,7 +10,6 @@ from __future__ import annotations
import json
import os
import re
import secrets
from datetime import datetime
from pathlib import Path
from typing import Any, cast
@@ -57,7 +56,6 @@ _TRANSCRIPT_NON_ANSWER_KINDS = {"progress", "reasoning", "tool_hint"}
def list_webui_sessions(session_manager: SessionManager) -> list[dict[str, Any]]:
"""Return session rows for the WebUI sidebar, backed by a rebuildable cache."""
with session_manager.locked_session_files():
rows, changed = _reconcile_index(session_manager)
if changed:
try:
@@ -171,14 +169,14 @@ def _read_index_rows(sessions_dir: Path) -> list[dict[str, Any]] | None:
def _write_index_rows(sessions_dir: Path, rows: list[dict[str, Any]]) -> None:
path = _index_path(sessions_dir)
tmp_path = path.with_name(f"{path.name}.{secrets.token_hex(8)}.tmp")
tmp_path = path.with_suffix(".json.tmp")
data = {"version": _INDEX_VERSION, "sessions": rows}
try:
with open(tmp_path, "x", encoding="utf-8") as file:
file.write(json.dumps(data, ensure_ascii=False) + "\n")
tmp_path.write_text(json.dumps(data, ensure_ascii=False) + "\n", encoding="utf-8")
os.replace(tmp_path, path)
finally:
except BaseException:
tmp_path.unlink(missing_ok=True)
raise
def _file_signature(path: Path) -> dict[str, int]:
+1 -9
View File
@@ -149,7 +149,6 @@ def workspaces_payload(
default_workspace: Path,
default_restrict_to_workspace: bool,
controls_available: bool,
folder_picker_available: bool = False,
) -> dict[str, Any]:
default_access_mode = read_webui_default_access_mode()
default_scope = (
@@ -168,7 +167,6 @@ def workspaces_payload(
"controls": {
"can_change_project": controls_available,
"can_use_full_access": controls_available,
"can_pick_folder": folder_picker_available,
},
}
@@ -243,17 +241,11 @@ class WebUIWorkspaceController:
cast(object, metadata_data.get(WORKSPACE_SCOPE_METADATA_KEY))
)
def payload(
self,
*,
controls_available: bool,
folder_picker_available: bool = False,
) -> dict[str, Any]:
def payload(self, *, controls_available: bool) -> dict[str, Any]:
return workspaces_payload(
default_workspace=self._default_workspace,
default_restrict_to_workspace=self._default_restrict_to_workspace,
controls_available=controls_available,
folder_picker_available=folder_picker_available,
)
def scope_from_envelope(
+1 -45
View File
@@ -62,7 +62,6 @@ from nanobot.webui.http_utils import (
from nanobot.webui.http_utils import (
is_localhost as _is_localhost,
)
from nanobot.webui.http_utils import is_loopback_host as _is_loopback_host
from nanobot.webui.http_utils import (
is_trusted_proxy_authenticated_request as _is_trusted_proxy_authenticated_request,
)
@@ -86,11 +85,6 @@ from nanobot.webui.http_utils import (
)
from nanobot.webui.ingress_policy import WebUIIngressPolicy
from nanobot.webui.media_gateway import WebUIMediaGateway
from nanobot.webui.native_folder_picker import (
NativeFolderPickerError,
native_folder_picker_available,
pick_native_folder,
)
from nanobot.webui.session_automations import (
all_automations_payload,
serialize_automation_jobs,
@@ -139,7 +133,6 @@ _WEBUI_MUTATION_PATHS = {
"skill.update": "/api/webui/skills/update",
"skill.delete": "/api/webui/skills/delete",
"sidebar.update": "/api/webui/sidebar-state/update",
"workspace.pick_folder": "/api/workspaces/pick-folder",
"settings.agent.update": "/api/settings/update",
"settings.model_configuration.create": "/api/settings/model-configurations/create",
"settings.model_configuration.update": "/api/settings/model-configurations/update",
@@ -336,7 +329,6 @@ class GatewayHTTPHandler:
)
self.skill_state_action = skill_state_action
self._skill_install_lock = asyncio.Lock()
self._folder_picker_lock = asyncio.Lock()
self.cron_service = cron_service
self.local_trigger_store = local_trigger_store
self.cron_pending_job_ids = cron_pending_job_ids
@@ -368,17 +360,6 @@ class GatewayHTTPHandler:
def workspace_controls_available(self, connection: Any) -> bool:
return self._runtime_surface == "native" or _is_localhost(connection)
def workspace_folder_picker_available(
self,
connection: Any,
request: WsRequest,
) -> bool:
return (
_is_loopback_host(self.config.host)
and _is_local_browser_request(connection, request.headers)
and native_folder_picker_available()
)
# -- Token management ---------------------------------------------------
def check_api_token(self, request: WsRequest) -> bool:
@@ -454,7 +435,6 @@ class GatewayHTTPHandler:
"/api/webui/skills/update",
"/api/webui/skills/delete",
"/api/webui/sidebar-state/update",
"/api/workspaces/pick-folder",
}
@staticmethod
@@ -1074,8 +1054,6 @@ class GatewayHTTPHandler:
return await self._handle_sessions_list(request)
if got == "/api/commands":
return self._handle_commands(request)
if got == "/api/workspaces/pick-folder":
return await self._handle_workspace_folder_picker(connection, request)
if got == "/api/workspaces":
return self._handle_workspaces(connection, request)
if got == "/api/webui/skills/search":
@@ -1111,32 +1089,10 @@ class GatewayHTTPHandler:
return _http_error(401, "Unauthorized")
return _http_json_response(
self.workspaces.payload(
controls_available=self.workspace_controls_available(connection),
folder_picker_available=self.workspace_folder_picker_available(
connection,
request,
),
controls_available=self.workspace_controls_available(connection)
)
)
async def _handle_workspace_folder_picker(
self,
connection: Any,
request: WsRequest,
) -> Response:
if not self.check_api_token(request):
return _http_error(401, "Unauthorized")
if not self.workspace_folder_picker_available(connection, request):
return _http_error(403, "native folder picker is unavailable for this connection")
if self._folder_picker_lock.locked():
return _http_error(409, "native folder picker is already open")
try:
async with self._folder_picker_lock:
path = await pick_native_folder()
except NativeFolderPickerError as exc:
return _http_error(503, str(exc))
return _http_json_response({"path": path})
def _handle_webui_skills(self, request: WsRequest) -> Response:
if not self.check_api_token(request):
return _http_error(401, "Unauthorized")
+13 -24
View File
@@ -29,11 +29,8 @@ class TestPruneDreamSessions:
import os
import time
manager = SessionManager(
tmp_path / "workspace",
sessions_root=tmp_path / "runtime",
)
sessions_dir = manager.sessions_dir
sessions_dir = tmp_path / "sessions"
sessions_dir.mkdir()
base_time = time.time() - 100
dream_paths = []
@@ -53,7 +50,7 @@ class TestPruneDreamSessions:
normal_path = sessions_dir / "telegram_123.jsonl"
normal_path.write_text('{"_type": "metadata"}\n', encoding="utf-8")
MemoryStore.prune_dream_sessions(manager, keep=10)
MemoryStore.prune_dream_sessions(sessions_dir, keep=10)
assert [path.exists() for path in dream_paths] == [False] * 5 + [True] * 10
assert normal_path.exists()
@@ -62,11 +59,8 @@ class TestPruneDreamSessions:
import os
import time
manager = SessionManager(
tmp_path / "workspace",
sessions_root=tmp_path / "runtime",
)
sessions_dir = manager.sessions_dir
sessions_dir = tmp_path / "sessions"
sessions_dir.mkdir()
base_time = time.time() - 100
current_paths = []
@@ -87,29 +81,24 @@ class TestPruneDreamSessions:
)
os.utime(legacy_path, (base_time - 1, base_time - 1))
MemoryStore.prune_dream_sessions(manager, keep=1)
MemoryStore.prune_dream_sessions(sessions_dir, keep=1)
assert [path.exists() for path in current_paths] == [False, True]
assert legacy_path.exists()
def test_noop_when_under_limit(self, tmp_path):
manager = SessionManager(
tmp_path / "workspace",
sessions_root=tmp_path / "runtime",
)
sessions_dir = manager.sessions_dir
sessions_dir = tmp_path / "sessions"
sessions_dir.mkdir()
for i in range(3):
key = f"dream:20260528-{100000 + i:06d}"
path = sessions_dir / f"{SessionManager._storage_key(key)}.jsonl"
path.write_text("{}", encoding="utf-8")
MemoryStore.prune_dream_sessions(manager, keep=10)
MemoryStore.prune_dream_sessions(sessions_dir, keep=10)
assert len(list(sessions_dir.glob("*.jsonl"))) == 3
def test_empty_dir_noop(self, tmp_path):
manager = SessionManager(
tmp_path / "workspace",
sessions_root=tmp_path / "runtime",
)
MemoryStore.prune_dream_sessions(manager, keep=10)
assert list(manager.sessions_dir.glob("*.jsonl")) == []
sessions_dir = tmp_path / "sessions"
sessions_dir.mkdir()
MemoryStore.prune_dream_sessions(sessions_dir, keep=10)
assert list(sessions_dir.iterdir()) == []
+8 -39
View File
@@ -4,9 +4,6 @@ import json
from datetime import datetime
from pathlib import Path
import pytest
from filelock import Timeout
from nanobot.providers.base import ProviderConversationState
from nanobot.session.manager import Session, SessionManager
@@ -40,23 +37,14 @@ class TestAtomicSave:
tmp_files = list(mgr.sessions_dir.glob("*.tmp"))
assert tmp_files == []
def test_unique_tmp_file_cleaned_up_on_write_failure(
self,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
):
def test_tmp_file_cleaned_up_on_write_failure(self, tmp_path: Path):
mgr = SessionManager(tmp_path)
session = Session(key="test:fail")
path = mgr._get_session_path("test:fail")
stale_shared_tmp = path.with_suffix(".jsonl.tmp")
unique_tmp = path.with_name(f".{path.name}.save-failure.tmp")
tmp_path_file = path.with_suffix(".jsonl.tmp")
path.parent.mkdir(parents=True, exist_ok=True)
stale_shared_tmp.write_text("stale", encoding="utf-8")
monkeypatch.setattr(
"nanobot.session.manager.secrets.token_hex",
lambda _length: "save-failure",
)
tmp_path_file.write_text("stale")
class BadMessage:
def __init__(self, data):
@@ -76,17 +64,13 @@ class TestAtomicSave:
]
import unittest.mock
with (
unittest.mock.patch(
"nanobot.session.manager.json.dumps",
side_effect=failing_dumps,
),
pytest.raises(OSError, match="simulated disk full"),
):
with unittest.mock.patch("nanobot.session.manager.json.dumps", side_effect=failing_dumps):
try:
mgr.save(session)
except OSError:
pass
assert not unique_tmp.exists()
assert stale_shared_tmp.read_text(encoding="utf-8") == "stale"
assert not tmp_path_file.exists()
def test_overwrite_preserves_latest_data(self, tmp_path: Path):
mgr = SessionManager(tmp_path)
@@ -118,21 +102,6 @@ class TestAtomicSave:
for i in range(5):
assert loaded.messages[i]["content"] == f"msg{i}"
def test_managers_for_same_directory_coordinate_saves(self, tmp_path: Path):
workspace = tmp_path / "workspace"
sessions_root = tmp_path / "runtime"
owner = SessionManager(workspace, sessions_root=sessions_root)
peer = SessionManager(workspace, sessions_root=sessions_root)
assert owner.sessions_dir == peer.sessions_dir
session = Session(key="test:peer-manager")
peer._jsonl_store._session_files_lock.timeout = 0
with owner.locked_session_files(), pytest.raises(Timeout):
peer.save(session)
peer.save(session)
assert peer._get_session_path(session.key).is_file()
def test_provider_state_round_trips_in_private_record_only(self, tmp_path: Path):
mgr = SessionManager(tmp_path)
secret = "encrypted-reasoning-blob"
@@ -1,5 +1,3 @@
import pytest
from nanobot.providers.base import ProviderConversationState
from nanobot.runtime_context import (
RUNTIME_CONTEXT_HISTORY_META,
@@ -983,36 +981,6 @@ def test_enforce_file_cap_correct_archive_with_last_consolidated_in_else_branch(
)
def test_enforce_file_cap_restores_session_when_archive_fails():
state = ProviderConversationState(
kind="openai_responses",
provider="openai:test",
model="test-model",
version=1,
payload={"items": []},
)
session = Session(key="test:archive-failure", provider_state=state)
for i in range(8):
session.messages.append({"role": "user", "content": f"msg{i}"})
original_messages = session.messages
original_updated_at = session.updated_at
session.last_consolidated = 2
def fail_archive(_messages):
raise RuntimeError("history unavailable")
with pytest.raises(RuntimeError, match="history unavailable"):
session.enforce_file_cap(on_archive=fail_archive, limit=4)
assert session.messages is original_messages
assert [message["content"] for message in session.messages] == [
f"msg{i}" for i in range(8)
]
assert session.last_consolidated == 2
assert session.provider_state is state
assert session.updated_at == original_updated_at
def test_retain_recent_legal_suffix_last_consolidated_correct_in_else_branch():
"""last_consolidated after retain_recent_legal_suffix should reflect how
many retained messages were inside the old consolidated prefix."""
+12 -12
View File
@@ -16,7 +16,6 @@ from nanobot.command.builtin import (
cmd_dream_restore,
)
from nanobot.command.router import CommandContext
from nanobot.session.manager import SessionManager
from nanobot.utils.gitstore import CommitInfo
@@ -108,13 +107,6 @@ class _FakeBus:
self.outbound.append(message)
def _make_sessions(tmp_path) -> SessionManager:
return SessionManager(
tmp_path / "workspace",
sessions_root=tmp_path / "runtime",
)
def _make_ctx(raw: str, git: _FakeGit, *, args: str = "", last_dream_cursor: int = 1) -> CommandContext:
msg = InboundMessage(channel="cli", sender_id="u1", chat_id="direct", content=raw)
store = _FakeStore(git, last_dream_cursor=last_dream_cursor)
@@ -126,10 +118,12 @@ def _make_dream_ctx(tmp_path) -> tuple[CommandContext, _FakeBus]:
msg = InboundMessage(channel="cli", sender_id="u1", chat_id="direct", content="/dream")
store = _FakeStore(_FakeGit(initialized=False), dream_prompt_result=None)
bus = _FakeBus()
sessions_dir = tmp_path / "sessions"
sessions_dir.mkdir()
loop = SimpleNamespace(
bus=bus,
context=SimpleNamespace(memory=store, timezone="UTC"),
sessions=_make_sessions(tmp_path),
sessions=SimpleNamespace(sessions_dir=sessions_dir),
)
ctx = CommandContext(msg=msg, session=None, key=msg.session_key, raw="/dream", args="", loop=loop)
return ctx, bus
@@ -175,11 +169,13 @@ async def test_dream_internal_run_silences_progress(tmp_path) -> None:
metadata={"_stop_reason": "completed"},
)
sessions_dir = tmp_path / "sessions"
sessions_dir.mkdir()
dream_runtime = object()
loop = SimpleNamespace(
bus=bus,
context=SimpleNamespace(memory=store, timezone="UTC"),
sessions=_make_sessions(tmp_path),
sessions=SimpleNamespace(sessions_dir=sessions_dir),
process_direct=process_direct,
dream_runtime=lambda: dream_runtime,
)
@@ -228,10 +224,12 @@ def _build_runnable_dream(
)
bus = _FakeBus()
sessions_dir = tmp_path / "sessions"
sessions_dir.mkdir()
loop = SimpleNamespace(
bus=bus,
context=SimpleNamespace(memory=store, timezone="UTC"),
sessions=_make_sessions(tmp_path),
sessions=SimpleNamespace(sessions_dir=sessions_dir),
process_direct=process_direct,
dream_runtime=lambda: None,
)
@@ -313,10 +311,12 @@ async def test_dream_noop_batch_unlocks_following_history(tmp_path) -> None:
msg = InboundMessage(channel="cli", sender_id="u1", chat_id="direct", content="/dream")
bus = _FakeBus()
sessions_dir = tmp_path / "sessions"
sessions_dir.mkdir()
loop = SimpleNamespace(
bus=bus,
context=SimpleNamespace(memory=store, timezone="UTC"),
sessions=_make_sessions(tmp_path),
sessions=SimpleNamespace(sessions_dir=sessions_dir),
process_direct=process_direct,
dream_runtime=lambda: None,
)
+3 -5
View File
@@ -6,7 +6,7 @@ import errno
import os
import sys
from pathlib import Path
from unittest.mock import call, patch
from unittest.mock import patch
import pytest
@@ -65,7 +65,6 @@ class TestSaveFsync:
session.add_message("user", "hello")
directory_fd = 987654
with (
manager.locked_session_files(),
patch("nanobot.session.manager.os.open", return_value=directory_fd) as open_dir,
patch(
"nanobot.session.manager.os.fsync",
@@ -77,7 +76,7 @@ class TestSaveFsync:
assert manager._get_session_path(session.key).exists()
open_dir.assert_called_once_with(str(manager.sessions_dir), os.O_RDONLY)
assert close_dir.call_args_list.count(call(directory_fd)) == 1
close_dir.assert_called_once_with(directory_fd)
def test_save_propagates_other_directory_fsync_errors(
self, manager: SessionManager
@@ -86,7 +85,6 @@ class TestSaveFsync:
session = manager.get_or_create("test:directory-fsync-io-error")
directory_fd = 987654
with (
manager.locked_session_files(),
patch("nanobot.session.manager.os.open", return_value=directory_fd),
patch(
"nanobot.session.manager.os.fsync",
@@ -97,7 +95,7 @@ class TestSaveFsync:
):
manager.save(session, fsync=True)
assert close_dir.call_args_list.count(call(directory_fd)) == 1
close_dir.assert_called_once_with(directory_fd)
class TestFlushAll:
-30
View File
@@ -1,7 +1,5 @@
from unittest.mock import MagicMock
import pytest
import nanobot.session as session_api
from nanobot.session import Session, SessionManager
from nanobot.session.manager import FILE_MAX_MESSAGES, SessionStore
@@ -79,31 +77,3 @@ def test_manager_applies_file_cap_before_store_save(tmp_path) -> None:
assert len(session.messages) == FILE_MAX_MESSAGES
archiver.assert_called_once()
store.save.assert_called_once_with(session, fsync=False)
def test_manager_retries_file_cap_archive_after_failure(tmp_path) -> None:
store = MagicMock(spec=SessionStore)
archiver = MagicMock(side_effect=[RuntimeError("history unavailable"), None])
manager = SessionManager(tmp_path, store=store)
manager.set_file_cap_archiver(archiver)
session = Session(
key="cli:retry-large",
messages=[
{"role": "user", "content": str(index)}
for index in range(FILE_MAX_MESSAGES + 1)
],
)
with pytest.raises(RuntimeError, match="history unavailable"):
manager.save(session)
assert len(session.messages) == FILE_MAX_MESSAGES + 1
store.save.assert_not_called()
manager.save(session)
assert len(session.messages) == FILE_MAX_MESSAGES
assert archiver.call_count == 2
assert archiver.call_args_list[0].args[0][0]["content"] == "0"
assert archiver.call_args_list[1].args[0][0]["content"] == "0"
store.save.assert_called_once_with(session, fsync=False)
-112
View File
@@ -1,112 +0,0 @@
from __future__ import annotations
import sys
from pathlib import Path
import pytest
from nanobot.webui import native_folder_picker as picker
def _picker_command(tmp_path: Path, body: str) -> picker._PickerCommand:
script = tmp_path / "picker.py"
script.write_text(f"{body}\n", encoding="utf-8")
return picker._PickerCommand((sys.executable, str(script)), frozenset({1}))
@pytest.mark.asyncio
async def test_pick_native_folder_returns_selected_directory(tmp_path, monkeypatch) -> None:
selected = tmp_path / "project"
selected.mkdir()
command = _picker_command(tmp_path, f"print({str(selected)!r}, end='')")
monkeypatch.setattr(
picker,
"_picker_command",
lambda: command,
)
assert await picker.pick_native_folder() == str(selected)
@pytest.mark.asyncio
async def test_pick_native_folder_uses_secret_free_environment(tmp_path, monkeypatch) -> None:
selected = tmp_path / "project"
selected.mkdir()
command = _picker_command(tmp_path, f"print({str(selected)!r}, end='')")
monkeypatch.setattr(picker, "_picker_command", lambda: command)
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("OPENAI_API_KEY", "must-not-reach-picker")
original_spawn = picker.asyncio.create_subprocess_exec
captured_env: dict[str, str] = {}
async def capture_spawn(*args, **kwargs):
captured_env.update(kwargs["env"])
return await original_spawn(*args, **kwargs)
monkeypatch.setattr(picker.asyncio, "create_subprocess_exec", capture_spawn)
assert await picker.pick_native_folder() == str(selected)
assert captured_env["HOME"] == str(tmp_path)
assert "OPENAI_API_KEY" not in captured_env
def test_picker_environment_preserves_linux_display_context(monkeypatch) -> None:
monkeypatch.setattr(picker.sys, "platform", "linux")
monkeypatch.setenv("DISPLAY", ":42")
monkeypatch.setenv("DBUS_SESSION_BUS_ADDRESS", "unix:path=/run/user/test/bus")
monkeypatch.setenv("ANTHROPIC_API_KEY", "must-not-reach-picker")
environment = picker._picker_environment()
assert environment["DISPLAY"] == ":42"
assert environment["DBUS_SESSION_BUS_ADDRESS"] == "unix:path=/run/user/test/bus"
assert "ANTHROPIC_API_KEY" not in environment
@pytest.mark.asyncio
async def test_pick_native_folder_maps_dialog_cancel_to_none(tmp_path, monkeypatch) -> None:
command = _picker_command(tmp_path, "raise SystemExit(1)")
monkeypatch.setattr(
picker,
"_picker_command",
lambda: command,
)
assert await picker.pick_native_folder() is None
@pytest.mark.asyncio
async def test_pick_native_folder_rejects_non_directory_result(tmp_path, monkeypatch) -> None:
missing = tmp_path / "missing"
command = _picker_command(tmp_path, f"print({str(missing)!r}, end='')")
monkeypatch.setattr(
picker,
"_picker_command",
lambda: command,
)
with pytest.raises(picker.NativeFolderPickerError, match="invalid directory"):
await picker.pick_native_folder()
@pytest.mark.asyncio
async def test_pick_native_folder_reports_unavailable(monkeypatch) -> None:
monkeypatch.setattr(picker, "_picker_command", lambda: None)
assert picker.native_folder_picker_available() is False
with pytest.raises(picker.NativeFolderPickerError, match="unavailable"):
await picker.pick_native_folder()
@pytest.mark.asyncio
async def test_pick_native_folder_wraps_process_start_failure(tmp_path, monkeypatch) -> None:
command = _picker_command(tmp_path, "raise AssertionError('not started')")
monkeypatch.setattr(picker, "_picker_command", lambda: command)
async def fail_spawn(*args, **kwargs):
raise OSError("executable disappeared")
monkeypatch.setattr(picker.asyncio, "create_subprocess_exec", fail_spawn)
with pytest.raises(picker.NativeFolderPickerError, match="failed to start"):
await picker.pick_native_folder()
-17
View File
@@ -72,7 +72,6 @@ def test_workspace_payload_is_config_data_dir_scoped(tmp_path, monkeypatch) -> N
assert payload["default_scope"]["access_mode"] == "full"
assert payload["default_access_mode"] == "default"
assert payload["controls"]["can_change_project"] is True
assert payload["controls"]["can_pick_folder"] is False
def test_workspace_payload_hides_mutable_state_when_controls_unavailable(
@@ -92,22 +91,6 @@ def test_workspace_payload_hides_mutable_state_when_controls_unavailable(
assert payload["default_scope"]["project_path"] == str(default.resolve())
assert payload["controls"]["can_change_project"] is False
assert payload["controls"]["can_use_full_access"] is False
assert payload["controls"]["can_pick_folder"] is False
def test_workspace_payload_advertises_native_folder_picker(tmp_path, monkeypatch) -> None:
monkeypatch.setattr("nanobot.webui.workspaces.get_webui_dir", lambda: tmp_path / "webui")
default = tmp_path / "default"
default.mkdir()
payload = workspaces_payload(
default_workspace=default,
default_restrict_to_workspace=False,
controls_available=True,
folder_picker_available=True,
)
assert payload["controls"]["can_pick_folder"] is True
def test_workspace_payload_uses_webui_default_access_mode(tmp_path, monkeypatch) -> None:
-98
View File
@@ -3,8 +3,6 @@ from __future__ import annotations
import io
import json
import os
import threading
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime
from pathlib import Path
@@ -52,20 +50,6 @@ def test_webui_session_list_reuses_valid_index_without_scanning_files(
assert rows[0]["model_preset"] == "fast"
def test_webui_session_index_uses_unique_temp_file(tmp_path: Path) -> None:
manager = SessionManager(tmp_path)
session = manager.get_or_create("websocket:unique-index-temp")
session.add_message("user", "hello")
manager.save(session)
stale_shared_tmp = manager.sessions_dir / ".webui_session_index.json.tmp"
stale_shared_tmp.write_text("stale", encoding="utf-8")
assert list_webui_sessions(manager)[0]["preview"] == "hello"
assert stale_shared_tmp.read_text(encoding="utf-8") == "stale"
assert not list(manager.sessions_dir.glob(".webui_session_index.json.*.tmp"))
def test_webui_session_list_indexes_workspace_scope_and_preserves_null(
tmp_path: Path,
) -> None:
@@ -149,88 +133,6 @@ def test_webui_session_list_does_not_cache_old_snapshot_with_new_signature(
assert session_list_index.indexed_workspace_scope(second)[1]["access_mode"] == "restricted"
def test_webui_session_scan_does_not_overlap_session_save(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
manager = SessionManager(
tmp_path / "workspace",
sessions_root=tmp_path / "runtime",
)
session = manager.get_or_create("websocket:windows-reader")
session.add_message("user", "before")
manager.save(session)
session_path = manager._get_session_path(session.key)
session.messages[0]["content"] = "after"
reader_open = threading.Event()
release_reader = threading.Event()
save_started = threading.Event()
save_lock_attempted = threading.Event()
write_entered = threading.Event()
original_open = open
store = manager._jsonl_store
original_acquire = store._session_files_lock.acquire
original_save_unlocked = store._save_unlocked
class BlockingReader:
def __init__(self, file):
self.file = file
def __enter__(self):
entered = self.file.__enter__()
reader_open.set()
if not release_reader.wait(5):
raise AssertionError("timed out waiting to release the session reader")
return entered
def __exit__(self, *args):
try:
return self.file.__exit__(*args)
finally:
reader_open.clear()
def blocking_open(path, *args, **kwargs):
file = original_open(path, *args, **kwargs)
if Path(path) == session_path:
return BlockingReader(file)
return file
def observed_acquire(*args, **kwargs):
if save_started.is_set():
save_lock_attempted.set()
return original_acquire(*args, **kwargs)
def observed_save_unlocked(session, *, fsync=False):
write_entered.set()
assert not reader_open.is_set(), "save entered while the canonical file was open"
return original_save_unlocked(session, fsync=fsync)
monkeypatch.setattr(session_list_index, "open", blocking_open, raising=False)
monkeypatch.setattr(store._session_files_lock, "acquire", observed_acquire)
monkeypatch.setattr(store, "_save_unlocked", observed_save_unlocked)
def save_session() -> None:
save_started.set()
manager.save(session)
with ThreadPoolExecutor(max_workers=2) as executor:
list_future = executor.submit(list_webui_sessions, manager)
try:
assert reader_open.wait(5)
save_future = executor.submit(save_session)
assert save_lock_attempted.wait(5)
assert not write_entered.is_set()
finally:
release_reader.set()
assert list_future.result(timeout=5)[0]["preview"] == "before"
save_future.result(timeout=5)
assert write_entered.is_set()
assert list_webui_sessions(manager)[0]["preview"] == "after"
def test_webui_session_list_rejects_invalid_internal_model_preset_metadata(
tmp_path: Path,
) -> None:
+6 -4
View File
@@ -563,7 +563,7 @@ function PairingCodePopup({
aria-label={t("app.pairing.title", { defaultValue: "Pair a chat user" })}
className={cn(
"fixed right-4 top-[calc(0.75rem+env(safe-area-inset-top))] z-[70]",
"w-[min(calc(100vw-2rem),24rem)] rounded-modal",
"w-[min(calc(100vw-2rem),24rem)] rounded-[24px]",
floatingSurfaceElevationClassName,
"p-4",
"animate-in fade-in-0 slide-in-from-top-2 duration-200",
@@ -2718,6 +2718,7 @@ function Shell({
addPaneDisabled={creatingPane || activePaneLimitReached}
addPaneDisabledLabel={activePaneLimitReached
? t("workbench.paneLimit", {
defaultValue: "Maximum {{count}} panes",
count: MAX_WORKBENCH_PANES,
})
: undefined}
@@ -2811,6 +2812,7 @@ function Shell({
composerPortalTarget={context.composerPortalTarget}
composerActive={context.active}
composerInputAriaLabel={t("workbench.composerAria", {
defaultValue: "Message {{title}}",
title: pane.title,
})}
emptyComposerVariant="thread"
@@ -2890,9 +2892,9 @@ function Shell({
<RenameChatDialog
open
title={pendingTabRename.label}
dialogTitle={t("workbench.renameGroupTitle")}
description={t("workbench.renameGroupDescription")}
placeholder={t("workbench.renameGroupPlaceholder")}
dialogTitle={t("workbench.renameTabTitle")}
description={t("workbench.renameTabDescription")}
placeholder={t("workbench.renameTabPlaceholder")}
onCancel={() => setPendingTabRename(null)}
onConfirm={onConfirmTabRename}
/>
+3 -3
View File
@@ -94,7 +94,7 @@ export function AttachmentTile({ attachment, className, inline = false, variant
title={attachment.name ?? undefined}
aria-label={label}
className={cn(
"flex max-w-[18rem] items-center gap-2 rounded-control",
"flex max-w-[18rem] items-center gap-2 rounded-[14px]",
"border border-border/60 bg-muted/40 px-3 py-2 text-xs text-muted-foreground",
"transition-colors hover:bg-muted/55 hover:text-foreground",
variant === "compact" && "max-w-[14rem] rounded-xl px-2.5 py-1.5 text-[11.5px]",
@@ -109,7 +109,7 @@ export function AttachmentTile({ attachment, className, inline = false, variant
return (
<div
className={cn(
"flex max-w-[18rem] items-center gap-2 rounded-control",
"flex max-w-[18rem] items-center gap-2 rounded-[14px]",
"border border-border/60 bg-muted/35 px-3 py-2 text-xs text-muted-foreground",
variant === "compact" && "max-w-[14rem] rounded-xl px-2.5 py-1.5 text-[11.5px]",
className,
@@ -139,7 +139,7 @@ function AttachmentFrame({
variant?: "default" | "compact";
}) {
const frameClassName = cn(
"not-prose my-3 block w-fit max-w-full overflow-hidden rounded-control",
"not-prose my-3 block w-fit max-w-full overflow-hidden rounded-[14px]",
"border border-border/60 bg-muted/40",
attachment.kind === "image" && "bg-background/85",
attachment.kind === "video" ? "w-[min(100%,32rem)]" : "",
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -203,7 +203,7 @@ export function CliAppMentionToken({
<span
data-testid={`${testIdPrefix}-cli-mention-logo-${app.name}`}
className={cn(
"absolute left-1/2 top-1/2 grid place-items-center overflow-hidden rounded-mark",
"absolute left-1/2 top-1/2 grid place-items-center overflow-hidden rounded-[3px]",
"-translate-x-1/2 -translate-y-1/2",
isHero ? "h-[0.74em] w-[0.74em]" : "h-[0.72em] w-[0.72em]",
)}
@@ -260,7 +260,7 @@ export function McpPresetMentionToken({
<span
data-testid={`${testIdPrefix}-mcp-mention-logo-${preset.name}`}
className={cn(
"absolute left-1/2 top-1/2 grid place-items-center overflow-hidden rounded-mark",
"absolute left-1/2 top-1/2 grid place-items-center overflow-hidden rounded-[3px]",
"-translate-x-1/2 -translate-y-1/2",
isHero ? "h-[0.74em] w-[0.74em]" : "h-[0.72em] w-[0.72em]",
)}
+1 -1
View File
@@ -202,7 +202,7 @@ export function CodeBlock({
<div
className={cn(
"not-prose relative overflow-hidden",
hasChrome && "rounded-floating bg-secondary/70",
hasChrome && "rounded-[18px] bg-secondary/70",
className,
)}
data-language={language || t("code.fallbackLanguage")}
+8 -8
View File
@@ -44,11 +44,11 @@ export function DeleteConfirm({
className="w-[min(calc(100vw-2rem),24rem)] gap-0 p-5 text-center"
>
<AlertDialogHeader className="items-center space-y-0 text-center">
<Trash2
className="mb-4 h-6 w-6 text-destructive"
strokeWidth={1.8}
aria-hidden
/>
<div className="mb-5 grid h-16 w-16 place-items-center rounded-full bg-destructive/10 text-destructive">
<div className="grid h-9 w-9 place-items-center rounded-full border border-destructive/20 bg-destructive/5">
<Trash2 className="h-5 w-5" strokeWidth={2.4} aria-hidden />
</div>
</div>
<AlertDialogTitle className="text-center text-[20px] font-semibold leading-tight tracking-[-0.02em] text-foreground">
{multiple
? t("deleteConfirm.titleMany", {
@@ -96,16 +96,16 @@ export function DeleteConfirm({
</div>
) : null}
</AlertDialogHeader>
<AlertDialogFooter className="mt-6 !grid grid-cols-1 gap-2 sm:grid-cols-2">
<AlertDialogFooter className="mt-7 !grid grid-cols-1 gap-3 space-x-0 sm:grid-cols-2 sm:space-x-0">
<AlertDialogCancel
onClick={onCancel}
className="mt-0 w-full min-w-0"
className="mt-0 h-11 w-full min-w-0 rounded-full border-0 bg-muted/70 px-5 text-[15px] font-semibold text-foreground shadow-none hover:bg-muted"
>
{t("deleteConfirm.cancel")}
</AlertDialogCancel>
<AlertDialogAction
onClick={onConfirm}
className="w-full min-w-0 !whitespace-normal bg-destructive text-center text-destructive-foreground hover:bg-destructive/90"
className="h-11 w-full min-w-0 !whitespace-normal rounded-full bg-destructive px-5 text-center text-[15px] font-semibold text-destructive-foreground shadow-none hover:bg-destructive/90"
>
{hasAutomations
? t("deleteConfirm.confirmWithAutomations")
+1 -1
View File
@@ -183,7 +183,7 @@ export function FilePreviewPanel({
) : null}
<span
className={cn(
"min-w-0 truncate rounded-mark px-1 py-0.5",
"min-w-0 truncate rounded-[4px] px-1 py-0.5",
isLast
? "font-medium text-foreground"
: "max-w-[26vw] shrink text-muted-foreground/78",
+2 -2
View File
@@ -78,7 +78,7 @@ export function FileReferenceChip({
"text-sky-600 transition-colors hover:text-sky-700",
"dark:text-sky-300 dark:hover:text-sky-200",
interactive && [
"cursor-pointer rounded-compact",
"cursor-pointer rounded-[5px]",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-400/45",
],
)}
@@ -110,7 +110,7 @@ export function FileReferenceChip({
sideOffset={8}
collisionPadding={12}
className={cn(
"max-w-[min(38rem,calc(100vw-2rem))] rounded-control",
"max-w-[min(38rem,calc(100vw-2rem))] rounded-[10px]",
"px-2.5 py-1.5",
"break-all font-mono text-[11px] leading-snug text-popover-foreground",
)}
+1 -1
View File
@@ -120,7 +120,7 @@ export function ImageLightbox({
alt={current.name ?? ""}
decoding="async"
draggable={false}
className="max-h-[92vh] max-w-[94vw] select-none rounded-compact object-contain shadow-2xl"
className="max-h-[92vh] max-w-[94vw] select-none rounded-[6px] object-contain shadow-2xl"
/>
</div>
@@ -450,7 +450,7 @@ function InlineLinkPreviewRow({ link }: { link: InlineLinkPreview }) {
>
<span
className={cn(
"relative grid h-4 w-4 shrink-0 place-items-center overflow-hidden rounded-mark",
"relative grid h-4 w-4 shrink-0 place-items-center overflow-hidden rounded-[4px]",
"border border-border/65 bg-background text-muted-foreground",
)}
aria-hidden
@@ -459,7 +459,7 @@ function InlineLinkPreviewRow({ link }: { link: InlineLinkPreview }) {
<img
src={favicon}
alt=""
className="h-3 w-3 rounded-mark object-contain"
className="h-3 w-3 rounded-[2px] object-contain"
decoding="async"
loading="lazy"
referrerPolicy="no-referrer"
@@ -745,7 +745,7 @@ export default function MarkdownTextRenderer({
},
mark({ children: markdownChildren }) {
return (
<mark className="rounded-compact bg-yellow-200/75 px-1 py-0.5 text-inherit dark:bg-yellow-300/25">
<mark className="rounded-[5px] bg-yellow-200/75 px-1 py-0.5 text-inherit dark:bg-yellow-300/25">
{markdownChildren}
</mark>
);
+4 -4
View File
@@ -331,7 +331,7 @@ export function MessageBubble({
<p
data-temporary-message={temporary ? "true" : undefined}
className={cn(
"ml-auto w-fit max-w-full min-w-0 rounded-floating px-4 py-2",
"ml-auto w-fit max-w-full min-w-0 rounded-[18px] px-4 py-2",
"text-left text-[16px]/[1.75] whitespace-pre-wrap [overflow-wrap:anywhere]",
temporary
? "border border-dashed border-muted-foreground/40 bg-transparent"
@@ -499,7 +499,7 @@ function UserQuotedContext({ text, label }: { text: string; label: string }) {
return (
<blockquote
className={cn(
"ml-auto flex w-fit max-w-full min-w-0 items-start gap-2 rounded-control",
"ml-auto flex w-fit max-w-full min-w-0 items-start gap-2 rounded-[14px]",
"border border-border/60 bg-muted/35 px-3 py-2 text-left text-muted-foreground",
)}
aria-label={label}
@@ -718,8 +718,8 @@ function UserImageCell({
const tileClasses = cn(
"relative overflow-hidden border border-border/60 bg-muted/40",
size === "large"
? "w-[min(100%,34rem)] rounded-panel bg-transparent"
: "h-24 w-24 rounded-control",
? "w-[min(100%,34rem)] rounded-[20px] bg-transparent"
: "h-24 w-24 rounded-[14px]",
"shadow-[0_6px_18px_-14px_rgba(0,0,0,0.45)]",
);
+4 -9
View File
@@ -44,7 +44,7 @@ export function RenameChatDialog({
<Dialog open={open} onOpenChange={(next) => {
if (!next) onCancel();
}}>
<DialogContent className="max-w-sm">
<DialogContent className="max-w-sm p-5">
<form
className="grid gap-4"
onSubmit={(event) => {
@@ -66,16 +66,11 @@ export function RenameChatDialog({
autoFocus
maxLength={160}
/>
<DialogFooter>
<Button
className="min-w-20"
type="button"
variant="outline"
onClick={onCancel}
>
<DialogFooter className="gap-2 sm:space-x-0">
<Button type="button" variant="outline" onClick={onCancel}>
{t("deleteConfirm.cancel")}
</Button>
<Button className="min-w-20" type="submit" disabled={!trimmed}>
<Button type="submit" disabled={!trimmed}>
{t("chat.renameSave")}
</Button>
</DialogFooter>
+2 -1
View File
@@ -120,6 +120,7 @@ export function SessionSearchDialog({
showCloseButton={false}
className={cn(
"flex max-h-[min(40rem,calc(100vh-2rem))] w-[calc(100vw-2rem)] max-w-[42rem] flex-col gap-0 overflow-hidden p-0",
"rounded-[22px]",
)}
>
<DialogTitle className="sr-only">{t("sidebar.searchAria")}</DialogTitle>
@@ -182,7 +183,7 @@ export function SessionSearchDialog({
onMouseEnter={() => setHighlightedIndex(index)}
aria-current={active ? "page" : undefined}
className={cn(
"grid min-h-[54px] w-full min-w-0 grid-cols-[minmax(0,1fr)_auto] items-center gap-3 rounded-control px-3 py-2 text-left transition-colors",
"grid min-h-[54px] w-full min-w-0 grid-cols-[minmax(0,1fr)_auto] items-center gap-3 rounded-[11px] px-3 py-2 text-left transition-colors",
highlighted
? "bg-muted text-foreground"
: "text-foreground hover:bg-muted",
@@ -621,7 +621,7 @@ export function SettingsPage({
) : null}
{loading ? (
<div className="flex h-48 items-center justify-center rounded-panel bg-settings-surface text-sm text-muted-foreground">
<div className="flex h-48 items-center justify-center rounded-[22px] bg-settings-surface text-sm text-muted-foreground">
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t("settings.status.loading")}
</div>
@@ -640,7 +640,7 @@ export function SettingsPage({
)}
>
{error ? (
<div className="rounded-floating border border-destructive/20 bg-destructive/5 px-4 py-3 text-[13px] text-destructive">
<div className="rounded-[18px] border border-destructive/20 bg-destructive/5 px-4 py-3 text-[13px] text-destructive">
{error}
</div>
) : null}
@@ -102,7 +102,7 @@ export function SettingsSidebar({
<button
type="button"
aria-label={`${t("settings.sidebar.title")}: ${activeLabel}`}
className="touch-target flex h-11 w-full items-center gap-2.5 rounded-control bg-sidebar-accent px-3 text-left text-[13px] font-medium text-foreground transition-colors hover:bg-sidebar-accent/80 lg:hidden"
className="touch-target flex h-11 w-full items-center gap-2.5 rounded-[14px] bg-sidebar-accent px-3 text-left text-[13px] font-medium text-foreground transition-colors hover:bg-sidebar-accent/80 lg:hidden"
>
<ActiveIcon className="h-4 w-4 shrink-0" strokeWidth={2} aria-hidden />
<span className="min-w-0 flex-1 truncate">{activeLabel}</span>
@@ -176,7 +176,7 @@ export function SettingsSidebar({
type="button"
variant="ghost"
onClick={onLogout}
className="h-9 w-full justify-start gap-2 rounded-control px-2.5 text-[13px] font-medium text-muted-foreground hover:bg-destructive/8 hover:text-destructive"
className="h-9 w-full justify-start gap-2 rounded-[10px] px-2.5 text-[13px] font-medium text-muted-foreground hover:bg-destructive/8 hover:text-destructive"
>
<LogOut className="h-4 w-4" aria-hidden />
{t("app.account.logout")}
@@ -98,7 +98,7 @@ export function SkillsCatalogSettings({ skills }: { skills: SkillSummary[] }) {
/>
{view === "installed" ? (
<section className="overflow-hidden rounded-panel bg-settings-surface">
<section className="overflow-hidden rounded-[22px] bg-settings-surface">
<div className="flex flex-col gap-3 px-4 pb-2 pt-4 sm:flex-row sm:items-center sm:justify-between">
<div className="relative w-full sm:max-w-[320px]">
<Search
@@ -114,7 +114,7 @@ export function SkillsCatalogSettings({ skills }: { skills: SkillSummary[] }) {
aria-label={t("settings.skills.searchInstalled", {
defaultValue: "Search installed skills",
})}
className="h-9 bg-background pl-9 text-[13px]"
className="h-9 rounded-[11px] bg-background pl-9 text-[13px]"
/>
</div>
<SegmentedControl
@@ -220,7 +220,7 @@ function SkillCatalogRow({
})}
onClick={() => onSelect(skill)}
className={cn(
"group flex w-full min-w-0 items-center gap-3 rounded-control px-2 py-3 text-left",
"group flex w-full min-w-0 items-center gap-3 rounded-[14px] px-2 py-3 text-left",
"transition-colors duration-150",
"hover:bg-muted/70",
"focus-visible:bg-muted/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
@@ -436,7 +436,7 @@ function SkillDetailSheet({
{t("settings.skills.loadingDetail", { defaultValue: "Loading skill details..." })}
</div>
) : loadFailed ? (
<div className="mt-8 rounded-floating bg-destructive/10 px-3 py-3 text-sm text-destructive">
<div className="mt-8 rounded-[16px] bg-destructive/10 px-3 py-3 text-sm text-destructive">
{t("settings.skills.loadFailed", { defaultValue: "Could not load skill details." })}
</div>
) : (
@@ -484,7 +484,7 @@ function SkillDetailSheet({
</div>
{actionError ? (
<div className="rounded-control bg-destructive/10 px-3 py-2.5 text-[13px] text-destructive">
<div className="rounded-[14px] bg-destructive/10 px-3 py-2.5 text-[13px] text-destructive">
{actionError}
</div>
) : null}
@@ -533,7 +533,7 @@ function SkillDetailSheet({
</Sheet>
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<AlertDialogContent>
<AlertDialogContent className="rounded-[20px]">
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.skills.deleteConfirmTitle", {
@@ -574,7 +574,7 @@ function RawInstructionsBlock({ markdown }: { markdown: string }) {
});
return (
<details className="group rounded-floating border border-border/45 bg-muted/20 px-3 py-3">
<details className="group rounded-[18px] border border-border/45 bg-muted/20 px-3 py-3">
<summary className="flex min-h-11 cursor-pointer select-none items-center justify-between gap-3 text-[13px] font-medium text-foreground/90 transition-colors hover:text-foreground">
<span>
{t("settings.skills.instructionsTitle", { defaultValue: "Skill instructions" })}
@@ -583,7 +583,7 @@ function RawInstructionsBlock({ markdown }: { markdown: string }) {
SKILL.md
</code>
</summary>
<div className="mt-3 overflow-hidden rounded-control border border-border/35 bg-background/70">
<div className="mt-3 overflow-hidden rounded-[14px] border border-border/35 bg-background/70">
<pre
className={cn(
"max-h-[min(42vh,32rem)] overflow-auto overscroll-contain px-3.5 py-3 pr-4",
@@ -625,7 +625,7 @@ function RequirementsSection({
};
return (
<section className="rounded-floating border border-amber-500/20 bg-amber-500/[0.06] p-4">
<section className="rounded-[18px] border border-amber-500/20 bg-amber-500/[0.06] p-4">
<div className="flex items-start gap-2.5">
<CircleAlert
className="mt-0.5 h-4 w-4 shrink-0 text-amber-700 dark:text-amber-300"
@@ -648,7 +648,7 @@ function RequirementsSection({
{installOptions.map((option) => (
<div
key={`${option.id}:${option.command}`}
className="flex min-w-0 items-center gap-2 rounded-control bg-background/80 px-3 py-2"
className="flex min-w-0 items-center gap-2 rounded-[12px] bg-background/80 px-3 py-2"
>
<Terminal className="h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden />
<code className="min-w-0 flex-1 overflow-x-auto whitespace-nowrap font-mono text-[11px] text-foreground/80">
@@ -661,7 +661,7 @@ function RequirementsSection({
})}
title={option.label}
onClick={() => void copyCommand(option.command)}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-control text-muted-foreground transition-colors hover:bg-muted hover:text-foreground sm:h-7 sm:w-7"
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-[9px] text-muted-foreground transition-colors hover:bg-muted hover:text-foreground sm:h-7 sm:w-7"
>
{copiedCommand === option.command ? (
<Check className="h-3.5 w-3.5 text-emerald-600" aria-hidden />
@@ -208,7 +208,7 @@ export function SkillsMarketplace({
aria-label={t("settings.skills.marketplaceSearchLabel", {
defaultValue: "Search skills",
})}
className="h-11 rounded-control bg-settings-surface pl-9"
className="h-11 rounded-[14px] bg-settings-surface pl-9"
/>
{loading ? (
<span
@@ -226,13 +226,13 @@ export function SkillsMarketplace({
</div>
{error ? (
<div className="rounded-control bg-destructive/10 px-3 py-2.5 text-[13px] text-destructive">
<div className="rounded-[14px] bg-destructive/10 px-3 py-2.5 text-[13px] text-destructive">
{error}
</div>
) : null}
{query.trim().length < 2 ? (
<section className="overflow-hidden rounded-panel bg-settings-surface">
<section className="overflow-hidden rounded-[22px] bg-settings-surface">
<div className="flex flex-col items-start gap-2 px-4 pb-2 pt-4 sm:flex-row sm:items-center sm:justify-between sm:px-5">
<h2 className="text-[14px] font-semibold">
{t("settings.skills.marketplaceTrendingTitle", {
@@ -271,14 +271,14 @@ export function SkillsMarketplace({
)}
</section>
) : !loading && visibleResults.length === 0 && !error ? (
<div className="rounded-panel bg-settings-surface px-5 py-12 text-center text-sm text-muted-foreground">
<div className="rounded-[22px] bg-settings-surface px-5 py-12 text-center text-sm text-muted-foreground">
{t("settings.skills.marketplaceEmpty", {
query: query.trim(),
defaultValue: "No skills found for “{{query}}”.",
})}
</div>
) : (
<div className="overflow-hidden rounded-panel bg-settings-surface">
<div className="overflow-hidden rounded-[22px] bg-settings-surface">
<MarketplaceSkillGroups
skills={visibleResults}
installedNames={installedNames}
@@ -296,9 +296,9 @@ export function SkillsMarketplace({
if (!open) setSelected(null);
}}
>
<AlertDialogContent>
<AlertDialogContent className="rounded-[20px]">
<AlertDialogHeader>
<div className="mb-1 flex h-10 w-10 items-center justify-center rounded-control bg-amber-500/10 text-amber-700 dark:text-amber-300">
<div className="mb-1 flex h-10 w-10 items-center justify-center rounded-[12px] bg-amber-500/10 text-amber-700 dark:text-amber-300">
<ShieldAlert className="h-5 w-5" aria-hidden />
</div>
<AlertDialogTitle>
@@ -316,7 +316,7 @@ export function SkillsMarketplace({
"This third-party skill comes from {{provider}} ({{source}}) and may include instructions or executable scripts.",
})}
</span>
<span className="flex flex-wrap items-center gap-2 rounded-control bg-muted px-2 py-1.5 text-[12px] text-foreground">
<span className="flex flex-wrap items-center gap-2 rounded-md bg-muted px-2 py-1.5 text-[12px] text-foreground">
{selected ? <ProviderMark provider={selected.provider} /> : null}
<code>{selected?.source}</code>
{selected?.version ? <span>v{selected.version}</span> : null}
@@ -224,7 +224,7 @@ export function TokenUsageHeatmap({
<span
aria-label={ariaLabel}
className={cn(
"aspect-square w-full rounded-mark transition-transform hover:scale-110",
"aspect-square w-full rounded-[2px] transition-transform hover:scale-110 sm:rounded-[4px]",
tokenUsageCellClass(level, cell.future),
)}
/>
@@ -136,7 +136,7 @@ export function ChannelLogo({
if (showBrandLogos && logoUrl) {
return (
<span
className="grid h-10 w-10 shrink-0 place-items-center rounded-control bg-background"
className="grid h-10 w-10 shrink-0 place-items-center rounded-[12px] bg-background"
>
<img
src={logoUrl}
@@ -154,7 +154,7 @@ export function ChannelLogo({
if (Icon) {
return (
<span
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-control bg-background"
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-[12px] bg-background"
style={{ color }}
aria-hidden
>
@@ -165,7 +165,7 @@ export function ChannelLogo({
return (
<span
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-control bg-background text-[11px] font-bold"
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-[12px] bg-background text-[11px] font-bold"
style={{ color }}
aria-hidden
>
@@ -275,7 +275,7 @@ export function ChannelRuntimeError({
}) {
if (!message) return null;
return (
<div className={`${className} rounded-control border border-destructive/20 bg-destructive/5 px-3 py-2 text-[12px] leading-5 text-destructive`}>
<div className={`${className} rounded-[12px] border border-destructive/20 bg-destructive/5 px-3 py-2 text-[12px] leading-5 text-destructive`}>
{message}
</div>
);
@@ -144,7 +144,7 @@ export function ChannelInstancesPanel({
};
return (
<aside className="min-h-full rounded-panel bg-settings-surface p-5">
<aside className="min-h-full rounded-[20px] bg-settings-surface p-5">
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 items-start gap-3">
<ChannelLogo feature={feature} showBrandLogos={showBrandLogos} />
@@ -177,7 +177,7 @@ export function ChannelInstancesPanel({
<article
key={instance.id}
className={cn(
"overflow-hidden rounded-floating transition-colors",
"overflow-hidden rounded-[18px] transition-colors",
expanded
? "bg-background"
: "bg-background/70 hover:bg-muted",
@@ -313,7 +313,7 @@ export function ChannelInstancesPanel({
{customization.footer}
{notice ? (
<div className="mt-3 rounded-control border border-destructive/20 px-3 py-2 text-[12px] leading-5 text-destructive">
<div className="mt-3 rounded-[12px] border border-destructive/20 px-3 py-2 text-[12px] leading-5 text-destructive">
{notice}
</div>
) : null}
@@ -251,8 +251,8 @@ export function ChannelQrConnectFlow({
return (
<div className="mt-3 space-y-3">
{pending ? (
<div className="grid gap-4 rounded-control border border-border/70 p-4 sm:grid-cols-[auto_minmax(0,1fr)]">
<div className="grid h-[196px] w-[196px] place-items-center rounded-control border border-border/60 bg-background">
<div className="grid gap-4 rounded-[14px] border border-border/70 p-4 sm:grid-cols-[auto_minmax(0,1fr)]">
<div className="grid h-[196px] w-[196px] place-items-center rounded-[14px] border border-border/60 bg-background">
{qrDataUrl ? (
<img
src={qrDataUrl}
@@ -293,20 +293,20 @@ export function ChannelQrConnectFlow({
) : null}
{succeeded && !suppressSucceeded ? (
<div className="flex items-center gap-2 rounded-control border border-emerald-500/20 px-3 py-2 text-[12px] font-medium text-emerald-700 dark:text-emerald-200">
<div className="flex items-center gap-2 rounded-[12px] border border-emerald-500/20 px-3 py-2 text-[12px] font-medium text-emerald-700 dark:text-emerald-200">
<Check className="h-3.5 w-3.5" aria-hidden />
{displayMessage ?? labels.connected}
</div>
) : null}
{connect && ["expired", "failed", "cancelled"].includes(connect.status) ? (
<div className="rounded-control border border-border/60 px-3 py-2 text-[12px] leading-5 text-muted-foreground">
<div className="rounded-[12px] border border-border/60 px-3 py-2 text-[12px] leading-5 text-muted-foreground">
{displayMessage || labels.stopped}
</div>
) : null}
{error ? (
<div className="rounded-control border border-destructive/20 px-3 py-2 text-[12px] leading-5 text-destructive">
<div className="rounded-[12px] border border-destructive/20 px-3 py-2 text-[12px] leading-5 text-destructive">
{error}
</div>
) : null}
@@ -87,7 +87,7 @@ export function ChannelCatalogRow({
aria-pressed={selected}
onClick={onSelect}
className={cn(
"group flex w-full min-w-0 items-center gap-3 rounded-control px-3 py-3 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border/80",
"group flex w-full min-w-0 items-center gap-3 rounded-[14px] px-3 py-3 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border/80",
selected ? "bg-background" : "hover:bg-muted",
)}
>
@@ -187,7 +187,7 @@ export function ChannelSetupPanel({
});
return (
<aside className="min-h-full rounded-panel bg-settings-surface p-5">
<aside className="min-h-full rounded-[20px] bg-settings-surface p-5">
<div className="flex items-start justify-between gap-4">
<div className="flex min-w-0 items-start gap-3">
<ChannelLogo feature={feature} showBrandLogos={showBrandLogos} />
@@ -482,7 +482,7 @@ function ChannelSetupSurface({
) : null}
</div>
{setup.command ? (
<code className="mt-3 block rounded-control border border-border/50 bg-muted/45 px-2.5 py-2 font-mono text-[11px] leading-5 text-foreground">
<code className="mt-3 block rounded-[10px] border border-border/50 bg-muted/45 px-2.5 py-2 font-mono text-[11px] leading-5 text-foreground">
{setup.command}
</code>
) : null}
@@ -538,7 +538,7 @@ function ChannelSetupSurface({
{notice ? (
<div
role="status"
className="rounded-control bg-muted/55 px-3 py-2.5 text-[12px] leading-5 text-muted-foreground"
className="rounded-[12px] bg-muted/55 px-3 py-2.5 text-[12px] leading-5 text-muted-foreground"
>
{notice}
</div>
@@ -60,13 +60,13 @@ export function ChannelGuideLink({
"inline-flex max-w-full items-center gap-2 bg-background/80 font-semibold text-foreground transition-colors hover:bg-background",
compact
? "shrink-0 rounded-full py-1 pl-1 pr-2.5 text-[11.5px]"
: "mt-3 rounded-control py-1.5 pl-1.5 pr-3 text-[12px]",
: "mt-3 rounded-[12px] py-1.5 pl-1.5 pr-3 text-[12px]",
)}
>
<span
className={cn(
"grid shrink-0 place-items-center overflow-hidden bg-muted/70 font-bold",
compact ? "h-5 w-5 rounded-full text-[9px]" : "h-6 w-6 rounded-compact text-[10px]",
compact ? "h-5 w-5 rounded-full text-[9px]" : "h-6 w-6 rounded-[7px] text-[10px]",
)}
style={{ color }}
aria-hidden
@@ -231,7 +231,7 @@ export function ChannelProviderPresets({
<div
role="radiogroup"
aria-label={t("settings.channels.providerPreset", { defaultValue: "Provider" })}
className="grid rounded-control bg-muted p-0.5 text-[12px] font-medium text-muted-foreground"
className="grid rounded-[10px] bg-muted p-0.5 text-[12px] font-medium text-muted-foreground"
style={{ gridTemplateColumns: `repeat(${presets.length}, minmax(0, 1fr))` }}
>
{presets.map((preset) => (
@@ -245,7 +245,7 @@ export function ChannelProviderPresets({
onApply(preset);
}}
className={cn(
"min-h-8 rounded-compact px-2 py-1.5 transition-colors hover:text-foreground",
"min-h-8 rounded-[8px] px-2 py-1.5 transition-colors hover:text-foreground",
selected === preset.id && "bg-background text-foreground",
)}
>
@@ -370,7 +370,7 @@ export function ChannelSetupSteps({
))}
</ol>
{tryIt ? (
<div className="mt-3 rounded-control bg-background/75 px-3 py-2 text-[12px] text-muted-foreground">
<div className="mt-3 rounded-[12px] bg-background/75 px-3 py-2 text-[12px] text-muted-foreground">
<span className="font-medium text-foreground">
{tx("settings.channels.tryIt", "Try it")}
</span>
@@ -159,7 +159,7 @@ export function CredentialForm({
<span
role="radiogroup"
aria-label={field.label}
className="mt-1 grid rounded-control bg-muted p-0.5 text-[12px] font-medium text-muted-foreground"
className="mt-1 grid rounded-[10px] bg-muted p-0.5 text-[12px] font-medium text-muted-foreground"
style={{ gridTemplateColumns: `repeat(${field.options.length}, minmax(0, 1fr))` }}
>
{field.options.map((option) => (
@@ -170,7 +170,7 @@ export function CredentialForm({
aria-checked={selectedOption === option.value}
onClick={() => onChange(field.key, option.value)}
className={cn(
"min-h-8 rounded-compact px-2 py-1.5 transition-colors hover:text-foreground",
"min-h-8 rounded-[8px] px-2 py-1.5 transition-colors hover:text-foreground",
selectedOption === option.value
&& "bg-background text-foreground ring-1 ring-inset ring-border/45",
)}
@@ -200,7 +200,7 @@ export function CredentialForm({
value={values[field.key] ?? ""}
onChange={(event) => onChange(field.key, event.target.value)}
className={cn(
"h-9 rounded-control border-border/60 bg-muted/35 text-[13px]",
"h-9 rounded-[10px] border-border/60 bg-muted/35 text-[13px]",
showSecretToggle && "pr-9",
)}
/>
@@ -117,7 +117,7 @@ export function ModelPresetDeleteDialog({
t(key, { defaultValue: fallback, ...(values ?? {}) });
return (
<Dialog open={preset !== null} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[440px]">
<DialogContent className="max-w-[440px] rounded-[24px]">
<DialogHeader className="text-left">
<DialogTitle>
{tx("settings.models.deletePresetTitle", "Delete model preset?")}
@@ -130,10 +130,11 @@ export function ModelPresetDeleteDialog({
)}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogFooter className="gap-2 sm:space-x-0">
<Button
type="button"
variant="ghost"
className="rounded-full"
disabled={deleting}
onClick={() => onOpenChange(false)}
>
@@ -142,6 +143,7 @@ export function ModelPresetDeleteDialog({
<Button
type="button"
variant="destructive"
className="rounded-full"
disabled={deleting}
onClick={onConfirm}
>
@@ -341,7 +343,7 @@ export function ModelsSettings({
<div
id="model-preset-editor"
data-testid="model-preset-editor"
className="mx-3 mb-3 divide-y divide-border/45 overflow-hidden rounded-floating border border-border/45 bg-background/80 shadow-sm motion-reduce:animate-none animate-in fade-in-0 slide-in-from-top-1 duration-200 sm:mx-5 lg:mx-auto lg:w-[calc(100%-2.5rem)] lg:max-w-6xl"
className="mx-3 mb-3 divide-y divide-border/45 overflow-hidden rounded-[18px] border border-border/45 bg-background/80 shadow-sm motion-reduce:animate-none animate-in fade-in-0 slide-in-from-top-1 duration-200 sm:mx-5 lg:mx-auto lg:w-[calc(100%-2.5rem)] lg:max-w-6xl"
>
{creating ? (
<div className="flex min-h-[52px] items-center px-4 py-3 sm:px-5">
@@ -525,7 +527,7 @@ export function ModelsSettings({
{!settings.model_call_order_editable ? (
<div className="flex flex-col gap-4 px-4 py-4 sm:flex-row sm:items-center sm:justify-between sm:px-5">
<div className="flex min-w-0 items-start gap-3">
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-control bg-muted text-muted-foreground">
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-[12px] bg-muted text-muted-foreground">
<ListOrdered className="h-4 w-4" aria-hidden />
</span>
<div className="min-w-0">
@@ -672,7 +674,7 @@ export function ModelsSettings({
aria-controls={isSelected ? "model-preset-editor" : undefined}
disabled={!preset}
onClick={() => preset && selectPreset(preset, key)}
className="flex min-w-0 flex-1 items-center gap-3 rounded-control text-left outline-none focus-visible:ring-2 focus-visible:ring-ring"
className="flex min-w-0 flex-1 items-center gap-3 rounded-[12px] text-left outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{ordered ? (
<span className="grid h-7 w-7 shrink-0 place-items-center rounded-full bg-muted font-mono text-[11px] font-semibold tabular-nums text-muted-foreground">
@@ -839,7 +841,7 @@ function ModelAdvancedFields({
const value = Number(event.target.value);
if (Number.isFinite(value)) onChange({ maxTokens: value });
}}
className="h-9 text-[13px]"
className="h-9 rounded-[12px] text-[13px]"
/>
</label>
<label className="block">
@@ -856,7 +858,7 @@ function ModelAdvancedFields({
const value = Number(event.target.value);
if (Number.isFinite(value)) onChange({ temperature: value });
}}
className="h-9 text-[13px]"
className="h-9 rounded-[12px] text-[13px]"
/>
</label>
</div>
@@ -885,7 +887,7 @@ function ModelAdvancedFields({
placeholder={tx("settings.values.default", "Default")}
autoCapitalize="none"
spellCheck={false}
className="h-9 text-[13px]"
className="h-9 rounded-[12px] text-[13px]"
/>
</label>
</div>
@@ -276,7 +276,7 @@ export function ProviderOAuthLoginDialog({
if (!open) onClose();
}}
>
<DialogContent className="w-[min(calc(100vw-2rem),28rem)]">
<DialogContent className="w-[min(calc(100vw-2rem),28rem)] rounded-[24px]">
<form
className="space-y-4"
onSubmit={(event) => {
@@ -296,7 +296,7 @@ export function ProviderOAuthLoginDialog({
: t("settings.oauth.localCodeHelp")}
</DialogDescription>
</DialogHeader>
<div className="flex items-center gap-2 rounded-control border border-border/45 bg-muted/35 px-3 py-2.5 text-[12px] text-muted-foreground">
<div className="flex items-center gap-2 rounded-[14px] border border-border/45 bg-muted/35 px-3 py-2.5 text-[12px] text-muted-foreground">
{expectsCallbackUrl && remoteBrowserAccess ? (
<Clipboard className="h-3.5 w-3.5 shrink-0" aria-hidden />
) : (
@@ -341,12 +341,12 @@ export function ProviderOAuthLoginDialog({
{error ? (
<p
role="alert"
className="rounded-control border border-destructive/20 bg-destructive/5 px-3 py-2.5 text-[12px] text-destructive"
className="rounded-[14px] border border-destructive/20 bg-destructive/5 px-3 py-2.5 text-[12px] text-destructive"
>
{error}
</p>
) : null}
<DialogFooter>
<DialogFooter className="gap-2 sm:space-x-0">
<Button type="button" variant="outline" onClick={onOpenAuthorization}>
<ExternalLink className="mr-2 h-4 w-4" aria-hidden />
{expectsCallbackUrl
@@ -380,7 +380,7 @@ function ProviderRequestOptions({
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
return (
<div className="overflow-hidden rounded-floating border border-border/45 bg-background/75">
<div className="overflow-hidden rounded-[18px] border border-border/45 bg-background/75">
{options.map((option, index) => {
const title = tx(option.titleKey, option.title);
const Icon = option.kind === "priority" ? Zap : Globe2;
@@ -599,7 +599,7 @@ function ProviderAdvancedOptions({
onChange={(event) => onChange({ extraHeaders: event.target.value })}
placeholder={'{"X-Header":"value"}'}
spellCheck={false}
className="min-h-[88px] resize-y bg-background font-mono text-[12px]"
className="min-h-[88px] resize-y rounded-[14px] bg-background font-mono text-[12px]"
/>
</label>
) : null}
@@ -613,7 +613,7 @@ function ProviderAdvancedOptions({
onChange={(event) => onChange({ extraQuery: event.target.value })}
placeholder={'{"api-version":"2024-02-01"}'}
spellCheck={false}
className="min-h-[88px] resize-y bg-background font-mono text-[12px]"
className="min-h-[88px] resize-y rounded-[14px] bg-background font-mono text-[12px]"
/>
</label>
) : null}
@@ -627,7 +627,7 @@ function ProviderAdvancedOptions({
onChange={(event) => onChange({ extraBody: event.target.value })}
placeholder={'{"service_tier":"priority"}'}
spellCheck={false}
className="min-h-[96px] resize-y bg-background font-mono text-[12px]"
className="min-h-[96px] resize-y rounded-[14px] bg-background font-mono text-[12px]"
/>
</label>
) : null}
@@ -825,7 +825,7 @@ export function ProvidersSettings({
) : null}
{isOauthProvider ? (
<>
<div className="flex flex-col gap-3 rounded-floating border border-border/45 bg-background/75 px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-col gap-3 rounded-[18px] border border-border/45 bg-background/75 px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<p className="text-[13px] font-semibold text-foreground">
{tx("settings.oauth.authentication", "OAuth authentication")}
@@ -1240,7 +1240,7 @@ export function ProvidersSettings({
className="group flex min-h-[70px] w-full items-center justify-between gap-4 px-4 py-3 text-left transition-colors hover:bg-muted/35 sm:px-5"
>
<span className="flex min-w-0 items-center gap-3">
<span className="grid h-10 w-10 shrink-0 place-items-center rounded-control bg-muted text-muted-foreground">
<span className="grid h-10 w-10 shrink-0 place-items-center rounded-[14px] bg-muted text-muted-foreground">
<Plus className="h-5 w-5" aria-hidden />
</span>
<span className="truncate text-[15px] font-semibold text-foreground">
@@ -1334,7 +1334,7 @@ function ProviderIcon({
return (
<span
data-testid={`provider-logo-${provider}`}
className="grid h-10 w-10 shrink-0 place-items-center overflow-hidden rounded-control border border-border/45 bg-background"
className="grid h-10 w-10 shrink-0 place-items-center overflow-hidden rounded-[14px] border border-border/45 bg-background"
>
<img
src={logoUrl}
@@ -1352,7 +1352,7 @@ function ProviderIcon({
return (
<span
data-testid={`provider-logo-fallback-${provider}`}
className="grid h-10 w-10 shrink-0 place-items-center rounded-control text-[11px] font-semibold text-white"
className="grid h-10 w-10 shrink-0 place-items-center rounded-[14px] text-[11px] font-semibold text-white"
style={{ backgroundColor: brand.color }}
aria-hidden
>
@@ -129,7 +129,7 @@ export function OverviewSettings({
: tx("settings.values.ready", "Ready");
return (
<div className="space-y-7">
<section className="rounded-panel bg-settings-surface px-4 py-4 sm:px-5">
<section className="rounded-[22px] bg-settings-surface px-4 py-4 sm:px-5">
<TokenUsageHeatmap usage={settings.usage} timeZone={settings.agent.timezone} />
</section>
@@ -433,7 +433,7 @@ function OverviewRowIcon({
icon: LucideIcon;
}) {
return (
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-control bg-muted text-foreground/82 transition-colors group-hover:bg-muted/80 dark:bg-muted/70">
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-[12px] bg-muted text-foreground/82 transition-colors group-hover:bg-muted/80 dark:bg-muted/70">
<Icon className="h-4 w-4" aria-hidden />
</span>
);
@@ -309,7 +309,7 @@ export function ModelIdPicker({
key={model.id}
{...navigation.getOptionProps(model.id)}
className={cn(
"flex cursor-default items-center justify-between gap-2 rounded-control px-2 py-1.5 text-[12px]",
"flex cursor-default items-center justify-between gap-2 rounded-[12px] px-2 py-1.5 text-[12px]",
options.selected && "text-foreground",
)}
>
@@ -455,7 +455,7 @@ export function ModelIdPicker({
) : null}
<ComboboxOption
{...navigation.getOptionProps(customCandidate)}
className="flex cursor-default items-center gap-2 rounded-control px-2 py-1.5 text-[12px]"
className="flex cursor-default items-center gap-2 rounded-[12px] px-2 py-1.5 text-[12px]"
>
<span className="grid h-5 w-5 shrink-0 place-items-center rounded-md bg-muted/80 text-muted-foreground">
<Pencil className="h-3 w-3" aria-hidden />
@@ -31,7 +31,7 @@ export function CapabilityInstallNotice({
installing?: boolean;
}) {
return (
<div className="flex items-start gap-3 rounded-control border border-border/55 bg-muted/22 px-3.5 py-3">
<div className="flex items-start gap-3 rounded-[14px] border border-border/55 bg-muted/22 px-3.5 py-3">
{installing ? (
<Loader2 className="mt-0.5 h-4 w-4 shrink-0 animate-spin text-muted-foreground" aria-hidden />
) : (
@@ -78,13 +78,13 @@ export function NanobotFeatureInstallDialog({
)}
</DialogDescription>
</DialogHeader>
<DialogFooter className="mt-7 !grid grid-cols-1 gap-2 sm:grid-cols-2">
<DialogFooter className="mt-7 !grid grid-cols-1 gap-3 space-x-0 sm:grid-cols-2 sm:space-x-0">
<Button
type="button"
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={installing}
className="h-11 w-full min-w-0 bg-muted/70 px-5 text-[15px] font-semibold text-foreground shadow-none hover:bg-muted"
className="h-11 w-full min-w-0 rounded-full bg-muted/70 px-5 text-[15px] font-semibold text-foreground shadow-none hover:bg-muted"
>
{tx("settings.automations.cancel", "Cancel")}
</Button>
@@ -92,7 +92,7 @@ export function NanobotFeatureInstallDialog({
type="button"
onClick={() => feature && void onConfirm(feature)}
disabled={!feature || installing}
className="h-11 w-full min-w-0 !whitespace-normal px-5 text-center text-[15px] font-semibold"
className="h-11 w-full min-w-0 !whitespace-normal rounded-full px-5 text-center text-[15px] font-semibold"
>
{installing ? <Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden /> : null}
{tx("settings.nanobotFeatures.installConfirmAction", "Install and enable")}
@@ -117,7 +117,7 @@ export function DismissibleStatusMessage({
return (
<div
className={cn(
"flex items-center justify-between gap-3 rounded-control border py-2.5 pl-4 pr-2 text-[13px]",
"flex items-center justify-between gap-3 rounded-[12px] border py-2.5 pl-4 pr-2 text-[13px]",
isError
? "border-destructive/20 bg-destructive/5 text-destructive"
: "border-border/55 bg-muted/35 text-muted-foreground",
@@ -153,7 +153,7 @@ export function RestartRequiredNotice({
}) {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-3 rounded-control border border-amber-500/20 bg-amber-500/8 px-4 py-3 text-[12.5px] text-amber-800 dark:text-amber-200 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-col gap-3 rounded-[12px] border border-amber-500/20 bg-amber-500/8 px-4 py-3 text-[12.5px] text-amber-800 dark:text-amber-200 sm:flex-row sm:items-center sm:justify-between">
<span>{message}</span>
{onRestart ? (
<Button
@@ -186,7 +186,7 @@ export function SettingsSectionTitle({ children }: { children: ReactNode }) {
export function SettingsGroup({ children }: { children: ReactNode }) {
return (
<div className="overflow-hidden rounded-panel bg-settings-surface">
<div className="overflow-hidden rounded-[22px] bg-settings-surface">
<div className="divide-y divide-border/45">{children}</div>
</div>
);
@@ -256,7 +256,7 @@ export function AppsCatalogSettings({
onChange={(event) => onQueryChange(event.target.value)}
placeholder={tx("settings.apps.searchPlaceholder", "Search Apps")}
className={cn(
"h-12 pl-11 text-[15px]",
"h-12 rounded-[14px] pl-11 text-[15px]",
SETTINGS_SEARCH_INPUT_CLASS,
)}
/>
@@ -289,7 +289,7 @@ export function AppsCatalogSettings({
/>
) : null}
<section className="rounded-panel bg-settings-surface px-3 py-3 sm:px-4">
<section className="rounded-[22px] bg-settings-surface px-3 py-3 sm:px-4">
<div className="flex items-center justify-between border-b border-border/45 pb-3">
<SettingsSectionTitle>
{filter === "mcp"
@@ -412,7 +412,7 @@ function CliAppsCatalogRow({
const description = app.description || app.requires || app.entry_point || app.name;
return (
<article className="apps-catalog-row group flex min-w-0 items-center gap-3 rounded-control px-3 py-3 transition-colors hover:bg-muted/45">
<article className="apps-catalog-row group flex min-w-0 items-center gap-3 rounded-[14px] px-3 py-3 transition-colors hover:bg-muted/45">
<CliAppLogo app={app} showBrandLogos={showBrandLogos} />
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-baseline gap-2">
@@ -583,7 +583,7 @@ function McpAppsCatalogRow({
};
return (
<article className="min-w-0 rounded-control transition-colors hover:bg-muted/45">
<article className="min-w-0 rounded-[14px] transition-colors hover:bg-muted/45">
<div className="group flex min-w-0 flex-wrap items-center gap-x-3 gap-y-2 px-3 py-3">
<McpPresetLogo preset={preset} showBrandLogos={showBrandLogos} />
<div className="min-w-[8rem] flex-[1_1_8rem]">
@@ -747,7 +747,7 @@ function McpAppsCatalogRow({
{manualCallback ? (
<form
className="mx-3 mb-3 min-w-0 space-y-3 rounded-control bg-background/55 p-3"
className="mx-3 mb-3 min-w-0 space-y-3 rounded-[14px] bg-background/55 p-3"
onSubmit={(event) => {
event.preventDefault();
onOAuthComplete();
@@ -825,7 +825,7 @@ function McpAppsCatalogRow({
</div>
</form>
) : oauthFlow && oauthPopupBlocked && oauthFlow.authorization_url ? (
<div className="mx-3 mb-3 flex flex-col gap-2.5 rounded-control bg-background/55 p-3">
<div className="mx-3 mb-3 flex flex-col gap-2.5 rounded-[14px] bg-background/55 p-3">
<div className="flex min-w-0 items-center gap-2.5 text-[12.5px] text-muted-foreground">
<span>
{mcpOAuthStatusText(
@@ -1016,10 +1016,10 @@ function McpCustomServerPanel({
];
return (
<section className="overflow-hidden rounded-floating bg-settings-surface">
<section className="overflow-hidden rounded-[16px] bg-settings-surface">
<div className="flex flex-col gap-3 px-3 py-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex min-w-0 items-center gap-3">
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-control bg-muted text-muted-foreground">
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-[11px] bg-muted text-muted-foreground">
<Server className="h-4 w-4" aria-hidden />
</span>
<div className="min-w-0">
@@ -1154,7 +1154,7 @@ function McpCustomServerPanel({
value={form.headers}
onChange={(event) => update("headers", event.target.value)}
placeholder={'{"Authorization":"Bearer ..."}'}
className="min-h-[68px] resize-y bg-background/80 font-mono text-[12px]"
className="min-h-[68px] resize-y rounded-[12px] bg-background/80 font-mono text-[12px]"
/>
<p
id={headersHelpId}
@@ -1202,7 +1202,7 @@ function McpCustomServerPanel({
value={form.args}
onChange={(event) => update("args", event.target.value)}
placeholder={'["-y", "docs-mcp"]'}
className="min-h-[68px] resize-y bg-background/80 font-mono text-[12px]"
className="min-h-[68px] resize-y rounded-[12px] bg-background/80 font-mono text-[12px]"
/>
</label>
) : null}
@@ -1214,7 +1214,7 @@ function McpCustomServerPanel({
value={form.env}
onChange={(event) => update("env", event.target.value)}
placeholder={'{"API_KEY":"..."}'}
className="min-h-[68px] resize-y bg-background/80 font-mono text-[12px]"
className="min-h-[68px] resize-y rounded-[12px] bg-background/80 font-mono text-[12px]"
/>
</label>
<label className="min-w-0">
@@ -1257,7 +1257,7 @@ function McpCustomServerPanel({
value={configImport}
onChange={(event) => onConfigImportChange(event.target.value)}
placeholder={'{"mcpServers":{"docs":{"command":"npx","args":["-y","docs-mcp"]}}}'}
className="min-h-[84px] resize-y bg-background/80 font-mono text-[12px]"
className="min-h-[84px] resize-y rounded-[12px] bg-background/80 font-mono text-[12px]"
/>
</label>
<Button
@@ -1352,7 +1352,7 @@ function McpPresetLogo({
<span
className={cn(
"grid shrink-0 place-items-center border border-border/45 bg-background",
compact ? "h-10 w-10 rounded-control" : "h-11 w-11 rounded-compact",
compact ? "h-10 w-10 rounded-[10px]" : "h-11 w-11 rounded-[8px]",
)}
>
<img
@@ -1372,8 +1372,8 @@ function McpPresetLogo({
className={cn(
"grid shrink-0 place-items-center font-semibold text-white",
compact
? "h-10 w-10 rounded-control text-[12px]"
: "h-11 w-11 rounded-compact text-[13px]",
? "h-10 w-10 rounded-[10px] text-[12px]"
: "h-11 w-11 rounded-[8px] text-[13px]",
)}
style={{ backgroundColor: bg }}
>
@@ -1406,7 +1406,7 @@ function CliAppReadyPanel({
};
return (
<section className="rounded-control bg-settings-surface px-4 py-3">
<section className="rounded-[12px] bg-settings-surface px-4 py-3">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<CliAppLogo app={app} showBrandLogos={showBrandLogos} />
<div className="min-w-0 flex-1">
@@ -1472,7 +1472,7 @@ function CliAppLogo({ app, showBrandLogos }: { app: CliAppInfo; showBrandLogos:
return (
<span
className="relative grid h-11 w-11 shrink-0 place-items-center overflow-hidden rounded-compact border border-border/45 bg-muted text-[13px] font-semibold"
className="relative grid h-11 w-11 shrink-0 place-items-center overflow-hidden rounded-[8px] border border-border/45 bg-muted text-[13px] font-semibold"
style={{ color: app.brand_color || "hsl(var(--muted-foreground))" }}
>
<span
@@ -129,14 +129,14 @@ export function AutomationsSettings({
<section className="shrink-0">
<div className="mx-auto flex w-full max-w-[56rem] flex-col gap-3">
<div className="-mx-1 overflow-x-auto px-1 pb-0.5">
<div className="grid w-full min-w-[36rem] grid-cols-5 gap-1 rounded-floating bg-muted p-1">
<div className="grid w-full min-w-[36rem] grid-cols-5 gap-1 rounded-[15px] bg-muted p-1">
{summaryOptions.map((option) => (
<button
key={option.value}
type="button"
onClick={() => onFilterChange(option.value)}
className={cn(
"inline-flex h-8 min-w-0 shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-control px-3 text-[12px] font-medium text-muted-foreground transition-colors",
"inline-flex h-8 min-w-0 shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-[11px] px-3 text-[12px] font-medium text-muted-foreground transition-colors",
filter === option.value && "bg-background text-foreground",
automationFilterToneClass(option.value, option.count, filter === option.value),
)}
@@ -166,7 +166,7 @@ export function AutomationsSettings({
"Search task, message, linked chat, or schedule",
)}
className={cn(
"h-9 w-full rounded-control pl-9 text-[13px]",
"h-9 w-full rounded-[13px] pl-9 text-[13px]",
SETTINGS_SEARCH_INPUT_CLASS,
)}
/>
@@ -175,7 +175,7 @@ export function AutomationsSettings({
<DropdownMenuTrigger asChild>
<button
type="button"
className="inline-flex h-9 min-w-[8.5rem] items-center justify-center gap-1.5 whitespace-nowrap rounded-control border border-border/45 bg-settings-surface px-3 text-[12px] font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground sm:w-auto"
className="inline-flex h-9 min-w-[8.5rem] items-center justify-center gap-1.5 whitespace-nowrap rounded-[13px] border border-border/45 bg-settings-surface px-3 text-[12px] font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground sm:w-auto"
>
<ArrowUpDown className="h-3.5 w-3.5" aria-hidden />
<span>{sortLabel[sort]}</span>
@@ -197,19 +197,19 @@ export function AutomationsSettings({
) : null}
{error ? (
<div className="flex items-center gap-2 rounded-floating border border-destructive/20 bg-destructive/5 px-4 py-3 text-[13px] text-destructive">
<div className="flex items-center gap-2 rounded-[18px] border border-destructive/20 bg-destructive/5 px-4 py-3 text-[13px] text-destructive">
<CircleAlert className="h-4 w-4 shrink-0" aria-hidden />
<span>{error}</span>
</div>
) : null}
{loading && !payload ? (
<div className="flex h-44 items-center justify-center rounded-panel bg-settings-surface text-[13px] text-muted-foreground">
<div className="flex h-44 items-center justify-center rounded-[22px] bg-settings-surface text-[13px] text-muted-foreground">
<Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden />
{tx("settings.automations.loading", "Loading automations...")}
</div>
) : filtered.length && selectedJob ? (
<section className="grid min-h-0 overflow-hidden rounded-panel bg-settings-surface xl:grid-cols-[minmax(16rem,18rem)_minmax(0,1fr)] xl:items-stretch">
<section className="grid min-h-0 overflow-hidden rounded-[22px] bg-settings-surface xl:grid-cols-[minmax(16rem,18rem)_minmax(0,1fr)] xl:items-stretch">
<aside className="flex min-h-0 flex-col overflow-hidden border-b border-border/35 bg-settings-surface xl:border-b-0 xl:border-r">
<div className="flex shrink-0 items-center justify-between gap-3 px-4 py-3">
<h2 className="text-[13px] font-semibold tracking-[-0.01em] text-foreground/85">
@@ -245,7 +245,7 @@ export function AutomationsSettings({
/>
</section>
) : (
<div className="rounded-panel bg-settings-surface px-5 py-12 text-center text-[13px] text-muted-foreground">
<div className="rounded-[22px] bg-settings-surface px-5 py-12 text-center text-[13px] text-muted-foreground">
<div>
{jobs.length
? tx("settings.automations.noMatches", "No automations match this view.")
@@ -313,7 +313,7 @@ function AutomationListItem({
aria-pressed={selected}
onClick={onSelect}
className={cn(
"group grid w-full grid-cols-[minmax(0,1fr)_auto] gap-3 rounded-floating px-3 py-3.5 text-left transition-colors",
"group grid w-full grid-cols-[minmax(0,1fr)_auto] gap-3 rounded-[18px] px-3 py-3.5 text-left transition-colors",
selected
? "bg-background/80 text-foreground"
: "text-muted-foreground hover:bg-background/55 hover:text-foreground",
@@ -433,7 +433,7 @@ function AutomationDetailPanel({
<div className="grid min-h-0 min-w-0 flex-1 overflow-hidden lg:grid-cols-[minmax(0,1fr)_14.5rem]">
<div className="min-h-0 min-w-0 space-y-3 overflow-y-auto overscroll-contain p-4 sm:p-5">
<section className="rounded-floating bg-background/55 px-4 py-3.5">
<section className="rounded-[20px] bg-background/55 px-4 py-3.5">
<div className="flex items-center justify-between gap-3">
<div className="text-[11px] font-medium leading-none text-muted-foreground/75">
{messageLabel}
@@ -506,7 +506,7 @@ function AutomationDetailPanel({
</div>
{job.state.last_error ? (
<div className="rounded-floating border border-destructive/20 bg-destructive/8 px-3 py-2 text-[12px] leading-5 text-destructive">
<div className="rounded-[16px] border border-destructive/20 bg-destructive/8 px-3 py-2 text-[12px] leading-5 text-destructive">
{job.state.last_error}
</div>
) : null}
@@ -520,7 +520,7 @@ function AutomationDetailPanel({
>
{schedule}
</AutomationDetail>
<div className="rounded-floating bg-background/55 p-3">
<div className="rounded-[18px] bg-background/55 p-3">
<div className="grid gap-3">
{created ? (
<div>
@@ -672,7 +672,7 @@ function AutomationDetail({
children: ReactNode;
}) {
return (
<div className="min-w-0 rounded-floating bg-background/55 px-3 py-3">
<div className="min-w-0 rounded-[17px] bg-background/55 px-3 py-3">
<div className="text-[11px] font-medium leading-none text-muted-foreground/75">
{label}
</div>
@@ -757,7 +757,7 @@ export function AutomationEditDialog({
{job ? (
<DialogContent
aria-describedby={undefined}
className="w-[min(calc(100vw-2rem),34rem)]"
className="w-[min(calc(100vw-2rem),34rem)] rounded-[26px]"
>
<form className="space-y-5" onSubmit={submit}>
<DialogHeader>
@@ -772,6 +772,7 @@ export function AutomationEditDialog({
<Input
value={draft.name}
onChange={(event) => setDraft((prev) => ({ ...prev, name: event.target.value }))}
className="h-10 rounded-[12px]"
/>
</label>
@@ -783,7 +784,7 @@ export function AutomationEditDialog({
<Textarea
value={draft.message}
onChange={(event) => setDraft((prev) => ({ ...prev, message: event.target.value }))}
className="min-h-[160px] resize-none text-[13px] leading-5"
className="min-h-[160px] resize-none rounded-[12px] text-[13px] leading-5"
/>
</label>
) : null}
@@ -820,6 +821,7 @@ export function AutomationEditDialog({
onChange={(event) =>
setDraft((prev) => ({ ...prev, everyValue: event.target.value }))
}
className="h-10 rounded-[12px]"
/>
</label>
<label className="block space-y-1.5">
@@ -835,7 +837,7 @@ export function AutomationEditDialog({
}))
}
className={cn(
"h-10 w-full rounded-control border border-input bg-background px-3 text-[13px] text-foreground transition-colors",
"h-10 w-full rounded-[12px] border border-input bg-background px-3 text-[13px] text-foreground transition-colors",
formControlFocusClassName,
)}
>
@@ -859,7 +861,7 @@ export function AutomationEditDialog({
value={draft.cronExpr}
onChange={(event) => setDraft((prev) => ({ ...prev, cronExpr: event.target.value }))}
placeholder="0 9 * * *"
className="font-mono text-[13px]"
className="h-10 rounded-[12px] font-mono text-[13px]"
/>
</label>
<label className="block space-y-1.5">
@@ -870,7 +872,7 @@ export function AutomationEditDialog({
value={draft.tz}
onChange={(event) => setDraft((prev) => ({ ...prev, tz: event.target.value }))}
placeholder="Asia/Shanghai"
className="text-[13px]"
className="h-10 rounded-[12px] text-[13px]"
/>
</label>
</div>
@@ -885,12 +887,13 @@ export function AutomationEditDialog({
type="datetime-local"
value={draft.atLocal}
onChange={(event) => setDraft((prev) => ({ ...prev, atLocal: event.target.value }))}
className="h-10 rounded-[12px]"
/>
</label>
) : null}
{validation ? (
<div className="rounded-control bg-destructive/8 px-3 py-2 text-[12px] text-destructive">
<div className="rounded-[12px] bg-destructive/8 px-3 py-2 text-[12px] text-destructive">
{validation}
</div>
) : null}
@@ -902,10 +905,11 @@ export function AutomationEditDialog({
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={saving}
className="rounded-full"
>
{tx("settings.automations.cancel", "Cancel")}
</Button>
<Button type="submit" disabled={Boolean(validation) || saving}>
<Button type="submit" disabled={Boolean(validation) || saving} className="rounded-full">
{saving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden /> : null}
{tx("settings.automations.save", "Save")}
</Button>
@@ -933,7 +937,7 @@ export function AutomationDeleteDialog({
t(key, { defaultValue: fallback, ...(values ?? {}) });
return (
<Dialog open={Boolean(job)} onOpenChange={onOpenChange}>
<DialogContent className="w-[min(calc(100vw-2rem),26rem)]">
<DialogContent className="w-[min(calc(100vw-2rem),26rem)] rounded-[26px]">
<DialogHeader>
<DialogTitle>{tx("settings.automations.deleteTitle", "Delete automation")}</DialogTitle>
<DialogDescription>
@@ -950,6 +954,7 @@ export function AutomationDeleteDialog({
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={deleting}
className="rounded-full"
>
{tx("settings.automations.cancel", "Cancel")}
</Button>
@@ -958,6 +963,7 @@ export function AutomationDeleteDialog({
variant="destructive"
onClick={() => job && void onConfirm(job)}
disabled={!job || deleting}
className="rounded-full"
>
{deleting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden /> : null}
{tx("settings.automations.delete", "Delete")}
@@ -147,19 +147,19 @@ export function ChannelsSettings({
onChange={(event) => onQueryChange(event.target.value)}
placeholder={tx("settings.channels.searchPlaceholder", "Search channels")}
className={cn(
"h-12 rounded-control pl-11 text-[15px]",
"h-12 rounded-[14px] pl-11 text-[15px]",
SETTINGS_SEARCH_INPUT_CLASS,
)}
/>
</div>
<div className="flex shrink-0 flex-wrap gap-1.5 rounded-control bg-muted/55 p-1">
<div className="flex shrink-0 flex-wrap gap-1.5 rounded-[14px] bg-muted/55 p-1">
{filterOptions.map((option) => (
<button
key={option.value}
type="button"
onClick={() => setFilter(option.value)}
className={cn(
"rounded-control px-3 py-1.5 text-[12px] font-medium transition-colors",
"rounded-[11px] px-3 py-1.5 text-[12px] font-medium transition-colors",
filter === option.value
? "bg-background text-foreground"
: "text-muted-foreground hover:text-foreground",
@@ -153,6 +153,7 @@ export function McpManagementDialog({
showCloseButton={false}
className={cn(
"flex h-[min(34rem,calc(100dvh-2rem))] w-[calc(100vw-2rem)] max-w-[42rem] flex-col gap-0 overflow-hidden p-0",
"rounded-[22px]",
)}
>
<div className="flex shrink-0 items-center gap-3 border-b border-border/45 px-5 py-3.5 sm:px-6">
@@ -281,7 +282,7 @@ function OverviewPanel({
const previewTools = (preset.tool_names ?? []).slice(0, 4);
return (
<div className="space-y-4">
<section className="rounded-floating border border-border/55 px-4 py-3.5">
<section className="rounded-[16px] border border-border/55 px-4 py-3.5">
<h3 className="text-[13px] font-semibold text-foreground">
{tx("settings.mcp.about", "About this MCP")}
</h3>
@@ -313,7 +314,7 @@ function OverviewPanel({
</dl>
{previewTools.length ? (
<section className="rounded-floating bg-muted/45 p-4">
<section className="rounded-[16px] bg-muted/45 p-4">
<h3 className="text-[13px] font-semibold text-foreground">
{tx("settings.mcp.toolPreview", "Tools")}
</h3>
@@ -374,7 +375,7 @@ function ToolsPanel({
return (
<div
className={cn(
"flex flex-col gap-3 rounded-floating border px-4 py-3.5 sm:flex-row sm:items-center sm:justify-between",
"flex flex-col gap-3 rounded-[16px] border px-4 py-3.5 sm:flex-row sm:items-center sm:justify-between",
preset.error ? "border-destructive/25 bg-destructive/5" : "border-border/55 bg-muted/25",
)}
>
@@ -443,7 +444,7 @@ function ToolsPanel({
</div>
</div>
<div className="overflow-hidden rounded-floating border border-border/55">
<div className="overflow-hidden rounded-[16px] border border-border/55">
{filteredTools.length ? filteredTools.map((toolName) => {
const selected = selectedTools.has(toolName);
return (
@@ -462,7 +463,7 @@ function ToolsPanel({
<span
aria-hidden
className={cn(
"pointer-events-none absolute inset-0 grid place-items-center rounded-compact border transition-colors",
"pointer-events-none absolute inset-0 grid place-items-center rounded-[7px] border transition-colors",
selected
? "border-foreground bg-foreground text-background"
: "border-border bg-background text-transparent",
@@ -528,7 +529,7 @@ function ConnectionPanel({
<h3 id={`mcp-connection-${preset.name}`} className="sr-only">
{tx("settings.mcp.connectionDetails", "Connection details")}
</h3>
<div className="rounded-floating bg-muted/40 px-4 py-3.5">
<div className="rounded-[16px] bg-muted/40 px-4 py-3.5">
{preset.connection_summary ? (
<div className="min-w-0">
<p className="text-[12.5px] font-medium text-muted-foreground">{connectionLabel}</p>
@@ -579,7 +580,7 @@ function ConnectionPanel({
</section>
{preset.error ? (
<div role="alert" className="rounded-control bg-destructive/10 px-3.5 py-3 text-[12.5px] leading-5 text-destructive">
<div role="alert" className="rounded-[14px] bg-destructive/10 px-3.5 py-3 text-[12.5px] leading-5 text-destructive">
{preset.error}
</div>
) : null}
@@ -660,7 +661,7 @@ function ConnectionPanel({
function MetricCard({ label, value }: { label: string; value: string }) {
return (
<div className="min-w-0 rounded-control bg-muted/45 px-3.5 py-3">
<div className="min-w-0 rounded-[14px] bg-muted/45 px-3.5 py-3">
<dt className="text-[12px] font-medium text-muted-foreground">{label}</dt>
<dd className="mt-1 truncate text-[14px] font-semibold text-foreground">{value}</dd>
</div>
@@ -319,7 +319,7 @@ export function AgentActivityCluster({
syncActivityScrollFade();
}, [syncActivityScrollFade]);
if (!hasVisibleActivity && !isTurnStreaming) return null;
if (!hasVisibleActivity) return null;
if (hasOnlyFileActivity) {
return (
@@ -343,7 +343,6 @@ export function AgentActivityCluster({
contentRef={activityContentRef}
fadeTop={activityScrollFade.top}
fadeBottom={activityScrollFade.bottom}
hasDetails={hasVisibleActivity}
onToggle={toggleOuter}
onScroll={onActivityScroll}
>
@@ -383,13 +382,7 @@ function activityDurationMs(
const timestamps = messages
.map((message) => message.createdAt)
.filter((value) => Number.isFinite(value));
if (!timestamps.length) {
return active
&& typeof activeStartedAtMs === "number"
&& Number.isFinite(activeStartedAtMs)
? Math.max(0, now - activeStartedAtMs)
: 0;
}
if (!timestamps.length) return 0;
const first = active && Number.isFinite(activeStartedAtMs)
? activeStartedAtMs!
: Math.min(...timestamps);
@@ -1112,7 +1105,7 @@ function CliRunRow({ run, active, app }: { run: CliRunSummary; active: boolean;
<span
data-testid={`activity-cli-logo-${run.name.toLowerCase()}`}
className={cn(
"grid h-4 w-4 shrink-0 place-items-center overflow-hidden rounded-mark border text-[6.5px] font-semibold text-white",
"grid h-4 w-4 shrink-0 place-items-center overflow-hidden rounded-[4px] border text-[6.5px] font-semibold text-white",
rowActive && "animate-pulse",
)}
style={{
@@ -1190,7 +1183,7 @@ function McpRunRow({ run, active, preset }: { run: McpRunSummary; active: boolea
<span
data-testid={`activity-mcp-logo-${run.presetName.toLowerCase()}`}
className={cn(
"grid h-4 w-4 shrink-0 place-items-center overflow-hidden rounded-mark border text-[6.5px] font-semibold text-white",
"grid h-4 w-4 shrink-0 place-items-center overflow-hidden rounded-[4px] border text-[6.5px] font-semibold text-white",
rowActive && "animate-pulse",
)}
style={{
+1 -1
View File
@@ -203,7 +203,7 @@ export function PromptRail({
aria-hidden
data-testid={previewVisible ? "prompt-rail-preview" : undefined}
className={cn(
"pointer-events-none absolute left-10 z-30 w-[34rem] max-w-[calc(100vw-4rem)] -translate-y-1/2 rounded-panel px-4 py-3 text-left",
"pointer-events-none absolute left-10 z-30 w-[34rem] max-w-[calc(100vw-4rem)] -translate-y-1/2 rounded-[20px] px-4 py-3 text-left",
floatingSurfaceElevationClassName,
"transition-[opacity,transform] duration-150",
previewVisible
@@ -41,12 +41,12 @@ export function SessionInfoPopover({ sessionKey, token, title }: SessionInfoPopo
const [open, setOpen] = useState(false);
const { jobs, loading, loadFailed, now } = useSessionAutomationJobs(open, token, sessionKey);
const automationContent = loading ? (
<div className="flex items-center gap-2 rounded-floating bg-muted/45 px-3 py-3 text-[12.5px] text-muted-foreground">
<div className="flex items-center gap-2 rounded-[16px] bg-muted/45 px-3 py-3 text-[12.5px] text-muted-foreground">
<RefreshCcw className="h-3.5 w-3.5 animate-spin" />
{t("thread.sessionInfo.loading")}
</div>
) : loadFailed ? (
<div className="flex items-center gap-2 rounded-floating bg-destructive/10 px-3 py-3 text-[12.5px] text-destructive">
<div className="flex items-center gap-2 rounded-[16px] bg-destructive/10 px-3 py-3 text-[12.5px] text-destructive">
<CircleAlert className="h-3.5 w-3.5" />
{t("thread.sessionInfo.loadFailed")}
</div>
@@ -57,7 +57,7 @@ export function SessionInfoPopover({ sessionKey, token, title }: SessionInfoPopo
))}
</div>
) : (
<div className="rounded-floating bg-muted/35 px-3 py-3 text-[12.5px] leading-relaxed text-muted-foreground">
<div className="rounded-[16px] bg-muted/35 px-3 py-3 text-[12.5px] leading-relaxed text-muted-foreground">
{t("thread.sessionInfo.empty")}
</div>
);
@@ -124,7 +124,7 @@ function AutomationRow({ job, now }: { job: SessionAutomationJob; now: number })
: "bg-muted-foreground/35";
return (
<div className="rounded-floating px-3 py-2.5 transition-colors hover:bg-muted/40">
<div className="rounded-[16px] px-3 py-2.5 transition-colors hover:bg-muted/40">
<div className="flex items-start gap-2.5">
<span className={cn("mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full", statusClass)} />
<div className="min-w-0 flex-1">
+62 -25
View File
@@ -83,6 +83,7 @@ import { useClipboardAndDrop } from "@/hooks/useClipboardAndDrop";
import { useLogoFallback } from "@/hooks/useLogoFallback";
import { useMediaQuery } from "@/hooks/useMediaQuery";
import type { SendAttachment, SendOptions } from "@/hooks/useNanobotStream";
import { usePageVisibility } from "@/hooks/usePageVisibility";
import { useVoiceRecorder, type VoiceRecorderErrorKey } from "@/hooks/useVoiceRecorder";
import type {
CliAppInfo,
@@ -206,6 +207,8 @@ interface ThreadComposerProps {
onStop?: () => void;
surfaceRef?: Ref<HTMLDivElement>;
onTranscribeAudio?: (dataUrl: string, options?: { durationMs?: number }) => Promise<string>;
/** Unix seconds from server; turn elapsed timer above input while set. */
runStartedAt?: number | null;
/** Sustained objective for this chat (WebSocket ``goal_state``). */
goalState?: GoalStateWsPayload;
workspaceScope?: WorkspaceScopePayload | null;
@@ -214,7 +217,6 @@ interface ThreadComposerProps {
workspaceControls?: WorkspacesPayload["controls"] | null;
workspaceScopeDisabled?: boolean;
workspaceError?: string | null;
onPickWorkspaceFolder?: () => Promise<string | null>;
onWorkspaceScopeChange?: (scope: WorkspaceScopePayload) => void;
pendingQueueKey?: string | null;
transcriptionProvider?: string | null;
@@ -692,38 +694,63 @@ function mcpPresetMentionPayload(preset: McpPresetInfo): OutboundMcpPresetMentio
};
}
function GoalStateStrip({
function RunPulseIcon() {
return (
<span className="run-pulse-icon relative flex h-4 w-4 shrink-0 items-center justify-center" aria-hidden>
<span className="run-pulse-icon__ring" />
<span className="run-pulse-icon__dot" />
</span>
);
}
function RunElapsedStrip({
startedAt,
goalState,
}: {
startedAt: number | null;
goalState?: GoalStateWsPayload;
}) {
const { t } = useTranslation();
const pageVisible = usePageVisibility();
const [goalPanelOpen, setGoalPanelOpen] = useState(false);
const showTimer = startedAt != null;
const stripLabel = goalStateStripPreview(goalState, t);
const active = !!stripLabel?.trim();
const showGoal = !!stripLabel?.trim();
const active = showTimer || showGoal;
const [, setTick] = useState(0);
const stripWrapperRef = useRef<HTMLDivElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const expandToggleRef = useRef<HTMLButtonElement>(null);
const stripSnapshotRef = useRef<{
startedAt: number | null;
goalState?: GoalStateWsPayload;
stripLabel: string | null;
} | null>(null);
const [panelMaxPx, setPanelMaxPx] = useState(280);
if (active) {
stripSnapshotRef.current = { goalState, stripLabel };
stripSnapshotRef.current = { startedAt, goalState, stripLabel };
}
useEffect(() => {
if (!active) setGoalPanelOpen(false);
}, [active]);
useEffect(() => {
if (startedAt == null || !pageVisible) return;
setTick((n) => n + 1);
const id = window.setInterval(() => setTick((n) => n + 1), 1000);
return () => window.clearInterval(id);
}, [pageVisible, startedAt]);
const display = active
? { goalState, stripLabel }
? { startedAt, goalState, stripLabel }
: stripSnapshotRef.current;
const displayStartedAt = display?.startedAt ?? null;
const displayGoalState = display?.goalState;
const displayStripLabel = display?.stripLabel ?? null;
const displayShowTimer = displayStartedAt != null;
const displayShowGoal = !!displayStripLabel?.trim();
const objectiveFull = displayGoalState?.objective?.trim() ?? "";
const summaryFull = displayGoalState?.ui_summary?.trim() ?? "";
@@ -791,11 +818,17 @@ function GoalStateStrip({
};
}, [goalPanelOpen]);
if (!display) return null;
const elapsed =
displayStartedAt != null ? Math.max(0, Math.floor(Date.now() / 1000 - displayStartedAt)) : 0;
const m = Math.floor(elapsed / 60);
const sec = elapsed % 60;
const shortElapsed = m > 0 ? `${m}:${sec.toString().padStart(2, "0")}` : `${sec}s`;
const timerTitle = displayShowTimer
? t("thread.composer.runRuntimeTitle", { elapsed: shortElapsed })
: null;
const ariaLabel = displayStripLabel
? t("thread.composer.goalStateStrip", { label: displayStripLabel })
: t("thread.composer.goalStateFallback");
const ariaParts = [timerTitle, displayShowGoal ? displayStripLabel : null].filter(Boolean);
const ariaLabel = ariaParts.join(" · ");
return (
<div
@@ -804,11 +837,6 @@ function GoalStateStrip({
data-composer-status-drawer=""
data-state={active ? "open" : "closed"}
aria-hidden={active ? undefined : true}
onTransitionEnd={(event) => {
if (active || event.target !== event.currentTarget) return;
stripSnapshotRef.current = null;
setTick((n) => n + 1);
}}
>
{goalPanelOpen && canExpandGoal && markdownBody ? (
<div
@@ -862,9 +890,19 @@ function GoalStateStrip({
role="status"
aria-label={ariaLabel}
>
{displayShowTimer ? (
<RunPulseIcon />
) : (
<Target className="h-4 w-4 shrink-0 text-primary/75" aria-hidden />
)}
<span className="flex min-w-0 flex-1 items-center gap-1.5 text-[12px] font-medium text-foreground/75">
{displayStripLabel ? (
{timerTitle ? <span className="shrink-0">{timerTitle}</span> : null}
{timerTitle && displayShowGoal ? (
<span className="shrink-0 text-muted-foreground/45" aria-hidden>
·
</span>
) : null}
{displayShowGoal ? (
<span className="truncate">
{t("thread.composer.goalStateStrip", { label: displayStripLabel })}
</span>
@@ -924,6 +962,7 @@ export function ThreadComposer({
onStop,
surfaceRef,
onTranscribeAudio,
runStartedAt = null,
goalState,
workspaceScope = null,
workspaceControlsHidden = false,
@@ -931,7 +970,6 @@ export function ThreadComposer({
workspaceControls = null,
workspaceScopeDisabled = false,
workspaceError = null,
onPickWorkspaceFolder,
onWorkspaceScopeChange,
pendingQueueKey = null,
transcriptionProvider = null,
@@ -2246,8 +2284,8 @@ export function ThreadComposer({
className={cn(
"thread-composer-surface group/composer relative mx-auto flex w-full flex-col overflow-visible transition-all duration-200",
isHero
? "max-w-[58rem] rounded-prominent bg-muted/30 focus-within:bg-muted/50 dark:bg-card dark:focus-within:bg-white/[0.06]"
: "max-w-[49.5rem] rounded-panel bg-muted/30 focus-within:bg-muted/50 dark:bg-card dark:focus-within:bg-white/[0.06]",
? "max-w-[58rem] rounded-[28px] bg-muted/30 focus-within:bg-muted/50 dark:bg-card dark:focus-within:bg-white/[0.06]"
: "max-w-[49.5rem] rounded-[22px] bg-muted/30 focus-within:bg-muted/50 dark:bg-card dark:focus-within:bg-white/[0.06]",
interactionDisabled && "opacity-60",
sessionDragPreview && "ring-1 ring-primary/25",
isDragging && "ring-2 ring-primary/40 motion-reduce:ring-0 motion-reduce:border-primary",
@@ -2330,7 +2368,7 @@ export function ThreadComposer({
</button>
</div>
) : null}
<GoalStateStrip goalState={goalState} />
<RunElapsedStrip startedAt={runStartedAt} goalState={goalState} />
<div className="relative">
{hasMentionDecorations ? (
<ComposerCliMentionOverlay
@@ -2562,7 +2600,6 @@ export function ThreadComposer({
defaultScope={workspaceDefaultScope}
controls={workspaceControls}
error={workspaceError}
onPickFolder={onPickWorkspaceFolder}
onChange={onWorkspaceScopeChange}
/>
</div>
@@ -2610,7 +2647,7 @@ function QueuedPromptStack({
role="group"
data-state="enter"
className={cn(
"composer-status-strip relative z-20 mx-3 mt-3 overflow-hidden rounded-floating",
"composer-status-strip relative z-20 mx-3 mt-3 overflow-hidden rounded-[18px]",
"border border-black/[0.05] bg-popover/90 p-1.5",
"shadow-[0_10px_28px_rgba(15,23,42,0.07)] backdrop-blur-md",
"dark:border-white/[0.08] dark:bg-popover/90 dark:shadow-[0_14px_34px_rgba(0,0,0,0.30)]",
@@ -2688,7 +2725,7 @@ function QueuedPromptRow({
}}
onDragEnd={onDragEnd}
className={cn(
"queued-prompt-row group/queued flex min-h-8 items-center gap-1.5 rounded-control px-2 py-0.5",
"queued-prompt-row group/queued flex min-h-8 items-center gap-1.5 rounded-[12px] px-2 py-0.5",
"text-[13px] transition-colors hover:bg-muted/55 dark:hover:bg-white/[0.055]",
isHero && "text-[13.5px]",
)}
@@ -2973,7 +3010,7 @@ function MentionCandidateLogo({
return (
<span
className={cn(
"flex h-5 w-5 shrink-0 items-center justify-center overflow-hidden rounded-compact",
"flex h-5 w-5 shrink-0 items-center justify-center overflow-hidden rounded-[5px]",
selected ? "bg-background/55" : "bg-transparent",
)}
>
@@ -2991,7 +3028,7 @@ function MentionCandidateLogo({
}
return (
<span
className="flex h-5 w-5 shrink-0 items-center justify-center rounded-compact text-[7.5px] font-semibold text-white"
className="flex h-5 w-5 shrink-0 items-center justify-center rounded-[5px] text-[7.5px] font-semibold text-white"
style={{ backgroundColor: color }}
>
{candidate.initials}
@@ -3135,7 +3172,7 @@ function AttachmentChip({
return (
<div
className={cn(
"group relative flex items-center gap-2 rounded-control border px-2 py-1.5",
"group relative flex items-center gap-2 rounded-[12px] border px-2 py-1.5",
"transition-colors motion-reduce:transition-none",
tone,
)}
@@ -11,9 +11,6 @@ interface ThreadMessagesProps {
temporary?: boolean;
/** When true, agent turn still in flight — keeps activity timeline expanded. */
isStreaming?: boolean;
activeTurnId?: string | null;
/** Optimistic or canonical active-turn start, in unix seconds. */
runStartedAt?: number | null;
hiddenUserMessageCount?: number;
cliApps?: CliAppInfo[];
mcpPresets?: McpPresetInfo[];
@@ -56,8 +53,6 @@ export function ThreadMessages({
messages,
temporary = false,
isStreaming = false,
activeTurnId = null,
runStartedAt = null,
hiddenUserMessageCount = 0,
cliApps = [],
mcpPresets = [],
@@ -79,16 +74,6 @@ export function ThreadMessages({
() => isStreaming ? currentActivityClusterIndices(units) : new Set<number>(),
[isStreaming, units],
);
const pendingTurn = useMemo(
() => pendingTurnProjection(messages, activeTurnId),
[activeTurnId, messages],
);
const pendingActivity = (
isStreaming
&& liveActivityClusterIndices.size === 0
&& pendingTurn !== null
&& !pendingTurn.hasVisibleOutput
) ? pendingTurn : null;
const unitKeys = useMemo(() => unitKeysForDisplay(units), [units]);
let nextUserIndex = hiddenUserMessageCount;
@@ -151,68 +136,10 @@ export function ThreadMessages({
/>
);
})}
{pendingActivity ? (
<div className={units.length > 0 ? "mt-5" : undefined}>
<AgentActivityCluster
messages={[]}
isTurnStreaming
hasBodyBelow={false}
startedAtMs={
runStartedAt != null
? runStartedAt * 1000
: pendingActivity.startedAtMs
}
/>
</div>
) : null}
</div>
);
}
interface PendingTurnProjection {
startedAtMs?: number;
hasVisibleOutput: boolean;
}
function pendingTurnProjection(
messages: UIMessage[],
activeTurnId: string | null,
): PendingTurnProjection | null {
let promptIndex = -1;
for (let index = messages.length - 1; index >= 0; index -= 1) {
const message = messages[index];
if (
message.role === "user"
&& message.deliveryStatus !== "failed"
&& (activeTurnId === null || message.turnId === activeTurnId)
) {
promptIndex = index;
break;
}
}
if (promptIndex < 0) return null;
const prompt = messages[promptIndex];
const hasVisibleOutput = messages.slice(promptIndex + 1).some((message) => {
if (message.role === "user") return false;
if (activeTurnId && message.turnId && message.turnId !== activeTurnId) return false;
return (
message.content.trim().length > 0
|| !!message.reasoning?.trim()
|| !!message.reasoningStreaming
|| message.kind === "trace"
|| !!message.media?.length
);
});
return {
...(typeof prompt.createdAt === "number" && Number.isFinite(prompt.createdAt)
? { startedAtMs: prompt.createdAt }
: {}),
hasVisibleOutput,
};
}
interface ThreadDisplayUnitProps {
unit: DisplayUnit;
marginTop: string;
+2 -15
View File
@@ -661,14 +661,6 @@ export function ThreadShell({
forkBoundaryMessageCount,
} = useSessionHistory(historyKey);
const { client, getToken, ingressLimits, modelName, token } = useClient();
const pickWorkspaceFolder = useCallback(async (): Promise<string | null> => {
const response = await client.requestMutation<{ path: unknown }>(
"workspace.pick_folder",
{},
300_000,
);
return typeof response.path === "string" ? response.path : null;
}, [client]);
const [fallbackModelName, setFallbackModelName] = useState<string | null>(null);
const [booting, setBooting] = useState(false);
const [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]);
@@ -1458,6 +1450,7 @@ export function ThreadShell({
skills={skills}
onStop={stop}
onTranscribeAudio={transcribeAudio}
runStartedAt={currentRunStartedAt}
goalState={currentGoalState}
workspaceScope={workspaceScope}
workspaceControlsHidden={temporary}
@@ -1465,9 +1458,6 @@ export function ThreadShell({
workspaceControls={workspaceControls}
workspaceScopeDisabled={workspaceScopeDisabled}
workspaceError={workspaceError}
onPickWorkspaceFolder={
workspaceControls?.can_pick_folder ? pickWorkspaceFolder : undefined
}
onWorkspaceScopeChange={onWorkspaceScopeChange}
pendingQueueKey={temporary ? null : chatId}
transcriptionProvider={settingsSnapshot?.transcription?.provider}
@@ -1504,6 +1494,7 @@ export function ThreadShell({
sessions={mentionSessions}
skills={skills}
surfaceRef={composerSurfaceRef}
runStartedAt={currentRunStartedAt}
onTranscribeAudio={transcribeAudio}
goalState={currentGoalState}
workspaceScope={workspaceScope}
@@ -1512,9 +1503,6 @@ export function ThreadShell({
workspaceControls={workspaceControls}
workspaceScopeDisabled={workspaceScopeDisabled}
workspaceError={workspaceError}
onPickWorkspaceFolder={
workspaceControls?.can_pick_folder ? pickWorkspaceFolder : undefined
}
onWorkspaceScopeChange={onWorkspaceScopeChange}
transcriptionProvider={settingsSnapshot?.transcription?.provider}
ingressLimits={ingressLimits}
@@ -1577,7 +1565,6 @@ export function ThreadShell({
messages={displayMessages}
temporary={temporary}
isStreaming={turnActive}
runStartedAt={currentRunStartedAt}
emptyState={emptyState}
composer={composerPortalTarget === undefined ? composer : null}
activeTurnId={viewportTurnId}
+4 -74
View File
@@ -37,8 +37,6 @@ interface ThreadViewportProps {
messages: UIMessage[];
temporary?: boolean;
isStreaming: boolean;
/** Optimistic or canonical start time for the active turn, in unix seconds. */
runStartedAt?: number | null;
composer?: ReactNode;
emptyState?: ReactNode;
scrollToBottomSignal?: number;
@@ -66,9 +64,6 @@ const DEFAULT_SCROLL_BUTTON_BOTTOM_PX = 192;
const EXTERNAL_COMPOSER_SCROLL_BUTTON_BOTTOM_PX = 16;
const SCROLL_BUTTON_COMPOSER_GAP_PX = 16;
const SOFT_KEYBOARD_MIN_INSET_PX = 80;
const SESSION_HANDOFF_EXIT_DURATION_MS = 80;
const SESSION_HANDOFF_ENTER_DURATION_MS = 140;
const SESSION_HANDOFF_OPACITY = 0.82;
export const INITIAL_HISTORY_WINDOW = 160;
export const HISTORY_WINDOW_INCREMENT = 120;
@@ -109,13 +104,6 @@ function isThreadDisclosureTarget(target: EventTarget | null): boolean {
&& target.closest("[data-thread-disclosure]") !== null;
}
function isKeyboardControl(element: Element | null): boolean {
return element instanceof HTMLElement
&& element.closest(
"button, a[href], select, [role='button'], [role='menuitem'], [role='option']",
) !== null;
}
type ThreadScrollDirection = "backward" | "forward";
const KEYBOARD_SCROLL_DIRECTIONS: Readonly<
@@ -173,7 +161,6 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
messages,
temporary = false,
isStreaming,
runStartedAt = null,
composer,
emptyState,
scrollToBottomSignal = 0,
@@ -200,12 +187,9 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
const contentRef = useRef<HTMLDivElement>(null);
const messageRegionRef = useRef<HTMLDivElement>(null);
const messageContentRef = useRef<HTMLDivElement>(null);
const emptyStateRef = useRef<HTMLDivElement>(null);
const composerDockRef = useRef<HTMLDivElement>(null);
const bottomRef = useRef<HTMLDivElement>(null);
const lastConversationKeyRef = useRef<string | null>(conversationKey);
const conversationHandoffPendingRef = useRef(false);
const conversationHandoffAnimationRef = useRef<Animation | null>(null);
const pendingConversationScrollRef = useRef(true);
const pendingPromptJumpRef = useRef<string | null>(null);
const restoreScrollAfterPrependRef =
@@ -438,27 +422,11 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
useLayoutEffect(() => {
if (lastConversationKeyRef.current === conversationKey) return;
lastConversationKeyRef.current = conversationKey;
conversationHandoffAnimationRef.current?.cancel();
conversationHandoffAnimationRef.current = null;
conversationHandoffPendingRef.current = true;
pendingConversationScrollRef.current = true;
threadMotionRef.current?.reset();
setAtBottom(true);
setVisibleMessageCount(INITIAL_HISTORY_WINDOW);
const surface = hasMessages ? messageRegionRef.current : emptyStateRef.current;
const reduceMotion = typeof window.matchMedia === "function"
&& window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!surface || reduceMotion || typeof surface.animate !== "function") return;
conversationHandoffAnimationRef.current = surface.animate(
[{ opacity: 1 }, { opacity: SESSION_HANDOFF_OPACITY }],
{
duration: SESSION_HANDOFF_EXIT_DURATION_MS,
easing: "cubic-bezier(0.2, 0, 0, 1)",
fill: "forwards",
},
);
}, [conversationKey, hasMessages]);
}, [conversationKey]);
useLayoutEffect(() => {
if (!conversationReady) {
@@ -545,41 +513,11 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
scrollToBottom,
]);
useLayoutEffect(() => {
if (!conversationReady || !conversationHandoffPendingRef.current) return;
conversationHandoffPendingRef.current = false;
conversationHandoffAnimationRef.current?.cancel();
conversationHandoffAnimationRef.current = null;
const surface = hasMessages ? messageRegionRef.current : emptyStateRef.current;
const reduceMotion = typeof window.matchMedia === "function"
&& window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!surface || reduceMotion || typeof surface.animate !== "function") return;
const animation = surface.animate(
[{ opacity: SESSION_HANDOFF_OPACITY }, { opacity: 1 }],
{
duration: SESSION_HANDOFF_ENTER_DURATION_MS,
easing: "cubic-bezier(0.2, 0, 0, 1)",
},
);
conversationHandoffAnimationRef.current = animation;
const clearAnimation = () => {
if (conversationHandoffAnimationRef.current === animation) {
conversationHandoffAnimationRef.current = null;
}
};
animation.onfinish = clearAnimation;
animation.oncancel = clearAnimation;
}, [conversationReady, hasMessages]);
useLayoutEffect(() => {
threadMotionRef.current?.invalidateGeometry();
}, [composer, hasMessages, visibleMessages.length]);
useEffect(() => () => {
conversationHandoffAnimationRef.current?.cancel();
threadMotionRef.current?.dispose();
}, []);
useEffect(() => () => threadMotionRef.current?.dispose(), []);
useLayoutEffect(() => {
const el = scrollRef.current;
@@ -592,13 +530,10 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
const invalidateGeometry = () => {
threadMotionRef.current?.invalidateGeometry();
};
const reconcileObservedGeometry = () => {
threadMotionRef.current?.reconcileObservedGeometry();
};
reconcileObservedGeometry();
invalidateGeometry();
const observer = typeof ResizeObserver === "undefined"
? null
: new ResizeObserver(reconcileObservedGeometry);
: new ResizeObserver(invalidateGeometry);
observer?.observe(el);
if (content) observer?.observe(content);
if (messageRegion) observer?.observe(messageRegion);
@@ -688,7 +623,6 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
yieldCameraToUser();
return;
}
if (isKeyboardControl(event.target as Element | null)) return;
handleDirectionalInput(keyboardScrollDirection(event));
};
el.addEventListener("scroll", handleScroll, { passive: true });
@@ -756,8 +690,6 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
messages={visibleMessages}
temporary={temporary}
isStreaming={isStreaming}
activeTurnId={activeTurnId}
runStartedAt={runStartedAt}
hiddenUserMessageCount={hiddenUserMessageCount}
cliApps={cliApps}
mcpPresets={mcpPresets}
@@ -772,8 +704,6 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
</div>
) : (
<div
ref={emptyStateRef}
data-testid="thread-empty-region"
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",
@@ -51,7 +51,6 @@ export function WorkspaceProjectPicker({
defaultScope,
controls,
error,
onPickFolder,
onChange,
}: {
isHero: boolean;
@@ -62,7 +61,6 @@ export function WorkspaceProjectPicker({
defaultScope: WorkspaceScopePayload | null;
controls: WorkspacesPayload["controls"] | null;
error?: string | null;
onPickFolder?: () => Promise<string | null>;
onChange?: (scope: WorkspaceScopePayload) => void;
}) {
const { t } = useTranslation();
@@ -81,7 +79,7 @@ export function WorkspaceProjectPicker({
&& !!defaultScope
&& !!onChange
&& controls?.can_change_project !== false;
const pickFolder = getRuntimeHost().pickFolder ?? onPickFolder;
const pickFolder = getRuntimeHost().pickFolder;
const nativeProjectPicker = !!pickFolder;
useEffect(() => {
@@ -221,7 +219,7 @@ export function WorkspaceProjectPicker({
"flex min-h-[48px] w-full cursor-default gap-3 px-3 py-2.5 focus:bg-muted/55",
)}
>
<span className="grid h-8 w-8 shrink-0 place-items-center rounded-control bg-muted text-foreground/80">
<span className="grid h-8 w-8 shrink-0 place-items-center rounded-[12px] bg-muted text-foreground/80">
<Folder className="h-4 w-4" />
</span>
<span className="min-w-0 flex-1">
@@ -328,7 +326,7 @@ export function WorkspaceAccessMenu({
aria-label={accessAriaLabel}
title={accessLabel}
className={cn(
"thread-composer-access touch-target min-w-0 max-w-[min(12.5rem,42vw)] whitespace-nowrap rounded-control border border-transparent font-semibold shadow-none",
"thread-composer-access touch-target min-w-0 max-w-[min(12.5rem,42vw)] whitespace-nowrap rounded-[10px] border border-transparent font-semibold shadow-none",
isHero ? "h-8 px-2.5 text-[12px]" : "h-9 px-3 text-[12.5px]",
isFull
? "bg-transparent text-orange-600 hover:bg-orange-500/8 dark:text-orange-300 dark:hover:bg-orange-400/10"
@@ -12,7 +12,6 @@ interface ThinkingReasoningShellProps {
contentRef: Ref<HTMLDivElement>;
fadeTop: boolean;
fadeBottom: boolean;
hasDetails?: boolean;
onToggle: () => void;
onScroll: () => void;
}
@@ -26,7 +25,6 @@ export function ThinkingReasoningShell({
contentRef,
fadeTop,
fadeBottom,
hasDetails = true,
onToggle,
onScroll,
}: ThinkingReasoningShellProps) {
@@ -35,7 +33,6 @@ export function ThinkingReasoningShell({
className="flex w-full max-w-[45rem] animate-in flex-col fade-in duration-300 motion-reduce:animate-none"
data-state={active ? "thinking" : "done"}
>
{hasDetails ? (
<button
type="button"
data-thread-disclosure=""
@@ -70,25 +67,7 @@ export function ThinkingReasoningShell({
/>
</span>
</button>
) : (
<div
className="inline-flex min-h-5 items-center self-start"
role="status"
aria-label={label}
aria-live={active ? "polite" : undefined}
>
<span
className={cn(
"min-w-0 truncate text-[13px] font-medium leading-[18px] text-muted-foreground/70",
active && "animate-pulse motion-reduce:animate-none",
)}
>
{label}
</span>
</div>
)}
{hasDetails ? (
<div
{...(!expanded ? { inert: "" } : {})}
aria-hidden={!expanded}
@@ -128,7 +107,6 @@ export function ThinkingReasoningShell({
) : null}
</div>
</div>
) : null}
</div>
);
}
@@ -61,7 +61,7 @@ function WebFavicon({ host, active }: { host: string; active: boolean }) {
<img
src={logoUrl}
alt=""
className={`h-4 w-4 shrink-0 rounded-mark object-contain${active ? " animate-pulse" : ""}`}
className={`h-4 w-4 shrink-0 rounded-[3px] object-contain${active ? " animate-pulse" : ""}`}
decoding="async"
loading="lazy"
referrerPolicy="no-referrer"
+4 -13
View File
@@ -147,10 +147,10 @@ function defaultScheduler(): ThreadMotionScheduler {
}
/**
* Owns the policy that turns layout events into automatic tail pinning or
* explicit camera navigation. Discrete notifications are coalesced into one
* display frame. ResizeObserver deliveries reconcile immediately because they
* already carry the browser's authoritative layout and run before paint.
* Owns the policy that turns discrete layout events into automatic tail
* pinning or explicit camera navigation. Callers only invalidate geometry;
* one display frame coalesces those notifications and reads the authoritative
* layout before applying either policy.
*/
export class ThreadMotionCoordinator {
private readonly camera: ThreadMotionCamera;
@@ -240,15 +240,6 @@ export class ThreadMotionCoordinator {
this.measurementFrameId = this.scheduler.request(this.flushGeometry);
}
reconcileObservedGeometry(): void {
if (this.measurementFrameId !== null) {
this.scheduler.cancel(this.measurementFrameId);
this.measurementFrameId = null;
}
this.geometryDirty = true;
this.flushGeometry();
}
handleComposerInput(): void {
// Input and protocol completion can arrive in either order. Remember
// editing that starts just before turn_end so the completion drawer
+2 -2
View File
@@ -37,7 +37,7 @@ const AlertDialogContent = React.forwardRef<
ref={ref}
className={cn(
modalSurfaceClassName,
"grid w-full max-w-lg origin-center gap-4 rounded-modal p-6 duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
"grid w-full max-w-lg origin-center gap-4 rounded-[22px] p-6 duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
className,
)}
{...props}
@@ -64,7 +64,7 @@ const AlertDialogFooter = ({
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className,
)}
{...props}
+3 -3
View File
@@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-control text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
@@ -21,8 +21,8 @@ const buttonVariants = cva(
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 px-3",
lg: "h-11 px-8",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-9 w-9",
},
},
+2 -2
View File
@@ -45,7 +45,7 @@ const DialogContent = React.forwardRef<
ref={ref}
className={cn(
modalSurfaceClassName,
"grid w-full max-w-lg origin-center gap-4 rounded-modal p-6 duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
"grid w-full max-w-lg origin-center gap-4 rounded-[22px] p-6 duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
className,
)}
{...props}
@@ -84,7 +84,7 @@ const DialogFooter = ({
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className,
)}
{...props}
+2 -2
View File
@@ -2,7 +2,7 @@ export const floatingSurfaceElevationClassName =
"bg-popover text-popover-foreground shadow-[0_8px_24px_rgba(15,23,42,0.10)] dark:shadow-[0_12px_28px_rgba(0,0,0,0.32)]";
export const floatingSurfaceVisualClassName =
`rounded-floating p-1.5 ${floatingSurfaceElevationClassName}`;
`rounded-[18px] p-1.5 ${floatingSurfaceElevationClassName}`;
export const modalOverlayClassName =
"fixed inset-0 z-50 bg-black/45 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0";
@@ -17,7 +17,7 @@ export const floatingSurfaceMotionClassName =
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0";
export const floatingItemClassName =
"relative flex min-h-8 select-none items-center gap-2 rounded-control px-2.5 py-2 text-[13px] outline-none transition-colors [&>svg]:h-4 [&>svg]:w-4 [&>svg]:shrink-0";
"relative flex min-h-8 select-none items-center gap-2 rounded-[12px] px-2.5 py-2 text-[13px] outline-none transition-colors [&>svg]:h-4 [&>svg]:w-4 [&>svg]:shrink-0";
export const floatingItemFocusClassName =
"focus:bg-foreground/[0.055] focus:text-foreground dark:focus:bg-white/[0.08]";
+1 -1
View File
@@ -11,7 +11,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
<input
type={type}
className={cn(
"flex h-10 w-full rounded-control border border-input bg-background px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
formControlFocusClassName,
className,
)}
+1 -1
View File
@@ -10,7 +10,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
return (
<textarea
className={cn(
"flex min-h-[60px] w-full rounded-control border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
"flex min-h-[60px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
formControlFocusClassName,
className,
)}
+1 -1
View File
@@ -18,7 +18,7 @@ const TooltipContent = React.forwardRef<
sideOffset={sideOffset}
className={cn(
floatingSurfaceElevationClassName,
"z-50 overflow-hidden rounded-control px-3 py-1.5 text-xs animate-in fade-in-0 zoom-in-95",
"z-50 overflow-hidden rounded-[10px] px-3 py-1.5 text-xs animate-in fade-in-0 zoom-in-95",
className,
)}
{...props}
@@ -220,9 +220,7 @@ export function PaneWorkbench({
const gridRef = useRef<HTMLDivElement | null>(null);
const paneRefs = useRef(new Map<string, HTMLElement>());
const lastRectsRef = useRef(new Map<string, DOMRect>());
const lastElementRectsRef = useRef(new Map<HTMLElement, DOMRect>());
const pendingRectsRef = useRef<Map<string, DOMRect> | null>(null);
const pendingElementRectsRef = useRef<Map<HTMLElement, DOMRect> | null>(null);
const animationsRef = useRef(new Map<string, Animation>());
const sourceSplitRatiosKey = splitRatios.join("\u0000");
const [previewSplitRatios, setPreviewSplitRatios] = useState(splitRatios);
@@ -286,36 +284,24 @@ export function PaneWorkbench({
return rects;
}, []);
const measurePaneElements = useCallback(() => {
const rects = new Map<HTMLElement, DOMRect>();
for (const element of paneRefs.current.values()) {
if (!element.hidden) rects.set(element, element.getBoundingClientRect());
}
return rects;
}, []);
const captureLayout = useCallback(() => {
pendingRectsRef.current = measurePanes();
pendingElementRectsRef.current = measurePaneElements();
for (const animation of animationsRef.current.values()) animation.cancel();
animationsRef.current.clear();
}, [measurePaneElements, measurePanes]);
}, [measurePanes]);
useLayoutEffect(() => {
const previousRects = pendingRectsRef.current ?? lastRectsRef.current;
const previousElementRects = pendingElementRectsRef.current ?? lastElementRectsRef.current;
pendingRectsRef.current = null;
pendingElementRectsRef.current = null;
const nextRects = measurePanes();
const nextElementRects = measurePaneElements();
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;
const previousRect = previousRects.get(key) ?? previousElementRects.get(element);
if (!previousRect) {
if (previousRects.size === 0 || typeof element.animate !== "function") continue;
const animation = element.animate(
@@ -370,8 +356,7 @@ export function PaneWorkbench({
}
}
lastRectsRef.current = nextRects;
lastElementRectsRef.current = nextElementRects;
}, [activePaneKey, effectiveLayout, measurePaneElements, measurePanes, paneOrder]);
}, [activePaneKey, effectiveLayout, measurePanes, paneOrder]);
useEffect(() => () => {
for (const animation of animationsRef.current.values()) animation.cancel();
+58 -11
View File
@@ -38,15 +38,6 @@
--temporary-foreground: 17 88% 32%;
--temporary-border: 17 88% 40%;
--radius: 0.4375rem;
/* Shared shape scale: marks, compact content, controls, and progressively larger surfaces. */
--radius-mark: 0.25rem;
--radius-compact: 0.5rem;
--radius-control: 0.75rem;
--radius-floating: 1.125rem;
--radius-panel: 1.375rem;
--radius-modal: 1.375rem;
--radius-prominent: 1.75rem;
--radius-pill: 9999px;
--sidebar: 40 8% 96.8%;
--sidebar-foreground: 0 0% 3.9%;
--sidebar-selected: 40 1% 89.4%;
@@ -130,7 +121,7 @@
*::-webkit-scrollbar-thumb {
background-color: var(--scrollbar-thumb);
border-radius: var(--radius-pill);
border-radius: 9999px;
}
*::-webkit-scrollbar-thumb:hover {
@@ -487,6 +478,50 @@
transform: translateY(0);
}
}
@keyframes run-pulse-dot {
0%,
100% {
transform: scale(0.9);
opacity: 0.76;
}
50% {
transform: scale(1.08);
opacity: 1;
}
}
@keyframes run-pulse-ring {
0% {
transform: scale(0.42);
opacity: 0.34;
}
100% {
transform: scale(1.28);
opacity: 0;
}
}
.run-pulse-icon {
color: hsl(204 82% 46%);
}
.run-pulse-icon__ring,
.run-pulse-icon__dot {
display: block;
border-radius: 999px;
pointer-events: none;
}
.run-pulse-icon__ring {
position: absolute;
height: 12px;
width: 12px;
background: hsl(204 82% 46% / 0.22);
animation: run-pulse-ring 1.55s ease-out infinite;
}
.run-pulse-icon__dot {
height: 6px;
width: 6px;
background: currentColor;
box-shadow: 0 0 0 1px hsl(204 82% 46% / 0.14);
animation: run-pulse-dot 1.55s ease-in-out infinite;
}
@keyframes queued-prompt-row-enter {
0% {
opacity: 0;
@@ -517,6 +552,18 @@
.thread-layout {
transition-duration: 0.01ms;
}
.run-pulse-icon,
.run-pulse-icon * {
animation: none;
}
.run-pulse-icon__ring {
opacity: 0.18;
transform: scale(1);
}
.run-pulse-icon__dot {
opacity: 1;
transform: scale(1);
}
.queued-prompt-row {
animation: none;
}
@@ -576,7 +623,7 @@
}
.scrollbar-thin::-webkit-scrollbar-thumb {
background-color: hsl(var(--muted-foreground) / 0.4);
border-radius: var(--radius-pill);
border-radius: 9999px;
}
.scrollbar-track-transparent {
scrollbar-gutter: stable;
+4 -4
View File
@@ -1451,16 +1451,16 @@
"workbench": {
"aria": "Conversation workbench",
"panes": "Panes",
"tabAria": "Group: {{title}}",
"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",
"renameGroupTitle": "Rename group",
"renameGroupDescription": "Give this group a name.",
"renameGroupPlaceholder": "Group name",
"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",
+4 -4
View File
@@ -1438,16 +1438,16 @@
"workbench": {
"aria": "Área de conversaciones",
"panes": "Paneles",
"tabAria": "Grupo: {{title}}",
"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",
"renameGroupTitle": "Renombrar grupo",
"renameGroupDescription": "Ponle un nombre a este grupo.",
"renameGroupPlaceholder": "Nombre del grupo",
"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",
+4 -4
View File
@@ -1437,16 +1437,16 @@
"workbench": {
"aria": "Espace de conversations",
"panes": "Volets",
"tabAria": "Groupe : {{title}}",
"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",
"renameGroupTitle": "Renommer le groupe",
"renameGroupDescription": "Donnez un nom à ce groupe.",
"renameGroupPlaceholder": "Nom du groupe",
"renameTabTitle": "Renommer longlet",
"renameTabDescription": "Donnez un nom à cet onglet pour organiser ses volets.",
"renameTabPlaceholder": "Nom de longlet",
"dissolveTab": "Dissoudre le groupe",
"layout": "Disposition des volets",
"addPane": "Ajouter un volet",
+4 -4
View File
@@ -1437,16 +1437,16 @@
"workbench": {
"aria": "Ruang kerja percakapan",
"panes": "Panel",
"tabAria": "Grup: {{title}}",
"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",
"renameGroupTitle": "Ganti nama grup",
"renameGroupDescription": "Beri nama untuk grup ini.",
"renameGroupPlaceholder": "Nama grup",
"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",
+4 -4
View File
@@ -1437,16 +1437,16 @@
"workbench": {
"aria": "会話ワークベンチ",
"panes": "ペイン",
"tabAria": "グループ{{title}}",
"tabAria": "タブ{{title}}",
"panesInTab": "{{title}} のペイン",
"collapseTabGroup": "{{title}} のペインを折りたたむ",
"expandTabGroup": "{{title}} のペインを展開する",
"dropPane": "{{pane}} を {{tab}} に移動",
"createGroup": "グループを作成",
"moveTo": "移動先",
"renameGroupTitle": "グループ名を変更",
"renameGroupDescription": "このグループに名前を付けます。",
"renameGroupPlaceholder": "グループ名",
"renameTabTitle": "タブ名を変更",
"renameTabDescription": "ペインを整理するため、このタブに名前を付けます。",
"renameTabPlaceholder": "タブ名",
"dissolveTab": "グループを解除",
"layout": "ペインレイアウト",
"addPane": "ペインを追加",
+4 -4
View File
@@ -1437,16 +1437,16 @@
"workbench": {
"aria": "대화 워크벤치",
"panes": "창",
"tabAria": "그룹: {{title}}",
"tabAria": ": {{title}}",
"panesInTab": "{{title}}의 창",
"collapseTabGroup": "{{title}}의 창 접기",
"expandTabGroup": "{{title}}의 창 펼치기",
"dropPane": "{{pane}}을(를) {{tab}}으로 이동",
"createGroup": "그룹 만들기",
"moveTo": "이동",
"renameGroupTitle": "그룹 이름 바꾸기",
"renameGroupDescription": "이 그룹에 이름을 지정하세요.",
"renameGroupPlaceholder": "그룹 이름",
"renameTabTitle": " 이름 바꾸기",
"renameTabDescription": "창을 정리할 수 있도록 이 탭에 이름을 지정하세요.",
"renameTabPlaceholder": " 이름",
"dissolveTab": "그룹 해제",
"layout": "창 레이아웃",
"addPane": "창 추가",
+4 -4
View File
@@ -1451,16 +1451,16 @@
"workbench": {
"aria": "Área de conversas",
"panes": "Painéis",
"tabAria": "Grupo: {{title}}",
"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",
"renameGroupTitle": "Renomear grupo",
"renameGroupDescription": "Dê um nome a este grupo.",
"renameGroupPlaceholder": "Nome do grupo",
"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",
+4 -4
View File
@@ -1437,16 +1437,16 @@
"workbench": {
"aria": "Không gian hội thoại",
"panes": "Khung",
"tabAria": "Nhóm: {{title}}",
"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",
"renameGroupTitle": "Đổi tên nhóm",
"renameGroupDescription": "Đặt tên cho nhóm này.",
"renameGroupPlaceholder": "Tên nhóm",
"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",
+4 -4
View File
@@ -1451,16 +1451,16 @@
"workbench": {
"aria": "会话工作台",
"panes": "窗格",
"tabAria": "分组{{title}}",
"tabAria": "标签页{{title}}",
"panesInTab": "{{title}} 中的窗格",
"collapseTabGroup": "折叠 {{title}} 中的窗格",
"expandTabGroup": "展开 {{title}} 中的窗格",
"dropPane": "将 {{pane}} 移入 {{tab}}",
"createGroup": "创建分组",
"moveTo": "移动到",
"renameGroupTitle": "重命名分组",
"renameGroupDescription": "为这个分组命名。",
"renameGroupPlaceholder": "分组名称",
"renameTabTitle": "重命名标签页",
"renameTabDescription": "为这个标签页命名,以便组织其中的窗格。",
"renameTabPlaceholder": "标签页名称",
"dissolveTab": "解散分组",
"layout": "窗格布局",
"addPane": "添加窗格",
+4 -4
View File
@@ -1437,16 +1437,16 @@
"workbench": {
"aria": "對話工作台",
"panes": "窗格",
"tabAria": "群組{{title}}",
"tabAria": "標籤頁{{title}}",
"panesInTab": "{{title}} 中的窗格",
"collapseTabGroup": "收合 {{title}} 中的窗格",
"expandTabGroup": "展開 {{title}} 中的窗格",
"dropPane": "將 {{pane}} 移入 {{tab}}",
"createGroup": "建立群組",
"moveTo": "移動到",
"renameGroupTitle": "重新命名群組",
"renameGroupDescription": "為這個群組命名。",
"renameGroupPlaceholder": "群組名稱",
"renameTabTitle": "重新命名分頁",
"renameTabDescription": "為這個分頁命名,以便整理其中的窗格。",
"renameTabPlaceholder": "分頁名稱",
"dissolveTab": "解散群組",
"layout": "窗格佈局",
"addPane": "新增窗格",
-1
View File
@@ -363,7 +363,6 @@ export interface WorkspacesPayload {
controls: {
can_change_project: boolean;
can_use_full_access: boolean;
can_pick_folder?: boolean;
};
}
+24 -36
View File
@@ -2040,20 +2040,18 @@ describe("App layout", () => {
act(() => {
for (const handler of runStatusHandlers) handler("chat-a", 12_345);
});
expect(within(sidebar).getByRole("img", { name: "Agent running" })).toBeInTheDocument();
expect(within(sidebar).getByTitle("Agent running")).toBeInTheDocument();
act(() => {
for (const handler of runStatusHandlers) handler("chat-a", null);
});
expect(within(sidebar).queryByRole("img", { name: "Agent running" }))
.not.toBeInTheDocument();
expect(within(sidebar).getByRole("img", { name: "New activity" })).toBeInTheDocument();
expect(within(sidebar).queryByTitle("Agent running")).not.toBeInTheDocument();
expect(within(sidebar).getByTitle("New activity")).toBeInTheDocument();
await act(async () => {
fireEvent.click(within(sidebar).getByRole("button", { name: /^Working chat$/ }));
});
expect(within(sidebar).queryByRole("img", { name: "New activity" }))
.not.toBeInTheDocument();
expect(within(sidebar).queryByTitle("New activity")).not.toBeInTheDocument();
});
it("does not show an updated dot later when the active session finishes", async () => {
@@ -2094,21 +2092,18 @@ describe("App layout", () => {
act(() => {
for (const handler of runStatusHandlers) handler("chat-a", 12_345);
});
expect(within(sidebar).getByRole("img", { name: "Agent running" })).toBeInTheDocument();
expect(within(sidebar).getByTitle("Agent running")).toBeInTheDocument();
act(() => {
for (const handler of runStatusHandlers) handler("chat-a", null);
});
expect(within(sidebar).queryByRole("img", { name: "Agent running" }))
.not.toBeInTheDocument();
expect(within(sidebar).queryByRole("img", { name: "New activity" }))
.not.toBeInTheDocument();
expect(within(sidebar).queryByTitle("Agent running")).not.toBeInTheDocument();
expect(within(sidebar).queryByTitle("New activity")).not.toBeInTheDocument();
await act(async () => {
fireEvent.click(within(sidebar).getByRole("button", { name: /^Other chat$/ }));
});
expect(within(sidebar).queryByRole("img", { name: "New activity" }))
.not.toBeInTheDocument();
expect(within(sidebar).queryByTitle("New activity")).not.toBeInTheDocument();
});
it("marks inactive sessions when a thread update arrives", async () => {
@@ -2143,14 +2138,13 @@ describe("App layout", () => {
for (const handler of sessionUpdateHandlers) handler("chat-b", "thread");
});
expect(within(sidebar).getByRole("img", { name: "New activity" })).toBeInTheDocument();
expect(within(sidebar).getByTitle("New activity")).toBeInTheDocument();
await act(async () => {
fireEvent.click(within(sidebar).getByRole("button", { name: /^Scheduled update target$/ }));
});
expect(within(sidebar).queryByRole("img", { name: "New activity" }))
.not.toBeInTheDocument();
expect(within(sidebar).queryByTitle("New activity")).not.toBeInTheDocument();
});
it("restores sidebar run indicators after a page reload", async () => {
@@ -2183,9 +2177,9 @@ describe("App layout", () => {
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
await waitFor(() =>
expect(within(sidebar).getByRole("img", { name: "Agent running" })).toBeInTheDocument(),
expect(within(sidebar).getByTitle("Agent running")).toBeInTheDocument(),
);
expect(within(sidebar).getByRole("img", { name: "New activity" })).toBeInTheDocument();
expect(within(sidebar).getByTitle("New activity")).toBeInTheDocument();
expect(attachSpy).toHaveBeenCalledWith("chat-a");
});
@@ -3099,16 +3093,19 @@ describe("App layout", () => {
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
const alphaTab = await within(sidebar).findByRole("button", { name: "Group: Alpha tab" });
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 alphaChild = within(alphaGroup).getByRole("button", { name: "Alpha child" });
const alphaRoot = within(alphaGroup).getByRole("button", { name: "Alpha tab" });
expect(alphaChild.compareDocumentPosition(alphaRoot) & Node.DOCUMENT_POSITION_FOLLOWING)
.toBeTruthy();
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 () => {
@@ -3218,7 +3215,7 @@ describe("App layout", () => {
statusHandlers.forEach((handler) => handler("open"));
});
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
expect(within(sidebar).queryByRole("button", { name: "Group: Solo pane" }))
expect(within(sidebar).queryByRole("button", { name: "Tab: Solo pane" }))
.not.toBeInTheDocument();
setSidebarStateSpy.mockClear();
@@ -3228,14 +3225,14 @@ describe("App layout", () => {
fireEvent.click(await screen.findByRole("menuitem", { name: "Create group" }));
const tabButton = await within(sidebar).findByRole("button", {
name: "Group: Solo pane",
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: "Group: Other pane" }))
expect(within(sidebar).queryByRole("button", { name: "Tab: Other pane" }))
.not.toBeInTheDocument();
await waitFor(() => expect(setSidebarStateSpy).toHaveBeenCalledWith(
expect.objectContaining({
@@ -3246,15 +3243,6 @@ describe("App layout", () => {
}),
}),
));
fireEvent.pointerDown(within(tabGroup).getByRole("button", {
name: "Topic actions for Solo pane",
}), { button: 0, ctrlKey: false });
fireEvent.click(await screen.findByRole("menuitem", { name: "Rename" }));
const renameDialog = await screen.findByRole("dialog", { name: "Rename group" });
expect(within(renameDialog).getByText("Give this group a name.")).toBeInTheDocument();
expect(within(renameDialog).getByPlaceholderText("Group name")).toHaveValue("Solo pane");
});
it("restores a created pane group from gateway state after remount", async () => {
@@ -3312,7 +3300,7 @@ describe("App layout", () => {
render(<App />);
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
const secondSidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
expect(await within(secondSidebar).findByRole("button", { name: "Group: Solo pane" }))
expect(await within(secondSidebar).findByRole("button", { name: "Tab: Solo pane" }))
.toBeInTheDocument();
});
+39 -382
View File
@@ -18,54 +18,13 @@ function session(overrides: Partial<ChatSummary>): ChatSummary {
};
}
function rect(top: number): DOMRect {
return {
x: 0,
y: top,
width: 240,
height: 32,
top,
right: 240,
bottom: top + 32,
left: 0,
toJSON: () => ({}),
};
}
describe("ChatList", () => {
const originalAnimate = HTMLElement.prototype.animate;
const originalGetBoundingClientRect = HTMLElement.prototype.getBoundingClientRect;
afterEach(() => {
HTMLElement.prototype.animate = originalAnimate;
HTMLElement.prototype.getBoundingClientRect = originalGetBoundingClientRect;
localStorage.removeItem("nanobot-webui.collapsed-pane-groups.v1");
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it("opens a conversation's existing actions from the row context menu", async () => {
const onTogglePin = vi.fn();
render(
<ChatList
sessions={[session({ chatId: "review", title: "Review the patch" })]}
activeKey="websocket:review"
onSelect={vi.fn()}
onRequestDelete={vi.fn()}
onTogglePin={onTogglePin}
onRequestRename={vi.fn()}
onToggleArchive={vi.fn()}
/>,
);
const row = screen.getByRole("button", { name: "Review the patch" })
.closest("[data-chat-row]")!;
fireEvent.contextMenu(row);
fireEvent.click(await screen.findByRole("menuitem", { name: "Pin" }));
expect(onTogglePin).toHaveBeenCalledWith("websocket:review");
});
it("keeps tab grouping out of drag protocols while exposing inactive panes as mention sources", () => {
render(
<ChatList
@@ -90,9 +49,7 @@ describe("ChatList", () => {
/>,
);
expect(screen.getByRole("button", { name: "Group: Root topic" }))
.toHaveAttribute("draggable", "false");
expect(screen.getByRole("button", { name: "Root topic" }))
expect(screen.getByRole("button", { name: "Tab: Root topic" }))
.toHaveAttribute("draggable", "false");
const pane = screen.getByRole("button", { name: "Research pane" });
expect(pane).toHaveAttribute("draggable", "true");
@@ -114,46 +71,6 @@ describe("ChatList", () => {
expect(document.querySelector("[data-pane-snap-slot]")).not.toBeInTheDocument();
});
it("opens grouped pane and tab actions from their context menus", async () => {
const onRequestRename = vi.fn();
const onDissolveTab = vi.fn();
render(
<ChatList
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={onRequestRename}
onToggleArchive={vi.fn()}
onDissolveTab={onDissolveTab}
/>,
);
const paneRow = screen.getByRole("button", { name: "Research pane" })
.closest("[data-sidebar-pane]")!;
fireEvent.contextMenu(paneRow);
fireEvent.click(await screen.findByRole("menuitem", { name: "Rename" }));
expect(onRequestRename).toHaveBeenCalledWith("websocket:child", "Research pane");
const tabRow = screen.getByRole("button", { name: "Group: Root topic" })
.closest("[data-workbench-tab]")!;
fireEvent.contextMenu(tabRow);
fireEvent.click(await screen.findByRole("menuitem", { name: "Dissolve group" }));
expect(onDissolveTab).toHaveBeenCalledWith("websocket:root");
});
it("creates a visible tab in place and only moves panes into visible tabs", async () => {
const onAttachPane = vi.fn();
const onCreateTab = vi.fn();
@@ -211,7 +128,7 @@ describe("ChatList", () => {
/>,
);
expect(screen.queryByRole("button", { name: "Group: Solo pane" }))
expect(screen.queryByRole("button", { name: "Tab: Solo pane" }))
.not.toBeInTheDocument();
expect(screen.getAllByText("Solo pane")).toHaveLength(1);
expect(screen.queryByRole("list", { name: "Panes in Solo pane" }))
@@ -240,159 +157,6 @@ describe("ChatList", () => {
expect(onAttachPane).toHaveBeenCalledWith("websocket:solo", "tab:fine");
});
it("moves a dragged topic directly into a visible group", () => {
const onAttachPane = vi.fn();
render(
<ChatList
sessions={[
session({ chatId: "solo", title: "Solo topic" }),
session({ chatId: "target", title: "Target group" }),
session({ chatId: "full", title: "Full group" }),
]}
activeKey="websocket:solo"
paneGroups={{
"websocket:solo": {
tabKey: "tab:solo",
title: "Solo topic",
activePaneKey: "websocket:solo",
visible: false,
panes: [{ key: "websocket:solo", chatId: "solo", title: "Solo topic" }],
},
"websocket:target": {
tabKey: "tab:target",
title: "Target group",
activePaneKey: "websocket:target",
visible: true,
panes: [{ key: "websocket:target", chatId: "target", title: "Target pane" }],
},
"websocket:full": {
tabKey: "tab:full",
title: "Full group",
activePaneKey: "websocket:full-1",
visible: true,
panes: [1, 2, 3, 4].map((index) => ({
key: `websocket:full-${index}`,
chatId: `full-${index}`,
title: `Full pane ${index}`,
})),
},
}}
onAttachPane={onAttachPane}
onSelect={vi.fn()}
onRequestDelete={vi.fn()}
onTogglePin={vi.fn()}
onRequestRename={vi.fn()}
onToggleArchive={vi.fn()}
/>,
);
const values = new Map<string, string>();
const dataTransfer = {
effectAllowed: "none",
dropEffect: "none",
setData: vi.fn((type: string, value: string) => values.set(type, value)),
getData: vi.fn((type: string) => values.get(type) ?? ""),
types: [SESSION_DRAG_TYPE],
} as unknown as DataTransfer;
const source = screen.getByRole("button", { name: "Solo topic" });
expect(source).toHaveAttribute("draggable", "true");
const targetGroup = screen.getByRole("button", { name: "Group: Target group" })
.closest("[data-sidebar-tab-group]")!;
const targetSurface = targetGroup.querySelector("[data-workbench-tab-surface]")!;
const fullGroup = screen.getByRole("button", { name: "Group: Full group" })
.closest("[data-sidebar-tab-group]")!;
const fullSurface = fullGroup.querySelector("[data-workbench-tab-surface]")!;
fireEvent.dragStart(source, { dataTransfer });
fireEvent.dragEnter(fullSurface, { dataTransfer });
fireEvent.dragOver(fullSurface, { dataTransfer });
fireEvent.drop(fullSurface, { dataTransfer });
expect(fullGroup).not.toHaveAttribute("data-pane-drop-target");
expect(onAttachPane).not.toHaveBeenCalled();
fireEvent.dragEnter(targetSurface, { dataTransfer });
fireEvent.dragOver(targetSurface, { dataTransfer });
expect(targetGroup).toHaveAttribute("data-pane-drop-target", "true");
expect(targetSurface).toHaveClass("ring-2", "ring-primary/35");
fireEvent.drop(targetSurface, { dataTransfer });
expect(onAttachPane).toHaveBeenCalledOnce();
expect(onAttachPane).toHaveBeenCalledWith("websocket:solo", "tab:target");
expect(targetGroup).not.toHaveAttribute("data-pane-drop-target");
});
it("detaches a grouped pane when it is dropped back into the standalone list", () => {
const onDetachPane = vi.fn();
render(
<ChatList
sessions={[
session({ chatId: "root", title: "Root topic" }),
session({ chatId: "solo", title: "Solo topic" }),
]}
activeKey="websocket:root"
paneGroups={{
"websocket:root": {
tabKey: "tab:root",
title: "Root group",
activePaneKey: "websocket:child",
visible: true,
panes: [
{ key: "websocket:root", chatId: "root", title: "Root topic" },
{ key: "websocket:child", chatId: "child", title: "Research pane" },
],
},
"websocket:solo": {
tabKey: "tab:solo",
title: "Solo topic",
activePaneKey: "websocket:solo",
visible: false,
panes: [{ key: "websocket:solo", chatId: "solo", title: "Solo topic" }],
},
}}
onDetachPane={onDetachPane}
onSelect={vi.fn()}
onRequestDelete={vi.fn()}
onTogglePin={vi.fn()}
onRequestRename={vi.fn()}
onToggleArchive={vi.fn()}
/>,
);
const values = new Map<string, string>();
const dataTransfer = {
effectAllowed: "none",
dropEffect: "none",
setData: vi.fn((type: string, value: string) => values.set(type, value)),
getData: vi.fn((type: string) => values.get(type) ?? ""),
types: [SESSION_DRAG_TYPE],
} as unknown as DataTransfer;
const source = screen.getByRole("button", { name: "Research pane" });
expect(source).toHaveAttribute("aria-current", "true");
expect(source).toHaveAttribute("draggable", "true");
const sourceSurface = screen.getByRole("button", { name: "Group: Root topic" })
.closest("[data-workbench-tab-surface]")!;
const standaloneList = document.querySelector("[data-chat-list-content]")!;
fireEvent.dragStart(source, { dataTransfer });
fireEvent.dragEnter(sourceSurface, { dataTransfer });
fireEvent.drop(sourceSurface, { dataTransfer });
expect(onDetachPane).not.toHaveBeenCalled();
fireEvent.dragStart(source, { dataTransfer });
fireEvent.dragEnter(standaloneList, { dataTransfer });
fireEvent.dragOver(standaloneList, { dataTransfer });
expect(standaloneList).toHaveAttribute("data-pane-detach-target", "true");
expect(standaloneList).toHaveClass("ring-1", "ring-primary/25");
fireEvent.drop(standaloneList, { dataTransfer });
expect(onDetachPane).toHaveBeenCalledOnce();
expect(onDetachPane).toHaveBeenCalledWith("tab:root", "websocket:child");
expect(standaloneList).not.toHaveAttribute("data-pane-detach-target");
});
it("shows every tab's pane membership in a sidebar tab group", async () => {
const onSelect = vi.fn();
const onSelectPane = vi.fn();
@@ -448,7 +212,7 @@ describe("ChatList", () => {
expect(child.closest("[data-sidebar-pane]"))
.toHaveAttribute("data-sidebar-pane", "websocket:child");
expect(child).toHaveAttribute("aria-current", "true");
const targetTabRow = screen.getByRole("button", { name: "Group: Target tab" })
const targetTabRow = screen.getByRole("button", { name: "Tab: Target tab" })
.closest("li")!;
const targetChild = within(targetTabRow).getByRole("button", {
name: "Target research",
@@ -469,7 +233,7 @@ describe("ChatList", () => {
expect(onSelect).not.toHaveBeenCalled();
onSelectPane.mockClear();
const rootTab = screen.getByRole("button", { name: "Group: Root topic" });
const rootTab = screen.getByRole("button", { name: "Tab: Root topic" });
fireEvent.click(rootTab);
expect(onSelectPane).not.toHaveBeenCalled();
expect(onSelect).not.toHaveBeenCalled();
@@ -514,8 +278,8 @@ describe("ChatList", () => {
.toBeInTheDocument();
fireEvent.keyDown(document, { key: "Escape" });
expect(child).toHaveAttribute("draggable", "true");
expect(screen.getByRole("button", { name: "Group: Target tab" }))
expect(child).toHaveAttribute("draggable", "false");
expect(screen.getByRole("button", { name: "Tab: Target tab" }))
.toHaveAttribute("draggable", "false");
});
@@ -552,7 +316,7 @@ describe("ChatList", () => {
/>,
);
const tabButton = screen.getByRole("button", { name: "Group: Root topic" });
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]")!;
@@ -560,25 +324,21 @@ describe("ChatList", () => {
expect(tabHeader).not.toHaveAttribute("data-chat-row");
expect(tabHeader).not.toHaveAttribute("data-sidebar-pane");
expect(tabButton).not.toHaveAttribute("aria-current");
expect(tabButton.querySelector(".lucide-folder-tree")).toBeInTheDocument();
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("rounded-control");
expect(activePane.querySelector("[data-sidebar-selection-track]"))
.toHaveAttribute("data-active", "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");
expect(paneList).toHaveClass(
"rounded-es-[14px]",
"border-s-2",
"border-sidebar-foreground/25",
);
const collapse = within(tabGroup).getByRole("button", {
name: "Collapse panes in Root topic",
@@ -592,8 +352,9 @@ describe("ChatList", () => {
expect(within(tabGroup).getByRole("button", {
name: "Expand panes in Root topic",
})).toHaveAttribute("aria-expanded", "false");
expect(within(tabGroup).getByRole("button", { name: "Group: Root topic" }))
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",
@@ -639,8 +400,8 @@ describe("ChatList", () => {
fireEvent.click(await screen.findByRole("menuitem", { name: "Select" }));
expect(screen.getByText("1 selected")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Group: Root topic" }));
expect(screen.getByRole("button", { name: "Group: Root topic" }))
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");
@@ -756,13 +517,10 @@ describe("ChatList", () => {
);
const pinnedSection = screen.getByRole("region", { name: "Pinned" });
expect(within(pinnedSection).getByTitle("Pinned")).toBeInTheDocument();
expect(
within(pinnedSection)
.getByText("Pinned chat")
.closest("[data-chat-row]")
?.querySelector("[data-sidebar-pinned-indicator]"),
).toBeInTheDocument();
expect(document.querySelectorAll("[data-sidebar-pinned-indicator]")).toHaveLength(1);
within(screen.getByRole("region", { name: "Earlier" })).queryByTitle("Pinned"),
).not.toBeInTheDocument();
});
it("groups WebUI chats by workspace project while preserving in-project sorting and activity", () => {
@@ -816,16 +574,8 @@ describe("ChatList", () => {
const nanobotSection = screen.getByRole("region", { name: "nanobot" });
const nanobotText = nanobotSection.textContent ?? "";
const projectSurface = nanobotSection.querySelector(
"[data-sidebar-project-surface]",
);
expect(screen.getByRole("region", { name: "nanobot-bench" })).toBeInTheDocument();
expect(projectSurface).toHaveClass(
"rounded-es-[16px]",
"border-s-2",
"border-sidebar-foreground/10",
);
expect(within(nanobotSection).getByText("Alpha task")).toBeInTheDocument();
expect(within(nanobotSection).getByText("Zeta task")).toBeInTheDocument();
expect(nanobotText.indexOf("Alpha task")).toBeLessThan(nanobotText.indexOf("Zeta task"));
@@ -880,7 +630,7 @@ describe("ChatList", () => {
expect(within(chatsSection).queryByText("Project chat")).not.toBeInTheDocument();
});
it("grows and retracts the row-owned selection track", () => {
it("switches row-owned tab highlights without a moving selection surface", () => {
const props = {
sessions: [
session({ chatId: "active", title: "Active topic" }),
@@ -900,10 +650,12 @@ describe("ChatList", () => {
/>,
);
const activeButton = screen.getByRole("button", { name: "Active topic" });
const activeButton = screen.getByTitle("Active topic");
expect(activeButton).toHaveAttribute("aria-current", "page");
expect(activeButton.querySelector("[data-sidebar-selection-track]"))
.toHaveClass("origin-left", "scale-x-100", "transition-transform", "bg-current");
expect(activeButton.closest("[data-sidebar-tab]")).toHaveClass(
"bg-sidebar-selected",
);
expect(screen.queryByTestId("sessions-selection-highlight")).not.toBeInTheDocument();
rerender(
<ChatList
@@ -912,16 +664,11 @@ describe("ChatList", () => {
/>,
);
expect(screen.getByRole("button", { name: "Active topic" }))
.not.toHaveAttribute("aria-current");
expect(screen.getByRole("button", { name: "Inactive topic" }))
.toHaveAttribute("aria-current", "page");
expect(screen.getByRole("button", { name: "Active topic" })
.querySelector("[data-sidebar-selection-track]"))
.toHaveClass("scale-x-0");
expect(screen.getByRole("button", { name: "Inactive topic" })
.querySelector("[data-sidebar-selection-track]"))
.toHaveClass("scale-x-100");
expect(screen.getByTitle("Active topic")).not.toHaveAttribute("aria-current");
expect(screen.getByTitle("Inactive topic")).toHaveAttribute("aria-current", "page");
expect(screen.getByTitle("Inactive topic").closest("[data-sidebar-tab]")).toHaveClass(
"bg-sidebar-selected",
);
});
it("restores collapsed tabs from the local UI preference", () => {
@@ -947,12 +694,12 @@ describe("ChatList", () => {
};
const firstRender = render(<ChatList {...props} />);
fireEvent.click(screen.getByRole("button", { name: "Group: Root topic" }));
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: "Group: Root topic" }))
expect(screen.getByRole("button", { name: "Tab: Root topic" }))
.toHaveAttribute("aria-expanded", "false");
expect(screen.queryByRole("button", { name: "Research pane" })).not.toBeInTheDocument();
});
@@ -996,111 +743,21 @@ describe("ChatList", () => {
expect(onToggleGroup).toHaveBeenCalledWith("project:/Users/me/nanobot");
expect(within(projectSection).queryByText("Alpha task")).not.toBeInTheDocument();
const projectButton = within(projectSection).getByRole("button", { name: "Photos" });
fireEvent.contextMenu(projectButton);
fireEvent.click(await screen.findByRole("menuitem", { name: "New topic" }));
fireEvent.click(
within(projectSection).getByRole("button", { name: "Start a new topic in Photos" }),
);
expect(onNewChatInProject).toHaveBeenCalledWith("/Users/me/nanobot", "Photos");
expect(onToggleGroup).toHaveBeenCalledTimes(1);
fireEvent.contextMenu(projectButton);
fireEvent.pointerDown(
within(projectSection).getByLabelText("Topic actions for Photos"),
{ button: 0 },
);
fireEvent.click(await screen.findByRole("menuitem", { name: "Rename" }));
expect(onRequestRenameProject).toHaveBeenCalledWith("/Users/me/nanobot", "Photos");
});
it("animates project disclosure and surrounding layout like tab groups", () => {
let collapsed = false;
const onToggleGroup = vi.fn();
const animate = vi.fn(() => ({
addEventListener: vi.fn(),
cancel: vi.fn(),
}) as unknown as Animation);
HTMLElement.prototype.animate = animate;
HTMLElement.prototype.getBoundingClientRect = function getBoundingClientRect() {
const followsCollapsedProject = this.textContent?.includes("Beta") ?? false;
return rect(followsCollapsedProject ? (collapsed ? 64 : 160) : 0);
};
const sessions = [
session({
chatId: "alpha",
title: "Alpha task",
workspaceScope: {
project_path: "/Users/me/alpha",
project_name: "Alpha project",
access_mode: "restricted",
},
}),
session({
chatId: "beta",
title: "Beta task",
workspaceScope: {
project_path: "/Users/me/beta",
project_name: "Beta project",
access_mode: "restricted",
},
}),
];
const props = {
sessions,
activeKey: "websocket:alpha",
onSelect: vi.fn(),
onRequestDelete: vi.fn(),
onTogglePin: vi.fn(),
onRequestRename: vi.fn(),
onRequestRenameProject: vi.fn(),
onToggleArchive: vi.fn(),
onToggleGroup,
};
const { rerender } = render(
<ChatList {...props} collapsedGroups={{ "project:/Users/me/alpha": false }} />,
);
const projectButton = screen.getByRole("button", { name: "Alpha project" });
const disclosureButton = screen.getByRole("button", {
name: "Projects: Alpha project",
});
expect(projectButton).toHaveAttribute("aria-expanded", "true");
expect(disclosureButton).toHaveAttribute("aria-expanded", "true");
const expandedIcon = disclosureButton
.querySelector("[data-sidebar-project-disclosure-icon]");
expect(expandedIcon).toHaveClass(
"transition-transform",
"duration-200",
"ease-out",
"motion-reduce:transition-none",
);
expect(expandedIcon).not.toHaveClass("rotate-90");
expect(screen.getByRole("button", { name: "Topic actions for Alpha project" })
.compareDocumentPosition(disclosureButton) & Node.DOCUMENT_POSITION_FOLLOWING)
.toBeTruthy();
fireEvent.click(disclosureButton);
expect(onToggleGroup).toHaveBeenCalledWith("project:/Users/me/alpha");
collapsed = true;
rerender(
<ChatList {...props} collapsedGroups={{ "project:/Users/me/alpha": true }} />,
);
expect(screen.getByRole("button", { name: "Projects: Alpha project" })
.querySelector("[data-sidebar-project-disclosure-icon]"))
.toHaveClass("rotate-90");
expect(projectButton).toHaveAttribute("aria-expanded", "false");
expect(animate).toHaveBeenCalledWith(
[
{ transform: "translateY(96px)" },
{ transform: "translateY(0)" },
],
{
duration: 180,
easing: "cubic-bezier(0.2, 0, 0, 1)",
},
);
expect(screen.getByRole("button", { name: "Beta project" })
.closest("[data-sidebar-group-header]"))
.toHaveAttribute("data-sidebar-group-header", "project:/Users/me/beta");
});
it("hides the updated dot for the active chat", () => {
const sessions = [
session({
+1 -1
View File
@@ -54,7 +54,7 @@ describe("CodeBlock", () => {
expect(screen.getByTestId("plain-code-fallback")).toHaveClass("py-4", "pl-5", "pr-14");
const container = screen.getByTestId("plain-code-fallback").closest(".not-prose");
expect(container).toHaveClass("relative", "rounded-floating", "bg-secondary/70");
expect(container).toHaveClass("relative", "rounded-[18px]", "bg-secondary/70");
expect(container).not.toHaveClass("border");
expect(container).toHaveAttribute("data-language", "ts");
-28
View File
@@ -269,22 +269,6 @@ const LOCALIZED_NEW_SURFACE_KEYS = [
"chat.groups.yesterday",
"chat.groups.earlier",
"chat.groups.archived",
"workbench.tabAria",
"workbench.panesInTab",
"workbench.collapseTabGroup",
"workbench.expandTabGroup",
"workbench.dropPane",
"workbench.createGroup",
"workbench.moveTo",
"workbench.renameGroupTitle",
"workbench.renameGroupDescription",
"workbench.renameGroupPlaceholder",
"workbench.dissolveTab",
"workbench.deleteConversations",
"workbench.paneLimit",
"workbench.paneActions",
"workbench.detachPane",
"workbench.composerAria",
"thread.promptNavigator.railAria",
"thread.composer.mentions.cliTitle",
"thread.composer.mentions.mcpTitle",
@@ -598,18 +582,6 @@ describe("webui i18n", () => {
expect(settings.skills.marketplaceTrendingTitle).toBe("各市场热门技能");
});
it("keeps the Simplified Chinese group workflow localized", () => {
const workbench = resources["zh-CN"].common.workbench;
expect(workbench.tabAria).toBe("分组:{{title}}");
expect(workbench.createGroup).toBe("创建分组");
expect(workbench.renameGroupTitle).toBe("重命名分组");
expect(workbench.renameGroupDescription).toBe("为这个分组命名。");
expect(workbench.renameGroupPlaceholder).toBe("分组名称");
expect(workbench.moveTo).toBe("移动到");
expect(workbench.detachPane).toBe("移出");
});
it("keeps Indonesian and Vietnamese settings free of copied Spanish help text", () => {
const spanish = flattenResource(resources.es.common);
+3 -3
View File
@@ -108,7 +108,7 @@ describe("MessageBubble", () => {
const pill = screen.getByText("hello");
expect(row).toHaveClass("ml-auto", "flex");
expect(pill).toHaveClass("ml-auto", "w-fit", "rounded-floating");
expect(pill).toHaveClass("ml-auto", "w-fit", "rounded-[18px]");
expect(screen.getByRole("button", { name: "Copy" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Fork" })).not.toBeInTheDocument();
});
@@ -286,7 +286,7 @@ describe("MessageBubble", () => {
expect(command.getAttribute("style")).toContain("var(--inline-token-highlight)");
expect(command.className).not.toMatch(/(?:^|\s)(?:bg-|border|ring|rounded)/);
expect(command.parentElement).toHaveTextContent("/model gpt-5");
expect(command.parentElement).toHaveClass("rounded-floating", "bg-secondary/70");
expect(command.parentElement).toHaveClass("rounded-[18px]", "bg-secondary/70");
});
it("keeps unknown and invalid slash commands as plain message text", () => {
@@ -934,7 +934,7 @@ describe("MessageBubble", () => {
const { container } = render(<MessageBubble message={message} />);
const imageButton = screen.getByRole("button", { name: /view image/i });
expect(imageButton).toHaveClass("w-[min(100%,34rem)]", "rounded-panel");
expect(imageButton).toHaveClass("w-[min(100%,34rem)]", "rounded-[20px]");
expect(imageButton).toHaveClass(
"border",
"border-border/60",
-45
View File
@@ -314,51 +314,6 @@ describe("PaneWorkbench", () => {
await waitFor(() => expect(animate).toHaveBeenCalledTimes(4));
});
it("keeps a retargeted pane surface in the same physical motion", async () => {
const common = {
layout: "columns" as const,
showLayoutControl: false,
onActivatePane: vi.fn(),
onAddPane: vi.fn(),
onLayoutChange: vi.fn(),
onPaneOrderChange: vi.fn(),
renderPane: (pane: { title: string }) => <span>{pane.title}</span>,
};
const { rerender } = render(
<PaneWorkbench
{...common}
panes={[
{ key: "alpha", reactKey: "tab-root", title: "Alpha" },
{ key: "beta", reactKey: "pane:beta", title: "Beta" },
]}
activePaneKey="alpha"
/>,
);
animate.mockClear();
rerender(
<PaneWorkbench
{...common}
panes={[
{ key: "beta", reactKey: "pane:beta", title: "Beta" },
{ key: "gamma", reactKey: "tab-root", title: "Gamma" },
]}
activePaneKey="gamma"
/>,
);
await waitFor(() => expect(animate).toHaveBeenCalledWith(
[
{ transform: "translate(-500px, 0px) scale(1, 1)" },
{ transform: "translate(0, 0) scale(1, 1)" },
],
{
duration: 260,
easing: "cubic-bezier(0.2, 0, 0, 1)",
},
));
});
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"),
+1 -1
View File
@@ -165,7 +165,7 @@ describe("Settings models", () => {
expect(editor).toHaveClass(
"slide-in-from-top-1",
"lg:max-w-6xl",
"rounded-floating",
"rounded-[18px]",
);
expect(within(editor).getByDisplayValue("Primary")).toBeInTheDocument();
const deleteButton = within(editor).getByRole("button", { name: "Delete" });
+42 -51
View File
@@ -554,7 +554,7 @@ describe("ThreadComposer", () => {
expect(input.className).toContain("min-h-[50px]");
expect(input.className).toContain("text-[16px]");
expect(input.parentElement?.parentElement?.className).toContain("max-w-[49.5rem]");
expect(input.parentElement?.parentElement?.className).toContain("rounded-panel");
expect(input.parentElement?.parentElement?.className).toContain("rounded-[22px]");
expect(input.parentElement?.parentElement?.className).not.toContain("shadow-");
expect(screen.getByRole("button", { name: "Attach files" }).className).toContain("bg-card");
expect(screen.getByRole("button", { name: "Send message" }).className).toContain("bg-foreground");
@@ -1260,45 +1260,6 @@ describe("ThreadComposer", () => {
}));
});
it("uses the gateway folder picker for a locally hosted WebUI", async () => {
const onWorkspaceScopeChange = vi.fn();
const pickFolder = vi.fn().mockResolvedValue("/Users/test/gateway-project");
const defaultScope = {
project_path: "/Users/test/.nanobot/workspace",
project_name: "workspace",
access_mode: "full" as const,
restrict_to_workspace: false,
};
render(
<ThreadComposer
onSend={vi.fn()}
placeholder="Ask anything..."
variant="hero"
workspaceScope={defaultScope}
workspaceDefaultScope={defaultScope}
workspaceControls={{
can_change_project: true,
can_use_full_access: true,
can_pick_folder: true,
}}
onPickWorkspaceFolder={pickFolder}
onWorkspaceScopeChange={onWorkspaceScopeChange}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Choose project" }));
await waitFor(() => expect(pickFolder).toHaveBeenCalled());
expect(screen.queryByLabelText("Paste path")).not.toBeInTheDocument();
expect(onWorkspaceScopeChange).toHaveBeenCalledWith(expect.objectContaining({
project_path: "/Users/test/gateway-project",
project_name: "gateway-project",
access_mode: "full",
restrict_to_workspace: false,
}));
});
it("uses the web path menu when no native host picker is available", async () => {
const user = userEvent.setup();
const defaultScope = {
@@ -1326,21 +1287,54 @@ describe("ThreadComposer", () => {
expect(screen.getByLabelText("Paste path")).toBeInTheDocument();
});
it("closes the sustained goal through its existing drawer", () => {
it("shows turn run timer when runStartedAt is set", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date((1_000 + 125) * 1000));
render(
<ThreadComposer
onSend={vi.fn()}
placeholder="Type your message..."
runStartedAt={1000}
/>,
);
const status = screen.getByRole("status");
expect(status).toHaveTextContent(/Running/);
expect(status).toHaveTextContent(/2:05/);
expect(status).toHaveClass("composer-status-drawer-content");
expect(status.closest("[data-composer-status-drawer]")).toHaveAttribute(
"data-state",
"open",
);
expect(status.querySelector(".run-pulse-icon")).not.toBeNull();
vi.useRealTimers();
});
it("opens and closes the run timer through one persistent drawer", () => {
const { container, rerender } = render(
<ThreadComposer
onSend={vi.fn()}
placeholder="Type your message..."
goalState={{
active: true,
objective: "Ship the release",
ui_summary: "Preparing release",
}}
runStartedAt={null}
/>,
);
const drawer = container.querySelector("[data-composer-status-drawer]");
expect(drawer).not.toBeNull();
expect(drawer).toHaveAttribute("data-state", "closed");
expect(drawer).toHaveAttribute("aria-hidden", "true");
rerender(
<ThreadComposer
onSend={vi.fn()}
placeholder="Type your message..."
runStartedAt={Math.floor(Date.now() / 1000)}
/>,
);
expect(container.querySelector("[data-composer-status-drawer]")).toBe(drawer);
expect(drawer).toHaveAttribute("data-state", "open");
expect(drawer).not.toHaveAttribute("aria-hidden");
const status = screen.getByRole("status");
@@ -1350,7 +1344,7 @@ describe("ThreadComposer", () => {
<ThreadComposer
onSend={vi.fn()}
placeholder="Type your message..."
goalState={{ active: false }}
runStartedAt={null}
/>,
);
@@ -1359,9 +1353,6 @@ describe("ThreadComposer", () => {
expect(drawer).toHaveAttribute("aria-hidden", "true");
expect(screen.queryByRole("status")).not.toBeInTheDocument();
expect(drawer?.querySelector('[role="status"]')).toBe(status);
fireEvent.transitionEnd(drawer as Element, { propertyName: "grid-template-rows" });
expect(container.querySelector("[data-composer-status-drawer]")).toBeNull();
});
it("opens an upward anchored goal panel with markdown content when expand is clicked", async () => {
@@ -1807,7 +1798,7 @@ describe("ThreadComposer", () => {
});
});
it("rejects self-session drops that are unavailable to the composer", () => {
it("rejects session drops that are unavailable to the composer", () => {
render(
<ThreadComposer
onSend={vi.fn()}
-48
View File
@@ -16,54 +16,6 @@ afterEach(() => {
});
describe("ThreadMessages", () => {
it("shows optimistic turn progress in the thread before the first agent output", () => {
vi.useFakeTimers();
const now = new Date("2026-08-13T10:00:05.000Z").getTime();
vi.setSystemTime(now);
const prompt: UIMessage = {
id: "u-optimistic",
role: "user",
content: "check this",
turnId: "turn-optimistic",
turnPhase: "user",
deliveryStatus: "sending",
createdAt: now - 5_000,
};
const { rerender } = render(
<ThreadMessages
messages={[prompt]}
isStreaming
activeTurnId="turn-optimistic"
runStartedAt={(now - 5_000) / 1000}
/>,
);
expect(screen.getByRole("status", { name: "Thinking for 5s" })).toBeInTheDocument();
rerender(
<ThreadMessages
messages={[
{ ...prompt, deliveryStatus: "accepted" },
{
id: "t-optimistic",
role: "tool",
kind: "trace",
content: "web_search()",
traces: ["web_search()"],
turnId: "turn-optimistic",
turnPhase: "activity",
createdAt: now,
},
]}
isStreaming
activeTurnId="turn-optimistic"
runStartedAt={(now - 5_000) / 1000}
/>,
);
expect(screen.getByRole("button", { name: "Working for 5s" })).toBeInTheDocument();
});
it("does not move a mounted tail answer into offscreen rendering on the next turn", () => {
const completed: UIMessage[] = [
{ id: "u1", role: "user", content: "question", createdAt: 1 },
-25
View File
@@ -120,31 +120,6 @@ describe("ThreadMotionCoordinator", () => {
});
});
it("reconciles observed layout growth before the next paint", () => {
const {
camera,
coordinator,
frames,
scheduler,
advanceFrame,
setGeometry,
} = motionHarness();
coordinator.resumeAutoFollow();
advanceFrame();
camera.jumpTo.mockClear();
scheduler.cancel.mockClear();
setGeometry({ scrollTop: 1_400, scrollHeight: 2_054 });
coordinator.invalidateGeometry();
coordinator.reconcileObservedGeometry();
expect(camera.jumpTo).toHaveBeenCalledWith(1_554);
expect(scheduler.cancel).toHaveBeenCalledTimes(1);
expect(frames).toHaveLength(0);
expect(coordinator.snapshot()).toMatchObject({ measurementPending: false });
});
it("pins repeated output growth on each authoritative geometry frame", () => {
const {
camera,
+4 -31
View File
@@ -241,10 +241,6 @@ describe("ThreadViewport", () => {
takeUserControl.mockClear();
fireEvent.keyDown(disclosure, { key: "Enter" });
expect(takeUserControl).toHaveBeenCalledTimes(1);
takeUserControl.mockClear();
fireEvent.keyDown(disclosure, { key: " " });
expect(takeUserControl).toHaveBeenCalledTimes(1);
});
it("top-aligns short threads in the message rendering area", () => {
@@ -657,7 +653,7 @@ describe("ThreadViewport", () => {
}
});
it("settles observed streamed layout growth before paint", async () => {
it("coalesces streamed layout growth into frame-driven camera targets", async () => {
const resizeObserver = stubResizeObserver();
const followTo = vi.spyOn(ThreadCameraController.prototype, "followTo")
.mockReturnValue("started");
@@ -744,7 +740,10 @@ describe("ThreadViewport", () => {
});
act(() => {
contentObserver!.callback([], contentObserver as unknown as ResizeObserver);
contentObserver!.callback([], contentObserver as unknown as ResizeObserver);
});
expect(followTo).not.toHaveBeenCalled();
await flushAnimationFrame();
expect(followTo).toHaveBeenCalledTimes(1);
expect(followTo).toHaveBeenLastCalledWith(1448);
followTo.mockClear();
@@ -1960,12 +1959,6 @@ describe("ThreadViewport", () => {
it("waits for the next conversation's transcript before restoring its bottom", async () => {
const jumpTo = vi.spyOn(ThreadCameraController.prototype, "jumpTo");
const followTo = vi.spyOn(ThreadCameraController.prototype, "followTo");
const handoffAnimation = {
cancel: vi.fn(),
oncancel: null,
onfinish: null,
} as unknown as Animation;
const animate = vi.fn(() => handoffAnimation);
const oldMessages: UIMessage[] = [
{
id: "old-user",
@@ -2005,7 +1998,6 @@ describe("ThreadViewport", () => {
scrollHeight: { configurable: true, value: 2400 },
clientHeight: { configurable: true, value: 600 },
scrollTop: { configurable: true, writable: true, value: 300 },
animate: { configurable: true, value: animate },
});
jumpTo.mockClear();
@@ -2021,14 +2013,6 @@ describe("ThreadViewport", () => {
);
expect(scroller.scrollTop).toBe(300);
expect(jumpTo).not.toHaveBeenCalled();
expect(animate).toHaveBeenCalledWith(
[{ opacity: 1 }, { opacity: 0.82 }],
{
duration: 80,
easing: "cubic-bezier(0.2, 0, 0, 1)",
fill: "forwards",
},
);
Object.defineProperty(scroller, "scrollHeight", {
configurable: true,
@@ -2049,17 +2033,6 @@ describe("ThreadViewport", () => {
await flushAnimationFrame();
expect(jumpTo.mock.calls).toEqual([[2400]]);
expect(followTo).toHaveBeenCalledWith(2400);
expect(handoffAnimation.cancel).toHaveBeenCalled();
expect(animate).toHaveBeenCalledWith(
[{ opacity: 0.82 }, { opacity: 1 }],
{
duration: 140,
easing: "cubic-bezier(0.2, 0, 0, 1)",
},
);
expect(jumpTo.mock.invocationCallOrder[0]).toBeLessThan(
animate.mock.invocationCallOrder[1],
);
});
it("waits for hydrated messages before fulfilling open-chat bottom scroll", async () => {
-83
View File
@@ -1,83 +0,0 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import {
AlertDialog,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogTitle,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
describe("UI shape system", () => {
it("gives standard controls one shared radius", () => {
render(
<>
<Button>Continue</Button>
<Input aria-label="Name" />
<Textarea aria-label="Description" />
</>,
);
expect(screen.getByRole("button", { name: "Continue" })).toHaveClass("rounded-control");
expect(screen.getByRole("textbox", { name: "Name" })).toHaveClass("rounded-control");
expect(screen.getByRole("textbox", { name: "Description" })).toHaveClass(
"rounded-control",
);
});
it("gives dialogs and alert dialogs one shared modal radius", () => {
const dialog = render(
<Dialog open>
<DialogContent showCloseButton={false}>
<DialogTitle>Edit name</DialogTitle>
<DialogDescription>Choose a new name.</DialogDescription>
</DialogContent>
</Dialog>,
);
expect(screen.getByRole("dialog", { name: "Edit name" })).toHaveClass("rounded-modal");
dialog.unmount();
render(
<AlertDialog open>
<AlertDialogContent>
<AlertDialogTitle>Delete item?</AlertDialogTitle>
<AlertDialogDescription>This cannot be undone.</AlertDialogDescription>
</AlertDialogContent>
</AlertDialog>,
);
expect(screen.getByRole("alertdialog", { name: "Delete item?" })).toHaveClass(
"rounded-modal",
);
});
it("keeps floating surfaces and their items on the shared radius scale", () => {
render(
<DropdownMenu open>
<DropdownMenuTrigger>Open menu</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>Rename</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>,
);
expect(screen.getByRole("menu")).toHaveClass("rounded-floating");
expect(screen.getByRole("menuitem", { name: "Rename" })).toHaveClass(
"rounded-control",
);
});
});
-7
View File
@@ -51,13 +51,6 @@ export default {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
mark: "var(--radius-mark)",
compact: "var(--radius-compact)",
control: "var(--radius-control)",
floating: "var(--radius-floating)",
panel: "var(--radius-panel)",
modal: "var(--radius-modal)",
prominent: "var(--radius-prominent)",
},
colors: {
background: "hsl(var(--background))",