mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-14 14:19:29 +08:00
Compare commits
170 Commits
fix/worktr
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b5ba112ad | ||
|
|
cdf30a7ac6 | ||
|
|
b0288ae9b6 | ||
|
|
630a4ef03c | ||
|
|
b4ba3f5e3b | ||
|
|
8f278403d1 | ||
|
|
1b16c48170 | ||
|
|
e986e3fc68 | ||
|
|
12682d96b9 | ||
|
|
8d5d36d793 | ||
|
|
7aaae7acd0 | ||
|
|
73d1357747 | ||
|
|
af1995a838 | ||
|
|
dc90ca4e17 | ||
|
|
af5b526472 | ||
|
|
b42c5bf652 | ||
|
|
a218a0f156 | ||
|
|
1106879147 | ||
|
|
6b76284c77 | ||
|
|
4026f526d5 | ||
|
|
9a2b976326 | ||
|
|
3581131e7d | ||
|
|
bf8effad02 | ||
|
|
817f392311 | ||
|
|
2b67e96aec | ||
|
|
abd69b8117 | ||
|
|
da28d5d113 | ||
|
|
1fa761f8de | ||
|
|
069bfd6545 | ||
|
|
1d584a301e | ||
|
|
57c2a55be4 | ||
|
|
0a865e5948 | ||
|
|
c8e5f34f24 | ||
|
|
7d11fa4e9e | ||
|
|
7c0605bf22 | ||
|
|
819def44c7 | ||
|
|
08890d77e6 | ||
|
|
425e777f54 | ||
|
|
7be22e37e1 | ||
|
|
28902dc890 | ||
|
|
63097ee0d7 | ||
|
|
6e2fd955ca | ||
|
|
78c11d99e3 | ||
|
|
957a8ffa88 | ||
|
|
cc14b74718 | ||
|
|
9b5f7b63c6 | ||
|
|
d146b85173 | ||
|
|
28bf8fb47d | ||
|
|
3380563d94 | ||
|
|
ad7436a5d9 | ||
|
|
fc46354580 | ||
|
|
1185dfd773 | ||
|
|
f82cb48120 | ||
|
|
4fd9397ae3 | ||
|
|
45f9099e51 | ||
|
|
4373e802a1 | ||
|
|
d206e1f51d | ||
|
|
16fb573bae | ||
|
|
6f43ff5572 | ||
|
|
eed61a1251 | ||
|
|
74c5158b10 | ||
|
|
6724daa2c2 | ||
|
|
aa53a78d67 | ||
|
|
0333a99925 | ||
|
|
5acd185f7c | ||
|
|
39a35b784f | ||
|
|
2667601c05 | ||
|
|
643dc82793 | ||
|
|
e256f4aae4 | ||
|
|
cb125c2b3f | ||
|
|
a59d5e37e8 | ||
|
|
4b646bc21e | ||
|
|
62b4618e9a | ||
|
|
2abcae9678 | ||
|
|
c814d3d1dd | ||
|
|
573b964dc7 | ||
|
|
aa0798352a | ||
|
|
311ff967de | ||
|
|
bd66e7e3fb | ||
|
|
2681c5a12d | ||
|
|
fa2aba90b4 | ||
|
|
5b857201b7 | ||
|
|
905ed413d1 | ||
|
|
bea6c1c01f | ||
|
|
a5e9b17ce3 | ||
|
|
5d6c16e972 | ||
|
|
266b5a19f1 | ||
|
|
202e318cb1 | ||
|
|
2d474e39c7 | ||
|
|
2a5dc0ef3d | ||
|
|
197337cc47 | ||
|
|
8cf9d8689d | ||
|
|
b62e57b2f4 | ||
|
|
bc060c7c1c | ||
|
|
3803e5fc28 | ||
|
|
bdd3868b57 | ||
|
|
b6c7ebf028 | ||
|
|
b2bc48cd5e | ||
|
|
9cd3d8a6ac | ||
|
|
b82d2e549f | ||
|
|
b15dc58064 | ||
|
|
acd4278c8a | ||
|
|
be6713c536 | ||
|
|
77687156b4 | ||
|
|
45ceee8a32 | ||
|
|
0a7a81835b | ||
|
|
76b93869d8 | ||
|
|
a856276124 | ||
|
|
1e755ff556 | ||
|
|
7f302c91b2 | ||
|
|
18916376f1 | ||
|
|
f23a4b7bb3 | ||
|
|
bf090deed3 | ||
|
|
7d183f6497 | ||
|
|
492c402774 | ||
|
|
d62e9b7592 | ||
|
|
3cf7d43262 | ||
|
|
edc36f3a45 | ||
|
|
7c226cc57f | ||
|
|
a86b7b314b | ||
|
|
d14f6c9563 | ||
|
|
a1c6349c1f | ||
|
|
78ce91750e | ||
|
|
1a3cd3d436 | ||
|
|
9688c1a94f | ||
|
|
7e46533d9f | ||
|
|
956af7f3c3 | ||
|
|
1899c8f507 | ||
|
|
dd12a5403d | ||
|
|
8905ee6b8a | ||
|
|
5d0408d9fe | ||
|
|
aec38855b5 | ||
|
|
0595af0ad1 | ||
|
|
e90672696e | ||
|
|
bbf020e709 | ||
|
|
135fe90166 | ||
|
|
68536d4375 | ||
|
|
2fef3e2df2 | ||
|
|
691ff7c188 | ||
|
|
7a318aae22 | ||
|
|
b16e22b8f2 | ||
|
|
2e874ef879 | ||
|
|
0db5cb8e75 | ||
|
|
749b7219c4 | ||
|
|
a118b94a85 | ||
|
|
bba9b519aa | ||
|
|
9b01c4d193 | ||
|
|
fca84fe20b | ||
|
|
2714fc8396 | ||
|
|
dc467488a7 | ||
|
|
c2326bc3be | ||
|
|
331cb38e21 | ||
|
|
fa5e98facb | ||
|
|
652dd9c9f2 | ||
|
|
05b9c84ca4 | ||
|
|
6b4073648e | ||
|
|
bc3f4ed70f | ||
|
|
8c3c08c50b | ||
|
|
c61815232a | ||
|
|
1e25358a8f | ||
|
|
8044bf0206 | ||
|
|
4d68984ec7 | ||
|
|
6ff39c31ad | ||
|
|
c41a6534cf | ||
|
|
2f9d18711f | ||
|
|
46d758bb3e | ||
|
|
7d4e60e44a | ||
|
|
79c3ed3cc9 | ||
|
|
d62979a6f3 | ||
|
|
9c50521704 |
99
.github/workflows/deploy-site.yml
vendored
99
.github/workflows/deploy-site.yml
vendored
@@ -11,8 +11,20 @@ on:
|
||||
- 'optional-skills/**'
|
||||
- '.github/workflows/deploy-site.yml'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
skills_index_run_id:
|
||||
description: 'Optional Build Skills Index run ID whose skills-index artifact should be deployed'
|
||||
required: false
|
||||
type: string
|
||||
rebuild_skills_index:
|
||||
description: 'Force a fresh multi-source crawl instead of reusing the latest healthy index'
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
@@ -55,26 +67,81 @@ jobs:
|
||||
- name: Install PyYAML for skill extraction
|
||||
run: pip install pyyaml==6.0.2 httpx==0.28.1
|
||||
|
||||
- name: Build skills index (unified multi-source catalog)
|
||||
- name: Prepare skills index (unified multi-source catalog)
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
SKILLS_INDEX_RUN_ID: ${{ github.event.inputs.skills_index_run_id || '' }}
|
||||
REBUILD_SKILLS_INDEX: ${{ github.event.inputs.rebuild_skills_index || 'false' }}
|
||||
run: |
|
||||
# Rebuild the unified catalog. The file is gitignored, so a fresh
|
||||
# checkout starts without it and we want the freshest crawl in
|
||||
# every deploy.
|
||||
# The unified external catalog is expensive to crawl and can burn
|
||||
# through the repository installation's GitHub API quota when several
|
||||
# docs deploys land close together. Normal docs deploys therefore
|
||||
# reuse the latest healthy catalog: first the artifact from a
|
||||
# scheduled skills-index run, then the currently live index. Only a
|
||||
# manual force rebuild does a fresh crawl here.
|
||||
#
|
||||
# This MUST be fatal. build_skills_index.py runs a health check and
|
||||
# exits non-zero WITHOUT writing the output file when a source
|
||||
# collapses (e.g. a GitHub API rate limit zeroes the github /
|
||||
# claude-marketplace / well-known taps all at once). Letting the
|
||||
# deploy continue would either (a) ship a degenerate index missing
|
||||
# whole hubs — the June 2026 regression where OpenAI/Anthropic/
|
||||
# HuggingFace/NVIDIA tabs vanished — or (b) fall through to a
|
||||
# local-only catalog. Failing here keeps the last good deployment
|
||||
# live (GitHub Pages serves the previous build) instead of
|
||||
# publishing a broken catalog. Re-run the workflow once the
|
||||
# transient rate limit clears.
|
||||
# If we do crawl, the build remains fatal. build_skills_index.py runs
|
||||
# the health check BEFORE writing and exits non-zero on source
|
||||
# collapse, keeping the last good Pages deployment live instead of
|
||||
# publishing a degenerate catalog.
|
||||
set -euo pipefail
|
||||
INDEX_PATH="website/static/api/skills-index.json"
|
||||
mkdir -p "$(dirname "$INDEX_PATH")"
|
||||
|
||||
validate_index() {
|
||||
python3 - "$INDEX_PATH" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
path = Path(sys.argv[1])
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception as exc:
|
||||
print(f"invalid skills index JSON: {exc}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
skills = data.get("skills")
|
||||
if not isinstance(skills, list) or len(skills) < 1500:
|
||||
count = len(skills) if isinstance(skills, list) else "missing"
|
||||
print(f"skills index too small: {count}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
print(f"skills index ready: {len(skills)} skills")
|
||||
PY
|
||||
}
|
||||
|
||||
if [ "$REBUILD_SKILLS_INDEX" = "true" ]; then
|
||||
python3 scripts/build_skills_index.py
|
||||
validate_index
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ -n "$SKILLS_INDEX_RUN_ID" ]; then
|
||||
tmpdir="$(mktemp -d)"
|
||||
echo "Downloading skills-index artifact from run $SKILLS_INDEX_RUN_ID"
|
||||
if gh run download "$SKILLS_INDEX_RUN_ID" --name skills-index --dir "$tmpdir"; then
|
||||
candidate="$(find "$tmpdir" -name skills-index.json -type f | head -n 1 || true)"
|
||||
if [ -n "$candidate" ]; then
|
||||
cp "$candidate" "$INDEX_PATH"
|
||||
if validate_index; then
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
echo "::warning::Could not use skills-index artifact from run $SKILLS_INDEX_RUN_ID; trying live index"
|
||||
fi
|
||||
|
||||
echo "Downloading currently live skills index"
|
||||
if curl -fsSL --retry 3 --retry-delay 5 \
|
||||
"https://hermes-agent.nousresearch.com/docs/api/skills-index.json" \
|
||||
-o "$INDEX_PATH" && validate_index; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "::warning::Live skills index unavailable or unhealthy; falling back to a fresh crawl"
|
||||
rm -f "$INDEX_PATH"
|
||||
python3 scripts/build_skills_index.py
|
||||
validate_index
|
||||
|
||||
- name: Extract skill metadata for dashboard
|
||||
run: python3 website/scripts/extract-skills.py
|
||||
|
||||
4
.github/workflows/docker-publish.yml
vendored
4
.github/workflows/docker-publish.yml
vendored
@@ -90,7 +90,7 @@ jobs:
|
||||
# (see `_SKIP_PARTS` in scripts/run_tests_parallel.py) because each
|
||||
# shard would otherwise reach the session-scoped ``built_image``
|
||||
# fixture in ``tests/docker/conftest.py`` and start a 3-7min
|
||||
# ``docker build`` under a 180s pytest-timeout cap — guaranteed to
|
||||
# ``docker build`` — guaranteed to
|
||||
# die in fixture setup.
|
||||
#
|
||||
# Piggybacking here avoids a second image build: the smoke test
|
||||
@@ -114,7 +114,7 @@ jobs:
|
||||
run: |
|
||||
uv venv .venv --python 3.11
|
||||
source .venv/bin/activate
|
||||
# ``dev`` extra pulls in pytest, pytest-asyncio, pytest-timeout —
|
||||
# ``dev`` extra pulls in pytest, pytest-asyncio —
|
||||
# everything tests/docker/ needs. We deliberately avoid ``all``
|
||||
# here because the docker tests only drive the container via
|
||||
# subprocess and don't import hermes_agent's optional deps.
|
||||
|
||||
2
.github/workflows/skills-index.yml
vendored
2
.github/workflows/skills-index.yml
vendored
@@ -53,4 +53,4 @@ jobs:
|
||||
- name: Trigger Deploy Site workflow
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: gh workflow run deploy-site.yml --repo ${{ github.repository }}
|
||||
run: gh workflow run deploy-site.yml --repo ${{ github.repository }} -f skills_index_run_id=${{ github.run_id }}
|
||||
|
||||
34
.github/workflows/tests.yml
vendored
34
.github/workflows/tests.yml
vendored
@@ -4,13 +4,13 @@ on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths-ignore:
|
||||
- '**/*.md'
|
||||
- 'docs/**'
|
||||
- "**/*.md"
|
||||
- "docs/**"
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths-ignore:
|
||||
- '**/*.md'
|
||||
- 'docs/**'
|
||||
- "**/*.md"
|
||||
- "docs/**"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -30,13 +30,17 @@ jobs:
|
||||
slice: [1, 2, 3, 4, 5, 6]
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Restore duration cache
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: test_durations.json
|
||||
# Single stable key. main always overwrites, PRs always find it.
|
||||
# main always writes a new suffix, but jobs pick the latest one with the same prefix
|
||||
# quote from https://docs.github.com/en/actions/reference/workflows-and-actions/dependency-caching#cache-hits-and-misses
|
||||
# If you provide restore-keys, the cache action sequentially searches for any caches that match the list of restore-keys.
|
||||
# If there are no exact matches, the action searches for partial matches of the restore keys.
|
||||
# When the action finds a partial match, the most recent cache is restored to the path directory.
|
||||
key: test-durations
|
||||
|
||||
- name: Install ripgrep (prebuilt binary)
|
||||
@@ -54,7 +58,7 @@ jobs:
|
||||
rg --version
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
|
||||
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
|
||||
with:
|
||||
# Persist uv's download/wheel cache (~/.cache/uv) across runs.
|
||||
# Keyed on the dependency manifests, so the cache is reused until
|
||||
@@ -115,7 +119,7 @@ jobs:
|
||||
NOUS_API_KEY: ""
|
||||
|
||||
- name: Upload per-slice durations
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: test-durations-slice-${{ matrix.slice }}
|
||||
path: test_durations.json
|
||||
@@ -125,11 +129,11 @@ jobs:
|
||||
# (including PRs) get balanced slicing.
|
||||
save-durations:
|
||||
needs: test
|
||||
if: always() && github.ref == 'refs/heads/main'
|
||||
if: needs.test.result == 'success' && github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Download all slice durations
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
pattern: test-durations-slice-*
|
||||
path: durations
|
||||
@@ -149,17 +153,17 @@ jobs:
|
||||
"
|
||||
|
||||
- name: Save merged duration cache
|
||||
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: test_durations.json
|
||||
key: test-durations
|
||||
key: test-durations-${{ github.run_id }}
|
||||
|
||||
e2e:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Install ripgrep (prebuilt binary)
|
||||
run: |
|
||||
@@ -176,7 +180,7 @@ jobs:
|
||||
rg --version
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
|
||||
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
|
||||
with:
|
||||
# Persist uv's download/wheel cache (~/.cache/uv) across runs.
|
||||
# Keyed on the dependency manifests, so the cache is reused until
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -132,3 +132,7 @@ scripts/out/
|
||||
# stores the published notes. They are not a build artifact and must never be
|
||||
# committed to the repo root. See the hermes-release skill.
|
||||
RELEASE_v*.md
|
||||
|
||||
# Desktop demo-run scratch output (hermes writes demo/*.txt during recorded
|
||||
# walkthroughs). Throwaway artifacts, never part of the app.
|
||||
apps/desktop/demo/
|
||||
|
||||
@@ -824,6 +824,7 @@ class HermesACPAgent(acp.Agent):
|
||||
|
||||
try:
|
||||
from model_tools import get_tool_definitions
|
||||
from agent.memory_manager import inject_memory_provider_tools
|
||||
|
||||
enabled_toolsets = _expand_acp_enabled_toolsets(
|
||||
getattr(state.agent, "enabled_toolsets", None) or ["hermes-acp"],
|
||||
@@ -839,6 +840,7 @@ class HermesACPAgent(acp.Agent):
|
||||
state.agent.valid_tool_names = {
|
||||
tool["function"]["name"] for tool in state.agent.tools or []
|
||||
}
|
||||
inject_memory_provider_tools(state.agent)
|
||||
invalidate = getattr(state.agent, "_invalidate_system_prompt", None)
|
||||
if callable(invalidate):
|
||||
invalidate()
|
||||
@@ -1779,10 +1781,25 @@ class HermesACPAgent(acp.Agent):
|
||||
def _cmd_tools(self, args: str, state: SessionState) -> str:
|
||||
try:
|
||||
from model_tools import get_tool_definitions
|
||||
from types import SimpleNamespace
|
||||
from agent.memory_manager import inject_memory_provider_tools
|
||||
|
||||
toolsets = _expand_acp_enabled_toolsets(
|
||||
getattr(state.agent, "enabled_toolsets", None) or ["hermes-acp"]
|
||||
)
|
||||
tools = get_tool_definitions(enabled_toolsets=toolsets, quiet_mode=True)
|
||||
tool_view = SimpleNamespace(
|
||||
tools=list(tools or []),
|
||||
valid_tool_names={
|
||||
tool.get("function", {}).get("name")
|
||||
for tool in tools or []
|
||||
if isinstance(tool, dict)
|
||||
},
|
||||
enabled_toolsets=toolsets,
|
||||
_memory_manager=getattr(state.agent, "_memory_manager", None),
|
||||
)
|
||||
inject_memory_provider_tools(tool_view)
|
||||
tools = tool_view.tools
|
||||
if not tools:
|
||||
return "No tools available."
|
||||
lines = [f"Available tools ({len(tools)}):"]
|
||||
|
||||
@@ -900,6 +900,9 @@ def init_agent(
|
||||
agent.api_key = client_kwargs.get("api_key", "")
|
||||
agent.base_url = client_kwargs.get("base_url", agent.base_url)
|
||||
try:
|
||||
from agent.ssl_guard import verify_ca_bundle_with_fallback
|
||||
|
||||
verify_ca_bundle_with_fallback()
|
||||
agent.client = agent._create_openai_client(client_kwargs, reason="agent_init", shared=True)
|
||||
if not agent.quiet_mode:
|
||||
print(f"🤖 AI Agent initialized with model: {agent.model}")
|
||||
@@ -1193,38 +1196,8 @@ def init_agent(
|
||||
_ra().logger.warning("Memory provider plugin init failed: %s", _mpe)
|
||||
agent._memory_manager = None
|
||||
|
||||
# Inject memory provider tool schemas into the tool surface.
|
||||
# Skip tools whose names already exist (plugins may register the
|
||||
# same tools via ctx.register_tool(), which lands in agent.tools
|
||||
# through _ra().get_tool_definitions()). Duplicate function names cause
|
||||
# 400 errors on providers that enforce unique names (e.g. Xiaomi
|
||||
# MiMo via Nous Portal).
|
||||
#
|
||||
# Respect the platform's enabled_toolsets configuration (#5544):
|
||||
# enabled_toolsets is None → no filter, inject (backward compat)
|
||||
# "memory" in enabled_toolsets → user opted in, inject
|
||||
# otherwise (incl. []) → user excluded memory, skip injection
|
||||
#
|
||||
# Without this gate, `platform_toolsets: telegram: []` still leaks memory
|
||||
# provider tools (fact_store, etc.) into the tool surface — a 10x latency
|
||||
# penalty on local models and a frequent trigger of tool-call loops.
|
||||
if agent._memory_manager and agent.tools is not None and (
|
||||
agent.enabled_toolsets is None or "memory" in agent.enabled_toolsets
|
||||
):
|
||||
_existing_tool_names = {
|
||||
t.get("function", {}).get("name")
|
||||
for t in agent.tools
|
||||
if isinstance(t, dict)
|
||||
}
|
||||
for _schema in agent._memory_manager.get_all_tool_schemas():
|
||||
_tname = _schema.get("name", "")
|
||||
if _tname and _tname in _existing_tool_names:
|
||||
continue # already registered via plugin path
|
||||
_wrapped = {"type": "function", "function": _schema}
|
||||
agent.tools.append(_wrapped)
|
||||
if _tname:
|
||||
agent.valid_tool_names.add(_tname)
|
||||
_existing_tool_names.add(_tname)
|
||||
from agent.memory_manager import inject_memory_provider_tools as _inject_memory_provider_tools
|
||||
_inject_memory_provider_tools(agent)
|
||||
|
||||
# Skills config: nudge interval for skill creation reminders
|
||||
agent._skill_nudge_interval = 10
|
||||
|
||||
@@ -445,6 +445,45 @@ def repair_message_sequence(agent, messages: List[Dict]) -> int:
|
||||
return repairs
|
||||
|
||||
|
||||
def repair_message_sequence_with_cursor(agent, messages: List[Dict]) -> int:
|
||||
"""Run :func:`repair_message_sequence` and keep the SessionDB flush
|
||||
cursor consistent with the compacted list (#44837).
|
||||
|
||||
``repair_message_sequence`` merges/drops messages in place, shrinking
|
||||
the list. ``_last_flushed_db_idx`` (the DB-write cursor) indexes into
|
||||
that list, so after compaction it can point past the new end — the
|
||||
turn-end flush would then skip the assistant/tool chain entirely — or
|
||||
past unflushed messages shifted to lower indexes.
|
||||
|
||||
Repair preserves object identity for surviving messages, so counting
|
||||
the survivors from the previously-flushed prefix gives the exact new
|
||||
cursor even when messages are dropped/merged at indexes *before* the
|
||||
cursor — a plain ``min()`` clamp would silently skip that many
|
||||
unflushed rows. Falls back to the clamp when no prefix snapshot is
|
||||
available.
|
||||
|
||||
Returns the number of repairs made (same as ``repair_message_sequence``).
|
||||
"""
|
||||
pre_repair_flushed_ids = None
|
||||
flush_cursor = getattr(agent, "_last_flushed_db_idx", None)
|
||||
if isinstance(flush_cursor, int) and flush_cursor > 0:
|
||||
pre_repair_flushed_ids = {id(m) for m in messages[:flush_cursor]}
|
||||
|
||||
repairs = repair_message_sequence(agent, messages)
|
||||
|
||||
if repairs > 0 and hasattr(agent, "_last_flushed_db_idx"):
|
||||
if pre_repair_flushed_ids is not None:
|
||||
agent._last_flushed_db_idx = sum(
|
||||
1 for m in messages if id(m) in pre_repair_flushed_ids
|
||||
)
|
||||
else:
|
||||
agent._last_flushed_db_idx = min(
|
||||
agent._last_flushed_db_idx, len(messages)
|
||||
)
|
||||
|
||||
return repairs
|
||||
|
||||
|
||||
|
||||
def strip_think_blocks(agent, content: str) -> str:
|
||||
"""Remove reasoning/thinking blocks from content, returning only visible text.
|
||||
@@ -579,12 +618,33 @@ def recover_with_credential_pool(
|
||||
current_provider = (getattr(agent, "provider", "") or "").strip().lower()
|
||||
pool_provider = (getattr(pool, "provider", "") or "").strip().lower()
|
||||
if current_provider and pool_provider and current_provider != pool_provider:
|
||||
_ra().logger.warning(
|
||||
"Credential pool provider mismatch: pool=%s, agent=%s — "
|
||||
"skipping pool mutation to avoid cross-provider contamination",
|
||||
pool_provider, current_provider,
|
||||
)
|
||||
return False, has_retried_429
|
||||
# Custom endpoints use two naming conventions for the SAME provider:
|
||||
# the agent carries the generic ``custom`` label while the pool is
|
||||
# keyed ``custom:<name>`` (see CUSTOM_POOL_PREFIX). A literal string
|
||||
# compare treats them as a mismatch and skips recovery for every
|
||||
# custom-provider user — 401s/429s then burn the full retry cycle
|
||||
# with no rotation or refresh. Accept the pair as matching only when
|
||||
# the agent's CURRENT base_url actually resolves to this pool key,
|
||||
# so a fallback provider (or a different custom endpoint) still
|
||||
# triggers the guard.
|
||||
_custom_match = False
|
||||
if current_provider == "custom" and pool_provider.startswith("custom:"):
|
||||
try:
|
||||
from agent.credential_pool import get_custom_provider_pool_key
|
||||
_agent_base = (getattr(agent, "base_url", "") or "").strip()
|
||||
_custom_match = bool(_agent_base) and (
|
||||
(get_custom_provider_pool_key(_agent_base) or "").strip().lower()
|
||||
== pool_provider
|
||||
)
|
||||
except Exception:
|
||||
_custom_match = False
|
||||
if not _custom_match:
|
||||
_ra().logger.warning(
|
||||
"Credential pool provider mismatch: pool=%s, agent=%s — "
|
||||
"skipping pool mutation to avoid cross-provider contamination",
|
||||
pool_provider, current_provider,
|
||||
)
|
||||
return False, has_retried_429
|
||||
|
||||
effective_reason = classified_reason
|
||||
if effective_reason is None:
|
||||
@@ -821,6 +881,8 @@ def try_recover_primary_transport(
|
||||
|
||||
def drop_thinking_only_and_merge_users(
|
||||
messages: List[Dict[str, Any]],
|
||||
*,
|
||||
drop_codex_reasoning_items: bool = True,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Drop thinking-only assistant turns; merge any adjacent user messages left behind.
|
||||
|
||||
@@ -842,7 +904,13 @@ def drop_thinking_only_and_merge_users(
|
||||
return messages
|
||||
|
||||
# Pass 1: drop thinking-only assistant turns.
|
||||
kept = [m for m in messages if not _ra().AIAgent._is_thinking_only_assistant(m)]
|
||||
kept = [
|
||||
m for m in messages
|
||||
if not _ra().AIAgent._is_thinking_only_assistant(
|
||||
m,
|
||||
drop_codex_reasoning_items=drop_codex_reasoning_items,
|
||||
)
|
||||
]
|
||||
dropped = len(messages) - len(kept)
|
||||
if dropped == 0:
|
||||
return messages
|
||||
|
||||
@@ -3190,7 +3190,7 @@ def _resolve_auto(main_runtime: Optional[Dict[str, Any]] = None) -> Tuple[Option
|
||||
if (main_provider and main_model
|
||||
and main_provider not in {"auto", ""}):
|
||||
resolved_provider = main_provider
|
||||
explicit_base_url = None
|
||||
explicit_base_url = runtime_base_url or None
|
||||
explicit_api_key = None
|
||||
if runtime_base_url and (main_provider == "custom" or main_provider.startswith("custom:")):
|
||||
resolved_provider = "custom"
|
||||
@@ -5004,7 +5004,7 @@ def _build_call_kwargs(
|
||||
|
||||
# Provider-specific extra_body
|
||||
merged_extra = dict(extra_body or {})
|
||||
if provider == "nous" or auxiliary_is_nous:
|
||||
if provider == "nous":
|
||||
merged_extra.setdefault("tags", []).extend(_nous_portal_tags())
|
||||
if merged_extra:
|
||||
kwargs["extra_body"] = merged_extra
|
||||
|
||||
@@ -935,11 +935,14 @@ def build_converse_kwargs(
|
||||
if system_prompt:
|
||||
kwargs["system"] = system_prompt
|
||||
|
||||
if temperature is not None:
|
||||
kwargs["inferenceConfig"]["temperature"] = temperature
|
||||
from agent.anthropic_adapter import _forbids_sampling_params
|
||||
|
||||
if top_p is not None:
|
||||
kwargs["inferenceConfig"]["topP"] = top_p
|
||||
if not _forbids_sampling_params(model):
|
||||
if temperature is not None:
|
||||
kwargs["inferenceConfig"]["temperature"] = temperature
|
||||
|
||||
if top_p is not None:
|
||||
kwargs["inferenceConfig"]["topP"] = top_p
|
||||
|
||||
if stop_sequences:
|
||||
kwargs["inferenceConfig"]["stopSequences"] = stop_sequences
|
||||
|
||||
@@ -1081,6 +1081,7 @@ def _normalize_codex_response(
|
||||
message_items_raw: List[Dict[str, Any]] = []
|
||||
tool_calls: List[Any] = []
|
||||
has_incomplete_items = response_status in {"queued", "in_progress", "incomplete"}
|
||||
saw_streaming_or_item_incomplete = response_status in {"queued", "in_progress"}
|
||||
saw_commentary_phase = False
|
||||
saw_final_answer_phase = False
|
||||
saw_reasoning_item = False
|
||||
@@ -1095,6 +1096,7 @@ def _normalize_codex_response(
|
||||
|
||||
if item_status in {"queued", "in_progress", "incomplete"}:
|
||||
has_incomplete_items = True
|
||||
saw_streaming_or_item_incomplete = True
|
||||
|
||||
if item_type == "message":
|
||||
item_phase = getattr(item, "phase", None)
|
||||
@@ -1252,7 +1254,9 @@ def _normalize_codex_response(
|
||||
finish_reason = "tool_calls"
|
||||
elif leaked_tool_call_text:
|
||||
finish_reason = "incomplete"
|
||||
elif has_incomplete_items or (saw_commentary_phase and not saw_final_answer_phase):
|
||||
elif saw_streaming_or_item_incomplete:
|
||||
finish_reason = "incomplete"
|
||||
elif (has_incomplete_items or saw_commentary_phase) and not saw_final_answer_phase:
|
||||
finish_reason = "incomplete"
|
||||
elif (reasoning_items_raw or reasoning_parts or saw_reasoning_item) and not final_text:
|
||||
# Response contains only reasoning (encrypted thinking state and/or
|
||||
|
||||
@@ -69,6 +69,31 @@ SUMMARY_PREFIX = (
|
||||
)
|
||||
LEGACY_SUMMARY_PREFIX = "[CONTEXT SUMMARY]:"
|
||||
|
||||
# Metadata key added to context compression summary messages so that frontends
|
||||
# (CLI, Desktop, gateway, TUI) can distinguish them from real assistant/user
|
||||
# messages and filter or render them appropriately without content-prefix
|
||||
# heuristics. See https://github.com/NousResearch/hermes-agent/issues/38389
|
||||
#
|
||||
# Underscore-prefixed ON PURPOSE: the wire sanitizers
|
||||
# (agent/transports/chat_completions.py convert_messages and the summary-path
|
||||
# mirror in agent/chat_completion_helpers.py) strip every top-level message
|
||||
# key starting with "_" before the request leaves the process. Strict
|
||||
# OpenAI-compatible gateways (Fireworks, Mistral, Moonshot/Kimi, opencode-go)
|
||||
# reject payloads carrying unknown keys with "Extra inputs are not permitted",
|
||||
# poisoning every subsequent request in the session — a bare key like
|
||||
# "is_compressed_summary" would reach the wire and trip exactly that.
|
||||
COMPRESSED_SUMMARY_METADATA_KEY = "_compressed_summary"
|
||||
|
||||
# Appended to every standalone summary message (and to the merged-into-tail
|
||||
# prefix) so the model has an unambiguous "summary ends here" boundary.
|
||||
# Without it, weak models read the verbatim "## Active Task" quote as fresh
|
||||
# user input (#11475, #14521) or regurgitate an assistant-role summary as
|
||||
# their own output (#33256).
|
||||
_SUMMARY_END_MARKER = (
|
||||
"--- END OF CONTEXT SUMMARY — "
|
||||
"respond to the message below, not the summary above ---"
|
||||
)
|
||||
|
||||
# Handoff prefixes that shipped in earlier releases. A summary persisted under
|
||||
# one of these can be inherited into a resumed lineage (#35344); when it is
|
||||
# re-normalized on re-compaction we must strip the OLD prefix too, otherwise the
|
||||
@@ -146,6 +171,11 @@ _FALLBACK_TURN_MAX_CHARS = 700
|
||||
_AUTO_FOCUS_MAX_TURNS = 3
|
||||
_AUTO_FOCUS_TURN_MAX_CHARS = 260
|
||||
_AUTO_FOCUS_MAX_CHARS = 700
|
||||
# Keep a short run of recent messages verbatim even when the token budget is
|
||||
# already exhausted. The public ``protect_last_n`` default is intentionally
|
||||
# high for small/light tails, but using all 20 as a hard floor here would bring
|
||||
# back the old large-tool-output case where nothing can be compacted.
|
||||
_MAX_TAIL_MESSAGE_FLOOR = 8
|
||||
|
||||
|
||||
_PATH_MENTION_RE = re.compile(r"(?:/|~/?|[A-Za-z]:\\)[^\s`'\")\]}<>]+")
|
||||
@@ -1616,7 +1646,13 @@ This compaction should PRIORITISE preserving all information related to the focu
|
||||
text = (summary or "").strip()
|
||||
for prefix in (SUMMARY_PREFIX, LEGACY_SUMMARY_PREFIX, *_HISTORICAL_SUMMARY_PREFIXES):
|
||||
if text.startswith(prefix):
|
||||
return text[len(prefix):].lstrip()
|
||||
text = text[len(prefix):].lstrip()
|
||||
break
|
||||
# Strip the trailing end marker too — a rehydrated handoff body that
|
||||
# keeps it would leak the boundary directive into the iterative-update
|
||||
# summarizer prompt (and the marker is re-appended on insertion anyway).
|
||||
if text.endswith(_SUMMARY_END_MARKER):
|
||||
text = text[: -len(_SUMMARY_END_MARKER)].rstrip()
|
||||
return text
|
||||
|
||||
@classmethod
|
||||
@@ -1632,6 +1668,19 @@ This compaction should PRIORITISE preserving all information related to the focu
|
||||
return True
|
||||
return any(text.startswith(p) for p in _HISTORICAL_SUMMARY_PREFIXES)
|
||||
|
||||
@staticmethod
|
||||
def _has_compressed_summary_metadata(message: Any) -> bool:
|
||||
"""Return True if *message* carries the compressed-summary flag.
|
||||
|
||||
Callers (frontends, CLI, gateway) can use this to distinguish context
|
||||
compaction summaries from real assistant or user messages without
|
||||
relying on content-prefix heuristics. The flag is in-process only —
|
||||
the wire sanitizers strip underscore-prefixed keys before API calls.
|
||||
"""
|
||||
if not isinstance(message, dict):
|
||||
return False
|
||||
return bool(message.get(COMPRESSED_SUMMARY_METADATA_KEY))
|
||||
|
||||
@classmethod
|
||||
def _derive_auto_focus_topic(
|
||||
cls,
|
||||
@@ -1817,6 +1866,105 @@ This compaction should PRIORITISE preserving all information related to the focu
|
||||
return i
|
||||
return -1
|
||||
|
||||
def _find_last_assistant_message_idx(
|
||||
self, messages: List[Dict[str, Any]], head_end: int
|
||||
) -> int:
|
||||
"""Return the index of the last user-visible assistant reply at or
|
||||
after *head_end*, or -1.
|
||||
|
||||
A "user-visible reply" is an assistant message with non-empty
|
||||
textual content — i.e. one that the WebUI / TUI / SessionsPage
|
||||
rendered as a bubble the operator could read. We deliberately
|
||||
skip assistant messages that contain only ``tool_calls`` (and
|
||||
no text), because those render as small "calling tool X"
|
||||
indicators and aren't what the reporter means by "the output
|
||||
of the last message you sent" (#29824).
|
||||
|
||||
Falling back to the most recent assistant message of ANY kind
|
||||
only kicks in when no content-bearing assistant message exists
|
||||
in the compressible region — typically a fresh session that
|
||||
just started a multi-step tool sequence with no prior reply
|
||||
to anchor. In that case the agent fix is a no-op and the
|
||||
existing user-message anchor carries the load.
|
||||
"""
|
||||
last_any = -1
|
||||
for i in range(len(messages) - 1, head_end - 1, -1):
|
||||
msg = messages[i]
|
||||
if msg.get("role") != "assistant":
|
||||
continue
|
||||
if last_any < 0:
|
||||
last_any = i
|
||||
content = msg.get("content")
|
||||
if isinstance(content, str) and content.strip():
|
||||
return i
|
||||
if isinstance(content, list):
|
||||
# Multimodal / Anthropic-style content: look for any
|
||||
# text block with non-empty text.
|
||||
for part in content:
|
||||
if isinstance(part, dict):
|
||||
text = part.get("text") or part.get("content")
|
||||
if isinstance(text, str) and text.strip():
|
||||
return i
|
||||
return last_any
|
||||
|
||||
def _ensure_last_assistant_message_in_tail(
|
||||
self,
|
||||
messages: List[Dict[str, Any]],
|
||||
cut_idx: int,
|
||||
head_end: int,
|
||||
) -> int:
|
||||
"""Guarantee the most recent assistant message is in the protected tail.
|
||||
|
||||
WebUI / TUI / SessionsPage bug (#29824). Without this anchor,
|
||||
``_find_tail_cut_by_tokens`` can leave the user's most recent
|
||||
visible assistant response inside the compressed middle region —
|
||||
especially when the conversation has a single oversized tool
|
||||
result or a long stretch of tool-call/result pairs after the
|
||||
last assistant reply. The summariser then rolls that reply up
|
||||
into the single ``[CONTEXT COMPACTION — REFERENCE ONLY]`` block
|
||||
persisted as ``role="user"`` or ``role="assistant"``. From the
|
||||
operator's perspective the WebUI session viewer
|
||||
(``web/src/pages/SessionsPage.tsx``) and the TUI chat panel
|
||||
both suddenly show the opaque "Context compaction" block in the
|
||||
slot where they were just reading the assistant's actual reply:
|
||||
|
||||
User: "i cant see the output of the last message you
|
||||
sent, i did see it previously, however now see
|
||||
'context compaction'"
|
||||
|
||||
Mirror of ``_ensure_last_user_message_in_tail`` but anchors on
|
||||
the last assistant-role message. Re-runs the tool-group
|
||||
alignment so we don't split a ``tool_call`` / ``tool_result``
|
||||
group that immediately precedes the anchored message — orphaned
|
||||
tool messages would otherwise be removed by
|
||||
``_sanitize_tool_pairs`` and trigger the same data-loss symptom
|
||||
we're trying to prevent.
|
||||
"""
|
||||
last_asst_idx = self._find_last_assistant_message_idx(messages, head_end)
|
||||
if last_asst_idx < 0:
|
||||
# No assistant message in the compressible region — nothing
|
||||
# to anchor (single-turn pre-reply state, etc.).
|
||||
return cut_idx
|
||||
if last_asst_idx >= cut_idx:
|
||||
# Already in the tail — the token-budget walk did the right
|
||||
# thing on its own.
|
||||
return cut_idx
|
||||
# Pull cut_idx back to the assistant message, then re-align so
|
||||
# we don't split a tool group that immediately precedes it
|
||||
# (e.g. an ``assistant(tool_calls)`` → ``tool(result)`` →
|
||||
# ``assistant(final reply)`` sequence would otherwise leave the
|
||||
# ``tool`` orphan when cut lands at the final reply).
|
||||
new_cut = self._align_boundary_backward(messages, last_asst_idx)
|
||||
if not self.quiet_mode:
|
||||
logger.debug(
|
||||
"Anchoring tail cut to last assistant message at index %d "
|
||||
"(was %d, aligned to %d) to keep the previously-visible "
|
||||
"reply out of the compaction summary (#29824)",
|
||||
last_asst_idx, cut_idx, new_cut,
|
||||
)
|
||||
# Safety: never go back into the head region.
|
||||
return max(new_cut, head_end + 1)
|
||||
|
||||
def _ensure_last_user_message_in_tail(
|
||||
self,
|
||||
messages: List[Dict[str, Any]],
|
||||
@@ -1875,11 +2023,12 @@ This compaction should PRIORITISE preserving all information related to the focu
|
||||
derived from ``summary_target_ratio * context_length``, so it
|
||||
scales automatically with the model's context window.
|
||||
|
||||
Token budget is the primary criterion. A hard minimum of 3 messages
|
||||
is always protected, but the budget is allowed to exceed by up to
|
||||
1.5x to avoid cutting inside an oversized message (tool output, file
|
||||
read, etc.). If even the minimum 3 messages exceed 1.5x the budget
|
||||
the cut is placed right after the head so compression still runs.
|
||||
Token budget is the primary criterion. A bounded message-count floor
|
||||
keeps a short run of recent turns verbatim even when the budget is
|
||||
exhausted, but the budget is allowed to exceed by up to 1.5x to avoid
|
||||
cutting inside an oversized message (tool output, file read, etc.). If
|
||||
even that floor exceeds 1.5x the budget, the cut is placed right after
|
||||
the head so compression still runs.
|
||||
|
||||
Never cuts inside a tool_call/result group. Always ensures the most
|
||||
recent user message is in the tail (see ``_ensure_last_user_message_in_tail``).
|
||||
@@ -1887,8 +2036,19 @@ This compaction should PRIORITISE preserving all information related to the focu
|
||||
if token_budget is None:
|
||||
token_budget = self.tail_token_budget
|
||||
n = len(messages)
|
||||
# Hard minimum: always keep at least 3 messages in the tail
|
||||
min_tail = min(3, n - head_end - 1) if n - head_end > 1 else 0
|
||||
# Hard minimum: always keep a bounded recent-message floor in the tail.
|
||||
# ``protect_last_n`` remains a minimum up to the cap; the cap avoids
|
||||
# preserving a whole run of bulky tool outputs on every compaction.
|
||||
available_tail = max(0, n - head_end - 1)
|
||||
min_tail_floor = max(3, min(self.protect_last_n, _MAX_TAIL_MESSAGE_FLOOR))
|
||||
# Leave at least two non-head messages available to summarize on short
|
||||
# transcripts; otherwise compression can replace a tiny middle with a
|
||||
# summary and save no messages at all.
|
||||
compressible_tail_cap = max(3, available_tail - 2)
|
||||
min_tail = (
|
||||
min(min_tail_floor, compressible_tail_cap, available_tail)
|
||||
if available_tail > 1 else 0
|
||||
)
|
||||
soft_ceiling = int(token_budget * 1.5)
|
||||
accumulated = 0
|
||||
cut_idx = n # start from beyond the end
|
||||
@@ -1960,6 +2120,13 @@ This compaction should PRIORITISE preserving all information related to the focu
|
||||
# active task is never lost to compression (fixes #10896).
|
||||
cut_idx = self._ensure_last_user_message_in_tail(messages, cut_idx, head_end)
|
||||
|
||||
# Ensure the most recent assistant message is always in the tail
|
||||
# so the previously-visible reply isn't silently rolled into the
|
||||
# ``[CONTEXT COMPACTION — REFERENCE ONLY]`` block (fixes #29824).
|
||||
# Each anchor only walks ``cut_idx`` backward, so chaining them is
|
||||
# monotonic — the tail can only grow, never shrink.
|
||||
cut_idx = self._ensure_last_assistant_message_in_tail(messages, cut_idx, head_end)
|
||||
|
||||
return max(cut_idx, head_end + 1)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@@ -2193,32 +2360,33 @@ This compaction should PRIORITISE preserving all information related to the focu
|
||||
|
||||
# When the summary lands as a standalone role="user" message,
|
||||
# weak models read the verbatim "## Active Task" quote of a past
|
||||
# user request as fresh input (#11475, #14521). Append the explicit
|
||||
# end marker — the same one used in the merge-into-tail path — so
|
||||
# the model has a clear "summary above, not new input" signal.
|
||||
if not _merge_summary_into_tail and summary_role == "user":
|
||||
summary = (
|
||||
summary
|
||||
+ "\n\n--- END OF CONTEXT SUMMARY — "
|
||||
"respond to the message below, not the summary above ---"
|
||||
)
|
||||
# user request as fresh input (#11475, #14521).
|
||||
# When it lands as role="assistant", models may regurgitate the
|
||||
# summary text as their own output (#33256). In both cases, append
|
||||
# the explicit end marker so the model has a clear "summary ends
|
||||
# here, respond to the message below" signal.
|
||||
if not _merge_summary_into_tail:
|
||||
summary = summary + "\n\n" + _SUMMARY_END_MARKER
|
||||
|
||||
if not _merge_summary_into_tail:
|
||||
compressed.append({"role": summary_role, "content": summary})
|
||||
compressed.append({
|
||||
"role": summary_role,
|
||||
"content": summary,
|
||||
COMPRESSED_SUMMARY_METADATA_KEY: True,
|
||||
})
|
||||
|
||||
for i in range(compress_end, n_messages):
|
||||
msg = messages[i].copy()
|
||||
if _merge_summary_into_tail and i == compress_end:
|
||||
merged_prefix = (
|
||||
summary
|
||||
+ "\n\n--- END OF CONTEXT SUMMARY — "
|
||||
"respond to the message below, not the summary above ---\n\n"
|
||||
)
|
||||
merged_prefix = summary + "\n\n" + _SUMMARY_END_MARKER + "\n\n"
|
||||
msg["content"] = _append_text_to_content(
|
||||
msg.get("content"),
|
||||
merged_prefix,
|
||||
prepend=True,
|
||||
)
|
||||
# Mark the merged message so frontends can identify it as
|
||||
# containing a compression summary prefix.
|
||||
msg[COMPRESSED_SUMMARY_METADATA_KEY] = True
|
||||
_merge_summary_into_tail = False
|
||||
compressed.append(msg)
|
||||
|
||||
|
||||
@@ -631,7 +631,11 @@ def compress_context(
|
||||
return compressed, new_system_prompt
|
||||
|
||||
|
||||
def try_shrink_image_parts_in_messages(api_messages: list) -> bool:
|
||||
def try_shrink_image_parts_in_messages(
|
||||
api_messages: list,
|
||||
*,
|
||||
max_dimension: int = 8000,
|
||||
) -> bool:
|
||||
"""Re-encode all native image parts at a smaller size to recover from
|
||||
image-too-large errors (Anthropic 5 MB, unknown other providers).
|
||||
|
||||
@@ -642,7 +646,8 @@ def try_shrink_image_parts_in_messages(api_messages: list) -> bool:
|
||||
Strategy: look for ``image_url`` / ``input_image`` parts carrying a
|
||||
``data:image/...;base64,...`` payload. For each one whose encoded
|
||||
size exceeds 4 MB (a safe target that slides under Anthropic's 5 MB
|
||||
ceiling with header overhead), write the base64 to a tempfile, call
|
||||
ceiling with header overhead) or whose longest side exceeds
|
||||
``max_dimension``, write the base64 to a tempfile, call
|
||||
``vision_tools._resize_image_for_vision`` to produce a smaller data
|
||||
URL, and substitute it in place.
|
||||
|
||||
@@ -664,10 +669,9 @@ def try_shrink_image_parts_in_messages(api_messages: list) -> bool:
|
||||
# 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
|
||||
# the 5 MB byte cap. In many-image requests, the provider can report a
|
||||
# lower cap (observed: 2000px). The caller passes that parsed ceiling
|
||||
# when the rejection includes it.
|
||||
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
|
||||
@@ -684,9 +688,9 @@ def try_shrink_image_parts_in_messages(api_messages: list) -> bool:
|
||||
# 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.
|
||||
# Even if bytes are fine, check pixel dimensions against the
|
||||
# provider's reported per-side cap. A screenshot can be tiny in
|
||||
# bytes yet too large in pixels.
|
||||
try:
|
||||
import base64 as _b64_dim
|
||||
header_d, _, data_d = url.partition(",")
|
||||
|
||||
@@ -71,6 +71,35 @@ logger = logging.getLogger(__name__)
|
||||
INTERRUPT_WAITING_FOR_MODEL_PREFIX = "Operation interrupted: waiting for model response ("
|
||||
|
||||
|
||||
def _image_error_max_dimension(error: Exception) -> Optional[int]:
|
||||
"""Extract a provider-reported image dimension ceiling, if present."""
|
||||
parts = []
|
||||
for value in (
|
||||
error,
|
||||
getattr(error, "message", None),
|
||||
getattr(error, "body", None),
|
||||
):
|
||||
if value:
|
||||
try:
|
||||
parts.append(str(value))
|
||||
except Exception:
|
||||
pass
|
||||
text = " ".join(parts).lower()
|
||||
if "image" not in text or "dimension" not in text or "max allowed size" not in text:
|
||||
return None
|
||||
|
||||
match = re.search(r"max allowed size(?:\s+for [^:]+)?:\s*(\d{3,5})\s*pixels?", text)
|
||||
if not match:
|
||||
return None
|
||||
try:
|
||||
max_dimension = int(match.group(1))
|
||||
except ValueError:
|
||||
return None
|
||||
if 512 <= max_dimension <= 8000:
|
||||
return max_dimension
|
||||
return None
|
||||
|
||||
|
||||
def _ollama_context_limit_error(agent: Any, request_tokens: int) -> Optional[str]:
|
||||
"""Return a user-facing error when Ollama is loaded with too little context."""
|
||||
if not getattr(agent, "tools", None):
|
||||
@@ -595,7 +624,11 @@ def run_conversation(
|
||||
# landed after an orphan tool result). Most providers return
|
||||
# empty content on malformed sequences, which would otherwise
|
||||
# retrigger the empty-retry loop indefinitely.
|
||||
repaired_seq = agent._repair_message_sequence(messages)
|
||||
# repair_message_sequence_with_cursor also recomputes the SessionDB
|
||||
# flush cursor (_last_flushed_db_idx) when repair compacts the list,
|
||||
# so the turn-end flush doesn't skip the assistant/tool chain (#44837).
|
||||
from agent.agent_runtime_helpers import repair_message_sequence_with_cursor
|
||||
repaired_seq = repair_message_sequence_with_cursor(agent, messages)
|
||||
if repaired_seq > 0:
|
||||
request_logger.info(
|
||||
"Repaired %s message-alternation violations before request (session=%s)",
|
||||
@@ -703,7 +736,10 @@ def run_conversation(
|
||||
# a thinking-only turn. Runs on the per-call copy only — the
|
||||
# stored conversation history keeps the reasoning block for the
|
||||
# UI transcript and session persistence.
|
||||
api_messages = agent._drop_thinking_only_and_merge_users(api_messages)
|
||||
api_messages = agent._drop_thinking_only_and_merge_users(
|
||||
api_messages,
|
||||
drop_codex_reasoning_items=agent.api_mode != "codex_responses",
|
||||
)
|
||||
|
||||
# Normalize message whitespace and tool-call JSON for consistent
|
||||
# prefix matching. Ensures bit-perfect prefixes across turns,
|
||||
@@ -2063,7 +2099,11 @@ def run_conversation(
|
||||
and not _retry.image_shrink_retry_attempted
|
||||
):
|
||||
_retry.image_shrink_retry_attempted = True
|
||||
if agent._try_shrink_image_parts_in_messages(api_messages):
|
||||
image_max_dimension = _image_error_max_dimension(api_error) or 8000
|
||||
if agent._try_shrink_image_parts_in_messages(
|
||||
api_messages,
|
||||
max_dimension=image_max_dimension,
|
||||
):
|
||||
agent._vprint(
|
||||
f"{agent.log_prefix}📐 Image(s) exceeded provider size limit — "
|
||||
f"shrank and retrying...",
|
||||
@@ -2631,10 +2671,13 @@ def run_conversation(
|
||||
except Exception:
|
||||
pass
|
||||
if _genuine_nous_rate_limit:
|
||||
# Skip straight to max_retries -- the
|
||||
# top-of-loop guard will handle fallback or
|
||||
# bail cleanly.
|
||||
retry_count = max_retries
|
||||
# Re-enter the loop exactly once so the
|
||||
# top-of-loop Nous guard handles fallback or
|
||||
# bails cleanly. (Setting retry_count to
|
||||
# max_retries would make the while condition
|
||||
# false immediately and the guard would never
|
||||
# run -- no fallback, generic exhaustion error.)
|
||||
retry_count = max(0, max_retries - 1)
|
||||
continue
|
||||
# Upstream capacity 429: fall through to normal
|
||||
# retry logic. A different model (or the same
|
||||
|
||||
3
agent/errors.py
Normal file
3
agent/errors.py
Normal file
@@ -0,0 +1,3 @@
|
||||
class SSLConfigurationError(Exception):
|
||||
"""Raised when SSL/TLS certificate bundle configuration fails."""
|
||||
pass
|
||||
@@ -41,6 +41,16 @@ DEFAULT_GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"
|
||||
GEMINI_DEFAULT_MAX_OUTPUT_TOKENS = 65535
|
||||
|
||||
|
||||
def bare_gemini_model_id(model: str) -> str:
|
||||
"""Strip Gemini's own provider prefix from an aggregator-style model id."""
|
||||
name = (model or "").strip()
|
||||
lowered = name.lower()
|
||||
for prefix in ("google/", "gemini/"):
|
||||
if lowered.startswith(prefix):
|
||||
return name[len(prefix):].strip() or name
|
||||
return name
|
||||
|
||||
|
||||
def is_native_gemini_base_url(base_url: str) -> bool:
|
||||
"""Return True when the endpoint speaks Gemini's native REST API."""
|
||||
normalized = str(base_url or "").strip().rstrip("/").lower()
|
||||
@@ -330,7 +340,7 @@ def _build_gemini_contents(messages: List[Dict[str, Any]]) -> tuple[List[Dict[st
|
||||
system_instruction = None
|
||||
joined_system = "\n".join(part for part in system_text_parts if part).strip()
|
||||
if joined_system:
|
||||
system_instruction = {"parts": [{"text": joined_system}]}
|
||||
system_instruction = {"role": "system", "parts": [{"text": joined_system}]}
|
||||
return contents, system_instruction
|
||||
|
||||
|
||||
@@ -914,6 +924,7 @@ class GeminiNativeClient:
|
||||
thinking_config=thinking_config,
|
||||
)
|
||||
|
||||
model = bare_gemini_model_id(model)
|
||||
if stream:
|
||||
return self._stream_completion(model=model, request=request, timeout=timeout)
|
||||
|
||||
|
||||
@@ -44,6 +44,66 @@ logger = logging.getLogger(__name__)
|
||||
_SYNC_DRAIN_TIMEOUT_S = 5.0
|
||||
|
||||
|
||||
def memory_provider_tools_enabled(enabled_toolsets: Optional[List[str]]) -> bool:
|
||||
"""Return whether external memory-provider tools should be exposed."""
|
||||
if enabled_toolsets is None:
|
||||
return True
|
||||
if not enabled_toolsets:
|
||||
return False
|
||||
if "memory" in enabled_toolsets:
|
||||
return True
|
||||
|
||||
try:
|
||||
from toolsets import resolve_toolset
|
||||
|
||||
return any("memory" in resolve_toolset(name) for name in enabled_toolsets)
|
||||
except Exception:
|
||||
logger.debug("Failed to resolve enabled toolsets for memory-provider tools", exc_info=True)
|
||||
return False
|
||||
|
||||
|
||||
def inject_memory_provider_tools(agent: Any) -> int:
|
||||
"""Append external memory-provider tool schemas to an agent tool surface."""
|
||||
memory_manager = getattr(agent, "_memory_manager", None)
|
||||
tools = getattr(agent, "tools", None)
|
||||
if not memory_manager or tools is None:
|
||||
return 0
|
||||
|
||||
existing_tool_names = {
|
||||
tool.get("function", {}).get("name")
|
||||
for tool in tools
|
||||
if isinstance(tool, dict)
|
||||
}
|
||||
if (
|
||||
"memory" not in existing_tool_names
|
||||
and not memory_provider_tools_enabled(getattr(agent, "enabled_toolsets", None))
|
||||
):
|
||||
return 0
|
||||
|
||||
get_schemas = getattr(memory_manager, "get_all_tool_schemas", None)
|
||||
if not callable(get_schemas):
|
||||
return 0
|
||||
|
||||
valid_tool_names = getattr(agent, "valid_tool_names", None)
|
||||
if valid_tool_names is None:
|
||||
valid_tool_names = set()
|
||||
agent.valid_tool_names = valid_tool_names
|
||||
|
||||
added = 0
|
||||
for schema in get_schemas():
|
||||
if not isinstance(schema, dict):
|
||||
continue
|
||||
tool_name = schema.get("name", "")
|
||||
if not tool_name or tool_name in existing_tool_names:
|
||||
continue
|
||||
tools.append({"type": "function", "function": schema})
|
||||
valid_tool_names.add(tool_name)
|
||||
existing_tool_names.add(tool_name)
|
||||
added += 1
|
||||
|
||||
return added
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Context fencing helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -135,7 +135,14 @@ def _repair_schema(node: Any, is_schema: bool = True) -> Any:
|
||||
|
||||
def _fill_missing_type(node: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Infer a reasonable ``type`` if this schema node has none."""
|
||||
if "type" in node and node["type"] not in {None, ""}:
|
||||
node_type = node.get("type")
|
||||
if isinstance(node_type, list):
|
||||
concrete = next(
|
||||
(t for t in node_type if isinstance(t, str) and t not in {"", "null"}),
|
||||
"string",
|
||||
)
|
||||
return {**node, "type": concrete}
|
||||
if "type" in node and node_type not in {None, ""}:
|
||||
return node
|
||||
|
||||
# Heuristic: presence of ``properties`` → object, ``items`` → array, ``enum``
|
||||
|
||||
@@ -508,13 +508,22 @@ PLATFORM_HINTS = {
|
||||
),
|
||||
"telegram": (
|
||||
"You are on a text messaging communication platform, Telegram. "
|
||||
"Standard markdown is automatically converted to Telegram format. "
|
||||
"Standard Markdown is automatically converted to Telegram formatting. "
|
||||
"Supported: **bold**, *italic*, ~~strikethrough~~, ||spoiler||, "
|
||||
"`inline code`, ```code blocks```, [links](url), and ## headers. "
|
||||
"Telegram has NO table syntax — prefer bullet lists or labeled "
|
||||
"key: value pairs over pipe tables (any tables you do emit are "
|
||||
"auto-rewritten into row-group bullets, which you can produce "
|
||||
"directly for cleaner output). "
|
||||
"Telegram now supports rich Markdown, so lean into it: whenever it "
|
||||
"makes the answer clearer or easier to scan, actively reach for real "
|
||||
"Markdown tables (pipe `| col | col |` syntax), bullet and numbered "
|
||||
"lists, task lists (`- [ ]` / `- [x]`), headings, nested blockquotes, "
|
||||
"collapsible details, footnotes/references, math/formulas (`$...$`, "
|
||||
"`$$...$$`), underline, subscript/superscript, marked (highlighted) "
|
||||
"text, and anchors. Default to structured formatting over dense "
|
||||
"paragraphs for any comparison, set of steps, key/value summary, or "
|
||||
"tabular data. Prefer real Markdown tables and task lists over "
|
||||
"hand-built bullet substitutes when presenting structured data; these "
|
||||
"degrade gracefully (tables become readable bullet groups) when rich "
|
||||
"rendering is unavailable, but advanced constructs like math and "
|
||||
"collapsible details may render as plain source text in that case. "
|
||||
"You can send media files natively: to deliver a file to the user, "
|
||||
"include MEDIA:/absolute/path/to/file in your response. Images "
|
||||
"(.png, .jpg, .webp) appear as photos, audio (.ogg) sends as voice "
|
||||
|
||||
94
agent/ssl_guard.py
Normal file
94
agent/ssl_guard.py
Normal file
@@ -0,0 +1,94 @@
|
||||
"""Preventive SSL CA certificate checks for Hermes Agent.
|
||||
|
||||
This module catches broken CA bundle paths before OpenAI/httpx turns them into
|
||||
opaque ``FileNotFoundError: [Errno 2] No such file or directory`` failures.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import ssl
|
||||
from pathlib import Path
|
||||
|
||||
from agent.errors import SSLConfigurationError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_CA_BUNDLE_ENV_VARS = (
|
||||
"HERMES_CA_BUNDLE",
|
||||
"SSL_CERT_FILE",
|
||||
"REQUESTS_CA_BUNDLE",
|
||||
"CURL_CA_BUNDLE",
|
||||
)
|
||||
|
||||
_SKIP_VALUES = {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def _skip_ssl_guard_enabled() -> bool:
|
||||
return os.getenv("HERMES_SKIP_SSL_GUARD", "").strip().lower() in _SKIP_VALUES
|
||||
|
||||
|
||||
def _repair_hint() -> str:
|
||||
return (
|
||||
"Repair: python -m pip install --force-reinstall certifi openai httpx\n"
|
||||
"If you configured a custom corporate CA bundle, fix or unset the "
|
||||
"broken CA bundle environment variable."
|
||||
)
|
||||
|
||||
|
||||
def _ssl_err(message: str) -> SSLConfigurationError:
|
||||
"""Create a consistent, user-actionable SSL configuration error."""
|
||||
return SSLConfigurationError(f"{message}\n{_repair_hint()}")
|
||||
|
||||
|
||||
def _validate_bundle_path(label: str, value: str, *, require_substantial: bool = False) -> None:
|
||||
path = Path(value).expanduser()
|
||||
if not path.exists():
|
||||
raise _ssl_err(f"{label} points to a missing CA bundle: {value}")
|
||||
if not path.is_file():
|
||||
raise _ssl_err(f"{label} does not point to a CA bundle file: {value}")
|
||||
if require_substantial and path.stat().st_size < 1024:
|
||||
raise _ssl_err(f"{label} at {value} appears corrupted (too small)")
|
||||
try:
|
||||
ctx = ssl.create_default_context(cafile=str(path))
|
||||
except Exception as exc:
|
||||
raise _ssl_err(f"{label} CA bundle at {value} cannot be loaded: {exc}") from exc
|
||||
if not ctx.get_ca_certs():
|
||||
raise _ssl_err(f"{label} CA bundle at {value} did not load any certificates")
|
||||
|
||||
|
||||
def verify_ca_bundle() -> None:
|
||||
"""Verify configured and bundled CA certificates are present and loadable.
|
||||
|
||||
Raises:
|
||||
SSLConfigurationError: If an explicit CA-bundle environment variable
|
||||
points at a bad path, or if certifi's bundled ``cacert.pem`` is
|
||||
missing/corrupt.
|
||||
"""
|
||||
if _skip_ssl_guard_enabled():
|
||||
logger.debug("SSL CA bundle guard skipped via HERMES_SKIP_SSL_GUARD")
|
||||
return
|
||||
|
||||
for env_var in _CA_BUNDLE_ENV_VARS:
|
||||
value = os.getenv(env_var)
|
||||
if value:
|
||||
_validate_bundle_path(env_var, value)
|
||||
|
||||
try:
|
||||
import certifi
|
||||
except Exception as exc:
|
||||
raise _ssl_err(f"certifi is not importable: {exc}") from exc
|
||||
|
||||
ca_bundle = str(certifi.where())
|
||||
_validate_bundle_path("certifi", ca_bundle, require_substantial=True)
|
||||
|
||||
|
||||
def verify_ca_bundle_with_fallback() -> None:
|
||||
"""Backward-compatible wrapper for older call sites.
|
||||
|
||||
The old PR name mentioned a platform fallback, but allowing startup with a
|
||||
broken certifi bundle still leaves httpx/OpenAI and requests call sites
|
||||
failing later. Keep the wrapper name but enforce the same check.
|
||||
"""
|
||||
verify_ca_bundle()
|
||||
@@ -218,22 +218,10 @@ class ResponsesApiTransport(ProviderTransport):
|
||||
kwargs.pop("timeout", None)
|
||||
|
||||
if is_codex_backend:
|
||||
prompt_cache_key = kwargs.get("prompt_cache_key")
|
||||
cache_scope_id = str(prompt_cache_key or session_id or "").strip()
|
||||
if cache_scope_id:
|
||||
existing_extra_headers = kwargs.get("extra_headers")
|
||||
merged_extra_headers: Dict[str, str] = {}
|
||||
if isinstance(existing_extra_headers, dict):
|
||||
merged_extra_headers.update(
|
||||
{
|
||||
str(key): str(value)
|
||||
for key, value in existing_extra_headers.items()
|
||||
if key and value is not None
|
||||
}
|
||||
)
|
||||
merged_extra_headers["session_id"] = cache_scope_id
|
||||
merged_extra_headers["x-client-request-id"] = cache_scope_id
|
||||
kwargs["extra_headers"] = merged_extra_headers
|
||||
# chatgpt.com/backend-api/codex rejects body-level
|
||||
# ``extra_headers`` with HTTP 400. Correlation/cache routing for
|
||||
# this backend must not be sent through the Responses payload.
|
||||
kwargs.pop("extra_headers", None)
|
||||
|
||||
max_tokens = params.get("max_tokens")
|
||||
if max_tokens is not None and not is_codex_backend:
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
//! Driven when the installer is launched as `Hermes-Setup.exe --update` (see
|
||||
//! `AppMode` in lib.rs). The desktop app hands off to us — it exits, then we:
|
||||
//!
|
||||
//! 1. wait for the old Hermes desktop process to fully exit (so the venv
|
||||
//! shim is free; otherwise `hermes update` aborts with exit code 2),
|
||||
//! 1. wait for the old Hermes desktop process to fully exit (so both the
|
||||
//! venv shim and packaged app.asar are free; otherwise `hermes update`
|
||||
//! or repair bootstrap can race locked files),
|
||||
//! 2. run `hermes update --yes --gateway` (Python/repo update; this does NOT
|
||||
//! rebuild apps/desktop by design — see cmd_update in hermes_cli/main.py),
|
||||
//! 3. run `hermes desktop --build-only` (the rebuild step update skips),
|
||||
@@ -38,8 +39,8 @@ use crate::events::{BootstrapEvent, LogStream, StageInfo, StageState};
|
||||
/// hermes_cli/main.py (sys.exit(2)). We surface a targeted message for this.
|
||||
const UPDATE_EXIT_CONCURRENT: i32 = 2;
|
||||
|
||||
/// How long to wait for the old desktop process to release the venv shim
|
||||
/// before giving up and letting `hermes update`'s own guard decide.
|
||||
/// How long to wait for the old desktop process to release files under the
|
||||
/// install tree before giving up and letting `hermes update`'s own guard decide.
|
||||
const DESKTOP_EXIT_WAIT: Duration = Duration::from_secs(20);
|
||||
const DESKTOP_EXIT_POLL: Duration = Duration::from_millis(500);
|
||||
|
||||
@@ -150,8 +151,10 @@ async fn run_update(app: AppHandle) -> Result<()> {
|
||||
// ---- pre-step: wait for the old desktop to die -----------------------
|
||||
// The desktop exec'd us then called app.exit(), but process teardown is
|
||||
// async on Windows. If it still holds the venv shim, `hermes update`
|
||||
// aborts with exit 2. Give it a bounded window to clear.
|
||||
wait_for_venv_free(&install_root, &app).await;
|
||||
// aborts with exit 2. If it still holds the packaged app.asar,
|
||||
// install.ps1's repair/re-clone path cannot move/remove the install tree.
|
||||
// Give both handles a bounded window to clear.
|
||||
wait_for_install_locks_free(&install_root, &app, "update").await;
|
||||
|
||||
// ---- stage 1: hermes update -----------------------------------------
|
||||
// Pass --branch so `hermes update` targets the branch this installer was
|
||||
@@ -173,8 +176,8 @@ async fn run_update(app: AppHandle) -> Result<()> {
|
||||
vec!["update".into(), "--yes".into(), "--gateway".into()];
|
||||
// --force skips `hermes update`'s Windows running-exe guard (which would
|
||||
// `sys.exit(2)` and dead-end the handoff). By contract the desktop has
|
||||
// already exited and waited for the venv shim to unlock before launching
|
||||
// us, and wait_for_venv_free below force-kills any straggler — so by the
|
||||
// already exited and waited for the install locks to clear before launching
|
||||
// us, and wait_for_install_locks_free below force-kills any straggler — so by the
|
||||
// time `hermes update` runs there is no legitimate hermes.exe to protect,
|
||||
// and the guard would only produce a false "Hermes is still running" stop.
|
||||
update_args.push("--force".into());
|
||||
@@ -391,48 +394,57 @@ async fn run_update(app: AppHandle) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Poll until the venv shim is no longer locked (Windows) or a bounded timeout
|
||||
/// elapses. On non-Windows this is a short fixed grace since file locking
|
||||
/// isn't the failure mode there.
|
||||
async fn wait_for_venv_free(install_root: &Path, app: &AppHandle) {
|
||||
let shim = venv_hermes(install_root);
|
||||
/// Poll until the venv shim AND packaged desktop app bundle are no longer locked
|
||||
/// (Windows) or a bounded timeout elapses. On non-Windows this is a short fixed
|
||||
/// grace since file locking isn't the failure mode there.
|
||||
pub(crate) async fn wait_for_install_locks_free(install_root: &Path, app: &AppHandle, stage: &str) {
|
||||
let lock_targets = install_lock_probe_paths(install_root);
|
||||
let deadline = Instant::now() + DESKTOP_EXIT_WAIT;
|
||||
|
||||
emit_log(app, Some("update"), LogStream::Stdout, "[update] waiting for Hermes to exit…");
|
||||
emit_log(app, Some(stage), LogStream::Stdout, "[handoff] waiting for Hermes to exit…");
|
||||
|
||||
loop {
|
||||
if !is_locked(&shim) {
|
||||
let locked = locked_paths(&lock_targets);
|
||||
if locked.is_empty() {
|
||||
return;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
// Last resort: a backend hermes.exe (or a grandchild it spawned)
|
||||
// is still holding the shim. The desktop should have reaped its
|
||||
// tree before handing off, but SIGTERM races / detached
|
||||
// grandchildren / AV handles can leave a straggler. Rather than
|
||||
// "proceed anyway" straight into uv's "Access is denied", force-kill
|
||||
// every hermes.exe except ourselves, then give the OS a beat to
|
||||
// unload the image.
|
||||
// Last resort: a backend hermes.exe (or the desktop Hermes.exe
|
||||
// itself) is still holding one of the update-sensitive files. The
|
||||
// desktop should have reaped its tree before handing off, but
|
||||
// SIGTERM races / detached grandchildren / AV handles can leave a
|
||||
// straggler. Rather than "proceed anyway" straight into uv's
|
||||
// "Access is denied" or install.ps1's locked app.asar failure,
|
||||
// force-kill every Hermes.exe except ourselves, then give the OS a
|
||||
// beat to unload the image.
|
||||
emit_log(
|
||||
app,
|
||||
Some("update"),
|
||||
Some(stage),
|
||||
LogStream::Stdout,
|
||||
"[update] Hermes still holding the venv shim; force-killing stragglers…",
|
||||
&format!(
|
||||
"[handoff] Hermes still holding install files ({}); force-killing stragglers…",
|
||||
format_locked_paths(&locked)
|
||||
),
|
||||
);
|
||||
force_kill_other_hermes();
|
||||
tokio::time::sleep(Duration::from_millis(800)).await;
|
||||
if !is_locked(&shim) {
|
||||
let locked_after_kill = locked_paths(&lock_targets);
|
||||
if locked_after_kill.is_empty() {
|
||||
emit_log(
|
||||
app,
|
||||
Some("update"),
|
||||
Some(stage),
|
||||
LogStream::Stdout,
|
||||
"[update] venv shim freed after force-kill",
|
||||
"[handoff] install files freed after force-kill",
|
||||
);
|
||||
} else {
|
||||
emit_log(
|
||||
app,
|
||||
Some("update"),
|
||||
Some(stage),
|
||||
LogStream::Stdout,
|
||||
"[update] venv shim still locked; proceeding (--force + quarantine will handle it)",
|
||||
&format!(
|
||||
"[handoff] install files still locked ({}); proceeding (--force + quarantine will handle it)",
|
||||
format_locked_paths(&locked_after_kill)
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
@@ -441,13 +453,44 @@ async fn wait_for_venv_free(install_root: &Path, app: &AppHandle) {
|
||||
}
|
||||
}
|
||||
|
||||
fn install_lock_probe_paths(install_root: &Path) -> Vec<PathBuf> {
|
||||
let mut paths = vec![venv_hermes(install_root)];
|
||||
paths.extend(desktop_app_payload_paths(install_root));
|
||||
paths
|
||||
}
|
||||
|
||||
fn desktop_app_payload_paths(install_root: &Path) -> Vec<PathBuf> {
|
||||
let release = install_root.join("apps").join("desktop").join("release");
|
||||
if cfg!(target_os = "windows") {
|
||||
vec![
|
||||
release.join("win-unpacked").join("resources").join("app.asar"),
|
||||
release.join("win-arm64-unpacked").join("resources").join("app.asar"),
|
||||
]
|
||||
} else if cfg!(target_os = "macos") {
|
||||
vec![
|
||||
release.join("mac").join("Hermes.app").join("Contents").join("Resources").join("app.asar"),
|
||||
release.join("mac-arm64").join("Hermes.app").join("Contents").join("Resources").join("app.asar"),
|
||||
]
|
||||
} else {
|
||||
vec![release.join("linux-unpacked").join("resources").join("app.asar")]
|
||||
}
|
||||
}
|
||||
|
||||
fn locked_paths(paths: &[PathBuf]) -> Vec<PathBuf> {
|
||||
paths.iter().filter(|p| is_locked(p)).cloned().collect()
|
||||
}
|
||||
|
||||
fn format_locked_paths(paths: &[PathBuf]) -> String {
|
||||
paths.iter().map(|p| p.display().to_string()).collect::<Vec<_>>().join(", ")
|
||||
}
|
||||
|
||||
/// Force-kill any `hermes.exe` other than this process. Windows-only; a no-op
|
||||
/// elsewhere (POSIX has no mandatory-lock contention). We can't selectively
|
||||
/// target "the backend" by PID here — the desktop already exited and we never
|
||||
/// knew its children — so we kill the whole `hermes.exe` image tree via
|
||||
/// taskkill, excluding our own PID.
|
||||
///
|
||||
/// Safe w.r.t. our own update child: this runs inside `wait_for_venv_free`,
|
||||
/// Safe w.r.t. our own update child: this runs inside the install-lock wait,
|
||||
/// which completes BEFORE we spawn `venv\Scripts\hermes.exe update`. At this
|
||||
/// point no update-driven hermes.exe exists yet, so the only hermes.exe images
|
||||
/// are stragglers from the old desktop — exactly what we want gone. (`/FI PID
|
||||
@@ -891,6 +934,29 @@ mod tests {
|
||||
assert!(!is_locked(Path::new("/nonexistent/does/not/exist/xyz")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lock_probe_paths_include_desktop_app_payload() {
|
||||
let root = Path::new("/x/hermes-agent");
|
||||
let probes = install_lock_probe_paths(root);
|
||||
|
||||
assert!(
|
||||
probes.iter().any(|p| p == &venv_hermes(root)),
|
||||
"venv shim remains part of the update lock probe"
|
||||
);
|
||||
assert!(
|
||||
probes.iter().any(|p| p.ends_with(Path::new("resources/app.asar"))),
|
||||
"packaged app.asar must be probed so repair/re-clone waits for the old desktop to exit"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locked_paths_ignores_missing_payloads() {
|
||||
let root = Path::new("/nonexistent/hermes-agent");
|
||||
let probes = install_lock_probe_paths(root);
|
||||
|
||||
assert!(locked_paths(&probes).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_update_branch_from_space_or_equals_args() {
|
||||
assert_eq!(
|
||||
|
||||
101
apps/desktop/electron/backend-env.cjs
Normal file
101
apps/desktop/electron/backend-env.cjs
Normal file
@@ -0,0 +1,101 @@
|
||||
const path = require('node:path')
|
||||
|
||||
// Match the POSIX fallback surface used by the Python terminal environment.
|
||||
// macOS apps launched from Finder/Dock often inherit only /usr/bin:/bin:/usr/sbin:/sbin,
|
||||
// which misses Apple Silicon Homebrew and user-installed CLI tools such as codex.
|
||||
const POSIX_SANE_PATH_ENTRIES = Object.freeze([
|
||||
'/opt/homebrew/bin',
|
||||
'/opt/homebrew/sbin',
|
||||
'/usr/local/sbin',
|
||||
'/usr/local/bin',
|
||||
'/usr/sbin',
|
||||
'/usr/bin',
|
||||
'/sbin',
|
||||
'/bin'
|
||||
])
|
||||
|
||||
function delimiterForPlatform(platform = process.platform) {
|
||||
return platform === 'win32' ? ';' : ':'
|
||||
}
|
||||
|
||||
function pathModuleForPlatform(platform = process.platform) {
|
||||
return platform === 'win32' ? path.win32 : path.posix
|
||||
}
|
||||
|
||||
function pathEnvKey(env = process.env, platform = process.platform) {
|
||||
if (platform !== 'win32') return 'PATH'
|
||||
return Object.keys(env || {}).find(key => key.toUpperCase() === 'PATH') || 'PATH'
|
||||
}
|
||||
|
||||
function currentPathValue(env = process.env, platform = process.platform) {
|
||||
const key = pathEnvKey(env, platform)
|
||||
return env?.[key] || ''
|
||||
}
|
||||
|
||||
function appendUniquePathEntries(entries, { delimiter = path.delimiter } = {}) {
|
||||
const seen = new Set()
|
||||
const ordered = []
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry) continue
|
||||
const parts = Array.isArray(entry) ? entry : String(entry).split(delimiter)
|
||||
for (const part of parts) {
|
||||
if (!part || seen.has(part)) continue
|
||||
seen.add(part)
|
||||
ordered.push(part)
|
||||
}
|
||||
}
|
||||
|
||||
return ordered.join(delimiter)
|
||||
}
|
||||
|
||||
function buildDesktopBackendPath({
|
||||
hermesHome,
|
||||
venvRoot,
|
||||
currentPath = '',
|
||||
platform = process.platform,
|
||||
pathModule = pathModuleForPlatform(platform)
|
||||
} = {}) {
|
||||
const delimiter = delimiterForPlatform(platform)
|
||||
const hermesNodeBin = hermesHome ? pathModule.join(hermesHome, 'node', 'bin') : null
|
||||
const venvBin = venvRoot ? pathModule.join(venvRoot, platform === 'win32' ? 'Scripts' : 'bin') : null
|
||||
const saneEntries = platform === 'win32' ? [] : POSIX_SANE_PATH_ENTRIES
|
||||
|
||||
return appendUniquePathEntries(
|
||||
[hermesNodeBin, venvBin, currentPath, saneEntries],
|
||||
{ delimiter }
|
||||
)
|
||||
}
|
||||
|
||||
function buildDesktopBackendEnv({
|
||||
hermesHome,
|
||||
pythonPathEntries = [],
|
||||
venvRoot,
|
||||
currentEnv = process.env,
|
||||
platform = process.platform,
|
||||
pathModule = pathModuleForPlatform(platform)
|
||||
} = {}) {
|
||||
const delimiter = delimiterForPlatform(platform)
|
||||
const currentPythonPath = currentEnv?.PYTHONPATH || ''
|
||||
const key = pathEnvKey(currentEnv, platform)
|
||||
|
||||
return {
|
||||
PYTHONPATH: appendUniquePathEntries([...pythonPathEntries, currentPythonPath], { delimiter }),
|
||||
[key]: buildDesktopBackendPath({
|
||||
hermesHome,
|
||||
venvRoot,
|
||||
currentPath: currentPathValue(currentEnv, platform),
|
||||
platform,
|
||||
pathModule
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
POSIX_SANE_PATH_ENTRIES,
|
||||
appendUniquePathEntries,
|
||||
buildDesktopBackendEnv,
|
||||
buildDesktopBackendPath,
|
||||
delimiterForPlatform,
|
||||
pathEnvKey
|
||||
}
|
||||
95
apps/desktop/electron/backend-env.test.cjs
Normal file
95
apps/desktop/electron/backend-env.test.cjs
Normal file
@@ -0,0 +1,95 @@
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const path = require('node:path')
|
||||
|
||||
const {
|
||||
POSIX_SANE_PATH_ENTRIES,
|
||||
appendUniquePathEntries,
|
||||
buildDesktopBackendEnv,
|
||||
buildDesktopBackendPath,
|
||||
pathEnvKey
|
||||
} = require('./backend-env.cjs')
|
||||
|
||||
test('desktop backend PATH adds Hermes-managed bins and missing POSIX sane entries', () => {
|
||||
const result = buildDesktopBackendPath({
|
||||
hermesHome: '/Users/test/.hermes',
|
||||
venvRoot: '/Users/test/.hermes/hermes-agent/venv',
|
||||
currentPath: '/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin',
|
||||
platform: 'darwin',
|
||||
pathModule: path.posix
|
||||
})
|
||||
|
||||
const entries = result.split(':')
|
||||
assert.equal(entries[0], '/Users/test/.hermes/node/bin')
|
||||
assert.equal(entries[1], '/Users/test/.hermes/hermes-agent/venv/bin')
|
||||
assert.ok(entries.includes('/opt/homebrew/bin'), 'Apple Silicon Homebrew bin is added')
|
||||
assert.ok(entries.includes('/opt/homebrew/sbin'), 'Apple Silicon Homebrew sbin is added')
|
||||
assert.ok(entries.includes('/usr/local/sbin'), 'missing standard sbin is added')
|
||||
|
||||
for (const expected of POSIX_SANE_PATH_ENTRIES) {
|
||||
assert.ok(entries.includes(expected), `${expected} should be present`)
|
||||
}
|
||||
})
|
||||
|
||||
test('desktop backend PATH preserves first occurrence and avoids duplicates', () => {
|
||||
const result = buildDesktopBackendPath({
|
||||
hermesHome: '/Users/test/.hermes',
|
||||
venvRoot: '/Users/test/.hermes/hermes-agent/venv',
|
||||
currentPath: '/opt/homebrew/bin:/usr/bin:/opt/homebrew/bin:/bin',
|
||||
platform: 'darwin',
|
||||
pathModule: path.posix
|
||||
})
|
||||
|
||||
const entries = result.split(':')
|
||||
assert.equal(entries.filter(entry => entry === '/opt/homebrew/bin').length, 1)
|
||||
assert.ok(
|
||||
entries.indexOf('/opt/homebrew/bin') < entries.indexOf('/opt/homebrew/sbin'),
|
||||
'existing Homebrew bin keeps its precedence over appended missing sane entries'
|
||||
)
|
||||
})
|
||||
|
||||
test('buildDesktopBackendEnv extends PYTHONPATH and backend PATH together', () => {
|
||||
const env = buildDesktopBackendEnv({
|
||||
hermesHome: '/Users/test/.hermes',
|
||||
pythonPathEntries: ['/repo/hermes-agent'],
|
||||
venvRoot: '/Users/test/.hermes/hermes-agent/venv',
|
||||
currentEnv: {
|
||||
PATH: '/usr/bin:/bin',
|
||||
PYTHONPATH: '/existing/pythonpath'
|
||||
},
|
||||
platform: 'darwin',
|
||||
pathModule: path.posix
|
||||
})
|
||||
|
||||
assert.equal(env.PYTHONPATH, '/repo/hermes-agent:/existing/pythonpath')
|
||||
assert.ok(env.PATH.startsWith('/Users/test/.hermes/node/bin:/Users/test/.hermes/hermes-agent/venv/bin:'))
|
||||
assert.ok(env.PATH.includes('/opt/homebrew/bin'))
|
||||
})
|
||||
|
||||
test('Windows PATH casing and delimiter are preserved without POSIX sane entries', () => {
|
||||
const env = buildDesktopBackendEnv({
|
||||
hermesHome: 'C:\\Users\\test\\AppData\\Local\\hermes',
|
||||
pythonPathEntries: ['C:\\repo\\hermes-agent'],
|
||||
venvRoot: 'C:\\Users\\test\\AppData\\Local\\hermes\\hermes-agent\\venv',
|
||||
currentEnv: {
|
||||
Path: 'C:\\Windows\\System32;C:\\Windows',
|
||||
PYTHONPATH: 'C:\\existing\\pythonpath'
|
||||
},
|
||||
platform: 'win32',
|
||||
pathModule: path.win32
|
||||
})
|
||||
|
||||
assert.equal(pathEnvKey({ Path: 'x' }, 'win32'), 'Path')
|
||||
assert.equal(env.PATH, undefined)
|
||||
assert.ok(env.Path.startsWith('C:\\Users\\test\\AppData\\Local\\hermes\\node\\bin;'))
|
||||
assert.ok(env.Path.includes('\\venv\\Scripts;'))
|
||||
assert.ok(env.Path.includes(';C:\\Windows\\System32;C:\\Windows'))
|
||||
assert.equal(env.Path.includes('/opt/homebrew/bin'), false)
|
||||
})
|
||||
|
||||
test('appendUniquePathEntries drops empty entries and keeps first occurrence', () => {
|
||||
assert.equal(
|
||||
appendUniquePathEntries([':/a::/b', ['/a', '/c']], { delimiter: ':' }),
|
||||
'/a:/b:/c'
|
||||
)
|
||||
})
|
||||
66
apps/desktop/electron/backend-ready.cjs
Normal file
66
apps/desktop/electron/backend-ready.cjs
Normal file
@@ -0,0 +1,66 @@
|
||||
const _READY_RE = /^HERMES_DASHBOARD_READY port=(\d+)/m
|
||||
|
||||
/**
|
||||
* Watch a child process's stdout for the `HERMES_DASHBOARD_READY port=<N>`
|
||||
* line that web_server.py prints after uvicorn binds its socket.
|
||||
*
|
||||
* Returns the parsed port. Rejects if:
|
||||
* - the child exits before emitting the line
|
||||
* - the child emits an `error` event
|
||||
* - no line arrives within the timeout
|
||||
*
|
||||
* A single `cleanup()` tears down every listener (data/exit/error/timeout)
|
||||
* on every terminal path — resolve, reject, or timeout — so repeated
|
||||
* backend spawns don't leak listener slots on the child.
|
||||
*/
|
||||
function waitForDashboardPort(child, timeoutMs = 45_000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let buf = ''
|
||||
let done = false
|
||||
|
||||
function cleanup() {
|
||||
if (done) return
|
||||
done = true
|
||||
clearTimeout(timer)
|
||||
child.stdout.off('data', onData)
|
||||
child.off('exit', onExit)
|
||||
child.off('error', onError)
|
||||
}
|
||||
|
||||
function onData(chunk) {
|
||||
buf += chunk.toString()
|
||||
let nl
|
||||
while ((nl = buf.indexOf('\n')) !== -1) {
|
||||
const line = buf.slice(0, nl)
|
||||
buf = buf.slice(nl + 1)
|
||||
const m = line.match(_READY_RE)
|
||||
if (m) {
|
||||
cleanup()
|
||||
resolve(parseInt(m[1], 10))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onExit(code, signal) {
|
||||
cleanup()
|
||||
reject(new Error(`Hermes backend: exited before port announcement (${signal || code})`))
|
||||
}
|
||||
|
||||
function onError(err) {
|
||||
cleanup()
|
||||
reject(err)
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
cleanup()
|
||||
reject(new Error(`Timed out waiting for Hermes backend port announcement (${timeoutMs}ms)`))
|
||||
}, timeoutMs)
|
||||
|
||||
child.stdout.on('data', onData)
|
||||
child.on('exit', onExit)
|
||||
child.on('error', onError)
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = { waitForDashboardPort }
|
||||
174
apps/desktop/electron/git-worktrees.cjs
Normal file
174
apps/desktop/electron/git-worktrees.cjs
Normal file
@@ -0,0 +1,174 @@
|
||||
'use strict'
|
||||
|
||||
// Resolve git-worktree relationships for a set of session cwds, reading git's
|
||||
// on-disk metadata directly (no `git` spawn per path):
|
||||
//
|
||||
// - A normal checkout has a `.git` DIRECTORY at its root → it's the main
|
||||
// worktree; its repo root IS that directory's parent.
|
||||
// - A linked worktree has a `.git` FILE: `gitdir: <repo>/.git/worktrees/<name>`.
|
||||
// That admin dir's `commondir` points back at the shared `<repo>/.git`, whose
|
||||
// parent is the main repo root.
|
||||
//
|
||||
// Grouping by repoRoot therefore clusters a repo's main checkout with all of its
|
||||
// linked worktrees, regardless of how the worktree directories are named. The
|
||||
// branch (read from the worktree's own HEAD) gives each worktree a meaningful
|
||||
// label.
|
||||
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const { resolveRequestedPathForIpc } = require('./hardening.cjs')
|
||||
|
||||
// Walk up from `start` to the nearest ancestor that carries a `.git` entry
|
||||
// (file for a linked worktree, dir for the main checkout). Capped so a stray
|
||||
// path can't loop forever.
|
||||
function findGitHost(start, fsImpl) {
|
||||
let dir = start
|
||||
|
||||
for (let i = 0; i < 64; i += 1) {
|
||||
const dotgit = path.join(dir, '.git')
|
||||
|
||||
try {
|
||||
if (fsImpl.existsSync(dotgit)) {
|
||||
return dir
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
const parent = path.dirname(dir)
|
||||
|
||||
if (parent === dir) {
|
||||
return null
|
||||
}
|
||||
|
||||
dir = parent
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function readBranch(gitDir, fsImpl) {
|
||||
try {
|
||||
const head = fsImpl.readFileSync(path.join(gitDir, 'HEAD'), 'utf8').trim()
|
||||
const ref = head.match(/^ref:\s*refs\/heads\/(.+)$/)
|
||||
|
||||
if (ref) {
|
||||
return ref[1]
|
||||
}
|
||||
|
||||
// Detached HEAD: surface a short sha so the worktree still gets a label.
|
||||
return /^[0-9a-f]{7,40}$/i.test(head) ? head.slice(0, 8) : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Given the directory that owns the `.git` entry, resolve its worktree identity.
|
||||
function resolveFromHost(host, fsImpl) {
|
||||
const dotgit = path.join(host, '.git')
|
||||
let stat
|
||||
|
||||
try {
|
||||
stat = fsImpl.statSync(dotgit)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
return {
|
||||
repoRoot: host,
|
||||
worktreeRoot: host,
|
||||
isMainWorktree: true,
|
||||
branch: readBranch(dotgit, fsImpl)
|
||||
}
|
||||
}
|
||||
|
||||
// Linked worktree: `.git` is a file pointing at the admin dir.
|
||||
let contents
|
||||
|
||||
try {
|
||||
contents = fsImpl.readFileSync(dotgit, 'utf8').trim()
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
const match = contents.match(/^gitdir:\s*(.+)$/m)
|
||||
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
|
||||
const adminDir = path.resolve(host, match[1].trim())
|
||||
|
||||
// `commondir` resolves to the shared `<repo>/.git`; fall back to walking two
|
||||
// levels up from `<repo>/.git/worktrees/<name>` if it's missing.
|
||||
let commonDir
|
||||
|
||||
try {
|
||||
const rel = fsImpl.readFileSync(path.join(adminDir, 'commondir'), 'utf8').trim()
|
||||
commonDir = path.resolve(adminDir, rel)
|
||||
} catch {
|
||||
commonDir = path.dirname(path.dirname(adminDir))
|
||||
}
|
||||
|
||||
return {
|
||||
repoRoot: path.dirname(commonDir),
|
||||
worktreeRoot: host,
|
||||
isMainWorktree: false,
|
||||
branch: readBranch(adminDir, fsImpl)
|
||||
}
|
||||
}
|
||||
|
||||
function resolveWorktree(startPath, fsImpl = fs) {
|
||||
let resolved
|
||||
|
||||
try {
|
||||
resolved = resolveRequestedPathForIpc(startPath, { purpose: 'Worktree lookup' })
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
let start = resolved
|
||||
|
||||
try {
|
||||
const stat = fsImpl.statSync(resolved)
|
||||
|
||||
if (!stat.isDirectory()) {
|
||||
start = path.dirname(resolved)
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
const host = findGitHost(start, fsImpl)
|
||||
|
||||
if (!host) {
|
||||
return null
|
||||
}
|
||||
|
||||
return resolveFromHost(host, fsImpl)
|
||||
}
|
||||
|
||||
// Batch entry point for the renderer: maps each requested cwd to its worktree
|
||||
// info (or null when it isn't inside a git checkout / can't be read). Dedupes so
|
||||
// many sessions sharing a cwd cost one lookup.
|
||||
async function worktreesForIpc(cwds, options = {}) {
|
||||
const fsImpl = options.fs || fs
|
||||
const list = Array.isArray(cwds) ? cwds : []
|
||||
const out = {}
|
||||
|
||||
for (const cwd of list) {
|
||||
if (typeof cwd !== 'string' || !cwd.trim() || cwd in out) {
|
||||
continue
|
||||
}
|
||||
|
||||
out[cwd] = resolveWorktree(cwd, fsImpl)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
resolveWorktree,
|
||||
worktreesForIpc
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
const fs = require('node:fs')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
const { fileURLToPath } = require('node:url')
|
||||
|
||||
@@ -142,7 +143,14 @@ function rejectUnsafePathSyntax(filePath, purpose = 'File read') {
|
||||
|
||||
function resolveRequestedPathForIpc(filePath, options = {}) {
|
||||
const purpose = String(options.purpose || 'File read')
|
||||
const raw = rejectUnsafePathSyntax(filePath, purpose)
|
||||
let raw = rejectUnsafePathSyntax(filePath, purpose)
|
||||
|
||||
// Gateway-reported cwds (config `terminal.cwd`, remote sessions) routinely
|
||||
// arrive as `~/...`. Node's fs has no shell — without expansion the path
|
||||
// resolves under process.cwd() and every read "ENOENT"s forever.
|
||||
if (raw === '~' || raw.startsWith('~/') || raw.startsWith('~\\')) {
|
||||
raw = path.join(os.homedir(), raw.slice(1))
|
||||
}
|
||||
|
||||
if (/^file:/i.test(raw)) {
|
||||
let resolvedPath
|
||||
|
||||
@@ -106,6 +106,19 @@ test('resolveRequestedPathForIpc resolves relative paths from the trimmed base d
|
||||
)
|
||||
})
|
||||
|
||||
test('resolveRequestedPathForIpc expands ~ to the home directory', () => {
|
||||
assert.equal(resolveRequestedPathForIpc('~', { purpose: 'Directory read' }), path.resolve(os.homedir()))
|
||||
assert.equal(
|
||||
resolveRequestedPathForIpc('~/www/project', { purpose: 'Directory read' }),
|
||||
path.resolve(os.homedir(), 'www/project')
|
||||
)
|
||||
// `~user` shorthand is NOT expanded — only the caller's own home.
|
||||
assert.equal(
|
||||
resolveRequestedPathForIpc('~other/secret', { baseDir: os.tmpdir(), purpose: 'Directory read' }),
|
||||
path.resolve(os.tmpdir(), '~other/secret')
|
||||
)
|
||||
})
|
||||
|
||||
test('resolveReadableFileForIpc validates existence type size and sensitivity', async t => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-hardening-'))
|
||||
t.after(() => fs.rmSync(tempDir, { recursive: true, force: true }))
|
||||
|
||||
@@ -26,19 +26,23 @@ const { pathToFileURL } = require('node:url')
|
||||
const { execFileSync, spawn } = require('node:child_process')
|
||||
const { detectRemoteDisplay, isWindowsBinaryPathInWsl, isWslEnvironment } = require('./bootstrap-platform.cjs')
|
||||
const { runBootstrap } = require('./bootstrap-runner.cjs')
|
||||
const { buildSessionWindowUrl, createSessionWindowRegistry } = require('./session-windows.cjs')
|
||||
const {
|
||||
buildSessionWindowUrl,
|
||||
createSessionWindowRegistry,
|
||||
SESSION_WINDOW_MIN_HEIGHT,
|
||||
SESSION_WINDOW_MIN_WIDTH
|
||||
} = require('./session-windows.cjs')
|
||||
const { canImportHermesCli, verifyHermesCli } = require('./backend-probes.cjs')
|
||||
const { probeGatewayWebSocket } = require('./gateway-ws-probe.cjs')
|
||||
const { adoptServedDashboardToken } = require('./dashboard-token.cjs')
|
||||
const { PortPool } = require('./port-pool.cjs')
|
||||
const { waitForDashboardPort } = require('./backend-ready.cjs')
|
||||
const { serializeJsonBody, setJsonRequestHeaders } = require('./oauth-net-request.cjs')
|
||||
const { fetchMarketplaceThemes, searchMarketplaceThemes } = require('./vscode-marketplace.cjs')
|
||||
const { buildDesktopBackendEnv } = require('./backend-env.cjs')
|
||||
const { readDirForIpc } = require('./fs-read-dir.cjs')
|
||||
const { gitRootForIpc } = require('./git-root.cjs')
|
||||
const {
|
||||
OFFICIAL_REPO_HTTPS_URL,
|
||||
isOfficialSshRemote
|
||||
} = require('./update-remote.cjs')
|
||||
const { worktreesForIpc } = require('./git-worktrees.cjs')
|
||||
const { OFFICIAL_REPO_HTTPS_URL, isOfficialSshRemote } = require('./update-remote.cjs')
|
||||
const {
|
||||
buildPosixCleanupScript,
|
||||
buildWindowsCleanupScript,
|
||||
@@ -108,12 +112,6 @@ if (USER_DATA_OVERRIDE) {
|
||||
app.setPath('userData', resolvedUserData)
|
||||
}
|
||||
|
||||
const PORT_FLOOR = 9120
|
||||
const PORT_CEILING = 9199
|
||||
// In-process port reservations that close the pickPort() TOCTOU window where
|
||||
// two concurrent backend spawns could be handed the same port. See
|
||||
// port-pool.cjs for the full rationale.
|
||||
const portPool = new PortPool(PORT_FLOOR, PORT_CEILING)
|
||||
const DEV_SERVER = process.env.HERMES_DESKTOP_DEV_SERVER
|
||||
const IS_PACKAGED = app.isPackaged
|
||||
const IS_MAC = process.platform === 'darwin'
|
||||
@@ -347,10 +345,110 @@ const APP_ICON_PATHS = [
|
||||
let rendererTitleBarTheme = null
|
||||
const terminalSessions = new Map()
|
||||
|
||||
// Force the NATIVE window appearance (vibrancy material, titlebar, the
|
||||
// pre-first-paint window background) to follow the APP theme instead of the
|
||||
// OS appearance. With `vibrancy` set, macOS paints an NSVisualEffectView that
|
||||
// tracks the window's effective appearance and ignores `backgroundColor` —
|
||||
// so a dark-themed app on a light-mode Mac flashes a white material on every
|
||||
// new window until the renderer covers it. The renderer reports its mode via
|
||||
// 'hermes:native-theme' ('dark' | 'light' | 'system'); we pin
|
||||
// nativeTheme.themeSource to it and persist the value so cold launches paint
|
||||
// correctly before the renderer has even loaded.
|
||||
const NATIVE_THEME_CONFIG_PATH = path.join(app.getPath('userData'), 'native-theme.json')
|
||||
const THEME_SOURCES = new Set(['dark', 'light', 'system'])
|
||||
|
||||
function readPersistedThemeSource() {
|
||||
try {
|
||||
const parsed = JSON.parse(fs.readFileSync(NATIVE_THEME_CONFIG_PATH, 'utf8'))
|
||||
|
||||
if (parsed && THEME_SOURCES.has(parsed.themeSource)) {
|
||||
return parsed.themeSource
|
||||
}
|
||||
} catch {
|
||||
// Missing / malformed → follow the OS like a fresh install.
|
||||
}
|
||||
|
||||
return 'system'
|
||||
}
|
||||
|
||||
function writePersistedThemeSource(mode) {
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(NATIVE_THEME_CONFIG_PATH), { recursive: true })
|
||||
fs.writeFileSync(NATIVE_THEME_CONFIG_PATH, JSON.stringify({ themeSource: mode }, null, 2), 'utf8')
|
||||
} catch (error) {
|
||||
rememberLog(`[theme] write native theme failed: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
nativeTheme.themeSource = readPersistedThemeSource()
|
||||
|
||||
// Window translucency (see-through window). One lever, 0–100; 0 = off (the
|
||||
// default). Mapped to the native window opacity so the desktop shows through
|
||||
// the whole window. Persisted so a cold launch applies it at window creation,
|
||||
// before the renderer reports its value. macOS + Windows only; `setOpacity` is
|
||||
// a no-op on Linux. See store/translucency.
|
||||
const TRANSLUCENCY_CONFIG_PATH = path.join(app.getPath('userData'), 'translucency.json')
|
||||
|
||||
function clampIntensity(value) {
|
||||
const n = Math.round(Number(value))
|
||||
|
||||
return Number.isFinite(n) ? Math.min(100, Math.max(0, n)) : 0
|
||||
}
|
||||
|
||||
function readPersistedTranslucency() {
|
||||
try {
|
||||
return clampIntensity(JSON.parse(fs.readFileSync(TRANSLUCENCY_CONFIG_PATH, 'utf8')).intensity)
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
function writePersistedTranslucency(intensity) {
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(TRANSLUCENCY_CONFIG_PATH), { recursive: true })
|
||||
fs.writeFileSync(TRANSLUCENCY_CONFIG_PATH, JSON.stringify({ intensity }, null, 2), 'utf8')
|
||||
} catch (error) {
|
||||
rememberLog(`[translucency] write failed: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
let translucencyIntensity = readPersistedTranslucency()
|
||||
|
||||
// Map the 0–100 lever to a window opacity. Floor at 0.3 so the most see-through
|
||||
// setting is still usable rather than nearly invisible. 0 → fully opaque.
|
||||
function windowOpacity() {
|
||||
return 1 - (translucencyIntensity / 100) * 0.7
|
||||
}
|
||||
|
||||
// Re-apply translucency to a live window (runtime toggle, no recreation).
|
||||
// `setOpacity` is a no-op on Linux, which is fine — it just stays opaque there.
|
||||
function applyWindowTranslucency(win) {
|
||||
if (!win || win.isDestroyed() || typeof win.setOpacity !== 'function') {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
win.setOpacity(windowOpacity())
|
||||
} catch (error) {
|
||||
rememberLog(`[translucency] apply failed: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
function isHexColor(value) {
|
||||
return typeof value === 'string' && /^#[0-9a-f]{6}$/i.test(value)
|
||||
}
|
||||
|
||||
// Background color to paint a window with BEFORE its renderer loads, so a new
|
||||
// (or reopened) window doesn't flash white/light in dark mode. Prefer the theme
|
||||
// the renderer last reported; fall back to the OS preference on first launch.
|
||||
function getWindowBackgroundColor() {
|
||||
if (rendererTitleBarTheme && isHexColor(rendererTitleBarTheme.background)) {
|
||||
return rendererTitleBarTheme.background
|
||||
}
|
||||
|
||||
return nativeTheme.shouldUseDarkColors ? '#111111' : '#f7f7f7'
|
||||
}
|
||||
|
||||
function getTitleBarOverlayOptions() {
|
||||
if (IS_MAC) {
|
||||
return { height: TITLEBAR_HEIGHT }
|
||||
@@ -1163,10 +1261,14 @@ function findSystemPython() {
|
||||
if (pyExe) {
|
||||
for (const version of SUPPORTED_VERSIONS) {
|
||||
try {
|
||||
const out = execFileSync(pyExe, [`-${version}`, '-c', 'import sys; print(sys.executable)'], hiddenWindowsChildOptions({
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'ignore']
|
||||
}))
|
||||
const out = execFileSync(
|
||||
pyExe,
|
||||
[`-${version}`, '-c', 'import sys; print(sys.executable)'],
|
||||
hiddenWindowsChildOptions({
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'ignore']
|
||||
})
|
||||
)
|
||||
const candidate = out.trim()
|
||||
if (candidate && fileExists(candidate)) return candidate
|
||||
} catch {
|
||||
@@ -1301,11 +1403,15 @@ function resolveUpdateRoot() {
|
||||
|
||||
function runGit(args, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(resolveGitBinary(), IS_WINDOWS ? ['-c', 'windows.appendAtomically=false', ...args] : args, hiddenWindowsChildOptions({
|
||||
cwd: options.cwd,
|
||||
env: { ...process.env, ...(options.env || {}), GIT_TERMINAL_PROMPT: '0' },
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
}))
|
||||
const child = spawn(
|
||||
resolveGitBinary(),
|
||||
IS_WINDOWS ? ['-c', 'windows.appendAtomically=false', ...args] : args,
|
||||
hiddenWindowsChildOptions({
|
||||
cwd: options.cwd,
|
||||
env: { ...process.env, ...(options.env || {}), GIT_TERMINAL_PROMPT: '0' },
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
})
|
||||
)
|
||||
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
@@ -1729,6 +1835,44 @@ async function applyUpdates(opts = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handOffWindowsBootstrapRecovery(reason) {
|
||||
if (!IS_WINDOWS || !IS_PACKAGED) return false
|
||||
|
||||
const updater = resolveUpdaterBinary()
|
||||
if (!updater) return false
|
||||
|
||||
const updateRoot = resolveUpdateRoot()
|
||||
const { branch: configuredBranch } = readDesktopUpdateConfig()
|
||||
const branch = directoryExists(path.join(updateRoot, '.git'))
|
||||
? await resolveHealedBranch(updateRoot, configuredBranch || DEFAULT_UPDATE_BRANCH)
|
||||
: configuredBranch || DEFAULT_UPDATE_BRANCH
|
||||
const venvBin = path.join(updateRoot, 'venv', IS_WINDOWS ? 'Scripts' : 'bin')
|
||||
const venvHermes = path.join(venvBin, IS_WINDOWS ? 'hermes.exe' : 'hermes')
|
||||
const updaterArgs = fileExists(venvHermes) ? ['--update', '--branch', branch] : ['--repair', '--branch', branch]
|
||||
|
||||
await releaseBackendLockForUpdate(updateRoot)
|
||||
|
||||
const child = spawn(updater, updaterArgs, {
|
||||
cwd: HERMES_HOME,
|
||||
env: {
|
||||
...process.env,
|
||||
HERMES_HOME,
|
||||
PATH: [path.join(HERMES_HOME, 'node', 'bin'), venvBin, process.env.PATH].filter(Boolean).join(path.delimiter)
|
||||
},
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
windowsHide: false
|
||||
})
|
||||
child.unref()
|
||||
|
||||
rememberLog(`[bootstrap] handed off ${reason} recovery to updater: ${updater} ${updaterArgs.join(' ')}; exiting desktop to release app.asar`)
|
||||
setTimeout(() => {
|
||||
app.quit()
|
||||
}, 600)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Resolve the hermes CLI to drive an in-app update: prefer the venv shim in
|
||||
// the install we're updating, fall back to `hermes` on PATH.
|
||||
function resolveHermesCliBinary(updateRoot) {
|
||||
@@ -1742,11 +1886,15 @@ function runStreamedUpdate(command, args, { cwd, env, stage } = {}) {
|
||||
return new Promise(resolve => {
|
||||
let child
|
||||
try {
|
||||
child = spawn(command, args, hiddenWindowsChildOptions({
|
||||
cwd,
|
||||
env: { ...process.env, ...(env || {}) },
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
}))
|
||||
child = spawn(
|
||||
command,
|
||||
args,
|
||||
hiddenWindowsChildOptions({
|
||||
cwd,
|
||||
env: { ...process.env, ...(env || {}) },
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
})
|
||||
)
|
||||
} catch (err) {
|
||||
resolve({ code: 1, error: err.message })
|
||||
return
|
||||
@@ -2134,9 +2282,11 @@ function createPythonBackend(root, label, dashboardArgs, options = {}) {
|
||||
label,
|
||||
command: python,
|
||||
args: ['-m', 'hermes_cli.main', ...dashboardArgs],
|
||||
env: {
|
||||
PYTHONPATH: [root, process.env.PYTHONPATH].filter(Boolean).join(path.delimiter)
|
||||
},
|
||||
env: buildDesktopBackendEnv({
|
||||
hermesHome: HERMES_HOME,
|
||||
pythonPathEntries: [root],
|
||||
venvRoot: path.join(root, 'venv')
|
||||
}),
|
||||
root,
|
||||
bootstrap: Boolean(options.bootstrap),
|
||||
shell: false
|
||||
@@ -2155,9 +2305,11 @@ function createActiveBackend(dashboardArgs) {
|
||||
label: `Hermes at ${ACTIVE_HERMES_ROOT}`,
|
||||
command: fileExists(venvPython) ? venvPython : findSystemPython(),
|
||||
args: ['-m', 'hermes_cli.main', ...dashboardArgs],
|
||||
env: {
|
||||
PYTHONPATH: [ACTIVE_HERMES_ROOT, process.env.PYTHONPATH].filter(Boolean).join(path.delimiter)
|
||||
},
|
||||
env: buildDesktopBackendEnv({
|
||||
hermesHome: HERMES_HOME,
|
||||
pythonPathEntries: [ACTIVE_HERMES_ROOT],
|
||||
venvRoot: VENV_ROOT
|
||||
}),
|
||||
root: ACTIVE_HERMES_ROOT,
|
||||
bootstrap: true,
|
||||
shell: false
|
||||
@@ -2318,6 +2470,14 @@ async function ensureRuntime(backend) {
|
||||
if (backend.kind === 'bootstrap-needed') {
|
||||
rememberLog('[bootstrap] no Hermes install found; starting first-launch bootstrap')
|
||||
|
||||
if (await handOffWindowsBootstrapRecovery('bootstrap-needed')) {
|
||||
const handoffError = new Error('Hermes recovery was handed off to Hermes Setup. The desktop will restart when recovery completes.')
|
||||
handoffError.isBootstrapFailure = true
|
||||
handoffError.bootstrapHandedOff = true
|
||||
bootstrapFailure = handoffError
|
||||
throw handoffError
|
||||
}
|
||||
|
||||
// Eagerly flip the bootstrap UI state to 'active' so the renderer
|
||||
// shows the install overlay BEFORE the runner finishes fetching the
|
||||
// manifest (which on slow networks can take tens of seconds and would
|
||||
@@ -2447,24 +2607,6 @@ async function ensureRuntime(backend) {
|
||||
return backend
|
||||
}
|
||||
|
||||
function isPortAvailable(port) {
|
||||
return new Promise(resolve => {
|
||||
const server = net.createServer()
|
||||
server.once('error', () => resolve(false))
|
||||
server.once('listening', () => {
|
||||
server.close(() => resolve(true))
|
||||
})
|
||||
server.listen(port, '127.0.0.1')
|
||||
})
|
||||
}
|
||||
|
||||
async function pickPort() {
|
||||
const port = await portPool.reserve(isPortAvailable)
|
||||
if (port === null) {
|
||||
throw new Error(`No free localhost port in ${PORT_FLOOR}-${PORT_CEILING}`)
|
||||
}
|
||||
return port
|
||||
}
|
||||
|
||||
function fetchJson(url, token, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -4542,49 +4684,41 @@ async function spawnPoolBackend(profile, entry) {
|
||||
}
|
||||
}
|
||||
|
||||
const port = await pickPort()
|
||||
const token = crypto.randomBytes(32).toString('base64url')
|
||||
// --profile wins over the inherited HERMES_HOME env (see _apply_profile_override
|
||||
// step 3 in hermes_cli/main.py), so the child re-homes to this profile.
|
||||
const dashboardArgs = ['--profile', profile, 'dashboard', '--no-open', '--host', '127.0.0.1', '--port', String(port)]
|
||||
let backend
|
||||
let hermesCwd
|
||||
let webDist
|
||||
try {
|
||||
backend = await ensureRuntime(resolveHermesBackend(dashboardArgs))
|
||||
hermesCwd = resolveHermesCwd()
|
||||
webDist = resolveWebDist()
|
||||
} catch (error) {
|
||||
// These run before the child exists / its exit handler is attached, so a
|
||||
// throw here would otherwise leak the reservation and slowly exhaust the
|
||||
// 9120-9199 range across switch cycles in one app session.
|
||||
portPool.release(port)
|
||||
throw error
|
||||
}
|
||||
// --port 0: the OS assigns an ephemeral port; the child announces it on stdout.
|
||||
const dashboardArgs = ['--profile', profile, 'dashboard', '--no-open', '--host', '127.0.0.1', '--port', '0']
|
||||
const backend = await ensureRuntime(resolveHermesBackend(dashboardArgs))
|
||||
const hermesCwd = resolveHermesCwd()
|
||||
const webDist = resolveWebDist()
|
||||
|
||||
rememberLog(`Starting Hermes backend for profile "${profile}" via ${backend.label}`)
|
||||
|
||||
const child = spawn(backend.command, backend.args, hiddenWindowsChildOptions({
|
||||
cwd: hermesCwd,
|
||||
env: {
|
||||
...process.env,
|
||||
HERMES_HOME,
|
||||
...backend.env,
|
||||
// Pin the gateway's tool/terminal cwd to the same directory we chose for
|
||||
// the child process. Inherited TERMINAL_CWD (or a stale config bridge)
|
||||
// can still point at the install dir even when spawn cwd is home.
|
||||
TERMINAL_CWD: hermesCwd,
|
||||
HERMES_DASHBOARD_SESSION_TOKEN: token,
|
||||
// Marks this dashboard backend as desktop-spawned so it runs the cron
|
||||
// scheduler tick loop (the gateway isn't running under the app).
|
||||
HERMES_DESKTOP: '1',
|
||||
HERMES_WEB_DIST: webDist
|
||||
},
|
||||
shell: backend.shell,
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
}))
|
||||
const child = spawn(
|
||||
backend.command,
|
||||
backend.args,
|
||||
hiddenWindowsChildOptions({
|
||||
cwd: hermesCwd,
|
||||
env: {
|
||||
...process.env,
|
||||
HERMES_HOME,
|
||||
...backend.env,
|
||||
// Pin the gateway's tool/terminal cwd to the same directory we chose for
|
||||
// the child process. Inherited TERMINAL_CWD (or a stale config bridge)
|
||||
// can still point at the install dir even when spawn cwd is home.
|
||||
TERMINAL_CWD: hermesCwd,
|
||||
HERMES_DASHBOARD_SESSION_TOKEN: token,
|
||||
// Marks this dashboard backend as desktop-spawned so it runs the cron
|
||||
// scheduler tick loop (the gateway isn't running under the app).
|
||||
HERMES_DESKTOP: '1',
|
||||
HERMES_WEB_DIST: webDist
|
||||
},
|
||||
shell: backend.shell,
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
})
|
||||
)
|
||||
entry.process = child
|
||||
entry.port = port
|
||||
entry.token = token
|
||||
|
||||
child.stdout.on('data', rememberLog)
|
||||
@@ -4598,13 +4732,11 @@ async function spawnPoolBackend(profile, entry) {
|
||||
child.once('error', error => {
|
||||
rememberLog(`Hermes backend for profile "${profile}" failed to start: ${error.message}`)
|
||||
backendPool.delete(profile)
|
||||
portPool.release(port)
|
||||
rejectStart?.(error)
|
||||
})
|
||||
child.once('exit', (code, signal) => {
|
||||
rememberLog(`Hermes backend for profile "${profile}" exited (${signal || code})`)
|
||||
backendPool.delete(profile)
|
||||
portPool.release(port)
|
||||
if (!ready) {
|
||||
rejectStart?.(
|
||||
new Error(`Hermes backend for profile "${profile}" exited before it became ready (${signal || code}).`)
|
||||
@@ -4612,6 +4744,10 @@ async function spawnPoolBackend(profile, entry) {
|
||||
}
|
||||
})
|
||||
|
||||
// Discover the ephemeral port the child bound to
|
||||
const port = await Promise.race([waitForDashboardPort(child), startFailed])
|
||||
entry.port = port
|
||||
|
||||
const baseUrl = `http://127.0.0.1:${port}`
|
||||
await Promise.race([waitForHermes(baseUrl, token), startFailed])
|
||||
ready = true
|
||||
@@ -4639,7 +4775,6 @@ function stopPoolBackend(profile) {
|
||||
const entry = backendPool.get(profile)
|
||||
if (!entry) return
|
||||
backendPool.delete(profile)
|
||||
if (entry.port) portPool.release(entry.port)
|
||||
if (entry.process && !entry.process.killed) {
|
||||
try {
|
||||
entry.process.kill('SIGTERM')
|
||||
@@ -4725,11 +4860,6 @@ async function startHermes() {
|
||||
}
|
||||
if (connectionPromise) return connectionPromise
|
||||
|
||||
// Hoisted so the outer .catch can release a port reserved by pickPort() when
|
||||
// a throw (e.g. ensureRuntime failing) happens before the child's exit
|
||||
// handler is attached. Stays null on the remote path (no port picked).
|
||||
let reservedPort = null
|
||||
|
||||
connectionPromise = (async () => {
|
||||
await advanceBootProgress('backend.resolve', 'Resolving Hermes backend', 8)
|
||||
// Resolve for the desktop's primary profile so a per-profile remote
|
||||
@@ -4757,11 +4887,9 @@ async function startHermes() {
|
||||
}
|
||||
}
|
||||
|
||||
await advanceBootProgress('backend.port', 'Finding an open local port', 16)
|
||||
const port = await pickPort()
|
||||
reservedPort = port
|
||||
const token = crypto.randomBytes(32).toString('base64url')
|
||||
const dashboardArgs = ['dashboard', '--no-open', '--host', '127.0.0.1', '--port', String(port)]
|
||||
// --port 0: the OS assigns an ephemeral port; the child announces it on stdout.
|
||||
const dashboardArgs = ['dashboard', '--no-open', '--host', '127.0.0.1', '--port', '0']
|
||||
// Pin the desktop's chosen profile via the global --profile flag. This is
|
||||
// deterministic (it wins over the sticky ~/.hermes/active_profile file) and
|
||||
// resolves HERMES_HOME the same way `hermes -p <name>` does on the CLI. An
|
||||
@@ -4779,30 +4907,34 @@ async function startHermes() {
|
||||
await advanceBootProgress('backend.spawn', `Starting Hermes backend via ${backend.label}`, 84)
|
||||
rememberLog(`Starting Hermes backend via ${backend.label}`)
|
||||
|
||||
hermesProcess = spawn(backend.command, backend.args, hiddenWindowsChildOptions({
|
||||
cwd: hermesCwd,
|
||||
env: {
|
||||
...process.env,
|
||||
// Explicitly pin HERMES_HOME for the child so Python's get_hermes_home()
|
||||
// resolves to the SAME location our resolveHermesHome() picked. Without
|
||||
// this pin, Python falls back to ~/.hermes on every platform — fine on
|
||||
// mac/linux (where our default matches), but on Windows our default is
|
||||
// %LOCALAPPDATA%\hermes, which differs from C:\Users\<u>\.hermes.
|
||||
// Mismatch would split config / sessions / .env / logs across two
|
||||
// directories. install.ps1 sets HERMES_HOME via setx; the desktop
|
||||
// can't reliably do that, so we set it inline for every spawn.
|
||||
HERMES_HOME,
|
||||
...backend.env,
|
||||
TERMINAL_CWD: hermesCwd,
|
||||
HERMES_DASHBOARD_SESSION_TOKEN: token,
|
||||
// Marks this dashboard backend as desktop-spawned so it runs the cron
|
||||
// scheduler tick loop (the gateway isn't running under the app).
|
||||
HERMES_DESKTOP: '1',
|
||||
HERMES_WEB_DIST: webDist
|
||||
},
|
||||
shell: backend.shell,
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
}))
|
||||
hermesProcess = spawn(
|
||||
backend.command,
|
||||
backend.args,
|
||||
hiddenWindowsChildOptions({
|
||||
cwd: hermesCwd,
|
||||
env: {
|
||||
...process.env,
|
||||
// Explicitly pin HERMES_HOME for the child so Python's get_hermes_home()
|
||||
// resolves to the SAME location our resolveHermesHome() picked. Without
|
||||
// this pin, Python falls back to ~/.hermes on every platform — fine on
|
||||
// mac/linux (where our default matches), but on Windows our default is
|
||||
// %LOCALAPPDATA%\hermes, which differs from C:\Users\<u>\.hermes.
|
||||
// Mismatch would split config / sessions / .env / logs across two
|
||||
// directories. install.ps1 sets HERMES_HOME via setx; the desktop
|
||||
// can't reliably do that, so we set it inline for every spawn.
|
||||
HERMES_HOME,
|
||||
...backend.env,
|
||||
TERMINAL_CWD: hermesCwd,
|
||||
HERMES_DASHBOARD_SESSION_TOKEN: token,
|
||||
// Marks this dashboard backend as desktop-spawned so it runs the cron
|
||||
// scheduler tick loop (the gateway isn't running under the app).
|
||||
HERMES_DESKTOP: '1',
|
||||
HERMES_WEB_DIST: webDist
|
||||
},
|
||||
shell: backend.shell,
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
})
|
||||
)
|
||||
|
||||
hermesProcess.stdout.on('data', rememberLog)
|
||||
hermesProcess.stderr.on('data', rememberLog)
|
||||
@@ -4824,7 +4956,6 @@ async function startHermes() {
|
||||
)
|
||||
hermesProcess = null
|
||||
connectionPromise = null
|
||||
portPool.release(port)
|
||||
sendBackendExit({ code: null, signal: null, error: error.message })
|
||||
rejectBackendStart?.(error)
|
||||
})
|
||||
@@ -4832,7 +4963,6 @@ async function startHermes() {
|
||||
rememberLog(`Hermes backend exited (${signal || code})`)
|
||||
hermesProcess = null
|
||||
connectionPromise = null
|
||||
portPool.release(port)
|
||||
sendBackendExit({ code, signal })
|
||||
if (!backendReady) {
|
||||
const message = `Hermes backend exited before it became ready (${signal || code}).`
|
||||
@@ -4853,6 +4983,10 @@ async function startHermes() {
|
||||
}
|
||||
})
|
||||
|
||||
await advanceBootProgress('backend.port', 'Waiting for Hermes backend to launch', 86)
|
||||
// Discover the ephemeral port the child bound to
|
||||
const port = await Promise.race([waitForDashboardPort(hermesProcess), backendStartFailed])
|
||||
|
||||
const baseUrl = `http://127.0.0.1:${port}`
|
||||
await advanceBootProgress('backend.wait', 'Waiting for Hermes backend to become ready', 90)
|
||||
await Promise.race([waitForHermes(baseUrl, token), backendStartFailed])
|
||||
@@ -4892,7 +5026,6 @@ async function startHermes() {
|
||||
{ allowDecrease: true }
|
||||
)
|
||||
connectionPromise = null
|
||||
portPool.release(reservedPort)
|
||||
throw error
|
||||
})
|
||||
|
||||
@@ -4940,21 +5073,29 @@ function focusWindow(win) {
|
||||
}
|
||||
|
||||
// Open (or focus) a standalone window for a single chat session.
|
||||
function createSessionWindow(sessionId) {
|
||||
function createSessionWindow(sessionId, { watch = false } = {}) {
|
||||
return sessionWindows.openOrFocus(sessionId, () => {
|
||||
const icon = getAppIconPath()
|
||||
const win = new BrowserWindow({
|
||||
width: 480,
|
||||
height: 800,
|
||||
minWidth: 420,
|
||||
minHeight: 620,
|
||||
width: SESSION_WINDOW_MIN_WIDTH,
|
||||
height: SESSION_WINDOW_MIN_HEIGHT,
|
||||
minWidth: SESSION_WINDOW_MIN_WIDTH,
|
||||
minHeight: SESSION_WINDOW_MIN_HEIGHT,
|
||||
title: 'Hermes',
|
||||
titleBarStyle: 'hidden',
|
||||
titleBarOverlay: getTitleBarOverlayOptions(),
|
||||
trafficLightPosition: IS_MAC ? WINDOW_BUTTON_POSITION : undefined,
|
||||
vibrancy: IS_MAC ? 'sidebar' : undefined,
|
||||
opacity: windowOpacity(),
|
||||
icon,
|
||||
backgroundColor: '#f7f7f7',
|
||||
// Don't show until the renderer's first themed paint is ready. macOS
|
||||
// `vibrancy` ignores `backgroundColor` and paints a translucent OS
|
||||
// material (which follows the OS appearance, not the app theme), so a
|
||||
// dark-themed app on a light-mode Mac flashes white until the renderer
|
||||
// covers it. ready-to-show fires after the boot-time paint in
|
||||
// themes/context.tsx, so the window appears already themed.
|
||||
show: false,
|
||||
backgroundColor: getWindowBackgroundColor(),
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.cjs'),
|
||||
contextIsolation: true,
|
||||
@@ -4969,6 +5110,10 @@ function createSessionWindow(sessionId) {
|
||||
win.setWindowButtonPosition?.(WINDOW_BUTTON_POSITION)
|
||||
}
|
||||
|
||||
win.once('ready-to-show', () => {
|
||||
if (!win.isDestroyed()) win.show()
|
||||
})
|
||||
|
||||
win.on('will-enter-full-screen', () => sendWindowStateChanged(true))
|
||||
win.on('enter-full-screen', () => sendWindowStateChanged(true))
|
||||
win.on('will-leave-full-screen', () => sendWindowStateChanged(false))
|
||||
@@ -4979,7 +5124,8 @@ function createSessionWindow(sessionId) {
|
||||
win.loadURL(
|
||||
buildSessionWindowUrl(sessionId, {
|
||||
devServer: DEV_SERVER,
|
||||
rendererIndexPath: DEV_SERVER ? undefined : resolveRendererIndex()
|
||||
rendererIndexPath: DEV_SERVER ? undefined : resolveRendererIndex(),
|
||||
watch
|
||||
})
|
||||
)
|
||||
|
||||
@@ -5005,8 +5151,13 @@ function createWindow() {
|
||||
titleBarOverlay: getTitleBarOverlayOptions(),
|
||||
trafficLightPosition: IS_MAC ? WINDOW_BUTTON_POSITION : undefined,
|
||||
vibrancy: IS_MAC ? 'sidebar' : undefined,
|
||||
opacity: windowOpacity(),
|
||||
icon,
|
||||
backgroundColor: '#f7f7f7',
|
||||
// Hidden until the first themed paint so macOS `vibrancy` (which ignores
|
||||
// `backgroundColor` and follows the OS appearance) can't flash a light
|
||||
// material before the renderer paints the app theme. See createSessionWindow.
|
||||
show: false,
|
||||
backgroundColor: getWindowBackgroundColor(),
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.cjs'),
|
||||
contextIsolation: true,
|
||||
@@ -5042,6 +5193,10 @@ function createWindow() {
|
||||
}
|
||||
}
|
||||
|
||||
mainWindow.once('ready-to-show', () => {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) mainWindow.show()
|
||||
})
|
||||
|
||||
mainWindow.on('will-enter-full-screen', () => sendWindowStateChanged(true))
|
||||
mainWindow.on('enter-full-screen', () => sendWindowStateChanged(true))
|
||||
mainWindow.on('will-leave-full-screen', () => sendWindowStateChanged(false))
|
||||
@@ -5153,12 +5308,12 @@ ipcMain.handle('hermes:backend:touch', async (_event, profile) => {
|
||||
return { ok: true }
|
||||
})
|
||||
ipcMain.handle('hermes:gateway:ws-url', async (_event, profile) => freshGatewayWsUrl(profile))
|
||||
ipcMain.handle('hermes:window:openSession', async (_event, sessionId) => {
|
||||
ipcMain.handle('hermes:window:openSession', async (_event, sessionId, opts) => {
|
||||
if (typeof sessionId !== 'string' || !sessionId.trim()) {
|
||||
return { ok: false, error: 'invalid-session-id' }
|
||||
}
|
||||
|
||||
createSessionWindow(sessionId.trim())
|
||||
createSessionWindow(sessionId.trim(), { watch: opts?.watch === true })
|
||||
|
||||
return { ok: true }
|
||||
})
|
||||
@@ -5454,11 +5609,30 @@ ipcMain.handle('hermes:api', async (_event, request) => {
|
||||
|
||||
ipcMain.handle('hermes:notify', (_event, payload) => {
|
||||
if (!Notification.isSupported()) return false
|
||||
new Notification({
|
||||
// Action buttons render only on signed macOS builds; elsewhere they're dropped
|
||||
// and the body click still works.
|
||||
const actions = Array.isArray(payload?.actions) ? payload.actions : []
|
||||
const notification = new Notification({
|
||||
title: payload?.title || 'Hermes',
|
||||
body: payload?.body || '',
|
||||
silent: Boolean(payload?.silent)
|
||||
}).show()
|
||||
silent: Boolean(payload?.silent),
|
||||
actions: actions.map(action => ({ type: 'button', text: String(action?.text || '') }))
|
||||
})
|
||||
notification.on('click', () => {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) return
|
||||
focusWindow(mainWindow)
|
||||
if (payload?.sessionId) {
|
||||
mainWindow.webContents.send('hermes:focus-session', payload.sessionId)
|
||||
}
|
||||
})
|
||||
notification.on('action', (_actionEvent, index) => {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) return
|
||||
const action = actions[index]
|
||||
if (action?.id) {
|
||||
mainWindow.webContents.send('hermes:notification-action', { sessionId: payload?.sessionId, actionId: action.id })
|
||||
}
|
||||
})
|
||||
notification.show()
|
||||
return true
|
||||
})
|
||||
|
||||
@@ -5566,6 +5740,35 @@ ipcMain.on('hermes:titlebar-theme', (_event, payload) => {
|
||||
mainWindow?.setTitleBarOverlay?.(getTitleBarOverlayOptions())
|
||||
})
|
||||
|
||||
// Pin the native appearance to the app theme (see NATIVE_THEME_CONFIG_PATH).
|
||||
ipcMain.on('hermes:native-theme', (_event, mode) => {
|
||||
if (!THEME_SOURCES.has(mode)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (nativeTheme.themeSource !== mode) {
|
||||
nativeTheme.themeSource = mode
|
||||
writePersistedThemeSource(mode)
|
||||
}
|
||||
})
|
||||
|
||||
// See-through window translucency. Persist + re-apply opacity to every open
|
||||
// window at runtime (no recreation, so caching/sessions are untouched).
|
||||
ipcMain.on('hermes:translucency', (_event, payload) => {
|
||||
const next = clampIntensity(payload && payload.intensity)
|
||||
|
||||
if (next === translucencyIntensity) {
|
||||
return
|
||||
}
|
||||
|
||||
translucencyIntensity = next
|
||||
writePersistedTranslucency(next)
|
||||
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
applyWindowTranslucency(win)
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('hermes:openExternal', (_event, url) => {
|
||||
if (!openExternalUrl(url)) {
|
||||
throw new Error('Invalid external URL')
|
||||
@@ -5817,6 +6020,8 @@ ipcMain.handle('hermes:fs:readDir', async (_event, dirPath) => readDirForIpc(dir
|
||||
|
||||
ipcMain.handle('hermes:fs:gitRoot', async (_event, startPath) => gitRootForIpc(startPath))
|
||||
|
||||
ipcMain.handle('hermes:fs:worktrees', async (_event, cwds) => worktreesForIpc(cwds))
|
||||
|
||||
ipcMain.handle('hermes:terminal:start', async (event, payload = {}) => {
|
||||
if (!nodePty) {
|
||||
throw new Error('PTY support is unavailable. Reinstall desktop dependencies and restart Hermes.')
|
||||
@@ -6003,11 +6208,15 @@ async function getUninstallSummary() {
|
||||
resolve(value)
|
||||
}
|
||||
try {
|
||||
const child = spawn(py, ['-m', 'hermes_cli.main', 'uninstall', '--gui-summary'], hiddenWindowsChildOptions({
|
||||
cwd: agentRoot,
|
||||
env: { ...process.env, HERMES_HOME, NO_COLOR: '1' },
|
||||
stdio: ['ignore', 'pipe', 'ignore']
|
||||
}))
|
||||
const child = spawn(
|
||||
py,
|
||||
['-m', 'hermes_cli.main', 'uninstall', '--gui-summary'],
|
||||
hiddenWindowsChildOptions({
|
||||
cwd: agentRoot,
|
||||
env: { ...process.env, HERMES_HOME, NO_COLOR: '1' },
|
||||
stdio: ['ignore', 'pipe', 'ignore']
|
||||
})
|
||||
)
|
||||
child.stdout.on('data', chunk => {
|
||||
stdout += chunk.toString()
|
||||
})
|
||||
@@ -6165,7 +6374,7 @@ let _rendererReadyForDeepLink = false
|
||||
|
||||
function _extractDeepLink(argv) {
|
||||
if (!Array.isArray(argv)) return null
|
||||
return argv.find((a) => typeof a === 'string' && a.startsWith(`${HERMES_PROTOCOL}://`)) || null
|
||||
return argv.find(a => typeof a === 'string' && a.startsWith(`${HERMES_PROTOCOL}://`)) || null
|
||||
}
|
||||
|
||||
function handleDeepLink(url) {
|
||||
@@ -6209,9 +6418,7 @@ ipcMain.handle('hermes:deep-link-ready', () => {
|
||||
_pendingDeepLink = null
|
||||
handleDeepLink(
|
||||
`${HERMES_PROTOCOL}://${queued.kind}/${encodeURIComponent(queued.name)}` +
|
||||
(Object.keys(queued.params).length
|
||||
? '?' + new URLSearchParams(queued.params).toString()
|
||||
: ''),
|
||||
(Object.keys(queued.params).length ? '?' + new URLSearchParams(queued.params).toString() : '')
|
||||
)
|
||||
}
|
||||
return { ok: true }
|
||||
@@ -6222,9 +6429,7 @@ function registerDeepLinkProtocol() {
|
||||
if (process.defaultApp && process.argv.length >= 2) {
|
||||
// Dev: register with the electron exec path + entry script so the OS can
|
||||
// relaunch us with the URL.
|
||||
app.setAsDefaultProtocolClient(HERMES_PROTOCOL, process.execPath, [
|
||||
path.resolve(process.argv[1]),
|
||||
])
|
||||
app.setAsDefaultProtocolClient(HERMES_PROTOCOL, process.execPath, [path.resolve(process.argv[1])])
|
||||
} else {
|
||||
app.setAsDefaultProtocolClient(HERMES_PROTOCOL)
|
||||
}
|
||||
@@ -6257,7 +6462,6 @@ app.on('open-url', (event, url) => {
|
||||
handleDeepLink(url)
|
||||
})
|
||||
|
||||
|
||||
app.whenReady().then(() => {
|
||||
if (IS_MAC) {
|
||||
Menu.setApplicationMenu(buildApplicationMenu())
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
'use strict'
|
||||
|
||||
/**
|
||||
* In-process port reservation pool for the desktop backend launcher.
|
||||
*
|
||||
* pickPort() probes a localhost port with a throwaway server and closes it
|
||||
* before the real bind happens in a separate Python child. Between that probe
|
||||
* and the child's bind there is a TOCTOU window: a second concurrent spawn
|
||||
* (the primary backend racing a pool backend) can be handed the SAME port, and
|
||||
* one then dies with EADDRINUSE ("address already in use" -> "Object has been
|
||||
* destroyed" boot loop). Reserving the chosen port in THIS process until the
|
||||
* child exits closes that window.
|
||||
*
|
||||
* The OS bind remains the source of truth; this only deconflicts racers inside
|
||||
* this process — it can't stop a foreign squatter, which the probe + the
|
||||
* EADDRINUSE self-heal still cover.
|
||||
*
|
||||
* The pool is dependency-injected (the availability probe is passed in) and
|
||||
* free of Electron/Node socket I/O, so it is unit-tested without real sockets
|
||||
* (see port-pool.test.cjs).
|
||||
*/
|
||||
class PortPool {
|
||||
/**
|
||||
* @param {number} floor inclusive lowest port to hand out
|
||||
* @param {number} ceiling inclusive highest port to hand out
|
||||
*/
|
||||
constructor(floor, ceiling) {
|
||||
this.floor = floor
|
||||
this.ceiling = ceiling
|
||||
this._reserved = new Set()
|
||||
}
|
||||
|
||||
/** @returns {boolean} whether `port` is currently reserved in-process. */
|
||||
has(port) {
|
||||
return this._reserved.has(port)
|
||||
}
|
||||
|
||||
/** Release a previously reserved port. No-op if it was not reserved. */
|
||||
release(port) {
|
||||
this._reserved.delete(port)
|
||||
}
|
||||
|
||||
/** Drop all reservations. */
|
||||
clear() {
|
||||
this._reserved.clear()
|
||||
}
|
||||
|
||||
/** @returns {number} count of currently reserved ports. */
|
||||
get size() {
|
||||
return this._reserved.size
|
||||
}
|
||||
|
||||
/**
|
||||
* Reserve and return the lowest port in [floor, ceiling] that is neither
|
||||
* already reserved in-process nor rejected by `isAvailable(port)`, or null
|
||||
* if every port is taken. `isAvailable` may be sync (boolean) or async
|
||||
* (Promise<boolean>); it is awaited either way.
|
||||
*
|
||||
* @param {(port: number) => boolean | Promise<boolean>} isAvailable
|
||||
* @returns {Promise<number|null>}
|
||||
*/
|
||||
async reserve(isAvailable) {
|
||||
for (let port = this.floor; port <= this.ceiling; port += 1) {
|
||||
if (this._reserved.has(port)) continue
|
||||
if (!(await isAvailable(port))) continue
|
||||
this._reserved.add(port)
|
||||
return port
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { PortPool }
|
||||
@@ -1,77 +0,0 @@
|
||||
/**
|
||||
* Tests for electron/port-pool.cjs.
|
||||
*
|
||||
* Run with: node --test electron/port-pool.test.cjs
|
||||
*
|
||||
* PortPool is the in-process reservation that closes the pickPort() TOCTOU
|
||||
* window. These cover selection order, skipping reserved/unavailable ports,
|
||||
* release/reuse, exhaustion, and async probes — without real sockets.
|
||||
*/
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const { PortPool } = require('./port-pool.cjs')
|
||||
|
||||
const allFree = () => true
|
||||
|
||||
test('reserve returns the lowest free port and reserves it', async () => {
|
||||
const pool = new PortPool(9120, 9199)
|
||||
const port = await pool.reserve(allFree)
|
||||
assert.equal(port, 9120)
|
||||
assert.ok(pool.has(9120))
|
||||
assert.equal(pool.size, 1)
|
||||
})
|
||||
|
||||
test('reserve skips ports already reserved in-process', async () => {
|
||||
const pool = new PortPool(9120, 9199)
|
||||
const first = await pool.reserve(allFree)
|
||||
const second = await pool.reserve(allFree)
|
||||
assert.equal(first, 9120)
|
||||
assert.equal(second, 9121)
|
||||
})
|
||||
|
||||
test('reserve skips ports the probe rejects', async () => {
|
||||
const pool = new PortPool(9120, 9199)
|
||||
const busy = new Set([9120, 9121])
|
||||
const port = await pool.reserve(p => !busy.has(p))
|
||||
assert.equal(port, 9122)
|
||||
})
|
||||
|
||||
test('reserve returns null when every port is taken', async () => {
|
||||
const pool = new PortPool(9120, 9121)
|
||||
await pool.reserve(allFree)
|
||||
await pool.reserve(allFree)
|
||||
assert.equal(await pool.reserve(allFree), null)
|
||||
})
|
||||
|
||||
test('release frees a reserved port for reuse', async () => {
|
||||
const pool = new PortPool(9120, 9120)
|
||||
assert.equal(await pool.reserve(allFree), 9120)
|
||||
assert.equal(await pool.reserve(allFree), null) // exhausted
|
||||
pool.release(9120)
|
||||
assert.ok(!pool.has(9120))
|
||||
assert.equal(await pool.reserve(allFree), 9120) // reusable
|
||||
})
|
||||
|
||||
test('release is a no-op for an unreserved port', () => {
|
||||
const pool = new PortPool(9120, 9199)
|
||||
pool.release(9120)
|
||||
assert.equal(pool.size, 0)
|
||||
})
|
||||
|
||||
test('reserve awaits an async probe', async () => {
|
||||
const pool = new PortPool(9120, 9199)
|
||||
const busy = new Set([9120])
|
||||
const port = await pool.reserve(p => Promise.resolve(!busy.has(p)))
|
||||
assert.equal(port, 9121)
|
||||
})
|
||||
|
||||
test('clear drops all reservations', async () => {
|
||||
const pool = new PortPool(9120, 9199)
|
||||
await pool.reserve(allFree)
|
||||
await pool.reserve(allFree)
|
||||
assert.equal(pool.size, 2)
|
||||
pool.clear()
|
||||
assert.equal(pool.size, 0)
|
||||
})
|
||||
@@ -5,7 +5,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
||||
revalidateConnection: () => ipcRenderer.invoke('hermes:connection:revalidate'),
|
||||
touchBackend: profile => ipcRenderer.invoke('hermes:backend:touch', profile),
|
||||
getGatewayWsUrl: profile => ipcRenderer.invoke('hermes:gateway:ws-url', profile),
|
||||
openSessionWindow: sessionId => ipcRenderer.invoke('hermes:window:openSession', sessionId),
|
||||
openSessionWindow: (sessionId, opts) => ipcRenderer.invoke('hermes:window:openSession', sessionId, opts),
|
||||
getBootProgress: () => ipcRenderer.invoke('hermes:boot-progress:get'),
|
||||
getConnectionConfig: profile => ipcRenderer.invoke('hermes:connection-config:get', profile),
|
||||
saveConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:save', payload),
|
||||
@@ -39,6 +39,8 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
||||
watchPreviewFile: url => ipcRenderer.invoke('hermes:watchPreviewFile', url),
|
||||
stopPreviewFileWatch: id => ipcRenderer.invoke('hermes:stopPreviewFileWatch', id),
|
||||
setTitleBarTheme: payload => ipcRenderer.send('hermes:titlebar-theme', payload),
|
||||
setNativeTheme: mode => ipcRenderer.send('hermes:native-theme', mode),
|
||||
setTranslucency: payload => ipcRenderer.send('hermes:translucency', payload),
|
||||
setPreviewShortcutActive: active => ipcRenderer.send('hermes:previewShortcutActive', Boolean(active)),
|
||||
openExternal: url => ipcRenderer.invoke('hermes:openExternal', url),
|
||||
fetchLinkTitle: url => ipcRenderer.invoke('hermes:fetchLinkTitle', url),
|
||||
@@ -52,6 +54,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
||||
getRecentLogs: () => ipcRenderer.invoke('hermes:logs:recent'),
|
||||
readDir: dirPath => ipcRenderer.invoke('hermes:fs:readDir', dirPath),
|
||||
gitRoot: startPath => ipcRenderer.invoke('hermes:fs:gitRoot', startPath),
|
||||
worktrees: cwds => ipcRenderer.invoke('hermes:fs:worktrees', cwds),
|
||||
terminal: {
|
||||
dispose: id => ipcRenderer.invoke('hermes:terminal:dispose', id),
|
||||
resize: (id, size) => ipcRenderer.invoke('hermes:terminal:resize', id, size),
|
||||
@@ -91,6 +94,16 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
||||
ipcRenderer.on('hermes:window-state-changed', listener)
|
||||
return () => ipcRenderer.removeListener('hermes:window-state-changed', listener)
|
||||
},
|
||||
onFocusSession: callback => {
|
||||
const listener = (_event, sessionId) => callback(sessionId)
|
||||
ipcRenderer.on('hermes:focus-session', listener)
|
||||
return () => ipcRenderer.removeListener('hermes:focus-session', listener)
|
||||
},
|
||||
onNotificationAction: callback => {
|
||||
const listener = (_event, payload) => callback(payload)
|
||||
ipcRenderer.on('hermes:notification-action', listener)
|
||||
return () => ipcRenderer.removeListener('hermes:notification-action', listener)
|
||||
},
|
||||
onPreviewFileChanged: callback => {
|
||||
const listener = (_event, payload) => callback(payload)
|
||||
ipcRenderer.on('hermes:preview-file-changed', listener)
|
||||
|
||||
@@ -5,22 +5,30 @@
|
||||
|
||||
const { pathToFileURL } = require('node:url')
|
||||
|
||||
// Secondary windows open at the minimum usable size — a compact side panel for
|
||||
// subagent watch / cmd-click session pop-out, not a second full desktop.
|
||||
const SESSION_WINDOW_MIN_WIDTH = 420
|
||||
const SESSION_WINDOW_MIN_HEIGHT = 620
|
||||
|
||||
// Build the renderer URL for a secondary window. The renderer uses a
|
||||
// HashRouter, so the session route lives after the '#'. The `?win=secondary`
|
||||
// flag MUST sit in the query string BEFORE the '#': anything after the '#' is
|
||||
// treated as the route by HashRouter and would break routeSessionId(). The
|
||||
// renderer reads the flag from window.location.search to suppress the install /
|
||||
// onboarding overlays and the global session sidebar.
|
||||
function buildSessionWindowUrl(sessionId, { devServer, rendererIndexPath } = {}) {
|
||||
// onboarding overlays and the global session sidebar. `watch=1` marks a
|
||||
// spectator window (e.g. a running subagent's session): the renderer resumes
|
||||
// it lazily so the gateway never builds an agent just to stream into it.
|
||||
function buildSessionWindowUrl(sessionId, { devServer, rendererIndexPath, watch } = {}) {
|
||||
const query = `?win=secondary${watch ? '&watch=1' : ''}`
|
||||
const route = `#/${encodeURIComponent(sessionId)}`
|
||||
|
||||
if (devServer) {
|
||||
const base = devServer.endsWith('/') ? devServer.slice(0, -1) : devServer
|
||||
|
||||
return `${base}/?win=secondary${route}`
|
||||
return `${base}/${query}${route}`
|
||||
}
|
||||
|
||||
return `${pathToFileURL(rendererIndexPath).toString()}?win=secondary${route}`
|
||||
return `${pathToFileURL(rendererIndexPath).toString()}${query}${route}`
|
||||
}
|
||||
|
||||
// A small registry keyed by sessionId that guarantees one window per chat:
|
||||
@@ -83,4 +91,9 @@ function createSessionWindowRegistry() {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { buildSessionWindowUrl, createSessionWindowRegistry }
|
||||
module.exports = {
|
||||
buildSessionWindowUrl,
|
||||
createSessionWindowRegistry,
|
||||
SESSION_WINDOW_MIN_HEIGHT,
|
||||
SESSION_WINDOW_MIN_WIDTH
|
||||
}
|
||||
|
||||
@@ -76,6 +76,12 @@ test('buildSessionWindowUrl builds a packaged file URL with the flag before the
|
||||
assert.match(url, /^file:\/\/.*index\.html\?win=secondary#\/abc$/)
|
||||
})
|
||||
|
||||
test('buildSessionWindowUrl adds the watch flag for spectator windows, before the hash', () => {
|
||||
const url = buildSessionWindowUrl('abc', { devServer: 'http://localhost:5173', watch: true })
|
||||
|
||||
assert.equal(url, 'http://localhost:5173/?win=secondary&watch=1#/abc')
|
||||
})
|
||||
|
||||
test('registry opens one window per session and focuses on re-open', () => {
|
||||
const registry = createSessionWindowRegistry()
|
||||
let built = 0
|
||||
|
||||
@@ -42,6 +42,9 @@ test('intentional or interactive desktop child processes stay documented', () =>
|
||||
const source = readElectronFile('main.cjs')
|
||||
|
||||
assert.match(source, /windowsHide: false/)
|
||||
assert.match(source, /handOffWindowsBootstrapRecovery/)
|
||||
assert.match(source, /'--repair', '--branch'/)
|
||||
assert.match(source, /'--update', '--branch'/)
|
||||
assert.match(source, /nodePty\.spawn\(command, args/)
|
||||
assert.match(source, /spawn\('cmd\.exe', \['\/c', 'start'/)
|
||||
})
|
||||
|
||||
@@ -9,6 +9,28 @@
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<link rel="shortcut icon" href="/apple-touch-icon.png" />
|
||||
<title>Hermes</title>
|
||||
<script>
|
||||
// Pre-paint the themed background before the app bundle loads. Without
|
||||
// this, the first frame (which is what `ready-to-show` waits for) is the
|
||||
// UA-default white page, and the real theme only lands once the whole
|
||||
// module graph has executed — i.e. the "white flash" on every new
|
||||
// window. applyTheme() in src/themes/context.tsx keeps these keys fresh.
|
||||
try {
|
||||
let bg = localStorage.getItem('hermes-boot-background')
|
||||
let scheme = localStorage.getItem('hermes-boot-color-scheme')
|
||||
if (!bg) {
|
||||
const dark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
bg = dark ? '#111111' : '#f7f7f7'
|
||||
scheme = dark ? 'dark' : 'light'
|
||||
}
|
||||
document.documentElement.style.backgroundColor = bg
|
||||
if (scheme === 'dark' || scheme === 'light') {
|
||||
document.documentElement.style.colorScheme = scheme
|
||||
}
|
||||
} catch {
|
||||
// localStorage unavailable — keep UA defaults.
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root" class="scrollbar-dt"></div>
|
||||
|
||||
@@ -36,7 +36,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 electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/port-pool.test.cjs electron/session-windows.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs",
|
||||
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs",
|
||||
"typecheck": "tsc -p . --noEmit",
|
||||
"lint": "eslint src/ electron/",
|
||||
"lint:fix": "eslint src/ electron/ --fix",
|
||||
@@ -90,6 +90,7 @@
|
||||
"react-router-dom": "^7.17.0",
|
||||
"react-shiki": "^0.9.3",
|
||||
"remark-math": "^6.0.0",
|
||||
"remend": "^1.3.0",
|
||||
"shiki": "^4.0.2",
|
||||
"streamdown": "^2.5.0",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
@@ -98,6 +99,7 @@
|
||||
"unicode-animations": "^1.0.3",
|
||||
"unified": "^11.0.5",
|
||||
"unist-util-visit-parents": "^6.0.2",
|
||||
"use-stick-to-bottom": "^1.1.6",
|
||||
"vfile": "^6.0.3",
|
||||
"web-haptics": "^0.0.6"
|
||||
},
|
||||
|
||||
@@ -3,8 +3,8 @@ import { type ReactNode, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { useElapsedSeconds } from '@/components/chat/activity-timer'
|
||||
import { ActivityTimerText } from '@/components/chat/activity-timer-text'
|
||||
import { BrailleSpinner } from '@/components/ui/braille-spinner'
|
||||
import { FadeText } from '@/components/ui/fade-text'
|
||||
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
|
||||
import { type Translations, useI18n } from '@/i18n'
|
||||
import { AlertCircle, CheckCircle2, Sparkles } from '@/lib/icons'
|
||||
import { useEnterAnimation } from '@/lib/use-enter-animation'
|
||||
@@ -25,7 +25,7 @@ import { OverlayView } from '../overlays/overlay-view'
|
||||
function statusGlyph(status: SubagentStatus, a: Translations['agents']): ReactNode {
|
||||
if (status === 'running' || status === 'queued') {
|
||||
return (
|
||||
<BrailleSpinner
|
||||
<GlyphSpinner
|
||||
ariaLabel={a.running}
|
||||
className="size-3.5 shrink-0 text-[0.95rem] text-muted-foreground/80"
|
||||
spinner="breathe"
|
||||
@@ -290,7 +290,7 @@ function StreamLine({
|
||||
<span className={cn('min-w-0 flex-1 wrap-anywhere', tone, isMono && 'font-mono text-[0.69rem]')}>
|
||||
{entry.text}
|
||||
{active ? (
|
||||
<BrailleSpinner
|
||||
<GlyphSpinner
|
||||
ariaLabel={t.agents.streaming}
|
||||
className="ml-1 inline-block size-2.5 align-middle text-muted-foreground/70"
|
||||
spinner="breathe"
|
||||
@@ -372,7 +372,9 @@ function SubagentRow({ node, depth = 0, nowMs }: { node: SubagentNode; depth?: n
|
||||
|
||||
{open && fileLines.length > 0 ? (
|
||||
<div className="grid min-w-0 gap-0.5 pl-6">
|
||||
<p className="text-[0.58rem] font-medium tracking-wider text-muted-foreground/60 uppercase">{t.agents.files}</p>
|
||||
<p className="text-[0.58rem] font-medium tracking-wider text-muted-foreground/60 uppercase">
|
||||
{t.agents.files}
|
||||
</p>
|
||||
{fileLines.slice(0, 8).map(line => (
|
||||
<p className="wrap-break-word font-mono text-[0.67rem] leading-relaxed text-muted-foreground/80" key={line}>
|
||||
{line}
|
||||
|
||||
@@ -2,25 +2,21 @@ import type { Unstable_TriggerAdapter } from '@assistant-ui/core'
|
||||
import { ComposerPrimitive } from '@assistant-ui/react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
export const COMPLETION_DRAWER_CLASS = [
|
||||
'absolute bottom-[calc(100%+0.375rem)] left-0 z-50',
|
||||
'w-80 max-w-[calc(100vw-2rem)]',
|
||||
'max-h-[min(22rem,calc(100vh-8rem))] overflow-y-auto overscroll-contain',
|
||||
'rounded-xl border border-(--ui-stroke-secondary)',
|
||||
'bg-[color-mix(in_srgb,var(--ui-bg-elevated)_97%,transparent)]',
|
||||
'p-1 text-xs text-popover-foreground shadow-lg',
|
||||
'backdrop-blur-md'
|
||||
].join(' ')
|
||||
import { composerFusedDockCard } from '@/components/chat/composer-dock'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export const COMPLETION_DRAWER_BELOW_CLASS = [
|
||||
'absolute left-0 top-[calc(100%+0.375rem)] z-50',
|
||||
'w-80 max-w-[calc(100vw-2rem)]',
|
||||
'max-h-[min(22rem,calc(100vh-8rem))] overflow-y-auto overscroll-contain',
|
||||
'rounded-xl border border-(--ui-stroke-secondary)',
|
||||
'bg-[color-mix(in_srgb,var(--ui-bg-elevated)_97%,transparent)]',
|
||||
'p-1 text-xs text-popover-foreground shadow-lg',
|
||||
'backdrop-blur-md'
|
||||
].join(' ')
|
||||
// Same docked chrome as the queue/status stack, but its own thing: a narrow,
|
||||
// left-aligned card (not full width) that fuses to the composer's edge instead
|
||||
// of floating above it. `left-1` matches the stack's `mx-1` inset; the negative
|
||||
// margin overlaps the seam so the composer's (now-transparent) edge border reads
|
||||
// as shared. Fused (opaque) fill — the composer surface swaps to the same fill
|
||||
// while a drawer is open, so the two paint as one panel.
|
||||
const DRAWER_SHELL =
|
||||
'absolute left-1 z-50 w-80 max-w-[calc(100%-0.5rem)] max-h-[min(22rem,calc(100vh-8rem))] overflow-y-auto overscroll-contain p-1 text-xs text-popover-foreground'
|
||||
|
||||
export const COMPLETION_DRAWER_CLASS = cn(DRAWER_SHELL, 'bottom-full -mb-[9px]', composerFusedDockCard('top'))
|
||||
|
||||
export const COMPLETION_DRAWER_BELOW_CLASS = cn(DRAWER_SHELL, 'top-full -mt-[9px]', composerFusedDockCard('bottom'))
|
||||
|
||||
export function ComposerCompletionDrawer({
|
||||
adapter,
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Kbd } from '@/components/ui/kbd'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { Clipboard, FileText, FolderOpen, type IconComponent, ImageIcon, Link, MessageSquareText } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -86,7 +87,7 @@ export function ContextMenu({
|
||||
|
||||
<div className="px-2 py-1 text-[0.7rem] text-muted-foreground/80">
|
||||
{c.tipPre}
|
||||
<kbd className="rounded bg-muted/70 px-1 py-px font-mono text-[0.65rem]">@</kbd>
|
||||
<Kbd size="sm">@</Kbd>
|
||||
{c.tipPost}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { KbdCombo } from '@/components/ui/kbd'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
@@ -63,7 +64,14 @@ export function ComposerControls({
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const c = t.composer
|
||||
const steerLabel = `${c.steer} (${formatCombo('mod+enter')})`
|
||||
const steerCombo = formatCombo('mod+enter')
|
||||
const steerLabel = `${c.steer} (${steerCombo})`
|
||||
const steerTip = (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{c.steer}
|
||||
<KbdCombo combo="mod+enter" size="sm" variant="inverted" />
|
||||
</span>
|
||||
)
|
||||
|
||||
if (conversation.active) {
|
||||
return <ConversationPill {...conversation} disabled={disabled} />
|
||||
@@ -75,7 +83,7 @@ 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} />
|
||||
{canSteer && (
|
||||
<Tip label={steerLabel}>
|
||||
<Tip label={steerTip}>
|
||||
<Button
|
||||
aria-label={steerLabel}
|
||||
className={GHOST_ICON_BTN}
|
||||
|
||||
@@ -24,6 +24,7 @@ afterEach(cleanup)
|
||||
// state stays stale while the DOM already holds the text.
|
||||
function Harness({
|
||||
busy = false,
|
||||
disabled = false,
|
||||
queued = [],
|
||||
onSubmit,
|
||||
onQueue,
|
||||
@@ -31,6 +32,7 @@ function Harness({
|
||||
onDrain
|
||||
}: {
|
||||
busy?: boolean
|
||||
disabled?: boolean
|
||||
queued?: readonly string[]
|
||||
onSubmit: (text: string) => void
|
||||
onQueue: (text: string) => void
|
||||
@@ -52,6 +54,10 @@ function Harness({
|
||||
}
|
||||
|
||||
const submitDraft = () => {
|
||||
if (disabled) {
|
||||
return
|
||||
}
|
||||
|
||||
const editor = editorRef.current
|
||||
if (editor) {
|
||||
const domText = composerPlainText(editor)
|
||||
@@ -84,6 +90,10 @@ function Harness({
|
||||
const editorText = editorRef.current ? composerPlainText(editorRef.current) : draftRef.current
|
||||
const hasLivePayload = editorText.trim().length > 0 || attachments.length > 0
|
||||
|
||||
if (disabled) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!busy && !hasLivePayload && queued.length > 0) {
|
||||
onDrain()
|
||||
|
||||
@@ -186,4 +196,23 @@ describe('composer Enter submit — live DOM vs stale composer state (#39630)',
|
||||
expect(onDrain).toHaveBeenCalledTimes(1)
|
||||
expect(onSubmit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps reconnect drafts editable but blocks Enter submit until the gateway returns', async () => {
|
||||
const onSubmit = vi.fn()
|
||||
const onDrain = vi.fn()
|
||||
const { getByTestId } = render(
|
||||
<Harness disabled onCancel={vi.fn()} onDrain={onDrain} onQueue={vi.fn()} onSubmit={onSubmit} queued={['queued-1']} />
|
||||
)
|
||||
const editor = getByTestId('editor')
|
||||
|
||||
await act(async () => {
|
||||
editor.textContent = 'draft while reconnecting'
|
||||
fireEvent.input(editor)
|
||||
fireEvent.keyDown(editor, { key: 'Enter' })
|
||||
})
|
||||
|
||||
expect(editor.textContent).toBe('draft while reconnecting')
|
||||
expect(onDrain).not.toHaveBeenCalled()
|
||||
expect(onSubmit).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
* steal focus from the composer effect.
|
||||
*/
|
||||
|
||||
import { RICH_INPUT_SLOT } from './rich-editor'
|
||||
import type { InlineRefInput } from './inline-refs'
|
||||
|
||||
export type ComposerTarget = 'edit' | 'main'
|
||||
@@ -123,3 +124,12 @@ export const focusComposerInput = (el: HTMLElement | null) => {
|
||||
window.requestAnimationFrame(focus)
|
||||
window.setTimeout(focus, 0)
|
||||
}
|
||||
|
||||
/** Drop focus from the main composer input (status-stack chrome, sidebar, etc.). */
|
||||
export const blurComposerInput = () => {
|
||||
const el = document.querySelector(`[data-slot="${RICH_INPUT_SLOT}"]`) as HTMLElement | null
|
||||
|
||||
if (el && document.activeElement === el) {
|
||||
el.blur()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { KbdCombo } from '@/components/ui/kbd'
|
||||
import { useI18n } from '@/i18n'
|
||||
|
||||
import { COMPLETION_DRAWER_CLASS } from './completion-drawer'
|
||||
|
||||
const COMMON_COMMAND_KEYS = ['/help', '/clear', '/resume', '/details', '/copy', '/quit']
|
||||
const HOTKEY_KEYS = ['@', '/', '?', 'Enter', 'Cmd/Ctrl+Shift+K', 'Cmd/Ctrl+/', 'Esc', '↑ / ↓']
|
||||
|
||||
/** Stable ids → i18n `hotkeyDescs` keys. Combos resolve mod labels per OS. */
|
||||
const COMPOSER_HOTKEY_ROWS = [
|
||||
{ id: 'composer.mention', combos: ['@'] },
|
||||
{ id: 'composer.slash', combos: ['/'] },
|
||||
{ id: 'composer.help', combos: ['?'] },
|
||||
{ id: 'composer.sendNewline', combos: ['enter', 'shift+enter'] },
|
||||
{ id: 'composer.sendQueued', combos: ['mod+shift+k'] },
|
||||
{ id: 'keybinds.openPanel', combos: ['mod+/'] },
|
||||
{ id: 'composer.cancel', combos: ['escape'] },
|
||||
{ id: 'composer.history', combos: ['up', 'down'] }
|
||||
] as const
|
||||
|
||||
export function HelpHint() {
|
||||
const { t } = useI18n()
|
||||
@@ -20,8 +32,8 @@ export function HelpHint() {
|
||||
</Section>
|
||||
|
||||
<Section title={c.hotkeys}>
|
||||
{HOTKEY_KEYS.map(key => (
|
||||
<Row description={c.hotkeyDescs[key] ?? ''} key={key} keyLabel={key} />
|
||||
{COMPOSER_HOTKEY_ROWS.map(row => (
|
||||
<HotkeyRow description={c.hotkeyDescs[row.id] ?? ''} combos={[...row.combos]} key={row.id} />
|
||||
))}
|
||||
</Section>
|
||||
|
||||
@@ -57,3 +69,16 @@ function Row({ description, keyLabel, mono = false }: { description: string; key
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function HotkeyRow({ combos, description }: { combos: string[]; description: string }) {
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-2 rounded-md px-2.5 py-1 text-xs">
|
||||
<span className="flex shrink-0 items-center gap-1">
|
||||
{combos.map(combo => (
|
||||
<KbdCombo combo={combo} key={combo} size="sm" />
|
||||
))}
|
||||
</span>
|
||||
<span className="min-w-0 truncate text-muted-foreground/80">{description}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from 'react'
|
||||
|
||||
import { hermesDirectiveFormatter, type SlashChipKind } from '@/components/assistant-ui/directive-text'
|
||||
import { composerFill, composerSurfaceGlass } from '@/components/chat/composer-dock'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useMediaQuery } from '@/hooks/use-media-query'
|
||||
import { useResizeObserver } from '@/hooks/use-resize-observer'
|
||||
@@ -42,12 +43,16 @@ import {
|
||||
import {
|
||||
$queuedPromptsBySession,
|
||||
enqueueQueuedPrompt,
|
||||
MAX_AUTO_DRAIN_ATTEMPTS,
|
||||
migrateQueuedPrompts,
|
||||
promoteQueuedPrompt,
|
||||
type QueuedPromptEntry,
|
||||
removeQueuedPrompt,
|
||||
shouldAutoDrainOnSettle,
|
||||
shouldAutoDrain,
|
||||
updateQueuedPrompt
|
||||
} from '@/store/composer-queue'
|
||||
import { $statusItemsBySession } from '@/store/composer-status'
|
||||
import { notify } from '@/store/notifications'
|
||||
import { $gatewayState, $messages, setSessionPickerOpen } from '@/store/session'
|
||||
import { $threadScrolledUp } from '@/store/thread-scroll'
|
||||
import { useTheme } from '@/themes'
|
||||
@@ -80,12 +85,16 @@ import {
|
||||
import { QueuePanel } from './queue-panel'
|
||||
import {
|
||||
composerPlainText,
|
||||
deleteSelectionInEditor,
|
||||
insertPlainTextAtCaret,
|
||||
normalizeComposerEditorDom,
|
||||
placeCaretEnd,
|
||||
refChipElement,
|
||||
renderComposerContents,
|
||||
RICH_INPUT_SLOT,
|
||||
slashChipElement
|
||||
} from './rich-editor'
|
||||
import { ComposerStatusStack } from './status-stack'
|
||||
import { detectTrigger, extractClipboardImageBlobs, textBeforeCaret, type TriggerState } from './text-utils'
|
||||
import { ComposerTriggerPopover } from './trigger-popover'
|
||||
import type { ChatBarProps } from './types'
|
||||
@@ -128,6 +137,12 @@ function slashChipKindForItem(item: Unstable_TriggerItem): SlashChipKind {
|
||||
return 'command'
|
||||
}
|
||||
|
||||
/** A `/` query is at its arg stage once it's past the command name. */
|
||||
const slashArgStage = (query: string) => query.includes(' ')
|
||||
|
||||
/** The `/command` token of a slash query (`personality x` → `/personality`). */
|
||||
const slashCommandToken = (query: string) => `/${query.split(/\s+/, 1)[0]?.toLowerCase() ?? ''}`
|
||||
|
||||
interface QueueEditState {
|
||||
attachments: ComposerAttachment[]
|
||||
draft: string
|
||||
@@ -168,8 +183,8 @@ export function ChatBar({
|
||||
const draft = useAuiState(s => s.composer.text)
|
||||
const attachments = useStore($composerAttachments)
|
||||
const queuedPromptsBySession = useStore($queuedPromptsBySession)
|
||||
const statusItemsBySession = useStore($statusItemsBySession)
|
||||
const scrolledUp = useStore($threadScrolledUp)
|
||||
const sessionMessages = useStore($messages)
|
||||
const activeQueueSessionKey = queueSessionKey || sessionId || null
|
||||
|
||||
const queuedPrompts = useMemo(
|
||||
@@ -177,15 +192,29 @@ export function ChatBar({
|
||||
[activeQueueSessionKey, queuedPromptsBySession]
|
||||
)
|
||||
|
||||
// Status items (subagents, background processes) are keyed by the RUNTIME
|
||||
// session id — gateway events and process.list both speak that id. Only the
|
||||
// queue uses the stored-session fallback key (prompts can queue pre-resume).
|
||||
const statusSessionId = sessionId ?? null
|
||||
|
||||
const statusStackVisible = useMemo(
|
||||
() =>
|
||||
queuedPrompts.length > 0 || (statusSessionId ? (statusItemsBySession[statusSessionId]?.length ?? 0) > 0 : false),
|
||||
[queuedPrompts.length, statusItemsBySession, statusSessionId]
|
||||
)
|
||||
|
||||
const composerRef = useRef<HTMLFormElement | null>(null)
|
||||
const composerSurfaceRef = useRef<HTMLDivElement | null>(null)
|
||||
const editorRef = useRef<HTMLDivElement | null>(null)
|
||||
const draftRef = useRef(draft)
|
||||
const previousBusyRef = useRef(busy)
|
||||
const pendingDraftPersistRef = useRef<{ scope: string | null; text: string } | null>(null)
|
||||
const activeQueueSessionKeyRef = useRef(activeQueueSessionKey)
|
||||
activeQueueSessionKeyRef.current = activeQueueSessionKey
|
||||
const prevQueueKeyRef = useRef(activeQueueSessionKey)
|
||||
const drainingQueueRef = useRef(false)
|
||||
// Per-entry auto-drain failure counts; bounds retries so a persistent 404
|
||||
// can't spin-loop. Cleared on success; reset naturally on remount/reconnect.
|
||||
const drainFailuresRef = useRef(new Map<string, number>())
|
||||
const urlInputRef = useRef<HTMLInputElement | null>(null)
|
||||
|
||||
const [urlOpen, setUrlOpen] = useState(false)
|
||||
@@ -226,6 +255,8 @@ export function ChatBar({
|
||||
const gatewayState = useStore($gatewayState)
|
||||
const newSessionPlaceholders = t.composer.newSessionPlaceholders
|
||||
const followUpPlaceholders = t.composer.followUpPlaceholders
|
||||
const reconnecting = gatewayState === 'closed' || gatewayState === 'error'
|
||||
const inputDisabled = disabled && !reconnecting
|
||||
|
||||
// Resting placeholder: a starter for brand-new sessions, a continuation for
|
||||
// existing ones. Picked once and only re-rolled when we genuinely move to a
|
||||
@@ -256,11 +287,13 @@ export function ChatBar({
|
||||
setRestingPlaceholder(pickPlaceholder(sessionId ? followUpPlaceholders : newSessionPlaceholders))
|
||||
}, [followUpPlaceholders, newSessionPlaceholders, sessionId])
|
||||
|
||||
// When the bar is disabled it's because the gateway isn't open. Distinguish a
|
||||
// cold start ("Starting Hermes...") from a dropped connection we're trying to
|
||||
// restore (e.g. after the Mac slept) so the stuck state reads as recoverable.
|
||||
// When the transport is disabled it's because the gateway isn't open.
|
||||
// Distinguish a cold start ("Starting Hermes...") from a dropped connection
|
||||
// we're trying to restore. During reconnect, keep the textbox editable so a
|
||||
// flaky network doesn't block drafting; only submit/backend actions stay
|
||||
// disabled until the gateway is open again.
|
||||
const placeholder = disabled
|
||||
? gatewayState === 'closed' || gatewayState === 'error'
|
||||
? reconnecting
|
||||
? t.composer.placeholderReconnecting
|
||||
: t.composer.placeholderStarting
|
||||
: restingPlaceholder
|
||||
@@ -302,13 +335,13 @@ export function ChatBar({
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!disabled) {
|
||||
if (!inputDisabled) {
|
||||
focusInput()
|
||||
}
|
||||
}, [disabled, focusInput, focusKey, focusRequestId])
|
||||
}, [focusInput, focusKey, focusRequestId, inputDisabled])
|
||||
|
||||
useEffect(() => {
|
||||
if (disabled) {
|
||||
if (inputDisabled) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -328,7 +361,7 @@ export function ChatBar({
|
||||
offFocus()
|
||||
offInsert()
|
||||
}
|
||||
}, [appendExternalText, disabled])
|
||||
}, [appendExternalText, inputDisabled])
|
||||
|
||||
// Keep draftRef in sync with the assistant-ui composer state for callers
|
||||
// that read the latest text outside the React render cycle. We don't push
|
||||
@@ -507,48 +540,6 @@ export function ChatBar({
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handlePaste = (event: ClipboardEvent<HTMLDivElement>) => {
|
||||
const imageBlobs = extractClipboardImageBlobs(event.clipboardData)
|
||||
|
||||
if (imageBlobs.length > 0) {
|
||||
event.preventDefault()
|
||||
|
||||
if (onAttachImageBlob) {
|
||||
triggerHaptic('selection')
|
||||
|
||||
for (const blob of imageBlobs) {
|
||||
void onAttachImageBlob(blob)
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Trim surrounding whitespace so a copy that dragged along leading/trailing
|
||||
// blank lines (common when selecting from terminals, code blocks, web pages)
|
||||
// doesn't dump multiline padding into the composer. Internal newlines are
|
||||
// preserved — only the edges are cleaned up.
|
||||
const pastedText = event.clipboardData.getData('text').trim()
|
||||
|
||||
if (!pastedText) {
|
||||
event.preventDefault()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (DATA_IMAGE_URL_RE.test(pastedText)) {
|
||||
event.preventDefault()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
document.execCommand('insertText', false, pastedText)
|
||||
const nextDraft = composerPlainText(event.currentTarget)
|
||||
draftRef.current = nextDraft
|
||||
aui.composer().setText(nextDraft)
|
||||
}
|
||||
|
||||
const [trigger, setTrigger] = useState<TriggerState | null>(null)
|
||||
const [triggerActive, setTriggerActive] = useState(0)
|
||||
const [triggerItems, setTriggerItems] = useState<readonly Unstable_TriggerItem[]>([])
|
||||
@@ -585,7 +576,15 @@ export function ChatBar({
|
||||
}
|
||||
|
||||
const before = textBeforeCaret(editor)
|
||||
const detected = detectTrigger(before ?? composerPlainText(editor))
|
||||
const found = detectTrigger(before ?? composerPlainText(editor))
|
||||
|
||||
// The arg-stage popover is only useful for commands with an options screen.
|
||||
// For a no-arg command it would dead-end on "No matches", so drop it — the
|
||||
// directive is already complete.
|
||||
const detected =
|
||||
found?.kind === '/' && slashArgStage(found.query) && !desktopSlashCommandTakesArgs(slashCommandToken(found.query))
|
||||
? null
|
||||
: found
|
||||
|
||||
setTrigger(detected)
|
||||
|
||||
@@ -602,9 +601,7 @@ export function ChatBar({
|
||||
// (which drives `hasComposerPayload` → the send button). Shared by the input
|
||||
// and compositionend paths so committed IME text reaches state through either.
|
||||
const flushEditorToDraft = (editor: HTMLDivElement) => {
|
||||
if (editor.childNodes.length === 1 && editor.firstChild?.nodeName === 'BR') {
|
||||
editor.replaceChildren()
|
||||
}
|
||||
normalizeComposerEditorDom(editor)
|
||||
|
||||
const nextDraft = composerPlainText(editor)
|
||||
|
||||
@@ -627,6 +624,46 @@ export function ChatBar({
|
||||
flushEditorToDraft(event.currentTarget)
|
||||
}
|
||||
|
||||
const handlePaste = (event: ClipboardEvent<HTMLDivElement>) => {
|
||||
const imageBlobs = extractClipboardImageBlobs(event.clipboardData)
|
||||
|
||||
if (imageBlobs.length > 0) {
|
||||
event.preventDefault()
|
||||
|
||||
if (onAttachImageBlob) {
|
||||
triggerHaptic('selection')
|
||||
|
||||
for (const blob of imageBlobs) {
|
||||
void onAttachImageBlob(blob)
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Trim surrounding whitespace so a copy that dragged along leading/trailing
|
||||
// blank lines (common when selecting from terminals, code blocks, web pages)
|
||||
// doesn't dump multiline padding into the composer. Internal newlines are
|
||||
// preserved — only the edges are cleaned up.
|
||||
const pastedText = event.clipboardData.getData('text').trim()
|
||||
|
||||
if (!pastedText) {
|
||||
event.preventDefault()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (DATA_IMAGE_URL_RE.test(pastedText)) {
|
||||
event.preventDefault()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
insertPlainTextAtCaret(event.currentTarget, pastedText)
|
||||
flushEditorToDraft(event.currentTarget)
|
||||
}
|
||||
|
||||
const triggerAdapter: Unstable_TriggerAdapter | null =
|
||||
trigger?.kind === '@' ? at.adapter : trigger?.kind === '/' ? slash.adapter : null
|
||||
|
||||
@@ -642,6 +679,12 @@ export function ChatBar({
|
||||
|
||||
const triggerLoading = trigger?.kind === '@' ? at.loading : trigger?.kind === '/' ? slash.loading : false
|
||||
|
||||
// Suppress the "No matches" empty state once a slash command is past its name:
|
||||
// a no-arg command has nothing to offer, and a fully-typed arg commits on
|
||||
// Space/Tab — neither should dead-end on a popover.
|
||||
const argStageEmpty =
|
||||
trigger?.kind === '/' && slashArgStage(trigger.query) && !triggerLoading && !triggerItems.length
|
||||
|
||||
const closeTrigger = () => {
|
||||
setTrigger(null)
|
||||
setTriggerItems([])
|
||||
@@ -652,6 +695,25 @@ export function ChatBar({
|
||||
setTriggerActive(idx => Math.min(idx, Math.max(0, triggerItems.length - 1)))
|
||||
}, [triggerItems.length])
|
||||
|
||||
// Commit the literally-typed `/command arg` as a directive chip — used when
|
||||
// the completion list is empty because the arg is already fully typed (the
|
||||
// backend completer drops exact matches). Reuses the chip path via a
|
||||
// synthetic item whose serialized form is the verbatim text.
|
||||
const commitTypedSlashDirective = () => {
|
||||
if (trigger?.kind !== '/') {
|
||||
return
|
||||
}
|
||||
|
||||
const text = `/${trigger.query.trimEnd()}`
|
||||
|
||||
replaceTriggerWithChip({
|
||||
id: text,
|
||||
type: 'slash',
|
||||
label: text.slice(1),
|
||||
metadata: { command: slashCommandToken(trigger.query), display: text, meta: '', group: '', action: '', rawText: text }
|
||||
})
|
||||
}
|
||||
|
||||
const replaceTriggerWithChip = (item: Unstable_TriggerItem) => {
|
||||
const editor = editorRef.current
|
||||
|
||||
@@ -688,8 +750,7 @@ export function ChatBar({
|
||||
// already an arg pick (`/personality alice`), so it commits normally.
|
||||
const command = (item.metadata as { command?: string } | undefined)?.command ?? ''
|
||||
|
||||
const expandsToArgs =
|
||||
trigger.kind === '/' && !serialized.includes(' ') && desktopSlashCommandTakesArgs(command)
|
||||
const expandsToArgs = trigger.kind === '/' && !serialized.includes(' ') && desktopSlashCommandTakesArgs(command)
|
||||
|
||||
const text = starter || serialized.endsWith(' ') ? serialized : `${serialized} `
|
||||
const directive = !starter && serialized.match(/^@([^:]+):(.+)$/)
|
||||
@@ -771,6 +832,18 @@ export function ChatBar({
|
||||
return
|
||||
}
|
||||
|
||||
// Non-collapsed Backspace/Delete: native selection-delete is ~O(n²) on large
|
||||
// drafts (Ctrl+A → Delete froze ~1.3s). Collapsed carets fall through.
|
||||
if (
|
||||
(event.key === 'Backspace' || event.key === 'Delete') &&
|
||||
deleteSelectionInEditor(event.currentTarget)
|
||||
) {
|
||||
event.preventDefault()
|
||||
flushEditorToDraft(event.currentTarget)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Cmd/Ctrl+Shift+K drains the next queued message. Plain Cmd/Ctrl+K is
|
||||
// reserved for the global command palette.
|
||||
if ((event.metaKey || event.ctrlKey) && !event.altKey && event.shiftKey && event.key.toLowerCase() === 'k') {
|
||||
@@ -800,7 +873,15 @@ export function ChatBar({
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Enter' || event.key === 'Tab') {
|
||||
// Enter / Tab / Space all accept the highlighted item: a no-arg command
|
||||
// commits its directive chip, an arg-taking command expands to its
|
||||
// options step, and an arg option commits the full `/cmd arg` chip. Space
|
||||
// is slash-only (an `@` mention takes a literal space) and gated to a
|
||||
// non-empty query so a bare `/ ` still types a space.
|
||||
const acceptOnSpace = event.key === ' ' && trigger.kind === '/' && Boolean(trigger.query.trim())
|
||||
const accept = event.key === 'Enter' || event.key === 'Tab' || acceptOnSpace
|
||||
|
||||
if (accept) {
|
||||
event.preventDefault()
|
||||
triggerKeyConsumedRef.current = true
|
||||
const item = triggerItems[triggerActive]
|
||||
@@ -821,6 +902,24 @@ export function ChatBar({
|
||||
}
|
||||
}
|
||||
|
||||
// Arg stage with nothing left to suggest — a fully-typed arg the backend
|
||||
// completer no longer echoes (it drops the exact match), e.g.
|
||||
// `/personality creative`. Space/Tab still commit what's typed as a single
|
||||
// directive chip; Enter falls through to submit (send it as-is).
|
||||
if (
|
||||
trigger?.kind === '/' &&
|
||||
!triggerItems.length &&
|
||||
(event.key === ' ' || event.key === 'Tab') &&
|
||||
slashArgStage(trigger.query) &&
|
||||
trigger.query.trim()
|
||||
) {
|
||||
event.preventDefault()
|
||||
triggerKeyConsumedRef.current = true
|
||||
commitTypedSlashDirective()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// ArrowUp/ArrowDown navigate, in priority order: the queue (edit entries in
|
||||
// place) then sent-message history. The history ring is derived from live
|
||||
// session messages each press — single source of truth, no mirror.
|
||||
@@ -853,7 +952,9 @@ export function ChatBar({
|
||||
event.preventDefault()
|
||||
triggerKeyConsumedRef.current = true
|
||||
|
||||
const history = deriveUserHistory(sessionMessages, chatMessageText)
|
||||
// $messages is read imperatively (not subscribed) so the composer
|
||||
// doesn't re-render on every streaming delta flush.
|
||||
const history = deriveUserHistory($messages.get(), chatMessageText)
|
||||
const entry = browseBackward(sessionId, currentDraft, history)
|
||||
|
||||
if (entry !== null) {
|
||||
@@ -878,7 +979,7 @@ export function ChatBar({
|
||||
event.preventDefault()
|
||||
triggerKeyConsumedRef.current = true
|
||||
|
||||
const history = deriveUserHistory(sessionMessages, chatMessageText)
|
||||
const history = deriveUserHistory($messages.get(), chatMessageText)
|
||||
const result = browseForward(sessionId, history)
|
||||
|
||||
if (result !== null) {
|
||||
@@ -914,6 +1015,10 @@ export function ChatBar({
|
||||
const editorText = editorRef.current ? composerPlainText(editorRef.current) : draftRef.current
|
||||
const hasLivePayload = editorText.trim().length > 0 || attachments.length > 0
|
||||
|
||||
if (disabled) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!busy && !hasLivePayload && queuedPrompts.length > 0) {
|
||||
void drainNextQueued()
|
||||
|
||||
@@ -1113,11 +1218,8 @@ export function ChatBar({
|
||||
}
|
||||
}
|
||||
|
||||
const stashAt = (
|
||||
scope: string | null,
|
||||
text = draftRef.current,
|
||||
attachments = $composerAttachments.get()
|
||||
) => stashSessionDraft(scope, text, attachments)
|
||||
const stashAt = (scope: string | null, text = draftRef.current, attachments = $composerAttachments.get()) =>
|
||||
stashSessionDraft(scope, text, attachments)
|
||||
|
||||
// Per-thread draft swap — the composer's only session coupling. Lifecycle
|
||||
// never clears composer state; this effect alone stashes on leave, restores
|
||||
@@ -1315,6 +1417,7 @@ export function ChatBar({
|
||||
return false
|
||||
}
|
||||
|
||||
drainFailuresRef.current.delete(entry.id)
|
||||
removeQueuedPrompt(activeQueueSessionKey, entry.id)
|
||||
resetBrowseState(sessionId)
|
||||
|
||||
@@ -1326,16 +1429,17 @@ export function ChatBar({
|
||||
[activeQueueSessionKey, onSubmit, queuedPrompts, sessionId]
|
||||
)
|
||||
|
||||
const drainNextQueued = useCallback(
|
||||
() =>
|
||||
runDrain(entries => {
|
||||
const skip = queueEdit?.entryId
|
||||
const pickDrainHead = useCallback(
|
||||
(entries: QueuedPromptEntry[]) => {
|
||||
const skip = queueEditRef.current?.entryId
|
||||
|
||||
return skip ? entries.find(e => e.id !== skip) : entries[0]
|
||||
}),
|
||||
[queueEdit, runDrain]
|
||||
return skip ? entries.find(e => e.id !== skip) : entries[0]
|
||||
},
|
||||
[] // reads the edit id off a ref so the lock-holder always sees the latest
|
||||
)
|
||||
|
||||
const drainNextQueued = useCallback(() => runDrain(pickDrainHead), [pickDrainHead, runDrain])
|
||||
|
||||
const sendQueuedNow = useCallback(
|
||||
(id: string) => {
|
||||
if (!activeQueueSessionKey || id === queueEdit?.entryId) {
|
||||
@@ -1353,30 +1457,76 @@ export function ChatBar({
|
||||
return true
|
||||
}
|
||||
|
||||
// A manual send clears the auto-drain backoff so a stuck entry the user
|
||||
// taps gets a fresh attempt (and re-enables auto-retry on success).
|
||||
drainFailuresRef.current.delete(id)
|
||||
|
||||
return runDrain(entries => entries.find(e => e.id === id))
|
||||
},
|
||||
[activeQueueSessionKey, busy, onCancel, queueEdit, runDrain]
|
||||
)
|
||||
|
||||
// Auto-drain on busy → false (turn settled). Queued turns always flow once
|
||||
// the session is idle again — whether the turn finished naturally or the
|
||||
// user interrupted it. Interrupting to reach a queued message is the whole
|
||||
// point of the queue, so we never suppress the drain. To cancel queued
|
||||
// turns, the user deletes them from the panel.
|
||||
useEffect(() => {
|
||||
const wasBusy = previousBusyRef.current
|
||||
previousBusyRef.current = busy
|
||||
|
||||
if (
|
||||
shouldAutoDrainOnSettle({
|
||||
isBusy: busy,
|
||||
queueLength: queuedPrompts.length,
|
||||
wasBusy
|
||||
})
|
||||
) {
|
||||
void drainNextQueued()
|
||||
// Edge-independent auto-drain: send the head whenever the session is idle and
|
||||
// the queue is non-empty, bounding retries so a thrown/rejected onSubmit (e.g.
|
||||
// a stale-session 404) can't strand the entry permanently nor spin-loop. The
|
||||
// drain lock serializes sends; a remount/reconnect resets the failure counts.
|
||||
const autoDrainNext = useCallback(() => {
|
||||
if (busy || drainingQueueRef.current || !activeQueueSessionKey) {
|
||||
return
|
||||
}
|
||||
}, [busy, drainNextQueued, queuedPrompts.length])
|
||||
|
||||
const entry = pickDrainHead(queuedPrompts)
|
||||
|
||||
if (!entry || (drainFailuresRef.current.get(entry.id) ?? 0) >= MAX_AUTO_DRAIN_ATTEMPTS) {
|
||||
return
|
||||
}
|
||||
|
||||
const onFail = () => {
|
||||
const fails = (drainFailuresRef.current.get(entry.id) ?? 0) + 1
|
||||
drainFailuresRef.current.set(entry.id, fails)
|
||||
|
||||
if (fails >= MAX_AUTO_DRAIN_ATTEMPTS) {
|
||||
notify({
|
||||
id: 'composer-queue-stuck',
|
||||
kind: 'error',
|
||||
title: t.composer.queueStuckTitle,
|
||||
message: t.composer.queueStuckBody
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
void runDrain(() => entry)
|
||||
.then(sent => {
|
||||
if (!sent) {
|
||||
onFail()
|
||||
}
|
||||
})
|
||||
.catch(onFail)
|
||||
}, [activeQueueSessionKey, busy, pickDrainHead, queuedPrompts, runDrain, t])
|
||||
|
||||
// Re-key on a runtime session-id change. A stable stored id (queueSessionKey)
|
||||
// never churns, so a change there is a real session switch and must NOT
|
||||
// migrate; only the runtime-derived key (queueSessionKey falsy → key is
|
||||
// sessionId) churns on a backend bounce/resume of the same conversation.
|
||||
useEffect(() => {
|
||||
const prev = prevQueueKeyRef.current
|
||||
prevQueueKeyRef.current = activeQueueSessionKey
|
||||
|
||||
if (queueSessionKey || !prev || !activeQueueSessionKey || prev === activeQueueSessionKey) {
|
||||
return
|
||||
}
|
||||
|
||||
migrateQueuedPrompts(prev, activeQueueSessionKey)
|
||||
}, [activeQueueSessionKey, queueSessionKey])
|
||||
|
||||
// Queued turns flow whenever the session is idle — on the busy→false settle
|
||||
// edge, on mount/reconnect, and after a re-key — so a swallowed edge can't
|
||||
// strand them. To cancel queued turns, the user deletes them from the panel.
|
||||
useEffect(() => {
|
||||
if (shouldAutoDrain({ isBusy: busy, queueLength: queuedPrompts.length })) {
|
||||
autoDrainNext()
|
||||
}
|
||||
}, [autoDrainNext, busy, queuedPrompts.length])
|
||||
|
||||
// Queue-edit cleanup: on session swap the scope effect already stashed the
|
||||
// edit snapshot; only restore into the composer when still on the same scope.
|
||||
@@ -1411,6 +1561,10 @@ export function ChatBar({
|
||||
}
|
||||
|
||||
const submitDraft = () => {
|
||||
if (disabled) {
|
||||
return
|
||||
}
|
||||
|
||||
// Source the text from the DOM editor, not React state. The AUI composer
|
||||
// state (`draft`) and the derived `hasComposerPayload` lag the DOM by a
|
||||
// render, so on fast typing or IME composition the final keystroke(s) may
|
||||
@@ -1591,6 +1745,7 @@ export function ChatBar({
|
||||
const input = (
|
||||
<div className={cn('relative', stacked ? 'w-full' : 'min-w-(--composer-input-inline-min-width) flex-1')}>
|
||||
<div
|
||||
aria-disabled={inputDisabled ? true : undefined}
|
||||
aria-label={t.composer.message}
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
@@ -1601,7 +1756,7 @@ export function ChatBar({
|
||||
stacked && 'pl-3',
|
||||
stacked ? 'w-full' : 'min-w-(--composer-input-inline-min-width) flex-1'
|
||||
)}
|
||||
contentEditable={!disabled}
|
||||
contentEditable={!inputDisabled}
|
||||
data-placeholder={placeholder}
|
||||
data-slot={RICH_INPUT_SLOT}
|
||||
onBlur={() => window.setTimeout(closeTrigger, 80)}
|
||||
@@ -1669,6 +1824,7 @@ export function ChatBar({
|
||||
className="group/composer absolute bottom-0 left-1/2 z-30 w-[min(var(--composer-width),calc(100%-2rem))] max-w-full -translate-x-1/2 rounded-2xl pt-2 pb-[var(--composer-shell-pad-block-end)]"
|
||||
data-drag-active={dragActive ? '' : undefined}
|
||||
data-slot="composer-root"
|
||||
data-status-stack={statusStackVisible ? '' : undefined}
|
||||
data-thread-scrolled-up={scrolledUp ? '' : undefined}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
@@ -1686,7 +1842,7 @@ export function ChatBar({
|
||||
ref={composerRef}
|
||||
>
|
||||
{showHelpHint && <HelpHint />}
|
||||
{trigger && (
|
||||
{trigger && !argStageEmpty && (
|
||||
<ComposerTriggerPopover
|
||||
activeIndex={triggerActive}
|
||||
items={triggerItems}
|
||||
@@ -1696,26 +1852,30 @@ export function ChatBar({
|
||||
onPick={replaceTriggerWithChip}
|
||||
/>
|
||||
)}
|
||||
{activeQueueSessionKey && queuedPrompts.length > 0 && (
|
||||
// Out of flow so the queue never inflates the composer's measured
|
||||
// height (that drives thread bottom padding → chat resizes on
|
||||
// queue). Overlaps -mb-2 onto the surface's top border for a shared
|
||||
// edge; capped + scrollable. Overlays the chat instead of pushing it.
|
||||
<div className="absolute inset-x-0 bottom-full z-6 -mb-2 max-h-[40vh] overflow-y-auto">
|
||||
<QueuePanel
|
||||
busy={busy}
|
||||
editingId={queueEdit?.entryId ?? null}
|
||||
entries={queuedPrompts}
|
||||
onDelete={id => {
|
||||
if (removeQueuedPrompt(activeQueueSessionKey, id) && queueEdit?.entryId === id) {
|
||||
exitQueuedEdit('cancel')
|
||||
}
|
||||
}}
|
||||
onEdit={beginQueuedEdit}
|
||||
onSendNow={id => void sendQueuedNow(id)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* Session-scoped status stack (todos, subagents, background tasks,
|
||||
queue). Out of flow so it never inflates the composer's measured
|
||||
height; it overlays the chat instead of pushing it, and publishes
|
||||
its own --status-stack-measured-height so the thread's clearance
|
||||
accounts for it. Collapses to nothing when every status is empty. */}
|
||||
<ComposerStatusStack
|
||||
queue={
|
||||
activeQueueSessionKey && queuedPrompts.length > 0 ? (
|
||||
<QueuePanel
|
||||
busy={busy}
|
||||
editingId={queueEdit?.entryId ?? null}
|
||||
entries={queuedPrompts}
|
||||
onDelete={id => {
|
||||
if (removeQueuedPrompt(activeQueueSessionKey, id) && queueEdit?.entryId === id) {
|
||||
exitQueuedEdit('cancel')
|
||||
}
|
||||
}}
|
||||
onEdit={beginQueuedEdit}
|
||||
onSendNow={id => void sendQueuedNow(id)}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
sessionId={statusSessionId}
|
||||
/>
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 rounded-[inherit]"
|
||||
style={{ background: COMPOSER_FADE_BACKGROUND }}
|
||||
@@ -1723,9 +1883,8 @@ export function ChatBar({
|
||||
<div className="relative w-full rounded-[inherit]">
|
||||
<div
|
||||
className={cn(
|
||||
'relative z-4 isolate rounded-[inherit] border border-[color-mix(in_srgb,var(--dt-composer-ring)_calc(18%*var(--composer-ring-strength)),var(--dt-input))] transition-[border-color] duration-200 ease-out',
|
||||
'group/composer-surface relative z-4 isolate rounded-[inherit] border border-[color-mix(in_srgb,var(--dt-composer-ring)_calc(18%*var(--composer-ring-strength)),var(--dt-input))] transition-[border-color] duration-200 ease-out focus-within:border-[color-mix(in_srgb,var(--dt-composer-ring)_calc(45%*var(--composer-ring-strength)),transparent)]',
|
||||
COMPOSER_DROP_FADE_CLASS,
|
||||
'group-focus-within/composer:border-[color-mix(in_srgb,var(--dt-composer-ring)_calc(45%*var(--composer-ring-strength)),transparent)]',
|
||||
'group-has-data-[state=open]/composer:border-t-transparent',
|
||||
dragActive && COMPOSER_DROP_ACTIVE_CLASS
|
||||
)}
|
||||
@@ -1736,20 +1895,14 @@ export function ChatBar({
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'pointer-events-none absolute inset-0 -z-10 rounded-[inherit]',
|
||||
'bg-[color-mix(in_srgb,var(--dt-card)_72%,transparent)]',
|
||||
'backdrop-blur-[0.75rem] backdrop-saturate-[1.12]',
|
||||
'[-webkit-backdrop-filter:blur(0.75rem)_saturate(1.12)]',
|
||||
'transition-[background-color] duration-150 ease-out',
|
||||
'group-data-[thread-scrolled-up]/composer:bg-[color-mix(in_srgb,var(--dt-card)_48%,transparent)]',
|
||||
'group-focus-within/composer:bg-[color-mix(in_srgb,var(--dt-card)_85%,transparent)]'
|
||||
composerFill,
|
||||
composerSurfaceGlass
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
'relative z-1 flex min-h-0 w-full flex-col gap-(--composer-row-gap) overflow-hidden rounded-[inherit] px-(--composer-surface-pad-x) py-(--composer-surface-pad-y) transition-opacity duration-200 ease-out',
|
||||
scrolledUp
|
||||
? 'opacity-30 group-hover/composer:opacity-100 group-focus-within/composer:opacity-100'
|
||||
: 'opacity-100'
|
||||
scrolledUp ? 'opacity-30 group-hover/composer:opacity-100 group-focus-within/composer-surface:opacity-100' : 'opacity-100'
|
||||
)}
|
||||
data-slot="composer-fade"
|
||||
>
|
||||
@@ -1824,12 +1977,8 @@ export function ChatBarFallback() {
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'pointer-events-none absolute inset-0 -z-10 rounded-[inherit]',
|
||||
'bg-[color-mix(in_srgb,var(--dt-card)_72%,transparent)]',
|
||||
'backdrop-blur-[0.75rem] backdrop-saturate-[1.12]',
|
||||
'[-webkit-backdrop-filter:blur(0.75rem)_saturate(1.12)]',
|
||||
'transition-[background-color] duration-150 ease-out',
|
||||
'group-data-[thread-scrolled-up]/composer:bg-[color-mix(in_srgb,var(--dt-card)_48%,transparent)]',
|
||||
'group-focus-within/composer:bg-[color-mix(in_srgb,var(--dt-card)_85%,transparent)]'
|
||||
composerFill,
|
||||
composerSurfaceGlass
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,12 @@ import { contextPath } from '@/lib/chat-runtime'
|
||||
|
||||
import type { DroppedFile } from '../hooks/use-composer-actions'
|
||||
|
||||
import { composerPlainText, escapeHtml, placeCaretEnd, refChipHtml } from './rich-editor'
|
||||
import {
|
||||
composerPlainText,
|
||||
normalizeComposerEditorDom,
|
||||
placeCaretEnd,
|
||||
refChipElement
|
||||
} 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 }
|
||||
@@ -89,56 +94,102 @@ export function droppedFileInlineRefs(candidates: DroppedFile[], cwd: string | n
|
||||
return candidates.map(candidate => droppedFileInlineRef(candidate, cwd)).filter((ref): ref is string => Boolean(ref))
|
||||
}
|
||||
|
||||
export function insertInlineRefsIntoEditor(editor: HTMLDivElement, refs: readonly InlineRefInput[]) {
|
||||
if (!refs.length) {
|
||||
function parseInlineRef(ref: InlineRefInput): { kind: string; label?: string; rawValue: string } | null {
|
||||
if (typeof ref !== 'string') {
|
||||
return { kind: ref.kind, label: ref.label, rawValue: ref.value }
|
||||
}
|
||||
|
||||
const match = ref.match(/^@([^:]+):(.+)$/)
|
||||
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
|
||||
const refsHtml = refs
|
||||
.map(ref => {
|
||||
if (typeof ref !== 'string') {
|
||||
return refChipHtml(ref.kind, ref.value, ref.label)
|
||||
}
|
||||
return { kind: match[1] || 'file', rawValue: match[2] || '' }
|
||||
}
|
||||
|
||||
const match = ref.match(/^@([^:]+):(.+)$/)
|
||||
function plainTextInRange(editor: HTMLDivElement, range: Range, edge: 'after' | 'before') {
|
||||
const slice = range.cloneRange()
|
||||
slice.selectNodeContents(editor)
|
||||
|
||||
return match ? refChipHtml(match[1], match[2]) : escapeHtml(ref)
|
||||
})
|
||||
.join(' ')
|
||||
if (edge === 'before') {
|
||||
slice.setEnd(range.startContainer, range.startOffset)
|
||||
} else {
|
||||
slice.setStart(range.endContainer, range.endOffset)
|
||||
}
|
||||
|
||||
const container = document.createElement('div')
|
||||
container.appendChild(slice.cloneContents())
|
||||
|
||||
return composerPlainText(container)
|
||||
}
|
||||
|
||||
function buildRefFragment(
|
||||
refs: readonly { kind: string; label?: string; rawValue: string }[],
|
||||
{ needsBeforeSpace, needsAfterSpace }: { needsAfterSpace: boolean; needsBeforeSpace: boolean }
|
||||
) {
|
||||
const fragment = document.createDocumentFragment()
|
||||
|
||||
if (needsBeforeSpace) {
|
||||
fragment.append(document.createTextNode(' '))
|
||||
}
|
||||
|
||||
refs.forEach((ref, index) => {
|
||||
if (index > 0) {
|
||||
fragment.append(document.createTextNode(' '))
|
||||
}
|
||||
|
||||
fragment.append(refChipElement(ref.kind, ref.rawValue, ref.label))
|
||||
})
|
||||
|
||||
if (needsAfterSpace) {
|
||||
fragment.append(document.createTextNode(' '))
|
||||
}
|
||||
|
||||
return fragment
|
||||
}
|
||||
|
||||
export function insertInlineRefsIntoEditor(editor: HTMLDivElement, refs: readonly InlineRefInput[]) {
|
||||
const parsed = refs.map(parseInlineRef).filter((ref): ref is NonNullable<typeof ref> => ref !== null)
|
||||
|
||||
if (!parsed.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
editor.focus({ preventScroll: true })
|
||||
|
||||
const selection = window.getSelection()
|
||||
|
||||
const range =
|
||||
selection?.rangeCount && editor.contains(selection.getRangeAt(0).commonAncestorContainer)
|
||||
? selection.getRangeAt(0)
|
||||
: null
|
||||
|
||||
editor.focus({ preventScroll: true })
|
||||
if (range && selection) {
|
||||
const beforeText = plainTextInRange(editor, range, 'before')
|
||||
const afterText = plainTextInRange(editor, range, 'after')
|
||||
|
||||
if (range) {
|
||||
const beforeRange = range.cloneRange()
|
||||
beforeRange.selectNodeContents(editor)
|
||||
beforeRange.setEnd(range.startContainer, range.startOffset)
|
||||
const beforeContainer = document.createElement('div')
|
||||
beforeContainer.appendChild(beforeRange.cloneContents())
|
||||
|
||||
const afterRange = range.cloneRange()
|
||||
afterRange.selectNodeContents(editor)
|
||||
afterRange.setStart(range.endContainer, range.endOffset)
|
||||
const afterContainer = document.createElement('div')
|
||||
afterContainer.appendChild(afterRange.cloneContents())
|
||||
|
||||
const beforeText = composerPlainText(beforeContainer)
|
||||
const afterText = composerPlainText(afterContainer)
|
||||
const needsBeforeSpace = beforeText.length > 0 && !/\s$/.test(beforeText)
|
||||
const needsAfterSpace = afterText.length === 0 || !/^\s/.test(afterText)
|
||||
|
||||
document.execCommand('insertHTML', false, `${needsBeforeSpace ? ' ' : ''}${refsHtml}${needsAfterSpace ? ' ' : ''}`)
|
||||
range.insertNode(
|
||||
buildRefFragment(parsed, {
|
||||
needsAfterSpace: afterText.length === 0 || !/^\s/.test(afterText),
|
||||
needsBeforeSpace: beforeText.length > 0 && !/\s$/.test(beforeText)
|
||||
})
|
||||
)
|
||||
range.collapse(false)
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
} else {
|
||||
const current = composerPlainText(editor)
|
||||
|
||||
editor.append(
|
||||
buildRefFragment(parsed, {
|
||||
needsAfterSpace: true,
|
||||
needsBeforeSpace: current.length > 0 && !/\s$/.test(current)
|
||||
})
|
||||
)
|
||||
placeCaretEnd(editor)
|
||||
document.execCommand('insertHTML', false, `${current && !/\s$/.test(current) ? ' ' : ''}${refsHtml} `)
|
||||
}
|
||||
|
||||
normalizeComposerEditorDom(editor)
|
||||
|
||||
return composerPlainText(editor)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
import { StatusRow } from '@/components/chat/status-row'
|
||||
import { StatusSection } from '@/components/chat/status-section'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { type Translations, useI18n } from '@/i18n'
|
||||
import { ArrowUp, Pencil, Trash2 } from '@/lib/icons'
|
||||
@@ -23,108 +22,84 @@ const entryPreview = (entry: QueuedPromptEntry, c: Translations['composer']) =>
|
||||
export function QueuePanel({ busy, editingId, entries, onDelete, onEdit, onSendNow }: QueuePanelProps) {
|
||||
const { t } = useI18n()
|
||||
const c = t.composer
|
||||
const [collapsed, setCollapsed] = useState(true)
|
||||
|
||||
if (entries.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-t-2xl border border-b-0 border-border/65 bg-[color-mix(in_srgb,var(--dt-card)_70%,transparent)] pt-0.5 pb-1 mx-1">
|
||||
<button
|
||||
className="flex w-full items-center gap-1.5 px-2 text-left text-[0.6rem] font-medium text-muted-foreground/92 transition-colors hover:text-foreground/90"
|
||||
onClick={() => setCollapsed(open => !open)}
|
||||
type="button"
|
||||
>
|
||||
<DisclosureCaret className="shrink-0" open={!collapsed} size="1em" />
|
||||
<span className="truncate">{c.queued(entries.length)}</span>
|
||||
</button>
|
||||
<StatusSection label={c.queued(entries.length)}>
|
||||
{entries.map(entry => {
|
||||
const isEditing = editingId === entry.id
|
||||
const attachmentsCount = entry.attachments.length
|
||||
|
||||
{!collapsed && (
|
||||
<div className="space-y-0.5 px-1 pb-0.5">
|
||||
{entries.map(entry => {
|
||||
const isEditing = editingId === entry.id
|
||||
const attachmentsCount = entry.attachments.length
|
||||
const sendLabel = busy ? c.sendQueuedNext : c.sendQueuedNow
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group/queue-row flex items-center gap-1.5 rounded-lg border border-transparent px-1.5 py-0.5',
|
||||
'transition-colors duration-300 ease-out hover:bg-(--chrome-action-hover) hover:transition-none',
|
||||
isEditing && 'border-[color-mix(in_srgb,var(--dt-composer-ring)_40%,transparent)] bg-accent/25'
|
||||
)}
|
||||
key={entry.id}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="h-3.5 w-3.5 shrink-0 rounded-full border border-foreground/35 bg-transparent"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-[0.73rem] leading-4 text-foreground/92">{entryPreview(entry, c)}</p>
|
||||
{(attachmentsCount > 0 || isEditing) && (
|
||||
<div className="mt-0.5 flex items-center gap-1.5 text-[0.64rem] text-muted-foreground/75">
|
||||
{attachmentsCount > 0 && <span>{c.attachments(attachmentsCount)}</span>}
|
||||
{isEditing && (
|
||||
<span className="text-[color-mix(in_srgb,var(--dt-composer-ring)_78%,var(--muted-foreground))]">
|
||||
{c.editingInComposer}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
return (
|
||||
<StatusRow
|
||||
className={cn(
|
||||
'border border-transparent',
|
||||
isEditing && 'border-[color-mix(in_srgb,var(--dt-composer-ring)_40%,transparent)] bg-accent/25'
|
||||
)}
|
||||
key={entry.id}
|
||||
trailing={
|
||||
<>
|
||||
<Tip label={c.queueEdit}>
|
||||
<Button
|
||||
aria-label={c.queueEdit}
|
||||
className="size-5 rounded-md"
|
||||
disabled={Boolean(editingId) && !isEditing}
|
||||
onClick={() => onEdit(entry)}
|
||||
size="icon-xs"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Pencil size={11} />
|
||||
</Button>
|
||||
</Tip>
|
||||
<Tip label={busy ? c.queueSendNext : c.queueSend}>
|
||||
<Button
|
||||
aria-label={busy ? c.queueSendNext : c.queueSend}
|
||||
className="size-5 rounded-md"
|
||||
disabled={isEditing}
|
||||
onClick={() => onSendNow(entry.id)}
|
||||
size="icon-xs"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<ArrowUp size={11} />
|
||||
</Button>
|
||||
</Tip>
|
||||
<Tip label={c.queueDelete}>
|
||||
<Button
|
||||
aria-label={c.queueDelete}
|
||||
className="size-5 rounded-md"
|
||||
onClick={() => onDelete(entry.id)}
|
||||
size="icon-xs"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Trash2 size={11} />
|
||||
</Button>
|
||||
</Tip>
|
||||
</>
|
||||
}
|
||||
trailingVisible={isEditing}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-[0.73rem] leading-4 text-foreground/92">{entryPreview(entry, c)}</p>
|
||||
{(attachmentsCount > 0 || isEditing) && (
|
||||
<div className="mt-0.5 flex items-center gap-1.5 text-[0.64rem] text-muted-foreground/75">
|
||||
{attachmentsCount > 0 && <span>{c.attachments(attachmentsCount)}</span>}
|
||||
{isEditing && (
|
||||
<span className="text-[color-mix(in_srgb,var(--dt-composer-ring)_78%,var(--muted-foreground))]">
|
||||
{c.editingInComposer}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'flex shrink-0 items-center gap-0 transition-opacity',
|
||||
isEditing
|
||||
? 'opacity-100'
|
||||
: 'opacity-0 group-hover/queue-row:opacity-100 group-focus-within/queue-row:opacity-100'
|
||||
)}
|
||||
>
|
||||
<Tip label={c.editQueued}>
|
||||
<Button
|
||||
aria-label={c.editQueued}
|
||||
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={sendLabel}>
|
||||
<Button
|
||||
aria-label={sendLabel}
|
||||
className="h-5 w-5 rounded-md"
|
||||
disabled={isEditing}
|
||||
onClick={() => onSendNow(entry.id)}
|
||||
size="icon-xs"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<ArrowUp size={11} />
|
||||
</Button>
|
||||
</Tip>
|
||||
<Tip label={c.deleteQueued}>
|
||||
<Button
|
||||
aria-label={c.deleteQueued}
|
||||
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>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</StatusRow>
|
||||
)
|
||||
})}
|
||||
</StatusSection>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,25 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { composerPlainText, renderComposerContents, RICH_INPUT_SLOT } from './rich-editor'
|
||||
import { insertInlineRefsIntoEditor } from './inline-refs'
|
||||
import {
|
||||
composerPlainText,
|
||||
deleteSelectionInEditor,
|
||||
insertPlainTextAtCaret,
|
||||
normalizeComposerEditorDom,
|
||||
refChipElement,
|
||||
renderComposerContents,
|
||||
RICH_INPUT_SLOT
|
||||
} from './rich-editor'
|
||||
|
||||
const caretIn = (editor: HTMLElement) => {
|
||||
const range = document.createRange()
|
||||
const selection = window.getSelection()!
|
||||
|
||||
range.selectNodeContents(editor)
|
||||
range.collapse(false)
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
}
|
||||
|
||||
describe('renderComposerContents', () => {
|
||||
it('renders refs and raw text without interpreting user text as HTML', () => {
|
||||
@@ -16,3 +35,100 @@ describe('renderComposerContents', () => {
|
||||
expect(composerPlainText(editor)).toBe('@file:`<img src=x onerror=alert(1)>` <b>raw</b>')
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeComposerEditorDom', () => {
|
||||
it('unwraps a single insertHTML wrapper div so plain text stays one line', () => {
|
||||
const editor = document.createElement('div')
|
||||
editor.dataset.slot = RICH_INPUT_SLOT
|
||||
editor.innerHTML = '<div><span data-ref-text="@file:`src/foo.ts`" contenteditable="false">foo.ts</span> </div>'
|
||||
|
||||
normalizeComposerEditorDom(editor)
|
||||
|
||||
expect(composerPlainText(editor)).toBe('@file:`src/foo.ts` ')
|
||||
expect(editor.querySelector(':scope > div')).toBeNull()
|
||||
})
|
||||
|
||||
it('removes a trailing br after a ref chip', () => {
|
||||
const editor = document.createElement('div')
|
||||
editor.dataset.slot = RICH_INPUT_SLOT
|
||||
editor.append(refChipElement('file', '`src/foo.ts`'), document.createElement('br'))
|
||||
|
||||
normalizeComposerEditorDom(editor)
|
||||
|
||||
expect(composerPlainText(editor)).toBe('@file:`src/foo.ts`')
|
||||
expect(editor.querySelector('br')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('insertInlineRefsIntoEditor', () => {
|
||||
it('inserts chips without wrapper divs or spurious newlines', () => {
|
||||
const editor = document.createElement('div')
|
||||
editor.dataset.slot = RICH_INPUT_SLOT
|
||||
|
||||
insertInlineRefsIntoEditor(editor, ['@file:`src/foo.ts`'])
|
||||
|
||||
expect(editor.querySelector(':scope > div')).toBeNull()
|
||||
expect(composerPlainText(editor)).toBe('@file:`src/foo.ts` ')
|
||||
})
|
||||
})
|
||||
|
||||
describe('insertPlainTextAtCaret', () => {
|
||||
it('inserts multiline text as text nodes + br', () => {
|
||||
const editor = document.createElement('div')
|
||||
editor.dataset.slot = RICH_INPUT_SLOT
|
||||
document.body.append(editor)
|
||||
caretIn(editor)
|
||||
|
||||
insertPlainTextAtCaret(editor, 'one\ntwo\nthree')
|
||||
|
||||
expect(editor.querySelectorAll('br').length).toBe(2)
|
||||
expect(composerPlainText(editor)).toBe('one\ntwo\nthree')
|
||||
|
||||
editor.remove()
|
||||
})
|
||||
|
||||
it('replaces the selected span', () => {
|
||||
const editor = document.createElement('div')
|
||||
editor.dataset.slot = RICH_INPUT_SLOT
|
||||
editor.textContent = 'abXYef'
|
||||
document.body.append(editor)
|
||||
|
||||
const text = editor.firstChild!
|
||||
const selection = window.getSelection()!
|
||||
const range = document.createRange()
|
||||
|
||||
range.setStart(text, 2)
|
||||
range.setEnd(text, 4)
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
|
||||
insertPlainTextAtCaret(editor, 'cd')
|
||||
|
||||
expect(composerPlainText(editor)).toBe('abcdef')
|
||||
|
||||
editor.remove()
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteSelectionInEditor', () => {
|
||||
it('clears a non-collapsed range and leaves a collapsed caret', () => {
|
||||
const editor = document.createElement('div')
|
||||
editor.dataset.slot = RICH_INPUT_SLOT
|
||||
editor.textContent = 'hello world'
|
||||
document.body.append(editor)
|
||||
|
||||
const selection = window.getSelection()!
|
||||
const range = document.createRange()
|
||||
|
||||
range.selectNodeContents(editor)
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
|
||||
expect(deleteSelectionInEditor(editor)).toBe(true)
|
||||
expect(composerPlainText(editor)).toBe('')
|
||||
expect(selection.getRangeAt(0).collapsed).toBe(true)
|
||||
expect(deleteSelectionInEditor(editor)).toBe(false)
|
||||
|
||||
editor.remove()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -132,6 +132,63 @@ export function renderComposerContents(target: HTMLElement, text: string) {
|
||||
appendComposerContents(target, text)
|
||||
}
|
||||
|
||||
/** Caret range when the selection lives inside `editor`; else null. */
|
||||
function composerSelectionRange(editor: HTMLElement) {
|
||||
const selection = window.getSelection()
|
||||
const range = selection?.rangeCount ? selection.getRangeAt(0) : null
|
||||
|
||||
if (!selection || !range || !editor.contains(range.commonAncestorContainer)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return { range, selection }
|
||||
}
|
||||
|
||||
/** Insert plain text at the caret (replacing any selection). Pastes use this
|
||||
* instead of `execCommand('insertText')` — Chromium's editing pipeline is
|
||||
* ~O(n²) on large multiline blobs. */
|
||||
export function insertPlainTextAtCaret(editor: HTMLElement, text: string) {
|
||||
const hit = composerSelectionRange(editor)
|
||||
const fragment = document.createDocumentFragment()
|
||||
|
||||
appendTextWithBreaks(fragment, text)
|
||||
|
||||
const tail = fragment.lastChild
|
||||
|
||||
if (hit) {
|
||||
hit.range.deleteContents()
|
||||
hit.range.insertNode(fragment)
|
||||
} else {
|
||||
editor.append(fragment)
|
||||
}
|
||||
|
||||
if (tail) {
|
||||
const caret = document.createRange()
|
||||
caret.setStartAfter(tail)
|
||||
caret.collapse(true)
|
||||
const selection = hit?.selection ?? window.getSelection()
|
||||
selection?.removeAllRanges()
|
||||
selection?.addRange(caret)
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove a non-collapsed selection in-editor. Skips collapsed carets so word/
|
||||
* line delete (Opt/Cmd+Backspace) stays native. Returns whether anything ran. */
|
||||
export function deleteSelectionInEditor(editor: HTMLElement) {
|
||||
const hit = composerSelectionRange(editor)
|
||||
|
||||
if (!hit || hit.range.collapsed) {
|
||||
return false
|
||||
}
|
||||
|
||||
hit.range.deleteContents()
|
||||
hit.range.collapse(true)
|
||||
hit.selection.removeAllRanges()
|
||||
hit.selection.addRange(hit.range)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/** Serialize a draft string into chip-HTML for the contenteditable surface. */
|
||||
export function composerHtml(text: string) {
|
||||
let cursor = 0
|
||||
@@ -184,3 +241,36 @@ export function placeCaretEnd(element: HTMLElement) {
|
||||
selection?.removeAllRanges()
|
||||
selection?.addRange(range)
|
||||
}
|
||||
|
||||
/** Drop contenteditable junk that serializes as `\n` and falsely expands the composer. */
|
||||
export function normalizeComposerEditorDom(editor: HTMLElement) {
|
||||
if (editor.childNodes.length === 1 && editor.firstChild?.nodeName === 'BR') {
|
||||
editor.replaceChildren()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (editor.childNodes.length === 1 && editor.firstChild?.nodeType === Node.ELEMENT_NODE) {
|
||||
const wrapper = editor.firstChild as HTMLElement
|
||||
|
||||
if (wrapper.tagName === 'DIV' && wrapper.dataset.slot !== RICH_INPUT_SLOT) {
|
||||
editor.replaceChildren(...Array.from(wrapper.childNodes))
|
||||
}
|
||||
}
|
||||
|
||||
const last = editor.lastChild
|
||||
|
||||
if (last?.nodeName !== 'BR') {
|
||||
return
|
||||
}
|
||||
|
||||
let prev: ChildNode | null = last.previousSibling
|
||||
|
||||
while (prev?.nodeType === Node.TEXT_NODE && !(prev.textContent || '').trim()) {
|
||||
prev = prev.previousSibling
|
||||
}
|
||||
|
||||
if ((prev as HTMLElement | null)?.dataset.refText) {
|
||||
editor.removeChild(last)
|
||||
}
|
||||
}
|
||||
|
||||
202
apps/desktop/src/app/chat/composer/status-stack/index.tsx
Normal file
202
apps/desktop/src/app/chat/composer/status-stack/index.tsx
Normal file
@@ -0,0 +1,202 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { type ReactNode, useEffect, useLayoutEffect, useMemo, useRef } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import { blurComposerInput } from '@/app/chat/composer/focus'
|
||||
import { AGENTS_ROUTE } from '@/app/routes'
|
||||
import { composerDockCard } from '@/components/chat/composer-dock'
|
||||
import { StatusSection } from '@/components/chat/status-section'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { type Translations, useI18n } from '@/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
$statusItemsBySession,
|
||||
type ComposerStatusItem,
|
||||
dismissBackgroundProcess,
|
||||
groupStatusItems,
|
||||
refreshBackgroundProcesses,
|
||||
type StatusGroup,
|
||||
stopBackgroundProcess
|
||||
} from '@/store/composer-status'
|
||||
import { $threadScrolledUp } from '@/store/thread-scroll'
|
||||
import { openSessionInNewWindow } from '@/store/windows'
|
||||
|
||||
import { StatusItemRow } from './status-row'
|
||||
|
||||
// Slow safety-net poll for silent exits (processes without notify_on_complete
|
||||
// emit no event when they die). Only armed while a running row is on screen.
|
||||
const BACKGROUND_POLL_MS = 5_000
|
||||
|
||||
const groupLabel = (group: StatusGroup, s: Translations['statusStack']) => {
|
||||
if (group.type === 'todo') {
|
||||
return s.todos(group.items.filter(i => i.todoStatus === 'completed').length, group.items.length)
|
||||
}
|
||||
|
||||
return group.type === 'subagent' ? s.subagents(group.items.length) : s.background(group.items.length)
|
||||
}
|
||||
|
||||
interface ComposerStatusStackProps {
|
||||
/** The queue, built by the composer (it owns the queue's callbacks). Rendered
|
||||
* as the last group so it stays fused to the composer like before. */
|
||||
queue: ReactNode
|
||||
sessionId: null | string
|
||||
}
|
||||
|
||||
/**
|
||||
* The status "sink" above the composer: one card (the queue's chrome) holding
|
||||
* every session-scoped status — subagents, background tasks, queue — grouped by
|
||||
* type and separated by light dividers. Collapses to nothing when empty.
|
||||
*/
|
||||
export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackProps) {
|
||||
const { t } = useI18n()
|
||||
const navigate = useNavigate()
|
||||
const itemsBySession = useStore($statusItemsBySession)
|
||||
const scrolledUp = useStore($threadScrolledUp)
|
||||
|
||||
const groups = useMemo(
|
||||
() => groupStatusItems(sessionId ? (itemsBySession[sessionId] ?? []) : []),
|
||||
[itemsBySession, sessionId]
|
||||
)
|
||||
|
||||
// Seed from the registry on session open; event-driven refreshes (terminal /
|
||||
// process tool completions) live in use-message-stream.
|
||||
useEffect(() => {
|
||||
if (sessionId) {
|
||||
void refreshBackgroundProcesses(sessionId)
|
||||
}
|
||||
}, [sessionId])
|
||||
|
||||
const hasRunningBackground = groups.some(g => g.type === 'background' && g.items.some(i => i.state === 'running'))
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionId || !hasRunningBackground) {
|
||||
return
|
||||
}
|
||||
|
||||
const timer = setInterval(() => void refreshBackgroundProcesses(sessionId), BACKGROUND_POLL_MS)
|
||||
|
||||
return () => clearInterval(timer)
|
||||
}, [hasRunningBackground, sessionId])
|
||||
|
||||
const openAgents = () => navigate(AGENTS_ROUTE)
|
||||
|
||||
const openSubagent = (item: ComposerStatusItem) =>
|
||||
item.sessionId ? void openSessionInNewWindow(item.sessionId, { watch: true }) : openAgents()
|
||||
|
||||
const sections: { key: string; node: ReactNode }[] = groups.map(group => ({
|
||||
key: group.type,
|
||||
node: (
|
||||
<StatusSection
|
||||
accessory={
|
||||
group.type === 'subagent' ? (
|
||||
<Button
|
||||
className="text-muted-foreground/75 hover:text-foreground/90"
|
||||
onClick={openAgents}
|
||||
size="micro"
|
||||
type="button"
|
||||
variant="text"
|
||||
>
|
||||
{t.statusStack.agents}
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
defaultCollapsed={group.type !== 'todo'}
|
||||
icon={
|
||||
group.type === 'todo' ? (
|
||||
<Codicon className="text-muted-foreground/70" name="checklist" size="0.8rem" />
|
||||
) : undefined
|
||||
}
|
||||
label={groupLabel(group, t.statusStack)}
|
||||
>
|
||||
{group.items.map(item => (
|
||||
<StatusItemRow
|
||||
item={item}
|
||||
key={item.id}
|
||||
onDismiss={sessionId ? id => dismissBackgroundProcess(sessionId, id) : undefined}
|
||||
onOpen={() => openSubagent(item)}
|
||||
onStop={sessionId ? id => stopBackgroundProcess(sessionId, id) : undefined}
|
||||
/>
|
||||
))}
|
||||
</StatusSection>
|
||||
)
|
||||
}))
|
||||
|
||||
if (queue) {
|
||||
sections.push({ key: 'queue', node: queue })
|
||||
}
|
||||
|
||||
const visible = sections.length > 0
|
||||
const stackRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
// The stack is out of flow (overlays the thread), so the composer's measured
|
||||
// height never sees it. Publish our own measured height — bucketed like the
|
||||
// composer's, to avoid style invalidation churn — so the thread's
|
||||
// last-message clearance can add it and the stack never hides messages.
|
||||
useLayoutEffect(() => {
|
||||
const root = document.documentElement
|
||||
const el = stackRef.current
|
||||
|
||||
if (!visible || !el) {
|
||||
root.style.removeProperty('--status-stack-measured-height')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
let last = -1
|
||||
|
||||
const sync = () => {
|
||||
const bucket = Math.round(el.getBoundingClientRect().height / 8) * 8
|
||||
|
||||
if (bucket !== last) {
|
||||
last = bucket
|
||||
root.style.setProperty('--status-stack-measured-height', `${bucket}px`)
|
||||
}
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver(sync)
|
||||
observer.observe(el)
|
||||
sync()
|
||||
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
root.style.removeProperty('--status-stack-measured-height')
|
||||
}
|
||||
}, [visible])
|
||||
|
||||
if (!visible) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
// Sits above the composer (bottom-full), nudged down by the shell's 0.5rem
|
||||
// top pad (pt-2 on composer-root) plus 1px so its bottom edge overlaps the
|
||||
// composer surface's top border. z BELOW the surface (z-4) so the surface's
|
||||
// top border paints over our transparent bottom border — one seam, no
|
||||
// double line.
|
||||
className="absolute inset-x-0 bottom-full z-3 max-h-[40vh] translate-y-[calc(0.5rem+1px)] overflow-y-auto"
|
||||
onPointerDownCapture={() => blurComposerInput()}
|
||||
ref={stackRef}
|
||||
>
|
||||
{/* The card paints the shared --composer-fill (rest / scrolled / focused
|
||||
all match the composer surface by construction); on scroll we only
|
||||
ghost the CONTENT — element opacity on the card would kill the blur.
|
||||
Rounded top, square bottom; the bottom border is TRANSPARENT — the
|
||||
composer surface's visible top border (which sits at a higher z) is the
|
||||
single shared seam, so the two read as one fused capsule. */}
|
||||
<div className={cn(composerDockCard('top'), 'mx-2 rounded-b-none border-b border-b-transparent pt-0.5 pb-1')}>
|
||||
<div
|
||||
className={cn(
|
||||
'transition-opacity duration-200 ease-out',
|
||||
scrolledUp ? 'opacity-30 group-hover/composer:opacity-100' : 'opacity-100'
|
||||
)}
|
||||
>
|
||||
{sections.map(section => (
|
||||
<div key={section.key}>{section.node}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
155
apps/desktop/src/app/chat/composer/status-stack/status-row.tsx
Normal file
155
apps/desktop/src/app/chat/composer/status-stack/status-row.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import { Fragment, memo, type ReactNode, useState } from 'react'
|
||||
|
||||
import { StatusRow } from '@/components/chat/status-row'
|
||||
import { TerminalOutput } from '@/components/chat/terminal-output'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
|
||||
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { type Translations, useI18n } from '@/i18n'
|
||||
import { ArrowUpRight, X } from '@/lib/icons'
|
||||
import type { TodoStatus } from '@/lib/todos'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { ComposerStatusItem } from '@/store/composer-status'
|
||||
|
||||
const toolLabel = (name: string) =>
|
||||
name
|
||||
.split('_')
|
||||
.filter(Boolean)
|
||||
.map(part => part[0]!.toUpperCase() + part.slice(1))
|
||||
.join(' ') || name
|
||||
|
||||
// Todo rows speak checkbox, not spinner-and-dot: a dashed ring while the item
|
||||
// is still open (pending), codicons once it resolves, a live spinner only on
|
||||
// the in-progress item.
|
||||
const TODO_GLYPHS: Record<Exclude<TodoStatus, 'in_progress' | 'pending'>, { icon: string; tone: string }> = {
|
||||
cancelled: { icon: 'circle-slash', tone: 'text-muted-foreground/45' },
|
||||
completed: { icon: 'pass-filled', tone: 'text-emerald-500/80' }
|
||||
}
|
||||
|
||||
// Left slot: braille spinner while running, otherwise a small status dot
|
||||
// (green = done, red = failed) so the slot is always filled and rows align.
|
||||
function leadingGlyph(item: ComposerStatusItem, s: Translations['statusStack']): ReactNode {
|
||||
if (item.todoStatus === 'pending') {
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
className="box-border size-[0.7rem] rounded-full border border-dashed border-muted-foreground/60"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (item.todoStatus && item.todoStatus !== 'in_progress') {
|
||||
const glyph = TODO_GLYPHS[item.todoStatus]
|
||||
|
||||
return <Codicon className={glyph.tone} name={glyph.icon} size="0.8rem" />
|
||||
}
|
||||
|
||||
if (item.state === 'running') {
|
||||
return (
|
||||
<GlyphSpinner
|
||||
ariaLabel={s.running}
|
||||
className="text-[0.9rem] leading-none text-muted-foreground/80"
|
||||
spinner="braille"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn('size-1.5 rounded-full', item.state === 'failed' ? 'bg-destructive/80' : 'bg-emerald-500/70')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
interface StatusItemRowProps {
|
||||
item: ComposerStatusItem
|
||||
/** Clear a finished background task from the stack. */
|
||||
onDismiss?: (id: string) => void
|
||||
/** Open the subagent's own session window, livestreamed by the gateway's
|
||||
* child-session mirror (Agents view fallback for older gateways). */
|
||||
onOpen?: () => void
|
||||
/** Cancel a running background task. */
|
||||
onStop?: (id: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders one {@link ComposerStatusItem} into the shared {@link StatusRow}.
|
||||
* Memoised + keyed by id so parent re-renders never remount it (the spinner
|
||||
* keeps ticking instead of resetting).
|
||||
*/
|
||||
export const StatusItemRow = memo(function StatusItemRow({ item, onDismiss, onOpen, onStop }: StatusItemRowProps) {
|
||||
const { t } = useI18n()
|
||||
const s = t.statusStack
|
||||
const [outputOpen, setOutputOpen] = useState(false)
|
||||
const failed = item.state === 'failed'
|
||||
const running = item.state === 'running'
|
||||
|
||||
const action =
|
||||
item.type === 'background'
|
||||
? running
|
||||
? onStop && { label: s.stop, onClick: () => onStop(item.id) }
|
||||
: onDismiss && { label: s.dismiss, onClick: () => onDismiss(item.id) }
|
||||
: null
|
||||
|
||||
const canOpen = item.type === 'subagent' && !!onOpen
|
||||
const hasOutput = item.type === 'background' && !!item.output
|
||||
const onActivate = canOpen ? onOpen : hasOutput ? () => setOutputOpen(open => !open) : undefined
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<StatusRow
|
||||
leading={leadingGlyph(item, s)}
|
||||
onActivate={onActivate}
|
||||
trailing={
|
||||
action ? (
|
||||
<Tip label={action.label}>
|
||||
<Button
|
||||
aria-label={action.label}
|
||||
className="-my-1 size-4 rounded-md text-muted-foreground/60 hover:text-foreground/90"
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
action.onClick()
|
||||
}}
|
||||
size="icon-xs"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<X size={12} />
|
||||
</Button>
|
||||
</Tip>
|
||||
) : canOpen ? (
|
||||
<ArrowUpRight aria-hidden className="size-3.5 text-muted-foreground/55" />
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'min-w-0 max-w-[18rem] truncate text-[0.73rem] leading-4',
|
||||
failed
|
||||
? 'text-destructive/90'
|
||||
: item.todoStatus && item.todoStatus !== 'in_progress'
|
||||
? 'text-muted-foreground/75'
|
||||
: 'text-foreground/92'
|
||||
)}
|
||||
>
|
||||
{item.title}
|
||||
</span>
|
||||
{item.type === 'subagent' && item.currentTool && (
|
||||
<span className="shrink-0 truncate text-[0.62rem] leading-4 text-muted-foreground/70">
|
||||
{toolLabel(item.currentTool)}
|
||||
</span>
|
||||
)}
|
||||
{failed && typeof item.exitCode === 'number' && item.exitCode !== 0 && (
|
||||
<span className="shrink-0 rounded bg-destructive/15 px-1 text-[0.58rem] font-semibold text-destructive tabular-nums">
|
||||
{s.exit(item.exitCode)}
|
||||
</span>
|
||||
)}
|
||||
{hasOutput && <DisclosureCaret className="shrink-0 text-muted-foreground/45" open={outputOpen} size="0.8em" />}
|
||||
</StatusRow>
|
||||
{hasOutput && outputOpen && <TerminalOutput className="mx-auto mb-1 max-w-[90%]" text={item.output!} />}
|
||||
</Fragment>
|
||||
)
|
||||
})
|
||||
@@ -1,16 +1,12 @@
|
||||
import type { Unstable_TriggerItem } from '@assistant-ui/core'
|
||||
import { Fragment } from 'react'
|
||||
|
||||
import { BrailleSpinner } from '@/components/ui/braille-spinner'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import {
|
||||
COMPLETION_DRAWER_BELOW_CLASS,
|
||||
COMPLETION_DRAWER_CLASS,
|
||||
CompletionDrawerEmpty
|
||||
} from './completion-drawer'
|
||||
import { COMPLETION_DRAWER_BELOW_CLASS, COMPLETION_DRAWER_CLASS, CompletionDrawerEmpty } from './completion-drawer'
|
||||
|
||||
const AT_ICON_BY_TYPE: Record<string, string> = {
|
||||
diff: 'diff',
|
||||
@@ -87,7 +83,7 @@ export function ComposerTriggerPopover({
|
||||
{items.length === 0 ? (
|
||||
loading ? (
|
||||
<div className="flex items-center gap-2 px-2 py-1.5 text-(--ui-text-tertiary)">
|
||||
<BrailleSpinner ariaLabel={copy.lookupLoading} className="text-foreground/70" spinner="braille" />
|
||||
<GlyphSpinner ariaLabel={copy.lookupLoading} className="text-foreground/70" spinner="braille" />
|
||||
<span>{copy.lookupLoading}</span>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback } from 'react'
|
||||
|
||||
import { requestComposerFocus, requestComposerInsert } from '@/app/chat/composer/focus'
|
||||
import { requestComposerFocus, requestComposerInsert, requestComposerInsertRefs } from '@/app/chat/composer/focus'
|
||||
import { droppedFileInlineRef } from '@/app/chat/composer/inline-refs'
|
||||
import { formatRefValue } from '@/components/assistant-ui/directive-text'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { attachmentId, contextPath, pathLabel } from '@/lib/chat-runtime'
|
||||
@@ -286,6 +287,26 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
|
||||
[currentCwd]
|
||||
)
|
||||
|
||||
const insertContextPathInlineRef = useCallback(
|
||||
(path: string, isDirectory = false) => {
|
||||
if (!path) {
|
||||
return false
|
||||
}
|
||||
|
||||
const ref = droppedFileInlineRef({ isDirectory, path }, currentCwd)
|
||||
|
||||
if (!ref) {
|
||||
return false
|
||||
}
|
||||
|
||||
requestComposerInsertRefs([ref])
|
||||
requestComposerFocus('main')
|
||||
|
||||
return true
|
||||
},
|
||||
[currentCwd]
|
||||
)
|
||||
|
||||
const attachContextFilePath = useCallback(
|
||||
(filePath: string) => {
|
||||
if (!filePath) {
|
||||
@@ -546,6 +567,7 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
|
||||
attachDroppedItems,
|
||||
attachImageBlob,
|
||||
attachImagePath,
|
||||
insertContextPathInlineRef,
|
||||
pasteClipboardImage,
|
||||
pickContextPaths,
|
||||
pickImages,
|
||||
|
||||
@@ -35,7 +35,9 @@ import {
|
||||
$gatewayState,
|
||||
$introPersonality,
|
||||
$introSeed,
|
||||
$lastVisibleMessageIsUser,
|
||||
$messages,
|
||||
$messagesEmpty,
|
||||
$selectedStoredSessionId,
|
||||
$sessions,
|
||||
sessionPinId
|
||||
@@ -43,7 +45,7 @@ import {
|
||||
import type { ModelOptionsResponse } from '@/types/hermes'
|
||||
|
||||
import { routeSessionId } from '../routes'
|
||||
import { titlebarHeaderBaseClass, titlebarHeaderShadowClass } from '../shell/titlebar'
|
||||
import { titlebarHeaderBaseClass, titlebarHeaderShadowClass, titlebarHeaderTitleClass } from '../shell/titlebar'
|
||||
|
||||
import { ChatDropOverlay } from './chat-drop-overlay'
|
||||
import { ChatSwapOverlay } from './chat-swap-overlay'
|
||||
@@ -53,8 +55,9 @@ import { droppedFileInlineRefs, type SessionDragPayload, sessionInlineRef } from
|
||||
import type { ChatBarState } from './composer/types'
|
||||
import { type DroppedFile, partitionDroppedFiles } from './hooks/use-composer-actions'
|
||||
import { useFileDropZone } from './hooks/use-file-drop-zone'
|
||||
import { ScrollToBottomButton } from './scroll-to-bottom-button'
|
||||
import { SessionActionsMenu } from './sidebar/session-actions-menu'
|
||||
import { lastVisibleMessageIsUser, threadLoadingState } from './thread-loading'
|
||||
import { threadLoadingState } from './thread-loading'
|
||||
|
||||
interface ChatViewProps extends Omit<React.ComponentProps<'div'>, 'onSubmit'> {
|
||||
gateway: HermesGateway | null
|
||||
@@ -80,6 +83,7 @@ interface ChatViewProps extends Omit<React.ComponentProps<'div'>, 'onSubmit'> {
|
||||
onThreadMessagesChange: (messages: readonly ThreadMessage[]) => void
|
||||
onEdit: (message: AppendMessage) => Promise<void>
|
||||
onReload: (parentId: string | null) => Promise<void>
|
||||
onRestoreToMessage?: (messageId: string) => Promise<void>
|
||||
onTranscribeAudio?: (audio: Blob) => Promise<string>
|
||||
}
|
||||
|
||||
@@ -125,7 +129,7 @@ function ChatHeader({
|
||||
return (
|
||||
<header className={cn(titlebarHeaderBaseClass, isRoutedSessionView && titlebarHeaderShadowClass)}>
|
||||
<div
|
||||
className="min-w-0 flex-1"
|
||||
className={titlebarHeaderTitleClass}
|
||||
style={{
|
||||
maxWidth:
|
||||
'calc(100vw - var(--titlebar-content-inset,0px) - var(--titlebar-tools-right) - var(--titlebar-tools-width) - 1.5rem)'
|
||||
@@ -141,7 +145,7 @@ function ChatHeader({
|
||||
title={title}
|
||||
>
|
||||
<Button
|
||||
className="pointer-events-auto flex h-6 min-w-0 max-w-full gap-1 border border-transparent bg-transparent px-2 py-0 text-(--ui-text-secondary) hover:border-(--ui-stroke-tertiary) hover:bg-(--ui-control-hover-background) hover:text-foreground data-[state=open]:border-(--ui-stroke-tertiary) data-[state=open]:bg-(--ui-control-active-background) [-webkit-app-region:no-drag]"
|
||||
className="pointer-events-auto flex h-6 min-w-0 max-w-full gap-1 overflow-hidden border border-transparent bg-transparent px-2 py-0 text-(--ui-text-secondary) hover:border-(--ui-stroke-tertiary) hover:bg-(--ui-control-hover-background) hover:text-foreground data-[state=open]:border-(--ui-stroke-tertiary) data-[state=open]:bg-(--ui-control-active-background) [-webkit-app-region:no-drag]"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
@@ -154,104 +158,42 @@ function ChatHeader({
|
||||
)
|
||||
}
|
||||
|
||||
export function ChatView({
|
||||
className,
|
||||
gateway,
|
||||
onToggleSelectedPin,
|
||||
onDeleteSelectedSession,
|
||||
interface ChatRuntimeBoundaryProps {
|
||||
busy: boolean
|
||||
children: React.ReactNode
|
||||
onCancel: () => Promise<void> | void
|
||||
onEdit: (message: AppendMessage) => Promise<void>
|
||||
onReload: (parentId: string | null) => Promise<void>
|
||||
onThreadMessagesChange: (messages: readonly ThreadMessage[]) => void
|
||||
/** Route points at an unloaded session — render empty until resume swaps in
|
||||
* the new transcript, so the previous session's messages don't linger. */
|
||||
suppressMessages: boolean
|
||||
}
|
||||
|
||||
const NO_MESSAGES: ChatMessage[] = []
|
||||
|
||||
/**
|
||||
* Owns the $messages subscription and the assistant-ui external-store runtime.
|
||||
*
|
||||
* Isolated from ChatView so the per-token delta flush (which replaces the
|
||||
* $messages atom ~30×/s during streaming) only re-renders this component and
|
||||
* the runtime provider. The children (Thread, ChatBar) are created by
|
||||
* ChatView, whose render output is stable across flushes — so React bails out
|
||||
* of re-rendering them by element identity and the stream's render cost stays
|
||||
* confined to the streaming message's own subtree.
|
||||
*/
|
||||
function ChatRuntimeBoundary({
|
||||
busy,
|
||||
children,
|
||||
onCancel,
|
||||
onAddContextRef,
|
||||
onAddUrl,
|
||||
onAttachImageBlob,
|
||||
onAttachDroppedItems,
|
||||
onBranchInNewChat,
|
||||
maxVoiceRecordingSeconds,
|
||||
onPasteClipboardImage,
|
||||
onPickFiles,
|
||||
onPickFolders,
|
||||
onPickImages,
|
||||
onRemoveAttachment,
|
||||
onSteer,
|
||||
onSubmit,
|
||||
onThreadMessagesChange,
|
||||
onEdit,
|
||||
onReload,
|
||||
onTranscribeAudio
|
||||
}: ChatViewProps) {
|
||||
const location = useLocation()
|
||||
const activeSessionId = useStore($activeSessionId)
|
||||
const awaitingResponse = useStore($awaitingResponse)
|
||||
const busy = useStore($busy)
|
||||
const contextSuggestions = useStore($contextSuggestions)
|
||||
const currentCwd = useStore($currentCwd)
|
||||
const currentModel = useStore($currentModel)
|
||||
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)
|
||||
const messages = useStore($messages)
|
||||
const selectedSessionId = useStore($selectedStoredSessionId)
|
||||
onThreadMessagesChange,
|
||||
suppressMessages
|
||||
}: ChatRuntimeBoundaryProps) {
|
||||
const storeMessages = useStore($messages)
|
||||
const messages = suppressMessages ? NO_MESSAGES : storeMessages
|
||||
const runtimeMessageCacheRef = useRef(new WeakMap<ChatMessage, ThreadMessage>())
|
||||
const isRoutedSessionView = Boolean(routeSessionId(location.pathname))
|
||||
|
||||
const showIntro =
|
||||
freshDraftReady && !isRoutedSessionView && !selectedSessionId && !activeSessionId && messages.length === 0
|
||||
|
||||
// Session is still loading if the route references a session we haven't
|
||||
// resumed yet. Once `activeSessionId` is set (runtime has resumed), the
|
||||
// session exists — even if it has zero messages (a brand-new routed
|
||||
// session). The flicker where `busy` flips true briefly during hydrate
|
||||
// is handled by `threadLoadingState`'s last-visible-user gate.
|
||||
const loadingSession = isRoutedSessionView && messages.length === 0 && !activeSessionId
|
||||
const threadLoading = threadLoadingState(loadingSession, busy, awaitingResponse, lastVisibleMessageIsUser(messages))
|
||||
const showChatBar = !loadingSession
|
||||
const threadKey = selectedSessionId || activeSessionId || (isRoutedSessionView ? location.pathname : 'new')
|
||||
|
||||
const modelOptionsQuery = useQuery<ModelOptionsResponse>({
|
||||
queryKey: ['model-options', activeSessionId || 'global'],
|
||||
queryFn: () => {
|
||||
if (!activeSessionId) {
|
||||
return getGlobalModelOptions()
|
||||
}
|
||||
|
||||
if (!gateway) {
|
||||
throw new Error('Hermes gateway unavailable')
|
||||
}
|
||||
|
||||
return gateway.request<ModelOptionsResponse>('model.options', { session_id: activeSessionId })
|
||||
},
|
||||
enabled: gatewayOpen
|
||||
})
|
||||
|
||||
const quickModels = useMemo(
|
||||
() => quickModelOptions(modelOptionsQuery.data, currentProvider, currentModel),
|
||||
[currentModel, currentProvider, modelOptionsQuery.data]
|
||||
)
|
||||
|
||||
const chatBarState = useMemo<ChatBarState>(
|
||||
() => ({
|
||||
model: {
|
||||
model: currentModel,
|
||||
provider: currentProvider,
|
||||
canSwitch: gatewayOpen,
|
||||
loading: !gatewayOpen || (!currentModel && !currentProvider),
|
||||
quickModels
|
||||
},
|
||||
tools: {
|
||||
enabled: true,
|
||||
label: 'Add context',
|
||||
suggestions: contextSuggestions
|
||||
},
|
||||
voice: {
|
||||
enabled: true,
|
||||
active: false
|
||||
}
|
||||
}),
|
||||
[contextSuggestions, currentModel, currentProvider, gatewayOpen, quickModels]
|
||||
)
|
||||
|
||||
const runtimeMessageRepository = useMemo(() => {
|
||||
const items: { message: ThreadMessage; parentId: string | null }[] = []
|
||||
@@ -301,6 +243,120 @@ export function ChatView({
|
||||
onReload
|
||||
})
|
||||
|
||||
return <AssistantRuntimeProvider runtime={runtime}>{children}</AssistantRuntimeProvider>
|
||||
}
|
||||
|
||||
export function ChatView({
|
||||
className,
|
||||
gateway,
|
||||
onToggleSelectedPin,
|
||||
onDeleteSelectedSession,
|
||||
onCancel,
|
||||
onAddContextRef,
|
||||
onAddUrl,
|
||||
onAttachImageBlob,
|
||||
onAttachDroppedItems,
|
||||
onBranchInNewChat,
|
||||
maxVoiceRecordingSeconds,
|
||||
onPasteClipboardImage,
|
||||
onPickFiles,
|
||||
onPickFolders,
|
||||
onPickImages,
|
||||
onRemoveAttachment,
|
||||
onSteer,
|
||||
onSubmit,
|
||||
onThreadMessagesChange,
|
||||
onEdit,
|
||||
onReload,
|
||||
onRestoreToMessage,
|
||||
onTranscribeAudio
|
||||
}: ChatViewProps) {
|
||||
const location = useLocation()
|
||||
const activeSessionId = useStore($activeSessionId)
|
||||
const awaitingResponse = useStore($awaitingResponse)
|
||||
const busy = useStore($busy)
|
||||
const contextSuggestions = useStore($contextSuggestions)
|
||||
const currentCwd = useStore($currentCwd)
|
||||
const currentModel = useStore($currentModel)
|
||||
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)
|
||||
// PERF: ChatView must not subscribe to $messages — the atom is replaced on
|
||||
// every streaming delta flush (~30×/s) and a subscription here re-renders
|
||||
// the entire chat shell (header, chat bar, thread wrapper) per token. The
|
||||
// runtime that DOES need the messages lives in ChatRuntimeBoundary below;
|
||||
// this component only needs streaming-stable derivations.
|
||||
const messagesEmpty = useStore($messagesEmpty)
|
||||
const lastVisibleIsUser = useStore($lastVisibleMessageIsUser)
|
||||
const selectedSessionId = useStore($selectedStoredSessionId)
|
||||
const routedSessionId = routeSessionId(location.pathname)
|
||||
const isRoutedSessionView = Boolean(routedSessionId)
|
||||
|
||||
// The URL points at a session the store hasn't loaded yet (sidebar / cmd-K /
|
||||
// direct nav). Derived in render so the swap reads instantly: the same frame
|
||||
// the id changes we drop the old transcript and show the loader, instead of
|
||||
// waiting for the resume effect (which paints a frame later) to clear them.
|
||||
const routeSessionMismatch = isRoutedSessionView && routedSessionId !== selectedSessionId
|
||||
|
||||
const showIntro = freshDraftReady && !isRoutedSessionView && !selectedSessionId && !activeSessionId && messagesEmpty
|
||||
|
||||
// Session is still loading if the route references a session we haven't
|
||||
// resumed yet. Once `activeSessionId` is set (runtime has resumed), the
|
||||
// session exists — even if it has zero messages (a brand-new routed
|
||||
// session). The flicker where `busy` flips true briefly during hydrate
|
||||
// is handled by `threadLoadingState`'s last-visible-user gate.
|
||||
const loadingSession = isRoutedSessionView && (routeSessionMismatch || (messagesEmpty && !activeSessionId))
|
||||
const threadLoading = threadLoadingState(loadingSession, busy, awaitingResponse, lastVisibleIsUser)
|
||||
const showChatBar = !loadingSession
|
||||
const threadKey = selectedSessionId || activeSessionId || (isRoutedSessionView ? location.pathname : 'new')
|
||||
|
||||
const modelOptionsQuery = useQuery<ModelOptionsResponse>({
|
||||
queryKey: ['model-options', activeSessionId || 'global'],
|
||||
queryFn: () => {
|
||||
if (!activeSessionId) {
|
||||
return getGlobalModelOptions()
|
||||
}
|
||||
|
||||
if (!gateway) {
|
||||
throw new Error('Hermes gateway unavailable')
|
||||
}
|
||||
|
||||
return gateway.request<ModelOptionsResponse>('model.options', { session_id: activeSessionId })
|
||||
},
|
||||
enabled: gatewayOpen
|
||||
})
|
||||
|
||||
const quickModels = useMemo(
|
||||
() => quickModelOptions(modelOptionsQuery.data, currentProvider, currentModel),
|
||||
[currentModel, currentProvider, modelOptionsQuery.data]
|
||||
)
|
||||
|
||||
const chatBarState = useMemo<ChatBarState>(
|
||||
() => ({
|
||||
model: {
|
||||
model: currentModel,
|
||||
provider: currentProvider,
|
||||
canSwitch: gatewayOpen,
|
||||
loading: !gatewayOpen || (!currentModel && !currentProvider),
|
||||
quickModels
|
||||
},
|
||||
tools: {
|
||||
enabled: true,
|
||||
label: 'Add context',
|
||||
suggestions: contextSuggestions
|
||||
},
|
||||
voice: {
|
||||
enabled: true,
|
||||
active: false
|
||||
}
|
||||
}),
|
||||
[contextSuggestions, currentModel, currentProvider, gatewayOpen, quickModels]
|
||||
)
|
||||
|
||||
// Drop files anywhere in the conversation area, not just on the composer
|
||||
// input. In-app drags (project tree / gutter) carry workspace-relative paths
|
||||
// the gateway resolves directly, so they stay inline `@file:` refs. OS/Finder
|
||||
@@ -353,7 +409,14 @@ export function ChatView({
|
||||
className="relative min-h-0 max-w-full flex-1 overflow-hidden bg-(--ui-chat-surface-background) contain-[layout_paint]"
|
||||
{...dropHandlers}
|
||||
>
|
||||
<AssistantRuntimeProvider runtime={runtime}>
|
||||
<ChatRuntimeBoundary
|
||||
busy={busy}
|
||||
onCancel={onCancel}
|
||||
onEdit={onEdit}
|
||||
onReload={onReload}
|
||||
onThreadMessagesChange={onThreadMessagesChange}
|
||||
suppressMessages={routeSessionMismatch}
|
||||
>
|
||||
<Thread
|
||||
clampToComposer={showChatBar}
|
||||
cwd={currentCwd}
|
||||
@@ -362,6 +425,7 @@ export function ChatView({
|
||||
loading={threadLoading}
|
||||
onBranchInNewChat={onBranchInNewChat}
|
||||
onCancel={onCancel}
|
||||
onRestoreToMessage={onRestoreToMessage}
|
||||
sessionId={activeSessionId}
|
||||
sessionKey={threadKey}
|
||||
/>
|
||||
@@ -387,13 +451,14 @@ export function ChatView({
|
||||
onSteer={onSteer}
|
||||
onSubmit={onSubmit}
|
||||
onTranscribeAudio={onTranscribeAudio}
|
||||
queueSessionKey={selectedSessionId || activeSessionId}
|
||||
queueSessionKey={selectedSessionId}
|
||||
sessionId={activeSessionId}
|
||||
state={chatBarState}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</AssistantRuntimeProvider>
|
||||
</ChatRuntimeBoundary>
|
||||
{showChatBar && <ScrollToBottomButton />}
|
||||
<ChatDropOverlay kind={dragKind} />
|
||||
<ChatSwapOverlay profile={gatewaySwapTarget} />
|
||||
</div>
|
||||
|
||||
@@ -10,12 +10,16 @@ import { useEffect, useMemo, useState } from 'react'
|
||||
import ShikiHighlighter from 'react-shiki'
|
||||
import { Streamdown } from 'streamdown'
|
||||
|
||||
import { requestComposerFocus, requestComposerInsertRefs } from '@/app/chat/composer/focus'
|
||||
import { droppedFileInlineRef } from '@/app/chat/composer/inline-refs'
|
||||
import { HERMES_PATHS_MIME } from '@/app/chat/hooks/use-composer-actions'
|
||||
import { isAddSelectionShortcut } from '@/app/right-sidebar/terminal/selection'
|
||||
import { PageLoader } from '@/components/page-loader'
|
||||
import { translateNow, useI18n } from '@/i18n'
|
||||
import { readDesktopFileDataUrl, readDesktopFileText } from '@/lib/desktop-fs'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { PreviewTarget } from '@/store/preview'
|
||||
import { $currentCwd } from '@/store/session'
|
||||
|
||||
const SHIKI_THEME = { dark: 'github-dark-default', light: 'github-light-default' } as const
|
||||
const TEXT_PREVIEW_MAX_BYTES = 512 * 1024
|
||||
@@ -357,6 +361,38 @@ function SourceView({ filePath, language, text }: { filePath: string; language:
|
||||
startLineDrag(event, filePath, inSelection(line) && selection ? selection : { end: line, start: line })
|
||||
}
|
||||
|
||||
// ⌘/Ctrl+L with a line selection drops the same `@line:path:start-end` ref the
|
||||
// gutter drag produces — so the keyboard path mirrors dragging the lines into
|
||||
// the composer. Capture-phase + stopPropagation so it beats the terminal's
|
||||
// global ⌘L handler (which would otherwise grab the native text selection).
|
||||
useEffect(() => {
|
||||
if (!selection) {
|
||||
return
|
||||
}
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (!isAddSelectionShortcut(event)) {
|
||||
return
|
||||
}
|
||||
|
||||
const lineEnd = selection.end > selection.start ? selection.end : undefined
|
||||
const ref = droppedFileInlineRef({ line: selection.start, lineEnd, path: filePath }, $currentCwd.get())
|
||||
|
||||
if (!ref) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
requestComposerInsertRefs([ref])
|
||||
requestComposerFocus('main')
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', onKeyDown, { capture: true })
|
||||
|
||||
return () => window.removeEventListener('keydown', onKeyDown, { capture: true })
|
||||
}, [filePath, selection])
|
||||
|
||||
return (
|
||||
<div className="grid min-w-max grid-cols-[auto_minmax(0,1fr)] font-mono text-xs leading-relaxed">
|
||||
<div className="select-none py-3 text-right text-muted-foreground/55">
|
||||
|
||||
67
apps/desktop/src/app/chat/scroll-to-bottom-button.test.tsx
Normal file
67
apps/desktop/src/app/chat/scroll-to-bottom-button.test.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { clearAllPrompts, setApprovalRequest } from '@/store/prompts'
|
||||
import { $activeSessionId } from '@/store/session'
|
||||
import { onScrollToBottomRequest, resetThreadScroll, setThreadAtBottom } from '@/store/thread-scroll'
|
||||
|
||||
import { ScrollToBottomButton } from './scroll-to-bottom-button'
|
||||
|
||||
function pendingApproval() {
|
||||
$activeSessionId.set('sess-1')
|
||||
setApprovalRequest({ command: 'rm -rf /tmp/x', description: 'dangerous command', sessionId: 'sess-1' })
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
clearAllPrompts()
|
||||
resetThreadScroll()
|
||||
$activeSessionId.set(null)
|
||||
})
|
||||
|
||||
// `getByRole('button')` excludes aria-hidden nodes, so "queryByRole null" is the
|
||||
// control's hidden (parked-at-bottom) state.
|
||||
describe('ScrollToBottomButton', () => {
|
||||
it('stays hidden while parked at the bottom', () => {
|
||||
render(<ScrollToBottomButton />)
|
||||
|
||||
expect(screen.queryByRole('button')).toBeNull()
|
||||
})
|
||||
|
||||
it('is a plain jump-to-bottom control when scrolled up with no approval', () => {
|
||||
setThreadAtBottom(false)
|
||||
render(<ScrollToBottomButton />)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Scroll to bottom' })).toBeTruthy()
|
||||
expect(screen.queryByText('Approval needed')).toBeNull()
|
||||
})
|
||||
|
||||
it('morphs into the approval pill when scrolled up with a pending approval', () => {
|
||||
pendingApproval()
|
||||
setThreadAtBottom(false)
|
||||
render(<ScrollToBottomButton />)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Approval needed' })).toBeTruthy()
|
||||
expect(screen.getByText('Approval needed')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('does not morph while a pending approval is still in view (at bottom)', () => {
|
||||
pendingApproval()
|
||||
render(<ScrollToBottomButton />)
|
||||
|
||||
// Parked at bottom → control hidden, so it can't claim "approval needed".
|
||||
expect(screen.queryByRole('button')).toBeNull()
|
||||
})
|
||||
|
||||
it('re-arms sticky-bottom on click', () => {
|
||||
const handler = vi.fn()
|
||||
const stop = onScrollToBottomRequest(handler)
|
||||
setThreadAtBottom(false)
|
||||
render(<ScrollToBottomButton />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(1)
|
||||
stop()
|
||||
})
|
||||
})
|
||||
74
apps/desktop/src/app/chat/scroll-to-bottom-button.tsx
Normal file
74
apps/desktop/src/app/chat/scroll-to-bottom-button.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useRef } from 'react'
|
||||
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $approvalRequest } from '@/store/prompts'
|
||||
import { $threadJumpButtonVisible, requestScrollToBottom } from '@/store/thread-scroll'
|
||||
|
||||
/**
|
||||
* Floating "jump to bottom" control. Sits centered just above the composer,
|
||||
* clearing the out-of-flow status stack via the same measured-height CSS vars
|
||||
* the thread's bottom clearance uses (`--composer-measured-height` +
|
||||
* `--status-stack-measured-height`), so it never overlaps the queue / subagent
|
||||
* / background cards. Visible only while the user has scrolled meaningfully
|
||||
* away from the bottom; clicking re-arms sticky-bottom and pins the viewport.
|
||||
*
|
||||
* When the turn is BLOCKED on an approval, this same control morphs into an
|
||||
* "Approval needed" pill — the only response surface is the inline Run/Reject
|
||||
* bar on the parked tool row, which is always the bottom-most content, so the
|
||||
* existing scroll-to-bottom action lands the user right on it. One control, no
|
||||
* collision, no second scroll path (native scrollIntoView would scroll
|
||||
* overflow:hidden ancestors that can't scroll back and wreck the layout).
|
||||
*
|
||||
* Enter/exit motion lives in styles.css under `.thread-jump-button` — a
|
||||
* directional scale (contract in from 1.1, contract out to 0.9) keyed off
|
||||
* `data-state`. `idle` (never-shown) stays silent so it can't flash on mount;
|
||||
* `in`/`out` only swap once it has actually appeared.
|
||||
*/
|
||||
export function ScrollToBottomButton() {
|
||||
const { t } = useI18n()
|
||||
const visible = useStore($threadJumpButtonVisible)
|
||||
const request = useStore($approvalRequest)
|
||||
// Scrolled away while an approval is pending → the inline Run/Reject bar is
|
||||
// below the fold. Relabel so the user knows the session needs them, not just
|
||||
// that there's more to read.
|
||||
const approval = visible && Boolean(request)
|
||||
const hasShownRef = useRef(false)
|
||||
|
||||
if (visible) {
|
||||
hasShownRef.current = true
|
||||
}
|
||||
|
||||
const state = visible ? 'in' : hasShownRef.current ? 'out' : 'idle'
|
||||
const label = approval ? t.assistant.approval.jumpToApproval : t.assistant.thread.scrollToBottom
|
||||
|
||||
return (
|
||||
<button
|
||||
aria-hidden={!visible}
|
||||
aria-label={label}
|
||||
className={cn(
|
||||
'thread-jump-button absolute left-1/2 z-20 grid place-items-center backdrop-blur-[0.75rem] [-webkit-backdrop-filter:blur(0.75rem)]',
|
||||
approval
|
||||
? 'h-8 grid-flow-col gap-1.5 rounded-full border border-primary/40 bg-(--composer-fill) px-3 text-primary hover:bg-primary/10'
|
||||
: 'size-8 rounded-full border border-border/65 bg-(--composer-fill) text-muted-foreground hover:text-foreground',
|
||||
!visible && 'pointer-events-none'
|
||||
)}
|
||||
data-state={state}
|
||||
onClick={() => {
|
||||
triggerHaptic('selection')
|
||||
requestScrollToBottom()
|
||||
}}
|
||||
style={{
|
||||
bottom: 'calc(var(--composer-measured-height) + var(--status-stack-measured-height) + 0.625rem)'
|
||||
}}
|
||||
tabIndex={visible ? 0 : -1}
|
||||
type="button"
|
||||
>
|
||||
<Codicon name="arrow-down" size={approval ? '0.875rem' : '1rem'} />
|
||||
{approval && <span className="text-xs font-medium">{label}</span>}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -168,7 +168,7 @@ export function SidebarCronJobsSection({
|
||||
</button>
|
||||
</div>
|
||||
{open && (
|
||||
<SidebarGroupContent className="flex max-h-72 flex-col gap-px overflow-y-auto overscroll-contain pb-1.75 compact:max-h-none compact:overflow-visible">
|
||||
<SidebarGroupContent className="flex max-h-72 flex-col gap-px overflow-x-hidden overflow-y-auto overscroll-contain pb-1.75 compact:max-h-none compact:overflow-visible">
|
||||
{shown.map(job => (
|
||||
<CronJobSidebarRow
|
||||
expanded={peekJobId === job.id}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
21
apps/desktop/src/app/chat/sidebar/order.test.ts
Normal file
21
apps/desktop/src/app/chat/sidebar/order.test.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { resolveManualSessionOrderIds } from './order'
|
||||
|
||||
describe('resolveManualSessionOrderIds', () => {
|
||||
it('clears legacy auto-seeded order until the user manually reorders sessions', () => {
|
||||
expect(resolveManualSessionOrderIds(['newest', 'older'], ['older', 'newest'], false)).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps a manual order and surfaces newly seen sessions first', () => {
|
||||
expect(resolveManualSessionOrderIds(['newest', 'older', 'oldest'], ['oldest', 'older'], true)).toEqual([
|
||||
'newest',
|
||||
'oldest',
|
||||
'older'
|
||||
])
|
||||
})
|
||||
|
||||
it('clears manual order when none of the saved ids still exist', () => {
|
||||
expect(resolveManualSessionOrderIds(['newest'], ['gone'], true)).toEqual([])
|
||||
})
|
||||
})
|
||||
17
apps/desktop/src/app/chat/sidebar/order.ts
Normal file
17
apps/desktop/src/app/chat/sidebar/order.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export function resolveManualSessionOrderIds(currentIds: string[], orderIds: string[], manual: boolean): string[] {
|
||||
if (!manual || !currentIds.length || !orderIds.length) {
|
||||
return []
|
||||
}
|
||||
|
||||
const current = new Set(currentIds)
|
||||
const retained = orderIds.filter(id => current.has(id))
|
||||
|
||||
if (!retained.length) {
|
||||
return []
|
||||
}
|
||||
|
||||
const retainedSet = new Set(retained)
|
||||
const fresh = currentIds.filter(id => !retainedSet.has(id))
|
||||
|
||||
return [...fresh, ...retained]
|
||||
}
|
||||
@@ -284,6 +284,7 @@ export function ProfileRail() {
|
||||
selectProfile(name)
|
||||
}}
|
||||
open={createOpen}
|
||||
profiles={profiles}
|
||||
/>
|
||||
|
||||
<RenameProfileDialog
|
||||
@@ -467,6 +468,10 @@ function ProfileSquare({ active, color, label, onDelete, onRecolor, onRename, on
|
||||
aria-label={p.actionsFor(label)}
|
||||
className="w-40"
|
||||
collisionPadding={{ bottom: 44, left: 8, right: 8, top: 8 }}
|
||||
// Menu close refocuses the trigger — which doubles as the popover
|
||||
// anchor — so the picker reads it as focus-outside and dies on open.
|
||||
// Suppress the refocus and the picker survives.
|
||||
onCloseAutoFocus={event => event.preventDefault()}
|
||||
>
|
||||
<ContextMenuItem onSelect={() => setPickerOpen(true)}>
|
||||
<Codicon name="symbol-color" size="0.875rem" />
|
||||
|
||||
@@ -96,7 +96,9 @@ export function SidebarSessionRow({
|
||||
'group relative grid min-h-[1.625rem] cursor-pointer grid-cols-[minmax(0,1fr)_1.375rem] items-center rounded-md transition-colors duration-100 ease-out hover:bg-(--ui-row-hover-background) hover:transition-none',
|
||||
isSelected && 'bg-(--ui-row-active-background)',
|
||||
isWorking && 'text-foreground',
|
||||
dragging && 'z-10 cursor-grabbing opacity-60 shadow-sm',
|
||||
// Opaque surface while lifted so the dragged row erases what's under
|
||||
// it (translucency let the rows below bleed through).
|
||||
dragging && 'z-10 cursor-grabbing bg-(--ui-sidebar-surface-background)',
|
||||
className
|
||||
)}
|
||||
data-working={isWorking ? 'true' : undefined}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable'
|
||||
import { useSortable } from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||
import { type FC, useCallback, useMemo, useRef } from 'react'
|
||||
import { type FC, useCallback, useRef } from 'react'
|
||||
|
||||
import type { SessionInfo } from '@/hermes'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -48,7 +48,6 @@ export const VirtualSessionList: FC<VirtualSessionListProps> = ({
|
||||
workingSessionIdSet
|
||||
}) => {
|
||||
const scrollerRef = useRef<HTMLDivElement | null>(null)
|
||||
const ids = useMemo(() => sessions.map(s => s.id), [sessions])
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: sessions.length,
|
||||
@@ -101,21 +100,16 @@ export const VirtualSessionList: FC<VirtualSessionListProps> = ({
|
||||
)
|
||||
})
|
||||
|
||||
const list = (
|
||||
<div className={cn('relative min-h-0 flex-1 overflow-y-auto overscroll-contain', className)} ref={scrollerRef}>
|
||||
// When sortable, the caller wraps this in a ReorderableList that owns the
|
||||
// DndContext + SortableContext (keyed on the same ids); the virtualized rows
|
||||
// just consume that context via useSortable.
|
||||
return (
|
||||
<div className={cn('relative min-h-0 flex-1 overflow-x-hidden overflow-y-auto overscroll-contain', className)} ref={scrollerRef}>
|
||||
<div className="grid gap-px" style={{ paddingBottom: `${paddingBottom}px`, paddingTop: `${paddingTop}px` }}>
|
||||
{rows}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return sortable ? (
|
||||
<SortableContext items={ids} strategy={verticalListSortingStrategy}>
|
||||
{list}
|
||||
</SortableContext>
|
||||
) : (
|
||||
list
|
||||
)
|
||||
}
|
||||
|
||||
interface VirtualSortableRowProps {
|
||||
|
||||
149
apps/desktop/src/app/chat/sidebar/workspace-groups.test.ts
Normal file
149
apps/desktop/src/app/chat/sidebar/workspace-groups.test.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { HermesWorktreeInfo } from '@/global'
|
||||
import type { SessionInfo } from '@/types/hermes'
|
||||
|
||||
import { uniqueCwds, workspaceGroupsFor, workspaceTreeFor, type WorktreeResolver } from './workspace-groups'
|
||||
|
||||
let nextId = 0
|
||||
|
||||
function makeSession(cwd: null | string, overrides: Partial<SessionInfo> = {}): SessionInfo {
|
||||
return {
|
||||
archived: false,
|
||||
cwd,
|
||||
ended_at: null,
|
||||
id: `s${nextId++}`,
|
||||
input_tokens: 0,
|
||||
is_active: false,
|
||||
last_active: 1_000,
|
||||
message_count: 1,
|
||||
model: 'claude',
|
||||
output_tokens: 0,
|
||||
preview: null,
|
||||
source: 'cli',
|
||||
started_at: 1_000,
|
||||
title: null,
|
||||
tool_call_count: 0,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
const labels = (sessions: SessionInfo[]) => workspaceGroupsFor(sessions, 'No workspace').map(g => g.label)
|
||||
|
||||
describe('workspaceGroupsFor', () => {
|
||||
it('groups by full cwd, not by basename — same-named folders are separate groups', () => {
|
||||
const groups = workspaceGroupsFor(
|
||||
[makeSession('/a/hermes-agent/apps/desktop'), makeSession('/a/hermes-agent-wt-rtl/apps/desktop')],
|
||||
'No workspace'
|
||||
)
|
||||
|
||||
expect(groups).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('disambiguates colliding basenames by walking up the path', () => {
|
||||
expect(
|
||||
labels([makeSession('/a/hermes-agent/apps/desktop'), makeSession('/a/hermes-agent-wt-rtl/apps/desktop')])
|
||||
).toEqual(['hermes-agent/apps/desktop', 'hermes-agent-wt-rtl/apps/desktop'])
|
||||
})
|
||||
|
||||
it('leaves a unique basename as its short label', () => {
|
||||
expect(labels([makeSession('/a/hermes-agent/apps/desktop'), makeSession('/b/heval-py')])).toEqual([
|
||||
'desktop',
|
||||
'heval-py'
|
||||
])
|
||||
})
|
||||
|
||||
it('grows the prefix past one segment when the parent also collides', () => {
|
||||
expect(labels([makeSession('/x/proj/apps/desktop'), makeSession('/y/proj/apps/desktop')])).toEqual([
|
||||
'x/proj/apps/desktop',
|
||||
'y/proj/apps/desktop'
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps the synthetic no-workspace group untouched even if a real group shares its label', () => {
|
||||
const groups = workspaceGroupsFor([makeSession(null), makeSession('/a/No workspace')], 'No workspace')
|
||||
const noWorkspace = groups.find(g => g.path === null)
|
||||
|
||||
expect(noWorkspace?.label).toBe('No workspace')
|
||||
})
|
||||
})
|
||||
|
||||
const info = (over: Partial<HermesWorktreeInfo> & Pick<HermesWorktreeInfo, 'repoRoot' | 'worktreeRoot'>): HermesWorktreeInfo => ({
|
||||
branch: null,
|
||||
isMainWorktree: false,
|
||||
...over
|
||||
})
|
||||
|
||||
describe('workspaceTreeFor', () => {
|
||||
it('heuristic nests `<repo>-wt-<branch>` under its sibling repo', () => {
|
||||
const tree = workspaceTreeFor(
|
||||
[makeSession('/www/hermes-agent'), makeSession('/www/hermes-agent-wt-rtl')],
|
||||
'No workspace'
|
||||
)
|
||||
|
||||
expect(tree).toHaveLength(1)
|
||||
expect(tree[0].label).toBe('hermes-agent')
|
||||
expect(tree[0].groups.map(g => g.label).sort()).toEqual(['hermes-agent', 'rtl'])
|
||||
})
|
||||
|
||||
it('git metadata is authoritative — worktrees group by repoRoot regardless of directory naming', () => {
|
||||
const resolver: WorktreeResolver = cwd => {
|
||||
if (cwd === '/www/hermes-agent') {
|
||||
return info({ repoRoot: '/www/hermes-agent', worktreeRoot: '/www/hermes-agent', isMainWorktree: true, branch: 'main' })
|
||||
}
|
||||
|
||||
if (cwd === '/elsewhere/ha-rtl') {
|
||||
return info({ repoRoot: '/www/hermes-agent', worktreeRoot: '/elsewhere/ha-rtl', branch: 'rtl' })
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const tree = workspaceTreeFor(
|
||||
[makeSession('/www/hermes-agent'), makeSession('/elsewhere/ha-rtl')],
|
||||
'No workspace',
|
||||
resolver
|
||||
)
|
||||
|
||||
expect(tree).toHaveLength(1)
|
||||
expect(tree[0].label).toBe('hermes-agent')
|
||||
// The main checkout labels by directory (its branch is transient — using it
|
||||
// would misattribute old sessions to the currently checked-out branch);
|
||||
// linked worktrees label by branch.
|
||||
expect(tree[0].groups.map(g => g.label)).toEqual(['hermes-agent', 'rtl'])
|
||||
})
|
||||
|
||||
it('a standalone directory is its own parent (always parent → worktree → sessions)', () => {
|
||||
const tree = workspaceTreeFor([makeSession('/www/heval-node')], 'No workspace')
|
||||
|
||||
expect(tree).toHaveLength(1)
|
||||
expect(tree[0].label).toBe('heval-node')
|
||||
expect(tree[0].groups).toHaveLength(1)
|
||||
expect(tree[0].groups[0].label).toBe('heval-node')
|
||||
})
|
||||
|
||||
it('aggregates session counts across a repo’s worktrees', () => {
|
||||
const tree = workspaceTreeFor(
|
||||
[makeSession('/www/ha'), makeSession('/www/ha-wt-x'), makeSession('/www/ha-wt-x')],
|
||||
'No workspace'
|
||||
)
|
||||
|
||||
const parent = tree.find(p => p.label === 'ha')
|
||||
|
||||
expect(parent?.sessionCount).toBe(3)
|
||||
})
|
||||
|
||||
it('no-workspace sessions form their own parent', () => {
|
||||
const tree = workspaceTreeFor([makeSession(null)], 'No workspace')
|
||||
|
||||
expect(tree).toHaveLength(1)
|
||||
expect(tree[0].label).toBe('No workspace')
|
||||
expect(tree[0].path).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('uniqueCwds', () => {
|
||||
it('dedupes and drops empty/whitespace cwds', () => {
|
||||
expect(uniqueCwds([makeSession('/a'), makeSession('/a'), makeSession(null), makeSession(' ')])).toEqual(['/a'])
|
||||
})
|
||||
})
|
||||
326
apps/desktop/src/app/chat/sidebar/workspace-groups.ts
Normal file
326
apps/desktop/src/app/chat/sidebar/workspace-groups.ts
Normal file
@@ -0,0 +1,326 @@
|
||||
import type { HermesWorktreeInfo } from '@/global'
|
||||
import type { SessionInfo } from '@/hermes'
|
||||
|
||||
export interface SidebarSessionGroup {
|
||||
id: string
|
||||
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' | 'source' | 'workspace'
|
||||
onLoadMore?: () => void
|
||||
sourceId?: string
|
||||
totalCount?: number
|
||||
}
|
||||
|
||||
const NO_WORKSPACE_ID = '__no_workspace__'
|
||||
|
||||
/** Path split into segments, ignoring trailing slashes and mixed separators. */
|
||||
const segments = (path: string): string[] => path.replace(/[/\\]+$/, '').split(/[/\\]/).filter(Boolean)
|
||||
|
||||
/** Last path segment. */
|
||||
export const baseName = (path: string): string | undefined => segments(path).pop()
|
||||
|
||||
/** The segments above the basename. */
|
||||
const parentSegments = (path: string): string[] => segments(path).slice(0, -1)
|
||||
|
||||
interface Labelable {
|
||||
id: string
|
||||
label: string
|
||||
path: null | string
|
||||
}
|
||||
|
||||
/**
|
||||
* Disambiguate groups whose basename collides (worktrees all end in the same
|
||||
* `apps/desktop`, sibling repos share a folder name, etc.) by walking up the
|
||||
* path and prepending parent segments until each colliding label is unique —
|
||||
* e.g. `hermes-agent/desktop` vs `hermes-agent-wt-rtl/desktop`. Groups with a
|
||||
* unique basename keep their short label untouched.
|
||||
*/
|
||||
function disambiguateLabels(groups: Labelable[]): void {
|
||||
const byLabel = new Map<string, Labelable[]>()
|
||||
|
||||
for (const group of groups) {
|
||||
const bucket = byLabel.get(group.label)
|
||||
|
||||
if (bucket) {
|
||||
bucket.push(group)
|
||||
} else {
|
||||
byLabel.set(group.label, [group])
|
||||
}
|
||||
}
|
||||
|
||||
for (const bucket of byLabel.values()) {
|
||||
if (bucket.length < 2) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Only groups backed by a real path can grow a prefix; the synthetic
|
||||
// "No workspace" group has no path and stays as-is.
|
||||
const pathed = bucket.filter(group => group.path)
|
||||
|
||||
if (pathed.length < 2) {
|
||||
continue
|
||||
}
|
||||
|
||||
const parents = new Map(pathed.map(group => [group.id, parentSegments(group.path!)]))
|
||||
let depth = 1
|
||||
|
||||
// Grow the prefix one parent segment at a time until every label in the
|
||||
// bucket is distinct, or we run out of parent segments to add.
|
||||
while (depth <= Math.max(...pathed.map(g => parents.get(g.id)!.length))) {
|
||||
const labels = new Map<string, number>()
|
||||
|
||||
for (const group of pathed) {
|
||||
const segs = parents.get(group.id)!
|
||||
const prefix = segs.slice(-depth).join('/')
|
||||
const base = baseName(group.path!) ?? group.path!
|
||||
group.label = prefix ? `${prefix}/${base}` : base
|
||||
labels.set(group.label, (labels.get(group.label) ?? 0) + 1)
|
||||
}
|
||||
|
||||
if ([...labels.values()].every(count => count === 1)) {
|
||||
break
|
||||
}
|
||||
|
||||
depth += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function workspaceGroupsFor(
|
||||
sessions: SessionInfo[],
|
||||
noWorkspaceLabel: string,
|
||||
options: { preserveSessionOrder?: boolean } = {}
|
||||
): SidebarSessionGroup[] {
|
||||
const groups = new Map<string, SidebarSessionGroup>()
|
||||
|
||||
for (const session of sessions) {
|
||||
const path = session.cwd?.trim() || ''
|
||||
const id = path || NO_WORKSPACE_ID
|
||||
const label = baseName(path) || path || noWorkspaceLabel
|
||||
|
||||
const group = groups.get(id) ?? { id, label, path: path || null, sessions: [] }
|
||||
group.sessions.push(session)
|
||||
groups.set(id, group)
|
||||
}
|
||||
|
||||
if (!options.preserveSessionOrder) {
|
||||
// Groups keep recency order (Map insertion = first-seen in the recency-sorted
|
||||
// input, so an active project floats up), but rows *within* a group sort by
|
||||
// creation time so they don't reshuffle every time a message lands — keeps
|
||||
// muscle memory intact.
|
||||
for (const group of groups.values()) {
|
||||
group.sessions.sort((a, b) => b.started_at - a.started_at)
|
||||
}
|
||||
}
|
||||
|
||||
const result = [...groups.values()]
|
||||
disambiguateLabels(result)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* A worktree's main repo and all its linked worktrees collapse into ONE parent
|
||||
* (keyed by the repo root); each worktree is a child group; sessions hang off
|
||||
* the worktree they ran in. `parent → worktree → sessions`.
|
||||
*/
|
||||
export interface SidebarWorkspaceTree {
|
||||
id: string
|
||||
label: string
|
||||
path: null | string
|
||||
groups: SidebarSessionGroup[]
|
||||
sessionCount: number
|
||||
}
|
||||
|
||||
/** Resolves a session cwd to git-worktree identity (from the local fs probe). */
|
||||
export type WorktreeResolver = (cwd: string) => HermesWorktreeInfo | null | undefined
|
||||
|
||||
interface WorkspacePlacement {
|
||||
parentKey: string
|
||||
parentLabel: string
|
||||
parentPath: string
|
||||
worktreeKey: string
|
||||
worktreeLabel: string
|
||||
worktreePath: string
|
||||
}
|
||||
|
||||
/** Replace a path's final segment, preserving its prefix + separators. */
|
||||
const withBaseName = (path: string, name: string): string =>
|
||||
path.replace(/[/\\]+$/, '').replace(/[^/\\]+$/, name)
|
||||
|
||||
/**
|
||||
* Path-only fallback for when git metadata is unavailable (remote backends,
|
||||
* unreadable paths). Mirrors the git layout: a `<repo>-wt-<branch>` directory
|
||||
* nests under its sibling `<repo>`; any other directory is its own repo root.
|
||||
*/
|
||||
function placeByHeuristic(path: string): WorkspacePlacement | null {
|
||||
const base = baseName(path)
|
||||
|
||||
if (!base) {
|
||||
return null
|
||||
}
|
||||
|
||||
const worktreeMatch = base.match(/^(.+)-wt-(.+)$/)
|
||||
|
||||
if (worktreeMatch) {
|
||||
const repo = worktreeMatch[1]
|
||||
const repoPath = withBaseName(path, repo)
|
||||
|
||||
return {
|
||||
parentKey: repoPath,
|
||||
parentLabel: repo,
|
||||
parentPath: repoPath,
|
||||
worktreeKey: path,
|
||||
worktreeLabel: worktreeMatch[2],
|
||||
worktreePath: path
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
parentKey: path,
|
||||
parentLabel: base,
|
||||
parentPath: path,
|
||||
worktreeKey: path,
|
||||
worktreeLabel: base,
|
||||
worktreePath: path
|
||||
}
|
||||
}
|
||||
|
||||
function placeWorkspace(path: string, resolver?: WorktreeResolver): WorkspacePlacement | null {
|
||||
const info = resolver?.(path)
|
||||
|
||||
if (info?.repoRoot && info.worktreeRoot) {
|
||||
const dirLabel = baseName(info.worktreeRoot) || info.worktreeRoot
|
||||
|
||||
return {
|
||||
parentKey: info.repoRoot,
|
||||
parentLabel: baseName(info.repoRoot) ?? info.repoRoot,
|
||||
parentPath: info.repoRoot,
|
||||
worktreeKey: info.worktreeRoot,
|
||||
// The main checkout's branch is transient — it changes as you work, so a
|
||||
// branch label would misattribute every past session to whatever branch
|
||||
// is checked out *now*. Label it by directory. Linked worktrees are
|
||||
// per-branch by construction, so branch is the clearest label there.
|
||||
worktreeLabel: info.isMainWorktree ? dirLabel : info.branch || dirLabel,
|
||||
worktreePath: info.worktreeRoot
|
||||
}
|
||||
}
|
||||
|
||||
return placeByHeuristic(path)
|
||||
}
|
||||
|
||||
/** Unique, non-empty session cwds — the batch to probe for worktree info. */
|
||||
export function uniqueCwds(sessions: SessionInfo[]): string[] {
|
||||
const seen = new Set<string>()
|
||||
|
||||
for (const session of sessions) {
|
||||
const path = session.cwd?.trim()
|
||||
|
||||
if (path) {
|
||||
seen.add(path)
|
||||
}
|
||||
}
|
||||
|
||||
return [...seen]
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the `parent → worktree → sessions` tree. Parents keep recency order
|
||||
* (first-seen in the recency-sorted input); worktree groups within a parent do
|
||||
* too, while rows inside a worktree sort by creation time (stable muscle memory,
|
||||
* matching `workspaceGroupsFor`).
|
||||
*/
|
||||
export function workspaceTreeFor(
|
||||
sessions: SessionInfo[],
|
||||
noWorkspaceLabel: string,
|
||||
resolver?: WorktreeResolver,
|
||||
options: { preserveSessionOrder?: boolean } = {}
|
||||
): SidebarWorkspaceTree[] {
|
||||
interface WorktreeEntry {
|
||||
group: SidebarSessionGroup
|
||||
parentKey: string
|
||||
parentLabel: string
|
||||
parentPath: string
|
||||
}
|
||||
|
||||
const worktrees = new Map<string, WorktreeEntry>()
|
||||
const noWorkspace: SessionInfo[] = []
|
||||
|
||||
for (const session of sessions) {
|
||||
const path = session.cwd?.trim() || ''
|
||||
|
||||
if (!path) {
|
||||
noWorkspace.push(session)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
const placement = placeWorkspace(path, resolver)
|
||||
|
||||
if (!placement) {
|
||||
noWorkspace.push(session)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
let entry = worktrees.get(placement.worktreeKey)
|
||||
|
||||
if (!entry) {
|
||||
entry = {
|
||||
group: { id: placement.worktreeKey, label: placement.worktreeLabel, path: placement.worktreePath, sessions: [] },
|
||||
parentKey: placement.parentKey,
|
||||
parentLabel: placement.parentLabel,
|
||||
parentPath: placement.parentPath
|
||||
}
|
||||
worktrees.set(placement.worktreeKey, entry)
|
||||
}
|
||||
|
||||
entry.group.sessions.push(session)
|
||||
}
|
||||
|
||||
if (!options.preserveSessionOrder) {
|
||||
for (const entry of worktrees.values()) {
|
||||
entry.group.sessions.sort((a, b) => b.started_at - a.started_at)
|
||||
}
|
||||
}
|
||||
|
||||
const parents = new Map<string, SidebarWorkspaceTree>()
|
||||
|
||||
for (const entry of worktrees.values()) {
|
||||
let parent = parents.get(entry.parentKey)
|
||||
|
||||
if (!parent) {
|
||||
parent = { id: entry.parentKey, label: entry.parentLabel, path: entry.parentPath, groups: [], sessionCount: 0 }
|
||||
parents.set(entry.parentKey, parent)
|
||||
}
|
||||
|
||||
parent.groups.push(entry.group)
|
||||
parent.sessionCount += entry.group.sessions.length
|
||||
}
|
||||
|
||||
const result = [...parents.values()]
|
||||
|
||||
if (noWorkspace.length) {
|
||||
result.push({
|
||||
id: NO_WORKSPACE_ID,
|
||||
label: noWorkspaceLabel,
|
||||
path: null,
|
||||
groups: [{ id: NO_WORKSPACE_ID, label: noWorkspaceLabel, path: null, sessions: noWorkspace }],
|
||||
sessionCount: noWorkspace.length
|
||||
})
|
||||
}
|
||||
|
||||
// Parents that collide on basename grow a path prefix; worktree labels that
|
||||
// collide inside a parent do the same.
|
||||
disambiguateLabels(result)
|
||||
|
||||
for (const parent of result) {
|
||||
disambiguateLabels(parent.groups)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -3,9 +3,14 @@ import type { ChatMessage } from '@/lib/chat-messages'
|
||||
export type ThreadLoadingState = 'response' | 'session'
|
||||
|
||||
export function lastVisibleMessageIsUser(messages: ChatMessage[]): boolean {
|
||||
const lastVisible = [...messages].reverse().find(message => !message.hidden)
|
||||
// Allocation-free reverse scan — runs in a hot $messages computed.
|
||||
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
||||
if (!messages[i].hidden) {
|
||||
return messages[i].role === 'user'
|
||||
}
|
||||
}
|
||||
|
||||
return lastVisible?.role === 'user'
|
||||
return false
|
||||
}
|
||||
|
||||
export function threadLoadingState(
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useNavigate } from 'react-router-dom'
|
||||
import { HUD_HEADING, HUD_ITEM, HUD_POSITION, HUD_SURFACE, HUD_TEXT } from '@/app/floating-hud'
|
||||
import { setTerminalTakeover } from '@/app/right-sidebar/store'
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
|
||||
import { KbdGroup } from '@/components/ui/kbd'
|
||||
import { KbdCombo } from '@/components/ui/kbd'
|
||||
import { getHermesConfigRecord, listAllProfileSessions } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { sessionTitle } from '@/lib/chat-runtime'
|
||||
@@ -38,7 +38,6 @@ import {
|
||||
Wrench,
|
||||
Zap
|
||||
} from '@/lib/icons'
|
||||
import { comboTokens } from '@/lib/keybinds/combo'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $commandPaletteOpen, closeCommandPalette, setCommandPaletteOpen } from '@/store/command-palette'
|
||||
import { $bindings } from '@/store/keybinds'
|
||||
@@ -119,6 +118,10 @@ const paletteFilter = (value: string, search: string, keywords?: string[]): numb
|
||||
return needle.split(/\s+/).every(term => haystack.includes(term)) ? 1 : 0
|
||||
}
|
||||
|
||||
// Hermes session ids: <YYYYMMDD>_<HHMMSS>_<6 hex>. Used to offer a direct
|
||||
// "Go to session ‹id›" jump for ids that aren't in the recent-200 list.
|
||||
const SESSION_ID_RE = /^\d{8}_\d{6}_[a-f0-9]{6}$/
|
||||
|
||||
type SessionRow = Awaited<ReturnType<typeof listAllProfileSessions>>['sessions'][number]
|
||||
|
||||
const toSessionEntry = (session: SessionRow): SessionEntry => ({
|
||||
@@ -414,6 +417,24 @@ export function CommandPalette() {
|
||||
|
||||
const result: PaletteGroup[] = []
|
||||
|
||||
// Paste a raw session id → jump straight to it, even if it predates the
|
||||
// recent-200 window the lists below are built from.
|
||||
const directId = search.trim()
|
||||
|
||||
if (SESSION_ID_RE.test(directId)) {
|
||||
result.push({
|
||||
items: [
|
||||
{
|
||||
icon: MessageCircle,
|
||||
id: `goto-${directId}`,
|
||||
keywords: ['session', 'id', 'go to', directId],
|
||||
label: `${t.commandCenter.goToSession} ${directId}`,
|
||||
run: go(sessionRoute(directId))
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
if (sessions.length > 0) {
|
||||
result.push({
|
||||
heading: t.commandCenter.sections.sessions,
|
||||
@@ -620,7 +641,6 @@ export function CommandPalette() {
|
||||
{group.items.map(item => {
|
||||
const Icon = item.icon
|
||||
const combo = item.action ? bindings[item.action]?.[0] : undefined
|
||||
const keys = combo ? comboTokens(combo) : null
|
||||
|
||||
return (
|
||||
<CommandItem
|
||||
@@ -632,10 +652,10 @@ export function CommandPalette() {
|
||||
>
|
||||
<Icon className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">{item.label}</span>
|
||||
{keys && <KbdGroup className="ml-auto" keys={keys} />}
|
||||
{combo && <KbdCombo className="ml-auto opacity-55" combo={combo} size="sm" />}
|
||||
{item.to && (
|
||||
<ChevronRight
|
||||
className={cn('size-3.5 shrink-0 text-muted-foreground/70', !keys && 'ml-auto')}
|
||||
className={cn('size-3.5 shrink-0 text-muted-foreground/70', !combo && 'ml-auto')}
|
||||
/>
|
||||
)}
|
||||
</CommandItem>
|
||||
|
||||
@@ -11,7 +11,6 @@ import { Pane, PaneMain } from '@/components/pane-shell'
|
||||
import { useMediaQuery } from '@/hooks/use-media-query'
|
||||
import { useSkinCommand } from '@/themes/use-skin-command'
|
||||
|
||||
import { requestComposerFocus, requestComposerInsert } from './chat/composer/focus'
|
||||
import { formatRefValue } from '../components/assistant-ui/directive-text'
|
||||
import { getCronJobs, getSessionMessages, listAllProfileSessions, type SessionInfo, triggerCronJob } from '../hermes'
|
||||
import { preserveLocalAssistantErrors, toChatMessages } from '../lib/chat-messages'
|
||||
@@ -21,6 +20,7 @@ import {
|
||||
MESSAGING_SESSION_SOURCE_IDS,
|
||||
normalizeSessionSource
|
||||
} from '../lib/session-source'
|
||||
import { latestSessionTodos } from '../lib/todos'
|
||||
import { setCronFocusJobId, setCronJobs } from '../store/cron'
|
||||
import {
|
||||
$panesFlipped,
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
SIDEBAR_SESSIONS_PAGE_SIZE,
|
||||
unpinSession
|
||||
} from '../store/layout'
|
||||
import { respondToApprovalAction } from '../store/native-notifications'
|
||||
import { $filePreviewTarget, $previewTarget, closeActiveRightRailTab } from '../store/preview'
|
||||
import {
|
||||
$activeGatewayProfile,
|
||||
@@ -76,10 +77,12 @@ import {
|
||||
setSessionsLoading,
|
||||
setSessionsTotal
|
||||
} from '../store/session'
|
||||
import { clearSessionTodos, setSessionTodos, todoListActive } from '../store/todos'
|
||||
import { openUpdatesWindow, startUpdatePoller, stopUpdatePoller } from '../store/updates'
|
||||
import { isSecondaryWindow } from '../store/windows'
|
||||
|
||||
import { ChatView } from './chat'
|
||||
import { requestComposerFocus, requestComposerInsert } from './chat/composer/focus'
|
||||
import { useComposerActions } from './chat/hooks/use-composer-actions'
|
||||
import {
|
||||
ChatPreviewRail,
|
||||
@@ -141,7 +144,7 @@ const CRON_POLL_INTERVAL_MS = 30_000
|
||||
// self-managed sidebar section (refreshMessagingSessions). Excluding both here
|
||||
// keeps "Load more" paging through interactive local chats instead of
|
||||
// interleaving gateway threads that bury them.
|
||||
const SIDEBAR_EXCLUDED_SOURCES = ['cron', ...MESSAGING_SESSION_SOURCE_IDS]
|
||||
const SIDEBAR_EXCLUDED_SOURCES = ['cron', 'subagent', 'tool', ...MESSAGING_SESSION_SOURCE_IDS]
|
||||
// The messaging slice is the inverse: drop cron + every local source so only
|
||||
// external-platform conversations remain, then split per platform in the UI.
|
||||
const MESSAGING_EXCLUDED_SOURCES = ['cron', ...LOCAL_SESSION_SOURCE_IDS]
|
||||
@@ -267,28 +270,53 @@ export function DesktopController() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Notification click: the main process already focused the window; jump to its session.
|
||||
useEffect(() => {
|
||||
const unsubscribe = window.hermesDesktop?.onFocusSession?.(sessionId => {
|
||||
if (sessionId) {
|
||||
navigate(sessionRoute(sessionId))
|
||||
}
|
||||
})
|
||||
|
||||
return () => unsubscribe?.()
|
||||
}, [navigate])
|
||||
|
||||
// Notification action button (Approve/Reject) — resolve in place, no navigation.
|
||||
useEffect(() => {
|
||||
const unsubscribe = window.hermesDesktop?.onNotificationAction?.(({ actionId, sessionId }) => {
|
||||
void respondToApprovalAction(sessionId ?? null, actionId)
|
||||
})
|
||||
|
||||
return () => unsubscribe?.()
|
||||
}, [])
|
||||
|
||||
// hermes:// deep links (e.g. a docs "Send to App" button for an automation blueprint).
|
||||
// Build the equivalent /blueprint slash command from the payload and drop
|
||||
// it into the composer — the user reviews/edits, then sends; the agent (or
|
||||
// the shared command handler) creates the job. Signal readiness so a link
|
||||
// that arrived during boot is flushed exactly once.
|
||||
useEffect(() => {
|
||||
const unsubscribe = window.hermesDesktop?.onDeepLink?.((payload) => {
|
||||
const unsubscribe = window.hermesDesktop?.onDeepLink?.(payload => {
|
||||
if (!payload || payload.kind !== 'blueprint' || !payload.name) {
|
||||
return
|
||||
}
|
||||
|
||||
const slots = Object.entries(payload.params || {})
|
||||
.map(([k, v]) => {
|
||||
const sval = /\s/.test(v) ? `"${v.replace(/"/g, '\\"')}"` : v
|
||||
|
||||
return `${k}=${sval}`
|
||||
})
|
||||
.join(' ')
|
||||
|
||||
const command = `/blueprint ${payload.name}${slots ? ' ' + slots : ''}`
|
||||
requestComposerInsert(command, { mode: 'block', target: 'main' })
|
||||
requestComposerFocus('main')
|
||||
})
|
||||
|
||||
// Tell the main process the renderer is ready to receive deep links.
|
||||
void window.hermesDesktop?.signalDeepLinkReady?.()
|
||||
|
||||
return () => unsubscribe?.()
|
||||
}, [])
|
||||
|
||||
@@ -554,15 +582,27 @@ export function DesktopController() {
|
||||
for (let index = 0; index < Math.max(1, attempts); index += 1) {
|
||||
try {
|
||||
const latest = await getSessionMessages(storedSessionId, storedProfile)
|
||||
const messages = toChatMessages(latest.messages)
|
||||
updateSessionState(
|
||||
runtimeSessionId,
|
||||
state => ({
|
||||
...state,
|
||||
messages: preserveLocalAssistantErrors(toChatMessages(latest.messages), state.messages)
|
||||
messages: preserveLocalAssistantErrors(messages, state.messages)
|
||||
}),
|
||||
storedSessionId
|
||||
)
|
||||
|
||||
// Seed the status stack's todo group from history — but only while
|
||||
// the plan is still in flight, so reopening an old chat doesn't pin
|
||||
// its finished todo list above the composer forever.
|
||||
const todos = latestSessionTodos(messages)
|
||||
|
||||
if (todos && todoListActive(todos)) {
|
||||
setSessionTodos(runtimeSessionId, todos)
|
||||
} else {
|
||||
clearSessionTodos(runtimeSessionId)
|
||||
}
|
||||
|
||||
return
|
||||
} catch {
|
||||
// Best-effort fallback when live stream payloads are empty.
|
||||
@@ -582,6 +622,7 @@ export function DesktopController() {
|
||||
queryClient,
|
||||
refreshHermesConfig,
|
||||
refreshSessions,
|
||||
sessionStateByRuntimeIdRef,
|
||||
updateSessionState
|
||||
})
|
||||
|
||||
@@ -711,6 +752,7 @@ export function DesktopController() {
|
||||
editMessage,
|
||||
handleThreadMessagesChange,
|
||||
reloadFromMessage,
|
||||
restoreToMessage,
|
||||
steerPrompt,
|
||||
submitText,
|
||||
transcribeVoiceAudio
|
||||
@@ -945,6 +987,7 @@ export function DesktopController() {
|
||||
onPickImages={() => void composer.pickImages()}
|
||||
onReload={reloadFromMessage}
|
||||
onRemoveAttachment={id => void composer.removeAttachment(id)}
|
||||
onRestoreToMessage={restoreToMessage}
|
||||
onSteer={steerPrompt}
|
||||
onSubmit={submitText}
|
||||
onThreadMessagesChange={handleThreadMessagesChange}
|
||||
@@ -990,8 +1033,8 @@ export function DesktopController() {
|
||||
width={FILE_BROWSER_DEFAULT_WIDTH}
|
||||
>
|
||||
<RightSidebarPane
|
||||
onActivateFile={composer.attachContextFilePath}
|
||||
onActivateFolder={composer.attachContextFolderPath}
|
||||
onActivateFile={path => composer.insertContextPathInlineRef(path)}
|
||||
onActivateFolder={path => composer.insertContextPathInlineRef(path, true)}
|
||||
onChangeCwd={changeSessionCwd}
|
||||
/>
|
||||
</Pane>
|
||||
|
||||
@@ -2,14 +2,15 @@ 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { createProfile, updateProfileSoul } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { AlertTriangle } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { ProfileInfo } from '@/types/hermes'
|
||||
|
||||
const PROFILE_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/
|
||||
|
||||
@@ -23,16 +24,18 @@ export function isValidProfileName(name: string): boolean {
|
||||
export function CreateProfileDialog({
|
||||
onClose,
|
||||
onCreated,
|
||||
open
|
||||
open,
|
||||
profiles = []
|
||||
}: {
|
||||
onClose: () => void
|
||||
onCreated?: (name: string) => Promise<void> | void
|
||||
open: boolean
|
||||
profiles?: ProfileInfo[]
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const p = t.profiles
|
||||
const [name, setName] = useState('')
|
||||
const [cloneFromDefault, setCloneFromDefault] = useState(true)
|
||||
const [cloneFrom, setCloneFrom] = useState<null | string>('default')
|
||||
const [soul, setSoul] = useState('')
|
||||
const [status, setStatus] = useState<'done' | 'idle' | 'saving'>('idle')
|
||||
const [error, setError] = useState<null | string>(null)
|
||||
@@ -43,7 +46,7 @@ export function CreateProfileDialog({
|
||||
}
|
||||
|
||||
setName('')
|
||||
setCloneFromDefault(true)
|
||||
setCloneFrom('default')
|
||||
setSoul('')
|
||||
setError(null)
|
||||
setStatus('idle')
|
||||
@@ -66,7 +69,7 @@ export function CreateProfileDialog({
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
await createProfile({ name: trimmed, clone_from_default: cloneFromDefault })
|
||||
await createProfile({ name: trimmed, clone_from: cloneFrom })
|
||||
|
||||
if (soul.trim()) {
|
||||
await updateProfileSoul(trimmed, soul)
|
||||
@@ -107,17 +110,25 @@ export function CreateProfileDialog({
|
||||
</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">{p.cloneFromDefault}</span>
|
||||
<span className="text-xs text-muted-foreground">{p.cloneFromDefaultDesc}</span>
|
||||
</span>
|
||||
</label>
|
||||
<div className="grid gap-1.5">
|
||||
<label className="text-xs font-medium" htmlFor="new-profile-clone-from">
|
||||
{p.cloneFrom}
|
||||
</label>
|
||||
<Select onValueChange={value => setCloneFrom(value === '__none__' ? null : value)} value={cloneFrom ?? '__none__'}>
|
||||
<SelectTrigger className="h-9 rounded-md" id="new-profile-clone-from">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">{p.cloneFromNone}</SelectItem>
|
||||
{profiles.map(profile => (
|
||||
<SelectItem key={profile.name} value={profile.name}>
|
||||
{profile.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">{p.cloneFromDesc}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1.5">
|
||||
<label className="text-xs font-medium" htmlFor="new-profile-soul">
|
||||
@@ -127,7 +138,7 @@ export function CreateProfileDialog({
|
||||
className="min-h-28 font-mono text-xs leading-5"
|
||||
id="new-profile-soul"
|
||||
onChange={event => setSoul(event.target.value)}
|
||||
placeholder={p.soulPlaceholder(cloneFromDefault ? p.soulPlaceholderCloned : p.soulPlaceholderEmpty)}
|
||||
placeholder={p.soulPlaceholder(cloneFrom ? p.soulPlaceholderCloned : p.soulPlaceholderEmpty)}
|
||||
value={soul}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
createProfile,
|
||||
@@ -82,14 +83,14 @@ export function ProfilesView({ onClose }: ProfilesViewProps) {
|
||||
}, [profiles, selectedName])
|
||||
|
||||
const handleCreate = useCallback(
|
||||
async (name: string, cloneFromDefault: boolean) => {
|
||||
async (name: string, cloneFrom: null | string) => {
|
||||
const trimmed = name.trim()
|
||||
|
||||
if (!isValidProfileName(trimmed)) {
|
||||
throw new Error(p.nameHint)
|
||||
}
|
||||
|
||||
await createProfile({ name: trimmed, clone_from_default: cloneFromDefault })
|
||||
await createProfile({ name: trimmed, clone_from: cloneFrom })
|
||||
notify({ kind: 'success', title: p.created, message: trimmed })
|
||||
setSelectedName(trimmed)
|
||||
await refresh()
|
||||
@@ -180,8 +181,9 @@ export function ProfilesView({ onClose }: ProfilesViewProps) {
|
||||
|
||||
<CreateProfileDialog
|
||||
onClose={() => setCreateOpen(false)}
|
||||
onCreate={async (name, cloneFromDefault) => handleCreate(name, cloneFromDefault)}
|
||||
onCreate={async (name, cloneFrom) => handleCreate(name, cloneFrom)}
|
||||
open={createOpen}
|
||||
profiles={profiles ?? []}
|
||||
/>
|
||||
|
||||
<Dialog onOpenChange={open => !open && !deleting && setPendingDelete(null)} open={pendingDelete !== null}>
|
||||
@@ -453,16 +455,18 @@ function SoulEditor({ profileName }: { profileName: string }) {
|
||||
function CreateProfileDialog({
|
||||
onClose,
|
||||
onCreate,
|
||||
open
|
||||
open,
|
||||
profiles
|
||||
}: {
|
||||
onClose: () => void
|
||||
onCreate: (name: string, cloneFromDefault: boolean) => Promise<void>
|
||||
onCreate: (name: string, cloneFrom: null | string) => Promise<void>
|
||||
open: boolean
|
||||
profiles: ProfileInfo[]
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const p = t.profiles
|
||||
const [name, setName] = useState('')
|
||||
const [cloneFromDefault, setCloneFromDefault] = useState(true)
|
||||
const [cloneFrom, setCloneFrom] = useState<null | string>('default')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<null | string>(null)
|
||||
|
||||
@@ -472,7 +476,7 @@ function CreateProfileDialog({
|
||||
}
|
||||
|
||||
setName('')
|
||||
setCloneFromDefault(true)
|
||||
setCloneFrom('default')
|
||||
setError(null)
|
||||
setSaving(false)
|
||||
}, [open])
|
||||
@@ -493,7 +497,7 @@ function CreateProfileDialog({
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
await onCreate(trimmed, cloneFromDefault)
|
||||
await onCreate(trimmed, cloneFrom)
|
||||
onClose()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : p.failedCreate)
|
||||
@@ -528,18 +532,25 @@ function CreateProfileDialog({
|
||||
</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">{p.cloneFromDefault}</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">{p.cloneFromDefaultDesc}</span>
|
||||
</span>
|
||||
</label>
|
||||
<div className="grid gap-1.5">
|
||||
<label className="text-xs font-medium" htmlFor="new-profile-clone-from">
|
||||
{p.cloneFrom}
|
||||
</label>
|
||||
<Select onValueChange={value => setCloneFrom(value === '__none__' ? null : value)} value={cloneFrom ?? '__none__'}>
|
||||
<SelectTrigger className="h-9 rounded-md" id="new-profile-clone-from">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">{p.cloneFromNone}</SelectItem>
|
||||
{profiles.map(profile => (
|
||||
<SelectItem key={profile.name} value={profile.name}>
|
||||
{profile.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">{p.cloneFromDesc}</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">
|
||||
|
||||
@@ -12,6 +12,8 @@ import type { TreeNode } from './use-project-tree'
|
||||
|
||||
const ROW_HEIGHT = 22
|
||||
const INDENT = 10
|
||||
/** Base inset for every row; react-arborist owns paddingLeft for depth indent. */
|
||||
const TREE_ROW_INSET = 12
|
||||
|
||||
interface ProjectTreeProps {
|
||||
collapseNonce: number
|
||||
@@ -200,18 +202,16 @@ function ProjectTreeRow({
|
||||
event.dataTransfer.setData('text/plain', node.data.id)
|
||||
}}
|
||||
ref={dragHandle}
|
||||
style={style}
|
||||
style={{
|
||||
...style,
|
||||
paddingLeft:
|
||||
(typeof style.paddingLeft === 'number'
|
||||
? style.paddingLeft
|
||||
: Number.parseFloat(String(style.paddingLeft ?? 0)) || 0) + TREE_ROW_INSET
|
||||
}}
|
||||
>
|
||||
{isFolder && !isPlaceholder && (
|
||||
<span aria-hidden className="flex w-3 items-center justify-center">
|
||||
<Codicon
|
||||
className="text-(--ui-text-tertiary)"
|
||||
name={node.isOpen ? 'chevron-down' : 'chevron-right'}
|
||||
size="0.75rem"
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
{!isFolder && <span aria-hidden className="w-3 shrink-0" />}
|
||||
{/* No chevron column — the folder icon (open/closed) already carries the
|
||||
expand state, so the extra glyph was pure noise. */}
|
||||
<span aria-hidden className="flex w-3.5 items-center justify-center text-(--ui-text-tertiary)">
|
||||
{isPlaceholder && !isErrorPlaceholder ? (
|
||||
<Codicon name="loading" size="0.75rem" spinning />
|
||||
|
||||
@@ -221,6 +221,36 @@ describe('useProjectTree', () => {
|
||||
expect(readDir).toHaveBeenLastCalledWith('/b')
|
||||
})
|
||||
|
||||
it('falls back to the sanitized workspace dir when the session cwd is gone', async () => {
|
||||
const sanitizeWorkspaceCwd = vi.fn(async () => ({ cwd: '/home/me/projects', sanitized: true }))
|
||||
readDir.mockImplementation(async path => {
|
||||
if (path === '/deleted/worktree') return { entries: [], error: 'ENOENT' }
|
||||
if (path === '/home/me/projects') return ok([{ name: 'repo', path: '/home/me/projects/repo', isDirectory: true }])
|
||||
throw new Error(`unexpected path ${path}`)
|
||||
})
|
||||
;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = { readDir, sanitizeWorkspaceCwd }
|
||||
|
||||
const { result } = renderHook(() => useProjectTree('/deleted/worktree'))
|
||||
|
||||
await waitFor(() => expect(result.current.data.length).toBe(1))
|
||||
|
||||
expect(sanitizeWorkspaceCwd).toHaveBeenCalledWith('/deleted/worktree')
|
||||
expect(result.current.rootError).toBeNull()
|
||||
expect(result.current.effectiveCwd).toBe('/home/me/projects')
|
||||
expect(result.current.data[0]?.name).toBe('repo')
|
||||
})
|
||||
|
||||
it('keeps the root error when sanitize offers no usable fallback', async () => {
|
||||
const sanitizeWorkspaceCwd = vi.fn(async () => ({ cwd: '/deleted/worktree', sanitized: false }))
|
||||
readDir.mockResolvedValue({ entries: [], error: 'ENOENT' })
|
||||
;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = { readDir, sanitizeWorkspaceCwd }
|
||||
|
||||
const { result } = renderHook(() => useProjectTree('/deleted/worktree'))
|
||||
|
||||
await waitFor(() => expect(result.current.rootError).toBe('ENOENT'))
|
||||
expect(result.current.effectiveCwd).toBe('/deleted/worktree')
|
||||
})
|
||||
|
||||
it('returns no-bridge gracefully when window.hermesDesktop is missing', async () => {
|
||||
delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
|
||||
|
||||
|
||||
@@ -64,6 +64,10 @@ export interface UseProjectTreeResult {
|
||||
/** Bumped by collapseAll so callers can remount the tree fully collapsed. */
|
||||
collapseNonce: number
|
||||
data: TreeNode[]
|
||||
/** Directory actually displayed — differs from the requested cwd when the
|
||||
* session's recorded cwd no longer exists and we fell back to the default
|
||||
* workspace dir. */
|
||||
effectiveCwd: string
|
||||
openState: Record<string, boolean>
|
||||
rootError: string | null
|
||||
rootLoading: boolean
|
||||
@@ -80,6 +84,8 @@ interface ProjectTreeState {
|
||||
loaded: boolean
|
||||
openState: Record<string, boolean>
|
||||
requestId: number
|
||||
/** Directory the displayed entries were read from ('' until first load). */
|
||||
resolvedCwd: string
|
||||
rootError: string | null
|
||||
rootLoading: boolean
|
||||
}
|
||||
@@ -91,6 +97,7 @@ const initialState: ProjectTreeState = {
|
||||
loaded: false,
|
||||
openState: {},
|
||||
requestId: 0,
|
||||
resolvedCwd: '',
|
||||
rootError: null,
|
||||
rootLoading: false
|
||||
}
|
||||
@@ -100,6 +107,11 @@ const $projectTree = atom<ProjectTreeState>(initialState)
|
||||
let nextRootRequestId = 0
|
||||
let lastConnectionKey = ''
|
||||
|
||||
// While the root is errored (ENOENT during a session's cwd race, a folder that
|
||||
// reappears after a checkout, a remote that wasn't ready), keep retrying on a
|
||||
// slow cadence so the tree self-heals instead of staying "UNREADABLE" forever.
|
||||
const ROOT_ERROR_RETRY_MS = 3_000
|
||||
|
||||
function setProjectTree(updater: (current: ProjectTreeState) => ProjectTreeState) {
|
||||
$projectTree.set(updater($projectTree.get()))
|
||||
}
|
||||
@@ -110,6 +122,31 @@ function clearProjectTree() {
|
||||
$projectTree.set({ ...initialState, requestId: nextRootRequestId })
|
||||
}
|
||||
|
||||
/** Sessions record their launch cwd; deleted worktrees and remote-backend
|
||||
* paths arrive here as directories that don't exist on this machine. Rather
|
||||
* than bricking the tree, display the sanitized workspace fallback (main
|
||||
* prefers the configured default project dir). Local connections only —
|
||||
* remote trees are read through the remote bridge. */
|
||||
async function fallbackRootFor(cwd: string): Promise<string | null> {
|
||||
if ($connection.get()?.mode === 'remote') {
|
||||
return null
|
||||
}
|
||||
|
||||
const sanitize = window.hermesDesktop?.sanitizeWorkspaceCwd
|
||||
|
||||
if (!sanitize) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const { cwd: fallback, sanitized } = await sanitize(cwd)
|
||||
|
||||
return sanitized && fallback && fallback !== cwd ? fallback : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRoot(cwd: string, { force = false }: { force?: boolean } = {}) {
|
||||
if (!cwd) {
|
||||
clearProjectTree()
|
||||
@@ -138,11 +175,27 @@ async function loadRoot(cwd: string, { force = false }: { force?: boolean } = {}
|
||||
loaded: false,
|
||||
openState: current.cwd === cwd ? current.openState : {},
|
||||
requestId,
|
||||
resolvedCwd: '',
|
||||
rootError: null,
|
||||
rootLoading: true
|
||||
})
|
||||
|
||||
const { entries, error } = await readProjectDir(cwd, cwd)
|
||||
let resolvedCwd = cwd
|
||||
let { entries, error } = await readProjectDir(cwd, cwd)
|
||||
|
||||
if (error) {
|
||||
const fallback = await fallbackRootFor(cwd)
|
||||
|
||||
if (fallback) {
|
||||
const retry = await readProjectDir(fallback, fallback)
|
||||
|
||||
if (!retry.error) {
|
||||
resolvedCwd = fallback
|
||||
entries = retry.entries
|
||||
error = undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setProjectTree(latest => {
|
||||
if (latest.cwd !== cwd || latest.requestId !== requestId) {
|
||||
@@ -153,6 +206,7 @@ async function loadRoot(cwd: string, { force = false }: { force?: boolean } = {}
|
||||
...latest,
|
||||
data: error ? [] : entries.map(e => makeNode(e.path, e.name, e.isDirectory)),
|
||||
loaded: true,
|
||||
resolvedCwd,
|
||||
rootError: error || null,
|
||||
rootLoading: false
|
||||
}
|
||||
@@ -230,7 +284,8 @@ export function useProjectTree(cwd: string): UseProjectTreeResult {
|
||||
}
|
||||
})
|
||||
|
||||
const { entries, error } = await readProjectDir(id, cwd)
|
||||
const rootPath = $projectTree.get().resolvedCwd || cwd
|
||||
const { entries, error } = await readProjectDir(id, rootPath)
|
||||
|
||||
inflight.delete(id)
|
||||
|
||||
@@ -256,19 +311,62 @@ export function useProjectTree(cwd: string): UseProjectTreeResult {
|
||||
useEffect(() => {
|
||||
const connectionChanged = lastConnectionKey !== '' && lastConnectionKey !== connectionKey
|
||||
lastConnectionKey = connectionKey
|
||||
|
||||
if (connectionChanged) {
|
||||
clearProjectDirCache()
|
||||
void loadRoot(cwd, { force: true })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
void loadRoot(cwd)
|
||||
}, [connectionKey, cwd])
|
||||
|
||||
// Self-heal: an errored root re-probes every few seconds while the tree is
|
||||
// mounted. Each attempt bumps requestId, so a persistent error re-arms the
|
||||
// timer; a success clears rootError and stops it.
|
||||
useEffect(() => {
|
||||
if (!cwd || state.cwd !== cwd || !state.rootError) {
|
||||
return
|
||||
}
|
||||
|
||||
const timer = window.setTimeout(() => void loadRoot(cwd, { force: true }), ROOT_ERROR_RETRY_MS)
|
||||
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [cwd, state.cwd, state.requestId, state.rootError])
|
||||
|
||||
// While showing the fallback root, quietly re-probe the session's real cwd
|
||||
// (a worktree re-created, a checkout restored) and switch back when it
|
||||
// reappears. The probe never touches state, so there's no flicker.
|
||||
const usingFallback = state.cwd === cwd && Boolean(state.resolvedCwd) && state.resolvedCwd !== cwd
|
||||
|
||||
useEffect(() => {
|
||||
if (!cwd || !usingFallback) {
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
|
||||
const timer = window.setInterval(() => {
|
||||
void readProjectDir(cwd, cwd).then(({ error }) => {
|
||||
if (!cancelled && !error) {
|
||||
void loadRoot(cwd, { force: true })
|
||||
}
|
||||
})
|
||||
}, ROOT_ERROR_RETRY_MS)
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
window.clearInterval(timer)
|
||||
}
|
||||
}, [cwd, usingFallback])
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
collapseAll,
|
||||
collapseNonce: state.cwd === cwd ? state.collapseNonce : 0,
|
||||
data: state.cwd === cwd ? state.data : [],
|
||||
effectiveCwd: state.cwd === cwd && state.resolvedCwd ? state.resolvedCwd : cwd,
|
||||
loadChildren,
|
||||
openState: state.cwd === cwd ? state.openState : {},
|
||||
refreshRoot,
|
||||
@@ -286,6 +384,7 @@ export function useProjectTree(cwd: string): UseProjectTreeResult {
|
||||
state.cwd,
|
||||
state.data,
|
||||
state.openState,
|
||||
state.resolvedCwd,
|
||||
state.rootError,
|
||||
state.rootLoading
|
||||
]
|
||||
|
||||
@@ -5,7 +5,6 @@ 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 { useI18n } from '@/i18n'
|
||||
import { selectDesktopPaths } from '@/lib/desktop-fs'
|
||||
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
|
||||
@@ -34,17 +33,11 @@ export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd
|
||||
const currentCwd = useStore($currentCwd).trim()
|
||||
const hasCwd = currentCwd.length > 0
|
||||
|
||||
const cwdName = hasCwd
|
||||
? (currentCwd
|
||||
.split(/[\\/]+/)
|
||||
.filter(Boolean)
|
||||
.pop() ?? currentCwd)
|
||||
: r.noFolderSelected
|
||||
|
||||
const {
|
||||
collapseAll,
|
||||
collapseNonce,
|
||||
data,
|
||||
effectiveCwd,
|
||||
loadChildren,
|
||||
openState,
|
||||
refreshRoot,
|
||||
@@ -53,11 +46,18 @@ export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd
|
||||
setNodeOpen
|
||||
} = useProjectTree(currentCwd)
|
||||
|
||||
const cwdName = hasCwd
|
||||
? (effectiveCwd
|
||||
.split(/[\\/]+/)
|
||||
.filter(Boolean)
|
||||
.pop() ?? effectiveCwd)
|
||||
: r.noFolderSelected
|
||||
|
||||
const canCollapse = Object.values(openState).some(Boolean)
|
||||
|
||||
const chooseFolder = async () => {
|
||||
const selected = await selectDesktopPaths({
|
||||
defaultPath: hasCwd ? currentCwd : undefined,
|
||||
defaultPath: hasCwd ? effectiveCwd : undefined,
|
||||
directories: true,
|
||||
multiple: false,
|
||||
title: r.changeCwdTitle
|
||||
@@ -70,7 +70,7 @@ export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd
|
||||
|
||||
const previewFile = async (path: string) => {
|
||||
try {
|
||||
const preview = await normalizeOrLocalPreviewTarget(path, currentCwd || undefined)
|
||||
const preview = await normalizeOrLocalPreviewTarget(path, effectiveCwd || undefined)
|
||||
|
||||
if (!preview) {
|
||||
throw new Error(r.couldNotPreview(path))
|
||||
@@ -97,7 +97,7 @@ export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd
|
||||
<FilesystemTab
|
||||
canCollapse={canCollapse}
|
||||
collapseNonce={collapseNonce}
|
||||
cwd={currentCwd}
|
||||
cwd={effectiveCwd}
|
||||
cwdName={cwdName}
|
||||
data={data}
|
||||
error={rootError}
|
||||
@@ -126,13 +126,12 @@ interface FilesystemTabProps extends FileTreeBodyProps {
|
||||
onRefresh: () => void
|
||||
}
|
||||
|
||||
// Sidebar-specific color/hover treatment only — size, radius, cursor and the
|
||||
// base focus ring come from <Button size="icon-xs">. This constant exists
|
||||
// purely to share the sidebar palette + the hover-reveal behavior below.
|
||||
// Sidebar palette + hover-reveal: refresh tracks label hover; collapse-all
|
||||
// stays visible while any folder is expanded.
|
||||
const HEADER_ACTION_CLASS =
|
||||
'text-sidebar-foreground/70 hover:bg-sidebar-accent! hover:text-sidebar-accent-foreground! focus-visible:ring-sidebar-ring'
|
||||
|
||||
const HEADER_ACTION_REVEAL_CLASS = `${HEADER_ACTION_CLASS} pointer-events-none opacity-0 transition-opacity focus-visible:opacity-100 group-focus-within/project-header:pointer-events-auto group-focus-within/project-header:opacity-100 group-hover/project-header:pointer-events-auto group-hover/project-header:opacity-100`
|
||||
const HEADER_ACTION_LABEL_REVEAL = `${HEADER_ACTION_CLASS} pointer-events-none opacity-0 transition-opacity focus-visible:pointer-events-auto focus-visible:opacity-100 peer-focus-visible/project-label:pointer-events-auto peer-focus-visible/project-label:opacity-100 peer-hover/project-label:pointer-events-auto peer-hover/project-label:opacity-100`
|
||||
|
||||
function FilesystemTab({
|
||||
canCollapse,
|
||||
@@ -157,20 +156,20 @@ function FilesystemTab({
|
||||
const r = t.rightSidebar
|
||||
|
||||
return (
|
||||
<div className="group/project-header flex min-h-0 flex-1 flex-col">
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<RightSidebarSectionHeader>
|
||||
<Tip label={hasCwd ? r.folderTip(cwd) : r.openFolder}>
|
||||
<div className="peer/project-label flex min-w-0 flex-1">
|
||||
<button
|
||||
className="flex min-w-0 flex-1 items-center rounded-md text-left hover:text-(--ui-text-secondary)"
|
||||
className="flex w-full min-w-0 items-center rounded-md text-left hover:text-(--ui-text-secondary)"
|
||||
onClick={() => void onChangeFolder()}
|
||||
type="button"
|
||||
>
|
||||
<SidebarPanelLabel>{cwdName}</SidebarPanelLabel>
|
||||
</button>
|
||||
</Tip>
|
||||
</div>
|
||||
<Button
|
||||
aria-label={r.refreshTree}
|
||||
className={HEADER_ACTION_CLASS}
|
||||
className={HEADER_ACTION_LABEL_REVEAL}
|
||||
disabled={!hasCwd || loading}
|
||||
onClick={onRefresh}
|
||||
size="icon-xs"
|
||||
@@ -189,7 +188,7 @@ function FilesystemTab({
|
||||
</Button>
|
||||
<Button
|
||||
aria-label={r.collapseAll}
|
||||
className={HEADER_ACTION_REVEAL_CLASS}
|
||||
className={cn(HEADER_ACTION_CLASS, !canCollapse && 'pointer-events-none opacity-0')}
|
||||
disabled={!hasCwd || !canCollapse}
|
||||
onClick={onCollapseAll}
|
||||
size="icon-xs"
|
||||
@@ -209,6 +208,7 @@ function FilesystemTab({
|
||||
onLoadChildren={onLoadChildren}
|
||||
onNodeOpenChange={onNodeOpenChange}
|
||||
onPreviewFile={onPreviewFile}
|
||||
onRetry={onRefresh}
|
||||
openState={openState}
|
||||
/>
|
||||
</div>
|
||||
@@ -230,6 +230,9 @@ interface FileTreeBodyProps {
|
||||
onLoadChildren: (id: string) => void | Promise<void>
|
||||
onNodeOpenChange: (id: string, open: boolean) => void
|
||||
onPreviewFile?: (path: string) => void
|
||||
/** Force-reload the root. The hook also auto-retries while errored, so this
|
||||
* is the impatient-user path. */
|
||||
onRetry?: () => void
|
||||
openState: ReturnType<typeof useProjectTree>['openState']
|
||||
}
|
||||
|
||||
@@ -244,6 +247,7 @@ function FileTreeBody({
|
||||
onLoadChildren,
|
||||
onNodeOpenChange,
|
||||
onPreviewFile,
|
||||
onRetry,
|
||||
openState
|
||||
}: FileTreeBodyProps) {
|
||||
const { t } = useI18n()
|
||||
@@ -254,7 +258,20 @@ function FileTreeBody({
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <EmptyState body={r.unreadableBody(error)} title={r.unreadableTitle} />
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-2 px-4 text-center">
|
||||
<EmptyState body={r.unreadableBody(error)} title={r.unreadableTitle} />
|
||||
{onRetry && (
|
||||
<button
|
||||
className="text-[0.68rem] font-medium text-muted-foreground transition hover:text-foreground"
|
||||
onClick={onRetry}
|
||||
type="button"
|
||||
>
|
||||
{r.tryAgain}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (loading && data.length === 0) {
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useI18n } from '@/i18n'
|
||||
import { SidebarPanelLabel } from '../../shell/sidebar-label'
|
||||
import { setTerminalTakeover } from '../store'
|
||||
|
||||
import { addSelectionShortcutLabel } from './selection'
|
||||
import { KbdCombo } from '@/components/ui/kbd'
|
||||
import { useTerminalSession } from './use-terminal-session'
|
||||
|
||||
interface TerminalTabProps {
|
||||
@@ -69,7 +69,7 @@ export function TerminalTab({ cwd, onAddSelectionToChat }: TerminalTabProps) {
|
||||
variant="secondary"
|
||||
>
|
||||
{t.rightSidebar.addToChat}
|
||||
<span className="ml-1 text-[0.6rem] text-(--ui-text-tertiary)">{addSelectionShortcutLabel()}</span>
|
||||
<KbdCombo className="ml-1 opacity-70" combo="mod+l" size="sm" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -99,8 +99,6 @@ export function resolveSurfaceColor(fallback: string): string {
|
||||
|
||||
export const isMacPlatform = () => navigator.platform.toLowerCase().includes('mac')
|
||||
|
||||
export const addSelectionShortcutLabel = () => (isMacPlatform() ? '⌘L' : 'Ctrl+L')
|
||||
|
||||
export function isAddSelectionShortcut(event: KeyboardEvent) {
|
||||
const mod = isMacPlatform() ? event.metaKey : event.ctrlKey
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { CSSProperties } from 'react'
|
||||
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { $filePreviewTarget, $previewTarget } from '@/store/preview'
|
||||
import { useTheme } from '@/themes/context'
|
||||
|
||||
import { makeTerminalReader, setActiveTerminalReader } from './buffer'
|
||||
@@ -20,6 +21,17 @@ import {
|
||||
|
||||
type TerminalStatus = 'closed' | 'open' | 'starting'
|
||||
|
||||
// ⌘/Ctrl+L is a global shortcut, so a text selection in the file preview pane
|
||||
// lands in this handler with no xterm selection. Label those with the previewed
|
||||
// file's name instead of the shell, so the composer ref reads as a file quote
|
||||
// rather than a bogus "zsh:N lines".
|
||||
function previewSelectionLabel(): string {
|
||||
const target = $filePreviewTarget.get() ?? $previewTarget.get()
|
||||
const source = target?.path || target?.url || ''
|
||||
|
||||
return source.split(/[\\/]/).filter(Boolean).pop() || target?.label?.trim() || ''
|
||||
}
|
||||
|
||||
const HERMES_PATHS_MIME = 'application/x-hermes-paths'
|
||||
|
||||
function readEscapeSequence(data: string, index: number) {
|
||||
@@ -257,16 +269,20 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
|
||||
)
|
||||
|
||||
const addSelectionToChat = useCallback(() => {
|
||||
const selectedText = readSelection() || selectionRef.current
|
||||
const termSelection = (termRef.current?.getSelection() || selectionRef.current).trim()
|
||||
const selectedText = termSelection || window.getSelection()?.toString() || ''
|
||||
const trimmed = selectedText.trim()
|
||||
|
||||
if (!trimmed) {
|
||||
return
|
||||
}
|
||||
|
||||
const label =
|
||||
selectionLabelRef.current ||
|
||||
(termRef.current ? terminalSelectionLabel(termRef.current, shellNameRef.current, selectedText) : 'selection')
|
||||
// Terminal selection → shell-anchored label; anything else came from the
|
||||
// preview pane sharing this global shortcut → label it with the file.
|
||||
const label = termSelection
|
||||
? selectionLabelRef.current ||
|
||||
(termRef.current ? terminalSelectionLabel(termRef.current, shellNameRef.current, selectedText) : 'selection')
|
||||
: previewSelectionLabel() || 'selection'
|
||||
|
||||
onAddSelectionToChatRef.current(trimmed, label)
|
||||
termRef.current?.clearSelection()
|
||||
@@ -275,7 +291,7 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
|
||||
setSelection('')
|
||||
setSelectionStyle(null)
|
||||
triggerHaptic('selection')
|
||||
}, [readSelection])
|
||||
}, [])
|
||||
|
||||
// Always listen — gating on the React selection state misses selections the
|
||||
// TUI redraw races. Only swallow ⌘/Ctrl+L when there's text to send, else it
|
||||
@@ -312,13 +328,19 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
|
||||
|
||||
const term = new Terminal({
|
||||
allowProposedApi: true,
|
||||
allowTransparency: true,
|
||||
// Opaque canvas = WebGL's crisp fast-path. allowTransparency instead bakes
|
||||
// glyphs as grayscale-alpha for compositing over a see-through canvas, which
|
||||
// reads soft on every platform; VS Code keeps it off and our surface
|
||||
// (--ui-bg-chrome) is opaque anyway, so withSurface paints it solid.
|
||||
allowTransparency: false,
|
||||
convertEol: true,
|
||||
cursorBlink: true,
|
||||
fontFamily: "'JetBrains Mono', 'Cascadia Code', 'SF Mono', Menlo, Consolas, monospace",
|
||||
fontSize: 11,
|
||||
fontWeight: '400',
|
||||
fontWeightBold: '700',
|
||||
// VS Code's terminal renders 'normal'/'bold' (400/700); we were using Medium
|
||||
// (500) as the base, which reads a touch heavy at this size.
|
||||
fontWeight: 'normal',
|
||||
fontWeightBold: 'bold',
|
||||
letterSpacing: 0,
|
||||
lineHeight: 1.12,
|
||||
// Full-screen TUIs (hermes --tui, vim) grab the mouse, so a plain drag
|
||||
@@ -601,8 +623,10 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
|
||||
startSession()
|
||||
}
|
||||
|
||||
// fonts.ready settles only already-requested faces; bold/italic aren't asked
|
||||
// for until styled output paints (past atlas init), so warm them up front.
|
||||
// fonts.ready settles only already-requested faces; the regular (400),
|
||||
// bold (700) and italic aren't asked for until styled output paints (past
|
||||
// atlas init), so warm them up front — otherwise the WebGL atlas bakes a
|
||||
// fallback face and the terminal renders thin until a repaint.
|
||||
const warm = document.fonts?.load
|
||||
? Promise.allSettled(['400', '700', 'italic 400'].map(v => document.fonts.load(`${v} 11px 'JetBrains Mono'`)))
|
||||
: Promise.resolve()
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { QueryClient } from '@tanstack/react-query'
|
||||
import { type MutableRefObject, useCallback, useEffect, useRef } from 'react'
|
||||
|
||||
import { readActiveTerminal } from '@/app/right-sidebar/terminal/buffer'
|
||||
import { translateNow } from '@/i18n'
|
||||
import {
|
||||
appendAssistantTextPart,
|
||||
appendReasoningPart,
|
||||
@@ -15,11 +16,20 @@ import {
|
||||
upsertToolPart
|
||||
} from '@/lib/chat-messages'
|
||||
import { coerceGatewayText, coerceThinkingText, normalizePersonalityValue } from '@/lib/chat-runtime'
|
||||
import { playCompletionSound } from '@/lib/completion-sound'
|
||||
import { gatewayEventRequiresSessionId } from '@/lib/gateway-events'
|
||||
import {
|
||||
dedupeGeneratedImageEchoesInParts,
|
||||
generatedImageEchoSources,
|
||||
stripGeneratedImageEchoes
|
||||
} from '@/lib/generated-images'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors'
|
||||
import { parseTodos } from '@/lib/todos'
|
||||
import { setClarifyRequest } from '@/store/clarify'
|
||||
import { refreshBackgroundProcesses } from '@/store/composer-status'
|
||||
import { $gateway } from '@/store/gateway'
|
||||
import { dispatchNativeNotification } from '@/store/native-notifications'
|
||||
import { notify } from '@/store/notifications'
|
||||
import { requestDesktopOnboarding } from '@/store/onboarding'
|
||||
import { clearAllPrompts, setApprovalRequest, setSecretRequest, setSudoRequest } from '@/store/prompts'
|
||||
@@ -37,6 +47,7 @@ import {
|
||||
setYoloActive
|
||||
} from '@/store/session'
|
||||
import { clearSessionSubagents, pruneDelegateFallbackSubagents, upsertSubagent } from '@/store/subagents'
|
||||
import { setSessionTodos } from '@/store/todos'
|
||||
import { recordToolDiff } from '@/store/tool-diffs'
|
||||
import type { RpcEvent } from '@/types/hermes'
|
||||
|
||||
@@ -52,6 +63,7 @@ interface MessageStreamOptions {
|
||||
queryClient: QueryClient
|
||||
refreshHermesConfig: () => Promise<void>
|
||||
refreshSessions: () => Promise<void>
|
||||
sessionStateByRuntimeIdRef: MutableRefObject<Map<string, ClientSessionState>>
|
||||
updateSessionState: (
|
||||
sessionId: string,
|
||||
updater: (state: ClientSessionState) => ClientSessionState,
|
||||
@@ -67,15 +79,7 @@ interface QueuedStreamDeltas {
|
||||
type SessionRuntimeStatePatch = Partial<
|
||||
Pick<
|
||||
ClientSessionState,
|
||||
| 'branch'
|
||||
| 'cwd'
|
||||
| 'fast'
|
||||
| 'model'
|
||||
| 'personality'
|
||||
| 'provider'
|
||||
| 'reasoningEffort'
|
||||
| 'serviceTier'
|
||||
| 'yolo'
|
||||
'branch' | 'cwd' | 'fast' | 'model' | 'personality' | 'provider' | 'reasoningEffort' | 'serviceTier' | 'yolo'
|
||||
>
|
||||
>
|
||||
|
||||
@@ -253,8 +257,14 @@ export function useMessageStream({
|
||||
queryClient,
|
||||
refreshHermesConfig,
|
||||
refreshSessions,
|
||||
sessionStateByRuntimeIdRef,
|
||||
updateSessionState
|
||||
}: MessageStreamOptions) {
|
||||
const sessionInterrupted = useCallback(
|
||||
(sessionId: string) => sessionStateByRuntimeIdRef.current.get(sessionId)?.interrupted ?? false,
|
||||
[sessionStateByRuntimeIdRef]
|
||||
)
|
||||
|
||||
// Patch the in-flight assistant message (or seed it). Centralises the
|
||||
// streamId/groupId bookkeeping every event callback would otherwise repeat.
|
||||
const mutateStream = useCallback(
|
||||
@@ -341,7 +351,7 @@ export function useMessageStream({
|
||||
if (queued.assistant) {
|
||||
mutateStream(
|
||||
id,
|
||||
parts => appendAssistantTextPart(parts, queued.assistant),
|
||||
parts => dedupeGeneratedImageEchoesInParts(appendAssistantTextPart(parts, queued.assistant)),
|
||||
() => [assistantTextPart(queued.assistant)]
|
||||
)
|
||||
}
|
||||
@@ -478,6 +488,20 @@ export function useMessageStream({
|
||||
// a tool part can't jump ahead of the text that preceded it.
|
||||
flushQueuedDeltas(sessionId)
|
||||
|
||||
if (sessionInterrupted(sessionId)) {
|
||||
return
|
||||
}
|
||||
|
||||
// The composer status stack owns todo display now (no inline panel) —
|
||||
// mirror every todo state the tool reports into its session store.
|
||||
if (payload?.name === 'todo') {
|
||||
const todos = parseTodos(payload.todos) ?? parseTodos(payload.result) ?? parseTodos(payload.args)
|
||||
|
||||
if (todos) {
|
||||
setSessionTodos(sessionId, todos)
|
||||
}
|
||||
}
|
||||
|
||||
if (!nativeSubagentSessionsRef.current.has(sessionId)) {
|
||||
for (const subagentPayload of delegateTaskPayloads(payload, phase, sourceEventType)) {
|
||||
upsertSubagent(
|
||||
@@ -491,12 +515,12 @@ export function useMessageStream({
|
||||
|
||||
mutateStream(
|
||||
sessionId,
|
||||
parts => upsertToolPart(parts, payload, phase),
|
||||
parts => dedupeGeneratedImageEchoesInParts(upsertToolPart(parts, payload, phase)),
|
||||
() => upsertToolPart([], payload, phase),
|
||||
{ pending: m => phase !== 'complete' || (m.pending ?? false) }
|
||||
)
|
||||
},
|
||||
[flushQueuedDeltas, mutateStream]
|
||||
[flushQueuedDeltas, mutateStream, sessionInterrupted]
|
||||
)
|
||||
|
||||
const completeAssistantMessage = useCallback(
|
||||
@@ -524,9 +548,11 @@ export function useMessageStream({
|
||||
const finalText = renderMediaTags(text).trim()
|
||||
const completionError = completionErrorText(finalText)
|
||||
const normalize = (value: string) => value.replace(/\s+/g, ' ').trim()
|
||||
const dedupeReference = normalize(finalText)
|
||||
|
||||
const replaceTextPart = (parts: ChatMessagePart[]) => {
|
||||
const visibleFinalText = stripGeneratedImageEchoes(finalText, generatedImageEchoSources(parts)).trim()
|
||||
const dedupeReference = normalize(visibleFinalText)
|
||||
|
||||
const kept = parts.filter(part => {
|
||||
if (part.type === 'text') {
|
||||
return false
|
||||
@@ -541,7 +567,7 @@ export function useMessageStream({
|
||||
return !(r && (dedupeReference.startsWith(r) || r.startsWith(dedupeReference)))
|
||||
})
|
||||
|
||||
return finalText ? [...kept, assistantTextPart(finalText)] : kept
|
||||
return visibleFinalText ? [...kept, assistantTextPart(visibleFinalText)] : kept
|
||||
}
|
||||
|
||||
const completeMessage = (message: ChatMessage): ChatMessage =>
|
||||
@@ -617,14 +643,14 @@ export function useMessageStream({
|
||||
void hydrateFromStoredSession(3, completedState.storedSessionId, sessionId)
|
||||
}
|
||||
|
||||
if (document.hidden && sessionId === activeSessionIdRef.current) {
|
||||
void window.hermesDesktop?.notify({
|
||||
title: 'Hermes finished',
|
||||
body: text.slice(0, 140) || 'The response is ready.'
|
||||
})
|
||||
}
|
||||
dispatchNativeNotification({
|
||||
body: text.slice(0, 140) || translateNow('notifications.native.turnDoneBody'),
|
||||
kind: 'turnDone',
|
||||
sessionId,
|
||||
title: translateNow('notifications.native.turnDoneTitle')
|
||||
})
|
||||
},
|
||||
[activeSessionIdRef, hydrateFromStoredSession, refreshSessions, updateSessionState]
|
||||
[hydrateFromStoredSession, refreshSessions, updateSessionState]
|
||||
)
|
||||
|
||||
const failAssistantMessage = useCallback(
|
||||
@@ -677,9 +703,11 @@ export function useMessageStream({
|
||||
(event: RpcEvent) => {
|
||||
const payload = event.payload as GatewayEventPayload | undefined
|
||||
const explicitSid = event.session_id || ''
|
||||
|
||||
if (!explicitSid && gatewayEventRequiresSessionId(event.type)) {
|
||||
return
|
||||
}
|
||||
|
||||
const sessionId = explicitSid || activeSessionIdRef.current
|
||||
const isActiveEvent = !!sessionId && sessionId === activeSessionIdRef.current
|
||||
|
||||
@@ -845,9 +873,7 @@ export function useMessageStream({
|
||||
|
||||
flushQueuedDeltas(sessionId)
|
||||
|
||||
if (isActiveEvent) {
|
||||
triggerHaptic('streamDone')
|
||||
}
|
||||
playCompletionSound()
|
||||
|
||||
const finalText = coerceGatewayText(payload?.text) || coerceGatewayText(payload?.rendered)
|
||||
completeAssistantMessage(sessionId, finalText)
|
||||
@@ -875,13 +901,22 @@ export function useMessageStream({
|
||||
// the sidebar indicator clears as soon as it's answered, not only at
|
||||
// message.complete.
|
||||
updateSessionState(sessionId, state => (state.needsInput ? { ...state, needsInput: false } : state))
|
||||
|
||||
// terminal/process tool calls are the only things that spawn or reap
|
||||
// background processes — sync the composer status stack right after.
|
||||
if (
|
||||
!sessionInterrupted(sessionId) &&
|
||||
(payload?.name === 'terminal' || payload?.name === 'process')
|
||||
) {
|
||||
void refreshBackgroundProcesses(sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof payload?.inline_diff === 'string' && payload.inline_diff.trim()) {
|
||||
recordToolDiff(payload.tool_id || payload.name || '', payload.inline_diff)
|
||||
}
|
||||
} else if (SUBAGENT_EVENT_TYPES.has(event.type)) {
|
||||
if (sessionId && payload) {
|
||||
if (sessionId && payload && !sessionInterrupted(sessionId)) {
|
||||
if (!nativeSubagentSessionsRef.current.has(sessionId)) {
|
||||
pruneDelegateFallbackSubagents(sessionId)
|
||||
}
|
||||
@@ -924,6 +959,13 @@ export function useMessageStream({
|
||||
if (sessionId) {
|
||||
updateSessionState(sessionId, state => ({ ...state, needsInput: true }))
|
||||
}
|
||||
|
||||
dispatchNativeNotification({
|
||||
body: question,
|
||||
kind: 'input',
|
||||
sessionId,
|
||||
title: translateNow('notifications.native.inputTitle')
|
||||
})
|
||||
}
|
||||
} else if (event.type === 'approval.request') {
|
||||
// Dangerous-command / execute_code approval. The Python side is blocked
|
||||
@@ -932,17 +974,31 @@ export function useMessageStream({
|
||||
// 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.
|
||||
const command = typeof payload?.command === 'string' ? payload.command : ''
|
||||
const description = typeof payload?.description === 'string' ? payload.description : 'dangerous command'
|
||||
|
||||
setApprovalRequest({
|
||||
// false only when a tirith warning forbids it; backend omits the field otherwise.
|
||||
allowPermanent: payload?.allow_permanent !== false,
|
||||
command: typeof payload?.command === 'string' ? payload.command : '',
|
||||
description: typeof payload?.description === 'string' ? payload.description : 'dangerous command',
|
||||
command,
|
||||
description,
|
||||
sessionId: sessionId ?? null
|
||||
})
|
||||
|
||||
if (sessionId) {
|
||||
updateSessionState(sessionId, state => ({ ...state, needsInput: true }))
|
||||
}
|
||||
|
||||
dispatchNativeNotification({
|
||||
actions: [
|
||||
{ id: 'approve', text: translateNow('notifications.native.approveAction') },
|
||||
{ id: 'reject', text: translateNow('notifications.native.rejectAction') }
|
||||
],
|
||||
body: command || description,
|
||||
kind: 'approval',
|
||||
sessionId,
|
||||
title: translateNow('notifications.native.approvalTitle')
|
||||
})
|
||||
} else if (event.type === 'sudo.request') {
|
||||
// Sudo password capture (tools/terminal_tool.py). Blocked on
|
||||
// sudo.respond {request_id, password}.
|
||||
@@ -954,6 +1010,13 @@ export function useMessageStream({
|
||||
if (sessionId) {
|
||||
updateSessionState(sessionId, state => ({ ...state, needsInput: true }))
|
||||
}
|
||||
|
||||
dispatchNativeNotification({
|
||||
body: translateNow('notifications.native.inputBody'),
|
||||
kind: 'input',
|
||||
sessionId,
|
||||
title: translateNow('notifications.native.inputTitle')
|
||||
})
|
||||
}
|
||||
} else if (event.type === 'secret.request') {
|
||||
// Skill credential capture (tools/skills_tool.py). Blocked on
|
||||
@@ -961,16 +1024,26 @@ export function useMessageStream({
|
||||
const requestId = typeof payload?.request_id === 'string' ? payload.request_id : ''
|
||||
|
||||
if (requestId) {
|
||||
const envVar = typeof payload?.env_var === 'string' ? payload.env_var : ''
|
||||
const promptText = typeof payload?.prompt === 'string' ? payload.prompt : ''
|
||||
|
||||
setSecretRequest({
|
||||
requestId,
|
||||
envVar: typeof payload?.env_var === 'string' ? payload.env_var : '',
|
||||
prompt: typeof payload?.prompt === 'string' ? payload.prompt : '',
|
||||
envVar,
|
||||
prompt: promptText,
|
||||
sessionId: sessionId ?? null
|
||||
})
|
||||
|
||||
if (sessionId) {
|
||||
updateSessionState(sessionId, state => ({ ...state, needsInput: true }))
|
||||
}
|
||||
|
||||
dispatchNativeNotification({
|
||||
body: promptText || envVar || translateNow('notifications.native.inputBody'),
|
||||
kind: 'input',
|
||||
sessionId,
|
||||
title: translateNow('notifications.native.inputTitle')
|
||||
})
|
||||
}
|
||||
} else if (event.type === 'terminal.read.request') {
|
||||
// read_terminal tool: serialize the renderer's xterm buffer and answer
|
||||
@@ -987,6 +1060,12 @@ export function useMessageStream({
|
||||
text: result ? JSON.stringify(result) : ''
|
||||
})
|
||||
}
|
||||
} else if (event.type === 'status.update') {
|
||||
// The gateway's notification poller announces background process
|
||||
// completions / watch matches here — re-sync the status stack.
|
||||
if (sessionId && payload?.kind === 'process') {
|
||||
void refreshBackgroundProcesses(sessionId)
|
||||
}
|
||||
} else if (event.type === 'error') {
|
||||
const errorMessage = payload?.message || 'Hermes reported an error'
|
||||
const looksLikeProviderSetup = isProviderSetupErrorMessage(errorMessage)
|
||||
@@ -998,6 +1077,13 @@ export function useMessageStream({
|
||||
clearAllPrompts(sessionId)
|
||||
}
|
||||
|
||||
dispatchNativeNotification({
|
||||
body: errorMessage,
|
||||
kind: 'turnError',
|
||||
sessionId,
|
||||
title: translateNow('notifications.native.turnErrorTitle')
|
||||
})
|
||||
|
||||
if (looksLikeProviderSetup) {
|
||||
requestDesktopOnboarding(errorMessage)
|
||||
} else if (isActiveEvent) {
|
||||
@@ -1027,6 +1113,7 @@ export function useMessageStream({
|
||||
flushQueuedDeltas,
|
||||
queryClient,
|
||||
refreshHermesConfig,
|
||||
sessionInterrupted,
|
||||
updateSessionState,
|
||||
upsertToolCall
|
||||
]
|
||||
|
||||
@@ -3,8 +3,9 @@ import type { MutableRefObject } from 'react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { textPart } from '@/lib/chat-messages'
|
||||
import { $composerAttachments, type ComposerAttachment } from '@/store/composer'
|
||||
import { $connection, $sessions, setSessions } from '@/store/session'
|
||||
import { $busy, $connection, $messages, $sessions, setSessions } from '@/store/session'
|
||||
import type { SessionInfo } from '@/types/hermes'
|
||||
|
||||
import { uploadComposerAttachment, usePromptActions } from './use-prompt-actions'
|
||||
@@ -43,6 +44,7 @@ function sessionInfo(overrides: Partial<SessionInfo> = {}): SessionInfo {
|
||||
|
||||
interface HarnessHandle {
|
||||
cancelRun: () => Promise<void>
|
||||
restoreToMessage: (messageId: string) => Promise<void>
|
||||
steerPrompt: (text: string) => Promise<boolean>
|
||||
submitText: (
|
||||
text: string,
|
||||
@@ -57,6 +59,7 @@ function Harness({
|
||||
refreshSessions,
|
||||
requestGateway,
|
||||
resumeStoredSession,
|
||||
seedMessages,
|
||||
storedSessionId
|
||||
}: {
|
||||
busyRef?: MutableRefObject<boolean>
|
||||
@@ -65,6 +68,7 @@ function Harness({
|
||||
refreshSessions: () => Promise<void>
|
||||
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
|
||||
resumeStoredSession?: (storedSessionId: string) => Promise<void> | void
|
||||
seedMessages?: unknown[]
|
||||
storedSessionId?: null | string
|
||||
}) {
|
||||
const activeSessionIdRef: MutableRefObject<string | null> = { current: RUNTIME_SESSION_ID }
|
||||
@@ -73,7 +77,7 @@ function Harness({
|
||||
}
|
||||
const localBusyRef = busyRef ?? { current: false }
|
||||
const stateRef = useRef({
|
||||
messages: [],
|
||||
messages: seedMessages ?? [],
|
||||
busy: false,
|
||||
awaitingResponse: false,
|
||||
interrupted: true
|
||||
@@ -105,10 +109,11 @@ function Harness({
|
||||
useEffect(() => {
|
||||
onReady({
|
||||
cancelRun: actions.cancelRun,
|
||||
restoreToMessage: actions.restoreToMessage,
|
||||
steerPrompt: actions.steerPrompt,
|
||||
submitText: actions.submitText
|
||||
})
|
||||
}, [actions.cancelRun, actions.steerPrompt, actions.submitText, onReady])
|
||||
}, [actions.cancelRun, actions.restoreToMessage, actions.steerPrompt, actions.submitText, onReady])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -320,6 +325,81 @@ describe('usePromptActions submit / queue drain semantics', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('a rejected fromQueue drain returns false (entry stays queued) and a later retry sends it', async () => {
|
||||
// A stale-session 404 must not strand the queued entry: submitPrompt returns
|
||||
// false on failure so the composer keeps it, and the edge-independent
|
||||
// auto-drain re-attempts once the session is idle again. storedSessionId is
|
||||
// null so the session.resume recovery path is skipped and the error surfaces.
|
||||
let attempt = 0
|
||||
const requestGateway = vi.fn(async (method: string) => {
|
||||
if (method === 'prompt.submit') {
|
||||
attempt += 1
|
||||
|
||||
if (attempt === 1) {
|
||||
throw new Error('404: {"detail":"Session not found"}')
|
||||
}
|
||||
}
|
||||
|
||||
return {} as never
|
||||
})
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
render(
|
||||
<Harness
|
||||
onReady={h => (handle = h)}
|
||||
refreshSessions={async () => undefined}
|
||||
requestGateway={requestGateway}
|
||||
storedSessionId={null}
|
||||
/>
|
||||
)
|
||||
|
||||
const first = await handle!.submitText('please send me', { fromQueue: true })
|
||||
expect(first).toBe(false)
|
||||
|
||||
const second = await handle!.submitText('please send me', { fromQueue: true })
|
||||
expect(second).toBe(true)
|
||||
expect(requestGateway).toHaveBeenCalledWith('prompt.submit', {
|
||||
session_id: RUNTIME_SESSION_ID,
|
||||
text: 'please send me'
|
||||
})
|
||||
})
|
||||
|
||||
it('rides out a transient "session busy" so the user never sees it (retries, no error bubble)', async () => {
|
||||
// A submit racing the settle edge can hit a transient 4009 before the turn
|
||||
// has fully wound down. It must be invisible: retried in place until the
|
||||
// gateway accepts, never a red "session busy" bubble.
|
||||
let attempt = 0
|
||||
const seeds: Record<string, unknown>[] = []
|
||||
const requestGateway = vi.fn(async (method: string) => {
|
||||
if (method === 'prompt.submit') {
|
||||
attempt += 1
|
||||
|
||||
if (attempt === 1) {
|
||||
throw new Error('4009: session busy')
|
||||
}
|
||||
}
|
||||
|
||||
return {} as never
|
||||
})
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
render(
|
||||
<Harness
|
||||
onReady={h => (handle = h)}
|
||||
onSeedState={s => seeds.push(s)}
|
||||
refreshSessions={async () => undefined}
|
||||
requestGateway={requestGateway}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(await handle!.submitText('sent while settling')).toBe(true)
|
||||
expect(attempt).toBe(2) // rode past the busy on the second try
|
||||
// No assistant-error message was appended for the transient busy.
|
||||
expect(seeds.some(s => Array.isArray(s.messages) && (s.messages as { error?: string }[]).some(m => m.error))).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('a normal (non-queue) submit still respects the busyRef guard', async () => {
|
||||
const busyRef = { current: true }
|
||||
const requestGateway = vi.fn(async () => ({}) as never)
|
||||
@@ -395,6 +475,125 @@ describe('usePromptActions steerPrompt', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('usePromptActions restoreToMessage', () => {
|
||||
beforeEach(() => {
|
||||
$busy.set(false)
|
||||
$messages.set([
|
||||
{ id: 'u1', role: 'user', parts: [textPart('first prompt')] },
|
||||
{ id: 'a1', role: 'assistant', parts: [textPart('first answer')] },
|
||||
{ id: 'u2', role: 'user', parts: [textPart('second prompt')] },
|
||||
{ id: 'a2', role: 'assistant', parts: [textPart('second answer')] }
|
||||
])
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
$busy.set(false)
|
||||
$messages.set([])
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('rewinds to the target user turn and resubmits its text', async () => {
|
||||
const requestGateway = vi.fn(async () => ({}) as never)
|
||||
let lastState: Record<string, unknown> = {}
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
render(
|
||||
<Harness
|
||||
onReady={h => (handle = h)}
|
||||
onSeedState={state => (lastState = state)}
|
||||
refreshSessions={async () => undefined}
|
||||
requestGateway={requestGateway}
|
||||
seedMessages={$messages.get()}
|
||||
/>
|
||||
)
|
||||
|
||||
await handle!.restoreToMessage('u1')
|
||||
|
||||
// Ordinal 0 = "truncate before the first visible user message": the gateway
|
||||
// drops that turn and everything after, then runs the same text again.
|
||||
expect(requestGateway).toHaveBeenCalledWith('prompt.submit', {
|
||||
session_id: RUNTIME_SESSION_ID,
|
||||
text: 'first prompt',
|
||||
truncate_before_user_ordinal: 0
|
||||
})
|
||||
expect((lastState.messages as { id: string }[]).map(m => m.id)).toEqual(['u1'])
|
||||
expect(lastState.busy).toBe(true)
|
||||
})
|
||||
|
||||
it('rethrows gateway failures and clears the busy flags for the dialog to surface', async () => {
|
||||
const requestGateway = vi.fn(async () => {
|
||||
throw new Error('gateway exploded')
|
||||
})
|
||||
|
||||
let lastState: Record<string, unknown> = {}
|
||||
let handle: HarnessHandle | null = null
|
||||
|
||||
render(
|
||||
<Harness
|
||||
onReady={h => (handle = h)}
|
||||
onSeedState={state => (lastState = state)}
|
||||
refreshSessions={async () => undefined}
|
||||
requestGateway={requestGateway}
|
||||
/>
|
||||
)
|
||||
|
||||
await expect(handle!.restoreToMessage('u2')).rejects.toThrow('gateway exploded')
|
||||
expect(lastState.busy).toBe(false)
|
||||
})
|
||||
|
||||
it('interrupts the live turn and retries past "session busy" when reverting mid-stream', async () => {
|
||||
$busy.set(true)
|
||||
|
||||
let submitAttempts = 0
|
||||
const requestGateway = vi.fn(async (method: string) => {
|
||||
if (method === 'prompt.submit') {
|
||||
submitAttempts += 1
|
||||
|
||||
// The cooperative interrupt hasn't wound the turn down yet on the first
|
||||
// try; the second attempt lands once the gateway reports idle.
|
||||
if (submitAttempts === 1) {
|
||||
throw new Error('session busy')
|
||||
}
|
||||
}
|
||||
|
||||
return {} as never
|
||||
})
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
render(
|
||||
<Harness
|
||||
onReady={h => (handle = h)}
|
||||
refreshSessions={async () => undefined}
|
||||
requestGateway={requestGateway}
|
||||
seedMessages={$messages.get()}
|
||||
/>
|
||||
)
|
||||
|
||||
await handle!.restoreToMessage('u1')
|
||||
|
||||
expect(requestGateway).toHaveBeenCalledWith('session.interrupt', { session_id: RUNTIME_SESSION_ID })
|
||||
expect(submitAttempts).toBe(2)
|
||||
expect(requestGateway).toHaveBeenCalledWith('prompt.submit', {
|
||||
session_id: RUNTIME_SESSION_ID,
|
||||
text: 'first prompt',
|
||||
truncate_before_user_ordinal: 0
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores non-user targets and unknown ids without touching the gateway', async () => {
|
||||
const requestGateway = vi.fn(async () => ({}) as never)
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
render(<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />)
|
||||
|
||||
await handle!.restoreToMessage('a1')
|
||||
await handle!.restoreToMessage('missing')
|
||||
|
||||
expect(requestGateway).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('usePromptActions file attachment sync', () => {
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
@@ -677,7 +876,7 @@ describe('usePromptActions sleep/wake session recovery', () => {
|
||||
const requestGateway = vi.fn(async (method: string) => {
|
||||
calls.push(method)
|
||||
if (method === 'prompt.submit') {
|
||||
throw new Error('session busy')
|
||||
throw new Error('gateway exploded')
|
||||
}
|
||||
return {} as never
|
||||
})
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
terminalContextBlocksFromDraft,
|
||||
updateComposerAttachment
|
||||
} from '@/store/composer'
|
||||
import { resetSessionBackground } from '@/store/composer-status'
|
||||
import { clearNotifications, notify, notifyError } from '@/store/notifications'
|
||||
import { requestDesktopOnboarding } from '@/store/onboarding'
|
||||
import { $activeGatewayProfile, $newChatProfile, ensureGatewayProfile, normalizeProfileKey } from '@/store/profile'
|
||||
@@ -52,6 +53,8 @@ import {
|
||||
setSessions,
|
||||
setYoloActive
|
||||
} from '@/store/session'
|
||||
import { clearSessionSubagents } from '@/store/subagents'
|
||||
import { clearSessionTodos } from '@/store/todos'
|
||||
|
||||
import type {
|
||||
ClientSessionState,
|
||||
@@ -114,6 +117,40 @@ function isSessionNotFoundError(error: unknown): boolean {
|
||||
return /session not found/i.test(message)
|
||||
}
|
||||
|
||||
// The gateway refuses prompt.submit while a turn is running (4009 "session
|
||||
// busy"). It's a transient concurrency guard, never a user-facing error: a
|
||||
// submit racing the settle edge (or a rewind interrupting mid-turn) just waits
|
||||
// a beat for the turn to wind down, then lands. Bounded so a genuinely stuck
|
||||
// turn still surfaces eventually.
|
||||
const SESSION_BUSY_RETRY_TIMEOUT_MS = 6_000
|
||||
const SESSION_BUSY_RETRY_INTERVAL_MS = 150
|
||||
|
||||
function isSessionBusyError(error: unknown): boolean {
|
||||
return /session busy/i.test(error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
|
||||
const sleep = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms))
|
||||
|
||||
// Retry a gateway call across transient "session busy" so it never reaches the
|
||||
// user — the turn settles within the deadline and the call lands.
|
||||
async function withSessionBusyRetry<T>(call: () => Promise<T>): Promise<T> {
|
||||
const deadline = Date.now() + SESSION_BUSY_RETRY_TIMEOUT_MS
|
||||
|
||||
for (;;) {
|
||||
try {
|
||||
return await call()
|
||||
} catch (err) {
|
||||
if (isSessionBusyError(err) && Date.now() < deadline) {
|
||||
await sleep(SESSION_BUSY_RETRY_INTERVAL_MS)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
throw err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function base64FromDataUrl(dataUrl: string): string {
|
||||
const comma = dataUrl.indexOf(',')
|
||||
|
||||
@@ -523,6 +560,7 @@ export function usePromptActions({
|
||||
// Images use their base64 preview so the thumbnail renders inline without
|
||||
// a (remote-mode 403-prone) /api/media fetch — see optimisticAttachmentRef.
|
||||
let attachmentRefs = attachments.map(optimisticAttachmentRef).filter((r): r is string => Boolean(r))
|
||||
|
||||
const buildContextText = (atts: ComposerAttachment[]): string => {
|
||||
const contextRefs = atts
|
||||
.map(a => a.refText)
|
||||
@@ -540,6 +578,7 @@ export function usePromptActions({
|
||||
// bounce the drained send. The drain lock serializes them; the user path
|
||||
// keeps the guard so a stray Enter mid-turn can't double-submit.
|
||||
const hasSendable = Boolean(visibleText || terminalContextBlocks || attachments.length || hasImage)
|
||||
|
||||
if (!hasSendable || (!options?.fromQueue && busyRef.current)) {
|
||||
return false
|
||||
}
|
||||
@@ -652,6 +691,7 @@ export function usePromptActions({
|
||||
const syncedAttachments = await syncAttachmentsForSubmit(sessionId, attachments, {
|
||||
updateComposerAttachments: usingComposerAttachments
|
||||
})
|
||||
|
||||
// Rewrite the optimistic message + prompt text with the synced refs so
|
||||
// the gateway receives @file: paths that resolve in its workspace.
|
||||
// (Images keep their inline base64 preview — see optimisticAttachmentRef.)
|
||||
@@ -665,18 +705,19 @@ export function usePromptActions({
|
||||
let submitErr: unknown = null
|
||||
|
||||
try {
|
||||
await requestGateway('prompt.submit', { session_id: sessionId, text })
|
||||
await withSessionBusyRetry(() => requestGateway('prompt.submit', { session_id: sessionId, text }))
|
||||
} catch (firstErr) {
|
||||
if (isSessionNotFoundError(firstErr) && selectedStoredSessionIdRef.current) {
|
||||
// Re-register the session in the gateway and get a fresh live ID.
|
||||
const resumed = await requestGateway<{ session_id: string }>('session.resume', {
|
||||
session_id: selectedStoredSessionIdRef.current
|
||||
})
|
||||
|
||||
const recoveredId = resumed?.session_id
|
||||
|
||||
if (recoveredId) {
|
||||
activeSessionIdRef.current = recoveredId
|
||||
await requestGateway('prompt.submit', { session_id: recoveredId, text })
|
||||
await withSessionBusyRetry(() => requestGateway('prompt.submit', { session_id: recoveredId, text }))
|
||||
} else {
|
||||
submitErr = firstErr
|
||||
}
|
||||
@@ -695,9 +736,17 @@ export function usePromptActions({
|
||||
|
||||
return true
|
||||
} catch (err) {
|
||||
releaseBusy()
|
||||
|
||||
// A queued drain that raced a not-yet-settled turn gets a transient
|
||||
// "session busy" (4009). Don't surface an error bubble/toast — the entry
|
||||
// stays queued and the composer's bounded auto-drain retries when idle.
|
||||
if (options?.fromQueue && isSessionBusyError(err)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const message = inlineErrorMessage(err, copy.promptFailed)
|
||||
|
||||
releaseBusy()
|
||||
updateSessionState(sessionId, state => ({
|
||||
...state,
|
||||
messages: [
|
||||
@@ -1234,12 +1283,13 @@ export function usePromptActions({
|
||||
|
||||
const cancelRun = useCallback(async () => {
|
||||
const sessionId = activeSessionId || activeSessionIdRef.current
|
||||
const releaseBusy = () => {
|
||||
setMutableRef(busyRef, false)
|
||||
setBusy(false)
|
||||
}
|
||||
|
||||
setAwaitingResponse(false)
|
||||
|
||||
// Interrupting keeps whatever was already generated and just
|
||||
// stops — no "[interrupted]" marker. A pending/streaming message with no
|
||||
// body text is dropped entirely so we never leave an empty bubble behind.
|
||||
const finalizeMessages = (messages: ChatMessage[], streamId?: string | null) =>
|
||||
messages
|
||||
.filter(
|
||||
@@ -1251,8 +1301,7 @@ export function usePromptActions({
|
||||
)
|
||||
|
||||
if (!sessionId) {
|
||||
setMutableRef(busyRef, false)
|
||||
setBusy(false)
|
||||
releaseBusy()
|
||||
setMessages(finalizeMessages($messages.get()))
|
||||
|
||||
return
|
||||
@@ -1260,13 +1309,12 @@ export function usePromptActions({
|
||||
|
||||
updateSessionState(sessionId, state => {
|
||||
const streamId = state.streamId
|
||||
|
||||
const messages = finalizeMessages(state.messages, streamId)
|
||||
|
||||
return {
|
||||
...state,
|
||||
messages,
|
||||
busy: true,
|
||||
busy: false,
|
||||
awaitingResponse: false,
|
||||
streamId: null,
|
||||
pendingBranchGroup: null,
|
||||
@@ -1274,8 +1322,13 @@ export function usePromptActions({
|
||||
}
|
||||
})
|
||||
|
||||
clearSessionTodos(sessionId)
|
||||
clearSessionSubagents(sessionId)
|
||||
resetSessionBackground(sessionId)
|
||||
|
||||
try {
|
||||
await requestGateway('session.interrupt', { session_id: sessionId })
|
||||
releaseBusy()
|
||||
} catch (err) {
|
||||
let stopError = err
|
||||
|
||||
@@ -1284,11 +1337,13 @@ export function usePromptActions({
|
||||
const resumed = await requestGateway<{ session_id: string }>('session.resume', {
|
||||
session_id: selectedStoredSessionIdRef.current
|
||||
})
|
||||
|
||||
const recoveredId = resumed?.session_id
|
||||
|
||||
if (recoveredId) {
|
||||
activeSessionIdRef.current = recoveredId
|
||||
await requestGateway('session.interrupt', { session_id: recoveredId })
|
||||
releaseBusy()
|
||||
|
||||
return
|
||||
}
|
||||
@@ -1297,8 +1352,7 @@ export function usePromptActions({
|
||||
}
|
||||
}
|
||||
|
||||
setMutableRef(busyRef, false)
|
||||
setBusy(false)
|
||||
releaseBusy()
|
||||
notifyError(stopError, copy.stopFailed)
|
||||
}
|
||||
}, [
|
||||
@@ -1421,13 +1475,101 @@ export function usePromptActions({
|
||||
[activeSessionId, copy.regenerateFailed, requestGateway, updateSessionState]
|
||||
)
|
||||
|
||||
// Cursor-style "restore checkpoint": rewind the conversation to a past user
|
||||
// prompt and run it again from there. Reuses the edit composer's rewind
|
||||
// mechanism — `prompt.submit` with `truncate_before_user_ordinal` drops that
|
||||
// user turn and everything after it from the session history, then the same
|
||||
// text is submitted as a fresh turn. Callers confirm before invoking; errors
|
||||
// are rethrown so the confirmation dialog can surface them inline.
|
||||
// Submit a rewind (truncate-before-ordinal + resubmit). Because edit/restore
|
||||
// can fire while a turn is streaming, interrupt the live turn first — the
|
||||
// cooperative interrupt takes a beat, so the shared busy-retry rides it out.
|
||||
const submitRewindPrompt = useCallback(
|
||||
async (sessionId: string, text: string, truncateOrdinal: number | undefined, wasRunning: boolean) => {
|
||||
if (wasRunning) {
|
||||
try {
|
||||
await requestGateway('session.interrupt', { session_id: sessionId })
|
||||
} catch {
|
||||
// Best-effort — the busy-retry below still gates the submit.
|
||||
}
|
||||
}
|
||||
|
||||
await withSessionBusyRetry(() =>
|
||||
requestGateway('prompt.submit', {
|
||||
session_id: sessionId,
|
||||
text,
|
||||
...(truncateOrdinal !== undefined && { truncate_before_user_ordinal: truncateOrdinal })
|
||||
})
|
||||
)
|
||||
},
|
||||
[requestGateway]
|
||||
)
|
||||
|
||||
const restoreToMessage = useCallback(
|
||||
async (messageId: string) => {
|
||||
const sessionId = activeSessionId || activeSessionIdRef.current
|
||||
|
||||
if (!sessionId) {
|
||||
return
|
||||
}
|
||||
|
||||
const messages = $messages.get()
|
||||
const sourceIndex = messages.findIndex(m => m.id === messageId)
|
||||
const source = messages[sourceIndex]
|
||||
|
||||
if (!source || source.role !== 'user') {
|
||||
return
|
||||
}
|
||||
|
||||
const text = chatMessageText(source).trim()
|
||||
|
||||
if (!text) {
|
||||
return
|
||||
}
|
||||
|
||||
const wasRunning = $busy.get()
|
||||
const truncateBeforeUserOrdinal = visibleUserOrdinal(messages, sourceIndex)
|
||||
|
||||
// The turns we're discarding may have spawned todos and background
|
||||
// processes; they belong to the abandoned timeline, so wipe their status
|
||||
// rows (and kill the live processes) before the fresh run repopulates.
|
||||
clearSessionTodos(sessionId)
|
||||
resetSessionBackground(sessionId)
|
||||
|
||||
clearNotifications()
|
||||
setMutableRef(busyRef, true)
|
||||
setBusy(true)
|
||||
setAwaitingResponse(true)
|
||||
updateSessionState(sessionId, state => ({
|
||||
...state,
|
||||
busy: true,
|
||||
awaitingResponse: true,
|
||||
pendingBranchGroup: null,
|
||||
sawAssistantPayload: false,
|
||||
interrupted: false,
|
||||
messages: state.messages.slice(0, sourceIndex + 1)
|
||||
}))
|
||||
|
||||
try {
|
||||
await submitRewindPrompt(sessionId, text, truncateBeforeUserOrdinal, wasRunning)
|
||||
} catch (err) {
|
||||
setMutableRef(busyRef, false)
|
||||
setBusy(false)
|
||||
setAwaitingResponse(false)
|
||||
updateSessionState(sessionId, state => ({ ...state, busy: false, awaitingResponse: false }))
|
||||
throw err
|
||||
}
|
||||
},
|
||||
[activeSessionId, activeSessionIdRef, busyRef, submitRewindPrompt, updateSessionState]
|
||||
)
|
||||
|
||||
const editMessage = useCallback(
|
||||
async (edited: AppendMessage) => {
|
||||
const sessionId = activeSessionId || activeSessionIdRef.current
|
||||
const sourceId = edited.sourceId || edited.parentId
|
||||
const text = appendText(edited)
|
||||
|
||||
if (!sessionId || !sourceId || !text || edited.role !== 'user' || $busy.get()) {
|
||||
if (!sessionId || !sourceId || !text || edited.role !== 'user') {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1439,12 +1581,23 @@ export function usePromptActions({
|
||||
return
|
||||
}
|
||||
|
||||
// Sending an edit is a revert: rewind to this prompt and re-run with the
|
||||
// new text. It can fire mid-turn, so capture the live state — the submit
|
||||
// helper interrupts first when a turn is running.
|
||||
const wasRunning = $busy.get()
|
||||
|
||||
// Failed turn: optimistic user msg never reached the gateway, so truncating
|
||||
// by ordinal would 422. Submit as a plain resend instead.
|
||||
const nextMessage = messages[sourceIndex + 1]
|
||||
const isFailedTurn = nextMessage?.role === 'assistant' && Boolean(nextMessage.error)
|
||||
const editedMessage: ChatMessage = { ...source, parts: [textPart(text)] }
|
||||
|
||||
// Editing rewinds the conversation to this prompt — same as restore — so
|
||||
// drop the abandoned timeline's todos/background rows (and kill the live
|
||||
// processes) before the re-run repopulates them.
|
||||
clearSessionTodos(sessionId)
|
||||
resetSessionBackground(sessionId)
|
||||
|
||||
clearNotifications()
|
||||
setMutableRef(busyRef, true)
|
||||
setBusy(true)
|
||||
@@ -1459,24 +1612,18 @@ export function usePromptActions({
|
||||
messages: [...state.messages.slice(0, sourceIndex), editedMessage]
|
||||
}))
|
||||
|
||||
const submit = (truncateOrdinal?: number) =>
|
||||
requestGateway('prompt.submit', {
|
||||
session_id: sessionId,
|
||||
text,
|
||||
...(truncateOrdinal !== undefined && { truncate_before_user_ordinal: truncateOrdinal })
|
||||
})
|
||||
|
||||
const isStaleTargetError = (err: unknown) =>
|
||||
/no longer in session history|not in session history/i.test(err instanceof Error ? err.message : String(err))
|
||||
|
||||
try {
|
||||
await submit(isFailedTurn ? undefined : visibleUserOrdinal(messages, sourceIndex))
|
||||
await submitRewindPrompt(sessionId, text, isFailedTurn ? undefined : visibleUserOrdinal(messages, sourceIndex), wasRunning)
|
||||
} catch (err) {
|
||||
let surfaced = err
|
||||
|
||||
if (!isFailedTurn && isStaleTargetError(err)) {
|
||||
try {
|
||||
await submit()
|
||||
// Already interrupted on the first attempt — submit as a plain resend.
|
||||
await submitRewindPrompt(sessionId, text, undefined, false)
|
||||
|
||||
return
|
||||
} catch (retryErr) {
|
||||
@@ -1491,7 +1638,7 @@ export function usePromptActions({
|
||||
notifyError(surfaced, copy.editFailed)
|
||||
}
|
||||
},
|
||||
[activeSessionId, activeSessionIdRef, busyRef, copy.editFailed, requestGateway, updateSessionState]
|
||||
[activeSessionId, activeSessionIdRef, busyRef, copy.editFailed, submitRewindPrompt, updateSessionState]
|
||||
)
|
||||
|
||||
const handleThreadMessagesChange = useCallback(
|
||||
@@ -1534,6 +1681,7 @@ export function usePromptActions({
|
||||
handleThreadMessagesChange,
|
||||
handoffSession,
|
||||
reloadFromMessage,
|
||||
restoreToMessage,
|
||||
steerPrompt,
|
||||
submitText,
|
||||
transcribeVoiceAudio
|
||||
|
||||
119
apps/desktop/src/app/session/hooks/use-session-actions.test.tsx
Normal file
119
apps/desktop/src/app/session/hooks/use-session-actions.test.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { cleanup, render, waitFor } from '@testing-library/react'
|
||||
import type { MutableRefObject } from 'react'
|
||||
import { useEffect } from 'react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { $activeGatewayProfile, $newChatProfile } from '@/store/profile'
|
||||
import { $currentCwd } from '@/store/session'
|
||||
|
||||
import type { ClientSessionState } from '../../types'
|
||||
|
||||
import { useSessionActions } from './use-session-actions'
|
||||
|
||||
vi.mock('@/hermes', async importOriginal => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
deleteSession: vi.fn(),
|
||||
getSessionMessages: vi.fn(),
|
||||
listAllProfileSessions: vi.fn(),
|
||||
setApiRequestProfile: vi.fn(),
|
||||
setSessionArchived: vi.fn()
|
||||
}))
|
||||
|
||||
const RUNTIME_SESSION_ID = 'rt-new-001'
|
||||
|
||||
function Harness({
|
||||
onReady,
|
||||
requestGateway
|
||||
}: {
|
||||
onReady: (create: (preview?: string | null) => Promise<string | null>) => void
|
||||
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
|
||||
}) {
|
||||
const ref = <T,>(value: T): MutableRefObject<T> => ({ current: value })
|
||||
|
||||
const actions = useSessionActions({
|
||||
activeSessionId: null,
|
||||
activeSessionIdRef: ref<string | null>(null),
|
||||
busyRef: ref(false),
|
||||
creatingSessionRef: ref(false),
|
||||
ensureSessionState: () => ({}) as ClientSessionState,
|
||||
getRouteToken: () => 'token',
|
||||
navigate: vi.fn() as never,
|
||||
requestGateway,
|
||||
runtimeIdByStoredSessionIdRef: ref(new Map<string, string>()),
|
||||
selectedStoredSessionId: null,
|
||||
selectedStoredSessionIdRef: ref<string | null>(null),
|
||||
sessionStateByRuntimeIdRef: ref(new Map<string, ClientSessionState>()),
|
||||
syncSessionStateToView: vi.fn(),
|
||||
updateSessionState: () => ({}) as ClientSessionState
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
onReady(actions.createBackendSessionForSend)
|
||||
}, [actions.createBackendSessionForSend, onReady])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
async function createWith(profileSetup: () => void): Promise<Record<string, unknown> | undefined> {
|
||||
let createParams: Record<string, unknown> | undefined
|
||||
|
||||
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
|
||||
if (method === 'session.create') {
|
||||
createParams = params
|
||||
|
||||
return { session_id: RUNTIME_SESSION_ID, stored_session_id: null } as never
|
||||
}
|
||||
|
||||
return {} as never
|
||||
})
|
||||
|
||||
$currentCwd.set('')
|
||||
profileSetup()
|
||||
|
||||
let create: ((preview?: string | null) => Promise<string | null>) | null = null
|
||||
render(<Harness onReady={c => (create = c)} requestGateway={requestGateway} />)
|
||||
await waitFor(() => expect(create).not.toBeNull())
|
||||
await create!()
|
||||
|
||||
return createParams
|
||||
}
|
||||
|
||||
describe('createBackendSessionForSend profile routing', () => {
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
$newChatProfile.set(null)
|
||||
$activeGatewayProfile.set('default')
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('routes a plain new chat (no explicit profile) to the live gateway profile', async () => {
|
||||
// The "rubberband to default" bug: the top New Session button clears
|
||||
// $newChatProfile to null. In global-remote mode one backend serves every
|
||||
// profile, so an omitted `profile` lands the chat on the launch (default)
|
||||
// profile. The session must instead carry the active gateway profile.
|
||||
const params = await createWith(() => {
|
||||
$activeGatewayProfile.set('coder')
|
||||
$newChatProfile.set(null)
|
||||
})
|
||||
|
||||
expect(params).toMatchObject({ profile: 'coder' })
|
||||
})
|
||||
|
||||
it('honours an explicit per-profile "+" selection', async () => {
|
||||
const params = await createWith(() => {
|
||||
$activeGatewayProfile.set('coder')
|
||||
$newChatProfile.set('analyst')
|
||||
})
|
||||
|
||||
expect(params).toMatchObject({ profile: 'analyst' })
|
||||
})
|
||||
|
||||
it('passes the default profile for single-profile users (backend resolves it to launch)', async () => {
|
||||
const params = await createWith(() => {
|
||||
$activeGatewayProfile.set('default')
|
||||
$newChatProfile.set(null)
|
||||
})
|
||||
|
||||
expect(params).toMatchObject({ profile: 'default' })
|
||||
})
|
||||
})
|
||||
@@ -2,7 +2,7 @@ import type { MutableRefObject } from 'react'
|
||||
import { useCallback, useRef } from 'react'
|
||||
import type { NavigateFunction } from 'react-router-dom'
|
||||
|
||||
import { deleteSession, getSessionMessages, listAllProfileSessions, setSessionArchived } from '@/hermes'
|
||||
import { deleteSession, getSession, getSessionMessages, setSessionArchived } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { type ChatMessage, chatMessageText, preserveLocalAssistantErrors, toChatMessages } from '@/lib/chat-messages'
|
||||
import { normalizePersonalityValue } from '@/lib/chat-runtime'
|
||||
@@ -12,7 +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 { $activeGatewayProfile, $newChatProfile, $profiles, ensureGatewayProfile, normalizeProfileKey } from '@/store/profile'
|
||||
import {
|
||||
$currentCwd,
|
||||
$messages,
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
workspaceCwdForNewSession
|
||||
} from '@/store/session'
|
||||
import { reportBackendContract } from '@/store/updates'
|
||||
import { isWatchWindow } from '@/store/windows'
|
||||
import type { SessionCreateResponse, SessionInfo, SessionResumeResponse, SessionRuntimeInfo, UsageStats } from '@/types/hermes'
|
||||
|
||||
import { NEW_CHAT_ROUTE, sessionRoute, SETTINGS_ROUTE } from '../../routes'
|
||||
@@ -235,18 +236,42 @@ async function resolveStoredSession(storedSessionId: string): Promise<SessionInf
|
||||
return cached
|
||||
}
|
||||
|
||||
// Direct by-id on the live backend — one row lookup, no list scan. Covers
|
||||
// single-profile users and any id on the active profile (e.g. an old session
|
||||
// past the sidebar's recent window). 404 just means it's not on this profile.
|
||||
try {
|
||||
const result = await listAllProfileSessions(500, 0, 'include', 'recent', 'all')
|
||||
const resolved = result.sessions.find(session => sessionMatchesStoredId(session, storedSessionId))
|
||||
const session = await getSession(storedSessionId)
|
||||
|
||||
if (resolved) {
|
||||
upsertResolvedSession(resolved, storedSessionId)
|
||||
}
|
||||
upsertResolvedSession(session, storedSessionId)
|
||||
|
||||
return resolved
|
||||
return session
|
||||
} catch {
|
||||
return undefined
|
||||
// Not on the active profile — fall through to the cross-profile probe.
|
||||
}
|
||||
|
||||
// Multi-profile only: probe each other profile by id (still one cheap lookup
|
||||
// each) rather than pulling every profile's recent sessions. The first hit
|
||||
// carries its owning `profile`, which routes the resume to the right backend.
|
||||
const activeKey = normalizeProfileKey($activeGatewayProfile.get())
|
||||
|
||||
const otherProfiles = $profiles
|
||||
.get()
|
||||
.map(profile => normalizeProfileKey(profile.name))
|
||||
.filter(key => key !== activeKey)
|
||||
|
||||
for (const profile of otherProfiles) {
|
||||
try {
|
||||
const session = await getSession(storedSessionId, profile)
|
||||
|
||||
upsertResolvedSession(session, storedSessionId)
|
||||
|
||||
return session
|
||||
} catch {
|
||||
// Not on this profile; try the next.
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
type SessionRuntimeStatePatch = Partial<
|
||||
@@ -406,13 +431,17 @@ export function useSessionActions({
|
||||
creatingSessionRef.current = true
|
||||
|
||||
try {
|
||||
// Route the new chat to the chosen profile's backend (null = primary,
|
||||
// so single-profile users are unaffected).
|
||||
await ensureGatewayProfile($newChatProfile.get())
|
||||
// A plain new session (top "New Session", /new, keybind) leaves
|
||||
// $newChatProfile null to mean "use the live context"; the per-profile
|
||||
// "+" sets it explicitly. Resolve null to the active gateway profile so
|
||||
// session.create always carries it: in global-remote mode one backend
|
||||
// serves every profile, so an omitted profile param silently lands the
|
||||
// chat on the launch (default) profile — the "rubberbands back to
|
||||
// default" bug. This is a no-op for single-profile/local-pooled users:
|
||||
// a backend resolves its own launch profile to None (_profile_home).
|
||||
const newChatProfile = $newChatProfile.get() ?? normalizeProfileKey($activeGatewayProfile.get())
|
||||
await ensureGatewayProfile(newChatProfile)
|
||||
const cwd = $currentCwd.get().trim() || workspaceCwdForNewSession()
|
||||
// Pass the owning profile so a new chat under a non-launch profile (global
|
||||
// remote mode) builds its agent + persists against THAT profile's home/db.
|
||||
const newChatProfile = $newChatProfile.get()
|
||||
|
||||
const created = await requestGateway<SessionCreateResponse>('session.create', {
|
||||
cols: 96,
|
||||
@@ -518,8 +547,31 @@ export function useSessionActions({
|
||||
const isCurrentResume = () =>
|
||||
resumeRequestRef.current === requestId && selectedStoredSessionIdRef.current === storedSessionId
|
||||
|
||||
// Paint the click before the profile-resolve / gateway-swap awaits below,
|
||||
// so there's zero dead air: highlight the row instantly (the sidebar reads
|
||||
// $selectedStoredSessionId) and, for a cold target, drop the previous
|
||||
// transcript so the thread shows its loader instead of the old session
|
||||
// lingering until resume lands. A warm-cached target keeps its transcript —
|
||||
// the cached fast-path repaints it this same tick. Setting the ref here is
|
||||
// also what use-route-resume's self-heal assumes ("set synchronously at
|
||||
// resume entry").
|
||||
setFreshDraftReady(false)
|
||||
clearNotifications()
|
||||
setSelectedStoredSessionId(storedSessionId)
|
||||
selectedStoredSessionIdRef.current = storedSessionId
|
||||
|
||||
const warmRuntimeId = runtimeIdByStoredSessionIdRef.current.get(storedSessionId)
|
||||
|
||||
if (!warmRuntimeId || !sessionStateByRuntimeIdRef.current.get(warmRuntimeId)) {
|
||||
setActiveSessionId(null)
|
||||
activeSessionIdRef.current = null
|
||||
setMessages([])
|
||||
}
|
||||
|
||||
// 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).
|
||||
// resolveStoredSession finds the row by id (cheap), so an uncached pasted
|
||||
// id loads as fast as a sidebar click instead of hanging on a list scan.
|
||||
const storedForProfile = await resolveStoredSession(storedSessionId)
|
||||
const sessionProfile = storedForProfile?.profile
|
||||
|
||||
@@ -534,6 +586,7 @@ export function useSessionActions({
|
||||
|
||||
if (cachedRuntimeId && cachedState) {
|
||||
const stored = $sessions.get().find(session => session.id === storedSessionId)
|
||||
|
||||
const cachedViewState =
|
||||
!cachedState.model && stored?.model != null
|
||||
? {
|
||||
@@ -606,40 +659,46 @@ export function useSessionActions({
|
||||
}))
|
||||
}
|
||||
|
||||
let resumedRunning = false
|
||||
|
||||
try {
|
||||
// Load the local snapshot first, then ask the gateway to resume.
|
||||
// Previously these raced:
|
||||
// 1. clear messages to []
|
||||
// 2. local getSessionMessages -> 45 msgs
|
||||
// 3. a second resume path cleared [] again
|
||||
// 4. gateway resume -> 43 msgs
|
||||
// That is the ctrl+R flash chain. Avoid showing an empty thread
|
||||
// while we already have a route-scoped session id, and don't race the
|
||||
// local snapshot against gateway resume.
|
||||
const watchWindow = isWatchWindow()
|
||||
let localSnapshot = $messages.get()
|
||||
|
||||
// REST transcript prefetch and the gateway resume RPC are independent
|
||||
// — run them concurrently so a big session's wall time is
|
||||
// max(prefetch, resume) instead of their sum. The prefetch paints the
|
||||
// transcript as soon as it lands; the RPC binds the runtime id.
|
||||
// Watch windows skip the prefetch — lazy resume attaches the live mirror.
|
||||
const prefetchPromise = watchWindow ? null : getSessionMessages(storedSessionId, sessionProfile)
|
||||
|
||||
const resumePromise = requestGateway<SessionResumeResponse>('session.resume', {
|
||||
session_id: storedSessionId,
|
||||
cols: 96,
|
||||
...(watchWindow ? { lazy: true } : {}),
|
||||
...(sessionProfile ? { profile: sessionProfile } : {})
|
||||
})
|
||||
// The rejection is consumed by the `await` below; this guard only
|
||||
// keeps it from surfacing as unhandled while the prefetch settles.
|
||||
resumePromise.catch(() => undefined)
|
||||
|
||||
try {
|
||||
const storedMessages = await getSessionMessages(storedSessionId, sessionProfile)
|
||||
if (prefetchPromise) {
|
||||
const storedMessages = await prefetchPromise
|
||||
|
||||
if (isCurrentResume()) {
|
||||
localSnapshot = preserveLocalAssistantErrors(toChatMessages(storedMessages.messages), $messages.get())
|
||||
if (isCurrentResume()) {
|
||||
localSnapshot = preserveLocalAssistantErrors(toChatMessages(storedMessages.messages), $messages.get())
|
||||
|
||||
if (!chatMessageArraysEquivalent($messages.get(), localSnapshot)) {
|
||||
setMessages(localSnapshot)
|
||||
if (!chatMessageArraysEquivalent($messages.get(), localSnapshot)) {
|
||||
setMessages(localSnapshot)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal: gateway resume below can still hydrate the session.
|
||||
}
|
||||
|
||||
const resumed = await requestGateway<SessionResumeResponse>('session.resume', {
|
||||
session_id: storedSessionId,
|
||||
cols: 96,
|
||||
// Owning profile: in app-global remote mode one backend serves every
|
||||
// profile, so the gateway opens this profile's state.db + home to
|
||||
// resume + persist the right session (no-op for single/launch profile).
|
||||
...(sessionProfile ? { profile: sessionProfile } : {})
|
||||
})
|
||||
const resumed = await resumePromise
|
||||
|
||||
if (!isCurrentResume()) {
|
||||
return
|
||||
@@ -647,25 +706,22 @@ export function useSessionActions({
|
||||
|
||||
const currentMessages = $messages.get()
|
||||
|
||||
const resumedMessages = preserveLocalAssistantErrors(
|
||||
reconcileResumeMessages(toChatMessages(resumed.messages), currentMessages),
|
||||
currentMessages
|
||||
)
|
||||
// Avoid a second visible transcript rebuild on resume/switch.
|
||||
// `getSessionMessages()` is the stable stored transcript snapshot and
|
||||
// paints first; `session.resume` can return a slightly different
|
||||
// runtime-shaped projection (e.g. tool/system coalescing), which was
|
||||
// causing a second full message-list replacement a second later.
|
||||
// Keep the already-painted local snapshot for the view/cache when it
|
||||
// exists; use gateway messages only as a fallback when no local
|
||||
// snapshot was available.
|
||||
|
||||
// Keep the local snapshot when resume would only reshuffle runtime
|
||||
// projection. When the REST prefetch already hydrated the transcript,
|
||||
// skip converting/reconciling the resume payload entirely — on a
|
||||
// 1000+-message session that second conversion plus the deep
|
||||
// equivalence compare costs over a second of main-thread time.
|
||||
const preferredMessages =
|
||||
localSnapshot.length > 0
|
||||
? localSnapshot
|
||||
: chatMessageArraysEquivalent(currentMessages, resumedMessages)
|
||||
? currentMessages
|
||||
: resumedMessages
|
||||
: (() => {
|
||||
const resumedMessages = preserveLocalAssistantErrors(
|
||||
reconcileResumeMessages(toChatMessages(resumed.messages), currentMessages),
|
||||
currentMessages
|
||||
)
|
||||
|
||||
return chatMessageArraysEquivalent(currentMessages, resumedMessages) ? currentMessages : resumedMessages
|
||||
})()
|
||||
|
||||
const messagesForView = preserveLocalAssistantErrors(preferredMessages, currentMessages)
|
||||
|
||||
@@ -675,14 +731,16 @@ export function useSessionActions({
|
||||
|
||||
patchSessionWorkspace(storedSessionId, runtimeInfo?.cwd)
|
||||
|
||||
resumedRunning = Boolean((resumed as { running?: boolean }).running)
|
||||
|
||||
updateSessionState(
|
||||
resumed.session_id,
|
||||
state => ({
|
||||
...state,
|
||||
...(runtimeInfo ?? {}),
|
||||
messages: messagesForView,
|
||||
busy: false,
|
||||
awaitingResponse: false
|
||||
busy: resumedRunning,
|
||||
awaitingResponse: resumedRunning
|
||||
}),
|
||||
storedSessionId
|
||||
)
|
||||
@@ -701,9 +759,9 @@ export function useSessionActions({
|
||||
notifyError(err, copy.resumeFailed)
|
||||
} finally {
|
||||
if (isCurrentResume()) {
|
||||
busyRef.current = false
|
||||
setBusy(false)
|
||||
setAwaitingResponse(false)
|
||||
busyRef.current = resumedRunning
|
||||
setBusy(resumedRunning)
|
||||
setAwaitingResponse(resumedRunning)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Check, Download, Loader2, Palette, Trash2 } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $activeGatewayProfile, $profiles, normalizeProfileKey } from '@/store/profile'
|
||||
import { $toolViewMode, setToolViewMode } from '@/store/tool-view'
|
||||
import { $translucency, setTranslucency } from '@/store/translucency'
|
||||
import { useTheme } from '@/themes/context'
|
||||
import { installVscodeThemeFromMarketplace } from '@/themes/install'
|
||||
import { isUserTheme, removeUserTheme, resolveTheme } from '@/themes/user-themes'
|
||||
@@ -135,6 +136,7 @@ export function AppearanceSettings() {
|
||||
const { t, isSavingLocale } = useI18n()
|
||||
const { themeName, mode, availableThemes, setTheme, setMode } = useTheme()
|
||||
const toolViewMode = useStore($toolViewMode)
|
||||
const translucency = useStore($translucency)
|
||||
const profiles = useStore($profiles)
|
||||
const activeProfileKey = normalizeProfileKey(useStore($activeGatewayProfile))
|
||||
const a = t.settings.appearance
|
||||
@@ -183,6 +185,32 @@ export function AppearanceSettings() {
|
||||
title={a.colorMode}
|
||||
/>
|
||||
|
||||
<ListRow
|
||||
action={
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
aria-label={a.translucencyTitle}
|
||||
className="h-1 w-40 cursor-pointer appearance-none rounded-full bg-(--ui-stroke-tertiary)"
|
||||
max={100}
|
||||
min={0}
|
||||
onChange={event => {
|
||||
triggerHaptic('selection')
|
||||
setTranslucency(Number(event.target.value))
|
||||
}}
|
||||
step={5}
|
||||
style={{ accentColor: 'var(--dt-primary)' }}
|
||||
type="range"
|
||||
value={translucency}
|
||||
/>
|
||||
<span className="w-9 text-right text-[length:var(--conversation-caption-font-size)] tabular-nums text-(--ui-text-tertiary)">
|
||||
{translucency}%
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
description={a.translucencyDesc}
|
||||
title={a.translucencyTitle}
|
||||
/>
|
||||
|
||||
<ListRow
|
||||
below={
|
||||
<>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Tip } from '@/components/ui/tooltip'
|
||||
import { getHermesConfigDefaults, getHermesConfigRecord, saveHermesConfig } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { Archive, Globe, Info, KeyRound, Settings2, Sparkles, Wrench, Zap } from '@/lib/icons'
|
||||
import { Archive, Bell, Globe, Info, KeyRound, Settings2, Sparkles, Wrench, Zap } from '@/lib/icons'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
|
||||
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
|
||||
@@ -20,6 +20,7 @@ import { SECTIONS } from './constants'
|
||||
import { GatewaySettings } from './gateway-settings'
|
||||
import { KEYS_VIEWS, KeysSettings, type KeysView } from './keys-settings'
|
||||
import { McpSettings } from './mcp-settings'
|
||||
import { NotificationsSettings } from './notifications-settings'
|
||||
import { PROVIDER_VIEWS, ProvidersSettings, type ProviderView } from './providers-settings'
|
||||
import { SessionsSettings } from './sessions-settings'
|
||||
import type { SettingsPageProps, SettingsView as SettingsViewId } from './types'
|
||||
@@ -30,6 +31,7 @@ const SETTINGS_VIEWS: readonly SettingsViewId[] = [
|
||||
'gateway',
|
||||
'keys',
|
||||
'mcp',
|
||||
'notifications',
|
||||
'sessions',
|
||||
'about'
|
||||
]
|
||||
@@ -101,6 +103,12 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
|
||||
/>
|
||||
)
|
||||
})}
|
||||
<OverlayNavItem
|
||||
active={activeView === 'notifications'}
|
||||
icon={Bell}
|
||||
label={t.settings.nav.notifications}
|
||||
onClick={() => setActiveView('notifications')}
|
||||
/>
|
||||
<div className="my-2 h-px bg-border/30" />
|
||||
<OverlayNavItem
|
||||
active={activeView === 'providers'}
|
||||
@@ -225,6 +233,8 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
|
||||
<KeysSettings view={keysView} />
|
||||
) : activeView === 'mcp' ? (
|
||||
<McpSettings gateway={gateway} onConfigSaved={onConfigSaved} />
|
||||
) : activeView === 'notifications' ? (
|
||||
<NotificationsSettings />
|
||||
) : (
|
||||
<SessionsSettings />
|
||||
)}
|
||||
|
||||
150
apps/desktop/src/app/settings/notifications-settings.tsx
Normal file
150
apps/desktop/src/app/settings/notifications-settings.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { COMPLETION_SOUND_VARIANTS, previewCompletionSound } from '@/lib/completion-sound'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { Bell, Play } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $completionSoundVariantId, setCompletionSoundVariantId } from '@/store/completion-sound'
|
||||
import {
|
||||
$nativeNotifyPrefs,
|
||||
NATIVE_NOTIFICATION_KINDS,
|
||||
sendTestNativeNotification,
|
||||
setNativeNotifyEnabled,
|
||||
setNativeNotifyKind
|
||||
} from '@/store/native-notifications'
|
||||
import { notify } from '@/store/notifications'
|
||||
|
||||
import { CONTROL_TEXT } from './constants'
|
||||
import { ListRow, SectionHeading, SettingsContent } from './primitives'
|
||||
|
||||
const CAPTION = 'text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)'
|
||||
|
||||
function Caption({ children, className }: { children: ReactNode; className?: string }) {
|
||||
return <p className={cn(CAPTION, className)}>{children}</p>
|
||||
}
|
||||
|
||||
function ToggleRow(props: {
|
||||
checked: boolean
|
||||
description: string
|
||||
disabled?: boolean
|
||||
label: string
|
||||
onChange: (on: boolean) => void
|
||||
}) {
|
||||
return (
|
||||
<ListRow
|
||||
action={
|
||||
<Switch
|
||||
aria-label={props.label}
|
||||
checked={props.checked}
|
||||
disabled={props.disabled}
|
||||
onCheckedChange={on => {
|
||||
triggerHaptic('selection')
|
||||
props.onChange(on)
|
||||
}}
|
||||
/>
|
||||
}
|
||||
description={props.description}
|
||||
title={props.label}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function NotificationsSettings() {
|
||||
const { t } = useI18n()
|
||||
const prefs = useStore($nativeNotifyPrefs)
|
||||
const completionSoundVariantId = useStore($completionSoundVariantId)
|
||||
const copy = t.settings.notifications
|
||||
|
||||
const runTest = async () => {
|
||||
triggerHaptic('open')
|
||||
const ok = await sendTestNativeNotification(copy.testTitle, copy.testBody)
|
||||
notify({ kind: ok ? 'info' : 'error', message: ok ? copy.testSent : copy.testUnsupported })
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsContent>
|
||||
<SectionHeading icon={Bell} title={copy.title} />
|
||||
<Caption className="mb-2 leading-(--conversation-caption-line-height)">{copy.intro}</Caption>
|
||||
|
||||
<ToggleRow
|
||||
checked={prefs.enabled}
|
||||
description={copy.enableAllDesc}
|
||||
label={copy.enableAll}
|
||||
onChange={setNativeNotifyEnabled}
|
||||
/>
|
||||
|
||||
<div className="my-1 h-px bg-border/30" />
|
||||
|
||||
{NATIVE_NOTIFICATION_KINDS.map(kind => (
|
||||
<ToggleRow
|
||||
checked={prefs.enabled && prefs.kinds[kind]}
|
||||
description={copy.kinds[kind].description}
|
||||
disabled={!prefs.enabled}
|
||||
key={kind}
|
||||
label={copy.kinds[kind].label}
|
||||
onChange={on => setNativeNotifyKind(kind, on)}
|
||||
/>
|
||||
))}
|
||||
|
||||
<div className="my-1 h-px bg-border/30" />
|
||||
|
||||
<ListRow
|
||||
action={
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<Select
|
||||
onValueChange={value => {
|
||||
const variantId = Number.parseInt(value, 10)
|
||||
|
||||
setCompletionSoundVariantId(variantId)
|
||||
previewCompletionSound(variantId)
|
||||
triggerHaptic('selection')
|
||||
}}
|
||||
value={String(completionSoundVariantId)}
|
||||
>
|
||||
<SelectTrigger className={cn('min-w-56', CONTROL_TEXT)}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
{COMPLETION_SOUND_VARIANTS.map(variant => (
|
||||
<SelectItem key={variant.id} value={String(variant.id)}>
|
||||
{variant.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button
|
||||
className="gap-1.5"
|
||||
onClick={() => {
|
||||
previewCompletionSound()
|
||||
triggerHaptic('crisp')
|
||||
}}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<Play className="size-3.5" />
|
||||
{copy.completionSoundPreview}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
description={copy.completionSoundDesc}
|
||||
title={copy.completionSoundTitle}
|
||||
/>
|
||||
|
||||
<div className="mt-4 flex flex-col gap-2">
|
||||
<Button className="self-start" onClick={() => void runTest()} size="sm" type="button" variant="outline">
|
||||
<Bell />
|
||||
{copy.test}
|
||||
</Button>
|
||||
<Caption>{copy.focusedHint}</Caption>
|
||||
</div>
|
||||
</SettingsContent>
|
||||
)
|
||||
}
|
||||
100
apps/desktop/src/app/settings/providers-settings.test.tsx
Normal file
100
apps/desktop/src/app/settings/providers-settings.test.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { atom } from 'nanostores'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { OAuthProvider } from '@/types/hermes'
|
||||
|
||||
const listOAuthProviders = vi.fn()
|
||||
const disconnectOAuthProvider = vi.fn()
|
||||
const getEnvVars = vi.fn()
|
||||
const startManualProviderOAuth = vi.fn()
|
||||
const onboarding = atom({ manual: false })
|
||||
|
||||
vi.mock('@/hermes', () => ({
|
||||
disconnectOAuthProvider: (providerId: string) => disconnectOAuthProvider(providerId),
|
||||
getEnvVars: () => getEnvVars(),
|
||||
listOAuthProviders: () => listOAuthProviders()
|
||||
}))
|
||||
|
||||
vi.mock('@/store/onboarding', () => ({
|
||||
$desktopOnboarding: onboarding,
|
||||
startManualProviderOAuth: (providerId: string) => startManualProviderOAuth(providerId)
|
||||
}))
|
||||
|
||||
function provider(id: string, loggedIn: boolean, patch: Partial<OAuthProvider> = {}): OAuthProvider {
|
||||
return {
|
||||
cli_command: `hermes auth add ${id}`,
|
||||
disconnectable: true,
|
||||
docs_url: '',
|
||||
flow: 'device_code',
|
||||
id,
|
||||
name: id === 'nous' ? 'Nous Portal' : 'MiniMax',
|
||||
status: {
|
||||
logged_in: loggedIn
|
||||
},
|
||||
...patch
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
onboarding.set({ manual: false })
|
||||
getEnvVars.mockResolvedValue({})
|
||||
disconnectOAuthProvider.mockResolvedValue({ ok: true, provider: 'nous' })
|
||||
listOAuthProviders.mockResolvedValue({
|
||||
providers: [provider('nous', true), provider('minimax-oauth', false)]
|
||||
})
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.restoreAllMocks()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
async function renderProvidersSettings() {
|
||||
const { ProvidersSettings } = await import('./providers-settings')
|
||||
|
||||
return render(<ProvidersSettings onViewChange={vi.fn()} view="accounts" />)
|
||||
}
|
||||
|
||||
describe('ProvidersSettings', () => {
|
||||
it('disconnects a connected provider account and refreshes the accounts list', async () => {
|
||||
await renderProvidersSettings()
|
||||
|
||||
const remove = await screen.findByRole('button', { name: 'Remove Nous Portal' })
|
||||
fireEvent.click(remove)
|
||||
|
||||
await waitFor(() => expect(disconnectOAuthProvider).toHaveBeenCalledWith('nous'))
|
||||
expect(listOAuthProviders).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('keeps provider selection separate from account removal', async () => {
|
||||
await renderProvidersSettings()
|
||||
|
||||
fireEvent.click(await screen.findByText('Nous Portal'))
|
||||
|
||||
expect(startManualProviderOAuth).toHaveBeenCalledWith('nous')
|
||||
expect(disconnectOAuthProvider).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not offer removal for externally managed providers', async () => {
|
||||
listOAuthProviders.mockResolvedValue({
|
||||
providers: [
|
||||
provider('qwen-oauth', true, {
|
||||
cli_command: 'hermes auth add qwen-oauth',
|
||||
disconnect_hint: 'Use `hermes auth add qwen-oauth` or that provider\'s CLI to remove it.',
|
||||
disconnectable: false,
|
||||
flow: 'external',
|
||||
name: 'Qwen (via Qwen CLI)'
|
||||
})
|
||||
]
|
||||
})
|
||||
|
||||
await renderProvidersSettings()
|
||||
|
||||
expect(await screen.findByText('Qwen Code')).toBeTruthy()
|
||||
expect(screen.queryByRole('button', { name: 'Remove Qwen Code' })).toBeNull()
|
||||
expect(screen.getByText(/managed outside Hermes/)).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -1,18 +1,20 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import {
|
||||
FEATURED_ID,
|
||||
FeaturedProviderRow,
|
||||
KeyProviderRow,
|
||||
ProviderRow,
|
||||
providerTitle,
|
||||
sortProviders
|
||||
} from '@/components/desktop-onboarding-overlay'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { listOAuthProviders } from '@/hermes'
|
||||
import { disconnectOAuthProvider, listOAuthProviders } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { ChevronDown, KeyRound } from '@/lib/icons'
|
||||
import { Check, ChevronDown, ChevronRight, KeyRound, Loader2, Terminal, Trash2 } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import { $desktopOnboarding, startManualProviderOAuth } from '@/store/onboarding'
|
||||
import type { EnvVarInfo, OAuthProvider } from '@/types/hermes'
|
||||
|
||||
@@ -85,7 +87,17 @@ function buildProviderKeyGroups(vars: Record<string, EnvVarInfo>): ProviderKeyGr
|
||||
// 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[] }) {
|
||||
function OAuthPicker({
|
||||
disconnecting,
|
||||
onDisconnect,
|
||||
onWantApiKey,
|
||||
providers
|
||||
}: {
|
||||
disconnecting: null | string
|
||||
onDisconnect: (provider: OAuthProvider) => void
|
||||
onWantApiKey: () => void
|
||||
providers: OAuthProvider[]
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const p = t.settings.providers
|
||||
const [showAll, setShowAll] = useState(false)
|
||||
@@ -97,7 +109,7 @@ function OAuthPicker({ onWantApiKey, providers }: { onWantApiKey: () => void; pr
|
||||
|
||||
const select = (p: OAuthProvider) => startManualProviderOAuth(p.id)
|
||||
|
||||
const featured = ordered.find(p => p.id === FEATURED_ID) ?? null
|
||||
const featured = ordered.find(p => p.id === FEATURED_ID && !p.status?.logged_in) ?? 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.
|
||||
@@ -130,7 +142,13 @@ function OAuthPicker({ onWantApiKey, providers }: { onWantApiKey: () => void; pr
|
||||
{p.connected}
|
||||
</p>
|
||||
{connected.map(p => (
|
||||
<ProviderRow key={p.id} onSelect={select} provider={p} />
|
||||
<ConnectedProviderRow
|
||||
disconnecting={disconnecting === p.id}
|
||||
key={p.id}
|
||||
onDisconnect={onDisconnect}
|
||||
onSelect={select}
|
||||
provider={p}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
@@ -158,6 +176,63 @@ function OAuthPicker({ onWantApiKey, providers }: { onWantApiKey: () => void; pr
|
||||
)
|
||||
}
|
||||
|
||||
function ConnectedProviderRow({
|
||||
disconnecting,
|
||||
onDisconnect,
|
||||
onSelect,
|
||||
provider
|
||||
}: {
|
||||
disconnecting: boolean
|
||||
onDisconnect: (provider: OAuthProvider) => void
|
||||
onSelect: (provider: OAuthProvider) => void
|
||||
provider: OAuthProvider
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const title = providerTitle(provider)
|
||||
const Trail = provider.flow === 'external' ? Terminal : ChevronRight
|
||||
const canDisconnect = provider.disconnectable ?? provider.flow !== 'external'
|
||||
|
||||
const disconnectHint = provider.flow === 'external'
|
||||
? t.settings.providers.removeExternal(title, provider.cli_command)
|
||||
: t.settings.providers.removeKeyManaged(title)
|
||||
|
||||
return (
|
||||
<div className="group grid grid-cols-[minmax(0,1fr)_auto] items-center gap-1 rounded-[6px] transition-colors hover:bg-(--ui-control-hover-background)">
|
||||
<button className="min-w-0 px-3 py-2.5 text-left" onClick={() => onSelect(provider)} type="button">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate text-[length:var(--conversation-text-font-size)] font-semibold">{title}</span>
|
||||
<span className="inline-flex shrink-0 items-center gap-1 bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
|
||||
<Check className="size-3" />
|
||||
{t.settings.providers.connected}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">{t.onboarding.flowSubtitles[provider.flow]}</p>
|
||||
{!canDisconnect && (
|
||||
<p className="mt-0.5 truncate text-[0.68rem] leading-5 text-muted-foreground/70">
|
||||
{disconnectHint}
|
||||
</p>
|
||||
)}
|
||||
</button>
|
||||
<div className="flex items-center gap-1 pr-2">
|
||||
<Trail className="size-4 text-muted-foreground transition group-hover:text-foreground" />
|
||||
{canDisconnect && (
|
||||
<Button
|
||||
aria-label={`${t.common.remove} ${title}`}
|
||||
disabled={disconnecting}
|
||||
onClick={() => onDisconnect(provider)}
|
||||
size="icon-xs"
|
||||
title={`${t.common.remove} ${title}`}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{disconnecting ? <Loader2 className="size-3 animate-spin" /> : <Trash2 className="size-3" />}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function NoProviderKeys() {
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -173,20 +248,26 @@ export function ProvidersSettings({ onViewChange, view }: ProvidersSettingsProps
|
||||
const { rowProps, vars } = useEnvCredentials()
|
||||
const [oauthProviders, setOauthProviders] = useState<OAuthProvider[]>([])
|
||||
const [openProvider, setOpenProvider] = useState<null | string>(null)
|
||||
const [disconnecting, setDisconnecting] = 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
|
||||
}
|
||||
const refreshOAuthProviders = useCallback(async () => {
|
||||
// OAuth providers are best-effort — a failure here just hides the panel.
|
||||
const { providers } = await listOAuthProviders()
|
||||
setOauthProviders(providers)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
// OAuth providers are best-effort — a failure here just hides the panel.
|
||||
void (async () => {
|
||||
if (onboardingActive) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const { providers } = await listOAuthProviders()
|
||||
|
||||
@@ -201,6 +282,26 @@ export function ProvidersSettings({ onViewChange, view }: ProvidersSettingsProps
|
||||
return () => void (cancelled = true)
|
||||
}, [onboardingActive])
|
||||
|
||||
async function handleDisconnect(provider: OAuthProvider) {
|
||||
const name = providerTitle(provider)
|
||||
|
||||
if (!window.confirm(t.settings.providers.removeConfirm(name))) {
|
||||
return
|
||||
}
|
||||
|
||||
setDisconnecting(provider.id)
|
||||
|
||||
try {
|
||||
await disconnectOAuthProvider(provider.id)
|
||||
notify({ durationMs: 3_000, kind: 'success', title: t.settings.providers.removedTitle, message: t.settings.providers.removedMessage(name) })
|
||||
await refreshOAuthProviders().catch(() => undefined)
|
||||
} catch (err) {
|
||||
notifyError(err, t.settings.providers.failedRemove(name))
|
||||
} finally {
|
||||
setDisconnecting(null)
|
||||
}
|
||||
}
|
||||
|
||||
if (!vars) {
|
||||
return <LoadingState label={t.settings.providers.loading} />
|
||||
}
|
||||
@@ -237,7 +338,12 @@ export function ProvidersSettings({ onViewChange, view }: ProvidersSettingsProps
|
||||
|
||||
return (
|
||||
<SettingsContent>
|
||||
<OAuthPicker onWantApiKey={() => onViewChange('keys')} providers={oauthProviders} />
|
||||
<OAuthPicker
|
||||
disconnecting={disconnecting}
|
||||
onDisconnect={provider => void handleDisconnect(provider)}
|
||||
onWantApiKey={() => onViewChange('keys')}
|
||||
providers={oauthProviders}
|
||||
/>
|
||||
</SettingsContent>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,15 @@ import type { HermesGateway } from '@/hermes'
|
||||
import type { IconComponent } from '@/lib/icons'
|
||||
import type { EnvVarInfo } from '@/types/hermes'
|
||||
|
||||
export type SettingsView = 'about' | 'gateway' | 'keys' | 'mcp' | 'providers' | 'sessions' | `config:${string}`
|
||||
export type SettingsView =
|
||||
| 'about'
|
||||
| 'gateway'
|
||||
| 'keys'
|
||||
| 'mcp'
|
||||
| 'notifications'
|
||||
| 'providers'
|
||||
| 'sessions'
|
||||
| `config:${string}`
|
||||
export type EnvPatch = Partial<Pick<EnvVarInfo, 'is_set' | 'redacted_value'>>
|
||||
|
||||
export interface SettingsPageProps {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
|
||||
import { Kbd, KbdCombo } from '@/components/ui/kbd'
|
||||
import { useI18n } from '@/i18n'
|
||||
import {
|
||||
KEYBIND_ACTIONS,
|
||||
@@ -166,15 +167,11 @@ function KeybindRow({ action }: { action: KeybindActionMeta }) {
|
||||
type="button"
|
||||
>
|
||||
{capturing ? (
|
||||
<span className="kbd-cap kbd-capturing">{k.pressKey}</span>
|
||||
<Kbd variant="capturing">{k.pressKey}</Kbd>
|
||||
) : combos.length > 0 ? (
|
||||
combos.map(combo => (
|
||||
<span className="kbd-cap" key={combo}>
|
||||
{formatCombo(combo)}
|
||||
</span>
|
||||
))
|
||||
combos.map(combo => <KbdCombo combo={combo} key={combo} />)
|
||||
) : (
|
||||
<span className="kbd-cap kbd-cap--ghost">{k.set}</span>
|
||||
<Kbd variant="ghost">{k.set}</Kbd>
|
||||
)}
|
||||
</button>
|
||||
|
||||
@@ -209,9 +206,7 @@ function ReadonlyRow({ shortcut }: { shortcut: KeybindReadonly }) {
|
||||
<span className="min-w-0 flex-1 truncate text-[0.82rem] text-foreground/75">{label}</span>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{shortcut.keys.map(key => (
|
||||
<span className="kbd-cap" key={key}>
|
||||
{formatCombo(key)}
|
||||
</span>
|
||||
<KbdCombo combo={key} key={key} />
|
||||
))}
|
||||
</div>
|
||||
<span aria-hidden className="size-6 shrink-0" />
|
||||
|
||||
@@ -19,7 +19,10 @@ export const titlebarButtonClass =
|
||||
'text-muted-foreground/85 hover:bg-(--ui-control-hover-background) hover:text-foreground'
|
||||
|
||||
export const titlebarHeaderBaseClass =
|
||||
'pointer-events-none relative z-3 flex h-(--titlebar-height) shrink-0 items-center justify-start gap-3 border-b border-(--ui-stroke-tertiary) bg-(--ui-chat-surface-background) px-[max(0.75rem,var(--titlebar-content-inset,0rem))]'
|
||||
'pointer-events-none relative z-3 flex h-(--titlebar-height) w-full min-w-0 shrink-0 items-center justify-start gap-3 overflow-hidden border-b border-(--ui-stroke-tertiary) bg-(--ui-chat-surface-background) px-[max(0.75rem,var(--titlebar-content-inset,0rem))] pr-[calc(var(--titlebar-tools-right,0.75rem)+var(--titlebar-tools-width,0px)+0.75rem)]'
|
||||
|
||||
// Title row inside the header — must stay in the flex truncate chain.
|
||||
export const titlebarHeaderTitleClass = 'min-w-0 flex-1 overflow-hidden'
|
||||
|
||||
export const titlebarHeaderShadowClass =
|
||||
"after:pointer-events-none after:absolute after:left-0 after:right-0 after:top-full after:h-4 after:bg-linear-to-b after:from-(--ui-chat-surface-background) after:to-transparent after:content-['']"
|
||||
|
||||
@@ -6,6 +6,7 @@ import { type FormEvent, type KeyboardEvent, useCallback, useMemo, useRef, useSt
|
||||
|
||||
import { ToolFallback } from '@/components/assistant-ui/tool-fallback'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { KbdCombo } from '@/components/ui/kbd'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
@@ -229,7 +230,10 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
|
||||
value={draft}
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-[0.6875rem] text-muted-foreground/85">{copy.shortcut}</span>
|
||||
<span className="inline-flex items-center gap-1 text-[0.6875rem] text-muted-foreground/85">
|
||||
<KbdCombo combo="mod+enter" size="sm" />
|
||||
{copy.shortcutSuffix}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{hasChoices && (
|
||||
<Button
|
||||
|
||||
@@ -2,12 +2,22 @@
|
||||
|
||||
import { TextMessagePartProvider, useMessagePartText } from '@assistant-ui/react'
|
||||
import {
|
||||
parseMarkdownIntoBlocks,
|
||||
type StreamdownTextComponents,
|
||||
StreamdownTextPrimitive,
|
||||
type SyntaxHighlighterProps
|
||||
} from '@assistant-ui/react-streamdown'
|
||||
import { code } from '@streamdown/code'
|
||||
import { type ComponentProps, memo, type ReactNode, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
type ComponentProps,
|
||||
memo,
|
||||
type ReactNode,
|
||||
useDeferredValue,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState
|
||||
} from 'react'
|
||||
|
||||
import { PreviewAttachment } from '@/components/chat/preview-attachment'
|
||||
import { SyntaxHighlighter } from '@/components/chat/shiki-highlighter'
|
||||
@@ -26,6 +36,7 @@ import {
|
||||
mediaStreamUrl
|
||||
} from '@/lib/media'
|
||||
import { previewTargetFromMarkdownHref } from '@/lib/preview-targets'
|
||||
import { tailBoundedRemend } from '@/lib/remend-tail'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// Math rendering plugin (KaTeX). Configured once at module scope — the
|
||||
@@ -42,6 +53,51 @@ import { cn } from '@/lib/utils'
|
||||
// LLM convention). The default false-setting only accepts `$$...$$`.
|
||||
const mathPlugin = createMemoizedMathPlugin({ singleDollarTextMath: true })
|
||||
|
||||
// Replaces Streamdown's `parseIncompleteMarkdown` (full-text remend per
|
||||
// flush) with a tail-bounded repair — see lib/remend-tail.ts. Must stay
|
||||
// module-scope so the prop identity is stable across renders.
|
||||
function preprocessWithTailRepair(text: string): string {
|
||||
return tailBoundedRemend(preprocessMarkdown(text))
|
||||
}
|
||||
|
||||
// Memoized block splitter. Streamdown calls `parseMarkdownIntoBlocks` (a full
|
||||
// `marked` lex of the entire message, ~1.6ms per 28KB) inside a useMemo keyed
|
||||
// on the text — but the same text is re-lexed every time a message REMOUNTS
|
||||
// (virtualizer scroll, session switch) and whenever multiple surfaces render
|
||||
// the same content (deferred + smooth reveal republish). A small module-level
|
||||
// LRU keyed by the exact source string removes all of those repeat parses
|
||||
// with zero correctness risk (same input → same output). Streaming tail
|
||||
// growth misses the cache by design (every flush is a new string) — that
|
||||
// single lex is the irreducible cost.
|
||||
const BLOCK_CACHE_MAX = 64
|
||||
const BLOCK_CACHE_MIN_LENGTH = 1024
|
||||
const blockCache = new Map<string, string[]>()
|
||||
|
||||
function parseMarkdownIntoBlocksCached(markdown: string): string[] {
|
||||
if (markdown.length < BLOCK_CACHE_MIN_LENGTH) {
|
||||
return parseMarkdownIntoBlocks(markdown)
|
||||
}
|
||||
|
||||
const hit = blockCache.get(markdown)
|
||||
|
||||
if (hit) {
|
||||
// Refresh recency (Map iteration order is insertion order).
|
||||
blockCache.delete(markdown)
|
||||
blockCache.set(markdown, hit)
|
||||
|
||||
return hit
|
||||
}
|
||||
|
||||
const blocks = parseMarkdownIntoBlocks(markdown)
|
||||
blockCache.set(markdown, blocks)
|
||||
|
||||
if (blockCache.size > BLOCK_CACHE_MAX) {
|
||||
blockCache.delete(blockCache.keys().next().value as string)
|
||||
}
|
||||
|
||||
return blocks
|
||||
}
|
||||
|
||||
async function mediaSrc(path: string): Promise<string> {
|
||||
if (/^(?:https?|data):/i.test(path)) {
|
||||
return path
|
||||
@@ -241,6 +297,13 @@ function MarkdownImage({ className, src, alt, ...props }: ComponentProps<'img'>)
|
||||
// keeps draining its tail instead of snapping.
|
||||
const REVEAL_DRAIN_MS = 500
|
||||
const REVEAL_MAX_CHARS_PER_FRAME = 30
|
||||
// Floor between reveal commits. Each commit republishes the text context and
|
||||
// re-runs the whole Streamdown pipeline (preprocess → remend → lex → micromark
|
||||
// on the open block) over the full accumulated text — at raw rAF cadence
|
||||
// that's 60 full parses/second and was the dominant streaming cost for
|
||||
// reasoning text. ~33ms keeps the reveal visually fluid (2 frames) while
|
||||
// halving the parse work.
|
||||
const REVEAL_MIN_COMMIT_MS = 33
|
||||
|
||||
function useSmoothReveal(text: string, isRunning: boolean): string {
|
||||
const [displayed, setDisplayed] = useState(isRunning ? '' : text)
|
||||
@@ -273,10 +336,27 @@ function useSmoothReveal(text: string, isRunning: boolean): string {
|
||||
const tick = () => {
|
||||
const now = performance.now()
|
||||
const dt = now - lastTickRef.current
|
||||
|
||||
// Skip this frame if the floor hasn't elapsed — the backlog math below
|
||||
// is dt-proportional, so delayed commits reveal proportionally more.
|
||||
if (dt < REVEAL_MIN_COMMIT_MS) {
|
||||
frameRef.current = requestAnimationFrame(tick)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
lastTickRef.current = now
|
||||
|
||||
const remaining = targetRef.current.length - shownRef.current.length
|
||||
const add = Math.min(remaining, REVEAL_MAX_CHARS_PER_FRAME, Math.max(1, Math.ceil((remaining * dt) / REVEAL_DRAIN_MS)))
|
||||
|
||||
const add = Math.min(
|
||||
remaining,
|
||||
// dt-scaled so the per-commit cap stays equivalent to the old
|
||||
// per-frame cap at any commit cadence.
|
||||
Math.ceil((REVEAL_MAX_CHARS_PER_FRAME * dt) / 16.7),
|
||||
Math.max(1, Math.ceil((remaining * dt) / REVEAL_DRAIN_MS))
|
||||
)
|
||||
|
||||
shownRef.current = targetRef.current.slice(0, shownRef.current.length + add)
|
||||
setDisplayed(shownRef.current)
|
||||
|
||||
@@ -460,17 +540,20 @@ function MarkdownTextSurface({ containerClassName, containerProps }: MarkdownTex
|
||||
containerProps={containerProps}
|
||||
lineNumbers={false}
|
||||
mode="streaming"
|
||||
// Always auto-close incomplete fences — even during streaming.
|
||||
// Without this, an unclosed ```python ... ``` whose body contains
|
||||
// `$` (very common: shell snippets, JS template strings, dollar
|
||||
// amounts) leaks those dollars out to the math parser and they
|
||||
// get rendered as broken inline math until the closing fence
|
||||
// arrives. Shiki is independently deferred via `defer={isStreaming}`
|
||||
// on the SyntaxHighlighter component, so we don't pay code-block
|
||||
// tokenization on every token even with this set.
|
||||
parseIncompleteMarkdown
|
||||
// Incomplete-markdown repair is handled by `preprocessWithTailRepair`
|
||||
// below (tail-bounded remend) instead of Streamdown's built-in pass,
|
||||
// which re-runs remend over the ENTIRE message on every flush — ~18%
|
||||
// of streaming script time on 50KB+ messages. The repair itself stays
|
||||
// always-on (even between flushes / for completed messages): an
|
||||
// unclosed ```python ... ``` whose body contains `$` (shell snippets,
|
||||
// JS template strings, dollar amounts) would otherwise leak those
|
||||
// dollars to the math parser and render broken inline math. Shiki is
|
||||
// independently deferred via `defer={isStreaming}` on the
|
||||
// SyntaxHighlighter component.
|
||||
parseIncompleteMarkdown={false}
|
||||
parseMarkdownIntoBlocksFn={parseMarkdownIntoBlocksCached}
|
||||
plugins={plugins}
|
||||
preprocess={preprocessMarkdown}
|
||||
preprocess={preprocessWithTailRepair}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -58,9 +58,9 @@ Element.prototype.animate = function animate() {
|
||||
} as unknown as Animation
|
||||
}
|
||||
|
||||
// jsdom returns 0 for offset*; the virtualizer reads those to size its
|
||||
// jsdom returns 0 for offset*; some layout code reads those to size the
|
||||
// viewport. Fall through to client* (which tests can override) or a sane
|
||||
// default so virtualized items render.
|
||||
// default so message rows render with non-zero dimensions.
|
||||
function stubOffsetDimension(
|
||||
prop: 'offsetHeight' | 'offsetWidth',
|
||||
clientProp: 'clientHeight' | 'clientWidth',
|
||||
@@ -216,25 +216,21 @@ function assistantTodoMessage(
|
||||
} as ThreadMessage
|
||||
}
|
||||
|
||||
function assistantReasoningTodoMessage(
|
||||
todos: Array<{ content: string; id: string; status: 'cancelled' | 'completed' | 'in_progress' | 'pending' }>
|
||||
): ThreadMessage {
|
||||
function assistantImageMessage(running = false): ThreadMessage {
|
||||
return {
|
||||
id: 'assistant-reasoning-todo-1',
|
||||
id: `assistant-image-${running ? 'running' : 'done'}`,
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'reasoning', text: 'Let me make a quick todo list.' },
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'todo-1',
|
||||
toolName: 'todo',
|
||||
args: { todos },
|
||||
argsText: JSON.stringify({ todos }),
|
||||
result: { todos }
|
||||
},
|
||||
{ type: 'text', text: 'Done — fake list created.' }
|
||||
toolCallId: 'image-1',
|
||||
toolName: 'image_generate',
|
||||
args: { prompt: 'draw a cat' },
|
||||
argsText: JSON.stringify({ prompt: 'draw a cat' }),
|
||||
...(running ? {} : { result: { image: 'https://cdn.example/cat.png', success: true } })
|
||||
}
|
||||
],
|
||||
status: { type: 'complete', reason: 'stop' },
|
||||
status: running ? { type: 'running' } : { type: 'complete', reason: 'stop' },
|
||||
createdAt,
|
||||
metadata: {
|
||||
unstable_state: null,
|
||||
@@ -284,20 +280,6 @@ function StreamingHarness() {
|
||||
)
|
||||
}
|
||||
|
||||
function StaticThreadHarness() {
|
||||
const runtime = useExternalStoreRuntime<ThreadMessage>({
|
||||
messages: [userMessage(), assistantMessage('complete response', false)],
|
||||
isRunning: false,
|
||||
onNew: async () => {}
|
||||
})
|
||||
|
||||
return (
|
||||
<AssistantRuntimeProvider runtime={runtime}>
|
||||
<Thread />
|
||||
</AssistantRuntimeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function TodoHarness({ message }: { message: ThreadMessage }) {
|
||||
const runtime = useExternalStoreRuntime<ThreadMessage>({
|
||||
messages: [message],
|
||||
@@ -439,222 +421,11 @@ describe('assistant-ui streaming renderer', () => {
|
||||
expect(screen.getByRole('alert').textContent).toContain('OpenRouter rejected the request (403).')
|
||||
})
|
||||
|
||||
it('does not pull the viewport back down after the user scrolls up during streaming', async () => {
|
||||
const { container } = render(<StreamingHarness />)
|
||||
|
||||
const content = container.querySelector('[data-slot="aui_thread-content"]') as HTMLDivElement
|
||||
const viewport = content.parentElement as HTMLDivElement
|
||||
let scrollHeight = 1_000
|
||||
|
||||
Object.defineProperty(viewport, 'clientHeight', { configurable: true, value: 200 })
|
||||
Object.defineProperty(viewport, 'scrollHeight', {
|
||||
configurable: true,
|
||||
get: () => scrollHeight
|
||||
})
|
||||
|
||||
await wait(80)
|
||||
|
||||
await act(async () => {
|
||||
viewport.scrollTop = 800
|
||||
fireEvent.scroll(viewport)
|
||||
})
|
||||
await wait(0)
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.wheel(viewport, { deltaY: -120 })
|
||||
viewport.scrollTop = 420
|
||||
fireEvent.scroll(viewport)
|
||||
})
|
||||
|
||||
scrollHeight = 1_200
|
||||
|
||||
await act(async () => {
|
||||
for (const observer of resizeObservers) {
|
||||
observer.trigger(1_200)
|
||||
}
|
||||
})
|
||||
await wait(0)
|
||||
|
||||
expect(viewport.scrollTop).toBe(420)
|
||||
})
|
||||
|
||||
it('does not auto-follow idle layout shifts', async () => {
|
||||
const { container } = render(<StaticThreadHarness />)
|
||||
|
||||
const content = container.querySelector('[data-slot="aui_thread-content"]') as HTMLDivElement
|
||||
const viewport = content.parentElement as HTMLDivElement
|
||||
let scrollHeight = 1_000
|
||||
|
||||
Object.defineProperty(viewport, 'clientHeight', { configurable: true, value: 200 })
|
||||
Object.defineProperty(viewport, 'scrollHeight', {
|
||||
configurable: true,
|
||||
get: () => scrollHeight
|
||||
})
|
||||
|
||||
await wait(80)
|
||||
|
||||
await act(async () => {
|
||||
viewport.scrollTop = 420
|
||||
fireEvent.scroll(viewport)
|
||||
})
|
||||
|
||||
scrollHeight = 1_200
|
||||
|
||||
await act(async () => {
|
||||
for (const observer of resizeObservers) {
|
||||
observer.trigger(1_200)
|
||||
}
|
||||
})
|
||||
await wait(0)
|
||||
|
||||
expect(viewport.scrollTop).toBe(420)
|
||||
})
|
||||
|
||||
it('does not follow streaming content growth even while parked at the bottom', async () => {
|
||||
const { container } = render(<StreamingHarness />)
|
||||
|
||||
const content = container.querySelector('[data-slot="aui_thread-content"]') as HTMLDivElement
|
||||
const viewport = content.parentElement as HTMLDivElement
|
||||
let clientHeight = 200
|
||||
let scrollHeight = 1_000
|
||||
|
||||
Object.defineProperty(viewport, 'clientHeight', {
|
||||
configurable: true,
|
||||
get: () => clientHeight
|
||||
})
|
||||
Object.defineProperty(viewport, 'scrollHeight', {
|
||||
configurable: true,
|
||||
get: () => scrollHeight
|
||||
})
|
||||
|
||||
await wait(80)
|
||||
|
||||
// Park the user at the bottom of the current content.
|
||||
await act(async () => {
|
||||
viewport.scrollTop = 800
|
||||
fireEvent.scroll(viewport)
|
||||
})
|
||||
|
||||
clientHeight = 240
|
||||
|
||||
await act(async () => {
|
||||
viewport.scrollTop = 760
|
||||
fireEvent.scroll(viewport)
|
||||
})
|
||||
|
||||
// Content grows as tokens stream in. Streaming auto-follow is removed, so
|
||||
// the viewport must NOT chase the new bottom — it stays where the user
|
||||
// last left it.
|
||||
scrollHeight = 1_200
|
||||
|
||||
await act(async () => {
|
||||
for (const observer of resizeObservers) {
|
||||
observer.trigger(1_200)
|
||||
}
|
||||
})
|
||||
await wait(0)
|
||||
|
||||
expect(viewport.scrollTop).toBe(760)
|
||||
})
|
||||
|
||||
it('honors the first upward wheel scroll even when a programmatic bottom-pin scroll event is still pending', async () => {
|
||||
const { container } = render(<StreamingHarness />)
|
||||
|
||||
const content = container.querySelector('[data-slot="aui_thread-content"]') as HTMLDivElement
|
||||
const viewport = content.parentElement as HTMLDivElement
|
||||
let scrollHeight = 1_000
|
||||
|
||||
Object.defineProperty(viewport, 'clientHeight', { configurable: true, value: 200 })
|
||||
Object.defineProperty(viewport, 'scrollHeight', {
|
||||
configurable: true,
|
||||
get: () => scrollHeight
|
||||
})
|
||||
|
||||
await wait(80)
|
||||
await wait(0)
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.wheel(viewport, { deltaY: -120 })
|
||||
viewport.scrollTop = 420
|
||||
fireEvent.scroll(viewport)
|
||||
})
|
||||
|
||||
scrollHeight = 1_200
|
||||
|
||||
await act(async () => {
|
||||
for (const observer of resizeObservers) {
|
||||
observer.trigger(1_200)
|
||||
}
|
||||
})
|
||||
await wait(0)
|
||||
|
||||
expect(viewport.scrollTop).toBe(420)
|
||||
})
|
||||
|
||||
it('does not snap to the bottom on final code-highlight growth after a run completes', async () => {
|
||||
const { container } = render(<StreamingHarness />)
|
||||
|
||||
const content = container.querySelector('[data-slot="aui_thread-content"]') as HTMLDivElement
|
||||
const viewport = content.parentElement as HTMLDivElement
|
||||
let scrollHeight = 1_000
|
||||
|
||||
Object.defineProperty(viewport, 'clientHeight', { configurable: true, value: 200 })
|
||||
Object.defineProperty(viewport, 'scrollHeight', {
|
||||
configurable: true,
|
||||
get: () => scrollHeight
|
||||
})
|
||||
|
||||
await wait(80)
|
||||
|
||||
await act(async () => {
|
||||
viewport.scrollTop = 800
|
||||
fireEvent.scroll(viewport)
|
||||
})
|
||||
|
||||
await wait(650)
|
||||
|
||||
// Completion re-measures (Shiki highlight) and grows the content. The
|
||||
// post-run bottom lock is removed, so the viewport stays put instead of
|
||||
// snapping to the new bottom.
|
||||
scrollHeight = 1_700
|
||||
await wait(0)
|
||||
|
||||
expect(viewport.scrollTop).toBe(800)
|
||||
})
|
||||
|
||||
it('does not restart bottom-follow after completion when the user scrolled up', async () => {
|
||||
const { container } = render(<StreamingHarness />)
|
||||
|
||||
const content = container.querySelector('[data-slot="aui_thread-content"]') as HTMLDivElement
|
||||
const viewport = content.parentElement as HTMLDivElement
|
||||
let scrollHeight = 1_000
|
||||
|
||||
Object.defineProperty(viewport, 'clientHeight', { configurable: true, value: 200 })
|
||||
Object.defineProperty(viewport, 'scrollHeight', {
|
||||
configurable: true,
|
||||
get: () => scrollHeight
|
||||
})
|
||||
|
||||
await wait(80)
|
||||
|
||||
await act(async () => {
|
||||
viewport.scrollTop = 800
|
||||
fireEvent.scroll(viewport)
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.wheel(viewport, { deltaY: -120 })
|
||||
viewport.scrollTop = 420
|
||||
fireEvent.scroll(viewport)
|
||||
})
|
||||
|
||||
await wait(650)
|
||||
|
||||
scrollHeight = 1_700
|
||||
await wait(0)
|
||||
|
||||
expect(viewport.scrollTop).toBe(420)
|
||||
})
|
||||
// Scroll behavior (follow-at-bottom, escape-on-scroll-up, re-engage) is owned
|
||||
// by the use-stick-to-bottom library and covered by its own test suite. We
|
||||
// don't re-assert its scrollTop mechanics here — doing so in jsdom (no real
|
||||
// layout, spring animation via rAF) only produces brittle change-detector
|
||||
// tests. The rendering/streaming-content tests below remain the contract.
|
||||
|
||||
it('renders an incomplete streaming fenced code block as a code card', async () => {
|
||||
const { container } = render(<RunningMessageHarness message={assistantMessage('```ts\nconst answer = 42\n')} />)
|
||||
@@ -670,14 +441,19 @@ describe('assistant-ui streaming renderer', () => {
|
||||
it('renders an incomplete streaming reasoning fenced code block as a code card', async () => {
|
||||
const { container } = render(<RunningReasoningHarness />)
|
||||
const ui = within(container)
|
||||
const thinkingToggle = ui.getByRole('button', { name: /thinking/i })
|
||||
|
||||
fireEvent.click(ui.getByRole('button', { name: /thinking/i }))
|
||||
if (thinkingToggle.getAttribute('aria-expanded') !== 'true') {
|
||||
fireEvent.click(thinkingToggle)
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector('[data-slot="code-card"]')).toBeTruthy()
|
||||
})
|
||||
|
||||
expect(container.querySelector('[data-slot="aui_reasoning-text"]')?.textContent).toContain('const answer = 42')
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector('[data-slot="aui_reasoning-text"]')?.textContent).toContain('const answer = 42')
|
||||
})
|
||||
expect(container.textContent).not.toContain('```ts')
|
||||
})
|
||||
|
||||
@@ -718,7 +494,7 @@ describe('assistant-ui streaming renderer', () => {
|
||||
expect(container.textContent).toContain('Interim answer.')
|
||||
})
|
||||
|
||||
it('renders live todo rows during a running turn', () => {
|
||||
it('does not render an inline todo panel — todos live in the composer status stack', () => {
|
||||
const { container } = render(
|
||||
<TodoHarness
|
||||
message={assistantTodoMessage([
|
||||
@@ -728,52 +504,18 @@ describe('assistant-ui streaming renderer', () => {
|
||||
/>
|
||||
)
|
||||
|
||||
const ui = within(container)
|
||||
|
||||
expect(container.querySelector('[data-slot="aui_todo-hoisted"]')).toBeTruthy()
|
||||
expect(ui.getAllByText('Boil water').length).toBeGreaterThan(0)
|
||||
expect(ui.getByText('Gather ingredients')).toBeTruthy()
|
||||
expect(ui.queryByText(/pending/i)).toBeNull()
|
||||
expect(ui.queryByRole('button', { name: /todo/i })).toBeNull()
|
||||
expect(container.querySelector('[data-slot="aui_todo-hoisted"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders archived todos after turn completion regardless of pending state', () => {
|
||||
const first = render(
|
||||
<TodoHarness message={assistantTodoMessage([{ content: 'Boil water', id: 'boil', status: 'pending' }], false)} />
|
||||
)
|
||||
it('renders completed image generation results in the tool slot', async () => {
|
||||
const { container } = render(<MessageHarness message={assistantImageMessage()} />)
|
||||
|
||||
const ui = within(first.container)
|
||||
|
||||
expect(ui.getAllByText('Boil water').length).toBeGreaterThan(0)
|
||||
|
||||
first.unmount()
|
||||
|
||||
const second = render(
|
||||
<TodoHarness
|
||||
message={assistantTodoMessage([{ content: 'Serve latte', id: 'serve', status: 'completed' }], false)}
|
||||
/>
|
||||
)
|
||||
|
||||
const archivedUi = within(second.container)
|
||||
|
||||
expect(archivedUi.getAllByText('Serve latte').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('hoists todo outside the thinking disclosure when reasoning is present', () => {
|
||||
const { container } = render(
|
||||
<TodoHarness
|
||||
message={assistantReasoningTodoMessage([
|
||||
{ content: 'Buy oats', id: 'oats', status: 'completed' },
|
||||
{ content: "Reply to Sam's email", id: 'email', status: 'in_progress' }
|
||||
])}
|
||||
/>
|
||||
)
|
||||
|
||||
const todoPanel = container.querySelector('[data-slot="aui_todo-hoisted"]')
|
||||
const thinkingDisclosure = container.querySelector('[data-slot="aui_thinking-disclosure"]')
|
||||
|
||||
expect(todoPanel).toBeTruthy()
|
||||
expect(thinkingDisclosure).toBeTruthy()
|
||||
expect(Boolean(thinkingDisclosure?.contains(todoPanel as Node))).toBe(false)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('img', { name: 'Generated image' }).getAttribute('src')).toBe(
|
||||
'https://cdn.example/cat.png'
|
||||
)
|
||||
})
|
||||
expect(container.querySelector('[data-slot="aui_generated-image"]')).toBeTruthy()
|
||||
expect(screen.queryByRole('status', { name: /rendering image/i })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
307
apps/desktop/src/components/assistant-ui/thread-list.tsx
Normal file
307
apps/desktop/src/components/assistant-ui/thread-list.tsx
Normal file
@@ -0,0 +1,307 @@
|
||||
import { ThreadPrimitive, useAuiEvent, useAuiState } from '@assistant-ui/react'
|
||||
import {
|
||||
type ComponentProps,
|
||||
type FC,
|
||||
memo,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState
|
||||
} from 'react'
|
||||
import { useStickToBottom } from 'use-stick-to-bottom'
|
||||
|
||||
import { useI18n } from '@/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
onScrollToBottomRequest,
|
||||
onThreadEditClose,
|
||||
onThreadEditOpen,
|
||||
resetThreadScroll,
|
||||
setThreadAtBottom
|
||||
} from '@/store/thread-scroll'
|
||||
|
||||
import { MessageRenderBoundary } from './message-render-boundary'
|
||||
|
||||
type ThreadMessageComponents = ComponentProps<typeof ThreadPrimitive.MessageByIndex>['components']
|
||||
|
||||
type MessageGroup = { id: string; weight: number } & (
|
||||
| { index: number; kind: 'standalone' }
|
||||
| { indices: number[]; kind: 'turn' }
|
||||
)
|
||||
|
||||
// DOM is bounded by a rendered-PART budget, not a message/turn count: a single
|
||||
// assistant message folds every tool call into a part, so heavy sessions are
|
||||
// ~40 turns / ~100 messages but ~1000 parts — and parts are what drive node
|
||||
// count. "Show earlier" prepends another page; whole turns stay intact so the
|
||||
// sticky human bubble never loses its turn. This is the long-session perf lever
|
||||
// WITHOUT a virtualizer — pure rendering, never touches scrollTop, so it can't
|
||||
// fight use-stick-to-bottom (the single scroll owner).
|
||||
const RENDER_BUDGET = 300
|
||||
|
||||
interface ThreadMessageListProps {
|
||||
clampToComposer: boolean
|
||||
components: ThreadMessageComponents
|
||||
emptyPlaceholder?: ReactNode
|
||||
loadingIndicator?: ReactNode
|
||||
sessionKey?: string | null
|
||||
}
|
||||
|
||||
// Group each user message with the assistant turn(s) that follow it so the
|
||||
// human bubble can `position: sticky` against the scroller across its whole
|
||||
// turn (see StickyHumanMessageContainer in thread.tsx).
|
||||
function buildGroups(signature: string): MessageGroup[] {
|
||||
if (!signature) {
|
||||
return []
|
||||
}
|
||||
|
||||
const messages = signature.split('\n').map(row => {
|
||||
const [index, id, role, weight] = row.split(':')
|
||||
|
||||
return { id, index: Number(index), role, weight: Number(weight) || 1 }
|
||||
})
|
||||
|
||||
const groups: MessageGroup[] = []
|
||||
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const message = messages[i]
|
||||
|
||||
if (message.role !== 'user') {
|
||||
groups.push({ id: message.id, index: message.index, kind: 'standalone', weight: message.weight })
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
const indices = [message.index]
|
||||
let weight = message.weight
|
||||
|
||||
while (i + 1 < messages.length && messages[i + 1].role !== 'user') {
|
||||
weight += messages[++i].weight
|
||||
indices.push(messages[i].index)
|
||||
}
|
||||
|
||||
groups.push({ id: message.id, indices, kind: 'turn', weight })
|
||||
}
|
||||
|
||||
return groups
|
||||
}
|
||||
|
||||
const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
|
||||
clampToComposer,
|
||||
components,
|
||||
emptyPlaceholder,
|
||||
loadingIndicator,
|
||||
sessionKey
|
||||
}) => {
|
||||
const messageSignature = useAuiState(s =>
|
||||
s.thread.messages
|
||||
.map((message, index) => `${index}:${message.id}:${message.role}:${message.content?.length ?? 1}`)
|
||||
.join('\n')
|
||||
)
|
||||
|
||||
const { t } = useI18n()
|
||||
const groups = buildGroups(messageSignature)
|
||||
const renderEmpty = groups.length === 0 && Boolean(emptyPlaceholder)
|
||||
|
||||
// use-stick-to-bottom owns scrollTop (single writer): follow while locked,
|
||||
// escape on user scroll-up, re-lock at bottom. Snap instantly, not spring — a
|
||||
// spring can't tell live-token growth from a session-switch bulk relayout, and
|
||||
// chasing the latter reads as the view scrolling to random spots before
|
||||
// settling. Its refs hang off our own DOM so the sticky human bubbles survive.
|
||||
const { scrollRef, contentRef, isAtBottom, scrollToBottom, stopScroll } = useStickToBottom({
|
||||
initial: 'instant',
|
||||
resize: 'instant'
|
||||
})
|
||||
|
||||
const [renderBudget, setRenderBudget] = useState(RENDER_BUDGET)
|
||||
|
||||
// Walk turns newest-first, summing their part weights until the budget is met;
|
||||
// everything before that first kept turn is hidden.
|
||||
let firstVisible = groups.length
|
||||
|
||||
for (let i = groups.length - 1, weight = 0; i >= 0; i--) {
|
||||
weight += groups[i].weight
|
||||
firstVisible = i
|
||||
|
||||
if (weight >= renderBudget) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const hiddenCount = firstVisible
|
||||
const visibleGroups = hiddenCount > 0 ? groups.slice(hiddenCount) : groups
|
||||
const restoreFromBottomRef = useRef<number | null>(null)
|
||||
|
||||
useEffect(() => setThreadAtBottom(isAtBottom), [isAtBottom])
|
||||
useEffect(() => () => resetThreadScroll(), [])
|
||||
|
||||
// Floating jump button (outside this subtree) → return to the bottom.
|
||||
useEffect(() => onScrollToBottomRequest(() => void scrollToBottom()), [scrollToBottom])
|
||||
|
||||
const endEditHold = useCallback(() => {
|
||||
scrollRef.current?.removeAttribute('data-editing')
|
||||
}, [scrollRef])
|
||||
|
||||
// Inline edit grows a sticky bubble. Escape before focus/layout so the
|
||||
// resize-follow can't snap scrollTop; native anchoring holds the viewport.
|
||||
const beginEditHold = useCallback(() => {
|
||||
const el = scrollRef.current
|
||||
|
||||
if (!el) {
|
||||
return
|
||||
}
|
||||
|
||||
endEditHold()
|
||||
stopScroll()
|
||||
el.setAttribute('data-editing', 'true')
|
||||
}, [endEditHold, scrollRef, stopScroll])
|
||||
|
||||
useEffect(() => onThreadEditOpen(beginEditHold), [beginEditHold])
|
||||
useEffect(() => onThreadEditClose(endEditHold), [endEditHold])
|
||||
useEffect(() => () => endEditHold(), [endEditHold])
|
||||
// New run → snap to the latest turn.
|
||||
useAuiEvent('thread.runStart', () => void scrollToBottom())
|
||||
|
||||
// Reset the cap and pin to bottom on mount + every session switch (messages
|
||||
// swap in place on a long-lived runtime, so sessionKey is the only signal).
|
||||
// The swap is multi-step and lays out over many frames; letting the library
|
||||
// follow re-pins every frame to a moving target — visible as ~10 scroll jumps.
|
||||
// Instead: quiet it, glue to the true bottom until the height holds steady,
|
||||
// then hand back locked. Live streaming afterward uses the normal resize follow.
|
||||
useLayoutEffect(() => {
|
||||
setRenderBudget(RENDER_BUDGET)
|
||||
|
||||
const el = scrollRef.current
|
||||
|
||||
if (!el) {
|
||||
return
|
||||
}
|
||||
|
||||
stopScroll()
|
||||
el.scrollTop = el.scrollHeight
|
||||
|
||||
let frame = 0
|
||||
let stableFrames = 0
|
||||
let lastHeight = el.scrollHeight
|
||||
|
||||
const settle = () => {
|
||||
const node = scrollRef.current
|
||||
|
||||
if (!node) {
|
||||
return
|
||||
}
|
||||
|
||||
const height = node.scrollHeight
|
||||
|
||||
stableFrames = height === lastHeight ? stableFrames + 1 : 0
|
||||
lastHeight = height
|
||||
node.scrollTop = height
|
||||
|
||||
// ~5 steady frames ≈ layout has settled; the frame cap bounds slow loads.
|
||||
if (stableFrames >= 5 || ++frame > 90) {
|
||||
void scrollToBottom('instant')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
rafId = requestAnimationFrame(settle)
|
||||
}
|
||||
|
||||
let rafId = requestAnimationFrame(settle)
|
||||
|
||||
return () => cancelAnimationFrame(rafId)
|
||||
}, [scrollRef, scrollToBottom, sessionKey, stopScroll])
|
||||
|
||||
// Prepend an older page while preserving the on-screen position. The user is
|
||||
// scrolled up (reading history) so the stick-to-bottom lock is escaped and
|
||||
// won't fight this manual restore.
|
||||
const showEarlier = useCallback(() => {
|
||||
const el = scrollRef.current
|
||||
|
||||
restoreFromBottomRef.current = el ? el.scrollHeight - el.scrollTop : null
|
||||
setRenderBudget(budget => budget + RENDER_BUDGET)
|
||||
}, [scrollRef])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = scrollRef.current
|
||||
|
||||
if (el && restoreFromBottomRef.current != null) {
|
||||
el.scrollTop = el.scrollHeight - restoreFromBottomRef.current
|
||||
restoreFromBottomRef.current = null
|
||||
}
|
||||
}, [scrollRef, renderBudget])
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative min-h-0 max-w-full overflow-hidden contain-[layout_paint]"
|
||||
style={{ height: clampToComposer ? 'var(--thread-viewport-height)' : '100%' }}
|
||||
>
|
||||
<div
|
||||
className="size-full overflow-x-hidden overflow-y-auto overscroll-contain"
|
||||
data-following={isAtBottom ? 'true' : 'false'}
|
||||
data-slot="aui_thread-viewport"
|
||||
ref={scrollRef as React.RefCallback<HTMLDivElement>}
|
||||
>
|
||||
{renderEmpty ? (
|
||||
<div
|
||||
className="mx-auto grid h-full w-full max-w-(--composer-width) grid-rows-[minmax(0,1fr)_auto] min-w-0 gap-(--conversation-turn-gap) px-6 py-8"
|
||||
data-slot="aui_thread-content"
|
||||
>
|
||||
{emptyPlaceholder}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
'mx-auto flex w-full max-w-(--composer-width) min-w-0 flex-col px-6 pt-[calc(var(--titlebar-height)+1.5rem)]'
|
||||
)}
|
||||
data-slot="aui_thread-content"
|
||||
ref={contentRef as React.RefCallback<HTMLDivElement>}
|
||||
>
|
||||
{hiddenCount > 0 && (
|
||||
<button
|
||||
className="mx-auto mb-(--conversation-turn-gap) rounded-full border border-border/65 bg-(--composer-fill) px-3 py-1 text-xs text-muted-foreground hover:text-foreground"
|
||||
onClick={showEarlier}
|
||||
type="button"
|
||||
>
|
||||
{t.assistant.thread.showEarlier}
|
||||
</button>
|
||||
)}
|
||||
{visibleGroups.map(group => (
|
||||
<div
|
||||
className="flex min-w-0 flex-col gap-(--conversation-turn-gap) pb-(--conversation-turn-gap)"
|
||||
key={group.id}
|
||||
>
|
||||
<MessageRenderBoundary resetKey={messageSignature}>
|
||||
{group.kind === 'turn' ? (
|
||||
<div
|
||||
className="composer-human-ai-pair-container relative flex min-w-0 flex-col gap-(--conversation-turn-gap)"
|
||||
data-slot="aui_turn-pair"
|
||||
>
|
||||
{group.indices.map(index => (
|
||||
<ThreadPrimitive.MessageByIndex components={components} index={index} key={index} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<ThreadPrimitive.MessageByIndex components={components} index={group.index} />
|
||||
)}
|
||||
</MessageRenderBoundary>
|
||||
</div>
|
||||
))}
|
||||
{loadingIndicator}
|
||||
{clampToComposer && (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="shrink-0"
|
||||
data-slot="aui_composer-clearance"
|
||||
style={{ height: 'var(--thread-last-message-clearance)' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const ThreadMessageList = memo(ThreadMessageListInner)
|
||||
@@ -1,469 +0,0 @@
|
||||
import { ThreadPrimitive, useAuiEvent, useAuiState } from '@assistant-ui/react'
|
||||
import { useVirtualizer, type Virtualizer } from '@tanstack/react-virtual'
|
||||
import {
|
||||
type ComponentProps,
|
||||
type FC,
|
||||
memo,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef
|
||||
} from 'react'
|
||||
|
||||
import { setMutableRef } from '@/lib/mutable-ref'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { setThreadScrolledUp } from '@/store/thread-scroll'
|
||||
|
||||
import { MessageRenderBoundary } from './message-render-boundary'
|
||||
|
||||
const ESTIMATED_ITEM_HEIGHT = 220
|
||||
const OVERSCAN = 4
|
||||
const AT_BOTTOM_THRESHOLD = 4
|
||||
|
||||
type ThreadMessageComponents = ComponentProps<typeof ThreadPrimitive.MessageByIndex>['components']
|
||||
|
||||
type MessageGroup = { id: string; index: number; kind: 'standalone' } | { id: string; indices: number[]; kind: 'turn' }
|
||||
|
||||
interface VirtualizedThreadProps {
|
||||
clampToComposer: boolean
|
||||
components: ThreadMessageComponents
|
||||
emptyPlaceholder?: ReactNode
|
||||
loadingIndicator?: ReactNode
|
||||
sessionKey?: string | null
|
||||
}
|
||||
|
||||
function buildGroups(signature: string): MessageGroup[] {
|
||||
if (!signature) {
|
||||
return []
|
||||
}
|
||||
|
||||
const messages = signature.split('\n').map(row => {
|
||||
const [index, id, role] = row.split(':')
|
||||
|
||||
return { id, index: Number(index), role }
|
||||
})
|
||||
|
||||
const groups: MessageGroup[] = []
|
||||
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const message = messages[i]
|
||||
|
||||
if (message.role !== 'user') {
|
||||
groups.push({ id: message.id, index: message.index, kind: 'standalone' })
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
const indices = [message.index]
|
||||
|
||||
while (i + 1 < messages.length && messages[i + 1].role !== 'user') {
|
||||
indices.push(messages[++i].index)
|
||||
}
|
||||
|
||||
groups.push({ id: message.id, indices, kind: 'turn' })
|
||||
}
|
||||
|
||||
return groups
|
||||
}
|
||||
|
||||
const VirtualizedThreadInner: FC<VirtualizedThreadProps> = ({
|
||||
clampToComposer,
|
||||
components,
|
||||
emptyPlaceholder,
|
||||
loadingIndicator,
|
||||
sessionKey
|
||||
}) => {
|
||||
const messageSignature = useAuiState(s =>
|
||||
s.thread.messages.map((message, index) => `${index}:${message.id}:${message.role}`).join('\n')
|
||||
)
|
||||
|
||||
const isRunning = useAuiState(s => s.thread.isRunning)
|
||||
|
||||
const groups = useMemo(() => buildGroups(messageSignature), [messageSignature])
|
||||
const renderEmpty = groups.length === 0 && Boolean(emptyPlaceholder)
|
||||
const scrollerRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
// Shared ref so scrollToFn can check whether the user is parked at the
|
||||
// bottom without needing a ref from inside useThreadScrollAnchor.
|
||||
const stickyBottomRef = useRef(true)
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: groups.length,
|
||||
estimateSize: () => ESTIMATED_ITEM_HEIGHT,
|
||||
getItemKey: index => groups[index]?.id ?? index,
|
||||
getScrollElement: () => scrollerRef.current,
|
||||
// Seed the rect so the initial range mounts something before
|
||||
// `observeElementRect` reports the real layout (it overrides this).
|
||||
initialRect: { height: 600, width: 800 },
|
||||
overscan: OVERSCAN,
|
||||
// When the virtualizer adjusts scroll due to item measurement changes,
|
||||
// skip the adjustment if the user is at the bottom. Our ResizeObserver +
|
||||
// pinToBottom loop handles scroll anchoring; letting the virtualizer also
|
||||
// adjust creates a feedback loop where the two fight each other,
|
||||
// producing visible rubber-banding (the view snaps to the composer
|
||||
// then jumps back up).
|
||||
scrollToFn: (offset, _options, instance) => {
|
||||
const el = instance.scrollElement
|
||||
|
||||
if (!el) {
|
||||
return
|
||||
}
|
||||
|
||||
if (stickyBottomRef.current) {
|
||||
const maxScroll = el.scrollHeight - el.clientHeight
|
||||
const distFromBottom = maxScroll - el.scrollTop
|
||||
|
||||
if (distFromBottom <= AT_BOTTOM_THRESHOLD && offset < maxScroll) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
;(el as HTMLElement).scrollTo(0, offset)
|
||||
}
|
||||
})
|
||||
|
||||
useThreadScrollAnchor({
|
||||
enabled: !renderEmpty,
|
||||
groupCount: groups.length,
|
||||
isRunning,
|
||||
scrollerRef,
|
||||
sessionKey: sessionKey ?? null,
|
||||
stickyBottomRef,
|
||||
virtualizer
|
||||
})
|
||||
|
||||
const virtualItems = virtualizer.getVirtualItems()
|
||||
const totalSize = virtualizer.getTotalSize()
|
||||
const paddingTop = virtualItems[0]?.start ?? 0
|
||||
const paddingBottom = Math.max(0, totalSize - (virtualItems.at(-1)?.end ?? 0))
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative min-h-0 max-w-full overflow-hidden contain-[layout_paint]"
|
||||
style={{ height: clampToComposer ? 'var(--thread-viewport-height)' : '100%' }}
|
||||
>
|
||||
<div
|
||||
className="size-full overflow-x-hidden overflow-y-auto overscroll-contain"
|
||||
data-slot="aui_thread-viewport"
|
||||
ref={scrollerRef}
|
||||
>
|
||||
{renderEmpty ? (
|
||||
<div
|
||||
className="mx-auto grid h-full w-full max-w-(--composer-width) grid-rows-[minmax(0,1fr)_auto] min-w-0 gap-(--conversation-turn-gap) px-6 py-8"
|
||||
data-slot="aui_thread-content"
|
||||
>
|
||||
{emptyPlaceholder}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
'mx-auto flex w-full max-w-(--composer-width) min-w-0 flex-col px-6 pt-[calc(var(--titlebar-height)+1.5rem)]'
|
||||
)}
|
||||
data-slot="aui_thread-content"
|
||||
>
|
||||
{/* Natural-flow virtualization: mounted items render as normal
|
||||
flex siblings so `position: sticky` on the human bubble
|
||||
resolves against the scroller without transform interference.
|
||||
Padding spacers reserve scroll space for unmounted items. */}
|
||||
<div style={{ paddingBottom: `${paddingBottom}px`, paddingTop: `${paddingTop}px` }}>
|
||||
{virtualItems.map(virtualItem => {
|
||||
const group = groups[virtualItem.index]
|
||||
|
||||
if (!group) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex min-w-0 flex-col gap-(--conversation-turn-gap) pb-(--conversation-turn-gap)"
|
||||
data-index={virtualItem.index}
|
||||
key={virtualItem.key}
|
||||
ref={virtualizer.measureElement}
|
||||
>
|
||||
<MessageRenderBoundary resetKey={messageSignature}>
|
||||
{group.kind === 'turn' ? (
|
||||
<div
|
||||
className="composer-human-ai-pair-container relative flex min-w-0 flex-col gap-(--conversation-turn-gap)"
|
||||
data-slot="aui_turn-pair"
|
||||
>
|
||||
{group.indices.map(index => (
|
||||
<ThreadPrimitive.MessageByIndex components={components} index={index} key={index} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<ThreadPrimitive.MessageByIndex components={components} index={group.index} />
|
||||
)}
|
||||
</MessageRenderBoundary>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{loadingIndicator}
|
||||
{clampToComposer && (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="shrink-0"
|
||||
data-slot="aui_composer-clearance"
|
||||
style={{ height: 'var(--thread-last-message-clearance)' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const VirtualizedThread = memo(VirtualizedThreadInner)
|
||||
|
||||
function scrollElementToBottom(el: HTMLDivElement) {
|
||||
el.scrollTop = el.scrollHeight
|
||||
}
|
||||
|
||||
interface ScrollAnchorOptions {
|
||||
enabled: boolean
|
||||
groupCount: number
|
||||
isRunning: boolean
|
||||
scrollerRef: React.RefObject<HTMLDivElement | null>
|
||||
sessionKey: string | null
|
||||
stickyBottomRef: React.MutableRefObject<boolean>
|
||||
virtualizer: Virtualizer<HTMLDivElement, Element>
|
||||
}
|
||||
|
||||
function useThreadScrollAnchor({
|
||||
enabled,
|
||||
groupCount,
|
||||
isRunning,
|
||||
scrollerRef,
|
||||
sessionKey,
|
||||
stickyBottomRef,
|
||||
virtualizer
|
||||
}: ScrollAnchorOptions) {
|
||||
// `stickyBottomRef` = parked at bottom, content growth should follow. Cleared on
|
||||
// user-driven upward scroll; re-armed when they reach bottom again.
|
||||
// This is a shared ref — scrollToFn reads it to prevent the virtualizer's
|
||||
// measurement adjustments from fighting our pinToBottom.
|
||||
const lastTopRef = useRef(0)
|
||||
const lastHeightRef = useRef(0)
|
||||
const lastClientHeightRef = useRef(0)
|
||||
// Counter that tracks how many scroll events we expect to be ours rather
|
||||
// than the user's. `pinToBottom` writes `el.scrollTop`, which fires an
|
||||
// async `scroll` event; without this guard the on-scroll handler can race
|
||||
// with the programmatic write (because content also grew, the *resulting*
|
||||
// scrollTop can be lower than `lastTopRef` from the previous frame) and
|
||||
// misread the programmatic pin as the user scrolling up — which disarms
|
||||
// sticky-bottom and the user's just-submitted message slides above the
|
||||
// fold. See `apps/desktop/scripts/measure-jump.mjs` for the repro
|
||||
// (distFromBottom 0 → 49 within one frame, sticking forever).
|
||||
const programmaticScrollPendingRef = useRef(0)
|
||||
const prevSessionKeyRef = useRef(sessionKey)
|
||||
const prevGroupCountRef = useRef(0)
|
||||
|
||||
const pinToBottom = useCallback(() => {
|
||||
const el = scrollerRef.current
|
||||
|
||||
if (!el) {
|
||||
return
|
||||
}
|
||||
|
||||
// Already parked at the bottom: writing `scrollTop` is a no-op and the
|
||||
// browser fires NO scroll event, so arming the programmatic gate here would
|
||||
// leave it permanently set. Repeated pins (streaming heartbeats, the
|
||||
// post-run lock loop) then accumulate the gate, and the next genuine user
|
||||
// scroll-up is misread as one of our programmatic scrolls — re-arming
|
||||
// sticky-bottom and yanking the viewport back down. Refresh trackers, bail.
|
||||
const distFromBottom = el.scrollHeight - (el.scrollTop + el.clientHeight)
|
||||
|
||||
if (distFromBottom <= AT_BOTTOM_THRESHOLD) {
|
||||
lastTopRef.current = el.scrollTop
|
||||
lastHeightRef.current = el.scrollHeight
|
||||
lastClientHeightRef.current = el.clientHeight
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Hold the disarm gate across the scroll event the next line will fire.
|
||||
// Set to 1 rather than incrementing: coalesced writes within a frame fire a
|
||||
// single scroll event, so a counter > 1 can never drain and would swallow a
|
||||
// later real user scroll.
|
||||
programmaticScrollPendingRef.current = 1
|
||||
scrollElementToBottom(el)
|
||||
lastTopRef.current = el.scrollTop
|
||||
lastHeightRef.current = el.scrollHeight
|
||||
lastClientHeightRef.current = el.clientHeight
|
||||
}, [scrollerRef])
|
||||
|
||||
const jumpToBottom = useCallback(() => {
|
||||
setMutableRef(stickyBottomRef, true)
|
||||
|
||||
if (groupCount > 0) {
|
||||
virtualizer.scrollToIndex(groupCount - 1, { align: 'end', behavior: 'auto' })
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (stickyBottomRef.current) {
|
||||
pinToBottom()
|
||||
}
|
||||
})
|
||||
}, [groupCount, pinToBottom, stickyBottomRef, virtualizer])
|
||||
|
||||
useEffect(() => () => setThreadScrolledUp(false), [])
|
||||
|
||||
// Track at-bottom state, dim composer when scrolled up, disarm on user
|
||||
// scroll/wheel/touch.
|
||||
useEffect(() => {
|
||||
const el = scrollerRef.current
|
||||
|
||||
if (!el) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const disarm = () => {
|
||||
setMutableRef(stickyBottomRef, false)
|
||||
programmaticScrollPendingRef.current = 0
|
||||
}
|
||||
|
||||
const onScroll = () => {
|
||||
const top = el.scrollTop
|
||||
|
||||
// If this scroll event is the consequence of `pinToBottom` writing
|
||||
// `el.scrollTop`, treat it as ours: don't disarm. The RO + rAF pin
|
||||
// loop will re-pin on the next frame if the browser clamped us
|
||||
// short of bottom (because content grew in the same frame).
|
||||
// Without this guard the post-pin scrollTop gets misread as the
|
||||
// user scrolling up, disarming sticky-bottom permanently and
|
||||
// leaving the just-submitted message below the fold.
|
||||
if (programmaticScrollPendingRef.current > 0) {
|
||||
programmaticScrollPendingRef.current -= 1
|
||||
lastTopRef.current = top
|
||||
lastHeightRef.current = el.scrollHeight
|
||||
lastClientHeightRef.current = el.clientHeight
|
||||
// Always re-arm — sticky-bottom should hold through clamp races.
|
||||
setMutableRef(stickyBottomRef, true)
|
||||
const atBottom = el.scrollHeight - (top + el.clientHeight) <= AT_BOTTOM_THRESHOLD
|
||||
setThreadScrolledUp(!atBottom)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Disarm only when `scrollTop` decreases while both content height and
|
||||
// viewport height are stable. A bare `top < lastTopRef.current` check is
|
||||
// unsafe: virtualizer measurement, streaming markdown, composer resizing,
|
||||
// window resizing, and toolbar/status updates can all move scrollTop as a
|
||||
// layout side effect. Wheel-up and touchmove still disarm immediately via
|
||||
// their own listeners below, so real user intent remains covered.
|
||||
const heightGrew = el.scrollHeight > lastHeightRef.current
|
||||
const clientHeightChanged = Math.abs(el.clientHeight - lastClientHeightRef.current) > 1
|
||||
|
||||
if (!heightGrew && !clientHeightChanged && top + 1 < lastTopRef.current) {
|
||||
setMutableRef(stickyBottomRef, false)
|
||||
}
|
||||
|
||||
lastTopRef.current = top
|
||||
lastHeightRef.current = el.scrollHeight
|
||||
lastClientHeightRef.current = el.clientHeight
|
||||
|
||||
const atBottom = el.scrollHeight - (top + el.clientHeight) <= AT_BOTTOM_THRESHOLD
|
||||
|
||||
if (atBottom) {
|
||||
setMutableRef(stickyBottomRef, true)
|
||||
}
|
||||
|
||||
setThreadScrolledUp(!atBottom)
|
||||
}
|
||||
|
||||
const onWheel = (event: WheelEvent) => {
|
||||
if (event.deltaY < 0) {
|
||||
disarm()
|
||||
}
|
||||
}
|
||||
|
||||
el.addEventListener('scroll', onScroll, { passive: true })
|
||||
el.addEventListener('wheel', onWheel, { passive: true })
|
||||
el.addEventListener('touchmove', disarm, { passive: true })
|
||||
|
||||
return () => {
|
||||
el.removeEventListener('scroll', onScroll)
|
||||
el.removeEventListener('wheel', onWheel)
|
||||
el.removeEventListener('touchmove', disarm)
|
||||
}
|
||||
}, [scrollerRef, stickyBottomRef])
|
||||
|
||||
// Intentionally NO streaming auto-follow. Earlier builds ran a
|
||||
// ResizeObserver here that re-pinned the viewport to the bottom on every
|
||||
// content growth while a turn was running, so the chat tracked tokens as
|
||||
// they streamed. That behavior is removed by request: once a turn is in
|
||||
// flight the viewport stays exactly where the user left it. The viewport
|
||||
// is still moved to the bottom ONCE per user submit / new turn / session
|
||||
// change (see the layout effect and the session-change effect below) so a
|
||||
// freshly submitted message lands in view — but it does not chase the
|
||||
// stream afterward.
|
||||
|
||||
// Jump to bottom on session change OR when an empty thread first gets
|
||||
// content. Both share the same intent and the same effect.
|
||||
useEffect(() => {
|
||||
const sessionChanged = prevSessionKeyRef.current !== sessionKey
|
||||
const becameNonEmpty = prevGroupCountRef.current === 0 && groupCount > 0
|
||||
|
||||
prevSessionKeyRef.current = sessionKey
|
||||
prevGroupCountRef.current = groupCount
|
||||
|
||||
if (enabled && (sessionChanged || becameNonEmpty)) {
|
||||
jumpToBottom()
|
||||
}
|
||||
}, [enabled, groupCount, jumpToBottom, sessionKey])
|
||||
|
||||
// Pre-paint pin: when groupCount increases while armed (a new turn arriving
|
||||
// from the user submit or assistant turn start), pin BEFORE the browser
|
||||
// commits the layout to screen. Using useLayoutEffect rather than useEffect
|
||||
// so this runs synchronously after React commits the DOM mutation but before
|
||||
// the browser paints. Without this, there's a ~50ms visual window where the
|
||||
// new message sits below the fold.
|
||||
//
|
||||
// We pin TWICE in this critical path — once synchronously, then once on
|
||||
// the next rAF. The second pin catches the case where React mounts the
|
||||
// new message in the second commit (after our layout effect ran), which
|
||||
// grows scrollHeight again; without the rAF pin the user briefly sees a
|
||||
// ~15 px gap below the new message. This fires once per user submit / new
|
||||
// turn arrival — it is NOT streaming-token follow (that path is removed
|
||||
// above), so a turn that streams a long response after this initial jump
|
||||
// will not chase the bottom.
|
||||
const prevGroupCountForLayoutRef = useRef(groupCount)
|
||||
useLayoutEffect(() => {
|
||||
if (!enabled) {
|
||||
return
|
||||
}
|
||||
|
||||
if (groupCount > prevGroupCountForLayoutRef.current && stickyBottomRef.current) {
|
||||
// Defer to rAF so that browser scroll/wheel events from the current
|
||||
// frame are processed first. Without this deferral, a trackpad
|
||||
// scroll-up during streaming can race with this effect: the wheel
|
||||
// event hasn't fired yet so stickyBottomRef is still true, and the
|
||||
// immediate pinToBottom() would snap the viewport back to bottom
|
||||
// against the user's intent.
|
||||
requestAnimationFrame(() => {
|
||||
if (stickyBottomRef.current) {
|
||||
pinToBottom()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
prevGroupCountForLayoutRef.current = groupCount
|
||||
}, [enabled, groupCount, pinToBottom, stickyBottomRef])
|
||||
|
||||
// Intentionally NO post-run bottom lock. Earlier builds kept pinning to
|
||||
// the bottom for POST_RUN_BOTTOM_LOCK_MS after `isRunning` flipped false to
|
||||
// chase final Shiki re-highlight measurement. With streaming follow gone,
|
||||
// re-pinning at completion would yank the viewport back to the bottom even
|
||||
// though the user is reading earlier content — the opposite of what's
|
||||
// wanted. The one-time submit / new-turn jump already covers landing a
|
||||
// fresh message in view.
|
||||
const prevIsRunningForLayoutRef = useRef(isRunning)
|
||||
useLayoutEffect(() => {
|
||||
prevIsRunningForLayoutRef.current = isRunning
|
||||
}, [isRunning])
|
||||
|
||||
useAuiEvent('thread.runStart', jumpToBottom)
|
||||
}
|
||||
@@ -7,7 +7,8 @@ import {
|
||||
MessagePrimitive,
|
||||
type ToolCallMessagePartProps,
|
||||
useAui,
|
||||
useAuiState
|
||||
useAuiState,
|
||||
useMessageRuntime
|
||||
} from '@assistant-ui/react'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { IconPlayerStopFilled } from '@tabler/icons-react'
|
||||
@@ -52,24 +53,28 @@ import {
|
||||
} from '@/app/chat/composer/rich-editor'
|
||||
import { detectTrigger, textBeforeCaret, type TriggerState } from '@/app/chat/composer/text-utils'
|
||||
import { ComposerTriggerPopover } from '@/app/chat/composer/trigger-popover'
|
||||
import { extractDroppedFiles, HERMES_PATHS_MIME, isImagePath, partitionDroppedFiles } from '@/app/chat/hooks/use-composer-actions'
|
||||
import {
|
||||
extractDroppedFiles,
|
||||
HERMES_PATHS_MIME,
|
||||
isImagePath,
|
||||
partitionDroppedFiles
|
||||
} from '@/app/chat/hooks/use-composer-actions'
|
||||
import { uploadComposerAttachment } from '@/app/session/hooks/use-prompt-actions'
|
||||
import { ClarifyTool } from '@/components/assistant-ui/clarify-tool'
|
||||
import { DirectiveContent, hermesDirectiveFormatter } from '@/components/assistant-ui/directive-text'
|
||||
import { MarkdownText, MarkdownTextContent } from '@/components/assistant-ui/markdown-text'
|
||||
import { VirtualizedThread } from '@/components/assistant-ui/thread-virtualizer'
|
||||
import { HoistedTodoPanel, todosFromMessageContent } from '@/components/assistant-ui/todo-tool'
|
||||
import { ThreadMessageList } from '@/components/assistant-ui/thread-list'
|
||||
import { ToolFallback, ToolGroupSlot } from '@/components/assistant-ui/tool-fallback'
|
||||
import { TooltipIconButton } from '@/components/assistant-ui/tooltip-icon-button'
|
||||
import { UserMessageText } from '@/components/assistant-ui/user-message-text'
|
||||
import { useElapsedSeconds } from '@/components/chat/activity-timer'
|
||||
import { ActivityTimerText } from '@/components/chat/activity-timer-text'
|
||||
import { DisclosureRow } from '@/components/chat/disclosure-row'
|
||||
import { GeneratedImageProvider, useGeneratedImageContext } from '@/components/chat/generated-image-context'
|
||||
import { ImageGenerationPlaceholder } from '@/components/chat/image-generation-placeholder'
|
||||
import { GeneratedImage } from '@/components/chat/generated-image-result'
|
||||
import { Intro, type IntroProps } from '@/components/chat/intro'
|
||||
import { PreviewAttachment } from '@/components/chat/preview-attachment'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
|
||||
import { CopyButton } from '@/components/ui/copy-button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -94,13 +99,18 @@ import { playSpeechText, stopVoicePlayback } from '@/lib/voice-playback'
|
||||
import type { ComposerAttachment } from '@/store/composer'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import { $connection } from '@/store/session'
|
||||
import { notifyThreadEditClose, notifyThreadEditOpen } from '@/store/thread-scroll'
|
||||
import { $voicePlayback } from '@/store/voice-playback'
|
||||
|
||||
type ThreadLoadingState = 'response' | 'session'
|
||||
|
||||
interface MessageActionProps {
|
||||
messageId: string
|
||||
messageText: string
|
||||
/** Lazy accessor — reads the live message text at action time. Passing the
|
||||
* text itself as a prop forces the whole footer to re-render on every
|
||||
* streaming delta flush (the text changes ~30×/s), which profiling showed
|
||||
* was a large slice of per-token script time on long transcripts. */
|
||||
getMessageText: () => string
|
||||
onBranchInNewChat?: (messageId: string) => void
|
||||
}
|
||||
|
||||
@@ -128,6 +138,28 @@ function messageContentText(content: unknown): string {
|
||||
return Array.isArray(content) ? content.map(partText).join('').trim() : ''
|
||||
}
|
||||
|
||||
// Cheap streaming-stable "does this message have visible text" check: returns
|
||||
// on the first non-whitespace text part without concatenating the whole
|
||||
// message. Used as a useAuiState selector so its boolean output stays stable
|
||||
// across token flushes (flips false→true once per turn).
|
||||
function contentHasVisibleText(content: unknown): boolean {
|
||||
if (typeof content === 'string') {
|
||||
return content.trim().length > 0
|
||||
}
|
||||
|
||||
if (!Array.isArray(content)) {
|
||||
return false
|
||||
}
|
||||
|
||||
for (const part of content) {
|
||||
if (partText(part).trim().length > 0) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export const Thread: FC<{
|
||||
clampToComposer?: boolean
|
||||
cwd?: string | null
|
||||
@@ -136,6 +168,7 @@ export const Thread: FC<{
|
||||
loading?: ThreadLoadingState
|
||||
onBranchInNewChat?: (messageId: string) => void
|
||||
onCancel?: () => Promise<void> | void
|
||||
onRestoreToMessage?: (messageId: string) => Promise<void> | void
|
||||
sessionId?: string | null
|
||||
sessionKey?: string | null
|
||||
}> = ({
|
||||
@@ -146,6 +179,7 @@ export const Thread: FC<{
|
||||
loading,
|
||||
onBranchInNewChat,
|
||||
onCancel,
|
||||
onRestoreToMessage,
|
||||
sessionId = null,
|
||||
sessionKey
|
||||
}) => {
|
||||
@@ -154,9 +188,9 @@ export const Thread: FC<{
|
||||
AssistantMessage: () => <AssistantMessage onBranchInNewChat={onBranchInNewChat} />,
|
||||
SystemMessage,
|
||||
UserEditComposer: () => <UserEditComposer cwd={cwd} gateway={gateway} sessionId={sessionId} />,
|
||||
UserMessage: () => <UserMessage onCancel={onCancel} />
|
||||
UserMessage: () => <UserMessage onCancel={onCancel} onRestoreToMessage={onRestoreToMessage} />
|
||||
}),
|
||||
[cwd, gateway, onBranchInNewChat, onCancel, sessionId]
|
||||
[cwd, gateway, onBranchInNewChat, onCancel, onRestoreToMessage, sessionId]
|
||||
)
|
||||
|
||||
const emptyPlaceholder = intro ? (
|
||||
@@ -166,18 +200,16 @@ export const Thread: FC<{
|
||||
) : undefined
|
||||
|
||||
return (
|
||||
<GeneratedImageProvider>
|
||||
<div className="relative grid h-full min-h-0 max-w-full grid-rows-[minmax(0,1fr)] overflow-hidden bg-transparent contain-[layout_paint]">
|
||||
<VirtualizedThread
|
||||
clampToComposer={clampToComposer}
|
||||
components={messageComponents}
|
||||
emptyPlaceholder={emptyPlaceholder}
|
||||
loadingIndicator={loading === 'response' ? <ResponseLoadingIndicator /> : null}
|
||||
sessionKey={sessionKey}
|
||||
/>
|
||||
{loading === 'session' && <CenteredThreadSpinner />}
|
||||
</div>
|
||||
</GeneratedImageProvider>
|
||||
<div className="relative grid h-full min-h-0 max-w-full grid-rows-[minmax(0,1fr)] overflow-hidden bg-transparent contain-[layout_paint]">
|
||||
<ThreadMessageList
|
||||
clampToComposer={clampToComposer}
|
||||
components={messageComponents}
|
||||
emptyPlaceholder={emptyPlaceholder}
|
||||
loadingIndicator={loading === 'response' ? <ResponseLoadingIndicator /> : null}
|
||||
sessionKey={sessionKey}
|
||||
/>
|
||||
{loading === 'session' && <CenteredThreadSpinner />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -214,21 +246,39 @@ const CenteredThreadSpinner: FC = () => {
|
||||
|
||||
const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }> = ({ onBranchInNewChat }) => {
|
||||
const messageId = useAuiState(s => s.message.id)
|
||||
const content = useAuiState(s => s.message.content)
|
||||
const messageText = messageContentText(content)
|
||||
const hoistedTodos = useMemo(() => todosFromMessageContent(content), [content])
|
||||
const messageRuntime = useMessageRuntime()
|
||||
|
||||
// PERF: this component must NOT subscribe to the streaming text. Every
|
||||
// selector here returns a value that stays referentially stable across
|
||||
// token flushes (booleans, status strings, '' while running), so the
|
||||
// 30 Hz delta stream only re-renders the markdown part and the tiny
|
||||
// StreamStallIndicator leaf — not the footer/preview/root subtree.
|
||||
const messageStatus = useAuiState(s => s.message.status?.type)
|
||||
const isRunning = messageStatus === 'running'
|
||||
const isPlaceholder = useAuiState(s => s.message.status?.type === 'running' && s.message.content.length === 0)
|
||||
const hasVisibleText = useAuiState(s => contentHasVisibleText(s.message.content))
|
||||
|
||||
// Preview targets only materialize once the turn completes — while running
|
||||
// the selector returns '' (stable), so per-token flushes skip the regex
|
||||
// scan and the re-render it would cause.
|
||||
const completedText = useAuiState(s =>
|
||||
s.message.status?.type === 'running' ? '' : messageContentText(s.message.content)
|
||||
)
|
||||
|
||||
const previewTargets = useMemo(() => {
|
||||
if (!messageText || !/(https?:\/\/|file:\/\/)/i.test(messageText)) {
|
||||
if (!completedText || !/(https?:\/\/|file:\/\/)/i.test(completedText)) {
|
||||
return []
|
||||
}
|
||||
|
||||
return pickPrimaryPreviewTarget(extractPreviewTargets(messageText))
|
||||
}, [messageText])
|
||||
return pickPrimaryPreviewTarget(extractPreviewTargets(completedText))
|
||||
}, [completedText])
|
||||
|
||||
const messageStatus = useAuiState(s => s.message.status?.type)
|
||||
const isPlaceholder = messageStatus === 'running' && content.length === 0
|
||||
const enterRef = useEnterAnimation(messageStatus === 'running', `assistant-message:${messageId}`)
|
||||
const getMessageText = useCallback(
|
||||
() => messageContentText(messageRuntime.getState().content),
|
||||
[messageRuntime]
|
||||
)
|
||||
|
||||
const enterRef = useEnterAnimation(isRunning, `assistant-message:${messageId}`)
|
||||
|
||||
if (isPlaceholder) {
|
||||
return null
|
||||
@@ -239,16 +289,16 @@ const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }>
|
||||
className="group flex w-full min-w-0 max-w-full flex-col gap-0 self-start overflow-hidden"
|
||||
data-role="assistant"
|
||||
data-slot="aui_assistant-message-root"
|
||||
data-streaming={messageStatus === 'running' ? 'true' : undefined}
|
||||
data-streaming={isRunning ? 'true' : undefined}
|
||||
ref={enterRef}
|
||||
>
|
||||
<div
|
||||
className="wrap-anywhere min-w-0 max-w-full overflow-hidden text-pretty text-[length:var(--conversation-text-font-size)] leading-(--dt-line-height) text-foreground"
|
||||
data-slot="aui_assistant-message-content"
|
||||
>
|
||||
{hoistedTodos.length > 0 && <HoistedTodoPanel todos={hoistedTodos} />}
|
||||
{/* Todos render in the composer status stack now, not inline. */}
|
||||
<MessagePrimitive.Parts components={MESSAGE_PARTS_COMPONENTS} />
|
||||
{messageStatus === 'running' && <StreamStallIndicator activity={`${content.length}:${messageText.length}`} />}
|
||||
{isRunning && <StreamStallIndicator />}
|
||||
{previewTargets.length > 0 && (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{previewTargets.map(target => (
|
||||
@@ -265,8 +315,8 @@ const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }>
|
||||
</ErrorPrimitive.Root>
|
||||
</MessagePrimitive.Error>
|
||||
</div>
|
||||
{messageText.trim().length > 0 && (
|
||||
<AssistantFooter messageId={messageId} messageText={messageText} onBranchInNewChat={onBranchInNewChat} />
|
||||
{hasVisibleText && (
|
||||
<AssistantFooter getMessageText={getMessageText} messageId={messageId} onBranchInNewChat={onBranchInNewChat} />
|
||||
)}
|
||||
</MessagePrimitive.Root>
|
||||
)
|
||||
@@ -307,10 +357,28 @@ const STREAM_STALL_S = 2
|
||||
|
||||
// Tail "still thinking" indicator: the pre-first-token spinner goes away once
|
||||
// text flows, but if the stream then goes quiet mid-turn (tool think-time,
|
||||
// provider stall) nothing signals that work continues. Watch a per-render
|
||||
// provider stall) nothing signals that work continues. Watch a per-flush
|
||||
// activity signal; when it hasn't changed for STREAM_STALL_S, re-show the
|
||||
// dither + a timer counting from the last activity.
|
||||
const StreamStallIndicator: FC<{ activity: string }> = ({ activity }) => {
|
||||
//
|
||||
// Subscribes to the activity signal ITSELF (rather than taking it as a prop)
|
||||
// so that per-token updates re-render only this leaf, not the whole
|
||||
// AssistantMessage subtree.
|
||||
const StreamStallIndicator: FC = () => {
|
||||
const activity = useAuiState(s => {
|
||||
let textLength = 0
|
||||
|
||||
for (const part of s.message.content) {
|
||||
const text = (part as { text?: unknown }).text
|
||||
|
||||
if (typeof text === 'string') {
|
||||
textLength += text.length
|
||||
}
|
||||
}
|
||||
|
||||
return `${s.message.content.length}:${textLength}`
|
||||
})
|
||||
|
||||
const [stalled, setStalled] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -334,21 +402,12 @@ const StreamStallIndicator: FC<{ activity: string }> = ({ activity }) => {
|
||||
)
|
||||
}
|
||||
|
||||
const ImageGenerateTool: FC<ToolCallMessagePartProps> = ({ result }) => {
|
||||
const generatedImage = useGeneratedImageContext()
|
||||
const running = result === undefined
|
||||
|
||||
useEffect(() => {
|
||||
generatedImage?.setPending(running)
|
||||
}, [generatedImage, running])
|
||||
|
||||
if (!running) {
|
||||
return null
|
||||
}
|
||||
const ImageGenerateTool: FC<ToolCallMessagePartProps> = ({ args, result }) => {
|
||||
const aspectRatio = typeof args?.aspect_ratio === 'string' ? args.aspect_ratio : undefined
|
||||
|
||||
return (
|
||||
<div className="mt-1.5">
|
||||
<ImageGenerationPlaceholder />
|
||||
<GeneratedImage aspectRatio={aspectRatio} result={result} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -578,7 +637,7 @@ function formatMessageTimestamp(
|
||||
return SHORT_FMT.format(date)
|
||||
}
|
||||
|
||||
const AssistantActionBar: FC<MessageActionProps> = ({ messageId, messageText, onBranchInNewChat }) => {
|
||||
const AssistantActionBar: FC<MessageActionProps> = ({ messageId, getMessageText, onBranchInNewChat }) => {
|
||||
const { t } = useI18n()
|
||||
const copy = t.assistant.thread
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
@@ -599,7 +658,7 @@ const AssistantActionBar: FC<MessageActionProps> = ({ messageId, messageText, on
|
||||
)}
|
||||
data-slot="aui_msg-actions"
|
||||
>
|
||||
<CopyButton appearance="icon" buttonSize="icon" disabled={!messageText} label={copy.copy} text={messageText} />
|
||||
<CopyButton appearance="icon" buttonSize="icon" label={copy.copy} text={getMessageText} />
|
||||
<ActionBarPrimitive.Reload asChild>
|
||||
<TooltipIconButton onClick={() => triggerHaptic('submit')} tooltip={copy.refresh}>
|
||||
<Codicon name="refresh" />
|
||||
@@ -617,7 +676,7 @@ const AssistantActionBar: FC<MessageActionProps> = ({ messageId, messageText, on
|
||||
<GitBranchIcon />
|
||||
{copy.branchNewChat}
|
||||
</DropdownMenuItem>
|
||||
<ReadAloudItem messageId={messageId} text={messageText} />
|
||||
<ReadAloudItem getText={getMessageText} messageId={messageId} />
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</ActionBarPrimitive.Root>
|
||||
@@ -625,7 +684,7 @@ const AssistantActionBar: FC<MessageActionProps> = ({ messageId, messageText, on
|
||||
)
|
||||
}
|
||||
|
||||
const ReadAloudItem: FC<{ messageId: string; text: string }> = ({ messageId, text }) => {
|
||||
const ReadAloudItem: FC<{ getText: () => string; messageId: string }> = ({ getText, messageId }) => {
|
||||
const { t } = useI18n()
|
||||
const copy = t.assistant.thread
|
||||
const voicePlayback = useStore($voicePlayback)
|
||||
@@ -639,6 +698,8 @@ const ReadAloudItem: FC<{ messageId: string; text: string }> = ({ messageId, tex
|
||||
const Icon = isPreparing ? Loader2Icon : isSpeaking ? VolumeXIcon : Volume2Icon
|
||||
|
||||
const read = useCallback(async () => {
|
||||
const text = getText()
|
||||
|
||||
if (!text || $voicePlayback.get().status !== 'idle') {
|
||||
return
|
||||
}
|
||||
@@ -648,11 +709,11 @@ const ReadAloudItem: FC<{ messageId: string; text: string }> = ({ messageId, tex
|
||||
} catch (error) {
|
||||
notifyError(error, copy.readAloudFailed)
|
||||
}
|
||||
}, [copy.readAloudFailed, messageId, text])
|
||||
}, [copy.readAloudFailed, getText, messageId])
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
disabled={isPreparing || (!isSpeaking && (anyPlaybackActive || !text))}
|
||||
disabled={isPreparing || (!isSpeaking && anyPlaybackActive)}
|
||||
onSelect={e => {
|
||||
e.preventDefault()
|
||||
void (isSpeaking ? stopVoicePlayback() : read())
|
||||
@@ -706,15 +767,22 @@ function messageAttachmentRefs(value: unknown): string[] {
|
||||
return value.every(ref => typeof ref === 'string') ? value : EMPTY_ATTACHMENT_REFS
|
||||
}
|
||||
|
||||
function StickyHumanMessageContainer({ children }: { children: ReactNode }) {
|
||||
function StickyHumanMessageContainer({ attachments, children }: { attachments?: ReactNode; children: ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
className="group/user-message sticky z-40 -mx-4 flex w-[calc(100%+2rem)] min-w-0 max-w-none flex-col items-stretch gap-0 self-end overflow-visible bg-(--ui-chat-surface-background) px-4 pb-(--conversation-turn-gap) pt-2"
|
||||
data-role="user"
|
||||
data-slot="aui_user-message-root"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
// Fragment, not a wrapper: a wrapping element becomes the sticky's
|
||||
// containing block (it'd stick within its own height = never). The bubble
|
||||
// and attachments are flow siblings so the bubble pins against the scroller
|
||||
// while attachments below it scroll away.
|
||||
<>
|
||||
<div
|
||||
className="group/user-message sticky z-40 -mx-4 flex w-[calc(100%+2rem)] min-w-0 max-w-none flex-col items-stretch gap-0 self-end overflow-visible bg-(--ui-chat-surface-background) px-4 pb-(--conversation-turn-gap) pt-1"
|
||||
data-role="user"
|
||||
data-slot="aui_user-message-root"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
{attachments}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -737,11 +805,46 @@ const USER_ACTION_ICON_BUTTON_CLASS =
|
||||
const USER_ACTION_ICON_SIZE = '0.6875rem'
|
||||
const StopGlyph = <IconPlayerStopFilled aria-hidden className="size-3.5 -translate-y-px" />
|
||||
|
||||
// Background-process notifications are injected into the conversation as user
|
||||
// messages (the agent must react to them, and message-role alternation forbids
|
||||
// a synthetic system row mid-loop). They are NOT something the human typed, so
|
||||
// render them as a compact system-style notice instead of a user bubble.
|
||||
// Shape: see tools/process_registry.py format_process_notification().
|
||||
const PROCESS_NOTIFICATION_RE = /^\[IMPORTANT: Background process [\s\S]*\]$/
|
||||
|
||||
const ProcessNotificationNote: FC<{ text: string }> = ({ text }) => {
|
||||
const body = text.replace(/^\[IMPORTANT:\s*/, '').replace(/\]$/, '')
|
||||
const newline = body.indexOf('\n')
|
||||
const headline = (newline === -1 ? body : body.slice(0, newline)).trim()
|
||||
const detail = newline === -1 ? '' : body.slice(newline + 1).trim()
|
||||
|
||||
return (
|
||||
<div className="flex max-w-[min(86%,44rem)] flex-col gap-0.5 self-center px-2 py-0.5 text-[0.6875rem] leading-5 text-muted-foreground/60">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Codicon className="shrink-0 text-muted-foreground/55" name="terminal" size="0.75rem" />
|
||||
<span className="wrap-anywhere">{headline}</span>
|
||||
</span>
|
||||
{detail && (
|
||||
<details className="pl-[1.3125rem]">
|
||||
<summary className="cursor-pointer select-none text-muted-foreground/45 hover:text-muted-foreground/70">
|
||||
output
|
||||
</summary>
|
||||
<pre className="mt-0.5 max-h-48 overflow-auto whitespace-pre-wrap font-mono text-[0.625rem] leading-4 text-muted-foreground/55">
|
||||
{detail}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const UserMessage: FC<{
|
||||
onCancel?: () => Promise<void> | void
|
||||
}> = ({ onCancel }) => {
|
||||
onRestoreToMessage?: (messageId: string) => Promise<void> | void
|
||||
}> = ({ onCancel, onRestoreToMessage }) => {
|
||||
const { t } = useI18n()
|
||||
const copy = t.assistant.thread
|
||||
const [restoreConfirmOpen, setRestoreConfirmOpen] = useState(false)
|
||||
const messageId = useAuiState(s => s.message.id)
|
||||
const content = useAuiState(s => s.message.content)
|
||||
const messageText = messageContentText(content)
|
||||
@@ -772,8 +875,10 @@ const UserMessage: FC<{
|
||||
// changes, not on every frame while the outer max-height animates open.
|
||||
const clampInnerRef = useRef<HTMLDivElement | null>(null)
|
||||
const [bodyClamped, setBodyClamped] = useState(false)
|
||||
const lastClampHeightRef = useRef(-1)
|
||||
const lineHeightRef = useRef(0)
|
||||
|
||||
const measureClamp = useCallback(() => {
|
||||
const measureClamp = useCallback((entries: readonly ResizeObserverEntry[]) => {
|
||||
const inner = clampInnerRef.current
|
||||
const outer = inner?.parentElement
|
||||
|
||||
@@ -781,68 +886,105 @@ const UserMessage: FC<{
|
||||
return
|
||||
}
|
||||
|
||||
const styles = getComputedStyle(inner)
|
||||
const lineHeight = parseFloat(styles.lineHeight) || 1.5 * parseFloat(styles.fontSize) || 20
|
||||
const fullHeight = inner.scrollHeight
|
||||
// Prefer the size the ResizeObserver already computed — reading
|
||||
// `scrollHeight` outside RO timing forces a synchronous layout, and with
|
||||
// many user bubbles observed at once those reads interleave with the
|
||||
// style write below into a read-write-read reflow cascade.
|
||||
const entryHeight = entries.find(entry => entry.target === inner)?.borderBoxSize?.[0]?.blockSize
|
||||
const fullHeight = Math.ceil(entryHeight ?? inner.scrollHeight)
|
||||
|
||||
if (fullHeight === lastClampHeightRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
lastClampHeightRef.current = fullHeight
|
||||
|
||||
// Line-height is stable for the life of the bubble (font settings don't
|
||||
// change under it) — resolve the computed style once.
|
||||
if (!lineHeightRef.current) {
|
||||
const styles = getComputedStyle(inner)
|
||||
lineHeightRef.current = parseFloat(styles.lineHeight) || 1.5 * parseFloat(styles.fontSize) || 20
|
||||
}
|
||||
|
||||
outer.style.setProperty('--human-msg-full', `${fullHeight}px`)
|
||||
setBodyClamped(fullHeight > lineHeight * 2 + 1)
|
||||
setBodyClamped(fullHeight > lineHeightRef.current * 2 + 1)
|
||||
}, [])
|
||||
|
||||
useResizeObserver(measureClamp, clampInnerRef)
|
||||
|
||||
// Injected background-process notification, not a human prompt — render the
|
||||
// compact system-style notice (after all hooks above have run).
|
||||
if (PROCESS_NOTIFICATION_RE.test(messageText.trim())) {
|
||||
return (
|
||||
<MessagePrimitive.Root
|
||||
className="flex w-full min-w-0 flex-col items-stretch"
|
||||
data-role="user"
|
||||
data-slot="aui_user-message-root"
|
||||
>
|
||||
<ProcessNotificationNote text={messageText.trim()} />
|
||||
</MessagePrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
const hasBody = messageText.trim().length > 0
|
||||
const isLatestUser = messageId === latestUserId
|
||||
const showStop = isLatestUser && threadRunning && Boolean(onCancel)
|
||||
const showRestore = !isLatestUser && !threadRunning
|
||||
// Restore (re-run this exact prompt) is available everywhere the Stop button
|
||||
// isn't — including mid-stream on older prompts, since the action interrupts
|
||||
// the live turn before rewinding.
|
||||
const showRestore = !showStop && Boolean(onRestoreToMessage) && hasBody
|
||||
|
||||
const bubbleClassName = cn(
|
||||
USER_BUBBLE_BASE_CLASS,
|
||||
'border-(--ui-stroke-tertiary) pr-9 text-[length:var(--conversation-text-font-size)] leading-(--dt-line-height) text-foreground/95 transition-colors',
|
||||
!threadRunning && 'cursor-pointer hover:border-(--ui-stroke-secondary)'
|
||||
'cursor-pointer pr-9 text-[length:var(--conversation-text-font-size)] leading-(--dt-line-height) text-foreground/95 transition-colors',
|
||||
'border-(--ui-stroke-tertiary) hover:border-(--ui-stroke-secondary)'
|
||||
)
|
||||
|
||||
const bubbleContent = (
|
||||
<>
|
||||
{attachmentRefs.length > 0 && (
|
||||
<span className="-mx-1 flex flex-wrap gap-1 border-b border-border/45 pb-1.5">
|
||||
<DirectiveContent text={attachmentRefs.join(' ')} />
|
||||
</span>
|
||||
)}
|
||||
{hasBody && (
|
||||
// Render the user's text through a minimal markdown pipeline:
|
||||
// backtick `code` and ``` fenced ``` blocks, with directive chips
|
||||
// (`@file:` etc.) still resolved inside the plain-text spans.
|
||||
<div className="sticky-human-clamp" data-clamped={bodyClamped ? 'true' : undefined}>
|
||||
<div ref={clampInnerRef}>
|
||||
<UserMessageText className="wrap-anywhere" text={messageText} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
const bubbleContent = hasBody && (
|
||||
// Render the user's text through a minimal markdown pipeline:
|
||||
// backtick `code` and ``` fenced ``` blocks, with directive chips
|
||||
// (`@file:` etc.) still resolved inside the plain-text spans.
|
||||
<div className="sticky-human-clamp" data-clamped={bodyClamped ? 'true' : undefined}>
|
||||
{/* Match the edit composer's collapsed line box (min-h-[1.25rem]) so
|
||||
clicking to edit can't grow the bubble by a sub-pixel and reflow the
|
||||
turn 1px. */}
|
||||
<div className="min-h-[1.25rem]" ref={clampInnerRef}>
|
||||
<UserMessageText className="wrap-anywhere" text={messageText} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<MessagePrimitive.Root asChild>
|
||||
<StickyHumanMessageContainer>
|
||||
<StickyHumanMessageContainer
|
||||
attachments={
|
||||
// Attachments live BELOW the sticky bubble in normal flow, so they
|
||||
// scroll away behind the pinned bubble instead of riding along with
|
||||
// it. Image refs render as thumbnails, file refs as chips; no border.
|
||||
attachmentRefs.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1 -mt-3 mb-2">
|
||||
<DirectiveContent text={attachmentRefs.join(' ')} />
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<ActionBarPrimitive.Root className="relative w-full max-w-full" data-slot="aui_user-bubble-actions">
|
||||
<div className="human-message-with-todos-wrapper flex w-full flex-col gap-0">
|
||||
<div className="relative w-full">
|
||||
{threadRunning ? (
|
||||
<div className={bubbleClassName}>{bubbleContent}</div>
|
||||
) : (
|
||||
<ActionBarPrimitive.Edit asChild>
|
||||
<button
|
||||
aria-label={copy.editMessage}
|
||||
className={bubbleClassName}
|
||||
onClick={() => triggerHaptic('selection')}
|
||||
title={copy.editMessage}
|
||||
type="button"
|
||||
>
|
||||
{bubbleContent}
|
||||
</button>
|
||||
</ActionBarPrimitive.Edit>
|
||||
)}
|
||||
{/* Always editable — clicking opens the edit composer even while a
|
||||
turn streams; sending the edit reverts (interrupt + rewind). */}
|
||||
<ActionBarPrimitive.Edit asChild>
|
||||
<button
|
||||
aria-label={copy.editMessage}
|
||||
className={bubbleClassName}
|
||||
onClick={() => triggerHaptic('selection')}
|
||||
onPointerDown={() => notifyThreadEditOpen()}
|
||||
title={copy.editMessage}
|
||||
type="button"
|
||||
>
|
||||
{bubbleContent}
|
||||
</button>
|
||||
</ActionBarPrimitive.Edit>
|
||||
{(showStop || showRestore) && (
|
||||
<div className="pointer-events-none absolute right-2 bottom-2 z-10 flex items-center justify-center opacity-0 transition-opacity group-hover/user-message:opacity-100 group-focus-within/user-message:opacity-100">
|
||||
{showStop ? (
|
||||
@@ -860,13 +1002,20 @@ const UserMessage: FC<{
|
||||
{StopGlyph}
|
||||
</button>
|
||||
) : (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="flex size-6 items-center justify-center rounded-md text-(--ui-text-tertiary)"
|
||||
title={copy.editableCheckpoint}
|
||||
<button
|
||||
aria-label={copy.restoreCheckpoint}
|
||||
className={cn('pointer-events-auto size-6', USER_ACTION_ICON_BUTTON_CLASS)}
|
||||
onClick={event => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
triggerHaptic('selection')
|
||||
setRestoreConfirmOpen(true)
|
||||
}}
|
||||
title={copy.restoreFromHere}
|
||||
type="button"
|
||||
>
|
||||
<Codicon name="discard" size="0.875rem" />
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -894,6 +1043,17 @@ const UserMessage: FC<{
|
||||
</BranchPickerPrimitive.Root>
|
||||
</div>
|
||||
</ActionBarPrimitive.Root>
|
||||
{showRestore && (
|
||||
<ConfirmDialog
|
||||
confirmLabel={copy.restoreConfirm}
|
||||
description={copy.restoreBody}
|
||||
destructive
|
||||
onClose={() => setRestoreConfirmOpen(false)}
|
||||
onConfirm={() => onRestoreToMessage?.(messageId)}
|
||||
open={restoreConfirmOpen}
|
||||
title={copy.restoreTitle}
|
||||
/>
|
||||
)}
|
||||
</StickyHumanMessageContainer>
|
||||
</MessagePrimitive.Root>
|
||||
)
|
||||
@@ -1008,6 +1168,8 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
|
||||
const at = useAtCompletions({ cwd, gateway, sessionId })
|
||||
const slash = useSlashCompletions({ gateway })
|
||||
|
||||
useEffect(() => () => notifyThreadEditClose(), [])
|
||||
|
||||
const focusEditor = useCallback(() => {
|
||||
const editor = editorRef.current
|
||||
|
||||
@@ -1261,7 +1423,10 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
|
||||
}
|
||||
|
||||
const remote = $connection.get()?.mode === 'remote'
|
||||
const requestGateway = <T,>(method: string, params?: Record<string, unknown>) => gateway.request<T>(method, params)
|
||||
|
||||
const requestGateway = <T,>(method: string, params?: Record<string, unknown>) =>
|
||||
gateway.request<T>(method, params)
|
||||
|
||||
const refs: InlineRefInput[] = []
|
||||
|
||||
for (const candidate of osDrops) {
|
||||
@@ -1530,9 +1695,8 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
|
||||
aria-label={copy.editMessage}
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
autoFocus
|
||||
className={cn(
|
||||
'ui-prompt-input-editor__input max-h-48 w-full resize-none bg-transparent p-0 pr-7 text-[length:var(--conversation-text-font-size)] leading-(--dt-line-height) text-foreground/95 outline-none',
|
||||
'ui-prompt-input-editor__input max-h-48 w-full resize-none bg-transparent p-0 pr-7 text-[length:var(--conversation-text-font-size)] text-foreground/95 outline-none',
|
||||
'empty:before:content-[attr(data-placeholder)] empty:before:text-muted-foreground/60',
|
||||
'**:data-ref-text:cursor-default',
|
||||
expanded ? 'min-h-16' : 'min-h-[1.25rem]'
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
import { type FC } from 'react'
|
||||
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Loader2Icon } from '@/lib/icons'
|
||||
import { parseTodos, type TodoItem, type TodoStatus } from '@/lib/todos'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function todosFromMessageContent(content: unknown): TodoItem[] {
|
||||
if (!Array.isArray(content)) {
|
||||
return []
|
||||
}
|
||||
|
||||
let latest: null | TodoItem[] = null
|
||||
|
||||
for (const part of content) {
|
||||
if (!part || typeof part !== 'object') {
|
||||
continue
|
||||
}
|
||||
|
||||
const row = part as Record<string, unknown>
|
||||
|
||||
if (row.type !== 'tool-call' || row.toolName !== 'todo') {
|
||||
continue
|
||||
}
|
||||
|
||||
const parsed = parseTodos(row.result) ?? parseTodos(row.args)
|
||||
|
||||
if (parsed !== null) {
|
||||
latest = parsed
|
||||
}
|
||||
}
|
||||
|
||||
return latest ?? []
|
||||
}
|
||||
|
||||
const headerLabel = (todos: readonly TodoItem[]): string =>
|
||||
todos.find(t => t.status === 'in_progress')?.content ??
|
||||
todos.find(t => t.status === 'pending')?.content ??
|
||||
todos.at(-1)?.content ??
|
||||
'Tasks'
|
||||
|
||||
const Checkmark: FC<{ status: TodoStatus; label: string }> = ({ status, label }) => {
|
||||
if (status === 'in_progress') {
|
||||
return (
|
||||
<span
|
||||
aria-label={`In progress: ${label}`}
|
||||
className="grid size-[1.1rem] shrink-0 place-items-center rounded-full border border-ring/65 bg-[color-mix(in_srgb,var(--dt-ring)_14%,transparent)]"
|
||||
>
|
||||
<Loader2Icon className="size-3 animate-spin text-ring" />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const checked = status === 'completed'
|
||||
|
||||
return (
|
||||
<Checkbox
|
||||
aria-label={label}
|
||||
checked={checked}
|
||||
className={cn(
|
||||
'size-[1.1rem] shrink-0 rounded-full border-border/80 pointer-events-none disabled:cursor-default disabled:opacity-100',
|
||||
checked &&
|
||||
'data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground [&_[data-slot=checkbox-indicator]_svg]:size-3',
|
||||
status === 'cancelled' && 'border-muted-foreground/40'
|
||||
)}
|
||||
disabled
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export const HoistedTodoPanel: FC<{ todos: TodoItem[] }> = ({ todos }) => {
|
||||
if (!todos.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const label = headerLabel(todos)
|
||||
|
||||
return (
|
||||
<section
|
||||
className="mt-1 mb-3 inline-block w-fit max-w-full overflow-hidden rounded-2xl border border-border/70 bg-card align-top shadow-[0_1px_2px_0_hsl(var(--foreground)/0.04),0_1px_4px_-1px_hsl(var(--foreground)/0.06)]"
|
||||
data-slot="aui_todo-hoisted"
|
||||
>
|
||||
<header className="px-3 pt-3 pb-2">
|
||||
<span
|
||||
className="block max-w-full truncate text-[0.85rem] font-semibold leading-tight tracking-tight text-foreground"
|
||||
title={label}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</header>
|
||||
<ul className="grid min-w-0 gap-0.5 px-3 pb-3">
|
||||
{todos.map(todo => (
|
||||
<li
|
||||
// Active row at full presence; everything else fades. Opacity on
|
||||
// the row so the checkbox glyph dims with the text.
|
||||
className={cn(
|
||||
'flex min-w-0 items-center gap-3 py-1.5 transition-opacity',
|
||||
todo.status === 'in_progress' ? 'opacity-100' : 'opacity-45'
|
||||
)}
|
||||
key={todo.id}
|
||||
>
|
||||
<Checkmark label={todo.content} status={todo.status} />
|
||||
<span className="min-w-0 wrap-anywhere text-[0.8rem] leading-[1.2rem] text-foreground">{todo.content}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import { AssistantRuntimeProvider, type ThreadMessage, useExternalStoreRuntime } from '@assistant-ui/react'
|
||||
import { cleanup, render, waitFor } from '@testing-library/react'
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { clearAllPrompts, setApprovalRequest } from '@/store/prompts'
|
||||
import { $activeSessionId } from '@/store/session'
|
||||
import { clearDismissedToolRows } from '@/store/tool-dismiss'
|
||||
import { $toolDisclosureStates } from '@/store/tool-view'
|
||||
|
||||
import { Thread } from './thread'
|
||||
@@ -104,6 +105,84 @@ function groupedPendingMessage(): ThreadMessage {
|
||||
} as ThreadMessage
|
||||
}
|
||||
|
||||
function pendingOnlyMessage(): ThreadMessage {
|
||||
return {
|
||||
id: 'assistant-pending-only',
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'term-only',
|
||||
toolName: 'terminal',
|
||||
args: { command: 'sleep 10' },
|
||||
argsText: JSON.stringify({ command: 'sleep 10' })
|
||||
}
|
||||
],
|
||||
status: { type: 'running' },
|
||||
createdAt,
|
||||
metadata: {
|
||||
unstable_state: null,
|
||||
unstable_annotations: [],
|
||||
unstable_data: [],
|
||||
steps: [],
|
||||
custom: {}
|
||||
}
|
||||
} as ThreadMessage
|
||||
}
|
||||
|
||||
function completedOnlyMessage(): ThreadMessage {
|
||||
return {
|
||||
id: 'assistant-completed-only',
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'read-only',
|
||||
toolName: 'read_file',
|
||||
args: { path: '/etc/hosts' },
|
||||
argsText: JSON.stringify({ path: '/etc/hosts' }),
|
||||
result: { content: '127.0.0.1 localhost' }
|
||||
}
|
||||
],
|
||||
status: { type: 'complete', reason: 'stop' },
|
||||
createdAt,
|
||||
metadata: {
|
||||
unstable_state: null,
|
||||
unstable_annotations: [],
|
||||
unstable_data: [],
|
||||
steps: [],
|
||||
custom: {}
|
||||
}
|
||||
} as ThreadMessage
|
||||
}
|
||||
|
||||
function failedOnlyMessage(): ThreadMessage {
|
||||
return {
|
||||
id: 'assistant-failed-only',
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'term-failed',
|
||||
toolName: 'terminal',
|
||||
args: { command: 'exit 1' },
|
||||
argsText: JSON.stringify({ command: 'exit 1' }),
|
||||
isError: true,
|
||||
result: { stderr: 'boom' }
|
||||
}
|
||||
],
|
||||
status: { type: 'complete', reason: 'stop' },
|
||||
createdAt,
|
||||
metadata: {
|
||||
unstable_state: null,
|
||||
unstable_annotations: [],
|
||||
unstable_data: [],
|
||||
steps: [],
|
||||
custom: {}
|
||||
}
|
||||
} as ThreadMessage
|
||||
}
|
||||
|
||||
function GroupHarness({ message }: { message: ThreadMessage }) {
|
||||
const runtime = useExternalStoreRuntime<ThreadMessage>({
|
||||
messages: [message],
|
||||
@@ -122,12 +201,14 @@ beforeEach(() => {
|
||||
clearAllPrompts()
|
||||
$activeSessionId.set('sess-1')
|
||||
$toolDisclosureStates.set({})
|
||||
clearDismissedToolRows()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
clearAllPrompts()
|
||||
$activeSessionId.set(null)
|
||||
clearDismissedToolRows()
|
||||
})
|
||||
|
||||
describe('flat tool list approval surfacing', () => {
|
||||
@@ -155,4 +236,64 @@ describe('flat tool list approval surfacing', () => {
|
||||
expect(bar?.closest('[hidden]')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
it('lets completed tool rows be dismissed', async () => {
|
||||
const { container } = render(<GroupHarness message={completedOnlyMessage()} />)
|
||||
|
||||
const dismiss = await screen.findByLabelText('Dismiss')
|
||||
|
||||
expect(container.querySelectorAll('[data-slot="tool-block"]').length).toBeGreaterThan(1)
|
||||
|
||||
fireEvent.click(dismiss)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByLabelText('Dismiss')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps a dismissed row hidden after a remount (virtualization)', async () => {
|
||||
// The thread virtualizes, so a row's component unmounts/remounts as it
|
||||
// scrolls. Dismissal must persist across that — component-local state would
|
||||
// forget it and the row would pop back. Simulate the remount by unmounting
|
||||
// and rendering the same message fresh.
|
||||
const first = render(<GroupHarness message={completedOnlyMessage()} />)
|
||||
|
||||
fireEvent.click(await screen.findByLabelText('Dismiss'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByLabelText('Dismiss')).toBeNull()
|
||||
})
|
||||
|
||||
first.unmount()
|
||||
|
||||
const { container } = render(<GroupHarness message={completedOnlyMessage()} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.querySelectorAll('[data-slot="tool-block"]').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
expect(screen.queryByLabelText('Dismiss')).toBeNull()
|
||||
})
|
||||
|
||||
it('lets failed tool rows be dismissed', async () => {
|
||||
render(<GroupHarness message={failedOnlyMessage()} />)
|
||||
|
||||
const dismiss = await screen.findByLabelText('Dismiss')
|
||||
|
||||
fireEvent.click(dismiss)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByLabelText('Dismiss')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
it('does not show dismiss for pending tool rows', async () => {
|
||||
const { container } = render(<GroupHarness message={pendingOnlyMessage()} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.querySelectorAll('[data-slot="tool-block"]').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
expect(screen.queryByLabelText('Dismiss')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -84,6 +84,19 @@ describe('PendingToolApproval', () => {
|
||||
expect($approvalRequest.get()).toBeNull()
|
||||
})
|
||||
|
||||
it('reveals the full command inline when the Command toggle is clicked', () => {
|
||||
const longCommand = 'python -c "' + 'x'.repeat(400) + '"'
|
||||
setRequest(longCommand)
|
||||
render(<PendingToolApproval part={part('terminal')} />)
|
||||
|
||||
// Collapsed by default: the full command is not in the DOM yet.
|
||||
expect(screen.queryByText(longCommand)).toBeNull()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Command/ }))
|
||||
|
||||
expect(screen.getByText(longCommand)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('sends choice "deny" on Reject', async () => {
|
||||
const request = mockGateway()
|
||||
setRequest()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user