mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-14 16:19:17 +03:00
Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1437d1a75a | ||
|
|
b378319d4a | ||
|
|
4266ef2099 | ||
|
|
60993597de | ||
|
|
7c04af86f9 | ||
|
|
cdf225cb89 | ||
|
|
4d18dd2c30 | ||
|
|
221e8a4e4a | ||
|
|
057c5e849b | ||
|
|
e226242dfc | ||
|
|
e3d1819a2b | ||
|
|
9703656b25 | ||
|
|
26c9687b80 | ||
|
|
410e5e5121 | ||
|
|
335808e525 | ||
|
|
afad96af5f | ||
|
|
fd7eb8e046 |
+2
-1
@@ -133,7 +133,8 @@ or a result you must retain.
|
|||||||
|
|
||||||
Use the workspace picker before starting project-specific work. This gives the
|
Use the workspace picker before starting project-specific work. This gives the
|
||||||
agent the right project context for file paths, shell commands, and session
|
agent the right project context for file paths, shell commands, and session
|
||||||
metadata.
|
metadata. A locally hosted WebUI opens the operating system's folder chooser
|
||||||
|
when one is available; remote deployments keep the manual absolute path entry.
|
||||||
|
|
||||||
Selecting a project does not replace the configured agent workspace. The two
|
Selecting a project does not replace the configured agent workspace. The two
|
||||||
paths have different responsibilities:
|
paths have different responsibilities:
|
||||||
|
|||||||
+8
-11
@@ -769,27 +769,24 @@ class MemoryStore:
|
|||||||
return f"{prefix}\n\n{diff_body}"
|
return f"{prefix}\n\n{diff_body}"
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def prune_dream_sessions(sessions_dir: Path, *, keep: int = 10) -> None:
|
def prune_dream_sessions(sessions: SessionManager, *, keep: int = 10) -> None:
|
||||||
"""Remove the oldest Dream session files, keeping only the N most recent.
|
"""Remove the oldest Dream session files, keeping only the N most recent.
|
||||||
|
|
||||||
Only current base64url-encoded Dream session keys are considered.
|
Only current base64url-encoded Dream session keys are considered.
|
||||||
Non-dream session files are never touched.
|
Non-dream session files are never touched.
|
||||||
"""
|
"""
|
||||||
dream_files: list[Path] = []
|
with sessions.locked_session_files() as sessions_dir:
|
||||||
|
dream_files: list[tuple[Path, str]] = []
|
||||||
for path in sessions_dir.glob("*.jsonl"):
|
for path in sessions_dir.glob("*.jsonl"):
|
||||||
decoded_key = SessionManager.decode_storage_key(path.stem)
|
decoded_key = SessionManager.decode_storage_key(path.stem)
|
||||||
if decoded_key is not None and decoded_key.startswith("dream:"):
|
if decoded_key is not None and decoded_key.startswith("dream:"):
|
||||||
dream_files.append(path)
|
dream_files.append((path, decoded_key))
|
||||||
dream_files.sort(key=lambda p: p.stat().st_mtime)
|
dream_files.sort(key=lambda item: item[0].stat().st_mtime)
|
||||||
if len(dream_files) <= keep:
|
|
||||||
return
|
|
||||||
|
|
||||||
to_remove = dream_files[: len(dream_files) - keep]
|
for path, key in dream_files[: max(0, len(dream_files) - keep)]:
|
||||||
for path in to_remove:
|
if sessions.delete_session(key):
|
||||||
try:
|
|
||||||
path.unlink()
|
|
||||||
logger.debug("Pruned old dream session: {}", path.stem)
|
logger.debug("Pruned old dream session: {}", path.stem)
|
||||||
except OSError:
|
else:
|
||||||
logger.warning("Failed to prune dream session {}", path)
|
logger.warning("Failed to prune dream session {}", path)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export function FeishuAssistantsPanel({
|
|||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
footer: (
|
footer: (
|
||||||
<div className="mt-4 overflow-hidden rounded-[16px] border border-border/70 bg-background px-4 py-4">
|
<div className="mt-4 overflow-hidden rounded-floating border border-border/70 bg-background px-4 py-4">
|
||||||
<div className="text-[13px] font-semibold text-foreground">
|
<div className="text-[13px] font-semibold text-foreground">
|
||||||
{tx("custom.createAnother", "Create another assistant")}
|
{tx("custom.createAnother", "Create another assistant")}
|
||||||
</div>
|
</div>
|
||||||
@@ -144,7 +144,7 @@ function FeishuInstanceAction({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{error ? (
|
{error ? (
|
||||||
<div className="mt-3 rounded-[12px] border border-destructive/20 px-3 py-2 text-[12px] leading-5 text-destructive">
|
<div className="mt-3 rounded-control border border-destructive/20 px-3 py-2 text-[12px] leading-5 text-destructive">
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -3266,6 +3266,85 @@ async def _webui_mutate(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_workspace_folder_picker_is_local_authenticated_mutation(
|
||||||
|
bus: MagicMock,
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch,
|
||||||
|
) -> None:
|
||||||
|
selected = tmp_path / "project"
|
||||||
|
selected.mkdir()
|
||||||
|
pick_folder = AsyncMock(return_value=str(selected))
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"nanobot.webui.ws_http.native_folder_picker_available",
|
||||||
|
lambda: True,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr("nanobot.webui.ws_http.pick_native_folder", pick_folder)
|
||||||
|
channel = _ch(bus)
|
||||||
|
|
||||||
|
response = await _webui_mutate(channel, "workspace.pick_folder")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"path": str(selected)}
|
||||||
|
pick_folder.assert_awaited_once_with()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_workspace_folder_picker_rejects_direct_http(
|
||||||
|
bus: MagicMock,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
pick_folder = AsyncMock(return_value="/tmp")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"nanobot.webui.ws_http.native_folder_picker_available",
|
||||||
|
lambda: True,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr("nanobot.webui.ws_http.pick_native_folder", pick_folder)
|
||||||
|
channel = _ch(bus)
|
||||||
|
|
||||||
|
response = await channel.gateway.http.dispatch(
|
||||||
|
_LOCAL,
|
||||||
|
_FakeReq(
|
||||||
|
{"Host": "127.0.0.1:8765"},
|
||||||
|
path="/api/workspaces/pick-folder",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response is not None
|
||||||
|
assert response.status_code == 405
|
||||||
|
assert b"authenticated WebSocket" in response.body
|
||||||
|
pick_folder.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("connection", "host"),
|
||||||
|
[(_REMOTE, "127.0.0.1"), (_LOCAL, "0.0.0.0")],
|
||||||
|
)
|
||||||
|
async def test_workspace_folder_picker_rejects_nonlocal_surfaces(
|
||||||
|
bus: MagicMock,
|
||||||
|
monkeypatch,
|
||||||
|
connection: _FakeConn,
|
||||||
|
host: str,
|
||||||
|
) -> None:
|
||||||
|
pick_folder = AsyncMock(return_value="/tmp")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"nanobot.webui.ws_http.native_folder_picker_available",
|
||||||
|
lambda: True,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr("nanobot.webui.ws_http.pick_native_folder", pick_folder)
|
||||||
|
channel = _ch(bus, host=host, token="test-token" if host == "0.0.0.0" else "")
|
||||||
|
|
||||||
|
response = await _webui_mutate(
|
||||||
|
channel,
|
||||||
|
"workspace.pick_folder",
|
||||||
|
connection=connection,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
pick_folder.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
def test_local_browser_request_requires_loopback_host_and_forwarded_origin() -> None:
|
def test_local_browser_request_requires_loopback_host_and_forwarded_origin() -> None:
|
||||||
from nanobot.webui.http_utils import is_local_browser_request
|
from nanobot.webui.http_utils import is_local_browser_request
|
||||||
|
|
||||||
|
|||||||
@@ -197,7 +197,7 @@ export function WeixinPanel({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="min-h-full rounded-[20px] bg-settings-surface p-5">
|
<aside className="min-h-full rounded-panel bg-settings-surface p-5">
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<div className="flex min-w-0 items-start gap-3">
|
<div className="flex min-w-0 items-start gap-3">
|
||||||
<WeixinLogo showBrandLogos={showBrandLogos} />
|
<WeixinLogo showBrandLogos={showBrandLogos} />
|
||||||
@@ -251,7 +251,7 @@ export function WeixinPanel({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{runtimeError ? (
|
{runtimeError ? (
|
||||||
<div className="mt-4 rounded-[12px] border border-destructive/20 bg-destructive/5 px-3 py-2 text-[12px] leading-5 text-destructive">
|
<div className="mt-4 rounded-control border border-destructive/20 bg-destructive/5 px-3 py-2 text-[12px] leading-5 text-destructive">
|
||||||
{runtimeError}
|
{runtimeError}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -304,7 +304,7 @@ export function WeixinPanel({
|
|||||||
{saveError ? (
|
{saveError ? (
|
||||||
<div
|
<div
|
||||||
role="alert"
|
role="alert"
|
||||||
className="rounded-[12px] border border-destructive/20 bg-destructive/5 px-3 py-2 text-[12px] leading-5 text-destructive"
|
className="rounded-control border border-destructive/20 bg-destructive/5 px-3 py-2 text-[12px] leading-5 text-destructive"
|
||||||
>
|
>
|
||||||
{saveError}
|
{saveError}
|
||||||
</div>
|
</div>
|
||||||
@@ -413,7 +413,7 @@ function WeixinLogo({ showBrandLogos }: { showBrandLogos: boolean }) {
|
|||||||
const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(logoUrls);
|
const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(logoUrls);
|
||||||
if (showBrandLogos && logoUrl) {
|
if (showBrandLogos && logoUrl) {
|
||||||
return (
|
return (
|
||||||
<span className="grid h-10 w-10 shrink-0 place-items-center rounded-[12px] bg-background">
|
<span className="grid h-10 w-10 shrink-0 place-items-center rounded-control bg-background">
|
||||||
<img
|
<img
|
||||||
src={logoUrl}
|
src={logoUrl}
|
||||||
alt=""
|
alt=""
|
||||||
@@ -428,7 +428,7 @@ function WeixinLogo({ showBrandLogos }: { showBrandLogos: boolean }) {
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-[12px] bg-background text-[11px] font-bold"
|
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-control bg-background text-[11px] font-bold"
|
||||||
style={{ color: "#07C160" }}
|
style={{ color: "#07C160" }}
|
||||||
aria-hidden
|
aria-hidden
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -566,7 +566,7 @@ def _run_gateway(
|
|||||||
if sha:
|
if sha:
|
||||||
logger.info("Dream commit: {}", sha)
|
logger.info("Dream commit: {}", sha)
|
||||||
store.compact_history()
|
store.compact_history()
|
||||||
prune_dream_sessions(agent.sessions.sessions_dir)
|
prune_dream_sessions(agent.sessions)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Heartbeat is a system job that checks HEARTBEAT.md for active tasks.
|
# Heartbeat is a system job that checks HEARTBEAT.md for active tasks.
|
||||||
|
|||||||
@@ -490,7 +490,7 @@ async def cmd_dream(ctx: CommandContext) -> OutboundMessage:
|
|||||||
if sha:
|
if sha:
|
||||||
content += f" (commit {sha})"
|
content += f" (commit {sha})"
|
||||||
store.compact_history()
|
store.compact_history()
|
||||||
prune_dream_sessions(loop.sessions.sessions_dir)
|
prune_dream_sessions(loop.sessions)
|
||||||
await loop.bus.publish_outbound(OutboundMessage(
|
await loop.bus.publish_outbound(OutboundMessage(
|
||||||
channel=msg.channel, chat_id=msg.chat_id, content=content,
|
channel=msg.channel, chat_id=msg.chat_id, content=content,
|
||||||
))
|
))
|
||||||
|
|||||||
+70
-11
@@ -9,12 +9,12 @@ import re
|
|||||||
import secrets
|
import secrets
|
||||||
import stat
|
import stat
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
from contextlib import suppress
|
from contextlib import contextmanager, suppress
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Callable, Collection, Protocol, TypedDict, cast
|
from typing import Any, Callable, Collection, Generator, Protocol, TypedDict, cast
|
||||||
from weakref import WeakValueDictionary
|
from weakref import WeakValueDictionary
|
||||||
|
|
||||||
from filelock import FileLock
|
from filelock import FileLock
|
||||||
@@ -65,6 +65,7 @@ _WORKSPACE_STATE_DIR = ".nanobot"
|
|||||||
_WORKSPACE_ID_FILE = "workspace-id"
|
_WORKSPACE_ID_FILE = "workspace-id"
|
||||||
_WORKSPACE_ID_RE = re.compile(r"^[0-9a-f]{32}$")
|
_WORKSPACE_ID_RE = re.compile(r"^[0-9a-f]{32}$")
|
||||||
_SESSION_MIGRATION_LOCK_TIMEOUT_SECONDS = 30
|
_SESSION_MIGRATION_LOCK_TIMEOUT_SECONDS = 30
|
||||||
|
_SESSION_FILES_LOCK_FILENAME = ".session-files.lock"
|
||||||
_COPY_CHUNK_SIZE = 1024 * 1024
|
_COPY_CHUNK_SIZE = 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
@@ -472,13 +473,28 @@ class Session:
|
|||||||
if limit <= 0 or len(self.messages) <= limit:
|
if limit <= 0 or len(self.messages) <= limit:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
original_messages = self.messages
|
||||||
|
original_last_consolidated = self.last_consolidated
|
||||||
|
original_provider_state = self.provider_state
|
||||||
|
original_updated_at = self.updated_at
|
||||||
result = self.retain_recent_legal_suffix(limit)
|
result = self.retain_recent_legal_suffix(limit)
|
||||||
if not result.dropped:
|
if not result.dropped:
|
||||||
return
|
return
|
||||||
|
|
||||||
archive_chunk = result.dropped[result.already_consolidated_count:]
|
archive_chunk = result.dropped[result.already_consolidated_count:]
|
||||||
if archive_chunk and on_archive:
|
if archive_chunk and on_archive:
|
||||||
|
try:
|
||||||
on_archive(archive_chunk)
|
on_archive(archive_chunk)
|
||||||
|
except BaseException:
|
||||||
|
# Retention runs before the archive callback so the callback can
|
||||||
|
# receive the exact dropped prefix. Restore the in-memory session
|
||||||
|
# if archival fails; otherwise a later save would persist the
|
||||||
|
# trimmed state and make that prefix impossible to retry.
|
||||||
|
self.messages = original_messages
|
||||||
|
self.last_consolidated = original_last_consolidated
|
||||||
|
self.provider_state = original_provider_state
|
||||||
|
self.updated_at = original_updated_at
|
||||||
|
raise
|
||||||
logger.info(
|
logger.info(
|
||||||
"Session file cap hit for {}: dropped {}, raw-archived {}, kept {}",
|
"Session file cap hit for {}: dropped {}, raw-archived {}, kept {}",
|
||||||
self.key,
|
self.key,
|
||||||
@@ -576,8 +592,18 @@ class JsonlSessionStore:
|
|||||||
)
|
)
|
||||||
self.sessions_dir = ensure_dir(root / workspace_id)
|
self.sessions_dir = ensure_dir(root / workspace_id)
|
||||||
self.legacy_sessions_dir = get_legacy_sessions_dir()
|
self.legacy_sessions_dir = get_legacy_sessions_dir()
|
||||||
|
self._session_files_lock = FileLock(
|
||||||
|
str(self.sessions_dir / _SESSION_FILES_LOCK_FILENAME)
|
||||||
|
)
|
||||||
|
with self._session_files_lock:
|
||||||
self._migrate_from_workspace(canonical_workspace)
|
self._migrate_from_workspace(canonical_workspace)
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def locked_session_files(self) -> Generator[Path, None, None]:
|
||||||
|
"""Guard direct access to canonical session files in this directory."""
|
||||||
|
with self._session_files_lock:
|
||||||
|
yield self.sessions_dir
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _fsync_directory(path: Path) -> None:
|
def _fsync_directory(path: Path) -> None:
|
||||||
with suppress(PermissionError, NotImplementedError):
|
with suppress(PermissionError, NotImplementedError):
|
||||||
@@ -959,7 +985,7 @@ class JsonlSessionStore:
|
|||||||
raise RuntimeError(f"refusing to restore into symlinked sessions directory: {old_dir}")
|
raise RuntimeError(f"refusing to restore into symlinked sessions directory: {old_dir}")
|
||||||
ensure_dir(old_dir)
|
ensure_dir(old_dir)
|
||||||
|
|
||||||
with self._migration_lock:
|
with self._migration_lock, self._session_files_lock:
|
||||||
for src in self.sessions_dir.glob("*.jsonl"):
|
for src in self.sessions_dir.glob("*.jsonl"):
|
||||||
if self.session_key_from_path(src) is None:
|
if self.session_key_from_path(src) is None:
|
||||||
continue
|
continue
|
||||||
@@ -1021,6 +1047,10 @@ class JsonlSessionStore:
|
|||||||
return self.legacy_sessions_dir / f"{self.safe_key(key)}.jsonl"
|
return self.legacy_sessions_dir / f"{self.safe_key(key)}.jsonl"
|
||||||
|
|
||||||
def load(self, key: str) -> Session | None:
|
def load(self, key: str) -> Session | None:
|
||||||
|
with self._session_files_lock:
|
||||||
|
return self._load_unlocked(key)
|
||||||
|
|
||||||
|
def _load_unlocked(self, key: str) -> Session | None:
|
||||||
path = self.get_session_path(key)
|
path = self.get_session_path(key)
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
return None
|
return None
|
||||||
@@ -1086,7 +1116,7 @@ class JsonlSessionStore:
|
|||||||
)
|
)
|
||||||
except _SESSION_DATA_ERRORS as e:
|
except _SESSION_DATA_ERRORS as e:
|
||||||
logger.warning("Failed to load session {}: {}", key, e)
|
logger.warning("Failed to load session {}: {}", key, e)
|
||||||
repaired = self.repair(key)
|
repaired = self._repair_unlocked(key)
|
||||||
if repaired is not None:
|
if repaired is not None:
|
||||||
logger.info(
|
logger.info(
|
||||||
"Recovered session {} from corrupt file ({} messages)",
|
"Recovered session {} from corrupt file ({} messages)",
|
||||||
@@ -1096,6 +1126,10 @@ class JsonlSessionStore:
|
|||||||
return repaired
|
return repaired
|
||||||
|
|
||||||
def repair(self, key: str, *, path: Path | None = None) -> Session | None:
|
def repair(self, key: str, *, path: Path | None = None) -> Session | None:
|
||||||
|
with self._session_files_lock:
|
||||||
|
return self._repair_unlocked(key, path=path)
|
||||||
|
|
||||||
|
def _repair_unlocked(self, key: str, *, path: Path | None = None) -> Session | None:
|
||||||
if path is None:
|
if path is None:
|
||||||
path = self.get_session_path(key)
|
path = self.get_session_path(key)
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
@@ -1188,11 +1222,15 @@ class JsonlSessionStore:
|
|||||||
}
|
}
|
||||||
|
|
||||||
def save(self, session: Session, *, fsync: bool = False) -> None:
|
def save(self, session: Session, *, fsync: bool = False) -> None:
|
||||||
|
with self._session_files_lock:
|
||||||
|
self._save_unlocked(session, fsync=fsync)
|
||||||
|
|
||||||
|
def _save_unlocked(self, session: Session, *, fsync: bool = False) -> None:
|
||||||
path = self.get_session_path(session.key)
|
path = self.get_session_path(session.key)
|
||||||
tmp_path = path.with_suffix(".jsonl.tmp")
|
tmp_path = path.with_name(f".{path.name}.{secrets.token_hex(8)}.tmp")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with open(tmp_path, "w", encoding="utf-8") as f:
|
with open(tmp_path, "x", encoding="utf-8") as f:
|
||||||
metadata_line = {
|
metadata_line = {
|
||||||
"_type": "metadata",
|
"_type": "metadata",
|
||||||
"key": session.key,
|
"key": session.key,
|
||||||
@@ -1226,11 +1264,14 @@ class JsonlSessionStore:
|
|||||||
raise
|
raise
|
||||||
finally:
|
finally:
|
||||||
os.close(fd)
|
os.close(fd)
|
||||||
except BaseException:
|
finally:
|
||||||
tmp_path.unlink(missing_ok=True)
|
tmp_path.unlink(missing_ok=True)
|
||||||
raise
|
|
||||||
|
|
||||||
def delete(self, key: str) -> bool:
|
def delete(self, key: str) -> bool:
|
||||||
|
with self._session_files_lock:
|
||||||
|
return self._delete_unlocked(key)
|
||||||
|
|
||||||
|
def _delete_unlocked(self, key: str) -> bool:
|
||||||
paths = [
|
paths = [
|
||||||
self.get_session_path(key),
|
self.get_session_path(key),
|
||||||
self.get_legacy_lossy_path(key),
|
self.get_legacy_lossy_path(key),
|
||||||
@@ -1248,6 +1289,10 @@ class JsonlSessionStore:
|
|||||||
return deleted
|
return deleted
|
||||||
|
|
||||||
def read(self, key: str) -> SessionPayload | None:
|
def read(self, key: str) -> SessionPayload | None:
|
||||||
|
with self._session_files_lock:
|
||||||
|
return self._read_unlocked(key)
|
||||||
|
|
||||||
|
def _read_unlocked(self, key: str) -> SessionPayload | None:
|
||||||
path = self.get_session_path(key)
|
path = self.get_session_path(key)
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
return None
|
return None
|
||||||
@@ -1297,13 +1342,17 @@ class JsonlSessionStore:
|
|||||||
}
|
}
|
||||||
except _SESSION_DATA_ERRORS as e:
|
except _SESSION_DATA_ERRORS as e:
|
||||||
logger.warning("Failed to read session {}: {}", key, e)
|
logger.warning("Failed to read session {}: {}", key, e)
|
||||||
repaired = self.repair(key, path=path)
|
repaired = self._repair_unlocked(key, path=path)
|
||||||
if repaired is not None:
|
if repaired is not None:
|
||||||
logger.info("Recovered read-only session view {} from corrupt file", key)
|
logger.info("Recovered read-only session view {} from corrupt file", key)
|
||||||
return self.session_payload(repaired)
|
return self.session_payload(repaired)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def read_metadata(self, key: str) -> SessionMetadataPayload | None:
|
def read_metadata(self, key: str) -> SessionMetadataPayload | None:
|
||||||
|
with self._session_files_lock:
|
||||||
|
return self._read_metadata_unlocked(key)
|
||||||
|
|
||||||
|
def _read_metadata_unlocked(self, key: str) -> SessionMetadataPayload | None:
|
||||||
path = self.get_session_path(key)
|
path = self.get_session_path(key)
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
return None
|
return None
|
||||||
@@ -1338,7 +1387,7 @@ class JsonlSessionStore:
|
|||||||
return None
|
return None
|
||||||
except _SESSION_DATA_ERRORS as e:
|
except _SESSION_DATA_ERRORS as e:
|
||||||
logger.warning("Failed to read session metadata {}: {}", key, e)
|
logger.warning("Failed to read session metadata {}: {}", key, e)
|
||||||
repaired = self.repair(key, path=path)
|
repaired = self._repair_unlocked(key, path=path)
|
||||||
if repaired is not None:
|
if repaired is not None:
|
||||||
logger.info("Recovered read-only session metadata {} from corrupt file", key)
|
logger.info("Recovered read-only session metadata {} from corrupt file", key)
|
||||||
return {
|
return {
|
||||||
@@ -1350,6 +1399,10 @@ class JsonlSessionStore:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def list_sessions(self) -> list[SessionInfo]:
|
def list_sessions(self) -> list[SessionInfo]:
|
||||||
|
with self._session_files_lock:
|
||||||
|
return self._list_sessions_unlocked()
|
||||||
|
|
||||||
|
def _list_sessions_unlocked(self) -> list[SessionInfo]:
|
||||||
sessions: list[SessionInfo] = []
|
sessions: list[SessionInfo] = []
|
||||||
|
|
||||||
for path in self.sessions_dir.glob("*.jsonl"):
|
for path in self.sessions_dir.glob("*.jsonl"):
|
||||||
@@ -1427,7 +1480,7 @@ class JsonlSessionStore:
|
|||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
continue
|
continue
|
||||||
except _SESSION_DATA_ERRORS:
|
except _SESSION_DATA_ERRORS:
|
||||||
repaired = self.repair(storage_key, path=path)
|
repaired = self._repair_unlocked(storage_key, path=path)
|
||||||
if repaired is not None:
|
if repaired is not None:
|
||||||
sessions.append(
|
sessions.append(
|
||||||
{
|
{
|
||||||
@@ -1536,6 +1589,12 @@ class SessionManager:
|
|||||||
"""Legacy global session path (~/.nanobot/sessions/)."""
|
"""Legacy global session path (~/.nanobot/sessions/)."""
|
||||||
return self._jsonl_store.get_legacy_session_path(key)
|
return self._jsonl_store.get_legacy_session_path(key)
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def locked_session_files(self) -> Generator[Path, None, None]:
|
||||||
|
"""Guard exceptional direct access to canonical JSONL files."""
|
||||||
|
with self._jsonl_store.locked_session_files() as sessions_dir:
|
||||||
|
yield sessions_dir
|
||||||
|
|
||||||
def get_or_create(self, key: str) -> Session:
|
def get_or_create(self, key: str) -> Session:
|
||||||
"""
|
"""
|
||||||
Get an existing session or create a new one.
|
Get an existing session or create a new one.
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
"""Native directory picker used by a locally hosted WebUI."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
from contextlib import suppress
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
_PICKER_TIMEOUT_SECONDS = 300
|
||||||
|
_COMMON_ENV_KEYS = (
|
||||||
|
"HOME",
|
||||||
|
"LANG",
|
||||||
|
"LANGUAGE",
|
||||||
|
"LC_ALL",
|
||||||
|
"LC_CTYPE",
|
||||||
|
"LC_MESSAGES",
|
||||||
|
"LOGNAME",
|
||||||
|
"PATH",
|
||||||
|
"SHELL",
|
||||||
|
"TMPDIR",
|
||||||
|
"USER",
|
||||||
|
)
|
||||||
|
_LINUX_GUI_ENV_KEYS = (
|
||||||
|
"DBUS_SESSION_BUS_ADDRESS",
|
||||||
|
"DESKTOP_SESSION",
|
||||||
|
"DISPLAY",
|
||||||
|
"WAYLAND_DISPLAY",
|
||||||
|
"XAUTHORITY",
|
||||||
|
"XDG_CURRENT_DESKTOP",
|
||||||
|
"XDG_RUNTIME_DIR",
|
||||||
|
)
|
||||||
|
_MACOS_GUI_ENV_KEYS = ("SECURITYSESSIONID", "__CF_USER_TEXT_ENCODING")
|
||||||
|
_WINDOWS_GUI_ENV_KEYS = (
|
||||||
|
"APPDATA",
|
||||||
|
"COMSPEC",
|
||||||
|
"HOMEDRIVE",
|
||||||
|
"HOMEPATH",
|
||||||
|
"LOCALAPPDATA",
|
||||||
|
"PATHEXT",
|
||||||
|
"ProgramData",
|
||||||
|
"ProgramFiles",
|
||||||
|
"ProgramFiles(x86)",
|
||||||
|
"ProgramW6432",
|
||||||
|
"SESSIONNAME",
|
||||||
|
"SYSTEMROOT",
|
||||||
|
"TEMP",
|
||||||
|
"TMP",
|
||||||
|
"USERDOMAIN",
|
||||||
|
"USERNAME",
|
||||||
|
"USERPROFILE",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class NativeFolderPickerError(RuntimeError):
|
||||||
|
"""Raised when an available native folder picker cannot complete."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class _PickerCommand:
|
||||||
|
argv: tuple[str, ...]
|
||||||
|
cancel_codes: frozenset[int]
|
||||||
|
cancel_markers: tuple[str, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
|
def _picker_command() -> _PickerCommand | None:
|
||||||
|
if sys.platform == "darwin":
|
||||||
|
executable = shutil.which("osascript")
|
||||||
|
if executable is None:
|
||||||
|
return None
|
||||||
|
return _PickerCommand(
|
||||||
|
argv=(
|
||||||
|
executable,
|
||||||
|
"-e",
|
||||||
|
'set selectedFolder to choose folder with prompt "Select Workspace Directory"',
|
||||||
|
"-e",
|
||||||
|
"POSIX path of selectedFolder",
|
||||||
|
),
|
||||||
|
cancel_codes=frozenset({1}),
|
||||||
|
cancel_markers=("user canceled", "(-128)"),
|
||||||
|
)
|
||||||
|
|
||||||
|
if sys.platform == "win32":
|
||||||
|
executable = shutil.which("powershell.exe") or shutil.which("powershell")
|
||||||
|
if executable is None:
|
||||||
|
return None
|
||||||
|
script = (
|
||||||
|
"Add-Type -AssemblyName System.Windows.Forms;"
|
||||||
|
"$dialog=New-Object System.Windows.Forms.FolderBrowserDialog;"
|
||||||
|
"$dialog.Description='Select Workspace Directory';"
|
||||||
|
"$dialog.ShowNewFolderButton=$true;"
|
||||||
|
"if($dialog.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK){"
|
||||||
|
"[Console]::OutputEncoding=[System.Text.UTF8Encoding]::new();"
|
||||||
|
"[Console]::Out.Write($dialog.SelectedPath)}"
|
||||||
|
)
|
||||||
|
return _PickerCommand(
|
||||||
|
argv=(
|
||||||
|
executable,
|
||||||
|
"-NoProfile",
|
||||||
|
"-NonInteractive",
|
||||||
|
"-STA",
|
||||||
|
"-Command",
|
||||||
|
script,
|
||||||
|
),
|
||||||
|
cancel_codes=frozenset(),
|
||||||
|
)
|
||||||
|
|
||||||
|
if sys.platform.startswith("linux"):
|
||||||
|
if not (os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY")):
|
||||||
|
return None
|
||||||
|
zenity = shutil.which("zenity")
|
||||||
|
if zenity is not None:
|
||||||
|
return _PickerCommand(
|
||||||
|
argv=(
|
||||||
|
zenity,
|
||||||
|
"--file-selection",
|
||||||
|
"--directory",
|
||||||
|
"--title=Select Workspace Directory",
|
||||||
|
),
|
||||||
|
cancel_codes=frozenset({1}),
|
||||||
|
)
|
||||||
|
kdialog = shutil.which("kdialog")
|
||||||
|
if kdialog is not None:
|
||||||
|
return _PickerCommand(
|
||||||
|
argv=(kdialog, "--getexistingdirectory", str(Path.home())),
|
||||||
|
cancel_codes=frozenset({1}),
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def native_folder_picker_available() -> bool:
|
||||||
|
"""Return whether this host can display a native directory picker."""
|
||||||
|
return _picker_command() is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _picker_environment() -> dict[str, str]:
|
||||||
|
"""Pass only host UI/runtime variables, never provider or gateway secrets."""
|
||||||
|
keys: list[str] = list(_COMMON_ENV_KEYS)
|
||||||
|
if sys.platform == "darwin":
|
||||||
|
keys.extend(_MACOS_GUI_ENV_KEYS)
|
||||||
|
elif sys.platform == "win32":
|
||||||
|
keys.extend(_WINDOWS_GUI_ENV_KEYS)
|
||||||
|
elif sys.platform.startswith("linux"):
|
||||||
|
keys.extend(_LINUX_GUI_ENV_KEYS)
|
||||||
|
return {
|
||||||
|
key: value
|
||||||
|
for key in keys
|
||||||
|
if (value := os.environ.get(key)) is not None
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _stop_process(process: asyncio.subprocess.Process) -> None:
|
||||||
|
if process.returncode is not None:
|
||||||
|
return
|
||||||
|
with suppress(ProcessLookupError):
|
||||||
|
process.terminate()
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(process.wait(), timeout=2)
|
||||||
|
except TimeoutError:
|
||||||
|
with suppress(ProcessLookupError):
|
||||||
|
process.kill()
|
||||||
|
await process.wait()
|
||||||
|
|
||||||
|
|
||||||
|
async def pick_native_folder() -> str | None:
|
||||||
|
"""Open the platform directory picker and return an existing absolute path."""
|
||||||
|
command = _picker_command()
|
||||||
|
if command is None:
|
||||||
|
raise NativeFolderPickerError("native folder picker is unavailable on this host")
|
||||||
|
|
||||||
|
try:
|
||||||
|
process = await asyncio.create_subprocess_exec(
|
||||||
|
*command.argv,
|
||||||
|
env=_picker_environment(),
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
)
|
||||||
|
except OSError as exc:
|
||||||
|
raise NativeFolderPickerError("native folder picker failed to start") from exc
|
||||||
|
try:
|
||||||
|
stdout, stderr = await asyncio.wait_for(
|
||||||
|
process.communicate(),
|
||||||
|
timeout=_PICKER_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
await _stop_process(process)
|
||||||
|
raise
|
||||||
|
except TimeoutError as exc:
|
||||||
|
await _stop_process(process)
|
||||||
|
raise NativeFolderPickerError("native folder picker timed out") from exc
|
||||||
|
|
||||||
|
error_text = stderr.decode("utf-8", errors="replace").strip()
|
||||||
|
normalized_error = error_text.lower()
|
||||||
|
if process.returncode != 0:
|
||||||
|
if process.returncode in command.cancel_codes and (
|
||||||
|
not command.cancel_markers
|
||||||
|
or any(marker in normalized_error for marker in command.cancel_markers)
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
raise NativeFolderPickerError("native folder picker failed")
|
||||||
|
|
||||||
|
selected = stdout.decode("utf-8", errors="replace").strip()
|
||||||
|
if not selected:
|
||||||
|
return None
|
||||||
|
path = Path(selected).expanduser()
|
||||||
|
if not path.is_absolute() or not path.is_dir():
|
||||||
|
raise NativeFolderPickerError("native folder picker returned an invalid directory")
|
||||||
|
return str(path)
|
||||||
@@ -10,6 +10,7 @@ from __future__ import annotations
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import secrets
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, cast
|
from typing import Any, cast
|
||||||
@@ -56,6 +57,7 @@ _TRANSCRIPT_NON_ANSWER_KINDS = {"progress", "reasoning", "tool_hint"}
|
|||||||
|
|
||||||
def list_webui_sessions(session_manager: SessionManager) -> list[dict[str, Any]]:
|
def list_webui_sessions(session_manager: SessionManager) -> list[dict[str, Any]]:
|
||||||
"""Return session rows for the WebUI sidebar, backed by a rebuildable cache."""
|
"""Return session rows for the WebUI sidebar, backed by a rebuildable cache."""
|
||||||
|
with session_manager.locked_session_files():
|
||||||
rows, changed = _reconcile_index(session_manager)
|
rows, changed = _reconcile_index(session_manager)
|
||||||
if changed:
|
if changed:
|
||||||
try:
|
try:
|
||||||
@@ -169,14 +171,14 @@ def _read_index_rows(sessions_dir: Path) -> list[dict[str, Any]] | None:
|
|||||||
|
|
||||||
def _write_index_rows(sessions_dir: Path, rows: list[dict[str, Any]]) -> None:
|
def _write_index_rows(sessions_dir: Path, rows: list[dict[str, Any]]) -> None:
|
||||||
path = _index_path(sessions_dir)
|
path = _index_path(sessions_dir)
|
||||||
tmp_path = path.with_suffix(".json.tmp")
|
tmp_path = path.with_name(f"{path.name}.{secrets.token_hex(8)}.tmp")
|
||||||
data = {"version": _INDEX_VERSION, "sessions": rows}
|
data = {"version": _INDEX_VERSION, "sessions": rows}
|
||||||
try:
|
try:
|
||||||
tmp_path.write_text(json.dumps(data, ensure_ascii=False) + "\n", encoding="utf-8")
|
with open(tmp_path, "x", encoding="utf-8") as file:
|
||||||
|
file.write(json.dumps(data, ensure_ascii=False) + "\n")
|
||||||
os.replace(tmp_path, path)
|
os.replace(tmp_path, path)
|
||||||
except BaseException:
|
finally:
|
||||||
tmp_path.unlink(missing_ok=True)
|
tmp_path.unlink(missing_ok=True)
|
||||||
raise
|
|
||||||
|
|
||||||
|
|
||||||
def _file_signature(path: Path) -> dict[str, int]:
|
def _file_signature(path: Path) -> dict[str, int]:
|
||||||
|
|||||||
@@ -149,6 +149,7 @@ def workspaces_payload(
|
|||||||
default_workspace: Path,
|
default_workspace: Path,
|
||||||
default_restrict_to_workspace: bool,
|
default_restrict_to_workspace: bool,
|
||||||
controls_available: bool,
|
controls_available: bool,
|
||||||
|
folder_picker_available: bool = False,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
default_access_mode = read_webui_default_access_mode()
|
default_access_mode = read_webui_default_access_mode()
|
||||||
default_scope = (
|
default_scope = (
|
||||||
@@ -167,6 +168,7 @@ def workspaces_payload(
|
|||||||
"controls": {
|
"controls": {
|
||||||
"can_change_project": controls_available,
|
"can_change_project": controls_available,
|
||||||
"can_use_full_access": controls_available,
|
"can_use_full_access": controls_available,
|
||||||
|
"can_pick_folder": folder_picker_available,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,11 +243,17 @@ class WebUIWorkspaceController:
|
|||||||
cast(object, metadata_data.get(WORKSPACE_SCOPE_METADATA_KEY))
|
cast(object, metadata_data.get(WORKSPACE_SCOPE_METADATA_KEY))
|
||||||
)
|
)
|
||||||
|
|
||||||
def payload(self, *, controls_available: bool) -> dict[str, Any]:
|
def payload(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
controls_available: bool,
|
||||||
|
folder_picker_available: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
return workspaces_payload(
|
return workspaces_payload(
|
||||||
default_workspace=self._default_workspace,
|
default_workspace=self._default_workspace,
|
||||||
default_restrict_to_workspace=self._default_restrict_to_workspace,
|
default_restrict_to_workspace=self._default_restrict_to_workspace,
|
||||||
controls_available=controls_available,
|
controls_available=controls_available,
|
||||||
|
folder_picker_available=folder_picker_available,
|
||||||
)
|
)
|
||||||
|
|
||||||
def scope_from_envelope(
|
def scope_from_envelope(
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ from nanobot.webui.http_utils import (
|
|||||||
from nanobot.webui.http_utils import (
|
from nanobot.webui.http_utils import (
|
||||||
is_localhost as _is_localhost,
|
is_localhost as _is_localhost,
|
||||||
)
|
)
|
||||||
|
from nanobot.webui.http_utils import is_loopback_host as _is_loopback_host
|
||||||
from nanobot.webui.http_utils import (
|
from nanobot.webui.http_utils import (
|
||||||
is_trusted_proxy_authenticated_request as _is_trusted_proxy_authenticated_request,
|
is_trusted_proxy_authenticated_request as _is_trusted_proxy_authenticated_request,
|
||||||
)
|
)
|
||||||
@@ -85,6 +86,11 @@ from nanobot.webui.http_utils import (
|
|||||||
)
|
)
|
||||||
from nanobot.webui.ingress_policy import WebUIIngressPolicy
|
from nanobot.webui.ingress_policy import WebUIIngressPolicy
|
||||||
from nanobot.webui.media_gateway import WebUIMediaGateway
|
from nanobot.webui.media_gateway import WebUIMediaGateway
|
||||||
|
from nanobot.webui.native_folder_picker import (
|
||||||
|
NativeFolderPickerError,
|
||||||
|
native_folder_picker_available,
|
||||||
|
pick_native_folder,
|
||||||
|
)
|
||||||
from nanobot.webui.session_automations import (
|
from nanobot.webui.session_automations import (
|
||||||
all_automations_payload,
|
all_automations_payload,
|
||||||
serialize_automation_jobs,
|
serialize_automation_jobs,
|
||||||
@@ -133,6 +139,7 @@ _WEBUI_MUTATION_PATHS = {
|
|||||||
"skill.update": "/api/webui/skills/update",
|
"skill.update": "/api/webui/skills/update",
|
||||||
"skill.delete": "/api/webui/skills/delete",
|
"skill.delete": "/api/webui/skills/delete",
|
||||||
"sidebar.update": "/api/webui/sidebar-state/update",
|
"sidebar.update": "/api/webui/sidebar-state/update",
|
||||||
|
"workspace.pick_folder": "/api/workspaces/pick-folder",
|
||||||
"settings.agent.update": "/api/settings/update",
|
"settings.agent.update": "/api/settings/update",
|
||||||
"settings.model_configuration.create": "/api/settings/model-configurations/create",
|
"settings.model_configuration.create": "/api/settings/model-configurations/create",
|
||||||
"settings.model_configuration.update": "/api/settings/model-configurations/update",
|
"settings.model_configuration.update": "/api/settings/model-configurations/update",
|
||||||
@@ -329,6 +336,7 @@ class GatewayHTTPHandler:
|
|||||||
)
|
)
|
||||||
self.skill_state_action = skill_state_action
|
self.skill_state_action = skill_state_action
|
||||||
self._skill_install_lock = asyncio.Lock()
|
self._skill_install_lock = asyncio.Lock()
|
||||||
|
self._folder_picker_lock = asyncio.Lock()
|
||||||
self.cron_service = cron_service
|
self.cron_service = cron_service
|
||||||
self.local_trigger_store = local_trigger_store
|
self.local_trigger_store = local_trigger_store
|
||||||
self.cron_pending_job_ids = cron_pending_job_ids
|
self.cron_pending_job_ids = cron_pending_job_ids
|
||||||
@@ -360,6 +368,17 @@ class GatewayHTTPHandler:
|
|||||||
def workspace_controls_available(self, connection: Any) -> bool:
|
def workspace_controls_available(self, connection: Any) -> bool:
|
||||||
return self._runtime_surface == "native" or _is_localhost(connection)
|
return self._runtime_surface == "native" or _is_localhost(connection)
|
||||||
|
|
||||||
|
def workspace_folder_picker_available(
|
||||||
|
self,
|
||||||
|
connection: Any,
|
||||||
|
request: WsRequest,
|
||||||
|
) -> bool:
|
||||||
|
return (
|
||||||
|
_is_loopback_host(self.config.host)
|
||||||
|
and _is_local_browser_request(connection, request.headers)
|
||||||
|
and native_folder_picker_available()
|
||||||
|
)
|
||||||
|
|
||||||
# -- Token management ---------------------------------------------------
|
# -- Token management ---------------------------------------------------
|
||||||
|
|
||||||
def check_api_token(self, request: WsRequest) -> bool:
|
def check_api_token(self, request: WsRequest) -> bool:
|
||||||
@@ -435,6 +454,7 @@ class GatewayHTTPHandler:
|
|||||||
"/api/webui/skills/update",
|
"/api/webui/skills/update",
|
||||||
"/api/webui/skills/delete",
|
"/api/webui/skills/delete",
|
||||||
"/api/webui/sidebar-state/update",
|
"/api/webui/sidebar-state/update",
|
||||||
|
"/api/workspaces/pick-folder",
|
||||||
}
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -1054,6 +1074,8 @@ class GatewayHTTPHandler:
|
|||||||
return await self._handle_sessions_list(request)
|
return await self._handle_sessions_list(request)
|
||||||
if got == "/api/commands":
|
if got == "/api/commands":
|
||||||
return self._handle_commands(request)
|
return self._handle_commands(request)
|
||||||
|
if got == "/api/workspaces/pick-folder":
|
||||||
|
return await self._handle_workspace_folder_picker(connection, request)
|
||||||
if got == "/api/workspaces":
|
if got == "/api/workspaces":
|
||||||
return self._handle_workspaces(connection, request)
|
return self._handle_workspaces(connection, request)
|
||||||
if got == "/api/webui/skills/search":
|
if got == "/api/webui/skills/search":
|
||||||
@@ -1089,10 +1111,32 @@ class GatewayHTTPHandler:
|
|||||||
return _http_error(401, "Unauthorized")
|
return _http_error(401, "Unauthorized")
|
||||||
return _http_json_response(
|
return _http_json_response(
|
||||||
self.workspaces.payload(
|
self.workspaces.payload(
|
||||||
controls_available=self.workspace_controls_available(connection)
|
controls_available=self.workspace_controls_available(connection),
|
||||||
|
folder_picker_available=self.workspace_folder_picker_available(
|
||||||
|
connection,
|
||||||
|
request,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def _handle_workspace_folder_picker(
|
||||||
|
self,
|
||||||
|
connection: Any,
|
||||||
|
request: WsRequest,
|
||||||
|
) -> Response:
|
||||||
|
if not self.check_api_token(request):
|
||||||
|
return _http_error(401, "Unauthorized")
|
||||||
|
if not self.workspace_folder_picker_available(connection, request):
|
||||||
|
return _http_error(403, "native folder picker is unavailable for this connection")
|
||||||
|
if self._folder_picker_lock.locked():
|
||||||
|
return _http_error(409, "native folder picker is already open")
|
||||||
|
try:
|
||||||
|
async with self._folder_picker_lock:
|
||||||
|
path = await pick_native_folder()
|
||||||
|
except NativeFolderPickerError as exc:
|
||||||
|
return _http_error(503, str(exc))
|
||||||
|
return _http_json_response({"path": path})
|
||||||
|
|
||||||
def _handle_webui_skills(self, request: WsRequest) -> Response:
|
def _handle_webui_skills(self, request: WsRequest) -> Response:
|
||||||
if not self.check_api_token(request):
|
if not self.check_api_token(request):
|
||||||
return _http_error(401, "Unauthorized")
|
return _http_error(401, "Unauthorized")
|
||||||
|
|||||||
@@ -29,8 +29,11 @@ class TestPruneDreamSessions:
|
|||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
|
|
||||||
sessions_dir = tmp_path / "sessions"
|
manager = SessionManager(
|
||||||
sessions_dir.mkdir()
|
tmp_path / "workspace",
|
||||||
|
sessions_root=tmp_path / "runtime",
|
||||||
|
)
|
||||||
|
sessions_dir = manager.sessions_dir
|
||||||
|
|
||||||
base_time = time.time() - 100
|
base_time = time.time() - 100
|
||||||
dream_paths = []
|
dream_paths = []
|
||||||
@@ -50,7 +53,7 @@ class TestPruneDreamSessions:
|
|||||||
normal_path = sessions_dir / "telegram_123.jsonl"
|
normal_path = sessions_dir / "telegram_123.jsonl"
|
||||||
normal_path.write_text('{"_type": "metadata"}\n', encoding="utf-8")
|
normal_path.write_text('{"_type": "metadata"}\n', encoding="utf-8")
|
||||||
|
|
||||||
MemoryStore.prune_dream_sessions(sessions_dir, keep=10)
|
MemoryStore.prune_dream_sessions(manager, keep=10)
|
||||||
|
|
||||||
assert [path.exists() for path in dream_paths] == [False] * 5 + [True] * 10
|
assert [path.exists() for path in dream_paths] == [False] * 5 + [True] * 10
|
||||||
assert normal_path.exists()
|
assert normal_path.exists()
|
||||||
@@ -59,8 +62,11 @@ class TestPruneDreamSessions:
|
|||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
|
|
||||||
sessions_dir = tmp_path / "sessions"
|
manager = SessionManager(
|
||||||
sessions_dir.mkdir()
|
tmp_path / "workspace",
|
||||||
|
sessions_root=tmp_path / "runtime",
|
||||||
|
)
|
||||||
|
sessions_dir = manager.sessions_dir
|
||||||
base_time = time.time() - 100
|
base_time = time.time() - 100
|
||||||
current_paths = []
|
current_paths = []
|
||||||
|
|
||||||
@@ -81,24 +87,29 @@ class TestPruneDreamSessions:
|
|||||||
)
|
)
|
||||||
os.utime(legacy_path, (base_time - 1, base_time - 1))
|
os.utime(legacy_path, (base_time - 1, base_time - 1))
|
||||||
|
|
||||||
MemoryStore.prune_dream_sessions(sessions_dir, keep=1)
|
MemoryStore.prune_dream_sessions(manager, keep=1)
|
||||||
|
|
||||||
assert [path.exists() for path in current_paths] == [False, True]
|
assert [path.exists() for path in current_paths] == [False, True]
|
||||||
assert legacy_path.exists()
|
assert legacy_path.exists()
|
||||||
|
|
||||||
def test_noop_when_under_limit(self, tmp_path):
|
def test_noop_when_under_limit(self, tmp_path):
|
||||||
sessions_dir = tmp_path / "sessions"
|
manager = SessionManager(
|
||||||
sessions_dir.mkdir()
|
tmp_path / "workspace",
|
||||||
|
sessions_root=tmp_path / "runtime",
|
||||||
|
)
|
||||||
|
sessions_dir = manager.sessions_dir
|
||||||
for i in range(3):
|
for i in range(3):
|
||||||
key = f"dream:20260528-{100000 + i:06d}"
|
key = f"dream:20260528-{100000 + i:06d}"
|
||||||
path = sessions_dir / f"{SessionManager._storage_key(key)}.jsonl"
|
path = sessions_dir / f"{SessionManager._storage_key(key)}.jsonl"
|
||||||
path.write_text("{}", encoding="utf-8")
|
path.write_text("{}", encoding="utf-8")
|
||||||
|
|
||||||
MemoryStore.prune_dream_sessions(sessions_dir, keep=10)
|
MemoryStore.prune_dream_sessions(manager, keep=10)
|
||||||
assert len(list(sessions_dir.glob("*.jsonl"))) == 3
|
assert len(list(sessions_dir.glob("*.jsonl"))) == 3
|
||||||
|
|
||||||
def test_empty_dir_noop(self, tmp_path):
|
def test_empty_dir_noop(self, tmp_path):
|
||||||
sessions_dir = tmp_path / "sessions"
|
manager = SessionManager(
|
||||||
sessions_dir.mkdir()
|
tmp_path / "workspace",
|
||||||
MemoryStore.prune_dream_sessions(sessions_dir, keep=10)
|
sessions_root=tmp_path / "runtime",
|
||||||
assert list(sessions_dir.iterdir()) == []
|
)
|
||||||
|
MemoryStore.prune_dream_sessions(manager, keep=10)
|
||||||
|
assert list(manager.sessions_dir.glob("*.jsonl")) == []
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import json
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from filelock import Timeout
|
||||||
|
|
||||||
from nanobot.providers.base import ProviderConversationState
|
from nanobot.providers.base import ProviderConversationState
|
||||||
from nanobot.session.manager import Session, SessionManager
|
from nanobot.session.manager import Session, SessionManager
|
||||||
|
|
||||||
@@ -37,14 +40,23 @@ class TestAtomicSave:
|
|||||||
tmp_files = list(mgr.sessions_dir.glob("*.tmp"))
|
tmp_files = list(mgr.sessions_dir.glob("*.tmp"))
|
||||||
assert tmp_files == []
|
assert tmp_files == []
|
||||||
|
|
||||||
def test_tmp_file_cleaned_up_on_write_failure(self, tmp_path: Path):
|
def test_unique_tmp_file_cleaned_up_on_write_failure(
|
||||||
|
self,
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
):
|
||||||
mgr = SessionManager(tmp_path)
|
mgr = SessionManager(tmp_path)
|
||||||
session = Session(key="test:fail")
|
session = Session(key="test:fail")
|
||||||
path = mgr._get_session_path("test:fail")
|
path = mgr._get_session_path("test:fail")
|
||||||
tmp_path_file = path.with_suffix(".jsonl.tmp")
|
stale_shared_tmp = path.with_suffix(".jsonl.tmp")
|
||||||
|
unique_tmp = path.with_name(f".{path.name}.save-failure.tmp")
|
||||||
|
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
tmp_path_file.write_text("stale")
|
stale_shared_tmp.write_text("stale", encoding="utf-8")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"nanobot.session.manager.secrets.token_hex",
|
||||||
|
lambda _length: "save-failure",
|
||||||
|
)
|
||||||
|
|
||||||
class BadMessage:
|
class BadMessage:
|
||||||
def __init__(self, data):
|
def __init__(self, data):
|
||||||
@@ -64,13 +76,17 @@ class TestAtomicSave:
|
|||||||
]
|
]
|
||||||
|
|
||||||
import unittest.mock
|
import unittest.mock
|
||||||
with unittest.mock.patch("nanobot.session.manager.json.dumps", side_effect=failing_dumps):
|
with (
|
||||||
try:
|
unittest.mock.patch(
|
||||||
|
"nanobot.session.manager.json.dumps",
|
||||||
|
side_effect=failing_dumps,
|
||||||
|
),
|
||||||
|
pytest.raises(OSError, match="simulated disk full"),
|
||||||
|
):
|
||||||
mgr.save(session)
|
mgr.save(session)
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
assert not tmp_path_file.exists()
|
assert not unique_tmp.exists()
|
||||||
|
assert stale_shared_tmp.read_text(encoding="utf-8") == "stale"
|
||||||
|
|
||||||
def test_overwrite_preserves_latest_data(self, tmp_path: Path):
|
def test_overwrite_preserves_latest_data(self, tmp_path: Path):
|
||||||
mgr = SessionManager(tmp_path)
|
mgr = SessionManager(tmp_path)
|
||||||
@@ -102,6 +118,21 @@ class TestAtomicSave:
|
|||||||
for i in range(5):
|
for i in range(5):
|
||||||
assert loaded.messages[i]["content"] == f"msg{i}"
|
assert loaded.messages[i]["content"] == f"msg{i}"
|
||||||
|
|
||||||
|
def test_managers_for_same_directory_coordinate_saves(self, tmp_path: Path):
|
||||||
|
workspace = tmp_path / "workspace"
|
||||||
|
sessions_root = tmp_path / "runtime"
|
||||||
|
owner = SessionManager(workspace, sessions_root=sessions_root)
|
||||||
|
peer = SessionManager(workspace, sessions_root=sessions_root)
|
||||||
|
assert owner.sessions_dir == peer.sessions_dir
|
||||||
|
|
||||||
|
session = Session(key="test:peer-manager")
|
||||||
|
peer._jsonl_store._session_files_lock.timeout = 0
|
||||||
|
with owner.locked_session_files(), pytest.raises(Timeout):
|
||||||
|
peer.save(session)
|
||||||
|
|
||||||
|
peer.save(session)
|
||||||
|
assert peer._get_session_path(session.key).is_file()
|
||||||
|
|
||||||
def test_provider_state_round_trips_in_private_record_only(self, tmp_path: Path):
|
def test_provider_state_round_trips_in_private_record_only(self, tmp_path: Path):
|
||||||
mgr = SessionManager(tmp_path)
|
mgr = SessionManager(tmp_path)
|
||||||
secret = "encrypted-reasoning-blob"
|
secret = "encrypted-reasoning-blob"
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
from nanobot.providers.base import ProviderConversationState
|
from nanobot.providers.base import ProviderConversationState
|
||||||
from nanobot.runtime_context import (
|
from nanobot.runtime_context import (
|
||||||
RUNTIME_CONTEXT_HISTORY_META,
|
RUNTIME_CONTEXT_HISTORY_META,
|
||||||
@@ -981,6 +983,36 @@ def test_enforce_file_cap_correct_archive_with_last_consolidated_in_else_branch(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_enforce_file_cap_restores_session_when_archive_fails():
|
||||||
|
state = ProviderConversationState(
|
||||||
|
kind="openai_responses",
|
||||||
|
provider="openai:test",
|
||||||
|
model="test-model",
|
||||||
|
version=1,
|
||||||
|
payload={"items": []},
|
||||||
|
)
|
||||||
|
session = Session(key="test:archive-failure", provider_state=state)
|
||||||
|
for i in range(8):
|
||||||
|
session.messages.append({"role": "user", "content": f"msg{i}"})
|
||||||
|
original_messages = session.messages
|
||||||
|
original_updated_at = session.updated_at
|
||||||
|
session.last_consolidated = 2
|
||||||
|
|
||||||
|
def fail_archive(_messages):
|
||||||
|
raise RuntimeError("history unavailable")
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="history unavailable"):
|
||||||
|
session.enforce_file_cap(on_archive=fail_archive, limit=4)
|
||||||
|
|
||||||
|
assert session.messages is original_messages
|
||||||
|
assert [message["content"] for message in session.messages] == [
|
||||||
|
f"msg{i}" for i in range(8)
|
||||||
|
]
|
||||||
|
assert session.last_consolidated == 2
|
||||||
|
assert session.provider_state is state
|
||||||
|
assert session.updated_at == original_updated_at
|
||||||
|
|
||||||
|
|
||||||
def test_retain_recent_legal_suffix_last_consolidated_correct_in_else_branch():
|
def test_retain_recent_legal_suffix_last_consolidated_correct_in_else_branch():
|
||||||
"""last_consolidated after retain_recent_legal_suffix should reflect how
|
"""last_consolidated after retain_recent_legal_suffix should reflect how
|
||||||
many retained messages were inside the old consolidated prefix."""
|
many retained messages were inside the old consolidated prefix."""
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from nanobot.command.builtin import (
|
|||||||
cmd_dream_restore,
|
cmd_dream_restore,
|
||||||
)
|
)
|
||||||
from nanobot.command.router import CommandContext
|
from nanobot.command.router import CommandContext
|
||||||
|
from nanobot.session.manager import SessionManager
|
||||||
from nanobot.utils.gitstore import CommitInfo
|
from nanobot.utils.gitstore import CommitInfo
|
||||||
|
|
||||||
|
|
||||||
@@ -107,6 +108,13 @@ class _FakeBus:
|
|||||||
self.outbound.append(message)
|
self.outbound.append(message)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_sessions(tmp_path) -> SessionManager:
|
||||||
|
return SessionManager(
|
||||||
|
tmp_path / "workspace",
|
||||||
|
sessions_root=tmp_path / "runtime",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _make_ctx(raw: str, git: _FakeGit, *, args: str = "", last_dream_cursor: int = 1) -> CommandContext:
|
def _make_ctx(raw: str, git: _FakeGit, *, args: str = "", last_dream_cursor: int = 1) -> CommandContext:
|
||||||
msg = InboundMessage(channel="cli", sender_id="u1", chat_id="direct", content=raw)
|
msg = InboundMessage(channel="cli", sender_id="u1", chat_id="direct", content=raw)
|
||||||
store = _FakeStore(git, last_dream_cursor=last_dream_cursor)
|
store = _FakeStore(git, last_dream_cursor=last_dream_cursor)
|
||||||
@@ -118,12 +126,10 @@ def _make_dream_ctx(tmp_path) -> tuple[CommandContext, _FakeBus]:
|
|||||||
msg = InboundMessage(channel="cli", sender_id="u1", chat_id="direct", content="/dream")
|
msg = InboundMessage(channel="cli", sender_id="u1", chat_id="direct", content="/dream")
|
||||||
store = _FakeStore(_FakeGit(initialized=False), dream_prompt_result=None)
|
store = _FakeStore(_FakeGit(initialized=False), dream_prompt_result=None)
|
||||||
bus = _FakeBus()
|
bus = _FakeBus()
|
||||||
sessions_dir = tmp_path / "sessions"
|
|
||||||
sessions_dir.mkdir()
|
|
||||||
loop = SimpleNamespace(
|
loop = SimpleNamespace(
|
||||||
bus=bus,
|
bus=bus,
|
||||||
context=SimpleNamespace(memory=store, timezone="UTC"),
|
context=SimpleNamespace(memory=store, timezone="UTC"),
|
||||||
sessions=SimpleNamespace(sessions_dir=sessions_dir),
|
sessions=_make_sessions(tmp_path),
|
||||||
)
|
)
|
||||||
ctx = CommandContext(msg=msg, session=None, key=msg.session_key, raw="/dream", args="", loop=loop)
|
ctx = CommandContext(msg=msg, session=None, key=msg.session_key, raw="/dream", args="", loop=loop)
|
||||||
return ctx, bus
|
return ctx, bus
|
||||||
@@ -169,13 +175,11 @@ async def test_dream_internal_run_silences_progress(tmp_path) -> None:
|
|||||||
metadata={"_stop_reason": "completed"},
|
metadata={"_stop_reason": "completed"},
|
||||||
)
|
)
|
||||||
|
|
||||||
sessions_dir = tmp_path / "sessions"
|
|
||||||
sessions_dir.mkdir()
|
|
||||||
dream_runtime = object()
|
dream_runtime = object()
|
||||||
loop = SimpleNamespace(
|
loop = SimpleNamespace(
|
||||||
bus=bus,
|
bus=bus,
|
||||||
context=SimpleNamespace(memory=store, timezone="UTC"),
|
context=SimpleNamespace(memory=store, timezone="UTC"),
|
||||||
sessions=SimpleNamespace(sessions_dir=sessions_dir),
|
sessions=_make_sessions(tmp_path),
|
||||||
process_direct=process_direct,
|
process_direct=process_direct,
|
||||||
dream_runtime=lambda: dream_runtime,
|
dream_runtime=lambda: dream_runtime,
|
||||||
)
|
)
|
||||||
@@ -224,12 +228,10 @@ def _build_runnable_dream(
|
|||||||
)
|
)
|
||||||
|
|
||||||
bus = _FakeBus()
|
bus = _FakeBus()
|
||||||
sessions_dir = tmp_path / "sessions"
|
|
||||||
sessions_dir.mkdir()
|
|
||||||
loop = SimpleNamespace(
|
loop = SimpleNamespace(
|
||||||
bus=bus,
|
bus=bus,
|
||||||
context=SimpleNamespace(memory=store, timezone="UTC"),
|
context=SimpleNamespace(memory=store, timezone="UTC"),
|
||||||
sessions=SimpleNamespace(sessions_dir=sessions_dir),
|
sessions=_make_sessions(tmp_path),
|
||||||
process_direct=process_direct,
|
process_direct=process_direct,
|
||||||
dream_runtime=lambda: None,
|
dream_runtime=lambda: None,
|
||||||
)
|
)
|
||||||
@@ -311,12 +313,10 @@ async def test_dream_noop_batch_unlocks_following_history(tmp_path) -> None:
|
|||||||
|
|
||||||
msg = InboundMessage(channel="cli", sender_id="u1", chat_id="direct", content="/dream")
|
msg = InboundMessage(channel="cli", sender_id="u1", chat_id="direct", content="/dream")
|
||||||
bus = _FakeBus()
|
bus = _FakeBus()
|
||||||
sessions_dir = tmp_path / "sessions"
|
|
||||||
sessions_dir.mkdir()
|
|
||||||
loop = SimpleNamespace(
|
loop = SimpleNamespace(
|
||||||
bus=bus,
|
bus=bus,
|
||||||
context=SimpleNamespace(memory=store, timezone="UTC"),
|
context=SimpleNamespace(memory=store, timezone="UTC"),
|
||||||
sessions=SimpleNamespace(sessions_dir=sessions_dir),
|
sessions=_make_sessions(tmp_path),
|
||||||
process_direct=process_direct,
|
process_direct=process_direct,
|
||||||
dream_runtime=lambda: None,
|
dream_runtime=lambda: None,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import errno
|
|||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import patch
|
from unittest.mock import call, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -65,6 +65,7 @@ class TestSaveFsync:
|
|||||||
session.add_message("user", "hello")
|
session.add_message("user", "hello")
|
||||||
directory_fd = 987654
|
directory_fd = 987654
|
||||||
with (
|
with (
|
||||||
|
manager.locked_session_files(),
|
||||||
patch("nanobot.session.manager.os.open", return_value=directory_fd) as open_dir,
|
patch("nanobot.session.manager.os.open", return_value=directory_fd) as open_dir,
|
||||||
patch(
|
patch(
|
||||||
"nanobot.session.manager.os.fsync",
|
"nanobot.session.manager.os.fsync",
|
||||||
@@ -76,7 +77,7 @@ class TestSaveFsync:
|
|||||||
|
|
||||||
assert manager._get_session_path(session.key).exists()
|
assert manager._get_session_path(session.key).exists()
|
||||||
open_dir.assert_called_once_with(str(manager.sessions_dir), os.O_RDONLY)
|
open_dir.assert_called_once_with(str(manager.sessions_dir), os.O_RDONLY)
|
||||||
close_dir.assert_called_once_with(directory_fd)
|
assert close_dir.call_args_list.count(call(directory_fd)) == 1
|
||||||
|
|
||||||
def test_save_propagates_other_directory_fsync_errors(
|
def test_save_propagates_other_directory_fsync_errors(
|
||||||
self, manager: SessionManager
|
self, manager: SessionManager
|
||||||
@@ -85,6 +86,7 @@ class TestSaveFsync:
|
|||||||
session = manager.get_or_create("test:directory-fsync-io-error")
|
session = manager.get_or_create("test:directory-fsync-io-error")
|
||||||
directory_fd = 987654
|
directory_fd = 987654
|
||||||
with (
|
with (
|
||||||
|
manager.locked_session_files(),
|
||||||
patch("nanobot.session.manager.os.open", return_value=directory_fd),
|
patch("nanobot.session.manager.os.open", return_value=directory_fd),
|
||||||
patch(
|
patch(
|
||||||
"nanobot.session.manager.os.fsync",
|
"nanobot.session.manager.os.fsync",
|
||||||
@@ -95,7 +97,7 @@ class TestSaveFsync:
|
|||||||
):
|
):
|
||||||
manager.save(session, fsync=True)
|
manager.save(session, fsync=True)
|
||||||
|
|
||||||
close_dir.assert_called_once_with(directory_fd)
|
assert close_dir.call_args_list.count(call(directory_fd)) == 1
|
||||||
|
|
||||||
|
|
||||||
class TestFlushAll:
|
class TestFlushAll:
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
import nanobot.session as session_api
|
import nanobot.session as session_api
|
||||||
from nanobot.session import Session, SessionManager
|
from nanobot.session import Session, SessionManager
|
||||||
from nanobot.session.manager import FILE_MAX_MESSAGES, SessionStore
|
from nanobot.session.manager import FILE_MAX_MESSAGES, SessionStore
|
||||||
@@ -77,3 +79,31 @@ def test_manager_applies_file_cap_before_store_save(tmp_path) -> None:
|
|||||||
assert len(session.messages) == FILE_MAX_MESSAGES
|
assert len(session.messages) == FILE_MAX_MESSAGES
|
||||||
archiver.assert_called_once()
|
archiver.assert_called_once()
|
||||||
store.save.assert_called_once_with(session, fsync=False)
|
store.save.assert_called_once_with(session, fsync=False)
|
||||||
|
|
||||||
|
|
||||||
|
def test_manager_retries_file_cap_archive_after_failure(tmp_path) -> None:
|
||||||
|
store = MagicMock(spec=SessionStore)
|
||||||
|
archiver = MagicMock(side_effect=[RuntimeError("history unavailable"), None])
|
||||||
|
manager = SessionManager(tmp_path, store=store)
|
||||||
|
manager.set_file_cap_archiver(archiver)
|
||||||
|
session = Session(
|
||||||
|
key="cli:retry-large",
|
||||||
|
messages=[
|
||||||
|
{"role": "user", "content": str(index)}
|
||||||
|
for index in range(FILE_MAX_MESSAGES + 1)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="history unavailable"):
|
||||||
|
manager.save(session)
|
||||||
|
|
||||||
|
assert len(session.messages) == FILE_MAX_MESSAGES + 1
|
||||||
|
store.save.assert_not_called()
|
||||||
|
|
||||||
|
manager.save(session)
|
||||||
|
|
||||||
|
assert len(session.messages) == FILE_MAX_MESSAGES
|
||||||
|
assert archiver.call_count == 2
|
||||||
|
assert archiver.call_args_list[0].args[0][0]["content"] == "0"
|
||||||
|
assert archiver.call_args_list[1].args[0][0]["content"] == "0"
|
||||||
|
store.save.assert_called_once_with(session, fsync=False)
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from nanobot.webui import native_folder_picker as picker
|
||||||
|
|
||||||
|
|
||||||
|
def _picker_command(tmp_path: Path, body: str) -> picker._PickerCommand:
|
||||||
|
script = tmp_path / "picker.py"
|
||||||
|
script.write_text(f"{body}\n", encoding="utf-8")
|
||||||
|
return picker._PickerCommand((sys.executable, str(script)), frozenset({1}))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_pick_native_folder_returns_selected_directory(tmp_path, monkeypatch) -> None:
|
||||||
|
selected = tmp_path / "project"
|
||||||
|
selected.mkdir()
|
||||||
|
command = _picker_command(tmp_path, f"print({str(selected)!r}, end='')")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
picker,
|
||||||
|
"_picker_command",
|
||||||
|
lambda: command,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert await picker.pick_native_folder() == str(selected)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_pick_native_folder_uses_secret_free_environment(tmp_path, monkeypatch) -> None:
|
||||||
|
selected = tmp_path / "project"
|
||||||
|
selected.mkdir()
|
||||||
|
command = _picker_command(tmp_path, f"print({str(selected)!r}, end='')")
|
||||||
|
monkeypatch.setattr(picker, "_picker_command", lambda: command)
|
||||||
|
monkeypatch.setenv("HOME", str(tmp_path))
|
||||||
|
monkeypatch.setenv("OPENAI_API_KEY", "must-not-reach-picker")
|
||||||
|
original_spawn = picker.asyncio.create_subprocess_exec
|
||||||
|
captured_env: dict[str, str] = {}
|
||||||
|
|
||||||
|
async def capture_spawn(*args, **kwargs):
|
||||||
|
captured_env.update(kwargs["env"])
|
||||||
|
return await original_spawn(*args, **kwargs)
|
||||||
|
|
||||||
|
monkeypatch.setattr(picker.asyncio, "create_subprocess_exec", capture_spawn)
|
||||||
|
|
||||||
|
assert await picker.pick_native_folder() == str(selected)
|
||||||
|
assert captured_env["HOME"] == str(tmp_path)
|
||||||
|
assert "OPENAI_API_KEY" not in captured_env
|
||||||
|
|
||||||
|
|
||||||
|
def test_picker_environment_preserves_linux_display_context(monkeypatch) -> None:
|
||||||
|
monkeypatch.setattr(picker.sys, "platform", "linux")
|
||||||
|
monkeypatch.setenv("DISPLAY", ":42")
|
||||||
|
monkeypatch.setenv("DBUS_SESSION_BUS_ADDRESS", "unix:path=/run/user/test/bus")
|
||||||
|
monkeypatch.setenv("ANTHROPIC_API_KEY", "must-not-reach-picker")
|
||||||
|
|
||||||
|
environment = picker._picker_environment()
|
||||||
|
|
||||||
|
assert environment["DISPLAY"] == ":42"
|
||||||
|
assert environment["DBUS_SESSION_BUS_ADDRESS"] == "unix:path=/run/user/test/bus"
|
||||||
|
assert "ANTHROPIC_API_KEY" not in environment
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_pick_native_folder_maps_dialog_cancel_to_none(tmp_path, monkeypatch) -> None:
|
||||||
|
command = _picker_command(tmp_path, "raise SystemExit(1)")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
picker,
|
||||||
|
"_picker_command",
|
||||||
|
lambda: command,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert await picker.pick_native_folder() is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_pick_native_folder_rejects_non_directory_result(tmp_path, monkeypatch) -> None:
|
||||||
|
missing = tmp_path / "missing"
|
||||||
|
command = _picker_command(tmp_path, f"print({str(missing)!r}, end='')")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
picker,
|
||||||
|
"_picker_command",
|
||||||
|
lambda: command,
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(picker.NativeFolderPickerError, match="invalid directory"):
|
||||||
|
await picker.pick_native_folder()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_pick_native_folder_reports_unavailable(monkeypatch) -> None:
|
||||||
|
monkeypatch.setattr(picker, "_picker_command", lambda: None)
|
||||||
|
|
||||||
|
assert picker.native_folder_picker_available() is False
|
||||||
|
with pytest.raises(picker.NativeFolderPickerError, match="unavailable"):
|
||||||
|
await picker.pick_native_folder()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_pick_native_folder_wraps_process_start_failure(tmp_path, monkeypatch) -> None:
|
||||||
|
command = _picker_command(tmp_path, "raise AssertionError('not started')")
|
||||||
|
monkeypatch.setattr(picker, "_picker_command", lambda: command)
|
||||||
|
|
||||||
|
async def fail_spawn(*args, **kwargs):
|
||||||
|
raise OSError("executable disappeared")
|
||||||
|
|
||||||
|
monkeypatch.setattr(picker.asyncio, "create_subprocess_exec", fail_spawn)
|
||||||
|
|
||||||
|
with pytest.raises(picker.NativeFolderPickerError, match="failed to start"):
|
||||||
|
await picker.pick_native_folder()
|
||||||
@@ -72,6 +72,7 @@ def test_workspace_payload_is_config_data_dir_scoped(tmp_path, monkeypatch) -> N
|
|||||||
assert payload["default_scope"]["access_mode"] == "full"
|
assert payload["default_scope"]["access_mode"] == "full"
|
||||||
assert payload["default_access_mode"] == "default"
|
assert payload["default_access_mode"] == "default"
|
||||||
assert payload["controls"]["can_change_project"] is True
|
assert payload["controls"]["can_change_project"] is True
|
||||||
|
assert payload["controls"]["can_pick_folder"] is False
|
||||||
|
|
||||||
|
|
||||||
def test_workspace_payload_hides_mutable_state_when_controls_unavailable(
|
def test_workspace_payload_hides_mutable_state_when_controls_unavailable(
|
||||||
@@ -91,6 +92,22 @@ def test_workspace_payload_hides_mutable_state_when_controls_unavailable(
|
|||||||
assert payload["default_scope"]["project_path"] == str(default.resolve())
|
assert payload["default_scope"]["project_path"] == str(default.resolve())
|
||||||
assert payload["controls"]["can_change_project"] is False
|
assert payload["controls"]["can_change_project"] is False
|
||||||
assert payload["controls"]["can_use_full_access"] is False
|
assert payload["controls"]["can_use_full_access"] is False
|
||||||
|
assert payload["controls"]["can_pick_folder"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_workspace_payload_advertises_native_folder_picker(tmp_path, monkeypatch) -> None:
|
||||||
|
monkeypatch.setattr("nanobot.webui.workspaces.get_webui_dir", lambda: tmp_path / "webui")
|
||||||
|
default = tmp_path / "default"
|
||||||
|
default.mkdir()
|
||||||
|
|
||||||
|
payload = workspaces_payload(
|
||||||
|
default_workspace=default,
|
||||||
|
default_restrict_to_workspace=False,
|
||||||
|
controls_available=True,
|
||||||
|
folder_picker_available=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert payload["controls"]["can_pick_folder"] is True
|
||||||
|
|
||||||
|
|
||||||
def test_workspace_payload_uses_webui_default_access_mode(tmp_path, monkeypatch) -> None:
|
def test_workspace_payload_uses_webui_default_access_mode(tmp_path, monkeypatch) -> None:
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ from __future__ import annotations
|
|||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import threading
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -50,6 +52,20 @@ def test_webui_session_list_reuses_valid_index_without_scanning_files(
|
|||||||
assert rows[0]["model_preset"] == "fast"
|
assert rows[0]["model_preset"] == "fast"
|
||||||
|
|
||||||
|
|
||||||
|
def test_webui_session_index_uses_unique_temp_file(tmp_path: Path) -> None:
|
||||||
|
manager = SessionManager(tmp_path)
|
||||||
|
session = manager.get_or_create("websocket:unique-index-temp")
|
||||||
|
session.add_message("user", "hello")
|
||||||
|
manager.save(session)
|
||||||
|
stale_shared_tmp = manager.sessions_dir / ".webui_session_index.json.tmp"
|
||||||
|
stale_shared_tmp.write_text("stale", encoding="utf-8")
|
||||||
|
|
||||||
|
assert list_webui_sessions(manager)[0]["preview"] == "hello"
|
||||||
|
|
||||||
|
assert stale_shared_tmp.read_text(encoding="utf-8") == "stale"
|
||||||
|
assert not list(manager.sessions_dir.glob(".webui_session_index.json.*.tmp"))
|
||||||
|
|
||||||
|
|
||||||
def test_webui_session_list_indexes_workspace_scope_and_preserves_null(
|
def test_webui_session_list_indexes_workspace_scope_and_preserves_null(
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -133,6 +149,88 @@ def test_webui_session_list_does_not_cache_old_snapshot_with_new_signature(
|
|||||||
assert session_list_index.indexed_workspace_scope(second)[1]["access_mode"] == "restricted"
|
assert session_list_index.indexed_workspace_scope(second)[1]["access_mode"] == "restricted"
|
||||||
|
|
||||||
|
|
||||||
|
def test_webui_session_scan_does_not_overlap_session_save(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
manager = SessionManager(
|
||||||
|
tmp_path / "workspace",
|
||||||
|
sessions_root=tmp_path / "runtime",
|
||||||
|
)
|
||||||
|
session = manager.get_or_create("websocket:windows-reader")
|
||||||
|
session.add_message("user", "before")
|
||||||
|
manager.save(session)
|
||||||
|
session_path = manager._get_session_path(session.key)
|
||||||
|
session.messages[0]["content"] = "after"
|
||||||
|
|
||||||
|
reader_open = threading.Event()
|
||||||
|
release_reader = threading.Event()
|
||||||
|
save_started = threading.Event()
|
||||||
|
save_lock_attempted = threading.Event()
|
||||||
|
write_entered = threading.Event()
|
||||||
|
original_open = open
|
||||||
|
store = manager._jsonl_store
|
||||||
|
original_acquire = store._session_files_lock.acquire
|
||||||
|
original_save_unlocked = store._save_unlocked
|
||||||
|
|
||||||
|
class BlockingReader:
|
||||||
|
def __init__(self, file):
|
||||||
|
self.file = file
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
entered = self.file.__enter__()
|
||||||
|
reader_open.set()
|
||||||
|
if not release_reader.wait(5):
|
||||||
|
raise AssertionError("timed out waiting to release the session reader")
|
||||||
|
return entered
|
||||||
|
|
||||||
|
def __exit__(self, *args):
|
||||||
|
try:
|
||||||
|
return self.file.__exit__(*args)
|
||||||
|
finally:
|
||||||
|
reader_open.clear()
|
||||||
|
|
||||||
|
def blocking_open(path, *args, **kwargs):
|
||||||
|
file = original_open(path, *args, **kwargs)
|
||||||
|
if Path(path) == session_path:
|
||||||
|
return BlockingReader(file)
|
||||||
|
return file
|
||||||
|
|
||||||
|
def observed_acquire(*args, **kwargs):
|
||||||
|
if save_started.is_set():
|
||||||
|
save_lock_attempted.set()
|
||||||
|
return original_acquire(*args, **kwargs)
|
||||||
|
|
||||||
|
def observed_save_unlocked(session, *, fsync=False):
|
||||||
|
write_entered.set()
|
||||||
|
assert not reader_open.is_set(), "save entered while the canonical file was open"
|
||||||
|
return original_save_unlocked(session, fsync=fsync)
|
||||||
|
|
||||||
|
monkeypatch.setattr(session_list_index, "open", blocking_open, raising=False)
|
||||||
|
monkeypatch.setattr(store._session_files_lock, "acquire", observed_acquire)
|
||||||
|
monkeypatch.setattr(store, "_save_unlocked", observed_save_unlocked)
|
||||||
|
|
||||||
|
def save_session() -> None:
|
||||||
|
save_started.set()
|
||||||
|
manager.save(session)
|
||||||
|
|
||||||
|
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||||
|
list_future = executor.submit(list_webui_sessions, manager)
|
||||||
|
try:
|
||||||
|
assert reader_open.wait(5)
|
||||||
|
save_future = executor.submit(save_session)
|
||||||
|
assert save_lock_attempted.wait(5)
|
||||||
|
assert not write_entered.is_set()
|
||||||
|
finally:
|
||||||
|
release_reader.set()
|
||||||
|
|
||||||
|
assert list_future.result(timeout=5)[0]["preview"] == "before"
|
||||||
|
save_future.result(timeout=5)
|
||||||
|
|
||||||
|
assert write_entered.is_set()
|
||||||
|
assert list_webui_sessions(manager)[0]["preview"] == "after"
|
||||||
|
|
||||||
|
|
||||||
def test_webui_session_list_rejects_invalid_internal_model_preset_metadata(
|
def test_webui_session_list_rejects_invalid_internal_model_preset_metadata(
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
+4
-6
@@ -563,7 +563,7 @@ function PairingCodePopup({
|
|||||||
aria-label={t("app.pairing.title", { defaultValue: "Pair a chat user" })}
|
aria-label={t("app.pairing.title", { defaultValue: "Pair a chat user" })}
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed right-4 top-[calc(0.75rem+env(safe-area-inset-top))] z-[70]",
|
"fixed right-4 top-[calc(0.75rem+env(safe-area-inset-top))] z-[70]",
|
||||||
"w-[min(calc(100vw-2rem),24rem)] rounded-[24px]",
|
"w-[min(calc(100vw-2rem),24rem)] rounded-modal",
|
||||||
floatingSurfaceElevationClassName,
|
floatingSurfaceElevationClassName,
|
||||||
"p-4",
|
"p-4",
|
||||||
"animate-in fade-in-0 slide-in-from-top-2 duration-200",
|
"animate-in fade-in-0 slide-in-from-top-2 duration-200",
|
||||||
@@ -2718,7 +2718,6 @@ function Shell({
|
|||||||
addPaneDisabled={creatingPane || activePaneLimitReached}
|
addPaneDisabled={creatingPane || activePaneLimitReached}
|
||||||
addPaneDisabledLabel={activePaneLimitReached
|
addPaneDisabledLabel={activePaneLimitReached
|
||||||
? t("workbench.paneLimit", {
|
? t("workbench.paneLimit", {
|
||||||
defaultValue: "Maximum {{count}} panes",
|
|
||||||
count: MAX_WORKBENCH_PANES,
|
count: MAX_WORKBENCH_PANES,
|
||||||
})
|
})
|
||||||
: undefined}
|
: undefined}
|
||||||
@@ -2812,7 +2811,6 @@ function Shell({
|
|||||||
composerPortalTarget={context.composerPortalTarget}
|
composerPortalTarget={context.composerPortalTarget}
|
||||||
composerActive={context.active}
|
composerActive={context.active}
|
||||||
composerInputAriaLabel={t("workbench.composerAria", {
|
composerInputAriaLabel={t("workbench.composerAria", {
|
||||||
defaultValue: "Message {{title}}",
|
|
||||||
title: pane.title,
|
title: pane.title,
|
||||||
})}
|
})}
|
||||||
emptyComposerVariant="thread"
|
emptyComposerVariant="thread"
|
||||||
@@ -2892,9 +2890,9 @@ function Shell({
|
|||||||
<RenameChatDialog
|
<RenameChatDialog
|
||||||
open
|
open
|
||||||
title={pendingTabRename.label}
|
title={pendingTabRename.label}
|
||||||
dialogTitle={t("workbench.renameTabTitle")}
|
dialogTitle={t("workbench.renameGroupTitle")}
|
||||||
description={t("workbench.renameTabDescription")}
|
description={t("workbench.renameGroupDescription")}
|
||||||
placeholder={t("workbench.renameTabPlaceholder")}
|
placeholder={t("workbench.renameGroupPlaceholder")}
|
||||||
onCancel={() => setPendingTabRename(null)}
|
onCancel={() => setPendingTabRename(null)}
|
||||||
onConfirm={onConfirmTabRename}
|
onConfirm={onConfirmTabRename}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ export function AttachmentTile({ attachment, className, inline = false, variant
|
|||||||
title={attachment.name ?? undefined}
|
title={attachment.name ?? undefined}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex max-w-[18rem] items-center gap-2 rounded-[14px]",
|
"flex max-w-[18rem] items-center gap-2 rounded-control",
|
||||||
"border border-border/60 bg-muted/40 px-3 py-2 text-xs text-muted-foreground",
|
"border border-border/60 bg-muted/40 px-3 py-2 text-xs text-muted-foreground",
|
||||||
"transition-colors hover:bg-muted/55 hover:text-foreground",
|
"transition-colors hover:bg-muted/55 hover:text-foreground",
|
||||||
variant === "compact" && "max-w-[14rem] rounded-xl px-2.5 py-1.5 text-[11.5px]",
|
variant === "compact" && "max-w-[14rem] rounded-xl px-2.5 py-1.5 text-[11.5px]",
|
||||||
@@ -109,7 +109,7 @@ export function AttachmentTile({ attachment, className, inline = false, variant
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex max-w-[18rem] items-center gap-2 rounded-[14px]",
|
"flex max-w-[18rem] items-center gap-2 rounded-control",
|
||||||
"border border-border/60 bg-muted/35 px-3 py-2 text-xs text-muted-foreground",
|
"border border-border/60 bg-muted/35 px-3 py-2 text-xs text-muted-foreground",
|
||||||
variant === "compact" && "max-w-[14rem] rounded-xl px-2.5 py-1.5 text-[11.5px]",
|
variant === "compact" && "max-w-[14rem] rounded-xl px-2.5 py-1.5 text-[11.5px]",
|
||||||
className,
|
className,
|
||||||
@@ -139,7 +139,7 @@ function AttachmentFrame({
|
|||||||
variant?: "default" | "compact";
|
variant?: "default" | "compact";
|
||||||
}) {
|
}) {
|
||||||
const frameClassName = cn(
|
const frameClassName = cn(
|
||||||
"not-prose my-3 block w-fit max-w-full overflow-hidden rounded-[14px]",
|
"not-prose my-3 block w-fit max-w-full overflow-hidden rounded-control",
|
||||||
"border border-border/60 bg-muted/40",
|
"border border-border/60 bg-muted/40",
|
||||||
attachment.kind === "image" && "bg-background/85",
|
attachment.kind === "image" && "bg-background/85",
|
||||||
attachment.kind === "video" ? "w-[min(100%,32rem)]" : "",
|
attachment.kind === "video" ? "w-[min(100%,32rem)]" : "",
|
||||||
|
|||||||
+432
-103
File diff suppressed because it is too large
Load Diff
@@ -203,7 +203,7 @@ export function CliAppMentionToken({
|
|||||||
<span
|
<span
|
||||||
data-testid={`${testIdPrefix}-cli-mention-logo-${app.name}`}
|
data-testid={`${testIdPrefix}-cli-mention-logo-${app.name}`}
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute left-1/2 top-1/2 grid place-items-center overflow-hidden rounded-[3px]",
|
"absolute left-1/2 top-1/2 grid place-items-center overflow-hidden rounded-mark",
|
||||||
"-translate-x-1/2 -translate-y-1/2",
|
"-translate-x-1/2 -translate-y-1/2",
|
||||||
isHero ? "h-[0.74em] w-[0.74em]" : "h-[0.72em] w-[0.72em]",
|
isHero ? "h-[0.74em] w-[0.74em]" : "h-[0.72em] w-[0.72em]",
|
||||||
)}
|
)}
|
||||||
@@ -260,7 +260,7 @@ export function McpPresetMentionToken({
|
|||||||
<span
|
<span
|
||||||
data-testid={`${testIdPrefix}-mcp-mention-logo-${preset.name}`}
|
data-testid={`${testIdPrefix}-mcp-mention-logo-${preset.name}`}
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute left-1/2 top-1/2 grid place-items-center overflow-hidden rounded-[3px]",
|
"absolute left-1/2 top-1/2 grid place-items-center overflow-hidden rounded-mark",
|
||||||
"-translate-x-1/2 -translate-y-1/2",
|
"-translate-x-1/2 -translate-y-1/2",
|
||||||
isHero ? "h-[0.74em] w-[0.74em]" : "h-[0.72em] w-[0.72em]",
|
isHero ? "h-[0.74em] w-[0.74em]" : "h-[0.72em] w-[0.72em]",
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -202,7 +202,7 @@ export function CodeBlock({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"not-prose relative overflow-hidden",
|
"not-prose relative overflow-hidden",
|
||||||
hasChrome && "rounded-[18px] bg-secondary/70",
|
hasChrome && "rounded-floating bg-secondary/70",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
data-language={language || t("code.fallbackLanguage")}
|
data-language={language || t("code.fallbackLanguage")}
|
||||||
|
|||||||
@@ -44,11 +44,11 @@ export function DeleteConfirm({
|
|||||||
className="w-[min(calc(100vw-2rem),24rem)] gap-0 p-5 text-center"
|
className="w-[min(calc(100vw-2rem),24rem)] gap-0 p-5 text-center"
|
||||||
>
|
>
|
||||||
<AlertDialogHeader className="items-center space-y-0 text-center">
|
<AlertDialogHeader className="items-center space-y-0 text-center">
|
||||||
<div className="mb-5 grid h-16 w-16 place-items-center rounded-full bg-destructive/10 text-destructive">
|
<Trash2
|
||||||
<div className="grid h-9 w-9 place-items-center rounded-full border border-destructive/20 bg-destructive/5">
|
className="mb-4 h-6 w-6 text-destructive"
|
||||||
<Trash2 className="h-5 w-5" strokeWidth={2.4} aria-hidden />
|
strokeWidth={1.8}
|
||||||
</div>
|
aria-hidden
|
||||||
</div>
|
/>
|
||||||
<AlertDialogTitle className="text-center text-[20px] font-semibold leading-tight tracking-[-0.02em] text-foreground">
|
<AlertDialogTitle className="text-center text-[20px] font-semibold leading-tight tracking-[-0.02em] text-foreground">
|
||||||
{multiple
|
{multiple
|
||||||
? t("deleteConfirm.titleMany", {
|
? t("deleteConfirm.titleMany", {
|
||||||
@@ -96,16 +96,16 @@ export function DeleteConfirm({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter className="mt-7 !grid grid-cols-1 gap-3 space-x-0 sm:grid-cols-2 sm:space-x-0">
|
<AlertDialogFooter className="mt-6 !grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||||
<AlertDialogCancel
|
<AlertDialogCancel
|
||||||
onClick={onCancel}
|
onClick={onCancel}
|
||||||
className="mt-0 h-11 w-full min-w-0 rounded-full border-0 bg-muted/70 px-5 text-[15px] font-semibold text-foreground shadow-none hover:bg-muted"
|
className="mt-0 w-full min-w-0"
|
||||||
>
|
>
|
||||||
{t("deleteConfirm.cancel")}
|
{t("deleteConfirm.cancel")}
|
||||||
</AlertDialogCancel>
|
</AlertDialogCancel>
|
||||||
<AlertDialogAction
|
<AlertDialogAction
|
||||||
onClick={onConfirm}
|
onClick={onConfirm}
|
||||||
className="h-11 w-full min-w-0 !whitespace-normal rounded-full bg-destructive px-5 text-center text-[15px] font-semibold text-destructive-foreground shadow-none hover:bg-destructive/90"
|
className="w-full min-w-0 !whitespace-normal bg-destructive text-center text-destructive-foreground hover:bg-destructive/90"
|
||||||
>
|
>
|
||||||
{hasAutomations
|
{hasAutomations
|
||||||
? t("deleteConfirm.confirmWithAutomations")
|
? t("deleteConfirm.confirmWithAutomations")
|
||||||
|
|||||||
@@ -183,7 +183,7 @@ export function FilePreviewPanel({
|
|||||||
) : null}
|
) : null}
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"min-w-0 truncate rounded-[4px] px-1 py-0.5",
|
"min-w-0 truncate rounded-mark px-1 py-0.5",
|
||||||
isLast
|
isLast
|
||||||
? "font-medium text-foreground"
|
? "font-medium text-foreground"
|
||||||
: "max-w-[26vw] shrink text-muted-foreground/78",
|
: "max-w-[26vw] shrink text-muted-foreground/78",
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ export function FileReferenceChip({
|
|||||||
"text-sky-600 transition-colors hover:text-sky-700",
|
"text-sky-600 transition-colors hover:text-sky-700",
|
||||||
"dark:text-sky-300 dark:hover:text-sky-200",
|
"dark:text-sky-300 dark:hover:text-sky-200",
|
||||||
interactive && [
|
interactive && [
|
||||||
"cursor-pointer rounded-[5px]",
|
"cursor-pointer rounded-compact",
|
||||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-400/45",
|
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-400/45",
|
||||||
],
|
],
|
||||||
)}
|
)}
|
||||||
@@ -110,7 +110,7 @@ export function FileReferenceChip({
|
|||||||
sideOffset={8}
|
sideOffset={8}
|
||||||
collisionPadding={12}
|
collisionPadding={12}
|
||||||
className={cn(
|
className={cn(
|
||||||
"max-w-[min(38rem,calc(100vw-2rem))] rounded-[10px]",
|
"max-w-[min(38rem,calc(100vw-2rem))] rounded-control",
|
||||||
"px-2.5 py-1.5",
|
"px-2.5 py-1.5",
|
||||||
"break-all font-mono text-[11px] leading-snug text-popover-foreground",
|
"break-all font-mono text-[11px] leading-snug text-popover-foreground",
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ export function ImageLightbox({
|
|||||||
alt={current.name ?? ""}
|
alt={current.name ?? ""}
|
||||||
decoding="async"
|
decoding="async"
|
||||||
draggable={false}
|
draggable={false}
|
||||||
className="max-h-[92vh] max-w-[94vw] select-none rounded-[6px] object-contain shadow-2xl"
|
className="max-h-[92vh] max-w-[94vw] select-none rounded-compact object-contain shadow-2xl"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -450,7 +450,7 @@ function InlineLinkPreviewRow({ link }: { link: InlineLinkPreview }) {
|
|||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative grid h-4 w-4 shrink-0 place-items-center overflow-hidden rounded-[4px]",
|
"relative grid h-4 w-4 shrink-0 place-items-center overflow-hidden rounded-mark",
|
||||||
"border border-border/65 bg-background text-muted-foreground",
|
"border border-border/65 bg-background text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
aria-hidden
|
aria-hidden
|
||||||
@@ -459,7 +459,7 @@ function InlineLinkPreviewRow({ link }: { link: InlineLinkPreview }) {
|
|||||||
<img
|
<img
|
||||||
src={favicon}
|
src={favicon}
|
||||||
alt=""
|
alt=""
|
||||||
className="h-3 w-3 rounded-[2px] object-contain"
|
className="h-3 w-3 rounded-mark object-contain"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
referrerPolicy="no-referrer"
|
referrerPolicy="no-referrer"
|
||||||
@@ -745,7 +745,7 @@ export default function MarkdownTextRenderer({
|
|||||||
},
|
},
|
||||||
mark({ children: markdownChildren }) {
|
mark({ children: markdownChildren }) {
|
||||||
return (
|
return (
|
||||||
<mark className="rounded-[5px] bg-yellow-200/75 px-1 py-0.5 text-inherit dark:bg-yellow-300/25">
|
<mark className="rounded-compact bg-yellow-200/75 px-1 py-0.5 text-inherit dark:bg-yellow-300/25">
|
||||||
{markdownChildren}
|
{markdownChildren}
|
||||||
</mark>
|
</mark>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -331,7 +331,7 @@ export function MessageBubble({
|
|||||||
<p
|
<p
|
||||||
data-temporary-message={temporary ? "true" : undefined}
|
data-temporary-message={temporary ? "true" : undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
"ml-auto w-fit max-w-full min-w-0 rounded-[18px] px-4 py-2",
|
"ml-auto w-fit max-w-full min-w-0 rounded-floating px-4 py-2",
|
||||||
"text-left text-[16px]/[1.75] whitespace-pre-wrap [overflow-wrap:anywhere]",
|
"text-left text-[16px]/[1.75] whitespace-pre-wrap [overflow-wrap:anywhere]",
|
||||||
temporary
|
temporary
|
||||||
? "border border-dashed border-muted-foreground/40 bg-transparent"
|
? "border border-dashed border-muted-foreground/40 bg-transparent"
|
||||||
@@ -499,7 +499,7 @@ function UserQuotedContext({ text, label }: { text: string; label: string }) {
|
|||||||
return (
|
return (
|
||||||
<blockquote
|
<blockquote
|
||||||
className={cn(
|
className={cn(
|
||||||
"ml-auto flex w-fit max-w-full min-w-0 items-start gap-2 rounded-[14px]",
|
"ml-auto flex w-fit max-w-full min-w-0 items-start gap-2 rounded-control",
|
||||||
"border border-border/60 bg-muted/35 px-3 py-2 text-left text-muted-foreground",
|
"border border-border/60 bg-muted/35 px-3 py-2 text-left text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
@@ -718,8 +718,8 @@ function UserImageCell({
|
|||||||
const tileClasses = cn(
|
const tileClasses = cn(
|
||||||
"relative overflow-hidden border border-border/60 bg-muted/40",
|
"relative overflow-hidden border border-border/60 bg-muted/40",
|
||||||
size === "large"
|
size === "large"
|
||||||
? "w-[min(100%,34rem)] rounded-[20px] bg-transparent"
|
? "w-[min(100%,34rem)] rounded-panel bg-transparent"
|
||||||
: "h-24 w-24 rounded-[14px]",
|
: "h-24 w-24 rounded-control",
|
||||||
"shadow-[0_6px_18px_-14px_rgba(0,0,0,0.45)]",
|
"shadow-[0_6px_18px_-14px_rgba(0,0,0,0.45)]",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export function RenameChatDialog({
|
|||||||
<Dialog open={open} onOpenChange={(next) => {
|
<Dialog open={open} onOpenChange={(next) => {
|
||||||
if (!next) onCancel();
|
if (!next) onCancel();
|
||||||
}}>
|
}}>
|
||||||
<DialogContent className="max-w-sm p-5">
|
<DialogContent className="max-w-sm">
|
||||||
<form
|
<form
|
||||||
className="grid gap-4"
|
className="grid gap-4"
|
||||||
onSubmit={(event) => {
|
onSubmit={(event) => {
|
||||||
@@ -66,11 +66,16 @@ export function RenameChatDialog({
|
|||||||
autoFocus
|
autoFocus
|
||||||
maxLength={160}
|
maxLength={160}
|
||||||
/>
|
/>
|
||||||
<DialogFooter className="gap-2 sm:space-x-0">
|
<DialogFooter>
|
||||||
<Button type="button" variant="outline" onClick={onCancel}>
|
<Button
|
||||||
|
className="min-w-20"
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={onCancel}
|
||||||
|
>
|
||||||
{t("deleteConfirm.cancel")}
|
{t("deleteConfirm.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={!trimmed}>
|
<Button className="min-w-20" type="submit" disabled={!trimmed}>
|
||||||
{t("chat.renameSave")}
|
{t("chat.renameSave")}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|||||||
@@ -120,7 +120,6 @@ export function SessionSearchDialog({
|
|||||||
showCloseButton={false}
|
showCloseButton={false}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex max-h-[min(40rem,calc(100vh-2rem))] w-[calc(100vw-2rem)] max-w-[42rem] flex-col gap-0 overflow-hidden p-0",
|
"flex max-h-[min(40rem,calc(100vh-2rem))] w-[calc(100vw-2rem)] max-w-[42rem] flex-col gap-0 overflow-hidden p-0",
|
||||||
"rounded-[22px]",
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<DialogTitle className="sr-only">{t("sidebar.searchAria")}</DialogTitle>
|
<DialogTitle className="sr-only">{t("sidebar.searchAria")}</DialogTitle>
|
||||||
@@ -183,7 +182,7 @@ export function SessionSearchDialog({
|
|||||||
onMouseEnter={() => setHighlightedIndex(index)}
|
onMouseEnter={() => setHighlightedIndex(index)}
|
||||||
aria-current={active ? "page" : undefined}
|
aria-current={active ? "page" : undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
"grid min-h-[54px] w-full min-w-0 grid-cols-[minmax(0,1fr)_auto] items-center gap-3 rounded-[11px] px-3 py-2 text-left transition-colors",
|
"grid min-h-[54px] w-full min-w-0 grid-cols-[minmax(0,1fr)_auto] items-center gap-3 rounded-control px-3 py-2 text-left transition-colors",
|
||||||
highlighted
|
highlighted
|
||||||
? "bg-muted text-foreground"
|
? "bg-muted text-foreground"
|
||||||
: "text-foreground hover:bg-muted",
|
: "text-foreground hover:bg-muted",
|
||||||
|
|||||||
@@ -621,7 +621,7 @@ export function SettingsPage({
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex h-48 items-center justify-center rounded-[22px] bg-settings-surface text-sm text-muted-foreground">
|
<div className="flex h-48 items-center justify-center rounded-panel bg-settings-surface text-sm text-muted-foreground">
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
{t("settings.status.loading")}
|
{t("settings.status.loading")}
|
||||||
</div>
|
</div>
|
||||||
@@ -640,7 +640,7 @@ export function SettingsPage({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{error ? (
|
{error ? (
|
||||||
<div className="rounded-[18px] border border-destructive/20 bg-destructive/5 px-4 py-3 text-[13px] text-destructive">
|
<div className="rounded-floating border border-destructive/20 bg-destructive/5 px-4 py-3 text-[13px] text-destructive">
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ export function SettingsSidebar({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={`${t("settings.sidebar.title")}: ${activeLabel}`}
|
aria-label={`${t("settings.sidebar.title")}: ${activeLabel}`}
|
||||||
className="touch-target flex h-11 w-full items-center gap-2.5 rounded-[14px] bg-sidebar-accent px-3 text-left text-[13px] font-medium text-foreground transition-colors hover:bg-sidebar-accent/80 lg:hidden"
|
className="touch-target flex h-11 w-full items-center gap-2.5 rounded-control bg-sidebar-accent px-3 text-left text-[13px] font-medium text-foreground transition-colors hover:bg-sidebar-accent/80 lg:hidden"
|
||||||
>
|
>
|
||||||
<ActiveIcon className="h-4 w-4 shrink-0" strokeWidth={2} aria-hidden />
|
<ActiveIcon className="h-4 w-4 shrink-0" strokeWidth={2} aria-hidden />
|
||||||
<span className="min-w-0 flex-1 truncate">{activeLabel}</span>
|
<span className="min-w-0 flex-1 truncate">{activeLabel}</span>
|
||||||
@@ -176,7 +176,7 @@ export function SettingsSidebar({
|
|||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={onLogout}
|
onClick={onLogout}
|
||||||
className="h-9 w-full justify-start gap-2 rounded-[10px] px-2.5 text-[13px] font-medium text-muted-foreground hover:bg-destructive/8 hover:text-destructive"
|
className="h-9 w-full justify-start gap-2 rounded-control px-2.5 text-[13px] font-medium text-muted-foreground hover:bg-destructive/8 hover:text-destructive"
|
||||||
>
|
>
|
||||||
<LogOut className="h-4 w-4" aria-hidden />
|
<LogOut className="h-4 w-4" aria-hidden />
|
||||||
{t("app.account.logout")}
|
{t("app.account.logout")}
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ export function SkillsCatalogSettings({ skills }: { skills: SkillSummary[] }) {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{view === "installed" ? (
|
{view === "installed" ? (
|
||||||
<section className="overflow-hidden rounded-[22px] bg-settings-surface">
|
<section className="overflow-hidden rounded-panel bg-settings-surface">
|
||||||
<div className="flex flex-col gap-3 px-4 pb-2 pt-4 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex flex-col gap-3 px-4 pb-2 pt-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div className="relative w-full sm:max-w-[320px]">
|
<div className="relative w-full sm:max-w-[320px]">
|
||||||
<Search
|
<Search
|
||||||
@@ -114,7 +114,7 @@ export function SkillsCatalogSettings({ skills }: { skills: SkillSummary[] }) {
|
|||||||
aria-label={t("settings.skills.searchInstalled", {
|
aria-label={t("settings.skills.searchInstalled", {
|
||||||
defaultValue: "Search installed skills",
|
defaultValue: "Search installed skills",
|
||||||
})}
|
})}
|
||||||
className="h-9 rounded-[11px] bg-background pl-9 text-[13px]"
|
className="h-9 bg-background pl-9 text-[13px]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<SegmentedControl
|
<SegmentedControl
|
||||||
@@ -220,7 +220,7 @@ function SkillCatalogRow({
|
|||||||
})}
|
})}
|
||||||
onClick={() => onSelect(skill)}
|
onClick={() => onSelect(skill)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"group flex w-full min-w-0 items-center gap-3 rounded-[14px] px-2 py-3 text-left",
|
"group flex w-full min-w-0 items-center gap-3 rounded-control px-2 py-3 text-left",
|
||||||
"transition-colors duration-150",
|
"transition-colors duration-150",
|
||||||
"hover:bg-muted/70",
|
"hover:bg-muted/70",
|
||||||
"focus-visible:bg-muted/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
"focus-visible:bg-muted/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||||
@@ -436,7 +436,7 @@ function SkillDetailSheet({
|
|||||||
{t("settings.skills.loadingDetail", { defaultValue: "Loading skill details..." })}
|
{t("settings.skills.loadingDetail", { defaultValue: "Loading skill details..." })}
|
||||||
</div>
|
</div>
|
||||||
) : loadFailed ? (
|
) : loadFailed ? (
|
||||||
<div className="mt-8 rounded-[16px] bg-destructive/10 px-3 py-3 text-sm text-destructive">
|
<div className="mt-8 rounded-floating bg-destructive/10 px-3 py-3 text-sm text-destructive">
|
||||||
{t("settings.skills.loadFailed", { defaultValue: "Could not load skill details." })}
|
{t("settings.skills.loadFailed", { defaultValue: "Could not load skill details." })}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -484,7 +484,7 @@ function SkillDetailSheet({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{actionError ? (
|
{actionError ? (
|
||||||
<div className="rounded-[14px] bg-destructive/10 px-3 py-2.5 text-[13px] text-destructive">
|
<div className="rounded-control bg-destructive/10 px-3 py-2.5 text-[13px] text-destructive">
|
||||||
{actionError}
|
{actionError}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -533,7 +533,7 @@ function SkillDetailSheet({
|
|||||||
</Sheet>
|
</Sheet>
|
||||||
|
|
||||||
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||||
<AlertDialogContent className="rounded-[20px]">
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>
|
<AlertDialogTitle>
|
||||||
{t("settings.skills.deleteConfirmTitle", {
|
{t("settings.skills.deleteConfirmTitle", {
|
||||||
@@ -574,7 +574,7 @@ function RawInstructionsBlock({ markdown }: { markdown: string }) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<details className="group rounded-[18px] border border-border/45 bg-muted/20 px-3 py-3">
|
<details className="group rounded-floating border border-border/45 bg-muted/20 px-3 py-3">
|
||||||
<summary className="flex min-h-11 cursor-pointer select-none items-center justify-between gap-3 text-[13px] font-medium text-foreground/90 transition-colors hover:text-foreground">
|
<summary className="flex min-h-11 cursor-pointer select-none items-center justify-between gap-3 text-[13px] font-medium text-foreground/90 transition-colors hover:text-foreground">
|
||||||
<span>
|
<span>
|
||||||
{t("settings.skills.instructionsTitle", { defaultValue: "Skill instructions" })}
|
{t("settings.skills.instructionsTitle", { defaultValue: "Skill instructions" })}
|
||||||
@@ -583,7 +583,7 @@ function RawInstructionsBlock({ markdown }: { markdown: string }) {
|
|||||||
SKILL.md
|
SKILL.md
|
||||||
</code>
|
</code>
|
||||||
</summary>
|
</summary>
|
||||||
<div className="mt-3 overflow-hidden rounded-[14px] border border-border/35 bg-background/70">
|
<div className="mt-3 overflow-hidden rounded-control border border-border/35 bg-background/70">
|
||||||
<pre
|
<pre
|
||||||
className={cn(
|
className={cn(
|
||||||
"max-h-[min(42vh,32rem)] overflow-auto overscroll-contain px-3.5 py-3 pr-4",
|
"max-h-[min(42vh,32rem)] overflow-auto overscroll-contain px-3.5 py-3 pr-4",
|
||||||
@@ -625,7 +625,7 @@ function RequirementsSection({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="rounded-[18px] border border-amber-500/20 bg-amber-500/[0.06] p-4">
|
<section className="rounded-floating border border-amber-500/20 bg-amber-500/[0.06] p-4">
|
||||||
<div className="flex items-start gap-2.5">
|
<div className="flex items-start gap-2.5">
|
||||||
<CircleAlert
|
<CircleAlert
|
||||||
className="mt-0.5 h-4 w-4 shrink-0 text-amber-700 dark:text-amber-300"
|
className="mt-0.5 h-4 w-4 shrink-0 text-amber-700 dark:text-amber-300"
|
||||||
@@ -648,7 +648,7 @@ function RequirementsSection({
|
|||||||
{installOptions.map((option) => (
|
{installOptions.map((option) => (
|
||||||
<div
|
<div
|
||||||
key={`${option.id}:${option.command}`}
|
key={`${option.id}:${option.command}`}
|
||||||
className="flex min-w-0 items-center gap-2 rounded-[12px] bg-background/80 px-3 py-2"
|
className="flex min-w-0 items-center gap-2 rounded-control bg-background/80 px-3 py-2"
|
||||||
>
|
>
|
||||||
<Terminal className="h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden />
|
<Terminal className="h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden />
|
||||||
<code className="min-w-0 flex-1 overflow-x-auto whitespace-nowrap font-mono text-[11px] text-foreground/80">
|
<code className="min-w-0 flex-1 overflow-x-auto whitespace-nowrap font-mono text-[11px] text-foreground/80">
|
||||||
@@ -661,7 +661,7 @@ function RequirementsSection({
|
|||||||
})}
|
})}
|
||||||
title={option.label}
|
title={option.label}
|
||||||
onClick={() => void copyCommand(option.command)}
|
onClick={() => void copyCommand(option.command)}
|
||||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-[9px] text-muted-foreground transition-colors hover:bg-muted hover:text-foreground sm:h-7 sm:w-7"
|
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-control text-muted-foreground transition-colors hover:bg-muted hover:text-foreground sm:h-7 sm:w-7"
|
||||||
>
|
>
|
||||||
{copiedCommand === option.command ? (
|
{copiedCommand === option.command ? (
|
||||||
<Check className="h-3.5 w-3.5 text-emerald-600" aria-hidden />
|
<Check className="h-3.5 w-3.5 text-emerald-600" aria-hidden />
|
||||||
|
|||||||
@@ -208,7 +208,7 @@ export function SkillsMarketplace({
|
|||||||
aria-label={t("settings.skills.marketplaceSearchLabel", {
|
aria-label={t("settings.skills.marketplaceSearchLabel", {
|
||||||
defaultValue: "Search skills",
|
defaultValue: "Search skills",
|
||||||
})}
|
})}
|
||||||
className="h-11 rounded-[14px] bg-settings-surface pl-9"
|
className="h-11 rounded-control bg-settings-surface pl-9"
|
||||||
/>
|
/>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<span
|
<span
|
||||||
@@ -226,13 +226,13 @@ export function SkillsMarketplace({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error ? (
|
{error ? (
|
||||||
<div className="rounded-[14px] bg-destructive/10 px-3 py-2.5 text-[13px] text-destructive">
|
<div className="rounded-control bg-destructive/10 px-3 py-2.5 text-[13px] text-destructive">
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{query.trim().length < 2 ? (
|
{query.trim().length < 2 ? (
|
||||||
<section className="overflow-hidden rounded-[22px] bg-settings-surface">
|
<section className="overflow-hidden rounded-panel bg-settings-surface">
|
||||||
<div className="flex flex-col items-start gap-2 px-4 pb-2 pt-4 sm:flex-row sm:items-center sm:justify-between sm:px-5">
|
<div className="flex flex-col items-start gap-2 px-4 pb-2 pt-4 sm:flex-row sm:items-center sm:justify-between sm:px-5">
|
||||||
<h2 className="text-[14px] font-semibold">
|
<h2 className="text-[14px] font-semibold">
|
||||||
{t("settings.skills.marketplaceTrendingTitle", {
|
{t("settings.skills.marketplaceTrendingTitle", {
|
||||||
@@ -271,14 +271,14 @@ export function SkillsMarketplace({
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
) : !loading && visibleResults.length === 0 && !error ? (
|
) : !loading && visibleResults.length === 0 && !error ? (
|
||||||
<div className="rounded-[22px] bg-settings-surface px-5 py-12 text-center text-sm text-muted-foreground">
|
<div className="rounded-panel bg-settings-surface px-5 py-12 text-center text-sm text-muted-foreground">
|
||||||
{t("settings.skills.marketplaceEmpty", {
|
{t("settings.skills.marketplaceEmpty", {
|
||||||
query: query.trim(),
|
query: query.trim(),
|
||||||
defaultValue: "No skills found for “{{query}}”.",
|
defaultValue: "No skills found for “{{query}}”.",
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-hidden rounded-[22px] bg-settings-surface">
|
<div className="overflow-hidden rounded-panel bg-settings-surface">
|
||||||
<MarketplaceSkillGroups
|
<MarketplaceSkillGroups
|
||||||
skills={visibleResults}
|
skills={visibleResults}
|
||||||
installedNames={installedNames}
|
installedNames={installedNames}
|
||||||
@@ -296,9 +296,9 @@ export function SkillsMarketplace({
|
|||||||
if (!open) setSelected(null);
|
if (!open) setSelected(null);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<AlertDialogContent className="rounded-[20px]">
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<div className="mb-1 flex h-10 w-10 items-center justify-center rounded-[12px] bg-amber-500/10 text-amber-700 dark:text-amber-300">
|
<div className="mb-1 flex h-10 w-10 items-center justify-center rounded-control bg-amber-500/10 text-amber-700 dark:text-amber-300">
|
||||||
<ShieldAlert className="h-5 w-5" aria-hidden />
|
<ShieldAlert className="h-5 w-5" aria-hidden />
|
||||||
</div>
|
</div>
|
||||||
<AlertDialogTitle>
|
<AlertDialogTitle>
|
||||||
@@ -316,7 +316,7 @@ export function SkillsMarketplace({
|
|||||||
"This third-party skill comes from {{provider}} ({{source}}) and may include instructions or executable scripts.",
|
"This third-party skill comes from {{provider}} ({{source}}) and may include instructions or executable scripts.",
|
||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex flex-wrap items-center gap-2 rounded-md bg-muted px-2 py-1.5 text-[12px] text-foreground">
|
<span className="flex flex-wrap items-center gap-2 rounded-control bg-muted px-2 py-1.5 text-[12px] text-foreground">
|
||||||
{selected ? <ProviderMark provider={selected.provider} /> : null}
|
{selected ? <ProviderMark provider={selected.provider} /> : null}
|
||||||
<code>{selected?.source}</code>
|
<code>{selected?.source}</code>
|
||||||
{selected?.version ? <span>v{selected.version}</span> : null}
|
{selected?.version ? <span>v{selected.version}</span> : null}
|
||||||
|
|||||||
@@ -224,7 +224,7 @@ export function TokenUsageHeatmap({
|
|||||||
<span
|
<span
|
||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
className={cn(
|
className={cn(
|
||||||
"aspect-square w-full rounded-[2px] transition-transform hover:scale-110 sm:rounded-[4px]",
|
"aspect-square w-full rounded-mark transition-transform hover:scale-110",
|
||||||
tokenUsageCellClass(level, cell.future),
|
tokenUsageCellClass(level, cell.future),
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ export function ChannelLogo({
|
|||||||
if (showBrandLogos && logoUrl) {
|
if (showBrandLogos && logoUrl) {
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className="grid h-10 w-10 shrink-0 place-items-center rounded-[12px] bg-background"
|
className="grid h-10 w-10 shrink-0 place-items-center rounded-control bg-background"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={logoUrl}
|
src={logoUrl}
|
||||||
@@ -154,7 +154,7 @@ export function ChannelLogo({
|
|||||||
if (Icon) {
|
if (Icon) {
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-[12px] bg-background"
|
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-control bg-background"
|
||||||
style={{ color }}
|
style={{ color }}
|
||||||
aria-hidden
|
aria-hidden
|
||||||
>
|
>
|
||||||
@@ -165,7 +165,7 @@ export function ChannelLogo({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-[12px] bg-background text-[11px] font-bold"
|
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-control bg-background text-[11px] font-bold"
|
||||||
style={{ color }}
|
style={{ color }}
|
||||||
aria-hidden
|
aria-hidden
|
||||||
>
|
>
|
||||||
@@ -275,7 +275,7 @@ export function ChannelRuntimeError({
|
|||||||
}) {
|
}) {
|
||||||
if (!message) return null;
|
if (!message) return null;
|
||||||
return (
|
return (
|
||||||
<div className={`${className} rounded-[12px] border border-destructive/20 bg-destructive/5 px-3 py-2 text-[12px] leading-5 text-destructive`}>
|
<div className={`${className} rounded-control border border-destructive/20 bg-destructive/5 px-3 py-2 text-[12px] leading-5 text-destructive`}>
|
||||||
{message}
|
{message}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -144,7 +144,7 @@ export function ChannelInstancesPanel({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="min-h-full rounded-[20px] bg-settings-surface p-5">
|
<aside className="min-h-full rounded-panel bg-settings-surface p-5">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="flex min-w-0 items-start gap-3">
|
<div className="flex min-w-0 items-start gap-3">
|
||||||
<ChannelLogo feature={feature} showBrandLogos={showBrandLogos} />
|
<ChannelLogo feature={feature} showBrandLogos={showBrandLogos} />
|
||||||
@@ -177,7 +177,7 @@ export function ChannelInstancesPanel({
|
|||||||
<article
|
<article
|
||||||
key={instance.id}
|
key={instance.id}
|
||||||
className={cn(
|
className={cn(
|
||||||
"overflow-hidden rounded-[18px] transition-colors",
|
"overflow-hidden rounded-floating transition-colors",
|
||||||
expanded
|
expanded
|
||||||
? "bg-background"
|
? "bg-background"
|
||||||
: "bg-background/70 hover:bg-muted",
|
: "bg-background/70 hover:bg-muted",
|
||||||
@@ -313,7 +313,7 @@ export function ChannelInstancesPanel({
|
|||||||
{customization.footer}
|
{customization.footer}
|
||||||
|
|
||||||
{notice ? (
|
{notice ? (
|
||||||
<div className="mt-3 rounded-[12px] border border-destructive/20 px-3 py-2 text-[12px] leading-5 text-destructive">
|
<div className="mt-3 rounded-control border border-destructive/20 px-3 py-2 text-[12px] leading-5 text-destructive">
|
||||||
{notice}
|
{notice}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -251,8 +251,8 @@ export function ChannelQrConnectFlow({
|
|||||||
return (
|
return (
|
||||||
<div className="mt-3 space-y-3">
|
<div className="mt-3 space-y-3">
|
||||||
{pending ? (
|
{pending ? (
|
||||||
<div className="grid gap-4 rounded-[14px] border border-border/70 p-4 sm:grid-cols-[auto_minmax(0,1fr)]">
|
<div className="grid gap-4 rounded-control border border-border/70 p-4 sm:grid-cols-[auto_minmax(0,1fr)]">
|
||||||
<div className="grid h-[196px] w-[196px] place-items-center rounded-[14px] border border-border/60 bg-background">
|
<div className="grid h-[196px] w-[196px] place-items-center rounded-control border border-border/60 bg-background">
|
||||||
{qrDataUrl ? (
|
{qrDataUrl ? (
|
||||||
<img
|
<img
|
||||||
src={qrDataUrl}
|
src={qrDataUrl}
|
||||||
@@ -293,20 +293,20 @@ export function ChannelQrConnectFlow({
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{succeeded && !suppressSucceeded ? (
|
{succeeded && !suppressSucceeded ? (
|
||||||
<div className="flex items-center gap-2 rounded-[12px] border border-emerald-500/20 px-3 py-2 text-[12px] font-medium text-emerald-700 dark:text-emerald-200">
|
<div className="flex items-center gap-2 rounded-control border border-emerald-500/20 px-3 py-2 text-[12px] font-medium text-emerald-700 dark:text-emerald-200">
|
||||||
<Check className="h-3.5 w-3.5" aria-hidden />
|
<Check className="h-3.5 w-3.5" aria-hidden />
|
||||||
{displayMessage ?? labels.connected}
|
{displayMessage ?? labels.connected}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{connect && ["expired", "failed", "cancelled"].includes(connect.status) ? (
|
{connect && ["expired", "failed", "cancelled"].includes(connect.status) ? (
|
||||||
<div className="rounded-[12px] border border-border/60 px-3 py-2 text-[12px] leading-5 text-muted-foreground">
|
<div className="rounded-control border border-border/60 px-3 py-2 text-[12px] leading-5 text-muted-foreground">
|
||||||
{displayMessage || labels.stopped}
|
{displayMessage || labels.stopped}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{error ? (
|
{error ? (
|
||||||
<div className="rounded-[12px] border border-destructive/20 px-3 py-2 text-[12px] leading-5 text-destructive">
|
<div className="rounded-control border border-destructive/20 px-3 py-2 text-[12px] leading-5 text-destructive">
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ export function ChannelCatalogRow({
|
|||||||
aria-pressed={selected}
|
aria-pressed={selected}
|
||||||
onClick={onSelect}
|
onClick={onSelect}
|
||||||
className={cn(
|
className={cn(
|
||||||
"group flex w-full min-w-0 items-center gap-3 rounded-[14px] px-3 py-3 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border/80",
|
"group flex w-full min-w-0 items-center gap-3 rounded-control px-3 py-3 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border/80",
|
||||||
selected ? "bg-background" : "hover:bg-muted",
|
selected ? "bg-background" : "hover:bg-muted",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -187,7 +187,7 @@ export function ChannelSetupPanel({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="min-h-full rounded-[20px] bg-settings-surface p-5">
|
<aside className="min-h-full rounded-panel bg-settings-surface p-5">
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<div className="flex min-w-0 items-start gap-3">
|
<div className="flex min-w-0 items-start gap-3">
|
||||||
<ChannelLogo feature={feature} showBrandLogos={showBrandLogos} />
|
<ChannelLogo feature={feature} showBrandLogos={showBrandLogos} />
|
||||||
@@ -482,7 +482,7 @@ function ChannelSetupSurface({
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{setup.command ? (
|
{setup.command ? (
|
||||||
<code className="mt-3 block rounded-[10px] border border-border/50 bg-muted/45 px-2.5 py-2 font-mono text-[11px] leading-5 text-foreground">
|
<code className="mt-3 block rounded-control border border-border/50 bg-muted/45 px-2.5 py-2 font-mono text-[11px] leading-5 text-foreground">
|
||||||
{setup.command}
|
{setup.command}
|
||||||
</code>
|
</code>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -538,7 +538,7 @@ function ChannelSetupSurface({
|
|||||||
{notice ? (
|
{notice ? (
|
||||||
<div
|
<div
|
||||||
role="status"
|
role="status"
|
||||||
className="rounded-[12px] bg-muted/55 px-3 py-2.5 text-[12px] leading-5 text-muted-foreground"
|
className="rounded-control bg-muted/55 px-3 py-2.5 text-[12px] leading-5 text-muted-foreground"
|
||||||
>
|
>
|
||||||
{notice}
|
{notice}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -60,13 +60,13 @@ export function ChannelGuideLink({
|
|||||||
"inline-flex max-w-full items-center gap-2 bg-background/80 font-semibold text-foreground transition-colors hover:bg-background",
|
"inline-flex max-w-full items-center gap-2 bg-background/80 font-semibold text-foreground transition-colors hover:bg-background",
|
||||||
compact
|
compact
|
||||||
? "shrink-0 rounded-full py-1 pl-1 pr-2.5 text-[11.5px]"
|
? "shrink-0 rounded-full py-1 pl-1 pr-2.5 text-[11.5px]"
|
||||||
: "mt-3 rounded-[12px] py-1.5 pl-1.5 pr-3 text-[12px]",
|
: "mt-3 rounded-control py-1.5 pl-1.5 pr-3 text-[12px]",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"grid shrink-0 place-items-center overflow-hidden bg-muted/70 font-bold",
|
"grid shrink-0 place-items-center overflow-hidden bg-muted/70 font-bold",
|
||||||
compact ? "h-5 w-5 rounded-full text-[9px]" : "h-6 w-6 rounded-[7px] text-[10px]",
|
compact ? "h-5 w-5 rounded-full text-[9px]" : "h-6 w-6 rounded-compact text-[10px]",
|
||||||
)}
|
)}
|
||||||
style={{ color }}
|
style={{ color }}
|
||||||
aria-hidden
|
aria-hidden
|
||||||
@@ -231,7 +231,7 @@ export function ChannelProviderPresets({
|
|||||||
<div
|
<div
|
||||||
role="radiogroup"
|
role="radiogroup"
|
||||||
aria-label={t("settings.channels.providerPreset", { defaultValue: "Provider" })}
|
aria-label={t("settings.channels.providerPreset", { defaultValue: "Provider" })}
|
||||||
className="grid rounded-[10px] bg-muted p-0.5 text-[12px] font-medium text-muted-foreground"
|
className="grid rounded-control bg-muted p-0.5 text-[12px] font-medium text-muted-foreground"
|
||||||
style={{ gridTemplateColumns: `repeat(${presets.length}, minmax(0, 1fr))` }}
|
style={{ gridTemplateColumns: `repeat(${presets.length}, minmax(0, 1fr))` }}
|
||||||
>
|
>
|
||||||
{presets.map((preset) => (
|
{presets.map((preset) => (
|
||||||
@@ -245,7 +245,7 @@ export function ChannelProviderPresets({
|
|||||||
onApply(preset);
|
onApply(preset);
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"min-h-8 rounded-[8px] px-2 py-1.5 transition-colors hover:text-foreground",
|
"min-h-8 rounded-compact px-2 py-1.5 transition-colors hover:text-foreground",
|
||||||
selected === preset.id && "bg-background text-foreground",
|
selected === preset.id && "bg-background text-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -370,7 +370,7 @@ export function ChannelSetupSteps({
|
|||||||
))}
|
))}
|
||||||
</ol>
|
</ol>
|
||||||
{tryIt ? (
|
{tryIt ? (
|
||||||
<div className="mt-3 rounded-[12px] bg-background/75 px-3 py-2 text-[12px] text-muted-foreground">
|
<div className="mt-3 rounded-control bg-background/75 px-3 py-2 text-[12px] text-muted-foreground">
|
||||||
<span className="font-medium text-foreground">
|
<span className="font-medium text-foreground">
|
||||||
{tx("settings.channels.tryIt", "Try it")}
|
{tx("settings.channels.tryIt", "Try it")}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -159,7 +159,7 @@ export function CredentialForm({
|
|||||||
<span
|
<span
|
||||||
role="radiogroup"
|
role="radiogroup"
|
||||||
aria-label={field.label}
|
aria-label={field.label}
|
||||||
className="mt-1 grid rounded-[10px] bg-muted p-0.5 text-[12px] font-medium text-muted-foreground"
|
className="mt-1 grid rounded-control bg-muted p-0.5 text-[12px] font-medium text-muted-foreground"
|
||||||
style={{ gridTemplateColumns: `repeat(${field.options.length}, minmax(0, 1fr))` }}
|
style={{ gridTemplateColumns: `repeat(${field.options.length}, minmax(0, 1fr))` }}
|
||||||
>
|
>
|
||||||
{field.options.map((option) => (
|
{field.options.map((option) => (
|
||||||
@@ -170,7 +170,7 @@ export function CredentialForm({
|
|||||||
aria-checked={selectedOption === option.value}
|
aria-checked={selectedOption === option.value}
|
||||||
onClick={() => onChange(field.key, option.value)}
|
onClick={() => onChange(field.key, option.value)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"min-h-8 rounded-[8px] px-2 py-1.5 transition-colors hover:text-foreground",
|
"min-h-8 rounded-compact px-2 py-1.5 transition-colors hover:text-foreground",
|
||||||
selectedOption === option.value
|
selectedOption === option.value
|
||||||
&& "bg-background text-foreground ring-1 ring-inset ring-border/45",
|
&& "bg-background text-foreground ring-1 ring-inset ring-border/45",
|
||||||
)}
|
)}
|
||||||
@@ -200,7 +200,7 @@ export function CredentialForm({
|
|||||||
value={values[field.key] ?? ""}
|
value={values[field.key] ?? ""}
|
||||||
onChange={(event) => onChange(field.key, event.target.value)}
|
onChange={(event) => onChange(field.key, event.target.value)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-9 rounded-[10px] border-border/60 bg-muted/35 text-[13px]",
|
"h-9 rounded-control border-border/60 bg-muted/35 text-[13px]",
|
||||||
showSecretToggle && "pr-9",
|
showSecretToggle && "pr-9",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ export function ModelPresetDeleteDialog({
|
|||||||
t(key, { defaultValue: fallback, ...(values ?? {}) });
|
t(key, { defaultValue: fallback, ...(values ?? {}) });
|
||||||
return (
|
return (
|
||||||
<Dialog open={preset !== null} onOpenChange={onOpenChange}>
|
<Dialog open={preset !== null} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="max-w-[440px] rounded-[24px]">
|
<DialogContent className="max-w-[440px]">
|
||||||
<DialogHeader className="text-left">
|
<DialogHeader className="text-left">
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
{tx("settings.models.deletePresetTitle", "Delete model preset?")}
|
{tx("settings.models.deletePresetTitle", "Delete model preset?")}
|
||||||
@@ -130,11 +130,10 @@ export function ModelPresetDeleteDialog({
|
|||||||
)}
|
)}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DialogFooter className="gap-2 sm:space-x-0">
|
<DialogFooter>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="rounded-full"
|
|
||||||
disabled={deleting}
|
disabled={deleting}
|
||||||
onClick={() => onOpenChange(false)}
|
onClick={() => onOpenChange(false)}
|
||||||
>
|
>
|
||||||
@@ -143,7 +142,6 @@ export function ModelPresetDeleteDialog({
|
|||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
className="rounded-full"
|
|
||||||
disabled={deleting}
|
disabled={deleting}
|
||||||
onClick={onConfirm}
|
onClick={onConfirm}
|
||||||
>
|
>
|
||||||
@@ -343,7 +341,7 @@ export function ModelsSettings({
|
|||||||
<div
|
<div
|
||||||
id="model-preset-editor"
|
id="model-preset-editor"
|
||||||
data-testid="model-preset-editor"
|
data-testid="model-preset-editor"
|
||||||
className="mx-3 mb-3 divide-y divide-border/45 overflow-hidden rounded-[18px] border border-border/45 bg-background/80 shadow-sm motion-reduce:animate-none animate-in fade-in-0 slide-in-from-top-1 duration-200 sm:mx-5 lg:mx-auto lg:w-[calc(100%-2.5rem)] lg:max-w-6xl"
|
className="mx-3 mb-3 divide-y divide-border/45 overflow-hidden rounded-floating border border-border/45 bg-background/80 shadow-sm motion-reduce:animate-none animate-in fade-in-0 slide-in-from-top-1 duration-200 sm:mx-5 lg:mx-auto lg:w-[calc(100%-2.5rem)] lg:max-w-6xl"
|
||||||
>
|
>
|
||||||
{creating ? (
|
{creating ? (
|
||||||
<div className="flex min-h-[52px] items-center px-4 py-3 sm:px-5">
|
<div className="flex min-h-[52px] items-center px-4 py-3 sm:px-5">
|
||||||
@@ -527,7 +525,7 @@ export function ModelsSettings({
|
|||||||
{!settings.model_call_order_editable ? (
|
{!settings.model_call_order_editable ? (
|
||||||
<div className="flex flex-col gap-4 px-4 py-4 sm:flex-row sm:items-center sm:justify-between sm:px-5">
|
<div className="flex flex-col gap-4 px-4 py-4 sm:flex-row sm:items-center sm:justify-between sm:px-5">
|
||||||
<div className="flex min-w-0 items-start gap-3">
|
<div className="flex min-w-0 items-start gap-3">
|
||||||
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-[12px] bg-muted text-muted-foreground">
|
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-control bg-muted text-muted-foreground">
|
||||||
<ListOrdered className="h-4 w-4" aria-hidden />
|
<ListOrdered className="h-4 w-4" aria-hidden />
|
||||||
</span>
|
</span>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
@@ -674,7 +672,7 @@ export function ModelsSettings({
|
|||||||
aria-controls={isSelected ? "model-preset-editor" : undefined}
|
aria-controls={isSelected ? "model-preset-editor" : undefined}
|
||||||
disabled={!preset}
|
disabled={!preset}
|
||||||
onClick={() => preset && selectPreset(preset, key)}
|
onClick={() => preset && selectPreset(preset, key)}
|
||||||
className="flex min-w-0 flex-1 items-center gap-3 rounded-[12px] text-left outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
className="flex min-w-0 flex-1 items-center gap-3 rounded-control text-left outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
>
|
>
|
||||||
{ordered ? (
|
{ordered ? (
|
||||||
<span className="grid h-7 w-7 shrink-0 place-items-center rounded-full bg-muted font-mono text-[11px] font-semibold tabular-nums text-muted-foreground">
|
<span className="grid h-7 w-7 shrink-0 place-items-center rounded-full bg-muted font-mono text-[11px] font-semibold tabular-nums text-muted-foreground">
|
||||||
@@ -841,7 +839,7 @@ function ModelAdvancedFields({
|
|||||||
const value = Number(event.target.value);
|
const value = Number(event.target.value);
|
||||||
if (Number.isFinite(value)) onChange({ maxTokens: value });
|
if (Number.isFinite(value)) onChange({ maxTokens: value });
|
||||||
}}
|
}}
|
||||||
className="h-9 rounded-[12px] text-[13px]"
|
className="h-9 text-[13px]"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
@@ -858,7 +856,7 @@ function ModelAdvancedFields({
|
|||||||
const value = Number(event.target.value);
|
const value = Number(event.target.value);
|
||||||
if (Number.isFinite(value)) onChange({ temperature: value });
|
if (Number.isFinite(value)) onChange({ temperature: value });
|
||||||
}}
|
}}
|
||||||
className="h-9 rounded-[12px] text-[13px]"
|
className="h-9 text-[13px]"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -887,7 +885,7 @@ function ModelAdvancedFields({
|
|||||||
placeholder={tx("settings.values.default", "Default")}
|
placeholder={tx("settings.values.default", "Default")}
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
className="h-9 rounded-[12px] text-[13px]"
|
className="h-9 text-[13px]"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -276,7 +276,7 @@ export function ProviderOAuthLoginDialog({
|
|||||||
if (!open) onClose();
|
if (!open) onClose();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DialogContent className="w-[min(calc(100vw-2rem),28rem)] rounded-[24px]">
|
<DialogContent className="w-[min(calc(100vw-2rem),28rem)]">
|
||||||
<form
|
<form
|
||||||
className="space-y-4"
|
className="space-y-4"
|
||||||
onSubmit={(event) => {
|
onSubmit={(event) => {
|
||||||
@@ -296,7 +296,7 @@ export function ProviderOAuthLoginDialog({
|
|||||||
: t("settings.oauth.localCodeHelp")}
|
: t("settings.oauth.localCodeHelp")}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="flex items-center gap-2 rounded-[14px] border border-border/45 bg-muted/35 px-3 py-2.5 text-[12px] text-muted-foreground">
|
<div className="flex items-center gap-2 rounded-control border border-border/45 bg-muted/35 px-3 py-2.5 text-[12px] text-muted-foreground">
|
||||||
{expectsCallbackUrl && remoteBrowserAccess ? (
|
{expectsCallbackUrl && remoteBrowserAccess ? (
|
||||||
<Clipboard className="h-3.5 w-3.5 shrink-0" aria-hidden />
|
<Clipboard className="h-3.5 w-3.5 shrink-0" aria-hidden />
|
||||||
) : (
|
) : (
|
||||||
@@ -341,12 +341,12 @@ export function ProviderOAuthLoginDialog({
|
|||||||
{error ? (
|
{error ? (
|
||||||
<p
|
<p
|
||||||
role="alert"
|
role="alert"
|
||||||
className="rounded-[14px] border border-destructive/20 bg-destructive/5 px-3 py-2.5 text-[12px] text-destructive"
|
className="rounded-control border border-destructive/20 bg-destructive/5 px-3 py-2.5 text-[12px] text-destructive"
|
||||||
>
|
>
|
||||||
{error}
|
{error}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
<DialogFooter className="gap-2 sm:space-x-0">
|
<DialogFooter>
|
||||||
<Button type="button" variant="outline" onClick={onOpenAuthorization}>
|
<Button type="button" variant="outline" onClick={onOpenAuthorization}>
|
||||||
<ExternalLink className="mr-2 h-4 w-4" aria-hidden />
|
<ExternalLink className="mr-2 h-4 w-4" aria-hidden />
|
||||||
{expectsCallbackUrl
|
{expectsCallbackUrl
|
||||||
@@ -380,7 +380,7 @@ function ProviderRequestOptions({
|
|||||||
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-hidden rounded-[18px] border border-border/45 bg-background/75">
|
<div className="overflow-hidden rounded-floating border border-border/45 bg-background/75">
|
||||||
{options.map((option, index) => {
|
{options.map((option, index) => {
|
||||||
const title = tx(option.titleKey, option.title);
|
const title = tx(option.titleKey, option.title);
|
||||||
const Icon = option.kind === "priority" ? Zap : Globe2;
|
const Icon = option.kind === "priority" ? Zap : Globe2;
|
||||||
@@ -599,7 +599,7 @@ function ProviderAdvancedOptions({
|
|||||||
onChange={(event) => onChange({ extraHeaders: event.target.value })}
|
onChange={(event) => onChange({ extraHeaders: event.target.value })}
|
||||||
placeholder={'{"X-Header":"value"}'}
|
placeholder={'{"X-Header":"value"}'}
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
className="min-h-[88px] resize-y rounded-[14px] bg-background font-mono text-[12px]"
|
className="min-h-[88px] resize-y bg-background font-mono text-[12px]"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -613,7 +613,7 @@ function ProviderAdvancedOptions({
|
|||||||
onChange={(event) => onChange({ extraQuery: event.target.value })}
|
onChange={(event) => onChange({ extraQuery: event.target.value })}
|
||||||
placeholder={'{"api-version":"2024-02-01"}'}
|
placeholder={'{"api-version":"2024-02-01"}'}
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
className="min-h-[88px] resize-y rounded-[14px] bg-background font-mono text-[12px]"
|
className="min-h-[88px] resize-y bg-background font-mono text-[12px]"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -627,7 +627,7 @@ function ProviderAdvancedOptions({
|
|||||||
onChange={(event) => onChange({ extraBody: event.target.value })}
|
onChange={(event) => onChange({ extraBody: event.target.value })}
|
||||||
placeholder={'{"service_tier":"priority"}'}
|
placeholder={'{"service_tier":"priority"}'}
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
className="min-h-[96px] resize-y rounded-[14px] bg-background font-mono text-[12px]"
|
className="min-h-[96px] resize-y bg-background font-mono text-[12px]"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -825,7 +825,7 @@ export function ProvidersSettings({
|
|||||||
) : null}
|
) : null}
|
||||||
{isOauthProvider ? (
|
{isOauthProvider ? (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col gap-3 rounded-[18px] border border-border/45 bg-background/75 px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex flex-col gap-3 rounded-floating border border-border/45 bg-background/75 px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-[13px] font-semibold text-foreground">
|
<p className="text-[13px] font-semibold text-foreground">
|
||||||
{tx("settings.oauth.authentication", "OAuth authentication")}
|
{tx("settings.oauth.authentication", "OAuth authentication")}
|
||||||
@@ -1240,7 +1240,7 @@ export function ProvidersSettings({
|
|||||||
className="group flex min-h-[70px] w-full items-center justify-between gap-4 px-4 py-3 text-left transition-colors hover:bg-muted/35 sm:px-5"
|
className="group flex min-h-[70px] w-full items-center justify-between gap-4 px-4 py-3 text-left transition-colors hover:bg-muted/35 sm:px-5"
|
||||||
>
|
>
|
||||||
<span className="flex min-w-0 items-center gap-3">
|
<span className="flex min-w-0 items-center gap-3">
|
||||||
<span className="grid h-10 w-10 shrink-0 place-items-center rounded-[14px] bg-muted text-muted-foreground">
|
<span className="grid h-10 w-10 shrink-0 place-items-center rounded-control bg-muted text-muted-foreground">
|
||||||
<Plus className="h-5 w-5" aria-hidden />
|
<Plus className="h-5 w-5" aria-hidden />
|
||||||
</span>
|
</span>
|
||||||
<span className="truncate text-[15px] font-semibold text-foreground">
|
<span className="truncate text-[15px] font-semibold text-foreground">
|
||||||
@@ -1334,7 +1334,7 @@ function ProviderIcon({
|
|||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
data-testid={`provider-logo-${provider}`}
|
data-testid={`provider-logo-${provider}`}
|
||||||
className="grid h-10 w-10 shrink-0 place-items-center overflow-hidden rounded-[14px] border border-border/45 bg-background"
|
className="grid h-10 w-10 shrink-0 place-items-center overflow-hidden rounded-control border border-border/45 bg-background"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={logoUrl}
|
src={logoUrl}
|
||||||
@@ -1352,7 +1352,7 @@ function ProviderIcon({
|
|||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
data-testid={`provider-logo-fallback-${provider}`}
|
data-testid={`provider-logo-fallback-${provider}`}
|
||||||
className="grid h-10 w-10 shrink-0 place-items-center rounded-[14px] text-[11px] font-semibold text-white"
|
className="grid h-10 w-10 shrink-0 place-items-center rounded-control text-[11px] font-semibold text-white"
|
||||||
style={{ backgroundColor: brand.color }}
|
style={{ backgroundColor: brand.color }}
|
||||||
aria-hidden
|
aria-hidden
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ export function OverviewSettings({
|
|||||||
: tx("settings.values.ready", "Ready");
|
: tx("settings.values.ready", "Ready");
|
||||||
return (
|
return (
|
||||||
<div className="space-y-7">
|
<div className="space-y-7">
|
||||||
<section className="rounded-[22px] bg-settings-surface px-4 py-4 sm:px-5">
|
<section className="rounded-panel bg-settings-surface px-4 py-4 sm:px-5">
|
||||||
<TokenUsageHeatmap usage={settings.usage} timeZone={settings.agent.timezone} />
|
<TokenUsageHeatmap usage={settings.usage} timeZone={settings.agent.timezone} />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -433,7 +433,7 @@ function OverviewRowIcon({
|
|||||||
icon: LucideIcon;
|
icon: LucideIcon;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-[12px] bg-muted text-foreground/82 transition-colors group-hover:bg-muted/80 dark:bg-muted/70">
|
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-control bg-muted text-foreground/82 transition-colors group-hover:bg-muted/80 dark:bg-muted/70">
|
||||||
<Icon className="h-4 w-4" aria-hidden />
|
<Icon className="h-4 w-4" aria-hidden />
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -309,7 +309,7 @@ export function ModelIdPicker({
|
|||||||
key={model.id}
|
key={model.id}
|
||||||
{...navigation.getOptionProps(model.id)}
|
{...navigation.getOptionProps(model.id)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex cursor-default items-center justify-between gap-2 rounded-[12px] px-2 py-1.5 text-[12px]",
|
"flex cursor-default items-center justify-between gap-2 rounded-control px-2 py-1.5 text-[12px]",
|
||||||
options.selected && "text-foreground",
|
options.selected && "text-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -455,7 +455,7 @@ export function ModelIdPicker({
|
|||||||
) : null}
|
) : null}
|
||||||
<ComboboxOption
|
<ComboboxOption
|
||||||
{...navigation.getOptionProps(customCandidate)}
|
{...navigation.getOptionProps(customCandidate)}
|
||||||
className="flex cursor-default items-center gap-2 rounded-[12px] px-2 py-1.5 text-[12px]"
|
className="flex cursor-default items-center gap-2 rounded-control px-2 py-1.5 text-[12px]"
|
||||||
>
|
>
|
||||||
<span className="grid h-5 w-5 shrink-0 place-items-center rounded-md bg-muted/80 text-muted-foreground">
|
<span className="grid h-5 w-5 shrink-0 place-items-center rounded-md bg-muted/80 text-muted-foreground">
|
||||||
<Pencil className="h-3 w-3" aria-hidden />
|
<Pencil className="h-3 w-3" aria-hidden />
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export function CapabilityInstallNotice({
|
|||||||
installing?: boolean;
|
installing?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-start gap-3 rounded-[14px] border border-border/55 bg-muted/22 px-3.5 py-3">
|
<div className="flex items-start gap-3 rounded-control border border-border/55 bg-muted/22 px-3.5 py-3">
|
||||||
{installing ? (
|
{installing ? (
|
||||||
<Loader2 className="mt-0.5 h-4 w-4 shrink-0 animate-spin text-muted-foreground" aria-hidden />
|
<Loader2 className="mt-0.5 h-4 w-4 shrink-0 animate-spin text-muted-foreground" aria-hidden />
|
||||||
) : (
|
) : (
|
||||||
@@ -78,13 +78,13 @@ export function NanobotFeatureInstallDialog({
|
|||||||
)}
|
)}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DialogFooter className="mt-7 !grid grid-cols-1 gap-3 space-x-0 sm:grid-cols-2 sm:space-x-0">
|
<DialogFooter className="mt-7 !grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => onOpenChange(false)}
|
onClick={() => onOpenChange(false)}
|
||||||
disabled={installing}
|
disabled={installing}
|
||||||
className="h-11 w-full min-w-0 rounded-full bg-muted/70 px-5 text-[15px] font-semibold text-foreground shadow-none hover:bg-muted"
|
className="h-11 w-full min-w-0 bg-muted/70 px-5 text-[15px] font-semibold text-foreground shadow-none hover:bg-muted"
|
||||||
>
|
>
|
||||||
{tx("settings.automations.cancel", "Cancel")}
|
{tx("settings.automations.cancel", "Cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -92,7 +92,7 @@ export function NanobotFeatureInstallDialog({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => feature && void onConfirm(feature)}
|
onClick={() => feature && void onConfirm(feature)}
|
||||||
disabled={!feature || installing}
|
disabled={!feature || installing}
|
||||||
className="h-11 w-full min-w-0 !whitespace-normal rounded-full px-5 text-center text-[15px] font-semibold"
|
className="h-11 w-full min-w-0 !whitespace-normal px-5 text-center text-[15px] font-semibold"
|
||||||
>
|
>
|
||||||
{installing ? <Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden /> : null}
|
{installing ? <Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden /> : null}
|
||||||
{tx("settings.nanobotFeatures.installConfirmAction", "Install and enable")}
|
{tx("settings.nanobotFeatures.installConfirmAction", "Install and enable")}
|
||||||
@@ -117,7 +117,7 @@ export function DismissibleStatusMessage({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center justify-between gap-3 rounded-[12px] border py-2.5 pl-4 pr-2 text-[13px]",
|
"flex items-center justify-between gap-3 rounded-control border py-2.5 pl-4 pr-2 text-[13px]",
|
||||||
isError
|
isError
|
||||||
? "border-destructive/20 bg-destructive/5 text-destructive"
|
? "border-destructive/20 bg-destructive/5 text-destructive"
|
||||||
: "border-border/55 bg-muted/35 text-muted-foreground",
|
: "border-border/55 bg-muted/35 text-muted-foreground",
|
||||||
@@ -153,7 +153,7 @@ export function RestartRequiredNotice({
|
|||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-3 rounded-[12px] border border-amber-500/20 bg-amber-500/8 px-4 py-3 text-[12.5px] text-amber-800 dark:text-amber-200 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex flex-col gap-3 rounded-control border border-amber-500/20 bg-amber-500/8 px-4 py-3 text-[12.5px] text-amber-800 dark:text-amber-200 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<span>{message}</span>
|
<span>{message}</span>
|
||||||
{onRestart ? (
|
{onRestart ? (
|
||||||
<Button
|
<Button
|
||||||
@@ -186,7 +186,7 @@ export function SettingsSectionTitle({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
export function SettingsGroup({ children }: { children: ReactNode }) {
|
export function SettingsGroup({ children }: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="overflow-hidden rounded-[22px] bg-settings-surface">
|
<div className="overflow-hidden rounded-panel bg-settings-surface">
|
||||||
<div className="divide-y divide-border/45">{children}</div>
|
<div className="divide-y divide-border/45">{children}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -256,7 +256,7 @@ export function AppsCatalogSettings({
|
|||||||
onChange={(event) => onQueryChange(event.target.value)}
|
onChange={(event) => onQueryChange(event.target.value)}
|
||||||
placeholder={tx("settings.apps.searchPlaceholder", "Search Apps")}
|
placeholder={tx("settings.apps.searchPlaceholder", "Search Apps")}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-12 rounded-[14px] pl-11 text-[15px]",
|
"h-12 pl-11 text-[15px]",
|
||||||
SETTINGS_SEARCH_INPUT_CLASS,
|
SETTINGS_SEARCH_INPUT_CLASS,
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -289,7 +289,7 @@ export function AppsCatalogSettings({
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<section className="rounded-[22px] bg-settings-surface px-3 py-3 sm:px-4">
|
<section className="rounded-panel bg-settings-surface px-3 py-3 sm:px-4">
|
||||||
<div className="flex items-center justify-between border-b border-border/45 pb-3">
|
<div className="flex items-center justify-between border-b border-border/45 pb-3">
|
||||||
<SettingsSectionTitle>
|
<SettingsSectionTitle>
|
||||||
{filter === "mcp"
|
{filter === "mcp"
|
||||||
@@ -412,7 +412,7 @@ function CliAppsCatalogRow({
|
|||||||
const description = app.description || app.requires || app.entry_point || app.name;
|
const description = app.description || app.requires || app.entry_point || app.name;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className="apps-catalog-row group flex min-w-0 items-center gap-3 rounded-[14px] px-3 py-3 transition-colors hover:bg-muted/45">
|
<article className="apps-catalog-row group flex min-w-0 items-center gap-3 rounded-control px-3 py-3 transition-colors hover:bg-muted/45">
|
||||||
<CliAppLogo app={app} showBrandLogos={showBrandLogos} />
|
<CliAppLogo app={app} showBrandLogos={showBrandLogos} />
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex min-w-0 items-baseline gap-2">
|
<div className="flex min-w-0 items-baseline gap-2">
|
||||||
@@ -583,7 +583,7 @@ function McpAppsCatalogRow({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className="min-w-0 rounded-[14px] transition-colors hover:bg-muted/45">
|
<article className="min-w-0 rounded-control transition-colors hover:bg-muted/45">
|
||||||
<div className="group flex min-w-0 flex-wrap items-center gap-x-3 gap-y-2 px-3 py-3">
|
<div className="group flex min-w-0 flex-wrap items-center gap-x-3 gap-y-2 px-3 py-3">
|
||||||
<McpPresetLogo preset={preset} showBrandLogos={showBrandLogos} />
|
<McpPresetLogo preset={preset} showBrandLogos={showBrandLogos} />
|
||||||
<div className="min-w-[8rem] flex-[1_1_8rem]">
|
<div className="min-w-[8rem] flex-[1_1_8rem]">
|
||||||
@@ -747,7 +747,7 @@ function McpAppsCatalogRow({
|
|||||||
|
|
||||||
{manualCallback ? (
|
{manualCallback ? (
|
||||||
<form
|
<form
|
||||||
className="mx-3 mb-3 min-w-0 space-y-3 rounded-[14px] bg-background/55 p-3"
|
className="mx-3 mb-3 min-w-0 space-y-3 rounded-control bg-background/55 p-3"
|
||||||
onSubmit={(event) => {
|
onSubmit={(event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
onOAuthComplete();
|
onOAuthComplete();
|
||||||
@@ -825,7 +825,7 @@ function McpAppsCatalogRow({
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
) : oauthFlow && oauthPopupBlocked && oauthFlow.authorization_url ? (
|
) : oauthFlow && oauthPopupBlocked && oauthFlow.authorization_url ? (
|
||||||
<div className="mx-3 mb-3 flex flex-col gap-2.5 rounded-[14px] bg-background/55 p-3">
|
<div className="mx-3 mb-3 flex flex-col gap-2.5 rounded-control bg-background/55 p-3">
|
||||||
<div className="flex min-w-0 items-center gap-2.5 text-[12.5px] text-muted-foreground">
|
<div className="flex min-w-0 items-center gap-2.5 text-[12.5px] text-muted-foreground">
|
||||||
<span>
|
<span>
|
||||||
{mcpOAuthStatusText(
|
{mcpOAuthStatusText(
|
||||||
@@ -1016,10 +1016,10 @@ function McpCustomServerPanel({
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="overflow-hidden rounded-[16px] bg-settings-surface">
|
<section className="overflow-hidden rounded-floating bg-settings-surface">
|
||||||
<div className="flex flex-col gap-3 px-3 py-3 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex flex-col gap-3 px-3 py-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div className="flex min-w-0 items-center gap-3">
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-[11px] bg-muted text-muted-foreground">
|
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-control bg-muted text-muted-foreground">
|
||||||
<Server className="h-4 w-4" aria-hidden />
|
<Server className="h-4 w-4" aria-hidden />
|
||||||
</span>
|
</span>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
@@ -1154,7 +1154,7 @@ function McpCustomServerPanel({
|
|||||||
value={form.headers}
|
value={form.headers}
|
||||||
onChange={(event) => update("headers", event.target.value)}
|
onChange={(event) => update("headers", event.target.value)}
|
||||||
placeholder={'{"Authorization":"Bearer ..."}'}
|
placeholder={'{"Authorization":"Bearer ..."}'}
|
||||||
className="min-h-[68px] resize-y rounded-[12px] bg-background/80 font-mono text-[12px]"
|
className="min-h-[68px] resize-y bg-background/80 font-mono text-[12px]"
|
||||||
/>
|
/>
|
||||||
<p
|
<p
|
||||||
id={headersHelpId}
|
id={headersHelpId}
|
||||||
@@ -1202,7 +1202,7 @@ function McpCustomServerPanel({
|
|||||||
value={form.args}
|
value={form.args}
|
||||||
onChange={(event) => update("args", event.target.value)}
|
onChange={(event) => update("args", event.target.value)}
|
||||||
placeholder={'["-y", "docs-mcp"]'}
|
placeholder={'["-y", "docs-mcp"]'}
|
||||||
className="min-h-[68px] resize-y rounded-[12px] bg-background/80 font-mono text-[12px]"
|
className="min-h-[68px] resize-y bg-background/80 font-mono text-[12px]"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -1214,7 +1214,7 @@ function McpCustomServerPanel({
|
|||||||
value={form.env}
|
value={form.env}
|
||||||
onChange={(event) => update("env", event.target.value)}
|
onChange={(event) => update("env", event.target.value)}
|
||||||
placeholder={'{"API_KEY":"..."}'}
|
placeholder={'{"API_KEY":"..."}'}
|
||||||
className="min-h-[68px] resize-y rounded-[12px] bg-background/80 font-mono text-[12px]"
|
className="min-h-[68px] resize-y bg-background/80 font-mono text-[12px]"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="min-w-0">
|
<label className="min-w-0">
|
||||||
@@ -1257,7 +1257,7 @@ function McpCustomServerPanel({
|
|||||||
value={configImport}
|
value={configImport}
|
||||||
onChange={(event) => onConfigImportChange(event.target.value)}
|
onChange={(event) => onConfigImportChange(event.target.value)}
|
||||||
placeholder={'{"mcpServers":{"docs":{"command":"npx","args":["-y","docs-mcp"]}}}'}
|
placeholder={'{"mcpServers":{"docs":{"command":"npx","args":["-y","docs-mcp"]}}}'}
|
||||||
className="min-h-[84px] resize-y rounded-[12px] bg-background/80 font-mono text-[12px]"
|
className="min-h-[84px] resize-y bg-background/80 font-mono text-[12px]"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<Button
|
<Button
|
||||||
@@ -1352,7 +1352,7 @@ function McpPresetLogo({
|
|||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"grid shrink-0 place-items-center border border-border/45 bg-background",
|
"grid shrink-0 place-items-center border border-border/45 bg-background",
|
||||||
compact ? "h-10 w-10 rounded-[10px]" : "h-11 w-11 rounded-[8px]",
|
compact ? "h-10 w-10 rounded-control" : "h-11 w-11 rounded-compact",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
@@ -1372,8 +1372,8 @@ function McpPresetLogo({
|
|||||||
className={cn(
|
className={cn(
|
||||||
"grid shrink-0 place-items-center font-semibold text-white",
|
"grid shrink-0 place-items-center font-semibold text-white",
|
||||||
compact
|
compact
|
||||||
? "h-10 w-10 rounded-[10px] text-[12px]"
|
? "h-10 w-10 rounded-control text-[12px]"
|
||||||
: "h-11 w-11 rounded-[8px] text-[13px]",
|
: "h-11 w-11 rounded-compact text-[13px]",
|
||||||
)}
|
)}
|
||||||
style={{ backgroundColor: bg }}
|
style={{ backgroundColor: bg }}
|
||||||
>
|
>
|
||||||
@@ -1406,7 +1406,7 @@ function CliAppReadyPanel({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="rounded-[12px] bg-settings-surface px-4 py-3">
|
<section className="rounded-control bg-settings-surface px-4 py-3">
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||||
<CliAppLogo app={app} showBrandLogos={showBrandLogos} />
|
<CliAppLogo app={app} showBrandLogos={showBrandLogos} />
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
@@ -1472,7 +1472,7 @@ function CliAppLogo({ app, showBrandLogos }: { app: CliAppInfo; showBrandLogos:
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className="relative grid h-11 w-11 shrink-0 place-items-center overflow-hidden rounded-[8px] border border-border/45 bg-muted text-[13px] font-semibold"
|
className="relative grid h-11 w-11 shrink-0 place-items-center overflow-hidden rounded-compact border border-border/45 bg-muted text-[13px] font-semibold"
|
||||||
style={{ color: app.brand_color || "hsl(var(--muted-foreground))" }}
|
style={{ color: app.brand_color || "hsl(var(--muted-foreground))" }}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -129,14 +129,14 @@ export function AutomationsSettings({
|
|||||||
<section className="shrink-0">
|
<section className="shrink-0">
|
||||||
<div className="mx-auto flex w-full max-w-[56rem] flex-col gap-3">
|
<div className="mx-auto flex w-full max-w-[56rem] flex-col gap-3">
|
||||||
<div className="-mx-1 overflow-x-auto px-1 pb-0.5">
|
<div className="-mx-1 overflow-x-auto px-1 pb-0.5">
|
||||||
<div className="grid w-full min-w-[36rem] grid-cols-5 gap-1 rounded-[15px] bg-muted p-1">
|
<div className="grid w-full min-w-[36rem] grid-cols-5 gap-1 rounded-floating bg-muted p-1">
|
||||||
{summaryOptions.map((option) => (
|
{summaryOptions.map((option) => (
|
||||||
<button
|
<button
|
||||||
key={option.value}
|
key={option.value}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onFilterChange(option.value)}
|
onClick={() => onFilterChange(option.value)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex h-8 min-w-0 shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-[11px] px-3 text-[12px] font-medium text-muted-foreground transition-colors",
|
"inline-flex h-8 min-w-0 shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-control px-3 text-[12px] font-medium text-muted-foreground transition-colors",
|
||||||
filter === option.value && "bg-background text-foreground",
|
filter === option.value && "bg-background text-foreground",
|
||||||
automationFilterToneClass(option.value, option.count, filter === option.value),
|
automationFilterToneClass(option.value, option.count, filter === option.value),
|
||||||
)}
|
)}
|
||||||
@@ -166,7 +166,7 @@ export function AutomationsSettings({
|
|||||||
"Search task, message, linked chat, or schedule",
|
"Search task, message, linked chat, or schedule",
|
||||||
)}
|
)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-9 w-full rounded-[13px] pl-9 text-[13px]",
|
"h-9 w-full rounded-control pl-9 text-[13px]",
|
||||||
SETTINGS_SEARCH_INPUT_CLASS,
|
SETTINGS_SEARCH_INPUT_CLASS,
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -175,7 +175,7 @@ export function AutomationsSettings({
|
|||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="inline-flex h-9 min-w-[8.5rem] items-center justify-center gap-1.5 whitespace-nowrap rounded-[13px] border border-border/45 bg-settings-surface px-3 text-[12px] font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground sm:w-auto"
|
className="inline-flex h-9 min-w-[8.5rem] items-center justify-center gap-1.5 whitespace-nowrap rounded-control border border-border/45 bg-settings-surface px-3 text-[12px] font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground sm:w-auto"
|
||||||
>
|
>
|
||||||
<ArrowUpDown className="h-3.5 w-3.5" aria-hidden />
|
<ArrowUpDown className="h-3.5 w-3.5" aria-hidden />
|
||||||
<span>{sortLabel[sort]}</span>
|
<span>{sortLabel[sort]}</span>
|
||||||
@@ -197,19 +197,19 @@ export function AutomationsSettings({
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{error ? (
|
{error ? (
|
||||||
<div className="flex items-center gap-2 rounded-[18px] border border-destructive/20 bg-destructive/5 px-4 py-3 text-[13px] text-destructive">
|
<div className="flex items-center gap-2 rounded-floating border border-destructive/20 bg-destructive/5 px-4 py-3 text-[13px] text-destructive">
|
||||||
<CircleAlert className="h-4 w-4 shrink-0" aria-hidden />
|
<CircleAlert className="h-4 w-4 shrink-0" aria-hidden />
|
||||||
<span>{error}</span>
|
<span>{error}</span>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{loading && !payload ? (
|
{loading && !payload ? (
|
||||||
<div className="flex h-44 items-center justify-center rounded-[22px] bg-settings-surface text-[13px] text-muted-foreground">
|
<div className="flex h-44 items-center justify-center rounded-panel bg-settings-surface text-[13px] text-muted-foreground">
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden />
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden />
|
||||||
{tx("settings.automations.loading", "Loading automations...")}
|
{tx("settings.automations.loading", "Loading automations...")}
|
||||||
</div>
|
</div>
|
||||||
) : filtered.length && selectedJob ? (
|
) : filtered.length && selectedJob ? (
|
||||||
<section className="grid min-h-0 overflow-hidden rounded-[22px] bg-settings-surface xl:grid-cols-[minmax(16rem,18rem)_minmax(0,1fr)] xl:items-stretch">
|
<section className="grid min-h-0 overflow-hidden rounded-panel bg-settings-surface xl:grid-cols-[minmax(16rem,18rem)_minmax(0,1fr)] xl:items-stretch">
|
||||||
<aside className="flex min-h-0 flex-col overflow-hidden border-b border-border/35 bg-settings-surface xl:border-b-0 xl:border-r">
|
<aside className="flex min-h-0 flex-col overflow-hidden border-b border-border/35 bg-settings-surface xl:border-b-0 xl:border-r">
|
||||||
<div className="flex shrink-0 items-center justify-between gap-3 px-4 py-3">
|
<div className="flex shrink-0 items-center justify-between gap-3 px-4 py-3">
|
||||||
<h2 className="text-[13px] font-semibold tracking-[-0.01em] text-foreground/85">
|
<h2 className="text-[13px] font-semibold tracking-[-0.01em] text-foreground/85">
|
||||||
@@ -245,7 +245,7 @@ export function AutomationsSettings({
|
|||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-[22px] bg-settings-surface px-5 py-12 text-center text-[13px] text-muted-foreground">
|
<div className="rounded-panel bg-settings-surface px-5 py-12 text-center text-[13px] text-muted-foreground">
|
||||||
<div>
|
<div>
|
||||||
{jobs.length
|
{jobs.length
|
||||||
? tx("settings.automations.noMatches", "No automations match this view.")
|
? tx("settings.automations.noMatches", "No automations match this view.")
|
||||||
@@ -313,7 +313,7 @@ function AutomationListItem({
|
|||||||
aria-pressed={selected}
|
aria-pressed={selected}
|
||||||
onClick={onSelect}
|
onClick={onSelect}
|
||||||
className={cn(
|
className={cn(
|
||||||
"group grid w-full grid-cols-[minmax(0,1fr)_auto] gap-3 rounded-[18px] px-3 py-3.5 text-left transition-colors",
|
"group grid w-full grid-cols-[minmax(0,1fr)_auto] gap-3 rounded-floating px-3 py-3.5 text-left transition-colors",
|
||||||
selected
|
selected
|
||||||
? "bg-background/80 text-foreground"
|
? "bg-background/80 text-foreground"
|
||||||
: "text-muted-foreground hover:bg-background/55 hover:text-foreground",
|
: "text-muted-foreground hover:bg-background/55 hover:text-foreground",
|
||||||
@@ -433,7 +433,7 @@ function AutomationDetailPanel({
|
|||||||
|
|
||||||
<div className="grid min-h-0 min-w-0 flex-1 overflow-hidden lg:grid-cols-[minmax(0,1fr)_14.5rem]">
|
<div className="grid min-h-0 min-w-0 flex-1 overflow-hidden lg:grid-cols-[minmax(0,1fr)_14.5rem]">
|
||||||
<div className="min-h-0 min-w-0 space-y-3 overflow-y-auto overscroll-contain p-4 sm:p-5">
|
<div className="min-h-0 min-w-0 space-y-3 overflow-y-auto overscroll-contain p-4 sm:p-5">
|
||||||
<section className="rounded-[20px] bg-background/55 px-4 py-3.5">
|
<section className="rounded-floating bg-background/55 px-4 py-3.5">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div className="text-[11px] font-medium leading-none text-muted-foreground/75">
|
<div className="text-[11px] font-medium leading-none text-muted-foreground/75">
|
||||||
{messageLabel}
|
{messageLabel}
|
||||||
@@ -506,7 +506,7 @@ function AutomationDetailPanel({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{job.state.last_error ? (
|
{job.state.last_error ? (
|
||||||
<div className="rounded-[16px] border border-destructive/20 bg-destructive/8 px-3 py-2 text-[12px] leading-5 text-destructive">
|
<div className="rounded-floating border border-destructive/20 bg-destructive/8 px-3 py-2 text-[12px] leading-5 text-destructive">
|
||||||
{job.state.last_error}
|
{job.state.last_error}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -520,7 +520,7 @@ function AutomationDetailPanel({
|
|||||||
>
|
>
|
||||||
{schedule}
|
{schedule}
|
||||||
</AutomationDetail>
|
</AutomationDetail>
|
||||||
<div className="rounded-[18px] bg-background/55 p-3">
|
<div className="rounded-floating bg-background/55 p-3">
|
||||||
<div className="grid gap-3">
|
<div className="grid gap-3">
|
||||||
{created ? (
|
{created ? (
|
||||||
<div>
|
<div>
|
||||||
@@ -672,7 +672,7 @@ function AutomationDetail({
|
|||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="min-w-0 rounded-[17px] bg-background/55 px-3 py-3">
|
<div className="min-w-0 rounded-floating bg-background/55 px-3 py-3">
|
||||||
<div className="text-[11px] font-medium leading-none text-muted-foreground/75">
|
<div className="text-[11px] font-medium leading-none text-muted-foreground/75">
|
||||||
{label}
|
{label}
|
||||||
</div>
|
</div>
|
||||||
@@ -757,7 +757,7 @@ export function AutomationEditDialog({
|
|||||||
{job ? (
|
{job ? (
|
||||||
<DialogContent
|
<DialogContent
|
||||||
aria-describedby={undefined}
|
aria-describedby={undefined}
|
||||||
className="w-[min(calc(100vw-2rem),34rem)] rounded-[26px]"
|
className="w-[min(calc(100vw-2rem),34rem)]"
|
||||||
>
|
>
|
||||||
<form className="space-y-5" onSubmit={submit}>
|
<form className="space-y-5" onSubmit={submit}>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
@@ -772,7 +772,6 @@ export function AutomationEditDialog({
|
|||||||
<Input
|
<Input
|
||||||
value={draft.name}
|
value={draft.name}
|
||||||
onChange={(event) => setDraft((prev) => ({ ...prev, name: event.target.value }))}
|
onChange={(event) => setDraft((prev) => ({ ...prev, name: event.target.value }))}
|
||||||
className="h-10 rounded-[12px]"
|
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
@@ -784,7 +783,7 @@ export function AutomationEditDialog({
|
|||||||
<Textarea
|
<Textarea
|
||||||
value={draft.message}
|
value={draft.message}
|
||||||
onChange={(event) => setDraft((prev) => ({ ...prev, message: event.target.value }))}
|
onChange={(event) => setDraft((prev) => ({ ...prev, message: event.target.value }))}
|
||||||
className="min-h-[160px] resize-none rounded-[12px] text-[13px] leading-5"
|
className="min-h-[160px] resize-none text-[13px] leading-5"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -821,7 +820,6 @@ export function AutomationEditDialog({
|
|||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
setDraft((prev) => ({ ...prev, everyValue: event.target.value }))
|
setDraft((prev) => ({ ...prev, everyValue: event.target.value }))
|
||||||
}
|
}
|
||||||
className="h-10 rounded-[12px]"
|
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="block space-y-1.5">
|
<label className="block space-y-1.5">
|
||||||
@@ -837,7 +835,7 @@ export function AutomationEditDialog({
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-10 w-full rounded-[12px] border border-input bg-background px-3 text-[13px] text-foreground transition-colors",
|
"h-10 w-full rounded-control border border-input bg-background px-3 text-[13px] text-foreground transition-colors",
|
||||||
formControlFocusClassName,
|
formControlFocusClassName,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -861,7 +859,7 @@ export function AutomationEditDialog({
|
|||||||
value={draft.cronExpr}
|
value={draft.cronExpr}
|
||||||
onChange={(event) => setDraft((prev) => ({ ...prev, cronExpr: event.target.value }))}
|
onChange={(event) => setDraft((prev) => ({ ...prev, cronExpr: event.target.value }))}
|
||||||
placeholder="0 9 * * *"
|
placeholder="0 9 * * *"
|
||||||
className="h-10 rounded-[12px] font-mono text-[13px]"
|
className="font-mono text-[13px]"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="block space-y-1.5">
|
<label className="block space-y-1.5">
|
||||||
@@ -872,7 +870,7 @@ export function AutomationEditDialog({
|
|||||||
value={draft.tz}
|
value={draft.tz}
|
||||||
onChange={(event) => setDraft((prev) => ({ ...prev, tz: event.target.value }))}
|
onChange={(event) => setDraft((prev) => ({ ...prev, tz: event.target.value }))}
|
||||||
placeholder="Asia/Shanghai"
|
placeholder="Asia/Shanghai"
|
||||||
className="h-10 rounded-[12px] text-[13px]"
|
className="text-[13px]"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -887,13 +885,12 @@ export function AutomationEditDialog({
|
|||||||
type="datetime-local"
|
type="datetime-local"
|
||||||
value={draft.atLocal}
|
value={draft.atLocal}
|
||||||
onChange={(event) => setDraft((prev) => ({ ...prev, atLocal: event.target.value }))}
|
onChange={(event) => setDraft((prev) => ({ ...prev, atLocal: event.target.value }))}
|
||||||
className="h-10 rounded-[12px]"
|
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{validation ? (
|
{validation ? (
|
||||||
<div className="rounded-[12px] bg-destructive/8 px-3 py-2 text-[12px] text-destructive">
|
<div className="rounded-control bg-destructive/8 px-3 py-2 text-[12px] text-destructive">
|
||||||
{validation}
|
{validation}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -905,11 +902,10 @@ export function AutomationEditDialog({
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => onOpenChange(false)}
|
onClick={() => onOpenChange(false)}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
className="rounded-full"
|
|
||||||
>
|
>
|
||||||
{tx("settings.automations.cancel", "Cancel")}
|
{tx("settings.automations.cancel", "Cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={Boolean(validation) || saving} className="rounded-full">
|
<Button type="submit" disabled={Boolean(validation) || saving}>
|
||||||
{saving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden /> : null}
|
{saving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden /> : null}
|
||||||
{tx("settings.automations.save", "Save")}
|
{tx("settings.automations.save", "Save")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -937,7 +933,7 @@ export function AutomationDeleteDialog({
|
|||||||
t(key, { defaultValue: fallback, ...(values ?? {}) });
|
t(key, { defaultValue: fallback, ...(values ?? {}) });
|
||||||
return (
|
return (
|
||||||
<Dialog open={Boolean(job)} onOpenChange={onOpenChange}>
|
<Dialog open={Boolean(job)} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="w-[min(calc(100vw-2rem),26rem)] rounded-[26px]">
|
<DialogContent className="w-[min(calc(100vw-2rem),26rem)]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{tx("settings.automations.deleteTitle", "Delete automation")}</DialogTitle>
|
<DialogTitle>{tx("settings.automations.deleteTitle", "Delete automation")}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
@@ -954,7 +950,6 @@ export function AutomationDeleteDialog({
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => onOpenChange(false)}
|
onClick={() => onOpenChange(false)}
|
||||||
disabled={deleting}
|
disabled={deleting}
|
||||||
className="rounded-full"
|
|
||||||
>
|
>
|
||||||
{tx("settings.automations.cancel", "Cancel")}
|
{tx("settings.automations.cancel", "Cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -963,7 +958,6 @@ export function AutomationDeleteDialog({
|
|||||||
variant="destructive"
|
variant="destructive"
|
||||||
onClick={() => job && void onConfirm(job)}
|
onClick={() => job && void onConfirm(job)}
|
||||||
disabled={!job || deleting}
|
disabled={!job || deleting}
|
||||||
className="rounded-full"
|
|
||||||
>
|
>
|
||||||
{deleting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden /> : null}
|
{deleting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden /> : null}
|
||||||
{tx("settings.automations.delete", "Delete")}
|
{tx("settings.automations.delete", "Delete")}
|
||||||
|
|||||||
@@ -147,19 +147,19 @@ export function ChannelsSettings({
|
|||||||
onChange={(event) => onQueryChange(event.target.value)}
|
onChange={(event) => onQueryChange(event.target.value)}
|
||||||
placeholder={tx("settings.channels.searchPlaceholder", "Search channels")}
|
placeholder={tx("settings.channels.searchPlaceholder", "Search channels")}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-12 rounded-[14px] pl-11 text-[15px]",
|
"h-12 rounded-control pl-11 text-[15px]",
|
||||||
SETTINGS_SEARCH_INPUT_CLASS,
|
SETTINGS_SEARCH_INPUT_CLASS,
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 flex-wrap gap-1.5 rounded-[14px] bg-muted/55 p-1">
|
<div className="flex shrink-0 flex-wrap gap-1.5 rounded-control bg-muted/55 p-1">
|
||||||
{filterOptions.map((option) => (
|
{filterOptions.map((option) => (
|
||||||
<button
|
<button
|
||||||
key={option.value}
|
key={option.value}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setFilter(option.value)}
|
onClick={() => setFilter(option.value)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-[11px] px-3 py-1.5 text-[12px] font-medium transition-colors",
|
"rounded-control px-3 py-1.5 text-[12px] font-medium transition-colors",
|
||||||
filter === option.value
|
filter === option.value
|
||||||
? "bg-background text-foreground"
|
? "bg-background text-foreground"
|
||||||
: "text-muted-foreground hover:text-foreground",
|
: "text-muted-foreground hover:text-foreground",
|
||||||
|
|||||||
@@ -153,7 +153,6 @@ export function McpManagementDialog({
|
|||||||
showCloseButton={false}
|
showCloseButton={false}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-[min(34rem,calc(100dvh-2rem))] w-[calc(100vw-2rem)] max-w-[42rem] flex-col gap-0 overflow-hidden p-0",
|
"flex h-[min(34rem,calc(100dvh-2rem))] w-[calc(100vw-2rem)] max-w-[42rem] flex-col gap-0 overflow-hidden p-0",
|
||||||
"rounded-[22px]",
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex shrink-0 items-center gap-3 border-b border-border/45 px-5 py-3.5 sm:px-6">
|
<div className="flex shrink-0 items-center gap-3 border-b border-border/45 px-5 py-3.5 sm:px-6">
|
||||||
@@ -282,7 +281,7 @@ function OverviewPanel({
|
|||||||
const previewTools = (preset.tool_names ?? []).slice(0, 4);
|
const previewTools = (preset.tool_names ?? []).slice(0, 4);
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<section className="rounded-[16px] border border-border/55 px-4 py-3.5">
|
<section className="rounded-floating border border-border/55 px-4 py-3.5">
|
||||||
<h3 className="text-[13px] font-semibold text-foreground">
|
<h3 className="text-[13px] font-semibold text-foreground">
|
||||||
{tx("settings.mcp.about", "About this MCP")}
|
{tx("settings.mcp.about", "About this MCP")}
|
||||||
</h3>
|
</h3>
|
||||||
@@ -314,7 +313,7 @@ function OverviewPanel({
|
|||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
{previewTools.length ? (
|
{previewTools.length ? (
|
||||||
<section className="rounded-[16px] bg-muted/45 p-4">
|
<section className="rounded-floating bg-muted/45 p-4">
|
||||||
<h3 className="text-[13px] font-semibold text-foreground">
|
<h3 className="text-[13px] font-semibold text-foreground">
|
||||||
{tx("settings.mcp.toolPreview", "Tools")}
|
{tx("settings.mcp.toolPreview", "Tools")}
|
||||||
</h3>
|
</h3>
|
||||||
@@ -375,7 +374,7 @@ function ToolsPanel({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col gap-3 rounded-[16px] border px-4 py-3.5 sm:flex-row sm:items-center sm:justify-between",
|
"flex flex-col gap-3 rounded-floating border px-4 py-3.5 sm:flex-row sm:items-center sm:justify-between",
|
||||||
preset.error ? "border-destructive/25 bg-destructive/5" : "border-border/55 bg-muted/25",
|
preset.error ? "border-destructive/25 bg-destructive/5" : "border-border/55 bg-muted/25",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -444,7 +443,7 @@ function ToolsPanel({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="overflow-hidden rounded-[16px] border border-border/55">
|
<div className="overflow-hidden rounded-floating border border-border/55">
|
||||||
{filteredTools.length ? filteredTools.map((toolName) => {
|
{filteredTools.length ? filteredTools.map((toolName) => {
|
||||||
const selected = selectedTools.has(toolName);
|
const selected = selectedTools.has(toolName);
|
||||||
return (
|
return (
|
||||||
@@ -463,7 +462,7 @@ function ToolsPanel({
|
|||||||
<span
|
<span
|
||||||
aria-hidden
|
aria-hidden
|
||||||
className={cn(
|
className={cn(
|
||||||
"pointer-events-none absolute inset-0 grid place-items-center rounded-[7px] border transition-colors",
|
"pointer-events-none absolute inset-0 grid place-items-center rounded-compact border transition-colors",
|
||||||
selected
|
selected
|
||||||
? "border-foreground bg-foreground text-background"
|
? "border-foreground bg-foreground text-background"
|
||||||
: "border-border bg-background text-transparent",
|
: "border-border bg-background text-transparent",
|
||||||
@@ -529,7 +528,7 @@ function ConnectionPanel({
|
|||||||
<h3 id={`mcp-connection-${preset.name}`} className="sr-only">
|
<h3 id={`mcp-connection-${preset.name}`} className="sr-only">
|
||||||
{tx("settings.mcp.connectionDetails", "Connection details")}
|
{tx("settings.mcp.connectionDetails", "Connection details")}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="rounded-[16px] bg-muted/40 px-4 py-3.5">
|
<div className="rounded-floating bg-muted/40 px-4 py-3.5">
|
||||||
{preset.connection_summary ? (
|
{preset.connection_summary ? (
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-[12.5px] font-medium text-muted-foreground">{connectionLabel}</p>
|
<p className="text-[12.5px] font-medium text-muted-foreground">{connectionLabel}</p>
|
||||||
@@ -580,7 +579,7 @@ function ConnectionPanel({
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{preset.error ? (
|
{preset.error ? (
|
||||||
<div role="alert" className="rounded-[14px] bg-destructive/10 px-3.5 py-3 text-[12.5px] leading-5 text-destructive">
|
<div role="alert" className="rounded-control bg-destructive/10 px-3.5 py-3 text-[12.5px] leading-5 text-destructive">
|
||||||
{preset.error}
|
{preset.error}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -661,7 +660,7 @@ function ConnectionPanel({
|
|||||||
|
|
||||||
function MetricCard({ label, value }: { label: string; value: string }) {
|
function MetricCard({ label, value }: { label: string; value: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="min-w-0 rounded-[14px] bg-muted/45 px-3.5 py-3">
|
<div className="min-w-0 rounded-control bg-muted/45 px-3.5 py-3">
|
||||||
<dt className="text-[12px] font-medium text-muted-foreground">{label}</dt>
|
<dt className="text-[12px] font-medium text-muted-foreground">{label}</dt>
|
||||||
<dd className="mt-1 truncate text-[14px] font-semibold text-foreground">{value}</dd>
|
<dd className="mt-1 truncate text-[14px] font-semibold text-foreground">{value}</dd>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -319,7 +319,7 @@ export function AgentActivityCluster({
|
|||||||
syncActivityScrollFade();
|
syncActivityScrollFade();
|
||||||
}, [syncActivityScrollFade]);
|
}, [syncActivityScrollFade]);
|
||||||
|
|
||||||
if (!hasVisibleActivity) return null;
|
if (!hasVisibleActivity && !isTurnStreaming) return null;
|
||||||
|
|
||||||
if (hasOnlyFileActivity) {
|
if (hasOnlyFileActivity) {
|
||||||
return (
|
return (
|
||||||
@@ -343,6 +343,7 @@ export function AgentActivityCluster({
|
|||||||
contentRef={activityContentRef}
|
contentRef={activityContentRef}
|
||||||
fadeTop={activityScrollFade.top}
|
fadeTop={activityScrollFade.top}
|
||||||
fadeBottom={activityScrollFade.bottom}
|
fadeBottom={activityScrollFade.bottom}
|
||||||
|
hasDetails={hasVisibleActivity}
|
||||||
onToggle={toggleOuter}
|
onToggle={toggleOuter}
|
||||||
onScroll={onActivityScroll}
|
onScroll={onActivityScroll}
|
||||||
>
|
>
|
||||||
@@ -382,7 +383,13 @@ function activityDurationMs(
|
|||||||
const timestamps = messages
|
const timestamps = messages
|
||||||
.map((message) => message.createdAt)
|
.map((message) => message.createdAt)
|
||||||
.filter((value) => Number.isFinite(value));
|
.filter((value) => Number.isFinite(value));
|
||||||
if (!timestamps.length) return 0;
|
if (!timestamps.length) {
|
||||||
|
return active
|
||||||
|
&& typeof activeStartedAtMs === "number"
|
||||||
|
&& Number.isFinite(activeStartedAtMs)
|
||||||
|
? Math.max(0, now - activeStartedAtMs)
|
||||||
|
: 0;
|
||||||
|
}
|
||||||
const first = active && Number.isFinite(activeStartedAtMs)
|
const first = active && Number.isFinite(activeStartedAtMs)
|
||||||
? activeStartedAtMs!
|
? activeStartedAtMs!
|
||||||
: Math.min(...timestamps);
|
: Math.min(...timestamps);
|
||||||
@@ -1105,7 +1112,7 @@ function CliRunRow({ run, active, app }: { run: CliRunSummary; active: boolean;
|
|||||||
<span
|
<span
|
||||||
data-testid={`activity-cli-logo-${run.name.toLowerCase()}`}
|
data-testid={`activity-cli-logo-${run.name.toLowerCase()}`}
|
||||||
className={cn(
|
className={cn(
|
||||||
"grid h-4 w-4 shrink-0 place-items-center overflow-hidden rounded-[4px] border text-[6.5px] font-semibold text-white",
|
"grid h-4 w-4 shrink-0 place-items-center overflow-hidden rounded-mark border text-[6.5px] font-semibold text-white",
|
||||||
rowActive && "animate-pulse",
|
rowActive && "animate-pulse",
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
@@ -1183,7 +1190,7 @@ function McpRunRow({ run, active, preset }: { run: McpRunSummary; active: boolea
|
|||||||
<span
|
<span
|
||||||
data-testid={`activity-mcp-logo-${run.presetName.toLowerCase()}`}
|
data-testid={`activity-mcp-logo-${run.presetName.toLowerCase()}`}
|
||||||
className={cn(
|
className={cn(
|
||||||
"grid h-4 w-4 shrink-0 place-items-center overflow-hidden rounded-[4px] border text-[6.5px] font-semibold text-white",
|
"grid h-4 w-4 shrink-0 place-items-center overflow-hidden rounded-mark border text-[6.5px] font-semibold text-white",
|
||||||
rowActive && "animate-pulse",
|
rowActive && "animate-pulse",
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -203,7 +203,7 @@ export function PromptRail({
|
|||||||
aria-hidden
|
aria-hidden
|
||||||
data-testid={previewVisible ? "prompt-rail-preview" : undefined}
|
data-testid={previewVisible ? "prompt-rail-preview" : undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
"pointer-events-none absolute left-10 z-30 w-[34rem] max-w-[calc(100vw-4rem)] -translate-y-1/2 rounded-[20px] px-4 py-3 text-left",
|
"pointer-events-none absolute left-10 z-30 w-[34rem] max-w-[calc(100vw-4rem)] -translate-y-1/2 rounded-panel px-4 py-3 text-left",
|
||||||
floatingSurfaceElevationClassName,
|
floatingSurfaceElevationClassName,
|
||||||
"transition-[opacity,transform] duration-150",
|
"transition-[opacity,transform] duration-150",
|
||||||
previewVisible
|
previewVisible
|
||||||
|
|||||||
@@ -41,12 +41,12 @@ export function SessionInfoPopover({ sessionKey, token, title }: SessionInfoPopo
|
|||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const { jobs, loading, loadFailed, now } = useSessionAutomationJobs(open, token, sessionKey);
|
const { jobs, loading, loadFailed, now } = useSessionAutomationJobs(open, token, sessionKey);
|
||||||
const automationContent = loading ? (
|
const automationContent = loading ? (
|
||||||
<div className="flex items-center gap-2 rounded-[16px] bg-muted/45 px-3 py-3 text-[12.5px] text-muted-foreground">
|
<div className="flex items-center gap-2 rounded-floating bg-muted/45 px-3 py-3 text-[12.5px] text-muted-foreground">
|
||||||
<RefreshCcw className="h-3.5 w-3.5 animate-spin" />
|
<RefreshCcw className="h-3.5 w-3.5 animate-spin" />
|
||||||
{t("thread.sessionInfo.loading")}
|
{t("thread.sessionInfo.loading")}
|
||||||
</div>
|
</div>
|
||||||
) : loadFailed ? (
|
) : loadFailed ? (
|
||||||
<div className="flex items-center gap-2 rounded-[16px] bg-destructive/10 px-3 py-3 text-[12.5px] text-destructive">
|
<div className="flex items-center gap-2 rounded-floating bg-destructive/10 px-3 py-3 text-[12.5px] text-destructive">
|
||||||
<CircleAlert className="h-3.5 w-3.5" />
|
<CircleAlert className="h-3.5 w-3.5" />
|
||||||
{t("thread.sessionInfo.loadFailed")}
|
{t("thread.sessionInfo.loadFailed")}
|
||||||
</div>
|
</div>
|
||||||
@@ -57,7 +57,7 @@ export function SessionInfoPopover({ sessionKey, token, title }: SessionInfoPopo
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-[16px] bg-muted/35 px-3 py-3 text-[12.5px] leading-relaxed text-muted-foreground">
|
<div className="rounded-floating bg-muted/35 px-3 py-3 text-[12.5px] leading-relaxed text-muted-foreground">
|
||||||
{t("thread.sessionInfo.empty")}
|
{t("thread.sessionInfo.empty")}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -124,7 +124,7 @@ function AutomationRow({ job, now }: { job: SessionAutomationJob; now: number })
|
|||||||
: "bg-muted-foreground/35";
|
: "bg-muted-foreground/35";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-[16px] px-3 py-2.5 transition-colors hover:bg-muted/40">
|
<div className="rounded-floating px-3 py-2.5 transition-colors hover:bg-muted/40">
|
||||||
<div className="flex items-start gap-2.5">
|
<div className="flex items-start gap-2.5">
|
||||||
<span className={cn("mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full", statusClass)} />
|
<span className={cn("mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full", statusClass)} />
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
|
|||||||
@@ -83,7 +83,6 @@ import { useClipboardAndDrop } from "@/hooks/useClipboardAndDrop";
|
|||||||
import { useLogoFallback } from "@/hooks/useLogoFallback";
|
import { useLogoFallback } from "@/hooks/useLogoFallback";
|
||||||
import { useMediaQuery } from "@/hooks/useMediaQuery";
|
import { useMediaQuery } from "@/hooks/useMediaQuery";
|
||||||
import type { SendAttachment, SendOptions } from "@/hooks/useNanobotStream";
|
import type { SendAttachment, SendOptions } from "@/hooks/useNanobotStream";
|
||||||
import { usePageVisibility } from "@/hooks/usePageVisibility";
|
|
||||||
import { useVoiceRecorder, type VoiceRecorderErrorKey } from "@/hooks/useVoiceRecorder";
|
import { useVoiceRecorder, type VoiceRecorderErrorKey } from "@/hooks/useVoiceRecorder";
|
||||||
import type {
|
import type {
|
||||||
CliAppInfo,
|
CliAppInfo,
|
||||||
@@ -207,8 +206,6 @@ interface ThreadComposerProps {
|
|||||||
onStop?: () => void;
|
onStop?: () => void;
|
||||||
surfaceRef?: Ref<HTMLDivElement>;
|
surfaceRef?: Ref<HTMLDivElement>;
|
||||||
onTranscribeAudio?: (dataUrl: string, options?: { durationMs?: number }) => Promise<string>;
|
onTranscribeAudio?: (dataUrl: string, options?: { durationMs?: number }) => Promise<string>;
|
||||||
/** Unix seconds from server; turn elapsed timer above input while set. */
|
|
||||||
runStartedAt?: number | null;
|
|
||||||
/** Sustained objective for this chat (WebSocket ``goal_state``). */
|
/** Sustained objective for this chat (WebSocket ``goal_state``). */
|
||||||
goalState?: GoalStateWsPayload;
|
goalState?: GoalStateWsPayload;
|
||||||
workspaceScope?: WorkspaceScopePayload | null;
|
workspaceScope?: WorkspaceScopePayload | null;
|
||||||
@@ -217,6 +214,7 @@ interface ThreadComposerProps {
|
|||||||
workspaceControls?: WorkspacesPayload["controls"] | null;
|
workspaceControls?: WorkspacesPayload["controls"] | null;
|
||||||
workspaceScopeDisabled?: boolean;
|
workspaceScopeDisabled?: boolean;
|
||||||
workspaceError?: string | null;
|
workspaceError?: string | null;
|
||||||
|
onPickWorkspaceFolder?: () => Promise<string | null>;
|
||||||
onWorkspaceScopeChange?: (scope: WorkspaceScopePayload) => void;
|
onWorkspaceScopeChange?: (scope: WorkspaceScopePayload) => void;
|
||||||
pendingQueueKey?: string | null;
|
pendingQueueKey?: string | null;
|
||||||
transcriptionProvider?: string | null;
|
transcriptionProvider?: string | null;
|
||||||
@@ -694,63 +692,38 @@ function mcpPresetMentionPayload(preset: McpPresetInfo): OutboundMcpPresetMentio
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function RunPulseIcon() {
|
function GoalStateStrip({
|
||||||
return (
|
|
||||||
<span className="run-pulse-icon relative flex h-4 w-4 shrink-0 items-center justify-center" aria-hidden>
|
|
||||||
<span className="run-pulse-icon__ring" />
|
|
||||||
<span className="run-pulse-icon__dot" />
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function RunElapsedStrip({
|
|
||||||
startedAt,
|
|
||||||
goalState,
|
goalState,
|
||||||
}: {
|
}: {
|
||||||
startedAt: number | null;
|
|
||||||
goalState?: GoalStateWsPayload;
|
goalState?: GoalStateWsPayload;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const pageVisible = usePageVisibility();
|
|
||||||
const [goalPanelOpen, setGoalPanelOpen] = useState(false);
|
const [goalPanelOpen, setGoalPanelOpen] = useState(false);
|
||||||
const showTimer = startedAt != null;
|
|
||||||
const stripLabel = goalStateStripPreview(goalState, t);
|
const stripLabel = goalStateStripPreview(goalState, t);
|
||||||
const showGoal = !!stripLabel?.trim();
|
const active = !!stripLabel?.trim();
|
||||||
const active = showTimer || showGoal;
|
|
||||||
const [, setTick] = useState(0);
|
const [, setTick] = useState(0);
|
||||||
const stripWrapperRef = useRef<HTMLDivElement>(null);
|
const stripWrapperRef = useRef<HTMLDivElement>(null);
|
||||||
const panelRef = useRef<HTMLDivElement>(null);
|
const panelRef = useRef<HTMLDivElement>(null);
|
||||||
const expandToggleRef = useRef<HTMLButtonElement>(null);
|
const expandToggleRef = useRef<HTMLButtonElement>(null);
|
||||||
const stripSnapshotRef = useRef<{
|
const stripSnapshotRef = useRef<{
|
||||||
startedAt: number | null;
|
|
||||||
goalState?: GoalStateWsPayload;
|
goalState?: GoalStateWsPayload;
|
||||||
stripLabel: string | null;
|
stripLabel: string | null;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [panelMaxPx, setPanelMaxPx] = useState(280);
|
const [panelMaxPx, setPanelMaxPx] = useState(280);
|
||||||
|
|
||||||
if (active) {
|
if (active) {
|
||||||
stripSnapshotRef.current = { startedAt, goalState, stripLabel };
|
stripSnapshotRef.current = { goalState, stripLabel };
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!active) setGoalPanelOpen(false);
|
if (!active) setGoalPanelOpen(false);
|
||||||
}, [active]);
|
}, [active]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (startedAt == null || !pageVisible) return;
|
|
||||||
setTick((n) => n + 1);
|
|
||||||
const id = window.setInterval(() => setTick((n) => n + 1), 1000);
|
|
||||||
return () => window.clearInterval(id);
|
|
||||||
}, [pageVisible, startedAt]);
|
|
||||||
|
|
||||||
const display = active
|
const display = active
|
||||||
? { startedAt, goalState, stripLabel }
|
? { goalState, stripLabel }
|
||||||
: stripSnapshotRef.current;
|
: stripSnapshotRef.current;
|
||||||
const displayStartedAt = display?.startedAt ?? null;
|
|
||||||
const displayGoalState = display?.goalState;
|
const displayGoalState = display?.goalState;
|
||||||
const displayStripLabel = display?.stripLabel ?? null;
|
const displayStripLabel = display?.stripLabel ?? null;
|
||||||
const displayShowTimer = displayStartedAt != null;
|
|
||||||
const displayShowGoal = !!displayStripLabel?.trim();
|
|
||||||
|
|
||||||
const objectiveFull = displayGoalState?.objective?.trim() ?? "";
|
const objectiveFull = displayGoalState?.objective?.trim() ?? "";
|
||||||
const summaryFull = displayGoalState?.ui_summary?.trim() ?? "";
|
const summaryFull = displayGoalState?.ui_summary?.trim() ?? "";
|
||||||
@@ -818,17 +791,11 @@ function RunElapsedStrip({
|
|||||||
};
|
};
|
||||||
}, [goalPanelOpen]);
|
}, [goalPanelOpen]);
|
||||||
|
|
||||||
const elapsed =
|
if (!display) return null;
|
||||||
displayStartedAt != null ? Math.max(0, Math.floor(Date.now() / 1000 - displayStartedAt)) : 0;
|
|
||||||
const m = Math.floor(elapsed / 60);
|
|
||||||
const sec = elapsed % 60;
|
|
||||||
const shortElapsed = m > 0 ? `${m}:${sec.toString().padStart(2, "0")}` : `${sec}s`;
|
|
||||||
const timerTitle = displayShowTimer
|
|
||||||
? t("thread.composer.runRuntimeTitle", { elapsed: shortElapsed })
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const ariaParts = [timerTitle, displayShowGoal ? displayStripLabel : null].filter(Boolean);
|
const ariaLabel = displayStripLabel
|
||||||
const ariaLabel = ariaParts.join(" · ");
|
? t("thread.composer.goalStateStrip", { label: displayStripLabel })
|
||||||
|
: t("thread.composer.goalStateFallback");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -837,6 +804,11 @@ function RunElapsedStrip({
|
|||||||
data-composer-status-drawer=""
|
data-composer-status-drawer=""
|
||||||
data-state={active ? "open" : "closed"}
|
data-state={active ? "open" : "closed"}
|
||||||
aria-hidden={active ? undefined : true}
|
aria-hidden={active ? undefined : true}
|
||||||
|
onTransitionEnd={(event) => {
|
||||||
|
if (active || event.target !== event.currentTarget) return;
|
||||||
|
stripSnapshotRef.current = null;
|
||||||
|
setTick((n) => n + 1);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{goalPanelOpen && canExpandGoal && markdownBody ? (
|
{goalPanelOpen && canExpandGoal && markdownBody ? (
|
||||||
<div
|
<div
|
||||||
@@ -890,19 +862,9 @@ function RunElapsedStrip({
|
|||||||
role="status"
|
role="status"
|
||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
>
|
>
|
||||||
{displayShowTimer ? (
|
|
||||||
<RunPulseIcon />
|
|
||||||
) : (
|
|
||||||
<Target className="h-4 w-4 shrink-0 text-primary/75" aria-hidden />
|
<Target className="h-4 w-4 shrink-0 text-primary/75" aria-hidden />
|
||||||
)}
|
|
||||||
<span className="flex min-w-0 flex-1 items-center gap-1.5 text-[12px] font-medium text-foreground/75">
|
<span className="flex min-w-0 flex-1 items-center gap-1.5 text-[12px] font-medium text-foreground/75">
|
||||||
{timerTitle ? <span className="shrink-0">{timerTitle}</span> : null}
|
{displayStripLabel ? (
|
||||||
{timerTitle && displayShowGoal ? (
|
|
||||||
<span className="shrink-0 text-muted-foreground/45" aria-hidden>
|
|
||||||
·
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
{displayShowGoal ? (
|
|
||||||
<span className="truncate">
|
<span className="truncate">
|
||||||
{t("thread.composer.goalStateStrip", { label: displayStripLabel })}
|
{t("thread.composer.goalStateStrip", { label: displayStripLabel })}
|
||||||
</span>
|
</span>
|
||||||
@@ -962,7 +924,6 @@ export function ThreadComposer({
|
|||||||
onStop,
|
onStop,
|
||||||
surfaceRef,
|
surfaceRef,
|
||||||
onTranscribeAudio,
|
onTranscribeAudio,
|
||||||
runStartedAt = null,
|
|
||||||
goalState,
|
goalState,
|
||||||
workspaceScope = null,
|
workspaceScope = null,
|
||||||
workspaceControlsHidden = false,
|
workspaceControlsHidden = false,
|
||||||
@@ -970,6 +931,7 @@ export function ThreadComposer({
|
|||||||
workspaceControls = null,
|
workspaceControls = null,
|
||||||
workspaceScopeDisabled = false,
|
workspaceScopeDisabled = false,
|
||||||
workspaceError = null,
|
workspaceError = null,
|
||||||
|
onPickWorkspaceFolder,
|
||||||
onWorkspaceScopeChange,
|
onWorkspaceScopeChange,
|
||||||
pendingQueueKey = null,
|
pendingQueueKey = null,
|
||||||
transcriptionProvider = null,
|
transcriptionProvider = null,
|
||||||
@@ -2284,8 +2246,8 @@ export function ThreadComposer({
|
|||||||
className={cn(
|
className={cn(
|
||||||
"thread-composer-surface group/composer relative mx-auto flex w-full flex-col overflow-visible transition-all duration-200",
|
"thread-composer-surface group/composer relative mx-auto flex w-full flex-col overflow-visible transition-all duration-200",
|
||||||
isHero
|
isHero
|
||||||
? "max-w-[58rem] rounded-[28px] bg-muted/30 focus-within:bg-muted/50 dark:bg-card dark:focus-within:bg-white/[0.06]"
|
? "max-w-[58rem] rounded-prominent bg-muted/30 focus-within:bg-muted/50 dark:bg-card dark:focus-within:bg-white/[0.06]"
|
||||||
: "max-w-[49.5rem] rounded-[22px] bg-muted/30 focus-within:bg-muted/50 dark:bg-card dark:focus-within:bg-white/[0.06]",
|
: "max-w-[49.5rem] rounded-panel bg-muted/30 focus-within:bg-muted/50 dark:bg-card dark:focus-within:bg-white/[0.06]",
|
||||||
interactionDisabled && "opacity-60",
|
interactionDisabled && "opacity-60",
|
||||||
sessionDragPreview && "ring-1 ring-primary/25",
|
sessionDragPreview && "ring-1 ring-primary/25",
|
||||||
isDragging && "ring-2 ring-primary/40 motion-reduce:ring-0 motion-reduce:border-primary",
|
isDragging && "ring-2 ring-primary/40 motion-reduce:ring-0 motion-reduce:border-primary",
|
||||||
@@ -2368,7 +2330,7 @@ export function ThreadComposer({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<RunElapsedStrip startedAt={runStartedAt} goalState={goalState} />
|
<GoalStateStrip goalState={goalState} />
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
{hasMentionDecorations ? (
|
{hasMentionDecorations ? (
|
||||||
<ComposerCliMentionOverlay
|
<ComposerCliMentionOverlay
|
||||||
@@ -2600,6 +2562,7 @@ export function ThreadComposer({
|
|||||||
defaultScope={workspaceDefaultScope}
|
defaultScope={workspaceDefaultScope}
|
||||||
controls={workspaceControls}
|
controls={workspaceControls}
|
||||||
error={workspaceError}
|
error={workspaceError}
|
||||||
|
onPickFolder={onPickWorkspaceFolder}
|
||||||
onChange={onWorkspaceScopeChange}
|
onChange={onWorkspaceScopeChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -2647,7 +2610,7 @@ function QueuedPromptStack({
|
|||||||
role="group"
|
role="group"
|
||||||
data-state="enter"
|
data-state="enter"
|
||||||
className={cn(
|
className={cn(
|
||||||
"composer-status-strip relative z-20 mx-3 mt-3 overflow-hidden rounded-[18px]",
|
"composer-status-strip relative z-20 mx-3 mt-3 overflow-hidden rounded-floating",
|
||||||
"border border-black/[0.05] bg-popover/90 p-1.5",
|
"border border-black/[0.05] bg-popover/90 p-1.5",
|
||||||
"shadow-[0_10px_28px_rgba(15,23,42,0.07)] backdrop-blur-md",
|
"shadow-[0_10px_28px_rgba(15,23,42,0.07)] backdrop-blur-md",
|
||||||
"dark:border-white/[0.08] dark:bg-popover/90 dark:shadow-[0_14px_34px_rgba(0,0,0,0.30)]",
|
"dark:border-white/[0.08] dark:bg-popover/90 dark:shadow-[0_14px_34px_rgba(0,0,0,0.30)]",
|
||||||
@@ -2725,7 +2688,7 @@ function QueuedPromptRow({
|
|||||||
}}
|
}}
|
||||||
onDragEnd={onDragEnd}
|
onDragEnd={onDragEnd}
|
||||||
className={cn(
|
className={cn(
|
||||||
"queued-prompt-row group/queued flex min-h-8 items-center gap-1.5 rounded-[12px] px-2 py-0.5",
|
"queued-prompt-row group/queued flex min-h-8 items-center gap-1.5 rounded-control px-2 py-0.5",
|
||||||
"text-[13px] transition-colors hover:bg-muted/55 dark:hover:bg-white/[0.055]",
|
"text-[13px] transition-colors hover:bg-muted/55 dark:hover:bg-white/[0.055]",
|
||||||
isHero && "text-[13.5px]",
|
isHero && "text-[13.5px]",
|
||||||
)}
|
)}
|
||||||
@@ -3010,7 +2973,7 @@ function MentionCandidateLogo({
|
|||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-5 w-5 shrink-0 items-center justify-center overflow-hidden rounded-[5px]",
|
"flex h-5 w-5 shrink-0 items-center justify-center overflow-hidden rounded-compact",
|
||||||
selected ? "bg-background/55" : "bg-transparent",
|
selected ? "bg-background/55" : "bg-transparent",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -3028,7 +2991,7 @@ function MentionCandidateLogo({
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className="flex h-5 w-5 shrink-0 items-center justify-center rounded-[5px] text-[7.5px] font-semibold text-white"
|
className="flex h-5 w-5 shrink-0 items-center justify-center rounded-compact text-[7.5px] font-semibold text-white"
|
||||||
style={{ backgroundColor: color }}
|
style={{ backgroundColor: color }}
|
||||||
>
|
>
|
||||||
{candidate.initials}
|
{candidate.initials}
|
||||||
@@ -3172,7 +3135,7 @@ function AttachmentChip({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"group relative flex items-center gap-2 rounded-[12px] border px-2 py-1.5",
|
"group relative flex items-center gap-2 rounded-control border px-2 py-1.5",
|
||||||
"transition-colors motion-reduce:transition-none",
|
"transition-colors motion-reduce:transition-none",
|
||||||
tone,
|
tone,
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ interface ThreadMessagesProps {
|
|||||||
temporary?: boolean;
|
temporary?: boolean;
|
||||||
/** When true, agent turn still in flight — keeps activity timeline expanded. */
|
/** When true, agent turn still in flight — keeps activity timeline expanded. */
|
||||||
isStreaming?: boolean;
|
isStreaming?: boolean;
|
||||||
|
activeTurnId?: string | null;
|
||||||
|
/** Optimistic or canonical active-turn start, in unix seconds. */
|
||||||
|
runStartedAt?: number | null;
|
||||||
hiddenUserMessageCount?: number;
|
hiddenUserMessageCount?: number;
|
||||||
cliApps?: CliAppInfo[];
|
cliApps?: CliAppInfo[];
|
||||||
mcpPresets?: McpPresetInfo[];
|
mcpPresets?: McpPresetInfo[];
|
||||||
@@ -53,6 +56,8 @@ export function ThreadMessages({
|
|||||||
messages,
|
messages,
|
||||||
temporary = false,
|
temporary = false,
|
||||||
isStreaming = false,
|
isStreaming = false,
|
||||||
|
activeTurnId = null,
|
||||||
|
runStartedAt = null,
|
||||||
hiddenUserMessageCount = 0,
|
hiddenUserMessageCount = 0,
|
||||||
cliApps = [],
|
cliApps = [],
|
||||||
mcpPresets = [],
|
mcpPresets = [],
|
||||||
@@ -74,6 +79,16 @@ export function ThreadMessages({
|
|||||||
() => isStreaming ? currentActivityClusterIndices(units) : new Set<number>(),
|
() => isStreaming ? currentActivityClusterIndices(units) : new Set<number>(),
|
||||||
[isStreaming, units],
|
[isStreaming, units],
|
||||||
);
|
);
|
||||||
|
const pendingTurn = useMemo(
|
||||||
|
() => pendingTurnProjection(messages, activeTurnId),
|
||||||
|
[activeTurnId, messages],
|
||||||
|
);
|
||||||
|
const pendingActivity = (
|
||||||
|
isStreaming
|
||||||
|
&& liveActivityClusterIndices.size === 0
|
||||||
|
&& pendingTurn !== null
|
||||||
|
&& !pendingTurn.hasVisibleOutput
|
||||||
|
) ? pendingTurn : null;
|
||||||
const unitKeys = useMemo(() => unitKeysForDisplay(units), [units]);
|
const unitKeys = useMemo(() => unitKeysForDisplay(units), [units]);
|
||||||
let nextUserIndex = hiddenUserMessageCount;
|
let nextUserIndex = hiddenUserMessageCount;
|
||||||
|
|
||||||
@@ -136,10 +151,68 @@ export function ThreadMessages({
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
{pendingActivity ? (
|
||||||
|
<div className={units.length > 0 ? "mt-5" : undefined}>
|
||||||
|
<AgentActivityCluster
|
||||||
|
messages={[]}
|
||||||
|
isTurnStreaming
|
||||||
|
hasBodyBelow={false}
|
||||||
|
startedAtMs={
|
||||||
|
runStartedAt != null
|
||||||
|
? runStartedAt * 1000
|
||||||
|
: pendingActivity.startedAtMs
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PendingTurnProjection {
|
||||||
|
startedAtMs?: number;
|
||||||
|
hasVisibleOutput: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pendingTurnProjection(
|
||||||
|
messages: UIMessage[],
|
||||||
|
activeTurnId: string | null,
|
||||||
|
): PendingTurnProjection | null {
|
||||||
|
let promptIndex = -1;
|
||||||
|
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||||
|
const message = messages[index];
|
||||||
|
if (
|
||||||
|
message.role === "user"
|
||||||
|
&& message.deliveryStatus !== "failed"
|
||||||
|
&& (activeTurnId === null || message.turnId === activeTurnId)
|
||||||
|
) {
|
||||||
|
promptIndex = index;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (promptIndex < 0) return null;
|
||||||
|
|
||||||
|
const prompt = messages[promptIndex];
|
||||||
|
const hasVisibleOutput = messages.slice(promptIndex + 1).some((message) => {
|
||||||
|
if (message.role === "user") return false;
|
||||||
|
if (activeTurnId && message.turnId && message.turnId !== activeTurnId) return false;
|
||||||
|
return (
|
||||||
|
message.content.trim().length > 0
|
||||||
|
|| !!message.reasoning?.trim()
|
||||||
|
|| !!message.reasoningStreaming
|
||||||
|
|| message.kind === "trace"
|
||||||
|
|| !!message.media?.length
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...(typeof prompt.createdAt === "number" && Number.isFinite(prompt.createdAt)
|
||||||
|
? { startedAtMs: prompt.createdAt }
|
||||||
|
: {}),
|
||||||
|
hasVisibleOutput,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
interface ThreadDisplayUnitProps {
|
interface ThreadDisplayUnitProps {
|
||||||
unit: DisplayUnit;
|
unit: DisplayUnit;
|
||||||
marginTop: string;
|
marginTop: string;
|
||||||
|
|||||||
@@ -661,6 +661,14 @@ export function ThreadShell({
|
|||||||
forkBoundaryMessageCount,
|
forkBoundaryMessageCount,
|
||||||
} = useSessionHistory(historyKey);
|
} = useSessionHistory(historyKey);
|
||||||
const { client, getToken, ingressLimits, modelName, token } = useClient();
|
const { client, getToken, ingressLimits, modelName, token } = useClient();
|
||||||
|
const pickWorkspaceFolder = useCallback(async (): Promise<string | null> => {
|
||||||
|
const response = await client.requestMutation<{ path: unknown }>(
|
||||||
|
"workspace.pick_folder",
|
||||||
|
{},
|
||||||
|
300_000,
|
||||||
|
);
|
||||||
|
return typeof response.path === "string" ? response.path : null;
|
||||||
|
}, [client]);
|
||||||
const [fallbackModelName, setFallbackModelName] = useState<string | null>(null);
|
const [fallbackModelName, setFallbackModelName] = useState<string | null>(null);
|
||||||
const [booting, setBooting] = useState(false);
|
const [booting, setBooting] = useState(false);
|
||||||
const [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]);
|
const [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]);
|
||||||
@@ -1450,7 +1458,6 @@ export function ThreadShell({
|
|||||||
skills={skills}
|
skills={skills}
|
||||||
onStop={stop}
|
onStop={stop}
|
||||||
onTranscribeAudio={transcribeAudio}
|
onTranscribeAudio={transcribeAudio}
|
||||||
runStartedAt={currentRunStartedAt}
|
|
||||||
goalState={currentGoalState}
|
goalState={currentGoalState}
|
||||||
workspaceScope={workspaceScope}
|
workspaceScope={workspaceScope}
|
||||||
workspaceControlsHidden={temporary}
|
workspaceControlsHidden={temporary}
|
||||||
@@ -1458,6 +1465,9 @@ export function ThreadShell({
|
|||||||
workspaceControls={workspaceControls}
|
workspaceControls={workspaceControls}
|
||||||
workspaceScopeDisabled={workspaceScopeDisabled}
|
workspaceScopeDisabled={workspaceScopeDisabled}
|
||||||
workspaceError={workspaceError}
|
workspaceError={workspaceError}
|
||||||
|
onPickWorkspaceFolder={
|
||||||
|
workspaceControls?.can_pick_folder ? pickWorkspaceFolder : undefined
|
||||||
|
}
|
||||||
onWorkspaceScopeChange={onWorkspaceScopeChange}
|
onWorkspaceScopeChange={onWorkspaceScopeChange}
|
||||||
pendingQueueKey={temporary ? null : chatId}
|
pendingQueueKey={temporary ? null : chatId}
|
||||||
transcriptionProvider={settingsSnapshot?.transcription?.provider}
|
transcriptionProvider={settingsSnapshot?.transcription?.provider}
|
||||||
@@ -1494,7 +1504,6 @@ export function ThreadShell({
|
|||||||
sessions={mentionSessions}
|
sessions={mentionSessions}
|
||||||
skills={skills}
|
skills={skills}
|
||||||
surfaceRef={composerSurfaceRef}
|
surfaceRef={composerSurfaceRef}
|
||||||
runStartedAt={currentRunStartedAt}
|
|
||||||
onTranscribeAudio={transcribeAudio}
|
onTranscribeAudio={transcribeAudio}
|
||||||
goalState={currentGoalState}
|
goalState={currentGoalState}
|
||||||
workspaceScope={workspaceScope}
|
workspaceScope={workspaceScope}
|
||||||
@@ -1503,6 +1512,9 @@ export function ThreadShell({
|
|||||||
workspaceControls={workspaceControls}
|
workspaceControls={workspaceControls}
|
||||||
workspaceScopeDisabled={workspaceScopeDisabled}
|
workspaceScopeDisabled={workspaceScopeDisabled}
|
||||||
workspaceError={workspaceError}
|
workspaceError={workspaceError}
|
||||||
|
onPickWorkspaceFolder={
|
||||||
|
workspaceControls?.can_pick_folder ? pickWorkspaceFolder : undefined
|
||||||
|
}
|
||||||
onWorkspaceScopeChange={onWorkspaceScopeChange}
|
onWorkspaceScopeChange={onWorkspaceScopeChange}
|
||||||
transcriptionProvider={settingsSnapshot?.transcription?.provider}
|
transcriptionProvider={settingsSnapshot?.transcription?.provider}
|
||||||
ingressLimits={ingressLimits}
|
ingressLimits={ingressLimits}
|
||||||
@@ -1565,6 +1577,7 @@ export function ThreadShell({
|
|||||||
messages={displayMessages}
|
messages={displayMessages}
|
||||||
temporary={temporary}
|
temporary={temporary}
|
||||||
isStreaming={turnActive}
|
isStreaming={turnActive}
|
||||||
|
runStartedAt={currentRunStartedAt}
|
||||||
emptyState={emptyState}
|
emptyState={emptyState}
|
||||||
composer={composerPortalTarget === undefined ? composer : null}
|
composer={composerPortalTarget === undefined ? composer : null}
|
||||||
activeTurnId={viewportTurnId}
|
activeTurnId={viewportTurnId}
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ interface ThreadViewportProps {
|
|||||||
messages: UIMessage[];
|
messages: UIMessage[];
|
||||||
temporary?: boolean;
|
temporary?: boolean;
|
||||||
isStreaming: boolean;
|
isStreaming: boolean;
|
||||||
|
/** Optimistic or canonical start time for the active turn, in unix seconds. */
|
||||||
|
runStartedAt?: number | null;
|
||||||
composer?: ReactNode;
|
composer?: ReactNode;
|
||||||
emptyState?: ReactNode;
|
emptyState?: ReactNode;
|
||||||
scrollToBottomSignal?: number;
|
scrollToBottomSignal?: number;
|
||||||
@@ -64,6 +66,9 @@ const DEFAULT_SCROLL_BUTTON_BOTTOM_PX = 192;
|
|||||||
const EXTERNAL_COMPOSER_SCROLL_BUTTON_BOTTOM_PX = 16;
|
const EXTERNAL_COMPOSER_SCROLL_BUTTON_BOTTOM_PX = 16;
|
||||||
const SCROLL_BUTTON_COMPOSER_GAP_PX = 16;
|
const SCROLL_BUTTON_COMPOSER_GAP_PX = 16;
|
||||||
const SOFT_KEYBOARD_MIN_INSET_PX = 80;
|
const SOFT_KEYBOARD_MIN_INSET_PX = 80;
|
||||||
|
const SESSION_HANDOFF_EXIT_DURATION_MS = 80;
|
||||||
|
const SESSION_HANDOFF_ENTER_DURATION_MS = 140;
|
||||||
|
const SESSION_HANDOFF_OPACITY = 0.82;
|
||||||
export const INITIAL_HISTORY_WINDOW = 160;
|
export const INITIAL_HISTORY_WINDOW = 160;
|
||||||
export const HISTORY_WINDOW_INCREMENT = 120;
|
export const HISTORY_WINDOW_INCREMENT = 120;
|
||||||
|
|
||||||
@@ -104,6 +109,13 @@ function isThreadDisclosureTarget(target: EventTarget | null): boolean {
|
|||||||
&& target.closest("[data-thread-disclosure]") !== null;
|
&& target.closest("[data-thread-disclosure]") !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isKeyboardControl(element: Element | null): boolean {
|
||||||
|
return element instanceof HTMLElement
|
||||||
|
&& element.closest(
|
||||||
|
"button, a[href], select, [role='button'], [role='menuitem'], [role='option']",
|
||||||
|
) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
type ThreadScrollDirection = "backward" | "forward";
|
type ThreadScrollDirection = "backward" | "forward";
|
||||||
|
|
||||||
const KEYBOARD_SCROLL_DIRECTIONS: Readonly<
|
const KEYBOARD_SCROLL_DIRECTIONS: Readonly<
|
||||||
@@ -161,6 +173,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
|||||||
messages,
|
messages,
|
||||||
temporary = false,
|
temporary = false,
|
||||||
isStreaming,
|
isStreaming,
|
||||||
|
runStartedAt = null,
|
||||||
composer,
|
composer,
|
||||||
emptyState,
|
emptyState,
|
||||||
scrollToBottomSignal = 0,
|
scrollToBottomSignal = 0,
|
||||||
@@ -187,9 +200,12 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
|||||||
const contentRef = useRef<HTMLDivElement>(null);
|
const contentRef = useRef<HTMLDivElement>(null);
|
||||||
const messageRegionRef = useRef<HTMLDivElement>(null);
|
const messageRegionRef = useRef<HTMLDivElement>(null);
|
||||||
const messageContentRef = useRef<HTMLDivElement>(null);
|
const messageContentRef = useRef<HTMLDivElement>(null);
|
||||||
|
const emptyStateRef = useRef<HTMLDivElement>(null);
|
||||||
const composerDockRef = useRef<HTMLDivElement>(null);
|
const composerDockRef = useRef<HTMLDivElement>(null);
|
||||||
const bottomRef = useRef<HTMLDivElement>(null);
|
const bottomRef = useRef<HTMLDivElement>(null);
|
||||||
const lastConversationKeyRef = useRef<string | null>(conversationKey);
|
const lastConversationKeyRef = useRef<string | null>(conversationKey);
|
||||||
|
const conversationHandoffPendingRef = useRef(false);
|
||||||
|
const conversationHandoffAnimationRef = useRef<Animation | null>(null);
|
||||||
const pendingConversationScrollRef = useRef(true);
|
const pendingConversationScrollRef = useRef(true);
|
||||||
const pendingPromptJumpRef = useRef<string | null>(null);
|
const pendingPromptJumpRef = useRef<string | null>(null);
|
||||||
const restoreScrollAfterPrependRef =
|
const restoreScrollAfterPrependRef =
|
||||||
@@ -422,11 +438,27 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
|||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (lastConversationKeyRef.current === conversationKey) return;
|
if (lastConversationKeyRef.current === conversationKey) return;
|
||||||
lastConversationKeyRef.current = conversationKey;
|
lastConversationKeyRef.current = conversationKey;
|
||||||
|
conversationHandoffAnimationRef.current?.cancel();
|
||||||
|
conversationHandoffAnimationRef.current = null;
|
||||||
|
conversationHandoffPendingRef.current = true;
|
||||||
pendingConversationScrollRef.current = true;
|
pendingConversationScrollRef.current = true;
|
||||||
threadMotionRef.current?.reset();
|
threadMotionRef.current?.reset();
|
||||||
setAtBottom(true);
|
setAtBottom(true);
|
||||||
setVisibleMessageCount(INITIAL_HISTORY_WINDOW);
|
setVisibleMessageCount(INITIAL_HISTORY_WINDOW);
|
||||||
}, [conversationKey]);
|
|
||||||
|
const surface = hasMessages ? messageRegionRef.current : emptyStateRef.current;
|
||||||
|
const reduceMotion = typeof window.matchMedia === "function"
|
||||||
|
&& window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||||
|
if (!surface || reduceMotion || typeof surface.animate !== "function") return;
|
||||||
|
conversationHandoffAnimationRef.current = surface.animate(
|
||||||
|
[{ opacity: 1 }, { opacity: SESSION_HANDOFF_OPACITY }],
|
||||||
|
{
|
||||||
|
duration: SESSION_HANDOFF_EXIT_DURATION_MS,
|
||||||
|
easing: "cubic-bezier(0.2, 0, 0, 1)",
|
||||||
|
fill: "forwards",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}, [conversationKey, hasMessages]);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (!conversationReady) {
|
if (!conversationReady) {
|
||||||
@@ -513,11 +545,41 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
|||||||
scrollToBottom,
|
scrollToBottom,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!conversationReady || !conversationHandoffPendingRef.current) return;
|
||||||
|
conversationHandoffPendingRef.current = false;
|
||||||
|
conversationHandoffAnimationRef.current?.cancel();
|
||||||
|
conversationHandoffAnimationRef.current = null;
|
||||||
|
const surface = hasMessages ? messageRegionRef.current : emptyStateRef.current;
|
||||||
|
const reduceMotion = typeof window.matchMedia === "function"
|
||||||
|
&& window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||||
|
if (!surface || reduceMotion || typeof surface.animate !== "function") return;
|
||||||
|
|
||||||
|
const animation = surface.animate(
|
||||||
|
[{ opacity: SESSION_HANDOFF_OPACITY }, { opacity: 1 }],
|
||||||
|
{
|
||||||
|
duration: SESSION_HANDOFF_ENTER_DURATION_MS,
|
||||||
|
easing: "cubic-bezier(0.2, 0, 0, 1)",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
conversationHandoffAnimationRef.current = animation;
|
||||||
|
const clearAnimation = () => {
|
||||||
|
if (conversationHandoffAnimationRef.current === animation) {
|
||||||
|
conversationHandoffAnimationRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
animation.onfinish = clearAnimation;
|
||||||
|
animation.oncancel = clearAnimation;
|
||||||
|
}, [conversationReady, hasMessages]);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
threadMotionRef.current?.invalidateGeometry();
|
threadMotionRef.current?.invalidateGeometry();
|
||||||
}, [composer, hasMessages, visibleMessages.length]);
|
}, [composer, hasMessages, visibleMessages.length]);
|
||||||
|
|
||||||
useEffect(() => () => threadMotionRef.current?.dispose(), []);
|
useEffect(() => () => {
|
||||||
|
conversationHandoffAnimationRef.current?.cancel();
|
||||||
|
threadMotionRef.current?.dispose();
|
||||||
|
}, []);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const el = scrollRef.current;
|
const el = scrollRef.current;
|
||||||
@@ -530,10 +592,13 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
|||||||
const invalidateGeometry = () => {
|
const invalidateGeometry = () => {
|
||||||
threadMotionRef.current?.invalidateGeometry();
|
threadMotionRef.current?.invalidateGeometry();
|
||||||
};
|
};
|
||||||
invalidateGeometry();
|
const reconcileObservedGeometry = () => {
|
||||||
|
threadMotionRef.current?.reconcileObservedGeometry();
|
||||||
|
};
|
||||||
|
reconcileObservedGeometry();
|
||||||
const observer = typeof ResizeObserver === "undefined"
|
const observer = typeof ResizeObserver === "undefined"
|
||||||
? null
|
? null
|
||||||
: new ResizeObserver(invalidateGeometry);
|
: new ResizeObserver(reconcileObservedGeometry);
|
||||||
observer?.observe(el);
|
observer?.observe(el);
|
||||||
if (content) observer?.observe(content);
|
if (content) observer?.observe(content);
|
||||||
if (messageRegion) observer?.observe(messageRegion);
|
if (messageRegion) observer?.observe(messageRegion);
|
||||||
@@ -623,6 +688,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
|||||||
yieldCameraToUser();
|
yieldCameraToUser();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (isKeyboardControl(event.target as Element | null)) return;
|
||||||
handleDirectionalInput(keyboardScrollDirection(event));
|
handleDirectionalInput(keyboardScrollDirection(event));
|
||||||
};
|
};
|
||||||
el.addEventListener("scroll", handleScroll, { passive: true });
|
el.addEventListener("scroll", handleScroll, { passive: true });
|
||||||
@@ -690,6 +756,8 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
|||||||
messages={visibleMessages}
|
messages={visibleMessages}
|
||||||
temporary={temporary}
|
temporary={temporary}
|
||||||
isStreaming={isStreaming}
|
isStreaming={isStreaming}
|
||||||
|
activeTurnId={activeTurnId}
|
||||||
|
runStartedAt={runStartedAt}
|
||||||
hiddenUserMessageCount={hiddenUserMessageCount}
|
hiddenUserMessageCount={hiddenUserMessageCount}
|
||||||
cliApps={cliApps}
|
cliApps={cliApps}
|
||||||
mcpPresets={mcpPresets}
|
mcpPresets={mcpPresets}
|
||||||
@@ -704,6 +772,8 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
|
ref={emptyStateRef}
|
||||||
|
data-testid="thread-empty-region"
|
||||||
className={cn(
|
className={cn(
|
||||||
"row-start-1 flex min-h-0 min-w-0 w-full items-center justify-center",
|
"row-start-1 flex min-h-0 min-w-0 w-full items-center justify-center",
|
||||||
hasComposer && "sm:items-end sm:pb-8",
|
hasComposer && "sm:items-end sm:pb-8",
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ export function WorkspaceProjectPicker({
|
|||||||
defaultScope,
|
defaultScope,
|
||||||
controls,
|
controls,
|
||||||
error,
|
error,
|
||||||
|
onPickFolder,
|
||||||
onChange,
|
onChange,
|
||||||
}: {
|
}: {
|
||||||
isHero: boolean;
|
isHero: boolean;
|
||||||
@@ -61,6 +62,7 @@ export function WorkspaceProjectPicker({
|
|||||||
defaultScope: WorkspaceScopePayload | null;
|
defaultScope: WorkspaceScopePayload | null;
|
||||||
controls: WorkspacesPayload["controls"] | null;
|
controls: WorkspacesPayload["controls"] | null;
|
||||||
error?: string | null;
|
error?: string | null;
|
||||||
|
onPickFolder?: () => Promise<string | null>;
|
||||||
onChange?: (scope: WorkspaceScopePayload) => void;
|
onChange?: (scope: WorkspaceScopePayload) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -79,7 +81,7 @@ export function WorkspaceProjectPicker({
|
|||||||
&& !!defaultScope
|
&& !!defaultScope
|
||||||
&& !!onChange
|
&& !!onChange
|
||||||
&& controls?.can_change_project !== false;
|
&& controls?.can_change_project !== false;
|
||||||
const pickFolder = getRuntimeHost().pickFolder;
|
const pickFolder = getRuntimeHost().pickFolder ?? onPickFolder;
|
||||||
const nativeProjectPicker = !!pickFolder;
|
const nativeProjectPicker = !!pickFolder;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -219,7 +221,7 @@ export function WorkspaceProjectPicker({
|
|||||||
"flex min-h-[48px] w-full cursor-default gap-3 px-3 py-2.5 focus:bg-muted/55",
|
"flex min-h-[48px] w-full cursor-default gap-3 px-3 py-2.5 focus:bg-muted/55",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="grid h-8 w-8 shrink-0 place-items-center rounded-[12px] bg-muted text-foreground/80">
|
<span className="grid h-8 w-8 shrink-0 place-items-center rounded-control bg-muted text-foreground/80">
|
||||||
<Folder className="h-4 w-4" />
|
<Folder className="h-4 w-4" />
|
||||||
</span>
|
</span>
|
||||||
<span className="min-w-0 flex-1">
|
<span className="min-w-0 flex-1">
|
||||||
@@ -326,7 +328,7 @@ export function WorkspaceAccessMenu({
|
|||||||
aria-label={accessAriaLabel}
|
aria-label={accessAriaLabel}
|
||||||
title={accessLabel}
|
title={accessLabel}
|
||||||
className={cn(
|
className={cn(
|
||||||
"thread-composer-access touch-target min-w-0 max-w-[min(12.5rem,42vw)] whitespace-nowrap rounded-[10px] border border-transparent font-semibold shadow-none",
|
"thread-composer-access touch-target min-w-0 max-w-[min(12.5rem,42vw)] whitespace-nowrap rounded-control border border-transparent font-semibold shadow-none",
|
||||||
isHero ? "h-8 px-2.5 text-[12px]" : "h-9 px-3 text-[12.5px]",
|
isHero ? "h-8 px-2.5 text-[12px]" : "h-9 px-3 text-[12.5px]",
|
||||||
isFull
|
isFull
|
||||||
? "bg-transparent text-orange-600 hover:bg-orange-500/8 dark:text-orange-300 dark:hover:bg-orange-400/10"
|
? "bg-transparent text-orange-600 hover:bg-orange-500/8 dark:text-orange-300 dark:hover:bg-orange-400/10"
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ interface ThinkingReasoningShellProps {
|
|||||||
contentRef: Ref<HTMLDivElement>;
|
contentRef: Ref<HTMLDivElement>;
|
||||||
fadeTop: boolean;
|
fadeTop: boolean;
|
||||||
fadeBottom: boolean;
|
fadeBottom: boolean;
|
||||||
|
hasDetails?: boolean;
|
||||||
onToggle: () => void;
|
onToggle: () => void;
|
||||||
onScroll: () => void;
|
onScroll: () => void;
|
||||||
}
|
}
|
||||||
@@ -25,6 +26,7 @@ export function ThinkingReasoningShell({
|
|||||||
contentRef,
|
contentRef,
|
||||||
fadeTop,
|
fadeTop,
|
||||||
fadeBottom,
|
fadeBottom,
|
||||||
|
hasDetails = true,
|
||||||
onToggle,
|
onToggle,
|
||||||
onScroll,
|
onScroll,
|
||||||
}: ThinkingReasoningShellProps) {
|
}: ThinkingReasoningShellProps) {
|
||||||
@@ -33,6 +35,7 @@ export function ThinkingReasoningShell({
|
|||||||
className="flex w-full max-w-[45rem] animate-in flex-col fade-in duration-300 motion-reduce:animate-none"
|
className="flex w-full max-w-[45rem] animate-in flex-col fade-in duration-300 motion-reduce:animate-none"
|
||||||
data-state={active ? "thinking" : "done"}
|
data-state={active ? "thinking" : "done"}
|
||||||
>
|
>
|
||||||
|
{hasDetails ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-thread-disclosure=""
|
data-thread-disclosure=""
|
||||||
@@ -67,7 +70,25 @@ export function ThinkingReasoningShell({
|
|||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="inline-flex min-h-5 items-center self-start"
|
||||||
|
role="status"
|
||||||
|
aria-label={label}
|
||||||
|
aria-live={active ? "polite" : undefined}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"min-w-0 truncate text-[13px] font-medium leading-[18px] text-muted-foreground/70",
|
||||||
|
active && "animate-pulse motion-reduce:animate-none",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasDetails ? (
|
||||||
<div
|
<div
|
||||||
{...(!expanded ? { inert: "" } : {})}
|
{...(!expanded ? { inert: "" } : {})}
|
||||||
aria-hidden={!expanded}
|
aria-hidden={!expanded}
|
||||||
@@ -107,6 +128,7 @@ export function ThinkingReasoningShell({
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ function WebFavicon({ host, active }: { host: string; active: boolean }) {
|
|||||||
<img
|
<img
|
||||||
src={logoUrl}
|
src={logoUrl}
|
||||||
alt=""
|
alt=""
|
||||||
className={`h-4 w-4 shrink-0 rounded-[3px] object-contain${active ? " animate-pulse" : ""}`}
|
className={`h-4 w-4 shrink-0 rounded-mark object-contain${active ? " animate-pulse" : ""}`}
|
||||||
decoding="async"
|
decoding="async"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
referrerPolicy="no-referrer"
|
referrerPolicy="no-referrer"
|
||||||
|
|||||||
@@ -147,10 +147,10 @@ function defaultScheduler(): ThreadMotionScheduler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Owns the policy that turns discrete layout events into automatic tail
|
* Owns the policy that turns layout events into automatic tail pinning or
|
||||||
* pinning or explicit camera navigation. Callers only invalidate geometry;
|
* explicit camera navigation. Discrete notifications are coalesced into one
|
||||||
* one display frame coalesces those notifications and reads the authoritative
|
* display frame. ResizeObserver deliveries reconcile immediately because they
|
||||||
* layout before applying either policy.
|
* already carry the browser's authoritative layout and run before paint.
|
||||||
*/
|
*/
|
||||||
export class ThreadMotionCoordinator {
|
export class ThreadMotionCoordinator {
|
||||||
private readonly camera: ThreadMotionCamera;
|
private readonly camera: ThreadMotionCamera;
|
||||||
@@ -240,6 +240,15 @@ export class ThreadMotionCoordinator {
|
|||||||
this.measurementFrameId = this.scheduler.request(this.flushGeometry);
|
this.measurementFrameId = this.scheduler.request(this.flushGeometry);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
reconcileObservedGeometry(): void {
|
||||||
|
if (this.measurementFrameId !== null) {
|
||||||
|
this.scheduler.cancel(this.measurementFrameId);
|
||||||
|
this.measurementFrameId = null;
|
||||||
|
}
|
||||||
|
this.geometryDirty = true;
|
||||||
|
this.flushGeometry();
|
||||||
|
}
|
||||||
|
|
||||||
handleComposerInput(): void {
|
handleComposerInput(): void {
|
||||||
// Input and protocol completion can arrive in either order. Remember
|
// Input and protocol completion can arrive in either order. Remember
|
||||||
// editing that starts just before turn_end so the completion drawer
|
// editing that starts just before turn_end so the completion drawer
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ const AlertDialogContent = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
modalSurfaceClassName,
|
modalSurfaceClassName,
|
||||||
"grid w-full max-w-lg origin-center gap-4 rounded-[22px] p-6 duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
|
"grid w-full max-w-lg origin-center gap-4 rounded-modal p-6 duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -64,7 +64,7 @@ const AlertDialogFooter = ({
|
|||||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const buttonVariants = cva(
|
const buttonVariants = cva(
|
||||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
"inline-flex items-center justify-center whitespace-nowrap rounded-control text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
@@ -21,8 +21,8 @@ const buttonVariants = cva(
|
|||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
default: "h-10 px-4 py-2",
|
default: "h-10 px-4 py-2",
|
||||||
sm: "h-9 rounded-md px-3",
|
sm: "h-9 px-3",
|
||||||
lg: "h-11 rounded-md px-8",
|
lg: "h-11 px-8",
|
||||||
icon: "h-9 w-9",
|
icon: "h-9 w-9",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ const DialogContent = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
modalSurfaceClassName,
|
modalSurfaceClassName,
|
||||||
"grid w-full max-w-lg origin-center gap-4 rounded-[22px] p-6 duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
|
"grid w-full max-w-lg origin-center gap-4 rounded-modal p-6 duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -84,7 +84,7 @@ const DialogFooter = ({
|
|||||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ export const floatingSurfaceElevationClassName =
|
|||||||
"bg-popover text-popover-foreground shadow-[0_8px_24px_rgba(15,23,42,0.10)] dark:shadow-[0_12px_28px_rgba(0,0,0,0.32)]";
|
"bg-popover text-popover-foreground shadow-[0_8px_24px_rgba(15,23,42,0.10)] dark:shadow-[0_12px_28px_rgba(0,0,0,0.32)]";
|
||||||
|
|
||||||
export const floatingSurfaceVisualClassName =
|
export const floatingSurfaceVisualClassName =
|
||||||
`rounded-[18px] p-1.5 ${floatingSurfaceElevationClassName}`;
|
`rounded-floating p-1.5 ${floatingSurfaceElevationClassName}`;
|
||||||
|
|
||||||
export const modalOverlayClassName =
|
export const modalOverlayClassName =
|
||||||
"fixed inset-0 z-50 bg-black/45 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0";
|
"fixed inset-0 z-50 bg-black/45 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0";
|
||||||
@@ -17,7 +17,7 @@ export const floatingSurfaceMotionClassName =
|
|||||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0";
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0";
|
||||||
|
|
||||||
export const floatingItemClassName =
|
export const floatingItemClassName =
|
||||||
"relative flex min-h-8 select-none items-center gap-2 rounded-[12px] px-2.5 py-2 text-[13px] outline-none transition-colors [&>svg]:h-4 [&>svg]:w-4 [&>svg]:shrink-0";
|
"relative flex min-h-8 select-none items-center gap-2 rounded-control px-2.5 py-2 text-[13px] outline-none transition-colors [&>svg]:h-4 [&>svg]:w-4 [&>svg]:shrink-0";
|
||||||
|
|
||||||
export const floatingItemFocusClassName =
|
export const floatingItemFocusClassName =
|
||||||
"focus:bg-foreground/[0.055] focus:text-foreground dark:focus:bg-white/[0.08]";
|
"focus:bg-foreground/[0.055] focus:text-foreground dark:focus:bg-white/[0.08]";
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|||||||
<input
|
<input
|
||||||
type={type}
|
type={type}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
|
"flex h-10 w-full rounded-control border border-input bg-background px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
formControlFocusClassName,
|
formControlFocusClassName,
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
|||||||
return (
|
return (
|
||||||
<textarea
|
<textarea
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex min-h-[60px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
|
"flex min-h-[60px] w-full rounded-control border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
formControlFocusClassName,
|
formControlFocusClassName,
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ const TooltipContent = React.forwardRef<
|
|||||||
sideOffset={sideOffset}
|
sideOffset={sideOffset}
|
||||||
className={cn(
|
className={cn(
|
||||||
floatingSurfaceElevationClassName,
|
floatingSurfaceElevationClassName,
|
||||||
"z-50 overflow-hidden rounded-[10px] px-3 py-1.5 text-xs animate-in fade-in-0 zoom-in-95",
|
"z-50 overflow-hidden rounded-control px-3 py-1.5 text-xs animate-in fade-in-0 zoom-in-95",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -220,7 +220,9 @@ export function PaneWorkbench({
|
|||||||
const gridRef = useRef<HTMLDivElement | null>(null);
|
const gridRef = useRef<HTMLDivElement | null>(null);
|
||||||
const paneRefs = useRef(new Map<string, HTMLElement>());
|
const paneRefs = useRef(new Map<string, HTMLElement>());
|
||||||
const lastRectsRef = useRef(new Map<string, DOMRect>());
|
const lastRectsRef = useRef(new Map<string, DOMRect>());
|
||||||
|
const lastElementRectsRef = useRef(new Map<HTMLElement, DOMRect>());
|
||||||
const pendingRectsRef = useRef<Map<string, DOMRect> | null>(null);
|
const pendingRectsRef = useRef<Map<string, DOMRect> | null>(null);
|
||||||
|
const pendingElementRectsRef = useRef<Map<HTMLElement, DOMRect> | null>(null);
|
||||||
const animationsRef = useRef(new Map<string, Animation>());
|
const animationsRef = useRef(new Map<string, Animation>());
|
||||||
const sourceSplitRatiosKey = splitRatios.join("\u0000");
|
const sourceSplitRatiosKey = splitRatios.join("\u0000");
|
||||||
const [previewSplitRatios, setPreviewSplitRatios] = useState(splitRatios);
|
const [previewSplitRatios, setPreviewSplitRatios] = useState(splitRatios);
|
||||||
@@ -284,24 +286,36 @@ export function PaneWorkbench({
|
|||||||
return rects;
|
return rects;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const measurePaneElements = useCallback(() => {
|
||||||
|
const rects = new Map<HTMLElement, DOMRect>();
|
||||||
|
for (const element of paneRefs.current.values()) {
|
||||||
|
if (!element.hidden) rects.set(element, element.getBoundingClientRect());
|
||||||
|
}
|
||||||
|
return rects;
|
||||||
|
}, []);
|
||||||
|
|
||||||
const captureLayout = useCallback(() => {
|
const captureLayout = useCallback(() => {
|
||||||
pendingRectsRef.current = measurePanes();
|
pendingRectsRef.current = measurePanes();
|
||||||
|
pendingElementRectsRef.current = measurePaneElements();
|
||||||
for (const animation of animationsRef.current.values()) animation.cancel();
|
for (const animation of animationsRef.current.values()) animation.cancel();
|
||||||
animationsRef.current.clear();
|
animationsRef.current.clear();
|
||||||
}, [measurePanes]);
|
}, [measurePaneElements, measurePanes]);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const previousRects = pendingRectsRef.current ?? lastRectsRef.current;
|
const previousRects = pendingRectsRef.current ?? lastRectsRef.current;
|
||||||
|
const previousElementRects = pendingElementRectsRef.current ?? lastElementRectsRef.current;
|
||||||
pendingRectsRef.current = null;
|
pendingRectsRef.current = null;
|
||||||
|
pendingElementRectsRef.current = null;
|
||||||
const nextRects = measurePanes();
|
const nextRects = measurePanes();
|
||||||
|
const nextElementRects = measurePaneElements();
|
||||||
const reduceMotion = typeof window.matchMedia === "function"
|
const reduceMotion = typeof window.matchMedia === "function"
|
||||||
&& window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
&& window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||||
|
|
||||||
if (!reduceMotion) {
|
if (!reduceMotion) {
|
||||||
for (const [key, nextRect] of nextRects) {
|
for (const [key, nextRect] of nextRects) {
|
||||||
const previousRect = previousRects.get(key);
|
|
||||||
const element = paneRefs.current.get(key);
|
const element = paneRefs.current.get(key);
|
||||||
if (!element) continue;
|
if (!element) continue;
|
||||||
|
const previousRect = previousRects.get(key) ?? previousElementRects.get(element);
|
||||||
if (!previousRect) {
|
if (!previousRect) {
|
||||||
if (previousRects.size === 0 || typeof element.animate !== "function") continue;
|
if (previousRects.size === 0 || typeof element.animate !== "function") continue;
|
||||||
const animation = element.animate(
|
const animation = element.animate(
|
||||||
@@ -356,7 +370,8 @@ export function PaneWorkbench({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
lastRectsRef.current = nextRects;
|
lastRectsRef.current = nextRects;
|
||||||
}, [activePaneKey, effectiveLayout, measurePanes, paneOrder]);
|
lastElementRectsRef.current = nextElementRects;
|
||||||
|
}, [activePaneKey, effectiveLayout, measurePaneElements, measurePanes, paneOrder]);
|
||||||
|
|
||||||
useEffect(() => () => {
|
useEffect(() => () => {
|
||||||
for (const animation of animationsRef.current.values()) animation.cancel();
|
for (const animation of animationsRef.current.values()) animation.cancel();
|
||||||
|
|||||||
+11
-58
@@ -38,6 +38,15 @@
|
|||||||
--temporary-foreground: 17 88% 32%;
|
--temporary-foreground: 17 88% 32%;
|
||||||
--temporary-border: 17 88% 40%;
|
--temporary-border: 17 88% 40%;
|
||||||
--radius: 0.4375rem;
|
--radius: 0.4375rem;
|
||||||
|
/* Shared shape scale: marks, compact content, controls, and progressively larger surfaces. */
|
||||||
|
--radius-mark: 0.25rem;
|
||||||
|
--radius-compact: 0.5rem;
|
||||||
|
--radius-control: 0.75rem;
|
||||||
|
--radius-floating: 1.125rem;
|
||||||
|
--radius-panel: 1.375rem;
|
||||||
|
--radius-modal: 1.375rem;
|
||||||
|
--radius-prominent: 1.75rem;
|
||||||
|
--radius-pill: 9999px;
|
||||||
--sidebar: 40 8% 96.8%;
|
--sidebar: 40 8% 96.8%;
|
||||||
--sidebar-foreground: 0 0% 3.9%;
|
--sidebar-foreground: 0 0% 3.9%;
|
||||||
--sidebar-selected: 40 1% 89.4%;
|
--sidebar-selected: 40 1% 89.4%;
|
||||||
@@ -121,7 +130,7 @@
|
|||||||
|
|
||||||
*::-webkit-scrollbar-thumb {
|
*::-webkit-scrollbar-thumb {
|
||||||
background-color: var(--scrollbar-thumb);
|
background-color: var(--scrollbar-thumb);
|
||||||
border-radius: 9999px;
|
border-radius: var(--radius-pill);
|
||||||
}
|
}
|
||||||
|
|
||||||
*::-webkit-scrollbar-thumb:hover {
|
*::-webkit-scrollbar-thumb:hover {
|
||||||
@@ -478,50 +487,6 @@
|
|||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@keyframes run-pulse-dot {
|
|
||||||
0%,
|
|
||||||
100% {
|
|
||||||
transform: scale(0.9);
|
|
||||||
opacity: 0.76;
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
transform: scale(1.08);
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@keyframes run-pulse-ring {
|
|
||||||
0% {
|
|
||||||
transform: scale(0.42);
|
|
||||||
opacity: 0.34;
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
transform: scale(1.28);
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.run-pulse-icon {
|
|
||||||
color: hsl(204 82% 46%);
|
|
||||||
}
|
|
||||||
.run-pulse-icon__ring,
|
|
||||||
.run-pulse-icon__dot {
|
|
||||||
display: block;
|
|
||||||
border-radius: 999px;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
.run-pulse-icon__ring {
|
|
||||||
position: absolute;
|
|
||||||
height: 12px;
|
|
||||||
width: 12px;
|
|
||||||
background: hsl(204 82% 46% / 0.22);
|
|
||||||
animation: run-pulse-ring 1.55s ease-out infinite;
|
|
||||||
}
|
|
||||||
.run-pulse-icon__dot {
|
|
||||||
height: 6px;
|
|
||||||
width: 6px;
|
|
||||||
background: currentColor;
|
|
||||||
box-shadow: 0 0 0 1px hsl(204 82% 46% / 0.14);
|
|
||||||
animation: run-pulse-dot 1.55s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
@keyframes queued-prompt-row-enter {
|
@keyframes queued-prompt-row-enter {
|
||||||
0% {
|
0% {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
@@ -552,18 +517,6 @@
|
|||||||
.thread-layout {
|
.thread-layout {
|
||||||
transition-duration: 0.01ms;
|
transition-duration: 0.01ms;
|
||||||
}
|
}
|
||||||
.run-pulse-icon,
|
|
||||||
.run-pulse-icon * {
|
|
||||||
animation: none;
|
|
||||||
}
|
|
||||||
.run-pulse-icon__ring {
|
|
||||||
opacity: 0.18;
|
|
||||||
transform: scale(1);
|
|
||||||
}
|
|
||||||
.run-pulse-icon__dot {
|
|
||||||
opacity: 1;
|
|
||||||
transform: scale(1);
|
|
||||||
}
|
|
||||||
.queued-prompt-row {
|
.queued-prompt-row {
|
||||||
animation: none;
|
animation: none;
|
||||||
}
|
}
|
||||||
@@ -623,7 +576,7 @@
|
|||||||
}
|
}
|
||||||
.scrollbar-thin::-webkit-scrollbar-thumb {
|
.scrollbar-thin::-webkit-scrollbar-thumb {
|
||||||
background-color: hsl(var(--muted-foreground) / 0.4);
|
background-color: hsl(var(--muted-foreground) / 0.4);
|
||||||
border-radius: 9999px;
|
border-radius: var(--radius-pill);
|
||||||
}
|
}
|
||||||
.scrollbar-track-transparent {
|
.scrollbar-track-transparent {
|
||||||
scrollbar-gutter: stable;
|
scrollbar-gutter: stable;
|
||||||
|
|||||||
@@ -1451,16 +1451,16 @@
|
|||||||
"workbench": {
|
"workbench": {
|
||||||
"aria": "Conversation workbench",
|
"aria": "Conversation workbench",
|
||||||
"panes": "Panes",
|
"panes": "Panes",
|
||||||
"tabAria": "Tab: {{title}}",
|
"tabAria": "Group: {{title}}",
|
||||||
"panesInTab": "Panes in {{title}}",
|
"panesInTab": "Panes in {{title}}",
|
||||||
"collapseTabGroup": "Collapse panes in {{title}}",
|
"collapseTabGroup": "Collapse panes in {{title}}",
|
||||||
"expandTabGroup": "Expand panes in {{title}}",
|
"expandTabGroup": "Expand panes in {{title}}",
|
||||||
"dropPane": "Move {{pane}} into {{tab}}",
|
"dropPane": "Move {{pane}} into {{tab}}",
|
||||||
"createGroup": "Create group",
|
"createGroup": "Create group",
|
||||||
"moveTo": "Move to",
|
"moveTo": "Move to",
|
||||||
"renameTabTitle": "Rename tab",
|
"renameGroupTitle": "Rename group",
|
||||||
"renameTabDescription": "Give this tab a name for organizing its panes.",
|
"renameGroupDescription": "Give this group a name.",
|
||||||
"renameTabPlaceholder": "Tab name",
|
"renameGroupPlaceholder": "Group name",
|
||||||
"dissolveTab": "Dissolve group",
|
"dissolveTab": "Dissolve group",
|
||||||
"layout": "Pane layout",
|
"layout": "Pane layout",
|
||||||
"addPane": "Add pane",
|
"addPane": "Add pane",
|
||||||
|
|||||||
@@ -1438,16 +1438,16 @@
|
|||||||
"workbench": {
|
"workbench": {
|
||||||
"aria": "Área de conversaciones",
|
"aria": "Área de conversaciones",
|
||||||
"panes": "Paneles",
|
"panes": "Paneles",
|
||||||
"tabAria": "Pestaña: {{title}}",
|
"tabAria": "Grupo: {{title}}",
|
||||||
"panesInTab": "Paneles de {{title}}",
|
"panesInTab": "Paneles de {{title}}",
|
||||||
"collapseTabGroup": "Contraer los paneles de {{title}}",
|
"collapseTabGroup": "Contraer los paneles de {{title}}",
|
||||||
"expandTabGroup": "Expandir los paneles de {{title}}",
|
"expandTabGroup": "Expandir los paneles de {{title}}",
|
||||||
"dropPane": "Mover {{pane}} a {{tab}}",
|
"dropPane": "Mover {{pane}} a {{tab}}",
|
||||||
"createGroup": "Crear grupo",
|
"createGroup": "Crear grupo",
|
||||||
"moveTo": "Mover a",
|
"moveTo": "Mover a",
|
||||||
"renameTabTitle": "Renombrar pestaña",
|
"renameGroupTitle": "Renombrar grupo",
|
||||||
"renameTabDescription": "Ponle un nombre a esta pestaña para organizar sus paneles.",
|
"renameGroupDescription": "Ponle un nombre a este grupo.",
|
||||||
"renameTabPlaceholder": "Nombre de la pestaña",
|
"renameGroupPlaceholder": "Nombre del grupo",
|
||||||
"dissolveTab": "Disolver grupo",
|
"dissolveTab": "Disolver grupo",
|
||||||
"layout": "Diseño de paneles",
|
"layout": "Diseño de paneles",
|
||||||
"addPane": "Añadir panel",
|
"addPane": "Añadir panel",
|
||||||
|
|||||||
@@ -1437,16 +1437,16 @@
|
|||||||
"workbench": {
|
"workbench": {
|
||||||
"aria": "Espace de conversations",
|
"aria": "Espace de conversations",
|
||||||
"panes": "Volets",
|
"panes": "Volets",
|
||||||
"tabAria": "Onglet : {{title}}",
|
"tabAria": "Groupe : {{title}}",
|
||||||
"panesInTab": "Volets dans {{title}}",
|
"panesInTab": "Volets dans {{title}}",
|
||||||
"collapseTabGroup": "Réduire les volets de {{title}}",
|
"collapseTabGroup": "Réduire les volets de {{title}}",
|
||||||
"expandTabGroup": "Développer les volets de {{title}}",
|
"expandTabGroup": "Développer les volets de {{title}}",
|
||||||
"dropPane": "Déplacer {{pane}} dans {{tab}}",
|
"dropPane": "Déplacer {{pane}} dans {{tab}}",
|
||||||
"createGroup": "Créer un groupe",
|
"createGroup": "Créer un groupe",
|
||||||
"moveTo": "Déplacer vers",
|
"moveTo": "Déplacer vers",
|
||||||
"renameTabTitle": "Renommer l’onglet",
|
"renameGroupTitle": "Renommer le groupe",
|
||||||
"renameTabDescription": "Donnez un nom à cet onglet pour organiser ses volets.",
|
"renameGroupDescription": "Donnez un nom à ce groupe.",
|
||||||
"renameTabPlaceholder": "Nom de l’onglet",
|
"renameGroupPlaceholder": "Nom du groupe",
|
||||||
"dissolveTab": "Dissoudre le groupe",
|
"dissolveTab": "Dissoudre le groupe",
|
||||||
"layout": "Disposition des volets",
|
"layout": "Disposition des volets",
|
||||||
"addPane": "Ajouter un volet",
|
"addPane": "Ajouter un volet",
|
||||||
|
|||||||
@@ -1437,16 +1437,16 @@
|
|||||||
"workbench": {
|
"workbench": {
|
||||||
"aria": "Ruang kerja percakapan",
|
"aria": "Ruang kerja percakapan",
|
||||||
"panes": "Panel",
|
"panes": "Panel",
|
||||||
"tabAria": "Tab: {{title}}",
|
"tabAria": "Grup: {{title}}",
|
||||||
"panesInTab": "Panel di {{title}}",
|
"panesInTab": "Panel di {{title}}",
|
||||||
"collapseTabGroup": "Ciutkan panel di {{title}}",
|
"collapseTabGroup": "Ciutkan panel di {{title}}",
|
||||||
"expandTabGroup": "Luaskan panel di {{title}}",
|
"expandTabGroup": "Luaskan panel di {{title}}",
|
||||||
"dropPane": "Pindahkan {{pane}} ke {{tab}}",
|
"dropPane": "Pindahkan {{pane}} ke {{tab}}",
|
||||||
"createGroup": "Buat grup",
|
"createGroup": "Buat grup",
|
||||||
"moveTo": "Pindahkan ke",
|
"moveTo": "Pindahkan ke",
|
||||||
"renameTabTitle": "Ganti nama tab",
|
"renameGroupTitle": "Ganti nama grup",
|
||||||
"renameTabDescription": "Beri nama tab ini untuk mengatur panelnya.",
|
"renameGroupDescription": "Beri nama untuk grup ini.",
|
||||||
"renameTabPlaceholder": "Nama tab",
|
"renameGroupPlaceholder": "Nama grup",
|
||||||
"dissolveTab": "Bubarkan grup",
|
"dissolveTab": "Bubarkan grup",
|
||||||
"layout": "Tata letak panel",
|
"layout": "Tata letak panel",
|
||||||
"addPane": "Tambah panel",
|
"addPane": "Tambah panel",
|
||||||
|
|||||||
@@ -1437,16 +1437,16 @@
|
|||||||
"workbench": {
|
"workbench": {
|
||||||
"aria": "会話ワークベンチ",
|
"aria": "会話ワークベンチ",
|
||||||
"panes": "ペイン",
|
"panes": "ペイン",
|
||||||
"tabAria": "タブ:{{title}}",
|
"tabAria": "グループ:{{title}}",
|
||||||
"panesInTab": "{{title}} のペイン",
|
"panesInTab": "{{title}} のペイン",
|
||||||
"collapseTabGroup": "{{title}} のペインを折りたたむ",
|
"collapseTabGroup": "{{title}} のペインを折りたたむ",
|
||||||
"expandTabGroup": "{{title}} のペインを展開する",
|
"expandTabGroup": "{{title}} のペインを展開する",
|
||||||
"dropPane": "{{pane}} を {{tab}} に移動",
|
"dropPane": "{{pane}} を {{tab}} に移動",
|
||||||
"createGroup": "グループを作成",
|
"createGroup": "グループを作成",
|
||||||
"moveTo": "移動先",
|
"moveTo": "移動先",
|
||||||
"renameTabTitle": "タブ名を変更",
|
"renameGroupTitle": "グループ名を変更",
|
||||||
"renameTabDescription": "ペインを整理するため、このタブに名前を付けます。",
|
"renameGroupDescription": "このグループに名前を付けます。",
|
||||||
"renameTabPlaceholder": "タブ名",
|
"renameGroupPlaceholder": "グループ名",
|
||||||
"dissolveTab": "グループを解除",
|
"dissolveTab": "グループを解除",
|
||||||
"layout": "ペインレイアウト",
|
"layout": "ペインレイアウト",
|
||||||
"addPane": "ペインを追加",
|
"addPane": "ペインを追加",
|
||||||
|
|||||||
@@ -1437,16 +1437,16 @@
|
|||||||
"workbench": {
|
"workbench": {
|
||||||
"aria": "대화 워크벤치",
|
"aria": "대화 워크벤치",
|
||||||
"panes": "창",
|
"panes": "창",
|
||||||
"tabAria": "탭: {{title}}",
|
"tabAria": "그룹: {{title}}",
|
||||||
"panesInTab": "{{title}}의 창",
|
"panesInTab": "{{title}}의 창",
|
||||||
"collapseTabGroup": "{{title}}의 창 접기",
|
"collapseTabGroup": "{{title}}의 창 접기",
|
||||||
"expandTabGroup": "{{title}}의 창 펼치기",
|
"expandTabGroup": "{{title}}의 창 펼치기",
|
||||||
"dropPane": "{{pane}}을(를) {{tab}}으로 이동",
|
"dropPane": "{{pane}}을(를) {{tab}}으로 이동",
|
||||||
"createGroup": "그룹 만들기",
|
"createGroup": "그룹 만들기",
|
||||||
"moveTo": "이동",
|
"moveTo": "이동",
|
||||||
"renameTabTitle": "탭 이름 바꾸기",
|
"renameGroupTitle": "그룹 이름 바꾸기",
|
||||||
"renameTabDescription": "창을 정리할 수 있도록 이 탭에 이름을 지정하세요.",
|
"renameGroupDescription": "이 그룹에 이름을 지정하세요.",
|
||||||
"renameTabPlaceholder": "탭 이름",
|
"renameGroupPlaceholder": "그룹 이름",
|
||||||
"dissolveTab": "그룹 해제",
|
"dissolveTab": "그룹 해제",
|
||||||
"layout": "창 레이아웃",
|
"layout": "창 레이아웃",
|
||||||
"addPane": "창 추가",
|
"addPane": "창 추가",
|
||||||
|
|||||||
@@ -1451,16 +1451,16 @@
|
|||||||
"workbench": {
|
"workbench": {
|
||||||
"aria": "Área de conversas",
|
"aria": "Área de conversas",
|
||||||
"panes": "Painéis",
|
"panes": "Painéis",
|
||||||
"tabAria": "Aba: {{title}}",
|
"tabAria": "Grupo: {{title}}",
|
||||||
"panesInTab": "Painéis em {{title}}",
|
"panesInTab": "Painéis em {{title}}",
|
||||||
"collapseTabGroup": "Recolher os painéis em {{title}}",
|
"collapseTabGroup": "Recolher os painéis em {{title}}",
|
||||||
"expandTabGroup": "Expandir os painéis em {{title}}",
|
"expandTabGroup": "Expandir os painéis em {{title}}",
|
||||||
"dropPane": "Mover {{pane}} para {{tab}}",
|
"dropPane": "Mover {{pane}} para {{tab}}",
|
||||||
"createGroup": "Criar grupo",
|
"createGroup": "Criar grupo",
|
||||||
"moveTo": "Mover para",
|
"moveTo": "Mover para",
|
||||||
"renameTabTitle": "Renomear aba",
|
"renameGroupTitle": "Renomear grupo",
|
||||||
"renameTabDescription": "Dê um nome a esta aba para organizar seus painéis.",
|
"renameGroupDescription": "Dê um nome a este grupo.",
|
||||||
"renameTabPlaceholder": "Nome da aba",
|
"renameGroupPlaceholder": "Nome do grupo",
|
||||||
"dissolveTab": "Desfazer grupo",
|
"dissolveTab": "Desfazer grupo",
|
||||||
"layout": "Layout de painéis",
|
"layout": "Layout de painéis",
|
||||||
"addPane": "Adicionar painel",
|
"addPane": "Adicionar painel",
|
||||||
|
|||||||
@@ -1437,16 +1437,16 @@
|
|||||||
"workbench": {
|
"workbench": {
|
||||||
"aria": "Không gian hội thoại",
|
"aria": "Không gian hội thoại",
|
||||||
"panes": "Khung",
|
"panes": "Khung",
|
||||||
"tabAria": "Thẻ: {{title}}",
|
"tabAria": "Nhóm: {{title}}",
|
||||||
"panesInTab": "Các khung trong {{title}}",
|
"panesInTab": "Các khung trong {{title}}",
|
||||||
"collapseTabGroup": "Thu gọn các khung trong {{title}}",
|
"collapseTabGroup": "Thu gọn các khung trong {{title}}",
|
||||||
"expandTabGroup": "Mở rộng các khung trong {{title}}",
|
"expandTabGroup": "Mở rộng các khung trong {{title}}",
|
||||||
"dropPane": "Di chuyển {{pane}} vào {{tab}}",
|
"dropPane": "Di chuyển {{pane}} vào {{tab}}",
|
||||||
"createGroup": "Tạo nhóm",
|
"createGroup": "Tạo nhóm",
|
||||||
"moveTo": "Di chuyển đến",
|
"moveTo": "Di chuyển đến",
|
||||||
"renameTabTitle": "Đổi tên thẻ",
|
"renameGroupTitle": "Đổi tên nhóm",
|
||||||
"renameTabDescription": "Đặt tên cho thẻ này để sắp xếp các khung.",
|
"renameGroupDescription": "Đặt tên cho nhóm này.",
|
||||||
"renameTabPlaceholder": "Tên thẻ",
|
"renameGroupPlaceholder": "Tên nhóm",
|
||||||
"dissolveTab": "Giải tán nhóm",
|
"dissolveTab": "Giải tán nhóm",
|
||||||
"layout": "Bố cục khung",
|
"layout": "Bố cục khung",
|
||||||
"addPane": "Thêm khung",
|
"addPane": "Thêm khung",
|
||||||
|
|||||||
@@ -1451,16 +1451,16 @@
|
|||||||
"workbench": {
|
"workbench": {
|
||||||
"aria": "会话工作台",
|
"aria": "会话工作台",
|
||||||
"panes": "窗格",
|
"panes": "窗格",
|
||||||
"tabAria": "标签页:{{title}}",
|
"tabAria": "分组:{{title}}",
|
||||||
"panesInTab": "{{title}} 中的窗格",
|
"panesInTab": "{{title}} 中的窗格",
|
||||||
"collapseTabGroup": "折叠 {{title}} 中的窗格",
|
"collapseTabGroup": "折叠 {{title}} 中的窗格",
|
||||||
"expandTabGroup": "展开 {{title}} 中的窗格",
|
"expandTabGroup": "展开 {{title}} 中的窗格",
|
||||||
"dropPane": "将 {{pane}} 移入 {{tab}}",
|
"dropPane": "将 {{pane}} 移入 {{tab}}",
|
||||||
"createGroup": "创建分组",
|
"createGroup": "创建分组",
|
||||||
"moveTo": "移动到",
|
"moveTo": "移动到",
|
||||||
"renameTabTitle": "重命名标签页",
|
"renameGroupTitle": "重命名分组",
|
||||||
"renameTabDescription": "为这个标签页命名,以便组织其中的窗格。",
|
"renameGroupDescription": "为这个分组命名。",
|
||||||
"renameTabPlaceholder": "标签页名称",
|
"renameGroupPlaceholder": "分组名称",
|
||||||
"dissolveTab": "解散分组",
|
"dissolveTab": "解散分组",
|
||||||
"layout": "窗格布局",
|
"layout": "窗格布局",
|
||||||
"addPane": "添加窗格",
|
"addPane": "添加窗格",
|
||||||
|
|||||||
@@ -1437,16 +1437,16 @@
|
|||||||
"workbench": {
|
"workbench": {
|
||||||
"aria": "對話工作台",
|
"aria": "對話工作台",
|
||||||
"panes": "窗格",
|
"panes": "窗格",
|
||||||
"tabAria": "標籤頁:{{title}}",
|
"tabAria": "群組:{{title}}",
|
||||||
"panesInTab": "{{title}} 中的窗格",
|
"panesInTab": "{{title}} 中的窗格",
|
||||||
"collapseTabGroup": "收合 {{title}} 中的窗格",
|
"collapseTabGroup": "收合 {{title}} 中的窗格",
|
||||||
"expandTabGroup": "展開 {{title}} 中的窗格",
|
"expandTabGroup": "展開 {{title}} 中的窗格",
|
||||||
"dropPane": "將 {{pane}} 移入 {{tab}}",
|
"dropPane": "將 {{pane}} 移入 {{tab}}",
|
||||||
"createGroup": "建立群組",
|
"createGroup": "建立群組",
|
||||||
"moveTo": "移動到",
|
"moveTo": "移動到",
|
||||||
"renameTabTitle": "重新命名分頁",
|
"renameGroupTitle": "重新命名群組",
|
||||||
"renameTabDescription": "為這個分頁命名,以便整理其中的窗格。",
|
"renameGroupDescription": "為這個群組命名。",
|
||||||
"renameTabPlaceholder": "分頁名稱",
|
"renameGroupPlaceholder": "群組名稱",
|
||||||
"dissolveTab": "解散群組",
|
"dissolveTab": "解散群組",
|
||||||
"layout": "窗格佈局",
|
"layout": "窗格佈局",
|
||||||
"addPane": "新增窗格",
|
"addPane": "新增窗格",
|
||||||
|
|||||||
@@ -363,6 +363,7 @@ export interface WorkspacesPayload {
|
|||||||
controls: {
|
controls: {
|
||||||
can_change_project: boolean;
|
can_change_project: boolean;
|
||||||
can_use_full_access: boolean;
|
can_use_full_access: boolean;
|
||||||
|
can_pick_folder?: boolean;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2040,18 +2040,20 @@ describe("App layout", () => {
|
|||||||
act(() => {
|
act(() => {
|
||||||
for (const handler of runStatusHandlers) handler("chat-a", 12_345);
|
for (const handler of runStatusHandlers) handler("chat-a", 12_345);
|
||||||
});
|
});
|
||||||
expect(within(sidebar).getByTitle("Agent running")).toBeInTheDocument();
|
expect(within(sidebar).getByRole("img", { name: "Agent running" })).toBeInTheDocument();
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
for (const handler of runStatusHandlers) handler("chat-a", null);
|
for (const handler of runStatusHandlers) handler("chat-a", null);
|
||||||
});
|
});
|
||||||
expect(within(sidebar).queryByTitle("Agent running")).not.toBeInTheDocument();
|
expect(within(sidebar).queryByRole("img", { name: "Agent running" }))
|
||||||
expect(within(sidebar).getByTitle("New activity")).toBeInTheDocument();
|
.not.toBeInTheDocument();
|
||||||
|
expect(within(sidebar).getByRole("img", { name: "New activity" })).toBeInTheDocument();
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.click(within(sidebar).getByRole("button", { name: /^Working chat$/ }));
|
fireEvent.click(within(sidebar).getByRole("button", { name: /^Working chat$/ }));
|
||||||
});
|
});
|
||||||
expect(within(sidebar).queryByTitle("New activity")).not.toBeInTheDocument();
|
expect(within(sidebar).queryByRole("img", { name: "New activity" }))
|
||||||
|
.not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not show an updated dot later when the active session finishes", async () => {
|
it("does not show an updated dot later when the active session finishes", async () => {
|
||||||
@@ -2092,18 +2094,21 @@ describe("App layout", () => {
|
|||||||
act(() => {
|
act(() => {
|
||||||
for (const handler of runStatusHandlers) handler("chat-a", 12_345);
|
for (const handler of runStatusHandlers) handler("chat-a", 12_345);
|
||||||
});
|
});
|
||||||
expect(within(sidebar).getByTitle("Agent running")).toBeInTheDocument();
|
expect(within(sidebar).getByRole("img", { name: "Agent running" })).toBeInTheDocument();
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
for (const handler of runStatusHandlers) handler("chat-a", null);
|
for (const handler of runStatusHandlers) handler("chat-a", null);
|
||||||
});
|
});
|
||||||
expect(within(sidebar).queryByTitle("Agent running")).not.toBeInTheDocument();
|
expect(within(sidebar).queryByRole("img", { name: "Agent running" }))
|
||||||
expect(within(sidebar).queryByTitle("New activity")).not.toBeInTheDocument();
|
.not.toBeInTheDocument();
|
||||||
|
expect(within(sidebar).queryByRole("img", { name: "New activity" }))
|
||||||
|
.not.toBeInTheDocument();
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.click(within(sidebar).getByRole("button", { name: /^Other chat$/ }));
|
fireEvent.click(within(sidebar).getByRole("button", { name: /^Other chat$/ }));
|
||||||
});
|
});
|
||||||
expect(within(sidebar).queryByTitle("New activity")).not.toBeInTheDocument();
|
expect(within(sidebar).queryByRole("img", { name: "New activity" }))
|
||||||
|
.not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("marks inactive sessions when a thread update arrives", async () => {
|
it("marks inactive sessions when a thread update arrives", async () => {
|
||||||
@@ -2138,13 +2143,14 @@ describe("App layout", () => {
|
|||||||
for (const handler of sessionUpdateHandlers) handler("chat-b", "thread");
|
for (const handler of sessionUpdateHandlers) handler("chat-b", "thread");
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(within(sidebar).getByTitle("New activity")).toBeInTheDocument();
|
expect(within(sidebar).getByRole("img", { name: "New activity" })).toBeInTheDocument();
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.click(within(sidebar).getByRole("button", { name: /^Scheduled update target$/ }));
|
fireEvent.click(within(sidebar).getByRole("button", { name: /^Scheduled update target$/ }));
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(within(sidebar).queryByTitle("New activity")).not.toBeInTheDocument();
|
expect(within(sidebar).queryByRole("img", { name: "New activity" }))
|
||||||
|
.not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("restores sidebar run indicators after a page reload", async () => {
|
it("restores sidebar run indicators after a page reload", async () => {
|
||||||
@@ -2177,9 +2183,9 @@ describe("App layout", () => {
|
|||||||
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
||||||
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
|
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(within(sidebar).getByTitle("Agent running")).toBeInTheDocument(),
|
expect(within(sidebar).getByRole("img", { name: "Agent running" })).toBeInTheDocument(),
|
||||||
);
|
);
|
||||||
expect(within(sidebar).getByTitle("New activity")).toBeInTheDocument();
|
expect(within(sidebar).getByRole("img", { name: "New activity" })).toBeInTheDocument();
|
||||||
expect(attachSpy).toHaveBeenCalledWith("chat-a");
|
expect(attachSpy).toHaveBeenCalledWith("chat-a");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -3093,19 +3099,16 @@ describe("App layout", () => {
|
|||||||
|
|
||||||
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
||||||
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
|
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
|
||||||
const alphaTab = await within(sidebar).findByRole("button", { name: "Tab: Alpha tab" });
|
const alphaTab = await within(sidebar).findByRole("button", { name: "Group: Alpha tab" });
|
||||||
const betaTab = within(sidebar).getByRole("button", { name: "Beta tab" });
|
const betaTab = within(sidebar).getByRole("button", { name: "Beta tab" });
|
||||||
expect(alphaTab.compareDocumentPosition(betaTab) & Node.DOCUMENT_POSITION_FOLLOWING)
|
expect(alphaTab.compareDocumentPosition(betaTab) & Node.DOCUMENT_POSITION_FOLLOWING)
|
||||||
.toBeTruthy();
|
.toBeTruthy();
|
||||||
|
|
||||||
const alphaGroup = alphaTab.closest("[data-sidebar-tab-group]") as HTMLElement;
|
const alphaGroup = alphaTab.closest("[data-sidebar-tab-group]") as HTMLElement;
|
||||||
const paneTitles = within(alphaGroup)
|
const alphaChild = within(alphaGroup).getByRole("button", { name: "Alpha child" });
|
||||||
.getAllByRole("button")
|
const alphaRoot = within(alphaGroup).getByRole("button", { name: "Alpha tab" });
|
||||||
.filter((button) => (
|
expect(alphaChild.compareDocumentPosition(alphaRoot) & Node.DOCUMENT_POSITION_FOLLOWING)
|
||||||
button.closest("[data-sidebar-pane]") && button.hasAttribute("title")
|
.toBeTruthy();
|
||||||
))
|
|
||||||
.map((button) => button.getAttribute("title"));
|
|
||||||
expect(paneTitles).toEqual(["Alpha child", "Alpha tab"]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses one active pane without workbench editing controls on mobile", async () => {
|
it("uses one active pane without workbench editing controls on mobile", async () => {
|
||||||
@@ -3215,7 +3218,7 @@ describe("App layout", () => {
|
|||||||
statusHandlers.forEach((handler) => handler("open"));
|
statusHandlers.forEach((handler) => handler("open"));
|
||||||
});
|
});
|
||||||
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
|
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
|
||||||
expect(within(sidebar).queryByRole("button", { name: "Tab: Solo pane" }))
|
expect(within(sidebar).queryByRole("button", { name: "Group: Solo pane" }))
|
||||||
.not.toBeInTheDocument();
|
.not.toBeInTheDocument();
|
||||||
setSidebarStateSpy.mockClear();
|
setSidebarStateSpy.mockClear();
|
||||||
|
|
||||||
@@ -3225,14 +3228,14 @@ describe("App layout", () => {
|
|||||||
fireEvent.click(await screen.findByRole("menuitem", { name: "Create group" }));
|
fireEvent.click(await screen.findByRole("menuitem", { name: "Create group" }));
|
||||||
|
|
||||||
const tabButton = await within(sidebar).findByRole("button", {
|
const tabButton = await within(sidebar).findByRole("button", {
|
||||||
name: "Tab: Solo pane",
|
name: "Group: Solo pane",
|
||||||
});
|
});
|
||||||
const tabGroup = tabButton.closest("[data-sidebar-tab-group]") as HTMLElement;
|
const tabGroup = tabButton.closest("[data-sidebar-tab-group]") as HTMLElement;
|
||||||
expect(within(tabGroup).getByRole("list", { name: "Panes in Solo pane" }))
|
expect(within(tabGroup).getByRole("list", { name: "Panes in Solo pane" }))
|
||||||
.toBeInTheDocument();
|
.toBeInTheDocument();
|
||||||
expect(within(tabGroup).getAllByRole("button", { name: "Solo pane" }))
|
expect(within(tabGroup).getAllByRole("button", { name: "Solo pane" }))
|
||||||
.toHaveLength(1);
|
.toHaveLength(1);
|
||||||
expect(within(sidebar).queryByRole("button", { name: "Tab: Other pane" }))
|
expect(within(sidebar).queryByRole("button", { name: "Group: Other pane" }))
|
||||||
.not.toBeInTheDocument();
|
.not.toBeInTheDocument();
|
||||||
await waitFor(() => expect(setSidebarStateSpy).toHaveBeenCalledWith(
|
await waitFor(() => expect(setSidebarStateSpy).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
@@ -3243,6 +3246,15 @@ describe("App layout", () => {
|
|||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
));
|
));
|
||||||
|
|
||||||
|
fireEvent.pointerDown(within(tabGroup).getByRole("button", {
|
||||||
|
name: "Topic actions for Solo pane",
|
||||||
|
}), { button: 0, ctrlKey: false });
|
||||||
|
fireEvent.click(await screen.findByRole("menuitem", { name: "Rename" }));
|
||||||
|
|
||||||
|
const renameDialog = await screen.findByRole("dialog", { name: "Rename group" });
|
||||||
|
expect(within(renameDialog).getByText("Give this group a name.")).toBeInTheDocument();
|
||||||
|
expect(within(renameDialog).getByPlaceholderText("Group name")).toHaveValue("Solo pane");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("restores a created pane group from gateway state after remount", async () => {
|
it("restores a created pane group from gateway state after remount", async () => {
|
||||||
@@ -3300,7 +3312,7 @@ describe("App layout", () => {
|
|||||||
render(<App />);
|
render(<App />);
|
||||||
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
||||||
const secondSidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
|
const secondSidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
|
||||||
expect(await within(secondSidebar).findByRole("button", { name: "Tab: Solo pane" }))
|
expect(await within(secondSidebar).findByRole("button", { name: "Group: Solo pane" }))
|
||||||
.toBeInTheDocument();
|
.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -18,13 +18,54 @@ function session(overrides: Partial<ChatSummary>): ChatSummary {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function rect(top: number): DOMRect {
|
||||||
|
return {
|
||||||
|
x: 0,
|
||||||
|
y: top,
|
||||||
|
width: 240,
|
||||||
|
height: 32,
|
||||||
|
top,
|
||||||
|
right: 240,
|
||||||
|
bottom: top + 32,
|
||||||
|
left: 0,
|
||||||
|
toJSON: () => ({}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe("ChatList", () => {
|
describe("ChatList", () => {
|
||||||
|
const originalAnimate = HTMLElement.prototype.animate;
|
||||||
|
const originalGetBoundingClientRect = HTMLElement.prototype.getBoundingClientRect;
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
HTMLElement.prototype.animate = originalAnimate;
|
||||||
|
HTMLElement.prototype.getBoundingClientRect = originalGetBoundingClientRect;
|
||||||
localStorage.removeItem("nanobot-webui.collapsed-pane-groups.v1");
|
localStorage.removeItem("nanobot-webui.collapsed-pane-groups.v1");
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("opens a conversation's existing actions from the row context menu", async () => {
|
||||||
|
const onTogglePin = vi.fn();
|
||||||
|
render(
|
||||||
|
<ChatList
|
||||||
|
sessions={[session({ chatId: "review", title: "Review the patch" })]}
|
||||||
|
activeKey="websocket:review"
|
||||||
|
onSelect={vi.fn()}
|
||||||
|
onRequestDelete={vi.fn()}
|
||||||
|
onTogglePin={onTogglePin}
|
||||||
|
onRequestRename={vi.fn()}
|
||||||
|
onToggleArchive={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const row = screen.getByRole("button", { name: "Review the patch" })
|
||||||
|
.closest("[data-chat-row]")!;
|
||||||
|
fireEvent.contextMenu(row);
|
||||||
|
fireEvent.click(await screen.findByRole("menuitem", { name: "Pin" }));
|
||||||
|
|
||||||
|
expect(onTogglePin).toHaveBeenCalledWith("websocket:review");
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps tab grouping out of drag protocols while exposing inactive panes as mention sources", () => {
|
it("keeps tab grouping out of drag protocols while exposing inactive panes as mention sources", () => {
|
||||||
render(
|
render(
|
||||||
<ChatList
|
<ChatList
|
||||||
@@ -49,7 +90,9 @@ describe("ChatList", () => {
|
|||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByRole("button", { name: "Tab: Root topic" }))
|
expect(screen.getByRole("button", { name: "Group: Root topic" }))
|
||||||
|
.toHaveAttribute("draggable", "false");
|
||||||
|
expect(screen.getByRole("button", { name: "Root topic" }))
|
||||||
.toHaveAttribute("draggable", "false");
|
.toHaveAttribute("draggable", "false");
|
||||||
const pane = screen.getByRole("button", { name: "Research pane" });
|
const pane = screen.getByRole("button", { name: "Research pane" });
|
||||||
expect(pane).toHaveAttribute("draggable", "true");
|
expect(pane).toHaveAttribute("draggable", "true");
|
||||||
@@ -71,6 +114,46 @@ describe("ChatList", () => {
|
|||||||
expect(document.querySelector("[data-pane-snap-slot]")).not.toBeInTheDocument();
|
expect(document.querySelector("[data-pane-snap-slot]")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("opens grouped pane and tab actions from their context menus", async () => {
|
||||||
|
const onRequestRename = vi.fn();
|
||||||
|
const onDissolveTab = vi.fn();
|
||||||
|
render(
|
||||||
|
<ChatList
|
||||||
|
sessions={[session({ chatId: "root", title: "Root topic" })]}
|
||||||
|
activeKey="websocket:root"
|
||||||
|
paneGroups={{
|
||||||
|
"websocket:root": {
|
||||||
|
tabKey: "websocket:root",
|
||||||
|
title: "Root topic",
|
||||||
|
activePaneKey: "websocket:root",
|
||||||
|
panes: [
|
||||||
|
{ key: "websocket:root", chatId: "root", title: "Root topic" },
|
||||||
|
{ key: "websocket:child", chatId: "child", title: "Research pane" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
onSelect={vi.fn()}
|
||||||
|
onRequestDelete={vi.fn()}
|
||||||
|
onTogglePin={vi.fn()}
|
||||||
|
onRequestRename={onRequestRename}
|
||||||
|
onToggleArchive={vi.fn()}
|
||||||
|
onDissolveTab={onDissolveTab}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const paneRow = screen.getByRole("button", { name: "Research pane" })
|
||||||
|
.closest("[data-sidebar-pane]")!;
|
||||||
|
fireEvent.contextMenu(paneRow);
|
||||||
|
fireEvent.click(await screen.findByRole("menuitem", { name: "Rename" }));
|
||||||
|
expect(onRequestRename).toHaveBeenCalledWith("websocket:child", "Research pane");
|
||||||
|
|
||||||
|
const tabRow = screen.getByRole("button", { name: "Group: Root topic" })
|
||||||
|
.closest("[data-workbench-tab]")!;
|
||||||
|
fireEvent.contextMenu(tabRow);
|
||||||
|
fireEvent.click(await screen.findByRole("menuitem", { name: "Dissolve group" }));
|
||||||
|
expect(onDissolveTab).toHaveBeenCalledWith("websocket:root");
|
||||||
|
});
|
||||||
|
|
||||||
it("creates a visible tab in place and only moves panes into visible tabs", async () => {
|
it("creates a visible tab in place and only moves panes into visible tabs", async () => {
|
||||||
const onAttachPane = vi.fn();
|
const onAttachPane = vi.fn();
|
||||||
const onCreateTab = vi.fn();
|
const onCreateTab = vi.fn();
|
||||||
@@ -128,7 +211,7 @@ describe("ChatList", () => {
|
|||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.queryByRole("button", { name: "Tab: Solo pane" }))
|
expect(screen.queryByRole("button", { name: "Group: Solo pane" }))
|
||||||
.not.toBeInTheDocument();
|
.not.toBeInTheDocument();
|
||||||
expect(screen.getAllByText("Solo pane")).toHaveLength(1);
|
expect(screen.getAllByText("Solo pane")).toHaveLength(1);
|
||||||
expect(screen.queryByRole("list", { name: "Panes in Solo pane" }))
|
expect(screen.queryByRole("list", { name: "Panes in Solo pane" }))
|
||||||
@@ -157,6 +240,159 @@ describe("ChatList", () => {
|
|||||||
expect(onAttachPane).toHaveBeenCalledWith("websocket:solo", "tab:fine");
|
expect(onAttachPane).toHaveBeenCalledWith("websocket:solo", "tab:fine");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("moves a dragged topic directly into a visible group", () => {
|
||||||
|
const onAttachPane = vi.fn();
|
||||||
|
render(
|
||||||
|
<ChatList
|
||||||
|
sessions={[
|
||||||
|
session({ chatId: "solo", title: "Solo topic" }),
|
||||||
|
session({ chatId: "target", title: "Target group" }),
|
||||||
|
session({ chatId: "full", title: "Full group" }),
|
||||||
|
]}
|
||||||
|
activeKey="websocket:solo"
|
||||||
|
paneGroups={{
|
||||||
|
"websocket:solo": {
|
||||||
|
tabKey: "tab:solo",
|
||||||
|
title: "Solo topic",
|
||||||
|
activePaneKey: "websocket:solo",
|
||||||
|
visible: false,
|
||||||
|
panes: [{ key: "websocket:solo", chatId: "solo", title: "Solo topic" }],
|
||||||
|
},
|
||||||
|
"websocket:target": {
|
||||||
|
tabKey: "tab:target",
|
||||||
|
title: "Target group",
|
||||||
|
activePaneKey: "websocket:target",
|
||||||
|
visible: true,
|
||||||
|
panes: [{ key: "websocket:target", chatId: "target", title: "Target pane" }],
|
||||||
|
},
|
||||||
|
"websocket:full": {
|
||||||
|
tabKey: "tab:full",
|
||||||
|
title: "Full group",
|
||||||
|
activePaneKey: "websocket:full-1",
|
||||||
|
visible: true,
|
||||||
|
panes: [1, 2, 3, 4].map((index) => ({
|
||||||
|
key: `websocket:full-${index}`,
|
||||||
|
chatId: `full-${index}`,
|
||||||
|
title: `Full pane ${index}`,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
onAttachPane={onAttachPane}
|
||||||
|
onSelect={vi.fn()}
|
||||||
|
onRequestDelete={vi.fn()}
|
||||||
|
onTogglePin={vi.fn()}
|
||||||
|
onRequestRename={vi.fn()}
|
||||||
|
onToggleArchive={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const values = new Map<string, string>();
|
||||||
|
const dataTransfer = {
|
||||||
|
effectAllowed: "none",
|
||||||
|
dropEffect: "none",
|
||||||
|
setData: vi.fn((type: string, value: string) => values.set(type, value)),
|
||||||
|
getData: vi.fn((type: string) => values.get(type) ?? ""),
|
||||||
|
types: [SESSION_DRAG_TYPE],
|
||||||
|
} as unknown as DataTransfer;
|
||||||
|
const source = screen.getByRole("button", { name: "Solo topic" });
|
||||||
|
expect(source).toHaveAttribute("draggable", "true");
|
||||||
|
const targetGroup = screen.getByRole("button", { name: "Group: Target group" })
|
||||||
|
.closest("[data-sidebar-tab-group]")!;
|
||||||
|
const targetSurface = targetGroup.querySelector("[data-workbench-tab-surface]")!;
|
||||||
|
const fullGroup = screen.getByRole("button", { name: "Group: Full group" })
|
||||||
|
.closest("[data-sidebar-tab-group]")!;
|
||||||
|
const fullSurface = fullGroup.querySelector("[data-workbench-tab-surface]")!;
|
||||||
|
|
||||||
|
fireEvent.dragStart(source, { dataTransfer });
|
||||||
|
fireEvent.dragEnter(fullSurface, { dataTransfer });
|
||||||
|
fireEvent.dragOver(fullSurface, { dataTransfer });
|
||||||
|
fireEvent.drop(fullSurface, { dataTransfer });
|
||||||
|
|
||||||
|
expect(fullGroup).not.toHaveAttribute("data-pane-drop-target");
|
||||||
|
expect(onAttachPane).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
fireEvent.dragEnter(targetSurface, { dataTransfer });
|
||||||
|
fireEvent.dragOver(targetSurface, { dataTransfer });
|
||||||
|
|
||||||
|
expect(targetGroup).toHaveAttribute("data-pane-drop-target", "true");
|
||||||
|
expect(targetSurface).toHaveClass("ring-2", "ring-primary/35");
|
||||||
|
|
||||||
|
fireEvent.drop(targetSurface, { dataTransfer });
|
||||||
|
|
||||||
|
expect(onAttachPane).toHaveBeenCalledOnce();
|
||||||
|
expect(onAttachPane).toHaveBeenCalledWith("websocket:solo", "tab:target");
|
||||||
|
expect(targetGroup).not.toHaveAttribute("data-pane-drop-target");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detaches a grouped pane when it is dropped back into the standalone list", () => {
|
||||||
|
const onDetachPane = vi.fn();
|
||||||
|
render(
|
||||||
|
<ChatList
|
||||||
|
sessions={[
|
||||||
|
session({ chatId: "root", title: "Root topic" }),
|
||||||
|
session({ chatId: "solo", title: "Solo topic" }),
|
||||||
|
]}
|
||||||
|
activeKey="websocket:root"
|
||||||
|
paneGroups={{
|
||||||
|
"websocket:root": {
|
||||||
|
tabKey: "tab:root",
|
||||||
|
title: "Root group",
|
||||||
|
activePaneKey: "websocket:child",
|
||||||
|
visible: true,
|
||||||
|
panes: [
|
||||||
|
{ key: "websocket:root", chatId: "root", title: "Root topic" },
|
||||||
|
{ key: "websocket:child", chatId: "child", title: "Research pane" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"websocket:solo": {
|
||||||
|
tabKey: "tab:solo",
|
||||||
|
title: "Solo topic",
|
||||||
|
activePaneKey: "websocket:solo",
|
||||||
|
visible: false,
|
||||||
|
panes: [{ key: "websocket:solo", chatId: "solo", title: "Solo topic" }],
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
onDetachPane={onDetachPane}
|
||||||
|
onSelect={vi.fn()}
|
||||||
|
onRequestDelete={vi.fn()}
|
||||||
|
onTogglePin={vi.fn()}
|
||||||
|
onRequestRename={vi.fn()}
|
||||||
|
onToggleArchive={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const values = new Map<string, string>();
|
||||||
|
const dataTransfer = {
|
||||||
|
effectAllowed: "none",
|
||||||
|
dropEffect: "none",
|
||||||
|
setData: vi.fn((type: string, value: string) => values.set(type, value)),
|
||||||
|
getData: vi.fn((type: string) => values.get(type) ?? ""),
|
||||||
|
types: [SESSION_DRAG_TYPE],
|
||||||
|
} as unknown as DataTransfer;
|
||||||
|
const source = screen.getByRole("button", { name: "Research pane" });
|
||||||
|
expect(source).toHaveAttribute("aria-current", "true");
|
||||||
|
expect(source).toHaveAttribute("draggable", "true");
|
||||||
|
const sourceSurface = screen.getByRole("button", { name: "Group: Root topic" })
|
||||||
|
.closest("[data-workbench-tab-surface]")!;
|
||||||
|
const standaloneList = document.querySelector("[data-chat-list-content]")!;
|
||||||
|
|
||||||
|
fireEvent.dragStart(source, { dataTransfer });
|
||||||
|
fireEvent.dragEnter(sourceSurface, { dataTransfer });
|
||||||
|
fireEvent.drop(sourceSurface, { dataTransfer });
|
||||||
|
expect(onDetachPane).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
fireEvent.dragStart(source, { dataTransfer });
|
||||||
|
fireEvent.dragEnter(standaloneList, { dataTransfer });
|
||||||
|
fireEvent.dragOver(standaloneList, { dataTransfer });
|
||||||
|
expect(standaloneList).toHaveAttribute("data-pane-detach-target", "true");
|
||||||
|
expect(standaloneList).toHaveClass("ring-1", "ring-primary/25");
|
||||||
|
|
||||||
|
fireEvent.drop(standaloneList, { dataTransfer });
|
||||||
|
expect(onDetachPane).toHaveBeenCalledOnce();
|
||||||
|
expect(onDetachPane).toHaveBeenCalledWith("tab:root", "websocket:child");
|
||||||
|
expect(standaloneList).not.toHaveAttribute("data-pane-detach-target");
|
||||||
|
});
|
||||||
|
|
||||||
it("shows every tab's pane membership in a sidebar tab group", async () => {
|
it("shows every tab's pane membership in a sidebar tab group", async () => {
|
||||||
const onSelect = vi.fn();
|
const onSelect = vi.fn();
|
||||||
const onSelectPane = vi.fn();
|
const onSelectPane = vi.fn();
|
||||||
@@ -212,7 +448,7 @@ describe("ChatList", () => {
|
|||||||
expect(child.closest("[data-sidebar-pane]"))
|
expect(child.closest("[data-sidebar-pane]"))
|
||||||
.toHaveAttribute("data-sidebar-pane", "websocket:child");
|
.toHaveAttribute("data-sidebar-pane", "websocket:child");
|
||||||
expect(child).toHaveAttribute("aria-current", "true");
|
expect(child).toHaveAttribute("aria-current", "true");
|
||||||
const targetTabRow = screen.getByRole("button", { name: "Tab: Target tab" })
|
const targetTabRow = screen.getByRole("button", { name: "Group: Target tab" })
|
||||||
.closest("li")!;
|
.closest("li")!;
|
||||||
const targetChild = within(targetTabRow).getByRole("button", {
|
const targetChild = within(targetTabRow).getByRole("button", {
|
||||||
name: "Target research",
|
name: "Target research",
|
||||||
@@ -233,7 +469,7 @@ describe("ChatList", () => {
|
|||||||
expect(onSelect).not.toHaveBeenCalled();
|
expect(onSelect).not.toHaveBeenCalled();
|
||||||
|
|
||||||
onSelectPane.mockClear();
|
onSelectPane.mockClear();
|
||||||
const rootTab = screen.getByRole("button", { name: "Tab: Root topic" });
|
const rootTab = screen.getByRole("button", { name: "Group: Root topic" });
|
||||||
fireEvent.click(rootTab);
|
fireEvent.click(rootTab);
|
||||||
expect(onSelectPane).not.toHaveBeenCalled();
|
expect(onSelectPane).not.toHaveBeenCalled();
|
||||||
expect(onSelect).not.toHaveBeenCalled();
|
expect(onSelect).not.toHaveBeenCalled();
|
||||||
@@ -278,8 +514,8 @@ describe("ChatList", () => {
|
|||||||
.toBeInTheDocument();
|
.toBeInTheDocument();
|
||||||
fireEvent.keyDown(document, { key: "Escape" });
|
fireEvent.keyDown(document, { key: "Escape" });
|
||||||
|
|
||||||
expect(child).toHaveAttribute("draggable", "false");
|
expect(child).toHaveAttribute("draggable", "true");
|
||||||
expect(screen.getByRole("button", { name: "Tab: Target tab" }))
|
expect(screen.getByRole("button", { name: "Group: Target tab" }))
|
||||||
.toHaveAttribute("draggable", "false");
|
.toHaveAttribute("draggable", "false");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -316,7 +552,7 @@ describe("ChatList", () => {
|
|||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
const tabButton = screen.getByRole("button", { name: "Tab: Root topic" });
|
const tabButton = screen.getByRole("button", { name: "Group: Root topic" });
|
||||||
const tabGroup = tabButton.closest("[data-sidebar-tab-group]")!;
|
const tabGroup = tabButton.closest("[data-sidebar-tab-group]")!;
|
||||||
const tabHeader = tabButton.closest("[data-workbench-tab]")!;
|
const tabHeader = tabButton.closest("[data-workbench-tab]")!;
|
||||||
const tabSurface = tabButton.closest("[data-workbench-tab-surface]")!;
|
const tabSurface = tabButton.closest("[data-workbench-tab-surface]")!;
|
||||||
@@ -324,21 +560,25 @@ describe("ChatList", () => {
|
|||||||
expect(tabHeader).not.toHaveAttribute("data-chat-row");
|
expect(tabHeader).not.toHaveAttribute("data-chat-row");
|
||||||
expect(tabHeader).not.toHaveAttribute("data-sidebar-pane");
|
expect(tabHeader).not.toHaveAttribute("data-sidebar-pane");
|
||||||
expect(tabButton).not.toHaveAttribute("aria-current");
|
expect(tabButton).not.toHaveAttribute("aria-current");
|
||||||
expect(tabButton.querySelector("svg")).not.toBeInTheDocument();
|
expect(tabButton.querySelector(".lucide-folder-tree")).toBeInTheDocument();
|
||||||
const paneList = within(tabGroup).getByRole("list", { name: "Panes in Root topic" });
|
const paneList = within(tabGroup).getByRole("list", { name: "Panes in Root topic" });
|
||||||
expect(tabSurface).toContainElement(paneList);
|
expect(tabSurface).toContainElement(paneList);
|
||||||
const activePane = within(tabGroup).getByRole("button", { name: "Research pane" });
|
const activePane = within(tabGroup).getByRole("button", { name: "Research pane" });
|
||||||
expect(activePane).toHaveAttribute("aria-current", "true");
|
expect(activePane).toHaveAttribute("aria-current", "true");
|
||||||
expect(activePane.closest("[data-sidebar-pane]")).toHaveClass(
|
expect(activePane.closest("[data-sidebar-pane]")).toHaveClass("rounded-control");
|
||||||
"bg-sidebar-selected",
|
expect(activePane.querySelector("[data-sidebar-selection-track]"))
|
||||||
"rounded-[0.65rem]",
|
.toHaveAttribute("data-active", "true");
|
||||||
);
|
|
||||||
expect(screen.getByRole("button", {
|
expect(screen.getByRole("button", {
|
||||||
name: "Research pane pane actions",
|
name: "Research pane pane actions",
|
||||||
})).toHaveClass("opacity-0");
|
})).toHaveClass("opacity-0");
|
||||||
expect(within(tabGroup).getByRole("button", { name: "Root topic" }))
|
expect(within(tabGroup).getByRole("button", { name: "Root topic" }))
|
||||||
.not.toHaveAttribute("aria-current");
|
.not.toHaveAttribute("aria-current");
|
||||||
expect(tabGroup).not.toHaveTextContent("2/4");
|
expect(tabGroup).not.toHaveTextContent("2/4");
|
||||||
|
expect(paneList).toHaveClass(
|
||||||
|
"rounded-es-[14px]",
|
||||||
|
"border-s-2",
|
||||||
|
"border-sidebar-foreground/25",
|
||||||
|
);
|
||||||
|
|
||||||
const collapse = within(tabGroup).getByRole("button", {
|
const collapse = within(tabGroup).getByRole("button", {
|
||||||
name: "Collapse panes in Root topic",
|
name: "Collapse panes in Root topic",
|
||||||
@@ -352,9 +592,8 @@ describe("ChatList", () => {
|
|||||||
expect(within(tabGroup).getByRole("button", {
|
expect(within(tabGroup).getByRole("button", {
|
||||||
name: "Expand panes in Root topic",
|
name: "Expand panes in Root topic",
|
||||||
})).toHaveAttribute("aria-expanded", "false");
|
})).toHaveAttribute("aria-expanded", "false");
|
||||||
expect(within(tabGroup).getByRole("button", { name: "Tab: Root topic" }))
|
expect(within(tabGroup).getByRole("button", { name: "Group: Root topic" }))
|
||||||
.not.toHaveAttribute("aria-current");
|
.not.toHaveAttribute("aria-current");
|
||||||
expect(tabSurface).toHaveClass("bg-sidebar-foreground/[0.045]");
|
|
||||||
|
|
||||||
fireEvent.click(within(tabGroup).getByRole("button", {
|
fireEvent.click(within(tabGroup).getByRole("button", {
|
||||||
name: "Expand panes in Root topic",
|
name: "Expand panes in Root topic",
|
||||||
@@ -400,8 +639,8 @@ describe("ChatList", () => {
|
|||||||
fireEvent.click(await screen.findByRole("menuitem", { name: "Select" }));
|
fireEvent.click(await screen.findByRole("menuitem", { name: "Select" }));
|
||||||
|
|
||||||
expect(screen.getByText("1 selected")).toBeInTheDocument();
|
expect(screen.getByText("1 selected")).toBeInTheDocument();
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Tab: Root topic" }));
|
fireEvent.click(screen.getByRole("button", { name: "Group: Root topic" }));
|
||||||
expect(screen.getByRole("button", { name: "Tab: Root topic" }))
|
expect(screen.getByRole("button", { name: "Group: Root topic" }))
|
||||||
.toHaveAttribute("aria-pressed", "true");
|
.toHaveAttribute("aria-pressed", "true");
|
||||||
expect(screen.getByRole("button", { name: "Root topic" }))
|
expect(screen.getByRole("button", { name: "Root topic" }))
|
||||||
.toHaveAttribute("aria-pressed", "true");
|
.toHaveAttribute("aria-pressed", "true");
|
||||||
@@ -517,10 +756,13 @@ describe("ChatList", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const pinnedSection = screen.getByRole("region", { name: "Pinned" });
|
const pinnedSection = screen.getByRole("region", { name: "Pinned" });
|
||||||
expect(within(pinnedSection).getByTitle("Pinned")).toBeInTheDocument();
|
|
||||||
expect(
|
expect(
|
||||||
within(screen.getByRole("region", { name: "Earlier" })).queryByTitle("Pinned"),
|
within(pinnedSection)
|
||||||
).not.toBeInTheDocument();
|
.getByText("Pinned chat")
|
||||||
|
.closest("[data-chat-row]")
|
||||||
|
?.querySelector("[data-sidebar-pinned-indicator]"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(document.querySelectorAll("[data-sidebar-pinned-indicator]")).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("groups WebUI chats by workspace project while preserving in-project sorting and activity", () => {
|
it("groups WebUI chats by workspace project while preserving in-project sorting and activity", () => {
|
||||||
@@ -574,8 +816,16 @@ describe("ChatList", () => {
|
|||||||
|
|
||||||
const nanobotSection = screen.getByRole("region", { name: "nanobot" });
|
const nanobotSection = screen.getByRole("region", { name: "nanobot" });
|
||||||
const nanobotText = nanobotSection.textContent ?? "";
|
const nanobotText = nanobotSection.textContent ?? "";
|
||||||
|
const projectSurface = nanobotSection.querySelector(
|
||||||
|
"[data-sidebar-project-surface]",
|
||||||
|
);
|
||||||
|
|
||||||
expect(screen.getByRole("region", { name: "nanobot-bench" })).toBeInTheDocument();
|
expect(screen.getByRole("region", { name: "nanobot-bench" })).toBeInTheDocument();
|
||||||
|
expect(projectSurface).toHaveClass(
|
||||||
|
"rounded-es-[16px]",
|
||||||
|
"border-s-2",
|
||||||
|
"border-sidebar-foreground/10",
|
||||||
|
);
|
||||||
expect(within(nanobotSection).getByText("Alpha task")).toBeInTheDocument();
|
expect(within(nanobotSection).getByText("Alpha task")).toBeInTheDocument();
|
||||||
expect(within(nanobotSection).getByText("Zeta task")).toBeInTheDocument();
|
expect(within(nanobotSection).getByText("Zeta task")).toBeInTheDocument();
|
||||||
expect(nanobotText.indexOf("Alpha task")).toBeLessThan(nanobotText.indexOf("Zeta task"));
|
expect(nanobotText.indexOf("Alpha task")).toBeLessThan(nanobotText.indexOf("Zeta task"));
|
||||||
@@ -630,7 +880,7 @@ describe("ChatList", () => {
|
|||||||
expect(within(chatsSection).queryByText("Project chat")).not.toBeInTheDocument();
|
expect(within(chatsSection).queryByText("Project chat")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("switches row-owned tab highlights without a moving selection surface", () => {
|
it("grows and retracts the row-owned selection track", () => {
|
||||||
const props = {
|
const props = {
|
||||||
sessions: [
|
sessions: [
|
||||||
session({ chatId: "active", title: "Active topic" }),
|
session({ chatId: "active", title: "Active topic" }),
|
||||||
@@ -650,12 +900,10 @@ describe("ChatList", () => {
|
|||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
const activeButton = screen.getByTitle("Active topic");
|
const activeButton = screen.getByRole("button", { name: "Active topic" });
|
||||||
expect(activeButton).toHaveAttribute("aria-current", "page");
|
expect(activeButton).toHaveAttribute("aria-current", "page");
|
||||||
expect(activeButton.closest("[data-sidebar-tab]")).toHaveClass(
|
expect(activeButton.querySelector("[data-sidebar-selection-track]"))
|
||||||
"bg-sidebar-selected",
|
.toHaveClass("origin-left", "scale-x-100", "transition-transform", "bg-current");
|
||||||
);
|
|
||||||
expect(screen.queryByTestId("sessions-selection-highlight")).not.toBeInTheDocument();
|
|
||||||
|
|
||||||
rerender(
|
rerender(
|
||||||
<ChatList
|
<ChatList
|
||||||
@@ -664,11 +912,16 @@ describe("ChatList", () => {
|
|||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByTitle("Active topic")).not.toHaveAttribute("aria-current");
|
expect(screen.getByRole("button", { name: "Active topic" }))
|
||||||
expect(screen.getByTitle("Inactive topic")).toHaveAttribute("aria-current", "page");
|
.not.toHaveAttribute("aria-current");
|
||||||
expect(screen.getByTitle("Inactive topic").closest("[data-sidebar-tab]")).toHaveClass(
|
expect(screen.getByRole("button", { name: "Inactive topic" }))
|
||||||
"bg-sidebar-selected",
|
.toHaveAttribute("aria-current", "page");
|
||||||
);
|
expect(screen.getByRole("button", { name: "Active topic" })
|
||||||
|
.querySelector("[data-sidebar-selection-track]"))
|
||||||
|
.toHaveClass("scale-x-0");
|
||||||
|
expect(screen.getByRole("button", { name: "Inactive topic" })
|
||||||
|
.querySelector("[data-sidebar-selection-track]"))
|
||||||
|
.toHaveClass("scale-x-100");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("restores collapsed tabs from the local UI preference", () => {
|
it("restores collapsed tabs from the local UI preference", () => {
|
||||||
@@ -694,12 +947,12 @@ describe("ChatList", () => {
|
|||||||
};
|
};
|
||||||
const firstRender = render(<ChatList {...props} />);
|
const firstRender = render(<ChatList {...props} />);
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Tab: Root topic" }));
|
fireEvent.click(screen.getByRole("button", { name: "Group: Root topic" }));
|
||||||
expect(screen.queryByRole("button", { name: "Research pane" })).not.toBeInTheDocument();
|
expect(screen.queryByRole("button", { name: "Research pane" })).not.toBeInTheDocument();
|
||||||
firstRender.unmount();
|
firstRender.unmount();
|
||||||
|
|
||||||
render(<ChatList {...props} />);
|
render(<ChatList {...props} />);
|
||||||
expect(screen.getByRole("button", { name: "Tab: Root topic" }))
|
expect(screen.getByRole("button", { name: "Group: Root topic" }))
|
||||||
.toHaveAttribute("aria-expanded", "false");
|
.toHaveAttribute("aria-expanded", "false");
|
||||||
expect(screen.queryByRole("button", { name: "Research pane" })).not.toBeInTheDocument();
|
expect(screen.queryByRole("button", { name: "Research pane" })).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -743,21 +996,111 @@ describe("ChatList", () => {
|
|||||||
expect(onToggleGroup).toHaveBeenCalledWith("project:/Users/me/nanobot");
|
expect(onToggleGroup).toHaveBeenCalledWith("project:/Users/me/nanobot");
|
||||||
expect(within(projectSection).queryByText("Alpha task")).not.toBeInTheDocument();
|
expect(within(projectSection).queryByText("Alpha task")).not.toBeInTheDocument();
|
||||||
|
|
||||||
fireEvent.click(
|
const projectButton = within(projectSection).getByRole("button", { name: "Photos" });
|
||||||
within(projectSection).getByRole("button", { name: "Start a new topic in Photos" }),
|
fireEvent.contextMenu(projectButton);
|
||||||
);
|
fireEvent.click(await screen.findByRole("menuitem", { name: "New topic" }));
|
||||||
expect(onNewChatInProject).toHaveBeenCalledWith("/Users/me/nanobot", "Photos");
|
expect(onNewChatInProject).toHaveBeenCalledWith("/Users/me/nanobot", "Photos");
|
||||||
expect(onToggleGroup).toHaveBeenCalledTimes(1);
|
expect(onToggleGroup).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
fireEvent.pointerDown(
|
fireEvent.contextMenu(projectButton);
|
||||||
within(projectSection).getByLabelText("Topic actions for Photos"),
|
|
||||||
{ button: 0 },
|
|
||||||
);
|
|
||||||
fireEvent.click(await screen.findByRole("menuitem", { name: "Rename" }));
|
fireEvent.click(await screen.findByRole("menuitem", { name: "Rename" }));
|
||||||
|
|
||||||
expect(onRequestRenameProject).toHaveBeenCalledWith("/Users/me/nanobot", "Photos");
|
expect(onRequestRenameProject).toHaveBeenCalledWith("/Users/me/nanobot", "Photos");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("animates project disclosure and surrounding layout like tab groups", () => {
|
||||||
|
let collapsed = false;
|
||||||
|
const onToggleGroup = vi.fn();
|
||||||
|
const animate = vi.fn(() => ({
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
cancel: vi.fn(),
|
||||||
|
}) as unknown as Animation);
|
||||||
|
HTMLElement.prototype.animate = animate;
|
||||||
|
HTMLElement.prototype.getBoundingClientRect = function getBoundingClientRect() {
|
||||||
|
const followsCollapsedProject = this.textContent?.includes("Beta") ?? false;
|
||||||
|
return rect(followsCollapsedProject ? (collapsed ? 64 : 160) : 0);
|
||||||
|
};
|
||||||
|
const sessions = [
|
||||||
|
session({
|
||||||
|
chatId: "alpha",
|
||||||
|
title: "Alpha task",
|
||||||
|
workspaceScope: {
|
||||||
|
project_path: "/Users/me/alpha",
|
||||||
|
project_name: "Alpha project",
|
||||||
|
access_mode: "restricted",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
session({
|
||||||
|
chatId: "beta",
|
||||||
|
title: "Beta task",
|
||||||
|
workspaceScope: {
|
||||||
|
project_path: "/Users/me/beta",
|
||||||
|
project_name: "Beta project",
|
||||||
|
access_mode: "restricted",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
const props = {
|
||||||
|
sessions,
|
||||||
|
activeKey: "websocket:alpha",
|
||||||
|
onSelect: vi.fn(),
|
||||||
|
onRequestDelete: vi.fn(),
|
||||||
|
onTogglePin: vi.fn(),
|
||||||
|
onRequestRename: vi.fn(),
|
||||||
|
onRequestRenameProject: vi.fn(),
|
||||||
|
onToggleArchive: vi.fn(),
|
||||||
|
onToggleGroup,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { rerender } = render(
|
||||||
|
<ChatList {...props} collapsedGroups={{ "project:/Users/me/alpha": false }} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
const projectButton = screen.getByRole("button", { name: "Alpha project" });
|
||||||
|
const disclosureButton = screen.getByRole("button", {
|
||||||
|
name: "Projects: Alpha project",
|
||||||
|
});
|
||||||
|
expect(projectButton).toHaveAttribute("aria-expanded", "true");
|
||||||
|
expect(disclosureButton).toHaveAttribute("aria-expanded", "true");
|
||||||
|
const expandedIcon = disclosureButton
|
||||||
|
.querySelector("[data-sidebar-project-disclosure-icon]");
|
||||||
|
expect(expandedIcon).toHaveClass(
|
||||||
|
"transition-transform",
|
||||||
|
"duration-200",
|
||||||
|
"ease-out",
|
||||||
|
"motion-reduce:transition-none",
|
||||||
|
);
|
||||||
|
expect(expandedIcon).not.toHaveClass("rotate-90");
|
||||||
|
expect(screen.getByRole("button", { name: "Topic actions for Alpha project" })
|
||||||
|
.compareDocumentPosition(disclosureButton) & Node.DOCUMENT_POSITION_FOLLOWING)
|
||||||
|
.toBeTruthy();
|
||||||
|
|
||||||
|
fireEvent.click(disclosureButton);
|
||||||
|
expect(onToggleGroup).toHaveBeenCalledWith("project:/Users/me/alpha");
|
||||||
|
collapsed = true;
|
||||||
|
rerender(
|
||||||
|
<ChatList {...props} collapsedGroups={{ "project:/Users/me/alpha": true }} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByRole("button", { name: "Projects: Alpha project" })
|
||||||
|
.querySelector("[data-sidebar-project-disclosure-icon]"))
|
||||||
|
.toHaveClass("rotate-90");
|
||||||
|
expect(projectButton).toHaveAttribute("aria-expanded", "false");
|
||||||
|
expect(animate).toHaveBeenCalledWith(
|
||||||
|
[
|
||||||
|
{ transform: "translateY(96px)" },
|
||||||
|
{ transform: "translateY(0)" },
|
||||||
|
],
|
||||||
|
{
|
||||||
|
duration: 180,
|
||||||
|
easing: "cubic-bezier(0.2, 0, 0, 1)",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(screen.getByRole("button", { name: "Beta project" })
|
||||||
|
.closest("[data-sidebar-group-header]"))
|
||||||
|
.toHaveAttribute("data-sidebar-group-header", "project:/Users/me/beta");
|
||||||
|
});
|
||||||
|
|
||||||
it("hides the updated dot for the active chat", () => {
|
it("hides the updated dot for the active chat", () => {
|
||||||
const sessions = [
|
const sessions = [
|
||||||
session({
|
session({
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ describe("CodeBlock", () => {
|
|||||||
expect(screen.getByTestId("plain-code-fallback")).toHaveClass("py-4", "pl-5", "pr-14");
|
expect(screen.getByTestId("plain-code-fallback")).toHaveClass("py-4", "pl-5", "pr-14");
|
||||||
|
|
||||||
const container = screen.getByTestId("plain-code-fallback").closest(".not-prose");
|
const container = screen.getByTestId("plain-code-fallback").closest(".not-prose");
|
||||||
expect(container).toHaveClass("relative", "rounded-[18px]", "bg-secondary/70");
|
expect(container).toHaveClass("relative", "rounded-floating", "bg-secondary/70");
|
||||||
expect(container).not.toHaveClass("border");
|
expect(container).not.toHaveClass("border");
|
||||||
expect(container).toHaveAttribute("data-language", "ts");
|
expect(container).toHaveAttribute("data-language", "ts");
|
||||||
|
|
||||||
|
|||||||
@@ -269,6 +269,22 @@ const LOCALIZED_NEW_SURFACE_KEYS = [
|
|||||||
"chat.groups.yesterday",
|
"chat.groups.yesterday",
|
||||||
"chat.groups.earlier",
|
"chat.groups.earlier",
|
||||||
"chat.groups.archived",
|
"chat.groups.archived",
|
||||||
|
"workbench.tabAria",
|
||||||
|
"workbench.panesInTab",
|
||||||
|
"workbench.collapseTabGroup",
|
||||||
|
"workbench.expandTabGroup",
|
||||||
|
"workbench.dropPane",
|
||||||
|
"workbench.createGroup",
|
||||||
|
"workbench.moveTo",
|
||||||
|
"workbench.renameGroupTitle",
|
||||||
|
"workbench.renameGroupDescription",
|
||||||
|
"workbench.renameGroupPlaceholder",
|
||||||
|
"workbench.dissolveTab",
|
||||||
|
"workbench.deleteConversations",
|
||||||
|
"workbench.paneLimit",
|
||||||
|
"workbench.paneActions",
|
||||||
|
"workbench.detachPane",
|
||||||
|
"workbench.composerAria",
|
||||||
"thread.promptNavigator.railAria",
|
"thread.promptNavigator.railAria",
|
||||||
"thread.composer.mentions.cliTitle",
|
"thread.composer.mentions.cliTitle",
|
||||||
"thread.composer.mentions.mcpTitle",
|
"thread.composer.mentions.mcpTitle",
|
||||||
@@ -582,6 +598,18 @@ describe("webui i18n", () => {
|
|||||||
expect(settings.skills.marketplaceTrendingTitle).toBe("各市场热门技能");
|
expect(settings.skills.marketplaceTrendingTitle).toBe("各市场热门技能");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps the Simplified Chinese group workflow localized", () => {
|
||||||
|
const workbench = resources["zh-CN"].common.workbench;
|
||||||
|
|
||||||
|
expect(workbench.tabAria).toBe("分组:{{title}}");
|
||||||
|
expect(workbench.createGroup).toBe("创建分组");
|
||||||
|
expect(workbench.renameGroupTitle).toBe("重命名分组");
|
||||||
|
expect(workbench.renameGroupDescription).toBe("为这个分组命名。");
|
||||||
|
expect(workbench.renameGroupPlaceholder).toBe("分组名称");
|
||||||
|
expect(workbench.moveTo).toBe("移动到");
|
||||||
|
expect(workbench.detachPane).toBe("移出");
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps Indonesian and Vietnamese settings free of copied Spanish help text", () => {
|
it("keeps Indonesian and Vietnamese settings free of copied Spanish help text", () => {
|
||||||
const spanish = flattenResource(resources.es.common);
|
const spanish = flattenResource(resources.es.common);
|
||||||
|
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ describe("MessageBubble", () => {
|
|||||||
const pill = screen.getByText("hello");
|
const pill = screen.getByText("hello");
|
||||||
|
|
||||||
expect(row).toHaveClass("ml-auto", "flex");
|
expect(row).toHaveClass("ml-auto", "flex");
|
||||||
expect(pill).toHaveClass("ml-auto", "w-fit", "rounded-[18px]");
|
expect(pill).toHaveClass("ml-auto", "w-fit", "rounded-floating");
|
||||||
expect(screen.getByRole("button", { name: "Copy" })).toBeInTheDocument();
|
expect(screen.getByRole("button", { name: "Copy" })).toBeInTheDocument();
|
||||||
expect(screen.queryByRole("button", { name: "Fork" })).not.toBeInTheDocument();
|
expect(screen.queryByRole("button", { name: "Fork" })).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -286,7 +286,7 @@ describe("MessageBubble", () => {
|
|||||||
expect(command.getAttribute("style")).toContain("var(--inline-token-highlight)");
|
expect(command.getAttribute("style")).toContain("var(--inline-token-highlight)");
|
||||||
expect(command.className).not.toMatch(/(?:^|\s)(?:bg-|border|ring|rounded)/);
|
expect(command.className).not.toMatch(/(?:^|\s)(?:bg-|border|ring|rounded)/);
|
||||||
expect(command.parentElement).toHaveTextContent("/model gpt-5");
|
expect(command.parentElement).toHaveTextContent("/model gpt-5");
|
||||||
expect(command.parentElement).toHaveClass("rounded-[18px]", "bg-secondary/70");
|
expect(command.parentElement).toHaveClass("rounded-floating", "bg-secondary/70");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps unknown and invalid slash commands as plain message text", () => {
|
it("keeps unknown and invalid slash commands as plain message text", () => {
|
||||||
@@ -934,7 +934,7 @@ describe("MessageBubble", () => {
|
|||||||
const { container } = render(<MessageBubble message={message} />);
|
const { container } = render(<MessageBubble message={message} />);
|
||||||
|
|
||||||
const imageButton = screen.getByRole("button", { name: /view image/i });
|
const imageButton = screen.getByRole("button", { name: /view image/i });
|
||||||
expect(imageButton).toHaveClass("w-[min(100%,34rem)]", "rounded-[20px]");
|
expect(imageButton).toHaveClass("w-[min(100%,34rem)]", "rounded-panel");
|
||||||
expect(imageButton).toHaveClass(
|
expect(imageButton).toHaveClass(
|
||||||
"border",
|
"border",
|
||||||
"border-border/60",
|
"border-border/60",
|
||||||
|
|||||||
@@ -314,6 +314,51 @@ describe("PaneWorkbench", () => {
|
|||||||
await waitFor(() => expect(animate).toHaveBeenCalledTimes(4));
|
await waitFor(() => expect(animate).toHaveBeenCalledTimes(4));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps a retargeted pane surface in the same physical motion", async () => {
|
||||||
|
const common = {
|
||||||
|
layout: "columns" as const,
|
||||||
|
showLayoutControl: false,
|
||||||
|
onActivatePane: vi.fn(),
|
||||||
|
onAddPane: vi.fn(),
|
||||||
|
onLayoutChange: vi.fn(),
|
||||||
|
onPaneOrderChange: vi.fn(),
|
||||||
|
renderPane: (pane: { title: string }) => <span>{pane.title}</span>,
|
||||||
|
};
|
||||||
|
const { rerender } = render(
|
||||||
|
<PaneWorkbench
|
||||||
|
{...common}
|
||||||
|
panes={[
|
||||||
|
{ key: "alpha", reactKey: "tab-root", title: "Alpha" },
|
||||||
|
{ key: "beta", reactKey: "pane:beta", title: "Beta" },
|
||||||
|
]}
|
||||||
|
activePaneKey="alpha"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
animate.mockClear();
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<PaneWorkbench
|
||||||
|
{...common}
|
||||||
|
panes={[
|
||||||
|
{ key: "beta", reactKey: "pane:beta", title: "Beta" },
|
||||||
|
{ key: "gamma", reactKey: "tab-root", title: "Gamma" },
|
||||||
|
]}
|
||||||
|
activePaneKey="gamma"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(animate).toHaveBeenCalledWith(
|
||||||
|
[
|
||||||
|
{ transform: "translate(-500px, 0px) scale(1, 1)" },
|
||||||
|
{ transform: "translate(0, 0) scale(1, 1)" },
|
||||||
|
],
|
||||||
|
{
|
||||||
|
duration: 260,
|
||||||
|
easing: "cubic-bezier(0.2, 0, 0, 1)",
|
||||||
|
},
|
||||||
|
));
|
||||||
|
});
|
||||||
|
|
||||||
it("renders only the active pane and hides workbench controls on mobile", () => {
|
it("renders only the active pane and hides workbench controls on mobile", () => {
|
||||||
vi.stubGlobal("matchMedia", vi.fn((query: string) => ({
|
vi.stubGlobal("matchMedia", vi.fn((query: string) => ({
|
||||||
matches: query.includes("max-width: 767px"),
|
matches: query.includes("max-width: 767px"),
|
||||||
|
|||||||
@@ -165,7 +165,7 @@ describe("Settings models", () => {
|
|||||||
expect(editor).toHaveClass(
|
expect(editor).toHaveClass(
|
||||||
"slide-in-from-top-1",
|
"slide-in-from-top-1",
|
||||||
"lg:max-w-6xl",
|
"lg:max-w-6xl",
|
||||||
"rounded-[18px]",
|
"rounded-floating",
|
||||||
);
|
);
|
||||||
expect(within(editor).getByDisplayValue("Primary")).toBeInTheDocument();
|
expect(within(editor).getByDisplayValue("Primary")).toBeInTheDocument();
|
||||||
const deleteButton = within(editor).getByRole("button", { name: "Delete" });
|
const deleteButton = within(editor).getByRole("button", { name: "Delete" });
|
||||||
|
|||||||
@@ -554,7 +554,7 @@ describe("ThreadComposer", () => {
|
|||||||
expect(input.className).toContain("min-h-[50px]");
|
expect(input.className).toContain("min-h-[50px]");
|
||||||
expect(input.className).toContain("text-[16px]");
|
expect(input.className).toContain("text-[16px]");
|
||||||
expect(input.parentElement?.parentElement?.className).toContain("max-w-[49.5rem]");
|
expect(input.parentElement?.parentElement?.className).toContain("max-w-[49.5rem]");
|
||||||
expect(input.parentElement?.parentElement?.className).toContain("rounded-[22px]");
|
expect(input.parentElement?.parentElement?.className).toContain("rounded-panel");
|
||||||
expect(input.parentElement?.parentElement?.className).not.toContain("shadow-");
|
expect(input.parentElement?.parentElement?.className).not.toContain("shadow-");
|
||||||
expect(screen.getByRole("button", { name: "Attach files" }).className).toContain("bg-card");
|
expect(screen.getByRole("button", { name: "Attach files" }).className).toContain("bg-card");
|
||||||
expect(screen.getByRole("button", { name: "Send message" }).className).toContain("bg-foreground");
|
expect(screen.getByRole("button", { name: "Send message" }).className).toContain("bg-foreground");
|
||||||
@@ -1260,6 +1260,45 @@ describe("ThreadComposer", () => {
|
|||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uses the gateway folder picker for a locally hosted WebUI", async () => {
|
||||||
|
const onWorkspaceScopeChange = vi.fn();
|
||||||
|
const pickFolder = vi.fn().mockResolvedValue("/Users/test/gateway-project");
|
||||||
|
const defaultScope = {
|
||||||
|
project_path: "/Users/test/.nanobot/workspace",
|
||||||
|
project_name: "workspace",
|
||||||
|
access_mode: "full" as const,
|
||||||
|
restrict_to_workspace: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ThreadComposer
|
||||||
|
onSend={vi.fn()}
|
||||||
|
placeholder="Ask anything..."
|
||||||
|
variant="hero"
|
||||||
|
workspaceScope={defaultScope}
|
||||||
|
workspaceDefaultScope={defaultScope}
|
||||||
|
workspaceControls={{
|
||||||
|
can_change_project: true,
|
||||||
|
can_use_full_access: true,
|
||||||
|
can_pick_folder: true,
|
||||||
|
}}
|
||||||
|
onPickWorkspaceFolder={pickFolder}
|
||||||
|
onWorkspaceScopeChange={onWorkspaceScopeChange}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Choose project" }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(pickFolder).toHaveBeenCalled());
|
||||||
|
expect(screen.queryByLabelText("Paste path")).not.toBeInTheDocument();
|
||||||
|
expect(onWorkspaceScopeChange).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
project_path: "/Users/test/gateway-project",
|
||||||
|
project_name: "gateway-project",
|
||||||
|
access_mode: "full",
|
||||||
|
restrict_to_workspace: false,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
it("uses the web path menu when no native host picker is available", async () => {
|
it("uses the web path menu when no native host picker is available", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const defaultScope = {
|
const defaultScope = {
|
||||||
@@ -1287,54 +1326,21 @@ describe("ThreadComposer", () => {
|
|||||||
expect(screen.getByLabelText("Paste path")).toBeInTheDocument();
|
expect(screen.getByLabelText("Paste path")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows turn run timer when runStartedAt is set", () => {
|
it("closes the sustained goal through its existing drawer", () => {
|
||||||
vi.useFakeTimers();
|
|
||||||
vi.setSystemTime(new Date((1_000 + 125) * 1000));
|
|
||||||
|
|
||||||
render(
|
|
||||||
<ThreadComposer
|
|
||||||
onSend={vi.fn()}
|
|
||||||
placeholder="Type your message..."
|
|
||||||
runStartedAt={1000}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
const status = screen.getByRole("status");
|
|
||||||
expect(status).toHaveTextContent(/Running/);
|
|
||||||
expect(status).toHaveTextContent(/2:05/);
|
|
||||||
expect(status).toHaveClass("composer-status-drawer-content");
|
|
||||||
expect(status.closest("[data-composer-status-drawer]")).toHaveAttribute(
|
|
||||||
"data-state",
|
|
||||||
"open",
|
|
||||||
);
|
|
||||||
expect(status.querySelector(".run-pulse-icon")).not.toBeNull();
|
|
||||||
|
|
||||||
vi.useRealTimers();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("opens and closes the run timer through one persistent drawer", () => {
|
|
||||||
const { container, rerender } = render(
|
const { container, rerender } = render(
|
||||||
<ThreadComposer
|
<ThreadComposer
|
||||||
onSend={vi.fn()}
|
onSend={vi.fn()}
|
||||||
placeholder="Type your message..."
|
placeholder="Type your message..."
|
||||||
runStartedAt={null}
|
goalState={{
|
||||||
|
active: true,
|
||||||
|
objective: "Ship the release",
|
||||||
|
ui_summary: "Preparing release",
|
||||||
|
}}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
const drawer = container.querySelector("[data-composer-status-drawer]");
|
const drawer = container.querySelector("[data-composer-status-drawer]");
|
||||||
expect(drawer).not.toBeNull();
|
expect(drawer).not.toBeNull();
|
||||||
expect(drawer).toHaveAttribute("data-state", "closed");
|
|
||||||
expect(drawer).toHaveAttribute("aria-hidden", "true");
|
|
||||||
|
|
||||||
rerender(
|
|
||||||
<ThreadComposer
|
|
||||||
onSend={vi.fn()}
|
|
||||||
placeholder="Type your message..."
|
|
||||||
runStartedAt={Math.floor(Date.now() / 1000)}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(container.querySelector("[data-composer-status-drawer]")).toBe(drawer);
|
|
||||||
expect(drawer).toHaveAttribute("data-state", "open");
|
expect(drawer).toHaveAttribute("data-state", "open");
|
||||||
expect(drawer).not.toHaveAttribute("aria-hidden");
|
expect(drawer).not.toHaveAttribute("aria-hidden");
|
||||||
const status = screen.getByRole("status");
|
const status = screen.getByRole("status");
|
||||||
@@ -1344,7 +1350,7 @@ describe("ThreadComposer", () => {
|
|||||||
<ThreadComposer
|
<ThreadComposer
|
||||||
onSend={vi.fn()}
|
onSend={vi.fn()}
|
||||||
placeholder="Type your message..."
|
placeholder="Type your message..."
|
||||||
runStartedAt={null}
|
goalState={{ active: false }}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1353,6 +1359,9 @@ describe("ThreadComposer", () => {
|
|||||||
expect(drawer).toHaveAttribute("aria-hidden", "true");
|
expect(drawer).toHaveAttribute("aria-hidden", "true");
|
||||||
expect(screen.queryByRole("status")).not.toBeInTheDocument();
|
expect(screen.queryByRole("status")).not.toBeInTheDocument();
|
||||||
expect(drawer?.querySelector('[role="status"]')).toBe(status);
|
expect(drawer?.querySelector('[role="status"]')).toBe(status);
|
||||||
|
|
||||||
|
fireEvent.transitionEnd(drawer as Element, { propertyName: "grid-template-rows" });
|
||||||
|
expect(container.querySelector("[data-composer-status-drawer]")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("opens an upward anchored goal panel with markdown content when expand is clicked", async () => {
|
it("opens an upward anchored goal panel with markdown content when expand is clicked", async () => {
|
||||||
@@ -1798,7 +1807,7 @@ describe("ThreadComposer", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects session drops that are unavailable to the composer", () => {
|
it("rejects self-session drops that are unavailable to the composer", () => {
|
||||||
render(
|
render(
|
||||||
<ThreadComposer
|
<ThreadComposer
|
||||||
onSend={vi.fn()}
|
onSend={vi.fn()}
|
||||||
|
|||||||
@@ -16,6 +16,54 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("ThreadMessages", () => {
|
describe("ThreadMessages", () => {
|
||||||
|
it("shows optimistic turn progress in the thread before the first agent output", () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const now = new Date("2026-08-13T10:00:05.000Z").getTime();
|
||||||
|
vi.setSystemTime(now);
|
||||||
|
const prompt: UIMessage = {
|
||||||
|
id: "u-optimistic",
|
||||||
|
role: "user",
|
||||||
|
content: "check this",
|
||||||
|
turnId: "turn-optimistic",
|
||||||
|
turnPhase: "user",
|
||||||
|
deliveryStatus: "sending",
|
||||||
|
createdAt: now - 5_000,
|
||||||
|
};
|
||||||
|
const { rerender } = render(
|
||||||
|
<ThreadMessages
|
||||||
|
messages={[prompt]}
|
||||||
|
isStreaming
|
||||||
|
activeTurnId="turn-optimistic"
|
||||||
|
runStartedAt={(now - 5_000) / 1000}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByRole("status", { name: "Thinking for 5s" })).toBeInTheDocument();
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<ThreadMessages
|
||||||
|
messages={[
|
||||||
|
{ ...prompt, deliveryStatus: "accepted" },
|
||||||
|
{
|
||||||
|
id: "t-optimistic",
|
||||||
|
role: "tool",
|
||||||
|
kind: "trace",
|
||||||
|
content: "web_search()",
|
||||||
|
traces: ["web_search()"],
|
||||||
|
turnId: "turn-optimistic",
|
||||||
|
turnPhase: "activity",
|
||||||
|
createdAt: now,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
isStreaming
|
||||||
|
activeTurnId="turn-optimistic"
|
||||||
|
runStartedAt={(now - 5_000) / 1000}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByRole("button", { name: "Working for 5s" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("does not move a mounted tail answer into offscreen rendering on the next turn", () => {
|
it("does not move a mounted tail answer into offscreen rendering on the next turn", () => {
|
||||||
const completed: UIMessage[] = [
|
const completed: UIMessage[] = [
|
||||||
{ id: "u1", role: "user", content: "question", createdAt: 1 },
|
{ id: "u1", role: "user", content: "question", createdAt: 1 },
|
||||||
|
|||||||
@@ -120,6 +120,31 @@ describe("ThreadMotionCoordinator", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("reconciles observed layout growth before the next paint", () => {
|
||||||
|
const {
|
||||||
|
camera,
|
||||||
|
coordinator,
|
||||||
|
frames,
|
||||||
|
scheduler,
|
||||||
|
advanceFrame,
|
||||||
|
setGeometry,
|
||||||
|
} = motionHarness();
|
||||||
|
|
||||||
|
coordinator.resumeAutoFollow();
|
||||||
|
advanceFrame();
|
||||||
|
camera.jumpTo.mockClear();
|
||||||
|
scheduler.cancel.mockClear();
|
||||||
|
|
||||||
|
setGeometry({ scrollTop: 1_400, scrollHeight: 2_054 });
|
||||||
|
coordinator.invalidateGeometry();
|
||||||
|
coordinator.reconcileObservedGeometry();
|
||||||
|
|
||||||
|
expect(camera.jumpTo).toHaveBeenCalledWith(1_554);
|
||||||
|
expect(scheduler.cancel).toHaveBeenCalledTimes(1);
|
||||||
|
expect(frames).toHaveLength(0);
|
||||||
|
expect(coordinator.snapshot()).toMatchObject({ measurementPending: false });
|
||||||
|
});
|
||||||
|
|
||||||
it("pins repeated output growth on each authoritative geometry frame", () => {
|
it("pins repeated output growth on each authoritative geometry frame", () => {
|
||||||
const {
|
const {
|
||||||
camera,
|
camera,
|
||||||
|
|||||||
@@ -241,6 +241,10 @@ describe("ThreadViewport", () => {
|
|||||||
takeUserControl.mockClear();
|
takeUserControl.mockClear();
|
||||||
fireEvent.keyDown(disclosure, { key: "Enter" });
|
fireEvent.keyDown(disclosure, { key: "Enter" });
|
||||||
expect(takeUserControl).toHaveBeenCalledTimes(1);
|
expect(takeUserControl).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
takeUserControl.mockClear();
|
||||||
|
fireEvent.keyDown(disclosure, { key: " " });
|
||||||
|
expect(takeUserControl).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("top-aligns short threads in the message rendering area", () => {
|
it("top-aligns short threads in the message rendering area", () => {
|
||||||
@@ -653,7 +657,7 @@ describe("ThreadViewport", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("coalesces streamed layout growth into frame-driven camera targets", async () => {
|
it("settles observed streamed layout growth before paint", async () => {
|
||||||
const resizeObserver = stubResizeObserver();
|
const resizeObserver = stubResizeObserver();
|
||||||
const followTo = vi.spyOn(ThreadCameraController.prototype, "followTo")
|
const followTo = vi.spyOn(ThreadCameraController.prototype, "followTo")
|
||||||
.mockReturnValue("started");
|
.mockReturnValue("started");
|
||||||
@@ -740,10 +744,7 @@ describe("ThreadViewport", () => {
|
|||||||
});
|
});
|
||||||
act(() => {
|
act(() => {
|
||||||
contentObserver!.callback([], contentObserver as unknown as ResizeObserver);
|
contentObserver!.callback([], contentObserver as unknown as ResizeObserver);
|
||||||
contentObserver!.callback([], contentObserver as unknown as ResizeObserver);
|
|
||||||
});
|
});
|
||||||
expect(followTo).not.toHaveBeenCalled();
|
|
||||||
await flushAnimationFrame();
|
|
||||||
expect(followTo).toHaveBeenCalledTimes(1);
|
expect(followTo).toHaveBeenCalledTimes(1);
|
||||||
expect(followTo).toHaveBeenLastCalledWith(1448);
|
expect(followTo).toHaveBeenLastCalledWith(1448);
|
||||||
followTo.mockClear();
|
followTo.mockClear();
|
||||||
@@ -1959,6 +1960,12 @@ describe("ThreadViewport", () => {
|
|||||||
it("waits for the next conversation's transcript before restoring its bottom", async () => {
|
it("waits for the next conversation's transcript before restoring its bottom", async () => {
|
||||||
const jumpTo = vi.spyOn(ThreadCameraController.prototype, "jumpTo");
|
const jumpTo = vi.spyOn(ThreadCameraController.prototype, "jumpTo");
|
||||||
const followTo = vi.spyOn(ThreadCameraController.prototype, "followTo");
|
const followTo = vi.spyOn(ThreadCameraController.prototype, "followTo");
|
||||||
|
const handoffAnimation = {
|
||||||
|
cancel: vi.fn(),
|
||||||
|
oncancel: null,
|
||||||
|
onfinish: null,
|
||||||
|
} as unknown as Animation;
|
||||||
|
const animate = vi.fn(() => handoffAnimation);
|
||||||
const oldMessages: UIMessage[] = [
|
const oldMessages: UIMessage[] = [
|
||||||
{
|
{
|
||||||
id: "old-user",
|
id: "old-user",
|
||||||
@@ -1998,6 +2005,7 @@ describe("ThreadViewport", () => {
|
|||||||
scrollHeight: { configurable: true, value: 2400 },
|
scrollHeight: { configurable: true, value: 2400 },
|
||||||
clientHeight: { configurable: true, value: 600 },
|
clientHeight: { configurable: true, value: 600 },
|
||||||
scrollTop: { configurable: true, writable: true, value: 300 },
|
scrollTop: { configurable: true, writable: true, value: 300 },
|
||||||
|
animate: { configurable: true, value: animate },
|
||||||
});
|
});
|
||||||
jumpTo.mockClear();
|
jumpTo.mockClear();
|
||||||
|
|
||||||
@@ -2013,6 +2021,14 @@ describe("ThreadViewport", () => {
|
|||||||
);
|
);
|
||||||
expect(scroller.scrollTop).toBe(300);
|
expect(scroller.scrollTop).toBe(300);
|
||||||
expect(jumpTo).not.toHaveBeenCalled();
|
expect(jumpTo).not.toHaveBeenCalled();
|
||||||
|
expect(animate).toHaveBeenCalledWith(
|
||||||
|
[{ opacity: 1 }, { opacity: 0.82 }],
|
||||||
|
{
|
||||||
|
duration: 80,
|
||||||
|
easing: "cubic-bezier(0.2, 0, 0, 1)",
|
||||||
|
fill: "forwards",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
Object.defineProperty(scroller, "scrollHeight", {
|
Object.defineProperty(scroller, "scrollHeight", {
|
||||||
configurable: true,
|
configurable: true,
|
||||||
@@ -2033,6 +2049,17 @@ describe("ThreadViewport", () => {
|
|||||||
await flushAnimationFrame();
|
await flushAnimationFrame();
|
||||||
expect(jumpTo.mock.calls).toEqual([[2400]]);
|
expect(jumpTo.mock.calls).toEqual([[2400]]);
|
||||||
expect(followTo).toHaveBeenCalledWith(2400);
|
expect(followTo).toHaveBeenCalledWith(2400);
|
||||||
|
expect(handoffAnimation.cancel).toHaveBeenCalled();
|
||||||
|
expect(animate).toHaveBeenCalledWith(
|
||||||
|
[{ opacity: 0.82 }, { opacity: 1 }],
|
||||||
|
{
|
||||||
|
duration: 140,
|
||||||
|
easing: "cubic-bezier(0.2, 0, 0, 1)",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(jumpTo.mock.invocationCallOrder[0]).toBeLessThan(
|
||||||
|
animate.mock.invocationCallOrder[1],
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("waits for hydrated messages before fulfilling open-chat bottom scroll", async () => {
|
it("waits for hydrated messages before fulfilling open-chat bottom scroll", async () => {
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
|
||||||
|
describe("UI shape system", () => {
|
||||||
|
it("gives standard controls one shared radius", () => {
|
||||||
|
render(
|
||||||
|
<>
|
||||||
|
<Button>Continue</Button>
|
||||||
|
<Input aria-label="Name" />
|
||||||
|
<Textarea aria-label="Description" />
|
||||||
|
</>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByRole("button", { name: "Continue" })).toHaveClass("rounded-control");
|
||||||
|
expect(screen.getByRole("textbox", { name: "Name" })).toHaveClass("rounded-control");
|
||||||
|
expect(screen.getByRole("textbox", { name: "Description" })).toHaveClass(
|
||||||
|
"rounded-control",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives dialogs and alert dialogs one shared modal radius", () => {
|
||||||
|
const dialog = render(
|
||||||
|
<Dialog open>
|
||||||
|
<DialogContent showCloseButton={false}>
|
||||||
|
<DialogTitle>Edit name</DialogTitle>
|
||||||
|
<DialogDescription>Choose a new name.</DialogDescription>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>,
|
||||||
|
);
|
||||||
|
expect(screen.getByRole("dialog", { name: "Edit name" })).toHaveClass("rounded-modal");
|
||||||
|
dialog.unmount();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<AlertDialog open>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogTitle>Delete item?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>This cannot be undone.</AlertDialogDescription>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>,
|
||||||
|
);
|
||||||
|
expect(screen.getByRole("alertdialog", { name: "Delete item?" })).toHaveClass(
|
||||||
|
"rounded-modal",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps floating surfaces and their items on the shared radius scale", () => {
|
||||||
|
render(
|
||||||
|
<DropdownMenu open>
|
||||||
|
<DropdownMenuTrigger>Open menu</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent>
|
||||||
|
<DropdownMenuItem>Rename</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByRole("menu")).toHaveClass("rounded-floating");
|
||||||
|
expect(screen.getByRole("menuitem", { name: "Rename" })).toHaveClass(
|
||||||
|
"rounded-control",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -51,6 +51,13 @@ export default {
|
|||||||
lg: "var(--radius)",
|
lg: "var(--radius)",
|
||||||
md: "calc(var(--radius) - 2px)",
|
md: "calc(var(--radius) - 2px)",
|
||||||
sm: "calc(var(--radius) - 4px)",
|
sm: "calc(var(--radius) - 4px)",
|
||||||
|
mark: "var(--radius-mark)",
|
||||||
|
compact: "var(--radius-compact)",
|
||||||
|
control: "var(--radius-control)",
|
||||||
|
floating: "var(--radius-floating)",
|
||||||
|
panel: "var(--radius-panel)",
|
||||||
|
modal: "var(--radius-modal)",
|
||||||
|
prominent: "var(--radius-prominent)",
|
||||||
},
|
},
|
||||||
colors: {
|
colors: {
|
||||||
background: "hsl(var(--background))",
|
background: "hsl(var(--background))",
|
||||||
|
|||||||
Reference in New Issue
Block a user