diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c02a436efb03..595569a82fa0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -165,3 +165,67 @@ jobs: sys.exit(1) print('All checks passed (or were skipped)') " + + # ───────────────────────────────────────────────────────────────────── + # CI timing report: collect per-job/step durations from the GitHub API, + # cache them on main (as a baseline), and on PRs generate an HTML diff + # report with a gantt chart + per-step breakdown. The report is uploaded + # as an artifact and a markdown summary is written to $GITHUB_STEP_SUMMARY. + # ───────────────────────────────────────────────────────────────────── + ci-timings: + name: CI timing report + needs: [all-checks-pass, docker] + if: always() + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Restore baseline cache (PR only) + if: github.event_name == 'pull_request' + uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + with: + path: ci-timings-baseline.json + # Prefix-match: exact key will never hit (run_id differs), so + # restore-keys finds the most recent baseline from main. + key: ci-timings-baseline-never-exact + restore-keys: | + ci-timings-baseline- + + - name: Collect timings and generate report + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + python3 scripts/ci/timings_report.py \ + --baseline ci-timings-baseline.json \ + --output ci-timings-report.html \ + --json-out ci-timings.json \ + --summary-out ci-timings-summary.md + + - name: Upload HTML report + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + id: ci-timings-artifact + with: + name: ci-timings-report + path: ci-timings-report.html + retention-days: 14 + archive: false + + - name: Output summary + env: + REPORT_URL: ${{ steps.ci-timings-artifact.outputs.artifact-url}} + run: | + echo "# CI Timing report" >> "$GITHUB_STEP_SUMMARY" + echo "[View the full interactive report]($REPORT_URL)" >> "$GITHUB_STEP_SUMMARY" + cat ci-timings-summary.md >> "$GITHUB_STEP_SUMMARY" + + - name: Save baseline cache (main only) + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + run: cp ci-timings.json ci-timings-baseline.json + + - name: Upload baseline to cache (main only) + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + with: + path: ci-timings-baseline.json + key: ci-timings-baseline-${{ github.run_id }} diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 13b86722b893..8030b889e246 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -7,15 +7,11 @@ on: permissions: contents: read - # Needed so the arm64 job can push/pull its registry-backed build cache - # to ghcr.io (cache-to/cache-from type=registry). See the build-arm64 - # job for why registry cache replaced the gha cache on that arch. - packages: write # Concurrency: push/release runs are NEVER cancelled so every merge gets # its own image. PR runs reuse a PR-scoped group with -# cancel-in-progress: true so rapid pushes to the same PR collapse to the -# latest commit. +# cancel-in-progress: true so rapid pushes to the same PR collapse to +# the latest commit. concurrency: group: docker-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} @@ -24,79 +20,47 @@ env: IMAGE_NAME: nousresearch/hermes-agent jobs: - # Build, test, and optionally push the amd64 image. - build-amd64: - # Only run on the upstream repository, not on forks + # Build, test, and optionally push the image for each architecture. + build: if: github.repository == 'NousResearch/hermes-agent' - runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - arch: amd64 + runner: ubuntu-latest + platform: linux/amd64 + cache-from: type=gha,scope=docker-amd64 + cache-to: type=gha,mode=max,scope=docker-amd64 + - arch: arm64 + runner: ubuntu-24.04-arm + platform: linux/arm64 + cache-from: type=gha,scope=docker-arm64 + cache-to: type=gha,mode=max,scope=docker-arm64 + + runs-on: ${{ matrix.runner }} timeout-minutes: 45 - outputs: - digest: ${{ steps.push.outputs.digest }} steps: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - # The image build + integration tests run on every event - # (PRs, push-to-main, release). Publish steps below are gated to - # push-to-main / release only. - name: Set up Docker Buildx uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 # Build once, load into the local daemon for testing. Cached - # to gha with a per-arch scope; the push step below reuses every - # layer from this build. - - name: Build image (amd64) + # per-arch; the push step below reuses every layer from this build. + - name: Build image (${{ matrix.arch }}) uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 with: context: . file: Dockerfile load: true - platforms: linux/amd64 + platforms: ${{ matrix.platform }} tags: ${{ env.IMAGE_NAME }}:test build-args: | HERMES_GIT_SHA=${{ github.sha }} - cache-from: type=gha,scope=docker-amd64 - cache-to: type=gha,mode=max,scope=docker-amd64 - - # Run the docker-integration test suite against the freshly-built - # image already loaded into the local daemon (`:test`). - # - # Piggybacking here avoids a second image build: the build step - # already loaded the image into the daemon under - # `${IMAGE_NAME}:test`, so we just point ``HERMES_TEST_IMAGE`` at - # that. The fixture's ``HERMES_TEST_IMAGE`` branch (see - # tests/docker/conftest.py:62-63) short-circuits the rebuild. - # - # Why this job and not a standalone one: the image is 5GB+; passing - # it between jobs via ``docker save``/``upload-artifact`` is slower - # than the build itself. Reusing the existing daemon state is the - # cheapest path to coverage on every PR that touches docker code. - # --------------------------------------------------------------------- - - name: Install uv (for docker tests) - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # 8.2.0 - - - name: Set up Python 3.11 (for docker tests) - run: uv python install 3.11 - - - name: Install Python dependencies (for docker tests) - run: | - # ``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. - uv sync --locked --python 3.11 --extra dev - - - name: Run docker integration tests - env: - # Skip rebuild; use the image already loaded by the build step. - HERMES_TEST_IMAGE: ${{ env.IMAGE_NAME }}:test - # Match the policy in tests.yml :: test job — no accidental - # real-API calls from inside the harness. - OPENROUTER_API_KEY: "" - OPENAI_API_KEY: "" - NOUS_API_KEY: "" - run: | - scripts/run_tests.sh tests/docker/ --file-timeout 600 + cache-from: ${{ matrix.cache-from }} + cache-to: ${{ (github.event_name != 'pull_request') && matrix.cache-to || '' }} - name: Log in to Docker Hub if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release' @@ -105,24 +69,24 @@ jobs: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - # Push amd64 by digest only (no tag). The merge job assembles the + # Push by digest only (no tag). The merge job assembles the # tagged manifest list. `push-by-digest=true` is docker's recommended # pattern for multi-runner multi-platform builds. - - name: Push amd64 by digest + - name: Push ${{ matrix.arch }} by digest id: push if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release' uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 with: context: . file: Dockerfile - platforms: linux/amd64 + platforms: ${{ matrix.platform }} labels: | org.opencontainers.image.revision=${{ github.sha }} build-args: | HERMES_GIT_SHA=${{ github.sha }} outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true - cache-from: type=gha,scope=docker-amd64 - cache-to: type=gha,mode=max,scope=docker-amd64 + cache-from: ${{ matrix.cache-from }} + cache-to: ${{ matrix.cache-to }} # Write the digest to a file and upload it as an artifact so the # merge job can stitch both per-arch digests into a manifest list. @@ -137,121 +101,51 @@ jobs: if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release' uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - name: digest-amd64 + name: digest-${{ matrix.arch }} path: /tmp/digests/* if-no-files-found: error retention-days: 1 - # --------------------------------------------------------------------------- - # Build, test, and optionally push the arm64 image. - # --------------------------------------------------------------------------- - build-arm64: - if: github.repository == 'NousResearch/hermes-agent' - runs-on: ubuntu-24.04-arm - timeout-minutes: 45 - outputs: - digest: ${{ steps.push.outputs.digest }} - steps: - - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 - - # Log in to ghcr.io so the registry-backed build cache below can be - # read (cache-from) on every event and written (cache-to) on - # push/release. Uses the workflow's GITHUB_TOKEN, which is valid for - # the whole job — unlike the gha cache backend's short-lived Azure SAS - # token, which expired mid-build on slow cold-cache arm64 runs and - # crashed the build before the tests ran (the reason the gha cache - # was removed from arm64 PRs in the first place). - - name: Log in to ghcr.io (build cache) - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - # Build once, load into the local daemon for testing, then push - # by digest below. Reads AND writes the registry-backed cache so the - # push reuses layers from this build and the next build starts warm. + # Run the docker-integration test suite against the freshly-built + # image already loaded into the local daemon (`:test`). # - # Registry cache (type=registry on ghcr.io) is used instead of the gha - # cache that previously broke here: its credential is the job-lifetime - # GITHUB_TOKEN, not a short-lived SAS token, so the cold-build-outlives- - # token failure mode cannot recur. - - name: Build image (arm64, cached publish) - uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 - with: - context: . - file: Dockerfile - load: true - platforms: linux/arm64 - tags: ${{ env.IMAGE_NAME }}:test - build-args: | - HERMES_GIT_SHA=${{ github.sha }} - cache-from: type=registry,ref=ghcr.io/nousresearch/hermes-agent:buildcache-arm64 - cache-to: type=registry,ref=ghcr.io/nousresearch/hermes-agent:buildcache-arm64,mode=max - - - name: Install uv for docker tests + # Piggybacking here avoids a second image build: the build step + # already loaded the image into the daemon under + # `${IMAGE_NAME}:test`, so we just point ``HERMES_TEST_IMAGE`` at + # that. The fixture's ``HERMES_TEST_IMAGE`` branch (see + # tests/docker/conftest.py:62-63) short-circuits the rebuild. + # + # Why this job and not a standalone one: the image is 5GB+; passing + # it between jobs via ``docker save``/``upload-artifact`` is slower + # than the build itself. Reusing the existing daemon state is the + # cheapest path to coverage on every PR that touches docker code. + # --------------------------------------------------------------------- + - name: Install uv (for docker tests) uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # 8.2.0 - - name: Set up Python 3.11 for docker tests + - name: Set up Python 3.11 (for docker tests) run: uv python install 3.11 - - name: Install Python dependencies for docker tests + - name: Install Python dependencies (for docker tests) run: | + # ``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. uv sync --locked --python 3.11 --extra dev - - name: Run docker tests + - name: Run docker integration tests env: # Skip rebuild; use the image already loaded by the build step. HERMES_TEST_IMAGE: ${{ env.IMAGE_NAME }}:test + # Match the policy in tests.yml :: test job — no accidental + # real-API calls from inside the harness. OPENROUTER_API_KEY: "" OPENAI_API_KEY: "" NOUS_API_KEY: "" run: | scripts/run_tests.sh tests/docker/ --file-timeout 600 - - name: Log in to Docker Hub - if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release' - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Push arm64 by digest - id: push - if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release' - uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 - with: - context: . - file: Dockerfile - platforms: linux/arm64 - labels: | - org.opencontainers.image.revision=${{ github.sha }} - build-args: | - HERMES_GIT_SHA=${{ github.sha }} - outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true - cache-from: type=registry,ref=ghcr.io/nousresearch/hermes-agent:buildcache-arm64 - cache-to: type=registry,ref=ghcr.io/nousresearch/hermes-agent:buildcache-arm64,mode=max - - - name: Export digest - if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release' - run: | - mkdir -p /tmp/digests - digest="${{ steps.push.outputs.digest }}" - touch "/tmp/digests/${digest#sha256:}" - - - name: Upload digest artifact - if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release' - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: digest-arm64 - path: /tmp/digests/* - if-no-files-found: error - retention-days: 1 - # --------------------------------------------------------------------------- # Stitch both per-arch digests into a single tagged multi-arch manifest. # This is a registry-side operation — no building, no layer re-push — @@ -263,7 +157,7 @@ jobs: merge: if: github.repository == 'NousResearch/hermes-agent' && (github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release') runs-on: ubuntu-latest - needs: [build-amd64, build-arm64] + needs: [build] timeout-minutes: 10 steps: - name: Download digests diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 511119ca615f..fcee2c1b8e86 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -109,46 +109,6 @@ jobs: --output .lint-reports/summary.md cat .lint-reports/summary.md >> "$GITHUB_STEP_SUMMARY" - - name: Upload reports as artifact - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: lint-reports - path: .lint-reports/ - retention-days: 14 - - - name: Post / update PR comment - if: inputs.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository - continue-on-error: true - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7 - with: - script: | - const fs = require('fs'); - const body = fs.readFileSync('.lint-reports/summary.md', 'utf8'); - const marker = ''; - const fullBody = marker + '\n' + body; - - const { data: comments } = await github.rest.issues.listComments({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - }); - const existing = comments.find(c => c.body && c.body.includes(marker)); - if (existing) { - await github.rest.issues.updateComment({ - owner: context.repo.owner, - repo: context.repo.repo, - comment_id: existing.id, - body: fullBody, - }); - } else { - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - body: fullBody, - }); - } - ruff-blocking: # Enforce the rules in pyproject.toml [tool.ruff.lint.select]. Currently # PLW1514 (unspecified-encoding) — catches bare ``open()`` / diff --git a/AGENTS.md b/AGENTS.md index d8306d9bdb8a..e89c819844e6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1289,65 +1289,22 @@ scripts/run_tests.sh # full suite, CI-parity scripts/run_tests.sh tests/gateway/ # one directory scripts/run_tests.sh tests/agent/test_foo.py::test_x # one test scripts/run_tests.sh -v --tb=long # pass-through pytest flags -scripts/run_tests.sh --no-isolate tests/foo/ # disable subprocess isolation (faster, for debugging) ``` -### Subprocess-per-test isolation +### Subprocess-per-test-file isolation -Every test runs in a freshly-spawned Python subprocess via the in-tree plugin -at `tests/_isolate_plugin.py`. This means module-level dicts/sets and -ContextVars from one test cannot leak into the next — the historic -`_reset_module_state` autouse fixture is gone. +Every test file runs in a freshly-spawned Python subprocess via `run_tests_parallel.py`. This means module-level dicts/sets and +ContextVars from one test file cannot leak into the next. -Implementation notes: +### Why the wrapper -- The plugin uses `multiprocessing.get_context("spawn")`, which works on - Linux, macOS, and Windows alike (POSIX `fork` is not used). -- Per-test overhead is ~0.5–1.0s (Python startup + pytest collection). xdist - parallelism amortizes this across cores; on a 20-core box the full suite - finishes in roughly the same wall time as before, but flake-free. -- `isolate_timeout` (configured in `pyproject.toml`) caps each test at 30s. - Hangs are killed and surfaced as a failure report. -- Pass `--no-isolate` to disable isolation — useful when debugging a single - test interactively, or when you specifically want to verify state leakage. -- The plugin disables itself in child processes (sentinel envvar - `HERMES_ISOLATE_CHILD=1`), so there's no fork-bomb risk. +| | Without wrapper | With wrapper | +| ------------------- | ------------------------------------------- | ----------------------------------------- | +| Provider API keys | Whatever is in your env (auto-detects pool) | All env vars except a specific few unset. | +| HOME / `~/.hermes/` | Your real config+auth.json | Temp dir per test | +| Timezone | Local TZ (PDT etc.) | UTC | +| Locale | Whatever is set | C.UTF-8 | -### Why the wrapper (and why the old "just call pytest" doesn't work) - -Five real sources of local-vs-CI drift the script closes: - -| | Without wrapper | With wrapper | -|---|---|---| -| Provider API keys | Whatever is in your env (auto-detects pool) | All `*_API_KEY`/`*_TOKEN`/etc. unset | -| HOME / `~/.hermes/` | Your real config+auth.json | Temp dir per test | -| Timezone | Local TZ (PDT etc.) | UTC | -| Locale | Whatever is set | C.UTF-8 | -| xdist workers | `-n auto` = all cores | `-n auto` (safe — subprocess isolation prevents cross-worker flakes) | - -`tests/conftest.py` also enforces points 1-4 as an autouse fixture so ANY pytest -invocation (including IDE integrations) gets hermetic behavior — but the wrapper -is belt-and-suspenders. - -### Running without the wrapper (only if you must) - -If you can't use the wrapper (e.g. inside an IDE that shells pytest directly), -at minimum activate the venv. The isolation plugin loads automatically from -`addopts` in `pyproject.toml`, so you get the same per-test process isolation -either way. - -```bash -source .venv/bin/activate # or: source venv/bin/activate -python -m pytest tests/ -q -``` - -If you need to bypass isolation for fast feedback while debugging: - -```bash -python -m pytest tests/agent/test_foo.py -q --no-isolate -``` - -Always run the full suite before pushing changes. ### Don't write change-detector tests diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7f56b971d1e6..bad33481c745 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -149,13 +149,20 @@ this way, make sure you run the `hermes` entrypoint from this venv; running the system `python3 -m hermes_cli.main` can pick up unrelated system Python packages. +Create the venv **outside** the cloned source tree. A venv that lives inside +the directory the agent operates from can be wiped by a relative-path command +the agent runs against its own checkout (`rm -rf venv`, `uv venv venv`, etc.), +which silently destroys the running runtime mid-session. Keeping it outside the +tree means no relative path from the workspace resolves to it. + ```bash git clone https://github.com/NousResearch/hermes-agent.git cd hermes-agent -# Create venv with Python 3.11 -uv venv venv --python 3.11 -export VIRTUAL_ENV="$(pwd)/venv" +# Create venv with Python 3.11, OUTSIDE the source tree +uv venv ~/.hermes/venvs/hermes-dev --python 3.11 +export VIRTUAL_ENV="$HOME/.hermes/venvs/hermes-dev" +export PATH="$VIRTUAL_ENV/bin:$PATH" # Install with all extras (messaging, cron, CLI menus, dev tools) uv pip install -e ".[all,dev]" diff --git a/README.md b/README.md index 4caad13ce20e..ba1322a38920 100644 --- a/README.md +++ b/README.md @@ -232,10 +232,14 @@ scripts/run_tests.sh Manual clone fallback (for throwaway clones/CI where you intentionally do not want the managed install layout): +Create the venv outside the cloned source tree — a venv inside the directory +the agent operates from can be wiped by a relative-path command the agent runs +against its own checkout, destroying the running runtime mid-session. + ```bash curl -LsSf https://astral.sh/uv/install.sh | sh -uv venv .venv --python 3.11 -source .venv/bin/activate +uv venv ~/.hermes/venvs/hermes-dev --python 3.11 +source ~/.hermes/venvs/hermes-dev/bin/activate uv pip install -e ".[all,dev]" scripts/run_tests.sh ``` diff --git a/acp_adapter/server.py b/acp_adapter/server.py index a51db91d4e82..df773297346a 100644 --- a/acp_adapter/server.py +++ b/acp_adapter/server.py @@ -74,6 +74,10 @@ from acp_adapter.provenance import session_provenance_meta from acp_adapter.session import SessionManager, SessionState, _expand_acp_enabled_toolsets from acp_adapter.tools import build_tool_complete, build_tool_start +from tools.approval import ( + reset_hermes_interactive_context, + set_hermes_interactive_context, +) logger = logging.getLogger(__name__) @@ -1446,20 +1450,23 @@ def stream_delta_cb(text: str) -> None: # Approval callback is per-thread (thread-local, GHSA-qg5c-hvr5-hjgr). # Set it INSIDE _run_agent so the TLS write happens in the executor # thread — setting it here would write to the event-loop thread's TLS, - # not the executor's. Also set HERMES_INTERACTIVE so approval.py - # takes the CLI-interactive path (which calls the registered - # callback via prompt_dangerous_approval) instead of the - # non-interactive auto-approve branch (GHSA-96vc-wcxf-jjff). + # not the executor's. Interactive routing uses a contextvar in + # tools.approval (set_hermes_interactive_context) rather than + # os.environ["HERMES_INTERACTIVE"], so concurrent executor workers can't + # race on a process-global flag — one session's restore can't drop + # another onto the non-interactive auto-approve path mid-run + # (GHSA-96vc-wcxf-jjff). The contextvar write is isolated by the + # contextvars.copy_context() wrapper around the executor call below. # ACP's conn.request_permission maps cleanly to the interactive # callback shape — not the gateway-queue HERMES_EXEC_ASK path, # which requires a notify_cb registered in _gateway_notify_cbs. previous_approval_cb = None - previous_interactive = None + interactive_token = None edit_approval_token = None previous_session_id = None def _run_agent() -> dict: - nonlocal previous_approval_cb, previous_interactive, edit_approval_token, previous_session_id + nonlocal previous_approval_cb, interactive_token, edit_approval_token, previous_session_id # Bind HERMES_SESSION_KEY for this session so per-session caches # (e.g. the interactive sudo password cache in tools.terminal_tool) # scope to the ACP session rather than leaking across sessions @@ -1491,9 +1498,10 @@ def _run_agent() -> dict: except Exception: logger.debug("Could not set ACP edit approval requester", exc_info=True) # Signal to tools.approval that we have an interactive callback - # and the non-interactive auto-approve path must not fire. - previous_interactive = os.environ.get("HERMES_INTERACTIVE") - os.environ["HERMES_INTERACTIVE"] = "1" + # and the non-interactive auto-approve path must not fire. Uses a + # contextvar (not os.environ) so concurrent executor workers don't + # race on the flag (GHSA-96vc-wcxf-jjff). + interactive_token = set_hermes_interactive_context(True) # Propagate the originating ACP session id to tools that want to # tag side-effects with it (e.g. ``kanban_create`` stamps it on # the new task so clients can render a per-session board). Save @@ -1513,11 +1521,9 @@ def _run_agent() -> dict: logger.exception("Agent error in session %s", session_id) return {"final_response": f"Error: {e}", "messages": state.history} finally: - # Restore HERMES_INTERACTIVE. - if previous_interactive is None: - os.environ.pop("HERMES_INTERACTIVE", None) - else: - os.environ["HERMES_INTERACTIVE"] = previous_interactive + # Restore the interactive contextvar for this context. + if interactive_token is not None: + reset_hermes_interactive_context(interactive_token) # Restore HERMES_SESSION_ID symmetrically. if previous_session_id is None: os.environ.pop("HERMES_SESSION_ID", None) diff --git a/agent/agent_init.py b/agent/agent_init.py index 41f7cc11bbb1..dcfb1082d4c5 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -828,7 +828,7 @@ def _moa_reference_relay(event: str, **kwargs: Any) -> None: client_kwargs["default_headers"] = build_nvidia_nim_headers(effective_base) elif base_url_host_matches(effective_base, "api.routermint.com"): client_kwargs["default_headers"] = _ra()._routermint_headers() - elif base_url_host_matches(effective_base, "api.githubcopilot.com"): + elif base_url_host_matches(effective_base, "githubcopilot.com"): from hermes_cli.models import copilot_default_headers client_kwargs["default_headers"] = copilot_default_headers() @@ -1665,6 +1665,12 @@ def _moa_reference_relay(event: str, **kwargs: Any) -> None: abort_on_summary_failure=compression_abort_on_summary_failure, max_tokens=agent.max_tokens, ) + _bind_session_state = getattr(agent.context_compressor, "bind_session_state", None) + if callable(_bind_session_state): + try: + _bind_session_state(session_db=session_db, session_id=agent.session_id) + except Exception: + pass agent.compression_enabled = compression_enabled agent.compression_in_place = compression_in_place diff --git a/agent/agent_runtime_helpers.py b/agent/agent_runtime_helpers.py index 21a14c977089..af64541a8285 100644 --- a/agent/agent_runtime_helpers.py +++ b/agent/agent_runtime_helpers.py @@ -368,6 +368,18 @@ def repair_message_sequence(agent, messages: List[Dict]) -> int: host code) can feed in already-broken histories. Repairs applied: + 0. Consecutive ``assistant`` messages with no intervening + ``tool``/``user`` turn — merged into a single assistant turn + (union of ``tool_calls``, concatenated ``content``). Strict + OpenAI-compatible providers (DeepSeek v4, Moonshot/Kimi) reject + a history where an ``assistant`` message carrying ``tool_calls`` + is immediately followed by another ``assistant`` message instead + of its ``tool`` results — HTTP 400 "An assistant message with + 'tool_calls' must be followed by tool messages…". The split + shape is produced by recovery/continuation paths that append an + interim assistant turn (thinking-prefill, codex + incomplete-continuation) or by host-fed / legacy-persisted / + resumed histories. Refs #29148, #49147. 1. Stray ``tool`` messages whose ``tool_call_id`` doesn't match any preceding assistant tool_call — dropped. 2. Consecutive ``user`` messages — merged with newline separator @@ -387,12 +399,74 @@ def repair_message_sequence(agent, messages: List[Dict]) -> int: repairs = 0 + # Pass 0: merge consecutive assistant messages. Runs BEFORE Pass 1 so + # the merged turn's union of tool_call ids is known when Pass 1 + # validates which tool-result messages are orphans. Two assistant + # messages are only adjacent here when nothing (no tool result, no + # user turn) separates them — an intervening ``tool`` message means + # two distinct, valid tool-call rounds that must NOT be merged. + # + # Codex Responses interim turns are exempt: the codex_responses + # api_mode legitimately keeps multiple consecutive incomplete + # assistant turns in history, each carrying its own encrypted + # continuation state (codex_reasoning_items / codex_message_items) + # that must be replayed verbatim. Collapsing them corrupts the + # Responses replay chain (the duplicate-detection logic at + # conversation_loop.py already de-dups identical codex interims). + def _is_codex_interim(m: Dict) -> bool: + return bool( + m.get("codex_reasoning_items") + or m.get("codex_message_items") + or m.get("finish_reason") == "incomplete" + ) + + collapsed: List[Dict] = [] + for msg in messages: + if ( + collapsed + and isinstance(msg, dict) + and msg.get("role") == "assistant" + and isinstance(collapsed[-1], dict) + and collapsed[-1].get("role") == "assistant" + and not _is_codex_interim(msg) + and not _is_codex_interim(collapsed[-1]) + ): + prev = collapsed[-1] + # Union tool_calls (preserve order, both may carry them). + prev_calls = list(prev.get("tool_calls") or []) + new_calls = list(msg.get("tool_calls") or []) + if new_calls: + prev["tool_calls"] = prev_calls + new_calls + elif prev_calls: + prev["tool_calls"] = prev_calls + # Concatenate plain-text content; leave multimodal (list) + # content on either side alone to avoid mangling attachment + # blocks — fall back to keeping the existing content. + prev_content = prev.get("content") + new_content = msg.get("content") + if isinstance(prev_content, str) and isinstance(new_content, str): + joined = "\n".join( + p for p in (prev_content.strip(), new_content.strip()) if p + ) + prev["content"] = joined + elif not prev_content and new_content is not None: + prev["content"] = new_content + # Carry reasoning_content from the later turn only if the + # earlier turn lacks it (strict thinking providers require a + # reasoning_content on the merged tool-call turn; the first + # non-empty one suffices). + if not prev.get("reasoning_content") and msg.get("reasoning_content"): + prev["reasoning_content"] = msg["reasoning_content"] + repairs += 1 + continue + collapsed.append(msg) + # Pass 1: drop stray tool messages that don't follow a known # assistant tool_call_id. Uses a rolling set of known ids refreshed # on each assistant message. known_tool_ids: set = set() filtered: List[Dict] = [] - for msg in messages: + for msg in collapsed: if not isinstance(msg, dict): filtered.append(msg) continue @@ -663,6 +737,25 @@ def recover_with_credential_pool( elif status_code in {401, 403}: effective_reason = FailoverReason.auth + if effective_reason == FailoverReason.upstream_rate_limit: + # An upstream provider (e.g. DeepSeek behind OpenRouter) is + # rate-limiting the aggregator's traffic — the user's credential is + # healthy. Do NOT rotate or mark exhausted; let the caller's fallback + # path switch to a different model entirely. + upstream = (error_context or {}).get("upstream_provider") if error_context else None + if upstream: + _ra().logger.info( + "Upstream provider %s rate-limited via aggregator — skipping " + "credential rotation, deferring to fallback chain", + upstream, + ) + else: + _ra().logger.info( + "Upstream aggregator 429 (provider unknown) — skipping " + "credential rotation, deferring to fallback chain" + ) + return False, has_retried_429 + if effective_reason == FailoverReason.billing: rotate_status = status_code if status_code is not None else 402 next_entry = pool.mark_exhausted_and_rotate(status_code=rotate_status, error_context=error_context) @@ -1281,7 +1374,11 @@ def dump_api_request_debug( dump_payload["error"] = error_info timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f") - dump_file = agent.logs_dir / f"request_dump_{agent.session_id}_{timestamp}.json" + # Sanitize the session ID into a traversal-free path segment — it can + # originate from untrusted input (X-Hermes-Session-Id header), and an + # unsanitized "../"-shaped ID would write the dump outside logs_dir. + safe_sid = _ra()._safe_session_filename_component(agent.session_id) + dump_file = agent.logs_dir / f"request_dump_{safe_sid}_{timestamp}.json" # Redact secrets before persisting/printing. This dump captures the # full request body (system prompt, tool defs, context-embedded @@ -1621,6 +1718,18 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo if (new_provider or "").strip().lower() == "moa": from agent.moa_loop import MoAClient + # The MoA virtual provider speaks only chat.completions via the + # MoAClient facade — the aggregator's real transport + # (codex_responses / anthropic_messages) is resolved and applied + # *inside* the reference/aggregator fan-out, never on the outer + # primary call. determine_api_mode("moa", ...) above may have left + # api_mode set to the aggregator's transport; if the conversation + # loop sees that, it dispatches client.responses.create (which the + # facade has no .responses for) and the call falls through to the + # moa://local placeholder → HTTP 404 → fallback to a reference + # model. Pin chat_completions here so the primary call always goes + # through MoAClient.chat.completions, matching agent_init.py. + agent.api_mode = "chat_completions" agent.api_key = api_key or "moa-virtual-provider" agent.base_url = "moa://local" agent._client_kwargs = {} @@ -2159,7 +2268,7 @@ def sanitize_api_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any] result_call_ids: set = set() for msg in messages: if msg.get("role") == "tool": - cid = msg.get("tool_call_id") + cid = (msg.get("tool_call_id") or "").strip() if cid: result_call_ids.add(cid) @@ -2168,7 +2277,7 @@ def sanitize_api_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any] if orphaned_results: messages = [ m for m in messages - if not (m.get("role") == "tool" and m.get("tool_call_id") in orphaned_results) + if not (m.get("role") == "tool" and (m.get("tool_call_id") or "").strip() in orphaned_results) ] _ra().logger.debug( "Pre-call sanitizer: removed %d orphaned tool result(s)", @@ -2202,7 +2311,7 @@ def sanitize_api_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any] def looks_like_codex_intermediate_ack( agent, - user_message: str, + user_message: Any, assistant_content: str, messages: List[Dict[str, Any]], require_workspace: bool = True, @@ -2282,7 +2391,14 @@ def looks_like_codex_intermediate_ack( if not require_workspace: return True - user_text = (user_message or "").strip().lower() + # ``user_message`` is typed ``str`` but can arrive as an OpenAI-style + # multi-part content list (``[{type:"text",...}, {type:"image_url",...}]``) + # for vision requests routed through the OpenAI-compat API server. A + # truthy list survives ``(user_message or "")`` and then ``.strip()`` + # raises ``AttributeError`` — flatten to text first. + from agent.codex_responses_adapter import _summarize_user_message_for_log + + user_text = _summarize_user_message_for_log(user_message).strip().lower() user_targets_workspace = ( any(marker in user_text for marker in workspace_markers) or "~/" in user_text diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index dfeec87e12d3..c24cc972a2e5 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -124,6 +124,15 @@ def _openai_http_client_kwargs( def _create_openai_client(*, api_key: str, base_url: str, **kwargs: Any) -> Any: kwargs = {**_openai_http_client_kwargs(base_url), **kwargs} + # Hermes owns auxiliary retry + provider/model fallback policy (the + # same-provider transient retry in call_llm plus the except-chain + # fallback). The OpenAI SDK's own default (max_retries=2 → up to 3 + # attempts) silently multiplies the effective wall time of every aux call + # by 3× on a slow/hung endpoint, so a 120s timeout can stall ~360s before + # Hermes sees a single failure (issue #54465). Disable SDK-internal retries + # by default and let Hermes control the budget; explicit callers can still + # override via kwargs. + kwargs.setdefault("max_retries", 0) return OpenAI(api_key=api_key, base_url=base_url, **kwargs) @@ -1615,7 +1624,7 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]: extra = {} if base_url_host_matches(base_url, "api.kimi.com"): extra["default_headers"] = {"User-Agent": "claude-code/0.1.0"} - elif base_url_host_matches(base_url, "api.githubcopilot.com"): + elif base_url_host_matches(base_url, "githubcopilot.com"): from hermes_cli.models import copilot_default_headers extra["default_headers"] = copilot_default_headers() @@ -1655,7 +1664,7 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]: extra = {} if base_url_host_matches(base_url, "api.kimi.com"): extra["default_headers"] = {"User-Agent": "claude-code/0.1.0"} - elif base_url_host_matches(base_url, "api.githubcopilot.com"): + elif base_url_host_matches(base_url, "githubcopilot.com"): from hermes_cli.models import copilot_default_headers extra["default_headers"] = copilot_default_headers() @@ -2590,6 +2599,27 @@ def _is_rate_limit_error(exc: Exception) -> bool: return False +def _is_timeout_error(exc: Exception) -> bool: + """Detect a request timeout — the full-budget stall, distinct from a fast + connection drop. + + A timeout burns the entire configured ``timeout`` before surfacing, so a + same-provider retry on the critical compression path doubles the + user-visible wall time (issue #54465). A streaming-close / dropped + connection, by contrast, fails fast and is cheap to retry — those stay on + the retry path even for compression. + """ + try: + from openai import APITimeoutError + if isinstance(exc, APITimeoutError): + return True + except ImportError: + pass + if "Timeout" in type(exc).__name__: + return True + return "timed out" in str(exc).lower() + + def _is_connection_error(exc: Exception) -> bool: """Detect connection/network errors that warrant provider fallback. @@ -2924,7 +2954,7 @@ def _recoverable_pool_provider( return "nous" if base_url_host_matches(base, "api.anthropic.com"): return "anthropic" - if base_url_host_matches(base, "api.githubcopilot.com"): + if base_url_host_matches(base, "githubcopilot.com"): return "copilot" if base_url_host_matches(base, "api.kimi.com"): return "kimi-coding" @@ -3793,7 +3823,7 @@ def _to_async_client(sync_client, model: str, is_vision: bool = False): sync_base_url = str(sync_client.base_url) if base_url_host_matches(sync_base_url, "openrouter.ai"): async_kwargs["default_headers"] = build_or_headers() - elif base_url_host_matches(sync_base_url, "api.githubcopilot.com"): + elif base_url_host_matches(sync_base_url, "githubcopilot.com"): from hermes_cli.copilot_auth import copilot_request_headers async_kwargs["default_headers"] = copilot_request_headers( @@ -3824,6 +3854,9 @@ def _to_async_client(sync_client, model: str, is_vision: bool = False): **_openai_http_client_kwargs(sync_base_url, async_mode=True), **async_kwargs, } + # See _create_openai_client: disable SDK-internal retries so Hermes owns + # the auxiliary retry/timeout budget (issue #54465). + async_kwargs.setdefault("max_retries", 0) return AsyncOpenAI(**async_kwargs), model @@ -4095,7 +4128,7 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = "", extra["default_query"] = _dq if base_url_host_matches(custom_base, "api.kimi.com"): extra["default_headers"] = {"User-Agent": "claude-code/0.1.0"} - elif base_url_host_matches(custom_base, "api.githubcopilot.com"): + elif base_url_host_matches(custom_base, "githubcopilot.com"): from hermes_cli.copilot_auth import copilot_request_headers extra["default_headers"] = copilot_request_headers( is_agent_turn=True, is_vision=is_vision @@ -4348,7 +4381,7 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = "", headers = {} if base_url_host_matches(base_url, "api.kimi.com"): headers["User-Agent"] = "claude-code/0.1.0" - elif base_url_host_matches(base_url, "api.githubcopilot.com"): + elif base_url_host_matches(base_url, "githubcopilot.com"): from hermes_cli.copilot_auth import copilot_request_headers headers.update(copilot_request_headers( @@ -4821,9 +4854,14 @@ def auxiliary_max_tokens_param(value: int, *, model: Optional[str] = None) -> di or_key = os.getenv("OPENROUTER_API_KEY") # Use max_completion_tokens for direct OpenAI-compatible providers that reject # max_tokens on newer GPT-4o/o-series/GPT-5-style models. + _custom_host = base_url_hostname(custom_base) or "" if (not or_key and _read_nous_auth() is None - and base_url_hostname(custom_base) in {"api.openai.com", "api.githubcopilot.com"}): + and ( + _custom_host == "api.openai.com" + or _custom_host == "api.githubcopilot.com" + or _custom_host.endswith(".githubcopilot.com") + )): return {"max_completion_tokens": value} # ...and for any caller serving a newer OpenAI-family model by name. if model_forces_max_completion_tokens(model): @@ -5200,9 +5238,10 @@ def _resolve_task_provider_model( 3. "auto" (full auto-detection chain) Returns (provider, model, base_url, api_key, api_mode) where model may - be None (use provider default). When base_url is set, provider is forced - to "custom" and the task uses that direct endpoint. api_mode is one of - "chat_completions", "codex_responses", or None (auto-detect). + be None (use provider default). A bare base_url is treated as custom, but + a first-class provider plus base_url keeps the provider identity so its + auth, transport, and request-shaping behavior still apply. api_mode is one + of "chat_completions", "codex_responses", or None (auto-detect). """ cfg_provider = None cfg_model = None @@ -5235,11 +5274,35 @@ def _expand_direct_api_alias(prov: Optional[str], existing_base: Optional[str]) return prov, existing_base return "custom", existing_base or target_base + def _preserve_provider_with_base_url(prov: Optional[str]) -> bool: + normalized = str(prov or "").strip().lower() + if normalized in {"", "auto", "custom"} or normalized.startswith("custom:"): + return False + try: + from hermes_cli.providers import get_provider + + return get_provider(normalized) is not None + except Exception: + # Keep the high-risk provider-backed routes safe even if provider + # catalog loading is unavailable during early import/test paths. + return normalized in { + "anthropic", + "copilot", + "copilot-acp", + "minimax-oauth", + "nous", + "openai-codex", + "qwen-oauth", + "xai-oauth", + } + if provider: provider, base_url = _expand_direct_api_alias(provider, base_url) if cfg_provider: cfg_provider, cfg_base_url = _expand_direct_api_alias(cfg_provider, cfg_base_url) + if base_url and _preserve_provider_with_base_url(provider): + return provider, resolved_model, base_url, api_key, resolved_api_mode if base_url: return "custom", resolved_model, base_url, api_key, resolved_api_mode if provider: @@ -5489,10 +5552,24 @@ def _build_call_kwargs( # ``/anthropic`` endpoint reached through the OpenAI SDK wrapper), where # max_tokens is a MANDATORY field — omitting it is a hard 400. Keep it only # there. + # + # NVIDIA NIM (integrate.api.nvidia.com and local NIM endpoints) is a + # second exception: some models—notably minimaxai/minimax-m3—return HTTP + # 200 with an empty choices[] payload when max_tokens is omitted. The main + # NVIDIA chat path already sends an output cap via the provider profile; + # preserve it on the auxiliary path too. _effective_base = base_url or ( _current_custom_base_url() if provider == "custom" else "" ) - if _is_anthropic_compat_endpoint(provider, _effective_base): + _provider_norm = str(provider or "").strip().lower() + _is_nvidia_nim = ( + _provider_norm in {"nvidia", "nvidia-nim", "nim", "build-nvidia", "nemotron"} + or base_url_host_matches(_effective_base, "integrate.api.nvidia.com") + ) + if ( + _is_anthropic_compat_endpoint(provider, _effective_base) + or _is_nvidia_nim + ): kwargs["max_tokens"] = max_tokens if tools: @@ -5633,6 +5710,9 @@ def call_llm( tools: list = None, timeout: float = None, extra_body: dict = None, + api_mode: str = None, + stream: bool = False, + stream_options: dict = None, ) -> Any: """Centralized synchronous LLM call. @@ -5645,21 +5725,32 @@ def call_llm( Reads provider:model from config/env. Ignored if provider is set. provider: Explicit provider override. model: Explicit model override. + api_mode: Explicit API mode override (e.g. "codex_responses", + "anthropic_messages"). Takes precedence over task config. messages: Chat messages list. temperature: Sampling temperature (None = provider default). max_tokens: Max output tokens (handles max_tokens vs max_completion_tokens). tools: Tool definitions (for function calling). timeout: Request timeout in seconds (None = read from auxiliary.{task}.timeout config). extra_body: Additional request body fields. + stream: When True, return the raw SDK streaming iterator instead of a + validated complete response. The caller is responsible for consuming + chunks (and for any fallback). Used by the MoA aggregator so its + output can stream to the user. + stream_options: Passed through to the request when stream is True + (e.g. {"include_usage": True}). Returns: - Response object with .choices[0].message.content + Response object with .choices[0].message.content, OR — when stream=True — + the raw streaming iterator from client.chat.completions.create(). Raises: RuntimeError: If no provider is configured. """ resolved_provider, resolved_model, resolved_base_url, resolved_api_key, resolved_api_mode = _resolve_task_provider_model( task, provider, model, base_url, api_key) + if api_mode: + resolved_api_mode = api_mode effective_extra_body = _get_task_extra_body(task) effective_extra_body.update(extra_body or {}) @@ -5753,6 +5844,20 @@ def call_llm( if _is_anthropic_compat_endpoint(resolved_provider, _client_base): kwargs["messages"] = _convert_openai_images_to_anthropic(kwargs["messages"]) + # Streaming path: return the raw SDK Stream iterator directly. This is used by + # the MoA aggregator so its tokens stream to the user. It deliberately skips + # _validate_llm_response and the temperature/max_tokens/payment fallback chain + # below — those all assume a complete response object, whereas a stream is + # consumed chunk-by-chunk by the caller. The caller (the agent's streaming + # consumer) owns chunk reassembly, stale-stream detection, and falling back to + # a non-streaming call on error. stream_options is best-effort: providers that + # reject it surface an error the caller's fallback already handles. + if stream: + kwargs["stream"] = True + if stream_options: + kwargs["stream_options"] = stream_options + return client.chat.completions.create(**kwargs) + # Handle unsupported temperature, max_tokens vs max_completion_tokens retry, # then payment fallback. try: @@ -5771,6 +5876,21 @@ def call_llm( except Exception as transient_err: if not _is_transient_transport_error(transient_err): raise + # Compression is on the critical preflight path: a user cannot + # continue or resume an oversized session until it compacts. A + # same-provider retry on a timeout means another full ``timeout``- + # long wall-clock block before the except-chain below can fall + # back — doubling the user-visible stall (issue #54465). Skip the + # same-provider retry for compression on a full-budget timeout and + # fall straight through to provider/model fallback; fast blips (a + # streaming-close or a 5xx) still retry, since those are cheap. + if task == "compression" and _is_timeout_error(transient_err): + logger.info( + "Auxiliary compression: timeout on the critical path; " + "skipping same-provider retry and falling back: %s", + transient_err, + ) + raise logger.info( "Auxiliary %s: transient transport error; retrying once on " "the same provider before fallback: %s", @@ -6296,6 +6416,16 @@ async def async_call_llm( except Exception as transient_err: if not _is_transient_transport_error(transient_err): raise + # See call_llm(): compression is on the critical preflight path, + # so skip the same-provider retry on a full-budget timeout and + # fall straight through to fallback (issue #54465). + if task == "compression" and _is_timeout_error(transient_err): + logger.info( + "Auxiliary compression (async): timeout on the critical " + "path; skipping same-provider retry and falling back: %s", + transient_err, + ) + raise logger.info( "Auxiliary %s (async): transient transport error; retrying " "once on the same provider before fallback: %s", diff --git a/agent/chat_completion_helpers.py b/agent/chat_completion_helpers.py index 7a5e75347237..aada15f51ed5 100644 --- a/agent/chat_completion_helpers.py +++ b/agent/chat_completion_helpers.py @@ -632,7 +632,7 @@ def build_api_kwargs(agent, api_messages: list) -> dict: _ct = agent._get_transport() is_github_responses = ( base_url_host_matches(agent.base_url, "models.github.ai") - or base_url_host_matches(agent.base_url, "api.githubcopilot.com") + or base_url_host_matches(agent.base_url, "githubcopilot.com") ) is_codex_backend = ( agent.provider == "openai-codex" @@ -702,7 +702,7 @@ def build_api_kwargs(agent, api_messages: list) -> dict: _is_or = agent._is_openrouter_url() _is_gh = ( base_url_host_matches(agent._base_url_lower, "models.github.ai") - or base_url_host_matches(agent._base_url_lower, "api.githubcopilot.com") + or base_url_host_matches(agent._base_url_lower, "githubcopilot.com") ) _is_nous = "nousresearch" in agent._base_url_lower _is_nvidia = "integrate.api.nvidia.com" in agent._base_url_lower @@ -1124,7 +1124,7 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool auth resolution and client construction — no duplicated provider→key mappings. """ - if reason in {FailoverReason.rate_limit, FailoverReason.billing}: + if reason in {FailoverReason.rate_limit, FailoverReason.billing, FailoverReason.upstream_rate_limit}: # Only start cooldown when leaving the primary provider. If we're # already on a fallback and chain-switching, the primary wasn't the # source of the 429 so the cooldown should not be reset/extended. @@ -1142,7 +1142,7 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool # provider again. Guards the cross-turn replay storm in #24996. if ( len(agent._fallback_chain) > 0 - and reason not in {FailoverReason.rate_limit, FailoverReason.billing} + and reason not in {FailoverReason.rate_limit, FailoverReason.billing, FailoverReason.upstream_rate_limit} ): _existing_cooldown = getattr(agent, "_rate_limited_until", 0) or 0 agent._rate_limited_until = max( @@ -2086,7 +2086,7 @@ def _call_chat_completions(): entry["function"]["arguments"] += tc_delta.function.arguments extra = getattr(tc_delta, "extra_content", None) if extra is None and hasattr(tc_delta, "model_extra"): - extra = (tc_delta.model_extra or {}).get("extra_content") + extra = (tc_delta.model_extra if isinstance(tc_delta.model_extra, dict) else {}).get("extra_content") if extra is not None: if hasattr(extra, "model_dump"): extra = extra.model_dump() diff --git a/agent/coding_context.py b/agent/coding_context.py index 8fb51a0b04d6..00f6d996d478 100644 --- a/agent/coding_context.py +++ b/agent/coding_context.py @@ -353,6 +353,29 @@ def _coding_mode(config: Optional[dict[str, Any]]) -> str: return "auto" +def _coding_instructions(config: Optional[dict[str, Any]]) -> str: + """Standing operator instructions for the coding posture (config). + + ``agent.coding_instructions`` — a string or list of strings appended to the + coding brief as an extra stable system block, so a user can pin project-wide + coding-workflow rules (e.g. "for UI work don't run tsc/lint until I approve; + clean the diff before committing") without editing the shipped brief. + Cache-safe: resolved once per session into the stable system-prompt tier, + like the rest of the posture. + """ + if config is None: + try: + from hermes_cli.config import load_config + + config = load_config() + except Exception: + config = {} + raw = ((config or {}).get("agent", {}) or {}).get("coding_instructions", "") + if isinstance(raw, (list, tuple)): + return "\n".join(str(item).strip() for item in raw if str(item).strip()) + return str(raw or "").strip() + + def _resolve_cwd(cwd: Optional[str | Path]) -> Path: if cwd: return Path(cwd).expanduser() @@ -459,6 +482,9 @@ class RuntimeMode: # only to steer edit-format guidance toward the model's family — see # ``_edit_format_line``. Fixed for the session, so cache-safe. model: Optional[str] = None + # Standing operator instructions (``agent.coding_instructions``), appended + # as an extra stable system block. Empty unless the user configures it. + instructions: str = "" @property def kind(self) -> str: @@ -505,6 +531,10 @@ def system_blocks(self) -> list[str]: workspace = build_coding_workspace_block(self.cwd) if workspace: blocks.append(workspace) + # Operator instructions ride their own block so the brief (block 0) stays + # byte-stable and cache-keyed independently of user config. + if self.instructions: + blocks.append(f"Operator instructions (from config):\n{self.instructions}") return blocks def compact_skill_categories(self) -> frozenset[str]: @@ -557,6 +587,7 @@ def resolve_runtime_mode( cwd=resolved_cwd, config_mode=mode, model=model, + instructions=_coding_instructions(config), ) diff --git a/agent/context_breakdown.py b/agent/context_breakdown.py new file mode 100644 index 000000000000..0e2eb772f2ff --- /dev/null +++ b/agent/context_breakdown.py @@ -0,0 +1,156 @@ +"""Live session context-window breakdown for UI surfaces. + +Estimates how the next provider request is composed: system prompt tiers, +tool schemas, and conversation history. Uses the same rough char/4 heuristic +as ``agent.model_metadata.estimate_request_tokens_rough`` so numbers align +with compression thresholds — not exact tokenizer counts. +""" + +from __future__ import annotations + +import json +import re +from typing import Any, Dict, List, Optional, Sequence, Tuple + +_SKILLS_BLOCK_RE = re.compile(r".*?", re.DOTALL) + +_SUBAGENT_TOOL_NAMES = frozenset({"delegate_task"}) + +_CATEGORY_COLORS = { + "system_prompt": "var(--context-usage-system)", + "tool_definitions": "var(--context-usage-tools)", + "rules": "var(--context-usage-rules)", + "skills": "var(--context-usage-skills)", + "mcp": "var(--context-usage-mcp)", + "subagent_definitions": "var(--context-usage-subagents)", + "memory": "var(--context-usage-memory)", + "conversation": "var(--context-usage-conversation)", +} + + +def _chars_to_tokens(text: str) -> int: + if not text: + return 0 + return (len(text) + 3) // 4 + + +def _json_tokens(value: Any) -> int: + if not value: + return 0 + return _chars_to_tokens(json.dumps(value, ensure_ascii=False)) + + +def _tool_name(tool: dict) -> str: + fn = tool.get("function") if isinstance(tool, dict) else None + if isinstance(fn, dict): + return str(fn.get("name") or "") + return str(tool.get("name") or "") + + +def _split_tools(tools: Sequence[dict]) -> Tuple[List[dict], List[dict], List[dict]]: + builtin: List[dict] = [] + mcp: List[dict] = [] + subagent: List[dict] = [] + for tool in tools: + name = _tool_name(tool) + if name.startswith("mcp_"): + mcp.append(tool) + elif name in _SUBAGENT_TOOL_NAMES: + subagent.append(tool) + else: + builtin.append(tool) + return builtin, mcp, subagent + + +def _memory_blocks(agent: Any) -> Tuple[str, str]: + memory_block = "" + user_block = "" + store = getattr(agent, "_memory_store", None) + if store is None: + return memory_block, user_block + try: + if getattr(agent, "_memory_enabled", True): + memory_block = store.format_for_system_prompt("memory") or "" + if getattr(agent, "_user_profile_enabled", True): + user_block = store.format_for_system_prompt("user") or "" + except Exception: + pass + return memory_block, user_block + + +def _strip_blocks(text: str, *blocks: str) -> str: + out = text + for block in blocks: + if block: + out = out.replace(block, "") + return out.strip() + + +def compute_session_context_breakdown( + agent: Any, + messages: Optional[List[dict]] = None, +) -> Dict[str, Any]: + """Return a Cursor-style context usage breakdown for one live agent.""" + from agent.model_metadata import estimate_messages_tokens_rough + from agent.system_prompt import build_system_prompt_parts + + parts = build_system_prompt_parts(agent) + stable = parts.get("stable", "") or "" + context = parts.get("context", "") or "" + volatile = parts.get("volatile", "") or "" + + skills_match = _SKILLS_BLOCK_RE.search(stable) + skills_index = skills_match.group(0) if skills_match else "" + + memory_block, user_block = _memory_blocks(agent) + memory_text = "\n\n".join(part for part in (memory_block, user_block) if part).strip() + + system_core = _strip_blocks(stable, skills_index) + system_tail = _strip_blocks(volatile, memory_block, user_block) + system_prompt_text = "\n\n".join(part for part in (system_core, system_tail) if part).strip() + + tools = list(getattr(agent, "tools", None) or []) + builtin_tools, mcp_tools, subagent_tools = _split_tools(tools) + + conversation_tokens = estimate_messages_tokens_rough(messages or []) + + categories = [ + ("system_prompt", "System prompt", _chars_to_tokens(system_prompt_text)), + ("tool_definitions", "Tool definitions", _json_tokens(builtin_tools)), + ("rules", "Rules", _chars_to_tokens(context)), + ("skills", "Skills", _chars_to_tokens(skills_index)), + ("mcp", "MCP", _json_tokens(mcp_tools)), + ("subagent_definitions", "Subagent definitions", _json_tokens(subagent_tools)), + ("memory", "Memory", _chars_to_tokens(memory_text)), + ("conversation", "Conversation", conversation_tokens), + ] + + estimated_total = sum(tokens for _, _, tokens in categories) + + comp = getattr(agent, "context_compressor", None) + context_max = int(getattr(comp, "context_length", 0) or 0) if comp else 0 + measured_used = int(getattr(comp, "last_prompt_tokens", 0) or 0) if comp else 0 + context_used = measured_used if measured_used > 0 else estimated_total + context_percent = ( + max(0, min(100, round(context_used / context_max * 100))) + if context_max + else 0 + ) + + return { + "categories": [ + { + "color": _CATEGORY_COLORS.get(category_id, "var(--ui-text-tertiary)"), + "id": category_id, + "label": label, + "tokens": tokens, + } + for category_id, label, tokens in categories + if tokens > 0 + ], + "context_max": context_max, + "context_percent": context_percent, + "context_used": context_used, + "estimated_total": estimated_total, + "model": getattr(agent, "model", "") or "", + } diff --git a/agent/context_compressor.py b/agent/context_compressor.py index fbde99bda5f9..4bccda13808b 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -19,6 +19,7 @@ import hashlib import json import logging +import sqlite3 import re import time from typing import Any, Dict, List, Optional @@ -638,6 +639,7 @@ def on_session_reset(self) -> None: self._last_compression_savings_pct = 100.0 self._ineffective_compression_count = 0 self._summary_failure_cooldown_until = 0.0 # transient errors must not block a fresh session + self._last_summary_error = None self.last_real_prompt_tokens = 0 self.last_compression_rough_tokens = 0 self.last_rough_tokens_when_real_prompt_fit = 0 @@ -659,6 +661,104 @@ def on_session_end(self, session_id: str, messages: List[Dict[str, Any]]) -> Non """ self._previous_summary = None + def bind_session_state(self, session_db: Any = None, session_id: str = "") -> None: + """Bind the current session row so durable cooldowns can round-trip.""" + self._session_db = session_db + self._session_id = session_id or "" + self._summary_failure_cooldown_until = 0.0 + self._last_summary_error = None + self.get_active_compression_failure_cooldown() + + def on_session_start(self, session_id: str, **kwargs) -> None: + """Bind session-scoped compression state for a new or resumed session.""" + super().on_session_start(session_id, **kwargs) + self.bind_session_state(kwargs.get("session_db", getattr(self, "_session_db", None)), session_id) + + def get_active_compression_failure_cooldown(self) -> Optional[Dict[str, Any]]: + """Return the live compression-failure cooldown for the bound session.""" + now_mono = time.monotonic() + if self._summary_failure_cooldown_until > now_mono: + return { + "cooldown_until": time.time() + ( + self._summary_failure_cooldown_until - now_mono + ), + "remaining_seconds": self._summary_failure_cooldown_until - now_mono, + "error": self._last_summary_error, + } + + session_db = getattr(self, "_session_db", None) + session_id = getattr(self, "_session_id", "") + if not session_db or not session_id: + return None + + getter = getattr(session_db, "get_compression_failure_cooldown", None) + if getter is None: + return None + try: + state = getter(session_id) + except sqlite3.Error as exc: + logger.debug("compression failure cooldown lookup failed: %s", exc) + return None + except Exception: + return None + if not state: + return None + + remaining_seconds = float(state.get("remaining_seconds") or 0.0) + if remaining_seconds <= 0: + return None + + self._summary_failure_cooldown_until = now_mono + remaining_seconds + self._last_summary_error = state.get("error") + return { + "cooldown_until": float(state.get("cooldown_until") or 0.0), + "remaining_seconds": remaining_seconds, + "error": self._last_summary_error, + } + + def _record_compression_failure_cooldown( + self, + cooldown_seconds: float, + error: Optional[str], + ) -> None: + cooldown_until = time.time() + cooldown_seconds + self._summary_failure_cooldown_until = time.monotonic() + cooldown_seconds + self._last_summary_error = error + + session_db = getattr(self, "_session_db", None) + session_id = getattr(self, "_session_id", "") + if not session_db or not session_id: + return + + recorder = getattr(session_db, "record_compression_failure_cooldown", None) + if recorder is None: + return + try: + recorder(session_id, cooldown_until, error) + except sqlite3.Error as exc: + logger.debug("compression failure cooldown persist failed: %s", exc) + except Exception as exc: + logger.debug("compression failure cooldown persist failed (non-sqlite): %s", exc) + + def _clear_compression_failure_cooldown(self) -> None: + self._summary_failure_cooldown_until = 0.0 + self._last_summary_error = None + + session_db = getattr(self, "_session_db", None) + session_id = getattr(self, "_session_id", "") + if not session_db or not session_id: + return + + clearer = getattr(session_db, "clear_compression_failure_cooldown", None) + if clearer is None: + return + try: + clearer(session_id) + except sqlite3.Error as exc: + logger.debug("compression failure cooldown clear failed: %s", exc) + except Exception as exc: + logger.debug("compression failure cooldown clear failed (non-sqlite): %s", exc) + def update_model( self, model: str, @@ -863,6 +963,8 @@ def __init__( self.awaiting_real_usage_after_compression = False self.summary_model = summary_model_override or "" + self._session_db: Any = None + self._session_id: str = "" # Stores the previous compaction summary for iterative updates self._previous_summary: Optional[str] = None @@ -1448,7 +1550,7 @@ def _fallback_to_main_for_compression(self, e: Exception, reason: str) -> None: self._last_aux_model_failure_error = _err_text self._last_aux_model_failure_model = self.summary_model self.summary_model = "" # empty = use main model - self._summary_failure_cooldown_until = 0.0 # no cooldown — retry immediately + self._clear_compression_failure_cooldown() # no cooldown — retry immediately def _generate_summary( self, @@ -1666,7 +1768,15 @@ def _generate_summary( # retry (_generate_summary recursion) re-enters harmlessly. with aux_interrupt_protection(): response = call_llm(**call_kwargs) - content = response.choices[0].message.content + # ``_validate_llm_response`` only guarantees ``choices[0].message`` + # exists, not that it's an object with ``.content``. Some + # OpenAI-compatible proxies / local backends return a dict- or + # str-shaped message; coerce defensively instead of crashing. + message = response.choices[0].message + if isinstance(message, dict): + content = message.get("content") + else: + content = getattr(message, "content", message) # Handle cases where content is not a string (e.g., dict from llama.cpp) if not isinstance(content, str): content = str(content) if content else "" @@ -1691,7 +1801,7 @@ def _generate_summary( summary = redact_sensitive_text(content.strip()) # Store for iterative updates on next compaction self._previous_summary = summary - self._summary_failure_cooldown_until = 0.0 + self._clear_compression_failure_cooldown() self._summary_model_fallen_back = False self._last_summary_error = None self._last_summary_auth_failure = False @@ -1711,7 +1821,10 @@ def _generate_summary( # a main-model retry before any cooldown. (#11978, #11914) if isinstance(e, RuntimeError) and "no llm provider configured" in str(e).lower(): # No provider configured — long cooldown, unlikely to self-resolve - self._summary_failure_cooldown_until = time.monotonic() + _SUMMARY_FAILURE_COOLDOWN_SECONDS + self._record_compression_failure_cooldown( + _SUMMARY_FAILURE_COOLDOWN_SECONDS, + "no auxiliary LLM provider configured", + ) self._last_summary_error = "no auxiliary LLM provider configured" logger.warning("Context compression: no provider available for " "summary. Middle turns will be dropped without summary " @@ -1823,10 +1936,10 @@ def _generate_summary( # streaming premature-close) — shorter cooldown for JSON decode and # streaming-closed since those conditions can self-resolve quickly. _transient_cooldown = 30 if (_is_json_decode or _is_streaming_closed) else 60 - self._summary_failure_cooldown_until = time.monotonic() + _transient_cooldown err_text = str(e).strip() or e.__class__.__name__ if len(err_text) > 220: err_text = err_text[:217].rstrip() + "..." + self._record_compression_failure_cooldown(_transient_cooldown, err_text) self._last_summary_error = err_text # A terminal connection/network failure (we reach this branch only # after any main-model fallback has already been tried or is @@ -2405,8 +2518,8 @@ def compress(self, messages: List[Dict[str, Any]], current_tokens: int = None, f # Manual /compress (force=True) bypasses the failure cooldown so the # user can retry immediately after an auto-compress abort. Without # this, /compress would silently no-op for 30-60s after a failure. - if force and self._summary_failure_cooldown_until > 0.0: - self._summary_failure_cooldown_until = 0.0 + if force: + self._clear_compression_failure_cooldown() n_messages = len(messages) # Only need head + 3 tail messages minimum (token budget decides the real tail size) _min_for_compress = self._protect_head_size(messages) + 3 + 1 diff --git a/agent/context_references.py b/agent/context_references.py index fad1ff00159b..fe63190e2c0d 100644 --- a/agent/context_references.py +++ b/agent/context_references.py @@ -152,13 +152,24 @@ async def preprocess_context_references_async( blocks: list[str] = [] injected_tokens = 0 - for ref in refs: - warning, block = await _expand_reference( - ref, - cwd_path, - url_fetcher=url_fetcher, - allowed_root=allowed_root_path, + # Expand all references concurrently. Each _expand_reference is independent + # (no shared state during expansion) — a message with several @url: refs + # would otherwise pay one full web_extract round-trip per ref in series. + # gather preserves positional order, so we reassemble warnings/blocks in the + # original ref order exactly as the prior serial loop did; the token-budget + # check below is unchanged (it runs once, after all refs are expanded). + expanded = await asyncio.gather( + *( + _expand_reference( + ref, + cwd_path, + url_fetcher=url_fetcher, + allowed_root=allowed_root_path, + ) + for ref in refs ) + ) + for warning, block in expanded: if warning: warnings.append(warning) if block: @@ -328,9 +339,9 @@ async def _fetch_url_content( async def _default_url_fetcher(url: str) -> str: from tools.web_tools import web_extract_tool - raw = await web_extract_tool([url], format="markdown", use_llm_processing=True) + raw = await web_extract_tool([url], format="markdown") payload = json.loads(raw) - docs = payload.get("data", {}).get("documents", []) + docs = payload.get("results", []) if not docs: return "" doc = docs[0] diff --git a/agent/conversation_compression.py b/agent/conversation_compression.py index b16765ea9b40..74e9feda2e38 100644 --- a/agent/conversation_compression.py +++ b/agent/conversation_compression.py @@ -32,6 +32,7 @@ import os import tempfile import uuid +import threading from datetime import datetime from pathlib import Path from typing import Any, Optional, Tuple @@ -71,6 +72,85 @@ def _compression_lock_holder(agent: Any) -> str: ) +class _CompressionLockLeaseRefresher: + def __init__( + self, + db: Any, + session_id: str, + holder: str, + ttl_seconds: float, + refresh_interval_seconds: float | None = None, + ) -> None: + self._db = db + self._session_id = session_id + self._holder = holder + self._ttl_seconds = ttl_seconds + if refresh_interval_seconds is None: + refresh_interval_seconds = max(1.0, min(60.0, ttl_seconds / 2.0)) + self._refresh_interval_seconds = max(0.1, float(refresh_interval_seconds)) + # Tolerate transient refresh failures for at most one lease's worth of + # time, so the give-up window is genuinely bounded by the TTL the + # acquirer set (a single blip recovers on the next tick; a persistent + # failure stops before the lease could outlive its TTL). Floor of 1 so a + # degenerate interval >= ttl still tolerates one blip. + self._max_consecutive_failures = max( + 1, int(self._ttl_seconds / self._refresh_interval_seconds) + ) + self._stop = threading.Event() + self._thread = threading.Thread( + target=self._run, + name="compression-lock-refresh", + daemon=True, + ) + + def start(self) -> "_CompressionLockLeaseRefresher": + self._thread.start() + return self + + def stop(self) -> None: + self._stop.set() + # join() may time out while the refresher is mid-UPDATE; that's safe — + # it's a daemon thread, and a late refresh on an already-released lock + # matches rowcount 0 (a no-op). stop() returning does not guarantee the + # thread has fully quiesced, only that we've signalled it and waited + # briefly. + if self._thread.is_alive() and threading.current_thread() is not self._thread: + self._thread.join(timeout=1.0) + + def _run(self) -> None: + # A single falsy refresh must NOT permanently kill the lease: a + # transient DB blip (write contention escaping _execute_write's retry + # budget, a momentary "database is locked") returns False just like a + # genuine lost-ownership, but only the latter should stop the loop. + # Tolerate consecutive failures for at most one lease's worth of time + # (_max_consecutive_failures = ttl / interval), so a one-off blip + # recovers on the next tick while the total give-up window stays bounded + # by the TTL the acquirer set — the lock can never be held past its TTL + # by a stuck refresher. + consecutive_failures = 0 + while not self._stop.wait(self._refresh_interval_seconds): + try: + refreshed = self._db.refresh_compression_lock( + self._session_id, + self._holder, + ttl_seconds=self._ttl_seconds, + ) + except Exception as exc: + logger.debug("compression lock refresh raised: %s", exc) + refreshed = False + if refreshed: + consecutive_failures = 0 + continue + consecutive_failures += 1 + if consecutive_failures >= self._max_consecutive_failures: + logger.debug( + "compression lock refresh failed %d times in a row; " + "stopping lease refresher for session %s", + consecutive_failures, self._session_id, + ) + break + + def check_compression_model_feasibility(agent: Any) -> None: """Warn at session start if the auxiliary compression model's context window is smaller than the main model's compression threshold. @@ -420,11 +500,17 @@ def compress_context( # and proceed with compression. Skipping the lock risks a rare # concurrent-compression session fork; an infinite no-progress loop # that never compresses at all is strictly worse. + try: + _lock_ttl = float(getattr(agent, "_compression_lock_ttl_seconds", 300.0) or 300.0) + except (TypeError, ValueError): + _lock_ttl = 300.0 + _lock_refresh_interval = getattr(agent, "_compression_lock_refresh_interval", None) + _lock_refresher: Optional[_CompressionLockLeaseRefresher] = None if _lock_db is not None and _lock_sid: _lock_holder = _compression_lock_holder(agent) try: _lock_acquired = _lock_db.try_acquire_compression_lock( - _lock_sid, _lock_holder + _lock_sid, _lock_holder, ttl_seconds=_lock_ttl ) except Exception as _lock_err: # Broken/absent lock subsystem (version skew, etc.). Log once @@ -467,9 +553,19 @@ def compress_context( if not _existing_sp: _existing_sp = agent._build_system_prompt(system_message) return messages, _existing_sp + if _lock_holder is not None: + _lock_refresher = _CompressionLockLeaseRefresher( + _lock_db, + _lock_sid, + _lock_holder, + _lock_ttl, + _lock_refresh_interval, + ).start() def _release_lock() -> None: """Release the lock keyed on the OLD session_id (before rotation).""" + if _lock_refresher is not None: + _lock_refresher.stop() if _lock_db is not None and _lock_sid and _lock_holder: try: _lock_db.release_compression_lock(_lock_sid, _lock_holder) @@ -488,7 +584,11 @@ def _release_lock() -> None: except TypeError: # Plugin context engine with strict signature that doesn't accept # focus_topic / force — fall back to calling without them. - compressed = agent.context_compressor.compress(messages, current_tokens=approx_tokens) + try: + compressed = agent.context_compressor.compress(messages, current_tokens=approx_tokens) + except BaseException: + _release_lock() + raise except BaseException: # ANY exception during compress() must release the lock so the # session isn't permanently blocked from future compression. @@ -501,328 +601,332 @@ def _release_lock() -> None: # session has logically ended), and let auto-compress callers detect # the no-op via len(returned) == len(input). if getattr(agent.context_compressor, "_last_compress_aborted", False): - _err = getattr(agent.context_compressor, "_last_summary_error", None) or "unknown error" - if getattr(agent, "_last_compression_summary_warning", None) != _err: - agent._last_compression_summary_warning = _err - agent._emit_warning( - f"⚠ Compression aborted: {_err}. " - "No messages were dropped — conversation continues unchanged. " - "Run /compress to retry, or /new to start a fresh session." - ) - _existing_sp = getattr(agent, "_cached_system_prompt", None) - if not _existing_sp: - _existing_sp = agent._build_system_prompt(system_message) - _release_lock() # compression aborted — no rotation will happen - return messages, _existing_sp - - summary_error = getattr(agent.context_compressor, "_last_summary_error", None) - if summary_error: - if getattr(agent, "_last_compression_summary_warning", None) != summary_error: - agent._last_compression_summary_warning = summary_error - agent._emit_warning( - f"⚠ Compression summary failed: {summary_error}. " - "Inserted a fallback context marker." - ) - else: - # No hard failure — but did the configured aux model error out - # and get recovered by retrying on main? Surface that so users - # know their auxiliary.compression.model setting is broken even - # though compression succeeded. - _aux_fail_model = getattr(agent.context_compressor, "_last_aux_model_failure_model", None) - _aux_fail_err = getattr(agent.context_compressor, "_last_aux_model_failure_error", None) - if _aux_fail_model: - # Dedup on (model, error) so we don't spam on every compaction - _aux_key = (_aux_fail_model, _aux_fail_err) - if getattr(agent, "_last_aux_fallback_warning_key", None) != _aux_key: - agent._last_aux_fallback_warning_key = _aux_key + try: + _err = getattr(agent.context_compressor, "_last_summary_error", None) or "unknown error" + if getattr(agent, "_last_compression_summary_warning", None) != _err: + agent._last_compression_summary_warning = _err agent._emit_warning( - f"ℹ Configured compression model '{_aux_fail_model}' failed " - f"({_aux_fail_err or 'unknown error'}). Recovered using main model — " - "check auxiliary.compression.model in config.yaml." + f"⚠ Compression aborted: {_err}. " + "No messages were dropped — conversation continues unchanged. " + "Run /compress to retry, or /new to start a fresh session." ) + _existing_sp = getattr(agent, "_cached_system_prompt", None) + if not _existing_sp: + _existing_sp = agent._build_system_prompt(system_message) + return messages, _existing_sp + finally: + _release_lock() - todo_snapshot = agent._todo_store.format_for_injection() - if todo_snapshot: - compressed.append({"role": "user", "content": todo_snapshot}) - - agent._invalidate_system_prompt() - new_system_prompt = agent._build_system_prompt(system_message) - agent._cached_system_prompt = new_system_prompt + try: + summary_error = getattr(agent.context_compressor, "_last_summary_error", None) + if summary_error: + if getattr(agent, "_last_compression_summary_warning", None) != summary_error: + agent._last_compression_summary_warning = summary_error + agent._emit_warning( + f"⚠ Compression summary failed: {summary_error}. " + "Inserted a fallback context marker." + ) + else: + # No hard failure — but did the configured aux model error out + # and get recovered by retrying on main? Surface that so users + # know their auxiliary.compression.model setting is broken even + # though compression succeeded. + _aux_fail_model = getattr(agent.context_compressor, "_last_aux_model_failure_model", None) + _aux_fail_err = getattr(agent.context_compressor, "_last_aux_model_failure_error", None) + if _aux_fail_model: + # Dedup on (model, error) so we don't spam on every compaction + _aux_key = (_aux_fail_model, _aux_fail_err) + if getattr(agent, "_last_aux_fallback_warning_key", None) != _aux_key: + agent._last_aux_fallback_warning_key = _aux_key + agent._emit_warning( + f"ℹ Configured compression model '{_aux_fail_model}' failed " + f"({_aux_fail_err or 'unknown error'}). Recovered using main model — " + "check auxiliary.compression.model in config.yaml." + ) - if agent._session_db: - try: - # Trigger memory extraction on the current session before the - # transcript is rewritten (runs in BOTH modes — the logical - # conversation's pre-compaction turns are about to be summarized - # away regardless of whether the id rotates). - agent.commit_memory_session(messages) - - if in_place: - # ── In-place compaction: keep the same session_id ────────── - # No end_session, no new row, no parent_session_id, no title - # renumber, no contextvar/env/logging re-sync. The session's - # id, title, cwd, /goal, and gateway routing all stay put. - # - # Durable, NON-DESTRUCTIVE replace: soft-archive the - # pre-compaction turns (active=0, kept on disk + FTS-searchable + - # recoverable) and insert `compressed` as the new live (active=1) - # set, atomically. `compressed` already carries the surviving - # tail (current-turn messages the compressor kept via - # protect_last_n), so we DON'T pre-flush here — a flush would - # INSERT current-turn rows that archive_and_compact would then - # archive alongside the rest (harmless but wasted writes). The - # live-context load filters active=1, so a resume reloads ONLY - # the compacted set; the original turns remain under the SAME id - # for search/recovery (Teknium review — keep one durable id - # WITHOUT destroying history, unlike a hard replace_messages). - # See #38763. - agent._session_db.archive_and_compact(agent.session_id, compressed) - # Reset the flush identity set so the next turn's appends are - # diffed against the COMPACTED transcript: the compacted dicts - # are passed as conversation_history next turn and skipped by - # identity, so only genuinely new turn messages get appended - # (no dup of the summary, no resurrection of dropped turns). - agent._flushed_db_message_ids = set() - # Rotation-independent signal: the conversation was compacted in - # place (id unchanged). The gateway reads this (NOT an id-change - # diff) to re-baseline transcript handling. - compacted_in_place = True - else: - # ── Rotation (legacy): end this session, fork a continuation ─ - # Flush any un-persisted current-turn messages to the OLD - # session before ending it, so they survive in the preserved - # parent transcript (#47202). (In-place skips this — see above.) - try: - agent._flush_messages_to_session_db(messages) - except Exception: - pass # best-effort — don't block compression on a flush error - # Propagate title to the new session with auto-numbering - old_title = agent._session_db.get_session_title(agent.session_id) - agent._session_db.end_session(agent.session_id, "compression") - old_session_id = agent.session_id - agent.session_id = f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:6]}" - # Ordering contract: the agent thread updates the contextvar here; - # the gateway propagates to SessionEntry after run_in_executor returns. - try: - from gateway.session_context import set_current_session_id + todo_snapshot = agent._todo_store.format_for_injection() + if todo_snapshot: + compressed.append({"role": "user", "content": todo_snapshot}) - set_current_session_id(agent.session_id) - except Exception: - os.environ["HERMES_SESSION_ID"] = agent.session_id - # The gateway/tools session context (ContextVar + env) and the - # logging session context are SEPARATE mechanisms. The call above - # moves the former; the ``[session_id]`` tag on log lines comes - # from ``hermes_logging._session_context`` (set once per turn in - # conversation_loop.py). Without this, post-rotation log lines in - # the same turn keep the STALE old id while the message/DB/gateway - # state carry the new one — breaking log correlation exactly at the - # compaction boundary (see #34089). Guarded separately so a logging - # failure can never regress the routing update above. - try: - from hermes_logging import set_session_context + agent._invalidate_system_prompt() + new_system_prompt = agent._build_system_prompt(system_message) + agent._cached_system_prompt = new_system_prompt - set_session_context(agent.session_id) - except Exception: - pass - agent._session_db_created = False - try: - agent._session_db.create_session( - session_id=agent.session_id, - source=agent.platform or os.environ.get("HERMES_SESSION_SOURCE", "cli"), - model=agent.model, - model_config=agent._session_init_model_config, - parent_session_id=old_session_id, - ) - except Exception as _cs_err: - # The child row could not be created (e.g. FK constraint, - # contended write). Previously the outer handler simply - # warned and let the agent continue on the NEW id — which - # has no row in state.db, producing an orphan: the parent - # is ended, the child is never indexed, and every - # subsequent message is attributed to a session that - # doesn't exist (#33906/#33907). Roll the live id back to - # the parent so the conversation stays attached to a real, - # indexed session instead of a phantom. - logger.warning( - "Compression child session create failed (%s) — " - "rolling back to parent session %s to avoid an orphan.", - _cs_err, old_session_id, - ) - agent.session_id = old_session_id + if agent._session_db: + try: + # Trigger memory extraction on the current session before the + # transcript is rewritten (runs in BOTH modes — the logical + # conversation's pre-compaction turns are about to be summarized + # away regardless of whether the id rotates). + agent.commit_memory_session(messages) + + if in_place: + # ── In-place compaction: keep the same session_id ────────── + # No end_session, no new row, no parent_session_id, no title + # renumber, no contextvar/env/logging re-sync. The session's + # id, title, cwd, /goal, and gateway routing all stay put. + # + # Durable, NON-DESTRUCTIVE replace: soft-archive the + # pre-compaction turns (active=0, kept on disk + FTS-searchable + + # recoverable) and insert `compressed` as the new live (active=1) + # set, atomically. `compressed` already carries the surviving + # tail (current-turn messages the compressor kept via + # protect_last_n), so we DON'T pre-flush here — a flush would + # INSERT current-turn rows that archive_and_compact would then + # archive alongside the rest (harmless but wasted writes). The + # live-context load filters active=1, so a resume reloads ONLY + # the compacted set; the original turns remain under the SAME id + # for search/recovery (Teknium review — keep one durable id + # WITHOUT destroying history, unlike a hard replace_messages). + # See #38763. + agent._session_db.archive_and_compact(agent.session_id, compressed) + # Reset the flush identity set so the next turn's appends are + # diffed against the COMPACTED transcript: the compacted dicts + # are passed as conversation_history next turn and skipped by + # identity, so only genuinely new turn messages get appended + # (no dup of the summary, no resurrection of dropped turns). + agent._flushed_db_message_ids = set() + # Rotation-independent signal: the conversation was compacted in + # place (id unchanged). The gateway reads this (NOT an id-change + # diff) to re-baseline transcript handling. + compacted_in_place = True + else: + # ── Rotation (legacy): end this session, fork a continuation ─ + # Flush any un-persisted current-turn messages to the OLD + # session before ending it, so they survive in the preserved + # parent transcript (#47202). (In-place skips this — see above.) + try: + agent._flush_messages_to_session_db(messages) + except Exception: + pass # best-effort — don't block compression on a flush error + # Propagate title to the new session with auto-numbering + old_title = agent._session_db.get_session_title(agent.session_id) + agent._session_db.end_session(agent.session_id, "compression") + old_session_id = agent.session_id + agent.session_id = f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:6]}" + # Ordering contract: the agent thread updates the contextvar here; + # the gateway propagates to SessionEntry after run_in_executor returns. try: from gateway.session_context import set_current_session_id + set_current_session_id(agent.session_id) except Exception: os.environ["HERMES_SESSION_ID"] = agent.session_id + # The gateway/tools session context (ContextVar + env) and the + # logging session context are SEPARATE mechanisms. The call above + # moves the former; the ``[session_id]`` tag on log lines comes + # from ``hermes_logging._session_context`` (set once per turn in + # conversation_loop.py). Without this, post-rotation log lines in + # the same turn keep the STALE old id while the message/DB/gateway + # state carry the new one — breaking log correlation exactly at the + # compaction boundary (see #34089). Guarded separately so a logging + # failure can never regress the routing update above. try: from hermes_logging import set_session_context + set_session_context(agent.session_id) except Exception: pass - # Re-open the parent: it was ended above, but we're - # continuing on it, so it must not stay closed. + agent._session_db_created = False try: - agent._session_db.reopen_session(old_session_id) - except Exception: - pass - old_session_id = None # no rotation happened - # The parent row already exists in state.db, so mark the - # session as created — _ensure_db_session would otherwise - # retry a (harmless INSERT OR IGNORE) create next turn. + agent._session_db.create_session( + session_id=agent.session_id, + source=agent.platform or os.environ.get("HERMES_SESSION_SOURCE", "cli"), + model=agent.model, + model_config=agent._session_init_model_config, + parent_session_id=old_session_id, + ) + except Exception as _cs_err: + # The child row could not be created (e.g. FK constraint, + # contended write). Previously the outer handler simply + # warned and let the agent continue on the NEW id — which + # has no row in state.db, producing an orphan: the parent + # is ended, the child is never indexed, and every + # subsequent message is attributed to a session that + # doesn't exist (#33906/#33907). Roll the live id back to + # the parent so the conversation stays attached to a real, + # indexed session instead of a phantom. + logger.warning( + "Compression child session create failed (%s) — " + "rolling back to parent session %s to avoid an orphan.", + _cs_err, old_session_id, + ) + agent.session_id = old_session_id + try: + from gateway.session_context import set_current_session_id + set_current_session_id(agent.session_id) + except Exception: + os.environ["HERMES_SESSION_ID"] = agent.session_id + try: + from hermes_logging import set_session_context + set_session_context(agent.session_id) + except Exception: + pass + # Re-open the parent: it was ended above, but we're + # continuing on it, so it must not stay closed. + try: + agent._session_db.reopen_session(old_session_id) + except Exception: + pass + old_session_id = None # no rotation happened + # The parent row already exists in state.db, so mark the + # session as created — _ensure_db_session would otherwise + # retry a (harmless INSERT OR IGNORE) create next turn. + agent._session_db_created = True + raise agent._session_db_created = True - raise - agent._session_db_created = True - # Carry a persistent /goal onto the continuation session. - # Compression mints a fresh child id; load_goal does a flat - # per-session lookup with no parent walk, so without this an - # active goal silently dies at the boundary (#33618). - try: - from hermes_cli.goals import migrate_goal_to_session - migrate_goal_to_session(old_session_id, agent.session_id, reason="compression") - except Exception as _goal_err: - logger.debug("Could not migrate goal on compression: %s", _goal_err) - # Auto-number the title for the continuation session - if old_title: + # Carry a persistent /goal onto the continuation session. + # Compression mints a fresh child id; load_goal does a flat + # per-session lookup with no parent walk, so without this an + # active goal silently dies at the boundary (#33618). try: - new_title = agent._session_db.get_next_title_in_lineage(old_title) - agent._session_db.set_session_title(agent.session_id, new_title) - except (ValueError, Exception) as e: - logger.debug("Could not propagate title on compression: %s", e) - - # Shared post-write steps (both modes target agent.session_id, which - # in-place keeps and rotation has already reassigned to the new id): - # refresh the stored system prompt and reset the flush cursor so the - # next turn re-bases its append diff. - agent._session_db.update_system_prompt(agent.session_id, new_system_prompt) - agent._last_flushed_db_idx = 0 - except Exception as e: - # If the rotation rolled back to the parent (orphan-avoidance - # above), agent.session_id is the still-indexed parent and - # old_session_id was cleared — so this is recovery, not an - # un-indexed orphan. Otherwise an earlier step failed before the - # child was created and the warning's original meaning holds. - if locals().get("old_session_id") is None and not in_place: - logger.warning( - "Compression rotation aborted and rolled back to the " - "parent session (%s): %s", agent.session_id or "?", e, + from hermes_cli.goals import migrate_goal_to_session + migrate_goal_to_session(old_session_id, agent.session_id, reason="compression") + except Exception as _goal_err: + logger.debug("Could not migrate goal on compression: %s", _goal_err) + # Auto-number the title for the continuation session + if old_title: + try: + new_title = agent._session_db.get_next_title_in_lineage(old_title) + agent._session_db.set_session_title(agent.session_id, new_title) + except (ValueError, Exception) as e: + logger.debug("Could not propagate title on compression: %s", e) + + # Shared post-write steps (both modes target agent.session_id, which + # in-place keeps and rotation has already reassigned to the new id): + # refresh the stored system prompt and reset the flush cursor so the + # next turn re-bases its append diff. + agent._session_db.update_system_prompt(agent.session_id, new_system_prompt) + agent._last_flushed_db_idx = 0 + except Exception as e: + # If the rotation rolled back to the parent (orphan-avoidance + # above), agent.session_id is the still-indexed parent and + # old_session_id was cleared — so this is recovery, not an + # un-indexed orphan. Otherwise an earlier step failed before the + # child was created and the warning's original meaning holds. + if locals().get("old_session_id") is None and not in_place: + logger.warning( + "Compression rotation aborted and rolled back to the " + "parent session (%s): %s", agent.session_id or "?", e, + ) + else: + logger.warning("Session DB compression split failed — new session will NOT be indexed: %s", e) + + # Compaction-boundary bookkeeping, computed once. `old_session_id` is only + # bound in the rotation branch; in-place leaves it unset. `_boundary_parent` + # is the id the boundary notifications attribute the prior state to: the old + # id on rotation, the (unchanged) current id in-place. + _old_sid = locals().get("old_session_id") + _is_boundary = bool(_old_sid) or in_place + _boundary_parent = _old_sid or agent.session_id or "" + + # Notify the context engine that a compaction boundary occurred. Plugin + # engines (e.g. hermes-lcm) use boundary_reason="compression" to preserve + # DAG lineage / checkpoint per-session state across the boundary instead of + # re-initializing fresh. See hermes-lcm#68. Built-in ContextCompressor + # ignores kwargs. Fires in BOTH modes: rotation passes old→new ids; in-place + # passes the SAME id (the boundary is real even though the id didn't move). + try: + if _is_boundary and hasattr(agent.context_compressor, "on_session_start"): + agent.context_compressor.on_session_start( + agent.session_id or "", + boundary_reason="compression", + old_session_id=_boundary_parent, + platform=getattr(agent, "platform", None) or "cli", + conversation_id=getattr(agent, "_gateway_session_key", None), ) - else: - logger.warning("Session DB compression split failed — new session will NOT be indexed: %s", e) - - # Compaction-boundary bookkeeping, computed once. `old_session_id` is only - # bound in the rotation branch; in-place leaves it unset. `_boundary_parent` - # is the id the boundary notifications attribute the prior state to: the old - # id on rotation, the (unchanged) current id in-place. - _old_sid = locals().get("old_session_id") - _is_boundary = bool(_old_sid) or in_place - _boundary_parent = _old_sid or agent.session_id or "" - - # Notify the context engine that a compaction boundary occurred. Plugin - # engines (e.g. hermes-lcm) use boundary_reason="compression" to preserve - # DAG lineage / checkpoint per-session state across the boundary instead of - # re-initializing fresh. See hermes-lcm#68. Built-in ContextCompressor - # ignores kwargs. Fires in BOTH modes: rotation passes old→new ids; in-place - # passes the SAME id (the boundary is real even though the id didn't move). - try: - if _is_boundary and hasattr(agent.context_compressor, "on_session_start"): - agent.context_compressor.on_session_start( - agent.session_id or "", - boundary_reason="compression", - old_session_id=_boundary_parent, - platform=getattr(agent, "platform", None) or "cli", - conversation_id=getattr(agent, "_gateway_session_key", None), - ) - except Exception as _ce_err: - logger.debug("context engine on_session_start (compression): %s", _ce_err) - - # Notify memory providers of the compaction boundary so provider-cached - # per-session state (Hindsight's _document_id, accumulated turn buffers, - # counters) refreshes. reset=False because the logical conversation - # continues. See #6672. Fires in BOTH modes: in-place uses the same id as - # parent (the conversation didn't fork, but the buffer must still be told - # the transcript was compacted so it doesn't double-count dropped turns). - try: - if _is_boundary and agent._memory_manager: - agent._memory_manager.on_session_switch( - agent.session_id or "", - parent_session_id=_boundary_parent, - reset=False, - reason="compression", + except Exception as _ce_err: + logger.debug("context engine on_session_start (compression): %s", _ce_err) + + # Notify memory providers of the compaction boundary so provider-cached + # per-session state (Hindsight's _document_id, accumulated turn buffers, + # counters) refreshes. reset=False because the logical conversation + # continues. See #6672. Fires in BOTH modes: in-place uses the same id as + # parent (the conversation didn't fork, but the buffer must still be told + # the transcript was compacted so it doesn't double-count dropped turns). + try: + if _is_boundary and agent._memory_manager: + agent._memory_manager.on_session_switch( + agent.session_id or "", + parent_session_id=_boundary_parent, + reset=False, + reason="compression", + ) + except Exception as _me_err: + logger.debug("memory manager on_session_switch (compression): %s", _me_err) + + # Warn on repeated compressions (quality degrades with each pass). + # Route through _emit_status (like the other compression warnings above) + # so the warning reaches the TUI / Telegram / Discord via status_callback, + # not just CLI stdout. _emit_status still _vprints for the CLI, and + # storing it on _compression_warning lets replay_compression_warning + # re-deliver it once a late-bound gateway status_callback is wired (#36908). + _cc = agent.context_compressor.compression_count + if _cc >= 2: + _cc_msg = ( + f"{agent.log_prefix}⚠️ Session compressed {_cc} times — " + f"accuracy may degrade. Consider /new to start fresh." ) - except Exception as _me_err: - logger.debug("memory manager on_session_switch (compression): %s", _me_err) - - # Warn on repeated compressions (quality degrades with each pass). - # Route through _emit_status (like the other compression warnings above) - # so the warning reaches the TUI / Telegram / Discord via status_callback, - # not just CLI stdout. _emit_status still _vprints for the CLI, and - # storing it on _compression_warning lets replay_compression_warning - # re-deliver it once a late-bound gateway status_callback is wired (#36908). - _cc = agent.context_compressor.compression_count - if _cc >= 2: - _cc_msg = ( - f"{agent.log_prefix}⚠️ Session compressed {_cc} times — " - f"accuracy may degrade. Consider /new to start fresh." + agent._compression_warning = _cc_msg + agent._emit_status(_cc_msg) + + # Emit session:compress event so hooks (e.g. MemPalace sync) can ingest + # the completed old session before its details are lost. In in-place mode + # there is no old id (same session); ``in_place=True`` tells hooks the + # transcript was compacted on the same id rather than rotated. + if getattr(agent, "event_callback", None): + try: + agent.event_callback("session:compress", { + "platform": agent.platform or "", + "session_id": agent.session_id, + "old_session_id": _old_sid or "", + "in_place": in_place, + "compression_count": agent.context_compressor.compression_count, + }) + except Exception as e: + logger.debug("event_callback error on session:compress: %s", e) + + # Surface the compaction mode to the caller (run_conversation / gateway) + # via a rotation-independent flag. The gateway uses this — NOT an + # id-change diff — to re-baseline transcript handling (history_offset=0 + + # rewrite on the same id) when compaction happened in place. See #38763. + agent._last_compaction_in_place = compacted_in_place + + # Keep the post-compression rough estimate for diagnostics, but do not + # treat it as provider-reported prompt usage. Schema-heavy rough estimates + # can remain above threshold even after the next real API request fits. + _compressed_est = estimate_request_tokens_rough( + compressed, + system_prompt=new_system_prompt or "", + tools=agent.tools or None, ) - agent._compression_warning = _cc_msg - agent._emit_status(_cc_msg) - - # Emit session:compress event so hooks (e.g. MemPalace sync) can ingest - # the completed old session before its details are lost. In in-place mode - # there is no old id (same session); ``in_place=True`` tells hooks the - # transcript was compacted on the same id rather than rotated. - if getattr(agent, "event_callback", None): + agent.context_compressor.last_compression_rough_tokens = _compressed_est + agent.context_compressor.last_prompt_tokens = -1 + agent.context_compressor.last_completion_tokens = 0 + agent.context_compressor.awaiting_real_usage_after_compression = True + + # Clear the file-read dedup cache. After compression the original + # read content is summarised away — if the model re-reads the same + # file it needs the full content, not a "file unchanged" stub. try: - agent.event_callback("session:compress", { - "platform": agent.platform or "", - "session_id": agent.session_id, - "old_session_id": _old_sid or "", - "in_place": in_place, - "compression_count": agent.context_compressor.compression_count, - }) - except Exception as e: - logger.debug("event_callback error on session:compress: %s", e) - - # Surface the compaction mode to the caller (run_conversation / gateway) - # via a rotation-independent flag. The gateway uses this — NOT an - # id-change diff — to re-baseline transcript handling (history_offset=0 + - # rewrite on the same id) when compaction happened in place. See #38763. - agent._last_compaction_in_place = compacted_in_place - - # Keep the post-compression rough estimate for diagnostics, but do not - # treat it as provider-reported prompt usage. Schema-heavy rough estimates - # can remain above threshold even after the next real API request fits. - _compressed_est = estimate_request_tokens_rough( - compressed, - system_prompt=new_system_prompt or "", - tools=agent.tools or None, - ) - agent.context_compressor.last_compression_rough_tokens = _compressed_est - agent.context_compressor.last_prompt_tokens = -1 - agent.context_compressor.last_completion_tokens = 0 - agent.context_compressor.awaiting_real_usage_after_compression = True - - # Clear the file-read dedup cache. After compression the original - # read content is summarised away — if the model re-reads the same - # file it needs the full content, not a "file unchanged" stub. - try: - from tools.file_tools import reset_file_dedup - reset_file_dedup(task_id) - except Exception: - pass + from tools.file_tools import reset_file_dedup + reset_file_dedup(task_id) + except Exception: + pass - logger.info( - "context compression done: session=%s messages=%d->%d rough_tokens=~%s awaiting_real_usage=true", - agent.session_id or "none", _pre_msg_count, len(compressed), - f"{_compressed_est:,}", - ) - # Release the lock on the OLD session_id only AFTER rotation completed - # and all post-rotation bookkeeping (memory manager, context engine, - # file dedup) ran. A concurrent path that wakes up the moment we - # release will see the NEW session_id in state.db / SessionEntry and - # acquire on that — no race against our just-finished work. - _release_lock() - return compressed, new_system_prompt + logger.info( + "context compression done: session=%s messages=%d->%d rough_tokens=~%s awaiting_real_usage=true", + agent.session_id or "none", _pre_msg_count, len(compressed), + f"{_compressed_est:,}", + ) + return compressed, new_system_prompt + finally: + # Release the lock on the OLD session_id only AFTER rotation completed + # and all post-rotation bookkeeping (memory manager, context engine, + # file dedup) ran. A concurrent path that wakes up the moment we + # release will see the NEW session_id in state.db / SessionEntry and + # acquire on that — no race against our just-finished work. + _release_lock() def try_shrink_image_parts_in_messages( diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index 10825cfd683e..7a5919807af1 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -52,6 +52,7 @@ estimate_messages_tokens_rough, estimate_request_tokens_rough, get_context_length_from_provider_error, + is_output_cap_error, parse_available_output_tokens_from_error, save_context_length, ) @@ -1167,11 +1168,22 @@ def _stop_spinner(): # stream. Mirror the ACP exclusion used for Responses # API upgrade (lines ~1083-1085). elif ( - agent.provider in {"copilot-acp", "moa"} + agent.provider in {"copilot-acp"} or str(agent.base_url or "").lower().startswith("acp://copilot") or str(agent.base_url or "").lower().startswith("acp+tcp://") ): _use_streaming = False + # MoA streams only when a display/TTS consumer is present to + # receive the deltas. MoAChatCompletions.create() honors + # stream=True (runs the references, then returns the aggregator's + # raw token stream) and is reached here because, for provider + # "moa", _create_request_openai_client returns the MoA facade + # itself. Without consumers (quiet mode, subagents, health-check + # probes) we keep the complete-response path: the facade returns a + # whole response when stream is not requested, preserving the + # prior behavior for those callers. + elif agent.provider == "moa" and not agent._has_stream_consumers(): + _use_streaming = False elif not agent._has_stream_consumers(): # No display/TTS consumer. Still prefer streaming for # health checking, but skip for Mock clients in tests @@ -2919,6 +2931,7 @@ def _perform_api_call(next_api_kwargs): is_rate_limited = classified.reason in { FailoverReason.rate_limit, FailoverReason.billing, + FailoverReason.upstream_rate_limit, } _is_transport_failure = classified.reason in { FailoverReason.timeout, @@ -2933,13 +2946,30 @@ def _perform_api_call(next_api_kwargs): # still recover. See _pool_may_recover_from_rate_limit # for the single-credential-pool and CloudCode-quota # exceptions. Fixes #11314 and #13636. - pool_may_recover = _ra()._pool_may_recover_from_rate_limit( - agent._credential_pool, - provider=agent.provider, - base_url=getattr(agent, "base_url", None), + # + # Exception: an upstream-aggregator 429 — the credential + # pool can't help when the *upstream* model (DeepSeek, + # etc.) is throttling OpenRouter, so always fall back to a + # different model regardless of pool state. + _is_upstream = classified.reason == FailoverReason.upstream_rate_limit + pool_may_recover = ( + False if _is_upstream + else _ra()._pool_may_recover_from_rate_limit( + agent._credential_pool, + provider=agent.provider, + base_url=getattr(agent, "base_url", None), + ) ) if not pool_may_recover: - if classified.reason == FailoverReason.billing: + if _is_upstream: + _upstream_name = (classified.error_context or {}).get( + "upstream_provider", "aggregator" + ) + agent._buffer_status( + f"⚠️ Upstream {_upstream_name} rate-limited — " + "switching to fallback model..." + ) + elif classified.reason == FailoverReason.billing: agent._buffer_status( "⚠️ Billing or credits exhausted — switching to fallback provider..." ) @@ -3213,6 +3243,45 @@ def _perform_api_call(next_api_kwargs): _retry.restart_with_compressed_messages = True break + # The error is output-cap-shaped (about max_tokens being + # too large) but the provider's wording didn't let us parse + # the available output budget. Compression CANNOT help here + # — the input already fits; the call fails deterministically + # on the oversized max_tokens. Routing it into compression + # re-sends the same max_tokens, gets the identical 400, and + # death-loops until "cannot compress further" (#55546). + # Fail fast with an actionable message instead of looping. + if is_output_cap_error(error_msg): + agent._flush_status_buffer() + agent._vprint( + f"{agent.log_prefix}❌ The provider rejected the request because " + f"max_tokens exceeds its output cap for this model.", + force=True, + ) + agent._vprint( + f"{agent.log_prefix} 💡 Lower model.max_tokens in your config.yaml to " + f"at or below the model's max-output limit. " + f"(This is an output-cap error, not a context overflow — " + f"compression cannot fix it.)", + force=True, + ) + logger.error( + f"{agent.log_prefix}Output-cap error not routed into compression " + f"(max_tokens over provider cap): {error_msg[:200]}" + ) + agent._persist_session(messages, conversation_history) + return { + "messages": messages, + "completed": False, + "api_calls": api_call_count, + "error": ( + "max_tokens exceeds the provider's output cap for this model. " + "Lower model.max_tokens in config.yaml." + ), + "partial": True, + "failed": True, + } + # Error is about the INPUT being too large. Only reduce # context_length when the provider explicitly reports the # real lower limit. If the provider only says "input @@ -4810,6 +4879,55 @@ def _perform_api_call(next_api_kwargs): agent._verification_stop_nudges) continue + # User verification-loop gate: when the agent edited code this + # turn, let a registered `pre_verify` hook (plugin/shell) keep it + # going one more turn. The shipped guidance is folded into the + # evidence-based verify-on-stop nudge above, so this path has no + # default continuation cost. + _verify_nudge2 = None + _edited = sorted(getattr(agent, "_turn_file_mutation_paths", set()) or []) + _attempt = getattr(agent, "_pre_verify_nudges", 0) + try: + from agent.verify_hooks import max_verify_nudges + from hermes_cli.plugins import get_pre_verify_continue_message, has_hook + + if _edited and has_hook("pre_verify") and _attempt < max_verify_nudges(): + # Posture is fixed for the session — resolve once + cache. + coding = getattr(agent, "_resolved_is_coding", None) + if coding is None: + from agent.coding_context import is_coding_context + coding = bool(is_coding_context(platform=getattr(agent, "platform", "") or "")) + agent._resolved_is_coding = coding + _verify_nudge2 = get_pre_verify_continue_message( + session_id=getattr(agent, "session_id", None) or "", + platform=getattr(agent, "platform", "") or "", + model=getattr(agent, "model", "") or "", + coding=coding, + attempt=_attempt, + final_response=final_response, + changed_paths=_edited, + ) + except Exception: + logger.debug("pre_verify hook check failed", exc_info=True) + _verify_nudge2 = None + + if _verify_nudge2: + agent._pre_verify_nudges = _attempt + 1 + final_msg["finish_reason"] = "verify_hook_continue" + # Same alternation contract as verify-on-stop: keep the + # attempted answer in history, follow it with a synthetic + # user nudge, and don't surface the premature answer. + messages.append(final_msg) + messages.append({ + "role": "user", + "content": _verify_nudge2, + "_pre_verify_synthetic": True, + }) + agent._session_messages = messages + logger.debug("pre_verify nudge issued (attempt %d)", + agent._pre_verify_nudges) + continue + messages.append(final_msg) _turn_exit_reason = f"text_response(finish_reason={finish_reason})" diff --git a/agent/credential_pool.py b/agent/credential_pool.py index d8ca2b1720ea..8d10bbb1cbf7 100644 --- a/agent/credential_pool.py +++ b/agent/credential_pool.py @@ -616,17 +616,32 @@ def _sync_anthropic_entry_from_credentials_file(self, entry: PooledCredential) - file_refresh = creds.get("refreshToken", "") file_access = creds.get("accessToken", "") file_expires = creds.get("expiresAt", 0) - # If the credentials file has a different token pair, sync it - if file_refresh and file_refresh != entry.refresh_token: - logger.debug("Pool entry %s: syncing tokens from credentials file (refresh token changed)", entry.id) + # Sync when either token changed. Access tokens can be re-issued + # without a new refresh token (silent re-issue path), so checking + # only refresh_token misses that case and leaves a stale + # access_token in the pool → 401 on every request until the pool + # entry's exhausted TTL expires. + entry_access = entry.access_token or "" + entry_refresh = entry.refresh_token or "" + if (file_access or file_refresh) and ( + (file_access and file_access != entry_access) + or (file_refresh and file_refresh != entry_refresh) + ): + logger.debug( + "Pool entry %s: syncing tokens from credentials file (tokens changed)", + entry.id, + ) updated = replace( entry, - access_token=file_access, - refresh_token=file_refresh, - expires_at_ms=file_expires, + access_token=file_access or entry.access_token, + refresh_token=file_refresh or entry.refresh_token, + expires_at_ms=file_expires or entry.expires_at_ms, last_status=None, last_status_at=None, last_error_code=None, + last_error_reason=None, + last_error_message=None, + last_error_reset_at=None, ) self._replace_entry(entry, updated) self._persist() @@ -1884,11 +1899,16 @@ def _env_val(key: str) -> str: from hermes_cli.copilot_auth import resolve_copilot_token, get_copilot_api_token token, source = resolve_copilot_token() if token: - api_token = get_copilot_api_token(token) + api_token, enterprise_base_url = get_copilot_api_token(token) source_name = "gh_cli" if "gh" in source.lower() else f"env:{source}" if not _is_suppressed(provider, source_name): active_sources.add(source_name) pconfig = PROVIDER_REGISTRY.get(provider) + # Use enterprise base URL from token exchange if available, + # otherwise fall back to the provider's default. + effective_base_url = enterprise_base_url or ( + pconfig.inference_base_url if pconfig else "" + ) changed |= _upsert_entry( entries, provider, @@ -1897,7 +1917,7 @@ def _env_val(key: str) -> str: "source": source_name, "auth_type": AUTH_TYPE_API_KEY, "access_token": api_token, - "base_url": pconfig.inference_base_url if pconfig else "", + "base_url": effective_base_url, "label": source, }, ) diff --git a/agent/display.py b/agent/display.py index 861d84bc4105..060ac1266fa0 100644 --- a/agent/display.py +++ b/agent/display.py @@ -537,6 +537,122 @@ def build_tool_preview(tool_name: str, args: dict, max_len: int | None = None) - return preview +# ========================================================================= +# Friendly tool labels (human-phrased verbs for built-in tools) +# +# Turns "web_search " into "Searching the web for " — the +# ChatGPT-style "Searching…/Reading…" surface. Curated and built-in only: +# we know each core tool's semantics, so the verb is fixed, not computed. +# Custom/plugin/MCP tools have no entry and fall back to the raw preview. +# ========================================================================= + +# Each entry maps a built-in tool name to its present-participle verb phrase. +# A trailing space-then-preview is appended by build_tool_label() when the +# tool's argument preview is available (e.g. "Reading docs/api.md"). +_TOOL_VERBS: dict[str, str] = { + "web_search": "Searching the web", + "web_extract": "Reading", + "browser_navigate": "Browsing", + "browser_click": "Clicking", + "browser_type": "Typing", + "read_file": "Reading", + "write_file": "Writing", + "patch": "Editing", + "search_files": "Searching files", + "terminal": "Running", + "execute_code": "Running code", + "image_generate": "Generating image", + "video_generate": "Generating video", + "text_to_speech": "Generating speech", + "vision_analyze": "Looking at the image", + "session_search": "Searching past sessions", + "skill_view": "Reading skill", + "skills_list": "Listing skills", + "skill_manage": "Updating skill", + "delegate_task": "Delegating", + "cronjob": "Scheduling", + "clarify": "Asking", + "memory": "Updating memory", + "todo": "Updating tasks", +} + +# Verbs that read better without the raw argument preview appended. +_TOOL_VERBS_NO_PREVIEW: frozenset[str] = frozenset({ + "skills_list", + "session_search", +}) + +# Verbs that take a "for" connector before the preview (search-style phrasing): +# "Searching the web for " reads better than "Searching the web ". +_TOOL_VERBS_FOR_CONNECTOR: frozenset[str] = frozenset({ + "web_search", + "search_files", +}) + +_friendly_tool_labels: bool = True + + +def set_friendly_tool_labels(enabled: bool) -> None: + """Toggle friendly human-phrased tool labels (display.friendly_tool_labels).""" + global _friendly_tool_labels + _friendly_tool_labels = bool(enabled) + + +def get_friendly_tool_labels() -> bool: + """Return whether friendly tool labels are enabled.""" + return _friendly_tool_labels + + +def get_tool_verb(tool_name: str) -> str | None: + """Return the friendly verb for a built-in tool, or None. + + Returns None when friendly labels are disabled or the tool has no curated + verb (custom/plugin/MCP tools). Callers that already hold a computed + argument preview can compose ``f"{verb} {preview}"`` themselves; use + :func:`tool_verb_connector` to pick the right joiner. + """ + if not _friendly_tool_labels: + return None + return _TOOL_VERBS.get(tool_name) + + +def tool_verb_connector(tool_name: str) -> str: + """Return the connector between a verb and its preview (" for " or " ").""" + return " for " if tool_name in _TOOL_VERBS_FOR_CONNECTOR else " " + + +def verb_drops_preview(tool_name: str) -> bool: + """Whether the verb should render alone, without the argument preview.""" + return tool_name in _TOOL_VERBS_NO_PREVIEW + + +def build_tool_label(tool_name: str, args: dict, max_len: int | None = None) -> str | None: + """Build a human-phrased status label for a tool call. + + For built-in tools with a known verb (``web_search`` -> "Searching the + web for ..."), returns the verb optionally followed by the argument + preview. For everything else (custom/plugin/MCP tools, or when friendly + labels are disabled) returns the raw preview, so callers can use this as a + drop-in replacement for :func:`build_tool_preview`. + """ + if not _friendly_tool_labels: + return build_tool_preview(tool_name, args, max_len=max_len) + + verb = _TOOL_VERBS.get(tool_name) + if not verb: + return build_tool_preview(tool_name, args, max_len=max_len) + + if tool_name in _TOOL_VERBS_NO_PREVIEW: + return verb + + preview = build_tool_preview(tool_name, args, max_len=max_len) + if not preview: + return verb + if tool_name in _TOOL_VERBS_FOR_CONNECTOR: + return f"{verb} for {preview}" + return f"{verb} {preview}" + + # ========================================================================= # Inline diff previews for write actions # ========================================================================= diff --git a/agent/error_classifier.py b/agent/error_classifier.py index a64683ba41ea..8111880a7ec6 100644 --- a/agent/error_classifier.py +++ b/agent/error_classifier.py @@ -31,6 +31,9 @@ class FailoverReason(enum.Enum): # Billing / quota billing = "billing" # 402 or confirmed credit exhaustion — rotate immediately rate_limit = "rate_limit" # 429 or quota-based throttling — backoff then rotate + # Upstream model rate-limited (aggregator 429) — fallback to a different + # model, NOT credential rotation. The user's key is healthy. + upstream_rate_limit = "upstream_rate_limit" # Server-side overloaded = "overloaded" # 503/529 — provider overloaded, backoff @@ -909,6 +912,22 @@ def _classify_by_status( FailoverReason.overloaded, retryable=True, ) + # Distinguish an OpenRouter-aggregator upstream 429 (an upstream model + # like DeepSeek rate-limited OpenRouter's aggregate traffic) from an + # account-level 429 (the user's key is actually throttled). OpenRouter + # wraps upstream errors with the outer message "Provider returned + # error" — the user's key is healthy, so marking it exhausted / rotating + # is wrong and burns the key for ~24min. Fall back to a different model. + if _is_openrouter_upstream_error(body, provider): + upstream_provider = _extract_upstream_provider_name(body) + ctx = {"upstream_provider": upstream_provider} if upstream_provider else {} + return result_fn( + FailoverReason.upstream_rate_limit, + retryable=True, + should_rotate_credential=False, + should_fallback=True, + error_context=ctx, + ) return result_fn( FailoverReason.rate_limit, retryable=True, @@ -1445,3 +1464,49 @@ def _extract_message(error: Exception, body: dict) -> str: return msg.strip()[:500] # Fallback to str(error) return str(error)[:500] + + +def _is_openrouter_upstream_error(body: Any, provider: str) -> bool: + """Detect OpenRouter's aggregator-wrapped upstream provider errors. + + OpenRouter returns errors from upstream model providers (DeepSeek, + Anthropic, etc.) wrapped with the outer message "Provider returned error" + and the real error nested in ``metadata.raw``. This signal means the + user's OpenRouter key is healthy — the upstream provider is the one that + failed — so credential rotation is the wrong recovery. + """ + if not isinstance(body, dict): + return False + provider_lower = (provider or "").strip().lower() + err = body.get("error") + if not isinstance(err, dict): + return False + outer_msg = str(err.get("message") or "").strip().lower() + if outer_msg != "provider returned error": + return False + # Require either the explicit OpenRouter provider OR the metadata shape + # that only OpenRouter produces (metadata.raw / metadata.provider_name). + if provider_lower == "openrouter": + return True + metadata = err.get("metadata") + if isinstance(metadata, dict) and ( + "raw" in metadata or "provider_name" in metadata + ): + return True + return False + + +def _extract_upstream_provider_name(body: Any) -> Optional[str]: + """Pull the upstream provider name out of OpenRouter's error metadata.""" + if not isinstance(body, dict): + return None + err = body.get("error") + if not isinstance(err, dict): + return None + metadata = err.get("metadata") + if not isinstance(metadata, dict): + return None + name = metadata.get("provider_name") + if isinstance(name, str) and name.strip(): + return name.strip() + return None diff --git a/agent/gemini_native_adapter.py b/agent/gemini_native_adapter.py index a79effebba46..c254bf61311b 100644 --- a/agent/gemini_native_adapter.py +++ b/agent/gemini_native_adapter.py @@ -337,6 +337,22 @@ def _build_gemini_contents(messages: List[Dict[str, Any]]) -> tuple[List[Dict[st if parts: contents.append({"role": gemini_role, "parts": parts}) + # Gemini's generateContent requires strict user/model alternation; + # consecutive same-role contents are rejected with HTTP 400 "Please ensure + # that multiturn requests alternate between user and model". The loop above + # emits one content per source message, so parallel tool calls (N tool + # results become N user functionResponse contents), back-to-back user turns, + # or merged assistant turns would each violate that. Merge adjacent + # same-role contents by concatenating their parts. For parallel calls this + # also produces the grouped multi-functionResponse turn Gemini expects. + merged_contents: List[Dict[str, Any]] = [] + for content in contents: + if merged_contents and merged_contents[-1]["role"] == content["role"]: + merged_contents[-1]["parts"].extend(content["parts"]) + else: + merged_contents.append(content) + contents = merged_contents + system_instruction = None joined_system = "\n".join(part for part in system_text_parts if part).strip() if joined_system: diff --git a/agent/learning_graph.py b/agent/learning_graph.py new file mode 100644 index 000000000000..6dc518b2abaf --- /dev/null +++ b/agent/learning_graph.py @@ -0,0 +1,320 @@ +"""Assemble the "learning made visible" graph for desktop. + +This graph is intentionally scoped to what a user actually learns over time: +- non-base, learned/profile skills (agent-created or used), +- memory chunks from ``MEMORY.md`` / ``USER.md`` as first-class nodes. + +Skill links come from declared ``related_skills``. Memory-to-skill links are +derived from lexical overlap so the graph can answer "which learned skills are +connected to the things I remember?". + +Run as a module to print edge-density stats against real data: + + python -m agent.learning_graph +""" + +from __future__ import annotations + +import json +import re +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Optional + +from hermes_constants import get_hermes_home + + +@dataclass +class SkillNode: + name: str + category: str + source: str = "profile" + timestamp: Optional[int] = None + use_count: int = 0 + state: str = "active" + created_by: Optional[str] = None + pinned: bool = False + related: list[str] = field(default_factory=list) + + +def _frontmatter(text: str) -> dict[str, Any]: + try: + from agent.skill_utils import parse_frontmatter + + fm, _ = parse_frontmatter(text) + return fm or {} + except Exception: + return {} + + +def _related(fm: dict[str, Any]) -> list[str]: + raw = fm.get("related_skills") or (fm.get("metadata", {}).get("hermes", {}) or {}).get("related_skills") + if isinstance(raw, list): + return [str(r).strip() for r in raw if str(r).strip()] + if isinstance(raw, str): + return [r.strip() for r in raw.strip("[]").split(",") if r.strip()] + return [] + + +def _category(fm: dict[str, Any], skill_md: Path) -> str: + cat = fm.get("category") or (fm.get("metadata", {}).get("hermes", {}) or {}).get("category") + if cat: + return str(cat) + # …/skills///SKILL.md + parts = skill_md.parts + return parts[-3] if len(parts) >= 3 else "general" + + +def _iter_skill_files(roots: list[tuple[str, Path]]): + for source, root in roots: + if root.exists(): + for path in root.rglob("SKILL.md"): + yield source, path + + +def _load_usage() -> dict[str, dict[str, Any]]: + try: + from tools.skill_usage import load_usage + + return load_usage() + except Exception: + path = get_hermes_home() / "skills" / ".usage.json" + try: + return json.loads(path.read_text(encoding="utf-8")) + except Exception: + return {} + + +def _to_int_ts(value: Any) -> Optional[int]: + try: + if value is None: + return None + if isinstance(value, (int, float)): + return int(value) + s = str(value).strip() + if not s: + return None + try: + return int(float(s)) + except ValueError: + parsed = datetime.fromisoformat(s.replace("Z", "+00:00")) + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return int(parsed.timestamp()) + except Exception: + return None + + +def _usage_timestamp(rec: dict[str, Any]) -> Optional[int]: + for key in ("last_activity_at", "last_used_at", "last_viewed_at", "last_patched_at", "created_at"): + ts = _to_int_ts(rec.get(key)) + if ts is not None: + return ts + return None + + +def build_skill_nodes(skill_roots: list[tuple[str, Path]]) -> dict[str, SkillNode]: + usage = _load_usage() + nodes: dict[str, SkillNode] = {} + + for source, skill_md in _iter_skill_files(skill_roots): + if any(p in {".archive", ".hub", "node_modules", ".git"} for p in skill_md.parts): + continue + try: + fm = _frontmatter(skill_md.read_text(encoding="utf-8")[:4000]) + except OSError: + continue + name = str(fm.get("name") or skill_md.parent.name).strip() + if not name or name in nodes: + continue + rec = usage.get(name, {}) + last_activity = _usage_timestamp(rec) + file_ts = _to_int_ts(skill_md.stat().st_mtime) + nodes[name] = SkillNode( + name=name, + category=_category(fm, skill_md), + source=source, + timestamp=last_activity or file_ts, + use_count=int(rec.get("use_count", 0) or 0), + state=str(rec.get("state", "active") or "active"), + created_by=rec.get("created_by"), + pinned=bool(rec.get("pinned", False)), + related=_related(fm), + ) + return nodes + + +def build_edges(nodes: dict[str, SkillNode]) -> list[tuple[str, str]]: + """Undirected related_skills edges where BOTH endpoints exist (deduped).""" + seen: set[tuple[str, str]] = set() + edges: list[tuple[str, str]] = [] + for node in nodes.values(): + for target in node.related: + if target in nodes and target != node.name: + a, b = sorted((node.name, target)) + key = (a, b) + if key not in seen: + seen.add(key) + edges.append(key) + return edges + + +def density_stats(nodes: dict[str, SkillNode], edges: list[tuple[str, str]]) -> dict[str, Any]: + linked: set[str] = set() + for a, b in edges: + linked.add(a) + linked.add(b) + cats: dict[str, int] = {} + for n in nodes.values(): + cats[n.category] = cats.get(n.category, 0) + 1 + n = len(nodes) or 1 + return { + "nodes": len(nodes), + "related_edges": len(edges), + "edges_per_node": round(len(edges) / n, 3), + "linked_nodes": len(linked), + "isolated_pct": round(100 * (n - len(linked)) / n, 1), + "categories": len(cats), + "agent_created": sum(1 for x in nodes.values() if x.created_by == "agent"), + "used": sum(1 for x in nodes.values() if x.use_count > 0), + "top_categories": sorted(cats.items(), key=lambda kv: -kv[1])[:8], + } + + +def _memory_cards() -> list[dict[str, Any]]: + """Freeform memory as readable cards. + + ``MEMORY.md`` / ``USER.md`` are prose split on bare ``§`` separators; each + chunk becomes one card. Every chunk is surfaced — the graph shows everything. + """ + base = get_hermes_home() / "memories" + cards: list[dict[str, Any]] = [] + for fname, source in (("MEMORY.md", "memory"), ("USER.md", "profile")): + path = base / fname + try: + text = path.read_text(encoding="utf-8").strip() + file_ts = _to_int_ts(path.stat().st_mtime) + except OSError: + continue + for chunk_idx, chunk in enumerate(c.strip() for c in text.split("\n§\n")): + if not chunk: + continue + first = chunk.splitlines()[0].strip().lstrip("# ").strip() + cards.append( + { + "source": source, + "timestamp": file_ts + chunk_idx if file_ts is not None else None, + "title": (first[:80] + "…") if len(first) > 80 else first, + "body": chunk[:1200], + } + ) + return cards + + +def _tokenize(text: str) -> set[str]: + return {t for t in re.split(r"[^a-z0-9]+", text.lower()) if len(t) >= 3} + + +def _memory_skill_edges(memory_cards: list[dict[str, Any]], skills: list[SkillNode]) -> list[tuple[str, str]]: + edges: list[tuple[str, str]] = [] + skill_meta = [(s, _tokenize(s.name), s.name.lower()) for s in skills] + for idx, card in enumerate(memory_cards): + mem_id = f"memory:{card['source']}:{idx}" + text = f"{card.get('title', '')}\n{card.get('body', '')}".lower() + text_tokens = _tokenize(text) + scored: list[tuple[int, str]] = [] + for skill, tokens, skill_name_lower in skill_meta: + score = 0 + if skill_name_lower in text: + score += 6 + score += len(tokens & text_tokens) + if score > 0: + scored.append((score, skill.name)) + scored.sort(key=lambda x: (-x[0], x[1])) + for _, skill_name in scored[:4]: + edges.append((mem_id, skill_name)) + return edges + + +def _skill_roots() -> list[tuple[str, Path]]: + repo = Path(__file__).resolve().parent.parent + home_skills = get_hermes_home() / "skills" + return [("base", repo / "skills"), ("profile", home_skills)] + + +def build_learning_graph() -> dict[str, Any]: + """Full payload for the desktop learning panel. + + Focus on what is profile-learned and actionable: + - skills that are NOT base-installed and show real learning signal + (agent-created or used), + - memory chunks as first-class graph nodes connected to those learned skills. + """ + all_skills = build_skill_nodes(_skill_roots()) + learned_skills = { + name: node + for name, node in all_skills.items() + if node.source != "base" and (node.created_by == "agent" or node.use_count > 0) + } + skill_edges = build_edges(learned_skills) + memory_cards = _memory_cards() + memory_edges = _memory_skill_edges(memory_cards, list(learned_skills.values())) + + edges = skill_edges + memory_edges + clusters: dict[str, int] = {} + for node in learned_skills.values(): + clusters[node.category] = clusters.get(node.category, 0) + 1 + if memory_cards: + clusters["memory"] = len(memory_cards) + + graph_nodes = [ + { + "id": n.name, + "label": n.name, + "kind": "skill", + "timestamp": n.timestamp, + "category": n.category, + "useCount": n.use_count, + "state": n.state, + "createdBy": n.created_by, + "pinned": n.pinned, + } + for n in learned_skills.values() + ] + for i, card in enumerate(memory_cards): + graph_nodes.append( + { + "id": f"memory:{card['source']}:{i}", + "label": card["title"], + "kind": "memory", + "memorySource": card["source"], + "timestamp": card.get("timestamp"), + "category": "memory", + "useCount": 0, + "state": "active", + "createdBy": "memory", + "pinned": False, + } + ) + + return { + "nodes": graph_nodes, + "edges": [{"source": a, "target": b} for a, b in edges], + "clusters": [ + {"category": c, "count": n} + for c, n in sorted(clusters.items(), key=lambda kv: -kv[1]) + ], + "memory": memory_cards, + "stats": { + **density_stats(learned_skills, skill_edges), + "memory_nodes": len(memory_cards), + "memory_skill_edges": len(memory_edges), + "learned_skills": len(learned_skills), + }, + } + + +if __name__ == "__main__": + nodes = build_skill_nodes(_skill_roots()) + print(json.dumps(density_stats(nodes, build_edges(nodes)), indent=2)) diff --git a/agent/lsp/reporter.py b/agent/lsp/reporter.py index 0eba96ba1ff9..2be1779ccedb 100644 --- a/agent/lsp/reporter.py +++ b/agent/lsp/reporter.py @@ -8,6 +8,7 @@ """ from __future__ import annotations +import html from typing import Any, Dict, List # Severity-1 only by default — warnings/info/hints would flood the @@ -18,18 +19,65 @@ MAX_PER_FILE = 20 MAX_TOTAL_CHARS = 4000 +# Per-field caps for diagnostic content sourced from the language server. +# These bound the length of any single attacker-controlled identifier that +# can ride into the model's tool output via an LSP diagnostic message. +MAX_MESSAGE_CHARS = 300 +MAX_CODE_CHARS = 80 +MAX_SOURCE_CHARS = 80 + + +def _sanitize_field(value: Any, *, limit: int) -> str: + """Make a language-server field safe to embed in a tool-result block. + + Diagnostic ``message``, ``code``, and ``source`` originate from a + language server that has just parsed user-controlled source code, so + they're untrusted from the agent's point of view. A hostile repo can + place instruction-shaped text inside identifier names, type aliases, + or import paths so the resulting diagnostic echoes that text back + into the ```` block the model reads. + + This helper: + + * Collapses CR/LF so a raw newline can't synthesize a new line in the + formatted block. + * Drops non-printable ASCII control characters that have no business + in a single-line summary. + * Caps length per-field so a long identifier can't push past the + block boundary. + * HTML-escapes ``< > &`` so the result can't close ```` + early or open a new tag. + + Returns ``""`` for ``None`` / empty so the surrounding format string + naturally omits the part (mirrors the prior ``if code not in {None, + ""}`` check at call sites). + """ + if value is None: + return "" + raw = str(value) + # Collapse newlines so identifier text with raw \n can't fake new lines. + raw = raw.replace("\r", " ").replace("\n", " ") + # Drop ASCII control chars; keep regular spaces. + raw = "".join(ch for ch in raw if ch == " " or ch.isprintable()) + raw = raw.strip()[:limit] + return html.escape(raw, quote=False) + def format_diagnostic(d: Dict[str, Any]) -> str: - """One-line representation of a single diagnostic.""" + """One-line representation of a single diagnostic. + + ``message``, ``code``, and ``source`` are sanitized before + interpolation — see ``_sanitize_field``. + """ sev = SEVERITY_NAMES.get(d.get("severity") or 1, "ERROR") rng = d.get("range") or {} start = rng.get("start") or {} line = int(start.get("line", 0)) + 1 col = int(start.get("character", 0)) + 1 - msg = str(d.get("message") or "").rstrip() - code = d.get("code") - code_part = f" [{code}]" if code not in {None, ""} else "" - source = d.get("source") + msg = _sanitize_field(d.get("message"), limit=MAX_MESSAGE_CHARS) + code = _sanitize_field(d.get("code"), limit=MAX_CODE_CHARS) + code_part = f" [{code}]" if code else "" + source = _sanitize_field(d.get("source"), limit=MAX_SOURCE_CHARS) source_part = f" ({source})" if source else "" return f"{sev} [{line}:{col}] {msg}{code_part}{source_part}" @@ -57,7 +105,11 @@ def report_for_file( body = "\n".join(lines) if extra > 0: body += f"\n... and {extra} more" - return f"\n{body}\n" + # quote=True escapes both ``"`` and ``&`` so a crafted file name like + # ``foo">\n{body}\n" def truncate(s: str, *, limit: int = MAX_TOTAL_CHARS) -> str: diff --git a/agent/moa_loop.py b/agent/moa_loop.py index 3c442d57b7c5..fcc76c2cf0f3 100644 --- a/agent/moa_loop.py +++ b/agent/moa_loop.py @@ -109,6 +109,8 @@ def _slot_runtime(slot: dict[str, str]) -> dict[str, Any]: out["base_url"] = rt["base_url"] if rt.get("api_key"): out["api_key"] = rt["api_key"] + if rt.get("api_mode"): + out["api_mode"] = rt["api_mode"] except Exception as exc: # pragma: no cover - defensive logger.debug("MoA slot runtime resolution failed for %s: %s", _slot_label(slot), exc) return out @@ -352,8 +354,14 @@ def _extract_text(response: Any) -> str: except Exception: pass try: - content = response.choices[0].message.content - return (content or "").strip() + message = response.choices[0].message + if isinstance(message, dict): + content = message.get("content") + else: + content = getattr(message, "content", message) + if not isinstance(content, str): + content = str(content) if content else "" + return content.strip() except Exception: return "" @@ -569,6 +577,24 @@ def create(self, **api_kwargs: Any) -> Any: # max_tokens is passed through from the caller (normally None → omitted # → the model's real maximum). The preset's old hardcoded 4096 default # is gone — it truncated long syntheses. + # When the agent's streaming consumer calls us with stream=True, run the + # references first (above) and then return the aggregator's RAW token + # stream so the acting model's output reaches the user live. The consumer + # reassembles chunks + tool_calls, runs stale-stream detection, and falls + # back to a non-streaming retry on error. The non-streaming path + # (stream=False) is unchanged — no stream/stream_options/timeout are + # forwarded, so its behavior is byte-for-byte identical to before. + stream = bool(api_kwargs.get("stream")) + stream_kwargs: dict[str, Any] = {} + if stream: + stream_kwargs["stream"] = True + stream_kwargs["stream_options"] = ( + api_kwargs.get("stream_options") or {"include_usage": True} + ) + # Forward the consumer's per-request (stream read) timeout so it + # actually governs the aggregator stream, not just call_llm's default. + if api_kwargs.get("timeout") is not None: + stream_kwargs["timeout"] = api_kwargs["timeout"] return call_llm( task="moa_aggregator", messages=agg_messages, @@ -576,6 +602,7 @@ def create(self, **api_kwargs: Any) -> Any: max_tokens=agg_kwargs.get("max_tokens"), tools=agg_kwargs.get("tools"), extra_body=agg_kwargs.get("extra_body"), + **stream_kwargs, **_slot_runtime(aggregator), ) diff --git a/agent/model_metadata.py b/agent/model_metadata.py index 9430a98bfb13..734febd3bf43 100644 --- a/agent/model_metadata.py +++ b/agent/model_metadata.py @@ -429,6 +429,10 @@ def _is_custom_endpoint(base_url: str) -> bool: "inference-api.nousresearch.com": "nous", "api.deepseek.com": "deepseek", "api.githubcopilot.com": "copilot", + # Enterprise Copilot endpoints look like api.enterprise.githubcopilot.com, + # api.business.githubcopilot.com, etc. Match the suffix so context-window + # resolution works for enterprise accounts too. + ".githubcopilot.com": "copilot", "models.github.ai": "copilot", # GitHub Models free tier (Azure-hosted prototyping endpoint) — same # canonical provider as the Copilot API. Hard per-request token cap @@ -1075,10 +1079,29 @@ def parse_available_output_tokens_from_error(error_msg: str) -> Optional[int]: "maximum context length" in error_lower and "requested" in error_lower and "output tokens" in error_lower + ) or ( + # DashScope / Alibaba Cloud (Qwen) phrasing. The provider rejects an + # over-cap output request with a bounded range whose upper bound IS the + # real max-output cap, e.g. + # "Range of max_tokens should be [1, 65536]" + # The input itself fits — this is purely an output-cap error, so reduce + # max_tokens and retry; do NOT compress. + "range of max_tokens should be" in error_lower ) if not is_output_cap_error: return None + # DashScope / Alibaba range form: "Range of max_tokens should be [1, 65536]". + # The upper bound is the available output cap. + _m_range = re.search( + r'range of max_tokens should be\s*\[\s*\d+\s*,\s*(\d+)\s*\]', + error_lower, + ) + if _m_range: + _cap = int(_m_range.group(1)) + if _cap >= 1: + return _cap + # Extract the available_tokens figure. # Anthropic format: "… = available_tokens: 10000" patterns = [ @@ -1125,6 +1148,70 @@ def parse_available_output_tokens_from_error(error_msg: str) -> Optional[int]: return None +def is_output_cap_error(error_msg: str) -> bool: + """Return True if a 400 is about the OUTPUT cap (max_tokens) being too large. + + This is the broader sibling of :func:`parse_available_output_tokens_from_error`: + that function only returns a number when it can extract the available output + budget from a *known* provider phrasing. This one answers the cheaper + yes/no question — "is this an output-cap error at all?" — across providers + whose exact wording we may not yet parse a number from. + + Why this matters: an output-cap 400 is deterministic (every retry with the + same ``max_tokens`` gets the identical rejection). If such an error is + misclassified as a context-overflow it gets routed into the compression + loop, the compressor re-issues the call with the same oversized + ``max_tokens``, the provider rejects it identically, and the session + death-loops until "cannot compress further" (issue #55546, DashScope/Qwen: + "Range of max_tokens should be [1, 65536]"). Compression cannot help an + output-cap error — the input already fits. + + The signal: the error talks about ``max_tokens`` (or its aliases) as a + cap/range/limit, and does NOT talk about the INPUT/prompt/context window + being too long. When both are present we defer to the context-overflow + path (a real input overflow can also mention max_tokens). + """ + error_lower = error_msg.lower() + + mentions_output_param = ( + "max_tokens" in error_lower + or "max_output_tokens" in error_lower + or "max_completion_tokens" in error_lower + ) + if not mentions_output_param: + return False + + # Phrasing that signals the OUTPUT cap specifically is the problem. + output_cap_signal = ( + "range of max_tokens should be" in error_lower # DashScope / Alibaba + or "available_tokens" in error_lower # Anthropic + or "available tokens" in error_lower + or ("in the output" in error_lower # OpenRouter / Nous + and "maximum context length" in error_lower) + or ("requested" in error_lower # LM Studio / llama.cpp + and "output tokens" in error_lower) + or "should be" in error_lower # generic "max_tokens should be <= N" + or "less than or equal" in error_lower + or "must be" in error_lower + ) + if not output_cap_signal: + return False + + # If the error ALSO clearly describes an oversized INPUT, it is a genuine + # context overflow that happens to mention max_tokens — let the + # context-overflow path handle it (it can compress the input). + input_overflow_signal = ( + "prompt is too long" in error_lower + or "prompt too long" in error_lower + or "input is too long" in error_lower + or "input token" in error_lower + or "prompt length" in error_lower + or "prompt contains" in error_lower + or "reduce the length" in error_lower + ) + return not input_overflow_signal + + def _model_id_matches(candidate_id: str, lookup_model: str) -> bool: """Return True if *candidate_id* (from server) matches *lookup_model* (configured). diff --git a/agent/redact.py b/agent/redact.py index c69003fcf663..307e5dc3adfe 100644 --- a/agent/redact.py +++ b/agent/redact.py @@ -106,6 +106,7 @@ r"brv_[A-Za-z0-9]{10,}", # ByteRover API key r"xai-[A-Za-z0-9]{30,}", # xAI (Grok) API key r"ntn_[A-Za-z0-9]{10,}", # Notion internal integration token + r"fw_[A-Za-z0-9]{30,}", # Fireworks AI API key ] # ENV assignment patterns: KEY=value where KEY contains a secret-like name. diff --git a/agent/shell_hooks.py b/agent/shell_hooks.py index a48bab42bb8a..3f155f20465c 100644 --- a/agent/shell_hooks.py +++ b/agent/shell_hooks.py @@ -588,6 +588,17 @@ def _parse_response(event: str, stdout: str) -> Optional[Dict[str, Any]]: return {"action": "block", "message": _block_message(data.get("reason"), data.get("message"))} return None + if event == "pre_verify": + # "continue" (Hermes) / "block" (Claude-Code Stop: block the stop) both + # mean keep going; the message/reason is the follow-up for the model. A + # continue with no message is a no-op — let the turn finish. + action = str(data.get("action") or data.get("decision") or "").strip().lower() + if action in {"continue", "block"}: + message = data.get("message") or data.get("reason") + if isinstance(message, str) and message.strip(): + return {"action": "continue", "message": message.strip()} + return None + context = data.get("context") if isinstance(context, str) and context.strip(): return {"context": context} diff --git a/agent/tool_executor.py b/agent/tool_executor.py index 6845f79195e0..167a60946b2c 100644 --- a/agent/tool_executor.py +++ b/agent/tool_executor.py @@ -24,6 +24,7 @@ from agent.display import ( KawaiiSpinner, build_tool_preview as _build_tool_preview, + build_tool_label as _build_tool_label, get_cute_tool_message as _get_cute_tool_message_impl, get_tool_emoji as _get_tool_emoji, redact_tool_args_for_display as _redact_tool_args_for_display, @@ -1224,7 +1225,7 @@ def _execute(next_args: dict) -> Any: face = random.choice(KawaiiSpinner.get_waiting_faces()) emoji = _get_tool_emoji(function_name) display_args = _redact_tool_args_for_display(function_name, function_args) or function_args - preview = _build_tool_preview(function_name, display_args) or function_name + preview = _build_tool_label(function_name, display_args) or function_name spinner = KawaiiSpinner(f"{face} {emoji} {preview}", spinner_type='dots', print_fn=agent._print_fn) spinner.start() _ce_result = None @@ -1258,7 +1259,7 @@ def _execute(next_args: dict) -> Any: face = random.choice(KawaiiSpinner.get_waiting_faces()) emoji = _get_tool_emoji(function_name) display_args = _redact_tool_args_for_display(function_name, function_args) or function_args - preview = _build_tool_preview(function_name, display_args) or function_name + preview = _build_tool_label(function_name, display_args) or function_name spinner = KawaiiSpinner(f"{face} {emoji} {preview}", spinner_type='dots', print_fn=agent._print_fn) spinner.start() _mem_result = None @@ -1290,7 +1291,7 @@ def _execute(next_args: dict) -> Any: face = random.choice(KawaiiSpinner.get_waiting_faces()) emoji = _get_tool_emoji(function_name) display_args = _redact_tool_args_for_display(function_name, function_args) or function_args - preview = _build_tool_preview(function_name, display_args) or function_name + preview = _build_tool_label(function_name, display_args) or function_name spinner = KawaiiSpinner(f"{face} {emoji} {preview}", spinner_type='dots', print_fn=agent._print_fn) spinner.start() _spinner_result = None diff --git a/agent/transports/chat_completions.py b/agent/transports/chat_completions.py index 42e81dc30e7c..878045da66d4 100644 --- a/agent/transports/chat_completions.py +++ b/agent/transports/chat_completions.py @@ -619,7 +619,7 @@ def normalize_response(self, response: Any, **kwargs) -> NormalizedResponse: tc_provider_data: dict[str, Any] = {} extra = getattr(tc, "extra_content", None) if extra is None and hasattr(tc, "model_extra"): - extra = (tc.model_extra or {}).get("extra_content") + extra = (tc.model_extra if isinstance(tc.model_extra, dict) else {}).get("extra_content") if extra is not None: if hasattr(extra, "model_dump"): try: diff --git a/agent/turn_context.py b/agent/turn_context.py index 189771511b62..88980b4ad276 100644 --- a/agent/turn_context.py +++ b/agent/turn_context.py @@ -223,6 +223,9 @@ def build_turn_context( agent._unicode_sanitization_passes = 0 agent._tool_guardrails.reset_for_turn() agent._tool_guardrail_halt_decision = None + _reset_consol = getattr(agent._memory_store, "reset_consolidation_failures", None) + if callable(_reset_consol): + _reset_consol() agent._vision_supported = True # Pre-turn connection health check: clean up dead TCP connections. @@ -360,6 +363,12 @@ def build_turn_context( if _last >= 0 and _preflight_tokens > _last: _compressor.last_prompt_tokens = _preflight_tokens + _compression_cooldown = getattr( + _compressor, + "get_active_compression_failure_cooldown", + lambda: None, + )() + if _preflight_deferred: logger.info( "Skipping preflight compression: rough estimate ~%s >= %s, " @@ -368,6 +377,13 @@ def build_turn_context( f"{_compressor.threshold_tokens:,}", f"{_compressor.last_real_prompt_tokens:,}", ) + elif _compression_cooldown: + logger.info( + "Skipping preflight compression: same-session cooldown active " + "(~%s seconds remaining, session %s)", + int(_compression_cooldown.get("remaining_seconds", 0.0)), + agent.session_id or "none", + ) elif _compressor.should_compress(_preflight_tokens): logger.info( "Preflight compression: ~%s tokens >= %s threshold (model %s, ctx %s)", @@ -443,6 +459,7 @@ def build_turn_context( agent._turn_failed_file_mutations = {} agent._turn_file_mutation_paths = set() agent._verification_stop_nudges = 0 + agent._pre_verify_nudges = 0 # Record the execution thread so interrupt()/clear_interrupt() can scope # the tool-level interrupt signal to THIS agent's thread only. diff --git a/agent/verification_stop.py b/agent/verification_stop.py index 8824267f6944..605d58d3a7de 100644 --- a/agent/verification_stop.py +++ b/agent/verification_stop.py @@ -137,12 +137,12 @@ def verify_on_stop_enabled(config: dict[str, Any] | None = None) -> bool: Precedence: an explicit ``HERMES_VERIFY_ON_STOP`` env var wins, then an explicit ``agent.verify_on_stop`` config value. The config default is - ``False`` (see ``DEFAULT_CONFIG``) — verify-on-stop is OFF unless the user - opts in. The legacy ``"auto"`` sentinel is still honored for anyone who - sets it explicitly: it resolves to ON for interactive coding surfaces - (CLI, TUI, desktop) and programmatic callers, and OFF for conversational - messaging surfaces (Telegram, Discord, etc.). A missing/unknown value - falls back to OFF. + ``"auto"`` (see ``DEFAULT_CONFIG``) — surface-aware: ON for interactive + coding surfaces (CLI, TUI, desktop) and programmatic callers, OFF for + conversational messaging surfaces (Telegram, Discord, etc.) where the + verification narrative would reach a human as chat noise. An explicit + bool forces the behavior in either direction. A missing or unrecognized + value falls back to the surface-aware ``"auto"`` default. """ env = os.environ.get("HERMES_VERIFY_ON_STOP") if env is not None: @@ -165,10 +165,9 @@ def verify_on_stop_enabled(config: dict[str, Any] | None = None) -> bool: if token in {"0", "false", "no", "off"}: return False if token == "auto": - # Explicit opt-in to the legacy surface-aware behavior. return not _session_is_messaging_surface() - # Missing or unknown value -> OFF (the new default). - return False + # Missing or unrecognized value -> surface-aware "auto" default. + return not _session_is_messaging_surface() def _candidate_cwds(paths: Iterable[str]) -> list[Path]: @@ -273,6 +272,15 @@ def build_verify_on_stop_nudge( if state == "passed": return None + # Optional shipped coding guidance, only paid when this evidence gate fires. + try: + from agent.verify_hooks import coding_verify_guidance + + guidance = coding_verify_guidance() + except Exception: + guidance = None + addendum = f"\n\n{guidance}" if guidance else "" + if verify_commands: command_instruction = ( "Run the relevant verification command now (" @@ -297,7 +305,8 @@ def build_verify_on_stop_nudge( f"Verification status: {_status_detail(status)}\n\n" f"Changed paths:\n{_format_changed_paths(paths)}\n\n" f"{command_instruction} If verification is not possible, explain the " - "concrete blocker instead of claiming the work is fully verified.]" + "concrete blocker instead of claiming the work is fully verified." + f"{addendum}]" ) diff --git a/agent/verify_hooks.py b/agent/verify_hooks.py new file mode 100644 index 000000000000..e051080202c8 --- /dev/null +++ b/agent/verify_hooks.py @@ -0,0 +1,69 @@ +"""Verification-loop helpers for the ``pre_verify`` round-end gate. + +When the agent has edited code and is about to verify/finish, the loop fires the +``pre_verify`` hook (user directives resolved by +:func:`hermes_cli.plugins.get_pre_verify_continue_message`). A directive keeps +the agent going one more turn — run a check, defer it, tidy the diff — instead of +stopping immediately. + +The shipped coding guidance lives on the evidence-based verification-stop nudge +(``agent/verification_stop.py``), not as a second default stop gate. That keeps +the default token cost tied to the existing "missing verification evidence" +decision while preserving ``pre_verify`` for user/plugin policy. +""" + +from __future__ import annotations + +from typing import Any, Optional + +from utils import is_truthy_value + +DEFAULT_MAX_VERIFY_NUDGES = 3 + +# Shipped guidance appended to the verification-stop nudge when code lacks fresh +# verification evidence. Wording mirrors the user-facing "clean your work" +# workflow, but does not create its own extra model turn. +CODING_VERIFY_GUIDANCE = ( + "[Coding] Before you run tests/linters or call this done: if this is " + "creative UI/visual work, hold off on tests and linters until the user says " + "they like the result or you're about to commit. And before every commit, " + "clean your work: keep it KISS/DRY, match the surrounding code style, and be " + "elitist, shorthand, clever, concise, efficient, and elegant." +) + + +def max_verify_nudges(config: Optional[dict[str, Any]] = None) -> int: + """Bound on consecutive ``pre_verify`` continue directives per turn (>= 0).""" + agent_cfg = _agent_cfg(config) + raw = agent_cfg.get("max_verify_nudges") + try: + return max(0, int(raw)) + except (TypeError, ValueError): + return DEFAULT_MAX_VERIFY_NUDGES + + +def coding_verify_guidance(config: Optional[dict[str, Any]] = None) -> Optional[str]: + """Return the optional guidance appended to verification-stop nudges.""" + if not is_truthy_value(_agent_cfg(config).get("verify_guidance", True), default=True): + return None + return CODING_VERIFY_GUIDANCE + + +def _agent_cfg(config: Optional[dict[str, Any]]) -> dict[str, Any]: + if config is None: + try: + from hermes_cli.config import load_config + + config = load_config() + except Exception: + config = {} + agent_cfg = (config or {}).get("agent") if isinstance(config, dict) else None + return agent_cfg if isinstance(agent_cfg, dict) else {} + + +__all__ = [ + "CODING_VERIFY_GUIDANCE", + "DEFAULT_MAX_VERIFY_NUDGES", + "coding_verify_guidance", + "max_verify_nudges", +] diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index e800034500b4..a6b70872632d 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -5108,13 +5108,24 @@ function resetBootProgressForReconnect() { ) } +function stopBackendChild(child) { + if (!child || child.killed) return + try { + if (IS_WINDOWS && Number.isInteger(child.pid)) { + forceKillProcessTree(child.pid) + } else { + child.kill('SIGTERM') + } + } catch { + // Already gone. + } +} + function resetHermesConnection() { connectionPromise = null backendStartFailure = null - if (hermesProcess && !hermesProcess.killed) { - hermesProcess.kill('SIGTERM') - } + stopBackendChild(hermesProcess) hermesProcess = null resetBootProgressForReconnect() @@ -5362,13 +5373,7 @@ function stopPoolBackend(profile) { const entry = backendPool.get(profile) if (!entry) return backendPool.delete(profile) - if (entry.process && !entry.process.killed) { - try { - entry.process.kill('SIGTERM') - } catch { - // Already gone. - } - } + stopBackendChild(entry.process) } async function teardownPoolBackendAndWait(profile) { @@ -5376,13 +5381,7 @@ async function teardownPoolBackendAndWait(profile) { if (!entry) return backendPool.delete(profile) - if (entry.process && !entry.process.killed) { - try { - entry.process.kill('SIGTERM') - } catch { - // Already gone. - } - } + stopBackendChild(entry.process) await waitForBackendExit(entry.process) } @@ -7600,9 +7599,7 @@ app.on('before-quit', () => { disposeTerminalSession(id) } - if (hermesProcess && !hermesProcess.killed) { - hermesProcess.kill('SIGTERM') - } + stopBackendChild(hermesProcess) stopAllPoolBackends() }) diff --git a/apps/desktop/electron/windows-child-process.test.cjs b/apps/desktop/electron/windows-child-process.test.cjs index 473fd0b2e0b6..c15dc3b7b505 100644 --- a/apps/desktop/electron/windows-child-process.test.cjs +++ b/apps/desktop/electron/windows-child-process.test.cjs @@ -74,6 +74,29 @@ test('desktop backend launches console python so child consoles are inherited, n requireHiddenChildOptions(source, /hermesProcess = spawn\(\s*backend\.command,\s*backend\.args/) }) +test('desktop backend teardown tree-kills Windows backend descendants', () => { + const source = readElectronFile('main.cjs') + + const helperIndex = source.indexOf('function stopBackendChild(child)') + assert.notEqual(helperIndex, -1, 'missing backend teardown helper') + const helperSnippet = source.slice(helperIndex, helperIndex + 500) + assert.match(helperSnippet, /IS_WINDOWS && Number\.isInteger\(child\.pid\)/) + assert.match(helperSnippet, /forceKillProcessTree\(child\.pid\)/) + assert.match(helperSnippet, /child\.kill\('SIGTERM'\)/) + + const resetIndex = source.indexOf('function resetHermesConnection()') + assert.notEqual(resetIndex, -1, 'missing resetHermesConnection') + const resetSnippet = source.slice(resetIndex, resetIndex + 300) + assert.match(resetSnippet, /stopBackendChild\(hermesProcess\)/) + assert.doesNotMatch(resetSnippet, /hermesProcess\.kill\('SIGTERM'\)/) + + const quitIndex = source.indexOf("app.on('before-quit'") + assert.notEqual(quitIndex, -1, 'missing before-quit handler') + const quitSnippet = source.slice(quitIndex, quitIndex + 900) + assert.match(quitSnippet, /stopBackendChild\(hermesProcess\)/) + assert.doesNotMatch(quitSnippet, /hermesProcess\.kill\('SIGTERM'\)/) +}) + test('intentional or interactive desktop child processes stay documented', () => { const source = readElectronFile('main.cjs') diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 4bf4eaade965..6fd35c0fbc47 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -81,8 +81,10 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", + "d3-force": "^3.0.0", "dnd-core": "^14.0.1", "dompurify": "^3.4.11", + "fflate": "^0.8.3", "hast-util-from-html-isomorphic": "^2.0.0", "hast-util-to-text": "^4.0.2", "ignore": "^7.0.5", @@ -118,6 +120,7 @@ "@eslint/js": "^9.39.4", "@testing-library/dom": "^10.4.0", "@testing-library/react": "^16.3.2", + "@types/d3-force": "^3.0.10", "@types/hast": "^3.0.4", "@types/node": "^24.13.2", "@types/react": "^19.2.14", diff --git a/apps/desktop/scripts/.gitignore b/apps/desktop/scripts/.gitignore new file mode 100644 index 000000000000..646f02ffc794 --- /dev/null +++ b/apps/desktop/scripts/.gitignore @@ -0,0 +1 @@ +share-codes.txt diff --git a/apps/desktop/scripts/gen-share-codes.ts b/apps/desktop/scripts/gen-share-codes.ts new file mode 100644 index 000000000000..8de54582461f --- /dev/null +++ b/apps/desktop/scripts/gen-share-codes.ts @@ -0,0 +1,171 @@ +// Throwaway generator: deterministic fake star-map graphs → real share codes +// (runs the actual encoder, so every string round-trips). Run with `npx tsx`. +import { writeFileSync } from 'node:fs' + +import type { StarmapEdge, StarmapGraph, StarmapMemoryCard, StarmapNode } from '../src/types/hermes' + +import { decodeShareCode, encodeShareCode } from '../src/app/starmap/share-code' + +const DAY = 86_400 +const END = Math.floor(Date.UTC(2026, 5, 29) / 1000) + +// mulberry32 — tiny seeded PRNG so the output is byte-stable across runs. +const rng = (seed: number) => () => { + seed |= 0 + seed = (seed + 0x6d2b79f5) | 0 + let t = Math.imul(seed ^ (seed >>> 15), 1 | seed) + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t + + return ((t ^ (t >>> 14)) >>> 0) / 4_294_967_296 +} + +const pick = (arr: readonly T[], r: number): T => arr[Math.floor(r * arr.length)]! + +const CATEGORIES = ['devops', 'research', 'creative', 'security', 'mlops', 'blockchain', 'email', 'health', 'web-development', 'comms'] as const +const STATES = ['active', 'active', 'active', 'archived', 'draft', 'disabled'] as const +const CREATED = [null, 'agent', 'agent', 'user'] as const + +const skill = (id: string, label: string, ts: number, r: () => number): StarmapNode => ({ + category: pick(CATEGORIES, r()), + createdBy: pick(CREATED, r()), + id, + kind: 'skill', + label, + pinned: r() > 0.85, + state: pick(STATES, r()), + timestamp: ts, + useCount: Math.floor(r() ** 3 * 120) +}) + +const memNode = (i: number, source: 'memory' | 'profile', label: string, ts: null | number): StarmapNode => ({ + category: 'memory', + createdBy: 'memory', + id: `memory:${source}:${i}`, + kind: 'memory', + label, + memorySource: source, + pinned: false, + state: 'active', + timestamp: ts, + useCount: 0 +}) + +const card = (source: 'memory' | 'profile', title: string, body: string, ts: null | number): StarmapMemoryCard => ({ body, source, timestamp: ts, title }) + +// ── 1. Tiny + quirky ────────────────────────────────────────────────────────── +function tiny(): StarmapGraph { + const r = rng(7) + const nodes: StarmapNode[] = [ + skill('summon-coffee', 'Summon Coffee', END - 40 * DAY, r), + skill('rubber-duck', 'Rubber-Duck Debugging', END - 22 * DAY, r), + skill('git-blame-zen', 'Git Blame Without Rage', END - 9 * DAY, r), + memNode(0, 'profile', 'Prefers tabs, dies on this hill', END - 30 * DAY), + memNode(1, 'memory', 'The prod incident of last Tuesday', END - 3 * DAY) + ] + const edges: StarmapEdge[] = [ + { source: 'memory:memory:1', target: 'git-blame-zen' }, + { source: 'rubber-duck', target: 'git-blame-zen' } + ] + const memory = [ + card('profile', 'Prefers tabs, dies on this hill', 'Tabs over spaces. Non-negotiable.', END - 30 * DAY), + card('memory', 'The prod incident of last Tuesday', 'Never deploy on a Friday again.', END - 3 * DAY) + ] + + return { clusters: [], edges, memory, nodes, stats: {} } +} + +// ── 2. Mid-size, mixed signal ──────────────────────────────────────────────── +function mid(): StarmapGraph { + const r = rng(42) + const names = ['Kubernetes Whispering', 'Prompt Surgery', 'Threat Modeling', 'Pixel Pushing', 'Vector Janitor', 'Smart-Contract Audit', 'Inbox Zero Ops', 'Sleep Debt Tracker', 'SSR Hydration', 'Standup Telepathy', 'Flaky-Test Exorcism', 'Cost Spelunking'] + const nodes: StarmapNode[] = names.map((label, i) => skill(`s${i}`, label, END - Math.floor(r() * 200) * DAY, r)) + const memTitles = ['Hates meetings before noon', 'Lives in us-east-1', 'Allergic to YAML', 'Caffeine half-life ~5h', 'Reviews in dark mode'] + + memTitles.forEach((title, i) => { + const ts = END - Math.floor(r() * 120) * DAY + nodes.push(memNode(i, i % 2 ? 'memory' : 'profile', title, ts)) + }) + + const edges: StarmapEdge[] = [] + + for (let i = 0; i < 9; i += 1) { + edges.push({ source: `s${Math.floor(r() * names.length)}`, target: `s${Math.floor(r() * names.length)}` }) + } + + const memory = memTitles.map((title, i) => card(i % 2 ? 'memory' : 'profile', title, `${title}. Logged automatically.`, END - Math.floor(rng(99 + i)() * 120) * DAY)) + + return { clusters: [], edges, memory, nodes, stats: {} } +} + +// ── 3. Dense web, partly undated (ordinal fallback) ────────────────────────── +function web(): StarmapGraph { + const r = rng(1337) + const nodes: StarmapNode[] = Array.from({ length: 22 }, (_, i) => + // Half the skills carry no timestamp → exercises the ordinal recency path. + skill(`w${i}`, `Neuron ${String.fromCharCode(65 + (i % 26))}${i}`, i % 2 ? END - Math.floor(r() * 300) * DAY : (null as unknown as number), r) + ) + const edges: StarmapEdge[] = [] + + for (let i = 0; i < 44; i += 1) { + edges.push({ source: `w${Math.floor(r() * 22)}`, target: `w${Math.floor(r() * 22)}` }) + } + + return { clusters: [], edges, memory: [], nodes, stats: {} } +} + +// ── 4. The beast: ~2 years, hundreds of nodes, bursty timeline ─────────────── +function beast(): StarmapGraph { + const r = rng(2024) + const start = END - 730 * DAY + const span = END - start + const nodes: StarmapNode[] = [] + const memory: StarmapMemoryCard[] = [] + + // Bursts → an interesting waveform instead of a flat smear. + const burstAt = (q: number) => Math.floor(start + (q + (r() - 0.5) * 0.06) * span) + + for (let i = 0; i < 240; i += 1) { + const burst = Math.floor(r() ** 1.5 * 12) / 12 // cluster toward the recent end + nodes.push(skill(`b${i}`, `Skill ${i} · ${pick(CATEGORIES, r())}`, burstAt(burst), r)) + } + + for (let i = 0; i < 150; i += 1) { + const ts = burstAt(Math.floor(r() ** 1.5 * 12) / 12) + const source = r() > 0.5 ? 'memory' : 'profile' + nodes.push(memNode(i, source, `Memory ${i}: ${pick(['quirk', 'fact', 'preference', 'incident', 'lesson'], r())}`, ts)) + memory.push(card(source, `Memory ${i}`, `Auto-captured note #${i}.`, ts)) + } + + const edges: StarmapEdge[] = [] + + for (let i = 0; i < 380; i += 1) { + const a = Math.floor(r() * 240) + const b = Math.floor(r() * 240) + + if (a !== b) { + edges.push({ source: `b${a}`, target: `b${b}` }) + } + } + + return { clusters: [], edges, memory, nodes, stats: {} } +} + +const graphs: [string, StarmapGraph][] = [ + ['tiny + quirky', tiny()], + ['mid · mixed signal', mid()], + ['dense web · half undated', web()], + ['the beast · ~2 years', beast()] +] + +const lines: string[] = [] + +for (const [name, g] of graphs) { + const code = encodeShareCode(g) + const back = decodeShareCode(code) // round-trip assert — throws if invalid + // v2 is viz-only: nodes + edge topology survive; memory prose is dropped. + const ok = back.nodes.length === g.nodes.length && back.edges.length <= g.edges.length + console.log(`${ok ? 'ok ' : 'BAD'} ${name} — ${g.nodes.length} nodes / ${g.edges.length} edges / ${g.memory.length} cards (${code.length} chars)`) + lines.push(`# ${name} — ${g.nodes.length} nodes, ${g.edges.length} edges, ${g.memory.length} cards`, code, '') +} + +writeFileSync(new URL('share-codes.txt', import.meta.url), lines.join('\n')) diff --git a/apps/desktop/src/app/artifacts/index.tsx b/apps/desktop/src/app/artifacts/index.tsx index d76cc2baee40..f7d9e3238e30 100644 --- a/apps/desktop/src/app/artifacts/index.tsx +++ b/apps/desktop/src/app/artifacts/index.tsx @@ -16,6 +16,7 @@ import { PaginationNext, PaginationPrevious } from '@/components/ui/pagination' +import { RowButton } from '@/components/ui/row-button' import { TextTab, TextTabMeta } from '@/components/ui/text-tab' import { Tip } from '@/components/ui/tooltip' import { getSessionMessages, listAllProfileSessions } from '@/hermes' @@ -761,13 +762,12 @@ function ArtifactCellAction({ } return ( - + ) } diff --git a/apps/desktop/src/app/chat/composer/composer-utils.test.ts b/apps/desktop/src/app/chat/composer/composer-utils.test.ts new file mode 100644 index 000000000000..9fc5f5b5730c --- /dev/null +++ b/apps/desktop/src/app/chat/composer/composer-utils.test.ts @@ -0,0 +1,40 @@ +import type { Unstable_TriggerItem } from '@assistant-ui/core' +import { describe, expect, it } from 'vitest' + +import { pickPlaceholder, slashArgStage, slashChipKindForItem, slashCommandToken } from './composer-utils' + +const item = (group: string): Unstable_TriggerItem => + ({ id: 'x', type: 'slash', label: 'x', metadata: { group } }) as unknown as Unstable_TriggerItem + +describe('slashArgStage', () => { + it('is true only once the query is past the command name', () => { + expect(slashArgStage('personality')).toBe(false) + expect(slashArgStage('personality alice')).toBe(true) + }) +}) + +describe('slashCommandToken', () => { + it('extracts the lowercased /command token', () => { + expect(slashCommandToken('Personality alice')).toBe('/personality') + expect(slashCommandToken('model')).toBe('/model') + }) + + it('handles an empty query', () => { + expect(slashCommandToken('')).toBe('/') + }) +}) + +describe('slashChipKindForItem', () => { + it('maps completion groups to chip kinds', () => { + expect(slashChipKindForItem(item('Skills'))).toBe('skill') + expect(slashChipKindForItem(item('Themes'))).toBe('theme') + expect(slashChipKindForItem(item('Commands'))).toBe('command') + }) +}) + +describe('pickPlaceholder', () => { + it('returns a member of the pool', () => { + const pool = ['a', 'b', 'c'] as const + expect(pool).toContain(pickPlaceholder(pool)) + }) +}) diff --git a/apps/desktop/src/app/chat/composer/composer-utils.ts b/apps/desktop/src/app/chat/composer/composer-utils.ts new file mode 100644 index 000000000000..ad7b63787fd0 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/composer-utils.ts @@ -0,0 +1,60 @@ +import type { Unstable_TriggerItem } from '@assistant-ui/core' + +import type { SlashChipKind } from '@/components/assistant-ui/directive-text' +import type { ComposerAttachment } from '@/store/composer' +import { setSessionPickerOpen } from '@/store/session' + +export const COMPOSER_STACK_BREAKPOINT_PX = 320 + +// A single editor line is ~28px (--composer-input-min-height 1.625rem + 0.5rem +// vertical padding). Anything taller means the text wrapped to a second line, +// which is when the composer should expand to the stacked layout. +export const COMPOSER_SINGLE_LINE_MAX_PX = 36 + +export const COMPOSER_FADE_BACKGROUND = + 'linear-gradient(to bottom, transparent, color-mix(in srgb, var(--dt-background) 10%, transparent))' + +// Quiet period after the last keystroke before persisting the draft; +// unmount/pagehide flushes bypass it. +export const DRAFT_PERSIST_DEBOUNCE_MS = 400 + +export const pickPlaceholder = (pool: readonly string[]) => pool[Math.floor(Math.random() * pool.length)] + +/** Completion items can carry an `action` (set in use-slash-completions) that + * runs a side effect on pick instead of inserting a chip — e.g. the session + * picker's "Browse all…" entry opens the overlay. Table-driven so new action + * items are a registry row, not a composer branch. */ +export const COMPLETION_ACTIONS: Record void> = { + 'session-picker': () => setSessionPickerOpen(true) +} + +/** Map a picked `/` completion to its pill accent. Driven by the completion + * group set in use-slash-completions (Skills / Themes / Commands|Options). */ +export function slashChipKindForItem(item: Unstable_TriggerItem): SlashChipKind { + const group = (item.metadata as { group?: unknown } | undefined)?.group + + if (group === 'Skills') { + return 'skill' + } + + if (group === 'Themes') { + return 'theme' + } + + return 'command' +} + +/** A `/` query is at its arg stage once it's past the command name. */ +export const slashArgStage = (query: string) => query.includes(' ') + +/** The `/command` token of a slash query (`personality x` → `/personality`). */ +export const slashCommandToken = (query: string) => `/${query.split(/\s+/, 1)[0]?.toLowerCase() ?? ''}` + +export interface QueueEditState { + attachments: ComposerAttachment[] + draft: string + entryId: string + sessionKey: string +} + +export const cloneAttachments = (attachments: ComposerAttachment[]) => attachments.map(a => ({ ...a })) diff --git a/apps/desktop/src/app/chat/composer/controls.tsx b/apps/desktop/src/app/chat/composer/controls.tsx index 7bef1e827674..8bc7abc4acc1 100644 --- a/apps/desktop/src/app/chat/composer/controls.tsx +++ b/apps/desktop/src/app/chat/composer/controls.tsx @@ -4,7 +4,7 @@ import { KbdCombo } from '@/components/ui/kbd' import { Tip } from '@/components/ui/tooltip' import { useI18n } from '@/i18n' import { triggerHaptic } from '@/lib/haptics' -import { AudioLines, Layers3, Loader2, Square, SteeringWheel } from '@/lib/icons' +import { AudioLines, iconSize, Layers3, Loader2, Square, SteeringWheel, Volume2, VolumeX } from '@/lib/icons' import { formatCombo } from '@/lib/keybinds/combo' import { cn } from '@/lib/utils' @@ -39,6 +39,7 @@ interface ConversationProps { } export function ComposerControls({ + autoSpeak, busy, busyAction, canSteer, @@ -50,8 +51,10 @@ export function ComposerControls({ state, voiceStatus, onDictate, - onSteer + onSteer, + onToggleAutoSpeak }: { + autoSpeak: boolean busy: boolean busyAction: 'queue' | 'stop' canSteer: boolean @@ -64,6 +67,7 @@ export function ComposerControls({ voiceStatus: VoiceStatus onDictate: () => void onSteer: () => void + onToggleAutoSpeak: () => void }) { const { t } = useI18n() const c = t.composer @@ -99,12 +103,13 @@ export function ComposerControls({ type="button" variant="ghost" > - + ) : ( )} + {showVoicePrimary ? ( ) : ( @@ -131,7 +136,7 @@ export function ComposerControls({ > {busy ? ( busyAction === 'queue' ? ( - + ) : ( ) @@ -202,7 +207,7 @@ function ConversationPill({ type="button" variant="ghost" > - + {c.stopShort} )} @@ -237,7 +242,7 @@ function ConversationIndicator({ speaking: boolean }) { if (speaking) { - return + return } const bars = [0.55, 0.85, 1, 0.85, 0.55] @@ -254,6 +259,39 @@ function ConversationIndicator({ ) } +// Pure-TTS toggle: type normally, but have every assistant reply read aloud — +// no dictation, no full conversation loop. Filled/accent when on, mirroring the +// muted-mic pressed state above. Driven by (and persisted to) `voice.auto_tts`. +function AutoSpeakButton({ active, disabled, onToggle }: { active: boolean; disabled: boolean; onToggle: () => void }) { + const { t } = useI18n() + const c = t.composer + const label = active ? c.stopSpeakingReplies : c.speakReplies + + return ( + + + + ) +} + function DictationButton({ disabled, state, @@ -295,9 +333,9 @@ function DictationButton({ variant="ghost" > {status === 'recording' ? ( - + ) : status === 'transcribing' ? ( - + ) : ( )} diff --git a/apps/desktop/src/app/chat/composer/hooks/use-auto-speak-replies.ts b/apps/desktop/src/app/chat/composer/hooks/use-auto-speak-replies.ts new file mode 100644 index 000000000000..c3268bc9cbd3 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/hooks/use-auto-speak-replies.ts @@ -0,0 +1,79 @@ +import { useStore } from '@nanostores/react' +import { useEffect, useRef } from 'react' + +import { playSpeechText } from '@/lib/voice-playback' +import { notifyError } from '@/store/notifications' +import { $messages } from '@/store/session' +import { $voicePlayback } from '@/store/voice-playback' +import { $autoSpeakReplies } from '@/store/voice-prefs' + +interface AutoSpeakReply { + id: string + pending: boolean + text: string +} + +interface UseAutoSpeakReplies { + conversationActive: boolean + failureLabel: string + /** Mark the current last reply spoken — shared dedupe with the conversation consumer. */ + markSpoken: () => void + /** Latest completed assistant reply, or null; `pending` true while still streaming. */ + pendingReply: () => AutoSpeakReply | null + /** Re-arm on session switch so opening a chat never reads its existing last reply. */ + sessionId: string | null | undefined +} + +/** + * Pure-TTS auto-speak: when `voice.auto_tts` is on, read each completed assistant + * turn aloud — no dictation, no conversation loop. Stays off while a full voice + * conversation runs (it speaks replies itself) and never overlaps clips: a reply + * landing mid-playback is held and spoken on the playback-idle edge. Always reads + * the latest reply, so a backlog collapses to the newest. + */ +export function useAutoSpeakReplies({ + conversationActive, + failureLabel, + markSpoken, + pendingReply, + sessionId +}: UseAutoSpeakReplies) { + const enabled = useStore($autoSpeakReplies) + const latest = useRef({ conversationActive, failureLabel, markSpoken, pendingReply }) + latest.current = { conversationActive, failureLabel, markSpoken, pendingReply } + + useEffect(() => { + if (!enabled) { + return undefined + } + + // Don't read whatever reply already sits at the bottom when the toggle flips + // on (or a chat opens) — consume it so only later replies are spoken. + latest.current.markSpoken() + + const speakLatest = () => { + const { conversationActive, failureLabel, markSpoken, pendingReply } = latest.current + + if (conversationActive || $voicePlayback.get().status !== 'idle') { + return + } + + const reply = pendingReply() + + if (!reply || reply.pending) { + return + } + + markSpoken() + void playSpeechText(reply.text, { messageId: reply.id, source: 'read-aloud' }).catch(error => + notifyError(error, failureLabel) + ) + } + + // Re-check on a reply completing ($messages) and on the prior clip ending + // ($voicePlayback → idle), which frees us to read the next held reply. + const stops = [$messages.subscribe(speakLatest), $voicePlayback.listen(speakLatest)] + + return () => stops.forEach(f => f()) + }, [enabled, sessionId]) +} diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts new file mode 100644 index 000000000000..5f8bcf8e2330 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts @@ -0,0 +1,344 @@ +import { useAui, useAuiState, useComposerRuntime } from '@assistant-ui/react' +import { type RefObject, useCallback, useEffect, useRef, useState } from 'react' + +import { SLASH_COMMAND_RE } from '@/lib/chat-runtime' +import { $composerAttachments, type ComposerAttachment, stashSessionDraft, takeSessionDraft } from '@/store/composer' +import { isBrowsingHistory } from '@/store/composer-input-history' + +import { cloneAttachments, DRAFT_PERSIST_DEBOUNCE_MS, type QueueEditState } from '../composer-utils' +import { + type ComposerInsertMode, + focusComposerInput, + markActiveComposer, + onComposerFocusRequest, + onComposerInsertRefsRequest, + onComposerInsertRequest +} from '../focus' +import { type InlineRefInput, insertInlineRefsIntoEditor } from '../inline-refs' +import { composerPlainText, placeCaretEnd, renderComposerContents } from '../rich-editor' +import type { ChatBarProps } from '../types' + +interface UseComposerDraftArgs { + activeQueueSessionKey: string | null + focusKey: ChatBarProps['focusKey'] + inputDisabled: boolean + queueEditRef: RefObject + sessionId: string | null | undefined +} + +/** + * The composer's draft engine — the detached source-of-truth spine. The live + * text lives in the contentEditable DOM + `draftRef`; React only sees coarse + * edge selectors, so typing never re-renders the chrome. Owns the imperative + * composer-runtime subscription (draftRef mirror + external repaint + debounced + * per-session stash), the edit primitives (append/insert/inline-refs), focus, + * and per-session load/clear/stash/restore. The contentEditable *event* + * handlers stay in ChatBar (they bridge into the trigger engine) and drive the + * primitives exposed here. + */ +export function useComposerDraft({ + activeQueueSessionKey, + focusKey, + inputDisabled, + queueEditRef, + sessionId +}: UseComposerDraftArgs) { + const aui = useAui() + const composerRuntime = useComposerRuntime() + + // Coarse edges only — these flip rarely (empty↔non-empty, the `?` help sigil, + // steerable-vs-slash), so typing within a line costs no render. + const hasText = useAuiState(s => s.composer.text.trim().length > 0) + const isHelpHint = useAuiState(s => s.composer.text === '?') + + const isSteerableText = useAuiState(s => { + const trimmed = s.composer.text.trim() + + return trimmed.length > 0 && !SLASH_COMMAND_RE.test(trimmed) + }) + + // assistant-ui's composer mutators throw when the core isn't bound yet (a + // startup/thread-swap window); the DOM + draftRef hold the text and the + // subscription reconciles once it binds, so swallow the premature write. + const setComposerText = useCallback( + (value: string) => { + try { + aui.composer().setText(value) + } catch { + // Composer core not bound yet — DOM/draftRef carry the text. + } + }, + [aui] + ) + + const editorRef = useRef(null) + const draftRef = useRef('') + const pendingDraftPersistRef = useRef<{ scope: string | null; text: string } | null>(null) + const draftPersistTimerRef = useRef(undefined) + const activeQueueSessionKeyRef = useRef(activeQueueSessionKey) + activeQueueSessionKeyRef.current = activeQueueSessionKey + const sessionIdRef = useRef(sessionId) + sessionIdRef.current = sessionId + const queueEditStateRef = useRef(queueEditRef.current) + queueEditStateRef.current = queueEditRef.current + + const [focusRequestId, setFocusRequestId] = useState(0) + + const focusInput = useCallback(() => { + focusComposerInput(editorRef.current) + markActiveComposer('main') + }, []) + + const requestMainFocus = useCallback(() => { + setFocusRequestId(id => id + 1) + }, []) + + // The single write path for programmatic draft mutations: mirror → AUI state → + // repaint the editor (caret to end). Repaints even while focused — inserts / + // restores run mid-focus, and the runtime sync only repaints an unfocused + // editor — so the visible text never lags the store. + const paintDraft = useCallback( + (next: string, focus = true) => { + draftRef.current = next + setComposerText(next) + + const editor = editorRef.current + + if (editor) { + renderComposerContents(editor, next) + placeCaretEnd(editor) + } + + if (focus) { + requestMainFocus() + } + }, + [requestMainFocus, setComposerText] + ) + + const appendExternalText = useCallback( + (text: string, mode: ComposerInsertMode) => { + const value = text.trim() + + if (!value) { + return + } + + const base = mode === 'inline' ? draftRef.current.trimEnd() : draftRef.current + const sep = mode === 'inline' ? (base ? ' ' : '') : base && !base.endsWith('\n') ? '\n\n' : '' + + paintDraft(`${base}${sep}${value}`) + }, + [paintDraft] + ) + + useEffect(() => { + if (!inputDisabled) { + focusInput() + } + }, [focusInput, focusKey, focusRequestId, inputDisabled]) + + useEffect(() => { + if (inputDisabled) { + return undefined + } + + const offFocus = onComposerFocusRequest(target => { + if (target === 'main') { + setFocusRequestId(id => id + 1) + } + }) + + const offInsert = onComposerInsertRequest(({ mode, target, text }) => { + if (target === 'main') { + appendExternalText(text, mode) + } + }) + + return () => { + offFocus() + offInsert() + } + }, [appendExternalText, inputDisabled]) + + const stashAt = (scope: string | null, text = draftRef.current, attachments = $composerAttachments.get()) => + stashSessionDraft(scope, text, attachments) + + const loadIntoComposer = (text: string, attachments: ComposerAttachment[]) => { + $composerAttachments.set(cloneAttachments(attachments)) + paintDraft(text, false) + } + + const clearDraft = useCallback(() => { + setComposerText('') + draftRef.current = '' + + if (editorRef.current) { + editorRef.current.replaceChildren() + } + }, [setComposerText]) + + // Read the editor's current plain text into draftRef + composer state. This + // closes the "queued rAF flush hasn't run yet" window so scope-swap/pagehide + // persistence captures the latest keystrokes. + const syncDraftFromEditor = useCallback(() => { + const editor = editorRef.current + + if (!editor) { + return draftRef.current + } + + const text = composerPlainText(editor) + + if (text !== draftRef.current) { + draftRef.current = text + setComposerText(text) + } + + return text + }, [setComposerText]) + + // Imperative draft sync — the spine of the "work only when work is to be + // performed" model. Subscribing to the composer runtime directly (not + // `useAuiState(text)` + a `[draft]` effect) keeps per-keystroke text out of + // React, so typing never re-renders the chrome. On each change we (1) mirror + // text into draftRef, (2) repaint the editor only when the change came from + // OUTSIDE it (programmatic clear/restore/insert; the focused editor is the + // source otherwise), and (3) schedule the debounced per-session stash. + // Browsing history / editing a queued prompt suppress the stash so recalled + // text never clobbers the draft. + useEffect(() => { + const sync = () => { + const text = composerRuntime.getState().text + draftRef.current = text + + const editor = editorRef.current + + if (editor && document.activeElement !== editor && composerPlainText(editor) !== text) { + renderComposerContents(editor, text) + } + + if (isBrowsingHistory(sessionIdRef.current) || queueEditRef.current) { + return + } + + const scope = activeQueueSessionKeyRef.current + pendingDraftPersistRef.current = { scope, text } + window.clearTimeout(draftPersistTimerRef.current) + draftPersistTimerRef.current = window.setTimeout(() => { + pendingDraftPersistRef.current = null + stashAt(scope, text) + }, DRAFT_PERSIST_DEBOUNCE_MS) + } + + const unsubscribe = composerRuntime.subscribe(sync) + + return () => { + unsubscribe() + window.clearTimeout(draftPersistTimerRef.current) + } + }, [composerRuntime, queueEditRef]) + + const insertText = (text: string) => { + const base = draftRef.current + const sep = base && !base.endsWith('\n') ? '\n' : '' + + paintDraft(`${base}${sep}${text}`) + } + + // insertInlineRefs mutates the editor in place (chips), so it can't go through + // paintDraft's re-render — it mirrors the resulting plain text and refocuses. + const insertInlineRefs = (refs: InlineRefInput[]) => { + const editor = editorRef.current + + if (!editor) { + return false + } + + const nextDraft = insertInlineRefsIntoEditor(editor, refs) + + if (nextDraft === null) { + return false + } + + draftRef.current = nextDraft + setComposerText(nextDraft) + requestMainFocus() + + return true + } + + // Latest-closure ref so the once-only subscription always calls the current + // insertInlineRefs without re-subscribing every render. + const insertInlineRefsRef = useRef(insertInlineRefs) + insertInlineRefsRef.current = insertInlineRefs + + useEffect(() => { + return onComposerInsertRefsRequest(({ refs, target }) => { + if (target === 'main') { + insertInlineRefsRef.current(refs) + } + }) + }, []) + + // Per-thread draft swap — the composer's only session coupling. Lifecycle + // never clears composer state; this effect alone stashes on leave, restores + // on enter. Keyed writes are idempotent, so no skip-sentinel. + useEffect(() => { + const { attachments, text } = takeSessionDraft(activeQueueSessionKey) + loadIntoComposer(text, attachments) + + return () => { + const latestText = syncDraftFromEditor() + const editing = queueEditStateRef.current + + if (editing?.sessionKey === activeQueueSessionKey) { + stashAt(activeQueueSessionKey, editing.draft, editing.attachments) + } else if (!isBrowsingHistory(sessionId)) { + stashAt(activeQueueSessionKey, latestText) + } + } + }, [activeQueueSessionKey]) // eslint-disable-line react-hooks/exhaustive-deps + + // pagehide is load-bearing: React skips effect cleanups on reload, so Cmd+R + // inside the debounce/rAF window would drop trailing keystrokes without this. + useEffect(() => { + const flushPendingDraftPersist = () => { + const scope = activeQueueSessionKeyRef.current + const editing = queueEditStateRef.current + + if (editing?.sessionKey === scope || isBrowsingHistory(sessionIdRef.current)) { + return + } + + const latestText = syncDraftFromEditor() + pendingDraftPersistRef.current = null + stashAt(scope, latestText) + } + + window.addEventListener('pagehide', flushPendingDraftPersist) + + return () => { + window.removeEventListener('pagehide', flushPendingDraftPersist) + flushPendingDraftPersist() + } + }, [syncDraftFromEditor]) + + return { + activeQueueSessionKeyRef, + clearDraft, + draftRef, + editorRef, + focusInput, + hasText, + insertInlineRefs, + insertText, + isHelpHint, + isSteerableText, + loadIntoComposer, + requestMainFocus, + sessionIdRef, + setComposerText, + stashAt + } +} diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-drop.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-drop.ts new file mode 100644 index 000000000000..2c56061c80d5 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-drop.ts @@ -0,0 +1,164 @@ +import { type DragEvent as ReactDragEvent, useRef, useState } from 'react' + +import { triggerHaptic } from '@/lib/haptics' + +import { extractDroppedFiles, HERMES_PATHS_MIME, partitionDroppedFiles } from '../../hooks/use-composer-actions' +import { dragHasAttachments, droppedFileInlineRefs, type InlineRefInput } from '../inline-refs' +import type { ChatBarProps } from '../types' + +interface UseComposerDropArgs { + cwd: ChatBarProps['cwd'] + insertInlineRefs: (refs: InlineRefInput[]) => boolean + onAttachDroppedItems: ChatBarProps['onAttachDroppedItems'] + requestMainFocus: () => void +} + +/** + * Drag-and-drop attachment engine. Splits drops by origin: in-app drags + * (project tree / gutter) stay inline `@file:`/`@line:` refs the gateway + * resolves directly; OS/Finder drops (absolute local paths a remote gateway + * can't read, image bytes vision needs) route through the upload pipeline. + * Off the keystroke path; consumes `insertInlineRefs` + the attach handler. + */ +export function useComposerDrop({ + cwd, + insertInlineRefs, + onAttachDroppedItems, + requestMainFocus +}: UseComposerDropArgs) { + const [dragActive, setDragActive] = useState(false) + const dragDepthRef = useRef(0) + + const resetDragState = () => { + dragDepthRef.current = 0 + setDragActive(false) + } + + const handleDragEnter = (event: ReactDragEvent) => { + if (!onAttachDroppedItems || !dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME)) { + return + } + + event.preventDefault() + dragDepthRef.current += 1 + + if (!dragActive) { + setDragActive(true) + } + } + + const handleDragOver = (event: ReactDragEvent) => { + if (!onAttachDroppedItems || !dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME)) { + return + } + + event.preventDefault() + event.dataTransfer.dropEffect = 'copy' + } + + const handleDragLeave = (event: ReactDragEvent) => { + if (!onAttachDroppedItems) { + return + } + + event.preventDefault() + dragDepthRef.current = Math.max(0, dragDepthRef.current - 1) + + if (dragDepthRef.current === 0) { + setDragActive(false) + } + } + + const handleDrop = (event: ReactDragEvent) => { + if (!onAttachDroppedItems) { + return + } + + event.preventDefault() + resetDragState() + + const candidates = extractDroppedFiles(event.dataTransfer) + + if (candidates.length === 0) { + return + } + + // In-app drags (project tree / gutter) are workspace-relative paths the + // gateway resolves directly, so they stay inline @file:/@line: refs. OS + // drops are absolute local paths a remote gateway can't read (and images + // need byte upload for vision), so route them through the upload pipeline. + const { inAppRefs, osDrops } = partitionDroppedFiles(candidates) + const refs = droppedFileInlineRefs(inAppRefs, cwd) + + if (refs.length && insertInlineRefs(refs)) { + triggerHaptic('selection') + } + + if (osDrops.length) { + void Promise.resolve(onAttachDroppedItems(osDrops)).then(attached => { + if (attached) { + triggerHaptic('selection') + requestMainFocus() + } + }) + } + } + + const handleInputDragOver = (event: ReactDragEvent) => { + if (!dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME)) { + return + } + + event.preventDefault() + event.stopPropagation() + event.dataTransfer.dropEffect = 'copy' + } + + const handleInputDrop = (event: ReactDragEvent) => { + if (!dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME)) { + return + } + + const candidates = extractDroppedFiles(event.dataTransfer) + + if (!candidates.length) { + return + } + + event.preventDefault() + event.stopPropagation() + resetDragState() + + // Dropping straight onto the text box used to inline-ref *every* file — + // including OS/Finder drops, whose absolute local path a remote gateway + // can't read and whose image bytes never reached vision. Split by origin: + // in-app drags stay inline refs; OS drops go through the upload pipeline. + // (When no upload handler is wired, fall back to inline refs for all.) + const attach = onAttachDroppedItems + const { inAppRefs, osDrops } = partitionDroppedFiles(candidates) + const refs = droppedFileInlineRefs(attach ? inAppRefs : candidates, cwd) + + if (refs.length && insertInlineRefs(refs)) { + triggerHaptic('selection') + } + + if (attach && osDrops.length) { + void Promise.resolve(attach(osDrops)).then(attached => { + if (attached) { + triggerHaptic('selection') + requestMainFocus() + } + }) + } + } + + return { + dragActive, + handleDragEnter, + handleDragLeave, + handleDragOver, + handleDrop, + handleInputDragOver, + handleInputDrop + } +} diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-metrics.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-metrics.ts new file mode 100644 index 000000000000..da66ddd843aa --- /dev/null +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-metrics.ts @@ -0,0 +1,160 @@ +import { useAuiState } from '@assistant-ui/react' +import { type RefObject, useCallback, useEffect, useRef, useState } from 'react' + +import { useMediaQuery } from '@/hooks/use-media-query' +import { useResizeObserver } from '@/hooks/use-resize-observer' +import { $composerPoppedOut } from '@/store/composer-popout' +import { isSecondaryWindow } from '@/store/windows' + +import { COMPOSER_SINGLE_LINE_MAX_PX, COMPOSER_STACK_BREAKPOINT_PX } from '../composer-utils' + +interface UseComposerMetricsArgs { + composerRef: RefObject + composerSurfaceRef: RefObject + editorRef: RefObject + poppedOut: boolean +} + +/** + * Owns the composer's *sizing* engine: the stacked-vs-inline layout decision + * and the measured-height CSS vars the thread reads for bottom clearance. All + * work is edge-gated — the ResizeObserver only fires on real size changes, the + * height vars are 8px-bucketed so per-keystroke growth never invalidates the + * tree's computed style, and `tight` only flips when it crosses the breakpoint. + * Returns `stacked` (the only value the render needs). + */ +export function useComposerMetrics({ composerRef, composerSurfaceRef, editorRef, poppedOut }: UseComposerMetricsArgs): { + stacked: boolean +} { + const [expanded, setExpanded] = useState(false) + const [tight, setTight] = useState(false) + const narrow = useMediaQuery('(max-width: 30rem)') + + // Edge signals, not the live text: these only re-render when emptiness / the + // presence of a non-trailing newline actually flips, so typing within a line + // costs nothing here. + const isEmpty = useAuiState(s => s.composer.text.length === 0) + const hasHardNewline = useAuiState(s => s.composer.text.trimEnd().includes('\n')) + + // Expansion (input on its own full-width row, controls below) is driven by + // the editor's *actual* rendered height via the ResizeObserver in + // syncComposerMetrics — it only fires when the text genuinely wraps to a + // second line, so the layout flips exactly at the wrap point rather than at + // a guessed character count. We only handle the two cases the observer + // can't: an explicit newline (expand before layout settles) and an emptied + // draft (collapse back). We never read scrollHeight per keystroke. + useEffect(() => { + if (isEmpty) { + setExpanded(false) + + return + } + + if (expanded) { + return + } + + // Only a non-trailing newline forces an immediate expand. A trailing newline + // (or phantom \n from contenteditable junk) is left to the ResizeObserver, + // which expands only when the editor's real height actually grows. + if (hasHardNewline) { + setExpanded(true) + } + }, [expanded, hasHardNewline, isEmpty]) + + // Bucket measured heights so we only invalidate the global CSS var when + // the size crosses a meaningful threshold. Without bucketing, the editor + // grows ~1px per character → setProperty fires every keystroke → entire + // tree's computed style is invalidated → next paint forces a full + // recalculate-style pass. With an 8px bucket, the invalidation rate drops + // ~8× and small char-by-char typing produces no style invalidation at all + // until a wrap or row change actually happens. + const lastBucketedHeightRef = useRef(0) + const lastBucketedSurfaceHeightRef = useRef(0) + const lastTightRef = useRef(null) + + const syncComposerMetrics = useCallback(() => { + const composer = composerRef.current + + if (!composer) { + return + } + + // Floating composer is out of the thread's flow — it must not reserve any + // bottom clearance. Zero the measured vars so the thread reclaims the space. + // (Read globals here so the callback stays stable; mirror the popoutAllowed + // gate since secondary windows are forced docked.) + if ($composerPoppedOut.get() && !isSecondaryWindow()) { + const root = document.documentElement + lastBucketedHeightRef.current = 0 + lastBucketedSurfaceHeightRef.current = 0 + root.style.setProperty('--composer-measured-height', '0px') + root.style.setProperty('--composer-surface-measured-height', '0px') + + return + } + + const { height, width } = composer.getBoundingClientRect() + const surfaceHeight = composerSurfaceRef.current?.getBoundingClientRect().height + const root = document.documentElement + + if (width > 0) { + const nextTight = width < COMPOSER_STACK_BREAKPOINT_PX + + if (nextTight !== lastTightRef.current) { + lastTightRef.current = nextTight + setTight(nextTight) + } + } + + // Expand once the input has actually wrapped past a single line. The + // observer only fires on real size changes, so this reads scrollHeight at + // most once per wrap (not per keystroke). One line ≈ 28px (1.625rem + // min-height + padding); a second line clears ~36px. We only ever expand + // here — collapse is handled by the emptied-draft effect to avoid + // oscillating across the wrap boundary as the input switches widths. + const editor = editorRef.current + + if (editor && editor.scrollHeight > COMPOSER_SINGLE_LINE_MAX_PX) { + setExpanded(true) + } + + if (height > 0) { + const bucket = Math.round(height / 8) * 8 + + if (bucket !== lastBucketedHeightRef.current) { + lastBucketedHeightRef.current = bucket + root.style.setProperty('--composer-measured-height', `${bucket}px`) + } + } + + if (surfaceHeight && surfaceHeight > 0) { + const bucket = Math.round(surfaceHeight / 8) * 8 + + if (bucket !== lastBucketedSurfaceHeightRef.current) { + lastBucketedSurfaceHeightRef.current = bucket + root.style.setProperty('--composer-surface-measured-height', `${bucket}px`) + } + } + }, [composerRef, composerSurfaceRef, editorRef]) + + useResizeObserver(syncComposerMetrics, composerRef, composerSurfaceRef, editorRef) + + // Toggling pop-out changes whether the composer reserves thread clearance. + // The ResizeObserver may not fire (the box can keep the same box size), so + // re-sync explicitly: docked republishes the measured height, floating zeroes + // it so the thread reclaims the bottom space. + useEffect(() => { + syncComposerMetrics() + }, [poppedOut, syncComposerMetrics]) + + useEffect(() => { + return () => { + const root = document.documentElement + root.style.removeProperty('--composer-measured-height') + root.style.removeProperty('--composer-surface-measured-height') + } + }, []) + + return { stacked: expanded || narrow || tight } +} diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-queue.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-queue.ts new file mode 100644 index 000000000000..c40d56a4826b --- /dev/null +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-queue.ts @@ -0,0 +1,350 @@ +import { type RefObject, useCallback, useEffect, useRef, useState } from 'react' + +import { useI18n } from '@/i18n' +import { triggerHaptic } from '@/lib/haptics' +import { useSessionSlice } from '@/lib/use-session-slice' +import { clearComposerAttachments, type ComposerAttachment } from '@/store/composer' +import { resetBrowseState } from '@/store/composer-input-history' +import { + $queuedPromptsBySession, + enqueueQueuedPrompt, + MAX_AUTO_DRAIN_ATTEMPTS, + migrateQueuedPrompts, + promoteQueuedPrompt, + type QueuedPromptEntry, + removeQueuedPrompt, + shouldAutoDrain, + updateQueuedPrompt +} from '@/store/composer-queue' +import { notify } from '@/store/notifications' + +import { cloneAttachments, type QueueEditState } from '../composer-utils' +import type { ChatBarProps } from '../types' + +interface UseComposerQueueArgs { + activeQueueSessionKey: string | null + attachments: ComposerAttachment[] + busy: boolean + clearDraft: () => void + draftRef: RefObject + focusInput: () => void + loadIntoComposer: (text: string, attachments: ComposerAttachment[]) => void + onCancel: ChatBarProps['onCancel'] + onSubmit: ChatBarProps['onSubmit'] + queueEditRef: RefObject + queueSessionKey: ChatBarProps['queueSessionKey'] + sessionId: string | null | undefined +} + +/** + * The composer's queue engine — everything about queued turns: the per-session + * queue store binding, in-place queued-prompt editing (begin/step/exit), the + * shared drain lock + send-then-remove sequence, manual send-now, and the + * edge-independent auto-drain with bounded retries. It consumes the draft API + * (draftRef/clearDraft/loadIntoComposer/focusInput) and writes the + * coordinator-owned `queueEditRef` so the draft engine can read the edit state + * without a back-reference. Behaviour-identical to the inline original. + */ +export function useComposerQueue({ + activeQueueSessionKey, + attachments, + busy, + clearDraft, + draftRef, + focusInput, + loadIntoComposer, + onCancel, + onSubmit, + queueEditRef, + queueSessionKey, + sessionId +}: UseComposerQueueArgs) { + const { t } = useI18n() + + // Per-session slice (edge): re-renders only when THIS session's queue changes, + // not on cross-session queue churn (the plain atom's map ref changes on every + // write; the keyed array does not). + const queuedPrompts = useSessionSlice($queuedPromptsBySession, activeQueueSessionKey) + + const [queueEdit, setQueueEdit] = useState(null) + queueEditRef.current = queueEdit + + const setQueueEditSnapshot = useCallback( + (next: QueueEditState | null) => { + queueEditRef.current = next + setQueueEdit(next) + }, + [queueEditRef] + ) + + const editingQueuedPrompt = queueEdit ? (queuedPrompts.find(entry => entry.id === queueEdit.entryId) ?? null) : null + + const prevQueueKeyRef = useRef(activeQueueSessionKey) + const drainingQueueRef = useRef(false) + const drainFailuresRef = useRef(new Map()) + + const beginQueuedEdit = (entry: QueuedPromptEntry) => { + if (!activeQueueSessionKey || queueEdit) { + return + } + + setQueueEditSnapshot({ + attachments: cloneAttachments(attachments), + draft: draftRef.current, + entryId: entry.id, + sessionKey: activeQueueSessionKey + }) + loadIntoComposer(entry.text, entry.attachments) + triggerHaptic('selection') + focusInput() + } + + // Walk queued entries while editing (ArrowUp = older, ArrowDown = newer), + // saving the in-progress edit on each step. Stepping newer past the last + // entry exits edit mode and restores the pre-edit draft. + const stepQueuedEdit = (direction: -1 | 1) => { + if (!queueEdit) { + return false + } + + const index = queuedPrompts.findIndex(e => e.id === queueEdit.entryId) + const target = index + direction + + if (index < 0 || target < 0) { + return index >= 0 // at the oldest: swallow; missing entry: let it fall through + } + + const saved = updateQueuedPrompt(queueEdit.sessionKey, queueEdit.entryId, { + attachments: cloneAttachments(attachments), + text: draftRef.current + }) + + const next = queuedPrompts[target] + + if (next) { + setQueueEditSnapshot({ ...queueEdit, entryId: next.id }) + loadIntoComposer(next.text, next.attachments) + } else { + setQueueEditSnapshot(null) + loadIntoComposer(queueEdit.draft, queueEdit.attachments) + } + + triggerHaptic(saved ? 'success' : 'selection') + focusInput() + + return true + } + + const exitQueuedEdit = (action: 'cancel' | 'save'): boolean => { + if (!queueEdit) { + return false + } + + if (action === 'save') { + const text = draftRef.current + const next = cloneAttachments(attachments) + + if (!text.trim() && next.length === 0) { + return false + } + + const saved = updateQueuedPrompt(queueEdit.sessionKey, queueEdit.entryId, { attachments: next, text }) + triggerHaptic(saved ? 'success' : 'selection') + } else { + triggerHaptic('cancel') + } + + setQueueEditSnapshot(null) + loadIntoComposer(queueEdit.draft, queueEdit.attachments) + focusInput() + + return true + } + + const queueCurrentDraft = useCallback(() => { + const text = draftRef.current + + if (!activeQueueSessionKey || (!text.trim() && attachments.length === 0)) { + return false + } + + if (!enqueueQueuedPrompt(activeQueueSessionKey, { text, attachments })) { + return false + } + + clearDraft() + clearComposerAttachments() + triggerHaptic('selection') + + return true + }, [activeQueueSessionKey, attachments, clearDraft, draftRef]) + + // All queue drain paths share one lock + send-then-remove sequence. + // `pickEntry` lets each caller choose head, by-id, or skip-edited. + const runDrain = useCallback( + async (pickEntry: (entries: QueuedPromptEntry[]) => QueuedPromptEntry | undefined): Promise => { + if (drainingQueueRef.current || !activeQueueSessionKey) { + return false + } + + const entry = pickEntry(queuedPrompts) + + if (!entry) { + return false + } + + drainingQueueRef.current = true + + try { + const accepted = await Promise.resolve( + onSubmit(entry.text, { attachments: entry.attachments, fromQueue: true }) + ) + + if (accepted === false) { + return false + } + + drainFailuresRef.current.delete(entry.id) + removeQueuedPrompt(activeQueueSessionKey, entry.id) + resetBrowseState(sessionId) + + return true + } finally { + drainingQueueRef.current = false + } + }, + [activeQueueSessionKey, onSubmit, queuedPrompts, sessionId] + ) + + const pickDrainHead = useCallback( + (entries: QueuedPromptEntry[]) => { + const skip = queueEditRef.current?.entryId + + return skip ? entries.find(e => e.id !== skip) : entries[0] + }, + [queueEditRef] // 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) { + return false + } + + if (busy) { + // Promote to the head, then interrupt. The gateway always emits a + // settle (message.complete + session.info running:false) when the + // turn unwinds, and the busy→false auto-drain below sends this entry. + promoteQueuedPrompt(activeQueueSessionKey, id) + triggerHaptic('selection') + void Promise.resolve(onCancel()) + + 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] + ) + + // 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 + } + + 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. + useEffect(() => { + if (!queueEdit) { + return + } + + if (queueEdit.sessionKey === activeQueueSessionKey) { + if (editingQueuedPrompt) { + return + } + + setQueueEditSnapshot(null) + loadIntoComposer(queueEdit.draft, queueEdit.attachments) + + return + } + + setQueueEditSnapshot(null) + }, [activeQueueSessionKey, editingQueuedPrompt, queueEdit, setQueueEditSnapshot]) // eslint-disable-line react-hooks/exhaustive-deps + + return { + beginQueuedEdit, + drainNextQueued, + editingQueuedPrompt, + exitQueuedEdit, + queueCurrentDraft, + queueEdit, + queuedPrompts, + sendQueuedNow, + stepQueuedEdit + } +} diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.ts new file mode 100644 index 000000000000..eab822d7cd89 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.ts @@ -0,0 +1,190 @@ +import { type RefObject, useEffect, useRef } from 'react' + +import { SLASH_COMMAND_RE } from '@/lib/chat-runtime' +import { triggerHaptic } from '@/lib/haptics' +import { clearComposerAttachments, clearSessionDraft, type ComposerAttachment } from '@/store/composer' +import { resetBrowseState } from '@/store/composer-input-history' +import { enqueueQueuedPrompt, type QueuedPromptEntry } from '@/store/composer-queue' + +import { cloneAttachments, type QueueEditState } from '../composer-utils' +import { onComposerSubmitRequest } from '../focus' +import { composerPlainText } from '../rich-editor' +import type { ChatBarProps } from '../types' + +interface UseComposerSubmitArgs { + activeQueueSessionKey: string | null + activeQueueSessionKeyRef: RefObject + attachments: ComposerAttachment[] + busy: boolean + canSteer: boolean + clearDraft: () => void + disabled: boolean + draftRef: RefObject + drainNextQueued: () => Promise + editorRef: RefObject + exitQueuedEdit: (action: 'cancel' | 'save') => boolean + focusInput: () => void + inputDisabled: boolean + loadIntoComposer: (text: string, attachments: ComposerAttachment[]) => void + onCancel: ChatBarProps['onCancel'] + onSteer: ChatBarProps['onSteer'] + onSubmit: ChatBarProps['onSubmit'] + queueCurrentDraft: () => boolean + queueEdit: QueueEditState | null + queuedPrompts: QueuedPromptEntry[] + sessionId: string | null | undefined + setComposerText: (value: string) => void + stashAt: (scope: string | null, text?: string, attachments?: ComposerAttachment[]) => void +} + +/** + * The composer's submit engine — the orchestration seam where the draft and + * queue meet. `submitDraft` is the one decision tree (queue-edit save · slash- + * now-while-busy · queue · drain · send · stop); `dispatchSubmit` is the shared + * send-with-restore primitive (re-loads + re-stashes the draft if the gateway + * rejects, so nothing is ever lost); `steerDraft` nudges the live turn. Reads + * the draft + queue APIs; owns no state of its own beyond the stable + * external-submit listener ref. + */ +export function useComposerSubmit({ + activeQueueSessionKey, + activeQueueSessionKeyRef, + attachments, + busy, + canSteer, + clearDraft, + disabled, + draftRef, + drainNextQueued, + editorRef, + exitQueuedEdit, + focusInput, + inputDisabled, + loadIntoComposer, + onCancel, + onSteer, + onSubmit, + queueCurrentDraft, + queueEdit, + queuedPrompts, + sessionId, + setComposerText, + stashAt +}: UseComposerSubmitArgs) { + // Shared send primitive: fire onSubmit, and if the gateway rejects (accepted + // === false) or throws, re-load + re-stash the draft so the words survive. + const dispatchSubmit = (text: string, attachments?: ComposerAttachment[]) => { + const submittedScope = activeQueueSessionKeyRef.current + const submittedAttachments = attachments ?? [] + + const restore = () => { + loadIntoComposer(text, submittedAttachments) + stashAt(activeQueueSessionKeyRef.current, text, submittedAttachments) + } + + void Promise.resolve(attachments ? onSubmit(text, { attachments }) : onSubmit(text)) + .then(accepted => void (accepted === false ? restore() : clearSessionDraft(submittedScope))) + .catch(restore) + } + + // External "submit this prompt" requests (e.g. the review pane's agent-ship + // button) route through the same send path. A ref keeps the listener stable + // while always calling the latest dispatchSubmit closure. + const dispatchSubmitRef = useRef(dispatchSubmit) + dispatchSubmitRef.current = dispatchSubmit + + useEffect( + () => + onComposerSubmitRequest(({ target, text }) => { + if (target === 'main' && !inputDisabled) { + dispatchSubmitRef.current(text) + } + }), + [inputDisabled] + ) + + 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 + // not have synced yet — reading state here drops the message (Enter looks + // like it does nothing; typing a trailing space only "fixes" it because the + // extra input event forces a state sync). draftRef is updated on every + // input event; refresh it from the editor once more to also cover an + // in-flight keystroke that hasn't fired its input event yet. + const editor = editorRef.current + + if (editor) { + const domText = composerPlainText(editor) + + if (domText !== draftRef.current) { + draftRef.current = domText + setComposerText(domText) + } + } + + const text = draftRef.current + const payloadPresent = text.trim().length > 0 || attachments.length > 0 + + if (queueEdit) { + exitQueuedEdit('save') + } else if (busy) { + // Slash commands should execute immediately even while the agent is + // busy — they're client-side operations (/yolo, /skin, /new, /help, + // etc.) or self-contained gateway RPCs (/status, /compress). onSubmit + // routes them to executeSlashCommand, which has its own per-command + // busy guard for commands that genuinely need an idle session (skill + // /send directives). Queuing them would make every slash command wait + // for the current turn to finish, which is how the TUI never behaves. + if (!attachments.length && SLASH_COMMAND_RE.test(text.trim())) { + triggerHaptic('submit') + clearDraft() + dispatchSubmit(text) + } else if (payloadPresent) { + queueCurrentDraft() + } else { + // Stop button (the only way to reach here while busy with an empty + // composer — empty Enter is short-circuited in the keydown handler). + triggerHaptic('cancel') + void Promise.resolve(onCancel()) + } + } else if (!payloadPresent && queuedPrompts.length > 0) { + void drainNextQueued() + } else if (payloadPresent) { + const submittedAttachments = cloneAttachments(attachments) + triggerHaptic('submit') + resetBrowseState(sessionId) + clearDraft() + clearComposerAttachments() + dispatchSubmit(text, submittedAttachments) + } + + focusInput() + } + + // Steer the live turn (nudge without interrupting). Clears the draft up front + // for snappy feedback; if the gateway rejects (no live tool window) the words + // are re-queued so nothing is lost — same safety net as a plain queue. + const steerDraft = () => { + if (!onSteer || !canSteer) { + return + } + + const text = draftRef.current.trim() + + triggerHaptic('submit') + clearDraft() + + void Promise.resolve(onSteer(text)).then(accepted => { + if (!accepted && activeQueueSessionKey) { + enqueueQueuedPrompt(activeQueueSessionKey, { text, attachments: [] }) + } + }) + } + + return { dispatchSubmit, steerDraft, submitDraft } +} diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-voice.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-voice.ts new file mode 100644 index 000000000000..2cff7a4084c7 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-voice.ts @@ -0,0 +1,160 @@ +import { useCallback, useEffect, useRef, useState } from 'react' + +import { useI18n } from '@/i18n' +import { chatMessageText } from '@/lib/chat-messages' +import { triggerHaptic } from '@/lib/haptics' +import { resetBrowseState } from '@/store/composer-input-history' +import { notifyError } from '@/store/notifications' +import { $messages } from '@/store/session' +import { $autoSpeakReplies, setAutoSpeakReplies } from '@/store/voice-prefs' + +import { onComposerVoiceToggleRequest } from '../focus' +import type { ChatBarProps } from '../types' + +import { useAutoSpeakReplies } from './use-auto-speak-replies' +import { useVoiceConversation } from './use-voice-conversation' +import { useVoiceRecorder } from './use-voice-recorder' + +interface UseComposerVoiceArgs { + busy: boolean + clearDraft: () => void + disabled: boolean + focusInput: () => void + insertText: (text: string) => void + maxRecordingSeconds: number + onSubmit: ChatBarProps['onSubmit'] + onTranscribeAudio: ChatBarProps['onTranscribeAudio'] + sessionId: string | null | undefined +} + +/** + * The composer's voice engine: push-to-talk dictation (transcript → draft), the + * full voice-conversation loop, and auto-speak of replies. Self-contained — it + * consumes the draft/submit primitives passed in but nothing depends back on it, + * so it lifts cleanly out of ChatBar. + */ +export function useComposerVoice({ + busy, + clearDraft, + disabled, + focusInput, + insertText, + maxRecordingSeconds, + onSubmit, + onTranscribeAudio, + sessionId +}: UseComposerVoiceArgs) { + const { t } = useI18n() + const [voiceConversationActive, setVoiceConversationActive] = useState(false) + const lastSpokenIdRef = useRef(null) + + const { dictate, voiceActivityState, voiceStatus } = useVoiceRecorder({ + focusInput, + maxRecordingSeconds, + onTranscript: insertText, + onTranscribeAudio + }) + + const pendingResponse = () => { + const messages = $messages.get() + const last = messages.findLast(m => m.role === 'assistant' && !m.hidden) + + if (!last || last.id === lastSpokenIdRef.current) { + return null + } + + const text = chatMessageText(last).trim() + + if (!text) { + return null + } + + return { + id: last.id, + pending: Boolean(last.pending), + text + } + } + + const consumePendingResponse = () => { + const messages = $messages.get() + const last = messages.findLast(m => m.role === 'assistant' && !m.hidden) + + if (last) { + lastSpokenIdRef.current = last.id + } + } + + const submitVoiceTurn = async (text: string) => { + if (busy) { + return + } + + triggerHaptic('submit') + resetBrowseState(sessionId) + clearDraft() + await onSubmit(text) + } + + const conversation = useVoiceConversation({ + busy, + consumePendingResponse, + enabled: voiceConversationActive, + onFatalError: () => setVoiceConversationActive(false), + onSubmit: submitVoiceTurn, + onTranscribeAudio, + pendingResponse + }) + + // The `composer.voice` hotkey (Ctrl+B) toggles the conversation. Starting + // with STT unconfigured lets the conversation surface its own "configure + // speech-to-text" notice rather than silently no-opping. + const toggleVoiceConversation = useCallback(() => { + if (disabled) { + return + } + + if (voiceConversationActive) { + setVoiceConversationActive(false) + void conversation.end() + } else { + setVoiceConversationActive(true) + } + }, [conversation, disabled, voiceConversationActive]) + + useEffect(() => onComposerVoiceToggleRequest(toggleVoiceConversation), [toggleVoiceConversation]) + + // Explicit start/end for the on-screen conversation controls (the hotkey uses + // the gated toggle above). + const startConversation = useCallback(() => setVoiceConversationActive(true), []) + + const endConversation = useCallback(() => { + setVoiceConversationActive(false) + void conversation.end() + }, [conversation]) + + const handleToggleAutoSpeak = useCallback(() => { + void setAutoSpeakReplies(!$autoSpeakReplies.get()).catch(error => + notifyError(error, t.settings.config.autosaveFailed) + ) + }, [t]) + + useAutoSpeakReplies({ + conversationActive: voiceConversationActive, + failureLabel: t.assistant.thread.readAloudFailed, + markSpoken: consumePendingResponse, + pendingReply: pendingResponse, + sessionId + }) + + return { + conversation, + dictate, + endConversation, + handleToggleAutoSpeak, + startConversation, + voiceActivityState, + voiceConversationActive, + voiceStatus + } +} diff --git a/apps/desktop/src/app/chat/composer/hooks/use-status-presence.ts b/apps/desktop/src/app/chat/composer/hooks/use-status-presence.ts new file mode 100644 index 000000000000..c6b9af53b737 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/hooks/use-status-presence.ts @@ -0,0 +1,36 @@ +import { useSyncExternalStore } from 'react' + +import { $statusItemsBySession } from '@/store/composer-status' +import { $previewStatusBySession } from '@/store/preview-status' + +const subscribe = (onChange: () => void) => { + const offItems = $statusItemsBySession.listen(onChange) + const offPreviews = $previewStatusBySession.listen(onChange) + + return () => { + offItems() + offPreviews() + } +} + +/** + * Whether a session has any status items or previews, as a coarse *edge*: the + * boolean only flips when the stack appears/disappears. ChatBar uses it to + * toggle a styling data-attr — subscribing to the whole `$statusItemsBySession` + * (a `computed` that rebuilds the entire map) / `$previewStatusBySession` maps + * re-rendered the ~1.4k ChatBar on every per-item mutation (a subagent tick, a + * 5s background poll) and on churn in OTHER sessions. The boolean snapshot bails + * out of all of that, re-rendering only on the actual show/hide transition. + */ +export function useSessionStatusPresence(sessionId: string | null): boolean { + return useSyncExternalStore(subscribe, () => { + if (!sessionId) { + return false + } + + return ( + ($statusItemsBySession.get()[sessionId]?.length ?? 0) > 0 || + ($previewStatusBySession.get()[sessionId]?.length ?? 0) > 0 + ) + }) +} diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index 379b6732a883..d49c8382b7bd 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -1,38 +1,26 @@ import type { Unstable_TriggerAdapter, Unstable_TriggerItem } from '@assistant-ui/core' -import { ComposerPrimitive, useAui, useAuiState } from '@assistant-ui/react' +import { ComposerPrimitive } from '@assistant-ui/react' import { useStore } from '@nanostores/react' import { type ClipboardEvent, type FormEvent, type KeyboardEvent, - type DragEvent as ReactDragEvent, useCallback, useEffect, - useMemo, useRef, useState } from 'react' -import { hermesDirectiveFormatter, type SlashChipKind } from '@/components/assistant-ui/directive-text' +import { hermesDirectiveFormatter } 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' import { useI18n } from '@/i18n' import { chatMessageText } from '@/lib/chat-messages' -import { SLASH_COMMAND_RE } from '@/lib/chat-runtime' import { desktopSlashCommandTakesArgs } from '@/lib/desktop-slash-commands' import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images' import { triggerHaptic } from '@/lib/haptics' import { cn } from '@/lib/utils' -import { - $composerAttachments, - clearComposerAttachments, - clearSessionDraft, - type ComposerAttachment, - stashSessionDraft, - takeSessionDraft -} from '@/store/composer' +import { $composerAttachments, clearComposerAttachments } from '@/store/composer' import { browseBackward, browseForward, @@ -48,56 +36,41 @@ import { setComposerPopoutPosition, setComposerPoppedOut } from '@/store/composer-popout' -import { - $queuedPromptsBySession, - enqueueQueuedPrompt, - MAX_AUTO_DRAIN_ATTEMPTS, - migrateQueuedPrompts, - promoteQueuedPrompt, - type QueuedPromptEntry, - removeQueuedPrompt, - shouldAutoDrain, - updateQueuedPrompt -} from '@/store/composer-queue' -import { $statusItemsBySession } from '@/store/composer-status' -import { notify } from '@/store/notifications' -import { $previewStatusBySession } from '@/store/preview-status' +import { removeQueuedPrompt } from '@/store/composer-queue' import { listRepoBranches, requestStartWorkSession, startWorkInRepo, switchBranchInRepo } from '@/store/projects' import { $activeSessionAwaitingInput } from '@/store/prompts' import { toggleReview } from '@/store/review' -import { $gatewayState, $messages, setSessionPickerOpen } from '@/store/session' +import { $gatewayState, $messages } from '@/store/session' import { $threadScrolledUp } from '@/store/thread-scroll' +import { $autoSpeakReplies } from '@/store/voice-prefs' import { isSecondaryWindow } from '@/store/windows' import { useTheme } from '@/themes' -import { extractDroppedFiles, HERMES_PATHS_MIME, partitionDroppedFiles } from '../hooks/use-composer-actions' - import { AttachmentList } from './attachments' +import { + COMPLETION_ACTIONS, + COMPOSER_FADE_BACKGROUND, + pickPlaceholder, + type QueueEditState, + slashArgStage, + slashChipKindForItem, + slashCommandToken +} from './composer-utils' import { ContextMenu } from './context-menu' import { ComposerControls } from './controls' import { COMPOSER_DROP_ACTIVE_CLASS, COMPOSER_DROP_FADE_CLASS } from './drop-affordance' -import { - type ComposerInsertMode, - focusComposerInput, - markActiveComposer, - onComposerFocusRequest, - onComposerInsertRefsRequest, - onComposerInsertRequest, - onComposerSubmitRequest, - onComposerVoiceToggleRequest -} from './focus' +import { markActiveComposer } from './focus' import { HelpHint } from './help-hint' import { useAtCompletions } from './hooks/use-at-completions' +import { useComposerDraft } from './hooks/use-composer-draft' +import { useComposerDrop } from './hooks/use-composer-drop' +import { useComposerMetrics } from './hooks/use-composer-metrics' +import { useComposerQueue } from './hooks/use-composer-queue' +import { useComposerSubmit } from './hooks/use-composer-submit' +import { useComposerVoice } from './hooks/use-composer-voice' import { useComposerPopoutGestures } from './hooks/use-popout-drag' import { useSlashCompletions } from './hooks/use-slash-completions' -import { useVoiceConversation } from './hooks/use-voice-conversation' -import { useVoiceRecorder } from './hooks/use-voice-recorder' -import { - dragHasAttachments, - droppedFileInlineRefs, - type InlineRefInput, - insertInlineRefsIntoEditor -} from './inline-refs' +import { useSessionStatusPresence } from './hooks/use-status-presence' import { QueuePanel } from './queue-panel' import { composerPlainText, @@ -119,61 +92,6 @@ import type { ChatBarProps } from './types' import { UrlDialog } from './url-dialog' import { VoiceActivity, VoicePlaybackActivity } from './voice-activity' -const COMPOSER_STACK_BREAKPOINT_PX = 320 - -// A single editor line is ~28px (--composer-input-min-height 1.625rem + 0.5rem -// vertical padding). Anything taller means the text wrapped to a second line, -// which is when the composer should expand to the stacked layout. -const COMPOSER_SINGLE_LINE_MAX_PX = 36 - -const COMPOSER_FADE_BACKGROUND = - 'linear-gradient(to bottom, transparent, color-mix(in srgb, var(--dt-background) 10%, transparent))' - -const pickPlaceholder = (pool: readonly string[]) => pool[Math.floor(Math.random() * pool.length)] - -/** Completion items can carry an `action` (set in use-slash-completions) that - * runs a side effect on pick instead of inserting a chip — e.g. the session - * picker's "Browse all…" entry opens the overlay. Table-driven so new action - * items are a registry row, not a composer branch. */ -const COMPLETION_ACTIONS: Record void> = { - 'session-picker': () => setSessionPickerOpen(true) -} - -/** Map a picked `/` completion to its pill accent. Driven by the completion - * group set in use-slash-completions (Skills / Themes / Commands|Options). */ -function slashChipKindForItem(item: Unstable_TriggerItem): SlashChipKind { - const group = (item.metadata as { group?: unknown } | undefined)?.group - - if (group === 'Skills') { - return 'skill' - } - - if (group === 'Themes') { - return 'theme' - } - - 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 - entryId: string - sessionKey: string -} - -const cloneAttachments = (attachments: ComposerAttachment[]) => attachments.map(a => ({ ...a })) - -// Quiet period after the last keystroke before persisting the draft; -// unmount/pagehide flushes bypass it. -const DRAFT_PERSIST_DEBOUNCE_MS = 400 - export function ChatBar({ busy, cwd, @@ -197,39 +115,9 @@ export function ChatBar({ onSubmit, onTranscribeAudio }: ChatBarProps) { - const aui = useAui() - const draft = useAuiState(s => s.composer.text) - - // assistant-ui's composer *mutators* (setText/send/…) throw "Composer is not - // available" when the thread's composer core isn't bound yet — and unlike the - // read path (`s.composer.text`, which is null-safe), there's no graceful - // fallback. There's a startup/thread-swap window where this ChatBar's mount - // effects (draft restore, clearDraft, external inserts) run before the core - // binds; the popout refactor (#49488) widened it by moving the composer out - // of the contain wrapper into a sibling of the thread, so the throw began - // surfacing as an uncaught error that wedged the desktop input (#49903). - // - // Guard every mutation: if the core isn't ready, no-op the assistant-ui write. - // The contentEditable DOM + draftRef already hold the text, and the - // draft⇄editor sync reconciles composer state once the core attaches, so the - // draft is never lost — only the (premature) state push is skipped. - const setComposerText = useCallback( - (value: string) => { - try { - aui.composer().setText(value) - } catch { - // Composer core not bound yet — DOM/draftRef carry the text; the sync - // effect re-applies it after bind. Swallow so the input stays usable. - } - }, - [aui] - ) - const attachments = useStore($composerAttachments) - const queuedPromptsBySession = useStore($queuedPromptsBySession) - const statusItemsBySession = useStore($statusItemsBySession) - const previewStatusBySession = useStore($previewStatusBySession) const scrolledUp = useStore($threadScrolledUp) + const autoSpeak = useStore($autoSpeakReplies) // The turn is parked on the user (clarify / approval / sudo / secret). Esc must // not interrupt it — there's nothing actively running to stop, and stopping // would discard a question the user may want to come back to. The blocking @@ -244,29 +132,17 @@ export function ChatBar({ const popoutPosition = useStore($composerPopoutPosition) const activeQueueSessionKey = queueSessionKey || sessionId || null - const queuedPrompts = useMemo( - () => (activeQueueSessionKey ? (queuedPromptsBySession[activeQueueSessionKey] ?? []) : []), - [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 || - (previewStatusBySession[statusSessionId]?.length ?? 0) > 0 - : false), - [previewStatusBySession, queuedPrompts.length, statusItemsBySession, statusSessionId] - ) + // Coarse edge: re-renders ChatBar only when the stack shows/hides, NOT on + // every per-item status mutation or other sessions' churn (see the hook). + const statusPresent = useSessionStatusPresence(statusSessionId) const composerRef = useRef(null) const composerSurfaceRef = useRef(null) - const editorRef = useRef(null) const handleComposerPopOut = useCallback(() => { triggerHaptic('open') @@ -296,57 +172,115 @@ export function ChatBar({ position: popoutPosition }) - const draftRef = useRef(draft) - 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()) const urlInputRef = useRef(null) const [urlOpen, setUrlOpen] = useState(false) const [urlValue, setUrlValue] = useState('') - const [expanded, setExpanded] = useState(false) - const [voiceConversationActive, setVoiceConversationActive] = useState(false) - const [tight, setTight] = useState(false) - const [dragActive, setDragActive] = useState(false) - const [queueEdit, setQueueEdit] = useState(null) - const [focusRequestId, setFocusRequestId] = useState(0) - const queueEditRef = useRef(queueEdit) - queueEditRef.current = queueEdit - const dragDepthRef = useRef(0) + // Coordinator-owned: the draft engine reads the live queue-edit snapshot off + // this ref (to suppress its stash while editing a queued prompt) and the queue + // engine writes it — an explicit shared handle, not a back-reference. + const queueEditRef = useRef(null) const composingRef = useRef(false) // true during IME composition (CJK input) - const lastSpokenIdRef = useRef(null) - - const narrow = useMediaQuery('(max-width: 30rem)') const { availableThemes, themeName } = useTheme() const at = useAtCompletions({ gateway: gateway ?? null, sessionId: sessionId ?? null, cwd: cwd ?? null }) const slash = useSlashCompletions({ activeSkin: themeName, gateway: gateway ?? null, skinThemes: availableThemes }) - const stacked = expanded || narrow || tight - const trimmedDraft = draft.trim() - const hasComposerPayload = trimmedDraft.length > 0 || attachments.length > 0 + const { t } = useI18n() + const gatewayState = useStore($gatewayState) + const newSessionPlaceholders = t.composer.newSessionPlaceholders + const followUpPlaceholders = t.composer.followUpPlaceholders + const reconnecting = gatewayState === 'closed' || gatewayState === 'error' + const inputDisabled = disabled && !reconnecting + + // The draft engine — detached source of truth (DOM + draftRef + edge + // selectors); typing never re-renders the chrome. ChatBar owns `queueEditRef` + // and threads it in so the draft↔queue coupling is an explicit dep, not a tangle. + const { + activeQueueSessionKeyRef, + clearDraft, + draftRef, + editorRef, + focusInput, + hasText, + insertInlineRefs, + insertText, + isHelpHint, + isSteerableText, + loadIntoComposer, + requestMainFocus, + sessionIdRef, + setComposerText, + stashAt + } = useComposerDraft({ activeQueueSessionKey, focusKey, inputDisabled, queueEditRef, sessionId }) + + // The queue engine — queued turns, in-place editing, the shared drain lock, + // and bounded auto-drain. Consumes the draft API and writes `queueEditRef`. + const { + beginQueuedEdit, + drainNextQueued, + editingQueuedPrompt, + exitQueuedEdit, + queueCurrentDraft, + queueEdit, + queuedPrompts, + sendQueuedNow, + stepQueuedEdit + } = useComposerQueue({ + activeQueueSessionKey, + attachments, + busy, + clearDraft, + draftRef, + focusInput, + loadIntoComposer, + onCancel, + onSubmit, + queueEditRef, + queueSessionKey, + sessionId + }) + + const statusStackVisible = queuedPrompts.length > 0 || statusPresent + + const { stacked } = useComposerMetrics({ composerRef, composerSurfaceRef, editorRef, poppedOut }) + const hasComposerPayload = hasText || attachments.length > 0 const canSubmit = busy || hasComposerPayload - const editingQueuedPrompt = queueEdit ? (queuedPrompts.find(entry => entry.id === queueEdit.entryId) ?? null) : null const busyAction = busy && hasComposerPayload ? 'queue' : 'stop' // Steer only makes sense mid-turn, text-only (the gateway can't carry images // into a tool result) and never for a slash command (those execute inline). - const canSteer = - busy && !!onSteer && attachments.length === 0 && trimmedDraft.length > 0 && !SLASH_COMMAND_RE.test(trimmedDraft) + const canSteer = busy && !!onSteer && attachments.length === 0 && isSteerableText - const showHelpHint = draft === '?' + const showHelpHint = isHelpHint - const { t } = useI18n() - const gatewayState = useStore($gatewayState) - const newSessionPlaceholders = t.composer.newSessionPlaceholders - const followUpPlaceholders = t.composer.followUpPlaceholders - const reconnecting = gatewayState === 'closed' || gatewayState === 'error' - const inputDisabled = disabled && !reconnecting + // The submit engine — the orchestration seam where draft + queue meet. Owns + // the submit decision tree, the send-with-restore primitive, and steer. + const { steerDraft, submitDraft } = useComposerSubmit({ + activeQueueSessionKey, + activeQueueSessionKeyRef, + attachments, + busy, + canSteer, + clearDraft, + disabled, + draftRef, + drainNextQueued, + editorRef, + exitQueuedEdit, + focusInput, + inputDisabled, + loadIntoComposer, + onCancel, + onSteer, + onSubmit, + queueCurrentDraft, + queueEdit, + queuedPrompts, + sessionId, + setComposerText, + stashAt + }) // 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 @@ -388,207 +322,12 @@ export function ChatBar({ : t.composer.placeholderStarting : restingPlaceholder - const focusInput = useCallback(() => { - focusComposerInput(editorRef.current) - markActiveComposer('main') - }, []) - - const requestMainFocus = useCallback(() => { - setFocusRequestId(id => id + 1) - }, []) - - const appendExternalText = useCallback( - (text: string, mode: ComposerInsertMode) => { - const value = text.trim() - - if (!value) { - return - } - - const base = mode === 'inline' ? draftRef.current.trimEnd() : draftRef.current - const sep = mode === 'inline' ? (base ? ' ' : '') : base && !base.endsWith('\n') ? '\n\n' : '' - const next = `${base}${sep}${value}` - - draftRef.current = next - setComposerText(next) - - const editor = editorRef.current - - if (editor) { - renderComposerContents(editor, next) - placeCaretEnd(editor) - } - - setFocusRequestId(id => id + 1) - }, - [setComposerText] - ) - - useEffect(() => { - if (!inputDisabled) { - focusInput() - } - }, [focusInput, focusKey, focusRequestId, inputDisabled]) - - useEffect(() => { - if (inputDisabled) { - return undefined - } - - const offFocus = onComposerFocusRequest(target => { - if (target === 'main') { - setFocusRequestId(id => id + 1) - } - }) - - const offInsert = onComposerInsertRequest(({ mode, target, text }) => { - if (target === 'main') { - appendExternalText(text, mode) - } - }) - - return () => { - offFocus() - offInsert() - } - }, [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 - // to `$composerDraft` per keystroke any more — nobody outside the composer - // subscribes to it (verified by grep), and the round-trip - // `setText` ⇄ `subscribe` ⇄ `setText` was adding two useEffects to the per- - // keystroke critical path. `reconcileComposerTerminalSelections` only - // matters when the draft is submitted; we now call it from the submit - // path instead. - useEffect(() => { - draftRef.current = draft - - const editor = editorRef.current - - if (editor && document.activeElement !== editor && composerPlainText(editor) !== draft) { - renderComposerContents(editor, draft) - } - }, [draft]) - useEffect(() => { if (urlOpen) { window.requestAnimationFrame(() => urlInputRef.current?.focus({ preventScroll: true })) } }, [urlOpen]) - // Expansion (input on its own full-width row, controls below) is driven by - // the editor's *actual* rendered height via the ResizeObserver in - // syncComposerMetrics — it only fires when the text genuinely wraps to a - // second line, so the layout flips exactly at the wrap point rather than at - // a guessed character count. We only handle the two cases the observer - // can't: an explicit newline (expand before layout settles) and an emptied - // draft (collapse back). We never read scrollHeight per keystroke. - useEffect(() => { - if (!draft) { - setExpanded(false) - - return - } - - if (expanded) { - return - } - - // Only a non-trailing newline forces an immediate expand. A trailing newline - // (or phantom \n from contenteditable junk) is left to the ResizeObserver, - // which expands only when the editor's real height actually grows. - if (draft.trimEnd().includes('\n')) { - setExpanded(true) - } - }, [draft, expanded]) - - // Bucket measured heights so we only invalidate the global CSS var when - // the size crosses a meaningful threshold. Without bucketing, the editor - // grows ~1px per character → setProperty fires every keystroke → entire - // tree's computed style is invalidated → next paint forces a full - // recalculate-style pass. With an 8px bucket, the invalidation rate drops - // ~8× and small char-by-char typing produces no style invalidation at all - // until a wrap or row change actually happens. - const lastBucketedHeightRef = useRef(0) - const lastBucketedSurfaceHeightRef = useRef(0) - const lastTightRef = useRef(null) - - const syncComposerMetrics = useCallback(() => { - const composer = composerRef.current - - if (!composer) { - return - } - - // Floating composer is out of the thread's flow — it must not reserve any - // bottom clearance. Zero the measured vars so the thread reclaims the space. - // (Read globals here so the callback stays stable; mirror the popoutAllowed - // gate since secondary windows are forced docked.) - if ($composerPoppedOut.get() && !isSecondaryWindow()) { - const root = document.documentElement - lastBucketedHeightRef.current = 0 - lastBucketedSurfaceHeightRef.current = 0 - root.style.setProperty('--composer-measured-height', '0px') - root.style.setProperty('--composer-surface-measured-height', '0px') - - return - } - - const { height, width } = composer.getBoundingClientRect() - const surfaceHeight = composerSurfaceRef.current?.getBoundingClientRect().height - const root = document.documentElement - - if (width > 0) { - const nextTight = width < COMPOSER_STACK_BREAKPOINT_PX - - if (nextTight !== lastTightRef.current) { - lastTightRef.current = nextTight - setTight(nextTight) - } - } - - // Expand once the input has actually wrapped past a single line. The - // observer only fires on real size changes, so this reads scrollHeight at - // most once per wrap (not per keystroke). One line ≈ 28px (1.625rem - // min-height + padding); a second line clears ~36px. We only ever expand - // here — collapse is handled by the emptied-draft effect to avoid - // oscillating across the wrap boundary as the input switches widths. - const editor = editorRef.current - - if (editor && editor.scrollHeight > COMPOSER_SINGLE_LINE_MAX_PX) { - setExpanded(true) - } - - if (height > 0) { - const bucket = Math.round(height / 8) * 8 - - if (bucket !== lastBucketedHeightRef.current) { - lastBucketedHeightRef.current = bucket - root.style.setProperty('--composer-measured-height', `${bucket}px`) - } - } - - if (surfaceHeight && surfaceHeight > 0) { - const bucket = Math.round(surfaceHeight / 8) * 8 - - if (bucket !== lastBucketedSurfaceHeightRef.current) { - lastBucketedSurfaceHeightRef.current = bucket - root.style.setProperty('--composer-surface-measured-height', `${bucket}px`) - } - } - }, []) - - useResizeObserver(syncComposerMetrics, composerRef, composerSurfaceRef, editorRef) - - // Toggling pop-out changes whether the composer reserves thread clearance. - // The ResizeObserver may not fire (the box can keep the same box size), so - // re-sync explicitly: docked republishes the measured height, floating zeroes - // it so the thread reclaims the bottom space. - useEffect(() => { - syncComposerMetrics() - }, [poppedOut, syncComposerMetrics]) - // Keep the floating box on-screen: re-clamp (with the real measured size + // thread bounds) when it pops out and on every window resize — so a position // persisted on a bigger/other monitor, a shrunk window, or now-wider sidebar @@ -617,72 +356,6 @@ export function ChatBar({ } }, [poppedOut]) - useEffect(() => { - return () => { - const root = document.documentElement - root.style.removeProperty('--composer-measured-height') - root.style.removeProperty('--composer-surface-measured-height') - } - }, []) - - const insertText = (text: string) => { - const currentDraft = draftRef.current - const sep = currentDraft && !currentDraft.endsWith('\n') ? '\n' : '' - const nextDraft = `${currentDraft}${sep}${text}` - - draftRef.current = nextDraft - setComposerText(nextDraft) - - // Push the new text into the contentEditable editor directly. Setting the - // assistant-ui composer state alone is not enough: the draft→editor sync - // effect only re-renders the editor when it is NOT focused - // (document.activeElement !== editor), and the dictation/insert paths - // typically run while the editor has (or immediately regains) focus — so - // the store would hold the text but the visible editor would stay empty - // and there'd be nothing to send. Mirror appendExternalText here. - const editor = editorRef.current - - if (editor) { - renderComposerContents(editor, nextDraft) - placeCaretEnd(editor) - } - - requestMainFocus() - } - - const insertInlineRefs = (refs: InlineRefInput[]) => { - const editor = editorRef.current - - if (!editor) { - return false - } - - const nextDraft = insertInlineRefsIntoEditor(editor, refs) - - if (nextDraft === null) { - return false - } - - draftRef.current = nextDraft - setComposerText(nextDraft) - requestMainFocus() - - return true - } - - // Latest-closure ref so the (once-only) subscription always calls the current - // insertInlineRefs without re-subscribing every render. - const insertInlineRefsRef = useRef(insertInlineRefs) - insertInlineRefsRef.current = insertInlineRefs - - useEffect(() => { - return onComposerInsertRefsRequest(({ refs, target }) => { - if (target === 'main') { - insertInlineRefsRef.current(refs) - } - }) - }, []) - const [trigger, setTrigger] = useState(null) const [triggerActive, setTriggerActive] = useState(0) const [triggerItems, setTriggerItems] = useState([]) @@ -743,7 +416,20 @@ export function ChatBar({ // Pull the live contentEditable text into draftRef + the AUI composer state // (which drives `hasComposerPayload` → the send button). Shared by the input // and compositionend paths so committed IME text reaches state through either. + // A pending coalesced flush (rAF id). `composerPlainText` serializes the whole + // editor (O(n)), so running it on every event during a burst — holding a key, + // or holding Cmd+V into a growing editor — is O(n²) across the burst. The + // contentEditable DOM is the source of truth (submit + the compositionend / + // keydown paths re-read it synchronously), so collapsing the input/paste + // flushes to one per paint is lossless. + const flushRafRef = useRef(undefined) + const flushEditorToDraft = (editor: HTMLDivElement) => { + if (flushRafRef.current !== undefined) { + window.cancelAnimationFrame(flushRafRef.current) + flushRafRef.current = undefined + } + normalizeComposerEditorDom(editor) const nextDraft = composerPlainText(editor) @@ -756,6 +442,29 @@ export function ChatBar({ window.setTimeout(refreshTrigger, 0) } + // Coalesce the high-frequency input/paste flushes to one per frame. Immediate + // paths (compositionend, Enter/keydown, submit) keep calling + // flushEditorToDraft directly, which cancels any pending coalesced run first. + const scheduleFlushEditorToDraft = (editor: HTMLDivElement) => { + if (flushRafRef.current !== undefined) { + return + } + + flushRafRef.current = window.requestAnimationFrame(() => { + flushRafRef.current = undefined + flushEditorToDraft(editor) + }) + } + + useEffect( + () => () => { + if (flushRafRef.current !== undefined) { + window.cancelAnimationFrame(flushRafRef.current) + } + }, + [] + ) + const handleEditorInput = (event: FormEvent) => { // During IME composition the DOM contains uncommitted preedit text // mixed with real content. Skip state writes — compositionend flushes @@ -764,7 +473,7 @@ export function ChatBar({ return } - flushEditorToDraft(event.currentTarget) + scheduleFlushEditorToDraft(event.currentTarget) } const handlePaste = (event: ClipboardEvent) => { @@ -814,7 +523,7 @@ export function ChatBar({ event.preventDefault() insertPlainTextAtCaret(event.currentTarget, pastedText) - flushEditorToDraft(event.currentTarget) + scheduleFlushEditorToDraft(event.currentTarget) } const triggerAdapter: Unstable_TriggerAdapter | null = @@ -1247,137 +956,15 @@ export function ChatBar({ window.setTimeout(refreshTrigger, 0) } - const resetDragState = () => { - dragDepthRef.current = 0 - setDragActive(false) - } - - const handleDragEnter = (event: ReactDragEvent) => { - if (!onAttachDroppedItems || !dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME)) { - return - } - - event.preventDefault() - dragDepthRef.current += 1 - - if (!dragActive) { - setDragActive(true) - } - } - - const handleDragOver = (event: ReactDragEvent) => { - if (!onAttachDroppedItems || !dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME)) { - return - } - - event.preventDefault() - event.dataTransfer.dropEffect = 'copy' - } - - const handleDragLeave = (event: ReactDragEvent) => { - if (!onAttachDroppedItems) { - return - } - - event.preventDefault() - dragDepthRef.current = Math.max(0, dragDepthRef.current - 1) - - if (dragDepthRef.current === 0) { - setDragActive(false) - } - } - - const handleDrop = (event: ReactDragEvent) => { - if (!onAttachDroppedItems) { - return - } - - event.preventDefault() - resetDragState() - - const candidates = extractDroppedFiles(event.dataTransfer) - - if (candidates.length === 0) { - return - } - - // In-app drags (project tree / gutter) are workspace-relative paths the - // gateway resolves directly, so they stay inline @file:/@line: refs. OS - // drops are absolute local paths a remote gateway can't read (and images - // need byte upload for vision), so route them through the upload pipeline. - const { inAppRefs, osDrops } = partitionDroppedFiles(candidates) - const refs = droppedFileInlineRefs(inAppRefs, cwd) - - if (refs.length && insertInlineRefs(refs)) { - triggerHaptic('selection') - } - - if (osDrops.length) { - void Promise.resolve(onAttachDroppedItems(osDrops)).then(attached => { - if (attached) { - triggerHaptic('selection') - requestMainFocus() - } - }) - } - } - - const handleInputDragOver = (event: ReactDragEvent) => { - if (!dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME)) { - return - } - - event.preventDefault() - event.stopPropagation() - event.dataTransfer.dropEffect = 'copy' - } - - const handleInputDrop = (event: ReactDragEvent) => { - if (!dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME)) { - return - } - - const candidates = extractDroppedFiles(event.dataTransfer) - - if (!candidates.length) { - return - } - - event.preventDefault() - event.stopPropagation() - resetDragState() - - // Dropping straight onto the text box used to inline-ref *every* file — - // including OS/Finder drops, whose absolute local path a remote gateway - // can't read and whose image bytes never reached vision. Split by origin: - // in-app drags stay inline refs; OS drops go through the upload pipeline. - // (When no upload handler is wired, fall back to inline refs for all.) - const attach = onAttachDroppedItems - const { inAppRefs, osDrops } = partitionDroppedFiles(candidates) - const refs = droppedFileInlineRefs(attach ? inAppRefs : candidates, cwd) - - if (refs.length && insertInlineRefs(refs)) { - triggerHaptic('selection') - } - - if (attach && osDrops.length) { - void Promise.resolve(attach(osDrops)).then(attached => { - if (attached) { - triggerHaptic('selection') - requestMainFocus() - } - }) - } - } - - const clearDraft = useCallback(() => { - setComposerText('') - draftRef.current = '' - - if (editorRef.current) { - editorRef.current.replaceChildren() - } - }, [setComposerText]) + const { + dragActive, + handleDragEnter, + handleDragLeave, + handleDragOver, + handleDrop, + handleInputDragOver, + handleInputDrop + } = useComposerDrop({ cwd, insertInlineRefs, onAttachDroppedItems, requestMainFocus }) // Hand a worktree off to the controller: open a fresh session anchored there, // carrying the composer draft as its first turn. Clearing here means the draft @@ -1453,329 +1040,6 @@ export function ChatBar({ [cwd] ) - const loadIntoComposer = (text: string, attachments: ComposerAttachment[]) => { - draftRef.current = text - setComposerText(text) - $composerAttachments.set(cloneAttachments(attachments)) - - const editor = editorRef.current - - if (editor) { - renderComposerContents(editor, text) - placeCaretEnd(editor) - } - } - - 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 - // on enter. Keyed writes are idempotent, so no skip-sentinel. - useEffect(() => { - const { attachments, text } = takeSessionDraft(activeQueueSessionKey) - loadIntoComposer(text, attachments) - - return () => { - const editing = queueEditRef.current - - if (editing?.sessionKey === activeQueueSessionKey) { - stashAt(activeQueueSessionKey, editing.draft, editing.attachments) - } else if (!isBrowsingHistory(sessionId)) { - stashAt(activeQueueSessionKey) - } - } - }, [activeQueueSessionKey]) // eslint-disable-line react-hooks/exhaustive-deps - - // Debounced stash into the active scope. Skipped while browsing history or - // editing a queued prompt — recalled text must not clobber the real draft. - useEffect(() => { - if (isBrowsingHistory(sessionId) || queueEdit) { - return - } - - pendingDraftPersistRef.current = { scope: activeQueueSessionKey, text: draft } - - const handle = window.setTimeout(() => { - pendingDraftPersistRef.current = null - stashAt(activeQueueSessionKey, draft) - }, DRAFT_PERSIST_DEBOUNCE_MS) - - return () => window.clearTimeout(handle) - }, [activeQueueSessionKey, draft, queueEdit, sessionId]) - - // pagehide is load-bearing: React skips effect cleanups on reload, so Cmd+R - // inside the debounce window would drop trailing keystrokes without this. - useEffect(() => { - const flushPendingDraftPersist = () => { - const pending = pendingDraftPersistRef.current - - if (!pending) { - return - } - - pendingDraftPersistRef.current = null - stashAt(pending.scope, pending.text) - } - - window.addEventListener('pagehide', flushPendingDraftPersist) - - return () => { - window.removeEventListener('pagehide', flushPendingDraftPersist) - flushPendingDraftPersist() - } - }, []) - - const beginQueuedEdit = (entry: QueuedPromptEntry) => { - if (!activeQueueSessionKey || queueEdit) { - return - } - - setQueueEdit({ - attachments: cloneAttachments($composerAttachments.get()), - draft: draftRef.current, - entryId: entry.id, - sessionKey: activeQueueSessionKey - }) - loadIntoComposer(entry.text, entry.attachments) - triggerHaptic('selection') - focusInput() - } - - // Walk queued entries while editing (ArrowUp = older, ArrowDown = newer), - // saving the in-progress edit on each step. Stepping newer past the last - // entry exits edit mode and restores the pre-edit draft. - const stepQueuedEdit = (direction: -1 | 1) => { - if (!queueEdit) { - return false - } - - const index = queuedPrompts.findIndex(e => e.id === queueEdit.entryId) - const target = index + direction - - if (index < 0 || target < 0) { - return index >= 0 // at the oldest: swallow; missing entry: let it fall through - } - - const saved = updateQueuedPrompt(queueEdit.sessionKey, queueEdit.entryId, { - attachments: cloneAttachments($composerAttachments.get()), - text: draftRef.current - }) - - const next = queuedPrompts[target] - - if (next) { - setQueueEdit({ ...queueEdit, entryId: next.id }) - loadIntoComposer(next.text, next.attachments) - } else { - setQueueEdit(null) - loadIntoComposer(queueEdit.draft, queueEdit.attachments) - } - - triggerHaptic(saved ? 'success' : 'selection') - focusInput() - - return true - } - - const exitQueuedEdit = (action: 'cancel' | 'save'): boolean => { - if (!queueEdit) { - return false - } - - if (action === 'save') { - const text = draftRef.current - const next = cloneAttachments($composerAttachments.get()) - - if (!text.trim() && next.length === 0) { - return false - } - - const saved = updateQueuedPrompt(queueEdit.sessionKey, queueEdit.entryId, { attachments: next, text }) - triggerHaptic(saved ? 'success' : 'selection') - } else { - triggerHaptic('cancel') - } - - loadIntoComposer(queueEdit.draft, queueEdit.attachments) - setQueueEdit(null) - focusInput() - - return true - } - - const queueCurrentDraft = useCallback(() => { - if (!activeQueueSessionKey || (!draft.trim() && attachments.length === 0)) { - return false - } - - if (!enqueueQueuedPrompt(activeQueueSessionKey, { text: draft, attachments })) { - return false - } - - clearDraft() - clearComposerAttachments() - triggerHaptic('selection') - - return true - }, [activeQueueSessionKey, attachments, clearDraft, draft]) - - // Steer the live turn (nudge without interrupting). Clears the draft up front - // for snappy feedback; if the gateway rejects (no live tool window) the words - // are re-queued so nothing is lost — same safety net as a plain queue. - const steerDraft = useCallback(() => { - if (!onSteer || !canSteer) { - return - } - - const text = draftRef.current.trim() - - triggerHaptic('submit') - clearDraft() - - void Promise.resolve(onSteer(text)).then(accepted => { - if (!accepted && activeQueueSessionKey) { - enqueueQueuedPrompt(activeQueueSessionKey, { text, attachments: [] }) - } - }) - }, [activeQueueSessionKey, canSteer, clearDraft, onSteer]) - - // All queue drain paths share one lock + send-then-remove sequence. - // `pickEntry` lets each caller choose head, by-id, or skip-edited. - const runDrain = useCallback( - async (pickEntry: (entries: QueuedPromptEntry[]) => QueuedPromptEntry | undefined): Promise => { - if (drainingQueueRef.current || !activeQueueSessionKey) { - return false - } - - const entry = pickEntry(queuedPrompts) - - if (!entry) { - return false - } - - drainingQueueRef.current = true - - try { - const accepted = await Promise.resolve( - onSubmit(entry.text, { attachments: entry.attachments, fromQueue: true }) - ) - - if (accepted === false) { - return false - } - - drainFailuresRef.current.delete(entry.id) - removeQueuedPrompt(activeQueueSessionKey, entry.id) - resetBrowseState(sessionId) - - return true - } finally { - drainingQueueRef.current = false - } - }, - [activeQueueSessionKey, onSubmit, queuedPrompts, sessionId] - ) - - const pickDrainHead = useCallback( - (entries: QueuedPromptEntry[]) => { - const skip = queueEditRef.current?.entryId - - 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) { - return false - } - - if (busy) { - // Promote to the head, then interrupt. The gateway always emits a - // settle (message.complete + session.info running:false) when the - // turn unwinds, and the busy→false auto-drain below sends this entry. - promoteQueuedPrompt(activeQueueSessionKey, id) - triggerHaptic('selection') - void Promise.resolve(onCancel()) - - 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] - ) - - // 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 - } - - 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]) - // Esc cancels the in-flight turn when the CHAT has focus — not just the // composer input (which has its own handler above). Clicking into the // transcript and hitting Esc now stops the run, matching the Stop button. @@ -1816,117 +1080,6 @@ export function ChatBar({ return () => window.removeEventListener('keydown', onKeyDown) }, []) - // 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. - useEffect(() => { - if (!queueEdit) { - return - } - - if (queueEdit.sessionKey === activeQueueSessionKey) { - if (editingQueuedPrompt) { - return - } - - loadIntoComposer(queueEdit.draft, queueEdit.attachments) - } - - setQueueEdit(null) - }, [activeQueueSessionKey, editingQueuedPrompt, queueEdit]) // eslint-disable-line react-hooks/exhaustive-deps - - const dispatchSubmit = (text: string, attachments?: ComposerAttachment[]) => { - const submittedScope = activeQueueSessionKeyRef.current - const submittedAttachments = attachments ?? [] - - const restore = () => { - loadIntoComposer(text, submittedAttachments) - stashAt(activeQueueSessionKeyRef.current, text, submittedAttachments) - } - - void Promise.resolve(attachments ? onSubmit(text, { attachments }) : onSubmit(text)) - .then(accepted => void (accepted === false ? restore() : clearSessionDraft(submittedScope))) - .catch(restore) - } - - // External "submit this prompt" requests (e.g. the review pane's agent-ship - // button) route through the same send path. A ref keeps the listener stable - // while always calling the latest dispatchSubmit closure. - const dispatchSubmitRef = useRef(dispatchSubmit) - dispatchSubmitRef.current = dispatchSubmit - - useEffect( - () => - onComposerSubmitRequest(({ target, text }) => { - if (target === 'main' && !inputDisabled) { - dispatchSubmitRef.current(text) - } - }), - [inputDisabled] - ) - - 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 - // not have synced yet — reading state here drops the message (Enter looks - // like it does nothing; typing a trailing space only "fixes" it because the - // extra input event forces a state sync). draftRef is updated on every - // input event; refresh it from the editor once more to also cover an - // in-flight keystroke that hasn't fired its input event yet. - const editor = editorRef.current - - if (editor) { - const domText = composerPlainText(editor) - - if (domText !== draftRef.current) { - draftRef.current = domText - setComposerText(domText) - } - } - - const text = draftRef.current - const payloadPresent = text.trim().length > 0 || attachments.length > 0 - - if (queueEdit) { - exitQueuedEdit('save') - } else if (busy) { - // Slash commands should execute immediately even while the agent is - // busy — they're client-side operations (/yolo, /skin, /new, /help, - // etc.) or self-contained gateway RPCs (/status, /compress). onSubmit - // routes them to executeSlashCommand, which has its own per-command - // busy guard for commands that genuinely need an idle session (skill - // /send directives). Queuing them would make every slash command wait - // for the current turn to finish, which is how the TUI never behaves. - if (!attachments.length && SLASH_COMMAND_RE.test(text.trim())) { - triggerHaptic('submit') - clearDraft() - dispatchSubmit(text) - } else if (payloadPresent) { - queueCurrentDraft() - } else { - // Stop button (the only way to reach here while busy with an empty - // composer — empty Enter is short-circuited in the keydown handler). - triggerHaptic('cancel') - void Promise.resolve(onCancel()) - } - } else if (!payloadPresent && queuedPrompts.length > 0) { - void drainNextQueued() - } else if (payloadPresent) { - const submittedAttachments = cloneAttachments(attachments) - triggerHaptic('submit') - resetBrowseState(sessionId) - clearDraft() - clearComposerAttachments() - dispatchSubmit(text, submittedAttachments) - } - - focusInput() - } - const submitUrl = () => { const url = urlValue.trim() @@ -1945,82 +1098,27 @@ export function ChatBar({ setUrlOpen(false) } - const { dictate, voiceActivityState, voiceStatus } = useVoiceRecorder({ + const { + conversation, + dictate, + endConversation, + handleToggleAutoSpeak, + startConversation, + voiceActivityState, + voiceConversationActive, + voiceStatus + } = useComposerVoice({ + busy, + clearDraft, + disabled, focusInput, + insertText, maxRecordingSeconds, - onTranscript: insertText, - onTranscribeAudio - }) - - const pendingResponse = () => { - const messages = $messages.get() - const last = messages.findLast(m => m.role === 'assistant' && !m.hidden) - - if (!last || last.id === lastSpokenIdRef.current) { - return null - } - - const text = chatMessageText(last).trim() - - if (!text) { - return null - } - - return { - id: last.id, - pending: Boolean(last.pending), - text - } - } - - const consumePendingResponse = () => { - const messages = $messages.get() - const last = messages.findLast(m => m.role === 'assistant' && !m.hidden) - - if (last) { - lastSpokenIdRef.current = last.id - } - } - - const submitVoiceTurn = async (text: string) => { - if (busy) { - return - } - - triggerHaptic('submit') - resetBrowseState(sessionId) - clearDraft() - await onSubmit(text) - } - - const conversation = useVoiceConversation({ - busy, - consumePendingResponse, - enabled: voiceConversationActive, - onFatalError: () => setVoiceConversationActive(false), - onSubmit: submitVoiceTurn, + onSubmit, onTranscribeAudio, - pendingResponse + sessionId }) - // The `composer.voice` hotkey (Ctrl+B) toggles the conversation. Starting - // with STT unconfigured lets the conversation surface its own "configure - // speech-to-text" notice rather than silently no-opping. - const toggleVoiceConversation = useCallback(() => { - if (disabled) { - return - } - - if (voiceConversationActive) { - setVoiceConversationActive(false) - void conversation.end() - } else { - setVoiceConversationActive(true) - } - }, [conversation, disabled, voiceConversationActive]) - - useEffect(() => onComposerVoiceToggleRequest(toggleVoiceConversation), [toggleVoiceConversation]) - const contextMenu = ( { - setVoiceConversationActive(false) - void conversation.end() - }, - onStart: () => setVoiceConversationActive(true), + onEnd: endConversation, + onStart: startConversation, onStopTurn: conversation.stopTurn, onToggleMute: conversation.toggleMute, status: conversation.status @@ -2060,6 +1156,7 @@ export function ChatBar({ hasComposerPayload={hasComposerPayload} onDictate={dictate} onSteer={steerDraft} + onToggleAutoSpeak={handleToggleAutoSpeak} state={state} voiceStatus={voiceStatus} /> @@ -2145,7 +1242,12 @@ export function ChatBar({ {dragging && poppedOut && (
+ } + label={c.queued(entries.length)} + > {entries.map(entry => { const isEditing = editingId === entry.id const attachmentsCount = entry.attachments.length @@ -52,7 +56,7 @@ export function QueuePanel({ busy, editingId, entries, onDelete, onEdit, onSendN type="button" variant="ghost" > - + @@ -65,7 +69,7 @@ export function QueuePanel({ busy, editingId, entries, onDelete, onEdit, onSendN type="button" variant="ghost" > - + @@ -77,7 +81,7 @@ export function QueuePanel({ busy, editingId, entries, onDelete, onEdit, onSendN type="button" variant="ghost" > - + diff --git a/apps/desktop/src/app/chat/composer/status-stack/index.tsx b/apps/desktop/src/app/chat/composer/status-stack/index.tsx index 93c8a2dc1af4..fd360764da45 100644 --- a/apps/desktop/src/app/chat/composer/status-stack/index.tsx +++ b/apps/desktop/src/app/chat/composer/status-stack/index.tsx @@ -35,11 +35,11 @@ const BACKGROUND_POLL_MS = 5_000 // letting dead URLs pile up. File previews (a real on-disk artifact) stand alone. const isLocalhostPreview = (target: string): boolean => /\b(?:localhost|127\.0\.0\.1|0\.0\.0\.0)\b/i.test(target) -// Real codicons per group (no sparkles): a checklist for todos, a bot for -// subagents, a background process glyph for background tasks. +// Real codicons per group (no sparkles): a checklist for todos, the agent glyph +// for subagents, a background process glyph for background tasks. const GROUP_ICON: Record = { todo: 'checklist', - subagent: 'hubot', + subagent: 'agent', background: 'server-process' } @@ -118,48 +118,59 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro const hasBackgroundGroup = groups.some(g => g.type === 'background') - const sections: { key: string; node: ReactNode }[] = groups.map(group => ({ - key: group.type, - node: ( - - {t.statusStack.agents} - - ) : undefined - } - defaultCollapsed={group.type !== 'todo'} - icon={} - label={groupLabel(group, t.statusStack)} - > - {group.items.map(item => ( - dismissBackgroundProcess(sessionId, id) : undefined} - onOpen={() => openSubagent(item)} - onStop={sessionId ? id => void stopBackgroundProcess(sessionId, id) : undefined} - /> - ))} - {group.type === 'background' && previewRows} - - ) - })) + const previewBlock =
{previewRows}
- // No background group to host them (e.g. a standalone on-disk file preview): - // keep the previews as their own row block so they don't disappear. - if (previewRows.length > 0 && !hasBackgroundGroup) { + const sections: { key: string; node: ReactNode }[] = [] + + for (const group of groups) { sections.push({ - key: 'preview', - node:
{previewRows}
+ key: group.type, + node: ( + + {t.statusStack.agents} + + ) : undefined + } + defaultCollapsed={group.type !== 'todo'} + icon={} + label={groupLabel(group, t.statusStack)} + > + {group.items.map(item => ( + dismissBackgroundProcess(sessionId, id) : undefined} + onOpen={() => openSubagent(item)} + onStop={sessionId ? id => void stopBackgroundProcess(sessionId, id) : undefined} + /> + ))} + + ) }) + + // Preview links belong to the background group (a localhost dev server and + // its preview are the same thing), but they must stay VISIBLE even when that + // group is collapsed — the whole point is a one-tap open. Render them as an + // always-visible block right after the background section, not as collapsible + // children that get swallowed the moment a background task appears. + if (group.type === 'background' && previewRows.length > 0) { + sections.push({ key: 'preview', node: previewBlock }) + } + } + + // No background group to host them (e.g. a standalone on-disk file preview): + // still render them as their own always-visible block. + if (previewRows.length > 0 && !hasBackgroundGroup) { + sections.push({ key: 'preview', node: previewBlock }) } if (queue) { diff --git a/apps/desktop/src/app/chat/composer/voice-activity.tsx b/apps/desktop/src/app/chat/composer/voice-activity.tsx index 535d1422e45a..bd1f33036218 100644 --- a/apps/desktop/src/app/chat/composer/voice-activity.tsx +++ b/apps/desktop/src/app/chat/composer/voice-activity.tsx @@ -3,7 +3,7 @@ import { useEffect, useRef } from 'react' import { Button } from '@/components/ui/button' import { useI18n } from '@/i18n' -import { Loader2, Mic, Volume2, VolumeX } from '@/lib/icons' +import { iconSize, Loader2, Mic, Volume2, VolumeX } from '@/lib/icons' import { cn } from '@/lib/utils' import { stopVoicePlayback } from '@/lib/voice-playback' import { $voicePlayback } from '@/store/voice-playback' @@ -188,7 +188,7 @@ export function VoiceActivity({ state }: { state: VoiceActivityState }) { recording ? 'bg-primary/15 text-primary' : 'bg-primary/10 text-primary' )} > - {recording ? : } + {recording ? : }
@@ -229,7 +229,7 @@ export function VoicePlaybackActivity() { role="status" >
- {preparing ? : } + {preparing ? : }
@@ -244,7 +244,7 @@ export function VoicePlaybackActivity() { type="button" variant="ghost" > - + Stop
diff --git a/apps/desktop/src/app/chat/index.tsx b/apps/desktop/src/app/chat/index.tsx index b61df2337b71..a5216210a799 100644 --- a/apps/desktop/src/app/chat/index.tsx +++ b/apps/desktop/src/app/chat/index.tsx @@ -45,7 +45,7 @@ import { $sessions, sessionPinId } from '@/store/session' -import { isSecondaryWindow } from '@/store/windows' +import { isSecondaryWindow, isWatchWindow } from '@/store/windows' import type { ModelOptionsResponse } from '@/types/hermes' import { routeSessionId } from '../routes' @@ -342,8 +342,9 @@ export function ChatView({ const threadLoading = threadLoadingState(loadingSession, busy, awaitingResponse, lastVisibleIsUser) // Hide the composer in the exhausted error state too: there's no live runtime - // to send to until a retry rebinds one. - const showChatBar = !loadingSession && !resumeExhausted + // to send to until a retry rebinds one. Watch windows are pure spectators of a + // subagent run driven elsewhere — no composer, transcript is read-only. + const showChatBar = !loadingSession && !resumeExhausted && !isWatchWindow() const threadKey = selectedSessionId || activeSessionId || (isRoutedSessionView ? location.pathname : 'new') const modelOptionsQuery = useQuery({ diff --git a/apps/desktop/src/app/chat/sidebar/chrome.tsx b/apps/desktop/src/app/chat/sidebar/chrome.tsx index 3963aaf3dbd9..7815d1fafcf3 100644 --- a/apps/desktop/src/app/chat/sidebar/chrome.tsx +++ b/apps/desktop/src/app/chat/sidebar/chrome.tsx @@ -1,6 +1,7 @@ import type * as React from 'react' import { Codicon } from '@/components/ui/codicon' +import { RowButton } from '@/components/ui/row-button' import { cn } from '@/lib/utils' // Shared, content-agnostic sidebar chrome — used by both the flat session @@ -64,7 +65,7 @@ export function SidebarRowCluster({ className, ...props }: React.ComponentProps< /** Session row main tap target. */ export function SidebarRowBody({ className, ...props }: React.ComponentProps<'button'>) { - return + ) } diff --git a/apps/desktop/src/app/chat/sidebar/index.tsx b/apps/desktop/src/app/chat/sidebar/index.tsx index ca38d65908ba..89e719f77600 100644 --- a/apps/desktop/src/app/chat/sidebar/index.tsx +++ b/apps/desktop/src/app/chat/sidebar/index.tsx @@ -1,19 +1,5 @@ -import { - closestCenter, - DndContext, - type DragEndEvent, - KeyboardSensor, - PointerSensor, - useSensor, - useSensors -} from '@dnd-kit/core' -import { - arrayMove, - SortableContext, - sortableKeyboardCoordinates, - useSortable, - verticalListSortingStrategy -} from '@dnd-kit/sortable' +import { KeyboardSensor, PointerSensor, useSensor, useSensors } from '@dnd-kit/core' +import { sortableKeyboardCoordinates } from '@dnd-kit/sortable' import { useStore } from '@nanostores/react' import type * as React from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' @@ -21,7 +7,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { PlatformAvatar } from '@/app/messaging/platform-icon' 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 { KbdGroup } from '@/components/ui/kbd' import { SearchField } from '@/components/ui/search-field' @@ -34,13 +19,10 @@ import { SidebarMenuButton, SidebarMenuItem } from '@/components/ui/sidebar' -import { Skeleton } from '@/components/ui/skeleton' -import type { HermesGitWorktree } from '@/global' import { searchSessions, type SessionInfo, type SessionSearchResult } from '@/hermes' import { useI18n } from '@/i18n' import { comboTokens } from '@/lib/keybinds/combo' import { profileColor } from '@/lib/profile-color' -import { flattenSessionsWithBranches } from '@/lib/session-branch-tree' import { sessionMatchesSearch } from '@/lib/session-search' import { normalizeSessionSource, sessionSourceLabel } from '@/lib/session-source' import { cn } from '@/lib/utils' @@ -114,37 +96,31 @@ import { } from '@/store/session' import { type AppView, ARTIFACTS_ROUTE, MESSAGING_ROUTE, SKILLS_ROUTE } from '../../routes' -import { SidebarPanelLabel } from '../../shell/sidebar-label' import type { SidebarNavItem } from '../../types' -import { countLabel, SidebarCount } from './chrome' +import { countLabel } from './chrome' import { SidebarCronJobsSection } from './cron-jobs-section' import { SidebarLoadMoreRow } from './load-more-row' -import { reconcileFreshFirst, resolveManualSessionOrderIds } from './order' +import { orderByIds, reconcileOrderIds, resolveManualSessionOrderIds, sameIds } from './order' import { ProfileRail } from './profile-switcher' import { ProjectDialog } from './project-dialog' import { - EnteredProjectContent, overlayLiveLanes, overlayLivePreviews, PROJECT_PREVIEW_COUNT, ProjectBackRow, ProjectMenu, - ProjectOverviewRow, projectTreeCwd, sessionRecency as sessionTime, type SidebarProjectTree, type SidebarSessionGroup, - SidebarWorkspaceGroup, type SidebarWorkspaceTree, sortProjectsForOverview, StartWorkButton, useRepoWorktreeMap } from './projects' -import { SidebarSessionRow } from './session-row' -import { VirtualSessionList } from './virtual-session-list' - -const VIRTUALIZE_THRESHOLD = 25 +import { SidebarBlankState, SidebarPinnedEmptyState, SidebarSessionSkeletons } from './section-states' +import { SidebarSessionsSection, VIRTUALIZE_THRESHOLD } from './sessions-section' // Non-session groups (messaging platforms) stay compact: show a few rows up // front, reveal more in larger steps on demand. Keeps a busy platform from @@ -196,108 +172,6 @@ const HEADER_ACTION_BTN = const HEADER_NAV_BTN = 'text-(--ui-text-tertiary) opacity-70 transition-opacity hover:bg-(--ui-control-hover-background) hover:text-foreground hover:opacity-100 focus-visible:opacity-100' -// Sidebar reordering is a strictly vertical list. The dragged item's transform -// is rendered Y-only in useSortableBindings (no x, no scale); this just stops -// dnd-kit's auto-scroll from dragging the rail — or the window — sideways when -// the pointer nears an edge, killing the horizontal "drag to valhalla". -const reorderAutoScroll = { threshold: { x: 0, y: 0.2 } } - -// One self-contained, nesting-safe reorderable list. It owns its DndContext, so a -// drag only ever collides with THIS list's own items — drop it at any depth (repos, -// worktrees, sessions) and reordering "just works" without leaking into the lists -// around or inside it. Pair each item with useSortableBindings(id); the list reports -// the new id order and the caller persists it. This is the single generic primitive -// behind every reorderable surface in the sidebar. -function ReorderableList({ - children, - ids, - onReorder, - sensors -}: { - children: React.ReactNode - ids: string[] - onReorder: (ids: string[]) => void - sensors?: ReturnType -}) { - const handleDragEnd = ({ activatorEvent, active, over }: DragEndEvent) => { - // dnd-kit only restores focus for keyboard drags; after a pointer drop the - // browser leaves :focus on the grab handle, which keeps a focus-within - // grabber/affordance reveal stuck "on". Drop that focus so the row returns - // to its resting state once the pointer moves away. - if (!(activatorEvent instanceof KeyboardEvent)) { - ;(document.activeElement as HTMLElement | null)?.blur() - } - - if (!over || active.id === over.id) { - return - } - - const from = ids.indexOf(String(active.id)) - const to = ids.indexOf(String(over.id)) - - if (from >= 0 && to >= 0) { - onReorder(arrayMove(ids, from, to)) - } - } - - return ( - - - {children} - - - ) -} - -function orderByIds(items: T[], getId: (item: T) => string, orderIds: string[]): T[] { - if (!orderIds.length) { - return items - } - - const byId = new Map(items.map(item => [getId(item), item])) - const seen = new Set() - const ordered: T[] = [] - - for (const id of orderIds) { - const item = byId.get(id) - - if (item) { - ordered.push(item) - seen.add(id) - } - } - - // Items missing from the persisted order are new since it was last - // reconciled. Callers pass recency-sorted lists (newest first), so surface - // these at the TOP instead of burying them beneath the saved order — - // otherwise a brand-new session sinks to the bottom of the sidebar and reads - // as "my latest session never showed up". - const fresh = items.filter(item => !seen.has(getId(item))) - - return fresh.length ? [...fresh, ...ordered] : ordered -} - -function reconcileOrderIds(currentIds: string[], orderIds: string[]): string[] { - if (!currentIds.length) { - return [] - } - - if (!orderIds.length) { - return currentIds - } - - return reconcileFreshFirst(currentIds, orderIds) -} - -function sameIds(left: string[], right: string[]) { - return left.length === right.length && left.every((item, index) => item === right[index]) -} - // FTS results cover sessions that aren't in the loaded page; synthesize a // minimal SessionInfo so they render in the same row component (resume works // by id; the snippet stands in for the preview). @@ -324,25 +198,6 @@ function searchResultToSession(result: SessionSearchResult): SessionInfo { } } -function useSortableBindings(id: string) { - const { attributes, isDragging, listeners, setNodeRef, transform, transition } = useSortable({ id }) - - return { - dragging: isDragging, - dragHandleProps: { ...attributes, ...listeners }, - ref: setNodeRef, - reorderable: true as const, - style: { - // Uniform vertical list: only ever translate on Y. Ignoring x and the - // scaleX/scaleY that CSS.Transform.toString would emit keeps a dragged - // group/row from drifting sideways or morphing its size mid-drag. - transform: transform ? `translate3d(0px, ${transform.y}px, 0)` : undefined, - transition: isDragging ? undefined : transition, - willChange: isDragging ? 'transform' : undefined - } - } -} - interface ChatSidebarProps extends React.ComponentProps { currentView: AppView onNavigate: (item: SidebarNavItem) => void @@ -1149,8 +1004,7 @@ export function ChatSidebar({ const showSessionSkeletons = sessionsLoading && sortedSessions.length === 0 - const showSessionSections = - showSessionSkeletons || sortedSessions.length > 0 || projectModel.length > 0 + const showSessionSections = showSessionSkeletons || sortedSessions.length > 0 || projectModel.length > 0 // Each reorderable list reports its OWN new id order; persisting is a direct, // typed write — no id-prefix sniffing to figure out which level moved. @@ -1551,110 +1405,6 @@ export function ChatSidebar({ ) } -interface SidebarSectionHeaderProps { - label: string - open: boolean - onToggle: () => void - action?: React.ReactNode - meta?: React.ReactNode - icon?: React.ReactNode - // When false the section can't be collapsed: the label renders static (no - // toggle, no caret) and the section is always open. Used for the single- - // project view, where collapsing one project makes no sense. - collapsible?: boolean -} - -function SidebarSectionHeader({ - label, - open, - onToggle, - action, - meta, - icon, - collapsible = true -}: SidebarSectionHeaderProps) { - const labelBody = ( - <> - {icon} - {label} - {meta && {meta}} - - ) - - return ( -
- {collapsible ? ( - - ) : ( -
{labelBody}
- )} - {action} -
- ) -} - -function SidebarSessionSkeletons() { - return ( - - ) -} - -function SidebarBlankState({ onNewProject }: { onNewProject: () => void }) { - const { t } = useI18n() - const s = t.sidebar - - return ( -
-
- -

{s.noSessions}

- -
-
- ) -} - -function SidebarPinnedEmptyState() { - const { t } = useI18n() - - return ( -
- - - - {t.sidebar.shiftClickHint} -
- ) -} - interface MessagingSection { sourceId: string label: string @@ -1662,302 +1412,3 @@ interface MessagingSection { total: number hasMore: boolean } - -interface SidebarSessionsSectionProps { - label: string - open: boolean - onToggle: () => void - sessions: SessionInfo[] - activeSessionId: null | string - workingSessionIdSet: Set - onResumeSession: (sessionId: string) => void - onDeleteSession: (sessionId: string) => void - onArchiveSession: (sessionId: string) => void - onBranchSession?: (sessionId: string, profile?: string) => void - onTogglePin: (sessionId: string) => void - onNewSessionInWorkspace?: (path: null | string) => void - pinned: boolean - rootClassName?: string - contentClassName?: string - emptyState: React.ReactNode - forceEmptyState?: boolean - headerAction?: React.ReactNode - footer?: React.ReactNode - groups?: SidebarSessionGroup[] - tree?: SidebarWorkspaceTree[] - // Project overview: when present, render a drill-in list of project rows - // instead of sessions. Clicking a row enters that project (onEnterProject), - // which then passes `projectContent` on the next render. Takes precedence - // over `tree` / `groups`. - projectOverview?: SidebarProjectTree[] - // Per-project preview rows (from the backend tree), keyed by project path. - projectOverviewPreviews?: Record - // True while the backend project tree is loading (overview skeleton). - projectsLoading?: boolean - onEnterProject?: (id: string) => void - // The entered project's flattened content: main-checkout sessions render - // directly (no redundant repo/branch header); only linked worktrees nest. - projectContent?: SidebarProjectTree - // Live git lanes (`git worktree list`) for repos in the entered project — - // a VISUAL enhancer only (empty lanes), never session membership. - projectRepoWorktrees?: Record - // Live session cache used for optimistic placement inside entered-project lanes. - liveSessions?: SessionInfo[] - // Client-side optimistic eviction layer (deleted/archived ids). - removedSessionIds?: ReadonlySet - activeProjectId?: null | string - labelMeta?: React.ReactNode - labelIcon?: React.ReactNode - // When false the section header is static (no caret/toggle) and always open. - collapsible?: boolean - sortable?: boolean - // The flat session list is the only hand-reorderable surface (grouped/project - // views sort deterministically), so it owns the one ReorderableList. - onReorderSessions?: (ids: string[]) => void - // Drag-to-reorder for the project overview list (top-level projects). - onReorderProjects?: (ids: string[]) => void - // Rendered atop the entered-project body (a "back to overview" row). - projectBackRow?: React.ReactNode - dndSensors?: ReturnType -} - -function SidebarSessionsSection({ - label, - open, - onToggle, - sessions, - activeSessionId, - workingSessionIdSet, - onResumeSession, - onDeleteSession, - onArchiveSession, - onBranchSession, - onTogglePin, - onNewSessionInWorkspace, - pinned, - rootClassName, - contentClassName, - emptyState, - forceEmptyState = false, - headerAction, - footer, - groups, - projectOverview, - projectOverviewPreviews, - projectsLoading = false, - onEnterProject, - projectContent, - projectRepoWorktrees, - liveSessions, - removedSessionIds, - activeProjectId, - labelMeta, - labelIcon, - collapsible = true, - sortable = false, - onReorderSessions, - onReorderProjects, - projectBackRow, - dndSensors -}: SidebarSessionsSectionProps) { - const sectionOpen = collapsible ? open : true - const hasGroupedSessions = Boolean(groups?.some(group => group.sessions.length > 0)) - // A defined project list is itself content (even an empty project should - // render as a drill-in row so the user can see it exists). - const hasProjectOverview = Boolean(projectOverview?.length) - const hasProjectContent = Boolean(projectContent && projectContent.sessionCount > 0) - - const showEmptyState = - forceEmptyState || (!hasGroupedSessions && !hasProjectOverview && !hasProjectContent && sessions.length === 0) - - // The flat recents/pinned list is the only place sessions reorder by hand; - // grouped/tree views always sort by creation date and never drag. - const sessionsDraggable = sortable && !!onReorderSessions - const displayEntries = useMemo(() => flattenSessionsWithBranches(sessions), [sessions]) - - const renderRow = (session: SessionInfo, draggable: boolean, branchStem?: string) => { - const rowProps = { - branchStem, - isPinned: pinned, - isSelected: session.id === activeSessionId, - isWorking: workingSessionIdSet.has(session.id), - onArchive: () => onArchiveSession(session.id), - onBranch: onBranchSession ? () => onBranchSession(session.id, session.profile) : undefined, - onDelete: () => onDeleteSession(session.id), - onPin: () => onTogglePin(sessionPinId(session)), - onResume: () => onResumeSession(session.id), - reorderable: draggable && !branchStem, - session - } - - return draggable && !branchStem ? ( - - ) : ( - - ) - } - - // Sessions inside repos/worktrees are date-ordered and static. - const renderRows = (items: SessionInfo[]) => - flattenSessionsWithBranches(items).map(({ branchStem, session }) => renderRow(session, false, branchStem)) - - const flatVirtualized = - !showEmptyState && - !groups?.length && - !projectOverview?.length && - !projectContent && - sessions.length >= VIRTUALIZE_THRESHOLD - - // First paint into the grouped view (e.g. the app restoring the Projects tab) - // has flat recents in `sessions` but no tree yet. Show skeletons rather than - // flashing the flat session list until the overview/content/groups resolve. A - // background refresh keeps the prior tree, so this only fires when empty. - const showProjectsSkeleton = - projectsLoading && !hasProjectOverview && !hasProjectContent && !projectContent && !groups?.length - - let inner: React.ReactNode - - if (showProjectsSkeleton) { - inner = - } else if (projectContent) { - // Entered a project: the back row is always present, then either the - // (overlay-aware) content or a clean empty state — never a bare spinner or a - // blank pane while lanes hydrate. - inner = ( - <> - {projectBackRow} - {hasProjectContent ? ( - - ) : ( - emptyState - )} - - ) - } else if (showEmptyState) { - inner = emptyState - } else if (projectOverview?.length) { - // The model is already ordered (default sort groups explicit-before-auto; - // a manual drag-order, when present, wins). Render in that order and make - // rows drag-to-reorder when a handler is wired. - const projectsDraggable = projectOverview.length > 1 && !!onReorderProjects - const Row = projectsDraggable ? SortableProjectOverviewRow : ProjectOverviewRow - - const rows = projectOverview.map(project => ( - - )) - - inner = - projectsDraggable && onReorderProjects ? ( - project.id)} - onReorder={onReorderProjects} - sensors={dndSensors} - > - {rows} - - ) : ( - rows - ) - } else if (groups?.length) { - // Profile/source groups never reorder; render them flat with static rows. - inner = groups.map(group => ( - - )) - } else if (flatVirtualized) { - const virtual = ( - - ) - - inner = - sessionsDraggable && onReorderSessions ? ( - s.id)} onReorder={onReorderSessions} sensors={dndSensors}> - {virtual} - - ) : ( - virtual - ) - } else if (sessionsDraggable && onReorderSessions) { - inner = ( - s.id)} onReorder={onReorderSessions} sensors={dndSensors}> - {displayEntries.map(({ branchStem, session }) => renderRow(session, true, branchStem))} - - ) - } else { - inner = displayEntries.map(({ branchStem, session }) => renderRow(session, false, branchStem)) - } - - // The virtualizer owns its own scroller, so suppress the wrapper's overflow - // to avoid a double scroll container. - const resolvedContentClassName = cn(contentClassName, flatVirtualized && 'overflow-y-visible') - - return ( - - - {sectionOpen && ( - - {inner} - {footer} - - )} - - ) -} - -interface SortableSessionRowProps { - session: SessionInfo - isPinned: boolean - isSelected: boolean - isWorking: boolean - onArchive: () => void - onDelete: () => void - onPin: () => void - onResume: () => void -} - -function SortableSidebarSessionRow(props: SortableSessionRowProps) { - return -} - -function SortableProjectOverviewRow(props: React.ComponentProps) { - return -} diff --git a/apps/desktop/src/app/chat/sidebar/order.test.ts b/apps/desktop/src/app/chat/sidebar/order.test.ts index f65b08e260ca..e1a48bc5fbf5 100644 --- a/apps/desktop/src/app/chat/sidebar/order.test.ts +++ b/apps/desktop/src/app/chat/sidebar/order.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { resolveManualSessionOrderIds } from './order' +import { orderByIds, reconcileOrderIds, resolveManualSessionOrderIds, sameIds } from './order' describe('resolveManualSessionOrderIds', () => { it('clears legacy auto-seeded order until the user manually reorders sessions', () => { @@ -19,3 +19,44 @@ describe('resolveManualSessionOrderIds', () => { expect(resolveManualSessionOrderIds(['newest'], ['gone'], true)).toEqual([]) }) }) + +describe('orderByIds', () => { + const id = (item: { id: string }) => item.id + + it('returns items untouched when no order is given', () => { + const items = [{ id: 'a' }, { id: 'b' }] + expect(orderByIds(items, id, [])).toBe(items) + }) + + it('reorders by the given ids and drops missing ones', () => { + const items = [{ id: 'a' }, { id: 'b' }, { id: 'c' }] + expect(orderByIds(items, id, ['c', 'gone', 'a'])).toEqual([{ id: 'b' }, { id: 'c' }, { id: 'a' }]) + }) + + it('surfaces items absent from the order first', () => { + const items = [{ id: 'fresh' }, { id: 'a' }, { id: 'b' }] + expect(orderByIds(items, id, ['b', 'a'])).toEqual([{ id: 'fresh' }, { id: 'b' }, { id: 'a' }]) + }) +}) + +describe('reconcileOrderIds', () => { + it('returns empty for no current ids', () => { + expect(reconcileOrderIds([], ['a'])).toEqual([]) + }) + + it('returns current ids when there is no saved order', () => { + expect(reconcileOrderIds(['a', 'b'], [])).toEqual(['a', 'b']) + }) + + it('puts newly-seen ids ahead of the retained saved order', () => { + expect(reconcileOrderIds(['fresh', 'a', 'b'], ['b', 'a', 'gone'])).toEqual(['fresh', 'b', 'a']) + }) +}) + +describe('sameIds', () => { + it('is true only for identical ordered lists', () => { + expect(sameIds(['a', 'b'], ['a', 'b'])).toBe(true) + expect(sameIds(['a', 'b'], ['b', 'a'])).toBe(false) + expect(sameIds(['a'], ['a', 'b'])).toBe(false) + }) +}) diff --git a/apps/desktop/src/app/chat/sidebar/order.ts b/apps/desktop/src/app/chat/sidebar/order.ts index 97225ac5a4c1..9cefea57d01b 100644 --- a/apps/desktop/src/app/chat/sidebar/order.ts +++ b/apps/desktop/src/app/chat/sidebar/order.ts @@ -21,3 +21,50 @@ export function resolveManualSessionOrderIds(currentIds: string[], orderIds: str return reconcileFreshFirst(currentIds, orderIds) } + +/** Reorder `items` by `orderIds`; items missing from the order surface first. */ +export function orderByIds(items: T[], getId: (item: T) => string, orderIds: string[]): T[] { + if (!orderIds.length) { + return items + } + + const byId = new Map(items.map(item => [getId(item), item])) + const seen = new Set() + const ordered: T[] = [] + + for (const id of orderIds) { + const item = byId.get(id) + + if (item) { + ordered.push(item) + seen.add(id) + } + } + + // Items missing from the persisted order are new since it was last + // reconciled. Callers pass recency-sorted lists (newest first), so surface + // these at the TOP instead of burying them beneath the saved order — + // otherwise a brand-new session sinks to the bottom of the sidebar and reads + // as "my latest session never showed up". + const fresh = items.filter(item => !seen.has(getId(item))) + + return fresh.length ? [...fresh, ...ordered] : ordered +} + +/** Reconcile a persisted order against the live id set (fresh-first). */ +export function reconcileOrderIds(currentIds: string[], orderIds: string[]): string[] { + if (!currentIds.length) { + return [] + } + + if (!orderIds.length) { + return currentIds + } + + return reconcileFreshFirst(currentIds, orderIds) +} + +/** True when two id lists are element-for-element identical. */ +export function sameIds(left: string[], right: string[]): boolean { + return left.length === right.length && left.every((item, index) => item === right[index]) +} diff --git a/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx b/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx index 612305b1479b..100ad8001e44 100644 --- a/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx +++ b/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx @@ -200,7 +200,7 @@ export function ProfileRail() { }, [createRequest]) return ( -
+
{/* One button toggles default ↔ all: home face when scoped to a profile, layers face when showing everything. Pinned left like Manage is right. Hidden until a second profile exists. */} diff --git a/apps/desktop/src/app/chat/sidebar/reorderable-list.tsx b/apps/desktop/src/app/chat/sidebar/reorderable-list.tsx new file mode 100644 index 000000000000..8be14fcb8ea0 --- /dev/null +++ b/apps/desktop/src/app/chat/sidebar/reorderable-list.tsx @@ -0,0 +1,81 @@ +import type { useSensors } from '@dnd-kit/core'; +import { closestCenter, DndContext, type DragEndEvent } from '@dnd-kit/core' +import { arrayMove, SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable' +import type * as React from 'react' + +// Sidebar reordering is a strictly vertical list. The dragged item's transform +// is rendered Y-only in useSortableBindings (no x, no scale); this just stops +// dnd-kit's auto-scroll from dragging the rail — or the window — sideways when +// the pointer nears an edge, killing the horizontal "drag to valhalla". +const reorderAutoScroll = { threshold: { x: 0, y: 0.2 } } + +// One self-contained, nesting-safe reorderable list. It owns its DndContext, so a +// drag only ever collides with THIS list's own items — drop it at any depth (repos, +// worktrees, sessions) and reordering "just works" without leaking into the lists +// around or inside it. Pair each item with useSortableBindings(id); the list reports +// the new id order and the caller persists it. This is the single generic primitive +// behind every reorderable surface in the sidebar. +export function ReorderableList({ + children, + ids, + onReorder, + sensors +}: { + children: React.ReactNode + ids: string[] + onReorder: (ids: string[]) => void + sensors?: ReturnType +}) { + const handleDragEnd = ({ activatorEvent, active, over }: DragEndEvent) => { + // dnd-kit only restores focus for keyboard drags; after a pointer drop the + // browser leaves :focus on the grab handle, which keeps a focus-within + // grabber/affordance reveal stuck "on". Drop that focus so the row returns + // to its resting state once the pointer moves away. + if (!(activatorEvent instanceof KeyboardEvent)) { + ;(document.activeElement as HTMLElement | null)?.blur() + } + + if (!over || active.id === over.id) { + return + } + + const from = ids.indexOf(String(active.id)) + const to = ids.indexOf(String(over.id)) + + if (from >= 0 && to >= 0) { + onReorder(arrayMove(ids, from, to)) + } + } + + return ( + + + {children} + + + ) +} + +export function useSortableBindings(id: string) { + const { attributes, isDragging, listeners, setNodeRef, transform, transition } = useSortable({ id }) + + return { + dragging: isDragging, + dragHandleProps: { ...attributes, ...listeners }, + ref: setNodeRef, + reorderable: true as const, + style: { + // Uniform vertical list: only ever translate on Y. Ignoring x and the + // scaleX/scaleY that CSS.Transform.toString would emit keeps a dragged + // group/row from drifting sideways or morphing its size mid-drag. + transform: transform ? `translate3d(0px, ${transform.y}px, 0)` : undefined, + transition: isDragging ? undefined : transition, + willChange: isDragging ? 'transform' : undefined + } + } +} diff --git a/apps/desktop/src/app/chat/sidebar/section-states.tsx b/apps/desktop/src/app/chat/sidebar/section-states.tsx new file mode 100644 index 000000000000..d65eda981326 --- /dev/null +++ b/apps/desktop/src/app/chat/sidebar/section-states.tsx @@ -0,0 +1,52 @@ +import { Button } from '@/components/ui/button' +import { Codicon } from '@/components/ui/codicon' +import { Skeleton } from '@/components/ui/skeleton' +import { useI18n } from '@/i18n' +import { cn } from '@/lib/utils' + +export function SidebarSessionSkeletons() { + return ( + + ) +} + +export function SidebarBlankState({ onNewProject }: { onNewProject: () => void }) { + const { t } = useI18n() + const s = t.sidebar + + return ( +
+
+ +

{s.noSessions}

+ +
+
+ ) +} + +export function SidebarPinnedEmptyState() { + const { t } = useI18n() + + return ( +
+ + + + {t.sidebar.shiftClickHint} +
+ ) +} diff --git a/apps/desktop/src/app/chat/sidebar/sessions-section.tsx b/apps/desktop/src/app/chat/sidebar/sessions-section.tsx new file mode 100644 index 000000000000..ffe729eb51e4 --- /dev/null +++ b/apps/desktop/src/app/chat/sidebar/sessions-section.tsx @@ -0,0 +1,379 @@ +import type { useSensors } from '@dnd-kit/core' +import type * as React from 'react' +import { useMemo } from 'react' + +import { SidebarPanelLabel } from '@/app/shell/sidebar-label' +import { DisclosureCaret } from '@/components/ui/disclosure-caret' +import { SidebarGroup, SidebarGroupContent } from '@/components/ui/sidebar' +import type { HermesGitWorktree } from '@/global' +import type { SessionInfo } from '@/hermes' +import { flattenSessionsWithBranches } from '@/lib/session-branch-tree' +import { cn } from '@/lib/utils' +import { sessionPinId } from '@/store/session' + +import { SidebarCount } from './chrome' +import { + EnteredProjectContent, + ProjectOverviewRow, + type SidebarProjectTree, + type SidebarSessionGroup, + SidebarWorkspaceGroup, + type SidebarWorkspaceTree +} from './projects' +import { ReorderableList, useSortableBindings } from './reorderable-list' +import { SidebarSessionSkeletons } from './section-states' +import { SidebarSessionRow } from './session-row' +import { VirtualSessionList } from './virtual-session-list' + +export const VIRTUALIZE_THRESHOLD = 25 + +interface SidebarSectionHeaderProps { + label: string + open: boolean + onToggle: () => void + action?: React.ReactNode + meta?: React.ReactNode + icon?: React.ReactNode + // When false the section can't be collapsed: the label renders static (no + // toggle, no caret) and the section is always open. Used for the single- + // project view, where collapsing one project makes no sense. + collapsible?: boolean +} + +function SidebarSectionHeader({ + label, + open, + onToggle, + action, + meta, + icon, + collapsible = true +}: SidebarSectionHeaderProps) { + const labelBody = ( + <> + {icon} + {label} + {meta && {meta}} + + ) + + return ( +
+ {collapsible ? ( + + ) : ( +
{labelBody}
+ )} + {action} +
+ ) +} + +interface SidebarSessionsSectionProps { + label: string + open: boolean + onToggle: () => void + sessions: SessionInfo[] + activeSessionId: null | string + workingSessionIdSet: Set + onResumeSession: (sessionId: string) => void + onDeleteSession: (sessionId: string) => void + onArchiveSession: (sessionId: string) => void + onBranchSession?: (sessionId: string, profile?: string) => void + onTogglePin: (sessionId: string) => void + onNewSessionInWorkspace?: (path: null | string) => void + pinned: boolean + rootClassName?: string + contentClassName?: string + emptyState: React.ReactNode + forceEmptyState?: boolean + headerAction?: React.ReactNode + footer?: React.ReactNode + groups?: SidebarSessionGroup[] + tree?: SidebarWorkspaceTree[] + // Project overview: when present, render a drill-in list of project rows + // instead of sessions. Clicking a row enters that project (onEnterProject), + // which then passes `projectContent` on the next render. Takes precedence + // over `tree` / `groups`. + projectOverview?: SidebarProjectTree[] + // Per-project preview rows (from the backend tree), keyed by project path. + projectOverviewPreviews?: Record + // True while the backend project tree is loading (overview skeleton). + projectsLoading?: boolean + onEnterProject?: (id: string) => void + // The entered project's flattened content: main-checkout sessions render + // directly (no redundant repo/branch header); only linked worktrees nest. + projectContent?: SidebarProjectTree + // Live git lanes (`git worktree list`) for repos in the entered project — + // a VISUAL enhancer only (empty lanes), never session membership. + projectRepoWorktrees?: Record + // Live session cache used for optimistic placement inside entered-project lanes. + liveSessions?: SessionInfo[] + // Client-side optimistic eviction layer (deleted/archived ids). + removedSessionIds?: ReadonlySet + activeProjectId?: null | string + labelMeta?: React.ReactNode + labelIcon?: React.ReactNode + // When false the section header is static (no caret/toggle) and always open. + collapsible?: boolean + sortable?: boolean + // The flat session list is the only hand-reorderable surface (grouped/project + // views sort deterministically), so it owns the one ReorderableList. + onReorderSessions?: (ids: string[]) => void + // Drag-to-reorder for the project overview list (top-level projects). + onReorderProjects?: (ids: string[]) => void + // Rendered atop the entered-project body (a "back to overview" row). + projectBackRow?: React.ReactNode + dndSensors?: ReturnType +} + +export function SidebarSessionsSection({ + label, + open, + onToggle, + sessions, + activeSessionId, + workingSessionIdSet, + onResumeSession, + onDeleteSession, + onArchiveSession, + onBranchSession, + onTogglePin, + onNewSessionInWorkspace, + pinned, + rootClassName, + contentClassName, + emptyState, + forceEmptyState = false, + headerAction, + footer, + groups, + projectOverview, + projectOverviewPreviews, + projectsLoading = false, + onEnterProject, + projectContent, + projectRepoWorktrees, + liveSessions, + removedSessionIds, + activeProjectId, + labelMeta, + labelIcon, + collapsible = true, + sortable = false, + onReorderSessions, + onReorderProjects, + projectBackRow, + dndSensors +}: SidebarSessionsSectionProps) { + const sectionOpen = collapsible ? open : true + const hasGroupedSessions = Boolean(groups?.some(group => group.sessions.length > 0)) + // A defined project list is itself content (even an empty project should + // render as a drill-in row so the user can see it exists). + const hasProjectOverview = Boolean(projectOverview?.length) + const hasProjectContent = Boolean(projectContent && projectContent.sessionCount > 0) + + const showEmptyState = + forceEmptyState || (!hasGroupedSessions && !hasProjectOverview && !hasProjectContent && sessions.length === 0) + + // The flat recents/pinned list is the only place sessions reorder by hand; + // grouped/tree views always sort by creation date and never drag. + const sessionsDraggable = sortable && !!onReorderSessions + const displayEntries = useMemo(() => flattenSessionsWithBranches(sessions), [sessions]) + + const renderRow = (session: SessionInfo, draggable: boolean, branchStem?: string) => { + const rowProps = { + branchStem, + isPinned: pinned, + isSelected: session.id === activeSessionId, + isWorking: workingSessionIdSet.has(session.id), + onArchive: () => onArchiveSession(session.id), + onBranch: onBranchSession ? () => onBranchSession(session.id, session.profile) : undefined, + onDelete: () => onDeleteSession(session.id), + onPin: () => onTogglePin(sessionPinId(session)), + onResume: () => onResumeSession(session.id), + reorderable: draggable && !branchStem, + session + } + + return draggable && !branchStem ? ( + + ) : ( + + ) + } + + // Sessions inside repos/worktrees are date-ordered and static. + const renderRows = (items: SessionInfo[]) => + flattenSessionsWithBranches(items).map(({ branchStem, session }) => renderRow(session, false, branchStem)) + + const flatVirtualized = + !showEmptyState && + !groups?.length && + !projectOverview?.length && + !projectContent && + sessions.length >= VIRTUALIZE_THRESHOLD + + // First paint into the grouped view (e.g. the app restoring the Projects tab) + // has flat recents in `sessions` but no tree yet. Show skeletons rather than + // flashing the flat session list until the overview/content/groups resolve. A + // background refresh keeps the prior tree, so this only fires when empty. + const showProjectsSkeleton = + projectsLoading && !hasProjectOverview && !hasProjectContent && !projectContent && !groups?.length + + let inner: React.ReactNode + + if (showProjectsSkeleton) { + inner = + } else if (projectContent) { + // Entered a project: the back row is always present, then either the + // (overlay-aware) content or a clean empty state — never a bare spinner or a + // blank pane while lanes hydrate. + inner = ( + <> + {projectBackRow} + {hasProjectContent ? ( + + ) : ( + emptyState + )} + + ) + } else if (showEmptyState) { + inner = emptyState + } else if (projectOverview?.length) { + // The model is already ordered (default sort groups explicit-before-auto; + // a manual drag-order, when present, wins). Render in that order and make + // rows drag-to-reorder when a handler is wired. + const projectsDraggable = projectOverview.length > 1 && !!onReorderProjects + const Row = projectsDraggable ? SortableProjectOverviewRow : ProjectOverviewRow + + const rows = projectOverview.map(project => ( + + )) + + inner = + projectsDraggable && onReorderProjects ? ( + project.id)} + onReorder={onReorderProjects} + sensors={dndSensors} + > + {rows} + + ) : ( + rows + ) + } else if (groups?.length) { + // Profile/source groups never reorder; render them flat with static rows. + inner = groups.map(group => ( + + )) + } else if (flatVirtualized) { + const virtual = ( + + ) + + inner = + sessionsDraggable && onReorderSessions ? ( + s.id)} onReorder={onReorderSessions} sensors={dndSensors}> + {virtual} + + ) : ( + virtual + ) + } else if (sessionsDraggable && onReorderSessions) { + inner = ( + s.id)} onReorder={onReorderSessions} sensors={dndSensors}> + {displayEntries.map(({ branchStem, session }) => renderRow(session, true, branchStem))} + + ) + } else { + inner = displayEntries.map(({ branchStem, session }) => renderRow(session, false, branchStem)) + } + + // The virtualizer owns its own scroller, so suppress the wrapper's overflow + // to avoid a double scroll container. + const resolvedContentClassName = cn(contentClassName, flatVirtualized && 'overflow-y-visible') + + return ( + + + {sectionOpen && ( + + {inner} + {footer} + + )} + + ) +} + +interface SortableSessionRowProps { + session: SessionInfo + isPinned: boolean + isSelected: boolean + isWorking: boolean + onArchive: () => void + onDelete: () => void + onPin: () => void + onResume: () => void +} + +function SortableSidebarSessionRow(props: SortableSessionRowProps) { + return +} + +function SortableProjectOverviewRow(props: React.ComponentProps) { + return +} diff --git a/apps/desktop/src/app/command-palette/index.tsx b/apps/desktop/src/app/command-palette/index.tsx index bfda963204ce..ec1c79566dcb 100644 --- a/apps/desktop/src/app/command-palette/index.tsx +++ b/apps/desktop/src/app/command-palette/index.tsx @@ -36,6 +36,7 @@ import { RefreshCw, Settings, Settings2, + Starmap, Sun, Terminal, Users, @@ -68,7 +69,8 @@ import { PROFILES_ROUTE, sessionRoute, SETTINGS_ROUTE, - SKILLS_ROUTE + SKILLS_ROUTE, + STARMAP_ROUTE } from '../routes' import { FIELD_LABELS, SECTIONS } from '../settings/constants' import { fieldCopyForSchemaKey } from '../settings/field-copy' @@ -383,7 +385,14 @@ export function CommandPalette() { run: go(CRON_ROUTE) }, { action: 'nav.profiles', icon: Users, id: 'nav-profiles', label: t.profiles.title, run: go(PROFILES_ROUTE) }, - { action: 'nav.agents', icon: Cpu, id: 'nav-agents', label: t.agents.title, run: go(AGENTS_ROUTE) } + { action: 'nav.agents', icon: Cpu, id: 'nav-agents', label: t.agents.title, run: go(AGENTS_ROUTE) }, + { + icon: Starmap, + id: 'nav-starmap', + keywords: ['star map', 'memory', 'memories', 'skills', 'graph', 'learning', 'constellation'], + label: t.starmap.title, + run: go(STARMAP_ROUTE) + } ] }, ...branchGroup, diff --git a/apps/desktop/src/app/command-palette/marketplace-theme-page.tsx b/apps/desktop/src/app/command-palette/marketplace-theme-page.tsx index eb175fdcb720..6766b2dae308 100644 --- a/apps/desktop/src/app/command-palette/marketplace-theme-page.tsx +++ b/apps/desktop/src/app/command-palette/marketplace-theme-page.tsx @@ -8,6 +8,7 @@ * user can grab several. */ +import { useStore } from '@nanostores/react' import { useQuery } from '@tanstack/react-query' import { useEffect, useState } from 'react' @@ -18,6 +19,7 @@ import { triggerHaptic } from '@/lib/haptics' import { Check, Download, Loader2, Palette } from '@/lib/icons' import { cn } from '@/lib/utils' import { installVscodeThemeFromMarketplace } from '@/themes/install' +import { $marketplaceInstalls } from '@/themes/user-themes' const compactNumber = new Intl.NumberFormat(undefined, { notation: 'compact', maximumFractionDigits: 1 }) @@ -43,8 +45,8 @@ export function MarketplaceThemePage({ search, onPickTheme }: MarketplaceThemePa const { t } = useI18n() const copy = t.commandCenter.installTheme const debouncedSearch = useDebounced(search.trim(), 300) + const installs = useStore($marketplaceInstalls) const [installingId, setInstallingId] = useState(null) - const [installed, setInstalled] = useState>({}) const [installError, setInstallError] = useState(null) const query = useQuery({ @@ -53,6 +55,20 @@ export function MarketplaceThemePage({ search, onPickTheme }: MarketplaceThemePa staleTime: 5 * 60 * 1000 }) + // Already installed → just re-activate it; never re-download what we have. + const select = (item: DesktopMarketplaceSearchItem) => { + const owned = installs.get(item.extensionId) + + if (owned) { + triggerHaptic('crisp') + onPickTheme(owned.name) + + return + } + + void install(item) + } + const install = async (item: DesktopMarketplaceSearchItem) => { if (installingId) { return @@ -65,7 +81,6 @@ export function MarketplaceThemePage({ search, onPickTheme }: MarketplaceThemePa const theme = await installVscodeThemeFromMarketplace(item.extensionId) triggerHaptic('crisp') - setInstalled(prev => ({ ...prev, [item.extensionId]: true })) onPickTheme(theme.name) } catch (error) { setInstallError(error instanceof Error ? error.message : copy.error) @@ -93,7 +108,7 @@ export function MarketplaceThemePage({ search, onPickTheme }: MarketplaceThemePa {installError &&

{installError}

} {results.map(item => { const busy = installingId === item.extensionId - const done = installed[item.extensionId] + const done = installs.has(item.extensionId) return ( + {meta ? {meta} : null} {menu ?
{menu}
: null}
diff --git a/apps/desktop/src/app/routes.ts b/apps/desktop/src/app/routes.ts index 2b655fccc8da..66ab264e474b 100644 --- a/apps/desktop/src/app/routes.ts +++ b/apps/desktop/src/app/routes.ts @@ -8,6 +8,7 @@ export const ARTIFACTS_ROUTE = '/artifacts' export const CRON_ROUTE = '/cron' export const PROFILES_ROUTE = '/profiles' export const AGENTS_ROUTE = '/agents' +export const STARMAP_ROUTE = '/starmap' export type AppView = | 'agents' @@ -19,6 +20,7 @@ export type AppView = | 'profiles' | 'settings' | 'skills' + | 'starmap' export type AppRouteId = | 'agents' @@ -30,6 +32,7 @@ export type AppRouteId = | 'profiles' | 'settings' | 'skills' + | 'starmap' export interface AppRoute { id: AppRouteId @@ -46,7 +49,8 @@ export const APP_ROUTES = [ { id: 'artifacts', path: ARTIFACTS_ROUTE, view: 'artifacts' }, { id: 'cron', path: CRON_ROUTE, view: 'cron' }, { id: 'profiles', path: PROFILES_ROUTE, view: 'profiles' }, - { id: 'agents', path: AGENTS_ROUTE, view: 'agents' } + { id: 'agents', path: AGENTS_ROUTE, view: 'agents' }, + { id: 'starmap', path: STARMAP_ROUTE, view: 'starmap' } ] as const satisfies readonly AppRoute[] const APP_VIEW_BY_PATH = new Map(APP_ROUTES.map(route => [route.path, route.view])) @@ -55,7 +59,14 @@ const RESERVED_PATHS: ReadonlySet = new Set(APP_ROUTES.map(route => rout // Views that render as a full-screen modal card (OverlayView) over the shell. // While one is open the app's titlebar control clusters must hide so they don't // bleed over the overlay (they sit at a higher z-index than the overlay card). -export const OVERLAY_VIEWS: ReadonlySet = new Set(['agents', 'command-center', 'cron', 'profiles', 'settings']) +export const OVERLAY_VIEWS: ReadonlySet = new Set([ + 'agents', + 'command-center', + 'cron', + 'profiles', + 'settings', + 'starmap' +]) export function isOverlayView(view: AppView): boolean { return OVERLAY_VIEWS.has(view) diff --git a/apps/desktop/src/app/session/hooks/use-hermes-config.ts b/apps/desktop/src/app/session/hooks/use-hermes-config.ts index 59406c8dff2f..84bff0e59430 100644 --- a/apps/desktop/src/app/session/hooks/use-hermes-config.ts +++ b/apps/desktop/src/app/session/hooks/use-hermes-config.ts @@ -12,6 +12,7 @@ import { setCurrentServiceTier, setIntroPersonality } from '@/store/session' +import { applyAutoSpeakFromConfig } from '@/store/voice-prefs' const DEFAULT_VOICE_SECONDS = 120 const FAST_TIERS = new Set(['fast', 'priority', 'on']) @@ -65,6 +66,7 @@ export function useHermesConfig({ activeSessionIdRef, refreshProjectBranch }: He setVoiceMaxRecordingSeconds(recordingLimit(config.voice?.max_recording_seconds)) setSttEnabled(config.stt?.enabled !== false) + applyAutoSpeakFromConfig(config) } catch { // Config is nice-to-have; chat still works without it. } diff --git a/apps/desktop/src/app/session/hooks/use-message-stream.ts b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts similarity index 53% rename from apps/desktop/src/app/session/hooks/use-message-stream.ts rename to apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts index a455bae06cc4..8bb4010937fb 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts @@ -1,34 +1,16 @@ import type { QueryClient } from '@tanstack/react-query' -import { type MutableRefObject, useCallback, useEffect, useRef } from 'react' +import { type MutableRefObject, useCallback } from 'react' import { writeAgentTerminalChunk } from '@/app/right-sidebar/terminal/agent-terminal-stream' -import { closeAgentTerminalByProc } from '@/app/right-sidebar/terminal/terminals' import { readActiveTerminal } from '@/app/right-sidebar/terminal/buffer' +import { closeAgentTerminalByProc } from '@/app/right-sidebar/terminal/terminals' import { translateNow } from '@/i18n' -import { - appendAssistantTextPart, - appendReasoningPart, - assistantTextPart, - type ChatMessage, - type ChatMessagePart, - chatMessageText, - type GatewayEventPayload, - reasoningPart, - renderMediaTags, - textPart, - upsertToolPart -} from '@/lib/chat-messages' +import { type GatewayEventPayload, textPart } 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 { clearClarifyRequest, setClarifyRequest } from '@/store/clarify' import { setSessionCompacting } from '@/store/compaction' import { refreshBackgroundProcesses } from '@/store/composer-status' @@ -54,674 +36,61 @@ import { setTurnStartedAt, setYoloActive } from '@/store/session' -import { broadcastSessionsChanged } from '@/store/session-sync' import { clearSessionSubagents, pruneDelegateFallbackSubagents, upsertSubagent } from '@/store/subagents' -import { setSessionTodos } from '@/store/todos' import { recordToolDiff } from '@/store/tool-diffs' import { notifyWorkspaceChanged, toolMayMutateFiles } from '@/store/workspace-events' import type { RpcEvent } from '@/types/hermes' -import type { ClientSessionState } from '../../types' +import type { ClientSessionState } from '../../../types' + +import { hasSessionInfoStatePatch, sessionInfoStatePatch, SUBAGENT_EVENT_TYPES, toTodoPayload } from './utils' -interface MessageStreamOptions { +interface GatewayEventDeps { activeSessionIdRef: MutableRefObject - hydrateFromStoredSession: ( - attempts?: number, - storedSessionId?: string | null, - runtimeSessionId?: string | null - ) => Promise + compactedTurnRef: MutableRefObject> + lastCwdInfoSessionRef: MutableRefObject + nativeSubagentSessionsRef: MutableRefObject> + appendAssistantDelta: (sessionId: string, delta: string) => void + appendReasoningDelta: (sessionId: string, delta: string, replace?: boolean) => void + completeAssistantMessage: (sessionId: string, text: string) => void + failAssistantMessage: (sessionId: string, errorMessage: string) => void + flushQueuedDeltas: (sessionId?: string) => void queryClient: QueryClient refreshHermesConfig: () => Promise - refreshSessions: () => Promise - sessionStateByRuntimeIdRef: MutableRefObject> + sessionInterrupted: (sessionId: string) => boolean updateSessionState: ( sessionId: string, updater: (state: ClientSessionState) => ClientSessionState, storedSessionId?: string | null ) => ClientSessionState + upsertToolCall: ( + sessionId: string, + payload: GatewayEventPayload | undefined, + phase: 'running' | 'complete', + sourceEventType?: string + ) => void } -interface QueuedStreamDeltas { - assistant: string - reasoning: string -} - -type SessionRuntimeStatePatch = Partial< - Pick< - ClientSessionState, - 'branch' | 'cwd' | 'fast' | 'model' | 'personality' | 'provider' | 'reasoningEffort' | 'serviceTier' | 'yolo' - > -> - -function sessionInfoStatePatch(payload: GatewayEventPayload | undefined): SessionRuntimeStatePatch { - const patch: SessionRuntimeStatePatch = {} - - if (typeof payload?.model === 'string') { - patch.model = payload.model || '' - } - - if (typeof payload?.provider === 'string') { - patch.provider = payload.provider || '' - } - - if (typeof payload?.cwd === 'string') { - patch.cwd = payload.cwd - } - - if (typeof payload?.branch === 'string') { - patch.branch = payload.branch - } - - if (typeof payload?.personality === 'string') { - patch.personality = normalizePersonalityValue(payload.personality) - } - - if (typeof payload?.reasoning_effort === 'string') { - patch.reasoningEffort = payload.reasoning_effort - } - - if (typeof payload?.service_tier === 'string') { - patch.serviceTier = payload.service_tier - } - - if (typeof payload?.fast === 'boolean') { - patch.fast = payload.fast - } - - if (typeof payload?.yolo === 'boolean') { - patch.yolo = payload.yolo - } - - return patch -} - -function hasSessionInfoStatePatch(patch: SessionRuntimeStatePatch): boolean { - return Object.keys(patch).length > 0 -} - -// Minimum gap between two assistant-text flushes during a stream. Was 16ms -// (rAF only), which at typical LLM token rates of ~30-80 tok/sec meant every -// token got its own React commit + Streamdown markdown re-parse, scaling -// linearly with the growing last-block length. Bumping to 33ms lets ~2 tokens -// batch into one commit at 60 tok/sec without introducing visible lag on the -// streaming text (still 30 fps of visible text growth). Big perceived -// smoothness win on long messages with big trailing paragraphs; see -// `scripts/profile-typing-lag.md` for the measurement work behind this. -const STREAM_DELTA_FLUSH_MS = 33 - -// Gateway/provider failures sometimes arrive as message.complete text instead -// of an explicit error event. Treat matches as inline assistant errors so they -// persist like real error events and don't get erased by hydrate fallback. -const COMPLETION_ERROR_PATTERNS = [ - /^API call failed after \d+ retries:/i, - /^HTTP\s+\d{3}\b/i, - /^(Provider|Gateway)\s+error:/i -] - -function completionErrorText(finalText: string): string | null { - const text = finalText.trim() - - return text && COMPLETION_ERROR_PATTERNS.some(re => re.test(text)) ? text : null -} - -const SUBAGENT_EVENT_TYPES = new Set([ - 'subagent.spawn_requested', - 'subagent.start', - 'subagent.thinking', - 'subagent.tool', - 'subagent.progress', - 'subagent.complete' -]) - -// Anonymous progress events that carry todos but no name still belong to the -// todo stream; named todo events are obviously routed there too. -function toTodoPayload(payload: GatewayEventPayload | undefined): GatewayEventPayload | undefined { - if (!payload) { - return undefined - } - - const isTodo = payload.name === 'todo' || (!payload.name && Object.hasOwn(payload, 'todos')) - - return isTodo ? { ...payload, name: 'todo', tool_id: payload.tool_id || 'todo-live' } : undefined -} - -function asRecord(value: unknown): Record { - return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record) : {} -} - -function parseMaybeRecord(value: unknown): Record { - if (typeof value === 'string') { - try { - return asRecord(JSON.parse(value)) - } catch { - return {} - } - } - - return asRecord(value) -} - -const firstString = (...candidates: unknown[]): string => { - for (const v of candidates) { - if (typeof v === 'string' && v) { - return v - } - } - - return '' -} - -function delegateTaskPayloads( - payload: GatewayEventPayload | undefined, - phase: 'running' | 'complete', - sourceEventType?: string -): Record[] { - if (payload?.name !== 'delegate_task') { - return [] - } - - const args = parseMaybeRecord(payload.args ?? payload.input) - const result = parseMaybeRecord(payload.result) - const rawTasks = Array.isArray(args.tasks) ? args.tasks : [] - const tasks = rawTasks.length ? rawTasks.map(parseMaybeRecord) : [args] - const status = phase === 'complete' ? (payload.error ? 'failed' : 'completed') : 'running' - const toolId = payload.tool_id || payload.tool_call_id || payload.id || 'delegate_task' - const progressText = firstString(payload.preview, payload.message, payload.context) - - const eventType = - phase === 'complete' - ? 'subagent.complete' - : sourceEventType === 'tool.start' - ? 'subagent.start' - : 'subagent.progress' - - return tasks.map((task, index) => { - const goal = firstString(task.goal, args.goal, payload.context) || 'Delegated task' - const summary = firstString(result.summary, payload.summary, payload.message) - - return { - depth: 0, - duration_seconds: payload.duration_s, - goal, - status, - subagent_id: `delegate-tool:${toolId}:${index}`, - summary: summary || undefined, - task_count: tasks.length, - task_index: index, - text: eventType === 'subagent.progress' ? progressText || goal : undefined, - tool_name: eventType === 'subagent.start' ? 'delegate_task' : undefined, - tool_preview: eventType === 'subagent.start' ? progressText : undefined, - toolsets: Array.isArray(task.toolsets) ? task.toolsets : Array.isArray(args.toolsets) ? args.toolsets : [], - event_type: eventType, - output_tail: - phase === 'complete' && summary - ? [{ is_error: Boolean(payload.error), preview: summary, tool: 'delegate_task' }] - : undefined - } - }) -} - -export function useMessageStream({ - activeSessionIdRef, - hydrateFromStoredSession, - 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( - ( - sessionId: string, - transform: (parts: ChatMessagePart[], message: ChatMessage) => ChatMessagePart[], - seed: () => ChatMessagePart[], - opts: { - pending?: (message: ChatMessage) => boolean - } = {} - ) => { - const apply = () => { - updateSessionState(sessionId, state => { - // After a stop, drop any late deltas / tool events for the - // cancelled turn so they don't keep growing the (now finalized) - // assistant bubble or, worse, seed a brand-new bubble that - // appears to belong to the next user message. - if (state.interrupted) { - return state - } - - const streamId = state.streamId ?? `assistant-stream-${Date.now()}` - const groupId = state.pendingBranchGroup ?? undefined - const prev = state.messages - let nextMessages: ChatMessage[] - - if (!prev.some(m => m.id === streamId)) { - nextMessages = [ - ...prev, - { - id: streamId, - role: 'assistant', - parts: seed(), - pending: true, - branchGroupId: groupId - } - ] - } else { - nextMessages = prev.map(m => - m.id === streamId - ? { - ...m, - parts: transform(m.parts, m), - pending: opts.pending ? opts.pending(m) : true - } - : m - ) - } - - return { - ...state, - messages: nextMessages, - streamId, - sawAssistantPayload: true, - awaitingResponse: false - } - }) - } - - apply() - }, - [updateSessionState] - ) - - const queuedDeltasRef = useRef>(new Map()) - const flushHandleRef = useRef(null) - const lastFlushAtRef = useRef(0) - const nativeSubagentSessionsRef = useRef>(new Set()) - // Turns that auto-compacted: skip post-turn hydrate so live scrollback survives. - const compactedTurnRef = useRef>(new Set()) - // Last session we applied a session.info cwd for — lets us tell an agent - // relocating the SAME session (follow it) from a session switch (don't yank). - const lastCwdInfoSessionRef = useRef(null) - - const flushQueuedDeltas = useCallback( - (sessionId?: string) => { - const queue = queuedDeltasRef.current - const ids = sessionId ? [sessionId] : [...queue.keys()] - - for (const id of ids) { - const queued = queue.get(id) - - if (!queued) { - continue - } - - queue.delete(id) - - if (queued.assistant) { - mutateStream( - id, - parts => dedupeGeneratedImageEchoesInParts(appendAssistantTextPart(parts, queued.assistant)), - () => [assistantTextPart(queued.assistant)] - ) - } - - if (queued.reasoning) { - mutateStream( - id, - parts => appendReasoningPart(parts, queued.reasoning), - () => [reasoningPart(queued.reasoning)] - ) - } - } - }, - [mutateStream] - ) - - const scheduleDeltaFlush = useCallback(() => { - if (flushHandleRef.current !== null) { - return - } - - if (typeof window === 'undefined') { - flushQueuedDeltas() - - return - } - - // Enforce a floor on the gap between two flushes. Without it, an LLM - // emitting tokens slower than the rAF cadence (~30-80 tok/sec is typical) - // forces one React commit + Streamdown re-parse per token, and the - // last-block markdown re-parse cost is roughly linear in current block - // length. With this floor, slower streams still coalesce ~2 tokens per - // commit and the synthetic harness shows longtask counts drop from ~5/5s - // to ~1/5s on big sessions (see scripts/profile-typing-lag.md). - const sinceLast = performance.now() - lastFlushAtRef.current - - const runFlush = () => { - flushHandleRef.current = null - lastFlushAtRef.current = performance.now() - flushQueuedDeltas() - } - - if (sinceLast >= STREAM_DELTA_FLUSH_MS && typeof window.requestAnimationFrame === 'function') { - flushHandleRef.current = window.requestAnimationFrame(runFlush) - - return - } - - flushHandleRef.current = window.setTimeout(runFlush, Math.max(0, STREAM_DELTA_FLUSH_MS - sinceLast)) - }, [flushQueuedDeltas]) - - const queueDelta = useCallback( - (sessionId: string, key: keyof QueuedStreamDeltas, delta: string) => { - if (!delta) { - return - } - - const queued = queuedDeltasRef.current.get(sessionId) ?? { assistant: '', reasoning: '' } - queued[key] += delta - queuedDeltasRef.current.set(sessionId, queued) - scheduleDeltaFlush() - }, - [scheduleDeltaFlush] - ) - - useEffect( - () => () => { - if (flushHandleRef.current !== null && typeof window !== 'undefined') { - if (typeof window.cancelAnimationFrame === 'function') { - window.cancelAnimationFrame(flushHandleRef.current) - } else { - window.clearTimeout(flushHandleRef.current) - } - } - - flushHandleRef.current = null - flushQueuedDeltas() - }, - [flushQueuedDeltas] - ) - - const appendAssistantDelta = useCallback( - (sessionId: string, delta: string) => { - if (!delta) { - return - } - - queueDelta(sessionId, 'assistant', delta) - }, - [queueDelta] - ) - - const appendReasoningDelta = useCallback( - (sessionId: string, delta: string, replace = false) => { - if (!delta) { - return - } - - if (!replace) { - queueDelta(sessionId, 'reasoning', delta) - - return - } - - flushQueuedDeltas(sessionId) - - mutateStream( - sessionId, - (parts, message) => { - if (replace && chatMessageText(message).trim()) { - return parts - } - - if (replace) { - return [...parts.filter(part => part.type !== 'reasoning'), reasoningPart(delta)] - } - - return appendReasoningPart(parts, delta) - }, - () => [reasoningPart(delta)] - ) - }, - [flushQueuedDeltas, mutateStream, queueDelta] - ) - - const upsertToolCall = useCallback( - ( - sessionId: string, - payload: GatewayEventPayload | undefined, - phase: 'running' | 'complete', - sourceEventType?: string - ) => { - // Text deltas flush on a timer but tool events apply now; flush first so - // 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( - sessionId, - subagentPayload, - true, - phase === 'complete' ? 'delegate.complete' : 'delegate.running' - ) - } - } - - mutateStream( - sessionId, - parts => dedupeGeneratedImageEchoesInParts(upsertToolPart(parts, payload, phase)), - () => upsertToolPart([], payload, phase), - { pending: m => phase !== 'complete' || (m.pending ?? false) } - ) - }, - [flushQueuedDeltas, mutateStream, sessionInterrupted] - ) - - const completeAssistantMessage = useCallback( - (sessionId: string, text: string) => { - let shouldHydrate = false - - const completedState = updateSessionState(sessionId, state => { - // Late completion from an already-cancelled turn: cancelRun has - // already finalized the bubble (kept the partial text, dropped it if - // empty). Re-running the dedupe below would replace the partial with - // the just-cancelled full text, so we settle and bail instead. - if (state.interrupted) { - return { - ...state, - awaitingResponse: false, - busy: false, - needsInput: false, - pendingBranchGroup: null, - streamId: null, - turnStartedAt: null - } - } - - const streamId = state.streamId - const finalText = renderMediaTags(text).trim() - const completionError = completionErrorText(finalText) - const normalize = (value: string) => value.replace(/\s+/g, ' ').trim() - - 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 - } - - if (part.type !== 'reasoning' || !dedupeReference) { - return true - } - - const r = normalize(part.text) - - return !(r && (dedupeReference.startsWith(r) || r.startsWith(dedupeReference))) - }) - - return visibleFinalText ? [...kept, assistantTextPart(visibleFinalText)] : kept - } - - const completeMessage = (message: ChatMessage): ChatMessage => - completionError - ? { - ...message, - error: completionError, - parts: message.parts.filter(part => part.type !== 'text'), - pending: false - } - : { - ...message, - parts: replaceTextPart(message.parts), - pending: false - } - - const newAssistantFromCompletion = (): ChatMessage => ({ - id: `assistant-${Date.now()}`, - role: 'assistant', - parts: completionError ? [] : [assistantTextPart(finalText)], - branchGroupId: state.pendingBranchGroup ?? undefined, - ...(completionError && { error: completionError }) - }) - - const prev = state.messages - let nextMessages = prev - - if (streamId && prev.some(m => m.id === streamId)) { - nextMessages = prev.map(m => (m.id === streamId ? completeMessage(m) : m)) - } else { - const fallbackIndex = [...prev] - .reverse() - .findIndex(message => message.role === 'assistant' && !message.hidden) - - if (fallbackIndex >= 0) { - const index = prev.length - 1 - fallbackIndex - const existing = prev[index] - const existingText = chatMessageText(existing).trim() - - if (existing.pending || (finalText && existingText === finalText)) { - nextMessages = prev.map((message, messageIndex) => - messageIndex === index ? completeMessage(message) : message - ) - } else if (finalText) { - nextMessages = [...prev, newAssistantFromCompletion()] - } - } else if (finalText) { - nextMessages = [...prev, newAssistantFromCompletion()] - } - } - - const hasInlineError = nextMessages.some(m => m.role === 'assistant' && m.error && !m.hidden) - const lastVisible = [...nextMessages].reverse().find(m => !m.hidden) - const unresolvedUserTail = lastVisible?.role === 'user' - shouldHydrate = - !completionError && !hasInlineError && !unresolvedUserTail && (!state.sawAssistantPayload || !finalText) - - return { - ...state, - messages: nextMessages, - streamId: null, - pendingBranchGroup: null, - awaitingResponse: false, - busy: false, - needsInput: false, - turnStartedAt: null - } - }) - - void refreshSessions().catch(() => undefined) - // Sync the freshly-titled row to other windows (e.g. main, when the turn - // ran in the pop-out). - broadcastSessionsChanged() - - if (compactedTurnRef.current.delete(sessionId)) { - shouldHydrate = false - } - - if (shouldHydrate) { - void hydrateFromStoredSession(3, completedState.storedSessionId, sessionId) - } - - dispatchNativeNotification({ - body: text.slice(0, 140) || translateNow('notifications.native.turnDoneBody'), - kind: 'turnDone', - sessionId, - title: translateNow('notifications.native.turnDoneTitle') - }) - }, - [hydrateFromStoredSession, refreshSessions, updateSessionState] - ) - - const failAssistantMessage = useCallback( - (sessionId: string, errorMessage: string) => { - updateSessionState(sessionId, state => { - const streamId = state.streamId ?? `assistant-error-${Date.now()}` - const groupId = state.pendingBranchGroup ?? undefined - const prev = state.messages - const error = errorMessage.trim() || 'Hermes reported an error' - - const nextMessages = prev.some(m => m.id === streamId) - ? prev.map(message => - message.id === streamId - ? { - ...message, - error, - pending: false - } - : message - ) - : [ - ...prev, - { - id: streamId, - role: 'assistant' as const, - parts: [], - error, - pending: false, - branchGroupId: groupId - } - ] - - return { - ...state, - messages: nextMessages, - streamId: null, - pendingBranchGroup: null, - sawAssistantPayload: true, - awaitingResponse: false, - busy: false, - needsInput: false, - turnStartedAt: null - } - }) - }, - [updateSessionState] - ) +/** The gateway-event dispatcher, extracted from useMessageStream. */ +export function useGatewayEventHandler(deps: GatewayEventDeps) { + const { + appendAssistantDelta, + appendReasoningDelta, + activeSessionIdRef, + compactedTurnRef, + lastCwdInfoSessionRef, + nativeSubagentSessionsRef, + completeAssistantMessage, + failAssistantMessage, + flushQueuedDeltas, + queryClient, + refreshHermesConfig, + sessionInterrupted, + updateSessionState, + upsertToolCall + } = deps - const handleGatewayEvent = useCallback( + return useCallback( (event: RpcEvent) => { const payload = event.payload as GatewayEventPayload | undefined const explicitSid = event.session_id || '' @@ -1264,9 +633,12 @@ export function useMessageStream({ appendAssistantDelta, appendReasoningDelta, activeSessionIdRef, + compactedTurnRef, completeAssistantMessage, failAssistantMessage, flushQueuedDeltas, + lastCwdInfoSessionRef, + nativeSubagentSessionsRef, queryClient, refreshHermesConfig, sessionInterrupted, @@ -1274,12 +646,4 @@ export function useMessageStream({ upsertToolCall ] ) - - return { - appendAssistantDelta, - appendReasoningDelta, - completeAssistantMessage, - handleGatewayEvent, - upsertToolCall - } } diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/index.ts b/apps/desktop/src/app/session/hooks/use-message-stream/index.ts new file mode 100644 index 000000000000..65a203a215eb --- /dev/null +++ b/apps/desktop/src/app/session/hooks/use-message-stream/index.ts @@ -0,0 +1,540 @@ +import type { QueryClient } from '@tanstack/react-query' +import { type MutableRefObject, useCallback, useEffect, useRef } from 'react' + +import { translateNow } from '@/i18n' +import { + appendAssistantTextPart, + appendReasoningPart, + assistantTextPart, + type ChatMessage, + type ChatMessagePart, + chatMessageText, + type GatewayEventPayload, + reasoningPart, + renderMediaTags, + upsertToolPart +} from '@/lib/chat-messages' +import { + dedupeGeneratedImageEchoesInParts, + generatedImageEchoSources, + stripGeneratedImageEchoes +} from '@/lib/generated-images' +import { parseTodos } from '@/lib/todos' +import { dispatchNativeNotification } from '@/store/native-notifications' +import { broadcastSessionsChanged } from '@/store/session-sync' +import { upsertSubagent } from '@/store/subagents' +import { setSessionTodos } from '@/store/todos' + +import type { ClientSessionState } from '../../../types' + +import { useGatewayEventHandler } from './gateway-event' +import { completionErrorText, delegateTaskPayloads, STREAM_DELTA_FLUSH_MS } from './utils' + +interface MessageStreamOptions { + activeSessionIdRef: MutableRefObject + hydrateFromStoredSession: ( + attempts?: number, + storedSessionId?: string | null, + runtimeSessionId?: string | null + ) => Promise + queryClient: QueryClient + refreshHermesConfig: () => Promise + refreshSessions: () => Promise + sessionStateByRuntimeIdRef: MutableRefObject> + updateSessionState: ( + sessionId: string, + updater: (state: ClientSessionState) => ClientSessionState, + storedSessionId?: string | null + ) => ClientSessionState +} + +interface QueuedStreamDeltas { + assistant: string + reasoning: string +} + +export function useMessageStream({ + activeSessionIdRef, + hydrateFromStoredSession, + 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( + ( + sessionId: string, + transform: (parts: ChatMessagePart[], message: ChatMessage) => ChatMessagePart[], + seed: () => ChatMessagePart[], + opts: { + pending?: (message: ChatMessage) => boolean + } = {} + ) => { + const apply = () => { + updateSessionState(sessionId, state => { + // After a stop, drop any late deltas / tool events for the + // cancelled turn so they don't keep growing the (now finalized) + // assistant bubble or, worse, seed a brand-new bubble that + // appears to belong to the next user message. + if (state.interrupted) { + return state + } + + const streamId = state.streamId ?? `assistant-stream-${Date.now()}` + const groupId = state.pendingBranchGroup ?? undefined + const prev = state.messages + let nextMessages: ChatMessage[] + + if (!prev.some(m => m.id === streamId)) { + nextMessages = [ + ...prev, + { + id: streamId, + role: 'assistant', + parts: seed(), + pending: true, + branchGroupId: groupId + } + ] + } else { + nextMessages = prev.map(m => + m.id === streamId + ? { + ...m, + parts: transform(m.parts, m), + pending: opts.pending ? opts.pending(m) : true + } + : m + ) + } + + return { + ...state, + messages: nextMessages, + streamId, + sawAssistantPayload: true, + awaitingResponse: false + } + }) + } + + apply() + }, + [updateSessionState] + ) + + const queuedDeltasRef = useRef>(new Map()) + const flushHandleRef = useRef(null) + const lastFlushAtRef = useRef(0) + const nativeSubagentSessionsRef = useRef>(new Set()) + // Turns that auto-compacted: skip post-turn hydrate so live scrollback survives. + const compactedTurnRef = useRef>(new Set()) + // Last session we applied a session.info cwd for — lets us tell an agent + // relocating the SAME session (follow it) from a session switch (don't yank). + const lastCwdInfoSessionRef = useRef(null) + + const flushQueuedDeltas = useCallback( + (sessionId?: string) => { + const queue = queuedDeltasRef.current + const ids = sessionId ? [sessionId] : [...queue.keys()] + + for (const id of ids) { + const queued = queue.get(id) + + if (!queued) { + continue + } + + queue.delete(id) + + if (queued.assistant) { + mutateStream( + id, + parts => dedupeGeneratedImageEchoesInParts(appendAssistantTextPart(parts, queued.assistant)), + () => [assistantTextPart(queued.assistant)] + ) + } + + if (queued.reasoning) { + mutateStream( + id, + parts => appendReasoningPart(parts, queued.reasoning), + () => [reasoningPart(queued.reasoning)] + ) + } + } + }, + [mutateStream] + ) + + const scheduleDeltaFlush = useCallback(() => { + if (flushHandleRef.current !== null) { + return + } + + if (typeof window === 'undefined') { + flushQueuedDeltas() + + return + } + + // Enforce a floor on the gap between two flushes. Without it, an LLM + // emitting tokens slower than the rAF cadence (~30-80 tok/sec is typical) + // forces one React commit + Streamdown re-parse per token, and the + // last-block markdown re-parse cost is roughly linear in current block + // length. With this floor, slower streams still coalesce ~2 tokens per + // commit and the synthetic harness shows longtask counts drop from ~5/5s + // to ~1/5s on big sessions (see scripts/profile-typing-lag.md). + const sinceLast = performance.now() - lastFlushAtRef.current + + const runFlush = () => { + flushHandleRef.current = null + lastFlushAtRef.current = performance.now() + flushQueuedDeltas() + } + + if (sinceLast >= STREAM_DELTA_FLUSH_MS && typeof window.requestAnimationFrame === 'function') { + flushHandleRef.current = window.requestAnimationFrame(runFlush) + + return + } + + flushHandleRef.current = window.setTimeout(runFlush, Math.max(0, STREAM_DELTA_FLUSH_MS - sinceLast)) + }, [flushQueuedDeltas]) + + const queueDelta = useCallback( + (sessionId: string, key: keyof QueuedStreamDeltas, delta: string) => { + if (!delta) { + return + } + + const queued = queuedDeltasRef.current.get(sessionId) ?? { assistant: '', reasoning: '' } + queued[key] += delta + queuedDeltasRef.current.set(sessionId, queued) + scheduleDeltaFlush() + }, + [scheduleDeltaFlush] + ) + + useEffect( + () => () => { + if (flushHandleRef.current !== null && typeof window !== 'undefined') { + if (typeof window.cancelAnimationFrame === 'function') { + window.cancelAnimationFrame(flushHandleRef.current) + } else { + window.clearTimeout(flushHandleRef.current) + } + } + + flushHandleRef.current = null + flushQueuedDeltas() + }, + [flushQueuedDeltas] + ) + + const appendAssistantDelta = useCallback( + (sessionId: string, delta: string) => { + if (!delta) { + return + } + + queueDelta(sessionId, 'assistant', delta) + }, + [queueDelta] + ) + + const appendReasoningDelta = useCallback( + (sessionId: string, delta: string, replace = false) => { + if (!delta) { + return + } + + if (!replace) { + queueDelta(sessionId, 'reasoning', delta) + + return + } + + flushQueuedDeltas(sessionId) + + mutateStream( + sessionId, + (parts, message) => { + if (replace && chatMessageText(message).trim()) { + return parts + } + + if (replace) { + return [...parts.filter(part => part.type !== 'reasoning'), reasoningPart(delta)] + } + + return appendReasoningPart(parts, delta) + }, + () => [reasoningPart(delta)] + ) + }, + [flushQueuedDeltas, mutateStream, queueDelta] + ) + + const upsertToolCall = useCallback( + ( + sessionId: string, + payload: GatewayEventPayload | undefined, + phase: 'running' | 'complete', + sourceEventType?: string + ) => { + // Text deltas flush on a timer but tool events apply now; flush first so + // 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( + sessionId, + subagentPayload, + true, + phase === 'complete' ? 'delegate.complete' : 'delegate.running' + ) + } + } + + mutateStream( + sessionId, + parts => dedupeGeneratedImageEchoesInParts(upsertToolPart(parts, payload, phase)), + () => upsertToolPart([], payload, phase), + { pending: m => phase !== 'complete' || (m.pending ?? false) } + ) + }, + [flushQueuedDeltas, mutateStream, sessionInterrupted] + ) + + const completeAssistantMessage = useCallback( + (sessionId: string, text: string) => { + let shouldHydrate = false + + const completedState = updateSessionState(sessionId, state => { + // Late completion from an already-cancelled turn: cancelRun has + // already finalized the bubble (kept the partial text, dropped it if + // empty). Re-running the dedupe below would replace the partial with + // the just-cancelled full text, so we settle and bail instead. + if (state.interrupted) { + return { + ...state, + awaitingResponse: false, + busy: false, + needsInput: false, + pendingBranchGroup: null, + streamId: null, + turnStartedAt: null + } + } + + const streamId = state.streamId + const finalText = renderMediaTags(text).trim() + const completionError = completionErrorText(finalText) + const normalize = (value: string) => value.replace(/\s+/g, ' ').trim() + + 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 + } + + if (part.type !== 'reasoning' || !dedupeReference) { + return true + } + + const r = normalize(part.text) + + return !(r && (dedupeReference.startsWith(r) || r.startsWith(dedupeReference))) + }) + + return visibleFinalText ? [...kept, assistantTextPart(visibleFinalText)] : kept + } + + const completeMessage = (message: ChatMessage): ChatMessage => + completionError + ? { + ...message, + error: completionError, + parts: message.parts.filter(part => part.type !== 'text'), + pending: false + } + : { + ...message, + parts: replaceTextPart(message.parts), + pending: false + } + + const newAssistantFromCompletion = (): ChatMessage => ({ + id: `assistant-${Date.now()}`, + role: 'assistant', + parts: completionError ? [] : [assistantTextPart(finalText)], + branchGroupId: state.pendingBranchGroup ?? undefined, + ...(completionError && { error: completionError }) + }) + + const prev = state.messages + let nextMessages = prev + + if (streamId && prev.some(m => m.id === streamId)) { + nextMessages = prev.map(m => (m.id === streamId ? completeMessage(m) : m)) + } else { + const fallbackIndex = [...prev] + .reverse() + .findIndex(message => message.role === 'assistant' && !message.hidden) + + if (fallbackIndex >= 0) { + const index = prev.length - 1 - fallbackIndex + const existing = prev[index] + const existingText = chatMessageText(existing).trim() + + if (existing.pending || (finalText && existingText === finalText)) { + nextMessages = prev.map((message, messageIndex) => + messageIndex === index ? completeMessage(message) : message + ) + } else if (finalText) { + nextMessages = [...prev, newAssistantFromCompletion()] + } + } else if (finalText) { + nextMessages = [...prev, newAssistantFromCompletion()] + } + } + + const hasInlineError = nextMessages.some(m => m.role === 'assistant' && m.error && !m.hidden) + const lastVisible = [...nextMessages].reverse().find(m => !m.hidden) + const unresolvedUserTail = lastVisible?.role === 'user' + shouldHydrate = + !completionError && !hasInlineError && !unresolvedUserTail && (!state.sawAssistantPayload || !finalText) + + return { + ...state, + messages: nextMessages, + streamId: null, + pendingBranchGroup: null, + awaitingResponse: false, + busy: false, + needsInput: false, + turnStartedAt: null + } + }) + + void refreshSessions().catch(() => undefined) + // Sync the freshly-titled row to other windows (e.g. main, when the turn + // ran in the pop-out). + broadcastSessionsChanged() + + if (compactedTurnRef.current.delete(sessionId)) { + shouldHydrate = false + } + + if (shouldHydrate) { + void hydrateFromStoredSession(3, completedState.storedSessionId, sessionId) + } + + dispatchNativeNotification({ + body: text.slice(0, 140) || translateNow('notifications.native.turnDoneBody'), + kind: 'turnDone', + sessionId, + title: translateNow('notifications.native.turnDoneTitle') + }) + }, + [hydrateFromStoredSession, refreshSessions, updateSessionState] + ) + + const failAssistantMessage = useCallback( + (sessionId: string, errorMessage: string) => { + updateSessionState(sessionId, state => { + const streamId = state.streamId ?? `assistant-error-${Date.now()}` + const groupId = state.pendingBranchGroup ?? undefined + const prev = state.messages + const error = errorMessage.trim() || 'Hermes reported an error' + + const nextMessages = prev.some(m => m.id === streamId) + ? prev.map(message => + message.id === streamId + ? { + ...message, + error, + pending: false + } + : message + ) + : [ + ...prev, + { + id: streamId, + role: 'assistant' as const, + parts: [], + error, + pending: false, + branchGroupId: groupId + } + ] + + return { + ...state, + messages: nextMessages, + streamId: null, + pendingBranchGroup: null, + sawAssistantPayload: true, + awaitingResponse: false, + busy: false, + needsInput: false, + turnStartedAt: null + } + }) + }, + [updateSessionState] + ) + + const handleGatewayEvent = useGatewayEventHandler({ + appendAssistantDelta, + appendReasoningDelta, + activeSessionIdRef, + compactedTurnRef, + lastCwdInfoSessionRef, + nativeSubagentSessionsRef, + completeAssistantMessage, + failAssistantMessage, + flushQueuedDeltas, + queryClient, + refreshHermesConfig, + sessionInterrupted, + updateSessionState, + upsertToolCall + }) + + return { + appendAssistantDelta, + appendReasoningDelta, + completeAssistantMessage, + handleGatewayEvent, + upsertToolCall + } +} diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/utils.test.ts b/apps/desktop/src/app/session/hooks/use-message-stream/utils.test.ts new file mode 100644 index 000000000000..47994355074c --- /dev/null +++ b/apps/desktop/src/app/session/hooks/use-message-stream/utils.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest' + +import type { GatewayEventPayload } from '@/lib/chat-messages' + +import { + completionErrorText, + delegateTaskPayloads, + hasSessionInfoStatePatch, + sessionInfoStatePatch, + toTodoPayload +} from './utils' + +const payload = (over: Record): GatewayEventPayload => over as GatewayEventPayload + +describe('completionErrorText', () => { + it('flags provider/HTTP/retry failures, ignores normal text', () => { + expect(completionErrorText('API call failed after 3 retries: boom')).toMatch(/^API call failed/) + expect(completionErrorText('HTTP 500 upstream')).toMatch(/^HTTP 500/) + expect(completionErrorText('Gateway error: nope')).toMatch(/^Gateway error/) + expect(completionErrorText('here is your answer')).toBeNull() + expect(completionErrorText(' ')).toBeNull() + }) +}) + +describe('toTodoPayload', () => { + it('routes named todo and anonymous todos-bearing events to the todo stream', () => { + expect(toTodoPayload(payload({ name: 'todo' }))?.tool_id).toBe('todo-live') + expect(toTodoPayload(payload({ todos: [] }))?.name).toBe('todo') + expect(toTodoPayload(payload({ name: 'web_search' }))).toBeUndefined() + expect(toTodoPayload(undefined)).toBeUndefined() + }) +}) + +describe('sessionInfoStatePatch / hasSessionInfoStatePatch', () => { + it('extracts only present runtime fields', () => { + const patch = sessionInfoStatePatch(payload({ model: 'gpt', fast: true, branch: 'main' })) + expect(patch).toMatchObject({ model: 'gpt', fast: true, branch: 'main' }) + expect(hasSessionInfoStatePatch(patch)).toBe(true) + expect(hasSessionInfoStatePatch(sessionInfoStatePatch(payload({})))).toBe(false) + }) +}) + +describe('delegateTaskPayloads', () => { + it('returns [] for non-delegate events', () => { + expect(delegateTaskPayloads(payload({ name: 'web_search' }), 'running')).toEqual([]) + }) + + it('maps a running tool.start to a subagent.start spec', () => { + const [spec] = delegateTaskPayloads( + payload({ name: 'delegate_task', tool_id: 't1', args: { goal: 'do it' } }), + 'running', + 'tool.start' + ) + + expect(spec).toMatchObject({ event_type: 'subagent.start', goal: 'do it', status: 'running' }) + }) + + it('maps completion (with error) to a failed subagent.complete', () => { + const [spec] = delegateTaskPayloads( + payload({ name: 'delegate_task', error: 'boom', result: { summary: 'failed run' } }), + 'complete' + ) + + expect(spec).toMatchObject({ event_type: 'subagent.complete', status: 'failed' }) + }) +}) diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/utils.ts b/apps/desktop/src/app/session/hooks/use-message-stream/utils.ts new file mode 100644 index 000000000000..d9a90b366920 --- /dev/null +++ b/apps/desktop/src/app/session/hooks/use-message-stream/utils.ts @@ -0,0 +1,179 @@ +import type { GatewayEventPayload } from '@/lib/chat-messages' +import { normalizePersonalityValue } from '@/lib/chat-runtime' + +import type { ClientSessionState } from '../../../types' + +type SessionRuntimeStatePatch = Partial< + Pick< + ClientSessionState, + 'branch' | 'cwd' | 'fast' | 'model' | 'personality' | 'provider' | 'reasoningEffort' | 'serviceTier' | 'yolo' + > +> + +export function sessionInfoStatePatch(payload: GatewayEventPayload | undefined): SessionRuntimeStatePatch { + const patch: SessionRuntimeStatePatch = {} + + if (typeof payload?.model === 'string') { + patch.model = payload.model || '' + } + + if (typeof payload?.provider === 'string') { + patch.provider = payload.provider || '' + } + + if (typeof payload?.cwd === 'string') { + patch.cwd = payload.cwd + } + + if (typeof payload?.branch === 'string') { + patch.branch = payload.branch + } + + if (typeof payload?.personality === 'string') { + patch.personality = normalizePersonalityValue(payload.personality) + } + + if (typeof payload?.reasoning_effort === 'string') { + patch.reasoningEffort = payload.reasoning_effort + } + + if (typeof payload?.service_tier === 'string') { + patch.serviceTier = payload.service_tier + } + + if (typeof payload?.fast === 'boolean') { + patch.fast = payload.fast + } + + if (typeof payload?.yolo === 'boolean') { + patch.yolo = payload.yolo + } + + return patch +} + +export function hasSessionInfoStatePatch(patch: SessionRuntimeStatePatch): boolean { + return Object.keys(patch).length > 0 +} + +// Minimum gap between two assistant-text flushes during a stream. Was 16ms +// (rAF only), which at typical LLM token rates of ~30-80 tok/sec meant every +// token got its own React commit + Streamdown markdown re-parse, scaling +// linearly with the growing last-block length. Bumping to 33ms lets ~2 tokens +// batch into one commit at 60 tok/sec without introducing visible lag on the +// streaming text (still 30 fps of visible text growth). Big perceived +// smoothness win on long messages with big trailing paragraphs; see +// `scripts/profile-typing-lag.md` for the measurement work behind this. +export const STREAM_DELTA_FLUSH_MS = 33 + +// Gateway/provider failures sometimes arrive as message.complete text instead +// of an explicit error event. Treat matches as inline assistant errors so they +// persist like real error events and don't get erased by hydrate fallback. +const COMPLETION_ERROR_PATTERNS = [ + /^API call failed after \d+ retries:/i, + /^HTTP\s+\d{3}\b/i, + /^(Provider|Gateway)\s+error:/i +] + +export function completionErrorText(finalText: string): string | null { + const text = finalText.trim() + + return text && COMPLETION_ERROR_PATTERNS.some(re => re.test(text)) ? text : null +} + +export const SUBAGENT_EVENT_TYPES = new Set([ + 'subagent.spawn_requested', + 'subagent.start', + 'subagent.thinking', + 'subagent.tool', + 'subagent.progress', + 'subagent.complete' +]) + +// Anonymous progress events that carry todos but no name still belong to the +// todo stream; named todo events are obviously routed there too. +export function toTodoPayload(payload: GatewayEventPayload | undefined): GatewayEventPayload | undefined { + if (!payload) { + return undefined + } + + const isTodo = payload.name === 'todo' || (!payload.name && Object.hasOwn(payload, 'todos')) + + return isTodo ? { ...payload, name: 'todo', tool_id: payload.tool_id || 'todo-live' } : undefined +} + +function asRecord(value: unknown): Record { + return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record) : {} +} + +function parseMaybeRecord(value: unknown): Record { + if (typeof value === 'string') { + try { + return asRecord(JSON.parse(value)) + } catch { + return {} + } + } + + return asRecord(value) +} + +const firstString = (...candidates: unknown[]): string => { + for (const v of candidates) { + if (typeof v === 'string' && v) { + return v + } + } + + return '' +} + +export function delegateTaskPayloads( + payload: GatewayEventPayload | undefined, + phase: 'running' | 'complete', + sourceEventType?: string +): Record[] { + if (payload?.name !== 'delegate_task') { + return [] + } + + const args = parseMaybeRecord(payload.args ?? payload.input) + const result = parseMaybeRecord(payload.result) + const rawTasks = Array.isArray(args.tasks) ? args.tasks : [] + const tasks = rawTasks.length ? rawTasks.map(parseMaybeRecord) : [args] + const status = phase === 'complete' ? (payload.error ? 'failed' : 'completed') : 'running' + const toolId = payload.tool_id || payload.tool_call_id || payload.id || 'delegate_task' + const progressText = firstString(payload.preview, payload.message, payload.context) + + const eventType = + phase === 'complete' + ? 'subagent.complete' + : sourceEventType === 'tool.start' + ? 'subagent.start' + : 'subagent.progress' + + return tasks.map((task, index) => { + const goal = firstString(task.goal, args.goal, payload.context) || 'Delegated task' + const summary = firstString(result.summary, payload.summary, payload.message) + + return { + depth: 0, + duration_seconds: payload.duration_s, + goal, + status, + subagent_id: `delegate-tool:${toolId}:${index}`, + summary: summary || undefined, + task_count: tasks.length, + task_index: index, + text: eventType === 'subagent.progress' ? progressText || goal : undefined, + tool_name: eventType === 'subagent.start' ? 'delegate_task' : undefined, + tool_preview: eventType === 'subagent.start' ? progressText : undefined, + toolsets: Array.isArray(task.toolsets) ? task.toolsets : Array.isArray(args.toolsets) ? args.toolsets : [], + event_type: eventType, + output_tail: + phase === 'complete' && summary + ? [{ is_error: Boolean(payload.error), preview: summary, tool: 'delegate_task' }] + : undefined + } + }) +} diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions.ts deleted file mode 100644 index 6e2829d61783..000000000000 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions.ts +++ /dev/null @@ -1,1956 +0,0 @@ -import type { AppendMessage, ThreadMessage } from '@assistant-ui/react' -import { useStore } from '@nanostores/react' -import { type MutableRefObject, useCallback, useEffect, useRef } from 'react' - -import { getProfiles, transcribeAudio } from '@/hermes' -import { translateNow, type Translations, useI18n } from '@/i18n' -import { stripAnsi } from '@/lib/ansi' -import { branchGroupForUser, type ChatMessage, chatMessageText, textPart } from '@/lib/chat-messages' -import { - optimisticAttachmentRef, - parseCommandDispatch, - parseSlashCommand, - pathLabel, - sessionTitle, - SLASH_COMMAND_RE -} from '@/lib/chat-runtime' -import { - type CommandsCatalogLike, - type DesktopActionId, - type DesktopPickerId, - desktopSlashUnavailableMessage, - filterDesktopCommandsCatalog, - isDesktopSlashCommand, - resolveDesktopCommand -} from '@/lib/desktop-slash-commands' -import { triggerHaptic } from '@/lib/haptics' -import { setMutableRef } from '@/lib/mutable-ref' -import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors' -import { setSessionYolo } from '@/lib/yolo-session' -import { clearClarifyRequest } from '@/store/clarify' -import { openCommandPalettePage } from '@/store/command-palette' -import { - $composerAttachments, - clearComposerAttachments, - type ComposerAttachment, - setComposerAttachmentUploadState, - setComposerDraft, - terminalContextBlocksFromDraft, - updateComposerAttachment -} from '@/store/composer' -import { resetSessionBackground } from '@/store/composer-status' -import { clearNotifications, notify, notifyError } from '@/store/notifications' -import { requestDesktopOnboarding } from '@/store/onboarding' -import { setPetScale } from '@/store/pet-gallery' -import { $petGenInput, openPetGenerate } from '@/store/pet-generate' -import { clearPreviewArtifacts } from '@/store/preview-status' -import { $activeGatewayProfile, $newChatProfile, ensureGatewayProfile, normalizeProfileKey } from '@/store/profile' -import { clearAllPrompts } from '@/store/prompts' -import { - $busy, - $connection, - $messages, - $sessions, - $yoloActive, - setAwaitingResponse, - setBusy, - setMessages, - setModelPickerOpen, - setSessionPickerOpen, - setSessions, - setYoloActive -} from '@/store/session' -import { clearSessionSubagents } from '@/store/subagents' -import { clearSessionTodos } from '@/store/todos' - -import type { - BrowserManageResponse, - ClientSessionState, - FileAttachResponse, - HandoffFailResponse, - HandoffRequestResponse, - HandoffStateResponse, - ImageAttachResponse, - SessionSteerResponse, - SessionTitleResponse, - SlashExecResponse -} from '../../types' - -interface HandoffResult { - ok: boolean - error?: string -} - -function delay(ms: number): Promise { - return new Promise(resolve => setTimeout(resolve, ms)) -} - -function isSessionIdCandidate(value: string): boolean { - const trimmed = value.trim() - - return /^\d{8}_\d{6}_[A-Fa-f0-9]{6}$/.test(trimmed) || /^[A-Fa-f0-9]{32}$/.test(trimmed) -} - -function blobToDataUrl(blob: Blob): Promise { - return new Promise((resolve, reject) => { - const reader = new FileReader() - - reader.addEventListener('load', () => { - if (typeof reader.result === 'string') { - resolve(reader.result) - } else { - reject(new Error(translateNow('desktop.audioReadFailed'))) - } - }) - reader.addEventListener('error', () => reject(reader.error || new Error(translateNow('desktop.audioReadFailed')))) - reader.readAsDataURL(blob) - }) -} - -function isProviderSetupError(error: unknown) { - const message = error instanceof Error ? error.message : String(error) - - return isProviderSetupErrorMessage(message) -} - -function inlineErrorMessage(error: unknown, fallback: string): string { - const raw = error instanceof Error ? error.message : typeof error === 'string' ? error : fallback - - return (raw.match(/Error invoking remote method '[^']+': Error: (.+)$/)?.[1] ?? raw).replace(/^Error:\s*/, '').trim() -} - -function isSessionNotFoundError(error: unknown): boolean { - const message = error instanceof Error ? error.message : String(error) - - 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(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(call: () => Promise): Promise { - 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 - } - } -} - -// Hard guard: at most one prompt.submit in flight per session. Every submit -// path — user Enter, queue drain, busy-retry, slash fallthrough — funnels -// through submitPromptText. Without this, a stalled turn (e.g. a context-bloated -// session whose first call hangs) let the SAME prompt launch several real turns -// at once (the "message stacked 5×" bug). Keyed by stored/active session id. -const _submitInFlight = new Set() - -function base64FromDataUrl(dataUrl: string): string { - const comma = dataUrl.indexOf(',') - - return comma >= 0 ? dataUrl.slice(comma + 1) : '' -} - -function imageFilenameFromPath(filePath: string): string { - return filePath.split(/[\\/]/).filter(Boolean).pop() || 'image.png' -} - -// Remote gateway: the local composer-image file lives on THIS machine's disk, -// not the gateway's, so read the bytes here and upload them via -// image.attach_bytes. Returns null when the file can't be read. -async function readImageForRemoteAttach(filePath: string): Promise<{ contentBase64: string; filename: string } | null> { - const dataUrl = await window.hermesDesktop?.readFileDataUrl(filePath) - const contentBase64 = dataUrl ? base64FromDataUrl(dataUrl) : '' - - return contentBase64 ? { contentBase64, filename: imageFilenameFromPath(filePath) } : null -} - -// Read a non-image file as a data URL for upload via file.attach. Returns null -// when the desktop bridge can't read the file (e.g. it was moved/deleted). -async function readFileDataUrlForAttach(filePath: string): Promise { - const reader = window.hermesDesktop?.readFileDataUrl - - if (!reader) { - return null - } - - const dataUrl = await reader(filePath) - - return dataUrl || null -} - -// The readFileDataUrl IPC base64-loads the whole file into memory and is -// hard-capped (DATA_URL_READ_MAX_BYTES, 16 MB) in electron/hardening.cjs, which -// rejects with a raw "file is too large (N bytes; limit M bytes)" string. In -// remote mode every attachment's bytes go through that read, so a big file -// surfaces that internal message verbatim in the failure toast. Translate it -// into a friendly "too large to upload to the remote gateway" line, parsing the -// limit out of the message so it tracks the real cap. Non-cap errors pass -// through unchanged. -function friendlyRemoteAttachError(err: unknown, label: string): Error { - const message = err instanceof Error ? err.message : String(err) - - if (!/too large/i.test(message)) { - return err instanceof Error ? err : new Error(message) - } - - const limitBytes = Number(message.match(/limit (\d+) bytes/)?.[1]) - const cap = Number.isFinite(limitBytes) && limitBytes > 0 ? ` (max ${Math.floor(limitBytes / (1024 * 1024))} MB)` : '' - - return new Error(`${label} is too large to upload to the remote gateway${cap}.`) -} - -type GatewayRequest = (method: string, params?: Record) => Promise - -/** - * Stage one file/image attachment into the session workspace and return the - * attachment rewritten with the gateway-side ref. Images upload their bytes in - * remote mode (so vision works) and pass the path locally; non-image files - * upload bytes remotely and pass the path locally. Throws on failure so callers - * can surface an error. Shared by submit-time sync, the eager drop-time upload, - * and the message-edit composer drop — keep them in lockstep. - */ -export async function uploadComposerAttachment( - attachment: ComposerAttachment, - opts: { remote: boolean; requestGateway: GatewayRequest; sessionId: string } -): Promise { - const { remote, requestGateway, sessionId } = opts - const path = attachment.path ?? '' - const label = attachment.label || pathLabel(path) - - if (attachment.kind === 'image') { - let result: ImageAttachResponse - - if (remote) { - let payload: Awaited> - - try { - payload = await readImageForRemoteAttach(path) - } catch (err) { - throw friendlyRemoteAttachError(err, label) - } - - if (!payload) { - throw new Error(`Could not read ${label}`) - } - - result = await requestGateway('image.attach_bytes', { - session_id: sessionId, - content_base64: payload.contentBase64, - filename: payload.filename - }) - } else { - result = await requestGateway('image.attach', { - path, - session_id: sessionId - }) - } - - if (!result.attached) { - throw new Error(result.message || `Could not attach ${label}`) - } - - const attachedPath = result.path || path - - return { - ...attachment, - attachedSessionId: sessionId, - label: attachedPath ? pathLabel(attachedPath) : attachment.label, - path: attachedPath, - uploadState: undefined - } - } - - // Non-image file. - let dataUrl: string | null = null - - if (remote) { - try { - dataUrl = await readFileDataUrlForAttach(path) - } catch (err) { - throw friendlyRemoteAttachError(err, label) - } - - if (!dataUrl) { - throw new Error(`Could not read ${label}`) - } - } - - const result = await requestGateway('file.attach', { - name: label, - path, - session_id: sessionId, - ...(dataUrl ? { data_url: dataUrl } : {}) - }) - - if (!result.attached || !result.ref_text) { - throw new Error(result.message || `Could not attach ${label}`) - } - - return { - ...attachment, - attachedSessionId: sessionId, - refText: result.ref_text, - uploadState: undefined - } -} - -interface PromptActionsOptions { - activeSessionId: string | null - activeSessionIdRef: MutableRefObject - busyRef: MutableRefObject - branchCurrentSession: () => Promise - createBackendSessionForSend: (preview?: string | null) => Promise - handleSkinCommand: (arg: string) => string - refreshSessions: () => Promise - requestGateway: (method: string, params?: Record) => Promise - resumeStoredSession: (storedSessionId: string) => Promise | void - selectedStoredSessionIdRef: MutableRefObject - startFreshSessionDraft: () => void - sttEnabled: boolean - updateSessionState: ( - sessionId: string, - updater: (state: ClientSessionState) => ClientSessionState, - storedSessionId?: string | null - ) => ClientSessionState -} - -interface SubmitTextOptions { - attachments?: ComposerAttachment[] - fromQueue?: boolean -} - -/** Everything a slash handler needs about the invocation it's serving. */ -interface SlashActionCtx { - arg: string - command: string - name: string - recordInput: boolean - sessionHint?: string -} - -function renderCommandsCatalog(catalog: CommandsCatalogLike, copy: Translations['desktop']): string { - const desktopCatalog = filterDesktopCommandsCatalog(catalog) - - const sections = desktopCatalog.categories?.length - ? desktopCatalog.categories - : [{ name: copy.desktopCommands, pairs: desktopCatalog.pairs ?? [] }] - - const body = sections - .filter(section => section.pairs.length > 0) - .map(section => { - const rows = section.pairs.map(([cmd, desc]) => `${cmd.padEnd(18)} ${desc}`) - - return [`${section.name}:`, ...rows].join('\n') - }) - .join('\n\n') - - const tail = [ - desktopCatalog.skill_count ? copy.skillCommandsAvailable(desktopCatalog.skill_count) : '', - desktopCatalog.warning ? copy.warningLine(desktopCatalog.warning) : '' - ] - .filter(Boolean) - .join('\n') - - return [body || 'No desktop commands available.', tail].filter(Boolean).join('\n\n') -} - -function slashStatusText(command: string, output: string): string { - return [`slash:${command}`, output.trim()].filter(Boolean).join('\n') -} - -function appendText(message: AppendMessage): string { - return message.content - .map(part => ('text' in part ? part.text : '')) - .join('') - .trim() -} - -function visibleUserOrdinal(messages: readonly ChatMessage[], end: number): number { - return messages.slice(0, end).filter(m => m.role === 'user' && !m.hidden).length -} - -function visibleUserIndexAtOrdinal(messages: readonly ChatMessage[], targetOrdinal: number): number { - let ordinal = 0 - - for (let index = 0; index < messages.length; index += 1) { - const message = messages[index] - - if (message.role !== 'user' || message.hidden) { - continue - } - - if (ordinal === targetOrdinal) { - return index - } - - ordinal += 1 - } - - return -1 -} - -interface RestoreMessageTarget { - text?: string - userOrdinal?: number | null -} - -export function usePromptActions({ - activeSessionId, - activeSessionIdRef, - busyRef, - branchCurrentSession, - createBackendSessionForSend, - handleSkinCommand, - refreshSessions, - requestGateway, - resumeStoredSession, - selectedStoredSessionIdRef, - startFreshSessionDraft, - sttEnabled, - updateSessionState -}: PromptActionsOptions) { - const { t } = useI18n() - const copy = t.desktop - - const appendSessionTextMessage = useCallback( - (sessionId: string, role: ChatMessage['role'], text: string) => { - // Strip ANSI: slash-command output from the backend worker carries SGR - // color codes (e.g. "Unknown command" in red). The ESC byte is invisible - // in the chat panel, so without this the `[1;31m…[0m` payload leaks as - // literal text. - const body = stripAnsi(text).trim() - - if (!body) { - return - } - - updateSessionState( - sessionId, - state => ({ - ...state, - messages: [ - ...state.messages, - { - id: `${role}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, - role, - parts: [textPart(body)] - } - ] - }), - selectedStoredSessionIdRef.current - ) - }, - [selectedStoredSessionIdRef, updateSessionState] - ) - - // In-flight drop-time eager uploads, keyed by attachment id. Submit joins - // these before re-uploading so a drop-then-immediately-Enter can't fire - // file.attach twice and stage duplicate copies on the gateway. - const eagerUploadInFlight = useRef>>(new Map()) - - const syncAttachmentsForSubmit = useCallback( - async ( - sessionId: string, - attachments: ComposerAttachment[], - options: { updateComposerAttachments?: boolean } = {} - ): Promise => { - const updateComposerAttachments = options.updateComposerAttachments ?? true - const remote = $connection.get()?.mode === 'remote' - const synced: ComposerAttachment[] = [] - - for (const original of attachments) { - let attachment = original - - // Join a drop-time eager upload still in flight for this attachment - // before deciding anything — otherwise submit and the eager task both - // call file.attach and stage duplicate files. After it settles, take the - // store's updated copy (its gateway ref, or its failure) over the stale - // pre-upload snapshot. - const inFlight = eagerUploadInFlight.current.get(attachment.id) - - if (inFlight) { - await inFlight - attachment = $composerAttachments.get().find(item => item.id === attachment.id) ?? attachment - } - - // Already-synced or pathless refs (terminal, url, etc.) pass through. - // A drop-time eager upload may already have staged this one (matching - // attachedSessionId) — don't re-upload it. - if (!attachment.path || attachment.attachedSessionId === sessionId) { - synced.push(attachment) - - continue - } - - if (attachment.kind === 'image' || attachment.kind === 'file') { - const nextAttachment = await uploadComposerAttachment(attachment, { remote, requestGateway, sessionId }) - - // Update-only: never resurrect a chip the user removed mid-upload. - if (updateComposerAttachments) { - updateComposerAttachment(nextAttachment) - } - - synced.push(nextAttachment) - - continue - } - - synced.push(attachment) - } - - return synced - }, - [requestGateway] - ) - - // Stage a freshly dropped file as soon as it lands (when a session already - // exists), so the upload runs while the user is still typing rather than - // stalling the send. The card shows a spinner via `uploadState`; on success - // the chip carries its gateway-side ref so submit skips re-uploading. - // - // Images are intentionally NOT eager-uploaded: attachImagePath adds the chip - // and then fills in `previewUrl` (the base64 thumbnail) on a second tick, so - // an eager upload would race that write — clobbering the thumbnail and - // swapping `path` to a gateway path the local preview can't read. Images are - // small and still byte-upload at submit via image.attach_bytes. - const eagerlyUploadAttachment = useCallback( - async (sessionId: string, attachment: ComposerAttachment) => { - const remote = $connection.get()?.mode === 'remote' - - setComposerAttachmentUploadState(attachment.id, 'uploading') - - try { - // Update-only: if the user removed the chip while this was uploading, - // don't resurrect it — just drop the staged result on the floor. - updateComposerAttachment(await uploadComposerAttachment(attachment, { remote, requestGateway, sessionId })) - } catch (err) { - // Leave the chip in place so submit-time sync can retry (or the user can - // remove it) and flag the card; also toast so a hard failure (unreadable - // file, gateway perms) isn't swallowed while the user keeps typing. - setComposerAttachmentUploadState(attachment.id, 'error') - notifyError(err, copy.dropFiles) - } - }, - [copy.dropFiles, requestGateway] - ) - - const composerAttachments = useStore($composerAttachments) - - useEffect(() => { - if (!activeSessionId) { - return - } - - for (const attachment of composerAttachments) { - const needsUpload = - attachment.kind === 'file' && - Boolean(attachment.path) && - !attachment.attachedSessionId && - !attachment.uploadState && - !eagerUploadInFlight.current.has(attachment.id) - - if (!needsUpload) { - continue - } - - const task = eagerlyUploadAttachment(activeSessionId, attachment).finally(() => - eagerUploadInFlight.current.delete(attachment.id) - ) - - eagerUploadInFlight.current.set(attachment.id, task) - } - }, [activeSessionId, composerAttachments, eagerlyUploadAttachment]) - - const submitPromptText = useCallback( - async (rawText: string, options?: SubmitTextOptions) => { - const visibleText = rawText.trim() - const usingComposerAttachments = !options?.attachments - - // Drop undefined/null holes a session switch or draft restore can leave in - // the attachments array (same bug class as AttachmentList #49624). Without - // this, the sibling iterations below (a.kind / a.label / a.refText, and the - // sync step) throw "Cannot read properties of undefined (reading 'refText')" - // and break the chat surface. - const attachments = (options?.attachments ?? $composerAttachments.get()).filter((a): a is ComposerAttachment => - Boolean(a) - ) - - const terminalContextBlocks = terminalContextBlocksFromDraft(rawText).join('\n\n') - const hasImage = attachments.some(a => a.kind === 'image') - - // Refs are recomputed after sync (file.attach rewrites @file: refs to - // workspace-relative paths the remote gateway can resolve). Seed the - // optimistic message with the pre-sync refs, then rewrite once synced. - // 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 => { - // atts may be the post-sync array, which can reintroduce holes; filter - // before touching a.refText / a.kind. - const present = atts.filter((a): a is ComposerAttachment => Boolean(a)) - - const contextRefs = present - .map(a => a.refText) - .filter(Boolean) - .join('\n') - - return ( - [contextRefs, terminalContextBlocks, visibleText].filter(Boolean).join('\n\n') || - (present.some(a => a.kind === 'image') ? 'What do you see in this image?' : '') - ) - } - - // Queue drains fire on the busy→false settle edge, where busyRef (synced - // from $busy by a separate effect) may still read true — honoring it would - // 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 - } - - // One submit in flight per session — drop any concurrent re-fire so a - // stalled turn can't stack the same prompt into multiple real turns. - const submitLockKey = selectedStoredSessionIdRef.current || activeSessionId || '__pending_new__' - - if (_submitInFlight.has(submitLockKey)) { - return false - } - - _submitInFlight.add(submitLockKey) - let submitLockReleased = false - - const releaseSubmitLock = () => { - if (!submitLockReleased) { - submitLockReleased = true - _submitInFlight.delete(submitLockKey) - } - } - - const optimisticId = `user-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` - - const buildUserMessage = (): ChatMessage => ({ - id: optimisticId, - role: 'user', - parts: [textPart(visibleText || (attachmentRefs.length ? '' : attachments.map(a => a.label).join(', ')))], - attachmentRefs - }) - - const releaseBusy = () => { - releaseSubmitLock() - setMutableRef(busyRef, false) - setBusy(false) - setAwaitingResponse(false) - } - - // Idempotent optimistic insert — re-running with the resolved sessionId - // after createBackendSessionForSend just overwrites with the same id. - const seedOptimistic = (sid: string) => - updateSessionState( - sid, - state => ({ - ...state, - messages: state.messages.some(m => m.id === optimisticId) - ? state.messages - : [...state.messages, buildUserMessage()], - busy: true, - awaitingResponse: true, - pendingBranchGroup: null, - sawAssistantPayload: false, - // Fresh submit = new turn — clear any leftover interrupt flag, else - // mutateStream/completeAssistantMessage drop every delta of this turn - // (what made drained-after-interrupt sends go silent). - interrupted: false - }), - selectedStoredSessionIdRef.current - ) - - // After sync rewrites refs, refresh the optimistic message in place so the - // transcript shows the resolved @file: ref rather than the local path. - const rewriteOptimistic = (sid: string) => - updateSessionState( - sid, - state => ({ - ...state, - messages: state.messages.map(message => (message.id === optimisticId ? buildUserMessage() : message)) - }), - selectedStoredSessionIdRef.current - ) - - const dropOptimistic = (sid: null | string) => { - if (!sid) { - setMessages(current => current.filter(m => m.id !== optimisticId)) - - return - } - - updateSessionState( - sid, - state => ({ - ...state, - messages: state.messages.filter(m => m.id !== optimisticId), - busy: false, - awaitingResponse: false, - pendingBranchGroup: null - }), - selectedStoredSessionIdRef.current - ) - } - - setMutableRef(busyRef, true) - setBusy(true) - setAwaitingResponse(true) - clearNotifications() - - let sessionId: null | string = activeSessionId - - if (sessionId) { - seedOptimistic(sessionId) - } else { - setMessages(current => [...current, buildUserMessage()]) - } - - if (!sessionId) { - try { - sessionId = await createBackendSessionForSend(visibleText) - } catch (err) { - dropOptimistic(null) - releaseBusy() - notifyError(err, copy.sessionUnavailable) - - return false - } - - if (!sessionId) { - dropOptimistic(null) - releaseBusy() - notify({ kind: 'error', title: copy.sessionUnavailable, message: copy.createSessionFailed }) - - return false - } - - seedOptimistic(sessionId) - } - - try { - 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.) - attachmentRefs = syncedAttachments.map(optimisticAttachmentRef).filter((r): r is string => Boolean(r)) - rewriteOptimistic(sessionId) - const text = buildContextText(syncedAttachments) - - // On sleep/wake the gateway's in-memory session may have been cleared - // while the desktop app still holds the old session ID. Detect this, - // resume the stored session to re-register it, and retry once. - let submitErr: unknown = null - - try { - 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 withSessionBusyRetry(() => requestGateway('prompt.submit', { session_id: recoveredId, text })) - } else { - submitErr = firstErr - } - } else { - submitErr = firstErr - } - } - - if (submitErr !== null) { - throw submitErr - } - - if (usingComposerAttachments) { - clearComposerAttachments() - } - - // Submit landed — the turn now runs (busy stays true), but the submit - // window is closed, so release the lock for the next (sequential) send. - releaseSubmitLock() - - 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) - - updateSessionState(sessionId, state => ({ - ...state, - messages: [ - ...state.messages, - { - id: `assistant-error-${Date.now()}`, - role: 'assistant', - parts: [], - error: message || copy.promptFailed, - branchGroupId: state.pendingBranchGroup ?? undefined - } - ], - busy: false, - awaitingResponse: false, - pendingBranchGroup: null, - sawAssistantPayload: true - })) - - if (isProviderSetupError(err)) { - requestDesktopOnboarding(copy.providerCredentialRequired) - - return false - } - - notifyError(err, copy.promptFailed) - - return false - } - }, - [ - activeSessionId, - activeSessionIdRef, - busyRef, - copy, - createBackendSessionForSend, - requestGateway, - selectedStoredSessionIdRef, - syncAttachmentsForSubmit, - updateSessionState - ] - ) - - // Queue a handoff of this session to a messaging platform and watch it to - // a terminal state. We only write the request through the gateway; the - // separate `hermes gateway` process performs the actual transfer, so we - // poll `handoff.state` (mirror of the CLI's block-poll) for the result. - const handoffSession = useCallback( - async ( - platform: string, - options?: { onProgress?: (state: string) => void; sessionId?: string } - ): Promise => { - const sid = options?.sessionId || activeSessionIdRef.current - - if (!sid) { - return { error: copy.sessionUnavailable, ok: false } - } - - const target = platform.trim().toLowerCase() - - if (!target) { - return { error: copy.handoff.failed(''), ok: false } - } - - try { - options?.onProgress?.('pending') - await requestGateway('handoff.request', { - platform: target, - session_id: sid - }) - } catch (err) { - return { error: inlineErrorMessage(err, copy.handoff.failed(target)), ok: false } - } - - const deadline = Date.now() + 60_000 - let lastState = 'pending' - - while (Date.now() < deadline) { - await delay(800) - - let record: HandoffStateResponse - - try { - record = await requestGateway('handoff.state', { session_id: sid }) - } catch { - continue - } - - const state = record.state || 'pending' - - if (state !== lastState) { - options?.onProgress?.(state) - lastState = state - } - - if (state === 'completed') { - appendSessionTextMessage(sid, 'system', copy.handoff.systemNote(target)) - notify({ kind: 'success', message: copy.handoff.success(target) }) - - return { ok: true } - } - - if (state === 'failed') { - return { error: record.error || copy.handoff.failed(target), ok: false } - } - } - - const cleanup = await requestGateway('handoff.fail', { - error: copy.handoff.timedOut, - session_id: sid - }).catch(() => null) - - if (cleanup?.state === 'completed') { - appendSessionTextMessage(sid, 'system', copy.handoff.systemNote(target)) - notify({ kind: 'success', message: copy.handoff.success(target) }) - - return { ok: true } - } - - return { error: copy.handoff.timedOut, ok: false } - }, - [activeSessionIdRef, appendSessionTextMessage, copy, requestGateway] - ) - - const executeSlashCommand = useCallback( - async (rawCommand: string, options?: { sessionId?: string; recordInput?: boolean }) => { - const ensureSessionId = async (sessionHint?: string) => - sessionHint || activeSessionIdRef.current || (await createBackendSessionForSend()) - - // Resolve the target session plus a writer for inline slash output, or - // notify + return null when none can be created. Folds the ensure / bail / - // build-renderSlashOutput boilerplate every exec-style handler repeats. - const withSlashOutput = async ( - ctx: SlashActionCtx - ): Promise<{ render: (text: string) => void; sessionId: string } | null> => { - const sessionId = await ensureSessionId(ctx.sessionHint) - - if (!sessionId) { - notify({ kind: 'error', title: copy.sessionUnavailable, message: copy.createSessionFailed }) - - return null - } - - const render = (text: string) => - appendSessionTextMessage(sessionId, 'system', ctx.recordInput ? slashStatusText(ctx.command, text) : text) - - return { render, sessionId } - } - - // `exec` commands (and unknown skill / quick commands the backend owns) - // run on the gateway and render their text output inline. This is the only - // path that talks to slash.exec / command.dispatch. - async function runExec(ctx: SlashActionCtx): Promise { - const { arg, command, name } = ctx - const resolved = await withSlashOutput(ctx) - - if (!resolved) { - return - } - - const { render: renderSlashOutput, sessionId } = resolved - - if (!isDesktopSlashCommand(name)) { - renderSlashOutput(desktopSlashUnavailableMessage(name) || `/${name} is not available in the desktop app.`) - - return - } - - const handleDispatch = async ( - dispatch: NonNullable> - ): Promise => { - if (dispatch.type === 'exec' || dispatch.type === 'plugin') { - renderSlashOutput(dispatch.output ?? '(no output)') - - return - } - - if (dispatch.type === 'alias') { - await runSlash(`/${dispatch.target}${arg ? ` ${arg}` : ''}`, sessionId, false) - - return - } - - // send / prefill carry an optional `notice` (e.g. "⊙ Goal set …") - // that the backend wants shown as a system line before the message - // is acted on. Mirrors the TUI's createSlashHandler — without it a - // `/goal ` looked like it did nothing. - if ((dispatch.type === 'send' || dispatch.type === 'prefill') && dispatch.notice?.trim()) { - renderSlashOutput(dispatch.notice.trim()) - } - - const message = ('message' in dispatch ? dispatch.message : '')?.trim() ?? '' - - // /undo returns a prefill directive: drop the backed-up message into - // the composer for editing instead of submitting it immediately. - if (dispatch.type === 'prefill') { - if (message) { - setComposerDraft(message) - } - - return - } - - if (!message) { - renderSlashOutput( - `/${name}: ${dispatch.type === 'skill' ? 'skill payload missing message' : 'empty message'}` - ) - - return - } - - if (dispatch.type === 'skill') { - renderSlashOutput(`⚡ loading skill: ${dispatch.name}`) - } - - if (busyRef.current) { - renderSlashOutput('session busy — /interrupt the current turn before sending this command') - - return - } - - await submitPromptText(message) - } - - try { - const result = await requestGateway('slash.exec', { - session_id: sessionId, - command: command.replace(/^\/+/, '') - }) - - const dispatch = parseCommandDispatch(result) - - if (dispatch) { - await handleDispatch(dispatch) - - return - } - - const output = result && typeof result === 'object' ? (result as SlashExecResponse) : null - const body = output?.output || `/${name}: no output` - renderSlashOutput(output?.warning ? `warning: ${output.warning}\n${body}` : body) - - return - } catch { - // Fall back to command.dispatch for skill/send/alias directives. - } - - try { - const dispatch = parseCommandDispatch( - await requestGateway('command.dispatch', { session_id: sessionId, name, arg }) - ) - - if (!dispatch) { - renderSlashOutput('error: invalid response: command.dispatch') - - return - } - - await handleDispatch(dispatch) - } catch (err) { - renderSlashOutput(`error: ${err instanceof Error ? err.message : String(err)}`) - } - } - - // One handler per `action` command. Adding a desktop-native command is a - // registry row in desktop-slash-commands.ts plus an entry here — never a - // new branch in a dispatch ladder. - const actionHandlers: Record Promise> = { - new: async () => { - startFreshSessionDraft() - }, - branch: async () => { - await branchCurrentSession() - }, - // /yolo maps to the status-bar YOLO control — a per-session approval - // bypass, same scope as the TUI's Shift+Tab. With no session yet we arm - // it locally; the session-create path applies it on the first message. - yolo: async ({ sessionHint }) => { - const sid = sessionHint || activeSessionIdRef.current - const next = !$yoloActive.get() - - if (!sid) { - setYoloActive(next) - notify({ kind: 'success', message: next ? copy.yoloArmed : copy.yoloOff }) - - return - } - - try { - const active = await setSessionYolo(requestGateway, sid, next) - appendSessionTextMessage(sid, 'system', copy.yoloSystem(active)) - } catch { - notify({ kind: 'error', title: copy.yoloTitle, message: copy.yoloToggleFailed }) - } - }, - // /handoff hands this session to a messaging platform. The platform is - // completed inline in the slash popover (backend _handoff_completions), - // so there is no overlay: `/handoff ` runs the desktop's own - // handoff RPC. cli_only on the backend, so it must not reach slash.exec. - handoff: async ({ arg, command, recordInput, sessionHint }) => { - const platform = arg.trim() - - if (!platform) { - notify({ kind: 'success', message: copy.handoff.pickPlatform }) - - return - } - - const sid = sessionHint || activeSessionIdRef.current - - if (!sid) { - notify({ kind: 'error', title: copy.sessionUnavailable, message: copy.createSessionFailed }) - - return - } - - const result = await handoffSession(platform, { sessionId: sid }) - - if (!result.ok && result.error) { - appendSessionTextMessage(sid, 'system', recordInput ? slashStatusText(command, result.error) : result.error) - } - }, - // /profile selects which profile new chats open in — no app relaunch. - // A profile is per-session now, so an existing thread can't change its - // profile mid-stream; `/profile ` points the next new chat (and - // the current empty draft) at that profile's backend. - profile: async ({ arg }) => { - const target = arg.trim() - const current = normalizeProfileKey($activeGatewayProfile.get()) - - if (!target) { - notify({ kind: 'success', message: copy.profileStatus(current) }) - - return - } - - try { - const { profiles } = await getProfiles() - const match = profiles.find(profile => profile.name === target) - - if (!match) { - notify({ - kind: 'error', - title: copy.unknownProfile, - message: copy.noProfileNamed(target, profiles.map(profile => profile.name).join(', ')) - }) - - return - } - - const key = normalizeProfileKey(match.name) - - $newChatProfile.set(key) - await ensureGatewayProfile(key) - notify({ kind: 'success', message: copy.newChatsProfile(match.name) }) - } catch (err) { - notifyError(err, copy.setProfileFailed) - } - }, - skin: async ({ arg, command, recordInput, sessionHint }) => { - const sid = sessionHint || activeSessionIdRef.current - const message = handleSkinCommand(arg) - - // No session to print into yet — surface it as a toast instead of - // spinning up a backend session just to change the theme. - if (!sid) { - notify({ kind: 'success', message }) - - return - } - - appendSessionTextMessage(sid, 'system', recordInput ? slashStatusText(command, message) : message) - }, - // /title renames via the gateway's session.title RPC — the same - // path the TUI uses, NOT REST renameSession (which 404s on runtime ids) - // nor the slash worker (whose DB write can silently fail). Bare /title - // shows the current title, which the worker owns, so delegate to exec. - title: async ctx => { - if (!ctx.arg) { - await runExec(ctx) - - return - } - - const resolved = await withSlashOutput(ctx) - - if (!resolved) { - return - } - - const { render: renderSlashOutput, sessionId } = resolved - const { arg } = ctx - - try { - const result = await requestGateway('session.title', { - session_id: sessionId, - title: arg - }) - - const finalTitle = (result?.title || arg).trim() - const queued = result?.pending === true - - setSessions(prev => prev.map(s => (s.id === sessionId ? { ...s, title: finalTitle || null } : s))) - await refreshSessions().catch(() => undefined) - renderSlashOutput( - finalTitle - ? `Session title set: ${finalTitle}${queued ? ' (queued while session initializes)' : ''}` - : 'Session title cleared.' - ) - } catch (err) { - renderSlashOutput(`error: ${err instanceof Error ? err.message : String(err)}`) - } - }, - help: async ctx => { - const resolved = await withSlashOutput(ctx) - - if (!resolved) { - return - } - - const { render: renderSlashOutput, sessionId } = resolved - - try { - const catalog = await requestGateway('commands.catalog', { session_id: sessionId }) - - renderSlashOutput(renderCommandsCatalog(catalog, copy)) - } catch (err) { - renderSlashOutput(`error: ${err instanceof Error ? err.message : String(err)}`) - } - }, - // /hatch opens the pet generator overlay (the desktop's rich, multi-step - // generate→pick→hatch→adopt flow). A typed description seeds the prompt - // so `/hatch a cyber fox` lands on the composer step prefilled. - hatch: async ({ arg }) => { - const concept = arg.trim() - - if (concept) { - $petGenInput.set(concept) - } - - openPetGenerate() - }, - pet: async ctx => { - const [sub = '', rawValue = ''] = ctx.arg.trim().split(/\s+/) - const lower = sub.toLowerCase() - - if (lower === 'list' || lower === 'gallery' || lower === 'browse' || lower === 'all') { - openCommandPalettePage('pets') - - return - } - - // `/pet scale ` resizes the floating pet locally (instant) and - // persists via the store — no round-trip to the slash worker. - if (lower === 'scale') { - const value = Number(rawValue) - - if (!rawValue || Number.isNaN(value)) { - const resolved = await withSlashOutput(ctx) - resolved?.render('usage: /pet scale (e.g. /pet scale 0.5)') - - return - } - - setPetScale(requestGateway, value) - - return - } - - await runExec(ctx) - }, - // /browser connect|disconnect|status manages the live CDP connection on - // the gateway host, mirroring the TUI's browser.manage RPC. It mutates - // BROWSER_CDP_URL (and may launch Chrome) in the gateway process — only - // meaningful when that process runs on this machine, so it's gated to - // local connections. A remote gateway would act on the wrong host. - browser: async ctx => { - const resolved = await withSlashOutput(ctx) - - if (!resolved) { - return - } - - const { render: renderSlashOutput, sessionId } = resolved - - if ($connection.get()?.mode === 'remote') { - renderSlashOutput( - '/browser manages a Chromium-family browser on the gateway host — only available when connected to a local gateway.' - ) - - return - } - - const [rawAction = 'status', ...rest] = ctx.arg.trim().split(/\s+/).filter(Boolean) - const cmdAction = rawAction.toLowerCase() - - if (!['connect', 'disconnect', 'status'].includes(cmdAction)) { - renderSlashOutput( - 'usage: /browser [connect|disconnect|status] [url] · persistent: set browser.cdp_url in config.yaml' - ) - - return - } - - const url = cmdAction === 'connect' ? rest.join(' ').trim() || 'http://127.0.0.1:9222' : undefined - - if (url) { - renderSlashOutput(`checking Chromium-family browser remote debugging at ${url}...`) - } - - try { - const result = await requestGateway('browser.manage', { - action: cmdAction, - session_id: sessionId, - ...(url && { url }) - }) - - // Without a streamed session subscription, the gateway bundles its - // progress lines into `messages` — flush them inline. - result?.messages?.forEach(message => renderSlashOutput(message)) - - if (cmdAction === 'status') { - renderSlashOutput( - result?.connected - ? `browser connected: ${result.url || '(url unavailable)'}` - : 'browser not connected (try /browser connect or set browser.cdp_url in config.yaml)' - ) - - return - } - - if (cmdAction === 'disconnect') { - renderSlashOutput('browser disconnected') - - return - } - - if (result?.connected) { - renderSlashOutput('Browser connected to live Chromium-family browser via CDP') - renderSlashOutput(`Endpoint: ${result.url || '(url unavailable)'}`) - renderSlashOutput('next browser tool call will use this CDP endpoint') - } - } catch (err) { - renderSlashOutput(`error: ${err instanceof Error ? err.message : String(err)}`) - } - } - } - - // Picker commands open a desktop overlay; a typed arg is resolved by that - // picker so the command never dead-ends or falls through to the backend. - const openPicker = async (pickerId: DesktopPickerId, ctx: SlashActionCtx): Promise => { - if (pickerId === 'model') { - if (!ctx.arg.trim()) { - setModelPickerOpen(true) - - return - } - - // Power users can still type `/model ` — run it on the backend. - await runExec(ctx) - - return - } - - // session picker — /resume, /sessions, /switch - const query = ctx.arg.trim() - - if (!query) { - setSessionPickerOpen(true) - - return - } - - const sessions = $sessions.get() - const lower = query.toLowerCase() - - const match = - sessions.find(session => session.id === query) || - sessions.find(session => sessionTitle(session).toLowerCase().includes(lower)) || - sessions.find(session => (session.preview ?? '').toLowerCase().includes(lower)) - - if (!match) { - if (isSessionIdCandidate(query)) { - await resumeStoredSession(query) - - return - } - - notify({ kind: 'error', message: copy.resumeFailed }) - - return - } - - await resumeStoredSession(match.id) - } - - // The whole dispatcher: resolve the command's desktop surface, then act on - // its kind. No per-command ladder — behavior lives in the registry. - async function runSlash(commandText: string, sessionHint?: string, recordInput = true): Promise { - const command = commandText.trim() - const { name, arg } = parseSlashCommand(command) - - if (!name) { - const sessionId = await ensureSessionId(sessionHint) - - if (sessionId) { - appendSessionTextMessage(sessionId, 'system', copy.emptySlashCommand) - } - - return - } - - const ctx: SlashActionCtx = { arg, command, name, recordInput, sessionHint } - const surface = resolveDesktopCommand(`/${name}`)?.surface - - switch (surface?.kind) { - case 'unavailable': { - const resolved = await withSlashOutput(ctx) - resolved?.render(desktopSlashUnavailableMessage(name) || `/${name} is not available in the desktop app.`) - - return - } - - case 'picker': - return openPicker(surface.picker, ctx) - - case 'action': - return actionHandlers[surface.action](ctx) - - default: - // exec spec, or an unknown skill / quick command the backend owns. - return runExec(ctx) - } - } - - await runSlash(rawCommand, options?.sessionId, options?.recordInput ?? true) - }, - [ - activeSessionIdRef, - appendSessionTextMessage, - branchCurrentSession, - busyRef, - copy, - createBackendSessionForSend, - handleSkinCommand, - handoffSession, - refreshSessions, - requestGateway, - resumeStoredSession, - startFreshSessionDraft, - submitPromptText - ] - ) - - const submitText = useCallback( - async (rawText: string, options?: SubmitTextOptions) => { - const visibleText = rawText.trim() - const attachments = options?.attachments ?? $composerAttachments.get() - - if (!attachments.length && SLASH_COMMAND_RE.test(visibleText)) { - triggerHaptic('selection') - await executeSlashCommand(visibleText) - - return true - } - - return await submitPromptText(rawText, options) - }, - [executeSlashCommand, submitPromptText] - ) - - const transcribeVoiceAudio = useCallback( - async (audio: Blob) => { - if (!sttEnabled) { - throw new Error(copy.sttDisabled) - } - - const dataUrl = await blobToDataUrl(audio) - const result = await transcribeAudio(dataUrl, audio.type) - - return result.transcript - }, - [copy.sttDisabled, sttEnabled] - ) - - const cancelRun = useCallback(async () => { - const sessionId = activeSessionId || activeSessionIdRef.current - - const releaseBusy = () => { - setMutableRef(busyRef, false) - setBusy(false) - } - - setAwaitingResponse(false) - - const finalizeMessages = (messages: ChatMessage[], streamId?: string | null) => - messages - .filter(message => !((message.pending || message.id === streamId) && !chatMessageText(message).trim())) - .map(message => (message.pending || message.id === streamId ? { ...message, pending: false } : message)) - - if (!sessionId) { - releaseBusy() - setMessages(finalizeMessages($messages.get())) - - return - } - - updateSessionState(sessionId, state => { - const streamId = state.streamId - const messages = finalizeMessages(state.messages, streamId) - - return { - ...state, - messages, - busy: false, - awaitingResponse: false, - streamId: null, - pendingBranchGroup: null, - needsInput: false, - interrupted: true - } - }) - - clearSessionTodos(sessionId) - clearSessionSubagents(sessionId) - resetSessionBackground(sessionId) - // Stop ends the turn, so the gateway is no longer blocked on any prompt it - // raised. Drop this session's pending clarify / approval / sudo / secret so - // a dead panel (and the sidebar "needs input" dot) can't linger and accept - // an answer the backend will reject. - clearAllPrompts(sessionId) - clearClarifyRequest(undefined, sessionId) - - try { - await requestGateway('session.interrupt', { session_id: sessionId }) - releaseBusy() - } catch (err) { - let stopError = err - - if (isSessionNotFoundError(err) && selectedStoredSessionIdRef.current) { - try { - 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 - } - } catch (resumeErr) { - stopError = resumeErr - } - } - - releaseBusy() - notifyError(stopError, copy.stopFailed) - } - }, [ - activeSessionId, - activeSessionIdRef, - busyRef, - copy.stopFailed, - requestGateway, - selectedStoredSessionIdRef, - updateSessionState - ]) - - // Steer = nudge the live turn without interrupting: the gateway appends the - // text to the next tool result so the model reads it on its next iteration - // (desktop parity with `/steer`). Returns false on reject (no live tool - // window) so the caller can fall back to queueing the words for the next turn. - const steerPrompt = useCallback( - async (rawText: string): Promise => { - const text = rawText.trim() - const sessionId = activeSessionId || activeSessionIdRef.current - - if (!text || !sessionId) { - return false - } - - try { - const result = await requestGateway('session.steer', { session_id: sessionId, text }) - - if (result?.status === 'queued') { - triggerHaptic('submit') - // Inline note (not a toast) so the nudge lives in the transcript next - // to the turn it steered. The `steer:` prefix is rendered as a codicon - // row by SystemMessage (see STEER_NOTE_RE), same style as slash output. - appendSessionTextMessage(sessionId, 'system', `steer:${text}`) - - return true - } - } catch { - // Swallow — caller queues the text so nothing is lost. - } - - return false - }, - [activeSessionId, activeSessionIdRef, appendSessionTextMessage, requestGateway] - ) - - const reloadFromMessage = useCallback( - async (parentId: string | null) => { - if (!activeSessionId || $busy.get()) { - return - } - - const messages = $messages.get() - const parentIndex = parentId ? messages.findIndex(message => message.id === parentId) : messages.length - 1 - - const userIndex = - parentIndex >= 0 - ? [...messages.slice(0, parentIndex + 1)].reverse().findIndex(message => message.role === 'user') - : -1 - - if (userIndex < 0) { - return - } - - const absoluteUserIndex = parentIndex - userIndex - const userMessage = messages[absoluteUserIndex] - const userText = userMessage ? chatMessageText(userMessage).trim() : '' - - if (!userText) { - return - } - - const targetAssistant = - parentId && messages[parentIndex]?.role === 'assistant' - ? messages[parentIndex] - : messages.slice(absoluteUserIndex + 1).find(message => message.role === 'assistant') - - const branchGroupId = targetAssistant?.branchGroupId ?? branchGroupForUser(userMessage) - const truncateBeforeUserOrdinal = visibleUserOrdinal(messages, absoluteUserIndex) - - clearNotifications() - updateSessionState(activeSessionId, state => { - const nextUserIndex = state.messages.findIndex( - (message, index) => index > absoluteUserIndex && message.role === 'user' - ) - - const end = nextUserIndex < 0 ? state.messages.length : nextUserIndex - - return { - ...state, - busy: true, - awaitingResponse: true, - pendingBranchGroup: branchGroupId, - sawAssistantPayload: false, - interrupted: false, - messages: [ - ...state.messages.slice(0, absoluteUserIndex + 1), - ...state.messages - .slice(absoluteUserIndex + 1, end) - .map(message => (message.role === 'assistant' ? { ...message, branchGroupId, hidden: true } : message)) - ] - } - }) - - try { - await requestGateway('prompt.submit', { - session_id: activeSessionId, - text: userText, - truncate_before_user_ordinal: truncateBeforeUserOrdinal - }) - } catch (err) { - updateSessionState(activeSessionId, state => ({ - ...state, - busy: false, - awaitingResponse: false - })) - notifyError(err, copy.regenerateFailed) - } - }, - [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 callers can surface failures. Idle rewinds submit directly: - // interrupting an idle agent can leave a stale interrupt flag that cancels the - // fresh turn. Live/stuck turns interrupt first, and a raced "session busy" - // response interrupts + retries through the shared busy gate. - const submitRewindPrompt = useCallback( - async (sessionId: string, text: string, truncateOrdinal: number | undefined, interruptFirst: boolean) => { - const interrupt = async () => { - try { - await requestGateway('session.interrupt', { session_id: sessionId }) - } catch { - // Best-effort. The submit path still gates on the gateway state. - } - } - - const submit = () => - requestGateway('prompt.submit', { - session_id: sessionId, - text, - ...(truncateOrdinal !== undefined && { truncate_before_user_ordinal: truncateOrdinal }) - }) - - if (interruptFirst) { - await interrupt() - } - - try { - await submit() - } catch (err) { - if (!isSessionBusyError(err)) { - throw err - } - - await interrupt() - await withSessionBusyRetry(submit) - } - }, - [requestGateway] - ) - - const restoreToMessage = useCallback( - async (messageId: string, target?: RestoreMessageTarget) => { - const sessionId = activeSessionId || activeSessionIdRef.current - - if (!sessionId) { - throw new Error('No active session to restore.') - } - - const messages = $messages.get() - const idIndex = messages.findIndex(m => m.id === messageId && m.role === 'user') - - const fallbackIndex = - target?.userOrdinal === null || target?.userOrdinal === undefined - ? -1 - : visibleUserIndexAtOrdinal(messages, target.userOrdinal) - - const sourceIndex = idIndex >= 0 ? idIndex : fallbackIndex - const source = messages[sourceIndex] - - if (!source || source.role !== 'user') { - throw new Error('Could not find the message to restore.') - } - - const text = (chatMessageText(source).trim() || target?.text?.trim() || '').trim() - - if (!text) { - throw new Error('Cannot restore an empty message.') - } - - const truncateBeforeUserOrdinal = - target?.userOrdinal === null || target?.userOrdinal === undefined - ? visibleUserOrdinal(messages, sourceIndex) - : target.userOrdinal - - // 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) - clearPreviewArtifacts(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, busyRef.current || $busy.get()) - } catch (err) { - // The rewind never landed (e.g. the gateway stayed busy past the retry - // deadline). Roll the optimistic truncation back to the full original - // history so the UI doesn't desync from what's persisted — leaving it - // truncated is what made subsequent sends look duplicative. - setMutableRef(busyRef, false) - setBusy(false) - setAwaitingResponse(false) - updateSessionState(sessionId, state => ({ - ...state, - busy: false, - awaitingResponse: false, - messages - })) - 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') { - return - } - - const messages = $messages.get() - const sourceIndex = messages.findIndex(m => m.id === sourceId) - const source = messages[sourceIndex] - - if (!source || source.role !== 'user' || chatMessageText(source).trim() === text) { - return - } - - // Sending an edit is a revert: rewind to this prompt and re-run with the - // new text. It can fire mid-turn; submitRewindPrompt always interrupts - // first, so a live turn is wound down before the resubmit. - - // 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) - clearPreviewArtifacts(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), editedMessage] - })) - - const isStaleTargetError = (err: unknown) => - /no longer in session history|not in session history/i.test(err instanceof Error ? err.message : String(err)) - - try { - await submitRewindPrompt( - sessionId, - text, - isFailedTurn ? undefined : visibleUserOrdinal(messages, sourceIndex), - busyRef.current || $busy.get() - ) - } catch (err) { - let surfaced = err - - if (!isFailedTurn && isStaleTargetError(err)) { - try { - // Already interrupted on the first attempt — submit as a plain resend. - await submitRewindPrompt(sessionId, text, undefined, false) - - return - } catch (retryErr) { - surfaced = retryErr - } - } - - // Roll the optimistic edit/truncation back to the original history so the - // UI stays in sync with what's persisted instead of stranding a partial - // timeline. - setMutableRef(busyRef, false) - setBusy(false) - setAwaitingResponse(false) - updateSessionState(sessionId, state => ({ ...state, busy: false, awaitingResponse: false, messages })) - notifyError(surfaced, copy.editFailed) - } - }, - [activeSessionId, activeSessionIdRef, busyRef, copy.editFailed, submitRewindPrompt, updateSessionState] - ) - - const handleThreadMessagesChange = useCallback( - (nextMessages: readonly ThreadMessage[]) => { - const visibleIds = new Set(nextMessages.map(m => m.id)) - const sessionId = activeSessionIdRef.current - - if (!sessionId) { - return - } - - updateSessionState(sessionId, state => { - let changed = false - - const messages = state.messages.map(message => { - if (message.role !== 'assistant' || !message.branchGroupId) { - return message - } - - const hidden = !visibleIds.has(message.id) - - if (message.hidden === hidden) { - return message - } - - changed = true - - return { ...message, hidden } - }) - - return changed ? { ...state, messages } : state - }) - }, - [activeSessionIdRef, updateSessionState] - ) - - return { - cancelRun, - editMessage, - handleThreadMessagesChange, - handoffSession, - reloadFromMessage, - restoreToMessage, - steerPrompt, - submitText, - transcribeVoiceAudio - } -} diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions.test.tsx b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx similarity index 99% rename from apps/desktop/src/app/session/hooks/use-prompt-actions.test.tsx rename to apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx index bd971dd52c92..2647f4dcef14 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx @@ -8,7 +8,7 @@ import { $composerAttachments, type ComposerAttachment } from '@/store/composer' import { $busy, $connection, $messages, $sessions, setSessions } from '@/store/session' import type { SessionInfo } from '@/types/hermes' -import { uploadComposerAttachment, usePromptActions } from './use-prompt-actions' +import { uploadComposerAttachment, usePromptActions } from '.' vi.mock('@/hermes', () => ({ getProfiles: vi.fn(async () => ({ profiles: [] })), diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts new file mode 100644 index 000000000000..e6f6b4bcaec1 --- /dev/null +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts @@ -0,0 +1,937 @@ +import type { AppendMessage, ThreadMessage } from '@assistant-ui/react' +import { useStore } from '@nanostores/react' +import { type MutableRefObject, useCallback, useEffect, useRef } from 'react' + +import { transcribeAudio } from '@/hermes' +import { useI18n } from '@/i18n' +import { stripAnsi } from '@/lib/ansi' +import { branchGroupForUser, type ChatMessage, chatMessageText, textPart } from '@/lib/chat-messages' +import { pathLabel, SLASH_COMMAND_RE } from '@/lib/chat-runtime' +import { triggerHaptic } from '@/lib/haptics' +import { setMutableRef } from '@/lib/mutable-ref' +import { clearClarifyRequest } from '@/store/clarify' +import { + $composerAttachments, + type ComposerAttachment, + setComposerAttachmentUploadState, + updateComposerAttachment +} from '@/store/composer' +import { resetSessionBackground } from '@/store/composer-status' +import { clearNotifications, notify, notifyError } from '@/store/notifications' +import { clearPreviewArtifacts } from '@/store/preview-status' +import { clearAllPrompts } from '@/store/prompts' +import { $busy, $connection, $messages, setAwaitingResponse, setBusy, setMessages } from '@/store/session' +import { clearSessionSubagents } from '@/store/subagents' +import { clearSessionTodos } from '@/store/todos' + +import type { + ClientSessionState, + FileAttachResponse, + HandoffFailResponse, + HandoffRequestResponse, + HandoffStateResponse, + ImageAttachResponse, + SessionSteerResponse +} from '../../../types' + +import { useSlashCommand } from './slash' +import { useSubmitPrompt } from './submit' +import { + appendText, + blobToDataUrl, + delay, + friendlyRemoteAttachError, + type GatewayRequest, + inlineErrorMessage, + isSessionBusyError, + isSessionNotFoundError, + readFileDataUrlForAttach, + readImageForRemoteAttach, + type SubmitTextOptions, + visibleUserIndexAtOrdinal, + visibleUserOrdinal, + withSessionBusyRetry +} from './utils' + +interface HandoffResult { + ok: boolean + error?: string +} + +/** + * Stage one file/image attachment into the session workspace and return the + * attachment rewritten with the gateway-side ref. Images upload their bytes in + * remote mode (so vision works) and pass the path locally; non-image files + * upload bytes remotely and pass the path locally. Throws on failure so callers + * can surface an error. Shared by submit-time sync, the eager drop-time upload, + * and the message-edit composer drop — keep them in lockstep. + */ +export async function uploadComposerAttachment( + attachment: ComposerAttachment, + opts: { remote: boolean; requestGateway: GatewayRequest; sessionId: string } +): Promise { + const { remote, requestGateway, sessionId } = opts + const path = attachment.path ?? '' + const label = attachment.label || pathLabel(path) + + if (attachment.kind === 'image') { + let result: ImageAttachResponse + + if (remote) { + let payload: Awaited> + + try { + payload = await readImageForRemoteAttach(path) + } catch (err) { + throw friendlyRemoteAttachError(err, label) + } + + if (!payload) { + throw new Error(`Could not read ${label}`) + } + + result = await requestGateway('image.attach_bytes', { + session_id: sessionId, + content_base64: payload.contentBase64, + filename: payload.filename + }) + } else { + result = await requestGateway('image.attach', { + path, + session_id: sessionId + }) + } + + if (!result.attached) { + throw new Error(result.message || `Could not attach ${label}`) + } + + const attachedPath = result.path || path + + return { + ...attachment, + attachedSessionId: sessionId, + label: attachedPath ? pathLabel(attachedPath) : attachment.label, + path: attachedPath, + uploadState: undefined + } + } + + // Non-image file. + let dataUrl: string | null = null + + if (remote) { + try { + dataUrl = await readFileDataUrlForAttach(path) + } catch (err) { + throw friendlyRemoteAttachError(err, label) + } + + if (!dataUrl) { + throw new Error(`Could not read ${label}`) + } + } + + const result = await requestGateway('file.attach', { + name: label, + path, + session_id: sessionId, + ...(dataUrl ? { data_url: dataUrl } : {}) + }) + + if (!result.attached || !result.ref_text) { + throw new Error(result.message || `Could not attach ${label}`) + } + + return { + ...attachment, + attachedSessionId: sessionId, + refText: result.ref_text, + uploadState: undefined + } +} + +interface PromptActionsOptions { + activeSessionId: string | null + activeSessionIdRef: MutableRefObject + busyRef: MutableRefObject + branchCurrentSession: () => Promise + createBackendSessionForSend: (preview?: string | null) => Promise + handleSkinCommand: (arg: string) => string + refreshSessions: () => Promise + requestGateway: (method: string, params?: Record) => Promise + resumeStoredSession: (storedSessionId: string) => Promise | void + selectedStoredSessionIdRef: MutableRefObject + startFreshSessionDraft: () => void + sttEnabled: boolean + updateSessionState: ( + sessionId: string, + updater: (state: ClientSessionState) => ClientSessionState, + storedSessionId?: string | null + ) => ClientSessionState +} + +/** Everything a slash handler needs about the invocation it's serving. */ + +interface RestoreMessageTarget { + text?: string + userOrdinal?: number | null +} + +export function usePromptActions({ + activeSessionId, + activeSessionIdRef, + busyRef, + branchCurrentSession, + createBackendSessionForSend, + handleSkinCommand, + refreshSessions, + requestGateway, + resumeStoredSession, + selectedStoredSessionIdRef, + startFreshSessionDraft, + sttEnabled, + updateSessionState +}: PromptActionsOptions) { + const { t } = useI18n() + const copy = t.desktop + + const appendSessionTextMessage = useCallback( + (sessionId: string, role: ChatMessage['role'], text: string) => { + // Strip ANSI: slash-command output from the backend worker carries SGR + // color codes (e.g. "Unknown command" in red). The ESC byte is invisible + // in the chat panel, so without this the `[1;31m…[0m` payload leaks as + // literal text. + const body = stripAnsi(text).trim() + + if (!body) { + return + } + + updateSessionState( + sessionId, + state => ({ + ...state, + messages: [ + ...state.messages, + { + id: `${role}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + role, + parts: [textPart(body)] + } + ] + }), + selectedStoredSessionIdRef.current + ) + }, + [selectedStoredSessionIdRef, updateSessionState] + ) + + // In-flight drop-time eager uploads, keyed by attachment id. Submit joins + // these before re-uploading so a drop-then-immediately-Enter can't fire + // file.attach twice and stage duplicate copies on the gateway. + const eagerUploadInFlight = useRef>>(new Map()) + + const syncAttachmentsForSubmit = useCallback( + async ( + sessionId: string, + attachments: ComposerAttachment[], + options: { updateComposerAttachments?: boolean } = {} + ): Promise => { + const updateComposerAttachments = options.updateComposerAttachments ?? true + const remote = $connection.get()?.mode === 'remote' + const synced: ComposerAttachment[] = [] + + for (const original of attachments) { + let attachment = original + + // Join a drop-time eager upload still in flight for this attachment + // before deciding anything — otherwise submit and the eager task both + // call file.attach and stage duplicate files. After it settles, take the + // store's updated copy (its gateway ref, or its failure) over the stale + // pre-upload snapshot. + const inFlight = eagerUploadInFlight.current.get(attachment.id) + + if (inFlight) { + await inFlight + attachment = $composerAttachments.get().find(item => item.id === attachment.id) ?? attachment + } + + // Already-synced or pathless refs (terminal, url, etc.) pass through. + // A drop-time eager upload may already have staged this one (matching + // attachedSessionId) — don't re-upload it. + if (!attachment.path || attachment.attachedSessionId === sessionId) { + synced.push(attachment) + + continue + } + + if (attachment.kind === 'image' || attachment.kind === 'file') { + const nextAttachment = await uploadComposerAttachment(attachment, { remote, requestGateway, sessionId }) + + // Update-only: never resurrect a chip the user removed mid-upload. + if (updateComposerAttachments) { + updateComposerAttachment(nextAttachment) + } + + synced.push(nextAttachment) + + continue + } + + synced.push(attachment) + } + + return synced + }, + [requestGateway] + ) + + // Stage a freshly dropped file as soon as it lands (when a session already + // exists), so the upload runs while the user is still typing rather than + // stalling the send. The card shows a spinner via `uploadState`; on success + // the chip carries its gateway-side ref so submit skips re-uploading. + // + // Images are intentionally NOT eager-uploaded: attachImagePath adds the chip + // and then fills in `previewUrl` (the base64 thumbnail) on a second tick, so + // an eager upload would race that write — clobbering the thumbnail and + // swapping `path` to a gateway path the local preview can't read. Images are + // small and still byte-upload at submit via image.attach_bytes. + const eagerlyUploadAttachment = useCallback( + async (sessionId: string, attachment: ComposerAttachment) => { + const remote = $connection.get()?.mode === 'remote' + + setComposerAttachmentUploadState(attachment.id, 'uploading') + + try { + // Update-only: if the user removed the chip while this was uploading, + // don't resurrect it — just drop the staged result on the floor. + updateComposerAttachment(await uploadComposerAttachment(attachment, { remote, requestGateway, sessionId })) + } catch (err) { + // Leave the chip in place so submit-time sync can retry (or the user can + // remove it) and flag the card; also toast so a hard failure (unreadable + // file, gateway perms) isn't swallowed while the user keeps typing. + setComposerAttachmentUploadState(attachment.id, 'error') + notifyError(err, copy.dropFiles) + } + }, + [copy.dropFiles, requestGateway] + ) + + const composerAttachments = useStore($composerAttachments) + + useEffect(() => { + if (!activeSessionId) { + return + } + + for (const attachment of composerAttachments) { + const needsUpload = + attachment.kind === 'file' && + Boolean(attachment.path) && + !attachment.attachedSessionId && + !attachment.uploadState && + !eagerUploadInFlight.current.has(attachment.id) + + if (!needsUpload) { + continue + } + + const task = eagerlyUploadAttachment(activeSessionId, attachment).finally(() => + eagerUploadInFlight.current.delete(attachment.id) + ) + + eagerUploadInFlight.current.set(attachment.id, task) + } + }, [activeSessionId, composerAttachments, eagerlyUploadAttachment]) + + const submitPromptText = useSubmitPrompt({ + activeSessionId, + activeSessionIdRef, + busyRef, + copy, + createBackendSessionForSend, + requestGateway, + selectedStoredSessionIdRef, + syncAttachmentsForSubmit, + updateSessionState + }) + + // Queue a handoff of this session to a messaging platform and watch it to + // a terminal state. We only write the request through the gateway; the + // separate `hermes gateway` process performs the actual transfer, so we + // poll `handoff.state` (mirror of the CLI's block-poll) for the result. + const handoffSession = useCallback( + async ( + platform: string, + options?: { onProgress?: (state: string) => void; sessionId?: string } + ): Promise => { + const sid = options?.sessionId || activeSessionIdRef.current + + if (!sid) { + return { error: copy.sessionUnavailable, ok: false } + } + + const target = platform.trim().toLowerCase() + + if (!target) { + return { error: copy.handoff.failed(''), ok: false } + } + + try { + options?.onProgress?.('pending') + await requestGateway('handoff.request', { + platform: target, + session_id: sid + }) + } catch (err) { + return { error: inlineErrorMessage(err, copy.handoff.failed(target)), ok: false } + } + + const deadline = Date.now() + 60_000 + let lastState = 'pending' + + while (Date.now() < deadline) { + await delay(800) + + let record: HandoffStateResponse + + try { + record = await requestGateway('handoff.state', { session_id: sid }) + } catch { + continue + } + + const state = record.state || 'pending' + + if (state !== lastState) { + options?.onProgress?.(state) + lastState = state + } + + if (state === 'completed') { + appendSessionTextMessage(sid, 'system', copy.handoff.systemNote(target)) + notify({ kind: 'success', message: copy.handoff.success(target) }) + + return { ok: true } + } + + if (state === 'failed') { + return { error: record.error || copy.handoff.failed(target), ok: false } + } + } + + const cleanup = await requestGateway('handoff.fail', { + error: copy.handoff.timedOut, + session_id: sid + }).catch(() => null) + + if (cleanup?.state === 'completed') { + appendSessionTextMessage(sid, 'system', copy.handoff.systemNote(target)) + notify({ kind: 'success', message: copy.handoff.success(target) }) + + return { ok: true } + } + + return { error: copy.handoff.timedOut, ok: false } + }, + [activeSessionIdRef, appendSessionTextMessage, copy, requestGateway] + ) + + const executeSlashCommand = useSlashCommand({ + activeSessionIdRef, + appendSessionTextMessage, + branchCurrentSession, + busyRef, + copy, + createBackendSessionForSend, + handleSkinCommand, + handoffSession, + refreshSessions, + requestGateway, + resumeStoredSession, + startFreshSessionDraft, + submitPromptText + }) + + const submitText = useCallback( + async (rawText: string, options?: SubmitTextOptions) => { + const visibleText = rawText.trim() + const attachments = options?.attachments ?? $composerAttachments.get() + + if (!attachments.length && SLASH_COMMAND_RE.test(visibleText)) { + triggerHaptic('selection') + await executeSlashCommand(visibleText) + + return true + } + + return await submitPromptText(rawText, options) + }, + [executeSlashCommand, submitPromptText] + ) + + const transcribeVoiceAudio = useCallback( + async (audio: Blob) => { + if (!sttEnabled) { + throw new Error(copy.sttDisabled) + } + + const dataUrl = await blobToDataUrl(audio) + const result = await transcribeAudio(dataUrl, audio.type) + + return result.transcript + }, + [copy.sttDisabled, sttEnabled] + ) + + const cancelRun = useCallback(async () => { + const sessionId = activeSessionId || activeSessionIdRef.current + + const releaseBusy = () => { + setMutableRef(busyRef, false) + setBusy(false) + } + + setAwaitingResponse(false) + + const finalizeMessages = (messages: ChatMessage[], streamId?: string | null) => + messages + .filter(message => !((message.pending || message.id === streamId) && !chatMessageText(message).trim())) + .map(message => (message.pending || message.id === streamId ? { ...message, pending: false } : message)) + + if (!sessionId) { + releaseBusy() + setMessages(finalizeMessages($messages.get())) + + return + } + + updateSessionState(sessionId, state => { + const streamId = state.streamId + const messages = finalizeMessages(state.messages, streamId) + + return { + ...state, + messages, + busy: false, + awaitingResponse: false, + streamId: null, + pendingBranchGroup: null, + needsInput: false, + interrupted: true + } + }) + + clearSessionTodos(sessionId) + clearSessionSubagents(sessionId) + resetSessionBackground(sessionId) + // Stop ends the turn, so the gateway is no longer blocked on any prompt it + // raised. Drop this session's pending clarify / approval / sudo / secret so + // a dead panel (and the sidebar "needs input" dot) can't linger and accept + // an answer the backend will reject. + clearAllPrompts(sessionId) + clearClarifyRequest(undefined, sessionId) + + try { + await requestGateway('session.interrupt', { session_id: sessionId }) + releaseBusy() + } catch (err) { + let stopError = err + + if (isSessionNotFoundError(err) && selectedStoredSessionIdRef.current) { + try { + 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 + } + } catch (resumeErr) { + stopError = resumeErr + } + } + + releaseBusy() + notifyError(stopError, copy.stopFailed) + } + }, [ + activeSessionId, + activeSessionIdRef, + busyRef, + copy.stopFailed, + requestGateway, + selectedStoredSessionIdRef, + updateSessionState + ]) + + // Steer = nudge the live turn without interrupting: the gateway appends the + // text to the next tool result so the model reads it on its next iteration + // (desktop parity with `/steer`). Returns false on reject (no live tool + // window) so the caller can fall back to queueing the words for the next turn. + const steerPrompt = useCallback( + async (rawText: string): Promise => { + const text = rawText.trim() + const sessionId = activeSessionId || activeSessionIdRef.current + + if (!text || !sessionId) { + return false + } + + try { + const result = await requestGateway('session.steer', { session_id: sessionId, text }) + + if (result?.status === 'queued') { + triggerHaptic('submit') + // Inline note (not a toast) so the nudge lives in the transcript next + // to the turn it steered. The `steer:` prefix is rendered as a codicon + // row by SystemMessage (see STEER_NOTE_RE), same style as slash output. + appendSessionTextMessage(sessionId, 'system', `steer:${text}`) + + return true + } + } catch { + // Swallow — caller queues the text so nothing is lost. + } + + return false + }, + [activeSessionId, activeSessionIdRef, appendSessionTextMessage, requestGateway] + ) + + const reloadFromMessage = useCallback( + async (parentId: string | null) => { + if (!activeSessionId || $busy.get()) { + return + } + + const messages = $messages.get() + const parentIndex = parentId ? messages.findIndex(message => message.id === parentId) : messages.length - 1 + + const userIndex = + parentIndex >= 0 + ? [...messages.slice(0, parentIndex + 1)].reverse().findIndex(message => message.role === 'user') + : -1 + + if (userIndex < 0) { + return + } + + const absoluteUserIndex = parentIndex - userIndex + const userMessage = messages[absoluteUserIndex] + const userText = userMessage ? chatMessageText(userMessage).trim() : '' + + if (!userText) { + return + } + + const targetAssistant = + parentId && messages[parentIndex]?.role === 'assistant' + ? messages[parentIndex] + : messages.slice(absoluteUserIndex + 1).find(message => message.role === 'assistant') + + const branchGroupId = targetAssistant?.branchGroupId ?? branchGroupForUser(userMessage) + const truncateBeforeUserOrdinal = visibleUserOrdinal(messages, absoluteUserIndex) + + clearNotifications() + updateSessionState(activeSessionId, state => { + const nextUserIndex = state.messages.findIndex( + (message, index) => index > absoluteUserIndex && message.role === 'user' + ) + + const end = nextUserIndex < 0 ? state.messages.length : nextUserIndex + + return { + ...state, + busy: true, + awaitingResponse: true, + pendingBranchGroup: branchGroupId, + sawAssistantPayload: false, + interrupted: false, + messages: [ + ...state.messages.slice(0, absoluteUserIndex + 1), + ...state.messages + .slice(absoluteUserIndex + 1, end) + .map(message => (message.role === 'assistant' ? { ...message, branchGroupId, hidden: true } : message)) + ] + } + }) + + try { + await requestGateway('prompt.submit', { + session_id: activeSessionId, + text: userText, + truncate_before_user_ordinal: truncateBeforeUserOrdinal + }) + } catch (err) { + updateSessionState(activeSessionId, state => ({ + ...state, + busy: false, + awaitingResponse: false + })) + notifyError(err, copy.regenerateFailed) + } + }, + [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 callers can surface failures. Idle rewinds submit directly: + // interrupting an idle agent can leave a stale interrupt flag that cancels the + // fresh turn. Live/stuck turns interrupt first, and a raced "session busy" + // response interrupts + retries through the shared busy gate. + const submitRewindPrompt = useCallback( + async (sessionId: string, text: string, truncateOrdinal: number | undefined, interruptFirst: boolean) => { + const interrupt = async () => { + try { + await requestGateway('session.interrupt', { session_id: sessionId }) + } catch { + // Best-effort. The submit path still gates on the gateway state. + } + } + + const submit = () => + requestGateway('prompt.submit', { + session_id: sessionId, + text, + ...(truncateOrdinal !== undefined && { truncate_before_user_ordinal: truncateOrdinal }) + }) + + if (interruptFirst) { + await interrupt() + } + + try { + await submit() + } catch (err) { + if (!isSessionBusyError(err)) { + throw err + } + + await interrupt() + await withSessionBusyRetry(submit) + } + }, + [requestGateway] + ) + + const restoreToMessage = useCallback( + async (messageId: string, target?: RestoreMessageTarget) => { + const sessionId = activeSessionId || activeSessionIdRef.current + + if (!sessionId) { + throw new Error('No active session to restore.') + } + + const messages = $messages.get() + const idIndex = messages.findIndex(m => m.id === messageId && m.role === 'user') + + const fallbackIndex = + target?.userOrdinal === null || target?.userOrdinal === undefined + ? -1 + : visibleUserIndexAtOrdinal(messages, target.userOrdinal) + + const sourceIndex = idIndex >= 0 ? idIndex : fallbackIndex + const source = messages[sourceIndex] + + if (!source || source.role !== 'user') { + throw new Error('Could not find the message to restore.') + } + + const text = (chatMessageText(source).trim() || target?.text?.trim() || '').trim() + + if (!text) { + throw new Error('Cannot restore an empty message.') + } + + const truncateBeforeUserOrdinal = + target?.userOrdinal === null || target?.userOrdinal === undefined + ? visibleUserOrdinal(messages, sourceIndex) + : target.userOrdinal + + // 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) + clearPreviewArtifacts(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, busyRef.current || $busy.get()) + } catch (err) { + // The rewind never landed (e.g. the gateway stayed busy past the retry + // deadline). Roll the optimistic truncation back to the full original + // history so the UI doesn't desync from what's persisted — leaving it + // truncated is what made subsequent sends look duplicative. + setMutableRef(busyRef, false) + setBusy(false) + setAwaitingResponse(false) + updateSessionState(sessionId, state => ({ + ...state, + busy: false, + awaitingResponse: false, + messages + })) + 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') { + return + } + + const messages = $messages.get() + const sourceIndex = messages.findIndex(m => m.id === sourceId) + const source = messages[sourceIndex] + + if (!source || source.role !== 'user' || chatMessageText(source).trim() === text) { + return + } + + // Sending an edit is a revert: rewind to this prompt and re-run with the + // new text. It can fire mid-turn; submitRewindPrompt always interrupts + // first, so a live turn is wound down before the resubmit. + + // 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) + clearPreviewArtifacts(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), editedMessage] + })) + + const isStaleTargetError = (err: unknown) => + /no longer in session history|not in session history/i.test(err instanceof Error ? err.message : String(err)) + + try { + await submitRewindPrompt( + sessionId, + text, + isFailedTurn ? undefined : visibleUserOrdinal(messages, sourceIndex), + busyRef.current || $busy.get() + ) + } catch (err) { + let surfaced = err + + if (!isFailedTurn && isStaleTargetError(err)) { + try { + // Already interrupted on the first attempt — submit as a plain resend. + await submitRewindPrompt(sessionId, text, undefined, false) + + return + } catch (retryErr) { + surfaced = retryErr + } + } + + // Roll the optimistic edit/truncation back to the original history so the + // UI stays in sync with what's persisted instead of stranding a partial + // timeline. + setMutableRef(busyRef, false) + setBusy(false) + setAwaitingResponse(false) + updateSessionState(sessionId, state => ({ ...state, busy: false, awaitingResponse: false, messages })) + notifyError(surfaced, copy.editFailed) + } + }, + [activeSessionId, activeSessionIdRef, busyRef, copy.editFailed, submitRewindPrompt, updateSessionState] + ) + + const handleThreadMessagesChange = useCallback( + (nextMessages: readonly ThreadMessage[]) => { + const visibleIds = new Set(nextMessages.map(m => m.id)) + const sessionId = activeSessionIdRef.current + + if (!sessionId) { + return + } + + updateSessionState(sessionId, state => { + let changed = false + + const messages = state.messages.map(message => { + if (message.role !== 'assistant' || !message.branchGroupId) { + return message + } + + const hidden = !visibleIds.has(message.id) + + if (message.hidden === hidden) { + return message + } + + changed = true + + return { ...message, hidden } + }) + + return changed ? { ...state, messages } : state + }) + }, + [activeSessionIdRef, updateSessionState] + ) + + return { + cancelRun, + editMessage, + handleThreadMessagesChange, + handoffSession, + reloadFromMessage, + restoreToMessage, + steerPrompt, + submitText, + transcribeVoiceAudio + } +} diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/slash.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/slash.ts new file mode 100644 index 000000000000..4d887f4ef64d --- /dev/null +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/slash.ts @@ -0,0 +1,614 @@ +import { type MutableRefObject, useCallback } from 'react' + +import { getProfiles } from '@/hermes' +import type { Translations } from '@/i18n' +import { type ChatMessage } from '@/lib/chat-messages' +import { parseCommandDispatch, parseSlashCommand, sessionTitle } from '@/lib/chat-runtime' +import { + type CommandsCatalogLike, + type DesktopActionId, + type DesktopPickerId, + desktopSlashUnavailableMessage, + isDesktopSlashCommand, + resolveDesktopCommand +} from '@/lib/desktop-slash-commands' +import { setSessionYolo } from '@/lib/yolo-session' +import { openCommandPalettePage } from '@/store/command-palette' +import { type ComposerAttachment, setComposerDraft } from '@/store/composer' +import { notify, notifyError } from '@/store/notifications' +import { setPetScale } from '@/store/pet-gallery' +import { $petGenInput, openPetGenerate } from '@/store/pet-generate' +import { $activeGatewayProfile, $newChatProfile, ensureGatewayProfile, normalizeProfileKey } from '@/store/profile' +import { + $connection, + $sessions, + $yoloActive, + setModelPickerOpen, + setSessionPickerOpen, + setSessions, + setYoloActive +} from '@/store/session' + +import type { BrowserManageResponse, SessionTitleResponse, SlashExecResponse } from '../../../types' + +import { type GatewayRequest, isSessionIdCandidate, renderCommandsCatalog, slashStatusText } from './utils' + +/** Everything a slash handler needs about the invocation it's serving. */ +interface SlashActionCtx { + arg: string + command: string + name: string + recordInput: boolean + sessionHint?: string +} + +interface SlashCommandDeps { + activeSessionIdRef: MutableRefObject + appendSessionTextMessage: (sessionId: string, role: ChatMessage['role'], text: string) => void + branchCurrentSession: () => Promise + busyRef: MutableRefObject + copy: Translations['desktop'] + createBackendSessionForSend: (preview?: string | null) => Promise + handleSkinCommand: (arg: string) => string + handoffSession: ( + platform: string, + options?: { onProgress?: (state: string) => void; sessionId?: string } + ) => Promise<{ ok: boolean; error?: string }> + refreshSessions: () => Promise + requestGateway: GatewayRequest + resumeStoredSession: (storedSessionId: string) => Promise | void + startFreshSessionDraft: () => void + submitPromptText: ( + rawText: string, + options?: { attachments?: ComposerAttachment[]; fromQueue?: boolean } + ) => Promise +} + +/** The /slash command dispatcher, extracted from usePromptActions. */ +export function useSlashCommand(deps: SlashCommandDeps) { + const { + activeSessionIdRef, + appendSessionTextMessage, + branchCurrentSession, + busyRef, + copy, + createBackendSessionForSend, + handleSkinCommand, + handoffSession, + refreshSessions, + requestGateway, + resumeStoredSession, + startFreshSessionDraft, + submitPromptText + } = deps + + return useCallback( + async (rawCommand: string, options?: { sessionId?: string; recordInput?: boolean }) => { + const ensureSessionId = async (sessionHint?: string) => + sessionHint || activeSessionIdRef.current || (await createBackendSessionForSend()) + + // Resolve the target session plus a writer for inline slash output, or + // notify + return null when none can be created. Folds the ensure / bail / + // build-renderSlashOutput boilerplate every exec-style handler repeats. + const withSlashOutput = async ( + ctx: SlashActionCtx + ): Promise<{ render: (text: string) => void; sessionId: string } | null> => { + const sessionId = await ensureSessionId(ctx.sessionHint) + + if (!sessionId) { + notify({ kind: 'error', title: copy.sessionUnavailable, message: copy.createSessionFailed }) + + return null + } + + const render = (text: string) => + appendSessionTextMessage(sessionId, 'system', ctx.recordInput ? slashStatusText(ctx.command, text) : text) + + return { render, sessionId } + } + + // `exec` commands (and unknown skill / quick commands the backend owns) + // run on the gateway and render their text output inline. This is the only + // path that talks to slash.exec / command.dispatch. + async function runExec(ctx: SlashActionCtx): Promise { + const { arg, command, name } = ctx + const resolved = await withSlashOutput(ctx) + + if (!resolved) { + return + } + + const { render: renderSlashOutput, sessionId } = resolved + + if (!isDesktopSlashCommand(name)) { + renderSlashOutput(desktopSlashUnavailableMessage(name) || `/${name} is not available in the desktop app.`) + + return + } + + const handleDispatch = async ( + dispatch: NonNullable> + ): Promise => { + if (dispatch.type === 'exec' || dispatch.type === 'plugin') { + renderSlashOutput(dispatch.output ?? '(no output)') + + return + } + + if (dispatch.type === 'alias') { + await runSlash(`/${dispatch.target}${arg ? ` ${arg}` : ''}`, sessionId, false) + + return + } + + // send / prefill carry an optional `notice` (e.g. "⊙ Goal set …") + // that the backend wants shown as a system line before the message + // is acted on. Mirrors the TUI's createSlashHandler — without it a + // `/goal ` looked like it did nothing. + if ((dispatch.type === 'send' || dispatch.type === 'prefill') && dispatch.notice?.trim()) { + renderSlashOutput(dispatch.notice.trim()) + } + + const message = ('message' in dispatch ? dispatch.message : '')?.trim() ?? '' + + // /undo returns a prefill directive: drop the backed-up message into + // the composer for editing instead of submitting it immediately. + if (dispatch.type === 'prefill') { + if (message) { + setComposerDraft(message) + } + + return + } + + if (!message) { + renderSlashOutput( + `/${name}: ${dispatch.type === 'skill' ? 'skill payload missing message' : 'empty message'}` + ) + + return + } + + if (dispatch.type === 'skill') { + renderSlashOutput(`⚡ loading skill: ${dispatch.name}`) + } + + if (busyRef.current) { + renderSlashOutput('session busy — /interrupt the current turn before sending this command') + + return + } + + await submitPromptText(message) + } + + try { + const result = await requestGateway('slash.exec', { + session_id: sessionId, + command: command.replace(/^\/+/, '') + }) + + const dispatch = parseCommandDispatch(result) + + if (dispatch) { + await handleDispatch(dispatch) + + return + } + + const output = result && typeof result === 'object' ? (result as SlashExecResponse) : null + const body = output?.output || `/${name}: no output` + renderSlashOutput(output?.warning ? `warning: ${output.warning}\n${body}` : body) + + return + } catch { + // Fall back to command.dispatch for skill/send/alias directives. + } + + try { + const dispatch = parseCommandDispatch( + await requestGateway('command.dispatch', { session_id: sessionId, name, arg }) + ) + + if (!dispatch) { + renderSlashOutput('error: invalid response: command.dispatch') + + return + } + + await handleDispatch(dispatch) + } catch (err) { + renderSlashOutput(`error: ${err instanceof Error ? err.message : String(err)}`) + } + } + + // One handler per `action` command. Adding a desktop-native command is a + // registry row in desktop-slash-commands.ts plus an entry here — never a + // new branch in a dispatch ladder. + const actionHandlers: Record Promise> = { + new: async () => { + startFreshSessionDraft() + }, + branch: async () => { + await branchCurrentSession() + }, + // /yolo maps to the status-bar YOLO control — a per-session approval + // bypass, same scope as the TUI's Shift+Tab. With no session yet we arm + // it locally; the session-create path applies it on the first message. + yolo: async ({ sessionHint }) => { + const sid = sessionHint || activeSessionIdRef.current + const next = !$yoloActive.get() + + if (!sid) { + setYoloActive(next) + notify({ kind: 'success', message: next ? copy.yoloArmed : copy.yoloOff }) + + return + } + + try { + const active = await setSessionYolo(requestGateway, sid, next) + appendSessionTextMessage(sid, 'system', copy.yoloSystem(active)) + } catch { + notify({ kind: 'error', title: copy.yoloTitle, message: copy.yoloToggleFailed }) + } + }, + // /handoff hands this session to a messaging platform. The platform is + // completed inline in the slash popover (backend _handoff_completions), + // so there is no overlay: `/handoff ` runs the desktop's own + // handoff RPC. cli_only on the backend, so it must not reach slash.exec. + handoff: async ({ arg, command, recordInput, sessionHint }) => { + const platform = arg.trim() + + if (!platform) { + notify({ kind: 'success', message: copy.handoff.pickPlatform }) + + return + } + + const sid = sessionHint || activeSessionIdRef.current + + if (!sid) { + notify({ kind: 'error', title: copy.sessionUnavailable, message: copy.createSessionFailed }) + + return + } + + const result = await handoffSession(platform, { sessionId: sid }) + + if (!result.ok && result.error) { + appendSessionTextMessage(sid, 'system', recordInput ? slashStatusText(command, result.error) : result.error) + } + }, + // /profile selects which profile new chats open in — no app relaunch. + // A profile is per-session now, so an existing thread can't change its + // profile mid-stream; `/profile ` points the next new chat (and + // the current empty draft) at that profile's backend. + profile: async ({ arg }) => { + const target = arg.trim() + const current = normalizeProfileKey($activeGatewayProfile.get()) + + if (!target) { + notify({ kind: 'success', message: copy.profileStatus(current) }) + + return + } + + try { + const { profiles } = await getProfiles() + const match = profiles.find(profile => profile.name === target) + + if (!match) { + notify({ + kind: 'error', + title: copy.unknownProfile, + message: copy.noProfileNamed(target, profiles.map(profile => profile.name).join(', ')) + }) + + return + } + + const key = normalizeProfileKey(match.name) + + $newChatProfile.set(key) + await ensureGatewayProfile(key) + notify({ kind: 'success', message: copy.newChatsProfile(match.name) }) + } catch (err) { + notifyError(err, copy.setProfileFailed) + } + }, + skin: async ({ arg, command, recordInput, sessionHint }) => { + const sid = sessionHint || activeSessionIdRef.current + const message = handleSkinCommand(arg) + + // No session to print into yet — surface it as a toast instead of + // spinning up a backend session just to change the theme. + if (!sid) { + notify({ kind: 'success', message }) + + return + } + + appendSessionTextMessage(sid, 'system', recordInput ? slashStatusText(command, message) : message) + }, + // /title renames via the gateway's session.title RPC — the same + // path the TUI uses, NOT REST renameSession (which 404s on runtime ids) + // nor the slash worker (whose DB write can silently fail). Bare /title + // shows the current title, which the worker owns, so delegate to exec. + title: async ctx => { + if (!ctx.arg) { + await runExec(ctx) + + return + } + + const resolved = await withSlashOutput(ctx) + + if (!resolved) { + return + } + + const { render: renderSlashOutput, sessionId } = resolved + const { arg } = ctx + + try { + const result = await requestGateway('session.title', { + session_id: sessionId, + title: arg + }) + + const finalTitle = (result?.title || arg).trim() + const queued = result?.pending === true + + setSessions(prev => prev.map(s => (s.id === sessionId ? { ...s, title: finalTitle || null } : s))) + await refreshSessions().catch(() => undefined) + renderSlashOutput( + finalTitle + ? `Session title set: ${finalTitle}${queued ? ' (queued while session initializes)' : ''}` + : 'Session title cleared.' + ) + } catch (err) { + renderSlashOutput(`error: ${err instanceof Error ? err.message : String(err)}`) + } + }, + help: async ctx => { + const resolved = await withSlashOutput(ctx) + + if (!resolved) { + return + } + + const { render: renderSlashOutput, sessionId } = resolved + + try { + const catalog = await requestGateway('commands.catalog', { session_id: sessionId }) + + renderSlashOutput(renderCommandsCatalog(catalog, copy)) + } catch (err) { + renderSlashOutput(`error: ${err instanceof Error ? err.message : String(err)}`) + } + }, + // /hatch opens the pet generator overlay (the desktop's rich, multi-step + // generate→pick→hatch→adopt flow). A typed description seeds the prompt + // so `/hatch a cyber fox` lands on the composer step prefilled. + hatch: async ({ arg }) => { + const concept = arg.trim() + + if (concept) { + $petGenInput.set(concept) + } + + openPetGenerate() + }, + pet: async ctx => { + const [sub = '', rawValue = ''] = ctx.arg.trim().split(/\s+/) + const lower = sub.toLowerCase() + + if (lower === 'list' || lower === 'gallery' || lower === 'browse' || lower === 'all') { + openCommandPalettePage('pets') + + return + } + + // `/pet scale ` resizes the floating pet locally (instant) and + // persists via the store — no round-trip to the slash worker. + if (lower === 'scale') { + const value = Number(rawValue) + + if (!rawValue || Number.isNaN(value)) { + const resolved = await withSlashOutput(ctx) + resolved?.render('usage: /pet scale (e.g. /pet scale 0.5)') + + return + } + + setPetScale(requestGateway, value) + + return + } + + await runExec(ctx) + }, + // /browser connect|disconnect|status manages the live CDP connection on + // the gateway host, mirroring the TUI's browser.manage RPC. It mutates + // BROWSER_CDP_URL (and may launch Chrome) in the gateway process — only + // meaningful when that process runs on this machine, so it's gated to + // local connections. A remote gateway would act on the wrong host. + browser: async ctx => { + const resolved = await withSlashOutput(ctx) + + if (!resolved) { + return + } + + const { render: renderSlashOutput, sessionId } = resolved + + if ($connection.get()?.mode === 'remote') { + renderSlashOutput( + '/browser manages a Chromium-family browser on the gateway host — only available when connected to a local gateway.' + ) + + return + } + + const [rawAction = 'status', ...rest] = ctx.arg.trim().split(/\s+/).filter(Boolean) + const cmdAction = rawAction.toLowerCase() + + if (!['connect', 'disconnect', 'status'].includes(cmdAction)) { + renderSlashOutput( + 'usage: /browser [connect|disconnect|status] [url] · persistent: set browser.cdp_url in config.yaml' + ) + + return + } + + const url = cmdAction === 'connect' ? rest.join(' ').trim() || 'http://127.0.0.1:9222' : undefined + + if (url) { + renderSlashOutput(`checking Chromium-family browser remote debugging at ${url}...`) + } + + try { + const result = await requestGateway('browser.manage', { + action: cmdAction, + session_id: sessionId, + ...(url && { url }) + }) + + // Without a streamed session subscription, the gateway bundles its + // progress lines into `messages` — flush them inline. + result?.messages?.forEach(message => renderSlashOutput(message)) + + if (cmdAction === 'status') { + renderSlashOutput( + result?.connected + ? `browser connected: ${result.url || '(url unavailable)'}` + : 'browser not connected (try /browser connect or set browser.cdp_url in config.yaml)' + ) + + return + } + + if (cmdAction === 'disconnect') { + renderSlashOutput('browser disconnected') + + return + } + + if (result?.connected) { + renderSlashOutput('Browser connected to live Chromium-family browser via CDP') + renderSlashOutput(`Endpoint: ${result.url || '(url unavailable)'}`) + renderSlashOutput('next browser tool call will use this CDP endpoint') + } + } catch (err) { + renderSlashOutput(`error: ${err instanceof Error ? err.message : String(err)}`) + } + } + } + + // Picker commands open a desktop overlay; a typed arg is resolved by that + // picker so the command never dead-ends or falls through to the backend. + const openPicker = async (pickerId: DesktopPickerId, ctx: SlashActionCtx): Promise => { + if (pickerId === 'model') { + if (!ctx.arg.trim()) { + setModelPickerOpen(true) + + return + } + + // Power users can still type `/model ` — run it on the backend. + await runExec(ctx) + + return + } + + // session picker — /resume, /sessions, /switch + const query = ctx.arg.trim() + + if (!query) { + setSessionPickerOpen(true) + + return + } + + const sessions = $sessions.get() + const lower = query.toLowerCase() + + const match = + sessions.find(session => session.id === query) || + sessions.find(session => sessionTitle(session).toLowerCase().includes(lower)) || + sessions.find(session => (session.preview ?? '').toLowerCase().includes(lower)) + + if (!match) { + if (isSessionIdCandidate(query)) { + await resumeStoredSession(query) + + return + } + + notify({ kind: 'error', message: copy.resumeFailed }) + + return + } + + await resumeStoredSession(match.id) + } + + // The whole dispatcher: resolve the command's desktop surface, then act on + // its kind. No per-command ladder — behavior lives in the registry. + async function runSlash(commandText: string, sessionHint?: string, recordInput = true): Promise { + const command = commandText.trim() + const { name, arg } = parseSlashCommand(command) + + if (!name) { + const sessionId = await ensureSessionId(sessionHint) + + if (sessionId) { + appendSessionTextMessage(sessionId, 'system', copy.emptySlashCommand) + } + + return + } + + const ctx: SlashActionCtx = { arg, command, name, recordInput, sessionHint } + const surface = resolveDesktopCommand(`/${name}`)?.surface + + switch (surface?.kind) { + case 'unavailable': { + const resolved = await withSlashOutput(ctx) + resolved?.render(desktopSlashUnavailableMessage(name) || `/${name} is not available in the desktop app.`) + + return + } + + case 'picker': + return openPicker(surface.picker, ctx) + + case 'action': + return actionHandlers[surface.action](ctx) + + default: + // exec spec, or an unknown skill / quick command the backend owns. + return runExec(ctx) + } + } + + await runSlash(rawCommand, options?.sessionId, options?.recordInput ?? true) + }, + [ + activeSessionIdRef, + appendSessionTextMessage, + branchCurrentSession, + busyRef, + copy, + createBackendSessionForSend, + handleSkinCommand, + handoffSession, + refreshSessions, + requestGateway, + resumeStoredSession, + startFreshSessionDraft, + submitPromptText + ] + ) +} diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts new file mode 100644 index 000000000000..1975bf189b1b --- /dev/null +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts @@ -0,0 +1,342 @@ +import { type MutableRefObject, useCallback } from 'react' + +import type { Translations } from '@/i18n' +import { type ChatMessage, textPart } from '@/lib/chat-messages' +import { optimisticAttachmentRef } from '@/lib/chat-runtime' +import { setMutableRef } from '@/lib/mutable-ref' +import { + $composerAttachments, + clearComposerAttachments, + type ComposerAttachment, + terminalContextBlocksFromDraft +} from '@/store/composer' +import { clearNotifications, notify, notifyError } from '@/store/notifications' +import { requestDesktopOnboarding } from '@/store/onboarding' +import { setAwaitingResponse, setBusy, setMessages } from '@/store/session' + +import type { ClientSessionState } from '../../../types' + +import { + _submitInFlight, + type GatewayRequest, + inlineErrorMessage, + isProviderSetupError, + isSessionBusyError, + isSessionNotFoundError, + type SubmitTextOptions, + withSessionBusyRetry +} from './utils' + +interface SubmitPromptDeps { + activeSessionId: string | null + activeSessionIdRef: MutableRefObject + busyRef: MutableRefObject + copy: Translations['desktop'] + createBackendSessionForSend: (preview?: string | null) => Promise + requestGateway: GatewayRequest + selectedStoredSessionIdRef: MutableRefObject + syncAttachmentsForSubmit: ( + sessionId: string, + attachments: ComposerAttachment[], + options?: { updateComposerAttachments?: boolean } + ) => Promise + updateSessionState: ( + sessionId: string, + updater: (state: ClientSessionState) => ClientSessionState, + storedSessionId?: string | null + ) => ClientSessionState +} + +/** The prompt submit pipeline, extracted from usePromptActions. */ +export function useSubmitPrompt(deps: SubmitPromptDeps) { + const { + activeSessionId, + activeSessionIdRef, + busyRef, + copy, + createBackendSessionForSend, + requestGateway, + selectedStoredSessionIdRef, + syncAttachmentsForSubmit, + updateSessionState + } = deps + + return useCallback( + async (rawText: string, options?: SubmitTextOptions) => { + const visibleText = rawText.trim() + const usingComposerAttachments = !options?.attachments + + // Drop undefined/null holes a session switch or draft restore can leave in + // the attachments array (same bug class as AttachmentList #49624). Without + // this, the sibling iterations below (a.kind / a.label / a.refText, and the + // sync step) throw "Cannot read properties of undefined (reading 'refText')" + // and break the chat surface. + const attachments = (options?.attachments ?? $composerAttachments.get()).filter((a): a is ComposerAttachment => + Boolean(a) + ) + + const terminalContextBlocks = terminalContextBlocksFromDraft(rawText).join('\n\n') + const hasImage = attachments.some(a => a.kind === 'image') + + // Refs are recomputed after sync (file.attach rewrites @file: refs to + // workspace-relative paths the remote gateway can resolve). Seed the + // optimistic message with the pre-sync refs, then rewrite once synced. + // 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 => { + // atts may be the post-sync array, which can reintroduce holes; filter + // before touching a.refText / a.kind. + const present = atts.filter((a): a is ComposerAttachment => Boolean(a)) + + const contextRefs = present + .map(a => a.refText) + .filter(Boolean) + .join('\n') + + return ( + [contextRefs, terminalContextBlocks, visibleText].filter(Boolean).join('\n\n') || + (present.some(a => a.kind === 'image') ? 'What do you see in this image?' : '') + ) + } + + // Queue drains fire on the busy→false settle edge, where busyRef (synced + // from $busy by a separate effect) may still read true — honoring it would + // 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 + } + + // One submit in flight per session — drop any concurrent re-fire so a + // stalled turn can't stack the same prompt into multiple real turns. + const submitLockKey = selectedStoredSessionIdRef.current || activeSessionId || '__pending_new__' + + if (_submitInFlight.has(submitLockKey)) { + return false + } + + _submitInFlight.add(submitLockKey) + let submitLockReleased = false + + const releaseSubmitLock = () => { + if (!submitLockReleased) { + submitLockReleased = true + _submitInFlight.delete(submitLockKey) + } + } + + const optimisticId = `user-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` + + const buildUserMessage = (): ChatMessage => ({ + id: optimisticId, + role: 'user', + parts: [textPart(visibleText || (attachmentRefs.length ? '' : attachments.map(a => a.label).join(', ')))], + attachmentRefs + }) + + const releaseBusy = () => { + releaseSubmitLock() + setMutableRef(busyRef, false) + setBusy(false) + setAwaitingResponse(false) + } + + // Idempotent optimistic insert — re-running with the resolved sessionId + // after createBackendSessionForSend just overwrites with the same id. + const seedOptimistic = (sid: string) => + updateSessionState( + sid, + state => ({ + ...state, + messages: state.messages.some(m => m.id === optimisticId) + ? state.messages + : [...state.messages, buildUserMessage()], + busy: true, + awaitingResponse: true, + pendingBranchGroup: null, + sawAssistantPayload: false, + // Fresh submit = new turn — clear any leftover interrupt flag, else + // mutateStream/completeAssistantMessage drop every delta of this turn + // (what made drained-after-interrupt sends go silent). + interrupted: false + }), + selectedStoredSessionIdRef.current + ) + + // After sync rewrites refs, refresh the optimistic message in place so the + // transcript shows the resolved @file: ref rather than the local path. + const rewriteOptimistic = (sid: string) => + updateSessionState( + sid, + state => ({ + ...state, + messages: state.messages.map(message => (message.id === optimisticId ? buildUserMessage() : message)) + }), + selectedStoredSessionIdRef.current + ) + + const dropOptimistic = (sid: null | string) => { + if (!sid) { + setMessages(current => current.filter(m => m.id !== optimisticId)) + + return + } + + updateSessionState( + sid, + state => ({ + ...state, + messages: state.messages.filter(m => m.id !== optimisticId), + busy: false, + awaitingResponse: false, + pendingBranchGroup: null + }), + selectedStoredSessionIdRef.current + ) + } + + setMutableRef(busyRef, true) + setBusy(true) + setAwaitingResponse(true) + clearNotifications() + + let sessionId: null | string = activeSessionId + + if (sessionId) { + seedOptimistic(sessionId) + } else { + setMessages(current => [...current, buildUserMessage()]) + } + + if (!sessionId) { + try { + sessionId = await createBackendSessionForSend(visibleText) + } catch (err) { + dropOptimistic(null) + releaseBusy() + notifyError(err, copy.sessionUnavailable) + + return false + } + + if (!sessionId) { + dropOptimistic(null) + releaseBusy() + notify({ kind: 'error', title: copy.sessionUnavailable, message: copy.createSessionFailed }) + + return false + } + + seedOptimistic(sessionId) + } + + try { + 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.) + attachmentRefs = syncedAttachments.map(optimisticAttachmentRef).filter((r): r is string => Boolean(r)) + rewriteOptimistic(sessionId) + const text = buildContextText(syncedAttachments) + + // On sleep/wake the gateway's in-memory session may have been cleared + // while the desktop app still holds the old session ID. Detect this, + // resume the stored session to re-register it, and retry once. + let submitErr: unknown = null + + try { + 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 withSessionBusyRetry(() => requestGateway('prompt.submit', { session_id: recoveredId, text })) + } else { + submitErr = firstErr + } + } else { + submitErr = firstErr + } + } + + if (submitErr !== null) { + throw submitErr + } + + if (usingComposerAttachments) { + clearComposerAttachments() + } + + // Submit landed — the turn now runs (busy stays true), but the submit + // window is closed, so release the lock for the next (sequential) send. + releaseSubmitLock() + + 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) + + updateSessionState(sessionId, state => ({ + ...state, + messages: [ + ...state.messages, + { + id: `assistant-error-${Date.now()}`, + role: 'assistant', + parts: [], + error: message || copy.promptFailed, + branchGroupId: state.pendingBranchGroup ?? undefined + } + ], + busy: false, + awaitingResponse: false, + pendingBranchGroup: null, + sawAssistantPayload: true + })) + + if (isProviderSetupError(err)) { + requestDesktopOnboarding(copy.providerCredentialRequired) + + return false + } + + notifyError(err, copy.promptFailed) + + return false + } + }, + [ + activeSessionId, + activeSessionIdRef, + busyRef, + copy, + createBackendSessionForSend, + requestGateway, + selectedStoredSessionIdRef, + syncAttachmentsForSubmit, + updateSessionState + ] + ) +} diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/utils.test.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/utils.test.ts new file mode 100644 index 000000000000..1acc854aac55 --- /dev/null +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/utils.test.ts @@ -0,0 +1,127 @@ +import type { AppendMessage } from '@assistant-ui/react' +import { describe, expect, it } from 'vitest' + +import type { ChatMessage } from '@/lib/chat-messages' + +import { + appendText, + base64FromDataUrl, + friendlyRemoteAttachError, + imageFilenameFromPath, + inlineErrorMessage, + isSessionBusyError, + isSessionIdCandidate, + isSessionNotFoundError, + slashStatusText, + visibleUserIndexAtOrdinal, + visibleUserOrdinal +} from './utils' + +describe('isSessionIdCandidate', () => { + it('accepts the timestamped and hex id forms', () => { + expect(isSessionIdCandidate('20260101_120000_abc123')).toBe(true) + expect(isSessionIdCandidate('a'.repeat(32))).toBe(true) + }) + + it('rejects arbitrary text', () => { + expect(isSessionIdCandidate('hello world')).toBe(false) + expect(isSessionIdCandidate('abc')).toBe(false) + }) +}) + +describe('inlineErrorMessage', () => { + it('unwraps an electron remote-method error', () => { + expect(inlineErrorMessage(new Error("Error invoking remote method 'x': Error: boom"), 'fallback')).toBe('boom') + }) + + it('strips a leading Error: prefix', () => { + expect(inlineErrorMessage(new Error('Error: nope'), 'fallback')).toBe('nope') + }) + + it('falls back for non-error, non-string input', () => { + expect(inlineErrorMessage(undefined, 'fallback')).toBe('fallback') + }) +}) + +describe('session error classifiers', () => { + it('detects not-found and busy errors', () => { + expect(isSessionNotFoundError(new Error('Session not found'))).toBe(true) + expect(isSessionBusyError(new Error('session busy'))).toBe(true) + expect(isSessionNotFoundError(new Error('other'))).toBe(false) + expect(isSessionBusyError(new Error('other'))).toBe(false) + }) +}) + +describe('base64FromDataUrl', () => { + it('returns the part after the comma', () => { + expect(base64FromDataUrl('data:image/png;base64,AAAA')).toBe('AAAA') + }) + + it('returns empty when there is no comma', () => { + expect(base64FromDataUrl('nope')).toBe('') + }) +}) + +describe('imageFilenameFromPath', () => { + it('takes the last path segment', () => { + expect(imageFilenameFromPath('/a/b/c.png')).toBe('c.png') + expect(imageFilenameFromPath('C:\\a\\b\\d.jpg')).toBe('d.jpg') + }) + + it('defaults when the path is empty', () => { + expect(imageFilenameFromPath('')).toBe('image.png') + }) +}) + +describe('friendlyRemoteAttachError', () => { + it('rewrites a too-large error with the parsed cap', () => { + const err = friendlyRemoteAttachError(new Error('file is too large (20 bytes; limit 16777216 bytes)'), 'pic.png') + expect(err.message).toBe('pic.png is too large to upload to the remote gateway (max 16 MB).') + }) + + it('passes non-cap errors through', () => { + const original = new Error('something else') + expect(friendlyRemoteAttachError(original, 'pic.png')).toBe(original) + }) +}) + +describe('slashStatusText', () => { + it('joins command and trimmed output', () => { + expect(slashStatusText('/model', ' gpt ')).toBe('slash:/model\ngpt') + }) + + it('omits empty output', () => { + expect(slashStatusText('/clear', ' ')).toBe('slash:/clear') + }) +}) + +describe('appendText', () => { + it('concatenates text parts and trims', () => { + const message = { + content: [ + { type: 'text', text: ' a' }, + { type: 'text', text: 'b ' } + ] + } as unknown as AppendMessage + + expect(appendText(message)).toBe('ab') + }) +}) + +describe('visible user ordinals', () => { + const messages = [ + { role: 'user', hidden: false }, + { role: 'assistant' }, + { role: 'user', hidden: true }, + { role: 'user', hidden: false } + ] as ChatMessage[] + + it('counts visible user messages before an index', () => { + expect(visibleUserOrdinal(messages, messages.length)).toBe(2) + }) + + it('maps an ordinal back to a message index, skipping hidden', () => { + expect(visibleUserIndexAtOrdinal(messages, 1)).toBe(3) + expect(visibleUserIndexAtOrdinal(messages, 5)).toBe(-1) + }) +}) diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/utils.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/utils.ts new file mode 100644 index 000000000000..d3533f4d688e --- /dev/null +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/utils.ts @@ -0,0 +1,217 @@ +import type { AppendMessage } from '@assistant-ui/react' + +import { translateNow, type Translations } from '@/i18n' +import type { ChatMessage } from '@/lib/chat-messages' +import { type CommandsCatalogLike, filterDesktopCommandsCatalog } from '@/lib/desktop-slash-commands' +import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors' +import type { ComposerAttachment } from '@/store/composer' + +export type GatewayRequest = (method: string, params?: Record) => Promise + +export function delay(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)) +} + +export function isSessionIdCandidate(value: string): boolean { + const trimmed = value.trim() + + return /^\d{8}_\d{6}_[A-Fa-f0-9]{6}$/.test(trimmed) || /^[A-Fa-f0-9]{32}$/.test(trimmed) +} + +export function blobToDataUrl(blob: Blob): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader() + + reader.addEventListener('load', () => { + if (typeof reader.result === 'string') { + resolve(reader.result) + } else { + reject(new Error(translateNow('desktop.audioReadFailed'))) + } + }) + reader.addEventListener('error', () => reject(reader.error || new Error(translateNow('desktop.audioReadFailed')))) + reader.readAsDataURL(blob) + }) +} + +export function isProviderSetupError(error: unknown) { + const message = error instanceof Error ? error.message : String(error) + + return isProviderSetupErrorMessage(message) +} + +export function inlineErrorMessage(error: unknown, fallback: string): string { + const raw = error instanceof Error ? error.message : typeof error === 'string' ? error : fallback + + return (raw.match(/Error invoking remote method '[^']+': Error: (.+)$/)?.[1] ?? raw).replace(/^Error:\s*/, '').trim() +} + +export function isSessionNotFoundError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error) + + 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. +export const SESSION_BUSY_RETRY_TIMEOUT_MS = 6_000 +export const SESSION_BUSY_RETRY_INTERVAL_MS = 150 + +export function isSessionBusyError(error: unknown): boolean { + return /session busy/i.test(error instanceof Error ? error.message : String(error)) +} + +const sleep = (ms: number) => new Promise(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. +export async function withSessionBusyRetry(call: () => Promise): Promise { + 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 + } + } +} + +// Hard guard: at most one prompt.submit in flight per session. Every submit +// path — user Enter, queue drain, busy-retry, slash fallthrough — funnels +// through submitPromptText. Without this, a stalled turn (e.g. a context-bloated +// session whose first call hangs) let the SAME prompt launch several real turns +// at once (the "message stacked 5×" bug). Keyed by stored/active session id. +export const _submitInFlight = new Set() + +export function base64FromDataUrl(dataUrl: string): string { + const comma = dataUrl.indexOf(',') + + return comma >= 0 ? dataUrl.slice(comma + 1) : '' +} + +export function imageFilenameFromPath(filePath: string): string { + return filePath.split(/[\\/]/).filter(Boolean).pop() || 'image.png' +} + +// Remote gateway: the local composer-image file lives on THIS machine's disk, +// not the gateway's, so read the bytes here and upload them via +// image.attach_bytes. Returns null when the file can't be read. +export async function readImageForRemoteAttach( + filePath: string +): Promise<{ contentBase64: string; filename: string } | null> { + const dataUrl = await window.hermesDesktop?.readFileDataUrl(filePath) + const contentBase64 = dataUrl ? base64FromDataUrl(dataUrl) : '' + + return contentBase64 ? { contentBase64, filename: imageFilenameFromPath(filePath) } : null +} + +// Read a non-image file as a data URL for upload via file.attach. Returns null +// when the desktop bridge can't read the file (e.g. it was moved/deleted). +export async function readFileDataUrlForAttach(filePath: string): Promise { + const reader = window.hermesDesktop?.readFileDataUrl + + if (!reader) { + return null + } + + const dataUrl = await reader(filePath) + + return dataUrl || null +} + +// The readFileDataUrl IPC base64-loads the whole file into memory and is +// hard-capped (DATA_URL_READ_MAX_BYTES, 16 MB) in electron/hardening.cjs, which +// rejects with a raw "file is too large (N bytes; limit M bytes)" string. In +// remote mode every attachment's bytes go through that read, so a big file +// surfaces that internal message verbatim in the failure toast. Translate it +// into a friendly "too large to upload to the remote gateway" line, parsing the +// limit out of the message so it tracks the real cap. Non-cap errors pass +// through unchanged. +export function friendlyRemoteAttachError(err: unknown, label: string): Error { + const message = err instanceof Error ? err.message : String(err) + + if (!/too large/i.test(message)) { + return err instanceof Error ? err : new Error(message) + } + + const limitBytes = Number(message.match(/limit (\d+) bytes/)?.[1]) + const cap = Number.isFinite(limitBytes) && limitBytes > 0 ? ` (max ${Math.floor(limitBytes / (1024 * 1024))} MB)` : '' + + return new Error(`${label} is too large to upload to the remote gateway${cap}.`) +} + +export function renderCommandsCatalog(catalog: CommandsCatalogLike, copy: Translations['desktop']): string { + const desktopCatalog = filterDesktopCommandsCatalog(catalog) + + const sections = desktopCatalog.categories?.length + ? desktopCatalog.categories + : [{ name: copy.desktopCommands, pairs: desktopCatalog.pairs ?? [] }] + + const body = sections + .filter(section => section.pairs.length > 0) + .map(section => { + const rows = section.pairs.map(([cmd, desc]) => `${cmd.padEnd(18)} ${desc}`) + + return [`${section.name}:`, ...rows].join('\n') + }) + .join('\n\n') + + const tail = [ + desktopCatalog.skill_count ? copy.skillCommandsAvailable(desktopCatalog.skill_count) : '', + desktopCatalog.warning ? copy.warningLine(desktopCatalog.warning) : '' + ] + .filter(Boolean) + .join('\n') + + return [body || 'No desktop commands available.', tail].filter(Boolean).join('\n\n') +} + +export function slashStatusText(command: string, output: string): string { + return [`slash:${command}`, output.trim()].filter(Boolean).join('\n') +} + +export function appendText(message: AppendMessage): string { + return message.content + .map(part => ('text' in part ? part.text : '')) + .join('') + .trim() +} + +export function visibleUserOrdinal(messages: readonly ChatMessage[], end: number): number { + return messages.slice(0, end).filter(m => m.role === 'user' && !m.hidden).length +} + +export function visibleUserIndexAtOrdinal(messages: readonly ChatMessage[], targetOrdinal: number): number { + let ordinal = 0 + + for (let index = 0; index < messages.length; index += 1) { + const message = messages[index] + + if (message.role !== 'user' || message.hidden) { + continue + } + + if (ordinal === targetOrdinal) { + return index + } + + ordinal += 1 + } + + return -1 +} + +export interface SubmitTextOptions { + attachments?: ComposerAttachment[] + fromQueue?: boolean +} diff --git a/apps/desktop/src/app/session/hooks/use-session-actions.ts b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts similarity index 77% rename from apps/desktop/src/app/session/hooks/use-session-actions.ts rename to apps/desktop/src/app/session/hooks/use-session-actions/index.ts index 0e3af87bdd01..32d7d6d56c6d 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts @@ -2,20 +2,16 @@ import type { MutableRefObject } from 'react' import { useCallback, useRef } from 'react' import type { NavigateFunction } from 'react-router-dom' -import { deleteSession, getSession, getSessionMessages, setSessionArchived } from '@/hermes' +import { deleteSession, getSessionMessages, setSessionArchived } from '@/hermes' import { useI18n } from '@/i18n' -import { type ChatMessage, chatMessageText, preserveLocalAssistantErrors, toChatMessages } from '@/lib/chat-messages' -import { normalizePersonalityValue } from '@/lib/chat-runtime' -import { embeddedImageUrls, textWithoutEmbeddedImages } from '@/lib/embedded-images' +import { preserveLocalAssistantErrors, toChatMessages } from '@/lib/chat-messages' import { setSessionYolo } from '@/lib/yolo-session' 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, - $profiles, ensureGatewayProfile, normalizeProfileKey } from '@/store/profile' @@ -35,11 +31,6 @@ import { setBusy, setCurrentBranch, setCurrentCwd, - setCurrentFastMode, - setCurrentModel, - setCurrentPersonality, - setCurrentProvider, - setCurrentReasoningEffort, setCurrentServiceTier, setCurrentUsage, setFreshDraftReady, @@ -56,18 +47,30 @@ import { workspaceCwdForNewSession } from '@/store/session' import { broadcastSessionsChanged } from '@/store/session-sync' -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' -import type { ClientSessionState, SidebarNavItem } from '../../types' +import { NEW_CHAT_ROUTE, sessionRoute, SETTINGS_ROUTE } from '../../../routes' +import type { ClientSessionState, SidebarNavItem } from '../../../types' + +import { + applyRuntimeInfo, + applyStoredSessionPreviewRuntimeInfo, + type BranchMessage, + chatMessageArraysEquivalent, + isSessionGoneError, + patchSessionWorkspace, + reconcileResumeMessages, + resolveStoredSession, + sessionMatchesStoredId, + sessionShouldHaveTranscript, + toBranchMessages, + upsertOptimisticSession +} from './utils' interface SessionActionsOptions { activeSessionId: string | null @@ -90,325 +93,6 @@ interface SessionActionsOptions { ) => ClientSessionState } -function withAppendedText(message: ChatMessage, suffix: string): ChatMessage { - let appended = false - - const parts = message.parts.map(part => { - if (part.type !== 'text' || appended) { - return part - } - - appended = true - - return { ...part, text: `${part.text}${suffix}` } - }) - - return appended ? { ...message, parts } : message -} - -function preserveReasoningParts(message: ChatMessage, previous: ChatMessage): ChatMessage { - if (message.parts.some(part => part.type === 'reasoning')) { - return message - } - - const reasoningParts = previous.parts.filter(part => part.type === 'reasoning') - - return reasoningParts.length ? { ...message, parts: [...reasoningParts, ...message.parts] } : message -} - -function chatMessagesEquivalent(a: ChatMessage, b: ChatMessage): boolean { - if ( - a.id !== b.id || - a.role !== b.role || - a.pending !== b.pending || - a.error !== b.error || - a.hidden !== b.hidden || - a.branchGroupId !== b.branchGroupId - ) { - return false - } - - if (a.parts.length !== b.parts.length) { - return false - } - - return a.parts.every((part, index) => JSON.stringify(part) === JSON.stringify(b.parts[index])) -} - -function chatMessageArraysEquivalent(a: ChatMessage[], b: ChatMessage[]): boolean { - return a.length === b.length && a.every((message, index) => chatMessagesEquivalent(message, b[index])) -} - -function reconcileResumeMessages(nextMessages: ChatMessage[], previousMessages: ChatMessage[]): ChatMessage[] { - if (!previousMessages.length) { - return nextMessages - } - - const previousByRoleOrdinal = new Map() - const previousRoleCounts = new Map() - - for (const message of previousMessages) { - const ordinal = previousRoleCounts.get(message.role) ?? 0 - previousRoleCounts.set(message.role, ordinal + 1) - previousByRoleOrdinal.set(`${message.role}:${ordinal}`, message) - } - - const nextRoleCounts = new Map() - - return nextMessages.map(message => { - const ordinal = nextRoleCounts.get(message.role) ?? 0 - nextRoleCounts.set(message.role, ordinal + 1) - - const previous = previousByRoleOrdinal.get(`${message.role}:${ordinal}`) - - if (!previous) { - return message - } - - const nextText = chatMessageText(message).trim() - const previousText = chatMessageText(previous) - const previousVisibleText = textWithoutEmbeddedImages(previousText) - let preserved = message - - if (nextText === previousVisibleText || nextText === previousText.trim()) { - preserved = preserveReasoningParts(preserved, previous) - } - - const previousImages = embeddedImageUrls(previousText) - - if (!previousImages.length || embeddedImageUrls(chatMessageText(preserved)).length) { - return preserved - } - - if (nextText !== previousVisibleText) { - return preserved - } - - return withAppendedText(preserved, previousImages.map(url => `\n${url}`).join('')) - }) -} - -interface BranchMessage { - content: string - role: ChatMessage['role'] - source: ChatMessage -} - -// The copyable spine of a branch: user/assistant turns that carry text. -const toBranchMessages = (messages: ChatMessage[]): BranchMessage[] => - messages - .map(message => ({ content: chatMessageText(message), role: message.role, source: message })) - .filter(({ content, role }) => content.trim() && (role === 'assistant' || role === 'user')) - -function upsertOptimisticSession( - created: SessionCreateResponse, - id: string, - title: string | null = null, - preview: string | null = null, - parentSessionId: string | null = null, - lastActive?: number -) { - const now = lastActive ?? Date.now() / 1000 - // Stamp the profile the session was just created on (= the live gateway's - // profile) so the scoped sidebar shows the new row immediately instead of - // filtering it out as "default" until the aggregator re-fetches. - const profileKey = normalizeProfileKey($activeGatewayProfile.get()) - - const session: SessionInfo = { - // Seed cwd so the grouped sidebar can place the new row in its repo/worktree - // lane immediately (the overlay groups by path); fall back to the workspace - // the session was just started in when the create response omits it. - cwd: created.info?.cwd ?? ($currentCwd.get().trim() || null), - ended_at: null, - id, - input_tokens: 0, - is_active: true, - is_default_profile: profileKey === 'default', - last_active: now, - message_count: created.message_count ?? created.messages?.length ?? 0, - model: created.info?.model ?? null, - output_tokens: 0, - parent_session_id: parentSessionId, - preview, - profile: profileKey, - source: 'tui', - started_at: now, - title, - tool_call_count: 0 - } - - setSessions(prev => [session, ...prev.filter(s => s.id !== id)]) -} - -function patchSessionWorkspace(sessionId: string, cwd: string | undefined) { - if (!cwd) { - return - } - - setSessions(prev => prev.map(session => (session.id === sessionId ? { ...session, cwd } : session))) -} - -function sessionMatchesStoredId(session: SessionInfo, storedSessionId: string): boolean { - return session.id === storedSessionId || session._lineage_root_id === storedSessionId -} - -function sessionShouldHaveTranscript(session: SessionInfo | undefined): boolean { - return (session?.message_count ?? 0) > 0 -} - -function upsertResolvedSession(session: SessionInfo, storedSessionId: string) { - const lineage = session._lineage_root_id ?? session.id - - setSessions(prev => [ - session, - ...prev.filter(existing => { - if (sessionMatchesStoredId(existing, storedSessionId)) { - return false - } - - return (existing._lineage_root_id ?? existing.id) !== lineage - }) - ]) -} - -async function resolveStoredSession(storedSessionId: string): Promise { - const cached = $sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId)) - - if (cached) { - 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 session = await getSession(storedSessionId) - - upsertResolvedSession(session, storedSessionId) - - return session - } catch { - // 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< - Pick< - ClientSessionState, - 'branch' | 'cwd' | 'fast' | 'model' | 'personality' | 'provider' | 'reasoningEffort' | 'serviceTier' | 'yolo' - > -> - -function applyRuntimeInfo(info: SessionRuntimeInfo | undefined): SessionRuntimeStatePatch | null { - if (!info) { - return null - } - - const sessionState: SessionRuntimeStatePatch = {} - - reportBackendContract(info.desktop_contract) - - if (info.credential_warning) { - requestDesktopOnboarding(info.credential_warning) - } - - if (typeof info.model === 'string') { - setCurrentModel(info.model) - sessionState.model = info.model - } - - if (typeof info.provider === 'string') { - setCurrentProvider(info.provider) - sessionState.provider = info.provider - } - - if (info.cwd) { - setCurrentCwd(info.cwd) - sessionState.cwd = info.cwd - } - - if (info.branch !== undefined) { - setCurrentBranch(info.branch || '') - sessionState.branch = info.branch || '' - } - - if (typeof info.personality === 'string') { - const personality = normalizePersonalityValue(info.personality) - setCurrentPersonality(personality) - sessionState.personality = personality - } - - if (typeof info.reasoning_effort === 'string') { - setCurrentReasoningEffort(info.reasoning_effort) - sessionState.reasoningEffort = info.reasoning_effort - } - - if (typeof info.service_tier === 'string') { - setCurrentServiceTier(info.service_tier) - sessionState.serviceTier = info.service_tier - } - - if (typeof info.fast === 'boolean') { - setCurrentFastMode(info.fast) - sessionState.fast = info.fast - } - - if (typeof info.yolo === 'boolean') { - setYoloActive(info.yolo) - sessionState.yolo = info.yolo - } - - if (info.usage) { - setCurrentUsage(current => ({ ...current, ...info.usage })) - } - - return sessionState -} - -function applyStoredSessionPreviewRuntimeInfo(stored: { model?: null | string } | undefined) { - setCurrentModel(stored?.model || '') - setCurrentProvider('') - setCurrentReasoningEffort('') - setCurrentServiceTier('') - setCurrentFastMode(false) - setYoloActive(false) - setCurrentPersonality('') -} - -// A "session genuinely doesn't exist" failure (deleted, or an id from a wiped / -// rotated backend) — the REST transcript 404s with `Session not found`. Distinct -// from a transient/wedged backend (ECONNREFUSED, timeout), which must still -// retry rather than discard the id. -function isSessionGoneError(err: unknown): boolean { - const message = err instanceof Error ? err.message : String(err ?? '') - - return message.includes('404') || /session not found/i.test(message) -} - export function useSessionActions({ activeSessionId, activeSessionIdRef, @@ -685,7 +369,9 @@ export function useSessionActions({ if (warmHit) { const cachedRuntimeId = warmHit.runtimeId const cachedState = warmHit.state - const stored = $sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId)) ?? storedForProfile + + const stored = + $sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId)) ?? storedForProfile const cachedViewState = !cachedState.model && stored?.model != null @@ -752,7 +438,10 @@ export function useSessionActions({ setSelectedStoredSessionId(storedSessionId) selectedStoredSessionIdRef.current = storedSessionId setSessionStartedAt(Date.now()) - const stored = $sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId)) ?? storedForProfile + + const stored = + $sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId)) ?? storedForProfile + applyStoredSessionPreviewRuntimeInfo(stored) if (stored) { diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/utils.test.ts b/apps/desktop/src/app/session/hooks/use-session-actions/utils.test.ts new file mode 100644 index 000000000000..680cc754286e --- /dev/null +++ b/apps/desktop/src/app/session/hooks/use-session-actions/utils.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest' + +import type { ChatMessage } from '@/lib/chat-messages' +import type { SessionInfo } from '@/types/hermes' + +import { + chatMessageArraysEquivalent, + isSessionGoneError, + reconcileResumeMessages, + sessionMatchesStoredId, + sessionShouldHaveTranscript, + toBranchMessages +} from './utils' + +const msg = (id: string, role: ChatMessage['role'], text: string, extra: Partial = {}): ChatMessage => + ({ id, role, parts: [{ type: 'text', text }], ...extra }) as ChatMessage + +const session = (over: Partial): SessionInfo => over as SessionInfo + +describe('isSessionGoneError', () => { + it('is true for 404 / session-not-found, false otherwise', () => { + expect(isSessionGoneError(new Error('Request failed 404'))).toBe(true) + expect(isSessionGoneError(new Error('Session not found'))).toBe(true) + expect(isSessionGoneError(new Error('ECONNREFUSED'))).toBe(false) + expect(isSessionGoneError(null)).toBe(false) + }) +}) + +describe('sessionMatchesStoredId', () => { + it('matches on live id or lineage root', () => { + expect(sessionMatchesStoredId(session({ id: 'a' }), 'a')).toBe(true) + expect(sessionMatchesStoredId(session({ id: 'live', _lineage_root_id: 'root' }), 'root')).toBe(true) + expect(sessionMatchesStoredId(session({ id: 'a' }), 'b')).toBe(false) + }) +}) + +describe('sessionShouldHaveTranscript', () => { + it('is true only when the session has messages', () => { + expect(sessionShouldHaveTranscript(session({ message_count: 3 }))).toBe(true) + expect(sessionShouldHaveTranscript(session({ message_count: 0 }))).toBe(false) + expect(sessionShouldHaveTranscript(undefined)).toBe(false) + }) +}) + +describe('toBranchMessages', () => { + it('keeps only user/assistant turns that carry text', () => { + const out = toBranchMessages([ + msg('u', 'user', 'hi'), + msg('blank', 'assistant', ' '), + msg('sys', 'system', 'ignored'), + msg('a', 'assistant', 'hello') + ]) + + expect(out.map(b => b.source.id)).toEqual(['u', 'a']) + expect(out[0]).toMatchObject({ content: 'hi', role: 'user' }) + }) +}) + +describe('chatMessageArraysEquivalent', () => { + it('compares length and per-message equivalence', () => { + const a = [msg('1', 'user', 'x'), msg('2', 'assistant', 'y')] + expect(chatMessageArraysEquivalent(a, [msg('1', 'user', 'x'), msg('2', 'assistant', 'y')])).toBe(true) + expect(chatMessageArraysEquivalent(a, [msg('1', 'user', 'x')])).toBe(false) + expect(chatMessageArraysEquivalent(a, [msg('1', 'user', 'x'), msg('2', 'assistant', 'changed')])).toBe(false) + }) +}) + +describe('reconcileResumeMessages', () => { + it('returns next untouched when there is no previous transcript', () => { + const next = [msg('1', 'user', 'hi')] + expect(reconcileResumeMessages(next, [])).toBe(next) + }) + + it('re-grafts reasoning parts onto a matching assistant turn', () => { + const next = [msg('a', 'assistant', 'answer')] + + const previous = [ + msg('a', 'assistant', 'answer', { + parts: [ + { type: 'reasoning', text: 'thinking' }, + { type: 'text', text: 'answer' } + ] + } as Partial) + ] + + const [out] = reconcileResumeMessages(next, previous) + expect(out.parts.some(p => p.type === 'reasoning')).toBe(true) + }) +}) diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts b/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts new file mode 100644 index 000000000000..254a58e1298a --- /dev/null +++ b/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts @@ -0,0 +1,344 @@ +import { getSession } from '@/hermes' +import { type ChatMessage, chatMessageText } from '@/lib/chat-messages' +import { normalizePersonalityValue } from '@/lib/chat-runtime' +import { embeddedImageUrls, textWithoutEmbeddedImages } from '@/lib/embedded-images' +import { requestDesktopOnboarding } from '@/store/onboarding' +import { $activeGatewayProfile, $profiles, normalizeProfileKey } from '@/store/profile' +import { + $currentCwd, + $sessions, + setCurrentBranch, + setCurrentCwd, + setCurrentFastMode, + setCurrentModel, + setCurrentPersonality, + setCurrentProvider, + setCurrentReasoningEffort, + setCurrentServiceTier, + setCurrentUsage, + setSessions, + setYoloActive +} from '@/store/session' +import { reportBackendContract } from '@/store/updates' +import type { SessionCreateResponse, SessionInfo, SessionRuntimeInfo } from '@/types/hermes' + +import type { ClientSessionState } from '../../../types' + +function withAppendedText(message: ChatMessage, suffix: string): ChatMessage { + let appended = false + + const parts = message.parts.map(part => { + if (part.type !== 'text' || appended) { + return part + } + + appended = true + + return { ...part, text: `${part.text}${suffix}` } + }) + + return appended ? { ...message, parts } : message +} + +function preserveReasoningParts(message: ChatMessage, previous: ChatMessage): ChatMessage { + if (message.parts.some(part => part.type === 'reasoning')) { + return message + } + + const reasoningParts = previous.parts.filter(part => part.type === 'reasoning') + + return reasoningParts.length ? { ...message, parts: [...reasoningParts, ...message.parts] } : message +} + +function chatMessagesEquivalent(a: ChatMessage, b: ChatMessage): boolean { + if ( + a.id !== b.id || + a.role !== b.role || + a.pending !== b.pending || + a.error !== b.error || + a.hidden !== b.hidden || + a.branchGroupId !== b.branchGroupId + ) { + return false + } + + if (a.parts.length !== b.parts.length) { + return false + } + + return a.parts.every((part, index) => JSON.stringify(part) === JSON.stringify(b.parts[index])) +} + +export function chatMessageArraysEquivalent(a: ChatMessage[], b: ChatMessage[]): boolean { + return a.length === b.length && a.every((message, index) => chatMessagesEquivalent(message, b[index])) +} + +export function reconcileResumeMessages(nextMessages: ChatMessage[], previousMessages: ChatMessage[]): ChatMessage[] { + if (!previousMessages.length) { + return nextMessages + } + + const previousByRoleOrdinal = new Map() + const previousRoleCounts = new Map() + + for (const message of previousMessages) { + const ordinal = previousRoleCounts.get(message.role) ?? 0 + previousRoleCounts.set(message.role, ordinal + 1) + previousByRoleOrdinal.set(`${message.role}:${ordinal}`, message) + } + + const nextRoleCounts = new Map() + + return nextMessages.map(message => { + const ordinal = nextRoleCounts.get(message.role) ?? 0 + nextRoleCounts.set(message.role, ordinal + 1) + + const previous = previousByRoleOrdinal.get(`${message.role}:${ordinal}`) + + if (!previous) { + return message + } + + const nextText = chatMessageText(message).trim() + const previousText = chatMessageText(previous) + const previousVisibleText = textWithoutEmbeddedImages(previousText) + let preserved = message + + if (nextText === previousVisibleText || nextText === previousText.trim()) { + preserved = preserveReasoningParts(preserved, previous) + } + + const previousImages = embeddedImageUrls(previousText) + + if (!previousImages.length || embeddedImageUrls(chatMessageText(preserved)).length) { + return preserved + } + + if (nextText !== previousVisibleText) { + return preserved + } + + return withAppendedText(preserved, previousImages.map(url => `\n${url}`).join('')) + }) +} + +export interface BranchMessage { + content: string + role: ChatMessage['role'] + source: ChatMessage +} + +// The copyable spine of a branch: user/assistant turns that carry text. +export const toBranchMessages = (messages: ChatMessage[]): BranchMessage[] => + messages + .map(message => ({ content: chatMessageText(message), role: message.role, source: message })) + .filter(({ content, role }) => content.trim() && (role === 'assistant' || role === 'user')) + +export function upsertOptimisticSession( + created: SessionCreateResponse, + id: string, + title: string | null = null, + preview: string | null = null, + parentSessionId: string | null = null, + lastActive?: number +) { + const now = lastActive ?? Date.now() / 1000 + // Stamp the profile the session was just created on (= the live gateway's + // profile) so the scoped sidebar shows the new row immediately instead of + // filtering it out as "default" until the aggregator re-fetches. + const profileKey = normalizeProfileKey($activeGatewayProfile.get()) + + const session: SessionInfo = { + // Seed cwd so the grouped sidebar can place the new row in its repo/worktree + // lane immediately (the overlay groups by path); fall back to the workspace + // the session was just started in when the create response omits it. + cwd: created.info?.cwd ?? ($currentCwd.get().trim() || null), + ended_at: null, + id, + input_tokens: 0, + is_active: true, + is_default_profile: profileKey === 'default', + last_active: now, + message_count: created.message_count ?? created.messages?.length ?? 0, + model: created.info?.model ?? null, + output_tokens: 0, + parent_session_id: parentSessionId, + preview, + profile: profileKey, + source: 'tui', + started_at: now, + title, + tool_call_count: 0 + } + + setSessions(prev => [session, ...prev.filter(s => s.id !== id)]) +} + +export function patchSessionWorkspace(sessionId: string, cwd: string | undefined) { + if (!cwd) { + return + } + + setSessions(prev => prev.map(session => (session.id === sessionId ? { ...session, cwd } : session))) +} + +export function sessionMatchesStoredId(session: SessionInfo, storedSessionId: string): boolean { + return session.id === storedSessionId || session._lineage_root_id === storedSessionId +} + +export function sessionShouldHaveTranscript(session: SessionInfo | undefined): boolean { + return (session?.message_count ?? 0) > 0 +} + +function upsertResolvedSession(session: SessionInfo, storedSessionId: string) { + const lineage = session._lineage_root_id ?? session.id + + setSessions(prev => [ + session, + ...prev.filter(existing => { + if (sessionMatchesStoredId(existing, storedSessionId)) { + return false + } + + return (existing._lineage_root_id ?? existing.id) !== lineage + }) + ]) +} + +export async function resolveStoredSession(storedSessionId: string): Promise { + const cached = $sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId)) + + if (cached) { + 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 session = await getSession(storedSessionId) + + upsertResolvedSession(session, storedSessionId) + + return session + } catch { + // 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< + Pick< + ClientSessionState, + 'branch' | 'cwd' | 'fast' | 'model' | 'personality' | 'provider' | 'reasoningEffort' | 'serviceTier' | 'yolo' + > +> + +export function applyRuntimeInfo(info: SessionRuntimeInfo | undefined): SessionRuntimeStatePatch | null { + if (!info) { + return null + } + + const sessionState: SessionRuntimeStatePatch = {} + + reportBackendContract(info.desktop_contract) + + if (info.credential_warning) { + requestDesktopOnboarding(info.credential_warning) + } + + if (typeof info.model === 'string') { + setCurrentModel(info.model) + sessionState.model = info.model + } + + if (typeof info.provider === 'string') { + setCurrentProvider(info.provider) + sessionState.provider = info.provider + } + + if (info.cwd) { + setCurrentCwd(info.cwd) + sessionState.cwd = info.cwd + } + + if (info.branch !== undefined) { + setCurrentBranch(info.branch || '') + sessionState.branch = info.branch || '' + } + + if (typeof info.personality === 'string') { + const personality = normalizePersonalityValue(info.personality) + setCurrentPersonality(personality) + sessionState.personality = personality + } + + if (typeof info.reasoning_effort === 'string') { + setCurrentReasoningEffort(info.reasoning_effort) + sessionState.reasoningEffort = info.reasoning_effort + } + + if (typeof info.service_tier === 'string') { + setCurrentServiceTier(info.service_tier) + sessionState.serviceTier = info.service_tier + } + + if (typeof info.fast === 'boolean') { + setCurrentFastMode(info.fast) + sessionState.fast = info.fast + } + + if (typeof info.yolo === 'boolean') { + setYoloActive(info.yolo) + sessionState.yolo = info.yolo + } + + if (info.usage) { + setCurrentUsage(current => ({ ...current, ...info.usage })) + } + + return sessionState +} + +export function applyStoredSessionPreviewRuntimeInfo(stored: { model?: null | string } | undefined) { + setCurrentModel(stored?.model || '') + setCurrentProvider('') + setCurrentReasoningEffort('') + setCurrentServiceTier('') + setCurrentFastMode(false) + setYoloActive(false) + setCurrentPersonality('') +} + +// A "session genuinely doesn't exist" failure (deleted, or an id from a wiped / +// rotated backend) — the REST transcript 404s with `Session not found`. Distinct +// from a transient/wedged backend (ECONNREFUSED, timeout), which must still +// retry rather than discard the id. +export function isSessionGoneError(err: unknown): boolean { + const message = err instanceof Error ? err.message : String(err ?? '') + + return message.includes('404') || /session not found/i.test(message) +} diff --git a/apps/desktop/src/app/session/hooks/use-session-list-actions.ts b/apps/desktop/src/app/session/hooks/use-session-list-actions.ts new file mode 100644 index 000000000000..6c5d89e7112e --- /dev/null +++ b/apps/desktop/src/app/session/hooks/use-session-list-actions.ts @@ -0,0 +1,230 @@ +import { useCallback, useRef } from 'react' + +import { getCronJobs, listAllProfileSessions, type SessionInfo } from '@/hermes' +import { + isMessagingSource, + LOCAL_SESSION_SOURCE_IDS, + MESSAGING_SESSION_SOURCE_IDS, + normalizeSessionSource +} from '@/lib/session-source' +import { setCronJobs } from '@/store/cron' +import { $pinnedSessionIds, $sessionsLimit, bumpSessionsLimit, SIDEBAR_SESSIONS_PAGE_SIZE } from '@/store/layout' +import { ALL_PROFILES, normalizeProfileKey } from '@/store/profile' +import { + $messagingSessions, + $selectedStoredSessionId, + $sessions, + $workingSessionIds, + CRON_SECTION_LIMIT, + getRecentlySettledSessionIds, + mergeSessionPage, + MESSAGING_SECTION_LIMIT, + setCronSessions, + setMessagingPlatformTotals, + setMessagingSessions, + setMessagingTruncated, + setSessionProfileTotals, + setSessions, + setSessionsLoading, + setSessionsTotal +} from '@/store/session' + +import { sameCronSignature } from '../../desktop-controller-utils' + +// The recents list is local-only: cron rows have their own section, and each +// messaging platform (telegram, discord, …) is fetched separately into its own +// 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', '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] + +// Rows a session refresh must preserve even if the aggregator omits them: +// in-flight first turns (message_count 0), pinned rows aged off the page, the +// actively-viewed chat (its "working" flag clears a beat before the aggregator +// sees the persisted row), and sessions whose turn just settled (same race, but +// for a chat the user has already navigated away from). Pass `scope` to only +// keep the active row when it belongs to the profile being paged. +function sessionsToKeep(scope?: string): Set { + const keep = new Set([ + ...$workingSessionIds.get(), + ...$pinnedSessionIds.get(), + ...getRecentlySettledSessionIds() + ]) + + const active = $selectedStoredSessionId.get() + + if (active) { + const session = scope ? $sessions.get().find(s => s.id === active) : null + + if (!scope || !session || normalizeProfileKey(session.profile) === scope) { + keep.add(active) + } + } + + return keep +} + +interface UseSessionListActionsArgs { + profileScope: string +} + +/** Owns the sidebar's session-list fetching + paging: recents, cron runs/jobs, + * and the per-platform messaging slices. Returns the callbacks the controller + * wires into the sidebar and refresh effects. */ +export function useSessionListActions({ profileScope }: UseSessionListActionsArgs) { + const refreshSessionsRequestRef = useRef(0) + + // Cron-job sessions as their own list (latest N). Independent of the recents + // page so the two never compete for slots. Cheap + bounded. Kept (even though + // the sidebar now lists cron *jobs*, not run sessions) so a pinned cron run + // still resolves into the Pinned section via sessionByAnyId. + const refreshCronSessions = useCallback(async () => { + try { + const { sessions } = await listAllProfileSessions(CRON_SECTION_LIMIT, 1, 'exclude', 'recent', 'all', { + source: 'cron' + }) + + setCronSessions(prev => (sameCronSignature(prev, sessions) ? prev : sessions)) + } catch { + // Non-fatal: the cron section just stays empty/stale. + } + }, []) + + // Messaging-platform sessions as their own slice, fetched separately from + // local recents so each platform renders a self-managed section and never + // competes with local chats for the recents page budget. One combined fetch + // seeds every platform; the sidebar splits the rows per source. + const refreshMessagingSessions = useCallback(async () => { + try { + const result = await listAllProfileSessions(MESSAGING_SECTION_LIMIT, 1, 'exclude', 'recent', 'all', { + excludeSources: MESSAGING_EXCLUDED_SOURCES + }) + + // Drop any non-messaging source the broad exclude didn't catch (custom + // sources) — those stay in local recents, not a platform section. + const rows = result.sessions.filter(s => isMessagingSource(s.source)) + + setMessagingSessions(prev => (sameCronSignature(prev, rows) ? prev : rows)) + // Hit the cap → at least one platform may have more on disk than loaded, + // so platform sections offer their own per-platform "load more". + setMessagingTruncated(result.sessions.length >= MESSAGING_SECTION_LIMIT) + } catch { + // Non-fatal: the messaging sections just stay empty/stale. + } + }, []) + + // Page a single platform's section independently (mirrors the per-profile + // pager): fetch that source's next window and merge it back in place, leaving + // every other platform's rows untouched. Resolves the platform's exact total. + const loadMoreMessagingForPlatform = useCallback(async (platform: string) => { + const inPlatform = (s: SessionInfo) => normalizeSessionSource(s.source) === platform + const loaded = $messagingSessions.get().filter(inPlatform).length + + const result = await listAllProfileSessions(loaded + SIDEBAR_SESSIONS_PAGE_SIZE, 1, 'exclude', 'recent', 'all', { + source: platform + }) + + const incoming = result.sessions.filter(s => normalizeSessionSource(s.source) === platform) + + setMessagingSessions(prev => [ + ...prev.filter(s => !inPlatform(s)), + ...mergeSessionPage(prev.filter(inPlatform), incoming, sessionsToKeep()) + ]) + + const total = result.total ?? incoming.length + setMessagingPlatformTotals(prev => ({ ...prev, [platform]: Math.max(total, incoming.length) })) + }, []) + + // Cron *jobs* drive the sidebar "Cron jobs" section. Jobs are created + // synchronously (agent tool call or the cron UI), so refreshing here right + // after an agent turn surfaces a new job immediately; the interval poll keeps + // next-run/state fresh as the scheduler advances them. + const refreshCronJobs = useCallback(async () => { + try { + const jobs = await getCronJobs() + + setCronJobs(jobs) + } catch { + // Non-fatal: the cron section just keeps its last-known jobs. + } + }, []) + + const refreshSessions = useCallback(async () => { + const requestId = refreshSessionsRequestRef.current + 1 + refreshSessionsRequestRef.current = requestId + setSessionsLoading(true) + + try { + const limit = $sessionsLimit.get() + + // Require at least one message so abandoned/empty "Untitled" drafts (one + // was created per TUI/desktop launch before the lazy-create fix) don't + // clutter the sidebar. + // Unified cross-profile list (served read-only off each profile's + // state.db; no per-profile backend is spawned). Single-profile users get + // the same rows tagged profile="default". Cron sessions are excluded here + // and fetched separately (refreshCronSessions) so the scheduler's + // always-newest rows can't consume the recents page budget. + // Scope the fetch to the active profile (not always 'all') so a profile + // with few recent sessions isn't windowed out of the cross-profile + // recency page — the empty-history-on-profile-switch bug. + const sessionProfile = profileScope === ALL_PROFILES ? 'all' : profileScope + + const result = await listAllProfileSessions(limit, 1, 'exclude', 'recent', sessionProfile, { + excludeSources: SIDEBAR_EXCLUDED_SOURCES + }) + + if (refreshSessionsRequestRef.current === requestId) { + setSessions(prev => mergeSessionPage(prev, result.sessions, sessionsToKeep())) + setSessionsTotal(typeof result.total === 'number' ? result.total : result.sessions.length) + setSessionProfileTotals(result.profile_totals ?? {}) + } + } finally { + if (refreshSessionsRequestRef.current === requestId) { + setSessionsLoading(false) + } + } + + void refreshCronSessions() + void refreshCronJobs() + void refreshMessagingSessions() + }, [profileScope, refreshCronSessions, refreshCronJobs, refreshMessagingSessions]) + + const loadMoreSessions = useCallback(async () => { + bumpSessionsLimit() + await refreshSessions() + }, [refreshSessions]) + + // ALL-profiles view pages one profile at a time: fetch that profile's next + // page and merge it in place, leaving every other profile's rows untouched. + const loadMoreSessionsForProfile = useCallback(async (profile: string) => { + const key = normalizeProfileKey(profile) + const inKey = (s: SessionInfo) => normalizeProfileKey(s.profile) === key + const loaded = $sessions.get().filter(inKey).length + + const result = await listAllProfileSessions(loaded + SIDEBAR_SESSIONS_PAGE_SIZE, 1, 'exclude', 'recent', key, { + excludeSources: SIDEBAR_EXCLUDED_SOURCES + }) + + const keep = sessionsToKeep(key) + + setSessions(prev => [ + ...prev.filter(s => !inKey(s)), + ...mergeSessionPage(prev.filter(inKey), result.sessions, keep) + ]) + + const total = result.profile_totals?.[key] ?? result.total ?? result.sessions.length + setSessionProfileTotals(prev => ({ ...prev, [key]: Math.max(total, result.sessions.length) })) + }, []) + + return { + loadMoreMessagingForPlatform, + loadMoreSessions, + loadMoreSessionsForProfile, + refreshCronJobs, + refreshSessions + } +} diff --git a/apps/desktop/src/app/settings/appearance-settings.tsx b/apps/desktop/src/app/settings/appearance-settings.tsx index ffa41c5af4e0..f9fbc5462240 100644 --- a/apps/desktop/src/app/settings/appearance-settings.tsx +++ b/apps/desktop/src/app/settings/appearance-settings.tsx @@ -17,7 +17,8 @@ import { $toolViewMode, setToolViewMode } from '@/store/tool-view' import { $translucency, setTranslucency } from '@/store/translucency' import { getBaseColors, useTheme } from '@/themes/context' import { installVscodeThemeFromMarketplace } from '@/themes/install' -import { isUserTheme, removeUserTheme } from '@/themes/user-themes' +import type { DesktopTheme } from '@/themes/types' +import { $marketplaceInstalls, isUserTheme, removeUserTheme } from '@/themes/user-themes' import { MODE_OPTIONS } from './constants' import { PetSettings } from './pet-settings' @@ -82,18 +83,17 @@ const compactNumber = new Intl.NumberFormat(undefined, { notation: 'compact', ma */ function MarketplaceThemeResults({ query, - installedExtIds, + installs, onInstalled }: { query: string - installedExtIds: Set + installs: ReadonlyMap onInstalled: (name: string) => void }) { const { t } = useI18n() const copy = t.commandCenter.installTheme const debounced = useDebounced(query.trim(), 300) const [installingId, setInstallingId] = useState(null) - const [installedHere, setInstalledHere] = useState>({}) const [error, setError] = useState(null) const search = useQuery({ @@ -103,6 +103,20 @@ function MarketplaceThemeResults({ staleTime: 5 * 60 * 1000 }) + // Already installed → just re-activate it; never re-download what we have. + const select = (item: DesktopMarketplaceSearchItem) => { + const owned = installs.get(item.extensionId) + + if (owned) { + triggerHaptic('crisp') + onInstalled(owned.name) + + return + } + + void install(item) + } + const install = async (item: DesktopMarketplaceSearchItem) => { if (installingId) { return @@ -115,7 +129,6 @@ function MarketplaceThemeResults({ const theme = await installVscodeThemeFromMarketplace(item.extensionId) triggerHaptic('crisp') - setInstalledHere(prev => ({ ...prev, [item.extensionId]: true })) onInstalled(theme.name) } catch (e) { setError(e instanceof Error ? e.message : copy.error) @@ -173,7 +186,7 @@ function MarketplaceThemeResults({
{results.map(item => { const busy = installingId === item.extensionId - const done = installedHere[item.extensionId] || installedExtIds.has(item.extensionId) + const done = installs.has(item.extensionId) return (
)} - setTheme(name)} - query={query} - /> + setTheme(name)} query={query} />
{showProfileNote && (

diff --git a/apps/desktop/src/app/settings/pet-settings.tsx b/apps/desktop/src/app/settings/pet-settings.tsx index ba4c10f52247..1ee2dc4070f7 100644 --- a/apps/desktop/src/app/settings/pet-settings.tsx +++ b/apps/desktop/src/app/settings/pet-settings.tsx @@ -13,7 +13,7 @@ import { triggerHaptic } from '@/lib/haptics' import { Download, Loader2, PawPrint, Pencil, Trash2 } from '@/lib/icons' import { selectableCardClass } from '@/lib/selectable-card' import { cn } from '@/lib/utils' -import { $petInfo } from '@/store/pet' +import { $petInfo, $petRoam, setPetRoam } from '@/store/pet' import { $petBusy, $petGallery, @@ -54,6 +54,7 @@ export function PetSettings() { const error = useStore($petGalleryError) const busySlug = useStore($petBusy) const petInfo = useStore($petInfo) + const roam = useStore($petRoam) const [query, setQuery] = useState('') const [confirmDelete, setConfirmDelete] = useState(null) const [renameTarget, setRenameTarget] = useState(null) @@ -279,6 +280,26 @@ export function PetSettings() { title={copy.scaleTitle} /> )} + + {enabled && ( + { + setPetRoam(id === 'on') + triggerHaptic('crisp') + }} + options={[ + { id: 'off', label: copy.off }, + { id: 'on', label: copy.on } + ]} + value={roam ? 'on' : 'off'} + /> + } + description={copy.roamDesc} + title={copy.roamTitle} + /> + )}

- +
{canDisconnect && ( diff --git a/apps/desktop/src/app/shell/context-usage-panel.tsx b/apps/desktop/src/app/shell/context-usage-panel.tsx new file mode 100644 index 000000000000..5343515ef046 --- /dev/null +++ b/apps/desktop/src/app/shell/context-usage-panel.tsx @@ -0,0 +1,147 @@ +import { useEffect, useMemo, useState } from 'react' + +import { useI18n } from '@/i18n' +import { formatK } from '@/lib/statusbar' +import { cn } from '@/lib/utils' +import type { ContextBreakdown, ContextUsageCategory, UsageStats } from '@/types/hermes' + +interface ContextUsagePanelProps { + currentUsage: UsageStats + requestGateway: (method: string, params?: Record) => Promise + sessionId: string | null +} + +export function ContextUsagePanel({ currentUsage, requestGateway, sessionId }: ContextUsagePanelProps) { + const { t } = useI18n() + const copy = t.shell.statusbar.contextUsagePanel + const [breakdown, setBreakdown] = useState(null) + const [loading, setLoading] = useState(false) + + useEffect(() => { + if (!sessionId) { + setBreakdown(null) + setLoading(false) + return + } + + let cancelled = false + setLoading(true) + + void requestGateway('session.context_breakdown', { session_id: sessionId }) + .then(data => { + if (!cancelled) { + setBreakdown(data) + } + }) + .catch(() => { + if (!cancelled) { + setBreakdown(null) + } + }) + .finally(() => { + if (!cancelled) { + setLoading(false) + } + }) + + return () => { + cancelled = true + } + }, [requestGateway, sessionId]) + + const contextMax = breakdown?.context_max ?? currentUsage.context_max ?? 0 + const contextUsed = breakdown?.context_used ?? currentUsage.context_used ?? 0 + const contextPercent = Math.max( + 0, + Math.min(100, Math.round(breakdown?.context_percent ?? currentUsage.context_percent ?? 0)) + ) + + const categories = useMemo( + () => + (breakdown?.categories ?? []).map(category => ({ + ...category, + label: copy.categories[category.id as keyof typeof copy.categories] ?? category.label + })), + [breakdown?.categories, copy.categories] + ) + + const segmentTotal = categories.reduce((sum, category) => sum + category.tokens, 0) || contextUsed || 1 + + return ( +
+
+

{copy.title}

+ + + {copy.tokenSummary(`~${formatK(contextUsed)}`, formatK(contextMax))} + +
+ +

{copy.percentFull(contextPercent)}

+ + + +
    + {categories.map(category => ( +
  • + + + + {category.label} + + + {formatCategoryTokens(category.tokens)} +
  • + ))} +
+ + {loading &&

{copy.loading}

} + + {!loading && !categories.length &&

{copy.empty}

} +
+ ) +} + +function ContextUsageBar({ + categories, + segmentTotal +}: { + categories: readonly ContextUsageCategory[] + segmentTotal: number +}) { + return ( +
+ {categories.map(category => ( + + ))} +
+ ) +} + +function formatCategoryTokens(value: number): string { + if (!Number.isFinite(value) || value <= 0) { + return '0' + } + + if (value >= 1_000) { + return `${formatK(value)}` + } + + return value.toLocaleString() +} diff --git a/apps/desktop/src/app/shell/hooks/use-overlay-routing.ts b/apps/desktop/src/app/shell/hooks/use-overlay-routing.ts index d4b0d2130f55..01873b08dd69 100644 --- a/apps/desktop/src/app/shell/hooks/use-overlay-routing.ts +++ b/apps/desktop/src/app/shell/hooks/use-overlay-routing.ts @@ -2,7 +2,14 @@ import { useCallback, useEffect, useMemo, useRef } from 'react' import { useLocation, useNavigate } from 'react-router-dom' import { type CommandCenterSection } from '@/app/command-center' -import { AGENTS_ROUTE, appViewForPath, COMMAND_CENTER_ROUTE, isOverlayView, NEW_CHAT_ROUTE } from '@/app/routes' +import { + AGENTS_ROUTE, + appViewForPath, + COMMAND_CENTER_ROUTE, + isOverlayView, + NEW_CHAT_ROUTE, + STARMAP_ROUTE +} from '@/app/routes' const SECTIONS = ['sessions', 'system', 'usage'] as const @@ -14,6 +21,7 @@ export function useOverlayRouting() { const settingsOpen = currentView === 'settings' const commandCenterOpen = currentView === 'command-center' const agentsOpen = currentView === 'agents' + const starmapOpen = currentView === 'starmap' const cronOpen = currentView === 'cron' const profilesOpen = currentView === 'profiles' const chatOpen = currentView === 'chat' @@ -53,6 +61,7 @@ export function useOverlayRouting() { }, [closeOverlayToPreviousRoute, commandCenterOpen, navigate]) const openAgents = useCallback(() => navigate(AGENTS_ROUTE), [navigate]) + const openStarmap = useCallback(() => navigate(STARMAP_ROUTE), [navigate]) return { agentsOpen, @@ -64,8 +73,10 @@ export function useOverlayRouting() { currentView, openAgents, openCommandCenterSection, + openStarmap, profilesOpen, settingsOpen, + starmapOpen, toggleCommandCenter } } diff --git a/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx b/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx index b6328be65436..753c3893bbd5 100644 --- a/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx +++ b/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx @@ -3,6 +3,7 @@ import { useCallback, useMemo } from 'react' import type { CommandCenterSection } from '@/app/command-center' import { $terminalTakeover, setTerminalTakeover } from '@/app/right-sidebar/store' +import { ContextUsagePanel } from '@/app/shell/context-usage-panel' import { GatewayMenuPanel } from '@/app/shell/gateway-menu-panel' import { Codicon } from '@/components/ui/codicon' import { GlyphSpinner } from '@/components/ui/glyph-spinner' @@ -365,8 +366,13 @@ export function useStatusbarItems({ hidden: !contextUsage, id: 'context-usage', label: contextUsage, - title: copy.contextUsage, - variant: 'text' + menuAlign: 'end', + menuClassName: 'w-auto border-(--ui-stroke-secondary) p-0', + menuContent: ( + + ), + title: copy.openContextUsage, + variant: 'menu' }, { detail: , @@ -402,18 +408,21 @@ export function useStatusbarItems({ ...(backendVersionItem ? [backendVersionItem] : []) ], [ + activeSessionId, + backendVersionItem, busy, chatOpen, + clientVersionItem, contextBar, contextUsage, copy, + currentUsage, + requestGateway, sessionStartedAt, showYoloToggle, terminalTakeover, toggleYolo, turnStartedAt, - clientVersionItem, - backendVersionItem, yoloActive ] ) diff --git a/apps/desktop/src/app/shell/statusbar-controls.tsx b/apps/desktop/src/app/shell/statusbar-controls.tsx index f33099a6f82e..9a9f0980884b 100644 --- a/apps/desktop/src/app/shell/statusbar-controls.tsx +++ b/apps/desktop/src/app/shell/statusbar-controls.tsx @@ -2,7 +2,7 @@ import { type ComponentProps, type ReactNode, useState } from 'react' import { useNavigate } from 'react-router-dom' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' -import { Tip } from '@/components/ui/tooltip' +import { Tip, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' import { cn } from '@/lib/utils' // Shared chrome styling for interactive statusbar items (button / link / menu @@ -64,6 +64,7 @@ export function StatusbarControls({ className, leftItems = [], items = [], ...pr 'flex h-5 shrink-0 items-stretch justify-between gap-2 border-t border-(--ui-stroke-tertiary) bg-(--ui-sidebar-surface-background) px-1 py-0 text-(--ui-text-tertiary) [-webkit-app-region:no-drag]', className )} + data-slot="statusbar" {...props} > {/* `overflow-x-clip` (not `overflow-x-auto`) so a wide status item — for @@ -100,60 +101,76 @@ function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate: ) if (item.variant === 'menu' && (item.menuContent || (item.menuItems && item.menuItems.length > 0))) { + // The `Tip` helper can't wrap a menu: its TooltipTrigger needs a DOM child, + // but DropdownMenu's Root renders no element, so the hover listeners never + // land on the button and the tooltip silently never shows. Compose the two + // trigger Slots directly onto the same + + ) + return ( - - - - - - - {item.menuContent - ? typeof item.menuContent === 'function' - ? item.menuContent(() => setMenuOpen(false)) - : item.menuContent - : (item.menuItems ?? []) - .filter(menuItem => !menuItem.hidden) - .map(menuItem => ( - { - if (menuItem.to) { - navigate(menuItem.to) - } - - menuItem.onSelect?.() - }} - > - {menuItem.href ? ( - - {menuItem.icon} - {menuItem.label} - - ) : ( - <> - {menuItem.icon} - {menuItem.label} - - )} - - ))} - - - + + {item.title ? ( + + + {trigger} + {item.title} + + + ) : ( + trigger + )} + + {item.menuContent + ? typeof item.menuContent === 'function' + ? item.menuContent(() => setMenuOpen(false)) + : item.menuContent + : (item.menuItems ?? []) + .filter(menuItem => !menuItem.hidden) + .map(menuItem => ( + { + if (menuItem.to) { + navigate(menuItem.to) + } + + menuItem.onSelect?.() + }} + > + {menuItem.href ? ( + + {menuItem.icon} + {menuItem.label} + + ) : ( + <> + {menuItem.icon} + {menuItem.label} + + )} + + ))} + + ) } diff --git a/apps/desktop/src/app/starmap/color.ts b/apps/desktop/src/app/starmap/color.ts new file mode 100644 index 000000000000..0d5e44480717 --- /dev/null +++ b/apps/desktop/src/app/starmap/color.ts @@ -0,0 +1,126 @@ +import { BLACK, MODE_DEFAULTS } from './constants' +import { clamp } from './geometry' +import type { Palette, Rgb } from './types' + +// Theme tokens come through `color-mix()`/oklch, so getComputedStyle returns a +// non-rgb() string. Rasterize through a 1x1 canvas to get real sRGB bytes — +// naive string parsing of oklab()/color(srgb …) silently yields black. +let _probe: CanvasRenderingContext2D | null = null + +export function resolveRgb(color: string): Rgb { + if (!_probe) { + const c = document.createElement('canvas') + c.width = 1 + c.height = 1 + _probe = c.getContext('2d', { willReadFrequently: true }) + } + + if (!_probe) { + return { b: 184, g: 163, r: 148 } + } + + _probe.clearRect(0, 0, 1, 1) + _probe.fillStyle = '#888888' + _probe.fillStyle = color + _probe.fillRect(0, 0, 1, 1) + const d = _probe.getImageData(0, 0, 1, 1).data + + return { b: d[2], g: d[1], r: d[0] } +} + +export function rgba(c: Rgb, a: number): string { + return `rgba(${c.r},${c.g},${c.b},${a})` +} + +export function mixRgb(a: Rgb, b: Rgb, t: number): Rgb { + const p = clamp(t, 0, 1) + + return { + b: Math.round(a.b + (b.b - a.b) * p), + g: Math.round(a.g + (b.g - a.g) * p), + r: Math.round(a.r + (b.r - a.r) * p) + } +} + +export function darken(c: Rgb, amount: number): Rgb { + return mixRgb(c, BLACK, amount) +} + +export function luminance(r: number, g: number, b: number): number { + return (0.2126 * r + 0.7152 * g + 0.114 * b) / 255 +} + +function rgbToHsl(c: Rgb): [number, number, number] { + const r = c.r / 255 + const g = c.g / 255 + const b = c.b / 255 + const max = Math.max(r, g, b) + const min = Math.min(r, g, b) + const l = (max + min) / 2 + const d = max - min + let h = 0 + let s = 0 + + if (d) { + s = l > 0.5 ? d / (2 - max - min) : d / (max + min) + h = (max === r ? (g - b) / d + (g < b ? 6 : 0) : max === g ? (b - r) / d + 2 : (r - g) / d + 4) * 60 + } + + return [h, s, l] +} + +function hslToRgb(h: number, s: number, l: number): Rgb { + const hue = ((h % 360) + 360) % 360 + const c = (1 - Math.abs(2 * l - 1)) * s + const x = c * (1 - Math.abs(((hue / 60) % 2) - 1)) + const m = l - c / 2 + + const [r, g, b] = + hue < 60 ? [c, x, 0] : hue < 120 ? [x, c, 0] : hue < 180 ? [0, c, x] : hue < 240 ? [0, x, c] : hue < 300 ? [x, 0, c] : [c, 0, x] + + return { b: Math.round((b + m) * 255), g: Math.round((g + m) * 255), r: Math.round((r + m) * 255) } +} + +// Complementary ink: rotate the source hue (the theme primary) and keep it vivid +// so memories read as a distinct color from skills, in any theme. +function complementaryInk(c: Rgb): Rgb { + const [h, s, l] = rgbToHsl(c) + + return hslToRgb(h + 165, Math.max(s, 0.5), clamp(l, 0.5, 0.7)) +} + +// Memory ink: the complementary hue muted toward the overlay background so it +// reads as a distinct-but-quiet color (fake alpha), not a loud full-sat pop. +export function memoryInkFor(primary: Rgb, bg: Rgb): Rgb { + return mixRgb(complementaryInk(primary), bg, 0.45) +} + +// Resolve the theme-derived palette once per theme change — the resolveRgb probe +// does a getImageData readback, so this stays out of the per-frame path. Node +// groups borrow restrained tint from the theme; structure stays foreground ink. +export function computePalette(canvas: HTMLCanvasElement): Palette { + const style = getComputedStyle(canvas) + const fg = resolveRgb(style.color) + const darkTheme = luminance(fg.r, fg.g, fg.b) > 0.55 + const base: Rgb = darkTheme ? { b: 255, g: 255, r: 255 } : { b: 0, g: 0, r: 0 } + const primary = resolveRgb(style.getPropertyValue('--theme-primary').trim() || style.color) + + const bg = resolveRgb( + style.getPropertyValue('--background').trim() || style.getPropertyValue('--dt-background').trim() || (darkTheme ? '#000' : '#fff') + ) + + return { + // Band tint derives from the theme primary so rings read consistently in + // both modes (foreground ink would go white on dark / black on light). + bandInk: mixRgb(primary, base, darkTheme ? 0.3 : 0), + base, + bg, + c: MODE_DEFAULTS[darkTheme ? 'dark' : 'light'], + chipBg: darkTheme ? 'rgba(0,0,0,0.72)' : 'rgba(255,255,255,0.85)', + darkTheme, + inkInv: darkTheme ? 'rgba(0,0,0,1)' : 'rgba(255,255,255,1)', + memoryInk: memoryInkFor(primary, bg), + primary, + skillInk: mixRgb(primary, base, darkTheme ? 0.12 : 0.18) + } +} diff --git a/apps/desktop/src/app/starmap/constants.ts b/apps/desktop/src/app/starmap/constants.ts new file mode 100644 index 000000000000..02f44ab49869 --- /dev/null +++ b/apps/desktop/src/app/starmap/constants.ts @@ -0,0 +1,62 @@ +import type { StarmapNode } from '@/types/hermes' + +import type { GraphParams, Rgb, RingParams, Shape } from './types' + +// ── Disk geometry ──────────────────────────────────────────────────────────── +export const RING_INNER = 58 +export const RING_OUTER = 340 +export const ZOOM_MIN = 0.3 +export const ZOOM_MAX = 5 +export const FIT_PADDING = 80 +export const TILT = 1 // vertical squash → "looking down at a tilted disk" +export const RING_STEPS = 4 + +export const WHITE: Rgb = { b: 255, g: 255, r: 255 } +export const BLACK: Rgb = { b: 0, g: 0, r: 0 } + +// Fixed recency (age) gradient — old content quiet, recent content bright. +export const AGE_GRADIENT = { mid: 0.52, midInk: 0.74, newInk: 0.95, oldInk: 0.42, reach: 1 } + +// Node glyph per kind — pure path geometry (the seam a future sprite/instanced +// renderer would bake from). +export const NODE_SHAPE: Record = { memory: 'diamond', skill: 'circle' } + +// Darken the orb body so a bright primary doesn't swallow the sheen (the +// highlight is computed from the original ink, so it still reads). +export const ORB_DARKEN = 0.3 + +// Sheen forced this high when the orb ink is near-white (a white body needs a +// pure-white core to read as a sphere at all). +export const WHITEISH_SHEEN = 0.95 + +// Flat wash alpha for a lit (hovered/selected) date's band. The focused ring +// outline derives from this (×2). +export const LIT_BAND_ALPHA = 0.04 + +export const MODE_DEFAULTS: Record<'dark' | 'light', GraphParams> = { + dark: { + lineAlpha: 0.12, + lineDash: 1.5, + lineDashed: true, + lineWidth: 0.5, + ringAlpha: 0.1, + ringDash: 4, + ringDashed: false, + ringWidth: 1.5 + }, + light: { + lineAlpha: 0.18, + lineDash: 1.5, + lineDashed: true, + lineWidth: 0.5, + ringAlpha: 0.06, + ringDash: 4, + ringDashed: false, + ringWidth: 2 + } +} + +export const RING_PARAMS: Record<'dark' | 'light', RingParams> = { + dark: { bandAlpha: 0.01, lightSize: 0.64, ringAlpha: 0.03, sheen: 0.12 }, + light: { bandAlpha: 0.03, lightSize: 0.27, ringAlpha: 0.028, sheen: 0.1 } +} diff --git a/apps/desktop/src/app/starmap/geometry.ts b/apps/desktop/src/app/starmap/geometry.ts new file mode 100644 index 000000000000..c4395e66b0c3 --- /dev/null +++ b/apps/desktop/src/app/starmap/geometry.ts @@ -0,0 +1,132 @@ +import type { StarmapNode } from '@/types/hermes' + +import { AGE_GRADIENT, FIT_PADDING, RING_INNER, RING_OUTER, TILT, ZOOM_MAX, ZOOM_MIN } from './constants' +import type { Ring, Shape, Viewport } from './types' + +export function clamp(v: number, lo: number, hi: number): number { + return Math.max(lo, Math.min(hi, v)) +} + +// FNV-1a — stable per-id seed for layout angle / starfield. +export function hash(input: string): number { + let h = 2166136261 + + for (let i = 0; i < input.length; i += 1) { + h ^= input.charCodeAt(i) + h = Math.imul(h, 16777619) + } + + return h >>> 0 +} + +export function nodeRadius(n: StarmapNode): number { + if (n.kind === 'memory') { + return 4.4 + } + + const base = n.state === 'archived' || n.state === 'stale' ? 2.4 : 3 + + return base + Math.sqrt(Math.max(0, n.useCount)) * 0.55 + (n.pinned ? 0.8 : 0) +} + +// Smoothstep recency → ink alpha along the age gradient. +export function recencyInk(rec: number): number { + const reach = Math.max(0.01, AGE_GRADIENT.reach) + const mid = clamp(AGE_GRADIENT.mid, 0.01, 0.99) + const t = clamp(rec / reach, 0, 1) + + if (t <= mid) { + const p = t / mid + + return AGE_GRADIENT.oldInk + (AGE_GRADIENT.midInk - AGE_GRADIENT.oldInk) * (p * p * (3 - 2 * p)) + } + + const p = (t - mid) / (1 - mid) + + return AGE_GRADIENT.midInk + (AGE_GRADIENT.newInk - AGE_GRADIENT.midInk) * (p * p * (3 - 2 * p)) +} + +// Trace a centred geometric shape of radius r into the current path. +export function shapePath(ctx: CanvasRenderingContext2D, shape: Shape, x: number, y: number, r: number): void { + ctx.beginPath() + + if (shape === 'square') { + ctx.rect(x - r, y - r, r * 2, r * 2) + + return + } + + if (shape === 'circle') { + ctx.arc(x, y, r, 0, Math.PI * 2) + + return + } + + const pts = shape === 'diamond' ? 4 : shape === 'triangle' ? 3 : 6 + // Diamond/triangle point up; hexagon is flat-topped. + const rot = shape === 'hexagon' ? Math.PI / 6 : -Math.PI / 2 + + for (let i = 0; i < pts; i += 1) { + const a = rot + (i / pts) * Math.PI * 2 + const px = x + Math.cos(a) * r + const py = y + Math.sin(a) * r + + if (i === 0) { + ctx.moveTo(px, py) + } else { + ctx.lineTo(px, py) + } + } + + ctx.closePath() +} + +// Center the tilted disk in the viewport at a fit zoom. `outer` is the radius to +// fit (defaults to the full disk); the scrubber passes the revealed extent so the +// camera tightens at the core and zooms out as the rings grow. +export function fitViewport(w: number, h: number, outer: number = RING_OUTER): Viewport { + if (w <= 0 || h <= 0) { + return { k: 1, x: w / 2, y: h / 2 } + } + + // Fit zoom for a disk of radius r into this viewport (capped at 2.2× zoom-in). + const kFor = (r: number): number => { + const spanX = (r + 30) * 2 + + return Math.min((w - FIT_PADDING * 2) / spanX, (h - FIT_PADDING * 2) / (spanX * TILT), 2.2) + } + + // Never zoom out past the reference (RING_OUTER / 5-ring) extent: a bigger map + // renders at that constant scale and overflows — you pan it — instead of + // shrinking every node to fit. Smaller extents (few rings, or the playback + // core) still fit tightly / zoom in. + const k = clamp(Math.max(kFor(outer), kFor(RING_OUTER)), ZOOM_MIN, ZOOM_MAX) + + // Bias the center down a touch — the timeline along the top adds visual weight + // up there, so true-center reads as sitting high. + return { k, x: w / 2, y: h / 2 + h * 0.05 } +} + +// Target radius for a node at recency `rec` (oldest at the core), scaled to a +// disk of the given outer radius. +export function radiusForRecency(rec: number, outer: number = RING_OUTER): number { + return RING_INNER + rec * (outer - RING_INNER) +} + +// Screen-space scale at the graph's fully-rested fit. Nodes size against THIS, +// not the live (playback) camera — so a spore-zoom moves WHERE they sit, not how +// big they read (billboarded), while a full-map view keeps its honest density. +export const fitScale = (w: number, h: number, rings: Ring[]): number => + fitViewport(w, h, rings.at(-1)?.r ?? RING_OUTER).k + +// Squared distance from point (px,py) to segment a→b — for cheap link hit-tests. +export function distToSegmentSq(px: number, py: number, ax: number, ay: number, bx: number, by: number): number { + const dx = bx - ax + const dy = by - ay + const len = dx * dx + dy * dy + const t = len ? clamp(((px - ax) * dx + (py - ay) * dy) / len, 0, 1) : 0 + const cx = ax + dx * t + const cy = ay + dy * t + + return (px - cx) ** 2 + (py - cy) ** 2 +} diff --git a/apps/desktop/src/app/starmap/index.tsx b/apps/desktop/src/app/starmap/index.tsx new file mode 100644 index 000000000000..7603006d8f88 --- /dev/null +++ b/apps/desktop/src/app/starmap/index.tsx @@ -0,0 +1,53 @@ +import { useStore } from '@nanostores/react' +import { useEffect, useState } from 'react' + +import { PageLoader } from '@/components/page-loader' +import { useI18n } from '@/i18n' +import { $starmapError, $starmapGraph, $starmapLoading, loadStarmapGraph } from '@/store/starmap' +import type { StarmapGraph } from '@/types/hermes' + +import { Panel, PanelEmpty } from '../overlays/panel' + +import { StarMap } from './star-map' + +// Star map overlay: a top-down map of what Hermes has learned for a profile, +// over a radial time axis. Data is fetched on demand into the $starmap* atoms; +// the map itself lives in ./star-map. The chrome is owned by the map itself +// (timeline scrubber + legend float over the canvas), so there's no panel +// header here. +export function StarmapView({ onClose }: { onClose: () => void }) { + const { t } = useI18n() + const graph = useStore($starmapGraph) + const loading = useStore($starmapLoading) + const error = useStore($starmapError) + + // A pasted share code populates the map with someone else's (or an exported) + // graph, overriding the live profile scan. Cleared by "back to my map" and + // whenever a fresh profile graph loads in. + const [imported, setImported] = useState(null) + + useEffect(() => { + void loadStarmapGraph() + }, []) + + // Drop a stale import when the underlying profile graph changes out from under it. + useEffect(() => { + setImported(null) + }, [graph]) + + const shown = imported ?? graph + + return ( + + {error ? ( + + ) : !shown && loading ? ( + + ) : shown && shown.nodes.length === 0 && !imported ? ( + + ) : shown ? ( + setImported(null)} /> + ) : null} + + ) +} diff --git a/apps/desktop/src/app/starmap/render.ts b/apps/desktop/src/app/starmap/render.ts new file mode 100644 index 000000000000..12e26eab1719 --- /dev/null +++ b/apps/desktop/src/app/starmap/render.ts @@ -0,0 +1,864 @@ +import { darken, luminance, mixRgb, rgba } from './color' +import { + LIT_BAND_ALPHA, + NODE_SHAPE, + ORB_DARKEN, + RING_INNER, + RING_PARAMS, + TILT, + WHITE, + WHITEISH_SHEEN +} from './constants' +import { clamp, fitScale, nodeRadius, recencyInk, shapePath } from './geometry' +import { countLabel, ellipsize, metaBadges, nodeFooter, wrapText } from './text' +import type { + FadeBuckets, + MemoryCard, + Palette, + Rect, + Rgb, + Ring, + RingLabelRect, + SimLink, + SimNode, + Viewport +} from './types' + +export interface Scene { + adjacency: Map> + byId: Map + ctx: CanvasRenderingContext2D + dpr: number + fades: FadeBuckets + focusId: null | string + hoverId: null | string + hoverLink: null | string + hoverRing: null | number + links: SimLink[] + memById: Map + nodes: SimNode[] + palette: Palette + // Time scrubber: only paint nodes/links whose recency has been reached. 1 = + // everything (the default, idle state); lower values "build up" the map. + reveal: number + rings: Ring[] + selectedRing: null | number + size: { h: number; w: number } + // Scrub jumps: snap every ease to its target this frame (no birth/fade replay). + snapMotion?: boolean + vp: Viewport +} + +export interface DrawResult { + animating: boolean + ringLabelRects: RingLabelRect[] +} + +// Smoothstep — eases the birth animations (position grow-out) in and out. +const ease = (t: number): number => { + const u = t < 0 ? 0 : t > 1 ? 1 : t + + return u * u * (3 - 2 * u) +} + +// EVE-style warp arrival for node births: the star streaks outward fast, then +// decelerates hard (exponential ease-out) and drops onto its ring — like a ship +// dropping out of warp. WARP_FROM is how deep toward the core it launches from. +const WARP_FROM = 0.32 + +const warpIn = (t: number): number => { + const u = t < 0 ? 0 : t > 1 ? 1 : t + + return u >= 1 ? 1 : 1 - 2 ** (-9 * u) +} + +// Layered birth speeds for the scrubber's parallax: rings expand slowly and +// grandly in the background, stars pop in quicker up front — both well below the +// default hover/focus speeds so the build-up reads as a cinematic settle. +const RING_BIRTH = { down: 0.055, up: 0.032 } +const NODE_BIRTH = { down: 0.11, up: 0.075 } + +// Glyph pool for the empty-core scramble: Matrix-style half-width katakana plus +// a few digits/symbols for the "digital rain / decoding" look. +const SCRAMBLE_CHARS = 'ハヒフヘホマミムメモヤユヨラリルレワンヲアウエオカキケコサシスセタチツテナニヌネ0123456789:.=*+<>Ξ╳' + +// Sphere-sprite atlas: a lit orb is the same picture at every size, so we render +// each distinct (ink, sheen, darken) appearance ONCE into an offscreen sprite and +// blit it (scaled) per node — instead of allocating a fresh radial gradient for +// every star on every frame. Keyed by appearance, not size; drawImage scales it. +// Reference radius the sprite is rendered at — larger than the usual billboarded +// screen-space orb, so sprites scale down in normal use and stay crisp. +const SPRITE_R = 96 + +const spriteCache = new Map() + +// Build (or fetch) the orb sprite for one appearance: an offset radial gradient +// from a hot core → darkened body → translucent rim, clipped to the disk, so a +// flat circle reads with volume. `strength` is how white the core is; `bodyDarken` +// darkens the body (0 for active/hover nodes so they pop full bright). Near-white +// inks skip the darken and force a near-full sheen so the white core still reads. +function sphereSprite(ink: Rgb, strength: number, bodyDarken: number): HTMLCanvasElement { + const key = `${ink.r},${ink.g},${ink.b}|${strength}|${bodyDarken}` + const cached = spriteCache.get(key) + + if (cached) { + return cached + } + + const R = SPRITE_R + // Margin for the gradient's rim (extends to 1.15·R) so it isn't clipped. + const pad = Math.ceil(R * 0.15) + 1 + const size = (R + pad) * 2 + const c = R + pad + const cv = document.createElement('canvas') + cv.width = size + cv.height = size + const g2 = cv.getContext('2d') + + if (!g2) { + return cv + } + + const mx = Math.max(ink.r, ink.g, ink.b) + const mn = Math.min(ink.r, ink.g, ink.b) + const sat = mx ? (mx - mn) / mx : 0 + const whiteness = clamp((luminance(ink.r, ink.g, ink.b) - 0.7) / 0.3, 0, 1) * (1 - sat) + const eff = strength + (WHITEISH_SHEEN - strength) * whiteness + const hi = mixRgb(ink, WHITE, 0.7 * eff) + const body = darken(ink, bodyDarken * (1 - whiteness)) + const grad = g2.createRadialGradient(c - R * 0.35, c - R * 0.4, R * 0.05, c, c, R * 1.15) + grad.addColorStop(0, rgba(hi, 1)) + grad.addColorStop(0.5, rgba(body, 1)) + grad.addColorStop(1, rgba(body, 0.85)) + g2.fillStyle = grad + g2.beginPath() + g2.arc(c, c, R, 0, Math.PI * 2) + g2.fill() + spriteCache.set(key, cv) + + return cv +} + +// Paint a lit orb of radius `r` centered at (x, y) by blitting its cached sprite. +// Honors the caller's globalAlpha (drawImage multiplies it), matching the old +// gradient fill. No path needed — the sprite already carries the disk + AA rim. +function sphereFill( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + r: number, + ink: Rgb, + strength: number, + bodyDarken: number +): void { + const sprite = sphereSprite(ink, strength, bodyDarken) + const scale = r / SPRITE_R + const drawSize = sprite.width * scale + ctx.drawImage(sprite, x - drawSize / 2, y - drawSize / 2, drawSize, drawSize) +} + +const rectsOverlap = (a: Rect, b: Rect) => a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y + +// Paint a full frame of the star map. Pure given its inputs (draws to the +// canvas + advances the fade buckets); returns whether it's still animating and +// the ring-label hit rects for pointer picking. +export function drawScene(scene: Scene): DrawResult { + const { + adjacency, + byId, + ctx, + dpr, + fades, + focusId, + hoverId, + hoverLink, + hoverRing, + links, + memById, + nodes, + palette, + reveal, + rings, + selectedRing, + size, + snapMotion = false, + vp + } = scene + + // Small epsilon so a node exactly at the playhead counts as revealed. + const seen = (rec: number) => rec <= reveal + 1e-3 + // Recency for styling is RELATIVE to the newest revealed node — the current + // "present" — not the bare playhead. So a lone frontier node still reads as + // fresh (bright/full size) even with empty space between it and the scrubber. + // At reveal = 1 the frontier is the newest node, collapsing back to raw recency. + let frontier = 0 + + for (const fn of nodes) { + if (fn.rec <= reveal + 1e-3 && fn.rec > frontier) { + frontier = fn.rec + } + } + + const erec = (rec: number) => (frontier > 0 ? clamp(rec / frontier, 0, 1) : 1) + const { h, w } = size + const { bandInk, base, bg, c, chipBg, darkTheme, inkInv, memoryInk, skillInk } = palette + const { bandAlpha, lightSize, ringAlpha, sheen } = RING_PARAMS[darkTheme ? 'dark' : 'light'] + + let animating = false + const ringLabelRects: RingLabelRect[] = [] + + // Eased opacity per element: snaps up when newly highlighted, eases otherwise. + // `rates` overrides the default in/out lerp speed (the slow births pass their + // own gentler pair so the build-up reads as a graceful settle, not a flash). + const fadeAlpha = ( + bucket: Map, + key: string, + target: number, + snapUp = false, + rates?: { down: number; up: number } + ) => { + const targetAlpha = clamp(target, 0, 1) + const prev = bucket.get(key) + + // Scrub: jump straight to the target so a fast drag doesn't replay easing. + if (snapMotion) { + bucket.set(key, targetAlpha) + + return targetAlpha + } + + if (prev == null || (snapUp && targetAlpha > prev)) { + bucket.set(key, targetAlpha) + + return targetAlpha + } + + const up = rates?.up ?? 0.22 + const down = rates?.down ?? 0.32 + const rate = targetAlpha > prev ? up : down + const next = prev + (targetAlpha - prev) * rate + + if (Math.abs(next - targetAlpha) < 0.01) { + bucket.set(key, targetAlpha) + + return targetAlpha + } + + animating = true + bucket.set(key, next) + + return next + } + + const shade = (a: number) => `rgba(${base.r},${base.g},${base.b},${a})` + const projX = (wx: number) => wx * vp.k + vp.x + const projY = (wy: number) => wy * vp.k * TILT + vp.y + // Baseline node scale: the rested fit, held stable while the playback camera + // dives into the core — so t≈0 nodes don't balloon (see fitScale). + const nodeK = fitScale(w, h, rings) + + // Two composable layers: node highlight (selected ?? hovered) in full ink, and + // a selection-only ring/date filter that only shifts alpha. + const focusSet = focusId ? (adjacency.get(focusId) ?? new Set()) : null + const ringIdx = selectedRing + const ring = ringIdx != null ? (rings[ringIdx] ?? null) : null + // A selected ring owns the band it caps: previous ring → this ring. Ring 0 is + // visual-only/unlabeled, so the first selectable date naturally owns shell 0→1. + const ringLo = ring && ringIdx != null ? (rings[ringIdx - 1]?.ratio ?? 0) - 1e-3 : 0 + const ringHi = ring ? ring.ratio + 1e-3 : 1 + + ctx.setTransform(dpr, 0, 0, dpr, 0, 0) + ctx.clearRect(0, 0, w, h) + + ctx.globalAlpha = 1 + + // Tilted world transform for the disk structure. + ctx.setTransform(vp.k * dpr, 0, 0, vp.k * TILT * dpr, vp.x * dpr, vp.y * dpr) + + // The "lit" date = hovered (preview) or selected (locked) — drives the band + // flatten only; the ring outline reacts to selection. + const litRingIdx = hoverRing ?? ringIdx + + // A ring is "laid" one band AHEAD of the playhead — it appears the moment the + // scrubber enters the band beneath it (its inner neighbor's date), so the date + // gridline that caps a region is always drawn before any node in that region. + // Ring 0 is just the visual core; the first real shell still needs non-zero + // playback progress so replay starts empty instead of showing it pre-laid. + const ringSeen = (i: number) => { + const threshold = rings[i - 1]?.ratio ?? 0 + + return i === 0 || (threshold <= 0 ? reveal > 1e-3 : reveal + 1e-3 >= threshold) + } + + // Per-ring "grow out" progress (advanced once per frame, reused by bands / + // outlines / labels): a revealed ring eases its radius from its inner neighbor + // outward to its resting radius, so it expands into place instead of popping. + const ringAppear = rings.map((rg, i) => + ease(fadeAlpha(fades.appear, `ring:${i}`, ringSeen(i) ? 1 : 0, false, RING_BIRTH)) + ) + + // Direction-based origin (the sign of the reveal): a ring growing IN expands + // outward from its inner neighbour — never from the dead centre — while a ring + // fading OUT collapses all the way to the core. ringSeen is the direction tell: + // true = revealing/at rest, false = receding. + const ringDrawR = rings.map((rg, i) => { + const startR = ringSeen(i) ? (rings[i - 1]?.r ?? rg.r) : RING_INNER + + return startR + (rg.r - startR) * (ringAppear[i] ?? 1) + }) + + // Opacity envelope that stays near-full through most of the grow/shrink and + // only fades in the final stretch — so the radius TRAVEL is visible (the ring + // shrinks back into place) instead of just dimming out where it stands. + const ringVis = ringAppear.map(a => clamp(a / 0.55, 0, 1)) + + // Inter-ring bands: a theme-tinted wash sliver at the outer edge; the lit + // date's band flattens to an even wash. + if (bandAlpha > 0 || litRingIdx != null) { + for (let i = 0; i < rings.length - 1; i += 1) { + const lit = litRingIdx != null && i + 1 === litRingIdx + + if (!lit && bandAlpha <= 0) { + continue + } + + // The band tracks its outer ring's grow-in. + if ((ringAppear[i + 1] ?? 1) <= 0.01) { + continue + } + + const inner = ringDrawR[i] ?? 0 + const outer = ringDrawR[i + 1] ?? 0 + + if (lit) { + ctx.fillStyle = rgba(bandInk, LIT_BAND_ALPHA) + } else { + const grad = ctx.createRadialGradient(0, 0, inner, 0, 0, outer) + + if (darkTheme) { + // Dark: a light wash on each band's OUTER rim — reads as light catching + // a raised edge → depth. + grad.addColorStop(0, rgba(bandInk, 0)) + grad.addColorStop(clamp(1 - lightSize, 0.01, 0.99), rgba(bandInk, 0)) + grad.addColorStop(1, rgba(bandInk, bandAlpha)) + } else { + // Light: flip it — the (darker) wash sits on the INNER edge and fades + // outward, so each shell reads as recessed toward the core (depth), + // not a raised mound. + grad.addColorStop(0, rgba(bandInk, bandAlpha)) + grad.addColorStop(clamp(lightSize, 0.01, 0.99), rgba(bandInk, 0)) + grad.addColorStop(1, rgba(bandInk, 0)) + } + + ctx.fillStyle = grad + } + + ctx.beginPath() + ctx.arc(0, 0, outer, 0, Math.PI * 2) + ctx.arc(0, 0, inner, 0, Math.PI * 2, true) + ctx.fill() + } + } + + // Ring outline: brightens only on selection — the selected ring + its inner + // neighbor (the two bounding the lit band). + ctx.lineWidth = c.ringWidth / vp.k + ctx.setLineDash(c.ringDashed ? [c.ringDash / vp.k, c.ringDash / vp.k] : []) + rings.forEach((rg, i) => { + const emphasized = ringIdx != null && (i === ringIdx || i === ringIdx - 1) + // Reveal in/out rides the smooth (slow) ringAppear envelope so a ring fades + // out as gracefully as it grew in; the alpha bucket only carries the snappy + // selection emphasis. + const emphasisAlpha = emphasized ? clamp(LIT_BAND_ALPHA * 2, 0, 1) : ringAlpha + // The core ring (i 0) fades in from reveal 0 so the scramble orb starts + // un-enclosed (no outline boxing it in) and the shell appears as it plays. + const coreFade = i === 0 ? clamp(reveal / 0.08, 0, 1) : 1 + const ringAlphaNow = fadeAlpha(fades.rings, String(i), emphasisAlpha, emphasized) * (ringVis[i] ?? 1) * coreFade + + if (ringAlphaNow < 0.004) { + return + } + + ctx.strokeStyle = shade(ringAlphaNow) + ctx.beginPath() + ctx.arc(0, 0, ringDrawR[i] ?? rg.r, 0, Math.PI * 2) + ctx.stroke() + }) + ctx.setLineDash([]) + + // Screen space for the jump routes and glyphs (crisp, easy to trim). The empty + // core's animated scramble is NOT painted here — it's the only perpetually + // moving layer, so it's drawn live each frame by drawScramble() on top of the + // (cached) static scene. Everything else in this function is static until an + // input changes, which is why `animating` now reflects only in-flight fades. + ctx.setTransform(dpr, 0, 0, dpr, 0, 0) + + // Jump routes — a focused node's links stop at its selection ring. + const focusNode = focusId ? (byId.get(focusId) ?? null) : null + const focusRingR = focusNode ? nodeRadius(focusNode) * nodeK + 4 : 0 + + for (const link of links) { + const s = typeof link.source === 'object' ? link.source : byId.get(String(link.source)) + const t = typeof link.target === 'object' ? link.target : byId.get(String(link.target)) + + if (!s || !t) { + continue + } + + // A jump route only exists once both of its endpoints have ignited. + const revealed = seen(s.rec) && seen(t.rec) + + const lit = + revealed && + !!focusId && + (s.id === focusId || t.id === focusId || (!!focusSet && focusSet.has(s.id) && focusSet.has(t.id))) + + let x1 = projX(s.x) + let y1 = projY(s.y) + let x2 = projX(t.x) + let y2 = projY(t.y) + + if (s.id === focusId) { + const d = Math.hypot(x2 - x1, y2 - y1) || 1 + x1 += ((x2 - x1) / d) * focusRingR + y1 += ((y2 - y1) / d) * focusRingR + } + + if (t.id === focusId) { + const d = Math.hypot(x1 - x2, y1 - y2) || 1 + x2 += ((x1 - x2) / d) * focusRingR + y2 += ((y1 - y2) / d) * focusRingR + } + + const key = `${s.id}->${t.id}` + const ambient = recencyInk(erec((s.rec + t.rec) / 2)) * c.lineAlpha + + // Hovering a line fades it in a bit (×2, capped — never full white). + const targetAlpha = !revealed + ? 0 + : lit + ? 1 + : key === hoverLink + ? clamp(ambient * 2, 0, 0.7) + : focusId || ring + ? 0.025 + : ambient + + const linkAlpha = fadeAlpha(fades.links, key, targetAlpha, lit) + + if (linkAlpha < 0.004) { + continue + } + + ctx.strokeStyle = shade(linkAlpha) + ctx.setLineDash(lit || !c.lineDashed ? [] : [c.lineDash, c.lineDash]) + ctx.lineWidth = lit ? 1.5 : c.lineWidth + ctx.beginPath() + ctx.moveTo(x1, y1) + ctx.lineTo(x2, y2) + ctx.stroke() + } + + ctx.setLineDash([]) + + // Nodes: the node layer paints pure ink (focused node + neighbors); the date + // filter is alpha-only, so the two states compose. Track which rings have at + // least one revealed node so a ring's date only shows once it has content. + const revealedRings = new Set() + + for (const n of nodes) { + // The land comes first: a node waits for the ring that CAPS its region (its + // outer date gridline) to grow in before it ignites — so the ring is always + // drawn before any star inside it, not after. + const landLaid = (ringAppear[n.outerRingIndex] ?? 1) >= 0.5 + const revealed = seen(n.rec) && landLaid + + if (revealed) { + revealedRings.add(n.outerRingIndex) + } + + const isFocus = revealed && n.id === focusId + const isNeighbor = revealed && !!focusSet && focusSet.has(n.id) + const inRing = !!ring && n.rec >= ringLo && n.rec < ringHi + const nodeHigh = isFocus || isNeighbor + const er = erec(n.rec) + const ageScale = nodeHigh || inRing ? 1 : 0.34 + Math.min(1, er / 0.4) * 0.66 + // Stable screen-space radius: use the graph's resting fit zoom, not the + // current playback camera zoom. Full-map views keep their original density, + // while t≈0 spore-zoom no longer inflates nodes into bubbles. + const r = nodeRadius(n) * nodeK * ageScale + + const baseAlpha = nodeHigh ? 1 : ring ? (inRing ? (focusId ? 0.55 : 1) : 0.16) : focusId ? 0.16 : recencyInk(er) + const alpha = fadeAlpha(fades.nodes, n.id, revealed ? baseAlpha : 0, nodeHigh || inRing) + + // Birth fade + warp rise are coupled (slow rates) so a star grows in instead + // of flashing. Focus snaps (no drift). + const rawBorn = fadeAlpha(fades.appear, n.id, revealed ? 1 : 0, nodeHigh || inRing, NODE_BIRTH) + const born = ease(rawBorn) + const vis = alpha * born + + if (vis < 0.004) { + continue + } + + // Warp-in: streak outward from WARP_FROM·radius and decelerate hard onto the + // ring (origin = disk core), echoing an EVE ship dropping out of warp. + const posScale = WARP_FROM + (1 - WARP_FROM) * warpIn(rawBorn) + const sx = projX(n.x * posScale) + const sy = projY(n.y * posScale) + + ctx.globalAlpha = vis + const nodeInk = nodeHigh ? base : n.kind === 'memory' ? memoryInk : skillInk + const shape = NODE_SHAPE[n.kind] + + if (shape === 'circle') { + // Highlighted orbs pop full bright; others darken so the sheen reads. The + // sprite carries the disk, so no path is built for circles. + sphereFill(ctx, sx, sy, r, nodeInk, sheen, nodeHigh ? 0 : ORB_DARKEN) + } else { + shapePath(ctx, shape, sx, sy, r) + ctx.fillStyle = rgba(nodeInk, 1) + ctx.fill() + } + + if (isFocus) { + ctx.globalAlpha = 1 + ctx.strokeStyle = rgba(nodeInk, 1) + ctx.lineWidth = 1.4 + shapePath(ctx, shape, sx, sy, r + 4) + ctx.stroke() + } + } + + ctx.globalAlpha = 1 + ctx.setTransform(dpr, 0, 0, dpr, 0, 0) + + // Ring date labels (top of each ellipse) — hoverable to focus the ring. Many + // adaptive rings can crowd the top, so labels thin out: skip any that would + // land within LABEL_GAP of the last one drawn (the gridline still shows). + ctx.font = '10px ui-sans-serif, system-ui, sans-serif' + ctx.textAlign = 'center' + const LABEL_GAP = 15 + let lastLabelY = Number.POSITIVE_INFINITY + // A ring's date only shows once it actually has a revealed node — no floating + // date over a blank disk (t=0) or a lone empty ring. + rings.forEach((rg, i) => { + if (!rg.label || !revealedRings.has(i)) { + return + } + + const sx = projX(0) + // Track the growing radius so the date rides the ring as it expands out. + const sy = projY(-(ringDrawR[i] ?? rg.r)) + + if (sy < 8 || sy > h - 8 || lastLabelY - sy < LABEL_GAP) { + return + } + + lastLabelY = sy + const tw = ctx.measureText(rg.label).width + const boxW = tw + 6 + const isThis = ringIdx === i || hoverRing === i + const faded = (focusId != null || ringIdx != null) && !isThis + // The date rides the same smooth ringAppear envelope, so it recedes as + // gently as it appears; the bucket carries only the snappy focus/selection dim. + const emphasisAlpha = faded ? 0.33 : 1 + const labelAlpha = fadeAlpha(fades.labels, String(i), emphasisAlpha, isThis) * (ringVis[i] ?? 1) + + if (labelAlpha < 0.01) { + return + } + + ctx.globalAlpha = labelAlpha + ctx.fillStyle = rgba(bg, 1) + ctx.fillRect(sx - boxW / 2, sy - 6, boxW, 13) + ctx.fillStyle = shade(isThis ? 1 : 0.2) + ctx.fillText(rg.label, sx, sy + 3) + ctx.globalAlpha = 1 + // Hidden labels (mid fade-out / not yet reached) drop out of hit-testing. + ringLabelRects.push({ h: 18, i, w: boxW + 6, x: sx - boxW / 2 - 3, y: sy - 10 }) + }) + + // Tooltip on focus — measured first so its rect joins the avoidance set and + // neighbor labels route around it. + const tipNode = focusId ? byId.get(focusId) : null + const tip = tipNode && seen(tipNode.rec) ? tipNode : null + let tipRect: null | Rect = null + + if (tip) { + const PADX = 6 + const PADY = 4 + const BADGE_H = 14 + const ROW_GAP = 3 + const LINE_H = 16 + const ITEM_GAP = 8 + const badgeFont = '9px ui-sans-serif, system-ui, sans-serif' + const monoFont = '9px ui-monospace, SFMono-Regular, Menlo, monospace' + const titleFont = '600 11px ui-sans-serif, system-ui, sans-serif' + const footerFont = '9px ui-sans-serif, system-ui, sans-serif' + const FOOTER_H = 13 + // The date (index 0) stays sans; the rest of the tags are monospace. + const badgeFontFor = (i: number) => (i === 0 ? badgeFont : monoFont) + + const badges = metaBadges(tip) + const use = countLabel(tip) + const titleText = tip.kind === 'memory' ? memById.get(tip.id)?.body.split('\n')[0]?.trim() || tip.label : tip.label + + const badgeW = badges.map((b, i) => { + ctx.font = badgeFontFor(i) + + return ctx.measureText(b).width + }) + + const rowW = badgeW.reduce((a, b) => a + b, 0) + ITEM_GAP * Math.max(0, badges.length - 1) + ctx.font = monoFont + const useW = use ? ctx.measureText(use).width : 0 + const metaW = rowW + (use ? ITEM_GAP + useW : 0) + + ctx.font = titleFont + const maxTitleW = Math.min(380, w - 16) - PADX * 2 + const titleLines = wrapText(ctx, titleText, maxTitleW) + const titleW = Math.max(0, ...titleLines.map(l => ctx.measureText(l).width)) + const titleBgW = titleW + PADX * 2 + const titleBgH = titleLines.length * LINE_H + PADY * 2 + + const footerText = nodeFooter(tip) + ctx.font = footerFont + const footerW = footerText ? ctx.measureText(footerText).width : 0 + + const totalW = Math.max(metaW, footerW, titleBgW) + const totalH = BADGE_H + ROW_GAP + titleBgH + (footerText ? ROW_GAP + FOOTER_H : 0) + const bx = clamp(projX(tip.x) - totalW / 2, 4, Math.max(4, w - totalW - 4)) + const by = clamp(projY(tip.y) - (nodeRadius(tip) * nodeK + 8) - totalH, 4, Math.max(4, h - totalH - 4)) + tipRect = { h: totalH, w: totalW, x: bx, y: by } + + ctx.textAlign = 'left' + ctx.textBaseline = 'middle' + const badgeMidY = by + BADGE_H / 2 + + // Metadata row, flush at the left edge. + ctx.fillStyle = shade(0.7) + let cx = bx + badges.forEach((label, i) => { + ctx.font = badgeFontFor(i) + ctx.fillText(label, cx, badgeMidY) + cx += badgeW[i] + ITEM_GAP + }) + + if (use) { + ctx.font = monoFont + ctx.fillStyle = shade(0.5) + ctx.fillText(use, cx, badgeMidY) + } + + // Title: inverted (fg/bg flipped) so the focused tooltip pops. + const ty = by + BADGE_H + ROW_GAP + ctx.fillStyle = shade(1) + ctx.fillRect(bx, ty, titleBgW, titleBgH) + ctx.font = titleFont + ctx.fillStyle = inkInv + titleLines.forEach((line, i) => { + ctx.fillText(line, bx + PADX, ty + PADY + LINE_H * i + LINE_H / 2) + }) + + if (footerText) { + ctx.font = footerFont + ctx.fillStyle = shade(0.45) + ctx.fillText(footerText, bx, ty + titleBgH + ROW_GAP + FOOTER_H / 2) + } + + ctx.textBaseline = 'alphabetic' + } + + // Neighbor constellation labels — greedy placement that clamps to the overlay + // and dodges placed labels (date labels + tooltip) so nothing overlaps/clips. + ctx.font = '11px ui-sans-serif, system-ui, sans-serif' + ctx.textAlign = 'center' + const LBL_M = 6 + const LBL_H = 15 + const placed: Rect[] = ringLabelRects.map(r => ({ h: r.h, w: r.w, x: r.x, y: r.y })) + + if (tipRect) { + placed.push(tipRect) + } + + for (const id of focusSet ?? []) { + if (id === hoverId) { + continue + } + + const n = byId.get(id) + + if (!n || !seen(n.rec)) { + continue + } + + const label = ellipsize(ctx, n.label, Math.min(180, w * 0.32)) + const bw = ctx.measureText(label).width + 8 + const x = clamp(projX(n.x) - bw / 2, LBL_M, Math.max(LBL_M, w - bw - LBL_M)) + const top = projY(n.y) - (nodeRadius(n) * nodeK + 7) - LBL_H + 4 + const clampY = (v: number) => clamp(v, LBL_M, Math.max(LBL_M, h - LBL_H - LBL_M)) + const step = LBL_H + 3 + let y: null | number = null + + // Prefer above the node, then fan outward; skip if nothing stays clear (a + // label on the tooltip reads worse than no label). + for (let k = 0; k <= 7 && y == null; k += 1) { + for (const dy of k === 0 ? [0] : [-k * step, k * step]) { + const cand = { h: LBL_H, w: bw, x, y: clampY(top + dy) } + + if (!placed.some(p => rectsOverlap(cand, p))) { + y = cand.y + + break + } + } + } + + if (y == null) { + continue + } + + placed.push({ h: LBL_H, w: bw, x, y }) + ctx.fillStyle = chipBg + ctx.fillRect(x, y, bw, LBL_H) + ctx.fillStyle = shade(0.85) + ctx.fillText(label, x + bw / 2, y + 11) + } + + return { animating, ringLabelRects } +} + +// Glyph cells from the core's center to its rim — the target density. In the mid +// range the field is this many cells across (constant "amount of text"), and the +// glyph size tracks the camera. Bump for denser, drop for sparser. +const SCRAMBLE_RADIUS = 6 + +// Glyph size (px) is clamped to this band: the font grows with the camera but +// never balloons on a big/zoomed-in core — past the ceiling the core fills with +// MORE, smaller glyphs instead of fewer huge ones — and stays legible when tiny. +const SCRAMBLE_CELL_MIN = 5 +const SCRAMBLE_CELL_MAX = 13 + +// The empty-core scramble: a tilted, Matrix-style decoding-glyph field laid on +// the disk plane (rows squashed by TILT, clipped to the core ellipse) so the +// empty center reads as "computing", not missing. PURELY decorative — the glyphs +// are a seeded PRNG field, never derived from nodes/memories. Drawn live each +// frame on top of the cached static scene, since it's the only animated layer. +export function drawScramble({ + ctx, + dpr, + palette, + rings, + vp +}: { + ctx: CanvasRenderingContext2D + dpr: number + palette: Palette + rings: Ring[] + vp: Viewport +}): void { + const { bg, darkTheme, primary } = palette + const projX = (wx: number) => wx * vp.k + vp.x + const projY = (wy: number) => wy * vp.k * TILT + vp.y + + ctx.setTransform(dpr, 0, 0, dpr, 0, 0) + + const coreX = projX(0) + const coreY = projY(0) + // Scale with the world (like the rings), but ~1.25× bigger than the bare inner + // shell so the core reads prominently at the rested fit. + const coreRx = (rings[0]?.r ?? RING_INNER) * vp.k * 1.25 + + if (coreRx <= 0) { + return + } + + // Backdrop wash: a background-colour radial dimming the core ellipse, so on a + // busy map the nodes/links crowding through the centre recede behind the orb. + // Self-masking — bg over empty bg is invisible, so a sparse map shows no disc. + const washR = coreRx * 1.15 + ctx.save() + ctx.translate(coreX, coreY) + ctx.scale(1, TILT) + const wash = ctx.createRadialGradient(0, 0, 0, 0, 0, washR) + // Near-opaque across the core (busy graph effectively vanishes behind the orb) + // with a soft falloff only at the rim so there's no hard disc edge. + wash.addColorStop(0, rgba(bg, darkTheme ? 0.9 : 0.93)) + wash.addColorStop(0.62, rgba(bg, darkTheme ? 0.84 : 0.88)) + wash.addColorStop(1, rgba(bg, 0)) + ctx.fillStyle = wash + ctx.beginPath() + ctx.arc(0, 0, washR, 0, Math.PI * 2) + ctx.fill() + ctx.restore() + + // Target ~SCRAMBLE_RADIUS cells to the rim (camera-scaled glyphs), but clamp the + // glyph SIZE so a big/zoomed-in core scales the font DOWN — packing in more, + // smaller glyphs rather than a few giant ones — and stays legible when tiny. + const cell = clamp(coreRx / SCRAMBLE_RADIUS, SCRAMBLE_CELL_MIN, SCRAMBLE_CELL_MAX) + // Aspect-correct on the tilt: rows are spaced by the full glyph height (square + // cells, no vertical squish), but the field is clipped to the disk's ELLIPSE + // (vertical extent = coreRx * TILT), so it sits on the tilted plane while the + // glyphs themselves stay un-squished. Fewer rows fit vertically — that's it. + const coreRy = coreRx * TILT + const half = Math.max(3, Math.round(coreRx / cell)) + const now = performance.now() + const t = now / 1000 // seconds, for the travelling-glow highlight + + ctx.save() + ctx.font = `${cell}px "JetBrains Mono", "Hiragino Sans", "Noto Sans JP", ui-monospace, monospace` + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + + for (let r = -half; r <= half; r += 1) { + // Per-row flow: half the rows drift left, half right, each at its own speed. + // The drift is a continuous pixel scroll (not a per-cell swap), and each + // glyph's identity is tied to its slot index — so a character visibly slides + // across instead of the whole row flickering in place. Combined with the + // TILT squash + opposite directions, the field reads as a turning surface. + const rowSeed = (r * 19349663) >>> 0 || 1 + const dir = rowSeed & 1 ? 1 : -1 + const speed = 8 + (rowSeed % 16) // px/sec + const scroll = (now / 1000) * speed * dir + const ny = (r * cell) / coreRy + // Latitude dimming: rows away from the equator fade, selling the sphere read. + const rowDim = 1 - 0.5 * Math.min(1, Math.abs(ny)) + const kMin = Math.floor((-coreRx - scroll) / cell) - 1 + const kMax = Math.ceil((coreRx - scroll) / cell) + 1 + + for (let k = kMin; k <= kMax; k += 1) { + const sx = k * cell + scroll // screen-space x relative to the core center + const nx = sx / coreRx + const d2 = nx * nx + ny * ny + + if (d2 > 1) { + continue + } + + const seed = (rowSeed ^ ((k >>> 0) * 73856093)) >>> 0 + const ch = SCRAMBLE_CHARS[seed % SCRAMBLE_CHARS.length] ?? '0' + // Mostly flat brightness, fading only near the rim (reduced gradient). + const edge = clamp((1 - Math.sqrt(d2)) / 0.4, 0, 1) + const flick = 0.7 + 0.3 * (((seed >>> 5) % 100) / 100) + // Travelling glow: two crossing sine waves (drifting in time) light a + // lattice of bright spots that ripple ACROSS the orb — so the highlight + // moves and twinkles instead of being a fixed random set. A per-glyph + // phase keeps neighbours from pulsing in lockstep. + const phase = (seed & 7) * 0.35 + const glow = Math.sin(nx * 4.5 + t * 1.3 + phase) * Math.sin(ny * 4.5 - t * 0.9 + phase) + const pop = 1 + clamp((glow - 0.25) / 0.75, 0, 1) * 2.6 + const a = clamp((darkTheme ? 0.22 : 0.3) * edge * flick * rowDim * pop, 0, 0.9) + + if (a < 0.02) { + continue + } + + ctx.fillStyle = rgba(primary, a) + ctx.fillText(ch, coreX + sx, coreY + r * cell) + } + } + + ctx.restore() + ctx.globalAlpha = 1 +} diff --git a/apps/desktop/src/app/starmap/share-code.test.ts b/apps/desktop/src/app/starmap/share-code.test.ts new file mode 100644 index 000000000000..a011292d8de0 --- /dev/null +++ b/apps/desktop/src/app/starmap/share-code.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from 'vitest' + +import type { StarmapGraph } from '@/types/hermes' + +import { decodeShareCode, encodeShareCode, ShareCodeError } from './share-code' + +function sampleGraph(): StarmapGraph { + return { + clusters: [], + edges: [ + { source: 'skill-a', target: 'skill-b' }, + { source: 'skill-b', target: 'memory:profile:0' } + ], + memory: [ + { body: 'Prefers concise answers.', source: 'profile', timestamp: 1_700_000_000, title: 'Tone' }, + { body: 'Uses a worktree.', source: 'memory', timestamp: null, title: 'Env' } + ], + nodes: [ + { category: 'devops', createdBy: 'agent', id: 'skill-a', kind: 'skill', label: 'skill-a', pinned: true, state: 'active', timestamp: 1_699_900_000, useCount: 7 }, + { category: 'devops', createdBy: null, id: 'skill-b', kind: 'skill', label: 'skill-b', pinned: false, state: 'draft', timestamp: 1_699_950_000, useCount: 0 }, + { category: 'memory', createdBy: null, id: 'memory:profile:0', kind: 'memory', label: 'A fact', memorySource: 'profile', pinned: false, state: 'active', timestamp: 1_700_000_000, useCount: 0 } + ], + stats: {} + } +} + +// Decoded edges compared by node POSITION (ids are synthesized), so topology is +// the invariant, not the literal id strings. +const topology = (g: StarmapGraph): [number, number][] => { + const idx = new Map(g.nodes.map((n, i) => [n.id, i])) + + return g.edges.map(e => [idx.get(e.source)!, idx.get(e.target)!]) +} + +describe('share-code', () => { + // The viz contract: everything the star map RENDERS survives — kinds, radius + // inputs, time position, edge topology — while text is dropped (it's a loadout, + // not a backup). + it('preserves the visualization', () => { + const g = sampleGraph() + const decoded = decodeShareCode(encodeShareCode(g)) + const span = 1_700_000_000 - 1_699_900_000 + const tol = Math.ceil(span / 4095) + 1 + + expect(decoded.nodes).toHaveLength(g.nodes.length) + + decoded.nodes.forEach((d, i) => { + const o = g.nodes[i]! + expect(d.kind).toBe(o.kind) + expect(d.label).toBe(o.label) + expect(d.useCount).toBe(o.useCount) + expect(d.state).toBe(o.state) + expect(d.pinned).toBe(o.pinned) + expect(d.category).toBe(o.category) + expect(d.memorySource).toBe(o.memorySource) + expect(d.createdBy).toBe(o.createdBy) + + if (o.timestamp == null) { + expect(d.timestamp).toBeNull() + } else { + expect(Math.abs((d.timestamp ?? 0) - o.timestamp)).toBeLessThanOrEqual(tol) + } + }) + + expect(topology(decoded)).toEqual(topology(g)) + }) + + it('drops memory prose (loadout is viz-only)', () => { + expect(decodeShareCode(encodeShareCode(sampleGraph())).memory).toHaveLength(0) + }) + + it('rebuilds clusters from node categories', () => { + const decoded = decodeShareCode(encodeShareCode(sampleGraph())) + + expect(decoded.clusters.find(c => c.category === 'devops')?.count).toBe(2) + }) + + it('produces a short, opaque, prefixed code', () => { + const code = encodeShareCode(sampleGraph()) + + expect(code.startsWith('HML')).toBe(true) + expect(code.slice(3)).toMatch(/^[A-Za-z0-9_-]+$/) + // Strictly smaller than the naive JSON it replaces — the whole point. + expect(code.length).toBeLessThan(JSON.stringify(sampleGraph()).length) + }) + + it('stays compact on a large graph (no string bloat)', () => { + const nodes = Array.from({ length: 500 }, (_, i) => ({ + category: `cat-${i % 8}`, + createdBy: 'agent' as const, + id: `s${i}`, + kind: 'skill' as const, + label: `A fairly verbose skill label number ${i}`, + pinned: false, + state: 'active', + timestamp: 1_700_000_000 + i * 3600, + useCount: i % 50 + })) + + const graph: StarmapGraph = { clusters: [], edges: [], memory: [], nodes, stats: {} } + const code = encodeShareCode(graph) + + // Deflate keeps even verbose, repetitive labels far below the naive JSON. + expect(code.length).toBeLessThan(JSON.stringify(graph).length / 5) + }) + + it('handles an empty graph', () => { + const decoded = decodeShareCode(encodeShareCode({ clusters: [], edges: [], memory: [], nodes: [], stats: {} })) + + expect(decoded.nodes).toHaveLength(0) + expect(decoded.edges).toHaveLength(0) + }) + + it('drops edges whose endpoints are missing', () => { + const g = sampleGraph() + g.edges.push({ source: 'skill-a', target: 'does-not-exist' }) + + expect(decodeShareCode(encodeShareCode(g)).edges).toHaveLength(2) + }) + + it('rejects garbage with a ShareCodeError', () => { + expect(() => decodeShareCode('not a real code !!!')).toThrow(ShareCodeError) + expect(() => decodeShareCode('')).toThrow(ShareCodeError) + }) + + it('rejects a corrupted (bit-flipped) code', () => { + const code = encodeShareCode(sampleGraph()) + // Flip a mid-payload char (trailing base64 bits can be dropped on decode). + const i = Math.floor(code.length / 2) + const corrupted = code.slice(0, i) + (code[i] === 'A' ? 'B' : 'A') + code.slice(i + 1) + + expect(() => decodeShareCode(corrupted)).toThrow(ShareCodeError) + }) + + it('tolerates whitespace, including internal wraps', () => { + const code = encodeShareCode(sampleGraph()) + const wrapped = ` ${code.slice(0, 20)}\n${code.slice(20)}\t` + + expect(() => decodeShareCode(wrapped)).not.toThrow() + expect(decodeShareCode(wrapped).nodes).toHaveLength(sampleGraph().nodes.length) + }) +}) diff --git a/apps/desktop/src/app/starmap/share-code.ts b/apps/desktop/src/app/starmap/share-code.ts new file mode 100644 index 000000000000..6e1e0d70ac10 --- /dev/null +++ b/apps/desktop/src/app/starmap/share-code.ts @@ -0,0 +1,186 @@ +import { type BitReader, type BitWriter, createLoadout, Dict, idxOf, indexBits, LoadoutError } from '@/lib/loadout' +import type { StarmapEdge, StarmapGraph, StarmapNode } from '@/types/hermes' + +// ── Star-map share code ─────────────────────────────────────────────────────── +// +// The body schema for a star map, riding the generic loadout codec (@/lib/loadout +// owns the bitstream, DEFLATE, version+checksum frame, and base64url). We encode +// what the map RENDERS — each node's kind, its time POSITION (12-bit quantized, +// not an absolute epoch), radius inputs (useCount/state/pinned), and an interned +// label + category — plus edges as fixed-width node indices. Memory prose is +// dropped; labels are trimmed. DEFLATE then makes the repetitive label/category +// text almost free. A 60-skill map is a few hundred chars. + +const VERSION = 3 +const PREFIX = 'HML' // "Hermes Memory Loadout" — namespaces our codes like WoW's leading bytes. +const MAX_LABEL = 64 // trim runaway memory titles so one card can't bloat the code. + +const trim = (s: string): string => (s.length > MAX_LABEL ? s.slice(0, MAX_LABEL) : s) + +const KINDS = ['skill', 'memory'] as const +const STATES = ['active', 'archived', 'disabled', 'draft'] as const +const MEM_SOURCES = ['none', 'memory', 'profile'] as const +const CREATED_BY = ['none', 'agent', 'user'] as const + +const REC_BITS = 12 // time position resolution: 1/4096 of the span — sub-pixel here. +const REC_MAX = (1 << REC_BITS) - 1 + +const finiteTs = (v?: null | number): null | number => + typeof v === 'number' && Number.isFinite(v) ? Math.max(0, Math.round(v)) : null + +function writeNode(w: BitWriter, n: StarmapNode, dict: Dict, minTs: number, span: number): void { + w.uint(idxOf(KINDS, n.kind), 1) + w.varint(dict.id(trim(n.label || ''))) + w.varint(dict.id(n.category || '')) + w.varint(Math.max(0, n.useCount | 0)) + w.uint(idxOf(STATES, n.state), 2) + w.uint(idxOf(MEM_SOURCES, n.memorySource ?? 'none'), 2) + w.uint(idxOf(CREATED_BY, n.createdBy ?? 'none'), 2) + w.bit(n.pinned) + + // Time as a 12-bit POSITION within [minTs, maxTs] — not an absolute epoch. + const ts = finiteTs(n.timestamp) + + if (ts === null) { + w.bit(0) + } else { + w.bit(1) + w.uint(span > 0 ? Math.round(((ts - minTs) / span) * REC_MAX) : 0, REC_BITS) + } +} + +function readNode(r: BitReader, dict: string[], i: number, minTs: number, span: number): StarmapNode { + const kind = KINDS[r.uint(1)] ?? 'skill' + const label = dict[r.varint()] ?? '' + const category = dict[r.varint()] ?? '' + const useCount = r.varint() + const state = STATES[r.uint(2)] ?? 'active' + const memSrc = MEM_SOURCES[r.uint(2)] ?? 'none' + const createdBy = CREATED_BY[r.uint(2)] ?? 'none' + const pinned = r.bit() === 1 + const timestamp = r.bit() === 1 ? minTs + (span > 0 ? Math.round((r.uint(REC_BITS) / REC_MAX) * span) : 0) : null + + // Ids are synthesized (they're never displayed); memory ids mirror the scan's + // `memory::` shape so the rest of the UI is none the wiser. + const isMemory = kind === 'memory' + const source = memSrc === 'none' ? 'memory' : memSrc + + return { + category, + createdBy: createdBy === 'none' ? null : createdBy, + id: isMemory ? `memory:${source}:${i}` : `s${i}`, + kind, + label, + memorySource: isMemory ? source : undefined, + pinned, + state, + timestamp, + useCount + } +} + +function writeGraph(w: BitWriter, graph: StarmapGraph): void { + const dict = new Dict() + + // Intern labels + categories; deflate later squeezes the inevitable repetition. + for (const n of graph.nodes) { + dict.id(trim(n.label || '')) + dict.id(n.category || '') + } + + const stamps = graph.nodes.map(n => finiteTs(n.timestamp)).filter((v): v is number => v !== null) + const minTs = stamps.length ? Math.min(...stamps) : 0 + const maxTs = stamps.length ? Math.max(...stamps) : 0 + const span = maxTs - minTs + + w.varint(minTs) + w.varint(maxTs) + w.varint(dict.list.length) + + for (const s of dict.list) { + w.str(s) + } + + w.varint(graph.nodes.length) + + for (const n of graph.nodes) { + writeNode(w, n, dict, minTs, span) + } + + // Edges reference nodes by position; drop any whose endpoints aren't both nodes. + const order = new Map(graph.nodes.map((n, i) => [n.id, i])) + const edges = graph.edges.filter(e => order.has(e.source) && order.has(e.target)) + const bits = indexBits(graph.nodes.length) + w.varint(edges.length) + + for (const e of edges) { + w.uint(order.get(e.source)!, bits) + w.uint(order.get(e.target)!, bits) + } +} + +function readGraph(r: BitReader): StarmapGraph { + const minTs = r.varint() + const maxTs = r.varint() + const span = maxTs - minTs + + const dictLen = r.varint() + const dict: string[] = [] + + for (let i = 0; i < dictLen; i += 1) { + dict.push(r.str()) + } + + const nodeCount = r.varint() + const nodes: StarmapNode[] = [] + + for (let i = 0; i < nodeCount; i += 1) { + nodes.push(readNode(r, dict, i, minTs, span)) + } + + const bits = indexBits(nodeCount) + const edgeCount = r.varint() + const edges: StarmapEdge[] = [] + + for (let i = 0; i < edgeCount; i += 1) { + const src = nodes[r.uint(bits)] + const dst = nodes[r.uint(bits)] + + if (src && dst) { + edges.push({ source: src.id, target: dst.id }) + } + } + + const counts = new Map() + + for (const n of nodes) { + counts.set(n.category, (counts.get(n.category) ?? 0) + 1) + } + + const clusters = [...counts.entries()].map(([category, count]) => ({ category, count })).sort((a, b) => b.count - a.count) + + // Memory cards are dropped (viz-only); a marker lets the UI tell a decoded map + // apart from a freshly-scanned one. + return { clusters, edges, memory: [], nodes, stats: { imported: true } } +} + +export class ShareCodeError extends LoadoutError {} + +const codec = createLoadout({ + error: ShareCodeError, + noun: 'map code', + prefix: PREFIX, + read: readGraph, + version: VERSION, + write: writeGraph +}) + +// Serialize a star-map graph to a short, opaque, clipboard-safe loadout string. +export function encodeShareCode(graph: StarmapGraph): string { + return codec.encode(graph) +} + +// Parse a loadout string back into a (viz-complete, text-synthesized) graph. +export function decodeShareCode(code: string): StarmapGraph { + return codec.decode(code) +} diff --git a/apps/desktop/src/app/starmap/share-controls.tsx b/apps/desktop/src/app/starmap/share-controls.tsx new file mode 100644 index 000000000000..45c9bcc0f1b4 --- /dev/null +++ b/apps/desktop/src/app/starmap/share-controls.tsx @@ -0,0 +1,132 @@ +import { useState } from 'react' + +import { Button } from '@/components/ui/button' +import { CopyButton } from '@/components/ui/copy-button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger +} from '@/components/ui/dialog' +import { useI18n } from '@/i18n' +import { Upload } from '@/lib/icons' + +interface ShareControlsProps { + // True when the shown map was loaded from a pasted code (not the live scan). + imported?: boolean + // Decode + apply a pasted code. Returns an error string to show inline, or null. + onImport?: (code: string) => null | string + onResetMap?: () => void + // The current map serialized as a WoW-style share code (the copy target). + shareCode?: string +} + +// Share / import a map as a single code. The textarea shows the current map's +// code (copy it to share); edit/replace it and hit Load to view someone else's. +// One field, one button — a standard Dialog matching rename/create. +export function ShareControls({ imported = false, onImport, onResetMap, shareCode }: ShareControlsProps) { + const { t } = useI18n() + const [open, setOpen] = useState(false) + const [value, setValue] = useState('') + const [error, setError] = useState(null) + + const own = (shareCode ?? '').trim() + const code = value.trim() + const canLoad = code !== '' && code !== own + + const load = () => { + if (!code) { + setError(t.starmap.importEmpty) + + return + } + + const err = onImport?.(code) ?? null + setError(err) + + if (err === null) { + setOpen(false) + } + } + + return ( +
+ {imported && ( + + )} + + { + setOpen(next) + setError(null) + + if (next) { + setValue(shareCode ?? '') + } + }} + open={open} + > + + + + + + + {t.starmap.shareTitle} + {t.starmap.shareHint} + + + {/* One code field: pre-filled with this map's code (copy to share); edit + or paste another and Load. Copy button floats in on hover, like a + thread code block. */} +
+