Compare commits

..
Author SHA1 Message Date
Xubin Ren 1437d1a75a fix(webui): allow moving active group panes 2026-08-14 19:55:25 +09:00
Xubin Ren b378319d4a fix(webui): enforce localized group copy 2026-08-14 19:55:25 +09:00
Xubin Ren 4266ef2099 feat(webui): allow dragging panes out of groups 2026-08-14 19:55:25 +09:00
Xubin Ren 60993597de refactor(webui): unify shared shape system 2026-08-14 19:55:25 +09:00
Xubin Ren 7c04af86f9 style(webui): simplify delete confirmation icon 2026-08-14 19:55:25 +09:00
Xubin Ren cdf225cb89 feat(webui): allow dragging topics into groups 2026-08-14 19:55:25 +09:00
Xubin Ren 4d18dd2c30 fix(webui): align group rename dialog terminology 2026-08-14 19:55:25 +09:00
chengyongruandGitHub 221e8a4e4a feat(webui): polish sidebar and session transitions (#5393) 2026-08-14 17:03:11 +08:00
dajiaohuangandchengyongru 057c5e849b fix(session): restore state when file-cap archive fails 2026-08-14 11:22:22 +08:00
chengyongruandGitHub e226242dfc fix(session): serialize canonical file access (#5383) 2026-08-14 10:32:17 +08:00
Xubin Ren e3d1819a2b fix(webui): isolate folder picker environment 2026-08-14 04:03:54 +09:00
Xubin Ren 9703656b25 test(webui): make folder picker tests portable 2026-08-14 04:03:54 +09:00
Xubin Ren 26c9687b80 feat(webui): add native workspace folder picker 2026-08-14 04:03:54 +09:00
Xubin Ren 410e5e5121 test(websocket): isolate persisted runtime data 2026-08-14 03:46:01 +09:00
Xubin Ren 335808e525 fix(webui): bound restored session previews 2026-08-14 03:46:01 +09:00
Xubin Ren afad96af5f refactor(webui): simplify transcript discovery 2026-08-14 03:46:01 +09:00
Xubin Ren fd7eb8e046 fix(webui): restore transcript-only session history 2026-08-14 03:46:01 +09:00
98 changed files with 2756 additions and 945 deletions
+2 -1
View File
@@ -133,7 +133,8 @@ or a result you must retain.
Use the workspace picker before starting project-specific work. This gives the Use the workspace picker before starting project-specific work. This gives the
agent the right project context for file paths, shell commands, and session agent the right project context for file paths, shell commands, and session
metadata. metadata. A locally hosted WebUI opens the operating system's folder chooser
when one is available; remote deployments keep the manual absolute path entry.
Selecting a project does not replace the configured agent workspace. The two Selecting a project does not replace the configured agent workspace. The two
paths have different responsibilities: paths have different responsibilities:
+8 -11
View File
@@ -769,27 +769,24 @@ class MemoryStore:
return f"{prefix}\n\n{diff_body}" return f"{prefix}\n\n{diff_body}"
@staticmethod @staticmethod
def prune_dream_sessions(sessions_dir: Path, *, keep: int = 10) -> None: def prune_dream_sessions(sessions: SessionManager, *, keep: int = 10) -> None:
"""Remove the oldest Dream session files, keeping only the N most recent. """Remove the oldest Dream session files, keeping only the N most recent.
Only current base64url-encoded Dream session keys are considered. Only current base64url-encoded Dream session keys are considered.
Non-dream session files are never touched. Non-dream session files are never touched.
""" """
dream_files: list[Path] = [] with sessions.locked_session_files() as sessions_dir:
dream_files: list[tuple[Path, str]] = []
for path in sessions_dir.glob("*.jsonl"): for path in sessions_dir.glob("*.jsonl"):
decoded_key = SessionManager.decode_storage_key(path.stem) decoded_key = SessionManager.decode_storage_key(path.stem)
if decoded_key is not None and decoded_key.startswith("dream:"): if decoded_key is not None and decoded_key.startswith("dream:"):
dream_files.append(path) dream_files.append((path, decoded_key))
dream_files.sort(key=lambda p: p.stat().st_mtime) dream_files.sort(key=lambda item: item[0].stat().st_mtime)
if len(dream_files) <= keep:
return
to_remove = dream_files[: len(dream_files) - keep] for path, key in dream_files[: max(0, len(dream_files) - keep)]:
for path in to_remove: if sessions.delete_session(key):
try:
path.unlink()
logger.debug("Pruned old dream session: {}", path.stem) logger.debug("Pruned old dream session: {}", path.stem)
except OSError: else:
logger.warning("Failed to prune dream session {}", path) logger.warning("Failed to prune dream session {}", path)
@@ -59,7 +59,7 @@ export function FeishuAssistantsPanel({
/> />
), ),
footer: ( footer: (
<div className="mt-4 overflow-hidden rounded-[16px] border border-border/70 bg-background px-4 py-4"> <div className="mt-4 overflow-hidden rounded-floating border border-border/70 bg-background px-4 py-4">
<div className="text-[13px] font-semibold text-foreground"> <div className="text-[13px] font-semibold text-foreground">
{tx("custom.createAnother", "Create another assistant")} {tx("custom.createAnother", "Create another assistant")}
</div> </div>
@@ -144,7 +144,7 @@ function FeishuInstanceAction({
</Button> </Button>
</div> </div>
{error ? ( {error ? (
<div className="mt-3 rounded-[12px] border border-destructive/20 px-3 py-2 text-[12px] leading-5 text-destructive"> <div className="mt-3 rounded-control border border-destructive/20 px-3 py-2 text-[12px] leading-5 text-destructive">
{error} {error}
</div> </div>
) : null} ) : null}
@@ -3266,6 +3266,85 @@ 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: def test_local_browser_request_requires_loopback_host_and_forwarded_origin() -> None:
from nanobot.webui.http_utils import is_local_browser_request from nanobot.webui.http_utils import is_local_browser_request
@@ -197,7 +197,7 @@ export function WeixinPanel({
}); });
return ( return (
<aside className="min-h-full rounded-[20px] bg-settings-surface p-5"> <aside className="min-h-full rounded-panel bg-settings-surface p-5">
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div className="flex min-w-0 items-start gap-3"> <div className="flex min-w-0 items-start gap-3">
<WeixinLogo showBrandLogos={showBrandLogos} /> <WeixinLogo showBrandLogos={showBrandLogos} />
@@ -251,7 +251,7 @@ export function WeixinPanel({
</div> </div>
{runtimeError ? ( {runtimeError ? (
<div className="mt-4 rounded-[12px] border border-destructive/20 bg-destructive/5 px-3 py-2 text-[12px] leading-5 text-destructive"> <div className="mt-4 rounded-control border border-destructive/20 bg-destructive/5 px-3 py-2 text-[12px] leading-5 text-destructive">
{runtimeError} {runtimeError}
</div> </div>
) : null} ) : null}
@@ -304,7 +304,7 @@ export function WeixinPanel({
{saveError ? ( {saveError ? (
<div <div
role="alert" role="alert"
className="rounded-[12px] border border-destructive/20 bg-destructive/5 px-3 py-2 text-[12px] leading-5 text-destructive" className="rounded-control border border-destructive/20 bg-destructive/5 px-3 py-2 text-[12px] leading-5 text-destructive"
> >
{saveError} {saveError}
</div> </div>
@@ -413,7 +413,7 @@ function WeixinLogo({ showBrandLogos }: { showBrandLogos: boolean }) {
const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(logoUrls); const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(logoUrls);
if (showBrandLogos && logoUrl) { if (showBrandLogos && logoUrl) {
return ( return (
<span className="grid h-10 w-10 shrink-0 place-items-center rounded-[12px] bg-background"> <span className="grid h-10 w-10 shrink-0 place-items-center rounded-control bg-background">
<img <img
src={logoUrl} src={logoUrl}
alt="" alt=""
@@ -428,7 +428,7 @@ function WeixinLogo({ showBrandLogos }: { showBrandLogos: boolean }) {
} }
return ( return (
<span <span
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-[12px] bg-background text-[11px] font-bold" className="flex h-10 w-10 shrink-0 items-center justify-center rounded-control bg-background text-[11px] font-bold"
style={{ color: "#07C160" }} style={{ color: "#07C160" }}
aria-hidden aria-hidden
> >
+1 -1
View File
@@ -566,7 +566,7 @@ def _run_gateway(
if sha: if sha:
logger.info("Dream commit: {}", sha) logger.info("Dream commit: {}", sha)
store.compact_history() store.compact_history()
prune_dream_sessions(agent.sessions.sessions_dir) prune_dream_sessions(agent.sessions)
return None return None
# Heartbeat is a system job that checks HEARTBEAT.md for active tasks. # 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: if sha:
content += f" (commit {sha})" content += f" (commit {sha})"
store.compact_history() store.compact_history()
prune_dream_sessions(loop.sessions.sessions_dir) prune_dream_sessions(loop.sessions)
await loop.bus.publish_outbound(OutboundMessage( await loop.bus.publish_outbound(OutboundMessage(
channel=msg.channel, chat_id=msg.chat_id, content=content, channel=msg.channel, chat_id=msg.chat_id, content=content,
)) ))
+70 -11
View File
@@ -9,12 +9,12 @@ import re
import secrets import secrets
import stat import stat
from collections import OrderedDict from collections import OrderedDict
from contextlib import suppress from contextlib import contextmanager, suppress
from copy import deepcopy from copy import deepcopy
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Any, Callable, Collection, Protocol, TypedDict, cast from typing import Any, Callable, Collection, Generator, Protocol, TypedDict, cast
from weakref import WeakValueDictionary from weakref import WeakValueDictionary
from filelock import FileLock from filelock import FileLock
@@ -65,6 +65,7 @@ _WORKSPACE_STATE_DIR = ".nanobot"
_WORKSPACE_ID_FILE = "workspace-id" _WORKSPACE_ID_FILE = "workspace-id"
_WORKSPACE_ID_RE = re.compile(r"^[0-9a-f]{32}$") _WORKSPACE_ID_RE = re.compile(r"^[0-9a-f]{32}$")
_SESSION_MIGRATION_LOCK_TIMEOUT_SECONDS = 30 _SESSION_MIGRATION_LOCK_TIMEOUT_SECONDS = 30
_SESSION_FILES_LOCK_FILENAME = ".session-files.lock"
_COPY_CHUNK_SIZE = 1024 * 1024 _COPY_CHUNK_SIZE = 1024 * 1024
@@ -472,13 +473,28 @@ class Session:
if limit <= 0 or len(self.messages) <= limit: if limit <= 0 or len(self.messages) <= limit:
return 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) result = self.retain_recent_legal_suffix(limit)
if not result.dropped: if not result.dropped:
return return
archive_chunk = result.dropped[result.already_consolidated_count:] archive_chunk = result.dropped[result.already_consolidated_count:]
if archive_chunk and on_archive: if archive_chunk and on_archive:
try:
on_archive(archive_chunk) 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( logger.info(
"Session file cap hit for {}: dropped {}, raw-archived {}, kept {}", "Session file cap hit for {}: dropped {}, raw-archived {}, kept {}",
self.key, self.key,
@@ -576,8 +592,18 @@ class JsonlSessionStore:
) )
self.sessions_dir = ensure_dir(root / workspace_id) self.sessions_dir = ensure_dir(root / workspace_id)
self.legacy_sessions_dir = get_legacy_sessions_dir() 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) 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 @staticmethod
def _fsync_directory(path: Path) -> None: def _fsync_directory(path: Path) -> None:
with suppress(PermissionError, NotImplementedError): with suppress(PermissionError, NotImplementedError):
@@ -959,7 +985,7 @@ class JsonlSessionStore:
raise RuntimeError(f"refusing to restore into symlinked sessions directory: {old_dir}") raise RuntimeError(f"refusing to restore into symlinked sessions directory: {old_dir}")
ensure_dir(old_dir) ensure_dir(old_dir)
with self._migration_lock: with self._migration_lock, self._session_files_lock:
for src in self.sessions_dir.glob("*.jsonl"): for src in self.sessions_dir.glob("*.jsonl"):
if self.session_key_from_path(src) is None: if self.session_key_from_path(src) is None:
continue continue
@@ -1021,6 +1047,10 @@ class JsonlSessionStore:
return self.legacy_sessions_dir / f"{self.safe_key(key)}.jsonl" return self.legacy_sessions_dir / f"{self.safe_key(key)}.jsonl"
def load(self, key: str) -> Session | None: 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) path = self.get_session_path(key)
if not path.exists(): if not path.exists():
return None return None
@@ -1086,7 +1116,7 @@ class JsonlSessionStore:
) )
except _SESSION_DATA_ERRORS as e: except _SESSION_DATA_ERRORS as e:
logger.warning("Failed to load session {}: {}", key, e) logger.warning("Failed to load session {}: {}", key, e)
repaired = self.repair(key) repaired = self._repair_unlocked(key)
if repaired is not None: if repaired is not None:
logger.info( logger.info(
"Recovered session {} from corrupt file ({} messages)", "Recovered session {} from corrupt file ({} messages)",
@@ -1096,6 +1126,10 @@ class JsonlSessionStore:
return repaired return repaired
def repair(self, key: str, *, path: Path | None = None) -> Session | None: 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: if path is None:
path = self.get_session_path(key) path = self.get_session_path(key)
if not path.exists(): if not path.exists():
@@ -1188,11 +1222,15 @@ class JsonlSessionStore:
} }
def save(self, session: Session, *, fsync: bool = False) -> None: 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) path = self.get_session_path(session.key)
tmp_path = path.with_suffix(".jsonl.tmp") tmp_path = path.with_name(f".{path.name}.{secrets.token_hex(8)}.tmp")
try: try:
with open(tmp_path, "w", encoding="utf-8") as f: with open(tmp_path, "x", encoding="utf-8") as f:
metadata_line = { metadata_line = {
"_type": "metadata", "_type": "metadata",
"key": session.key, "key": session.key,
@@ -1226,11 +1264,14 @@ class JsonlSessionStore:
raise raise
finally: finally:
os.close(fd) os.close(fd)
except BaseException: finally:
tmp_path.unlink(missing_ok=True) tmp_path.unlink(missing_ok=True)
raise
def delete(self, key: str) -> bool: def delete(self, key: str) -> bool:
with self._session_files_lock:
return self._delete_unlocked(key)
def _delete_unlocked(self, key: str) -> bool:
paths = [ paths = [
self.get_session_path(key), self.get_session_path(key),
self.get_legacy_lossy_path(key), self.get_legacy_lossy_path(key),
@@ -1248,6 +1289,10 @@ class JsonlSessionStore:
return deleted return deleted
def read(self, key: str) -> SessionPayload | None: 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) path = self.get_session_path(key)
if not path.exists(): if not path.exists():
return None return None
@@ -1297,13 +1342,17 @@ class JsonlSessionStore:
} }
except _SESSION_DATA_ERRORS as e: except _SESSION_DATA_ERRORS as e:
logger.warning("Failed to read session {}: {}", key, e) logger.warning("Failed to read session {}: {}", key, e)
repaired = self.repair(key, path=path) repaired = self._repair_unlocked(key, path=path)
if repaired is not None: if repaired is not None:
logger.info("Recovered read-only session view {} from corrupt file", key) logger.info("Recovered read-only session view {} from corrupt file", key)
return self.session_payload(repaired) return self.session_payload(repaired)
return None return None
def read_metadata(self, key: str) -> SessionMetadataPayload | 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) path = self.get_session_path(key)
if not path.exists(): if not path.exists():
return None return None
@@ -1338,7 +1387,7 @@ class JsonlSessionStore:
return None return None
except _SESSION_DATA_ERRORS as e: except _SESSION_DATA_ERRORS as e:
logger.warning("Failed to read session metadata {}: {}", key, e) logger.warning("Failed to read session metadata {}: {}", key, e)
repaired = self.repair(key, path=path) repaired = self._repair_unlocked(key, path=path)
if repaired is not None: if repaired is not None:
logger.info("Recovered read-only session metadata {} from corrupt file", key) logger.info("Recovered read-only session metadata {} from corrupt file", key)
return { return {
@@ -1350,6 +1399,10 @@ class JsonlSessionStore:
return None return None
def list_sessions(self) -> list[SessionInfo]: 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] = [] sessions: list[SessionInfo] = []
for path in self.sessions_dir.glob("*.jsonl"): for path in self.sessions_dir.glob("*.jsonl"):
@@ -1427,7 +1480,7 @@ class JsonlSessionStore:
except FileNotFoundError: except FileNotFoundError:
continue continue
except _SESSION_DATA_ERRORS: except _SESSION_DATA_ERRORS:
repaired = self.repair(storage_key, path=path) repaired = self._repair_unlocked(storage_key, path=path)
if repaired is not None: if repaired is not None:
sessions.append( sessions.append(
{ {
@@ -1536,6 +1589,12 @@ class SessionManager:
"""Legacy global session path (~/.nanobot/sessions/).""" """Legacy global session path (~/.nanobot/sessions/)."""
return self._jsonl_store.get_legacy_session_path(key) 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: def get_or_create(self, key: str) -> Session:
""" """
Get an existing session or create a new one. Get an existing session or create a new one.
+211
View File
@@ -0,0 +1,211 @@
"""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)
+6 -4
View File
@@ -10,6 +10,7 @@ from __future__ import annotations
import json import json
import os import os
import re import re
import secrets
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Any, cast from typing import Any, cast
@@ -56,6 +57,7 @@ _TRANSCRIPT_NON_ANSWER_KINDS = {"progress", "reasoning", "tool_hint"}
def list_webui_sessions(session_manager: SessionManager) -> list[dict[str, Any]]: def list_webui_sessions(session_manager: SessionManager) -> list[dict[str, Any]]:
"""Return session rows for the WebUI sidebar, backed by a rebuildable cache.""" """Return session rows for the WebUI sidebar, backed by a rebuildable cache."""
with session_manager.locked_session_files():
rows, changed = _reconcile_index(session_manager) rows, changed = _reconcile_index(session_manager)
if changed: if changed:
try: try:
@@ -169,14 +171,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: def _write_index_rows(sessions_dir: Path, rows: list[dict[str, Any]]) -> None:
path = _index_path(sessions_dir) path = _index_path(sessions_dir)
tmp_path = path.with_suffix(".json.tmp") tmp_path = path.with_name(f"{path.name}.{secrets.token_hex(8)}.tmp")
data = {"version": _INDEX_VERSION, "sessions": rows} data = {"version": _INDEX_VERSION, "sessions": rows}
try: try:
tmp_path.write_text(json.dumps(data, ensure_ascii=False) + "\n", encoding="utf-8") with open(tmp_path, "x", encoding="utf-8") as file:
file.write(json.dumps(data, ensure_ascii=False) + "\n")
os.replace(tmp_path, path) os.replace(tmp_path, path)
except BaseException: finally:
tmp_path.unlink(missing_ok=True) tmp_path.unlink(missing_ok=True)
raise
def _file_signature(path: Path) -> dict[str, int]: def _file_signature(path: Path) -> dict[str, int]:
+9 -1
View File
@@ -149,6 +149,7 @@ def workspaces_payload(
default_workspace: Path, default_workspace: Path,
default_restrict_to_workspace: bool, default_restrict_to_workspace: bool,
controls_available: bool, controls_available: bool,
folder_picker_available: bool = False,
) -> dict[str, Any]: ) -> dict[str, Any]:
default_access_mode = read_webui_default_access_mode() default_access_mode = read_webui_default_access_mode()
default_scope = ( default_scope = (
@@ -167,6 +168,7 @@ def workspaces_payload(
"controls": { "controls": {
"can_change_project": controls_available, "can_change_project": controls_available,
"can_use_full_access": controls_available, "can_use_full_access": controls_available,
"can_pick_folder": folder_picker_available,
}, },
} }
@@ -241,11 +243,17 @@ class WebUIWorkspaceController:
cast(object, metadata_data.get(WORKSPACE_SCOPE_METADATA_KEY)) cast(object, metadata_data.get(WORKSPACE_SCOPE_METADATA_KEY))
) )
def payload(self, *, controls_available: bool) -> dict[str, Any]: def payload(
self,
*,
controls_available: bool,
folder_picker_available: bool = False,
) -> dict[str, Any]:
return workspaces_payload( return workspaces_payload(
default_workspace=self._default_workspace, default_workspace=self._default_workspace,
default_restrict_to_workspace=self._default_restrict_to_workspace, default_restrict_to_workspace=self._default_restrict_to_workspace,
controls_available=controls_available, controls_available=controls_available,
folder_picker_available=folder_picker_available,
) )
def scope_from_envelope( def scope_from_envelope(
+45 -1
View File
@@ -62,6 +62,7 @@ from nanobot.webui.http_utils import (
from nanobot.webui.http_utils import ( from nanobot.webui.http_utils import (
is_localhost as _is_localhost, is_localhost as _is_localhost,
) )
from nanobot.webui.http_utils import is_loopback_host as _is_loopback_host
from nanobot.webui.http_utils import ( from nanobot.webui.http_utils import (
is_trusted_proxy_authenticated_request as _is_trusted_proxy_authenticated_request, is_trusted_proxy_authenticated_request as _is_trusted_proxy_authenticated_request,
) )
@@ -85,6 +86,11 @@ from nanobot.webui.http_utils import (
) )
from nanobot.webui.ingress_policy import WebUIIngressPolicy from nanobot.webui.ingress_policy import WebUIIngressPolicy
from nanobot.webui.media_gateway import WebUIMediaGateway 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 ( from nanobot.webui.session_automations import (
all_automations_payload, all_automations_payload,
serialize_automation_jobs, serialize_automation_jobs,
@@ -133,6 +139,7 @@ _WEBUI_MUTATION_PATHS = {
"skill.update": "/api/webui/skills/update", "skill.update": "/api/webui/skills/update",
"skill.delete": "/api/webui/skills/delete", "skill.delete": "/api/webui/skills/delete",
"sidebar.update": "/api/webui/sidebar-state/update", "sidebar.update": "/api/webui/sidebar-state/update",
"workspace.pick_folder": "/api/workspaces/pick-folder",
"settings.agent.update": "/api/settings/update", "settings.agent.update": "/api/settings/update",
"settings.model_configuration.create": "/api/settings/model-configurations/create", "settings.model_configuration.create": "/api/settings/model-configurations/create",
"settings.model_configuration.update": "/api/settings/model-configurations/update", "settings.model_configuration.update": "/api/settings/model-configurations/update",
@@ -329,6 +336,7 @@ class GatewayHTTPHandler:
) )
self.skill_state_action = skill_state_action self.skill_state_action = skill_state_action
self._skill_install_lock = asyncio.Lock() self._skill_install_lock = asyncio.Lock()
self._folder_picker_lock = asyncio.Lock()
self.cron_service = cron_service self.cron_service = cron_service
self.local_trigger_store = local_trigger_store self.local_trigger_store = local_trigger_store
self.cron_pending_job_ids = cron_pending_job_ids self.cron_pending_job_ids = cron_pending_job_ids
@@ -360,6 +368,17 @@ class GatewayHTTPHandler:
def workspace_controls_available(self, connection: Any) -> bool: def workspace_controls_available(self, connection: Any) -> bool:
return self._runtime_surface == "native" or _is_localhost(connection) 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 --------------------------------------------------- # -- Token management ---------------------------------------------------
def check_api_token(self, request: WsRequest) -> bool: def check_api_token(self, request: WsRequest) -> bool:
@@ -435,6 +454,7 @@ class GatewayHTTPHandler:
"/api/webui/skills/update", "/api/webui/skills/update",
"/api/webui/skills/delete", "/api/webui/skills/delete",
"/api/webui/sidebar-state/update", "/api/webui/sidebar-state/update",
"/api/workspaces/pick-folder",
} }
@staticmethod @staticmethod
@@ -1054,6 +1074,8 @@ class GatewayHTTPHandler:
return await self._handle_sessions_list(request) return await self._handle_sessions_list(request)
if got == "/api/commands": if got == "/api/commands":
return self._handle_commands(request) return self._handle_commands(request)
if got == "/api/workspaces/pick-folder":
return await self._handle_workspace_folder_picker(connection, request)
if got == "/api/workspaces": if got == "/api/workspaces":
return self._handle_workspaces(connection, request) return self._handle_workspaces(connection, request)
if got == "/api/webui/skills/search": if got == "/api/webui/skills/search":
@@ -1089,10 +1111,32 @@ class GatewayHTTPHandler:
return _http_error(401, "Unauthorized") return _http_error(401, "Unauthorized")
return _http_json_response( return _http_json_response(
self.workspaces.payload( self.workspaces.payload(
controls_available=self.workspace_controls_available(connection) controls_available=self.workspace_controls_available(connection),
folder_picker_available=self.workspace_folder_picker_available(
connection,
request,
),
) )
) )
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: def _handle_webui_skills(self, request: WsRequest) -> Response:
if not self.check_api_token(request): if not self.check_api_token(request):
return _http_error(401, "Unauthorized") return _http_error(401, "Unauthorized")
+24 -13
View File
@@ -29,8 +29,11 @@ class TestPruneDreamSessions:
import os import os
import time import time
sessions_dir = tmp_path / "sessions" manager = SessionManager(
sessions_dir.mkdir() tmp_path / "workspace",
sessions_root=tmp_path / "runtime",
)
sessions_dir = manager.sessions_dir
base_time = time.time() - 100 base_time = time.time() - 100
dream_paths = [] dream_paths = []
@@ -50,7 +53,7 @@ class TestPruneDreamSessions:
normal_path = sessions_dir / "telegram_123.jsonl" normal_path = sessions_dir / "telegram_123.jsonl"
normal_path.write_text('{"_type": "metadata"}\n', encoding="utf-8") normal_path.write_text('{"_type": "metadata"}\n', encoding="utf-8")
MemoryStore.prune_dream_sessions(sessions_dir, keep=10) MemoryStore.prune_dream_sessions(manager, keep=10)
assert [path.exists() for path in dream_paths] == [False] * 5 + [True] * 10 assert [path.exists() for path in dream_paths] == [False] * 5 + [True] * 10
assert normal_path.exists() assert normal_path.exists()
@@ -59,8 +62,11 @@ class TestPruneDreamSessions:
import os import os
import time import time
sessions_dir = tmp_path / "sessions" manager = SessionManager(
sessions_dir.mkdir() tmp_path / "workspace",
sessions_root=tmp_path / "runtime",
)
sessions_dir = manager.sessions_dir
base_time = time.time() - 100 base_time = time.time() - 100
current_paths = [] current_paths = []
@@ -81,24 +87,29 @@ class TestPruneDreamSessions:
) )
os.utime(legacy_path, (base_time - 1, base_time - 1)) os.utime(legacy_path, (base_time - 1, base_time - 1))
MemoryStore.prune_dream_sessions(sessions_dir, keep=1) MemoryStore.prune_dream_sessions(manager, keep=1)
assert [path.exists() for path in current_paths] == [False, True] assert [path.exists() for path in current_paths] == [False, True]
assert legacy_path.exists() assert legacy_path.exists()
def test_noop_when_under_limit(self, tmp_path): def test_noop_when_under_limit(self, tmp_path):
sessions_dir = tmp_path / "sessions" manager = SessionManager(
sessions_dir.mkdir() tmp_path / "workspace",
sessions_root=tmp_path / "runtime",
)
sessions_dir = manager.sessions_dir
for i in range(3): for i in range(3):
key = f"dream:20260528-{100000 + i:06d}" key = f"dream:20260528-{100000 + i:06d}"
path = sessions_dir / f"{SessionManager._storage_key(key)}.jsonl" path = sessions_dir / f"{SessionManager._storage_key(key)}.jsonl"
path.write_text("{}", encoding="utf-8") path.write_text("{}", encoding="utf-8")
MemoryStore.prune_dream_sessions(sessions_dir, keep=10) MemoryStore.prune_dream_sessions(manager, keep=10)
assert len(list(sessions_dir.glob("*.jsonl"))) == 3 assert len(list(sessions_dir.glob("*.jsonl"))) == 3
def test_empty_dir_noop(self, tmp_path): def test_empty_dir_noop(self, tmp_path):
sessions_dir = tmp_path / "sessions" manager = SessionManager(
sessions_dir.mkdir() tmp_path / "workspace",
MemoryStore.prune_dream_sessions(sessions_dir, keep=10) sessions_root=tmp_path / "runtime",
assert list(sessions_dir.iterdir()) == [] )
MemoryStore.prune_dream_sessions(manager, keep=10)
assert list(manager.sessions_dir.glob("*.jsonl")) == []
+39 -8
View File
@@ -4,6 +4,9 @@ import json
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
import pytest
from filelock import Timeout
from nanobot.providers.base import ProviderConversationState from nanobot.providers.base import ProviderConversationState
from nanobot.session.manager import Session, SessionManager from nanobot.session.manager import Session, SessionManager
@@ -37,14 +40,23 @@ class TestAtomicSave:
tmp_files = list(mgr.sessions_dir.glob("*.tmp")) tmp_files = list(mgr.sessions_dir.glob("*.tmp"))
assert tmp_files == [] assert tmp_files == []
def test_tmp_file_cleaned_up_on_write_failure(self, tmp_path: Path): def test_unique_tmp_file_cleaned_up_on_write_failure(
self,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
):
mgr = SessionManager(tmp_path) mgr = SessionManager(tmp_path)
session = Session(key="test:fail") session = Session(key="test:fail")
path = mgr._get_session_path("test:fail") path = mgr._get_session_path("test:fail")
tmp_path_file = path.with_suffix(".jsonl.tmp") stale_shared_tmp = path.with_suffix(".jsonl.tmp")
unique_tmp = path.with_name(f".{path.name}.save-failure.tmp")
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
tmp_path_file.write_text("stale") stale_shared_tmp.write_text("stale", encoding="utf-8")
monkeypatch.setattr(
"nanobot.session.manager.secrets.token_hex",
lambda _length: "save-failure",
)
class BadMessage: class BadMessage:
def __init__(self, data): def __init__(self, data):
@@ -64,13 +76,17 @@ class TestAtomicSave:
] ]
import unittest.mock import unittest.mock
with unittest.mock.patch("nanobot.session.manager.json.dumps", side_effect=failing_dumps): with (
try: unittest.mock.patch(
"nanobot.session.manager.json.dumps",
side_effect=failing_dumps,
),
pytest.raises(OSError, match="simulated disk full"),
):
mgr.save(session) mgr.save(session)
except OSError:
pass
assert not tmp_path_file.exists() assert not unique_tmp.exists()
assert stale_shared_tmp.read_text(encoding="utf-8") == "stale"
def test_overwrite_preserves_latest_data(self, tmp_path: Path): def test_overwrite_preserves_latest_data(self, tmp_path: Path):
mgr = SessionManager(tmp_path) mgr = SessionManager(tmp_path)
@@ -102,6 +118,21 @@ class TestAtomicSave:
for i in range(5): for i in range(5):
assert loaded.messages[i]["content"] == f"msg{i}" 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): def test_provider_state_round_trips_in_private_record_only(self, tmp_path: Path):
mgr = SessionManager(tmp_path) mgr = SessionManager(tmp_path)
secret = "encrypted-reasoning-blob" secret = "encrypted-reasoning-blob"
@@ -1,3 +1,5 @@
import pytest
from nanobot.providers.base import ProviderConversationState from nanobot.providers.base import ProviderConversationState
from nanobot.runtime_context import ( from nanobot.runtime_context import (
RUNTIME_CONTEXT_HISTORY_META, RUNTIME_CONTEXT_HISTORY_META,
@@ -981,6 +983,36 @@ 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(): def test_retain_recent_legal_suffix_last_consolidated_correct_in_else_branch():
"""last_consolidated after retain_recent_legal_suffix should reflect how """last_consolidated after retain_recent_legal_suffix should reflect how
many retained messages were inside the old consolidated prefix.""" many retained messages were inside the old consolidated prefix."""
+12 -12
View File
@@ -16,6 +16,7 @@ from nanobot.command.builtin import (
cmd_dream_restore, cmd_dream_restore,
) )
from nanobot.command.router import CommandContext from nanobot.command.router import CommandContext
from nanobot.session.manager import SessionManager
from nanobot.utils.gitstore import CommitInfo from nanobot.utils.gitstore import CommitInfo
@@ -107,6 +108,13 @@ class _FakeBus:
self.outbound.append(message) 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: 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) msg = InboundMessage(channel="cli", sender_id="u1", chat_id="direct", content=raw)
store = _FakeStore(git, last_dream_cursor=last_dream_cursor) store = _FakeStore(git, last_dream_cursor=last_dream_cursor)
@@ -118,12 +126,10 @@ def _make_dream_ctx(tmp_path) -> tuple[CommandContext, _FakeBus]:
msg = InboundMessage(channel="cli", sender_id="u1", chat_id="direct", content="/dream") msg = InboundMessage(channel="cli", sender_id="u1", chat_id="direct", content="/dream")
store = _FakeStore(_FakeGit(initialized=False), dream_prompt_result=None) store = _FakeStore(_FakeGit(initialized=False), dream_prompt_result=None)
bus = _FakeBus() bus = _FakeBus()
sessions_dir = tmp_path / "sessions"
sessions_dir.mkdir()
loop = SimpleNamespace( loop = SimpleNamespace(
bus=bus, bus=bus,
context=SimpleNamespace(memory=store, timezone="UTC"), context=SimpleNamespace(memory=store, timezone="UTC"),
sessions=SimpleNamespace(sessions_dir=sessions_dir), sessions=_make_sessions(tmp_path),
) )
ctx = CommandContext(msg=msg, session=None, key=msg.session_key, raw="/dream", args="", loop=loop) ctx = CommandContext(msg=msg, session=None, key=msg.session_key, raw="/dream", args="", loop=loop)
return ctx, bus return ctx, bus
@@ -169,13 +175,11 @@ async def test_dream_internal_run_silences_progress(tmp_path) -> None:
metadata={"_stop_reason": "completed"}, metadata={"_stop_reason": "completed"},
) )
sessions_dir = tmp_path / "sessions"
sessions_dir.mkdir()
dream_runtime = object() dream_runtime = object()
loop = SimpleNamespace( loop = SimpleNamespace(
bus=bus, bus=bus,
context=SimpleNamespace(memory=store, timezone="UTC"), context=SimpleNamespace(memory=store, timezone="UTC"),
sessions=SimpleNamespace(sessions_dir=sessions_dir), sessions=_make_sessions(tmp_path),
process_direct=process_direct, process_direct=process_direct,
dream_runtime=lambda: dream_runtime, dream_runtime=lambda: dream_runtime,
) )
@@ -224,12 +228,10 @@ def _build_runnable_dream(
) )
bus = _FakeBus() bus = _FakeBus()
sessions_dir = tmp_path / "sessions"
sessions_dir.mkdir()
loop = SimpleNamespace( loop = SimpleNamespace(
bus=bus, bus=bus,
context=SimpleNamespace(memory=store, timezone="UTC"), context=SimpleNamespace(memory=store, timezone="UTC"),
sessions=SimpleNamespace(sessions_dir=sessions_dir), sessions=_make_sessions(tmp_path),
process_direct=process_direct, process_direct=process_direct,
dream_runtime=lambda: None, dream_runtime=lambda: None,
) )
@@ -311,12 +313,10 @@ async def test_dream_noop_batch_unlocks_following_history(tmp_path) -> None:
msg = InboundMessage(channel="cli", sender_id="u1", chat_id="direct", content="/dream") msg = InboundMessage(channel="cli", sender_id="u1", chat_id="direct", content="/dream")
bus = _FakeBus() bus = _FakeBus()
sessions_dir = tmp_path / "sessions"
sessions_dir.mkdir()
loop = SimpleNamespace( loop = SimpleNamespace(
bus=bus, bus=bus,
context=SimpleNamespace(memory=store, timezone="UTC"), context=SimpleNamespace(memory=store, timezone="UTC"),
sessions=SimpleNamespace(sessions_dir=sessions_dir), sessions=_make_sessions(tmp_path),
process_direct=process_direct, process_direct=process_direct,
dream_runtime=lambda: None, dream_runtime=lambda: None,
) )
+5 -3
View File
@@ -6,7 +6,7 @@ import errno
import os import os
import sys import sys
from pathlib import Path from pathlib import Path
from unittest.mock import patch from unittest.mock import call, patch
import pytest import pytest
@@ -65,6 +65,7 @@ class TestSaveFsync:
session.add_message("user", "hello") session.add_message("user", "hello")
directory_fd = 987654 directory_fd = 987654
with ( with (
manager.locked_session_files(),
patch("nanobot.session.manager.os.open", return_value=directory_fd) as open_dir, patch("nanobot.session.manager.os.open", return_value=directory_fd) as open_dir,
patch( patch(
"nanobot.session.manager.os.fsync", "nanobot.session.manager.os.fsync",
@@ -76,7 +77,7 @@ class TestSaveFsync:
assert manager._get_session_path(session.key).exists() assert manager._get_session_path(session.key).exists()
open_dir.assert_called_once_with(str(manager.sessions_dir), os.O_RDONLY) open_dir.assert_called_once_with(str(manager.sessions_dir), os.O_RDONLY)
close_dir.assert_called_once_with(directory_fd) assert close_dir.call_args_list.count(call(directory_fd)) == 1
def test_save_propagates_other_directory_fsync_errors( def test_save_propagates_other_directory_fsync_errors(
self, manager: SessionManager self, manager: SessionManager
@@ -85,6 +86,7 @@ class TestSaveFsync:
session = manager.get_or_create("test:directory-fsync-io-error") session = manager.get_or_create("test:directory-fsync-io-error")
directory_fd = 987654 directory_fd = 987654
with ( with (
manager.locked_session_files(),
patch("nanobot.session.manager.os.open", return_value=directory_fd), patch("nanobot.session.manager.os.open", return_value=directory_fd),
patch( patch(
"nanobot.session.manager.os.fsync", "nanobot.session.manager.os.fsync",
@@ -95,7 +97,7 @@ class TestSaveFsync:
): ):
manager.save(session, fsync=True) manager.save(session, fsync=True)
close_dir.assert_called_once_with(directory_fd) assert close_dir.call_args_list.count(call(directory_fd)) == 1
class TestFlushAll: class TestFlushAll:
+30
View File
@@ -1,5 +1,7 @@
from unittest.mock import MagicMock from unittest.mock import MagicMock
import pytest
import nanobot.session as session_api import nanobot.session as session_api
from nanobot.session import Session, SessionManager from nanobot.session import Session, SessionManager
from nanobot.session.manager import FILE_MAX_MESSAGES, SessionStore from nanobot.session.manager import FILE_MAX_MESSAGES, SessionStore
@@ -77,3 +79,31 @@ def test_manager_applies_file_cap_before_store_save(tmp_path) -> None:
assert len(session.messages) == FILE_MAX_MESSAGES assert len(session.messages) == FILE_MAX_MESSAGES
archiver.assert_called_once() archiver.assert_called_once()
store.save.assert_called_once_with(session, fsync=False) 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
@@ -0,0 +1,112 @@
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,6 +72,7 @@ def test_workspace_payload_is_config_data_dir_scoped(tmp_path, monkeypatch) -> N
assert payload["default_scope"]["access_mode"] == "full" assert payload["default_scope"]["access_mode"] == "full"
assert payload["default_access_mode"] == "default" assert payload["default_access_mode"] == "default"
assert payload["controls"]["can_change_project"] is True 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( def test_workspace_payload_hides_mutable_state_when_controls_unavailable(
@@ -91,6 +92,22 @@ def test_workspace_payload_hides_mutable_state_when_controls_unavailable(
assert payload["default_scope"]["project_path"] == str(default.resolve()) assert payload["default_scope"]["project_path"] == str(default.resolve())
assert payload["controls"]["can_change_project"] is False assert payload["controls"]["can_change_project"] is False
assert payload["controls"]["can_use_full_access"] 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: def test_workspace_payload_uses_webui_default_access_mode(tmp_path, monkeypatch) -> None:
+98
View File
@@ -3,6 +3,8 @@ from __future__ import annotations
import io import io
import json import json
import os import os
import threading
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
@@ -50,6 +52,20 @@ def test_webui_session_list_reuses_valid_index_without_scanning_files(
assert rows[0]["model_preset"] == "fast" 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( def test_webui_session_list_indexes_workspace_scope_and_preserves_null(
tmp_path: Path, tmp_path: Path,
) -> None: ) -> None:
@@ -133,6 +149,88 @@ 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" 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( def test_webui_session_list_rejects_invalid_internal_model_preset_metadata(
tmp_path: Path, tmp_path: Path,
) -> None: ) -> None:
+4 -6
View File
@@ -563,7 +563,7 @@ function PairingCodePopup({
aria-label={t("app.pairing.title", { defaultValue: "Pair a chat user" })} aria-label={t("app.pairing.title", { defaultValue: "Pair a chat user" })}
className={cn( className={cn(
"fixed right-4 top-[calc(0.75rem+env(safe-area-inset-top))] z-[70]", "fixed right-4 top-[calc(0.75rem+env(safe-area-inset-top))] z-[70]",
"w-[min(calc(100vw-2rem),24rem)] rounded-[24px]", "w-[min(calc(100vw-2rem),24rem)] rounded-modal",
floatingSurfaceElevationClassName, floatingSurfaceElevationClassName,
"p-4", "p-4",
"animate-in fade-in-0 slide-in-from-top-2 duration-200", "animate-in fade-in-0 slide-in-from-top-2 duration-200",
@@ -2718,7 +2718,6 @@ function Shell({
addPaneDisabled={creatingPane || activePaneLimitReached} addPaneDisabled={creatingPane || activePaneLimitReached}
addPaneDisabledLabel={activePaneLimitReached addPaneDisabledLabel={activePaneLimitReached
? t("workbench.paneLimit", { ? t("workbench.paneLimit", {
defaultValue: "Maximum {{count}} panes",
count: MAX_WORKBENCH_PANES, count: MAX_WORKBENCH_PANES,
}) })
: undefined} : undefined}
@@ -2812,7 +2811,6 @@ function Shell({
composerPortalTarget={context.composerPortalTarget} composerPortalTarget={context.composerPortalTarget}
composerActive={context.active} composerActive={context.active}
composerInputAriaLabel={t("workbench.composerAria", { composerInputAriaLabel={t("workbench.composerAria", {
defaultValue: "Message {{title}}",
title: pane.title, title: pane.title,
})} })}
emptyComposerVariant="thread" emptyComposerVariant="thread"
@@ -2892,9 +2890,9 @@ function Shell({
<RenameChatDialog <RenameChatDialog
open open
title={pendingTabRename.label} title={pendingTabRename.label}
dialogTitle={t("workbench.renameTabTitle")} dialogTitle={t("workbench.renameGroupTitle")}
description={t("workbench.renameTabDescription")} description={t("workbench.renameGroupDescription")}
placeholder={t("workbench.renameTabPlaceholder")} placeholder={t("workbench.renameGroupPlaceholder")}
onCancel={() => setPendingTabRename(null)} onCancel={() => setPendingTabRename(null)}
onConfirm={onConfirmTabRename} onConfirm={onConfirmTabRename}
/> />
+3 -3
View File
@@ -94,7 +94,7 @@ export function AttachmentTile({ attachment, className, inline = false, variant
title={attachment.name ?? undefined} title={attachment.name ?? undefined}
aria-label={label} aria-label={label}
className={cn( className={cn(
"flex max-w-[18rem] items-center gap-2 rounded-[14px]", "flex max-w-[18rem] items-center gap-2 rounded-control",
"border border-border/60 bg-muted/40 px-3 py-2 text-xs text-muted-foreground", "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", "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]", 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 ( return (
<div <div
className={cn( className={cn(
"flex max-w-[18rem] items-center gap-2 rounded-[14px]", "flex max-w-[18rem] items-center gap-2 rounded-control",
"border border-border/60 bg-muted/35 px-3 py-2 text-xs text-muted-foreground", "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]", variant === "compact" && "max-w-[14rem] rounded-xl px-2.5 py-1.5 text-[11.5px]",
className, className,
@@ -139,7 +139,7 @@ function AttachmentFrame({
variant?: "default" | "compact"; variant?: "default" | "compact";
}) { }) {
const frameClassName = cn( const frameClassName = cn(
"not-prose my-3 block w-fit max-w-full overflow-hidden rounded-[14px]", "not-prose my-3 block w-fit max-w-full overflow-hidden rounded-control",
"border border-border/60 bg-muted/40", "border border-border/60 bg-muted/40",
attachment.kind === "image" && "bg-background/85", attachment.kind === "image" && "bg-background/85",
attachment.kind === "video" ? "w-[min(100%,32rem)]" : "", 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 <span
data-testid={`${testIdPrefix}-cli-mention-logo-${app.name}`} data-testid={`${testIdPrefix}-cli-mention-logo-${app.name}`}
className={cn( className={cn(
"absolute left-1/2 top-1/2 grid place-items-center overflow-hidden rounded-[3px]", "absolute left-1/2 top-1/2 grid place-items-center overflow-hidden rounded-mark",
"-translate-x-1/2 -translate-y-1/2", "-translate-x-1/2 -translate-y-1/2",
isHero ? "h-[0.74em] w-[0.74em]" : "h-[0.72em] w-[0.72em]", isHero ? "h-[0.74em] w-[0.74em]" : "h-[0.72em] w-[0.72em]",
)} )}
@@ -260,7 +260,7 @@ export function McpPresetMentionToken({
<span <span
data-testid={`${testIdPrefix}-mcp-mention-logo-${preset.name}`} data-testid={`${testIdPrefix}-mcp-mention-logo-${preset.name}`}
className={cn( className={cn(
"absolute left-1/2 top-1/2 grid place-items-center overflow-hidden rounded-[3px]", "absolute left-1/2 top-1/2 grid place-items-center overflow-hidden rounded-mark",
"-translate-x-1/2 -translate-y-1/2", "-translate-x-1/2 -translate-y-1/2",
isHero ? "h-[0.74em] w-[0.74em]" : "h-[0.72em] w-[0.72em]", 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 <div
className={cn( className={cn(
"not-prose relative overflow-hidden", "not-prose relative overflow-hidden",
hasChrome && "rounded-[18px] bg-secondary/70", hasChrome && "rounded-floating bg-secondary/70",
className, className,
)} )}
data-language={language || t("code.fallbackLanguage")} 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" className="w-[min(calc(100vw-2rem),24rem)] gap-0 p-5 text-center"
> >
<AlertDialogHeader className="items-center space-y-0 text-center"> <AlertDialogHeader className="items-center space-y-0 text-center">
<div className="mb-5 grid h-16 w-16 place-items-center rounded-full bg-destructive/10 text-destructive"> <Trash2
<div className="grid h-9 w-9 place-items-center rounded-full border border-destructive/20 bg-destructive/5"> className="mb-4 h-6 w-6 text-destructive"
<Trash2 className="h-5 w-5" strokeWidth={2.4} aria-hidden /> strokeWidth={1.8}
</div> aria-hidden
</div> />
<AlertDialogTitle className="text-center text-[20px] font-semibold leading-tight tracking-[-0.02em] text-foreground"> <AlertDialogTitle className="text-center text-[20px] font-semibold leading-tight tracking-[-0.02em] text-foreground">
{multiple {multiple
? t("deleteConfirm.titleMany", { ? t("deleteConfirm.titleMany", {
@@ -96,16 +96,16 @@ export function DeleteConfirm({
</div> </div>
) : null} ) : null}
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter className="mt-7 !grid grid-cols-1 gap-3 space-x-0 sm:grid-cols-2 sm:space-x-0"> <AlertDialogFooter className="mt-6 !grid grid-cols-1 gap-2 sm:grid-cols-2">
<AlertDialogCancel <AlertDialogCancel
onClick={onCancel} onClick={onCancel}
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" className="mt-0 w-full min-w-0"
> >
{t("deleteConfirm.cancel")} {t("deleteConfirm.cancel")}
</AlertDialogCancel> </AlertDialogCancel>
<AlertDialogAction <AlertDialogAction
onClick={onConfirm} onClick={onConfirm}
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" className="w-full min-w-0 !whitespace-normal bg-destructive text-center text-destructive-foreground hover:bg-destructive/90"
> >
{hasAutomations {hasAutomations
? t("deleteConfirm.confirmWithAutomations") ? t("deleteConfirm.confirmWithAutomations")
+1 -1
View File
@@ -183,7 +183,7 @@ export function FilePreviewPanel({
) : null} ) : null}
<span <span
className={cn( className={cn(
"min-w-0 truncate rounded-[4px] px-1 py-0.5", "min-w-0 truncate rounded-mark px-1 py-0.5",
isLast isLast
? "font-medium text-foreground" ? "font-medium text-foreground"
: "max-w-[26vw] shrink text-muted-foreground/78", : "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", "text-sky-600 transition-colors hover:text-sky-700",
"dark:text-sky-300 dark:hover:text-sky-200", "dark:text-sky-300 dark:hover:text-sky-200",
interactive && [ interactive && [
"cursor-pointer rounded-[5px]", "cursor-pointer rounded-compact",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-400/45", "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-400/45",
], ],
)} )}
@@ -110,7 +110,7 @@ export function FileReferenceChip({
sideOffset={8} sideOffset={8}
collisionPadding={12} collisionPadding={12}
className={cn( className={cn(
"max-w-[min(38rem,calc(100vw-2rem))] rounded-[10px]", "max-w-[min(38rem,calc(100vw-2rem))] rounded-control",
"px-2.5 py-1.5", "px-2.5 py-1.5",
"break-all font-mono text-[11px] leading-snug text-popover-foreground", "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 ?? ""} alt={current.name ?? ""}
decoding="async" decoding="async"
draggable={false} draggable={false}
className="max-h-[92vh] max-w-[94vw] select-none rounded-[6px] object-contain shadow-2xl" className="max-h-[92vh] max-w-[94vw] select-none rounded-compact object-contain shadow-2xl"
/> />
</div> </div>
@@ -450,7 +450,7 @@ function InlineLinkPreviewRow({ link }: { link: InlineLinkPreview }) {
> >
<span <span
className={cn( className={cn(
"relative grid h-4 w-4 shrink-0 place-items-center overflow-hidden rounded-[4px]", "relative grid h-4 w-4 shrink-0 place-items-center overflow-hidden rounded-mark",
"border border-border/65 bg-background text-muted-foreground", "border border-border/65 bg-background text-muted-foreground",
)} )}
aria-hidden aria-hidden
@@ -459,7 +459,7 @@ function InlineLinkPreviewRow({ link }: { link: InlineLinkPreview }) {
<img <img
src={favicon} src={favicon}
alt="" alt=""
className="h-3 w-3 rounded-[2px] object-contain" className="h-3 w-3 rounded-mark object-contain"
decoding="async" decoding="async"
loading="lazy" loading="lazy"
referrerPolicy="no-referrer" referrerPolicy="no-referrer"
@@ -745,7 +745,7 @@ export default function MarkdownTextRenderer({
}, },
mark({ children: markdownChildren }) { mark({ children: markdownChildren }) {
return ( return (
<mark className="rounded-[5px] bg-yellow-200/75 px-1 py-0.5 text-inherit dark:bg-yellow-300/25"> <mark className="rounded-compact bg-yellow-200/75 px-1 py-0.5 text-inherit dark:bg-yellow-300/25">
{markdownChildren} {markdownChildren}
</mark> </mark>
); );
+4 -4
View File
@@ -331,7 +331,7 @@ export function MessageBubble({
<p <p
data-temporary-message={temporary ? "true" : undefined} data-temporary-message={temporary ? "true" : undefined}
className={cn( className={cn(
"ml-auto w-fit max-w-full min-w-0 rounded-[18px] px-4 py-2", "ml-auto w-fit max-w-full min-w-0 rounded-floating px-4 py-2",
"text-left text-[16px]/[1.75] whitespace-pre-wrap [overflow-wrap:anywhere]", "text-left text-[16px]/[1.75] whitespace-pre-wrap [overflow-wrap:anywhere]",
temporary temporary
? "border border-dashed border-muted-foreground/40 bg-transparent" ? "border border-dashed border-muted-foreground/40 bg-transparent"
@@ -499,7 +499,7 @@ function UserQuotedContext({ text, label }: { text: string; label: string }) {
return ( return (
<blockquote <blockquote
className={cn( className={cn(
"ml-auto flex w-fit max-w-full min-w-0 items-start gap-2 rounded-[14px]", "ml-auto flex w-fit max-w-full min-w-0 items-start gap-2 rounded-control",
"border border-border/60 bg-muted/35 px-3 py-2 text-left text-muted-foreground", "border border-border/60 bg-muted/35 px-3 py-2 text-left text-muted-foreground",
)} )}
aria-label={label} aria-label={label}
@@ -718,8 +718,8 @@ function UserImageCell({
const tileClasses = cn( const tileClasses = cn(
"relative overflow-hidden border border-border/60 bg-muted/40", "relative overflow-hidden border border-border/60 bg-muted/40",
size === "large" size === "large"
? "w-[min(100%,34rem)] rounded-[20px] bg-transparent" ? "w-[min(100%,34rem)] rounded-panel bg-transparent"
: "h-24 w-24 rounded-[14px]", : "h-24 w-24 rounded-control",
"shadow-[0_6px_18px_-14px_rgba(0,0,0,0.45)]", "shadow-[0_6px_18px_-14px_rgba(0,0,0,0.45)]",
); );
+9 -4
View File
@@ -44,7 +44,7 @@ export function RenameChatDialog({
<Dialog open={open} onOpenChange={(next) => { <Dialog open={open} onOpenChange={(next) => {
if (!next) onCancel(); if (!next) onCancel();
}}> }}>
<DialogContent className="max-w-sm p-5"> <DialogContent className="max-w-sm">
<form <form
className="grid gap-4" className="grid gap-4"
onSubmit={(event) => { onSubmit={(event) => {
@@ -66,11 +66,16 @@ export function RenameChatDialog({
autoFocus autoFocus
maxLength={160} maxLength={160}
/> />
<DialogFooter className="gap-2 sm:space-x-0"> <DialogFooter>
<Button type="button" variant="outline" onClick={onCancel}> <Button
className="min-w-20"
type="button"
variant="outline"
onClick={onCancel}
>
{t("deleteConfirm.cancel")} {t("deleteConfirm.cancel")}
</Button> </Button>
<Button type="submit" disabled={!trimmed}> <Button className="min-w-20" type="submit" disabled={!trimmed}>
{t("chat.renameSave")} {t("chat.renameSave")}
</Button> </Button>
</DialogFooter> </DialogFooter>
+1 -2
View File
@@ -120,7 +120,6 @@ export function SessionSearchDialog({
showCloseButton={false} showCloseButton={false}
className={cn( 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", "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> <DialogTitle className="sr-only">{t("sidebar.searchAria")}</DialogTitle>
@@ -183,7 +182,7 @@ export function SessionSearchDialog({
onMouseEnter={() => setHighlightedIndex(index)} onMouseEnter={() => setHighlightedIndex(index)}
aria-current={active ? "page" : undefined} aria-current={active ? "page" : undefined}
className={cn( className={cn(
"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", "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",
highlighted highlighted
? "bg-muted text-foreground" ? "bg-muted text-foreground"
: "text-foreground hover:bg-muted", : "text-foreground hover:bg-muted",
@@ -621,7 +621,7 @@ export function SettingsPage({
) : null} ) : null}
{loading ? ( {loading ? (
<div className="flex h-48 items-center justify-center rounded-[22px] bg-settings-surface text-sm text-muted-foreground"> <div className="flex h-48 items-center justify-center rounded-panel bg-settings-surface text-sm text-muted-foreground">
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t("settings.status.loading")} {t("settings.status.loading")}
</div> </div>
@@ -640,7 +640,7 @@ export function SettingsPage({
)} )}
> >
{error ? ( {error ? (
<div className="rounded-[18px] border border-destructive/20 bg-destructive/5 px-4 py-3 text-[13px] text-destructive"> <div className="rounded-floating border border-destructive/20 bg-destructive/5 px-4 py-3 text-[13px] text-destructive">
{error} {error}
</div> </div>
) : null} ) : null}
@@ -102,7 +102,7 @@ export function SettingsSidebar({
<button <button
type="button" type="button"
aria-label={`${t("settings.sidebar.title")}: ${activeLabel}`} aria-label={`${t("settings.sidebar.title")}: ${activeLabel}`}
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" 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"
> >
<ActiveIcon className="h-4 w-4 shrink-0" strokeWidth={2} aria-hidden /> <ActiveIcon className="h-4 w-4 shrink-0" strokeWidth={2} aria-hidden />
<span className="min-w-0 flex-1 truncate">{activeLabel}</span> <span className="min-w-0 flex-1 truncate">{activeLabel}</span>
@@ -176,7 +176,7 @@ export function SettingsSidebar({
type="button" type="button"
variant="ghost" variant="ghost"
onClick={onLogout} onClick={onLogout}
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" 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"
> >
<LogOut className="h-4 w-4" aria-hidden /> <LogOut className="h-4 w-4" aria-hidden />
{t("app.account.logout")} {t("app.account.logout")}
@@ -98,7 +98,7 @@ export function SkillsCatalogSettings({ skills }: { skills: SkillSummary[] }) {
/> />
{view === "installed" ? ( {view === "installed" ? (
<section className="overflow-hidden rounded-[22px] bg-settings-surface"> <section className="overflow-hidden rounded-panel 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="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]"> <div className="relative w-full sm:max-w-[320px]">
<Search <Search
@@ -114,7 +114,7 @@ export function SkillsCatalogSettings({ skills }: { skills: SkillSummary[] }) {
aria-label={t("settings.skills.searchInstalled", { aria-label={t("settings.skills.searchInstalled", {
defaultValue: "Search installed skills", defaultValue: "Search installed skills",
})} })}
className="h-9 rounded-[11px] bg-background pl-9 text-[13px]" className="h-9 bg-background pl-9 text-[13px]"
/> />
</div> </div>
<SegmentedControl <SegmentedControl
@@ -220,7 +220,7 @@ function SkillCatalogRow({
})} })}
onClick={() => onSelect(skill)} onClick={() => onSelect(skill)}
className={cn( className={cn(
"group flex w-full min-w-0 items-center gap-3 rounded-[14px] px-2 py-3 text-left", "group flex w-full min-w-0 items-center gap-3 rounded-control px-2 py-3 text-left",
"transition-colors duration-150", "transition-colors duration-150",
"hover:bg-muted/70", "hover:bg-muted/70",
"focus-visible:bg-muted/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", "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..." })} {t("settings.skills.loadingDetail", { defaultValue: "Loading skill details..." })}
</div> </div>
) : loadFailed ? ( ) : loadFailed ? (
<div className="mt-8 rounded-[16px] bg-destructive/10 px-3 py-3 text-sm text-destructive"> <div className="mt-8 rounded-floating bg-destructive/10 px-3 py-3 text-sm text-destructive">
{t("settings.skills.loadFailed", { defaultValue: "Could not load skill details." })} {t("settings.skills.loadFailed", { defaultValue: "Could not load skill details." })}
</div> </div>
) : ( ) : (
@@ -484,7 +484,7 @@ function SkillDetailSheet({
</div> </div>
{actionError ? ( {actionError ? (
<div className="rounded-[14px] bg-destructive/10 px-3 py-2.5 text-[13px] text-destructive"> <div className="rounded-control bg-destructive/10 px-3 py-2.5 text-[13px] text-destructive">
{actionError} {actionError}
</div> </div>
) : null} ) : null}
@@ -533,7 +533,7 @@ function SkillDetailSheet({
</Sheet> </Sheet>
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}> <AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<AlertDialogContent className="rounded-[20px]"> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle> <AlertDialogTitle>
{t("settings.skills.deleteConfirmTitle", { {t("settings.skills.deleteConfirmTitle", {
@@ -574,7 +574,7 @@ function RawInstructionsBlock({ markdown }: { markdown: string }) {
}); });
return ( return (
<details className="group rounded-[18px] border border-border/45 bg-muted/20 px-3 py-3"> <details className="group rounded-floating 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"> <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> <span>
{t("settings.skills.instructionsTitle", { defaultValue: "Skill instructions" })} {t("settings.skills.instructionsTitle", { defaultValue: "Skill instructions" })}
@@ -583,7 +583,7 @@ function RawInstructionsBlock({ markdown }: { markdown: string }) {
SKILL.md SKILL.md
</code> </code>
</summary> </summary>
<div className="mt-3 overflow-hidden rounded-[14px] border border-border/35 bg-background/70"> <div className="mt-3 overflow-hidden rounded-control border border-border/35 bg-background/70">
<pre <pre
className={cn( className={cn(
"max-h-[min(42vh,32rem)] overflow-auto overscroll-contain px-3.5 py-3 pr-4", "max-h-[min(42vh,32rem)] overflow-auto overscroll-contain px-3.5 py-3 pr-4",
@@ -625,7 +625,7 @@ function RequirementsSection({
}; };
return ( return (
<section className="rounded-[18px] border border-amber-500/20 bg-amber-500/[0.06] p-4"> <section className="rounded-floating border border-amber-500/20 bg-amber-500/[0.06] p-4">
<div className="flex items-start gap-2.5"> <div className="flex items-start gap-2.5">
<CircleAlert <CircleAlert
className="mt-0.5 h-4 w-4 shrink-0 text-amber-700 dark:text-amber-300" 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) => ( {installOptions.map((option) => (
<div <div
key={`${option.id}:${option.command}`} key={`${option.id}:${option.command}`}
className="flex min-w-0 items-center gap-2 rounded-[12px] bg-background/80 px-3 py-2" className="flex min-w-0 items-center gap-2 rounded-control bg-background/80 px-3 py-2"
> >
<Terminal className="h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden /> <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"> <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} title={option.label}
onClick={() => void copyCommand(option.command)} onClick={() => void copyCommand(option.command)}
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" 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"
> >
{copiedCommand === option.command ? ( {copiedCommand === option.command ? (
<Check className="h-3.5 w-3.5 text-emerald-600" aria-hidden /> <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", { aria-label={t("settings.skills.marketplaceSearchLabel", {
defaultValue: "Search skills", defaultValue: "Search skills",
})} })}
className="h-11 rounded-[14px] bg-settings-surface pl-9" className="h-11 rounded-control bg-settings-surface pl-9"
/> />
{loading ? ( {loading ? (
<span <span
@@ -226,13 +226,13 @@ export function SkillsMarketplace({
</div> </div>
{error ? ( {error ? (
<div className="rounded-[14px] bg-destructive/10 px-3 py-2.5 text-[13px] text-destructive"> <div className="rounded-control bg-destructive/10 px-3 py-2.5 text-[13px] text-destructive">
{error} {error}
</div> </div>
) : null} ) : null}
{query.trim().length < 2 ? ( {query.trim().length < 2 ? (
<section className="overflow-hidden rounded-[22px] bg-settings-surface"> <section className="overflow-hidden rounded-panel 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"> <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"> <h2 className="text-[14px] font-semibold">
{t("settings.skills.marketplaceTrendingTitle", { {t("settings.skills.marketplaceTrendingTitle", {
@@ -271,14 +271,14 @@ export function SkillsMarketplace({
)} )}
</section> </section>
) : !loading && visibleResults.length === 0 && !error ? ( ) : !loading && visibleResults.length === 0 && !error ? (
<div className="rounded-[22px] bg-settings-surface px-5 py-12 text-center text-sm text-muted-foreground"> <div className="rounded-panel bg-settings-surface px-5 py-12 text-center text-sm text-muted-foreground">
{t("settings.skills.marketplaceEmpty", { {t("settings.skills.marketplaceEmpty", {
query: query.trim(), query: query.trim(),
defaultValue: "No skills found for “{{query}}”.", defaultValue: "No skills found for “{{query}}”.",
})} })}
</div> </div>
) : ( ) : (
<div className="overflow-hidden rounded-[22px] bg-settings-surface"> <div className="overflow-hidden rounded-panel bg-settings-surface">
<MarketplaceSkillGroups <MarketplaceSkillGroups
skills={visibleResults} skills={visibleResults}
installedNames={installedNames} installedNames={installedNames}
@@ -296,9 +296,9 @@ export function SkillsMarketplace({
if (!open) setSelected(null); if (!open) setSelected(null);
}} }}
> >
<AlertDialogContent className="rounded-[20px]"> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<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"> <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">
<ShieldAlert className="h-5 w-5" aria-hidden /> <ShieldAlert className="h-5 w-5" aria-hidden />
</div> </div>
<AlertDialogTitle> <AlertDialogTitle>
@@ -316,7 +316,7 @@ export function SkillsMarketplace({
"This third-party skill comes from {{provider}} ({{source}}) and may include instructions or executable scripts.", "This third-party skill comes from {{provider}} ({{source}}) and may include instructions or executable scripts.",
})} })}
</span> </span>
<span className="flex flex-wrap items-center gap-2 rounded-md bg-muted px-2 py-1.5 text-[12px] text-foreground"> <span className="flex flex-wrap items-center gap-2 rounded-control bg-muted px-2 py-1.5 text-[12px] text-foreground">
{selected ? <ProviderMark provider={selected.provider} /> : null} {selected ? <ProviderMark provider={selected.provider} /> : null}
<code>{selected?.source}</code> <code>{selected?.source}</code>
{selected?.version ? <span>v{selected.version}</span> : null} {selected?.version ? <span>v{selected.version}</span> : null}
@@ -224,7 +224,7 @@ export function TokenUsageHeatmap({
<span <span
aria-label={ariaLabel} aria-label={ariaLabel}
className={cn( className={cn(
"aspect-square w-full rounded-[2px] transition-transform hover:scale-110 sm:rounded-[4px]", "aspect-square w-full rounded-mark transition-transform hover:scale-110",
tokenUsageCellClass(level, cell.future), tokenUsageCellClass(level, cell.future),
)} )}
/> />
@@ -136,7 +136,7 @@ export function ChannelLogo({
if (showBrandLogos && logoUrl) { if (showBrandLogos && logoUrl) {
return ( return (
<span <span
className="grid h-10 w-10 shrink-0 place-items-center rounded-[12px] bg-background" className="grid h-10 w-10 shrink-0 place-items-center rounded-control bg-background"
> >
<img <img
src={logoUrl} src={logoUrl}
@@ -154,7 +154,7 @@ export function ChannelLogo({
if (Icon) { if (Icon) {
return ( return (
<span <span
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-[12px] bg-background" className="flex h-10 w-10 shrink-0 items-center justify-center rounded-control bg-background"
style={{ color }} style={{ color }}
aria-hidden aria-hidden
> >
@@ -165,7 +165,7 @@ export function ChannelLogo({
return ( return (
<span <span
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-[12px] bg-background text-[11px] font-bold" className="flex h-10 w-10 shrink-0 items-center justify-center rounded-control bg-background text-[11px] font-bold"
style={{ color }} style={{ color }}
aria-hidden aria-hidden
> >
@@ -275,7 +275,7 @@ export function ChannelRuntimeError({
}) { }) {
if (!message) return null; if (!message) return null;
return ( return (
<div className={`${className} rounded-[12px] border border-destructive/20 bg-destructive/5 px-3 py-2 text-[12px] leading-5 text-destructive`}> <div className={`${className} rounded-control border border-destructive/20 bg-destructive/5 px-3 py-2 text-[12px] leading-5 text-destructive`}>
{message} {message}
</div> </div>
); );
@@ -144,7 +144,7 @@ export function ChannelInstancesPanel({
}; };
return ( return (
<aside className="min-h-full rounded-[20px] bg-settings-surface p-5"> <aside className="min-h-full rounded-panel bg-settings-surface p-5">
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 items-start gap-3"> <div className="flex min-w-0 items-start gap-3">
<ChannelLogo feature={feature} showBrandLogos={showBrandLogos} /> <ChannelLogo feature={feature} showBrandLogos={showBrandLogos} />
@@ -177,7 +177,7 @@ export function ChannelInstancesPanel({
<article <article
key={instance.id} key={instance.id}
className={cn( className={cn(
"overflow-hidden rounded-[18px] transition-colors", "overflow-hidden rounded-floating transition-colors",
expanded expanded
? "bg-background" ? "bg-background"
: "bg-background/70 hover:bg-muted", : "bg-background/70 hover:bg-muted",
@@ -313,7 +313,7 @@ export function ChannelInstancesPanel({
{customization.footer} {customization.footer}
{notice ? ( {notice ? (
<div className="mt-3 rounded-[12px] border border-destructive/20 px-3 py-2 text-[12px] leading-5 text-destructive"> <div className="mt-3 rounded-control border border-destructive/20 px-3 py-2 text-[12px] leading-5 text-destructive">
{notice} {notice}
</div> </div>
) : null} ) : null}
@@ -251,8 +251,8 @@ export function ChannelQrConnectFlow({
return ( return (
<div className="mt-3 space-y-3"> <div className="mt-3 space-y-3">
{pending ? ( {pending ? (
<div className="grid gap-4 rounded-[14px] border border-border/70 p-4 sm:grid-cols-[auto_minmax(0,1fr)]"> <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-[14px] border border-border/60 bg-background"> <div className="grid h-[196px] w-[196px] place-items-center rounded-control border border-border/60 bg-background">
{qrDataUrl ? ( {qrDataUrl ? (
<img <img
src={qrDataUrl} src={qrDataUrl}
@@ -293,20 +293,20 @@ export function ChannelQrConnectFlow({
) : null} ) : null}
{succeeded && !suppressSucceeded ? ( {succeeded && !suppressSucceeded ? (
<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"> <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">
<Check className="h-3.5 w-3.5" aria-hidden /> <Check className="h-3.5 w-3.5" aria-hidden />
{displayMessage ?? labels.connected} {displayMessage ?? labels.connected}
</div> </div>
) : null} ) : null}
{connect && ["expired", "failed", "cancelled"].includes(connect.status) ? ( {connect && ["expired", "failed", "cancelled"].includes(connect.status) ? (
<div className="rounded-[12px] border border-border/60 px-3 py-2 text-[12px] leading-5 text-muted-foreground"> <div className="rounded-control border border-border/60 px-3 py-2 text-[12px] leading-5 text-muted-foreground">
{displayMessage || labels.stopped} {displayMessage || labels.stopped}
</div> </div>
) : null} ) : null}
{error ? ( {error ? (
<div className="rounded-[12px] border border-destructive/20 px-3 py-2 text-[12px] leading-5 text-destructive"> <div className="rounded-control border border-destructive/20 px-3 py-2 text-[12px] leading-5 text-destructive">
{error} {error}
</div> </div>
) : null} ) : null}
@@ -87,7 +87,7 @@ export function ChannelCatalogRow({
aria-pressed={selected} aria-pressed={selected}
onClick={onSelect} onClick={onSelect}
className={cn( className={cn(
"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", "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",
selected ? "bg-background" : "hover:bg-muted", selected ? "bg-background" : "hover:bg-muted",
)} )}
> >
@@ -187,7 +187,7 @@ export function ChannelSetupPanel({
}); });
return ( return (
<aside className="min-h-full rounded-[20px] bg-settings-surface p-5"> <aside className="min-h-full rounded-panel bg-settings-surface p-5">
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div className="flex min-w-0 items-start gap-3"> <div className="flex min-w-0 items-start gap-3">
<ChannelLogo feature={feature} showBrandLogos={showBrandLogos} /> <ChannelLogo feature={feature} showBrandLogos={showBrandLogos} />
@@ -482,7 +482,7 @@ function ChannelSetupSurface({
) : null} ) : null}
</div> </div>
{setup.command ? ( {setup.command ? (
<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"> <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">
{setup.command} {setup.command}
</code> </code>
) : null} ) : null}
@@ -538,7 +538,7 @@ function ChannelSetupSurface({
{notice ? ( {notice ? (
<div <div
role="status" role="status"
className="rounded-[12px] bg-muted/55 px-3 py-2.5 text-[12px] leading-5 text-muted-foreground" className="rounded-control bg-muted/55 px-3 py-2.5 text-[12px] leading-5 text-muted-foreground"
> >
{notice} {notice}
</div> </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", "inline-flex max-w-full items-center gap-2 bg-background/80 font-semibold text-foreground transition-colors hover:bg-background",
compact compact
? "shrink-0 rounded-full py-1 pl-1 pr-2.5 text-[11.5px]" ? "shrink-0 rounded-full py-1 pl-1 pr-2.5 text-[11.5px]"
: "mt-3 rounded-[12px] py-1.5 pl-1.5 pr-3 text-[12px]", : "mt-3 rounded-control py-1.5 pl-1.5 pr-3 text-[12px]",
)} )}
> >
<span <span
className={cn( className={cn(
"grid shrink-0 place-items-center overflow-hidden bg-muted/70 font-bold", "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-[7px] text-[10px]", compact ? "h-5 w-5 rounded-full text-[9px]" : "h-6 w-6 rounded-compact text-[10px]",
)} )}
style={{ color }} style={{ color }}
aria-hidden aria-hidden
@@ -231,7 +231,7 @@ export function ChannelProviderPresets({
<div <div
role="radiogroup" role="radiogroup"
aria-label={t("settings.channels.providerPreset", { defaultValue: "Provider" })} aria-label={t("settings.channels.providerPreset", { defaultValue: "Provider" })}
className="grid rounded-[10px] bg-muted p-0.5 text-[12px] font-medium text-muted-foreground" className="grid rounded-control bg-muted p-0.5 text-[12px] font-medium text-muted-foreground"
style={{ gridTemplateColumns: `repeat(${presets.length}, minmax(0, 1fr))` }} style={{ gridTemplateColumns: `repeat(${presets.length}, minmax(0, 1fr))` }}
> >
{presets.map((preset) => ( {presets.map((preset) => (
@@ -245,7 +245,7 @@ export function ChannelProviderPresets({
onApply(preset); onApply(preset);
}} }}
className={cn( className={cn(
"min-h-8 rounded-[8px] px-2 py-1.5 transition-colors hover:text-foreground", "min-h-8 rounded-compact px-2 py-1.5 transition-colors hover:text-foreground",
selected === preset.id && "bg-background text-foreground", selected === preset.id && "bg-background text-foreground",
)} )}
> >
@@ -370,7 +370,7 @@ export function ChannelSetupSteps({
))} ))}
</ol> </ol>
{tryIt ? ( {tryIt ? (
<div className="mt-3 rounded-[12px] bg-background/75 px-3 py-2 text-[12px] text-muted-foreground"> <div className="mt-3 rounded-control bg-background/75 px-3 py-2 text-[12px] text-muted-foreground">
<span className="font-medium text-foreground"> <span className="font-medium text-foreground">
{tx("settings.channels.tryIt", "Try it")} {tx("settings.channels.tryIt", "Try it")}
</span> </span>
@@ -159,7 +159,7 @@ export function CredentialForm({
<span <span
role="radiogroup" role="radiogroup"
aria-label={field.label} aria-label={field.label}
className="mt-1 grid rounded-[10px] bg-muted p-0.5 text-[12px] font-medium text-muted-foreground" className="mt-1 grid rounded-control bg-muted p-0.5 text-[12px] font-medium text-muted-foreground"
style={{ gridTemplateColumns: `repeat(${field.options.length}, minmax(0, 1fr))` }} style={{ gridTemplateColumns: `repeat(${field.options.length}, minmax(0, 1fr))` }}
> >
{field.options.map((option) => ( {field.options.map((option) => (
@@ -170,7 +170,7 @@ export function CredentialForm({
aria-checked={selectedOption === option.value} aria-checked={selectedOption === option.value}
onClick={() => onChange(field.key, option.value)} onClick={() => onChange(field.key, option.value)}
className={cn( className={cn(
"min-h-8 rounded-[8px] px-2 py-1.5 transition-colors hover:text-foreground", "min-h-8 rounded-compact px-2 py-1.5 transition-colors hover:text-foreground",
selectedOption === option.value selectedOption === option.value
&& "bg-background text-foreground ring-1 ring-inset ring-border/45", && "bg-background text-foreground ring-1 ring-inset ring-border/45",
)} )}
@@ -200,7 +200,7 @@ export function CredentialForm({
value={values[field.key] ?? ""} value={values[field.key] ?? ""}
onChange={(event) => onChange(field.key, event.target.value)} onChange={(event) => onChange(field.key, event.target.value)}
className={cn( className={cn(
"h-9 rounded-[10px] border-border/60 bg-muted/35 text-[13px]", "h-9 rounded-control border-border/60 bg-muted/35 text-[13px]",
showSecretToggle && "pr-9", showSecretToggle && "pr-9",
)} )}
/> />
@@ -117,7 +117,7 @@ export function ModelPresetDeleteDialog({
t(key, { defaultValue: fallback, ...(values ?? {}) }); t(key, { defaultValue: fallback, ...(values ?? {}) });
return ( return (
<Dialog open={preset !== null} onOpenChange={onOpenChange}> <Dialog open={preset !== null} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[440px] rounded-[24px]"> <DialogContent className="max-w-[440px]">
<DialogHeader className="text-left"> <DialogHeader className="text-left">
<DialogTitle> <DialogTitle>
{tx("settings.models.deletePresetTitle", "Delete model preset?")} {tx("settings.models.deletePresetTitle", "Delete model preset?")}
@@ -130,11 +130,10 @@ export function ModelPresetDeleteDialog({
)} )}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter className="gap-2 sm:space-x-0"> <DialogFooter>
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
className="rounded-full"
disabled={deleting} disabled={deleting}
onClick={() => onOpenChange(false)} onClick={() => onOpenChange(false)}
> >
@@ -143,7 +142,6 @@ export function ModelPresetDeleteDialog({
<Button <Button
type="button" type="button"
variant="destructive" variant="destructive"
className="rounded-full"
disabled={deleting} disabled={deleting}
onClick={onConfirm} onClick={onConfirm}
> >
@@ -343,7 +341,7 @@ export function ModelsSettings({
<div <div
id="model-preset-editor" id="model-preset-editor"
data-testid="model-preset-editor" data-testid="model-preset-editor"
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" 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"
> >
{creating ? ( {creating ? (
<div className="flex min-h-[52px] items-center px-4 py-3 sm:px-5"> <div className="flex min-h-[52px] items-center px-4 py-3 sm:px-5">
@@ -527,7 +525,7 @@ export function ModelsSettings({
{!settings.model_call_order_editable ? ( {!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 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"> <div className="flex min-w-0 items-start gap-3">
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-[12px] bg-muted text-muted-foreground"> <span className="grid h-9 w-9 shrink-0 place-items-center rounded-control bg-muted text-muted-foreground">
<ListOrdered className="h-4 w-4" aria-hidden /> <ListOrdered className="h-4 w-4" aria-hidden />
</span> </span>
<div className="min-w-0"> <div className="min-w-0">
@@ -674,7 +672,7 @@ export function ModelsSettings({
aria-controls={isSelected ? "model-preset-editor" : undefined} aria-controls={isSelected ? "model-preset-editor" : undefined}
disabled={!preset} disabled={!preset}
onClick={() => preset && selectPreset(preset, key)} onClick={() => preset && selectPreset(preset, key)}
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" 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"
> >
{ordered ? ( {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"> <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">
@@ -841,7 +839,7 @@ function ModelAdvancedFields({
const value = Number(event.target.value); const value = Number(event.target.value);
if (Number.isFinite(value)) onChange({ maxTokens: value }); if (Number.isFinite(value)) onChange({ maxTokens: value });
}} }}
className="h-9 rounded-[12px] text-[13px]" className="h-9 text-[13px]"
/> />
</label> </label>
<label className="block"> <label className="block">
@@ -858,7 +856,7 @@ function ModelAdvancedFields({
const value = Number(event.target.value); const value = Number(event.target.value);
if (Number.isFinite(value)) onChange({ temperature: value }); if (Number.isFinite(value)) onChange({ temperature: value });
}} }}
className="h-9 rounded-[12px] text-[13px]" className="h-9 text-[13px]"
/> />
</label> </label>
</div> </div>
@@ -887,7 +885,7 @@ function ModelAdvancedFields({
placeholder={tx("settings.values.default", "Default")} placeholder={tx("settings.values.default", "Default")}
autoCapitalize="none" autoCapitalize="none"
spellCheck={false} spellCheck={false}
className="h-9 rounded-[12px] text-[13px]" className="h-9 text-[13px]"
/> />
</label> </label>
</div> </div>
@@ -276,7 +276,7 @@ export function ProviderOAuthLoginDialog({
if (!open) onClose(); if (!open) onClose();
}} }}
> >
<DialogContent className="w-[min(calc(100vw-2rem),28rem)] rounded-[24px]"> <DialogContent className="w-[min(calc(100vw-2rem),28rem)]">
<form <form
className="space-y-4" className="space-y-4"
onSubmit={(event) => { onSubmit={(event) => {
@@ -296,7 +296,7 @@ export function ProviderOAuthLoginDialog({
: t("settings.oauth.localCodeHelp")} : t("settings.oauth.localCodeHelp")}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<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"> <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">
{expectsCallbackUrl && remoteBrowserAccess ? ( {expectsCallbackUrl && remoteBrowserAccess ? (
<Clipboard className="h-3.5 w-3.5 shrink-0" aria-hidden /> <Clipboard className="h-3.5 w-3.5 shrink-0" aria-hidden />
) : ( ) : (
@@ -341,12 +341,12 @@ export function ProviderOAuthLoginDialog({
{error ? ( {error ? (
<p <p
role="alert" role="alert"
className="rounded-[14px] border border-destructive/20 bg-destructive/5 px-3 py-2.5 text-[12px] text-destructive" className="rounded-control border border-destructive/20 bg-destructive/5 px-3 py-2.5 text-[12px] text-destructive"
> >
{error} {error}
</p> </p>
) : null} ) : null}
<DialogFooter className="gap-2 sm:space-x-0"> <DialogFooter>
<Button type="button" variant="outline" onClick={onOpenAuthorization}> <Button type="button" variant="outline" onClick={onOpenAuthorization}>
<ExternalLink className="mr-2 h-4 w-4" aria-hidden /> <ExternalLink className="mr-2 h-4 w-4" aria-hidden />
{expectsCallbackUrl {expectsCallbackUrl
@@ -380,7 +380,7 @@ function ProviderRequestOptions({
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback }); const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
return ( return (
<div className="overflow-hidden rounded-[18px] border border-border/45 bg-background/75"> <div className="overflow-hidden rounded-floating border border-border/45 bg-background/75">
{options.map((option, index) => { {options.map((option, index) => {
const title = tx(option.titleKey, option.title); const title = tx(option.titleKey, option.title);
const Icon = option.kind === "priority" ? Zap : Globe2; const Icon = option.kind === "priority" ? Zap : Globe2;
@@ -599,7 +599,7 @@ function ProviderAdvancedOptions({
onChange={(event) => onChange({ extraHeaders: event.target.value })} onChange={(event) => onChange({ extraHeaders: event.target.value })}
placeholder={'{"X-Header":"value"}'} placeholder={'{"X-Header":"value"}'}
spellCheck={false} spellCheck={false}
className="min-h-[88px] resize-y rounded-[14px] bg-background font-mono text-[12px]" className="min-h-[88px] resize-y bg-background font-mono text-[12px]"
/> />
</label> </label>
) : null} ) : null}
@@ -613,7 +613,7 @@ function ProviderAdvancedOptions({
onChange={(event) => onChange({ extraQuery: event.target.value })} onChange={(event) => onChange({ extraQuery: event.target.value })}
placeholder={'{"api-version":"2024-02-01"}'} placeholder={'{"api-version":"2024-02-01"}'}
spellCheck={false} spellCheck={false}
className="min-h-[88px] resize-y rounded-[14px] bg-background font-mono text-[12px]" className="min-h-[88px] resize-y bg-background font-mono text-[12px]"
/> />
</label> </label>
) : null} ) : null}
@@ -627,7 +627,7 @@ function ProviderAdvancedOptions({
onChange={(event) => onChange({ extraBody: event.target.value })} onChange={(event) => onChange({ extraBody: event.target.value })}
placeholder={'{"service_tier":"priority"}'} placeholder={'{"service_tier":"priority"}'}
spellCheck={false} spellCheck={false}
className="min-h-[96px] resize-y rounded-[14px] bg-background font-mono text-[12px]" className="min-h-[96px] resize-y bg-background font-mono text-[12px]"
/> />
</label> </label>
) : null} ) : null}
@@ -825,7 +825,7 @@ export function ProvidersSettings({
) : null} ) : null}
{isOauthProvider ? ( {isOauthProvider ? (
<> <>
<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="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="min-w-0"> <div className="min-w-0">
<p className="text-[13px] font-semibold text-foreground"> <p className="text-[13px] font-semibold text-foreground">
{tx("settings.oauth.authentication", "OAuth authentication")} {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" 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="flex min-w-0 items-center gap-3">
<span className="grid h-10 w-10 shrink-0 place-items-center rounded-[14px] bg-muted text-muted-foreground"> <span className="grid h-10 w-10 shrink-0 place-items-center rounded-control bg-muted text-muted-foreground">
<Plus className="h-5 w-5" aria-hidden /> <Plus className="h-5 w-5" aria-hidden />
</span> </span>
<span className="truncate text-[15px] font-semibold text-foreground"> <span className="truncate text-[15px] font-semibold text-foreground">
@@ -1334,7 +1334,7 @@ function ProviderIcon({
return ( return (
<span <span
data-testid={`provider-logo-${provider}`} data-testid={`provider-logo-${provider}`}
className="grid h-10 w-10 shrink-0 place-items-center overflow-hidden rounded-[14px] border border-border/45 bg-background" className="grid h-10 w-10 shrink-0 place-items-center overflow-hidden rounded-control border border-border/45 bg-background"
> >
<img <img
src={logoUrl} src={logoUrl}
@@ -1352,7 +1352,7 @@ function ProviderIcon({
return ( return (
<span <span
data-testid={`provider-logo-fallback-${provider}`} data-testid={`provider-logo-fallback-${provider}`}
className="grid h-10 w-10 shrink-0 place-items-center rounded-[14px] text-[11px] font-semibold text-white" className="grid h-10 w-10 shrink-0 place-items-center rounded-control text-[11px] font-semibold text-white"
style={{ backgroundColor: brand.color }} style={{ backgroundColor: brand.color }}
aria-hidden aria-hidden
> >
@@ -129,7 +129,7 @@ export function OverviewSettings({
: tx("settings.values.ready", "Ready"); : tx("settings.values.ready", "Ready");
return ( return (
<div className="space-y-7"> <div className="space-y-7">
<section className="rounded-[22px] bg-settings-surface px-4 py-4 sm:px-5"> <section className="rounded-panel bg-settings-surface px-4 py-4 sm:px-5">
<TokenUsageHeatmap usage={settings.usage} timeZone={settings.agent.timezone} /> <TokenUsageHeatmap usage={settings.usage} timeZone={settings.agent.timezone} />
</section> </section>
@@ -433,7 +433,7 @@ function OverviewRowIcon({
icon: LucideIcon; icon: LucideIcon;
}) { }) {
return ( return (
<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"> <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">
<Icon className="h-4 w-4" aria-hidden /> <Icon className="h-4 w-4" aria-hidden />
</span> </span>
); );
@@ -309,7 +309,7 @@ export function ModelIdPicker({
key={model.id} key={model.id}
{...navigation.getOptionProps(model.id)} {...navigation.getOptionProps(model.id)}
className={cn( className={cn(
"flex cursor-default items-center justify-between gap-2 rounded-[12px] px-2 py-1.5 text-[12px]", "flex cursor-default items-center justify-between gap-2 rounded-control px-2 py-1.5 text-[12px]",
options.selected && "text-foreground", options.selected && "text-foreground",
)} )}
> >
@@ -455,7 +455,7 @@ export function ModelIdPicker({
) : null} ) : null}
<ComboboxOption <ComboboxOption
{...navigation.getOptionProps(customCandidate)} {...navigation.getOptionProps(customCandidate)}
className="flex cursor-default items-center gap-2 rounded-[12px] px-2 py-1.5 text-[12px]" className="flex cursor-default items-center gap-2 rounded-control 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"> <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 /> <Pencil className="h-3 w-3" aria-hidden />
@@ -31,7 +31,7 @@ export function CapabilityInstallNotice({
installing?: boolean; installing?: boolean;
}) { }) {
return ( return (
<div className="flex items-start gap-3 rounded-[14px] border border-border/55 bg-muted/22 px-3.5 py-3"> <div className="flex items-start gap-3 rounded-control border border-border/55 bg-muted/22 px-3.5 py-3">
{installing ? ( {installing ? (
<Loader2 className="mt-0.5 h-4 w-4 shrink-0 animate-spin text-muted-foreground" aria-hidden /> <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> </DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter className="mt-7 !grid grid-cols-1 gap-3 space-x-0 sm:grid-cols-2 sm:space-x-0"> <DialogFooter className="mt-7 !grid grid-cols-1 gap-2 sm:grid-cols-2">
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
onClick={() => onOpenChange(false)} onClick={() => onOpenChange(false)}
disabled={installing} disabled={installing}
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" className="h-11 w-full min-w-0 bg-muted/70 px-5 text-[15px] font-semibold text-foreground shadow-none hover:bg-muted"
> >
{tx("settings.automations.cancel", "Cancel")} {tx("settings.automations.cancel", "Cancel")}
</Button> </Button>
@@ -92,7 +92,7 @@ export function NanobotFeatureInstallDialog({
type="button" type="button"
onClick={() => feature && void onConfirm(feature)} onClick={() => feature && void onConfirm(feature)}
disabled={!feature || installing} disabled={!feature || installing}
className="h-11 w-full min-w-0 !whitespace-normal rounded-full px-5 text-center text-[15px] font-semibold" className="h-11 w-full min-w-0 !whitespace-normal px-5 text-center text-[15px] font-semibold"
> >
{installing ? <Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden /> : null} {installing ? <Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden /> : null}
{tx("settings.nanobotFeatures.installConfirmAction", "Install and enable")} {tx("settings.nanobotFeatures.installConfirmAction", "Install and enable")}
@@ -117,7 +117,7 @@ export function DismissibleStatusMessage({
return ( return (
<div <div
className={cn( className={cn(
"flex items-center justify-between gap-3 rounded-[12px] border py-2.5 pl-4 pr-2 text-[13px]", "flex items-center justify-between gap-3 rounded-control border py-2.5 pl-4 pr-2 text-[13px]",
isError isError
? "border-destructive/20 bg-destructive/5 text-destructive" ? "border-destructive/20 bg-destructive/5 text-destructive"
: "border-border/55 bg-muted/35 text-muted-foreground", : "border-border/55 bg-muted/35 text-muted-foreground",
@@ -153,7 +153,7 @@ export function RestartRequiredNotice({
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<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"> <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">
<span>{message}</span> <span>{message}</span>
{onRestart ? ( {onRestart ? (
<Button <Button
@@ -186,7 +186,7 @@ export function SettingsSectionTitle({ children }: { children: ReactNode }) {
export function SettingsGroup({ children }: { children: ReactNode }) { export function SettingsGroup({ children }: { children: ReactNode }) {
return ( return (
<div className="overflow-hidden rounded-[22px] bg-settings-surface"> <div className="overflow-hidden rounded-panel bg-settings-surface">
<div className="divide-y divide-border/45">{children}</div> <div className="divide-y divide-border/45">{children}</div>
</div> </div>
); );
@@ -256,7 +256,7 @@ export function AppsCatalogSettings({
onChange={(event) => onQueryChange(event.target.value)} onChange={(event) => onQueryChange(event.target.value)}
placeholder={tx("settings.apps.searchPlaceholder", "Search Apps")} placeholder={tx("settings.apps.searchPlaceholder", "Search Apps")}
className={cn( className={cn(
"h-12 rounded-[14px] pl-11 text-[15px]", "h-12 pl-11 text-[15px]",
SETTINGS_SEARCH_INPUT_CLASS, SETTINGS_SEARCH_INPUT_CLASS,
)} )}
/> />
@@ -289,7 +289,7 @@ export function AppsCatalogSettings({
/> />
) : null} ) : null}
<section className="rounded-[22px] bg-settings-surface px-3 py-3 sm:px-4"> <section className="rounded-panel bg-settings-surface px-3 py-3 sm:px-4">
<div className="flex items-center justify-between border-b border-border/45 pb-3"> <div className="flex items-center justify-between border-b border-border/45 pb-3">
<SettingsSectionTitle> <SettingsSectionTitle>
{filter === "mcp" {filter === "mcp"
@@ -412,7 +412,7 @@ function CliAppsCatalogRow({
const description = app.description || app.requires || app.entry_point || app.name; const description = app.description || app.requires || app.entry_point || app.name;
return ( return (
<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"> <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">
<CliAppLogo app={app} showBrandLogos={showBrandLogos} /> <CliAppLogo app={app} showBrandLogos={showBrandLogos} />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex min-w-0 items-baseline gap-2"> <div className="flex min-w-0 items-baseline gap-2">
@@ -583,7 +583,7 @@ function McpAppsCatalogRow({
}; };
return ( return (
<article className="min-w-0 rounded-[14px] transition-colors hover:bg-muted/45"> <article className="min-w-0 rounded-control 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"> <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} /> <McpPresetLogo preset={preset} showBrandLogos={showBrandLogos} />
<div className="min-w-[8rem] flex-[1_1_8rem]"> <div className="min-w-[8rem] flex-[1_1_8rem]">
@@ -747,7 +747,7 @@ function McpAppsCatalogRow({
{manualCallback ? ( {manualCallback ? (
<form <form
className="mx-3 mb-3 min-w-0 space-y-3 rounded-[14px] bg-background/55 p-3" className="mx-3 mb-3 min-w-0 space-y-3 rounded-control bg-background/55 p-3"
onSubmit={(event) => { onSubmit={(event) => {
event.preventDefault(); event.preventDefault();
onOAuthComplete(); onOAuthComplete();
@@ -825,7 +825,7 @@ function McpAppsCatalogRow({
</div> </div>
</form> </form>
) : oauthFlow && oauthPopupBlocked && oauthFlow.authorization_url ? ( ) : oauthFlow && oauthPopupBlocked && oauthFlow.authorization_url ? (
<div className="mx-3 mb-3 flex flex-col gap-2.5 rounded-[14px] bg-background/55 p-3"> <div className="mx-3 mb-3 flex flex-col gap-2.5 rounded-control bg-background/55 p-3">
<div className="flex min-w-0 items-center gap-2.5 text-[12.5px] text-muted-foreground"> <div className="flex min-w-0 items-center gap-2.5 text-[12.5px] text-muted-foreground">
<span> <span>
{mcpOAuthStatusText( {mcpOAuthStatusText(
@@ -1016,10 +1016,10 @@ function McpCustomServerPanel({
]; ];
return ( return (
<section className="overflow-hidden rounded-[16px] bg-settings-surface"> <section className="overflow-hidden rounded-floating 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 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"> <div className="flex min-w-0 items-center gap-3">
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-[11px] bg-muted text-muted-foreground"> <span className="grid h-9 w-9 shrink-0 place-items-center rounded-control bg-muted text-muted-foreground">
<Server className="h-4 w-4" aria-hidden /> <Server className="h-4 w-4" aria-hidden />
</span> </span>
<div className="min-w-0"> <div className="min-w-0">
@@ -1154,7 +1154,7 @@ function McpCustomServerPanel({
value={form.headers} value={form.headers}
onChange={(event) => update("headers", event.target.value)} onChange={(event) => update("headers", event.target.value)}
placeholder={'{"Authorization":"Bearer ..."}'} placeholder={'{"Authorization":"Bearer ..."}'}
className="min-h-[68px] resize-y rounded-[12px] bg-background/80 font-mono text-[12px]" className="min-h-[68px] resize-y bg-background/80 font-mono text-[12px]"
/> />
<p <p
id={headersHelpId} id={headersHelpId}
@@ -1202,7 +1202,7 @@ function McpCustomServerPanel({
value={form.args} value={form.args}
onChange={(event) => update("args", event.target.value)} onChange={(event) => update("args", event.target.value)}
placeholder={'["-y", "docs-mcp"]'} placeholder={'["-y", "docs-mcp"]'}
className="min-h-[68px] resize-y rounded-[12px] bg-background/80 font-mono text-[12px]" className="min-h-[68px] resize-y bg-background/80 font-mono text-[12px]"
/> />
</label> </label>
) : null} ) : null}
@@ -1214,7 +1214,7 @@ function McpCustomServerPanel({
value={form.env} value={form.env}
onChange={(event) => update("env", event.target.value)} onChange={(event) => update("env", event.target.value)}
placeholder={'{"API_KEY":"..."}'} placeholder={'{"API_KEY":"..."}'}
className="min-h-[68px] resize-y rounded-[12px] bg-background/80 font-mono text-[12px]" className="min-h-[68px] resize-y bg-background/80 font-mono text-[12px]"
/> />
</label> </label>
<label className="min-w-0"> <label className="min-w-0">
@@ -1257,7 +1257,7 @@ function McpCustomServerPanel({
value={configImport} value={configImport}
onChange={(event) => onConfigImportChange(event.target.value)} onChange={(event) => onConfigImportChange(event.target.value)}
placeholder={'{"mcpServers":{"docs":{"command":"npx","args":["-y","docs-mcp"]}}}'} placeholder={'{"mcpServers":{"docs":{"command":"npx","args":["-y","docs-mcp"]}}}'}
className="min-h-[84px] resize-y rounded-[12px] bg-background/80 font-mono text-[12px]" className="min-h-[84px] resize-y bg-background/80 font-mono text-[12px]"
/> />
</label> </label>
<Button <Button
@@ -1352,7 +1352,7 @@ function McpPresetLogo({
<span <span
className={cn( className={cn(
"grid shrink-0 place-items-center border border-border/45 bg-background", "grid shrink-0 place-items-center border border-border/45 bg-background",
compact ? "h-10 w-10 rounded-[10px]" : "h-11 w-11 rounded-[8px]", compact ? "h-10 w-10 rounded-control" : "h-11 w-11 rounded-compact",
)} )}
> >
<img <img
@@ -1372,8 +1372,8 @@ function McpPresetLogo({
className={cn( className={cn(
"grid shrink-0 place-items-center font-semibold text-white", "grid shrink-0 place-items-center font-semibold text-white",
compact compact
? "h-10 w-10 rounded-[10px] text-[12px]" ? "h-10 w-10 rounded-control text-[12px]"
: "h-11 w-11 rounded-[8px] text-[13px]", : "h-11 w-11 rounded-compact text-[13px]",
)} )}
style={{ backgroundColor: bg }} style={{ backgroundColor: bg }}
> >
@@ -1406,7 +1406,7 @@ function CliAppReadyPanel({
}; };
return ( return (
<section className="rounded-[12px] bg-settings-surface px-4 py-3"> <section className="rounded-control bg-settings-surface px-4 py-3">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<CliAppLogo app={app} showBrandLogos={showBrandLogos} /> <CliAppLogo app={app} showBrandLogos={showBrandLogos} />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
@@ -1472,7 +1472,7 @@ function CliAppLogo({ app, showBrandLogos }: { app: CliAppInfo; showBrandLogos:
return ( return (
<span <span
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" 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"
style={{ color: app.brand_color || "hsl(var(--muted-foreground))" }} style={{ color: app.brand_color || "hsl(var(--muted-foreground))" }}
> >
<span <span
@@ -129,14 +129,14 @@ export function AutomationsSettings({
<section className="shrink-0"> <section className="shrink-0">
<div className="mx-auto flex w-full max-w-[56rem] flex-col gap-3"> <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="-mx-1 overflow-x-auto px-1 pb-0.5">
<div className="grid w-full min-w-[36rem] grid-cols-5 gap-1 rounded-[15px] bg-muted p-1"> <div className="grid w-full min-w-[36rem] grid-cols-5 gap-1 rounded-floating bg-muted p-1">
{summaryOptions.map((option) => ( {summaryOptions.map((option) => (
<button <button
key={option.value} key={option.value}
type="button" type="button"
onClick={() => onFilterChange(option.value)} onClick={() => onFilterChange(option.value)}
className={cn( className={cn(
"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", "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",
filter === option.value && "bg-background text-foreground", filter === option.value && "bg-background text-foreground",
automationFilterToneClass(option.value, option.count, filter === option.value), automationFilterToneClass(option.value, option.count, filter === option.value),
)} )}
@@ -166,7 +166,7 @@ export function AutomationsSettings({
"Search task, message, linked chat, or schedule", "Search task, message, linked chat, or schedule",
)} )}
className={cn( className={cn(
"h-9 w-full rounded-[13px] pl-9 text-[13px]", "h-9 w-full rounded-control pl-9 text-[13px]",
SETTINGS_SEARCH_INPUT_CLASS, SETTINGS_SEARCH_INPUT_CLASS,
)} )}
/> />
@@ -175,7 +175,7 @@ export function AutomationsSettings({
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<button <button
type="button" type="button"
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" 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"
> >
<ArrowUpDown className="h-3.5 w-3.5" aria-hidden /> <ArrowUpDown className="h-3.5 w-3.5" aria-hidden />
<span>{sortLabel[sort]}</span> <span>{sortLabel[sort]}</span>
@@ -197,19 +197,19 @@ export function AutomationsSettings({
) : null} ) : null}
{error ? ( {error ? (
<div className="flex items-center gap-2 rounded-[18px] border border-destructive/20 bg-destructive/5 px-4 py-3 text-[13px] text-destructive"> <div className="flex items-center gap-2 rounded-floating 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 /> <CircleAlert className="h-4 w-4 shrink-0" aria-hidden />
<span>{error}</span> <span>{error}</span>
</div> </div>
) : null} ) : null}
{loading && !payload ? ( {loading && !payload ? (
<div className="flex h-44 items-center justify-center rounded-[22px] bg-settings-surface text-[13px] text-muted-foreground"> <div className="flex h-44 items-center justify-center rounded-panel bg-settings-surface text-[13px] text-muted-foreground">
<Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden /> <Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden />
{tx("settings.automations.loading", "Loading automations...")} {tx("settings.automations.loading", "Loading automations...")}
</div> </div>
) : filtered.length && selectedJob ? ( ) : filtered.length && selectedJob ? (
<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"> <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">
<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"> <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"> <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"> <h2 className="text-[13px] font-semibold tracking-[-0.01em] text-foreground/85">
@@ -245,7 +245,7 @@ export function AutomationsSettings({
/> />
</section> </section>
) : ( ) : (
<div className="rounded-[22px] bg-settings-surface px-5 py-12 text-center text-[13px] text-muted-foreground"> <div className="rounded-panel bg-settings-surface px-5 py-12 text-center text-[13px] text-muted-foreground">
<div> <div>
{jobs.length {jobs.length
? tx("settings.automations.noMatches", "No automations match this view.") ? tx("settings.automations.noMatches", "No automations match this view.")
@@ -313,7 +313,7 @@ function AutomationListItem({
aria-pressed={selected} aria-pressed={selected}
onClick={onSelect} onClick={onSelect}
className={cn( className={cn(
"group grid w-full grid-cols-[minmax(0,1fr)_auto] gap-3 rounded-[18px] px-3 py-3.5 text-left transition-colors", "group grid w-full grid-cols-[minmax(0,1fr)_auto] gap-3 rounded-floating px-3 py-3.5 text-left transition-colors",
selected selected
? "bg-background/80 text-foreground" ? "bg-background/80 text-foreground"
: "text-muted-foreground hover:bg-background/55 hover: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="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"> <div className="min-h-0 min-w-0 space-y-3 overflow-y-auto overscroll-contain p-4 sm:p-5">
<section className="rounded-[20px] bg-background/55 px-4 py-3.5"> <section className="rounded-floating bg-background/55 px-4 py-3.5">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div className="text-[11px] font-medium leading-none text-muted-foreground/75"> <div className="text-[11px] font-medium leading-none text-muted-foreground/75">
{messageLabel} {messageLabel}
@@ -506,7 +506,7 @@ function AutomationDetailPanel({
</div> </div>
{job.state.last_error ? ( {job.state.last_error ? (
<div className="rounded-[16px] border border-destructive/20 bg-destructive/8 px-3 py-2 text-[12px] leading-5 text-destructive"> <div className="rounded-floating border border-destructive/20 bg-destructive/8 px-3 py-2 text-[12px] leading-5 text-destructive">
{job.state.last_error} {job.state.last_error}
</div> </div>
) : null} ) : null}
@@ -520,7 +520,7 @@ function AutomationDetailPanel({
> >
{schedule} {schedule}
</AutomationDetail> </AutomationDetail>
<div className="rounded-[18px] bg-background/55 p-3"> <div className="rounded-floating bg-background/55 p-3">
<div className="grid gap-3"> <div className="grid gap-3">
{created ? ( {created ? (
<div> <div>
@@ -672,7 +672,7 @@ function AutomationDetail({
children: ReactNode; children: ReactNode;
}) { }) {
return ( return (
<div className="min-w-0 rounded-[17px] bg-background/55 px-3 py-3"> <div className="min-w-0 rounded-floating bg-background/55 px-3 py-3">
<div className="text-[11px] font-medium leading-none text-muted-foreground/75"> <div className="text-[11px] font-medium leading-none text-muted-foreground/75">
{label} {label}
</div> </div>
@@ -757,7 +757,7 @@ export function AutomationEditDialog({
{job ? ( {job ? (
<DialogContent <DialogContent
aria-describedby={undefined} aria-describedby={undefined}
className="w-[min(calc(100vw-2rem),34rem)] rounded-[26px]" className="w-[min(calc(100vw-2rem),34rem)]"
> >
<form className="space-y-5" onSubmit={submit}> <form className="space-y-5" onSubmit={submit}>
<DialogHeader> <DialogHeader>
@@ -772,7 +772,6 @@ export function AutomationEditDialog({
<Input <Input
value={draft.name} value={draft.name}
onChange={(event) => setDraft((prev) => ({ ...prev, name: event.target.value }))} onChange={(event) => setDraft((prev) => ({ ...prev, name: event.target.value }))}
className="h-10 rounded-[12px]"
/> />
</label> </label>
@@ -784,7 +783,7 @@ export function AutomationEditDialog({
<Textarea <Textarea
value={draft.message} value={draft.message}
onChange={(event) => setDraft((prev) => ({ ...prev, message: event.target.value }))} onChange={(event) => setDraft((prev) => ({ ...prev, message: event.target.value }))}
className="min-h-[160px] resize-none rounded-[12px] text-[13px] leading-5" className="min-h-[160px] resize-none text-[13px] leading-5"
/> />
</label> </label>
) : null} ) : null}
@@ -821,7 +820,6 @@ export function AutomationEditDialog({
onChange={(event) => onChange={(event) =>
setDraft((prev) => ({ ...prev, everyValue: event.target.value })) setDraft((prev) => ({ ...prev, everyValue: event.target.value }))
} }
className="h-10 rounded-[12px]"
/> />
</label> </label>
<label className="block space-y-1.5"> <label className="block space-y-1.5">
@@ -837,7 +835,7 @@ export function AutomationEditDialog({
})) }))
} }
className={cn( className={cn(
"h-10 w-full rounded-[12px] border border-input bg-background px-3 text-[13px] text-foreground transition-colors", "h-10 w-full rounded-control border border-input bg-background px-3 text-[13px] text-foreground transition-colors",
formControlFocusClassName, formControlFocusClassName,
)} )}
> >
@@ -861,7 +859,7 @@ export function AutomationEditDialog({
value={draft.cronExpr} value={draft.cronExpr}
onChange={(event) => setDraft((prev) => ({ ...prev, cronExpr: event.target.value }))} onChange={(event) => setDraft((prev) => ({ ...prev, cronExpr: event.target.value }))}
placeholder="0 9 * * *" placeholder="0 9 * * *"
className="h-10 rounded-[12px] font-mono text-[13px]" className="font-mono text-[13px]"
/> />
</label> </label>
<label className="block space-y-1.5"> <label className="block space-y-1.5">
@@ -872,7 +870,7 @@ export function AutomationEditDialog({
value={draft.tz} value={draft.tz}
onChange={(event) => setDraft((prev) => ({ ...prev, tz: event.target.value }))} onChange={(event) => setDraft((prev) => ({ ...prev, tz: event.target.value }))}
placeholder="Asia/Shanghai" placeholder="Asia/Shanghai"
className="h-10 rounded-[12px] text-[13px]" className="text-[13px]"
/> />
</label> </label>
</div> </div>
@@ -887,13 +885,12 @@ export function AutomationEditDialog({
type="datetime-local" type="datetime-local"
value={draft.atLocal} value={draft.atLocal}
onChange={(event) => setDraft((prev) => ({ ...prev, atLocal: event.target.value }))} onChange={(event) => setDraft((prev) => ({ ...prev, atLocal: event.target.value }))}
className="h-10 rounded-[12px]"
/> />
</label> </label>
) : null} ) : null}
{validation ? ( {validation ? (
<div className="rounded-[12px] bg-destructive/8 px-3 py-2 text-[12px] text-destructive"> <div className="rounded-control bg-destructive/8 px-3 py-2 text-[12px] text-destructive">
{validation} {validation}
</div> </div>
) : null} ) : null}
@@ -905,11 +902,10 @@ export function AutomationEditDialog({
variant="ghost" variant="ghost"
onClick={() => onOpenChange(false)} onClick={() => onOpenChange(false)}
disabled={saving} disabled={saving}
className="rounded-full"
> >
{tx("settings.automations.cancel", "Cancel")} {tx("settings.automations.cancel", "Cancel")}
</Button> </Button>
<Button type="submit" disabled={Boolean(validation) || saving} className="rounded-full"> <Button type="submit" disabled={Boolean(validation) || saving}>
{saving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden /> : null} {saving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden /> : null}
{tx("settings.automations.save", "Save")} {tx("settings.automations.save", "Save")}
</Button> </Button>
@@ -937,7 +933,7 @@ export function AutomationDeleteDialog({
t(key, { defaultValue: fallback, ...(values ?? {}) }); t(key, { defaultValue: fallback, ...(values ?? {}) });
return ( return (
<Dialog open={Boolean(job)} onOpenChange={onOpenChange}> <Dialog open={Boolean(job)} onOpenChange={onOpenChange}>
<DialogContent className="w-[min(calc(100vw-2rem),26rem)] rounded-[26px]"> <DialogContent className="w-[min(calc(100vw-2rem),26rem)]">
<DialogHeader> <DialogHeader>
<DialogTitle>{tx("settings.automations.deleteTitle", "Delete automation")}</DialogTitle> <DialogTitle>{tx("settings.automations.deleteTitle", "Delete automation")}</DialogTitle>
<DialogDescription> <DialogDescription>
@@ -954,7 +950,6 @@ export function AutomationDeleteDialog({
variant="ghost" variant="ghost"
onClick={() => onOpenChange(false)} onClick={() => onOpenChange(false)}
disabled={deleting} disabled={deleting}
className="rounded-full"
> >
{tx("settings.automations.cancel", "Cancel")} {tx("settings.automations.cancel", "Cancel")}
</Button> </Button>
@@ -963,7 +958,6 @@ export function AutomationDeleteDialog({
variant="destructive" variant="destructive"
onClick={() => job && void onConfirm(job)} onClick={() => job && void onConfirm(job)}
disabled={!job || deleting} disabled={!job || deleting}
className="rounded-full"
> >
{deleting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden /> : null} {deleting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden /> : null}
{tx("settings.automations.delete", "Delete")} {tx("settings.automations.delete", "Delete")}
@@ -147,19 +147,19 @@ export function ChannelsSettings({
onChange={(event) => onQueryChange(event.target.value)} onChange={(event) => onQueryChange(event.target.value)}
placeholder={tx("settings.channels.searchPlaceholder", "Search channels")} placeholder={tx("settings.channels.searchPlaceholder", "Search channels")}
className={cn( className={cn(
"h-12 rounded-[14px] pl-11 text-[15px]", "h-12 rounded-control pl-11 text-[15px]",
SETTINGS_SEARCH_INPUT_CLASS, SETTINGS_SEARCH_INPUT_CLASS,
)} )}
/> />
</div> </div>
<div className="flex shrink-0 flex-wrap gap-1.5 rounded-[14px] bg-muted/55 p-1"> <div className="flex shrink-0 flex-wrap gap-1.5 rounded-control bg-muted/55 p-1">
{filterOptions.map((option) => ( {filterOptions.map((option) => (
<button <button
key={option.value} key={option.value}
type="button" type="button"
onClick={() => setFilter(option.value)} onClick={() => setFilter(option.value)}
className={cn( className={cn(
"rounded-[11px] px-3 py-1.5 text-[12px] font-medium transition-colors", "rounded-control px-3 py-1.5 text-[12px] font-medium transition-colors",
filter === option.value filter === option.value
? "bg-background text-foreground" ? "bg-background text-foreground"
: "text-muted-foreground hover:text-foreground", : "text-muted-foreground hover:text-foreground",
@@ -153,7 +153,6 @@ export function McpManagementDialog({
showCloseButton={false} showCloseButton={false}
className={cn( className={cn(
"flex h-[min(34rem,calc(100dvh-2rem))] w-[calc(100vw-2rem)] max-w-[42rem] flex-col gap-0 overflow-hidden p-0", "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"> <div className="flex shrink-0 items-center gap-3 border-b border-border/45 px-5 py-3.5 sm:px-6">
@@ -282,7 +281,7 @@ function OverviewPanel({
const previewTools = (preset.tool_names ?? []).slice(0, 4); const previewTools = (preset.tool_names ?? []).slice(0, 4);
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<section className="rounded-[16px] border border-border/55 px-4 py-3.5"> <section className="rounded-floating border border-border/55 px-4 py-3.5">
<h3 className="text-[13px] font-semibold text-foreground"> <h3 className="text-[13px] font-semibold text-foreground">
{tx("settings.mcp.about", "About this MCP")} {tx("settings.mcp.about", "About this MCP")}
</h3> </h3>
@@ -314,7 +313,7 @@ function OverviewPanel({
</dl> </dl>
{previewTools.length ? ( {previewTools.length ? (
<section className="rounded-[16px] bg-muted/45 p-4"> <section className="rounded-floating bg-muted/45 p-4">
<h3 className="text-[13px] font-semibold text-foreground"> <h3 className="text-[13px] font-semibold text-foreground">
{tx("settings.mcp.toolPreview", "Tools")} {tx("settings.mcp.toolPreview", "Tools")}
</h3> </h3>
@@ -375,7 +374,7 @@ function ToolsPanel({
return ( return (
<div <div
className={cn( className={cn(
"flex flex-col gap-3 rounded-[16px] border px-4 py-3.5 sm:flex-row sm:items-center sm:justify-between", "flex flex-col gap-3 rounded-floating 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", preset.error ? "border-destructive/25 bg-destructive/5" : "border-border/55 bg-muted/25",
)} )}
> >
@@ -444,7 +443,7 @@ function ToolsPanel({
</div> </div>
</div> </div>
<div className="overflow-hidden rounded-[16px] border border-border/55"> <div className="overflow-hidden rounded-floating border border-border/55">
{filteredTools.length ? filteredTools.map((toolName) => { {filteredTools.length ? filteredTools.map((toolName) => {
const selected = selectedTools.has(toolName); const selected = selectedTools.has(toolName);
return ( return (
@@ -463,7 +462,7 @@ function ToolsPanel({
<span <span
aria-hidden aria-hidden
className={cn( className={cn(
"pointer-events-none absolute inset-0 grid place-items-center rounded-[7px] border transition-colors", "pointer-events-none absolute inset-0 grid place-items-center rounded-compact border transition-colors",
selected selected
? "border-foreground bg-foreground text-background" ? "border-foreground bg-foreground text-background"
: "border-border bg-background text-transparent", : "border-border bg-background text-transparent",
@@ -529,7 +528,7 @@ function ConnectionPanel({
<h3 id={`mcp-connection-${preset.name}`} className="sr-only"> <h3 id={`mcp-connection-${preset.name}`} className="sr-only">
{tx("settings.mcp.connectionDetails", "Connection details")} {tx("settings.mcp.connectionDetails", "Connection details")}
</h3> </h3>
<div className="rounded-[16px] bg-muted/40 px-4 py-3.5"> <div className="rounded-floating bg-muted/40 px-4 py-3.5">
{preset.connection_summary ? ( {preset.connection_summary ? (
<div className="min-w-0"> <div className="min-w-0">
<p className="text-[12.5px] font-medium text-muted-foreground">{connectionLabel}</p> <p className="text-[12.5px] font-medium text-muted-foreground">{connectionLabel}</p>
@@ -580,7 +579,7 @@ function ConnectionPanel({
</section> </section>
{preset.error ? ( {preset.error ? (
<div role="alert" className="rounded-[14px] bg-destructive/10 px-3.5 py-3 text-[12.5px] leading-5 text-destructive"> <div role="alert" className="rounded-control bg-destructive/10 px-3.5 py-3 text-[12.5px] leading-5 text-destructive">
{preset.error} {preset.error}
</div> </div>
) : null} ) : null}
@@ -661,7 +660,7 @@ function ConnectionPanel({
function MetricCard({ label, value }: { label: string; value: string }) { function MetricCard({ label, value }: { label: string; value: string }) {
return ( return (
<div className="min-w-0 rounded-[14px] bg-muted/45 px-3.5 py-3"> <div className="min-w-0 rounded-control bg-muted/45 px-3.5 py-3">
<dt className="text-[12px] font-medium text-muted-foreground">{label}</dt> <dt className="text-[12px] font-medium text-muted-foreground">{label}</dt>
<dd className="mt-1 truncate text-[14px] font-semibold text-foreground">{value}</dd> <dd className="mt-1 truncate text-[14px] font-semibold text-foreground">{value}</dd>
</div> </div>
@@ -319,7 +319,7 @@ export function AgentActivityCluster({
syncActivityScrollFade(); syncActivityScrollFade();
}, [syncActivityScrollFade]); }, [syncActivityScrollFade]);
if (!hasVisibleActivity) return null; if (!hasVisibleActivity && !isTurnStreaming) return null;
if (hasOnlyFileActivity) { if (hasOnlyFileActivity) {
return ( return (
@@ -343,6 +343,7 @@ export function AgentActivityCluster({
contentRef={activityContentRef} contentRef={activityContentRef}
fadeTop={activityScrollFade.top} fadeTop={activityScrollFade.top}
fadeBottom={activityScrollFade.bottom} fadeBottom={activityScrollFade.bottom}
hasDetails={hasVisibleActivity}
onToggle={toggleOuter} onToggle={toggleOuter}
onScroll={onActivityScroll} onScroll={onActivityScroll}
> >
@@ -382,7 +383,13 @@ function activityDurationMs(
const timestamps = messages const timestamps = messages
.map((message) => message.createdAt) .map((message) => message.createdAt)
.filter((value) => Number.isFinite(value)); .filter((value) => Number.isFinite(value));
if (!timestamps.length) return 0; if (!timestamps.length) {
return active
&& typeof activeStartedAtMs === "number"
&& Number.isFinite(activeStartedAtMs)
? Math.max(0, now - activeStartedAtMs)
: 0;
}
const first = active && Number.isFinite(activeStartedAtMs) const first = active && Number.isFinite(activeStartedAtMs)
? activeStartedAtMs! ? activeStartedAtMs!
: Math.min(...timestamps); : Math.min(...timestamps);
@@ -1105,7 +1112,7 @@ function CliRunRow({ run, active, app }: { run: CliRunSummary; active: boolean;
<span <span
data-testid={`activity-cli-logo-${run.name.toLowerCase()}`} data-testid={`activity-cli-logo-${run.name.toLowerCase()}`}
className={cn( className={cn(
"grid h-4 w-4 shrink-0 place-items-center overflow-hidden rounded-[4px] border text-[6.5px] font-semibold text-white", "grid h-4 w-4 shrink-0 place-items-center overflow-hidden rounded-mark border text-[6.5px] font-semibold text-white",
rowActive && "animate-pulse", rowActive && "animate-pulse",
)} )}
style={{ style={{
@@ -1183,7 +1190,7 @@ function McpRunRow({ run, active, preset }: { run: McpRunSummary; active: boolea
<span <span
data-testid={`activity-mcp-logo-${run.presetName.toLowerCase()}`} data-testid={`activity-mcp-logo-${run.presetName.toLowerCase()}`}
className={cn( className={cn(
"grid h-4 w-4 shrink-0 place-items-center overflow-hidden rounded-[4px] border text-[6.5px] font-semibold text-white", "grid h-4 w-4 shrink-0 place-items-center overflow-hidden rounded-mark border text-[6.5px] font-semibold text-white",
rowActive && "animate-pulse", rowActive && "animate-pulse",
)} )}
style={{ style={{
+1 -1
View File
@@ -203,7 +203,7 @@ export function PromptRail({
aria-hidden aria-hidden
data-testid={previewVisible ? "prompt-rail-preview" : undefined} data-testid={previewVisible ? "prompt-rail-preview" : undefined}
className={cn( className={cn(
"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", "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",
floatingSurfaceElevationClassName, floatingSurfaceElevationClassName,
"transition-[opacity,transform] duration-150", "transition-[opacity,transform] duration-150",
previewVisible previewVisible
@@ -41,12 +41,12 @@ export function SessionInfoPopover({ sessionKey, token, title }: SessionInfoPopo
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const { jobs, loading, loadFailed, now } = useSessionAutomationJobs(open, token, sessionKey); const { jobs, loading, loadFailed, now } = useSessionAutomationJobs(open, token, sessionKey);
const automationContent = loading ? ( const automationContent = loading ? (
<div className="flex items-center gap-2 rounded-[16px] bg-muted/45 px-3 py-3 text-[12.5px] text-muted-foreground"> <div className="flex items-center gap-2 rounded-floating bg-muted/45 px-3 py-3 text-[12.5px] text-muted-foreground">
<RefreshCcw className="h-3.5 w-3.5 animate-spin" /> <RefreshCcw className="h-3.5 w-3.5 animate-spin" />
{t("thread.sessionInfo.loading")} {t("thread.sessionInfo.loading")}
</div> </div>
) : loadFailed ? ( ) : loadFailed ? (
<div className="flex items-center gap-2 rounded-[16px] bg-destructive/10 px-3 py-3 text-[12.5px] text-destructive"> <div className="flex items-center gap-2 rounded-floating bg-destructive/10 px-3 py-3 text-[12.5px] text-destructive">
<CircleAlert className="h-3.5 w-3.5" /> <CircleAlert className="h-3.5 w-3.5" />
{t("thread.sessionInfo.loadFailed")} {t("thread.sessionInfo.loadFailed")}
</div> </div>
@@ -57,7 +57,7 @@ export function SessionInfoPopover({ sessionKey, token, title }: SessionInfoPopo
))} ))}
</div> </div>
) : ( ) : (
<div className="rounded-[16px] bg-muted/35 px-3 py-3 text-[12.5px] leading-relaxed text-muted-foreground"> <div className="rounded-floating bg-muted/35 px-3 py-3 text-[12.5px] leading-relaxed text-muted-foreground">
{t("thread.sessionInfo.empty")} {t("thread.sessionInfo.empty")}
</div> </div>
); );
@@ -124,7 +124,7 @@ function AutomationRow({ job, now }: { job: SessionAutomationJob; now: number })
: "bg-muted-foreground/35"; : "bg-muted-foreground/35";
return ( return (
<div className="rounded-[16px] px-3 py-2.5 transition-colors hover:bg-muted/40"> <div className="rounded-floating px-3 py-2.5 transition-colors hover:bg-muted/40">
<div className="flex items-start gap-2.5"> <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)} /> <span className={cn("mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full", statusClass)} />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
+25 -62
View File
@@ -83,7 +83,6 @@ import { useClipboardAndDrop } from "@/hooks/useClipboardAndDrop";
import { useLogoFallback } from "@/hooks/useLogoFallback"; import { useLogoFallback } from "@/hooks/useLogoFallback";
import { useMediaQuery } from "@/hooks/useMediaQuery"; import { useMediaQuery } from "@/hooks/useMediaQuery";
import type { SendAttachment, SendOptions } from "@/hooks/useNanobotStream"; import type { SendAttachment, SendOptions } from "@/hooks/useNanobotStream";
import { usePageVisibility } from "@/hooks/usePageVisibility";
import { useVoiceRecorder, type VoiceRecorderErrorKey } from "@/hooks/useVoiceRecorder"; import { useVoiceRecorder, type VoiceRecorderErrorKey } from "@/hooks/useVoiceRecorder";
import type { import type {
CliAppInfo, CliAppInfo,
@@ -207,8 +206,6 @@ interface ThreadComposerProps {
onStop?: () => void; onStop?: () => void;
surfaceRef?: Ref<HTMLDivElement>; surfaceRef?: Ref<HTMLDivElement>;
onTranscribeAudio?: (dataUrl: string, options?: { durationMs?: number }) => Promise<string>; 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``). */ /** Sustained objective for this chat (WebSocket ``goal_state``). */
goalState?: GoalStateWsPayload; goalState?: GoalStateWsPayload;
workspaceScope?: WorkspaceScopePayload | null; workspaceScope?: WorkspaceScopePayload | null;
@@ -217,6 +214,7 @@ interface ThreadComposerProps {
workspaceControls?: WorkspacesPayload["controls"] | null; workspaceControls?: WorkspacesPayload["controls"] | null;
workspaceScopeDisabled?: boolean; workspaceScopeDisabled?: boolean;
workspaceError?: string | null; workspaceError?: string | null;
onPickWorkspaceFolder?: () => Promise<string | null>;
onWorkspaceScopeChange?: (scope: WorkspaceScopePayload) => void; onWorkspaceScopeChange?: (scope: WorkspaceScopePayload) => void;
pendingQueueKey?: string | null; pendingQueueKey?: string | null;
transcriptionProvider?: string | null; transcriptionProvider?: string | null;
@@ -694,63 +692,38 @@ function mcpPresetMentionPayload(preset: McpPresetInfo): OutboundMcpPresetMentio
}; };
} }
function RunPulseIcon() { function GoalStateStrip({
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, goalState,
}: { }: {
startedAt: number | null;
goalState?: GoalStateWsPayload; goalState?: GoalStateWsPayload;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const pageVisible = usePageVisibility();
const [goalPanelOpen, setGoalPanelOpen] = useState(false); const [goalPanelOpen, setGoalPanelOpen] = useState(false);
const showTimer = startedAt != null;
const stripLabel = goalStateStripPreview(goalState, t); const stripLabel = goalStateStripPreview(goalState, t);
const showGoal = !!stripLabel?.trim(); const active = !!stripLabel?.trim();
const active = showTimer || showGoal;
const [, setTick] = useState(0); const [, setTick] = useState(0);
const stripWrapperRef = useRef<HTMLDivElement>(null); const stripWrapperRef = useRef<HTMLDivElement>(null);
const panelRef = useRef<HTMLDivElement>(null); const panelRef = useRef<HTMLDivElement>(null);
const expandToggleRef = useRef<HTMLButtonElement>(null); const expandToggleRef = useRef<HTMLButtonElement>(null);
const stripSnapshotRef = useRef<{ const stripSnapshotRef = useRef<{
startedAt: number | null;
goalState?: GoalStateWsPayload; goalState?: GoalStateWsPayload;
stripLabel: string | null; stripLabel: string | null;
} | null>(null); } | null>(null);
const [panelMaxPx, setPanelMaxPx] = useState(280); const [panelMaxPx, setPanelMaxPx] = useState(280);
if (active) { if (active) {
stripSnapshotRef.current = { startedAt, goalState, stripLabel }; stripSnapshotRef.current = { goalState, stripLabel };
} }
useEffect(() => { useEffect(() => {
if (!active) setGoalPanelOpen(false); if (!active) setGoalPanelOpen(false);
}, [active]); }, [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 const display = active
? { startedAt, goalState, stripLabel } ? { goalState, stripLabel }
: stripSnapshotRef.current; : stripSnapshotRef.current;
const displayStartedAt = display?.startedAt ?? null;
const displayGoalState = display?.goalState; const displayGoalState = display?.goalState;
const displayStripLabel = display?.stripLabel ?? null; const displayStripLabel = display?.stripLabel ?? null;
const displayShowTimer = displayStartedAt != null;
const displayShowGoal = !!displayStripLabel?.trim();
const objectiveFull = displayGoalState?.objective?.trim() ?? ""; const objectiveFull = displayGoalState?.objective?.trim() ?? "";
const summaryFull = displayGoalState?.ui_summary?.trim() ?? ""; const summaryFull = displayGoalState?.ui_summary?.trim() ?? "";
@@ -818,17 +791,11 @@ function RunElapsedStrip({
}; };
}, [goalPanelOpen]); }, [goalPanelOpen]);
const elapsed = if (!display) return null;
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 ariaParts = [timerTitle, displayShowGoal ? displayStripLabel : null].filter(Boolean); const ariaLabel = displayStripLabel
const ariaLabel = ariaParts.join(" · "); ? t("thread.composer.goalStateStrip", { label: displayStripLabel })
: t("thread.composer.goalStateFallback");
return ( return (
<div <div
@@ -837,6 +804,11 @@ function RunElapsedStrip({
data-composer-status-drawer="" data-composer-status-drawer=""
data-state={active ? "open" : "closed"} data-state={active ? "open" : "closed"}
aria-hidden={active ? undefined : true} aria-hidden={active ? undefined : true}
onTransitionEnd={(event) => {
if (active || event.target !== event.currentTarget) return;
stripSnapshotRef.current = null;
setTick((n) => n + 1);
}}
> >
{goalPanelOpen && canExpandGoal && markdownBody ? ( {goalPanelOpen && canExpandGoal && markdownBody ? (
<div <div
@@ -890,19 +862,9 @@ function RunElapsedStrip({
role="status" role="status"
aria-label={ariaLabel} aria-label={ariaLabel}
> >
{displayShowTimer ? (
<RunPulseIcon />
) : (
<Target className="h-4 w-4 shrink-0 text-primary/75" aria-hidden /> <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"> <span className="flex min-w-0 flex-1 items-center gap-1.5 text-[12px] font-medium text-foreground/75">
{timerTitle ? <span className="shrink-0">{timerTitle}</span> : null} {displayStripLabel ? (
{timerTitle && displayShowGoal ? (
<span className="shrink-0 text-muted-foreground/45" aria-hidden>
·
</span>
) : null}
{displayShowGoal ? (
<span className="truncate"> <span className="truncate">
{t("thread.composer.goalStateStrip", { label: displayStripLabel })} {t("thread.composer.goalStateStrip", { label: displayStripLabel })}
</span> </span>
@@ -962,7 +924,6 @@ export function ThreadComposer({
onStop, onStop,
surfaceRef, surfaceRef,
onTranscribeAudio, onTranscribeAudio,
runStartedAt = null,
goalState, goalState,
workspaceScope = null, workspaceScope = null,
workspaceControlsHidden = false, workspaceControlsHidden = false,
@@ -970,6 +931,7 @@ export function ThreadComposer({
workspaceControls = null, workspaceControls = null,
workspaceScopeDisabled = false, workspaceScopeDisabled = false,
workspaceError = null, workspaceError = null,
onPickWorkspaceFolder,
onWorkspaceScopeChange, onWorkspaceScopeChange,
pendingQueueKey = null, pendingQueueKey = null,
transcriptionProvider = null, transcriptionProvider = null,
@@ -2284,8 +2246,8 @@ export function ThreadComposer({
className={cn( className={cn(
"thread-composer-surface group/composer relative mx-auto flex w-full flex-col overflow-visible transition-all duration-200", "thread-composer-surface group/composer relative mx-auto flex w-full flex-col overflow-visible transition-all duration-200",
isHero isHero
? "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-[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-[22px] 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]",
interactionDisabled && "opacity-60", interactionDisabled && "opacity-60",
sessionDragPreview && "ring-1 ring-primary/25", sessionDragPreview && "ring-1 ring-primary/25",
isDragging && "ring-2 ring-primary/40 motion-reduce:ring-0 motion-reduce:border-primary", isDragging && "ring-2 ring-primary/40 motion-reduce:ring-0 motion-reduce:border-primary",
@@ -2368,7 +2330,7 @@ export function ThreadComposer({
</button> </button>
</div> </div>
) : null} ) : null}
<RunElapsedStrip startedAt={runStartedAt} goalState={goalState} /> <GoalStateStrip goalState={goalState} />
<div className="relative"> <div className="relative">
{hasMentionDecorations ? ( {hasMentionDecorations ? (
<ComposerCliMentionOverlay <ComposerCliMentionOverlay
@@ -2600,6 +2562,7 @@ export function ThreadComposer({
defaultScope={workspaceDefaultScope} defaultScope={workspaceDefaultScope}
controls={workspaceControls} controls={workspaceControls}
error={workspaceError} error={workspaceError}
onPickFolder={onPickWorkspaceFolder}
onChange={onWorkspaceScopeChange} onChange={onWorkspaceScopeChange}
/> />
</div> </div>
@@ -2647,7 +2610,7 @@ function QueuedPromptStack({
role="group" role="group"
data-state="enter" data-state="enter"
className={cn( className={cn(
"composer-status-strip relative z-20 mx-3 mt-3 overflow-hidden rounded-[18px]", "composer-status-strip relative z-20 mx-3 mt-3 overflow-hidden rounded-floating",
"border border-black/[0.05] bg-popover/90 p-1.5", "border border-black/[0.05] bg-popover/90 p-1.5",
"shadow-[0_10px_28px_rgba(15,23,42,0.07)] backdrop-blur-md", "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)]", "dark:border-white/[0.08] dark:bg-popover/90 dark:shadow-[0_14px_34px_rgba(0,0,0,0.30)]",
@@ -2725,7 +2688,7 @@ function QueuedPromptRow({
}} }}
onDragEnd={onDragEnd} onDragEnd={onDragEnd}
className={cn( className={cn(
"queued-prompt-row group/queued flex min-h-8 items-center gap-1.5 rounded-[12px] px-2 py-0.5", "queued-prompt-row group/queued flex min-h-8 items-center gap-1.5 rounded-control px-2 py-0.5",
"text-[13px] transition-colors hover:bg-muted/55 dark:hover:bg-white/[0.055]", "text-[13px] transition-colors hover:bg-muted/55 dark:hover:bg-white/[0.055]",
isHero && "text-[13.5px]", isHero && "text-[13.5px]",
)} )}
@@ -3010,7 +2973,7 @@ function MentionCandidateLogo({
return ( return (
<span <span
className={cn( className={cn(
"flex h-5 w-5 shrink-0 items-center justify-center overflow-hidden rounded-[5px]", "flex h-5 w-5 shrink-0 items-center justify-center overflow-hidden rounded-compact",
selected ? "bg-background/55" : "bg-transparent", selected ? "bg-background/55" : "bg-transparent",
)} )}
> >
@@ -3028,7 +2991,7 @@ function MentionCandidateLogo({
} }
return ( return (
<span <span
className="flex h-5 w-5 shrink-0 items-center justify-center rounded-[5px] text-[7.5px] font-semibold text-white" className="flex h-5 w-5 shrink-0 items-center justify-center rounded-compact text-[7.5px] font-semibold text-white"
style={{ backgroundColor: color }} style={{ backgroundColor: color }}
> >
{candidate.initials} {candidate.initials}
@@ -3172,7 +3135,7 @@ function AttachmentChip({
return ( return (
<div <div
className={cn( className={cn(
"group relative flex items-center gap-2 rounded-[12px] border px-2 py-1.5", "group relative flex items-center gap-2 rounded-control border px-2 py-1.5",
"transition-colors motion-reduce:transition-none", "transition-colors motion-reduce:transition-none",
tone, tone,
)} )}
@@ -11,6 +11,9 @@ interface ThreadMessagesProps {
temporary?: boolean; temporary?: boolean;
/** When true, agent turn still in flight — keeps activity timeline expanded. */ /** When true, agent turn still in flight — keeps activity timeline expanded. */
isStreaming?: boolean; isStreaming?: boolean;
activeTurnId?: string | null;
/** Optimistic or canonical active-turn start, in unix seconds. */
runStartedAt?: number | null;
hiddenUserMessageCount?: number; hiddenUserMessageCount?: number;
cliApps?: CliAppInfo[]; cliApps?: CliAppInfo[];
mcpPresets?: McpPresetInfo[]; mcpPresets?: McpPresetInfo[];
@@ -53,6 +56,8 @@ export function ThreadMessages({
messages, messages,
temporary = false, temporary = false,
isStreaming = false, isStreaming = false,
activeTurnId = null,
runStartedAt = null,
hiddenUserMessageCount = 0, hiddenUserMessageCount = 0,
cliApps = [], cliApps = [],
mcpPresets = [], mcpPresets = [],
@@ -74,6 +79,16 @@ export function ThreadMessages({
() => isStreaming ? currentActivityClusterIndices(units) : new Set<number>(), () => isStreaming ? currentActivityClusterIndices(units) : new Set<number>(),
[isStreaming, units], [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]); const unitKeys = useMemo(() => unitKeysForDisplay(units), [units]);
let nextUserIndex = hiddenUserMessageCount; let nextUserIndex = hiddenUserMessageCount;
@@ -136,10 +151,68 @@ 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> </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 { interface ThreadDisplayUnitProps {
unit: DisplayUnit; unit: DisplayUnit;
marginTop: string; marginTop: string;
+15 -2
View File
@@ -661,6 +661,14 @@ export function ThreadShell({
forkBoundaryMessageCount, forkBoundaryMessageCount,
} = useSessionHistory(historyKey); } = useSessionHistory(historyKey);
const { client, getToken, ingressLimits, modelName, token } = useClient(); 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 [fallbackModelName, setFallbackModelName] = useState<string | null>(null);
const [booting, setBooting] = useState(false); const [booting, setBooting] = useState(false);
const [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]); const [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]);
@@ -1450,7 +1458,6 @@ export function ThreadShell({
skills={skills} skills={skills}
onStop={stop} onStop={stop}
onTranscribeAudio={transcribeAudio} onTranscribeAudio={transcribeAudio}
runStartedAt={currentRunStartedAt}
goalState={currentGoalState} goalState={currentGoalState}
workspaceScope={workspaceScope} workspaceScope={workspaceScope}
workspaceControlsHidden={temporary} workspaceControlsHidden={temporary}
@@ -1458,6 +1465,9 @@ export function ThreadShell({
workspaceControls={workspaceControls} workspaceControls={workspaceControls}
workspaceScopeDisabled={workspaceScopeDisabled} workspaceScopeDisabled={workspaceScopeDisabled}
workspaceError={workspaceError} workspaceError={workspaceError}
onPickWorkspaceFolder={
workspaceControls?.can_pick_folder ? pickWorkspaceFolder : undefined
}
onWorkspaceScopeChange={onWorkspaceScopeChange} onWorkspaceScopeChange={onWorkspaceScopeChange}
pendingQueueKey={temporary ? null : chatId} pendingQueueKey={temporary ? null : chatId}
transcriptionProvider={settingsSnapshot?.transcription?.provider} transcriptionProvider={settingsSnapshot?.transcription?.provider}
@@ -1494,7 +1504,6 @@ export function ThreadShell({
sessions={mentionSessions} sessions={mentionSessions}
skills={skills} skills={skills}
surfaceRef={composerSurfaceRef} surfaceRef={composerSurfaceRef}
runStartedAt={currentRunStartedAt}
onTranscribeAudio={transcribeAudio} onTranscribeAudio={transcribeAudio}
goalState={currentGoalState} goalState={currentGoalState}
workspaceScope={workspaceScope} workspaceScope={workspaceScope}
@@ -1503,6 +1512,9 @@ export function ThreadShell({
workspaceControls={workspaceControls} workspaceControls={workspaceControls}
workspaceScopeDisabled={workspaceScopeDisabled} workspaceScopeDisabled={workspaceScopeDisabled}
workspaceError={workspaceError} workspaceError={workspaceError}
onPickWorkspaceFolder={
workspaceControls?.can_pick_folder ? pickWorkspaceFolder : undefined
}
onWorkspaceScopeChange={onWorkspaceScopeChange} onWorkspaceScopeChange={onWorkspaceScopeChange}
transcriptionProvider={settingsSnapshot?.transcription?.provider} transcriptionProvider={settingsSnapshot?.transcription?.provider}
ingressLimits={ingressLimits} ingressLimits={ingressLimits}
@@ -1565,6 +1577,7 @@ export function ThreadShell({
messages={displayMessages} messages={displayMessages}
temporary={temporary} temporary={temporary}
isStreaming={turnActive} isStreaming={turnActive}
runStartedAt={currentRunStartedAt}
emptyState={emptyState} emptyState={emptyState}
composer={composerPortalTarget === undefined ? composer : null} composer={composerPortalTarget === undefined ? composer : null}
activeTurnId={viewportTurnId} activeTurnId={viewportTurnId}
+74 -4
View File
@@ -37,6 +37,8 @@ interface ThreadViewportProps {
messages: UIMessage[]; messages: UIMessage[];
temporary?: boolean; temporary?: boolean;
isStreaming: boolean; isStreaming: boolean;
/** Optimistic or canonical start time for the active turn, in unix seconds. */
runStartedAt?: number | null;
composer?: ReactNode; composer?: ReactNode;
emptyState?: ReactNode; emptyState?: ReactNode;
scrollToBottomSignal?: number; scrollToBottomSignal?: number;
@@ -64,6 +66,9 @@ const DEFAULT_SCROLL_BUTTON_BOTTOM_PX = 192;
const EXTERNAL_COMPOSER_SCROLL_BUTTON_BOTTOM_PX = 16; const EXTERNAL_COMPOSER_SCROLL_BUTTON_BOTTOM_PX = 16;
const SCROLL_BUTTON_COMPOSER_GAP_PX = 16; const SCROLL_BUTTON_COMPOSER_GAP_PX = 16;
const SOFT_KEYBOARD_MIN_INSET_PX = 80; const SOFT_KEYBOARD_MIN_INSET_PX = 80;
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 INITIAL_HISTORY_WINDOW = 160;
export const HISTORY_WINDOW_INCREMENT = 120; export const HISTORY_WINDOW_INCREMENT = 120;
@@ -104,6 +109,13 @@ function isThreadDisclosureTarget(target: EventTarget | null): boolean {
&& target.closest("[data-thread-disclosure]") !== null; && 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"; type ThreadScrollDirection = "backward" | "forward";
const KEYBOARD_SCROLL_DIRECTIONS: Readonly< const KEYBOARD_SCROLL_DIRECTIONS: Readonly<
@@ -161,6 +173,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
messages, messages,
temporary = false, temporary = false,
isStreaming, isStreaming,
runStartedAt = null,
composer, composer,
emptyState, emptyState,
scrollToBottomSignal = 0, scrollToBottomSignal = 0,
@@ -187,9 +200,12 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
const contentRef = useRef<HTMLDivElement>(null); const contentRef = useRef<HTMLDivElement>(null);
const messageRegionRef = useRef<HTMLDivElement>(null); const messageRegionRef = useRef<HTMLDivElement>(null);
const messageContentRef = useRef<HTMLDivElement>(null); const messageContentRef = useRef<HTMLDivElement>(null);
const emptyStateRef = useRef<HTMLDivElement>(null);
const composerDockRef = useRef<HTMLDivElement>(null); const composerDockRef = useRef<HTMLDivElement>(null);
const bottomRef = useRef<HTMLDivElement>(null); const bottomRef = useRef<HTMLDivElement>(null);
const lastConversationKeyRef = useRef<string | null>(conversationKey); const lastConversationKeyRef = useRef<string | null>(conversationKey);
const conversationHandoffPendingRef = useRef(false);
const conversationHandoffAnimationRef = useRef<Animation | null>(null);
const pendingConversationScrollRef = useRef(true); const pendingConversationScrollRef = useRef(true);
const pendingPromptJumpRef = useRef<string | null>(null); const pendingPromptJumpRef = useRef<string | null>(null);
const restoreScrollAfterPrependRef = const restoreScrollAfterPrependRef =
@@ -422,11 +438,27 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
useLayoutEffect(() => { useLayoutEffect(() => {
if (lastConversationKeyRef.current === conversationKey) return; if (lastConversationKeyRef.current === conversationKey) return;
lastConversationKeyRef.current = conversationKey; lastConversationKeyRef.current = conversationKey;
conversationHandoffAnimationRef.current?.cancel();
conversationHandoffAnimationRef.current = null;
conversationHandoffPendingRef.current = true;
pendingConversationScrollRef.current = true; pendingConversationScrollRef.current = true;
threadMotionRef.current?.reset(); threadMotionRef.current?.reset();
setAtBottom(true); setAtBottom(true);
setVisibleMessageCount(INITIAL_HISTORY_WINDOW); setVisibleMessageCount(INITIAL_HISTORY_WINDOW);
}, [conversationKey]);
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]);
useLayoutEffect(() => { useLayoutEffect(() => {
if (!conversationReady) { if (!conversationReady) {
@@ -513,11 +545,41 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
scrollToBottom, 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(() => { useLayoutEffect(() => {
threadMotionRef.current?.invalidateGeometry(); threadMotionRef.current?.invalidateGeometry();
}, [composer, hasMessages, visibleMessages.length]); }, [composer, hasMessages, visibleMessages.length]);
useEffect(() => () => threadMotionRef.current?.dispose(), []); useEffect(() => () => {
conversationHandoffAnimationRef.current?.cancel();
threadMotionRef.current?.dispose();
}, []);
useLayoutEffect(() => { useLayoutEffect(() => {
const el = scrollRef.current; const el = scrollRef.current;
@@ -530,10 +592,13 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
const invalidateGeometry = () => { const invalidateGeometry = () => {
threadMotionRef.current?.invalidateGeometry(); threadMotionRef.current?.invalidateGeometry();
}; };
invalidateGeometry(); const reconcileObservedGeometry = () => {
threadMotionRef.current?.reconcileObservedGeometry();
};
reconcileObservedGeometry();
const observer = typeof ResizeObserver === "undefined" const observer = typeof ResizeObserver === "undefined"
? null ? null
: new ResizeObserver(invalidateGeometry); : new ResizeObserver(reconcileObservedGeometry);
observer?.observe(el); observer?.observe(el);
if (content) observer?.observe(content); if (content) observer?.observe(content);
if (messageRegion) observer?.observe(messageRegion); if (messageRegion) observer?.observe(messageRegion);
@@ -623,6 +688,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
yieldCameraToUser(); yieldCameraToUser();
return; return;
} }
if (isKeyboardControl(event.target as Element | null)) return;
handleDirectionalInput(keyboardScrollDirection(event)); handleDirectionalInput(keyboardScrollDirection(event));
}; };
el.addEventListener("scroll", handleScroll, { passive: true }); el.addEventListener("scroll", handleScroll, { passive: true });
@@ -690,6 +756,8 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
messages={visibleMessages} messages={visibleMessages}
temporary={temporary} temporary={temporary}
isStreaming={isStreaming} isStreaming={isStreaming}
activeTurnId={activeTurnId}
runStartedAt={runStartedAt}
hiddenUserMessageCount={hiddenUserMessageCount} hiddenUserMessageCount={hiddenUserMessageCount}
cliApps={cliApps} cliApps={cliApps}
mcpPresets={mcpPresets} mcpPresets={mcpPresets}
@@ -704,6 +772,8 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
</div> </div>
) : ( ) : (
<div <div
ref={emptyStateRef}
data-testid="thread-empty-region"
className={cn( className={cn(
"row-start-1 flex min-h-0 min-w-0 w-full items-center justify-center", "row-start-1 flex min-h-0 min-w-0 w-full items-center justify-center",
hasComposer && "sm:items-end sm:pb-8", hasComposer && "sm:items-end sm:pb-8",
@@ -51,6 +51,7 @@ export function WorkspaceProjectPicker({
defaultScope, defaultScope,
controls, controls,
error, error,
onPickFolder,
onChange, onChange,
}: { }: {
isHero: boolean; isHero: boolean;
@@ -61,6 +62,7 @@ export function WorkspaceProjectPicker({
defaultScope: WorkspaceScopePayload | null; defaultScope: WorkspaceScopePayload | null;
controls: WorkspacesPayload["controls"] | null; controls: WorkspacesPayload["controls"] | null;
error?: string | null; error?: string | null;
onPickFolder?: () => Promise<string | null>;
onChange?: (scope: WorkspaceScopePayload) => void; onChange?: (scope: WorkspaceScopePayload) => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -79,7 +81,7 @@ export function WorkspaceProjectPicker({
&& !!defaultScope && !!defaultScope
&& !!onChange && !!onChange
&& controls?.can_change_project !== false; && controls?.can_change_project !== false;
const pickFolder = getRuntimeHost().pickFolder; const pickFolder = getRuntimeHost().pickFolder ?? onPickFolder;
const nativeProjectPicker = !!pickFolder; const nativeProjectPicker = !!pickFolder;
useEffect(() => { useEffect(() => {
@@ -219,7 +221,7 @@ export function WorkspaceProjectPicker({
"flex min-h-[48px] w-full cursor-default gap-3 px-3 py-2.5 focus:bg-muted/55", "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-[12px] bg-muted text-foreground/80"> <span className="grid h-8 w-8 shrink-0 place-items-center rounded-control bg-muted text-foreground/80">
<Folder className="h-4 w-4" /> <Folder className="h-4 w-4" />
</span> </span>
<span className="min-w-0 flex-1"> <span className="min-w-0 flex-1">
@@ -326,7 +328,7 @@ export function WorkspaceAccessMenu({
aria-label={accessAriaLabel} aria-label={accessAriaLabel}
title={accessLabel} title={accessLabel}
className={cn( className={cn(
"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", "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",
isHero ? "h-8 px-2.5 text-[12px]" : "h-9 px-3 text-[12.5px]", isHero ? "h-8 px-2.5 text-[12px]" : "h-9 px-3 text-[12.5px]",
isFull isFull
? "bg-transparent text-orange-600 hover:bg-orange-500/8 dark:text-orange-300 dark:hover:bg-orange-400/10" ? "bg-transparent text-orange-600 hover:bg-orange-500/8 dark:text-orange-300 dark:hover:bg-orange-400/10"
@@ -12,6 +12,7 @@ interface ThinkingReasoningShellProps {
contentRef: Ref<HTMLDivElement>; contentRef: Ref<HTMLDivElement>;
fadeTop: boolean; fadeTop: boolean;
fadeBottom: boolean; fadeBottom: boolean;
hasDetails?: boolean;
onToggle: () => void; onToggle: () => void;
onScroll: () => void; onScroll: () => void;
} }
@@ -25,6 +26,7 @@ export function ThinkingReasoningShell({
contentRef, contentRef,
fadeTop, fadeTop,
fadeBottom, fadeBottom,
hasDetails = true,
onToggle, onToggle,
onScroll, onScroll,
}: ThinkingReasoningShellProps) { }: ThinkingReasoningShellProps) {
@@ -33,6 +35,7 @@ export function ThinkingReasoningShell({
className="flex w-full max-w-[45rem] animate-in flex-col fade-in duration-300 motion-reduce:animate-none" className="flex w-full max-w-[45rem] animate-in flex-col fade-in duration-300 motion-reduce:animate-none"
data-state={active ? "thinking" : "done"} data-state={active ? "thinking" : "done"}
> >
{hasDetails ? (
<button <button
type="button" type="button"
data-thread-disclosure="" data-thread-disclosure=""
@@ -67,7 +70,25 @@ export function ThinkingReasoningShell({
/> />
</span> </span>
</button> </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 <div
{...(!expanded ? { inert: "" } : {})} {...(!expanded ? { inert: "" } : {})}
aria-hidden={!expanded} aria-hidden={!expanded}
@@ -107,6 +128,7 @@ export function ThinkingReasoningShell({
) : null} ) : null}
</div> </div>
</div> </div>
) : null}
</div> </div>
); );
} }
@@ -61,7 +61,7 @@ function WebFavicon({ host, active }: { host: string; active: boolean }) {
<img <img
src={logoUrl} src={logoUrl}
alt="" alt=""
className={`h-4 w-4 shrink-0 rounded-[3px] object-contain${active ? " animate-pulse" : ""}`} className={`h-4 w-4 shrink-0 rounded-mark object-contain${active ? " animate-pulse" : ""}`}
decoding="async" decoding="async"
loading="lazy" loading="lazy"
referrerPolicy="no-referrer" referrerPolicy="no-referrer"
+13 -4
View File
@@ -147,10 +147,10 @@ function defaultScheduler(): ThreadMotionScheduler {
} }
/** /**
* Owns the policy that turns discrete layout events into automatic tail * Owns the policy that turns layout events into automatic tail pinning or
* pinning or explicit camera navigation. Callers only invalidate geometry; * explicit camera navigation. Discrete notifications are coalesced into one
* one display frame coalesces those notifications and reads the authoritative * display frame. ResizeObserver deliveries reconcile immediately because they
* layout before applying either policy. * already carry the browser's authoritative layout and run before paint.
*/ */
export class ThreadMotionCoordinator { export class ThreadMotionCoordinator {
private readonly camera: ThreadMotionCamera; private readonly camera: ThreadMotionCamera;
@@ -240,6 +240,15 @@ export class ThreadMotionCoordinator {
this.measurementFrameId = this.scheduler.request(this.flushGeometry); 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 { handleComposerInput(): void {
// Input and protocol completion can arrive in either order. Remember // Input and protocol completion can arrive in either order. Remember
// editing that starts just before turn_end so the completion drawer // 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} ref={ref}
className={cn( className={cn(
modalSurfaceClassName, modalSurfaceClassName,
"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", "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",
className, className,
)} )}
{...props} {...props}
@@ -64,7 +64,7 @@ const AlertDialogFooter = ({
}: React.HTMLAttributes<HTMLDivElement>) => ( }: React.HTMLAttributes<HTMLDivElement>) => (
<div <div
className={cn( className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", "flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className, className,
)} )}
{...props} {...props}
+3 -3
View File
@@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const buttonVariants = cva( const buttonVariants = cva(
"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", "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",
{ {
variants: { variants: {
variant: { variant: {
@@ -21,8 +21,8 @@ const buttonVariants = cva(
}, },
size: { size: {
default: "h-10 px-4 py-2", default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3", sm: "h-9 px-3",
lg: "h-11 rounded-md px-8", lg: "h-11 px-8",
icon: "h-9 w-9", icon: "h-9 w-9",
}, },
}, },
+2 -2
View File
@@ -45,7 +45,7 @@ const DialogContent = React.forwardRef<
ref={ref} ref={ref}
className={cn( className={cn(
modalSurfaceClassName, modalSurfaceClassName,
"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", "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",
className, className,
)} )}
{...props} {...props}
@@ -84,7 +84,7 @@ const DialogFooter = ({
}: React.HTMLAttributes<HTMLDivElement>) => ( }: React.HTMLAttributes<HTMLDivElement>) => (
<div <div
className={cn( className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", "flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className, className,
)} )}
{...props} {...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)]"; "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 = export const floatingSurfaceVisualClassName =
`rounded-[18px] p-1.5 ${floatingSurfaceElevationClassName}`; `rounded-floating p-1.5 ${floatingSurfaceElevationClassName}`;
export const modalOverlayClassName = 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"; "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"; "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 = export const floatingItemClassName =
"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"; "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";
export const floatingItemFocusClassName = export const floatingItemFocusClassName =
"focus:bg-foreground/[0.055] focus:text-foreground dark:focus:bg-white/[0.08]"; "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 <input
type={type} type={type}
className={cn( className={cn(
"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", "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",
formControlFocusClassName, formControlFocusClassName,
className, className,
)} )}
+1 -1
View File
@@ -10,7 +10,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
return ( return (
<textarea <textarea
className={cn( className={cn(
"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", "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",
formControlFocusClassName, formControlFocusClassName,
className, className,
)} )}
+1 -1
View File
@@ -18,7 +18,7 @@ const TooltipContent = React.forwardRef<
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className={cn(
floatingSurfaceElevationClassName, floatingSurfaceElevationClassName,
"z-50 overflow-hidden rounded-[10px] px-3 py-1.5 text-xs animate-in fade-in-0 zoom-in-95", "z-50 overflow-hidden rounded-control px-3 py-1.5 text-xs animate-in fade-in-0 zoom-in-95",
className, className,
)} )}
{...props} {...props}
@@ -220,7 +220,9 @@ export function PaneWorkbench({
const gridRef = useRef<HTMLDivElement | null>(null); const gridRef = useRef<HTMLDivElement | null>(null);
const paneRefs = useRef(new Map<string, HTMLElement>()); const paneRefs = useRef(new Map<string, HTMLElement>());
const lastRectsRef = useRef(new Map<string, DOMRect>()); const lastRectsRef = useRef(new Map<string, DOMRect>());
const lastElementRectsRef = useRef(new Map<HTMLElement, DOMRect>());
const pendingRectsRef = useRef<Map<string, DOMRect> | null>(null); const pendingRectsRef = useRef<Map<string, DOMRect> | null>(null);
const pendingElementRectsRef = useRef<Map<HTMLElement, DOMRect> | null>(null);
const animationsRef = useRef(new Map<string, Animation>()); const animationsRef = useRef(new Map<string, Animation>());
const sourceSplitRatiosKey = splitRatios.join("\u0000"); const sourceSplitRatiosKey = splitRatios.join("\u0000");
const [previewSplitRatios, setPreviewSplitRatios] = useState(splitRatios); const [previewSplitRatios, setPreviewSplitRatios] = useState(splitRatios);
@@ -284,24 +286,36 @@ export function PaneWorkbench({
return rects; 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(() => { const captureLayout = useCallback(() => {
pendingRectsRef.current = measurePanes(); pendingRectsRef.current = measurePanes();
pendingElementRectsRef.current = measurePaneElements();
for (const animation of animationsRef.current.values()) animation.cancel(); for (const animation of animationsRef.current.values()) animation.cancel();
animationsRef.current.clear(); animationsRef.current.clear();
}, [measurePanes]); }, [measurePaneElements, measurePanes]);
useLayoutEffect(() => { useLayoutEffect(() => {
const previousRects = pendingRectsRef.current ?? lastRectsRef.current; const previousRects = pendingRectsRef.current ?? lastRectsRef.current;
const previousElementRects = pendingElementRectsRef.current ?? lastElementRectsRef.current;
pendingRectsRef.current = null; pendingRectsRef.current = null;
pendingElementRectsRef.current = null;
const nextRects = measurePanes(); const nextRects = measurePanes();
const nextElementRects = measurePaneElements();
const reduceMotion = typeof window.matchMedia === "function" const reduceMotion = typeof window.matchMedia === "function"
&& window.matchMedia("(prefers-reduced-motion: reduce)").matches; && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!reduceMotion) { if (!reduceMotion) {
for (const [key, nextRect] of nextRects) { for (const [key, nextRect] of nextRects) {
const previousRect = previousRects.get(key);
const element = paneRefs.current.get(key); const element = paneRefs.current.get(key);
if (!element) continue; if (!element) continue;
const previousRect = previousRects.get(key) ?? previousElementRects.get(element);
if (!previousRect) { if (!previousRect) {
if (previousRects.size === 0 || typeof element.animate !== "function") continue; if (previousRects.size === 0 || typeof element.animate !== "function") continue;
const animation = element.animate( const animation = element.animate(
@@ -356,7 +370,8 @@ export function PaneWorkbench({
} }
} }
lastRectsRef.current = nextRects; lastRectsRef.current = nextRects;
}, [activePaneKey, effectiveLayout, measurePanes, paneOrder]); lastElementRectsRef.current = nextElementRects;
}, [activePaneKey, effectiveLayout, measurePaneElements, measurePanes, paneOrder]);
useEffect(() => () => { useEffect(() => () => {
for (const animation of animationsRef.current.values()) animation.cancel(); for (const animation of animationsRef.current.values()) animation.cancel();
+11 -58
View File
@@ -38,6 +38,15 @@
--temporary-foreground: 17 88% 32%; --temporary-foreground: 17 88% 32%;
--temporary-border: 17 88% 40%; --temporary-border: 17 88% 40%;
--radius: 0.4375rem; --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: 40 8% 96.8%;
--sidebar-foreground: 0 0% 3.9%; --sidebar-foreground: 0 0% 3.9%;
--sidebar-selected: 40 1% 89.4%; --sidebar-selected: 40 1% 89.4%;
@@ -121,7 +130,7 @@
*::-webkit-scrollbar-thumb { *::-webkit-scrollbar-thumb {
background-color: var(--scrollbar-thumb); background-color: var(--scrollbar-thumb);
border-radius: 9999px; border-radius: var(--radius-pill);
} }
*::-webkit-scrollbar-thumb:hover { *::-webkit-scrollbar-thumb:hover {
@@ -478,50 +487,6 @@
transform: translateY(0); 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 { @keyframes queued-prompt-row-enter {
0% { 0% {
opacity: 0; opacity: 0;
@@ -552,18 +517,6 @@
.thread-layout { .thread-layout {
transition-duration: 0.01ms; 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 { .queued-prompt-row {
animation: none; animation: none;
} }
@@ -623,7 +576,7 @@
} }
.scrollbar-thin::-webkit-scrollbar-thumb { .scrollbar-thin::-webkit-scrollbar-thumb {
background-color: hsl(var(--muted-foreground) / 0.4); background-color: hsl(var(--muted-foreground) / 0.4);
border-radius: 9999px; border-radius: var(--radius-pill);
} }
.scrollbar-track-transparent { .scrollbar-track-transparent {
scrollbar-gutter: stable; scrollbar-gutter: stable;
+4 -4
View File
@@ -1451,16 +1451,16 @@
"workbench": { "workbench": {
"aria": "Conversation workbench", "aria": "Conversation workbench",
"panes": "Panes", "panes": "Panes",
"tabAria": "Tab: {{title}}", "tabAria": "Group: {{title}}",
"panesInTab": "Panes in {{title}}", "panesInTab": "Panes in {{title}}",
"collapseTabGroup": "Collapse panes in {{title}}", "collapseTabGroup": "Collapse panes in {{title}}",
"expandTabGroup": "Expand panes in {{title}}", "expandTabGroup": "Expand panes in {{title}}",
"dropPane": "Move {{pane}} into {{tab}}", "dropPane": "Move {{pane}} into {{tab}}",
"createGroup": "Create group", "createGroup": "Create group",
"moveTo": "Move to", "moveTo": "Move to",
"renameTabTitle": "Rename tab", "renameGroupTitle": "Rename group",
"renameTabDescription": "Give this tab a name for organizing its panes.", "renameGroupDescription": "Give this group a name.",
"renameTabPlaceholder": "Tab name", "renameGroupPlaceholder": "Group name",
"dissolveTab": "Dissolve group", "dissolveTab": "Dissolve group",
"layout": "Pane layout", "layout": "Pane layout",
"addPane": "Add pane", "addPane": "Add pane",
+4 -4
View File
@@ -1438,16 +1438,16 @@
"workbench": { "workbench": {
"aria": "Área de conversaciones", "aria": "Área de conversaciones",
"panes": "Paneles", "panes": "Paneles",
"tabAria": "Pestaña: {{title}}", "tabAria": "Grupo: {{title}}",
"panesInTab": "Paneles de {{title}}", "panesInTab": "Paneles de {{title}}",
"collapseTabGroup": "Contraer los paneles de {{title}}", "collapseTabGroup": "Contraer los paneles de {{title}}",
"expandTabGroup": "Expandir los paneles de {{title}}", "expandTabGroup": "Expandir los paneles de {{title}}",
"dropPane": "Mover {{pane}} a {{tab}}", "dropPane": "Mover {{pane}} a {{tab}}",
"createGroup": "Crear grupo", "createGroup": "Crear grupo",
"moveTo": "Mover a", "moveTo": "Mover a",
"renameTabTitle": "Renombrar pestaña", "renameGroupTitle": "Renombrar grupo",
"renameTabDescription": "Ponle un nombre a esta pestaña para organizar sus paneles.", "renameGroupDescription": "Ponle un nombre a este grupo.",
"renameTabPlaceholder": "Nombre de la pestaña", "renameGroupPlaceholder": "Nombre del grupo",
"dissolveTab": "Disolver grupo", "dissolveTab": "Disolver grupo",
"layout": "Diseño de paneles", "layout": "Diseño de paneles",
"addPane": "Añadir panel", "addPane": "Añadir panel",
+4 -4
View File
@@ -1437,16 +1437,16 @@
"workbench": { "workbench": {
"aria": "Espace de conversations", "aria": "Espace de conversations",
"panes": "Volets", "panes": "Volets",
"tabAria": "Onglet : {{title}}", "tabAria": "Groupe : {{title}}",
"panesInTab": "Volets dans {{title}}", "panesInTab": "Volets dans {{title}}",
"collapseTabGroup": "Réduire les volets de {{title}}", "collapseTabGroup": "Réduire les volets de {{title}}",
"expandTabGroup": "Développer les volets de {{title}}", "expandTabGroup": "Développer les volets de {{title}}",
"dropPane": "Déplacer {{pane}} dans {{tab}}", "dropPane": "Déplacer {{pane}} dans {{tab}}",
"createGroup": "Créer un groupe", "createGroup": "Créer un groupe",
"moveTo": "Déplacer vers", "moveTo": "Déplacer vers",
"renameTabTitle": "Renommer longlet", "renameGroupTitle": "Renommer le groupe",
"renameTabDescription": "Donnez un nom à cet onglet pour organiser ses volets.", "renameGroupDescription": "Donnez un nom à ce groupe.",
"renameTabPlaceholder": "Nom de longlet", "renameGroupPlaceholder": "Nom du groupe",
"dissolveTab": "Dissoudre le groupe", "dissolveTab": "Dissoudre le groupe",
"layout": "Disposition des volets", "layout": "Disposition des volets",
"addPane": "Ajouter un volet", "addPane": "Ajouter un volet",
+4 -4
View File
@@ -1437,16 +1437,16 @@
"workbench": { "workbench": {
"aria": "Ruang kerja percakapan", "aria": "Ruang kerja percakapan",
"panes": "Panel", "panes": "Panel",
"tabAria": "Tab: {{title}}", "tabAria": "Grup: {{title}}",
"panesInTab": "Panel di {{title}}", "panesInTab": "Panel di {{title}}",
"collapseTabGroup": "Ciutkan panel di {{title}}", "collapseTabGroup": "Ciutkan panel di {{title}}",
"expandTabGroup": "Luaskan panel di {{title}}", "expandTabGroup": "Luaskan panel di {{title}}",
"dropPane": "Pindahkan {{pane}} ke {{tab}}", "dropPane": "Pindahkan {{pane}} ke {{tab}}",
"createGroup": "Buat grup", "createGroup": "Buat grup",
"moveTo": "Pindahkan ke", "moveTo": "Pindahkan ke",
"renameTabTitle": "Ganti nama tab", "renameGroupTitle": "Ganti nama grup",
"renameTabDescription": "Beri nama tab ini untuk mengatur panelnya.", "renameGroupDescription": "Beri nama untuk grup ini.",
"renameTabPlaceholder": "Nama tab", "renameGroupPlaceholder": "Nama grup",
"dissolveTab": "Bubarkan grup", "dissolveTab": "Bubarkan grup",
"layout": "Tata letak panel", "layout": "Tata letak panel",
"addPane": "Tambah panel", "addPane": "Tambah panel",
+4 -4
View File
@@ -1437,16 +1437,16 @@
"workbench": { "workbench": {
"aria": "会話ワークベンチ", "aria": "会話ワークベンチ",
"panes": "ペイン", "panes": "ペイン",
"tabAria": "タブ{{title}}", "tabAria": "グループ{{title}}",
"panesInTab": "{{title}} のペイン", "panesInTab": "{{title}} のペイン",
"collapseTabGroup": "{{title}} のペインを折りたたむ", "collapseTabGroup": "{{title}} のペインを折りたたむ",
"expandTabGroup": "{{title}} のペインを展開する", "expandTabGroup": "{{title}} のペインを展開する",
"dropPane": "{{pane}} を {{tab}} に移動", "dropPane": "{{pane}} を {{tab}} に移動",
"createGroup": "グループを作成", "createGroup": "グループを作成",
"moveTo": "移動先", "moveTo": "移動先",
"renameTabTitle": "タブ名を変更", "renameGroupTitle": "グループ名を変更",
"renameTabDescription": "ペインを整理するため、このタブに名前を付けます。", "renameGroupDescription": "このグループに名前を付けます。",
"renameTabPlaceholder": "タブ名", "renameGroupPlaceholder": "グループ名",
"dissolveTab": "グループを解除", "dissolveTab": "グループを解除",
"layout": "ペインレイアウト", "layout": "ペインレイアウト",
"addPane": "ペインを追加", "addPane": "ペインを追加",
+4 -4
View File
@@ -1437,16 +1437,16 @@
"workbench": { "workbench": {
"aria": "대화 워크벤치", "aria": "대화 워크벤치",
"panes": "창", "panes": "창",
"tabAria": ": {{title}}", "tabAria": "그룹: {{title}}",
"panesInTab": "{{title}}의 창", "panesInTab": "{{title}}의 창",
"collapseTabGroup": "{{title}}의 창 접기", "collapseTabGroup": "{{title}}의 창 접기",
"expandTabGroup": "{{title}}의 창 펼치기", "expandTabGroup": "{{title}}의 창 펼치기",
"dropPane": "{{pane}}을(를) {{tab}}으로 이동", "dropPane": "{{pane}}을(를) {{tab}}으로 이동",
"createGroup": "그룹 만들기", "createGroup": "그룹 만들기",
"moveTo": "이동", "moveTo": "이동",
"renameTabTitle": " 이름 바꾸기", "renameGroupTitle": "그룹 이름 바꾸기",
"renameTabDescription": "창을 정리할 수 있도록 이 탭에 이름을 지정하세요.", "renameGroupDescription": "이 그룹에 이름을 지정하세요.",
"renameTabPlaceholder": " 이름", "renameGroupPlaceholder": "그룹 이름",
"dissolveTab": "그룹 해제", "dissolveTab": "그룹 해제",
"layout": "창 레이아웃", "layout": "창 레이아웃",
"addPane": "창 추가", "addPane": "창 추가",
+4 -4
View File
@@ -1451,16 +1451,16 @@
"workbench": { "workbench": {
"aria": "Área de conversas", "aria": "Área de conversas",
"panes": "Painéis", "panes": "Painéis",
"tabAria": "Aba: {{title}}", "tabAria": "Grupo: {{title}}",
"panesInTab": "Painéis em {{title}}", "panesInTab": "Painéis em {{title}}",
"collapseTabGroup": "Recolher os painéis em {{title}}", "collapseTabGroup": "Recolher os painéis em {{title}}",
"expandTabGroup": "Expandir os painéis em {{title}}", "expandTabGroup": "Expandir os painéis em {{title}}",
"dropPane": "Mover {{pane}} para {{tab}}", "dropPane": "Mover {{pane}} para {{tab}}",
"createGroup": "Criar grupo", "createGroup": "Criar grupo",
"moveTo": "Mover para", "moveTo": "Mover para",
"renameTabTitle": "Renomear aba", "renameGroupTitle": "Renomear grupo",
"renameTabDescription": "Dê um nome a esta aba para organizar seus painéis.", "renameGroupDescription": "Dê um nome a este grupo.",
"renameTabPlaceholder": "Nome da aba", "renameGroupPlaceholder": "Nome do grupo",
"dissolveTab": "Desfazer grupo", "dissolveTab": "Desfazer grupo",
"layout": "Layout de painéis", "layout": "Layout de painéis",
"addPane": "Adicionar painel", "addPane": "Adicionar painel",
+4 -4
View File
@@ -1437,16 +1437,16 @@
"workbench": { "workbench": {
"aria": "Không gian hội thoại", "aria": "Không gian hội thoại",
"panes": "Khung", "panes": "Khung",
"tabAria": "Thẻ: {{title}}", "tabAria": "Nhóm: {{title}}",
"panesInTab": "Các khung trong {{title}}", "panesInTab": "Các khung trong {{title}}",
"collapseTabGroup": "Thu gọn các khung trong {{title}}", "collapseTabGroup": "Thu gọn các khung trong {{title}}",
"expandTabGroup": "Mở rộng các khung trong {{title}}", "expandTabGroup": "Mở rộng các khung trong {{title}}",
"dropPane": "Di chuyển {{pane}} vào {{tab}}", "dropPane": "Di chuyển {{pane}} vào {{tab}}",
"createGroup": "Tạo nhóm", "createGroup": "Tạo nhóm",
"moveTo": "Di chuyển đến", "moveTo": "Di chuyển đến",
"renameTabTitle": "Đổi tên thẻ", "renameGroupTitle": "Đổi tên nhóm",
"renameTabDescription": "Đặt tên cho thẻ này để sắp xếp các khung.", "renameGroupDescription": "Đặt tên cho nhóm này.",
"renameTabPlaceholder": "Tên thẻ", "renameGroupPlaceholder": "Tên nhóm",
"dissolveTab": "Giải tán nhóm", "dissolveTab": "Giải tán nhóm",
"layout": "Bố cục khung", "layout": "Bố cục khung",
"addPane": "Thêm khung", "addPane": "Thêm khung",
+4 -4
View File
@@ -1451,16 +1451,16 @@
"workbench": { "workbench": {
"aria": "会话工作台", "aria": "会话工作台",
"panes": "窗格", "panes": "窗格",
"tabAria": "标签页{{title}}", "tabAria": "分组{{title}}",
"panesInTab": "{{title}} 中的窗格", "panesInTab": "{{title}} 中的窗格",
"collapseTabGroup": "折叠 {{title}} 中的窗格", "collapseTabGroup": "折叠 {{title}} 中的窗格",
"expandTabGroup": "展开 {{title}} 中的窗格", "expandTabGroup": "展开 {{title}} 中的窗格",
"dropPane": "将 {{pane}} 移入 {{tab}}", "dropPane": "将 {{pane}} 移入 {{tab}}",
"createGroup": "创建分组", "createGroup": "创建分组",
"moveTo": "移动到", "moveTo": "移动到",
"renameTabTitle": "重命名标签页", "renameGroupTitle": "重命名分组",
"renameTabDescription": "为这个标签页命名,以便组织其中的窗格。", "renameGroupDescription": "为这个分组命名。",
"renameTabPlaceholder": "标签页名称", "renameGroupPlaceholder": "分组名称",
"dissolveTab": "解散分组", "dissolveTab": "解散分组",
"layout": "窗格布局", "layout": "窗格布局",
"addPane": "添加窗格", "addPane": "添加窗格",
+4 -4
View File
@@ -1437,16 +1437,16 @@
"workbench": { "workbench": {
"aria": "對話工作台", "aria": "對話工作台",
"panes": "窗格", "panes": "窗格",
"tabAria": "標籤頁{{title}}", "tabAria": "群組{{title}}",
"panesInTab": "{{title}} 中的窗格", "panesInTab": "{{title}} 中的窗格",
"collapseTabGroup": "收合 {{title}} 中的窗格", "collapseTabGroup": "收合 {{title}} 中的窗格",
"expandTabGroup": "展開 {{title}} 中的窗格", "expandTabGroup": "展開 {{title}} 中的窗格",
"dropPane": "將 {{pane}} 移入 {{tab}}", "dropPane": "將 {{pane}} 移入 {{tab}}",
"createGroup": "建立群組", "createGroup": "建立群組",
"moveTo": "移動到", "moveTo": "移動到",
"renameTabTitle": "重新命名分頁", "renameGroupTitle": "重新命名群組",
"renameTabDescription": "為這個分頁命名,以便整理其中的窗格。", "renameGroupDescription": "為這個群組命名。",
"renameTabPlaceholder": "分頁名稱", "renameGroupPlaceholder": "群組名稱",
"dissolveTab": "解散群組", "dissolveTab": "解散群組",
"layout": "窗格佈局", "layout": "窗格佈局",
"addPane": "新增窗格", "addPane": "新增窗格",
+1
View File
@@ -363,6 +363,7 @@ export interface WorkspacesPayload {
controls: { controls: {
can_change_project: boolean; can_change_project: boolean;
can_use_full_access: boolean; can_use_full_access: boolean;
can_pick_folder?: boolean;
}; };
} }
+36 -24
View File
@@ -2040,18 +2040,20 @@ describe("App layout", () => {
act(() => { act(() => {
for (const handler of runStatusHandlers) handler("chat-a", 12_345); for (const handler of runStatusHandlers) handler("chat-a", 12_345);
}); });
expect(within(sidebar).getByTitle("Agent running")).toBeInTheDocument(); expect(within(sidebar).getByRole("img", { name: "Agent running" })).toBeInTheDocument();
act(() => { act(() => {
for (const handler of runStatusHandlers) handler("chat-a", null); for (const handler of runStatusHandlers) handler("chat-a", null);
}); });
expect(within(sidebar).queryByTitle("Agent running")).not.toBeInTheDocument(); expect(within(sidebar).queryByRole("img", { name: "Agent running" }))
expect(within(sidebar).getByTitle("New activity")).toBeInTheDocument(); .not.toBeInTheDocument();
expect(within(sidebar).getByRole("img", { name: "New activity" })).toBeInTheDocument();
await act(async () => { await act(async () => {
fireEvent.click(within(sidebar).getByRole("button", { name: /^Working chat$/ })); fireEvent.click(within(sidebar).getByRole("button", { name: /^Working chat$/ }));
}); });
expect(within(sidebar).queryByTitle("New activity")).not.toBeInTheDocument(); expect(within(sidebar).queryByRole("img", { name: "New activity" }))
.not.toBeInTheDocument();
}); });
it("does not show an updated dot later when the active session finishes", async () => { it("does not show an updated dot later when the active session finishes", async () => {
@@ -2092,18 +2094,21 @@ describe("App layout", () => {
act(() => { act(() => {
for (const handler of runStatusHandlers) handler("chat-a", 12_345); for (const handler of runStatusHandlers) handler("chat-a", 12_345);
}); });
expect(within(sidebar).getByTitle("Agent running")).toBeInTheDocument(); expect(within(sidebar).getByRole("img", { name: "Agent running" })).toBeInTheDocument();
act(() => { act(() => {
for (const handler of runStatusHandlers) handler("chat-a", null); for (const handler of runStatusHandlers) handler("chat-a", null);
}); });
expect(within(sidebar).queryByTitle("Agent running")).not.toBeInTheDocument(); expect(within(sidebar).queryByRole("img", { name: "Agent running" }))
expect(within(sidebar).queryByTitle("New activity")).not.toBeInTheDocument(); .not.toBeInTheDocument();
expect(within(sidebar).queryByRole("img", { name: "New activity" }))
.not.toBeInTheDocument();
await act(async () => { await act(async () => {
fireEvent.click(within(sidebar).getByRole("button", { name: /^Other chat$/ })); fireEvent.click(within(sidebar).getByRole("button", { name: /^Other chat$/ }));
}); });
expect(within(sidebar).queryByTitle("New activity")).not.toBeInTheDocument(); expect(within(sidebar).queryByRole("img", { name: "New activity" }))
.not.toBeInTheDocument();
}); });
it("marks inactive sessions when a thread update arrives", async () => { it("marks inactive sessions when a thread update arrives", async () => {
@@ -2138,13 +2143,14 @@ describe("App layout", () => {
for (const handler of sessionUpdateHandlers) handler("chat-b", "thread"); for (const handler of sessionUpdateHandlers) handler("chat-b", "thread");
}); });
expect(within(sidebar).getByTitle("New activity")).toBeInTheDocument(); expect(within(sidebar).getByRole("img", { name: "New activity" })).toBeInTheDocument();
await act(async () => { await act(async () => {
fireEvent.click(within(sidebar).getByRole("button", { name: /^Scheduled update target$/ })); fireEvent.click(within(sidebar).getByRole("button", { name: /^Scheduled update target$/ }));
}); });
expect(within(sidebar).queryByTitle("New activity")).not.toBeInTheDocument(); expect(within(sidebar).queryByRole("img", { name: "New activity" }))
.not.toBeInTheDocument();
}); });
it("restores sidebar run indicators after a page reload", async () => { it("restores sidebar run indicators after a page reload", async () => {
@@ -2177,9 +2183,9 @@ describe("App layout", () => {
await waitFor(() => expect(connectSpy).toHaveBeenCalled()); await waitFor(() => expect(connectSpy).toHaveBeenCalled());
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" }); const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
await waitFor(() => await waitFor(() =>
expect(within(sidebar).getByTitle("Agent running")).toBeInTheDocument(), expect(within(sidebar).getByRole("img", { name: "Agent running" })).toBeInTheDocument(),
); );
expect(within(sidebar).getByTitle("New activity")).toBeInTheDocument(); expect(within(sidebar).getByRole("img", { name: "New activity" })).toBeInTheDocument();
expect(attachSpy).toHaveBeenCalledWith("chat-a"); expect(attachSpy).toHaveBeenCalledWith("chat-a");
}); });
@@ -3093,19 +3099,16 @@ describe("App layout", () => {
await waitFor(() => expect(connectSpy).toHaveBeenCalled()); await waitFor(() => expect(connectSpy).toHaveBeenCalled());
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" }); const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
const alphaTab = await within(sidebar).findByRole("button", { name: "Tab: Alpha tab" }); const alphaTab = await within(sidebar).findByRole("button", { name: "Group: Alpha tab" });
const betaTab = within(sidebar).getByRole("button", { name: "Beta tab" }); const betaTab = within(sidebar).getByRole("button", { name: "Beta tab" });
expect(alphaTab.compareDocumentPosition(betaTab) & Node.DOCUMENT_POSITION_FOLLOWING) expect(alphaTab.compareDocumentPosition(betaTab) & Node.DOCUMENT_POSITION_FOLLOWING)
.toBeTruthy(); .toBeTruthy();
const alphaGroup = alphaTab.closest("[data-sidebar-tab-group]") as HTMLElement; const alphaGroup = alphaTab.closest("[data-sidebar-tab-group]") as HTMLElement;
const paneTitles = within(alphaGroup) const alphaChild = within(alphaGroup).getByRole("button", { name: "Alpha child" });
.getAllByRole("button") const alphaRoot = within(alphaGroup).getByRole("button", { name: "Alpha tab" });
.filter((button) => ( expect(alphaChild.compareDocumentPosition(alphaRoot) & Node.DOCUMENT_POSITION_FOLLOWING)
button.closest("[data-sidebar-pane]") && button.hasAttribute("title") .toBeTruthy();
))
.map((button) => button.getAttribute("title"));
expect(paneTitles).toEqual(["Alpha child", "Alpha tab"]);
}); });
it("uses one active pane without workbench editing controls on mobile", async () => { it("uses one active pane without workbench editing controls on mobile", async () => {
@@ -3215,7 +3218,7 @@ describe("App layout", () => {
statusHandlers.forEach((handler) => handler("open")); statusHandlers.forEach((handler) => handler("open"));
}); });
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" }); const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
expect(within(sidebar).queryByRole("button", { name: "Tab: Solo pane" })) expect(within(sidebar).queryByRole("button", { name: "Group: Solo pane" }))
.not.toBeInTheDocument(); .not.toBeInTheDocument();
setSidebarStateSpy.mockClear(); setSidebarStateSpy.mockClear();
@@ -3225,14 +3228,14 @@ describe("App layout", () => {
fireEvent.click(await screen.findByRole("menuitem", { name: "Create group" })); fireEvent.click(await screen.findByRole("menuitem", { name: "Create group" }));
const tabButton = await within(sidebar).findByRole("button", { const tabButton = await within(sidebar).findByRole("button", {
name: "Tab: Solo pane", name: "Group: Solo pane",
}); });
const tabGroup = tabButton.closest("[data-sidebar-tab-group]") as HTMLElement; const tabGroup = tabButton.closest("[data-sidebar-tab-group]") as HTMLElement;
expect(within(tabGroup).getByRole("list", { name: "Panes in Solo pane" })) expect(within(tabGroup).getByRole("list", { name: "Panes in Solo pane" }))
.toBeInTheDocument(); .toBeInTheDocument();
expect(within(tabGroup).getAllByRole("button", { name: "Solo pane" })) expect(within(tabGroup).getAllByRole("button", { name: "Solo pane" }))
.toHaveLength(1); .toHaveLength(1);
expect(within(sidebar).queryByRole("button", { name: "Tab: Other pane" })) expect(within(sidebar).queryByRole("button", { name: "Group: Other pane" }))
.not.toBeInTheDocument(); .not.toBeInTheDocument();
await waitFor(() => expect(setSidebarStateSpy).toHaveBeenCalledWith( await waitFor(() => expect(setSidebarStateSpy).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
@@ -3243,6 +3246,15 @@ 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 () => { it("restores a created pane group from gateway state after remount", async () => {
@@ -3300,7 +3312,7 @@ describe("App layout", () => {
render(<App />); render(<App />);
await waitFor(() => expect(connectSpy).toHaveBeenCalled()); await waitFor(() => expect(connectSpy).toHaveBeenCalled());
const secondSidebar = screen.getByRole("navigation", { name: "Sidebar navigation" }); const secondSidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
expect(await within(secondSidebar).findByRole("button", { name: "Tab: Solo pane" })) expect(await within(secondSidebar).findByRole("button", { name: "Group: Solo pane" }))
.toBeInTheDocument(); .toBeInTheDocument();
}); });
+382 -39
View File
@@ -18,13 +18,54 @@ 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", () => { describe("ChatList", () => {
const originalAnimate = HTMLElement.prototype.animate;
const originalGetBoundingClientRect = HTMLElement.prototype.getBoundingClientRect;
afterEach(() => { afterEach(() => {
HTMLElement.prototype.animate = originalAnimate;
HTMLElement.prototype.getBoundingClientRect = originalGetBoundingClientRect;
localStorage.removeItem("nanobot-webui.collapsed-pane-groups.v1"); localStorage.removeItem("nanobot-webui.collapsed-pane-groups.v1");
vi.restoreAllMocks(); vi.restoreAllMocks();
vi.unstubAllGlobals(); 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", () => { it("keeps tab grouping out of drag protocols while exposing inactive panes as mention sources", () => {
render( render(
<ChatList <ChatList
@@ -49,7 +90,9 @@ describe("ChatList", () => {
/>, />,
); );
expect(screen.getByRole("button", { name: "Tab: Root topic" })) expect(screen.getByRole("button", { name: "Group: Root topic" }))
.toHaveAttribute("draggable", "false");
expect(screen.getByRole("button", { name: "Root topic" }))
.toHaveAttribute("draggable", "false"); .toHaveAttribute("draggable", "false");
const pane = screen.getByRole("button", { name: "Research pane" }); const pane = screen.getByRole("button", { name: "Research pane" });
expect(pane).toHaveAttribute("draggable", "true"); expect(pane).toHaveAttribute("draggable", "true");
@@ -71,6 +114,46 @@ describe("ChatList", () => {
expect(document.querySelector("[data-pane-snap-slot]")).not.toBeInTheDocument(); 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 () => { it("creates a visible tab in place and only moves panes into visible tabs", async () => {
const onAttachPane = vi.fn(); const onAttachPane = vi.fn();
const onCreateTab = vi.fn(); const onCreateTab = vi.fn();
@@ -128,7 +211,7 @@ describe("ChatList", () => {
/>, />,
); );
expect(screen.queryByRole("button", { name: "Tab: Solo pane" })) expect(screen.queryByRole("button", { name: "Group: Solo pane" }))
.not.toBeInTheDocument(); .not.toBeInTheDocument();
expect(screen.getAllByText("Solo pane")).toHaveLength(1); expect(screen.getAllByText("Solo pane")).toHaveLength(1);
expect(screen.queryByRole("list", { name: "Panes in Solo pane" })) expect(screen.queryByRole("list", { name: "Panes in Solo pane" }))
@@ -157,6 +240,159 @@ describe("ChatList", () => {
expect(onAttachPane).toHaveBeenCalledWith("websocket:solo", "tab:fine"); 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 () => { it("shows every tab's pane membership in a sidebar tab group", async () => {
const onSelect = vi.fn(); const onSelect = vi.fn();
const onSelectPane = vi.fn(); const onSelectPane = vi.fn();
@@ -212,7 +448,7 @@ describe("ChatList", () => {
expect(child.closest("[data-sidebar-pane]")) expect(child.closest("[data-sidebar-pane]"))
.toHaveAttribute("data-sidebar-pane", "websocket:child"); .toHaveAttribute("data-sidebar-pane", "websocket:child");
expect(child).toHaveAttribute("aria-current", "true"); expect(child).toHaveAttribute("aria-current", "true");
const targetTabRow = screen.getByRole("button", { name: "Tab: Target tab" }) const targetTabRow = screen.getByRole("button", { name: "Group: Target tab" })
.closest("li")!; .closest("li")!;
const targetChild = within(targetTabRow).getByRole("button", { const targetChild = within(targetTabRow).getByRole("button", {
name: "Target research", name: "Target research",
@@ -233,7 +469,7 @@ describe("ChatList", () => {
expect(onSelect).not.toHaveBeenCalled(); expect(onSelect).not.toHaveBeenCalled();
onSelectPane.mockClear(); onSelectPane.mockClear();
const rootTab = screen.getByRole("button", { name: "Tab: Root topic" }); const rootTab = screen.getByRole("button", { name: "Group: Root topic" });
fireEvent.click(rootTab); fireEvent.click(rootTab);
expect(onSelectPane).not.toHaveBeenCalled(); expect(onSelectPane).not.toHaveBeenCalled();
expect(onSelect).not.toHaveBeenCalled(); expect(onSelect).not.toHaveBeenCalled();
@@ -278,8 +514,8 @@ describe("ChatList", () => {
.toBeInTheDocument(); .toBeInTheDocument();
fireEvent.keyDown(document, { key: "Escape" }); fireEvent.keyDown(document, { key: "Escape" });
expect(child).toHaveAttribute("draggable", "false"); expect(child).toHaveAttribute("draggable", "true");
expect(screen.getByRole("button", { name: "Tab: Target tab" })) expect(screen.getByRole("button", { name: "Group: Target tab" }))
.toHaveAttribute("draggable", "false"); .toHaveAttribute("draggable", "false");
}); });
@@ -316,7 +552,7 @@ describe("ChatList", () => {
/>, />,
); );
const tabButton = screen.getByRole("button", { name: "Tab: Root topic" }); const tabButton = screen.getByRole("button", { name: "Group: Root topic" });
const tabGroup = tabButton.closest("[data-sidebar-tab-group]")!; const tabGroup = tabButton.closest("[data-sidebar-tab-group]")!;
const tabHeader = tabButton.closest("[data-workbench-tab]")!; const tabHeader = tabButton.closest("[data-workbench-tab]")!;
const tabSurface = tabButton.closest("[data-workbench-tab-surface]")!; const tabSurface = tabButton.closest("[data-workbench-tab-surface]")!;
@@ -324,21 +560,25 @@ describe("ChatList", () => {
expect(tabHeader).not.toHaveAttribute("data-chat-row"); expect(tabHeader).not.toHaveAttribute("data-chat-row");
expect(tabHeader).not.toHaveAttribute("data-sidebar-pane"); expect(tabHeader).not.toHaveAttribute("data-sidebar-pane");
expect(tabButton).not.toHaveAttribute("aria-current"); expect(tabButton).not.toHaveAttribute("aria-current");
expect(tabButton.querySelector("svg")).not.toBeInTheDocument(); expect(tabButton.querySelector(".lucide-folder-tree")).toBeInTheDocument();
const paneList = within(tabGroup).getByRole("list", { name: "Panes in Root topic" }); const paneList = within(tabGroup).getByRole("list", { name: "Panes in Root topic" });
expect(tabSurface).toContainElement(paneList); expect(tabSurface).toContainElement(paneList);
const activePane = within(tabGroup).getByRole("button", { name: "Research pane" }); const activePane = within(tabGroup).getByRole("button", { name: "Research pane" });
expect(activePane).toHaveAttribute("aria-current", "true"); expect(activePane).toHaveAttribute("aria-current", "true");
expect(activePane.closest("[data-sidebar-pane]")).toHaveClass( expect(activePane.closest("[data-sidebar-pane]")).toHaveClass("rounded-control");
"bg-sidebar-selected", expect(activePane.querySelector("[data-sidebar-selection-track]"))
"rounded-[0.65rem]", .toHaveAttribute("data-active", "true");
);
expect(screen.getByRole("button", { expect(screen.getByRole("button", {
name: "Research pane pane actions", name: "Research pane pane actions",
})).toHaveClass("opacity-0"); })).toHaveClass("opacity-0");
expect(within(tabGroup).getByRole("button", { name: "Root topic" })) expect(within(tabGroup).getByRole("button", { name: "Root topic" }))
.not.toHaveAttribute("aria-current"); .not.toHaveAttribute("aria-current");
expect(tabGroup).not.toHaveTextContent("2/4"); expect(tabGroup).not.toHaveTextContent("2/4");
expect(paneList).toHaveClass(
"rounded-es-[14px]",
"border-s-2",
"border-sidebar-foreground/25",
);
const collapse = within(tabGroup).getByRole("button", { const collapse = within(tabGroup).getByRole("button", {
name: "Collapse panes in Root topic", name: "Collapse panes in Root topic",
@@ -352,9 +592,8 @@ describe("ChatList", () => {
expect(within(tabGroup).getByRole("button", { expect(within(tabGroup).getByRole("button", {
name: "Expand panes in Root topic", name: "Expand panes in Root topic",
})).toHaveAttribute("aria-expanded", "false"); })).toHaveAttribute("aria-expanded", "false");
expect(within(tabGroup).getByRole("button", { name: "Tab: Root topic" })) expect(within(tabGroup).getByRole("button", { name: "Group: Root topic" }))
.not.toHaveAttribute("aria-current"); .not.toHaveAttribute("aria-current");
expect(tabSurface).toHaveClass("bg-sidebar-foreground/[0.045]");
fireEvent.click(within(tabGroup).getByRole("button", { fireEvent.click(within(tabGroup).getByRole("button", {
name: "Expand panes in Root topic", name: "Expand panes in Root topic",
@@ -400,8 +639,8 @@ describe("ChatList", () => {
fireEvent.click(await screen.findByRole("menuitem", { name: "Select" })); fireEvent.click(await screen.findByRole("menuitem", { name: "Select" }));
expect(screen.getByText("1 selected")).toBeInTheDocument(); expect(screen.getByText("1 selected")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Tab: Root topic" })); fireEvent.click(screen.getByRole("button", { name: "Group: Root topic" }));
expect(screen.getByRole("button", { name: "Tab: Root topic" })) expect(screen.getByRole("button", { name: "Group: Root topic" }))
.toHaveAttribute("aria-pressed", "true"); .toHaveAttribute("aria-pressed", "true");
expect(screen.getByRole("button", { name: "Root topic" })) expect(screen.getByRole("button", { name: "Root topic" }))
.toHaveAttribute("aria-pressed", "true"); .toHaveAttribute("aria-pressed", "true");
@@ -517,10 +756,13 @@ describe("ChatList", () => {
); );
const pinnedSection = screen.getByRole("region", { name: "Pinned" }); const pinnedSection = screen.getByRole("region", { name: "Pinned" });
expect(within(pinnedSection).getByTitle("Pinned")).toBeInTheDocument();
expect( expect(
within(screen.getByRole("region", { name: "Earlier" })).queryByTitle("Pinned"), within(pinnedSection)
).not.toBeInTheDocument(); .getByText("Pinned chat")
.closest("[data-chat-row]")
?.querySelector("[data-sidebar-pinned-indicator]"),
).toBeInTheDocument();
expect(document.querySelectorAll("[data-sidebar-pinned-indicator]")).toHaveLength(1);
}); });
it("groups WebUI chats by workspace project while preserving in-project sorting and activity", () => { it("groups WebUI chats by workspace project while preserving in-project sorting and activity", () => {
@@ -574,8 +816,16 @@ describe("ChatList", () => {
const nanobotSection = screen.getByRole("region", { name: "nanobot" }); const nanobotSection = screen.getByRole("region", { name: "nanobot" });
const nanobotText = nanobotSection.textContent ?? ""; const nanobotText = nanobotSection.textContent ?? "";
const projectSurface = nanobotSection.querySelector(
"[data-sidebar-project-surface]",
);
expect(screen.getByRole("region", { name: "nanobot-bench" })).toBeInTheDocument(); 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("Alpha task")).toBeInTheDocument();
expect(within(nanobotSection).getByText("Zeta task")).toBeInTheDocument(); expect(within(nanobotSection).getByText("Zeta task")).toBeInTheDocument();
expect(nanobotText.indexOf("Alpha task")).toBeLessThan(nanobotText.indexOf("Zeta task")); expect(nanobotText.indexOf("Alpha task")).toBeLessThan(nanobotText.indexOf("Zeta task"));
@@ -630,7 +880,7 @@ describe("ChatList", () => {
expect(within(chatsSection).queryByText("Project chat")).not.toBeInTheDocument(); expect(within(chatsSection).queryByText("Project chat")).not.toBeInTheDocument();
}); });
it("switches row-owned tab highlights without a moving selection surface", () => { it("grows and retracts the row-owned selection track", () => {
const props = { const props = {
sessions: [ sessions: [
session({ chatId: "active", title: "Active topic" }), session({ chatId: "active", title: "Active topic" }),
@@ -650,12 +900,10 @@ describe("ChatList", () => {
/>, />,
); );
const activeButton = screen.getByTitle("Active topic"); const activeButton = screen.getByRole("button", { name: "Active topic" });
expect(activeButton).toHaveAttribute("aria-current", "page"); expect(activeButton).toHaveAttribute("aria-current", "page");
expect(activeButton.closest("[data-sidebar-tab]")).toHaveClass( expect(activeButton.querySelector("[data-sidebar-selection-track]"))
"bg-sidebar-selected", .toHaveClass("origin-left", "scale-x-100", "transition-transform", "bg-current");
);
expect(screen.queryByTestId("sessions-selection-highlight")).not.toBeInTheDocument();
rerender( rerender(
<ChatList <ChatList
@@ -664,11 +912,16 @@ describe("ChatList", () => {
/>, />,
); );
expect(screen.getByTitle("Active topic")).not.toHaveAttribute("aria-current"); expect(screen.getByRole("button", { name: "Active topic" }))
expect(screen.getByTitle("Inactive topic")).toHaveAttribute("aria-current", "page"); .not.toHaveAttribute("aria-current");
expect(screen.getByTitle("Inactive topic").closest("[data-sidebar-tab]")).toHaveClass( expect(screen.getByRole("button", { name: "Inactive topic" }))
"bg-sidebar-selected", .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");
}); });
it("restores collapsed tabs from the local UI preference", () => { it("restores collapsed tabs from the local UI preference", () => {
@@ -694,12 +947,12 @@ describe("ChatList", () => {
}; };
const firstRender = render(<ChatList {...props} />); const firstRender = render(<ChatList {...props} />);
fireEvent.click(screen.getByRole("button", { name: "Tab: Root topic" })); fireEvent.click(screen.getByRole("button", { name: "Group: Root topic" }));
expect(screen.queryByRole("button", { name: "Research pane" })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Research pane" })).not.toBeInTheDocument();
firstRender.unmount(); firstRender.unmount();
render(<ChatList {...props} />); render(<ChatList {...props} />);
expect(screen.getByRole("button", { name: "Tab: Root topic" })) expect(screen.getByRole("button", { name: "Group: Root topic" }))
.toHaveAttribute("aria-expanded", "false"); .toHaveAttribute("aria-expanded", "false");
expect(screen.queryByRole("button", { name: "Research pane" })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Research pane" })).not.toBeInTheDocument();
}); });
@@ -743,21 +996,111 @@ describe("ChatList", () => {
expect(onToggleGroup).toHaveBeenCalledWith("project:/Users/me/nanobot"); expect(onToggleGroup).toHaveBeenCalledWith("project:/Users/me/nanobot");
expect(within(projectSection).queryByText("Alpha task")).not.toBeInTheDocument(); expect(within(projectSection).queryByText("Alpha task")).not.toBeInTheDocument();
fireEvent.click( const projectButton = within(projectSection).getByRole("button", { name: "Photos" });
within(projectSection).getByRole("button", { name: "Start a new topic in Photos" }), fireEvent.contextMenu(projectButton);
); fireEvent.click(await screen.findByRole("menuitem", { name: "New topic" }));
expect(onNewChatInProject).toHaveBeenCalledWith("/Users/me/nanobot", "Photos"); expect(onNewChatInProject).toHaveBeenCalledWith("/Users/me/nanobot", "Photos");
expect(onToggleGroup).toHaveBeenCalledTimes(1); expect(onToggleGroup).toHaveBeenCalledTimes(1);
fireEvent.pointerDown( fireEvent.contextMenu(projectButton);
within(projectSection).getByLabelText("Topic actions for Photos"),
{ button: 0 },
);
fireEvent.click(await screen.findByRole("menuitem", { name: "Rename" })); fireEvent.click(await screen.findByRole("menuitem", { name: "Rename" }));
expect(onRequestRenameProject).toHaveBeenCalledWith("/Users/me/nanobot", "Photos"); 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", () => { it("hides the updated dot for the active chat", () => {
const sessions = [ const sessions = [
session({ session({
+1 -1
View File
@@ -54,7 +54,7 @@ describe("CodeBlock", () => {
expect(screen.getByTestId("plain-code-fallback")).toHaveClass("py-4", "pl-5", "pr-14"); expect(screen.getByTestId("plain-code-fallback")).toHaveClass("py-4", "pl-5", "pr-14");
const container = screen.getByTestId("plain-code-fallback").closest(".not-prose"); const container = screen.getByTestId("plain-code-fallback").closest(".not-prose");
expect(container).toHaveClass("relative", "rounded-[18px]", "bg-secondary/70"); expect(container).toHaveClass("relative", "rounded-floating", "bg-secondary/70");
expect(container).not.toHaveClass("border"); expect(container).not.toHaveClass("border");
expect(container).toHaveAttribute("data-language", "ts"); expect(container).toHaveAttribute("data-language", "ts");
+28
View File
@@ -269,6 +269,22 @@ const LOCALIZED_NEW_SURFACE_KEYS = [
"chat.groups.yesterday", "chat.groups.yesterday",
"chat.groups.earlier", "chat.groups.earlier",
"chat.groups.archived", "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.promptNavigator.railAria",
"thread.composer.mentions.cliTitle", "thread.composer.mentions.cliTitle",
"thread.composer.mentions.mcpTitle", "thread.composer.mentions.mcpTitle",
@@ -582,6 +598,18 @@ describe("webui i18n", () => {
expect(settings.skills.marketplaceTrendingTitle).toBe("各市场热门技能"); 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", () => { it("keeps Indonesian and Vietnamese settings free of copied Spanish help text", () => {
const spanish = flattenResource(resources.es.common); const spanish = flattenResource(resources.es.common);
+3 -3
View File
@@ -108,7 +108,7 @@ describe("MessageBubble", () => {
const pill = screen.getByText("hello"); const pill = screen.getByText("hello");
expect(row).toHaveClass("ml-auto", "flex"); expect(row).toHaveClass("ml-auto", "flex");
expect(pill).toHaveClass("ml-auto", "w-fit", "rounded-[18px]"); expect(pill).toHaveClass("ml-auto", "w-fit", "rounded-floating");
expect(screen.getByRole("button", { name: "Copy" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Copy" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Fork" })).not.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.getAttribute("style")).toContain("var(--inline-token-highlight)");
expect(command.className).not.toMatch(/(?:^|\s)(?:bg-|border|ring|rounded)/); expect(command.className).not.toMatch(/(?:^|\s)(?:bg-|border|ring|rounded)/);
expect(command.parentElement).toHaveTextContent("/model gpt-5"); expect(command.parentElement).toHaveTextContent("/model gpt-5");
expect(command.parentElement).toHaveClass("rounded-[18px]", "bg-secondary/70"); expect(command.parentElement).toHaveClass("rounded-floating", "bg-secondary/70");
}); });
it("keeps unknown and invalid slash commands as plain message text", () => { it("keeps unknown and invalid slash commands as plain message text", () => {
@@ -934,7 +934,7 @@ describe("MessageBubble", () => {
const { container } = render(<MessageBubble message={message} />); const { container } = render(<MessageBubble message={message} />);
const imageButton = screen.getByRole("button", { name: /view image/i }); const imageButton = screen.getByRole("button", { name: /view image/i });
expect(imageButton).toHaveClass("w-[min(100%,34rem)]", "rounded-[20px]"); expect(imageButton).toHaveClass("w-[min(100%,34rem)]", "rounded-panel");
expect(imageButton).toHaveClass( expect(imageButton).toHaveClass(
"border", "border",
"border-border/60", "border-border/60",
+45
View File
@@ -314,6 +314,51 @@ describe("PaneWorkbench", () => {
await waitFor(() => expect(animate).toHaveBeenCalledTimes(4)); 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", () => { it("renders only the active pane and hides workbench controls on mobile", () => {
vi.stubGlobal("matchMedia", vi.fn((query: string) => ({ vi.stubGlobal("matchMedia", vi.fn((query: string) => ({
matches: query.includes("max-width: 767px"), matches: query.includes("max-width: 767px"),
+1 -1
View File
@@ -165,7 +165,7 @@ describe("Settings models", () => {
expect(editor).toHaveClass( expect(editor).toHaveClass(
"slide-in-from-top-1", "slide-in-from-top-1",
"lg:max-w-6xl", "lg:max-w-6xl",
"rounded-[18px]", "rounded-floating",
); );
expect(within(editor).getByDisplayValue("Primary")).toBeInTheDocument(); expect(within(editor).getByDisplayValue("Primary")).toBeInTheDocument();
const deleteButton = within(editor).getByRole("button", { name: "Delete" }); const deleteButton = within(editor).getByRole("button", { name: "Delete" });
+51 -42
View File
@@ -554,7 +554,7 @@ describe("ThreadComposer", () => {
expect(input.className).toContain("min-h-[50px]"); expect(input.className).toContain("min-h-[50px]");
expect(input.className).toContain("text-[16px]"); expect(input.className).toContain("text-[16px]");
expect(input.parentElement?.parentElement?.className).toContain("max-w-[49.5rem]"); expect(input.parentElement?.parentElement?.className).toContain("max-w-[49.5rem]");
expect(input.parentElement?.parentElement?.className).toContain("rounded-[22px]"); expect(input.parentElement?.parentElement?.className).toContain("rounded-panel");
expect(input.parentElement?.parentElement?.className).not.toContain("shadow-"); expect(input.parentElement?.parentElement?.className).not.toContain("shadow-");
expect(screen.getByRole("button", { name: "Attach files" }).className).toContain("bg-card"); expect(screen.getByRole("button", { name: "Attach files" }).className).toContain("bg-card");
expect(screen.getByRole("button", { name: "Send message" }).className).toContain("bg-foreground"); expect(screen.getByRole("button", { name: "Send message" }).className).toContain("bg-foreground");
@@ -1260,6 +1260,45 @@ 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 () => { it("uses the web path menu when no native host picker is available", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const defaultScope = { const defaultScope = {
@@ -1287,54 +1326,21 @@ describe("ThreadComposer", () => {
expect(screen.getByLabelText("Paste path")).toBeInTheDocument(); expect(screen.getByLabelText("Paste path")).toBeInTheDocument();
}); });
it("shows turn run timer when runStartedAt is set", () => { it("closes the sustained goal through its existing drawer", () => {
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( const { container, rerender } = render(
<ThreadComposer <ThreadComposer
onSend={vi.fn()} onSend={vi.fn()}
placeholder="Type your message..." placeholder="Type your message..."
runStartedAt={null} goalState={{
active: true,
objective: "Ship the release",
ui_summary: "Preparing release",
}}
/>, />,
); );
const drawer = container.querySelector("[data-composer-status-drawer]"); const drawer = container.querySelector("[data-composer-status-drawer]");
expect(drawer).not.toBeNull(); 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).toHaveAttribute("data-state", "open");
expect(drawer).not.toHaveAttribute("aria-hidden"); expect(drawer).not.toHaveAttribute("aria-hidden");
const status = screen.getByRole("status"); const status = screen.getByRole("status");
@@ -1344,7 +1350,7 @@ describe("ThreadComposer", () => {
<ThreadComposer <ThreadComposer
onSend={vi.fn()} onSend={vi.fn()}
placeholder="Type your message..." placeholder="Type your message..."
runStartedAt={null} goalState={{ active: false }}
/>, />,
); );
@@ -1353,6 +1359,9 @@ describe("ThreadComposer", () => {
expect(drawer).toHaveAttribute("aria-hidden", "true"); expect(drawer).toHaveAttribute("aria-hidden", "true");
expect(screen.queryByRole("status")).not.toBeInTheDocument(); expect(screen.queryByRole("status")).not.toBeInTheDocument();
expect(drawer?.querySelector('[role="status"]')).toBe(status); 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 () => { it("opens an upward anchored goal panel with markdown content when expand is clicked", async () => {
@@ -1798,7 +1807,7 @@ describe("ThreadComposer", () => {
}); });
}); });
it("rejects session drops that are unavailable to the composer", () => { it("rejects self-session drops that are unavailable to the composer", () => {
render( render(
<ThreadComposer <ThreadComposer
onSend={vi.fn()} onSend={vi.fn()}
+48
View File
@@ -16,6 +16,54 @@ afterEach(() => {
}); });
describe("ThreadMessages", () => { 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", () => { it("does not move a mounted tail answer into offscreen rendering on the next turn", () => {
const completed: UIMessage[] = [ const completed: UIMessage[] = [
{ id: "u1", role: "user", content: "question", createdAt: 1 }, { id: "u1", role: "user", content: "question", createdAt: 1 },
+25
View File
@@ -120,6 +120,31 @@ 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", () => { it("pins repeated output growth on each authoritative geometry frame", () => {
const { const {
camera, camera,
+31 -4
View File
@@ -241,6 +241,10 @@ describe("ThreadViewport", () => {
takeUserControl.mockClear(); takeUserControl.mockClear();
fireEvent.keyDown(disclosure, { key: "Enter" }); fireEvent.keyDown(disclosure, { key: "Enter" });
expect(takeUserControl).toHaveBeenCalledTimes(1); expect(takeUserControl).toHaveBeenCalledTimes(1);
takeUserControl.mockClear();
fireEvent.keyDown(disclosure, { key: " " });
expect(takeUserControl).toHaveBeenCalledTimes(1);
}); });
it("top-aligns short threads in the message rendering area", () => { it("top-aligns short threads in the message rendering area", () => {
@@ -653,7 +657,7 @@ describe("ThreadViewport", () => {
} }
}); });
it("coalesces streamed layout growth into frame-driven camera targets", async () => { it("settles observed streamed layout growth before paint", async () => {
const resizeObserver = stubResizeObserver(); const resizeObserver = stubResizeObserver();
const followTo = vi.spyOn(ThreadCameraController.prototype, "followTo") const followTo = vi.spyOn(ThreadCameraController.prototype, "followTo")
.mockReturnValue("started"); .mockReturnValue("started");
@@ -740,10 +744,7 @@ describe("ThreadViewport", () => {
}); });
act(() => { act(() => {
contentObserver!.callback([], contentObserver as unknown as ResizeObserver); 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).toHaveBeenCalledTimes(1);
expect(followTo).toHaveBeenLastCalledWith(1448); expect(followTo).toHaveBeenLastCalledWith(1448);
followTo.mockClear(); followTo.mockClear();
@@ -1959,6 +1960,12 @@ describe("ThreadViewport", () => {
it("waits for the next conversation's transcript before restoring its bottom", async () => { it("waits for the next conversation's transcript before restoring its bottom", async () => {
const jumpTo = vi.spyOn(ThreadCameraController.prototype, "jumpTo"); const jumpTo = vi.spyOn(ThreadCameraController.prototype, "jumpTo");
const followTo = vi.spyOn(ThreadCameraController.prototype, "followTo"); 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[] = [ const oldMessages: UIMessage[] = [
{ {
id: "old-user", id: "old-user",
@@ -1998,6 +2005,7 @@ describe("ThreadViewport", () => {
scrollHeight: { configurable: true, value: 2400 }, scrollHeight: { configurable: true, value: 2400 },
clientHeight: { configurable: true, value: 600 }, clientHeight: { configurable: true, value: 600 },
scrollTop: { configurable: true, writable: true, value: 300 }, scrollTop: { configurable: true, writable: true, value: 300 },
animate: { configurable: true, value: animate },
}); });
jumpTo.mockClear(); jumpTo.mockClear();
@@ -2013,6 +2021,14 @@ describe("ThreadViewport", () => {
); );
expect(scroller.scrollTop).toBe(300); expect(scroller.scrollTop).toBe(300);
expect(jumpTo).not.toHaveBeenCalled(); 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", { Object.defineProperty(scroller, "scrollHeight", {
configurable: true, configurable: true,
@@ -2033,6 +2049,17 @@ describe("ThreadViewport", () => {
await flushAnimationFrame(); await flushAnimationFrame();
expect(jumpTo.mock.calls).toEqual([[2400]]); expect(jumpTo.mock.calls).toEqual([[2400]]);
expect(followTo).toHaveBeenCalledWith(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 () => { it("waits for hydrated messages before fulfilling open-chat bottom scroll", async () => {
+83
View File
@@ -0,0 +1,83 @@
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,6 +51,13 @@ export default {
lg: "var(--radius)", lg: "var(--radius)",
md: "calc(var(--radius) - 2px)", md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)", 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: { colors: {
background: "hsl(var(--background))", background: "hsl(var(--background))",