mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-12 05:09:01 +08:00
Compare commits
159 Commits
fix/node-s
...
bb/update-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
350f4a6448 | ||
|
|
a6c60a2902 | ||
|
|
7f016f5f33 | ||
|
|
ab706a3346 | ||
|
|
4eca569bf4 | ||
|
|
7c00ffd92c | ||
|
|
fb853a1783 | ||
|
|
96cd37e212 | ||
|
|
bcb024ad48 | ||
|
|
500cf537b7 | ||
|
|
10c78bf625 | ||
|
|
9cc47b20cb | ||
|
|
5bcb63e400 | ||
|
|
2069e78b88 | ||
|
|
1bcfe9c58a | ||
|
|
e9529578d5 | ||
|
|
25742372eb | ||
|
|
facd011b63 | ||
|
|
338f0b2234 | ||
|
|
ff5652d0f6 | ||
|
|
7b4acadfe7 | ||
|
|
4891f9ae78 | ||
|
|
89baf02919 | ||
|
|
1b01fa3acf | ||
|
|
86371e6cd8 | ||
|
|
80672754a8 | ||
|
|
dfe6fbb0b3 | ||
|
|
46abf04012 | ||
|
|
ea44011d15 | ||
|
|
93b5df3189 | ||
|
|
c60952ba94 | ||
|
|
46b2afc56b | ||
|
|
76c7512dbf | ||
|
|
19db9cd076 | ||
|
|
d33d23c852 | ||
|
|
f736d2be86 | ||
|
|
4a4b9bd2dc | ||
|
|
99cee124dc | ||
|
|
36f1cd7dea | ||
|
|
f764b0400a | ||
|
|
0538c5ed19 | ||
|
|
74e845c000 | ||
|
|
9dbd3c57d7 | ||
|
|
fe4e327bb5 | ||
|
|
c14c37d46b | ||
|
|
b20fcffa54 | ||
|
|
8a888441d7 | ||
|
|
c54b935873 | ||
|
|
fd87c61078 | ||
|
|
54cae7d1cb | ||
|
|
2c98dc0a96 | ||
|
|
82c157b267 | ||
|
|
4690bbc363 | ||
|
|
751b91446e | ||
|
|
454d6cbe52 | ||
|
|
e7a7872a87 | ||
|
|
2f0c8e90e6 | ||
|
|
5300727a08 | ||
|
|
6ad015255d | ||
|
|
eb43a5b5d8 | ||
|
|
b434f8c3e0 | ||
|
|
495c3733d8 | ||
|
|
825629424d | ||
|
|
a40e20e136 | ||
|
|
cf9dc366dd | ||
|
|
dfd6bcf1ff | ||
|
|
48d8d80771 | ||
|
|
0c7def31aa | ||
|
|
76b98f43ca | ||
|
|
fb18bde897 | ||
|
|
9915665e4c | ||
|
|
3e4fa8ca9c | ||
|
|
cfbc47d893 | ||
|
|
e0121c59d3 | ||
|
|
d29caf3828 | ||
|
|
5df732a355 | ||
|
|
b94b3622b5 | ||
|
|
1eeb7da2e6 | ||
|
|
acce1a2452 | ||
|
|
a3fb48b2ce | ||
|
|
d1367355d5 | ||
|
|
1f347ee543 | ||
|
|
ee7948ea6e | ||
|
|
8077e7d2fb | ||
|
|
bd6d098762 | ||
|
|
98903d0313 | ||
|
|
30412a9771 | ||
|
|
693f4c7e9c | ||
|
|
2982122be7 | ||
|
|
580d924097 | ||
|
|
9ecc331be8 | ||
|
|
62f0cfd902 | ||
|
|
081694c111 | ||
|
|
de370fd10f | ||
|
|
c2d11cc95d | ||
|
|
6feb40e702 | ||
|
|
fef04a197e | ||
|
|
f583c6ebd5 | ||
|
|
e003c53b06 | ||
|
|
3858cf4307 | ||
|
|
b7169f9bbb | ||
|
|
a6a0a5b1b0 | ||
|
|
fff0561441 | ||
|
|
07f5382675 | ||
|
|
4cca7f569d | ||
|
|
dd4ba4c2c4 | ||
|
|
6bdbe30763 | ||
|
|
f7dabd3019 | ||
|
|
7314757876 | ||
|
|
f3bbfda6d1 | ||
|
|
86c64cfb5b | ||
|
|
38d3c49aaf | ||
|
|
c136eb4de1 | ||
|
|
28ca4460a1 | ||
|
|
cbfe1d21d1 | ||
|
|
cd68b8f0e8 | ||
|
|
d12c233378 | ||
|
|
71a9f44e80 | ||
|
|
fa8e2f935b | ||
|
|
b531b5d12a | ||
|
|
3d1d0a49fe | ||
|
|
5f62ba8e4b | ||
|
|
643181b346 | ||
|
|
b6206020d3 | ||
|
|
34a2903527 | ||
|
|
9fbfeb31b9 | ||
|
|
eb9cde7346 | ||
|
|
c14e6b4edf | ||
|
|
c9b62061d4 | ||
|
|
153fe28474 | ||
|
|
0b46c4163a | ||
|
|
9756dff5fd | ||
|
|
b04c6e95f6 | ||
|
|
a6a4e6f9d7 | ||
|
|
5f199e610b | ||
|
|
de60bf40c6 | ||
|
|
4ae3c988b5 | ||
|
|
d3fab54933 | ||
|
|
c0435f4fef | ||
|
|
df9fb8e5e6 | ||
|
|
616c0a36b6 | ||
|
|
f57ce341dc | ||
|
|
cae6b5486f | ||
|
|
bf82a7f1cc | ||
|
|
aeec88c77f | ||
|
|
b1b0f4b668 | ||
|
|
0175be3aa7 | ||
|
|
928f1ac0e1 | ||
|
|
4ed63170e4 | ||
|
|
bd12b3c232 | ||
|
|
fe709a4210 | ||
|
|
385a508e43 | ||
|
|
bf590c81d0 | ||
|
|
9d07927a23 | ||
|
|
9cbc37e25b | ||
|
|
b36a30db20 | ||
|
|
3a25912c14 | ||
|
|
acb0e2bacb | ||
|
|
ed9e8ba097 |
@@ -3,6 +3,21 @@
|
||||
.gitignore
|
||||
.gitmodules
|
||||
|
||||
# Python
|
||||
__pycache__
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
|
||||
# Virtual environments
|
||||
venv/
|
||||
env/
|
||||
ENV/
|
||||
|
||||
# Dependencies
|
||||
node_modules
|
||||
**/node_modules
|
||||
@@ -24,7 +39,20 @@ ui-tui/packages/hermes-ink/dist/
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.*
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Testing
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
|
||||
# Documentation
|
||||
*.md
|
||||
|
||||
# Runtime data (bind-mounted at /opt/data; must not leak into build context)
|
||||
|
||||
8
.gitattributes
vendored
8
.gitattributes
vendored
@@ -1,2 +1,10 @@
|
||||
# Auto-generated files — collapse diffs and exclude from language stats
|
||||
web/package-lock.json linguist-generated=true
|
||||
|
||||
# Enforce LF for scripts that run inside Linux containers.
|
||||
# Without this, Windows checkout converts to CRLF and breaks `exec` in the
|
||||
# container entrypoint with "no such file or directory".
|
||||
*.sh text eol=lf
|
||||
Dockerfile text eol=lf
|
||||
*.dockerfile text eol=lf
|
||||
docker/entrypoint.sh text eol=lf
|
||||
|
||||
BIN
.github/pr-screenshots/39327/providers-collapsed.png
vendored
Executable file
BIN
.github/pr-screenshots/39327/providers-collapsed.png
vendored
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
BIN
.github/pr-screenshots/39327/providers-expanded.png
vendored
Executable file
BIN
.github/pr-screenshots/39327/providers-expanded.png
vendored
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
BIN
.github/pr-screenshots/39327/tools-collapsed.png
vendored
Executable file
BIN
.github/pr-screenshots/39327/tools-collapsed.png
vendored
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
BIN
.github/pr-screenshots/39327/tools-expanded.png
vendored
Executable file
BIN
.github/pr-screenshots/39327/tools-expanded.png
vendored
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,5 +1,6 @@
|
||||
.DS_Store
|
||||
/venv/
|
||||
/venv.old/
|
||||
/_pycache/
|
||||
*.pyc*
|
||||
__pycache__/
|
||||
|
||||
@@ -33,7 +33,7 @@ Use any model you want — [Nous Portal](https://portal.nousresearch.com), [Open
|
||||
### Linux, macOS, WSL2, Termux
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash
|
||||
curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash
|
||||
```
|
||||
|
||||
### Windows (native, PowerShell)
|
||||
@@ -43,7 +43,7 @@ curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scri
|
||||
Run this in PowerShell:
|
||||
|
||||
```powershell
|
||||
iex (irm https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.ps1)
|
||||
iex (irm https://hermes-agent.nousresearch.com/install.ps1)
|
||||
```
|
||||
|
||||
The installer handles everything: uv, Python 3.11, Node.js, ripgrep, ffmpeg, **and a portable Git Bash** (MinGit, unpacked to `%LOCALAPPDATA%\hermes\git` — no admin required, completely isolated from any system Git install). Hermes uses this bundled Git Bash to run shell commands.
|
||||
@@ -52,7 +52,7 @@ If you already have Git installed, the installer detects it and uses that instea
|
||||
|
||||
> **Android / Termux:** The tested manual path is documented in the [Termux guide](https://hermes-agent.nousresearch.com/docs/getting-started/termux). On Termux, Hermes installs a curated `.[termux]` extra because the full `.[all]` extra currently pulls Android-incompatible voice dependencies.
|
||||
>
|
||||
> **Windows:** Native Windows is fully supported — the PowerShell one-liner above installs everything. If you'd rather use WSL2, the Linux command works there too. Native Windows install lives under `%LOCALAPPDATA%\hermes`; WSL2 installs under `~/.hermes` as on Linux. The only Hermes feature that currently needs WSL2 specifically is the browser-based dashboard chat pane (it uses a POSIX PTY — classic CLI and gateway both run natively).
|
||||
> **Windows:** Native Windows is fully supported — the PowerShell one-liner above installs everything. If you'd rather use WSL2, the Linux command works there too. Native Windows install lives under `%LOCALAPPDATA%\hermes`; WSL2 installs under `~/.hermes` as on Linux. The only Hermes feature that currently needs WSL2 specifically is the browser-based dashboard chat pane (it uses a POSIX PTY — classic CLI and gateway both run natively).
|
||||
|
||||
After installation:
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
## 快速安装
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash
|
||||
curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash
|
||||
```
|
||||
|
||||
支持 Linux、macOS、WSL2 和 Android (Termux)。安装程序会自动处理平台特定的配置。
|
||||
|
||||
@@ -457,12 +457,7 @@ class SessionManager:
|
||||
else:
|
||||
# Update model_config (contains cwd) if changed.
|
||||
try:
|
||||
with db._lock:
|
||||
db._conn.execute(
|
||||
"UPDATE sessions SET model_config = ?, model = COALESCE(?, model) WHERE id = ?",
|
||||
(cwd_json, model_str, state.session_id),
|
||||
)
|
||||
db._conn.commit()
|
||||
db.update_session_meta(state.session_id, cwd_json, model_str)
|
||||
except Exception:
|
||||
logger.debug("Failed to update ACP session metadata", exc_info=True)
|
||||
|
||||
|
||||
@@ -265,9 +265,6 @@ _API_KEY_PROVIDER_AUX_MODELS_FALLBACK: Dict[str, str] = {
|
||||
"stepfun": "step-3.5-flash",
|
||||
"kimi-coding-cn": "kimi-k2-turbo-preview",
|
||||
"gmi": "google/gemini-3.1-flash-lite-preview",
|
||||
"minimax": "MiniMax-M2.7",
|
||||
"minimax-oauth": "MiniMax-M2.7-highspeed",
|
||||
"minimax-cn": "MiniMax-M2.7",
|
||||
"anthropic": "claude-haiku-4-5-20251001",
|
||||
"opencode-zen": "gemini-3-flash",
|
||||
"opencode-go": "glm-5",
|
||||
@@ -4756,10 +4753,14 @@ def _is_anthropic_compat_endpoint(provider: str, base_url: str) -> bool:
|
||||
|
||||
|
||||
def _convert_openai_images_to_anthropic(messages: list) -> list:
|
||||
"""Convert OpenAI ``image_url`` content blocks to Anthropic ``image`` blocks.
|
||||
"""Convert OpenAI ``image_url``/``video_url`` blocks to Anthropic format.
|
||||
|
||||
Only touches messages that have list-type content with ``image_url`` blocks;
|
||||
plain text messages pass through unchanged.
|
||||
Converts:
|
||||
- ``image_url`` blocks to Anthropic ``image`` blocks
|
||||
- ``video_url`` blocks to Anthropic ``video`` blocks (MiniMax M3 compat)
|
||||
|
||||
Only touches messages that have list-type content with ``image_url`` or
|
||||
``video_url`` blocks; plain text messages pass through unchanged.
|
||||
"""
|
||||
converted = []
|
||||
for msg in messages:
|
||||
@@ -4796,6 +4797,39 @@ def _convert_openai_images_to_anthropic(messages: list) -> list:
|
||||
},
|
||||
})
|
||||
changed = True
|
||||
elif block.get("type") == "video_url":
|
||||
# MiniMax's Anthropic-compatible endpoint expects a "video"
|
||||
# block (not OpenAI's "video_url", and not "input_video").
|
||||
# See https://platform.minimax.io/docs/api-reference/text-anthropic-api
|
||||
# — the Messages-field table lists type="video" (M3 only,
|
||||
# URL/base64/mm_file://). The source shape mirrors the "image"
|
||||
# block: base64 → {type:"base64", media_type, data}, URL →
|
||||
# {type:"url", url}.
|
||||
video_url_val = (block.get("video_url") or {}).get("url", "")
|
||||
if video_url_val.startswith("data:"):
|
||||
# Parse data URI: data:<media_type>;base64,<data>
|
||||
header, _, b64data = video_url_val.partition(",")
|
||||
media_type = "video/mp4"
|
||||
if ":" in header and ";" in header:
|
||||
media_type = header.split(":", 1)[1].split(";", 1)[0]
|
||||
new_content.append({
|
||||
"type": "video",
|
||||
"source": {
|
||||
"type": "base64",
|
||||
"media_type": media_type,
|
||||
"data": b64data,
|
||||
},
|
||||
})
|
||||
else:
|
||||
# URL-based video
|
||||
new_content.append({
|
||||
"type": "video",
|
||||
"source": {
|
||||
"type": "url",
|
||||
"url": video_url_val,
|
||||
},
|
||||
})
|
||||
changed = True
|
||||
else:
|
||||
new_content.append(block)
|
||||
converted.append({**msg, "content": new_content} if changed else msg)
|
||||
|
||||
@@ -646,6 +646,11 @@ def try_shrink_image_parts_in_messages(api_messages: list) -> bool:
|
||||
# much larger; shrinking to 4 MB here loses quality but only fires
|
||||
# after a confirmed provider rejection, so the alternative is failure.
|
||||
target_bytes = 4 * 1024 * 1024
|
||||
# Anthropic enforces an 8000px per-side dimension cap independently of
|
||||
# the 5 MB byte cap. A tall screenshot can be well under 5 MB yet far
|
||||
# over 8000px (e.g. 1200×12000 at 0.06 MB). We check pixel dimensions
|
||||
# even when the byte budget is fine.
|
||||
max_dimension = 8000
|
||||
changed_count = 0
|
||||
# Track parts that are over the target but could NOT be shrunk under it.
|
||||
# If any survive, retrying is pointless — the same oversized payload will
|
||||
@@ -658,9 +663,30 @@ def try_shrink_image_parts_in_messages(api_messages: list) -> bool:
|
||||
"""Return a smaller data URL, or None if shrink can't help."""
|
||||
if not isinstance(url, str) or not url.startswith("data:"):
|
||||
return None
|
||||
if len(url) <= target_bytes:
|
||||
# This specific image wasn't the oversized one.
|
||||
return None
|
||||
|
||||
# Check both byte size AND pixel dimensions.
|
||||
needs_shrink = len(url) > target_bytes # over byte budget
|
||||
if not needs_shrink:
|
||||
# Even if bytes are fine, check pixel dimensions against
|
||||
# Anthropic's 8000px cap. A tall image can be tiny in bytes
|
||||
# yet huge in pixels.
|
||||
try:
|
||||
import base64 as _b64_dim
|
||||
header_d, _, data_d = url.partition(",")
|
||||
if not data_d:
|
||||
return None
|
||||
raw_d = _b64_dim.b64decode(data_d)
|
||||
from PIL import Image as _PILImage
|
||||
import io as _io_dim
|
||||
with _PILImage.open(_io_dim.BytesIO(raw_d)) as _img:
|
||||
if max(_img.size) <= max_dimension:
|
||||
return None # both bytes and pixels are fine
|
||||
needs_shrink = True # pixels exceed limit, force shrink
|
||||
except Exception:
|
||||
# If we can't check dimensions (Pillow unavailable, corrupt
|
||||
# image, etc.), fall back to byte-only check.
|
||||
return None
|
||||
|
||||
try:
|
||||
header, _, data = url.partition(",")
|
||||
mime = "image/jpeg"
|
||||
@@ -684,6 +710,7 @@ def try_shrink_image_parts_in_messages(api_messages: list) -> bool:
|
||||
Path(tmp.name),
|
||||
mime_type=mime,
|
||||
max_base64_bytes=target_bytes,
|
||||
max_dimension=max_dimension,
|
||||
)
|
||||
finally:
|
||||
try:
|
||||
|
||||
@@ -171,6 +171,9 @@ _IMAGE_TOO_LARGE_PATTERNS = [
|
||||
"image too large", # generic
|
||||
"image_too_large", # error_code variant
|
||||
"image size exceeds", # variant
|
||||
"image dimensions exceed", # Anthropic: "image dimensions exceed max allowed size: 8000 pixels"
|
||||
"dimensions exceed max allowed size", # Anthropic dimension-cap (wording variant)
|
||||
"max allowed size: 8000", # Anthropic dimension-cap (explicit pixel ceiling)
|
||||
# "request_too_large" on a request known to contain an image → image is
|
||||
# the likely culprit; we still try the shrink path before giving up.
|
||||
]
|
||||
|
||||
@@ -245,14 +245,6 @@ def _install_npm(
|
||||
needs ``typescript`` next to it; intelephense ships standalone).
|
||||
"""
|
||||
npm = shutil.which("npm")
|
||||
if npm is None:
|
||||
# Fall back to the bundled npm at <HERMES_HOME>/node/bin when off-PATH
|
||||
# (e.g. root FHS install whose symlink is missing, #38889).
|
||||
try:
|
||||
from hermes_constants import find_node_executable
|
||||
npm = find_node_executable("npm")
|
||||
except Exception:
|
||||
npm = None
|
||||
if npm is None:
|
||||
logger.info("[install] cannot install %s: npm not on PATH", pkg)
|
||||
return None
|
||||
|
||||
@@ -441,6 +441,10 @@ def is_local_endpoint(base_url: str) -> bool:
|
||||
# Docker / Podman / Lima internal DNS names (e.g. host.docker.internal)
|
||||
if any(host.endswith(suffix) for suffix in _CONTAINER_LOCAL_SUFFIXES):
|
||||
return True
|
||||
# Unqualified hostnames (no dots) are local by definition — Docker
|
||||
# Compose service names, /etc/hosts entries, or mDNS names.
|
||||
if host and "." not in host:
|
||||
return True
|
||||
# RFC-1918 private ranges, link-local, and Tailscale CGNAT
|
||||
try:
|
||||
addr = ipaddress.ip_address(host)
|
||||
@@ -1140,6 +1144,18 @@ def _model_name_suggests_minimax_m3(model: str) -> bool:
|
||||
return "minimax-m3" in model.lower()
|
||||
|
||||
|
||||
def _model_name_suggests_grok_4_3(model: str) -> bool:
|
||||
"""Return True if the model name looks like a Grok 4.3 variant.
|
||||
|
||||
Catches ``grok-4.3``, ``grok-4.3-latest``, and similar slugs.
|
||||
Used as a guard against stale cache entries seeded by pre-catalog builds
|
||||
that resolved grok-4.3 via the generic ``grok-4`` catch-all (256,000)
|
||||
before the ``grok-4.3`` (1M) entry was added to DEFAULT_CONTEXT_LENGTHS
|
||||
on 2026-05-15.
|
||||
"""
|
||||
return "grok-4.3" in model.lower()
|
||||
|
||||
|
||||
def _query_local_context_length(model: str, base_url: str, api_key: str = "") -> Optional[int]:
|
||||
"""Query a local server for the model's context length."""
|
||||
import httpx
|
||||
@@ -1564,6 +1580,19 @@ def get_model_context_length(
|
||||
model, base_url, f"{cached:,}",
|
||||
)
|
||||
_invalidate_cached_context_length(model, base_url)
|
||||
# Invalidate stale ≤256,000 cache entries for Grok-4.3. The
|
||||
# ``grok-4.3`` (1M) entry was added to DEFAULT_CONTEXT_LENGTHS on
|
||||
# 2026-05-15; prior to that, grok-4.3 slugs resolved via the
|
||||
# ``grok-4`` catch-all (256,000) and that value was persisted.
|
||||
# grok-4.3 is 1M, so any sub-262K cached value is a pre-catalog
|
||||
# leftover — drop it and fall through to the hardcoded default.
|
||||
elif cached <= 256_000 and _model_name_suggests_grok_4_3(model):
|
||||
logger.info(
|
||||
"Dropping stale Grok-4.3 cache entry %s@%s -> %s (pre-catalog value); "
|
||||
"re-resolving via hardcoded defaults",
|
||||
model, base_url, f"{cached:,}",
|
||||
)
|
||||
_invalidate_cached_context_length(model, base_url)
|
||||
# Nous Portal: the portal /v1/models endpoint is authoritative.
|
||||
# Bypass the persistent cache so step 5b can always reconcile
|
||||
# against it — this corrects pre-fix entries seeded from the
|
||||
|
||||
@@ -22,6 +22,7 @@ from agent.skill_utils import (
|
||||
get_disabled_skill_names,
|
||||
iter_skill_index_files,
|
||||
parse_frontmatter,
|
||||
skill_matches_environment,
|
||||
skill_matches_platform,
|
||||
)
|
||||
from utils import atomic_json_write
|
||||
@@ -1005,6 +1006,13 @@ def _parse_skill_file(skill_file: Path) -> tuple[bool, dict, str]:
|
||||
if not skill_matches_platform(frontmatter):
|
||||
return False, frontmatter, ""
|
||||
|
||||
# Environment relevance gate (offer-time only): hide skills tagged for
|
||||
# a runtime environment that isn't active (e.g. kanban-only skills for
|
||||
# non-kanban users, s6-only skills outside the container). Explicit
|
||||
# loads (skill_view / --skills) bypass this — see skill_matches_environment.
|
||||
if not skill_matches_environment(frontmatter):
|
||||
return False, frontmatter, ""
|
||||
|
||||
return True, frontmatter, extract_skill_description(frontmatter)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to parse skill file %s: %s", skill_file, e)
|
||||
|
||||
@@ -270,7 +270,7 @@ def scan_skill_commands() -> Dict[str, Dict[str, Any]]:
|
||||
_skill_commands_platform = _resolve_skill_commands_platform()
|
||||
_skill_commands = {}
|
||||
try:
|
||||
from tools.skills_tool import SKILLS_DIR, _parse_frontmatter, skill_matches_platform, _get_disabled_skill_names
|
||||
from tools.skills_tool import SKILLS_DIR, _parse_frontmatter, skill_matches_platform, skill_matches_environment, _get_disabled_skill_names
|
||||
from agent.skill_utils import get_external_skills_dirs, iter_skill_index_files
|
||||
disabled = _get_disabled_skill_names()
|
||||
seen_names: set = set()
|
||||
@@ -291,6 +291,10 @@ def scan_skill_commands() -> Dict[str, Dict[str, Any]]:
|
||||
# Skip skills incompatible with the current OS platform
|
||||
if not skill_matches_platform(frontmatter):
|
||||
continue
|
||||
# Skip skills not relevant to the current runtime env
|
||||
# (kanban/docker/s6). Offer-time only; explicit load bypasses.
|
||||
if not skill_matches_environment(frontmatter):
|
||||
continue
|
||||
name = frontmatter.get('name', skill_md.parent.name)
|
||||
if name in seen_names:
|
||||
continue
|
||||
|
||||
@@ -169,6 +169,106 @@ def skill_matches_platform(frontmatter: Dict[str, Any]) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
# ── Environment matching ──────────────────────────────────────────────────
|
||||
|
||||
# Recognized environment tags and how each is detected. An environment tag is
|
||||
# a *relevance* gate, not a hard-compatibility gate (that is what ``platforms:``
|
||||
# is for). A skill tagged for an environment it isn't relevant to is hidden from
|
||||
# the skills index / offer surfaces so it does not add noise for users who will
|
||||
# never need it — but it can ALWAYS still be loaded explicitly (``skill_view``,
|
||||
# ``--skills``), because an explicit request is explicit consent.
|
||||
#
|
||||
# Detection is cached for the process lifetime via ``_ENV_DETECT_CACHE``.
|
||||
_KNOWN_ENVIRONMENTS = frozenset({"kanban", "docker", "s6"})
|
||||
|
||||
_ENV_DETECT_CACHE: Dict[str, bool] = {}
|
||||
|
||||
|
||||
def _detect_environment(env: str) -> bool:
|
||||
"""Return True when the named runtime environment is currently active.
|
||||
|
||||
Cached per process. Unknown env names return True (fail-open: never hide a
|
||||
skill because of a tag we don't understand).
|
||||
"""
|
||||
if env in _ENV_DETECT_CACHE:
|
||||
return _ENV_DETECT_CACHE[env]
|
||||
|
||||
result = True
|
||||
if env == "kanban":
|
||||
# Kanban is "active" either as a dispatcher-spawned worker (the
|
||||
# dispatcher sets ``HERMES_KANBAN_TASK`` / ``HERMES_KANBAN_BOARD`` in the
|
||||
# worker env) or as an orchestrator profile that has opted into the
|
||||
# kanban toolset. Mirror the same signals the kanban tools themselves
|
||||
# gate on (``tools/kanban_tools.py``) so the offer filter agrees with
|
||||
# tool availability.
|
||||
if os.getenv("HERMES_KANBAN_TASK") or os.getenv("HERMES_KANBAN_BOARD"):
|
||||
result = True
|
||||
else:
|
||||
try:
|
||||
from tools.kanban_tools import _profile_has_kanban_toolset
|
||||
|
||||
result = bool(_profile_has_kanban_toolset())
|
||||
except Exception:
|
||||
result = False
|
||||
elif env == "docker":
|
||||
try:
|
||||
from hermes_constants import is_container
|
||||
|
||||
result = is_container()
|
||||
except Exception:
|
||||
result = False
|
||||
elif env == "s6":
|
||||
# The Hermes Docker image runs s6-overlay as PID 1 (/init). s6 plants
|
||||
# its runtime scaffolding under /run/s6 and ships its admin tree under
|
||||
# /package/admin/s6-overlay. Either marker means we're inside an
|
||||
# s6-supervised container.
|
||||
result = os.path.isdir("/run/s6") or os.path.isdir(
|
||||
"/package/admin/s6-overlay"
|
||||
)
|
||||
|
||||
_ENV_DETECT_CACHE[env] = result
|
||||
return result
|
||||
|
||||
|
||||
def skill_matches_environment(frontmatter: Dict[str, Any]) -> bool:
|
||||
"""Return True when the skill is relevant to the current runtime environment.
|
||||
|
||||
Skills may declare an ``environments`` list in their YAML frontmatter::
|
||||
|
||||
environments: [kanban] # only relevant when kanban is active
|
||||
environments: [s6] # only relevant inside the s6 Docker image
|
||||
environments: [docker] # only relevant inside any container
|
||||
|
||||
If the field is absent or empty the skill is relevant in **all**
|
||||
environments (backward-compatible default).
|
||||
|
||||
This is an OFFER-time filter: it controls whether a skill shows up in the
|
||||
skills index / autocomplete / slash-command list. It is intentionally NOT
|
||||
enforced by ``skill_view`` or ``--skills`` preloading — an explicit load is
|
||||
explicit consent, and load-bearing force-loads (e.g. the kanban dispatcher
|
||||
injecting ``--skills kanban-worker``) must always succeed regardless of how
|
||||
the offer surfaces filter the skill.
|
||||
|
||||
A skill matches when ANY of its declared environments is currently active
|
||||
(OR semantics, mirroring ``platforms``). Unknown env tags fail open.
|
||||
"""
|
||||
environments = frontmatter.get("environments")
|
||||
if not environments:
|
||||
return True
|
||||
if not isinstance(environments, list):
|
||||
environments = [environments]
|
||||
for env in environments:
|
||||
normalized = str(env).lower().strip()
|
||||
if not normalized:
|
||||
continue
|
||||
if normalized not in _KNOWN_ENVIRONMENTS:
|
||||
# Tag we don't understand — don't hide the skill over it.
|
||||
return True
|
||||
if _detect_environment(normalized):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# ── Disabled skills ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
Add `--include-desktop` to the [one-line installer](../../README.md#quick-install) and it sets up the agent and builds the desktop app in one go:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash -s -- --include-desktop
|
||||
curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash -s -- --include-desktop
|
||||
```
|
||||
|
||||
Already have the Hermes CLI? Just run:
|
||||
@@ -40,7 +40,7 @@ It builds and launches the GUI against your existing install — same config, ke
|
||||
|
||||
### Prebuilt installers
|
||||
|
||||
When a release ships desktop installers they're attached to its [releases page](https://github.com/NousResearch/hermes-agent/releases) — `.dmg` (macOS), `.exe` / `.msi` (Windows), `.AppImage` / `.deb` / `.rpm` (Linux). These are published manually, so the install-with-Hermes path above is the most reliable way to get the latest.
|
||||
Prebuilt installers are built and distributed via [the Hermes Desktop website.](https://hermes-agent.nousresearch.com/desktop).
|
||||
|
||||
---
|
||||
|
||||
@@ -56,10 +56,7 @@ hermes update
|
||||
|
||||
## Requirements
|
||||
|
||||
The installer handles everything for you (Python 3.11+, a portable Git, ripgrep). The only thing worth knowing:
|
||||
|
||||
- **Windows** — the installer bundles its own Git and Python; no admin rights or system changes required.
|
||||
- **macOS / Linux** — uses your system Python 3.11+ (installed automatically if missing).
|
||||
The installer handles everything for you (Python 3.11+, a portable Git, ripgrep).
|
||||
|
||||
---
|
||||
|
||||
@@ -94,7 +91,7 @@ Installers are built and uploaded to GitHub Releases manually. macOS/Windows sig
|
||||
|
||||
### How it works
|
||||
|
||||
The packaged app ships only the Electron shell. On first launch it installs the Hermes Agent runtime into `HERMES_HOME` (`~/.hermes`, or `%LOCALAPPDATA%\hermes` on Windows) — the **same layout a CLI install uses**, so the two are interchangeable. The renderer (React, in `src/`) talks to a `hermes dashboard --tui` backend over the standard gateway APIs and reuses the embedded TUI rather than reimplementing chat. The install, backend-resolution, and self-update logic all live in `electron/main.cjs`.
|
||||
The packaged app ships only the Electron shell. On first launch it installs the Hermes Agent runtime into `HERMES_HOME` (`~/.hermes`, or `%LOCALAPPDATA%\hermes` on Windows) — the **same layout a CLI install uses**, so the two are interchangeable. The renderer (React, in `src/`) talks to a `hermes dashboard` backend over the standard gateway APIs and reuses the embedded TUI rather than reimplementing chat. The install, backend-resolution, and self-update logic all live in `electron/main.cjs`.
|
||||
|
||||
### Verification
|
||||
|
||||
|
||||
@@ -67,7 +67,9 @@ test('verifyHermesCli returns true when --version exits 0', () => {
|
||||
} finally {
|
||||
try {
|
||||
fs.unlinkSync(scriptPath)
|
||||
} catch {}
|
||||
} catch {
|
||||
void 0
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -52,7 +52,9 @@ function detectRemoteDisplay(options = {}) {
|
||||
const env = options.env ?? process.env
|
||||
const platform = options.platform ?? process.platform
|
||||
|
||||
const override = String(env.HERMES_DESKTOP_DISABLE_GPU || '').trim().toLowerCase()
|
||||
const override = String(env.HERMES_DESKTOP_DISABLE_GPU || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
if (GPU_OVERRIDE_ON.has(override)) return 'override (HERMES_DESKTOP_DISABLE_GPU)'
|
||||
if (GPU_OVERRIDE_OFF.has(override)) return null
|
||||
|
||||
|
||||
@@ -45,11 +45,17 @@ test('detectRemoteDisplay does not treat WSLg as remote', () => {
|
||||
// WSLg renders locally via vGPU and doesn't show the flicker, so a WSL
|
||||
// session with a local DISPLAY keeps hardware acceleration on.
|
||||
assert.equal(detectRemoteDisplay({ env: { WSL_DISTRO_NAME: 'Ubuntu', DISPLAY: ':0' }, platform: 'linux' }), null)
|
||||
assert.equal(detectRemoteDisplay({ env: { WSL_INTEROP: '/run/WSL/1_interop', DISPLAY: ':0' }, platform: 'linux' }), null)
|
||||
assert.equal(
|
||||
detectRemoteDisplay({ env: { WSL_INTEROP: '/run/WSL/1_interop', DISPLAY: ':0' }, platform: 'linux' }),
|
||||
null
|
||||
)
|
||||
})
|
||||
|
||||
test('detectRemoteDisplay flags SSH sessions on any platform', () => {
|
||||
assert.equal(detectRemoteDisplay({ env: { SSH_CONNECTION: '1.2.3.4 5 6.7.8.9 22' }, platform: 'linux' }), 'ssh-session')
|
||||
assert.equal(
|
||||
detectRemoteDisplay({ env: { SSH_CONNECTION: '1.2.3.4 5 6.7.8.9 22' }, platform: 'linux' }),
|
||||
'ssh-session'
|
||||
)
|
||||
assert.equal(detectRemoteDisplay({ env: { SSH_CLIENT: '1.2.3.4 5 22' }, platform: 'darwin' }), 'ssh-session')
|
||||
assert.equal(detectRemoteDisplay({ env: { SSH_TTY: '/dev/pts/0' }, platform: 'win32' }), 'ssh-session')
|
||||
})
|
||||
|
||||
@@ -101,7 +101,9 @@ function downloadInstallScript(commit, destPath) {
|
||||
.get(res.headers.location, res2 => {
|
||||
if (res2.statusCode !== 200) {
|
||||
reject(
|
||||
new Error(`Failed to download ${scriptName}: HTTP ${res2.statusCode} from redirect ${res.headers.location}`)
|
||||
new Error(
|
||||
`Failed to download ${scriptName}: HTTP ${res2.statusCode} from redirect ${res.headers.location}`
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
@@ -121,7 +123,9 @@ function downloadInstallScript(commit, destPath) {
|
||||
out.close()
|
||||
try {
|
||||
fs.unlinkSync(tmpPath)
|
||||
} catch {}
|
||||
} catch {
|
||||
void 0
|
||||
}
|
||||
reject(new Error(`Failed to download ${scriptName}: HTTP ${res.statusCode} from ${url}`))
|
||||
return
|
||||
}
|
||||
@@ -134,14 +138,18 @@ function downloadInstallScript(commit, destPath) {
|
||||
out.on('error', err => {
|
||||
try {
|
||||
fs.unlinkSync(tmpPath)
|
||||
} catch {}
|
||||
} catch {
|
||||
void 0
|
||||
}
|
||||
reject(err)
|
||||
})
|
||||
})
|
||||
.on('error', err => {
|
||||
try {
|
||||
fs.unlinkSync(tmpPath)
|
||||
} catch {}
|
||||
} catch {
|
||||
void 0
|
||||
}
|
||||
reject(err)
|
||||
})
|
||||
})
|
||||
@@ -168,13 +176,19 @@ async function resolveInstallScript({ installStamp, sourceRepoRoot, hermesHome,
|
||||
const cached = cachedScriptPath(hermesHome, installStamp.commit)
|
||||
try {
|
||||
await fsp.access(cached, fs.constants.R_OK)
|
||||
emit({ type: 'log', line: `[bootstrap] using cached ${installScriptName()} for ${installStamp.commit.slice(0, 12)}` })
|
||||
emit({
|
||||
type: 'log',
|
||||
line: `[bootstrap] using cached ${installScriptName()} for ${installStamp.commit.slice(0, 12)}`
|
||||
})
|
||||
return { path: cached, source: 'cache', commit: installStamp.commit, kind: installScriptKind() }
|
||||
} catch {
|
||||
// not cached; download
|
||||
}
|
||||
|
||||
emit({ type: 'log', line: `[bootstrap] fetching ${installScriptName()} for ${installStamp.commit.slice(0, 12)} from GitHub` })
|
||||
emit({
|
||||
type: 'log',
|
||||
line: `[bootstrap] fetching ${installScriptName()} for ${installStamp.commit.slice(0, 12)} from GitHub`
|
||||
})
|
||||
await downloadInstallScript(installStamp.commit, cached)
|
||||
emit({ type: 'log', line: `[bootstrap] saved to ${cached}` })
|
||||
return { path: cached, source: 'download', commit: installStamp.commit, kind: installScriptKind() }
|
||||
@@ -207,7 +221,9 @@ function spawnPowerShell(scriptPath, args, { emit, stageName, abortSignal, herme
|
||||
killed = true
|
||||
try {
|
||||
child.kill('SIGTERM')
|
||||
} catch {}
|
||||
} catch {
|
||||
void 0
|
||||
}
|
||||
}
|
||||
if (abortSignal) {
|
||||
if (abortSignal.aborted) {
|
||||
@@ -278,7 +294,9 @@ function spawnBash(scriptPath, args, { emit, stageName, abortSignal, hermesHome
|
||||
killed = true
|
||||
try {
|
||||
child.kill('SIGTERM')
|
||||
} catch {}
|
||||
} catch {
|
||||
void 0
|
||||
}
|
||||
}
|
||||
if (abortSignal) {
|
||||
if (abortSignal.aborted) {
|
||||
@@ -369,7 +387,9 @@ async function fetchManifest({ scriptPath, installerKind, emit, hermesHome, acti
|
||||
hermesHome
|
||||
})
|
||||
if (result.code !== 0) {
|
||||
throw new Error(`${isPosix ? 'install.sh --manifest' : 'install.ps1 -Manifest'} failed: exit ${result.code}\n${result.stderr || result.stdout}`)
|
||||
throw new Error(
|
||||
`${isPosix ? 'install.sh --manifest' : 'install.ps1 -Manifest'} failed: exit ${result.code}\n${result.stderr || result.stdout}`
|
||||
)
|
||||
}
|
||||
// The manifest is the LAST JSON line on stdout (install.ps1 may print
|
||||
// banner / info lines first depending on Console.OutputEncoding effects).
|
||||
@@ -381,9 +401,13 @@ async function fetchManifest({ scriptPath, installerKind, emit, hermesHome, acti
|
||||
if (parsed && Array.isArray(parsed.stages)) {
|
||||
return parsed
|
||||
}
|
||||
} catch {}
|
||||
} catch {
|
||||
void 0
|
||||
}
|
||||
}
|
||||
throw new Error(`${isPosix ? 'install.sh --manifest' : 'install.ps1 -Manifest'} produced no parseable JSON payload\n${result.stdout}`)
|
||||
throw new Error(
|
||||
`${isPosix ? 'install.sh --manifest' : 'install.ps1 -Manifest'} produced no parseable JSON payload\n${result.stdout}`
|
||||
)
|
||||
}
|
||||
|
||||
// Parse the JSON result frame from a stage run. The protocol guarantees
|
||||
@@ -397,7 +421,9 @@ function parseStageResult(stdout) {
|
||||
if (parsed && typeof parsed.ok === 'boolean' && typeof parsed.stage === 'string') {
|
||||
return parsed
|
||||
}
|
||||
} catch {}
|
||||
} catch {
|
||||
void 0
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -408,13 +434,20 @@ async function runStage({ scriptPath, installerKind, stage, emit, hermesHome, ac
|
||||
|
||||
const isPosix = installerKind === 'posix'
|
||||
const args = isPosix
|
||||
? ['--stage', stage.name, '--non-interactive', '--json', ...buildPosixPinArgs({ installStamp, activeRoot, hermesHome })]
|
||||
? [
|
||||
'--stage',
|
||||
stage.name,
|
||||
'--non-interactive',
|
||||
'--json',
|
||||
...buildPosixPinArgs({ installStamp, activeRoot, hermesHome })
|
||||
]
|
||||
: ['-Stage', stage.name, '-NonInteractive', '-Json', ...buildPinArgs(installStamp)]
|
||||
const result = await (isPosix ? spawnBash : spawnPowerShell)(
|
||||
scriptPath,
|
||||
args,
|
||||
{ emit, stageName: stage.name, abortSignal, hermesHome }
|
||||
)
|
||||
const result = await (isPosix ? spawnBash : spawnPowerShell)(scriptPath, args, {
|
||||
emit,
|
||||
stageName: stage.name,
|
||||
abortSignal,
|
||||
hermesHome
|
||||
})
|
||||
|
||||
const durationMs = Date.now() - startedAt
|
||||
|
||||
@@ -449,7 +482,14 @@ async function runStage({ scriptPath, installerKind, stage, emit, hermesHome, ac
|
||||
emit(ev)
|
||||
return ev
|
||||
}
|
||||
const ev = { type: 'stage', name: stage.name, state: 'failed', durationMs, json, error: json.reason || `exit code ${result.code}` }
|
||||
const ev = {
|
||||
type: 'stage',
|
||||
name: stage.name,
|
||||
state: 'failed',
|
||||
durationMs,
|
||||
json,
|
||||
error: json.reason || `exit code ${result.code}`
|
||||
}
|
||||
emit(ev)
|
||||
return ev
|
||||
}
|
||||
@@ -489,7 +529,9 @@ async function runBootstrap(opts) {
|
||||
if (typeof onEvent === 'function') {
|
||||
try {
|
||||
onEvent({ type: 'failed', error: 'bootstrap cancelled by user' })
|
||||
} catch {}
|
||||
} catch {
|
||||
void 0
|
||||
}
|
||||
}
|
||||
return { ok: false, cancelled: true }
|
||||
}
|
||||
@@ -501,7 +543,9 @@ async function runBootstrap(opts) {
|
||||
const emit = ev => {
|
||||
try {
|
||||
runLog.stream.write(JSON.stringify(ev) + '\n')
|
||||
} catch {}
|
||||
} catch {
|
||||
void 0
|
||||
}
|
||||
try {
|
||||
if (typeof onEvent === 'function') onEvent(ev)
|
||||
} catch (err) {
|
||||
@@ -578,7 +622,9 @@ async function runBootstrap(opts) {
|
||||
} finally {
|
||||
try {
|
||||
runLog.stream.end()
|
||||
} catch {}
|
||||
} catch {
|
||||
void 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
172
apps/desktop/electron/connection-config.cjs
Normal file
172
apps/desktop/electron/connection-config.cjs
Normal file
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* connection-config.cjs
|
||||
*
|
||||
* Pure, electron-free helpers for the desktop's remote-gateway connection
|
||||
* config: URL normalization, WS-URL construction (token vs OAuth ticket),
|
||||
* auth-mode classification, and the auth-mode coercion rules.
|
||||
*
|
||||
* Kept standalone (no `require('electron')`) so it can be unit-tested with
|
||||
* `node --test` — same pattern as backend-probes.cjs / bootstrap-platform.cjs.
|
||||
* main.cjs requires these and wires them into the electron-coupled IPC layer.
|
||||
*
|
||||
* Background on the two auth models a remote gateway can use:
|
||||
* - 'token': legacy static dashboard session token. REST uses an
|
||||
* `X-Hermes-Session-Token` header; WS uses `?token=`.
|
||||
* - 'oauth': hosted gateways gate behind an OAuth provider. REST is authed
|
||||
* by an HttpOnly session cookie; WS upgrades require a single-use
|
||||
* `?ticket=` minted at POST /api/auth/ws-ticket. The gateway advertises
|
||||
* this via the public `/api/status` field `auth_required: true`.
|
||||
*/
|
||||
|
||||
// Bare + prefixed variants of the access-token cookie the gateway may set,
|
||||
// depending on its deploy shape (HTTPS direct → __Host-, behind a path prefix
|
||||
// → __Secure-, loopback HTTP → bare). Mirrors
|
||||
// hermes_cli/dashboard_auth/cookies.py.
|
||||
const AT_COOKIE_VARIANTS = ['__Host-hermes_session_at', '__Secure-hermes_session_at', 'hermes_session_at']
|
||||
|
||||
function normalizeRemoteBaseUrl(rawUrl) {
|
||||
const value = String(rawUrl || '').trim()
|
||||
|
||||
if (!value) {
|
||||
throw new Error('Remote gateway URL is required.')
|
||||
}
|
||||
|
||||
let parsed
|
||||
try {
|
||||
parsed = new URL(value)
|
||||
} catch (error) {
|
||||
throw new Error(`Remote gateway URL is not valid: ${error.message}`)
|
||||
}
|
||||
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
throw new Error(`Remote gateway URL must be http:// or https://, got ${parsed.protocol}`)
|
||||
}
|
||||
|
||||
parsed.hash = ''
|
||||
parsed.search = ''
|
||||
parsed.pathname = parsed.pathname.replace(/\/+$/, '')
|
||||
|
||||
return parsed.toString().replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
function buildGatewayWsUrl(baseUrl, token) {
|
||||
const parsed = new URL(baseUrl)
|
||||
const wsScheme = parsed.protocol === 'https:' ? 'wss' : 'ws'
|
||||
const prefix = parsed.pathname.replace(/\/+$/, '')
|
||||
|
||||
return `${wsScheme}://${parsed.host}${prefix}/api/ws?token=${encodeURIComponent(token)}`
|
||||
}
|
||||
|
||||
function buildGatewayWsUrlWithTicket(baseUrl, ticket) {
|
||||
const parsed = new URL(baseUrl)
|
||||
const wsScheme = parsed.protocol === 'https:' ? 'wss' : 'ws'
|
||||
const prefix = parsed.pathname.replace(/\/+$/, '')
|
||||
|
||||
return `${wsScheme}://${parsed.host}${prefix}/api/ws?ticket=${encodeURIComponent(ticket)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the WS URL the renderer would connect with, so the connection test can
|
||||
* exercise the same transport the app actually uses.
|
||||
*
|
||||
* The OAuth ticket-minter is injected (`mintTicket(baseUrl) -> Promise<ticket>`)
|
||||
* so this stays electron-free and unit-testable; main.cjs passes the real
|
||||
* `mintGatewayWsTicket`.
|
||||
*
|
||||
* Return semantics:
|
||||
* - token mode + token → ws(s)://…/api/ws?token=…
|
||||
* - token mode, no token → null (genuine skip; nothing to authenticate with)
|
||||
* - oauth, mint ok → ws(s)://…/api/ws?ticket=…
|
||||
* - oauth, mint fails → THROWS (NOT a skip)
|
||||
*
|
||||
* The oauth-mint-failure throw is the important case: the real boot path
|
||||
* (resolveRemoteBackend in main.cjs) treats a mint failure as a hard
|
||||
* "session expired" auth error and refuses to connect. Swallowing it here
|
||||
* would re-introduce the exact false-positive this test exists to catch —
|
||||
* HTTP /api/status passes, the test reports "reachable", then the renderer
|
||||
* can't authenticate /api/ws and boot dies with "Could not connect".
|
||||
*
|
||||
* @param {string} baseUrl
|
||||
* @param {'token'|'oauth'} authMode
|
||||
* @param {string|null} token
|
||||
* @param {{ mintTicket: (baseUrl: string) => Promise<string> }} deps
|
||||
* @returns {Promise<string|null>}
|
||||
*/
|
||||
async function resolveTestWsUrl(baseUrl, authMode, token, deps = {}) {
|
||||
if (authMode === 'oauth') {
|
||||
const mintTicket = deps.mintTicket
|
||||
if (typeof mintTicket !== 'function') {
|
||||
throw new Error('resolveTestWsUrl: a mintTicket function is required in OAuth mode.')
|
||||
}
|
||||
let ticket
|
||||
try {
|
||||
ticket = await mintTicket(baseUrl)
|
||||
} catch (error) {
|
||||
const err = new Error(
|
||||
'Reached the gateway over HTTP, but could not mint a WebSocket ticket for the OAuth session ' +
|
||||
'(it may have expired). Open Settings → Gateway and sign in again.'
|
||||
)
|
||||
err.needsOauthLogin = true
|
||||
err.cause = error
|
||||
throw err
|
||||
}
|
||||
return buildGatewayWsUrlWithTicket(baseUrl, ticket)
|
||||
}
|
||||
if (!token) {
|
||||
return null
|
||||
}
|
||||
return buildGatewayWsUrl(baseUrl, token)
|
||||
}
|
||||
|
||||
function tokenPreview(value) {
|
||||
const raw = String(value || '')
|
||||
|
||||
if (!raw) {
|
||||
return null
|
||||
}
|
||||
|
||||
return raw.length <= 8 ? 'set' : `...${raw.slice(-6)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a gateway's auth mode from its public /api/status body.
|
||||
* `auth_required: true` → OAuth gate engaged; otherwise legacy token auth.
|
||||
* Returns 'oauth' | 'token'.
|
||||
*/
|
||||
function authModeFromStatus(statusBody) {
|
||||
return statusBody && statusBody.auth_required ? 'oauth' : 'token'
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the effective auth mode for a coerce/save operation.
|
||||
* Explicit input wins; otherwise inherit the saved value; default 'token'.
|
||||
* Returns 'oauth' | 'token'.
|
||||
*/
|
||||
function resolveAuthMode(inputAuthMode, existingAuthMode) {
|
||||
if (inputAuthMode === 'oauth') return 'oauth'
|
||||
if (inputAuthMode === 'token') return 'token'
|
||||
if (existingAuthMode === 'oauth') return 'oauth'
|
||||
return 'token'
|
||||
}
|
||||
|
||||
/**
|
||||
* True if any cookie in `cookies` is a hermes session access-token cookie
|
||||
* with a non-empty value. `cookies` is an array of {name, value} (the shape
|
||||
* Electron's session.cookies.get returns).
|
||||
*/
|
||||
function cookiesHaveSession(cookies) {
|
||||
if (!Array.isArray(cookies)) return false
|
||||
return cookies.some(c => c && AT_COOKIE_VARIANTS.includes(c.name) && c.value)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
AT_COOKIE_VARIANTS,
|
||||
authModeFromStatus,
|
||||
buildGatewayWsUrl,
|
||||
buildGatewayWsUrlWithTicket,
|
||||
cookiesHaveSession,
|
||||
normalizeRemoteBaseUrl,
|
||||
resolveAuthMode,
|
||||
resolveTestWsUrl,
|
||||
tokenPreview
|
||||
}
|
||||
211
apps/desktop/electron/connection-config.test.cjs
Normal file
211
apps/desktop/electron/connection-config.test.cjs
Normal file
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* Tests for electron/connection-config.cjs.
|
||||
*
|
||||
* Run with: node --test electron/connection-config.test.cjs
|
||||
* (Wire into npm test:desktop:platforms in package.json.)
|
||||
*
|
||||
* These are the pure helpers behind the remote-gateway connection settings:
|
||||
* URL normalization, WS-URL construction (token vs OAuth ticket), auth-mode
|
||||
* classification from /api/status, the coerce-time auth-mode resolution rules,
|
||||
* and the OAuth session-cookie detector.
|
||||
*/
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const {
|
||||
AT_COOKIE_VARIANTS,
|
||||
authModeFromStatus,
|
||||
buildGatewayWsUrl,
|
||||
buildGatewayWsUrlWithTicket,
|
||||
cookiesHaveSession,
|
||||
normalizeRemoteBaseUrl,
|
||||
resolveAuthMode,
|
||||
resolveTestWsUrl,
|
||||
tokenPreview
|
||||
} = require('./connection-config.cjs')
|
||||
|
||||
// --- normalizeRemoteBaseUrl ---
|
||||
|
||||
test('normalizeRemoteBaseUrl strips trailing slashes, hash, and query', () => {
|
||||
assert.equal(normalizeRemoteBaseUrl('https://gw.example.com/'), 'https://gw.example.com')
|
||||
assert.equal(normalizeRemoteBaseUrl('https://gw.example.com/hermes/'), 'https://gw.example.com/hermes')
|
||||
assert.equal(normalizeRemoteBaseUrl('https://gw.example.com/hermes?x=1#frag'), 'https://gw.example.com/hermes')
|
||||
})
|
||||
|
||||
test('normalizeRemoteBaseUrl preserves a path prefix', () => {
|
||||
assert.equal(normalizeRemoteBaseUrl('https://host/hermes'), 'https://host/hermes')
|
||||
})
|
||||
|
||||
test('normalizeRemoteBaseUrl rejects empty input', () => {
|
||||
assert.throws(() => normalizeRemoteBaseUrl(''), /required/)
|
||||
assert.throws(() => normalizeRemoteBaseUrl(' '), /required/)
|
||||
})
|
||||
|
||||
test('normalizeRemoteBaseUrl rejects non-http(s) protocols', () => {
|
||||
assert.throws(() => normalizeRemoteBaseUrl('ftp://host'), /http:\/\/ or https:\/\//)
|
||||
assert.throws(() => normalizeRemoteBaseUrl('file:///etc/passwd'), /http:\/\/ or https:\/\//)
|
||||
})
|
||||
|
||||
test('normalizeRemoteBaseUrl rejects garbage', () => {
|
||||
assert.throws(() => normalizeRemoteBaseUrl('not a url'), /not valid/)
|
||||
})
|
||||
|
||||
// --- buildGatewayWsUrl (token) ---
|
||||
|
||||
test('buildGatewayWsUrl uses wss for https and bakes the token', () => {
|
||||
assert.equal(buildGatewayWsUrl('https://gw.example.com', 'tok123'), 'wss://gw.example.com/api/ws?token=tok123')
|
||||
})
|
||||
|
||||
test('buildGatewayWsUrl uses ws for http', () => {
|
||||
assert.equal(buildGatewayWsUrl('http://127.0.0.1:9119', 'abc'), 'ws://127.0.0.1:9119/api/ws?token=abc')
|
||||
})
|
||||
|
||||
test('buildGatewayWsUrl honors a path prefix', () => {
|
||||
assert.equal(buildGatewayWsUrl('https://host/hermes', 't'), 'wss://host/hermes/api/ws?token=t')
|
||||
})
|
||||
|
||||
test('buildGatewayWsUrl url-encodes the token', () => {
|
||||
assert.equal(buildGatewayWsUrl('https://host', 'a/b c+d'), 'wss://host/api/ws?token=a%2Fb%20c%2Bd')
|
||||
})
|
||||
|
||||
// --- buildGatewayWsUrlWithTicket (oauth) ---
|
||||
|
||||
test('buildGatewayWsUrlWithTicket uses ?ticket= not ?token=', () => {
|
||||
const url = buildGatewayWsUrlWithTicket('https://gw.example.com/hermes', 'tkt-9')
|
||||
assert.equal(url, 'wss://gw.example.com/hermes/api/ws?ticket=tkt-9')
|
||||
assert.ok(!url.includes('token='))
|
||||
})
|
||||
|
||||
test('buildGatewayWsUrlWithTicket url-encodes the ticket', () => {
|
||||
assert.equal(buildGatewayWsUrlWithTicket('https://host', 'a+b/c'), 'wss://host/api/ws?ticket=a%2Bb%2Fc')
|
||||
})
|
||||
|
||||
// --- authModeFromStatus ---
|
||||
|
||||
test('authModeFromStatus returns oauth when auth_required is true', () => {
|
||||
assert.equal(authModeFromStatus({ auth_required: true, auth_providers: ['nous'] }), 'oauth')
|
||||
})
|
||||
|
||||
test('authModeFromStatus returns token when auth_required is false/missing', () => {
|
||||
assert.equal(authModeFromStatus({ auth_required: false }), 'token')
|
||||
assert.equal(authModeFromStatus({}), 'token')
|
||||
assert.equal(authModeFromStatus(null), 'token')
|
||||
assert.equal(authModeFromStatus(undefined), 'token')
|
||||
})
|
||||
|
||||
// --- resolveAuthMode ---
|
||||
|
||||
test('resolveAuthMode: explicit input wins over existing', () => {
|
||||
assert.equal(resolveAuthMode('oauth', 'token'), 'oauth')
|
||||
assert.equal(resolveAuthMode('token', 'oauth'), 'token')
|
||||
})
|
||||
|
||||
test('resolveAuthMode: falls back to existing when input absent', () => {
|
||||
assert.equal(resolveAuthMode(undefined, 'oauth'), 'oauth')
|
||||
assert.equal(resolveAuthMode(undefined, 'token'), 'token')
|
||||
assert.equal(resolveAuthMode('', 'oauth'), 'oauth')
|
||||
})
|
||||
|
||||
test('resolveAuthMode: defaults to token when nothing is set', () => {
|
||||
assert.equal(resolveAuthMode(undefined, undefined), 'token')
|
||||
assert.equal(resolveAuthMode(null, null), 'token')
|
||||
})
|
||||
|
||||
test('resolveAuthMode: ignores unknown values, defaults to token', () => {
|
||||
assert.equal(resolveAuthMode('bogus', 'also-bogus'), 'token')
|
||||
})
|
||||
|
||||
// --- cookiesHaveSession ---
|
||||
|
||||
test('cookiesHaveSession detects the bare access-token cookie', () => {
|
||||
assert.equal(cookiesHaveSession([{ name: 'hermes_session_at', value: 'x' }]), true)
|
||||
})
|
||||
|
||||
test('cookiesHaveSession detects the __Host- and __Secure- prefixed variants', () => {
|
||||
assert.equal(cookiesHaveSession([{ name: '__Host-hermes_session_at', value: 'x' }]), true)
|
||||
assert.equal(cookiesHaveSession([{ name: '__Secure-hermes_session_at', value: 'x' }]), true)
|
||||
})
|
||||
|
||||
test('cookiesHaveSession is false for an empty value', () => {
|
||||
assert.equal(cookiesHaveSession([{ name: 'hermes_session_at', value: '' }]), false)
|
||||
})
|
||||
|
||||
test('cookiesHaveSession ignores unrelated cookies', () => {
|
||||
assert.equal(cookiesHaveSession([{ name: 'hermes_session_rt', value: 'x' }]), false)
|
||||
assert.equal(cookiesHaveSession([{ name: 'other', value: 'x' }]), false)
|
||||
})
|
||||
|
||||
test('cookiesHaveSession handles non-arrays', () => {
|
||||
assert.equal(cookiesHaveSession(null), false)
|
||||
assert.equal(cookiesHaveSession(undefined), false)
|
||||
assert.equal(cookiesHaveSession([]), false)
|
||||
})
|
||||
|
||||
test('AT_COOKIE_VARIANTS covers all three deploy shapes', () => {
|
||||
assert.deepEqual(AT_COOKIE_VARIANTS, ['__Host-hermes_session_at', '__Secure-hermes_session_at', 'hermes_session_at'])
|
||||
})
|
||||
|
||||
// --- tokenPreview ---
|
||||
|
||||
test('tokenPreview returns null for empty', () => {
|
||||
assert.equal(tokenPreview(''), null)
|
||||
assert.equal(tokenPreview(null), null)
|
||||
})
|
||||
|
||||
test('tokenPreview returns set for short tokens', () => {
|
||||
assert.equal(tokenPreview('12345678'), 'set')
|
||||
})
|
||||
|
||||
test('tokenPreview returns a masked suffix for long tokens', () => {
|
||||
assert.equal(tokenPreview('abcdefghijklmnop'), '...klmnop')
|
||||
})
|
||||
|
||||
// --- resolveTestWsUrl ---
|
||||
//
|
||||
// The "Test remote" button must exercise the same WS transport the app uses,
|
||||
// and must FAIL (not skip) when an OAuth session can't mint a ws-ticket — that
|
||||
// is the exact false-positive PR #39098 set out to eliminate.
|
||||
|
||||
test('resolveTestWsUrl (token mode) builds a ?token= URL the WS probe can use', async () => {
|
||||
const url = await resolveTestWsUrl('https://gw.example.com', 'token', 'tok123')
|
||||
assert.equal(url, 'wss://gw.example.com/api/ws?token=tok123')
|
||||
})
|
||||
|
||||
test('resolveTestWsUrl (token mode, no token) returns null — genuine skip', async () => {
|
||||
assert.equal(await resolveTestWsUrl('https://gw.example.com', 'token', null), null)
|
||||
})
|
||||
|
||||
test('resolveTestWsUrl (oauth, mint ok) builds a ?ticket= URL', async () => {
|
||||
const url = await resolveTestWsUrl('https://gw.example.com', 'oauth', null, {
|
||||
mintTicket: async () => 'tkt-9'
|
||||
})
|
||||
assert.equal(url, 'wss://gw.example.com/api/ws?ticket=tkt-9')
|
||||
})
|
||||
|
||||
test('resolveTestWsUrl (oauth, mint FAILS) throws — must NOT skip WS validation', async () => {
|
||||
await assert.rejects(
|
||||
() =>
|
||||
resolveTestWsUrl('https://gw.example.com', 'oauth', null, {
|
||||
mintTicket: async () => {
|
||||
throw new Error('401 ticket mint failed')
|
||||
}
|
||||
}),
|
||||
err => {
|
||||
// Actionable, points the user at re-auth, and preserves the cause + flag
|
||||
// the boot overlay uses to offer a sign-in prompt.
|
||||
assert.match(err.message, /WebSocket ticket/i)
|
||||
assert.match(err.message, /sign in again/i)
|
||||
assert.equal(err.needsOauthLogin, true)
|
||||
assert.ok(err.cause instanceof Error)
|
||||
return true
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
test('resolveTestWsUrl (oauth) requires a mintTicket function', async () => {
|
||||
await assert.rejects(
|
||||
() => resolveTestWsUrl('https://gw.example.com', 'oauth', null),
|
||||
/mintTicket function is required/
|
||||
)
|
||||
})
|
||||
188
apps/desktop/electron/gateway-ws-probe.cjs
Normal file
188
apps/desktop/electron/gateway-ws-probe.cjs
Normal file
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* Live WebSocket validation for the remote-gateway "Test remote" button.
|
||||
*
|
||||
* Background: the desktop boot does two independent things to a remote gateway:
|
||||
*
|
||||
* 1. The MAIN process hits ``GET /api/status`` over HTTP (token in a header)
|
||||
* to confirm the backend is up. This is what "Test remote" historically
|
||||
* checked, and what the boot logs print as "Remote Hermes backend is
|
||||
* ready".
|
||||
* 2. The RENDERER then opens a live WebSocket to ``/api/ws`` (credential in a
|
||||
* query param) via ``gateway.connect()``. The chat surface only works once
|
||||
* THIS succeeds.
|
||||
*
|
||||
* Those two paths use different processes, transports, and credentials, and the
|
||||
* server applies extra guards to the WS upgrade that the HTTP status route never
|
||||
* sees (Host/Origin checks, ws-ticket/token auth, peer-IP checks). So a gateway
|
||||
* can pass the HTTP status check yet reject the WebSocket — which surfaces to
|
||||
* the user as a green "Test remote" followed by an opaque "Could not connect to
|
||||
* Hermes gateway" on the boot overlay.
|
||||
*
|
||||
* This module performs the second half of the check: it actually opens the WS
|
||||
* URL and confirms the upgrade is accepted (and isn't immediately torn down by
|
||||
* a post-upgrade auth rejection). The ``WebSocketImpl`` is injectable so the
|
||||
* unit tests can drive the handshake without a real socket; in production the
|
||||
* caller passes the Node/Electron global ``WebSocket``.
|
||||
*/
|
||||
|
||||
const DEFAULT_CONNECT_TIMEOUT_MS = 10_000
|
||||
// After the upgrade is accepted, a gateway that rejects the credential
|
||||
// post-handshake closes the socket almost immediately. Wait a short grace
|
||||
// window: a frame (gateway.ready) or a still-open socket means success; an
|
||||
// early close means the upgrade was accepted but the session was refused.
|
||||
const DEFAULT_READY_GRACE_MS = 750
|
||||
|
||||
/**
|
||||
* Attempt a live WebSocket connection and classify the outcome.
|
||||
*
|
||||
* @param {string} wsUrl - Fully-formed ws(s):// URL including the credential.
|
||||
* @param {object} [options]
|
||||
* @param {new (url: string) => any} [options.WebSocketImpl] - WebSocket ctor.
|
||||
* @param {number} [options.connectTimeoutMs]
|
||||
* @param {number} [options.readyGraceMs]
|
||||
* @returns {Promise<{ ok: boolean, reason?: string }>}
|
||||
*/
|
||||
function probeGatewayWebSocket(wsUrl, options = {}) {
|
||||
const WebSocketImpl = options.WebSocketImpl
|
||||
const connectTimeoutMs = options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS
|
||||
const readyGraceMs = options.readyGraceMs ?? DEFAULT_READY_GRACE_MS
|
||||
|
||||
if (typeof WebSocketImpl !== 'function') {
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
reason: 'WebSocket is not available in this runtime.'
|
||||
})
|
||||
}
|
||||
|
||||
return new Promise(resolve => {
|
||||
let settled = false
|
||||
let opened = false
|
||||
let connectTimer = null
|
||||
let graceTimer = null
|
||||
let socket
|
||||
|
||||
const clearTimers = () => {
|
||||
if (connectTimer !== null) {
|
||||
clearTimeout(connectTimer)
|
||||
connectTimer = null
|
||||
}
|
||||
if (graceTimer !== null) {
|
||||
clearTimeout(graceTimer)
|
||||
graceTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
const finish = result => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
clearTimers()
|
||||
try {
|
||||
socket?.close?.()
|
||||
} catch {
|
||||
// ignore — best effort teardown
|
||||
}
|
||||
resolve(result)
|
||||
}
|
||||
|
||||
try {
|
||||
socket = new WebSocketImpl(wsUrl)
|
||||
} catch (error) {
|
||||
finish({
|
||||
ok: false,
|
||||
reason: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const onOpen = () => {
|
||||
if (settled) return
|
||||
opened = true
|
||||
// Upgrade accepted. Give the server a brief window to reject the
|
||||
// credential post-handshake (early close) before declaring success.
|
||||
graceTimer = setTimeout(() => {
|
||||
finish({ ok: true })
|
||||
}, readyGraceMs)
|
||||
}
|
||||
|
||||
const onMessage = () => {
|
||||
// Any frame means the gateway accepted us and is talking — unambiguous
|
||||
// success, no need to wait out the grace window.
|
||||
finish({ ok: true })
|
||||
}
|
||||
|
||||
const onError = event => {
|
||||
finish({
|
||||
ok: false,
|
||||
reason: extractErrorReason(event) || 'WebSocket connection failed.'
|
||||
})
|
||||
}
|
||||
|
||||
const onClose = event => {
|
||||
if (settled) return
|
||||
if (opened) {
|
||||
// Opened, then closed inside the grace window: the upgrade was accepted
|
||||
// but the session was refused (e.g. ws-ticket/token rejected, or a
|
||||
// server-side Host/Origin guard tripped after accept).
|
||||
finish({
|
||||
ok: false,
|
||||
reason: closeReason(event, 'The gateway accepted the connection then closed it (credential rejected?).')
|
||||
})
|
||||
return
|
||||
}
|
||||
finish({
|
||||
ok: false,
|
||||
reason: closeReason(event, 'The gateway closed the WebSocket before it opened.')
|
||||
})
|
||||
}
|
||||
|
||||
addListener(socket, 'open', onOpen)
|
||||
addListener(socket, 'message', onMessage)
|
||||
addListener(socket, 'error', onError)
|
||||
addListener(socket, 'close', onClose)
|
||||
|
||||
if (connectTimeoutMs > 0) {
|
||||
connectTimer = setTimeout(() => {
|
||||
finish({
|
||||
ok: false,
|
||||
reason: `Timed out after ${connectTimeoutMs}ms waiting for the WebSocket to open.`
|
||||
})
|
||||
}, connectTimeoutMs)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function addListener(socket, type, handler) {
|
||||
if (typeof socket.addEventListener === 'function') {
|
||||
socket.addEventListener(type, handler)
|
||||
return
|
||||
}
|
||||
// Node's global WebSocket implements addEventListener; this fallback keeps the
|
||||
// helper usable with the `ws` package's EventEmitter shape too.
|
||||
if (typeof socket.on === 'function') {
|
||||
socket.on(type, handler)
|
||||
}
|
||||
}
|
||||
|
||||
function extractErrorReason(event) {
|
||||
if (!event) return ''
|
||||
if (event instanceof Error) return event.message
|
||||
const err = event.error || event.message
|
||||
if (err instanceof Error) return err.message
|
||||
if (typeof err === 'string') return err
|
||||
return ''
|
||||
}
|
||||
|
||||
function closeReason(event, fallback) {
|
||||
const code = event && typeof event.code === 'number' ? event.code : null
|
||||
const reason = event && typeof event.reason === 'string' ? event.reason.trim() : ''
|
||||
if (code && reason) return `${fallback} (code ${code}: ${reason})`
|
||||
if (code) return `${fallback} (code ${code})`
|
||||
if (reason) return `${fallback} (${reason})`
|
||||
return fallback
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_CONNECT_TIMEOUT_MS,
|
||||
DEFAULT_READY_GRACE_MS,
|
||||
probeGatewayWebSocket
|
||||
}
|
||||
122
apps/desktop/electron/gateway-ws-probe.test.cjs
Normal file
122
apps/desktop/electron/gateway-ws-probe.test.cjs
Normal file
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Tests for electron/gateway-ws-probe.cjs.
|
||||
*
|
||||
* Run with: node --test electron/gateway-ws-probe.test.cjs
|
||||
* (Wired into npm test:desktop:platforms in package.json.)
|
||||
*
|
||||
* The probe drives a real WebSocket handshake for the "Test remote" button.
|
||||
* Here we inject a fake socket so we can deterministically replay each handshake
|
||||
* outcome (open, frame, error, early close, never-opens) without a network.
|
||||
*/
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const { probeGatewayWebSocket } = require('./gateway-ws-probe.cjs')
|
||||
|
||||
// Minimal WebSocket double: records listeners synchronously (the probe attaches
|
||||
// them in its executor) and exposes emit() so the test can replay events.
|
||||
function makeFakeWs() {
|
||||
const instances = []
|
||||
class FakeWs {
|
||||
constructor(url) {
|
||||
this.url = url
|
||||
this.listeners = {}
|
||||
this.closed = false
|
||||
instances.push(this)
|
||||
}
|
||||
addEventListener(type, fn) {
|
||||
;(this.listeners[type] ||= []).push(fn)
|
||||
}
|
||||
close() {
|
||||
this.closed = true
|
||||
}
|
||||
emit(type, event) {
|
||||
for (const fn of this.listeners[type] || []) fn(event)
|
||||
}
|
||||
}
|
||||
return { FakeWs, instances }
|
||||
}
|
||||
|
||||
const FAST = { connectTimeoutMs: 1_000, readyGraceMs: 10 }
|
||||
|
||||
test('probe resolves ok when the socket opens and stays open', async () => {
|
||||
const { FakeWs, instances } = makeFakeWs()
|
||||
const promise = probeGatewayWebSocket('ws://host/api/ws?token=t', { WebSocketImpl: FakeWs, ...FAST })
|
||||
instances[0].emit('open')
|
||||
const result = await promise
|
||||
assert.deepEqual(result, { ok: true })
|
||||
assert.equal(instances[0].closed, true)
|
||||
})
|
||||
|
||||
test('probe resolves ok immediately when a frame arrives', async () => {
|
||||
const { FakeWs, instances } = makeFakeWs()
|
||||
const promise = probeGatewayWebSocket('ws://host/api/ws?token=t', {
|
||||
WebSocketImpl: FakeWs,
|
||||
connectTimeoutMs: 1_000,
|
||||
readyGraceMs: 10_000 // long grace: success must come from the frame, not the timer
|
||||
})
|
||||
instances[0].emit('open')
|
||||
instances[0].emit('message', { data: '{"jsonrpc":"2.0"}' })
|
||||
const result = await promise
|
||||
assert.deepEqual(result, { ok: true })
|
||||
})
|
||||
|
||||
test('probe fails when the socket errors before opening', async () => {
|
||||
const { FakeWs, instances } = makeFakeWs()
|
||||
const promise = probeGatewayWebSocket('ws://host/api/ws?token=t', { WebSocketImpl: FakeWs, ...FAST })
|
||||
instances[0].emit('error', { message: 'ECONNREFUSED' })
|
||||
const result = await promise
|
||||
assert.equal(result.ok, false)
|
||||
assert.match(result.reason, /ECONNREFUSED/)
|
||||
})
|
||||
|
||||
test('probe fails when the gateway closes before opening', async () => {
|
||||
const { FakeWs, instances } = makeFakeWs()
|
||||
const promise = probeGatewayWebSocket('ws://host/api/ws?token=t', { WebSocketImpl: FakeWs, ...FAST })
|
||||
instances[0].emit('close', { code: 1006 })
|
||||
const result = await promise
|
||||
assert.equal(result.ok, false)
|
||||
assert.match(result.reason, /before it opened/)
|
||||
assert.match(result.reason, /1006/)
|
||||
})
|
||||
|
||||
test('probe fails when the gateway accepts then immediately closes (auth rejected)', async () => {
|
||||
const { FakeWs, instances } = makeFakeWs()
|
||||
const promise = probeGatewayWebSocket('ws://host/api/ws?token=t', { WebSocketImpl: FakeWs, ...FAST })
|
||||
instances[0].emit('open')
|
||||
instances[0].emit('close', { code: 4403, reason: 'forbidden' })
|
||||
const result = await promise
|
||||
assert.equal(result.ok, false)
|
||||
assert.match(result.reason, /credential rejected/)
|
||||
assert.match(result.reason, /4403/)
|
||||
assert.match(result.reason, /forbidden/)
|
||||
})
|
||||
|
||||
test('probe times out when the socket never opens', async () => {
|
||||
const { FakeWs } = makeFakeWs()
|
||||
const result = await probeGatewayWebSocket('ws://host/api/ws?token=t', {
|
||||
WebSocketImpl: FakeWs,
|
||||
connectTimeoutMs: 20,
|
||||
readyGraceMs: 10
|
||||
})
|
||||
assert.equal(result.ok, false)
|
||||
assert.match(result.reason, /Timed out/)
|
||||
})
|
||||
|
||||
test('probe fails gracefully when the constructor throws', async () => {
|
||||
class ThrowingWs {
|
||||
constructor() {
|
||||
throw new Error('bad url')
|
||||
}
|
||||
}
|
||||
const result = await probeGatewayWebSocket('ws://host/api/ws', { WebSocketImpl: ThrowingWs, ...FAST })
|
||||
assert.equal(result.ok, false)
|
||||
assert.match(result.reason, /bad url/)
|
||||
})
|
||||
|
||||
test('probe reports unavailable when no WebSocket implementation is provided', async () => {
|
||||
const result = await probeGatewayWebSocket('ws://host/api/ws', { WebSocketImpl: undefined })
|
||||
assert.equal(result.ok, false)
|
||||
assert.match(result.reason, /not available/)
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,21 @@
|
||||
const { contextBridge, ipcRenderer, webUtils } = require('electron')
|
||||
|
||||
contextBridge.exposeInMainWorld('hermesDesktop', {
|
||||
getConnection: () => ipcRenderer.invoke('hermes:connection'),
|
||||
getConnection: profile => ipcRenderer.invoke('hermes:connection', profile),
|
||||
touchBackend: profile => ipcRenderer.invoke('hermes:backend:touch', profile),
|
||||
getGatewayWsUrl: profile => ipcRenderer.invoke('hermes:gateway:ws-url', profile),
|
||||
getBootProgress: () => ipcRenderer.invoke('hermes:boot-progress:get'),
|
||||
getConnectionConfig: () => ipcRenderer.invoke('hermes:connection-config:get'),
|
||||
saveConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:save', payload),
|
||||
applyConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:apply', payload),
|
||||
testConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:test', payload),
|
||||
probeConnectionConfig: remoteUrl => ipcRenderer.invoke('hermes:connection-config:probe', remoteUrl),
|
||||
oauthLoginConnectionConfig: remoteUrl => ipcRenderer.invoke('hermes:connection-config:oauth-login', remoteUrl),
|
||||
oauthLogoutConnectionConfig: remoteUrl => ipcRenderer.invoke('hermes:connection-config:oauth-logout', remoteUrl),
|
||||
profile: {
|
||||
get: () => ipcRenderer.invoke('hermes:profile:get'),
|
||||
set: name => ipcRenderer.invoke('hermes:profile:set', name)
|
||||
},
|
||||
api: request => ipcRenderer.invoke('hermes:api', request),
|
||||
notify: payload => ipcRenderer.invoke('hermes:notify', payload),
|
||||
requestMicrophoneAccess: () => ipcRenderer.invoke('hermes:requestMicrophoneAccess'),
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
"test:desktop:nsis": "node scripts/test-desktop.mjs nsis",
|
||||
"test:desktop:existing": "node scripts/test-desktop.mjs existing",
|
||||
"test:desktop:fresh": "node scripts/test-desktop.mjs fresh",
|
||||
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs",
|
||||
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/gateway-ws-probe.test.cjs",
|
||||
"type-check": "tsc -b",
|
||||
"lint": "eslint src/ electron/",
|
||||
"lint:fix": "eslint src/ electron/ --fix",
|
||||
@@ -146,6 +146,7 @@
|
||||
"package.json"
|
||||
],
|
||||
"beforeBuild": "scripts/before-build.cjs",
|
||||
"beforePack": "scripts/before-pack.cjs",
|
||||
"afterPack": "scripts/after-pack.cjs",
|
||||
"extraResources": [
|
||||
{
|
||||
|
||||
78
apps/desktop/scripts/before-pack.cjs
Normal file
78
apps/desktop/scripts/before-pack.cjs
Normal file
@@ -0,0 +1,78 @@
|
||||
'use strict'
|
||||
|
||||
/**
|
||||
* before-pack.cjs — electron-builder beforePack hook.
|
||||
*
|
||||
* Removes any stale unpacked app directory (`appOutDir`) before
|
||||
* electron-builder stages the Electron binaries into it.
|
||||
*
|
||||
* WHY THIS EXISTS
|
||||
* ---------------
|
||||
* electron-builder's final packaging step copies the stock `electron`
|
||||
* binary into `release/<platform>-unpacked/` and then renames it to the
|
||||
* product name (`Hermes`). If a PREVIOUS `npm run pack` was interrupted
|
||||
* (Ctrl-C, OOM kill, crash, full disk) the unpacked directory is left in a
|
||||
* corrupted partial state: it keeps the already-renamed `LICENSE.electron.txt`
|
||||
* and the Chromium payload (.pak/.so/icudtl.dat/chrome-sandbox) but is MISSING
|
||||
* the `electron` binary itself.
|
||||
*
|
||||
* On the next run, electron-builder sees the destination directory already
|
||||
* populated, skips re-copying the binary it thinks is present, then tries to
|
||||
* rename a `electron` file that no longer exists. The build dies with:
|
||||
*
|
||||
* ENOENT: no such file or directory, rename
|
||||
* '.../release/linux-unpacked/electron' -> '.../release/linux-unpacked/Hermes'
|
||||
*
|
||||
* This is a hard failure with no obvious cause for the user — `hermes desktop`
|
||||
* just prints "Desktop GUI build failed" and the only fix is to manually
|
||||
* `rm -rf` the release directory, which a normal user has no way to know.
|
||||
*
|
||||
* The packaging step is not idempotent across an interrupted run, so we make
|
||||
* it idempotent ourselves: wipe the target unpacked directory up front so
|
||||
* electron-builder always stages into a clean tree. This is safe — the
|
||||
* directory is a pure build artifact that electron-builder fully recreates
|
||||
* on every pack; nothing else depends on its prior contents.
|
||||
*
|
||||
* Cross-platform: the same partial-state trap exists on macOS
|
||||
* (the mac-unpacked Hermes.app bundle) and Windows (win-unpacked), so we
|
||||
* clean whatever `appOutDir` electron-builder hands us regardless of platform.
|
||||
*
|
||||
* Best-effort: a cleanup failure must never mask the real build. We log and
|
||||
* resolve rather than throw — worst case electron-builder hits the original
|
||||
* ENOENT, which is no worse than not having this hook at all.
|
||||
*
|
||||
* electron-builder passes a context with:
|
||||
* - appOutDir: the unpacked app directory about to be staged
|
||||
* - electronPlatformName: 'win32' | 'darwin' | 'linux'
|
||||
*/
|
||||
|
||||
const fs = require('node:fs')
|
||||
|
||||
function cleanStaleAppOutDir(appOutDir) {
|
||||
if (!appOutDir || typeof appOutDir !== 'string') {
|
||||
return false
|
||||
}
|
||||
if (!fs.existsSync(appOutDir)) {
|
||||
return false
|
||||
}
|
||||
// Recursive + force so a half-written tree (read-only bits, partial files)
|
||||
// can't block the wipe. retry/maxRetries rides out transient EBUSY on
|
||||
// Windows where an AV/indexer may briefly hold a handle.
|
||||
fs.rmSync(appOutDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
|
||||
return true
|
||||
}
|
||||
|
||||
exports.cleanStaleAppOutDir = cleanStaleAppOutDir
|
||||
|
||||
exports.default = async function beforePack(context) {
|
||||
const appOutDir = context && context.appOutDir
|
||||
try {
|
||||
if (cleanStaleAppOutDir(appOutDir)) {
|
||||
console.log(`[before-pack] removed stale unpacked dir before staging: ${appOutDir}`)
|
||||
}
|
||||
} catch (err) {
|
||||
// Never fail the build over cleanup; surface why so a genuinely stuck
|
||||
// directory (permissions, mount) is still diagnosable.
|
||||
console.warn(`[before-pack] could not clean ${appOutDir} (${err.message}); continuing`)
|
||||
}
|
||||
}
|
||||
53
apps/desktop/scripts/before-pack.test.cjs
Normal file
53
apps/desktop/scripts/before-pack.test.cjs
Normal file
@@ -0,0 +1,53 @@
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
const test = require('node:test')
|
||||
|
||||
const { cleanStaleAppOutDir } = require('../scripts/before-pack.cjs')
|
||||
|
||||
test('cleanStaleAppOutDir removes a populated unpacked directory', () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-before-pack-'))
|
||||
try {
|
||||
const appOutDir = path.join(tempRoot, 'linux-unpacked')
|
||||
fs.mkdirSync(appOutDir, { recursive: true })
|
||||
// Reproduce the corrupted partial state: license + payload present,
|
||||
// electron binary missing — exactly what trips the ENOENT rename.
|
||||
fs.writeFileSync(path.join(appOutDir, 'LICENSE.electron.txt'), 'x', 'utf8')
|
||||
fs.writeFileSync(path.join(appOutDir, 'resources.pak'), 'x', 'utf8')
|
||||
fs.mkdirSync(path.join(appOutDir, 'resources'), { recursive: true })
|
||||
fs.writeFileSync(path.join(appOutDir, 'resources', 'app.asar'), 'x', 'utf8')
|
||||
|
||||
const removed = cleanStaleAppOutDir(appOutDir)
|
||||
|
||||
assert.equal(removed, true)
|
||||
assert.equal(fs.existsSync(appOutDir), false)
|
||||
} finally {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('cleanStaleAppOutDir is a no-op when the directory is absent', () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-before-pack-'))
|
||||
try {
|
||||
const missing = path.join(tempRoot, 'does-not-exist')
|
||||
assert.equal(cleanStaleAppOutDir(missing), false)
|
||||
} finally {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('cleanStaleAppOutDir ignores empty or invalid input', () => {
|
||||
assert.equal(cleanStaleAppOutDir(''), false)
|
||||
assert.equal(cleanStaleAppOutDir(undefined), false)
|
||||
assert.equal(cleanStaleAppOutDir(null), false)
|
||||
assert.equal(cleanStaleAppOutDir(42), false)
|
||||
})
|
||||
|
||||
test('beforePack default export resolves even when cleanup throws', async () => {
|
||||
const { default: beforePack } = require('../scripts/before-pack.cjs')
|
||||
// A directory path that rmSync can't remove is simulated by passing a
|
||||
// context whose appOutDir is a file the hook will try (and be allowed) to
|
||||
// remove; the contract under test is that the hook never rejects.
|
||||
await assert.doesNotReject(beforePack({ appOutDir: '', electronPlatformName: 'linux' }))
|
||||
})
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
PaginationPrevious
|
||||
} from '@/components/ui/pagination'
|
||||
import { TextTab, TextTabMeta } from '@/components/ui/text-tab'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { getSessionMessages, listSessions } from '@/hermes'
|
||||
import { sessionTitle } from '@/lib/chat-runtime'
|
||||
import { ExternalLink, ExternalLinkIcon, hostPathLabel, urlSlugTitleLabel, useLinkTitle } from '@/lib/external-link'
|
||||
@@ -736,7 +737,6 @@ function ArtifactCellAction({
|
||||
<button
|
||||
className="flex h-full w-full min-w-0 items-center gap-2 px-2.5 py-1.5 text-left text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) font-normal text-(--ui-text-secondary) no-underline underline-offset-4 decoration-current/20 transition-colors hover:text-foreground hover:underline"
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
type="button"
|
||||
>
|
||||
{children}
|
||||
@@ -774,15 +774,16 @@ function LocationCell({ artifact }: { artifact: ArtifactRecord; ctx: CellCtx })
|
||||
|
||||
return (
|
||||
<div className="group/location flex min-w-0 items-center gap-1.5">
|
||||
<div
|
||||
className={cn(
|
||||
'min-w-0 flex-1 truncate text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)',
|
||||
isLink ? 'font-normal' : 'font-mono'
|
||||
)}
|
||||
title={artifact.value}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
<Tip label={artifact.value}>
|
||||
<div
|
||||
className={cn(
|
||||
'min-w-0 flex-1 truncate text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)',
|
||||
isLink ? 'font-normal' : 'font-mono'
|
||||
)}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
</Tip>
|
||||
<CopyButton
|
||||
appearance="icon"
|
||||
buttonSize="icon-xs"
|
||||
|
||||
@@ -1,26 +1,43 @@
|
||||
import { useRef } from 'react'
|
||||
|
||||
import type { DragKind } from '@/app/chat/hooks/use-file-drop-zone'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const COPY: Record<'files' | 'session', { icon: string; label: string }> = {
|
||||
files: { icon: 'cloud-upload', label: 'Drop files to attach' },
|
||||
session: { icon: 'comment-discussion', label: 'Drop to link this chat' }
|
||||
}
|
||||
|
||||
/**
|
||||
* Full-bleed affordance shown while files are dragged over the chat area. Always
|
||||
* `pointer-events-none` so the drop lands on the real element underneath and the
|
||||
* drop-zone handler claims it — the overlay is purely visual. Mirrors the
|
||||
* composer surface so the two read as one family.
|
||||
* Full-bleed affordance shown while files or a session are dragged over the chat
|
||||
* area. Always `pointer-events-none` so the drop lands on the real element
|
||||
* underneath and the drop-zone handler claims it — the overlay is purely visual.
|
||||
* Copy adapts to whatever is being dragged; the last kind is held through the
|
||||
* fade-out so the label doesn't blank.
|
||||
*/
|
||||
export function ChatDropOverlay({ active }: { active: boolean }) {
|
||||
export function ChatDropOverlay({ kind }: { kind: DragKind }) {
|
||||
const lastKind = useRef<'files' | 'session'>('files')
|
||||
|
||||
if (kind) {
|
||||
lastKind.current = kind
|
||||
}
|
||||
|
||||
const { icon, label } = COPY[kind ?? lastKind.current]
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'pointer-events-none absolute inset-0 z-40 flex items-center justify-center p-4 transition-opacity duration-150 ease-out',
|
||||
active ? 'opacity-100' : 'opacity-0'
|
||||
kind ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
data-slot="chat-drop-overlay"
|
||||
>
|
||||
<div className="absolute inset-2 rounded-2xl border-2 border-dashed border-[color-mix(in_srgb,var(--dt-composer-ring)_55%,transparent)] bg-[color-mix(in_srgb,var(--dt-card)_55%,transparent)] backdrop-blur-[2px] [-webkit-backdrop-filter:blur(2px)]" />
|
||||
<div className="relative flex items-center gap-2 rounded-full border border-[color-mix(in_srgb,var(--dt-composer-ring)_45%,transparent)] bg-[color-mix(in_srgb,var(--dt-card)_92%,transparent)] px-4 py-2 text-[0.8125rem] font-medium text-foreground shadow-composer">
|
||||
<Codicon className="text-(--ui-accent)" name="cloud-upload" size="1rem" />
|
||||
Drop files to attach
|
||||
<Codicon className="text-(--ui-accent)" name={icon} size="1rem" />
|
||||
{label}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
45
apps/desktop/src/app/chat/chat-swap-overlay.tsx
Normal file
45
apps/desktop/src/app/chat/chat-swap-overlay.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// Braille spinner frames — reads as a tiny ASCII loader in monospace.
|
||||
const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
|
||||
|
||||
// Shown over the conversation while the live gateway swaps to another profile's
|
||||
// backend (lazily spawned). Keeps the last profile name through the fade-out so
|
||||
// the label doesn't blank. Purely visual — pointer-events-none.
|
||||
export function ChatSwapOverlay({ profile }: { profile: string | null }) {
|
||||
const [frame, setFrame] = useState(0)
|
||||
const [label, setLabel] = useState<null | string>(profile)
|
||||
|
||||
useEffect(() => {
|
||||
if (profile) {
|
||||
setLabel(profile)
|
||||
}
|
||||
}, [profile])
|
||||
|
||||
useEffect(() => {
|
||||
if (!profile) {
|
||||
return
|
||||
}
|
||||
|
||||
const id = window.setInterval(() => setFrame(value => (value + 1) % FRAMES.length), 80)
|
||||
|
||||
return () => window.clearInterval(id)
|
||||
}, [profile])
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'pointer-events-none absolute inset-0 z-50 flex items-center justify-center transition-opacity duration-150 ease-out',
|
||||
profile ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 bg-[color-mix(in_srgb,var(--dt-card)_92%,transparent)] px-4 py-2 font-mono text-[0.8125rem] text-foreground shadow-composer">
|
||||
<span className="w-3 text-(--ui-accent)">{FRAMES[frame]}</span>
|
||||
Waking up {label}…
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { FileText, FolderOpen, ImageIcon, Link, Terminal } from '@/lib/icons'
|
||||
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
|
||||
import type { ComposerAttachment } from '@/store/composer'
|
||||
@@ -62,49 +63,49 @@ function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachme
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group/attachment relative min-w-0 shrink-0"
|
||||
title={attachment.path || attachment.detail || attachment.label}
|
||||
>
|
||||
<button
|
||||
aria-label={canPreview ? `Preview ${attachment.label}` : attachment.label}
|
||||
className="flex max-w-56 items-center gap-2 border border-border/60 bg-background/50 px-2 py-1.5 text-left shadow-[inset_0_1px_0_rgba(255,255,255,0.25)] transition-colors hover:border-primary/35 hover:bg-accent/45 disabled:cursor-default"
|
||||
disabled={!canPreview}
|
||||
onClick={() => void openPreview()}
|
||||
title={canPreview ? `Preview ${attachment.label}` : attachment.label}
|
||||
type="button"
|
||||
>
|
||||
{attachment.previewUrl && attachment.kind === 'image' ? (
|
||||
<img
|
||||
alt={attachment.label}
|
||||
className="size-8 shrink-0 border border-border/70 object-cover"
|
||||
draggable={false}
|
||||
src={attachment.previewUrl}
|
||||
/>
|
||||
) : (
|
||||
<span className="grid size-8 shrink-0 place-items-center border border-border/55 bg-muted/35 text-muted-foreground">
|
||||
<Icon className="size-3.5" />
|
||||
</span>
|
||||
)}
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-[0.72rem] font-medium leading-4 text-foreground/90">
|
||||
{attachment.label}
|
||||
</span>
|
||||
{detail && (
|
||||
<span className="block truncate font-mono text-[0.6rem] leading-3 text-muted-foreground/65">{detail}</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
{onRemove && (
|
||||
<Tip label={attachment.path || attachment.detail || attachment.label}>
|
||||
<div className="group/attachment relative min-w-0 shrink-0">
|
||||
<button
|
||||
aria-label={`Remove ${attachment.label}`}
|
||||
className="absolute -right-1 -top-1 grid size-3.5 place-items-center rounded-full border border-border/70 bg-background text-muted-foreground opacity-0 shadow-xs transition hover:bg-accent hover:text-foreground group-hover/attachment:opacity-100 focus-visible:opacity-100"
|
||||
onClick={() => onRemove(attachment.id)}
|
||||
aria-label={canPreview ? `Preview ${attachment.label}` : attachment.label}
|
||||
className="flex max-w-56 items-center gap-2 border border-border/60 bg-background/50 px-2 py-1.5 text-left shadow-[inset_0_1px_0_rgba(255,255,255,0.25)] transition-colors hover:border-primary/35 hover:bg-accent/45 disabled:cursor-default"
|
||||
disabled={!canPreview}
|
||||
onClick={() => void openPreview()}
|
||||
type="button"
|
||||
>
|
||||
<Codicon name="close" size="0.625rem" />
|
||||
{attachment.previewUrl && attachment.kind === 'image' ? (
|
||||
<img
|
||||
alt={attachment.label}
|
||||
className="size-8 shrink-0 border border-border/70 object-cover"
|
||||
draggable={false}
|
||||
src={attachment.previewUrl}
|
||||
/>
|
||||
) : (
|
||||
<span className="grid size-8 shrink-0 place-items-center border border-border/55 bg-muted/35 text-muted-foreground">
|
||||
<Icon className="size-3.5" />
|
||||
</span>
|
||||
)}
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-[0.72rem] font-medium leading-4 text-foreground/90">
|
||||
{attachment.label}
|
||||
</span>
|
||||
{detail && (
|
||||
<span className="block truncate font-mono text-[0.6rem] leading-3 text-muted-foreground/65">
|
||||
{detail}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{onRemove && (
|
||||
<button
|
||||
aria-label={`Remove ${attachment.label}`}
|
||||
className="absolute -right-1 -top-1 grid size-3.5 place-items-center rounded-full border border-border/70 bg-background text-muted-foreground opacity-0 shadow-xs transition hover:bg-accent hover:text-foreground group-hover/attachment:opacity-100 focus-visible:opacity-100"
|
||||
onClick={() => onRemove(attachment.id)}
|
||||
type="button"
|
||||
>
|
||||
<Codicon name="close" size="0.625rem" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</Tip>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,13 +2,7 @@ import { useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -104,8 +98,8 @@ export function ContextMenu({
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<div className="px-2 py-1 text-[0.7rem] text-muted-foreground/80">
|
||||
Tip: type <kbd className="rounded bg-muted/70 px-1 py-px font-mono text-[0.65rem]">@</kbd> to reference files
|
||||
inline.
|
||||
Tip: type <kbd className="rounded bg-muted/70 px-1 py-px font-mono text-[0.65rem]">@</kbd> to reference
|
||||
files inline.
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
@@ -120,12 +114,7 @@ export function ContextMenu({
|
||||
)
|
||||
}
|
||||
|
||||
function PromptSnippetsDialog({
|
||||
onInsertText,
|
||||
onOpenChange,
|
||||
open,
|
||||
snippets
|
||||
}: PromptSnippetsDialogProps) {
|
||||
function PromptSnippetsDialog({ onInsertText, onOpenChange, open, snippets }: PromptSnippetsDialogProps) {
|
||||
return (
|
||||
<Dialog onOpenChange={onOpenChange} open={open}>
|
||||
<DialogContent className="max-w-md gap-3">
|
||||
@@ -160,12 +149,7 @@ function PromptSnippetsDialog({
|
||||
)
|
||||
}
|
||||
|
||||
export function ContextMenuItem({
|
||||
children,
|
||||
disabled,
|
||||
icon: Icon,
|
||||
onSelect
|
||||
}: ContextMenuItemProps) {
|
||||
export function ContextMenuItem({ children, disabled, icon: Icon, onSelect }: ContextMenuItemProps) {
|
||||
return (
|
||||
<DropdownMenuItem disabled={disabled} onSelect={onSelect}>
|
||||
<Icon />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { AudioLines, Layers3, Loader2, Square } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -64,38 +65,40 @@ export function ComposerControls({
|
||||
<div className="ml-auto flex shrink-0 items-center gap-(--composer-control-gap)">
|
||||
<DictationButton disabled={disabled} onToggle={onDictate} state={state.voice} status={voiceStatus} />
|
||||
{showVoicePrimary ? (
|
||||
<Button
|
||||
aria-label="Start voice conversation"
|
||||
className={PRIMARY_ICON_BTN}
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
triggerHaptic('open')
|
||||
conversation.onStart()
|
||||
}}
|
||||
size="icon"
|
||||
title="Start voice conversation"
|
||||
type="button"
|
||||
>
|
||||
<AudioLines size={17} />
|
||||
</Button>
|
||||
<Tip label="Start voice conversation">
|
||||
<Button
|
||||
aria-label="Start voice conversation"
|
||||
className={PRIMARY_ICON_BTN}
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
triggerHaptic('open')
|
||||
conversation.onStart()
|
||||
}}
|
||||
size="icon"
|
||||
type="button"
|
||||
>
|
||||
<AudioLines size={17} />
|
||||
</Button>
|
||||
</Tip>
|
||||
) : (
|
||||
<Button
|
||||
aria-label={busy ? (busyAction === 'queue' ? 'Queue message' : 'Stop') : 'Send'}
|
||||
className={PRIMARY_ICON_BTN}
|
||||
disabled={disabled || !canSubmit}
|
||||
title={busy ? (busyAction === 'queue' ? 'Queue message' : 'Stop') : 'Send'}
|
||||
type="submit"
|
||||
>
|
||||
{busy ? (
|
||||
busyAction === 'queue' ? (
|
||||
<Layers3 size={16} />
|
||||
<Tip label={busy ? (busyAction === 'queue' ? 'Queue message' : 'Stop') : 'Send'}>
|
||||
<Button
|
||||
aria-label={busy ? (busyAction === 'queue' ? 'Queue message' : 'Stop') : 'Send'}
|
||||
className={PRIMARY_ICON_BTN}
|
||||
disabled={disabled || !canSubmit}
|
||||
type="submit"
|
||||
>
|
||||
{busy ? (
|
||||
busyAction === 'queue' ? (
|
||||
<Layers3 size={16} />
|
||||
) : (
|
||||
<span className="block size-3 rounded-[0.1875rem] bg-current" />
|
||||
)
|
||||
) : (
|
||||
<span className="block size-3 rounded-[0.1875rem] bg-current" />
|
||||
)
|
||||
) : (
|
||||
<Codicon name="arrow-up" size="1rem" />
|
||||
)}
|
||||
</Button>
|
||||
<Codicon name="arrow-up" size="1rem" />
|
||||
)}
|
||||
</Button>
|
||||
</Tip>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
@@ -126,22 +129,23 @@ function ConversationPill({
|
||||
|
||||
return (
|
||||
<div className="ml-auto flex shrink-0 items-center gap-(--composer-control-gap)">
|
||||
<Button
|
||||
aria-label={muted ? 'Unmute microphone' : 'Mute microphone'}
|
||||
aria-pressed={muted}
|
||||
className={cn(GHOST_ICON_BTN, 'p-0', muted && 'bg-muted text-muted-foreground')}
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
triggerHaptic('selection')
|
||||
onToggleMute()
|
||||
}}
|
||||
size="icon"
|
||||
title={muted ? 'Unmute microphone' : 'Mute microphone'}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name={muted ? 'mic-off' : 'mic'} size="1rem" />
|
||||
</Button>
|
||||
<Tip label={muted ? 'Unmute microphone' : 'Mute microphone'}>
|
||||
<Button
|
||||
aria-label={muted ? 'Unmute microphone' : 'Mute microphone'}
|
||||
aria-pressed={muted}
|
||||
className={cn(GHOST_ICON_BTN, 'p-0', muted && 'bg-muted text-muted-foreground')}
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
triggerHaptic('selection')
|
||||
onToggleMute()
|
||||
}}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name={muted ? 'mic-off' : 'mic'} size="1rem" />
|
||||
</Button>
|
||||
</Tip>
|
||||
{listening && (
|
||||
<Button
|
||||
aria-label="Stop listening and send"
|
||||
@@ -151,7 +155,6 @@ function ConversationPill({
|
||||
triggerHaptic('submit')
|
||||
onStopTurn()
|
||||
}}
|
||||
title="Stop listening and send"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
@@ -167,7 +170,6 @@ function ConversationPill({
|
||||
triggerHaptic('close')
|
||||
onEnd()
|
||||
}}
|
||||
title="End voice conversation"
|
||||
type="button"
|
||||
>
|
||||
<ConversationIndicator level={level} listening={listening} speaking={speaking} />
|
||||
@@ -224,34 +226,35 @@ function DictationButton({
|
||||
status === 'recording' ? 'Stop dictation' : status === 'transcribing' ? 'Transcribing dictation' : 'Voice dictation'
|
||||
|
||||
return (
|
||||
<Button
|
||||
aria-label={aria}
|
||||
aria-pressed={active}
|
||||
className={cn(
|
||||
GHOST_ICON_BTN,
|
||||
'p-0',
|
||||
'data-[active=true]:bg-accent data-[active=true]:text-foreground',
|
||||
status === 'recording' && 'bg-primary/10 text-primary hover:bg-primary/15 hover:text-primary',
|
||||
status === 'transcribing' && 'bg-primary/10 text-primary'
|
||||
)}
|
||||
data-active={active}
|
||||
disabled={disabled || !state.enabled || status === 'transcribing'}
|
||||
onClick={() => {
|
||||
triggerHaptic(active ? 'close' : 'open')
|
||||
onToggle()
|
||||
}}
|
||||
size="icon"
|
||||
title={aria}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{status === 'recording' ? (
|
||||
<Square className="fill-current" size={12} />
|
||||
) : status === 'transcribing' ? (
|
||||
<Loader2 className="animate-spin" size={16} />
|
||||
) : (
|
||||
<Codicon name="mic" size="1rem" />
|
||||
)}
|
||||
</Button>
|
||||
<Tip label={aria}>
|
||||
<Button
|
||||
aria-label={aria}
|
||||
aria-pressed={active}
|
||||
className={cn(
|
||||
GHOST_ICON_BTN,
|
||||
'p-0',
|
||||
'data-[active=true]:bg-accent data-[active=true]:text-foreground',
|
||||
status === 'recording' && 'bg-primary/10 text-primary hover:bg-primary/15 hover:text-primary',
|
||||
status === 'transcribing' && 'bg-primary/10 text-primary'
|
||||
)}
|
||||
data-active={active}
|
||||
disabled={disabled || !state.enabled || status === 'transcribing'}
|
||||
onClick={() => {
|
||||
triggerHaptic(active ? 'close' : 'open')
|
||||
onToggle()
|
||||
}}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{status === 'recording' ? (
|
||||
<Square className="fill-current" size={12} />
|
||||
) : status === 'transcribing' ? (
|
||||
<Loader2 className="animate-spin" size={16} />
|
||||
) : (
|
||||
<Codicon name="mic" size="1rem" />
|
||||
)}
|
||||
</Button>
|
||||
</Tip>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
* steal focus from the composer effect.
|
||||
*/
|
||||
|
||||
import type { InlineRefInput } from './inline-refs'
|
||||
|
||||
export type ComposerTarget = 'edit' | 'main'
|
||||
export type ComposerInsertMode = 'block' | 'inline'
|
||||
|
||||
@@ -23,8 +25,14 @@ interface InsertDetail {
|
||||
text: string
|
||||
}
|
||||
|
||||
interface InsertRefsDetail {
|
||||
refs: InlineRefInput[]
|
||||
target: ComposerTarget
|
||||
}
|
||||
|
||||
const FOCUS_EVENT = 'hermes:composer-focus'
|
||||
const INSERT_EVENT = 'hermes:composer-insert'
|
||||
const INSERT_REFS_EVENT = 'hermes:composer-insert-refs'
|
||||
|
||||
let activeTarget: ComposerTarget = 'main'
|
||||
|
||||
@@ -82,6 +90,20 @@ export const onComposerFocusRequest = (handler: (target: ComposerTarget) => void
|
||||
export const onComposerInsertRequest = (handler: (detail: InsertDetail) => void) =>
|
||||
subscribe<InsertDetail>(INSERT_EVENT, handler)
|
||||
|
||||
/** Insert typed ref chips (carrying a display label) into a composer — the
|
||||
* structured cousin of {@link requestComposerInsert}, used for session links. */
|
||||
export const requestComposerInsertRefs = (
|
||||
refs: InlineRefInput[],
|
||||
{ target = 'active' }: { target?: ComposerTarget | 'active' } = {}
|
||||
) => {
|
||||
if (refs.length) {
|
||||
dispatch<InsertRefsDetail>(INSERT_REFS_EVENT, { refs, target: resolve(target) })
|
||||
}
|
||||
}
|
||||
|
||||
export const onComposerInsertRefsRequest = (handler: (detail: InsertRefsDetail) => void) =>
|
||||
subscribe<InsertRefsDetail>(INSERT_REFS_EVENT, handler)
|
||||
|
||||
/**
|
||||
* Focus a composer input across React commit + browser focus restore.
|
||||
*
|
||||
|
||||
@@ -16,6 +16,7 @@ interface SlashItemMetadata extends Record<string, string> {
|
||||
command: string
|
||||
display: string
|
||||
meta: string
|
||||
rawText: string
|
||||
}
|
||||
|
||||
function textValue(value: unknown, fallback = ''): string {
|
||||
@@ -91,7 +92,13 @@ export function useSlashCompletions(options: { gateway: HermesGateway | null }):
|
||||
const metadata: SlashItemMetadata = {
|
||||
command,
|
||||
display,
|
||||
meta
|
||||
meta,
|
||||
// Provide rawText so hermesDirectiveFormatter.serialize uses the
|
||||
// direct-insertion path instead of the legacy @type:id fallback.
|
||||
// Without this, the item.id (which includes a "|index" suffix for
|
||||
// trigger-adapter uniqueness) leaks into the serialized chip text
|
||||
// and the submitted command.
|
||||
rawText: command
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -18,14 +18,11 @@ import { Button } from '@/components/ui/button'
|
||||
import { useMediaQuery } from '@/hooks/use-media-query'
|
||||
import { useResizeObserver } from '@/hooks/use-resize-observer'
|
||||
import { chatMessageText } from '@/lib/chat-messages'
|
||||
import { SLASH_COMMAND_RE } from '@/lib/chat-runtime'
|
||||
import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
$composerAttachments,
|
||||
clearComposerAttachments,
|
||||
type ComposerAttachment
|
||||
} from '@/store/composer'
|
||||
import { $composerAttachments, clearComposerAttachments, type ComposerAttachment } from '@/store/composer'
|
||||
import {
|
||||
$queuedPromptsBySession,
|
||||
enqueueQueuedPrompt,
|
||||
@@ -48,6 +45,7 @@ import {
|
||||
focusComposerInput,
|
||||
markActiveComposer,
|
||||
onComposerFocusRequest,
|
||||
onComposerInsertRefsRequest,
|
||||
onComposerInsertRequest
|
||||
} from './focus'
|
||||
import { HelpHint } from './help-hint'
|
||||
@@ -55,7 +53,12 @@ import { useAtCompletions } from './hooks/use-at-completions'
|
||||
import { useSlashCompletions } from './hooks/use-slash-completions'
|
||||
import { useVoiceConversation } from './hooks/use-voice-conversation'
|
||||
import { useVoiceRecorder } from './hooks/use-voice-recorder'
|
||||
import { dragHasAttachments, droppedFileInlineRef, insertInlineRefsIntoEditor } from './inline-refs'
|
||||
import {
|
||||
dragHasAttachments,
|
||||
droppedFileInlineRef,
|
||||
type InlineRefInput,
|
||||
insertInlineRefsIntoEditor
|
||||
} from './inline-refs'
|
||||
import { QueuePanel } from './queue-panel'
|
||||
import {
|
||||
composerPlainText,
|
||||
@@ -172,7 +175,7 @@ export function ChatBar({
|
||||
const [queueEdit, setQueueEdit] = useState<QueueEditState | null>(null)
|
||||
const [focusRequestId, setFocusRequestId] = useState(0)
|
||||
const dragDepthRef = useRef(0)
|
||||
const composingRef = useRef(false) // true during IME composition (CJK input)
|
||||
const composingRef = useRef(false) // true during IME composition (CJK input)
|
||||
const lastSpokenIdRef = useRef<string | null>(null)
|
||||
|
||||
const narrow = useMediaQuery('(max-width: 30rem)')
|
||||
@@ -435,7 +438,7 @@ export function ChatBar({
|
||||
requestMainFocus()
|
||||
}
|
||||
|
||||
const insertInlineRefs = (refs: string[]) => {
|
||||
const insertInlineRefs = (refs: InlineRefInput[]) => {
|
||||
const editor = editorRef.current
|
||||
|
||||
if (!editor) {
|
||||
@@ -455,6 +458,19 @@ export function ChatBar({
|
||||
return true
|
||||
}
|
||||
|
||||
// Latest-closure ref so the (once-only) subscription always calls the current
|
||||
// insertInlineRefs without re-subscribing every render.
|
||||
const insertInlineRefsRef = useRef(insertInlineRefs)
|
||||
insertInlineRefsRef.current = insertInlineRefs
|
||||
|
||||
useEffect(() => {
|
||||
return onComposerInsertRefsRequest(({ refs, target }) => {
|
||||
if (target === 'main') {
|
||||
insertInlineRefsRef.current(refs)
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
const selectSkinSlashCommand = (command: string) => {
|
||||
draftRef.current = command
|
||||
aui.composer().setText(command)
|
||||
@@ -1041,7 +1057,19 @@ export function ChatBar({
|
||||
if (queueEdit) {
|
||||
exitQueuedEdit('save')
|
||||
} else if (busy) {
|
||||
if (hasComposerPayload) {
|
||||
// Slash commands should execute immediately even while the agent is
|
||||
// busy — they're client-side operations (/yolo, /skin, /new, /help,
|
||||
// etc.) or self-contained gateway RPCs (/status, /compress). onSubmit
|
||||
// routes them to executeSlashCommand, which has its own per-command
|
||||
// busy guard for commands that genuinely need an idle session (skill
|
||||
// /send directives). Queuing them would make every slash command wait
|
||||
// for the current turn to finish, which is how the TUI never behaves.
|
||||
if (!attachments.length && SLASH_COMMAND_RE.test(draft.trim())) {
|
||||
const submitted = draft
|
||||
triggerHaptic('submit')
|
||||
clearDraft()
|
||||
void onSubmit(submitted)
|
||||
} else if (hasComposerPayload) {
|
||||
queueCurrentDraft()
|
||||
} else {
|
||||
// Stop button: an explicit interrupt must actually halt the running
|
||||
@@ -1253,9 +1281,11 @@ export function ChatBar({
|
||||
onDrop={handleDrop}
|
||||
onSubmit={e => {
|
||||
e.preventDefault()
|
||||
|
||||
if (composingRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
submitDraft()
|
||||
}}
|
||||
ref={composerRef}
|
||||
|
||||
@@ -5,6 +5,49 @@ import type { DroppedFile } from '../hooks/use-composer-actions'
|
||||
|
||||
import { composerPlainText, escapeHtml, placeCaretEnd, refChipHtml } from './rich-editor'
|
||||
|
||||
/** A chip to insert: a raw `@kind:value` string, or a typed value + display label. */
|
||||
export type InlineRefInput = string | { kind: string; label?: string; value: string }
|
||||
|
||||
/** MIME for an in-app session drag (sidebar row → composer). */
|
||||
export const HERMES_SESSION_MIME = 'application/x-hermes-session'
|
||||
|
||||
export interface SessionDragPayload {
|
||||
id: string
|
||||
profile: string
|
||||
title: string
|
||||
}
|
||||
|
||||
export function writeSessionDrag(transfer: DataTransfer, payload: SessionDragPayload) {
|
||||
transfer.setData(HERMES_SESSION_MIME, JSON.stringify(payload))
|
||||
transfer.effectAllowed = 'copy'
|
||||
}
|
||||
|
||||
export function dragHasSession(transfer: DataTransfer | null) {
|
||||
return Boolean(transfer) && Array.from(transfer!.types || []).includes(HERMES_SESSION_MIME)
|
||||
}
|
||||
|
||||
export function readSessionDrag(transfer: DataTransfer | null): null | SessionDragPayload {
|
||||
const raw = transfer?.getData(HERMES_SESSION_MIME)
|
||||
|
||||
if (!raw) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Partial<SessionDragPayload>
|
||||
|
||||
return parsed.id ? { id: parsed.id, profile: parsed.profile || 'default', title: parsed.title || '' } : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Build a `@session:<profile>/<id>` chip. Value carries the metadata the agent
|
||||
* needs to resolve the link (session_search); label shows the friendly title. */
|
||||
export function sessionInlineRef({ id, profile, title }: SessionDragPayload): InlineRefInput {
|
||||
return { kind: 'session', label: title || `chat ${id.slice(0, 8)}`, value: `${profile || 'default'}/${id}` }
|
||||
}
|
||||
|
||||
export function dragHasAttachments(transfer: DataTransfer | null, pathsMime: string) {
|
||||
if (!transfer) {
|
||||
return false
|
||||
@@ -40,13 +83,17 @@ export function droppedFileInlineRef(candidate: DroppedFile, cwd: string | null
|
||||
return `@${kind}:${formatRefValue(rel)}`
|
||||
}
|
||||
|
||||
export function insertInlineRefsIntoEditor(editor: HTMLDivElement, refs: readonly string[]) {
|
||||
export function insertInlineRefsIntoEditor(editor: HTMLDivElement, refs: readonly InlineRefInput[]) {
|
||||
if (!refs.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const refsHtml = refs
|
||||
.map(ref => {
|
||||
if (typeof ref !== 'string') {
|
||||
return refChipHtml(ref.kind, ref.value, ref.label)
|
||||
}
|
||||
|
||||
const match = ref.match(/^@([^:]+):(.+)$/)
|
||||
|
||||
return match ? refChipHtml(match[1], match[2]) : escapeHtml(ref)
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { ArrowUp, Pencil, Trash2 } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { QueuedPromptEntry } from '@/store/composer-queue'
|
||||
@@ -80,41 +81,44 @@ export function QueuePanel({ busy, editingId, entries, onDelete, onEdit, onSendN
|
||||
: 'opacity-0 group-hover/queue-row:opacity-100 group-focus-within/queue-row:opacity-100'
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
aria-label="Edit queued turn"
|
||||
className="h-5 w-5 rounded-md"
|
||||
disabled={Boolean(editingId) && !isEditing}
|
||||
onClick={() => onEdit(entry)}
|
||||
size="icon-xs"
|
||||
title="Edit queued turn"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Pencil size={11} />
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Send queued turn now"
|
||||
className="h-5 w-5 rounded-md"
|
||||
disabled={busy || isEditing}
|
||||
onClick={() => onSendNow(entry.id)}
|
||||
size="icon-xs"
|
||||
title="Send queued turn now"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<ArrowUp size={11} />
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Delete queued turn"
|
||||
className="h-5 w-5 rounded-md"
|
||||
onClick={() => onDelete(entry.id)}
|
||||
size="icon-xs"
|
||||
title="Delete queued turn"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Trash2 size={11} />
|
||||
</Button>
|
||||
<Tip label="Edit queued turn">
|
||||
<Button
|
||||
aria-label="Edit queued turn"
|
||||
className="h-5 w-5 rounded-md"
|
||||
disabled={Boolean(editingId) && !isEditing}
|
||||
onClick={() => onEdit(entry)}
|
||||
size="icon-xs"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Pencil size={11} />
|
||||
</Button>
|
||||
</Tip>
|
||||
<Tip label="Send queued turn now">
|
||||
<Button
|
||||
aria-label="Send queued turn now"
|
||||
className="h-5 w-5 rounded-md"
|
||||
disabled={busy || isEditing}
|
||||
onClick={() => onSendNow(entry.id)}
|
||||
size="icon-xs"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<ArrowUp size={11} />
|
||||
</Button>
|
||||
</Tip>
|
||||
<Tip label="Delete queued turn">
|
||||
<Button
|
||||
aria-label="Delete queued turn"
|
||||
className="h-5 w-5 rounded-md"
|
||||
onClick={() => onDelete(entry.id)}
|
||||
size="icon-xs"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Trash2 size={11} />
|
||||
</Button>
|
||||
</Tip>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
|
||||
export const RICH_INPUT_SLOT = 'composer-rich-input'
|
||||
|
||||
export const REF_RE = /@(file|folder|url|image|tool|line|terminal):(`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|\S+)/g
|
||||
export const REF_RE = /@(file|folder|url|image|tool|line|terminal|session):(`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|\S+)/g
|
||||
|
||||
const ESC: Record<string, string> = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }
|
||||
|
||||
@@ -52,14 +52,14 @@ export function quoteRefValue(value: string) {
|
||||
return formatRefValue(value)
|
||||
}
|
||||
|
||||
export function refChipHtml(kind: string, rawValue: string) {
|
||||
export function refChipHtml(kind: string, rawValue: string, displayLabel?: string) {
|
||||
const id = unquoteRef(rawValue)
|
||||
const text = `@${kind}:${quoteRefValue(id)}`
|
||||
|
||||
return `<span contenteditable="false" data-ref-text="${escapeHtml(text)}" data-ref-id="${escapeHtml(id)}" data-ref-kind="${escapeHtml(kind)}" class="${DIRECTIVE_CHIP_CLASS}">${directiveIconSvg(kind)}<span class="truncate">${escapeHtml(refLabel(id))}</span></span>`
|
||||
return `<span contenteditable="false" data-ref-text="${escapeHtml(text)}" data-ref-id="${escapeHtml(id)}" data-ref-kind="${escapeHtml(kind)}" class="${DIRECTIVE_CHIP_CLASS}">${directiveIconSvg(kind)}<span class="truncate">${escapeHtml(displayLabel || refLabel(id))}</span></span>`
|
||||
}
|
||||
|
||||
export function refChipElement(kind: string, rawValue: string) {
|
||||
export function refChipElement(kind: string, rawValue: string, displayLabel?: string) {
|
||||
const id = unquoteRef(rawValue)
|
||||
const text = `@${kind}:${quoteRefValue(id)}`
|
||||
const chip = document.createElement('span')
|
||||
@@ -71,7 +71,7 @@ export function refChipElement(kind: string, rawValue: string) {
|
||||
chip.dataset.refKind = kind
|
||||
chip.className = DIRECTIVE_CHIP_CLASS
|
||||
label.className = 'truncate'
|
||||
label.textContent = refLabel(id)
|
||||
label.textContent = displayLabel || refLabel(id)
|
||||
chip.append(directiveIconElement(kind), label)
|
||||
|
||||
return chip
|
||||
|
||||
@@ -37,7 +37,10 @@ function Harness({
|
||||
const refreshTrigger = useCallback(() => {
|
||||
const editor = editorRef.current
|
||||
|
||||
if (!editor) {return}
|
||||
if (!editor) {
|
||||
return
|
||||
}
|
||||
|
||||
const raw = editor.textContent ?? ''
|
||||
|
||||
if (!raw.includes('@') && !raw.includes('/')) {
|
||||
|
||||
@@ -1,50 +1,71 @@
|
||||
import { type DragEvent as ReactDragEvent, useCallback, useRef, useState } from 'react'
|
||||
|
||||
import { dragHasAttachments } from '@/app/chat/composer/inline-refs'
|
||||
import {
|
||||
dragHasAttachments,
|
||||
dragHasSession,
|
||||
readSessionDrag,
|
||||
type SessionDragPayload
|
||||
} from '@/app/chat/composer/inline-refs'
|
||||
|
||||
import { type DroppedFile, extractDroppedFiles, HERMES_PATHS_MIME } from './use-composer-actions'
|
||||
|
||||
const hasFiles = (event: ReactDragEvent) => dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME)
|
||||
export type DragKind = 'files' | 'session' | null
|
||||
|
||||
const dragKindOf = (event: ReactDragEvent): DragKind => {
|
||||
if (dragHasSession(event.dataTransfer)) {
|
||||
return 'session'
|
||||
}
|
||||
|
||||
if (dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME)) {
|
||||
return 'files'
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
interface FileDropZoneOptions {
|
||||
/** When false the zone ignores drags entirely. */
|
||||
enabled?: boolean
|
||||
onDropFiles: (files: DroppedFile[]) => void
|
||||
onDropSession?: (session: SessionDragPayload) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* "Drop files anywhere in this region" affordance. An enter/leave depth counter
|
||||
* keeps nested children from flickering the active state; `onDropCapture` clears
|
||||
* it even when a nested target (the composer) handles the drop and stops
|
||||
* propagation before our bubble-phase `onDrop` would fire.
|
||||
* "Drop anywhere in this region" affordance for files *and* in-app session
|
||||
* links. An enter/leave depth counter keeps nested children from flickering the
|
||||
* active state; `onDropCapture` clears it even when a nested target (the
|
||||
* composer) handles the drop and stops propagation before our bubble-phase
|
||||
* `onDrop` would fire.
|
||||
*
|
||||
* Spread `dropHandlers` onto the container; render an overlay off `dragActive`.
|
||||
* Spread `dropHandlers` onto the container; render an overlay off `dragKind`.
|
||||
*/
|
||||
export function useFileDropZone({ enabled = true, onDropFiles }: FileDropZoneOptions) {
|
||||
const [dragActive, setDragActive] = useState(false)
|
||||
export function useFileDropZone({ enabled = true, onDropFiles, onDropSession }: FileDropZoneOptions) {
|
||||
const [dragKind, setDragKind] = useState<DragKind>(null)
|
||||
const depth = useRef(0)
|
||||
|
||||
const reset = useCallback(() => {
|
||||
depth.current = 0
|
||||
setDragActive(false)
|
||||
setDragKind(null)
|
||||
}, [])
|
||||
|
||||
const onDragEnter = useCallback(
|
||||
(event: ReactDragEvent) => {
|
||||
if (!enabled || !hasFiles(event)) {
|
||||
const kind = enabled ? dragKindOf(event) : null
|
||||
|
||||
if (!kind) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
depth.current += 1
|
||||
setDragActive(true)
|
||||
setDragKind(kind)
|
||||
},
|
||||
[enabled]
|
||||
)
|
||||
|
||||
const onDragOver = useCallback(
|
||||
(event: ReactDragEvent) => {
|
||||
if (!enabled || !hasFiles(event)) {
|
||||
if (!enabled || !dragKindOf(event)) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -62,21 +83,36 @@ export function useFileDropZone({ enabled = true, onDropFiles }: FileDropZoneOpt
|
||||
|
||||
const onDrop = useCallback(
|
||||
(event: ReactDragEvent) => {
|
||||
if (!enabled || !hasFiles(event)) {
|
||||
const kind = enabled ? dragKindOf(event) : null
|
||||
|
||||
if (!kind) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
reset()
|
||||
|
||||
if (kind === 'session') {
|
||||
const session = readSessionDrag(event.dataTransfer)
|
||||
|
||||
if (session) {
|
||||
onDropSession?.(session)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const files = extractDroppedFiles(event.dataTransfer)
|
||||
|
||||
if (files.length) {
|
||||
onDropFiles(files)
|
||||
}
|
||||
},
|
||||
[enabled, onDropFiles, reset]
|
||||
[enabled, onDropFiles, onDropSession, reset]
|
||||
)
|
||||
|
||||
return { dragActive, dropHandlers: { onDragEnter, onDragLeave, onDragOver, onDrop, onDropCapture: reset } }
|
||||
return {
|
||||
dragKind,
|
||||
dropHandlers: { onDragEnter, onDragLeave, onDragOver, onDrop, onDropCapture: reset }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ import { useLocation } from 'react-router-dom'
|
||||
|
||||
import { Thread } from '@/components/assistant-ui/thread'
|
||||
import { Backdrop } from '@/components/Backdrop'
|
||||
import { NotificationStack } from '@/components/notifications'
|
||||
import { PromptOverlays } from '@/components/prompt-overlays'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
@@ -23,6 +22,7 @@ import { useIncrementalExternalStoreRuntime } from '@/lib/incremental-external-s
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { ComposerAttachment } from '@/store/composer'
|
||||
import { $pinnedSessionIds } from '@/store/layout'
|
||||
import { $gatewaySwapTarget } from '@/store/profile'
|
||||
import {
|
||||
$activeSessionId,
|
||||
$awaitingResponse,
|
||||
@@ -46,9 +46,10 @@ import { routeSessionId } from '../routes'
|
||||
import { titlebarHeaderBaseClass, titlebarHeaderShadowClass } from '../shell/titlebar'
|
||||
|
||||
import { ChatDropOverlay } from './chat-drop-overlay'
|
||||
import { ChatSwapOverlay } from './chat-swap-overlay'
|
||||
import { ChatBar, ChatBarFallback } from './composer'
|
||||
import { requestComposerInsert } from './composer/focus'
|
||||
import { droppedFileInlineRef } from './composer/inline-refs'
|
||||
import { requestComposerInsert, requestComposerInsertRefs } from './composer/focus'
|
||||
import { droppedFileInlineRef, type SessionDragPayload, sessionInlineRef } from './composer/inline-refs'
|
||||
import type { ChatBarState } from './composer/types'
|
||||
import type { DroppedFile } from './hooks/use-composer-actions'
|
||||
import { useFileDropZone } from './hooks/use-file-drop-zone'
|
||||
@@ -179,6 +180,7 @@ export function ChatView({
|
||||
const currentProvider = useStore($currentProvider)
|
||||
const freshDraftReady = useStore($freshDraftReady)
|
||||
const gatewayState = useStore($gatewayState)
|
||||
const gatewaySwapTarget = useStore($gatewaySwapTarget)
|
||||
const gatewayOpen = gatewayState === 'open'
|
||||
const introPersonality = useStore($introPersonality)
|
||||
const introSeed = useStore($introSeed)
|
||||
@@ -307,7 +309,13 @@ export function ChatView({
|
||||
[currentCwd]
|
||||
)
|
||||
|
||||
const { dragActive, dropHandlers } = useFileDropZone({ enabled: showChatBar, onDropFiles })
|
||||
// Dropping a sidebar session inserts an @session link the agent can resolve
|
||||
// via session_search (carries the source profile, so cross-profile works).
|
||||
const onDropSession = useCallback((session: SessionDragPayload) => {
|
||||
requestComposerInsertRefs([sessionInlineRef(session)], { target: 'main' })
|
||||
}, [])
|
||||
|
||||
const { dragKind, dropHandlers } = useFileDropZone({ enabled: showChatBar, onDropFiles, onDropSession })
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -325,7 +333,6 @@ export function ChatView({
|
||||
selectedSessionId={selectedSessionId}
|
||||
/>
|
||||
|
||||
<NotificationStack />
|
||||
<PromptOverlays />
|
||||
|
||||
<div
|
||||
@@ -372,7 +379,8 @@ export function ChatView({
|
||||
</Suspense>
|
||||
)}
|
||||
</AssistantRuntimeProvider>
|
||||
<ChatDropOverlay active={dragActive} />
|
||||
<ChatDropOverlay kind={dragKind} />
|
||||
<ChatSwapOverlay profile={gatewaySwapTarget} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Profiler, type ProfilerOnRenderCallback, type ReactNode } from 'react'
|
||||
|
||||
import { $messages, setMessages, setBusy } from '@/store/session'
|
||||
import { $messages, setBusy, setMessages } from '@/store/session'
|
||||
|
||||
type Sample = {
|
||||
id: string
|
||||
@@ -40,13 +40,16 @@ if (typeof window !== 'undefined' && !window.__PERF_PROBE__) {
|
||||
},
|
||||
summary: () => {
|
||||
const byId = new Map<string, number[]>()
|
||||
|
||||
for (const s of samples) {
|
||||
const k = `${s.id}:${s.phase}`
|
||||
const arr = byId.get(k) ?? []
|
||||
arr.push(s.actualDuration)
|
||||
byId.set(k, arr)
|
||||
}
|
||||
|
||||
const out: Record<string, { count: number; total: number; max: number; p50: number; p95: number }> = {}
|
||||
|
||||
for (const [k, arr] of byId) {
|
||||
arr.sort((a, b) => a - b)
|
||||
const total = arr.reduce((a, b) => a + b, 0)
|
||||
@@ -55,19 +58,27 @@ if (typeof window !== 'undefined' && !window.__PERF_PROBE__) {
|
||||
total: Math.round(total * 100) / 100,
|
||||
max: Math.round(arr[arr.length - 1] * 100) / 100,
|
||||
p50: Math.round(arr[Math.floor(arr.length * 0.5)] * 100) / 100,
|
||||
p95: Math.round(arr[Math.floor(arr.length * 0.95)] * 100) / 100,
|
||||
p95: Math.round(arr[Math.floor(arr.length * 0.95)] * 100) / 100
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const onRender: ProfilerOnRenderCallback = (id, phase, actualDuration, baseDuration, startTime, commitTime) => {
|
||||
const probe = typeof window !== 'undefined' ? window.__PERF_PROBE__ : undefined
|
||||
if (!probe || !probe.enabled) return
|
||||
|
||||
if (!probe || !probe.enabled) {
|
||||
return
|
||||
}
|
||||
|
||||
probe.samples.push({ id, phase, actualDuration, baseDuration, startTime, commitTime })
|
||||
if (probe.samples.length > 5000) probe.samples.splice(0, probe.samples.length - 5000)
|
||||
|
||||
if (probe.samples.length > 5000) {
|
||||
probe.samples.splice(0, probe.samples.length - 5000)
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined' && !window.__PERF_DRIVE__) {
|
||||
@@ -86,7 +97,11 @@ if (typeof window !== 'undefined' && !window.__PERF_DRIVE__) {
|
||||
snapshotMsgs: () => $messages.get().length,
|
||||
reset: () => {
|
||||
activeHandle?.stop()
|
||||
if (baseline) setMessages(baseline)
|
||||
|
||||
if (baseline) {
|
||||
setMessages(baseline)
|
||||
}
|
||||
|
||||
baseline = null
|
||||
setBusy(false)
|
||||
},
|
||||
@@ -104,7 +119,11 @@ if (typeof window !== 'undefined' && !window.__PERF_DRIVE__) {
|
||||
}: { chunk?: string; intervalMs?: number; totalTokens?: number; flushMinMs?: number } = {}) => {
|
||||
activeHandle?.stop()
|
||||
const current = $messages.get()
|
||||
if (!baseline) baseline = current
|
||||
|
||||
if (!baseline) {
|
||||
baseline = current
|
||||
}
|
||||
|
||||
const msgId = `synthetic-${Date.now()}`
|
||||
// Seed an empty assistant message — assistant-ui will see it grow.
|
||||
setMessages([
|
||||
@@ -126,13 +145,20 @@ if (typeof window !== 'undefined' && !window.__PERF_DRIVE__) {
|
||||
let flushHandle: number | null = null
|
||||
|
||||
const applyDelta = (delta: string) => {
|
||||
if (!delta) return
|
||||
if (!delta) {
|
||||
return
|
||||
}
|
||||
|
||||
setMessages(prev =>
|
||||
prev.map(m => {
|
||||
if (m.id !== msgId) return m
|
||||
if (m.id !== msgId) {
|
||||
return m
|
||||
}
|
||||
|
||||
const head = m.parts.slice(0, -1)
|
||||
const last = m.parts.at(-1)
|
||||
const lastText = last && last.type === 'text' ? last.text : ''
|
||||
|
||||
return {
|
||||
...m,
|
||||
parts: [...head, { type: 'text', text: lastText + delta }]
|
||||
@@ -150,8 +176,16 @@ if (typeof window !== 'undefined' && !window.__PERF_DRIVE__) {
|
||||
}
|
||||
|
||||
const scheduleFlush = () => {
|
||||
if (flushHandle !== null) return
|
||||
if (flushMinMs <= 0) { flushNow(); return }
|
||||
if (flushHandle !== null) {
|
||||
return
|
||||
}
|
||||
|
||||
if (flushMinMs <= 0) {
|
||||
flushNow()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const since = performance.now() - lastFlushAt
|
||||
const wait = Math.max(0, flushMinMs - since)
|
||||
flushHandle =
|
||||
@@ -162,48 +196,62 @@ if (typeof window !== 'undefined' && !window.__PERF_DRIVE__) {
|
||||
|
||||
const handle: SyntheticDriverHandle = {
|
||||
stop: () => {
|
||||
if (timer) clearTimeout(timer)
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
|
||||
timer = null
|
||||
|
||||
if (flushHandle !== null) {
|
||||
clearTimeout(flushHandle)
|
||||
cancelAnimationFrame?.(flushHandle)
|
||||
}
|
||||
|
||||
flushHandle = null
|
||||
|
||||
if (pendingDelta) {
|
||||
applyDelta(pendingDelta)
|
||||
pendingDelta = ''
|
||||
}
|
||||
|
||||
activeHandle = null
|
||||
// Mark message finalized.
|
||||
setMessages(prev =>
|
||||
prev.map(m =>
|
||||
m.id === msgId
|
||||
? { ...m, pending: false }
|
||||
: m
|
||||
)
|
||||
)
|
||||
setMessages(prev => prev.map(m => (m.id === msgId ? { ...m, pending: false } : m)))
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
activeHandle = handle
|
||||
|
||||
const tick = () => {
|
||||
if (activeHandle !== handle) return
|
||||
if (pushed >= totalTokens) {
|
||||
if (pendingDelta) flushNow()
|
||||
handle.stop()
|
||||
if (activeHandle !== handle) {
|
||||
return
|
||||
}
|
||||
|
||||
if (pushed >= totalTokens) {
|
||||
if (pendingDelta) {
|
||||
flushNow()
|
||||
}
|
||||
|
||||
handle.stop()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
pushed += 1
|
||||
|
||||
if (flushMinMs > 0) {
|
||||
pendingDelta += chunk
|
||||
scheduleFlush()
|
||||
} else {
|
||||
applyDelta(chunk)
|
||||
}
|
||||
|
||||
timer = setTimeout(tick, intervalMs)
|
||||
}
|
||||
|
||||
timer = setTimeout(tick, intervalMs)
|
||||
|
||||
return handle
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useMemo, useRef } from 'react'
|
||||
|
||||
import { requestComposerInsert } from '@/app/chat/composer/focus'
|
||||
import { CopyButton } from '@/components/ui/copy-button'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { PanelBottom, Send, Trash2 } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify } from '@/store/notifications'
|
||||
@@ -80,17 +81,18 @@ function ConsoleRow({ copyText, log, onSend, onToggleSelect, selected }: Console
|
||||
selected && 'border-border/60 bg-accent/40'
|
||||
)}
|
||||
>
|
||||
<button
|
||||
className={cn(
|
||||
'mt-0.5 text-left uppercase opacity-70 transition-colors hover:opacity-100',
|
||||
consoleLevelClass[log.level] ?? consoleLevelClass[0]
|
||||
)}
|
||||
onClick={onToggleSelect}
|
||||
title={selected ? 'Deselect entry' : 'Select entry'}
|
||||
type="button"
|
||||
>
|
||||
{consoleLevelLabel[log.level] || 'log'}
|
||||
</button>
|
||||
<Tip label={selected ? 'Deselect entry' : 'Select entry'}>
|
||||
<button
|
||||
className={cn(
|
||||
'mt-0.5 text-left uppercase opacity-70 transition-colors hover:opacity-100',
|
||||
consoleLevelClass[log.level] ?? consoleLevelClass[0]
|
||||
)}
|
||||
onClick={onToggleSelect}
|
||||
type="button"
|
||||
>
|
||||
{consoleLevelLabel[log.level] || 'log'}
|
||||
</button>
|
||||
</Tip>
|
||||
<div className="min-w-0" data-selectable-text="true">
|
||||
<span className={cn('block wrap-break-word', consoleLevelClass[log.level] ?? consoleLevelClass[0])}>
|
||||
{log.message}
|
||||
@@ -112,14 +114,15 @@ function ConsoleRow({ copyText, log, onSend, onToggleSelect, selected }: Console
|
||||
showLabel={false}
|
||||
text={copyText}
|
||||
/>
|
||||
<button
|
||||
className="rounded-md p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
onClick={onSend}
|
||||
title="Send this entry to chat"
|
||||
type="button"
|
||||
>
|
||||
<Send className="size-3" />
|
||||
</button>
|
||||
<Tip label="Send this entry to chat">
|
||||
<button
|
||||
className="rounded-md p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
onClick={onSend}
|
||||
type="button"
|
||||
>
|
||||
<Send className="size-3" />
|
||||
</button>
|
||||
</Tip>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
@@ -225,11 +228,6 @@ export function PreviewConsolePanel({
|
||||
className="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[0.625rem] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40"
|
||||
disabled={sendableLogs.length === 0}
|
||||
onClick={() => sendLogsToComposer(sendableLogs)}
|
||||
title={
|
||||
visibleSelection.length > 0
|
||||
? `Send ${visibleSelection.length} selected to chat`
|
||||
: 'Send all log entries to chat'
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<Send className="size-3" />
|
||||
@@ -250,7 +248,6 @@ export function PreviewConsolePanel({
|
||||
className="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[0.625rem] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40"
|
||||
disabled={logs.length === 0}
|
||||
onClick={consoleState.clear}
|
||||
title="Clear console"
|
||||
type="button"
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { PointerEvent as ReactPointerEvent } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import type { SetTitlebarToolGroup, TitlebarTool } from '@/app/shell/titlebar-controls'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { Bug } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
@@ -607,15 +608,16 @@ export function PreviewPane({
|
||||
{!embedded && (
|
||||
<div className="pointer-events-none flex min-h-(--titlebar-height) items-center gap-1.5 border-b border-border/60 bg-background px-2 py-1">
|
||||
<div className="min-w-0 flex-1">
|
||||
<a
|
||||
className="pointer-events-auto inline max-w-full truncate text-left text-xs font-medium text-foreground underline-offset-4 decoration-current/20 transition-colors hover:text-primary hover:underline"
|
||||
href={currentUrl}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
title={`Open ${currentUrl}`}
|
||||
>
|
||||
{previewLabel || 'Preview'}
|
||||
</a>
|
||||
<Tip label={`Open ${currentUrl}`}>
|
||||
<a
|
||||
className="pointer-events-auto inline max-w-full truncate text-left text-xs font-medium text-foreground underline-offset-4 decoration-current/20 transition-colors hover:text-primary hover:underline"
|
||||
href={currentUrl}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
{previewLabel || 'Preview'}
|
||||
</a>
|
||||
</Tip>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useEffect, useMemo } from 'react'
|
||||
|
||||
import type { SetTitlebarToolGroup } from '@/app/shell/titlebar-controls'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
$rightRailActiveTabId,
|
||||
@@ -101,27 +102,33 @@ export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatP
|
||||
// memory. `onMouseDown` swallows the middle-button press so
|
||||
// Chromium doesn't switch into autoscroll mode.
|
||||
onAuxClick={event => {
|
||||
if (event.button !== 1) return
|
||||
if (event.button !== 1) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
closeRightRailTab(tab.id)
|
||||
}}
|
||||
onMouseDown={event => {
|
||||
if (event.button === 1) event.preventDefault()
|
||||
if (event.button === 1) {
|
||||
event.preventDefault()
|
||||
}
|
||||
}}
|
||||
>
|
||||
{active && (
|
||||
<span aria-hidden="true" className="absolute inset-x-0 top-0 h-px bg-(--ui-stroke-primary)" />
|
||||
)}
|
||||
<button
|
||||
aria-selected={active}
|
||||
className="flex h-full min-w-0 max-w-full items-center overflow-hidden pl-3 pr-2 text-left outline-none"
|
||||
onClick={() => selectRightRailTab(tab.id)}
|
||||
role="tab"
|
||||
title={tab.label}
|
||||
type="button"
|
||||
>
|
||||
<span className="block min-w-0 truncate">{tab.label}</span>
|
||||
</button>
|
||||
<Tip label={tab.label}>
|
||||
<button
|
||||
aria-selected={active}
|
||||
className="flex h-full min-w-0 max-w-full items-center overflow-hidden pl-3 pr-2 text-left outline-none"
|
||||
onClick={() => selectRightRailTab(tab.id)}
|
||||
role="tab"
|
||||
type="button"
|
||||
>
|
||||
<span className="block min-w-0 truncate">{tab.label}</span>
|
||||
</button>
|
||||
</Tip>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-y-0 right-0 w-9 bg-[linear-gradient(to_right,transparent,var(--tab-bg)_55%)] opacity-0 transition-opacity group-hover/tab:opacity-100 group-focus-within/tab:opacity-100"
|
||||
@@ -130,7 +137,6 @@ export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatP
|
||||
aria-label={`Close ${tab.label}`}
|
||||
className="pointer-events-none absolute right-1.5 top-1/2 grid size-4 -translate-y-1/2 place-items-center rounded-sm text-(--ui-text-tertiary) opacity-0 transition-[background-color,color,opacity] hover:bg-(--ui-bg-secondary) hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-hover/tab:pointer-events-auto group-hover/tab:opacity-100 group-focus-within/tab:pointer-events-auto group-focus-within/tab:opacity-100"
|
||||
onClick={() => closeRightRailTab(tab.id)}
|
||||
title={`Close ${tab.label}`}
|
||||
type="button"
|
||||
>
|
||||
<Codicon name="close" size="0.75rem" />
|
||||
@@ -143,7 +149,6 @@ export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatP
|
||||
aria-label="Close preview pane"
|
||||
className="mr-1.5 grid size-6 shrink-0 self-center place-items-center rounded-md text-(--ui-text-tertiary) opacity-0 transition-opacity hover:bg-(--ui-control-hover-background) hover:text-foreground focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring group-hover/rail-tabs:opacity-100 [-webkit-app-region:no-drag]"
|
||||
onClick={closeRightRail}
|
||||
title="Close preview pane"
|
||||
type="button"
|
||||
>
|
||||
<Codicon name="close" size="0.75rem" />
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import type * as React from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
@@ -34,7 +34,10 @@ import {
|
||||
SidebarMenuItem
|
||||
} from '@/components/ui/sidebar'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { searchSessions, type SessionInfo, type SessionSearchResult } from '@/hermes'
|
||||
import { profileColor } from '@/lib/profile-color'
|
||||
import { sessionMatchesSearch } from '@/lib/session-search'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
$panesFlipped,
|
||||
@@ -51,8 +54,17 @@ import {
|
||||
SIDEBAR_SESSIONS_PAGE_SIZE,
|
||||
unpinSession
|
||||
} from '@/store/layout'
|
||||
import {
|
||||
$newChatProfile,
|
||||
$profiles,
|
||||
$profileScope,
|
||||
ALL_PROFILES,
|
||||
newSessionInProfile,
|
||||
normalizeProfileKey
|
||||
} from '@/store/profile'
|
||||
import {
|
||||
$selectedStoredSessionId,
|
||||
$sessionProfileTotals,
|
||||
$sessions,
|
||||
$sessionsLoading,
|
||||
$sessionsTotal,
|
||||
@@ -64,6 +76,7 @@ import { type AppView, ARTIFACTS_ROUTE, MESSAGING_ROUTE, SKILLS_ROUTE } from '..
|
||||
import { SidebarPanelLabel } from '../../shell/sidebar-label'
|
||||
import type { SidebarNavItem } from '../../types'
|
||||
|
||||
import { ProfileRail } from './profile-switcher'
|
||||
import { SidebarSessionRow } from './session-row'
|
||||
import { VirtualSessionList } from './virtual-session-list'
|
||||
|
||||
@@ -93,6 +106,9 @@ const SIDEBAR_NAV: SidebarNavItem[] = [
|
||||
]
|
||||
|
||||
const WORKSPACE_PAGE = 5
|
||||
// ALL-profiles view: show only the latest N per profile up front to keep the
|
||||
// unified list scannable, then reveal/fetch more in N-sized steps on demand.
|
||||
const PROFILE_INITIAL_PAGE = 5
|
||||
const WS_ID_PREFIX = 'workspace:'
|
||||
|
||||
const wsId = (id: string) => `${WS_ID_PREFIX}${id}`
|
||||
@@ -200,6 +216,7 @@ interface ChatSidebarProps extends React.ComponentProps<typeof Sidebar> {
|
||||
currentView: AppView
|
||||
onNavigate: (item: SidebarNavItem) => void
|
||||
onLoadMoreSessions: () => void
|
||||
onLoadMoreProfileSessions?: (profile: string) => Promise<void> | void
|
||||
onResumeSession: (sessionId: string) => void
|
||||
onDeleteSession: (sessionId: string) => void
|
||||
onArchiveSession: (sessionId: string) => void
|
||||
@@ -210,6 +227,7 @@ export function ChatSidebar({
|
||||
currentView,
|
||||
onNavigate,
|
||||
onLoadMoreSessions,
|
||||
onLoadMoreProfileSessions,
|
||||
onResumeSession,
|
||||
onDeleteSession,
|
||||
onArchiveSession,
|
||||
@@ -225,12 +243,23 @@ export function ChatSidebar({
|
||||
const sessions = useStore($sessions)
|
||||
const sessionsLoading = useStore($sessionsLoading)
|
||||
const sessionsTotal = useStore($sessionsTotal)
|
||||
const sessionProfileTotals = useStore($sessionProfileTotals)
|
||||
const workingSessionIds = useStore($workingSessionIds)
|
||||
const profiles = useStore($profiles)
|
||||
const profileScope = useStore($profileScope)
|
||||
// Only surface the profile switcher when more than one profile exists, so
|
||||
// single-profile users see the unchanged sidebar.
|
||||
const multiProfile = profiles.length > 1
|
||||
// Gate ALL-profiles grouping on multiProfile too: if a user drops back to one
|
||||
// profile while scope is still ALL (persisted), the rail is hidden and they'd
|
||||
// otherwise be stuck in the grouped view with no way out.
|
||||
const showAllProfiles = multiProfile && profileScope === ALL_PROFILES
|
||||
const [agentOrderIds, setAgentOrderIds] = useState<string[]>([])
|
||||
const [workspaceOrderIds, setWorkspaceOrderIds] = useState<string[]>([])
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [serverMatches, setServerMatches] = useState<SessionSearchResult[]>([])
|
||||
const [newSessionKbdFlash, setNewSessionKbdFlash] = useState(false)
|
||||
const [profileLoadMorePending, setProfileLoadMorePending] = useState<Record<string, boolean>>({})
|
||||
const trimmedQuery = searchQuery.trim()
|
||||
|
||||
// Flash the ⌘N hint full-opacity (no transition) for the press, so hitting
|
||||
@@ -259,7 +288,19 @@ export function ChatSidebar({
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
|
||||
)
|
||||
|
||||
const sortedSessions = useMemo(() => [...sessions].sort((a, b) => sessionTime(b) - sessionTime(a)), [sessions])
|
||||
// Profile scope = the "workspace switcher" context. Concrete scope shows only
|
||||
// that profile's sessions (clean rows, no per-row tags); ALL fans every
|
||||
// profile in, grouped by profile below. Single-profile users land here with
|
||||
// scope === their only profile, so nothing is filtered out.
|
||||
const visibleSessions = useMemo(
|
||||
() => (showAllProfiles ? sessions : sessions.filter(s => normalizeProfileKey(s.profile) === profileScope)),
|
||||
[sessions, showAllProfiles, profileScope]
|
||||
)
|
||||
|
||||
const sortedSessions = useMemo(
|
||||
() => [...visibleSessions].sort((a, b) => sessionTime(b) - sessionTime(a)),
|
||||
[visibleSessions]
|
||||
)
|
||||
|
||||
const workingSessionIdSet = useMemo(() => new Set(workingSessionIds), [workingSessionIds])
|
||||
|
||||
@@ -268,7 +309,7 @@ export function ChatSidebar({
|
||||
const sessionByAnyId = useMemo(() => {
|
||||
const map = new Map<string, SessionInfo>()
|
||||
|
||||
for (const s of sessions) {
|
||||
for (const s of visibleSessions) {
|
||||
map.set(s.id, s)
|
||||
|
||||
if (s._lineage_root_id && !map.has(s._lineage_root_id)) {
|
||||
@@ -277,7 +318,7 @@ export function ChatSidebar({
|
||||
}
|
||||
|
||||
return map
|
||||
}, [sessions])
|
||||
}, [visibleSessions])
|
||||
|
||||
const pinnedSessions = useMemo(() => {
|
||||
const seen = new Set<string>()
|
||||
@@ -330,11 +371,10 @@ export function ChatSidebar({
|
||||
return []
|
||||
}
|
||||
|
||||
const needle = trimmedQuery.toLowerCase()
|
||||
const out = new Map<string, SessionInfo>()
|
||||
|
||||
for (const s of sortedSessions) {
|
||||
if (`${s.title ?? ''} ${s.preview ?? ''} ${s.cwd ?? ''}`.toLowerCase().includes(needle)) {
|
||||
if (sessionMatchesSearch(s, trimmedQuery)) {
|
||||
out.set(s.id, s)
|
||||
}
|
||||
}
|
||||
@@ -366,11 +406,87 @@ export function ChatSidebar({
|
||||
[agentSessions, workspaceOrderIds]
|
||||
)
|
||||
|
||||
const loadMoreForProfileGroup = useCallback(
|
||||
(profile: string) => {
|
||||
if (!onLoadMoreProfileSessions) {
|
||||
return
|
||||
}
|
||||
|
||||
setProfileLoadMorePending(prev => ({ ...prev, [profile]: true }))
|
||||
|
||||
void Promise.resolve(onLoadMoreProfileSessions(profile))
|
||||
.catch(() => undefined)
|
||||
.finally(() =>
|
||||
setProfileLoadMorePending(({ [profile]: _done, ...rest }) => rest)
|
||||
)
|
||||
},
|
||||
[onLoadMoreProfileSessions]
|
||||
)
|
||||
|
||||
// ALL-profiles view: one collapsible group per profile, color on the header
|
||||
// (not on every row). Default profile floats to the top, the rest alpha.
|
||||
const profileGroups = useMemo<SidebarSessionGroup[] | undefined>(() => {
|
||||
if (!showAllProfiles) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const groups = new Map<string, SidebarSessionGroup>()
|
||||
|
||||
for (const session of agentSessions) {
|
||||
const key = normalizeProfileKey(session.profile)
|
||||
|
||||
const group = groups.get(key) ?? {
|
||||
color: profileColor(key),
|
||||
id: key,
|
||||
label: key,
|
||||
mode: 'profile',
|
||||
path: null,
|
||||
sessions: []
|
||||
}
|
||||
|
||||
group.sessions.push(session)
|
||||
|
||||
groups.set(key, group)
|
||||
}
|
||||
|
||||
return [...groups.values()]
|
||||
.map(group => ({
|
||||
...group,
|
||||
loadingMore: Boolean(profileLoadMorePending[group.id]),
|
||||
onLoadMore: onLoadMoreProfileSessions ? () => loadMoreForProfileGroup(group.id) : undefined,
|
||||
totalCount: Math.max(group.sessions.length, sessionProfileTotals[group.id] ?? 0)
|
||||
}))
|
||||
// default (root) first, then the rest alphabetically.
|
||||
.sort((a, b) => (a.id === 'default' ? -1 : b.id === 'default' ? 1 : a.label.localeCompare(b.label)))
|
||||
}, [
|
||||
showAllProfiles,
|
||||
agentSessions,
|
||||
loadMoreForProfileGroup,
|
||||
onLoadMoreProfileSessions,
|
||||
profileLoadMorePending,
|
||||
sessionProfileTotals
|
||||
])
|
||||
|
||||
const showSessionSkeletons = sessionsLoading && sortedSessions.length === 0
|
||||
const showSessionSections = showSessionSkeletons || sortedSessions.length > 0
|
||||
const knownSessionTotal = Math.max(sessionsTotal, sortedSessions.length)
|
||||
const hasMoreSessions = knownSessionTotal > sortedSessions.length
|
||||
const remainingSessionCount = Math.max(0, knownSessionTotal - sortedSessions.length)
|
||||
// Pagination is scope-aware. In "All profiles" mode it tracks the global
|
||||
// unified set. When scoped to one profile it must compare that profile's own
|
||||
// loaded rows against that profile's total — otherwise a huge default profile
|
||||
// keeps "Load more" stuck on while you browse a small one (the aggregator's
|
||||
// total sums every profile). Per-profile totals come from the aggregator
|
||||
// (children excluded); fall back to the global total / loaded count.
|
||||
const loadedSessionCount = showAllProfiles ? sessions.length : visibleSessions.length
|
||||
const scopedProfileTotal = showAllProfiles ? undefined : sessionProfileTotals[profileScope]
|
||||
|
||||
const knownSessionTotal = Math.max(
|
||||
showAllProfiles ? sessionsTotal : (scopedProfileTotal ?? loadedSessionCount),
|
||||
loadedSessionCount
|
||||
)
|
||||
|
||||
const hasMoreSessions = knownSessionTotal > loadedSessionCount
|
||||
const remainingSessionCount = Math.max(0, knownSessionTotal - loadedSessionCount)
|
||||
|
||||
const recentsMeta = countLabel(agentSessions.length, knownSessionTotal)
|
||||
|
||||
const handlePinnedDragEnd = ({ active, over }: DragEndEvent) => {
|
||||
if (!over || active.id === over.id) {
|
||||
@@ -449,6 +565,8 @@ export function ChatSidebar({
|
||||
(item.id === 'messaging' && currentView === 'messaging') ||
|
||||
(item.id === 'artifacts' && currentView === 'artifacts')
|
||||
|
||||
const isNewSession = item.id === 'new-session'
|
||||
|
||||
return (
|
||||
<SidebarMenuItem key={item.id}>
|
||||
<SidebarMenuButton
|
||||
@@ -460,7 +578,17 @@ export function ChatSidebar({
|
||||
!isInteractive &&
|
||||
'cursor-default hover:border-transparent hover:bg-transparent hover:text-inherit'
|
||||
)}
|
||||
onClick={() => onNavigate(item)}
|
||||
onClick={() => {
|
||||
// A plain new session lands in whatever profile the live
|
||||
// gateway is on (= the active switcher context). null →
|
||||
// no swap. The switcher header is the single place to
|
||||
// change which profile that is.
|
||||
if (isNewSession) {
|
||||
$newChatProfile.set(null)
|
||||
}
|
||||
|
||||
onNavigate(item)
|
||||
}}
|
||||
tooltip={item.label}
|
||||
type="button"
|
||||
>
|
||||
@@ -468,7 +596,7 @@ export function ChatSidebar({
|
||||
{sidebarOpen && (
|
||||
<>
|
||||
<span className="min-w-0 flex-1 truncate max-[46.25rem]:hidden">{item.label}</span>
|
||||
{item.id === 'new-session' && (
|
||||
{isNewSession && (
|
||||
<KbdGroup
|
||||
className={cn('ml-auto max-[46.25rem]:hidden', newSessionKbdFlash && 'opacity-100!')}
|
||||
keys={[...NEW_SESSION_KBD]}
|
||||
@@ -544,11 +672,19 @@ export function ChatSidebar({
|
||||
{sidebarOpen && showSessionSections && !trimmedQuery && (
|
||||
<SidebarSessionsSection
|
||||
activeSessionId={activeSidebarSessionId}
|
||||
contentClassName="flex min-h-0 flex-1 flex-col gap-px overflow-y-auto overscroll-contain pb-1.75"
|
||||
contentClassName={cn(
|
||||
'flex min-h-0 flex-1 flex-col overflow-y-auto overscroll-contain pb-1.75',
|
||||
// Separate profile sections clearly in the ALL view; rows inside
|
||||
// each group keep their own tight gap-px rhythm.
|
||||
showAllProfiles ? 'gap-3' : 'gap-px'
|
||||
)}
|
||||
dndSensors={dndSensors}
|
||||
emptyState={showSessionSkeletons ? <SidebarSessionSkeletons /> : <SidebarAllPinnedState />}
|
||||
footer={
|
||||
!agentsGrouped && !showSessionSkeletons && hasMoreSessions ? (
|
||||
// Hide "load more" only when workspace-grouped (those groups page
|
||||
// themselves). ALL-profiles now pages per-profile from each profile
|
||||
// header; the global footer only applies to non-ALL views.
|
||||
!showAllProfiles && !agentsGrouped && !showSessionSkeletons && hasMoreSessions ? (
|
||||
<SidebarLoadMoreRow
|
||||
loading={sessionsLoading}
|
||||
onClick={onLoadMoreSessions}
|
||||
@@ -557,37 +693,43 @@ export function ChatSidebar({
|
||||
) : null
|
||||
}
|
||||
forceEmptyState={showSessionSkeletons}
|
||||
groups={agentsGrouped ? agentGroups : undefined}
|
||||
groups={showAllProfiles ? profileGroups : agentsGrouped ? agentGroups : undefined}
|
||||
headerAction={
|
||||
// Grouping operates on unpinned recents; if everything is
|
||||
// pinned the toggle does nothing visible, so hide it to avoid
|
||||
// a phantom click target.
|
||||
agentSessions.length > 0 ? (
|
||||
<Button
|
||||
aria-label={agentsGrouped ? 'Show sessions as a single list' : 'Group sessions by workspace'}
|
||||
className={cn(
|
||||
'text-(--ui-text-tertiary) opacity-70 hover:bg-(--ui-control-hover-background) hover:text-foreground hover:opacity-100 focus-visible:opacity-100',
|
||||
agentsGrouped && 'bg-(--ui-control-active-background) text-foreground opacity-100'
|
||||
)}
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
setSidebarRecentsOpen(true)
|
||||
setSidebarAgentsGrouped(!agentsGrouped)
|
||||
}}
|
||||
size="icon-xs"
|
||||
title={agentsGrouped ? 'Ungroup sessions' : 'Group by workspace'}
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name={agentsGrouped ? 'list-unordered' : 'root-folder'} size="0.75rem" />
|
||||
</Button>
|
||||
) : null
|
||||
// Always reserve the icon-xs (size-6) slot so the header keeps the
|
||||
// same height whether or not the toggle renders — otherwise the
|
||||
// "Sessions" label jumps when switching to the ALL-profiles view.
|
||||
// Grouping operates on unpinned recents; if everything is pinned
|
||||
// the toggle does nothing, and it's irrelevant in the ALL-profiles
|
||||
// view (always grouped by profile), so hide the button (not the slot).
|
||||
<div className="grid size-6 shrink-0 place-items-center">
|
||||
{!showAllProfiles && agentSessions.length > 0 ? (
|
||||
<Tip label={agentsGrouped ? 'Ungroup sessions' : 'Group by workspace'}>
|
||||
<Button
|
||||
aria-label={agentsGrouped ? 'Show sessions as a single list' : 'Group sessions by workspace'}
|
||||
className={cn(
|
||||
'text-(--ui-text-tertiary) opacity-70 hover:bg-(--ui-control-hover-background) hover:text-foreground hover:opacity-100 focus-visible:opacity-100',
|
||||
agentsGrouped && 'bg-(--ui-control-active-background) text-foreground opacity-100'
|
||||
)}
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
setSidebarRecentsOpen(true)
|
||||
setSidebarAgentsGrouped(!agentsGrouped)
|
||||
}}
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name={agentsGrouped ? 'list-unordered' : 'root-folder'} size="0.75rem" />
|
||||
</Button>
|
||||
</Tip>
|
||||
) : null}
|
||||
</div>
|
||||
}
|
||||
label="Sessions"
|
||||
labelMeta={countLabel(agentSessions.length, knownSessionTotal)}
|
||||
labelMeta={recentsMeta}
|
||||
onArchiveSession={onArchiveSession}
|
||||
onDeleteSession={onDeleteSession}
|
||||
onNewSessionInWorkspace={onNewSessionInWorkspace}
|
||||
onReorder={handleAgentDragEnd}
|
||||
onNewSessionInWorkspace={showAllProfiles ? undefined : onNewSessionInWorkspace}
|
||||
onReorder={showAllProfiles ? undefined : handleAgentDragEnd}
|
||||
onResumeSession={onResumeSession}
|
||||
onToggle={() => setSidebarRecentsOpen(!agentsOpen)}
|
||||
onTogglePin={pinSession}
|
||||
@@ -595,10 +737,18 @@ export function ChatSidebar({
|
||||
pinned={false}
|
||||
rootClassName="min-h-0 flex-1 p-0"
|
||||
sessions={agentSessions}
|
||||
sortable={agentSessions.length > 1}
|
||||
sortable={!showAllProfiles && agentSessions.length > 1}
|
||||
workingSessionIdSet={workingSessionIdSet}
|
||||
/>
|
||||
)}
|
||||
|
||||
{sidebarOpen && !showSessionSections && <div className="min-h-0 flex-1" />}
|
||||
|
||||
{sidebarOpen && (
|
||||
<div className="shrink-0 px-0.5 pb-1 pt-0.5">
|
||||
<ProfileRail />
|
||||
</div>
|
||||
)}
|
||||
</SidebarContent>
|
||||
</Sidebar>
|
||||
)
|
||||
@@ -667,6 +817,12 @@ interface SidebarSessionGroup {
|
||||
label: string
|
||||
path: null | string
|
||||
sessions: SessionInfo[]
|
||||
// Profile color for the ALL-profiles view; absent for workspace groups.
|
||||
color?: null | string
|
||||
loadingMore?: boolean
|
||||
mode?: 'profile' | 'workspace'
|
||||
onLoadMore?: () => void
|
||||
totalCount?: number
|
||||
}
|
||||
|
||||
interface SidebarSessionsSectionProps {
|
||||
@@ -850,38 +1006,65 @@ function SidebarWorkspaceGroup({
|
||||
ref,
|
||||
...rest
|
||||
}: SidebarWorkspaceGroupProps) {
|
||||
const isProfileGroup = group.mode === 'profile'
|
||||
const pageStep = isProfileGroup ? PROFILE_INITIAL_PAGE : WORKSPACE_PAGE
|
||||
const [open, setOpen] = useState(true)
|
||||
const [visibleCount, setVisibleCount] = useState(WORKSPACE_PAGE)
|
||||
const [visibleCount, setVisibleCount] = useState(pageStep)
|
||||
|
||||
const loadedCount = group.sessions.length
|
||||
// Profile groups know their on-disk total (children excluded); workspace
|
||||
// groups only ever page within what's already loaded.
|
||||
const totalCount = isProfileGroup ? Math.max(group.totalCount ?? loadedCount, loadedCount) : loadedCount
|
||||
const visibleSessions = group.sessions.slice(0, visibleCount)
|
||||
const hiddenCount = Math.max(0, group.sessions.length - visibleSessions.length)
|
||||
const nextCount = Math.min(WORKSPACE_PAGE, hiddenCount)
|
||||
const hiddenCount = Math.max(0, totalCount - visibleSessions.length)
|
||||
const nextCount = Math.min(pageStep, hiddenCount)
|
||||
|
||||
// Reveal already-loaded rows first; only hit the backend when the next page
|
||||
// crosses what's been fetched for this profile.
|
||||
const handleProfileLoadMore = () => {
|
||||
const target = visibleCount + pageStep
|
||||
|
||||
setVisibleCount(target)
|
||||
|
||||
if (target > loadedCount && loadedCount < totalCount) {
|
||||
group.onLoadMore?.()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('grid gap-px', dragging && 'z-10 opacity-60', className)} ref={ref} style={style} {...rest}>
|
||||
<div className="group/workspace flex min-h-6 items-center gap-1 px-2 pt-1 text-[0.6875rem] font-medium text-(--ui-text-tertiary)">
|
||||
<button
|
||||
className="flex min-w-0 items-center gap-1 bg-transparent text-left hover:text-(--ui-text-secondary)"
|
||||
className="flex min-w-0 items-center gap-1.5 bg-transparent text-left hover:text-(--ui-text-secondary)"
|
||||
onClick={() => setOpen(value => !value)}
|
||||
title={group.path ?? undefined}
|
||||
type="button"
|
||||
>
|
||||
{group.color ? (
|
||||
<span aria-hidden="true" className="size-2 shrink-0 rounded-full" style={{ backgroundColor: group.color }} />
|
||||
) : null}
|
||||
<span className="truncate">{group.label}</span>
|
||||
<SidebarCount>{group.sessions.length}</SidebarCount>
|
||||
<SidebarCount>
|
||||
{isProfileGroup ? countLabel(visibleSessions.length, totalCount) : group.sessions.length}
|
||||
</SidebarCount>
|
||||
<DisclosureCaret
|
||||
className="text-(--ui-text-tertiary) opacity-0 transition group-hover/workspace:opacity-100"
|
||||
open={open}
|
||||
/>
|
||||
</button>
|
||||
{onNewSession && (
|
||||
<button
|
||||
aria-label={`New session in ${group.label}`}
|
||||
className="grid size-4 shrink-0 place-items-center rounded-sm bg-transparent text-(--ui-text-quaternary) opacity-0 transition-opacity hover:bg-(--ui-control-hover-background) hover:text-foreground group-hover/workspace:opacity-100"
|
||||
onClick={() => onNewSession(group.path)}
|
||||
title={`New session in ${group.label}`}
|
||||
type="button"
|
||||
>
|
||||
<Codicon name="add" size="0.75rem" />
|
||||
</button>
|
||||
{(onNewSession || isProfileGroup) && (
|
||||
<Tip label={`New session in ${group.label}`}>
|
||||
<button
|
||||
aria-label={`New session in ${group.label}`}
|
||||
className="grid size-4 shrink-0 place-items-center rounded-sm bg-transparent text-(--ui-text-quaternary) opacity-0 transition-opacity hover:bg-(--ui-control-hover-background) hover:text-foreground group-hover/workspace:opacity-100"
|
||||
// Profile groups start a fresh session in that profile but keep the
|
||||
// all-profiles browse view (newSessionInProfile leaves the scope
|
||||
// alone); workspace groups seed the new session's cwd from the path.
|
||||
onClick={() => (isProfileGroup ? newSessionInProfile(group.id) : onNewSession?.(group.path))}
|
||||
type="button"
|
||||
>
|
||||
<Codicon name="add" size="0.75rem" />
|
||||
</button>
|
||||
</Tip>
|
||||
)}
|
||||
{reorderable && (
|
||||
<span
|
||||
@@ -904,17 +1087,21 @@ function SidebarWorkspaceGroup({
|
||||
{open && (
|
||||
<>
|
||||
{renderRows(visibleSessions)}
|
||||
{hiddenCount > 0 && (
|
||||
<button
|
||||
aria-label={`Show ${nextCount} more in ${group.label}`}
|
||||
className="ml-auto grid size-5 place-items-center rounded-sm bg-transparent text-(--ui-text-tertiary) transition-colors hover:bg-(--ui-control-hover-background) hover:text-foreground"
|
||||
onClick={() => setVisibleCount(count => count + WORKSPACE_PAGE)}
|
||||
title={`Show ${nextCount} more in ${group.label}`}
|
||||
type="button"
|
||||
>
|
||||
<Codicon name="ellipsis" size="0.75rem" />
|
||||
</button>
|
||||
)}
|
||||
{hiddenCount > 0 &&
|
||||
(isProfileGroup ? (
|
||||
<SidebarLoadMoreRow loading={Boolean(group.loadingMore)} onClick={handleProfileLoadMore} step={nextCount} />
|
||||
) : (
|
||||
<Tip label={`Show ${nextCount} more in ${group.label}`}>
|
||||
<button
|
||||
aria-label={`Show ${nextCount} more in ${group.label}`}
|
||||
className="ml-auto grid size-5 place-items-center rounded-sm bg-transparent text-(--ui-text-tertiary) transition-colors hover:bg-(--ui-control-hover-background) hover:text-foreground"
|
||||
onClick={() => setVisibleCount(count => count + WORKSPACE_PAGE)}
|
||||
type="button"
|
||||
>
|
||||
<Codicon name="ellipsis" size="0.75rem" />
|
||||
</button>
|
||||
</Tip>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -961,12 +1148,16 @@ function SidebarLoadMoreRow({ loading, onClick, step }: SidebarLoadMoreRowProps)
|
||||
|
||||
return (
|
||||
<button
|
||||
className="flex min-h-5 items-center gap-1 self-start bg-transparent pl-2 text-left text-[0.6875rem] text-(--ui-text-tertiary) transition-colors duration-100 ease-out hover:text-foreground hover:transition-none disabled:cursor-default disabled:opacity-60 disabled:hover:text-(--ui-text-tertiary)"
|
||||
className="flex min-h-5 items-center gap-1.5 self-start bg-transparent pl-2 text-left text-[0.6875rem] text-(--ui-text-tertiary) transition-colors duration-100 ease-out hover:text-foreground hover:transition-none disabled:cursor-default disabled:opacity-60 disabled:hover:text-(--ui-text-tertiary)"
|
||||
disabled={loading}
|
||||
onClick={onClick}
|
||||
type="button"
|
||||
>
|
||||
<Codicon className="opacity-70" name={loading ? 'loading' : 'chevron-down'} size="0.75rem" spinning={loading} />
|
||||
{/* Seat the icon in the same w-3.5 column session rows use for their dot
|
||||
so the chevron + label line up with the rows above. */}
|
||||
<span className="grid w-3.5 shrink-0 place-items-center">
|
||||
<Codicon className="opacity-70" name={loading ? 'loading' : 'chevron-down'} size="0.75rem" spinning={loading} />
|
||||
</span>
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
)
|
||||
|
||||
491
apps/desktop/src/app/chat/sidebar/profile-switcher.tsx
Normal file
491
apps/desktop/src/app/chat/sidebar/profile-switcher.tsx
Normal file
@@ -0,0 +1,491 @@
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
type DragEndEvent,
|
||||
type DragOverEvent,
|
||||
type DragStartEvent,
|
||||
KeyboardSensor,
|
||||
type Modifier,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors
|
||||
} from '@dnd-kit/core'
|
||||
import {
|
||||
arrayMove,
|
||||
horizontalListSortingStrategy,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
useSortable
|
||||
} from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu'
|
||||
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
|
||||
import { Tip, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { PROFILE_SWATCHES, profileColorSoft, resolveProfileColor } from '@/lib/profile-color'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
$activeGatewayProfile,
|
||||
$profileColors,
|
||||
$profileOrder,
|
||||
$profiles,
|
||||
$profileScope,
|
||||
ALL_PROFILES,
|
||||
normalizeProfileKey,
|
||||
refreshActiveProfile,
|
||||
selectProfile,
|
||||
setProfileColor,
|
||||
setProfileOrder,
|
||||
setShowAllProfiles,
|
||||
sortByProfileOrder
|
||||
} from '@/store/profile'
|
||||
import type { ProfileInfo } from '@/types/hermes'
|
||||
|
||||
import { CreateProfileDialog } from '../../profiles/create-profile-dialog'
|
||||
import { DeleteProfileDialog } from '../../profiles/delete-profile-dialog'
|
||||
import { RenameProfileDialog } from '../../profiles/rename-profile-dialog'
|
||||
import { PROFILES_ROUTE } from '../../routes'
|
||||
|
||||
const RAIL_GAP = 4 // px — matches gap-1 between squares.
|
||||
|
||||
// easeOutBack — a little overshoot so squares spring into their new slot rather
|
||||
// than sliding in flat. Neighbors reflow on RAIL_TRANSITION; the dragged square
|
||||
// glides between snapped cells on the snappier DRAG_TRANSITION.
|
||||
const SPRING = 'cubic-bezier(0.34, 1.56, 0.64, 1)'
|
||||
const RAIL_TRANSITION = { duration: 300, easing: SPRING }
|
||||
const DRAG_TRANSITION = `transform 200ms ${SPRING}`
|
||||
|
||||
// The rail is a single horizontal strip of fixed cells. Pin drags to the x-axis
|
||||
// (no cross-axis scrollbar), snap to whole cells so a square steps slot-to-slot
|
||||
// instead of gliding, and clamp to the occupied strip so it can't float past the
|
||||
// last profile onto the "+".
|
||||
const stepThroughCells: Modifier = ({ containerNodeRect, draggingNodeRect, transform }) => {
|
||||
if (!draggingNodeRect || !containerNodeRect) {
|
||||
return { ...transform, y: 0 }
|
||||
}
|
||||
|
||||
const pitch = draggingNodeRect.width + RAIL_GAP
|
||||
const minX = containerNodeRect.left - draggingNodeRect.left
|
||||
const maxX = containerNodeRect.right - draggingNodeRect.right
|
||||
const snapped = Math.round(transform.x / pitch) * pitch
|
||||
|
||||
return { ...transform, x: Math.min(maxX, Math.max(minX, snapped)), y: 0 }
|
||||
}
|
||||
|
||||
// Arc-Spaces-style profile rail at the sidebar foot: a default↔all toggle pinned
|
||||
// left, the colored named profiles scrolling between, and Manage pinned right.
|
||||
// The active profile pops in its own color — the "where am I" cue. Single-
|
||||
// profile users see only the "+" (create their first profile); everything else
|
||||
// appears once a second profile exists.
|
||||
export function ProfileRail() {
|
||||
const profiles = useStore($profiles)
|
||||
const scope = useStore($profileScope)
|
||||
const gatewayProfile = useStore($activeGatewayProfile)
|
||||
const order = useStore($profileOrder)
|
||||
const colors = useStore($profileColors)
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [pendingRename, setPendingRename] = useState<null | ProfileInfo>(null)
|
||||
const [pendingDelete, setPendingDelete] = useState<null | ProfileInfo>(null)
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// A plain mouse wheel only emits deltaY; map it to horizontal scroll so the
|
||||
// rail is navigable without a trackpad. Trackpad x-scroll (deltaX) passes
|
||||
// through. Native + non-passive so we can preventDefault and not bleed the
|
||||
// gesture into the sessions list above.
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current
|
||||
|
||||
if (!el) {
|
||||
return
|
||||
}
|
||||
|
||||
const onWheel = (event: WheelEvent) => {
|
||||
if (el.scrollWidth <= el.clientWidth || Math.abs(event.deltaY) <= Math.abs(event.deltaX)) {
|
||||
return
|
||||
}
|
||||
|
||||
el.scrollLeft += event.deltaY
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
el.addEventListener('wheel', onWheel, { passive: false })
|
||||
|
||||
return () => el.removeEventListener('wheel', onWheel)
|
||||
}, [])
|
||||
|
||||
const isAll = scope === ALL_PROFILES
|
||||
const activeKey = normalizeProfileKey(gatewayProfile)
|
||||
const defaultProfile = profiles.find(profile => profile.is_default)
|
||||
const onDefault = !isAll && activeKey === 'default'
|
||||
|
||||
const named = sortByProfileOrder(profiles.filter(profile => !profile.is_default), order)
|
||||
const multiProfile = profiles.length > 1
|
||||
|
||||
// distance constraint: a small drag reorders, a tap still selects the profile.
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
|
||||
)
|
||||
|
||||
// Tick a haptic each time the drag crosses into a new cell, and a satisfying
|
||||
// confirm on a committed reorder.
|
||||
const lastOverRef = useRef<string | null>(null)
|
||||
|
||||
const handleDragStart = ({ active }: DragStartEvent) => {
|
||||
lastOverRef.current = String(active.id)
|
||||
}
|
||||
|
||||
const handleDragOver = ({ over }: DragOverEvent) => {
|
||||
const id = over ? String(over.id) : null
|
||||
|
||||
if (id && id !== lastOverRef.current) {
|
||||
lastOverRef.current = id
|
||||
triggerHaptic('selection')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragEnd = ({ active, over }: DragEndEvent) => {
|
||||
lastOverRef.current = null
|
||||
|
||||
if (!over || active.id === over.id) {
|
||||
return
|
||||
}
|
||||
|
||||
const ids = named.map(profile => profile.name)
|
||||
const from = ids.indexOf(String(active.id))
|
||||
const to = ids.indexOf(String(over.id))
|
||||
|
||||
if (from >= 0 && to >= 0) {
|
||||
setProfileOrder(arrayMove(ids, from, to))
|
||||
triggerHaptic('success')
|
||||
}
|
||||
}
|
||||
|
||||
// Re-pull the running profile + list on mount so a profile created elsewhere
|
||||
// shows up; cheap and best-effort.
|
||||
useEffect(() => {
|
||||
void refreshActiveProfile()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div aria-label="Profiles" className="flex items-center gap-0.5" role="tablist">
|
||||
{/* One button toggles default ↔ all: home face when scoped to a profile,
|
||||
layers face when showing everything. Pinned left like Manage is right.
|
||||
Hidden until a second profile exists. */}
|
||||
{multiProfile &&
|
||||
(defaultProfile ? (
|
||||
// On default → toggle to all. Anywhere else (all view or a named
|
||||
// profile) → return to default. So leaving a profile never lands on all.
|
||||
<ProfilePill
|
||||
active={isAll || onDefault}
|
||||
glyph={isAll ? 'layers' : 'home'}
|
||||
label={onDefault ? 'Show all profiles' : `Switch to ${defaultProfile.name}`}
|
||||
onSelect={() => (onDefault ? setShowAllProfiles(true) : selectProfile(defaultProfile.name))}
|
||||
/>
|
||||
) : (
|
||||
<ProfilePill active={isAll} glyph="layers" label="All profiles" onSelect={() => setShowAllProfiles(true)} />
|
||||
))}
|
||||
|
||||
{/* Single-profile: the active default's home icon next to the create +. */}
|
||||
{!multiProfile && defaultProfile && (
|
||||
<ProfilePill active glyph="home" label={defaultProfile.name} onSelect={() => selectProfile(defaultProfile.name)} />
|
||||
)}
|
||||
|
||||
<div
|
||||
className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||
ref={scrollRef}
|
||||
>
|
||||
{multiProfile && (
|
||||
<DndContext
|
||||
collisionDetection={closestCenter}
|
||||
modifiers={[stepThroughCells]}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragOver={handleDragOver}
|
||||
onDragStart={handleDragStart}
|
||||
sensors={sensors}
|
||||
>
|
||||
<SortableContext items={named.map(profile => profile.name)} strategy={horizontalListSortingStrategy}>
|
||||
{/* relative → the strip is the dragged square's offsetParent, so the
|
||||
clamp modifier bounds drags to the occupied cells (not the +). */}
|
||||
<div className="relative flex items-center gap-1">
|
||||
{named.map(profile => (
|
||||
<ProfileSquare
|
||||
active={!isAll && normalizeProfileKey(profile.name) === activeKey}
|
||||
color={resolveProfileColor(profile.name, colors)}
|
||||
key={profile.name}
|
||||
label={profile.name}
|
||||
onDelete={() => setPendingDelete(profile)}
|
||||
onRecolor={color => setProfileColor(profile.name, color)}
|
||||
onRename={() => setPendingRename(profile)}
|
||||
onSelect={() => selectProfile(profile.name)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
)}
|
||||
|
||||
<Tip label="New profile">
|
||||
<button
|
||||
aria-label="New profile"
|
||||
className="grid size-5 shrink-0 place-items-center rounded-[3px] text-(--ui-text-tertiary) opacity-55 transition hover:bg-(--ui-control-hover-background) hover:text-foreground hover:opacity-100"
|
||||
onClick={() => setCreateOpen(true)}
|
||||
type="button"
|
||||
>
|
||||
<Codicon name="add" size="0.75rem" />
|
||||
</button>
|
||||
</Tip>
|
||||
</div>
|
||||
|
||||
{multiProfile && (
|
||||
<ProfilePill active={false} glyph="ellipsis" label="Manage profiles…" onSelect={() => navigate(PROFILES_ROUTE)} />
|
||||
)}
|
||||
|
||||
{/* Land in the new profile on a fresh chat (selectProfile triggers the
|
||||
new-session reset), not stuck on the session you were just in. */}
|
||||
<CreateProfileDialog
|
||||
onClose={() => setCreateOpen(false)}
|
||||
onCreated={async name => {
|
||||
await refreshActiveProfile()
|
||||
selectProfile(name)
|
||||
}}
|
||||
open={createOpen}
|
||||
/>
|
||||
|
||||
<RenameProfileDialog
|
||||
currentName={pendingRename?.name ?? ''}
|
||||
onClose={() => setPendingRename(null)}
|
||||
onRenamed={refreshActiveProfile}
|
||||
open={pendingRename !== null}
|
||||
/>
|
||||
|
||||
<DeleteProfileDialog
|
||||
onClose={() => setPendingDelete(null)}
|
||||
onDeleted={refreshActiveProfile}
|
||||
open={pendingDelete !== null}
|
||||
profile={pendingDelete}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface ProfilePillProps {
|
||||
active: boolean
|
||||
// home / All / Manage are glyph action buttons (navigation, not identity).
|
||||
glyph: string
|
||||
label: string
|
||||
onSelect: () => void
|
||||
}
|
||||
|
||||
function ProfilePill({ active, glyph, label, onSelect }: ProfilePillProps) {
|
||||
return (
|
||||
<Tip label={label}>
|
||||
<Button
|
||||
aria-label={label}
|
||||
aria-pressed={active}
|
||||
className={cn(
|
||||
'bg-transparent text-(--ui-text-tertiary) hover:bg-(--ui-control-hover-background) hover:text-foreground',
|
||||
active && 'bg-(--ui-control-active-background) text-foreground'
|
||||
)}
|
||||
onClick={onSelect}
|
||||
size="icon-xs"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name={glyph} size="0.875rem" />
|
||||
</Button>
|
||||
</Tip>
|
||||
)
|
||||
}
|
||||
|
||||
interface ProfileSquareProps {
|
||||
active: boolean
|
||||
color: null | string
|
||||
label: string
|
||||
onSelect: () => void
|
||||
onRecolor: (color: null | string) => void
|
||||
onRename: () => void
|
||||
onDelete: () => void
|
||||
}
|
||||
|
||||
// Hold this long without moving (a drag would have started first) to open the
|
||||
// color picker — the "hard press" gesture, distinct from tap-to-select.
|
||||
const LONG_PRESS_MS = 450
|
||||
|
||||
// A profile *is* its colored square — no icon-button chrome. Soft profile-tint
|
||||
// fill + the initial in the full color; the active one pops to full opacity with
|
||||
// a color ring. These pack tightly so the rail reads as a strip of profiles,
|
||||
// drag-sort to reorder (a tap below the drag threshold still selects), and
|
||||
// right-click to rename/delete. The button carries both the tooltip and
|
||||
// context-menu triggers via nested asChild Slots, so a single element keeps the
|
||||
// dnd listeners, hover tip, and right-click menu.
|
||||
function ProfileSquare({ active, color, label, onDelete, onRecolor, onRename, onSelect }: ProfileSquareProps) {
|
||||
const hue = color ?? 'var(--ui-text-quaternary)'
|
||||
const [pickerOpen, setPickerOpen] = useState(false)
|
||||
const pressTimer = useRef<null | number>(null)
|
||||
const suppressClick = useRef(false)
|
||||
|
||||
const { attributes, isDragging, listeners, setNodeRef, transform, transition } = useSortable({
|
||||
id: label,
|
||||
transition: RAIL_TRANSITION
|
||||
})
|
||||
|
||||
const clearPress = () => {
|
||||
if (pressTimer.current != null) {
|
||||
clearTimeout(pressTimer.current)
|
||||
pressTimer.current = null
|
||||
}
|
||||
}
|
||||
|
||||
// A real drag (movement past the dnd threshold) cancels the pending hold, so a
|
||||
// reorder never doubles as a color pick. Also tidy up on unmount.
|
||||
useEffect(() => {
|
||||
if (isDragging) {
|
||||
clearPress()
|
||||
}
|
||||
}, [isDragging])
|
||||
useEffect(() => clearPress, [])
|
||||
|
||||
const base = CSS.Transform.toString(transform)
|
||||
const ring = active ? `inset 0 0 0 1.5px ${hue}` : ''
|
||||
const lift = isDragging ? '0 6px 16px -4px rgb(0 0 0 / 0.4)' : ''
|
||||
|
||||
const pickColor = (next: null | string) => {
|
||||
onRecolor(next)
|
||||
setPickerOpen(false)
|
||||
triggerHaptic('selection')
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover onOpenChange={setPickerOpen} open={pickerOpen}>
|
||||
<ContextMenu>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<PopoverAnchor asChild>
|
||||
<ContextMenuTrigger asChild>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
'grid size-5 shrink-0 cursor-grab touch-none select-none place-items-center rounded-[3px] text-[0.5625rem] font-semibold uppercase leading-none transition-opacity hover:opacity-100',
|
||||
active ? 'opacity-100' : 'opacity-55',
|
||||
isDragging && 'z-10 cursor-grabbing opacity-100'
|
||||
)}
|
||||
ref={setNodeRef}
|
||||
style={{
|
||||
backgroundColor: profileColorSoft(hue, active ? 30 : 22),
|
||||
boxShadow: [ring, lift].filter(Boolean).join(', ') || undefined,
|
||||
color: color ?? undefined,
|
||||
// Glide the dragged square between snapped cells with a little
|
||||
// overshoot (no scale — the overflow-x strip would clip it).
|
||||
transform: base,
|
||||
transition: isDragging ? DRAG_TRANSITION : transition
|
||||
}}
|
||||
type="button"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
aria-label={label}
|
||||
aria-pressed={active}
|
||||
// Hold-to-recolor rides alongside the dnd pointer listener (call
|
||||
// it first so drag tracking still arms), then a timer opens the
|
||||
// picker and flags the trailing click so it doesn't also select.
|
||||
onClick={() => {
|
||||
if (suppressClick.current) {
|
||||
suppressClick.current = false
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
onSelect()
|
||||
}}
|
||||
onPointerCancel={clearPress}
|
||||
onPointerDown={event => {
|
||||
listeners?.onPointerDown?.(event)
|
||||
|
||||
if (event.button !== 0) {
|
||||
return
|
||||
}
|
||||
|
||||
suppressClick.current = false
|
||||
clearPress()
|
||||
pressTimer.current = window.setTimeout(() => {
|
||||
suppressClick.current = true
|
||||
triggerHaptic('success')
|
||||
setPickerOpen(true)
|
||||
}, LONG_PRESS_MS)
|
||||
}}
|
||||
onPointerLeave={clearPress}
|
||||
onPointerUp={clearPress}
|
||||
>
|
||||
{label.replace(/[^a-z0-9]/gi, '').charAt(0) || '?'}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
</ContextMenuTrigger>
|
||||
</PopoverAnchor>
|
||||
<TooltipContent>{label}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
{/* The rail sits at the very bottom, so pad off the chrome (esp. the
|
||||
statusbar) — Radix then flips the menu up instead of squishing it. */}
|
||||
<ContextMenuContent
|
||||
aria-label={`Actions for ${label}`}
|
||||
className="w-40"
|
||||
collisionPadding={{ bottom: 44, left: 8, right: 8, top: 8 }}
|
||||
>
|
||||
<ContextMenuItem onSelect={() => setPickerOpen(true)}>
|
||||
<Codicon name="symbol-color" size="0.875rem" />
|
||||
<span>Color…</span>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onSelect={onRename}>
|
||||
<Codicon name="edit" size="0.875rem" />
|
||||
<span>Rename</span>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem className="text-destructive focus:text-destructive" onSelect={onDelete} variant="destructive">
|
||||
<Codicon name="trash" size="0.875rem" />
|
||||
<span>Delete</span>
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
|
||||
<PopoverContent
|
||||
aria-label={`Color for ${label}`}
|
||||
className="w-auto p-2"
|
||||
collisionPadding={{ bottom: 44, left: 8, right: 8, top: 8 }}
|
||||
side="top"
|
||||
>
|
||||
<div className="grid grid-cols-6 gap-1.5">
|
||||
{PROFILE_SWATCHES.map(swatch => (
|
||||
<button
|
||||
aria-label={`Set color ${swatch}`}
|
||||
className="size-5 rounded-full transition-transform hover:scale-110"
|
||||
key={swatch}
|
||||
onClick={() => pickColor(swatch)}
|
||||
style={{
|
||||
backgroundColor: swatch,
|
||||
boxShadow: swatch === color ? '0 0 0 2px var(--ui-bg-elevated), 0 0 0 3.5px currentColor' : undefined,
|
||||
color: swatch
|
||||
}}
|
||||
type="button"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
className="mt-2 flex w-full items-center justify-center gap-1.5 rounded-md py-1 text-xs text-(--ui-text-tertiary) transition hover:bg-(--ui-control-hover-background) hover:text-foreground"
|
||||
onClick={() => pickColor(null)}
|
||||
type="button"
|
||||
>
|
||||
<Codicon name="sync" size="0.75rem" />
|
||||
Auto
|
||||
</button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -25,6 +25,7 @@ interface SessionActions {
|
||||
sessionId: string
|
||||
title: string
|
||||
pinned?: boolean
|
||||
profile?: string
|
||||
onPin?: () => void
|
||||
onArchive?: () => void
|
||||
onDelete?: () => void
|
||||
@@ -41,7 +42,7 @@ interface ItemSpec {
|
||||
variant?: 'destructive'
|
||||
}
|
||||
|
||||
function useSessionActions({ sessionId, title, pinned = false, onPin, onArchive, onDelete }: SessionActions) {
|
||||
function useSessionActions({ sessionId, title, pinned = false, profile, onPin, onArchive, onDelete }: SessionActions) {
|
||||
const [renameOpen, setRenameOpen] = useState(false)
|
||||
|
||||
const items: ItemSpec[] = [
|
||||
@@ -113,7 +114,13 @@ function useSessionActions({ sessionId, title, pinned = false, onPin, onArchive,
|
||||
))
|
||||
|
||||
const renameDialog = (
|
||||
<RenameSessionDialog currentTitle={title} onOpenChange={setRenameOpen} open={renameOpen} sessionId={sessionId} />
|
||||
<RenameSessionDialog
|
||||
currentTitle={title}
|
||||
onOpenChange={setRenameOpen}
|
||||
open={renameOpen}
|
||||
profile={profile}
|
||||
sessionId={sessionId}
|
||||
/>
|
||||
)
|
||||
|
||||
return { renameDialog, renderItems }
|
||||
@@ -170,9 +177,10 @@ interface RenameSessionDialogProps {
|
||||
onOpenChange: (open: boolean) => void
|
||||
sessionId: string
|
||||
currentTitle: string
|
||||
profile?: string
|
||||
}
|
||||
|
||||
function RenameSessionDialog({ open, onOpenChange, sessionId, currentTitle }: RenameSessionDialogProps) {
|
||||
function RenameSessionDialog({ open, onOpenChange, sessionId, currentTitle, profile }: RenameSessionDialogProps) {
|
||||
const [value, setValue] = useState(currentTitle)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
@@ -200,7 +208,7 @@ function RenameSessionDialog({ open, onOpenChange, sessionId, currentTitle }: Re
|
||||
setSubmitting(true)
|
||||
|
||||
try {
|
||||
const result = await renameSession(sessionId, next)
|
||||
const result = await renameSession(sessionId, next, profile)
|
||||
const finalTitle = result.title || next || ''
|
||||
setSessions(prev => prev.map(s => (s.id === sessionId ? { ...s, title: finalTitle || null } : s)))
|
||||
notify({ durationMs: 2_000, kind: 'success', message: 'Renamed' })
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import type * as React from 'react'
|
||||
|
||||
import { writeSessionDrag } from '@/app/chat/composer/inline-refs'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import type { SessionInfo } from '@/hermes'
|
||||
@@ -74,6 +75,7 @@ export function SidebarSessionRow({
|
||||
onDelete={onDelete}
|
||||
onPin={onPin}
|
||||
pinned={isPinned}
|
||||
profile={session.profile}
|
||||
sessionId={session.id}
|
||||
title={title}
|
||||
>
|
||||
@@ -86,6 +88,22 @@ export function SidebarSessionRow({
|
||||
className
|
||||
)}
|
||||
data-working={isWorking ? 'true' : undefined}
|
||||
draggable
|
||||
onDragStart={event => {
|
||||
// Reorder drags belong to dnd-kit (the grab handle) — cancel the
|
||||
// native drag so the two DnD systems don't fight.
|
||||
if ((event.target as HTMLElement).closest('[data-reorder-handle]')) {
|
||||
event.preventDefault()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
writeSessionDrag(event.dataTransfer, {
|
||||
id: session.id,
|
||||
profile: session.profile || 'default',
|
||||
title
|
||||
})
|
||||
}}
|
||||
ref={ref}
|
||||
style={style}
|
||||
{...rest}
|
||||
@@ -123,12 +141,15 @@ export function SidebarSessionRow({
|
||||
className={cn(
|
||||
// Scope the dot↔grabber swap to a local group so the grabber
|
||||
// only reveals when hovering/focusing the handle itself, not
|
||||
// anywhere on the row.
|
||||
'group/handle relative -my-0.5 grid w-4 shrink-0 cursor-grab touch-none place-items-center self-stretch overflow-hidden active:cursor-grabbing',
|
||||
// anywhere on the row. Width MUST match the non-reorderable dot
|
||||
// column (w-3.5) so rows don't shift horizontally when reorder is
|
||||
// toggled (e.g. scoped → ALL-profiles view).
|
||||
'group/handle relative -my-0.5 grid w-3.5 shrink-0 cursor-grab touch-none place-items-center self-stretch overflow-hidden active:cursor-grabbing',
|
||||
// The quest-glow box-shadow extends past the dot; let it bleed
|
||||
// out instead of being clipped by this handle's overflow-hidden.
|
||||
needsInput && 'overflow-visible'
|
||||
)}
|
||||
data-reorder-handle
|
||||
onClick={event => event.stopPropagation()}
|
||||
>
|
||||
<SidebarRowDot
|
||||
@@ -146,11 +167,16 @@ export function SidebarSessionRow({
|
||||
/>
|
||||
</span>
|
||||
) : (
|
||||
<span className={cn('grid w-3.5 shrink-0 place-items-center', needsInput ? 'overflow-visible' : 'overflow-hidden')}>
|
||||
<SidebarRowDot isWorking={isWorking} needsInput={needsInput} />
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'grid w-3.5 shrink-0 place-items-center',
|
||||
needsInput ? 'overflow-visible' : 'overflow-hidden'
|
||||
)}
|
||||
>
|
||||
<SidebarRowDot isWorking={isWorking} needsInput={needsInput} />
|
||||
</span>
|
||||
)}
|
||||
<span className="truncate text-[0.8125rem] font-normal text-(--ui-text-secondary) group-hover:text-foreground group-data-[working=true]:text-foreground/90">
|
||||
<span className="min-w-0 flex-1 truncate text-[0.8125rem] font-normal text-(--ui-text-secondary) group-hover:text-foreground group-data-[working=true]:text-foreground/90">
|
||||
{title}
|
||||
</span>
|
||||
</button>
|
||||
@@ -165,6 +191,7 @@ export function SidebarSessionRow({
|
||||
onDelete={onDelete}
|
||||
onPin={onPin}
|
||||
pinned={isPinned}
|
||||
profile={session.profile}
|
||||
sessionId={session.id}
|
||||
title={title}
|
||||
>
|
||||
|
||||
@@ -6,14 +6,8 @@ import { PageLoader } from '@/components/page-loader'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { SearchField } from '@/components/ui/search-field'
|
||||
import { SegmentedControl } from '@/components/ui/segmented-control'
|
||||
import {
|
||||
getActionStatus,
|
||||
getLogs,
|
||||
getStatus,
|
||||
getUsageAnalytics,
|
||||
restartGateway,
|
||||
updateHermes
|
||||
} from '@/hermes'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { getActionStatus, getLogs, getStatus, getUsageAnalytics, restartGateway, updateHermes } from '@/hermes'
|
||||
import type { ActionStatusResponse, AnalyticsResponse, StatusResponse } from '@/hermes'
|
||||
import { sessionTitle } from '@/lib/chat-runtime'
|
||||
import { Activity, AlertCircle, BarChart3, type IconComponent, Pin } from '@/lib/icons'
|
||||
@@ -100,17 +94,18 @@ function RowIconButton({
|
||||
title: string
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
aria-label={title}
|
||||
className={cn('text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground', className)}
|
||||
onClick={onClick}
|
||||
size="icon-xs"
|
||||
title={title}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
<Tip label={title}>
|
||||
<Button
|
||||
aria-label={title}
|
||||
className={cn('text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground', className)}
|
||||
onClick={onClick}
|
||||
size="icon-xs"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
</Tip>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -128,12 +123,7 @@ function EmptyPanel({ action, description, title }: { action?: ReactNode; descri
|
||||
)
|
||||
}
|
||||
|
||||
export function CommandCenterView({
|
||||
initialSection,
|
||||
onClose,
|
||||
onDeleteSession,
|
||||
onOpenSession
|
||||
}: CommandCenterViewProps) {
|
||||
export function CommandCenterView({ initialSection, onClose, onDeleteSession, onOpenSession }: CommandCenterViewProps) {
|
||||
const sessions = useStore($sessions)
|
||||
const pinnedSessionIds = useStore($pinnedSessionIds)
|
||||
|
||||
@@ -168,7 +158,7 @@ export function CommandCenterView({
|
||||
}
|
||||
|
||||
return sorted.filter(session => {
|
||||
const haystack = `${sessionTitle(session)} ${session.id}`.toLowerCase()
|
||||
const haystack = `${sessionTitle(session)} ${session.id} ${session._lineage_root_id ?? ''}`.toLowerCase()
|
||||
|
||||
return haystack.includes(needle)
|
||||
})
|
||||
@@ -586,20 +576,21 @@ function UsagePanel({ error, loading, onRefresh, period, usage }: UsagePanelProp
|
||||
const outputH = Math.round(((entry.output_tokens || 0) / maxTokens) * 96)
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group relative flex h-24 min-w-0 flex-1 flex-col justify-end"
|
||||
<Tip
|
||||
key={entry.day}
|
||||
title={`${entry.day} · in ${formatTokens(entry.input_tokens)} · out ${formatTokens(entry.output_tokens)}`}
|
||||
label={`${entry.day} · in ${formatTokens(entry.input_tokens)} · out ${formatTokens(entry.output_tokens)}`}
|
||||
>
|
||||
<div
|
||||
className="w-full rounded-t-[1px] bg-[color:var(--dt-primary)]/50"
|
||||
style={{ height: Math.max(inputH, entry.input_tokens > 0 ? 1 : 0) }}
|
||||
/>
|
||||
<div
|
||||
className="w-full bg-emerald-500/60"
|
||||
style={{ height: Math.max(outputH, entry.output_tokens > 0 ? 1 : 0) }}
|
||||
/>
|
||||
</div>
|
||||
<div className="group relative flex h-24 min-w-0 flex-1 flex-col justify-end">
|
||||
<div
|
||||
className="w-full rounded-t-[1px] bg-[color:var(--dt-primary)]/50"
|
||||
style={{ height: Math.max(inputH, entry.input_tokens > 0 ? 1 : 0) }}
|
||||
/>
|
||||
<div
|
||||
className="w-full bg-emerald-500/60"
|
||||
style={{ height: Math.max(outputH, entry.output_tokens > 0 ? 1 : 0) }}
|
||||
/>
|
||||
</div>
|
||||
</Tip>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -4,14 +4,7 @@ import { Dialog as DialogPrimitive } from 'radix-ui'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList
|
||||
} from '@/components/ui/command'
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
|
||||
import { getHermesConfigRecord, listSessions } from '@/hermes'
|
||||
import { sessionTitle } from '@/lib/chat-runtime'
|
||||
import {
|
||||
@@ -34,9 +27,11 @@ import {
|
||||
Palette,
|
||||
Plus,
|
||||
Settings,
|
||||
Settings2,
|
||||
Sun,
|
||||
Users,
|
||||
Wrench
|
||||
Wrench,
|
||||
Zap
|
||||
} from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $commandPaletteOpen, closeCommandPalette, setCommandPaletteOpen } from '@/store/command-palette'
|
||||
@@ -98,8 +93,31 @@ const toSessionEntry = (session: SessionRow): SessionEntry => ({
|
||||
})
|
||||
|
||||
const NON_CONFIG_SETTINGS: ReadonlyArray<{ icon: IconComponent; keywords?: string[]; label: string; tab: string }> = [
|
||||
{
|
||||
icon: Zap,
|
||||
keywords: ['accounts', 'sign in', 'oauth', 'login', 'subscription', 'models', 'anthropic', 'openai'],
|
||||
label: 'Providers',
|
||||
tab: 'providers&pview=accounts'
|
||||
},
|
||||
{
|
||||
icon: KeyRound,
|
||||
keywords: ['providers', 'api key', 'keys', 'secrets', 'tokens'],
|
||||
label: 'Provider API keys',
|
||||
tab: 'providers&pview=keys'
|
||||
},
|
||||
{ icon: Globe, keywords: ['connection', 'messaging'], label: 'Gateway', tab: 'gateway' },
|
||||
{ icon: KeyRound, keywords: ['api', 'secrets', 'tokens', 'credentials'], label: 'API Keys', tab: 'keys' },
|
||||
{
|
||||
icon: KeyRound,
|
||||
keywords: ['api', 'secrets', 'tokens', 'credentials', 'browser', 'search'],
|
||||
label: 'Tools & Keys',
|
||||
tab: 'keys&kview=tools'
|
||||
},
|
||||
{
|
||||
icon: Settings2,
|
||||
keywords: ['gateway', 'proxy', 'server', 'webhook', 'env'],
|
||||
label: 'Tools & Keys settings',
|
||||
tab: 'keys&kview=settings'
|
||||
},
|
||||
{ icon: Wrench, keywords: ['servers', 'tools'], label: 'MCP', tab: 'mcp' },
|
||||
{ icon: Archive, keywords: ['history', 'archived'], label: 'Archived Chats', tab: 'sessions' },
|
||||
{ icon: Info, keywords: ['version', 'about'], label: 'About', tab: 'about' }
|
||||
@@ -124,7 +142,11 @@ export function CommandPalette() {
|
||||
|
||||
// Server-backed sources for the type-to-search groups, fetched lazily while
|
||||
// the palette is open. react-query handles caching/dedup/staleness.
|
||||
const configQuery = useQuery({ queryKey: ['command-palette', 'config'], queryFn: getHermesConfigRecord, enabled: open })
|
||||
const configQuery = useQuery({
|
||||
queryKey: ['command-palette', 'config'],
|
||||
queryFn: getHermesConfigRecord,
|
||||
enabled: open
|
||||
})
|
||||
|
||||
const sessionsQuery = useQuery({
|
||||
queryKey: ['command-palette', 'sessions'],
|
||||
@@ -141,7 +163,9 @@ export function CommandPalette() {
|
||||
const mcpServers = useMemo(() => {
|
||||
const raw = configQuery.data?.mcp_servers
|
||||
|
||||
return raw && typeof raw === 'object' && !Array.isArray(raw) ? Object.keys(raw as Record<string, unknown>).sort() : []
|
||||
return raw && typeof raw === 'object' && !Array.isArray(raw)
|
||||
? Object.keys(raw as Record<string, unknown>).sort()
|
||||
: []
|
||||
}, [configQuery.data])
|
||||
|
||||
const sessions = useMemo(() => (sessionsQuery.data?.sessions ?? []).map(toSessionEntry), [sessionsQuery.data])
|
||||
@@ -169,7 +193,7 @@ export function CommandPalette() {
|
||||
{
|
||||
icon: Wrench,
|
||||
id: 'nav-skills',
|
||||
keywords: ['tools', 'toolsets', 'providers'],
|
||||
keywords: ['tools', 'toolsets'],
|
||||
label: 'Skills & Tools',
|
||||
run: go(SKILLS_ROUTE)
|
||||
},
|
||||
@@ -207,25 +231,9 @@ export function CommandPalette() {
|
||||
]
|
||||
},
|
||||
{
|
||||
heading: 'Settings',
|
||||
items: [
|
||||
...SECTIONS.map(section => ({
|
||||
icon: section.icon,
|
||||
id: `set-config-${section.id}`,
|
||||
keywords: ['settings', section.label],
|
||||
label: section.label,
|
||||
run: go(settingsTab(`config:${section.id}`))
|
||||
})),
|
||||
...NON_CONFIG_SETTINGS.map(entry => ({
|
||||
icon: entry.icon,
|
||||
id: `set-${entry.tab}`,
|
||||
keywords: ['settings', ...(entry.keywords ?? [])],
|
||||
label: entry.label,
|
||||
run: go(settingsTab(entry.tab))
|
||||
}))
|
||||
]
|
||||
},
|
||||
{
|
||||
// Declared before Settings: cmdk keeps group order, so this keeps the
|
||||
// theme/mode pickers on top for "theme"/"color" queries instead of
|
||||
// buried under a fuzzy Settings match.
|
||||
heading: 'Appearance',
|
||||
items: [
|
||||
{
|
||||
@@ -243,6 +251,25 @@ export function CommandPalette() {
|
||||
to: 'color-mode'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
heading: 'Settings',
|
||||
items: [
|
||||
...SECTIONS.map(section => ({
|
||||
icon: section.icon,
|
||||
id: `set-config-${section.id}`,
|
||||
keywords: ['settings', section.label],
|
||||
label: section.label,
|
||||
run: go(settingsTab(`config:${section.id}`))
|
||||
})),
|
||||
...NON_CONFIG_SETTINGS.map(entry => ({
|
||||
icon: entry.icon,
|
||||
id: `set-${entry.tab}`,
|
||||
keywords: ['settings', ...(entry.keywords ?? [])],
|
||||
label: entry.label,
|
||||
run: go(settingsTab(entry.tab))
|
||||
}))
|
||||
]
|
||||
}
|
||||
]
|
||||
}, [go])
|
||||
|
||||
108
apps/desktop/src/app/cron/cron-job-actions-menu.tsx
Normal file
108
apps/desktop/src/app/cron/cron-job-actions-menu.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import type * as React from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
|
||||
interface CronJobActions {
|
||||
busy?: boolean
|
||||
isPaused: boolean
|
||||
title: string
|
||||
onDelete: () => void
|
||||
onEdit: () => void
|
||||
onPauseResume: () => void
|
||||
onTrigger: () => void
|
||||
}
|
||||
|
||||
interface CronJobActionsMenuProps
|
||||
extends CronJobActions, Pick<React.ComponentProps<typeof DropdownMenuContent>, 'align' | 'sideOffset'> {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export function CronJobActionsMenu({
|
||||
align = 'end',
|
||||
busy = false,
|
||||
children,
|
||||
isPaused,
|
||||
onDelete,
|
||||
onEdit,
|
||||
onPauseResume,
|
||||
onTrigger,
|
||||
sideOffset = 6,
|
||||
title
|
||||
}: CronJobActionsMenuProps) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align={align}
|
||||
aria-label={`Actions for ${title}`}
|
||||
className="w-44"
|
||||
sideOffset={sideOffset}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
disabled={busy}
|
||||
onSelect={() => {
|
||||
triggerHaptic('selection')
|
||||
onPauseResume()
|
||||
}}
|
||||
>
|
||||
<Codicon name={isPaused ? 'play' : 'debug-pause'} size="0.875rem" />
|
||||
<span>{isPaused ? 'Resume' : 'Pause'}</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
disabled={busy}
|
||||
onSelect={() => {
|
||||
triggerHaptic('selection')
|
||||
onTrigger()
|
||||
}}
|
||||
>
|
||||
<Codicon name="zap" size="0.875rem" />
|
||||
<span>Trigger now</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
triggerHaptic('selection')
|
||||
onEdit()
|
||||
}}
|
||||
>
|
||||
<Codicon name="edit" size="0.875rem" />
|
||||
<span>Edit</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
triggerHaptic('warning')
|
||||
onDelete()
|
||||
}}
|
||||
variant="destructive"
|
||||
>
|
||||
<Codicon name="trash" size="0.875rem" />
|
||||
<span>Delete</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
interface CronJobActionsTriggerProps extends Omit<React.ComponentProps<typeof Button>, 'size' | 'variant'> {
|
||||
title: string
|
||||
}
|
||||
|
||||
export function CronJobActionsTrigger({ className, title, ...props }: CronJobActionsTriggerProps) {
|
||||
return (
|
||||
<Button
|
||||
aria-label={`Actions for ${title}`}
|
||||
className={className}
|
||||
size="icon-sm"
|
||||
title="Cron job actions"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
<Codicon className="text-muted-foreground" name="ellipsis" size="0.875rem" />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { PageLoader } from '@/components/page-loader'
|
||||
import { Badge, type BadgeProps } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -27,12 +28,13 @@ import {
|
||||
updateCronJob
|
||||
} from '@/hermes'
|
||||
import { AlertTriangle, Clock } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
|
||||
import { useRefreshHotkey } from '../hooks/use-refresh-hotkey'
|
||||
import { OverlayView } from '../overlays/overlay-view'
|
||||
|
||||
import { CronJobActionsMenu, CronJobActionsTrigger } from './cron-job-actions-menu'
|
||||
|
||||
const DEFAULT_DELIVER = 'local'
|
||||
|
||||
const DELIVERY_OPTIONS: ReadonlyArray<{ label: string; value: string }> = [
|
||||
@@ -310,7 +312,6 @@ export function CronView({ onClose }: CronViewProps) {
|
||||
|
||||
const [editor, setEditor] = useState<EditorState>({ mode: 'closed' })
|
||||
const [pendingDelete, setPendingDelete] = useState<CronJob | null>(null)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
@@ -371,25 +372,6 @@ export function CronView({ onClose }: CronViewProps) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleConfirmDelete() {
|
||||
if (!pendingDelete) {
|
||||
return
|
||||
}
|
||||
|
||||
setDeleting(true)
|
||||
|
||||
try {
|
||||
await deleteCronJob(pendingDelete.id)
|
||||
setJobs(current => (current ? current.filter(row => row.id !== pendingDelete.id) : current))
|
||||
notify({ kind: 'success', title: 'Cron deleted', message: truncate(jobTitle(pendingDelete), 60) })
|
||||
setPendingDelete(null)
|
||||
} catch (err) {
|
||||
notifyError(err, 'Failed to delete cron job')
|
||||
} finally {
|
||||
setDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEditorSave(values: EditorValues) {
|
||||
if (editor.mode === 'create') {
|
||||
const created = await createCronJob({
|
||||
@@ -432,20 +414,20 @@ export function CronView({ onClose }: CronViewProps) {
|
||||
{!jobs ? (
|
||||
<PageLoader label="Loading cron jobs..." />
|
||||
) : visibleJobs.length === 0 ? (
|
||||
// Empty state owns the primary "create" CTA — we used to also have
|
||||
// one in the filters bar but it was redundant. Only show the button
|
||||
// when there are zero jobs total; the search-empty case ("No
|
||||
// matches") just asks the user to broaden their query.
|
||||
<EmptyState
|
||||
actionLabel={totalCount === 0 ? 'Create first cron' : undefined}
|
||||
description={
|
||||
totalCount === 0
|
||||
? 'Schedule a prompt to run on a cron expression. Hermes will run it and deliver results to the destination you pick.'
|
||||
: 'Try a broader search query.'
|
||||
}
|
||||
onAction={totalCount === 0 ? () => setEditor({ mode: 'create' }) : undefined}
|
||||
title={totalCount === 0 ? 'No scheduled jobs yet' : 'No matches'}
|
||||
/>
|
||||
// Empty state owns the primary "create" CTA — we used to also have
|
||||
// one in the filters bar but it was redundant. Only show the button
|
||||
// when there are zero jobs total; the search-empty case ("No
|
||||
// matches") just asks the user to broaden their query.
|
||||
<EmptyState
|
||||
actionLabel={totalCount === 0 ? 'Create first cron' : undefined}
|
||||
description={
|
||||
totalCount === 0
|
||||
? 'Schedule a prompt to run on a cron expression. Hermes will run it and deliver results to the destination you pick.'
|
||||
: 'Try a broader search query.'
|
||||
}
|
||||
onAction={totalCount === 0 ? () => setEditor({ mode: 'create' }) : undefined}
|
||||
title={totalCount === 0 ? 'No scheduled jobs yet' : 'No matches'}
|
||||
/>
|
||||
) : (
|
||||
<div className="mx-auto w-full max-w-4xl min-h-0 flex-1 overflow-y-auto px-4 py-3">
|
||||
{/* Inline header replaces the old top-bar "New cron" button. We
|
||||
@@ -479,30 +461,33 @@ export function CronView({ onClose }: CronViewProps) {
|
||||
</div>
|
||||
<CronEditorDialog editor={editor} onClose={() => setEditor({ mode: 'closed' })} onSave={handleEditorSave} />
|
||||
|
||||
<Dialog onOpenChange={open => !open && !deleting && setPendingDelete(null)} open={pendingDelete !== null}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete cron job?</DialogTitle>
|
||||
<DialogDescription>
|
||||
{pendingDelete ? (
|
||||
<>
|
||||
This will remove{' '}
|
||||
<span className="font-medium text-foreground">{truncate(jobTitle(pendingDelete), 60)}</span>{' '}
|
||||
permanently. It will stop firing immediately.
|
||||
</>
|
||||
) : null}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button disabled={deleting} onClick={() => setPendingDelete(null)} variant="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button disabled={deleting} onClick={() => void handleConfirmDelete()} variant="destructive">
|
||||
{deleting ? 'Deleting...' : 'Delete'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<ConfirmDialog
|
||||
busyLabel="Deleting…"
|
||||
confirmLabel="Delete"
|
||||
description={
|
||||
pendingDelete ? (
|
||||
<>
|
||||
This will remove{' '}
|
||||
<span className="font-medium text-foreground">{truncate(jobTitle(pendingDelete), 60)}</span> permanently.
|
||||
It will stop firing immediately.
|
||||
</>
|
||||
) : null
|
||||
}
|
||||
destructive
|
||||
doneLabel="Deleted"
|
||||
onClose={() => setPendingDelete(null)}
|
||||
onConfirm={async () => {
|
||||
if (!pendingDelete) {
|
||||
return
|
||||
}
|
||||
|
||||
await deleteCronJob(pendingDelete.id)
|
||||
setJobs(current => (current ? current.filter(row => row.id !== pendingDelete.id) : current))
|
||||
notify({ kind: 'success', message: truncate(jobTitle(pendingDelete), 60), title: 'Cron deleted' })
|
||||
}}
|
||||
open={pendingDelete !== null}
|
||||
title="Delete cron job?"
|
||||
/>
|
||||
</OverlayView>
|
||||
)
|
||||
}
|
||||
@@ -563,47 +548,27 @@ function CronJobRow({
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-0.5">
|
||||
<IconAction
|
||||
aria-label={isPaused ? 'Resume cron' : 'Pause cron'}
|
||||
disabled={busy}
|
||||
onClick={onPauseResume}
|
||||
title={isPaused ? 'Resume' : 'Pause'}
|
||||
<div className="flex shrink-0 items-center">
|
||||
<CronJobActionsMenu
|
||||
busy={busy}
|
||||
isPaused={isPaused}
|
||||
onDelete={onDelete}
|
||||
onEdit={onEdit}
|
||||
onPauseResume={onPauseResume}
|
||||
onTrigger={onTrigger}
|
||||
title={jobTitle(job)}
|
||||
>
|
||||
<Codicon name={isPaused ? 'play' : 'debug-pause'} size="0.875rem" />
|
||||
</IconAction>
|
||||
<IconAction aria-label="Trigger now" disabled={busy} onClick={onTrigger} title="Trigger now">
|
||||
<Codicon name="zap" size="0.875rem" />
|
||||
</IconAction>
|
||||
<IconAction aria-label="Edit cron" onClick={onEdit} title="Edit">
|
||||
<Codicon name="edit" size="0.875rem" />
|
||||
</IconAction>
|
||||
<IconAction
|
||||
aria-label="Delete cron"
|
||||
className="text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
|
||||
onClick={onDelete}
|
||||
title="Delete"
|
||||
>
|
||||
<Codicon name="trash" size="0.875rem" />
|
||||
</IconAction>
|
||||
<CronJobActionsTrigger
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={event => event.stopPropagation()}
|
||||
title={jobTitle(job)}
|
||||
/>
|
||||
</CronJobActionsMenu>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function IconAction({ children, className, ...props }: Omit<React.ComponentProps<typeof Button>, 'size' | 'variant'>) {
|
||||
return (
|
||||
<Button
|
||||
className={cn('text-muted-foreground hover:text-foreground', className)}
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyState({
|
||||
actionLabel,
|
||||
description,
|
||||
|
||||
@@ -11,7 +11,7 @@ import { Pane, PaneMain } from '@/components/pane-shell'
|
||||
import { useSkinCommand } from '@/themes/use-skin-command'
|
||||
|
||||
import { formatRefValue } from '../components/assistant-ui/directive-text'
|
||||
import { getSessionMessages, listSessions } from '../hermes'
|
||||
import { getSessionMessages, listAllProfileSessions, type SessionInfo } from '../hermes'
|
||||
import { preserveLocalAssistantErrors, toChatMessages } from '../lib/chat-messages'
|
||||
import { toggleCommandPalette } from '../store/command-palette'
|
||||
import {
|
||||
@@ -25,9 +25,11 @@ import {
|
||||
pinSession,
|
||||
SIDEBAR_DEFAULT_WIDTH,
|
||||
SIDEBAR_MAX_WIDTH,
|
||||
SIDEBAR_SESSIONS_PAGE_SIZE,
|
||||
unpinSession
|
||||
} from '../store/layout'
|
||||
import { $filePreviewTarget, $previewTarget, closeActiveRightRailTab } from '../store/preview'
|
||||
import { $freshSessionRequest, normalizeProfileKey, refreshActiveProfile } from '../store/profile'
|
||||
import {
|
||||
$activeSessionId,
|
||||
$currentCwd,
|
||||
@@ -45,6 +47,7 @@ import {
|
||||
setCurrentModel,
|
||||
setCurrentProvider,
|
||||
setMessages,
|
||||
setSessionProfileTotals,
|
||||
setSessions,
|
||||
setSessionsLoading,
|
||||
setSessionsTotal
|
||||
@@ -98,6 +101,26 @@ const ProfilesView = lazy(async () => ({ default: (await import('./profiles')).P
|
||||
const SettingsView = lazy(async () => ({ default: (await import('./settings')).SettingsView }))
|
||||
const SkillsView = lazy(async () => ({ default: (await import('./skills')).SkillsView }))
|
||||
|
||||
// Rows a session refresh must preserve even if the aggregator omits them:
|
||||
// in-flight first turns (message_count 0), pinned rows aged off the page, and
|
||||
// the actively-viewed chat (its "working" flag clears a beat before the
|
||||
// aggregator sees the persisted row). Pass `scope` to only keep the active row
|
||||
// when it belongs to the profile being paged.
|
||||
function sessionsToKeep(scope?: string): Set<string> {
|
||||
const keep = new Set<string>([...$workingSessionIds.get(), ...$pinnedSessionIds.get()])
|
||||
const active = $selectedStoredSessionId.get()
|
||||
|
||||
if (active) {
|
||||
const session = scope ? $sessions.get().find(s => s.id === active) : null
|
||||
|
||||
if (!scope || !session || normalizeProfileKey(session.profile) === scope) {
|
||||
keep.add(active)
|
||||
}
|
||||
}
|
||||
|
||||
return keep
|
||||
}
|
||||
|
||||
export function DesktopController() {
|
||||
const queryClient = useQueryClient()
|
||||
const location = useLocation()
|
||||
@@ -201,9 +224,9 @@ export function DesktopController() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Global chrome shortcuts (plain Cmd/Ctrl, no alt/shift): Cmd+K → command
|
||||
// palette (the composer's "drain next queued" moved to Cmd+Shift+K), Cmd+. →
|
||||
// command center (sessions / system / usage).
|
||||
// Global chrome shortcuts (plain Cmd/Ctrl, no alt/shift): Cmd+K / Cmd+P →
|
||||
// command palette (the composer's "drain next queued" moved to Cmd+Shift+K),
|
||||
// Cmd+. → command center (sessions / system / usage).
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (!(event.metaKey || event.ctrlKey) || event.altKey || event.shiftKey) {
|
||||
@@ -212,7 +235,7 @@ export function DesktopController() {
|
||||
|
||||
const key = event.key.toLowerCase()
|
||||
|
||||
if (key === 'k') {
|
||||
if (key === 'k' || key === 'p') {
|
||||
event.preventDefault()
|
||||
toggleCommandPalette()
|
||||
} else if (key === '.') {
|
||||
@@ -236,17 +259,15 @@ export function DesktopController() {
|
||||
// Require at least one message so abandoned/empty "Untitled" drafts (one
|
||||
// was created per TUI/desktop launch before the lazy-create fix) don't
|
||||
// clutter the sidebar.
|
||||
const result = await listSessions(limit, 1)
|
||||
// Unified cross-profile list (served read-only off each profile's
|
||||
// state.db; no per-profile backend is spawned). Single-profile users get
|
||||
// the same rows tagged profile="default".
|
||||
const result = await listAllProfileSessions(limit, 1)
|
||||
|
||||
if (refreshSessionsRequestRef.current === requestId) {
|
||||
// Don't hard-replace. Two kinds of rows must survive a refresh the
|
||||
// server didn't return: (1) sessions whose first turn is still in
|
||||
// flight (message_count 0, so min_messages=1 omits them) and (2)
|
||||
// pinned sessions that have aged off the most-recent page — otherwise
|
||||
// the pin "disappears until you refresh". mergeSessionPage keeps both.
|
||||
const keepIds = new Set<string>([...$workingSessionIds.get(), ...$pinnedSessionIds.get()])
|
||||
setSessions(prev => mergeSessionPage(prev, result.sessions, keepIds))
|
||||
setSessions(prev => mergeSessionPage(prev, result.sessions, sessionsToKeep()))
|
||||
setSessionsTotal(typeof result.total === 'number' ? result.total : result.sessions.length)
|
||||
setSessionProfileTotals(result.profile_totals ?? {})
|
||||
}
|
||||
} finally {
|
||||
if (refreshSessionsRequestRef.current === requestId) {
|
||||
@@ -260,6 +281,21 @@ export function DesktopController() {
|
||||
void refreshSessions()
|
||||
}, [refreshSessions])
|
||||
|
||||
// ALL-profiles view pages one profile at a time: fetch that profile's next
|
||||
// page and merge it in place, leaving every other profile's rows untouched.
|
||||
const loadMoreSessionsForProfile = useCallback(async (profile: string) => {
|
||||
const key = normalizeProfileKey(profile)
|
||||
const inKey = (s: SessionInfo) => normalizeProfileKey(s.profile) === key
|
||||
const loaded = $sessions.get().filter(inKey).length
|
||||
const result = await listAllProfileSessions(loaded + SIDEBAR_SESSIONS_PAGE_SIZE, 1, 'exclude', 'recent', key)
|
||||
const keep = sessionsToKeep(key)
|
||||
|
||||
setSessions(prev => [...prev.filter(s => !inKey(s)), ...mergeSessionPage(prev.filter(inKey), result.sessions, keep)])
|
||||
|
||||
const total = result.profile_totals?.[key] ?? result.total ?? result.sessions.length
|
||||
setSessionProfileTotals(prev => ({ ...prev, [key]: Math.max(total, result.sessions.length) }))
|
||||
}, [])
|
||||
|
||||
const toggleSelectedPin = useCallback(() => {
|
||||
const sessionId = $selectedStoredSessionId.get()
|
||||
|
||||
@@ -316,7 +352,7 @@ export function DesktopController() {
|
||||
})
|
||||
|
||||
const openProviderSettings = useCallback(() => {
|
||||
navigate(`${SETTINGS_ROUTE}?tab=keys`)
|
||||
navigate(`${SETTINGS_ROUTE}?tab=providers`)
|
||||
}, [navigate])
|
||||
|
||||
const modelMenuContent = useMemo(
|
||||
@@ -349,9 +385,11 @@ export function DesktopController() {
|
||||
return
|
||||
}
|
||||
|
||||
const storedProfile = $sessions.get().find(session => session.id === storedSessionId)?.profile
|
||||
|
||||
for (let index = 0; index < Math.max(1, attempts); index += 1) {
|
||||
try {
|
||||
const latest = await getSessionMessages(storedSessionId)
|
||||
const latest = await getSessionMessages(storedSessionId, storedProfile)
|
||||
updateSessionState(
|
||||
runtimeSessionId,
|
||||
state => ({
|
||||
@@ -454,6 +492,20 @@ export function DesktopController() {
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
}, [startFreshSessionDraft])
|
||||
|
||||
// A profile switch/create drops to a fresh new-session draft so the previously
|
||||
// open session doesn't bleed across contexts. Skip the initial value.
|
||||
const freshSessionRequest = useStore($freshSessionRequest)
|
||||
const lastFreshRef = useRef(freshSessionRequest)
|
||||
|
||||
useEffect(() => {
|
||||
if (freshSessionRequest === lastFreshRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
lastFreshRef.current = freshSessionRequest
|
||||
startFreshSessionDraft()
|
||||
}, [freshSessionRequest, startFreshSessionDraft])
|
||||
|
||||
const composer = useComposerActions({
|
||||
activeSessionId,
|
||||
currentCwd,
|
||||
@@ -506,6 +558,7 @@ export function DesktopController() {
|
||||
busyRef,
|
||||
createBackendSessionForSend,
|
||||
handleSkinCommand,
|
||||
refreshSessions,
|
||||
requestGateway,
|
||||
selectedStoredSessionIdRef,
|
||||
startFreshSessionDraft,
|
||||
@@ -528,6 +581,7 @@ export function DesktopController() {
|
||||
useEffect(() => {
|
||||
if (gatewayState === 'open') {
|
||||
void refreshCurrentModel()
|
||||
void refreshActiveProfile()
|
||||
void refreshSessions().catch(() => undefined)
|
||||
}
|
||||
}, [gatewayState, refreshCurrentModel, refreshSessions])
|
||||
@@ -570,6 +624,7 @@ export function DesktopController() {
|
||||
currentView={currentView}
|
||||
onArchiveSession={sessionId => void archiveSession(sessionId)}
|
||||
onDeleteSession={sessionId => void removeSession(sessionId)}
|
||||
onLoadMoreProfileSessions={loadMoreSessionsForProfile}
|
||||
onLoadMoreSessions={loadMoreSessions}
|
||||
onNavigate={selectSidebarItem}
|
||||
onNewSessionInWorkspace={startSessionInWorkspace}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useRef } from 'react'
|
||||
|
||||
import type { HermesConnection } from '@/global'
|
||||
import { HermesGateway } from '@/hermes'
|
||||
import { isGatewayReauthRequired, resolveGatewayWsUrl } from '@/lib/gateway-ws-url'
|
||||
import {
|
||||
$desktopBoot,
|
||||
applyDesktopBootProgress,
|
||||
@@ -9,9 +10,27 @@ import {
|
||||
failDesktopBoot,
|
||||
setDesktopBootStep
|
||||
} from '@/store/boot'
|
||||
import { setGateway } from '@/store/gateway'
|
||||
import {
|
||||
$gateway,
|
||||
closeSecondaryGateways,
|
||||
configureGatewayRegistry,
|
||||
ensureGatewayForProfile,
|
||||
pruneSecondaryGateways,
|
||||
reconnectSecondaryGateways,
|
||||
reportPrimaryGatewayState,
|
||||
setPrimaryGateway,
|
||||
touchSecondaryGateways
|
||||
} from '@/store/gateway'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import { $connection, setConnection, setGatewayState, setSessionsLoading } from '@/store/session'
|
||||
import { $activeGatewayProfile, normalizeProfileKey, touchActiveGatewayBackend } from '@/store/profile'
|
||||
import {
|
||||
$attentionSessionIds,
|
||||
$connection,
|
||||
$sessions,
|
||||
$workingSessionIds,
|
||||
setConnection,
|
||||
setSessionsLoading
|
||||
} from '@/store/session'
|
||||
import type { RpcEvent } from '@/types/hermes'
|
||||
|
||||
interface GatewayBootOptions {
|
||||
@@ -75,6 +94,10 @@ export function useGatewayBoot({
|
||||
let reconnecting = false
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let reconnectAttempt = 0
|
||||
// Surface "sign in again" once per disconnect episode, not on every backoff
|
||||
// tick — a stale OAuth ticket fails every attempt and would otherwise stack
|
||||
// identical error toasts (and their haptics). Reset on the next clean open.
|
||||
let reauthNotified = false
|
||||
|
||||
// Wrap the live getter in a call so TS control-flow analysis doesn't narrow
|
||||
// `connectionState` to a constant across the early-return guards (the state
|
||||
@@ -96,14 +119,22 @@ export function useGatewayBoot({
|
||||
reconnecting = true
|
||||
|
||||
try {
|
||||
const conn = await desktop.getConnection()
|
||||
const conn = await desktop.getConnection($activeGatewayProfile.get())
|
||||
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
|
||||
publish(conn)
|
||||
await gateway.connect(conn.wsUrl)
|
||||
// Re-mint the WS URL before reconnecting. OAuth tickets are single-use
|
||||
// with a short TTL, so the ticket baked into the cached conn.wsUrl is
|
||||
// dead on every reconnect after the initial boot — reusing it surfaces
|
||||
// as an opaque "Could not connect to Hermes gateway". resolveGatewayWsUrl
|
||||
// mints a fresh ticket (or throws a reauth error in OAuth mode rather
|
||||
// than connecting with a stale one). For local/token gateways the URL
|
||||
// carries a long-lived token and the re-mint is a cheap no-op.
|
||||
const wsUrl = await resolveGatewayWsUrl(desktop, conn)
|
||||
await gateway.connect(wsUrl)
|
||||
|
||||
if (cancelled) {
|
||||
return
|
||||
@@ -113,8 +144,15 @@ export function useGatewayBoot({
|
||||
// Resync state that may have moved on the backend while we were asleep.
|
||||
await callbacksRef.current.refreshHermesConfig().catch(() => undefined)
|
||||
await callbacksRef.current.refreshSessions().catch(() => undefined)
|
||||
} catch {
|
||||
// Fall through to scheduleReconnect's backoff below.
|
||||
} catch (err) {
|
||||
// OAuth session expired mid-reconnect: surface the actionable "sign in
|
||||
// again" message once instead of silently looping the backoff against a
|
||||
// ticket that can never succeed. Transport failures fall through to the
|
||||
// backoff in the finally block below.
|
||||
if (!cancelled && isGatewayReauthRequired(err) && !reauthNotified) {
|
||||
reauthNotified = true
|
||||
notifyError(err, 'Gateway sign-in required')
|
||||
}
|
||||
} finally {
|
||||
reconnecting = false
|
||||
|
||||
@@ -145,6 +183,7 @@ export function useGatewayBoot({
|
||||
|
||||
clearReconnectTimer()
|
||||
reconnectAttempt = 0
|
||||
reconnectSecondaryGateways()
|
||||
|
||||
if (!gatewayOpen()) {
|
||||
void attemptReconnect()
|
||||
@@ -165,13 +204,18 @@ export function useGatewayBoot({
|
||||
|
||||
const gateway = new HermesGateway()
|
||||
callbacksRef.current.onGatewayReady(gateway)
|
||||
setGateway(gateway)
|
||||
setPrimaryGateway(gateway, normalizeProfileKey($activeGatewayProfile.get()))
|
||||
// Secondary (background-profile) sockets funnel into the same handler.
|
||||
configureGatewayRegistry({ onEvent: event => callbacksRef.current.handleGatewayEvent(event) })
|
||||
|
||||
const offState = gateway.onState(st => {
|
||||
setGatewayState(st)
|
||||
// Mirror to the composer only while the primary is the active profile —
|
||||
// a background secondary reconnect mustn't flip the foreground state.
|
||||
reportPrimaryGatewayState(st)
|
||||
|
||||
if (st === 'open') {
|
||||
reconnectAttempt = 0
|
||||
reauthNotified = false
|
||||
clearReconnectTimer()
|
||||
} else if (bootCompleted && (st === 'closed' || st === 'error')) {
|
||||
// The socket dropped after a healthy boot (typically sleep/wake). Try
|
||||
@@ -179,6 +223,7 @@ export function useGatewayBoot({
|
||||
scheduleReconnect()
|
||||
}
|
||||
})
|
||||
|
||||
const offEvent = gateway.onEvent(event => callbacksRef.current.handleGatewayEvent(event))
|
||||
|
||||
// Wake signals: power resume (macOS/Windows), network coming back, and the
|
||||
@@ -186,6 +231,7 @@ export function useGatewayBoot({
|
||||
const offPowerResume = desktop.onPowerResume?.(() => reconnectNow())
|
||||
|
||||
const onOnline = () => reconnectNow()
|
||||
|
||||
const onVisible = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
reconnectNow()
|
||||
@@ -195,6 +241,34 @@ export function useGatewayBoot({
|
||||
window.addEventListener('online', onOnline)
|
||||
document.addEventListener('visibilitychange', onVisible)
|
||||
|
||||
// Keep live pool backends alive while this window is open (the main process
|
||||
// can't observe the direct renderer↔backend WS). No-op for the primary.
|
||||
const keepaliveTimer = setInterval(() => {
|
||||
touchActiveGatewayBackend()
|
||||
touchSecondaryGateways()
|
||||
}, 60_000)
|
||||
|
||||
// Bound concurrency cost to live work: keep a background socket only while
|
||||
// its profile has a running (working) or blocked (needs-input) session.
|
||||
// Once that profile goes idle its socket is dropped and its backend is free
|
||||
// to idle-reap. The active profile is always spared.
|
||||
const recomputeKeptGateways = () => {
|
||||
const live = new Set([...$workingSessionIds.get(), ...$attentionSessionIds.get()])
|
||||
const keep = new Set<string>()
|
||||
|
||||
for (const session of $sessions.get()) {
|
||||
if (live.has(session.id)) {
|
||||
keep.add(normalizeProfileKey(session.profile))
|
||||
}
|
||||
}
|
||||
|
||||
pruneSecondaryGateways(keep)
|
||||
}
|
||||
|
||||
const offWorking = $workingSessionIds.subscribe(() => recomputeKeptGateways())
|
||||
const offAttention = $attentionSessionIds.subscribe(() => recomputeKeptGateways())
|
||||
const offActiveProfile = $activeGatewayProfile.subscribe(() => recomputeKeptGateways())
|
||||
|
||||
const offWindowState = desktop.onWindowStateChanged?.(payload => {
|
||||
const current = $connection.get()
|
||||
|
||||
@@ -230,12 +304,31 @@ export function useGatewayBoot({
|
||||
progress: 95
|
||||
})
|
||||
publish(conn)
|
||||
await gateway.connect(conn.wsUrl)
|
||||
// Mint a fresh WS URL right before connecting. For OAuth gateways the
|
||||
// ticket is single-use with a short TTL, so the ticket baked into
|
||||
// conn.wsUrl is stale; resolveGatewayWsUrl() re-mints it and, on
|
||||
// failure, throws a reauth error rather than connecting with a dead
|
||||
// ticket (which would surface as an opaque "connection closed").
|
||||
const wsUrl = await resolveGatewayWsUrl(desktop, conn)
|
||||
await gateway.connect(wsUrl)
|
||||
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
|
||||
// Record which profile the primary (window) backend booted as, so
|
||||
// same-profile resumes are no-op swaps and any reconnect targets the
|
||||
// right backend. Best-effort: a missing preference means "default".
|
||||
try {
|
||||
const pref = await desktop.profile?.get?.()
|
||||
const profileKey = (pref?.profile ?? '').trim() || 'default'
|
||||
$activeGatewayProfile.set(profileKey)
|
||||
setPrimaryGateway(gateway, profileKey)
|
||||
void ensureGatewayForProfile(profileKey)
|
||||
} catch {
|
||||
$activeGatewayProfile.set('default')
|
||||
}
|
||||
|
||||
setDesktopBootStep({
|
||||
phase: 'renderer.config',
|
||||
message: 'Loading Hermes settings',
|
||||
@@ -270,6 +363,10 @@ export function useGatewayBoot({
|
||||
return () => {
|
||||
cancelled = true
|
||||
clearReconnectTimer()
|
||||
clearInterval(keepaliveTimer)
|
||||
offWorking()
|
||||
offAttention()
|
||||
offActiveProfile()
|
||||
window.removeEventListener('online', onOnline)
|
||||
document.removeEventListener('visibilitychange', onVisible)
|
||||
offPowerResume?.()
|
||||
@@ -278,10 +375,12 @@ export function useGatewayBoot({
|
||||
offExit()
|
||||
offWindowState?.()
|
||||
offBootProgress()
|
||||
closeSecondaryGateways()
|
||||
gateway.close()
|
||||
publish(null)
|
||||
callbacksRef.current.onGatewayReady(null)
|
||||
setGateway(null)
|
||||
setPrimaryGateway(null)
|
||||
$gateway.set(null)
|
||||
}
|
||||
}, [])
|
||||
}
|
||||
|
||||
@@ -2,6 +2,9 @@ import { useStore } from '@nanostores/react'
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
|
||||
import type { HermesGateway } from '@/hermes'
|
||||
import { isGatewayReauthRequired, resolveGatewayWsUrl } from '@/lib/gateway-ws-url'
|
||||
import { $gateway, ensureActiveGatewayOpen, isActivePrimary } from '@/store/gateway'
|
||||
import { $activeGatewayProfile } from '@/store/profile'
|
||||
import { $gatewayState, setConnection } from '@/store/session'
|
||||
|
||||
export function useGatewayRequest() {
|
||||
@@ -14,11 +17,25 @@ export function useGatewayRequest() {
|
||||
|
||||
const gatewayStateRef = useRef(gatewayState)
|
||||
const reconnectingRef = useRef<Promise<HermesGateway | null> | null>(null)
|
||||
// Holds the reauth error from the most recent failed reconnect so
|
||||
// requestGateway can surface the gateway's "session expired, sign in again"
|
||||
// message instead of the opaque "connection closed" that triggered the retry.
|
||||
const reauthErrorRef = useRef<unknown>(null)
|
||||
|
||||
useEffect(() => {
|
||||
gatewayStateRef.current = gatewayState
|
||||
}, [gatewayState])
|
||||
|
||||
// Track the active gateway (primary or a background profile's socket) so
|
||||
// outbound requests and overlay props always target the focused profile.
|
||||
useEffect(
|
||||
() =>
|
||||
$gateway.subscribe(gateway => {
|
||||
gatewayRef.current = gateway as HermesGateway | null
|
||||
}),
|
||||
[]
|
||||
)
|
||||
|
||||
const ensureGatewayOpen = useCallback(async () => {
|
||||
const existing = gatewayRef.current
|
||||
|
||||
@@ -41,14 +58,29 @@ export function useGatewayRequest() {
|
||||
return null
|
||||
}
|
||||
|
||||
reauthErrorRef.current = null
|
||||
|
||||
try {
|
||||
const conn = await desktop.getConnection()
|
||||
// Reconnect to whichever profile the gateway is currently routed to (not
|
||||
// always the primary), so a sleep/wake reconnect keeps the user on the
|
||||
// profile they were chatting in.
|
||||
const conn = await desktop.getConnection($activeGatewayProfile.get())
|
||||
connectionRef.current = conn
|
||||
setConnection(conn)
|
||||
await existing.connect(conn.wsUrl)
|
||||
// Re-mint the WS URL before reconnecting. OAuth tickets are single-use
|
||||
// and short-lived, so the cached conn.wsUrl ticket is dead here;
|
||||
// resolveGatewayWsUrl() throws a reauth error in OAuth mode rather than
|
||||
// connecting with a stale ticket. Stash it so requestGateway can show
|
||||
// the actionable "sign in again" message.
|
||||
const wsUrl = await resolveGatewayWsUrl(desktop, conn)
|
||||
await existing.connect(wsUrl)
|
||||
|
||||
return existing
|
||||
} catch {
|
||||
} catch (error) {
|
||||
if (isGatewayReauthRequired(error)) {
|
||||
reauthErrorRef.current = error
|
||||
}
|
||||
|
||||
connectionRef.current = null
|
||||
setConnection(null)
|
||||
|
||||
@@ -78,9 +110,21 @@ export function useGatewayRequest() {
|
||||
throw error
|
||||
}
|
||||
|
||||
const recovered = await ensureGatewayOpen()
|
||||
// Primary keeps the OAuth-aware reconnect (remote gateways re-mint a
|
||||
// single-use ticket); background profiles are always local pool
|
||||
// backends, so the registry handles their reconnect with no reauth.
|
||||
const recovered = isActivePrimary() ? await ensureGatewayOpen() : await ensureActiveGatewayOpen()
|
||||
|
||||
if (!recovered) {
|
||||
// Prefer the reauth error from the failed reconnect (OAuth session
|
||||
// expired) over the generic transport error that triggered the retry.
|
||||
const reauthError = reauthErrorRef.current
|
||||
reauthErrorRef.current = null
|
||||
|
||||
if (reauthError) {
|
||||
throw reauthError
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,8 @@ import { notify, notifyError } from '@/store/notifications'
|
||||
import { useRefreshHotkey } from '../hooks/use-refresh-hotkey'
|
||||
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
|
||||
import { PageSearchShell } from '../page-search-shell'
|
||||
import { CREDENTIAL_CONTROL_CLASS } from '../settings/credential-key-ui'
|
||||
import { ListRow } from '../settings/primitives'
|
||||
import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
|
||||
|
||||
import { PlatformAvatar } from './platform-icon'
|
||||
@@ -108,6 +110,47 @@ const FIELD_COPY: Record<string, { advanced?: boolean; help?: string; label: str
|
||||
help: 'first, all, or off.',
|
||||
advanced: true
|
||||
},
|
||||
DISCORD_ALLOW_ALL_USERS: {
|
||||
label: 'Allow all Discord users',
|
||||
help: 'Development only. When true, anyone can DM the bot without an allowlist.',
|
||||
advanced: true
|
||||
},
|
||||
DISCORD_HOME_CHANNEL: {
|
||||
label: 'Home channel ID',
|
||||
help: 'Channel where the bot sends proactive messages (cron output, reminders).',
|
||||
advanced: true
|
||||
},
|
||||
DISCORD_HOME_CHANNEL_NAME: {
|
||||
label: 'Home channel name',
|
||||
help: 'Display name for the home channel in logs and status output.',
|
||||
advanced: true
|
||||
},
|
||||
BLUEBUBBLES_ALLOW_ALL_USERS: {
|
||||
label: 'Allow all iMessage users',
|
||||
help: 'When true, skip the BlueBubbles allowlist.',
|
||||
advanced: true
|
||||
},
|
||||
MATTERMOST_ALLOW_ALL_USERS: {
|
||||
label: 'Allow all Mattermost users',
|
||||
advanced: true
|
||||
},
|
||||
MATTERMOST_HOME_CHANNEL: {
|
||||
label: 'Home channel',
|
||||
advanced: true
|
||||
},
|
||||
QQ_ALLOW_ALL_USERS: {
|
||||
label: 'Allow all QQ users',
|
||||
advanced: true
|
||||
},
|
||||
QQBOT_HOME_CHANNEL: {
|
||||
label: 'QQ home channel',
|
||||
help: 'Default channel or group for cron delivery.',
|
||||
advanced: true
|
||||
},
|
||||
QQBOT_HOME_CHANNEL_NAME: {
|
||||
label: 'QQ home channel name',
|
||||
advanced: true
|
||||
},
|
||||
SLACK_BOT_TOKEN: {
|
||||
label: 'Slack bot token',
|
||||
help: 'Starts with xoxb-. Found under OAuth & Permissions after installing your Slack app.',
|
||||
@@ -497,7 +540,7 @@ function PlatformDetail({
|
||||
|
||||
<section>
|
||||
<SectionTitle>Required</SectionTitle>
|
||||
<div className="mt-3 space-y-4">
|
||||
<div className="mt-3 grid gap-1">
|
||||
{requiredFields.length > 0 ? (
|
||||
requiredFields.map(field => (
|
||||
<MessagingField
|
||||
@@ -520,7 +563,7 @@ function PlatformDetail({
|
||||
{optionalFields.length > 0 && (
|
||||
<section>
|
||||
<SectionTitle>Recommended</SectionTitle>
|
||||
<div className="mt-3 space-y-4">
|
||||
<div className="mt-3 grid gap-1">
|
||||
{optionalFields.map(field => (
|
||||
<MessagingField
|
||||
edits={edits}
|
||||
@@ -546,7 +589,7 @@ function PlatformDetail({
|
||||
<DisclosureCaret open={showAdvanced} size="0.875rem" />
|
||||
</button>
|
||||
{showAdvanced && (
|
||||
<div className="mt-3 space-y-4">
|
||||
<div className="mt-3 grid gap-1">
|
||||
{advancedFields.map(field => (
|
||||
<MessagingField
|
||||
edits={edits}
|
||||
@@ -640,45 +683,48 @@ function MessagingField({
|
||||
saving: string | null
|
||||
}) {
|
||||
const copy = fieldCopy(field)
|
||||
const fieldId = `messaging-field-${field.key}`
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex flex-wrap items-baseline gap-2">
|
||||
<label className="text-sm font-medium text-foreground" htmlFor={`messaging-field-${field.key}`}>
|
||||
{copy.label}
|
||||
</label>
|
||||
{field.is_set && <span className="text-[0.66rem] font-medium text-primary">Saved</span>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
className="font-mono"
|
||||
id={`messaging-field-${field.key}`}
|
||||
onChange={event => onEdit(field.key, event.target.value)}
|
||||
placeholder={field.is_set ? field.redacted_value || 'Replace current value' : copy.placeholder}
|
||||
type={field.is_password ? 'password' : 'text'}
|
||||
value={edits[field.key] || ''}
|
||||
/>
|
||||
{field.url && (
|
||||
<Button asChild size="icon-sm" title="Open docs" variant="ghost">
|
||||
<a href={field.url} rel="noreferrer" target="_blank">
|
||||
<ExternalLink className="size-3.5" />
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
{field.is_set && (
|
||||
<Button
|
||||
disabled={saving === `clear:${field.key}`}
|
||||
onClick={() => onClear(field.key)}
|
||||
size="icon-sm"
|
||||
title={`Clear ${field.key}`}
|
||||
variant="ghost"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{copy.help && <p className="text-xs leading-5 text-muted-foreground">{copy.help}</p>}
|
||||
</div>
|
||||
<ListRow
|
||||
action={
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
className={CREDENTIAL_CONTROL_CLASS}
|
||||
id={fieldId}
|
||||
onChange={event => onEdit(field.key, event.target.value)}
|
||||
placeholder={field.is_set ? field.redacted_value || 'Replace current value' : copy.placeholder}
|
||||
type={field.is_password ? 'password' : 'text'}
|
||||
value={edits[field.key] || ''}
|
||||
/>
|
||||
{field.url && (
|
||||
<Button asChild className="size-8 shrink-0" title="Open docs" variant="ghost">
|
||||
<a href={field.url} rel="noreferrer" target="_blank">
|
||||
<ExternalLink className="size-3.5" />
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
{field.is_set && (
|
||||
<Button
|
||||
className="size-8 shrink-0"
|
||||
disabled={saving === `clear:${field.key}`}
|
||||
onClick={() => onClear(field.key)}
|
||||
title={`Clear ${field.key}`}
|
||||
variant="ghost"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
description={copy.help}
|
||||
title={
|
||||
<span className="flex flex-wrap items-center gap-2">
|
||||
<label htmlFor={fieldId}>{copy.label}</label>
|
||||
{field.is_set && <span className="text-[0.66rem] font-medium text-primary">Saved</span>}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import type { ComponentType, SVGProps } from 'react'
|
||||
|
||||
import {
|
||||
SiApple,
|
||||
SiBilibili,
|
||||
@@ -14,6 +12,7 @@ import {
|
||||
SiWechat,
|
||||
SiWhatsapp
|
||||
} from '@icons-pack/react-simple-icons'
|
||||
import type { ComponentType, SVGProps } from 'react'
|
||||
|
||||
import { Globe, Link as LinkIcon, MessageSquareText } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -69,10 +68,7 @@ export function PlatformAvatar({ className, platformId, platformName }: Platform
|
||||
|
||||
if (!spec) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(baseClass, 'bg-(--ui-bg-tertiary) text-(--ui-text-tertiary)')}
|
||||
>
|
||||
<span aria-hidden="true" className={cn(baseClass, 'bg-(--ui-bg-tertiary) text-(--ui-text-tertiary)')}>
|
||||
{platformName.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
)
|
||||
|
||||
@@ -24,6 +24,9 @@ interface OverlayNavItemProps {
|
||||
active: boolean
|
||||
icon: IconComponent
|
||||
label: string
|
||||
// Renders as an indented child of another nav item: smaller icon and a
|
||||
// lighter active state so it never competes with the boxed parent item.
|
||||
nested?: boolean
|
||||
onClick: () => void
|
||||
trailing?: ReactNode
|
||||
}
|
||||
@@ -70,19 +73,29 @@ export function OverlayMain({ children, className }: OverlayMainProps) {
|
||||
)
|
||||
}
|
||||
|
||||
export function OverlayNavItem({ active, icon: Icon, label, onClick, trailing }: OverlayNavItemProps) {
|
||||
export function OverlayNavItem({ active, icon: Icon, label, nested, onClick, trailing }: OverlayNavItemProps) {
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'flex h-7 w-full items-center justify-start gap-2 rounded-md border px-2 text-left text-[length:var(--conversation-text-font-size)] font-normal transition-colors',
|
||||
active
|
||||
? 'border-(--ui-stroke-tertiary) bg-(--ui-bg-tertiary) text-foreground'
|
||||
: 'border-transparent bg-transparent text-(--ui-text-secondary) hover:bg-(--chrome-action-hover) hover:text-foreground'
|
||||
nested
|
||||
? active
|
||||
? 'border-transparent bg-(--chrome-action-hover) font-medium text-foreground'
|
||||
: 'border-transparent bg-transparent text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground'
|
||||
: active
|
||||
? 'border-(--ui-stroke-tertiary) bg-(--ui-bg-tertiary) text-foreground'
|
||||
: 'border-transparent bg-transparent text-(--ui-text-secondary) hover:bg-(--chrome-action-hover) hover:text-foreground'
|
||||
)}
|
||||
onClick={onClick}
|
||||
type="button"
|
||||
>
|
||||
<Icon className={cn('size-4 shrink-0', active ? 'text-foreground/80' : 'text-muted-foreground/80')} />
|
||||
<Icon
|
||||
className={cn(
|
||||
'shrink-0',
|
||||
nested ? 'size-3.5' : 'size-4',
|
||||
active ? 'text-foreground/80' : 'text-muted-foreground/80'
|
||||
)}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate">{label}</span>
|
||||
{trailing}
|
||||
</button>
|
||||
|
||||
@@ -51,9 +51,7 @@ export function PageSearchShell({
|
||||
<div className="shrink-0">
|
||||
{(tabs || !searchHidden) && (
|
||||
<div className="flex items-center gap-3 px-3 pb-2 pt-[calc(var(--titlebar-height)+0.5rem)]">
|
||||
{tabs ? (
|
||||
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-x-2 gap-y-1">{tabs}</div>
|
||||
) : null}
|
||||
{tabs ? <div className="flex min-w-0 flex-1 flex-wrap items-center gap-x-2 gap-y-1">{tabs}</div> : null}
|
||||
{!searchHidden && (
|
||||
<div className={cn('flex shrink-0 items-center', !tabs && 'flex-1')}>
|
||||
<SearchField
|
||||
@@ -66,9 +64,7 @@ export function PageSearchShell({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{filters ? (
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 px-3 pb-2">{filters}</div>
|
||||
) : null}
|
||||
{filters ? <div className="flex flex-wrap items-center gap-x-2 gap-y-1 px-3 pb-2">{filters}</div> : null}
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-hidden bg-(--ui-chat-surface-background)">{children}</div>
|
||||
</section>
|
||||
|
||||
158
apps/desktop/src/app/profiles/create-profile-dialog.tsx
Normal file
158
apps/desktop/src/app/profiles/create-profile-dialog.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { ActionStatus } from '@/components/ui/action-status'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { createProfile, updateProfileSoul } from '@/hermes'
|
||||
import { AlertTriangle } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const PROFILE_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/
|
||||
|
||||
export const PROFILE_NAME_HINT =
|
||||
'Lowercase letters, digits, hyphens, and underscores. Must start with a letter or digit.'
|
||||
|
||||
export function isValidProfileName(name: string): boolean {
|
||||
return PROFILE_NAME_RE.test(name.trim())
|
||||
}
|
||||
|
||||
// Self-contained create flow (name + clone toggle + optional SOUL.md). Owns the
|
||||
// createProfile/updateProfileSoul calls so every caller just refreshes/selects
|
||||
// via onCreated. SOUL left blank keeps the cloned/blank persona untouched.
|
||||
export function CreateProfileDialog({
|
||||
onClose,
|
||||
onCreated,
|
||||
open
|
||||
}: {
|
||||
onClose: () => void
|
||||
onCreated?: (name: string) => Promise<void> | void
|
||||
open: boolean
|
||||
}) {
|
||||
const [name, setName] = useState('')
|
||||
const [cloneFromDefault, setCloneFromDefault] = useState(true)
|
||||
const [soul, setSoul] = useState('')
|
||||
const [status, setStatus] = useState<'done' | 'idle' | 'saving'>('idle')
|
||||
const [error, setError] = useState<null | string>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return
|
||||
}
|
||||
|
||||
setName('')
|
||||
setCloneFromDefault(true)
|
||||
setSoul('')
|
||||
setError(null)
|
||||
setStatus('idle')
|
||||
}, [open])
|
||||
|
||||
const trimmed = name.trim()
|
||||
const invalid = trimmed !== '' && !isValidProfileName(trimmed)
|
||||
const busy = status === 'saving' || status === 'done'
|
||||
|
||||
async function handleSubmit(event: React.FormEvent) {
|
||||
event.preventDefault()
|
||||
|
||||
if (!trimmed || invalid) {
|
||||
setError(invalid ? `Invalid name. ${PROFILE_NAME_HINT}` : 'Name is required.')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
setStatus('saving')
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
await createProfile({ name: trimmed, clone_from_default: cloneFromDefault })
|
||||
|
||||
if (soul.trim()) {
|
||||
await updateProfileSoul(trimmed, soul)
|
||||
}
|
||||
|
||||
await onCreated?.(trimmed)
|
||||
setStatus('done')
|
||||
window.setTimeout(onClose, 800)
|
||||
} catch (err) {
|
||||
setStatus('idle')
|
||||
setError(err instanceof Error ? err.message : 'Failed to create profile')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={value => !value && !busy && onClose()} open={open}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>New profile</DialogTitle>
|
||||
<DialogDescription>
|
||||
Profiles are independent Hermes environments: separate config, skills, and SOUL.md.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form className="grid gap-4" onSubmit={handleSubmit}>
|
||||
<div className="grid gap-1.5">
|
||||
<label className="text-xs font-medium" htmlFor="new-profile-name">
|
||||
Name
|
||||
</label>
|
||||
<Input
|
||||
aria-invalid={invalid}
|
||||
autoFocus
|
||||
id="new-profile-name"
|
||||
onChange={event => setName(event.target.value)}
|
||||
placeholder="my-profile"
|
||||
value={name}
|
||||
/>
|
||||
<p className={cn('text-[0.66rem] leading-4', invalid ? 'text-destructive' : 'text-muted-foreground')}>
|
||||
{PROFILE_NAME_HINT}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label className="flex cursor-pointer select-none items-start gap-2.5 px-0.5 py-1">
|
||||
<Checkbox
|
||||
checked={cloneFromDefault}
|
||||
className="mt-0.5 shrink-0"
|
||||
onCheckedChange={checked => setCloneFromDefault(checked === true)}
|
||||
/>
|
||||
<span className="grid gap-0.5 leading-snug">
|
||||
<span className="text-sm font-medium">Clone from default</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Copy config, skills, and SOUL.md from your default profile.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div className="grid gap-1.5">
|
||||
<label className="text-xs font-medium" htmlFor="new-profile-soul">
|
||||
SOUL.md <span className="font-normal text-muted-foreground">— optional</span>
|
||||
</label>
|
||||
<Textarea
|
||||
className="min-h-28 font-mono text-xs leading-5"
|
||||
id="new-profile-soul"
|
||||
onChange={event => setSoul(event.target.value)}
|
||||
placeholder={`The system prompt / persona for this profile.\nLeave blank to keep the ${cloneFromDefault ? 'cloned' : 'empty'} default.`}
|
||||
value={soul}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button disabled={busy} onClick={onClose} type="button" variant="ghost">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button disabled={busy || !trimmed || invalid} type="submit">
|
||||
<ActionStatus busy="Creating…" done="Created" idle="Create profile" state={status} />
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
58
apps/desktop/src/app/profiles/delete-profile-dialog.tsx
Normal file
58
apps/desktop/src/app/profiles/delete-profile-dialog.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
|
||||
import { deleteProfile } from '@/hermes'
|
||||
import { $activeGatewayProfile, normalizeProfileKey, selectProfile, setActiveProfile } from '@/store/profile'
|
||||
|
||||
// Thin wrapper over ConfirmDialog: owns the deleteProfile call, inherits
|
||||
// Enter-to-confirm + busy/done/error from the shared dialog. The single choke
|
||||
// point for every delete entry point (rail + Profiles view).
|
||||
export function DeleteProfileDialog({
|
||||
profile,
|
||||
onClose,
|
||||
onDeleted,
|
||||
open
|
||||
}: {
|
||||
profile: { name: string; path: string } | null
|
||||
onClose: () => void
|
||||
onDeleted?: () => Promise<void> | void
|
||||
open: boolean
|
||||
}) {
|
||||
return (
|
||||
<ConfirmDialog
|
||||
busyLabel="Deleting…"
|
||||
confirmLabel="Delete"
|
||||
description={
|
||||
profile ? (
|
||||
<>
|
||||
This will delete <span className="font-medium text-foreground">{profile.name}</span> and remove its{' '}
|
||||
<span className="font-mono text-xs">{profile.path}</span> directory. This cannot be undone.
|
||||
</>
|
||||
) : null
|
||||
}
|
||||
destructive
|
||||
doneLabel="Deleted"
|
||||
onClose={onClose}
|
||||
onConfirm={async () => {
|
||||
if (!profile) {
|
||||
return
|
||||
}
|
||||
|
||||
// Deleting the profile the live gateway is on strands it on a dead
|
||||
// backend. Capture that before the delete; reset *after* the host's
|
||||
// onDeleted refresh so our reset is the last write — a refreshActiveProfile
|
||||
// racing the (still-dying) backend can't clobber the pill back to it.
|
||||
const wasActive = normalizeProfileKey(profile.name) === normalizeProfileKey($activeGatewayProfile.get())
|
||||
await deleteProfile(profile.name)
|
||||
await onDeleted?.()
|
||||
|
||||
if (wasActive) {
|
||||
// Swap gateway/sidebar to default and set the pill now — the primary
|
||||
// backend is always default, so this is correct, not just optimistic.
|
||||
selectProfile('default')
|
||||
setActiveProfile('default')
|
||||
}
|
||||
}}
|
||||
open={open}
|
||||
title="Delete profile?"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,45 +1,57 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { PageLoader } from '@/components/page-loader'
|
||||
import { ActionStatus } from '@/components/ui/action-status'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
createProfile,
|
||||
deleteProfile,
|
||||
getProfiles,
|
||||
getProfileSetupCommand,
|
||||
getProfileSoul,
|
||||
type ProfileInfo,
|
||||
renameProfile,
|
||||
updateProfileSoul
|
||||
} from '@/hermes'
|
||||
import { AlertTriangle, Pencil, Save, Terminal, Trash2, Users } from '@/lib/icons'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { createProfile, getProfiles, getProfileSoul, type ProfileInfo, updateProfileSoul } from '@/hermes'
|
||||
import { AlertTriangle, Save, Users } from '@/lib/icons'
|
||||
import { profileColor } from '@/lib/profile-color'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import { $activeProfile, switchProfile } from '@/store/profile'
|
||||
|
||||
import { useRefreshHotkey } from '../hooks/use-refresh-hotkey'
|
||||
import { OverlayMain, OverlaySidebar, OverlaySplitLayout } from '../overlays/overlay-split-layout'
|
||||
import { OverlayView } from '../overlays/overlay-view'
|
||||
|
||||
const PROFILE_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/
|
||||
import { CreateProfileDialog } from './create-profile-dialog'
|
||||
import { DeleteProfileDialog } from './delete-profile-dialog'
|
||||
import { RenameProfileDialog } from './rename-profile-dialog'
|
||||
|
||||
const PROFILE_NAME_HINT = 'Lowercase letters, digits, hyphens, and underscores. Must start with a letter or digit.'
|
||||
// Pick a free "<source>-copy" name for a duplicated profile, appending a numeric
|
||||
// suffix when the base is taken. Source is truncated to leave room for the
|
||||
// suffix and to stay within the 64-char profile-name limit.
|
||||
function uniqueCloneName(source: string, existing: Set<string>): string {
|
||||
const base = `${source}-copy`.slice(0, 58)
|
||||
|
||||
function isValidProfileName(name: string): boolean {
|
||||
return PROFILE_NAME_RE.test(name.trim())
|
||||
if (!existing.has(base)) {
|
||||
return base
|
||||
}
|
||||
|
||||
for (let i = 2; i < 1000; i++) {
|
||||
const candidate = `${base}-${i}`
|
||||
|
||||
if (!existing.has(candidate)) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
return `${base}-${Date.now()}`
|
||||
}
|
||||
|
||||
// Three-state affordance shared by every save/create/rename/delete button:
|
||||
// spinner while pending, a check on success, then back to the idle icon+label.
|
||||
interface ProfilesViewProps {
|
||||
onClose: () => void
|
||||
}
|
||||
@@ -48,13 +60,15 @@ export function ProfilesView({ onClose }: ProfilesViewProps) {
|
||||
const [profiles, setProfiles] = useState<null | ProfileInfo[]>(null)
|
||||
const [selectedName, setSelectedName] = useState<null | string>(null)
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [pendingRename, setPendingRename] = useState<null | ProfileInfo>(null)
|
||||
const [pendingDelete, setPendingDelete] = useState<null | ProfileInfo>(null)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const [loadError, setLoadError] = useState<null | string>(null)
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const { profiles: list } = await getProfiles()
|
||||
setProfiles(list)
|
||||
setLoadError(null)
|
||||
setSelectedName(current => {
|
||||
if (current && list.some(p => p.name === current)) {
|
||||
return current
|
||||
@@ -63,7 +77,8 @@ export function ProfilesView({ onClose }: ProfilesViewProps) {
|
||||
return list.find(p => p.is_default)?.name ?? list[0]?.name ?? null
|
||||
})
|
||||
} catch (err) {
|
||||
notifyError(err, 'Failed to load profiles')
|
||||
setLoadError(err instanceof Error ? err.message : 'Failed to load profiles')
|
||||
setProfiles(prev => prev ?? [])
|
||||
}
|
||||
}, [])
|
||||
|
||||
@@ -81,61 +96,31 @@ export function ProfilesView({ onClose }: ProfilesViewProps) {
|
||||
return profiles.find(p => p.name === selectedName) ?? profiles[0] ?? null
|
||||
}, [profiles, selectedName])
|
||||
|
||||
const handleCreate = useCallback(
|
||||
async (name: string, cloneFromDefault: boolean) => {
|
||||
const trimmed = name.trim()
|
||||
const handleClone = useCallback(
|
||||
async (source: ProfileInfo) => {
|
||||
const existing = new Set((profiles ?? []).map(p => p.name))
|
||||
const target = uniqueCloneName(source.name, existing)
|
||||
|
||||
if (!isValidProfileName(trimmed)) {
|
||||
throw new Error(PROFILE_NAME_HINT)
|
||||
try {
|
||||
await createProfile({ name: target, clone_from: source.name })
|
||||
setSelectedName(target)
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
setLoadError(err instanceof Error ? err.message : `Failed to duplicate ${source.name}`)
|
||||
}
|
||||
|
||||
await createProfile({ name: trimmed, clone_from_default: cloneFromDefault })
|
||||
notify({ kind: 'success', title: 'Profile created', message: trimmed })
|
||||
setSelectedName(trimmed)
|
||||
await refresh()
|
||||
},
|
||||
[refresh]
|
||||
[profiles, refresh]
|
||||
)
|
||||
|
||||
const handleRename = useCallback(
|
||||
async (from: string, to: string): Promise<void> => {
|
||||
const target = to.trim()
|
||||
|
||||
if (target === from) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!isValidProfileName(target)) {
|
||||
throw new Error(PROFILE_NAME_HINT)
|
||||
}
|
||||
|
||||
await renameProfile(from, target)
|
||||
notify({ kind: 'success', title: 'Profile renamed', message: `${from} → ${target}` })
|
||||
setSelectedName(target)
|
||||
await refresh()
|
||||
},
|
||||
[refresh]
|
||||
)
|
||||
|
||||
const handleConfirmDelete = useCallback(async () => {
|
||||
if (!pendingDelete) {
|
||||
return
|
||||
}
|
||||
|
||||
setDeleting(true)
|
||||
|
||||
const handleMakeDefault = useCallback(async (profile: ProfileInfo) => {
|
||||
try {
|
||||
await deleteProfile(pendingDelete.name)
|
||||
notify({ kind: 'success', title: 'Profile deleted', message: pendingDelete.name })
|
||||
setPendingDelete(null)
|
||||
setSelectedName(null)
|
||||
await refresh()
|
||||
// Relaunches the backend under this profile's HERMES_HOME and reloads the
|
||||
// window, so control normally doesn't return here.
|
||||
await switchProfile(profile.name)
|
||||
} catch (err) {
|
||||
notifyError(err, 'Failed to delete profile')
|
||||
} finally {
|
||||
setDeleting(false)
|
||||
setLoadError(err instanceof Error ? err.message : `Failed to switch to ${profile.name}`)
|
||||
}
|
||||
}, [pendingDelete, refresh])
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<OverlayView closeLabel="Close profiles" onClose={onClose}>
|
||||
@@ -158,27 +143,30 @@ export function ProfilesView({ onClose }: ProfilesViewProps) {
|
||||
<Codicon name="add" size="0.875rem" />
|
||||
</Button>
|
||||
</div>
|
||||
{loadError && (
|
||||
<div className="mb-1 flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-2 py-1.5 text-[0.66rem] text-destructive">
|
||||
<AlertTriangle className="mt-0.5 size-3 shrink-0" />
|
||||
<span>{loadError}</span>
|
||||
</div>
|
||||
)}
|
||||
{profiles.map(profile => (
|
||||
<ProfileRow
|
||||
active={selected?.name === profile.name}
|
||||
key={profile.name}
|
||||
onClone={() => void handleClone(profile)}
|
||||
onDelete={() => setPendingDelete(profile)}
|
||||
onMakeDefault={() => void handleMakeDefault(profile)}
|
||||
onRename={() => setPendingRename(profile)}
|
||||
onSelect={() => setSelectedName(profile.name)}
|
||||
profile={profile}
|
||||
/>
|
||||
))}
|
||||
{profiles.length === 0 && (
|
||||
<p className="px-1.5 py-3 text-xs text-muted-foreground">No profiles yet.</p>
|
||||
)}
|
||||
{profiles.length === 0 && <p className="px-1.5 py-3 text-xs text-muted-foreground">No profiles yet.</p>}
|
||||
</OverlaySidebar>
|
||||
|
||||
<OverlayMain className="px-0">
|
||||
{selected ? (
|
||||
<ProfileDetail
|
||||
key={selected.name}
|
||||
onDelete={() => setPendingDelete(selected)}
|
||||
onRename={newName => handleRename(selected.name, newName)}
|
||||
profile={selected}
|
||||
/>
|
||||
<ProfileDetail key={selected.name} profile={selected} />
|
||||
) : (
|
||||
<div className="grid h-full place-items-center px-6 py-12 text-center text-sm text-muted-foreground">
|
||||
<div>
|
||||
@@ -193,126 +181,181 @@ export function ProfilesView({ onClose }: ProfilesViewProps) {
|
||||
|
||||
<CreateProfileDialog
|
||||
onClose={() => setCreateOpen(false)}
|
||||
onCreate={async (name, cloneFromDefault) => handleCreate(name, cloneFromDefault)}
|
||||
onCreated={async name => {
|
||||
setSelectedName(name)
|
||||
await refresh()
|
||||
}}
|
||||
open={createOpen}
|
||||
/>
|
||||
|
||||
<Dialog onOpenChange={open => !open && !deleting && setPendingDelete(null)} open={pendingDelete !== null}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete profile?</DialogTitle>
|
||||
<DialogDescription>
|
||||
{pendingDelete ? (
|
||||
<>
|
||||
This will delete <span className="font-medium text-foreground">{pendingDelete.name}</span> and remove
|
||||
its <span className="font-mono text-xs">{pendingDelete.path}</span> directory. This cannot be undone.
|
||||
</>
|
||||
) : null}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button disabled={deleting} onClick={() => setPendingDelete(null)} variant="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button disabled={deleting} onClick={() => void handleConfirmDelete()} variant="destructive">
|
||||
{deleting ? 'Deleting...' : 'Delete'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<RenameProfileDialog
|
||||
currentName={pendingRename?.name ?? ''}
|
||||
onClose={() => setPendingRename(null)}
|
||||
onRenamed={async name => {
|
||||
setSelectedName(name)
|
||||
await refresh()
|
||||
}}
|
||||
open={pendingRename !== null}
|
||||
/>
|
||||
|
||||
<DeleteProfileDialog
|
||||
onClose={() => setPendingDelete(null)}
|
||||
onDeleted={async () => {
|
||||
setSelectedName(null)
|
||||
await refresh()
|
||||
}}
|
||||
open={pendingDelete !== null}
|
||||
profile={pendingDelete}
|
||||
/>
|
||||
</OverlayView>
|
||||
)
|
||||
}
|
||||
|
||||
function ProfileRow({ active, onSelect, profile }: { active: boolean; onSelect: () => void; profile: ProfileInfo }) {
|
||||
function ProfileRow({
|
||||
active,
|
||||
onClone,
|
||||
onDelete,
|
||||
onMakeDefault,
|
||||
onRename,
|
||||
onSelect,
|
||||
profile
|
||||
}: {
|
||||
active: boolean
|
||||
onClone: () => void
|
||||
onDelete: () => void
|
||||
onMakeDefault: () => void
|
||||
onRename: () => void
|
||||
onSelect: () => void
|
||||
profile: ProfileInfo
|
||||
}) {
|
||||
const running = useStore($activeProfile)
|
||||
const isRunning = profile.name === running
|
||||
|
||||
return (
|
||||
<button
|
||||
<div
|
||||
className={cn(
|
||||
'flex w-full flex-col items-start gap-0.5 rounded-md px-2 py-1.5 text-left transition-colors',
|
||||
'group relative flex items-center rounded-md border transition-colors',
|
||||
active
|
||||
? 'bg-(--ui-row-active-background) text-foreground'
|
||||
: 'text-(--ui-text-secondary) hover:bg-(--ui-row-hover-background) hover:text-foreground'
|
||||
? 'border-(--ui-stroke-tertiary) bg-(--ui-bg-tertiary)'
|
||||
: 'border-transparent hover:bg-(--chrome-action-hover)'
|
||||
)}
|
||||
onClick={onSelect}
|
||||
type="button"
|
||||
>
|
||||
<span className="flex w-full items-center justify-between gap-2">
|
||||
<span className="truncate text-sm font-medium">{profile.name}</span>
|
||||
{profile.is_default && <span className="text-[0.6rem] text-primary">default</span>}
|
||||
</span>
|
||||
<span className="text-[0.66rem] text-muted-foreground">
|
||||
{profile.skill_count} {profile.skill_count === 1 ? 'skill' : 'skills'}
|
||||
{profile.has_env ? ' · env' : ''}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
className={cn(
|
||||
'flex min-w-0 flex-1 flex-col items-start gap-0.5 rounded-md px-2 py-1.5 text-left text-[length:var(--conversation-text-font-size)] transition-colors',
|
||||
active ? 'text-foreground' : 'text-(--ui-text-secondary) group-hover:text-foreground'
|
||||
)}
|
||||
onClick={onSelect}
|
||||
type="button"
|
||||
>
|
||||
<span className="flex w-full items-center gap-1.5 pr-6">
|
||||
{profile.is_default ? null : (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="size-2 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: profileColor(profile.name) ?? 'var(--ui-text-quaternary)' }}
|
||||
/>
|
||||
)}
|
||||
<span className="truncate text-sm font-medium">{profile.name}</span>
|
||||
{isRunning && (
|
||||
<Tip label="Current default profile">
|
||||
<Codicon className="shrink-0 text-(--ui-accent)" name="pass-filled" size="0.75rem" />
|
||||
</Tip>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-[0.66rem] text-muted-foreground">
|
||||
{isRunning ? 'default · ' : ''}
|
||||
{profile.skill_count} {profile.skill_count === 1 ? 'skill' : 'skills'}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<ProfileActionsMenu
|
||||
isRunning={isRunning}
|
||||
onClone={onClone}
|
||||
onDelete={onDelete}
|
||||
onMakeDefault={onMakeDefault}
|
||||
onRename={onRename}
|
||||
profile={profile}
|
||||
>
|
||||
<Button
|
||||
aria-label={`Actions for ${profile.name}`}
|
||||
className="absolute right-1 top-1 size-6 bg-transparent text-(--ui-text-tertiary) opacity-0 transition-opacity hover:bg-(--ui-control-active-background) hover:text-foreground focus-visible:opacity-100 group-hover:opacity-100 data-[state=open]:bg-(--ui-control-active-background) data-[state=open]:text-foreground data-[state=open]:opacity-100"
|
||||
size="icon-xs"
|
||||
title="Profile actions"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="ellipsis" size="0.875rem" />
|
||||
</Button>
|
||||
</ProfileActionsMenu>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ProfileDetail({
|
||||
function ProfileActionsMenu({
|
||||
children,
|
||||
isRunning,
|
||||
onClone,
|
||||
onDelete,
|
||||
onMakeDefault,
|
||||
onRename,
|
||||
profile
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
isRunning: boolean
|
||||
onClone: () => void
|
||||
onDelete: () => void
|
||||
onRename: (newName: string) => Promise<void>
|
||||
onMakeDefault: () => void
|
||||
onRename: () => void
|
||||
profile: ProfileInfo
|
||||
}) {
|
||||
const [renameOpen, setRenameOpen] = useState(false)
|
||||
const [copying, setCopying] = useState(false)
|
||||
|
||||
const handleCopySetup = useCallback(async () => {
|
||||
setCopying(true)
|
||||
|
||||
try {
|
||||
const { command } = await getProfileSetupCommand(profile.name)
|
||||
await navigator.clipboard.writeText(command)
|
||||
notify({ kind: 'success', title: 'Setup command copied', message: command })
|
||||
} catch (err) {
|
||||
notifyError(err, 'Failed to copy setup command')
|
||||
} finally {
|
||||
setCopying(false)
|
||||
}
|
||||
}, [profile.name])
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" aria-label={`Actions for ${profile.name}`} className="w-44" sideOffset={6}>
|
||||
<DropdownMenuItem disabled={isRunning} onSelect={onMakeDefault}>
|
||||
<Codicon name="pass" size="0.875rem" />
|
||||
<span>{isRunning ? 'Current default' : 'Make default'}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
{!profile.is_default && (
|
||||
<DropdownMenuItem onSelect={onRename}>
|
||||
<Codicon name="edit" size="0.875rem" />
|
||||
<span>Rename</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem onSelect={onClone}>
|
||||
<Codicon name="copy" size="0.875rem" />
|
||||
<span>Duplicate</span>
|
||||
</DropdownMenuItem>
|
||||
{!profile.is_default && (
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onSelect={onDelete}
|
||||
variant="destructive"
|
||||
>
|
||||
<Codicon name="trash" size="0.875rem" />
|
||||
<span>Delete</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
function ProfileDetail({ profile }: { profile: ProfileInfo }) {
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
<div className="mx-auto max-w-2xl space-y-6 px-6 py-6">
|
||||
<header className="space-y-3">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="text-xl font-semibold tracking-tight">{profile.name}</h3>
|
||||
{profile.is_default && <Badge>Default</Badge>}
|
||||
{profile.has_env && <Badge variant="muted">.env</Badge>}
|
||||
</div>
|
||||
<p className="mt-1 font-mono text-[0.7rem] text-muted-foreground" title={profile.path}>
|
||||
{profile.path}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-3">
|
||||
{!profile.is_default && (
|
||||
<Button onClick={() => setRenameOpen(true)} size="sm" variant="text">
|
||||
<Pencil />
|
||||
Rename
|
||||
</Button>
|
||||
)}
|
||||
<Button disabled={copying} onClick={() => void handleCopySetup()} size="sm" variant="text">
|
||||
<Terminal />
|
||||
{copying ? 'Copying...' : 'Copy setup'}
|
||||
</Button>
|
||||
{!profile.is_default && (
|
||||
<Button
|
||||
className="hover:text-destructive hover:no-underline"
|
||||
onClick={onDelete}
|
||||
size="sm"
|
||||
variant="text"
|
||||
>
|
||||
<Trash2 />
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="text-xl font-semibold tracking-tight">{profile.name}</h3>
|
||||
{profile.is_default && <Badge>Default</Badge>}
|
||||
</div>
|
||||
<Tip label={profile.path}>
|
||||
<p className="mt-1 font-mono text-[0.7rem] text-muted-foreground">{profile.path}</p>
|
||||
</Tip>
|
||||
</div>
|
||||
|
||||
<dl className="grid gap-2 text-xs sm:grid-cols-2">
|
||||
@@ -333,16 +376,6 @@ function ProfileDetail({
|
||||
<SoulEditor profileName={profile.name} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RenameProfileDialog
|
||||
currentName={profile.name}
|
||||
onClose={() => setRenameOpen(false)}
|
||||
onRename={async newName => {
|
||||
await onRename(newName)
|
||||
setRenameOpen(false)
|
||||
}}
|
||||
open={renameOpen}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -360,14 +393,16 @@ function SoulEditor({ profileName }: { profileName: string }) {
|
||||
const [content, setContent] = useState('')
|
||||
const [original, setOriginal] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [status, setStatus] = useState<'idle' | 'saved' | 'saving'>('idle')
|
||||
const [error, setError] = useState<null | string>(null)
|
||||
const requestRef = useRef<string>(profileName)
|
||||
const savedTimerRef = useRef<null | number>(null)
|
||||
|
||||
useEffect(() => {
|
||||
requestRef.current = profileName
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
setStatus('idle')
|
||||
setContent('')
|
||||
setOriginal('')
|
||||
|
||||
@@ -391,21 +426,37 @@ function SoulEditor({ profileName }: { profileName: string }) {
|
||||
})()
|
||||
}, [profileName])
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (savedTimerRef.current !== null) {
|
||||
window.clearTimeout(savedTimerRef.current)
|
||||
}
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const dirty = content !== original
|
||||
const isEmpty = !content.trim()
|
||||
const saving = status === 'saving'
|
||||
|
||||
async function handleSave() {
|
||||
setSaving(true)
|
||||
setStatus('saving')
|
||||
setError(null)
|
||||
|
||||
if (savedTimerRef.current !== null) {
|
||||
window.clearTimeout(savedTimerRef.current)
|
||||
}
|
||||
|
||||
try {
|
||||
await updateProfileSoul(profileName, content)
|
||||
setOriginal(content)
|
||||
notify({ kind: 'success', title: 'SOUL.md saved', message: profileName })
|
||||
setStatus('saved')
|
||||
savedTimerRef.current = window.setTimeout(() => {
|
||||
setStatus(current => (current === 'saved' ? 'idle' : current))
|
||||
}, 2200)
|
||||
} catch (err) {
|
||||
setStatus('idle')
|
||||
setError(err instanceof Error ? err.message : 'Failed to save SOUL.md')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -440,230 +491,17 @@ function SoulEditor({ profileName }: { profileName: string }) {
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button disabled={!dirty || saving || loading} onClick={() => void handleSave()} size="sm">
|
||||
<Save />
|
||||
{saving ? 'Saving...' : 'Save SOUL.md'}
|
||||
<Button disabled={loading || saving || !dirty} onClick={() => void handleSave()} size="sm">
|
||||
<ActionStatus
|
||||
busy="Saving…"
|
||||
done="Saved"
|
||||
idle="Save SOUL.md"
|
||||
idleIcon={<Save />}
|
||||
state={saving ? 'saving' : status === 'saved' && !dirty ? 'done' : 'idle'}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function CreateProfileDialog({
|
||||
onClose,
|
||||
onCreate,
|
||||
open
|
||||
}: {
|
||||
onClose: () => void
|
||||
onCreate: (name: string, cloneFromDefault: boolean) => Promise<void>
|
||||
open: boolean
|
||||
}) {
|
||||
const [name, setName] = useState('')
|
||||
const [cloneFromDefault, setCloneFromDefault] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<null | string>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return
|
||||
}
|
||||
|
||||
setName('')
|
||||
setCloneFromDefault(true)
|
||||
setError(null)
|
||||
setSaving(false)
|
||||
}, [open])
|
||||
|
||||
const trimmed = name.trim()
|
||||
const invalid = trimmed !== '' && !isValidProfileName(trimmed)
|
||||
|
||||
async function handleSubmit(event: React.FormEvent) {
|
||||
event.preventDefault()
|
||||
|
||||
if (!trimmed || invalid) {
|
||||
setError(invalid ? `Invalid name. ${PROFILE_NAME_HINT}` : 'Name is required.')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
await onCreate(trimmed, cloneFromDefault)
|
||||
onClose()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to create profile')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={value => !value && !saving && onClose()} open={open}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>New profile</DialogTitle>
|
||||
<DialogDescription>
|
||||
Profiles are independent Hermes environments: separate config, skills, and SOUL.md.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form className="grid gap-4" onSubmit={handleSubmit}>
|
||||
<div className="grid gap-1.5">
|
||||
<label className="text-xs font-medium" htmlFor="new-profile-name">
|
||||
Name
|
||||
</label>
|
||||
<Input
|
||||
aria-invalid={invalid}
|
||||
autoFocus
|
||||
id="new-profile-name"
|
||||
onChange={event => setName(event.target.value)}
|
||||
placeholder="my-profile"
|
||||
value={name}
|
||||
/>
|
||||
<p className={cn('text-[0.66rem] leading-4', invalid ? 'text-destructive' : 'text-muted-foreground')}>
|
||||
{PROFILE_NAME_HINT}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label className="flex cursor-pointer items-center gap-2 rounded-md border border-border/40 bg-background/50 px-3 py-2 text-sm">
|
||||
<input
|
||||
checked={cloneFromDefault}
|
||||
className="size-4 accent-primary"
|
||||
onChange={event => setCloneFromDefault(event.target.checked)}
|
||||
type="checkbox"
|
||||
/>
|
||||
<span>
|
||||
<span className="font-medium">Clone from default</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
Copy config, skills, and SOUL.md from your default profile.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button disabled={saving} onClick={onClose} type="button" variant="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button disabled={saving || !trimmed || invalid} type="submit">
|
||||
{saving ? 'Creating...' : 'Create profile'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function RenameProfileDialog({
|
||||
currentName,
|
||||
onClose,
|
||||
onRename,
|
||||
open
|
||||
}: {
|
||||
currentName: string
|
||||
onClose: () => void
|
||||
onRename: (newName: string) => Promise<void>
|
||||
open: boolean
|
||||
}) {
|
||||
const [name, setName] = useState(currentName)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<null | string>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return
|
||||
}
|
||||
|
||||
setName(currentName)
|
||||
setError(null)
|
||||
setSaving(false)
|
||||
}, [currentName, open])
|
||||
|
||||
const trimmed = name.trim()
|
||||
const unchanged = trimmed === currentName
|
||||
const invalid = trimmed !== '' && !unchanged && !isValidProfileName(trimmed)
|
||||
|
||||
async function handleSubmit(event: React.FormEvent) {
|
||||
event.preventDefault()
|
||||
|
||||
if (unchanged) {
|
||||
onClose()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (!trimmed || invalid) {
|
||||
setError(invalid ? `Invalid name. ${PROFILE_NAME_HINT}` : 'Name is required.')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
await onRename(trimmed)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to rename profile')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={value => !value && !saving && onClose()} open={open}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Rename profile</DialogTitle>
|
||||
<DialogDescription>
|
||||
Renaming updates the profile directory and any wrapper scripts in{' '}
|
||||
<span className="font-mono">~/.local/bin</span>.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form className="grid gap-3" onSubmit={handleSubmit}>
|
||||
<div className="grid gap-1.5">
|
||||
<label className="text-xs font-medium" htmlFor="rename-profile-name">
|
||||
New name
|
||||
</label>
|
||||
<Input
|
||||
aria-invalid={invalid}
|
||||
autoFocus
|
||||
id="rename-profile-name"
|
||||
onChange={event => setName(event.target.value)}
|
||||
value={name}
|
||||
/>
|
||||
<p className={cn('text-[0.66rem] leading-4', invalid ? 'text-destructive' : 'text-muted-foreground')}>
|
||||
{PROFILE_NAME_HINT}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button disabled={saving} onClick={onClose} type="button" variant="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button disabled={saving || invalid || unchanged} type="submit">
|
||||
{saving ? 'Renaming...' : 'Rename'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
121
apps/desktop/src/app/profiles/rename-profile-dialog.tsx
Normal file
121
apps/desktop/src/app/profiles/rename-profile-dialog.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { ActionStatus } from '@/components/ui/action-status'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { renameProfile } from '@/hermes'
|
||||
import { AlertTriangle } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { isValidProfileName, PROFILE_NAME_HINT } from './create-profile-dialog'
|
||||
|
||||
// Self-contained rename (owns the renameProfile call) so every caller just
|
||||
// reacts via onRenamed. Unchanged name is a no-op close.
|
||||
export function RenameProfileDialog({
|
||||
currentName,
|
||||
onClose,
|
||||
onRenamed,
|
||||
open
|
||||
}: {
|
||||
currentName: string
|
||||
onClose: () => void
|
||||
onRenamed?: (name: string) => Promise<void> | void
|
||||
open: boolean
|
||||
}) {
|
||||
const [name, setName] = useState(currentName)
|
||||
const [status, setStatus] = useState<'done' | 'idle' | 'saving'>('idle')
|
||||
const [error, setError] = useState<null | string>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return
|
||||
}
|
||||
|
||||
setName(currentName)
|
||||
setError(null)
|
||||
setStatus('idle')
|
||||
}, [currentName, open])
|
||||
|
||||
const trimmed = name.trim()
|
||||
const unchanged = trimmed === currentName
|
||||
const invalid = trimmed !== '' && !unchanged && !isValidProfileName(trimmed)
|
||||
const busy = status === 'saving' || status === 'done'
|
||||
|
||||
async function handleSubmit(event: React.FormEvent) {
|
||||
event.preventDefault()
|
||||
|
||||
if (unchanged) {
|
||||
onClose()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (!trimmed || invalid) {
|
||||
setError(invalid ? `Invalid name. ${PROFILE_NAME_HINT}` : 'Name is required.')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
setStatus('saving')
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
await renameProfile(currentName, trimmed)
|
||||
await onRenamed?.(trimmed)
|
||||
setStatus('done')
|
||||
window.setTimeout(onClose, 800)
|
||||
} catch (err) {
|
||||
setStatus('idle')
|
||||
setError(err instanceof Error ? err.message : 'Failed to rename profile')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={value => !value && !busy && onClose()} open={open}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Rename profile</DialogTitle>
|
||||
<DialogDescription>
|
||||
Renaming updates the profile directory and any wrapper scripts in{' '}
|
||||
<span className="font-mono">~/.local/bin</span>.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form className="grid gap-3" onSubmit={handleSubmit}>
|
||||
<div className="grid gap-1.5">
|
||||
<label className="text-xs font-medium" htmlFor="rename-profile-name">
|
||||
New name
|
||||
</label>
|
||||
<Input
|
||||
aria-invalid={invalid}
|
||||
autoFocus
|
||||
id="rename-profile-name"
|
||||
onChange={event => setName(event.target.value)}
|
||||
value={name}
|
||||
/>
|
||||
<p className={cn('text-[0.66rem] leading-4', invalid ? 'text-destructive' : 'text-muted-foreground')}>
|
||||
{PROFILE_NAME_HINT}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button disabled={busy} onClick={onClose} type="button" variant="ghost">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button disabled={busy || invalid || unchanged} type="submit">
|
||||
<ActionStatus busy="Renaming…" done="Renamed" idle="Rename" state={status} />
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { ErrorBoundary } from '@/components/error-boundary'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { Loader } from '@/components/ui/loader'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $panesFlipped } from '@/store/layout'
|
||||
@@ -148,21 +149,21 @@ function RightSidebarChrome({
|
||||
<div className="flex items-center gap-2 px-2.5 py-1">
|
||||
<nav aria-label="Right sidebar panels" className="flex min-w-0 items-center gap-1">
|
||||
{tabs.map(tab => (
|
||||
<Button
|
||||
aria-label={tab.label}
|
||||
aria-pressed={tab.id === activeTab}
|
||||
className={cn(
|
||||
'text-(--ui-text-tertiary) hover:bg-(--ui-control-hover-background) hover:text-foreground',
|
||||
tab.id === activeTab && 'bg-(--ui-control-active-background) text-foreground'
|
||||
)}
|
||||
key={tab.id}
|
||||
onClick={() => setRightSidebarTab(tab.id)}
|
||||
size="icon-xs"
|
||||
title={tab.label}
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name={tab.icon} size="0.875rem" />
|
||||
</Button>
|
||||
<Tip key={tab.id} label={tab.label}>
|
||||
<Button
|
||||
aria-label={tab.label}
|
||||
aria-pressed={tab.id === activeTab}
|
||||
className={cn(
|
||||
'text-(--ui-text-tertiary) hover:bg-(--ui-control-hover-background) hover:text-foreground',
|
||||
tab.id === activeTab && 'bg-(--ui-control-active-background) text-foreground'
|
||||
)}
|
||||
onClick={() => setRightSidebarTab(tab.id)}
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name={tab.icon} size="0.875rem" />
|
||||
</Button>
|
||||
</Tip>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
@@ -216,21 +217,21 @@ function FilesystemTab({
|
||||
return (
|
||||
<div className="group/project-header flex min-h-0 flex-1 flex-col">
|
||||
<RightSidebarSectionHeader>
|
||||
<button
|
||||
className="flex min-w-0 flex-1 items-center rounded-md text-left hover:text-(--ui-text-secondary)"
|
||||
onClick={() => void onChangeFolder()}
|
||||
title={hasCwd ? `${cwd} — click to change folder` : 'Open a folder'}
|
||||
type="button"
|
||||
>
|
||||
<SidebarPanelLabel>{cwdName}</SidebarPanelLabel>
|
||||
</button>
|
||||
<Tip label={hasCwd ? `${cwd} — click to change folder` : 'Open a folder'}>
|
||||
<button
|
||||
className="flex min-w-0 flex-1 items-center rounded-md text-left hover:text-(--ui-text-secondary)"
|
||||
onClick={() => void onChangeFolder()}
|
||||
type="button"
|
||||
>
|
||||
<SidebarPanelLabel>{cwdName}</SidebarPanelLabel>
|
||||
</button>
|
||||
</Tip>
|
||||
<Button
|
||||
aria-label="Refresh tree"
|
||||
className={HEADER_ACTION_CLASS}
|
||||
disabled={!hasCwd || loading}
|
||||
onClick={onRefresh}
|
||||
size="icon-xs"
|
||||
title="Refresh tree"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="refresh" size="0.8125rem" spinning={loading} />
|
||||
@@ -240,7 +241,6 @@ function FilesystemTab({
|
||||
className={HEADER_ACTION_CLASS}
|
||||
onClick={() => void onChangeFolder()}
|
||||
size="icon-xs"
|
||||
title={hasCwd ? 'Open a different folder' : 'Open a folder'}
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="folder-opened" size="0.8125rem" />
|
||||
@@ -251,7 +251,6 @@ function FilesystemTab({
|
||||
disabled={!hasCwd || !canCollapse}
|
||||
onClick={onCollapseAll}
|
||||
size="icon-xs"
|
||||
title="Collapse all folders"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="collapse-all" size="0.8125rem" />
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useStore } from '@nanostores/react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { Loader } from '@/components/ui/loader'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
|
||||
import { SidebarPanelLabel } from '../../shell/sidebar-label'
|
||||
import { $terminalTakeover, setRightSidebarTab, setTerminalTakeover } from '../store'
|
||||
@@ -31,6 +32,7 @@ export function TerminalTab({ cwd, onAddSelectionToChat }: TerminalTabProps) {
|
||||
if (takeover) {
|
||||
setRightSidebarTab('terminal')
|
||||
}
|
||||
|
||||
setTerminalTakeover(!takeover)
|
||||
}
|
||||
|
||||
@@ -38,17 +40,18 @@ export function TerminalTab({ cwd, onAddSelectionToChat }: TerminalTabProps) {
|
||||
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col">
|
||||
<div className="flex h-8 shrink-0 items-center gap-2 px-2.5">
|
||||
<SidebarPanelLabel className="text-white!">{shellName}</SidebarPanelLabel>
|
||||
<Button
|
||||
aria-label={label}
|
||||
className="ml-auto size-6 rounded-md text-white!"
|
||||
onClick={toggleTakeover}
|
||||
size="icon"
|
||||
title={label}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name={takeover ? 'screen-normal' : 'screen-full'} size="0.875rem" />
|
||||
</Button>
|
||||
<Tip label={label}>
|
||||
<Button
|
||||
aria-label={label}
|
||||
className="ml-auto size-6 rounded-md text-white!"
|
||||
onClick={toggleTakeover}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name={takeover ? 'screen-normal' : 'screen-full'} size="0.875rem" />
|
||||
</Button>
|
||||
</Tip>
|
||||
</div>
|
||||
<div className="relative min-h-0 flex-1 bg-[#002b36] p-2">
|
||||
{status === 'starting' && (
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { atom } from 'nanostores'
|
||||
import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties } from 'react'
|
||||
import { type CSSProperties, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
|
||||
import { TERMINAL_BG } from './selection'
|
||||
|
||||
import { TerminalTab } from './index'
|
||||
import { TERMINAL_BG } from './selection'
|
||||
|
||||
/**
|
||||
* One xterm Terminal mounted at the layout root and CSS-overlayed onto
|
||||
@@ -21,11 +22,17 @@ export function TerminalSlot({ className = SLOT_CLASS }: { className?: string })
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current
|
||||
if (!el) return
|
||||
|
||||
if (!el) {
|
||||
return
|
||||
}
|
||||
|
||||
$slot.set(el)
|
||||
|
||||
return () => {
|
||||
if ($slot.get() === el) $slot.set(null)
|
||||
if ($slot.get() === el) {
|
||||
$slot.set(null)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
@@ -55,6 +62,7 @@ export function PersistentTerminal({ cwd, onAddSelectionToChat }: PersistentTerm
|
||||
useLayoutEffect(() => {
|
||||
if (!slot) {
|
||||
setRect(null)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -72,13 +80,17 @@ export function PersistentTerminal({ cwd, onAddSelectionToChat }: PersistentTerm
|
||||
if (!sameRect(prev, next)) {
|
||||
prev = next
|
||||
setRect(next)
|
||||
if (next.width > 0 && next.height > 0) setReady(true)
|
||||
|
||||
if (next.width > 0 && next.height > 0) {
|
||||
setReady(true)
|
||||
}
|
||||
}
|
||||
|
||||
frame = requestAnimationFrame(tick)
|
||||
}
|
||||
|
||||
tick()
|
||||
|
||||
return () => cancelAnimationFrame(frame)
|
||||
}, [slot])
|
||||
|
||||
|
||||
@@ -96,11 +96,18 @@ interface UseTerminalSessionOptions {
|
||||
}
|
||||
|
||||
function transferHasDropCandidates(t: DataTransfer): boolean {
|
||||
if (t.types?.includes(HERMES_PATHS_MIME)) return true
|
||||
if ((t.files?.length ?? 0) > 0) return true
|
||||
if (t.types?.includes(HERMES_PATHS_MIME)) {
|
||||
return true
|
||||
}
|
||||
|
||||
if ((t.files?.length ?? 0) > 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
for (let i = 0; i < (t.items?.length ?? 0); i += 1) {
|
||||
if (t.items[i]?.kind === 'file') return true
|
||||
if (t.items[i]?.kind === 'file') {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
@@ -108,22 +115,38 @@ function transferHasDropCandidates(t: DataTransfer): boolean {
|
||||
|
||||
function collectDroppedPaths(t: DataTransfer): string[] {
|
||||
const seen = new Set<string>()
|
||||
|
||||
const push = (value: unknown) => {
|
||||
if (typeof value !== 'string') return
|
||||
if (typeof value !== 'string') {
|
||||
return
|
||||
}
|
||||
|
||||
const path = value.trim()
|
||||
if (path) seen.add(path)
|
||||
|
||||
if (path) {
|
||||
seen.add(path)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = t.getData(HERMES_PATHS_MIME)
|
||||
if (raw) for (const entry of JSON.parse(raw) as { path?: unknown }[]) push(entry?.path)
|
||||
|
||||
if (raw) {
|
||||
for (const entry of JSON.parse(raw) as { path?: unknown }[]) {
|
||||
push(entry?.path)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Malformed in-app drag payload — fall through to OS files.
|
||||
}
|
||||
|
||||
const getPath = window.hermesDesktop?.getPathForFile
|
||||
|
||||
const addFile = (file: File | null) => {
|
||||
if (!file || !getPath) return
|
||||
if (!file || !getPath) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
push(getPath(file))
|
||||
} catch {
|
||||
@@ -131,10 +154,16 @@ function collectDroppedPaths(t: DataTransfer): string[] {
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < (t.files?.length ?? 0); i += 1) addFile(t.files.item(i))
|
||||
for (let i = 0; i < (t.files?.length ?? 0); i += 1) {
|
||||
addFile(t.files.item(i))
|
||||
}
|
||||
|
||||
for (let i = 0; i < (t.items?.length ?? 0); i += 1) {
|
||||
const item = t.items[i]
|
||||
if (item?.kind === 'file') addFile(item.getAsFile())
|
||||
|
||||
if (item?.kind === 'file') {
|
||||
addFile(item.getAsFile())
|
||||
}
|
||||
}
|
||||
|
||||
return [...seen]
|
||||
@@ -142,8 +171,15 @@ function collectDroppedPaths(t: DataTransfer): string[] {
|
||||
|
||||
function quotePathForShell(path: string, shellName: string): string {
|
||||
const shell = shellName.toLowerCase()
|
||||
if (shell.includes('powershell') || shell.includes('pwsh')) return `'${path.replace(/'/g, "''")}'`
|
||||
if (shell.includes('cmd')) return `"${path.replace(/"/g, '""')}"`
|
||||
|
||||
if (shell.includes('powershell') || shell.includes('pwsh')) {
|
||||
return `'${path.replace(/'/g, "''")}'`
|
||||
}
|
||||
|
||||
if (shell.includes('cmd')) {
|
||||
return `"${path.replace(/"/g, '""')}"`
|
||||
}
|
||||
|
||||
return `'${path.replace(/'/g, "'\\''")}'`
|
||||
}
|
||||
|
||||
@@ -250,12 +286,14 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
|
||||
webgl.onContextLoss(() => webgl.dispose())
|
||||
term.loadAddon(webgl)
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[hermes-terminal] WebGL unavailable; falling back to DOM', err)
|
||||
}
|
||||
|
||||
const onDragOver = (e: DragEvent) => {
|
||||
if (!e.dataTransfer || !transferHasDropCandidates(e.dataTransfer)) return
|
||||
if (!e.dataTransfer || !transferHasDropCandidates(e.dataTransfer)) {
|
||||
return
|
||||
}
|
||||
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
e.dataTransfer.dropEffect = 'copy'
|
||||
@@ -263,11 +301,19 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
|
||||
|
||||
const onDrop = (e: DragEvent) => {
|
||||
const id = sessionIdRef.current
|
||||
if (!id || !e.dataTransfer || !transferHasDropCandidates(e.dataTransfer)) return
|
||||
|
||||
if (!id || !e.dataTransfer || !transferHasDropCandidates(e.dataTransfer)) {
|
||||
return
|
||||
}
|
||||
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const paths = collectDroppedPaths(e.dataTransfer)
|
||||
if (!paths.length) return
|
||||
|
||||
if (!paths.length) {
|
||||
return
|
||||
}
|
||||
|
||||
void terminalApi.write(id, `${paths.map(p => quotePathForShell(p, shellNameRef.current)).join(' ')} `)
|
||||
term.focus()
|
||||
triggerHaptic('selection')
|
||||
@@ -305,11 +351,18 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
|
||||
// synchronously while sibling panes are mid-transition (e.g. file browser
|
||||
// collapsing to 0px) crashes the WebGL renderer mid texture-atlas rebuild.
|
||||
let pendingFrame = 0
|
||||
|
||||
const scheduleResize = () => {
|
||||
if (pendingFrame) return
|
||||
if (pendingFrame) {
|
||||
return
|
||||
}
|
||||
|
||||
pendingFrame = window.requestAnimationFrame(() => {
|
||||
pendingFrame = 0
|
||||
if (!disposed) fitAndResize()
|
||||
|
||||
if (!disposed) {
|
||||
fitAndResize()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -317,7 +370,10 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
|
||||
resizeObserver.observe(host)
|
||||
cleanup.push(() => {
|
||||
resizeObserver.disconnect()
|
||||
if (pendingFrame) window.cancelAnimationFrame(pendingFrame)
|
||||
|
||||
if (pendingFrame) {
|
||||
window.cancelAnimationFrame(pendingFrame)
|
||||
}
|
||||
})
|
||||
|
||||
const dataDisposable = term.onData(data => {
|
||||
|
||||
@@ -55,13 +55,7 @@ const RESERVED_PATHS: ReadonlySet<string> = new Set(APP_ROUTES.map(route => rout
|
||||
// Views that render as a full-screen modal card (OverlayView) over the shell.
|
||||
// While one is open the app's titlebar control clusters must hide so they don't
|
||||
// bleed over the overlay (they sit at a higher z-index than the overlay card).
|
||||
export const OVERLAY_VIEWS: ReadonlySet<AppView> = new Set([
|
||||
'agents',
|
||||
'command-center',
|
||||
'cron',
|
||||
'profiles',
|
||||
'settings'
|
||||
])
|
||||
export const OVERLAY_VIEWS: ReadonlySet<AppView> = new Set(['agents', 'command-center', 'cron', 'profiles', 'settings'])
|
||||
|
||||
export function isOverlayView(view: AppView): boolean {
|
||||
return OVERLAY_VIEWS.has(view)
|
||||
|
||||
@@ -326,10 +326,7 @@ export function useMessageStream({
|
||||
return
|
||||
}
|
||||
|
||||
flushHandleRef.current = window.setTimeout(
|
||||
runFlush,
|
||||
Math.max(0, STREAM_DELTA_FLUSH_MS - sinceLast)
|
||||
)
|
||||
flushHandleRef.current = window.setTimeout(runFlush, Math.max(0, STREAM_DELTA_FLUSH_MS - sinceLast))
|
||||
}, [flushQueuedDeltas])
|
||||
|
||||
const queueDelta = useCallback(
|
||||
@@ -755,12 +752,11 @@ export function useMessageStream({
|
||||
return
|
||||
}
|
||||
|
||||
// Turn ended — drop any blocking prompt that's still open (e.g. the
|
||||
// agent was interrupted, or the approval already resolved). Prevents a
|
||||
// stale overlay from outliving the turn that raised it.
|
||||
if (isActiveEvent) {
|
||||
clearAllPrompts()
|
||||
}
|
||||
// Turn ended — drop any blocking prompt still open for THIS session
|
||||
// (e.g. interrupted, or the approval already resolved). Scoped to the
|
||||
// session so a background turn finishing can't wipe the active chat's
|
||||
// prompt, and vice versa.
|
||||
clearAllPrompts(sessionId)
|
||||
|
||||
flushQueuedDeltas(sessionId)
|
||||
|
||||
@@ -845,37 +841,34 @@ export function useMessageStream({
|
||||
}
|
||||
}
|
||||
} else if (event.type === 'approval.request') {
|
||||
if (!isActiveEvent) {
|
||||
return
|
||||
}
|
||||
|
||||
// Dangerous-command / execute_code approval. The Python side is
|
||||
// blocked in _await_gateway_decision() until approval.respond lands;
|
||||
// without this the agent stalls until its 5-min timeout and the tool
|
||||
// is BLOCKED. Approval is session-keyed (no request_id) — the overlay
|
||||
// sends back {choice, session_id}.
|
||||
// Dangerous-command / execute_code approval. The Python side is blocked
|
||||
// in _await_gateway_decision() until approval.respond lands; without
|
||||
// this the agent stalls until its 5-min timeout and the tool is BLOCKED.
|
||||
// Park it per-session (like clarify) so a *background* profile's turn can
|
||||
// raise it and wait — the sidebar flags "needs input" and the inline bar
|
||||
// surfaces once the user focuses that chat.
|
||||
setApprovalRequest({
|
||||
command: typeof payload?.command === 'string' ? payload.command : '',
|
||||
description: typeof payload?.description === 'string' ? payload.description : 'dangerous command',
|
||||
sessionId: sessionId ?? null
|
||||
})
|
||||
} else if (event.type === 'sudo.request') {
|
||||
if (!isActiveEvent) {
|
||||
return
|
||||
}
|
||||
|
||||
if (sessionId) {
|
||||
updateSessionState(sessionId, state => ({ ...state, needsInput: true }))
|
||||
}
|
||||
} else if (event.type === 'sudo.request') {
|
||||
// Sudo password capture (tools/terminal_tool.py). Blocked on
|
||||
// sudo.respond {request_id, password}.
|
||||
const requestId = typeof payload?.request_id === 'string' ? payload.request_id : ''
|
||||
|
||||
if (requestId) {
|
||||
setSudoRequest({ requestId })
|
||||
setSudoRequest({ requestId, sessionId: sessionId ?? null })
|
||||
|
||||
if (sessionId) {
|
||||
updateSessionState(sessionId, state => ({ ...state, needsInput: true }))
|
||||
}
|
||||
}
|
||||
} else if (event.type === 'secret.request') {
|
||||
if (!isActiveEvent) {
|
||||
return
|
||||
}
|
||||
|
||||
// Skill credential capture (tools/skills_tool.py). Blocked on
|
||||
// secret.respond {request_id, value}.
|
||||
const requestId = typeof payload?.request_id === 'string' ? payload.request_id : ''
|
||||
@@ -884,18 +877,23 @@ export function useMessageStream({
|
||||
setSecretRequest({
|
||||
requestId,
|
||||
envVar: typeof payload?.env_var === 'string' ? payload.env_var : '',
|
||||
prompt: typeof payload?.prompt === 'string' ? payload.prompt : ''
|
||||
prompt: typeof payload?.prompt === 'string' ? payload.prompt : '',
|
||||
sessionId: sessionId ?? null
|
||||
})
|
||||
|
||||
if (sessionId) {
|
||||
updateSessionState(sessionId, state => ({ ...state, needsInput: true }))
|
||||
}
|
||||
}
|
||||
} else if (event.type === 'error') {
|
||||
const errorMessage = payload?.message || 'Hermes reported an error'
|
||||
const looksLikeProviderSetup = isProviderSetupErrorMessage(errorMessage)
|
||||
|
||||
// A turn that errors out has also ended — drop any open blocking
|
||||
// prompt so an approval/sudo/secret overlay can't linger past the
|
||||
// failed turn (same intent as the message.complete clear).
|
||||
if (isActiveEvent) {
|
||||
clearAllPrompts()
|
||||
// A turn that errors out has also ended — drop any open blocking prompt
|
||||
// for this session so an approval/sudo/secret overlay can't linger past
|
||||
// the failed turn (same intent as the message.complete clear).
|
||||
if (sessionId) {
|
||||
clearAllPrompts(sessionId)
|
||||
}
|
||||
|
||||
if (looksLikeProviderSetup) {
|
||||
|
||||
166
apps/desktop/src/app/session/hooks/use-prompt-actions.test.tsx
Normal file
166
apps/desktop/src/app/session/hooks/use-prompt-actions.test.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
import { cleanup, render } from '@testing-library/react'
|
||||
import type { MutableRefObject } from 'react'
|
||||
import { useEffect } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { $sessions, setSessions } from '@/store/session'
|
||||
import type { SessionInfo } from '@/types/hermes'
|
||||
|
||||
import { usePromptActions } from './use-prompt-actions'
|
||||
|
||||
vi.mock('@/hermes', () => ({
|
||||
transcribeAudio: vi.fn()
|
||||
}))
|
||||
|
||||
// The active id the desktop holds is the *runtime* session id from
|
||||
// session.create — deliberately distinct from the stored DB id here, because
|
||||
// that mismatch is the bug: the REST renameSession endpoint resolves against
|
||||
// the stored sessions table and 404s on a runtime id. session.title accepts
|
||||
// the runtime id directly.
|
||||
const RUNTIME_SESSION_ID = 'rt-abc123'
|
||||
|
||||
function sessionInfo(overrides: Partial<SessionInfo> = {}): SessionInfo {
|
||||
return {
|
||||
ended_at: null,
|
||||
id: RUNTIME_SESSION_ID,
|
||||
input_tokens: 0,
|
||||
is_active: true,
|
||||
last_active: 0,
|
||||
message_count: 3,
|
||||
model: null,
|
||||
output_tokens: 0,
|
||||
preview: null,
|
||||
source: null,
|
||||
started_at: 0,
|
||||
title: 'Old title',
|
||||
tool_call_count: 0,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
interface HarnessHandle {
|
||||
submitText: (text: string) => Promise<boolean>
|
||||
}
|
||||
|
||||
function Harness({
|
||||
onReady,
|
||||
refreshSessions,
|
||||
requestGateway
|
||||
}: {
|
||||
onReady: (handle: HarnessHandle) => void
|
||||
refreshSessions: () => Promise<void>
|
||||
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
|
||||
}) {
|
||||
const activeSessionIdRef: MutableRefObject<string | null> = { current: RUNTIME_SESSION_ID }
|
||||
const selectedStoredSessionIdRef: MutableRefObject<string | null> = { current: RUNTIME_SESSION_ID }
|
||||
const busyRef = { current: false }
|
||||
|
||||
const actions = usePromptActions({
|
||||
activeSessionId: RUNTIME_SESSION_ID,
|
||||
activeSessionIdRef,
|
||||
branchCurrentSession: async () => true,
|
||||
busyRef,
|
||||
createBackendSessionForSend: async () => RUNTIME_SESSION_ID,
|
||||
handleSkinCommand: () => '',
|
||||
refreshSessions,
|
||||
requestGateway,
|
||||
selectedStoredSessionIdRef,
|
||||
startFreshSessionDraft: () => undefined,
|
||||
sttEnabled: false,
|
||||
updateSessionState: (_sessionId, updater) =>
|
||||
updater({ messages: [], busy: false, awaitingResponse: false } as never)
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
onReady({ submitText: actions.submitText })
|
||||
}, [actions.submitText, onReady])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
describe('usePromptActions /title', () => {
|
||||
beforeEach(() => {
|
||||
setSessions(() => [sessionInfo()])
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('renames via the session.title RPC (with the runtime id), updates the sidebar store, and refreshes', async () => {
|
||||
const refreshSessions = vi.fn(async () => undefined)
|
||||
const requestGateway = vi.fn(async (method: string) =>
|
||||
(method === 'session.title' ? { pending: false, title: 'New title' } : {}) as never
|
||||
)
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
render(<Harness onReady={h => (handle = h)} refreshSessions={refreshSessions} requestGateway={requestGateway} />)
|
||||
|
||||
await handle!.submitText('/title New title')
|
||||
|
||||
// Routes through session.title with the runtime session id — NOT the slash
|
||||
// worker (slash.exec) and NOT the REST endpoint. This is the path that
|
||||
// resolves the runtime id and persists reliably across platforms.
|
||||
expect(requestGateway).toHaveBeenCalledWith('session.title', {
|
||||
session_id: RUNTIME_SESSION_ID,
|
||||
title: 'New title'
|
||||
})
|
||||
expect(requestGateway).not.toHaveBeenCalledWith('slash.exec', expect.anything())
|
||||
expect(refreshSessions).toHaveBeenCalledTimes(1)
|
||||
expect($sessions.get()[0]?.title).toBe('New title')
|
||||
})
|
||||
|
||||
it('reports the queued state when the session row is not persisted yet', async () => {
|
||||
const refreshSessions = vi.fn(async () => undefined)
|
||||
const requestGateway = vi.fn(async (method: string) =>
|
||||
(method === 'session.title' ? { pending: true, title: 'Fresh chat' } : {}) as never
|
||||
)
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
render(<Harness onReady={h => (handle = h)} refreshSessions={refreshSessions} requestGateway={requestGateway} />)
|
||||
|
||||
await handle!.submitText('/title Fresh chat')
|
||||
|
||||
expect(requestGateway).toHaveBeenCalledWith('session.title', {
|
||||
session_id: RUNTIME_SESSION_ID,
|
||||
title: 'Fresh chat'
|
||||
})
|
||||
// Even when queued, the sidebar reflects the chosen title optimistically.
|
||||
expect(refreshSessions).toHaveBeenCalledTimes(1)
|
||||
expect($sessions.get()[0]?.title).toBe('Fresh chat')
|
||||
})
|
||||
|
||||
it('falls through to the slash worker for a bare /title (show current title)', async () => {
|
||||
const refreshSessions = vi.fn(async () => undefined)
|
||||
const requestGateway = vi.fn(async () => ({ output: 'Title: Old title' }) as never)
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
render(<Harness onReady={h => (handle = h)} refreshSessions={refreshSessions} requestGateway={requestGateway} />)
|
||||
|
||||
await handle!.submitText('/title')
|
||||
|
||||
expect(requestGateway).not.toHaveBeenCalledWith('session.title', expect.anything())
|
||||
expect(requestGateway).toHaveBeenCalledWith('slash.exec', expect.objectContaining({ command: 'title' }))
|
||||
})
|
||||
|
||||
it('surfaces a rename error without touching the sidebar store', async () => {
|
||||
const refreshSessions = vi.fn(async () => undefined)
|
||||
const requestGateway = vi.fn(async (method: string) => {
|
||||
if (method === 'session.title') {
|
||||
throw new Error('Title too long')
|
||||
}
|
||||
|
||||
return {} as never
|
||||
})
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
render(<Harness onReady={h => (handle = h)} refreshSessions={refreshSessions} requestGateway={requestGateway} />)
|
||||
|
||||
await handle!.submitText('/title way too long title')
|
||||
|
||||
expect(requestGateway).toHaveBeenCalledWith('session.title', expect.objectContaining({ title: 'way too long title' }))
|
||||
expect(refreshSessions).not.toHaveBeenCalled()
|
||||
expect($sessions.get()[0]?.title).toBe('Old title')
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { AppendMessage, ThreadMessage } from '@assistant-ui/react'
|
||||
import { type MutableRefObject, useCallback } from 'react'
|
||||
|
||||
import { transcribeAudio } from '@/hermes'
|
||||
import { getProfiles, transcribeAudio } from '@/hermes'
|
||||
import { appendTextPart, branchGroupForUser, type ChatMessage, chatMessageText, textPart } from '@/lib/chat-messages'
|
||||
import {
|
||||
attachmentDisplayText,
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
isDesktopSlashCommand
|
||||
} from '@/lib/desktop-slash-commands'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { setMutableRef } from '@/lib/mutable-ref'
|
||||
import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors'
|
||||
import { setSessionYolo } from '@/lib/yolo-session'
|
||||
import {
|
||||
@@ -29,6 +30,7 @@ import {
|
||||
} from '@/store/composer'
|
||||
import { clearNotifications, notify, notifyError } from '@/store/notifications'
|
||||
import { requestDesktopOnboarding } from '@/store/onboarding'
|
||||
import { $activeGatewayProfile, $newChatProfile, ensureGatewayProfile, normalizeProfileKey } from '@/store/profile'
|
||||
import {
|
||||
$busy,
|
||||
$messages,
|
||||
@@ -36,10 +38,11 @@ import {
|
||||
setAwaitingResponse,
|
||||
setBusy,
|
||||
setMessages,
|
||||
setSessions,
|
||||
setYoloActive
|
||||
} from '@/store/session'
|
||||
|
||||
import type { ClientSessionState, ImageAttachResponse, SlashExecResponse } from '../../types'
|
||||
import type { ClientSessionState, ImageAttachResponse, SessionTitleResponse, SlashExecResponse } from '../../types'
|
||||
|
||||
function blobToDataUrl(blob: Blob): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -76,6 +79,7 @@ interface PromptActionsOptions {
|
||||
branchCurrentSession: () => Promise<boolean>
|
||||
createBackendSessionForSend: (preview?: string | null) => Promise<string | null>
|
||||
handleSkinCommand: (arg: string) => string
|
||||
refreshSessions: () => Promise<void>
|
||||
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
|
||||
selectedStoredSessionIdRef: MutableRefObject<string | null>
|
||||
startFreshSessionDraft: () => void
|
||||
@@ -140,6 +144,7 @@ export function usePromptActions({
|
||||
branchCurrentSession,
|
||||
createBackendSessionForSend,
|
||||
handleSkinCommand,
|
||||
refreshSessions,
|
||||
requestGateway,
|
||||
selectedStoredSessionIdRef,
|
||||
startFreshSessionDraft,
|
||||
@@ -246,7 +251,7 @@ export function usePromptActions({
|
||||
}
|
||||
|
||||
const releaseBusy = () => {
|
||||
busyRef.current = false
|
||||
setMutableRef(busyRef, false)
|
||||
setBusy(false)
|
||||
setAwaitingResponse(false)
|
||||
}
|
||||
@@ -290,7 +295,7 @@ export function usePromptActions({
|
||||
)
|
||||
}
|
||||
|
||||
busyRef.current = true
|
||||
setMutableRef(busyRef, true)
|
||||
setBusy(true)
|
||||
setAwaitingResponse(true)
|
||||
clearNotifications()
|
||||
@@ -439,6 +444,51 @@ export function usePromptActions({
|
||||
return
|
||||
}
|
||||
|
||||
// /profile selects which profile new chats open in — no app relaunch.
|
||||
// A profile is per-session now, so an existing thread can't change its
|
||||
// profile mid-stream; `/profile <name>` instead points the next new chat
|
||||
// (and the current empty draft) at that profile's backend.
|
||||
if (normalizedName === 'profile') {
|
||||
const target = arg.trim()
|
||||
const current = normalizeProfileKey($activeGatewayProfile.get())
|
||||
|
||||
if (!target) {
|
||||
notify({
|
||||
kind: 'success',
|
||||
message: `Profile: ${current}. Use /profile <name> or the "New session" picker to start a chat in another profile.`
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const { profiles } = await getProfiles()
|
||||
const match = profiles.find(profile => profile.name === target)
|
||||
|
||||
if (!match) {
|
||||
notify({
|
||||
kind: 'error',
|
||||
title: 'Unknown profile',
|
||||
message: `No profile named "${target}". Available: ${profiles.map(profile => profile.name).join(', ')}`
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const key = normalizeProfileKey(match.name)
|
||||
|
||||
$newChatProfile.set(key)
|
||||
// Swap the live gateway now so an empty draft sends into this
|
||||
// profile immediately; an existing thread keeps its own profile.
|
||||
await ensureGatewayProfile(key)
|
||||
notify({ kind: 'success', message: `New chats will use profile ${match.name}.` })
|
||||
} catch (err) {
|
||||
notifyError(err, 'Failed to set profile')
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const sessionId = sessionHint || activeSessionIdRef.current || (await createBackendSessionForSend())
|
||||
|
||||
if (!sessionId) {
|
||||
@@ -454,6 +504,50 @@ export function usePromptActions({
|
||||
const renderSlashOutput = (text: string) =>
|
||||
appendSessionTextMessage(sessionId, 'system', recordInput ? slashStatusText(command, text) : text)
|
||||
|
||||
// /title <name> renames the session. Route through the gateway's
|
||||
// `session.title` RPC — the same path the TUI uses — NOT the REST
|
||||
// renameSession endpoint and NOT the slash worker.
|
||||
//
|
||||
// Why not the slash worker: it's a separate HermesCLI subprocess whose
|
||||
// SQLite write to the shared state.db can silently fail (notably on
|
||||
// Windows), and it never refreshes the sidebar.
|
||||
//
|
||||
// Why not REST renameSession: `sessionId` here is the *runtime* session
|
||||
// id returned by session.create — it is NOT the stored DB `sessions.id`,
|
||||
// and session.create deliberately does not persist a DB row until the
|
||||
// first turn. The REST PATCH endpoint resolves against the sessions
|
||||
// table, so a runtime id (or a brand-new, not-yet-persisted session)
|
||||
// 404s with "Session not found" on every platform. See #38508 / #38576.
|
||||
//
|
||||
// session.title maps the runtime id to the in-memory session, writes
|
||||
// through the gateway's own DB connection, and QUEUES the title
|
||||
// (`pending: true`) when the row isn't persisted yet — so it works for a
|
||||
// fresh chat too. refreshSessions() then pulls the authoritative title
|
||||
// back into the sidebar. A bare `/title` (no arg) still falls through to
|
||||
// the worker to display the current title.
|
||||
if (normalizedName === 'title' && arg) {
|
||||
try {
|
||||
const result = await requestGateway<SessionTitleResponse>('session.title', {
|
||||
session_id: sessionId,
|
||||
title: arg
|
||||
})
|
||||
const finalTitle = (result?.title || arg).trim()
|
||||
const queued = result?.pending === true
|
||||
|
||||
setSessions(prev => prev.map(s => (s.id === sessionId ? { ...s, title: finalTitle || null } : s)))
|
||||
await refreshSessions().catch(() => undefined)
|
||||
renderSlashOutput(
|
||||
finalTitle
|
||||
? `Session title set: ${finalTitle}${queued ? ' (queued while session initializes)' : ''}`
|
||||
: 'Session title cleared.'
|
||||
)
|
||||
} catch (err) {
|
||||
renderSlashOutput(`error: ${err instanceof Error ? err.message : String(err)}`)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (normalizedName === 'skin') {
|
||||
renderSlashOutput(handleSkinCommand(arg))
|
||||
|
||||
@@ -554,6 +648,7 @@ export function usePromptActions({
|
||||
busyRef,
|
||||
createBackendSessionForSend,
|
||||
handleSkinCommand,
|
||||
refreshSessions,
|
||||
requestGateway,
|
||||
startFreshSessionDraft,
|
||||
submitPromptText
|
||||
@@ -594,7 +689,7 @@ export function usePromptActions({
|
||||
const cancelRun = useCallback(async () => {
|
||||
const sessionId = activeSessionId || activeSessionIdRef.current
|
||||
|
||||
busyRef.current = false
|
||||
setMutableRef(busyRef, false)
|
||||
setBusy(false)
|
||||
setAwaitingResponse(false)
|
||||
|
||||
@@ -753,7 +848,7 @@ export function usePromptActions({
|
||||
const editedMessage: ChatMessage = { ...source, parts: [textPart(text)] }
|
||||
|
||||
clearNotifications()
|
||||
busyRef.current = true
|
||||
setMutableRef(busyRef, true)
|
||||
setBusy(true)
|
||||
setAwaitingResponse(true)
|
||||
updateSessionState(sessionId, state => ({
|
||||
@@ -791,7 +886,7 @@ export function usePromptActions({
|
||||
}
|
||||
}
|
||||
|
||||
busyRef.current = false
|
||||
setMutableRef(busyRef, false)
|
||||
setBusy(false)
|
||||
setAwaitingResponse(false)
|
||||
updateSessionState(sessionId, state => ({ ...state, busy: false, awaitingResponse: false }))
|
||||
|
||||
@@ -12,6 +12,7 @@ import { clearQueuedPrompts } from '@/store/composer-queue'
|
||||
import { $pinnedSessionIds } from '@/store/layout'
|
||||
import { clearNotifications, notify, notifyError } from '@/store/notifications'
|
||||
import { requestDesktopOnboarding } from '@/store/onboarding'
|
||||
import { $activeGatewayProfile, $newChatProfile, ensureGatewayProfile, normalizeProfileKey } from '@/store/profile'
|
||||
import {
|
||||
$currentCwd,
|
||||
$messages,
|
||||
@@ -36,8 +37,8 @@ import {
|
||||
setMessages,
|
||||
setSelectedStoredSessionId,
|
||||
setSessions,
|
||||
setSessionsTotal,
|
||||
setSessionStartedAt,
|
||||
setSessionsTotal,
|
||||
setTurnStartedAt,
|
||||
setYoloActive
|
||||
} from '@/store/session'
|
||||
@@ -173,6 +174,10 @@ function upsertOptimisticSession(
|
||||
preview: string | null = null
|
||||
) {
|
||||
const now = Date.now() / 1000
|
||||
// Stamp the profile the session was just created on (= the live gateway's
|
||||
// profile) so the scoped sidebar shows the new row immediately instead of
|
||||
// filtering it out as "default" until the aggregator re-fetches.
|
||||
const profileKey = normalizeProfileKey($activeGatewayProfile.get())
|
||||
|
||||
const session: SessionInfo = {
|
||||
cwd: created.info?.cwd ?? null,
|
||||
@@ -180,11 +185,13 @@ function upsertOptimisticSession(
|
||||
id,
|
||||
input_tokens: 0,
|
||||
is_active: true,
|
||||
is_default_profile: profileKey === 'default',
|
||||
last_active: now,
|
||||
message_count: created.message_count ?? created.messages?.length ?? 0,
|
||||
model: created.info?.model ?? null,
|
||||
output_tokens: 0,
|
||||
preview,
|
||||
profile: profileKey,
|
||||
source: 'tui',
|
||||
started_at: now,
|
||||
title,
|
||||
@@ -311,74 +318,80 @@ export function useSessionActions({
|
||||
[activeSessionIdRef, busyRef, navigate, selectedStoredSessionIdRef]
|
||||
)
|
||||
|
||||
const createBackendSessionForSend = useCallback(async (preview: string | null = null): Promise<string | null> => {
|
||||
const startingActiveSessionId = activeSessionIdRef.current
|
||||
const startingStoredSessionId = selectedStoredSessionIdRef.current
|
||||
const startingRouteToken = getRouteToken()
|
||||
const createBackendSessionForSend = useCallback(
|
||||
async (preview: string | null = null): Promise<string | null> => {
|
||||
const startingActiveSessionId = activeSessionIdRef.current
|
||||
const startingStoredSessionId = selectedStoredSessionIdRef.current
|
||||
const startingRouteToken = getRouteToken()
|
||||
|
||||
creatingSessionRef.current = true
|
||||
creatingSessionRef.current = true
|
||||
|
||||
try {
|
||||
const cwd = $currentCwd.get().trim() || getRememberedWorkspaceCwd()
|
||||
const created = await requestGateway<SessionCreateResponse>('session.create', { cols: 96, ...(cwd && { cwd }) })
|
||||
const stored = created.stored_session_id ?? null
|
||||
try {
|
||||
// Route the new chat to the chosen profile's backend (null = primary,
|
||||
// so single-profile users are unaffected).
|
||||
await ensureGatewayProfile($newChatProfile.get())
|
||||
const cwd = $currentCwd.get().trim() || getRememberedWorkspaceCwd()
|
||||
const created = await requestGateway<SessionCreateResponse>('session.create', { cols: 96, ...(cwd && { cwd }) })
|
||||
const stored = created.stored_session_id ?? null
|
||||
|
||||
if (
|
||||
activeSessionIdRef.current !== startingActiveSessionId ||
|
||||
selectedStoredSessionIdRef.current !== startingStoredSessionId ||
|
||||
getRouteToken() !== startingRouteToken
|
||||
) {
|
||||
await requestGateway('session.close', { session_id: created.session_id }).catch(() => undefined)
|
||||
if (
|
||||
activeSessionIdRef.current !== startingActiveSessionId ||
|
||||
selectedStoredSessionIdRef.current !== startingStoredSessionId ||
|
||||
getRouteToken() !== startingRouteToken
|
||||
) {
|
||||
await requestGateway('session.close', { session_id: created.session_id }).catch(() => undefined)
|
||||
|
||||
return null
|
||||
return null
|
||||
}
|
||||
|
||||
activeSessionIdRef.current = created.session_id
|
||||
selectedStoredSessionIdRef.current = stored
|
||||
ensureSessionState(created.session_id, stored)
|
||||
|
||||
if (stored) {
|
||||
// Seed the sidebar preview with the user's first message so the row
|
||||
// reads meaningfully while the turn is in flight, instead of flashing
|
||||
// "Untitled session" until the turn persists and auto-title runs. The
|
||||
// server later returns its own preview/title and supersedes this.
|
||||
upsertOptimisticSession(created, stored, null, preview?.trim() || null)
|
||||
navigate(sessionRoute(stored), { replace: true })
|
||||
}
|
||||
|
||||
setFreshDraftReady(false)
|
||||
setActiveSessionId(created.session_id)
|
||||
setSelectedStoredSessionId(stored)
|
||||
setSessionStartedAt(Date.now())
|
||||
const yoloArmed = $yoloActive.get()
|
||||
const runtimeInfo = applyRuntimeInfo(created.info)
|
||||
|
||||
if (runtimeInfo) {
|
||||
updateSessionState(created.session_id, state => ({ ...state, ...runtimeInfo }), stored)
|
||||
}
|
||||
|
||||
// User may have armed YOLO on the new-chat draft before the runtime
|
||||
// session existed — apply it to the freshly created session.
|
||||
if (yoloArmed) {
|
||||
await setSessionYolo(requestGateway, created.session_id, true).catch(() => undefined)
|
||||
}
|
||||
|
||||
return created.session_id
|
||||
} finally {
|
||||
window.setTimeout(() => {
|
||||
creatingSessionRef.current = false
|
||||
}, 0)
|
||||
}
|
||||
|
||||
activeSessionIdRef.current = created.session_id
|
||||
selectedStoredSessionIdRef.current = stored
|
||||
ensureSessionState(created.session_id, stored)
|
||||
|
||||
if (stored) {
|
||||
// Seed the sidebar preview with the user's first message so the row
|
||||
// reads meaningfully while the turn is in flight, instead of flashing
|
||||
// "Untitled session" until the turn persists and auto-title runs. The
|
||||
// server later returns its own preview/title and supersedes this.
|
||||
upsertOptimisticSession(created, stored, null, preview?.trim() || null)
|
||||
navigate(sessionRoute(stored), { replace: true })
|
||||
}
|
||||
|
||||
setFreshDraftReady(false)
|
||||
setActiveSessionId(created.session_id)
|
||||
setSelectedStoredSessionId(stored)
|
||||
setSessionStartedAt(Date.now())
|
||||
const yoloArmed = $yoloActive.get()
|
||||
const runtimeInfo = applyRuntimeInfo(created.info)
|
||||
|
||||
if (runtimeInfo) {
|
||||
updateSessionState(created.session_id, state => ({ ...state, ...runtimeInfo }), stored)
|
||||
}
|
||||
|
||||
// User may have armed YOLO on the new-chat draft before the runtime
|
||||
// session existed — apply it to the freshly created session.
|
||||
if (yoloArmed) {
|
||||
await setSessionYolo(requestGateway, created.session_id, true).catch(() => undefined)
|
||||
}
|
||||
|
||||
return created.session_id
|
||||
} finally {
|
||||
window.setTimeout(() => {
|
||||
creatingSessionRef.current = false
|
||||
}, 0)
|
||||
}
|
||||
}, [
|
||||
activeSessionIdRef,
|
||||
creatingSessionRef,
|
||||
ensureSessionState,
|
||||
getRouteToken,
|
||||
navigate,
|
||||
requestGateway,
|
||||
selectedStoredSessionIdRef,
|
||||
updateSessionState
|
||||
])
|
||||
},
|
||||
[
|
||||
activeSessionIdRef,
|
||||
creatingSessionRef,
|
||||
ensureSessionState,
|
||||
getRouteToken,
|
||||
navigate,
|
||||
requestGateway,
|
||||
selectedStoredSessionIdRef,
|
||||
updateSessionState
|
||||
]
|
||||
)
|
||||
|
||||
const selectSidebarItem = useCallback(
|
||||
(item: SidebarNavItem) => {
|
||||
@@ -417,6 +430,12 @@ export function useSessionActions({
|
||||
const isCurrentResume = () =>
|
||||
resumeRequestRef.current === requestId && selectedStoredSessionIdRef.current === storedSessionId
|
||||
|
||||
// Swap the single live gateway to this session's profile before any
|
||||
// gateway call (no-op when it's already on that profile / single-profile).
|
||||
const storedForProfile = $sessions.get().find(session => session.id === storedSessionId)
|
||||
const sessionProfile = storedForProfile?.profile
|
||||
await ensureGatewayProfile(sessionProfile)
|
||||
|
||||
const cachedRuntimeId = runtimeIdByStoredSessionIdRef.current.get(storedSessionId)
|
||||
const cachedState = cachedRuntimeId && sessionStateByRuntimeIdRef.current.get(cachedRuntimeId)
|
||||
|
||||
@@ -479,7 +498,7 @@ export function useSessionActions({
|
||||
let localSnapshot = $messages.get()
|
||||
|
||||
try {
|
||||
const storedMessages = await getSessionMessages(storedSessionId)
|
||||
const storedMessages = await getSessionMessages(storedSessionId, sessionProfile)
|
||||
|
||||
if (isCurrentResume()) {
|
||||
localSnapshot = preserveLocalAssistantErrors(toChatMessages(storedMessages.messages), $messages.get())
|
||||
@@ -549,7 +568,7 @@ export function useSessionActions({
|
||||
return
|
||||
}
|
||||
|
||||
const fallback = await getSessionMessages(storedSessionId)
|
||||
const fallback = await getSessionMessages(storedSessionId, sessionProfile)
|
||||
|
||||
if (!isCurrentResume()) {
|
||||
return
|
||||
|
||||
@@ -4,6 +4,7 @@ import { type MutableRefObject, useCallback, useEffect, useRef } from 'react'
|
||||
import type { ChatMessage } from '@/lib/chat-messages'
|
||||
import { preserveLocalAssistantErrors } from '@/lib/chat-messages'
|
||||
import { createClientSessionState } from '@/lib/chat-runtime'
|
||||
import { setMutableRef } from '@/lib/mutable-ref'
|
||||
import { $busy, $messages, noteSessionActivity, setSessionAttention, setSessionWorking } from '@/store/session'
|
||||
|
||||
import type { ClientSessionState } from '../../types'
|
||||
@@ -38,7 +39,7 @@ export function useSessionStateCache({
|
||||
}, [activeSessionId])
|
||||
|
||||
useEffect(() => {
|
||||
busyRef.current = busy
|
||||
setMutableRef(busyRef, busy)
|
||||
}, [busy, busyRef])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -89,7 +90,7 @@ export function useSessionStateCache({
|
||||
|
||||
setMessages(preserveLocalAssistantErrors(pending.state.messages, $messages.get()))
|
||||
setBusy(pending.state.busy)
|
||||
busyRef.current = pending.state.busy
|
||||
setMutableRef(busyRef, pending.state.busy)
|
||||
setAwaitingResponse(pending.state.awaitingResponse)
|
||||
}, [busyRef, setAwaitingResponse, setBusy, setMessages])
|
||||
|
||||
|
||||
@@ -15,9 +15,21 @@ import type { ThemeMode } from '@/themes/context'
|
||||
|
||||
import type { DesktopConfigSection } from './types'
|
||||
|
||||
// Provider group definitions used to fold raw env-var names like
|
||||
// ``XAI_API_KEY`` into a single "xAI" card with a friendly label, short
|
||||
// description, and signup URL. Membership is determined by longest
|
||||
// prefix match (see ``providerGroup`` in helpers.ts) so more specific
|
||||
// prefixes (``MINIMAX_CN_``) correctly beat their general parents
|
||||
// (``MINIMAX_``). New providers should be added here so they get their
|
||||
// own card in Settings → Keys instead of being lumped into "Other".
|
||||
interface ProviderPrefix {
|
||||
prefix: string
|
||||
name: string
|
||||
/** Optional one-line tagline shown beneath the group name. */
|
||||
description?: string
|
||||
/** Optional canonical signup/console URL surfaced from the card header. */
|
||||
docsUrl?: string
|
||||
/** Lower numbers float to the top of the providers list. */
|
||||
priority: number
|
||||
}
|
||||
|
||||
@@ -25,24 +37,180 @@ export const EMPTY_SELECT_VALUE = '__hermes_empty__'
|
||||
export const CONTROL_TEXT = 'text-xs'
|
||||
|
||||
export const PROVIDER_GROUPS: ProviderPrefix[] = [
|
||||
{ prefix: 'NOUS_', name: 'Nous Portal', priority: 0 },
|
||||
{ prefix: 'ANTHROPIC_', name: 'Anthropic', priority: 1 },
|
||||
{ prefix: 'DASHSCOPE_', name: 'DashScope (Qwen)', priority: 2 },
|
||||
{ prefix: 'HERMES_QWEN_', name: 'DashScope (Qwen)', priority: 2 },
|
||||
{ prefix: 'DEEPSEEK_', name: 'DeepSeek', priority: 3 },
|
||||
{ prefix: 'GOOGLE_', name: 'Gemini', priority: 4 },
|
||||
{
|
||||
prefix: 'NOUS_',
|
||||
name: 'Nous Portal',
|
||||
description: 'Hosted Hermes & Nous-trained models',
|
||||
docsUrl: 'https://portal.nousresearch.com',
|
||||
priority: 0
|
||||
},
|
||||
{
|
||||
prefix: 'OPENROUTER_',
|
||||
name: 'OpenRouter',
|
||||
description: 'Aggregator for hundreds of frontier models',
|
||||
docsUrl: 'https://openrouter.ai/keys',
|
||||
priority: 1
|
||||
},
|
||||
{
|
||||
prefix: 'ANTHROPIC_',
|
||||
name: 'Anthropic',
|
||||
description: 'Claude API access (Sonnet, Opus, Haiku)',
|
||||
docsUrl: 'https://console.anthropic.com/settings/keys',
|
||||
priority: 2
|
||||
},
|
||||
{
|
||||
prefix: 'XAI_',
|
||||
name: 'xAI',
|
||||
description: 'Grok models (use OAuth for SuperGrok / Premium+)',
|
||||
docsUrl: 'https://console.x.ai/',
|
||||
priority: 3
|
||||
},
|
||||
{
|
||||
prefix: 'GOOGLE_',
|
||||
name: 'Gemini',
|
||||
description: 'Google AI Studio (Gemini 1.5 / 2.0 / 2.5)',
|
||||
docsUrl: 'https://aistudio.google.com/app/apikey',
|
||||
priority: 4
|
||||
},
|
||||
{ prefix: 'GEMINI_', name: 'Gemini', priority: 4 },
|
||||
{ prefix: 'GLM_', name: 'GLM / Z.AI', priority: 5 },
|
||||
{ prefix: 'ZAI_', name: 'GLM / Z.AI', priority: 5 },
|
||||
{ prefix: 'Z_AI_', name: 'GLM / Z.AI', priority: 5 },
|
||||
{ prefix: 'HF_', name: 'Hugging Face', priority: 6 },
|
||||
{ prefix: 'KIMI_', name: 'Kimi / Moonshot', priority: 7 },
|
||||
{ prefix: 'MINIMAX_', name: 'MiniMax', priority: 8 },
|
||||
{ prefix: 'MINIMAX_CN_', name: 'MiniMax (China)', priority: 9 },
|
||||
{ prefix: 'OPENCODE_GO_', name: 'OpenCode Go', priority: 10 },
|
||||
{ prefix: 'OPENCODE_ZEN_', name: 'OpenCode Zen', priority: 11 },
|
||||
{ prefix: 'OPENROUTER_', name: 'OpenRouter', priority: 12 },
|
||||
{ prefix: 'XIAOMI_', name: 'Xiaomi MiMo', priority: 13 }
|
||||
{ prefix: 'HERMES_GEMINI_', name: 'Gemini', priority: 4 },
|
||||
{
|
||||
prefix: 'DEEPSEEK_',
|
||||
name: 'DeepSeek',
|
||||
description: 'Direct DeepSeek API (V3.x, R1)',
|
||||
docsUrl: 'https://platform.deepseek.com/api_keys',
|
||||
priority: 5
|
||||
},
|
||||
{
|
||||
prefix: 'DASHSCOPE_',
|
||||
name: 'DashScope (Qwen)',
|
||||
description: 'Alibaba Cloud DashScope — Qwen and multi-vendor models',
|
||||
docsUrl: 'https://modelstudio.console.alibabacloud.com/',
|
||||
priority: 6
|
||||
},
|
||||
{ prefix: 'HERMES_QWEN_', name: 'DashScope (Qwen)', priority: 6 },
|
||||
{
|
||||
prefix: 'GLM_',
|
||||
name: 'GLM / Z.AI',
|
||||
description: 'Zhipu GLM-4.6 and Z.AI hosted endpoints',
|
||||
docsUrl: 'https://z.ai/',
|
||||
priority: 7
|
||||
},
|
||||
{ prefix: 'ZAI_', name: 'GLM / Z.AI', priority: 7 },
|
||||
{ prefix: 'Z_AI_', name: 'GLM / Z.AI', priority: 7 },
|
||||
{
|
||||
prefix: 'KIMI_',
|
||||
name: 'Kimi / Moonshot',
|
||||
description: 'Moonshot Kimi K2 / coding endpoints',
|
||||
docsUrl: 'https://platform.moonshot.cn/',
|
||||
priority: 8
|
||||
},
|
||||
{
|
||||
prefix: 'KIMI_CN_',
|
||||
name: 'Kimi (China)',
|
||||
description: 'Moonshot China endpoint',
|
||||
docsUrl: 'https://platform.moonshot.cn/',
|
||||
priority: 9
|
||||
},
|
||||
{
|
||||
prefix: 'MINIMAX_',
|
||||
name: 'MiniMax',
|
||||
description: 'MiniMax-M2 and Hailuo international endpoints',
|
||||
docsUrl: 'https://www.minimax.io/',
|
||||
priority: 10
|
||||
},
|
||||
{
|
||||
prefix: 'MINIMAX_CN_',
|
||||
name: 'MiniMax (China)',
|
||||
description: 'MiniMax mainland China endpoint',
|
||||
docsUrl: 'https://www.minimaxi.com/',
|
||||
priority: 11
|
||||
},
|
||||
{
|
||||
prefix: 'HF_',
|
||||
name: 'Hugging Face',
|
||||
description: 'Inference Providers — 20+ open models via router.huggingface.co',
|
||||
docsUrl: 'https://huggingface.co/settings/tokens',
|
||||
priority: 12
|
||||
},
|
||||
{
|
||||
prefix: 'OPENCODE_ZEN_',
|
||||
name: 'OpenCode Zen',
|
||||
description: 'Pay-as-you-go access to curated coding models',
|
||||
docsUrl: 'https://opencode.ai/auth',
|
||||
priority: 13
|
||||
},
|
||||
{
|
||||
prefix: 'OPENCODE_GO_',
|
||||
name: 'OpenCode Go',
|
||||
description: '$10/month subscription for open coding models',
|
||||
docsUrl: 'https://opencode.ai/auth',
|
||||
priority: 14
|
||||
},
|
||||
{
|
||||
prefix: 'NVIDIA_',
|
||||
name: 'NVIDIA NIM',
|
||||
description: 'build.nvidia.com or your own local NIM endpoint',
|
||||
docsUrl: 'https://build.nvidia.com/',
|
||||
priority: 15
|
||||
},
|
||||
{
|
||||
prefix: 'OLLAMA_',
|
||||
name: 'Ollama Cloud',
|
||||
description: 'Cloud-hosted open models from ollama.com',
|
||||
docsUrl: 'https://ollama.com/settings',
|
||||
priority: 16
|
||||
},
|
||||
{
|
||||
prefix: 'LM_',
|
||||
name: 'LM Studio',
|
||||
description: 'Local LM Studio server (OpenAI-compatible)',
|
||||
docsUrl: 'https://lmstudio.ai/docs/local-server',
|
||||
priority: 17
|
||||
},
|
||||
{
|
||||
prefix: 'STEPFUN_',
|
||||
name: 'StepFun',
|
||||
description: 'StepFun Step Plan coding models',
|
||||
docsUrl: 'https://platform.stepfun.com/',
|
||||
priority: 18
|
||||
},
|
||||
{
|
||||
prefix: 'XIAOMI_',
|
||||
name: 'Xiaomi MiMo',
|
||||
description: 'MiMo-V2.5 and Xiaomi proprietary models',
|
||||
docsUrl: 'https://platform.xiaomimimo.com',
|
||||
priority: 19
|
||||
},
|
||||
{
|
||||
prefix: 'ARCEEAI_',
|
||||
name: 'Arcee AI',
|
||||
description: 'Arcee-hosted small + medium models',
|
||||
docsUrl: 'https://chat.arcee.ai/',
|
||||
priority: 20
|
||||
},
|
||||
{ prefix: 'ARCEE_', name: 'Arcee AI', priority: 20 },
|
||||
{
|
||||
prefix: 'GMI_',
|
||||
name: 'GMI Cloud',
|
||||
description: 'GMI Cloud GPU + model serving',
|
||||
docsUrl: 'https://www.gmicloud.ai/',
|
||||
priority: 21
|
||||
},
|
||||
{
|
||||
prefix: 'AZURE_FOUNDRY_',
|
||||
name: 'Azure Foundry',
|
||||
description: 'Azure AI Foundry custom endpoints (OpenAI / Anthropic-compatible)',
|
||||
docsUrl: 'https://ai.azure.com/',
|
||||
priority: 22
|
||||
},
|
||||
{
|
||||
prefix: 'AWS_',
|
||||
name: 'AWS Bedrock',
|
||||
description: 'Authenticate via AWS profile + region',
|
||||
docsUrl: 'https://docs.aws.amazon.com/bedrock/latest/userguide/bedrock-regions.html',
|
||||
priority: 23
|
||||
}
|
||||
]
|
||||
|
||||
export const BUILTIN_PERSONALITIES = [
|
||||
|
||||
363
apps/desktop/src/app/settings/credential-key-ui.tsx
Normal file
363
apps/desktop/src/app/settings/credential-key-ui.tsx
Normal file
@@ -0,0 +1,363 @@
|
||||
import { type ChangeEvent, type KeyboardEvent } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { ChevronDown, ExternalLink, Loader2, Save } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { EnvVarInfo } from '@/types/hermes'
|
||||
|
||||
import { CONTROL_TEXT } from './constants'
|
||||
import { prettyName, withoutKey } from './helpers'
|
||||
import { ListRow } from './primitives'
|
||||
import type { EnvRowProps } from './types'
|
||||
|
||||
export type KeyRowProps = Omit<EnvRowProps, 'info' | 'varKey'>
|
||||
|
||||
/** Matches Advanced / config field controls (ListRow + Input). */
|
||||
export const CREDENTIAL_CONTROL_CLASS = cn('h-8', CONTROL_TEXT)
|
||||
|
||||
export const isKeyVar = (key: string, info: EnvVarInfo) =>
|
||||
info.is_password || /(?:_API_KEY|_TOKEN|_KEY)$/.test(key)
|
||||
|
||||
export const friendlyFieldLabel = (key: string, info: EnvVarInfo) =>
|
||||
info.description?.trim() ||
|
||||
key
|
||||
.replace(/_/g, ' ')
|
||||
.toLowerCase()
|
||||
.replace(/\b\w/g, c => c.toUpperCase())
|
||||
|
||||
export const credentialPlaceholder = (key: string, info: EnvVarInfo, label: string): string =>
|
||||
isKeyVar(key, info) ? `Paste ${label} key` : /URL$/i.test(key) ? 'https://…' : 'Optional'
|
||||
|
||||
// A single credential field: a set key shows as a filled read-only input
|
||||
// (redacted value) that edits in place on click. Save appears once typed; a set
|
||||
// key also offers Remove, and Esc cancels without closing the overlay.
|
||||
export function KeyField({
|
||||
info,
|
||||
placeholder,
|
||||
rowProps,
|
||||
varKey
|
||||
}: {
|
||||
info: EnvVarInfo
|
||||
placeholder?: string
|
||||
rowProps: KeyRowProps
|
||||
varKey: string
|
||||
}) {
|
||||
const { edits, onClear, onSave, saving, setEdits } = rowProps
|
||||
const editing = edits[varKey] !== undefined
|
||||
const draft = edits[varKey] ?? ''
|
||||
const dirty = draft.trim().length > 0
|
||||
const busy = saving === varKey
|
||||
const masked = info.redacted_value ?? '••••••••'
|
||||
const startEdit = () => setEdits(c => ({ ...c, [varKey]: '' }))
|
||||
const cancel = () => setEdits(c => withoutKey(c, varKey))
|
||||
const update = (e: ChangeEvent<HTMLInputElement>) => setEdits(c => ({ ...c, [varKey]: e.target.value }))
|
||||
|
||||
const keydown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter' && dirty) {
|
||||
void onSave(varKey)
|
||||
} else if (e.key === 'Escape' && editing) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
|
||||
const editType = info.is_password ? 'password' : 'text'
|
||||
|
||||
if (info.is_set && !editing) {
|
||||
return (
|
||||
<Input
|
||||
className={cn(CREDENTIAL_CONTROL_CLASS, 'cursor-pointer text-muted-foreground')}
|
||||
onFocus={startEdit}
|
||||
readOnly
|
||||
value={masked}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
autoFocus={editing}
|
||||
className={cn(CREDENTIAL_CONTROL_CLASS, 'min-w-0 flex-1')}
|
||||
onChange={update}
|
||||
onKeyDown={keydown}
|
||||
placeholder={placeholder ?? 'Paste key'}
|
||||
type={editType}
|
||||
value={draft}
|
||||
/>
|
||||
{dirty && (
|
||||
<Button className="h-8 shrink-0" disabled={busy} onClick={() => void onSave(varKey)} size="sm">
|
||||
{busy ? <Loader2 className="size-4 animate-spin" /> : <Save />}
|
||||
{busy ? 'Saving' : 'Save'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{editing && (
|
||||
<div className="flex items-center gap-1 text-[0.6875rem]">
|
||||
{info.is_set && (
|
||||
<>
|
||||
<Button
|
||||
className="h-auto px-0 py-0 text-[0.6875rem] text-destructive hover:text-destructive"
|
||||
disabled={busy}
|
||||
onClick={() => void onClear(varKey)}
|
||||
type="button"
|
||||
variant="text"
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
<span className="text-muted-foreground">or</span>
|
||||
</>
|
||||
)}
|
||||
<span className="text-muted-foreground">esc to cancel</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CredentialDocsLink({ href }: { href: string }) {
|
||||
return (
|
||||
<a
|
||||
className="inline-flex w-fit items-center gap-1 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary) underline-offset-4 transition-colors hover:text-foreground hover:underline"
|
||||
href={href}
|
||||
onClick={e => e.stopPropagation()}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Get a key
|
||||
<ExternalLink className="size-3" />
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
/** One credential row — collapsible; description and docs link expand on click. */
|
||||
export function CredentialKeyCard({
|
||||
expanded,
|
||||
info,
|
||||
label,
|
||||
onExpand,
|
||||
onToggle,
|
||||
placeholder,
|
||||
rowProps,
|
||||
varKey
|
||||
}: CredentialKeyCardProps) {
|
||||
const docsUrl = info.url?.trim()
|
||||
const description = info.description?.trim()
|
||||
const expandable = Boolean(description || docsUrl)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group/card rounded-[6px] px-2 py-1 transition-colors',
|
||||
expandable && 'cursor-pointer',
|
||||
expandable && !expanded && 'hover:bg-(--ui-row-hover-background)',
|
||||
expanded && 'bg-(--ui-bg-quaternary) ring-1 ring-(--ui-stroke-secondary)'
|
||||
)}
|
||||
onClick={expandable ? onToggle : undefined}
|
||||
onKeyDown={
|
||||
expandable
|
||||
? e => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
onToggle()
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
role={expandable ? 'button' : undefined}
|
||||
tabIndex={expandable ? 0 : undefined}
|
||||
>
|
||||
<div className="grid gap-3 py-2 sm:grid-cols-[minmax(0,1fr)_minmax(15rem,22rem)] sm:items-center">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
'size-2 shrink-0 rounded-full',
|
||||
info.is_set ? 'bg-primary' : 'bg-(--ui-stroke-secondary)'
|
||||
)}
|
||||
/>
|
||||
|
||||
<span className="min-w-0 truncate text-[length:var(--conversation-text-font-size)] font-medium text-foreground">
|
||||
{label}
|
||||
</span>
|
||||
|
||||
{expandable && (
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'size-3.5 shrink-0 text-muted-foreground transition',
|
||||
expanded ? 'rotate-180 opacity-100' : 'opacity-0 group-hover/card:opacity-100'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="min-w-0 sm:justify-self-end"
|
||||
onClick={e => e.stopPropagation()}
|
||||
onFocus={() => {
|
||||
if (expandable && !expanded) {
|
||||
onExpand()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<KeyField info={info} placeholder={placeholder} rowProps={rowProps} varKey={varKey} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expandable && expanded && (
|
||||
<div className="grid gap-2.5 pb-2 pl-4" onClick={e => e.stopPropagation()}>
|
||||
{description && (
|
||||
<p className="text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{docsUrl && <CredentialDocsLink href={docsUrl} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Provider API key group — collapsible card; description, docs link, and advanced fields expand on click. */
|
||||
export function ProviderKeyRows({ expanded, group, onExpand, onToggle, rowProps }: ProviderKeyRowsProps) {
|
||||
const docsUrl = group.docsUrl?.trim()
|
||||
const description = group.description?.trim()
|
||||
const expandable = Boolean(description || docsUrl || group.advanced.length > 0)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group/card rounded-[6px] px-2 py-1 transition-colors',
|
||||
expandable && 'cursor-pointer',
|
||||
expandable && !expanded && 'hover:bg-(--ui-row-hover-background)',
|
||||
expanded && 'bg-(--ui-bg-quaternary) ring-1 ring-(--ui-stroke-secondary)'
|
||||
)}
|
||||
onClick={expandable ? onToggle : undefined}
|
||||
onKeyDown={
|
||||
expandable
|
||||
? e => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
onToggle()
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
role={expandable ? 'button' : undefined}
|
||||
tabIndex={expandable ? 0 : undefined}
|
||||
>
|
||||
<div className="grid gap-3 py-2 sm:grid-cols-[minmax(0,1fr)_minmax(15rem,22rem)] sm:items-center">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
'size-2 shrink-0 rounded-full',
|
||||
group.hasAnySet ? 'bg-primary' : 'bg-(--ui-stroke-secondary)'
|
||||
)}
|
||||
/>
|
||||
|
||||
<span className="min-w-0 truncate text-[length:var(--conversation-text-font-size)] font-medium text-foreground">
|
||||
{group.name}
|
||||
</span>
|
||||
|
||||
{expandable && (
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'size-3.5 shrink-0 text-muted-foreground transition',
|
||||
expanded ? 'rotate-180 opacity-100' : 'opacity-0 group-hover/card:opacity-100'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="min-w-0 sm:justify-self-end"
|
||||
onClick={e => e.stopPropagation()}
|
||||
onFocus={() => {
|
||||
if (expandable && !expanded) {
|
||||
onExpand()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<KeyField
|
||||
info={group.primary[1]}
|
||||
placeholder={`Paste ${group.name} key`}
|
||||
rowProps={rowProps}
|
||||
varKey={group.primary[0]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expandable && expanded && (
|
||||
<div className="grid gap-2.5 pb-2 pl-4" onClick={e => e.stopPropagation()}>
|
||||
{description && (
|
||||
<p className="text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{group.advanced.map(([key, info]) => {
|
||||
const fieldLabel = isKeyVar(key, info)
|
||||
? prettyName(key.replace(/(?:_API_KEY|_TOKEN|_KEY)$/i, ''))
|
||||
: friendlyFieldLabel(key, info)
|
||||
|
||||
return (
|
||||
<ListRow
|
||||
action={
|
||||
<KeyField
|
||||
info={info}
|
||||
placeholder={credentialPlaceholder(key, info, fieldLabel)}
|
||||
rowProps={rowProps}
|
||||
varKey={key}
|
||||
/>
|
||||
}
|
||||
key={key}
|
||||
title={fieldLabel}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
{docsUrl && <CredentialDocsLink href={docsUrl} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function credentialRowLabel(varKey: string, info: EnvVarInfo): string {
|
||||
if (isKeyVar(varKey, info)) {
|
||||
return prettyName(varKey.replace(/(?:_API_KEY|_TOKEN|_KEY)$/i, ''))
|
||||
}
|
||||
|
||||
return prettyName(varKey)
|
||||
}
|
||||
|
||||
interface CredentialKeyCardProps {
|
||||
expanded: boolean
|
||||
info: EnvVarInfo
|
||||
label: string
|
||||
onExpand: () => void
|
||||
onToggle: () => void
|
||||
placeholder: string
|
||||
rowProps: KeyRowProps
|
||||
varKey: string
|
||||
}
|
||||
|
||||
interface ProviderKeyRowsProps {
|
||||
expanded: boolean
|
||||
group: ProviderKeyRowGroup
|
||||
onExpand: () => void
|
||||
onToggle: () => void
|
||||
rowProps: KeyRowProps
|
||||
}
|
||||
|
||||
export interface ProviderKeyRowGroup {
|
||||
advanced: [string, EnvVarInfo][]
|
||||
description?: string
|
||||
docsUrl?: string
|
||||
hasAnySet: boolean
|
||||
name: string
|
||||
primary: [string, EnvVarInfo]
|
||||
}
|
||||
194
apps/desktop/src/app/settings/env-credentials.tsx
Normal file
194
apps/desktop/src/app/settings/env-credentials.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { deleteEnvVar, getEnvVars, revealEnvVar, setEnvVar } from '@/hermes'
|
||||
import { type IconComponent } from '@/lib/icons'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import type { EnvVarInfo } from '@/types/hermes'
|
||||
|
||||
import { asText, includesQuery, redactedValue, withoutKey } from './helpers'
|
||||
import { Pill } from './primitives'
|
||||
import type { EnvRowProps } from './types'
|
||||
|
||||
// Shared filter used by every credential surface (Providers + Keys pages):
|
||||
// category gate first, then a free-text match across key name + description.
|
||||
export function filterEnv(info: EnvVarInfo, key: string, q: string, cat: string, extra?: string): boolean {
|
||||
if (asText(info.category) !== cat) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!q) {
|
||||
return true
|
||||
}
|
||||
|
||||
return (
|
||||
key.toLowerCase().includes(q) ||
|
||||
includesQuery(info.description, q) ||
|
||||
Boolean(extra && extra.toLowerCase().includes(q))
|
||||
)
|
||||
}
|
||||
|
||||
export function SettingsCategoryHeading({ count, icon: Icon, title }: CategoryHeadingProps) {
|
||||
return (
|
||||
<div className="mb-3 flex items-center gap-2 text-[length:var(--conversation-text-font-size)] font-medium">
|
||||
<Icon className="size-4 text-muted-foreground" />
|
||||
<span>{title}</span>
|
||||
{count && <Pill>{count}</Pill>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Owns the env-var fetch + the edit/reveal/save/delete lifecycle so multiple
|
||||
// credential pages (Providers, Keys) share one source of truth and one set of
|
||||
// mutation handlers instead of duplicating the plumbing.
|
||||
export function useEnvCredentials(): UseEnvCredentials {
|
||||
const [vars, setVars] = useState<Record<string, EnvVarInfo> | null>(null)
|
||||
const [edits, setEdits] = useState<Record<string, string>>({})
|
||||
const [revealed, setRevealed] = useState<Record<string, string>>({})
|
||||
const [saving, setSaving] = useState<string | null>(null)
|
||||
|
||||
// Best-effort cleanup of a retired localStorage flag (global "Show
|
||||
// advanced" toggle) — everything in these views is configuration-level.
|
||||
useEffect(() => {
|
||||
try {
|
||||
window.localStorage.removeItem('desktop.settings.keys.show_advanced')
|
||||
} catch {
|
||||
// Ignore — old key cleanup is best-effort.
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const next = await getEnvVars()
|
||||
|
||||
if (!cancelled) {
|
||||
setVars(next)
|
||||
}
|
||||
} catch (err) {
|
||||
notifyError(err, 'API keys failed to load')
|
||||
}
|
||||
})()
|
||||
|
||||
return () => void (cancelled = true)
|
||||
}, [])
|
||||
|
||||
function patchVar(key: string, patch: Partial<Pick<EnvVarInfo, 'is_set' | 'redacted_value'>>) {
|
||||
setVars(c => (c ? { ...c, [key]: { ...c[key], ...patch } } : c))
|
||||
}
|
||||
|
||||
function clearLocalState(key: string) {
|
||||
setEdits(c => withoutKey(c, key))
|
||||
setRevealed(c => withoutKey(c, key))
|
||||
}
|
||||
|
||||
async function handleSave(key: string) {
|
||||
const value = edits[key]
|
||||
|
||||
if (!value) {
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(key)
|
||||
|
||||
try {
|
||||
await setEnvVar(key, value)
|
||||
patchVar(key, { is_set: true, redacted_value: redactedValue(value) })
|
||||
clearLocalState(key)
|
||||
notify({ kind: 'success', title: 'Credential saved', message: `${key} updated.` })
|
||||
} catch (err) {
|
||||
notifyError(err, `Failed to save ${key}`)
|
||||
} finally {
|
||||
setSaving(null)
|
||||
}
|
||||
}
|
||||
|
||||
// Direct save for a known value (no edit-state round-trip) — used by the
|
||||
// onboarding-style key form, which owns its own input. Returns a result so
|
||||
// the form can surface inline errors instead of only toasting.
|
||||
async function saveValue(key: string, value: string): Promise<{ message?: string; ok: boolean }> {
|
||||
const trimmed = value.trim()
|
||||
|
||||
if (!trimmed) {
|
||||
return { message: 'Enter a value first.', ok: false }
|
||||
}
|
||||
|
||||
setSaving(key)
|
||||
|
||||
try {
|
||||
await setEnvVar(key, trimmed)
|
||||
patchVar(key, { is_set: true, redacted_value: redactedValue(trimmed) })
|
||||
clearLocalState(key)
|
||||
notify({ kind: 'success', message: `${key} updated.`, title: 'Credential saved' })
|
||||
|
||||
return { ok: true }
|
||||
} catch (err) {
|
||||
notifyError(err, `Failed to save ${key}`)
|
||||
|
||||
return { message: err instanceof Error ? err.message : 'Could not save credential.', ok: false }
|
||||
} finally {
|
||||
setSaving(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleClear(key: string) {
|
||||
if (!window.confirm(`Remove ${key} from .env?`)) {
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(key)
|
||||
|
||||
try {
|
||||
await deleteEnvVar(key)
|
||||
patchVar(key, { is_set: false, redacted_value: null })
|
||||
clearLocalState(key)
|
||||
notify({ kind: 'success', title: 'Credential removed', message: `${key} removed.` })
|
||||
} catch (err) {
|
||||
notifyError(err, `Failed to remove ${key}`)
|
||||
} finally {
|
||||
setSaving(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReveal(key: string) {
|
||||
if (revealed[key]) {
|
||||
setRevealed(c => withoutKey(c, key))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await revealEnvVar(key)
|
||||
setRevealed(c => ({ ...c, [key]: result.value }))
|
||||
} catch (err) {
|
||||
notifyError(err, `Failed to reveal ${key}`)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
saveValue,
|
||||
vars,
|
||||
rowProps: {
|
||||
edits,
|
||||
revealed,
|
||||
saving,
|
||||
setEdits,
|
||||
onSave: handleSave,
|
||||
onClear: handleClear,
|
||||
onReveal: handleReveal
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface CategoryHeadingProps {
|
||||
count?: string
|
||||
icon: IconComponent
|
||||
title: string
|
||||
}
|
||||
|
||||
interface UseEnvCredentials {
|
||||
rowProps: Omit<EnvRowProps, 'varKey' | 'info'>
|
||||
saveValue: (key: string, value: string) => Promise<{ message?: string; ok: boolean }>
|
||||
vars: Record<string, EnvVarInfo> | null
|
||||
}
|
||||
130
apps/desktop/src/app/settings/env-var-actions-menu.tsx
Normal file
130
apps/desktop/src/app/settings/env-var-actions-menu.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import type * as React from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Eye, EyeOff, ExternalLink, Trash2 } from '@/lib/icons'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface EnvVarActionsMenuProps
|
||||
extends Pick<React.ComponentProps<typeof DropdownMenuContent>, 'align' | 'sideOffset'> {
|
||||
children: React.ReactNode
|
||||
clearDisabled?: boolean
|
||||
docsUrl?: string | null
|
||||
isRevealed?: boolean
|
||||
isSet: boolean
|
||||
label: string
|
||||
onClear?: () => void
|
||||
onEdit: () => void
|
||||
onReveal?: () => void
|
||||
showReveal?: boolean
|
||||
}
|
||||
|
||||
export function EnvVarActionsMenu({
|
||||
align = 'end',
|
||||
children,
|
||||
clearDisabled = false,
|
||||
docsUrl,
|
||||
isRevealed = false,
|
||||
isSet,
|
||||
label,
|
||||
onClear,
|
||||
onEdit,
|
||||
onReveal,
|
||||
showReveal = true,
|
||||
sideOffset = 6
|
||||
}: EnvVarActionsMenuProps) {
|
||||
const hasClear = isSet && onClear
|
||||
const hasReveal = isSet && showReveal && onReveal
|
||||
const hasDocs = Boolean(docsUrl?.trim())
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align={align}
|
||||
aria-label={`Actions for ${label}`}
|
||||
className="w-44"
|
||||
sideOffset={sideOffset}
|
||||
>
|
||||
{hasDocs && (
|
||||
<DropdownMenuItem
|
||||
onSelect={event => {
|
||||
event.preventDefault()
|
||||
triggerHaptic('selection')
|
||||
window.open(docsUrl!, '_blank', 'noopener,noreferrer')
|
||||
}}
|
||||
>
|
||||
<ExternalLink className="size-3.5" />
|
||||
<span>Docs</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{hasReveal && (
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
triggerHaptic('selection')
|
||||
onReveal()
|
||||
}}
|
||||
>
|
||||
{isRevealed ? <EyeOff className="size-3.5" /> : <Eye className="size-3.5" />}
|
||||
<span>{isRevealed ? 'Hide value' : 'Reveal value'}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
triggerHaptic('selection')
|
||||
onEdit()
|
||||
}}
|
||||
>
|
||||
<Codicon name="edit" size="0.875rem" />
|
||||
<span>{isSet ? 'Replace' : 'Set'}</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
{hasClear && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
disabled={clearDisabled}
|
||||
onSelect={() => {
|
||||
triggerHaptic('warning')
|
||||
onClear()
|
||||
}}
|
||||
variant="destructive"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
<span>Clear</span>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
interface EnvVarActionsTriggerProps extends Omit<React.ComponentProps<typeof Button>, 'size' | 'variant'> {
|
||||
label: string
|
||||
}
|
||||
|
||||
export function EnvVarActionsTrigger({ className, label, ...props }: EnvVarActionsTriggerProps) {
|
||||
return (
|
||||
<Button
|
||||
aria-label={`Actions for ${label}`}
|
||||
className={cn('text-muted-foreground hover:text-foreground', className)}
|
||||
size="icon-sm"
|
||||
title="Credential actions"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
<Codicon name="ellipsis" size="0.875rem" />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { AlertCircle, Check, FileText, Globe, Loader2, Monitor } from '@/lib/icons'
|
||||
import type { DesktopAuthProvider, DesktopConnectionProbeResult } from '@/global'
|
||||
import { AlertCircle, Check, FileText, Globe, Loader2, LogIn, Monitor } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
|
||||
@@ -10,10 +11,14 @@ import { CONTROL_TEXT } from './constants'
|
||||
import { EmptyState, ListRow, LoadingState, Pill, SettingsContent } from './primitives'
|
||||
|
||||
type Mode = 'local' | 'remote'
|
||||
type AuthMode = 'oauth' | 'token'
|
||||
type ProbeStatus = 'idle' | 'probing' | 'done' | 'error'
|
||||
|
||||
interface GatewaySettingsState {
|
||||
envOverride: boolean
|
||||
mode: Mode
|
||||
remoteAuthMode: AuthMode
|
||||
remoteOauthConnected: boolean
|
||||
remoteTokenPreview: string | null
|
||||
remoteTokenSet: boolean
|
||||
remoteUrl: string
|
||||
@@ -22,6 +27,8 @@ interface GatewaySettingsState {
|
||||
const EMPTY_STATE: GatewaySettingsState = {
|
||||
envOverride: false,
|
||||
mode: 'local',
|
||||
remoteAuthMode: 'token',
|
||||
remoteOauthConnected: false,
|
||||
remoteTokenPreview: null,
|
||||
remoteTokenSet: false,
|
||||
remoteUrl: ''
|
||||
@@ -71,10 +78,18 @@ export function GatewaySettings() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [testing, setTesting] = useState(false)
|
||||
const [signingIn, setSigningIn] = useState(false)
|
||||
const [state, setState] = useState<GatewaySettingsState>(EMPTY_STATE)
|
||||
const [remoteToken, setRemoteToken] = useState('')
|
||||
const [lastTest, setLastTest] = useState<null | string>(null)
|
||||
|
||||
// Auth-mode probe: as the user types a remote URL we ask the gateway (via
|
||||
// its public /api/status) whether it gates with OAuth or a static session
|
||||
// token, so we can show the right control (login button vs token box).
|
||||
const [probeStatus, setProbeStatus] = useState<ProbeStatus>('idle')
|
||||
const [probe, setProbe] = useState<DesktopConnectionProbeResult | null>(null)
|
||||
const probeSeq = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const desktop = window.hermesDesktop
|
||||
@@ -104,15 +119,129 @@ export function GatewaySettings() {
|
||||
return () => void (cancelled = true)
|
||||
}, [])
|
||||
|
||||
const canUseRemote = useMemo(
|
||||
() => Boolean(state.remoteUrl.trim()) && (Boolean(remoteToken.trim()) || state.remoteTokenSet),
|
||||
[remoteToken, state.remoteTokenSet, state.remoteUrl]
|
||||
)
|
||||
// Debounced probe of the entered remote URL. Only runs in remote mode with a
|
||||
// syntactically plausible URL. The probe result drives whether we render the
|
||||
// OAuth login button or the session-token entry box. The effective auth mode
|
||||
// prefers a fresh probe result over the saved value.
|
||||
const trimmedUrl = state.remoteUrl.trim()
|
||||
useEffect(() => {
|
||||
if (state.mode !== 'remote' || !trimmedUrl || !/^https?:\/\//i.test(trimmedUrl)) {
|
||||
setProbeStatus('idle')
|
||||
setProbe(null)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const desktop = window.hermesDesktop
|
||||
|
||||
if (!desktop?.probeConnectionConfig) {
|
||||
return
|
||||
}
|
||||
|
||||
const seq = ++probeSeq.current
|
||||
setProbeStatus('probing')
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
desktop
|
||||
.probeConnectionConfig(trimmedUrl)
|
||||
.then(result => {
|
||||
if (seq !== probeSeq.current) {
|
||||
return
|
||||
}
|
||||
|
||||
setProbe(result)
|
||||
setProbeStatus(result.reachable ? 'done' : 'error')
|
||||
})
|
||||
.catch(() => {
|
||||
if (seq !== probeSeq.current) {
|
||||
return
|
||||
}
|
||||
|
||||
setProbe(null)
|
||||
setProbeStatus('error')
|
||||
})
|
||||
}, 500)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [state.mode, trimmedUrl])
|
||||
|
||||
// Effective auth mode: a reachable probe wins; otherwise fall back to the
|
||||
// saved config's mode so a re-open of settings doesn't flicker.
|
||||
const authMode: AuthMode = useMemo(() => {
|
||||
if (probeStatus === 'done' && probe && probe.authMode !== 'unknown') {
|
||||
return probe.authMode
|
||||
}
|
||||
|
||||
return state.remoteAuthMode
|
||||
}, [probe, probeStatus, state.remoteAuthMode])
|
||||
|
||||
// Whether we actually KNOW how this gateway authenticates yet. Until we do,
|
||||
// neither the OAuth button nor the session-token box should render —
|
||||
// `authMode` defaults to 'token', so without this gate the token box flashes
|
||||
// for every gateway (including OAuth ones) during the idle/probing window
|
||||
// before the first probe lands. The scheme is known when either:
|
||||
// * the live probe finished (probeStatus 'done'), or
|
||||
// * we're idle but showing a previously-saved remote config (re-opening
|
||||
// settings for a gateway already signed-in or with a saved token), so
|
||||
// its control appears immediately with no flicker.
|
||||
// While probing (or after a probe error), the scheme is unknown and we show
|
||||
// the probe status row instead of a control.
|
||||
const hasSavedRemote = state.remoteTokenSet || state.remoteOauthConnected
|
||||
|
||||
const authResolved = useMemo(() => {
|
||||
if (probeStatus === 'done') {
|
||||
return true
|
||||
}
|
||||
|
||||
return probeStatus === 'idle' && hasSavedRemote
|
||||
}, [probeStatus, hasSavedRemote])
|
||||
|
||||
const providerLabel = useMemo(() => {
|
||||
const providers: DesktopAuthProvider[] = probe?.providers ?? []
|
||||
|
||||
if (providers.length === 1) {
|
||||
return providers[0].displayName || providers[0].name
|
||||
}
|
||||
|
||||
if (providers.length > 1) {
|
||||
return providers.map(p => p.displayName || p.name).join(' / ')
|
||||
}
|
||||
|
||||
return 'your identity provider'
|
||||
}, [probe])
|
||||
|
||||
// A username/password gateway authenticates through a credential form on the
|
||||
// gateway's /login page (POST /auth/password-login) rather than an OAuth
|
||||
// redirect. Everything downstream — the session cookie, the ws-ticket mint,
|
||||
// the persistent partition — is identical, so the desktop drives it through
|
||||
// the same sign-in window; only the button copy changes. We treat the
|
||||
// gateway as password-style only when EVERY advertised provider supports
|
||||
// password, so a mixed deployment keeps the generic OAuth copy.
|
||||
const isPasswordProvider = useMemo(() => {
|
||||
const providers: DesktopAuthProvider[] = probe?.providers ?? []
|
||||
|
||||
return providers.length > 0 && providers.every(p => p.supportsPassword)
|
||||
}, [probe])
|
||||
|
||||
const oauthConnected = state.remoteOauthConnected
|
||||
|
||||
const canUseRemote = useMemo(() => {
|
||||
if (!trimmedUrl) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (authMode === 'oauth') {
|
||||
return oauthConnected
|
||||
}
|
||||
|
||||
return Boolean(remoteToken.trim()) || state.remoteTokenSet
|
||||
}, [authMode, oauthConnected, remoteToken, state.remoteTokenSet, trimmedUrl])
|
||||
|
||||
const payload = () => ({
|
||||
mode: state.mode,
|
||||
remoteToken: remoteToken.trim() || undefined,
|
||||
remoteUrl: state.remoteUrl.trim()
|
||||
remoteAuthMode: authMode,
|
||||
remoteToken: authMode === 'token' ? remoteToken.trim() || undefined : undefined,
|
||||
remoteUrl: trimmedUrl
|
||||
})
|
||||
|
||||
const save = async (apply: boolean) => {
|
||||
@@ -120,7 +249,10 @@ export function GatewaySettings() {
|
||||
notify({
|
||||
kind: 'warning',
|
||||
title: 'Remote gateway incomplete',
|
||||
message: 'Enter a remote URL and session token before switching to remote.'
|
||||
message:
|
||||
authMode === 'oauth'
|
||||
? 'Enter a remote URL and sign in before switching to remote.'
|
||||
: 'Enter a remote URL and session token before switching to remote.'
|
||||
})
|
||||
|
||||
return
|
||||
@@ -147,12 +279,73 @@ export function GatewaySettings() {
|
||||
}
|
||||
}
|
||||
|
||||
// OAuth sign-in: persist the URL + oauth mode first (so the saved config has
|
||||
// the URL the login window needs), then open the gateway login window and
|
||||
// refresh the connection status from the saved config once it completes.
|
||||
const signIn = async () => {
|
||||
if (!trimmedUrl) {
|
||||
notify({ kind: 'warning', title: 'Remote gateway incomplete', message: 'Enter a remote URL first.' })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
setSigningIn(true)
|
||||
|
||||
try {
|
||||
// Save (don't apply/restart) so the login window has a URL to use and the
|
||||
// oauth mode is persisted, without yet flipping the live connection.
|
||||
const saved = await window.hermesDesktop.saveConnectionConfig({
|
||||
mode: state.mode,
|
||||
remoteAuthMode: 'oauth',
|
||||
remoteUrl: trimmedUrl
|
||||
})
|
||||
|
||||
setState(saved)
|
||||
|
||||
const result = await window.hermesDesktop.oauthLoginConnectionConfig(trimmedUrl)
|
||||
|
||||
if (result.connected) {
|
||||
const refreshed = await window.hermesDesktop.getConnectionConfig()
|
||||
setState(refreshed)
|
||||
notify({ kind: 'success', title: 'Signed in', message: `Connected to ${providerLabel}.` })
|
||||
} else {
|
||||
notify({
|
||||
kind: 'warning',
|
||||
title: 'Sign-in incomplete',
|
||||
message: 'The login window closed before authentication finished.'
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
notifyError(err, 'Sign-in failed')
|
||||
} finally {
|
||||
setSigningIn(false)
|
||||
}
|
||||
}
|
||||
|
||||
const signOut = async () => {
|
||||
setSigningIn(true)
|
||||
|
||||
try {
|
||||
await window.hermesDesktop.oauthLogoutConnectionConfig(trimmedUrl || undefined)
|
||||
const refreshed = await window.hermesDesktop.getConnectionConfig()
|
||||
setState(refreshed)
|
||||
notify({ kind: 'success', title: 'Signed out', message: 'Cleared the remote gateway session.' })
|
||||
} catch (err) {
|
||||
notifyError(err, 'Sign-out failed')
|
||||
} finally {
|
||||
setSigningIn(false)
|
||||
}
|
||||
}
|
||||
|
||||
const testRemote = async () => {
|
||||
if (!canUseRemote) {
|
||||
notify({
|
||||
kind: 'warning',
|
||||
title: 'Remote gateway incomplete',
|
||||
message: 'Enter a remote URL and session token before testing.'
|
||||
message:
|
||||
authMode === 'oauth'
|
||||
? 'Enter a remote URL and sign in before testing.'
|
||||
: 'Enter a remote URL and session token before testing.'
|
||||
})
|
||||
|
||||
return
|
||||
@@ -164,8 +357,9 @@ export function GatewaySettings() {
|
||||
try {
|
||||
const result = await window.hermesDesktop.testConnectionConfig({
|
||||
mode: 'remote',
|
||||
remoteToken: remoteToken.trim() || undefined,
|
||||
remoteUrl: state.remoteUrl.trim()
|
||||
remoteAuthMode: authMode,
|
||||
remoteToken: authMode === 'token' ? remoteToken.trim() || undefined : undefined,
|
||||
remoteUrl: trimmedUrl
|
||||
})
|
||||
|
||||
const message = `Connected to ${result.baseUrl}${result.version ? ` · Hermes ${result.version}` : ''}`
|
||||
@@ -229,7 +423,7 @@ export function GatewaySettings() {
|
||||
/>
|
||||
<ModeCard
|
||||
active={state.mode === 'remote'}
|
||||
description="Connect this desktop shell to a remote Hermes backend using its session token."
|
||||
description="Connect this desktop shell to a remote Hermes backend. Hosted gateways use OAuth or a username and password; self-hosted ones may use a session token."
|
||||
disabled={state.envOverride}
|
||||
icon={Globe}
|
||||
onSelect={() => setState(current => ({ ...current, mode: 'remote' }))}
|
||||
@@ -251,23 +445,75 @@ export function GatewaySettings() {
|
||||
description="Base URL for the remote dashboard backend. Path prefixes are supported, for example /hermes."
|
||||
title="Remote URL"
|
||||
/>
|
||||
<ListRow
|
||||
action={
|
||||
<Input
|
||||
autoComplete="off"
|
||||
className={cn('h-8 font-mono', CONTROL_TEXT)}
|
||||
disabled={state.envOverride}
|
||||
onChange={event => setRemoteToken(event.target.value)}
|
||||
placeholder={
|
||||
state.remoteTokenSet ? `Existing token ${state.remoteTokenPreview ?? 'saved'}` : 'Paste session token'
|
||||
}
|
||||
type="password"
|
||||
value={remoteToken}
|
||||
/>
|
||||
}
|
||||
description="The dashboard session token used for REST and WebSocket access. Leave blank to keep the saved token."
|
||||
title="Session token"
|
||||
/>
|
||||
|
||||
{state.mode === 'remote' && probeStatus === 'probing' ? (
|
||||
<div className="flex items-center gap-2 py-3 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Checking how this gateway authenticates…
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{state.mode === 'remote' && probeStatus === 'error' ? (
|
||||
<div className="flex items-start gap-2 py-3 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||
<AlertCircle className="mt-0.5 size-4 shrink-0" />
|
||||
Could not reach this gateway yet. Check the URL — the auth method will appear once it responds.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* OAuth / password gateways: present a sign-in button + connection status. */}
|
||||
{state.mode === 'remote' && authResolved && authMode === 'oauth' ? (
|
||||
<ListRow
|
||||
action={
|
||||
oauthConnected ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Pill tone="primary">
|
||||
<Check className="size-3" /> Signed in
|
||||
</Pill>
|
||||
<Button disabled={signingIn || state.envOverride} onClick={() => void signOut()} variant="outline">
|
||||
{signingIn ? <Loader2 className="size-4 animate-spin" /> : null}
|
||||
Sign out
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button disabled={signingIn || state.envOverride || !trimmedUrl} onClick={() => void signIn()}>
|
||||
{signingIn ? <Loader2 className="size-4 animate-spin" /> : <LogIn className="size-4" />}
|
||||
{isPasswordProvider ? 'Sign in' : `Sign in with ${providerLabel}`}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
description={
|
||||
oauthConnected
|
||||
? isPasswordProvider
|
||||
? 'This gateway uses a username and password. You are signed in; the session refreshes automatically.'
|
||||
: 'This gateway uses OAuth. You are signed in; the session refreshes automatically.'
|
||||
: isPasswordProvider
|
||||
? 'This gateway uses a username and password. Sign in to authorize this desktop app.'
|
||||
: `This gateway uses OAuth. Sign in with ${providerLabel} to authorize this desktop app.`
|
||||
}
|
||||
title="Authentication"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* Session-token gateways: keep the existing token entry box. */}
|
||||
{state.mode === 'remote' && authResolved && authMode === 'token' ? (
|
||||
<ListRow
|
||||
action={
|
||||
<Input
|
||||
autoComplete="off"
|
||||
className={cn('h-8 font-mono', CONTROL_TEXT)}
|
||||
disabled={state.envOverride}
|
||||
onChange={event => setRemoteToken(event.target.value)}
|
||||
placeholder={
|
||||
state.remoteTokenSet ? `Existing token ${state.remoteTokenPreview ?? 'saved'}` : 'Paste session token'
|
||||
}
|
||||
type="password"
|
||||
value={remoteToken}
|
||||
/>
|
||||
}
|
||||
description="The dashboard session token used for REST and WebSocket access. Leave blank to keep the saved token."
|
||||
title="Session token"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{lastTest ? <div className="mt-4 text-xs text-primary">{lastTest}</div> : null}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { HermesConfigRecord } from '@/types/hermes'
|
||||
|
||||
import { getNested, setNested } from './helpers'
|
||||
import { getNested, providerGroup, setNested, stripToolsetLabel, toolsetDisplayLabel } from './helpers'
|
||||
|
||||
describe('settings helpers', () => {
|
||||
it('reads and writes nested config paths', () => {
|
||||
@@ -20,4 +20,48 @@ describe('settings helpers', () => {
|
||||
expect(() => setNested(config, 'constructor.prototype.polluted', true)).toThrow('Unsafe config path')
|
||||
expect(({} as Record<string, unknown>).polluted).toBeUndefined()
|
||||
})
|
||||
|
||||
describe('stripToolsetLabel', () => {
|
||||
it('removes leading emoji prefixes from registry labels', () => {
|
||||
expect(stripToolsetLabel('⏰ Cron Jobs')).toBe('Cron Jobs')
|
||||
expect(stripToolsetLabel('⚡ Code Execution')).toBe('Code Execution')
|
||||
expect(stripToolsetLabel('❓ Clarifying Questions')).toBe('Clarifying Questions')
|
||||
expect(stripToolsetLabel('🌐 Browser Automation')).toBe('Browser Automation')
|
||||
expect(stripToolsetLabel('🎨 Image Generation')).toBe('Image Generation')
|
||||
})
|
||||
|
||||
it('leaves plain titles unchanged', () => {
|
||||
expect(stripToolsetLabel('Terminal & Processes')).toBe('Terminal & Processes')
|
||||
})
|
||||
})
|
||||
|
||||
describe('toolsetDisplayLabel', () => {
|
||||
it('strips emoji from toolset rows', () => {
|
||||
expect(toolsetDisplayLabel({ name: 'cronjob', label: '⏰ Cron Jobs' })).toBe('Cron Jobs')
|
||||
})
|
||||
})
|
||||
|
||||
describe('providerGroup', () => {
|
||||
it('maps a provider env var to its labeled group', () => {
|
||||
expect(providerGroup('XAI_API_KEY')).toBe('xAI')
|
||||
expect(providerGroup('NOUS_API_KEY')).toBe('Nous Portal')
|
||||
expect(providerGroup('OPENROUTER_API_KEY')).toBe('OpenRouter')
|
||||
})
|
||||
|
||||
it('prefers the longest matching prefix so CN/regional buckets win', () => {
|
||||
// MINIMAX_CN_ must beat the generic MINIMAX_ prefix.
|
||||
expect(providerGroup('MINIMAX_CN_API_KEY')).toBe('MiniMax (China)')
|
||||
expect(providerGroup('MINIMAX_API_KEY')).toBe('MiniMax')
|
||||
// KIMI_CN_ likewise must beat KIMI_.
|
||||
expect(providerGroup('KIMI_CN_API_KEY')).toBe('Kimi (China)')
|
||||
expect(providerGroup('KIMI_API_KEY')).toBe('Kimi / Moonshot')
|
||||
// HERMES_QWEN_ and HERMES_GEMINI_ both share the HERMES_ stem.
|
||||
expect(providerGroup('HERMES_QWEN_BASE_URL')).toBe('DashScope (Qwen)')
|
||||
expect(providerGroup('HERMES_GEMINI_CLIENT_ID')).toBe('Gemini')
|
||||
})
|
||||
|
||||
it('falls back to "Other" for un-grouped env vars', () => {
|
||||
expect(providerGroup('SOMETHING_RANDOM')).toBe('Other')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,6 +8,13 @@ export const includesQuery = (v: unknown, q: string) => asText(v).toLowerCase().
|
||||
|
||||
export const prettyName = (v: string) => v.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
|
||||
|
||||
/** Strip leading emoji from toolset titles (CLI registry prefixes labels with icons). */
|
||||
export const stripToolsetLabel = (label: string): string =>
|
||||
label.replace(/^[\p{Emoji}\p{Extended_Pictographic}\s]+/u, '').trim() || label
|
||||
|
||||
export const toolsetDisplayLabel = (toolset: Pick<ToolsetInfo, 'label' | 'name'>): string =>
|
||||
stripToolsetLabel(asText(toolset.label || toolset.name))
|
||||
|
||||
export const toolNames = (t: ToolsetInfo) => (Array.isArray(t.tools) ? t.tools.map(asText).filter(Boolean) : [])
|
||||
|
||||
export const withoutKey = <T>(record: Record<string, T>, key: string) => {
|
||||
@@ -19,9 +26,30 @@ export const withoutKey = <T>(record: Record<string, T>, key: string) => {
|
||||
|
||||
export const redactedValue = (v: string) => (v.length <= 8 ? '••••' : `${v.slice(0, 4)}...${v.slice(-4)}`)
|
||||
|
||||
export const providerGroup = (key: string) => PROVIDER_GROUPS.find(g => key.startsWith(g.prefix))?.name ?? 'Other'
|
||||
// Longest-prefix match so a more specific group like ``MINIMAX_CN_`` is
|
||||
// chosen over its shorter parent ``MINIMAX_``. Falls back to the bucket
|
||||
// "Other" used by the Keys settings view for un-grouped env vars.
|
||||
export const providerGroup = (key: string) => {
|
||||
let best: (typeof PROVIDER_GROUPS)[number] | undefined
|
||||
|
||||
export const providerPriority = (name: string) => PROVIDER_GROUPS.find(g => g.name === name)?.priority ?? 99
|
||||
for (const candidate of PROVIDER_GROUPS) {
|
||||
if (!key.startsWith(candidate.prefix)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (!best || candidate.prefix.length > best.prefix.length) {
|
||||
best = candidate
|
||||
}
|
||||
}
|
||||
|
||||
return best?.name ?? 'Other'
|
||||
}
|
||||
|
||||
export const providerMeta = (name: string) =>
|
||||
PROVIDER_GROUPS.find(g => g.name === name && (g.description || g.docsUrl)) ??
|
||||
PROVIDER_GROUPS.find(g => g.name === name)
|
||||
|
||||
export const providerPriority = (name: string) => providerMeta(name)?.priority ?? 99
|
||||
|
||||
const POLLUTING_PATH_PARTS = new Set(['__proto__', 'constructor', 'prototype'])
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { IconDownload, IconRefresh, IconUpload } from '@tabler/icons-react'
|
||||
import { useRef } from 'react'
|
||||
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { getHermesConfigDefaults, getHermesConfigRecord, saveHermesConfig } from '@/hermes'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { Archive, Globe, Info, KeyRound, Wrench } from '@/lib/icons'
|
||||
import { Archive, Globe, Info, KeyRound, Settings2, Sparkles, Wrench, Zap } from '@/lib/icons'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
|
||||
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
|
||||
@@ -16,13 +17,15 @@ import { AppearanceSettings } from './appearance-settings'
|
||||
import { ConfigSettings } from './config-settings'
|
||||
import { SECTIONS } from './constants'
|
||||
import { GatewaySettings } from './gateway-settings'
|
||||
import { KeysSettings } from './keys-settings'
|
||||
import { KEYS_VIEWS, KeysSettings, type KeysView } from './keys-settings'
|
||||
import { McpSettings } from './mcp-settings'
|
||||
import { PROVIDER_VIEWS, ProvidersSettings, type ProviderView } from './providers-settings'
|
||||
import { SessionsSettings } from './sessions-settings'
|
||||
import type { SettingsPageProps, SettingsView as SettingsViewId } from './types'
|
||||
|
||||
const SETTINGS_VIEWS: readonly SettingsViewId[] = [
|
||||
...SECTIONS.map(s => `config:${s.id}` as SettingsViewId),
|
||||
'providers',
|
||||
'gateway',
|
||||
'keys',
|
||||
'mcp',
|
||||
@@ -32,6 +35,20 @@ const SETTINGS_VIEWS: readonly SettingsViewId[] = [
|
||||
|
||||
export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChanged }: SettingsPageProps) {
|
||||
const [activeView, setActiveView] = useRouteEnumParam('tab', SETTINGS_VIEWS, 'config:model' as SettingsViewId)
|
||||
// Providers subnav (Accounts vs API keys) lives in its own param so each
|
||||
// sub-view is deep-linkable and survives a refresh.
|
||||
const [providerView, setProviderView] = useRouteEnumParam<ProviderView>('pview', PROVIDER_VIEWS, 'accounts')
|
||||
const [keysView, setKeysView] = useRouteEnumParam<KeysView>('kview', KEYS_VIEWS, 'tools')
|
||||
|
||||
const openProviderView = (view: ProviderView) => {
|
||||
setActiveView('providers')
|
||||
setProviderView(view)
|
||||
}
|
||||
|
||||
const openKeysView = (view: KeysView) => {
|
||||
setActiveView('keys')
|
||||
setKeysView(view)
|
||||
}
|
||||
|
||||
const importInputRef = useRef<HTMLInputElement | null>(null)
|
||||
|
||||
@@ -83,6 +100,30 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
|
||||
)
|
||||
})}
|
||||
<div className="my-2 h-px bg-border/30" />
|
||||
<OverlayNavItem
|
||||
active={activeView === 'providers'}
|
||||
icon={Zap}
|
||||
label="Providers"
|
||||
onClick={() => setActiveView('providers')}
|
||||
/>
|
||||
{activeView === 'providers' && (
|
||||
<div className="ml-3.5 flex flex-col gap-0.5 pl-1.5">
|
||||
<OverlayNavItem
|
||||
active={providerView === 'accounts'}
|
||||
icon={Sparkles}
|
||||
label="Accounts"
|
||||
nested
|
||||
onClick={() => openProviderView('accounts')}
|
||||
/>
|
||||
<OverlayNavItem
|
||||
active={providerView === 'keys'}
|
||||
icon={KeyRound}
|
||||
label="API keys"
|
||||
nested
|
||||
onClick={() => openProviderView('keys')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<OverlayNavItem
|
||||
active={activeView === 'gateway'}
|
||||
icon={Globe}
|
||||
@@ -92,9 +133,27 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
|
||||
<OverlayNavItem
|
||||
active={activeView === 'keys'}
|
||||
icon={KeyRound}
|
||||
label="API Keys"
|
||||
label="Tools & Keys"
|
||||
onClick={() => setActiveView('keys')}
|
||||
/>
|
||||
{activeView === 'keys' && (
|
||||
<div className="ml-3.5 flex flex-col gap-0.5 pl-1.5">
|
||||
<OverlayNavItem
|
||||
active={keysView === 'tools'}
|
||||
icon={Wrench}
|
||||
label="Tools"
|
||||
nested
|
||||
onClick={() => openKeysView('tools')}
|
||||
/>
|
||||
<OverlayNavItem
|
||||
active={keysView === 'settings'}
|
||||
icon={Settings2}
|
||||
label="Settings"
|
||||
nested
|
||||
onClick={() => openKeysView('settings')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<OverlayNavItem
|
||||
active={activeView === 'mcp'}
|
||||
icon={Wrench}
|
||||
@@ -115,28 +174,32 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
|
||||
onClick={() => setActiveView('about')}
|
||||
/>
|
||||
<div className="mt-auto flex items-center gap-1 pt-2">
|
||||
<OverlayIconButton onClick={() => void exportConfig()} title="Export config">
|
||||
<IconDownload className="size-3.5" />
|
||||
</OverlayIconButton>
|
||||
<OverlayIconButton
|
||||
onClick={() => {
|
||||
triggerHaptic('open')
|
||||
importInputRef.current?.click()
|
||||
}}
|
||||
title="Import config"
|
||||
>
|
||||
<IconUpload className="size-3.5" />
|
||||
</OverlayIconButton>
|
||||
<OverlayIconButton
|
||||
className="hover:text-destructive"
|
||||
onClick={() => {
|
||||
triggerHaptic('warning')
|
||||
void resetConfig()
|
||||
}}
|
||||
title="Reset to defaults"
|
||||
>
|
||||
<IconRefresh className="size-3.5" />
|
||||
</OverlayIconButton>
|
||||
<Tip label="Export config">
|
||||
<OverlayIconButton onClick={() => void exportConfig()}>
|
||||
<IconDownload className="size-3.5" />
|
||||
</OverlayIconButton>
|
||||
</Tip>
|
||||
<Tip label="Import config">
|
||||
<OverlayIconButton
|
||||
onClick={() => {
|
||||
triggerHaptic('open')
|
||||
importInputRef.current?.click()
|
||||
}}
|
||||
>
|
||||
<IconUpload className="size-3.5" />
|
||||
</OverlayIconButton>
|
||||
</Tip>
|
||||
<Tip label="Reset to defaults">
|
||||
<OverlayIconButton
|
||||
className="hover:text-destructive"
|
||||
onClick={() => {
|
||||
triggerHaptic('warning')
|
||||
void resetConfig()
|
||||
}}
|
||||
>
|
||||
<IconRefresh className="size-3.5" />
|
||||
</OverlayIconButton>
|
||||
</Tip>
|
||||
</div>
|
||||
</OverlaySidebar>
|
||||
|
||||
@@ -154,8 +217,10 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
|
||||
onConfigSaved={onConfigSaved}
|
||||
onMainModelChanged={onMainModelChanged}
|
||||
/>
|
||||
) : activeView === 'providers' ? (
|
||||
<ProvidersSettings onViewChange={setProviderView} view={providerView} />
|
||||
) : activeView === 'keys' ? (
|
||||
<KeysSettings />
|
||||
<KeysSettings view={keysView} />
|
||||
) : activeView === 'mcp' ? (
|
||||
<McpSettings gateway={gateway} onConfigSaved={onConfigSaved} />
|
||||
) : (
|
||||
|
||||
@@ -1,425 +1,94 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { deleteEnvVar, getEnvVars, revealEnvVar, setEnvVar } from '@/hermes'
|
||||
import { Check, Eye, EyeOff, Save, Settings2, Trash2, Zap } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import type { EnvVarInfo } from '@/types/hermes'
|
||||
|
||||
import { CONTROL_TEXT } from './constants'
|
||||
import { asText, prettyName, providerGroup, providerPriority, redactedValue, withoutKey } from './helpers'
|
||||
import { LoadingState, Pill, SectionHeading, SettingsContent } from './primitives'
|
||||
import type { EnvPatch, EnvRowProps, ProviderGroup } from './types'
|
||||
import { useDeepLinkHighlight } from './use-deep-link-highlight'
|
||||
import { CredentialKeyCard, credentialPlaceholder, credentialRowLabel } from './credential-key-ui'
|
||||
import { useEnvCredentials } from './env-credentials'
|
||||
import { asText } from './helpers'
|
||||
import { LoadingState, SettingsContent } from './primitives'
|
||||
|
||||
interface EnvActionsProps {
|
||||
varKey: string
|
||||
info: EnvVarInfo
|
||||
saving: string | null
|
||||
onEdit: () => void
|
||||
onClear: (key: string) => void
|
||||
onReveal: (key: string) => void
|
||||
isRevealed: boolean
|
||||
showReveal?: boolean
|
||||
// Sub-views surfaced as sidebar subnav under Tools & Keys (see settings/index.tsx).
|
||||
export const KEYS_VIEWS = ['tools', 'settings'] as const
|
||||
|
||||
export type KeysView = (typeof KEYS_VIEWS)[number]
|
||||
|
||||
// Providers live on their own page; messaging-platform credentials live on the
|
||||
// dedicated Messaging page (and are hidden here via `channel_managed`). This
|
||||
// view covers tool API keys plus server/setting env vars (API server, webhook,
|
||||
// gateway), which fold into the Settings subnav.
|
||||
|
||||
// Backend categories that surface under each subnav. Platform credentials use the
|
||||
// `messaging` category but are flagged ``channel_managed`` and configured on
|
||||
// the Messaging page; only gateway-wide ``messaging`` rows (e.g. GATEWAY_PROXY)
|
||||
// appear here alongside ``setting``.
|
||||
const VIEW_CATEGORIES: Record<KeysView, readonly string[]> = {
|
||||
settings: ['setting', 'messaging'],
|
||||
tools: ['tool']
|
||||
}
|
||||
|
||||
function EnvActions({
|
||||
varKey,
|
||||
info,
|
||||
saving,
|
||||
onEdit,
|
||||
onClear,
|
||||
onReveal,
|
||||
isRevealed,
|
||||
showReveal = true
|
||||
}: EnvActionsProps) {
|
||||
return (
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
{info.url && (
|
||||
<Button asChild size="xs" title="Open provider docs" variant="ghost">
|
||||
<a href={info.url} rel="noreferrer" target="_blank">
|
||||
Docs
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
{info.is_set && showReveal && (
|
||||
<Button
|
||||
onClick={() => onReveal(varKey)}
|
||||
size="icon-xs"
|
||||
title={isRevealed ? 'Hide value' : 'Reveal value'}
|
||||
variant="ghost"
|
||||
>
|
||||
{isRevealed ? <EyeOff /> : <Eye />}
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={onEdit} size="xs" variant="textStrong">
|
||||
{info.is_set ? 'Replace' : 'Set'}
|
||||
</Button>
|
||||
{info.is_set && (
|
||||
<Button
|
||||
disabled={saving === varKey}
|
||||
onClick={() => onClear(varKey)}
|
||||
size="icon-xs"
|
||||
title="Clear value"
|
||||
variant="ghost"
|
||||
>
|
||||
<Trash2 />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EnvVarRow({
|
||||
varKey,
|
||||
info,
|
||||
edits,
|
||||
revealed,
|
||||
saving,
|
||||
setEdits,
|
||||
onSave,
|
||||
onClear,
|
||||
onReveal,
|
||||
compact = false
|
||||
}: EnvRowProps) {
|
||||
const isEditing = edits[varKey] !== undefined
|
||||
const isRevealed = revealed[varKey] !== undefined
|
||||
const value = isRevealed ? revealed[varKey] : info.redacted_value
|
||||
const startEdit = () => setEdits(c => ({ ...c, [varKey]: '' }))
|
||||
|
||||
if (compact && !isEditing) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3 py-1.5">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-mono text-[0.72rem] text-muted-foreground">{varKey}</div>
|
||||
<div className="truncate text-[0.68rem] text-muted-foreground/70">{info.description}</div>
|
||||
</div>
|
||||
<EnvActions
|
||||
info={info}
|
||||
isRevealed={isRevealed}
|
||||
onClear={onClear}
|
||||
onEdit={startEdit}
|
||||
onReveal={onReveal}
|
||||
saving={saving}
|
||||
showReveal={false}
|
||||
varKey={varKey}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-2 rounded-xl bg-background/55 p-3">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-mono text-xs font-medium">{varKey}</span>
|
||||
<Pill tone={info.is_set ? 'primary' : 'muted'}>
|
||||
{info.is_set && <Check className="size-3" />}
|
||||
{info.is_set ? 'Set' : 'Not set'}
|
||||
</Pill>
|
||||
</div>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">{info.description}</p>
|
||||
</div>
|
||||
<EnvActions
|
||||
info={info}
|
||||
isRevealed={isRevealed}
|
||||
onClear={onClear}
|
||||
onEdit={startEdit}
|
||||
onReveal={onReveal}
|
||||
saving={saving}
|
||||
varKey={varKey}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!isEditing && info.is_set && (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-md px-3 py-2 font-mono text-xs',
|
||||
isRevealed ? 'bg-background text-foreground' : 'bg-muted/30 text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{value || '---'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isEditing && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Input
|
||||
autoFocus
|
||||
className={cn('min-w-56 flex-1 font-mono', CONTROL_TEXT)}
|
||||
onChange={e => setEdits(c => ({ ...c, [varKey]: e.target.value }))}
|
||||
placeholder={info.is_set ? 'Replace current value' : 'Enter value'}
|
||||
type={info.is_password ? 'password' : 'text'}
|
||||
value={edits[varKey]}
|
||||
/>
|
||||
<Button disabled={saving === varKey || !edits[varKey]} onClick={() => onSave(varKey)} size="sm">
|
||||
<Save />
|
||||
{saving === varKey ? 'Saving' : 'Save'}
|
||||
</Button>
|
||||
<Button onClick={() => setEdits(c => withoutKey(c, varKey))} size="sm" variant="text">
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EnvProviderGroup({
|
||||
group,
|
||||
rowProps,
|
||||
forceExpand = false
|
||||
}: {
|
||||
group: ProviderGroup
|
||||
rowProps: Omit<EnvRowProps, 'varKey' | 'info'>
|
||||
forceExpand?: boolean
|
||||
}) {
|
||||
const setCount = group.entries.filter(([, info]) => info.is_set).length
|
||||
// Default-expand providers that already have at least one key set; the
|
||||
// user is much more likely to be coming back to edit those than to start
|
||||
// configuring a fresh provider from scratch.
|
||||
const [expanded, setExpanded] = useState(setCount > 0 || forceExpand)
|
||||
export function KeysSettings({ view }: KeysSettingsProps) {
|
||||
const { rowProps, vars } = useEnvCredentials()
|
||||
const [openKey, setOpenKey] = useState<null | string>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (forceExpand) {
|
||||
setExpanded(true)
|
||||
}
|
||||
}, [forceExpand])
|
||||
setOpenKey(null)
|
||||
}, [view])
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-xl bg-background/60">
|
||||
<button
|
||||
className="flex w-full items-center justify-between gap-3 bg-transparent px-3 py-2.5 text-left hover:bg-accent/50"
|
||||
onClick={() => setExpanded(e => !e)}
|
||||
type="button"
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<Zap className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate text-sm font-medium">
|
||||
{group.name === 'Other' ? 'Other providers' : group.name}
|
||||
</span>
|
||||
{setCount > 0 && <Pill tone="primary">{setCount} set</Pill>}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">{group.entries.length} keys</span>
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className="grid gap-2 bg-muted/20 p-3">
|
||||
{group.entries.map(([key, info]) => (
|
||||
<div className="scroll-mt-6 rounded-md" id={`env-var-${key}`} key={key}>
|
||||
<EnvVarRow compact={!info.is_set} info={info} varKey={key} {...rowProps} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function KeysSettings() {
|
||||
const [vars, setVars] = useState<Record<string, EnvVarInfo> | null>(null)
|
||||
const [edits, setEdits] = useState<Record<string, string>>({})
|
||||
const [revealed, setRevealed] = useState<Record<string, string>>({})
|
||||
const [saving, setSaving] = useState<string | null>(null)
|
||||
|
||||
// Deep-link from the command palette (?key=<ENV_VAR>): force-expand the
|
||||
// matching provider group, scroll the row in, and flash it.
|
||||
const highlightKey = useDeepLinkHighlight({
|
||||
elementId: key => `env-var-${key}`,
|
||||
param: 'key',
|
||||
ready: key => Boolean(vars?.[key])
|
||||
})
|
||||
|
||||
// We used to hide ~80% of rows behind a global "Show advanced" toggle, but
|
||||
// everything in this view is configuration-level — "advanced" was a poor
|
||||
// distinction. The full list is rendered now and provider groups
|
||||
// default-collapsed-unless-set keep the surface manageable.
|
||||
useEffect(() => {
|
||||
try {
|
||||
window.localStorage.removeItem('desktop.settings.keys.show_advanced')
|
||||
} catch {
|
||||
// Ignore — old key cleanup is best-effort.
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const next = await getEnvVars()
|
||||
|
||||
if (!cancelled) {
|
||||
setVars(next)
|
||||
}
|
||||
} catch (err) {
|
||||
notifyError(err, 'API keys failed to load')
|
||||
}
|
||||
})()
|
||||
|
||||
return () => void (cancelled = true)
|
||||
}, [])
|
||||
|
||||
const providerGroups = useMemo<ProviderGroup[]>(() => {
|
||||
const groups = useMemo(() => {
|
||||
if (!vars) {
|
||||
return []
|
||||
}
|
||||
|
||||
const entries = Object.entries(vars).filter(([, info]) => asText(info.category) === 'provider')
|
||||
return KEYS_VIEWS.flatMap(v => {
|
||||
const cats = VIEW_CATEGORIES[v]
|
||||
|
||||
const groups = new Map<string, [string, EnvVarInfo][]>()
|
||||
|
||||
for (const entry of entries) {
|
||||
const name = providerGroup(entry[0])
|
||||
groups.set(name, [...(groups.get(name) ?? []), entry])
|
||||
}
|
||||
|
||||
return Array.from(groups, ([name, entries]) => ({
|
||||
name,
|
||||
priority: providerPriority(name),
|
||||
entries: entries.sort(([a], [b]) => a.localeCompare(b)),
|
||||
hasAnySet: entries.some(([, info]) => info.is_set)
|
||||
})).sort((a, b) => a.priority - b.priority || a.name.localeCompare(b.name))
|
||||
}, [vars])
|
||||
|
||||
const otherGroups = useMemo(() => {
|
||||
if (!vars) {
|
||||
return []
|
||||
}
|
||||
|
||||
const labels: Record<string, string> = {
|
||||
tool: 'Tools',
|
||||
messaging: 'Messaging',
|
||||
setting: 'Settings'
|
||||
}
|
||||
|
||||
return ['tool', 'messaging', 'setting'].flatMap(cat => {
|
||||
const entries = Object.entries(vars)
|
||||
.filter(([, info]) => asText(info.category) === cat)
|
||||
.filter(([, info]) => !info.channel_managed && cats.includes(asText(info.category)))
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
|
||||
return entries.length === 0 ? [] : [{ category: cat, label: labels[cat] ?? prettyName(cat), entries }]
|
||||
return entries.length === 0 ? [] : [{ category: v, entries }]
|
||||
})
|
||||
}, [vars])
|
||||
|
||||
function patchVar(key: string, patch: EnvPatch) {
|
||||
setVars(c => (c ? { ...c, [key]: { ...c[key], ...patch } } : c))
|
||||
}
|
||||
|
||||
function clearLocalState(key: string) {
|
||||
setEdits(c => withoutKey(c, key))
|
||||
setRevealed(c => withoutKey(c, key))
|
||||
}
|
||||
|
||||
async function handleSave(key: string) {
|
||||
const value = edits[key]
|
||||
|
||||
if (!value) {
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(key)
|
||||
|
||||
try {
|
||||
await setEnvVar(key, value)
|
||||
patchVar(key, { is_set: true, redacted_value: redactedValue(value) })
|
||||
clearLocalState(key)
|
||||
notify({ kind: 'success', title: 'Credential saved', message: `${key} updated.` })
|
||||
} catch (err) {
|
||||
notifyError(err, `Failed to save ${key}`)
|
||||
} finally {
|
||||
setSaving(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleClear(key: string) {
|
||||
if (!window.confirm(`Remove ${key} from .env?`)) {
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(key)
|
||||
|
||||
try {
|
||||
await deleteEnvVar(key)
|
||||
patchVar(key, { is_set: false, redacted_value: null })
|
||||
clearLocalState(key)
|
||||
notify({ kind: 'success', title: 'Credential removed', message: `${key} removed.` })
|
||||
} catch (err) {
|
||||
notifyError(err, `Failed to remove ${key}`)
|
||||
} finally {
|
||||
setSaving(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReveal(key: string) {
|
||||
if (revealed[key]) {
|
||||
setRevealed(c => withoutKey(c, key))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await revealEnvVar(key)
|
||||
setRevealed(c => ({ ...c, [key]: result.value }))
|
||||
} catch (err) {
|
||||
notifyError(err, `Failed to reveal ${key}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (!vars) {
|
||||
return <LoadingState label="Loading API keys and credentials..." />
|
||||
}
|
||||
|
||||
const rowProps = {
|
||||
edits,
|
||||
revealed,
|
||||
saving,
|
||||
setEdits,
|
||||
onSave: handleSave,
|
||||
onClear: handleClear,
|
||||
onReveal: handleReveal
|
||||
}
|
||||
|
||||
const configuredCount = providerGroups.filter(g => g.hasAnySet).length
|
||||
const visible = groups.filter(g => g.category === view)
|
||||
|
||||
return (
|
||||
<SettingsContent>
|
||||
<div className="mb-6">
|
||||
<SectionHeading
|
||||
icon={Zap}
|
||||
meta={`${configuredCount} of ${providerGroups.length} configured`}
|
||||
title="LLM providers"
|
||||
/>
|
||||
<div className="grid gap-2">
|
||||
{providerGroups.map(group => (
|
||||
<EnvProviderGroup
|
||||
forceExpand={Boolean(highlightKey) && group.entries.some(([key]) => key === highlightKey)}
|
||||
group={group}
|
||||
key={group.name}
|
||||
rowProps={rowProps}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{visible.map(group => (
|
||||
<div className="grid gap-2" key={group.category}>
|
||||
{group.entries.map(([key, info]: [string, EnvVarInfo]) => {
|
||||
const label = credentialRowLabel(key, info)
|
||||
|
||||
{otherGroups.map(group => (
|
||||
<div className="mb-6" key={group.category}>
|
||||
<SectionHeading
|
||||
icon={Settings2}
|
||||
meta={`${group.entries.filter(([, i]) => i.is_set).length} of ${group.entries.length} set`}
|
||||
title={group.label}
|
||||
/>
|
||||
<div className="grid gap-2">
|
||||
{group.entries.map(([key, info]) => (
|
||||
<div className="scroll-mt-6 rounded-md" id={`env-var-${key}`} key={key}>
|
||||
<EnvVarRow info={info} varKey={key} {...rowProps} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
return (
|
||||
<CredentialKeyCard
|
||||
expanded={openKey === key}
|
||||
info={info}
|
||||
key={key}
|
||||
label={label}
|
||||
onExpand={() => setOpenKey(key)}
|
||||
onToggle={() => setOpenKey(prev => (prev === key ? null : key))}
|
||||
placeholder={credentialPlaceholder(key, info, label)}
|
||||
rowProps={rowProps}
|
||||
varKey={key}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{visible.length === 0 && (
|
||||
<div className="rounded-lg border border-dashed border-(--ui-stroke-tertiary) px-4 py-8 text-center text-[length:var(--conversation-caption-font-size)] text-muted-foreground">
|
||||
Nothing configured in this category yet.
|
||||
</div>
|
||||
)}
|
||||
</SettingsContent>
|
||||
)
|
||||
}
|
||||
|
||||
interface KeysSettingsProps {
|
||||
view: KeysView
|
||||
}
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from '@/components/ui/select'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { getAuxiliaryModels, getGlobalModelInfo, getGlobalModelOptions, setModelAssignment } from '@/hermes'
|
||||
import type { AuxiliaryModelsResponse, ModelOptionProvider } from '@/hermes'
|
||||
import { Cpu, Loader2 } from '@/lib/icons'
|
||||
@@ -29,7 +23,6 @@ const AUX_TASKS: readonly AuxTaskMeta[] = [
|
||||
{ key: 'vision', label: 'Vision', hint: 'Image analysis' },
|
||||
{ key: 'web_extract', label: 'Web extract', hint: 'Page summarization' },
|
||||
{ key: 'compression', label: 'Compression', hint: 'Context compaction' },
|
||||
{ key: 'session_search', label: 'Session search', hint: 'Recall queries' },
|
||||
{ key: 'skills_hub', label: 'Skills hub', hint: 'Skill search' },
|
||||
{ key: 'approval', label: 'Approval', hint: 'Smart auto-approve' },
|
||||
{ key: 'mcp', label: 'MCP', hint: 'MCP tool routing' },
|
||||
@@ -232,7 +225,11 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button disabled={!selectedProvider || !selectedModel || applying} onClick={() => void applyMainModel()} size="sm">
|
||||
<Button
|
||||
disabled={!selectedProvider || !selectedModel || applying}
|
||||
onClick={() => void applyMainModel()}
|
||||
size="sm"
|
||||
>
|
||||
{applying && <Loader2 className="size-3.5 animate-spin" />}
|
||||
{applying ? 'Applying...' : 'Apply'}
|
||||
</Button>
|
||||
@@ -333,7 +330,9 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
|
||||
}
|
||||
description={
|
||||
<span className="font-mono text-[0.68rem]">
|
||||
{isAuto ? 'auto · use main model' : `${current.provider} · ${current.model || '(provider default)'}`}
|
||||
{isAuto
|
||||
? 'auto · use main model'
|
||||
: `${current.provider} · ${current.model || '(provider default)'}`}
|
||||
</span>
|
||||
}
|
||||
key={meta.key}
|
||||
|
||||
251
apps/desktop/src/app/settings/providers-settings.tsx
Normal file
251
apps/desktop/src/app/settings/providers-settings.tsx
Normal file
@@ -0,0 +1,251 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import {
|
||||
FEATURED_ID,
|
||||
FeaturedProviderRow,
|
||||
KeyProviderRow,
|
||||
ProviderRow,
|
||||
sortProviders
|
||||
} from '@/components/desktop-onboarding-overlay'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { listOAuthProviders } from '@/hermes'
|
||||
import { ChevronDown, KeyRound } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $desktopOnboarding, startManualProviderOAuth } from '@/store/onboarding'
|
||||
import type { EnvVarInfo, OAuthProvider } from '@/types/hermes'
|
||||
|
||||
import { isKeyVar, ProviderKeyRows } from './credential-key-ui'
|
||||
import { SettingsCategoryHeading, useEnvCredentials } from './env-credentials'
|
||||
import { providerGroup, providerMeta, providerPriority } from './helpers'
|
||||
import { LoadingState, SettingsContent } from './primitives'
|
||||
|
||||
// Sub-views surfaced as a sidebar subnav: account sign-in vs raw API keys.
|
||||
export const PROVIDER_VIEWS = ['accounts', 'keys'] as const
|
||||
|
||||
export type ProviderView = (typeof PROVIDER_VIEWS)[number]
|
||||
|
||||
// Group the env catalog by provider — one ListRow per vendor plus optional
|
||||
// advanced overrides (base URL, region, etc.). Groups without a key field and
|
||||
// the "Other" bucket are skipped.
|
||||
function buildProviderKeyGroups(vars: Record<string, EnvVarInfo>): ProviderKeyGroup[] {
|
||||
const buckets = new Map<string, [string, EnvVarInfo][]>()
|
||||
|
||||
for (const [key, info] of Object.entries(vars)) {
|
||||
if (info.category !== 'provider') {
|
||||
continue
|
||||
}
|
||||
|
||||
const name = providerGroup(key)
|
||||
|
||||
if (name === 'Other') {
|
||||
continue
|
||||
}
|
||||
|
||||
buckets.set(name, [...(buckets.get(name) ?? []), [key, info]])
|
||||
}
|
||||
|
||||
const groups: ProviderKeyGroup[] = []
|
||||
|
||||
for (const [name, entries] of buckets) {
|
||||
const primary = entries.find(([k, i]) => !i.advanced && isKeyVar(k, i)) ?? entries.find(([k, i]) => isKeyVar(k, i))
|
||||
|
||||
if (!primary) {
|
||||
continue
|
||||
}
|
||||
|
||||
const meta = providerMeta(name)
|
||||
|
||||
groups.push({
|
||||
// Advanced = the provider's non-key knobs (base URL, region, deployment).
|
||||
// Skip redundant alias key vars (e.g. ANTHROPIC_TOKEN vs ANTHROPIC_API_KEY)
|
||||
// so we never render a second "Paste key" input — unless one is already
|
||||
// set, in which case keep it visible so it stays clearable.
|
||||
advanced: entries
|
||||
.filter(([k, i]) => k !== primary[0] && (!isKeyVar(k, i) || i.is_set))
|
||||
.sort(([a], [b]) => a.localeCompare(b)),
|
||||
description: meta?.description ?? primary[1].description,
|
||||
docsUrl: meta?.docsUrl ?? primary[1].url ?? undefined,
|
||||
hasAnySet: entries.some(([, i]) => i.is_set),
|
||||
name,
|
||||
primary,
|
||||
priority: providerPriority(name)
|
||||
})
|
||||
}
|
||||
|
||||
return groups.sort((a, b) => a.priority - b.priority || a.name.localeCompare(b.name))
|
||||
}
|
||||
|
||||
// Deliberately a near-1:1 replica of the first-run onboarding picker
|
||||
// (`Picker` in desktop-onboarding-overlay): same recommended card, same
|
||||
// provider rows, same "Other providers" disclosure, same OpenRouter quick-key
|
||||
// row, and the same bottom-right "I have an API key" affordance. The leaf cards
|
||||
// are the exact shared components, so the two surfaces stay visually identical.
|
||||
// Selecting a provider hands off to the shared onboarding overlay, which runs
|
||||
// that provider's real sign-in flow; the key affordances open the API-key
|
||||
// catalog below.
|
||||
function OAuthPicker({ onWantApiKey, providers }: { onWantApiKey: () => void; providers: OAuthProvider[] }) {
|
||||
const [showAll, setShowAll] = useState(false)
|
||||
const ordered = useMemo(() => sortProviders(providers), [providers])
|
||||
|
||||
if (ordered.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const select = (p: OAuthProvider) => startManualProviderOAuth(p.id)
|
||||
|
||||
const featured = ordered.find(p => p.id === FEATURED_ID) ?? null
|
||||
const rest = featured ? ordered.filter(p => p.id !== FEATURED_ID) : ordered
|
||||
// Keep connected accounts grouped and always visible; only the unconnected
|
||||
// providers hide behind the disclosure, so the page leads with what's set up.
|
||||
const connected = rest.filter(p => p.status?.logged_in)
|
||||
const others = rest.filter(p => !p.status?.logged_in)
|
||||
const collapsible = others.length > 0
|
||||
const showOthers = !collapsible || showAll
|
||||
|
||||
return (
|
||||
<section className="mb-5 grid gap-2">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-x-3">
|
||||
<SettingsCategoryHeading icon={KeyRound} title="Connect an account" />
|
||||
<Button
|
||||
className="h-auto px-0 py-0 text-[length:var(--conversation-caption-font-size)]"
|
||||
onClick={onWantApiKey}
|
||||
type="button"
|
||||
variant="textStrong"
|
||||
>
|
||||
Have an API key instead?
|
||||
</Button>
|
||||
</div>
|
||||
<p className="-mt-2 mb-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||
Sign in with a subscription — no API key to copy. Hermes runs the browser sign-in for you, right here in the
|
||||
app.
|
||||
</p>
|
||||
{featured && <FeaturedProviderRow onSelect={select} provider={featured} />}
|
||||
{connected.length > 0 && (
|
||||
<>
|
||||
<p className="mt-1 px-0.5 text-[length:var(--conversation-caption-font-size)] font-medium text-(--ui-text-tertiary)">
|
||||
Connected
|
||||
</p>
|
||||
{connected.map(p => (
|
||||
<ProviderRow key={p.id} onSelect={select} provider={p} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{showOthers && (
|
||||
<>
|
||||
{others.map(p => (
|
||||
<ProviderRow key={p.id} onSelect={select} provider={p} />
|
||||
))}
|
||||
<KeyProviderRow onClick={onWantApiKey} />
|
||||
</>
|
||||
)}
|
||||
{collapsible && (
|
||||
<Button
|
||||
className="h-auto px-0 py-1 text-[length:var(--conversation-caption-font-size)]"
|
||||
onClick={() => setShowAll(v => !v)}
|
||||
type="button"
|
||||
variant="text"
|
||||
>
|
||||
{showAll ? 'Collapse' : connected.length > 0 ? 'Connect another provider' : 'Other providers'}
|
||||
<ChevronDown className={cn('size-3.5 transition', showAll && 'rotate-180')} />
|
||||
</Button>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function NoProviderKeys() {
|
||||
return (
|
||||
<div className="grid min-h-32 place-items-center px-4 py-8 text-center text-[length:var(--conversation-caption-font-size)] text-muted-foreground">
|
||||
No provider API keys available.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ProvidersSettings({ onViewChange, view }: ProvidersSettingsProps) {
|
||||
const { rowProps, vars } = useEnvCredentials()
|
||||
const [oauthProviders, setOauthProviders] = useState<OAuthProvider[]>([])
|
||||
const [openProvider, setOpenProvider] = useState<null | string>(null)
|
||||
// The onboarding overlay owns the OAuth flow. Watch its `manual` flag so we
|
||||
// re-read connection state when the user finishes (or dismisses) a sign-in
|
||||
// they launched from this page — otherwise the cards keep their stale status.
|
||||
const onboardingActive = useStore($desktopOnboarding).manual
|
||||
|
||||
useEffect(() => {
|
||||
if (onboardingActive) {
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
|
||||
// OAuth providers are best-effort — a failure here just hides the panel.
|
||||
void (async () => {
|
||||
try {
|
||||
const { providers } = await listOAuthProviders()
|
||||
|
||||
if (!cancelled) {
|
||||
setOauthProviders(providers)
|
||||
}
|
||||
} catch {
|
||||
// Ignore — the OAuth panel just won't render.
|
||||
}
|
||||
})()
|
||||
|
||||
return () => void (cancelled = true)
|
||||
}, [onboardingActive])
|
||||
|
||||
if (!vars) {
|
||||
return <LoadingState label="Loading providers..." />
|
||||
}
|
||||
|
||||
const hasOauth = oauthProviders.length > 0
|
||||
// The sidebar subnav owns the Accounts/API-keys split now; with no OAuth
|
||||
// providers there's nothing for the "Accounts" view to show, so fall to keys.
|
||||
const showApiKeys = view === 'keys' || !hasOauth
|
||||
|
||||
const keyGroups = buildProviderKeyGroups(vars)
|
||||
|
||||
if (showApiKeys) {
|
||||
return (
|
||||
<SettingsContent>
|
||||
{keyGroups.length > 0 ? (
|
||||
<div className="grid gap-2">
|
||||
{keyGroups.map(group => (
|
||||
<ProviderKeyRows
|
||||
expanded={openProvider === group.name}
|
||||
group={group}
|
||||
key={group.name}
|
||||
onExpand={() => setOpenProvider(group.name)}
|
||||
onToggle={() => setOpenProvider(prev => (prev === group.name ? null : group.name))}
|
||||
rowProps={rowProps}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<NoProviderKeys />
|
||||
)}
|
||||
</SettingsContent>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsContent>
|
||||
<OAuthPicker onWantApiKey={() => onViewChange('keys')} providers={oauthProviders} />
|
||||
</SettingsContent>
|
||||
)
|
||||
}
|
||||
|
||||
interface ProviderKeyGroup {
|
||||
advanced: [string, EnvVarInfo][]
|
||||
description?: string
|
||||
docsUrl?: string
|
||||
hasAnySet: boolean
|
||||
name: string
|
||||
primary: [string, EnvVarInfo]
|
||||
priority: number
|
||||
}
|
||||
|
||||
interface ProvidersSettingsProps {
|
||||
onViewChange: (view: ProviderView) => void
|
||||
view: ProviderView
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { deleteSession, listSessions, setSessionArchived } from '@/hermes'
|
||||
import { sessionTitle } from '@/lib/chat-runtime'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
@@ -134,18 +135,19 @@ export function SessionsSettings() {
|
||||
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <ArchiveOff className="size-3.5" />}
|
||||
<span>Unarchive</span>
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Delete permanently"
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
disabled={busy}
|
||||
onClick={() => void remove(session)}
|
||||
size="icon"
|
||||
title="Delete permanently"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
<Tip label="Delete permanently">
|
||||
<Button
|
||||
aria-label="Delete permanently"
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
disabled={busy}
|
||||
onClick={() => void remove(session)}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</Tip>
|
||||
</div>
|
||||
}
|
||||
description={session.preview || undefined}
|
||||
@@ -251,13 +253,7 @@ function DefaultProjectDirSetting() {
|
||||
<ListRow
|
||||
action={
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
disabled={busy}
|
||||
onClick={() => void choose()}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="textStrong"
|
||||
>
|
||||
<Button disabled={busy} onClick={() => void choose()} size="sm" type="button" variant="textStrong">
|
||||
<FolderOpen className="size-3.5" />
|
||||
<span>{dir ? 'Change' : 'Choose'}</span>
|
||||
</Button>
|
||||
|
||||
@@ -4,11 +4,12 @@ import { PageLoader } from '@/components/page-loader'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { deleteEnvVar, getToolsetConfig, revealEnvVar, selectToolsetProvider, setEnvVar } from '@/hermes'
|
||||
import { Check, ExternalLink, Eye, EyeOff, Loader2, Save, Trash2 } from '@/lib/icons'
|
||||
import { Check, Loader2, Save } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import type { ToolEnvVar, ToolProvider, ToolsetConfig } from '@/types/hermes'
|
||||
|
||||
import { EnvVarActionsMenu, EnvVarActionsTrigger } from './env-var-actions-menu'
|
||||
import { Pill } from './primitives'
|
||||
|
||||
interface ToolsetConfigPanelProps {
|
||||
@@ -108,33 +109,26 @@ function EnvVarField({ envVar, isSet, onSaved, onCleared }: EnvVarFieldProps) {
|
||||
<p className="mt-0.5 text-[0.7rem] text-muted-foreground">{envVar.prompt}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
{envVar.url && (
|
||||
<Button asChild size="xs" title="Open provider docs" variant="ghost">
|
||||
<a href={envVar.url} rel="noreferrer" target="_blank">
|
||||
Docs
|
||||
<ExternalLink className="size-3" />
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
{isSet && (
|
||||
<Button onClick={() => void handleReveal()} size="icon-xs" title="Reveal value" variant="ghost">
|
||||
{revealed !== null ? <EyeOff /> : <Eye />}
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={() => setEditing(e => !e)} size="xs" variant="textStrong">
|
||||
{isSet ? 'Replace' : 'Set'}
|
||||
</Button>
|
||||
{isSet && (
|
||||
<Button disabled={busy} onClick={() => void handleClear()} size="icon-xs" title="Clear value" variant="ghost">
|
||||
<Trash2 />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{!editing && (
|
||||
<EnvVarActionsMenu
|
||||
clearDisabled={busy}
|
||||
docsUrl={envVar.url}
|
||||
isRevealed={revealed !== null}
|
||||
isSet={isSet}
|
||||
label={envVar.key}
|
||||
onClear={() => void handleClear()}
|
||||
onEdit={() => setEditing(true)}
|
||||
onReveal={() => void handleReveal()}
|
||||
>
|
||||
<EnvVarActionsTrigger label={envVar.key} onClick={event => event.stopPropagation()} />
|
||||
</EnvVarActionsMenu>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isSet && revealed !== null && (
|
||||
<div className="rounded-md bg-background px-2.5 py-1.5 font-mono text-xs text-foreground">{revealed || '---'}</div>
|
||||
<div className="rounded-md bg-background px-2.5 py-1.5 font-mono text-xs text-foreground">
|
||||
{revealed || '---'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editing && (
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { HermesGateway } from '@/hermes'
|
||||
import type { IconComponent } from '@/lib/icons'
|
||||
import type { EnvVarInfo } from '@/types/hermes'
|
||||
|
||||
export type SettingsView = 'about' | 'gateway' | 'keys' | 'mcp' | 'sessions' | `config:${string}`
|
||||
export type SettingsView = 'about' | 'gateway' | 'keys' | 'mcp' | 'providers' | 'sessions' | `config:${string}`
|
||||
export type EnvPatch = Partial<Pick<EnvVarInfo, 'is_set' | 'redacted_value'>>
|
||||
|
||||
export interface SettingsPageProps {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useStore } from '@nanostores/react'
|
||||
import type { CSSProperties, ReactNode } from 'react'
|
||||
import { useSyncExternalStore } from 'react'
|
||||
|
||||
import { NotificationStack } from '@/components/notifications'
|
||||
import { PaneShell } from '@/components/pane-shell'
|
||||
import { SidebarProvider } from '@/components/ui/sidebar'
|
||||
import {
|
||||
@@ -153,6 +154,10 @@ export function AppShell({
|
||||
</main>
|
||||
|
||||
{overlays}
|
||||
|
||||
{/* Mounted at the shell root (after overlays) so success/error toasts
|
||||
surface above every route and overlay — not just the chat view. */}
|
||||
<NotificationStack />
|
||||
</SidebarProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { IconLayoutDashboard } from '@tabler/icons-react'
|
||||
|
||||
import { StatusDot, type StatusTone } from '@/components/status-dot'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { Activity, AlertCircle } from '@/lib/icons'
|
||||
import type { RuntimeReadinessResult } from '@/lib/runtime-readiness'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -76,16 +77,17 @@ export function GatewayMenuPanel({
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
aria-label="Open system panel"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={onOpenSystem}
|
||||
size="icon-sm"
|
||||
title="Open system panel"
|
||||
variant="ghost"
|
||||
>
|
||||
<IconLayoutDashboard />
|
||||
</Button>
|
||||
<Tip label="Open system panel">
|
||||
<Button
|
||||
aria-label="Open system panel"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={onOpenSystem}
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
>
|
||||
<IconLayoutDashboard />
|
||||
</Button>
|
||||
</Tip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -99,13 +101,11 @@ export function GatewayMenuPanel({
|
||||
<SectionLabel>Recent activity</SectionLabel>
|
||||
<ul className="mt-1.5 space-y-0.5">
|
||||
{recentLogs.map((line, index) => (
|
||||
<li
|
||||
className="truncate font-mono text-[0.68rem] text-muted-foreground/85"
|
||||
key={`${index}:${line}`}
|
||||
title={line.trim()}
|
||||
>
|
||||
{trimLogLine(line) || '\u00A0'}
|
||||
</li>
|
||||
<Tip key={`${index}:${line}`} label={line.trim()}>
|
||||
<li className="truncate font-mono text-[0.68rem] text-muted-foreground/85">
|
||||
{trimLogLine(line) || '\u00A0'}
|
||||
</li>
|
||||
</Tip>
|
||||
))}
|
||||
</ul>
|
||||
<button
|
||||
|
||||
@@ -4,7 +4,18 @@ import { useCallback, useMemo } from 'react'
|
||||
|
||||
import type { CommandCenterSection } from '@/app/command-center'
|
||||
import { GatewayMenuPanel } from '@/app/shell/gateway-menu-panel'
|
||||
import { Activity, AlertCircle, ChevronDown, Clock, Command, Hash, Loader2, Sparkles, Zap, ZapFilled } from '@/lib/icons'
|
||||
import {
|
||||
Activity,
|
||||
AlertCircle,
|
||||
ChevronDown,
|
||||
Clock,
|
||||
Command,
|
||||
Hash,
|
||||
Loader2,
|
||||
Sparkles,
|
||||
Zap,
|
||||
ZapFilled
|
||||
} from '@/lib/icons'
|
||||
import { formatModelStatusLabel } from '@/lib/model-status-label'
|
||||
import type { RuntimeReadinessResult } from '@/lib/runtime-readiness'
|
||||
import { contextBarLabel, LiveDuration, usageContextLabel } from '@/lib/statusbar'
|
||||
@@ -311,7 +322,11 @@ export function useStatusbarItems({
|
||||
{
|
||||
className: cn('px-1', yoloActive && 'bg-(--chrome-action-hover)'),
|
||||
hidden: !showYoloToggle,
|
||||
icon: yoloActive ? <ZapFilled className="size-3.5 shrink-0" /> : <Zap className="size-3.5 shrink-0 opacity-70" />,
|
||||
icon: yoloActive ? (
|
||||
<ZapFilled className="size-3.5 shrink-0" />
|
||||
) : (
|
||||
<Zap className="size-3.5 shrink-0 opacity-70" />
|
||||
),
|
||||
id: 'yolo',
|
||||
onSelect: () => void toggleYolo(),
|
||||
title: yoloActive
|
||||
|
||||
@@ -55,9 +55,7 @@ export function resolveFastControl(
|
||||
|
||||
// Only a toggle if there's a base to switch back to; otherwise it's a
|
||||
// standalone fast model with no "off" state.
|
||||
return providerModels.includes(baseId)
|
||||
? { kind: 'variant', baseId, fastId: model, on: true }
|
||||
: { kind: 'none' }
|
||||
return providerModels.includes(baseId) ? { kind: 'variant', baseId, fastId: model, on: true } : { kind: 'none' }
|
||||
}
|
||||
|
||||
const fastId = `${model}-fast`
|
||||
@@ -182,24 +180,20 @@ export function ModelEditSubmenu({
|
||||
<>
|
||||
<DropdownMenuLabel className={dropdownMenuSectionLabel}>Options</DropdownMenuLabel>
|
||||
{reasoning ? (
|
||||
<DropdownMenuItem
|
||||
className={dropdownMenuRow}
|
||||
onSelect={event => event.preventDefault()}
|
||||
>
|
||||
<DropdownMenuItem className={dropdownMenuRow} onSelect={event => event.preventDefault()}>
|
||||
Thinking
|
||||
<Switch
|
||||
checked={thinkingOn}
|
||||
className="ml-auto"
|
||||
onCheckedChange={checked => void patchReasoning(checked ? effort || 'medium' : 'none', currentReasoningEffort)}
|
||||
onCheckedChange={checked =>
|
||||
void patchReasoning(checked ? effort || 'medium' : 'none', currentReasoningEffort)
|
||||
}
|
||||
size="xs"
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{hasFast ? (
|
||||
<DropdownMenuItem
|
||||
className={dropdownMenuRow}
|
||||
onSelect={event => event.preventDefault()}
|
||||
>
|
||||
<DropdownMenuItem className={dropdownMenuRow} onSelect={event => event.preventDefault()}>
|
||||
Fast
|
||||
<Switch checked={fastOn} className="ml-auto" onCheckedChange={toggleFast} size="xs" />
|
||||
</DropdownMenuItem>
|
||||
|
||||
@@ -157,7 +157,10 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
|
||||
// Grayed text: active row shows live state (Fast + effort);
|
||||
// others show a fast-capability hint.
|
||||
const meta = isCurrent
|
||||
? [fastControl.kind !== 'none' && fastControl.on ? 'Fast' : null, reasoningEffortLabel(currentReasoningEffort) || 'Med']
|
||||
? [
|
||||
fastControl.kind !== 'none' && fastControl.on ? 'Fast' : null,
|
||||
reasoningEffortLabel(currentReasoningEffort) || 'Med'
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
: caps?.fast || family.fastId
|
||||
|
||||
@@ -91,18 +91,11 @@ function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate:
|
||||
</>
|
||||
)
|
||||
|
||||
const title = item.title ?? (typeof item.label === 'string' ? item.label : undefined)
|
||||
|
||||
if (item.variant === 'menu' && (item.menuContent || (item.menuItems && item.menuItems.length > 0))) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
className={cn(STATUSBAR_ACTION_CLASS, item.className)}
|
||||
disabled={item.disabled}
|
||||
title={title}
|
||||
type="button"
|
||||
>
|
||||
<button className={cn(STATUSBAR_ACTION_CLASS, item.className)} disabled={item.disabled} type="button">
|
||||
{content}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
@@ -135,7 +128,6 @@ function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate:
|
||||
href={menuItem.href}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
title={menuItem.title ?? menuItem.label}
|
||||
>
|
||||
{menuItem.icon}
|
||||
<span className="truncate">{menuItem.label}</span>
|
||||
@@ -168,13 +160,7 @@ function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate:
|
||||
|
||||
if (item.href || item.variant === 'link') {
|
||||
return (
|
||||
<a
|
||||
className={cn(STATUSBAR_ACTION_CLASS, item.className)}
|
||||
href={item.href}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
title={title}
|
||||
>
|
||||
<a className={cn(STATUSBAR_ACTION_CLASS, item.className)} href={item.href} rel="noreferrer" target="_blank">
|
||||
{content}
|
||||
</a>
|
||||
)
|
||||
@@ -191,7 +177,6 @@ function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate:
|
||||
|
||||
item.onSelect?.()
|
||||
}}
|
||||
title={title}
|
||||
type="button"
|
||||
>
|
||||
{content}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user