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