diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 2ed6b2ccc0..750c8fc4b7 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -128,7 +128,7 @@ jobs: # artifacts. No registry push, no signing secrets. The publish phase never # rebuilds — it downloads these exact artifacts. build: - name: Build and test image (${{ matrix.arch }}) + name: Build and test image (${{ matrix.variant }}/${{ matrix.arch }}) needs: [mode, detect] if: >- !cancelled() && needs.mode.result == 'success' && @@ -136,19 +136,21 @@ jobs: strategy: fail-fast: false matrix: + # Two images per arch. `slim` is what :latest has always been; `desktop` + # adds the Bot Screen packages and distro Chromium fallback for the + # tier that offers a screen. PM's pinned full Chromium is in both. + # Both are built on a PR so gated-layer failures cannot reach publish. + arch: [amd64, arm64] + variant: [slim, desktop] include: - arch: amd64 runner: ubuntu-latest-32-core platform: linux/amd64 - cache-from: type=gha,scope=docker-amd64 - cache-to: type=gha,mode=max,scope=docker-amd64 # arm64 builds on the native arm64 larger runner. A build of # linux/arm64 on an x64 host uses emulation. - arch: arm64 runner: ubuntu-latest-32-arm-core 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 @@ -200,7 +202,7 @@ jobs: # Build once, load into the local daemon for testing. Cached # per-arch; the push step below reuses every layer from this build. - - name: Build image (${{ matrix.arch }}) + - name: Build image (${{ matrix.variant }}/${{ matrix.arch }}) uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 with: context: . @@ -208,30 +210,20 @@ jobs: load: true platforms: ${{ matrix.platform }} tags: ${{ env.IMAGE_NAME }}:test - cache-from: ${{ matrix.cache-from }} - cache-to: ${{ (github.event_name != 'pull_request') && matrix.cache-to || '' }} + build-args: | + HERMES_BOT_DESKTOP=${{ matrix.variant == 'desktop' && '1' || '0' }} + # Slim owns the scope; desktop adds the gated apt layer, so it reads + # and writes nothing — a mode=max scope could exceed the cache cap. + cache-from: type=gha,scope=docker-${{ matrix.arch }} + cache-to: ${{ (github.event_name != 'pull_request' && matrix.variant == 'slim') && format('type=gha,mode=max,scope=docker-{0}', matrix.arch) || '' }} - # 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. - # (The release path DOES pay that cost — see the save steps below — - # because publish must push the exact tested bytes, not a rebuild.) - # --------------------------------------------------------------------- - # The stamp above marks the checkout as a docker distribution, and PM - # then expects the image's packaged runtime. The runner is not the - # image: park the stamp while the test toolchain is provisioned, then - # put it back — tests/docker compares the image's provenance against it. + # Run the docker-integration test suite against the freshly-built image + # already loaded into the local daemon (`:test`). The fixture's + # HERMES_TEST_IMAGE branch short-circuits the rebuild. Release tests + # archive these exact bytes rather than rebuilding at publish time. + # Park the stamp while PM provisions the runner, then restore it so + # tests/docker can compare the image's provenance with the checkout. - name: Park the image install stamp while provisioning the runner toolchain run: mv install-stamp.json "$RUNNER_TEMP/install-stamp.json" @@ -278,6 +270,7 @@ jobs: env: ARCH: ${{ matrix.arch }} RELEASE_TAG: ${{ inputs.tag }} + VARIANT: ${{ matrix.variant }} run: | set -euo pipefail mkdir -p /tmp/image-artifacts @@ -290,7 +283,7 @@ jobs: raise SystemExit('Docker image architecture mismatch') Path('/tmp/image-artifacts/identity.json').write_text(json.dumps({ 'tag': os.environ['RELEASE_TAG'], 'commit': os.environ['GITHUB_SHA'], - 'arch': os.environ['ARCH'], 'imageId': image['Id']}), encoding='utf-8') + 'arch': os.environ['ARCH'], 'variant': os.environ['VARIANT'], 'imageId': image['Id']}), encoding='utf-8') PY docker save --output "/tmp/image-artifacts/image-${ARCH}.tar" "${IMAGE_NAME}:test" ( @@ -302,7 +295,7 @@ jobs: if: needs.mode.outputs.phase == 'test' uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - name: docker-test-image-${{ matrix.arch }}-${{ inputs.tag }} + name: docker-test-image-${{ matrix.variant }}-${{ matrix.arch }}-${{ inputs.tag }} path: | /tmp/image-artifacts/image-${{ matrix.arch }}.tar /tmp/image-artifacts/image-${{ matrix.arch }}.tar.sha256 @@ -330,18 +323,16 @@ jobs: strategy: fail-fast: false matrix: + arch: [amd64, arm64] + variant: [slim, desktop] include: - arch: amd64 runner: ubuntu-latest-32-core platform: linux/amd64 - cache-from: type=gha,scope=docker-amd64 - cache-to: type=gha,mode=max,scope=docker-amd64 # Native arm64 for the same reason as the build matrix above. - arch: arm64 runner: ubuntu-latest-32-arm-core 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: 30 steps: @@ -384,18 +375,20 @@ jobs: # Push by digest only (no tag). The merge job assembles the tagged # manifest list after both architecture publishers complete. - - name: Push ${{ matrix.arch }} by digest + - name: Push ${{ matrix.variant }}/${{ matrix.arch }} by digest id: push uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 with: context: . file: Dockerfile platforms: ${{ matrix.platform }} + build-args: | + HERMES_BOT_DESKTOP=${{ matrix.variant == 'desktop' && '1' || '0' }} labels: | org.opencontainers.image.revision=${{ github.sha }} outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true - cache-from: ${{ matrix.cache-from }} - cache-to: ${{ matrix.cache-to }} + cache-from: type=gha,scope=docker-${{ matrix.arch }} + cache-to: ${{ matrix.variant == 'slim' && format('type=gha,mode=max,scope=docker-{0}', matrix.arch) || '' }} - name: Export digest run: | @@ -406,7 +399,7 @@ jobs: - name: Upload digest artifact uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - name: digest-${{ matrix.arch }} + name: digest-${{ matrix.variant }}-${{ matrix.arch }} path: /tmp/digests/* if-no-files-found: error retention-days: 1 @@ -420,20 +413,29 @@ jobs: # publication controller. # --------------------------------------------------------------------------- merge: - if: >- - needs.mode.outputs.release != 'true' && - github.repository == 'NousResearch/hermes-agent' && - github.event_name == 'push' && github.ref == 'refs/heads/main' + # `needs` is the whole 4-leg matrix: only publish both :main variants + # after every arch and variant publisher succeeds. + if: ${{ !cancelled() && needs.mode.outputs.release != 'true' && github.repository == 'NousResearch/hermes-agent' && github.event_name == 'push' && github.ref == 'refs/heads/main' }} runs-on: ubuntu-latest needs: [mode, publish] timeout-minutes: 10 environment: container-publish + strategy: + fail-fast: false + matrix: + # One manifest list per variant. `slim` keeps the unsuffixed tags it has + # always had; `desktop` publishes its own digests under -desktop. + include: + - variant: slim + suffix: "" + - variant: desktop + suffix: "-desktop" steps: - name: Download digests uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: path: /tmp/digests - pattern: digest-* + pattern: digest-${{ matrix.variant }}-* merge-multiple: true # Retry once on transient Docker Hub / buildkit pull failures. @@ -457,13 +459,17 @@ jobs: working-directory: /tmp/digests env: IMAGE_NAME: ${{ env.IMAGE_NAME }} + SUFFIX: ${{ matrix.suffix }} run: | set -euo pipefail + # Without nullglob an empty dir yields the literal `*`: one bogus entry. + shopt -s nullglob args=() for digest_file in *; do args+=("${IMAGE_NAME}@sha256:${digest_file}") done - tags=(-t "${IMAGE_NAME}:main") + if [ "${#args[@]}" -ne 2 ]; then echo "::error::Expected two ${SUFFIX} digests"; exit 1; fi + tags=(-t "${IMAGE_NAME}:main${SUFFIX}") # Retry: Docker Hub API + just-pushed digest eventual consistency # can transiently fail the create; the operation is idempotent. for i in 1 2 3; do @@ -481,7 +487,8 @@ jobs: - name: Inspect image env: IMAGE_NAME: ${{ env.IMAGE_NAME }} - run: docker buildx imagetools inspect "${IMAGE_NAME}:main" + SUFFIX: ${{ matrix.suffix }} + run: docker buildx imagetools inspect "${IMAGE_NAME}:main${SUFFIX}" # =========================================================================== # Staged stable-release path (workflow_call from stable-release.yml). @@ -494,21 +501,22 @@ jobs: # per-arch and NEVER rebuild. No owner gate: on a fork the Docker Hub # login/push fails loudly (missing credentials) instead of faking green. release-publish: - name: Publish tested Docker image (${{ matrix.arch }}) + name: Publish tested Docker image (${{ matrix.variant }}/${{ matrix.arch }}) if: needs.mode.outputs.phase == 'publish' needs: [mode] environment: container-publish strategy: fail-fast: false matrix: - include: - - arch: amd64 - - arch: arm64 + arch: [amd64, arm64] + variant: [slim, desktop] runs-on: ubuntu-latest-32-core timeout-minutes: 45 env: ARCH: ${{ matrix.arch }} RELEASE_TAG: ${{ inputs.tag }} + VARIANT: ${{ matrix.variant }} + SUFFIX: ${{ matrix.variant == 'desktop' && '-desktop' || '' }} steps: - name: Checkout release code (helper scripts only, no build) uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -518,7 +526,7 @@ jobs: - name: Download tested image archive from the test phase (same run) uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: - name: docker-test-image-${{ matrix.arch }}-${{ inputs.tag }} + name: docker-test-image-${{ matrix.variant }}-${{ matrix.arch }}-${{ inputs.tag }} path: /tmp/image-artifacts - name: Verify tested archive hash (published bytes == tested bytes) @@ -547,8 +555,8 @@ jobs: from pathlib import Path identity = json.loads(Path('/tmp/image-artifacts/identity.json').read_text()) loaded = json.loads(Path('/tmp/image-artifacts/loaded.json').read_text())[0] - expected = (os.environ['RELEASE_TAG'], os.environ['GITHUB_SHA'], os.environ['ARCH']) - if (identity['tag'], identity['commit'], identity['arch']) != expected: + expected = (os.environ['RELEASE_TAG'], os.environ['GITHUB_SHA'], os.environ['ARCH'], os.environ['VARIANT']) + if (identity['tag'], identity['commit'], identity['arch'], identity['variant']) != expected: raise SystemExit('Tested Docker archive identity mismatch') if (loaded['Id'], loaded['Architecture']) != (identity['imageId'], identity['arch']): raise SystemExit('Loaded Docker image differs from tested image') @@ -563,14 +571,14 @@ jobs: - name: Push tested image with per-arch release tag run: | set -euo pipefail - docker tag "${IMAGE_NAME}:test" "${IMAGE_NAME}:${RELEASE_TAG}-${ARCH}" - docker push "${IMAGE_NAME}:${RELEASE_TAG}-${ARCH}" + docker tag "${IMAGE_NAME}:test" "${IMAGE_NAME}:${RELEASE_TAG}-${ARCH}${SUFFIX}" + docker push "${IMAGE_NAME}:${RELEASE_TAG}-${ARCH}${SUFFIX}" - name: Record pushed per-arch digest run: | set -euo pipefail mkdir -p /tmp/digests - digest="$(docker buildx imagetools inspect "${IMAGE_NAME}:${RELEASE_TAG}-${ARCH}" \ + digest="$(docker buildx imagetools inspect "${IMAGE_NAME}:${RELEASE_TAG}-${ARCH}${SUFFIX}" \ --format '{{json .Manifest.Digest}}' | tr -d '"')" case "$digest" in sha256:*) ;; @@ -582,7 +590,7 @@ jobs: - name: Upload per-arch digest artifact uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - name: docker-publish-digest-${{ matrix.arch }}-${{ inputs.tag }} + name: docker-publish-digest-${{ matrix.variant }}-${{ matrix.arch }}-${{ inputs.tag }} path: /tmp/digests/${{ matrix.arch }}.digest if-no-files-found: error retention-days: 7 @@ -611,7 +619,6 @@ jobs: with: path: /tmp/digests pattern: docker-publish-digest-*-${{ inputs.tag }} - merge-multiple: true - name: Set up Docker Buildx id: buildx @@ -628,49 +635,48 @@ jobs: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Create versioned manifest list + - name: Create both immutable versioned manifest lists env: IMAGE_NAME: ${{ env.IMAGE_NAME }} run: | set -euo pipefail cd /tmp/digests - test -f amd64.digest && test -f arm64.digest - args=() - for arch in amd64 arm64; do - args+=("${IMAGE_NAME}@$(cat "${arch}.digest")") - done - verify_manifest() { - python3 - "$1" amd64.digest arm64.digest <<'PY' + for variant in slim desktop; do + suffix="" + [ "$variant" = desktop ] && suffix=-desktop + digest_files=() + args=() + for arch in amd64 arm64; do + file="docker-publish-digest-${variant}-${arch}-${RELEASE_TAG}/${arch}.digest" + test -s "$file" + digest_files+=("$file") + args+=("${IMAGE_NAME}@$(cat "$file")") + done + verify_manifest() { + python3 - "$1" "${digest_files[@]}" <<'PY' import json, pathlib, sys - manifest = json.loads(pathlib.Path(sys.argv[1]).read_text()) expected = {pathlib.Path(path).read_text().strip() for path in sys.argv[2:]} actual = {row.get("digest") for row in manifest.get("manifests", [])} - if actual != expected: + if actual != expected or len(manifest.get("manifests", [])) != 2: raise SystemExit(f"versioned Docker manifest differs: expected {sorted(expected)}, got {sorted(actual)}") PY - } - if docker buildx imagetools inspect "${IMAGE_NAME}:${RELEASE_TAG}" --raw > existing.json 2>/dev/null; then - verify_manifest existing.json - echo "Verified existing immutable ${IMAGE_NAME}:${RELEASE_TAG}" - else - for i in 1 2 3; do - if docker buildx imagetools create \ - -t "${IMAGE_NAME}:${RELEASE_TAG}" \ - "${args[@]}"; then - break - fi - if [ "$i" = 3 ]; then - echo "::error::imagetools create failed after 3 attempts" - exit 1 - fi - sleep 20 - done - fi - docker buildx imagetools inspect "${IMAGE_NAME}:${RELEASE_TAG}" --raw > verified.json - verify_manifest verified.json + } + ref="${IMAGE_NAME}:${RELEASE_TAG}${suffix}" + if docker buildx imagetools inspect "$ref" --raw > existing.json 2>/dev/null; then + verify_manifest existing.json + else + for i in 1 2 3; do + if docker buildx imagetools create -t "$ref" "${args[@]}"; then break; fi + if [ "$i" = 3 ]; then echo "::error::imagetools create failed after 3 attempts"; exit 1; fi + sleep 20 + done + fi + docker buildx imagetools inspect "$ref" --raw > verified.json + verify_manifest verified.json + done - - name: Record manifest-list digest + - name: Record slim manifest-list digest for the release receipt id: list env: IMAGE_NAME: ${{ env.IMAGE_NAME }} @@ -692,8 +698,8 @@ jobs: python3 -m scripts.releases.docker manifest \ --tag "$RELEASE_TAG" \ --commit "$GITHUB_SHA" \ - --digest-amd64 "$(sed 's/^sha256://' /tmp/digests/amd64.digest)" \ - --digest-arm64 "$(sed 's/^sha256://' /tmp/digests/arm64.digest)" \ + --digest-amd64 "$(sed 's/^sha256://' /tmp/digests/docker-publish-digest-slim-amd64-${RELEASE_TAG}/amd64.digest)" \ + --digest-arm64 "$(sed 's/^sha256://' /tmp/digests/docker-publish-digest-slim-arm64-${RELEASE_TAG}/arm64.digest)" \ > /tmp/manifest/manifest.json python3 - "$RELEASE_TAG" "${{ steps.list.outputs.digest }}" <<'EOF' import json, sys diff --git a/Dockerfile b/Dockerfile index 973bd1ce3d..f10f5a7acd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -76,11 +76,15 @@ RUN apt-get -o Acquire::Retries=3 update && \ libasound2t64 libatk-bridge2.0-0t64 libatk1.0-0t64 libatspi2.0-0t64 libcairo2 libcups2t64 libdbus-1-3 libgbm1 libglib2.0-0t64 libnspr4 libnss3 libpango-1.0-0 libx11-6 libxcb1 libxcomposite1 libxdamage1 libxext6 libxfixes3 libxkbcommon0 libxrandr2 && \ rm -rf /var/lib/apt/lists/* -# Bot Screen (opt-in): TigerVNC + the Xfce components + a headed chromium, so a -# container that cannot run apt at run time (unprivileged user, no sudo — every -# hosted instance) can still stream a desktop. ~550 MB. Nothing here starts at -# boot; the layer costs no memory until a screen is started. Same package list -# as tools/bot_desktop/runtime.py::PACKAGES["apt"]. +# Bot Screen (opt-in): PACKAGES["apt"] from tools/bot_desktop/runtime.py plus apt +# `chromium` for the restricted-userns sandbox fallback. PM already stages +# pinned full Chromium for both variants; no separate Playwright install. +# Nothing starts +# at boot. docker.yml builds both variants and publishes these packages under +# the `-desktop` tags: hosted sandboxes pull a prebuilt image and never run a +# build, and cannot apt at run time either (unprivileged, no sudo). Only this +# build step needs root — +# Xvnc is a userspace X server, so the runtime user can drive it. # docker build --build-arg HERMES_BOT_DESKTOP=1 . ARG HERMES_BOT_DESKTOP=0 RUN if [ "$HERMES_BOT_DESKTOP" = "1" ]; then \ @@ -331,6 +335,14 @@ RUN cd plugins/platforms/photon/sidecar && \ # Shared product outputs are independent of application dependency assembly. COPY --from=frontend_build /opt/products/tui /opt/hermes/ui-tui COPY --from=frontend_build /opt/products/web /opt/hermes/hermes_cli/web_dist +# ---------- Bot Screen X socket directory ---------- +# Xvnc would create this itself (/tmp is 1777); pre-creating it keeps ownership +# deterministic when HERMES_UID is remapped between boots. +RUN mkdir -p /tmp/.X11-unix && chmod 1777 /tmp/.X11-unix + +# XDG_RUNTIME_DIR (set below) sits under a predictable name in world-writable /tmp. +# Shipping it root-owned means stage2 finds a directory it trusts and chowns it. +RUN mkdir -p /tmp/hermes-runtime && chmod 0700 /tmp/hermes-runtime # ---------- Source code ---------- # .dockerignore excludes node_modules, so the installs above survive. @@ -443,6 +455,12 @@ ENV HERMES_DISABLE_LAZY_INSTALLS=1 # HERMES_DISABLE_LAZY_INSTALLS above): the venv is sealed and opt-in backend # SDKs are not installed at runtime. +# Xfce, dbus and the display-allocation lock need one; containers have no logind +# to create /run/user/. The default fallback ($HOME/.cache) is the /opt/data +# volume, which a host-side install may share — two instances would then contend +# for one lock. Container-scoped instead; seeded 0700 by docker/stage2-hook.sh. +ENV XDG_RUNTIME_DIR=/tmp/hermes-runtime + # `docker exec` privilege-drop shim. When operators run # `docker exec hermes ...` they default to root, and any file the # command writes under $HERMES_HOME (auth.json, .env, config.yaml) ends diff --git a/agent/agent_init.py b/agent/agent_init.py index b55fdd507a..82d3a545b3 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -2304,6 +2304,7 @@ _GATEWAY_IDENTITY_PARAMS = ( ) _CALLBACK_PARAMS = ( "tool_progress_callback", "tool_start_callback", "tool_complete_callback", + "tool_result_metadata_callback", "thinking_callback", "reasoning_callback", "clarify_callback", "read_terminal_callback", "read_preview_callback", "drive_preview_callback", "read_window_below_callback", "connection_callback", "tour_callback", @@ -2349,6 +2350,7 @@ def init_agent( checkpoint_max_file_size_mb: int = 10, pass_session_id: bool = False, requested_provider: str = None, capabilities: Optional[Dict[str, bool]] = None, cwd: Optional[str] = None, side_agent: bool = False, memory_manager=None, + tool_result_metadata_callback: Optional[Callable[..., dict]] = None, ): _install_safe_stdio() diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index da26674189..501211ca57 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -3346,6 +3346,12 @@ def _is_structured_output_rejection(exc: Exception) -> bool: # without it is the same remedy, so treat the shape error as a rejection too. if "response_format" in err_lower and "json_schema" in err_lower: return True + # Gemini native names its own generationConfig keys, never ours: "Function calling with a response + # mime type: 'application/json' is unsupported" (pre-Gemini-3 + tools via a proxy), or an + # "Unknown name"/"Invalid value" 400 on response_schema / response_json_schema for a schema the + # surface cannot express. Same remedy: one retry without the format. + if _contains_any(err_lower, ("response mime type", "response_schema", "response_json_schema")): + return True return _is_unsupported_parameter_error(exc, "response_format") or _is_unsupported_parameter_error(exc, "output_config") diff --git a/agent/chat_completion_helpers.py b/agent/chat_completion_helpers.py index cad1a34f6b..589351c74e 100644 --- a/agent/chat_completion_helpers.py +++ b/agent/chat_completion_helpers.py @@ -39,7 +39,7 @@ from agent.gemini_native_adapter import is_native_gemini_base_url # misidentify and, without an api_key, return 401 on every leg (issue #89863). from agent.model_metadata import is_local_endpoint from agent.message_content import flatten_message_text -from agent.message_metadata import append_message, stamp_message_timestamp +from agent.message_metadata import PERSISTENCE_ONLY_MESSAGE_FIELDS, append_message, stamp_message_timestamp from agent.message_sanitization import ( _sanitize_surrogates, _repair_tool_call_arguments, normalize_finish_reason as _normalize_finish_reason, sanitize_outbound_kwargs, strip_images_for_rejecting_model, @@ -2129,8 +2129,8 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None, reset_a # Keys outside the Chat Completions schema that strict gateways (Fireworks-backed OpenCode # Go, Mistral, Moonshot/Kimi) reject with 422. The transport's convert_messages() drops them # in the main loop; the summary path calls chat.completions.create() directly, so mirror it. -_SUMMARY_FOREIGN_MESSAGE_KEYS = ("reasoning", "finish_reason", "tool_name", "codex_reasoning_items", - "codex_message_items", "timestamp", "platform_message_id") +_SUMMARY_FOREIGN_MESSAGE_KEYS = PERSISTENCE_ONLY_MESSAGE_FIELDS | {"reasoning", "finish_reason", "tool_name", + "codex_reasoning_items", "codex_message_items", "platform_message_id"} _EMPTY_SUMMARY_RESPONSE = "I reached the iteration limit and couldn't generate a summary." diff --git a/agent/display.py b/agent/display.py index ceb8ba4313..f317d25d76 100644 --- a/agent/display.py +++ b/agent/display.py @@ -643,19 +643,29 @@ def _resolve_skill_manage_paths(args: dict) -> list[Path]: return [skill_dir / "SKILL.md"] if action in {"edit", "patch"} else [] -def _resolve_local_edit_paths(tool_name: str, function_args: dict | None) -> list[Path]: +def _resolve_local_edit_paths(tool_name: str, function_args: dict | None, task_id: str | None = None) -> list[Path]: """Resolve local filesystem targets for write-capable tools.""" if not isinstance(function_args, dict): return [] if tool_name == "skill_manage": return _resolve_skill_manage_paths(function_args) path = function_args.get("path") if tool_name in {"write_file", "patch"} else None + if path and task_id is not None: + from tools.file_tools_paths import _resolve_path_for_task, _terminal_env_type_for_task + + # A remote target may happen to exist on this host too. Never persist a + # preview of that unrelated file; patch can still supply its own diff. + if _terminal_env_type_for_task(task_id) != "local": + return [] + return [Path(_resolve_path_for_task(path, task_id))] return [_resolved_path(path)] if path else [] -def capture_local_edit_snapshot(tool_name: str, function_args: dict | None) -> LocalEditSnapshot | None: +def capture_local_edit_snapshot( + tool_name: str, function_args: dict | None, *, task_id: str | None = None, +) -> LocalEditSnapshot | None: """Capture before-state for local write previews.""" - paths = _resolve_local_edit_paths(tool_name, function_args) + paths = _resolve_local_edit_paths(tool_name, function_args, task_id) if not paths: return None return LocalEditSnapshot(paths=paths, before={str(path): _snapshot_text(path) for path in paths}) diff --git a/agent/gemini_native_adapter.py b/agent/gemini_native_adapter.py index 7ff7bfa97f..6aafc1aaff 100644 --- a/agent/gemini_native_adapter.py +++ b/agent/gemini_native_adapter.py @@ -499,19 +499,36 @@ def _effective_gemini_max_output_tokens(max_tokens: Optional[int], thinking_conf return requested +def _translate_response_format(response_format: Any, *, json_schema: bool = False) -> Dict[str, Any]: + """OpenAI ``response_format`` → Gemini ``generationConfig`` JSON-output keys. + + Full-JSON-Schema ``responseJsonSchema`` exists only on the generativelanguage ``v1beta`` + surface (same gate as ``parametersJsonSchema``); ``v1`` / ``v1alpha``, Vertex express + ``v1beta1`` and unknown proxies take the OpenAPI-subset ``responseSchema`` path. + """ + if not isinstance(response_format, dict) or response_format.get("type") not in ("json_object", "json_schema"): + return {} + spec = response_format.get("json_schema") if response_format.get("type") == "json_schema" else None + # A ``json_schema`` spec with no ``schema`` key has nothing to constrain with (the Anthropic + # translator bails the same way) — ask for JSON and let the model shape it. + schema = spec.get("schema") if isinstance(spec, dict) else None + if not isinstance(schema, dict): + return {"responseMimeType": "application/json"} + key, prep = ("responseJsonSchema", prepare_gemini_tool_parameters) if json_schema else ("responseSchema", sanitize_gemini_tool_parameters) + return {"responseMimeType": "application/json", key: prep(schema)} + + def build_gemini_request( *, messages: List[Dict[str, Any]], tools: Any = None, tool_choice: Any = None, temperature: Optional[float] = None, max_tokens: Optional[int] = None, top_p: Optional[float] = None, stop: Any = None, thinking_config: Any = None, - model: str = "", tools_as_json_schema: bool = False, + response_format: Any = None, model: str = "", tools_as_json_schema: bool = False, ) -> Dict[str, Any]: # Gemini 3+ both requires tool-call ids and accepts multimodal functionResponse parts. is_gemini3 = gemini_requires_tool_call_ids(model) contents, system_instruction = _build_gemini_contents(messages, include_tool_call_ids=is_gemini3, is_gemini3=is_gemini3) - optional = ( - ("systemInstruction", system_instruction), - ("tools", _translate_tools_to_gemini(tools, json_schema=tools_as_json_schema)), - ("toolConfig", _translate_tool_choice_to_gemini(tool_choice)), - ) + gemini_tools = _translate_tools_to_gemini(tools, json_schema=tools_as_json_schema) + tool_config = _translate_tool_choice_to_gemini(tool_choice) + optional = (("systemInstruction", system_instruction), ("tools", gemini_tools), ("toolConfig", tool_config)) request: Dict[str, Any] = {"contents": contents, **{k: v for k, v in optional if v}} # Key order is part of the wire format (prompt-cache parity): temperature, maxOutputTokens, topP, stop, thinking. generation = ( @@ -519,7 +536,18 @@ def build_gemini_request( ("topP", top_p), ("stopSequences", (stop if isinstance(stop, list) else [str(stop)]) if stop else None), ("thinkingConfig", _normalize_thinking_config(thinking_config)), ) - request["generationConfig"] = {k: v for k, v in generation if v is not None} + json_output = _translate_response_format(response_format, json_schema=tools_as_json_schema) + # Gemini 400s when forced function calling (mode ANY, from ``tool_choice="required"`` or a named + # function) is combined with a JSON responseMimeType, and pre-Gemini-3 models reject JSON output + # alongside ANY function declarations ("Function calling with a response mime type: + # 'application/json' is unsupported"); only Gemini 3+ combines tools with structured output. + # The tools win; JSON can come on a later turn, and callers tolerate an unconstrained reply. + forced_call = (tool_config or {}).get("functionCallingConfig", {}).get("mode") == "ANY" + if json_output and (forced_call or (gemini_tools and not is_gemini3)): + logger.debug("Gemini: dropping JSON response_format — %s", + "tool_choice forces function calling (mode ANY)" if forced_call else "pre-Gemini-3 model with tools") + json_output = {} + request["generationConfig"] = {**{k: v for k, v in generation if v is not None}, **json_output} return request @@ -547,10 +575,21 @@ def _dump_call_args(fc: Dict[str, Any], **kwargs: Any) -> str: def _usage_from_metadata(usage_meta: Dict[str, Any]) -> SimpleNamespace: + """Gemini ``usageMetadata`` → OpenAI-shaped usage. + + Hidden thinking is reported separately in ``thoughtsTokenCount``: + ``candidatesTokenCount`` counts visible output only, while ``totalTokenCount`` + already includes thoughts. OpenAI's ``completion_tokens`` covers reasoning, so + thoughts are folded in (otherwise a thinking turn bills a few percent of its + real output and ``prompt + completion != total``) and also surfaced under + ``completion_tokens_details.reasoning_tokens``, where ``normalize_usage`` reads + them. Absent on non-thinking/older responses, which keeps their numbers as-is.""" count = lambda key: int(usage_meta.get(key) or 0) # noqa: E731 + reasoning_tokens = count("thoughtsTokenCount") return SimpleNamespace( - prompt_tokens=count("promptTokenCount"), completion_tokens=count("candidatesTokenCount"), + prompt_tokens=count("promptTokenCount"), completion_tokens=count("candidatesTokenCount") + reasoning_tokens, total_tokens=count("totalTokenCount"), prompt_tokens_details=SimpleNamespace(cached_tokens=count("cachedContentTokenCount")), + completion_tokens_details=SimpleNamespace(reasoning_tokens=reasoning_tokens), ) @@ -809,12 +848,14 @@ class GeminiNativeClient: def _create_chat_completion( self, *, model: str = "gemini-3.7-flash", messages: Optional[List[Dict[str, Any]]] = None, stream: bool = False, tools: Any = None, tool_choice: Any = None, temperature: Optional[float] = None, max_tokens: Optional[int] = None, - top_p: Optional[float] = None, stop: Any = None, extra_body: Optional[Dict[str, Any]] = None, timeout: Any = None, **_: Any, + top_p: Optional[float] = None, stop: Any = None, response_format: Any = None, extra_body: Optional[Dict[str, Any]] = None, + timeout: Any = None, **_: Any, ) -> Any: extra = extra_body if isinstance(extra_body, dict) else {} request = build_gemini_request( messages=messages or [], tools=tools, tool_choice=tool_choice, temperature=temperature, max_tokens=max_tokens, - top_p=top_p, stop=stop, thinking_config=extra.get("thinking_config") or extra.get("thinkingConfig"), model=model, + top_p=top_p, stop=stop, thinking_config=extra.get("thinking_config") or extra.get("thinkingConfig"), + response_format=response_format or extra.get("response_format"), model=model, tools_as_json_schema=gemini_accepts_parameters_json_schema(self.base_url), ) model = bare_gemini_model_id(model) diff --git a/agent/message_metadata.py b/agent/message_metadata.py index e13acb663f..687942156a 100644 --- a/agent/message_metadata.py +++ b/agent/message_metadata.py @@ -6,9 +6,12 @@ from time import time as wall_time from typing import Any, MutableMapping, Optional, TypeVar -# These fields describe Hermes' durable record, not provider-visible message -# content. They must not influence context-pressure decisions. -PERSISTENCE_ONLY_MESSAGE_FIELDS = frozenset({"timestamp"}) +# These fields describe Hermes' durable record and timeline display, not +# provider-visible message content. The request builder strips them from every +# outgoing copy and the token estimator ignores them: one set, so an estimate +# never prices bytes the provider never receives (an edit's inline_diff in +# display_metadata is ~9KB and would trigger premature compaction). +PERSISTENCE_ONLY_MESSAGE_FIELDS = frozenset({"timestamp", "display_kind", "display_metadata", "_row_id"}) _Message = TypeVar("_Message", bound=MutableMapping[str, Any]) diff --git a/agent/relay_cwd.py b/agent/relay_cwd.py new file mode 100644 index 0000000000..3eee3a44ef --- /dev/null +++ b/agent/relay_cwd.py @@ -0,0 +1,91 @@ +"""Resolve logical working directories for Hermes-owned Relay scopes.""" + +from __future__ import annotations + +import logging +from typing import Any + +logger = logging.getLogger(__name__) + +_CWD_SENTINELS = frozenset({"", ".", "./", "auto", "cwd"}) + + +def _clean_cwd(value: Any) -> str: + if not isinstance(value, str): + return "" + value = value.strip() + return "" if value.lower() in _CWD_SENTINELS else value + + +def _recorded_cwd(key: str) -> str: + if not key: + return "" + from tools.terminal_tool import get_session_cwd + + return _clean_cwd(get_session_cwd(key)) + + +def resolve_relay_scope_cwds( + agent: Any, task_id: str, session_id: str, platform: str +) -> tuple[str, str]: + """Return logical ``(session_cwd, turn_cwd)`` for Relay scope input. + + A task may use a worktree distinct from its owning session. Preserve remote paths as + declared and omit unknown paths instead of substituting the Hermes host's cwd. + """ + try: + task_cwd = _recorded_cwd(task_id) + except Exception: + logger.debug("Unable to read the Relay turn cwd", exc_info=True) + task_cwd = "" + + session_cwd = "" + try: + from agent.runtime_cwd import scoped_session_cwd + + session_cwd = _clean_cwd(scoped_session_cwd()) + except Exception: + logger.debug("Unable to read the scoped Relay session cwd", exc_info=True) + + if not session_cwd: + try: + from gateway.session_context import get_session_env + + session_key = get_session_env("HERMES_SESSION_KEY", "") + for key in dict.fromkeys(key for key in (session_key, session_id) if key): + if recorded := _recorded_cwd(key): + session_cwd = recorded + break + except Exception: + logger.debug("Unable to read the recorded Relay session cwd", exc_info=True) + + if not session_cwd: + session_cwd = _clean_cwd(getattr(agent, "session_cwd", None)) + + backend = "" + if not session_cwd: + try: + from tools.terminal_scope import terminal_env + + backend = terminal_env("TERMINAL_ENV", "local").strip().lower() + session_cwd = _clean_cwd(terminal_env("TERMINAL_CWD", "")) + except Exception: + logger.debug( + "Unable to read the configured Relay session cwd", exc_info=True + ) + + if not session_cwd and platform in {"", "cli"} and backend in {"", "local"}: + try: + from agent.runtime_cwd import resolve_agent_cwd + + resolved = resolve_agent_cwd() + session_cwd = _clean_cwd( + str(resolved if resolved.is_absolute() else resolved.resolve()) + ) + except Exception: + logger.debug("Unable to resolve the local Relay session cwd", exc_info=True) + + turn_cwd = task_cwd or session_cwd + if platform in {"subagent", "cron"} and task_cwd: + session_cwd = task_cwd + return session_cwd or turn_cwd, turn_cwd diff --git a/agent/relay_runtime.py b/agent/relay_runtime.py index 7433e1965b..c8c0724aff 100644 --- a/agent/relay_runtime.py +++ b/agent/relay_runtime.py @@ -73,6 +73,11 @@ def runtime_metadata(runtime_id: str, **extra: Any) -> dict[str, Any]: return {RUNTIME_SCHEMA_KEY: RUNTIME_SCHEMA_VERSION, RUNTIME_INSTANCE_KEY: runtime_id, **extra} +def _scope_input(cwd: Any = None) -> dict[str, str]: + """Return Relay scope input for a known logical working directory.""" + return {"cwd": cwd.strip()} if isinstance(cwd, str) and cwd.strip() else {} + + def _run_on_daemon_thread( fn: Callable[[], Any], *, name: str, timeout: float | None = None, timeout_message: str = "" ) -> Any: @@ -170,6 +175,7 @@ class RelaySession: segment_turns: int = 0 # turns completed within the current segment rotate_pending: bool = False # consumed at next begin_turn close_pending: bool = False # rotating compaction hit a live turn; end_turn consumes it + cwd: str = "" # latest logical working directory; reused when a session segment rotates def _load_segments_config() -> dict[str, Any]: @@ -407,7 +413,7 @@ class RelayRuntime: scope_metadata["nemo_relay_scope_role"] = "subagent" context = contextvars.Context() args = (self.relay.scope.push, SESSION_SCOPE, self.relay.ScopeType.Agent) - push_kwargs.update(handle=parent_handle, metadata=scope_metadata, input={}) + push_kwargs.update(handle=parent_handle, metadata=scope_metadata, input=_scope_input(session.cwd)) try: future = _scope_op_executor().submit(context.run, *args, **push_kwargs) except RuntimeError: @@ -421,7 +427,12 @@ class RelayRuntime: session.context = context def ensure_session( - self, event: dict[str, Any], *, data: Any = None, metadata: dict[str, Any] | None = None + self, + event: dict[str, Any], + *, + data: Any = None, + metadata: dict[str, Any] | None = None, + cwd: str | None = None, ) -> RelaySession | None: """Return the existing session scope or create it once.""" session_id = _session_id(event) @@ -438,6 +449,8 @@ class RelayRuntime: with session.lock: if session.closing: return None + if cwd is not None: + session.cwd = _scope_input(cwd).get("cwd", "") if session.handle is None: self._open_session_scope( session, {**(metadata or {}), **runtime_metadata(self.runtime_id)}, @@ -478,7 +491,8 @@ class RelayRuntime: ) def register_subagent( - self, event: dict[str, Any], *, metadata: dict[str, Any] | None = None + self, event: dict[str, Any], *, metadata: dict[str, Any] | None = None, + cwd: str | None = None, ) -> RelaySession | None: """Open a child Agent scope under its spawning turn when available.""" parent_session_id = str(event.get("parent_session_id") or "") @@ -496,7 +510,7 @@ class RelayRuntime: self._subagent_parents[child_session_id] = parent_session_id if parent_handle is not None: self._subagent_parent_handles[child_session_id] = parent_handle - return self.ensure_session({"session_id": child_session_id}, metadata=metadata) + return self.ensure_session({"session_id": child_session_id}, metadata=metadata, cwd=cwd) def unregister_subagent(self, event: dict[str, Any]) -> None: """Close a delegated session and forget its parent relationship.""" @@ -823,6 +837,7 @@ class ConversationLease: session: RelaySession | None parent_session_id: str = "" released: bool = False + turn_cwd: str = "" def live_runtime(self) -> RelayRuntime | None: """Return the real Relay host when this lease owns an open session.""" @@ -921,29 +936,47 @@ class RelaySessionCoordinator: logger.warning("Hermes Relay session initializer failed: %s", name, exc_info=True) def acquire_conversation( - self, *, profile_key: str, session_id: str, platform: str, parent_session_id: str = "", model: str = "", + self, + *, + profile_key: str, + session_id: str, + platform: str, + parent_session_id: str = "", + model: str = "", + session_cwd: str | None = None, + turn_cwd: str | None = None, ) -> ConversationLease: host = self.registry.for_profile(profile_key) or NoopRelayRuntime(profile_key, "Relay host creation was disabled") session = None if isinstance(host, RelayRuntime): context = { "profile_key": profile_key, "session_id": session_id, "platform": platform, - "parent_session_id": parent_session_id, "model": model, + "parent_session_id": parent_session_id, "model": model, "cwd": session_cwd, } session = _warn_on_error("conversation initialization", self._open_conversation_session, host, context) + if turn_cwd is not None: + effective_turn_cwd = _scope_input(turn_cwd).get("cwd", "") + elif session_cwd is not None: + effective_turn_cwd = _scope_input(session_cwd).get("cwd", "") + elif session is not None: + with session.lock: + effective_turn_cwd = session.cwd + else: + effective_turn_cwd = "" return ConversationLease( profile_key=profile_key, session_id=session_id, platform=platform, host=host, - session=session, parent_session_id=parent_session_id, + session=session, parent_session_id=parent_session_id, turn_cwd=effective_turn_cwd, ) def _open_conversation_session(self, host: RelayRuntime, context: dict[str, Any]) -> RelaySession | None: self._prepare_session(host, context) session_id, parent_session_id = context["session_id"], context["parent_session_id"] metadata = {"hermes.execution_surface": context["platform"] or "unknown"} + cwd = context.get("cwd") if parent_session_id and parent_session_id != session_id: event = {"parent_session_id": parent_session_id, "child_session_id": session_id} - return host.register_subagent(event, metadata=metadata) - return host.ensure_session({"session_id": session_id}, metadata=metadata) + return host.register_subagent(event, metadata=metadata, cwd=cwd) + return host.ensure_session({"session_id": session_id}, metadata=metadata, cwd=cwd) def begin_turn( self, @@ -981,7 +1014,8 @@ class RelaySessionCoordinator: ) turn.handle = _warn_on_error( "turn initialization", host.run_in_session, lease.session, host.relay.scope.push, - TURN_SCOPE, host.relay.ScopeType.Function, handle=lease.session.handle, input={}, + TURN_SCOPE, host.relay.ScopeType.Function, handle=lease.session.handle, + input=_scope_input(lease.turn_cwd), metadata=turn_metadata, timeout=_SCOPE_OP_TIMEOUT, ) diff --git a/agent/runtime_cwd.py b/agent/runtime_cwd.py index 7599650836..21f32e2581 100644 --- a/agent/runtime_cwd.py +++ b/agent/runtime_cwd.py @@ -44,6 +44,16 @@ def clear_session_cwd() -> None: _SESSION_CWD.set("") +def scoped_session_cwd() -> str: + """Return the current session's declared cwd without local path validation. + + Remote and container paths may not exist on the Hermes host. Callers that only need + logical workspace identity should preserve the declared value instead of resolving it. + """ + value = _SESSION_CWD.get() + return "" if value is _UNSET else str(value).strip() + + def reset_session_cwd(token: Token) -> None: """Restore the logical cwd that was active before the matching ``set_session_cwd``.""" _SESSION_CWD.reset(token) diff --git a/agent/tool_executor.py b/agent/tool_executor.py index b877a637a4..7265d18a24 100644 --- a/agent/tool_executor.py +++ b/agent/tool_executor.py @@ -1098,6 +1098,17 @@ def _commit_tool_result( # string-safe fallback so a rejected image result never poisons history. _tool_content = agent._tool_result_content_for_active_model(function_name, persisted_result) tool_message = make_tool_result_message(function_name, _tool_content, tool_call_id, effect_disposition=effect_disposition) + # Prepare presentation data before the append. The emitting completion callback + # stays below the durability fence; raw tool/model content remains unchanged. + prepare_metadata = getattr(agent, "tool_result_metadata_callback", None) + if not blocked and prepare_metadata: + try: + display_args = _redact_tool_args_for_display(function_name, function_args) or function_args + metadata = prepare_metadata(tool_call_id, function_name, display_args, function_result) + if metadata: + tool_message["display_metadata"] = metadata + except Exception as callback_error: + logging.debug("Tool result metadata callback error: %s", callback_error) messages.append(tool_message) if not _flush_session_db_after_tool_progress(agent, messages, stage=f"tool result {function_name}"): return None diff --git a/agent/turn_context.py b/agent/turn_context.py index 43f8ae03b4..113527c540 100644 --- a/agent/turn_context.py +++ b/agent/turn_context.py @@ -22,7 +22,7 @@ from agent.iteration_budget import IterationBudget from agent.memory_manager import build_memory_context_block from agent.memory_provider import is_trivial_prompt from agent.message_content import flatten_message_text -from agent.message_metadata import append_message, stamp_message_timestamp +from agent.message_metadata import PERSISTENCE_ONLY_MESSAGE_FIELDS, append_message, stamp_message_timestamp from agent.model_metadata import estimate_messages_tokens_rough, estimate_request_tokens_rough from agent.image_token_cost import bind_image_token_cost from agent.usage_anchor import anchored_context_tokens, restore_usage_anchor @@ -1220,11 +1220,12 @@ def build_api_messages( # persisted history via nested containers; see _clone_message_for_send. api_msg = _clone_message_for_send(msg) # api_content is bookkeeping (exact bytes sent), never a provider field — pop - # it from EVERY outgoing copy. display_* is display-only timeline metadata - # (strict OpenAI backends reject unknown keys); _row_id is the durable row id - # from _rows_to_conversation and only chat-completions strips underscore keys. + # it from EVERY outgoing copy. Persistence/display fields (display_*, _row_id, + # timestamp) are local bookkeeping: strict OpenAI backends reject unknown keys + # and only chat-completions strips underscore keys. The token estimator drops + # the same set, so it never prices bytes the provider never receives. _api_content = api_msg.pop("api_content", None) - for key in ("display_kind", "display_metadata", "_row_id"): + for key in PERSISTENCE_ONLY_MESSAGE_FIELDS: api_msg.pop(key, None) # Inject ephemeral context (memory prefetch + pre_llm_call user hooks) diff --git a/agent/turn_facade.py b/agent/turn_facade.py index f308203000..3c901a41cb 100644 --- a/agent/turn_facade.py +++ b/agent/turn_facade.py @@ -47,6 +47,7 @@ class TurnFacadeMixin: set_conversation_context, ) from agent.prompt_cache_scope import declared_conversation_scope_safe + from agent.relay_cwd import resolve_relay_scope_cwds from agent.review_idle_queue import QUEUE as _review_queue from agent.subagent_lifecycle import bind_subagent_parent from agent.interrupt_scope import track_in_interrupt_scope @@ -94,11 +95,19 @@ class TurnFacadeMixin: lease = admission.lease conversation_history = admission.conversation_history + relay_session_cwd, relay_turn_cwd = resolve_relay_scope_cwds( + self, + effective_task_id, + task_context["session_id"], + task_context["platform"], + ) relay_lease = relay_runtime.SESSION_COORDINATOR.acquire_conversation( profile_key=relay_runtime.current_profile_key(), session_id=task_context["session_id"], platform=task_context["platform"], parent_session_id=relay_parent_session_id, model=str(getattr(self, "model", None) or ""), + session_cwd=relay_session_cwd, + turn_cwd=relay_turn_cwd, ) relay_turn_kwargs: Dict[str, Any] = { "turn_id": relay_turn_id, diff --git a/apps/desktop/e2e/provider-setup-owner.spec.ts b/apps/desktop/e2e/provider-setup-owner.spec.ts new file mode 100644 index 0000000000..4141b840a6 --- /dev/null +++ b/apps/desktop/e2e/provider-setup-owner.spec.ts @@ -0,0 +1,451 @@ +/** + * Provider setup must keep the Settings owner's gateway AND profile. + * Two real serve backends; only OpenAI-compatible model discovery is a fixture. + * Build dist/ separately, then run this spec with HERMES_DESKTOP_PYTHON pointing + * at a dependency-complete interpreter. No OAuth account or inference is used. + */ +import { type ChildProcess, spawn, spawnSync } from 'node:child_process' +import * as fs from 'node:fs' +import * as http from 'node:http' +import * as net from 'node:net' +import * as path from 'node:path' + +import { createSandbox, findElectron, type Sandbox } from './fixtures' +import { + _electron, + collectErrorBanners, + type ElectronApplication, + expect, + installErrorBannerGuard, + type Page, + test +} from './test' + +const DESKTOP_ROOT = path.resolve(import.meta.dirname, '..') +const REPO_ROOT = path.resolve(DESKTOP_ROOT, '..', '..') +const REMOTE_ID = 'athena-fixture' +const REMOTE_LABEL = 'Athena fixture' +const PROFILE = 'leverage-ai' +const REMOTE_TOKEN = 'provider-owner-fixture-token' + +interface RpcReceipt { + endpoint: string + socketProfile: string | null + method: string + params: Record + result?: unknown + error?: unknown +} + +function isolatedEnv(home: string, hermesHome: string): Record { + // Allowlist rather than inheriting real provider keys, profile selection, + // browser account state, live desktop overrides, or the host session bus. + const env: Record = { + PATH: process.env.PATH ?? '', + HOME: home, + USERPROFILE: home, + HERMES_HOME: hermesHome, + PYTHONPATH: REPO_ROOT, + PYTHONNOUSERSITE: '1', + PYTHONDONTWRITEBYTECODE: '1', + TMPDIR: process.env.TMPDIR ?? path.dirname(home), + LANG: 'C.UTF-8', + TZ: 'UTC', + XDG_SESSION_TYPE: 'x11', + ELECTRON_OZONE_PLATFORM_HINT: 'x11' + } + + for (const key of ['DISPLAY', 'XAUTHORITY', 'TEST_WORKER_INDEX', 'SYSTEMROOT', 'WINDIR']) { + if (process.env[key]) { + env[key] = process.env[key]! + } + } + + for (const kind of ['CONFIG', 'CACHE', 'DATA', 'STATE', 'RUNTIME']) { + const dir = path.join(home, `xdg-${kind.toLowerCase()}`) + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }) + env[`XDG_${kind}_${kind === 'RUNTIME' ? 'DIR' : 'HOME'}`] = dir + } + + return env +} + +function pythonBinary(): string { + const python = + process.env.HERMES_DESKTOP_PYTHON ?? + path.join(REPO_ROOT, '.venv', process.platform === 'win32' ? 'Scripts/python.exe' : 'bin/python') + + if (!fs.existsSync(python)) { + throw new Error('Set HERMES_DESKTOP_PYTHON to an isolated dependency-complete interpreter') + } + + return python +} + +function verifyImports(python: string, env: Record): string { + const probe = spawnSync( + python, + [ + '-c', + [ + 'import json, pathlib, sys', + 'import hermes_cli.main, hermes_cli.web_server, tui_gateway.server', + 'root = pathlib.Path.cwd().resolve()', + 'paths = {name: str(pathlib.Path(sys.modules[name].__file__).resolve()) for name in ("hermes_cli.main", "hermes_cli.web_server", "tui_gateway.server")}', + 'assert all(pathlib.Path(p).is_relative_to(root) for p in paths.values()), paths', + 'sys.__stdout__.write(json.dumps(paths))' + ].join('\n') + ], + { cwd: REPO_ROOT, env, encoding: 'utf8', timeout: 60_000 } + ) + + expect(probe.status, probe.stderr).toBe(0) + + return probe.stdout +} + +function seedConfig(home: string, endpoint: string, model: string): void { + fs.mkdirSync(home, { recursive: true }) + // JSON is YAML, and these fixtures need no secret file at all. + fs.writeFileSync( + path.join(home, 'config.yaml'), + JSON.stringify( + { + model: { provider: 'custom', default: model, base_url: `${endpoint}/v1` }, + auxiliary: { title_generation: { enabled: false } } + }, + null, + 2 + ) + ) +} + +function configModel(python: string, env: Record, home: string): unknown { + const read = spawnSync( + python, + [ + '-c', + 'import json, pathlib, sys, yaml; print(json.dumps(yaml.safe_load(pathlib.Path(sys.argv[1]).read_text())["model"]))', + path.join(home, 'config.yaml') + ], + { cwd: REPO_ROOT, env, encoding: 'utf8', timeout: 10_000 } + ) + + expect(read.status, read.stderr).toBe(0) + + return JSON.parse(read.stdout) +} + +async function listen(server: net.Server): Promise { + return new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', () => resolve((server.address() as net.AddressInfo).port)) + }) +} + +async function stopChild(child: ChildProcess): Promise { + if (child.exitCode !== null || child.signalCode !== null) { + return + } + + await new Promise(resolve => { + const timer = setTimeout(() => child.kill('SIGKILL'), 10_000) + child.once('exit', () => { + clearTimeout(timer) + resolve() + }) + child.kill('SIGTERM') + }) +} + +async function startRemote(python: string, env: Record, logPath: string) { + const reservation = net.createServer() + const port = await listen(reservation) + await new Promise(resolve => reservation.close(() => resolve())) + const url = `http://127.0.0.1:${port}` + const log = fs.openSync(logPath, 'w') + + const child = spawn( + python, + ['-m', 'hermes_cli.main', 'serve', '--isolated', '--host', '127.0.0.1', '--port', String(port), '--skip-build'], + { + cwd: REPO_ROOT, + env: { ...env, HERMES_DASHBOARD_SESSION_TOKEN: REMOTE_TOKEN }, + stdio: ['ignore', log, log] + } + ) + + fs.closeSync(log) + + try { + await expect + .poll( + async () => { + if (child.exitCode !== null) { + throw new Error(`Remote serve exited: ${child.exitCode}; see ${logPath}`) + } + + return fetch(`${url}/api/status`, { + headers: { 'X-Hermes-Session-Token': REMOTE_TOKEN }, + signal: AbortSignal.timeout(2_000) + }) + .then(response => response.status) + .catch(() => 0) + }, + { timeout: 90_000, intervals: [200, 500, 1000] } + ) + .toBe(200) + } catch (error) { + await stopChild(child) + throw error + } + + return { url, child } +} + +function watchRpc(page: Page, receipts: RpcReceipt[]): void { + page.on('websocket', socket => { + const url = new URL(socket.url()) + const pending = new Map() + socket.on('framesent', ({ payload }) => { + const message = JSON.parse(payload.toString()) + + if (!message.method) { + return + } + + const receipt: RpcReceipt = { + endpoint: url.origin, + socketProfile: url.searchParams.get('profile'), + method: message.method, + params: message.params ?? {} + } + + receipts.push(receipt) + pending.set(message.id, receipt) + }) + socket.on('framereceived', ({ payload }) => { + const message = JSON.parse(payload.toString()) + const receipt = pending.get(message.id) + + if (receipt) { + receipt.result = message.result + receipt.error = message.error + } + }) + }) +} + +const gatewayGroup = (page: Page, id: string) => + page.locator(`[data-slot="profile-rail-gateway"][data-connection-id="${id}"]`) + +async function selectGateway(page: Page, id: string, label: string, profile: string): Promise { + const group = gatewayGroup(page, id) + await expect(group).toBeVisible({ timeout: 60_000 }) + await group.getByRole('button', { name: `${profile} · ${label}`, exact: true }).click() + await expect(page.getByRole('button', { name: /^Registered gateways: / })).toHaveAttribute( + 'aria-label', + `Registered gateways: ${label}`, + { timeout: 90_000 } + ) + await expect(group).toHaveAttribute('data-active', 'true') + await expect(group.getByRole('button', { name: profile, exact: true })).toHaveAttribute('aria-pressed', 'true') + await expect(page.locator('[data-slot="statusbar"]').getByText('ready', { exact: true })).toBeVisible({ + timeout: 60_000 + }) +} + +async function openProviderKeys(page: Page): Promise { + await page.getByRole('button', { name: 'Open settings', exact: true }).click() + await page.getByRole('button', { name: 'Providers', exact: true }).click() + await page.getByRole('button', { name: 'API keys', exact: true }).click() + await expect(page.getByRole('button', { name: /^Local \/ custom endpoint/ })).toBeVisible() +} + +test('Settings provider setup stays on its gateway/profile across A → B → A', async () => { + test.setTimeout(300_000) + const original = createSandbox('provider-owner') + const localHome = path.join(original.root, 'local') + const remoteHome = path.join(original.root, 'remote') + const sandbox: Sandbox = { ...original, hermesHome: path.join(localHome, '.hermes') } + const remoteHermesHome = path.join(remoteHome, '.hermes') + const remoteProfileHome = path.join(remoteHermesHome, 'profiles', PROFILE) + const python = pythonBinary() + const localEnv = isolatedEnv(localHome, sandbox.hermesHome) + const remoteEnv = isolatedEnv(remoteHome, remoteHermesHome) + const receipts: RpcReceipt[] = [] + const discoveryRequests: string[] = [] + let advertisedModel = 'remote-first' + + const endpoint = http.createServer((request, response) => { + discoveryRequests.push(request.url ?? '') + // Discovery only. An unexpected inference request must not look successful. + response.writeHead(request.url === '/v1/models' ? 200 : 404, { 'Content-Type': 'application/json' }) + response.end( + JSON.stringify( + request.url === '/v1/models' + ? { object: 'list', data: [{ id: advertisedModel, object: 'model', owned_by: 'e2e-fixture' }] } + : { error: 'This regression fixture only serves model discovery' } + ) + ) + }) + + let app: ElectronApplication | undefined + let page: Page | undefined + let remote: Awaited> | undefined + + try { + const endpointUrl = `http://127.0.0.1:${await listen(endpoint)}` + seedConfig(sandbox.hermesHome, endpointUrl, 'local-sentinel') + seedConfig(remoteHermesHome, endpointUrl, 'remote-default-sentinel') + seedConfig(remoteProfileHome, endpointUrl, 'remote-before-setup') + await test + .info() + .attach('worktree-imports', { body: verifyImports(python, remoteEnv), contentType: 'application/json' }) + remote = await startRemote(python, remoteEnv, test.info().outputPath('remote-serve.log')) + fs.writeFileSync( + path.join(sandbox.userDataDir, 'connections.json'), + JSON.stringify({ + version: 2, + primary: 'local', + launchMode: 'primary', + lastUsed: 'local', + connections: [ + { id: 'local', kind: 'local', label: 'This device' }, + { + id: REMOTE_ID, + kind: 'remote', + label: REMOTE_LABEL, + url: remote.url, + authMode: 'token', + token: { encoding: 'plain', value: REMOTE_TOKEN } + } + ] + }) + ) + expect(fs.existsSync(path.join(DESKTOP_ROOT, 'dist/electron-main.mjs')), 'Build desktop dist before E2E').toBe(true) + app = await _electron.launch({ + executablePath: findElectron(), + args: [DESKTOP_ROOT, '--disable-gpu', '--no-sandbox'], + cwd: DESKTOP_ROOT, + ...(process.env.PROVIDER_SETUP_VIDEO === '1' + ? { + recordVideo: { dir: test.info().outputPath('video'), size: { width: 1220, height: 800 } } + } + : {}), + env: { + ...localEnv, + HERMES_DESKTOP_PYTHON: python, + HERMES_DESKTOP_USER_DATA_DIR: sandbox.userDataDir, + HERMES_DESKTOP_IGNORE_EXISTING: '1', + HERMES_DESKTOP_HERMES_ROOT: REPO_ROOT, + HERMES_DESKTOP_APP_NAME: `ProviderOwnerE2E-${Date.now()}`, + HERMES_DESKTOP_SKIP_QUIT_CONFIRM: '1' + } + }) + page = await app.firstWindow() + installErrorBannerGuard(page) + watchRpc(page, receipts) + await expect(page.getByRole('button', { name: 'Open settings', exact: true })).toBeVisible({ timeout: 90_000 }) + await expect(page.locator('[data-slot="statusbar"]').getByText('ready', { exact: true })).toBeVisible({ + timeout: 60_000 + }) + const localConfig = fs.readFileSync(path.join(sandbox.hermesHome, 'config.yaml'), 'utf8') + const remoteDefault = fs.readFileSync(path.join(remoteHermesHome, 'config.yaml'), 'utf8') + + const localProfiles = () => + fs.existsSync(path.join(sandbox.hermesHome, 'profiles')) + ? fs.readdirSync(path.join(sandbox.hermesHome, 'profiles')).sort() + : [] + + expect(localProfiles()).toEqual([]) + + // A: inspect the local default through Settings, without changing it. + await openProviderKeys(page) + await page.getByRole('button', { name: 'Close settings', exact: true }).click() + await selectGateway(page, REMOTE_ID, REMOTE_LABEL, PROFILE) + + for (const model of ['remote-first', 'remote-second']) { + advertisedModel = model + await openProviderKeys(page) + await expect(page.getByText(new RegExp(`Changes on this page apply to.*${PROFILE}`))).toBeVisible() + await page.getByRole('button', { name: /^Local \/ custom endpoint/ }).click() + const input = page.getByPlaceholder('http://127.0.0.1:8000/v1') + await expect(input).toBeVisible() + await input.fill(`${endpointUrl}/v1`) + await input.scrollIntoViewIfNeeded() + await page.screenshot({ path: test.info().outputPath(`${model}-endpoint-form.png`) }) + const start = receipts.length + await page.getByRole('button', { name: 'Connect', exact: true }).click() + await expect(input).toBeHidden({ timeout: 45_000 }) + await expect + .poll(() => configModel(python, remoteEnv, remoteProfileHome)) + .toMatchObject({ + provider: expect.stringMatching(/^custom(?::.+)?$/), + default: model, + base_url: `${endpointUrl}/v1` + }) + + const setupCalls = receipts + .slice(start) + .filter(row => ['reload.env', 'setup.status', 'setup.runtime_check'].includes(row.method)) + + // A scoped setup must not mutate the launch profile's process environment. + expect(setupCalls.some(row => row.method === 'reload.env')).toBe(false) + + for (const method of ['setup.status', 'setup.runtime_check']) { + const calls = setupCalls.filter(row => row.method === method) + expect(calls, `wire request for ${method}`).not.toHaveLength(0) + + for (const call of calls) { + expect(new URL(call.endpoint).host).toBe(new URL(remote.url).host) + expect(call.params.profile).toBe(PROFILE) + expect(call.error).toBeUndefined() + } + } + + expect + .soft( + setupCalls.find(row => row.method === 'setup.runtime_check')?.result, + 'readiness must resolve the model just saved in the owning profile' + ) + .toMatchObject({ ok: true, model, profile: PROFILE }) + expect(fs.readFileSync(path.join(sandbox.hermesHome, 'config.yaml'), 'utf8')).toBe(localConfig) + expect(localProfiles()).toEqual([]) + expect(fs.readFileSync(path.join(remoteHermesHome, 'config.yaml'), 'utf8')).toBe(remoteDefault) + await page.screenshot({ path: test.info().outputPath(`${model}-saved.png`) }) + await page.getByRole('button', { name: 'Close settings', exact: true }).click() + await selectGateway(page, 'local', 'This device', 'default') + await openProviderKeys(page) + expect(configModel(python, localEnv, sandbox.hermesHome)).toMatchObject({ default: 'local-sentinel' }) + await page.screenshot({ path: test.info().outputPath(`${model}-back-on-local.png`) }) + await page.getByRole('button', { name: 'Close settings', exact: true }).click() + + if (model === 'remote-first') { + await selectGateway(page, REMOTE_ID, REMOTE_LABEL, PROFILE) + } + } + + expect(discoveryRequests).toContain('/v1/models') + expect(await collectErrorBanners(page)).toEqual([]) + } finally { + if (page && !page.isClosed()) { + await test.info().attach('last-ui', { body: await page.locator('body').innerText(), contentType: 'text/plain' }) + await page.screenshot({ path: test.info().outputPath('last-ui.png') }) + await collectErrorBanners(page) + } + + fs.writeFileSync(test.info().outputPath('setup-wire.json'), JSON.stringify(receipts, null, 2)) + await test + .info() + .attach('setup-wire', { path: test.info().outputPath('setup-wire.json'), contentType: 'application/json' }) + await app?.close() + + if (remote) { + await stopChild(remote.child) + } + + await new Promise(resolve => endpoint.close(() => resolve())) + sandbox.cleanup() + } +}) diff --git a/apps/desktop/electron/desktop-plugin-install.test.ts b/apps/desktop/electron/desktop-plugin-install.test.ts index f455cc9826..4aefbccd80 100644 --- a/apps/desktop/electron/desktop-plugin-install.test.ts +++ b/apps/desktop/electron/desktop-plugin-install.test.ts @@ -1,7 +1,8 @@ +import { execFileSync } from 'node:child_process' import fs from 'node:fs' import os from 'node:os' import path from 'node:path' -import { fileURLToPath } from 'node:url' +import { fileURLToPath, pathToFileURL } from 'node:url' import { afterEach, describe, expect, it } from 'vitest' @@ -9,6 +10,7 @@ import { desktopPluginFolderName, detectPluginComponents, findDesktopEntry, + probePluginRepo, resolvePluginGitUrl, resolveSubdirWithin } from './desktop-plugin-install' @@ -119,3 +121,32 @@ describe('detectPluginComponents', () => { }) }) }) + +describe('probePluginRepo', () => { + const roots: string[] = [] + + afterEach(() => { + for (const root of roots.splice(0)) { + fs.rmSync(root, { recursive: true, force: true }) + } + }) + + it('probes a monorepo subdirectory through the sparse partial clone', async () => { + const repo = mkdtemp('hermes-plugin-monorepo-') + roots.push(repo) + const git = (...args: string[]) => execFileSync('git', args, { cwd: repo, stdio: 'pipe' }) + const plugin = path.join(repo, 'integrations', 'hermes') + fs.mkdirSync(plugin, { recursive: true }) + fs.writeFileSync(path.join(plugin, 'plugin.yaml'), 'name: nested-agent\n') + fs.writeFileSync(path.join(plugin, '__init__.py'), 'def register(ctx): pass\n') + fs.writeFileSync(path.join(repo, 'unrelated.bin'), 'x'.repeat(4096)) + git('init', '-q') + git('config', 'uploadpack.allowFilter', 'true') + git('add', '.') + git('-c', 'user.email=fixture@example.com', '-c', 'user.name=Fixture', 'commit', '-qm', 'init') + + const result = await probePluginRepo('git', `${pathToFileURL(repo).href}#integrations/hermes`) + + expect(result).toMatchObject({ ok: true, agent: true, agentName: 'nested-agent' }) + }) +}) diff --git a/apps/desktop/electron/desktop-plugin-install.ts b/apps/desktop/electron/desktop-plugin-install.ts index d34cea1c37..abdc538bf5 100644 --- a/apps/desktop/electron/desktop-plugin-install.ts +++ b/apps/desktop/electron/desktop-plugin-install.ts @@ -262,6 +262,9 @@ function noninteractiveGitEnv(): NodeJS.ProcessEnv { } } +// Matches the backend's default `plugins.clone_timeout_seconds`. +const GIT_TIMEOUT_MS = 300_000 + function runGit(gitBin: string, args: string[], cwd?: string): Promise<{ code: number; stderr: string }> { return new Promise((resolve, reject) => { const child = spawn(gitBin, args, { @@ -275,8 +278,8 @@ function runGit(gitBin: string, args: string[], cwd?: string): Promise<{ code: n const timer = setTimeout(() => { child.kill('SIGKILL') - reject(new Error('Git clone timed out after 60 seconds.')) - }, 60_000) + reject(new Error(`Git ${args[0]} timed out after ${GIT_TIMEOUT_MS / 1000} seconds.`)) + }, GIT_TIMEOUT_MS) child.stderr?.on('data', chunk => { stderr += String(chunk) @@ -294,16 +297,38 @@ function runGit(gitBin: string, args: string[], cwd?: string): Promise<{ code: n }) } -async function cloneToTemp(gitBin: string, gitUrl: string): Promise { +async function runGitOrThrow(gitBin: string, args: string[], cwd?: string): Promise { + const { code, stderr } = await runGit(gitBin, args, cwd) + + if (code !== 0) { + throw new Error(`Git ${args[0]} failed:\n${stderr.trim()}`) + } +} + +/** Sparse-check-out only `subdir` via the classic pattern file, which older Git clients understand. */ +function sparseCheckoutPattern(subdir: string): string { + return `/${subdir.replace(/^\/+|\/+$/g, '').replace(/([\\*?[])/g, '\\$1')}/\n` +} + +// A subdirectory install is a blobless clone with a sparse checkout of that folder: a plugin inside +// a monorepo (Hindsight: 170 MB at depth 1, 2 MB for its plugin folder) otherwise downloads every +// file in the repository and times out on slow connections. +async function cloneToTemp(gitBin: string, gitUrl: string, subdir: string | null): Promise { const tmpRoot = await fsp.mkdtemp(path.join(os.tmpdir(), 'hermes-plugin-')) try { - const { code, stderr } = await runGit(gitBin, ['clone', '--depth', '1', gitUrl, tmpRoot]) + if (!subdir) { + await runGitOrThrow(gitBin, ['clone', '--depth', '1', gitUrl, tmpRoot]) - if (code !== 0) { - throw new Error(`Git clone failed:\n${stderr.trim()}`) + return tmpRoot } + await runGitOrThrow(gitBin, ['clone', '--depth', '1', '--filter=blob:none', '--no-checkout', gitUrl, tmpRoot]) + await runGitOrThrow(gitBin, ['config', 'core.sparseCheckout', 'true'], tmpRoot) + await fsp.mkdir(path.join(tmpRoot, '.git', 'info'), { recursive: true }) + await fsp.writeFile(path.join(tmpRoot, '.git', 'info', 'sparse-checkout'), sparseCheckoutPattern(subdir), 'utf8') + await runGitOrThrow(gitBin, ['checkout', 'HEAD'], tmpRoot) + return tmpRoot } catch (err) { await fsp.rm(tmpRoot, { recursive: true, force: true }).catch(() => undefined) @@ -340,7 +365,7 @@ export async function probePluginRepo(gitBin: string, identifier: string): Promi try { const { gitUrl, subdir } = resolvePluginGitUrl(identifier) const { warnings, insecure } = insecureSchemeWarnings(gitUrl) - const cloneRoot = await cloneToTemp(gitBin, gitUrl) + const cloneRoot = await cloneToTemp(gitBin, gitUrl, subdir) try { const pluginRoot = await resolvePluginRoot(cloneRoot, subdir) @@ -390,7 +415,7 @@ export async function installDesktopPluginFromGit( ): Promise { try { const { gitUrl, subdir } = resolvePluginGitUrl(identifier) - const cloneRoot = await cloneToTemp(gitBin, gitUrl) + const cloneRoot = await cloneToTemp(gitBin, gitUrl, subdir) try { const pluginRoot = await resolvePluginRoot(cloneRoot, subdir) diff --git a/apps/desktop/electron/desktop-profile-preload.test.ts b/apps/desktop/electron/desktop-profile-preload.test.ts index d8e07a0a79..954f8cdd48 100644 --- a/apps/desktop/electron/desktop-profile-preload.test.ts +++ b/apps/desktop/electron/desktop-profile-preload.test.ts @@ -8,6 +8,7 @@ const electron = vi.hoisted(() => ({ invoke: vi.fn(async () => ({ ok: true })), on: vi.fn(), removeListener: vi.fn(), + send: vi.fn(), sendSync: vi.fn(() => ({})) }, webFrame: {}, @@ -30,6 +31,18 @@ test('the native preload exposes routed peer opening and default preference even assert.deepEqual(electron.ipcRenderer.invoke.mock.lastCall, ['hermes:profile:default:set', route]) await bridge.profile.getDefault() assert.deepEqual(electron.ipcRenderer.invoke.mock.lastCall, ['hermes:profile:default:get']) + bridge.setF12ShortcutActive(true) + assert.equal(electron.ipcRenderer.send.mock.lastCall?.[0], 'hermes:f12ShortcutActive') + assert.equal(electron.ipcRenderer.send.mock.lastCall?.[1], true) + + const shortcutInputs: unknown[] = [] + const stopShortcut = bridge.onF12Shortcut((input: unknown) => shortcutInputs.push(input)) + const [shortcutChannel, shortcutListener] = electron.ipcRenderer.on.mock.lastCall! + assert.equal(shortcutChannel, 'hermes:f12-shortcut') + shortcutListener({}, { key: 'F12', repeat: true }) + assert.deepEqual(shortcutInputs, [{ key: 'F12', repeat: true }]) + stopShortcut() + assert.deepEqual(electron.ipcRenderer.removeListener.mock.lastCall, [shortcutChannel, shortcutListener]) const changes: unknown[] = [] const unsubscribe = bridge.profile.onDefaultChanged((value: unknown) => changes.push(value)) diff --git a/apps/desktop/electron/f12-shortcut.test.ts b/apps/desktop/electron/f12-shortcut.test.ts new file mode 100644 index 0000000000..5efe4b7388 --- /dev/null +++ b/apps/desktop/electron/f12-shortcut.test.ts @@ -0,0 +1,32 @@ +import assert from 'node:assert/strict' + +import { test } from 'vitest' + +import { f12ShortcutDecision, toF12KeyboardEventPayload } from './f12-shortcut' + +test('arbitrates only native F12 keydown and preserves repeat/modifiers for forwarding', () => { + const input = { + alt: true, + code: 'F12', + control: false, + isAutoRepeat: true, + key: 'F12', + meta: true, + shift: false, + type: 'keyDown' + } + + assert.equal(f12ShortcutDecision(input, true, false), 'forward') + assert.equal(f12ShortcutDecision({ ...input, type: 'keyUp' }, true, false), 'ignore') + assert.equal(f12ShortcutDecision(input, false, true), 'block') + assert.equal(f12ShortcutDecision(input, false, false), 'devtools') + assert.deepEqual(toF12KeyboardEventPayload(input), { + alt: true, + code: 'F12', + control: false, + key: 'F12', + meta: true, + repeat: true, + shift: false + }) +}) diff --git a/apps/desktop/electron/f12-shortcut.ts b/apps/desktop/electron/f12-shortcut.ts new file mode 100644 index 0000000000..b976df0a17 --- /dev/null +++ b/apps/desktop/electron/f12-shortcut.ts @@ -0,0 +1,40 @@ +export interface F12Input { + alt?: boolean + code?: string + control?: boolean + isAutoRepeat?: boolean + key: string + meta?: boolean + shift?: boolean + type: string +} + +export type F12ShortcutDecision = 'block' | 'devtools' | 'forward' | 'ignore' + +export function f12ShortcutDecision(input: F12Input, shortcutActive: boolean, disabled: boolean): F12ShortcutDecision { + if (input.type !== 'keyDown') { + return 'ignore' + } + + if (shortcutActive) { + return 'forward' + } + + if (disabled) { + return 'block' + } + + return 'devtools' +} + +export function toF12KeyboardEventPayload(input: F12Input) { + return { + alt: Boolean(input.alt), + code: input.code, + control: Boolean(input.control), + key: input.key, + meta: Boolean(input.meta), + repeat: Boolean(input.isAutoRepeat), + shift: Boolean(input.shift) + } +} diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index ce5dd3ba79..1236e5d85c 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -218,6 +218,7 @@ import { terminalScriptExtension, tuiResumeArgs } from './external-terminal' +import { f12ShortcutDecision, toF12KeyboardEventPayload } from './f12-shortcut' import { type FaviconIo, resolveFavicon } from './favicon' import { resolveFeatureFlags } from './feature-flags' import { @@ -1725,6 +1726,7 @@ const remoteHeaderSessions = new WeakSet() const remoteWsHeaderStore = createRemoteWsHeaderStore() const previewWatchers = new Map() let previewShortcutActive = false +const f12ShortcutActiveWindows = new Set() let nativeThemeListenerInstalled = false let bootProgressState = { @@ -6526,11 +6528,26 @@ function installDevToolsShortcut(window) { // Only Ctrl+Shift+I (or Cmd+Opt+I on Mac) opens DevTools. // F12 is explicitly blocked so Chromium's built-in handler doesn't open it. window.webContents.on('before-input-event', (event, input) => { + if (input.type !== 'keyDown') { + return + } + const key = input.key.toLowerCase() - // F12 opens DevTools by default; block only when the user disabled it. + // A renderer binding gets first refusal. Chromium otherwise claims F12 + // before the renderer can capture or dispatch it. if (input.key === 'F12') { - if (f12Blocked) { + const decision = f12ShortcutDecision(input, f12ShortcutActiveWindows.has(window.webContents.id), f12Blocked) + + if (decision === 'forward') { + event.preventDefault() + + window.webContents.send('hermes:f12-shortcut', toF12KeyboardEventPayload(input)) + + return + } + + if (decision === 'block') { event.preventDefault() return @@ -15824,6 +15841,18 @@ ipcMain.on('hermes:previewShortcutActive', (_event, active) => { previewShortcutActive = Boolean(active) }) +ipcMain.on('hermes:f12ShortcutActive', (event, active) => { + if (active) { + f12ShortcutActiveWindows.add(event.sender.id) + } else { + f12ShortcutActiveWindows.delete(event.sender.id) + } +}) + +app.on('web-contents-created', (_event, contents) => { + contents.once('destroyed', () => f12ShortcutActiveWindows.delete(contents.id)) +}) + ipcMain.handle('hermes:requestMicrophoneAccess', async () => { if (!IS_MAC || typeof systemPreferences.askForMediaAccess !== 'function') { return true diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts index 4e1a2fb7d8..cd6fd8d6c3 100644 --- a/apps/desktop/electron/preload.ts +++ b/apps/desktop/electron/preload.ts @@ -382,6 +382,13 @@ contextBridge.exposeInMainWorld('hermesDesktop', { } }, setDisableF12: blocked => ipcRenderer.send('hermes:devtools:disable-f12', blocked), + setF12ShortcutActive: active => ipcRenderer.send('hermes:f12ShortcutActive', Boolean(active)), + onF12Shortcut: callback => { + const listener = (_event, input) => callback(input) + ipcRenderer.on('hermes:f12-shortcut', listener) + + return () => ipcRenderer.removeListener('hermes:f12-shortcut', listener) + }, setPreviewShortcutActive: active => ipcRenderer.send('hermes:previewShortcutActive', Boolean(active)), openExternal: url => ipcRenderer.invoke('hermes:openExternal', url), mcpOauth: { diff --git a/apps/desktop/src/api/config.ts b/apps/desktop/src/api/config.ts index f0ce96655f..d17f4a4d2e 100644 --- a/apps/desktop/src/api/config.ts +++ b/apps/desktop/src/api/config.ts @@ -238,16 +238,21 @@ export function revealEnvVar(key: string, profile?: ProfileScope): Promise<{ key export function validateProviderCredential( key: string, value: string, - apiKey?: string + apiKey?: string, + profile?: ProfileScope ): Promise<{ ok: boolean; reachable: boolean; message: string; models?: string[]; resolved_base_url?: string }> { - return hermesApi<{ ok: boolean; reachable: boolean; message: string; models?: string[]; resolved_base_url?: string }>( - { - ...profileScoped(), - path: '/api/providers/validate', - method: 'POST', - body: { key, value, api_key: apiKey ?? '' } - } - ) + return window.hermesDesktop.api<{ + ok: boolean + reachable: boolean + message: string + models?: string[] + resolved_base_url?: string + }>({ + ...capabilityScoped(profile), + path: '/api/providers/validate', + method: 'POST', + body: { key, value, api_key: apiKey ?? '' } + }) } export function getCustomEndpoints(profile?: null | string): Promise { @@ -300,9 +305,9 @@ export function deleteCustomEndpoint(id: string, profile?: null | string): Promi }) } -export function listOAuthProviders(profile?: null | string): Promise { - return hermesApi({ - ...profileScoped(profile), +export function listOAuthProviders(profile?: ProfileScope): Promise { + return window.hermesDesktop.api({ + ...capabilityScoped(profile), path: '/api/providers/oauth' }) } @@ -331,10 +336,10 @@ export function submitOAuthCode( providerId: string, sessionId: string, code: string, - profile?: null | string + profile?: ProfileScope ): Promise { - return hermesApi({ - ...profileScoped(profile), + return window.hermesDesktop.api({ + ...capabilityScoped(profile), path: `/api/providers/oauth/${encodeURIComponent(providerId)}/submit`, method: 'POST', body: { session_id: sessionId, code } @@ -352,9 +357,9 @@ export function pollOAuthSession( }) } -export function cancelOAuthSession(sessionId: string, profile?: null | string): Promise<{ ok: boolean }> { - return hermesApi<{ ok: boolean }>({ - ...profileScoped(profile), +export function cancelOAuthSession(sessionId: string, profile?: ProfileScope): Promise<{ ok: boolean }> { + return window.hermesDesktop.api<{ ok: boolean }>({ + ...capabilityScoped(profile), path: `/api/providers/oauth/sessions/${encodeURIComponent(sessionId)}`, method: 'DELETE' }) diff --git a/apps/desktop/src/api/models.ts b/apps/desktop/src/api/models.ts index 9f3e34ecf8..6f38cfcc2d 100644 --- a/apps/desktop/src/api/models.ts +++ b/apps/desktop/src/api/models.ts @@ -32,7 +32,7 @@ export function getGlobalModelOptions( includeUnconfigured?: boolean explicitOnly?: boolean }, - profile?: null | string + profile?: ProfileScope ): Promise { const params = new URLSearchParams() @@ -48,8 +48,8 @@ export function getGlobalModelOptions( params.set('explicit_only', '1') } - return hermesApi({ - ...profileScoped(profile), + return window.hermesDesktop.api({ + ...capabilityScoped(profile), path: params.size > 0 ? `/api/model/options?${params.toString()}` : '/api/model/options', timeoutMs: STARTUP_REQUEST_TIMEOUT_MS }) @@ -65,12 +65,9 @@ export interface RecommendedDefaultModel { // Recommended default model for a freshly-authenticated provider. Mirrors the // curation `hermes model` does — for Nous it honors the free/paid tier so a // free user gets a free model instead of a paid default. -export function getRecommendedDefaultModel( - provider: string, - profile?: null | string -): Promise { - return hermesApi({ - ...profileScoped(profile), +export function getRecommendedDefaultModel(provider: string, profile?: ProfileScope): Promise { + return window.hermesDesktop.api({ + ...capabilityScoped(profile), path: `/api/model/recommended-default?provider=${encodeURIComponent(provider)}` }) } @@ -119,10 +116,10 @@ export function saveMoaModels( export function setModelAssignment( body: ModelAssignmentRequest, - profile?: null | string + profile?: ProfileScope ): Promise { - return hermesApi({ - ...profileScoped(profile), + return window.hermesDesktop.api({ + ...capabilityScoped(profile), path: '/api/model/set', method: 'POST', body diff --git a/apps/desktop/src/app/chat/composer/focus.test.ts b/apps/desktop/src/app/chat/composer/focus.test.ts index 2b3cb782e1..dbb7292ba8 100644 --- a/apps/desktop/src/app/chat/composer/focus.test.ts +++ b/apps/desktop/src/app/chat/composer/focus.test.ts @@ -7,9 +7,11 @@ import { focusComposerInput, getActiveComposer, markActiveComposer, + onComposerDictationRequest, onComposerFocusRequest, onComposerModelMenuRequest, releaseActiveComposer, + requestComposerDictation, requestComposerFocus, requestModelMenuToggle } from './focus' @@ -317,3 +319,19 @@ describe('requestModelMenuToggle', () => { expect(await collectModelMenuTargets()).toEqual([]) }) }) + +describe('requestComposerDictation', () => { + it('delivers the request only to the active visible composer', async () => { + mountSurface('main', true) + mountSurface('tile:front') + markActiveComposer('tile:front') + const targets: string[] = [] + const off = onComposerDictationRequest(target => targets.push(target)) + + requestComposerDictation('active') + await new Promise(resolve => window.setTimeout(resolve, 0)) + off() + + expect(targets).toEqual(['tile:front']) + }) +}) diff --git a/apps/desktop/src/app/chat/composer/focus.ts b/apps/desktop/src/app/chat/composer/focus.ts index b9d6caf863..82b6374820 100644 --- a/apps/desktop/src/app/chat/composer/focus.ts +++ b/apps/desktop/src/app/chat/composer/focus.ts @@ -50,6 +50,7 @@ const ATTACH_IMAGES_EVENT = 'hermes:composer-attach-images' const INSERT_REFS_EVENT = 'hermes:composer-insert-refs' const SUBMIT_EVENT = 'hermes:composer-submit' const VOICE_TOGGLE_EVENT = 'hermes:composer-voice-toggle' +const DICTATION_EVENT = 'hermes:composer-dictation' const MODEL_MENU_EVENT = 'hermes:composer-model-menu' /** Inline edit composer root — mounted only while a user bubble is being edited. */ @@ -366,6 +367,14 @@ export const requestVoiceToggle = (target: ComposerTarget | 'active' = 'active') export const onComposerVoiceToggleRequest = (handler: (target: ComposerTarget) => void) => subscribe<{ target: ComposerTarget }>(VOICE_TOGGLE_EVENT, ({ target }) => handler(target)) +/** Start or stop dictation on one composer. Like voice conversation, the + * rebindable action targets only the active visible composer. */ +export const requestComposerDictation = (target: ComposerTarget | 'active' = 'active') => + dispatch<{ target: ComposerTarget }>(DICTATION_EVENT, { target: resolve(target) }) + +export const onComposerDictationRequest = (handler: (target: ComposerTarget) => void) => + subscribe<{ target: ComposerTarget }>(DICTATION_EVENT, ({ target }) => handler(target)) + /** The chat surface inside the zone the pointer is over, if any. Mirrors the * tab verbs' hover-first targeting (`tabTargetGroupId`, #74447): the model * hotkey lands in the pane you're pointing at without clicking into it first. diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-voice-shortcuts.test.tsx b/apps/desktop/src/app/chat/composer/hooks/use-composer-voice-shortcuts.test.tsx new file mode 100644 index 0000000000..7d80e2211d --- /dev/null +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-voice-shortcuts.test.tsx @@ -0,0 +1,174 @@ +import { act, cleanup, render } from '@testing-library/react' +import type { ReactNode } from 'react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { markActiveComposer, requestComposerDictation, requestVoiceToggle } from '../focus' +import { ComposerScopeProvider, ComposerSurfaceProvider, MAIN_COMPOSER_SCOPE } from '../scope' + +import { useComposerVoice } from './use-composer-voice' + +const mocks = vi.hoisted(() => ({ + conversationEnabled: [] as boolean[], + dictate: vi.fn(), + endConversation: vi.fn(async () => undefined) +})) + +vi.mock('./use-voice-recorder', () => ({ + useVoiceRecorder: () => ({ + dictate: mocks.dictate, + voiceActivityState: { elapsedSeconds: 0, level: 0, status: 'idle' }, + voiceStatus: 'idle' + }) +})) + +vi.mock('./use-voice-conversation', () => ({ + useVoiceConversation: ({ enabled }: { enabled: boolean }) => { + mocks.conversationEnabled.push(enabled) + + return { end: mocks.endConversation, start: vi.fn(), status: 'idle' } + } +})) + +vi.mock('./use-voice-live-conversation', () => ({ + useVoiceLiveConversation: () => ({ end: mocks.endConversation, start: vi.fn(), status: 'idle' }) +})) + +vi.mock('./use-auto-speak-replies', () => ({ useAutoSpeakReplies: vi.fn() })) + +vi.mock('@/i18n', () => ({ + useI18n: () => ({ + t: { + notifications: { voice: {} }, + assistant: { thread: { readAloudFailed: '' } }, + settings: { config: { autosaveFailed: '' } } + } + }) +})) + +vi.mock('@/lib/haptics', () => ({ triggerHaptic: vi.fn() })) +vi.mock('@/lib/spoken-reply', () => ({ + adoptSpokenReplySession: vi.fn(), + markAssistantIdSpoken: vi.fn(), + resolveSpokenReply: vi.fn(() => null) +})) +vi.mock('@/lib/tts-lease', () => ({ + CONVERSATION_LEASE: 'conversation', + READ_ALOUD_LEASE: 'read-aloud', + syncTtsLease: vi.fn(async () => undefined) +})) +vi.mock('@/lib/wake-indicator', () => ({ clearWakeIndicator: vi.fn(), syncWakeIndicatorWithVoice: vi.fn() })) +vi.mock('@/lib/voice-live', () => ({ toLiveHistory: vi.fn(() => []) })) +vi.mock('@/store/notifications', () => ({ notify: vi.fn(), notifyError: vi.fn() })) +vi.mock('@/store/voice-live', async () => { + const { atom } = await import('nanostores') + + return { + $voiceLiveStatus: atom(null), + refreshVoiceLiveStatus: vi.fn(async () => undefined), + selectedVoiceChatMode: vi.fn(() => 'chained') + } +}) +vi.mock('@/store/voice-prefs', async () => { + const { atom } = await import('nanostores') + + return { + $autoSpeakReplies: atom(false), + $voiceStopPhrase: atom(null), + setAutoSpeakReplies: vi.fn(async () => undefined) + } +}) +vi.mock('@/store/gateway', async () => { + const { atom } = await import('nanostores') + + return { $gateway: atom(null) } +}) +vi.mock('@/store/composer-input-history', () => ({ resetBrowseState: vi.fn() })) +vi.mock('@/store/wake-word', () => ({ resumeWakeAfterVoice: vi.fn(async () => undefined) })) +vi.mock('../floating-target', () => ({ pinFloatingComposerCapture: vi.fn(() => undefined) })) + +function Composer({ disabled, target }: { disabled: boolean; target: string }) { + useComposerVoice({ + busy: false, + clearDraft: vi.fn(), + disabled, + focusInput: vi.fn(), + insertText: vi.fn(), + maxRecordingSeconds: 60, + onSubmit: vi.fn(async () => true), + onTranscribeAudio: vi.fn(async () => 'spoken text'), + sessionId: null, + target + }) + + return null +} + +function mountComposer(target: string, disabled: boolean, hidden = false) { + const scope = { ...MAIN_COMPOSER_SCOPE, target } + + return ( + + +
+ +
+
+
+ ) +} + +function renderComposers(children: ReactNode) { + return render(children) +} + +afterEach(() => { + cleanup() + document.body.innerHTML = '' + mocks.dictate.mockClear() + mocks.endConversation.mockClear() + mocks.conversationEnabled.length = 0 + markActiveComposer('main') +}) + +describe('composer voice shortcuts', () => { + it('dictates only on the active visible target and ignores a disabled target', async () => { + renderComposers( + <> + {mountComposer('main', true, true)} + {mountComposer('tile:front', false)} + + ) + markActiveComposer('tile:front') + + await act(async () => { + requestComposerDictation('active') + await new Promise(resolve => window.setTimeout(resolve, 0)) + }) + expect(mocks.dictate).toHaveBeenCalledTimes(1) + + await act(async () => { + requestComposerDictation('main') + await new Promise(resolve => window.setTimeout(resolve, 0)) + }) + expect(mocks.dictate).toHaveBeenCalledTimes(1) + }) + + it('forwards repeated dictation requests without toggling voice conversation', async () => { + renderComposers(mountComposer('main', false)) + + await act(async () => { + requestComposerDictation('active') + requestComposerDictation('active') + await new Promise(resolve => window.setTimeout(resolve, 0)) + }) + expect(mocks.dictate).toHaveBeenCalledTimes(2) + expect(mocks.endConversation).not.toHaveBeenCalled() + + await act(async () => { + requestVoiceToggle('active') + await new Promise(resolve => window.setTimeout(resolve, 0)) + }) + expect(mocks.dictate).toHaveBeenCalledTimes(2) + expect(mocks.conversationEnabled).toContain(true) + }) +}) 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 index a46513802c..a411dbcc62 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-voice.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-voice.ts @@ -19,7 +19,7 @@ import { resumeWakeAfterVoice } from '@/store/wake-word' import { pinFloatingComposerCapture } from '../floating-target' import type { ComposerTarget } from '../focus' -import { onComposerVoiceToggleRequest } from '../focus' +import { onComposerDictationRequest, onComposerVoiceToggleRequest } from '../focus' import { useComposerScope, useComposerSurfaceId } from '../scope' import type { ChatBarProps } from '../types' @@ -313,6 +313,14 @@ export function useComposerVoice({ [target, toggleVoiceConversation] ) + // The bindable `composer.dictate` action shares the mic button's callback, + // including its recording/transcribing state machine. Ignore disabled + // composers so an unavailable draft cannot acquire the microphone. + useEffect( + () => onComposerDictationRequest(requested => requested === target && !disabled && dictate()), + [dictate, disabled, target] + ) + useEffect(() => { if (target === 'main' && !disabled && takeVoiceConversationStart(voiceStartRequest) && !voiceConversationActive) { activateConversation() diff --git a/apps/desktop/src/app/command-palette/index.tsx b/apps/desktop/src/app/command-palette/index.tsx index 98a3cf7f39..3e5873b8d8 100644 --- a/apps/desktop/src/app/command-palette/index.tsx +++ b/apps/desktop/src/app/command-palette/index.tsx @@ -46,6 +46,7 @@ import { Package, Palette, PawPrint, + Pin, Plus, RefreshCw, Settings, @@ -70,11 +71,13 @@ import { setCommandPaletteOpen } from '@/store/command-palette' import { $bindings, bindingsFor } from '@/store/keybinds' -import { $dismissedAutoProjectIds, filterVisibleProjects } from '@/store/layout' +import { $dismissedAutoProjectIds, $pinnedSessionIds, filterVisibleProjects } from '@/store/layout' import { openPetGenerate } from '@/store/pet-generate' import { openBrowserTab } from '@/store/preview' import { $projectTree, goToProject, openFolderAsProject, requestStartWorkSession } from '@/store/projects' -import { $connection } from '@/store/session' +import { $connection, $cronSessions, $messagingSessions, $sessions } from '@/store/session' +import { $unconfirmedPinWrites } from '@/store/session-pin-sync' +import { $removedSessionIds } from '@/store/session-removal' import { runGatewayRestart } from '@/store/system-actions' import { $backendUpdateApply, @@ -89,6 +92,7 @@ import { luminance } from '@/themes/color' import { type ThemeMode, useTheme } from '@/themes/context' import { isUserTheme, resolveTheme } from '@/themes/user-themes' +import { buildSessionByAnyId, resolvePinnedSessions } from '../chat/sidebar/session-index' import { openSessionFromPicker, openSessionIntentFromModifiers } from '../open-session' import { AGENTS_ROUTE, @@ -393,6 +397,11 @@ const toSessionEntry = (session: SessionRow): SessionEntry => ({ title: sessionTitle(session) }) +// Search terms beyond the label: the preview and branch, so a session is +// findable by what it's about, not only what it's called. +const sessionKeywords = (session: SessionEntry, ...tags: string[]): string[] => + [...tags, 'chat', 'session', session.preview, session.git_branch].filter((word): word is string => !!word) + type NonConfigSettingsLabel = 'about' | 'archivedChats' | 'gateway' | 'keysSettings' | 'keysTools' | 'mcp' | 'providerAccounts' | 'providerApiKeys' @@ -637,21 +646,65 @@ function CommandPaletteBody({ onExited }: { onExited: () => void }) { queryFn: () => getHermesConfigRecord() }) + // staleTime 0 (not the 60s client default): renames, pins, and archives + // happen in the sidebar while this component is unmounted, so nothing can + // invalidate these keys — every open must revalidate. The cached page + // still paints instantly; the live-store overlay below covers the gap. const sessionsQuery = useQuery({ queryKey: ['command-palette', 'sessions'], - queryFn: () => listAllProfileSessions(200, 1, 'exclude') + queryFn: () => listAllProfileSessions(200, 1, 'exclude'), + staleTime: 0 }) const archivedQuery = useQuery({ queryKey: ['command-palette', 'archived'], - queryFn: () => listAllProfileSessions(200, 0, 'only') + queryFn: () => listAllProfileSessions(200, 0, 'only'), + staleTime: 0 }) + const liveSessions = useStore($sessions) + const liveCronSessions = useStore($cronSessions) + const liveMessagingSessions = useStore($messagingSessions) + const pinnedSessionIds = useStore($pinnedSessionIds) + const unconfirmedPinWrites = useStore($unconfirmedPinWrites) + const removedSessionIds = useStore($removedSessionIds) + // getServers is the shared choke point that also drops malformed (null/ // scalar) entries, so the palette never lists a server the MCP tab dropped. const mcpServers = useMemo(() => Object.keys(getServers(configQuery.data ?? null)).sort(), [configQuery.data]) - const sessions = useMemo(() => (sessionsQuery.data?.sessions ?? []).map(toSessionEntry), [sessionsQuery.data]) + // The sidebar's stores are where a rename / pin / archive lands first (the + // server page confirms later). Overlay them on the fetched 200-row page so + // the palette says what the sidebar says: same title, same pin, and no row + // the user just archived or deleted. + const liveRows = useMemo(() => { + const byId = new Map( + [...liveCronSessions, ...liveMessagingSessions, ...liveSessions].map(row => [row.id, row] as const) + ) + + return (sessionsQuery.data?.sessions ?? []) + .filter(session => !removedSessionIds.has(session.id)) + .map(session => { + const live = byId.get(session.id) + + return live ? { ...session, pinned: live.pinned, title: live.title } : session + }) + }, [liveCronSessions, liveMessagingSessions, liveSessions, removedSessionIds, sessionsQuery.data]) + + // Same resolution as the sidebar's Pinned section: local pin order first, + // then server-flagged pins, minus our own in-flight unpins. + const pinnedSessions = useMemo(() => { + const byAnyId = buildSessionByAnyId(liveRows, [], []) + + return resolvePinnedSessions(pinnedSessionIds, byAnyId, liveRows, unconfirmedPinWrites).map(toSessionEntry) + }, [liveRows, pinnedSessionIds, unconfirmedPinWrites]) + + const sessions = useMemo(() => { + const pinned = new Set(pinnedSessions.map(session => session.id)) + + return liveRows.filter(session => !pinned.has(session.id)).map(toSessionEntry) + }, [liveRows, pinnedSessions]) + const archivedSessions = useMemo(() => (archivedQuery.data?.sessions ?? []).map(toSessionEntry), [archivedQuery.data]) // Search/sub-page are local to a mount, and this component remounts per open @@ -1162,18 +1215,28 @@ function CommandPaletteBody({ onExited }: { onExited: () => void }) { })) }) + // Pinned before Sessions: rankGroups' stable sort keeps source order on + // equal scores, so a pin wins a tie with an unpinned row of the same name. + if (pinnedSessions.length > 0) { + result.push({ + heading: t.sidebar.pinned, + items: pinnedSessions.map(session => ({ + icon: Pin, + id: `pinned-${session.id}`, + keywords: sessionKeywords(session, 'pinned'), + label: session.title, + runWithEvent: goSession(session.id) + })) + }) + } + if (sessions.length > 0) { result.push({ heading: t.commandCenter.sections.sessions, items: sessions.map(session => ({ icon: MessageCircle, id: `session-${session.id}`, - keywords: [ - 'chat', - 'session', - ...(session.preview ? [session.preview] : []), - ...(session.git_branch ? [session.git_branch] : []) - ], + keywords: sessionKeywords(session), label: session.title, runWithEvent: goSession(session.id) })) @@ -1182,7 +1245,7 @@ function CommandPaletteBody({ onExited }: { onExited: () => void }) { const fieldItems = [ ...settingsCatalog.subpageEntries, - ...settingsCatalog.appearanceEntries, + ...settingsCatalog.settingEntries, ...settingsCatalog.configEntries ].map(settingsEntryItem) @@ -1230,13 +1293,7 @@ function CommandPaletteBody({ onExited }: { onExited: () => void }) { items: archivedSessions.map(session => ({ icon: Archive, id: `archived-${session.id}`, - keywords: [ - 'archived', - 'chat', - 'session', - ...(session.preview ? [session.preview] : []), - ...(session.git_branch ? [session.git_branch] : []) - ], + keywords: sessionKeywords(session, 'archived'), label: session.title, run: go(`${SETTINGS_ROUTE}?tab=sessions&session=${encodeURIComponent(session.id)}`) })) @@ -1251,6 +1308,7 @@ function CommandPaletteBody({ onExited }: { onExited: () => void }) { goSession, mcpServers, mode, + pinnedSessions, previewTheme, resolvedMode, resolveThemeMode, @@ -1308,7 +1366,7 @@ function CommandPaletteBody({ onExited }: { onExited: () => void }) { heading: cc.settingsFields, items: [ ...settingsCatalog.subpageEntries, - ...settingsCatalog.appearanceEntries, + ...settingsCatalog.settingEntries, ...settingsCatalog.configEntries ].map(settingsEntryItem) }) diff --git a/apps/desktop/src/app/contrib/hooks/use-background-sync.ts b/apps/desktop/src/app/contrib/hooks/use-background-sync.ts index d34297bf12..7f4b2d0e74 100644 --- a/apps/desktop/src/app/contrib/hooks/use-background-sync.ts +++ b/apps/desktop/src/app/contrib/hooks/use-background-sync.ts @@ -8,6 +8,7 @@ import { type ChatMessage, preserveLocalAssistantErrors, sealOpenToolParts, toCh import { createClientSessionState } from '@/lib/chat-runtime' import { sessionMessagesSignature } from '@/lib/session-signatures' import { latestSessionTodos } from '@/lib/todos' +import { pendingSessionReplay } from '@/store/gateway' import { $sidebarShowArchived } from '@/store/layout' import { $changeEventsAvailable, $cronChangeTick, $sessionsChangeTick } from '@/store/live-sync' import { $onBattery, batteryPollInterval } from '@/store/power' @@ -222,12 +223,31 @@ export async function reconcileTileTranscripts({ const profileScope = profileScopeForTranscriptSession(tile) const signatureKey = tileTranscriptSignatureKey(tile) - const messagesAtRequest = $sessionStates.get()[runtimeSessionId]?.messages try { + const replay = pendingSessionReplay(runtimeSessionId) + + if (replay && !(await replay)) { + continue + } + + if ( + requestId !== requestSequenceRef.current || + !tileStillPresent() || + tileRuntimeOwnsLiveState(runtimeSessionId) + ) { + continue + } + + const messagesAtRequest = $sessionStates.get()[runtimeSessionId]?.messages // Passive: a hidden tile's refresh must never cold-start its owner // backend or hold a pool slot (#103375); no warm backend = retry next tick. const latest = await getLatestSessionMessages(storedSessionId, profileScope, { passive: true }) + const replayAtReturn = pendingSessionReplay(runtimeSessionId) + + if (replayAtReturn && !(await replayAtReturn)) { + continue + } const current = $sessionStates.get()[runtimeSessionId] @@ -295,6 +315,12 @@ export async function hydrateStoredSessionTranscript({ storedProfile: ProfileScope updateSessionState: ActiveTranscriptRefreshDeps['updateSessionState'] }): Promise { + const replay = pendingSessionReplay(runtimeSessionId) + + if (replay && !(await replay)) { + return + } + const messagesAtRequest = $sessionStates.get()[runtimeSessionId]?.messages const superseded = () => @@ -312,6 +338,11 @@ export async function hydrateStoredSessionTranscript({ try { const latest = await getLatestSessionMessages(storedSessionId, storedProfile) + const replayAtReturn = pendingSessionReplay(runtimeSessionId) + + if (replayAtReturn && !(await replayAtReturn)) { + return + } // This fallback belongs to the completed turn, not any subsequent turn // that ran during the read or retry delay. Its todo restore is stale too. @@ -378,6 +409,22 @@ export async function reconcileActiveTranscript({ const requestId = requestSequenceRef.current + 1 requestSequenceRef.current = requestId + const replay = pendingSessionReplay(runtimeSessionId) + + if (replay && !(await replay)) { + return + } + + if ( + requestId !== requestSequenceRef.current || + busyRef.current || + tileRuntimeOwnsLiveState(runtimeSessionId) || + selectedStoredSessionIdRef.current !== storedSessionId || + activeSessionIdRef.current !== runtimeSessionId + ) { + return + } + // Busy at both endpoints can be false even though an entire turn streamed // while HTTP was in flight. Never let that older read replace newer text. const messagesAtRequest = $sessionStates.get()[runtimeSessionId]?.messages @@ -386,6 +433,12 @@ export async function reconcileActiveTranscript({ const profileScope: ProfileScope = profileScopeForTranscriptSession(stored) const latest = await getLatestSessionMessages(storedSessionId, profileScope) + const replayAtReturn = pendingSessionReplay(runtimeSessionId) + + if (replayAtReturn && !(await replayAtReturn)) { + return + } + const current = $sessionStates.get()[runtimeSessionId] if ( diff --git a/apps/desktop/src/app/hooks/use-keybinds.ts b/apps/desktop/src/app/hooks/use-keybinds.ts index 347f6ebe9e..d28a663554 100644 --- a/apps/desktop/src/app/hooks/use-keybinds.ts +++ b/apps/desktop/src/app/hooks/use-keybinds.ts @@ -69,7 +69,12 @@ import { toggleStatusbarVisible } from '@/store/statusbar-prefs' import { openNewWindow } from '@/store/windows' import { useTheme } from '@/themes/context' -import { requestComposerFocus, requestModelMenuToggle, requestVoiceToggle } from '../chat/composer/focus' +import { + requestComposerDictation, + requestComposerFocus, + requestModelMenuToggle, + requestVoiceToggle +} from '../chat/composer/focus' import { handleComposerFocusChord } from '../chat/composer/focus-chord' import { handleWindowPaste } from '../chat/composer/paste-to-focus' import { openSession } from '../open-session' @@ -197,6 +202,7 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void { } }, 'composer.voice': requestVoiceToggle, + 'composer.dictate': requestComposerDictation, // On the Settings overlay, ⌘K scopes to settings search; the second press // (or Esc) still closes as usual via toggle. @@ -325,6 +331,43 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void { [] ) + useEffect(() => { + const updateF12Ownership = () => { + const hasF12Binding = [...$comboIndex.get().keys()].some(combo => combo === 'f12' || combo.endsWith('+f12')) + window.hermesDesktop?.setF12ShortcutActive?.(hasF12Binding || $capture.get() !== null) + } + + const stopBindings = $comboIndex.subscribe(updateF12Ownership) + const stopCapture = $capture.subscribe(updateF12Ownership) + + return () => { + stopBindings() + stopCapture() + window.hermesDesktop?.setF12ShortcutActive?.(false) + } + }, []) + + useEffect(() => { + const stopF12Shortcut = window.hermesDesktop?.onF12Shortcut?.(input => { + const target = document.activeElement ?? document.body ?? document.documentElement + target.dispatchEvent( + new KeyboardEvent('keydown', { + altKey: input.alt, + bubbles: true, + cancelable: true, + code: input.code, + ctrlKey: input.control, + key: input.key, + metaKey: input.meta, + repeat: input.repeat, + shiftKey: input.shift + }) + ) + }) + + return () => stopF12Shortcut?.() + }, []) + useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { // An active IME composition owns the keyboard. Windows Chinese IMEs diff --git a/apps/desktop/src/app/model-picker-overlay.test.tsx b/apps/desktop/src/app/model-picker-overlay.test.tsx new file mode 100644 index 0000000000..347fa6b8c6 --- /dev/null +++ b/apps/desktop/src/app/model-picker-overlay.test.tsx @@ -0,0 +1,82 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import type { WritableAtom } from 'nanostores' +import { afterEach, expect, it, vi } from 'vitest' + +import { I18nProvider } from '@/i18n' +import { requestModelOptions } from '@/lib/model-options' +import { startManualOnboarding } from '@/store/onboarding' +import { $gatewayState, $modelPickerOpen, $selectedStoredSessionId } from '@/store/session' +import { $focusedTreePaneId as $focusedTreePaneIdMock } from '@/store/session-focus' +import { $sessionTiles } from '@/store/session-states' +import { stubMenuDomApis, stubResizeObserver } from '@/test/jsdom' + +import { ModelPickerOverlay } from './model-picker-overlay' + +// The mock below replaces the computed store with a writable atom. +const $focusedTreePaneId = $focusedTreePaneIdMock as unknown as WritableAtom + +vi.mock('@/store/session-focus', async () => { + const { atom } = await import('nanostores') + + return { $focusedTreePaneId: atom(null) } +}) +vi.mock('@/hermes', async importOriginal => ({ + ...(await importOriginal>()), + getLocalModelsStatus: vi.fn().mockResolvedValue({ loading: {} }) +})) +vi.mock('@/lib/model-options', async importOriginal => ({ + ...(await importOriginal>()), + requestModelOptions: vi.fn().mockResolvedValue({ model: '', provider: '', providers: [] }) +})) +vi.mock('@/store/onboarding', async importOriginal => ({ + ...(await importOriginal>()), + startManualOnboarding: vi.fn() +})) + +stubResizeObserver() +stubMenuDomApis() + +afterEach(() => { + cleanup() + vi.clearAllMocks() + $sessionTiles.set([]) + $focusedTreePaneId.set(null) + $selectedStoredSessionId.set(null) + $modelPickerOpen.set(false) + $gatewayState.set('idle') +}) + +it('reads the catalog from the backend profile but hands the Desktop alias to provider setup', async () => { + $gatewayState.set('open') + $modelPickerOpen.set(true) + $selectedStoredSessionId.set('primary-a') + $sessionTiles.set([ + { + ownerRoute: { connectionId: 'connection-b', profile: 'desktop-b', targetProfile: 'backend-b' }, + storedSessionId: 'tile-b' + } + ]) + $focusedTreePaneId.set('session-tile:tile-b') + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + + render( + + + undefined} + ownerConnectionId="connection-a" + profile="default" + requestGateway={async () => undefined as never} + /> + + + ) + + await waitFor(() => expect(requestModelOptions).toHaveBeenCalled()) + expect(vi.mocked(requestModelOptions).mock.calls.every(([options]) => options.profile === 'backend-b')).toBe(true) + + fireEvent.click(await screen.findByRole('button', { name: 'Add provider' })) + expect(startManualOnboarding).toHaveBeenCalledWith(undefined, { connectionId: 'connection-b', profile: 'desktop-b' }) + client.clear() +}) diff --git a/apps/desktop/src/app/model-picker-overlay.tsx b/apps/desktop/src/app/model-picker-overlay.tsx index 34c8806d9d..9ad25f312d 100644 --- a/apps/desktop/src/app/model-picker-overlay.tsx +++ b/apps/desktop/src/app/model-picker-overlay.tsx @@ -97,6 +97,7 @@ export function ModelPickerOverlay({ profile={pickerOwner.profile} request={pickerOwner.route ? requestPickerGateway : undefined} sessionId={sessionId} + setupProfile={pickerOwner.route?.profile ?? pickerOwner.profile} /> ) } diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts index 5dd13612a0..e67942d39d 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts @@ -34,6 +34,7 @@ import { $gateway, openGatewayForAgent, openGatewayForProfile, + pendingSessionReplay, requestGatewayForAgent, retainGatewayForAgent } from '@/store/gateway' @@ -1015,13 +1016,29 @@ export function useSessionActions({ const routeToken = getRouteToken() resumeRequestRef.current = requestId const resumedSameSelectedSession = selectedStoredSessionIdRef.current === storedSessionId - const resumeStartMessages = resumedSameSelectedSession ? $messages.get() : [] const isCurrentResume = () => resumeRequestRef.current === requestId && selectedStoredSessionIdRef.current === storedSessionId && getRouteToken() === routeToken + // A reconnect re-resumes the runtime this view is streaming. Let its + // replay land while that runtime still owns the view. Otherwise the REST + // read paints the finished turn first and the replayed rows are then + // overlaid onto it as concurrent runtime changes: the turn shows twice. + const viewRuntimeId = resumedSameSelectedSession ? activeSessionIdRef.current : null + const viewReplay = viewRuntimeId ? pendingSessionReplay(viewRuntimeId) : undefined + + if (viewReplay) { + await viewReplay + + if (!isCurrentResume()) { + return + } + } + + const resumeStartMessages = resumedSameSelectedSession ? $messages.get() : [] + // Paint the click before the profile-resolve / gateway-swap awaits below, // so there's zero dead air: highlight the row instantly (the sidebar reads // $selectedStoredSessionId) and, for a cold target, drop the previous @@ -1290,6 +1307,21 @@ export function useSessionActions({ setSessionStartedAt(Date.now()) try { + const replay = pendingSessionReplay(cachedRuntimeId) + + // Only ordering matters here. A lost socket (false) still goes on + // to session.activate so its existing branches own the outcome: + // degraded warm cache on a transport error, cold resume when the + // runtime is gone, or a normal rebind on a redialed socket (whose + // history publication is re-gated after the REST read below). + if (replay) { + await replay + + if (!isCurrentResume()) { + return + } + } + let activated: SessionResumeResult | null = null const activateStartedAt = Date.now() / 1000 const activateBaselineState = sessionStateByRuntimeIdRef.current.get(cachedRuntimeId) ?? cachedViewState @@ -1463,6 +1495,13 @@ export function useSessionActions({ if (persistedTranscriptPromise) { const persisted = await persistedTranscriptPromise + const replayAtReturn = pendingSessionReplay(cachedRuntimeId) + + if (replayAtReturn && !(await replayAtReturn)) { + hydration.release() + + return + } // Navigation only revokes foreground publication, not this // runtime's display read. Edits/rebinds revoke both. @@ -1766,6 +1805,15 @@ export function useSessionActions({ // Non-fatal: gateway resume below can still hydrate the session. } + // The socket can drop and redial while REST is in flight. Painting now + // would let the new socket's replay append the same turn again; a lost + // socket (false) drops this read and the resume below binds without it. + const viewReplayAtReturn = viewRuntimeId ? pendingSessionReplay(viewRuntimeId) : undefined + + if (prefetchedResult && viewReplayAtReturn && !(await viewReplayAtReturn)) { + prefetchedResult = null + } + // A completed read still warms its exact durable scope after navigation. // It must not adopt a runtime or touch the foreground on that path. if ( diff --git a/apps/desktop/src/app/session/hooks/warm-resume-replay-barrier.test.tsx b/apps/desktop/src/app/session/hooks/warm-resume-replay-barrier.test.tsx new file mode 100644 index 0000000000..9857fbc9b7 --- /dev/null +++ b/apps/desktop/src/app/session/hooks/warm-resume-replay-barrier.test.tsx @@ -0,0 +1,400 @@ +import { type GatewayEvent, JsonRpcGatewayClient } from '@hermes/shared' +import { QueryClient } from '@tanstack/react-query' +import { act, cleanup, renderHook } from '@testing-library/react' +import { useRef } from 'react' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' + +import { reconcileActiveTranscript } from '@/app/contrib/hooks/use-background-sync' +import { getLatestSessionMessages } from '@/hermes' +import { chatMessageText, toChatMessages } from '@/lib/chat-messages' +import { resetInFlightTurnJournalStateForTests } from '@/lib/inflight-turn-journal' +import { setPrimaryGateway } from '@/store/gateway' +import { $activeGatewayProfile } from '@/store/profile' +import { + $messages, + _resetSessionOwnerHintsForTests, + setActiveSessionId, + setAwaitingResponse, + setBusy, + setConnection, + setMessages, + setSelectedStoredSessionId, + setSessions +} from '@/store/session' +import { clearAllSessionStates } from '@/store/session-states' +import type { SessionMessage, SessionResumeResult } from '@/types/hermes' + +import { useMessageStream } from './use-message-stream' +import { useSessionActions } from './use-session-actions' +import { useSessionStateCache } from './use-session-state-cache' + +vi.mock('@/hermes', async original => ({ + ...(await original>()), + getLatestSessionMessages: vi.fn() +})) +vi.mock('@/store/profile', async original => ({ + ...(await original>()), + ensureGatewayProfile: vi.fn().mockResolvedValue(undefined) +})) + +const storedId = 'warm-replay-stored' +const runtimeId = 'warm-replay-runtime' +const noop = async () => undefined +const user: SessionMessage = { id: 1, role: 'user', content: 'Review example', timestamp: 1 } + +class Socket extends EventTarget { + readyState = 0 + sent: { id: string; method: string; params: Record }[] = [] + send(data: string) { + this.sent.push(JSON.parse(data)) + } + close() { + this.readyState = 3 + this.dispatchEvent(new CloseEvent('close')) + } + open() { + this.readyState = 1 + this.dispatchEvent(new Event('open')) + } + frame(frame: unknown) { + this.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(frame) })) + } + event(event: GatewayEvent) { + this.frame({ jsonrpc: '2.0', method: 'event', params: event }) + } +} + +function event(seq: number, type: GatewayEvent['type'], payload: Record = {}): GatewayEvent { + return { session_id: runtimeId, seq, type, payload: { timestamp: seq, ...payload } } as GatewayEvent +} + +const replay = [ + event(2, 'message.start'), + event(3, 'message.delta', { text: 'Finished result.' }), + event(4, 'message.complete', { text: 'Finished result.' }), + event(5, 'session.info', { running: false }) +] + +const snapshot: SessionResumeResult = { + session_id: runtimeId, + resumed: storedId, + messages: [], + message_count: 0, + running: false +} + +async function mountWithPendingReplay() { + const requestGateway = vi.fn().mockResolvedValue(snapshot) + + const hook = renderHook(() => { + const busyRef = useRef(false) + const creatingSessionRef = useRef(false) + const queryClient = useRef(new QueryClient()).current + + const cache = useSessionStateCache({ + activeSessionId: null, + selectedStoredSessionId: null, + busyRef, + setMessages, + setBusy, + setAwaitingResponse + }) + + const actions = useSessionActions({ + ...cache, + activeSessionId: null, + selectedStoredSessionId: null, + busyRef, + creatingSessionRef, + getRouteToken: () => 'A', + getRoutedStoredSessionId: () => null, + navigate: vi.fn(), + requestGateway + }) + + const stream = useMessageStream({ + ...cache, + queryClient, + hydrateFromStoredSession: noop, + refreshHermesConfig: noop, + refreshSessions: noop + }) + + return { cache, actions, stream } + }) + + const sockets: Socket[] = [] + + const client = new JsonRpcGatewayClient({ + heartbeatIntervalMs: 0, + heartbeatDeadlineMs: 0, + socketFactory: () => { + const socket = new Socket() + sockets.push(socket) + + return socket as unknown as WebSocket + } + }) + + setPrimaryGateway(client) + client.onEvent(gatewayEvent => hook.result.current.stream.handleGatewayEvent(gatewayEvent)) + + const connect = async () => { + const promise = client.connect('ws://fixture.test') + const socket = sockets.at(-1)! + socket.open() + await promise + + return socket + } + + // Warm cache: the runtime is already known and has seen seq 1. + act(() => { + hook.result.current.cache.updateSessionState( + runtimeId, + state => ({ ...state, messages: toChatMessages([user]) }), + storedId + ) + }) + const first = await connect() + act(() => first.event(event(1, 'session.info', { running: false }))) + client.invalidate() + const second = await connect() + const request = second.sent.find(item => item.method === 'session.events.since')! + expect(request.params).toMatchObject({ session_id: runtimeId, last_seen: 1 }) + + return { ...hook, client, connect, requestGateway, second, request } +} + +beforeEach(() => { + localStorage.clear() + clearAllSessionStates() + resetInFlightTurnJournalStateForTests() + _resetSessionOwnerHintsForTests() + $activeGatewayProfile.set('default') + setConnection(null) + setMessages([]) + setActiveSessionId(null) + setSelectedStoredSessionId(null) + setBusy(false) + setAwaitingResponse(false) + setSessions([ + { + id: storedId, + title: storedId, + source: 'desktop', + message_count: 3, + tool_call_count: 0, + is_active: true, + started_at: 1, + last_active: 1, + ended_at: null, + model: null, + preview: null, + input_tokens: 0, + output_tokens: 0 + } + ]) + vi.mocked(getLatestSessionMessages).mockReset() + vi.mocked(getLatestSessionMessages).mockResolvedValue({ + session_id: storedId, + messages: [user, { id: 2, role: 'assistant', content: 'Finished result.', timestamp: 2 }] + }) +}) + +afterEach(() => { + cleanup() + clearAllSessionStates() + resetInFlightTurnJournalStateForTests() + setPrimaryGateway(null) + localStorage.clear() + setSessions([]) + setMessages([]) + setActiveSessionId(null) + setSelectedStoredSessionId(null) + setBusy(false) + setAwaitingResponse(false) + vi.restoreAllMocks() +}) + +const activateCalls = (requestGateway: ReturnType) => + requestGateway.mock.calls.filter(([method]) => method === 'session.activate') + +it('waits for reconnect replay before activating and reading warm history', async () => { + const { result, client, requestGateway, second, request } = await mountWithPendingReplay() + + try { + let pending!: Promise + await act(async () => { + pending = result.current.actions.resumeSession(storedId, true) + }) + // The completed turn is already durable; neither the activation snapshot + // nor REST history may publish it ahead of the replay that carries it. + expect(activateCalls(requestGateway)).toHaveLength(0) + expect(getLatestSessionMessages).not.toHaveBeenCalled() + + await act(async () => { + second.frame({ id: request.id, jsonrpc: '2.0', result: { events: replay } }) + await pending + }) + + expect(activateCalls(requestGateway)).toHaveLength(1) + const rows = result.current.cache.sessionStateByRuntimeIdRef.current.get(runtimeId)!.messages + const text = rows.map(chatMessageText).join('\n') + expect(text).toContain('Review example') + expect(text.match(/Finished result\./g)).toHaveLength(1) + } finally { + client.close() + } +}) + +it('still settles a warm resume through session.activate when its replay socket is lost', async () => { + const { result, client, requestGateway } = await mountWithPendingReplay() + + requestGateway.mockImplementation(async (method: string) => { + if (method === 'session.activate') { + throw new Error('session not found') + } + + return snapshot + }) + + try { + let pending!: Promise + await act(async () => { + pending = result.current.actions.resumeSession(storedId, true) + }) + expect(activateCalls(requestGateway)).toHaveLength(0) + + await act(async () => { + client.invalidate() + await pending + }) + + // A lost socket is not proof the runtime is alive or gone: activation + // decides, and a gone runtime falls back to a cold resume instead of + // leaving the view on an unrebound warm cache. + expect(activateCalls(requestGateway)).toHaveLength(1) + expect(requestGateway.mock.calls.some(([method]) => method === 'session.resume')).toBe(true) + } finally { + client.close() + } +}) + +it('holds a reconnect re-resume of the selected session behind its replay (cold path)', async () => { + const { result, client, requestGateway, second, request } = await mountWithPendingReplay() + const { activeSessionIdRef, runtimeIdByStoredSessionIdRef, selectedStoredSessionIdRef } = result.current.cache + + // The view is streaming this runtime, but the warm mapping is gone, so the + // route's reconnect re-resume takes the cold REST + session.resume path. + selectedStoredSessionIdRef.current = storedId + activeSessionIdRef.current = runtimeId + runtimeIdByStoredSessionIdRef.current.delete(storedId) + + try { + let pending!: Promise + await act(async () => { + pending = result.current.actions.resumeSession(storedId, true) + }) + expect(getLatestSessionMessages).not.toHaveBeenCalled() + expect(requestGateway).not.toHaveBeenCalled() + + await act(async () => { + second.frame({ id: request.id, jsonrpc: '2.0', result: { events: replay } }) + await pending + }) + + expect(getLatestSessionMessages).toHaveBeenCalledTimes(1) + expect(requestGateway.mock.calls.some(([method]) => method === 'session.resume')).toBe(true) + expect( + $messages + .get() + .map(chatMessageText) + .join('\n') + .match(/Finished result\./g) + ).toHaveLength(1) + } finally { + client.close() + } +}) + +it('does not paint a cold re-resume read ahead of a replay redialed while REST was in flight', async () => { + const { result, client, connect } = await mountWithPendingReplay() + const { activeSessionIdRef, runtimeIdByStoredSessionIdRef, selectedStoredSessionIdRef } = result.current.cache + let releaseRest!: () => void + const restGate = new Promise(resolve => (releaseRest = resolve)) + const latest = vi.mocked(getLatestSessionMessages).getMockImplementation()! + vi.mocked(getLatestSessionMessages).mockImplementation(async (...args) => restGate.then(() => latest(...args))) + selectedStoredSessionIdRef.current = storedId + activeSessionIdRef.current = runtimeId + runtimeIdByStoredSessionIdRef.current.delete(storedId) + + try { + let pending!: Promise + await act(async () => { + pending = result.current.actions.resumeSession(storedId, true) + }) + // The replay socket drops (barrier false: cold resume starts its REST read) + // and redials before that read returns, owning a fresh replay of the turn. + let third!: Awaited> + await act(async () => { + client.invalidate() + third = await connect() + }) + expect(getLatestSessionMessages).toHaveBeenCalledTimes(1) + const request = third.sent.find(item => item.method === 'session.events.since')! + + await act(async () => { + releaseRest() + await new Promise(resolve => setTimeout(resolve, 0)) + third.frame({ id: request.id, jsonrpc: '2.0', result: { events: replay } }) + await pending + }) + + expect( + $messages + .get() + .map(chatMessageText) + .join('\n') + .match(/Finished result\./g) + ).toHaveLength(1) + } finally { + client.close() + } +}) + +it('holds a background active-transcript refresh behind the reconnect replay', async () => { + const { result, client, second, request } = await mountWithPendingReplay() + + const { activeSessionIdRef, selectedStoredSessionIdRef, sessionStateByRuntimeIdRef, updateSessionState } = + result.current.cache + + selectedStoredSessionIdRef.current = storedId + activeSessionIdRef.current = runtimeId + + try { + const refresh = reconcileActiveTranscript({ + activeSessionIdRef, + busyRef: { current: false }, + requestSequenceRef: { current: 0 }, + resolveSession: () => ({ profile: 'default' }), + selectedStoredSessionIdRef, + signatureRef: { current: new Map() }, + updateSessionState + }) + + await new Promise(resolve => setTimeout(resolve, 0)) + expect(getLatestSessionMessages).not.toHaveBeenCalled() + + await act(async () => { + second.frame({ id: request.id, jsonrpc: '2.0', result: { events: replay } }) + await refresh + }) + + expect(getLatestSessionMessages).toHaveBeenCalledTimes(1) + const text = sessionStateByRuntimeIdRef.current.get(runtimeId)!.messages.map(chatMessageText).join('\n') + expect(text.match(/Finished result\./g)).toHaveLength(1) + } finally { + client.close() + } +}) diff --git a/apps/desktop/src/app/settings/about-settings.tsx b/apps/desktop/src/app/settings/about-settings.tsx index 57812566a1..458add687e 100644 --- a/apps/desktop/src/app/settings/about-settings.tsx +++ b/apps/desktop/src/app/settings/about-settings.tsx @@ -9,13 +9,17 @@ import { $connection } from '@/store/session' import { $desktopVersion, checkBackendUpdates, refreshDesktopVersion } from '@/store/updates' import { SectionHeading, SettingsContent } from './primitives' +import { SETTING_IDS, settingElementId } from './settings-manifest' import { UninstallSection } from './uninstall-section' +import { useSettingDeepLink } from './use-setting-deep-link' interface AboutSettingsProps { subpage?: string } export function AboutSettings({ subpage }: AboutSettingsProps = {}): ReactElement { + useSettingDeepLink('about', page => subpage === undefined || page === subpage) + if (subpage === 'uninstall') { return ( @@ -49,18 +53,14 @@ function AppUpdatesSettings({ includeUninstall }: AppUpdatesSettingsProps): Reac return ( -
- -
+
{/* Client and remote backend updates are independent. Only the client has release notes. */} {remote && }
- {version && } - {includeUninstall && }
diff --git a/apps/desktop/src/app/settings/appearance-settings.tsx b/apps/desktop/src/app/settings/appearance-settings.tsx index 9d38106a30..c7f8a53f57 100644 --- a/apps/desktop/src/app/settings/appearance-settings.tsx +++ b/apps/desktop/src/app/settings/appearance-settings.tsx @@ -4,8 +4,9 @@ import { useEffect, useState } from 'react' import { useDebounced } from '@/app/hooks/use-debounced' import { LanguageSwitcher } from '@/components/language-switcher' -import { Button } from '@/components/ui/button' +import { SearchField } from '@/components/ui/search-field' import { SegmentedControl } from '@/components/ui/segmented-control' +import { Slider } from '@/components/ui/slider' import type { DesktopMarketplaceSearchItem } from '@/global' import { saveHermesConfig } from '@/hermes' import { useI18n } from '@/i18n' @@ -72,16 +73,16 @@ import { $marketplaceInstalls, isUserTheme, removeUserTheme } from '@/themes/use import { setHermesConfigCache, useHermesConfigRecord } from '../hooks/use-config-record' -import { appearanceSubpageForSetting, type AppearanceSubpageId } from './appearance-subpages' +import type { AppearanceSubpageId } from './appearance-subpages' import { ChatFontSetting } from './chat-font-setting' import { MODE_OPTIONS } from './constants' import { setNested } from './helpers' import { MinimizeToTraySetting } from './minimize-to-tray-setting' import { PetSettings } from './pet-settings' -import { ListRow, SectionHeading, SettingsContent, ToggleRow } from './primitives' -import { APPEARANCE_SETTING_IDS } from './settings-search' +import { ListRow, RowFootnoteAction, SectionHeading, SettingsContent, ToggleRow } from './primitives' +import { SETTING_IDS, settingElementId } from './settings-manifest' import { TerminalFontSetting } from './terminal-font-setting' -import { useDeepLinkHighlight } from './use-deep-link-highlight' +import { useSettingDeepLink } from './use-setting-deep-link' // display.resume_last_session lives in the backend config record (shared with // config.yaml and the cold-start restore in use-desktop-integrations), not a @@ -121,6 +122,7 @@ function ResumeLastSessionSetting() { checked={checked} description={a.resumeLastSessionDesc} disabled={!config} + id={settingElementId(ids.resumeLastSession)} label={a.resumeLastSessionTitle} onChange={update} /> @@ -170,8 +172,7 @@ function ThemePreview({ name, mode }: { name: string; mode: 'light' | 'dark' }) // presets highlights nothing, and the row description keeps showing the // exact current percent. const UI_SCALE_PRESETS = ['90', '100', '110', '125', '150', '175'] as const -const appearanceSettingElementId = (id: string) => `setting-field-${id}` - +const ids = SETTING_IDS.appearance type UiScalePreset = (typeof UI_SCALE_PRESETS)[number] function matchUiScalePreset(percent: number): UiScalePreset | null { @@ -362,9 +363,8 @@ interface TranslucencySliderProps { function TranslucencySlider({ label, onChange, value }: TranslucencySliderProps) { return ( <> - @@ -470,15 +468,7 @@ export function AppearanceSettings({ subpage }: AppearanceSettingsProps = {}) { const [query, setQuery] = useState('') const show = (id: AppearanceSubpageId) => subpage === undefined || subpage === id - useDeepLinkHighlight({ - elementId: appearanceSettingElementId, - param: 'setting', - ready: id => { - const targetSubpage = appearanceSubpageForSetting(id) - - return targetSubpage !== undefined && show(targetSubpage) - } - }) + useSettingDeepLink('config:appearance', page => page !== undefined && show(page as AppearanceSubpageId)) // One box does double duty: filter installed themes live (below), and run a // name search against the VS Code Marketplace (the Cmd-K "Install theme…" @@ -572,26 +562,34 @@ export function AppearanceSettings({ subpage }: AppearanceSettingsProps = {}) { } description={isSavingLocale ? t.language.saving : t.language.description} - id={appearanceSettingElementId(APPEARANCE_SETTING_IDS.language)} + id={settingElementId(ids.language)} title={t.language.label} /> )} {show('theme') && ( { + triggerHaptic('crisp') + setMode(id) + }} + options={modeOptions} + value={mode} + /> + } below={ <> {/* One search box: filters your installed themes (the grid) and live-searches the VS Code Marketplace below. */} -
- setQuery(event.target.value)} - placeholder={a.themeSearchPlaceholder} - spellCheck={false} - value={query} - /> -
+ {/* The dedicated theme page uses the page scroller rather than clipping its gallery inside another scroll area. */} @@ -661,20 +659,8 @@ export function AppearanceSettings({ subpage }: AppearanceSettingsProps = {}) { } description={a.themeDesc} - id={appearanceSettingElementId(APPEARANCE_SETTING_IDS.theme)} - title={ -
- {a.themeTitle} - { - triggerHaptic('crisp') - setMode(id) - }} - options={modeOptions} - value={mode} - /> -
- } + id={settingElementId(ids.theme)} + title={a.themeTitle} wide /> )} @@ -693,15 +679,15 @@ export function AppearanceSettings({ subpage }: AppearanceSettingsProps = {}) { /> } description={a.uiScaleDesc(zoomPercent)} - id={appearanceSettingElementId(APPEARANCE_SETTING_IDS.uiScale)} + id={settingElementId(ids.uiScale)} title={a.uiScaleTitle} /> -
+
-
+
@@ -720,7 +706,7 @@ export function AppearanceSettings({ subpage }: AppearanceSettingsProps = {}) { /> } description={t.interfaceMode.hint} - id={appearanceSettingElementId(APPEARANCE_SETTING_IDS.interfaceMode)} + id={settingElementId(ids.interfaceMode)} title={t.interfaceMode.title} /> )} @@ -738,6 +724,7 @@ export function AppearanceSettings({ subpage }: AppearanceSettingsProps = {}) { /> } description={a.sessionDensityDesc} + id={settingElementId(ids.sessionDensity)} title={a.sessionDensityTitle} /> )} @@ -755,6 +742,7 @@ export function AppearanceSettings({ subpage }: AppearanceSettingsProps = {}) { /> } description={a.tabStripDesc} + id={settingElementId(ids.tabStrip)} title={a.tabStripTitle} /> )} @@ -772,13 +760,13 @@ export function AppearanceSettings({ subpage }: AppearanceSettingsProps = {}) { /> } description={a.appActionsDesc} - id={appearanceSettingElementId(APPEARANCE_SETTING_IDS.appActions)} + id={settingElementId(ids.appActions)} title={a.appActionsTitle} /> )} {show('window-layout') && ( -
+
)} @@ -862,7 +850,7 @@ export function AppearanceSettings({ subpage }: AppearanceSettingsProps = {}) { ) : undefined } description={glassMode ? a.translucencyGlassDesc : a.translucencyDesc} - id={appearanceSettingElementId(APPEARANCE_SETTING_IDS.translucency)} + id={settingElementId(ids.translucency)} title={a.translucencyTitle} /> )} @@ -882,7 +870,7 @@ export function AppearanceSettings({ subpage }: AppearanceSettingsProps = {}) {
} description={a.userBubbleDesc} - id={appearanceSettingElementId(APPEARANCE_SETTING_IDS.userBubble)} + id={settingElementId(ids.userBubble)} title={a.userBubbleTitle} /> )} @@ -900,71 +888,38 @@ export function AppearanceSettings({ subpage }: AppearanceSettingsProps = {}) { /> } description={a.textDirectionDesc} - id={appearanceSettingElementId(APPEARANCE_SETTING_IDS.textDirection)} + id={settingElementId(ids.textDirection)} title={a.textDirectionTitle} /> )} {show('window-layout') && ( - { - triggerHaptic('selection') - setBackdrop(id === 'on') - }} - options={[ - { id: 'off', label: t.common.off }, - { id: 'on', label: t.common.on } - ]} - value={backdrop ? 'on' : 'off'} - /> - } + )} {show('chat-display') && ( - { - triggerHaptic('selection') - setHideThreadTimeline(id === 'on') - }} - options={[ - { id: 'off', label: t.common.off }, - { id: 'on', label: t.common.on } - ]} - value={hideThreadTimeline ? 'on' : 'off'} - /> - } + )} {show('general') && ( - { - triggerHaptic('selection') - setIntroSplash(id === 'on') - }} - options={[ - { id: 'off', label: t.common.off }, - { id: 'on', label: t.common.on } - ]} - value={introSplash ? 'on' : 'off'} - /> - } + )} @@ -972,6 +927,7 @@ export function AppearanceSettings({ subpage }: AppearanceSettingsProps = {}) { @@ -980,98 +936,47 @@ export function AppearanceSettings({ subpage }: AppearanceSettingsProps = {}) { {show('general') && } {show('chat-display') && ( - { - triggerHaptic('selection') - setReactionsEnabled(id === 'on') - }} - options={[ - { id: 'off', label: t.common.off }, - { id: 'on', label: t.common.on } - ]} - value={reactionsEnabled ? 'on' : 'off'} - /> - } + )} {show('general') && ( - - { - triggerHaptic('selection') - setTipsEnabled(id === 'on') - }} - options={[ - { id: 'off', label: t.common.off }, - { id: 'on', label: t.common.on } - ]} - value={tipsEnabled ? 'on' : 'off'} - /> - {/* A tip shows once (✕ or timer), so this is the only way to a - second lap. It appears once there is something to bring back. */} - {spentTips > 0 && ( - - )} -
+ 0 && {a.tipsReset(spentTips)} } + checked={tipsEnabled} description={a.tipsDesc} - title={a.tipsTitle} + id={settingElementId(ids.tips)} + label={a.tipsTitle} + onChange={setTipsEnabled} /> )} {show('general') && ( - { - triggerHaptic('selection') - setToursEnabled(id === 'on') - }} - options={[ - { id: 'off', label: t.common.off }, - { id: 'on', label: t.common.on } - ]} - value={toursEnabled ? 'on' : 'off'} - /> - } + )} {show('chat-display') && ( - { - triggerHaptic('selection') - setVibeHeartsEnabled(id === 'on') - }} - options={[ - { id: 'off', label: t.common.off }, - { id: 'on', label: t.common.on } - ]} - value={vibeHeartsEnabled ? 'on' : 'off'} - /> - } + )} @@ -1088,29 +993,28 @@ export function AppearanceSettings({ subpage }: AppearanceSettingsProps = {}) { /> } description={withModeNote(a.toolViewDesc, toolViewShadowed)} - id={appearanceSettingElementId(APPEARANCE_SETTING_IDS.toolView)} + id={settingElementId(ids.toolView)} title={a.toolViewTitle} /> )} {show('chat-display') && ( - { - triggerHaptic('selection') - setHideCodeDiffs(id === 'on') - }} - options={[ - { id: 'off', label: t.common.off }, - { id: 'on', label: t.common.on } - ]} - value={hideCodeDiffs ? 'on' : 'off'} - /> - } + + )} + + {show('chat-display') && ( + )} @@ -1120,48 +1024,21 @@ export function AppearanceSettings({ subpage }: AppearanceSettingsProps = {}) { { triggerHaptic('selection') - setReasoningCollapsedByDefault(id === 'on') + setEmbedMode(id) }} - options={[ - { id: 'off', label: t.common.off }, - { id: 'on', label: t.common.on } - ]} - value={reasoningCollapsedByDefault ? 'on' : 'off'} + options={embedOptions} + value={embedMode} /> } - description={withModeNote(a.reasoningCollapsedDesc, reasoningCollapsedShadowed)} - title={a.reasoningCollapsedTitle} - /> - )} - - {show('chat-display') && ( - - { - triggerHaptic('selection') - setEmbedMode(id) - }} - options={embedOptions} - value={embedMode} - /> - {embedAllowed.length > 0 && ( - - )} -
+ below={ + embedAllowed.length > 0 && ( + + {a.embedsReset(embedAllowed.length)} + + ) } description={a.embedsDesc} - id={appearanceSettingElementId(APPEARANCE_SETTING_IDS.embeds)} + id={settingElementId(ids.embeds)} title={a.embedsTitle} /> )} @@ -1169,7 +1046,7 @@ export function AppearanceSettings({ subpage }: AppearanceSettingsProps = {}) {
{show('pet') && ( -
+
)} diff --git a/apps/desktop/src/app/settings/appearance-subpages.ts b/apps/desktop/src/app/settings/appearance-subpages.ts index ca1c67a484..6d8c1b48b9 100644 --- a/apps/desktop/src/app/settings/appearance-subpages.ts +++ b/apps/desktop/src/app/settings/appearance-subpages.ts @@ -8,29 +8,3 @@ export const APPEARANCE_SUBPAGES = [ ] as const export type AppearanceSubpageId = (typeof APPEARANCE_SUBPAGES)[number]['id'] - -// Keep routing metadata independent of settings-search, which consumes it. -const SETTING_SUBPAGES: Readonly> = { - 'appearance.app-actions': 'window-layout', - 'appearance.backdrop': 'window-layout', - 'appearance.embeds': 'chat-display', - 'appearance.hide-code-diffs': 'chat-display', - 'appearance.hide-thread-timeline': 'chat-display', - 'appearance.intro-splash': 'general', - 'appearance.language': 'general', - 'appearance.minimize-to-tray': 'window-layout', - 'appearance.pet': 'pet', - 'appearance.text-direction': 'chat-display', - 'appearance.theme': 'theme', - 'appearance.tool-view': 'chat-display', - 'appearance.interface-mode': 'window-layout', - 'appearance.translucency': 'window-layout', - 'appearance.ui-scale': 'typography', - 'appearance.user-bubble': 'chat-display', - 'desktop.font_family': 'typography', - 'terminal.font_family': 'typography' -} - -export function appearanceSubpageForSetting(setting: string): AppearanceSubpageId | undefined { - return Object.hasOwn(SETTING_SUBPAGES, setting) ? SETTING_SUBPAGES[setting] : undefined -} diff --git a/apps/desktop/src/app/settings/billing/account-row-value.tsx b/apps/desktop/src/app/settings/billing/account-row-value.tsx index 4c54e4cbac..9ab65424fb 100644 --- a/apps/desktop/src/app/settings/billing/account-row-value.tsx +++ b/apps/desktop/src/app/settings/billing/account-row-value.tsx @@ -10,8 +10,9 @@ export function RowValue({ onAction, row }: { onAction?: () => void; row: Billin // Destructure to a const so narrowing survives into the onClick closure below. const { action } = row + // Rendered as a ListRow `action`; the row owns wrapping and alignment. return ( -
+ <> {row.value && ( {row.value} @@ -43,6 +44,6 @@ export function RowValue({ onAction, row }: { onAction?: () => void; row: Billin {!action.disabled && action.url && } )} -
+ ) } diff --git a/apps/desktop/src/app/settings/billing/auto-reload-row.tsx b/apps/desktop/src/app/settings/billing/auto-reload-row.tsx index d430945d1a..4b75a4d99f 100644 --- a/apps/desktop/src/app/settings/billing/auto-reload-row.tsx +++ b/apps/desktop/src/app/settings/billing/auto-reload-row.tsx @@ -6,7 +6,7 @@ import { Input } from '@/components/ui/input' import { useI18n } from '@/i18n' import { cn } from '@/lib/utils' -import { ListRow, Pill } from '../primitives' +import { LIST_ROW_COLUMNS, ListRow, Pill } from '../primitives' import { RowValue } from './account-row-value' import type { BillingRefusal } from './api' @@ -168,7 +168,7 @@ export function AutoReloadRow({ // panes. The form is `invisible` + `aria-hidden` when not editing. return (
-
+
{row.title} diff --git a/apps/desktop/src/app/settings/billing/current-plan-card.tsx b/apps/desktop/src/app/settings/billing/current-plan-card.tsx index 638aae9748..997adcffe3 100644 --- a/apps/desktop/src/app/settings/billing/current-plan-card.tsx +++ b/apps/desktop/src/app/settings/billing/current-plan-card.tsx @@ -1,6 +1,9 @@ import { Button } from '@/components/ui/button' import { useI18n } from '@/i18n' import { ExternalLink } from '@/lib/icons' +import { cn } from '@/lib/utils' + +import { LIST_ROW_COLUMNS } from '../primitives' import { BillingRefusalInline } from './inline-feedback' import { openExternal } from './open-external' @@ -15,7 +18,7 @@ export function CurrentPlanCard({ onViewPlans, plan }: { onViewPlans: () => void return (
-
+
diff --git a/apps/desktop/src/app/settings/billing/index.tsx b/apps/desktop/src/app/settings/billing/index.tsx index c97130dd43..2fe4760f64 100644 --- a/apps/desktop/src/app/settings/billing/index.tsx +++ b/apps/desktop/src/app/settings/billing/index.tsx @@ -195,7 +195,7 @@ function BuyCreditsRow({ billing, row }: { billing: BillingStateResponse; row: B return ( + <> setAmount(value)} @@ -224,7 +224,7 @@ function BuyCreditsRow({ billing, row }: { billing: BillingStateResponse; row: B -
+ } below={ ( - + const dataTour = `field-${schemaKey}` + + const row = (action: ReactNode) => ( + + ) + + // Editors too big for the control column (textareas, structured lists) take + // the full width under the description. + const wideRow = (editor: ReactNode) => ( + {editor}
} + data-tour={dataTour} + description={descriptionNode} + title={label} + wide + /> ) // `fallback_providers` is a list of {provider, model} objects; the generic // `list` branch below would stringify them to "[object Object]". Render the // dedicated structured editor instead. if (schemaKey === 'fallback_providers') { - return row(, true) + return wideRow() } if (schema.type === 'boolean') { - return row( -
- -
+ return ( + ) } @@ -201,7 +218,7 @@ export function ConfigField({ } if (typeof value === 'object' && value !== null) { - return row( + return wideRow(