diff --git a/.github/actions/linux-ci/action.yml b/.github/actions/linux-ci/action.yml new file mode 100644 index 0000000000..19c8837d4d --- /dev/null +++ b/.github/actions/linux-ci/action.yml @@ -0,0 +1,80 @@ +name: Linux Rust CI setup +description: >- + Rust toolchain, cargo-nextest, optional apt packages (with a cached .deb + directory) and the main-seeded Rust cache for Linux test jobs. Run + actions/checkout first. + +inputs: + rust-cache: + description: >- + rust-cache shared key. Jobs with the same key share one cache, so they + must have the same dependency graph. + required: true + save-rust-cache: + description: >- + Whether this job may save the cache when it runs on main. Give it to + exactly one job per key. Pull requests never save. + default: 'false' + apt-packages: + description: apt packages to install (space separated). Nothing is installed when empty. + default: '' + apt-cache: + description: Name of the cached .deb directory for apt-packages (one per package list). + default: '' + apt-recommends: + description: Install recommended packages too ('true' or 'false'). + default: 'false' + +runs: + using: composite + steps: + - name: Rust toolchain + shell: bash + run: rustup show active-toolchain || rustup toolchain install + + # apt mirrors on hosted runners occasionally stall for minutes; apt-install.sh + # bounds and retries the download and uses a directory of .debs that main + # saves to the cache (pull requests only restore it). + - name: Restore apt packages + id: apt-cache + if: inputs.apt-packages != '' + uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + path: ~/apt-debs/*.deb + key: apt-debs-v1-${{ inputs.apt-cache }}-${{ hashFiles('scripts/ci/apt-install.sh') }} + - name: Install system packages + if: inputs.apt-packages != '' + shell: bash + env: + APT_RECOMMENDS: ${{ inputs.apt-recommends == 'true' && '1' || '0' }} + APT_PACKAGES: ${{ inputs.apt-packages }} + run: | + # shellcheck disable=SC2086 # the package list is word-split on purpose + bash scripts/ci/apt-install.sh $APT_PACKAGES + - name: Save apt packages + if: inputs.apt-packages != '' && github.ref == 'refs/heads/main' && steps.apt-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + path: ~/apt-debs/*.deb + key: apt-debs-v1-${{ inputs.apt-cache }}-${{ hashFiles('scripts/ci/apt-install.sh') }} + + # Exported for the rest of the job. Tests and the browser fixture are built in + # the dev profile without debug info (smaller, faster to link). CARGO_* variables + # are part of the rust-cache key, so every job sharing a cache must set the same. + - name: Build settings + shell: bash + run: echo "CARGO_PROFILE_DEV_DEBUG=0" >> "$GITHUB_ENV" + + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + with: + prefix-key: ci-linux-v1 + shared-key: ${{ inputs.rust-cache }} + add-job-id-key: false + save-if: ${{ inputs.save-rust-cache == 'true' && github.ref == 'refs/heads/main' }} + cache-on-failure: true + + # After the cache restore, so the checksum-verified binary is the one that + # runs (rust-cache restores ~/.cargo/bin). + - name: Install cargo-nextest + shell: bash + run: bash scripts/ci/install-nextest.sh diff --git a/.github/actions/windows-ci-setup/action.yml b/.github/actions/windows-ci-setup/action.yml new file mode 100644 index 0000000000..5374c07d9b --- /dev/null +++ b/.github/actions/windows-ci-setup/action.yml @@ -0,0 +1,54 @@ +name: Windows CI setup +description: >- + Prepare a Windows runner for the Windows test jobs: toolchain, shader + compiler, a cheaper release profile for the CI builds, and the Rust cache. + +runs: + using: composite + steps: + - name: Disable Defender real-time scanning + shell: pwsh + # Hosted runners are ephemeral and only build this repository; scanning + # every file rustc writes slows builds down. Best effort: warn and carry + # on if the runner image does not allow it. + run: | + try { Set-MpPreference -DisableRealtimeMonitoring $true -ErrorAction Stop } + catch { Write-Warning "Could not disable Defender real-time scanning: $_" } + - name: Rust toolchain + shell: pwsh + run: rustup show active-toolchain || rustup toolchain install + - name: Locate Windows SDK shader compiler + shell: pwsh + run: | + $fxc = Get-ChildItem "${env:ProgramFiles(x86)}/Windows Kits/10/bin/*/x64/fxc.exe" | + Sort-Object FullName -Descending | Select-Object -First 1 + if (-not $fxc) { throw 'Windows SDK fxc.exe is required for GPUI release shaders' } + "GPUI_FXC_PATH=$($fxc.FullName)" >> $env:GITHUB_ENV + - name: Cheaper release profile for CI builds + shell: pwsh + # CI only: the binaries built and tested here are NOT the shipped ones + # (release.yml still builds those with the committed profile). Thin LTO is + # off and the workspace's own crates are built at opt-level 0; third-party + # dependencies (gpui, tokio, ...) keep the release opt-level. Written to + # $CARGO_HOME/config.toml so Cargo.toml is untouched and every cargo + # invocation in the job, including the ones inside + # scripts/package-windows.ps1, picks it up. + run: | + $members = (cargo metadata --no-deps --format-version 1 | ConvertFrom-Json).packages.name + $config = @('[profile.release]', 'lto = false') + foreach ($name in $members) { + $config += "[profile.release.package.`"$name`"]" + $config += 'opt-level = 0' + } + $cargoHome = if ($env:CARGO_HOME) { $env:CARGO_HOME } else { Join-Path $env:USERPROFILE '.cargo' } + Add-Content -LiteralPath (Join-Path $cargoHome 'config.toml') -Value $config + Write-Host "opt-level 0 for $($members.Count) workspace crates: $($members -join ', ')" + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + with: + # Only main writes the cache; PRs restore it. + save-if: ${{ github.ref == 'refs/heads/main' }} + cache-on-failure: true + # Workspace crates are rebuilt on every change anyway and are cheap at + # opt-level 0, so keep them out of the cache. Each job gets its own + # cache (rust-cache keys on the job id by default). + cache-workspace-crates: false diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000000..ed2e621091 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,13 @@ +version: 2 +updates: + # Workflow actions are pinned to commit SHAs (with the version in a comment); + # this keeps the pins current. One grouped PR per week. + - package-ecosystem: github-actions + directories: + - / + - /.github/actions/* + schedule: + interval: weekly + groups: + github-actions: + patterns: ['*'] diff --git a/.github/workflows/cursor-compatibility.yml b/.github/workflows/cursor-compatibility.yml index c521f439ee..7120e031cf 100644 --- a/.github/workflows/cursor-compatibility.yml +++ b/.github/workflows/cursor-compatibility.yml @@ -9,6 +9,16 @@ on: - 'Cargo.lock' - 'scripts/*cursor-sdk*' - '.github/workflows/cursor-*.yml' + push: + branches: [main] + paths: + - 'crates/harness/**' + - 'crates/doc/**' + - 'crates/engine/**' + - 'Cargo.toml' + - 'Cargo.lock' + - 'scripts/*cursor-sdk*' + - '.github/workflows/cursor-*.yml' workflow_dispatch: workflow_call: permissions: @@ -18,11 +28,10 @@ jobs: runs-on: ubuntu-24.04 timeout-minutes: 30 steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: '22' - - uses: Swatinem/rust-cache@v2 - name: Dependency updater validation run: python3 scripts/test-update-cursor-sdk.py - name: Install the exact SDK selected by this engine @@ -31,14 +40,21 @@ jobs: npm install --prefix "$RUNNER_TEMP/cursor-sdk-contract" --ignore-scripts --no-audit --no-fund "$pin" - name: Actual SDK token expiry, stream invalidation, and transient auth recovery run: node scripts/test-cursor-sdk-auth.mjs "$RUNNER_TEMP/cursor-sdk-contract/node_modules/@cursor/sdk" - - name: Harness compatibility, steering, and session recovery - run: cargo test --locked -p zeron-harness - - name: Durable commands and engine message delivery - run: | - cargo test --locked -p zeron-doc - cargo test --locked -p zeron-engine --test message_queue - cargo test --locked -p zeron-engine --lib sessions::tests - - name: Native Pi RPC driver - run: | - cargo test --locked -p zeron-harness --features native-fixture --test pi_rpc - cargo test --locked -p zeron-engine --test pi_resume + + # The Rust half (harness compatibility, steering, session recovery, durable + # commands, message delivery, native Pi RPC) is part of the `core-tests` job + # of ui-tests.yml, which already runs on pull requests and on main with a + # superset of this workflow's paths. Run it here only for the callers that + # do not get that: release.yml (tag push), cursor-sdk-update.yml (manual + # dispatch on the update branch) and manual runs. + rust-tests: + if: ${{ github.event_name != 'pull_request' && !(github.event_name == 'push' && github.ref == 'refs/heads/main') }} + runs-on: ubuntu-24.04 + timeout-minutes: 30 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: ./.github/actions/linux-ci + with: + rust-cache: core # saved by core-tests in ui-tests.yml + - name: Harness, engine, sync, update, doc and preview tests + run: scripts/ci/test-core.sh diff --git a/.github/workflows/cursor-sdk-update.yml b/.github/workflows/cursor-sdk-update.yml index e0202b8317..3c3de1fc81 100644 --- a/.github/workflows/cursor-sdk-update.yml +++ b/.github/workflows/cursor-sdk-update.yml @@ -15,8 +15,8 @@ jobs: runs-on: ubuntu-24.04 timeout-minutes: 10 steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: '22' - name: Check stable SDK version diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index b9b577f92f..f19512255a 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -25,7 +25,7 @@ jobs: # the token through job env and gate on that instead. CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: # `github.event.before` may precede several commits in a batched push. fetch-depth: 0 @@ -43,7 +43,7 @@ jobs: else echo "changed=false" >> "$GITHUB_OUTPUT" fi - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 if: steps.changed.outputs.changed == 'true' with: node-version: 22 @@ -72,7 +72,7 @@ jobs: env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: fetch-depth: 0 - name: detect landing changes @@ -89,7 +89,7 @@ jobs: else echo "changed=false" >> "$GITHUB_OUTPUT" fi - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 if: steps.changed.outputs.changed == 'true' with: node-version: 22 @@ -108,7 +108,7 @@ jobs: env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: fetch-depth: 0 - name: detect www-redirect changes @@ -125,7 +125,7 @@ jobs: else echo "changed=false" >> "$GITHUB_OUTPUT" fi - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 if: steps.changed.outputs.changed == 'true' with: node-version: 22 diff --git a/.github/workflows/linux-installer.yml b/.github/workflows/linux-installer.yml index 71e8d89fae..54a987c634 100644 --- a/.github/workflows/linux-installer.yml +++ b/.github/workflows/linux-installer.yml @@ -6,6 +6,7 @@ on: - 'edge/src/install.sh' - 'scripts/package-linux.sh' - 'scripts/test-linux-desktop-entry.sh' + - 'scripts/ci/apt-install.sh' - 'dist/zeron.desktop' - 'dist/zeron.png' - '.github/workflows/linux-installer.yml' @@ -15,6 +16,7 @@ on: - 'edge/src/install.sh' - 'scripts/package-linux.sh' - 'scripts/test-linux-desktop-entry.sh' + - 'scripts/ci/apt-install.sh' - 'dist/zeron.desktop' - 'dist/zeron.png' - '.github/workflows/linux-installer.yml' @@ -25,18 +27,18 @@ permissions: concurrency: group: linux-installer-${{ github.ref }} - cancel-in-progress: true + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: desktop-entry: runs-on: ubuntu-24.04 timeout-minutes: 10 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Desktop entry validator - run: | - sudo apt-get update -qq -o Dir::Etc::sourcelist="sources.list.d/ubuntu.sources" -o Dir::Etc::sourceparts="-" - sudo apt-get install -y -qq desktop-file-utils + # Skips apt when the runner image already has it; otherwise the install + # retries and is bounded instead of hanging on a stalled mirror. + run: command -v desktop-file-validate || bash scripts/ci/apt-install.sh desktop-file-utils # Both installers, offline, under a throwaway HOME (no network, no systemd). - name: Installers write a valid launcher entry run: scripts/test-linux-desktop-entry.sh diff --git a/.github/workflows/macos.yml b/.github/workflows/macos.yml new file mode 100644 index 0000000000..205201f688 --- /dev/null +++ b/.github/workflows/macos.yml @@ -0,0 +1,286 @@ +name: macOS tests + +on: + pull_request: + paths: + - 'Cargo.toml' + - 'Cargo.lock' + - 'rust-toolchain.toml' + - 'crates/**' + - 'edge/**' + - 'scripts/run-macos-browser-fixture.sh' + - 'scripts/ci/run-macos-fixture.sh' + - 'dist/macos/Info.plist' + - 'apps/ios/**' + - '.github/workflows/macos.yml' + push: + branches: [main] + paths: + - 'Cargo.toml' + - 'Cargo.lock' + - 'rust-toolchain.toml' + - 'crates/**' + - 'edge/**' + - 'scripts/run-macos-browser-fixture.sh' + - 'scripts/ci/run-macos-fixture.sh' + - 'dist/macos/Info.plist' + - 'apps/ios/**' + - '.github/workflows/macos.yml' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: macos-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +env: + # Tests run on the dev profile (workspace crates at opt-level 0, dependencies at 2 as configured in + # Cargo.toml) instead of --release: a cold build is several times cheaper. No debug info is needed. + CARGO_PROFILE_DEV_DEBUG: 0 + +# Every macOS job starts with `rustup update stable`: runner images are not uniform (one image may ship +# rustc 1.98.0 and another 1.98.1) and rust-cache keys include the rustc version, so a job that lands +# on an older image would otherwise miss the cache that `main` seeded. +jobs: + changes: + runs-on: ubuntu-24.04 + permissions: + pull-requests: read + outputs: + ios: ${{ steps.filter.outputs.result }} + steps: + - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + id: filter + with: + script: | + // Preserve iOS coverage for main pushes and manual runs. + if (context.eventName !== 'pull_request') return true; + const files = await github.paginate(github.rest.pulls.listFiles, { + ...context.repo, + pull_number: context.payload.pull_request.number, + per_page: 100, + }); + // Run conservatively if the API did not return the full diff. + if (files.length < context.payload.pull_request.changed_files) return true; + // The app links the Rust mobile core and everything it depends on. + const mobileCore = ['crates/mobile/', 'crates/client/', 'crates/text/', 'crates/markdown/', + 'crates/doc/', 'crates/sync/', 'crates/proto/', 'crates/rpc/', 'crates/syntax/', + 'scripts/ios/']; + const affectsIOS = path => path?.startsWith('apps/ios/') + || mobileCore.some(prefix => path?.startsWith(prefix)) + || path === 'Cargo.lock' + || path === 'Cargo.toml' + || path === 'rust-toolchain.toml' + || path === '.github/workflows/macos.yml'; + return files.some(file => affectsIOS(file.filename) + || affectsIOS(file.previous_filename)); + + # Updater and preview tests, native UI regressions and the two native WebKit fixtures. Everything is + # compiled once (one cargo invocation, so dependencies are built once) and then run. + macos-native: + runs-on: macos-latest + # A cold cache (first run, eviction, toolchain bump) takes about 32 minutes; warm is about 10. + timeout-minutes: 45 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - name: Rust toolchain + run: | + rustup update stable --no-self-update + rustup show active-toolchain || rustup toolchain install + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + with: + save-if: ${{ github.ref == 'refs/heads/main' }} + cache-on-failure: true + - name: Install cargo-nextest + run: | + # Pinned release, verified against a checksum. + version=0.9.146 + sha256=39785160b3c2f6ed9a765049cf4fa79f3b39aa02eb7598a5a0e2a1a0b9ffb9a8 + curl --proto '=https' --tlsv1.2 -fsSL --retry 5 -o "$RUNNER_TEMP/nextest.tar.gz" \ + "https://github.com/nextest-rs/nextest/releases/download/cargo-nextest-$version/cargo-nextest-$version-universal-apple-darwin.tar.gz" + echo "$sha256 $RUNNER_TEMP/nextest.tar.gz" | shasum -a 256 -c - + tar -xzf "$RUNNER_TEMP/nextest.tar.gz" -C "$HOME/.cargo/bin" cargo-nextest + - name: Build tests and fixtures + run: | + cargo nextest run --no-run --locked \ + -p zeron-update -p zeron-preview -p zeron-ui -p zeron-text \ + --features zeron-ui/browser-fixture --lib --tests + cargo build --locked -p zeron-update -p zeron-preview -p zeron-ui \ + --features zeron-ui/browser-fixture --example browser-fixture --example preview-fixture + - name: Check macOS application + env: + # Match the old `cargo check --release`: no debug assertions. + CARGO_PROFILE_DEV_DEBUG_ASSERTIONS: false + run: cargo check --locked -p zeron + - name: Updater, preview, CoreText and native UI regressions + run: | + # zeron-text's coretext tests are macOS-only (#![cfg(target_os = "macos")]); its other + # tests run on Linux in ios-core. The attachment filters cover file staging, chips and + # external Finder drops, whose paths and file metadata are platform-specific. + cargo nextest run --no-fail-fast --locked \ + -p zeron-update -p zeron-preview -p zeron-ui -p zeron-text \ + --features zeron-ui/browser-fixture --lib --tests \ + -E 'package(zeron-update) | package(zeron-preview) | binary_id(zeron-text::coretext) | (binary_id(zeron-ui) & (test(native_diff_font_geometry) | test(/notify::tests::/) | test(/^attachments::tests::/) | test(/composer::attachment_chip_tests::/) | test(/shell::chat_dropzone_tests::/) | test(=queue::tests::queue_rows_label_attachment_chips_and_file_only_sends) | test(=transcript::tests::user_bubbles_project_attachment_chips_in_place_of_the_strip) | test(=transcript::tests::sent_chips_show_upload_progress_until_their_bytes_land)))' + - name: Native browser regression and screenshots + timeout-minutes: 7 + env: + RUST_BACKTRACE: 1 + FIXTURE_ATTEMPT_TIMEOUT: 110 + run: | + scripts/ci/run-macos-fixture.sh target/debug/examples/browser-fixture "$RUNNER_TEMP/browser-captures" + test -f "$RUNNER_TEMP/browser-captures/result.txt" + - name: Upload browser screenshots + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: browser-macos-captures + path: ${{ runner.temp }}/browser-captures* + if-no-files-found: ignore + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 24 + - name: Install preview fixture server + run: npm ci --prefix edge + - name: Project discovery, stable URLs and HMR in native WebKit + timeout-minutes: 5 + env: + ZERON_PREVIEW_AUTO_OPEN: 1 + run: | + VITE_BINARY="$GITHUB_WORKSPACE/edge/node_modules/vite/bin/vite.js" scripts/ci/run-macos-fixture.sh target/debug/examples/preview-fixture "$RUNNER_TEMP/preview-captures" + test -f "$RUNNER_TEMP/preview-captures/result.txt" + - name: Upload preview screenshots + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: preview-macos-captures + path: ${{ runner.temp }}/preview-captures* + if-no-files-found: ignore + + # gpui_macos lives in the pinned GPUI checkout, a separate workspace with its own cache. + macos-frame-source: + runs-on: macos-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - name: Rust toolchain + run: | + rustup update stable --no-self-update + rustup show active-toolchain || rustup toolchain install + - name: Check out pinned GPUI for native frame-source tests + shell: bash + run: | + rev=$(python3 -c 'import tomllib; print(tomllib.load(open("Cargo.toml", "rb"))["workspace"]["dependencies"]["gpui"]["rev"])') + git init frame-source-tests + git -C frame-source-tests remote add origin https://github.com/zeronsh/zui + git -C frame-source-tests fetch --depth=1 origin "$rev" + git -C frame-source-tests checkout --detach FETCH_HEAD + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + with: + save-if: ${{ github.ref == 'refs/heads/main' }} + cache-on-failure: true + workspaces: frame-source-tests + - name: Native frame-source recovery regression + working-directory: frame-source-tests + run: cargo test --locked -p gpui_macos --test frame_source_recovery + + # The iOS checks that do not need Apple tooling run on Linux: Rust mobile core tests, and "the + # committed Swift bindings are current" (the UniFFI metadata is identical for any target, so the + # bindings are generated from the host build of the library). + ios-core: + needs: changes + if: needs.changes.outputs.ios == 'true' + runs-on: ubuntu-24.04 + timeout-minutes: 30 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - name: Rust toolchain + run: rustup show active-toolchain || rustup toolchain install + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + with: + save-if: ${{ github.ref == 'refs/heads/main' }} + cache-on-failure: true + - name: Rust mobile core tests + run: cargo test --locked -p zeron-text -p zeron-markdown -p zeron-client -p zeron-mobile + - name: Generated Swift bindings are current + run: | + cargo build --locked -p zeron-mobile --lib + cargo build --locked -p zeron-mobile --bin uniffi-bindgen --features bindgen + target/debug/uniffi-bindgen generate --library target/debug/libzeron_mobile.so \ + --language swift --out-dir "$RUNNER_TEMP/gen" >/dev/null + diff -u apps/ios/Zeron/Core/Generated/zeron_core.swift "$RUNNER_TEMP/gen/zeron_core.swift" + # The simulator job links these headers instead of regenerating them. + mkdir -p "$RUNNER_TEMP/ios-include" + cp "$RUNNER_TEMP/gen/zeron_coreFFI.h" "$RUNNER_TEMP/ios-include/zeron_coreFFI.h" + cp "$RUNNER_TEMP/gen/zeron_coreFFI.modulemap" "$RUNNER_TEMP/ios-include/module.modulemap" + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: ios-ffi-include + path: ${{ runner.temp }}/ios-include + retention-days: 1 + + # iOS unit tests in the simulator (only when iOS or the mobile core changed, see `changes`). + # Waiting for macos-frame-source keeps a run to two macOS runners at a time. + # Do not boot the simulator in the background: hundreds of launchd_sim daemons starve the 3-vCPU + # VM and slow every other step down by minutes. xcodebuild boots it when needed. + ios-tests: + needs: [changes, ios-core, macos-frame-source] + if: needs.changes.outputs.ios == 'true' + runs-on: macos-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - name: Select an available iPhone simulator + run: | + # The app targets iOS 26: use the newest Xcode 26 and the newest iOS runtime installed. + xcode=$(ls -d /Applications/Xcode_26*.app 2>/dev/null | sort -V | tail -1 || true) + if [ -n "$xcode" ]; then echo "DEVELOPER_DIR=$xcode/Contents/Developer" >> "$GITHUB_ENV"; fi + python3 - <<'PYTHON' + import json, os, subprocess + devices = json.loads(subprocess.check_output(["xcrun", "simctl", "list", "devices", "available", "--json"]))["devices"] + def version(runtime): + return [int(part) for part in runtime.split("iOS-")[-1].split("-") if part.isdigit()] + iphones = [device for runtime, group in sorted(devices.items(), key=lambda item: version(item[0]), reverse=True) + if "iOS" in runtime for device in group if device["name"].startswith("iPhone")] + if not iphones: + raise SystemExit("No iPhone simulator is installed") + with open(os.environ["GITHUB_ENV"], "a") as env: + env.write("IOS_SIMULATOR_ID=" + iphones[0]["udid"] + "\n") + PYTHON + - name: Rust toolchain + iOS simulator target + run: | + rustup update stable --no-self-update + rustup show active-toolchain || rustup toolchain install + rustup target add aarch64-apple-ios-sim + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + with: + save-if: ${{ github.ref == 'refs/heads/main' }} + cache-on-failure: true + - name: Build the Rust core for the simulator + run: | + # Same build as scripts/ios/build-core.sh iphonesimulator; the bindings came from ios-core. + IPHONEOS_DEPLOYMENT_TARGET=26.0 cargo build --locked -p zeron-mobile --lib --profile mobile --target aarch64-apple-ios-sim + mkdir -p target/ios-core/iphonesimulator + cp target/aarch64-apple-ios-sim/mobile/libzeron_mobile.a target/ios-core/iphonesimulator/ + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: ios-ffi-include + path: target/ios-core/iphonesimulator/include + - name: Run iOS unit tests + working-directory: apps/ios + env: + # Reuse the core built above instead of rebuilding it in the Xcode build phase. + ZERON_SKIP_CORE: 1 + run: | + xcodebuild -project Zeron.xcodeproj -scheme Zeron \ + -destination "platform=iOS Simulator,id=$IOS_SIMULATOR_ID" \ + -resultBundlePath "$RUNNER_TEMP/ios-tests.xcresult" \ + -only-testing:ZeronTests CODE_SIGNING_ALLOWED=NO test + - name: Upload iOS test results + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: ios-test-results + path: ${{ runner.temp }}/ios-tests.xcresult + if-no-files-found: ignore diff --git a/.github/workflows/preview-tests.yml b/.github/workflows/preview-tests.yml index 43487fb3eb..ccd8363ca8 100644 --- a/.github/workflows/preview-tests.yml +++ b/.github/workflows/preview-tests.yml @@ -9,19 +9,10 @@ permissions: contents: read concurrency: group: preview-tests-${{ github.ref }} - cancel-in-progress: true + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: - networking: - strategy: - matrix: - os: [ubuntu-latest, macos-latest] - runs-on: ${{ matrix.os }} - timeout-minutes: 30 - steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 - - run: cargo test --locked -p zeron-preview + # The Rust networking tests run in `core-tests` (ui-tests.yml, Linux) and + # `macos-native` (macos.yml); this workflow keeps the edge coordinator. coordinator: runs-on: ubuntu-latest timeout-minutes: 10 @@ -29,8 +20,8 @@ jobs: run: working-directory: edge steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: 24 cache: npm diff --git a/.github/workflows/release-profile-tests.yml b/.github/workflows/release-profile-tests.yml new file mode 100644 index 0000000000..12d8dc7d43 --- /dev/null +++ b/.github/workflows/release-profile-tests.yml @@ -0,0 +1,47 @@ +name: Release-profile tests + +# Pull-request and main CI test the dev profile (fast rebuilds). This runs the +# same Linux suites once a day against the optimized release profile that +# ships, so optimization-only failures still surface. It does not save a Rust +# cache, so it cannot evict the main-seeded caches pull requests depend on. + +on: + schedule: + - cron: '17 7 * * *' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: release-profile-tests + cancel-in-progress: false + +jobs: + core-tests: + runs-on: ubuntu-24.04 + timeout-minutes: 60 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: ./.github/actions/linux-ci + with: + rust-cache: release-profile-core + - name: Harness, engine, sync, update, doc and preview tests (release) + run: scripts/ci/test-core.sh --release + + ui-tests: + runs-on: ubuntu-24.04 + timeout-minutes: 60 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: ./.github/actions/linux-ci + with: + rust-cache: release-profile-ui + apt-cache: gpui + apt-packages: >- + libxkbcommon-dev libxkbcommon-x11-dev libwayland-dev + libx11-dev libxcb1-dev libx11-xcb-dev + libfontconfig1-dev libfreetype-dev libasound2-dev + libvulkan-dev pkg-config cmake libwebkit2gtk-4.1-dev libjson-glib-dev + - name: UI and headless layout regressions (release) + run: cargo nextest run --config-file scripts/ci/nextest.toml --locked --no-fail-fast --release -p zeron-ui --lib diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7c9c416338..ad51fa7cd7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -34,7 +34,7 @@ jobs: - runner: ubuntu-24.04-arm # aarch64 runs-on: ${{ matrix.runner }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: gpui system deps run: | sudo apt-get update -qq @@ -45,10 +45,10 @@ jobs: libvulkan-dev pkg-config cmake libwebkit2gtk-4.1-dev libjson-glib-dev - name: rust toolchain (rust-toolchain.toml) run: rustup show active-toolchain || rustup toolchain install - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - name: package run: scripts/package-linux.sh - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: linux-${{ runner.arch }} path: target/package/*.tar.gz @@ -72,10 +72,10 @@ jobs: AC_API_KEY_ID: ${{ secrets.AC_API_KEY_ID }} AC_API_ISSUER_ID: ${{ secrets.AC_API_ISSUER_ID }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: rust toolchain (rust-toolchain.toml) run: rustup show active-toolchain || rustup toolchain install - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - name: import Developer ID certificate if: env.MACOS_CERT_P12 != '' run: | @@ -107,7 +107,7 @@ jobs: } >>"$GITHUB_ENV" - name: package dmg run: scripts/package-macos.sh - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: macos-${{ runner.arch }} path: | @@ -122,7 +122,7 @@ jobs: run: shell: pwsh steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: rust toolchain (rust-toolchain.toml) run: rustup show active-toolchain || rustup toolchain install - name: Locate Windows SDK shader compiler @@ -131,10 +131,10 @@ jobs: Sort-Object FullName -Descending | Select-Object -First 1 if (-not $fxc) { throw 'Windows SDK fxc.exe is required for GPUI release shaders' } "GPUI_FXC_PATH=$($fxc.FullName)" >> $env:GITHUB_ENV - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - name: package installer and portable app run: ./scripts/package-windows.ps1 -ReleasesUrl "https://github.com/$env:GITHUB_REPOSITORY/releases/latest/download" - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: windows-${{ runner.arch }} path: | @@ -152,7 +152,7 @@ jobs: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_ACCOUNT_ID: b57dde68ee5964dccc025437b5478a5b steps: - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: path: dist merge-multiple: true @@ -176,7 +176,7 @@ jobs: '.files[$name] = {sha256: $sha}' <<<"$manifest") done printf '%s' "$manifest" > manifest.json - - uses: softprops/action-gh-release@v2 + - uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2 with: files: | dist/* diff --git a/.github/workflows/testflight.yml b/.github/workflows/testflight.yml index f0b425314a..62486aab5f 100644 --- a/.github/workflows/testflight.yml +++ b/.github/workflows/testflight.yml @@ -32,7 +32,7 @@ jobs: AC_API_ISSUER_ID: ${{ secrets.AC_API_ISSUER_ID }} AUDIENCE: ${{ inputs.audience || 'internal' }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Prepare App Store Connect credentials and build number shell: bash @@ -129,7 +129,7 @@ jobs: run: | rustup show active-toolchain || rustup toolchain install rustup target add aarch64-apple-ios aarch64-apple-ios-sim - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: key: ios-core cache-on-failure: true diff --git a/.github/workflows/ui-tests.yml b/.github/workflows/ui-tests.yml index efe5a97dfb..8e5b78f7ad 100644 --- a/.github/workflows/ui-tests.yml +++ b/.github/workflows/ui-tests.yml @@ -7,10 +7,11 @@ on: - 'Cargo.lock' - 'rust-toolchain.toml' - 'crates/**' + - '.github/actions/linux-ci/**' + - 'scripts/ci/**' + - 'scripts/*cursor-sdk*' + - '.github/workflows/cursor-*.yml' - 'scripts/test-linux-browser.sh' - - 'scripts/run-macos-browser-fixture.sh' - - 'dist/macos/Info.plist' - - 'apps/ios/**' - '.github/workflows/ui-tests.yml' push: branches: [main] @@ -19,10 +20,11 @@ on: - 'Cargo.lock' - 'rust-toolchain.toml' - 'crates/**' + - '.github/actions/linux-ci/**' + - 'scripts/ci/**' + - 'scripts/*cursor-sdk*' + - '.github/workflows/cursor-*.yml' - 'scripts/test-linux-browser.sh' - - 'scripts/run-macos-browser-fixture.sh' - - 'dist/macos/Info.plist' - - 'apps/ios/**' - '.github/workflows/ui-tests.yml' workflow_dispatch: @@ -31,226 +33,72 @@ permissions: concurrency: group: ui-tests-${{ github.ref }} - cancel-in-progress: true + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: - changes: + # Every Linux Rust test that is not UI: one build, one nextest run (see + # scripts/ci/test-core.sh). Replaces session-sync-regressions and the Rust + # steps of cursor-compatibility and preview-tests. + core-tests: runs-on: ubuntu-24.04 - permissions: - pull-requests: read - outputs: - ios: ${{ steps.filter.outputs.result }} + timeout-minutes: 30 steps: - - uses: actions/github-script@v8 - id: filter + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: ./.github/actions/linux-ci with: - script: | - // Preserve iOS coverage for main pushes and manual runs. - if (context.eventName !== 'pull_request') return true; - const files = await github.paginate(github.rest.pulls.listFiles, { - ...context.repo, - pull_number: context.payload.pull_request.number, - per_page: 100, - }); - // Run conservatively if the API did not return the full diff. - if (files.length < context.payload.pull_request.changed_files) return true; - // The app links the Rust mobile core and everything it depends on. - const mobileCore = ['crates/mobile/', 'crates/client/', 'crates/text/', 'crates/markdown/', - 'crates/doc/', 'crates/sync/', 'crates/proto/', 'crates/rpc/', 'crates/syntax/', - 'scripts/ios/']; - const affectsIOS = path => path?.startsWith('apps/ios/') - || mobileCore.some(prefix => path?.startsWith(prefix)) - || path === 'Cargo.lock' - || path === 'Cargo.toml' - || path === 'rust-toolchain.toml' - || path === '.github/workflows/ui-tests.yml'; - return files.some(file => affectsIOS(file.filename) - || affectsIOS(file.previous_filename)); - - session-sync-regressions: - runs-on: ubuntu-24.04 - timeout-minutes: 20 - steps: - - uses: actions/checkout@v4 - - name: Rust toolchain - run: rustup show active-toolchain || rustup toolchain install - - uses: Swatinem/rust-cache@v2 - - name: OpenCode protocol and permission regressions - run: | - cargo test --locked -p zeron-harness --lib opencode:: - cargo test --locked -p zeron-harness --test opencode - - name: Durable session publication and recovery - run: | - cargo test --locked -p zeron-sync --lib - cargo test --locked -p zeron-update --lib - cargo test --locked -p zeron-engine --lib --test session_publication --test restart_resume --test codex_subagents --test local_profiles + rust-cache: core + save-rust-cache: 'true' + - name: Harness, engine, sync, update, doc and preview tests + run: scripts/ci/test-core.sh ui-tests: runs-on: ubuntu-24.04 timeout-minutes: 30 steps: - - uses: actions/checkout@v4 - - name: GPUI system dependencies - run: | - # Only Ubuntu packages are needed; unrelated runner repositories can be out of sync. - sudo apt-get update -qq -o Dir::Etc::sourcelist="sources.list.d/ubuntu.sources" -o Dir::Etc::sourceparts="-" - sudo apt-get install -y -qq \ - libxkbcommon-dev libxkbcommon-x11-dev libwayland-dev \ - libx11-dev libxcb1-dev libx11-xcb-dev \ - libfontconfig1-dev libfreetype-dev libasound2-dev \ + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: ./.github/actions/linux-ci + with: + rust-cache: ui + save-rust-cache: 'true' + apt-cache: gpui + apt-packages: >- + libxkbcommon-dev libxkbcommon-x11-dev libwayland-dev + libx11-dev libxcb1-dev libx11-xcb-dev + libfontconfig1-dev libfreetype-dev libasound2-dev libvulkan-dev pkg-config cmake libwebkit2gtk-4.1-dev libjson-glib-dev - - name: Rust toolchain - run: rustup show active-toolchain || rustup toolchain install - - uses: Swatinem/rust-cache@v2 - name: UI and headless layout regressions - run: cargo test --release --locked -p zeron-ui --lib -- --test-threads=1 - - macos-frame-recovery: - runs-on: macos-latest - timeout-minutes: 45 - steps: - - uses: actions/checkout@v4 - - name: Rust toolchain - run: rustup show active-toolchain || rustup toolchain install - - name: Check out pinned GPUI for native frame-source tests - shell: bash - run: | - rev=$(python3 -c 'import tomllib; print(tomllib.load(open("Cargo.toml", "rb"))["workspace"]["dependencies"]["gpui"]["rev"])') - git init frame-source-tests - git -C frame-source-tests remote add origin https://github.com/zeronsh/zui - git -C frame-source-tests fetch --depth=1 origin "$rev" - git -C frame-source-tests checkout --detach FETCH_HEAD - - uses: Swatinem/rust-cache@v2 - with: - cache-on-failure: true - workspaces: | - . - frame-source-tests - - name: Check macOS application - run: cargo check --release --locked -p zeron - - name: Updater tests (bundle staging, install detection) - run: cargo test --release --locked -p zeron-update - - name: Native diff font geometry regression - run: cargo test --release --locked -p zeron-ui --lib native_diff_font_geometry -- --test-threads=1 - - name: Native notification click regressions - run: | - cargo test --release --locked -p zeron-ui --lib notify::tests:: -- --test-threads=1 - - name: Build native browser fixture - run: cargo build --release --locked -p zeron-ui --example browser-fixture --example preview-fixture --features browser-fixture - - name: Native browser regression and screenshots - timeout-minutes: 3 - env: - RUST_BACKTRACE: 1 - run: | - bash scripts/run-macos-browser-fixture.sh target/release/examples/browser-fixture "$RUNNER_TEMP/browser-captures" - test -f "$RUNNER_TEMP/browser-captures/result.txt" - - name: Upload browser screenshots - if: always() - uses: actions/upload-artifact@v4 - with: - name: browser-macos-captures - path: ${{ runner.temp }}/browser-captures - if-no-files-found: ignore - - uses: actions/setup-node@v4 - with: - node-version: 24 - - name: Install preview fixture server - run: npm ci --prefix edge - - name: Project discovery, stable URLs and HMR in native WebKit - timeout-minutes: 3 - env: - ZERON_PREVIEW_AUTO_OPEN: 1 - run: | - VITE_BINARY="$GITHUB_WORKSPACE/edge/node_modules/vite/bin/vite.js" bash scripts/run-macos-browser-fixture.sh target/release/examples/preview-fixture "$RUNNER_TEMP/preview-captures" - test -f "$RUNNER_TEMP/preview-captures/result.txt" - - name: Upload preview screenshots - if: always() - uses: actions/upload-artifact@v4 - with: - name: preview-macos-captures - path: ${{ runner.temp }}/preview-captures - if-no-files-found: ignore - - name: Native frame-source recovery regression - working-directory: frame-source-tests - run: cargo test --release --locked -p gpui_macos --test frame_source_recovery - - ios-tests: - needs: changes - if: needs.changes.outputs.ios == 'true' - runs-on: macos-latest - timeout-minutes: 50 - steps: - - uses: actions/checkout@v4 - - name: Select an available iPhone simulator - run: | - python3 - <<'PYTHON' - import json, os, subprocess - devices = json.loads(subprocess.check_output(["xcrun", "simctl", "list", "devices", "available", "--json"]))["devices"] - iphones = [device for runtime, group in devices.items() if "iOS" in runtime - for device in group if device["name"].startswith("iPhone")] - if not iphones: - raise SystemExit("No iPhone simulator is installed") - with open(os.environ["GITHUB_ENV"], "a") as env: - env.write("IOS_SIMULATOR_ID=" + iphones[0]["udid"] + "\n") - PYTHON - - name: Rust toolchain + iOS targets - run: | - rustup show active-toolchain || rustup toolchain install - rustup target add aarch64-apple-ios aarch64-apple-ios-sim - - uses: Swatinem/rust-cache@v2 - with: - key: ios-core - - name: Rust mobile core tests - run: cargo test --locked -p zeron-text -p zeron-markdown -p zeron-client -p zeron-mobile - - name: Generated Swift bindings are current - run: | - scripts/ios/build-core.sh iphonesimulator - git diff --exit-code -- apps/ios/Zeron/Core/Generated - - name: Run iOS unit tests - working-directory: apps/ios - run: | - xcodebuild -project Zeron.xcodeproj -scheme Zeron \ - -destination "platform=iOS Simulator,id=$IOS_SIMULATOR_ID" \ - -resultBundlePath "$RUNNER_TEMP/ios-tests.xcresult" \ - -only-testing:ZeronTests CODE_SIGNING_ALLOWED=NO test - - name: Upload iOS test results - if: always() - uses: actions/upload-artifact@v4 - with: - name: ios-test-results - path: ${{ runner.temp }}/ios-tests.xcresult - if-no-files-found: ignore + run: cargo nextest run --config-file scripts/ci/nextest.toml --locked --no-fail-fast -p zeron-ui --lib --test voice_orb_geometry linux-browser: runs-on: ubuntu-24.04 timeout-minutes: 30 steps: - - uses: actions/checkout@v4 - - name: Native browser and GPUI dependencies - run: | - # Only Ubuntu packages are needed; unrelated runner repositories can be out of sync. - sudo apt-get update -qq -o Dir::Etc::sourcelist="sources.list.d/ubuntu.sources" -o Dir::Etc::sourceparts="-" - sudo apt-get install -y -qq \ - libxkbcommon-dev libxkbcommon-x11-dev libwayland-dev \ - libx11-dev libxcb1-dev libx11-xcb-dev libfontconfig1-dev \ - libfreetype-dev libasound2-dev libvulkan-dev pkg-config cmake \ - libwebkit2gtk-4.1-dev libjson-glib-dev xvfb xdotool openbox \ + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: ./.github/actions/linux-ci + with: + # Same dependency graph as ui-tests, which saves this cache on main. + rust-cache: ui + apt-cache: browser + apt-recommends: 'true' + apt-packages: >- + libxkbcommon-dev libxkbcommon-x11-dev libwayland-dev + libx11-dev libxcb1-dev libx11-xcb-dev libfontconfig1-dev + libfreetype-dev libasound2-dev libvulkan-dev pkg-config cmake + libwebkit2gtk-4.1-dev libjson-glib-dev xvfb xdotool openbox weston ffmpeg imagemagick fonts-noto-cjk - - name: Rust toolchain - run: rustup show active-toolchain || rustup toolchain install - - uses: Swatinem/rust-cache@v2 - name: Build browser fixture - run: cargo build --release --locked -p zeron-ui --example browser-fixture --features browser-fixture + run: cargo build --locked -p zeron-ui --example browser-fixture --features browser-fixture - name: X11 and Wayland browser regressions timeout-minutes: 5 env: RUST_LOG: gpui_wgpu=info + BROWSER_FIXTURE_BINARY: target/debug/examples/browser-fixture run: | scripts/test-linux-browser.sh "$RUNNER_TEMP/linux-browser/default" VK_DRIVER_FILES=/dev/null scripts/test-linux-browser.sh "$RUNNER_TEMP/linux-browser/opengl" - name: Upload native browser evidence if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: linux-browser path: ${{ runner.temp }}/linux-browser diff --git a/.github/workflows/voice-tests.yml b/.github/workflows/voice-tests.yml new file mode 100644 index 0000000000..8aeec8e2a0 --- /dev/null +++ b/.github/workflows/voice-tests.yml @@ -0,0 +1,123 @@ +name: Native Codex voice +on: + pull_request: + paths: + - 'crates/audio/**' + - 'crates/voice-media/**' + - 'crates/voice-session/**' + - 'crates/orb/**' + - 'crates/proto/src/voice/**' + - 'crates/rpc/**' + - 'crates/client/**' + - 'crates/mobile/**' + - 'apps/ios/Zeron/Voice/**' + - 'apps/ios/ZeronTests/RemoteVoiceLifecycleTests.swift' + - 'dist/voice/**' + - 'scripts/package-voice-runtime.py' + - 'crates/proto/src/voice.rs' + - 'crates/harness/**' + - 'crates/engine/**' + - 'crates/ui/**' + - 'crates/mcp/**' + - 'Cargo.toml' + - 'Cargo.lock' + - 'scripts/codex-voice-remote-smoke.py' + - 'scripts/tests/*voice_remote*' + - 'scripts/tests/test_voice_packaging.py' + - '.github/workflows/voice-tests.yml' + workflow_dispatch: +permissions: + contents: read +jobs: + runtime-packaging: + strategy: + fail-fast: false + matrix: + os: [ubuntu-24.04, windows-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: 5 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: '3.12' + - name: Development runtime projection fixtures (no downloads or audio devices) + run: python -m unittest discover -s scripts/tests -p 'test_voice_packaging.py' -v + + split-host-probe: + strategy: + fail-fast: false + matrix: + os: [ubuntu-24.04, macos-14] + runs-on: ${{ matrix.os }} + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - name: Split-host diagnostic fixtures (no Codex, microphone or provider) + run: python3 -m unittest discover -s scripts/tests -p 'test_voice_remote*.py' -v + + offline-linux: + runs-on: ubuntu-24.04 + timeout-minutes: 45 + env: + CARGO_BUILD_JOBS: 2 + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: llvm-tools-preview + - name: Native audio and GPUI build tools + run: | + sudo apt-get update + sudo apt-get install -y libasound2-dev clang libclang-dev pkg-config meson ninja-build libwayland-dev libxkbcommon-dev libxcb1-dev libxcb-shape0-dev libxcb-xfixes0-dev libx11-xcb-dev libxrandr-dev libxi-dev libxcursor-dev libx11-dev libxinerama-dev libegl1-mesa-dev libvulkan-dev libfontconfig1-dev libfreetype6-dev libssl-dev libdbus-1-dev + - uses: Swatinem/rust-cache@v2 + - name: Protocol, transport and idle runtime (no provider access) + run: | + cargo test --locked -p zeron-voice-media --lib + cargo test --locked -p zeron-orb + cargo test --locked -p zeron-voice-session --lib + cargo test --locked -p zeron-mobile --lib voice + cargo test --locked -p zeron-mobile --lib orb + cargo test --locked -p zeron-proto voice --lib + cargo test --locked -p zeron-harness --lib + cargo test --locked -p zeron-harness --test codex --test acp + cargo test --locked -p zeron-doc voice --lib + cargo test --locked -p zeron-engine --lib voice + cargo test --locked -p zeron-engine --test voice_runtime + cargo test --locked -p zeron-engine --test device_routing + cargo test --locked -p zeron-rpc --test device_room + cargo test --locked -p zeron-mcp --lib + - name: Native audio and WebRTC AEC3 + run: cargo test --locked -p zeron-audio --features aec --lib + + native-platform-check: + strategy: + fail-fast: false + matrix: + os: [macos-14, windows-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: 45 + env: + CARGO_BUILD_JOBS: 2 + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - name: Compile subscription bridge without audio hardware or provider access + run: cargo check --locked -p zeron-harness -p zeron-engine + - name: Installed and development runtime resolution + run: cargo test --locked -p zeron-voice-media --lib + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + if: runner.os == 'Windows' + with: + python-version: '3.12' + - name: Initialize a projected Windows runtime (no provider or audio devices) + if: runner.os == 'Windows' + run: | + $env:ZERON_VOICE_MEDIA_DIR = Join-Path $env:RUNNER_TEMP 'zeron-media/codex-resources/voice' + python scripts/package-voice-runtime.py --download --destination $env:ZERON_VOICE_MEDIA_DIR + if ($LASTEXITCODE -ne 0) { throw 'Voice runtime packaging failed' } + cargo test --locked -p zeron-voice-media --lib packaged_native_runtime_initializes_without_audio_devices -- --ignored diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml index 5b00048499..27bde6cf84 100644 --- a/.github/workflows/windows.yml +++ b/.github/workflows/windows.yml @@ -36,32 +36,21 @@ permissions: concurrency: group: windows-tests-${{ github.ref }} - cancel-in-progress: true + cancel-in-progress: ${{ github.event_name == 'pull_request' }} defaults: run: shell: pwsh jobs: - tests: + # The original single serial job is split into three independent jobs so they + # run in parallel. Together they run exactly the commands the single job ran. + ui-tests: runs-on: windows-2022 - timeout-minutes: 90 + timeout-minutes: 45 steps: - - uses: actions/checkout@v4 - - name: Rust toolchain - run: rustup show active-toolchain || rustup toolchain install - - name: Locate Windows SDK shader compiler - run: | - $fxc = Get-ChildItem "${env:ProgramFiles(x86)}/Windows Kits/10/bin/*/x64/fxc.exe" | - Sort-Object FullName -Descending | Select-Object -First 1 - if (-not $fxc) { throw 'Windows SDK fxc.exe is required for GPUI release shaders' } - "GPUI_FXC_PATH=$($fxc.FullName)" >> $env:GITHUB_ENV - - uses: Swatinem/rust-cache@v2 - with: - cache-on-failure: true - cache-workspace-crates: true - - name: Native harness security and integration tests - run: cargo test --release --locked -p zeron-harness --features native-fixture --lib --test codex_availability --test windows_native + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: ./.github/actions/windows-ci-setup - name: UI tests env: RUST_BACKTRACE: '1' @@ -73,22 +62,47 @@ jobs: cargo test --release --locked -p zeron-ui --lib -- --test-threads=1 --skip native_diff_font_geometry --nocapture $headlessResult = $LASTEXITCODE if ($nativeResult -ne 0 -or $headlessResult -ne 0) { exit 1 } - - name: Build application - run: cargo build --release --locked -p zeron - - name: Verify portable and installer packaging - run: ./scripts/package-windows.ps1 -ReleasesUrl "https://github.com/$env:GITHUB_REPOSITORY/releases/latest/download" - - name: Installer install/uninstall - run: ./scripts/test-windows-installer.ps1 + + # The crate suites compile different package sets, so the two jobs below each + # build what they need in parallel instead of one job building them in turn. + harness-app-tests: + runs-on: windows-2022 + timeout-minutes: 45 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: ./.github/actions/windows-ci-setup + - name: Native harness security and integration tests + run: cargo test --release --locked -p zeron-harness --features native-fixture --lib --test codex_availability --test windows_native - name: Shader layout tests run: cargo test --release --locked -p gpui_windows --lib layout - name: Application and updater tests run: cargo test --release --locked -p zeron -p zeron-update + + engine-tests: + runs-on: windows-2022 + timeout-minutes: 45 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: ./.github/actions/windows-ci-setup - name: Engine unit tests run: cargo test --release --locked -p zeron-engine --lib - name: Harness catalog and login tests run: cargo test --release --locked -p zeron-engine --test codex_catalog --test codex_login_resolution - name: Auth integration tests run: cargo test --release --locked -p zeron-engine --test auth + + app: + runs-on: windows-2022 + timeout-minutes: 45 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: ./.github/actions/windows-ci-setup + - name: Build application + run: cargo build --release --locked -p zeron + - name: Verify portable and installer packaging + run: ./scripts/package-windows.ps1 -ReleasesUrl "https://github.com/$env:GITHUB_REPOSITORY/releases/latest/download" + - name: Installer install/uninstall + run: ./scripts/test-windows-installer.ps1 - name: GUI subsystem and redirected CLI output run: ./scripts/test-windows-startup.ps1 - name: CLI startup without HOME @@ -131,7 +145,7 @@ jobs: run: cargo build --release --locked -p zeron-ui --example windows-render-fixture --features windows-render-fixture - name: Upload GUI test binaries if: ${{ github.event_name == 'workflow_dispatch' && inputs.native_gui }} - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: windows-gui-binaries path: | @@ -142,12 +156,12 @@ jobs: native-gui: if: ${{ github.event_name == 'workflow_dispatch' && inputs.native_gui }} - needs: tests + needs: app runs-on: windows-2022 timeout-minutes: 10 steps: - - uses: actions/checkout@v4 - - uses: actions/download-artifact@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: windows-gui-binaries path: target/release @@ -157,7 +171,7 @@ jobs: run: ./scripts/test-windows-rendering.ps1 - name: Upload GUI test results if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: windows-native-probes path: | diff --git a/.gitignore b/.gitignore index 56b41bfb5c..d138f7f9df 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ edge/dist/ .agents .claude CLAUDE.md +/scripts/**/__pycache__/ diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 417de516c5..4bc2cc40db 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -104,7 +104,7 @@ Two persistent doc kinds. When sync is enabled, session docs ride the chat2 row run journal), tail/diff sidecars. Constants carried over (`STREAM_COMMIT_MS=120`, `DO_FLUSH_MS=5s`, compaction at 8MB, retain 30d, tail 64). -2. **Workspace registry doc** (per profile) — the `registry1` snapshot stores spaces (id, deviceId, path, name?, gitDetected, checkoutId), the chats index (id, deviceId, title, archived, cwd, branch, checkoutId, spaceId, lastSeenAt, lastMessagePreview/At, config), devices, session-status rows, and checkout-diff summary pointers. A space is a device+folder pair in the active profile; the owning device's `SpacesSync` stamps git presence so branch pickers and the diff sidebar can gate without another RPC. Local scope keeps the registry entirely in its profile store. Synced and development scopes join `/registry/{orgId}/ws`, backed by the private per-user room `reg1/{orgId}/{userId}`; rows are never visible to every member of an organization. +2. **Workspace registry doc** (per profile) — the `registry1` snapshot stores spaces (id, deviceId, path, name?, gitDetected, checkoutId, repositoryId), the chats index (id, deviceId, title, archived, cwd, branch, checkoutId, spaceId, lastSeenAt, lastMessagePreview/At, config), devices, session-status rows, and checkout-diff summary pointers. A space is a device+folder pair in the active profile; the owning device's `SpacesSync` stamps git presence so branch pickers and the diff sidebar can gate without another RPC, plus a repository identity (the trunk's root commit, so renames, transfers and remote spellings don't split a repository; a shallow or empty history falls back to the normalized origin remote, then a device-scoped common-git-dir hash) that groups clones and worktrees of one repository in the sidebar's project mode. Local scope keeps the registry entirely in its profile store. Synced and development scopes join `/registry/{orgId}/ws`, backed by the private per-user room `reg1/{orgId}/{userId}`; rows are never visible to every member of an organization. Writer discipline: each device writes its own device and session-status rows, rows for chats it hosts, and git stamps for spaces it owns. Creates, renames, archives, and seen marks are LWW sets accepted from any device. `deleteSpace` tombstones the space and every chat/session row in it in one commit. Presence uses ephemeral room frames rather than durable heartbeat writes. @@ -152,6 +152,7 @@ zeron/ # sockets ({s,k,to,from} frames) theme/ zeron-theme # source-neutral theme schema + built-in/custom registry, # validation, provenance, and local VS Code compiler + voice/ zeron-voice # desktop-local optional Parakeet model, capture and inference; no RPC/sync ui/ zeron-ui # gpui app: shell, sidebar, conversation, composer, # terminal view, diff pane, settings, animation kit apps/ diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 00a0a6e56c..80eb6cd94c 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -49,6 +49,7 @@ Useful knobs for exercising the UI without a real agent or account: | `ZERON_HARNESS=mock` | Offers the mock harness, which streams canned turns | | `ZERON_MOCK_SUBAGENT=1` | Mock turns spawn subagents | | `ZERON_MOCK_THINKING=1` | Mock turns include markdown-heavy thinking | +| `ZERON_MOCK_TODO=1` | Mock turns work through an 8-item checklist (pair with `ZERON_MOCK_DELAY_MS=900` to watch the todo panel) | ## Tests diff --git a/Cargo.lock b/Cargo.lock index 9d2ae3fd6d..9fb91a59b3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -163,6 +163,7 @@ dependencies = [ "const-random", "getrandom 0.3.4", "once_cell", + "serde", "version_check", "zerocopy", ] @@ -225,6 +226,28 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "alsa" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812947049edcd670a82cd5c73c3661d2e58468577ba8489de58e1a73c04cbd5d" +dependencies = [ + "alsa-sys", + "bitflags 2.13.1", + "cfg-if", + "libc", +] + +[[package]] +name = "alsa-sys" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad7569085a265dd3f607ebecce7458eaab2132a84393534c95b18dcbc3f31e04" +dependencies = [ + "libc", + "pkg-config", +] + [[package]] name = "android_system_properties" version = "0.1.6" @@ -814,6 +837,15 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "autotools" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef941527c41b0fc0dd48511a8154cd5fc7e29200a0ff8b7203c5d777dbc795cf" +dependencies = [ + "cc", +] + [[package]] name = "av-scenechange" version = "0.14.1" @@ -827,7 +859,7 @@ dependencies = [ "log", "num-rational", "num-traits", - "pastey", + "pastey 0.1.1", "rayon", "thiserror 2.0.20", "v_frame", @@ -878,6 +910,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + [[package]] name = "base64" version = "0.22.1" @@ -910,6 +948,26 @@ name = "bindgen" version = "0.71.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f58bf3d7db68cfbac37cfc485a8d711e87e064c3d0fe0435b92f7a407f9d6b3" +dependencies = [ + "bitflags 2.13.1", + "cexpr", + "clang-sys", + "itertools 0.10.5", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash 2.1.3", + "shlex 1.3.0", + "syn 2.0.119", +] + +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" dependencies = [ "bitflags 2.13.1", "cexpr", @@ -1243,6 +1301,15 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cbc" version = "0.1.2" @@ -1498,7 +1565,7 @@ dependencies = [ "cocoa-foundation 0.1.2", "core-foundation 0.9.4", "core-graphics 0.23.2", - "foreign-types", + "foreign-types 0.5.0", "libc", "objc", ] @@ -1514,7 +1581,7 @@ dependencies = [ "cocoa-foundation 0.2.0", "core-foundation 0.10.1", "core-graphics 0.24.0", - "foreign-types", + "foreign-types 0.5.0", "libc", "objc", ] @@ -1561,7 +1628,7 @@ dependencies = [ [[package]] name = "collections" version = "0.1.0" -source = "git+https://github.com/zeronsh/zui?rev=18a89af04cfe079d55275b111ac349d5640d9cda#18a89af04cfe079d55275b111ac349d5640d9cda" +source = "git+https://github.com/zeronsh/zui?rev=dce5c1f737a834b32c2b531d552ea78035e54093#dce5c1f737a834b32c2b531d552ea78035e54093" dependencies = [ "gpui_util", "indexmap", @@ -1590,6 +1657,21 @@ dependencies = [ "memchr", ] +[[package]] +name = "compact_str" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "serde", + "static_assertions", +] + [[package]] name = "compression-codecs" version = "0.4.38" @@ -1716,7 +1798,7 @@ dependencies = [ "bitflags 1.3.2", "core-foundation 0.9.4", "core-graphics-types 0.1.3", - "foreign-types", + "foreign-types 0.5.0", "libc", ] @@ -1729,7 +1811,7 @@ dependencies = [ "bitflags 2.13.1", "core-foundation 0.10.1", "core-graphics-types 0.2.0", - "foreign-types", + "foreign-types 0.5.0", "libc", ] @@ -1742,7 +1824,7 @@ dependencies = [ "bitflags 2.13.1", "core-foundation 0.9.4", "core-graphics-types 0.1.3", - "foreign-types", + "foreign-types 0.5.0", "libc", ] @@ -1789,7 +1871,7 @@ checksum = "a593227b66cbd4007b2a050dfdd9e1d1318311409c8d600dc82ba1b15ca9c130" dependencies = [ "core-foundation 0.10.1", "core-graphics 0.24.0", - "foreign-types", + "foreign-types 0.5.0", "libc", ] @@ -1816,6 +1898,20 @@ dependencies = [ "libm", ] +[[package]] +name = "coreaudio-rs" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5d7dca3ebcf65a035582c9ad4385371a9d9ee6537474d2a278f4e1e475bb58" +dependencies = [ + "bitflags 2.13.1", + "libc", + "objc2-audio-toolbox", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", +] + [[package]] name = "cosmic-text" version = "0.19.0" @@ -1840,6 +1936,66 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "cpal" +version = "0.17.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8942da362c0f0d895d7cac616263f2f9424edc5687364dfd1d25ef7eba506d7" +dependencies = [ + "alsa", + "coreaudio-rs", + "dasp_sample", + "jni 0.21.1", + "js-sys", + "libc", + "mach2 0.5.0", + "ndk", + "ndk-context", + "num-derive", + "num-traits", + "objc2 0.6.4", + "objc2-audio-toolbox", + "objc2-avf-audio", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows 0.62.2", +] + +[[package]] +name = "cpal" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f02e8d0327b42d3e2e4ab2119af397344eb9fc54a34bf0ddeaa1277af8681f1" +dependencies = [ + "alsa", + "block2 0.6.2", + "coreaudio-rs", + "dasp_sample", + "jni 0.22.4", + "js-sys", + "libc", + "mach2 0.6.0", + "ndk", + "ndk-context", + "num-derive", + "num-traits", + "objc2 0.6.4", + "objc2-audio-toolbox", + "objc2-avf-audio", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "web-sys", + "windows 0.62.2", + "windows-core 0.62.2", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -2079,6 +2235,12 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "daachorse" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5614204febbc33cc07a2806aa6440b904ac012b68eecc37f4493ea4a76455a3d" + [[package]] name = "darling" version = "0.20.11" @@ -2114,6 +2276,21 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "dary_heap" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b1e3a325bc115f096c8b77bbf027a7c2592230e70be2d985be950d3d5e60ebe" +dependencies = [ + "serde", +] + +[[package]] +name = "dasp_sample" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" + [[package]] name = "data-encoding" version = "2.11.1" @@ -2133,7 +2310,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ "const-oid", - "pem-rfc7468", + "pem-rfc7468 0.7.0", + "zeroize", +] + +[[package]] +name = "der" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a878c850e9e421b20262e9b41f9c860e4785fa07541c266b62ff9d1ef998a80a" +dependencies = [ + "pem-rfc7468 1.0.0", "zeroize", ] @@ -2182,6 +2369,37 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn 2.0.119", +] + [[package]] name = "derive_more" version = "2.1.1" @@ -2208,7 +2426,7 @@ dependencies = [ [[package]] name = "derive_refineable" version = "0.1.0" -source = "git+https://github.com/zeronsh/zui?rev=18a89af04cfe079d55275b111ac349d5640d9cda#18a89af04cfe079d55275b111ac349d5640d9cda" +source = "git+https://github.com/zeronsh/zui?rev=dce5c1f737a834b32c2b531d552ea78035e54093#dce5c1f737a834b32c2b531d552ea78035e54093" dependencies = [ "proc-macro2", "quote", @@ -2382,7 +2600,7 @@ version = "0.16.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" dependencies = [ - "der", + "der 0.7.10", "digest", "elliptic-curve", "rfc6979", @@ -2409,7 +2627,7 @@ dependencies = [ "generic-array", "group", "hkdf", - "pem-rfc7468", + "pem-rfc7468 0.7.0", "pkcs8", "rand_core 0.6.4", "sec1", @@ -2579,6 +2797,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "esaxx-rs" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d817e038c30374a4bcb22f94d0a8a0e216958d4c3dcde369b1439fec4bdda6e6" + [[package]] name = "etagere" version = "0.2.15" @@ -2635,6 +2859,17 @@ dependencies = [ "zune-inflate", ] +[[package]] +name = "eyre" +version = "0.6.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08309dbcc659c5549a24ddb9b27027640641b282ef5768267c7e675558986a3" +dependencies = [ + "autocfg", + "indenter", + "once_cell", +] + [[package]] name = "fallible-iterator" version = "0.3.0" @@ -2839,6 +3074,15 @@ dependencies = [ "ttf-parser", ] +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared 0.1.1", +] + [[package]] name = "foreign-types" version = "0.5.0" @@ -2846,7 +3090,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" dependencies = [ "foreign-types-macros", - "foreign-types-shared", + "foreign-types-shared 0.3.1", ] [[package]] @@ -2860,6 +3104,12 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "foreign-types-shared" version = "0.3.1" @@ -2895,6 +3145,12 @@ dependencies = [ "autocfg", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "fsevent-sys" version = "4.1.0" @@ -3391,14 +3647,14 @@ dependencies = [ [[package]] name = "gpui" version = "0.2.2" -source = "git+https://github.com/zeronsh/zui?rev=18a89af04cfe079d55275b111ac349d5640d9cda#18a89af04cfe079d55275b111ac349d5640d9cda" +source = "git+https://github.com/zeronsh/zui?rev=dce5c1f737a834b32c2b531d552ea78035e54093#dce5c1f737a834b32c2b531d552ea78035e54093" dependencies = [ "accesskit", "anyhow", "async-channel", "async-task", "backtrace", - "bindgen", + "bindgen 0.71.1", "bitflags 2.13.1", "block", "cbindgen", @@ -3416,7 +3672,7 @@ dependencies = [ "derive_more", "embed-resource", "etagere", - "foreign-types", + "foreign-types 0.5.0", "futures", "futures-concurrency", "getrandom 0.3.4", @@ -3431,7 +3687,7 @@ dependencies = [ "itertools 0.14.0", "log", "lyon", - "mach2", + "mach2 0.5.0", "media", "metal", "num_cpus", @@ -3477,7 +3733,7 @@ dependencies = [ [[package]] name = "gpui-base" version = "0.5.2" -source = "git+https://github.com/zeronsh/gpui-component?rev=103f13da91ff2c7c52941bdc2b1910f29716dc19#103f13da91ff2c7c52941bdc2b1910f29716dc19" +source = "git+https://github.com/zeronsh/gpui-component?rev=47ccd07295858d66f8fe826df9651a56218eb887#47ccd07295858d66f8fe826df9651a56218eb887" dependencies = [ "aho-corasick", "anyhow", @@ -3507,7 +3763,7 @@ dependencies = [ [[package]] name = "gpui_linux" version = "0.1.0" -source = "git+https://github.com/zeronsh/zui?rev=18a89af04cfe079d55275b111ac349d5640d9cda#18a89af04cfe079d55275b111ac349d5640d9cda" +source = "git+https://github.com/zeronsh/zui?rev=dce5c1f737a834b32c2b531d552ea78035e54093#dce5c1f737a834b32c2b531d552ea78035e54093" dependencies = [ "accesskit", "accesskit_unix", @@ -3559,7 +3815,7 @@ dependencies = [ [[package]] name = "gpui_macos" version = "0.1.0" -source = "git+https://github.com/zeronsh/zui?rev=18a89af04cfe079d55275b111ac349d5640d9cda#18a89af04cfe079d55275b111ac349d5640d9cda" +source = "git+https://github.com/zeronsh/zui?rev=dce5c1f737a834b32c2b531d552ea78035e54093#dce5c1f737a834b32c2b531d552ea78035e54093" dependencies = [ "accesskit", "accesskit_macos", @@ -3579,7 +3835,7 @@ dependencies = [ "derive_more", "dispatch2", "etagere", - "foreign-types", + "foreign-types 0.5.0", "futures", "gpui", "gpui_util", @@ -3587,7 +3843,7 @@ dependencies = [ "itertools 0.14.0", "libc", "log", - "mach2", + "mach2 0.5.0", "media", "metal", "objc", @@ -3608,7 +3864,7 @@ dependencies = [ [[package]] name = "gpui_macros" version = "0.1.0" -source = "git+https://github.com/zeronsh/zui?rev=18a89af04cfe079d55275b111ac349d5640d9cda#18a89af04cfe079d55275b111ac349d5640d9cda" +source = "git+https://github.com/zeronsh/zui?rev=dce5c1f737a834b32c2b531d552ea78035e54093#dce5c1f737a834b32c2b531d552ea78035e54093" dependencies = [ "heck 0.5.0", "proc-macro2", @@ -3619,7 +3875,7 @@ dependencies = [ [[package]] name = "gpui_platform" version = "0.1.0" -source = "git+https://github.com/zeronsh/zui?rev=18a89af04cfe079d55275b111ac349d5640d9cda#18a89af04cfe079d55275b111ac349d5640d9cda" +source = "git+https://github.com/zeronsh/zui?rev=dce5c1f737a834b32c2b531d552ea78035e54093#dce5c1f737a834b32c2b531d552ea78035e54093" dependencies = [ "console_error_panic_hook", "gpui", @@ -3632,7 +3888,7 @@ dependencies = [ [[package]] name = "gpui_shared_string" version = "0.1.0" -source = "git+https://github.com/zeronsh/zui?rev=18a89af04cfe079d55275b111ac349d5640d9cda#18a89af04cfe079d55275b111ac349d5640d9cda" +source = "git+https://github.com/zeronsh/zui?rev=dce5c1f737a834b32c2b531d552ea78035e54093#dce5c1f737a834b32c2b531d552ea78035e54093" dependencies = [ "schemars", "serde", @@ -3642,7 +3898,7 @@ dependencies = [ [[package]] name = "gpui_tokio" version = "0.1.0" -source = "git+https://github.com/zeronsh/zui?rev=18a89af04cfe079d55275b111ac349d5640d9cda#18a89af04cfe079d55275b111ac349d5640d9cda" +source = "git+https://github.com/zeronsh/zui?rev=dce5c1f737a834b32c2b531d552ea78035e54093#dce5c1f737a834b32c2b531d552ea78035e54093" dependencies = [ "anyhow", "gpui", @@ -3653,7 +3909,7 @@ dependencies = [ [[package]] name = "gpui_util" version = "0.1.0" -source = "git+https://github.com/zeronsh/zui?rev=18a89af04cfe079d55275b111ac349d5640d9cda#18a89af04cfe079d55275b111ac349d5640d9cda" +source = "git+https://github.com/zeronsh/zui?rev=dce5c1f737a834b32c2b531d552ea78035e54093#dce5c1f737a834b32c2b531d552ea78035e54093" dependencies = [ "anyhow", "log", @@ -3663,7 +3919,7 @@ dependencies = [ [[package]] name = "gpui_web" version = "0.1.0" -source = "git+https://github.com/zeronsh/zui?rev=18a89af04cfe079d55275b111ac349d5640d9cda#18a89af04cfe079d55275b111ac349d5640d9cda" +source = "git+https://github.com/zeronsh/zui?rev=dce5c1f737a834b32c2b531d552ea78035e54093#dce5c1f737a834b32c2b531d552ea78035e54093" dependencies = [ "anyhow", "console_error_panic_hook", @@ -3687,7 +3943,7 @@ dependencies = [ [[package]] name = "gpui_wgpu" version = "0.1.0" -source = "git+https://github.com/zeronsh/zui?rev=18a89af04cfe079d55275b111ac349d5640d9cda#18a89af04cfe079d55275b111ac349d5640d9cda" +source = "git+https://github.com/zeronsh/zui?rev=dce5c1f737a834b32c2b531d552ea78035e54093#dce5c1f737a834b32c2b531d552ea78035e54093" dependencies = [ "anyhow", "bytemuck", @@ -3716,7 +3972,7 @@ dependencies = [ [[package]] name = "gpui_windows" version = "0.1.0" -source = "git+https://github.com/zeronsh/zui?rev=18a89af04cfe079d55275b111ac349d5640d9cda#18a89af04cfe079d55275b111ac349d5640d9cda" +source = "git+https://github.com/zeronsh/zui?rev=dce5c1f737a834b32c2b531d552ea78035e54093#dce5c1f737a834b32c2b531d552ea78035e54093" dependencies = [ "accesskit", "accesskit_windows", @@ -3993,6 +4249,12 @@ dependencies = [ "digest", ] +[[package]] +name = "hmac-sha256" +version = "1.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad320b3b96fb2a455a0726d16efe0a5afdbd34b71dea5bc53b05ea057714d4e" + [[package]] name = "home" version = "0.5.12" @@ -4002,6 +4264,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "hound" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62adaabb884c94955b19907d60019f4e145d091c75345379e70d1ee696f7854f" + [[package]] name = "html5ever" version = "0.39.0" @@ -4048,7 +4316,7 @@ dependencies = [ [[package]] name = "http_client" version = "0.1.0" -source = "git+https://github.com/zeronsh/zui?rev=18a89af04cfe079d55275b111ac349d5640d9cda#18a89af04cfe079d55275b111ac349d5640d9cda" +source = "git+https://github.com/zeronsh/zui?rev=dce5c1f737a834b32c2b531d552ea78035e54093#dce5c1f737a834b32c2b531d552ea78035e54093" dependencies = [ "anyhow", "async-compression", @@ -4394,6 +4662,12 @@ version = "1.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89194689a993ab15268672e99e7b0e19da2da3268ac682e8f02d29d4d1434cd7" +[[package]] +name = "indenter" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5" + [[package]] name = "indexmap" version = "2.14.0" @@ -4612,6 +4886,36 @@ dependencies = [ "windows-sys 0.45.0", ] +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.20", + "walkdir", + "windows-link 0.2.1", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + [[package]] name = "jni-sys" version = "0.3.1" @@ -5140,6 +5444,12 @@ dependencies = [ "twox-hash", ] +[[package]] +name = "lzma-rust2" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e20f57f9918e5bd7bc58c22cdd70a6afc7375d4dd9683af5f2b34bd3d2bba619" + [[package]] name = "mac-notification-sys" version = "0.6.15" @@ -5163,6 +5473,28 @@ dependencies = [ "libc", ] +[[package]] +name = "mach2" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae608c151f68243f2b000364e1f7b186d9c29845f7d2d85bd31b9ad77ad552b" + +[[package]] +name = "macro_rules_attribute" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3ae8f6d608c795738406608304d30a2dfbdc8e58e44f7ba43236da5208ded3c" +dependencies = [ + "macro_rules_attribute-proc_macro", + "pastey 0.2.3", +] + +[[package]] +name = "macro_rules_attribute-proc_macro" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc04a4c58212d57930a24bf47d3fa87485264a3a054e9c10e042eb373573ad3c" + [[package]] name = "malloc_buf" version = "0.0.6" @@ -5192,6 +5524,16 @@ dependencies = [ "regex-automata", ] +[[package]] +name = "matrixmultiply" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f607c237553f086e7043417a51df26b2eb899d3caff94e6a67592ff992fedc7" +dependencies = [ + "autocfg", + "rawpointer", +] + [[package]] name = "maybe-rayon" version = "0.1.1" @@ -5221,14 +5563,14 @@ checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771" [[package]] name = "media" version = "0.1.0" -source = "git+https://github.com/zeronsh/zui?rev=18a89af04cfe079d55275b111ac349d5640d9cda#18a89af04cfe079d55275b111ac349d5640d9cda" +source = "git+https://github.com/zeronsh/zui?rev=dce5c1f737a834b32c2b531d552ea78035e54093#dce5c1f737a834b32c2b531d552ea78035e54093" dependencies = [ "anyhow", - "bindgen", + "bindgen 0.71.1", "core-foundation 0.10.1", "core-video", "ctor", - "foreign-types", + "foreign-types 0.5.0", "metal", "objc", ] @@ -5292,7 +5634,7 @@ dependencies = [ "bitflags 2.13.1", "block", "core-graphics-types 0.2.0", - "foreign-types", + "foreign-types 0.5.0", "log", "objc", "paste", @@ -5344,6 +5686,28 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "monostate" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3341a273f6c9d5bef1908f17b7267bbab0e95c9bf69a0d4dcf8e9e1b2c76ef67" +dependencies = [ + "monostate-impl", + "serde", + "serde_core", +] + +[[package]] +name = "monostate-impl" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4db6d5580af57bf992f59068d4ea26fd518574ff48d7639b255a36f9de6e7e9" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "moxcms" version = "0.8.1" @@ -5400,6 +5764,38 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "ndarray" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520080814a7a6b4a6e9070823bb24b4531daac8c4627e08ba5de8c5ef2f2752d" +dependencies = [ + "matrixmultiply", + "num-complex", + "num-integer", + "num-traits", + "portable-atomic", + "portable-atomic-util", + "rawpointer", +] + [[package]] name = "ndk" version = "0.9.0" @@ -5415,6 +5811,12 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + [[package]] name = "ndk-sys" version = "0.6.0+11769913" @@ -5791,6 +6193,32 @@ dependencies = [ "objc2-quartz-core 0.3.2", ] +[[package]] +name = "objc2-audio-toolbox" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6948501a91121d6399b79abaa33a8aa4ea7857fe019f341b8c23ad6e81b79b08" +dependencies = [ + "bitflags 2.13.1", + "libc", + "objc2 0.6.4", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-avf-audio" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13a380031deed8e99db00065c45937da434ca987c034e13b87e4441f9e4090be" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + [[package]] name = "objc2-cloud-kit" version = "0.3.2" @@ -5802,6 +6230,29 @@ dependencies = [ "objc2-foundation 0.3.2", ] +[[package]] +name = "objc2-core-audio" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1eebcea8b0dbff5f7c8504f3107c68fc061a3eb44932051c8cf8a68d969c3b2" +dependencies = [ + "dispatch2", + "objc2 0.6.4", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-core-audio-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", +] + [[package]] name = "objc2-core-data" version = "0.2.2" @@ -5832,7 +6283,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ "bitflags 2.13.1", + "block2 0.6.2", "dispatch2", + "libc", "objc2 0.6.4", ] @@ -6135,6 +6588,28 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "onig" +version = "6.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc3cbf698f9438986c11a880c90a6d04b9de27575afd28bbf45b154b6c709e2" +dependencies = [ + "bitflags 2.13.1", + "libc", + "once_cell", + "onig_sys", +] + +[[package]] +name = "onig_sys" +version = "69.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e68317604e77e53b85896388e1a803c1d21b74c899ec9e5e1112db90735edd7" +dependencies = [ + "cc", + "pkg-config", +] + [[package]] name = "oo7" version = "0.6.0" @@ -6192,6 +6667,49 @@ dependencies = [ "libc", ] +[[package]] +name = "openssl" +version = "0.10.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" +dependencies = [ + "bitflags 2.13.1", + "cfg-if", + "foreign-types 0.3.2", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "option-ext" version = "0.2.0" @@ -6217,6 +6735,30 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "ort" +version = "2.0.0-rc.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4336a1e2b38848325241c72889086886004e589b7c74f335e60a8e8db5138a0b" +dependencies = [ + "ndarray", + "ort-sys", + "smallvec", + "tracing", + "ureq", +] + +[[package]] +name = "ort-sys" +version = "2.0.0-rc.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf211e3776eea6aec988552fa118dd746d70e1b1e5e244058d1c98015f3e5872" +dependencies = [ + "hmac-sha256", + "lzma-rust2", + "ureq", +] + [[package]] name = "p256" version = "0.13.2" @@ -6266,6 +6808,22 @@ dependencies = [ "system-deps", ] +[[package]] +name = "parakeet-rs" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f54545a861dca6b43c8d44e3dd932764ca196d84bcf0d1b48ea212b43b29666" +dependencies = [ + "eyre", + "hound", + "ndarray", + "ort", + "realfft", + "serde", + "serde_json", + "tokenizers", +] + [[package]] name = "parking" version = "2.2.1" @@ -6307,6 +6865,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" +[[package]] +name = "pastey" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" + [[package]] name = "pathfinder_geometry" version = "0.5.1" @@ -6355,6 +6919,15 @@ dependencies = [ "base64ct", ] +[[package]] +name = "pem-rfc7468" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -6364,7 +6937,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perf" version = "0.1.0" -source = "git+https://github.com/zeronsh/zui?rev=18a89af04cfe079d55275b111ac349d5640d9cda#18a89af04cfe079d55275b111ac349d5640d9cda" +source = "git+https://github.com/zeronsh/zui?rev=dce5c1f737a834b32c2b531d552ea78035e54093#dce5c1f737a834b32c2b531d552ea78035e54093" dependencies = [ "collections", "serde", @@ -6521,7 +7094,7 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" dependencies = [ - "der", + "der 0.7.10", "spki", ] @@ -6786,6 +7359,15 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "primal-check" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0d895b311e3af9902528fbb8f928688abbd95872819320517cc24ca6b2bd08" +dependencies = [ + "num-integer", +] + [[package]] name = "primeorder" version = "0.13.6" @@ -7338,6 +7920,12 @@ dependencies = [ "objc2-quartz-core 0.3.2", ] +[[package]] +name = "rawpointer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" + [[package]] name = "rayon" version = "1.12.0" @@ -7348,6 +7936,17 @@ dependencies = [ "rayon-core", ] +[[package]] +name = "rayon-cond" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2964d0cf57a3e7a06e8183d14a8b527195c706b7983549cd5462d5aa3747438f" +dependencies = [ + "either", + "itertools 0.14.0", + "rayon", +] + [[package]] name = "rayon-core" version = "1.13.0" @@ -7394,6 +7993,15 @@ dependencies = [ "once_cell", ] +[[package]] +name = "realfft" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f821338fddb99d089116342c46e9f1fbf3828dba077674613e734e01d6ea8677" +dependencies = [ + "rustfft", +] + [[package]] name = "reborrow" version = "0.5.5" @@ -7443,7 +8051,7 @@ dependencies = [ [[package]] name = "refineable" version = "0.1.0" -source = "git+https://github.com/zeronsh/zui?rev=18a89af04cfe079d55275b111ac349d5640d9cda#18a89af04cfe079d55275b111ac349d5640d9cda" +source = "git+https://github.com/zeronsh/zui?rev=dce5c1f737a834b32c2b531d552ea78035e54093#dce5c1f737a834b32c2b531d552ea78035e54093" dependencies = [ "derive_refineable", ] @@ -7913,6 +8521,18 @@ dependencies = [ "sansio", ] +[[package]] +name = "rubato" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5258099699851cfd0082aeb645feb9c084d9a5e1f1b8d5372086b989fc5e56a1" +dependencies = [ + "num-complex", + "num-integer", + "num-traits", + "realfft", +] + [[package]] name = "rusqlite" version = "0.32.1" @@ -7988,6 +8608,20 @@ dependencies = [ "semver", ] +[[package]] +name = "rustfft" +version = "6.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21db5f9893e91f41798c88680037dba611ca6674703c1a18601b01a72c8adb89" +dependencies = [ + "num-complex", + "num-integer", + "num-traits", + "primal-check", + "strength_reduce", + "transpose", +] + [[package]] name = "rusticata-macros" version = "4.1.0" @@ -8126,10 +8760,19 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c62751faa8bc286982334a082fe125184a29fc89d17775766e4f891b7d726980" +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "scheduler" version = "0.1.0" -source = "git+https://github.com/zeronsh/zui?rev=18a89af04cfe079d55275b111ac349d5640d9cda#18a89af04cfe079d55275b111ac349d5640d9cda" +source = "git+https://github.com/zeronsh/zui?rev=dce5c1f737a834b32c2b531d552ea78035e54093#dce5c1f737a834b32c2b531d552ea78035e54093" dependencies = [ "async-task", "backtrace", @@ -8235,13 +8878,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" dependencies = [ "base16ct", - "der", + "der 0.7.10", "generic-array", "pkcs8", "subtle", "zeroize", ] +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.1", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "selectors" version = "0.38.0" @@ -8582,6 +9248,16 @@ version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + [[package]] name = "simd_helpers" version = "0.1.0" @@ -8715,6 +9391,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "socks" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0c3dbbd9ae980613c6dd8e28a9407b50509d3803b57624d5dfe8315218cd58b" +dependencies = [ + "byteorder", + "libc", + "winapi", +] + [[package]] name = "soup3" version = "0.5.0" @@ -8775,7 +9462,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" dependencies = [ "base64ct", - "der", + "der 0.7.10", +] + +[[package]] +name = "spm_precompiled" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5851699c4033c63636f7ea4cf7b7c1f1bf06d0cc03cfb42e711de5a5c46cf326" +dependencies = [ + "base64 0.13.1", + "nom 7.1.3", + "serde", + "unicode-segmentation", ] [[package]] @@ -8836,6 +9535,12 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520" +[[package]] +name = "strength_reduce" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82" + [[package]] name = "strict-num" version = "0.1.1" @@ -8914,7 +9619,7 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "sum_tree" version = "0.1.0" -source = "git+https://github.com/zeronsh/zui?rev=18a89af04cfe079d55275b111ac349d5640d9cda#18a89af04cfe079d55275b111ac349d5640d9cda" +source = "git+https://github.com/zeronsh/zui?rev=dce5c1f737a834b32c2b531d552ea78035e54093#dce5c1f737a834b32c2b531d552ea78035e54093" dependencies = [ "heapless 0.9.3", "log", @@ -9403,6 +10108,39 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +[[package]] +name = "tokenizers" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7afbf6e88718afcc138bad01d6ccc3051dbbc3b2ce9793d8b8a3aeb610969cfc" +dependencies = [ + "ahash", + "compact_str", + "daachorse", + "dary_heap", + "derive_builder", + "esaxx-rs", + "getrandom 0.3.4", + "itertools 0.14.0", + "log", + "macro_rules_attribute", + "monostate", + "onig", + "paste", + "rand 0.9.5", + "rayon", + "rayon-cond", + "regex", + "regex-syntax", + "serde", + "serde_json", + "spm_precompiled", + "thiserror 2.0.20", + "unicode-normalization-alignments", + "unicode-segmentation", + "unicode_categories", +] + [[package]] name = "tokio" version = "1.53.1" @@ -9467,6 +10205,7 @@ dependencies = [ "futures-core", "futures-sink", "futures-util", + "libc", "pin-project-lite", "tokio", ] @@ -9692,6 +10431,16 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "transpose" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad61aed86bc3faea4300c7aee358b4c6d0c8d6ccc36524c96e4c92ccf26e77e" +dependencies = [ + "num-integer", + "strength_reduce", +] + [[package]] name = "tree-sitter" version = "0.26.11" @@ -10092,6 +10841,15 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" +[[package]] +name = "unicode-normalization-alignments" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43f613e4fa046e69818dd287fdc4bc78175ff20331479dab6e1b0f98d57062de" +dependencies = [ + "smallvec", +] + [[package]] name = "unicode-properties" version = "0.1.4" @@ -10128,6 +10886,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "unicode_categories" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" + [[package]] name = "uniffi" version = "0.32.2" @@ -10271,6 +11035,36 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "ureq" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a7ac20be9b7726e0bbdbf974c059676d9acb1cd414961f570a4e8231cacd7fc" +dependencies = [ + "base64 0.23.1", + "der 0.8.2", + "log", + "native-tls", + "percent-encoding", + "rustls-pki-types", + "socks", + "ureq-proto", + "utf8-zero", + "webpki-root-certs", +] + +[[package]] +name = "ureq-proto" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f86fd172ccca569e458f61b6bdd6220965a9ef36e672a6852953b51a0e1583be" +dependencies = [ + "base64 0.23.1", + "http", + "httparse", + "log", +] + [[package]] name = "url" version = "2.5.8" @@ -10316,6 +11110,12 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -10331,7 +11131,7 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "util_macros" version = "0.1.0" -source = "git+https://github.com/zeronsh/zui?rev=18a89af04cfe079d55275b111ac349d5640d9cda#18a89af04cfe079d55275b111ac349d5640d9cda" +source = "git+https://github.com/zeronsh/zui?rev=dce5c1f737a834b32c2b531d552ea78035e54093#dce5c1f737a834b32c2b531d552ea78035e54093" dependencies = [ "perf", "quote", @@ -10759,6 +11559,15 @@ dependencies = [ "system-deps", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webpki-roots" version = "0.26.11" @@ -10795,6 +11604,37 @@ dependencies = [ "tokio", ] +[[package]] +name = "webrtc-audio-processing" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "190c57ced88d6307ba91778c245265a99f99d900a9239c87a1b15142330c9f7e" +dependencies = [ + "webrtc-audio-processing-config", + "webrtc-audio-processing-sys", +] + +[[package]] +name = "webrtc-audio-processing-config" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142cbc598e3dcf1a71e90109b6169ca9ce28ec1910bf74eef30f3c420ce850a4" + +[[package]] +name = "webrtc-audio-processing-sys" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8f6c2b4b20a03b165378172f0dbe23c49b2630b70091026589afbe04e79cd3e" +dependencies = [ + "anyhow", + "autotools", + "bindgen 0.72.1", + "cc", + "fs_extra", + "pkg-config", + "regex", +] + [[package]] name = "webview2-com" version = "0.38.2" @@ -11595,7 +12435,7 @@ dependencies = [ "gtk", "http", "javascriptcore-rs", - "jni", + "jni 0.21.1", "libc", "ndk", "objc2 0.6.4", @@ -12101,7 +12941,7 @@ dependencies = [ [[package]] name = "zeron" -version = "0.2.99" +version = "0.2.107" dependencies = [ "anyhow", "clap", @@ -12121,9 +12961,21 @@ dependencies = [ "zeron-update", ] +[[package]] +name = "zeron-audio" +version = "0.2.107" +dependencies = [ + "base64 0.22.1", + "cpal 0.18.2", + "crossbeam-queue", + "thiserror 2.0.20", + "webrtc-audio-processing", + "zeron-proto", +] + [[package]] name = "zeron-client" -version = "0.2.99" +version = "0.2.107" dependencies = [ "async-trait", "chrono", @@ -12143,11 +12995,12 @@ dependencies = [ "zeron-proto", "zeron-rpc", "zeron-sync", + "zeron-voice-session", ] [[package]] name = "zeron-doc" -version = "0.2.99" +version = "0.2.107" dependencies = [ "chrono", "loro", @@ -12161,7 +13014,7 @@ dependencies = [ [[package]] name = "zeron-engine" -version = "0.2.99" +version = "0.2.107" dependencies = [ "anyhow", "async-trait", @@ -12187,10 +13040,12 @@ dependencies = [ "tokio-util", "toml 0.8.23", "tracing", + "tracing-subscriber", "uuid", "windows-sys 0.61.2", "zeron-doc", "zeron-harness", + "zeron-mcp", "zeron-preview", "zeron-proto", "zeron-rpc", @@ -12201,7 +13056,7 @@ dependencies = [ [[package]] name = "zeron-harness" -version = "0.2.99" +version = "0.2.107" dependencies = [ "anyhow", "async-trait", @@ -12223,19 +13078,20 @@ dependencies = [ "uuid", "windows-sys 0.61.2", "zeron-proto", + "zeron-voice-media", "zip", ] [[package]] name = "zeron-markdown" -version = "0.2.99" +version = "0.2.107" dependencies = [ "pulldown-cmark", ] [[package]] name = "zeron-mcp" -version = "0.2.99" +version = "0.2.107" dependencies = [ "anyhow", "async-trait", @@ -12253,24 +13109,34 @@ dependencies = [ [[package]] name = "zeron-mobile" -version = "0.2.99" +version = "0.2.107" dependencies = [ + "async-trait", "serde", "serde_json", "similar", "thiserror 2.0.20", + "tokio", + "tokio-util", "uniffi", "zeron-client", "zeron-doc", "zeron-markdown", + "zeron-orb", "zeron-proto", "zeron-syntax", "zeron-text", + "zeron-veil", + "zeron-voice-session", ] +[[package]] +name = "zeron-orb" +version = "0.2.107" + [[package]] name = "zeron-preview" -version = "0.2.99" +version = "0.2.107" dependencies = [ "anyhow", "async-trait", @@ -12298,7 +13164,7 @@ dependencies = [ [[package]] name = "zeron-proto" -version = "0.2.99" +version = "0.2.107" dependencies = [ "chrono", "pulldown-cmark", @@ -12309,7 +13175,7 @@ dependencies = [ [[package]] name = "zeron-rpc" -version = "0.2.99" +version = "0.2.107" dependencies = [ "anyhow", "async-trait", @@ -12328,7 +13194,7 @@ dependencies = [ [[package]] name = "zeron-sync" -version = "0.2.99" +version = "0.2.107" dependencies = [ "block2 0.6.2", "chrono", @@ -12351,7 +13217,7 @@ dependencies = [ [[package]] name = "zeron-syntax" -version = "0.2.99" +version = "0.2.107" dependencies = [ "thiserror 2.0.20", "tree-sitter", @@ -12385,7 +13251,7 @@ dependencies = [ [[package]] name = "zeron-text" -version = "0.2.99" +version = "0.2.107" dependencies = [ "core-foundation 0.10.1", "core-graphics 0.24.0", @@ -12402,7 +13268,7 @@ dependencies = [ [[package]] name = "zeron-theme" -version = "0.2.99" +version = "0.2.107" dependencies = [ "anyhow", "clap", @@ -12417,7 +13283,7 @@ dependencies = [ [[package]] name = "zeron-ui" -version = "0.2.99" +version = "0.2.107" dependencies = [ "alacritty_terminal", "anyhow", @@ -12432,7 +13298,7 @@ dependencies = [ "core-foundation 0.10.1", "core-graphics 0.24.0", "fontdb", - "foreign-types", + "foreign-types 0.5.0", "futures", "gpui", "gpui-base", @@ -12460,6 +13326,7 @@ dependencies = [ "tempfile", "thiserror 2.0.20", "tokio", + "tokio-util", "tracing", "tracing-subscriber", "ttf-parser", @@ -12468,6 +13335,9 @@ dependencies = [ "url", "usvg", "uuid", + "webview2-com", + "windows 0.61.3", + "windows-core 0.61.2", "windows-sys 0.61.2", "wry", "x11rb 0.14.0", @@ -12475,16 +13345,21 @@ dependencies = [ "zeron-engine", "zeron-harness", "zeron-markdown", + "zeron-orb", "zeron-proto", "zeron-rpc", "zeron-syntax", "zeron-theme", "zeron-update", + "zeron-veil", + "zeron-voice", + "zeron-voice-media", + "zeron-voice-session", ] [[package]] name = "zeron-update" -version = "0.2.99" +version = "0.2.107" dependencies = [ "anyhow", "futures", @@ -12498,6 +13373,58 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "zeron-veil" +version = "0.2.107" + +[[package]] +name = "zeron-voice" +version = "0.2.107" +dependencies = [ + "anyhow", + "cpal 0.17.3", + "hound", + "parakeet-rs", + "reqwest", + "rubato", + "serde", + "serde_json", + "sha2", + "tempfile", + "tokio", +] + +[[package]] +name = "zeron-voice-media" +version = "0.2.107" +dependencies = [ + "async-trait", + "semver", + "serde_json", + "sha2", + "tempfile", + "tokio", + "tokio-util", + "zeron-proto", + "zeron-voice-session", +] + +[[package]] +name = "zeron-voice-session" +version = "0.2.107" +dependencies = [ + "async-trait", + "futures", + "serde", + "serde_json", + "tokio", + "tokio-util", + "tracing", + "zeron-orb", + "zeron-proto", + "zeron-rpc", +] + [[package]] name = "zerotrie" version = "0.2.5" diff --git a/Cargo.toml b/Cargo.toml index f71c5fbc6a..020bd94200 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,13 @@ [workspace] resolver = "2" members = [ + "crates/voice", + "crates/voice-media", + "crates/voice-session", + "crates/orb", + "crates/veil", "crates/theme", + "crates/audio", "crates/proto", "crates/preview", "crates/doc", @@ -21,12 +27,17 @@ members = [ ] [workspace.package] -version = "0.2.99" +version = "0.2.107" edition = "2024" license = "MIT" publish = false [workspace.dependencies] +zeron-voice-session = { path = "crates/voice-session" } +zeron-orb = { path = "crates/orb" } +zeron-veil = { path = "crates/veil" } +zeron-voice-media = { path = "crates/voice-media" } +zeron-audio = { path = "crates/audio" } zeron-proto = { path = "crates/proto" } zeron-preview = { path = "crates/preview" } zeron-doc = { path = "crates/doc" } @@ -76,15 +87,15 @@ loro-protocol = "0.3" # Linux — the Metal path's snapshot/blur/composite, ported). # Paint-time rounded image masks (#9), bounded Windows backdrop blur and # translucent compositing, plus blur-aware scene batching (#10). -gpui = { git = "https://github.com/zeronsh/zui", rev = "18a89af04cfe079d55275b111ac349d5640d9cda" } -gpui_platform = { git = "https://github.com/zeronsh/zui", rev = "18a89af04cfe079d55275b111ac349d5640d9cda", features = [ +gpui = { git = "https://github.com/zeronsh/zui", rev = "dce5c1f737a834b32c2b531d552ea78035e54093" } +gpui_platform = { git = "https://github.com/zeronsh/zui", rev = "dce5c1f737a834b32c2b531d552ea78035e54093", features = [ "wayland", "x11", "font-kit", "runtime_shaders", ] } -gpui_tokio = { git = "https://github.com/zeronsh/zui", rev = "18a89af04cfe079d55275b111ac349d5640d9cda" } -gpui-base = { git = "https://github.com/zeronsh/gpui-component", rev = "103f13da91ff2c7c52941bdc2b1910f29716dc19" } +gpui_tokio = { git = "https://github.com/zeronsh/zui", rev = "dce5c1f737a834b32c2b531d552ea78035e54093" } +gpui-base = { git = "https://github.com/zeronsh/gpui-component", rev = "47ccd07295858d66f8fe826df9651a56218eb887" } # diffs similar = "2" diff --git a/README.ja.md b/README.ja.md new file mode 100644 index 0000000000..793e01cde5 --- /dev/null +++ b/README.ja.md @@ -0,0 +1,56 @@ +# Zeron + +コーディングエージェント(Claude Code、Codex、Cursor、Devin、Grok、Hermes、Pi、Antigravity)をデフォルトではローカルで管理し、必要に応じて複数デバイス間で同期することもできます。 + +*[English](README.md) | [简体中文](README.zh-CN.md) | [한국어](README.ko.md) | 日本語* + +![Zeron デスクトップアプリ](docs/media/readme/app-screenshot.jpg) + +## デスクトップアプリ + +[GitHub Releases](https://github.com/zeronsh/zeron/releases/latest) から、お使いのプラットフォーム向けの最新版をダウンロードしてください。 + +- **macOS** — `zeron--macos-arm64.dmg` +- **Windows** — `zeron--windows-x86_64-setup.exe` +- **Linux** — `zeron--linux-.tar.gz` を展開し、中の `install.sh` を実行 + +アカウントもネットワーク接続も不要で、セッションはお使いのデバイスに保存されます。アプリは自動でアップデートされます。 + +## ヘッドレス実行(CLI) + +サーバーなど、ディスプレイのないマシン向けです。たとえば、ノートパソコンを閉じたあともエージェントを動かし続ける VPS に使えます。Linux のみ対応しています。 + +```bash +curl -fsSL https://zeron.sh/install.sh | sh +zeron status +``` + +インストーラーはエンジンをバックグラウンドサービスとして起動し、再起動後も自動的に立ち上がります。 + +```bash +zeron status # ローカル/同期モードとエンジンの状態を確認 +zeron update # 最新版にアップデート +zeron daemon start|stop|restart|status +``` + +## 複数デバイス間の同期(任意) + +サインインすると、あるデバイスで起動したエージェントを別のデバイスから確認したり操作したりできます。 + +```bash +zeron daemon stop +zeron login # ローカル専用モードに戻すには zeron logout +zeron daemon start +``` + +同じアカウントにサインインしたデバイスは、互いのワークスペースのファイルを読み書きできます。信頼できるデバイスでのみサインインしてください。既存のローカルセッションがアップロードされることはありません。 + +## スポンサー + +Zeron をスポンサーしてくださっている [The Context Company](https://www.thecontextcompany.com/) に感謝します。[GitHub でスポンサーになって](https://github.com/sponsors/zeronsh) Zeron の開発を支援することもできます。 + +--- + +開発に参加したい方や仕組みが気になる方は、[Ask DeepWiki](https://deepwiki.com/zeronsh/zeron) または [ARCHITECTURE.md](ARCHITECTURE.md) をご覧ください。 + +[MIT License](LICENSE) のもとで公開されています。 diff --git a/README.ko.md b/README.ko.md new file mode 100644 index 0000000000..22077c0dee --- /dev/null +++ b/README.ko.md @@ -0,0 +1,56 @@ +# Zeron + +코딩 에이전트(Claude Code, Codex, Cursor, Devin, Grok, Hermes, Pi, Antigravity)를 기본적으로 로컬에서 관리하고, 필요하면 여러 기기 간 동기화도 사용할 수 있습니다. + +*[English](README.md) | [简体中文](README.zh-CN.md) | 한국어 | [日本語](README.ja.md)* + +![Zeron 데스크톱 앱](docs/media/readme/app-screenshot.jpg) + +## 데스크톱 앱 + +[GitHub Releases](https://github.com/zeronsh/zeron/releases/latest)에서 플랫폼에 맞는 최신 버전을 내려받으세요. + +- **macOS** — `zeron--macos-arm64.dmg` +- **Windows** — `zeron--windows-x86_64-setup.exe` +- **Linux** — `zeron--linux-.tar.gz`의 압축을 풀고 안에 있는 `install.sh` 실행 + +계정이나 네트워크 연결이 필요 없으며, 세션은 사용 중인 기기에 저장됩니다. 앱은 자동으로 업데이트됩니다. + +## 헤드리스 실행 (CLI) + +서버처럼 디스플레이가 없는 머신용입니다. 예를 들어 노트북을 닫은 뒤에도 에이전트를 계속 돌려 두는 VPS에 쓰면 됩니다. Linux만 지원합니다. + +```bash +curl -fsSL https://zeron.sh/install.sh | sh +zeron status +``` + +설치 스크립트는 엔진을 백그라운드 서비스로 실행하며, 재부팅 후에도 자동으로 다시 시작됩니다. + +```bash +zeron status # 로컬/동기화 모드와 엔진 상태 확인 +zeron update # 최신 버전으로 업데이트 +zeron daemon start|stop|restart|status +``` + +## 여러 기기 간 동기화 (선택) + +로그인하면 한 기기에서 에이전트를 시작하고 다른 기기에서 이어서 보거나 조작할 수 있습니다. + +```bash +zeron daemon stop +zeron login # 로컬 전용 모드로 돌아가려면 zeron logout +zeron daemon start +``` + +같은 계정에 로그인한 기기끼리는 서로의 워크스페이스 파일을 읽고 쓸 수 있으므로, 신뢰하는 기기에서만 로그인하세요. 기존 로컬 세션은 업로드되지 않습니다. + +## 후원 + +Zeron을 후원해 주신 [The Context Company](https://www.thecontextcompany.com/)에 감사드립니다. [GitHub에서 후원자가 되어](https://github.com/sponsors/zeronsh) Zeron 개발을 지원하실 수도 있습니다. + +--- + +개발에 참여하고 싶거나 동작 방식이 궁금하다면 [Ask DeepWiki](https://deepwiki.com/zeronsh/zeron) 또는 [ARCHITECTURE.md](ARCHITECTURE.md)를 확인하세요. + +[MIT License](LICENSE)로 배포됩니다. diff --git a/README.md b/README.md index 1e4476709e..257b15a696 100644 --- a/README.md +++ b/README.md @@ -2,24 +2,30 @@ Control your coding agents (Claude Code, Codex, Cursor, Devin, Grok, Hermes, Pi, Antigravity) locally by default, with optional multi-device sync. -*English | [简体中文](README.zh-CN.md)* +*English | [简体中文](README.zh-CN.md) | [한국어](README.ko.md) | [日本語](README.ja.md)* -![Zeron driving a Claude Code session with a live branch diff sidebar](apps/landing/public/assets/app-screenshot.jpg) +![Zeron desktop app](docs/media/readme/app-screenshot.jpg) -Every device runs a small engine that stores sessions on that device. A new installation starts in local-only mode without an account or a network connection. +## Desktop app -## Install and run locally (Linux) +Download the latest release for your platform from [GitHub Releases](https://github.com/zeronsh/zeron/releases/latest): + +- **macOS** — `zeron--macos-arm64.dmg` +- **Windows** — `zeron--windows-x86_64-setup.exe` +- **Linux** — `zeron--linux-.tar.gz`, then run its `install.sh` + +No account or network connection is needed; sessions stay on your device. The app updates itself. + +## Headless (CLI) + +For servers and other machines without a display, such as a VPS that keeps agents running after you close your laptop. Linux only: ```bash curl -fsSL https://zeron.sh/install.sh | sh zeron status ``` -The installer starts the daemon immediately and keeps it running across reboots. No sign-in or sync configuration is required. It also adds Zeron to your application launcher: a per-user `zeron.desktop` and icon under `~/.local/share` (or `$XDG_DATA_HOME`), rewritten each time the installer runs. - -The desktop sidebar browser also needs the [Linux browser runtime](docs/reference/linux-browser.md). - -Day-to-day: +The installer starts the engine as a background service that survives reboots. ```bash zeron status # local/synced mode and engine status @@ -27,48 +33,24 @@ zeron update # update to the latest release zeron daemon start|stop|restart|status ``` -## Optional multi-device sync - -Sign in only when you want to open your account's synced workspace. Authentication changes the profile selected by the next engine start, so stop the daemon before changing it: - -```bash -zeron daemon stop -zeron login -zeron daemon start -``` - -You can then start an agent on one synced device and follow or drive it from another. An always-on machine such as a VPS can keep those agents working after you close your laptop. +## Multi-device sync (optional) -Devices signed in to the same synced account are trusted with remote workspace access. A device controlling a workspace on another device can list, read, and write its files; enabling `Show ignored files` also makes gitignored files such as `.env` available remotely. `.git` is always excluded. Only sign in devices you trust with the full contents of your workspaces. - -Signing in does not upload, move, or import existing local sessions. Local sessions and their attachments remain under the local profile and reappear when you return to local-only mode: +Sign in to start an agent on one device and follow or drive it from another: ```bash zeron daemon stop -zeron logout +zeron login # or: zeron logout to return to local-only zeron daemon start ``` -`zeron login` and `zeron logout` refuse to modify credentials while an engine owns the data directory. The desktop app follows the same next-restart profile boundary. - -On macOS: use the desktop release, or build `zeron` from source and run `zeron daemon install` to install the launchd service. - -On Windows: run the `zeron--windows-x86_64-setup.exe` installer from the [latest release](https://github.com/zeronsh/zeron/releases/latest). It installs for your user without administrator rights, adds Zeron to the Start menu, and appears in Settings → Apps for uninstalling. A portable ZIP is also published; keep `zeron-update.json` beside `zeron.exe` for in-app updates. See the [development notes](docs/reference/windows-development.md) for source builds. - -## Updates - -The desktop app checks for a new release when it starts, every hour while it runs, and when you come back to it after the machine slept. A new version downloads in the background; the sidebar then offers **Update ready — restart to apply**, and if you don't restart, it installs the next time you quit Zeron. Check by hand with **Zeron → Check for Updates…** on macOS, or **Check for updates** in the account menu (bottom of the sidebar) on Windows and Linux. Set `ZERON_AUTO_UPDATE=0` to be notified without the background download. - -Linux desktop installs from the release tarball's `install.sh` use the same `~/.zeron/app` layout as the curl installer, so they update in place too. A daemon installed as a service restarts into a newer installed version once no agent run or terminal is active; `zeron update` updates headless installs on demand. +Devices signed in to the same account can read and write each other's workspace files, so only sign in devices you trust. Existing local sessions are never uploaded. ## Sponsors -Thank you to [The Context Company](https://www.thecontextcompany.com/) for sponsoring Zeron. - -You can help fund Zeron's development too. Individuals and companies are welcome to [become a sponsor on GitHub](https://github.com/sponsors/zeronsh). +Thank you to [The Context Company](https://www.thecontextcompany.com/) for sponsoring Zeron. You can help fund Zeron's development too by [becoming a sponsor on GitHub](https://github.com/sponsors/zeronsh). --- -Developing or curious how it works? [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/zeronsh/zeron) or check out [ARCHITECTURE.md](ARCHITECTURE.md). +Developing or curious how it works? [Ask DeepWiki](https://deepwiki.com/zeronsh/zeron) or check out [ARCHITECTURE.md](ARCHITECTURE.md). Licensed under the [MIT License](LICENSE). diff --git a/README.zh-CN.md b/README.zh-CN.md index 420b65373d..8af544bd97 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -1,23 +1,31 @@ # Zeron -在本地管理你的编码 agent(Claude Code、Codex、Cursor、Grok、Hermes、Pi、Antigravity),也可以打开多设备同步。 +在本地管理你的编码 agent(Claude Code、Codex、Cursor、Devin、Grok、Hermes、Pi、Antigravity),也可以打开多设备同步。 -*[English](README.md) | 简体中文* +*[English](README.md) | 简体中文 | [한국어](README.ko.md) | [日本語](README.ja.md)* -![Zeron 驱动一个 Claude Code 会话,侧边栏是实时的分支 diff](apps/landing/public/assets/app-screenshot.jpg) +![Zeron 桌面应用](docs/media/readme/app-screenshot.jpg) -每台设备各跑一个小引擎,会话就存在这台设备上。装完默认是纯本地模式,不用账号,也不用联网。 +## 桌面应用 -## 在本地安装运行(Linux) +从 [GitHub Releases](https://github.com/zeronsh/zeron/releases/latest) 下载对应平台的最新版本: + +- **macOS** — `zeron--macos-arm64.dmg` +- **Windows** — `zeron--windows-x86_64-setup.exe` +- **Linux** — `zeron--linux-.tar.gz`,解压后运行里面的 `install.sh` + +不用账号,也不用联网,会话就存在这台设备上。应用会自动更新。 + +## 无界面运行(CLI) + +适用于服务器等没有显示器的机器,比如在你合上笔记本之后继续跑 agent 的 VPS。仅支持 Linux: ```bash curl -fsSL https://zeron.sh/install.sh | sh zeron status ``` -安装脚本会马上把守护进程拉起来,重启之后也会自己回来。不需要登录,也不需要配置同步。它还会把 Zeron 加入应用启动器:在 `~/.local/share`(或 `$XDG_DATA_HOME`)下写入用户级的 `zeron.desktop` 和图标,每次运行安装脚本都会重写。 - -日常命令: +安装脚本会把引擎作为后台服务拉起来,重启之后也会自己回来。 ```bash zeron status # 查看本地/同步模式和引擎状态 @@ -25,38 +33,24 @@ zeron update # 更新到最新版本 zeron daemon start|stop|restart|status ``` -## 可选:多设备同步 - -只有想打开账号下的同步工作区时才需要登录。登录会换掉引擎下次启动时用的 profile,所以改之前先停掉守护进程: - -```bash -zeron daemon stop -zeron login -zeron daemon start -``` - -之后就可以在一台同步过的设备上起 agent,换另一台设备接着看、接着操作。一台常开的机器,比如 VPS,可以在你合上笔记本之后继续跑这些 agent。 +## 多设备同步(可选) -登录不会上传、搬走或导入已有的本地会话。本地会话和它们的附件仍然留在本地 profile 下,切回纯本地模式时会照常出现: +登录后,可以在一台设备上起 agent,换另一台设备接着看、接着操作: ```bash zeron daemon stop -zeron logout +zeron login # 或者 zeron logout 切回纯本地模式 zeron daemon start ``` -如果有引擎正占着数据目录,`zeron login` 和 `zeron logout` 会拒绝改动凭据。桌面应用同样遵守这条边界:profile 要等下次重启才切换。 - -macOS 上用桌面版发行包,或者从源码构建 `zeron`,再运行 `zeron daemon install` 装上 launchd 服务。 +登录同一账号的设备可以读写彼此工作区里的文件,所以只登录你信任的设备。已有的本地会话不会被上传。 ## 赞助 -感谢 [The Context Company](https://www.thecontextcompany.com/) 对 Zeron 的赞助。 - -你也可以资助 Zeron 的开发。欢迎个人和公司[通过 GitHub 成为赞助者](https://github.com/sponsors/zeronsh)。 +感谢 [The Context Company](https://www.thecontextcompany.com/) 对 Zeron 的赞助。你也可以[通过 GitHub 成为赞助者](https://github.com/sponsors/zeronsh),资助 Zeron 的开发。 --- -想参与开发,或者好奇它怎么跑起来的?[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/zeronsh/zeron),也可以看 [ARCHITECTURE.md](ARCHITECTURE.md)。 +想参与开发,或者好奇它怎么跑起来的?[Ask DeepWiki](https://deepwiki.com/zeronsh/zeron),也可以看 [ARCHITECTURE.md](ARCHITECTURE.md)。 采用 [MIT License](LICENSE)。 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index b12292308a..e58d4dc56a 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -141,3 +141,285 @@ Zeron code. The Zui native overlay renderer adapts Apache-2.0 GPUI code from [`egoist/zed` at `57bd4fe`](https://github.com/egoist/zed/tree/57bd4fe181639797d395978d5de17bc9e10a6219/crates/gpui_macos). Attribution is retained in the pinned Zui dependency’s `NOTICE`. + +## Optional desktop dictation + +Parakeet TDT 0.6B v3 model weights are by NVIDIA under CC BY 4.0. The optional download uses Ivan Stupakov's INT8 ONNX conversion, pinned to revision `8f23f0c03c8761650bdb5b40aaf3e40d2c15f1ce`, with per-file SHA-256 verification. Original: https://huggingface.co/nvidia/parakeet-tdt-0.6b-v3. Conversion: https://huggingface.co/istupakov/parakeet-tdt-0.6b-v3-onnx. License: https://creativecommons.org/licenses/by/4.0/. + +Native runtime dependencies: parakeet-rs 0.3.8 (MIT OR Apache-2.0), ort/ort-sys 2.0.0-rc.13 (MIT OR Apache-2.0), ONNX Runtime 1.28.0 (MIT), cpal 0.17.3 (Apache-2.0), and rubato 0.16.2 (MIT). See `crates/voice/NOTICE.md` for exact conversion provenance, changes, runtime/platform limitations and supported languages. Models are not included in the application bundle. + +## Bezel thinking orbs + +`crates/orb/` and `crates/ui/src/orb/` were extracted from Bezel +(https://github.com/clearloop/bezel), revision +`6141af9c16f7353cdf36003f7404e0a94566a163`, `crates/agent/src/orbs/`. +This code is compiled into the desktop app and, through the mobile core, +the iOS app; Bezel is not a dependency. The upstream module is a port of +gpui-thinking-orbs (https://github.com/FrancoEscob/gpui-thinking-orbs), itself +a port of Jakub Antalik's thinking-orbs +(https://github.com/Jakubantalik/thinking-orbs); the orb algorithms and +per-state tunings originate there. All three are MIT-licensed. Original +attribution comments are retained. The iOS app carries this notice as +`Voice/ThinkingOrbs-LICENSE.txt`. + +MIT License + +Copyright (c) 2026 clearloop +Copyright (c) 2026 Wing (portions extracted from zeronsh/comet) +Copyright (c) 2026 gpui-thinking-orbs contributors +Copyright (c) 2026 Jakub Antalik + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +## Codex native voice protocol + +The bounded helper control protocol and native runtime environment in +`crates/voice-media/src/lib.rs` are adapted from OpenAI Codex +`rust-v0.159.0` (`377f7f557a6bdea0f3a2d26d4d899c66db4789d0`), +`codex-rs/realtime-webrtc/src/{protocol,client,linux_alsa}.rs`. +Copyright 2025 OpenAI. Licensed under the Apache License, Version 2.0. +Upstream NOTICE: "OpenAI Codex. Copyright 2025 OpenAI." +Zeron runs the helper of the user's separately installed standalone Codex, on +every platform; Zeron packages do not contain or redistribute Codex binaries or +its native audio runtime. This adaptation uses asynchronous bounded pipes and +the Zeron engine's owner leases. The iOS voice endpoint +(`apps/ios/Zeron/Voice/CodexVoicePeer.swift`) implements the transport behavior +described by `codex-rs/voice-host/src/transport.rs` at +`a956835d020762cb2b570053af06f643a11c0ecc` without copying its code. + +Voice sessions use OpenAI's service under the user's own ChatGPT-authenticated +Codex account and OpenAI's terms; this license covers the adapted code only. +OpenAI, ChatGPT and Codex are trademarks of OpenAI. Zeron is not affiliated +with or endorsed by OpenAI. The license text follows. + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright 2025 OpenAI + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +## WebRTC for iOS voice + +The iOS app links the WebRTC XCFramework from the `stasel/WebRTC` Swift package, +pinned to 150.0.0 (binary SHA-256 +`f9890492b0016e4c88ab20f07867b8b420054caedc8a692b2ec6ac041f3cf6b2`), a build of +Google's WebRTC: https://github.com/stasel/WebRTC/tree/150.0.0. WebRTC is +licensed under the BSD 3-Clause License, "Copyright (c) 2011, The WebRTC project +authors. All rights reserved.", with Google's additional patent grant: +https://webrtc.org/support/license. The full license text ships in the iOS app +as `Voice/WebRTC-LICENSE.txt`. The binary also contains WebRTC's own bundled +third-party components (such as BoringSSL, libsrtp, Opus, libyuv and Abseil) +under their respective licenses; see the WebRTC source tree's `third_party/` +for each license. diff --git a/apps/ios/README.md b/apps/ios/README.md index 682317fd2f..b83117fcae 100644 --- a/apps/ios/README.md +++ b/apps/ios/README.md @@ -48,6 +48,11 @@ Zeron/ Shell/ Tab bar (Sessions, Settings, search; "New session" accessory with live summary), sign-in Debug/ Transcript lab + hitch meter +ZeronLiveActivity/ Widget extension: the voice call's Live Activity (Dynamic + Island + Lock Screen), with the orb's states exported from + zeron-orb as vector assets +ZeronShared/ Compiled into the app and the extension: the activity's + attributes and its mute/end App Intents ``` ### Transcript pipeline diff --git a/apps/ios/Zeron.xcodeproj/project.pbxproj b/apps/ios/Zeron.xcodeproj/project.pbxproj index e1d7866b8c..535f4d6817 100644 --- a/apps/ios/Zeron.xcodeproj/project.pbxproj +++ b/apps/ios/Zeron.xcodeproj/project.pbxproj @@ -14,6 +14,21 @@ ); target = A1000000000000000000T001 /* Zeron */; }; + A400000000000000000000E1 /* Exceptions for "ZeronLiveActivity" folder in "ZeronLiveActivity" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = A4000000000000000000T001 /* ZeronLiveActivity */; + }; + A400000000000000000000E2 /* Exceptions for "Zeron" folder in "ZeronLiveActivity" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + "Fonts/Geist-Medium.ttf", + "Fonts/GeistMono-Medium.ttf", + ); + target = A4000000000000000000T001 /* ZeronLiveActivity */; + }; /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ @@ -21,6 +36,7 @@ isa = PBXFileSystemSynchronizedRootGroup; exceptions = ( A100000000000000000000E1 /* Exceptions for "Zeron" folder in "Zeron" target */, + A400000000000000000000E2 /* Exceptions for "Zeron" folder in "ZeronLiveActivity" target */, ); path = Zeron; sourceTree = ""; @@ -35,6 +51,19 @@ path = ZeronUITests; sourceTree = ""; }; + A4000000000000000000G001 /* ZeronLiveActivity */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + A400000000000000000000E1 /* Exceptions for "ZeronLiveActivity" folder in "ZeronLiveActivity" target */, + ); + path = ZeronLiveActivity; + sourceTree = ""; + }; + A5000000000000000000G001 /* ZeronShared */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = ZeronShared; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXContainerItemProxy section */ @@ -52,6 +81,13 @@ remoteGlobalIDString = A1000000000000000000T001; remoteInfo = Zeron; }; + A4000000000000000000X001 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = A1000000000000000000PJ01 /* Project object */; + proxyType = 1; + remoteGlobalIDString = A4000000000000000000T001; + remoteInfo = ZeronLiveActivity; + }; /* End PBXContainerItemProxy section */ /* Begin PBXTargetDependency section */ @@ -65,19 +101,56 @@ target = A1000000000000000000T001 /* Zeron */; targetProxy = A3000000000000000000X001 /* PBXContainerItemProxy */; }; + A4000000000000000000D001 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = A4000000000000000000T001 /* ZeronLiveActivity */; + targetProxy = A4000000000000000000X001 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin PBXFileReference section */ A1000000000000000000PR01 /* Zeron.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Zeron.app; sourceTree = BUILT_PRODUCTS_DIR; }; A2000000000000000000PR01 /* ZeronTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ZeronTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; A3000000000000000000PR01 /* ZeronUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ZeronUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + A4000000000000000000PR01 /* ZeronLiveActivity.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = ZeronLiveActivity.appex; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ +/* Begin PBXBuildFile section */ + B71000000000000000000001 /* WebRTC in Frameworks */ = {isa = PBXBuildFile; productRef = B71000000000000000000002 /* WebRTC */; }; + A4000000000000000000BF01 /* ZeronLiveActivity.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = A4000000000000000000PR01 /* ZeronLiveActivity.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; +/* End PBXBuildFile section */ +/* Begin PBXCopyFilesBuildPhase section */ + A1000000000000000000CP01 /* Embed Foundation Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + A4000000000000000000BF01 /* ZeronLiveActivity.appex in Embed Foundation Extensions */, + ); + name = "Embed Foundation Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ +/* Begin XCRemoteSwiftPackageReference section */ + B71000000000000000000003 /* WebRTC */ = {isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/stasel/WebRTC.git"; requirement = {kind = exactVersion; version = 150.0.0; }; }; +/* End XCRemoteSwiftPackageReference section */ +/* Begin XCSwiftPackageProductDependency section */ + B71000000000000000000002 /* WebRTC */ = {isa = XCSwiftPackageProductDependency; package = B71000000000000000000003; productName = WebRTC; }; +/* End XCSwiftPackageProductDependency section */ /* Begin PBXFrameworksBuildPhase section */ A1000000000000000000F001 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + B71000000000000000000001 /* WebRTC in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + A4000000000000000000F001 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( ); runOnlyForDeploymentPostprocessing = 0; }; @@ -90,6 +163,8 @@ A1000000000000000000G001 /* Zeron */, A2000000000000000000G001 /* ZeronTests */, A3000000000000000000G001 /* ZeronUITests */, + A4000000000000000000G001 /* ZeronLiveActivity */, + A5000000000000000000G001 /* ZeronShared */, A1000000000000000000G002 /* Products */, ); sourceTree = ""; @@ -100,6 +175,7 @@ A1000000000000000000PR01 /* Zeron.app */, A2000000000000000000PR01 /* ZeronTests.xctest */, A3000000000000000000PR01 /* ZeronUITests.xctest */, + A4000000000000000000PR01 /* ZeronLiveActivity.appex */, ); name = Products; sourceTree = ""; @@ -115,17 +191,20 @@ A1000000000000000000S001 /* Sources */, A1000000000000000000F001 /* Frameworks */, A1000000000000000000R001 /* Resources */, + A1000000000000000000CP01 /* Embed Foundation Extensions */, ); buildRules = ( ); dependencies = ( + A4000000000000000000D001 /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( A1000000000000000000G001 /* Zeron */, + A5000000000000000000G001 /* ZeronShared */, ); name = Zeron; packageProductDependencies = ( - ); + B71000000000000000000002 /* WebRTC */, ); productName = Zeron; productReference = A1000000000000000000PR01 /* Zeron.app */; productType = "com.apple.product-type.application"; @@ -172,11 +251,35 @@ productReference = A3000000000000000000PR01 /* ZeronUITests.xctest */; productType = "com.apple.product-type.bundle.ui-testing"; }; + A4000000000000000000T001 /* ZeronLiveActivity */ = { + isa = PBXNativeTarget; + buildConfigurationList = A4000000000000000000C001 /* Build configuration list for PBXNativeTarget "ZeronLiveActivity" */; + buildPhases = ( + A4000000000000000000S001 /* Sources */, + A4000000000000000000F001 /* Frameworks */, + A4000000000000000000R001 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + A4000000000000000000G001 /* ZeronLiveActivity */, + A5000000000000000000G001 /* ZeronShared */, + ); + name = ZeronLiveActivity; + packageProductDependencies = ( + ); + productName = ZeronLiveActivity; + productReference = A4000000000000000000PR01 /* ZeronLiveActivity.appex */; + productType = "com.apple.product-type.app-extension"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ A1000000000000000000PJ01 /* Project object */ = { isa = PBXProject; + packageReferences = (B71000000000000000000003); attributes = { BuildIndependentTargetsInParallel = 1; LastSwiftUpdateCheck = 2630; @@ -185,6 +288,9 @@ A1000000000000000000T001 = { CreatedOnToolsVersion = 26.3; }; + A4000000000000000000T001 = { + CreatedOnToolsVersion = 26.3; + }; }; }; buildConfigurationList = A1000000000000000000C000 /* Build configuration list for PBXProject "Zeron" */; @@ -204,6 +310,7 @@ A1000000000000000000T001 /* Zeron */, A2000000000000000000T001 /* ZeronTests */, A3000000000000000000T001 /* ZeronUITests */, + A4000000000000000000T001 /* ZeronLiveActivity */, ); }; /* End PBXProject section */ @@ -216,6 +323,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + A4000000000000000000R001 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ @@ -263,6 +377,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + A4000000000000000000S001 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin XCBuildConfiguration section */ @@ -470,6 +591,60 @@ }; name = Release; }; + A4000000000000000000BC03 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 5XY3M483YQ; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = ZeronLiveActivity/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = Zeron; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = sh.zeron.ios.LiveActivity; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + A4000000000000000000BC04 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 5XY3M483YQ; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = ZeronLiveActivity/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = Zeron; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = sh.zeron.ios.LiveActivity; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -509,6 +684,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + A4000000000000000000C001 /* Build configuration list for PBXNativeTarget "ZeronLiveActivity" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A4000000000000000000BC03 /* Debug */, + A4000000000000000000BC04 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ }; diff --git a/apps/ios/Zeron.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/apps/ios/Zeron.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000000..55e026c921 --- /dev/null +++ b/apps/ios/Zeron.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,15 @@ +{ + "originHash" : "7ca243407a3b132834ec3b117a3aea85a8f743151ef868a872c8bbf8082921f6", + "pins" : [ + { + "identity" : "webrtc", + "kind" : "remoteSourceControl", + "location" : "https://github.com/stasel/WebRTC.git", + "state" : { + "revision" : "6ed87f05368632f71dc95c89c14c051561710925", + "version" : "150.0.0" + } + } + ], + "version" : 3 +} diff --git a/apps/ios/Zeron/App/AppModel.swift b/apps/ios/Zeron/App/AppModel.swift index fb9d8515c2..070b11d2e0 100644 --- a/apps/ios/Zeron/App/AppModel.swift +++ b/apps/ios/Zeron/App/AppModel.swift @@ -17,6 +17,7 @@ final class AppModel { var awaiting = 0 } + @MainActor lazy var voice = RemoteVoiceController(app: self) private(set) var client: CoreClient? private(set) var frontPage = FrontPage() private(set) var archived: [SessionRowVM] = [] @@ -196,7 +197,8 @@ final class AppModel { } /// The user signing out: this device forgets the account's local docs. - func signOut() { + @MainActor func signOut() { + voice.stop() forgetOnSignOut = true onSignOut?() } @@ -206,7 +208,10 @@ final class AppModel { /// keeps a different account out of it. private var forgetOnSignOut = false - func signOutLocally() { + @MainActor func signOutLocally() { + voice.stop() + endDeliveryBackgroundTask() + lastDeliveryActivity = .distantPast client?.shutdown() client = nil Credentials.clearStored() @@ -228,8 +233,38 @@ final class AppModel { newSessionImages = [] } - func didEnterBackground() { client?.onBackground() } + private var lastDeliveryActivity = Date.distantPast + + func noteDeliveryActivity() { lastDeliveryActivity = Date() } + + private var deliveryBackgroundTask: UIBackgroundTaskIdentifier = .invalid + private var deliveryBackgroundDeadline: DispatchWorkItem? + + private func endDeliveryBackgroundTask() { + deliveryBackgroundDeadline?.cancel() + deliveryBackgroundDeadline = nil + guard deliveryBackgroundTask != .invalid else { return } + UIApplication.shared.endBackgroundTask(deliveryBackgroundTask) + deliveryBackgroundTask = .invalid + } + + func didEnterBackground() { + // Give the already-durable room outbox and host wake time to reach + // the edge when the user leaves immediately after tapping Send. + endDeliveryBackgroundTask() + if client != nil, !isDemo, + Date().timeIntervalSince(lastDeliveryActivity) < 120 || rows.values.contains(where: { $0.sendState == .sending || $0.sendState == .queued }) { + deliveryBackgroundTask = UIApplication.shared.beginBackgroundTask(withName: "Finish message delivery") { [weak self] in + self?.endDeliveryBackgroundTask() + } + let deadline = DispatchWorkItem { [weak self] in self?.endDeliveryBackgroundTask() } + deliveryBackgroundDeadline = deadline + DispatchQueue.main.asyncAfter(deadline: .now() + 25, execute: deadline) + } + client?.onBackground() + } func willEnterForeground() { + endDeliveryBackgroundTask() client?.onForeground() refreshWorkspace() } @@ -436,11 +471,15 @@ final class AppModel { // MARK: Sessions - func sessionSource(_ chatId: String) -> SessionSource { - guard let client, let handle = try? client.openSession(chatId: chatId) else { - return FixtureSessionSource(title: "Unavailable", subtitle: "") + func sessionSource(_ chatId: String) -> SessionSource? { + guard let client else { return nil } + do { + let handle = try client.openSession(chatId: chatId) + return CoreSessionSource(app: self, client: client, handle: handle, chatId: chatId) + } catch { + NSLog("open session failed: \(error)") + return nil } - return CoreSessionSource(app: self, client: client, handle: handle, chatId: chatId) } // MARK: New session @@ -476,7 +515,7 @@ final class AppModel { } var projectOptions: [ProjectOption] { - rawProjects.map { ProjectOption(id: $0.id, name: $0.name, device: $0.deviceId, deviceName: $0.deviceName ?? deviceName($0.deviceId), online: $0.deviceOnline, git: $0.gitDetected, colorIndex: Int($0.colorIndex)) } + rawProjects.map { ProjectOption(id: $0.id, name: $0.name, path: $0.path, device: $0.deviceId, deviceName: $0.deviceName ?? deviceName($0.deviceId), online: $0.deviceOnline, git: $0.gitDetected, groupKey: $0.groupKey, groupName: $0.groupName, colorIndex: Int($0.colorIndex)) } } var hostOptions: [HostOption] { @@ -545,6 +584,7 @@ final class AppModel { let project = rawProjects.first { $0.id == draft.projectId } let worktree = draft.worktree ? project.map { WorktreeSpec(repoPath: $0.path, base: draft.branch ?? "HEAD", spaceId: $0.id) } : nil _ = try handle.send(request: SendRequest(text: text, attachments: images.map(\.outgoing), worktree: worktree, busy: .queue)) + noteDeliveryActivity() refreshWorkspace() return chatId } catch { diff --git a/apps/ios/Zeron/App/SceneDelegate.swift b/apps/ios/Zeron/App/SceneDelegate.swift index fe75152937..2afd6f584f 100644 --- a/apps/ios/Zeron/App/SceneDelegate.swift +++ b/apps/ios/Zeron/App/SceneDelegate.swift @@ -53,6 +53,14 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate { window.makeKeyAndVisible() if !args.contains("-lab") { shellShown() } + #if DEBUG + if args.contains("-voice-preview") { + app.voice.preview() + if args.contains("-voice-stage") { + DispatchQueue.main.async { [app] in window.rootViewController?.presentVoiceStage(app: app, source: nil) } + } + } + #endif if let router = window.rootViewController as? AppRouter, let i = args.firstIndex(of: "-route"), i + 1 < args.count { let route = args[i + 1] DispatchQueue.main.async { @@ -93,6 +101,14 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate { } } + /// `zeron://voice` (the call's Live Activity) returns to the stage. + func scene(_ scene: UIScene, openURLContexts URLContexts: Set) { + guard URLContexts.contains(where: { $0.url.scheme == "zeron" && $0.url.host == "voice" }), + app.voice.live, let root = window?.rootViewController + else { return } + root.presentVoiceStage(app: app, source: nil) + } + func sceneDidEnterBackground(_ scene: UIScene) { app.didEnterBackground() } diff --git a/apps/ios/Zeron/Core/Generated/zeron_core.swift b/apps/ios/Zeron/Core/Generated/zeron_core.swift index 2a750518a6..55e89c2ef4 100644 --- a/apps/ios/Zeron/Core/Generated/zeron_core.swift +++ b/apps/ios/Zeron/Core/Generated/zeron_core.swift @@ -671,6 +671,143 @@ fileprivate struct FfiConverterData: FfiConverterRustBuffer { +/** + * One call's caption. Advance it whenever the caption changes and on every + * display frame while the last frame was `animating`. + */ +public protocol CaptionFaderProtocol: AnyObject, Sendable { + + func frame(item: String?, text: String, reducedMotion: Bool) -> CaptionFrame + +} +/** + * One call's caption. Advance it whenever the caption changes and on every + * display frame while the last frame was `animating`. + */ +open class CaptionFader: CaptionFaderProtocol, @unchecked Sendable { + fileprivate let handle: UInt64 + + /// Used to instantiate a [FFIObject] without an actual handle, for fakes in tests, mostly. +#if swift(>=5.8) + @_documentation(visibility: private) +#endif + public struct NoHandle { + public init() {} + } + + // TODO: We'd like this to be `private` but for Swifty reasons, + // we can't implement `FfiConverter` without making this `required` and we can't + // make it `required` without making it `public`. +#if swift(>=5.8) + @_documentation(visibility: private) +#endif + required public init(unsafeFromHandle handle: UInt64) { + self.handle = handle + } + + // This constructor can be used to instantiate a fake object. + // - Parameter noHandle: Placeholder value so we can have a constructor separate from the default empty one that may be implemented for classes extending [FFIObject]. + // + // - Warning: + // Any object instantiated with this constructor cannot be passed to an actual Rust-backed object. Since there isn't a backing handle the FFI lower functions will crash. +#if swift(>=5.8) + @_documentation(visibility: private) +#endif + public init(noHandle: NoHandle) { + self.handle = 0 + } + +#if swift(>=5.8) + @_documentation(visibility: private) +#endif + public func uniffiCloneHandle() -> UInt64 { + return try! rustCall { uniffi_zeron_mobile_fn_clone_captionfader(self.handle, $0) } + } +public convenience init(maxChars: UInt32) { + let handle = + try! rustCall() { + uniffiCallStatus in + uniffi_zeron_mobile_fn_constructor_captionfader_new( + FfiConverterUInt32.lower(maxChars),uniffiCallStatus + ) +} + self.init(unsafeFromHandle: handle) +} + + deinit { + if handle == 0 { + // Mock objects have handle=0 don't try to free them + return + } + + try! rustCall { uniffi_zeron_mobile_fn_free_captionfader(handle, $0) } + } + + + + +open func frame(item: String?, text: String, reducedMotion: Bool) -> CaptionFrame { + return try! FfiConverterTypeCaptionFrame_lift(try! rustCall() { + uniffiCallStatus in + uniffi_zeron_mobile_fn_method_captionfader_frame( + self.uniffiCloneHandle(), + FfiConverterOptionString.lower(item), + FfiConverterString.lower(text), + FfiConverterBool.lower(reducedMotion),uniffiCallStatus + ) +}) +} + + + +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeCaptionFader: FfiConverter { + typealias FfiType = UInt64 + typealias SwiftType = CaptionFader + + public static func lift(_ handle: UInt64) throws -> CaptionFader { + return CaptionFader(unsafeFromHandle: handle) + } + + public static func lower(_ value: CaptionFader) -> UInt64 { + return value.uniffiCloneHandle() + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> CaptionFader { + let handle: UInt64 = try readInt(&buf) + return try lift(handle) + } + + public static func write(_ value: CaptionFader, into buf: inout [UInt8]) { + writeInt(&buf, lower(value)) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeCaptionFader_lift(_ handle: UInt64) throws -> CaptionFader { + return try FfiConverterTypeCaptionFader.lift(handle) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeCaptionFader_lower(_ value: CaptionFader) -> UInt64 { + return FfiConverterTypeCaptionFader.lower(value) +} + + + + + + /** * Receives coalesced change events (at most one burst per display frame). * Called on a client thread: hop to the main thread and pull there. @@ -1098,6 +1235,12 @@ public protocol CoreClientProtocol: AnyObject, Sendable { */ func workspaceRevision() -> UInt64 + /** + * Returns immediately. Callbacks are dispatched on the core runtime; the + * platform must hop to its media/UI executor and call complete_media. + */ + func startVoice(hostDeviceId: String, voice: String?, media: VoiceMediaListener, listener: VoiceSessionListener) -> VoiceCall + } /** * One signed-in account (or Demo mode). Create one per sign-in; call @@ -1899,6 +2042,23 @@ open func workspaceRevision() -> UInt64 { }) } + /** + * Returns immediately. Callbacks are dispatched on the core runtime; the + * platform must hop to its media/UI executor and call complete_media. + */ +open func startVoice(hostDeviceId: String, voice: String?, media: VoiceMediaListener, listener: VoiceSessionListener) -> VoiceCall { + return try! FfiConverterTypeVoiceCall_lift(try! rustCall() { + uniffiCallStatus in + uniffi_zeron_mobile_fn_method_coreclient_start_voice( + self.uniffiCloneHandle(), + FfiConverterString.lower(hostDeviceId), + FfiConverterOptionString.lower(voice), + FfiConverterTypeVoiceMediaListener_lower(media), + FfiConverterTypeVoiceSessionListener_lower(listener),uniffiCallStatus + ) +}) +} + } @@ -2467,6 +2627,183 @@ public func FfiConverterTypeLayoutListener_lower(_ value: LayoutListener) -> UIn +/** + * One animated orb. Calls are cheap and safe from any thread; drive + * [`OrbRenderer::next_frame`] from the display link. + */ +public protocol OrbRendererProtocol: AnyObject, Sendable { + + /** + * Advance to now and return the frame to paint. Time only accumulates + * while `animating`; reduced motion shows the static representative frame. + */ + func nextFrame(animating: Bool, reducedMotion: Bool) -> OrbFrame + + /** + * Normalized 0…1 peaks; they speed the motion up, never the geometry. + */ + func setAudioLevels(microphone: Float, speaker: Float) + + func setOrb(orb: VoiceOrb) + +} +/** + * One animated orb. Calls are cheap and safe from any thread; drive + * [`OrbRenderer::next_frame`] from the display link. + */ +open class OrbRenderer: OrbRendererProtocol, @unchecked Sendable { + fileprivate let handle: UInt64 + + /// Used to instantiate a [FFIObject] without an actual handle, for fakes in tests, mostly. +#if swift(>=5.8) + @_documentation(visibility: private) +#endif + public struct NoHandle { + public init() {} + } + + // TODO: We'd like this to be `private` but for Swifty reasons, + // we can't implement `FfiConverter` without making this `required` and we can't + // make it `required` without making it `public`. +#if swift(>=5.8) + @_documentation(visibility: private) +#endif + required public init(unsafeFromHandle handle: UInt64) { + self.handle = handle + } + + // This constructor can be used to instantiate a fake object. + // - Parameter noHandle: Placeholder value so we can have a constructor separate from the default empty one that may be implemented for classes extending [FFIObject]. + // + // - Warning: + // Any object instantiated with this constructor cannot be passed to an actual Rust-backed object. Since there isn't a backing handle the FFI lower functions will crash. +#if swift(>=5.8) + @_documentation(visibility: private) +#endif + public init(noHandle: NoHandle) { + self.handle = 0 + } + +#if swift(>=5.8) + @_documentation(visibility: private) +#endif + public func uniffiCloneHandle() -> UInt64 { + return try! rustCall { uniffi_zeron_mobile_fn_clone_orbrenderer(self.handle, $0) } + } + /** + * Crossfades state changes over 300 ms, like the desktop voice orbs. + */ +public convenience init(preset: OrbPreset, orb: VoiceOrb) { + let handle = + try! rustCall() { + uniffiCallStatus in + uniffi_zeron_mobile_fn_constructor_orbrenderer_new( + FfiConverterTypeOrbPreset_lower(preset), + FfiConverterTypeVoiceOrb_lower(orb),uniffiCallStatus + ) +} + self.init(unsafeFromHandle: handle) +} + + deinit { + if handle == 0 { + // Mock objects have handle=0 don't try to free them + return + } + + try! rustCall { uniffi_zeron_mobile_fn_free_orbrenderer(handle, $0) } + } + + + + + /** + * Advance to now and return the frame to paint. Time only accumulates + * while `animating`; reduced motion shows the static representative frame. + */ +open func nextFrame(animating: Bool, reducedMotion: Bool) -> OrbFrame { + return try! FfiConverterTypeOrbFrame_lift(try! rustCall() { + uniffiCallStatus in + uniffi_zeron_mobile_fn_method_orbrenderer_next_frame( + self.uniffiCloneHandle(), + FfiConverterBool.lower(animating), + FfiConverterBool.lower(reducedMotion),uniffiCallStatus + ) +}) +} + + /** + * Normalized 0…1 peaks; they speed the motion up, never the geometry. + */ +open func setAudioLevels(microphone: Float, speaker: Float) {try! rustCall() { + uniffiCallStatus in + uniffi_zeron_mobile_fn_method_orbrenderer_set_audio_levels( + self.uniffiCloneHandle(), + FfiConverterFloat.lower(microphone), + FfiConverterFloat.lower(speaker),uniffiCallStatus + ) +} +} + +open func setOrb(orb: VoiceOrb) {try! rustCall() { + uniffiCallStatus in + uniffi_zeron_mobile_fn_method_orbrenderer_set_orb( + self.uniffiCloneHandle(), + FfiConverterTypeVoiceOrb_lower(orb),uniffiCallStatus + ) +} +} + + + +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeOrbRenderer: FfiConverter { + typealias FfiType = UInt64 + typealias SwiftType = OrbRenderer + + public static func lift(_ handle: UInt64) throws -> OrbRenderer { + return OrbRenderer(unsafeFromHandle: handle) + } + + public static func lower(_ value: OrbRenderer) -> UInt64 { + return value.uniffiCloneHandle() + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> OrbRenderer { + let handle: UInt64 = try readInt(&buf) + return try lift(handle) + } + + public static func write(_ value: OrbRenderer, into buf: inout [UInt8]) { + writeInt(&buf, lower(value)) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeOrbRenderer_lift(_ handle: UInt64) throws -> OrbRenderer { + return try FfiConverterTypeOrbRenderer.lift(handle) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeOrbRenderer_lower(_ value: OrbRenderer) -> UInt64 { + return FfiConverterTypeOrbRenderer.lower(value) +} + + + + + + /** * Measures text the bundled faces can't render (emoji, CJK…) with the * platform's own text engine — pretext's "browser as ground truth". @@ -3753,101 +4090,133 @@ public func FfiConverterTypeUploadProgress_lower(_ value: UploadProgress) -> UIn -public struct AppshotLabel: Equatable, Hashable { - public var appName: String - public var windowTitle: String? - // Default memberwise initializers are never public by default, so we - // declare one manually. - public init(appName: String, windowTitle: String?) { - self.appName = appName - self.windowTitle = windowTitle - } +public protocol VoiceCallProtocol: AnyObject, Sendable { - + /** + * Late native callbacks simply miss the retired request. No resume is possible. + */ + func completeMedia(requestId: UInt64, failure: VoiceMediaFailure?, sdp: String?, microphone: UInt16, speaker: UInt16) + + func setMuted(muted: Bool) + + func stop() } +open class VoiceCall: VoiceCallProtocol, @unchecked Sendable { + fileprivate let handle: UInt64 -#if compiler(>=6) -extension AppshotLabel: Sendable {} -#endif - + /// Used to instantiate a [FFIObject] without an actual handle, for fakes in tests, mostly. #if swift(>=5.8) -@_documentation(visibility: private) + @_documentation(visibility: private) #endif -public struct FfiConverterTypeAppshotLabel: FfiConverterRustBuffer { - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> AppshotLabel { - return - try AppshotLabel( - appName: FfiConverterString.read(from: &buf), - windowTitle: FfiConverterOptionString.read(from: &buf) - ) + public struct NoHandle { + public init() {} } - public static func write(_ value: AppshotLabel, into buf: inout [UInt8]) { - FfiConverterString.write(value.appName, into: &buf) - FfiConverterOptionString.write(value.windowTitle, into: &buf) + // TODO: We'd like this to be `private` but for Swifty reasons, + // we can't implement `FfiConverter` without making this `required` and we can't + // make it `required` without making it `public`. +#if swift(>=5.8) + @_documentation(visibility: private) +#endif + required public init(unsafeFromHandle handle: UInt64) { + self.handle = handle } -} - + // This constructor can be used to instantiate a fake object. + // - Parameter noHandle: Placeholder value so we can have a constructor separate from the default empty one that may be implemented for classes extending [FFIObject]. + // + // - Warning: + // Any object instantiated with this constructor cannot be passed to an actual Rust-backed object. Since there isn't a backing handle the FFI lower functions will crash. #if swift(>=5.8) -@_documentation(visibility: private) + @_documentation(visibility: private) #endif -public func FfiConverterTypeAppshotLabel_lift(_ buf: RustBuffer) throws -> AppshotLabel { - return try FfiConverterTypeAppshotLabel.lift(buf) -} + public init(noHandle: NoHandle) { + self.handle = 0 + } #if swift(>=5.8) -@_documentation(visibility: private) + @_documentation(visibility: private) #endif -public func FfiConverterTypeAppshotLabel_lower(_ value: AppshotLabel) -> RustBuffer { - return FfiConverterTypeAppshotLabel.lower(value) -} - + public func uniffiCloneHandle() -> UInt64 { + return try! rustCall { uniffi_zeron_mobile_fn_clone_voicecall(self.handle, $0) } + } + // No primary constructor declared for this class. -public struct AuthExchange: Equatable, Hashable { - public var user: AuthUser - /** - * Unscoped pair: refresh with an organization id before use. - */ - public var tokens: AuthTokens + deinit { + if handle == 0 { + // Mock objects have handle=0 don't try to free them + return + } - // Default memberwise initializers are never public by default, so we - // declare one manually. - public init(user: AuthUser, - /** - * Unscoped pair: refresh with an organization id before use. - */tokens: AuthTokens) { - self.user = user - self.tokens = tokens + try! rustCall { uniffi_zeron_mobile_fn_free_voicecall(handle, $0) } } + /** + * Late native callbacks simply miss the retired request. No resume is possible. + */ +open func completeMedia(requestId: UInt64, failure: VoiceMediaFailure?, sdp: String?, microphone: UInt16, speaker: UInt16) {try! rustCall() { + uniffiCallStatus in + uniffi_zeron_mobile_fn_method_voicecall_complete_media( + self.uniffiCloneHandle(), + FfiConverterUInt64.lower(requestId), + FfiConverterOptionTypeVoiceMediaFailure.lower(failure), + FfiConverterOptionString.lower(sdp), + FfiConverterUInt16.lower(microphone), + FfiConverterUInt16.lower(speaker),uniffiCallStatus + ) +} +} + +open func setMuted(muted: Bool) {try! rustCall() { + uniffiCallStatus in + uniffi_zeron_mobile_fn_method_voicecall_set_muted( + self.uniffiCloneHandle(), + FfiConverterBool.lower(muted),uniffiCallStatus + ) +} +} + +open func stop() {try! rustCall() { + uniffiCallStatus in + uniffi_zeron_mobile_fn_method_voicecall_stop( + self.uniffiCloneHandle(),uniffiCallStatus + ) +} +} + + + } -#if compiler(>=6) -extension AuthExchange: Sendable {} -#endif #if swift(>=5.8) @_documentation(visibility: private) #endif -public struct FfiConverterTypeAuthExchange: FfiConverterRustBuffer { - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> AuthExchange { - return - try AuthExchange( - user: FfiConverterTypeAuthUser.read(from: &buf), - tokens: FfiConverterTypeAuthTokens.read(from: &buf) - ) +public struct FfiConverterTypeVoiceCall: FfiConverter { + typealias FfiType = UInt64 + typealias SwiftType = VoiceCall + + public static func lift(_ handle: UInt64) throws -> VoiceCall { + return VoiceCall(unsafeFromHandle: handle) } - public static func write(_ value: AuthExchange, into buf: inout [UInt8]) { - FfiConverterTypeAuthUser.write(value.user, into: &buf) - FfiConverterTypeAuthTokens.write(value.tokens, into: &buf) + public static func lower(_ value: VoiceCall) -> UInt64 { + return value.uniffiCloneHandle() + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> VoiceCall { + let handle: UInt64 = try readInt(&buf) + return try lift(handle) + } + + public static func write(_ value: VoiceCall, into buf: inout [UInt8]) { + writeInt(&buf, lower(value)) } } @@ -3855,173 +4224,197 @@ public struct FfiConverterTypeAuthExchange: FfiConverterRustBuffer { #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeAuthExchange_lift(_ buf: RustBuffer) throws -> AuthExchange { - return try FfiConverterTypeAuthExchange.lift(buf) +public func FfiConverterTypeVoiceCall_lift(_ handle: UInt64) throws -> VoiceCall { + return try FfiConverterTypeVoiceCall.lift(handle) } #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeAuthExchange_lower(_ value: AuthExchange) -> RustBuffer { - return FfiConverterTypeAuthExchange.lower(value) +public func FfiConverterTypeVoiceCall_lower(_ value: VoiceCall) -> UInt64 { + return FfiConverterTypeVoiceCall.lower(value) } -public struct AuthOrg: Equatable, Hashable { - public var id: String - public var organizationId: String - public var name: String - // Default memberwise initializers are never public by default, so we - // declare one manually. - public init(id: String, organizationId: String, name: String) { - self.id = id - self.organizationId = organizationId - self.name = name - } - + +public protocol VoiceMediaListener: AnyObject, Sendable { + + func onRequest(request: VoiceMediaRequest) } +open class VoiceMediaListenerImpl: VoiceMediaListener, @unchecked Sendable { + fileprivate let handle: UInt64 -#if compiler(>=6) -extension AuthOrg: Sendable {} -#endif - + /// Used to instantiate a [FFIObject] without an actual handle, for fakes in tests, mostly. #if swift(>=5.8) -@_documentation(visibility: private) + @_documentation(visibility: private) #endif -public struct FfiConverterTypeAuthOrg: FfiConverterRustBuffer { - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> AuthOrg { - return - try AuthOrg( - id: FfiConverterString.read(from: &buf), - organizationId: FfiConverterString.read(from: &buf), - name: FfiConverterString.read(from: &buf) - ) + public struct NoHandle { + public init() {} } - public static func write(_ value: AuthOrg, into buf: inout [UInt8]) { - FfiConverterString.write(value.id, into: &buf) - FfiConverterString.write(value.organizationId, into: &buf) - FfiConverterString.write(value.name, into: &buf) + // TODO: We'd like this to be `private` but for Swifty reasons, + // we can't implement `FfiConverter` without making this `required` and we can't + // make it `required` without making it `public`. +#if swift(>=5.8) + @_documentation(visibility: private) +#endif + required public init(unsafeFromHandle handle: UInt64) { + self.handle = handle } -} - + // This constructor can be used to instantiate a fake object. + // - Parameter noHandle: Placeholder value so we can have a constructor separate from the default empty one that may be implemented for classes extending [FFIObject]. + // + // - Warning: + // Any object instantiated with this constructor cannot be passed to an actual Rust-backed object. Since there isn't a backing handle the FFI lower functions will crash. #if swift(>=5.8) -@_documentation(visibility: private) + @_documentation(visibility: private) #endif -public func FfiConverterTypeAuthOrg_lift(_ buf: RustBuffer) throws -> AuthOrg { - return try FfiConverterTypeAuthOrg.lift(buf) -} + public init(noHandle: NoHandle) { + self.handle = 0 + } #if swift(>=5.8) -@_documentation(visibility: private) + @_documentation(visibility: private) #endif -public func FfiConverterTypeAuthOrg_lower(_ value: AuthOrg) -> RustBuffer { - return FfiConverterTypeAuthOrg.lower(value) -} - + public func uniffiCloneHandle() -> UInt64 { + return try! rustCall { uniffi_zeron_mobile_fn_clone_voicemedialistener(self.handle, $0) } + } + // No primary constructor declared for this class. -public struct AuthTokens: Equatable, Hashable { - public var accessToken: String - public var refreshToken: String + deinit { + if handle == 0 { + // Mock objects have handle=0 don't try to free them + return + } - // Default memberwise initializers are never public by default, so we - // declare one manually. - public init(accessToken: String, refreshToken: String) { - self.accessToken = accessToken - self.refreshToken = refreshToken + try! rustCall { uniffi_zeron_mobile_fn_free_voicemedialistener(handle, $0) } } +open func onRequest(request: VoiceMediaRequest) {try! rustCall() { + uniffiCallStatus in + uniffi_zeron_mobile_fn_method_voicemedialistener_on_request( + self.uniffiCloneHandle(), + FfiConverterTypeVoiceMediaRequest_lower(request),uniffiCallStatus + ) } - -#if compiler(>=6) -extension AuthTokens: Sendable {} -#endif - -#if swift(>=5.8) -@_documentation(visibility: private) -#endif -public struct FfiConverterTypeAuthTokens: FfiConverterRustBuffer { - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> AuthTokens { - return - try AuthTokens( - accessToken: FfiConverterString.read(from: &buf), - refreshToken: FfiConverterString.read(from: &buf) - ) - } - - public static func write(_ value: AuthTokens, into buf: inout [UInt8]) { - FfiConverterString.write(value.accessToken, into: &buf) - FfiConverterString.write(value.refreshToken, into: &buf) - } } + - -#if swift(>=5.8) -@_documentation(visibility: private) -#endif -public func FfiConverterTypeAuthTokens_lift(_ buf: RustBuffer) throws -> AuthTokens { - return try FfiConverterTypeAuthTokens.lift(buf) + } -#if swift(>=5.8) -@_documentation(visibility: private) -#endif -public func FfiConverterTypeAuthTokens_lower(_ value: AuthTokens) -> RustBuffer { - return FfiConverterTypeAuthTokens.lower(value) -} -public struct AuthUser: Equatable, Hashable { - public var id: String - public var email: String? - public var firstName: String? - public var lastName: String? +// Put the implementation in a struct so we don't pollute the top-level namespace +fileprivate struct UniffiCallbackInterfaceVoiceMediaListener { - // Default memberwise initializers are never public by default, so we - // declare one manually. - public init(id: String, email: String?, firstName: String?, lastName: String?) { - self.id = id - self.email = email - self.firstName = firstName - self.lastName = lastName - } + // Create the VTable using a series of closures. + // Swift automatically converts these into C callback functions. + // + // Store the vtable directly. + static let vtable: UniffiVTableCallbackInterfaceVoiceMediaListener = UniffiVTableCallbackInterfaceVoiceMediaListener( + uniffiFree: { (uniffiHandle: UInt64) -> () in + do { + try FfiConverterTypeVoiceMediaListener.handleMap.remove(handle: uniffiHandle) + } catch { + print("Uniffi callback interface VoiceMediaListener: handle missing in uniffiFree") + } + }, + uniffiClone: { (uniffiHandle: UInt64) -> UInt64 in + do { + return try FfiConverterTypeVoiceMediaListener.handleMap.clone(handle: uniffiHandle) + } catch { + fatalError("Uniffi callback interface VoiceMediaListener: handle missing in uniffiClone") + } + }, + onRequest: { ( + uniffiHandle: UInt64, + request: RustBuffer, + uniffiOutReturn: UnsafeMutableRawPointer, + uniffiCallStatus: UnsafeMutablePointer + ) in + let makeCall = { + () throws -> () in + guard let uniffiObj = try? FfiConverterTypeVoiceMediaListener.handleMap.get(handle: uniffiHandle) else { + throw UniffiInternalError.unexpectedStaleHandle + } + return uniffiObj.onRequest( + request: try FfiConverterTypeVoiceMediaRequest_lift(request) + ) + } - + + let writeReturn = { () } + uniffiTraitInterfaceCall( + callStatus: uniffiCallStatus, + makeCall: makeCall, + writeReturn: writeReturn + ) + } + ) - + // Rust stores this pointer for future callback invocations, so it must live + // for the process lifetime (not just for the init function call). + // + // `nonisolated(unsafe)` is needed under Swift 6 strict concurrency. + // This is safe because the pointee is initialized once during static init + // and never mutated by either side of the FFI. Its fields are C function pointers. + nonisolated(unsafe) static let vtablePtr: UnsafePointer = { + let ptr = UnsafeMutablePointer.allocate(capacity: 1) + ptr.initialize(to: vtable) + return UnsafePointer(ptr) + }() } -#if compiler(>=6) -extension AuthUser: Sendable {} -#endif +private func uniffiCallbackInitVoiceMediaListener() { + uniffi_zeron_mobile_fn_init_callback_vtable_voicemedialistener(UniffiCallbackInterfaceVoiceMediaListener.vtablePtr) +} #if swift(>=5.8) @_documentation(visibility: private) #endif -public struct FfiConverterTypeAuthUser: FfiConverterRustBuffer { - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> AuthUser { - return - try AuthUser( - id: FfiConverterString.read(from: &buf), - email: FfiConverterOptionString.read(from: &buf), - firstName: FfiConverterOptionString.read(from: &buf), - lastName: FfiConverterOptionString.read(from: &buf) - ) +public struct FfiConverterTypeVoiceMediaListener: FfiConverter { + fileprivate static let handleMap = UniffiHandleMap() + + typealias FfiType = UInt64 + typealias SwiftType = VoiceMediaListener + + public static func lift(_ handle: UInt64) throws -> VoiceMediaListener { + if ((handle & 1) == 0) { + // Rust-generated handle, construct a new class that uses the handle to implement the + // interface + return VoiceMediaListenerImpl(unsafeFromHandle: handle) + } else { + // Swift-generated handle, get the object from the handle map + return try handleMap.remove(handle: handle) + } } - public static func write(_ value: AuthUser, into buf: inout [UInt8]) { - FfiConverterString.write(value.id, into: &buf) - FfiConverterOptionString.write(value.email, into: &buf) - FfiConverterOptionString.write(value.firstName, into: &buf) - FfiConverterOptionString.write(value.lastName, into: &buf) + public static func lower(_ value: VoiceMediaListener) -> UInt64 { + if let rustImpl = value as? VoiceMediaListenerImpl { + // Rust-implemented object. Clone the handle and return it + return rustImpl.uniffiCloneHandle() + } else { + // Swift object, generate a new vtable handle and return that. + return handleMap.insert(obj: value) + } + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> VoiceMediaListener { + let handle: UInt64 = try readInt(&buf) + return try lift(handle) + } + + public static func write(_ value: VoiceMediaListener, into buf: inout [UInt8]) { + writeInt(&buf, lower(value)) } } @@ -4029,164 +4422,238 @@ public struct FfiConverterTypeAuthUser: FfiConverterRustBuffer { #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeAuthUser_lift(_ buf: RustBuffer) throws -> AuthUser { - return try FfiConverterTypeAuthUser.lift(buf) +public func FfiConverterTypeVoiceMediaListener_lift(_ handle: UInt64) throws -> VoiceMediaListener { + return try FfiConverterTypeVoiceMediaListener.lift(handle) } #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeAuthUser_lower(_ value: AuthUser) -> RustBuffer { - return FfiConverterTypeAuthUser.lower(value) +public func FfiConverterTypeVoiceMediaListener_lower(_ value: VoiceMediaListener) -> UInt64 { + return FfiConverterTypeVoiceMediaListener.lower(value) } -public struct BoxPrim: Equatable, Hashable { - public var x: Float - public var y: Float - public var w: Float - public var h: Float - public var radius: Float - public var style: BoxStyle - public var color: ColorRole - public var scroller: UInt32? - // Default memberwise initializers are never public by default, so we - // declare one manually. - public init(x: Float, y: Float, w: Float, h: Float, radius: Float, style: BoxStyle, color: ColorRole, scroller: UInt32?) { - self.x = x - self.y = y - self.w = w - self.h = h - self.radius = radius - self.style = style - self.color = color - self.scroller = scroller - } - + +public protocol VoiceSessionListener: AnyObject, Sendable { + + func onVoiceState(state: VoiceCallState) + + /** + * Terminal, exactly once. `None` is an orderly end. + */ + func onVoiceClosed(reason: VoiceEndReason?) } +open class VoiceSessionListenerImpl: VoiceSessionListener, @unchecked Sendable { + fileprivate let handle: UInt64 -#if compiler(>=6) -extension BoxPrim: Sendable {} -#endif - + /// Used to instantiate a [FFIObject] without an actual handle, for fakes in tests, mostly. #if swift(>=5.8) -@_documentation(visibility: private) + @_documentation(visibility: private) #endif -public struct FfiConverterTypeBoxPrim: FfiConverterRustBuffer { - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> BoxPrim { - return - try BoxPrim( - x: FfiConverterFloat.read(from: &buf), - y: FfiConverterFloat.read(from: &buf), - w: FfiConverterFloat.read(from: &buf), - h: FfiConverterFloat.read(from: &buf), - radius: FfiConverterFloat.read(from: &buf), - style: FfiConverterTypeBoxStyle.read(from: &buf), - color: FfiConverterTypeColorRole.read(from: &buf), - scroller: FfiConverterOptionUInt32.read(from: &buf) - ) + public struct NoHandle { + public init() {} } - public static func write(_ value: BoxPrim, into buf: inout [UInt8]) { - FfiConverterFloat.write(value.x, into: &buf) - FfiConverterFloat.write(value.y, into: &buf) - FfiConverterFloat.write(value.w, into: &buf) - FfiConverterFloat.write(value.h, into: &buf) - FfiConverterFloat.write(value.radius, into: &buf) - FfiConverterTypeBoxStyle.write(value.style, into: &buf) - FfiConverterTypeColorRole.write(value.color, into: &buf) - FfiConverterOptionUInt32.write(value.scroller, into: &buf) + // TODO: We'd like this to be `private` but for Swifty reasons, + // we can't implement `FfiConverter` without making this `required` and we can't + // make it `required` without making it `public`. +#if swift(>=5.8) + @_documentation(visibility: private) +#endif + required public init(unsafeFromHandle handle: UInt64) { + self.handle = handle } -} - + // This constructor can be used to instantiate a fake object. + // - Parameter noHandle: Placeholder value so we can have a constructor separate from the default empty one that may be implemented for classes extending [FFIObject]. + // + // - Warning: + // Any object instantiated with this constructor cannot be passed to an actual Rust-backed object. Since there isn't a backing handle the FFI lower functions will crash. #if swift(>=5.8) -@_documentation(visibility: private) + @_documentation(visibility: private) #endif -public func FfiConverterTypeBoxPrim_lift(_ buf: RustBuffer) throws -> BoxPrim { - return try FfiConverterTypeBoxPrim.lift(buf) -} + public init(noHandle: NoHandle) { + self.handle = 0 + } #if swift(>=5.8) -@_documentation(visibility: private) + @_documentation(visibility: private) #endif -public func FfiConverterTypeBoxPrim_lower(_ value: BoxPrim) -> RustBuffer { - return FfiConverterTypeBoxPrim.lower(value) -} - + public func uniffiCloneHandle() -> UInt64 { + return try! rustCall { uniffi_zeron_mobile_fn_clone_voicesessionlistener(self.handle, $0) } + } + // No primary constructor declared for this class. -/** - * A chat's run configuration. Ids are wire strings. - */ -public struct ChatConfig: Equatable, Hashable { - /** - * `claude-code`, `codex`, … - */ - public var harness: String - public var model: String? - /** - * `low` … `ultrathink`. - */ - public var reasoning: String? - /** - * Model option id → choice id (`contextWindow` → `1m`). - */ - public var modelOptions: [String: String] - public var sandbox: SandboxLevel + deinit { + if handle == 0 { + // Mock objects have handle=0 don't try to free them + return + } - // Default memberwise initializers are never public by default, so we - // declare one manually. - public init( - /** - * `claude-code`, `codex`, … - */harness: String, model: String?, - /** - * `low` … `ultrathink`. - */reasoning: String?, - /** - * Model option id → choice id (`contextWindow` → `1m`). - */modelOptions: [String: String], sandbox: SandboxLevel) { - self.harness = harness - self.model = model - self.reasoning = reasoning - self.modelOptions = modelOptions - self.sandbox = sandbox + try! rustCall { uniffi_zeron_mobile_fn_free_voicesessionlistener(handle, $0) } } +open func onVoiceState(state: VoiceCallState) {try! rustCall() { + uniffiCallStatus in + uniffi_zeron_mobile_fn_method_voicesessionlistener_on_voice_state( + self.uniffiCloneHandle(), + FfiConverterTypeVoiceCallState_lower(state),uniffiCallStatus + ) +} +} + + /** + * Terminal, exactly once. `None` is an orderly end. + */ +open func onVoiceClosed(reason: VoiceEndReason?) {try! rustCall() { + uniffiCallStatus in + uniffi_zeron_mobile_fn_method_voicesessionlistener_on_voice_closed( + self.uniffiCloneHandle(), + FfiConverterOptionTypeVoiceEndReason.lower(reason),uniffiCallStatus + ) } +} + -#if compiler(>=6) -extension ChatConfig: Sendable {} -#endif + +} -#if swift(>=5.8) -@_documentation(visibility: private) -#endif -public struct FfiConverterTypeChatConfig: FfiConverterRustBuffer { - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> ChatConfig { - return - try ChatConfig( - harness: FfiConverterString.read(from: &buf), - model: FfiConverterOptionString.read(from: &buf), - reasoning: FfiConverterOptionString.read(from: &buf), - modelOptions: FfiConverterDictionaryStringString.read(from: &buf), - sandbox: FfiConverterTypeSandboxLevel.read(from: &buf) - ) + + +// Put the implementation in a struct so we don't pollute the top-level namespace +fileprivate struct UniffiCallbackInterfaceVoiceSessionListener { + + // Create the VTable using a series of closures. + // Swift automatically converts these into C callback functions. + // + // Store the vtable directly. + static let vtable: UniffiVTableCallbackInterfaceVoiceSessionListener = UniffiVTableCallbackInterfaceVoiceSessionListener( + uniffiFree: { (uniffiHandle: UInt64) -> () in + do { + try FfiConverterTypeVoiceSessionListener.handleMap.remove(handle: uniffiHandle) + } catch { + print("Uniffi callback interface VoiceSessionListener: handle missing in uniffiFree") + } + }, + uniffiClone: { (uniffiHandle: UInt64) -> UInt64 in + do { + return try FfiConverterTypeVoiceSessionListener.handleMap.clone(handle: uniffiHandle) + } catch { + fatalError("Uniffi callback interface VoiceSessionListener: handle missing in uniffiClone") + } + }, + onVoiceState: { ( + uniffiHandle: UInt64, + state: RustBuffer, + uniffiOutReturn: UnsafeMutableRawPointer, + uniffiCallStatus: UnsafeMutablePointer + ) in + let makeCall = { + () throws -> () in + guard let uniffiObj = try? FfiConverterTypeVoiceSessionListener.handleMap.get(handle: uniffiHandle) else { + throw UniffiInternalError.unexpectedStaleHandle + } + return uniffiObj.onVoiceState( + state: try FfiConverterTypeVoiceCallState_lift(state) + ) + } + + + let writeReturn = { () } + uniffiTraitInterfaceCall( + callStatus: uniffiCallStatus, + makeCall: makeCall, + writeReturn: writeReturn + ) + }, + onVoiceClosed: { ( + uniffiHandle: UInt64, + reason: RustBuffer, + uniffiOutReturn: UnsafeMutableRawPointer, + uniffiCallStatus: UnsafeMutablePointer + ) in + let makeCall = { + () throws -> () in + guard let uniffiObj = try? FfiConverterTypeVoiceSessionListener.handleMap.get(handle: uniffiHandle) else { + throw UniffiInternalError.unexpectedStaleHandle + } + return uniffiObj.onVoiceClosed( + reason: try FfiConverterOptionTypeVoiceEndReason.lift(reason) + ) + } + + + let writeReturn = { () } + uniffiTraitInterfaceCall( + callStatus: uniffiCallStatus, + makeCall: makeCall, + writeReturn: writeReturn + ) + } + ) + + // Rust stores this pointer for future callback invocations, so it must live + // for the process lifetime (not just for the init function call). + // + // `nonisolated(unsafe)` is needed under Swift 6 strict concurrency. + // This is safe because the pointee is initialized once during static init + // and never mutated by either side of the FFI. Its fields are C function pointers. + nonisolated(unsafe) static let vtablePtr: UnsafePointer = { + let ptr = UnsafeMutablePointer.allocate(capacity: 1) + ptr.initialize(to: vtable) + return UnsafePointer(ptr) + }() +} + +private func uniffiCallbackInitVoiceSessionListener() { + uniffi_zeron_mobile_fn_init_callback_vtable_voicesessionlistener(UniffiCallbackInterfaceVoiceSessionListener.vtablePtr) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeVoiceSessionListener: FfiConverter { + fileprivate static let handleMap = UniffiHandleMap() + + typealias FfiType = UInt64 + typealias SwiftType = VoiceSessionListener + + public static func lift(_ handle: UInt64) throws -> VoiceSessionListener { + if ((handle & 1) == 0) { + // Rust-generated handle, construct a new class that uses the handle to implement the + // interface + return VoiceSessionListenerImpl(unsafeFromHandle: handle) + } else { + // Swift-generated handle, get the object from the handle map + return try handleMap.remove(handle: handle) + } } - public static func write(_ value: ChatConfig, into buf: inout [UInt8]) { - FfiConverterString.write(value.harness, into: &buf) - FfiConverterOptionString.write(value.model, into: &buf) - FfiConverterOptionString.write(value.reasoning, into: &buf) - FfiConverterDictionaryStringString.write(value.modelOptions, into: &buf) - FfiConverterTypeSandboxLevel.write(value.sandbox, into: &buf) + public static func lower(_ value: VoiceSessionListener) -> UInt64 { + if let rustImpl = value as? VoiceSessionListenerImpl { + // Rust-implemented object. Clone the handle and return it + return rustImpl.uniffiCloneHandle() + } else { + // Swift object, generate a new vtable handle and return that. + return handleMap.insert(obj: value) + } + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> VoiceSessionListener { + let handle: UInt64 = try readInt(&buf) + return try lift(handle) + } + + public static func write(_ value: VoiceSessionListener, into buf: inout [UInt8]) { + writeInt(&buf, lower(value)) } } @@ -4194,104 +4661,29 @@ public struct FfiConverterTypeChatConfig: FfiConverterRustBuffer { #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeChatConfig_lift(_ buf: RustBuffer) throws -> ChatConfig { - return try FfiConverterTypeChatConfig.lift(buf) +public func FfiConverterTypeVoiceSessionListener_lift(_ handle: UInt64) throws -> VoiceSessionListener { + return try FfiConverterTypeVoiceSessionListener.lift(handle) } #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeChatConfig_lower(_ value: ChatConfig) -> RustBuffer { - return FfiConverterTypeChatConfig.lower(value) +public func FfiConverterTypeVoiceSessionListener_lower(_ value: VoiceSessionListener) -> UInt64 { + return FfiConverterTypeVoiceSessionListener.lower(value) } -/** - * Everything the composer and its status strip render. - */ -public struct ComposerState: Equatable, Hashable { - public var chatId: String - public var revision: UInt64 - public var title: String - public var host: HostInfo - public var live: LiveStatus - /** - * The shared queue, delivery order. - */ - public var queue: [QueueItem] - /** - * This device's unadopted sends, oldest first. - */ - public var pendingSends: [PendingSend] - /** - * Headline state of the oldest pending send. - */ - public var sendState: SendState? - /** - * A send now would queue rather than deliver promptly. - */ - public var deliveryDegraded: Bool - public var room: RoomState - /** - * Attachment escort progress (0..1). - */ - public var transferProgress: Double? - /** - * Answer this instead of typing. - */ - public var openInput: InputRequest? - /** - * Last queue action failure. - */ - public var queueError: String? - /** - * Scroll-to-own-send target. - */ - public var lastSubmittedMessageId: String? - public var contextUsage: ContextUsage? + + +public struct AppshotLabel: Equatable, Hashable { + public var appName: String + public var windowTitle: String? // Default memberwise initializers are never public by default, so we // declare one manually. - public init(chatId: String, revision: UInt64, title: String, host: HostInfo, live: LiveStatus, - /** - * The shared queue, delivery order. - */queue: [QueueItem], - /** - * This device's unadopted sends, oldest first. - */pendingSends: [PendingSend], - /** - * Headline state of the oldest pending send. - */sendState: SendState?, - /** - * A send now would queue rather than deliver promptly. - */deliveryDegraded: Bool, room: RoomState, - /** - * Attachment escort progress (0..1). - */transferProgress: Double?, - /** - * Answer this instead of typing. - */openInput: InputRequest?, - /** - * Last queue action failure. - */queueError: String?, - /** - * Scroll-to-own-send target. - */lastSubmittedMessageId: String?, contextUsage: ContextUsage?) { - self.chatId = chatId - self.revision = revision - self.title = title - self.host = host - self.live = live - self.queue = queue - self.pendingSends = pendingSends - self.sendState = sendState - self.deliveryDegraded = deliveryDegraded - self.room = room - self.transferProgress = transferProgress - self.openInput = openInput - self.queueError = queueError - self.lastSubmittedMessageId = lastSubmittedMessageId - self.contextUsage = contextUsage + public init(appName: String, windowTitle: String?) { + self.appName = appName + self.windowTitle = windowTitle } @@ -4300,50 +4692,24 @@ public struct ComposerState: Equatable, Hashable { } #if compiler(>=6) -extension ComposerState: Sendable {} +extension AppshotLabel: Sendable {} #endif #if swift(>=5.8) @_documentation(visibility: private) #endif -public struct FfiConverterTypeComposerState: FfiConverterRustBuffer { - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> ComposerState { +public struct FfiConverterTypeAppshotLabel: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> AppshotLabel { return - try ComposerState( - chatId: FfiConverterString.read(from: &buf), - revision: FfiConverterUInt64.read(from: &buf), - title: FfiConverterString.read(from: &buf), - host: FfiConverterTypeHostInfo.read(from: &buf), - live: FfiConverterTypeLiveStatus.read(from: &buf), - queue: FfiConverterSequenceTypeQueueItem.read(from: &buf), - pendingSends: FfiConverterSequenceTypePendingSend.read(from: &buf), - sendState: FfiConverterOptionTypeSendState.read(from: &buf), - deliveryDegraded: FfiConverterBool.read(from: &buf), - room: FfiConverterTypeRoomState.read(from: &buf), - transferProgress: FfiConverterOptionDouble.read(from: &buf), - openInput: FfiConverterOptionTypeInputRequest.read(from: &buf), - queueError: FfiConverterOptionString.read(from: &buf), - lastSubmittedMessageId: FfiConverterOptionString.read(from: &buf), - contextUsage: FfiConverterOptionTypeContextUsage.read(from: &buf) + try AppshotLabel( + appName: FfiConverterString.read(from: &buf), + windowTitle: FfiConverterOptionString.read(from: &buf) ) } - public static func write(_ value: ComposerState, into buf: inout [UInt8]) { - FfiConverterString.write(value.chatId, into: &buf) - FfiConverterUInt64.write(value.revision, into: &buf) - FfiConverterString.write(value.title, into: &buf) - FfiConverterTypeHostInfo.write(value.host, into: &buf) - FfiConverterTypeLiveStatus.write(value.live, into: &buf) - FfiConverterSequenceTypeQueueItem.write(value.queue, into: &buf) - FfiConverterSequenceTypePendingSend.write(value.pendingSends, into: &buf) - FfiConverterOptionTypeSendState.write(value.sendState, into: &buf) - FfiConverterBool.write(value.deliveryDegraded, into: &buf) - FfiConverterTypeRoomState.write(value.room, into: &buf) - FfiConverterOptionDouble.write(value.transferProgress, into: &buf) - FfiConverterOptionTypeInputRequest.write(value.openInput, into: &buf) - FfiConverterOptionString.write(value.queueError, into: &buf) - FfiConverterOptionString.write(value.lastSubmittedMessageId, into: &buf) - FfiConverterOptionTypeContextUsage.write(value.contextUsage, into: &buf) + public static func write(_ value: AppshotLabel, into buf: inout [UInt8]) { + FfiConverterString.write(value.appName, into: &buf) + FfiConverterOptionString.write(value.windowTitle, into: &buf) } } @@ -4351,49 +4717,33 @@ public struct FfiConverterTypeComposerState: FfiConverterRustBuffer { #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeComposerState_lift(_ buf: RustBuffer) throws -> ComposerState { - return try FfiConverterTypeComposerState.lift(buf) +public func FfiConverterTypeAppshotLabel_lift(_ buf: RustBuffer) throws -> AppshotLabel { + return try FfiConverterTypeAppshotLabel.lift(buf) } #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeComposerState_lower(_ value: ComposerState) -> RustBuffer { - return FfiConverterTypeComposerState.lower(value) +public func FfiConverterTypeAppshotLabel_lower(_ value: AppshotLabel) -> RustBuffer { + return FfiConverterTypeAppshotLabel.lower(value) } -public struct Connectivity: Equatable, Hashable { - public var state: ConnectivityState +public struct AuthExchange: Equatable, Hashable { + public var user: AuthUser /** - * Epoch ms of the next scheduled redial (countdown), if any. - */ - public var retryAtMs: Int64? - /** - * The failure that started the current outage. - */ - public var lastFailure: String? - /** - * Open chats whose room is graced-degraded. + * Unscoped pair: refresh with an organization id before use. */ - public var degradedChats: [String] + public var tokens: AuthTokens // Default memberwise initializers are never public by default, so we // declare one manually. - public init(state: ConnectivityState, - /** - * Epoch ms of the next scheduled redial (countdown), if any. - */retryAtMs: Int64?, - /** - * The failure that started the current outage. - */lastFailure: String?, + public init(user: AuthUser, /** - * Open chats whose room is graced-degraded. - */degradedChats: [String]) { - self.state = state - self.retryAtMs = retryAtMs - self.lastFailure = lastFailure - self.degradedChats = degradedChats + * Unscoped pair: refresh with an organization id before use. + */tokens: AuthTokens) { + self.user = user + self.tokens = tokens } @@ -4402,28 +4752,24 @@ public struct Connectivity: Equatable, Hashable { } #if compiler(>=6) -extension Connectivity: Sendable {} +extension AuthExchange: Sendable {} #endif #if swift(>=5.8) @_documentation(visibility: private) #endif -public struct FfiConverterTypeConnectivity: FfiConverterRustBuffer { - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> Connectivity { +public struct FfiConverterTypeAuthExchange: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> AuthExchange { return - try Connectivity( - state: FfiConverterTypeConnectivityState.read(from: &buf), - retryAtMs: FfiConverterOptionInt64.read(from: &buf), - lastFailure: FfiConverterOptionString.read(from: &buf), - degradedChats: FfiConverterSequenceString.read(from: &buf) + try AuthExchange( + user: FfiConverterTypeAuthUser.read(from: &buf), + tokens: FfiConverterTypeAuthTokens.read(from: &buf) ) } - public static func write(_ value: Connectivity, into buf: inout [UInt8]) { - FfiConverterTypeConnectivityState.write(value.state, into: &buf) - FfiConverterOptionInt64.write(value.retryAtMs, into: &buf) - FfiConverterOptionString.write(value.lastFailure, into: &buf) - FfiConverterSequenceString.write(value.degradedChats, into: &buf) + public static func write(_ value: AuthExchange, into buf: inout [UInt8]) { + FfiConverterTypeAuthUser.write(value.user, into: &buf) + FfiConverterTypeAuthTokens.write(value.tokens, into: &buf) } } @@ -4431,27 +4777,29 @@ public struct FfiConverterTypeConnectivity: FfiConverterRustBuffer { #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeConnectivity_lift(_ buf: RustBuffer) throws -> Connectivity { - return try FfiConverterTypeConnectivity.lift(buf) +public func FfiConverterTypeAuthExchange_lift(_ buf: RustBuffer) throws -> AuthExchange { + return try FfiConverterTypeAuthExchange.lift(buf) } #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeConnectivity_lower(_ value: Connectivity) -> RustBuffer { - return FfiConverterTypeConnectivity.lower(value) +public func FfiConverterTypeAuthExchange_lower(_ value: AuthExchange) -> RustBuffer { + return FfiConverterTypeAuthExchange.lower(value) } -public struct ContextUsage: Equatable, Hashable { - public var tokens: UInt64? - public var window: UInt64? +public struct AuthOrg: Equatable, Hashable { + public var id: String + public var organizationId: String + public var name: String // Default memberwise initializers are never public by default, so we // declare one manually. - public init(tokens: UInt64?, window: UInt64?) { - self.tokens = tokens - self.window = window + public init(id: String, organizationId: String, name: String) { + self.id = id + self.organizationId = organizationId + self.name = name } @@ -4460,24 +4808,26 @@ public struct ContextUsage: Equatable, Hashable { } #if compiler(>=6) -extension ContextUsage: Sendable {} +extension AuthOrg: Sendable {} #endif #if swift(>=5.8) @_documentation(visibility: private) #endif -public struct FfiConverterTypeContextUsage: FfiConverterRustBuffer { - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> ContextUsage { +public struct FfiConverterTypeAuthOrg: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> AuthOrg { return - try ContextUsage( - tokens: FfiConverterOptionUInt64.read(from: &buf), - window: FfiConverterOptionUInt64.read(from: &buf) + try AuthOrg( + id: FfiConverterString.read(from: &buf), + organizationId: FfiConverterString.read(from: &buf), + name: FfiConverterString.read(from: &buf) ) } - public static func write(_ value: ContextUsage, into buf: inout [UInt8]) { - FfiConverterOptionUInt64.write(value.tokens, into: &buf) - FfiConverterOptionUInt64.write(value.window, into: &buf) + public static func write(_ value: AuthOrg, into buf: inout [UInt8]) { + FfiConverterString.write(value.id, into: &buf) + FfiConverterString.write(value.organizationId, into: &buf) + FfiConverterString.write(value.name, into: &buf) } } @@ -4485,70 +4835,27 @@ public struct FfiConverterTypeContextUsage: FfiConverterRustBuffer { #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeContextUsage_lift(_ buf: RustBuffer) throws -> ContextUsage { - return try FfiConverterTypeContextUsage.lift(buf) +public func FfiConverterTypeAuthOrg_lift(_ buf: RustBuffer) throws -> AuthOrg { + return try FfiConverterTypeAuthOrg.lift(buf) } #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeContextUsage_lower(_ value: ContextUsage) -> RustBuffer { - return FfiConverterTypeContextUsage.lower(value) +public func FfiConverterTypeAuthOrg_lower(_ value: AuthOrg) -> RustBuffer { + return FfiConverterTypeAuthOrg.lower(value) } -/** - * Static per-install configuration. - */ -public struct CoreConfig: Equatable, Hashable { - /** - * Edge base URL (`auth_production_edge_url()` for prod). - */ - public var edgeUrl: String - /** - * Writable directory for registry/doc snapshots and caches, scoped by - * the platform to the signed-in identity (sign-out wipes it). - */ - public var dataDir: String - /** - * Stable device id (`ios-xxxxxxxx`), stamped on every command/queue row. - */ - public var deviceId: String - /** - * Human name for presence/attribution ("Wing's iPhone"). - */ - public var deviceName: String - /** - * `ios` / `android`. - */ - public var platform: String - public var appVersion: String +public struct AuthTokens: Equatable, Hashable { + public var accessToken: String + public var refreshToken: String // Default memberwise initializers are never public by default, so we // declare one manually. - public init( - /** - * Edge base URL (`auth_production_edge_url()` for prod). - */edgeUrl: String, - /** - * Writable directory for registry/doc snapshots and caches, scoped by - * the platform to the signed-in identity (sign-out wipes it). - */dataDir: String, - /** - * Stable device id (`ios-xxxxxxxx`), stamped on every command/queue row. - */deviceId: String, - /** - * Human name for presence/attribution ("Wing's iPhone"). - */deviceName: String, - /** - * `ios` / `android`. - */platform: String, appVersion: String) { - self.edgeUrl = edgeUrl - self.dataDir = dataDir - self.deviceId = deviceId - self.deviceName = deviceName - self.platform = platform - self.appVersion = appVersion + public init(accessToken: String, refreshToken: String) { + self.accessToken = accessToken + self.refreshToken = refreshToken } @@ -4557,32 +4864,24 @@ public struct CoreConfig: Equatable, Hashable { } #if compiler(>=6) -extension CoreConfig: Sendable {} +extension AuthTokens: Sendable {} #endif #if swift(>=5.8) @_documentation(visibility: private) #endif -public struct FfiConverterTypeCoreConfig: FfiConverterRustBuffer { - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> CoreConfig { +public struct FfiConverterTypeAuthTokens: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> AuthTokens { return - try CoreConfig( - edgeUrl: FfiConverterString.read(from: &buf), - dataDir: FfiConverterString.read(from: &buf), - deviceId: FfiConverterString.read(from: &buf), - deviceName: FfiConverterString.read(from: &buf), - platform: FfiConverterString.read(from: &buf), - appVersion: FfiConverterString.read(from: &buf) + try AuthTokens( + accessToken: FfiConverterString.read(from: &buf), + refreshToken: FfiConverterString.read(from: &buf) ) } - public static func write(_ value: CoreConfig, into buf: inout [UInt8]) { - FfiConverterString.write(value.edgeUrl, into: &buf) - FfiConverterString.write(value.dataDir, into: &buf) - FfiConverterString.write(value.deviceId, into: &buf) - FfiConverterString.write(value.deviceName, into: &buf) - FfiConverterString.write(value.platform, into: &buf) - FfiConverterString.write(value.appVersion, into: &buf) + public static func write(_ value: AuthTokens, into buf: inout [UInt8]) { + FfiConverterString.write(value.accessToken, into: &buf) + FfiConverterString.write(value.refreshToken, into: &buf) } } @@ -4590,34 +4889,31 @@ public struct FfiConverterTypeCoreConfig: FfiConverterRustBuffer { #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeCoreConfig_lift(_ buf: RustBuffer) throws -> CoreConfig { - return try FfiConverterTypeCoreConfig.lift(buf) +public func FfiConverterTypeAuthTokens_lift(_ buf: RustBuffer) throws -> AuthTokens { + return try FfiConverterTypeAuthTokens.lift(buf) } #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeCoreConfig_lower(_ value: CoreConfig) -> RustBuffer { - return FfiConverterTypeCoreConfig.lower(value) +public func FfiConverterTypeAuthTokens_lower(_ value: AuthTokens) -> RustBuffer { + return FfiConverterTypeAuthTokens.lower(value) } -/** - * A debug/demo transcript entry (markdown text), for fixtures and benches. - */ -public struct DebugEntry: Equatable, Hashable { +public struct AuthUser: Equatable, Hashable { public var id: String - public var user: Bool - public var text: String - public var streaming: Bool + public var email: String? + public var firstName: String? + public var lastName: String? // Default memberwise initializers are never public by default, so we // declare one manually. - public init(id: String, user: Bool, text: String, streaming: Bool) { + public init(id: String, email: String?, firstName: String?, lastName: String?) { self.id = id - self.user = user - self.text = text - self.streaming = streaming + self.email = email + self.firstName = firstName + self.lastName = lastName } @@ -4626,28 +4922,28 @@ public struct DebugEntry: Equatable, Hashable { } #if compiler(>=6) -extension DebugEntry: Sendable {} +extension AuthUser: Sendable {} #endif #if swift(>=5.8) @_documentation(visibility: private) #endif -public struct FfiConverterTypeDebugEntry: FfiConverterRustBuffer { - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> DebugEntry { +public struct FfiConverterTypeAuthUser: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> AuthUser { return - try DebugEntry( + try AuthUser( id: FfiConverterString.read(from: &buf), - user: FfiConverterBool.read(from: &buf), - text: FfiConverterString.read(from: &buf), - streaming: FfiConverterBool.read(from: &buf) + email: FfiConverterOptionString.read(from: &buf), + firstName: FfiConverterOptionString.read(from: &buf), + lastName: FfiConverterOptionString.read(from: &buf) ) } - public static func write(_ value: DebugEntry, into buf: inout [UInt8]) { + public static func write(_ value: AuthUser, into buf: inout [UInt8]) { FfiConverterString.write(value.id, into: &buf) - FfiConverterBool.write(value.user, into: &buf) - FfiConverterString.write(value.text, into: &buf) - FfiConverterBool.write(value.streaming, into: &buf) + FfiConverterOptionString.write(value.email, into: &buf) + FfiConverterOptionString.write(value.firstName, into: &buf) + FfiConverterOptionString.write(value.lastName, into: &buf) } } @@ -4655,43 +4951,39 @@ public struct FfiConverterTypeDebugEntry: FfiConverterRustBuffer { #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeDebugEntry_lift(_ buf: RustBuffer) throws -> DebugEntry { - return try FfiConverterTypeDebugEntry.lift(buf) +public func FfiConverterTypeAuthUser_lift(_ buf: RustBuffer) throws -> AuthUser { + return try FfiConverterTypeAuthUser.lift(buf) } #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeDebugEntry_lower(_ value: DebugEntry) -> RustBuffer { - return FfiConverterTypeDebugEntry.lower(value) +public func FfiConverterTypeAuthUser_lower(_ value: AuthUser) -> RustBuffer { + return FfiConverterTypeAuthUser.lower(value) } -public struct DemoOptions: Equatable, Hashable { - public var fixture: DemoFixture - /** - * Size of the flagship chat's (`chat-veil`) transcript. - */ - public var transcriptScale: TranscriptScale - public var streamSpeed: StreamSpeed - /** - * Repeat the scripted reply 12× (long streaming stress). - */ - public var longReply: Bool - - // Default memberwise initializers are never public by default, so we - // declare one manually. - public init(fixture: DemoFixture, - /** - * Size of the flagship chat's (`chat-veil`) transcript. - */transcriptScale: TranscriptScale, streamSpeed: StreamSpeed, - /** - * Repeat the scripted reply 12× (long streaming stress). - */longReply: Bool) { - self.fixture = fixture - self.transcriptScale = transcriptScale - self.streamSpeed = streamSpeed - self.longReply = longReply +public struct BoxPrim: Equatable, Hashable { + public var x: Float + public var y: Float + public var w: Float + public var h: Float + public var radius: Float + public var style: BoxStyle + public var color: ColorRole + public var scroller: UInt32? + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(x: Float, y: Float, w: Float, h: Float, radius: Float, style: BoxStyle, color: ColorRole, scroller: UInt32?) { + self.x = x + self.y = y + self.w = w + self.h = h + self.radius = radius + self.style = style + self.color = color + self.scroller = scroller } @@ -4700,28 +4992,36 @@ public struct DemoOptions: Equatable, Hashable { } #if compiler(>=6) -extension DemoOptions: Sendable {} +extension BoxPrim: Sendable {} #endif #if swift(>=5.8) @_documentation(visibility: private) #endif -public struct FfiConverterTypeDemoOptions: FfiConverterRustBuffer { - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> DemoOptions { +public struct FfiConverterTypeBoxPrim: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> BoxPrim { return - try DemoOptions( - fixture: FfiConverterTypeDemoFixture.read(from: &buf), - transcriptScale: FfiConverterTypeTranscriptScale.read(from: &buf), - streamSpeed: FfiConverterTypeStreamSpeed.read(from: &buf), - longReply: FfiConverterBool.read(from: &buf) + try BoxPrim( + x: FfiConverterFloat.read(from: &buf), + y: FfiConverterFloat.read(from: &buf), + w: FfiConverterFloat.read(from: &buf), + h: FfiConverterFloat.read(from: &buf), + radius: FfiConverterFloat.read(from: &buf), + style: FfiConverterTypeBoxStyle.read(from: &buf), + color: FfiConverterTypeColorRole.read(from: &buf), + scroller: FfiConverterOptionUInt32.read(from: &buf) ) } - public static func write(_ value: DemoOptions, into buf: inout [UInt8]) { - FfiConverterTypeDemoFixture.write(value.fixture, into: &buf) - FfiConverterTypeTranscriptScale.write(value.transcriptScale, into: &buf) - FfiConverterTypeStreamSpeed.write(value.streamSpeed, into: &buf) - FfiConverterBool.write(value.longReply, into: &buf) + public static func write(_ value: BoxPrim, into buf: inout [UInt8]) { + FfiConverterFloat.write(value.x, into: &buf) + FfiConverterFloat.write(value.y, into: &buf) + FfiConverterFloat.write(value.w, into: &buf) + FfiConverterFloat.write(value.h, into: &buf) + FfiConverterFloat.write(value.radius, into: &buf) + FfiConverterTypeBoxStyle.write(value.style, into: &buf) + FfiConverterTypeColorRole.write(value.color, into: &buf) + FfiConverterOptionUInt32.write(value.scroller, into: &buf) } } @@ -4729,49 +5029,51 @@ public struct FfiConverterTypeDemoOptions: FfiConverterRustBuffer { #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeDemoOptions_lift(_ buf: RustBuffer) throws -> DemoOptions { - return try FfiConverterTypeDemoOptions.lift(buf) +public func FfiConverterTypeBoxPrim_lift(_ buf: RustBuffer) throws -> BoxPrim { + return try FfiConverterTypeBoxPrim.lift(buf) } #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeDemoOptions_lower(_ value: DemoOptions) -> RustBuffer { - return FfiConverterTypeDemoOptions.lower(value) +public func FfiConverterTypeBoxPrim_lower(_ value: BoxPrim) -> RustBuffer { + return FfiConverterTypeBoxPrim.lower(value) } -public struct DeviceView: Equatable, Hashable { - public var id: String - public var name: String - public var platform: String - public var online: Bool - public var lastSeenMs: Int64? - public var version: String? - public var capabilities: [String] +public struct CaptionFrame: Equatable, Hashable { /** - * Can run sessions (desktop/server engines — not phones). + * The tail of the utterance to show (ellipsized in front when long). */ - public var isExecutionHost: Bool - public var isSelf: Bool - public var sessionCount: UInt32 + public var text: String + public var spans: [CaptionSpan] + /** + * The previous utterance while it fades out, and its opacity. + */ + public var previous: String? + public var previousAlpha: Float + /** + * Something is still fading: ask for the next frame. + */ + public var animating: Bool // Default memberwise initializers are never public by default, so we // declare one manually. - public init(id: String, name: String, platform: String, online: Bool, lastSeenMs: Int64?, version: String?, capabilities: [String], + public init( /** - * Can run sessions (desktop/server engines — not phones). - */isExecutionHost: Bool, isSelf: Bool, sessionCount: UInt32) { - self.id = id - self.name = name - self.platform = platform - self.online = online - self.lastSeenMs = lastSeenMs - self.version = version - self.capabilities = capabilities - self.isExecutionHost = isExecutionHost - self.isSelf = isSelf - self.sessionCount = sessionCount + * The tail of the utterance to show (ellipsized in front when long). + */text: String, spans: [CaptionSpan], + /** + * The previous utterance while it fades out, and its opacity. + */previous: String?, previousAlpha: Float, + /** + * Something is still fading: ask for the next frame. + */animating: Bool) { + self.text = text + self.spans = spans + self.previous = previous + self.previousAlpha = previousAlpha + self.animating = animating } @@ -4780,40 +5082,30 @@ public struct DeviceView: Equatable, Hashable { } #if compiler(>=6) -extension DeviceView: Sendable {} +extension CaptionFrame: Sendable {} #endif #if swift(>=5.8) @_documentation(visibility: private) #endif -public struct FfiConverterTypeDeviceView: FfiConverterRustBuffer { - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> DeviceView { +public struct FfiConverterTypeCaptionFrame: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> CaptionFrame { return - try DeviceView( - id: FfiConverterString.read(from: &buf), - name: FfiConverterString.read(from: &buf), - platform: FfiConverterString.read(from: &buf), - online: FfiConverterBool.read(from: &buf), - lastSeenMs: FfiConverterOptionInt64.read(from: &buf), - version: FfiConverterOptionString.read(from: &buf), - capabilities: FfiConverterSequenceString.read(from: &buf), - isExecutionHost: FfiConverterBool.read(from: &buf), - isSelf: FfiConverterBool.read(from: &buf), - sessionCount: FfiConverterUInt32.read(from: &buf) + try CaptionFrame( + text: FfiConverterString.read(from: &buf), + spans: FfiConverterSequenceTypeCaptionSpan.read(from: &buf), + previous: FfiConverterOptionString.read(from: &buf), + previousAlpha: FfiConverterFloat.read(from: &buf), + animating: FfiConverterBool.read(from: &buf) ) } - public static func write(_ value: DeviceView, into buf: inout [UInt8]) { - FfiConverterString.write(value.id, into: &buf) - FfiConverterString.write(value.name, into: &buf) - FfiConverterString.write(value.platform, into: &buf) - FfiConverterBool.write(value.online, into: &buf) - FfiConverterOptionInt64.write(value.lastSeenMs, into: &buf) - FfiConverterOptionString.write(value.version, into: &buf) - FfiConverterSequenceString.write(value.capabilities, into: &buf) - FfiConverterBool.write(value.isExecutionHost, into: &buf) - FfiConverterBool.write(value.isSelf, into: &buf) - FfiConverterUInt32.write(value.sessionCount, into: &buf) + public static func write(_ value: CaptionFrame, into buf: inout [UInt8]) { + FfiConverterString.write(value.text, into: &buf) + FfiConverterSequenceTypeCaptionSpan.write(value.spans, into: &buf) + FfiConverterOptionString.write(value.previous, into: &buf) + FfiConverterFloat.write(value.previousAlpha, into: &buf) + FfiConverterBool.write(value.animating, into: &buf) } } @@ -4821,27 +5113,32 @@ public struct FfiConverterTypeDeviceView: FfiConverterRustBuffer { #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeDeviceView_lift(_ buf: RustBuffer) throws -> DeviceView { - return try FfiConverterTypeDeviceView.lift(buf) +public func FfiConverterTypeCaptionFrame_lift(_ buf: RustBuffer) throws -> CaptionFrame { + return try FfiConverterTypeCaptionFrame.lift(buf) } #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeDeviceView_lower(_ value: DeviceView) -> RustBuffer { - return FfiConverterTypeDeviceView.lower(value) +public func FfiConverterTypeCaptionFrame_lower(_ value: CaptionFrame) -> RustBuffer { + return FfiConverterTypeCaptionFrame.lower(value) } -public struct FaceData: Equatable, Hashable { - public var role: FaceRole - public var bytes: Data +/** + * A veiled UTF-16 range of the caption and its opacity (0..1). + */ +public struct CaptionSpan: Equatable, Hashable { + public var start: UInt32 + public var end: UInt32 + public var alpha: Float // Default memberwise initializers are never public by default, so we // declare one manually. - public init(role: FaceRole, bytes: Data) { - self.role = role - self.bytes = bytes + public init(start: UInt32, end: UInt32, alpha: Float) { + self.start = start + self.end = end + self.alpha = alpha } @@ -4850,24 +5147,26 @@ public struct FaceData: Equatable, Hashable { } #if compiler(>=6) -extension FaceData: Sendable {} +extension CaptionSpan: Sendable {} #endif #if swift(>=5.8) @_documentation(visibility: private) #endif -public struct FfiConverterTypeFaceData: FfiConverterRustBuffer { - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> FaceData { +public struct FfiConverterTypeCaptionSpan: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> CaptionSpan { return - try FaceData( - role: FfiConverterTypeFaceRole.read(from: &buf), - bytes: FfiConverterData.read(from: &buf) + try CaptionSpan( + start: FfiConverterUInt32.read(from: &buf), + end: FfiConverterUInt32.read(from: &buf), + alpha: FfiConverterFloat.read(from: &buf) ) } - public static func write(_ value: FaceData, into buf: inout [UInt8]) { - FfiConverterTypeFaceRole.write(value.role, into: &buf) - FfiConverterData.write(value.bytes, into: &buf) + public static func write(_ value: CaptionSpan, into buf: inout [UInt8]) { + FfiConverterUInt32.write(value.start, into: &buf) + FfiConverterUInt32.write(value.end, into: &buf) + FfiConverterFloat.write(value.alpha, into: &buf) } } @@ -4875,40 +5174,54 @@ public struct FfiConverterTypeFaceData: FfiConverterRustBuffer { #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeFaceData_lift(_ buf: RustBuffer) throws -> FaceData { - return try FfiConverterTypeFaceData.lift(buf) +public func FfiConverterTypeCaptionSpan_lift(_ buf: RustBuffer) throws -> CaptionSpan { + return try FfiConverterTypeCaptionSpan.lift(buf) } #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeFaceData_lower(_ value: FaceData) -> RustBuffer { - return FfiConverterTypeFaceData.lower(value) +public func FfiConverterTypeCaptionSpan_lower(_ value: CaptionSpan) -> RustBuffer { + return FfiConverterTypeCaptionSpan.lower(value) } /** - * An overflow fade: runs whose baseline falls inside the rect's vertical - * band are painted through an alpha ramp — the text itself fades, so it - * works over any background. + * A chat's run configuration. Ids are wire strings. */ -public struct Fade: Equatable, Hashable { - public var x: Float - public var y: Float - public var w: Float - public var h: Float - public var edge: FadeEdge - public var scroller: UInt32? +public struct ChatConfig: Equatable, Hashable { + /** + * `claude-code`, `codex`, … + */ + public var harness: String + public var model: String? + /** + * `low` … `ultrathink`. + */ + public var reasoning: String? + /** + * Model option id → choice id (`contextWindow` → `1m`). + */ + public var modelOptions: [String: String] + public var sandbox: SandboxLevel // Default memberwise initializers are never public by default, so we // declare one manually. - public init(x: Float, y: Float, w: Float, h: Float, edge: FadeEdge, scroller: UInt32?) { - self.x = x - self.y = y - self.w = w - self.h = h - self.edge = edge - self.scroller = scroller + public init( + /** + * `claude-code`, `codex`, … + */harness: String, model: String?, + /** + * `low` … `ultrathink`. + */reasoning: String?, + /** + * Model option id → choice id (`contextWindow` → `1m`). + */modelOptions: [String: String], sandbox: SandboxLevel) { + self.harness = harness + self.model = model + self.reasoning = reasoning + self.modelOptions = modelOptions + self.sandbox = sandbox } @@ -4917,32 +5230,30 @@ public struct Fade: Equatable, Hashable { } #if compiler(>=6) -extension Fade: Sendable {} +extension ChatConfig: Sendable {} #endif #if swift(>=5.8) @_documentation(visibility: private) #endif -public struct FfiConverterTypeFade: FfiConverterRustBuffer { - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> Fade { +public struct FfiConverterTypeChatConfig: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> ChatConfig { return - try Fade( - x: FfiConverterFloat.read(from: &buf), - y: FfiConverterFloat.read(from: &buf), - w: FfiConverterFloat.read(from: &buf), - h: FfiConverterFloat.read(from: &buf), - edge: FfiConverterTypeFadeEdge.read(from: &buf), - scroller: FfiConverterOptionUInt32.read(from: &buf) + try ChatConfig( + harness: FfiConverterString.read(from: &buf), + model: FfiConverterOptionString.read(from: &buf), + reasoning: FfiConverterOptionString.read(from: &buf), + modelOptions: FfiConverterDictionaryStringString.read(from: &buf), + sandbox: FfiConverterTypeSandboxLevel.read(from: &buf) ) } - public static func write(_ value: Fade, into buf: inout [UInt8]) { - FfiConverterFloat.write(value.x, into: &buf) - FfiConverterFloat.write(value.y, into: &buf) - FfiConverterFloat.write(value.w, into: &buf) - FfiConverterFloat.write(value.h, into: &buf) - FfiConverterTypeFadeEdge.write(value.edge, into: &buf) - FfiConverterOptionUInt32.write(value.scroller, into: &buf) + public static func write(_ value: ChatConfig, into buf: inout [UInt8]) { + FfiConverterString.write(value.harness, into: &buf) + FfiConverterOptionString.write(value.model, into: &buf) + FfiConverterOptionString.write(value.reasoning, into: &buf) + FfiConverterDictionaryStringString.write(value.modelOptions, into: &buf) + FfiConverterTypeSandboxLevel.write(value.sandbox, into: &buf) } } @@ -4950,30 +5261,104 @@ public struct FfiConverterTypeFade: FfiConverterRustBuffer { #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeFade_lift(_ buf: RustBuffer) throws -> Fade { - return try FfiConverterTypeFade.lift(buf) +public func FfiConverterTypeChatConfig_lift(_ buf: RustBuffer) throws -> ChatConfig { + return try FfiConverterTypeChatConfig.lift(buf) } #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeFade_lower(_ value: Fade) -> RustBuffer { - return FfiConverterTypeFade.lower(value) +public func FfiConverterTypeChatConfig_lower(_ value: ChatConfig) -> RustBuffer { + return FfiConverterTypeChatConfig.lower(value) } /** - * A workspace file or folder for `@` mentions. + * Everything the composer and its status strip render. */ -public struct FileMatch: Equatable, Hashable { - public var path: String - public var isDir: Bool - +public struct ComposerState: Equatable, Hashable { + public var chatId: String + public var revision: UInt64 + public var title: String + public var host: HostInfo + public var live: LiveStatus + /** + * The shared queue, delivery order. + */ + public var queue: [QueueItem] + /** + * This device's unadopted sends, oldest first. + */ + public var pendingSends: [PendingSend] + /** + * Headline state of the oldest pending send. + */ + public var sendState: SendState? + /** + * A send now would queue rather than deliver promptly. + */ + public var deliveryDegraded: Bool + public var room: RoomState + /** + * Attachment escort progress (0..1). + */ + public var transferProgress: Double? + /** + * Answer this instead of typing. + */ + public var openInput: InputRequest? + /** + * Last queue action failure. + */ + public var queueError: String? + /** + * Scroll-to-own-send target. + */ + public var lastSubmittedMessageId: String? + public var contextUsage: ContextUsage? + // Default memberwise initializers are never public by default, so we // declare one manually. - public init(path: String, isDir: Bool) { - self.path = path - self.isDir = isDir + public init(chatId: String, revision: UInt64, title: String, host: HostInfo, live: LiveStatus, + /** + * The shared queue, delivery order. + */queue: [QueueItem], + /** + * This device's unadopted sends, oldest first. + */pendingSends: [PendingSend], + /** + * Headline state of the oldest pending send. + */sendState: SendState?, + /** + * A send now would queue rather than deliver promptly. + */deliveryDegraded: Bool, room: RoomState, + /** + * Attachment escort progress (0..1). + */transferProgress: Double?, + /** + * Answer this instead of typing. + */openInput: InputRequest?, + /** + * Last queue action failure. + */queueError: String?, + /** + * Scroll-to-own-send target. + */lastSubmittedMessageId: String?, contextUsage: ContextUsage?) { + self.chatId = chatId + self.revision = revision + self.title = title + self.host = host + self.live = live + self.queue = queue + self.pendingSends = pendingSends + self.sendState = sendState + self.deliveryDegraded = deliveryDegraded + self.room = room + self.transferProgress = transferProgress + self.openInput = openInput + self.queueError = queueError + self.lastSubmittedMessageId = lastSubmittedMessageId + self.contextUsage = contextUsage } @@ -4982,24 +5367,50 @@ public struct FileMatch: Equatable, Hashable { } #if compiler(>=6) -extension FileMatch: Sendable {} +extension ComposerState: Sendable {} #endif #if swift(>=5.8) @_documentation(visibility: private) #endif -public struct FfiConverterTypeFileMatch: FfiConverterRustBuffer { - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> FileMatch { +public struct FfiConverterTypeComposerState: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> ComposerState { return - try FileMatch( - path: FfiConverterString.read(from: &buf), - isDir: FfiConverterBool.read(from: &buf) + try ComposerState( + chatId: FfiConverterString.read(from: &buf), + revision: FfiConverterUInt64.read(from: &buf), + title: FfiConverterString.read(from: &buf), + host: FfiConverterTypeHostInfo.read(from: &buf), + live: FfiConverterTypeLiveStatus.read(from: &buf), + queue: FfiConverterSequenceTypeQueueItem.read(from: &buf), + pendingSends: FfiConverterSequenceTypePendingSend.read(from: &buf), + sendState: FfiConverterOptionTypeSendState.read(from: &buf), + deliveryDegraded: FfiConverterBool.read(from: &buf), + room: FfiConverterTypeRoomState.read(from: &buf), + transferProgress: FfiConverterOptionDouble.read(from: &buf), + openInput: FfiConverterOptionTypeInputRequest.read(from: &buf), + queueError: FfiConverterOptionString.read(from: &buf), + lastSubmittedMessageId: FfiConverterOptionString.read(from: &buf), + contextUsage: FfiConverterOptionTypeContextUsage.read(from: &buf) ) } - public static func write(_ value: FileMatch, into buf: inout [UInt8]) { - FfiConverterString.write(value.path, into: &buf) - FfiConverterBool.write(value.isDir, into: &buf) + public static func write(_ value: ComposerState, into buf: inout [UInt8]) { + FfiConverterString.write(value.chatId, into: &buf) + FfiConverterUInt64.write(value.revision, into: &buf) + FfiConverterString.write(value.title, into: &buf) + FfiConverterTypeHostInfo.write(value.host, into: &buf) + FfiConverterTypeLiveStatus.write(value.live, into: &buf) + FfiConverterSequenceTypeQueueItem.write(value.queue, into: &buf) + FfiConverterSequenceTypePendingSend.write(value.pendingSends, into: &buf) + FfiConverterOptionTypeSendState.write(value.sendState, into: &buf) + FfiConverterBool.write(value.deliveryDegraded, into: &buf) + FfiConverterTypeRoomState.write(value.room, into: &buf) + FfiConverterOptionDouble.write(value.transferProgress, into: &buf) + FfiConverterOptionTypeInputRequest.write(value.openInput, into: &buf) + FfiConverterOptionString.write(value.queueError, into: &buf) + FfiConverterOptionString.write(value.lastSubmittedMessageId, into: &buf) + FfiConverterOptionTypeContextUsage.write(value.contextUsage, into: &buf) } } @@ -5007,29 +5418,49 @@ public struct FfiConverterTypeFileMatch: FfiConverterRustBuffer { #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeFileMatch_lift(_ buf: RustBuffer) throws -> FileMatch { - return try FfiConverterTypeFileMatch.lift(buf) +public func FfiConverterTypeComposerState_lift(_ buf: RustBuffer) throws -> ComposerState { + return try FfiConverterTypeComposerState.lift(buf) } #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeFileMatch_lower(_ value: FileMatch) -> RustBuffer { - return FfiConverterTypeFileMatch.lower(value) +public func FfiConverterTypeComposerState_lower(_ value: ComposerState) -> RustBuffer { + return FfiConverterTypeComposerState.lower(value) } -public struct FolderEntry: Equatable, Hashable { - public var name: String - public var isDir: Bool - public var isRepo: Bool +public struct Connectivity: Equatable, Hashable { + public var state: ConnectivityState + /** + * Epoch ms of the next scheduled redial (countdown), if any. + */ + public var retryAtMs: Int64? + /** + * The failure that started the current outage. + */ + public var lastFailure: String? + /** + * Open chats whose room is graced-degraded. + */ + public var degradedChats: [String] // Default memberwise initializers are never public by default, so we // declare one manually. - public init(name: String, isDir: Bool, isRepo: Bool) { - self.name = name - self.isDir = isDir - self.isRepo = isRepo + public init(state: ConnectivityState, + /** + * Epoch ms of the next scheduled redial (countdown), if any. + */retryAtMs: Int64?, + /** + * The failure that started the current outage. + */lastFailure: String?, + /** + * Open chats whose room is graced-degraded. + */degradedChats: [String]) { + self.state = state + self.retryAtMs = retryAtMs + self.lastFailure = lastFailure + self.degradedChats = degradedChats } @@ -5038,26 +5469,28 @@ public struct FolderEntry: Equatable, Hashable { } #if compiler(>=6) -extension FolderEntry: Sendable {} +extension Connectivity: Sendable {} #endif #if swift(>=5.8) @_documentation(visibility: private) #endif -public struct FfiConverterTypeFolderEntry: FfiConverterRustBuffer { - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> FolderEntry { +public struct FfiConverterTypeConnectivity: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> Connectivity { return - try FolderEntry( - name: FfiConverterString.read(from: &buf), - isDir: FfiConverterBool.read(from: &buf), - isRepo: FfiConverterBool.read(from: &buf) + try Connectivity( + state: FfiConverterTypeConnectivityState.read(from: &buf), + retryAtMs: FfiConverterOptionInt64.read(from: &buf), + lastFailure: FfiConverterOptionString.read(from: &buf), + degradedChats: FfiConverterSequenceString.read(from: &buf) ) } - public static func write(_ value: FolderEntry, into buf: inout [UInt8]) { - FfiConverterString.write(value.name, into: &buf) - FfiConverterBool.write(value.isDir, into: &buf) - FfiConverterBool.write(value.isRepo, into: &buf) + public static func write(_ value: Connectivity, into buf: inout [UInt8]) { + FfiConverterTypeConnectivityState.write(value.state, into: &buf) + FfiConverterOptionInt64.write(value.retryAtMs, into: &buf) + FfiConverterOptionString.write(value.lastFailure, into: &buf) + FfiConverterSequenceString.write(value.degradedChats, into: &buf) } } @@ -5065,29 +5498,27 @@ public struct FfiConverterTypeFolderEntry: FfiConverterRustBuffer { #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeFolderEntry_lift(_ buf: RustBuffer) throws -> FolderEntry { - return try FfiConverterTypeFolderEntry.lift(buf) +public func FfiConverterTypeConnectivity_lift(_ buf: RustBuffer) throws -> Connectivity { + return try FfiConverterTypeConnectivity.lift(buf) } #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeFolderEntry_lower(_ value: FolderEntry) -> RustBuffer { - return FfiConverterTypeFolderEntry.lower(value) +public func FfiConverterTypeConnectivity_lower(_ value: Connectivity) -> RustBuffer { + return FfiConverterTypeConnectivity.lower(value) } -public struct FolderListing: Equatable, Hashable { - public var path: String - public var entries: [FolderEntry] - public var truncated: Bool +public struct ContextUsage: Equatable, Hashable { + public var tokens: UInt64? + public var window: UInt64? // Default memberwise initializers are never public by default, so we // declare one manually. - public init(path: String, entries: [FolderEntry], truncated: Bool) { - self.path = path - self.entries = entries - self.truncated = truncated + public init(tokens: UInt64?, window: UInt64?) { + self.tokens = tokens + self.window = window } @@ -5096,26 +5527,24 @@ public struct FolderListing: Equatable, Hashable { } #if compiler(>=6) -extension FolderListing: Sendable {} +extension ContextUsage: Sendable {} #endif #if swift(>=5.8) @_documentation(visibility: private) #endif -public struct FfiConverterTypeFolderListing: FfiConverterRustBuffer { - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> FolderListing { +public struct FfiConverterTypeContextUsage: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> ContextUsage { return - try FolderListing( - path: FfiConverterString.read(from: &buf), - entries: FfiConverterSequenceTypeFolderEntry.read(from: &buf), - truncated: FfiConverterBool.read(from: &buf) + try ContextUsage( + tokens: FfiConverterOptionUInt64.read(from: &buf), + window: FfiConverterOptionUInt64.read(from: &buf) ) } - public static func write(_ value: FolderListing, into buf: inout [UInt8]) { - FfiConverterString.write(value.path, into: &buf) - FfiConverterSequenceTypeFolderEntry.write(value.entries, into: &buf) - FfiConverterBool.write(value.truncated, into: &buf) + public static func write(_ value: ContextUsage, into buf: inout [UInt8]) { + FfiConverterOptionUInt64.write(value.tokens, into: &buf) + FfiConverterOptionUInt64.write(value.window, into: &buf) } } @@ -5123,50 +5552,70 @@ public struct FfiConverterTypeFolderListing: FfiConverterRustBuffer { #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeFolderListing_lift(_ buf: RustBuffer) throws -> FolderListing { - return try FfiConverterTypeFolderListing.lift(buf) +public func FfiConverterTypeContextUsage_lift(_ buf: RustBuffer) throws -> ContextUsage { + return try FfiConverterTypeContextUsage.lift(buf) } #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeFolderListing_lower(_ value: FolderListing) -> RustBuffer { - return FfiConverterTypeFolderListing.lower(value) +public func FfiConverterTypeContextUsage_lower(_ value: ContextUsage) -> RustBuffer { + return FfiConverterTypeContextUsage.lower(value) } /** - * The Threads page — the desktop sidebar order. + * Static per-install configuration. */ -public struct FrontPage: Equatable, Hashable { +public struct CoreConfig: Equatable, Hashable { /** - * Shared manual pin order. + * Edge base URL (`auth_production_edge_url()` for prod). */ - public var pinned: [SessionRow] + public var edgeUrl: String /** - * User sections ("folders"), each in recency order. - */ - public var sections: [SectionView] + * Writable directory for registry/doc snapshots and caches, scoped by + * the platform to the signed-in identity (sign-out wipes it). + */ + public var dataDir: String /** - * Everything else, by recency. + * Stable device id (`ios-xxxxxxxx`), stamped on every command/queue row. */ - public var recent: [SessionRow] + public var deviceId: String + /** + * Human name for presence/attribution ("Wing's iPhone"). + */ + public var deviceName: String + /** + * `ios` / `android`. + */ + public var platform: String + public var appVersion: String // Default memberwise initializers are never public by default, so we // declare one manually. public init( /** - * Shared manual pin order. - */pinned: [SessionRow], + * Edge base URL (`auth_production_edge_url()` for prod). + */edgeUrl: String, /** - * User sections ("folders"), each in recency order. - */sections: [SectionView], + * Writable directory for registry/doc snapshots and caches, scoped by + * the platform to the signed-in identity (sign-out wipes it). + */dataDir: String, /** - * Everything else, by recency. - */recent: [SessionRow]) { - self.pinned = pinned - self.sections = sections - self.recent = recent + * Stable device id (`ios-xxxxxxxx`), stamped on every command/queue row. + */deviceId: String, + /** + * Human name for presence/attribution ("Wing's iPhone"). + */deviceName: String, + /** + * `ios` / `android`. + */platform: String, appVersion: String) { + self.edgeUrl = edgeUrl + self.dataDir = dataDir + self.deviceId = deviceId + self.deviceName = deviceName + self.platform = platform + self.appVersion = appVersion } @@ -5175,26 +5624,32 @@ public struct FrontPage: Equatable, Hashable { } #if compiler(>=6) -extension FrontPage: Sendable {} +extension CoreConfig: Sendable {} #endif #if swift(>=5.8) @_documentation(visibility: private) #endif -public struct FfiConverterTypeFrontPage: FfiConverterRustBuffer { - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> FrontPage { +public struct FfiConverterTypeCoreConfig: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> CoreConfig { return - try FrontPage( - pinned: FfiConverterSequenceTypeSessionRow.read(from: &buf), - sections: FfiConverterSequenceTypeSectionView.read(from: &buf), - recent: FfiConverterSequenceTypeSessionRow.read(from: &buf) + try CoreConfig( + edgeUrl: FfiConverterString.read(from: &buf), + dataDir: FfiConverterString.read(from: &buf), + deviceId: FfiConverterString.read(from: &buf), + deviceName: FfiConverterString.read(from: &buf), + platform: FfiConverterString.read(from: &buf), + appVersion: FfiConverterString.read(from: &buf) ) } - public static func write(_ value: FrontPage, into buf: inout [UInt8]) { - FfiConverterSequenceTypeSessionRow.write(value.pinned, into: &buf) - FfiConverterSequenceTypeSectionView.write(value.sections, into: &buf) - FfiConverterSequenceTypeSessionRow.write(value.recent, into: &buf) + public static func write(_ value: CoreConfig, into buf: inout [UInt8]) { + FfiConverterString.write(value.edgeUrl, into: &buf) + FfiConverterString.write(value.dataDir, into: &buf) + FfiConverterString.write(value.deviceId, into: &buf) + FfiConverterString.write(value.deviceName, into: &buf) + FfiConverterString.write(value.platform, into: &buf) + FfiConverterString.write(value.appVersion, into: &buf) } } @@ -5202,59 +5657,34 @@ public struct FfiConverterTypeFrontPage: FfiConverterRustBuffer { #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeFrontPage_lift(_ buf: RustBuffer) throws -> FrontPage { - return try FfiConverterTypeFrontPage.lift(buf) +public func FfiConverterTypeCoreConfig_lift(_ buf: RustBuffer) throws -> CoreConfig { + return try FfiConverterTypeCoreConfig.lift(buf) } #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeFrontPage_lower(_ value: FrontPage) -> RustBuffer { - return FfiConverterTypeFrontPage.lower(value) +public func FfiConverterTypeCoreConfig_lower(_ value: CoreConfig) -> RustBuffer { + return FfiConverterTypeCoreConfig.lower(value) } -public struct HarnessInfo: Equatable, Hashable { +/** + * A debug/demo transcript entry (markdown text), for fixtures and benches. + */ +public struct DebugEntry: Equatable, Hashable { public var id: String - public var label: String - public var supportsSteering: Bool? - /** - * `step-boundary` / `turn-boundary`. - */ - public var steeringMode: String? - public var reasoningLevels: [String] - public var installed: Bool - public var enabled: Bool? - /** - * Installed and not disabled on the device. - */ - public var offered: Bool - /** - * Steers mid-turn (unknown until a live catalog). - */ - public var midTurnSteering: Bool? + public var user: Bool + public var text: String + public var streaming: Bool // Default memberwise initializers are never public by default, so we // declare one manually. - public init(id: String, label: String, supportsSteering: Bool?, - /** - * `step-boundary` / `turn-boundary`. - */steeringMode: String?, reasoningLevels: [String], installed: Bool, enabled: Bool?, - /** - * Installed and not disabled on the device. - */offered: Bool, - /** - * Steers mid-turn (unknown until a live catalog). - */midTurnSteering: Bool?) { + public init(id: String, user: Bool, text: String, streaming: Bool) { self.id = id - self.label = label - self.supportsSteering = supportsSteering - self.steeringMode = steeringMode - self.reasoningLevels = reasoningLevels - self.installed = installed - self.enabled = enabled - self.offered = offered - self.midTurnSteering = midTurnSteering + self.user = user + self.text = text + self.streaming = streaming } @@ -5263,38 +5693,28 @@ public struct HarnessInfo: Equatable, Hashable { } #if compiler(>=6) -extension HarnessInfo: Sendable {} +extension DebugEntry: Sendable {} #endif #if swift(>=5.8) @_documentation(visibility: private) #endif -public struct FfiConverterTypeHarnessInfo: FfiConverterRustBuffer { - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> HarnessInfo { +public struct FfiConverterTypeDebugEntry: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> DebugEntry { return - try HarnessInfo( + try DebugEntry( id: FfiConverterString.read(from: &buf), - label: FfiConverterString.read(from: &buf), - supportsSteering: FfiConverterOptionBool.read(from: &buf), - steeringMode: FfiConverterOptionString.read(from: &buf), - reasoningLevels: FfiConverterSequenceString.read(from: &buf), - installed: FfiConverterBool.read(from: &buf), - enabled: FfiConverterOptionBool.read(from: &buf), - offered: FfiConverterBool.read(from: &buf), - midTurnSteering: FfiConverterOptionBool.read(from: &buf) + user: FfiConverterBool.read(from: &buf), + text: FfiConverterString.read(from: &buf), + streaming: FfiConverterBool.read(from: &buf) ) } - public static func write(_ value: HarnessInfo, into buf: inout [UInt8]) { + public static func write(_ value: DebugEntry, into buf: inout [UInt8]) { FfiConverterString.write(value.id, into: &buf) - FfiConverterString.write(value.label, into: &buf) - FfiConverterOptionBool.write(value.supportsSteering, into: &buf) - FfiConverterOptionString.write(value.steeringMode, into: &buf) - FfiConverterSequenceString.write(value.reasoningLevels, into: &buf) - FfiConverterBool.write(value.installed, into: &buf) - FfiConverterOptionBool.write(value.enabled, into: &buf) - FfiConverterBool.write(value.offered, into: &buf) - FfiConverterOptionBool.write(value.midTurnSteering, into: &buf) + FfiConverterBool.write(value.user, into: &buf) + FfiConverterString.write(value.text, into: &buf) + FfiConverterBool.write(value.streaming, into: &buf) } } @@ -5302,49 +5722,43 @@ public struct FfiConverterTypeHarnessInfo: FfiConverterRustBuffer { #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeHarnessInfo_lift(_ buf: RustBuffer) throws -> HarnessInfo { - return try FfiConverterTypeHarnessInfo.lift(buf) +public func FfiConverterTypeDebugEntry_lift(_ buf: RustBuffer) throws -> DebugEntry { + return try FfiConverterTypeDebugEntry.lift(buf) } #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeHarnessInfo_lower(_ value: HarnessInfo) -> RustBuffer { - return FfiConverterTypeHarnessInfo.lower(value) +public func FfiConverterTypeDebugEntry_lower(_ value: DebugEntry) -> RustBuffer { + return FfiConverterTypeDebugEntry.lower(value) } -public struct HostCapabilities: Equatable, Hashable { - public var messageQueue: Bool - public var queueActions: Bool - public var queueAttachments: Bool - public var cleanAttachmentText: Bool - public var queueEditLease: Bool +public struct DemoOptions: Equatable, Hashable { + public var fixture: DemoFixture /** - * Host ≥ 0.2.12: attachments ride `pending://` refs. + * Size of the flagship chat's (`chat-veil`) transcript. */ - public var queuedAttachments: Bool + public var transcriptScale: TranscriptScale + public var streamSpeed: StreamSpeed /** - * The chat's harness steers mid-turn (unknown until a live catalog). + * Repeat the scripted reply 12× (long streaming stress). */ - public var midTurnSteering: Bool? + public var longReply: Bool // Default memberwise initializers are never public by default, so we // declare one manually. - public init(messageQueue: Bool, queueActions: Bool, queueAttachments: Bool, cleanAttachmentText: Bool, queueEditLease: Bool, + public init(fixture: DemoFixture, /** - * Host ≥ 0.2.12: attachments ride `pending://` refs. - */queuedAttachments: Bool, + * Size of the flagship chat's (`chat-veil`) transcript. + */transcriptScale: TranscriptScale, streamSpeed: StreamSpeed, /** - * The chat's harness steers mid-turn (unknown until a live catalog). - */midTurnSteering: Bool?) { - self.messageQueue = messageQueue - self.queueActions = queueActions - self.queueAttachments = queueAttachments - self.cleanAttachmentText = cleanAttachmentText - self.queueEditLease = queueEditLease - self.queuedAttachments = queuedAttachments - self.midTurnSteering = midTurnSteering + * Repeat the scripted reply 12× (long streaming stress). + */longReply: Bool) { + self.fixture = fixture + self.transcriptScale = transcriptScale + self.streamSpeed = streamSpeed + self.longReply = longReply } @@ -5353,34 +5767,28 @@ public struct HostCapabilities: Equatable, Hashable { } #if compiler(>=6) -extension HostCapabilities: Sendable {} +extension DemoOptions: Sendable {} #endif #if swift(>=5.8) @_documentation(visibility: private) #endif -public struct FfiConverterTypeHostCapabilities: FfiConverterRustBuffer { - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> HostCapabilities { +public struct FfiConverterTypeDemoOptions: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> DemoOptions { return - try HostCapabilities( - messageQueue: FfiConverterBool.read(from: &buf), - queueActions: FfiConverterBool.read(from: &buf), - queueAttachments: FfiConverterBool.read(from: &buf), - cleanAttachmentText: FfiConverterBool.read(from: &buf), - queueEditLease: FfiConverterBool.read(from: &buf), - queuedAttachments: FfiConverterBool.read(from: &buf), - midTurnSteering: FfiConverterOptionBool.read(from: &buf) + try DemoOptions( + fixture: FfiConverterTypeDemoFixture.read(from: &buf), + transcriptScale: FfiConverterTypeTranscriptScale.read(from: &buf), + streamSpeed: FfiConverterTypeStreamSpeed.read(from: &buf), + longReply: FfiConverterBool.read(from: &buf) ) } - public static func write(_ value: HostCapabilities, into buf: inout [UInt8]) { - FfiConverterBool.write(value.messageQueue, into: &buf) - FfiConverterBool.write(value.queueActions, into: &buf) - FfiConverterBool.write(value.queueAttachments, into: &buf) - FfiConverterBool.write(value.cleanAttachmentText, into: &buf) - FfiConverterBool.write(value.queueEditLease, into: &buf) - FfiConverterBool.write(value.queuedAttachments, into: &buf) - FfiConverterOptionBool.write(value.midTurnSteering, into: &buf) + public static func write(_ value: DemoOptions, into buf: inout [UInt8]) { + FfiConverterTypeDemoFixture.write(value.fixture, into: &buf) + FfiConverterTypeTranscriptScale.write(value.transcriptScale, into: &buf) + FfiConverterTypeStreamSpeed.write(value.streamSpeed, into: &buf) + FfiConverterBool.write(value.longReply, into: &buf) } } @@ -5388,31 +5796,49 @@ public struct FfiConverterTypeHostCapabilities: FfiConverterRustBuffer { #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeHostCapabilities_lift(_ buf: RustBuffer) throws -> HostCapabilities { - return try FfiConverterTypeHostCapabilities.lift(buf) +public func FfiConverterTypeDemoOptions_lift(_ buf: RustBuffer) throws -> DemoOptions { + return try FfiConverterTypeDemoOptions.lift(buf) } #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeHostCapabilities_lower(_ value: HostCapabilities) -> RustBuffer { - return FfiConverterTypeHostCapabilities.lower(value) +public func FfiConverterTypeDemoOptions_lower(_ value: DemoOptions) -> RustBuffer { + return FfiConverterTypeDemoOptions.lower(value) } -public struct HostInfo: Equatable, Hashable { - public var deviceId: String - public var name: String? +public struct DeviceView: Equatable, Hashable { + public var id: String + public var name: String + public var platform: String public var online: Bool - public var capabilities: HostCapabilities - + public var lastSeenMs: Int64? + public var version: String? + public var capabilities: [String] + /** + * Can run sessions (desktop/server engines — not phones). + */ + public var isExecutionHost: Bool + public var isSelf: Bool + public var sessionCount: UInt32 + // Default memberwise initializers are never public by default, so we // declare one manually. - public init(deviceId: String, name: String?, online: Bool, capabilities: HostCapabilities) { - self.deviceId = deviceId + public init(id: String, name: String, platform: String, online: Bool, lastSeenMs: Int64?, version: String?, capabilities: [String], + /** + * Can run sessions (desktop/server engines — not phones). + */isExecutionHost: Bool, isSelf: Bool, sessionCount: UInt32) { + self.id = id self.name = name + self.platform = platform self.online = online + self.lastSeenMs = lastSeenMs + self.version = version self.capabilities = capabilities + self.isExecutionHost = isExecutionHost + self.isSelf = isSelf + self.sessionCount = sessionCount } @@ -5421,28 +5847,40 @@ public struct HostInfo: Equatable, Hashable { } #if compiler(>=6) -extension HostInfo: Sendable {} +extension DeviceView: Sendable {} #endif #if swift(>=5.8) @_documentation(visibility: private) #endif -public struct FfiConverterTypeHostInfo: FfiConverterRustBuffer { - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> HostInfo { +public struct FfiConverterTypeDeviceView: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> DeviceView { return - try HostInfo( - deviceId: FfiConverterString.read(from: &buf), - name: FfiConverterOptionString.read(from: &buf), + try DeviceView( + id: FfiConverterString.read(from: &buf), + name: FfiConverterString.read(from: &buf), + platform: FfiConverterString.read(from: &buf), online: FfiConverterBool.read(from: &buf), - capabilities: FfiConverterTypeHostCapabilities.read(from: &buf) + lastSeenMs: FfiConverterOptionInt64.read(from: &buf), + version: FfiConverterOptionString.read(from: &buf), + capabilities: FfiConverterSequenceString.read(from: &buf), + isExecutionHost: FfiConverterBool.read(from: &buf), + isSelf: FfiConverterBool.read(from: &buf), + sessionCount: FfiConverterUInt32.read(from: &buf) ) } - public static func write(_ value: HostInfo, into buf: inout [UInt8]) { - FfiConverterString.write(value.deviceId, into: &buf) - FfiConverterOptionString.write(value.name, into: &buf) + public static func write(_ value: DeviceView, into buf: inout [UInt8]) { + FfiConverterString.write(value.id, into: &buf) + FfiConverterString.write(value.name, into: &buf) + FfiConverterString.write(value.platform, into: &buf) FfiConverterBool.write(value.online, into: &buf) - FfiConverterTypeHostCapabilities.write(value.capabilities, into: &buf) + FfiConverterOptionInt64.write(value.lastSeenMs, into: &buf) + FfiConverterOptionString.write(value.version, into: &buf) + FfiConverterSequenceString.write(value.capabilities, into: &buf) + FfiConverterBool.write(value.isExecutionHost, into: &buf) + FfiConverterBool.write(value.isSelf, into: &buf) + FfiConverterUInt32.write(value.sessionCount, into: &buf) } } @@ -5450,29 +5888,27 @@ public struct FfiConverterTypeHostInfo: FfiConverterRustBuffer { #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeHostInfo_lift(_ buf: RustBuffer) throws -> HostInfo { - return try FfiConverterTypeHostInfo.lift(buf) +public func FfiConverterTypeDeviceView_lift(_ buf: RustBuffer) throws -> DeviceView { + return try FfiConverterTypeDeviceView.lift(buf) } #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeHostInfo_lower(_ value: HostInfo) -> RustBuffer { - return FfiConverterTypeHostInfo.lower(value) +public func FfiConverterTypeDeviceView_lower(_ value: DeviceView) -> RustBuffer { + return FfiConverterTypeDeviceView.lower(value) } -public struct InputRequest: Equatable, Hashable { - public var entryId: String - public var requestId: String - public var questions: [UserInputQuestion] +public struct FaceData: Equatable, Hashable { + public var role: FaceRole + public var bytes: Data // Default memberwise initializers are never public by default, so we // declare one manually. - public init(entryId: String, requestId: String, questions: [UserInputQuestion]) { - self.entryId = entryId - self.requestId = requestId - self.questions = questions + public init(role: FaceRole, bytes: Data) { + self.role = role + self.bytes = bytes } @@ -5481,26 +5917,24 @@ public struct InputRequest: Equatable, Hashable { } #if compiler(>=6) -extension InputRequest: Sendable {} +extension FaceData: Sendable {} #endif #if swift(>=5.8) @_documentation(visibility: private) #endif -public struct FfiConverterTypeInputRequest: FfiConverterRustBuffer { - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> InputRequest { +public struct FfiConverterTypeFaceData: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> FaceData { return - try InputRequest( - entryId: FfiConverterString.read(from: &buf), - requestId: FfiConverterString.read(from: &buf), - questions: FfiConverterSequenceTypeUserInputQuestion.read(from: &buf) + try FaceData( + role: FfiConverterTypeFaceRole.read(from: &buf), + bytes: FfiConverterData.read(from: &buf) ) } - public static func write(_ value: InputRequest, into buf: inout [UInt8]) { - FfiConverterString.write(value.entryId, into: &buf) - FfiConverterString.write(value.requestId, into: &buf) - FfiConverterSequenceTypeUserInputQuestion.write(value.questions, into: &buf) + public static func write(_ value: FaceData, into buf: inout [UInt8]) { + FfiConverterTypeFaceRole.write(value.role, into: &buf) + FfiConverterData.write(value.bytes, into: &buf) } } @@ -5508,37 +5942,39 @@ public struct FfiConverterTypeInputRequest: FfiConverterRustBuffer { #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeInputRequest_lift(_ buf: RustBuffer) throws -> InputRequest { - return try FfiConverterTypeInputRequest.lift(buf) +public func FfiConverterTypeFaceData_lift(_ buf: RustBuffer) throws -> FaceData { + return try FfiConverterTypeFaceData.lift(buf) } #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeInputRequest_lower(_ value: InputRequest) -> RustBuffer { - return FfiConverterTypeInputRequest.lower(value) +public func FfiConverterTypeFaceData_lower(_ value: FaceData) -> RustBuffer { + return FfiConverterTypeFaceData.lower(value) } /** - * A tappable link region (one per fragment; multi-line links repeat). + * An overflow fade: runs whose baseline falls inside the rect's vertical + * band are painted through an alpha ramp — the text itself fades, so it + * works over any background. */ -public struct LinkHit: Equatable, Hashable { +public struct Fade: Equatable, Hashable { public var x: Float public var y: Float public var w: Float public var h: Float - public var url: String + public var edge: FadeEdge public var scroller: UInt32? // Default memberwise initializers are never public by default, so we // declare one manually. - public init(x: Float, y: Float, w: Float, h: Float, url: String, scroller: UInt32?) { + public init(x: Float, y: Float, w: Float, h: Float, edge: FadeEdge, scroller: UInt32?) { self.x = x self.y = y self.w = w self.h = h - self.url = url + self.edge = edge self.scroller = scroller } @@ -5548,31 +5984,31 @@ public struct LinkHit: Equatable, Hashable { } #if compiler(>=6) -extension LinkHit: Sendable {} +extension Fade: Sendable {} #endif #if swift(>=5.8) @_documentation(visibility: private) #endif -public struct FfiConverterTypeLinkHit: FfiConverterRustBuffer { - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> LinkHit { +public struct FfiConverterTypeFade: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> Fade { return - try LinkHit( + try Fade( x: FfiConverterFloat.read(from: &buf), y: FfiConverterFloat.read(from: &buf), w: FfiConverterFloat.read(from: &buf), h: FfiConverterFloat.read(from: &buf), - url: FfiConverterString.read(from: &buf), + edge: FfiConverterTypeFadeEdge.read(from: &buf), scroller: FfiConverterOptionUInt32.read(from: &buf) ) } - public static func write(_ value: LinkHit, into buf: inout [UInt8]) { + public static func write(_ value: Fade, into buf: inout [UInt8]) { FfiConverterFloat.write(value.x, into: &buf) FfiConverterFloat.write(value.y, into: &buf) FfiConverterFloat.write(value.w, into: &buf) FfiConverterFloat.write(value.h, into: &buf) - FfiConverterString.write(value.url, into: &buf) + FfiConverterTypeFadeEdge.write(value.edge, into: &buf) FfiConverterOptionUInt32.write(value.scroller, into: &buf) } } @@ -5581,57 +6017,30 @@ public struct FfiConverterTypeLinkHit: FfiConverterRustBuffer { #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeLinkHit_lift(_ buf: RustBuffer) throws -> LinkHit { - return try FfiConverterTypeLinkHit.lift(buf) +public func FfiConverterTypeFade_lift(_ buf: RustBuffer) throws -> Fade { + return try FfiConverterTypeFade.lift(buf) } #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeLinkHit_lower(_ value: LinkHit) -> RustBuffer { - return FfiConverterTypeLinkHit.lower(value) +public func FfiConverterTypeFade_lower(_ value: Fade) -> RustBuffer { + return FfiConverterTypeFade.lower(value) } -public struct LiveStatus: Equatable, Hashable { - /** - * Display status (an in-flight send reads Working). - */ - public var indicator: ChatIndicator - /** - * A turn is actually running on the host (sends queue/steer). - */ - public var turnRunning: Bool - public var workingSinceMs: Int64? - /** - * The newest transcript entry is streaming. - */ - public var streaming: Bool - /** - * Stop is meaningful. - */ - public var canInterrupt: Bool +/** + * A workspace file or folder for `@` mentions. + */ +public struct FileMatch: Equatable, Hashable { + public var path: String + public var isDir: Bool // Default memberwise initializers are never public by default, so we // declare one manually. - public init( - /** - * Display status (an in-flight send reads Working). - */indicator: ChatIndicator, - /** - * A turn is actually running on the host (sends queue/steer). - */turnRunning: Bool, workingSinceMs: Int64?, - /** - * The newest transcript entry is streaming. - */streaming: Bool, - /** - * Stop is meaningful. - */canInterrupt: Bool) { - self.indicator = indicator - self.turnRunning = turnRunning - self.workingSinceMs = workingSinceMs - self.streaming = streaming - self.canInterrupt = canInterrupt + public init(path: String, isDir: Bool) { + self.path = path + self.isDir = isDir } @@ -5640,30 +6049,24 @@ public struct LiveStatus: Equatable, Hashable { } #if compiler(>=6) -extension LiveStatus: Sendable {} +extension FileMatch: Sendable {} #endif #if swift(>=5.8) @_documentation(visibility: private) #endif -public struct FfiConverterTypeLiveStatus: FfiConverterRustBuffer { - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> LiveStatus { +public struct FfiConverterTypeFileMatch: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> FileMatch { return - try LiveStatus( - indicator: FfiConverterTypeChatIndicator.read(from: &buf), - turnRunning: FfiConverterBool.read(from: &buf), - workingSinceMs: FfiConverterOptionInt64.read(from: &buf), - streaming: FfiConverterBool.read(from: &buf), - canInterrupt: FfiConverterBool.read(from: &buf) + try FileMatch( + path: FfiConverterString.read(from: &buf), + isDir: FfiConverterBool.read(from: &buf) ) } - public static func write(_ value: LiveStatus, into buf: inout [UInt8]) { - FfiConverterTypeChatIndicator.write(value.indicator, into: &buf) - FfiConverterBool.write(value.turnRunning, into: &buf) - FfiConverterOptionInt64.write(value.workingSinceMs, into: &buf) - FfiConverterBool.write(value.streaming, into: &buf) - FfiConverterBool.write(value.canInterrupt, into: &buf) + public static func write(_ value: FileMatch, into buf: inout [UInt8]) { + FfiConverterString.write(value.path, into: &buf) + FfiConverterBool.write(value.isDir, into: &buf) } } @@ -5671,47 +6074,29 @@ public struct FfiConverterTypeLiveStatus: FfiConverterRustBuffer { #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeLiveStatus_lift(_ buf: RustBuffer) throws -> LiveStatus { - return try FfiConverterTypeLiveStatus.lift(buf) +public func FfiConverterTypeFileMatch_lift(_ buf: RustBuffer) throws -> FileMatch { + return try FfiConverterTypeFileMatch.lift(buf) } #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeLiveStatus_lower(_ value: LiveStatus) -> RustBuffer { - return FfiConverterTypeLiveStatus.lower(value) +public func FfiConverterTypeFileMatch_lower(_ value: FileMatch) -> RustBuffer { + return FfiConverterTypeFileMatch.lower(value) } -public struct ModelInfo: Equatable, Hashable { - public var id: String - public var label: String - public var description: String? - /** - * Wire effort values; empty = no effort ladder. - */ - public var reasoningLevels: [String] - public var options: [ModelOption] - /** - * The picker's default effort for this model. - */ - public var defaultReasoning: String? +public struct FolderEntry: Equatable, Hashable { + public var name: String + public var isDir: Bool + public var isRepo: Bool // Default memberwise initializers are never public by default, so we // declare one manually. - public init(id: String, label: String, description: String?, - /** - * Wire effort values; empty = no effort ladder. - */reasoningLevels: [String], options: [ModelOption], - /** - * The picker's default effort for this model. - */defaultReasoning: String?) { - self.id = id - self.label = label - self.description = description - self.reasoningLevels = reasoningLevels - self.options = options - self.defaultReasoning = defaultReasoning + public init(name: String, isDir: Bool, isRepo: Bool) { + self.name = name + self.isDir = isDir + self.isRepo = isRepo } @@ -5720,32 +6105,26 @@ public struct ModelInfo: Equatable, Hashable { } #if compiler(>=6) -extension ModelInfo: Sendable {} +extension FolderEntry: Sendable {} #endif #if swift(>=5.8) @_documentation(visibility: private) #endif -public struct FfiConverterTypeModelInfo: FfiConverterRustBuffer { - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> ModelInfo { +public struct FfiConverterTypeFolderEntry: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> FolderEntry { return - try ModelInfo( - id: FfiConverterString.read(from: &buf), - label: FfiConverterString.read(from: &buf), - description: FfiConverterOptionString.read(from: &buf), - reasoningLevels: FfiConverterSequenceString.read(from: &buf), - options: FfiConverterSequenceTypeModelOption.read(from: &buf), - defaultReasoning: FfiConverterOptionString.read(from: &buf) + try FolderEntry( + name: FfiConverterString.read(from: &buf), + isDir: FfiConverterBool.read(from: &buf), + isRepo: FfiConverterBool.read(from: &buf) ) } - public static func write(_ value: ModelInfo, into buf: inout [UInt8]) { - FfiConverterString.write(value.id, into: &buf) - FfiConverterString.write(value.label, into: &buf) - FfiConverterOptionString.write(value.description, into: &buf) - FfiConverterSequenceString.write(value.reasoningLevels, into: &buf) - FfiConverterSequenceTypeModelOption.write(value.options, into: &buf) - FfiConverterOptionString.write(value.defaultReasoning, into: &buf) + public static func write(_ value: FolderEntry, into buf: inout [UInt8]) { + FfiConverterString.write(value.name, into: &buf) + FfiConverterBool.write(value.isDir, into: &buf) + FfiConverterBool.write(value.isRepo, into: &buf) } } @@ -5753,31 +6132,29 @@ public struct FfiConverterTypeModelInfo: FfiConverterRustBuffer { #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeModelInfo_lift(_ buf: RustBuffer) throws -> ModelInfo { - return try FfiConverterTypeModelInfo.lift(buf) +public func FfiConverterTypeFolderEntry_lift(_ buf: RustBuffer) throws -> FolderEntry { + return try FfiConverterTypeFolderEntry.lift(buf) } #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeModelInfo_lower(_ value: ModelInfo) -> RustBuffer { - return FfiConverterTypeModelInfo.lower(value) +public func FfiConverterTypeFolderEntry_lower(_ value: FolderEntry) -> RustBuffer { + return FfiConverterTypeFolderEntry.lower(value) } -public struct ModelOption: Equatable, Hashable { - public var id: String - public var label: String - public var choices: [ModelOptionChoice] - public var defaultChoice: String +public struct FolderListing: Equatable, Hashable { + public var path: String + public var entries: [FolderEntry] + public var truncated: Bool // Default memberwise initializers are never public by default, so we // declare one manually. - public init(id: String, label: String, choices: [ModelOptionChoice], defaultChoice: String) { - self.id = id - self.label = label - self.choices = choices - self.defaultChoice = defaultChoice + public init(path: String, entries: [FolderEntry], truncated: Bool) { + self.path = path + self.entries = entries + self.truncated = truncated } @@ -5786,28 +6163,26 @@ public struct ModelOption: Equatable, Hashable { } #if compiler(>=6) -extension ModelOption: Sendable {} +extension FolderListing: Sendable {} #endif #if swift(>=5.8) @_documentation(visibility: private) #endif -public struct FfiConverterTypeModelOption: FfiConverterRustBuffer { - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> ModelOption { +public struct FfiConverterTypeFolderListing: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> FolderListing { return - try ModelOption( - id: FfiConverterString.read(from: &buf), - label: FfiConverterString.read(from: &buf), - choices: FfiConverterSequenceTypeModelOptionChoice.read(from: &buf), - defaultChoice: FfiConverterString.read(from: &buf) + try FolderListing( + path: FfiConverterString.read(from: &buf), + entries: FfiConverterSequenceTypeFolderEntry.read(from: &buf), + truncated: FfiConverterBool.read(from: &buf) ) } - public static func write(_ value: ModelOption, into buf: inout [UInt8]) { - FfiConverterString.write(value.id, into: &buf) - FfiConverterString.write(value.label, into: &buf) - FfiConverterSequenceTypeModelOptionChoice.write(value.choices, into: &buf) - FfiConverterString.write(value.defaultChoice, into: &buf) + public static func write(_ value: FolderListing, into buf: inout [UInt8]) { + FfiConverterString.write(value.path, into: &buf) + FfiConverterSequenceTypeFolderEntry.write(value.entries, into: &buf) + FfiConverterBool.write(value.truncated, into: &buf) } } @@ -5815,27 +6190,50 @@ public struct FfiConverterTypeModelOption: FfiConverterRustBuffer { #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeModelOption_lift(_ buf: RustBuffer) throws -> ModelOption { - return try FfiConverterTypeModelOption.lift(buf) +public func FfiConverterTypeFolderListing_lift(_ buf: RustBuffer) throws -> FolderListing { + return try FfiConverterTypeFolderListing.lift(buf) } #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeModelOption_lower(_ value: ModelOption) -> RustBuffer { - return FfiConverterTypeModelOption.lower(value) +public func FfiConverterTypeFolderListing_lower(_ value: FolderListing) -> RustBuffer { + return FfiConverterTypeFolderListing.lower(value) } -public struct ModelOptionChoice: Equatable, Hashable { - public var id: String - public var label: String +/** + * The Threads page — the desktop sidebar order. + */ +public struct FrontPage: Equatable, Hashable { + /** + * Shared manual pin order. + */ + public var pinned: [SessionRow] + /** + * User sections ("folders"), each in recency order. + */ + public var sections: [SectionView] + /** + * Everything else, by recency. + */ + public var recent: [SessionRow] // Default memberwise initializers are never public by default, so we // declare one manually. - public init(id: String, label: String) { - self.id = id - self.label = label + public init( + /** + * Shared manual pin order. + */pinned: [SessionRow], + /** + * User sections ("folders"), each in recency order. + */sections: [SectionView], + /** + * Everything else, by recency. + */recent: [SessionRow]) { + self.pinned = pinned + self.sections = sections + self.recent = recent } @@ -5844,24 +6242,26 @@ public struct ModelOptionChoice: Equatable, Hashable { } #if compiler(>=6) -extension ModelOptionChoice: Sendable {} +extension FrontPage: Sendable {} #endif #if swift(>=5.8) @_documentation(visibility: private) #endif -public struct FfiConverterTypeModelOptionChoice: FfiConverterRustBuffer { - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> ModelOptionChoice { +public struct FfiConverterTypeFrontPage: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> FrontPage { return - try ModelOptionChoice( - id: FfiConverterString.read(from: &buf), - label: FfiConverterString.read(from: &buf) + try FrontPage( + pinned: FfiConverterSequenceTypeSessionRow.read(from: &buf), + sections: FfiConverterSequenceTypeSectionView.read(from: &buf), + recent: FfiConverterSequenceTypeSessionRow.read(from: &buf) ) } - public static func write(_ value: ModelOptionChoice, into buf: inout [UInt8]) { - FfiConverterString.write(value.id, into: &buf) - FfiConverterString.write(value.label, into: &buf) + public static func write(_ value: FrontPage, into buf: inout [UInt8]) { + FfiConverterSequenceTypeSessionRow.write(value.pinned, into: &buf) + FfiConverterSequenceTypeSectionView.write(value.sections, into: &buf) + FfiConverterSequenceTypeSessionRow.write(value.recent, into: &buf) } } @@ -5869,45 +6269,59 @@ public struct FfiConverterTypeModelOptionChoice: FfiConverterRustBuffer { #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeModelOptionChoice_lift(_ buf: RustBuffer) throws -> ModelOptionChoice { - return try FfiConverterTypeModelOptionChoice.lift(buf) +public func FfiConverterTypeFrontPage_lift(_ buf: RustBuffer) throws -> FrontPage { + return try FfiConverterTypeFrontPage.lift(buf) } #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeModelOptionChoice_lower(_ value: ModelOptionChoice) -> RustBuffer { - return FfiConverterTypeModelOptionChoice.lower(value) +public func FfiConverterTypeFrontPage_lower(_ value: FrontPage) -> RustBuffer { + return FfiConverterTypeFrontPage.lower(value) } -public struct NewSession: Equatable, Hashable { - public var target: SessionTarget - public var config: ChatConfig? +public struct HarnessInfo: Equatable, Hashable { + public var id: String + public var label: String + public var supportsSteering: Bool? /** - * Branch stamped from the first frame (picked ref). + * `step-boundary` / `turn-boundary`. */ - public var branch: String? + public var steeringMode: String? + public var reasoningLevels: [String] + public var installed: Bool + public var enabled: Bool? /** - * Checkout override (an existing worktree's path). + * Installed and not disabled on the device. */ - public var cwd: String? - public var title: String? + public var offered: Bool + /** + * Steers mid-turn (unknown until a live catalog). + */ + public var midTurnSteering: Bool? // Default memberwise initializers are never public by default, so we // declare one manually. - public init(target: SessionTarget, config: ChatConfig?, + public init(id: String, label: String, supportsSteering: Bool?, /** - * Branch stamped from the first frame (picked ref). - */branch: String?, + * `step-boundary` / `turn-boundary`. + */steeringMode: String?, reasoningLevels: [String], installed: Bool, enabled: Bool?, /** - * Checkout override (an existing worktree's path). - */cwd: String?, title: String?) { - self.target = target - self.config = config - self.branch = branch - self.cwd = cwd - self.title = title + * Installed and not disabled on the device. + */offered: Bool, + /** + * Steers mid-turn (unknown until a live catalog). + */midTurnSteering: Bool?) { + self.id = id + self.label = label + self.supportsSteering = supportsSteering + self.steeringMode = steeringMode + self.reasoningLevels = reasoningLevels + self.installed = installed + self.enabled = enabled + self.offered = offered + self.midTurnSteering = midTurnSteering } @@ -5916,30 +6330,38 @@ public struct NewSession: Equatable, Hashable { } #if compiler(>=6) -extension NewSession: Sendable {} +extension HarnessInfo: Sendable {} #endif #if swift(>=5.8) @_documentation(visibility: private) #endif -public struct FfiConverterTypeNewSession: FfiConverterRustBuffer { - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> NewSession { +public struct FfiConverterTypeHarnessInfo: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> HarnessInfo { return - try NewSession( - target: FfiConverterTypeSessionTarget.read(from: &buf), - config: FfiConverterOptionTypeChatConfig.read(from: &buf), - branch: FfiConverterOptionString.read(from: &buf), - cwd: FfiConverterOptionString.read(from: &buf), - title: FfiConverterOptionString.read(from: &buf) + try HarnessInfo( + id: FfiConverterString.read(from: &buf), + label: FfiConverterString.read(from: &buf), + supportsSteering: FfiConverterOptionBool.read(from: &buf), + steeringMode: FfiConverterOptionString.read(from: &buf), + reasoningLevels: FfiConverterSequenceString.read(from: &buf), + installed: FfiConverterBool.read(from: &buf), + enabled: FfiConverterOptionBool.read(from: &buf), + offered: FfiConverterBool.read(from: &buf), + midTurnSteering: FfiConverterOptionBool.read(from: &buf) ) } - public static func write(_ value: NewSession, into buf: inout [UInt8]) { - FfiConverterTypeSessionTarget.write(value.target, into: &buf) - FfiConverterOptionTypeChatConfig.write(value.config, into: &buf) - FfiConverterOptionString.write(value.branch, into: &buf) - FfiConverterOptionString.write(value.cwd, into: &buf) - FfiConverterOptionString.write(value.title, into: &buf) + public static func write(_ value: HarnessInfo, into buf: inout [UInt8]) { + FfiConverterString.write(value.id, into: &buf) + FfiConverterString.write(value.label, into: &buf) + FfiConverterOptionBool.write(value.supportsSteering, into: &buf) + FfiConverterOptionString.write(value.steeringMode, into: &buf) + FfiConverterSequenceString.write(value.reasoningLevels, into: &buf) + FfiConverterBool.write(value.installed, into: &buf) + FfiConverterOptionBool.write(value.enabled, into: &buf) + FfiConverterBool.write(value.offered, into: &buf) + FfiConverterOptionBool.write(value.midTurnSteering, into: &buf) } } @@ -5947,57 +6369,85 @@ public struct FfiConverterTypeNewSession: FfiConverterRustBuffer { #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeNewSession_lift(_ buf: RustBuffer) throws -> NewSession { - return try FfiConverterTypeNewSession.lift(buf) +public func FfiConverterTypeHarnessInfo_lift(_ buf: RustBuffer) throws -> HarnessInfo { + return try FfiConverterTypeHarnessInfo.lift(buf) } #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeNewSession_lower(_ value: NewSession) -> RustBuffer { - return FfiConverterTypeNewSession.lower(value) +public func FfiConverterTypeHarnessInfo_lower(_ value: HarnessInfo) -> RustBuffer { + return FfiConverterTypeHarnessInfo.lower(value) } -public struct OutgoingAttachment: Equatable, Hashable { - public var name: String - public var mimeType: String - public var data: Data - - // Default memberwise initializers are never public by default, so we - // declare one manually. - public init(name: String, mimeType: String, data: Data) { - self.name = name - self.mimeType = mimeType - self.data = data - } - - - +public struct HostCapabilities: Equatable, Hashable { + public var messageQueue: Bool + public var queueActions: Bool + public var queueAttachments: Bool + public var cleanAttachmentText: Bool + public var queueEditLease: Bool + /** + * Host ≥ 0.2.12: attachments ride `pending://` refs. + */ + public var queuedAttachments: Bool + /** + * The chat's harness steers mid-turn (unknown until a live catalog). + */ + public var midTurnSteering: Bool? + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(messageQueue: Bool, queueActions: Bool, queueAttachments: Bool, cleanAttachmentText: Bool, queueEditLease: Bool, + /** + * Host ≥ 0.2.12: attachments ride `pending://` refs. + */queuedAttachments: Bool, + /** + * The chat's harness steers mid-turn (unknown until a live catalog). + */midTurnSteering: Bool?) { + self.messageQueue = messageQueue + self.queueActions = queueActions + self.queueAttachments = queueAttachments + self.cleanAttachmentText = cleanAttachmentText + self.queueEditLease = queueEditLease + self.queuedAttachments = queuedAttachments + self.midTurnSteering = midTurnSteering + } + + + } #if compiler(>=6) -extension OutgoingAttachment: Sendable {} +extension HostCapabilities: Sendable {} #endif #if swift(>=5.8) @_documentation(visibility: private) #endif -public struct FfiConverterTypeOutgoingAttachment: FfiConverterRustBuffer { - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> OutgoingAttachment { +public struct FfiConverterTypeHostCapabilities: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> HostCapabilities { return - try OutgoingAttachment( - name: FfiConverterString.read(from: &buf), - mimeType: FfiConverterString.read(from: &buf), - data: FfiConverterData.read(from: &buf) + try HostCapabilities( + messageQueue: FfiConverterBool.read(from: &buf), + queueActions: FfiConverterBool.read(from: &buf), + queueAttachments: FfiConverterBool.read(from: &buf), + cleanAttachmentText: FfiConverterBool.read(from: &buf), + queueEditLease: FfiConverterBool.read(from: &buf), + queuedAttachments: FfiConverterBool.read(from: &buf), + midTurnSteering: FfiConverterOptionBool.read(from: &buf) ) } - public static func write(_ value: OutgoingAttachment, into buf: inout [UInt8]) { - FfiConverterString.write(value.name, into: &buf) - FfiConverterString.write(value.mimeType, into: &buf) - FfiConverterData.write(value.data, into: &buf) + public static func write(_ value: HostCapabilities, into buf: inout [UInt8]) { + FfiConverterBool.write(value.messageQueue, into: &buf) + FfiConverterBool.write(value.queueActions, into: &buf) + FfiConverterBool.write(value.queueAttachments, into: &buf) + FfiConverterBool.write(value.cleanAttachmentText, into: &buf) + FfiConverterBool.write(value.queueEditLease, into: &buf) + FfiConverterBool.write(value.queuedAttachments, into: &buf) + FfiConverterOptionBool.write(value.midTurnSteering, into: &buf) } } @@ -6005,33 +6455,31 @@ public struct FfiConverterTypeOutgoingAttachment: FfiConverterRustBuffer { #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeOutgoingAttachment_lift(_ buf: RustBuffer) throws -> OutgoingAttachment { - return try FfiConverterTypeOutgoingAttachment.lift(buf) +public func FfiConverterTypeHostCapabilities_lift(_ buf: RustBuffer) throws -> HostCapabilities { + return try FfiConverterTypeHostCapabilities.lift(buf) } #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeOutgoingAttachment_lower(_ value: OutgoingAttachment) -> RustBuffer { - return FfiConverterTypeOutgoingAttachment.lower(value) +public func FfiConverterTypeHostCapabilities_lower(_ value: HostCapabilities) -> RustBuffer { + return FfiConverterTypeHostCapabilities.lower(value) } -public struct ParsedUserMessage: Equatable, Hashable { - /** - * Visible prompt (trailer + Appshot context stripped). - */ - public var text: String - public var images: [UserImage] +public struct HostInfo: Equatable, Hashable { + public var deviceId: String + public var name: String? + public var online: Bool + public var capabilities: HostCapabilities // Default memberwise initializers are never public by default, so we // declare one manually. - public init( - /** - * Visible prompt (trailer + Appshot context stripped). - */text: String, images: [UserImage]) { - self.text = text - self.images = images + public init(deviceId: String, name: String?, online: Bool, capabilities: HostCapabilities) { + self.deviceId = deviceId + self.name = name + self.online = online + self.capabilities = capabilities } @@ -6040,24 +6488,28 @@ public struct ParsedUserMessage: Equatable, Hashable { } #if compiler(>=6) -extension ParsedUserMessage: Sendable {} +extension HostInfo: Sendable {} #endif #if swift(>=5.8) @_documentation(visibility: private) #endif -public struct FfiConverterTypeParsedUserMessage: FfiConverterRustBuffer { - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> ParsedUserMessage { +public struct FfiConverterTypeHostInfo: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> HostInfo { return - try ParsedUserMessage( - text: FfiConverterString.read(from: &buf), - images: FfiConverterSequenceTypeUserImage.read(from: &buf) + try HostInfo( + deviceId: FfiConverterString.read(from: &buf), + name: FfiConverterOptionString.read(from: &buf), + online: FfiConverterBool.read(from: &buf), + capabilities: FfiConverterTypeHostCapabilities.read(from: &buf) ) } - public static func write(_ value: ParsedUserMessage, into buf: inout [UInt8]) { - FfiConverterString.write(value.text, into: &buf) - FfiConverterSequenceTypeUserImage.write(value.images, into: &buf) + public static func write(_ value: HostInfo, into buf: inout [UInt8]) { + FfiConverterString.write(value.deviceId, into: &buf) + FfiConverterOptionString.write(value.name, into: &buf) + FfiConverterBool.write(value.online, into: &buf) + FfiConverterTypeHostCapabilities.write(value.capabilities, into: &buf) } } @@ -6065,49 +6517,29 @@ public struct FfiConverterTypeParsedUserMessage: FfiConverterRustBuffer { #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeParsedUserMessage_lift(_ buf: RustBuffer) throws -> ParsedUserMessage { - return try FfiConverterTypeParsedUserMessage.lift(buf) +public func FfiConverterTypeHostInfo_lift(_ buf: RustBuffer) throws -> HostInfo { + return try FfiConverterTypeHostInfo.lift(buf) } #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeParsedUserMessage_lower(_ value: ParsedUserMessage) -> RustBuffer { - return FfiConverterTypeParsedUserMessage.lower(value) +public func FfiConverterTypeHostInfo_lower(_ value: HostInfo) -> RustBuffer { + return FfiConverterTypeHostInfo.lower(value) } -public struct PendingSend: Equatable, Hashable { - /** - * Client-minted id (the host's user entry reuses it). - */ - public var messageId: String - /** - * Raw content (attachment trailer included). - */ - public var text: String - public var visibleText: String - public var images: [String] - public var kind: PendingKind - public var sentAtMs: Int64 - public var state: SendState +public struct InputRequest: Equatable, Hashable { + public var entryId: String + public var requestId: String + public var questions: [UserInputQuestion] // Default memberwise initializers are never public by default, so we // declare one manually. - public init( - /** - * Client-minted id (the host's user entry reuses it). - */messageId: String, - /** - * Raw content (attachment trailer included). - */text: String, visibleText: String, images: [String], kind: PendingKind, sentAtMs: Int64, state: SendState) { - self.messageId = messageId - self.text = text - self.visibleText = visibleText - self.images = images - self.kind = kind - self.sentAtMs = sentAtMs - self.state = state + public init(entryId: String, requestId: String, questions: [UserInputQuestion]) { + self.entryId = entryId + self.requestId = requestId + self.questions = questions } @@ -6116,34 +6548,26 @@ public struct PendingSend: Equatable, Hashable { } #if compiler(>=6) -extension PendingSend: Sendable {} +extension InputRequest: Sendable {} #endif #if swift(>=5.8) @_documentation(visibility: private) #endif -public struct FfiConverterTypePendingSend: FfiConverterRustBuffer { - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> PendingSend { +public struct FfiConverterTypeInputRequest: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> InputRequest { return - try PendingSend( - messageId: FfiConverterString.read(from: &buf), - text: FfiConverterString.read(from: &buf), - visibleText: FfiConverterString.read(from: &buf), - images: FfiConverterSequenceString.read(from: &buf), - kind: FfiConverterTypePendingKind.read(from: &buf), - sentAtMs: FfiConverterInt64.read(from: &buf), - state: FfiConverterTypeSendState.read(from: &buf) + try InputRequest( + entryId: FfiConverterString.read(from: &buf), + requestId: FfiConverterString.read(from: &buf), + questions: FfiConverterSequenceTypeUserInputQuestion.read(from: &buf) ) } - public static func write(_ value: PendingSend, into buf: inout [UInt8]) { - FfiConverterString.write(value.messageId, into: &buf) - FfiConverterString.write(value.text, into: &buf) - FfiConverterString.write(value.visibleText, into: &buf) - FfiConverterSequenceString.write(value.images, into: &buf) - FfiConverterTypePendingKind.write(value.kind, into: &buf) - FfiConverterInt64.write(value.sentAtMs, into: &buf) - FfiConverterTypeSendState.write(value.state, into: &buf) + public static func write(_ value: InputRequest, into buf: inout [UInt8]) { + FfiConverterString.write(value.entryId, into: &buf) + FfiConverterString.write(value.requestId, into: &buf) + FfiConverterSequenceTypeUserInputQuestion.write(value.questions, into: &buf) } } @@ -6151,35 +6575,38 @@ public struct FfiConverterTypePendingSend: FfiConverterRustBuffer { #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypePendingSend_lift(_ buf: RustBuffer) throws -> PendingSend { - return try FfiConverterTypePendingSend.lift(buf) +public func FfiConverterTypeInputRequest_lift(_ buf: RustBuffer) throws -> InputRequest { + return try FfiConverterTypeInputRequest.lift(buf) } #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypePendingSend_lower(_ value: PendingSend) -> RustBuffer { - return FfiConverterTypePendingSend.lower(value) +public func FfiConverterTypeInputRequest_lower(_ value: InputRequest) -> RustBuffer { + return FfiConverterTypeInputRequest.lower(value) } -public struct ProjectRef: Equatable, Hashable { - public var id: String - public var name: String - /** - * Stable palette slot (0..PROJECT_COLOR_COUNT). - */ - public var colorIndex: UInt32 +/** + * A tappable link region (one per fragment; multi-line links repeat). + */ +public struct LinkHit: Equatable, Hashable { + public var x: Float + public var y: Float + public var w: Float + public var h: Float + public var url: String + public var scroller: UInt32? // Default memberwise initializers are never public by default, so we // declare one manually. - public init(id: String, name: String, - /** - * Stable palette slot (0..PROJECT_COLOR_COUNT). - */colorIndex: UInt32) { - self.id = id - self.name = name - self.colorIndex = colorIndex + public init(x: Float, y: Float, w: Float, h: Float, url: String, scroller: UInt32?) { + self.x = x + self.y = y + self.w = w + self.h = h + self.url = url + self.scroller = scroller } @@ -6188,86 +6615,90 @@ public struct ProjectRef: Equatable, Hashable { } #if compiler(>=6) -extension ProjectRef: Sendable {} +extension LinkHit: Sendable {} #endif #if swift(>=5.8) @_documentation(visibility: private) #endif -public struct FfiConverterTypeProjectRef: FfiConverterRustBuffer { - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> ProjectRef { +public struct FfiConverterTypeLinkHit: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> LinkHit { return - try ProjectRef( - id: FfiConverterString.read(from: &buf), - name: FfiConverterString.read(from: &buf), - colorIndex: FfiConverterUInt32.read(from: &buf) + try LinkHit( + x: FfiConverterFloat.read(from: &buf), + y: FfiConverterFloat.read(from: &buf), + w: FfiConverterFloat.read(from: &buf), + h: FfiConverterFloat.read(from: &buf), + url: FfiConverterString.read(from: &buf), + scroller: FfiConverterOptionUInt32.read(from: &buf) ) } - public static func write(_ value: ProjectRef, into buf: inout [UInt8]) { - FfiConverterString.write(value.id, into: &buf) - FfiConverterString.write(value.name, into: &buf) - FfiConverterUInt32.write(value.colorIndex, into: &buf) - } -} - - -#if swift(>=5.8) + public static func write(_ value: LinkHit, into buf: inout [UInt8]) { + FfiConverterFloat.write(value.x, into: &buf) + FfiConverterFloat.write(value.y, into: &buf) + FfiConverterFloat.write(value.w, into: &buf) + FfiConverterFloat.write(value.h, into: &buf) + FfiConverterString.write(value.url, into: &buf) + FfiConverterOptionUInt32.write(value.scroller, into: &buf) + } +} + + +#if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeProjectRef_lift(_ buf: RustBuffer) throws -> ProjectRef { - return try FfiConverterTypeProjectRef.lift(buf) +public func FfiConverterTypeLinkHit_lift(_ buf: RustBuffer) throws -> LinkHit { + return try FfiConverterTypeLinkHit.lift(buf) } #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeProjectRef_lower(_ value: ProjectRef) -> RustBuffer { - return FfiConverterTypeProjectRef.lower(value) +public func FfiConverterTypeLinkHit_lower(_ value: LinkHit) -> RustBuffer { + return FfiConverterTypeLinkHit.lower(value) } -public struct ProjectView: Equatable, Hashable { - public var id: String - public var name: String - public var path: String - public var colorIndex: UInt32 - public var deviceId: String - public var deviceName: String? - public var deviceOnline: Bool - public var gitDetected: Bool - public var createdAtMs: Int64 +public struct LiveStatus: Equatable, Hashable { /** - * Most urgent indicator among its active sessions. + * Display status (an in-flight send reads Working). */ public var indicator: ChatIndicator - public var unseenCount: UInt32 /** - * Active sessions, recency order. + * A turn is actually running on the host (sends queue/steer). */ - public var sessions: [SessionRow] + public var turnRunning: Bool + public var workingSinceMs: Int64? + /** + * The newest transcript entry is streaming. + */ + public var streaming: Bool + /** + * Stop is meaningful. + */ + public var canInterrupt: Bool // Default memberwise initializers are never public by default, so we // declare one manually. - public init(id: String, name: String, path: String, colorIndex: UInt32, deviceId: String, deviceName: String?, deviceOnline: Bool, gitDetected: Bool, createdAtMs: Int64, + public init( /** - * Most urgent indicator among its active sessions. - */indicator: ChatIndicator, unseenCount: UInt32, + * Display status (an in-flight send reads Working). + */indicator: ChatIndicator, /** - * Active sessions, recency order. - */sessions: [SessionRow]) { - self.id = id - self.name = name - self.path = path - self.colorIndex = colorIndex - self.deviceId = deviceId - self.deviceName = deviceName - self.deviceOnline = deviceOnline - self.gitDetected = gitDetected - self.createdAtMs = createdAtMs + * A turn is actually running on the host (sends queue/steer). + */turnRunning: Bool, workingSinceMs: Int64?, + /** + * The newest transcript entry is streaming. + */streaming: Bool, + /** + * Stop is meaningful. + */canInterrupt: Bool) { self.indicator = indicator - self.unseenCount = unseenCount - self.sessions = sessions + self.turnRunning = turnRunning + self.workingSinceMs = workingSinceMs + self.streaming = streaming + self.canInterrupt = canInterrupt } @@ -6276,44 +6707,30 @@ public struct ProjectView: Equatable, Hashable { } #if compiler(>=6) -extension ProjectView: Sendable {} +extension LiveStatus: Sendable {} #endif #if swift(>=5.8) @_documentation(visibility: private) #endif -public struct FfiConverterTypeProjectView: FfiConverterRustBuffer { - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> ProjectView { +public struct FfiConverterTypeLiveStatus: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> LiveStatus { return - try ProjectView( - id: FfiConverterString.read(from: &buf), - name: FfiConverterString.read(from: &buf), - path: FfiConverterString.read(from: &buf), - colorIndex: FfiConverterUInt32.read(from: &buf), - deviceId: FfiConverterString.read(from: &buf), - deviceName: FfiConverterOptionString.read(from: &buf), - deviceOnline: FfiConverterBool.read(from: &buf), - gitDetected: FfiConverterBool.read(from: &buf), - createdAtMs: FfiConverterInt64.read(from: &buf), + try LiveStatus( indicator: FfiConverterTypeChatIndicator.read(from: &buf), - unseenCount: FfiConverterUInt32.read(from: &buf), - sessions: FfiConverterSequenceTypeSessionRow.read(from: &buf) + turnRunning: FfiConverterBool.read(from: &buf), + workingSinceMs: FfiConverterOptionInt64.read(from: &buf), + streaming: FfiConverterBool.read(from: &buf), + canInterrupt: FfiConverterBool.read(from: &buf) ) } - public static func write(_ value: ProjectView, into buf: inout [UInt8]) { - FfiConverterString.write(value.id, into: &buf) - FfiConverterString.write(value.name, into: &buf) - FfiConverterString.write(value.path, into: &buf) - FfiConverterUInt32.write(value.colorIndex, into: &buf) - FfiConverterString.write(value.deviceId, into: &buf) - FfiConverterOptionString.write(value.deviceName, into: &buf) - FfiConverterBool.write(value.deviceOnline, into: &buf) - FfiConverterBool.write(value.gitDetected, into: &buf) - FfiConverterInt64.write(value.createdAtMs, into: &buf) + public static func write(_ value: LiveStatus, into buf: inout [UInt8]) { FfiConverterTypeChatIndicator.write(value.indicator, into: &buf) - FfiConverterUInt32.write(value.unseenCount, into: &buf) - FfiConverterSequenceTypeSessionRow.write(value.sessions, into: &buf) + FfiConverterBool.write(value.turnRunning, into: &buf) + FfiConverterOptionInt64.write(value.workingSinceMs, into: &buf) + FfiConverterBool.write(value.streaming, into: &buf) + FfiConverterBool.write(value.canInterrupt, into: &buf) } } @@ -6321,37 +6738,47 @@ public struct FfiConverterTypeProjectView: FfiConverterRustBuffer { #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeProjectView_lift(_ buf: RustBuffer) throws -> ProjectView { - return try FfiConverterTypeProjectView.lift(buf) +public func FfiConverterTypeLiveStatus_lift(_ buf: RustBuffer) throws -> LiveStatus { + return try FfiConverterTypeLiveStatus.lift(buf) } #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeProjectView_lower(_ value: ProjectView) -> RustBuffer { - return FfiConverterTypeProjectView.lower(value) +public func FfiConverterTypeLiveStatus_lower(_ value: LiveStatus) -> RustBuffer { + return FfiConverterTypeLiveStatus.lower(value) } -public struct PullRequest: Equatable, Hashable { - public var provider: String - public var number: UInt64 - public var title: String - public var url: String - public var state: PullRequestState - public var baseRef: String - public var headRef: String +public struct ModelInfo: Equatable, Hashable { + public var id: String + public var label: String + public var description: String? + /** + * Wire effort values; empty = no effort ladder. + */ + public var reasoningLevels: [String] + public var options: [ModelOption] + /** + * The picker's default effort for this model. + */ + public var defaultReasoning: String? // Default memberwise initializers are never public by default, so we // declare one manually. - public init(provider: String, number: UInt64, title: String, url: String, state: PullRequestState, baseRef: String, headRef: String) { - self.provider = provider - self.number = number - self.title = title - self.url = url - self.state = state - self.baseRef = baseRef - self.headRef = headRef + public init(id: String, label: String, description: String?, + /** + * Wire effort values; empty = no effort ladder. + */reasoningLevels: [String], options: [ModelOption], + /** + * The picker's default effort for this model. + */defaultReasoning: String?) { + self.id = id + self.label = label + self.description = description + self.reasoningLevels = reasoningLevels + self.options = options + self.defaultReasoning = defaultReasoning } @@ -6360,34 +6787,32 @@ public struct PullRequest: Equatable, Hashable { } #if compiler(>=6) -extension PullRequest: Sendable {} +extension ModelInfo: Sendable {} #endif #if swift(>=5.8) @_documentation(visibility: private) #endif -public struct FfiConverterTypePullRequest: FfiConverterRustBuffer { - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> PullRequest { +public struct FfiConverterTypeModelInfo: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> ModelInfo { return - try PullRequest( - provider: FfiConverterString.read(from: &buf), - number: FfiConverterUInt64.read(from: &buf), - title: FfiConverterString.read(from: &buf), - url: FfiConverterString.read(from: &buf), - state: FfiConverterTypePullRequestState.read(from: &buf), - baseRef: FfiConverterString.read(from: &buf), - headRef: FfiConverterString.read(from: &buf) + try ModelInfo( + id: FfiConverterString.read(from: &buf), + label: FfiConverterString.read(from: &buf), + description: FfiConverterOptionString.read(from: &buf), + reasoningLevels: FfiConverterSequenceString.read(from: &buf), + options: FfiConverterSequenceTypeModelOption.read(from: &buf), + defaultReasoning: FfiConverterOptionString.read(from: &buf) ) } - public static func write(_ value: PullRequest, into buf: inout [UInt8]) { - FfiConverterString.write(value.provider, into: &buf) - FfiConverterUInt64.write(value.number, into: &buf) - FfiConverterString.write(value.title, into: &buf) - FfiConverterString.write(value.url, into: &buf) - FfiConverterTypePullRequestState.write(value.state, into: &buf) - FfiConverterString.write(value.baseRef, into: &buf) - FfiConverterString.write(value.headRef, into: &buf) + public static func write(_ value: ModelInfo, into buf: inout [UInt8]) { + FfiConverterString.write(value.id, into: &buf) + FfiConverterString.write(value.label, into: &buf) + FfiConverterOptionString.write(value.description, into: &buf) + FfiConverterSequenceString.write(value.reasoningLevels, into: &buf) + FfiConverterSequenceTypeModelOption.write(value.options, into: &buf) + FfiConverterOptionString.write(value.defaultReasoning, into: &buf) } } @@ -6395,29 +6820,31 @@ public struct FfiConverterTypePullRequest: FfiConverterRustBuffer { #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypePullRequest_lift(_ buf: RustBuffer) throws -> PullRequest { - return try FfiConverterTypePullRequest.lift(buf) +public func FfiConverterTypeModelInfo_lift(_ buf: RustBuffer) throws -> ModelInfo { + return try FfiConverterTypeModelInfo.lift(buf) } #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypePullRequest_lower(_ value: PullRequest) -> RustBuffer { - return FfiConverterTypePullRequest.lower(value) +public func FfiConverterTypeModelInfo_lower(_ value: ModelInfo) -> RustBuffer { + return FfiConverterTypeModelInfo.lower(value) } -public struct PullRequestGroups: Equatable, Hashable { - public var `open`: [SessionRow] - public var merged: [SessionRow] - public var closed: [SessionRow] +public struct ModelOption: Equatable, Hashable { + public var id: String + public var label: String + public var choices: [ModelOptionChoice] + public var defaultChoice: String // Default memberwise initializers are never public by default, so we // declare one manually. - public init(`open`: [SessionRow], merged: [SessionRow], closed: [SessionRow]) { - self.`open` = `open` - self.merged = merged - self.closed = closed + public init(id: String, label: String, choices: [ModelOptionChoice], defaultChoice: String) { + self.id = id + self.label = label + self.choices = choices + self.defaultChoice = defaultChoice } @@ -6426,26 +6853,28 @@ public struct PullRequestGroups: Equatable, Hashable { } #if compiler(>=6) -extension PullRequestGroups: Sendable {} +extension ModelOption: Sendable {} #endif #if swift(>=5.8) @_documentation(visibility: private) #endif -public struct FfiConverterTypePullRequestGroups: FfiConverterRustBuffer { - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> PullRequestGroups { +public struct FfiConverterTypeModelOption: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> ModelOption { return - try PullRequestGroups( - open: FfiConverterSequenceTypeSessionRow.read(from: &buf), - merged: FfiConverterSequenceTypeSessionRow.read(from: &buf), - closed: FfiConverterSequenceTypeSessionRow.read(from: &buf) + try ModelOption( + id: FfiConverterString.read(from: &buf), + label: FfiConverterString.read(from: &buf), + choices: FfiConverterSequenceTypeModelOptionChoice.read(from: &buf), + defaultChoice: FfiConverterString.read(from: &buf) ) } - public static func write(_ value: PullRequestGroups, into buf: inout [UInt8]) { - FfiConverterSequenceTypeSessionRow.write(value.`open`, into: &buf) - FfiConverterSequenceTypeSessionRow.write(value.merged, into: &buf) - FfiConverterSequenceTypeSessionRow.write(value.closed, into: &buf) + public static func write(_ value: ModelOption, into buf: inout [UInt8]) { + FfiConverterString.write(value.id, into: &buf) + FfiConverterString.write(value.label, into: &buf) + FfiConverterSequenceTypeModelOptionChoice.write(value.choices, into: &buf) + FfiConverterString.write(value.defaultChoice, into: &buf) } } @@ -6453,32 +6882,27 @@ public struct FfiConverterTypePullRequestGroups: FfiConverterRustBuffer { #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypePullRequestGroups_lift(_ buf: RustBuffer) throws -> PullRequestGroups { - return try FfiConverterTypePullRequestGroups.lift(buf) +public func FfiConverterTypeModelOption_lift(_ buf: RustBuffer) throws -> ModelOption { + return try FfiConverterTypeModelOption.lift(buf) } #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypePullRequestGroups_lower(_ value: PullRequestGroups) -> RustBuffer { - return FfiConverterTypePullRequestGroups.lower(value) +public func FfiConverterTypeModelOption_lower(_ value: ModelOption) -> RustBuffer { + return FfiConverterTypeModelOption.lower(value) } -/** - * Which session notifications this device wants. - */ -public struct PushPrefs: Equatable, Hashable { - public var done: Bool - public var input: Bool - public var failed: Bool +public struct ModelOptionChoice: Equatable, Hashable { + public var id: String + public var label: String // Default memberwise initializers are never public by default, so we // declare one manually. - public init(done: Bool, input: Bool, failed: Bool) { - self.done = done - self.input = input - self.failed = failed + public init(id: String, label: String) { + self.id = id + self.label = label } @@ -6487,26 +6911,24 @@ public struct PushPrefs: Equatable, Hashable { } #if compiler(>=6) -extension PushPrefs: Sendable {} +extension ModelOptionChoice: Sendable {} #endif #if swift(>=5.8) @_documentation(visibility: private) #endif -public struct FfiConverterTypePushPrefs: FfiConverterRustBuffer { - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> PushPrefs { +public struct FfiConverterTypeModelOptionChoice: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> ModelOptionChoice { return - try PushPrefs( - done: FfiConverterBool.read(from: &buf), - input: FfiConverterBool.read(from: &buf), - failed: FfiConverterBool.read(from: &buf) + try ModelOptionChoice( + id: FfiConverterString.read(from: &buf), + label: FfiConverterString.read(from: &buf) ) } - public static func write(_ value: PushPrefs, into buf: inout [UInt8]) { - FfiConverterBool.write(value.done, into: &buf) - FfiConverterBool.write(value.input, into: &buf) - FfiConverterBool.write(value.failed, into: &buf) + public static func write(_ value: ModelOptionChoice, into buf: inout [UInt8]) { + FfiConverterString.write(value.id, into: &buf) + FfiConverterString.write(value.label, into: &buf) } } @@ -6514,39 +6936,45 @@ public struct FfiConverterTypePushPrefs: FfiConverterRustBuffer { #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypePushPrefs_lift(_ buf: RustBuffer) throws -> PushPrefs { - return try FfiConverterTypePushPrefs.lift(buf) +public func FfiConverterTypeModelOptionChoice_lift(_ buf: RustBuffer) throws -> ModelOptionChoice { + return try FfiConverterTypeModelOptionChoice.lift(buf) } #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypePushPrefs_lower(_ value: PushPrefs) -> RustBuffer { - return FfiConverterTypePushPrefs.lower(value) +public func FfiConverterTypeModelOptionChoice_lower(_ value: ModelOptionChoice) -> RustBuffer { + return FfiConverterTypeModelOptionChoice.lower(value) } -public struct QueueEditLease: Equatable, Hashable { - public var rowId: String - public var leaseId: String +public struct NewSession: Equatable, Hashable { + public var target: SessionTarget + public var config: ChatConfig? /** - * Row text at lease time. + * Branch stamped from the first frame (picked ref). */ - public var text: String - public var baseTextHash: String - public var expiresAtMs: Int64 + public var branch: String? + /** + * Checkout override (an existing worktree's path). + */ + public var cwd: String? + public var title: String? // Default memberwise initializers are never public by default, so we // declare one manually. - public init(rowId: String, leaseId: String, + public init(target: SessionTarget, config: ChatConfig?, /** - * Row text at lease time. - */text: String, baseTextHash: String, expiresAtMs: Int64) { - self.rowId = rowId - self.leaseId = leaseId - self.text = text - self.baseTextHash = baseTextHash - self.expiresAtMs = expiresAtMs + * Branch stamped from the first frame (picked ref). + */branch: String?, + /** + * Checkout override (an existing worktree's path). + */cwd: String?, title: String?) { + self.target = target + self.config = config + self.branch = branch + self.cwd = cwd + self.title = title } @@ -6555,30 +6983,30 @@ public struct QueueEditLease: Equatable, Hashable { } #if compiler(>=6) -extension QueueEditLease: Sendable {} +extension NewSession: Sendable {} #endif #if swift(>=5.8) @_documentation(visibility: private) #endif -public struct FfiConverterTypeQueueEditLease: FfiConverterRustBuffer { - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> QueueEditLease { +public struct FfiConverterTypeNewSession: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> NewSession { return - try QueueEditLease( - rowId: FfiConverterString.read(from: &buf), - leaseId: FfiConverterString.read(from: &buf), - text: FfiConverterString.read(from: &buf), - baseTextHash: FfiConverterString.read(from: &buf), - expiresAtMs: FfiConverterInt64.read(from: &buf) + try NewSession( + target: FfiConverterTypeSessionTarget.read(from: &buf), + config: FfiConverterOptionTypeChatConfig.read(from: &buf), + branch: FfiConverterOptionString.read(from: &buf), + cwd: FfiConverterOptionString.read(from: &buf), + title: FfiConverterOptionString.read(from: &buf) ) } - public static func write(_ value: QueueEditLease, into buf: inout [UInt8]) { - FfiConverterString.write(value.rowId, into: &buf) - FfiConverterString.write(value.leaseId, into: &buf) - FfiConverterString.write(value.text, into: &buf) - FfiConverterString.write(value.baseTextHash, into: &buf) - FfiConverterInt64.write(value.expiresAtMs, into: &buf) + public static func write(_ value: NewSession, into buf: inout [UInt8]) { + FfiConverterTypeSessionTarget.write(value.target, into: &buf) + FfiConverterOptionTypeChatConfig.write(value.config, into: &buf) + FfiConverterOptionString.write(value.branch, into: &buf) + FfiConverterOptionString.write(value.cwd, into: &buf) + FfiConverterOptionString.write(value.title, into: &buf) } } @@ -6586,63 +7014,35 @@ public struct FfiConverterTypeQueueEditLease: FfiConverterRustBuffer { #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeQueueEditLease_lift(_ buf: RustBuffer) throws -> QueueEditLease { - return try FfiConverterTypeQueueEditLease.lift(buf) +public func FfiConverterTypeNewSession_lift(_ buf: RustBuffer) throws -> NewSession { + return try FfiConverterTypeNewSession.lift(buf) } #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeQueueEditLease_lower(_ value: QueueEditLease) -> RustBuffer { - return FfiConverterTypeQueueEditLease.lower(value) +public func FfiConverterTypeNewSession_lower(_ value: NewSession) -> RustBuffer { + return FfiConverterTypeNewSession.lower(value) } -public struct QueueItem: Equatable, Hashable { - public var id: String - /** - * Raw row text (what the host will send). - */ - public var text: String - /** - * User-visible text (trailer/Appshot context stripped). - */ - public var visibleText: String - public var attachments: [String] - public var holdForTurnEnd: Bool - public var issuedBy: String - public var fromThisDevice: Bool - public var issuedAtMs: Int64 - public var editedAtMs: Int64? - public var gate: QueueGate? +public struct OrbFrame: Equatable, Hashable { /** - * A Send-now / Remove is awaiting the host's acknowledgement. + * Edge length of the artwork box, in logical points. */ - public var actionPending: Bool + public var size: Float + public var lines: [Float] + public var dots: [Float] // Default memberwise initializers are never public by default, so we // declare one manually. - public init(id: String, - /** - * Raw row text (what the host will send). - */text: String, - /** - * User-visible text (trailer/Appshot context stripped). - */visibleText: String, attachments: [String], holdForTurnEnd: Bool, issuedBy: String, fromThisDevice: Bool, issuedAtMs: Int64, editedAtMs: Int64?, gate: QueueGate?, + public init( /** - * A Send-now / Remove is awaiting the host's acknowledgement. - */actionPending: Bool) { - self.id = id - self.text = text - self.visibleText = visibleText - self.attachments = attachments - self.holdForTurnEnd = holdForTurnEnd - self.issuedBy = issuedBy - self.fromThisDevice = fromThisDevice - self.issuedAtMs = issuedAtMs - self.editedAtMs = editedAtMs - self.gate = gate - self.actionPending = actionPending + * Edge length of the artwork box, in logical points. + */size: Float, lines: [Float], dots: [Float]) { + self.size = size + self.lines = lines + self.dots = dots } @@ -6651,42 +7051,26 @@ public struct QueueItem: Equatable, Hashable { } #if compiler(>=6) -extension QueueItem: Sendable {} +extension OrbFrame: Sendable {} #endif #if swift(>=5.8) @_documentation(visibility: private) #endif -public struct FfiConverterTypeQueueItem: FfiConverterRustBuffer { - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> QueueItem { +public struct FfiConverterTypeOrbFrame: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> OrbFrame { return - try QueueItem( - id: FfiConverterString.read(from: &buf), - text: FfiConverterString.read(from: &buf), - visibleText: FfiConverterString.read(from: &buf), - attachments: FfiConverterSequenceString.read(from: &buf), - holdForTurnEnd: FfiConverterBool.read(from: &buf), - issuedBy: FfiConverterString.read(from: &buf), - fromThisDevice: FfiConverterBool.read(from: &buf), - issuedAtMs: FfiConverterInt64.read(from: &buf), - editedAtMs: FfiConverterOptionInt64.read(from: &buf), - gate: FfiConverterOptionTypeQueueGate.read(from: &buf), - actionPending: FfiConverterBool.read(from: &buf) + try OrbFrame( + size: FfiConverterFloat.read(from: &buf), + lines: FfiConverterSequenceFloat.read(from: &buf), + dots: FfiConverterSequenceFloat.read(from: &buf) ) } - public static func write(_ value: QueueItem, into buf: inout [UInt8]) { - FfiConverterString.write(value.id, into: &buf) - FfiConverterString.write(value.text, into: &buf) - FfiConverterString.write(value.visibleText, into: &buf) - FfiConverterSequenceString.write(value.attachments, into: &buf) - FfiConverterBool.write(value.holdForTurnEnd, into: &buf) - FfiConverterString.write(value.issuedBy, into: &buf) - FfiConverterBool.write(value.fromThisDevice, into: &buf) - FfiConverterInt64.write(value.issuedAtMs, into: &buf) - FfiConverterOptionInt64.write(value.editedAtMs, into: &buf) - FfiConverterOptionTypeQueueGate.write(value.gate, into: &buf) - FfiConverterBool.write(value.actionPending, into: &buf) + public static func write(_ value: OrbFrame, into buf: inout [UInt8]) { + FfiConverterFloat.write(value.size, into: &buf) + FfiConverterSequenceFloat.write(value.lines, into: &buf) + FfiConverterSequenceFloat.write(value.dots, into: &buf) } } @@ -6694,41 +7078,29 @@ public struct FfiConverterTypeQueueItem: FfiConverterRustBuffer { #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeQueueItem_lift(_ buf: RustBuffer) throws -> QueueItem { - return try FfiConverterTypeQueueItem.lift(buf) +public func FfiConverterTypeOrbFrame_lift(_ buf: RustBuffer) throws -> OrbFrame { + return try FfiConverterTypeOrbFrame.lift(buf) } #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeQueueItem_lower(_ value: QueueItem) -> RustBuffer { - return FfiConverterTypeQueueItem.lower(value) +public func FfiConverterTypeOrbFrame_lower(_ value: OrbFrame) -> RustBuffer { + return FfiConverterTypeOrbFrame.lower(value) } -public struct RepoRef: Equatable, Hashable { +public struct OutgoingAttachment: Equatable, Hashable { public var name: String - /** - * Checked out in the repo's main folder right now. - */ - public var current: Bool - /** - * Linked worktree this branch is checked out in, if any. - */ - public var worktreePath: String? + public var mimeType: String + public var data: Data // Default memberwise initializers are never public by default, so we // declare one manually. - public init(name: String, - /** - * Checked out in the repo's main folder right now. - */current: Bool, - /** - * Linked worktree this branch is checked out in, if any. - */worktreePath: String?) { + public init(name: String, mimeType: String, data: Data) { self.name = name - self.current = current - self.worktreePath = worktreePath + self.mimeType = mimeType + self.data = data } @@ -6737,26 +7109,26 @@ public struct RepoRef: Equatable, Hashable { } #if compiler(>=6) -extension RepoRef: Sendable {} +extension OutgoingAttachment: Sendable {} #endif #if swift(>=5.8) @_documentation(visibility: private) #endif -public struct FfiConverterTypeRepoRef: FfiConverterRustBuffer { - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> RepoRef { +public struct FfiConverterTypeOutgoingAttachment: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> OutgoingAttachment { return - try RepoRef( + try OutgoingAttachment( name: FfiConverterString.read(from: &buf), - current: FfiConverterBool.read(from: &buf), - worktreePath: FfiConverterOptionString.read(from: &buf) + mimeType: FfiConverterString.read(from: &buf), + data: FfiConverterData.read(from: &buf) ) } - public static func write(_ value: RepoRef, into buf: inout [UInt8]) { + public static func write(_ value: OutgoingAttachment, into buf: inout [UInt8]) { FfiConverterString.write(value.name, into: &buf) - FfiConverterBool.write(value.current, into: &buf) - FfiConverterOptionString.write(value.worktreePath, into: &buf) + FfiConverterString.write(value.mimeType, into: &buf) + FfiConverterData.write(value.data, into: &buf) } } @@ -6764,35 +7136,33 @@ public struct FfiConverterTypeRepoRef: FfiConverterRustBuffer { #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeRepoRef_lift(_ buf: RustBuffer) throws -> RepoRef { - return try FfiConverterTypeRepoRef.lift(buf) +public func FfiConverterTypeOutgoingAttachment_lift(_ buf: RustBuffer) throws -> OutgoingAttachment { + return try FfiConverterTypeOutgoingAttachment.lift(buf) } #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeRepoRef_lower(_ value: RepoRef) -> RustBuffer { - return FfiConverterTypeRepoRef.lower(value) +public func FfiConverterTypeOutgoingAttachment_lower(_ value: OutgoingAttachment) -> RustBuffer { + return FfiConverterTypeOutgoingAttachment.lower(value) } -public struct RoomState: Equatable, Hashable { - public var connected: Bool - public var retryAtMs: Int64? +public struct ParsedUserMessage: Equatable, Hashable { /** - * Graced degradation for this chat. + * Visible prompt (trailer + Appshot context stripped). */ - public var degraded: Bool + public var text: String + public var images: [UserImage] // Default memberwise initializers are never public by default, so we // declare one manually. - public init(connected: Bool, retryAtMs: Int64?, + public init( /** - * Graced degradation for this chat. - */degraded: Bool) { - self.connected = connected - self.retryAtMs = retryAtMs - self.degraded = degraded + * Visible prompt (trailer + Appshot context stripped). + */text: String, images: [UserImage]) { + self.text = text + self.images = images } @@ -6801,26 +7171,24 @@ public struct RoomState: Equatable, Hashable { } #if compiler(>=6) -extension RoomState: Sendable {} +extension ParsedUserMessage: Sendable {} #endif #if swift(>=5.8) @_documentation(visibility: private) #endif -public struct FfiConverterTypeRoomState: FfiConverterRustBuffer { - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> RoomState { +public struct FfiConverterTypeParsedUserMessage: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> ParsedUserMessage { return - try RoomState( - connected: FfiConverterBool.read(from: &buf), - retryAtMs: FfiConverterOptionInt64.read(from: &buf), - degraded: FfiConverterBool.read(from: &buf) + try ParsedUserMessage( + text: FfiConverterString.read(from: &buf), + images: FfiConverterSequenceTypeUserImage.read(from: &buf) ) } - public static func write(_ value: RoomState, into buf: inout [UInt8]) { - FfiConverterBool.write(value.connected, into: &buf) - FfiConverterOptionInt64.write(value.retryAtMs, into: &buf) - FfiConverterBool.write(value.degraded, into: &buf) + public static func write(_ value: ParsedUserMessage, into buf: inout [UInt8]) { + FfiConverterString.write(value.text, into: &buf) + FfiConverterSequenceTypeUserImage.write(value.images, into: &buf) } } @@ -6828,56 +7196,49 @@ public struct FfiConverterTypeRoomState: FfiConverterRustBuffer { #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeRoomState_lift(_ buf: RustBuffer) throws -> RoomState { - return try FfiConverterTypeRoomState.lift(buf) +public func FfiConverterTypeParsedUserMessage_lift(_ buf: RustBuffer) throws -> ParsedUserMessage { + return try FfiConverterTypeParsedUserMessage.lift(buf) } #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeRoomState_lower(_ value: RoomState) -> RustBuffer { - return FfiConverterTypeRoomState.lower(value) +public func FfiConverterTypeParsedUserMessage_lower(_ value: ParsedUserMessage) -> RustBuffer { + return FfiConverterTypeParsedUserMessage.lower(value) } -/** - * Everything needed to paint a row at its current width. - */ -public struct RowDisplay: Equatable, Hashable { - public var key: UInt64 - public var version: UInt64 - public var width: Float - public var height: Float - public var text: String - public var runs: [TextRun] - public var boxes: [BoxPrim] - public var links: [LinkHit] - public var scrollers: [Scroller] - public var widgets: [Widget] - public var fades: [Fade] +public struct PendingSend: Equatable, Hashable { /** - * Plain text for copy / accessibility. + * Client-minted id (the host's user entry reuses it). */ - public var copyText: String + public var messageId: String + /** + * Raw content (attachment trailer included). + */ + public var text: String + public var visibleText: String + public var images: [String] + public var kind: PendingKind + public var sentAtMs: Int64 + public var state: SendState // Default memberwise initializers are never public by default, so we // declare one manually. - public init(key: UInt64, version: UInt64, width: Float, height: Float, text: String, runs: [TextRun], boxes: [BoxPrim], links: [LinkHit], scrollers: [Scroller], widgets: [Widget], fades: [Fade], + public init( /** - * Plain text for copy / accessibility. - */copyText: String) { - self.key = key - self.version = version - self.width = width - self.height = height + * Client-minted id (the host's user entry reuses it). + */messageId: String, + /** + * Raw content (attachment trailer included). + */text: String, visibleText: String, images: [String], kind: PendingKind, sentAtMs: Int64, state: SendState) { + self.messageId = messageId self.text = text - self.runs = runs - self.boxes = boxes - self.links = links - self.scrollers = scrollers - self.widgets = widgets - self.fades = fades - self.copyText = copyText + self.visibleText = visibleText + self.images = images + self.kind = kind + self.sentAtMs = sentAtMs + self.state = state } @@ -6886,44 +7247,34 @@ public struct RowDisplay: Equatable, Hashable { } #if compiler(>=6) -extension RowDisplay: Sendable {} +extension PendingSend: Sendable {} #endif #if swift(>=5.8) @_documentation(visibility: private) #endif -public struct FfiConverterTypeRowDisplay: FfiConverterRustBuffer { - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> RowDisplay { +public struct FfiConverterTypePendingSend: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> PendingSend { return - try RowDisplay( - key: FfiConverterUInt64.read(from: &buf), - version: FfiConverterUInt64.read(from: &buf), - width: FfiConverterFloat.read(from: &buf), - height: FfiConverterFloat.read(from: &buf), + try PendingSend( + messageId: FfiConverterString.read(from: &buf), text: FfiConverterString.read(from: &buf), - runs: FfiConverterSequenceTypeTextRun.read(from: &buf), - boxes: FfiConverterSequenceTypeBoxPrim.read(from: &buf), - links: FfiConverterSequenceTypeLinkHit.read(from: &buf), - scrollers: FfiConverterSequenceTypeScroller.read(from: &buf), - widgets: FfiConverterSequenceTypeWidget.read(from: &buf), - fades: FfiConverterSequenceTypeFade.read(from: &buf), - copyText: FfiConverterString.read(from: &buf) + visibleText: FfiConverterString.read(from: &buf), + images: FfiConverterSequenceString.read(from: &buf), + kind: FfiConverterTypePendingKind.read(from: &buf), + sentAtMs: FfiConverterInt64.read(from: &buf), + state: FfiConverterTypeSendState.read(from: &buf) ) } - public static func write(_ value: RowDisplay, into buf: inout [UInt8]) { - FfiConverterUInt64.write(value.key, into: &buf) - FfiConverterUInt64.write(value.version, into: &buf) - FfiConverterFloat.write(value.width, into: &buf) - FfiConverterFloat.write(value.height, into: &buf) + public static func write(_ value: PendingSend, into buf: inout [UInt8]) { + FfiConverterString.write(value.messageId, into: &buf) FfiConverterString.write(value.text, into: &buf) - FfiConverterSequenceTypeTextRun.write(value.runs, into: &buf) - FfiConverterSequenceTypeBoxPrim.write(value.boxes, into: &buf) - FfiConverterSequenceTypeLinkHit.write(value.links, into: &buf) - FfiConverterSequenceTypeScroller.write(value.scrollers, into: &buf) - FfiConverterSequenceTypeWidget.write(value.widgets, into: &buf) - FfiConverterSequenceTypeFade.write(value.fades, into: &buf) - FfiConverterString.write(value.copyText, into: &buf) + FfiConverterString.write(value.visibleText, into: &buf) + FfiConverterSequenceString.write(value.images, into: &buf) + FfiConverterTypePendingKind.write(value.kind, into: &buf) + FfiConverterInt64.write(value.sentAtMs, into: &buf) + FfiConverterTypeSendState.write(value.state, into: &buf) } } @@ -6931,44 +7282,35 @@ public struct FfiConverterTypeRowDisplay: FfiConverterRustBuffer { #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeRowDisplay_lift(_ buf: RustBuffer) throws -> RowDisplay { - return try FfiConverterTypeRowDisplay.lift(buf) +public func FfiConverterTypePendingSend_lift(_ buf: RustBuffer) throws -> PendingSend { + return try FfiConverterTypePendingSend.lift(buf) } #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeRowDisplay_lower(_ value: RowDisplay) -> RustBuffer { - return FfiConverterTypeRowDisplay.lower(value) +public func FfiConverterTypePendingSend_lower(_ value: PendingSend) -> RustBuffer { + return FfiConverterTypePendingSend.lower(value) } -/** - * A row's position in a frame. - */ -public struct RowPlacement: Equatable, Hashable { - public var index: UInt32 - public var key: UInt64 +public struct ProjectRef: Equatable, Hashable { + public var id: String + public var name: String /** - * Changes whenever the row's painted content may change (not its width). + * Stable palette slot (0..PROJECT_COLOR_COUNT). */ - public var version: UInt64 - public var y: Float - public var height: Float - public var kind: RowKind + public var colorIndex: UInt32 // Default memberwise initializers are never public by default, so we // declare one manually. - public init(index: UInt32, key: UInt64, + public init(id: String, name: String, /** - * Changes whenever the row's painted content may change (not its width). - */version: UInt64, y: Float, height: Float, kind: RowKind) { - self.index = index - self.key = key - self.version = version - self.y = y - self.height = height - self.kind = kind + * Stable palette slot (0..PROJECT_COLOR_COUNT). + */colorIndex: UInt32) { + self.id = id + self.name = name + self.colorIndex = colorIndex } @@ -6977,32 +7319,26 @@ public struct RowPlacement: Equatable, Hashable { } #if compiler(>=6) -extension RowPlacement: Sendable {} +extension ProjectRef: Sendable {} #endif #if swift(>=5.8) @_documentation(visibility: private) #endif -public struct FfiConverterTypeRowPlacement: FfiConverterRustBuffer { - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> RowPlacement { +public struct FfiConverterTypeProjectRef: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> ProjectRef { return - try RowPlacement( - index: FfiConverterUInt32.read(from: &buf), - key: FfiConverterUInt64.read(from: &buf), - version: FfiConverterUInt64.read(from: &buf), - y: FfiConverterFloat.read(from: &buf), - height: FfiConverterFloat.read(from: &buf), - kind: FfiConverterTypeRowKind.read(from: &buf) + try ProjectRef( + id: FfiConverterString.read(from: &buf), + name: FfiConverterString.read(from: &buf), + colorIndex: FfiConverterUInt32.read(from: &buf) ) } - public static func write(_ value: RowPlacement, into buf: inout [UInt8]) { - FfiConverterUInt32.write(value.index, into: &buf) - FfiConverterUInt64.write(value.key, into: &buf) - FfiConverterUInt64.write(value.version, into: &buf) - FfiConverterFloat.write(value.y, into: &buf) - FfiConverterFloat.write(value.height, into: &buf) - FfiConverterTypeRowKind.write(value.kind, into: &buf) + public static func write(_ value: ProjectRef, into buf: inout [UInt8]) { + FfiConverterString.write(value.id, into: &buf) + FfiConverterString.write(value.name, into: &buf) + FfiConverterUInt32.write(value.colorIndex, into: &buf) } } @@ -7010,38 +7346,75 @@ public struct FfiConverterTypeRowPlacement: FfiConverterRustBuffer { #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeRowPlacement_lift(_ buf: RustBuffer) throws -> RowPlacement { - return try FfiConverterTypeRowPlacement.lift(buf) +public func FfiConverterTypeProjectRef_lift(_ buf: RustBuffer) throws -> ProjectRef { + return try FfiConverterTypeProjectRef.lift(buf) } #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeRowPlacement_lower(_ value: RowPlacement) -> RustBuffer { - return FfiConverterTypeRowPlacement.lower(value) +public func FfiConverterTypeProjectRef_lower(_ value: ProjectRef) -> RustBuffer { + return FfiConverterTypeProjectRef.lower(value) } -/** - * A horizontally scrolling viewport (code blocks, wide tables). Primitives - * that reference it are positioned in its content coordinates, whose origin - * is the viewport's top-left. - */ -public struct Scroller: Equatable, Hashable { - public var x: Float - public var y: Float - public var w: Float - public var h: Float - public var contentWidth: Float +public struct ProjectView: Equatable, Hashable { + public var id: String + public var name: String + public var path: String + public var colorIndex: UInt32 + public var deviceId: String + public var deviceName: String? + public var deviceOnline: Bool + public var gitDetected: Bool + public var createdAtMs: Int64 + /** + * Shared by every checkout of one repository, on any device. + */ + public var groupKey: String + /** + * The repository group's name, shared like `color_index`. + */ + public var groupName: String + /** + * Most urgent indicator among its active sessions. + */ + public var indicator: ChatIndicator + public var unseenCount: UInt32 + /** + * Active sessions, recency order. + */ + public var sessions: [SessionRow] // Default memberwise initializers are never public by default, so we // declare one manually. - public init(x: Float, y: Float, w: Float, h: Float, contentWidth: Float) { - self.x = x - self.y = y - self.w = w - self.h = h - self.contentWidth = contentWidth + public init(id: String, name: String, path: String, colorIndex: UInt32, deviceId: String, deviceName: String?, deviceOnline: Bool, gitDetected: Bool, createdAtMs: Int64, + /** + * Shared by every checkout of one repository, on any device. + */groupKey: String, + /** + * The repository group's name, shared like `color_index`. + */groupName: String, + /** + * Most urgent indicator among its active sessions. + */indicator: ChatIndicator, unseenCount: UInt32, + /** + * Active sessions, recency order. + */sessions: [SessionRow]) { + self.id = id + self.name = name + self.path = path + self.colorIndex = colorIndex + self.deviceId = deviceId + self.deviceName = deviceName + self.deviceOnline = deviceOnline + self.gitDetected = gitDetected + self.createdAtMs = createdAtMs + self.groupKey = groupKey + self.groupName = groupName + self.indicator = indicator + self.unseenCount = unseenCount + self.sessions = sessions } @@ -7050,30 +7423,48 @@ public struct Scroller: Equatable, Hashable { } #if compiler(>=6) -extension Scroller: Sendable {} +extension ProjectView: Sendable {} #endif #if swift(>=5.8) @_documentation(visibility: private) #endif -public struct FfiConverterTypeScroller: FfiConverterRustBuffer { - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> Scroller { +public struct FfiConverterTypeProjectView: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> ProjectView { return - try Scroller( - x: FfiConverterFloat.read(from: &buf), - y: FfiConverterFloat.read(from: &buf), - w: FfiConverterFloat.read(from: &buf), - h: FfiConverterFloat.read(from: &buf), - contentWidth: FfiConverterFloat.read(from: &buf) + try ProjectView( + id: FfiConverterString.read(from: &buf), + name: FfiConverterString.read(from: &buf), + path: FfiConverterString.read(from: &buf), + colorIndex: FfiConverterUInt32.read(from: &buf), + deviceId: FfiConverterString.read(from: &buf), + deviceName: FfiConverterOptionString.read(from: &buf), + deviceOnline: FfiConverterBool.read(from: &buf), + gitDetected: FfiConverterBool.read(from: &buf), + createdAtMs: FfiConverterInt64.read(from: &buf), + groupKey: FfiConverterString.read(from: &buf), + groupName: FfiConverterString.read(from: &buf), + indicator: FfiConverterTypeChatIndicator.read(from: &buf), + unseenCount: FfiConverterUInt32.read(from: &buf), + sessions: FfiConverterSequenceTypeSessionRow.read(from: &buf) ) } - public static func write(_ value: Scroller, into buf: inout [UInt8]) { - FfiConverterFloat.write(value.x, into: &buf) - FfiConverterFloat.write(value.y, into: &buf) - FfiConverterFloat.write(value.w, into: &buf) - FfiConverterFloat.write(value.h, into: &buf) - FfiConverterFloat.write(value.contentWidth, into: &buf) + public static func write(_ value: ProjectView, into buf: inout [UInt8]) { + FfiConverterString.write(value.id, into: &buf) + FfiConverterString.write(value.name, into: &buf) + FfiConverterString.write(value.path, into: &buf) + FfiConverterUInt32.write(value.colorIndex, into: &buf) + FfiConverterString.write(value.deviceId, into: &buf) + FfiConverterOptionString.write(value.deviceName, into: &buf) + FfiConverterBool.write(value.deviceOnline, into: &buf) + FfiConverterBool.write(value.gitDetected, into: &buf) + FfiConverterInt64.write(value.createdAtMs, into: &buf) + FfiConverterString.write(value.groupKey, into: &buf) + FfiConverterString.write(value.groupName, into: &buf) + FfiConverterTypeChatIndicator.write(value.indicator, into: &buf) + FfiConverterUInt32.write(value.unseenCount, into: &buf) + FfiConverterSequenceTypeSessionRow.write(value.sessions, into: &buf) } } @@ -7081,35 +7472,37 @@ public struct FfiConverterTypeScroller: FfiConverterRustBuffer { #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeScroller_lift(_ buf: RustBuffer) throws -> Scroller { - return try FfiConverterTypeScroller.lift(buf) +public func FfiConverterTypeProjectView_lift(_ buf: RustBuffer) throws -> ProjectView { + return try FfiConverterTypeProjectView.lift(buf) } #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeScroller_lower(_ value: Scroller) -> RustBuffer { - return FfiConverterTypeScroller.lower(value) +public func FfiConverterTypeProjectView_lower(_ value: ProjectView) -> RustBuffer { + return FfiConverterTypeProjectView.lower(value) } -public struct SearchHit: Equatable, Hashable { - public var session: SessionRow - public var score: UInt32 - /** - * The strongest field that matched. - */ - public var field: SearchField +public struct PullRequest: Equatable, Hashable { + public var provider: String + public var number: UInt64 + public var title: String + public var url: String + public var state: PullRequestState + public var baseRef: String + public var headRef: String // Default memberwise initializers are never public by default, so we // declare one manually. - public init(session: SessionRow, score: UInt32, - /** - * The strongest field that matched. - */field: SearchField) { - self.session = session - self.score = score - self.field = field + public init(provider: String, number: UInt64, title: String, url: String, state: PullRequestState, baseRef: String, headRef: String) { + self.provider = provider + self.number = number + self.title = title + self.url = url + self.state = state + self.baseRef = baseRef + self.headRef = headRef } @@ -7118,26 +7511,34 @@ public struct SearchHit: Equatable, Hashable { } #if compiler(>=6) -extension SearchHit: Sendable {} +extension PullRequest: Sendable {} #endif #if swift(>=5.8) @_documentation(visibility: private) #endif -public struct FfiConverterTypeSearchHit: FfiConverterRustBuffer { - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SearchHit { +public struct FfiConverterTypePullRequest: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> PullRequest { return - try SearchHit( - session: FfiConverterTypeSessionRow.read(from: &buf), - score: FfiConverterUInt32.read(from: &buf), - field: FfiConverterTypeSearchField.read(from: &buf) + try PullRequest( + provider: FfiConverterString.read(from: &buf), + number: FfiConverterUInt64.read(from: &buf), + title: FfiConverterString.read(from: &buf), + url: FfiConverterString.read(from: &buf), + state: FfiConverterTypePullRequestState.read(from: &buf), + baseRef: FfiConverterString.read(from: &buf), + headRef: FfiConverterString.read(from: &buf) ) } - public static func write(_ value: SearchHit, into buf: inout [UInt8]) { - FfiConverterTypeSessionRow.write(value.session, into: &buf) - FfiConverterUInt32.write(value.score, into: &buf) - FfiConverterTypeSearchField.write(value.field, into: &buf) + public static func write(_ value: PullRequest, into buf: inout [UInt8]) { + FfiConverterString.write(value.provider, into: &buf) + FfiConverterUInt64.write(value.number, into: &buf) + FfiConverterString.write(value.title, into: &buf) + FfiConverterString.write(value.url, into: &buf) + FfiConverterTypePullRequestState.write(value.state, into: &buf) + FfiConverterString.write(value.baseRef, into: &buf) + FfiConverterString.write(value.headRef, into: &buf) } } @@ -7145,37 +7546,29 @@ public struct FfiConverterTypeSearchHit: FfiConverterRustBuffer { #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeSearchHit_lift(_ buf: RustBuffer) throws -> SearchHit { - return try FfiConverterTypeSearchHit.lift(buf) +public func FfiConverterTypePullRequest_lift(_ buf: RustBuffer) throws -> PullRequest { + return try FfiConverterTypePullRequest.lift(buf) } #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeSearchHit_lower(_ value: SearchHit) -> RustBuffer { - return FfiConverterTypeSearchHit.lower(value) +public func FfiConverterTypePullRequest_lower(_ value: PullRequest) -> RustBuffer { + return FfiConverterTypePullRequest.lower(value) } -public struct SectionView: Equatable, Hashable { - public var id: String - public var name: String - public var collapsed: Bool - /** - * Recency order (pinned sessions excluded). - */ - public var sessions: [SessionRow] +public struct PullRequestGroups: Equatable, Hashable { + public var `open`: [SessionRow] + public var merged: [SessionRow] + public var closed: [SessionRow] // Default memberwise initializers are never public by default, so we // declare one manually. - public init(id: String, name: String, collapsed: Bool, - /** - * Recency order (pinned sessions excluded). - */sessions: [SessionRow]) { - self.id = id - self.name = name - self.collapsed = collapsed - self.sessions = sessions + public init(`open`: [SessionRow], merged: [SessionRow], closed: [SessionRow]) { + self.`open` = `open` + self.merged = merged + self.closed = closed } @@ -7184,28 +7577,26 @@ public struct SectionView: Equatable, Hashable { } #if compiler(>=6) -extension SectionView: Sendable {} +extension PullRequestGroups: Sendable {} #endif #if swift(>=5.8) @_documentation(visibility: private) #endif -public struct FfiConverterTypeSectionView: FfiConverterRustBuffer { - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SectionView { +public struct FfiConverterTypePullRequestGroups: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> PullRequestGroups { return - try SectionView( - id: FfiConverterString.read(from: &buf), - name: FfiConverterString.read(from: &buf), - collapsed: FfiConverterBool.read(from: &buf), - sessions: FfiConverterSequenceTypeSessionRow.read(from: &buf) + try PullRequestGroups( + open: FfiConverterSequenceTypeSessionRow.read(from: &buf), + merged: FfiConverterSequenceTypeSessionRow.read(from: &buf), + closed: FfiConverterSequenceTypeSessionRow.read(from: &buf) ) } - public static func write(_ value: SectionView, into buf: inout [UInt8]) { - FfiConverterString.write(value.id, into: &buf) - FfiConverterString.write(value.name, into: &buf) - FfiConverterBool.write(value.collapsed, into: &buf) - FfiConverterSequenceTypeSessionRow.write(value.sessions, into: &buf) + public static func write(_ value: PullRequestGroups, into buf: inout [UInt8]) { + FfiConverterSequenceTypeSessionRow.write(value.`open`, into: &buf) + FfiConverterSequenceTypeSessionRow.write(value.merged, into: &buf) + FfiConverterSequenceTypeSessionRow.write(value.closed, into: &buf) } } @@ -7213,37 +7604,32 @@ public struct FfiConverterTypeSectionView: FfiConverterRustBuffer { #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeSectionView_lift(_ buf: RustBuffer) throws -> SectionView { - return try FfiConverterTypeSectionView.lift(buf) +public func FfiConverterTypePullRequestGroups_lift(_ buf: RustBuffer) throws -> PullRequestGroups { + return try FfiConverterTypePullRequestGroups.lift(buf) } #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeSectionView_lower(_ value: SectionView) -> RustBuffer { - return FfiConverterTypeSectionView.lower(value) +public func FfiConverterTypePullRequestGroups_lower(_ value: PullRequestGroups) -> RustBuffer { + return FfiConverterTypePullRequestGroups.lower(value) } -public struct SendRequest: Equatable, Hashable { - public var text: String - public var attachments: [OutgoingAttachment] - /** - * Isolated host worktree for this run (new-session "New worktree"). - */ - public var worktree: WorktreeSpec? - public var busy: BusyPolicy +/** + * Which session notifications this device wants. + */ +public struct PushPrefs: Equatable, Hashable { + public var done: Bool + public var input: Bool + public var failed: Bool // Default memberwise initializers are never public by default, so we // declare one manually. - public init(text: String, attachments: [OutgoingAttachment], - /** - * Isolated host worktree for this run (new-session "New worktree"). - */worktree: WorktreeSpec?, busy: BusyPolicy) { - self.text = text - self.attachments = attachments - self.worktree = worktree - self.busy = busy + public init(done: Bool, input: Bool, failed: Bool) { + self.done = done + self.input = input + self.failed = failed } @@ -7252,28 +7638,26 @@ public struct SendRequest: Equatable, Hashable { } #if compiler(>=6) -extension SendRequest: Sendable {} +extension PushPrefs: Sendable {} #endif #if swift(>=5.8) @_documentation(visibility: private) #endif -public struct FfiConverterTypeSendRequest: FfiConverterRustBuffer { - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SendRequest { +public struct FfiConverterTypePushPrefs: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> PushPrefs { return - try SendRequest( - text: FfiConverterString.read(from: &buf), - attachments: FfiConverterSequenceTypeOutgoingAttachment.read(from: &buf), - worktree: FfiConverterOptionTypeWorktreeSpec.read(from: &buf), - busy: FfiConverterTypeBusyPolicy.read(from: &buf) + try PushPrefs( + done: FfiConverterBool.read(from: &buf), + input: FfiConverterBool.read(from: &buf), + failed: FfiConverterBool.read(from: &buf) ) } - public static func write(_ value: SendRequest, into buf: inout [UInt8]) { - FfiConverterString.write(value.text, into: &buf) - FfiConverterSequenceTypeOutgoingAttachment.write(value.attachments, into: &buf) - FfiConverterOptionTypeWorktreeSpec.write(value.worktree, into: &buf) - FfiConverterTypeBusyPolicy.write(value.busy, into: &buf) + public static func write(_ value: PushPrefs, into buf: inout [UInt8]) { + FfiConverterBool.write(value.done, into: &buf) + FfiConverterBool.write(value.input, into: &buf) + FfiConverterBool.write(value.failed, into: &buf) } } @@ -7281,152 +7665,39 @@ public struct FfiConverterTypeSendRequest: FfiConverterRustBuffer { #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeSendRequest_lift(_ buf: RustBuffer) throws -> SendRequest { - return try FfiConverterTypeSendRequest.lift(buf) +public func FfiConverterTypePushPrefs_lift(_ buf: RustBuffer) throws -> PushPrefs { + return try FfiConverterTypePushPrefs.lift(buf) } #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeSendRequest_lower(_ value: SendRequest) -> RustBuffer { - return FfiConverterTypeSendRequest.lower(value) +public func FfiConverterTypePushPrefs_lower(_ value: PushPrefs) -> RustBuffer { + return FfiConverterTypePushPrefs.lower(value) } -/** - * One session row, as every list renders it. - */ -public struct SessionRow: Equatable, Hashable { - public var id: String - /** - * Content hash — equal ⇒ nothing to redraw. - */ - public var revision: UInt64 - /** - * Display title ("New session" when untitled). - */ - public var title: String - public var hasTitle: Bool - public var preview: String? - /** - * `None` = projectless (host's home folder). - */ - public var project: ProjectRef? - public var deviceId: String - public var deviceName: String? - public var deviceOnline: Bool - /** - * Wire harness id (`claude-code`). - */ - public var harness: String? - public var harnessLabel: String? - public var model: String? - public var modelLabel: String? - /** - * Wire effort (`xhigh`). - */ - public var reasoning: String? - public var branch: String? - public var cwd: String? - /** - * Staleness-gated (45s) live status; an in-flight send reads Working. - */ - public var indicator: ChatIndicator - /** - * Host-reported status only (no local-send override). - */ - public var hostIndicator: ChatIndicator - /** - * Run start of the live turn while Working/AwaitingInput. - */ - public var workingSinceMs: Int64? - public var lastActivityMs: Int64 - /** - * `now` / `34m` / `4h` / `2d` at derive time. - */ - public var timeLabel: String - public var createdAtMs: Int64 - public var unseen: Bool - public var archived: Bool - public var pinned: Bool - public var sectionId: String? - public var pullRequest: PullRequest? - /** - * Oldest unadopted send from this device (open sessions only). - */ - public var sendState: SendState? - public var parentChatId: String? +public struct QueueEditLease: Equatable, Hashable { + public var rowId: String + public var leaseId: String /** - * 2 = chat2; 1 = legacy (not dialable). + * Row text at lease time. */ - public var roomGen: UInt32 + public var text: String + public var baseTextHash: String + public var expiresAtMs: Int64 // Default memberwise initializers are never public by default, so we // declare one manually. - public init(id: String, - /** - * Content hash — equal ⇒ nothing to redraw. - */revision: UInt64, - /** - * Display title ("New session" when untitled). - */title: String, hasTitle: Bool, preview: String?, - /** - * `None` = projectless (host's home folder). - */project: ProjectRef?, deviceId: String, deviceName: String?, deviceOnline: Bool, - /** - * Wire harness id (`claude-code`). - */harness: String?, harnessLabel: String?, model: String?, modelLabel: String?, - /** - * Wire effort (`xhigh`). - */reasoning: String?, branch: String?, cwd: String?, - /** - * Staleness-gated (45s) live status; an in-flight send reads Working. - */indicator: ChatIndicator, - /** - * Host-reported status only (no local-send override). - */hostIndicator: ChatIndicator, - /** - * Run start of the live turn while Working/AwaitingInput. - */workingSinceMs: Int64?, lastActivityMs: Int64, - /** - * `now` / `34m` / `4h` / `2d` at derive time. - */timeLabel: String, createdAtMs: Int64, unseen: Bool, archived: Bool, pinned: Bool, sectionId: String?, pullRequest: PullRequest?, - /** - * Oldest unadopted send from this device (open sessions only). - */sendState: SendState?, parentChatId: String?, + public init(rowId: String, leaseId: String, /** - * 2 = chat2; 1 = legacy (not dialable). - */roomGen: UInt32) { - self.id = id - self.revision = revision - self.title = title - self.hasTitle = hasTitle - self.preview = preview - self.project = project - self.deviceId = deviceId - self.deviceName = deviceName - self.deviceOnline = deviceOnline - self.harness = harness - self.harnessLabel = harnessLabel - self.model = model - self.modelLabel = modelLabel - self.reasoning = reasoning - self.branch = branch - self.cwd = cwd - self.indicator = indicator - self.hostIndicator = hostIndicator - self.workingSinceMs = workingSinceMs - self.lastActivityMs = lastActivityMs - self.timeLabel = timeLabel - self.createdAtMs = createdAtMs - self.unseen = unseen - self.archived = archived - self.pinned = pinned - self.sectionId = sectionId - self.pullRequest = pullRequest - self.sendState = sendState - self.parentChatId = parentChatId - self.roomGen = roomGen + * Row text at lease time. + */text: String, baseTextHash: String, expiresAtMs: Int64) { + self.rowId = rowId + self.leaseId = leaseId + self.text = text + self.baseTextHash = baseTextHash + self.expiresAtMs = expiresAtMs } @@ -7435,80 +7706,30 @@ public struct SessionRow: Equatable, Hashable { } #if compiler(>=6) -extension SessionRow: Sendable {} +extension QueueEditLease: Sendable {} #endif #if swift(>=5.8) @_documentation(visibility: private) #endif -public struct FfiConverterTypeSessionRow: FfiConverterRustBuffer { - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SessionRow { +public struct FfiConverterTypeQueueEditLease: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> QueueEditLease { return - try SessionRow( - id: FfiConverterString.read(from: &buf), - revision: FfiConverterUInt64.read(from: &buf), - title: FfiConverterString.read(from: &buf), - hasTitle: FfiConverterBool.read(from: &buf), - preview: FfiConverterOptionString.read(from: &buf), - project: FfiConverterOptionTypeProjectRef.read(from: &buf), - deviceId: FfiConverterString.read(from: &buf), - deviceName: FfiConverterOptionString.read(from: &buf), - deviceOnline: FfiConverterBool.read(from: &buf), - harness: FfiConverterOptionString.read(from: &buf), - harnessLabel: FfiConverterOptionString.read(from: &buf), - model: FfiConverterOptionString.read(from: &buf), - modelLabel: FfiConverterOptionString.read(from: &buf), - reasoning: FfiConverterOptionString.read(from: &buf), - branch: FfiConverterOptionString.read(from: &buf), - cwd: FfiConverterOptionString.read(from: &buf), - indicator: FfiConverterTypeChatIndicator.read(from: &buf), - hostIndicator: FfiConverterTypeChatIndicator.read(from: &buf), - workingSinceMs: FfiConverterOptionInt64.read(from: &buf), - lastActivityMs: FfiConverterInt64.read(from: &buf), - timeLabel: FfiConverterString.read(from: &buf), - createdAtMs: FfiConverterInt64.read(from: &buf), - unseen: FfiConverterBool.read(from: &buf), - archived: FfiConverterBool.read(from: &buf), - pinned: FfiConverterBool.read(from: &buf), - sectionId: FfiConverterOptionString.read(from: &buf), - pullRequest: FfiConverterOptionTypePullRequest.read(from: &buf), - sendState: FfiConverterOptionTypeSendState.read(from: &buf), - parentChatId: FfiConverterOptionString.read(from: &buf), - roomGen: FfiConverterUInt32.read(from: &buf) + try QueueEditLease( + rowId: FfiConverterString.read(from: &buf), + leaseId: FfiConverterString.read(from: &buf), + text: FfiConverterString.read(from: &buf), + baseTextHash: FfiConverterString.read(from: &buf), + expiresAtMs: FfiConverterInt64.read(from: &buf) ) } - public static func write(_ value: SessionRow, into buf: inout [UInt8]) { - FfiConverterString.write(value.id, into: &buf) - FfiConverterUInt64.write(value.revision, into: &buf) - FfiConverterString.write(value.title, into: &buf) - FfiConverterBool.write(value.hasTitle, into: &buf) - FfiConverterOptionString.write(value.preview, into: &buf) - FfiConverterOptionTypeProjectRef.write(value.project, into: &buf) - FfiConverterString.write(value.deviceId, into: &buf) - FfiConverterOptionString.write(value.deviceName, into: &buf) - FfiConverterBool.write(value.deviceOnline, into: &buf) - FfiConverterOptionString.write(value.harness, into: &buf) - FfiConverterOptionString.write(value.harnessLabel, into: &buf) - FfiConverterOptionString.write(value.model, into: &buf) - FfiConverterOptionString.write(value.modelLabel, into: &buf) - FfiConverterOptionString.write(value.reasoning, into: &buf) - FfiConverterOptionString.write(value.branch, into: &buf) - FfiConverterOptionString.write(value.cwd, into: &buf) - FfiConverterTypeChatIndicator.write(value.indicator, into: &buf) - FfiConverterTypeChatIndicator.write(value.hostIndicator, into: &buf) - FfiConverterOptionInt64.write(value.workingSinceMs, into: &buf) - FfiConverterInt64.write(value.lastActivityMs, into: &buf) - FfiConverterString.write(value.timeLabel, into: &buf) - FfiConverterInt64.write(value.createdAtMs, into: &buf) - FfiConverterBool.write(value.unseen, into: &buf) - FfiConverterBool.write(value.archived, into: &buf) - FfiConverterBool.write(value.pinned, into: &buf) - FfiConverterOptionString.write(value.sectionId, into: &buf) - FfiConverterOptionTypePullRequest.write(value.pullRequest, into: &buf) - FfiConverterOptionTypeSendState.write(value.sendState, into: &buf) - FfiConverterOptionString.write(value.parentChatId, into: &buf) - FfiConverterUInt32.write(value.roomGen, into: &buf) + public static func write(_ value: QueueEditLease, into buf: inout [UInt8]) { + FfiConverterString.write(value.rowId, into: &buf) + FfiConverterString.write(value.leaseId, into: &buf) + FfiConverterString.write(value.text, into: &buf) + FfiConverterString.write(value.baseTextHash, into: &buf) + FfiConverterInt64.write(value.expiresAtMs, into: &buf) } } @@ -7516,64 +7737,107 @@ public struct FfiConverterTypeSessionRow: FfiConverterRustBuffer { #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeSessionRow_lift(_ buf: RustBuffer) throws -> SessionRow { - return try FfiConverterTypeSessionRow.lift(buf) +public func FfiConverterTypeQueueEditLease_lift(_ buf: RustBuffer) throws -> QueueEditLease { + return try FfiConverterTypeQueueEditLease.lift(buf) } #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeSessionRow_lower(_ value: SessionRow) -> RustBuffer { - return FfiConverterTypeSessionRow.lower(value) +public func FfiConverterTypeQueueEditLease_lower(_ value: QueueEditLease) -> RustBuffer { + return FfiConverterTypeQueueEditLease.lower(value) } -/** - * A style the platform must be able to draw: `id` → (face, size). - */ -public struct StyleDesc: Equatable, Hashable { - public var id: UInt16 - public var face: FaceRole - public var size: Float - public var ligatures: Bool - - // Default memberwise initializers are never public by default, so we - // declare one manually. - public init(id: UInt16, face: FaceRole, size: Float, ligatures: Bool) { - self.id = id - self.face = face - self.size = size - self.ligatures = ligatures - } - - - - -} - -#if compiler(>=6) -extension StyleDesc: Sendable {} +public struct QueueItem: Equatable, Hashable { + public var id: String + /** + * Raw row text (what the host will send). + */ + public var text: String + /** + * User-visible text (trailer/Appshot context stripped). + */ + public var visibleText: String + public var attachments: [String] + public var holdForTurnEnd: Bool + public var issuedBy: String + public var fromThisDevice: Bool + public var issuedAtMs: Int64 + public var editedAtMs: Int64? + public var gate: QueueGate? + /** + * A Send-now / Remove is awaiting the host's acknowledgement. + */ + public var actionPending: Bool + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(id: String, + /** + * Raw row text (what the host will send). + */text: String, + /** + * User-visible text (trailer/Appshot context stripped). + */visibleText: String, attachments: [String], holdForTurnEnd: Bool, issuedBy: String, fromThisDevice: Bool, issuedAtMs: Int64, editedAtMs: Int64?, gate: QueueGate?, + /** + * A Send-now / Remove is awaiting the host's acknowledgement. + */actionPending: Bool) { + self.id = id + self.text = text + self.visibleText = visibleText + self.attachments = attachments + self.holdForTurnEnd = holdForTurnEnd + self.issuedBy = issuedBy + self.fromThisDevice = fromThisDevice + self.issuedAtMs = issuedAtMs + self.editedAtMs = editedAtMs + self.gate = gate + self.actionPending = actionPending + } + + + + +} + +#if compiler(>=6) +extension QueueItem: Sendable {} #endif #if swift(>=5.8) @_documentation(visibility: private) #endif -public struct FfiConverterTypeStyleDesc: FfiConverterRustBuffer { - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> StyleDesc { +public struct FfiConverterTypeQueueItem: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> QueueItem { return - try StyleDesc( - id: FfiConverterUInt16.read(from: &buf), - face: FfiConverterTypeFaceRole.read(from: &buf), - size: FfiConverterFloat.read(from: &buf), - ligatures: FfiConverterBool.read(from: &buf) + try QueueItem( + id: FfiConverterString.read(from: &buf), + text: FfiConverterString.read(from: &buf), + visibleText: FfiConverterString.read(from: &buf), + attachments: FfiConverterSequenceString.read(from: &buf), + holdForTurnEnd: FfiConverterBool.read(from: &buf), + issuedBy: FfiConverterString.read(from: &buf), + fromThisDevice: FfiConverterBool.read(from: &buf), + issuedAtMs: FfiConverterInt64.read(from: &buf), + editedAtMs: FfiConverterOptionInt64.read(from: &buf), + gate: FfiConverterOptionTypeQueueGate.read(from: &buf), + actionPending: FfiConverterBool.read(from: &buf) ) } - public static func write(_ value: StyleDesc, into buf: inout [UInt8]) { - FfiConverterUInt16.write(value.id, into: &buf) - FfiConverterTypeFaceRole.write(value.face, into: &buf) - FfiConverterFloat.write(value.size, into: &buf) - FfiConverterBool.write(value.ligatures, into: &buf) + public static func write(_ value: QueueItem, into buf: inout [UInt8]) { + FfiConverterString.write(value.id, into: &buf) + FfiConverterString.write(value.text, into: &buf) + FfiConverterString.write(value.visibleText, into: &buf) + FfiConverterSequenceString.write(value.attachments, into: &buf) + FfiConverterBool.write(value.holdForTurnEnd, into: &buf) + FfiConverterString.write(value.issuedBy, into: &buf) + FfiConverterBool.write(value.fromThisDevice, into: &buf) + FfiConverterInt64.write(value.issuedAtMs, into: &buf) + FfiConverterOptionInt64.write(value.editedAtMs, into: &buf) + FfiConverterOptionTypeQueueGate.write(value.gate, into: &buf) + FfiConverterBool.write(value.actionPending, into: &buf) } } @@ -7581,67 +7845,41 @@ public struct FfiConverterTypeStyleDesc: FfiConverterRustBuffer { #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeStyleDesc_lift(_ buf: RustBuffer) throws -> StyleDesc { - return try FfiConverterTypeStyleDesc.lift(buf) +public func FfiConverterTypeQueueItem_lift(_ buf: RustBuffer) throws -> QueueItem { + return try FfiConverterTypeQueueItem.lift(buf) } #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeStyleDesc_lower(_ value: StyleDesc) -> RustBuffer { - return FfiConverterTypeStyleDesc.lower(value) +public func FfiConverterTypeQueueItem_lower(_ value: QueueItem) -> RustBuffer { + return FfiConverterTypeQueueItem.lower(value) } -/** - * One positioned piece of text: `text[start..start+len]` (UTF-16) drawn with - * `style` so its origin sits at (`x`, `baseline`). - */ -public struct TextRun: Equatable, Hashable { - public var start: UInt32 - public var len: UInt32 - public var x: Float - public var baseline: Float - /** - * Rust-measured advance — painters may use it for selection/hit rects. - */ - public var width: Float - public var style: UInt16 - public var color: ColorRole - public var decoration: Decoration +public struct RepoRef: Equatable, Hashable { + public var name: String /** - * Index into [`RowDisplay::scrollers`] when the run scrolls horizontally. + * Checked out in the repo's main folder right now. */ - public var scroller: UInt32? + public var current: Bool /** - * Fade-in start in ms since the row's layout epoch (streaming veil); - * `None` = fully visible. + * Linked worktree this branch is checked out in, if any. */ - public var revealMs: UInt32? + public var worktreePath: String? // Default memberwise initializers are never public by default, so we // declare one manually. - public init(start: UInt32, len: UInt32, x: Float, baseline: Float, - /** - * Rust-measured advance — painters may use it for selection/hit rects. - */width: Float, style: UInt16, color: ColorRole, decoration: Decoration, + public init(name: String, /** - * Index into [`RowDisplay::scrollers`] when the run scrolls horizontally. - */scroller: UInt32?, + * Checked out in the repo's main folder right now. + */current: Bool, /** - * Fade-in start in ms since the row's layout epoch (streaming veil); - * `None` = fully visible. - */revealMs: UInt32?) { - self.start = start - self.len = len - self.x = x - self.baseline = baseline - self.width = width - self.style = style - self.color = color - self.decoration = decoration - self.scroller = scroller - self.revealMs = revealMs + * Linked worktree this branch is checked out in, if any. + */worktreePath: String?) { + self.name = name + self.current = current + self.worktreePath = worktreePath } @@ -7650,40 +7888,26 @@ public struct TextRun: Equatable, Hashable { } #if compiler(>=6) -extension TextRun: Sendable {} +extension RepoRef: Sendable {} #endif #if swift(>=5.8) @_documentation(visibility: private) #endif -public struct FfiConverterTypeTextRun: FfiConverterRustBuffer { - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> TextRun { +public struct FfiConverterTypeRepoRef: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> RepoRef { return - try TextRun( - start: FfiConverterUInt32.read(from: &buf), - len: FfiConverterUInt32.read(from: &buf), - x: FfiConverterFloat.read(from: &buf), - baseline: FfiConverterFloat.read(from: &buf), - width: FfiConverterFloat.read(from: &buf), - style: FfiConverterUInt16.read(from: &buf), - color: FfiConverterTypeColorRole.read(from: &buf), - decoration: FfiConverterTypeDecoration.read(from: &buf), - scroller: FfiConverterOptionUInt32.read(from: &buf), - revealMs: FfiConverterOptionUInt32.read(from: &buf) + try RepoRef( + name: FfiConverterString.read(from: &buf), + current: FfiConverterBool.read(from: &buf), + worktreePath: FfiConverterOptionString.read(from: &buf) ) } - public static func write(_ value: TextRun, into buf: inout [UInt8]) { - FfiConverterUInt32.write(value.start, into: &buf) - FfiConverterUInt32.write(value.len, into: &buf) - FfiConverterFloat.write(value.x, into: &buf) - FfiConverterFloat.write(value.baseline, into: &buf) - FfiConverterFloat.write(value.width, into: &buf) - FfiConverterUInt16.write(value.style, into: &buf) - FfiConverterTypeColorRole.write(value.color, into: &buf) - FfiConverterTypeDecoration.write(value.decoration, into: &buf) - FfiConverterOptionUInt32.write(value.scroller, into: &buf) - FfiConverterOptionUInt32.write(value.revealMs, into: &buf) + public static func write(_ value: RepoRef, into buf: inout [UInt8]) { + FfiConverterString.write(value.name, into: &buf) + FfiConverterBool.write(value.current, into: &buf) + FfiConverterOptionString.write(value.worktreePath, into: &buf) } } @@ -7691,61 +7915,35 @@ public struct FfiConverterTypeTextRun: FfiConverterRustBuffer { #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeTextRun_lift(_ buf: RustBuffer) throws -> TextRun { - return try FfiConverterTypeTextRun.lift(buf) +public func FfiConverterTypeRepoRef_lift(_ buf: RustBuffer) throws -> RepoRef { + return try FfiConverterTypeRepoRef.lift(buf) } #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeTextRun_lower(_ value: TextRun) -> RustBuffer { - return FfiConverterTypeTextRun.lower(value) +public func FfiConverterTypeRepoRef_lower(_ value: RepoRef) -> RustBuffer { + return FfiConverterTypeRepoRef.lower(value) } -/** - * Transcript-level facts the platform needs without the rows (loader vs - * empty state, scroll-to-bottom affordances, the working pill). - */ -public struct TranscriptStatus: Equatable, Hashable { - public var revision: UInt64 - /** - * Content present (false = show a loader, not an empty chat). - */ - public var hydrated: Bool - /** - * Host-written entries. - */ - public var entryCount: UInt32 - public var pendingCount: UInt32 - public var streaming: Bool - public var working: Bool - public var workingSinceMs: Int64? +public struct RoomState: Equatable, Hashable { + public var connected: Bool + public var retryAtMs: Int64? /** - * Id of the newest entry (host or echo). + * Graced degradation for this chat. */ - public var lastEntryId: String? + public var degraded: Bool // Default memberwise initializers are never public by default, so we // declare one manually. - public init(revision: UInt64, - /** - * Content present (false = show a loader, not an empty chat). - */hydrated: Bool, - /** - * Host-written entries. - */entryCount: UInt32, pendingCount: UInt32, streaming: Bool, working: Bool, workingSinceMs: Int64?, + public init(connected: Bool, retryAtMs: Int64?, /** - * Id of the newest entry (host or echo). - */lastEntryId: String?) { - self.revision = revision - self.hydrated = hydrated - self.entryCount = entryCount - self.pendingCount = pendingCount - self.streaming = streaming - self.working = working - self.workingSinceMs = workingSinceMs - self.lastEntryId = lastEntryId + * Graced degradation for this chat. + */degraded: Bool) { + self.connected = connected + self.retryAtMs = retryAtMs + self.degraded = degraded } @@ -7754,36 +7952,26 @@ public struct TranscriptStatus: Equatable, Hashable { } #if compiler(>=6) -extension TranscriptStatus: Sendable {} +extension RoomState: Sendable {} #endif #if swift(>=5.8) @_documentation(visibility: private) #endif -public struct FfiConverterTypeTranscriptStatus: FfiConverterRustBuffer { - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> TranscriptStatus { +public struct FfiConverterTypeRoomState: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> RoomState { return - try TranscriptStatus( - revision: FfiConverterUInt64.read(from: &buf), - hydrated: FfiConverterBool.read(from: &buf), - entryCount: FfiConverterUInt32.read(from: &buf), - pendingCount: FfiConverterUInt32.read(from: &buf), - streaming: FfiConverterBool.read(from: &buf), - working: FfiConverterBool.read(from: &buf), - workingSinceMs: FfiConverterOptionInt64.read(from: &buf), - lastEntryId: FfiConverterOptionString.read(from: &buf) + try RoomState( + connected: FfiConverterBool.read(from: &buf), + retryAtMs: FfiConverterOptionInt64.read(from: &buf), + degraded: FfiConverterBool.read(from: &buf) ) } - public static func write(_ value: TranscriptStatus, into buf: inout [UInt8]) { - FfiConverterUInt64.write(value.revision, into: &buf) - FfiConverterBool.write(value.hydrated, into: &buf) - FfiConverterUInt32.write(value.entryCount, into: &buf) - FfiConverterUInt32.write(value.pendingCount, into: &buf) - FfiConverterBool.write(value.streaming, into: &buf) - FfiConverterBool.write(value.working, into: &buf) - FfiConverterOptionInt64.write(value.workingSinceMs, into: &buf) - FfiConverterOptionString.write(value.lastEntryId, into: &buf) + public static func write(_ value: RoomState, into buf: inout [UInt8]) { + FfiConverterBool.write(value.connected, into: &buf) + FfiConverterOptionInt64.write(value.retryAtMs, into: &buf) + FfiConverterBool.write(value.degraded, into: &buf) } } @@ -7791,35 +7979,56 @@ public struct FfiConverterTypeTranscriptStatus: FfiConverterRustBuffer { #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeTranscriptStatus_lift(_ buf: RustBuffer) throws -> TranscriptStatus { - return try FfiConverterTypeTranscriptStatus.lift(buf) +public func FfiConverterTypeRoomState_lift(_ buf: RustBuffer) throws -> RoomState { + return try FfiConverterTypeRoomState.lift(buf) } #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeTranscriptStatus_lower(_ value: TranscriptStatus) -> RustBuffer { - return FfiConverterTypeTranscriptStatus.lower(value) +public func FfiConverterTypeRoomState_lower(_ value: RoomState) -> RustBuffer { + return FfiConverterTypeRoomState.lower(value) } -public struct UserImage: Equatable, Hashable { +/** + * Everything needed to paint a row at its current width. + */ +public struct RowDisplay: Equatable, Hashable { + public var key: UInt64 + public var version: UInt64 + public var width: Float + public var height: Float + public var text: String + public var runs: [TextRun] + public var boxes: [BoxPrim] + public var links: [LinkHit] + public var scrollers: [Scroller] + public var widgets: [Widget] + public var fades: [Fade] /** - * Host path (or `pending://` ref) — read via `read_attachment`. + * Plain text for copy / accessibility. */ - public var path: String - public var name: String - public var appshot: AppshotLabel? + public var copyText: String // Default memberwise initializers are never public by default, so we // declare one manually. - public init( + public init(key: UInt64, version: UInt64, width: Float, height: Float, text: String, runs: [TextRun], boxes: [BoxPrim], links: [LinkHit], scrollers: [Scroller], widgets: [Widget], fades: [Fade], /** - * Host path (or `pending://` ref) — read via `read_attachment`. - */path: String, name: String, appshot: AppshotLabel?) { - self.path = path - self.name = name - self.appshot = appshot + * Plain text for copy / accessibility. + */copyText: String) { + self.key = key + self.version = version + self.width = width + self.height = height + self.text = text + self.runs = runs + self.boxes = boxes + self.links = links + self.scrollers = scrollers + self.widgets = widgets + self.fades = fades + self.copyText = copyText } @@ -7828,26 +8037,44 @@ public struct UserImage: Equatable, Hashable { } #if compiler(>=6) -extension UserImage: Sendable {} +extension RowDisplay: Sendable {} #endif #if swift(>=5.8) @_documentation(visibility: private) #endif -public struct FfiConverterTypeUserImage: FfiConverterRustBuffer { - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> UserImage { +public struct FfiConverterTypeRowDisplay: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> RowDisplay { return - try UserImage( - path: FfiConverterString.read(from: &buf), - name: FfiConverterString.read(from: &buf), - appshot: FfiConverterOptionTypeAppshotLabel.read(from: &buf) + try RowDisplay( + key: FfiConverterUInt64.read(from: &buf), + version: FfiConverterUInt64.read(from: &buf), + width: FfiConverterFloat.read(from: &buf), + height: FfiConverterFloat.read(from: &buf), + text: FfiConverterString.read(from: &buf), + runs: FfiConverterSequenceTypeTextRun.read(from: &buf), + boxes: FfiConverterSequenceTypeBoxPrim.read(from: &buf), + links: FfiConverterSequenceTypeLinkHit.read(from: &buf), + scrollers: FfiConverterSequenceTypeScroller.read(from: &buf), + widgets: FfiConverterSequenceTypeWidget.read(from: &buf), + fades: FfiConverterSequenceTypeFade.read(from: &buf), + copyText: FfiConverterString.read(from: &buf) ) } - public static func write(_ value: UserImage, into buf: inout [UInt8]) { - FfiConverterString.write(value.path, into: &buf) - FfiConverterString.write(value.name, into: &buf) - FfiConverterOptionTypeAppshotLabel.write(value.appshot, into: &buf) + public static func write(_ value: RowDisplay, into buf: inout [UInt8]) { + FfiConverterUInt64.write(value.key, into: &buf) + FfiConverterUInt64.write(value.version, into: &buf) + FfiConverterFloat.write(value.width, into: &buf) + FfiConverterFloat.write(value.height, into: &buf) + FfiConverterString.write(value.text, into: &buf) + FfiConverterSequenceTypeTextRun.write(value.runs, into: &buf) + FfiConverterSequenceTypeBoxPrim.write(value.boxes, into: &buf) + FfiConverterSequenceTypeLinkHit.write(value.links, into: &buf) + FfiConverterSequenceTypeScroller.write(value.scrollers, into: &buf) + FfiConverterSequenceTypeWidget.write(value.widgets, into: &buf) + FfiConverterSequenceTypeFade.write(value.fades, into: &buf) + FfiConverterString.write(value.copyText, into: &buf) } } @@ -7855,33 +8082,44 @@ public struct FfiConverterTypeUserImage: FfiConverterRustBuffer { #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeUserImage_lift(_ buf: RustBuffer) throws -> UserImage { - return try FfiConverterTypeUserImage.lift(buf) +public func FfiConverterTypeRowDisplay_lift(_ buf: RustBuffer) throws -> RowDisplay { + return try FfiConverterTypeRowDisplay.lift(buf) } #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeUserImage_lower(_ value: UserImage) -> RustBuffer { - return FfiConverterTypeUserImage.lower(value) +public func FfiConverterTypeRowDisplay_lower(_ value: RowDisplay) -> RustBuffer { + return FfiConverterTypeRowDisplay.lower(value) } -public struct UserInputAnswer: Equatable, Hashable { - public var questionId: String +/** + * A row's position in a frame. + */ +public struct RowPlacement: Equatable, Hashable { + public var index: UInt32 + public var key: UInt64 /** - * Picked option labels (or free text as a single label). + * Changes whenever the row's painted content may change (not its width). */ - public var labels: [String] + public var version: UInt64 + public var y: Float + public var height: Float + public var kind: RowKind // Default memberwise initializers are never public by default, so we // declare one manually. - public init(questionId: String, + public init(index: UInt32, key: UInt64, /** - * Picked option labels (or free text as a single label). - */labels: [String]) { - self.questionId = questionId - self.labels = labels + * Changes whenever the row's painted content may change (not its width). + */version: UInt64, y: Float, height: Float, kind: RowKind) { + self.index = index + self.key = key + self.version = version + self.y = y + self.height = height + self.kind = kind } @@ -7890,24 +8128,32 @@ public struct UserInputAnswer: Equatable, Hashable { } #if compiler(>=6) -extension UserInputAnswer: Sendable {} +extension RowPlacement: Sendable {} #endif #if swift(>=5.8) @_documentation(visibility: private) #endif -public struct FfiConverterTypeUserInputAnswer: FfiConverterRustBuffer { - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> UserInputAnswer { +public struct FfiConverterTypeRowPlacement: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> RowPlacement { return - try UserInputAnswer( - questionId: FfiConverterString.read(from: &buf), - labels: FfiConverterSequenceString.read(from: &buf) + try RowPlacement( + index: FfiConverterUInt32.read(from: &buf), + key: FfiConverterUInt64.read(from: &buf), + version: FfiConverterUInt64.read(from: &buf), + y: FfiConverterFloat.read(from: &buf), + height: FfiConverterFloat.read(from: &buf), + kind: FfiConverterTypeRowKind.read(from: &buf) ) } - public static func write(_ value: UserInputAnswer, into buf: inout [UInt8]) { - FfiConverterString.write(value.questionId, into: &buf) - FfiConverterSequenceString.write(value.labels, into: &buf) + public static func write(_ value: RowPlacement, into buf: inout [UInt8]) { + FfiConverterUInt32.write(value.index, into: &buf) + FfiConverterUInt64.write(value.key, into: &buf) + FfiConverterUInt64.write(value.version, into: &buf) + FfiConverterFloat.write(value.y, into: &buf) + FfiConverterFloat.write(value.height, into: &buf) + FfiConverterTypeRowKind.write(value.kind, into: &buf) } } @@ -7915,37 +8161,38 @@ public struct FfiConverterTypeUserInputAnswer: FfiConverterRustBuffer { #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeUserInputAnswer_lift(_ buf: RustBuffer) throws -> UserInputAnswer { - return try FfiConverterTypeUserInputAnswer.lift(buf) +public func FfiConverterTypeRowPlacement_lift(_ buf: RustBuffer) throws -> RowPlacement { + return try FfiConverterTypeRowPlacement.lift(buf) } #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeUserInputAnswer_lower(_ value: UserInputAnswer) -> RustBuffer { - return FfiConverterTypeUserInputAnswer.lower(value) +public func FfiConverterTypeRowPlacement_lower(_ value: RowPlacement) -> RustBuffer { + return FfiConverterTypeRowPlacement.lower(value) } -public struct UserInputQuestion: Equatable, Hashable { - public var id: String - public var header: String - public var question: String - public var options: [String] - public var multiSelect: Bool - public var prefill: String? - public var multiline: Bool +/** + * A horizontally scrolling viewport (code blocks, wide tables). Primitives + * that reference it are positioned in its content coordinates, whose origin + * is the viewport's top-left. + */ +public struct Scroller: Equatable, Hashable { + public var x: Float + public var y: Float + public var w: Float + public var h: Float + public var contentWidth: Float // Default memberwise initializers are never public by default, so we // declare one manually. - public init(id: String, header: String, question: String, options: [String], multiSelect: Bool, prefill: String?, multiline: Bool) { - self.id = id - self.header = header - self.question = question - self.options = options - self.multiSelect = multiSelect - self.prefill = prefill - self.multiline = multiline + public init(x: Float, y: Float, w: Float, h: Float, contentWidth: Float) { + self.x = x + self.y = y + self.w = w + self.h = h + self.contentWidth = contentWidth } @@ -7954,34 +8201,30 @@ public struct UserInputQuestion: Equatable, Hashable { } #if compiler(>=6) -extension UserInputQuestion: Sendable {} +extension Scroller: Sendable {} #endif #if swift(>=5.8) @_documentation(visibility: private) #endif -public struct FfiConverterTypeUserInputQuestion: FfiConverterRustBuffer { - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> UserInputQuestion { +public struct FfiConverterTypeScroller: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> Scroller { return - try UserInputQuestion( - id: FfiConverterString.read(from: &buf), - header: FfiConverterString.read(from: &buf), - question: FfiConverterString.read(from: &buf), - options: FfiConverterSequenceString.read(from: &buf), - multiSelect: FfiConverterBool.read(from: &buf), - prefill: FfiConverterOptionString.read(from: &buf), - multiline: FfiConverterBool.read(from: &buf) + try Scroller( + x: FfiConverterFloat.read(from: &buf), + y: FfiConverterFloat.read(from: &buf), + w: FfiConverterFloat.read(from: &buf), + h: FfiConverterFloat.read(from: &buf), + contentWidth: FfiConverterFloat.read(from: &buf) ) } - public static func write(_ value: UserInputQuestion, into buf: inout [UInt8]) { - FfiConverterString.write(value.id, into: &buf) - FfiConverterString.write(value.header, into: &buf) - FfiConverterString.write(value.question, into: &buf) - FfiConverterSequenceString.write(value.options, into: &buf) - FfiConverterBool.write(value.multiSelect, into: &buf) - FfiConverterOptionString.write(value.prefill, into: &buf) - FfiConverterBool.write(value.multiline, into: &buf) + public static func write(_ value: Scroller, into buf: inout [UInt8]) { + FfiConverterFloat.write(value.x, into: &buf) + FfiConverterFloat.write(value.y, into: &buf) + FfiConverterFloat.write(value.w, into: &buf) + FfiConverterFloat.write(value.h, into: &buf) + FfiConverterFloat.write(value.contentWidth, into: &buf) } } @@ -7989,39 +8232,35 @@ public struct FfiConverterTypeUserInputQuestion: FfiConverterRustBuffer { #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeUserInputQuestion_lift(_ buf: RustBuffer) throws -> UserInputQuestion { - return try FfiConverterTypeUserInputQuestion.lift(buf) +public func FfiConverterTypeScroller_lift(_ buf: RustBuffer) throws -> Scroller { + return try FfiConverterTypeScroller.lift(buf) } #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeUserInputQuestion_lower(_ value: UserInputQuestion) -> RustBuffer { - return FfiConverterTypeUserInputQuestion.lower(value) +public func FfiConverterTypeScroller_lower(_ value: Scroller) -> RustBuffer { + return FfiConverterTypeScroller.lower(value) } -public struct Widget: Equatable, Hashable { - public var id: UInt32 - public var kind: WidgetKind - public var x: Float - public var y: Float - public var w: Float - public var h: Float - public var payload: String? - public var scroller: UInt32? +public struct SearchHit: Equatable, Hashable { + public var session: SessionRow + public var score: UInt32 + /** + * The strongest field that matched. + */ + public var field: SearchField // Default memberwise initializers are never public by default, so we // declare one manually. - public init(id: UInt32, kind: WidgetKind, x: Float, y: Float, w: Float, h: Float, payload: String?, scroller: UInt32?) { - self.id = id - self.kind = kind - self.x = x - self.y = y - self.w = w - self.h = h - self.payload = payload - self.scroller = scroller + public init(session: SessionRow, score: UInt32, + /** + * The strongest field that matched. + */field: SearchField) { + self.session = session + self.score = score + self.field = field } @@ -8030,36 +8269,26 @@ public struct Widget: Equatable, Hashable { } #if compiler(>=6) -extension Widget: Sendable {} +extension SearchHit: Sendable {} #endif #if swift(>=5.8) @_documentation(visibility: private) #endif -public struct FfiConverterTypeWidget: FfiConverterRustBuffer { - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> Widget { +public struct FfiConverterTypeSearchHit: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SearchHit { return - try Widget( - id: FfiConverterUInt32.read(from: &buf), - kind: FfiConverterTypeWidgetKind.read(from: &buf), - x: FfiConverterFloat.read(from: &buf), - y: FfiConverterFloat.read(from: &buf), - w: FfiConverterFloat.read(from: &buf), - h: FfiConverterFloat.read(from: &buf), - payload: FfiConverterOptionString.read(from: &buf), - scroller: FfiConverterOptionUInt32.read(from: &buf) + try SearchHit( + session: FfiConverterTypeSessionRow.read(from: &buf), + score: FfiConverterUInt32.read(from: &buf), + field: FfiConverterTypeSearchField.read(from: &buf) ) } - public static func write(_ value: Widget, into buf: inout [UInt8]) { - FfiConverterUInt32.write(value.id, into: &buf) - FfiConverterTypeWidgetKind.write(value.kind, into: &buf) - FfiConverterFloat.write(value.x, into: &buf) - FfiConverterFloat.write(value.y, into: &buf) - FfiConverterFloat.write(value.w, into: &buf) - FfiConverterFloat.write(value.h, into: &buf) - FfiConverterOptionString.write(value.payload, into: &buf) - FfiConverterOptionUInt32.write(value.scroller, into: &buf) + public static func write(_ value: SearchHit, into buf: inout [UInt8]) { + FfiConverterTypeSessionRow.write(value.session, into: &buf) + FfiConverterUInt32.write(value.score, into: &buf) + FfiConverterTypeSearchField.write(value.field, into: &buf) } } @@ -8067,70 +8296,37 @@ public struct FfiConverterTypeWidget: FfiConverterRustBuffer { #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeWidget_lift(_ buf: RustBuffer) throws -> Widget { - return try FfiConverterTypeWidget.lift(buf) +public func FfiConverterTypeSearchHit_lift(_ buf: RustBuffer) throws -> SearchHit { + return try FfiConverterTypeSearchHit.lift(buf) } #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeWidget_lower(_ value: Widget) -> RustBuffer { - return FfiConverterTypeWidget.lower(value) +public func FfiConverterTypeSearchHit_lower(_ value: SearchHit) -> RustBuffer { + return FfiConverterTypeSearchHit.lower(value) } -/** - * Everything the workspace screens render. - */ -public struct WorkspaceSnapshot: Equatable, Hashable { - public var revision: UInt64 - public var computedAtMs: Int64 - /** - * Pin/section edits allowed (sidebar prefs known). - */ - public var pinsReady: Bool - /** - * The registry reached this device at least once (or demo). - */ - public var synced: Bool - public var front: FrontPage - /** - * Creation order. - */ - public var projects: [ProjectView] +public struct SectionView: Equatable, Hashable { + public var id: String + public var name: String + public var collapsed: Bool /** - * Active sessions without a project. + * Recency order (pinned sessions excluded). */ - public var projectless: [SessionRow] - public var pullRequests: PullRequestGroups - public var archived: [SessionRow] - public var devices: [DeviceView] + public var sessions: [SessionRow] // Default memberwise initializers are never public by default, so we // declare one manually. - public init(revision: UInt64, computedAtMs: Int64, - /** - * Pin/section edits allowed (sidebar prefs known). - */pinsReady: Bool, - /** - * The registry reached this device at least once (or demo). - */synced: Bool, front: FrontPage, - /** - * Creation order. - */projects: [ProjectView], + public init(id: String, name: String, collapsed: Bool, /** - * Active sessions without a project. - */projectless: [SessionRow], pullRequests: PullRequestGroups, archived: [SessionRow], devices: [DeviceView]) { - self.revision = revision - self.computedAtMs = computedAtMs - self.pinsReady = pinsReady - self.synced = synced - self.front = front - self.projects = projects - self.projectless = projectless - self.pullRequests = pullRequests - self.archived = archived - self.devices = devices + * Recency order (pinned sessions excluded). + */sessions: [SessionRow]) { + self.id = id + self.name = name + self.collapsed = collapsed + self.sessions = sessions } @@ -8139,40 +8335,28 @@ public struct WorkspaceSnapshot: Equatable, Hashable { } #if compiler(>=6) -extension WorkspaceSnapshot: Sendable {} +extension SectionView: Sendable {} #endif #if swift(>=5.8) @_documentation(visibility: private) #endif -public struct FfiConverterTypeWorkspaceSnapshot: FfiConverterRustBuffer { - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> WorkspaceSnapshot { +public struct FfiConverterTypeSectionView: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SectionView { return - try WorkspaceSnapshot( - revision: FfiConverterUInt64.read(from: &buf), - computedAtMs: FfiConverterInt64.read(from: &buf), - pinsReady: FfiConverterBool.read(from: &buf), - synced: FfiConverterBool.read(from: &buf), - front: FfiConverterTypeFrontPage.read(from: &buf), - projects: FfiConverterSequenceTypeProjectView.read(from: &buf), - projectless: FfiConverterSequenceTypeSessionRow.read(from: &buf), - pullRequests: FfiConverterTypePullRequestGroups.read(from: &buf), - archived: FfiConverterSequenceTypeSessionRow.read(from: &buf), - devices: FfiConverterSequenceTypeDeviceView.read(from: &buf) + try SectionView( + id: FfiConverterString.read(from: &buf), + name: FfiConverterString.read(from: &buf), + collapsed: FfiConverterBool.read(from: &buf), + sessions: FfiConverterSequenceTypeSessionRow.read(from: &buf) ) } - public static func write(_ value: WorkspaceSnapshot, into buf: inout [UInt8]) { - FfiConverterUInt64.write(value.revision, into: &buf) - FfiConverterInt64.write(value.computedAtMs, into: &buf) - FfiConverterBool.write(value.pinsReady, into: &buf) - FfiConverterBool.write(value.synced, into: &buf) - FfiConverterTypeFrontPage.write(value.front, into: &buf) - FfiConverterSequenceTypeProjectView.write(value.projects, into: &buf) - FfiConverterSequenceTypeSessionRow.write(value.projectless, into: &buf) - FfiConverterTypePullRequestGroups.write(value.pullRequests, into: &buf) - FfiConverterSequenceTypeSessionRow.write(value.archived, into: &buf) - FfiConverterSequenceTypeDeviceView.write(value.devices, into: &buf) + public static func write(_ value: SectionView, into buf: inout [UInt8]) { + FfiConverterString.write(value.id, into: &buf) + FfiConverterString.write(value.name, into: &buf) + FfiConverterBool.write(value.collapsed, into: &buf) + FfiConverterSequenceTypeSessionRow.write(value.sessions, into: &buf) } } @@ -8180,41 +8364,37 @@ public struct FfiConverterTypeWorkspaceSnapshot: FfiConverterRustBuffer { #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeWorkspaceSnapshot_lift(_ buf: RustBuffer) throws -> WorkspaceSnapshot { - return try FfiConverterTypeWorkspaceSnapshot.lift(buf) +public func FfiConverterTypeSectionView_lift(_ buf: RustBuffer) throws -> SectionView { + return try FfiConverterTypeSectionView.lift(buf) } #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeWorkspaceSnapshot_lower(_ value: WorkspaceSnapshot) -> RustBuffer { - return FfiConverterTypeWorkspaceSnapshot.lower(value) +public func FfiConverterTypeSectionView_lower(_ value: SectionView) -> RustBuffer { + return FfiConverterTypeSectionView.lower(value) } -public struct WorktreeSpec: Equatable, Hashable { - /** - * The repo to branch (the project's folder on the host). - */ - public var repoPath: String +public struct SendRequest: Equatable, Hashable { + public var text: String + public var attachments: [OutgoingAttachment] /** - * Base ref for the fresh `zeron/` branch. + * Isolated host worktree for this run (new-session "New worktree"). */ - public var base: String - public var spaceId: String? + public var worktree: WorktreeSpec? + public var busy: BusyPolicy // Default memberwise initializers are never public by default, so we // declare one manually. - public init( - /** - * The repo to branch (the project's folder on the host). - */repoPath: String, + public init(text: String, attachments: [OutgoingAttachment], /** - * Base ref for the fresh `zeron/` branch. - */base: String, spaceId: String?) { - self.repoPath = repoPath - self.base = base - self.spaceId = spaceId + * Isolated host worktree for this run (new-session "New worktree"). + */worktree: WorktreeSpec?, busy: BusyPolicy) { + self.text = text + self.attachments = attachments + self.worktree = worktree + self.busy = busy } @@ -8223,26 +8403,28 @@ public struct WorktreeSpec: Equatable, Hashable { } #if compiler(>=6) -extension WorktreeSpec: Sendable {} +extension SendRequest: Sendable {} #endif #if swift(>=5.8) @_documentation(visibility: private) #endif -public struct FfiConverterTypeWorktreeSpec: FfiConverterRustBuffer { - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> WorktreeSpec { +public struct FfiConverterTypeSendRequest: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SendRequest { return - try WorktreeSpec( - repoPath: FfiConverterString.read(from: &buf), - base: FfiConverterString.read(from: &buf), - spaceId: FfiConverterOptionString.read(from: &buf) + try SendRequest( + text: FfiConverterString.read(from: &buf), + attachments: FfiConverterSequenceTypeOutgoingAttachment.read(from: &buf), + worktree: FfiConverterOptionTypeWorktreeSpec.read(from: &buf), + busy: FfiConverterTypeBusyPolicy.read(from: &buf) ) } - public static func write(_ value: WorktreeSpec, into buf: inout [UInt8]) { - FfiConverterString.write(value.repoPath, into: &buf) - FfiConverterString.write(value.base, into: &buf) - FfiConverterOptionString.write(value.spaceId, into: &buf) + public static func write(_ value: SendRequest, into buf: inout [UInt8]) { + FfiConverterString.write(value.text, into: &buf) + FfiConverterSequenceTypeOutgoingAttachment.write(value.attachments, into: &buf) + FfiConverterOptionTypeWorktreeSpec.write(value.worktree, into: &buf) + FfiConverterTypeBusyPolicy.write(value.busy, into: &buf) } } @@ -8250,25 +8432,2035 @@ public struct FfiConverterTypeWorktreeSpec: FfiConverterRustBuffer { #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeWorktreeSpec_lift(_ buf: RustBuffer) throws -> WorktreeSpec { - return try FfiConverterTypeWorktreeSpec.lift(buf) +public func FfiConverterTypeSendRequest_lift(_ buf: RustBuffer) throws -> SendRequest { + return try FfiConverterTypeSendRequest.lift(buf) } #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeWorktreeSpec_lower(_ value: WorktreeSpec) -> RustBuffer { - return FfiConverterTypeWorktreeSpec.lower(value) +public func FfiConverterTypeSendRequest_lower(_ value: SendRequest) -> RustBuffer { + return FfiConverterTypeSendRequest.lower(value) } - -public enum AuthCallback: Equatable, Hashable { - - case code(code: String, state: String? - ) - case error(error: String, description: String? - ) +/** + * One session row, as every list renders it. + */ +public struct SessionRow: Equatable, Hashable { + public var id: String + /** + * Content hash — equal ⇒ nothing to redraw. + */ + public var revision: UInt64 + /** + * Display title ("New session" when untitled). + */ + public var title: String + public var hasTitle: Bool + public var preview: String? + /** + * `None` = projectless (host's home folder). + */ + public var project: ProjectRef? + public var deviceId: String + public var deviceName: String? + public var deviceOnline: Bool + /** + * Wire harness id (`claude-code`). + */ + public var harness: String? + public var harnessLabel: String? + public var model: String? + public var modelLabel: String? + /** + * Wire effort (`xhigh`). + */ + public var reasoning: String? + public var branch: String? + public var cwd: String? + /** + * Staleness-gated (45s) live status; an in-flight send reads Working. + */ + public var indicator: ChatIndicator + /** + * Host-reported status only (no local-send override). + */ + public var hostIndicator: ChatIndicator + /** + * Run start of the live turn while Working/AwaitingInput. + */ + public var workingSinceMs: Int64? + public var lastActivityMs: Int64 + /** + * `now` / `34m` / `4h` / `2d` at derive time. + */ + public var timeLabel: String + public var createdAtMs: Int64 + public var unseen: Bool + public var archived: Bool + public var pinned: Bool + public var sectionId: String? + public var pullRequest: PullRequest? + /** + * Oldest unadopted send from this device (open sessions only). + */ + public var sendState: SendState? + public var parentChatId: String? + /** + * 2 = chat2; 1 = legacy (not dialable). + */ + public var roomGen: UInt32 + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(id: String, + /** + * Content hash — equal ⇒ nothing to redraw. + */revision: UInt64, + /** + * Display title ("New session" when untitled). + */title: String, hasTitle: Bool, preview: String?, + /** + * `None` = projectless (host's home folder). + */project: ProjectRef?, deviceId: String, deviceName: String?, deviceOnline: Bool, + /** + * Wire harness id (`claude-code`). + */harness: String?, harnessLabel: String?, model: String?, modelLabel: String?, + /** + * Wire effort (`xhigh`). + */reasoning: String?, branch: String?, cwd: String?, + /** + * Staleness-gated (45s) live status; an in-flight send reads Working. + */indicator: ChatIndicator, + /** + * Host-reported status only (no local-send override). + */hostIndicator: ChatIndicator, + /** + * Run start of the live turn while Working/AwaitingInput. + */workingSinceMs: Int64?, lastActivityMs: Int64, + /** + * `now` / `34m` / `4h` / `2d` at derive time. + */timeLabel: String, createdAtMs: Int64, unseen: Bool, archived: Bool, pinned: Bool, sectionId: String?, pullRequest: PullRequest?, + /** + * Oldest unadopted send from this device (open sessions only). + */sendState: SendState?, parentChatId: String?, + /** + * 2 = chat2; 1 = legacy (not dialable). + */roomGen: UInt32) { + self.id = id + self.revision = revision + self.title = title + self.hasTitle = hasTitle + self.preview = preview + self.project = project + self.deviceId = deviceId + self.deviceName = deviceName + self.deviceOnline = deviceOnline + self.harness = harness + self.harnessLabel = harnessLabel + self.model = model + self.modelLabel = modelLabel + self.reasoning = reasoning + self.branch = branch + self.cwd = cwd + self.indicator = indicator + self.hostIndicator = hostIndicator + self.workingSinceMs = workingSinceMs + self.lastActivityMs = lastActivityMs + self.timeLabel = timeLabel + self.createdAtMs = createdAtMs + self.unseen = unseen + self.archived = archived + self.pinned = pinned + self.sectionId = sectionId + self.pullRequest = pullRequest + self.sendState = sendState + self.parentChatId = parentChatId + self.roomGen = roomGen + } + + + + +} + +#if compiler(>=6) +extension SessionRow: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeSessionRow: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SessionRow { + return + try SessionRow( + id: FfiConverterString.read(from: &buf), + revision: FfiConverterUInt64.read(from: &buf), + title: FfiConverterString.read(from: &buf), + hasTitle: FfiConverterBool.read(from: &buf), + preview: FfiConverterOptionString.read(from: &buf), + project: FfiConverterOptionTypeProjectRef.read(from: &buf), + deviceId: FfiConverterString.read(from: &buf), + deviceName: FfiConverterOptionString.read(from: &buf), + deviceOnline: FfiConverterBool.read(from: &buf), + harness: FfiConverterOptionString.read(from: &buf), + harnessLabel: FfiConverterOptionString.read(from: &buf), + model: FfiConverterOptionString.read(from: &buf), + modelLabel: FfiConverterOptionString.read(from: &buf), + reasoning: FfiConverterOptionString.read(from: &buf), + branch: FfiConverterOptionString.read(from: &buf), + cwd: FfiConverterOptionString.read(from: &buf), + indicator: FfiConverterTypeChatIndicator.read(from: &buf), + hostIndicator: FfiConverterTypeChatIndicator.read(from: &buf), + workingSinceMs: FfiConverterOptionInt64.read(from: &buf), + lastActivityMs: FfiConverterInt64.read(from: &buf), + timeLabel: FfiConverterString.read(from: &buf), + createdAtMs: FfiConverterInt64.read(from: &buf), + unseen: FfiConverterBool.read(from: &buf), + archived: FfiConverterBool.read(from: &buf), + pinned: FfiConverterBool.read(from: &buf), + sectionId: FfiConverterOptionString.read(from: &buf), + pullRequest: FfiConverterOptionTypePullRequest.read(from: &buf), + sendState: FfiConverterOptionTypeSendState.read(from: &buf), + parentChatId: FfiConverterOptionString.read(from: &buf), + roomGen: FfiConverterUInt32.read(from: &buf) + ) + } + + public static func write(_ value: SessionRow, into buf: inout [UInt8]) { + FfiConverterString.write(value.id, into: &buf) + FfiConverterUInt64.write(value.revision, into: &buf) + FfiConverterString.write(value.title, into: &buf) + FfiConverterBool.write(value.hasTitle, into: &buf) + FfiConverterOptionString.write(value.preview, into: &buf) + FfiConverterOptionTypeProjectRef.write(value.project, into: &buf) + FfiConverterString.write(value.deviceId, into: &buf) + FfiConverterOptionString.write(value.deviceName, into: &buf) + FfiConverterBool.write(value.deviceOnline, into: &buf) + FfiConverterOptionString.write(value.harness, into: &buf) + FfiConverterOptionString.write(value.harnessLabel, into: &buf) + FfiConverterOptionString.write(value.model, into: &buf) + FfiConverterOptionString.write(value.modelLabel, into: &buf) + FfiConverterOptionString.write(value.reasoning, into: &buf) + FfiConverterOptionString.write(value.branch, into: &buf) + FfiConverterOptionString.write(value.cwd, into: &buf) + FfiConverterTypeChatIndicator.write(value.indicator, into: &buf) + FfiConverterTypeChatIndicator.write(value.hostIndicator, into: &buf) + FfiConverterOptionInt64.write(value.workingSinceMs, into: &buf) + FfiConverterInt64.write(value.lastActivityMs, into: &buf) + FfiConverterString.write(value.timeLabel, into: &buf) + FfiConverterInt64.write(value.createdAtMs, into: &buf) + FfiConverterBool.write(value.unseen, into: &buf) + FfiConverterBool.write(value.archived, into: &buf) + FfiConverterBool.write(value.pinned, into: &buf) + FfiConverterOptionString.write(value.sectionId, into: &buf) + FfiConverterOptionTypePullRequest.write(value.pullRequest, into: &buf) + FfiConverterOptionTypeSendState.write(value.sendState, into: &buf) + FfiConverterOptionString.write(value.parentChatId, into: &buf) + FfiConverterUInt32.write(value.roomGen, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeSessionRow_lift(_ buf: RustBuffer) throws -> SessionRow { + return try FfiConverterTypeSessionRow.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeSessionRow_lower(_ value: SessionRow) -> RustBuffer { + return FfiConverterTypeSessionRow.lower(value) +} + + +/** + * A style the platform must be able to draw: `id` → (face, size). + */ +public struct StyleDesc: Equatable, Hashable { + public var id: UInt16 + public var face: FaceRole + public var size: Float + public var ligatures: Bool + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(id: UInt16, face: FaceRole, size: Float, ligatures: Bool) { + self.id = id + self.face = face + self.size = size + self.ligatures = ligatures + } + + + + +} + +#if compiler(>=6) +extension StyleDesc: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeStyleDesc: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> StyleDesc { + return + try StyleDesc( + id: FfiConverterUInt16.read(from: &buf), + face: FfiConverterTypeFaceRole.read(from: &buf), + size: FfiConverterFloat.read(from: &buf), + ligatures: FfiConverterBool.read(from: &buf) + ) + } + + public static func write(_ value: StyleDesc, into buf: inout [UInt8]) { + FfiConverterUInt16.write(value.id, into: &buf) + FfiConverterTypeFaceRole.write(value.face, into: &buf) + FfiConverterFloat.write(value.size, into: &buf) + FfiConverterBool.write(value.ligatures, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeStyleDesc_lift(_ buf: RustBuffer) throws -> StyleDesc { + return try FfiConverterTypeStyleDesc.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeStyleDesc_lower(_ value: StyleDesc) -> RustBuffer { + return FfiConverterTypeStyleDesc.lower(value) +} + + +/** + * One positioned piece of text: `text[start..start+len]` (UTF-16) drawn with + * `style` so its origin sits at (`x`, `baseline`). + */ +public struct TextRun: Equatable, Hashable { + public var start: UInt32 + public var len: UInt32 + public var x: Float + public var baseline: Float + /** + * Rust-measured advance — painters may use it for selection/hit rects. + */ + public var width: Float + public var style: UInt16 + public var color: ColorRole + public var decoration: Decoration + /** + * Index into [`RowDisplay::scrollers`] when the run scrolls horizontally. + */ + public var scroller: UInt32? + /** + * Fade-in start in ms since the row's layout epoch (streaming veil); + * `None` = fully visible. + */ + public var revealMs: UInt32? + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(start: UInt32, len: UInt32, x: Float, baseline: Float, + /** + * Rust-measured advance — painters may use it for selection/hit rects. + */width: Float, style: UInt16, color: ColorRole, decoration: Decoration, + /** + * Index into [`RowDisplay::scrollers`] when the run scrolls horizontally. + */scroller: UInt32?, + /** + * Fade-in start in ms since the row's layout epoch (streaming veil); + * `None` = fully visible. + */revealMs: UInt32?) { + self.start = start + self.len = len + self.x = x + self.baseline = baseline + self.width = width + self.style = style + self.color = color + self.decoration = decoration + self.scroller = scroller + self.revealMs = revealMs + } + + + + +} + +#if compiler(>=6) +extension TextRun: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeTextRun: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> TextRun { + return + try TextRun( + start: FfiConverterUInt32.read(from: &buf), + len: FfiConverterUInt32.read(from: &buf), + x: FfiConverterFloat.read(from: &buf), + baseline: FfiConverterFloat.read(from: &buf), + width: FfiConverterFloat.read(from: &buf), + style: FfiConverterUInt16.read(from: &buf), + color: FfiConverterTypeColorRole.read(from: &buf), + decoration: FfiConverterTypeDecoration.read(from: &buf), + scroller: FfiConverterOptionUInt32.read(from: &buf), + revealMs: FfiConverterOptionUInt32.read(from: &buf) + ) + } + + public static func write(_ value: TextRun, into buf: inout [UInt8]) { + FfiConverterUInt32.write(value.start, into: &buf) + FfiConverterUInt32.write(value.len, into: &buf) + FfiConverterFloat.write(value.x, into: &buf) + FfiConverterFloat.write(value.baseline, into: &buf) + FfiConverterFloat.write(value.width, into: &buf) + FfiConverterUInt16.write(value.style, into: &buf) + FfiConverterTypeColorRole.write(value.color, into: &buf) + FfiConverterTypeDecoration.write(value.decoration, into: &buf) + FfiConverterOptionUInt32.write(value.scroller, into: &buf) + FfiConverterOptionUInt32.write(value.revealMs, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeTextRun_lift(_ buf: RustBuffer) throws -> TextRun { + return try FfiConverterTypeTextRun.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeTextRun_lower(_ value: TextRun) -> RustBuffer { + return FfiConverterTypeTextRun.lower(value) +} + + +/** + * Transcript-level facts the platform needs without the rows (loader vs + * empty state, scroll-to-bottom affordances, the working pill). + */ +public struct TranscriptStatus: Equatable, Hashable { + public var revision: UInt64 + /** + * Content present (false = show a loader, not an empty chat). + */ + public var hydrated: Bool + /** + * Host-written entries. + */ + public var entryCount: UInt32 + public var pendingCount: UInt32 + public var streaming: Bool + public var working: Bool + public var workingSinceMs: Int64? + /** + * Id of the newest entry (host or echo). + */ + public var lastEntryId: String? + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(revision: UInt64, + /** + * Content present (false = show a loader, not an empty chat). + */hydrated: Bool, + /** + * Host-written entries. + */entryCount: UInt32, pendingCount: UInt32, streaming: Bool, working: Bool, workingSinceMs: Int64?, + /** + * Id of the newest entry (host or echo). + */lastEntryId: String?) { + self.revision = revision + self.hydrated = hydrated + self.entryCount = entryCount + self.pendingCount = pendingCount + self.streaming = streaming + self.working = working + self.workingSinceMs = workingSinceMs + self.lastEntryId = lastEntryId + } + + + + +} + +#if compiler(>=6) +extension TranscriptStatus: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeTranscriptStatus: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> TranscriptStatus { + return + try TranscriptStatus( + revision: FfiConverterUInt64.read(from: &buf), + hydrated: FfiConverterBool.read(from: &buf), + entryCount: FfiConverterUInt32.read(from: &buf), + pendingCount: FfiConverterUInt32.read(from: &buf), + streaming: FfiConverterBool.read(from: &buf), + working: FfiConverterBool.read(from: &buf), + workingSinceMs: FfiConverterOptionInt64.read(from: &buf), + lastEntryId: FfiConverterOptionString.read(from: &buf) + ) + } + + public static func write(_ value: TranscriptStatus, into buf: inout [UInt8]) { + FfiConverterUInt64.write(value.revision, into: &buf) + FfiConverterBool.write(value.hydrated, into: &buf) + FfiConverterUInt32.write(value.entryCount, into: &buf) + FfiConverterUInt32.write(value.pendingCount, into: &buf) + FfiConverterBool.write(value.streaming, into: &buf) + FfiConverterBool.write(value.working, into: &buf) + FfiConverterOptionInt64.write(value.workingSinceMs, into: &buf) + FfiConverterOptionString.write(value.lastEntryId, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeTranscriptStatus_lift(_ buf: RustBuffer) throws -> TranscriptStatus { + return try FfiConverterTypeTranscriptStatus.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeTranscriptStatus_lower(_ value: TranscriptStatus) -> RustBuffer { + return FfiConverterTypeTranscriptStatus.lower(value) +} + + +public struct UserImage: Equatable, Hashable { + /** + * Host path (or `pending://` ref) — read via `read_attachment`. + */ + public var path: String + public var name: String + public var appshot: AppshotLabel? + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init( + /** + * Host path (or `pending://` ref) — read via `read_attachment`. + */path: String, name: String, appshot: AppshotLabel?) { + self.path = path + self.name = name + self.appshot = appshot + } + + + + +} + +#if compiler(>=6) +extension UserImage: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeUserImage: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> UserImage { + return + try UserImage( + path: FfiConverterString.read(from: &buf), + name: FfiConverterString.read(from: &buf), + appshot: FfiConverterOptionTypeAppshotLabel.read(from: &buf) + ) + } + + public static func write(_ value: UserImage, into buf: inout [UInt8]) { + FfiConverterString.write(value.path, into: &buf) + FfiConverterString.write(value.name, into: &buf) + FfiConverterOptionTypeAppshotLabel.write(value.appshot, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeUserImage_lift(_ buf: RustBuffer) throws -> UserImage { + return try FfiConverterTypeUserImage.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeUserImage_lower(_ value: UserImage) -> RustBuffer { + return FfiConverterTypeUserImage.lower(value) +} + + +public struct UserInputAnswer: Equatable, Hashable { + public var questionId: String + /** + * Picked option labels (or free text as a single label). + */ + public var labels: [String] + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(questionId: String, + /** + * Picked option labels (or free text as a single label). + */labels: [String]) { + self.questionId = questionId + self.labels = labels + } + + + + +} + +#if compiler(>=6) +extension UserInputAnswer: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeUserInputAnswer: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> UserInputAnswer { + return + try UserInputAnswer( + questionId: FfiConverterString.read(from: &buf), + labels: FfiConverterSequenceString.read(from: &buf) + ) + } + + public static func write(_ value: UserInputAnswer, into buf: inout [UInt8]) { + FfiConverterString.write(value.questionId, into: &buf) + FfiConverterSequenceString.write(value.labels, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeUserInputAnswer_lift(_ buf: RustBuffer) throws -> UserInputAnswer { + return try FfiConverterTypeUserInputAnswer.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeUserInputAnswer_lower(_ value: UserInputAnswer) -> RustBuffer { + return FfiConverterTypeUserInputAnswer.lower(value) +} + + +public struct UserInputQuestion: Equatable, Hashable { + public var id: String + public var header: String + public var question: String + public var options: [String] + public var multiSelect: Bool + public var prefill: String? + public var multiline: Bool + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(id: String, header: String, question: String, options: [String], multiSelect: Bool, prefill: String?, multiline: Bool) { + self.id = id + self.header = header + self.question = question + self.options = options + self.multiSelect = multiSelect + self.prefill = prefill + self.multiline = multiline + } + + + + +} + +#if compiler(>=6) +extension UserInputQuestion: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeUserInputQuestion: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> UserInputQuestion { + return + try UserInputQuestion( + id: FfiConverterString.read(from: &buf), + header: FfiConverterString.read(from: &buf), + question: FfiConverterString.read(from: &buf), + options: FfiConverterSequenceString.read(from: &buf), + multiSelect: FfiConverterBool.read(from: &buf), + prefill: FfiConverterOptionString.read(from: &buf), + multiline: FfiConverterBool.read(from: &buf) + ) + } + + public static func write(_ value: UserInputQuestion, into buf: inout [UInt8]) { + FfiConverterString.write(value.id, into: &buf) + FfiConverterString.write(value.header, into: &buf) + FfiConverterString.write(value.question, into: &buf) + FfiConverterSequenceString.write(value.options, into: &buf) + FfiConverterBool.write(value.multiSelect, into: &buf) + FfiConverterOptionString.write(value.prefill, into: &buf) + FfiConverterBool.write(value.multiline, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeUserInputQuestion_lift(_ buf: RustBuffer) throws -> UserInputQuestion { + return try FfiConverterTypeUserInputQuestion.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeUserInputQuestion_lower(_ value: UserInputQuestion) -> RustBuffer { + return FfiConverterTypeUserInputQuestion.lower(value) +} + + +/** + * Everything a call screen shows. Ephemeral: never log or persist captions. + */ +public struct VoiceCallState: Equatable, Hashable { + public var phase: VoiceCallPhase + public var orb: VoiceOrb + /** + * Full id of the host's orchestrator chat (its canonical transcript). + */ + public var chatId: String? + public var work: VoiceCallWork + public var muted: Bool + /** + * The assistant's voice is playing. + */ + public var speaking: Bool + public var caption: String + /** + * Set once the caption is a final segment; live partials have none. + */ + public var captionSpeaker: VoiceSpeaker? + /** + * The utterance the caption belongs to: a change is a new speaker turn. + */ + public var captionItem: String? + /** + * Normalized 0…1 peaks (microphone is 0 while muted). + */ + public var microphone: Float + public var speaker: Float + /** + * Styles the host offers, once it reports them. + */ + public var voices: [String] + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(phase: VoiceCallPhase, orb: VoiceOrb, + /** + * Full id of the host's orchestrator chat (its canonical transcript). + */chatId: String?, work: VoiceCallWork, muted: Bool, + /** + * The assistant's voice is playing. + */speaking: Bool, caption: String, + /** + * Set once the caption is a final segment; live partials have none. + */captionSpeaker: VoiceSpeaker?, + /** + * The utterance the caption belongs to: a change is a new speaker turn. + */captionItem: String?, + /** + * Normalized 0…1 peaks (microphone is 0 while muted). + */microphone: Float, speaker: Float, + /** + * Styles the host offers, once it reports them. + */voices: [String]) { + self.phase = phase + self.orb = orb + self.chatId = chatId + self.work = work + self.muted = muted + self.speaking = speaking + self.caption = caption + self.captionSpeaker = captionSpeaker + self.captionItem = captionItem + self.microphone = microphone + self.speaker = speaker + self.voices = voices + } + + + + +} + +#if compiler(>=6) +extension VoiceCallState: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeVoiceCallState: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> VoiceCallState { + return + try VoiceCallState( + phase: FfiConverterTypeVoiceCallPhase.read(from: &buf), + orb: FfiConverterTypeVoiceOrb.read(from: &buf), + chatId: FfiConverterOptionString.read(from: &buf), + work: FfiConverterTypeVoiceCallWork.read(from: &buf), + muted: FfiConverterBool.read(from: &buf), + speaking: FfiConverterBool.read(from: &buf), + caption: FfiConverterString.read(from: &buf), + captionSpeaker: FfiConverterOptionTypeVoiceSpeaker.read(from: &buf), + captionItem: FfiConverterOptionString.read(from: &buf), + microphone: FfiConverterFloat.read(from: &buf), + speaker: FfiConverterFloat.read(from: &buf), + voices: FfiConverterSequenceString.read(from: &buf) + ) + } + + public static func write(_ value: VoiceCallState, into buf: inout [UInt8]) { + FfiConverterTypeVoiceCallPhase.write(value.phase, into: &buf) + FfiConverterTypeVoiceOrb.write(value.orb, into: &buf) + FfiConverterOptionString.write(value.chatId, into: &buf) + FfiConverterTypeVoiceCallWork.write(value.work, into: &buf) + FfiConverterBool.write(value.muted, into: &buf) + FfiConverterBool.write(value.speaking, into: &buf) + FfiConverterString.write(value.caption, into: &buf) + FfiConverterOptionTypeVoiceSpeaker.write(value.captionSpeaker, into: &buf) + FfiConverterOptionString.write(value.captionItem, into: &buf) + FfiConverterFloat.write(value.microphone, into: &buf) + FfiConverterFloat.write(value.speaker, into: &buf) + FfiConverterSequenceString.write(value.voices, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeVoiceCallState_lift(_ buf: RustBuffer) throws -> VoiceCallState { + return try FfiConverterTypeVoiceCallState.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeVoiceCallState_lower(_ value: VoiceCallState) -> RustBuffer { + return FfiConverterTypeVoiceCallState.lower(value) +} + + +public struct VoiceMediaRequest: Equatable, Hashable { + public var requestId: UInt64 + public var operation: VoiceMediaOperation + public var sdp: String? + public var muted: Bool + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(requestId: UInt64, operation: VoiceMediaOperation, sdp: String?, muted: Bool) { + self.requestId = requestId + self.operation = operation + self.sdp = sdp + self.muted = muted + } + + + + +} + +#if compiler(>=6) +extension VoiceMediaRequest: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeVoiceMediaRequest: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> VoiceMediaRequest { + return + try VoiceMediaRequest( + requestId: FfiConverterUInt64.read(from: &buf), + operation: FfiConverterTypeVoiceMediaOperation.read(from: &buf), + sdp: FfiConverterOptionString.read(from: &buf), + muted: FfiConverterBool.read(from: &buf) + ) + } + + public static func write(_ value: VoiceMediaRequest, into buf: inout [UInt8]) { + FfiConverterUInt64.write(value.requestId, into: &buf) + FfiConverterTypeVoiceMediaOperation.write(value.operation, into: &buf) + FfiConverterOptionString.write(value.sdp, into: &buf) + FfiConverterBool.write(value.muted, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeVoiceMediaRequest_lift(_ buf: RustBuffer) throws -> VoiceMediaRequest { + return try FfiConverterTypeVoiceMediaRequest.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeVoiceMediaRequest_lower(_ value: VoiceMediaRequest) -> RustBuffer { + return FfiConverterTypeVoiceMediaRequest.lower(value) +} + + +public struct Widget: Equatable, Hashable { + public var id: UInt32 + public var kind: WidgetKind + public var x: Float + public var y: Float + public var w: Float + public var h: Float + public var payload: String? + public var scroller: UInt32? + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(id: UInt32, kind: WidgetKind, x: Float, y: Float, w: Float, h: Float, payload: String?, scroller: UInt32?) { + self.id = id + self.kind = kind + self.x = x + self.y = y + self.w = w + self.h = h + self.payload = payload + self.scroller = scroller + } + + + + +} + +#if compiler(>=6) +extension Widget: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeWidget: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> Widget { + return + try Widget( + id: FfiConverterUInt32.read(from: &buf), + kind: FfiConverterTypeWidgetKind.read(from: &buf), + x: FfiConverterFloat.read(from: &buf), + y: FfiConverterFloat.read(from: &buf), + w: FfiConverterFloat.read(from: &buf), + h: FfiConverterFloat.read(from: &buf), + payload: FfiConverterOptionString.read(from: &buf), + scroller: FfiConverterOptionUInt32.read(from: &buf) + ) + } + + public static func write(_ value: Widget, into buf: inout [UInt8]) { + FfiConverterUInt32.write(value.id, into: &buf) + FfiConverterTypeWidgetKind.write(value.kind, into: &buf) + FfiConverterFloat.write(value.x, into: &buf) + FfiConverterFloat.write(value.y, into: &buf) + FfiConverterFloat.write(value.w, into: &buf) + FfiConverterFloat.write(value.h, into: &buf) + FfiConverterOptionString.write(value.payload, into: &buf) + FfiConverterOptionUInt32.write(value.scroller, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeWidget_lift(_ buf: RustBuffer) throws -> Widget { + return try FfiConverterTypeWidget.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeWidget_lower(_ value: Widget) -> RustBuffer { + return FfiConverterTypeWidget.lower(value) +} + + +/** + * Everything the workspace screens render. + */ +public struct WorkspaceSnapshot: Equatable, Hashable { + public var revision: UInt64 + public var computedAtMs: Int64 + /** + * Pin/section edits allowed (sidebar prefs known). + */ + public var pinsReady: Bool + /** + * The registry reached this device at least once (or demo). + */ + public var synced: Bool + public var front: FrontPage + /** + * Creation order. + */ + public var projects: [ProjectView] + /** + * Active sessions without a project. + */ + public var projectless: [SessionRow] + public var pullRequests: PullRequestGroups + public var archived: [SessionRow] + public var devices: [DeviceView] + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(revision: UInt64, computedAtMs: Int64, + /** + * Pin/section edits allowed (sidebar prefs known). + */pinsReady: Bool, + /** + * The registry reached this device at least once (or demo). + */synced: Bool, front: FrontPage, + /** + * Creation order. + */projects: [ProjectView], + /** + * Active sessions without a project. + */projectless: [SessionRow], pullRequests: PullRequestGroups, archived: [SessionRow], devices: [DeviceView]) { + self.revision = revision + self.computedAtMs = computedAtMs + self.pinsReady = pinsReady + self.synced = synced + self.front = front + self.projects = projects + self.projectless = projectless + self.pullRequests = pullRequests + self.archived = archived + self.devices = devices + } + + + + +} + +#if compiler(>=6) +extension WorkspaceSnapshot: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeWorkspaceSnapshot: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> WorkspaceSnapshot { + return + try WorkspaceSnapshot( + revision: FfiConverterUInt64.read(from: &buf), + computedAtMs: FfiConverterInt64.read(from: &buf), + pinsReady: FfiConverterBool.read(from: &buf), + synced: FfiConverterBool.read(from: &buf), + front: FfiConverterTypeFrontPage.read(from: &buf), + projects: FfiConverterSequenceTypeProjectView.read(from: &buf), + projectless: FfiConverterSequenceTypeSessionRow.read(from: &buf), + pullRequests: FfiConverterTypePullRequestGroups.read(from: &buf), + archived: FfiConverterSequenceTypeSessionRow.read(from: &buf), + devices: FfiConverterSequenceTypeDeviceView.read(from: &buf) + ) + } + + public static func write(_ value: WorkspaceSnapshot, into buf: inout [UInt8]) { + FfiConverterUInt64.write(value.revision, into: &buf) + FfiConverterInt64.write(value.computedAtMs, into: &buf) + FfiConverterBool.write(value.pinsReady, into: &buf) + FfiConverterBool.write(value.synced, into: &buf) + FfiConverterTypeFrontPage.write(value.front, into: &buf) + FfiConverterSequenceTypeProjectView.write(value.projects, into: &buf) + FfiConverterSequenceTypeSessionRow.write(value.projectless, into: &buf) + FfiConverterTypePullRequestGroups.write(value.pullRequests, into: &buf) + FfiConverterSequenceTypeSessionRow.write(value.archived, into: &buf) + FfiConverterSequenceTypeDeviceView.write(value.devices, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeWorkspaceSnapshot_lift(_ buf: RustBuffer) throws -> WorkspaceSnapshot { + return try FfiConverterTypeWorkspaceSnapshot.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeWorkspaceSnapshot_lower(_ value: WorkspaceSnapshot) -> RustBuffer { + return FfiConverterTypeWorkspaceSnapshot.lower(value) +} + + +public struct WorktreeSpec: Equatable, Hashable { + /** + * The repo to branch (the project's folder on the host). + */ + public var repoPath: String + /** + * Base ref for the fresh `zeron/` branch. + */ + public var base: String + public var spaceId: String? + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init( + /** + * The repo to branch (the project's folder on the host). + */repoPath: String, + /** + * Base ref for the fresh `zeron/` branch. + */base: String, spaceId: String?) { + self.repoPath = repoPath + self.base = base + self.spaceId = spaceId + } + + + + +} + +#if compiler(>=6) +extension WorktreeSpec: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeWorktreeSpec: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> WorktreeSpec { + return + try WorktreeSpec( + repoPath: FfiConverterString.read(from: &buf), + base: FfiConverterString.read(from: &buf), + spaceId: FfiConverterOptionString.read(from: &buf) + ) + } + + public static func write(_ value: WorktreeSpec, into buf: inout [UInt8]) { + FfiConverterString.write(value.repoPath, into: &buf) + FfiConverterString.write(value.base, into: &buf) + FfiConverterOptionString.write(value.spaceId, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeWorktreeSpec_lift(_ buf: RustBuffer) throws -> WorktreeSpec { + return try FfiConverterTypeWorktreeSpec.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeWorktreeSpec_lower(_ value: WorktreeSpec) -> RustBuffer { + return FfiConverterTypeWorktreeSpec.lower(value) +} + + + +public enum AuthCallback: Equatable, Hashable { + + case code(code: String, state: String? + ) + case error(error: String, description: String? + ) + + + + + +} + +#if compiler(>=6) +extension AuthCallback: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeAuthCallback: FfiConverterRustBuffer { + typealias SwiftType = AuthCallback + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> AuthCallback { + let variant: Int32 = try readInt(&buf) + switch variant { + + case 1: return .code(code: try FfiConverterString.read(from: &buf), state: try FfiConverterOptionString.read(from: &buf) + ) + + case 2: return .error(error: try FfiConverterString.read(from: &buf), description: try FfiConverterOptionString.read(from: &buf) + ) + + default: throw UniffiInternalError.unexpectedEnumCase + } + } + + public static func write(_ value: AuthCallback, into buf: inout [UInt8]) { + switch value { + + + case let .code(code,state): + writeInt(&buf, Int32(1)) + FfiConverterString.write(code, into: &buf) + FfiConverterOptionString.write(state, into: &buf) + + + case let .error(error,description): + writeInt(&buf, Int32(2)) + FfiConverterString.write(error, into: &buf) + FfiConverterOptionString.write(description, into: &buf) + + } + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeAuthCallback_lift(_ buf: RustBuffer) throws -> AuthCallback { + return try FfiConverterTypeAuthCallback.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeAuthCallback_lower(_ value: AuthCallback) -> RustBuffer { + return FfiConverterTypeAuthCallback.lower(value) +} + + + + +public enum BoxStyle: Equatable, Hashable { + + case fill + /** + * A hairline (1 device pixel) stroke on the rect's inside edge. + */ + case hairline + + + + + +} + +#if compiler(>=6) +extension BoxStyle: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeBoxStyle: FfiConverterRustBuffer { + typealias SwiftType = BoxStyle + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> BoxStyle { + let variant: Int32 = try readInt(&buf) + switch variant { + + case 1: return .fill + + case 2: return .hairline + + default: throw UniffiInternalError.unexpectedEnumCase + } + } + + public static func write(_ value: BoxStyle, into buf: inout [UInt8]) { + switch value { + + + case .fill: + writeInt(&buf, Int32(1)) + + + case .hairline: + writeInt(&buf, Int32(2)) + + } + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeBoxStyle_lift(_ buf: RustBuffer) throws -> BoxStyle { + return try FfiConverterTypeBoxStyle.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeBoxStyle_lower(_ value: BoxStyle) -> RustBuffer { + return FfiConverterTypeBoxStyle.lower(value) +} + + + + +public enum BusyPolicy: Equatable, Hashable { + + /** + * Park it on the shared queue until the turn ends (default). + */ + case queue + /** + * Deliver mid-turn (steer). + */ + case steer + + + + + +} + +#if compiler(>=6) +extension BusyPolicy: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeBusyPolicy: FfiConverterRustBuffer { + typealias SwiftType = BusyPolicy + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> BusyPolicy { + let variant: Int32 = try readInt(&buf) + switch variant { + + case 1: return .queue + + case 2: return .steer + + default: throw UniffiInternalError.unexpectedEnumCase + } + } + + public static func write(_ value: BusyPolicy, into buf: inout [UInt8]) { + switch value { + + + case .queue: + writeInt(&buf, Int32(1)) + + + case .steer: + writeInt(&buf, Int32(2)) + + } + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeBusyPolicy_lift(_ buf: RustBuffer) throws -> BusyPolicy { + return try FfiConverterTypeBusyPolicy.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeBusyPolicy_lower(_ value: BusyPolicy) -> RustBuffer { + return FfiConverterTypeBusyPolicy.lower(value) +} + + + + +public enum ChatIndicator: Equatable, Hashable { + + case working + case awaitingInput + case errored + /** + * Finished but not seen yet on any device. + */ + case completed + case idle + + + + + +} + +#if compiler(>=6) +extension ChatIndicator: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeChatIndicator: FfiConverterRustBuffer { + typealias SwiftType = ChatIndicator + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> ChatIndicator { + let variant: Int32 = try readInt(&buf) + switch variant { + + case 1: return .working + + case 2: return .awaitingInput + + case 3: return .errored + + case 4: return .completed + + case 5: return .idle + + default: throw UniffiInternalError.unexpectedEnumCase + } + } + + public static func write(_ value: ChatIndicator, into buf: inout [UInt8]) { + switch value { + + + case .working: + writeInt(&buf, Int32(1)) + + + case .awaitingInput: + writeInt(&buf, Int32(2)) + + + case .errored: + writeInt(&buf, Int32(3)) + + + case .completed: + writeInt(&buf, Int32(4)) + + + case .idle: + writeInt(&buf, Int32(5)) + + } + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeChatIndicator_lift(_ buf: RustBuffer) throws -> ChatIndicator { + return try FfiConverterTypeChatIndicator.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeChatIndicator_lower(_ value: ChatIndicator) -> RustBuffer { + return FfiConverterTypeChatIndicator.lower(value) +} + + + +/** + * Coalesced change notification. Pull the new state after receiving one; + * delivered on a client thread — hop to the main thread and return fast. + */ + +public enum ClientEvent: Equatable, Hashable { + + /** + * `workspace()` advanced. + */ + case workspaceChanged(revision: UInt64 + ) + /** + * A session's transcript advanced (the layout engine consumes it in Rust; + * this is informational for the platform). + */ + case sessionChanged(chatId: String, revision: UInt64 + ) + /** + * A session's `composer()` advanced. + */ + case composerChanged(chatId: String, revision: UInt64 + ) + case connectivityChanged(connectivity: Connectivity + ) + /** + * The WorkOS pair rotated — persist it now (the old refresh token is spent). + */ + case authRefreshed(tokens: AuthTokens + ) + /** + * The session can't be refreshed any more — sign out. + */ + case authExpired(reason: String + ) + + + + + +} + +#if compiler(>=6) +extension ClientEvent: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeClientEvent: FfiConverterRustBuffer { + typealias SwiftType = ClientEvent + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> ClientEvent { + let variant: Int32 = try readInt(&buf) + switch variant { + + case 1: return .workspaceChanged(revision: try FfiConverterUInt64.read(from: &buf) + ) + + case 2: return .sessionChanged(chatId: try FfiConverterString.read(from: &buf), revision: try FfiConverterUInt64.read(from: &buf) + ) + + case 3: return .composerChanged(chatId: try FfiConverterString.read(from: &buf), revision: try FfiConverterUInt64.read(from: &buf) + ) + + case 4: return .connectivityChanged(connectivity: try FfiConverterTypeConnectivity.read(from: &buf) + ) + + case 5: return .authRefreshed(tokens: try FfiConverterTypeAuthTokens.read(from: &buf) + ) + + case 6: return .authExpired(reason: try FfiConverterString.read(from: &buf) + ) + + default: throw UniffiInternalError.unexpectedEnumCase + } + } + + public static func write(_ value: ClientEvent, into buf: inout [UInt8]) { + switch value { + + + case let .workspaceChanged(revision): + writeInt(&buf, Int32(1)) + FfiConverterUInt64.write(revision, into: &buf) + + + case let .sessionChanged(chatId,revision): + writeInt(&buf, Int32(2)) + FfiConverterString.write(chatId, into: &buf) + FfiConverterUInt64.write(revision, into: &buf) + + + case let .composerChanged(chatId,revision): + writeInt(&buf, Int32(3)) + FfiConverterString.write(chatId, into: &buf) + FfiConverterUInt64.write(revision, into: &buf) + + + case let .connectivityChanged(connectivity): + writeInt(&buf, Int32(4)) + FfiConverterTypeConnectivity.write(connectivity, into: &buf) + + + case let .authRefreshed(tokens): + writeInt(&buf, Int32(5)) + FfiConverterTypeAuthTokens.write(tokens, into: &buf) + + + case let .authExpired(reason): + writeInt(&buf, Int32(6)) + FfiConverterString.write(reason, into: &buf) + + } + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeClientEvent_lift(_ buf: RustBuffer) throws -> ClientEvent { + return try FfiConverterTypeClientEvent.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeClientEvent_lower(_ value: ClientEvent) -> RustBuffer { + return FfiConverterTypeClientEvent.lower(value) +} + + + +/** + * Paint roles. The painter maps these to its palette (light/dark). + */ + +public enum ColorRole: Equatable, Hashable { + + case text + case textSecondary + case textTertiary + case link + case accent + case danger + case success + case warning + case inlineCodeText + case inlineCodeBackground + case codeText + case codeBackground + case codeBorder + case quoteBar + case rule + case tableBorder + case tableHeaderBackground + case userBubble + case chipBackground + case syntaxKeyword + case syntaxString + case syntaxComment + case syntaxNumber + case syntaxFunction + case syntaxType + case syntaxConstant + case syntaxVariable + case syntaxProperty + case syntaxOperator + case syntaxPunctuation + case syntaxTag + case syntaxAttribute + case syntaxEscape + /** + * `text_faint`: detail bodies. + */ + case textFaint + /** + * `text` @ 0.85: file badge names, agent card details. + */ + case textSoft + /** + * `hairline(0.12)`: the activity rail. + */ + case toolRail + /** + * `ink(0.06)`: file badge fill. + */ + case toolBadge + /** + * File-icon well inside a badge. + */ + case toolWell + /** + * `ink(0.03)` / `hairline(0.07)` / `ink(0.08)`: subagent cards and tiles. + */ + case agentCard + case agentCardBorder + case agentTile + /** + * Diff rows: wash (0.055), accent bar (0.55), hunk header. + */ + case diffAddWash + case diffDelWash + case diffAddBar + case diffDelBar + case diffHunk + + + + + +} + +#if compiler(>=6) +extension ColorRole: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeColorRole: FfiConverterRustBuffer { + typealias SwiftType = ColorRole + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> ColorRole { + let variant: Int32 = try readInt(&buf) + switch variant { + + case 1: return .text + + case 2: return .textSecondary + + case 3: return .textTertiary + + case 4: return .link + + case 5: return .accent + + case 6: return .danger + + case 7: return .success + + case 8: return .warning + + case 9: return .inlineCodeText + + case 10: return .inlineCodeBackground + + case 11: return .codeText + + case 12: return .codeBackground + + case 13: return .codeBorder + + case 14: return .quoteBar + + case 15: return .rule + + case 16: return .tableBorder + + case 17: return .tableHeaderBackground + + case 18: return .userBubble + + case 19: return .chipBackground + + case 20: return .syntaxKeyword + + case 21: return .syntaxString + + case 22: return .syntaxComment + + case 23: return .syntaxNumber + + case 24: return .syntaxFunction + + case 25: return .syntaxType + + case 26: return .syntaxConstant + + case 27: return .syntaxVariable + + case 28: return .syntaxProperty + + case 29: return .syntaxOperator + + case 30: return .syntaxPunctuation + + case 31: return .syntaxTag + + case 32: return .syntaxAttribute + + case 33: return .syntaxEscape + + case 34: return .textFaint + + case 35: return .textSoft + + case 36: return .toolRail + + case 37: return .toolBadge + + case 38: return .toolWell + + case 39: return .agentCard + + case 40: return .agentCardBorder + + case 41: return .agentTile + + case 42: return .diffAddWash + + case 43: return .diffDelWash + + case 44: return .diffAddBar + + case 45: return .diffDelBar + + case 46: return .diffHunk + + default: throw UniffiInternalError.unexpectedEnumCase + } + } + + public static func write(_ value: ColorRole, into buf: inout [UInt8]) { + switch value { + + + case .text: + writeInt(&buf, Int32(1)) + + + case .textSecondary: + writeInt(&buf, Int32(2)) + + + case .textTertiary: + writeInt(&buf, Int32(3)) + + + case .link: + writeInt(&buf, Int32(4)) + + + case .accent: + writeInt(&buf, Int32(5)) + + + case .danger: + writeInt(&buf, Int32(6)) + + + case .success: + writeInt(&buf, Int32(7)) + + + case .warning: + writeInt(&buf, Int32(8)) + + + case .inlineCodeText: + writeInt(&buf, Int32(9)) + + + case .inlineCodeBackground: + writeInt(&buf, Int32(10)) + + + case .codeText: + writeInt(&buf, Int32(11)) + + + case .codeBackground: + writeInt(&buf, Int32(12)) + + + case .codeBorder: + writeInt(&buf, Int32(13)) + + + case .quoteBar: + writeInt(&buf, Int32(14)) + + + case .rule: + writeInt(&buf, Int32(15)) + + + case .tableBorder: + writeInt(&buf, Int32(16)) + + + case .tableHeaderBackground: + writeInt(&buf, Int32(17)) + + + case .userBubble: + writeInt(&buf, Int32(18)) + + + case .chipBackground: + writeInt(&buf, Int32(19)) + + + case .syntaxKeyword: + writeInt(&buf, Int32(20)) + + + case .syntaxString: + writeInt(&buf, Int32(21)) + + + case .syntaxComment: + writeInt(&buf, Int32(22)) + + + case .syntaxNumber: + writeInt(&buf, Int32(23)) + + + case .syntaxFunction: + writeInt(&buf, Int32(24)) + + + case .syntaxType: + writeInt(&buf, Int32(25)) + + + case .syntaxConstant: + writeInt(&buf, Int32(26)) + + + case .syntaxVariable: + writeInt(&buf, Int32(27)) + + + case .syntaxProperty: + writeInt(&buf, Int32(28)) + + + case .syntaxOperator: + writeInt(&buf, Int32(29)) + + + case .syntaxPunctuation: + writeInt(&buf, Int32(30)) + + + case .syntaxTag: + writeInt(&buf, Int32(31)) + + + case .syntaxAttribute: + writeInt(&buf, Int32(32)) + + + case .syntaxEscape: + writeInt(&buf, Int32(33)) + + + case .textFaint: + writeInt(&buf, Int32(34)) + + + case .textSoft: + writeInt(&buf, Int32(35)) + + + case .toolRail: + writeInt(&buf, Int32(36)) + + + case .toolBadge: + writeInt(&buf, Int32(37)) + + + case .toolWell: + writeInt(&buf, Int32(38)) + + + case .agentCard: + writeInt(&buf, Int32(39)) + + + case .agentCardBorder: + writeInt(&buf, Int32(40)) + + + case .agentTile: + writeInt(&buf, Int32(41)) + + + case .diffAddWash: + writeInt(&buf, Int32(42)) + + + case .diffDelWash: + writeInt(&buf, Int32(43)) + + + case .diffAddBar: + writeInt(&buf, Int32(44)) + + + case .diffDelBar: + writeInt(&buf, Int32(45)) + + + case .diffHunk: + writeInt(&buf, Int32(46)) + + } + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeColorRole_lift(_ buf: RustBuffer) throws -> ColorRole { + return try FfiConverterTypeColorRole.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeColorRole_lower(_ value: ColorRole) -> RustBuffer { + return FfiConverterTypeColorRole.lower(value) +} + + + + +public enum ConnectivityState: Equatable, Hashable { + + /** + * Demo / no edge transports — hide the pill. + */ + case disabled + /** + * No network path (graced): sends are saved locally. + */ + case offline + /** + * The registry room is down (graced). + */ + case reconnecting + case connected @@ -8277,44 +10469,50 @@ public enum AuthCallback: Equatable, Hashable { } #if compiler(>=6) -extension AuthCallback: Sendable {} +extension ConnectivityState: Sendable {} #endif #if swift(>=5.8) @_documentation(visibility: private) #endif -public struct FfiConverterTypeAuthCallback: FfiConverterRustBuffer { - typealias SwiftType = AuthCallback +public struct FfiConverterTypeConnectivityState: FfiConverterRustBuffer { + typealias SwiftType = ConnectivityState - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> AuthCallback { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> ConnectivityState { let variant: Int32 = try readInt(&buf) switch variant { - case 1: return .code(code: try FfiConverterString.read(from: &buf), state: try FfiConverterOptionString.read(from: &buf) - ) + case 1: return .disabled - case 2: return .error(error: try FfiConverterString.read(from: &buf), description: try FfiConverterOptionString.read(from: &buf) - ) + case 2: return .offline + + case 3: return .reconnecting + + case 4: return .connected default: throw UniffiInternalError.unexpectedEnumCase } } - public static func write(_ value: AuthCallback, into buf: inout [UInt8]) { + public static func write(_ value: ConnectivityState, into buf: inout [UInt8]) { switch value { - case let .code(code,state): + case .disabled: writeInt(&buf, Int32(1)) - FfiConverterString.write(code, into: &buf) - FfiConverterOptionString.write(state, into: &buf) - - case let .error(error,description): + + case .offline: writeInt(&buf, Int32(2)) - FfiConverterString.write(error, into: &buf) - FfiConverterOptionString.write(description, into: &buf) - + + + case .reconnecting: + writeInt(&buf, Int32(3)) + + + case .connected: + writeInt(&buf, Int32(4)) + } } } @@ -8323,67 +10521,179 @@ public struct FfiConverterTypeAuthCallback: FfiConverterRustBuffer { #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeAuthCallback_lift(_ buf: RustBuffer) throws -> AuthCallback { - return try FfiConverterTypeAuthCallback.lift(buf) +public func FfiConverterTypeConnectivityState_lift(_ buf: RustBuffer) throws -> ConnectivityState { + return try FfiConverterTypeConnectivityState.lift(buf) } #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeAuthCallback_lower(_ value: AuthCallback) -> RustBuffer { - return FfiConverterTypeAuthCallback.lower(value) +public func FfiConverterTypeConnectivityState_lower(_ value: ConnectivityState) -> RustBuffer { + return FfiConverterTypeConnectivityState.lower(value) } +public +enum CoreError: Swift.Error, Equatable, Hashable, Foundation.LocalizedError { -public enum BoxStyle: Equatable, Hashable { - case fill + + case NotFound(message: String + ) + case InvalidArgument(message: String + ) /** - * A hairline (1 device pixel) stroke on the rect's inside edge. + * The host device could not be reached over the relay. */ - case hairline - - + case HostUnavailable(message: String + ) + case Unsupported(message: String + ) + /** + * The host answered with an error (bad request, git failure, …). + */ + case HostError(message: String + ) + case Network(message: String + ) + /** + * Credentials rejected. Irrecoverable cases also raise `AuthExpired`. + */ + case Auth(message: String + ) + case Storage(message: String + ) + case NotImplemented(message: String + ) + case Closed + case Internal(message: String + ) + + + + public var errorDescription: String? { + String(reflecting: self) + } + } #if compiler(>=6) -extension BoxStyle: Sendable {} +extension CoreError: Sendable {} #endif #if swift(>=5.8) @_documentation(visibility: private) #endif -public struct FfiConverterTypeBoxStyle: FfiConverterRustBuffer { - typealias SwiftType = BoxStyle +public struct FfiConverterTypeCoreError: FfiConverterRustBuffer { + typealias SwiftType = CoreError - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> BoxStyle { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> CoreError { let variant: Int32 = try readInt(&buf) switch variant { + - case 1: return .fill - - case 2: return .hairline + - default: throw UniffiInternalError.unexpectedEnumCase + case 1: return .NotFound( + message: try FfiConverterString.read(from: &buf) + ) + case 2: return .InvalidArgument( + message: try FfiConverterString.read(from: &buf) + ) + case 3: return .HostUnavailable( + message: try FfiConverterString.read(from: &buf) + ) + case 4: return .Unsupported( + message: try FfiConverterString.read(from: &buf) + ) + case 5: return .HostError( + message: try FfiConverterString.read(from: &buf) + ) + case 6: return .Network( + message: try FfiConverterString.read(from: &buf) + ) + case 7: return .Auth( + message: try FfiConverterString.read(from: &buf) + ) + case 8: return .Storage( + message: try FfiConverterString.read(from: &buf) + ) + case 9: return .NotImplemented( + message: try FfiConverterString.read(from: &buf) + ) + case 10: return .Closed + case 11: return .Internal( + message: try FfiConverterString.read(from: &buf) + ) + + default: throw UniffiInternalError.unexpectedEnumCase } } - public static func write(_ value: BoxStyle, into buf: inout [UInt8]) { + public static func write(_ value: CoreError, into buf: inout [UInt8]) { switch value { + + + - case .fill: + case let .NotFound(message): writeInt(&buf, Int32(1)) + FfiConverterString.write(message, into: &buf) + - - case .hairline: + case let .InvalidArgument(message): writeInt(&buf, Int32(2)) + FfiConverterString.write(message, into: &buf) + + + case let .HostUnavailable(message): + writeInt(&buf, Int32(3)) + FfiConverterString.write(message, into: &buf) + + + case let .Unsupported(message): + writeInt(&buf, Int32(4)) + FfiConverterString.write(message, into: &buf) + + + case let .HostError(message): + writeInt(&buf, Int32(5)) + FfiConverterString.write(message, into: &buf) + + + case let .Network(message): + writeInt(&buf, Int32(6)) + FfiConverterString.write(message, into: &buf) + + + case let .Auth(message): + writeInt(&buf, Int32(7)) + FfiConverterString.write(message, into: &buf) + + + case let .Storage(message): + writeInt(&buf, Int32(8)) + FfiConverterString.write(message, into: &buf) + + case let .NotImplemented(message): + writeInt(&buf, Int32(9)) + FfiConverterString.write(message, into: &buf) + + + case .Closed: + writeInt(&buf, Int32(10)) + + + case let .Internal(message): + writeInt(&buf, Int32(11)) + FfiConverterString.write(message, into: &buf) + } } } @@ -8392,30 +10702,39 @@ public struct FfiConverterTypeBoxStyle: FfiConverterRustBuffer { #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeBoxStyle_lift(_ buf: RustBuffer) throws -> BoxStyle { - return try FfiConverterTypeBoxStyle.lift(buf) +public func FfiConverterTypeCoreError_lift(_ buf: RustBuffer) throws -> CoreError { + return try FfiConverterTypeCoreError.lift(buf) } #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeBoxStyle_lower(_ value: BoxStyle) -> RustBuffer { - return FfiConverterTypeBoxStyle.lower(value) +public func FfiConverterTypeCoreError_lower(_ value: CoreError) -> RustBuffer { + return FfiConverterTypeCoreError.lower(value) } +/** + * How the client authenticates against the edge. + */ - -public enum BusyPolicy: Equatable, Hashable { +public enum Credentials: Equatable, Hashable { /** - * Park it on the shared queue until the turn ends (default). + * WorkOS session scoped to `org_id`. */ - case queue + case workOs(userId: String, orgId: String, tokens: AuthTokens + ) /** - * Deliver mid-turn (steer). + * `AUTH_MODE=dev` edge: the bearer is `userId@orgId`. */ - case steer + case dev(userId: String, orgId: String + ) + /** + * Fully offline dataset with a simulated host. + */ + case demo(options: DemoOptions + ) @@ -8424,38 +10743,53 @@ public enum BusyPolicy: Equatable, Hashable { } #if compiler(>=6) -extension BusyPolicy: Sendable {} +extension Credentials: Sendable {} #endif #if swift(>=5.8) @_documentation(visibility: private) #endif -public struct FfiConverterTypeBusyPolicy: FfiConverterRustBuffer { - typealias SwiftType = BusyPolicy +public struct FfiConverterTypeCredentials: FfiConverterRustBuffer { + typealias SwiftType = Credentials - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> BusyPolicy { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> Credentials { let variant: Int32 = try readInt(&buf) switch variant { - case 1: return .queue + case 1: return .workOs(userId: try FfiConverterString.read(from: &buf), orgId: try FfiConverterString.read(from: &buf), tokens: try FfiConverterTypeAuthTokens.read(from: &buf) + ) - case 2: return .steer + case 2: return .dev(userId: try FfiConverterString.read(from: &buf), orgId: try FfiConverterString.read(from: &buf) + ) + + case 3: return .demo(options: try FfiConverterTypeDemoOptions.read(from: &buf) + ) default: throw UniffiInternalError.unexpectedEnumCase } } - public static func write(_ value: BusyPolicy, into buf: inout [UInt8]) { + public static func write(_ value: Credentials, into buf: inout [UInt8]) { switch value { - case .queue: + case let .workOs(userId,orgId,tokens): writeInt(&buf, Int32(1)) + FfiConverterString.write(userId, into: &buf) + FfiConverterString.write(orgId, into: &buf) + FfiConverterTypeAuthTokens.write(tokens, into: &buf) + - - case .steer: + case let .dev(userId,orgId): writeInt(&buf, Int32(2)) + FfiConverterString.write(userId, into: &buf) + FfiConverterString.write(orgId, into: &buf) + + case let .demo(options): + writeInt(&buf, Int32(3)) + FfiConverterTypeDemoOptions.write(options, into: &buf) + } } } @@ -8464,30 +10798,28 @@ public struct FfiConverterTypeBusyPolicy: FfiConverterRustBuffer { #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeBusyPolicy_lift(_ buf: RustBuffer) throws -> BusyPolicy { - return try FfiConverterTypeBusyPolicy.lift(buf) +public func FfiConverterTypeCredentials_lift(_ buf: RustBuffer) throws -> Credentials { + return try FfiConverterTypeCredentials.lift(buf) } #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeBusyPolicy_lower(_ value: BusyPolicy) -> RustBuffer { - return FfiConverterTypeBusyPolicy.lower(value) +public func FfiConverterTypeCredentials_lower(_ value: Credentials) -> RustBuffer { + return FfiConverterTypeCredentials.lower(value) } +/** + * Text decorations, painted relative to the run's baseline. + */ -public enum ChatIndicator: Equatable, Hashable { +public enum Decoration: Equatable, Hashable { - case working - case awaitingInput - case errored - /** - * Finished but not seen yet on any device. - */ - case completed - case idle + case none + case underline + case strikethrough @@ -8496,56 +10828,44 @@ public enum ChatIndicator: Equatable, Hashable { } #if compiler(>=6) -extension ChatIndicator: Sendable {} +extension Decoration: Sendable {} #endif #if swift(>=5.8) @_documentation(visibility: private) #endif -public struct FfiConverterTypeChatIndicator: FfiConverterRustBuffer { - typealias SwiftType = ChatIndicator +public struct FfiConverterTypeDecoration: FfiConverterRustBuffer { + typealias SwiftType = Decoration - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> ChatIndicator { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> Decoration { let variant: Int32 = try readInt(&buf) switch variant { - case 1: return .working - - case 2: return .awaitingInput - - case 3: return .errored + case 1: return .none - case 4: return .completed + case 2: return .underline - case 5: return .idle + case 3: return .strikethrough default: throw UniffiInternalError.unexpectedEnumCase } } - public static func write(_ value: ChatIndicator, into buf: inout [UInt8]) { + public static func write(_ value: Decoration, into buf: inout [UInt8]) { switch value { - case .working: + case .none: writeInt(&buf, Int32(1)) - case .awaitingInput: + case .underline: writeInt(&buf, Int32(2)) - case .errored: + case .strikethrough: writeInt(&buf, Int32(3)) - - case .completed: - writeInt(&buf, Int32(4)) - - - case .idle: - writeInt(&buf, Int32(5)) - } } } @@ -8554,54 +10874,34 @@ public struct FfiConverterTypeChatIndicator: FfiConverterRustBuffer { #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeChatIndicator_lift(_ buf: RustBuffer) throws -> ChatIndicator { - return try FfiConverterTypeChatIndicator.lift(buf) +public func FfiConverterTypeDecoration_lift(_ buf: RustBuffer) throws -> Decoration { + return try FfiConverterTypeDecoration.lift(buf) } #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeChatIndicator_lower(_ value: ChatIndicator) -> RustBuffer { - return FfiConverterTypeChatIndicator.lower(value) +public func FfiConverterTypeDecoration_lower(_ value: Decoration) -> RustBuffer { + return FfiConverterTypeDecoration.lower(value) } -/** - * Coalesced change notification. Pull the new state after receiving one; - * delivered on a client thread — hop to the main thread and return fast. - */ -public enum ClientEvent: Equatable, Hashable { +public enum DemoFixture: Equatable, Hashable { /** - * `workspace()` advanced. - */ - case workspaceChanged(revision: UInt64 - ) - /** - * A session's transcript advanced (the layout engine consumes it in Rust; - * this is informational for the platform). - */ - case sessionChanged(chatId: String, revision: UInt64 - ) - /** - * A session's `composer()` advanced. + * Devices, projects, pinned + sectioned sessions, PRs in every state. */ - case composerChanged(chatId: String, revision: UInt64 - ) - case connectivityChanged(connectivity: Connectivity - ) + case standard /** - * The WorkOS pair rotated — persist it now (the old refresh token is spent). + * No projects and no sessions (empty states). */ - case authRefreshed(tokens: AuthTokens - ) + case noProjects /** - * The session can't be refreshed any more — sign out. + * Only this phone — no execution hosts. */ - case authExpired(reason: String - ) + case iosOnly @@ -8610,76 +10910,44 @@ public enum ClientEvent: Equatable, Hashable { } #if compiler(>=6) -extension ClientEvent: Sendable {} +extension DemoFixture: Sendable {} #endif #if swift(>=5.8) @_documentation(visibility: private) #endif -public struct FfiConverterTypeClientEvent: FfiConverterRustBuffer { - typealias SwiftType = ClientEvent +public struct FfiConverterTypeDemoFixture: FfiConverterRustBuffer { + typealias SwiftType = DemoFixture - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> ClientEvent { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> DemoFixture { let variant: Int32 = try readInt(&buf) switch variant { - case 1: return .workspaceChanged(revision: try FfiConverterUInt64.read(from: &buf) - ) - - case 2: return .sessionChanged(chatId: try FfiConverterString.read(from: &buf), revision: try FfiConverterUInt64.read(from: &buf) - ) - - case 3: return .composerChanged(chatId: try FfiConverterString.read(from: &buf), revision: try FfiConverterUInt64.read(from: &buf) - ) - - case 4: return .connectivityChanged(connectivity: try FfiConverterTypeConnectivity.read(from: &buf) - ) + case 1: return .standard - case 5: return .authRefreshed(tokens: try FfiConverterTypeAuthTokens.read(from: &buf) - ) + case 2: return .noProjects - case 6: return .authExpired(reason: try FfiConverterString.read(from: &buf) - ) + case 3: return .iosOnly default: throw UniffiInternalError.unexpectedEnumCase } } - public static func write(_ value: ClientEvent, into buf: inout [UInt8]) { + public static func write(_ value: DemoFixture, into buf: inout [UInt8]) { switch value { - case let .workspaceChanged(revision): + case .standard: writeInt(&buf, Int32(1)) - FfiConverterUInt64.write(revision, into: &buf) - - case let .sessionChanged(chatId,revision): - writeInt(&buf, Int32(2)) - FfiConverterString.write(chatId, into: &buf) - FfiConverterUInt64.write(revision, into: &buf) - - case let .composerChanged(chatId,revision): - writeInt(&buf, Int32(3)) - FfiConverterString.write(chatId, into: &buf) - FfiConverterUInt64.write(revision, into: &buf) - + case .noProjects: + writeInt(&buf, Int32(2)) - case let .connectivityChanged(connectivity): - writeInt(&buf, Int32(4)) - FfiConverterTypeConnectivity.write(connectivity, into: &buf) - - case let .authRefreshed(tokens): - writeInt(&buf, Int32(5)) - FfiConverterTypeAuthTokens.write(tokens, into: &buf) - + case .iosOnly: + writeInt(&buf, Int32(3)) - case let .authExpired(reason): - writeInt(&buf, Int32(6)) - FfiConverterString.write(reason, into: &buf) - } } } @@ -8688,92 +10956,37 @@ public struct FfiConverterTypeClientEvent: FfiConverterRustBuffer { #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeClientEvent_lift(_ buf: RustBuffer) throws -> ClientEvent { - return try FfiConverterTypeClientEvent.lift(buf) +public func FfiConverterTypeDemoFixture_lift(_ buf: RustBuffer) throws -> DemoFixture { + return try FfiConverterTypeDemoFixture.lift(buf) } #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeClientEvent_lower(_ value: ClientEvent) -> RustBuffer { - return FfiConverterTypeClientEvent.lower(value) +public func FfiConverterTypeDemoFixture_lower(_ value: DemoFixture) -> RustBuffer { + return FfiConverterTypeDemoFixture.lower(value) } /** - * Paint roles. The painter maps these to its palette (light/dark). + * Faces the platform registers (same bytes it hands CoreText/Skia). */ -public enum ColorRole: Equatable, Hashable { +public enum FaceRole: Equatable, Hashable { - case text - case textSecondary - case textTertiary - case link - case accent - case danger - case success - case warning - case inlineCodeText - case inlineCodeBackground - case codeText - case codeBackground - case codeBorder - case quoteBar - case rule - case tableBorder - case tableHeaderBackground - case userBubble - case chipBackground - case syntaxKeyword - case syntaxString - case syntaxComment - case syntaxNumber - case syntaxFunction - case syntaxType - case syntaxConstant - case syntaxVariable - case syntaxProperty - case syntaxOperator - case syntaxPunctuation - case syntaxTag - case syntaxAttribute - case syntaxEscape - /** - * `text_faint`: detail bodies. - */ - case textFaint - /** - * `text` @ 0.85: file badge names, agent card details. - */ - case textSoft - /** - * `hairline(0.12)`: the activity rail. - */ - case toolRail - /** - * `ink(0.06)`: file badge fill. - */ - case toolBadge - /** - * File-icon well inside a badge. - */ - case toolWell - /** - * `ink(0.03)` / `hairline(0.07)` / `ink(0.08)`: subagent cards and tiles. - */ - case agentCard - case agentCardBorder - case agentTile - /** - * Diff rows: wash (0.055), accent bar (0.55), hunk header. - */ - case diffAddWash - case diffDelWash - case diffAddBar - case diffDelBar - case diffHunk + case sans + case sansMedium + case sansSemibold + case sansBold + case sansItalic + case sansMediumItalic + case sansSemiboldItalic + case sansBoldItalic + case mono + case monoMedium + case monoSemibold + case monoItalic @@ -8782,301 +10995,174 @@ public enum ColorRole: Equatable, Hashable { } #if compiler(>=6) -extension ColorRole: Sendable {} +extension FaceRole: Sendable {} #endif #if swift(>=5.8) @_documentation(visibility: private) #endif -public struct FfiConverterTypeColorRole: FfiConverterRustBuffer { - typealias SwiftType = ColorRole +public struct FfiConverterTypeFaceRole: FfiConverterRustBuffer { + typealias SwiftType = FaceRole - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> ColorRole { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> FaceRole { let variant: Int32 = try readInt(&buf) switch variant { - case 1: return .text - - case 2: return .textSecondary - - case 3: return .textTertiary - - case 4: return .link - - case 5: return .accent - - case 6: return .danger - - case 7: return .success - - case 8: return .warning - - case 9: return .inlineCodeText - - case 10: return .inlineCodeBackground - - case 11: return .codeText - - case 12: return .codeBackground - - case 13: return .codeBorder - - case 14: return .quoteBar - - case 15: return .rule - - case 16: return .tableBorder - - case 17: return .tableHeaderBackground - - case 18: return .userBubble - - case 19: return .chipBackground - - case 20: return .syntaxKeyword - - case 21: return .syntaxString - - case 22: return .syntaxComment - - case 23: return .syntaxNumber - - case 24: return .syntaxFunction - - case 25: return .syntaxType - - case 26: return .syntaxConstant - - case 27: return .syntaxVariable - - case 28: return .syntaxProperty - - case 29: return .syntaxOperator - - case 30: return .syntaxPunctuation - - case 31: return .syntaxTag - - case 32: return .syntaxAttribute - - case 33: return .syntaxEscape - - case 34: return .textFaint - - case 35: return .textSoft + case 1: return .sans - case 36: return .toolRail + case 2: return .sansMedium - case 37: return .toolBadge + case 3: return .sansSemibold - case 38: return .toolWell + case 4: return .sansBold - case 39: return .agentCard + case 5: return .sansItalic - case 40: return .agentCardBorder + case 6: return .sansMediumItalic - case 41: return .agentTile + case 7: return .sansSemiboldItalic - case 42: return .diffAddWash + case 8: return .sansBoldItalic - case 43: return .diffDelWash + case 9: return .mono - case 44: return .diffAddBar + case 10: return .monoMedium - case 45: return .diffDelBar + case 11: return .monoSemibold - case 46: return .diffHunk + case 12: return .monoItalic default: throw UniffiInternalError.unexpectedEnumCase } } - public static func write(_ value: ColorRole, into buf: inout [UInt8]) { + public static func write(_ value: FaceRole, into buf: inout [UInt8]) { switch value { - case .text: + case .sans: writeInt(&buf, Int32(1)) - case .textSecondary: + case .sansMedium: writeInt(&buf, Int32(2)) - case .textTertiary: + case .sansSemibold: writeInt(&buf, Int32(3)) - case .link: + case .sansBold: writeInt(&buf, Int32(4)) - case .accent: + case .sansItalic: writeInt(&buf, Int32(5)) - case .danger: + case .sansMediumItalic: writeInt(&buf, Int32(6)) - case .success: + case .sansSemiboldItalic: writeInt(&buf, Int32(7)) - case .warning: + case .sansBoldItalic: writeInt(&buf, Int32(8)) - case .inlineCodeText: + case .mono: writeInt(&buf, Int32(9)) - case .inlineCodeBackground: + case .monoMedium: writeInt(&buf, Int32(10)) - case .codeText: + case .monoSemibold: writeInt(&buf, Int32(11)) - case .codeBackground: + case .monoItalic: writeInt(&buf, Int32(12)) + } + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeFaceRole_lift(_ buf: RustBuffer) throws -> FaceRole { + return try FfiConverterTypeFaceRole.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeFaceRole_lower(_ value: FaceRole) -> RustBuffer { + return FfiConverterTypeFaceRole.lower(value) +} + + + +/** + * Which way overflowing text fades out. + */ + +public enum FadeEdge: Equatable, Hashable { + + /** + * Text fades to transparent across the rect, left → right, and is + * clipped at its right edge (single lines wider than their slot). + */ + case trailing + /** + * Text fades to transparent across the rect, top → bottom (folded + * blocks whose last visible line continues). + */ + case bottom + + + + + +} + +#if compiler(>=6) +extension FadeEdge: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeFadeEdge: FfiConverterRustBuffer { + typealias SwiftType = FadeEdge + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> FadeEdge { + let variant: Int32 = try readInt(&buf) + switch variant { - case .codeBorder: - writeInt(&buf, Int32(13)) - - - case .quoteBar: - writeInt(&buf, Int32(14)) - - - case .rule: - writeInt(&buf, Int32(15)) - - - case .tableBorder: - writeInt(&buf, Int32(16)) - - - case .tableHeaderBackground: - writeInt(&buf, Int32(17)) - - - case .userBubble: - writeInt(&buf, Int32(18)) - - - case .chipBackground: - writeInt(&buf, Int32(19)) - - - case .syntaxKeyword: - writeInt(&buf, Int32(20)) - - - case .syntaxString: - writeInt(&buf, Int32(21)) - - - case .syntaxComment: - writeInt(&buf, Int32(22)) - - - case .syntaxNumber: - writeInt(&buf, Int32(23)) - - - case .syntaxFunction: - writeInt(&buf, Int32(24)) - - - case .syntaxType: - writeInt(&buf, Int32(25)) - - - case .syntaxConstant: - writeInt(&buf, Int32(26)) - - - case .syntaxVariable: - writeInt(&buf, Int32(27)) - - - case .syntaxProperty: - writeInt(&buf, Int32(28)) - - - case .syntaxOperator: - writeInt(&buf, Int32(29)) - - - case .syntaxPunctuation: - writeInt(&buf, Int32(30)) - - - case .syntaxTag: - writeInt(&buf, Int32(31)) - - - case .syntaxAttribute: - writeInt(&buf, Int32(32)) - - - case .syntaxEscape: - writeInt(&buf, Int32(33)) - - - case .textFaint: - writeInt(&buf, Int32(34)) - - - case .textSoft: - writeInt(&buf, Int32(35)) - - - case .toolRail: - writeInt(&buf, Int32(36)) - - - case .toolBadge: - writeInt(&buf, Int32(37)) - - - case .toolWell: - writeInt(&buf, Int32(38)) - - - case .agentCard: - writeInt(&buf, Int32(39)) - - - case .agentCardBorder: - writeInt(&buf, Int32(40)) - - - case .agentTile: - writeInt(&buf, Int32(41)) - - - case .diffAddWash: - writeInt(&buf, Int32(42)) - - - case .diffDelWash: - writeInt(&buf, Int32(43)) + case 1: return .trailing + case 2: return .bottom - case .diffAddBar: - writeInt(&buf, Int32(44)) + default: throw UniffiInternalError.unexpectedEnumCase + } + } + + public static func write(_ value: FadeEdge, into buf: inout [UInt8]) { + switch value { - case .diffDelBar: - writeInt(&buf, Int32(45)) + case .trailing: + writeInt(&buf, Int32(1)) - case .diffHunk: - writeInt(&buf, Int32(46)) + case .bottom: + writeInt(&buf, Int32(2)) } } @@ -9086,35 +11172,29 @@ public struct FfiConverterTypeColorRole: FfiConverterRustBuffer { #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeColorRole_lift(_ buf: RustBuffer) throws -> ColorRole { - return try FfiConverterTypeColorRole.lift(buf) +public func FfiConverterTypeFadeEdge_lift(_ buf: RustBuffer) throws -> FadeEdge { + return try FfiConverterTypeFadeEdge.lift(buf) } #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeColorRole_lower(_ value: ColorRole) -> RustBuffer { - return FfiConverterTypeColorRole.lower(value) +public func FfiConverterTypeFadeEdge_lower(_ value: FadeEdge) -> RustBuffer { + return FfiConverterTypeFadeEdge.lower(value) } +/** + * The four tuned size presets (inline 20, avatar 64, large 96, hero 128). + */ -public enum ConnectivityState: Equatable, Hashable { +public enum OrbPreset: Equatable, Hashable { - /** - * Demo / no edge transports — hide the pill. - */ - case disabled - /** - * No network path (graced): sends are saved locally. - */ - case offline - /** - * The registry room is down (graced). - */ - case reconnecting - case connected + case inline + case avatar + case large + case hero @@ -9123,48 +11203,48 @@ public enum ConnectivityState: Equatable, Hashable { } #if compiler(>=6) -extension ConnectivityState: Sendable {} +extension OrbPreset: Sendable {} #endif #if swift(>=5.8) @_documentation(visibility: private) #endif -public struct FfiConverterTypeConnectivityState: FfiConverterRustBuffer { - typealias SwiftType = ConnectivityState +public struct FfiConverterTypeOrbPreset: FfiConverterRustBuffer { + typealias SwiftType = OrbPreset - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> ConnectivityState { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> OrbPreset { let variant: Int32 = try readInt(&buf) switch variant { - case 1: return .disabled + case 1: return .inline - case 2: return .offline + case 2: return .avatar - case 3: return .reconnecting + case 3: return .large - case 4: return .connected + case 4: return .hero default: throw UniffiInternalError.unexpectedEnumCase } } - public static func write(_ value: ConnectivityState, into buf: inout [UInt8]) { + public static func write(_ value: OrbPreset, into buf: inout [UInt8]) { switch value { - case .disabled: + case .inline: writeInt(&buf, Int32(1)) - case .offline: + case .avatar: writeInt(&buf, Int32(2)) - case .reconnecting: + case .large: writeInt(&buf, Int32(3)) - case .connected: + case .hero: writeInt(&buf, Int32(4)) } @@ -9175,179 +11255,137 @@ public struct FfiConverterTypeConnectivityState: FfiConverterRustBuffer { #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeConnectivityState_lift(_ buf: RustBuffer) throws -> ConnectivityState { - return try FfiConverterTypeConnectivityState.lift(buf) +public func FfiConverterTypeOrbPreset_lift(_ buf: RustBuffer) throws -> OrbPreset { + return try FfiConverterTypeOrbPreset.lift(buf) } #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeConnectivityState_lower(_ value: ConnectivityState) -> RustBuffer { - return FfiConverterTypeConnectivityState.lower(value) +public func FfiConverterTypeOrbPreset_lower(_ value: OrbPreset) -> RustBuffer { + return FfiConverterTypeOrbPreset.lower(value) } -public -enum CoreError: Swift.Error, Equatable, Hashable, Foundation.LocalizedError { +public enum PendingKind: Equatable, Hashable { - - case NotFound(message: String - ) - case InvalidArgument(message: String - ) - /** - * The host device could not be reached over the relay. - */ - case HostUnavailable(message: String - ) - case Unsupported(message: String - ) - /** - * The host answered with an error (bad request, git failure, …). - */ - case HostError(message: String - ) - case Network(message: String - ) - /** - * Credentials rejected. Irrecoverable cases also raise `AuthExpired`. - */ - case Auth(message: String - ) - case Storage(message: String - ) - case NotImplemented(message: String - ) - case Closed - case Internal(message: String - ) + case run + case steer + + - - - - public var errorDescription: String? { - String(reflecting: self) - } - } #if compiler(>=6) -extension CoreError: Sendable {} +extension PendingKind: Sendable {} #endif #if swift(>=5.8) @_documentation(visibility: private) #endif -public struct FfiConverterTypeCoreError: FfiConverterRustBuffer { - typealias SwiftType = CoreError +public struct FfiConverterTypePendingKind: FfiConverterRustBuffer { + typealias SwiftType = PendingKind - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> CoreError { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> PendingKind { let variant: Int32 = try readInt(&buf) switch variant { - - + case 1: return .run - case 1: return .NotFound( - message: try FfiConverterString.read(from: &buf) - ) - case 2: return .InvalidArgument( - message: try FfiConverterString.read(from: &buf) - ) - case 3: return .HostUnavailable( - message: try FfiConverterString.read(from: &buf) - ) - case 4: return .Unsupported( - message: try FfiConverterString.read(from: &buf) - ) - case 5: return .HostError( - message: try FfiConverterString.read(from: &buf) - ) - case 6: return .Network( - message: try FfiConverterString.read(from: &buf) - ) - case 7: return .Auth( - message: try FfiConverterString.read(from: &buf) - ) - case 8: return .Storage( - message: try FfiConverterString.read(from: &buf) - ) - case 9: return .NotImplemented( - message: try FfiConverterString.read(from: &buf) - ) - case 10: return .Closed - case 11: return .Internal( - message: try FfiConverterString.read(from: &buf) - ) - - default: throw UniffiInternalError.unexpectedEnumCase + case 2: return .steer + + default: throw UniffiInternalError.unexpectedEnumCase } } - public static func write(_ value: CoreError, into buf: inout [UInt8]) { + public static func write(_ value: PendingKind, into buf: inout [UInt8]) { switch value { - + + case .run: + writeInt(&buf, Int32(1)) + + + case .steer: + writeInt(&buf, Int32(2)) + + } + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypePendingKind_lift(_ buf: RustBuffer) throws -> PendingKind { + return try FfiConverterTypePendingKind.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypePendingKind_lower(_ value: PendingKind) -> RustBuffer { + return FfiConverterTypePendingKind.lower(value) +} + + + + +public enum PullRequestState: Equatable, Hashable { + + case `open` + case merged + case closed + + + + + +} + +#if compiler(>=6) +extension PullRequestState: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypePullRequestState: FfiConverterRustBuffer { + typealias SwiftType = PullRequestState + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> PullRequestState { + let variant: Int32 = try readInt(&buf) + switch variant { + case 1: return .`open` - case let .NotFound(message): - writeInt(&buf, Int32(1)) - FfiConverterString.write(message, into: &buf) - - - case let .InvalidArgument(message): - writeInt(&buf, Int32(2)) - FfiConverterString.write(message, into: &buf) - - - case let .HostUnavailable(message): - writeInt(&buf, Int32(3)) - FfiConverterString.write(message, into: &buf) - + case 2: return .merged - case let .Unsupported(message): - writeInt(&buf, Int32(4)) - FfiConverterString.write(message, into: &buf) - + case 3: return .closed - case let .HostError(message): - writeInt(&buf, Int32(5)) - FfiConverterString.write(message, into: &buf) - + default: throw UniffiInternalError.unexpectedEnumCase + } + } + + public static func write(_ value: PullRequestState, into buf: inout [UInt8]) { + switch value { - case let .Network(message): - writeInt(&buf, Int32(6)) - FfiConverterString.write(message, into: &buf) - - case let .Auth(message): - writeInt(&buf, Int32(7)) - FfiConverterString.write(message, into: &buf) - + case .`open`: + writeInt(&buf, Int32(1)) - case let .Storage(message): - writeInt(&buf, Int32(8)) - FfiConverterString.write(message, into: &buf) - - case let .NotImplemented(message): - writeInt(&buf, Int32(9)) - FfiConverterString.write(message, into: &buf) - + case .merged: + writeInt(&buf, Int32(2)) - case .Closed: - writeInt(&buf, Int32(10)) + case .closed: + writeInt(&buf, Int32(3)) - case let .Internal(message): - writeInt(&buf, Int32(11)) - FfiConverterString.write(message, into: &buf) - } } } @@ -9356,39 +11394,38 @@ public struct FfiConverterTypeCoreError: FfiConverterRustBuffer { #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeCoreError_lift(_ buf: RustBuffer) throws -> CoreError { - return try FfiConverterTypeCoreError.lift(buf) +public func FfiConverterTypePullRequestState_lift(_ buf: RustBuffer) throws -> PullRequestState { + return try FfiConverterTypePullRequestState.lift(buf) } #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeCoreError_lower(_ value: CoreError) -> RustBuffer { - return FfiConverterTypeCoreError.lower(value) +public func FfiConverterTypePullRequestState_lower(_ value: PullRequestState) -> RustBuffer { + return FfiConverterTypePullRequestState.lower(value) } -/** - * How the client authenticates against the edge. - */ -public enum Credentials: Equatable, Hashable { + +public enum QueueEditAction: Equatable, Hashable { /** - * WorkOS session scoped to `org_id`. + * Save the new text. */ - case workOs(userId: String, orgId: String, tokens: AuthTokens - ) + case commit /** - * `AUTH_MODE=dev` edge: the bearer is `userId@orgId`. + * Keep the old text, release the row. */ - case dev(userId: String, orgId: String - ) + case cancel /** - * Fully offline dataset with a simulated host. + * Delete the row. */ - case demo(options: DemoOptions - ) + case discard + /** + * Release without changing anything. + */ + case release @@ -9397,53 +11434,50 @@ public enum Credentials: Equatable, Hashable { } #if compiler(>=6) -extension Credentials: Sendable {} +extension QueueEditAction: Sendable {} #endif #if swift(>=5.8) @_documentation(visibility: private) #endif -public struct FfiConverterTypeCredentials: FfiConverterRustBuffer { - typealias SwiftType = Credentials +public struct FfiConverterTypeQueueEditAction: FfiConverterRustBuffer { + typealias SwiftType = QueueEditAction - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> Credentials { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> QueueEditAction { let variant: Int32 = try readInt(&buf) switch variant { - case 1: return .workOs(userId: try FfiConverterString.read(from: &buf), orgId: try FfiConverterString.read(from: &buf), tokens: try FfiConverterTypeAuthTokens.read(from: &buf) - ) + case 1: return .commit - case 2: return .dev(userId: try FfiConverterString.read(from: &buf), orgId: try FfiConverterString.read(from: &buf) - ) + case 2: return .cancel - case 3: return .demo(options: try FfiConverterTypeDemoOptions.read(from: &buf) - ) + case 3: return .discard + + case 4: return .release default: throw UniffiInternalError.unexpectedEnumCase } } - public static func write(_ value: Credentials, into buf: inout [UInt8]) { + public static func write(_ value: QueueEditAction, into buf: inout [UInt8]) { switch value { - case let .workOs(userId,orgId,tokens): + case .commit: writeInt(&buf, Int32(1)) - FfiConverterString.write(userId, into: &buf) - FfiConverterString.write(orgId, into: &buf) - FfiConverterTypeAuthTokens.write(tokens, into: &buf) - - case let .dev(userId,orgId): + + case .cancel: writeInt(&buf, Int32(2)) - FfiConverterString.write(userId, into: &buf) - FfiConverterString.write(orgId, into: &buf) - - case let .demo(options): + + case .discard: writeInt(&buf, Int32(3)) - FfiConverterTypeDemoOptions.write(options, into: &buf) - + + + case .release: + writeInt(&buf, Int32(4)) + } } } @@ -9452,28 +11486,33 @@ public struct FfiConverterTypeCredentials: FfiConverterRustBuffer { #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeCredentials_lift(_ buf: RustBuffer) throws -> Credentials { - return try FfiConverterTypeCredentials.lift(buf) +public func FfiConverterTypeQueueEditAction_lift(_ buf: RustBuffer) throws -> QueueEditAction { + return try FfiConverterTypeQueueEditAction.lift(buf) } #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeCredentials_lower(_ value: Credentials) -> RustBuffer { - return FfiConverterTypeCredentials.lower(value) +public func FfiConverterTypeQueueEditAction_lower(_ value: QueueEditAction) -> RustBuffer { + return FfiConverterTypeQueueEditAction.lower(value) } -/** - * Text decorations, painted relative to the run's baseline. - */ -public enum Decoration: Equatable, Hashable { +public enum QueueEditFinish: Equatable, Hashable { - case none - case underline - case strikethrough + case finished + /** + * The row changed under the lease. + */ + case conflict + case missing + /** + * The lease was superseded or expired. + */ + case lost + case unavailable @@ -9482,44 +11521,56 @@ public enum Decoration: Equatable, Hashable { } #if compiler(>=6) -extension Decoration: Sendable {} +extension QueueEditFinish: Sendable {} #endif #if swift(>=5.8) @_documentation(visibility: private) #endif -public struct FfiConverterTypeDecoration: FfiConverterRustBuffer { - typealias SwiftType = Decoration +public struct FfiConverterTypeQueueEditFinish: FfiConverterRustBuffer { + typealias SwiftType = QueueEditFinish - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> Decoration { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> QueueEditFinish { let variant: Int32 = try readInt(&buf) switch variant { - case 1: return .none + case 1: return .finished - case 2: return .underline + case 2: return .conflict - case 3: return .strikethrough + case 3: return .missing + + case 4: return .lost + + case 5: return .unavailable default: throw UniffiInternalError.unexpectedEnumCase } } - public static func write(_ value: Decoration, into buf: inout [UInt8]) { + public static func write(_ value: QueueEditFinish, into buf: inout [UInt8]) { switch value { - case .none: + case .finished: writeInt(&buf, Int32(1)) - case .underline: + case .conflict: writeInt(&buf, Int32(2)) - case .strikethrough: + case .missing: writeInt(&buf, Int32(3)) + + case .lost: + writeInt(&buf, Int32(4)) + + + case .unavailable: + writeInt(&buf, Int32(5)) + } } } @@ -9528,34 +11579,36 @@ public struct FfiConverterTypeDecoration: FfiConverterRustBuffer { #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeDecoration_lift(_ buf: RustBuffer) throws -> Decoration { - return try FfiConverterTypeDecoration.lift(buf) +public func FfiConverterTypeQueueEditFinish_lift(_ buf: RustBuffer) throws -> QueueEditFinish { + return try FfiConverterTypeQueueEditFinish.lift(buf) } #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeDecoration_lower(_ value: Decoration) -> RustBuffer { - return FfiConverterTypeDecoration.lower(value) +public func FfiConverterTypeQueueEditFinish_lower(_ value: QueueEditFinish) -> RustBuffer { + return FfiConverterTypeQueueEditFinish.lower(value) } -public enum DemoFixture: Equatable, Hashable { +public enum QueueEditStart: Equatable, Hashable { + case acquired(lease: QueueEditLease + ) /** - * Devices, projects, pinned + sectioned sessions, PRs in every state. + * Another editor holds the row. */ - case standard + case locked /** - * No projects and no sessions (empty states). + * The row left the queue. */ - case noProjects + case missing /** - * Only this phone — no execution hosts. + * Host unreachable / capability missing. */ - case iosOnly + case unavailable @@ -9564,44 +11617,52 @@ public enum DemoFixture: Equatable, Hashable { } #if compiler(>=6) -extension DemoFixture: Sendable {} +extension QueueEditStart: Sendable {} #endif #if swift(>=5.8) @_documentation(visibility: private) #endif -public struct FfiConverterTypeDemoFixture: FfiConverterRustBuffer { - typealias SwiftType = DemoFixture +public struct FfiConverterTypeQueueEditStart: FfiConverterRustBuffer { + typealias SwiftType = QueueEditStart - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> DemoFixture { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> QueueEditStart { let variant: Int32 = try readInt(&buf) switch variant { - case 1: return .standard + case 1: return .acquired(lease: try FfiConverterTypeQueueEditLease.read(from: &buf) + ) - case 2: return .noProjects + case 2: return .locked - case 3: return .iosOnly + case 3: return .missing + + case 4: return .unavailable default: throw UniffiInternalError.unexpectedEnumCase } } - public static func write(_ value: DemoFixture, into buf: inout [UInt8]) { + public static func write(_ value: QueueEditStart, into buf: inout [UInt8]) { switch value { - case .standard: + case let .acquired(lease): writeInt(&buf, Int32(1)) + FfiConverterTypeQueueEditLease.write(lease, into: &buf) + - - case .noProjects: + case .locked: writeInt(&buf, Int32(2)) - case .iosOnly: + case .missing: writeInt(&buf, Int32(3)) + + case .unavailable: + writeInt(&buf, Int32(4)) + } } } @@ -9610,37 +11671,32 @@ public struct FfiConverterTypeDemoFixture: FfiConverterRustBuffer { #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeDemoFixture_lift(_ buf: RustBuffer) throws -> DemoFixture { - return try FfiConverterTypeDemoFixture.lift(buf) +public func FfiConverterTypeQueueEditStart_lift(_ buf: RustBuffer) throws -> QueueEditStart { + return try FfiConverterTypeQueueEditStart.lift(buf) } #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeDemoFixture_lower(_ value: DemoFixture) -> RustBuffer { - return FfiConverterTypeDemoFixture.lower(value) +public func FfiConverterTypeQueueEditStart_lower(_ value: QueueEditStart) -> RustBuffer { + return FfiConverterTypeQueueEditStart.lower(value) } -/** - * Faces the platform registers (same bytes it hands CoreText/Skia). - */ -public enum FaceRole: Equatable, Hashable { +public enum QueueGate: Equatable, Hashable { - case sans - case sansMedium - case sansSemibold - case sansBold - case sansItalic - case sansMediumItalic - case sansSemiboldItalic - case sansBoldItalic - case mono - case monoMedium - case monoSemibold - case monoItalic + /** + * Someone holds an edit lease; the row won't deliver until released. + */ + case editing(ownerDeviceId: String, expiresAtMs: Int64, mine: Bool + ) + /** + * A lease lapsed — a person must review before it sends. + */ + case reviewRequired(ownerDeviceId: String + ) @@ -9649,98 +11705,44 @@ public enum FaceRole: Equatable, Hashable { } #if compiler(>=6) -extension FaceRole: Sendable {} +extension QueueGate: Sendable {} #endif #if swift(>=5.8) @_documentation(visibility: private) #endif -public struct FfiConverterTypeFaceRole: FfiConverterRustBuffer { - typealias SwiftType = FaceRole +public struct FfiConverterTypeQueueGate: FfiConverterRustBuffer { + typealias SwiftType = QueueGate - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> FaceRole { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> QueueGate { let variant: Int32 = try readInt(&buf) switch variant { - case 1: return .sans - - case 2: return .sansMedium - - case 3: return .sansSemibold - - case 4: return .sansBold - - case 5: return .sansItalic - - case 6: return .sansMediumItalic - - case 7: return .sansSemiboldItalic - - case 8: return .sansBoldItalic - - case 9: return .mono - - case 10: return .monoMedium - - case 11: return .monoSemibold + case 1: return .editing(ownerDeviceId: try FfiConverterString.read(from: &buf), expiresAtMs: try FfiConverterInt64.read(from: &buf), mine: try FfiConverterBool.read(from: &buf) + ) - case 12: return .monoItalic + case 2: return .reviewRequired(ownerDeviceId: try FfiConverterString.read(from: &buf) + ) default: throw UniffiInternalError.unexpectedEnumCase } } - public static func write(_ value: FaceRole, into buf: inout [UInt8]) { + public static func write(_ value: QueueGate, into buf: inout [UInt8]) { switch value { - case .sans: + case let .editing(ownerDeviceId,expiresAtMs,mine): writeInt(&buf, Int32(1)) + FfiConverterString.write(ownerDeviceId, into: &buf) + FfiConverterInt64.write(expiresAtMs, into: &buf) + FfiConverterBool.write(mine, into: &buf) + - - case .sansMedium: + case let .reviewRequired(ownerDeviceId): writeInt(&buf, Int32(2)) - - - case .sansSemibold: - writeInt(&buf, Int32(3)) - - - case .sansBold: - writeInt(&buf, Int32(4)) - - - case .sansItalic: - writeInt(&buf, Int32(5)) - - - case .sansMediumItalic: - writeInt(&buf, Int32(6)) - - - case .sansSemiboldItalic: - writeInt(&buf, Int32(7)) - - - case .sansBoldItalic: - writeInt(&buf, Int32(8)) - - - case .mono: - writeInt(&buf, Int32(9)) - - - case .monoMedium: - writeInt(&buf, Int32(10)) - - - case .monoSemibold: - writeInt(&buf, Int32(11)) - - - case .monoItalic: - writeInt(&buf, Int32(12)) - + FfiConverterString.write(ownerDeviceId, into: &buf) + } } } @@ -9749,35 +11751,31 @@ public struct FfiConverterTypeFaceRole: FfiConverterRustBuffer { #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeFaceRole_lift(_ buf: RustBuffer) throws -> FaceRole { - return try FfiConverterTypeFaceRole.lift(buf) +public func FfiConverterTypeQueueGate_lift(_ buf: RustBuffer) throws -> QueueGate { + return try FfiConverterTypeQueueGate.lift(buf) } #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeFaceRole_lower(_ value: FaceRole) -> RustBuffer { - return FfiConverterTypeFaceRole.lower(value) +public func FfiConverterTypeQueueGate_lower(_ value: QueueGate) -> RustBuffer { + return FfiConverterTypeQueueGate.lower(value) } /** - * Which way overflowing text fades out. + * Row kinds the painter may style differently (e.g. context menus). */ -public enum FadeEdge: Equatable, Hashable { +public enum RowKind: Equatable, Hashable { - /** - * Text fades to transparent across the rect, left → right, and is - * clipped at its right edge (single lines wider than their slot). - */ - case trailing - /** - * Text fades to transparent across the rect, top → bottom (folded - * blocks whose last visible line continues). - */ - case bottom + case markdown + case user + case tools + case chip + case image + case working @@ -9786,38 +11784,62 @@ public enum FadeEdge: Equatable, Hashable { } #if compiler(>=6) -extension FadeEdge: Sendable {} +extension RowKind: Sendable {} #endif #if swift(>=5.8) @_documentation(visibility: private) #endif -public struct FfiConverterTypeFadeEdge: FfiConverterRustBuffer { - typealias SwiftType = FadeEdge +public struct FfiConverterTypeRowKind: FfiConverterRustBuffer { + typealias SwiftType = RowKind - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> FadeEdge { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> RowKind { let variant: Int32 = try readInt(&buf) switch variant { - case 1: return .trailing + case 1: return .markdown - case 2: return .bottom + case 2: return .user + + case 3: return .tools + + case 4: return .chip + + case 5: return .image + + case 6: return .working default: throw UniffiInternalError.unexpectedEnumCase } } - public static func write(_ value: FadeEdge, into buf: inout [UInt8]) { + public static func write(_ value: RowKind, into buf: inout [UInt8]) { switch value { - case .trailing: + case .markdown: writeInt(&buf, Int32(1)) - case .bottom: + case .user: writeInt(&buf, Int32(2)) + + case .tools: + writeInt(&buf, Int32(3)) + + + case .chip: + writeInt(&buf, Int32(4)) + + + case .image: + writeInt(&buf, Int32(5)) + + + case .working: + writeInt(&buf, Int32(6)) + } } } @@ -9826,24 +11848,25 @@ public struct FfiConverterTypeFadeEdge: FfiConverterRustBuffer { #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeFadeEdge_lift(_ buf: RustBuffer) throws -> FadeEdge { - return try FfiConverterTypeFadeEdge.lift(buf) +public func FfiConverterTypeRowKind_lift(_ buf: RustBuffer) throws -> RowKind { + return try FfiConverterTypeRowKind.lift(buf) } #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeFadeEdge_lower(_ value: FadeEdge) -> RustBuffer { - return FfiConverterTypeFadeEdge.lower(value) +public func FfiConverterTypeRowKind_lower(_ value: RowKind) -> RustBuffer { + return FfiConverterTypeRowKind.lower(value) } -public enum PendingKind: Equatable, Hashable { +public enum SandboxLevel: Equatable, Hashable { - case run - case steer + case readOnly + case workspaceWrite + case dangerFullAccess @@ -9852,38 +11875,44 @@ public enum PendingKind: Equatable, Hashable { } #if compiler(>=6) -extension PendingKind: Sendable {} +extension SandboxLevel: Sendable {} #endif #if swift(>=5.8) @_documentation(visibility: private) #endif -public struct FfiConverterTypePendingKind: FfiConverterRustBuffer { - typealias SwiftType = PendingKind +public struct FfiConverterTypeSandboxLevel: FfiConverterRustBuffer { + typealias SwiftType = SandboxLevel - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> PendingKind { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SandboxLevel { let variant: Int32 = try readInt(&buf) switch variant { - case 1: return .run + case 1: return .readOnly - case 2: return .steer + case 2: return .workspaceWrite + + case 3: return .dangerFullAccess default: throw UniffiInternalError.unexpectedEnumCase } } - public static func write(_ value: PendingKind, into buf: inout [UInt8]) { + public static func write(_ value: SandboxLevel, into buf: inout [UInt8]) { switch value { - case .run: + case .readOnly: writeInt(&buf, Int32(1)) - case .steer: + case .workspaceWrite: writeInt(&buf, Int32(2)) + + case .dangerFullAccess: + writeInt(&buf, Int32(3)) + } } } @@ -9892,25 +11921,26 @@ public struct FfiConverterTypePendingKind: FfiConverterRustBuffer { #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypePendingKind_lift(_ buf: RustBuffer) throws -> PendingKind { - return try FfiConverterTypePendingKind.lift(buf) +public func FfiConverterTypeSandboxLevel_lift(_ buf: RustBuffer) throws -> SandboxLevel { + return try FfiConverterTypeSandboxLevel.lift(buf) } #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypePendingKind_lower(_ value: PendingKind) -> RustBuffer { - return FfiConverterTypePendingKind.lower(value) +public func FfiConverterTypeSandboxLevel_lower(_ value: SandboxLevel) -> RustBuffer { + return FfiConverterTypeSandboxLevel.lower(value) } -public enum PullRequestState: Equatable, Hashable { +public enum SearchField: Equatable, Hashable { - case `open` - case merged - case closed + case title + case project + case branch + case preview @@ -9919,44 +11949,50 @@ public enum PullRequestState: Equatable, Hashable { } #if compiler(>=6) -extension PullRequestState: Sendable {} +extension SearchField: Sendable {} #endif #if swift(>=5.8) @_documentation(visibility: private) #endif -public struct FfiConverterTypePullRequestState: FfiConverterRustBuffer { - typealias SwiftType = PullRequestState +public struct FfiConverterTypeSearchField: FfiConverterRustBuffer { + typealias SwiftType = SearchField - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> PullRequestState { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SearchField { let variant: Int32 = try readInt(&buf) switch variant { - case 1: return .`open` + case 1: return .title - case 2: return .merged + case 2: return .project - case 3: return .closed + case 3: return .branch + + case 4: return .preview default: throw UniffiInternalError.unexpectedEnumCase } } - public static func write(_ value: PullRequestState, into buf: inout [UInt8]) { + public static func write(_ value: SearchField, into buf: inout [UInt8]) { switch value { - case .`open`: + case .title: writeInt(&buf, Int32(1)) - case .merged: + case .project: writeInt(&buf, Int32(2)) - case .closed: + case .branch: writeInt(&buf, Int32(3)) + + case .preview: + writeInt(&buf, Int32(4)) + } } } @@ -9965,38 +12001,37 @@ public struct FfiConverterTypePullRequestState: FfiConverterRustBuffer { #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypePullRequestState_lift(_ buf: RustBuffer) throws -> PullRequestState { - return try FfiConverterTypePullRequestState.lift(buf) +public func FfiConverterTypeSearchField_lift(_ buf: RustBuffer) throws -> SearchField { + return try FfiConverterTypeSearchField.lift(buf) } #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypePullRequestState_lower(_ value: PullRequestState) -> RustBuffer { - return FfiConverterTypePullRequestState.lower(value) +public func FfiConverterTypeSearchField_lower(_ value: SearchField) -> RustBuffer { + return FfiConverterTypeSearchField.lower(value) } -public enum QueueEditAction: Equatable, Hashable { +public enum SendOutcome: Equatable, Hashable { /** - * Save the new text. - */ - case commit - /** - * Keep the old text, release the row. + * A new turn was requested; the echo's id is `message_id`. */ - case cancel + case started(messageId: String + ) /** - * Delete the row. + * Delivered into the live turn. */ - case discard + case steered(messageId: String + ) /** - * Release without changing anything. + * Parked on the shared queue. */ - case release + case queued(queueId: String + ) @@ -10005,50 +12040,50 @@ public enum QueueEditAction: Equatable, Hashable { } #if compiler(>=6) -extension QueueEditAction: Sendable {} +extension SendOutcome: Sendable {} #endif #if swift(>=5.8) @_documentation(visibility: private) #endif -public struct FfiConverterTypeQueueEditAction: FfiConverterRustBuffer { - typealias SwiftType = QueueEditAction +public struct FfiConverterTypeSendOutcome: FfiConverterRustBuffer { + typealias SwiftType = SendOutcome - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> QueueEditAction { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SendOutcome { let variant: Int32 = try readInt(&buf) switch variant { - case 1: return .commit - - case 2: return .cancel + case 1: return .started(messageId: try FfiConverterString.read(from: &buf) + ) - case 3: return .discard + case 2: return .steered(messageId: try FfiConverterString.read(from: &buf) + ) - case 4: return .release + case 3: return .queued(queueId: try FfiConverterString.read(from: &buf) + ) default: throw UniffiInternalError.unexpectedEnumCase } } - public static func write(_ value: QueueEditAction, into buf: inout [UInt8]) { + public static func write(_ value: SendOutcome, into buf: inout [UInt8]) { switch value { - case .commit: + case let .started(messageId): writeInt(&buf, Int32(1)) + FfiConverterString.write(messageId, into: &buf) + - - case .cancel: + case let .steered(messageId): writeInt(&buf, Int32(2)) + FfiConverterString.write(messageId, into: &buf) + - - case .discard: + case let .queued(queueId): writeInt(&buf, Int32(3)) - - - case .release: - writeInt(&buf, Int32(4)) - + FfiConverterString.write(queueId, into: &buf) + } } } @@ -10057,33 +12092,31 @@ public struct FfiConverterTypeQueueEditAction: FfiConverterRustBuffer { #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeQueueEditAction_lift(_ buf: RustBuffer) throws -> QueueEditAction { - return try FfiConverterTypeQueueEditAction.lift(buf) +public func FfiConverterTypeSendOutcome_lift(_ buf: RustBuffer) throws -> SendOutcome { + return try FfiConverterTypeSendOutcome.lift(buf) } #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeQueueEditAction_lower(_ value: QueueEditAction) -> RustBuffer { - return FfiConverterTypeQueueEditAction.lower(value) +public func FfiConverterTypeSendOutcome_lower(_ value: SendOutcome) -> RustBuffer { + return FfiConverterTypeSendOutcome.lower(value) } -public enum QueueEditFinish: Equatable, Hashable { +public enum SendState: Equatable, Hashable { - case finished + case sending /** - * The row changed under the lease. + * Durable, waiting on a degraded path. */ - case conflict - case missing + case queued /** - * The lease was superseded or expired. + * "Not delivered — retry". */ - case lost - case unavailable + case failed @@ -10092,56 +12125,44 @@ public enum QueueEditFinish: Equatable, Hashable { } #if compiler(>=6) -extension QueueEditFinish: Sendable {} +extension SendState: Sendable {} #endif #if swift(>=5.8) @_documentation(visibility: private) #endif -public struct FfiConverterTypeQueueEditFinish: FfiConverterRustBuffer { - typealias SwiftType = QueueEditFinish +public struct FfiConverterTypeSendState: FfiConverterRustBuffer { + typealias SwiftType = SendState - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> QueueEditFinish { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SendState { let variant: Int32 = try readInt(&buf) switch variant { - case 1: return .finished - - case 2: return .conflict - - case 3: return .missing + case 1: return .sending - case 4: return .lost + case 2: return .queued - case 5: return .unavailable + case 3: return .failed default: throw UniffiInternalError.unexpectedEnumCase } } - public static func write(_ value: QueueEditFinish, into buf: inout [UInt8]) { + public static func write(_ value: SendState, into buf: inout [UInt8]) { switch value { - case .finished: + case .sending: writeInt(&buf, Int32(1)) - case .conflict: + case .queued: writeInt(&buf, Int32(2)) - case .missing: + case .failed: writeInt(&buf, Int32(3)) - - case .lost: - writeInt(&buf, Int32(4)) - - - case .unavailable: - writeInt(&buf, Int32(5)) - } } } @@ -10150,36 +12171,35 @@ public struct FfiConverterTypeQueueEditFinish: FfiConverterRustBuffer { #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeQueueEditFinish_lift(_ buf: RustBuffer) throws -> QueueEditFinish { - return try FfiConverterTypeQueueEditFinish.lift(buf) +public func FfiConverterTypeSendState_lift(_ buf: RustBuffer) throws -> SendState { + return try FfiConverterTypeSendState.lift(buf) } #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeQueueEditFinish_lower(_ value: QueueEditFinish) -> RustBuffer { - return FfiConverterTypeQueueEditFinish.lower(value) +public func FfiConverterTypeSendState_lower(_ value: SendState) -> RustBuffer { + return FfiConverterTypeSendState.lower(value) } +/** + * Where a new session runs. + */ -public enum QueueEditStart: Equatable, Hashable { +public enum SessionTarget: Equatable, Hashable { - case acquired(lease: QueueEditLease - ) - /** - * Another editor holds the row. - */ - case locked /** - * The row left the queue. + * In a project (host = its device, cwd = its folder or `cwd` override). */ - case missing + case project(spaceId: String + ) /** - * Host unreachable / capability missing. + * No project: the chosen host's home folder. */ - case unavailable + case projectless(deviceId: String + ) @@ -10188,52 +12208,42 @@ public enum QueueEditStart: Equatable, Hashable { } #if compiler(>=6) -extension QueueEditStart: Sendable {} +extension SessionTarget: Sendable {} #endif #if swift(>=5.8) @_documentation(visibility: private) #endif -public struct FfiConverterTypeQueueEditStart: FfiConverterRustBuffer { - typealias SwiftType = QueueEditStart +public struct FfiConverterTypeSessionTarget: FfiConverterRustBuffer { + typealias SwiftType = SessionTarget - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> QueueEditStart { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SessionTarget { let variant: Int32 = try readInt(&buf) switch variant { - case 1: return .acquired(lease: try FfiConverterTypeQueueEditLease.read(from: &buf) + case 1: return .project(spaceId: try FfiConverterString.read(from: &buf) ) - case 2: return .locked - - case 3: return .missing - - case 4: return .unavailable + case 2: return .projectless(deviceId: try FfiConverterString.read(from: &buf) + ) default: throw UniffiInternalError.unexpectedEnumCase } } - public static func write(_ value: QueueEditStart, into buf: inout [UInt8]) { + public static func write(_ value: SessionTarget, into buf: inout [UInt8]) { switch value { - case let .acquired(lease): + case let .project(spaceId): writeInt(&buf, Int32(1)) - FfiConverterTypeQueueEditLease.write(lease, into: &buf) + FfiConverterString.write(spaceId, into: &buf) - case .locked: + case let .projectless(deviceId): writeInt(&buf, Int32(2)) - - - case .missing: - writeInt(&buf, Int32(3)) - - - case .unavailable: - writeInt(&buf, Int32(4)) - + FfiConverterString.write(deviceId, into: &buf) + } } } @@ -10242,32 +12252,30 @@ public struct FfiConverterTypeQueueEditStart: FfiConverterRustBuffer { #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeQueueEditStart_lift(_ buf: RustBuffer) throws -> QueueEditStart { - return try FfiConverterTypeQueueEditStart.lift(buf) +public func FfiConverterTypeSessionTarget_lift(_ buf: RustBuffer) throws -> SessionTarget { + return try FfiConverterTypeSessionTarget.lift(buf) } #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeQueueEditStart_lower(_ value: QueueEditStart) -> RustBuffer { - return FfiConverterTypeQueueEditStart.lower(value) +public func FfiConverterTypeSessionTarget_lower(_ value: SessionTarget) -> RustBuffer { + return FfiConverterTypeSessionTarget.lower(value) } -public enum QueueGate: Equatable, Hashable { +public enum StreamSpeed: Equatable, Hashable { /** - * Someone holds an edit lease; the row won't deliver until released. + * Word by word at 30–140ms. */ - case editing(ownerDeviceId: String, expiresAtMs: Int64, mine: Bool - ) + case realistic /** - * A lease lapsed — a person must review before it sends. + * ~4ms per word (benchmarks). */ - case reviewRequired(ownerDeviceId: String - ) + case fast @@ -10276,44 +12284,38 @@ public enum QueueGate: Equatable, Hashable { } #if compiler(>=6) -extension QueueGate: Sendable {} +extension StreamSpeed: Sendable {} #endif #if swift(>=5.8) @_documentation(visibility: private) #endif -public struct FfiConverterTypeQueueGate: FfiConverterRustBuffer { - typealias SwiftType = QueueGate +public struct FfiConverterTypeStreamSpeed: FfiConverterRustBuffer { + typealias SwiftType = StreamSpeed - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> QueueGate { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> StreamSpeed { let variant: Int32 = try readInt(&buf) switch variant { - case 1: return .editing(ownerDeviceId: try FfiConverterString.read(from: &buf), expiresAtMs: try FfiConverterInt64.read(from: &buf), mine: try FfiConverterBool.read(from: &buf) - ) + case 1: return .realistic - case 2: return .reviewRequired(ownerDeviceId: try FfiConverterString.read(from: &buf) - ) + case 2: return .fast default: throw UniffiInternalError.unexpectedEnumCase } } - public static func write(_ value: QueueGate, into buf: inout [UInt8]) { + public static func write(_ value: StreamSpeed, into buf: inout [UInt8]) { switch value { - case let .editing(ownerDeviceId,expiresAtMs,mine): + case .realistic: writeInt(&buf, Int32(1)) - FfiConverterString.write(ownerDeviceId, into: &buf) - FfiConverterInt64.write(expiresAtMs, into: &buf) - FfiConverterBool.write(mine, into: &buf) - - case let .reviewRequired(ownerDeviceId): + + case .fast: writeInt(&buf, Int32(2)) - FfiConverterString.write(ownerDeviceId, into: &buf) - + } } } @@ -10322,31 +12324,33 @@ public struct FfiConverterTypeQueueGate: FfiConverterRustBuffer { #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeQueueGate_lift(_ buf: RustBuffer) throws -> QueueGate { - return try FfiConverterTypeQueueGate.lift(buf) +public func FfiConverterTypeStreamSpeed_lift(_ buf: RustBuffer) throws -> StreamSpeed { + return try FfiConverterTypeStreamSpeed.lift(buf) } #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeQueueGate_lower(_ value: QueueGate) -> RustBuffer { - return FfiConverterTypeQueueGate.lower(value) +public func FfiConverterTypeStreamSpeed_lower(_ value: StreamSpeed) -> RustBuffer { + return FfiConverterTypeStreamSpeed.lower(value) } -/** - * Row kinds the painter may style differently (e.g. context menus). - */ -public enum RowKind: Equatable, Hashable { +public enum TranscriptScale: Equatable, Hashable { - case markdown - case user - case tools - case chip - case image - case working + case normal + /** + * 120 synthetic turns. + */ + case big + /** + * 600 synthetic turns. + */ + case huge + case turns(count: UInt32 + ) @@ -10355,62 +12359,52 @@ public enum RowKind: Equatable, Hashable { } #if compiler(>=6) -extension RowKind: Sendable {} +extension TranscriptScale: Sendable {} #endif #if swift(>=5.8) @_documentation(visibility: private) #endif -public struct FfiConverterTypeRowKind: FfiConverterRustBuffer { - typealias SwiftType = RowKind +public struct FfiConverterTypeTranscriptScale: FfiConverterRustBuffer { + typealias SwiftType = TranscriptScale - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> RowKind { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> TranscriptScale { let variant: Int32 = try readInt(&buf) switch variant { - case 1: return .markdown - - case 2: return .user - - case 3: return .tools + case 1: return .normal - case 4: return .chip + case 2: return .big - case 5: return .image + case 3: return .huge - case 6: return .working + case 4: return .turns(count: try FfiConverterUInt32.read(from: &buf) + ) default: throw UniffiInternalError.unexpectedEnumCase } } - public static func write(_ value: RowKind, into buf: inout [UInt8]) { + public static func write(_ value: TranscriptScale, into buf: inout [UInt8]) { switch value { - case .markdown: + case .normal: writeInt(&buf, Int32(1)) - case .user: + case .big: writeInt(&buf, Int32(2)) - case .tools: + case .huge: writeInt(&buf, Int32(3)) - case .chip: + case let .turns(count): writeInt(&buf, Int32(4)) - - - case .image: - writeInt(&buf, Int32(5)) - - - case .working: - writeInt(&buf, Int32(6)) - + FfiConverterUInt32.write(count, into: &buf) + } } } @@ -10419,25 +12413,25 @@ public struct FfiConverterTypeRowKind: FfiConverterRustBuffer { #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeRowKind_lift(_ buf: RustBuffer) throws -> RowKind { - return try FfiConverterTypeRowKind.lift(buf) +public func FfiConverterTypeTranscriptScale_lift(_ buf: RustBuffer) throws -> TranscriptScale { + return try FfiConverterTypeTranscriptScale.lift(buf) } #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeRowKind_lower(_ value: RowKind) -> RustBuffer { - return FfiConverterTypeRowKind.lower(value) +public func FfiConverterTypeTranscriptScale_lower(_ value: TranscriptScale) -> RustBuffer { + return FfiConverterTypeTranscriptScale.lower(value) } -public enum SandboxLevel: Equatable, Hashable { +public enum VoiceCallPhase: Equatable, Hashable { - case readOnly - case workspaceWrite - case dangerFullAccess + case connecting + case active + case ending @@ -10446,42 +12440,42 @@ public enum SandboxLevel: Equatable, Hashable { } #if compiler(>=6) -extension SandboxLevel: Sendable {} +extension VoiceCallPhase: Sendable {} #endif #if swift(>=5.8) @_documentation(visibility: private) #endif -public struct FfiConverterTypeSandboxLevel: FfiConverterRustBuffer { - typealias SwiftType = SandboxLevel +public struct FfiConverterTypeVoiceCallPhase: FfiConverterRustBuffer { + typealias SwiftType = VoiceCallPhase - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SandboxLevel { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> VoiceCallPhase { let variant: Int32 = try readInt(&buf) switch variant { - case 1: return .readOnly + case 1: return .connecting - case 2: return .workspaceWrite + case 2: return .active - case 3: return .dangerFullAccess + case 3: return .ending default: throw UniffiInternalError.unexpectedEnumCase } } - public static func write(_ value: SandboxLevel, into buf: inout [UInt8]) { + public static func write(_ value: VoiceCallPhase, into buf: inout [UInt8]) { switch value { - case .readOnly: + case .connecting: writeInt(&buf, Int32(1)) - case .workspaceWrite: + case .active: writeInt(&buf, Int32(2)) - case .dangerFullAccess: + case .ending: writeInt(&buf, Int32(3)) } @@ -10492,26 +12486,31 @@ public struct FfiConverterTypeSandboxLevel: FfiConverterRustBuffer { #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeSandboxLevel_lift(_ buf: RustBuffer) throws -> SandboxLevel { - return try FfiConverterTypeSandboxLevel.lift(buf) +public func FfiConverterTypeVoiceCallPhase_lift(_ buf: RustBuffer) throws -> VoiceCallPhase { + return try FfiConverterTypeVoiceCallPhase.lift(buf) } #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeSandboxLevel_lower(_ value: SandboxLevel) -> RustBuffer { - return FfiConverterTypeSandboxLevel.lower(value) +public func FfiConverterTypeVoiceCallPhase_lower(_ value: VoiceCallPhase) -> RustBuffer { + return FfiConverterTypeVoiceCallPhase.lower(value) } -public enum SearchField: Equatable, Hashable { +public enum VoiceCallWork: Equatable, Hashable { - case title - case project - case branch - case preview + case idle + /** + * The orchestrator's Codex turn (or a delegation) is running. + */ + case working + /** + * Codex asked the user something; answer it in the transcript. + */ + case awaitingInput @@ -10520,50 +12519,44 @@ public enum SearchField: Equatable, Hashable { } #if compiler(>=6) -extension SearchField: Sendable {} +extension VoiceCallWork: Sendable {} #endif #if swift(>=5.8) @_documentation(visibility: private) #endif -public struct FfiConverterTypeSearchField: FfiConverterRustBuffer { - typealias SwiftType = SearchField +public struct FfiConverterTypeVoiceCallWork: FfiConverterRustBuffer { + typealias SwiftType = VoiceCallWork - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SearchField { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> VoiceCallWork { let variant: Int32 = try readInt(&buf) switch variant { - case 1: return .title - - case 2: return .project + case 1: return .idle - case 3: return .branch + case 2: return .working - case 4: return .preview + case 3: return .awaitingInput default: throw UniffiInternalError.unexpectedEnumCase } } - public static func write(_ value: SearchField, into buf: inout [UInt8]) { + public static func write(_ value: VoiceCallWork, into buf: inout [UInt8]) { switch value { - case .title: + case .idle: writeInt(&buf, Int32(1)) - case .project: + case .working: writeInt(&buf, Int32(2)) - case .branch: + case .awaitingInput: writeInt(&buf, Int32(3)) - - case .preview: - writeInt(&buf, Int32(4)) - } } } @@ -10572,37 +12565,42 @@ public struct FfiConverterTypeSearchField: FfiConverterRustBuffer { #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeSearchField_lift(_ buf: RustBuffer) throws -> SearchField { - return try FfiConverterTypeSearchField.lift(buf) +public func FfiConverterTypeVoiceCallWork_lift(_ buf: RustBuffer) throws -> VoiceCallWork { + return try FfiConverterTypeVoiceCallWork.lift(buf) } #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeSearchField_lower(_ value: SearchField) -> RustBuffer { - return FfiConverterTypeSearchField.lower(value) +public func FfiConverterTypeVoiceCallWork_lower(_ value: VoiceCallWork) -> RustBuffer { + return FfiConverterTypeVoiceCallWork.lower(value) } +/** + * Why a call ended without the user hanging up. + */ -public enum SendOutcome: Equatable, Hashable { +public enum VoiceEndReason: Equatable, Hashable { + case microphoneDenied + case audioUnavailable /** - * A new turn was requested; the echo's id is `message_id`. + * Codex on the host must be signed in with ChatGPT. */ - case started(messageId: String - ) + case signInRequired + case usageUnavailable /** - * Delivered into the live turn. + * The host already has a voice call. */ - case steered(messageId: String - ) + case busy + case hostUnavailable /** - * Parked on the shared queue. + * The host's Zeron is too old or has remote voice disabled. */ - case queued(queueId: String - ) + case hostIncompatible + case connectionLost @@ -10611,50 +12609,74 @@ public enum SendOutcome: Equatable, Hashable { } #if compiler(>=6) -extension SendOutcome: Sendable {} +extension VoiceEndReason: Sendable {} #endif #if swift(>=5.8) @_documentation(visibility: private) #endif -public struct FfiConverterTypeSendOutcome: FfiConverterRustBuffer { - typealias SwiftType = SendOutcome +public struct FfiConverterTypeVoiceEndReason: FfiConverterRustBuffer { + typealias SwiftType = VoiceEndReason - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SendOutcome { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> VoiceEndReason { let variant: Int32 = try readInt(&buf) switch variant { - case 1: return .started(messageId: try FfiConverterString.read(from: &buf) - ) + case 1: return .microphoneDenied - case 2: return .steered(messageId: try FfiConverterString.read(from: &buf) - ) + case 2: return .audioUnavailable - case 3: return .queued(queueId: try FfiConverterString.read(from: &buf) - ) + case 3: return .signInRequired + + case 4: return .usageUnavailable + + case 5: return .busy + + case 6: return .hostUnavailable + + case 7: return .hostIncompatible + + case 8: return .connectionLost default: throw UniffiInternalError.unexpectedEnumCase } } - public static func write(_ value: SendOutcome, into buf: inout [UInt8]) { + public static func write(_ value: VoiceEndReason, into buf: inout [UInt8]) { switch value { - case let .started(messageId): + case .microphoneDenied: writeInt(&buf, Int32(1)) - FfiConverterString.write(messageId, into: &buf) - - case let .steered(messageId): + + case .audioUnavailable: writeInt(&buf, Int32(2)) - FfiConverterString.write(messageId, into: &buf) - - case let .queued(queueId): + + case .signInRequired: writeInt(&buf, Int32(3)) - FfiConverterString.write(queueId, into: &buf) - + + + case .usageUnavailable: + writeInt(&buf, Int32(4)) + + + case .busy: + writeInt(&buf, Int32(5)) + + + case .hostUnavailable: + writeInt(&buf, Int32(6)) + + + case .hostIncompatible: + writeInt(&buf, Int32(7)) + + + case .connectionLost: + writeInt(&buf, Int32(8)) + } } } @@ -10663,31 +12685,27 @@ public struct FfiConverterTypeSendOutcome: FfiConverterRustBuffer { #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeSendOutcome_lift(_ buf: RustBuffer) throws -> SendOutcome { - return try FfiConverterTypeSendOutcome.lift(buf) +public func FfiConverterTypeVoiceEndReason_lift(_ buf: RustBuffer) throws -> VoiceEndReason { + return try FfiConverterTypeVoiceEndReason.lift(buf) } #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeSendOutcome_lower(_ value: SendOutcome) -> RustBuffer { - return FfiConverterTypeSendOutcome.lower(value) +public func FfiConverterTypeVoiceEndReason_lower(_ value: VoiceEndReason) -> RustBuffer { + return FfiConverterTypeVoiceEndReason.lower(value) } +/** + * Why a platform media operation failed. + */ -public enum SendState: Equatable, Hashable { +public enum VoiceMediaFailure: Equatable, Hashable { - case sending - /** - * Durable, waiting on a degraded path. - */ - case queued - /** - * "Not delivered — retry". - */ - case failed + case permissionDenied + case unavailable @@ -10696,44 +12714,38 @@ public enum SendState: Equatable, Hashable { } #if compiler(>=6) -extension SendState: Sendable {} +extension VoiceMediaFailure: Sendable {} #endif #if swift(>=5.8) @_documentation(visibility: private) #endif -public struct FfiConverterTypeSendState: FfiConverterRustBuffer { - typealias SwiftType = SendState +public struct FfiConverterTypeVoiceMediaFailure: FfiConverterRustBuffer { + typealias SwiftType = VoiceMediaFailure - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SendState { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> VoiceMediaFailure { let variant: Int32 = try readInt(&buf) switch variant { - case 1: return .sending - - case 2: return .queued + case 1: return .permissionDenied - case 3: return .failed + case 2: return .unavailable default: throw UniffiInternalError.unexpectedEnumCase } } - public static func write(_ value: SendState, into buf: inout [UInt8]) { + public static func write(_ value: VoiceMediaFailure, into buf: inout [UInt8]) { switch value { - case .sending: + case .permissionDenied: writeInt(&buf, Int32(1)) - case .queued: + case .unavailable: writeInt(&buf, Int32(2)) - - case .failed: - writeInt(&buf, Int32(3)) - } } } @@ -10742,35 +12754,28 @@ public struct FfiConverterTypeSendState: FfiConverterRustBuffer { #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeSendState_lift(_ buf: RustBuffer) throws -> SendState { - return try FfiConverterTypeSendState.lift(buf) -} - -#if swift(>=5.8) -@_documentation(visibility: private) -#endif -public func FfiConverterTypeSendState_lower(_ value: SendState) -> RustBuffer { - return FfiConverterTypeSendState.lower(value) +public func FfiConverterTypeVoiceMediaFailure_lift(_ buf: RustBuffer) throws -> VoiceMediaFailure { + return try FfiConverterTypeVoiceMediaFailure.lift(buf) } - - -/** - * Where a new session runs. - */ - -public enum SessionTarget: Equatable, Hashable { - - /** - * In a project (host = its device, cwd = its folder or `cwd` override). - */ - case project(spaceId: String - ) - /** - * No project: the chosen host's home folder. - */ - case projectless(deviceId: String - ) +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeVoiceMediaFailure_lower(_ value: VoiceMediaFailure) -> RustBuffer { + return FfiConverterTypeVoiceMediaFailure.lower(value) +} + + + + +public enum VoiceMediaOperation: Equatable, Hashable { + + case prepare + case offer + case applyAnswer + case setMuted + case levels + case close @@ -10779,42 +12784,62 @@ public enum SessionTarget: Equatable, Hashable { } #if compiler(>=6) -extension SessionTarget: Sendable {} +extension VoiceMediaOperation: Sendable {} #endif #if swift(>=5.8) @_documentation(visibility: private) #endif -public struct FfiConverterTypeSessionTarget: FfiConverterRustBuffer { - typealias SwiftType = SessionTarget +public struct FfiConverterTypeVoiceMediaOperation: FfiConverterRustBuffer { + typealias SwiftType = VoiceMediaOperation - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SessionTarget { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> VoiceMediaOperation { let variant: Int32 = try readInt(&buf) switch variant { - case 1: return .project(spaceId: try FfiConverterString.read(from: &buf) - ) + case 1: return .prepare - case 2: return .projectless(deviceId: try FfiConverterString.read(from: &buf) - ) + case 2: return .offer + + case 3: return .applyAnswer + + case 4: return .setMuted + + case 5: return .levels + + case 6: return .close default: throw UniffiInternalError.unexpectedEnumCase } } - public static func write(_ value: SessionTarget, into buf: inout [UInt8]) { + public static func write(_ value: VoiceMediaOperation, into buf: inout [UInt8]) { switch value { - case let .project(spaceId): + case .prepare: writeInt(&buf, Int32(1)) - FfiConverterString.write(spaceId, into: &buf) - - case let .projectless(deviceId): + + case .offer: writeInt(&buf, Int32(2)) - FfiConverterString.write(deviceId, into: &buf) - + + + case .applyAnswer: + writeInt(&buf, Int32(3)) + + + case .setMuted: + writeInt(&buf, Int32(4)) + + + case .levels: + writeInt(&buf, Int32(5)) + + + case .close: + writeInt(&buf, Int32(6)) + } } } @@ -10823,30 +12848,35 @@ public struct FfiConverterTypeSessionTarget: FfiConverterRustBuffer { #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeSessionTarget_lift(_ buf: RustBuffer) throws -> SessionTarget { - return try FfiConverterTypeSessionTarget.lift(buf) +public func FfiConverterTypeVoiceMediaOperation_lift(_ buf: RustBuffer) throws -> VoiceMediaOperation { + return try FfiConverterTypeVoiceMediaOperation.lift(buf) } #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeSessionTarget_lower(_ value: SessionTarget) -> RustBuffer { - return FfiConverterTypeSessionTarget.lower(value) +public func FfiConverterTypeVoiceMediaOperation_lower(_ value: VoiceMediaOperation) -> RustBuffer { + return FfiConverterTypeVoiceMediaOperation.lower(value) } +/** + * What the voice orb is showing, in call terms. + */ -public enum StreamSpeed: Equatable, Hashable { +public enum VoiceOrb: Equatable, Hashable { /** - * Word by word at 30–140ms. - */ - case realistic - /** - * ~4ms per word (benchmarks). + * No call: the calm form of a muted orchestrator. */ - case fast + case idle + case connecting + case listening + case speaking + case working + case awaitingInput + case muted @@ -10855,38 +12885,68 @@ public enum StreamSpeed: Equatable, Hashable { } #if compiler(>=6) -extension StreamSpeed: Sendable {} +extension VoiceOrb: Sendable {} #endif #if swift(>=5.8) @_documentation(visibility: private) #endif -public struct FfiConverterTypeStreamSpeed: FfiConverterRustBuffer { - typealias SwiftType = StreamSpeed +public struct FfiConverterTypeVoiceOrb: FfiConverterRustBuffer { + typealias SwiftType = VoiceOrb - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> StreamSpeed { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> VoiceOrb { let variant: Int32 = try readInt(&buf) switch variant { - case 1: return .realistic + case 1: return .idle - case 2: return .fast + case 2: return .connecting + + case 3: return .listening + + case 4: return .speaking + + case 5: return .working + + case 6: return .awaitingInput + + case 7: return .muted default: throw UniffiInternalError.unexpectedEnumCase } } - public static func write(_ value: StreamSpeed, into buf: inout [UInt8]) { + public static func write(_ value: VoiceOrb, into buf: inout [UInt8]) { switch value { - case .realistic: + case .idle: writeInt(&buf, Int32(1)) - case .fast: + case .connecting: writeInt(&buf, Int32(2)) + + case .listening: + writeInt(&buf, Int32(3)) + + + case .speaking: + writeInt(&buf, Int32(4)) + + + case .working: + writeInt(&buf, Int32(5)) + + + case .awaitingInput: + writeInt(&buf, Int32(6)) + + + case .muted: + writeInt(&buf, Int32(7)) + } } } @@ -10895,33 +12955,24 @@ public struct FfiConverterTypeStreamSpeed: FfiConverterRustBuffer { #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeStreamSpeed_lift(_ buf: RustBuffer) throws -> StreamSpeed { - return try FfiConverterTypeStreamSpeed.lift(buf) +public func FfiConverterTypeVoiceOrb_lift(_ buf: RustBuffer) throws -> VoiceOrb { + return try FfiConverterTypeVoiceOrb.lift(buf) } #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeStreamSpeed_lower(_ value: StreamSpeed) -> RustBuffer { - return FfiConverterTypeStreamSpeed.lower(value) +public func FfiConverterTypeVoiceOrb_lower(_ value: VoiceOrb) -> RustBuffer { + return FfiConverterTypeVoiceOrb.lower(value) } -public enum TranscriptScale: Equatable, Hashable { +public enum VoiceSpeaker: Equatable, Hashable { - case normal - /** - * 120 synthetic turns. - */ - case big - /** - * 600 synthetic turns. - */ - case huge - case turns(count: UInt32 - ) + case user + case assistant @@ -10930,52 +12981,38 @@ public enum TranscriptScale: Equatable, Hashable { } #if compiler(>=6) -extension TranscriptScale: Sendable {} +extension VoiceSpeaker: Sendable {} #endif #if swift(>=5.8) @_documentation(visibility: private) #endif -public struct FfiConverterTypeTranscriptScale: FfiConverterRustBuffer { - typealias SwiftType = TranscriptScale +public struct FfiConverterTypeVoiceSpeaker: FfiConverterRustBuffer { + typealias SwiftType = VoiceSpeaker - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> TranscriptScale { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> VoiceSpeaker { let variant: Int32 = try readInt(&buf) switch variant { - case 1: return .normal - - case 2: return .big - - case 3: return .huge + case 1: return .user - case 4: return .turns(count: try FfiConverterUInt32.read(from: &buf) - ) + case 2: return .assistant default: throw UniffiInternalError.unexpectedEnumCase } } - public static func write(_ value: TranscriptScale, into buf: inout [UInt8]) { + public static func write(_ value: VoiceSpeaker, into buf: inout [UInt8]) { switch value { - case .normal: + case .user: writeInt(&buf, Int32(1)) - case .big: + case .assistant: writeInt(&buf, Int32(2)) - - case .huge: - writeInt(&buf, Int32(3)) - - - case let .turns(count): - writeInt(&buf, Int32(4)) - FfiConverterUInt32.write(count, into: &buf) - } } } @@ -10984,15 +13021,15 @@ public struct FfiConverterTypeTranscriptScale: FfiConverterRustBuffer { #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeTranscriptScale_lift(_ buf: RustBuffer) throws -> TranscriptScale { - return try FfiConverterTypeTranscriptScale.lift(buf) +public func FfiConverterTypeVoiceSpeaker_lift(_ buf: RustBuffer) throws -> VoiceSpeaker { + return try FfiConverterTypeVoiceSpeaker.lift(buf) } #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeTranscriptScale_lower(_ value: TranscriptScale) -> RustBuffer { - return FfiConverterTypeTranscriptScale.lower(value) +public func FfiConverterTypeVoiceSpeaker_lower(_ value: VoiceSpeaker) -> RustBuffer { + return FfiConverterTypeVoiceSpeaker.lower(value) } @@ -11856,6 +13893,78 @@ fileprivate struct FfiConverterOptionTypeSendState: FfiConverterRustBuffer { } } +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterOptionTypeVoiceEndReason: FfiConverterRustBuffer { + typealias SwiftType = VoiceEndReason? + + public static func write(_ value: SwiftType, into buf: inout [UInt8]) { + guard let value = value else { + writeInt(&buf, Int8(0)) + return + } + writeInt(&buf, Int8(1)) + FfiConverterTypeVoiceEndReason.write(value, into: &buf) + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SwiftType { + switch try readInt(&buf) as Int8 { + case 0: return nil + case 1: return try FfiConverterTypeVoiceEndReason.read(from: &buf) + default: throw UniffiInternalError.unexpectedOptionalTag + } + } +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterOptionTypeVoiceMediaFailure: FfiConverterRustBuffer { + typealias SwiftType = VoiceMediaFailure? + + public static func write(_ value: SwiftType, into buf: inout [UInt8]) { + guard let value = value else { + writeInt(&buf, Int8(0)) + return + } + writeInt(&buf, Int8(1)) + FfiConverterTypeVoiceMediaFailure.write(value, into: &buf) + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SwiftType { + switch try readInt(&buf) as Int8 { + case 0: return nil + case 1: return try FfiConverterTypeVoiceMediaFailure.read(from: &buf) + default: throw UniffiInternalError.unexpectedOptionalTag + } + } +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterOptionTypeVoiceSpeaker: FfiConverterRustBuffer { + typealias SwiftType = VoiceSpeaker? + + public static func write(_ value: SwiftType, into buf: inout [UInt8]) { + guard let value = value else { + writeInt(&buf, Int8(0)) + return + } + writeInt(&buf, Int8(1)) + FfiConverterTypeVoiceSpeaker.write(value, into: &buf) + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SwiftType { + switch try readInt(&buf) as Int8 { + case 0: return nil + case 1: return try FfiConverterTypeVoiceSpeaker.read(from: &buf) + default: throw UniffiInternalError.unexpectedOptionalTag + } + } +} + #if swift(>=5.8) @_documentation(visibility: private) #endif @@ -11981,6 +14090,31 @@ fileprivate struct FfiConverterSequenceTypeBoxPrim: FfiConverterRustBuffer { } } +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterSequenceTypeCaptionSpan: FfiConverterRustBuffer { + typealias SwiftType = [CaptionSpan] + + public static func write(_ value: [CaptionSpan], into buf: inout [UInt8]) { + let len = Int32(value.count) + writeInt(&buf, len) + for item in value { + FfiConverterTypeCaptionSpan.write(item, into: &buf) + } + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> [CaptionSpan] { + let len: Int32 = try readInt(&buf) + var seq = [CaptionSpan]() + seq.reserveCapacity(Int(len)) + for _ in 0 ..< len { + seq.append(try FfiConverterTypeCaptionSpan.read(from: &buf)) + } + return seq + } +} + #if swift(>=5.8) @_documentation(visibility: private) #endif @@ -12955,6 +15089,26 @@ public func fileMentionLink(path: String, isDir: Bool) -> String { ) }) } +/** + * Codex voice styles to offer before a host reports its own. + */ +public func defaultVoiceStyles() -> [String] { + return try! FfiConverterSequenceString.lift(try! rustCall() { + uniffiCallStatus in + uniffi_zeron_mobile_fn_func_default_voice_styles(uniffiCallStatus + ) +}) +} +/** + * Hosts advertising this capability accept client-media voice calls. + */ +public func voiceHostCapability() -> String { + return try! FfiConverterString.lift(try! rustCall() { + uniffiCallStatus in + uniffi_zeron_mobile_fn_func_voice_host_capability(uniffiCallStatus + ) +}) +} /** * Rust's line breaks for `text` in one face/size at `width`, as UTF-16 * offsets of each line start — the accuracy harness compares these with @@ -13096,6 +15250,12 @@ private let initializationResult: InitializationResult = { if (uniffi_zeron_mobile_checksum_func_file_mention_link() != 14340) { return InitializationResult.apiChecksumMismatch } + if (uniffi_zeron_mobile_checksum_func_default_voice_styles() != 30650) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_zeron_mobile_checksum_func_voice_host_capability() != 32424) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_zeron_mobile_checksum_func_debug_line_starts() != 43822) { return InitializationResult.apiChecksumMismatch } @@ -13108,6 +15268,9 @@ private let initializationResult: InitializationResult = { if (uniffi_zeron_mobile_checksum_func_wallpaper_safe_opacity() != 57056) { return InitializationResult.apiChecksumMismatch } + if (uniffi_zeron_mobile_checksum_method_captionfader_frame() != 19899) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_zeron_mobile_checksum_method_clientlistener_on_event() != 55106) { return InitializationResult.apiChecksumMismatch } @@ -13285,6 +15448,9 @@ private let initializationResult: InitializationResult = { if (uniffi_zeron_mobile_checksum_method_coreclient_workspace_revision() != 48105) { return InitializationResult.apiChecksumMismatch } + if (uniffi_zeron_mobile_checksum_method_coreclient_start_voice() != 62086) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_zeron_mobile_checksum_method_uploadprogress_on_progress() != 40458) { return InitializationResult.apiChecksumMismatch } @@ -13348,6 +15514,24 @@ private let initializationResult: InitializationResult = { if (uniffi_zeron_mobile_checksum_method_sessionhandle_transcript_status() != 12910) { return InitializationResult.apiChecksumMismatch } + if (uniffi_zeron_mobile_checksum_method_voicecall_complete_media() != 130) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_zeron_mobile_checksum_method_voicecall_set_muted() != 41942) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_zeron_mobile_checksum_method_voicecall_stop() != 33696) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_zeron_mobile_checksum_method_voicemedialistener_on_request() != 10473) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_zeron_mobile_checksum_method_voicesessionlistener_on_voice_state() != 7593) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_zeron_mobile_checksum_method_voicesessionlistener_on_voice_closed() != 14100) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_zeron_mobile_checksum_method_layoutframe_build_micros() != 17231) { return InitializationResult.apiChecksumMismatch } @@ -13423,6 +15607,18 @@ private let initializationResult: InitializationResult = { if (uniffi_zeron_mobile_checksum_method_transcriptview_toggle_detail() != 17098) { return InitializationResult.apiChecksumMismatch } + if (uniffi_zeron_mobile_checksum_method_orbrenderer_next_frame() != 6064) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_zeron_mobile_checksum_method_orbrenderer_set_audio_levels() != 53067) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_zeron_mobile_checksum_method_orbrenderer_set_orb() != 18211) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_zeron_mobile_checksum_constructor_captionfader_new() != 15591) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_zeron_mobile_checksum_constructor_coreclient_new() != 27504) { return InitializationResult.apiChecksumMismatch } @@ -13432,11 +15628,16 @@ private let initializationResult: InitializationResult = { if (uniffi_zeron_mobile_checksum_constructor_transcriptview_new() != 26914) { return InitializationResult.apiChecksumMismatch } + if (uniffi_zeron_mobile_checksum_constructor_orbrenderer_new() != 46989) { + return InitializationResult.apiChecksumMismatch + } uniffiCallbackInitClientListener() uniffiCallbackInitLayoutListener() uniffiCallbackInitPlatformMeasurer() uniffiCallbackInitUploadProgress() + uniffiCallbackInitVoiceMediaListener() + uniffiCallbackInitVoiceSessionListener() return InitializationResult.ok }() diff --git a/apps/ios/Zeron/Info.plist b/apps/ios/Zeron/Info.plist index f0ab6a5d76..886741d917 100644 --- a/apps/ios/Zeron/Info.plist +++ b/apps/ios/Zeron/Info.plist @@ -52,6 +52,14 @@ NSPhotoLibraryUsageDescription Attach photos to messages. + NSMicrophoneUsageDescription + Voice calls you start stream your audio to OpenAI through the ChatGPT account signed in to Codex on your host. + UIBackgroundModes + + audio + + NSSupportsLiveActivities + NSCameraUsageDescription Take photos to attach to messages. UILaunchScreen diff --git a/apps/ios/Zeron/Session/CoreSessionSource.swift b/apps/ios/Zeron/Session/CoreSessionSource.swift index 609c10fc79..3e96d3c5c9 100644 --- a/apps/ios/Zeron/Session/CoreSessionSource.swift +++ b/apps/ios/Zeron/Session/CoreSessionSource.swift @@ -78,6 +78,8 @@ final class CoreSessionSource: SessionSource { next.banner = .notDelivered } else if c.sendState == .queued { next.banner = .failed("\(c.host.name ?? "Host") is offline — will send when it's back") + } else if c.sendState == .sending { + next.banner = .sending } else if app?.connectivity?.state == .offline { next.banner = .offline } else if !c.room.connected, let retry = c.room.retryAtMs { @@ -111,6 +113,7 @@ final class CoreSessionSource: SessionSource { do { if mode == .interrupt, chrome.running { try handle.interrupt() } _ = try handle.send(request: SendRequest(text: text, attachments: images.map(\.outgoing), worktree: nil, busy: mode == .steer ? .steer : .queue)) + app?.noteDeliveryActivity() sendFailure = nil return true } catch { @@ -247,6 +250,15 @@ final class CoreSessionSource: SessionSource { private static let images = NSCache() + func image(_ reference: String) async -> UIImage? { + if let hit = Self.images.object(forKey: reference as NSString) { return hit } + guard let data = try? await client.readAttachment(deviceId: hostDevice, path: reference), + let image = await UIImage(data: data)?.byPreparingForDisplay() + else { return nil } + Self.images.setObject(image, forKey: reference as NSString) + return image + } + func loadImage(_ reference: String, into view: UIImageView) { // Claim the view first: a slower load for a row it used to show // must not land on top of this one. diff --git a/apps/ios/Zeron/Session/NewSessionViewController.swift b/apps/ios/Zeron/Session/NewSessionViewController.swift index c306b4c1b3..809575e882 100644 --- a/apps/ios/Zeron/Session/NewSessionViewController.swift +++ b/apps/ios/Zeron/Session/NewSessionViewController.swift @@ -16,11 +16,16 @@ struct NewSessionDraft: Equatable, Codable { struct ProjectOption: Equatable { let id: String let name: String + let path: String let device: String /// Display name of `device` (never show the id). let deviceName: String let online: Bool let git: Bool + /// Shared by every checkout of one repository, on any device; with + /// `groupName` and `colorIndex` they read as one project. + let groupKey: String + let groupName: String let colorIndex: Int } @@ -231,10 +236,20 @@ final class NewSessionViewController: UIViewController, UIGestureRecognizerDeleg private var project: ProjectOption? { app.projectOptions.first { $0.id == draft.projectId } } private var deviceId: String { project?.device ?? draft.hostId ?? "" } + /// Checkouts of the picked project, one per row of the host menu. + private var checkouts: [ProjectOption] { + guard let p = project else { return [] } + return app.projectOptions + .filter { $0.groupKey == p.groupKey } + .sorted { ($0.deviceName.lowercased(), $0.path) < ($1.deviceName.lowercased(), $1.path) } + } + private func refreshChips() { var chips: [ComposerChip] = [] if let p = project { - chips.append(ComposerChip(id: "project", title: p.name, symbol: nil, icon: ProjectTile.image(name: p.name, colorIndex: p.colorIndex))) + // The project, then the host it runs on. + chips.append(ComposerChip(id: "project", title: p.groupName, symbol: nil, icon: ProjectTile.image(name: p.groupName, colorIndex: p.colorIndex))) + chips.append(ComposerChip(id: "host", title: p.deviceName, symbol: "desktopcomputer")) if p.git { chips.append(ComposerChip(id: "branch", title: draft.worktree ? "New worktree" : (draft.branch ?? "Current branch"), symbol: nil, icon: BranchIcon.sized())) } @@ -262,20 +277,27 @@ final class NewSessionViewController: UIViewController, UIGestureRecognizerDeleg mark.image = BrandMarks.image(for: draft.harness, side: 34) } + /// One entry per project — checkouts of one repository, on any device, + /// are one entry listing their hosts; the host chip then picks which. private func projectMenu() -> UIMenu { - let byDevice = Dictionary(grouping: app.projectOptions, by: \.device) - var sections: [UIMenuElement] = byDevice.keys.sorted { (byDevice[$0]?.first?.deviceName ?? "") < (byDevice[$1]?.first?.deviceName ?? "") }.map { device in - let items = byDevice[device]! - let name = items.first?.deviceName ?? "Host" - return UIMenu(title: name + (items.first?.online == false ? " · offline" : ""), options: .displayInline, children: items.map { p in - UIAction(title: p.name, image: UIImage(systemName: p.git ? "folder.badge.gearshape" : "folder"), state: p.id == draft.projectId ? .on : .off) { [weak self] _ in - self?.draft.projectId = p.id - self?.draft.hostId = nil - self?.draft.branch = nil - self?.loadModels() - } - }) + var groups: [[ProjectOption]] = [] + for p in app.projectOptions { + if let i = groups.firstIndex(where: { $0[0].groupKey == p.groupKey }) { + groups[i].append(p) + } else { + groups.append([p]) + } } + groups.sort { $0[0].groupName.localizedCaseInsensitiveCompare($1[0].groupName) == .orderedAscending } + let picked = project?.groupKey + var sections: [UIMenuElement] = [UIMenu(options: .displayInline, children: groups.map { members in + let first = members[0] + var hosts: [String] = [] + for m in members where !hosts.contains(m.deviceName) { hosts.append(m.deviceName) } + return UIAction(title: first.groupName, subtitle: hosts.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending }.joined(separator: ", "), image: ProjectTile.image(name: first.groupName, colorIndex: first.colorIndex, side: 18), state: first.groupKey == picked ? .on : .off) { [weak self] _ in + self?.pickProject(members) + } + })] sections.append(UIMenu(options: .displayInline, children: [ UIAction(title: "No Project…", image: UIImage(systemName: "tray"), state: draft.projectId == nil ? .on : .off) { [weak self] _ in guard let self else { return } @@ -298,8 +320,37 @@ final class NewSessionViewController: UIViewController, UIGestureRecognizerDeleg return UIMenu(title: "Project", children: sections) } + /// Keep the host when it has a checkout of the project, else the first + /// reachable one. + private func pickProject(_ members: [ProjectOption]) { + let pick = members.first { $0.id == draft.projectId } + ?? members.first { $0.device == deviceId } + ?? members.first(where: \.online) + ?? members[0] + pickCheckout(pick) + } + + private func pickCheckout(_ p: ProjectOption) { + if p.id != draft.projectId { draft.branch = nil } + draft.projectId = p.id + draft.hostId = nil + loadModels() + } + + /// With a project: its checkouts, by host (the path tells apart several + /// on one host). Without: every host — project-less sessions run in its + /// home. private func hostMenu() -> UIMenu { - UIMenu(title: "Run on", children: app.hostOptions.map { h in + let checkouts = self.checkouts + if !checkouts.isEmpty { + return UIMenu(title: "Run on", children: checkouts.map { p in + let shared = checkouts.filter { $0.device == p.device }.count > 1 + return UIAction(title: p.deviceName, subtitle: shared ? p.path : (p.online ? "Online" : "Offline"), image: UIImage(systemName: "desktopcomputer"), state: p.id == draft.projectId ? .on : .off) { [weak self] _ in + self?.pickCheckout(p) + } + }) + } + return UIMenu(title: "Run on", children: app.hostOptions.map { h in UIAction(title: h.name, subtitle: h.online ? "Online" : "Offline", image: UIImage(systemName: "desktopcomputer"), state: h.id == draft.hostId ? .on : .off) { [weak self] _ in self?.draft.hostId = h.id self?.loadModels() diff --git a/apps/ios/Zeron/Session/SessionSource.swift b/apps/ios/Zeron/Session/SessionSource.swift index 687ea474b4..99b1077baa 100644 --- a/apps/ios/Zeron/Session/SessionSource.swift +++ b/apps/ios/Zeron/Session/SessionSource.swift @@ -6,6 +6,7 @@ struct SessionChrome: Equatable { enum Banner: Equatable { case none case working(since: Date?, word: String) + case sending case offline case reconnecting(in: Int) case notDelivered @@ -76,6 +77,8 @@ protocol SessionSource: AnyObject { /// Menu for a composer chip (model, effort, branch…), or nil. func chipMenu(_ id: String) -> UIMenu? func loadImage(_ reference: String, into view: UIImageView) + /// An attachment's picture, for opening it full size (an image chip). + func image(_ reference: String) async -> UIImage? /// Workspace files for `@` mentions. func searchFiles(_ query: String) async -> [FileMatch] } @@ -227,5 +230,6 @@ final class FixtureSessionSource: SessionSource { } } func loadImage(_ reference: String, into view: UIImageView) {} + func image(_ reference: String) async -> UIImage? { nil } func searchFiles(_ query: String) async -> [FileMatch] { [] } } diff --git a/apps/ios/Zeron/Session/SessionViewController.swift b/apps/ios/Zeron/Session/SessionViewController.swift index 504938fc00..2958dd82b6 100644 --- a/apps/ios/Zeron/Session/SessionViewController.swift +++ b/apps/ios/Zeron/Session/SessionViewController.swift @@ -20,10 +20,11 @@ final class SessionViewController: UIViewController, UIGestureRecognizerDelegate private let openedAt = CACurrentMediaTime() private var reportedOpen = false - init(app: AppModel, chatId: String) { + init?(app: AppModel, chatId: String) { + guard let source = app.sessionSource(chatId) else { return nil } self.app = app self.chatId = chatId - self.source = app.sessionSource(chatId) + self.source = source super.init(nibName: nil, bundle: nil) hidesBottomBarWhenPushed = true } @@ -40,11 +41,27 @@ final class SessionViewController: UIViewController, UIGestureRecognizerDelegate navigationItem.largeTitleDisplayMode = .never navigationItem.titleView = titleView navigationItem.rightBarButtonItem = UIBarButtonItem(image: UIImage(systemName: "ellipsis"), menu: sessionMenu()) + // The session has its own composer, so the tab bar's voice control + // steps aside with the accessory; the call stays a tap away up here. + let voiceBar = VoiceBarItem(app: app, host: self) + self.voiceBar = voiceBar + let menu = navigationItem.rightBarButtonItem! + let syncVoice = { [weak self, weak voiceBar] in + guard let self, let voiceBar else { return } + let items = voiceBar.visible ? [menu, voiceBar.item] : [menu] + if self.navigationItem.rightBarButtonItems?.count != items.count { + self.navigationItem.setRightBarButtonItems(items, animated: self.view.window != nil) + } + } + voiceToken = app.voice.observe(syncVoice) + hostsToken = app.observe(syncVoice) + syncVoice() list.frame = view.bounds list.autoresizingMask = [.flexibleWidth, .flexibleHeight] list.accessibilityIdentifier = "transcript" list.imageLoader = { [weak self] ref, iv in self?.source.loadImage(ref, into: iv) } + list.imageFetcher = { [weak self] ref in await self?.source.image(ref) } // Hidden while following (the runway glide and tail spring travel). list.onDistanceFromBottom = { [weak self] d in guard let self else { return } @@ -163,6 +180,9 @@ final class SessionViewController: UIViewController, UIGestureRecognizerDelegate private var hasAppeared = false private var backgroundObserver: NSObjectProtocol? + private var voiceBar: VoiceBarItem? + private var voiceToken: AnyObject? + private var hostsToken: AnyObject? deinit { if let backgroundObserver { NotificationCenter.default.removeObserver(backgroundObserver) } @@ -614,6 +634,9 @@ final class StatusPill: UIControl { } render() timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in render() } + case .sending: + grid.kind = .trailer + label.text = "Sending to host…" case .offline: grid.kind = .dot(StatusTone.idle) label.text = "Offline — sends are saved" diff --git a/apps/ios/Zeron/Shell/MainTabController.swift b/apps/ios/Zeron/Shell/MainTabController.swift index 1d33a51f4c..eda05c2c03 100644 --- a/apps/ios/Zeron/Shell/MainTabController.swift +++ b/apps/ios/Zeron/Shell/MainTabController.swift @@ -40,6 +40,25 @@ final class MainTabController: UITabBarController, UITabBarControllerDelegate, A liveToken = app.observe { [weak self] in guard let self else { return } self.accessoryContent.update(self.app.live) + // Hosts come and go with the workspace (online, capabilities). + self.accessoryContent.updateVoice(self.app.voice) + } + let voice = accessoryContent.voice + voice.onStart = { [weak self] source in + guard let self else { return } + self.openVoice(app: self.app, source: source) + } + voice.onOpen = { [weak self] source in + guard let self else { return } + self.presentVoiceStage(app: self.app, source: source) + } + voice.onMute = { [weak self] in self?.app.voice.toggleMute() } + voice.onEnd = { [weak self] in self?.app.voice.stop() } + voice.startButton.menu = voiceMenu(app: app) + accessoryContent.updateVoice(app.voice) + voiceToken = app.voice.observe { [weak self] in + guard let self else { return } + self.accessoryContent.updateVoice(self.app.voice) } } @@ -63,6 +82,7 @@ final class MainTabController: UITabBarController, UITabBarControllerDelegate, A private lazy var accessoryContent = AskAnythingAccessory { [weak self] in self?.presentNewSession() } private lazy var accessory = UITabAccessory(contentView: accessoryContent) private var liveToken: AnyObject? + private var voiceToken: AnyObject? /// Accessory state from what's on screen: hidden over a session (it has /// its own composer). @@ -144,7 +164,9 @@ final class MainTabController: UITabBarController, UITabBarControllerDelegate, A else { return openSession(chatId) } selectedTab = tab guard let nav = tab.viewController as? UINavigationController else { return openSession(chatId) } - let session = SessionViewController(app: app, chatId: chatId) + guard let session = SessionViewController(app: app, chatId: chatId) else { + return showSessionOpenError() + } UIView.performWithoutAnimation { nav.popToRootViewController(animated: false) nav.pushViewController(session, animated: false) @@ -175,36 +197,58 @@ final class MainTabController: UITabBarController, UITabBarControllerDelegate, A nav.popToRootViewController(animated: false) } + func showSessionOpenError() { + if presentedViewController != nil { dismiss(animated: false) } + view.endEditing(true) + selectedTab = tabs.first { $0.identifier == "sessions" } + popToFrontPage() + syncAccessory() + view.layoutIfNeeded() + Toast.show("Couldn't open session. Try again.", in: view.window) + } + /// Push a session on the Sessions tab (from new-session, deep links, search). func openSession(_ chatId: String) { - if presentedViewController != nil { dismiss(animated: true) } guard let tab = tabs.first(where: { $0.identifier == "sessions" }) else { return } selectedTab = tab guard let nav = tab.viewController as? UINavigationController else { return } + guard let session = SessionViewController(app: app, chatId: chatId) else { + return showSessionOpenError() + } + if presentedViewController != nil { dismiss(animated: true) } nav.popToRootViewController(animated: false) - nav.pushViewController(SessionViewController(app: app, chatId: chatId), animated: true) + nav.pushViewController(session, animated: true) } } /// The capsule above the tab bar: a plus, "New session", and a live -/// summary of what's running ("2 working · 1 needs you"). Tapping it opens the -/// new-session composer. -final class AskAnythingAccessory: UIControl { - private let onTap: () -> Void +/// summary of what's running ("2 working · 1 needs you"), with voice at its +/// trailing end. Tapping the plus side opens the new-session composer; the +/// waveform calls Codex, and during a call the strip shows it (orb, clock, +/// mute, hang-up) and opens the call stage. +final class AskAnythingAccessory: UIView { + private let newSession = PressableControl() private let label = UILabel() private let summary = UILabel() private let cells = StatusGlyph() private let mark = UIImageView(image: UIImage(systemName: "plus", withConfiguration: UIImage.SymbolConfiguration(pointSize: 15, weight: .semibold))) + private let plate = UIView() + let voice = VoiceAccessoryView() + private var voiceShown = false + private var voiceLive = false + private var restConstraints: [NSLayoutConstraint] = [] + private var idleVoiceConstraints: [NSLayoutConstraint] = [] + private var liveVoiceConstraints: [NSLayoutConstraint] = [] init(onTap: @escaping () -> Void) { - self.onTap = onTap super.init(frame: .zero) - accessibilityIdentifier = "new-session" - accessibilityTraits = .button + newSession.accessibilityIdentifier = "new-session" + newSession.accessibilityTraits = .button + newSession.isAccessibilityElement = true + newSession.addAction(UIAction { _ in onTap() }, for: .touchUpInside) mark.tintColor = Palette.accent mark.contentMode = .center - let plate = UIView() plate.backgroundColor = Palette.accentSoft // A circle, concentric with the accessory capsule (7pt inset). plate.layer.cornerRadius = 17 @@ -218,31 +262,56 @@ final class AskAnythingAccessory: UIControl { summary.textColor = Palette.secondary summary.textAlignment = .right cells.isHidden = true - for v in [plate, label, mark, summary, cells] as [UIView] { v.translatesAutoresizingMaskIntoConstraints = false } - for v in [plate, label, summary, cells] as [UIView] { addSubview(v) } + for v in [newSession, plate, label, mark, summary, cells, voice] as [UIView] { v.translatesAutoresizingMaskIntoConstraints = false } + for v in [plate, label, summary, cells] as [UIView] { newSession.addSubview(v) } + addSubview(newSession) + addSubview(voice) + voice.isHidden = true summary.setContentCompressionResistancePriority(.required, for: .horizontal) NSLayoutConstraint.activate([ - plate.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 7), - plate.centerYAnchor.constraint(equalTo: centerYAnchor), + newSession.leadingAnchor.constraint(equalTo: leadingAnchor), + newSession.topAnchor.constraint(equalTo: topAnchor), + newSession.bottomAnchor.constraint(equalTo: bottomAnchor), + plate.leadingAnchor.constraint(equalTo: newSession.leadingAnchor, constant: 7), + plate.centerYAnchor.constraint(equalTo: newSession.centerYAnchor), plate.widthAnchor.constraint(equalToConstant: 34), plate.heightAnchor.constraint(equalToConstant: 34), mark.centerXAnchor.constraint(equalTo: plate.centerXAnchor), mark.centerYAnchor.constraint(equalTo: plate.centerYAnchor), label.leadingAnchor.constraint(equalTo: plate.trailingAnchor, constant: 11), - label.centerYAnchor.constraint(equalTo: centerYAnchor), - label.trailingAnchor.constraint(lessThanOrEqualTo: cells.leadingAnchor, constant: -10), - summary.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16), - summary.centerYAnchor.constraint(equalTo: centerYAnchor), + label.centerYAnchor.constraint(equalTo: newSession.centerYAnchor), + summary.trailingAnchor.constraint(equalTo: newSession.trailingAnchor, constant: -16), + summary.centerYAnchor.constraint(equalTo: newSession.centerYAnchor), cells.trailingAnchor.constraint(equalTo: summary.leadingAnchor, constant: -7), - cells.centerYAnchor.constraint(equalTo: centerYAnchor), + cells.centerYAnchor.constraint(equalTo: newSession.centerYAnchor), cells.widthAnchor.constraint(equalToConstant: 12), cells.heightAnchor.constraint(equalToConstant: 12), + voice.topAnchor.constraint(equalTo: topAnchor), + voice.bottomAnchor.constraint(equalTo: bottomAnchor), + voice.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -7), ]) - addAction(UIAction { [weak self] _ in self?.onTap() }, for: .touchUpInside) + // Yields while the call strip narrows this side to the plus. + let labelEnd = label.trailingAnchor.constraint(lessThanOrEqualTo: cells.leadingAnchor, constant: -10) + labelEnd.priority = .defaultHigh + labelEnd.isActive = true + // No voice: "New session" spans the capsule. + restConstraints = [newSession.trailingAnchor.constraint(equalTo: trailingAnchor)] + // Idle voice: a 34pt waveform at the trailing end. + idleVoiceConstraints = [ + voice.widthAnchor.constraint(equalToConstant: 34), + newSession.trailingAnchor.constraint(equalTo: voice.leadingAnchor, constant: -2), + ] + // Live: the call strip takes everything after the plus. + liveVoiceConstraints = [ + newSession.trailingAnchor.constraint(equalTo: plate.trailingAnchor, constant: 4), + voice.leadingAnchor.constraint(equalTo: plate.trailingAnchor, constant: 12), + ] + NSLayoutConstraint.activate(restConstraints) registerForTraitChanges([UITraitTabAccessoryEnvironment.self]) { (self: AskAnythingAccessory, _) in // Inline (minimized tab bar): the plus and the live summary only. let inline = self.traitCollection.tabAccessoryEnvironment == .inline - self.label.alpha = inline ? 0 : 1 + self.label.alpha = inline || self.voiceLive ? 0 : 1 + self.voice.setInline(inline) } update(AppModel.LiveCounts()) } @@ -254,12 +323,34 @@ final class AskAnythingAccessory: UIControl { if live.working > 0 { parts.append("\(live.working) working") } if live.awaiting > 0 { parts.append("\(live.awaiting) need\(live.awaiting == 1 ? "s" : "") you") } summary.text = parts.joined(separator: " · ") - cells.isHidden = parts.isEmpty + cells.isHidden = parts.isEmpty || voiceLive + summary.isHidden = voiceLive cells.kind = live.working > 0 ? .spinner : .dot(StatusTone.input) - accessibilityLabel = parts.isEmpty ? "New session" : "New session, " + parts.joined(separator: ", ") + newSession.accessibilityLabel = parts.isEmpty ? "New session" : "New session, " + parts.joined(separator: ", ") } - override var isHighlighted: Bool { - didSet { UIView.animate(withDuration: 0.15) { self.alpha = self.isHighlighted ? 0.6 : 1 } } + /// Show voice when a host can run it, and the call strip while one runs. + func updateVoice(_ controller: RemoteVoiceController) { + let shown = controller.live || controller.available + let live = controller.live + voice.update(controller) + guard shown != voiceShown || live != voiceLive else { return } + voiceShown = shown + voiceLive = live + NSLayoutConstraint.deactivate(restConstraints + idleVoiceConstraints + liveVoiceConstraints) + NSLayoutConstraint.activate(!shown ? restConstraints : live ? liveVoiceConstraints : idleVoiceConstraints) + voice.isHidden = !shown + let inline = traitCollection.tabAccessoryEnvironment == .inline + let apply = { + self.label.alpha = inline || live ? 0 : 1 + self.summary.isHidden = live + self.cells.isHidden = live || self.summary.text?.isEmpty != false + self.layoutIfNeeded() + } + if window == nil || UIAccessibility.isReduceMotionEnabled { + apply() + } else { + UIView.animate(withDuration: 0.35, delay: 0, usingSpringWithDamping: 0.9, initialSpringVelocity: 0, options: [.beginFromCurrentState, .allowUserInteraction], animations: apply) + } } } diff --git a/apps/ios/Zeron/Shell/MoreViewController.swift b/apps/ios/Zeron/Shell/MoreViewController.swift index d3310dba97..c98ecfd974 100644 --- a/apps/ios/Zeron/Shell/MoreViewController.swift +++ b/apps/ios/Zeron/Shell/MoreViewController.swift @@ -184,6 +184,10 @@ final class MoreViewController: UIViewController, UICollectionViewDelegate { ]) s.appendSections(["Devices"]) s.appendItems(app.hostOptions.map { Row(id: "device:\($0.id)", title: $0.name, subtitle: $0.online ? "Online" : "Offline", symbol: "desktopcomputer", accessory: .dot($0.online)) }) + if app.voice.available { + s.appendSections(["Voice"]) + s.appendItems([Row(id: "voice", title: "Voice", subtitle: app.voice.selectedStyle.map { "Codex voice · \($0.capitalized)" } ?? "Codex voice device and style", symbol: "waveform")]) + } s.appendSections(["Notifications"]) s.appendItems(notificationRows()) s.appendSections(["Appearance"]) @@ -212,6 +216,7 @@ final class MoreViewController: UIViewController, UICollectionViewDelegate { collectionView.deselectItem(at: path, animated: true) guard let row = dataSource.itemIdentifier(for: path) else { return } switch row.id { + case "voice": navigationController?.pushViewController(VoiceViewController(app: app), animated: true) case let id where id.hasPrefix("appearance:"): let style = Int(id.dropFirst(11)) ?? 0 UserDefaults.standard.set(style, forKey: "appearance") diff --git a/apps/ios/Zeron/Shell/SplitRootController.swift b/apps/ios/Zeron/Shell/SplitRootController.swift index dce09b4f3b..bbbc3b7787 100644 --- a/apps/ios/Zeron/Shell/SplitRootController.swift +++ b/apps/ios/Zeron/Shell/SplitRootController.swift @@ -72,9 +72,21 @@ final class SplitRootController: UISplitViewController, UISplitViewControllerDel if presentedViewController != nil { dismiss(animated: true) } guard !isCollapsed else { return tabs.openSession(chatId) } if currentChatId == chatId, detail.viewControllers.first is SessionViewController { return } + guard let session = SessionViewController(app: app, chatId: chatId) else { + return showSessionOpenError() + } currentChatId = chatId sidebar.currentChatId = chatId - detail.setViewControllers([SessionViewController(app: app, chatId: chatId)], animated: false) + detail.setViewControllers([session], animated: false) + } + + private func showSessionOpenError() { + if presentedViewController != nil { dismiss(animated: false) } + view.endEditing(true) + showDraft(prompt: nil, focus: false) + sidebar.showSessions() + show(.primary) + Toast.show("Couldn't open session. Try again.", in: view.window) } func presentNewSession(prompt: String?) { @@ -94,9 +106,11 @@ final class SplitRootController: UISplitViewController, UISplitViewControllerDel } // In place: the chat replaces the draft in the column and the // handoff carries the composer and message across. + guard let session = SessionViewController(app: self.app, chatId: chatId) else { + return self.showSessionOpenError() + } self.currentChatId = chatId self.sidebar.currentChatId = chatId - let session = SessionViewController(app: self.app, chatId: chatId) UIView.performWithoutAnimation { self.detail.setViewControllers([session], animated: false) self.view.layoutIfNeeded() @@ -163,7 +177,10 @@ final class SplitRootController: UISplitViewController, UISplitViewControllerDel let shown = (self.detail.viewControllers.first as? SessionViewController)?.chatId if let chatId = current { if shown != chatId { - self.detail.setViewControllers([SessionViewController(app: self.app, chatId: chatId)], animated: false) + guard let session = SessionViewController(app: self.app, chatId: chatId) else { + return self.showSessionOpenError() + } + self.detail.setViewControllers([session], animated: false) } self.sidebar.currentChatId = chatId } else if self.detail.viewControllers.isEmpty { @@ -183,6 +200,9 @@ final class SidebarViewController: UIViewController, UISearchResultsUpdating { private let list: SessionsViewController private let nav: UINavigationController private let search = UISearchController(searchResultsController: nil) + private var voiceBar: VoiceBarItem? + private var voiceToken: AnyObject? + private var workspaceToken: AnyObject? var currentChatId: String? { get { list.currentChatId } @@ -233,6 +253,19 @@ final class SidebarViewController: UIViewController, UISearchResultsUpdating { list.navigationItem.rightBarButtonItem = nil list.toolbarItems = [settings, options, .flexibleSpace(), compose] nav.isToolbarHidden = false + // Voice beside compose, once a host can run it. + let voiceBar = VoiceBarItem(app: app, host: self) + self.voiceBar = voiceBar + let syncVoice = { [weak self, weak voiceBar] in + guard let self, let voiceBar else { return } + let items = voiceBar.visible ? [settings, options, .flexibleSpace(), voiceBar.item, compose] : [settings, options, .flexibleSpace(), compose] + if self.list.toolbarItems?.count != items.count { + self.list.setToolbarItems(items, animated: self.view.window != nil) + } + } + voiceToken = app.voice.observe(syncVoice) + workspaceToken = app.observe(syncVoice) + syncVoice() // A hairline between the sidebar and the main column. let edge = UIView() @@ -255,6 +288,13 @@ final class SidebarViewController: UIViewController, UISearchResultsUpdating { nav.popToRootViewController(animated: false) search.searchBar.becomeFirstResponder() } + + func showSessions() { + search.isActive = false + search.searchBar.text = "" + list.query = "" + nav.popToRootViewController(animated: false) + } } extension UIViewController { diff --git a/apps/ios/Zeron/Threads/SessionListController.swift b/apps/ios/Zeron/Threads/SessionListController.swift index 7593a3a29f..b624d5c9b7 100644 --- a/apps/ios/Zeron/Threads/SessionListController.swift +++ b/apps/ios/Zeron/Threads/SessionListController.swift @@ -206,7 +206,11 @@ class SessionListController: UIViewController, UICollectionViewDelegate { split.openSession(id) return } - navigationController?.pushViewController(SessionViewController(app: app, chatId: id), animated: true) + guard let session = SessionViewController(app: app, chatId: id) else { + (tabBarController as? MainTabController)?.showSessionOpenError() + return + } + navigationController?.pushViewController(session, animated: true) } func openFolder(_ id: String) { diff --git a/apps/ios/Zeron/Transcript/TranscriptListView.swift b/apps/ios/Zeron/Transcript/TranscriptListView.swift index a4aa052855..514b7d8244 100644 --- a/apps/ios/Zeron/Transcript/TranscriptListView.swift +++ b/apps/ios/Zeron/Transcript/TranscriptListView.swift @@ -38,6 +38,8 @@ final class TranscriptListView: UIScrollView, RowViewDelegate, UIScrollViewDeleg var onFollowChange: ((Bool) -> Void)? var onDistanceFromBottom: ((CGFloat) -> Void)? var imageLoader: ((String, UIImageView) -> Void)? + /// An attachment's picture, for an image chip opened full size. + var imageFetcher: ((String) async -> UIImage?)? // MARK: Runway (desktop transcript.rs `OwnTurnAnchor`) // @@ -571,6 +573,17 @@ final class TranscriptListView: UIScrollView, RowViewDelegate, UIScrollViewDeleg func rowView(_ view: RowView, open url: URL) { guard let vc = findViewController() else { return } + // An image chip opens its upload full size, as its thumbnail did. + if url.scheme == "zeron-preview" { + guard let reference = URLComponents(url: url, resolvingAgainstBaseURL: false)? + .queryItems?.first(where: { $0.name == "ref" })?.value + else { return } + Task { @MainActor [weak self, weak view, weak vc] in + guard let image = await self?.imageFetcher?(reference), let view, let vc else { return } + vc.present(ImageViewer(image: image, source: view), animated: true) + } + return + } if url.scheme == "http" || url.scheme == "https" { let safari = SFSafariViewController(url: url) safari.preferredControlTintColor = Palette.accent diff --git a/apps/ios/Zeron/Voice/CodexVoicePeer.swift b/apps/ios/Zeron/Voice/CodexVoicePeer.swift new file mode 100644 index 0000000000..49b4a8e8d8 --- /dev/null +++ b/apps/ios/Zeron/Voice/CodexVoicePeer.swift @@ -0,0 +1,230 @@ +import AVFoundation +import UIKit +import WebRTC + +/// Native audio endpoint. Credentials and tools stay on the execution host. +/// The ordered oai-events channel and gathered offer mirror the pinned helper. +/// +/// Audio behaves like a phone call: loudspeaker while the phone is in hand, +/// the earpiece while it is held to the ear, and whatever headset or car the +/// user connected otherwise. The call survives the screen locking (background +/// audio); an interruption such as an incoming call ends it. +@MainActor +final class CodexVoicePeer: NSObject { + enum Failure: Error { case closed, unavailable, permissionDenied, timeout } + private static let factory: RTCPeerConnectionFactory = { + RTCInitializeSSL() + return RTCPeerConnectionFactory() + }() + private var peer: RTCPeerConnection? + private var channel: RTCDataChannel? + private var track: RTCAudioTrack? + private var closed = false + private var active = false + private var nearEar = false + private var observers: [NSObjectProtocol] = [] + var onFailure: (() -> Void)? + + func prepare() async throws { + guard !closed else { throw Failure.closed } + guard await AVAudioApplication.requestRecordPermission() else { throw Failure.permissionDenied } + guard !closed, UIApplication.shared.applicationState != .background else { throw Failure.closed } + let audio = RTCAudioSession.sharedInstance() + audio.useManualAudio = true + audio.isAudioEnabled = false + let configuration = RTCAudioSessionConfiguration.webRTC() + configuration.category = AVAudioSession.Category.playAndRecord.rawValue + configuration.mode = AVAudioSession.Mode.voiceChat.rawValue + // No defaultToSpeaker: the route follows the proximity sensor instead. + configuration.categoryOptions = [.allowBluetoothHFP] + // WebRTC re-applies its *global* configuration when the audio unit + // starts; a session-only change would be overwritten mid-call. + RTCAudioSessionConfiguration.setWebRTC(configuration) + audio.lockForConfiguration() + defer { audio.unlockForConfiguration() } + try audio.setConfiguration(configuration) + let config = RTCConfiguration() + config.sdpSemantics = .unifiedPlan + config.bundlePolicy = .maxBundle + guard let connection = Self.factory.peerConnection(with: config, constraints: RTCMediaConstraints(mandatoryConstraints: nil, optionalConstraints: nil), delegate: self) else { throw Failure.unavailable } + peer = connection + let source = Self.factory.audioSource(with: RTCMediaConstraints(mandatoryConstraints: nil, optionalConstraints: nil)) + let track = Self.factory.audioTrack(with: source, trackId: "voice-audio") + track.isEnabled = false + self.track = track + connection.add(track, streamIds: ["voice"]) + let dataConfig = RTCDataChannelConfiguration() + dataConfig.isOrdered = true + channel = connection.dataChannel(forLabel: "oai-events", configuration: dataConfig) + guard channel != nil else { throw Failure.unavailable } + channel?.delegate = self + observers.append(NotificationCenter.default.addObserver(forName: AVAudioSession.interruptionNotification, object: nil, queue: .main) { [weak self] notification in + // A phone call, Siri or an alarm takes the audio: end, never resume. + guard (notification.userInfo?[AVAudioSessionInterruptionTypeKey] as? UInt) == AVAudioSession.InterruptionType.began.rawValue else { return } + MainActor.assumeIsolated { self?.fail() } + }) + observers.append(NotificationCenter.default.addObserver(forName: AVAudioSession.routeChangeNotification, object: nil, queue: .main) { [weak self] notification in + // Our own override reports a change too; everything else (a headset + // leaving, WebRTC re-applying its category) re-routes the call. + guard (notification.userInfo?[AVAudioSessionRouteChangeReasonKey] as? UInt) != AVAudioSession.RouteChangeReason.override.rawValue else { return } + MainActor.assumeIsolated { self?.applyRoute() } + }) + } + + func offer() async throws -> String { + guard let peer, !closed else { throw Failure.closed } + let offer: RTCSessionDescription = try await callback { complete in + peer.offer(for: RTCMediaConstraints(mandatoryConstraints: ["OfferToReceiveAudio": "true"], optionalConstraints: nil)) { description, error in + complete(description.map(Result.success) ?? .failure(error ?? Failure.unavailable)) + } + } + let _: Void = try await callback { complete in + peer.setLocalDescription(offer) { error in complete(error.map(Result.failure) ?? .success(())) } + } + try await waitUntil { peer.iceGatheringState == .complete } + guard let sdp = peer.localDescription?.sdp, sdp.utf8.count <= 65_536 else { throw Failure.unavailable } + return sdp + } + + func apply(answer: String) async throws { + guard let peer, !closed, !answer.isEmpty, answer.utf8.count <= 65_536 else { throw Failure.closed } + let _: Void = try await callback { complete in + peer.setRemoteDescription(RTCSessionDescription(type: .answer, sdp: answer)) { error in + complete(error.map(Result.failure) ?? .success(())) + } + } + try await waitUntil { peer.connectionState == .connected && self.channel?.readyState == .open } + } + + func setMuted(_ muted: Bool) throws { + guard !closed, peer?.connectionState == .connected else { throw Failure.closed } + // Called only after the host confirms the current generation. + if !active { + let audio = RTCAudioSession.sharedInstance() + audio.lockForConfiguration() + defer { audio.unlockForConfiguration() } + try audio.setActive(true) + audio.isAudioEnabled = true + active = true + applyRoute(locked: true) + } + track?.isEnabled = !muted + } + + /// The proximity sensor: held to the ear, play through the earpiece. + func setNearEar(_ near: Bool) { + guard nearEar != near else { return } + nearEar = near + applyRoute() + } + + /// Loudspeaker in hand, earpiece at the ear; a headset, Bluetooth or car + /// route always wins. + private func applyRoute(locked: Bool = false) { + guard active, !closed else { return } + let audio = RTCAudioSession.sharedInstance() + let external = audio.session.currentRoute.outputs.contains { + $0.portType != .builtInReceiver && $0.portType != .builtInSpeaker + } + if !locked { audio.lockForConfiguration() } + defer { if !locked { audio.unlockForConfiguration() } } + try? audio.overrideOutputAudioPort(external || nearEar ? .none : .speaker) + } + + func levels() async throws -> (UInt16, UInt16) { + guard let peer, !closed, peer.connectionState != .failed, peer.connectionState != .closed else { throw Failure.closed } + // Nobody sees the orb with the screen off; skip the statistics walk. + guard UIApplication.shared.applicationState != .background else { return (0, 0) } + let report: RTCStatisticsReport = try await callback { complete in peer.statistics { complete(.success($0)) } } + var microphone: Double = 0 + var speaker: Double = 0 + for stat in report.statistics.values { + guard let level = stat.values["audioLevel"] as? NSNumber else { continue } + if stat.type == "media-source" { microphone = max(microphone, level.doubleValue) } + if stat.type == "inbound-rtp" { speaker = max(speaker, level.doubleValue) } + } + return (UInt16(min(1, max(0, microphone)) * 65535), UInt16(min(1, max(0, speaker)) * 65535)) + } + + func close() { + guard !closed else { return } + closed = true + track?.isEnabled = false + RTCAudioSession.sharedInstance().isAudioEnabled = false + channel?.close() + peer?.close() + channel = nil; peer = nil; track = nil + for observer in observers { NotificationCenter.default.removeObserver(observer) } + observers.removeAll() + let audio = RTCAudioSession.sharedInstance() + audio.lockForConfiguration() + try? audio.overrideOutputAudioPort(.none) + try? audio.setActive(false) + audio.unlockForConfiguration() + active = false + } + + private func fail() { guard !closed else { return }; close(); onFailure?() } + private func waitUntil(_ ready: () -> Bool) async throws { + let deadline = ContinuousClock.now.advanced(by: .seconds(25)) + while !ready() { + guard !closed else { throw Failure.closed } + guard ContinuousClock.now < deadline else { throw Failure.timeout } + try await Task.sleep(for: .milliseconds(20)) + } + guard !closed else { throw Failure.closed } + } + private func callback(_ start: (@escaping (Result) -> Void) -> Void) async throws -> T { + let slot = VoiceContinuation() + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + slot.install(continuation) + start { slot.finish($0) } + } + } onCancel: { slot.finish(.failure(CancellationError())) } + } +} + +/// A cancelled operation can receive a late RTC callback, exactly once. +final class VoiceContinuation: @unchecked Sendable { + private let lock = NSLock() + private var continuation: CheckedContinuation? + private var result: Result? + private var finished = false + func install(_ continuation: CheckedContinuation) { + lock.lock() + if let result { lock.unlock(); continuation.resume(with: result); return } + self.continuation = continuation + lock.unlock() + } + func finish(_ result: Result) { + lock.lock() + guard !finished else { lock.unlock(); return } + finished = true + self.result = result + let continuation = self.continuation + self.continuation = nil + lock.unlock() + continuation?.resume(with: result) + } +} + +extension CodexVoicePeer: RTCPeerConnectionDelegate, RTCDataChannelDelegate { + nonisolated func peerConnection(_ peerConnection: RTCPeerConnection, didChange stateChanged: RTCSignalingState) {} + nonisolated func peerConnection(_ peerConnection: RTCPeerConnection, didAdd stream: RTCMediaStream) {} + nonisolated func peerConnection(_ peerConnection: RTCPeerConnection, didRemove stream: RTCMediaStream) {} + nonisolated func peerConnectionShouldNegotiate(_ peerConnection: RTCPeerConnection) {} + nonisolated func peerConnection(_ peerConnection: RTCPeerConnection, didChange newState: RTCIceConnectionState) { + if newState == .failed || newState == .closed || newState == .disconnected { Task { @MainActor [weak self] in self?.fail() } } + } + nonisolated func peerConnection(_ peerConnection: RTCPeerConnection, didChange newState: RTCIceGatheringState) {} + nonisolated func peerConnection(_ peerConnection: RTCPeerConnection, didGenerate candidate: RTCIceCandidate) {} + nonisolated func peerConnection(_ peerConnection: RTCPeerConnection, didRemove candidates: [RTCIceCandidate]) {} + nonisolated func peerConnection(_ peerConnection: RTCPeerConnection, didOpen dataChannel: RTCDataChannel) { dataChannel.close() } + nonisolated func dataChannelDidChangeState(_ dataChannel: RTCDataChannel) { + if dataChannel.readyState == .closed || dataChannel.readyState == .closing { Task { @MainActor [weak self] in self?.fail() } } + } + nonisolated func dataChannel(_ dataChannel: RTCDataChannel, didReceiveMessageWith buffer: RTCDataBuffer) { + // The host owns canonical transcripts and handoffs. Do not persist a second copy. + } +} diff --git a/apps/ios/Zeron/Voice/NativeVoiceMedia.swift b/apps/ios/Zeron/Voice/NativeVoiceMedia.swift new file mode 100644 index 0000000000..3ed4574aca --- /dev/null +++ b/apps/ios/Zeron/Voice/NativeVoiceMedia.swift @@ -0,0 +1,82 @@ +import Foundation + +/// A single-generation platform bridge. The Rust call owns the callback object; +/// its back-reference is weak so releasing the call always closes native audio. +@MainActor +final class NativeVoiceMedia: VoiceMediaListener { + weak var call: VoiceCall? + private let peer = CodexVoicePeer() + private var operations: [UInt64: Task] = [:] + private var closed = false + private var activated = false + var onFailure: (() -> Void)? + + init() { + peer.onFailure = { [weak self] in + guard let self else { return } + self.close() + self.call?.stop() + self.onFailure?() + } + } + + nonisolated func onRequest(request: VoiceMediaRequest) { + Task { @MainActor in self.perform(request) } + } + + private func perform(_ request: VoiceMediaRequest) { + if case .close = request.operation { close(); return } + guard !closed else { + call?.completeMedia(requestId: request.requestId, failure: .unavailable, sdp: nil, microphone: 0, speaker: 0) + return + } + guard operations.count < 4 else { close(); call?.stop(); return } + operations[request.requestId] = Task { @MainActor [weak self] in + guard let self else { return } + defer { self.operations[request.requestId] = nil } + do { + var sdp: String? + var microphone: UInt16 = 0 + var speaker: UInt16 = 0 + switch request.operation { + case .prepare: try await self.peer.prepare() + case .offer: sdp = try await self.peer.offer() + case .applyAnswer: + guard let answer = request.sdp else { throw CodexVoicePeer.Failure.unavailable } + try await self.peer.apply(answer: answer) + case .setMuted: + try self.peer.setMuted(request.muted) + self.activated = true + case .levels: (microphone, speaker) = try await self.peer.levels() + case .close: self.close() + } + guard !self.closed, !Task.isCancelled else { return } + self.call?.completeMedia(requestId: request.requestId, failure: nil, sdp: sdp, microphone: microphone, speaker: speaker) + } catch { + guard !self.closed else { return } + let failure: VoiceMediaFailure = (error as? CodexVoicePeer.Failure) == .permissionDenied ? .permissionDenied : .unavailable + self.call?.completeMedia(requestId: request.requestId, failure: failure, sdp: nil, microphone: 0, speaker: 0) + } + } + } + + func muteLocally(_ muted: Bool) { + // UI mute never waits for the Rust control queue or the relay. + guard !closed, activated else { return } + try? peer.setMuted(muted) + } + + /// Proximity sensor state: the earpiece while the phone is at the ear. + func setNearEar(_ near: Bool) { + guard !closed else { return } + peer.setNearEar(near) + } + + func close() { + guard !closed else { return } + closed = true + peer.close() + for operation in operations.values { operation.cancel() } + operations.removeAll() + } +} diff --git a/apps/ios/Zeron/Voice/OrbView.swift b/apps/ios/Zeron/Voice/OrbView.swift new file mode 100644 index 0000000000..8d82300107 --- /dev/null +++ b/apps/ios/Zeron/Voice/OrbView.swift @@ -0,0 +1,131 @@ +import UIKit + +/// The desktop voice orb (`zeron-orb`): the core runs the same geometry, +/// clock, audio response and 300 ms state crossfades as the GPUI widget, and +/// this view only paints the frames — lines, then disks back to front, in +/// monochrome ink on a transparent canvas. +/// +/// Redraws at 30 fps (the desktop rate) while on screen; hidden, off-window or +/// backgrounded it costs nothing and its clock stops, so motion resumes where +/// it left off. Reduce Motion shows the desktop's static frame. +final class OrbView: UIView { + var orb: VoiceOrb { + didSet { + guard orb != oldValue else { return } + renderer.setOrb(orb: orb) + if link == nil { redraw(animating: false) } + } + } + + private let renderer: OrbRenderer + private var frameData: OrbFrame? + private var link: CADisplayLink? + private var observers: [NSObjectProtocol] = [] + + init(preset: OrbPreset, orb: VoiceOrb = .idle) { + self.orb = orb + renderer = OrbRenderer(preset: preset, orb: orb) + super.init(frame: .zero) + isOpaque = false + backgroundColor = .clear + contentMode = .redraw + isUserInteractionEnabled = false + isAccessibilityElement = false + // Resume on didBecomeActive: at willEnterForeground the app still + // reports .background, so the display link would stay stopped. + for name in [UIApplication.didEnterBackgroundNotification, UIApplication.didBecomeActiveNotification, UIAccessibility.reduceMotionStatusDidChangeNotification] { + observers.append(NotificationCenter.default.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in + self?.syncLink() + }) + } + registerForTraitChanges([UITraitUserInterfaceStyle.self]) { (self: OrbView, _) in self.setNeedsDisplay() } + } + + required init?(coder: NSCoder) { fatalError() } + + deinit { + link?.invalidate() + observers.forEach(NotificationCenter.default.removeObserver) + } + + /// Normalized 0…1 peaks. They quicken the motion, like the desktop orb. + func setLevels(microphone: Float, speaker: Float) { + renderer.setAudioLevels(microphone: microphone, speaker: speaker) + } + + override var isHidden: Bool { didSet { syncLink() } } + + override func didMoveToWindow() { + super.didMoveToWindow() + syncLink() + } + + private var shouldAnimate: Bool { + window != nil && !isHidden && !UIAccessibility.isReduceMotionEnabled + && UIApplication.shared.applicationState != .background + } + + private func syncLink() { + if shouldAnimate { + guard link == nil else { return } + let link = CADisplayLink(target: DisplayLinkProxy(self), selector: #selector(DisplayLinkProxy.tick)) + link.preferredFrameRateRange = CAFrameRateRange(minimum: 30, maximum: 30, preferred: 30) + link.add(to: .main, forMode: .common) + self.link = link + redraw(animating: true) + } else { + link?.invalidate() + link = nil + // Stop the clock now, so the hidden time never plays back as a jump. + if window != nil { redraw(animating: false) } + } + } + + fileprivate func tick() { redraw(animating: true) } + + private func redraw(animating: Bool) { + frameData = renderer.nextFrame(animating: animating, reducedMotion: UIAccessibility.isReduceMotionEnabled) + setNeedsDisplay() + } + + override func draw(_ rect: CGRect) { + guard let frame = frameData, frame.size > 0, let ctx = UIGraphicsGetCurrentContext() else { return } + let dark = traitCollection.userInterfaceStyle == .dark + // The preset's artwork, scaled uniformly to fit and centered. + let scale = min(bounds.width, bounds.height) / CGFloat(frame.size) + ctx.translateBy(x: (bounds.width - CGFloat(frame.size) * scale) / 2, y: (bounds.height - CGFloat(frame.size) * scale) / 2) + ctx.scaleBy(x: scale, y: scale) + ctx.setLineCap(.butt) + let lines = frame.lines + var i = 0 + while i + 6 < lines.count { + ctx.setStrokeColor(gray: ink(lines[i + 5], dark), alpha: CGFloat(min(1, max(0, lines[i + 6])))) + ctx.setLineWidth(CGFloat(lines[i + 4])) + ctx.move(to: CGPoint(x: CGFloat(lines[i]), y: CGFloat(lines[i + 1]))) + ctx.addLine(to: CGPoint(x: CGFloat(lines[i + 2]), y: CGFloat(lines[i + 3]))) + ctx.strokePath() + i += 7 + } + let dots = frame.dots + i = 0 + while i + 4 < dots.count { + let r = CGFloat(dots[i + 2]) + ctx.setFillColor(gray: ink(dots[i + 3], dark), alpha: CGFloat(min(1, max(0, dots[i + 4])))) + ctx.fillEllipse(in: CGRect(x: CGFloat(dots[i]) - r, y: CGFloat(dots[i + 1]) - r, width: r * 2, height: r * 2)) + i += 5 + } + } + + /// Ink `white` on paper; dark appearances mirror it (desktop `ink_color`). + private func ink(_ white: Float, _ dark: Bool) -> CGFloat { + let w = CGFloat(min(1, max(0, white))) + return dark ? 1 - w : w + } +} + +/// CADisplayLink retains its target; the proxy keeps the orb releasable. +private final class DisplayLinkProxy: NSObject { + weak var view: OrbView? + init(_ view: OrbView) { self.view = view } + @objc func tick() { MainActor.assumeIsolated { view?.tick() } } +} diff --git a/apps/ios/Zeron/Voice/RemoteVoiceController.swift b/apps/ios/Zeron/Voice/RemoteVoiceController.swift new file mode 100644 index 0000000000..2fa8096d04 --- /dev/null +++ b/apps/ios/Zeron/Voice/RemoteVoiceController.swift @@ -0,0 +1,331 @@ +import AudioToolbox +import UIKit + +/// The app-wide voice orchestrator call. Audio runs on this phone; Codex, its +/// credentials and the orchestrator chat run on a registered execution host +/// (any device advertising `voice-client-media-v1`). One call at a time, no +/// automatic reconnect: a dropped call explains itself and waits for a tap. +@MainActor +final class RemoteVoiceController { + static let capability = voiceHostCapability() + /// Development override: offer voice before any host advertises it. + static var forced: Bool { + ProcessInfo.processInfo.arguments.contains("-remote-voice") || ProcessInfo.processInfo.environment["ZERON_REMOTE_VOICE"] == "1" + } + + private weak var app: AppModel? + private var call: VoiceCall? + private var media: NativeVoiceMedia? + private var generation: UInt64 = 0 + private var observers: [UUID: () -> Void] = [:] + private var proximity: NSObjectProtocol? + /// The call in the Dynamic Island and on the Lock Screen. + private let liveActivity = VoiceActivityController() + + private(set) var state: VoiceCallState? + /// Why the last call ended on its own; cleared by the next start. + private(set) var endReason: VoiceEndReason? + private(set) var muted = false + private(set) var hostName = "" + private(set) var activeSince: Date? + /// Styles the last host offered. + private(set) var styles: [String] = defaultVoiceStyles() + + var live: Bool { + #if DEBUG + return call != nil || previewing + #else + return call != nil + #endif + } + var active: Bool { state?.phase == .active } + var orb: VoiceOrb { state?.orb ?? (live ? .connecting : .idle) } + + init(app: AppModel) { + self.app = app + liveActivity.attach(to: self) + } + + // MARK: Hosts and preferences + + static func compatible(_ device: DeviceView) -> Bool { + device.isExecutionHost && device.capabilities.contains(capability) + } + + /// Registered hosts that can take a call right now. + var hosts: [DeviceView] { app?.client?.devices().filter { $0.online && Self.compatible($0) } ?? [] } + + /// Voice controls appear once any registered host can run them. + var available: Bool { + Self.forced || (app?.client?.devices().contains(where: Self.compatible) ?? false) + } + + private var preferenceKey: String { + "remoteVoice.\(app?.client?.userId() ?? "none").\(app?.client?.orgId() ?? "none")" + } + var selectedHost: String? { + get { UserDefaults.standard.string(forKey: preferenceKey + ".host") } + set { if !live { UserDefaults.standard.set(newValue, forKey: preferenceKey + ".host"); changed() } } + } + var selectedStyle: String? { + get { UserDefaults.standard.string(forKey: preferenceKey + ".style") } + set { UserDefaults.standard.set(newValue, forKey: preferenceKey + ".style"); changed() } + } + + /// The host a plain tap calls: the chosen one, or the only one online. + var startHost: DeviceView? { + let hosts = self.hosts + if let id = selectedHost, let host = hosts.first(where: { $0.id == id }) { return host } + return hosts.count == 1 ? hosts.first : nil + } + + func observe(_ callback: @escaping () -> Void) -> AnyObject { + let id = UUID() + observers[id] = callback + return Token { [weak self] in self?.observers[id] = nil } + } + private func changed() { for callback in observers.values { callback() } } + + // MARK: Call + + /// Calls `host` (remembered for next time) or the default host. False + /// when there is no host to call; the caller offers the picker. + @discardableResult + func start(host: DeviceView? = nil) -> Bool { + guard !live, let client = app?.client, let device = host ?? startHost else { return false } + if host != nil { selectedHost = device.id } + generation &+= 1 + state = nil; endReason = nil; muted = false; activeSince = nil + hostName = device.name + let media = NativeVoiceMedia() + self.media = media + let generation = self.generation + media.onFailure = { [weak self] in + guard let self, self.generation == generation else { return } + self.finish(reason: .audioUnavailable) + } + let listener = VoiceListenerBridge(controller: self, generation: generation) + let call = client.startVoice(hostDeviceId: device.id, voice: selectedStyle, media: media, listener: listener) + self.call = call + media.call = call + // A call keeps the screen awake, like the phone app. + UIApplication.shared.isIdleTimerDisabled = true + UIImpactFeedbackGenerator(style: .medium).impactOccurred() + // The call sounds as it is placed, well before the microphone opens. + CallSound.start.play() + changed() + return true + } + + func toggleMute() { + guard live else { return } + muted.toggle() + media?.muteLocally(muted) + call?.setMuted(muted: muted) + UIImpactFeedbackGenerator(style: .light).impactOccurred() + changed() + } + + /// The user hangs up. Local capture stops before any remote control. + func stop() { + guard live else { return } + UIImpactFeedbackGenerator(style: .rigid).impactOccurred() + finish(reason: nil) + } + + func dismissEndReason() { + guard endReason != nil else { return } + endReason = nil + changed() + } + + private func finish(reason: VoiceEndReason?) { + #if DEBUG + previewing = false + previewTimer?.invalidate() + #endif + generation &+= 1 + // A hang-up or a connected call ending sounds; a failure to connect + // only explains itself. + let sounds = call != nil && (activeSince != nil || reason == nil) + media?.close() + call?.stop() + // After close: the call's audio session no longer ducks it. + if sounds { CallSound.end.play() } + call = nil; media = nil; state = nil; activeSince = nil + endReason = reason + setProximityMonitoring(false) + UIApplication.shared.isIdleTimerDisabled = false + if let reason { + UINotificationFeedbackGenerator().notificationOccurred(.error) + // The stage explains a failure itself; elsewhere a toast does. + if let window = Self.keyWindow, !(Self.top(window.rootViewController) is VoiceStageViewController) { + endReason = nil + Toast.show(message(for: reason), action: reason == .microphoneDenied ? "Settings" : "Retry", in: window) { [weak self] in + if reason == .microphoneDenied, let url = URL(string: UIApplication.openSettingsURLString) { + UIApplication.shared.open(url) + } else if let self, let app = self.app, self.start() { + Self.top(window.rootViewController)?.presentVoiceStage(app: app, source: nil) + } + } + } + } + changed() + } + + private static var keyWindow: UIWindow? { + UIApplication.shared.connectedScenes.compactMap { ($0 as? UIWindowScene)?.keyWindow }.first + } + + private static func top(_ controller: UIViewController?) -> UIViewController? { + var top = controller + while let presented = top?.presentedViewController { top = presented } + return top + } + + fileprivate func receive(_ state: VoiceCallState, generation: UInt64) { + guard generation == self.generation, live else { return } + let becameActive = state.phase == .active && self.state?.phase != .active + self.state = state + if !state.voices.isEmpty { styles = state.voices } + if becameActive { + activeSince = Date() + setProximityMonitoring(true) + UINotificationFeedbackGenerator().notificationOccurred(.success) + } + changed() + } + + fileprivate func closed(_ reason: VoiceEndReason?, generation: UInt64) { + guard generation == self.generation, live else { return } + finish(reason: reason) + } + + /// Held to the ear the screen goes dark and audio moves to the earpiece; + /// in hand it plays on the loudspeaker. No-op on devices without a sensor. + private func setProximityMonitoring(_ on: Bool) { + UIDevice.current.isProximityMonitoringEnabled = on + if on, proximity == nil { + proximity = NotificationCenter.default.addObserver(forName: UIDevice.proximityStateDidChangeNotification, object: nil, queue: .main) { [weak self] _ in + MainActor.assumeIsolated { self?.media?.setNearEar(UIDevice.current.proximityState) } + } + } else if !on, let proximity { + NotificationCenter.default.removeObserver(proximity) + self.proximity = nil + } + } + + // MARK: Presentation + + /// `m:ss`, or `h:mm:ss` past the hour (desktop `format_elapsed`). + var elapsedText: String? { + guard let activeSince else { return nil } + let seconds = max(0, Int(Date().timeIntervalSince(activeSince))) + let (h, m, s) = (seconds / 3600, seconds / 60 % 60, seconds % 60) + return h > 0 ? String(format: "%d:%02d:%02d", h, m, s) : String(format: "%d:%02d", m, s) + } + + /// One line for what the call is doing now. + var statusText: String { + guard let state else { return live ? "Calling \(hostName)…" : "Codex voice" } + if state.phase == .connecting { return "Connecting to \(hostName)…" } + if state.phase == .ending { return "Ending…" } + switch state.orb { + case .speaking: return "Speaking" + case .awaitingInput: return "Codex needs your answer" + case .working: return "Working on it…" + case .muted: return "Muted" + default: return "Listening" + } + } + + func message(for reason: VoiceEndReason) -> String { + Self.message(for: reason, host: hostName.isEmpty ? "the selected device" : hostName) + } + + static func message(for reason: VoiceEndReason, host: String) -> String { + switch reason { + case .microphoneDenied: return "Zeron needs microphone access to talk with Codex." + case .audioUnavailable: return "The audio connection closed. Start a new call to reconnect." + case .signInRequired: return "Sign in to Codex with ChatGPT on \(host)." + case .usageUnavailable: return "Codex usage is currently unavailable on \(host)." + case .busy: return "\(host) is already on a voice call." + case .hostUnavailable: return "\(host) isn't reachable right now." + case .hostIncompatible: return "Update Zeron on \(host) and enable remote voice there." + case .connectionLost: return "The call dropped. Start a new one to reconnect." + } + } + + #if DEBUG + private var previewing = false + private var previewTimer: Timer? + + /// `-voice-preview`: a scripted call for screenshots and UI work — the + /// orb walks through listening, speaking and working with synthetic + /// levels. No audio, host or relay is involved. + func preview() { + previewing = true + hostName = "Fedora" + activeSince = Date().addingTimeInterval(-83) + let script: [(VoiceOrb, VoiceCallWork, String, VoiceSpeaker?)] = [ + (.listening, .idle, "Open a chat on the Mac and ask Claude to fix the failing sync test", .user), + (.speaking, .idle, "On it. I'm starting a Claude session in the comet project to look at the sync test.", nil), + (.working, .working, "On it. I'm starting a Claude session in the comet project to look at the sync test.", .assistant), + (.awaitingInput, .awaitingInput, "Claude wants to know whether to update the fixture or the parser.", .assistant), + ] + var step = 0 + previewTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] _ in + MainActor.assumeIsolated { + guard let self else { return } + let turn = (step / 40) % script.count + let (orb, work, caption, speaker) = script[turn] + let wave = Float(abs(sin(Double(step) / 2.3))) + // Words stream in, about three per second, like a live partial. + let words = caption.split(separator: " ") + let streamed = words.prefix(1 + (step % 40) / 3).joined(separator: " ") + self.state = VoiceCallState(phase: .active, orb: orb, chatId: "voice-orchestrator-preview", work: work, muted: self.muted, speaking: orb == .speaking, caption: streamed, captionSpeaker: speaker, captionItem: "preview-\(turn)", microphone: orb == .listening ? wave : 0, speaker: orb == .speaking ? wave : 0, voices: []) + step += 1 + self.changed() + } + } + } + #endif + + private final class Token { + let cancel: () -> Void + init(cancel: @escaping () -> Void) { self.cancel = cancel } + deinit { cancel() } + } +} + +/// The call's two sounds, as system sounds: they play beside the call's own +/// audio session and follow the ringer switch, like the phone's. +@MainActor +private enum CallSound: String { + case start = "voice-start" + case end = "voice-end" + + private static var loaded: [CallSound: SystemSoundID] = [:] + + func play() { + if Self.loaded[self] == nil, let url = Bundle.main.url(forResource: rawValue, withExtension: "wav") { + var id: SystemSoundID = 0 + if AudioServicesCreateSystemSoundID(url as CFURL, &id) == kAudioServicesNoError { Self.loaded[self] = id } + } + if let id = Self.loaded[self] { AudioServicesPlaySystemSound(id) } + } +} + +private final class VoiceListenerBridge: VoiceSessionListener, @unchecked Sendable { + private weak var controller: RemoteVoiceController? + private let generation: UInt64 + init(controller: RemoteVoiceController, generation: UInt64) { self.controller = controller; self.generation = generation } + func onVoiceState(state: VoiceCallState) { + let controller = controller; let generation = generation + Task { @MainActor [weak controller] in controller?.receive(state, generation: generation) } + } + func onVoiceClosed(reason: VoiceEndReason?) { + let controller = controller; let generation = generation + Task { @MainActor [weak controller] in controller?.closed(reason, generation: generation) } + } +} diff --git a/apps/ios/Zeron/Voice/Sounds/voice-end.wav b/apps/ios/Zeron/Voice/Sounds/voice-end.wav new file mode 100644 index 0000000000..d2be6c2b86 Binary files /dev/null and b/apps/ios/Zeron/Voice/Sounds/voice-end.wav differ diff --git a/apps/ios/Zeron/Voice/Sounds/voice-start.wav b/apps/ios/Zeron/Voice/Sounds/voice-start.wav new file mode 100644 index 0000000000..866e5957be Binary files /dev/null and b/apps/ios/Zeron/Voice/Sounds/voice-start.wav differ diff --git a/apps/ios/Zeron/Voice/ThinkingOrbs-LICENSE.txt b/apps/ios/Zeron/Voice/ThinkingOrbs-LICENSE.txt new file mode 100644 index 0000000000..77841b14ef --- /dev/null +++ b/apps/ios/Zeron/Voice/ThinkingOrbs-LICENSE.txt @@ -0,0 +1,30 @@ +The voice orb (zeron-orb) is extracted from Bezel +(https://github.com/clearloop/bezel, revision +6141af9c16f7353cdf36003f7404e0a94566a163), a port of gpui-thinking-orbs +(https://github.com/FrancoEscob/gpui-thinking-orbs), itself a port of +thinking-orbs by Jakub Antalik (https://github.com/Jakubantalik/thinking-orbs). + +MIT License + +Copyright (c) 2026 clearloop +Copyright (c) 2026 Wing (portions extracted from zeronsh/comet) +Copyright (c) 2026 gpui-thinking-orbs contributors +Copyright (c) 2026 Jakub Antalik + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/apps/ios/Zeron/Voice/VoiceActivityController.swift b/apps/ios/Zeron/Voice/VoiceActivityController.swift new file mode 100644 index 0000000000..8f4f8d15d9 --- /dev/null +++ b/apps/ios/Zeron/Voice/VoiceActivityController.swift @@ -0,0 +1,103 @@ +import ActivityKit +import Foundation + +/// Mirrors the call into its Live Activity (`ZeronLiveActivity`): requested +/// when a call starts (the app is in front then), updated as the orb changes +/// state and ended with the call. The Dynamic Island only shows it once Zeron +/// leaves the screen. Its mute and end buttons drive this controller's call. +@MainActor +final class VoiceActivityController { + private weak var voice: RemoteVoiceController? + private var token: AnyObject? + private var activity: Activity? + private var sent: VoiceActivityAttributes.ContentState? + private var sentAt = Date.distantPast + private var pending: Task? + + /// Orb states flip with every sentence; the island needs at most one + /// update a second to keep up. + private static let interval: TimeInterval = 1 + + func attach(to voice: RemoteVoiceController) { + self.voice = voice + token = voice.observe { [weak self] in self?.sync() } + VoiceActivityAction.handler = { [weak voice] action in + switch action { + case .toggleMute: voice?.toggleMute() + case .end: voice?.stop() + } + } + // A call never outlives its process: anything still showing is stale. + Task { + for stale in Activity.activities where stale.id != activity?.id { + await stale.end(nil, dismissalPolicy: .immediate) + } + } + } + + private func sync() { + guard let voice, voice.live else { return end() } + let state = VoiceActivityAttributes.ContentState(orb: Self.orb(voice), muted: voice.muted, since: voice.activeSince) + guard let activity else { return start(host: voice.hostName, state: state) } + guard state != sent else { + pending?.cancel() + pending = nil + return + } + pending?.cancel() + let delay = Self.interval - Date().timeIntervalSince(sentAt) + // Mute, end and the call going live answer at once; orb flips settle. + let urgent = state.muted != sent?.muted || state.since != sent?.since + pending = Task { [weak self] in + if !urgent, delay > 0 { + try? await Task.sleep(for: .seconds(delay)) + if Task.isCancelled { return } + } + self?.send(state, to: activity) + } + } + + private func start(host: String, state: VoiceActivityAttributes.ContentState) { + guard ActivityAuthorizationInfo().areActivitiesEnabled else { return } + do { + activity = try Activity.request( + attributes: VoiceActivityAttributes(host: host), + content: ActivityContent(state: state, staleDate: nil), + pushType: nil + ) + sent = state + sentAt = Date() + } catch { + activity = nil + } + } + + private func send(_ state: VoiceActivityAttributes.ContentState, to activity: Activity) { + guard activity.id == self.activity?.id else { return } + sent = state + sentAt = Date() + Task { await activity.update(ActivityContent(state: state, staleDate: nil)) } + } + + private func end() { + pending?.cancel() + pending = nil + guard let activity else { return } + self.activity = nil + let last = sent + sent = nil + Task { await activity.end(last.map { ActivityContent(state: $0, staleDate: nil) }, dismissalPolicy: .immediate) } + } + + private static func orb(_ voice: RemoteVoiceController) -> VoiceActivityOrb { + guard let state = voice.state, state.phase == .active else { return .connecting } + switch state.orb { + case .speaking: return .speaking + case .working: return .working + case .awaitingInput: return .awaiting + case .listening: return .listening + case .connecting: return .connecting + case .muted, .idle: return .muted + } + } +} diff --git a/apps/ios/Zeron/Voice/VoiceControls.swift b/apps/ios/Zeron/Voice/VoiceControls.swift new file mode 100644 index 0000000000..719b82b5d8 --- /dev/null +++ b/apps/ios/Zeron/Voice/VoiceControls.swift @@ -0,0 +1,205 @@ +import UIKit + +/// Voice inside the tab bar's bottom accessory. Idle: a round waveform button +/// beside "New session" (tap to call, hold for host and style). Live: a call +/// strip with the orb, the call clock and mute/hang-up — tap it for the stage. +final class VoiceAccessoryView: UIView { + var onStart: ((UIView) -> Void)? + var onOpen: ((UIView) -> Void)? + var onMute: (() -> Void)? + var onEnd: (() -> Void)? + + private(set) var live = false + let startButton = UIButton(type: .system) + private let strip = PressableControl() + let orb = OrbView(preset: .avatar, orb: .connecting) + private let title = UILabel() + private let detail = UILabel() + private let mute = UIButton(type: .system) + private let end = UIButton(type: .system) + private var inline = false + + override init(frame: CGRect) { + super.init(frame: frame) + var config = UIButton.Configuration.filled() + config.image = UIImage(systemName: "waveform", withConfiguration: UIImage.SymbolConfiguration(pointSize: 15, weight: .semibold)) + config.baseForegroundColor = Palette.accent + config.baseBackgroundColor = Palette.accentSoft + config.cornerStyle = .capsule + startButton.configuration = config + startButton.accessibilityLabel = "Talk to Codex" + startButton.accessibilityHint = "Starts a voice call. Touch and hold to choose the device and voice." + startButton.accessibilityIdentifier = "voice-start" + startButton.addAction(UIAction { [weak self] _ in + guard let self else { return } + self.onStart?(self.startButton) + }, for: .primaryActionTriggered) + + orb.isUserInteractionEnabled = false + title.font = Fonts.ui(.sansMedium, 15) + title.textColor = Palette.text + detail.font = Fonts.ui(.monoMedium, 12) + detail.textColor = Palette.secondary + let labels = UIStackView(arrangedSubviews: [title, detail]) + labels.axis = .vertical + labels.spacing = 0 + labels.isUserInteractionEnabled = false + for (button, symbol, tint, fill) in [ + (mute, "mic.fill", Palette.text, Palette.controlFill), + (end, "phone.down.fill", UIColor.white, UIColor.systemRed), + ] { + var config = UIButton.Configuration.filled() + config.image = UIImage(systemName: symbol, withConfiguration: UIImage.SymbolConfiguration(pointSize: 13, weight: .bold)) + config.baseForegroundColor = tint + config.baseBackgroundColor = fill + config.cornerStyle = .capsule + button.configuration = config + button.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + button.widthAnchor.constraint(equalToConstant: 32), + button.heightAnchor.constraint(equalToConstant: 32), + ]) + } + mute.addAction(UIAction { [weak self] _ in self?.onMute?() }, for: .primaryActionTriggered) + end.addAction(UIAction { [weak self] _ in self?.onEnd?() }, for: .primaryActionTriggered) + end.accessibilityLabel = "End call" + end.accessibilityIdentifier = "voice-accessory-end" + strip.accessibilityIdentifier = "voice-live" + strip.isAccessibilityElement = true + strip.accessibilityTraits = .button + // A plain UIControl never sends .primaryActionTriggered (only buttons do). + strip.addAction(UIAction { [weak self] _ in + guard let self else { return } + self.onOpen?(self.orb) + }, for: .touchUpInside) + + for v in [orb, labels, mute, end] as [UIView] { + v.translatesAutoresizingMaskIntoConstraints = false + strip.addSubview(v) + } + for v in [startButton, strip] as [UIView] { + v.translatesAutoresizingMaskIntoConstraints = false + addSubview(v) + } + NSLayoutConstraint.activate([ + startButton.trailingAnchor.constraint(equalTo: trailingAnchor), + startButton.centerYAnchor.constraint(equalTo: centerYAnchor), + startButton.widthAnchor.constraint(equalToConstant: 34), + startButton.heightAnchor.constraint(equalToConstant: 34), + startButton.leadingAnchor.constraint(greaterThanOrEqualTo: leadingAnchor), + + strip.topAnchor.constraint(equalTo: topAnchor), + strip.bottomAnchor.constraint(equalTo: bottomAnchor), + strip.leadingAnchor.constraint(equalTo: leadingAnchor), + strip.trailingAnchor.constraint(equalTo: trailingAnchor), + orb.leadingAnchor.constraint(equalTo: strip.leadingAnchor), + orb.centerYAnchor.constraint(equalTo: strip.centerYAnchor), + orb.widthAnchor.constraint(equalToConstant: 34), + orb.heightAnchor.constraint(equalToConstant: 34), + labels.leadingAnchor.constraint(equalTo: orb.trailingAnchor, constant: 8), + labels.centerYAnchor.constraint(equalTo: strip.centerYAnchor), + labels.trailingAnchor.constraint(lessThanOrEqualTo: mute.leadingAnchor, constant: -8), + mute.trailingAnchor.constraint(equalTo: end.leadingAnchor, constant: -6), + mute.centerYAnchor.constraint(equalTo: strip.centerYAnchor), + end.trailingAnchor.constraint(equalTo: strip.trailingAnchor), + end.centerYAnchor.constraint(equalTo: strip.centerYAnchor), + ]) + strip.isHidden = true + } + + required init?(coder: NSCoder) { fatalError() } + + /// Minimized tab bar: the orb, the clock and hang-up only. + func setInline(_ inline: Bool) { + self.inline = inline + title.isHidden = inline + mute.isHidden = inline + } + + func update(_ voice: RemoteVoiceController) { + live = voice.live + startButton.isHidden = live + strip.isHidden = !live + orb.isHidden = !live + guard live else { return } + let state = voice.state + orb.orb = voice.orb + orb.setLevels(microphone: state?.microphone ?? 0, speaker: state?.speaker ?? 0) + title.text = state?.work == .awaitingInput ? "Codex needs you" : "Codex" + title.textColor = state?.work == .awaitingInput ? Palette.warning : Palette.text + detail.text = voice.active ? [voice.elapsedText, voice.muted ? "Muted" : nil].compactMap { $0 }.joined(separator: " · ") : "Connecting…" + var config = mute.configuration + config?.image = UIImage(systemName: voice.muted ? "mic.slash.fill" : "mic.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 13, weight: .bold)) + config?.baseBackgroundColor = voice.muted ? Palette.text : Palette.controlFill + config?.baseForegroundColor = voice.muted ? Palette.background : Palette.text + mute.configuration = config + mute.accessibilityLabel = voice.muted ? "Unmute microphone" : "Mute microphone" + strip.accessibilityLabel = "Voice call, \(voice.statusText)" + (voice.elapsedText.map { ", \($0)" } ?? "") + strip.accessibilityHint = "Opens the call" + } +} + +/// A plain control that dims while pressed. +class PressableControl: UIControl { + override var isHighlighted: Bool { + didSet { UIView.animate(withDuration: 0.15) { self.alpha = self.isHighlighted ? 0.6 : 1 } } + } +} + +/// Voice in a navigation bar or toolbar: a waveform to start, the live orb +/// while a call runs. Used beside an open session and in the iPad sidebar. +@MainActor +final class VoiceBarItem { + let item: UIBarButtonItem + private let app: AppModel + private let button = UIButton(type: .system) + private let orb = OrbView(preset: .avatar, orb: .connecting) + private weak var host: UIViewController? + private var token: AnyObject? + + init(app: AppModel, host: UIViewController) { + self.app = app + self.host = host + orb.isUserInteractionEnabled = false + orb.translatesAutoresizingMaskIntoConstraints = false + button.translatesAutoresizingMaskIntoConstraints = false + button.addSubview(orb) + NSLayoutConstraint.activate([ + button.widthAnchor.constraint(equalToConstant: 36), + button.heightAnchor.constraint(equalToConstant: 36), + orb.centerXAnchor.constraint(equalTo: button.centerXAnchor), + orb.centerYAnchor.constraint(equalTo: button.centerYAnchor), + orb.widthAnchor.constraint(equalToConstant: 32), + orb.heightAnchor.constraint(equalToConstant: 32), + ]) + button.setImage(UIImage(systemName: "waveform", withConfiguration: UIImage.SymbolConfiguration(pointSize: 16, weight: .semibold)), for: .normal) + button.tintColor = Palette.text + button.accessibilityIdentifier = "voice-bar" + item = UIBarButtonItem(customView: button) + button.addAction(UIAction { [weak self] _ in + guard let self, let host = self.host else { return } + host.openVoice(app: app, source: self.button) + }, for: .primaryActionTriggered) + token = app.voice.observe { [weak self] in self?.refresh() } + refresh() + } + + var visible: Bool { app.voice.live || app.voice.available } + + private var shownLive: Bool? + + private func refresh() { + let voice = app.voice + if voice.live { + orb.orb = voice.orb + orb.setLevels(microphone: voice.state?.microphone ?? 0, speaker: voice.state?.speaker ?? 0) + } + button.accessibilityLabel = voice.live ? "Voice call, \(voice.statusText)" : "Talk to Codex" + guard shownLive != voice.live else { return } + shownLive = voice.live + orb.isHidden = !voice.live + button.setImage(voice.live ? nil : UIImage(systemName: "waveform", withConfiguration: UIImage.SymbolConfiguration(pointSize: 16, weight: .semibold)), for: .normal) + // Holding the live orb must not offer to change the call's host. + button.menu = voice.live ? nil : host?.voiceMenu(app: app) + } +} diff --git a/apps/ios/Zeron/Voice/VoiceStageViewController.swift b/apps/ios/Zeron/Voice/VoiceStageViewController.swift new file mode 100644 index 0000000000..056f654466 --- /dev/null +++ b/apps/ios/Zeron/Voice/VoiceStageViewController.swift @@ -0,0 +1,432 @@ +import AVKit +import UIKit + +/// The full-screen call (desktop `voice_stage.rs`): the session's orb over the +/// wallpaper, the live caption, and the call controls. Swipe down to get back +/// to the app; the call continues until End. Held to the ear it behaves like a +/// phone call — screen off, audio in the earpiece. +final class VoiceStageViewController: UIViewController { + private let app: AppModel + private var voice: RemoteVoiceController { app.voice } + private var token: AnyObject? + private let wallpaper = WallpaperView() + private let orb = OrbView(preset: .hero, orb: .connecting) + private let dot = UIView() + private let clock = UILabel() + private let host = UILabel() + private let status = UILabel() + private let caption = UILabel() + /// The previous speaker turn, fading out over the new caption. + private let previousCaption = UILabel() + /// Streamed words veil in as on desktop (`zeron-veil` through the core). + private let captionFader = CaptionFader(maxChars: 160) + private var captionLink: CADisplayLink? + private var captionSource: (item: String?, text: String, user: Bool) = (nil, "", false) + /// The caption holds an end-of-call message instead of the utterance. + private var captionShowsMessage = false + private lazy var minimize = Glass.circleButton(symbol: "chevron.down", size: 44, pointSize: 16, action: UIAction { [weak self] _ in + self?.dismiss(animated: true) + }) + private lazy var transcript = Glass.circleButton(symbol: "text.bubble", size: 44, pointSize: 16, action: UIAction { [weak self] _ in + self?.openTranscript() + }) + private let badge = UIView() + private let bar = Glass.surface() + private let controls = UIStackView() + private lazy var mute = Self.callButton(symbol: "mic.fill", tint: Palette.text, background: Palette.controlFill) { [weak self] in + self?.voice.toggleMute() + } + private let route = AVRoutePickerView() + private lazy var routeWell = Self.well(route) + private lazy var end = Self.callButton(symbol: "phone.down.fill", tint: .white, background: .systemRed, width: 88) { [weak self] in + self?.voice.stop() + } + private lazy var retry = Self.pillButton(title: "Call again", tint: .white, background: Palette.accent) { [weak self] in + self?.retryCall() + } + private lazy var close = Self.pillButton(title: "Close", tint: Palette.text, background: Palette.controlFill) { [weak self] in + self?.voice.dismissEndReason() + self?.dismiss(animated: true) + } + private var shownMuted: Bool? + + init(app: AppModel) { + self.app = app + super.init(nibName: nil, bundle: nil) + modalPresentationStyle = .fullScreen + } + + required init?(coder: NSCoder) { fatalError() } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = Palette.background + view.accessibilityIdentifier = "voice-stage" + view.addSubview(wallpaper) + + orb.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(orb) + + dot.layer.cornerRadius = 3.5 + dot.translatesAutoresizingMaskIntoConstraints = false + clock.font = Fonts.ui(.monoMedium, 13) + clock.textColor = Palette.secondary + host.font = Fonts.ui(.sansMedium, 13) + host.textColor = Palette.tertiary + let header = UIStackView(arrangedSubviews: [dot, clock, host]) + header.spacing = 7 + header.alignment = .center + header.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(header) + + status.font = Fonts.ui(.sansMedium, 17) + status.textColor = Palette.text + status.textAlignment = .center + status.accessibilityTraits = .updatesFrequently + caption.font = Fonts.ui(.sans, 16) + caption.textColor = Palette.secondary + caption.textAlignment = .center + caption.numberOfLines = 4 + caption.lineBreakMode = .byTruncatingHead + previousCaption.font = caption.font + previousCaption.textColor = Palette.secondary + previousCaption.textAlignment = .center + previousCaption.numberOfLines = 4 + previousCaption.isAccessibilityElement = false + previousCaption.isHidden = true + for label in [status, caption, previousCaption] { + label.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(label) + } + + minimize.accessibilityLabel = "Back to Zeron" + minimize.accessibilityIdentifier = "voice-minimize" + transcript.accessibilityLabel = "Open the voice transcript" + transcript.accessibilityIdentifier = "voice-transcript" + badge.backgroundColor = Palette.warning + badge.layer.cornerRadius = 5 + badge.layer.borderWidth = 2 + badge.layer.borderColor = Palette.background.cgColor + badge.isUserInteractionEnabled = false + badge.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(minimize) + view.addSubview(transcript) + view.addSubview(badge) + + route.activeTintColor = Palette.accent + route.tintColor = Palette.text + route.prioritizesVideoDevices = false + routeWell.accessibilityLabel = "Audio output" + mute.accessibilityIdentifier = "voice-mute" + end.accessibilityLabel = "End call" + end.accessibilityIdentifier = "voice-end" + retry.accessibilityIdentifier = "voice-retry" + controls.axis = .horizontal + controls.spacing = 14 + controls.alignment = .center + controls.translatesAutoresizingMaskIntoConstraints = false + for v in [mute, routeWell, end, retry, close] { controls.addArrangedSubview(v) } + bar.contentView.addSubview(controls) + bar.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(bar) + + let side = orb.widthAnchor.constraint(equalToConstant: 340) + side.priority = .defaultHigh + NSLayoutConstraint.activate([ + minimize.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 8), + minimize.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16), + transcript.centerYAnchor.constraint(equalTo: minimize.centerYAnchor), + transcript.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16), + badge.widthAnchor.constraint(equalToConstant: 10), + badge.heightAnchor.constraint(equalToConstant: 10), + badge.topAnchor.constraint(equalTo: transcript.topAnchor, constant: 2), + badge.trailingAnchor.constraint(equalTo: transcript.trailingAnchor, constant: -2), + dot.widthAnchor.constraint(equalToConstant: 7), + dot.heightAnchor.constraint(equalToConstant: 7), + header.centerXAnchor.constraint(equalTo: view.centerXAnchor), + header.centerYAnchor.constraint(equalTo: minimize.centerYAnchor), + header.leadingAnchor.constraint(greaterThanOrEqualTo: minimize.trailingAnchor, constant: 12), + + orb.centerXAnchor.constraint(equalTo: view.centerXAnchor), + orb.centerYAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerYAnchor, constant: -64), + side, + orb.widthAnchor.constraint(lessThanOrEqualTo: view.widthAnchor, constant: -32), + orb.heightAnchor.constraint(equalTo: orb.widthAnchor), + orb.topAnchor.constraint(greaterThanOrEqualTo: minimize.bottomAnchor, constant: 8), + + status.topAnchor.constraint(equalTo: orb.bottomAnchor, constant: 4), + status.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 32), + status.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -32), + caption.topAnchor.constraint(equalTo: status.bottomAnchor, constant: 10), + caption.leadingAnchor.constraint(equalTo: status.leadingAnchor), + caption.trailingAnchor.constraint(equalTo: status.trailingAnchor), + caption.bottomAnchor.constraint(lessThanOrEqualTo: bar.topAnchor, constant: -16), + previousCaption.topAnchor.constraint(equalTo: caption.topAnchor), + previousCaption.leadingAnchor.constraint(equalTo: caption.leadingAnchor), + previousCaption.trailingAnchor.constraint(equalTo: caption.trailingAnchor), + + bar.centerXAnchor.constraint(equalTo: view.centerXAnchor), + bar.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -20), + controls.topAnchor.constraint(equalTo: bar.contentView.topAnchor, constant: 10), + controls.bottomAnchor.constraint(equalTo: bar.contentView.bottomAnchor, constant: -10), + controls.leadingAnchor.constraint(equalTo: bar.contentView.leadingAnchor, constant: 10), + controls.trailingAnchor.constraint(equalTo: bar.contentView.trailingAnchor, constant: -10), + ]) + + let swipe = UISwipeGestureRecognizer(target: self, action: #selector(swipeDown)) + swipe.direction = .down + view.addGestureRecognizer(swipe) + + token = voice.observe { [weak self] in self?.refresh() } + // The veil colors are resolved per appearance. + registerForTraitChanges([UITraitUserInterfaceStyle.self]) { (self: VoiceStageViewController, _) in + if !self.captionShowsMessage { self.paintCaption() } + } + refresh() + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + wallpaper.frame = CGRect(x: 0, y: 0, width: view.bounds.width, height: min(view.bounds.height * 0.72, 760)) + } + + @objc private func swipeDown() { dismiss(animated: true) } + + private func refresh() { + let voice = self.voice + guard voice.live || voice.endReason != nil else { + // Hung up (here or elsewhere): the stage has nothing left to show. + if presentingViewController != nil, !isBeingDismissed { dismiss(animated: true) } + return + } + let state = voice.state + orb.orb = voice.live ? voice.orb : .idle + orb.setLevels(microphone: state?.microphone ?? 0, speaker: state?.speaker ?? 0) + + let awaiting = state?.work == .awaitingInput + dot.backgroundColor = !voice.active ? Palette.tertiary : awaiting ? Palette.warning : voice.muted ? Palette.tertiary : Palette.success + clock.text = voice.elapsedText ?? "–:––" + host.text = voice.hostName + transcript.isHidden = state?.chatId == nil + badge.isHidden = transcript.isHidden || !awaiting + transcript.accessibilityLabel = awaiting ? "Answer Codex in the transcript" : "Open the voice transcript" + + if let reason = voice.endReason, !voice.live { + status.text = "Call ended" + stopCaptionLink() + captionShowsMessage = true + previousCaption.isHidden = true + caption.attributedText = nil + caption.text = voice.message(for: reason) + caption.textColor = Palette.secondary + retry.configuration?.title = reason == .microphoneDenied ? "Open Settings" : "Call again" + } else { + status.text = voice.statusText + let source = (item: state?.captionItem, text: state?.caption ?? "", user: state?.captionSpeaker == .user) + if captionShowsMessage || source != captionSource { + captionShowsMessage = false + captionSource = source + paintCaption() + } + } + let ended = !voice.live + for v in [mute, routeWell, end] { v.isHidden = ended } + for v in [retry, close] { v.isHidden = !ended } + if shownMuted != voice.muted { + shownMuted = voice.muted + var config = mute.configuration + config?.image = UIImage(systemName: voice.muted ? "mic.slash.fill" : "mic.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .semibold)) + // Inverted plate while muted, like the desktop call bar. + config?.baseBackgroundColor = voice.muted ? Palette.text : Palette.controlFill + config?.baseForegroundColor = voice.muted ? Palette.background : Palette.text + mute.configuration = config + mute.accessibilityLabel = voice.muted ? "Unmute microphone" : "Mute microphone" + } + } + + private func openTranscript() { + guard let chatId = voice.state?.chatId else { return } + let router = self.router + dismiss(animated: true) { router?.openSession(chatId) } + } + + private func retryCall() { + if voice.endReason == .microphoneDenied, let url = URL(string: UIApplication.openSettingsURLString) { + UIApplication.shared.open(url) + return + } + if !voice.start() { voice.dismissEndReason() } + } + + /// The tail of the utterance, its newest words fading in and the previous + /// turn fading out; repaints every display frame until settled. + private func paintCaption() { + let frame = captionFader.frame(item: captionSource.item, text: captionSource.text, reducedMotion: UIAccessibility.isReduceMotionEnabled) + let color = (captionSource.user ? Palette.tertiary : Palette.secondary).resolvedColor(with: traitCollection) + let text = NSMutableAttributedString(string: frame.text, attributes: [.font: caption.font as Any, .foregroundColor: color]) + for span in frame.spans where span.alpha < 1 { + let range = NSRange(location: Int(span.start), length: Int(span.end) - Int(span.start)) + guard NSMaxRange(range) <= text.length else { continue } + text.addAttribute(.foregroundColor, value: color.withAlphaComponent(color.cgColor.alpha * CGFloat(span.alpha)), range: range) + } + caption.attributedText = text + previousCaption.text = frame.previous + previousCaption.alpha = CGFloat(frame.previousAlpha) + previousCaption.isHidden = frame.previous == nil + if frame.animating, captionLink == nil { + let link = CADisplayLink(target: CaptionLinkProxy(self), selector: #selector(CaptionLinkProxy.tick)) + link.add(to: .main, forMode: .common) + captionLink = link + } else if !frame.animating { + stopCaptionLink() + } + } + + fileprivate func captionTick() { paintCaption() } + + private func stopCaptionLink() { + captionLink?.invalidate() + captionLink = nil + } + + deinit { captionLink?.invalidate() } + + private static func callButton(symbol: String, tint: UIColor, background: UIColor, width: CGFloat = 60, action: @escaping () -> Void) -> UIButton { + var config = UIButton.Configuration.filled() + config.image = UIImage(systemName: symbol, withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .semibold)) + config.baseForegroundColor = tint + config.baseBackgroundColor = background + config.cornerStyle = .capsule + let button = UIButton(configuration: config, primaryAction: UIAction { _ in action() }) + button.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + button.widthAnchor.constraint(equalToConstant: width), + button.heightAnchor.constraint(equalToConstant: 60), + ]) + return button + } + + private static func pillButton(title: String, tint: UIColor, background: UIColor, action: @escaping () -> Void) -> UIButton { + var config = UIButton.Configuration.filled() + config.title = title + config.baseForegroundColor = tint + config.baseBackgroundColor = background + config.cornerStyle = .capsule + config.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 22, bottom: 0, trailing: 22) + config.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { attributes in + var attributes = attributes + attributes.font = Fonts.ui(.sansSemibold, 16) + return attributes + } + let button = UIButton(configuration: config, primaryAction: UIAction { _ in action() }) + button.translatesAutoresizingMaskIntoConstraints = false + button.heightAnchor.constraint(equalToConstant: 52).isActive = true + return button + } + + /// The system route picker (speaker, AirPods, car) in a call-button well. + private static func well(_ picker: AVRoutePickerView) -> UIView { + let well = UIView() + well.backgroundColor = Palette.controlFill + well.layer.cornerRadius = 30 + well.isAccessibilityElement = false + picker.translatesAutoresizingMaskIntoConstraints = false + well.addSubview(picker) + well.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + well.widthAnchor.constraint(equalToConstant: 60), + well.heightAnchor.constraint(equalToConstant: 60), + picker.centerXAnchor.constraint(equalTo: well.centerXAnchor), + picker.centerYAnchor.constraint(equalTo: well.centerYAnchor), + picker.widthAnchor.constraint(equalToConstant: 44), + picker.heightAnchor.constraint(equalToConstant: 44), + ]) + return well + } +} + +// MARK: - Entry points + +extension UIViewController { + /// The voice control in a bar: open the live call, start one on the + /// default host, or let the user pick a host. + func openVoice(app: AppModel, source: UIView?) { + let voice = app.voice + if voice.live || voice.start() { + presentVoiceStage(app: app, source: source) + } else { + presentVoiceHostPicker(app: app, source: source) + } + } + + func presentVoiceStage(app: AppModel, source: UIView?) { + let host = presentedViewController ?? self + guard !(host is VoiceStageViewController) else { return } + let stage = VoiceStageViewController(app: app) + if let source, source.window != nil, !UIAccessibility.isReduceMotionEnabled { + // The orb blooms out of the bar, like the desktop footer orb. + stage.preferredTransition = .zoom { [weak source] _ in source } + } + host.present(stage, animated: true) + } + + func presentVoiceHostPicker(app: AppModel, source: UIView?) { + let hosts = app.voice.hosts + let message = hosts.isEmpty + ? "No online device can host Codex voice. Start Zeron with remote voice enabled on your Mac or server." + : "Audio stays on this iPhone. Codex and its tools run on the device you pick." + let sheet = UIAlertController(title: "Talk to Codex on…", message: message, preferredStyle: .actionSheet) + for device in hosts { + sheet.addAction(UIAlertAction(title: device.name, style: .default) { [weak self] _ in + guard let self, app.voice.start(host: device) else { return } + self.presentVoiceStage(app: app, source: source) + }) + } + sheet.addAction(UIAlertAction(title: "Voice Settings", style: .default) { [weak self] _ in + self?.presentVoiceSettings(app: app) + }) + sheet.addAction(UIAlertAction(title: "Cancel", style: .cancel)) + sheet.popoverPresentationController?.sourceView = source ?? view + (presentedViewController ?? self).present(sheet, animated: true) + } + + func presentVoiceSettings(app: AppModel) { + let settings = VoiceViewController(app: app) + let nav = MainTabController.nav(settings) + settings.navigationItem.rightBarButtonItem = UIBarButtonItem(systemItem: .done, primaryAction: UIAction { [weak nav] _ in + nav?.dismiss(animated: true) + }) + (presentedViewController ?? self).present(nav, animated: true) + } + + /// Long-press menu of a voice control: host and style for the next call. + func voiceMenu(app: AppModel) -> UIMenu { + UIMenu(children: [UIDeferredMenuElement.uncached { [weak self] completion in + let voice = app.voice + let hosts = voice.hosts.map { device in + UIAction(title: device.name, image: UIImage(systemName: "desktopcomputer"), attributes: voice.live ? .disabled : [], state: device.id == voice.selectedHost ? .on : .off) { _ in + voice.selectedHost = device.id + } + } + let styles = ([nil] + voice.styles.map(Optional.some)).map { style in + UIAction(title: style?.capitalized ?? "Codex default", state: style == voice.selectedStyle ? .on : .off) { _ in + voice.selectedStyle = style + } + } + var items: [UIMenuElement] = [] + if !hosts.isEmpty { items.append(UIMenu(title: "Codex runs on", options: .displayInline, children: hosts)) } + items.append(UIMenu(title: "Voice", subtitle: voice.selectedStyle?.capitalized ?? "Codex default", image: UIImage(systemName: "person.wave.2"), children: styles)) + items.append(UIAction(title: "Voice Settings", image: UIImage(systemName: "gearshape")) { _ in + self?.presentVoiceSettings(app: app) + }) + completion(items) + }]) + } +} + +/// CADisplayLink retains its target; the proxy keeps the stage releasable. +private final class CaptionLinkProxy: NSObject { + weak var stage: VoiceStageViewController? + init(_ stage: VoiceStageViewController) { self.stage = stage } + @objc func tick() { MainActor.assumeIsolated { stage?.captionTick() } } +} diff --git a/apps/ios/Zeron/Voice/VoiceViewController.swift b/apps/ios/Zeron/Voice/VoiceViewController.swift new file mode 100644 index 0000000000..d4419b86df --- /dev/null +++ b/apps/ios/Zeron/Voice/VoiceViewController.swift @@ -0,0 +1,251 @@ +import UIKit + +/// Voice settings, in the Settings list style: which registered device runs +/// Codex for a call and which voice it speaks with. Calls start from the bar. +final class VoiceViewController: UIViewController, UICollectionViewDelegate { + private let app: AppModel + private var collectionView: UICollectionView! + private var dataSource: UICollectionViewDiffableDataSource! + private var voiceToken: AnyObject? + private var appToken: AnyObject? + private var signature = "" + + enum Section: Int, Hashable { case hero, hosts, voice, call } + + struct Row: Hashable { + let id: String + let title: String + var subtitle: String? + var symbol: String? + var selected = false + var enabled = true + var status: UIColor? + } + + init(app: AppModel) { + self.app = app + super.init(nibName: nil, bundle: nil) + title = "Voice" + } + + required init?(coder: NSCoder) { fatalError() } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = Palette.background + navigationItem.largeTitleDisplayMode = .never + var config = UICollectionLayoutListConfiguration(appearance: .insetGrouped) + config.backgroundColor = .clear + config.headerMode = .supplementary + config.footerMode = .supplementary + let layout = UICollectionViewCompositionalLayout { index, environment in + var section = config + if Section(rawValue: index) == .hero { + section.headerMode = .none + section.footerMode = .none + } + return .list(using: section, layoutEnvironment: environment) + } + collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: layout) + collectionView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + collectionView.backgroundColor = .clear + collectionView.delegate = self + view.addSubview(collectionView) + + let hero = UICollectionView.CellRegistration { cell, _, _ in + cell.backgroundConfiguration = .clear() + } + let cell = UICollectionView.CellRegistration { [weak self] cell, _, row in + var c = row.id == "style" ? UIListContentConfiguration.valueCell() : UIListContentConfiguration.subtitleCell() + c.text = row.title + c.textProperties.font = Fonts.ui(.sansMedium, 16) + c.textProperties.color = row.enabled ? Palette.text : Palette.tertiary + c.secondaryText = row.subtitle + c.secondaryTextProperties.font = Fonts.ui(.sans, 13) + c.secondaryTextProperties.color = Palette.secondary + if let symbol = row.symbol { + c.image = UIImage(systemName: symbol) + c.imageProperties.tintColor = row.enabled ? Palette.secondary : Palette.tertiary + } + cell.contentConfiguration = c + var bg = UIBackgroundConfiguration.listGroupedCell() + bg.backgroundColor = Palette.elevated + cell.backgroundConfiguration = bg + var accessories: [UICellAccessory] = [] + if let status = row.status { + let dot = UIView(frame: CGRect(x: 0, y: 0, width: 8, height: 8)) + dot.backgroundColor = status + dot.layer.cornerRadius = 4 + accessories.append(.customView(configuration: .init(customView: dot, placement: .trailing()))) + } + if row.selected { accessories.append(.checkmark(options: .init(tintColor: Palette.accent))) } + if row.id == "style", let menu = self?.styleMenu() { + accessories.append(.popUpMenu(menu, options: .init(tintColor: Palette.secondary))) + } + cell.accessories = accessories + cell.accessibilityIdentifier = "voice-\(row.id)" + cell.accessibilityTraits = row.selected ? [.button, .selected] : row.enabled ? .button : .notEnabled + } + let header = UICollectionView.SupplementaryRegistration(elementKind: UICollectionView.elementKindSectionHeader) { view, _, path in + var c = UIListContentConfiguration.groupedHeader() + c.text = Self.header(Section(rawValue: path.section)) + view.contentConfiguration = c + } + let footer = UICollectionView.SupplementaryRegistration(elementKind: UICollectionView.elementKindSectionFooter) { [weak self] view, _, path in + var c = UIListContentConfiguration.groupedFooter() + c.text = self?.footer(Section(rawValue: path.section)) + c.textProperties.font = Fonts.ui(.sans, 13) + c.textProperties.color = Palette.secondary + view.contentConfiguration = c + } + dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView) { cv, path, row in + if Section(rawValue: path.section) == .hero { + return cv.dequeueConfiguredReusableCell(using: hero, for: path, item: row) + } + return cv.dequeueConfiguredReusableCell(using: cell, for: path, item: row) + } + dataSource.supplementaryViewProvider = { cv, kind, path in + kind == UICollectionView.elementKindSectionHeader + ? cv.dequeueConfiguredReusableSupplementary(using: header, for: path) + : cv.dequeueConfiguredReusableSupplementary(using: footer, for: path) + } + voiceToken = app.voice.observe { [weak self] in self?.reload() } + appToken = app.observe { [weak self] in self?.reload() } + reload() + } + + private static func header(_ section: Section?) -> String? { + switch section { + case .hosts: "Codex runs on" + case .voice: "Voice" + case .call: "During a call" + default: nil + } + } + + private func footer(_ section: Section?) -> String? { + switch section { + case .hosts: app.voice.live + ? "End the call to change devices." + : "Audio stays on this iPhone. Codex, its sign-in and its tools run on the device you choose." + case .voice: "Applies to your next call." + default: nil + } + } + + /// Phones can't host Codex; only machines that can are listed. + private var devices: [DeviceView] { (app.client?.devices() ?? []).filter(\.isExecutionHost) } + + private func reload() { + let voice = app.voice + let devices = self.devices + // Level frames during a call must not rebuild the list. + let signature = "\(voice.live)|\(voice.selectedHost ?? "")|\(voice.selectedStyle ?? "")|\(voice.styles)|" + + devices.map { "\($0.id):\($0.online):\($0.capabilities)" }.joined() + guard signature != self.signature else { return } + self.signature = signature + + var s = NSDiffableDataSourceSnapshot() + s.appendSections([.hero, .hosts, .voice, .call]) + s.appendItems([Row(id: "hero", title: "")], toSection: .hero) + let chosen = voice.startHost?.id + if devices.isEmpty { + s.appendItems([Row(id: "no-hosts", title: "No Mac or server yet", subtitle: "Open Zeron on your Mac or server with remote voice enabled.", symbol: "desktopcomputer", enabled: false)], toSection: .hosts) + } else { + s.appendItems(devices.map { device in + let compatible = RemoteVoiceController.compatible(device) + let ready = device.online && compatible + return Row( + id: "host-\(device.id)", + title: device.name, + subtitle: !device.online ? "Offline" : !compatible ? "Update Zeron and enable remote voice" : "Ready", + symbol: Self.symbol(for: device.platform), + selected: device.id == chosen, + enabled: ready && !voice.live, + status: ready ? Palette.success : Palette.tertiary + ) + }, toSection: .hosts) + } + s.appendItems([Row(id: "style", title: "Voice", symbol: "person.wave.2")], toSection: .voice) + s.appendItems([ + Row(id: "tip-ear", title: "Hold it to your ear", subtitle: "The screen turns off and Codex plays through the earpiece.", symbol: "ear", enabled: true), + Row(id: "tip-lock", title: "Lock your iPhone", subtitle: "The call keeps going. An incoming call ends it.", symbol: "lock", enabled: true), + Row(id: "tip-bar", title: "Start from the bottom bar", subtitle: "Tap the waveform; hold it to switch device or voice.", symbol: "waveform", enabled: true), + ], toSection: .call) + dataSource.apply(s, animatingDifferences: view.window != nil) + } + + private static func symbol(for platform: String) -> String { + switch platform.lowercased() { + case let p where p.contains("mac") || p.contains("darwin"): "laptopcomputer" + case let p where p.contains("win"): "pc" + default: "server.rack" + } + } + + func collectionView(_ collectionView: UICollectionView, shouldHighlightItemAt path: IndexPath) -> Bool { + guard let row = dataSource.itemIdentifier(for: path) else { return false } + return (row.id.hasPrefix("host-") && row.enabled) || row.id == "style" + } + + func collectionView(_ collectionView: UICollectionView, didSelectItemAt path: IndexPath) { + collectionView.deselectItem(at: path, animated: true) + guard let row = dataSource.itemIdentifier(for: path) else { return } + let voice = app.voice + guard row.id.hasPrefix("host-"), row.enabled else { return } + voice.selectedHost = String(row.id.dropFirst("host-".count)) + UISelectionFeedbackGenerator().selectionChanged() + } + + /// Every voice the host offers, the current one checked. + private func styleMenu() -> UIMenu { + let voice = app.voice + let styles: [String?] = [nil] + voice.styles.map(Optional.some) + return UIMenu(options: .singleSelection, children: styles.map { style in + UIAction(title: style?.capitalized ?? "Codex default", state: style == voice.selectedStyle ? .on : .off) { _ in + voice.selectedStyle = style + UISelectionFeedbackGenerator().selectionChanged() + } + }) + } +} + +/// The settings hero: the desktop orb at rest over a one-line explanation. +private final class VoiceHeroCell: UICollectionViewCell { + private let orb = OrbView(preset: .large, orb: .idle) + private let title = UILabel() + private let detail = UILabel() + + override init(frame: CGRect) { + super.init(frame: frame) + title.text = "Talk with Codex" + title.font = Fonts.ui(.sansSemibold, 20) + title.textColor = Palette.text + title.textAlignment = .center + detail.text = "A voice orchestrator that starts sessions, checks on them and reports back." + detail.font = Fonts.ui(.sans, 14) + detail.textColor = Palette.secondary + detail.textAlignment = .center + detail.numberOfLines = 0 + let stack = UIStackView(arrangedSubviews: [orb, title, detail]) + stack.axis = .vertical + stack.alignment = .center + stack.spacing = 6 + stack.setCustomSpacing(10, after: orb) + stack.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(stack) + NSLayoutConstraint.activate([ + orb.widthAnchor.constraint(equalToConstant: 112), + orb.heightAnchor.constraint(equalToConstant: 112), + stack.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 4), + stack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -8), + stack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 24), + stack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -24), + detail.widthAnchor.constraint(lessThanOrEqualToConstant: 320), + ]) + isAccessibilityElement = true + accessibilityLabel = "Talk with Codex. " + (detail.text ?? "") + } + + required init?(coder: NSCoder) { fatalError() } +} diff --git a/apps/ios/Zeron/Voice/WebRTC-LICENSE.txt b/apps/ios/Zeron/Voice/WebRTC-LICENSE.txt new file mode 100644 index 0000000000..4c41b7b251 --- /dev/null +++ b/apps/ios/Zeron/Voice/WebRTC-LICENSE.txt @@ -0,0 +1,29 @@ +Copyright (c) 2011, The WebRTC project authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in + the documentation and/or other materials provided with the + distribution. + + * Neither the name of Google nor the names of its contributors may + be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/apps/ios/ZeronLiveActivity/Assets.xcassets/Contents.json b/apps/ios/ZeronLiveActivity/Assets.xcassets/Contents.json new file mode 100644 index 0000000000..d8b757af05 --- /dev/null +++ b/apps/ios/ZeronLiveActivity/Assets.xcassets/Contents.json @@ -0,0 +1,3 @@ +{ + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/apps/ios/ZeronLiveActivity/Assets.xcassets/orb-awaiting-avatar.imageset/Contents.json b/apps/ios/ZeronLiveActivity/Assets.xcassets/orb-awaiting-avatar.imageset/Contents.json new file mode 100644 index 0000000000..b85128079a --- /dev/null +++ b/apps/ios/ZeronLiveActivity/Assets.xcassets/orb-awaiting-avatar.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images": [ + { + "filename": "orb-awaiting-avatar.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true, + "template-rendering-intent": "template" + } +} diff --git a/apps/ios/ZeronLiveActivity/Assets.xcassets/orb-awaiting-avatar.imageset/orb-awaiting-avatar.svg b/apps/ios/ZeronLiveActivity/Assets.xcassets/orb-awaiting-avatar.imageset/orb-awaiting-avatar.svg new file mode 100644 index 0000000000..f8e2f6c67c --- /dev/null +++ b/apps/ios/ZeronLiveActivity/Assets.xcassets/orb-awaiting-avatar.imageset/orb-awaiting-avatar.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/ios/ZeronLiveActivity/Assets.xcassets/orb-awaiting-inline.imageset/Contents.json b/apps/ios/ZeronLiveActivity/Assets.xcassets/orb-awaiting-inline.imageset/Contents.json new file mode 100644 index 0000000000..63cc1cf1f3 --- /dev/null +++ b/apps/ios/ZeronLiveActivity/Assets.xcassets/orb-awaiting-inline.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images": [ + { + "filename": "orb-awaiting-inline.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true, + "template-rendering-intent": "template" + } +} diff --git a/apps/ios/ZeronLiveActivity/Assets.xcassets/orb-awaiting-inline.imageset/orb-awaiting-inline.svg b/apps/ios/ZeronLiveActivity/Assets.xcassets/orb-awaiting-inline.imageset/orb-awaiting-inline.svg new file mode 100644 index 0000000000..45b703d6e7 --- /dev/null +++ b/apps/ios/ZeronLiveActivity/Assets.xcassets/orb-awaiting-inline.imageset/orb-awaiting-inline.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/ios/ZeronLiveActivity/Assets.xcassets/orb-connecting-avatar.imageset/Contents.json b/apps/ios/ZeronLiveActivity/Assets.xcassets/orb-connecting-avatar.imageset/Contents.json new file mode 100644 index 0000000000..c827327941 --- /dev/null +++ b/apps/ios/ZeronLiveActivity/Assets.xcassets/orb-connecting-avatar.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images": [ + { + "filename": "orb-connecting-avatar.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true, + "template-rendering-intent": "template" + } +} diff --git a/apps/ios/ZeronLiveActivity/Assets.xcassets/orb-connecting-avatar.imageset/orb-connecting-avatar.svg b/apps/ios/ZeronLiveActivity/Assets.xcassets/orb-connecting-avatar.imageset/orb-connecting-avatar.svg new file mode 100644 index 0000000000..0ed680178b --- /dev/null +++ b/apps/ios/ZeronLiveActivity/Assets.xcassets/orb-connecting-avatar.imageset/orb-connecting-avatar.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/ios/ZeronLiveActivity/Assets.xcassets/orb-connecting-inline.imageset/Contents.json b/apps/ios/ZeronLiveActivity/Assets.xcassets/orb-connecting-inline.imageset/Contents.json new file mode 100644 index 0000000000..ebc309f47d --- /dev/null +++ b/apps/ios/ZeronLiveActivity/Assets.xcassets/orb-connecting-inline.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images": [ + { + "filename": "orb-connecting-inline.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true, + "template-rendering-intent": "template" + } +} diff --git a/apps/ios/ZeronLiveActivity/Assets.xcassets/orb-connecting-inline.imageset/orb-connecting-inline.svg b/apps/ios/ZeronLiveActivity/Assets.xcassets/orb-connecting-inline.imageset/orb-connecting-inline.svg new file mode 100644 index 0000000000..4f91d383c2 --- /dev/null +++ b/apps/ios/ZeronLiveActivity/Assets.xcassets/orb-connecting-inline.imageset/orb-connecting-inline.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/ios/ZeronLiveActivity/Assets.xcassets/orb-listening-avatar.imageset/Contents.json b/apps/ios/ZeronLiveActivity/Assets.xcassets/orb-listening-avatar.imageset/Contents.json new file mode 100644 index 0000000000..f418908fe8 --- /dev/null +++ b/apps/ios/ZeronLiveActivity/Assets.xcassets/orb-listening-avatar.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images": [ + { + "filename": "orb-listening-avatar.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true, + "template-rendering-intent": "template" + } +} diff --git a/apps/ios/ZeronLiveActivity/Assets.xcassets/orb-listening-avatar.imageset/orb-listening-avatar.svg b/apps/ios/ZeronLiveActivity/Assets.xcassets/orb-listening-avatar.imageset/orb-listening-avatar.svg new file mode 100644 index 0000000000..7c83c4bf68 --- /dev/null +++ b/apps/ios/ZeronLiveActivity/Assets.xcassets/orb-listening-avatar.imageset/orb-listening-avatar.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/ios/ZeronLiveActivity/Assets.xcassets/orb-listening-inline.imageset/Contents.json b/apps/ios/ZeronLiveActivity/Assets.xcassets/orb-listening-inline.imageset/Contents.json new file mode 100644 index 0000000000..fdb96bd857 --- /dev/null +++ b/apps/ios/ZeronLiveActivity/Assets.xcassets/orb-listening-inline.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images": [ + { + "filename": "orb-listening-inline.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true, + "template-rendering-intent": "template" + } +} diff --git a/apps/ios/ZeronLiveActivity/Assets.xcassets/orb-listening-inline.imageset/orb-listening-inline.svg b/apps/ios/ZeronLiveActivity/Assets.xcassets/orb-listening-inline.imageset/orb-listening-inline.svg new file mode 100644 index 0000000000..ff5ad21856 --- /dev/null +++ b/apps/ios/ZeronLiveActivity/Assets.xcassets/orb-listening-inline.imageset/orb-listening-inline.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/ios/ZeronLiveActivity/Assets.xcassets/orb-muted-avatar.imageset/Contents.json b/apps/ios/ZeronLiveActivity/Assets.xcassets/orb-muted-avatar.imageset/Contents.json new file mode 100644 index 0000000000..3ef3570ee0 --- /dev/null +++ b/apps/ios/ZeronLiveActivity/Assets.xcassets/orb-muted-avatar.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images": [ + { + "filename": "orb-muted-avatar.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true, + "template-rendering-intent": "template" + } +} diff --git a/apps/ios/ZeronLiveActivity/Assets.xcassets/orb-muted-avatar.imageset/orb-muted-avatar.svg b/apps/ios/ZeronLiveActivity/Assets.xcassets/orb-muted-avatar.imageset/orb-muted-avatar.svg new file mode 100644 index 0000000000..9d5e45517e --- /dev/null +++ b/apps/ios/ZeronLiveActivity/Assets.xcassets/orb-muted-avatar.imageset/orb-muted-avatar.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/ios/ZeronLiveActivity/Assets.xcassets/orb-muted-inline.imageset/Contents.json b/apps/ios/ZeronLiveActivity/Assets.xcassets/orb-muted-inline.imageset/Contents.json new file mode 100644 index 0000000000..778a9172d1 --- /dev/null +++ b/apps/ios/ZeronLiveActivity/Assets.xcassets/orb-muted-inline.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images": [ + { + "filename": "orb-muted-inline.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true, + "template-rendering-intent": "template" + } +} diff --git a/apps/ios/ZeronLiveActivity/Assets.xcassets/orb-muted-inline.imageset/orb-muted-inline.svg b/apps/ios/ZeronLiveActivity/Assets.xcassets/orb-muted-inline.imageset/orb-muted-inline.svg new file mode 100644 index 0000000000..eb38bc06f3 --- /dev/null +++ b/apps/ios/ZeronLiveActivity/Assets.xcassets/orb-muted-inline.imageset/orb-muted-inline.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/ios/ZeronLiveActivity/Assets.xcassets/orb-speaking-avatar.imageset/Contents.json b/apps/ios/ZeronLiveActivity/Assets.xcassets/orb-speaking-avatar.imageset/Contents.json new file mode 100644 index 0000000000..f626a07b99 --- /dev/null +++ b/apps/ios/ZeronLiveActivity/Assets.xcassets/orb-speaking-avatar.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images": [ + { + "filename": "orb-speaking-avatar.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true, + "template-rendering-intent": "template" + } +} diff --git a/apps/ios/ZeronLiveActivity/Assets.xcassets/orb-speaking-avatar.imageset/orb-speaking-avatar.svg b/apps/ios/ZeronLiveActivity/Assets.xcassets/orb-speaking-avatar.imageset/orb-speaking-avatar.svg new file mode 100644 index 0000000000..3ae34b2601 --- /dev/null +++ b/apps/ios/ZeronLiveActivity/Assets.xcassets/orb-speaking-avatar.imageset/orb-speaking-avatar.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/ios/ZeronLiveActivity/Assets.xcassets/orb-speaking-inline.imageset/Contents.json b/apps/ios/ZeronLiveActivity/Assets.xcassets/orb-speaking-inline.imageset/Contents.json new file mode 100644 index 0000000000..525401af9b --- /dev/null +++ b/apps/ios/ZeronLiveActivity/Assets.xcassets/orb-speaking-inline.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images": [ + { + "filename": "orb-speaking-inline.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true, + "template-rendering-intent": "template" + } +} diff --git a/apps/ios/ZeronLiveActivity/Assets.xcassets/orb-speaking-inline.imageset/orb-speaking-inline.svg b/apps/ios/ZeronLiveActivity/Assets.xcassets/orb-speaking-inline.imageset/orb-speaking-inline.svg new file mode 100644 index 0000000000..6b3f646377 --- /dev/null +++ b/apps/ios/ZeronLiveActivity/Assets.xcassets/orb-speaking-inline.imageset/orb-speaking-inline.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/ios/ZeronLiveActivity/Assets.xcassets/orb-working-avatar.imageset/Contents.json b/apps/ios/ZeronLiveActivity/Assets.xcassets/orb-working-avatar.imageset/Contents.json new file mode 100644 index 0000000000..e104c24b4c --- /dev/null +++ b/apps/ios/ZeronLiveActivity/Assets.xcassets/orb-working-avatar.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images": [ + { + "filename": "orb-working-avatar.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true, + "template-rendering-intent": "template" + } +} diff --git a/apps/ios/ZeronLiveActivity/Assets.xcassets/orb-working-avatar.imageset/orb-working-avatar.svg b/apps/ios/ZeronLiveActivity/Assets.xcassets/orb-working-avatar.imageset/orb-working-avatar.svg new file mode 100644 index 0000000000..b9c0c7d235 --- /dev/null +++ b/apps/ios/ZeronLiveActivity/Assets.xcassets/orb-working-avatar.imageset/orb-working-avatar.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/ios/ZeronLiveActivity/Assets.xcassets/orb-working-inline.imageset/Contents.json b/apps/ios/ZeronLiveActivity/Assets.xcassets/orb-working-inline.imageset/Contents.json new file mode 100644 index 0000000000..869d360129 --- /dev/null +++ b/apps/ios/ZeronLiveActivity/Assets.xcassets/orb-working-inline.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images": [ + { + "filename": "orb-working-inline.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true, + "template-rendering-intent": "template" + } +} diff --git a/apps/ios/ZeronLiveActivity/Assets.xcassets/orb-working-inline.imageset/orb-working-inline.svg b/apps/ios/ZeronLiveActivity/Assets.xcassets/orb-working-inline.imageset/orb-working-inline.svg new file mode 100644 index 0000000000..fd2af25be6 --- /dev/null +++ b/apps/ios/ZeronLiveActivity/Assets.xcassets/orb-working-inline.imageset/orb-working-inline.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/ios/ZeronLiveActivity/Info.plist b/apps/ios/ZeronLiveActivity/Info.plist new file mode 100644 index 0000000000..649c10d2e8 --- /dev/null +++ b/apps/ios/ZeronLiveActivity/Info.plist @@ -0,0 +1,16 @@ + + + + + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + UIAppFonts + + Geist-Medium.ttf + GeistMono-Medium.ttf + + + diff --git a/apps/ios/ZeronLiveActivity/VoiceLiveActivity.swift b/apps/ios/ZeronLiveActivity/VoiceLiveActivity.swift new file mode 100644 index 0000000000..a91a1f5e4f --- /dev/null +++ b/apps/ios/ZeronLiveActivity/VoiceLiveActivity.swift @@ -0,0 +1,260 @@ +import ActivityKit +import AppIntents +import SwiftUI +import UIKit +import WidgetKit + +/// The orchestrator call outside the app. The Dynamic Island keeps the orb +/// and the call clock beside the camera; expanded, and on the Lock Screen, it +/// adds what Codex is doing, the host and the stage's mute and end controls. +/// Tapping it returns to the full-screen stage. +struct VoiceLiveActivity: Widget { + var body: some WidgetConfiguration { + ActivityConfiguration(for: VoiceActivityAttributes.self) { context in + LockScreenCall(host: context.attributes.host, state: context.state) + .activitySystemActionForegroundColor(.primary) + .widgetURL(Stage.url) + } dynamicIsland: { context in + let state = context.state + return DynamicIsland { + DynamicIslandExpandedRegion(.leading) { + OrbGlyph(orb: state.orb, large: true) + .frame(width: 46, height: 46) + .padding(.leading, 6) + .padding(.top, 2) + } + DynamicIslandExpandedRegion(.trailing) { + CallClock(since: state.since) + .font(Face.mono(15)) + .foregroundStyle(.secondary) + .frame(maxHeight: .infinity, alignment: .center) + .padding(.trailing, 6) + } + DynamicIslandExpandedRegion(.center) { + VStack(alignment: .leading, spacing: 2) { + Status(orb: state.orb, size: 16) + Text("Codex on \(context.attributes.host)") + .font(Face.sans(12)) + .foregroundStyle(.secondary) + .lineLimit(1) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.leading, 4) + } + DynamicIslandExpandedRegion(.bottom) { + HStack(spacing: 10) { + MuteControl(muted: state.muted, labeled: true) + EndControl(labeled: true) + } + .padding(.top, 6) + .padding(.horizontal, 4) + } + } compactLeading: { + OrbGlyph(orb: state.orb, large: false) + .frame(width: 22, height: 22) + .padding(.leading, 2) + } compactTrailing: { + if state.muted { + Image(systemName: "mic.slash.fill") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(.secondary) + .padding(.trailing, 2) + } else { + CallClock(since: state.since) + .font(Face.mono(14)) + .frame(width: 44, alignment: .trailing) + } + } minimal: { + OrbGlyph(orb: state.orb, large: false) + .frame(width: 20, height: 20) + } + .keylineTint(state.orb == .awaiting ? Tone.warning : Tone.accent) + .widgetURL(Stage.url) + } + } +} + +private enum Stage { + static let url = URL(string: "zeron://voice") +} + +// MARK: Lock Screen + +private struct LockScreenCall: View { + let host: String + let state: VoiceActivityAttributes.ContentState + + var body: some View { + HStack(spacing: 14) { + OrbGlyph(orb: state.orb, large: true) + .frame(width: 52, height: 52) + VStack(alignment: .leading, spacing: 3) { + Text("Codex · \(host)") + .font(Face.mono(12)) + .foregroundStyle(.secondary) + .lineLimit(1) + Status(orb: state.orb, size: 17) + CallClock(since: state.since) + .font(Face.mono(13)) + .foregroundStyle(.secondary) + } + Spacer(minLength: 8) + HStack(spacing: 10) { + MuteControl(muted: state.muted, labeled: false) + EndControl(labeled: false) + } + } + .padding(.horizontal, 18) + .padding(.vertical, 16) + } +} + +// MARK: Pieces + +/// The desktop orb's drawing for this state, in the surrounding ink. Waiting +/// on an answer it takes the stage's warning tone; muted it rests dimmer. +private struct OrbGlyph: View { + let orb: VoiceActivityOrb + let large: Bool + + var body: some View { + Image("orb-\(orb.rawValue)-\(large ? "avatar" : "inline")") + .resizable() + .renderingMode(.template) + .scaledToFit() + .foregroundStyle(orb == .awaiting ? AnyShapeStyle(Tone.warning) : AnyShapeStyle(.primary)) + .opacity(orb == .muted ? 0.55 : 1) + .id(orb) + .transition(.opacity.combined(with: .scale(scale: 0.8))) + .accessibilityLabel(orb.status) + } +} + +private struct Status: View { + let orb: VoiceActivityOrb + let size: CGFloat + + var body: some View { + Text(orb.status) + .font(Face.sans(size)) + .foregroundStyle(orb == .awaiting ? AnyShapeStyle(Tone.warning) : AnyShapeStyle(.primary)) + .lineLimit(1) + .minimumScaleFactor(0.8) + .contentTransition(.opacity) + } +} + +/// Elapsed call time, ticking on its own between updates. +private struct CallClock: View { + let since: Date? + + var body: some View { + if let since { + Text(timerInterval: since...Date.distantFuture, countsDown: false) + .monospacedDigit() + .multilineTextAlignment(.trailing) + } else { + Image(systemName: "ellipsis") + .font(.system(size: 13, weight: .semibold)) + } + } +} + +/// The stage's mute toggle: a quiet disc, filled with ink while muted. +private struct MuteControl: View { + let muted: Bool + let labeled: Bool + + var body: some View { + Button(intent: ToggleVoiceMuteIntent()) { + ControlFace(symbol: muted ? "mic.slash.fill" : "mic.fill", label: labeled ? (muted ? "Unmute" : "Mute") : nil) + // Plain colors: hierarchical styles turn vibrant on the Lock + // Screen material, and the muted disc would lose its ink. + .foregroundStyle(muted ? Color(uiColor: .systemBackground) : Color.primary) + .background(muted ? Color.primary : Color.primary.opacity(0.14), in: Capsule()) + } + .buttonStyle(.plain) + .accessibilityLabel(muted ? "Unmute" : "Mute") + } +} + +/// Hang up, in the stage's red. +private struct EndControl: View { + let labeled: Bool + + var body: some View { + Button(intent: EndVoiceCallIntent()) { + ControlFace(symbol: "phone.down.fill", label: labeled ? "End" : nil) + .foregroundStyle(.white) + .background(Color.red, in: Capsule()) + } + .buttonStyle(.plain) + .accessibilityLabel("End call") + } +} + +private struct ControlFace: View { + let symbol: String + let label: String? + + var body: some View { + if let label { + Label(label, systemImage: symbol) + .font(Face.sans(14)) + .labelStyle(.titleAndIcon) + .frame(maxWidth: .infinity) + .frame(height: 40) + } else { + Image(systemName: symbol) + .font(.system(size: 15, weight: .bold)) + .frame(width: 44, height: 44) + } + } +} + +// MARK: Style + +/// The app's faces (Geist), bundled with the extension. +private enum Face { + static func sans(_ size: CGFloat) -> Font { .custom("Geist-Medium", size: size) } + static func mono(_ size: CGFloat) -> Font { .custom("GeistMono-Medium", size: size) } +} + +/// Zeron Dark/Light accents (`Palette`), as the app paints them. +private enum Tone { + static let accent = Color(UIColor { $0.userInterfaceStyle == .dark ? UIColor(hex: 0x8B7CF6) : UIColor(hex: 0x5B43E8) }) + static let warning = Color(UIColor { $0.userInterfaceStyle == .dark ? UIColor(hex: 0xFACC15) : UIColor(hex: 0xA16207) }) +} + +private extension UIColor { + convenience init(hex: UInt32) { + self.init( + red: CGFloat(hex >> 16 & 0xFF) / 255, + green: CGFloat(hex >> 8 & 0xFF) / 255, + blue: CGFloat(hex & 0xFF) / 255, + alpha: 1 + ) + } +} + +#if DEBUG +#Preview("Island", as: .dynamicIsland(.expanded), using: VoiceActivityAttributes(host: "Fedora")) { + VoiceLiveActivity() +} contentStates: { + VoiceActivityAttributes.ContentState(orb: .speaking, muted: false, since: .now.addingTimeInterval(-83)) + VoiceActivityAttributes.ContentState(orb: .awaiting, muted: true, since: .now.addingTimeInterval(-421)) +} + +#Preview("Compact", as: .dynamicIsland(.compact), using: VoiceActivityAttributes(host: "Fedora")) { + VoiceLiveActivity() +} contentStates: { + VoiceActivityAttributes.ContentState(orb: .listening, muted: false, since: .now.addingTimeInterval(-83)) + VoiceActivityAttributes.ContentState(orb: .muted, muted: true, since: .now.addingTimeInterval(-83)) +} + +#Preview("Lock Screen", as: .content, using: VoiceActivityAttributes(host: "Fedora")) { + VoiceLiveActivity() +} contentStates: { + VoiceActivityAttributes.ContentState(orb: .working, muted: false, since: .now.addingTimeInterval(-83)) +} +#endif diff --git a/apps/ios/ZeronLiveActivity/ZeronLiveActivityBundle.swift b/apps/ios/ZeronLiveActivity/ZeronLiveActivityBundle.swift new file mode 100644 index 0000000000..f89776dfea --- /dev/null +++ b/apps/ios/ZeronLiveActivity/ZeronLiveActivityBundle.swift @@ -0,0 +1,9 @@ +import SwiftUI +import WidgetKit + +@main +struct ZeronLiveActivityBundle: WidgetBundle { + var body: some Widget { + VoiceLiveActivity() + } +} diff --git a/apps/ios/ZeronShared/VoiceActivity.swift b/apps/ios/ZeronShared/VoiceActivity.swift new file mode 100644 index 0000000000..65cce55506 --- /dev/null +++ b/apps/ios/ZeronShared/VoiceActivity.swift @@ -0,0 +1,69 @@ +import ActivityKit +import AppIntents +import Foundation + +/// The voice call as a Live Activity: the Dynamic Island and the Lock Screen +/// keep showing the orchestrator call once Zeron leaves the screen. Shared by +/// the app, which starts and updates it, and the widget extension, which +/// draws it. +struct VoiceActivityAttributes: ActivityAttributes { + struct ContentState: Codable, Hashable { + var orb: VoiceActivityOrb + var muted: Bool + /// When the call went live; nil while it connects. + var since: Date? + } + + /// The execution host Codex runs on ("Fedora", "MacBook Pro"). + var host: String +} + +/// The orb's call states. Each has the desktop orb's own drawing (exported +/// from `zeron-orb`) in the extension's asset catalog. +enum VoiceActivityOrb: String, Codable, Hashable { + case connecting, listening, speaking, working, awaiting, muted + + /// What the call is doing, in the stage's words. + var status: String { + switch self { + case .connecting: "Connecting…" + case .listening: "Listening" + case .speaking: "Speaking" + case .working: "Working on it…" + case .awaiting: "Codex needs your answer" + case .muted: "Muted" + } + } +} + +/// Live Activity buttons run in the app's process, where the call lives. The +/// widget extension compiles these too but never performs them. +enum VoiceActivityAction { + case toggleMute, end + + @MainActor static var handler: ((VoiceActivityAction) -> Void)? +} + +struct ToggleVoiceMuteIntent: LiveActivityIntent { + static let title: LocalizedStringResource = "Mute or Unmute Voice" + static var isDiscoverable: Bool { false } + + init() {} + + func perform() async throws -> some IntentResult { + await MainActor.run { VoiceActivityAction.handler?(.toggleMute) } + return .result() + } +} + +struct EndVoiceCallIntent: LiveActivityIntent { + static let title: LocalizedStringResource = "End Voice Call" + static var isDiscoverable: Bool { false } + + init() {} + + func perform() async throws -> some IntentResult { + await MainActor.run { VoiceActivityAction.handler?(.end) } + return .result() + } +} diff --git a/apps/ios/ZeronTests/RemoteVoiceLifecycleTests.swift b/apps/ios/ZeronTests/RemoteVoiceLifecycleTests.swift new file mode 100644 index 0000000000..caafa98a5c --- /dev/null +++ b/apps/ios/ZeronTests/RemoteVoiceLifecycleTests.swift @@ -0,0 +1,72 @@ +import XCTest +@testable import Zeron + +final class RemoteVoiceLifecycleTests: XCTestCase { + func testCancelledContinuationIgnoresLateNativeSuccess() async { + let slot = VoiceContinuation() + slot.finish(.failure(CancellationError())) + do { + let _: String = try await withCheckedThrowingContinuation { continuation in + slot.install(continuation) + slot.finish(.success("late offer")) + } + XCTFail("Cancelled negotiation must not produce an offer") + } catch { XCTAssertTrue(error is CancellationError) } + } + + func testNativeCompletionResumesOnlyOnce() async throws { + let slot = VoiceContinuation() + let value = try await withCheckedThrowingContinuation { continuation in + slot.install(continuation) + slot.finish(.success(1)) + slot.finish(.success(2)) + slot.finish(.failure(CancellationError())) + } + XCTAssertEqual(value, 1) + } + + @MainActor + func testClosedEndpointCannotReactivateOrProduceOffer() async { + let peer = CodexVoicePeer() + peer.close() + peer.close() + XCTAssertThrowsError(try peer.setMuted(false)) + do { _ = try await peer.offer(); XCTFail("Closed peer cannot negotiate") } catch {} + do { try await peer.apply(answer: "late answer"); XCTFail("Closed peer cannot apply answer") } catch {} + } + + @MainActor + func testOrbFramesMatchTheDesktopArtworkBox() { + for orb: VoiceOrb in [.idle, .connecting, .listening, .speaking, .working, .awaitingInput, .muted] { + let renderer = OrbRenderer(preset: .hero, orb: orb) + renderer.setAudioLevels(microphone: 0.4, speaker: 0.9) + let frame = renderer.nextFrame(animating: true, reducedMotion: false) + XCTAssertEqual(frame.size, 128) + XCTAssertFalse(frame.dots.isEmpty) + XCTAssertEqual(frame.dots.count % 5, 0) + XCTAssertEqual(frame.lines.count % 7, 0) + XCTAssertTrue((frame.dots + frame.lines).allSatisfy(\.isFinite)) + } + } + + func testCaptionShowsTheTailAndVeilsNewWords() { + let fader = CaptionFader(maxChars: 160) + XCTAssertEqual(fader.frame(item: "a", text: " short ", reducedMotion: true).text, "short") + let long = String(repeating: "a", count: 300) + " end" + let frame = fader.frame(item: "b", text: long, reducedMotion: false) + XCTAssertTrue(frame.text.hasPrefix("…")) + XCTAssertTrue(frame.text.hasSuffix(" end")) + XCTAssertLessThanOrEqual(frame.text.count, 161) + // A new turn: its words fade in while the previous caption fades out. + XCTAssertTrue(frame.animating) + XCTAssertEqual(frame.previous, "short") + XCTAssertEqual(frame.spans.first?.alpha, 0) + } + + @MainActor + func testEveryEndReasonExplainsItself() { + for reason: VoiceEndReason in [.microphoneDenied, .audioUnavailable, .signInRequired, .usageUnavailable, .busy, .hostUnavailable, .hostIncompatible, .connectionLost] { + XCTAssertFalse(RemoteVoiceController.message(for: reason, host: "Fedora").isEmpty) + } + } +} diff --git a/apps/ios/ZeronUITests/SessionFlowTests.swift b/apps/ios/ZeronUITests/SessionFlowTests.swift index fe5426ec10..5b6edc5ae9 100644 --- a/apps/ios/ZeronUITests/SessionFlowTests.swift +++ b/apps/ios/ZeronUITests/SessionFlowTests.swift @@ -205,6 +205,33 @@ final class SessionFlowTests: XCTestCase { snapshot(app, "notification-opened") } + /// An unknown chat stays on Sessions and reports the failed open. + /// `-route` calls the same AppRouter.openSession entry point as a push + /// tap, without needing APNs or a host-side sender. This reproduces the + /// failed-open path, not the original notification's delivery or + /// the sync timing that may have made its chat unavailable. + func testUnknownChatRouteReturnsToSessions() { + let chatId = "missing-notification-chat-\(UUID().uuidString)" + let app = launch(["-route", "chat:\(chatId)"]) + XCTAssertTrue(app.staticTexts["Couldn't open session. Try again."].waitForExistence(timeout: 10)) + XCTAssertTrue(app.navigationBars["Sessions"].waitForExistence(timeout: 5)) + XCTAssertTrue(app.buttons["new-session"].waitForExistence(timeout: 5)) + XCTAssertFalse(app.scrollViews["transcript"].exists, "failed open must not present a conversation") + XCTAssertFalse(app.staticTexts["Unavailable"].exists) + + let prompt = app.staticTexts.matching(NSPredicate( + format: "label == %@", + "You: How does the layout engine avoid measuring text on the main thread?" + )).firstMatch + let reply = app.staticTexts.matching(NSPredicate( + format: "label CONTAINS %@", "Plan for the rewrite" + )).firstMatch + XCTAssertFalse(prompt.exists, "fixture prompt must never appear for a missing chat") + XCTAssertFalse(reply.exists, "fixture reply must never appear for a missing chat") + XCTAssertFalse(app.cells["session-\(chatId)"].exists) + snapshot(app, "unknown-chat-returned-to-sessions") + } + func testTabsAndSearch() { let app = launch() XCTAssertTrue(app.staticTexts["Sessions"].waitForExistence(timeout: 10)) @@ -284,6 +311,36 @@ final class SessionFlowTests: XCTestCase { XCTAssertFalse(use.exists) } + /// Clones of one repository are one project; the host chip then picks + /// which checkout runs the session. + func testProjectPickerGroupsClonesAndHostPicksCheckout() { + let app = launch(["-route", "new"]) + let project = app.buttons["composer-chip-project"] + XCTAssertTrue(project.waitForExistence(timeout: 10)) + project.tap() + let zeron = app.buttons.matching(NSPredicate(format: "label BEGINSWITH 'zeron'")) + XCTAssertTrue(zeron.firstMatch.waitForExistence(timeout: 5)) + XCTAssertEqual(zeron.count, 1, "one entry for both clones") + snapshot(app, "project-menu") + zeron.firstMatch.tap() + let host = app.buttons["composer-chip-host"] + XCTAssertTrue(host.waitForExistence(timeout: 5)) + host.tap() + func item(_ name: String) -> XCUIElement { + app.buttons.matching(NSPredicate(format: "label BEGINSWITH %@", name)).firstMatch + } + XCTAssertTrue(item("hetzner-01").waitForExistence(timeout: 5)) + XCTAssertTrue(item("MacBook Pro").exists) + XCTAssertFalse(item("Mac Studio").exists, "only hosts with a checkout") + snapshot(app, "host-menu") + item("hetzner-01").tap() + let moved = NSPredicate(format: "label CONTAINS 'hetzner-01'") + expectation(for: moved, evaluatedWith: host) + waitForExpectations(timeout: 5) + XCTAssertTrue(project.label.contains("zeron"), "the project stays picked") + snapshot(app, "picked-clone") + } + func testToolGroupExpandsAndShowsDetail() { let app = launch(["-route", "chat:chat-veil"]) let expand = app.buttons["Expand"].firstMatch diff --git a/apps/ios/ZeronUITests/VoiceFlowTests.swift b/apps/ios/ZeronUITests/VoiceFlowTests.swift new file mode 100644 index 0000000000..aa7890b9fe --- /dev/null +++ b/apps/ios/ZeronUITests/VoiceFlowTests.swift @@ -0,0 +1,72 @@ +import XCTest + +/// Voice controls around a scripted call (`-voice-preview`: no audio, host or relay). +final class VoiceFlowTests: XCTestCase { + override func setUpWithError() throws { + continueAfterFailure = false + try XCTSkipUnless(UIDevice.current.userInterfaceIdiom == .phone, "iPhone tab shell") + } + + private func launch(_ args: [String]) -> XCUIApplication { + let app = XCUIApplication() + app.launchArguments = ["-demo", "-fast", "-voice-preview"] + args + app.launch() + return app + } + + /// Stage → transcript → back: the live strip in the bottom bar reopens the stage. + func testLiveStripReopensTheStageAfterTheTranscript() { + let app = launch(["-voice-stage"]) + let stage = app.otherElements["voice-stage"] + XCTAssertTrue(stage.waitForExistence(timeout: 10)) + app.buttons["voice-transcript"].tap() + XCTAssertTrue(app.scrollViews["transcript"].waitForExistence(timeout: 10)) + XCTAssertFalse(stage.exists) + app.navigationBars.buttons.element(boundBy: 0).tap() + let strip = app.descendants(matching: .any)["voice-live"].firstMatch + XCTAssertTrue(strip.waitForExistence(timeout: 5)) + strip.tap() + XCTAssertTrue(stage.waitForExistence(timeout: 5), "the live strip opens the call stage") + } + + /// Minimizing the stage and tapping the strip brings it back. + func testLiveStripReopensAMinimizedStage() { + let app = launch(["-voice-stage"]) + let stage = app.otherElements["voice-stage"] + XCTAssertTrue(stage.waitForExistence(timeout: 10)) + app.buttons["voice-minimize"].tap() + let strip = app.descendants(matching: .any)["voice-live"].firstMatch + XCTAssertTrue(strip.waitForExistence(timeout: 5)) + sleep(1) + strip.tap() + XCTAssertTrue(stage.waitForExistence(timeout: 5)) + } + + /// Settings → Voice lists hosts and call tips; the voice is a pop-up menu. + func testVoiceSettings() { + let app = XCUIApplication() + app.launchArguments = ["-demo", "-fast", "-remote-voice", "-route", "more"] + app.launch() + let row = app.cells["settings-voice"] + XCTAssertTrue(row.waitForExistence(timeout: 10)) + row.tap() + let style = app.cells["voice-style"] + XCTAssertTrue(style.waitForExistence(timeout: 5)) + snapshot(app, "voice-settings") + style.tap() + let maple = app.buttons["Maple"] + XCTAssertTrue(maple.waitForExistence(timeout: 5)) + snapshot(app, "voice-settings-menu") + maple.tap() + XCTAssertTrue(style.staticTexts["Maple"].waitForExistence(timeout: 5), "the chosen voice shows in the row") + app.swipeUp() + snapshot(app, "voice-settings-scrolled") + } + + private func snapshot(_ app: XCUIApplication, _ name: String) { + let shot = XCTAttachment(screenshot: app.screenshot()) + shot.name = name + shot.lifetime = .keepAlways + add(shot) + } +} diff --git a/apps/landing/public/index.html b/apps/landing/public/index.html index e0243c68e7..8e5edeb7df 100644 --- a/apps/landing/public/index.html +++ b/apps/landing/public/index.html @@ -808,6 +808,7 @@ } .footer-inner .right { margin-left: auto; display: flex; align-items: center; gap: 20px; } .footer-inner .gh-icon { padding: 0; } + .footer-company { flex-basis: 100%; font-size: 11px; } footer a:hover { color: var(--dim); } /* ── mobile ──────────────────────────────────────────── */ @@ -1207,6 +1208,7 @@

Bring your agents together.

MIT © 2026 + 1499487 B.C. LTD. diff --git a/apps/zeron/build.rs b/apps/zeron/build.rs index b9430530ef..7e389dcb48 100644 --- a/apps/zeron/build.rs +++ b/apps/zeron/build.rs @@ -1,4 +1,16 @@ fn main() { + if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("macos") { + let plist = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../dist/macos/Info-unbundled.plist"); + println!("cargo:rerun-if-changed={}", plist.display()); + // `cargo run` has no .app Info.plist. NSBundle also reads privacy + // declarations from this Mach-O section, so source builds can request + // microphone access without requiring a separate packaging step. + println!( + "cargo:rustc-link-arg-bin=zeron=-Wl,-sectcreate,__TEXT,__info_plist,{}", + plist.display() + ); + } println!("cargo:rerun-if-changed=../../dist/windows/zeron.rc"); println!("cargo:rerun-if-changed=../../dist/windows/zeron.ico"); if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") { diff --git a/crates/audio/Cargo.toml b/crates/audio/Cargo.toml new file mode 100644 index 0000000000..97ffb49091 --- /dev/null +++ b/crates/audio/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "zeron-audio" +version.workspace = true +edition.workspace = true +license.workspace = true +publish.workspace = true + +[features] +default = ["native"] +native = ["dep:cpal"] +aec = ["dep:webrtc-audio-processing"] + +[dependencies] +zeron-proto.workspace = true +base64 = "0.22" +crossbeam-queue = "0.3" +cpal = { version = "=0.18.2", optional = true } +webrtc-audio-processing = { version = "~2.1.0", features = ["bundled"], optional = true } +thiserror.workspace = true diff --git a/crates/audio/src/aec.rs b/crates/audio/src/aec.rs new file mode 100644 index 0000000000..b11c533e9e --- /dev/null +++ b/crates/audio/src/aec.rs @@ -0,0 +1,89 @@ +//! WebRTC AEC3 at 48 kHz / 10 ms. Feed audio actually rendered by the +//! hardware, including underrun silence; never use received network packets. +use crate::AudioError; +use webrtc_audio_processing::{ + Config, Processor, + config::{EchoCanceller, HighPassFilter}, +}; + +pub const DSP_SAMPLES: usize = 480; +pub struct EchoProcessor { + processor: Processor, +} +impl EchoProcessor { + pub fn new() -> Result { + let processor = Processor::new(48_000).map_err(|_| AudioError::Device)?; + processor.set_config(Config { + echo_canceller: Some(EchoCanceller::Full { + stream_delay_ms: None, + }), + high_pass_filter: Some(HighPassFilter::default()), + ..Config::default() + }); + Ok(Self { processor }) + } + pub fn render(&self, actually_played: &[f32; DSP_SAMPLES]) -> Result<(), AudioError> { + self.processor + .analyze_render_frame([actually_played.as_slice()]) + .map_err(|_| AudioError::Frame) + } + pub fn capture(&self, microphone: &mut [f32; DSP_SAMPLES]) -> Result<(), AudioError> { + self.processor + .process_capture_frame([microphone.as_mut_slice()]) + .map_err(|_| AudioError::Frame) + } + /// Device/session changes discard adaptive history as well as samples. + pub fn reset(&mut self) -> Result<(), AudioError> { + *self = Self::new()?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn aec_reduces_delayed_synthetic_echo_without_erasing_double_talk() { + let processor=EchoProcessor::new().unwrap(); + let mut delayed=std::collections::VecDeque::from([[0.0;DSP_SAMPLES];3]); + let mut random=1u32; + let mut before=0.0f64; let mut after=0.0f64; + let mut near_before=0.0f64; let mut near_after=0.0f64; + for index in 0..1000 { + let reference=std::array::from_fn(|_| { + random=random.wrapping_mul(1664525).wrapping_add(1013904223); + (random as f64/u32::MAX as f64*0.4-0.2) as f32 + }); + processor.render(&reference).unwrap(); + let echo=delayed.pop_front().unwrap(); delayed.push_back(reference); + let mut microphone=std::array::from_fn(|sample| { + let human=if index>=800 { ((index*DSP_SAMPLES+sample) as f32*0.08).sin()*0.15 } else { 0.0 }; + human+echo[sample]*0.55 + }); + let input_energy=microphone.iter().map(|s|(*s as f64).powi(2)).sum::(); + processor.capture(&mut microphone).unwrap(); + let output_energy=microphone.iter().map(|s|(*s as f64).powi(2)).sum::(); + if (600..800).contains(&index) { before+=input_energy; after+=output_energy; } + if index>=900 { near_before+=input_energy; near_after+=output_energy; } + } + assert!(afternear_before*0.1,"double-talk energy ratio {}",near_after/near_before); + } + + #[test] + fn reverse_and_capture_frames_are_finite_and_reset_cleanly() { + let mut processor = EchoProcessor::new().unwrap(); + for frame in 0..400 { + let mut reference = [0.0; DSP_SAMPLES]; + for (i, v) in reference.iter_mut().enumerate() { + *v = ((frame * DSP_SAMPLES + i) as f32 * 0.04).sin() * 0.2; + } + processor.render(&reference).unwrap(); + let mut microphone = reference.map(|s| s * 0.6); + processor.capture(&mut microphone).unwrap(); + assert!(microphone.iter().all(|s| s.is_finite())); + } + processor.reset().unwrap(); + processor.capture(&mut [0.0; DSP_SAMPLES]).unwrap(); + } +} diff --git a/crates/audio/src/dsp.rs b/crates/audio/src/dsp.rs new file mode 100644 index 0000000000..2a454eeb15 --- /dev/null +++ b/crates/audio/src/dsp.rs @@ -0,0 +1,68 @@ +//! Stateful resampling and fixed framing outside real-time callbacks. +/// Continuous linear resampler. One sample of lookahead, no chunk-edge reset. +pub struct Resampler { + step: f64, + phase: f64, + previous: Option, +} +impl Resampler { + pub fn new(input: u32, output: u32) -> Option { + (input >= 8_000 && output >= 8_000 && input <= 192_000 && output <= 192_000).then_some( + Self { + step: input as f64 / output as f64, + phase: 0.0, + previous: None, + }, + ) + } + pub fn push(&mut self, sample: f32, output: &mut Vec) { + let sample = if sample.is_finite() { + sample.clamp(-1.0, 1.0) + } else { + 0.0 + }; + if let Some(previous) = self.previous { + while self.phase < 1.0 { + output.push(previous + (sample - previous) * self.phase as f32); + self.phase += self.step; + } + self.phase -= 1.0; + } + self.previous = Some(sample); + } +} +pub fn level(samples: &[f32]) -> f32 { + if samples.is_empty() { + return 0.0; + } + (samples + .iter() + .map(|s| if s.is_finite() { s * s } else { 0.0 }) + .sum::() + / samples.len() as f32) + .sqrt() + .clamp(0.0, 1.0) +} +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn resampling_keeps_continuity_and_exact_duration_across_chunks() { + let input: Vec = (0..4801).map(|i| (i as f32 * 0.03).sin()).collect(); + let mut a = Resampler::new(48_000, 24_000).unwrap(); + let mut all = Vec::new(); + for x in &input { + a.push(*x, &mut all); + } + let mut b = Resampler::new(48_000, 24_000).unwrap(); + let mut chunks = Vec::new(); + for chunk in input.chunks(37) { + for x in chunk { + b.push(*x, &mut chunks); + } + } + assert_eq!(all, chunks); + assert_eq!(all.len(), 2400); + assert!(all.iter().all(|s| s.is_finite())); + } +} diff --git a/crates/audio/src/lib.rs b/crates/audio/src/lib.rs new file mode 100644 index 0000000000..642e755ee6 --- /dev/null +++ b/crates/audio/src/lib.rs @@ -0,0 +1,79 @@ +//! Desktop-only bounded audio. PCM is ephemeral; no logs, files or network I/O. +#[cfg(feature = "aec")] +pub mod aec; +pub mod dsp; +#[cfg(feature = "native")] +pub mod native; +#[cfg(all(feature = "aec", feature = "native"))] +pub mod worker; +use base64::{Engine as _, engine::general_purpose::STANDARD}; +use zeron_proto::voice::*; + +#[derive(Debug, thiserror::Error)] +pub enum AudioError { + #[error("audio device unavailable or unsupported")] + Device, + #[error("audio buffer overflow")] + Overflow, + #[error("invalid audio format or frame")] + Frame, +} +pub fn decode(frame: &VoiceFrame) -> Result, AudioError> { + if frame.data.len() > MAX_AUDIO_BYTES.div_ceil(3) * 4 { + return Err(AudioError::Frame); + } + let bytes = STANDARD + .decode(&frame.data) + .map_err(|_| AudioError::Frame)?; + if !frame.format.validate(bytes.len()) { + return Err(AudioError::Frame); + } + Ok(bytes + .chunks_exact(2) + .map(|b| i16::from_le_bytes([b[0], b[1]]) as f32 / 32768.0) + .collect()) +} +pub fn encode(samples: &[f32], generation: u64, sequence: u64) -> Result { + if samples.is_empty() || samples.len() * 2 > MAX_AUDIO_BYTES { + return Err(AudioError::Frame); + } + let bytes: Vec<_> = samples + .iter() + .flat_map(|s| { + let value = if s.is_finite() { + (s.clamp(-1.0, 1.0) * 32767.0) as i16 + } else { + 0 + }; + value.to_le_bytes() + }) + .collect(); + Ok(VoiceFrame { + generation, + sequence, + format: VoiceFormat { + encoding: VoiceEncoding::Pcm16Le, + sample_rate: 24_000, + channels: 1, + }, + data: STANDARD.encode(bytes), + item_id: None, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn codec_bounds_endian_and_nan() { + let f = encode(&[0.0, 0.5, -0.5, f32::NAN], 7, 8).unwrap(); + let samples = decode(&f).unwrap(); + assert!((samples[1] - 0.5).abs() < 0.0001); + assert!((samples[2] + 0.5).abs() < 0.0001); + assert_eq!(samples[3], 0.0); + let mut invalid = f.clone(); + invalid.data = "A".into(); + assert!(decode(&invalid).is_err()); + assert!(encode(&vec![0.0; MAX_AUDIO_BYTES], 1, 1).is_err()); + } +} diff --git a/crates/audio/src/native.rs b/crates/audio/src/native.rs new file mode 100644 index 0000000000..30233a6414 --- /dev/null +++ b/crates/audio/src/native.rs @@ -0,0 +1,248 @@ +//! CPAL streams: callbacks touch only preallocated lock-free rings and atomics. +use crate::{AudioError, dsp::Resampler}; +use cpal::{ + FromSample, Sample, SampleFormat, SizedSample, Stream, StreamConfig, + traits::{DeviceTrait, HostTrait, StreamTrait}, +}; +use crossbeam_queue::ArrayQueue; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; + +const MAX_RING_SAMPLES: usize = 38_400; +fn ring_samples(rate: u32) -> usize { + (rate as usize / 5).min(MAX_RING_SAMPLES) +} +#[derive(Clone, Copy)] +struct OutputSample { + generation: u64, + value: f32, +} +/// Open returns paused streams. Caller starts after explicit user action and +/// eligibility; merely constructing preferences must never open a microphone. +pub struct AudioIo { + input: Stream, + output: Stream, + pub capture: Arc>, + pub reference: Arc>, + playout: Arc>, + pub failed: Arc, + pub muted: Arc, + generation: Arc, + pub input_rate: u32, + pub output_rate: u32, + output_resampler: Resampler, + scratch: Vec, +} +impl AudioIo { + pub fn open(generation: u64) -> Result { + let host = cpal::default_host(); + let input_device = host.default_input_device().ok_or(AudioError::Device)?; + let output_device = host.default_output_device().ok_or(AudioError::Device)?; + let input_config = input_device + .default_input_config() + .map_err(|_| AudioError::Device)?; + let output_config = output_device + .default_output_config() + .map_err(|_| AudioError::Device)?; + let input_rate = input_config.sample_rate(); + let output_rate = output_config.sample_rate(); + let capture = Arc::new(ArrayQueue::new(ring_samples(input_rate))); + let reference = Arc::new(ArrayQueue::new(ring_samples(output_rate))); + let playout = Arc::new(ArrayQueue::new(ring_samples(output_rate))); + let failed = Arc::new(AtomicBool::new(false)); + let muted = Arc::new(AtomicBool::new(false)); + let generation = Arc::new(AtomicU64::new(generation)); + let ic: StreamConfig = input_config.clone().into(); + let oc: StreamConfig = output_config.clone().into(); + if ic.channels == 0 || oc.channels == 0 || ic.channels > 8 || oc.channels > 8 { + return Err(AudioError::Device); + } + let output_resampler = Resampler::new(24_000, output_rate).ok_or(AudioError::Device)?; + if Resampler::new(input_rate, 48_000).is_none() { + return Err(AudioError::Device); + } + macro_rules! input { + ($t:ty) => { + input_stream::<$t>( + &input_device, + ic, + capture.clone(), + failed.clone(), + muted.clone(), + )? + }; + } + macro_rules! output { + ($t:ty) => { + output_stream::<$t>( + &output_device, + oc, + playout.clone(), + reference.clone(), + failed.clone(), + generation.clone(), + )? + }; + } + let input = match input_config.sample_format() { + SampleFormat::F32 => input!(f32), + SampleFormat::F64 => input!(f64), + SampleFormat::I16 => input!(i16), + SampleFormat::U16 => input!(u16), + SampleFormat::I32 => input!(i32), + SampleFormat::U32 => input!(u32), + SampleFormat::I8 => input!(i8), + SampleFormat::U8 => input!(u8), + _ => return Err(AudioError::Device), + }; + let output = match output_config.sample_format() { + SampleFormat::F32 => output!(f32), + SampleFormat::F64 => output!(f64), + SampleFormat::I16 => output!(i16), + SampleFormat::U16 => output!(u16), + SampleFormat::I32 => output!(i32), + SampleFormat::U32 => output!(u32), + SampleFormat::I8 => output!(i8), + SampleFormat::U8 => output!(u8), + _ => return Err(AudioError::Device), + }; + Ok(Self { + input, + output, + capture, + reference, + playout, + failed, + muted, + generation, + input_rate, + output_rate, + output_resampler, + scratch: Vec::with_capacity(MAX_RING_SAMPLES), + }) + } + pub fn start(&self) -> Result<(), AudioError> { + self.output.play().map_err(|_| AudioError::Device)?; + if self.input.play().is_err() { + let _ = self.output.pause(); + return Err(AudioError::Device); + } + Ok(()) + } + pub fn enqueue(&mut self, frame: &zeron_proto::voice::VoiceFrame) -> Result<(), AudioError> { + if frame.generation != self.generation.load(Ordering::Acquire) { + return Err(AudioError::Frame); + } + let samples = crate::decode(frame)?; + self.scratch.clear(); + for sample in samples { + self.output_resampler.push(sample, &mut self.scratch); + } + if self.scratch.len() > self.playout.capacity() - self.playout.len() { + return Err(AudioError::Overflow); + } + for value in &self.scratch { + self.playout + .push(OutputSample { + generation: frame.generation, + value: *value, + }) + .map_err(|_| AudioError::Overflow)?; + } + Ok(()) + } + pub fn mute(&self, value: bool) { + self.muted.store(value, Ordering::Release); + while self.capture.pop().is_some() {} + } + pub fn flush(&mut self, next_generation: u64) { + self.generation.store(next_generation, Ordering::Release); + while self.playout.pop().is_some() {} + while self.reference.pop().is_some() {} + while self.capture.pop().is_some() {} + self.output_resampler = Resampler::new(24_000, self.output_rate).unwrap(); + } +} +impl Drop for AudioIo { + fn drop(&mut self) { + self.muted.store(true, Ordering::Release); + let _ = self.input.pause(); + let _ = self.output.pause(); + self.generation.fetch_add(1, Ordering::AcqRel); + while self.playout.pop().is_some() {} + } +} +fn input_stream( + device: &cpal::Device, + config: StreamConfig, + ring: Arc>, + failed: Arc, + muted: Arc, +) -> Result +where + T: SizedSample, + f32: FromSample, +{ + let channels = config.channels as usize; + let errors = failed.clone(); + device + .build_input_stream( + config, + move |samples: &[T], _| { + if muted.load(Ordering::Acquire) { + return; + } + for frame in samples.chunks_exact(channels) { + let mono = + frame.iter().map(|s| f32::from_sample(*s)).sum::() / channels as f32; + if ring.push(mono).is_err() { + failed.store(true, Ordering::Release); + break; + } + } + }, + move |_| { + errors.store(true, Ordering::Release); + }, + Some(std::time::Duration::from_secs(2)), + ) + .map_err(|_| AudioError::Device) +} +fn output_stream( + device: &cpal::Device, + config: StreamConfig, + ring: Arc>, + reference: Arc>, + failed: Arc, + generation: Arc, +) -> Result +where + T: SizedSample + FromSample, +{ + let channels = config.channels as usize; + let errors = failed.clone(); + device + .build_output_stream( + config, + move |samples: &mut [T], _| { + for frame in samples.chunks_exact_mut(channels) { + let value = ring + .pop() + .filter(|s| s.generation == generation.load(Ordering::Acquire)) + .map_or(0.0, |s| s.value); + for sample in frame { + *sample = T::from_sample(value); + } + // Exactly what was sent to the device, including underrun silence. + if reference.push(value).is_err() { + failed.store(true, Ordering::Release); + } + } + }, + move |_| { + errors.store(true, Ordering::Release); + }, + Some(std::time::Duration::from_secs(2)), + ) + .map_err(|_| AudioError::Device) +} diff --git a/crates/audio/src/worker.rs b/crates/audio/src/worker.rs new file mode 100644 index 0000000000..7377373941 --- /dev/null +++ b/crates/audio/src/worker.rs @@ -0,0 +1,104 @@ +//! Poll on an audio worker, never GPUI or a hardware callback. Exact 10 ms +//! DSP windows retain partial frames between polls; network frames are 20 ms. +use crate::{ + AudioError, + aec::{DSP_SAMPLES, EchoProcessor}, + dsp::Resampler, + native::AudioIo, +}; +use std::collections::VecDeque; +use std::sync::atomic::Ordering; +use zeron_proto::voice::VoiceFrame; + +pub struct AudioWorker { + pub io: AudioIo, + echo: EchoProcessor, + mic_resampler: Resampler, + reference_resampler: Resampler, + mic: VecDeque, + reference: VecDeque, + network: Vec, + scratch: Vec, + generation: u64, + sequence: u64, +} +impl AudioWorker { + pub fn open(generation: u64) -> Result { + let io = AudioIo::open(generation)?; + Ok(Self { + echo: EchoProcessor::new()?, + mic_resampler: Resampler::new(io.input_rate, 48_000).ok_or(AudioError::Device)?, + reference_resampler: Resampler::new(io.output_rate, 48_000) + .ok_or(AudioError::Device)?, + io, + mic: VecDeque::with_capacity(9600), + reference: VecDeque::with_capacity(9600), + network: Vec::with_capacity(480), + scratch: Vec::with_capacity(8), + generation, + sequence: 0, + }) + } + pub fn poll(&mut self) -> Result, AudioError> { + if self.io.failed.load(Ordering::Acquire) { + return Err(AudioError::Device); + } + while let Some(sample) = self.io.reference.pop() { + self.scratch.clear(); + self.reference_resampler.push(sample, &mut self.scratch); + self.reference.extend(self.scratch.iter().copied()); + if self.reference.len() > 9600 { + return Err(AudioError::Overflow); + } + } + while self.reference.len() >= DSP_SAMPLES { + let frame = std::array::from_fn(|_| self.reference.pop_front().unwrap()); + self.echo.render(&frame)?; + } + if self.io.muted.load(Ordering::Acquire) { + while self.io.capture.pop().is_some() {} + self.mic.clear(); + self.network.clear(); + self.mic_resampler = Resampler::new(self.io.input_rate, 48_000).unwrap(); + return Ok(Vec::new()); + } + while let Some(sample) = self.io.capture.pop() { + self.scratch.clear(); + self.mic_resampler.push(sample, &mut self.scratch); + self.mic.extend(self.scratch.iter().copied()); + if self.mic.len() > 9600 { + return Err(AudioError::Overflow); + } + } + let mut frames = Vec::with_capacity(10); + while self.mic.len() >= DSP_SAMPLES { + let mut frame = std::array::from_fn(|_| self.mic.pop_front().unwrap()); + self.echo.capture(&mut frame)?; + // Exact 48 -> 24 kHz decimation with a pair average after AEC. + self.network + .extend(frame.chunks_exact(2).map(|s| (s[0] + s[1]) * 0.5)); + if self.network.len() == 480 { + self.sequence += 1; + frames.push(crate::encode( + &self.network, + self.generation, + self.sequence, + )?); + self.network.clear(); + } + } + Ok(frames) + } + pub fn invalidate_playout(&mut self, next_generation: u64) -> Result<(), AudioError> { + self.io.flush(next_generation); + self.echo.reset()?; + self.generation = next_generation; + self.sequence = 0; + self.mic.clear(); + self.reference.clear(); + self.network.clear(); + self.mic_resampler = Resampler::new(self.io.input_rate, 48_000).unwrap(); + self.reference_resampler = Resampler::new(self.io.output_rate, 48_000).unwrap(); + Ok(()) + } +} diff --git a/crates/client/Cargo.toml b/crates/client/Cargo.toml index 34c213e71d..2187afa34a 100644 --- a/crates/client/Cargo.toml +++ b/crates/client/Cargo.toml @@ -12,6 +12,7 @@ publish.workspace = true # frontend (iOS, Android, tests, CLIs). [dependencies] +zeron-voice-session.workspace = true zeron-proto.workspace = true zeron-doc.workspace = true zeron-sync.workspace = true diff --git a/crates/client/src/attachments.rs b/crates/client/src/attachments.rs index fb9d404b17..13009b167d 100644 --- a/crates/client/src/attachments.rs +++ b/crates/client/src/attachments.rs @@ -20,6 +20,8 @@ use crate::lock; /// Body used when a message has only attachments. pub const ATTACHMENT_ONLY_TEXT: &str = "See the attached image(s)."; +/// Body the desktop uses when any attachment is not an image. +pub const FILE_ATTACHMENT_ONLY_TEXT: &str = "See the attached file(s)."; /// The trailer marker line. pub const ATTACHMENT_MARKER: &str = "Attached images (local files — open them to view):"; /// Appshot context marker: everything after it is untrusted observed content. @@ -204,7 +206,7 @@ pub fn parse_user_message(content: &str) -> ParsedUserMessage { let body = lines[..marker - 1].join("\n"); let visible = visible_text(body.trim()).to_owned(); ParsedUserMessage { - text: if visible == ATTACHMENT_ONLY_TEXT { + text: if visible == ATTACHMENT_ONLY_TEXT || visible == FILE_ATTACHMENT_ONLY_TEXT { String::new() } else { visible @@ -300,6 +302,10 @@ mod tests { let only = with_attachments("", &paths[..1]); assert_eq!(parse_user_message(&only).text, ""); assert_eq!(queue_visible_text(&only, &paths[..1]), ATTACHMENT_ONLY_TEXT); + let files = format!( + "{FILE_ATTACHMENT_ONLY_TEXT}\n\n{ATTACHMENT_MARKER}\n- /tmp/ab12cd34-notes.zip" + ); + assert_eq!(parse_user_message(&files).text, ""); } #[test] diff --git a/crates/client/src/client.rs b/crates/client/src/client.rs index 3cfdd9dfea..f6f5c2f2c2 100644 --- a/crates/client/src/client.rs +++ b/crates/client/src/client.rs @@ -146,11 +146,11 @@ impl ClientInner { } } - /// After any local registry write: settle (demo) / push (live), re-derive. + /// After any local registry write: push (live), re-derive. Demo's + /// local-only replica already holds the write. pub(crate) fn after_registry_write(self: &Arc) { - match self.backend() { - Backend::Demo(demo) => demo.settle_registry(&self.workspace), - Backend::Live(live) => live.registry_written(), + if let Backend::Live(live) = self.backend() { + live.registry_written(); } self.recompute_workspace(); } @@ -236,46 +236,23 @@ impl ClientInner { live.relay.reconcile_watches(self, targets); } - /// Best-effort durable wake for the chat's host (`POST /device/{id}/nudge`). - /// Waits (briefly) for our own pending registry writes — e.g. a fresh - /// chat's CreateChat row — to reach the edge first, so the host never - /// wakes for a chat it can't see yet. - pub(crate) fn nudge_host(self: &Arc, host: &str, chat_id: &str) { + /// Durable wake: retry until the edge accepts it, including after relaunch. + pub(crate) fn nudge_host(self: &Arc, chat_id: &str) { let Some(live) = self.live() else { return }; - let url = crate::live::urls::nudge(&live.edge, host); - let inner = Arc::downgrade(self); - let chat_id = chat_id.to_owned(); - crate::runtime::shared().spawn(async move { - for _ in 0..50 { - let Some(inner) = inner.upgrade() else { return }; - if inner.workspace.mutate(|doc| doc.pending_len()) == 0 { - break; - } - drop(inner); - tokio::time::sleep(Duration::from_millis(100)).await; - } - let Some(inner) = inner.upgrade() else { return }; - let Ok(token) = inner.tokens.bearer().await else { - return; - }; - drop(inner); - for attempt in 0..3u64 { - let sent = crate::auth::http() - .post(&url) - .bearer_auth(&token) - .json(&serde_json::json!({ "chatId": chat_id })) - .timeout(Duration::from_secs(10)) - .send() - .await; - match sent { - // 503 nudge_queue_full is retryable. - Ok(response) if response.status().as_u16() == 503 => { - tokio::time::sleep(Duration::from_millis(500 * (attempt + 1))).await; - } - _ => return, - } - } - }); + if let Err(err) = live + .store + .schedule_sync_job(chat_id, crate::live::delivery::JOB) + { + tracing::warn!(chat = %chat_id, error = %err, "host wake persistence failed"); + } + live.delivery_wake.notify_one(); + } + + pub(crate) fn resume_delivery(self: &Arc, chat_id: &str) -> Result { + Client { + inner: self.clone(), + } + .open_session(chat_id) } pub(crate) fn registry_write( @@ -467,9 +444,8 @@ impl ClientInner { if has_attachments { live.escorts.respawn_chat(self, &core.chat_id); } - if let Some(host) = self.workspace.chat(&core.chat_id).map(|c| c.device_id) { - self.nudge_host(&host, &core.chat_id); - } + // SessionCore::write already persisted the wake receipt. + live.delivery_wake.notify_one(); } } } @@ -556,6 +532,21 @@ impl std::fmt::Debug for Client { } impl Client { + /// Dedicated ephemeral call handle; never uses durable commands or RPC retries. + pub async fn voice_transport( + &self, + device_id: &str, + ) -> Result> { + let live = self.inner.live().ok_or_else(|| { + ClientError::Unsupported("remote voice requires a connected account".into()) + })?; + live.relay.voice_transport(device_id).await + } + /// Sign-out/shutdown closes every call created by this client. + pub fn voice_cancellation(&self) -> CancellationToken { + self.inner.cancel.child_token() + } + /// Build and start. Never blocks on the network: live mode hydrates from /// `data_dir` and connects in the background; Demo seeds its dataset. pub fn new( @@ -571,7 +562,12 @@ impl Client { // Live: restore the registry replica + open the docs store first, so // the very first snapshot renders the cached workspace (instant). let (registry, store) = if credentials.is_demo() { - (RegistryDoc::new(config.device_id.clone()), None) + // Demo has no edge: nothing would ever ack a queued write, so the + // replica folds writes straight into its rows (as the engine does + // for edge-less profiles). + let mut registry = RegistryDoc::new(config.device_id.clone()); + registry.set_local_only(true); + (registry, None) } else { let (store, registry) = LiveBackend::open(&config.data_dir, &config.device_id)?; (registry, Some(store)) @@ -953,6 +949,7 @@ impl Client { git_detected, git_checked_at: None, checkout_id: None, + repository_id: None, created_at: Utc::now(), }; let id = space.id.clone(); @@ -1059,6 +1056,18 @@ impl Client { core.ensure_room(&self.inner); core.touch(); core.refresh(); + // Upgrade recovery: older clients could persist an ACKed command + // without its wake. Opening that chat must re-arm host discovery. + if let Some(live) = self.inner.live() + && core.send_state().is_some() + && live + .store + .sync_job_version(chat_id, crate::live::delivery::JOB) + .ok() + == Some(None) + { + self.inner.nudge_host(chat_id); + } if let Some(demo) = self.inner.demo() { demo.session_opened(&core); } @@ -1178,7 +1187,9 @@ impl Client { environment: &str, prefs: PushPrefs, ) -> Result<()> { - let Some(live) = self.inner.live() else { return Ok(()) }; + let Some(live) = self.inner.live() else { + return Ok(()); + }; let url = crate::live::urls::registry_push_target( &live.edge, self.inner.credentials.org_id(), @@ -1198,14 +1209,19 @@ impl Client { .await .map_err(|e| ClientError::Network(e.to_string()))?; if !response.status().is_success() { - return Err(ClientError::HostError(format!("push registration http {}", response.status()))); + return Err(ClientError::HostError(format!( + "push registration http {}", + response.status() + ))); } Ok(()) } /// Stop notifications to this device (sign-out, turned off). pub async fn unregister_push_target(&self) -> Result<()> { - let Some(live) = self.inner.live() else { return Ok(()) }; + let Some(live) = self.inner.live() else { + return Ok(()); + }; let url = crate::live::urls::registry_push_target( &live.edge, self.inner.credentials.org_id(), @@ -1219,7 +1235,10 @@ impl Client { .await .map_err(|e| ClientError::Network(e.to_string()))?; if !response.status().is_success() { - return Err(ClientError::HostError(format!("push removal http {}", response.status()))); + return Err(ClientError::HostError(format!( + "push removal http {}", + response.status() + ))); } Ok(()) } @@ -1502,3 +1521,43 @@ pub struct PushPrefs { /// A run failed. pub failed: bool, } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn demo_replica_is_local_only_and_never_queues() { + let dir = tempfile::tempdir().unwrap(); + let mut config = ClientConfig::new("https://edge.invalid", dir.path()); + config.device_id = "ios-test".into(); + let client = Client::new( + config, + Credentials::Demo(Default::default()), + Arc::new(crate::events::NullListener), + ) + .unwrap(); + let chat_id = client + .inner + .workspace + .mutate(|doc| doc.read_chats()) + .unwrap() + .into_iter() + .find(|c| c.parent_chat_id.is_none()) + .expect("seeded chat") + .id; + for i in 0..20 { + client + .rename_session(&chat_id, &format!("title {i}")) + .unwrap(); + } + let (pending, title) = client.inner.workspace.mutate(|doc| { + ( + doc.pending_len(), + doc.chat(&chat_id).unwrap().unwrap().title, + ) + }); + assert_eq!(pending, 0); + assert_eq!(title.as_deref(), Some("title 19")); + } +} diff --git a/crates/client/src/demo/fixtures.rs b/crates/client/src/demo/fixtures.rs index bb13e70152..db0cb86cf1 100644 --- a/crates/client/src/demo/fixtures.rs +++ b/crates/client/src/demo/fixtures.rs @@ -178,8 +178,8 @@ pub(crate) fn chats() -> Vec { last_ago_ms: 10 * MIN, ..chat( "chat-cjk", - Some("space-zeron"), - MAC, + Some("space-zeron-vps"), + VPS, "多言語テキストのレイアウト 🌏", "日本語の長い段落です。", ) @@ -246,8 +246,13 @@ pub(crate) fn spaces(now: i64) -> Vec { git_detected: git, git_checked_at: git.then(|| ms(now)), checkout_id: git.then(|| format!("co-{id}")), + repository_id: None, created_at: ms(now - ago), }; + let zeron = |mut space: Space| { + space.repository_id = Some("github.com/zeronsh/zeron".into()); + space + }; vec![ space( "space-blog", @@ -257,7 +262,23 @@ pub(crate) fn spaces(now: i64) -> Vec { false, 20 * DAY, ), - space("space-zeron", MAC, "/Users/dev/zeron", None, true, 9 * DAY), + zeron(space( + "space-zeron", + MAC, + "/Users/dev/zeron", + None, + true, + 9 * DAY, + )), + // A second clone of the same repository on the VPS. + zeron(space( + "space-zeron-vps", + VPS, + "/srv/src/zeron", + None, + true, + 6 * DAY, + )), space("space-edge", VPS, "/srv/deploys/edge", None, true, 4 * DAY), space( "space-mobile", @@ -332,8 +353,8 @@ pub(crate) struct Seeded { pub change_requests: Vec, } -/// Write the whole dataset into the replica (as local writes; the demo -/// server settles them). +/// Write the whole dataset into the replica (as local writes, which the +/// local-only demo replica folds straight into its rows). pub(crate) fn seed( doc: &mut RegistryDoc, fixture: DemoFixture, @@ -414,6 +435,7 @@ pub(crate) fn seed( if let Some(status) = demo.status { doc.upsert_session(&Session { last_completed_turn: None, + running_subagents: 0, chat_id: demo.id.into(), device_id: demo.device.into(), status, diff --git a/crates/client/src/demo/mod.rs b/crates/client/src/demo/mod.rs index c6d92e005c..528fb66ad2 100644 --- a/crates/client/src/demo/mod.rs +++ b/crates/client/src/demo/mod.rs @@ -2,8 +2,8 @@ //! simulated host. //! //! Nothing here short-circuits the client: the dataset lives in a real -//! [`RegistryDoc`] (local writes settle through [`DemoServer`], a stand-in for -//! the registry room) and real [`SessionDoc`]s. The viewer side writes +//! [`RegistryDoc`] (local-only, like an edge-less engine: writes fold straight +//! into its rows, with no room to ack them) and real [`SessionDoc`]s. The viewer side writes //! commands and queue rows exactly as in live mode; [`DemoHost`] plays the //! engine — adopting commands (writing the user entry under the client-minted //! id), streaming replies through `SegmentWriter`, flipping session status @@ -21,8 +21,8 @@ use std::time::Duration; use chrono::Utc; use tokio_util::sync::CancellationToken; use zeron_doc::{ - MessagePart, MessageRole, MessageStatus, QueueDeliveryGate, RegistryDoc, RegistryRow, RowOp, - SegmentWriter, SessionCommandPayload, SessionCommandStatus, SessionDoc, apply_op, + MessagePart, MessageRole, MessageStatus, QueueDeliveryGate, SegmentWriter, + SessionCommandPayload, SessionCommandStatus, SessionDoc, }; use zeron_proto::{FolderEntry, FolderListing, RepoRef, Session, SessionStatus}; @@ -32,7 +32,6 @@ use crate::config::{DemoOptions, StreamSpeed}; use crate::error::{ClientError, Result}; use crate::rpc::ProgressFn; use crate::session::SessionCore; -use crate::workspace::WorkspaceStore; use crate::{lock, now_ms}; use transcripts::Step; @@ -43,46 +42,6 @@ const ADOPT_DELAY: Duration = Duration::from_millis(180); const HEARTBEAT: Duration = Duration::from_secs(10); const LEASE_MS: i64 = 60_000; -/// In-process registry room: merges pushed op batches into server rows and -/// broadcasts them back (the mock server's merge, minus the socket). -#[derive(Default)] -pub(crate) struct DemoServer { - rows: HashMap<(String, String), RegistryRow>, - seq: u64, -} - -impl DemoServer { - fn settle(&mut self, doc: &mut RegistryDoc) { - loop { - let batches = doc.take_pushable(); - if batches.is_empty() { - return; - } - for batch in batches { - let mut touched = Vec::new(); - for op in &batch.ops { - self.apply(op, &mut touched); - } - self.seq = self.seq.max(doc.cursor()) + 1; - let _ = doc.apply_rows(self.seq, touched); - doc.ack_batch(&batch.batch, self.seq); - } - } - } - - fn apply(&mut self, op: &RowOp, touched: &mut Vec) { - let key = (op.kind.clone(), op.id.clone()); - let (next, changed) = apply_op(self.rows.get(&key), op); - if let Some(mut row) = next - && changed - { - row.seq = self.seq + 1; - self.rows.insert(key, row.clone()); - touched.push(row); - } - } -} - /// A tiny deterministic PRNG (xorshift64*): demo cadence is reproducible. struct Rng(u64); @@ -109,7 +68,6 @@ struct Lease { pub(crate) struct DemoHost { options: DemoOptions, client: Weak, - server: Mutex, refs: Mutex>>, /// Running turn per chat: (turn id, cancel token). turns: Mutex>, @@ -132,7 +90,6 @@ impl DemoHost { Arc::new(Self { options, client: Arc::downgrade(client), - server: Mutex::new(DemoServer::default()), refs: Mutex::new(HashMap::new()), turns: Mutex::new(HashMap::new()), turn_seq: AtomicU64::new(1), @@ -164,16 +121,10 @@ impl DemoHost { for status in seeded.change_requests { client.workspace.put_change_request(status); } - self.settle_registry(&client.workspace); self.beat_presence(client); Ok(()) } - pub(crate) fn settle_registry(&self, workspace: &WorkspaceStore) { - let mut server = lock(&self.server); - workspace.mutate(|doc| server.settle(doc)); - } - fn beat_presence(&self, client: &ClientInner) { let now = now_ms(); for device in fixtures::ONLINE { @@ -496,6 +447,7 @@ impl DemoHost { let _ = client.registry_write(|doc| { doc.upsert_session(&Session { last_completed_turn: completed.map(str::to_owned), + running_subagents: 0, chat_id: chat_id.to_owned(), device_id: host, status, diff --git a/crates/client/src/demo/transcripts.rs b/crates/client/src/demo/transcripts.rs index e0f0d5d36e..e99691811d 100644 --- a/crates/client/src/demo/transcripts.rs +++ b/crates/client/src/demo/transcripts.rs @@ -5,7 +5,7 @@ //! for benchmarks, and the scripted streaming reply. use zeron_doc::{MessagePart, MessageRole, MessageStatus, SessionMessageEntry, ToolDiffStat}; -use zeron_proto::{TodoItem, ToolCall, UserInputQuestion}; +use zeron_proto::{TodoItem, TodoStatus, ToolCall, UserInputQuestion}; pub(crate) const PHONE: &str = "ios-demo"; @@ -101,7 +101,12 @@ fn assistant(id: &str, host: &str, at: i64, parts: Vec) -> SessionM /// Paths the demo serves generated images for. pub(crate) const DEMO_IMAGES: &[(&str, u32, u32, u32)] = &[ - ("/Users/dev/.zeron/uploads/veil-before.png", 960, 540, 1), + ( + "/Users/dev/.zeron/uploads/4f1c9a2e-Image_1.png", + 960, + 540, + 1, + ), ("/Users/dev/.zeron/uploads/scroll-tall.png", 400, 800, 2), ("/Users/dev/.zeron/uploads/square.png", 600, 600, 3), ]; @@ -201,9 +206,22 @@ gh pr create --base main --title "Stream pull request status on every client" PR **#90** is open: https://github.com/zeron-sh/zeron/pull/90"#; fn veil(host: &str, now: i64) -> Vec { + // Sent from the desktop composer: file, folder and skill chips, plus the + // image and recording it attached, each mentioned by its chip. + let review = zeron_proto::invocation::Invocation::Skill { + name: "review".into(), + path: "/Users/dev/.claude/skills/review/SKILL.md".into(), + command: None, + }; let attach = crate::attachments::with_attachments( - "Port the streaming fade-in veil from the desktop transcript. It must never affect layout — opacity only, split at chunk boundaries. Here's how it looks today:", - &[DEMO_IMAGES[0].0.to_owned()], + &format!( + "Port the streaming fade-in veil from [transcript.rs](zeron-file:crates/ui/src/transcript.rs) into [Transcript](zeron-file:apps/ios/Zeron/Transcript/). It must never affect layout — opacity only, split at chunk boundaries. Here's how it looks today: [Image 1](zeron-image:1), and the fade is in [veil-recording.zip](zeron-attachment:2). Run {} when done.", + review.link() + ), + &[ + DEMO_IMAGES[0].0.to_owned(), + "/Users/dev/.zeron/uploads/7d03b6e1-veil-recording.zip".to_owned(), + ], ); let mut live = assistant( "m6", @@ -288,18 +306,15 @@ fn veil(host: &str, now: i64) -> Vec { "k3", ToolCall::Todo { items: vec![ - TodoItem { - text: "Snap chunk splits to grapheme clusters".into(), - done: true, - }, - TodoItem { - text: "Table test for ZWJ sequences".into(), - done: true, - }, - TodoItem { - text: "Measure veil cost on 600-turn transcript".into(), - done: false, - }, + TodoItem::new( + "Snap chunk splits to grapheme clusters", + TodoStatus::Completed, + ), + TodoItem::new("Table test for ZWJ sequences", TodoStatus::Completed), + TodoItem::new( + "Measure veil cost on 600-turn transcript", + TodoStatus::Pending, + ), ], }, false, diff --git a/crates/client/src/live/delivery.rs b/crates/client/src/live/delivery.rs new file mode 100644 index 0000000000..1ce3f249c8 --- /dev/null +++ b/crates/client/src/live/delivery.rs @@ -0,0 +1,155 @@ +//! The room outbox and host discovery are separate delivery obligations. +//! Persist the wake until the edge accepts it AND all local rows are ACKed. +//! Restart recovery opens these rooms without depending on viewport/preloads. + +use std::collections::HashSet; +use std::sync::Arc; +use std::time::Duration; + +use tokio::sync::Notify; +use tokio_util::sync::CancellationToken; + +use crate::client::ClientInner; + +pub(crate) const JOB: &str = "viewer-delivery"; +const MAX_ACTIVE: usize = 8; + +pub(crate) fn start(inner: &Arc, wake: Arc, cancel: CancellationToken) { + let weak = Arc::downgrade(inner); + crate::runtime::shared().spawn(async move { + let mut tasks = tokio::task::JoinSet::new(); + let mut active = HashSet::new(); + let mut after = String::new(); + loop { + let notified = wake.notified(); + while let Some(result) = tasks.try_join_next() { + if let Ok(chat) = result { + active.remove(&chat); + } + } + let Some(inner) = weak.upgrade() else { return }; + let Some(live) = inner.live() else { return }; + // Preserve the scan cursor while full: wrapping here would let + // the same first page reclaim every released slot. + if active.len() < MAX_ACTIVE { + match live.store.pending_viewer_deliveries(&after, 64) { + Ok(page) => { + if page.is_empty() { + after.clear(); + } + for chat in page { + if active.len() >= MAX_ACTIVE { + break; + } + after = chat.clone(); + if inner.workspace.chat(&chat).is_none() || !active.insert(chat.clone()) { + continue; + } + let inner = Arc::downgrade(&inner); + let cancel = cancel.clone(); + tasks.spawn(async move { + tokio::select! { + _ = cancel.cancelled() => {}, + // Yield stalled receipts to later destinations. + _ = tokio::time::timeout(Duration::from_secs(12), deliver(inner, &chat)) => {}, + } + chat + }); + } + } + Err(err) => tracing::warn!(%err, "delivery discovery failed"), + } + } + drop(inner); + tokio::select! { + _ = cancel.cancelled() => return, + _ = notified => {}, + _ = tokio::time::sleep(Duration::from_millis(500)) => {}, + } + } + }); +} + +async fn deliver(weak: std::sync::Weak, chat: &str) { + // Hold the session (not the account) while delivering: a detached room + // must remain alive through its outgoing ACK, including queue-only writes. + let _session = { + let Some(inner) = weak.upgrade() else { return }; + let Ok(session) = inner.resume_delivery(chat) else { + return; + }; + session + }; + let mut backoff = super::JOIN_RETRY_BASE; + loop { + let Some(inner) = weak.upgrade() else { return }; + let Some(live) = inner.live() else { return }; + let Some(row) = inner.workspace.chat(chat) else { + return; + }; + let version = match live.store.sync_job_version(chat, JOB) { + Ok(version) => version, + Err(err) => { + tracing::warn!(%err, %chat, "delivery receipt load failed"); + None + } + }; + let rows_flushed = live.store.has_pending_chat_updates(chat).ok() == Some(false); + // No registry wait: hosts durably admit a wake before CreateChat + // arrives and default missing rows to chat2 (the born-chat race). + let result = async { + let token = inner.tokens.bearer().await?; + let response = crate::auth::http() + .post(super::urls::nudge(&live.edge, &row.device_id)) + .bearer_auth(token) + .json(&serde_json::json!({ "chatId": chat })) + .timeout(Duration::from_secs(10)) + .send() + .await + .map_err(|err| crate::ClientError::Network(err.to_string()))?; + // Another owner's device (403) or a chat id its room refuses + // (400): no retry can change that, so the wake is settled. The + // receipt still waits for the outgoing rows. Unclaimed (404: + // the host has not connected yet) and 5xx retry. + if matches!(response.status().as_u16(), 400 | 403) { + tracing::warn!(%chat, status = response.status().as_u16(), "host wake refused; not retrying"); + return Ok(()); + } + if !response.status().is_success() { + return Err(crate::ClientError::Network(format!( + "host wake HTTP {}", + response.status() + ))); + } + Ok(()) + } + .await; + if result.is_ok() && rows_flushed { + match live.store.has_pending_chat_updates(chat) { + Ok(false) => { + // This wake was posted AFTER the outgoing row ACKs. A + // wake sent before them can catch up to an empty room. + if let Some(version) = version { + if let Err(err) = live.store.complete_sync_job(chat, JOB, version) { + tracing::warn!(%err, %chat, "delivery receipt completion failed"); + } else if live.store.sync_job_version(chat, JOB).ok() == Some(None) { + return; + } + } else { + return; + } + } + Ok(true) => {} + Err(err) => tracing::warn!(%err, %chat, "delivery outbox check failed"), + } + } else if let Err(err) = result { + tracing::debug!(%err, %chat, "host wake retrying"); + } + let cancel = inner.cancel.clone(); + drop(inner); + if !super::wait_backoff(&cancel, backoff).await { + return; + } + backoff = (backoff * 2).min(super::JOIN_RETRY_CAP); + } +} diff --git a/crates/client/src/live/escort.rs b/crates/client/src/live/escort.rs index 52ddbc4331..3c29aaa72d 100644 --- a/crates/client/src/live/escort.rs +++ b/crates/client/src/live/escort.rs @@ -173,7 +173,7 @@ async fn run( Ok(path) => { tracing::info!(upload = %upload_id, %path, "attachment escorted"); live.escorts.remove(upload_id); - inner.nudge_host(&meta.host_device_id, &meta.chat_id); + inner.nudge_host(&meta.chat_id); return; } Err(err) => { diff --git a/crates/client/src/live/mod.rs b/crates/client/src/live/mod.rs index 2e0d387ce6..c3becae2dc 100644 --- a/crates/client/src/live/mod.rs +++ b/crates/client/src/live/mod.rs @@ -13,6 +13,7 @@ //! short — the legacy client's one-shot chat join left chats dark until a //! relaunch. +pub(crate) mod delivery; pub(crate) mod escort; pub(crate) mod relay; pub(crate) mod room; @@ -194,10 +195,12 @@ pub(crate) struct LiveBackend { registry_retry_at: AtomicI64, registry_failure: Mutex>, registry_save: Arc, + registry_save_gate: Mutex<()>, registry_dirty: Arc, last_liveness_probe: AtomicI64, pub(crate) relay: relay::Relay, pub(crate) escorts: escort::Escorts, + pub(crate) delivery_wake: Arc, cancel: CancellationToken, } @@ -226,12 +229,14 @@ impl LiveBackend { Self { relay: relay::Relay::new(inner, &edge, bearer), escorts: escort::Escorts::new(&inner.config.data_dir), + delivery_wake: Arc::new(Notify::new()), edge, store, registry: Mutex::new(None), registry_retry_at: AtomicI64::new(0), registry_failure: Mutex::new(None), registry_save: Arc::new(Notify::new()), + registry_save_gate: Mutex::new(()), registry_dirty: Arc::new(AtomicBool::new(false)), last_liveness_probe: AtomicI64::new(0), cancel: inner.cancel.child_token(), @@ -248,6 +253,7 @@ impl LiveBackend { self.spawn_registry(inner); self.spawn_registry_saver(inner); self.escorts.respawn(inner); + delivery::start(inner, self.delivery_wake.clone(), self.cancel.clone()); } pub(crate) fn stop(&self) { @@ -357,17 +363,22 @@ impl LiveBackend { return; } let Some(inner) = inner else { return }; - let bytes = inner.workspace.mutate(|doc| doc.to_bytes()); - match bytes { - Ok(bytes) => { - if let Err(err) = self.store.save_snapshot(REGISTRY_DOC_ID, &bytes) { - tracing::warn!(error = %err, "registry snapshot save failed"); - } - } - Err(err) => tracing::warn!(error = %err, "registry export failed"), + if let Err(err) = self.persist_registry(inner) { + self.registry_dirty.store(true, Ordering::Release); + tracing::warn!(error = %err, "registry snapshot save failed"); } } + /// Serialize export and save, so a debounced older export cannot overwrite + /// a new chat row persisted before its first command. + pub(crate) fn persist_registry(&self, inner: &ClientInner) -> Result<()> { + let _gate = lock(&self.registry_save_gate); + let bytes = inner.workspace.mutate(|doc| doc.to_bytes())?; + self.store + .save_snapshot(REGISTRY_DOC_ID, &bytes) + .map_err(|err| ClientError::Storage(err.to_string())) + } + /// Persist the registry now (backgrounding). pub(crate) fn flush_registry(&self, inner: &ClientInner) { self.flush_registry_now_with(Some(inner)); diff --git a/crates/client/src/live/relay.rs b/crates/client/src/live/relay.rs index f1ae1c46fc..0e03e13bda 100644 --- a/crates/client/src/live/relay.rs +++ b/crates/client/src/live/relay.rs @@ -65,6 +65,21 @@ pub(crate) struct WatchKey { } impl Relay { + pub(crate) async fn voice_transport( + &self, + device_id: &str, + ) -> Result> { + let client = self + .links + .client(device_id) + .await + .map_err(|e| map_rpc(device_id, methods::VOICE_CAPABILITIES_V2, e))?; + Ok(Arc::new(zeron_voice_session::RpcTransport { + client, + host: device_id.into(), + })) + } + pub(crate) fn new(inner: &Arc, edge: &str, bearer: Bearer) -> Self { let weak = Arc::downgrade(inner); let started = now_ms(); diff --git a/crates/client/src/live/room.rs b/crates/client/src/live/room.rs index f5c1381235..d335d3efb3 100644 --- a/crates/client/src/live/room.rs +++ b/crates/client/src/live/room.rs @@ -306,11 +306,13 @@ pub(crate) struct ChatUrl { pub(crate) edge: String, pub(crate) chat_id: String, pub(crate) device_id: String, + pub(crate) host_device_id: String, } impl UrlProvider for ChatUrl { fn url(&self) -> BoxFuture<'static, Result> { let bearer = self.bearer.clone(); + let host = self.host_device_id.clone(); let (edge, chat, device) = ( self.edge.clone(), self.chat_id.clone(), @@ -318,7 +320,11 @@ impl UrlProvider for ChatUrl { ); Box::pin(async move { let token = bearer.get().await?; - Ok(urls::chat_ws(&edge, &chat, &token, &device)) + Ok(format!( + "{}&hostDevice={}", + urls::chat_ws(&edge, &chat, &token, &device), + urls::encode(&host) + )) }) } } @@ -330,6 +336,7 @@ pub(crate) struct ChatHttp { pub(crate) edge: String, pub(crate) chat_id: String, pub(crate) device_id: String, + pub(crate) host_device_id: String, } fn http_err(err: reqwest::Error) -> SyncError { @@ -435,7 +442,11 @@ impl ChatTransport for ChatHttp { bytes: Vec, ) -> BoxFuture<'static, Result> { let bearer = self.bearer.clone(); - let url = urls::chat_push(&self.edge, &self.chat_id, &batch_id, &self.device_id); + let url = format!( + "{}&hostDevice={}", + urls::chat_push(&self.edge, &self.chat_id, &batch_id, &self.device_id), + urls::encode(&self.host_device_id) + ); Box::pin(async move { let _permit = zeron_sync::budget::shared() .http(zeron_sync::budget::Priority::Interactive) @@ -493,6 +504,7 @@ pub(crate) struct RoomDeps { pub bearer: Bearer, pub edge: String, pub device_id: String, + pub host_device_id: String, pub store: Arc, /// Remote change landed (schedule a coalesced session refresh). pub on_applied: Arc, @@ -564,12 +576,14 @@ impl Room { edge: deps.edge.clone(), chat_id: self.chat_id.clone(), device_id: deps.device_id.clone(), + host_device_id: deps.host_device_id.clone(), }); let http = Arc::new(ChatHttp { bearer: deps.bearer.clone(), edge: deps.edge.clone(), chat_id: self.chat_id.clone(), device_id: deps.device_id.clone(), + host_device_id: deps.host_device_id.clone(), }); let weak = Arc::downgrade(self); let cancel = self.cancel.clone(); diff --git a/crates/client/src/session/mod.rs b/crates/client/src/session/mod.rs index 0c92ef5fcb..f876a3bc1b 100644 --- a/crates/client/src/session/mod.rs +++ b/crates/client/src/session/mod.rs @@ -343,6 +343,7 @@ impl SessionCore { bearer: crate::live::LiveBackend::bearer(client), edge: live.edge.clone(), device_id: client.config.device_id.clone(), + host_device_id: chat.device_id.clone(), store: live.store.clone(), on_applied, on_status, @@ -381,7 +382,20 @@ impl SessionCore { ) -> Result { let result = { let _gate = lock(&self.write_gate); - f(&self.doc)? + let client = self.client()?; + if let Some(live) = client.live() { + // A new chat's registry row must survive alongside its outbox: + // recovery needs its host and room generation before any UI opens it. + live.persist_registry(&client)?; + } + let result = f(&self.doc)?; + if let Some(live) = client.live() { + live.store + .schedule_sync_job(&self.chat_id, crate::live::delivery::JOB) + .map_err(|err| ClientError::Storage(err.to_string()))?; + live.delivery_wake.notify_one(); + } + result }; self.refresh(); Ok(result) @@ -449,9 +463,8 @@ impl SessionCore { .entries() .last() .is_some_and(|e| e.is_streaming()); - let sending = st.pending.iter().any(|p| p.state == SendState::Sending); let live = LiveFlags { - working: indicator_working || streaming || sending, + working: indicator_working || streaming, working_since_ms, }; let live_changed = previous.working != live.working diff --git a/crates/client/src/workspace/mod.rs b/crates/client/src/workspace/mod.rs index a0ad5d3e50..2b919de375 100644 --- a/crates/client/src/workspace/mod.rs +++ b/crates/client/src/workspace/mod.rs @@ -1,8 +1,8 @@ //! The workspace registry replica and its derived [`WorkspaceSnapshot`]. //! //! The replica is a [`RegistryDoc`] shared (`Arc>`) with the -//! `zeron_sync::RegistryClient` in live mode; Demo mode settles local writes -//! through an in-process stand-in for the registry room. Reads materialize +//! `zeron_sync::RegistryClient` in live mode; Demo mode's replica is +//! local-only (writes fold straight into its rows). Reads materialize //! the rows once per registry *generation* (cached), so the 1 Hz time-driven //! re-derivation (staleness, presence expiry, send grace) never re-decodes. diff --git a/crates/client/src/workspace/view.rs b/crates/client/src/workspace/view.rs index e7c7117ca2..ccc490b302 100644 --- a/crates/client/src/workspace/view.rs +++ b/crates/client/src/workspace/view.rs @@ -15,7 +15,7 @@ use std::sync::Arc; use chrono::{DateTime, Utc}; use zeron_doc::WorkspaceState; -use zeron_proto::view::{attention_rank, display_status}; +use zeron_proto::view::{attention_rank, display_status, project_key, representative_space}; use zeron_proto::{ ChangeRequestState, ChangeRequestSummary, Chat, ChatIndicator, CheckoutChangeRequestStatus, Device, Session, SidebarPreferences, Space, @@ -32,9 +32,9 @@ pub const PROJECT_COLOR_COUNT: u32 = 8; /// monogram tone: 32-bit FNV-1a of the project's path (`"home"` without a /// project), so a project has the same color on every device. pub fn project_color_index(space_path: &str) -> u32 { - let hash = space_path - .bytes() - .fold(2_166_136_261u32, |h, b| (h ^ u32::from(b)).wrapping_mul(16_777_619)); + let hash = space_path.bytes().fold(2_166_136_261u32, |h, b| { + (h ^ u32::from(b)).wrapping_mul(16_777_619) + }); hash % PROJECT_COLOR_COUNT } @@ -80,9 +80,8 @@ pub struct SessionRow { pub branch: Option, pub cwd: Option, pub indicator: ChatIndicator, - /// The host-reported status alone (45s staleness-gated) — `indicator` - /// minus the "a send of mine is in flight" override. What stop/busy - /// logic keys off (a send parked for an offline host is not a turn). + /// Host-reported status (45s staleness-gated). Stop/busy logic keys off + /// this; an outgoing send is not evidence of a running turn. pub host_indicator: ChatIndicator, /// Run start of the live turn while Working/AwaitingInput. pub working_since_ms: Option, @@ -131,6 +130,13 @@ pub struct ProjectView { pub device_online: bool, pub git_detected: bool, pub created_at_ms: i64, + /// The repository project this checkout belongs to + /// ([`zeron_proto::view::project_key`]): clones and worktrees of one + /// repository, on any device, share it. + pub group_key: String, + /// The group's name — its representative checkout's — shared by every + /// member, like `color_index`. + pub group_name: String, /// Most urgent indicator among its active sessions (Idle when none). pub indicator: ChatIndicator, pub unseen_count: u32, @@ -411,6 +417,7 @@ fn hash_row(row: &SessionRow) -> u64 { } struct RowContext<'a> { + all_spaces: &'a [Space], spaces: HashMap<&'a str, &'a Space>, devices: HashMap<&'a str, &'a Device>, sessions: HashMap<&'a str, &'a Session>, @@ -423,21 +430,13 @@ fn build_row(chat: &Chat, rc: &RowContext<'_>, cx: &DeriveContext<'_>) -> Arc session .and_then(|s| s.started_at) .map(|t| t.timestamp_millis()), _ => None, }; - // A send in flight reads as Working (desktop `display_status_for`). - if matches!(send_state, Some(SendState::Sending | SendState::Queued)) - && indicator != ChatIndicator::Working - && indicator != ChatIndicator::AwaitingInput - { - indicator = ChatIndicator::Working; - working_since_ms = None; - } let project = chat .space_id .as_deref() @@ -445,7 +444,8 @@ fn build_row(chat: &Chat, rc: &RowContext<'_>, cx: &DeriveContext<'_>) -> Arc = state .chats .iter() - .filter(|c| !c.archived && c.parent_chat_id.is_none()) + .filter(|c| !c.archived && c.is_top_level()) .filter(|c| { c.space_id .as_deref() @@ -617,11 +618,12 @@ pub(crate) fn derive( .map(|r| r.indicator) .min_by_key(|i| attention_rank(*i)) .unwrap_or(ChatIndicator::Idle); + let representative = representative_space(&state.spaces, space); ProjectView { id: space.id.clone(), name: space.display_name().to_owned(), path: space.path.clone(), - color_index: project_color_index(&space.path), + color_index: project_color_index(&representative.path), device_id: space.device_id.clone(), device_name: rc .devices @@ -635,6 +637,8 @@ pub(crate) fn derive( ), git_detected: space.git_detected, created_at_ms: space.created_at.timestamp_millis(), + group_key: project_key(space), + group_name: representative.display_name().to_owned(), indicator, unseen_count: sessions.iter().filter(|r| r.unseen).count() as u32, sessions, @@ -661,7 +665,7 @@ pub(crate) fn derive( let mut archived_chats: Vec<&Chat> = state .chats .iter() - .filter(|c| c.archived && c.parent_chat_id.is_none()) + .filter(|c| c.archived && c.is_top_level()) .collect(); sort_recency(&mut archived_chats); let archived: Vec> = archived_chats.iter().map(|c| row(c)).collect(); @@ -751,6 +755,9 @@ fn snapshot_hash(s: &WorkspaceSnapshot) -> u64 { project.id.hash(&mut h); project.name.hash(&mut h); project.path.hash(&mut h); + project.color_index.hash(&mut h); + project.group_key.hash(&mut h); + project.group_name.hash(&mut h); project.device_name.hash(&mut h); project.device_online.hash(&mut h); project.git_detected.hash(&mut h); diff --git a/crates/client/tests/demo.rs b/crates/client/tests/demo.rs index 82a6811ea2..100765a737 100644 --- a/crates/client/tests/demo.rs +++ b/crates/client/tests/demo.rs @@ -97,6 +97,14 @@ fn front_page_mirrors_the_desktop_sidebar() { let zeron = ws.project("space-zeron").unwrap(); assert_eq!(zeron.indicator, ChatIndicator::AwaitingInput); assert!(zeron.unseen_count >= 2); + // Clones of one repository are one project, with one name and color. + let clone = ws.project("space-zeron-vps").unwrap(); + assert_eq!(clone.group_key, zeron.group_key); + assert_eq!(clone.group_name, "zeron"); + assert_eq!(clone.color_index, zeron.color_index); + let cjk = ws.session("chat-cjk").unwrap(); + assert_eq!(cjk.project.as_ref().unwrap().color_index, zeron.color_index); + assert_ne!(ws.project("space-edge").unwrap().group_key, zeron.group_key); assert_eq!(ids(&ws.projectless), ["chat-home"]); assert!(ws.devices.iter().any(|d| d.is_self && d.id == "ios-test")); assert!(!ws.device("dev-studio").unwrap().online); @@ -175,7 +183,10 @@ fn send_streams_a_reply_and_adopts_the_echo() { assert_eq!(echo.pending.len(), 1); assert_eq!(echo.pending[0].message_id, message_id); assert!(echo.entry(&message_id).unwrap().echo.is_some()); - assert!(echo.working, "a send in flight reads as working"); + assert!( + !echo.working, + "a pending send is not confirmed host activity" + ); let (tx, rx) = std::sync::mpsc::channel(); let _watch = session.watch(move |snap| { @@ -249,8 +260,7 @@ fn questions_answer_through_respond_input() { }); // The host publishes the question, then flips the chat's status. wait_for("awaiting input", Duration::from_secs(5), || { - client.workspace().session("chat-deploy").unwrap().indicator - == ChatIndicator::AwaitingInput + client.workspace().session("chat-deploy").unwrap().indicator == ChatIndicator::AwaitingInput }); let input = session.composer().open_input.clone().unwrap(); session diff --git a/crates/client/tests/live.rs b/crates/client/tests/live.rs index 27fff96daa..9a6cfbfb28 100644 --- a/crates/client/tests/live.rs +++ b/crates/client/tests/live.rs @@ -58,6 +58,7 @@ fn host_rows(now: chrono::DateTime) -> (Device, Space, Chat) { git_detected: true, git_checked_at: None, checkout_id: None, + repository_id: None, created_at: now, }; let chat = Chat { @@ -326,7 +327,18 @@ async fn session_send_round_trips_through_the_chat_room() { panic!("idle chat starts a turn"); }; assert_eq!(session.snapshot().pending.len(), 1); - assert!(session.snapshot().working, "in flight to a live host"); + assert!( + !session.snapshot().working, + "sending is not confirmed host activity" + ); + assert_eq!( + session.composer().send_state, + Some(zeron_client::SendState::Sending) + ); + assert_ne!( + client.workspace().session(CHAT).unwrap().indicator, + ChatIndicator::Working + ); // The command row reaches the room; the host answers. let host_doc = LoroDoc::new(); @@ -413,6 +425,15 @@ async fn sends_survive_a_room_outage_and_deliver_on_rejoin() { tokio::time::sleep(Duration::from_millis(1500)).await; assert!(edge.rows(CHAT).is_empty(), "room refused"); assert_eq!(session.snapshot().pending.len(), 1, "durable echo stays"); + let store = zeron_sync::DocsStore::open(dir.path()).unwrap(); + assert!( + store + .sync_job_version(CHAT, "viewer-delivery") + .unwrap() + .is_some(), + "an early successful wake cannot retire unpushed rows" + ); + assert_eq!(edge.last_nudge_row_count(CHAT), Some(0)); // The room comes back: the join loop retries (never one-shot) and the // outbox delivers. @@ -425,6 +446,19 @@ async fn sends_survive_a_room_outage_and_deliver_on_rejoin() { ); tokio::time::sleep(Duration::from_millis(100)).await; } + let start = Instant::now(); + while store + .sync_job_version(CHAT, "viewer-delivery") + .unwrap() + .is_some() + { + assert!(start.elapsed() < Duration::from_secs(10)); + tokio::time::sleep(Duration::from_millis(20)).await; + } + assert!( + edge.last_nudge_row_count(CHAT).unwrap() > 0, + "the final host wake follows command publication" + ); let host_doc = LoroDoc::new(); let start = Instant::now(); while host_answers(&edge, &host_doc).is_none() { @@ -811,5 +845,266 @@ async fn phone_born_sessions_reach_the_host_before_their_first_command() { ); tokio::time::sleep(Duration::from_millis(20)).await; } + assert_eq!(edge.host_hint(&chat_id).as_deref(), Some(HOST)); + client.shutdown(); +} + +// The wake must survive independently of the chat row ACK: the host may have +// no room open yet, even though the edge already holds every command byte. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn acked_command_recovers_failed_host_wake_without_opening_the_chat() { + let edge = MockEdge::start().await; + let _host = HostRegistry::start(&edge).await; + edge.nudge_status(500); + let dir = tempfile::tempdir().unwrap(); + let client = phone(&edge, dir.path()); + await_chat(&client, CHAT).await; + let session = client.open_session(CHAT).unwrap(); + session + .send(SendRequest::text("wake the cold host")) + .unwrap(); + let store = zeron_sync::DocsStore::open(dir.path()).unwrap(); + let start = Instant::now(); + while edge.rows(CHAT).is_empty() || store.has_pending_chat_updates(CHAT).unwrap() { + assert!(start.elapsed() < Duration::from_secs(10)); + tokio::time::sleep(Duration::from_millis(20)).await; + } + // A 500 used to silently abandon the wake after the first attempt. + let start = Instant::now(); + while edge.nudges(CHAT) < 2 { + assert!( + start.elapsed() < Duration::from_secs(5), + "host wake was not retried" + ); + tokio::time::sleep(Duration::from_millis(20)).await; + } + assert!( + store + .sync_job_version(CHAT, "viewer-delivery") + .unwrap() + .is_some() + ); + client.shutdown(); + drop(session); + drop(client); + let before = edge.nudges(CHAT); + edge.nudge_status(200); + let restarted = phone(&edge, dir.path()); + // No open_session, viewport attach or preload: recovery discovers the job. + let start = Instant::now(); + while edge.nudges(CHAT) <= before + || store + .sync_job_version(CHAT, "viewer-delivery") + .unwrap() + .is_some() + { + assert!( + start.elapsed() < Duration::from_secs(10), + "ACKed command lost its host wake on restart" + ); + tokio::time::sleep(Duration::from_millis(20)).await; + } + restarted.shutdown(); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_refused_host_wake_settles_instead_of_retrying_forever() { + let edge = MockEdge::start().await; + let _host = HostRegistry::start(&edge).await; + // Another owner's device: no retry can change a 403. + edge.nudge_status(403); + let dir = tempfile::tempdir().unwrap(); + let client = phone(&edge, dir.path()); + await_chat(&client, CHAT).await; + let session = client.open_session(CHAT).unwrap(); + session + .send(SendRequest::text("to a foreign host")) + .unwrap(); + let store = zeron_sync::DocsStore::open(dir.path()).unwrap(); + let start = Instant::now(); + while edge.rows(CHAT).is_empty() + || store.has_pending_chat_updates(CHAT).unwrap() + || store + .sync_job_version(CHAT, "viewer-delivery") + .unwrap() + .is_some() + { + assert!( + start.elapsed() < Duration::from_secs(10), + "a refused wake kept its receipt" + ); + tokio::time::sleep(Duration::from_millis(20)).await; + } + let settled = edge.nudges(CHAT); + tokio::time::sleep(Duration::from_secs(2)).await; + assert_eq!(edge.nudges(CHAT), settled, "a refused wake was retried"); + client.shutdown(); +} + +async fn await_chat(client: &Client, chat: &str) { + let start = Instant::now(); + while client.workspace().session(chat).is_none() { + assert!(start.elapsed() < Duration::from_secs(10)); + tokio::time::sleep(Duration::from_millis(20)).await; + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn immediate_exit_after_first_send_recovers_new_chat_on_each_host_platform() { + for (platform, harness) in [ + ("windows", HarnessId::Codex), + ("macos", HarnessId::Cursor), + ("linux", HarnessId::ClaudeCode), + ] { + let edge = MockEdge::start().await; + let host = HostRegistry::start(&edge).await; + let (mut device, _, _) = host_rows(Utc::now()); + device.platform = platform.into(); + host.doc.lock().unwrap().upsert_device(&device).unwrap(); + host.client.nudge(); + edge.refuse_chat_joins(true); + edge.nudge_status(500); + let dir = tempfile::tempdir().unwrap(); + let client = phone(&edge, dir.path()); + await_chat(&client, CHAT).await; + let chat = client + .create_session(zeron_client::NewSession { + target: zeron_client::SessionTarget::Project { + space_id: SPACE.into(), + }, + config: Some(ChatConfig { + harness, + model: None, + reasoning: None, + model_options: Default::default(), + sandbox: SandboxLevel::WorkspaceWrite, + }), + branch: None, + cwd: None, + title: None, + }) + .unwrap(); + let session = client.open_session(&chat).unwrap(); + let SendOutcome::Started { message_id } = session + .send(SendRequest::text("first message before closing")) + .unwrap() + else { + panic!() + }; + // No background flush, debounce sleep, or incoming host acknowledgement. + let store = zeron_sync::DocsStore::open(dir.path()).unwrap(); + let bytes = store + .load_snapshot(zeron_doc::REGISTRY_DOC_ID) + .unwrap() + .unwrap(); + let saved = RegistryDoc::from_bytes(&bytes, "ios-live").unwrap(); + assert!( + saved.chat(&chat).unwrap().is_some(), + "{platform}: first send returned before its registry row was saved" + ); + assert!(!session.snapshot().working); + client.shutdown(); + drop(session); + drop(client); + edge.refuse_chat_joins(false); + edge.nudge_status(200); + let restarted = phone(&edge, dir.path()); + assert!( + restarted.workspace().session(&chat).is_some(), + "{platform}: new registry row was not durable" + ); + let store = zeron_sync::DocsStore::open(dir.path()).unwrap(); + let start = Instant::now(); + while edge.rows(&chat).is_empty() + || edge.nudges(&chat) == 0 + || store.has_pending_chat_updates(&chat).unwrap() + || store + .sync_job_version(&chat, "viewer-delivery") + .unwrap() + .is_some() + { + assert!( + start.elapsed() < Duration::from_secs(15), + "{platform}: first send was stranded without a UI open" + ); + tokio::time::sleep(Duration::from_millis(20)).await; + } + let doc = SessionDoc::from_doc(LoroDoc::new()); + for row in edge.rows(&chat) { + doc.doc().import(&row.bytes).unwrap(); + } + let commands = doc.read_commands().unwrap(); + assert_eq!( + commands.len(), + 1, + "{platform}: recovery duplicated the command" + ); + assert!( + matches!(&commands[0].payload, SessionCommandPayload::Run { request, message_id: id } if id.as_str() == message_id.as_str() && request.harness == Some(harness)) + ); + restarted.shutdown(); + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn stalled_wakes_yield_delivery_capacity_to_later_chats() { + let edge = MockEdge::start().await; + let host = HostRegistry::start(&edge).await; + edge.nudge_status(500); + edge.nudge_status_for("z-healthy", 200); + for n in 0..9 { + let (_, _, mut row) = host_rows(Utc::now()); + row.id = if n == 8 { + "z-healthy".into() + } else { + format!("a-stalled-{n}") + }; + host.doc.lock().unwrap().upsert_chat(&row).unwrap(); + } + host.client.nudge(); + let dir = tempfile::tempdir().unwrap(); + let client = phone(&edge, dir.path()); + await_chat(&client, "z-healthy").await; + for n in 0..8 { + client + .open_session(&format!("a-stalled-{n}")) + .unwrap() + .send(SendRequest::text("stalled wake")) + .unwrap(); + } + let start = Instant::now(); + while edge.nudges("a-stalled-7") == 0 { + assert!( + start.elapsed() < Duration::from_secs(5), + "delivery slots did not fill" + ); + tokio::time::sleep(Duration::from_millis(20)).await; + } + client + .open_session("z-healthy") + .unwrap() + .send(SendRequest::text("healthy destination")) + .unwrap(); + let store = zeron_sync::DocsStore::open(dir.path()).unwrap(); + let start = Instant::now(); + while edge.nudges("z-healthy") == 0 + || store + .sync_job_version("z-healthy", "viewer-delivery") + .unwrap() + .is_some() + { + assert!( + start.elapsed() < Duration::from_secs(16), + "stalled wakes starved the next delivery" + ); + tokio::time::sleep(Duration::from_millis(20)).await; + } + assert!( + store + .sync_job_version("a-stalled-0", "viewer-delivery") + .unwrap() + .is_some(), + "yielding must preserve the stalled obligation" + ); client.shutdown(); } diff --git a/crates/client/tests/support/mock_edge.rs b/crates/client/tests/support/mock_edge.rs index 17397d95cc..43c73bae89 100644 --- a/crates/client/tests/support/mock_edge.rs +++ b/crates/client/tests/support/mock_edge.rs @@ -19,6 +19,7 @@ use std::collections::HashMap; use std::sync::{Arc, Mutex}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; use futures::{SinkExt, StreamExt}; use serde_json::json; @@ -52,6 +53,10 @@ struct Shared { /// Refuse chat2 joins (outage simulation). refuse_chat: Mutex, relays: Mutex>, + nudge_status: Mutex, + nudge_status_by_chat: Mutex>, + nudges: Mutex>, + host_hints: Mutex>, } enum Out { @@ -85,16 +90,88 @@ impl MockEdge { sockets: Mutex::new(HashMap::new()), refuse_chat: Mutex::new(false), relays: Mutex::new(HashMap::new()), + nudge_status: Mutex::new(200), + nudge_status_by_chat: Mutex::new(HashMap::new()), + nudges: Mutex::new(Vec::new()), + host_hints: Mutex::new(HashMap::new()), }); let accept_shared = shared.clone(); let task = tokio::spawn(async move { loop { - let Ok((stream, _)) = listener.accept().await else { + let Ok((mut stream, _)) = listener.accept().await else { return; }; let shared = accept_shared.clone(); let registry_url = registry_url.clone(); tokio::spawn(async move { + let mut first = [0u8; 4]; + if stream.peek(&mut first).await.is_ok() && first[0] == b'P' { + let mut request = Vec::new(); + let mut buf = [0u8; 4096]; + loop { + let Ok(n) = stream.read(&mut buf).await else { + return; + }; + if n == 0 { + return; + } + request.extend_from_slice(&buf[..n]); + let Some(end) = request.windows(4).position(|b| b == b"\r\n\r\n") + else { + continue; + }; + let headers = String::from_utf8_lossy(&request[..end]); + let length = headers + .lines() + .find_map(|line| { + let (key, value) = line.split_once(':')?; + key.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse::().ok()) + .flatten() + }) + .unwrap_or(0); + if request.len() < end + 4 + length { + continue; + } + if headers + .lines() + .next() + .is_some_and(|l| l.contains("/nudge ")) + { + let mut nudge_chat = None; + if let Ok(body) = serde_json::from_slice::( + &request[end + 4..end + 4 + length], + ) { + if let Some(chat) = body["chatId"].as_str() { + nudge_chat = Some(chat.to_owned()); + let rows = shared + .rooms + .lock() + .unwrap() + .get(chat) + .map_or(0, |r| r.rows.len()); + shared.nudges.lock().unwrap().push((chat.to_owned(), rows)); + } + } + let status = nudge_chat + .as_ref() + .and_then(|chat| { + shared + .nudge_status_by_chat + .lock() + .unwrap() + .get(chat) + .copied() + }) + .unwrap_or_else(|| *shared.nudge_status.lock().unwrap()); + let response = format!( + "HTTP/1.1 {status} Test\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{{}}" + ); + let _ = stream.write_all(response.as_bytes()).await; + } + return; + } + } let path = Arc::new(Mutex::new(String::new())); let seen = path.clone(); let callback = move |request: &Request, response: Response| { @@ -121,6 +198,13 @@ impl MockEdge { if *shared.refuse_chat.lock().unwrap() { return; } + if let Some(host) = query.get("hostDevice") { + shared + .host_hints + .lock() + .unwrap() + .insert(chat.to_string(), host.clone()); + } serve_chat(ws, chat.to_string(), shared).await } _ => {} @@ -136,6 +220,43 @@ impl MockEdge { } } + pub fn nudge_status(&self, status: u16) { + *self.shared.nudge_status.lock().unwrap() = status; + } + + pub fn host_hint(&self, chat: &str) -> Option { + self.shared.host_hints.lock().unwrap().get(chat).cloned() + } + + pub fn nudge_status_for(&self, chat: &str, status: u16) { + self.shared + .nudge_status_by_chat + .lock() + .unwrap() + .insert(chat.to_owned(), status); + } + + pub fn nudges(&self, chat: &str) -> usize { + self.shared + .nudges + .lock() + .unwrap() + .iter() + .filter(|(id, _)| id == chat) + .count() + } + + pub fn last_nudge_row_count(&self, chat: &str) -> Option { + self.shared + .nudges + .lock() + .unwrap() + .iter() + .rev() + .find(|(id, _)| id == chat) + .map(|(_, rows)| *rows) + } + pub fn edge_url(&self) -> String { format!("http://127.0.0.1:{}", self.port) } diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 8c52c58501..f03251e5a2 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -16,6 +16,7 @@ pub mod rebuild; pub mod registry; pub mod schema; pub mod transcript_delta; +pub mod voice; pub mod workspace; pub use commands::*; diff --git a/crates/doc/src/registry.rs b/crates/doc/src/registry.rs index 255427e0ac..90f48b8755 100644 --- a/crates/doc/src/registry.rs +++ b/crates/doc/src/registry.rs @@ -6,9 +6,17 @@ //! `rows` frames (the server merges; clients never re-merge server rows); //! - `pending` op batches — local writes not yet acked, replayed over the //! authoritative rows for every read (optimistic overlay), re-pushed on -//! reconnect (idempotent by strict-`>` clock compare); +//! reconnect (idempotent by strict-`>` clock compare). Repeated writes to +//! one row in the unsent tail coalesce ([`coalesce_ops`]) wherever the +//! room's outcome — rows AND push notifications — stays identical, so an +//! offline queue grows with rows touched and status transitions, not with +//! writes made (see `RegistryDoc::coalesce_unsent`); //! - an HLC clock stamping every local write. //! +//! A local-only replica ([`RegistryDoc::set_local_only`]) has no server to +//! ack anything: its writes fold straight into `authoritative` and `pending` +//! stays empty. +//! //! The merge function [`apply_op`] mirrors `edge/src/registry-core.ts` 1:1 — //! the shared test vectors live in both files; change them together. The //! typed API mirrors the old `WorkspaceDoc` surface so `WorkspaceHost` is a @@ -290,6 +298,111 @@ pub fn row_to_seed_op(row: &RegistryRow) -> RowOp { } } +/// One op with EXACTLY the effect of applying `a` then `b` (same row) to any +/// row state — live, tombstoned, or missing — or `None` when no single op +/// can express the pair. Per-field writes always merge (newest clock wins, a +/// tie keeps `a`, as in [`apply_op`]); what can break exactness is +/// existence: an update never creates/revives, and an upsert revives a +/// tombstone on its own `hlc` with ALL its fields. So: +/// - `b` update: `a` alone decides existence → `a`'s kind and revival clock; +/// - `b` upsert: exact when `a` (an upsert) can never revive where `b` would +/// not (`b.hlc <= a.hlc`), or when `b` overwrites every field `a` wrote +/// with a newer clock — whatever `a` revived or created, `b` replaces; +/// - deletes compare against the row's clocks at apply time and never fold. +/// +/// The steady-state writers (full-row session/chat/device upserts, field +/// updates) all hit the exact cases. A merge whose result the room would +/// reject as oversized ([`MAX_OP_BYTES`]) is refused too: the inputs stay +/// separate and each still fits. +pub fn coalesce_ops(a: &RowOp, b: &RowOp) -> Option { + if a.kind != b.kind || a.id != b.id { + return None; + } + let (op, hlc) = match (a.op, b.op) { + (OpKind::Delete, _) | (_, OpKind::Delete) => return None, + (_, OpKind::Update) => (a.op, a.hlc.clone()), + (OpKind::Upsert, OpKind::Upsert) if b.hlc <= a.hlc || shadows(b, a) => { + (OpKind::Upsert, a.hlc.clone().max(b.hlc.clone())) + } + (OpKind::Update, OpKind::Upsert) if shadows(b, a) => (OpKind::Upsert, b.hlc.clone()), + _ => return None, + }; + let mut writes: BTreeMap = BTreeMap::new(); + for (key, value) in a.set.iter().flatten() { + writes.insert(key.clone(), (value.clone(), a.clock_for(key).to_string())); + } + for (key, value) in b.set.iter().flatten() { + let clock = b.clock_for(key); + if writes + .get(key) + .is_none_or(|(_, prev)| hlc_newer(clock, Some(prev))) + { + writes.insert(key.clone(), (value.clone(), clock.to_string())); + } + } + // Every write keeps its ORIGINAL clock; only those differing from the + // op's `hlc` need an explicit override. + let mut set = BTreeMap::new(); + let mut clocks = BTreeMap::new(); + for (key, (value, clock)) in writes { + if clock != hlc { + clocks.insert(key.clone(), clock); + } + set.insert(key, value); + } + let merged = RowOp { + kind: a.kind.clone(), + id: a.id.clone(), + op, + set: Some(set), + hlc, + clocks: (!clocks.is_empty()).then_some(clocks), + }; + fits_op_budget(&merged).then_some(merged) +} + +/// Per-op serialized budget — the 1:1 mirror of `MAX_OP_BYTES` in +/// `edge/src/registry-core.ts`; `validateOp` rejects (and with it the whole +/// batch) any op whose `JSON.stringify` is longer. +pub const MAX_OP_BYTES: usize = 16 * 1024; + +/// Whether `op` passes the room's size check. serde_json's UTF-8 byte count +/// is never below `JSON.stringify`'s UTF-16 length for the same value, so +/// this errs toward "too large". +fn fits_op_budget(op: &RowOp) -> bool { + serde_json::to_vec(op).is_ok_and(|bytes| bytes.len() <= MAX_OP_BYTES) +} + +/// The `sessions` fields the room's push notifications switch on +/// (`notificationFor` in `edge/src/push-notify.ts`): the status indicator +/// and the completed-turn marker. `updatedAt` only gates staleness, so the +/// 10 s keepalive touch differs from its predecessor only there. +fn session_signal(op: &RowOp) -> (Option<&Value>, Option<&Value>) { + let field = |name: &str| op.set.as_ref().and_then(|s| s.get(name)); + (field("status"), field("lastCompletedTurn")) +} + +/// A `chats` op that can change what a session notification sees +/// (`chatForNotification`: existence/deletion, side-chat origin, archived, +/// title). Freshness writes (`lastMessage*`, `lastSeenAt`, …) cannot. +fn chat_gates_notifications(op: &RowOp) -> bool { + op.op != OpKind::Update + || op.set.as_ref().is_some_and(|s| { + ["title", "archived", "parentChatId"] + .iter() + .any(|k| s.contains_key(*k)) + }) +} + +/// `b` writes every field `a` writes, each with a strictly newer clock. +fn shadows(b: &RowOp, a: &RowOp) -> bool { + let b_set = b.set.as_ref(); + a.set.iter().flatten().all(|(key, _)| { + b_set.is_some_and(|s| s.contains_key(key)) + && hlc_newer(b.clock_for(key), Some(a.clock_for(key))) + }) +} + // ── pending batches ───────────────────────────────────────────────────────── #[derive(Debug, Clone, Serialize, Deserialize)] @@ -337,6 +450,10 @@ struct PersistedState { server_seq: u64, gc_floor: u64, clock: HlcClock, + /// See [`RegistryDoc::local_writes`]. Additive — older snapshots default + /// to `false`. + #[serde(default)] + local_writes: bool, rows: Vec, pending: Vec, } @@ -351,6 +468,15 @@ pub struct RegistryDoc { gc_floor: u64, clock: HlcClock, pending: Vec, + /// No server will ever ack (local profile): writes fold straight into + /// `authoritative` — queueing them grew `pending` without bound (a field + /// snapshot held 178k never-acked batches for 289 rows). Runtime mode, + /// set by the host; not persisted. + local_only: bool, + /// Writes were folded into `authoritative` that no server has seen. If + /// an edge ever attaches to this replica, every row re-seeds once (see + /// [`Self::set_local_only`]). Persisted. + local_writes: bool, /// Bumped on every mutation (local or applied) — the engine host converts /// this into watch-channel publishes and snapshot debounces. generation: u64, @@ -365,6 +491,8 @@ impl RegistryDoc { gc_floor: 0, clock: HlcClock::default(), pending: Vec::new(), + local_only: false, + local_writes: false, generation: 0, } } @@ -387,6 +515,59 @@ impl RegistryDoc { self.pending.len() } + /// Total ops across pending batches (introspection/tests). + pub fn pending_ops_len(&self) -> usize { + self.pending.iter().map(|b| b.ops.len()).sum() + } + + /// Switch local-only mode (see the `local_only` field). Entering it folds + /// any queued batches into `authoritative` — nothing can be in flight + /// without a transport. Leaving it after local-only writes re-seeds every + /// row with its ORIGINAL clocks (the server may hold some of them with + /// older values; a full `state` replace would otherwise drop the newer + /// local ones), exactly like the server-behind recovery in + /// [`Self::apply_state`]. Call before a transport attaches. + pub fn set_local_only(&mut self, local_only: bool) { + self.local_only = local_only; + if local_only { + let pending = std::mem::take(&mut self.pending); + if pending.is_empty() { + return; + } + let ops: usize = pending.iter().map(|b| b.ops.len()).sum(); + tracing::info!( + batches = pending.len(), + ops, + "registry: folded queued writes into the local-only replica" + ); + self.generation += 1; + self.local_writes = true; + for batch in pending { + for op in &batch.ops { + self.fold_op(op); + } + } + } else if self.local_writes { + self.local_writes = false; + let seed: Vec = self + .authoritative + .values() + .flat_map(|m| m.values()) + .map(row_to_seed_op) + .collect(); + self.enqueue_ops(seed); + } + } + + /// Apply one op straight to the authoritative row — the same step the + /// overlay replays, made permanent. + fn fold_op(&mut self, op: &RowOp) { + let row = self.authoritative.get(&op.kind).and_then(|m| m.get(&op.id)); + if let (Some(next), true) = apply_op(row, op) { + self.put_authoritative(next); + } + } + // ── persistence ───────────────────────────────────────────────────────── // (see PersistedState::resync_epoch) @@ -404,6 +585,7 @@ impl RegistryDoc { server_seq: self.server_seq, gc_floor: self.gc_floor, clock: self.clock.clone(), + local_writes: self.local_writes, rows, pending: self.pending.clone(), }; @@ -432,6 +614,7 @@ impl RegistryDoc { } doc.gc_floor = state.gc_floor; doc.clock = state.clock; + doc.local_writes = state.local_writes; doc.pending = state.pending; for row in state.rows { doc.authoritative @@ -439,6 +622,18 @@ impl RegistryDoc { .or_default() .insert(row.id.clone(), row); } + // Nothing is in flight after a load, so the whole queue coalesces — + // this shrinks snapshots bloated before coalescing existed (178k + // batches → one op per touched row). The host persists the result. + let before = doc.pending_ops_len(); + if doc.coalesce_unsent() > 0 { + tracing::info!( + before, + after = doc.pending_ops_len(), + batches = doc.pending.len(), + "registry: compacted queued writes on load" + ); + } Ok(doc) } @@ -594,6 +789,14 @@ impl RegistryDoc { if ops.is_empty() { return; } + if self.local_only { + self.generation += 1; + self.local_writes = true; + for op in &ops { + self.fold_op(op); + } + return; + } // The registry room rejects any batch over its op cap (500), and a // rejected batch is a PERMANENT wedge: error frames carry no batch // id, so the client can never retire it — it replays and fails on @@ -620,6 +823,132 @@ impl RegistryDoc { }); ops = tail; } + self.coalesce_unsent(); + } + + /// Collapse repeated writes to one row across the UNSENT tail of + /// `pending` (everything after the last in-flight batch) into single ops + /// via [`coalesce_ops`]; returns how many ops were folded away. + /// + /// The room treats a batch as a unit (`applyPushBatch` in + /// `edge/src/registry-room.ts`): validated and committed atomically, + /// one seq and one rows broadcast, and push notifications derived from + /// each `sessions` row before vs after the WHOLE batch, labelled from the + /// chat row after it. So: + /// - only single-op batches (one local write each) take part. Multi-op + /// batches — cascade deletes, re-seeds, migration seeds — are written + /// as units and keep every op, in place; + /// - a merged op lands at the LATER op's position and the earlier one is + /// removed. Moving an op later only reorders it against other rows, + /// which merge independently — except a session notification reads + /// its chat row, so a session op never moves past a chat op that + /// gates notifications, nor such a chat op past a session op; + /// - session ops merge only when status and completed turn match, and + /// the first op of each such run (a potential notification) never + /// absorbs later ones: working→needs-input→working stays three ops, + /// while a run of keepalive touches folds into the run head plus one. + /// Then every batch the room applies yields the same notifications as + /// the unmerged queue (the merged follower can only re-cross the + /// staleness window once, exactly as the touches it replaces would). + /// + /// Ack safety: in-flight batches are never touched, so an ack always + /// retires exactly what was pushed. A tail batch may still have been + /// pushed on a dead connection (its late HTTPS ack can race a + /// disconnect), so a batch that gained content takes a fresh id: a stale + /// ack then retires nothing, and a removed batch's ack finds nothing. + /// Re-pushing merged ops is an idempotent LWW replay. + fn coalesce_unsent(&mut self) -> usize { + let start = self + .pending + .iter() + .rposition(|b| b.in_flight) + .map_or(0, |i| i + 1); + if self.pending.len().saturating_sub(start) < 2 { + return 0; + } + let mut tail: Vec<(String, Vec>, bool)> = self + .pending + .split_off(start) + .into_iter() + .map(|b| (b.batch, b.ops.into_iter().map(Some).collect(), false)) + .collect(); + // (kind, id) → (position of that row's latest live op in the tail, + // whether that op heads a session-signal run). + let mut last: HashMap<(String, String), ((usize, usize), bool)> = HashMap::new(); + // chat id → position of its latest notification-gating chat op. + let mut gating_chat: HashMap = HashMap::new(); + let mut folded = 0; + for bi in 0..tail.len() { + for oi in 0..tail[bi].1.len() { + let Some(op) = tail[bi].1[oi].as_ref() else { + continue; + }; + let here = (bi, oi); + let key = (op.kind.clone(), op.id.clone()); + let prev = last + .get(&key) + .and_then(|&(at, head)| Some((at, head, tail[at.0].1[at.1].as_ref()?))); + let session = op.kind == KIND_SESSIONS; + let head = session + && prev.is_none_or(|(_, _, p)| { + p.op == OpKind::Delete || session_signal(p) != session_signal(op) + }); + let merged = prev.and_then(|(at, prev_head, prev_op)| { + let single = tail[bi].1.len() == 1 && tail[at.0].1.len() == 1; + let may_move = if session { + !head && !prev_head && !gating_chat.get(&op.id).is_some_and(|&c| c > at) + } else if op.kind == KIND_CHATS && chat_gates_notifications(prev_op) { + !last + .get(&(KIND_SESSIONS.to_string(), op.id.clone())) + .is_some_and(|&(s, _)| s > at) + } else { + true + }; + (single && may_move) + .then(|| coalesce_ops(prev_op, op)) + .flatten() + .map(|merged| (at, merged)) + }); + if let Some((at, merged)) = merged { + tail[at.0].1[at.1] = None; + tail[bi].1[oi] = Some(merged); + tail[bi].2 = true; + folded += 1; + } + let op = tail[bi].1[oi].as_ref().expect("op stays live"); + if op.kind == KIND_CHATS && chat_gates_notifications(op) { + gating_chat.insert(op.id.clone(), here); + } + last.insert(key, (here, head)); + } + } + if folded == 0 { + self.pending + .extend(tail.into_iter().map(|(batch, ops, _)| PendingBatch { + batch, + ops: ops.into_iter().flatten().collect(), + in_flight: false, + })); + return 0; + } + for (batch, ops, gained) in tail { + let ops: Vec = ops.into_iter().flatten().collect(); + if ops.is_empty() { + continue; + } + let batch = if gained { + format!("b-{}", self.next_hlc()) + } else { + batch + }; + self.pending.push(PendingBatch { + batch, + ops, + in_flight: false, + }); + } + self.generation += 1; + folded } fn write(&mut self, kind: &str, id: &str, op: OpKind, set: BTreeMap) { @@ -652,42 +981,42 @@ impl RegistryDoc { // ── overlay reads ─────────────────────────────────────────────────────── + fn pending_ops(&self) -> impl Iterator { + self.pending.iter().flat_map(|b| &b.ops) + } + /// The row as this device should display it: authoritative + pending ops. fn overlay_row(&self, kind: &str, id: &str) -> Option { - let mut row = self - .authoritative - .get(kind) - .and_then(|m| m.get(id)) - .cloned(); - for batch in &self.pending { - for op in &batch.ops { - if op.kind == kind && op.id == id { - let (next, _) = apply_op(row.as_ref(), op); - if let Some(next) = next { - row = Some(next); - } - } - } - } - row.filter(|r| !r.deleted) + overlay( + self.authoritative.get(kind).and_then(|m| m.get(id)), + self.pending_ops() + .filter(|op| op.kind == kind && op.id == id), + ) } - /// All live rows of `kind`, overlay applied. + /// All live rows of `kind`, overlay applied. Pending ops are grouped by + /// row in one pass — rescanning the queue per row made every read + /// O(rows × pending). fn overlay_rows(&self, kind: &str) -> Vec { - let mut ids: Vec = self - .authoritative - .get(kind) - .map(|m| m.keys().cloned().collect()) + let authoritative = self.authoritative.get(kind); + let mut ids: Vec<&str> = authoritative + .map(|m| m.keys().map(String::as_str).collect()) .unwrap_or_default(); - for batch in &self.pending { - for op in &batch.ops { - if op.kind == kind && !ids.iter().any(|id| id == &op.id) { - ids.push(op.id.clone()); - } + let mut ops: HashMap<&str, Vec<&RowOp>> = HashMap::new(); + for op in self.pending_ops().filter(|op| op.kind == kind) { + let row_ops = ops.entry(op.id.as_str()).or_default(); + if row_ops.is_empty() && !authoritative.is_some_and(|m| m.contains_key(&op.id)) { + ids.push(&op.id); } + row_ops.push(op); } - ids.iter() - .filter_map(|id| self.overlay_row(kind, id)) + ids.into_iter() + .filter_map(|id| { + overlay( + authoritative.and_then(|m| m.get(id)), + ops.get(id).into_iter().flatten().copied(), + ) + }) .collect() } @@ -785,6 +1114,7 @@ impl RegistryDoc { ("gitDetected", json!(space.git_detected)), ("gitCheckedAt", opt_ms(space.git_checked_at)), ("checkoutId", opt_str(space.checkout_id.as_deref())), + ("repositoryId", opt_str(space.repository_id.as_deref())), ("createdAt", json!(space.created_at.timestamp_millis())), ]); self.write(KIND_SPACES, &space.id.clone(), OpKind::Upsert, set); @@ -830,6 +1160,7 @@ impl RegistryDoc { space_id: &str, detected: bool, checkout_id: Option<&str>, + repository_id: Option<&str>, checked_at: DateTime, ) -> Result { if !self.row_exists(KIND_SPACES, space_id) { @@ -842,6 +1173,7 @@ impl RegistryDoc { fields([ ("gitDetected", json!(detected)), ("checkoutId", opt_str(checkout_id)), + ("repositoryId", opt_str(repository_id)), ("gitCheckedAt", json!(checked_at.timestamp_millis())), ]), ); @@ -1176,6 +1508,7 @@ impl RegistryDoc { ("lastCompletedTurn", json!(session.last_completed_turn)), ("startedAt", opt_ms(session.started_at)), ("updatedAt", json!(session.updated_at.timestamp_millis())), + ("runningSubagents", json!(session.running_subagents)), ]); self.write(KIND_SESSIONS, &session.chat_id.clone(), OpKind::Upsert, set); Ok(()) @@ -1291,6 +1624,7 @@ impl RegistryDoc { ("gitDetected", json!(space.git_detected)), ("gitCheckedAt", opt_ms(space.git_checked_at)), ("checkoutId", opt_str(space.checkout_id.as_deref())), + ("repositoryId", opt_str(space.repository_id.as_deref())), ("createdAt", json!(space.created_at.timestamp_millis())), ]), ); @@ -1352,6 +1686,7 @@ impl RegistryDoc { ("lastCompletedTurn", json!(session.last_completed_turn)), ("startedAt", opt_ms(session.started_at)), ("updatedAt", json!(session.updated_at.timestamp_millis())), + ("runningSubagents", json!(session.running_subagents)), ]), ); } @@ -1364,6 +1699,22 @@ impl RegistryDoc { } } +/// Replay `ops` (one row's, in queue order) over its authoritative state; +/// `None` when the result is missing or a tombstone. +fn overlay<'a>( + base: Option<&RegistryRow>, + ops: impl IntoIterator, +) -> Option { + let mut row = base.cloned(); + for op in ops { + let (next, _) = apply_op(row.as_ref(), op); + if let Some(next) = next { + row = Some(next); + } + } + row.filter(|r| !r.deleted) +} + // ── field helpers ─────────────────────────────────────────────────────────── fn fields(entries: [(&str, Value); N]) -> BTreeMap { diff --git a/crates/doc/src/registry/tests.rs b/crates/doc/src/registry/tests.rs index 2bef1ada73..632a72140a 100644 --- a/crates/doc/src/registry/tests.rs +++ b/crates/doc/src/registry/tests.rs @@ -288,6 +288,7 @@ fn space(id: &str, device_id: &str, path: &str) -> Space { git_detected: false, git_checked_at: None, checkout_id: None, + repository_id: None, created_at: ts(1_500), } } @@ -295,6 +296,7 @@ fn space(id: &str, device_id: &str, path: &str) -> Space { fn session(chat_id: &str, device_id: &str, status: SessionStatus) -> Session { Session { last_completed_turn: None, + running_subagents: 0, chat_id: chat_id.into(), device_id: device_id.into(), status, @@ -566,16 +568,26 @@ fn spaces_round_trip_and_mutate() { assert_eq!(ws.space("sp-1").unwrap().unwrap().display_name(), "project"); assert!( - ws.set_space_git("sp-1", true, Some("checkout-abc"), ts(4_000)) - .unwrap() + ws.set_space_git( + "sp-1", + true, + Some("checkout-abc"), + Some("github.com/owner/project"), + ts(4_000), + ) + .unwrap() ); let row = ws.space("sp-1").unwrap().unwrap(); assert!(row.git_detected); assert_eq!(row.checkout_id.as_deref(), Some("checkout-abc")); + assert_eq!( + row.repository_id.as_deref(), + Some("github.com/owner/project") + ); assert_eq!(row.git_checked_at, Some(ts(4_000))); assert!(!ws.rename_space("nope", Some("x")).unwrap()); - assert!(!ws.set_space_git("nope", true, None, ts(1)).unwrap()); + assert!(!ws.set_space_git("nope", true, None, None, ts(1)).unwrap()); } #[test] @@ -1516,3 +1528,622 @@ fn side_chat_origin_syncs_and_survives_updates_and_restart() { Some("main") ); } + +// ── bounded pending: local-only folding and tail coalescing ───────────────── + +/// Deterministic xorshift — random op shapes without a rand dependency. +struct Rng(u64); + +impl Rng { + fn next(&mut self, n: u64) -> u64 { + self.0 ^= self.0 << 13; + self.0 ^= self.0 >> 7; + self.0 ^= self.0 << 17; + self.0 % n + } + + fn hlc(&mut self) -> String { + let device = if self.next(2) == 0 { "dev-a" } else { "dev-b" }; + encode_hlc(1 + self.next(12) as i64, 0, device) + } + + /// An op on `chats/chat-1` with a random kind, field subset (nulls + /// included), revival clock and per-field clock overrides. + fn op(&mut self) -> RowOp { + let kind = match self.next(5) { + 0 => OpKind::Delete, + 1 | 2 => OpKind::Update, + _ => OpKind::Upsert, + }; + let mut set = BTreeMap::new(); + let mut clocks = BTreeMap::new(); + for field in ["a", "b", "c"] { + if self.next(3) == 0 { + continue; + } + let value = match self.next(4) { + 0 => Value::Null, + v => json!(v), + }; + set.insert(field.to_string(), value); + if self.next(3) == 0 { + clocks.insert(field.to_string(), self.hlc()); + } + } + RowOp { + kind: "chats".into(), + id: "chat-1".into(), + op: kind, + set: (kind != OpKind::Delete).then_some(set), + hlc: self.hlc(), + clocks: (kind != OpKind::Delete && !clocks.is_empty()).then_some(clocks), + } + } +} + +/// Overlay-style sequential replay (tombstones kept, so they compare too). +fn replay(base: Option, ops: &[&RowOp]) -> Option { + let mut row = base; + for op in ops { + if let Some(next) = apply_op(row.as_ref(), op).0 { + row = Some(next); + } + } + row +} + +#[test] +fn coalesced_ops_are_exact_on_every_row_state() { + let mut rng = Rng(0x5eed_cafe_f00d); + let mut merged = 0; + for _ in 0..50_000 { + // Bases: missing, live, tombstoned and revived rows with mixed clocks. + let history: Vec = (0..rng.next(4)).map(|_| rng.op()).collect(); + let base = replay(None, &history.iter().collect::>()); + let (a, b) = (rng.op(), rng.op()); + let Some(c) = coalesce_ops(&a, &b) else { + continue; + }; + merged += 1; + assert_eq!( + replay(base.clone(), &[&c]), + replay(base.clone(), &[&a, &b]), + "a={a:?}\nb={b:?}\nc={c:?}\nbase={base:?}" + ); + } + assert!(merged > 10_000, "only {merged} pairs coalesced"); +} + +#[test] +fn steady_state_writers_coalesce_to_a_bounded_queue() { + let mut doc = RegistryDoc::new("dev-a"); + doc.upsert_chat(&chat("chat-1", "dev-a")).unwrap(); + for i in 0..500 { + let mut touch = session("chat-1", "dev-a", SessionStatus::Working); + touch.updated_at = ts(10_000 + i * 10_000); + doc.upsert_session(&touch).unwrap(); + doc.set_chat_last_message("chat-1", &format!("msg {i}"), ts(10_000 + i)) + .unwrap(); + } + doc.rename_chat("chat-1", "Renamed").unwrap(); + // chat: the creating upsert (it gates notifications, so it never moves + // past the session touches) + every later update folded into one; + // sessions: the run head + one op for the other 499 keepalives. + assert_eq!(doc.pending_ops_len(), 4); + let chat = doc.chat("chat-1").unwrap().unwrap(); + assert_eq!(chat.title.as_deref(), Some("Renamed")); + assert_eq!(chat.last_message_preview.as_deref(), Some("msg 499")); + assert_eq!( + doc.read_sessions().unwrap()[0].updated_at, + ts(10_000 + 499 * 10_000) + ); +} + +/// Session statuses of the queued `sessions` ops, in push order. +fn queued_statuses(doc: &RegistryDoc) -> Vec { + doc.pending_ops() + .filter(|op| op.kind == KIND_SESSIONS) + .map(|op| op.set.as_ref().unwrap()["status"].as_str().unwrap().into()) + .collect() +} + +#[test] +fn status_changes_never_coalesce_but_keepalives_do() { + let mut doc = RegistryDoc::new("dev-a"); + let mut at = 10_000; + let mut touch = |doc: &mut RegistryDoc, status| { + at += 10_000; + let mut row = session("chat-1", "dev-a", status); + row.updated_at = ts(at); + doc.upsert_session(&row).unwrap(); + }; + touch(&mut doc, SessionStatus::Working); + touch(&mut doc, SessionStatus::AwaitingInput); + touch(&mut doc, SessionStatus::Working); + // working → needs input → working: each transition can raise a push. + assert_eq!( + queued_statuses(&doc), + ["working", "awaitingInput", "working"] + ); + for _ in 0..50 { + touch(&mut doc, SessionStatus::Working); + } + // The keepalives fold into one op behind the run head. + assert_eq!( + queued_statuses(&doc), + ["working", "awaitingInput", "working", "working"] + ); +} + +/// Mirror of `notificationFor` (edge/src/push-notify.ts). +fn notification( + before: Option<&RegistryRow>, + after: &RegistryRow, + now: i64, +) -> Option<&'static str> { + let before = before.filter(|r| !r.deleted)?; + if after.deleted { + return None; + } + let indicator = |row: &RegistryRow| { + let updated = row + .fields + .get("updatedAt") + .and_then(Value::as_i64) + .unwrap_or(0); + match row.fields.get("status").and_then(Value::as_str) { + Some("errored") => "errored", + Some("working") if now - updated <= 45_000 => "working", + Some("awaitingInput") if now - updated <= 45_000 => "awaiting", + _ => "none", + } + }; + let (prev, next) = (indicator(before), indicator(after)); + if next == "errored" && prev != "errored" { + return Some("failed"); + } + if next == "awaiting" && prev != "awaiting" { + return Some("input"); + } + let turn = |row: &RegistryRow| { + row.fields + .get("lastCompletedTurn") + .and_then(Value::as_str) + .map(str::to_owned) + }; + let updated = after + .fields + .get("updatedAt") + .and_then(Value::as_i64) + .unwrap_or(0); + let fresh = now - updated <= 45_000; + (fresh && turn(after).is_some() && turn(after) != turn(before)).then_some("done") +} + +/// Push `batches` the way the room does: apply each atomically, then derive +/// one notification per touched session from its before/after rows. +fn pushed_notifications( + base: &HashMap<(String, String), RegistryRow>, + batches: &[Vec], + now: i64, +) -> Vec<&'static str> { + let mut rows = base.clone(); + let mut out = Vec::new(); + for batch in batches { + let mut before: HashMap> = HashMap::new(); + for op in batch { + let key = (op.kind.clone(), op.id.clone()); + if op.kind == KIND_SESSIONS { + before + .entry(op.id.clone()) + .or_insert_with(|| rows.get(&key).cloned()); + } + if let (Some(next), true) = apply_op(rows.get(&key), op) { + rows.insert(key, next); + } + } + for (id, prev) in before { + if let Some(after) = rows.get(&(KIND_SESSIONS.to_string(), id)) { + out.extend(notification(prev.as_ref(), after, now)); + } + } + } + out +} + +#[test] +fn coalescing_preserves_every_push_notification() { + let mut rng = Rng(0xa1e7_5eed); + let statuses = ["working", "awaitingInput", "errored", "idle"]; + let mut notified = 0; + for _ in 0..300 { + let mut doc = RegistryDoc::new("dev-a"); + let mut base = HashMap::new(); + let mut at = 100_000 + rng.next(100_000) as i64; + let mut turn = 0; + let row_for = |rng: &mut Rng, at: &mut i64, turn: &mut i32, ms: i64| { + *at += 1 + rng.next(30_000) as i64; + if rng.next(4) == 0 { + *turn += 1; + } + let set: BTreeMap = [ + ("chatId".to_string(), json!("chat-1")), + ("status".to_string(), json!(statuses[rng.next(4) as usize])), + ("lastCompletedTurn".to_string(), json!(format!("t{turn}"))), + ("updatedAt".to_string(), json!(*at)), + ] + .into(); + RowOp { + kind: KIND_SESSIONS.into(), + id: "chat-1".into(), + op: OpKind::Upsert, + set: Some(set), + hlc: encode_hlc(ms, 0, "dev-a"), + clocks: None, + } + }; + // The room already holds an older version of the row. + let seed = row_for(&mut rng, &mut at, &mut turn, 1); + base.insert( + (KIND_SESSIONS.to_string(), "chat-1".to_string()), + apply_op(None, &seed).0.unwrap(), + ); + // Long status runs (keepalives) with occasional transitions. + let mut ops = Vec::new(); + let mut current = row_for(&mut rng, &mut at, &mut turn, 2); + for i in 0..20 + rng.next(40) as i64 { + if rng.next(6) == 0 { + current = row_for(&mut rng, &mut at, &mut turn, 3 + i); + } else { + at += 1 + rng.next(30_000) as i64; + let set = current.set.as_mut().unwrap(); + set.insert("updatedAt".into(), json!(at)); + current.hlc = encode_hlc(3 + i, 0, "dev-a"); + } + ops.push(current.clone()); + doc.enqueue_ops(vec![current.clone()]); + } + let original: Vec> = ops.iter().map(|op| vec![op.clone()]).collect(); + let coalesced: Vec> = doc.pending.iter().map(|b| b.ops.clone()).collect(); + assert!(coalesced.len() <= original.len()); + // Any push time: before, during and after the queued window. + for _ in 0..8 { + let now = 100_000 + rng.next((at - 50_000).max(1) as u64 + 200_000) as i64; + let expected = pushed_notifications(&base, &original, now); + assert_eq!( + pushed_notifications(&base, &coalesced, now), + expected, + "now={now}" + ); + notified += expected.len(); + } + } + assert!(notified > 500, "only {notified} notifications exercised"); +} + +#[test] +fn a_session_op_never_moves_past_a_notification_gating_chat_op() { + let mut doc = RegistryDoc::new("dev-a"); + doc.upsert_chat(&chat("chat-1", "dev-a")).unwrap(); + let touch = |doc: &mut RegistryDoc, ms| { + let mut row = session("chat-1", "dev-a", SessionStatus::Working); + row.updated_at = ts(ms); + doc.upsert_session(&row).unwrap(); + }; + touch(&mut doc, 10_000); + touch(&mut doc, 20_000); + // Archiving silences the chat's pushes: the follower touch before it + // must not be carried past it (nor the archive past the next touch). + doc.set_chat_archived("chat-1", true).unwrap(); + touch(&mut doc, 30_000); + let order: Vec<(String, bool)> = doc + .pending_ops() + .map(|op| { + ( + op.kind.clone(), + op.set + .as_ref() + .is_some_and(|s| s.contains_key("archived") && op.op == OpKind::Update), + ) + }) + .collect(); + assert_eq!( + order, + [ + ("chats".to_string(), false), + ("sessions".to_string(), false), + ("sessions".to_string(), false), + ("chats".to_string(), true), + ("sessions".to_string(), false), + ] + ); + // Freshness writes gate nothing and still fold across session touches. + let before = doc.pending_ops_len(); + for i in 0..20 { + doc.set_chat_last_message("chat-1", &format!("m{i}"), ts(40_000 + i)) + .unwrap(); + touch(&mut doc, 40_000 + i * 1_000); + } + assert!( + doc.pending_ops_len() <= before + 2, + "{}", + doc.pending_ops_len() + ); +} + +#[test] +fn merges_that_would_exceed_the_op_budget_stay_separate() { + let big = |field: &str, at: i64| { + let mut op = update(&[(field, json!("x".repeat(9 * 1024)))], hlc(at)); + op.kind = KIND_SPACES.into(); + op + }; + let (a, b) = (big("name", 1), big("path", 2)); + assert!(fits_op_budget(&a) && fits_op_budget(&b)); + assert_eq!(coalesce_ops(&a, &b), None); + // Same field: the merge is no larger than `b`, so it folds. + assert!(coalesce_ops(&a, &big("name", 2)).is_some()); + + let mut doc = RegistryDoc::new("dev-a"); + doc.enqueue_ops(vec![a.clone()]); + doc.enqueue_ops(vec![b.clone()]); + assert_eq!(doc.pending_ops_len(), 2); + for batch in doc.take_pushable() { + assert!(batch.ops.iter().all(fits_op_budget)); + } +} + +#[test] +fn multi_op_batches_keep_their_ops_in_place() { + let mut doc = RegistryDoc::new("dev-a"); + let state = WorkspaceState { + devices: vec![device("dev-a", "laptop")], + spaces: vec![space("space-1", "dev-a", "/p")], + chats: vec![chat("chat-1", "dev-a")], + sessions: vec![], + }; + doc.seed_from_workspace(&state).unwrap(); + let seed = doc.pending[0].clone(); + assert_eq!(seed.ops.len(), 3); + for i in 0..5 { + doc.rename_chat("chat-1", &format!("t{i}")).unwrap(); + } + // The migration batch is untouched (id and every op); the renames fold + // among themselves. + assert_eq!(doc.pending[0].batch, seed.batch); + assert_eq!(doc.pending[0].ops, seed.ops); + assert_eq!(doc.pending_ops_len(), 4); + assert_eq!( + doc.chat("chat-1").unwrap().unwrap().title.as_deref(), + Some("t4") + ); +} + +#[test] +fn tail_coalescing_preserves_the_overlay_and_the_server_outcome() { + let mut rng = Rng(0x0dd_ba11); + for _ in 0..300 { + let mut doc = RegistryDoc::new("dev-a"); + let history: Vec = (0..rng.next(4)).map(|_| rng.op()).collect(); + let base = replay(None, &history.iter().collect::>()); + if let Some(row) = base.clone() { + doc.put_authoritative(row); + } + let ops: Vec = (0..1 + rng.next(30)).map(|_| rng.op()).collect(); + for op in &ops { + doc.enqueue_ops(vec![op.clone()]); + } + let expected = replay(base.clone(), &ops.iter().collect::>()); + assert!(doc.pending_ops_len() <= ops.len()); + // The local overlay… + assert_eq!( + doc.overlay_row("chats", "chat-1"), + expected.clone().filter(|r| !r.deleted) + ); + // …and a server holding the same base row applying the pushed queue. + let pushed: Vec = doc + .take_pushable() + .into_iter() + .flat_map(|b| b.ops) + .collect(); + assert_eq!(replay(base, &pushed.iter().collect::>()), expected); + } +} + +#[test] +fn in_flight_batches_are_never_coalesced_and_still_ack() { + let mut doc = RegistryDoc::new("dev-a"); + doc.upsert_chat(&chat("chat-1", "dev-a")).unwrap(); + let sent = doc.take_pushable(); + assert_eq!(sent.len(), 1); + for i in 0..10 { + doc.rename_chat("chat-1", &format!("title {i}")).unwrap(); + } + // The in-flight upsert keeps its id and ops; the ten renames are one op. + assert_eq!(doc.pending_len(), 2); + assert_eq!(doc.pending[0].batch, sent[0].batch); + assert_eq!(doc.pending[0].ops, sent[0].ops); + assert_eq!(doc.pending[1].ops.len(), 1); + // The room broadcasts merged rows before the ack. + assert!(doc.apply_rows(1, vec![applied(None, &sent[0].ops[0])])); + assert!(doc.ack_batch(&sent[0].batch, 1)); + assert_eq!(doc.pending_len(), 1); + assert_eq!( + doc.chat("chat-1").unwrap().unwrap().title.as_deref(), + Some("title 9") + ); + let renames = doc.take_pushable(); + assert!(doc.ack_batch(&renames[0].batch, 2)); + assert_eq!(doc.pending_len(), 0); +} + +#[test] +fn a_late_ack_for_a_coalesced_away_batch_cannot_drop_newer_writes() { + let mut doc = RegistryDoc::new("dev-a"); + doc.upsert_chat(&chat("chat-1", "dev-a")).unwrap(); + // Pushed over HTTPS, then the socket drops before the ack lands. + let raced = doc.take_pushable().pop().unwrap(); + doc.mark_disconnected(); + doc.rename_chat("chat-1", "after the drop").unwrap(); + // The upsert folded forward into a batch under a FRESH id… + assert_eq!(doc.pending_len(), 1); + assert_ne!(doc.pending[0].batch, raced.batch); + // …so the stale ack retires nothing and the rename still pushes. + assert!(!doc.ack_batch(&raced.batch, 1)); + assert_eq!(doc.pending_ops_len(), 1); + assert_eq!( + doc.chat("chat-1").unwrap().unwrap().title.as_deref(), + Some("after the drop") + ); +} + +#[test] +fn local_only_writes_fold_and_keep_pending_empty() { + let mut local = RegistryDoc::new("dev-a"); + local.set_local_only(true); + let mut queued = RegistryDoc::new("dev-a"); + for doc in [&mut local, &mut queued] { + doc.upsert_device(&device("dev-a", "laptop")).unwrap(); + doc.upsert_space(&space("space-1", "dev-a", "/p")).unwrap(); + doc.upsert_chat(&chat("chat-1", "dev-a")).unwrap(); + doc.upsert_chat(&chat("chat-2", "dev-a")).unwrap(); + for _ in 0..50 { + doc.upsert_session(&session("chat-1", "dev-a", SessionStatus::Working)) + .unwrap(); + } + doc.rename_chat("chat-1", "renamed").unwrap(); + doc.delete_chat("chat-2").unwrap(); + pin_sessions(doc, &["chat-1"]); + } + assert_eq!(local.pending_len(), 0); + assert!(queued.pending_len() > 0); + assert_eq!(local.read_all().unwrap(), queued.read_all().unwrap()); + assert_eq!(local.sidebar_preferences(), queued.sidebar_preferences()); + assert_eq!(local.read_chats().unwrap().len(), 1); +} + +/// A pre-fix snapshot: every write its own batch, none ever acked. Marking +/// each batch in flight as it is written keeps the doc from coalescing, +/// and `in_flight` is not persisted, so the snapshot reloads all-unsent. +fn bloated_snapshot() -> (Vec, Vec) { + let mut doc = RegistryDoc::new("dev-a"); + let mut ops = Vec::new(); + let mut write = |doc: &mut RegistryDoc, f: &dyn Fn(&mut RegistryDoc)| { + f(doc); + ops.extend(doc.take_pushable().into_iter().flat_map(|b| b.ops)); + }; + write(&mut doc, &|d| { + d.upsert_device(&device("dev-a", "laptop")).unwrap() + }); + write(&mut doc, &|d| { + d.upsert_chat(&chat("chat-1", "dev-a")).unwrap() + }); + write(&mut doc, &|d| { + d.upsert_chat(&chat("chat-2", "dev-a")).unwrap() + }); + for i in 0..2_000 { + write(&mut doc, &|d| { + d.upsert_session(&session("chat-1", "dev-a", SessionStatus::Working)) + .unwrap() + }); + if i % 10 == 0 { + write(&mut doc, &|d| { + d.set_chat_seen("chat-1", ts(5_000 + i)).unwrap(); + }); + } + } + write(&mut doc, &|d| { + d.delete_chat("chat-2").unwrap(); + }); + assert_eq!(doc.pending_ops_len(), ops.len()); + (doc.to_bytes().unwrap(), ops) +} + +#[test] +fn loading_a_bloated_snapshot_coalesces_it() { + let (bytes, ops) = bloated_snapshot(); + assert!(ops.len() > 2_000); + let doc = RegistryDoc::from_bytes(&bytes, "dev-a").unwrap(); + // device; chat-1's creating upsert + its folded seen-markers; session-1's + // run head + folded keepalives; chat-2's upsert and its two-op delete + // batch (deletes never fold, multi-op batches stay whole) — 8, not 2k. + assert_eq!(doc.pending_ops_len(), 8); + let expected = { + let mut server = HashMap::new(); + for op in &ops { + let key = (op.kind.clone(), op.id.clone()); + if let Some(next) = apply_op(server.get(&key), op).0 { + server.insert(key, next); + } + } + server + }; + // Pushing the compacted queue lands the server on the same rows. + let mut server = HashMap::new(); + let mut seq = 0; + let mut doc = doc; + server_round(&mut server, &mut seq, &mut [&mut doc]); + assert_eq!(server.len(), expected.len()); + for (key, row) in &expected { + let got = &server[key]; + assert_eq!( + (&got.fields, &got.clocks, got.deleted), + (&row.fields, &row.clocks, row.deleted) + ); + } + assert!(doc.to_bytes().unwrap().len() * 50 < bytes.len()); +} + +#[test] +fn loading_a_bloated_snapshot_local_only_folds_it() { + let (bytes, _) = bloated_snapshot(); + let queued = RegistryDoc::from_bytes(&bytes, "dev-a").unwrap(); + let mut doc = RegistryDoc::from_bytes(&bytes, "dev-a").unwrap(); + doc.set_local_only(true); + assert_eq!(doc.pending_len(), 0); + assert_eq!(doc.read_all().unwrap(), queued.read_all().unwrap()); + // The compacted snapshot round-trips with nothing queued. + let compact = doc.to_bytes().unwrap(); + assert!(compact.len() * 50 < bytes.len()); + let restored = RegistryDoc::from_bytes(&compact, "dev-a").unwrap(); + assert_eq!(restored.pending_len(), 0); + assert_eq!(restored.read_all().unwrap(), queued.read_all().unwrap()); +} + +#[test] +fn attaching_an_edge_after_local_only_writes_reseeds_them() { + // Synced once, then edited with no edge (e.g. a dev profile run + // without a token), then attached again. + let mut doc = RegistryDoc::new("dev-a"); + doc.upsert_chat(&chat("chat-1", "dev-a")).unwrap(); + let mut server = HashMap::new(); + let mut seq = 0; + server_round(&mut server, &mut seq, &mut [&mut doc]); + let mut doc = RegistryDoc::from_bytes(&doc.to_bytes().unwrap(), "dev-a").unwrap(); + doc.set_local_only(true); + doc.rename_chat("chat-1", "offline edit").unwrap(); + doc.upsert_chat(&chat("chat-2", "dev-a")).unwrap(); + assert_eq!(doc.pending_len(), 0); + + // The flag survives a restart; leaving local-only queues a re-seed. + let mut doc = RegistryDoc::from_bytes(&doc.to_bytes().unwrap(), "dev-a").unwrap(); + doc.set_local_only(false); + assert!(doc.pending_len() > 0); + // A full state carrying the server's OLDER chat-1 must not erase the + // offline rename: the re-seed overlays it and wins LWW on push. + let rows: Vec = server.values().cloned().collect(); + doc.apply_state(seq, true, 0, rows); + let title = |d: &RegistryDoc| d.chat("chat-1").unwrap().unwrap().title; + assert_eq!(title(&doc).as_deref(), Some("offline edit")); + server_round(&mut server, &mut seq, &mut [&mut doc]); + assert_eq!(doc.pending_len(), 0); + assert_eq!(title(&doc).as_deref(), Some("offline edit")); + assert_eq!(doc.read_chats().unwrap().len(), 2); + let key = ("chats".to_string(), "chat-1".to_string()); + assert_eq!(server[&key].fields["title"], json!("offline edit")); + + // Re-seeding is one-shot: the next attach queues nothing. + let mut doc = RegistryDoc::from_bytes(&doc.to_bytes().unwrap(), "dev-a").unwrap(); + doc.set_local_only(false); + assert_eq!(doc.pending_len(), 0); +} diff --git a/crates/doc/src/schema.rs b/crates/doc/src/schema.rs index 8dc8845809..bc68b3bf8e 100644 --- a/crates/doc/src/schema.rs +++ b/crates/doc/src/schema.rs @@ -1552,6 +1552,64 @@ mod tests { assert_eq!(doc.chat_id().as_deref(), Some("chat-1")); } + #[test] + fn todo_status_survives_the_doc_and_refreshes_in_place() { + use zeron_proto::{TodoItem, TodoStatus}; + let todo = |items: Vec| MessagePart::Tool { + // The ACP/Codex plan reuses one id for every update. + id: zeron_proto::LIVE_PLAN_TOOL_ID.into(), + call: ToolCall::Todo { items }, + is_error: false, + resolved: true, + output: None, + diff: None, + output_ref: None, + output_bytes: None, + diff_ref: None, + diff_stats: None, + subagent_ref: None, + subagent_status: None, + subagent_tail: None, + }; + let doc = SessionDoc::init("c1").unwrap(); + let mut w = SegmentWriter::begin(&doc, "e1", "dev", 1).unwrap(); + let first = todo(vec![ + TodoItem::new("read", TodoStatus::InProgress), + TodoItem::new("fix", TodoStatus::Pending), + ]); + w.sync(std::slice::from_ref(&first)).unwrap(); + assert_eq!(doc.read_entries().unwrap()[0].parts, vec![first]); + // The next plan update rewrites the same part, in-progress moved on. + let second = todo(vec![ + TodoItem::new("read", TodoStatus::Completed), + TodoItem::new("fix", TodoStatus::InProgress), + ]); + w.sync(std::slice::from_ref(&second)).unwrap(); + let entries = doc.read_entries().unwrap(); + assert_eq!(entries[0].parts, vec![second]); + } + + #[test] + fn legacy_todo_part_without_status_still_reads() { + // Written by a build that only knew `done`. + let part: DocPartJson = serde_json::from_value(serde_json::json!({ + "id": "t", "kind": "tool", "isError": false, + "call": { "kind": "todo", "items": [ + { "text": "a", "done": true }, { "text": "b", "done": false } + ]} + })) + .unwrap(); + let MessagePart::Tool { + call: ToolCall::Todo { items }, + .. + } = from_doc_part(part) + else { + panic!("a legacy todo part must still decode"); + }; + assert_eq!(items[0].status(), zeron_proto::TodoStatus::Completed); + assert_eq!(items[1].status(), zeron_proto::TodoStatus::Pending); + } + #[test] fn segment_sync_persists_subagent_chip_fields_on_live_parts() { // The eager-done world's OTHER path: the chip mutates while its diff --git a/crates/doc/src/voice.rs b/crates/doc/src/voice.rs new file mode 100644 index 0000000000..c9d9363e75 --- /dev/null +++ b/crates/doc/src/voice.rs @@ -0,0 +1,112 @@ +//! Canonical final text reducer. Call from the engine's serialized document +//! owner; this never submits a command and never accepts PCM or partial text. +use crate::{DocError, MessagePart, MessageRole, MessageStatus, SessionDoc, SessionMessageEntry}; +use zeron_proto::voice::{MAX_TRANSCRIPT_BYTES, VoiceRole, VoiceTranscript}; + +pub fn commit_voice_transcript( + doc: &SessionDoc, + final_text: &VoiceTranscript, + device: &str, + created_at: i64, +) -> Result, DocError> { + if final_text.text.len() > MAX_TRANSCRIPT_BYTES + || final_text.session_id.is_empty() + || final_text.item_id.is_empty() + || final_text.session_id.len() > 256 + || final_text.item_id.len() > 256 + { + return Err(DocError::Schema( + "invalid canonical voice transcript".into(), + )); + } + if final_text.text.trim().is_empty() { + return Ok(None); + } + // Length-prefixing prevents collisions if a provider id contains ':'; + // identity is unrelated to text, so two identical phrases stay two items. + let id = format!( + "voice:{}:{}:{}", + final_text.session_id.len(), + final_text.session_id, + final_text.item_id + ); + let entries = doc.read_entries()?; + if entries.iter().any(|entry| entry.id == id) { + return Ok(None); + } + if let Some(promoted) = &final_text.promoted_message_id { + // Promotion must point to an existing native record. Never silently + // create a duplicate when it arrives out of order; retry after routing. + return if entries.iter().any(|entry| &entry.id == promoted) { + Ok(None) + } else { + Err(DocError::Schema( + "voice promotion precedes native message".into(), + )) + }; + } + doc.push_message(&SessionMessageEntry { + id: id.clone(), + role: match final_text.role { + VoiceRole::User => MessageRole::User, + VoiceRole::Assistant => MessageRole::Assistant, + }, + parts: vec![MessagePart::Text { + id: "t0".into(), + text: final_text.text.clone(), + }], + created_at, + device_id: device.into(), + status: Some(MessageStatus::Complete), + continuation_of: None, + duration_ms: None, + })?; + Ok(Some(id)) +} + +#[cfg(test)] +mod tests { + use super::*; + use loro::ToJson; + #[test] + fn final_identity_survives_replay_and_snapshot_without_commands_or_audio() { + let doc = SessionDoc::init("chat").unwrap(); + let mut text = VoiceTranscript { + session_id: "session".into(), + item_id: "one".into(), + role: VoiceRole::User, + text: "create a chat".into(), + promoted_message_id: None, + }; + assert!( + commit_voice_transcript(&doc, &text, "device", 1) + .unwrap() + .is_some() + ); + assert!( + commit_voice_transcript(&doc, &text, "device", 2) + .unwrap() + .is_none() + ); + text.item_id = "two".into(); + assert!( + commit_voice_transcript(&doc, &text, "device", 3) + .unwrap() + .is_some() + ); + let snapshot = doc.doc().export(loro::ExportMode::Snapshot).unwrap(); + let restored = loro::LoroDoc::new(); + restored.import(&snapshot).unwrap(); + let restored = SessionDoc::from_doc(restored); + assert_eq!(restored.read_entries().unwrap().len(), 2); + assert!( + commit_voice_transcript(&restored, &text, "device", 4) + .unwrap() + .is_none() + ); + let json = restored.doc().get_deep_value().to_json_value().to_string(); + assert!(!json.contains("leaseToken")); + assert!(!json.contains("sampleRate")); + assert!(!json.contains("Submit")); + } +} diff --git a/crates/doc/src/workspace.rs b/crates/doc/src/workspace.rs index 59c89d2bd8..eaf33f3504 100644 --- a/crates/doc/src/workspace.rs +++ b/crates/doc/src/workspace.rs @@ -7,7 +7,7 @@ //! to the *same* row settle field-by-field LWW (exactly right for renames/archives): //! - `devices`: LoroMap keyed by deviceId → row map {id, name, platform, lastSeenAt} //! - `spaces`: LoroMap keyed by spaceId → row map {id, deviceId, path, name?, -//! gitDetected, gitCheckedAt?, checkoutId?, createdAt} +//! gitDetected, gitCheckedAt?, checkoutId?, repositoryId?, createdAt} //! - `chats`: LoroMap keyed by chatId → row map {id, deviceId, title?, archived, cwd?, //! branch?, checkoutId?, config?(json), lastMessagePreview?, lastMessageAt?, createdAt, //! harnessSessionId?, harnessSessionCwd?, spaceId?, lastSeenAt?} @@ -170,6 +170,7 @@ impl WorkspaceDoc { row.insert("gitDetected", space.git_detected)?; set_opt_ms(&row, "gitCheckedAt", space.git_checked_at)?; set_opt_str(&row, "checkoutId", space.checkout_id.as_deref())?; + set_opt_str(&row, "repositoryId", space.repository_id.as_deref())?; row.insert("createdAt", space.created_at.timestamp_millis())?; self.doc.commit(); Ok(()) @@ -208,6 +209,7 @@ impl WorkspaceDoc { space_id: &str, detected: bool, checkout_id: Option<&str>, + repository_id: Option<&str>, checked_at: DateTime, ) -> Result { let Some(row) = self.existing_row("spaces", space_id) else { @@ -215,6 +217,7 @@ impl WorkspaceDoc { }; row.insert("gitDetected", detected)?; set_opt_str(&row, "checkoutId", checkout_id)?; + set_opt_str(&row, "repositoryId", repository_id)?; row.insert("gitCheckedAt", checked_at.timestamp_millis())?; self.doc.commit(); Ok(true) @@ -484,6 +487,7 @@ impl WorkspaceDoc { )?; set_opt_ms(&row, "startedAt", session.started_at)?; row.insert("updatedAt", session.updated_at.timestamp_millis())?; + row.insert("runningSubagents", i64::from(session.running_subagents))?; self.doc.commit(); Ok(()) } @@ -655,6 +659,8 @@ pub(crate) struct RawSpace { #[serde(default)] checkout_id: Option, #[serde(default)] + repository_id: Option, + #[serde(default)] created_at: i64, } @@ -668,6 +674,7 @@ impl From for Space { git_detected: raw.git_detected, git_checked_at: raw.git_checked_at.map(dt), checkout_id: raw.checkout_id, + repository_id: raw.repository_id, created_at: dt(raw.created_at), } } @@ -773,6 +780,8 @@ pub(crate) struct RawSession { started_at: Option, #[serde(default)] updated_at: i64, + #[serde(default)] + running_subagents: u32, } impl From for Session { @@ -784,6 +793,7 @@ impl From for Session { status: raw.status, started_at: raw.started_at.map(dt), updated_at: dt(raw.updated_at), + running_subagents: raw.running_subagents, } } } @@ -878,6 +888,7 @@ mod tests { git_detected: false, git_checked_at: None, checkout_id: None, + repository_id: None, created_at: ts(1_500), } } @@ -890,6 +901,7 @@ mod tests { status, started_at: Some(ts(3_000)), updated_at: ts(3_500), + running_subagents: 0, } } @@ -984,6 +996,14 @@ mod tests { vec![session("chat-1", "dev-a", SessionStatus::Working)] ); + // The running-subagent count rides the row and survives the round trip. + let mut busy = session("chat-1", "dev-a", SessionStatus::Working); + busy.running_subagents = 4; + ws.upsert_session(&busy).unwrap(); + assert_eq!(ws.read_sessions().unwrap(), vec![busy]); + ws.upsert_session(&session("chat-1", "dev-a", SessionStatus::Working)) + .unwrap(); + // Upsert refreshes in place — no duplicate rows, cleared options removed. let mut updated = chat("chat-1", "dev-a"); updated.title = None; @@ -1096,17 +1116,27 @@ mod tests { assert_eq!(ws.space("sp-1").unwrap().unwrap().display_name(), "project"); assert!( - ws.set_space_git("sp-1", true, Some("checkout-abc"), ts(4_000)) - .unwrap() + ws.set_space_git( + "sp-1", + true, + Some("checkout-abc"), + Some("github.com/owner/project"), + ts(4_000), + ) + .unwrap() ); let row = ws.space("sp-1").unwrap().unwrap(); assert!(row.git_detected); assert_eq!(row.checkout_id.as_deref(), Some("checkout-abc")); + assert_eq!( + row.repository_id.as_deref(), + Some("github.com/owner/project") + ); assert_eq!(row.git_checked_at, Some(ts(4_000))); // Unknown rows report false, never invent rows. assert!(!ws.rename_space("nope", Some("x")).unwrap()); - assert!(!ws.set_space_git("nope", true, None, ts(1)).unwrap()); + assert!(!ws.set_space_git("nope", true, None, None, ts(1)).unwrap()); } #[test] diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index ce891260bf..7b883cd14b 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -43,7 +43,11 @@ tempfile.workspace = true windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Security", "Win32_Storage_FileSystem", "Win32_System_Console", "Win32_System_Pipes", "Win32_System_Threading"] } [dev-dependencies] +tracing-subscriber.workspace = true +zeron-mcp.workspace = true zeron-harness = { workspace = true, features = ["installer-fixture"] } +# Paused-clock tests (the 30-minute idle reaper). +tokio = { workspace = true, features = ["test-util"] } zip.workspace = true tokio-tungstenite.workspace = true # In-process registry server for the two-engine workspace integration tests. diff --git a/crates/engine/examples/mcp_standalone_smoke.rs b/crates/engine/examples/mcp_standalone_smoke.rs new file mode 100644 index 0000000000..1ab1294c9c --- /dev/null +++ b/crates/engine/examples/mcp_standalone_smoke.rs @@ -0,0 +1,103 @@ +//! Isolated MCP stdio instance for manual discovery/create/converse smoke tests. +//! Run with `cargo run -p zeron-engine --example mcp_standalone_smoke`. +//! Uses a temporary profile and scripted harness, never the user's workspace. + +use std::sync::Arc; + +use async_trait::async_trait; +use futures::stream::BoxStream; +use zeron_engine::{EngineCore, HarnessRegistry}; +use zeron_harness::{Harness, HarnessError, RunControls, mock::MockHarness}; +use zeron_mcp::{Origin, Tools, Zeron}; +use zeron_proto::{ + AgentEvent, DoneStatus, HarnessId, Model, ReasoningLevel, RunRequest, SteeringMode, +}; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let dir = tempfile::tempdir()?; + std::fs::write(dir.path().join("device-id"), "smoke-device")?; + let registry = HarnessRegistry::new(); + registry.register(Arc::new(SmokeHarness)); + let core = EngineCore::assemble(dir.path(), Arc::new(registry), HarnessId::Codex, None)?; + core.workspace.create_space( + "smoke-project", + "smoke-device", + dir.path().to_str().unwrap(), + Some("Smoke project".into()), + false, + )?; + core.workspace + .create_chat("smoke-origin", Some("smoke-project"), None, None, None)?; + core.workspace.rename_chat("smoke-origin", "Coordinator")?; + let tools = Tools::new(Arc::new(Zeron::with_client( + zeron_rpc::memory_client(core.rpc_service()), + Origin { + chat_id: Some("smoke-origin".into()), + device_id: Some("smoke-device".into()), + }, + ))); + zeron_mcp::serve_stdio(Arc::new(tools)).await?; + core.shutdown().await; + Ok(()) +} + +/// A scripted, installed Codex adapter: the production catalog excludes Mock. +struct SmokeHarness; + +#[async_trait] +impl Harness for SmokeHarness { + fn id(&self) -> HarnessId { + HarnessId::Codex + } + fn display_name(&self) -> &str { + "Scripted Codex (smoke only)" + } + fn supports_steering(&self) -> bool { + false + } + fn steering_mode(&self) -> SteeringMode { + SteeringMode::TurnBoundary + } + fn reasoning_levels(&self) -> &[ReasoningLevel] { + &[] + } + async fn models(&self) -> Result, HarnessError> { + Ok(vec![Model { + id: "smoke-1".into(), + label: "Smoke 1".into(), + description: None, + reasoning_levels: vec![], + options: vec![], + }]) + } + async fn run( + &self, + request: RunRequest, + controls: RunControls, + ) -> Result>, HarnessError> { + let session_id = uuid::Uuid::new_v4().to_string(); + let mock = MockHarness { + script: vec![ + AgentEvent::SessionStarted { + harness: HarnessId::Codex, + model: "smoke-1".into(), + tools: vec![], + cwd: request.cwd.clone(), + session_id: session_id.clone(), + assistant_message_id: uuid::Uuid::new_v4().to_string(), + }, + AgentEvent::TextDelta { + text: "pong".into(), + }, + AgentEvent::Done { + status: DoneStatus::Completed, + result: None, + error: None, + session_id: Some(session_id), + }, + ], + }; + mock.run(request, controls).await + } +} diff --git a/crates/engine/src/chat2_host.rs b/crates/engine/src/chat2_host.rs index 246ebbb3da..6974019eef 100644 --- a/crates/engine/src/chat2_host.rs +++ b/crates/engine/src/chat2_host.rs @@ -357,9 +357,15 @@ pub struct EdgeChatTransport { edge: EdgeConfig, chat_id: String, device_id: String, + host_device: Option, } impl EdgeChatTransport { + pub fn with_host_device(mut self, host: Option) -> Self { + self.host_device = host; + self + } + pub fn with_priority(mut self, priority: zeron_sync::budget::Priority) -> Self { self.priority = priority; self @@ -377,6 +383,7 @@ impl EdgeChatTransport { edge, chat_id: chat_id.into(), device_id: device_id.into(), + host_device: None, } } @@ -430,14 +437,19 @@ impl zeron_sync::chat_client::ChatTransport for EdgeChatTransport { let edge = self.edge.clone(); let url = self.rows_url(); let device = self.device_id.clone(); + let host = self.host_device.clone(); Box::pin(async move { let _permit = zeron_sync::budget::shared().http(priority).await?; let bearer = edge.bearer().await.map_err(SyncError::from)?; - let res = http + let mut request = http .post(&url) .query(&[("batchId", batch_id), ("device", device)]) .bearer_auth(&bearer) - .body(bytes) + .body(bytes); + if let Some(host) = host { + request = request.query(&[("hostDevice", host)]); + } + let res = request .send() .await .map_err(|e| SyncError::WebSocket(describe_http_error(e)))?; diff --git a/crates/engine/src/diff_sync.rs b/crates/engine/src/diff_sync.rs index e9cb782daf..cdd8058331 100644 --- a/crates/engine/src/diff_sync.rs +++ b/crates/engine/src/diff_sync.rs @@ -15,8 +15,13 @@ //! is captured by the command host and is never rewritten from this watcher; //! otherwise one checkout change would relabel every chat sharing that folder. //! -//! Fast recursive `notify` watchers (debounced [`WATCH_DEBOUNCE`]) are backed by a -//! slow 2-minute repair tick because native watchers may coalesce or drop events. +//! Archived chats are ignored: they get no entry, no watcher, and no captures +//! (an entry whose every chat was archived is dropped on the next reconcile; +//! unarchiving re-adds it). +//! +//! Fast recursive watches on the process-wide [`FsWatchHub`] (one watcher thread +//! for every checkout; debounced [`WATCH_DEBOUNCE`]) are backed by a slow +//! 2-minute repair tick because native watchers may coalesce or drop events. //! Snapshots carry a sha256 checksum; an unchanged checksum publishes nothing. //! //! Reconcile is deliberately damped, because it runs on *every* workspace chat @@ -31,28 +36,35 @@ //! `GetCheckoutDiff` captures for the Changes pane's scopes: *branch changes* //! (vs `merge-base(baseRef, HEAD)`, same capture path with the base overridden) //! and *latest turn* (vs a temp-index `write-tree` snapshot taken when a turn -//! dispatches — see [`CheckoutDiffSync::note_turn_start`]). +//! dispatches — see [`CheckoutDiffSync::note_turn_start`]). Those snapshots +//! live in Zeron's own object store, never the checkout's — see +//! [`turn_snapshot`]. use std::collections::{HashMap, HashSet}; -use std::ffi::OsString; +use std::ffi::{OsStr, OsString}; use std::path::{Component, Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex, MutexGuard, PoisonError, Weak}; -use std::time::Duration; +use std::time::{Duration, Instant}; use serde::Serialize; use sha2::{Digest, Sha256}; use tokio::io::AsyncReadExt; -use tokio::sync::{mpsc, watch}; +use tokio::sync::{Notify, mpsc, watch}; use tokio_util::sync::CancellationToken; use zeron_proto::{Chat, CheckoutDiff, DiffFileSummary}; use crate::EngineError; use crate::doc_host::EdgeConfig; +use crate::fs_watch::{FsWatch, FsWatchHub}; use crate::repos::{CheckoutIdentity, Repos}; use crate::workspace_host::WorkspaceHost; mod git_status; +mod turn_snapshot; + +pub use turn_snapshot::{TreeSnapshot, snapshot_tree}; /// Hard cap on the unified patch (plus untracked hunks) — "Partial snapshot". pub const MAX_PATCH_BYTES: usize = 3 * 1024 * 1024; @@ -61,6 +73,10 @@ pub const MAX_DIFF_SOURCE_BYTES: usize = 2 * 1024 * 1024; const WATCH_DEBOUNCE: Duration = Duration::from_millis(500); /// Slow repair pass: re-reconcile + re-sync every checkout. const REPAIR_INTERVAL: Duration = Duration::from_secs(120); +/// How long an archived chat stays tracked after its last viewer leaves — +/// flicking between chats must not tear down and rebuild (full capture) its +/// checkout every time. +const INTEREST_LINGER: Duration = Duration::from_secs(30); /// Max subdirectories a checkout may have before we skip its live recursive /// watch (one OS watch per dir; past this the watcher thread's own bookkeeping /// costs more than instant diffs are worth). A normal source tree is well @@ -127,16 +143,16 @@ struct CheckoutEntry { /// of CPU on a big checkout, so a single flapping chat-watch emission or /// transient identity failure must never destroy a live entry. orphaned_since: Mutex>, + /// A kick arrived while orphaned and was skipped — a chat-less checkout + /// is not worth a capture. Reconcile re-kicks if chats come back. + skipped_while_orphaned: AtomicBool, /// Kick channel into the entry's debounce/sync task. kick_tx: mpsc::UnboundedSender<()>, - /// Destructive mutations are serialized per checkout. File-system - /// watchers and read-only captures may still run concurrently. - discard_lock: tokio::sync::Mutex<()>, - /// Keeps the recursive fs watchers alive; dropped on entry close. Filled - /// asynchronously — watcher setup (budget walk + FSEvents registration) can - /// block for seconds, so [`add_entry`] does it off the runtime and attaches - /// the result here once ready. - watchers: Mutex>, + /// Keeps the recursive fs watches alive on the shared [`FsWatchHub`]; + /// dropped on entry close. Filled asynchronously — watcher setup (budget + /// walk + FSEvents registration) can block for seconds, so [`add_entry`] + /// does it off the runtime and attaches the result here once ready. + watchers: Mutex>, } /// Working-tree snapshot recorded when a chat's turn dispatches — the diff @@ -146,8 +162,9 @@ struct CheckoutEntry { pub struct TurnSnapshot { /// Canonical checkout root the tree was captured in. pub root: PathBuf, - /// `git write-tree` sha of the tracked + untracked (unignored) tree. - pub tree: String, + /// The tracked + untracked (unignored) tree, with the object store that + /// holds it. Replacing or dropping the snapshot deletes that store. + pub tree: TreeSnapshot, pub at: chrono::DateTime, } @@ -175,6 +192,84 @@ struct DiffSyncInner { /// upgraded Arc — the token cuts it so no sidecar HTTP outlives shutdown. cancel: CancellationToken, supervisor: Mutex>>, + /// chat_id → viewer interest; see [`CheckoutDiffSync::retain_chat`]. + interest: Mutex>, + /// How long released interest keeps an archived chat tracked. + interest_linger: Duration, + /// Wakes the supervisor to reconcile when an archived chat's interest + /// starts or lapses. + interest_changed: Arc, + /// Serializes discards, whether or not their checkout has an entry: an + /// entry can appear between the lookup and the lock, and two discards of + /// one root must never interleave. File-system watchers and read-only + /// captures may still run concurrently. + discard_lock: tokio::sync::Mutex<()>, +} + +#[derive(Default)] +struct Interest { + holders: usize, + /// When `holders` last fell to zero; interest lingers `interest_linger`. + released_at: Option, +} + +/// Keeps a chat tracked by diff sync while alive, even if archived. See +/// [`CheckoutDiffSync::retain_chat`]. +pub struct ChatInterest { + inner: Weak, + chat_id: String, +} + +impl Drop for ChatInterest { + fn drop(&mut self) { + let Some(inner) = self.inner.upgrade() else { + return; + }; + { + let mut interest = lock(&inner.interest); + let Some(state) = interest.get_mut(&self.chat_id) else { + return; + }; + state.holders = state.holders.saturating_sub(1); + if state.holders > 0 { + return; + } + state.released_at = Some(Instant::now()); + } + // Only an archived chat's entry depends on interest; reconcile once the + // linger runs out so its checkout is released. + if is_archived_here(&inner, &self.chat_id) + && let Ok(runtime) = tokio::runtime::Handle::try_current() + { + let notify = inner.interest_changed.clone(); + let linger = inner.interest_linger; + runtime.spawn(async move { + tokio::time::sleep(linger + Duration::from_millis(50)).await; + notify.notify_one(); + }); + } + } +} + +fn is_archived_here(inner: &DiffSyncInner, chat_id: &str) -> bool { + inner + .workspace + .watch_chats() + .borrow() + .iter() + .any(|chat| chat.id == chat_id && chat.archived && chat.device_id == inner.device_id) +} + +/// Chats with live or lingering interest; prunes lapsed records. +fn interested_chats(inner: &DiffSyncInner) -> HashSet { + let mut interest = lock(&inner.interest); + interest.retain(|_, state| { + state.holders > 0 + || state + .released_at + .is_some_and(|at| at.elapsed() < inner.interest_linger) + }); + interest.keys().cloned().collect() } fn lock(mutex: &Mutex) -> MutexGuard<'_, T> { @@ -209,8 +304,32 @@ impl CheckoutDiffSync { device_id: &str, edge: Option, orphan_grace: Duration, + ) -> Self { + Self::start_with_timings( + repos, + workspace, + device_id, + edge, + orphan_grace, + INTEREST_LINGER, + ) + } + + /// [`CheckoutDiffSync::start_with_orphan_grace`] plus an explicit interest + /// linger — test hook for archived-chat interest release. + #[doc(hidden)] + pub fn start_with_timings( + repos: Repos, + workspace: WorkspaceHost, + device_id: &str, + edge: Option, + orphan_grace: Duration, + interest_linger: Duration, ) -> Self { let (diffs_tx, _) = watch::channel(Vec::new()); + // Turn snapshots are in-memory state: whatever an earlier run left on + // disk is unreachable now. + turn_snapshot::sweep_stale_stores(repos.data_dir()); let sync = Self { inner: Arc::new(DiffSyncInner { repos, @@ -227,11 +346,16 @@ impl CheckoutDiffSync { turn_trees: Mutex::new(HashMap::new()), cancel: CancellationToken::new(), supervisor: Mutex::new(None), + interest: Mutex::new(HashMap::new()), + interest_linger, + interest_changed: Arc::new(Notify::new()), + discard_lock: tokio::sync::Mutex::new(()), }), }; let task = tokio::spawn(diff_sync_task( Arc::downgrade(&sync.inner), workspace.watch_chats(), + sync.inner.interest_changed.clone(), sync.inner.cancel.clone(), )); *lock(&sync.inner.supervisor) = Some(task); @@ -276,6 +400,48 @@ impl CheckoutDiffSync { self.sync_all(); } + /// Tracked checkout roots, each with its count of live fs watches — test + /// hook (watches attach asynchronously after an entry is added). + #[doc(hidden)] + pub fn tracked_checkouts(&self) -> Vec<(PathBuf, usize)> { + let mut tracked: Vec<(PathBuf, usize)> = lock(&self.inner.entries) + .values() + .map(|entry| { + let live = lock(&entry.watchers).iter().filter(|w| w.is_live()).count(); + (entry.identity.root.clone(), live) + }) + .collect(); + tracked.sort(); + tracked + } + + /// Keep `chat_id` tracked while the returned guard lives — even when the + /// chat is archived. Archived chats otherwise cost nothing (no entry, + /// watch, capture or sidecar), but an archived chat someone has open must + /// still show its checkout's live diff, accept a discard, and get its + /// `checkoutId` stamped. The engine holds one guard per open transcript + /// stream; released interest lingers [`INTEREST_LINGER`]. + pub fn retain_chat(&self, chat_id: &str) -> ChatInterest { + let first = { + let mut interest = lock(&self.inner.interest); + let state = interest.entry(chat_id.to_string()).or_default(); + state.holders += 1; + let lapsed = state + .released_at + .take() + .is_none_or(|at| at.elapsed() >= self.inner.interest_linger); + state.holders == 1 && lapsed + }; + // Non-archived chats are tracked anyway — no reconcile needed. + if first && is_archived_here(&self.inner, chat_id) { + self.inner.interest_changed.notify_one(); + } + ChatInterest { + inner: Arc::downgrade(&self.inner), + chat_id: chat_id.to_string(), + } + } + /// Kick an immediate sync of every tracked checkout (repair-tick path). pub fn sync_all(&self) { for entry in lock(&self.inner.entries).values() { @@ -294,9 +460,13 @@ impl CheckoutDiffSync { let Some(inner) = inner.upgrade() else { return }; let identity = match inner.repos.checkout_identity(&cwd).await { Ok(identity) => identity, - Err(_) => return, // not a checkout + Err(_) => { + // Not a checkout: no turn to diff, not the previous one. + lock(&inner.turn_trees).remove(&chat_id); + return; + } }; - match snapshot_tree(&identity.root).await { + match snapshot_tree(&inner.repos, &identity.root).await { Ok(tree) => { lock(&inner.turn_trees).insert( chat_id, @@ -308,6 +478,8 @@ impl CheckoutDiffSync { ); } Err(err) => { + // Keeping the previous base would silently diff two turns. + lock(&inner.turn_trees).remove(&chat_id); tracing::debug!(chat = %chat_id, error = %err, "diff-sync: turn snapshot failed"); } @@ -321,25 +493,33 @@ impl CheckoutDiffSync { lock(&self.inner.turn_trees).get(chat_id).cloned() } - /// Discard the complete uncommitted state for a tracked checkout after - /// verifying that the UI acted on the latest full snapshot. + /// Discard the complete uncommitted state of a checkout after verifying + /// that the UI acted on the latest full snapshot. A checkout without an + /// entry (e.g. only archived chats, interest not yet reconciled) is + /// discarded directly; there is just no live diff to republish. pub async fn discard_working_tree( &self, - checkout_id: &str, + identity: &CheckoutIdentity, expected_checksum: &str, ) -> Result { - let entry = lock(&self.inner.entries) - .get(checkout_id) - .cloned() - .ok_or_else(|| EngineError::Other("checkout is no longer available".into()))?; + let entry = lock(&self.inner.entries).get(&identity.id).cloned(); let inner = self.inner.clone(); let expected_checksum = expected_checksum.to_owned(); + let Some(entry) = entry else { + let root = identity.root.clone(); + return tokio::spawn(async move { + let _guard = inner.discard_lock.lock().await; + discard_working_tree(&inner.repos, &root, &expected_checksum).await + }) + .await + .map_err(|error| EngineError::Other(format!("discard task failed: {error}")))?; + }; // Own task: a started discard always runs to completion even if the // RPC caller goes away, and the nested git captures start from a fresh // worker stack instead of stacking on the RPC dispatcher's frames // (which overflowed the 2 MiB worker stack in debug builds). tokio::spawn(async move { - let _guard = entry.discard_lock.lock().await; + let _guard = inner.discard_lock.lock().await; let result = discard_working_tree(&inner.repos, &entry.identity.root, &expected_checksum).await; @@ -413,10 +593,19 @@ async fn reconcile(inner: &Arc, chats: Vec, fresh: bool) { let mut groups: HashMap)> = HashMap::new(); // Dedupe resolution within this pass — many chats share one checkout. let mut resolved: HashMap> = HashMap::new(); + // This device's archived chats nobody has open: never grouped (no entry, + // watcher, capture or sidecar), and an entry left holding only these is + // dropped below. An archived chat with a viewer is grouped like any other. + let interested = interested_chats(inner); + let mut archived: HashSet = HashSet::new(); for chat in chats { if chat.device_id != inner.device_id { continue; } + if chat.archived && !interested.contains(&chat.id) { + archived.insert(chat.id); + continue; + } let Some(cwd) = chat.cwd.clone() else { continue; }; @@ -443,18 +632,43 @@ async fn reconcile(inner: &Arc, chats: Vec, fresh: bool) { .1 .push(chat); } + // A turn snapshot holds an object store on disk: keep it only for chats + // that can still show "Latest turn". Judged on the current rows, not this + // pass's (possibly older) list, so a chat created meanwhile keeps its own. + { + let current = inner.workspace.watch_chats(); + let current = current.borrow(); + let live: HashSet<&str> = current + .iter() + .filter(|chat| !chat.archived || interested.contains(&chat.id)) + .map(|chat| chat.id.as_str()) + .collect(); + lock(&inner.turn_trees).retain(|chat_id, _| live.contains(chat_id.as_str())); + } // Close entries whose checkout has had no chats for a full grace period; // drop their published diff. A single pass that misses a checkout only // *marks* it — teardown is expensive to undo (re-add kicks a capture), so - // absence must be sustained before we act on it. + // absence must be sustained before we act on it. Archiving is not a flap: + // an entry whose every chat is now an archived row goes immediately. let removed: Vec = { let now = std::time::Instant::now(); let mut entries = lock(&inner.entries); let mut removed = Vec::new(); for (id, entry) in entries.iter() { if groups.contains_key(id) { - *lock(&entry.orphaned_since) = None; + let was_orphaned = lock(&entry.orphaned_since).take().is_some(); + if was_orphaned && entry.skipped_while_orphaned.swap(false, Ordering::AcqRel) { + let _ = entry.kick_tx.send(()); // catch up on what we skipped + } + continue; + } + let archived_away = { + let held = lock(&entry.chats); + !held.is_empty() && held.iter().all(|chat| archived.contains(&chat.id)) + }; + if archived_away { + removed.push(id.clone()); continue; } let mut orphaned = lock(&entry.orphaned_since); @@ -567,8 +781,8 @@ fn add_entry(inner: &Arc, identity: CheckoutIdentity, chats: Vec< chats: Mutex::new(chats), checksum: Mutex::new(None), orphaned_since: Mutex::new(None), + skipped_while_orphaned: AtomicBool::new(false), kick_tx: kick_tx.clone(), - discard_lock: tokio::sync::Mutex::new(()), watchers: Mutex::new(Vec::new()), }); lock(&inner.entries).insert(entry.identity.id.clone(), entry.clone()); @@ -585,48 +799,39 @@ fn add_entry(inner: &Arc, identity: CheckoutIdentity, chats: Vec< // for seconds when fseventsd is contended. Doing it inline starved the whole // runtime (workspace watches, presence) whenever entries were (re)built, so // it runs on the blocking pool and attaches to the entry when ready. Events - // occurring before attachment are covered by the initial sync; one extra - // kick after attachment closes the capture→attach gap. + // occurring before attachment are covered by the initial sync; the hub's + // rescan when each watch goes live closes the capture→attach gap. let weak = Arc::downgrade(&entry); tokio::task::spawn_blocking(move || { let Some(entry) = weak.upgrade() else { return; // entry removed before watchers were ready }; - let watchers = build_watchers(&entry.identity, &kick_tx); + let watchers = build_watchers(&FsWatchHub::global(), &entry.identity, &kick_tx); *lock(&entry.watchers) = watchers; - let _ = kick_tx.send(()); }); } -/// Recursive watchers on the worktree root (budget permitting) and the git -/// dir — HEAD/index churn and file edits both land here. Failures are fine: -/// the initial + repair sync still keep the snapshot correct. Blocking — call -/// from the blocking pool. +/// Recursive watches on the worktree root (budget permitting) and the git +/// dir — HEAD/index churn and file edits both land here. Registered on the +/// shared [`FsWatchHub`], so a checkout costs OS watches but no thread of its +/// own; the hub's rescan signals (watch went live, events possibly lost) kick +/// like any change. Failures are fine: the initial + repair sync still keep +/// the snapshot correct. Blocking (budget walk) — call from the blocking pool. fn build_watchers( + hub: &Arc, identity: &CheckoutIdentity, kick_tx: &mpsc::UnboundedSender<()>, -) -> Vec { +) -> Vec { let mut watchers = Vec::new(); for target in watch_targets(identity) { let tx = kick_tx.clone(); - let watcher = - notify::recommended_watcher(move |event: Result| { - if event.as_ref().is_ok_and(is_checkout_change) { + watchers.push( + hub.watch(&target, notify::RecursiveMode::Recursive, move |event| { + if is_checkout_change(event) { let _ = tx.send(()); } - }); - match watcher { - Ok(mut watcher) => { - use notify::Watcher as _; - match watcher.watch(&target, notify::RecursiveMode::Recursive) { - Ok(()) => watchers.push(watcher), - Err(err) => { - tracing::debug!(path = %target.display(), error = %err, "diff-sync: watch failed") - } - } - } - Err(err) => tracing::debug!(error = %err, "diff-sync: watcher create failed"), - } + }), + ); } watchers } @@ -659,6 +864,13 @@ async fn entry_task( let (Some(inner), Some(entry)) = (inner.upgrade(), entry.upgrade()) else { return; }; + // No chats (deleted / moved away; teardown pending the orphan grace): + // nobody consumes this checkout's diff, so skip the capture — repair + // ticks and fs churn included. Reconcile re-kicks if chats return. + if lock(&entry.orphaned_since).is_some() { + entry.skipped_while_orphaned.store(true, Ordering::Release); + continue; + } // The upgraded Arc would let a sync outlive shutdown — race the token // so an in-flight sidecar POST is dropped, not completed. tokio::select! { @@ -789,6 +1001,7 @@ fn publish_watch(inner: &Arc) { async fn diff_sync_task( inner: Weak, mut chats_rx: watch::Receiver>, + interest_changed: Arc, cancel: CancellationToken, ) { let mut repair = tokio::time::interval(REPAIR_INTERVAL); @@ -807,6 +1020,12 @@ async fn diff_sync_task( // sync itself writes them) and must never fan out into git. reconcile(&inner, chats, false).await; } + // An archived chat gained its first viewer or its interest lapsed. + _ = interest_changed.notified() => { + let Some(inner) = inner.upgrade() else { break }; + let chats = chats_rx.borrow().clone(); + reconcile(&inner, chats, false).await; + } _ = repair.tick() => { let Some(inner) = inner.upgrade() else { break }; let chats = chats_rx.borrow().clone(); @@ -831,7 +1050,25 @@ struct Capture { /// Run git capturing stdout under a hard byte ceiling — the child is killed once /// the cap is hit, so an arbitrarily large repository diff never buffers fully. -async fn capture_git(cwd: &Path, args: &[&str], max_bytes: usize) -> Result { +/// +/// Not an `async fn`: a wrapping future would add a poll frame, and a copy of +/// the 64 KiB read buffer, to every capture on the RPC worker's stack. +fn capture_git<'a, S: AsRef>( + cwd: &'a Path, + args: &'a [S], + max_bytes: usize, +) -> impl Future> + 'a { + capture_git_in(cwd, args, max_bytes, None) +} + +/// [`capture_git`] that can also see a turn snapshot's objects, which exist +/// only in that snapshot's store. +async fn capture_git_in>( + cwd: &Path, + args: &[S], + max_bytes: usize, + snapshot: Option<&TreeSnapshot>, +) -> Result { let mut cmd = tokio::process::Command::new("git"); #[cfg(windows)] { @@ -839,6 +1076,12 @@ async fn capture_git(cwd: &Path, args: &[&str], max_bytes: usize) -> Result Result = Vec::new(); - let mut buf = [0u8; 64 * 1024]; + // Keep the read buffer out of nested async state: inline storage grows + // every caller's future and can overflow a standard RPC worker's stack. + let mut buf = vec![0u8; 64 * 1024]; let mut truncated = false; loop { let n = stdout @@ -1090,9 +1335,20 @@ async fn read_worktree_source(root: &Path, path: &Path) -> Result Result { +async fn read_git_source( + root: &Path, + revision: &str, + snapshot: Option<&TreeSnapshot>, + path: &Path, +) -> Result { let spec = format!("{revision}:{}", path.to_string_lossy()); - capture_git(root, &["cat-file", "blob", &spec], MAX_DIFF_SOURCE_BYTES).await + capture_git_in( + root, + &["cat-file", "blob", &spec], + MAX_DIFF_SOURCE_BYTES, + snapshot, + ) + .await } /// Read the exact old/new documents for one file in a previously captured diff. @@ -1103,15 +1359,18 @@ pub async fn read_diff_file_text( base: &str, file: &DiffFileSummary, ) -> Result { - read_diff_file_text_at(root, base, None, file).await + read_diff_file_text_at(root, base, None, None, file).await } /// Read the exact old/new documents for one file in a diff between `base` and /// an optional committed target. Without a target, the new source is the live -/// working tree; with one, both sources are immutable Git blobs. +/// working tree; with one, both sources are immutable Git blobs. A `base` that +/// is a turn snapshot's tree needs that snapshot as `base_snapshot`: its +/// objects are not in the checkout. pub(crate) async fn read_diff_file_text_at( root: &Path, base: &str, + base_snapshot: Option<&TreeSnapshot>, target: Option<&str>, file: &DiffFileSummary, ) -> Result { @@ -1121,12 +1380,12 @@ pub(crate) async fn read_diff_file_text_at( let old = if file.status == "added" { None } else { - Some(read_git_source(root, base, old_path).await?) + Some(read_git_source(root, base, base_snapshot, old_path).await?) }; let new = if file.status == "deleted" { None } else if let Some(target) = target { - Some(read_git_source(root, target, new_path).await?) + Some(read_git_source(root, target, None, new_path).await?) } else { Some(read_worktree_source(root, new_path).await?) }; @@ -1722,65 +1981,22 @@ pub async fn merge_base(root: &Path, base_ref: &str) -> Result Result { - let index = std::env::temp_dir().join(format!( - "zeron-turn-index-{}-{}", - std::process::id(), - chrono::Utc::now().timestamp_micros() - )); - let run = |args: &[&str]| { - let mut cmd = tokio::process::Command::new("git"); - #[cfg(windows)] - { - use std::os::windows::process::CommandExt; - cmd.as_std_mut().creation_flags(0x08000000); - } - cmd.arg("-C").arg(root).args(args); - cmd.env("GIT_INDEX_FILE", &index); - cmd.stdin(std::process::Stdio::null()); - cmd.output() - }; - let added = run(&["add", "-A", "--ignore-errors", "."]) - .await - .map_err(|e| EngineError::Other(format!("git add failed: {e}")))?; - if !added.status.success() { - let _ = tokio::fs::remove_file(&index).await; - return Err(EngineError::Other(format!( - "git add: {}", - String::from_utf8_lossy(&added.stderr).trim() - ))); - } - let written = run(&["write-tree"]) - .await - .map_err(|e| EngineError::Other(format!("git write-tree failed: {e}"))); - let _ = tokio::fs::remove_file(&index).await; - let written = written?; - if !written.status.success() { - return Err(EngineError::Other(format!( - "git write-tree: {}", - String::from_utf8_lossy(&written.stderr).trim() - ))); - } - Ok(String::from_utf8_lossy(&written.stdout).trim().to_string()) -} - /// "Latest turn" capture: tree-to-tree diff from the turn-start snapshot to a /// fresh [`snapshot_tree`] of the current state. Both trees carry untracked /// (unignored) files, so no synthesis is needed and a file that was already /// untracked at turn start diffs correctly (the watch capture's synthesis /// would misreport it as entirely new). +/// +/// The fresh snapshot is layered on the turn's store, so it only writes the +/// files that changed since, and it is deleted again when this returns. pub async fn capture_turn_diff( repos: &Repos, root: &Path, - turn_tree: &str, + turn: &TreeSnapshot, ) -> Result { - let current = snapshot_tree(root).await?; + let current = turn_snapshot::snapshot_tree_onto(repos, root, Some(turn)).await?; + let turn_tree = turn.tree(); + let current_tree = current.tree(); let head = capture_git(root, &["rev-parse", "--verify", "HEAD"], 256) .await .map(|c| String::from_utf8_lossy(&c.stdout).trim().to_string()) @@ -1790,7 +2006,7 @@ pub async fn capture_turn_diff( .await .unwrap_or_else(|_| "HEAD".into()); - let names = capture_git( + let names = capture_git_in( root, &[ "diff", @@ -1798,13 +2014,14 @@ pub async fn capture_turn_diff( "-z", "--find-renames", turn_tree, - ¤t, + current_tree, "--", ], 2 * 1024 * 1024, + Some(¤t), ) .await?; - let nums = capture_git( + let nums = capture_git_in( root, &[ "diff", @@ -1812,13 +2029,14 @@ pub async fn capture_turn_diff( "-z", "--find-renames", turn_tree, - ¤t, + current_tree, "--", ], 2 * 1024 * 1024, + Some(¤t), ) .await?; - let tracked = capture_git( + let tracked = capture_git_in( root, &[ "diff", @@ -1827,10 +2045,11 @@ pub async fn capture_turn_diff( "--find-renames", "--unified=3", turn_tree, - ¤t, + current_tree, "--", ], MAX_PATCH_BYTES, + Some(¤t), ) .await?; @@ -1889,8 +2108,14 @@ mod watch_budget_tests { let root = std::fs::canonicalize(tmp.path()).unwrap(); std::fs::write(root.join("a.txt"), "one\n").unwrap(); let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); - let _watchers = super::build_watchers(&identity(&root, &root.join(".git")), &tx); - // Let registration settle and drop anything it raised. + let hub = crate::fs_watch::FsWatchHub::new(); + let watchers = super::build_watchers(&hub, &identity(&root, &root.join(".git")), &tx); + // Install now (the batch worker would within ~100 ms) and expect the + // attach rescan's kick; then let registration settle and drop + // anything it raised. + hub.apply(); + assert!(watchers.iter().all(super::FsWatch::is_live)); + rx.try_recv().expect("going live kicks a sync"); tokio::time::sleep(std::time::Duration::from_millis(300)).await; while rx.try_recv().is_ok() {} diff --git a/crates/engine/src/diff_sync/turn_snapshot.rs b/crates/engine/src/diff_sync/turn_snapshot.rs new file mode 100644 index 0000000000..750d1f3077 --- /dev/null +++ b/crates/engine/src/diff_sync/turn_snapshot.rs @@ -0,0 +1,1069 @@ +//! Working-tree snapshots for the "Latest turn" scope, kept out of the +//! checkout's object database. +//! +//! A snapshot is `git add -A` into a throwaway index followed by `git +//! write-tree`, and `git add` writes a blob for every file it hashes. Those +//! blobs used to land in the checkout's own `.git/objects`, where nothing ever +//! referenced them. A checkout with an unignored build directory collected +//! ~20 GB of loose objects that way, and git's auto-maintenance (run after +//! every commit, merge and fetch) then tried to delta-compress them: one +//! multi-gigabyte `pack-objects` per agent commit, 23 at once on the machine +//! that reported it. +//! +//! Two rules keep that from happening again: +//! +//! - **Zeron's objects stay in Zeron's directory.** Each snapshot writes into +//! its own object directory under the data dir (`GIT_OBJECT_DIRECTORY`) and +//! reads the checkout's objects as an alternate. The checkout's database is +//! never written to, and the directory is deleted with the snapshot. +//! - **The work is bounded.** Untracked files past [`MAX_UNTRACKED_FILE_BYTES`] +//! and untracked directories past the untracked budget are left out, and the +//! whole snapshot runs under [`SNAPSHOT_TIMEOUT`]. A left-out path simply +//! never shows in the turn diff; tracked files are always snapshotted. +//! +//! Git still refreshes the mtime of checkout objects a snapshot finds there +//! instead of writing them (as it did before), so pruning the checkout while +//! a turn is open can make that turn's diff unreadable until the next turn. + +use std::ffi::{OsStr, OsString}; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, OnceLock}; +use std::time::Duration; + +use tokio::io::AsyncWriteExt; +use tokio_util::sync::CancellationToken; + +use super::{MAX_PATH_ARGUMENT_BYTES, capture_git, path_argument, path_batches}; +use crate::EngineError; +use crate::repos::Repos; + +/// Snapshot stores live in `{data_dir}/turn-snapshots/{run}/{snapshot}`. +const STORE_DIR: &str = "turn-snapshots"; +/// Largest untracked file a snapshot carries. The Changes pane cannot render +/// more than [`super::MAX_DIFF_SOURCE_BYTES`] of a file anyway; the headroom +/// keeps ordinary binary assets (screenshots, fonts) in the file list. +const MAX_UNTRACKED_FILE_BYTES: u64 = 8 * 1024 * 1024; +/// Budget for untracked content, applied to each untracked directory on its +/// own and then to everything that is left. Source-sized trees fit with room +/// to spare; build output, dependency trees and datasets do not. +const MAX_UNTRACKED_FILES: usize = 10_000; +const MAX_UNTRACKED_BYTES: u64 = 256 * 1024 * 1024; +/// Cap on one untracked listing from git. +const MAX_UNTRACKED_LISTING_BYTES: usize = 8 * 1024 * 1024; +/// Backstop for a whole snapshot (a wedged mount, a hung clean filter). The +/// budgets above keep a healthy checkout far below it. +const SNAPSHOT_TIMEOUT: Duration = Duration::from_secs(120); + +#[cfg(windows)] +const ALTERNATES_SEPARATOR: char = ';'; +#[cfg(not(windows))] +const ALTERNATES_SEPARATOR: char = ':'; + +/// A working tree written as a git tree, together with the private object +/// store that holds it. Cloning shares the store; the last clone to drop +/// deletes it. +#[derive(Debug, Clone)] +pub struct TreeSnapshot { + tree: String, + store: Arc, + /// Untracked paths left out of `tree` (git's spelling, from the root). + /// A snapshot layered on this one leaves them as they are here. + skipped: Arc>>, +} + +impl TreeSnapshot { + /// `git write-tree` sha of the tracked + untracked (unignored) tree. + pub fn tree(&self) -> &str { + &self.tree + } + + /// Point a git command at this snapshot's objects. Without it the tree + /// does not exist as far as the checkout is concerned. + pub(super) fn apply_to(&self, cmd: &mut tokio::process::Command) { + self.store.apply_to(cmd); + } +} + +/// One snapshot's object directory. +#[derive(Debug)] +struct ObjectStore { + dir: PathBuf, + /// `GIT_ALTERNATE_OBJECT_DIRECTORIES`: the checkout's objects, behind the + /// base snapshot's when there is one. + alternates: OsString, + /// A layered snapshot reads through its base's directory, so the base + /// has to outlive it. + _base: Option>, +} + +impl ObjectStore { + async fn create( + repos: &Repos, + root: &Path, + base: Option<&Arc>, + ) -> Result { + let alternates = match base { + Some(base) => { + let mut value = alternates_value(&[&base.objects_dir()]); + value.push(ALTERNATES_SEPARATOR.to_string()); + value.push(&base.alternates); + value + } + None => { + // `--git-path` resolves linked worktrees to the shared database. + let listed = capture_git( + root, + &[ + "rev-parse", + "--path-format=absolute", + "--git-path", + "objects", + ], + 16 * 1024, + ) + .await?; + let mut path = listed.stdout.as_slice(); + while let Some(rest) = path + .strip_suffix(b"\n") + .or_else(|| path.strip_suffix(b"\r")) + { + path = rest; + } + if path.is_empty() { + return Err(EngineError::Other("git object directory unknown".into())); + } + let mut value = alternates_value(&[Path::new(&path_argument(path))]); + // A checkout that already borrows objects this way keeps doing so. + if let Some(inherited) = std::env::var_os("GIT_ALTERNATE_OBJECT_DIRECTORIES") + .filter(|inherited| !inherited.is_empty()) + { + value.push(ALTERNATES_SEPARATOR.to_string()); + value.push(inherited); + } + value + } + }; + // Absolute: git resolves `GIT_OBJECT_DIRECTORY` against the checkout + // (`-C`), not against this process's directory. + let dir = std::path::absolute(store_root(repos.data_dir())) + .map_err(|error| EngineError::Other(format!("turn snapshot store: {error}")))? + .join(uuid::Uuid::new_v4().to_string()); + let objects = dir.join("objects"); + tokio::fs::create_dir_all(&objects) + .await + .map_err(|error| EngineError::Other(format!("turn snapshot store: {error}")))?; + Ok(Self { + dir, + alternates, + _base: base.cloned(), + }) + } + + fn objects_dir(&self) -> PathBuf { + self.dir.join("objects") + } + + fn apply_to(&self, cmd: &mut tokio::process::Command) { + cmd.env("GIT_OBJECT_DIRECTORY", self.objects_dir()); + cmd.env("GIT_ALTERNATE_OBJECT_DIRECTORIES", &self.alternates); + } +} + +impl Drop for ObjectStore { + fn drop(&mut self) { + let dir = std::mem::take(&mut self.dir); + // A store can hold thousands of loose objects: delete it off the + // runtime's worker threads. + match tokio::runtime::Handle::try_current() { + Ok(runtime) => { + runtime.spawn_blocking(move || { + let _ = std::fs::remove_dir_all(dir); + }); + } + Err(_) => { + let _ = std::fs::remove_dir_all(dir); + } + } + } +} + +/// Encode directories for `GIT_ALTERNATE_OBJECT_DIRECTORIES`. Git splits the +/// value on the platform's path-list separator, and reads an entry that +/// starts with `"` as a C-quoted path, which is the only way to name a +/// directory containing that separator. +fn alternates_value(dirs: &[&Path]) -> OsString { + let mut value = OsString::new(); + for (index, dir) in dirs.iter().enumerate() { + if index > 0 { + value.push(ALTERNATES_SEPARATOR.to_string()); + } + match dir.to_str() { + Some(text) if text.contains(ALTERNATES_SEPARATOR) || text.starts_with('"') => { + value.push("\""); + value.push(text.replace('\\', "\\\\").replace('"', "\\\"")); + value.push("\""); + } + _ => value.push(dir.as_os_str()), + } + } + value +} + +/// This process's stores. An engine holds its data dir exclusively +/// ([`crate::InstanceLock`]), so any sibling directory was left by a run that +/// is gone — see [`sweep_stale_stores`]. +fn store_root(data_dir: &Path) -> PathBuf { + data_dir.join(STORE_DIR).join(run_id()) +} + +fn run_id() -> &'static str { + static RUN_ID: OnceLock = OnceLock::new(); + RUN_ID.get_or_init(|| { + format!( + "{}-{}", + std::process::id(), + chrono::Utc::now().timestamp_micros() + ) + }) +} + +/// Delete the stores of earlier engine runs. Snapshots are in-memory state, so +/// nothing on disk from a previous run is reachable; a run that was killed, or +/// exited without unwinding, leaves its stores behind for this to collect. +/// Requires a tokio runtime. +pub(super) fn sweep_stale_stores(data_dir: &Path) { + let parent = data_dir.join(STORE_DIR); + tokio::task::spawn_blocking(move || remove_other_runs(&parent, OsStr::new(run_id()))); +} + +fn remove_other_runs(parent: &Path, current: &OsStr) { + let Ok(entries) = std::fs::read_dir(parent) else { + return; + }; + for entry in entries.flatten() { + if entry.file_name() == current { + continue; + } + let path = entry.path(); + if std::fs::remove_dir_all(&path).is_err() { + let _ = std::fs::remove_file(&path); + } + } +} + +/// Write the checkout's current tracked + untracked (unignored) tree into a +/// private object store: `git add -A` under a throwaway `GIT_INDEX_FILE`, then +/// `git write-tree`. Neither the real index nor the checkout's object database +/// is touched. Costs one hash pass over the tracked files and the untracked +/// files within budget (no stat cache in a fresh index). +pub async fn snapshot_tree(repos: &Repos, root: &Path) -> Result { + snapshot_tree_onto(repos, root, None).await +} + +/// [`snapshot_tree`] layered on `base`: objects the base already holds are +/// read from its store instead of being written again, so the new store only +/// carries what changed since. The result keeps `base` alive. +pub(super) async fn snapshot_tree_onto( + repos: &Repos, + root: &Path, + base: Option<&TreeSnapshot>, +) -> Result { + // The git children are `kill_on_drop`: timing out takes them down too. + // Boxed because the future embeds several 64 KiB git read buffers, which + // callers would otherwise carry inline in their own frames. + let snapshot = Box::pin(write_snapshot(repos, root, base)); + match tokio::time::timeout(SNAPSHOT_TIMEOUT, snapshot).await { + Ok(result) => result, + Err(_) => Err(EngineError::Other(format!( + "turn snapshot timed out after {}s", + SNAPSHOT_TIMEOUT.as_secs() + ))), + } +} + +async fn write_snapshot( + repos: &Repos, + root: &Path, + base: Option<&TreeSnapshot>, +) -> Result { + let store = Arc::new(ObjectStore::create(repos, root, base.map(|base| &base.store)).await?); + let mut skipped = untracked_to_skip(root).await?; + let index = store.dir.join("index"); + if let Some(base) = base { + // Start from the base tree and leave out what either side left out: + // a path skipped on one side only (a directory that crossed the + // budget mid-turn) then keeps its base state instead of showing as + // thousands of added or deleted files. + let read = snapshot_git(root, &store, &index, &["read-tree", base.tree()], None) + .await + .map_err(|e| EngineError::Other(format!("git read-tree failed: {e}")))?; + if !read.status.success() { + return Err(EngineError::Other(format!( + "git read-tree: {}", + String::from_utf8_lossy(&read.stderr).trim() + ))); + } + let known: std::collections::HashSet<&[u8]> = skipped.iter().map(Vec::as_slice).collect(); + let inherited: Vec> = base + .skipped + .iter() + .filter(|path| !known.contains(path.as_slice())) + .cloned() + .collect(); + skipped.extend(inherited); + } + // NUL-separated pathspecs on stdin: no argv limit, no quoting. + let mut pathspecs = b".\0".to_vec(); + for path in &skipped { + pathspecs.extend_from_slice(b":(top,exclude,literal)"); + pathspecs.extend_from_slice(path); + pathspecs.push(0); + } + let added = snapshot_git( + root, + &store, + &index, + &[ + "add", + "-A", + "--ignore-errors", + "--pathspec-from-file=-", + "--pathspec-file-nul", + ], + Some(pathspecs), + ) + .await + .map_err(|e| EngineError::Other(format!("git add failed: {e}")))?; + if !added.status.success() { + return Err(EngineError::Other(format!( + "git add: {}", + String::from_utf8_lossy(&added.stderr).trim() + ))); + } + let written = snapshot_git(root, &store, &index, &["write-tree"], None) + .await + .map_err(|e| EngineError::Other(format!("git write-tree failed: {e}"))); + // The tree is all that is kept; the index can run to tens of megabytes. + let _ = tokio::fs::remove_file(&index).await; + let written = written?; + if !written.status.success() { + return Err(EngineError::Other(format!( + "git write-tree: {}", + String::from_utf8_lossy(&written.stderr).trim() + ))); + } + Ok(TreeSnapshot { + tree: String::from_utf8_lossy(&written.stdout).trim().to_string(), + store, + skipped: Arc::new(skipped), + }) +} + +async fn snapshot_git( + root: &Path, + store: &ObjectStore, + index: &Path, + args: &[&str], + stdin: Option>, +) -> std::io::Result { + let mut cmd = tokio::process::Command::new("git"); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + cmd.as_std_mut().creation_flags(0x08000000); + } + cmd.arg("-C").arg(root).args(args); + cmd.env("GIT_INDEX_FILE", index); + store.apply_to(&mut cmd); + cmd.stdin(if stdin.is_some() { + std::process::Stdio::piped() + } else { + std::process::Stdio::null() + }); + cmd.stdout(std::process::Stdio::piped()); + cmd.stderr(std::process::Stdio::piped()); + cmd.kill_on_drop(true); + let mut child = cmd.spawn()?; + if let Some(input) = stdin + && let Some(mut pipe) = child.stdin.take() + { + // A short pathspec list would drop exclusions, so a failed write is + // an error rather than something to carry on from. + pipe.write_all(&input).await?; + pipe.shutdown().await?; + } + child.wait_with_output().await +} + +/// The untracked paths `git add` must leave out, as git spells them relative +/// to the checkout root. +/// +/// Git lists untracked content as units: a file, or a wholly untracked +/// directory. A directory over the untracked budget goes as one unit (a build +/// tree is useless in part); inside the directories that fit, and among loose +/// files, only files past [`MAX_UNTRACKED_FILE_BYTES`] go. If what remains +/// still exceeds the budget, every untracked unit goes and the snapshot covers +/// tracked files only. +async fn untracked_to_skip(root: &Path) -> Result>, EngineError> { + let listing = capture_git( + root, + &[ + "ls-files", + "--others", + "--exclude-standard", + "--directory", + "-z", + ], + MAX_UNTRACKED_LISTING_BYTES, + ) + .await?; + if listing.truncated { + return Err(EngineError::Other( + "too many untracked paths to snapshot".into(), + )); + } + let units = split_nul(&listing.stdout); + if units.is_empty() { + return Ok(Vec::new()); + } + + // Budget-check directories with a walk that stops at the budget, before + // asking git to enumerate them: git lists a directory in full or not at + // all, which takes seconds on a build tree. The walk runs on a blocking + // thread, which outlives this future if the snapshot times out, so the + // guard tells it to stop. + let cancel = CancellationToken::new(); + let _stop_walk = cancel.clone().drop_guard(); + let walk_root = root.to_path_buf(); + let walk_units = units.clone(); + let (mut skipped, dirs, files) = + tokio::task::spawn_blocking(move || triage_units(&walk_root, walk_units, &cancel)) + .await + .map_err(|error| EngineError::Other(format!("untracked walk: {error}")))?; + + // Files inside the directories that fit, in git's own spelling: only git + // knows which of them are ignored, and the names go back to git as + // pathspecs. Loose files are units of their own. + let mut measured: Vec = files + .into_iter() + .map(|path| Unit { + files: vec![path.clone()], + path, + }) + .collect(); + let dir_units: std::collections::HashMap, usize> = dirs + .iter() + .enumerate() + .map(|(index, dir)| (dir.clone(), measured.len() + index)) + .collect(); + measured.extend(dirs.iter().map(|dir| Unit { + path: dir.clone(), + files: Vec::new(), + })); + for batch in path_batches(&dirs, MAX_PATH_ARGUMENT_BYTES) { + let mut args: Vec = [ + "--literal-pathspecs", + "ls-files", + "--others", + "--exclude-standard", + "-z", + "--", + ] + .into_iter() + .map(OsString::from) + .collect(); + args.extend(batch.iter().map(|dir| path_argument(dir))); + let listed = capture_git(root, &args, MAX_UNTRACKED_LISTING_BYTES).await?; + if listed.truncated { + // Unmeasurable: leave every directory out, keep the loose files. + skipped.extend(dirs); + return Ok(skipped); + } + for file in split_nul(&listed.stdout) { + // Git's untracked units never nest: the file belongs to the one + // listed directory among its ancestors. + let owner = file + .iter() + .enumerate() + .filter(|(_, byte)| **byte == b'/') + .find_map(|(end, _)| dir_units.get(&file[..=end])); + if let Some(&owner) = owner { + measured[owner].files.push(file); + } + } + } + + let stat_root = root.to_path_buf(); + let (oversized, excess) = tokio::task::spawn_blocking(move || fit_units(&stat_root, measured)) + .await + .map_err(|error| EngineError::Other(format!("untracked stat: {error}")))?; + skipped.extend(oversized); + skipped.extend(excess); + Ok(skipped) +} + +/// An untracked unit (a loose file, or a wholly untracked directory) and the +/// files it holds, as git spells them. +struct Unit { + path: Vec, + files: Vec>, +} + +fn split_nul(bytes: &[u8]) -> Vec> { + bytes + .split(|byte| *byte == 0) + .filter(|path| !path.is_empty()) + .map(<[u8]>::to_vec) + .collect() +} + +/// Sort untracked units into directories over budget (skipped whole), +/// directories that fit, and loose files. Git marks a directory with a +/// trailing slash. +#[allow(clippy::type_complexity)] +fn triage_units( + root: &Path, + units: Vec>, + cancel: &CancellationToken, +) -> (Vec>, Vec>, Vec>) { + let mut skipped = Vec::new(); + let mut dirs = Vec::new(); + let mut files = Vec::new(); + for unit in units { + if cancel.is_cancelled() { + break; + } + if !unit.ends_with(b"/") { + files.push(unit); + } else if dir_exceeds_budget(&root.join(path_argument(&unit))) { + skipped.push(unit); + } else { + dirs.push(unit); + } + } + (skipped, dirs, files) +} + +/// True once `dir` holds more unignored content than the untracked budget. +/// Stops at the budget, so the cost is bounded however large the directory +/// is. Files past the per-file cap count as files but not as bytes: they are +/// left out one by one, and must not condemn the directory around them. +fn dir_exceeds_budget(dir: &Path) -> bool { + let mut files = 0usize; + let mut bytes = 0u64; + let walk = ignore::WalkBuilder::new(dir) + .hidden(false) + .ignore(false) + .git_ignore(true) + .git_global(true) + .git_exclude(true) + .filter_entry(|entry| entry.file_name() != ".git") + .build(); + for entry in walk.flatten() { + let Some(kind) = entry.file_type() else { + continue; + }; + if kind.is_dir() { + continue; + } + files += 1; + if kind.is_file() + && let Ok(metadata) = entry.metadata() + && metadata.len() <= MAX_UNTRACKED_FILE_BYTES + { + bytes += metadata.len(); + } + if files > MAX_UNTRACKED_FILES || bytes > MAX_UNTRACKED_BYTES { + return true; + } + } + false +} + +/// Files past the per-file cap, then the units to leave out so the rest fits +/// the untracked budget: the largest go first, so one big directory or a +/// pile of assets costs only itself and new source files keep showing. A +/// path that vanished since git listed it costs nothing; `git add +/// --ignore-errors` copes with it. +fn fit_units(root: &Path, units: Vec) -> (Vec>, Vec>) { + let mut oversized = Vec::new(); + // (path, files, bytes) of what each unit would add. + let mut costs = Vec::with_capacity(units.len()); + for unit in units { + let (mut files, mut bytes) = (0usize, 0u64); + for file in unit.files { + let Ok(metadata) = std::fs::symlink_metadata(root.join(path_argument(&file))) else { + continue; + }; + if metadata.is_file() && metadata.len() > MAX_UNTRACKED_FILE_BYTES { + oversized.push(file); + continue; + } + files += 1; + if metadata.is_file() { + bytes += metadata.len(); + } + } + costs.push((unit.path, files, bytes)); + } + let mut files: usize = costs.iter().map(|(_, files, _)| files).sum(); + let mut bytes: u64 = costs.iter().map(|(_, _, bytes)| bytes).sum(); + let mut excess = Vec::new(); + if files <= MAX_UNTRACKED_FILES && bytes <= MAX_UNTRACKED_BYTES { + return (oversized, excess); + } + // Largest first, measured against the budget it overruns most. + let weight = |unit_files: usize, unit_bytes: u64| { + (unit_files as f64 / MAX_UNTRACKED_FILES as f64) + .max(unit_bytes as f64 / MAX_UNTRACKED_BYTES as f64) + }; + costs.sort_by(|a, b| weight(b.1, b.2).total_cmp(&weight(a.1, a.2))); + for (path, unit_files, unit_bytes) in costs { + if files <= MAX_UNTRACKED_FILES && bytes <= MAX_UNTRACKED_BYTES { + break; + } + files -= unit_files; + bytes -= unit_bytes; + // A loose file's oversized self is already left out. + if unit_files > 0 || unit_bytes > 0 { + excess.push(path); + } + } + (oversized, excess) +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeSet; + use std::path::{Path, PathBuf}; + + use super::{ + MAX_UNTRACKED_BYTES, MAX_UNTRACKED_FILE_BYTES, MAX_UNTRACKED_FILES, STORE_DIR, + TreeSnapshot, alternates_value, remove_other_runs, run_id, snapshot_tree, + }; + use crate::repos::Repos; + + async fn git(cwd: &Path, args: &[&str]) { + let output = tokio::process::Command::new("git") + .args(args) + .current_dir(cwd) + .env("GIT_AUTHOR_NAME", "test") + .env("GIT_AUTHOR_EMAIL", "test@test") + .env("GIT_COMMITTER_NAME", "test") + .env("GIT_COMMITTER_EMAIL", "test@test") + .output() + .await + .expect("git spawns"); + assert!( + output.status.success(), + "git {:?} failed: {}", + args, + String::from_utf8_lossy(&output.stderr) + ); + } + + /// A repo with one committed file `a.txt`, and a `Repos` whose data dir + /// sits next to it. + async fn fixture(tmp: &Path) -> (PathBuf, Repos, PathBuf) { + let root = tmp.join("repo"); + std::fs::create_dir_all(&root).expect("repo dir"); + git(&root, &["init", "-b", "main"]).await; + std::fs::write(root.join("a.txt"), "one\ntwo\n").expect("write a.txt"); + git(&root, &["add", "."]).await; + git(&root, &["commit", "-m", "initial"]).await; + let data_dir = tmp.join("data"); + let repos = + Repos::with_worktrees_root(&data_dir, "device-test", data_dir.join("worktrees")); + (root, repos, data_dir) + } + + /// Every file under the checkout's object database, relative to it. + fn checkout_objects(root: &Path) -> BTreeSet { + fn collect(dir: &Path, base: &Path, out: &mut BTreeSet) { + for entry in std::fs::read_dir(dir).expect("read objects dir").flatten() { + let path = entry.path(); + if path.is_dir() { + collect(&path, base, out); + } else { + out.insert(path.strip_prefix(base).expect("under base").to_path_buf()); + } + } + } + let objects = root.join(".git").join("objects"); + let mut out = BTreeSet::new(); + collect(&objects, &objects, &mut out); + out + } + + /// Paths in the snapshot's tree, read through the snapshot's own store. + async fn tree_paths(root: &Path, snapshot: &TreeSnapshot) -> BTreeSet { + let mut cmd = tokio::process::Command::new("git"); + cmd.arg("-C") + .arg(root) + .args(["ls-tree", "-r", "--name-only", "-z", snapshot.tree()]); + snapshot.apply_to(&mut cmd); + let output = cmd.output().await.expect("git ls-tree spawns"); + assert!( + output.status.success(), + "ls-tree failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + output + .stdout + .split(|byte| *byte == 0) + .filter(|path| !path.is_empty()) + .map(|path| String::from_utf8_lossy(path).into_owned()) + .collect() + } + + /// A file of `len` zero bytes. Sparse where the filesystem allows, so the + /// budget tests do not write hundreds of megabytes. + fn many_files(dir: &Path, count: usize) { + std::fs::create_dir_all(dir).expect("dir"); + for i in 0..count { + std::fs::write(dir.join(format!("f{i}.txt")), "x").expect("file"); + } + } + + fn sized_file(path: &Path, len: u64) { + std::fs::File::create(path) + .and_then(|file| file.set_len(len)) + .expect("sized file"); + } + + fn store_dirs(data_dir: &Path) -> Vec { + std::fs::read_dir(data_dir.join(STORE_DIR).join(run_id())) + .map(|entries| entries.flatten().map(|entry| entry.path()).collect()) + .unwrap_or_default() + } + + /// The incident this module exists for: snapshot blobs written into the + /// checkout's `.git/objects`, where auto-maintenance later repacked them. + #[tokio::test] + async fn snapshot_writes_nothing_into_the_checkout_object_database() { + let tmp = tempfile::tempdir().expect("tempdir"); + let (root, repos, data_dir) = fixture(tmp.path()).await; + std::fs::write(root.join("a.txt"), "one\ntwo\nedited\n").expect("edit a.txt"); + std::fs::create_dir_all(root.join("build")).expect("build dir"); + std::fs::write(root.join("build/out.bin"), vec![7u8; 64 * 1024]).expect("out.bin"); + let before = checkout_objects(&root); + + let turn = snapshot_tree(&repos, &root).await.expect("snapshot"); + std::fs::write(root.join("later.txt"), "after the snapshot\n").expect("later.txt"); + let diff = crate::diff_sync::capture_turn_diff(&repos, &root, &turn) + .await + .expect("turn diff"); + + assert_eq!( + checkout_objects(&root), + before, + "snapshot objects leaked into the checkout's object database" + ); + // The snapshot still saw everything, from its own store. + assert_eq!( + tree_paths(&root, &turn).await, + BTreeSet::from(["a.txt".to_string(), "build/out.bin".to_string()]) + ); + assert_eq!(diff.files.len(), 1); + assert_eq!(diff.files[0].path, "later.txt"); + // And the tree is invisible to the checkout without that store. + let plain = tokio::process::Command::new("git") + .arg("-C") + .arg(&root) + .args(["cat-file", "-e", turn.tree()]) + .output() + .await + .expect("git cat-file spawns"); + assert!(!plain.status.success()); + + // The store lives exactly as long as the snapshot. + assert_eq!(store_dirs(&data_dir).len(), 1); + drop(turn); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + while !store_dirs(&data_dir).is_empty() { + assert!( + std::time::Instant::now() < deadline, + "snapshot store was not deleted: {:?}", + store_dirs(&data_dir) + ); + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } + } + + #[tokio::test] + async fn oversized_untracked_files_are_left_out() { + let tmp = tempfile::tempdir().expect("tempdir"); + let (root, repos, _data_dir) = fixture(tmp.path()).await; + std::fs::write(root.join("notes.txt"), "small\n").expect("notes.txt"); + sized_file(&root.join("dump.bin"), MAX_UNTRACKED_FILE_BYTES + 1); + std::fs::create_dir_all(root.join("assets")).expect("assets dir"); + std::fs::write(root.join("assets/logo.svg"), "\n").expect("logo.svg"); + sized_file(&root.join("assets/video.mov"), MAX_UNTRACKED_FILE_BYTES + 1); + + let snapshot = snapshot_tree(&repos, &root).await.expect("snapshot"); + assert_eq!( + tree_paths(&root, &snapshot).await, + BTreeSet::from([ + "a.txt".to_string(), + "assets/logo.svg".to_string(), + "notes.txt".to_string(), + ]) + ); + } + + #[tokio::test] + async fn untracked_directory_over_budget_is_left_out_whole() { + let tmp = tempfile::tempdir().expect("tempdir"); + let (root, repos, _data_dir) = fixture(tmp.path()).await; + std::fs::write(root.join("a.txt"), "one\ntwo\nedited\n").expect("edit a.txt"); + std::fs::write(root.join("notes.txt"), "small\n").expect("notes.txt"); + // An unignored build tree: every file fits the per-file cap, the + // directory as a whole does not fit the budget. + std::fs::create_dir_all(root.join("target-app/debug")).expect("build dir"); + for i in 0..=MAX_UNTRACKED_BYTES / MAX_UNTRACKED_FILE_BYTES { + sized_file( + &root.join(format!("target-app/debug/lib{i}.rlib")), + MAX_UNTRACKED_FILE_BYTES, + ); + } + + let snapshot = snapshot_tree(&repos, &root).await.expect("snapshot"); + assert_eq!( + tree_paths(&root, &snapshot).await, + BTreeSet::from(["a.txt".to_string(), "notes.txt".to_string()]) + ); + // More build output during the turn stays out of the turn diff. + sized_file( + &root.join("target-app/debug/late.rlib"), + MAX_UNTRACKED_FILE_BYTES, + ); + let diff = crate::diff_sync::capture_turn_diff(&repos, &root, &snapshot) + .await + .expect("turn diff"); + assert!(diff.files.is_empty(), "unexpected: {:?}", diff.files); + } + + #[tokio::test] + async fn untracked_over_budget_leaves_out_the_largest_units_first() { + let tmp = tempfile::tempdir().expect("tempdir"); + let (root, repos, _data_dir) = fixture(tmp.path()).await; + std::fs::write(root.join("a.txt"), "one\ntwo\nedited\n").expect("edit a.txt"); + std::fs::create_dir_all(root.join("docs")).expect("docs dir"); + std::fs::write(root.join("docs/readme.md"), "small\n").expect("readme.md"); + // One file-sized unit past the byte budget. + let captures = MAX_UNTRACKED_BYTES / MAX_UNTRACKED_FILE_BYTES + 1; + for i in 0..captures { + sized_file( + &root.join(format!("capture-{i}.raw")), + MAX_UNTRACKED_FILE_BYTES, + ); + } + + let snapshot = snapshot_tree(&repos, &root).await.expect("snapshot"); + let paths = tree_paths(&root, &snapshot).await; + assert!(paths.contains("a.txt")); + assert!( + paths.contains("docs/readme.md"), + "small files stay: {paths:?}" + ); + let kept = paths + .iter() + .filter(|path| path.starts_with("capture-")) + .count(); + // 32 captures fill the budget exactly, so the readme's bytes push + // one more out: only the excess goes, largest first. + assert_eq!(kept as u64, captures - 2, "only the excess is left out"); + // The tracked edit is still in the tree. + let mut cmd = tokio::process::Command::new("git"); + cmd.arg("-C") + .arg(&root) + .args(["cat-file", "blob", &format!("{}:a.txt", snapshot.tree())]); + snapshot.apply_to(&mut cmd); + let output = cmd.output().await.expect("git cat-file spawns"); + assert_eq!( + String::from_utf8_lossy(&output.stdout), + "one\ntwo\nedited\n" + ); + } + + /// Directories that each fit but together overrun the budget cost only + /// the largest of them: a file the agent adds still shows in the turn. + #[tokio::test] + async fn new_files_show_when_untracked_directories_overrun_the_budget() { + let tmp = tempfile::tempdir().expect("tempdir"); + let (root, repos, _data_dir) = fixture(tmp.path()).await; + let per_dir = MAX_UNTRACKED_FILES * 2 / 5; + for (dir, count) in [("a", per_dir + 2), ("b", per_dir + 1), ("c", per_dir)] { + many_files(&root.join(dir), count); + } + let turn = snapshot_tree(&repos, &root).await.expect("turn snapshot"); + std::fs::write(root.join("new.rs"), "fn main() {}\n").expect("new.rs"); + + let diff = crate::diff_sync::capture_turn_diff(&repos, &root, &turn) + .await + .expect("turn diff"); + let paths: Vec<&str> = diff.files.iter().map(|file| file.path.as_str()).collect(); + assert_eq!(paths, ["new.rs"]); + } + + /// A directory that crosses the budget during a turn keeps its turn-start + /// state on both sides instead of diffing as a mass add or delete. + #[tokio::test] + async fn a_directory_crossing_the_budget_mid_turn_is_not_a_mass_change() { + let tmp = tempfile::tempdir().expect("tempdir"); + let (root, repos, _data_dir) = fixture(tmp.path()).await; + let gen_dir = root.join("gen"); + + // Fits at turn start, over budget after one more file. + many_files(&gen_dir, MAX_UNTRACKED_FILES); + let turn = snapshot_tree(&repos, &root).await.expect("turn snapshot"); + std::fs::write(gen_dir.join("extra.txt"), "x").expect("extra"); + std::fs::write(root.join("a.txt"), "one\ntwo\nthree\n").expect("edit a.txt"); + let diff = crate::diff_sync::capture_turn_diff(&repos, &root, &turn) + .await + .expect("turn diff"); + let paths: Vec<&str> = diff.files.iter().map(|file| file.path.as_str()).collect(); + assert_eq!(paths, ["a.txt"], "grew past the budget"); + + // Over budget at turn start, fits after the turn removed files. + let turn = snapshot_tree(&repos, &root).await.expect("turn snapshot"); + std::fs::remove_file(gen_dir.join("extra.txt")).expect("remove extra"); + std::fs::remove_file(gen_dir.join("f0.txt")).expect("remove f0"); + let diff = crate::diff_sync::capture_turn_diff(&repos, &root, &turn) + .await + .expect("turn diff"); + assert!( + diff.files.is_empty(), + "shrank under the budget: {:?}", + diff.files.len() + ); + } + + /// The Changes pane reads a file's turn-start text straight from the + /// snapshot tree, which only the snapshot's store can resolve. + #[tokio::test] + async fn turn_start_text_is_read_from_the_snapshot_store() { + let tmp = tempfile::tempdir().expect("tempdir"); + let (root, repos, _data_dir) = fixture(tmp.path()).await; + std::fs::write(root.join("pre.txt"), "before the turn\n").expect("pre.txt"); + let turn = snapshot_tree(&repos, &root).await.expect("snapshot"); + std::fs::write(root.join("pre.txt"), "before the turn\nedited in turn\n") + .expect("edit pre.txt"); + + let diff = crate::diff_sync::capture_turn_diff(&repos, &root, &turn) + .await + .expect("turn diff"); + let file = diff + .files + .iter() + .find(|file| file.path == "pre.txt") + .expect("pre.txt summary"); + let pair = + crate::diff_sync::read_diff_file_text_at(&root, turn.tree(), Some(&turn), None, file) + .await + .expect("file text"); + assert_eq!(pair.old_text.as_deref(), Some("before the turn\n")); + assert_eq!( + pair.new_text.as_deref(), + Some("before the turn\nedited in turn\n") + ); + } + + #[tokio::test] + async fn repository_without_commits_or_files_snapshots_to_the_empty_tree() { + let tmp = tempfile::tempdir().expect("tempdir"); + let root = tmp.path().join("repo"); + std::fs::create_dir_all(&root).expect("repo dir"); + git(&root, &["init", "-b", "main"]).await; + let data_dir = tmp.path().join("data"); + let repos = + Repos::with_worktrees_root(&data_dir, "device-test", data_dir.join("worktrees")); + + let snapshot = snapshot_tree(&repos, &root).await.expect("snapshot"); + assert_eq!(snapshot.tree(), crate::diff_sync::EMPTY_TREE_SHA); + } + + /// A linked worktree keeps its objects in the main checkout's database. + /// The snapshot has to borrow from there: `a.txt` is unchanged at turn + /// start, so its blob exists nowhere else. + #[tokio::test] + async fn linked_worktree_snapshot_borrows_the_shared_object_database() { + let tmp = tempfile::tempdir().expect("tempdir"); + let (root, repos, _data_dir) = fixture(tmp.path()).await; + let linked = tmp.path().join("linked"); + git( + &root, + &["worktree", "add", "-b", "side", &linked.to_string_lossy()], + ) + .await; + let before = checkout_objects(&root); + + let turn = snapshot_tree(&repos, &linked).await.expect("snapshot"); + std::fs::write(linked.join("a.txt"), "one\ntwo\nedited in turn\n").expect("edit a.txt"); + let diff = crate::diff_sync::capture_turn_diff(&repos, &linked, &turn) + .await + .expect("turn diff"); + assert_eq!(diff.files.len(), 1); + let pair = crate::diff_sync::read_diff_file_text_at( + &linked, + turn.tree(), + Some(&turn), + None, + &diff.files[0], + ) + .await + .expect("file text"); + assert_eq!(pair.old_text.as_deref(), Some("one\ntwo\n")); + assert_eq!(checkout_objects(&root), before); + } + + /// The store's path travels in an environment list that git splits on + /// `:`; a layered snapshot can only find its base if that survives. + #[cfg(not(windows))] + #[tokio::test] + async fn snapshot_works_from_a_data_dir_containing_the_list_separator() { + let tmp = tempfile::tempdir().expect("tempdir"); + let (root, _repos, _data_dir) = fixture(tmp.path()).await; + let data_dir = tmp.path().join("da:ta"); + let repos = + Repos::with_worktrees_root(&data_dir, "device-test", data_dir.join("worktrees")); + std::fs::write(root.join("pre.txt"), "before the turn\n").expect("pre.txt"); + let turn = snapshot_tree(&repos, &root).await.expect("snapshot"); + std::fs::write(root.join("pre.txt"), "before the turn\nedited in turn\n") + .expect("edit pre.txt"); + + let diff = crate::diff_sync::capture_turn_diff(&repos, &root, &turn) + .await + .expect("turn diff"); + assert!(diff.patch.contains("+edited in turn")); + assert!(!diff.patch.contains("+before the turn")); + } + + #[test] + fn stale_run_stores_are_removed_and_the_current_run_is_kept() { + let tmp = tempfile::tempdir().expect("tempdir"); + let parent = tmp.path().join(STORE_DIR); + let stale = parent.join("4242-1700000000000000").join("snapshot"); + let current = parent.join(run_id()).join("snapshot"); + std::fs::create_dir_all(stale.join("objects")).expect("stale store"); + std::fs::write(stale.join("objects").join("blob"), "left behind").expect("stale blob"); + std::fs::create_dir_all(current.join("objects")).expect("current store"); + + remove_other_runs(&parent, std::ffi::OsStr::new(run_id())); + assert!(!parent.join("4242-1700000000000000").exists()); + assert!(current.join("objects").is_dir()); + // A data dir that never held a store is fine too. + remove_other_runs(&tmp.path().join("missing"), std::ffi::OsStr::new(run_id())); + } + + #[cfg(not(windows))] + #[test] + fn alternates_quote_a_directory_containing_the_separator() { + let plain = Path::new("/repo/.git/objects"); + let colon = Path::new("/data:dir/say \"hi\"/objects"); + assert_eq!( + alternates_value(&[colon, plain]), + std::ffi::OsString::from("\"/data:dir/say \\\"hi\\\"/objects\":/repo/.git/objects") + ); + } +} diff --git a/crates/engine/src/doc_host.rs b/crates/engine/src/doc_host.rs index e581310069..168be0bd9e 100644 --- a/crates/engine/src/doc_host.rs +++ b/crates/engine/src/doc_host.rs @@ -31,8 +31,8 @@ use tokio_util::task::TaskTracker; use zeron_doc::{ COMMAND_DEFAULT_TTL_MS, CommandBasedOn, CommandDisposition, DocError, EvaluationContext, MessagePart, MessageRole, MessageStatus, QueueDeliveryGate, QueuedMessage, SessionCommandEntry, - SessionCommandPayload, SessionCommandStatus, SessionDoc, SessionMessageEntry, evaluate_command, - join_continuation_entries, + SessionCommandPayload, SessionCommandStatus, SessionDoc, SessionMessageEntry, SubagentStatus, + evaluate_command, join_continuation_entries, }; use zeron_proto::{ConversationSourceContext, HarnessId, UserInputAnswer, UserInputQuestion}; use zeron_sync::DocsStore; @@ -186,11 +186,20 @@ impl EdgeConfig { /// the bearer is re-fetched before every connect, so reconnects after a /// token expiry present a fresh `?token=` instead of the boot-time one. pub fn room_url(&self, path: impl Into) -> Arc { + self.room_url_with_host(path, None) + } + + fn room_url_with_host( + &self, + path: impl Into, + host_device: Option, + ) -> Arc { let ws_base = self.url.replacen("http", "ws", 1); Arc::new(EdgeRoomUrl { base: format!("{}{}", ws_base.trim_end_matches('/'), path.into()), token: self.token.clone(), device_id: self.device_id.clone(), + host_device, }) } } @@ -199,6 +208,7 @@ struct EdgeRoomUrl { base: String, token: Arc, device_id: String, + host_device: Option, } impl zeron_sync::UrlProvider for EdgeRoomUrl { @@ -206,12 +216,19 @@ impl zeron_sync::UrlProvider for EdgeRoomUrl { let token = self.token.clone(); let base = self.base.clone(); let device = self.device_id.clone(); + let host = self.host_device.clone(); Box::pin(async move { let token = token.token().await.map_err(zeron_sync::SyncError::from)?; let mut url = format!("{base}?token={token}"); if !device.is_empty() { url.push_str(&format!("&device={device}")); } + if let Some(host) = host { + let mut parsed = reqwest::Url::parse(&url) + .map_err(|e| zeron_sync::SyncError::Protocol(e.to_string()))?; + parsed.query_pairs_mut().append_pair("hostDevice", &host); + url = parsed.into(); + } Ok(url) }) } @@ -409,9 +426,7 @@ impl ChatConnectionSnapshot { sync_requested, stats: client.as_ref().map(|client| client.stats()), delivery_live: sync_started - && client - .as_ref() - .is_some_and(|client| client.delivery_live()), + && client.as_ref().is_some_and(|client| client.delivery_live()), } } @@ -562,6 +577,14 @@ pub struct ChatDocHandle { /// Queue rows held as explicit steers for a turn-boundary agent. They /// lead ordinary queued rows, in the order they were steered. steered_rows: Mutex>, + /// Sends held for a turn-boundary agent's turn end (rather than queued + /// by the user), with the run configuration each was sent with: the row + /// carries only its text and images. + held_sends: Mutex>, + /// The latest Stop command that has acted. A prompt sent before it that + /// only executes now (a loaded host, a slow agent) was cancelled by it: + /// running it would start the stopped work again in a fresh runtime. + stopped_by: Mutex>, /// An explicit user interrupt freezes automatic queue delivery. The next /// explicit prompt or queue send resumes it; incidental doc/status changes /// must not turn Cancel into "send the next row". @@ -652,6 +675,47 @@ impl Drop for ChatDocHandle { } impl ChatDocHandle { + /// The turn ending now is followed at once by a message the user has + /// already sent: one still on its way through the command log (a burst + /// the agent outran), or one held for a turn-boundary agent's turn end. + /// The work goes on, so the end is not a completion to notify. + pub(crate) fn continues_with_sent_message(&self) -> bool { + // Not yet in the transcript: delivery writes the user's message + // before dispatching it, so the command that started the turn now + // ending (still marked pending a moment longer) never counts. + let in_flight = self.doc.read_commands().is_ok_and(|commands| { + let pending: Vec<&str> = commands + .iter() + .filter(|c| c.status == SessionCommandStatus::Pending) + .filter_map(|c| match &c.payload { + SessionCommandPayload::Run { message_id, .. } => Some(message_id.as_str()), + SessionCommandPayload::Steer { message_id, .. } => { + Some(message_id.as_deref().unwrap_or(c.id.as_str())) + } + _ => None, + }) + .collect(); + !pending.is_empty() + && self.doc.read_entries().is_ok_and(|entries| { + pending + .iter() + .any(|id| !entries.iter().any(|e| e.id == *id)) + }) + }); + if in_flight { + return true; + } + if self.queue_paused.load(Ordering::Acquire) { + return false; + } + let Ok(Some(head)) = self.doc.read_queue().map(|q| q.into_iter().next()) else { + return false; + }; + head.delivery_gate.is_none() + && (lock(&self.held_sends).contains_key(&head.id) + || lock(&self.steered_rows).contains(&head.id)) + } + /// Live views and agents retain their documents and resist idle retirement. /// At capacity their transports can yield without dropping these leases. fn sync_protected(&self) -> bool { @@ -664,6 +728,20 @@ impl ChatDocHandle { &self.chat_id } + pub(crate) fn commit_voice( + &self, + transcript: &zeron_proto::voice::VoiceTranscript, + ) -> Result, EngineError> { + let _owner = lock(&self.transcript_import); + zeron_doc::voice::commit_voice_transcript( + &self.doc, + transcript, + &self.device_id, + chrono::Utc::now().timestamp_millis(), + ) + .map_err(EngineError::from) + } + pub fn doc(&self) -> &SessionDoc { &self.doc } @@ -786,17 +864,33 @@ impl ChatDocHandle { }) } - /// Recovery sweep: stamp this device's abandoned `streaming` entries `aborted`, appending + /// Recovery sweep: settle this device's running subagent chips (including + /// chips in completed parent turns), then stamp abandoned `streaming` + /// entries `aborted`, appending /// `note` as a visible error part so the transcript says WHY the turn /// ended (zeron folded "Run interrupted by backend restart" the same /// way). Returns the stamped entries' `(id, created_at)` — recovery uses /// them for the resume-freshness check. pub fn mark_abandoned_streams(&self, note: &str) -> Result, DocError> { let mut stamped = Vec::new(); + let mut chips_changed = false; for entry in self.doc.read_entries()? { - if entry.role == MessageRole::Assistant - && entry.status == Some(MessageStatus::Streaming) - && entry.device_id == self.device_id + if entry.role != MessageRole::Assistant || entry.device_id != self.device_id { + continue; + } + for part in &entry.parts { + if let MessagePart::Tool { + id, + subagent_status: Some(SubagentStatus::Running), + .. + } = part + { + chips_changed |= + self.doc + .update_subagent_chip(id, None, Some("failed"), None)?; + } + } + if entry.status == Some(MessageStatus::Streaming) && self .doc .set_message_status(&entry.id, MessageStatus::Aborted)? @@ -808,7 +902,7 @@ impl ChatDocHandle { stamped.push((entry.id.clone(), entry.created_at)); } } - if !stamped.is_empty() { + if chips_changed || !stamped.is_empty() { self.publish_messages(); } Ok(stamped) @@ -911,6 +1005,7 @@ impl DocHost { }; if tokio::runtime::Handle::try_current().is_ok() { host.spawn_sync_scheduler(); + host.spawn_remote_wake_delivery(); } host } @@ -1501,6 +1596,8 @@ impl DocHost { drain_lock: tokio::sync::Mutex::new(()), command_drain_lock: tokio::sync::Mutex::new(()), steered_rows: Mutex::new(Vec::new()), + held_sends: Mutex::new(HashMap::new()), + stopped_by: Mutex::new(None), queue_paused: AtomicBool::new(recovered_queue_pending), mirror_dirty: AtomicBool::new(true), last_access: AtomicI64::new(now_ms()), @@ -2129,7 +2226,8 @@ impl DocHost { edge.clone(), chat.clone(), ).with_priority(priority)); - let url = edge.room_url(format!("/chat2/{chat}/ws")); + let remote_host = host.remote_host_for(&chat); + let url = edge.room_url_with_host(format!("/chat2/{chat}/ws"), remote_host.clone()); let mut wake = zeron_sync::wake::subscribe(); // Sibling-dial successes end a backoff wait immediately, exactly // like the joined clients' own reconnect loops (chat_client.rs). @@ -2154,7 +2252,7 @@ impl DocHost { edge.clone(), chat.clone(), device.clone(), - ).with_priority(priority)); + ).with_host_device(remote_host.clone()).with_priority(priority)); let dial = tokio::time::timeout( std::time::Duration::from_secs(60), zeron_sync::ChatClient::connect_via_transport( @@ -3354,10 +3452,8 @@ impl DocHost { if is_message { self.unarchive_on_send(chat_id); } - // §7 durable delivery: when another device hosts this chat, nudge its device - // room so a cold host opens the doc and drains the queue. Fire-and-forget — - // the command is durable in the doc either way (a host that opens the chat - // for any other reason still executes it). + // The cold host must discover the command even after its room rows + // are ACKed. Persist that wake independently and recover it on restart. self.nudge_remote_host(chat_id); self.spawn_command_delivery(chat_id, entry, transfers); Ok(id) @@ -4026,19 +4122,98 @@ impl DocHost { } } + /// A Stop acts on everything sent before it, in the order the user sent + /// it. Controls bypass the prompt drain (so a stalled provider stays + /// stoppable), which let a Stop execute before a send queued ahead of it + /// had dispatched: with no turn yet it did nothing and the turn then ran + /// in full; mid-turn, the earlier steer landed after the Stop and read as + /// the user's next message, re-running in the fresh runtime. Wait for + /// earlier prompts to dispatch (milliseconds) — bounded short while a + /// turn is in flight, so a provider wedged on backpressure still stops. + async fn await_earlier_prompts( + &self, + sessions: &SessionsEngine, + handle: &Arc, + stop_id: &str, + ) { + let started = tokio::time::Instant::now(); + loop { + let bound = if sessions.turn_in_flight(&handle.chat_id) { + std::time::Duration::from_secs(2) + } else { + std::time::Duration::from_secs(15) + }; + if started.elapsed() >= bound { + return; + } + let Ok(commands) = handle.doc.read_commands() else { + return; + }; + let Some(at) = commands.iter().position(|c| c.id == stop_id) else { + return; + }; + let earlier_prompt = commands[..at].iter().any(|c| { + c.status == SessionCommandStatus::Pending + && matches!( + c.payload, + SessionCommandPayload::Run { .. } | SessionCommandPayload::Steer { .. } + ) + }); + if !earlier_prompt { + return; + } + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } + } + + /// A prompt queued after a control that is still executing (a Stop + /// tearing the runtime down) waits for it: running ahead of it put the + /// user's next message into the runtime the Stop was killing. + async fn await_earlier_controls(&self, handle: &Arc, prompt_id: &str) { + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(30); + while tokio::time::Instant::now() < deadline { + let Ok(commands) = handle.doc.read_commands() else { + return; + }; + let Some(at) = commands.iter().position(|c| c.id == prompt_id) else { + return; + }; + let earlier_control = { + let executing = lock(&self.inner.executing); + commands[..at].iter().any(|c| { + c.status == SessionCommandStatus::Pending + && !matches!( + c.payload, + SessionCommandPayload::Run { .. } | SessionCommandPayload::Steer { .. } + ) + && executing.contains(&c.id) + }) + }; + if !earlier_control { + return; + } + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } + } + /// Stop the active turn without treating the resulting Idle transition as /// permission to release the next queued message. The same lock used by /// drains closes the race between clicking Cancel and the status watcher. + /// A parked runtime still running background work is stopped too: the + /// idle reaper spares it, so Stop is what ends that work. async fn interrupt_and_pause_queue( &self, sessions: &SessionsEngine, handle: &Arc, ) -> Result { let _drain = handle.drain_lock.lock().await; - if !sessions.turn_in_flight(&handle.chat_id) { + if !sessions.turn_in_flight(&handle.chat_id) + && !sessions.holds_background_work(&handle.chat_id) + { return Ok(false); } handle.queue_paused.store(true, Ordering::Release); + self.cancel_held_sends(handle); match sessions.interrupt(&handle.chat_id).await { Ok(true) => Ok(true), Ok(false) => { @@ -4052,6 +4227,33 @@ impl DocHost { } } + /// A Stop cancels the messages sent during the turn it stops: the sends + /// and steers held for that turn's end leave the queue (the user's own + /// queued rows stay, paused) and land in the transcript unrun — never + /// to start the stopped work again in the next runtime. + fn cancel_held_sends(&self, handle: &Arc) { + let held: Vec = { + let mut sends = lock(&handle.held_sends); + let mut steered = lock(&handle.steered_rows); + let ids = sends.keys().cloned().chain(steered.drain(..)).collect(); + sends.clear(); + ids + }; + let mut changed = false; + for id in held { + let Ok(Some(row)) = handle.doc.take_queued(&id) else { + continue; + }; + changed = true; + if let Err(err) = handle.write_user_message(&row.id, &row.text, row.issued_at) { + tracing::warn!(chat = %handle.chat_id, error = %err, "cancelled held send write failed"); + } + } + if changed { + handle.publish_queue(); + } + } + /// Send one taken queue row. async fn dispatch_queued( &self, @@ -4074,7 +4276,14 @@ impl DocHost { )); } let mut attachments = item.attachments.clone(); - let mut prompt = queued_message_prompt(&item.text, &attachments); + // A send held for the turn end goes out exactly as it was sent: its + // text never carried the queue's image note. + let held = lock(&handle.held_sends).remove(&item.id); + let mut prompt = if held.is_some() { + item.text.clone() + } else { + queued_message_prompt(&item.text, &attachments) + }; self.resolve_attachment_refs(&mut prompt, &mut attachments); if send == QueueSend::Steer { match sessions @@ -4094,20 +4303,24 @@ impl DocHost { } // Same reading of "busy" as the drain: a turn parked on a question is // still a turn, and it has to be stopped before this one starts. + // The message replaces the turn, not the agent: its background work + // keeps running (only the user's Stop tears that down). if send == QueueSend::Interrupt && sessions.turn_in_flight(chat_id) { - sessions.interrupt(chat_id).await?; + sessions.stop_turn(chat_id).await?; } let previous = sessions.last_request(chat_id); - let request = self - .request_from_chat_row(chat_id, &prompt) - .map(|mut current| { - if let Some(previous) = &previous { - current.auto_approve = previous.auto_approve; - current.worktree = previous.worktree.clone(); - } - current - }) - .or(previous); + // ... and on its model, effort and options, not the chat's saved ones. + let request = held.or_else(|| { + self.request_from_chat_row(chat_id, &prompt) + .map(|mut current| { + if let Some(previous) = &previous { + current.auto_approve = previous.auto_approve; + current.worktree = previous.worktree.clone(); + } + current + }) + .or(previous) + }); let Some(mut request) = request else { return Err(EngineError::Other( "no live run and no prior run config".into(), @@ -4122,62 +4335,15 @@ impl DocHost { Ok(()) } - /// POST `{edge}/device/{host}/nudge {chatId}` when the chat's workspace row names - /// another device as host. Best-effort: offline/edge-less engines skip silently. + /// Persist remote host discovery separately from the outgoing room rows. + /// A row ACK does not prove a cold host has been told to open its room. fn nudge_remote_host(&self, chat_id: &str) { - let Some(edge) = self.inner.config.edge.clone() else { - return; - }; - let Some(workspace) = self.workspace() else { - return; - }; - let host_device = match workspace.chat(chat_id) { - Ok(Some(chat)) => chat.device_id, - // Unclaimed chat: whoever drains first claims it — nobody to nudge. - _ => return, - }; - if host_device == self.inner.config.device_id { + if self.inner.config.edge.is_none() || self.remote_host_for(chat_id).is_none() { return; } - // Only meaningful inside a runtime (RPC handlers, executors); bare sync - // callers (unit tests) skip rather than panic. - let Ok(runtime) = tokio::runtime::Handle::try_current() else { - return; - }; - let url = format!( - "{}/device/{}/nudge", - edge.url.trim_end_matches('/'), - host_device - ); - let chat = chat_id.to_string(); - self.spawn_worker_on(&runtime, async move { - // Fresh bearer per request — never the boot-time snapshot. - let bearer = match edge.bearer().await { - Ok(bearer) => bearer, - Err(err) => { - tracing::warn!(chat = %chat, error = %err, "nudge skipped: token unavailable"); - return; - } - }; - let send = reqwest::Client::new() - .post(&url) - .bearer_auth(&bearer) - .json(&serde_json::json!({ "chatId": chat })) - .timeout(std::time::Duration::from_secs(10)) - .send() - .await; - match send { - Ok(res) if res.status().is_success() => { - tracing::info!(chat = %chat, device = %host_device, "host nudged"); - } - Ok(res) => tracing::warn!(chat = %chat, device = %host_device, - status = res.status().as_u16(), "nudge rejected"), - Err(err) => { - let err = describe_http_error(err); - tracing::warn!(chat = %chat, error = %err, "nudge failed (best-effort)") - } - } - }); + if let Err(err) = self.inner.store.schedule_sync_job(chat_id, "remote-wake") { + tracing::warn!(chat = %chat_id, %err, "remote host wake persistence failed"); + } } /// The chat's host device when it is NOT this engine (mirrors @@ -4577,8 +4743,12 @@ impl DocHost { CommandDisposition::Skip => Ok("duplicate"), CommandDisposition::Expired => Ok("expired"), CommandDisposition::Superseded => Ok("superseded"), - CommandDisposition::Execute => match self.execute(&sessions, &handle, &entry).await - { + CommandDisposition::Execute => match { + if _prompt_guard.is_some() { + self.await_earlier_controls(&handle, &entry.id).await; + } + self.execute(&sessions, &handle, &entry).await + } { Ok(_) => Ok("executed"), Err(err) => Err(err), }, @@ -4969,6 +5139,12 @@ impl DocHost { self.resolve_command(handle, &entry.id, SessionCommandStatus::Superseded, None); } CommandDisposition::Execute => { + if matches!( + entry.payload, + SessionCommandPayload::Run { .. } | SessionCommandPayload::Steer { .. } + ) { + self.await_earlier_controls(handle, &entry.id).await; + } let (status, resolution) = match self.execute(&sessions, handle, &entry).await { Ok(outcome) => outcome, Err(err) => (SessionCommandStatus::Rejected, Some(err.to_string())), @@ -5132,6 +5308,27 @@ impl DocHost { } } + /// `entry` is a prompt sent before the Stop that has already acted on + /// this chat: the Stop cancelled it. Returns its message id. + fn cancelled_by_stop( + &self, + handle: &Arc, + entry: &SessionCommandEntry, + ) -> Option { + let message_id = match &entry.payload { + SessionCommandPayload::Run { message_id, .. } => message_id.clone(), + SessionCommandPayload::Steer { message_id, .. } => { + message_id.clone().unwrap_or_else(|| entry.id.clone()) + } + _ => return None, + }; + let stop = lock(&handle.stopped_by).clone()?; + let commands = handle.doc.read_commands().ok()?; + let at = commands.iter().position(|c| c.id == entry.id)?; + let stop_at = commands.iter().position(|c| c.id == stop)?; + (at < stop_at).then_some(message_id) + } + async fn execute( &self, sessions: &SessionsEngine, @@ -5139,6 +5336,24 @@ impl DocHost { entry: &SessionCommandEntry, ) -> Result<(SessionCommandStatus, Option), EngineError> { let chat_id = &handle.chat_id; + if let Some(message_id) = self.cancelled_by_stop(handle, entry) { + // Still the user's message: it stays in the transcript, unrun. + let prompt = match &entry.payload { + SessionCommandPayload::Run { request, .. } => request.prompt.clone(), + SessionCommandPayload::Steer { prompt, .. } => prompt.clone(), + _ => String::new(), + }; + if let Err(err) = + handle.write_user_message(&message_id, &prompt, entry.issued_at.min(now_ms())) + { + tracing::warn!(chat = %chat_id, error = %err, "cancelled message write failed"); + } + tracing::info!(chat = %chat_id, "prompt sent before a Stop that has acted: cancelled"); + return Ok(( + SessionCommandStatus::Applied, + Some("cancelled by the Stop sent after it".into()), + )); + } match &entry.payload { SessionCommandPayload::Run { request, @@ -5220,6 +5435,7 @@ impl DocHost { &request.prompt, entry.issued_at, false, + Some(&request), )?; return Ok(( SessionCommandStatus::Applied, @@ -5264,6 +5480,29 @@ impl DocHost { .await } SessionCommandPayload::Interrupt {} => { + self.await_earlier_prompts(sessions, handle, &entry.id) + .await; + *lock(&handle.stopped_by) = Some(entry.id.clone()); + // Sends before this Stop that are still on their way (a slow + // dispatch the wait above gave up on) are cancelled with it. + if let Ok(commands) = handle.doc.read_commands() + && let Some(at) = commands.iter().position(|c| c.id == entry.id) + { + let earlier = commands[..at] + .iter() + .filter(|c| c.status == SessionCommandStatus::Pending) + .filter_map(|c| match &c.payload { + SessionCommandPayload::Run { message_id, .. } => { + Some(message_id.clone()) + } + SessionCommandPayload::Steer { message_id, .. } => { + Some(message_id.clone().unwrap_or_else(|| c.id.clone())) + } + _ => None, + }) + .collect(); + sessions.cancel_sent_messages(&handle.chat_id, earlier); + } self.interrupt_and_pause_queue(sessions, handle).await?; Ok((SessionCommandStatus::Applied, None)) } @@ -5347,11 +5586,12 @@ impl DocHost { prompt: &str, issued_at: i64, steer: bool, + request: Option<&zeron_proto::RunRequest>, ) -> Result<(), EngineError> { let item = QueuedMessage { id: message_id.to_string(), text: prompt.to_string(), - attachments: Vec::new(), + attachments: request.map(|r| r.attachments.clone()).unwrap_or_default(), hold_for_turn_end: false, issued_by: self.inner.config.device_id.clone(), issued_at: issued_at.min(now_ms()), @@ -5361,6 +5601,9 @@ impl DocHost { if handle.doc.read_queue()?.iter().any(|row| row.id == item.id) { return Ok(()); // a redelivered command: already held } + if let Some(request) = request { + lock(&handle.held_sends).insert(item.id.clone(), request.clone()); + } if steer { handle .doc @@ -5405,7 +5648,7 @@ impl DocHost { && (unsteerable_turn || sessions.defers_to_turn_end(chat_id, None)) { let id = message_id.unwrap_or_else(new_id); - self.hold_until_turn_end(handle, &id, &prompt, issued_at, true)?; + self.hold_until_turn_end(handle, &id, &prompt, issued_at, true, None)?; return Ok(( SessionCommandStatus::Applied, Some("held until the turn ends".into()), @@ -5458,7 +5701,7 @@ impl DocHost { } SteerOutcome::DeferredByUpdate => { let id = message_id.unwrap_or_else(new_id); - self.hold_until_turn_end(handle, &id, &prompt, issued_at, true)?; + self.hold_until_turn_end(handle, &id, &prompt, issued_at, true, None)?; // The completed turn's status publication normally re-drains // this queue. Also cover completion racing the enqueue itself. self.drain_queue(handle).await; @@ -5510,11 +5753,6 @@ impl DocHost { .await } - /// Create (or reuse) the isolated worktree a Run's [`zeron_proto::WorktreeSpec`] - /// asks for, returning the resolved cwd plus the fresh worktree when one was - /// actually created. Reuse guard: a chat whose row already points inside a - /// linked worktree of the same repo keeps it — a duplicate Run (client retry - /// after a lost ack, ledger reset) must not mint a second checkout. async fn materialize_worktree( &self, chat_id: &str, @@ -6458,3 +6696,6 @@ mod publication_eviction_tests { #[cfg(test)] #[path = "doc_host_sync_tests.rs"] mod sync_lifecycle_tests; + +#[path = "doc_host_delivery.rs"] +mod remote_delivery; diff --git a/crates/engine/src/doc_host_delivery.rs b/crates/engine/src/doc_host_delivery.rs new file mode 100644 index 0000000000..4ed3f6ee2a --- /dev/null +++ b/crates/engine/src/doc_host_delivery.rs @@ -0,0 +1,341 @@ +//! Durable host discovery for desktop senders. The chat2 outbox already +//! recovers rows on restart; a separate receipt recovers the host wake even +//! when those rows have been acknowledged and their room is cold. + +use super::*; + +impl DocHost { + pub(super) fn spawn_remote_wake_delivery(&self) { + if self.inner.config.edge.is_none() { + return; + } + let weak = Arc::downgrade(&self.inner); + self.spawn_worker(async move { + let mut tasks = tokio::task::JoinSet::new(); + let mut active = HashSet::new(); + let mut after = String::new(); + loop { + while let Some(result) = tasks.try_join_next() { + if let Ok(chat) = result { + active.remove(&chat); + } + } + let Some(inner) = weak.upgrade() else { return }; + let host = DocHost { inner }; + if host.workspace().is_some() + && host.inner.config.edge.is_some() + && !host.inner.edge_disconnected.load(Ordering::Acquire) + && active.len() < 8 + { + match host + .inner + .store + .pending_sync_jobs("remote-wake", &after, 32) + { + Ok(page) => { + if page.is_empty() { + after.clear(); + } + for chat in page { + if active.len() >= 8 { + break; + } + after = chat.clone(); + if !active.insert(chat.clone()) { + continue; + } + let weak = weak.clone(); + tasks.spawn(async move { + // Yield stalled obligations so eight dead peers cannot + // indefinitely block later chats on healthy hosts. + let _ = tokio::time::timeout( + std::time::Duration::from_secs(12), + deliver(weak, &chat), + ) + .await; + chat + }); + } + } + Err(err) => tracing::warn!(%err, "remote wake discovery failed"), + } + } + drop(host); + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } + }); + } +} + +async fn deliver(weak: std::sync::Weak, chat: &str) { + let mut backoff = std::time::Duration::from_millis(500); + loop { + let Some(inner) = weak.upgrade() else { return }; + let host = DocHost { inner }; + let Ok(Some(version)) = host.inner.store.sync_job_version(chat, "remote-wake") else { + return; + }; + let Some(edge) = host.inner.config.edge.clone() else { + return; + }; + if host.inner.edge_disconnected.load(Ordering::Acquire) { + return; + } + let Some(target) = host.remote_host_for(chat) else { + // Registry loading can precede a restored remote row. Only + // retire deleted/local targets after registry catch-up. + if host + .workspace() + .is_some_and(|w| w.sync_status().is_some_and(|s| s.synced)) + { + let _ = host + .inner + .store + .complete_sync_job(chat, "remote-wake", version); + return; + } + drop(host); + tokio::time::sleep(backoff).await; + continue; + }; + let rows_flushed = host.inner.store.has_pending_chat_updates(chat).ok() == Some(false); + let sent = async { + let bearer = edge.bearer().await?; + let url = format!("{}/device/{target}/nudge", edge.url.trim_end_matches('/')); + host.inner + .http + .post(url) + .bearer_auth(bearer) + .json(&serde_json::json!({ "chatId": chat })) + .timeout(std::time::Duration::from_secs(10)) + .send() + .await + .map_err(|err| EngineError::Other(describe_http_error(err))) + } + .await; + let accepted = match sent { + Ok(response) if response.status().is_success() => true, + // Another owner's device (403) or a chat id its room refuses + // (400): no retry can change that, so the wake is settled. + // Unclaimed (404: not connected yet) and 5xx retry. + Ok(response) if matches!(response.status().as_u16(), 400 | 403) => { + tracing::warn!(%chat, device = %target, status = response.status().as_u16(), "remote host wake refused; not retrying"); + true + } + Ok(response) => { + tracing::debug!(%chat, device = %target, status = response.status().as_u16(), "remote host wake retrying"); + false + } + Err(err) => { + tracing::debug!(%chat, device = %target, %err, "remote host wake retrying"); + false + } + }; + if rows_flushed + && accepted + && host.inner.store.has_pending_chat_updates(chat).ok() == Some(false) + { + match host + .inner + .store + .complete_sync_job(chat, "remote-wake", version) + { + Ok(()) + if host.inner.store.sync_job_version(chat, "remote-wake").ok() + == Some(None) => + { + return; + } + Ok(()) => {} + Err(err) => tracing::warn!(%err, %chat, "remote wake completion failed"), + } + } + drop(host); + let mut wake = zeron_sync::wake::subscribe(); + let mut online = zeron_sync::wake::subscribe_online(); + tokio::select! { + _ = tokio::time::sleep(backoff) => {}, + _ = wake.recv() => {}, + _ = online.recv() => {}, + } + backoff = (backoff * 2).min(std::time::Duration::from_secs(16)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + #[tokio::test] + async fn remote_chat_transports_carry_the_host_on_both_paths() { + use zeron_sync::chat_client::ChatTransport; + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let edge = EdgeConfig::with_static_token( + format!("http://{}", listener.local_addr().unwrap()), + "test", + ) + .with_device("sender"); + let url = edge + .room_url_with_host("/chat2/chat/ws", Some("remote-host".into())) + .url() + .await + .unwrap(); + let url = reqwest::Url::parse(&url).unwrap(); + assert!( + url.query_pairs() + .any(|(key, value)| key == "hostDevice" && value == "remote-host") + ); + let server = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + let mut request = Vec::new(); + let mut buf = [0; 4096]; + loop { + let n = stream.read(&mut buf).await.unwrap(); + assert!(n > 0); + request.extend_from_slice(&buf[..n]); + if request.windows(4).any(|b| b == b"\r\n\r\n") { + break; + } + } + let request = String::from_utf8_lossy(&request); + let line = request.lines().next().unwrap(); + assert!(line.contains("hostDevice=remote-host"), "{line}"); + assert!(line.contains("device=sender"), "{line}"); + stream + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{}") + .await + .unwrap(); + }); + crate::chat2_host::EdgeChatTransport::new(reqwest::Client::new(), edge, "chat", "sender") + .with_host_device(Some("remote-host".into())) + .push("batch".into(), vec![1]) + .await + .unwrap(); + server.await.unwrap(); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn failed_remote_wake_survives_row_ack_and_host_restart() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let edge = format!("http://{}", listener.local_addr().unwrap()); + let attempts = Arc::new(AtomicUsize::new(0)); + let status = Arc::new(AtomicUsize::new(500)); + let count = attempts.clone(); + let code = status.clone(); + let server = tokio::spawn(async move { + loop { + let (mut stream, _) = listener.accept().await.unwrap(); + let count = count.clone(); + let code = code.clone(); + tokio::spawn(async move { + let mut request = Vec::new(); + let mut buf = [0; 4096]; + loop { + let n = stream.read(&mut buf).await.unwrap(); + if n == 0 { + return; + } + request.extend_from_slice(&buf[..n]); + if request.windows(4).any(|s| s == b"\r\n\r\n") { + break; + } + } + assert!( + String::from_utf8_lossy(&request).starts_with("POST /device/remote/nudge ") + ); + count.fetch_add(1, Ordering::SeqCst); + let response = format!( + "HTTP/1.1 {} Test\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + code.load(Ordering::SeqCst) + ); + let _ = stream.write_all(response.as_bytes()).await; + }); + } + }); + let dir = tempfile::tempdir().unwrap(); + let store = Arc::new(DocsStore::open(dir.path()).unwrap()); + let workspace = WorkspaceHost::open( + store.clone(), + crate::workspace_host::WorkspaceHostConfig { + device_id: "sender".into(), + device_name: "Sender".into(), + platform: "linux".into(), + org_id: "org".into(), + user_id: "user".into(), + edge: None, + local_only: false, + }, + ) + .unwrap(); + workspace + .create_chat("outgoing", None, Some("remote"), None, None) + .unwrap(); + let config = DocHostConfig { + device_id: "sender".into(), + default_harness: HarnessId::Mock, + edge: Some(EdgeConfig::with_static_token(&edge, "test")), + }; + let host = DocHost::new(store.clone(), config.clone()); + host.set_workspace(workspace.clone()); + store + .enqueue_chat_update("outgoing", "acked-row", b"command bytes") + .unwrap(); + host.nudge_remote_host("outgoing"); + store + .acknowledge_chat_update("outgoing", "acked-row") + .unwrap(); + tokio::time::timeout(std::time::Duration::from_secs(5), async { + while attempts.load(Ordering::SeqCst) < 2 { + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } + }) + .await + .expect("failed wake was not retried"); + assert!( + store + .sync_job_version("outgoing", "remote-wake") + .unwrap() + .is_some() + ); + assert!( + !store.has_pending_chat_updates("outgoing").unwrap(), + "row delivery and wake delivery are independent" + ); + host.shutdown_workers().await; + workspace.flush(); + let attempts_before = attempts.load(Ordering::SeqCst); + status.store(200, Ordering::SeqCst); + let reopened = Arc::new(DocsStore::open(dir.path()).unwrap()); + let restarted = DocHost::new(reopened.clone(), config); + let restored_workspace = WorkspaceHost::open( + reopened.clone(), + crate::workspace_host::WorkspaceHostConfig { + device_id: "sender".into(), + device_name: "Sender".into(), + platform: "linux".into(), + org_id: "org".into(), + user_id: "user".into(), + edge: None, + local_only: false, + }, + ) + .unwrap(); + restarted.set_workspace(restored_workspace); + tokio::time::timeout(std::time::Duration::from_secs(5), async { + while reopened + .sync_job_version("outgoing", "remote-wake") + .unwrap() + .is_some() + { + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } + }) + .await + .expect("wake was not recovered after restart"); + assert!(attempts.load(Ordering::SeqCst) > attempts_before); + restarted.shutdown_workers().await; + server.abort(); + } +} diff --git a/crates/engine/src/doc_host_sync_tests.rs b/crates/engine/src/doc_host_sync_tests.rs index 500ee1e4a9..34f87504ba 100644 --- a/crates/engine/src/doc_host_sync_tests.rs +++ b/crates/engine/src/doc_host_sync_tests.rs @@ -268,6 +268,7 @@ fn workspace(store: Arc) -> WorkspaceHost { org_id: "test-org".into(), user_id: "test-user".into(), edge: None, + local_only: false, }, ) .unwrap() diff --git a/crates/engine/src/fs_watch.rs b/crates/engine/src/fs_watch.rs new file mode 100644 index 0000000000..33144e1bd8 --- /dev/null +++ b/crates/engine/src/fs_watch.rs @@ -0,0 +1,964 @@ +//! FsWatchHub — one `notify` watcher shared by every subsystem that live-watches +//! folders (diff-sync checkouts, space folders). +//! +//! Every `notify::RecommendedWatcher` owns a dedicated OS thread (the FSEvents +//! run loop on macOS, the inotify loop on Linux). One watcher per checkout and +//! per space folder put ~25-30 idle threads on a normal device at boot. The hub +//! instead installs every path on a single watcher and routes each event to +//! the registrations whose path covers it, so the whole engine pays for one +//! watcher thread. Exception: inotify/kqueue get one watcher per installed +//! root (see [`SHARED_WATCHER`]); they still share the routing and batching. +//! +//! Semantics match a private watcher per registration: +//! +//! - a [`notify::RecursiveMode::NonRecursive`] registration only sees events for +//! its folder and that folder's direct children (notify's own rule); +//! - pathless events (inotify queue overflow → rescan) reach every registration, +//! since an overflow loses events for all of them; +//! - dropping the [`FsWatch`] handle stops delivery immediately and releases the +//! OS watch once no other registration needs it. +//! +//! Handlers also receive synthetic *rescan* events +//! ([`notify::Event::need_rescan`]): once when the registration's watch goes +//! live (anything before that was unobserved), and whenever events may have +//! been lost (see below). Callers treat them like any change. +//! +//! **Batching.** Registration changes are coalesced on a short-lived worker +//! thread ([`BATCH_QUIET`] of quiet, at most [`BATCH_MAX`]) and applied in one +//! pass, so a boot or a burst of entries is one reconcile, not one per path. +//! +//! **Restart gaps.** notify 7's FSEvents backend stops the stream, joins its +//! thread and starts a new stream (from "now" — the event id is not settable) +//! on *every* `watch`/`unwatch`, so each change drops events for every other +//! path for that moment. While a pass changes the main watcher, the hub runs +//! a temporary *bridge* stream on the surviving paths' common ancestor, started +//! before the first restart and stopped after the last: the surviving paths +//! stay covered throughout and nothing needs re-capturing. If the bridge cannot +//! be opened, the pass ends by sending a rescan to every registration that was +//! live across it (one per registration per pass; callers debounce). inotify +//! and Windows watches are per path and never restart, so neither applies. +//! +//! OS watches are the minimal cover of the registrations: identical paths are +//! installed once (recursive if any registration wants recursion) and a path +//! under a recursively watched ancestor is not installed separately. When the +//! covering watch of a live registration is replaced (a nested path takes over +//! from a dropped recursive ancestor, a mode change), the registration gets a +//! rescan: the swap itself was unobserved. +//! +//! Watch failures (the folder vanished, inotify limits) are logged and the +//! registration stays without a live watch, exactly like a private watcher +//! whose `watch()` failed — callers keep their repair ticks for that case. + +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex, MutexGuard, OnceLock, PoisonError}; +use std::time::{Duration, Instant}; + +use notify::Watcher as _; + +/// A batch applies once registrations have been quiet this long… +const BATCH_QUIET: Duration = Duration::from_millis(100); +/// …or this long after its first change, whichever comes first. +const BATCH_MAX: Duration = Duration::from_secs(1); +/// Whether a watch change restarts the stream for every path (FSEvents). +const STREAM_RESTARTS_ON_CHANGE: bool = cfg!(target_os = "macos"); +/// Whether every root can share one watcher. inotify (and kqueue) build a +/// recursive watch by walking the tree *following symlinks*, so in a shared +/// instance a checkout that symlinks into another checkout (`npm link`) would +/// alias that checkout's watch descriptors: its events would be relabelled +/// and unwatching either root would strip the other. Those backends get one +/// watcher per installed root instead, exactly as before the hub. +const SHARED_WATCHER: bool = cfg!(any(target_os = "macos", windows)); + +type Handler = Arc; + +fn lock(mutex: &Mutex) -> MutexGuard<'_, T> { + mutex.lock().unwrap_or_else(PoisonError::into_inner) +} + +/// The synthetic "anything may have changed" signal. +fn rescan_event() -> notify::Event { + notify::Event::new(notify::EventKind::Other).set_flag(notify::event::Flag::Rescan) +} + +struct Registration { + /// Canonical path — what FSEvents/inotify report events under. + path: PathBuf, + recursive: bool, + handler: Handler, + /// Shared with the [`FsWatch`]: an installed OS watch covers `path`. + attached: Arc, +} + +impl Registration { + fn covers(&self, path: &Path) -> bool { + path.starts_with(&self.path) + && (self.recursive || path == self.path || path.parent() == Some(self.path.as_path())) + } +} + +#[derive(Default)] +struct Routes { + next_id: u64, + regs: HashMap, +} + +#[derive(Default)] +struct OsState { + /// The shared watcher ([`SHARED_WATCHER`] backends). Created lazily on the + /// first install. On macOS an FSEvents watcher with no paths has no + /// run-loop thread at all. + watcher: Option, + /// One watcher per installed root on the other backends. Dropping one + /// closes its instance, releasing every OS watch it added — including + /// those a failed recursive `watch` left behind. + roots: HashMap, + /// Canonical path → recursive, as installed. + installed: HashMap, + /// Wanted paths whose watch failed. Not retried while still wanted (every + /// FSEvents watch/unwatch restarts the stream for *all* paths); forgotten + /// once nothing registers them, so a fresh registration retries. + failed: HashSet, +} + +#[derive(Default)] +struct Batch { + /// Bumped on every registration change. + generation: u64, + /// A batch worker thread is pending or applying. + worker: bool, +} + +#[derive(Default)] +struct Stats { + /// Passes that changed the main watcher. + passes: AtomicUsize, + /// Bridge streams opened. + bridges: AtomicUsize, +} + +pub(crate) struct FsWatchHub { + /// Shared with the watcher callbacks. Never held across a watcher call: + /// FSEvents `watch`/`unwatch`/drop stop and join the run-loop thread, + /// which may be inside the callback waiting on this lock. + routes: Arc>, + /// Serializes OS watch changes. + os: Mutex, + batch: Mutex, + restarts_on_change: bool, + /// [`SHARED_WATCHER`]; a field so tests can drive both modes. + shared_watcher: bool, + bridge_enabled: AtomicBool, + #[cfg_attr(not(test), allow(dead_code))] + stats: Stats, +} + +/// A registration. Dropping it stops delivery and releases the OS watch. +pub(crate) struct FsWatch { + hub: Arc, + id: u64, + attached: Arc, +} + +impl FsWatch { + /// Whether an installed OS watch currently covers the path. False until + /// the registration's batch applies, and for good if the watch failed. + pub(crate) fn is_live(&self) -> bool { + self.attached.load(Ordering::Acquire) + } +} + +impl Drop for FsWatch { + fn drop(&mut self) { + if lock(&self.hub.routes).regs.remove(&self.id).is_some() { + self.hub.schedule(); + } + } +} + +/// Resets [`Batch::worker`] if a pass panics, so later changes still get a +/// worker. A normal exit resets it in [`FsWatchHub::run_batches`] itself. +struct WorkerGuard<'a>(&'a FsWatchHub); + +impl Drop for WorkerGuard<'_> { + fn drop(&mut self) { + if std::thread::panicking() { + lock(&self.0.batch).worker = false; + } + } +} + +impl FsWatchHub { + pub(crate) fn new() -> Arc { + Arc::new(Self { + routes: Arc::new(Mutex::new(Routes::default())), + os: Mutex::new(OsState::default()), + batch: Mutex::new(Batch::default()), + restarts_on_change: STREAM_RESTARTS_ON_CHANGE, + shared_watcher: SHARED_WATCHER, + bridge_enabled: AtomicBool::new(true), + stats: Stats::default(), + }) + } + + /// The process-wide hub. + pub(crate) fn global() -> Arc { + static HUB: OnceLock> = OnceLock::new(); + HUB.get_or_init(Self::new).clone() + } + + /// Route events under `path` to `handler` until the returned handle drops. + /// Returns at once; the OS watch is installed by the next batch, which + /// then sends `handler` a rescan event. Canonicalizes `path` (a blocking + /// stat) — call from the blocking pool. + pub(crate) fn watch( + self: &Arc, + path: &Path, + mode: notify::RecursiveMode, + handler: impl Fn(¬ify::Event) + Send + Sync + 'static, + ) -> FsWatch { + let path = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()); + let attached = Arc::new(AtomicBool::new(false)); + let id = { + let mut routes = lock(&self.routes); + routes.next_id += 1; + let id = routes.next_id; + routes.regs.insert( + id, + Registration { + path, + recursive: matches!(mode, notify::RecursiveMode::Recursive), + handler: Arc::new(handler), + attached: attached.clone(), + }, + ); + id + }; + self.schedule(); + FsWatch { + hub: self.clone(), + id, + attached, + } + } + + /// Note a registration change; start a batch worker unless one is pending. + /// The worker is a short-lived plain thread (not the tokio blocking pool): + /// a task queued on a runtime that shuts down before running it would + /// leave `worker` set and wedge the hub for the rest of the process. + fn schedule(self: &Arc) { + { + let mut batch = lock(&self.batch); + batch.generation += 1; + if batch.worker { + return; + } + batch.worker = true; + } + let hub = self.clone(); + let spawned = std::thread::Builder::new() + .name("fs-watch batch".into()) + .spawn(move || hub.run_batches()); + if let Err(err) = spawned { + tracing::debug!(error = %err, "fs-watch: batch worker spawn failed; applying inline"); + lock(&self.batch).worker = false; + self.apply(); + } + } + + fn run_batches(&self) { + let _guard = WorkerGuard(self); + loop { + let started = Instant::now(); + let mut seen = lock(&self.batch).generation; + loop { + std::thread::sleep(BATCH_QUIET); + let now = lock(&self.batch).generation; + let quiet = now == seen; + seen = now; + if quiet || started.elapsed() >= BATCH_MAX { + break; + } + } + self.apply(); + // Changes that landed during the pass need another one. Release + // `worker` under the same lock as the check: a `schedule` between + // the two would see a worker that is about to exit and be lost. + let mut batch = lock(&self.batch); + if batch.generation == seen { + batch.worker = false; + return; + } + } + } + + /// Bring the installed OS watches in line with the registrations and + /// deliver the resulting rescan signals. Blocking. Idempotent. + pub(crate) fn apply(&self) { + let rescans = { + let mut os = lock(&self.os); + self.reconcile_os(&mut os) + }; + let event = rescan_event(); + for handler in rescans { + handler(&event); + } + } + + fn reconcile_os(&self, os: &mut OsState) -> Vec { + // Wanted set: distinct paths, recursive if any registration recurses. + let mut wanted: HashMap = HashMap::new(); + for reg in lock(&self.routes).regs.values() { + *wanted.entry(reg.path.clone()).or_default() |= reg.recursive; + } + os.failed.retain(|path| wanted.contains_key(path)); + let before = os.installed.clone(); + let mut bridge: Option = None; + let mut bridge_tried = false; + let mut touched = false; + // A failed install may uncover paths it would have covered — iterate + // until a pass adds no new failures (bounded: `failed` only grows). + loop { + let desired = minimal_cover(&wanted, &os.failed); + if desired == os.installed { + break; + } + if self.restarts_on_change && !bridge_tried { + bridge_tried = true; + bridge = self.open_bridge(&os.installed, &desired); + } + // Removals before additions, so an outgoing watch never overlaps an + // incoming one on the same watcher. + let stale: Vec = os + .installed + .iter() + .filter(|(path, recursive)| desired.get(*path) != Some(*recursive)) + .map(|(path, _)| path.clone()) + .collect(); + for path in stale { + os.installed.remove(&path); + if os.roots.remove(&path).is_some() { + touched = true; + } else if let Some(watcher) = os.watcher.as_mut() { + touched = true; + if let Err(err) = watcher.unwatch(&path) { + tracing::debug!(path = %path.display(), error = %err, "fs-watch: unwatch failed"); + } + } + } + let mut new_failure = false; + for (path, recursive) in desired { + if os.installed.get(&path) == Some(&recursive) { + continue; + } + match install( + os, + &self.routes, + self.shared_watcher, + &path, + recursive, + &mut touched, + ) { + Ok(()) => { + os.installed.insert(path, recursive); + } + Err(err) => { + tracing::debug!(path = %path.display(), error = %err, "fs-watch: watch failed"); + os.failed.insert(path); + new_failure = true; + } + } + } + if !new_failure { + break; + } + } + if touched { + self.stats.passes.fetch_add(1, Ordering::Relaxed); + } + // Only now — the main stream is back up — may the bridge stop. + let bridged = bridge.is_some(); + drop(bridge); + let gap = self.restarts_on_change && touched && !bridged; + + let routes = lock(&self.routes); + let mut rescans = Vec::new(); + for reg in routes.regs.values() { + if !is_covered(®.path, &os.installed) { + reg.attached.store(false, Ordering::Release); + continue; + } + let was_attached = reg.attached.swap(true, Ordering::AcqRel); + // New: everything before the watch went live was unobserved. + // Gap: events may have been dropped while the stream restarted. + // Moved: the OS watch covering it was replaced (a recursive root + // dropped for a nested one, a mode change) — the bridge only spans + // unchanged roots, so the swap itself was unobserved. + let moved = + covering_roots(®.path, &before) != covering_roots(®.path, &os.installed); + if !was_attached || moved || (gap && is_covered(®.path, &before)) { + rescans.push(reg.handler.clone()); + } + } + rescans + } + + /// Start a stream covering every installed path that survives this pass, + /// so the main stream's restarts drop nothing for them. One recursive + /// watch on their common ancestor: FSEvents filters a volume-wide journal + /// by path prefix, so a broad path costs no per-directory watches — only + /// unrelated events to filter in [`dispatch`] for the bridge's short life. + fn open_bridge( + &self, + installed: &HashMap, + desired: &HashMap, + ) -> Option { + if !self.bridge_enabled.load(Ordering::Relaxed) { + return None; + } + let survivors: Vec<&PathBuf> = installed + .iter() + .filter(|(path, recursive)| desired.get(*path) == Some(*recursive)) + .map(|(path, _)| path) + .collect(); + let ancestor = common_ancestor(&survivors)?; + let routes = self.routes.clone(); + let opened = notify::recommended_watcher(move |event: notify::Result| { + if let Ok(event) = event { + dispatch(&routes, &event); + } + }) + .and_then(|mut watcher| { + watcher + .watch(&ancestor, notify::RecursiveMode::Recursive) + .map(|()| watcher) + }); + match opened { + Ok(watcher) => { + self.stats.bridges.fetch_add(1, Ordering::Relaxed); + Some(watcher) + } + Err(err) => { + tracing::debug!(path = %ancestor.display(), error = %err, + "fs-watch: bridge failed; surviving watches get a rescan"); + None + } + } + } + + #[cfg(test)] + fn installed(&self) -> HashMap { + lock(&self.os).installed.clone() + } +} + +fn install( + os: &mut OsState, + routes: &Arc>, + shared: bool, + path: &Path, + recursive: bool, + touched: &mut bool, +) -> Result<(), notify::Error> { + // FSEvents restarts the stream for every path even when `watch` fails — + // skip the obvious failure without touching the watcher. + if !path.exists() { + return Err(notify::Error::path_not_found().add_path(path.to_path_buf())); + } + let new_watcher = || { + let routes = routes.clone(); + notify::recommended_watcher(move |event: notify::Result| match event { + Ok(event) => dispatch(&routes, &event), + Err(err) => tracing::debug!(error = %err, "fs-watch: watcher error"), + }) + }; + let mode = if recursive { + notify::RecursiveMode::Recursive + } else { + notify::RecursiveMode::NonRecursive + }; + if !shared { + // On failure the watcher drops here, with any partial watches. + let mut watcher = new_watcher()?; + *touched = true; + watcher.watch(path, mode)?; + os.roots.insert(path.to_path_buf(), watcher); + return Ok(()); + } + if os.watcher.is_none() { + os.watcher = Some(new_watcher()?); + } + *touched = true; + os.watcher + .as_mut() + .expect("watcher just created") + .watch(path, mode) +} + +fn root_covers(root: &Path, recursive: bool, path: &Path) -> bool { + path == root || (recursive && path.starts_with(root)) +} + +fn is_covered(path: &Path, installed: &HashMap) -> bool { + installed + .iter() + .any(|(root, recursive)| root_covers(root, *recursive, path)) +} + +fn covering_roots<'a>( + path: &Path, + installed: &'a HashMap, +) -> std::collections::BTreeSet<(&'a PathBuf, bool)> { + installed + .iter() + .filter(|(root, recursive)| root_covers(root, **recursive, path)) + .map(|(root, recursive)| (root, *recursive)) + .collect() +} + +fn common_ancestor(paths: &[&PathBuf]) -> Option { + let (first, rest) = paths.split_first()?; + let mut ancestor: &Path = first; + for path in rest { + while !path.starts_with(ancestor) { + ancestor = ancestor.parent()?; + } + } + Some(ancestor.to_path_buf()) +} + +/// Paths to install: every wanted, non-failed path not already inside another +/// installable recursive path. +fn minimal_cover( + wanted: &HashMap, + failed: &HashSet, +) -> HashMap { + let candidates: Vec<(&PathBuf, bool)> = wanted + .iter() + .filter(|(path, _)| !failed.contains(*path)) + .map(|(path, recursive)| (path, *recursive)) + .collect(); + candidates + .iter() + .filter(|(path, _)| { + !candidates + .iter() + .any(|(other, recursive)| *recursive && other != path && path.starts_with(other)) + }) + .map(|(path, recursive)| ((*path).clone(), *recursive)) + .collect() +} + +fn dispatch(routes: &Mutex, event: ¬ify::Event) { + // Collect under the lock, call outside it: a handler must never be able to + // stall registration bookkeeping. + let handlers: Vec = lock(routes) + .regs + .values() + .filter(|reg| event.paths.is_empty() || event.paths.iter().any(|path| reg.covers(path))) + .map(|reg| reg.handler.clone()) + .collect(); + for handler in handlers { + handler(event); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender, unbounded_channel}; + + fn reg(path: &str, recursive: bool) -> Registration { + Registration { + path: PathBuf::from(path), + recursive, + handler: Arc::new(|_| {}), + attached: Arc::default(), + } + } + + /// Kind of signal a test handler saw. + #[derive(Debug, PartialEq)] + enum Seen { + Event, + Rescan, + } + + fn recorder() -> ( + impl Fn(¬ify::Event) + Send + Sync + 'static, + UnboundedReceiver, + ) { + let (tx, rx): (UnboundedSender, _) = unbounded_channel(); + let handler = move |event: ¬ify::Event| { + let _ = tx.send(if event.need_rescan() { + Seen::Rescan + } else { + Seen::Event + }); + }; + (handler, rx) + } + + fn drain(rx: &mut UnboundedReceiver) -> Vec { + let mut seen = Vec::new(); + while let Ok(signal) = rx.try_recv() { + seen.push(signal); + } + seen + } + + async fn expect(rx: &mut UnboundedReceiver, want: Seen) { + let deadline = tokio::time::Instant::now() + Duration::from_secs(5); + loop { + let got = tokio::time::timeout_at(deadline, rx.recv()) + .await + .unwrap_or_else(|_| panic!("{want:?} delivered before timeout")) + .expect("channel open"); + if got == want { + return; + } + } + } + + /// Wait for the batch worker, then FSEvents' first deliveries, to settle. + async fn settle(hub: &FsWatchHub) { + let deadline = Instant::now() + Duration::from_secs(10); + while lock(&hub.batch).worker { + assert!(Instant::now() < deadline, "batch worker idles"); + tokio::time::sleep(Duration::from_millis(20)).await; + } + tokio::time::sleep(Duration::from_millis(300)).await; + } + + fn tree() -> (tempfile::TempDir, PathBuf) { + let tmp = tempfile::tempdir().unwrap(); + let root = std::fs::canonicalize(tmp.path()).unwrap(); + (tmp, root) + } + + #[test] + fn non_recursive_registration_sees_only_direct_children() { + let flat = reg("/r/space", false); + assert!(flat.covers(Path::new("/r/space"))); + assert!(flat.covers(Path::new("/r/space/.git"))); + assert!(!flat.covers(Path::new("/r/space/src/main.rs"))); + assert!(!flat.covers(Path::new("/r/spaceship"))); + let deep = reg("/r/space", true); + assert!(deep.covers(Path::new("/r/space/src/main.rs"))); + assert!(!deep.covers(Path::new("/r/other/x"))); + } + + #[test] + fn cover_dedupes_merges_modes_and_skips_nested_paths() { + let wanted: HashMap = [ + ("/r/main", true), + ("/r/main/.git/worktrees/wt", true), // inside a recursive watch + ("/r/flat", false), + ("/r/flat/sub", true), // a non-recursive parent covers nothing + ] + .into_iter() + .map(|(p, r)| (PathBuf::from(p), r)) + .collect(); + let cover = minimal_cover(&wanted, &HashSet::new()); + let mut paths: Vec<_> = cover.keys().cloned().collect(); + paths.sort(); + assert_eq!( + paths, + ["/r/flat", "/r/flat/sub", "/r/main"] + .map(PathBuf::from) + .to_vec() + ); + // A failed ancestor no longer covers its descendants. + let failed: HashSet = [PathBuf::from("/r/main")].into(); + assert!( + minimal_cover(&wanted, &failed).contains_key(Path::new("/r/main/.git/worktrees/wt")) + ); + } + + #[test] + fn common_ancestor_of_survivors() { + let a = PathBuf::from("/u/me/projects/a"); + let b = PathBuf::from("/u/me/projects/b/.git"); + let c = PathBuf::from("/u/me/.zeron/wt"); + assert_eq!(common_ancestor(&[&a]), Some(a.clone())); + assert_eq!( + common_ancestor(&[&a, &b]), + Some(PathBuf::from("/u/me/projects")) + ); + assert_eq!(common_ancestor(&[&a, &b, &c]), Some(PathBuf::from("/u/me"))); + assert_eq!(common_ancestor(&[]), None); + } + + #[test] + fn events_route_by_path_and_pathless_events_broadcast() { + let routes = Mutex::new(Routes::default()); + let hits = Arc::new(Mutex::new(Vec::<&'static str>::new())); + for (id, (name, path, recursive)) in [ + ("a", "/r/a", true), + ("b", "/r/b", true), + ("flat", "/r", false), + ] + .into_iter() + .enumerate() + { + let hits = hits.clone(); + lock(&routes).regs.insert( + id as u64, + Registration { + path: PathBuf::from(path), + recursive, + handler: Arc::new(move |_| lock(&hits).push(name)), + attached: Arc::default(), + }, + ); + } + let event = + |path: &str| notify::Event::new(notify::EventKind::Any).add_path(PathBuf::from(path)); + dispatch(&routes, &event("/r/a/src/x.rs")); + assert_eq!(std::mem::take(&mut *lock(&hits)), vec!["a"]); + dispatch(&routes, &event("/r/b")); + let mut got = std::mem::take(&mut *lock(&hits)); + got.sort(); + assert_eq!(got, vec!["b", "flat"]); // `/r/b` is a direct child of `/r` + dispatch(&routes, ¬ify::Event::new(notify::EventKind::Other)); + assert_eq!(lock(&hits).len(), 3, "a rescan reaches every registration"); + } + + /// End to end through one real watcher: two folders, each event reaches + /// only its own registration, each gets one rescan when it goes live, and + /// a dropped handle unwatches. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn shared_watcher_routes_events_and_unwatches_on_drop() { + let (_tmp, root) = tree(); + let (a, b) = (root.join("a"), root.join("b")); + std::fs::create_dir_all(a.join("deep")).unwrap(); + std::fs::create_dir_all(&b).unwrap(); + + let hub = FsWatchHub::new(); + let (on_a, mut a_rx) = recorder(); + let (on_b, mut b_rx) = recorder(); + let watch_a = hub.watch(&a, notify::RecursiveMode::Recursive, on_a); + let watch_b = hub.watch(&b, notify::RecursiveMode::NonRecursive, on_b); + expect(&mut a_rx, Seen::Rescan).await; + expect(&mut b_rx, Seen::Rescan).await; + assert!(watch_a.is_live() && watch_b.is_live()); + assert_eq!(hub.installed().len(), 2); + settle(&hub).await; + drain(&mut a_rx); + drain(&mut b_rx); + + std::fs::write(a.join("deep/x.txt"), "x").unwrap(); + expect(&mut a_rx, Seen::Event).await; + tokio::time::sleep(Duration::from_millis(300)).await; + assert!(drain(&mut b_rx).is_empty(), "a's event must not reach b"); + + std::fs::write(b.join("y.txt"), "y").unwrap(); + expect(&mut b_rx, Seen::Event).await; + settle(&hub).await; + drain(&mut a_rx); + + drop(watch_a); + settle(&hub).await; + assert_eq!(hub.installed().len(), 1, "dropped path unwatched"); + std::fs::write(a.join("deep/x.txt"), "xx").unwrap(); + tokio::time::sleep(Duration::from_millis(500)).await; + assert!( + drain(&mut a_rx).is_empty(), + "a dropped handle receives nothing" + ); + drop(watch_b); + } + + /// A burst of registrations is one pass over the OS watcher (one stream + /// restart on FSEvents), not one per path. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn registration_burst_applies_in_one_pass() { + let (_tmp, root) = tree(); + let hub = FsWatchHub::new(); + let mut watches = Vec::new(); + for i in 0..8 { + let dir = root.join(format!("d{i}")); + std::fs::create_dir(&dir).unwrap(); + watches.push(hub.watch(&dir, notify::RecursiveMode::Recursive, |_| {})); + } + settle(&hub).await; + assert!(watches.iter().all(FsWatch::is_live)); + assert_eq!(hub.installed().len(), 8); + assert_eq!(hub.stats.passes.load(Ordering::Relaxed), 1); + watches.clear(); + settle(&hub).await; + assert!(hub.installed().is_empty()); + assert_eq!( + hub.stats.passes.load(Ordering::Relaxed), + 2, + "one pass for the drops" + ); + } + + /// Changing the watch set restarts the FSEvents stream. With the bridge, + /// surviving registrations stay covered and get no rescan; without it + /// (bridge unavailable) each gets exactly one rescan per pass. + #[cfg(target_os = "macos")] + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn restart_gap_is_bridged_or_compensated() { + let (_tmp, root) = tree(); + let (a, b, c) = (root.join("a"), root.join("b"), root.join("c")); + for dir in [&a, &b, &c] { + std::fs::create_dir(dir).unwrap(); + } + let hub = FsWatchHub::new(); + let (on_a, mut a_rx) = recorder(); + let _watch_a = hub.watch(&a, notify::RecursiveMode::Recursive, on_a); + expect(&mut a_rx, Seen::Rescan).await; + settle(&hub).await; + drain(&mut a_rx); + + let watch_b = hub.watch(&b, notify::RecursiveMode::Recursive, |_| {}); + settle(&hub).await; + assert!(watch_b.is_live()); + assert_eq!( + hub.stats.bridges.load(Ordering::Relaxed), + 1, + "bridge opened" + ); + assert!( + !drain(&mut a_rx).contains(&Seen::Rescan), + "a bridged restart needs no rescan" + ); + std::fs::write(a.join("x"), "x").unwrap(); + expect(&mut a_rx, Seen::Event).await; + settle(&hub).await; + drain(&mut a_rx); + + hub.bridge_enabled.store(false, Ordering::Relaxed); + let _watch_c = hub.watch(&c, notify::RecursiveMode::Recursive, |_| {}); + drop(watch_b); + settle(&hub).await; + let seen = drain(&mut a_rx); + assert_eq!( + seen.iter().filter(|s| **s == Seen::Rescan).count(), + 1, + "one compensating rescan per pass: {seen:?}" + ); + } + + #[test] + fn missing_path_is_not_live_and_does_not_block_others() { + let (_tmp, root) = tree(); + let hub = FsWatchHub::new(); + let (on_gone, mut gone_rx) = recorder(); + let gone = hub.watch( + &root.join("gone"), + notify::RecursiveMode::Recursive, + on_gone, + ); + let here = hub.watch(&root, notify::RecursiveMode::NonRecursive, |_| {}); + hub.apply(); + assert!(!gone.is_live()); + assert!( + drain(&mut gone_rx).is_empty(), + "a failed watch never attaches" + ); + assert!(here.is_live()); + assert_eq!(hub.installed().keys().collect::>(), vec![&root]); + drop(here); + drop(gone); + hub.apply(); + assert!(hub.installed().is_empty()); + } + + #[test] + fn overlapping_registrations_share_one_install_until_the_last_drops() { + let (_tmp, root) = tree(); + std::fs::create_dir_all(root.join("nested")).unwrap(); + let hub = FsWatchHub::new(); + let flat = hub.watch(&root, notify::RecursiveMode::NonRecursive, |_| {}); + let nested = hub.watch( + &root.join("nested"), + notify::RecursiveMode::Recursive, + |_| {}, + ); + let deep = hub.watch(&root, notify::RecursiveMode::Recursive, |_| {}); + hub.apply(); + // Same path merges to recursive; the nested path rides along inside it. + assert_eq!(hub.installed(), HashMap::from([(root.clone(), true)])); + assert!(nested.is_live()); + drop(deep); + hub.apply(); + assert_eq!( + hub.installed(), + HashMap::from([(root.clone(), false), (root.join("nested"), true)]) + ); + drop(flat); + drop(nested); + hub.apply(); + assert!(hub.installed().is_empty()); + } + + /// A hub configured like inotify's: a watcher per root, no restarts. + fn per_root_hub() -> Arc { + let hub = Arc::into_inner(FsWatchHub::new()).expect("fresh hub"); + Arc::new(FsWatchHub { + shared_watcher: false, + restarts_on_change: false, + ..hub + }) + } + + #[test] + fn replaced_cover_rescans_the_registration() { + let (_tmp, root) = tree(); + std::fs::create_dir_all(root.join("nested")).unwrap(); + for hub in [FsWatchHub::new(), per_root_hub()] { + let (on_nested, mut nested_rx) = recorder(); + let _nested = hub.watch( + &root.join("nested"), + notify::RecursiveMode::Recursive, + on_nested, + ); + let deep = hub.watch(&root, notify::RecursiveMode::Recursive, |_| {}); + hub.apply(); + drain(&mut nested_rx); + // `nested` rode inside `root`'s watch; it now gets its own, and + // nothing observed the swap. + drop(deep); + hub.apply(); + assert_eq!( + hub.installed(), + HashMap::from([(root.join("nested"), true)]) + ); + assert!(drain(&mut nested_rx).contains(&Seen::Rescan)); + // A pass that leaves its cover alone sends nothing. + let _other = hub.watch(&root, notify::RecursiveMode::NonRecursive, |_| {}); + hub.apply(); + assert!(!drain(&mut nested_rx).contains(&Seen::Rescan)); + } + } + + #[tokio::test] + async fn per_root_watchers_route_events_and_close_on_drop() { + let (_tmp, root) = tree(); + let (a, b) = (root.join("a"), root.join("b")); + for dir in [&a, &b] { + std::fs::create_dir(dir).unwrap(); + } + let hub = per_root_hub(); + let (on_a, mut a_rx) = recorder(); + let watch_a = hub.watch(&a, notify::RecursiveMode::Recursive, on_a); + let (on_b, mut b_rx) = recorder(); + let _watch_b = hub.watch(&b, notify::RecursiveMode::Recursive, on_b); + hub.apply(); + assert_eq!(lock(&hub.os).roots.len(), 2); + assert!(lock(&hub.os).watcher.is_none()); + settle(&hub).await; + drain(&mut a_rx); + drain(&mut b_rx); + std::fs::write(b.join("x"), "x").unwrap(); + expect(&mut b_rx, Seen::Event).await; + assert!(!drain(&mut a_rx).contains(&Seen::Event)); + drop(watch_a); + hub.apply(); + assert_eq!(lock(&hub.os).roots.keys().collect::>(), [&b]); + } +} diff --git a/crates/engine/src/harness_updates.rs b/crates/engine/src/harness_updates.rs index 24557fe2d2..92a90613c6 100644 --- a/crates/engine/src/harness_updates.rs +++ b/crates/engine/src/harness_updates.rs @@ -72,6 +72,33 @@ struct CodexStandaloneInstall { target: String, } +/// A Homebrew cask or formula that owns the resolved CLI. The token is taken +/// from the `Caskroom` or `Cellar` directory, never from installer output. +#[derive(Debug, Clone, PartialEq, Eq)] +struct HomebrewPackage { + brew: PathBuf, + cask: bool, + token: String, +} + +impl HomebrewPackage { + fn upgrade_command(&self) -> String { + if self.cask { + format!("brew upgrade --cask {}", self.token) + } else { + format!("brew upgrade --formula {}", self.token) + } + } + + fn upgrade_args(&self) -> Vec<&str> { + if self.cask { + vec!["upgrade", "--cask", self.token.as_str()] + } else { + vec!["upgrade", "--formula", self.token.as_str()] + } + } +} + enum UpdatePlan { Command { executable: PathBuf, @@ -79,6 +106,7 @@ enum UpdatePlan { }, AntigravityArchive, CodexStandalone(CodexStandaloneInstall), + Homebrew(HomebrewPackage), } struct ReleaseAsset { @@ -195,9 +223,8 @@ fn provider(id: HarnessId) -> ProviderSpec { repository: "openai/codex", tag_prefix: "rust-v", }, - // Codex has no self-update command. npm/Homebrew remain manual; - // `update_plan` separately recognizes the official standalone - // release layout, where an atomic in-place update is unambiguous. + // npm stays manual. Homebrew casks and formulae are upgraded with + // `brew upgrade`; the official standalone layout is updated in place. update_args: None, manual_command: "Update Codex with its original installer or package manager", }, @@ -255,6 +282,9 @@ fn provider(id: HarnessId) -> ProviderSpec { } fn update_plan(harness: HarnessId, executable: &Path) -> Result { + if let Some(package) = homebrew_package(executable) { + return Ok(UpdatePlan::Homebrew(package)); + } if harness == HarnessId::ClaudeCode && claude_package_manager_command(executable).is_some() { return Err("update Claude Code with its package manager".into()); } @@ -534,6 +564,13 @@ impl HarnessUpdateCoordinator { && status.policy == HarnessUpdatePolicy::AutoWhenIdle && status.phase == HarnessUpdatePhase::Available && status.can_apply + // The Update button can still run `brew upgrade` when Homebrew has + // not published the upstream release. Automatic installs wait until + // Homebrew itself reports a newer package. + && !status + .manual_command + .as_deref() + .is_some_and(unpublished_homebrew_upgrade) } fn schedule_automatic_update(&self, harness: HarnessId) { @@ -609,6 +646,11 @@ impl HarnessUpdateCoordinator { if self.settle_if_unmonitored(harness) { return Ok(()); } + if let Some(package) = homebrew_package(&executable) { + return self + .finish_homebrew_check(harness, &executable, installed, source, package) + .await; + } let latest = match spec.latest { LatestSource::AntigravityAcp => self .antigravity_acp_latest() @@ -750,6 +792,148 @@ impl HarnessUpdateCoordinator { Ok(()) } + /// Homebrew owns this binary, so the installable release is the cask or + /// formula version. An upstream release that Homebrew has not published yet + /// still gets an Update button — it runs `brew upgrade` — but is not + /// installed automatically. + async fn finish_homebrew_check( + &self, + harness: HarnessId, + executable: &Path, + installed: String, + source: HarnessInstallSource, + package: HomebrewPackage, + ) -> Result<(), String> { + let brew_version = match self.homebrew_latest(&package).await { + Ok(version) => version, + Err(error) => { + return self.fail_check_with_installed(harness, installed, source, error); + } + }; + let upstream = self + .upstream_version(harness, &installed) + .await + .ok() + .filter(|version| version_is_newer(version, &installed)); + let brew_newer = version_is_newer(&brew_version, &installed); + let (latest, note) = if brew_newer { + (brew_version, None) + } else if let Some(upstream) = + upstream.filter(|version| version_is_newer(version, &brew_version)) + { + (upstream, Some(package.upgrade_command())) + } else { + (brew_version, None) + }; + if self.settle_if_unmonitored(harness) { + return Ok(()); + } + let dismissed = lock(&self.inner.prefs) + .dismissed_versions + .get(&harness) + .is_some_and(|dismissed| dismissed == &latest); + let available = version_is_newer(&latest, &installed) && !dismissed; + let manual_command = if available { note } else { None }; + let channel = (harness == HarnessId::ClaudeCode) + .then(|| claude_cask_channel(executable)) + .flatten() + .map(str::to_owned); + let registry = self.inner.registry.clone(); + self.mutate(harness, |status| { + status.installed_version = Some(installed); + status.latest_version = Some(latest); + if channel.is_some() { + status.channel = channel.clone(); + } + status.source = source; + status.can_apply = true; + status.manual_command = manual_command; + status.phase = if status.policy == HarnessUpdatePolicy::Off + || !registry.enabled_set().contains(&harness) + { + HarnessUpdatePhase::Dormant + } else if available { + HarnessUpdatePhase::Available + } else { + HarnessUpdatePhase::Current + }; + status.checked_at = Some(now_ms()); + status.error = None; + }); + Ok(()) + } + + async fn homebrew_latest(&self, package: &HomebrewPackage) -> Result { + let api = self.homebrew_api_version(package).await; + let local = self.homebrew_local_version(package).await; + match (api, local) { + (Ok(api), Ok(local)) => Ok(if version_is_newer(&local, &api) { + local + } else { + api + }), + (Ok(version), Err(_)) | (Err(_), Ok(version)) => Ok(version), + (Err(api), Err(local)) => Err(format!("{api}; {local}")), + } + } + + async fn homebrew_api_version(&self, package: &HomebrewPackage) -> Result { + let kind = if package.cask { "cask" } else { "formula" }; + let url = format!( + "https://formulae.brew.sh/api/{kind}/{}.json", + homebrew_url_component(&package.token) + ); + let response = self + .inner + .client + .get(url) + .send() + .await + .map_err(|error| format!("Homebrew version check failed: {error}"))? + .error_for_status() + .map_err(|error| format!("Homebrew version check failed: {error}"))?; + let body = response + .text() + .await + .map_err(|error| format!("Homebrew version response was invalid: {error}"))?; + parse_homebrew_version(package.cask, &body) + } + + async fn homebrew_local_version(&self, package: &HomebrewPackage) -> Result { + let brew = resolve_brew(&package.brew)?; + let token = package.token.as_str(); + let args: &[&str] = if package.cask { + &["info", "--cask", "--json=v2", token] + } else { + &["info", "--formula", "--json=v2", token] + }; + // Don't refresh taps on a probe. `brew upgrade` does that when applying. + let output = run_command_output_env(&brew, args, COMMAND_TIMEOUT, BREW_INFO_ENV).await?; + parse_homebrew_version(package.cask, &output) + } + + /// Release feeds that do not spawn the CLI. A failure here only hides the + /// "published upstream, not yet in Homebrew" note. + async fn upstream_version( + &self, + harness: HarnessId, + installed: &str, + ) -> Result { + match provider(harness).latest { + LatestSource::Github { + repository, + tag_prefix, + } => self.github_latest(repository, tag_prefix).await, + LatestSource::Npm(package) => self.npm_latest(package).await, + LatestSource::Opencode => self.npm_latest(opencode_release_package(installed)).await, + LatestSource::AntigravityAcp => self.antigravity_acp_latest().await, + LatestSource::Claude + | LatestSource::Command(_) + | LatestSource::Hermes + | LatestSource::Manual => Err("no separate upstream feed".into()), + } + } + /// Apply one known release. Cancellation is honored while waiting for the /// exclusive lease and before mutation; once the vendor updater starts, /// the installation phase is intentionally non-interruptible. The owned @@ -876,6 +1060,20 @@ impl HarnessUpdateCoordinator { self.install_codex_standalone(harness, ¤t, install, &cancel) .await } + UpdatePlan::Homebrew(package) => match resolve_brew(&package.brew) { + Ok(brew) => { + let args = package.upgrade_args(); + match self.begin_install(harness, &cancel) { + Ok(()) => { + run_command_output_env(&brew, &args, UPDATE_TIMEOUT, BREW_UPGRADE_ENV) + .await + .map(drop) + } + Err(error) => Err(error), + } + } + Err(error) => Err(error), + }, }; if let Err(error) = applied { drop(lease); @@ -893,18 +1091,32 @@ impl HarnessUpdateCoordinator { }); let verified = match self.executable(harness) { Ok(executable) => { - run_version_command(harness, &executable, provider(harness).version_args).await + run_version_command(harness, &executable, provider(harness).version_args) + .await + .map(|version| (version, executable)) } Err(error) => Err(error), }; let result = match verified { - Ok(version) => { + Ok((version, executable)) => { let expected = current.latest_version.as_deref(); - if expected.is_some_and(|latest| version_is_newer(latest, &version)) { - Err(format!( - "verification returned {version}, older than expected {}", - expected.unwrap_or_default() - )) + if let Some(latest) = expected.filter(|latest| version_is_newer(latest, &version)) { + let lag = current + .manual_command + .as_deref() + .is_some_and(unpublished_homebrew_upgrade); + Err(match homebrew_package(&executable) { + Some(package) if lag => format!( + "Homebrew is still on {version}. {latest} is not available from `{}` yet", + package.upgrade_command() + ), + Some(_) => format!( + "Homebrew upgrade left the CLI on {version}, older than expected {latest}" + ), + None => { + format!("verification returned {version}, older than expected {latest}") + } + }) } else { lock(&self.inner.prefs).dismissed_versions.remove(&harness); self.persist_preferences(); @@ -1697,6 +1909,134 @@ fn activate_codex_release( Err("automatic Codex standalone updates are not supported on this platform".into()) } +fn unpublished_homebrew_upgrade(command: &str) -> bool { + command + .trim() + .trim_matches('`') + .starts_with("brew upgrade ") +} + +fn homebrew_token_ok(token: &str) -> bool { + let Some(first) = token.chars().next() else { + return false; + }; + first.is_ascii_alphanumeric() + && token.len() <= 128 + && token + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '+' | '-' | '_' | '.' | '@')) + // brew loads a `.rb` or `.json` argument as a local package file. + && !token.ends_with(".rb") + && !token.ends_with(".json") +} + +fn homebrew_url_component(token: &str) -> String { + let mut encoded = String::new(); + for byte in token.bytes() { + match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + encoded.push(byte as char); + } + _ => encoded.push_str(&format!("%{byte:02X}")), + } + } + encoded +} + +/// `Caskroom///…` and `Cellar///{bin,libexec}/…` +/// are Homebrew's own layout. Anything else under a Homebrew prefix is not a +/// Homebrew-owned CLI: npm packages in `lib/node_modules`, including globals +/// that a keg-only runtime such as `node@20` keeps inside its own keg, belong +/// to that package manager, and upgrading the runtime would not update them. +fn homebrew_package(path: &Path) -> Option { + // Homebrew runs only on macOS and Linux. + if !cfg!(unix) { + return None; + } + let canonical = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()); + let components: Vec<_> = canonical.components().collect(); + let (index, cask) = + components + .iter() + .enumerate() + .find_map(|(index, component)| match component.as_os_str().to_str()? { + "Caskroom" => Some((index, true)), + "Cellar" => Some((index, false)), + _ => None, + })?; + let token = components.get(index + 1)?.as_os_str().to_str()?; + if !homebrew_token_ok(token) { + return None; + } + let is_normal = |offset: usize| { + matches!( + components.get(index + offset), + Some(std::path::Component::Normal(_)) + ) + }; + // The executable must live inside a versioned install of the package. + if !is_normal(2) || !is_normal(3) { + return None; + } + if !cask + && !matches!( + components[index + 3].as_os_str().to_str(), + Some("bin" | "libexec") + ) + { + return None; + } + let mut prefix = PathBuf::new(); + for component in &components[..index] { + prefix.push(component); + } + if prefix.as_os_str().is_empty() { + return None; + } + Some(HomebrewPackage { + brew: prefix.join("bin").join("brew"), + cask, + token: token.to_string(), + }) +} + +fn resolve_brew(preferred: &Path) -> Result { + if preferred.is_file() { + Ok(preferred.to_path_buf()) + } else { + // A different prefix's brew would upgrade the wrong install. + Err(format!("Homebrew was not found at {}", preferred.display())) + } +} + +fn parse_homebrew_version(cask: bool, body: &str) -> Result { + let json: serde_json::Value = serde_json::from_str(body) + .map_err(|error| format!("Homebrew version response was invalid: {error}"))?; + let version = if cask { + json.get("version") + .and_then(serde_json::Value::as_str) + .or_else(|| { + json.pointer("/casks/0/version") + .and_then(serde_json::Value::as_str) + }) + } else { + json.pointer("/versions/stable") + .and_then(serde_json::Value::as_str) + .or_else(|| { + json.pointer("/formulae/0/versions/stable") + .and_then(serde_json::Value::as_str) + }) + } + .map(str::trim) + .filter(|version| !version.is_empty()) + .ok_or_else(|| "Homebrew version response contained no version".to_string())?; + let comparable = version.split(',').next().unwrap_or(version).trim(); + if version_numbers(comparable).is_none() { + return Err(format!("Homebrew version {version} is not comparable")); + } + Ok(comparable.to_string()) +} + fn classify_source(path: &Path) -> HarnessInstallSource { let canonical = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()); let text = format!("{}\n{}", path.display(), canonical.display()).to_ascii_lowercase(); @@ -1724,17 +2064,14 @@ fn claude_package_manager_command_for(harness: HarnessId, path: &Path) -> Option } fn claude_package_manager_command(path: &Path) -> Option { + if let Some(package) = homebrew_package(path) { + return Some(package.upgrade_command()); + } let canonical = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()); let text = format!("{}\n{}", path.display(), canonical.display()) .replace('\\', "/") .to_ascii_lowercase(); - if let Some(channel) = claude_cask_channel(path) { - Some(if channel == "latest" { - "brew upgrade claude-code@latest".into() - } else { - "brew upgrade claude-code".into() - }) - } else if text.contains("/winget/") || text.contains("/microsoft/winget/") { + if text.contains("/winget/") || text.contains("/microsoft/winget/") { Some("winget upgrade Anthropic.ClaudeCode".into()) } else if canonical.starts_with("/usr/bin") || canonical.starts_with("/nix/store") @@ -1806,6 +2143,21 @@ async fn run_command(executable: &Path, args: &[&str], timeout: Duration) -> Res .map(drop) } +const BREW_UPGRADE_ENV: &[(&str, &str)] = &[ + ("NONINTERACTIVE", "1"), + ("HOMEBREW_NO_ANALYTICS", "1"), + ("HOMEBREW_NO_ENV_HINTS", "1"), + ("HOMEBREW_NO_EMOJI", "1"), +]; + +const BREW_INFO_ENV: &[(&str, &str)] = &[ + ("NONINTERACTIVE", "1"), + ("HOMEBREW_NO_ANALYTICS", "1"), + ("HOMEBREW_NO_ENV_HINTS", "1"), + ("HOMEBREW_NO_EMOJI", "1"), + ("HOMEBREW_NO_AUTO_UPDATE", "1"), +]; + /// Unix updaters can delegate installation to npm, pip, or a shell. Own /// their process group as well as the leader, including on future cancellation. #[cfg(unix)] @@ -1823,6 +2175,15 @@ async fn run_command_output( executable: &Path, args: &[&str], timeout: Duration, +) -> Result { + run_command_output_env(executable, args, timeout, &[]).await +} + +async fn run_command_output_env( + executable: &Path, + args: &[&str], + timeout: Duration, + env: &[(&str, &str)], ) -> Result { let mut command = Command::new(executable); command @@ -1832,6 +2193,9 @@ async fn run_command_output( .stderr(Stdio::piped()) .kill_on_drop(true) .env("NO_COLOR", "1"); + for (key, value) in env { + command.env(*key, *value); + } zeron_harness::compose_child_path(&mut command, executable); #[cfg(unix)] command.process_group(0); @@ -3103,22 +3467,25 @@ esac for (path, command, channel) in [ ( "/opt/homebrew/Caskroom/claude-code/2.1.100/claude", - "brew upgrade claude-code", + "brew upgrade --cask claude-code", "stable", ), ( "/opt/homebrew/Caskroom/claude-code@latest/2.1.110/claude", - "brew upgrade claude-code@latest", + "brew upgrade --cask claude-code@latest", "latest", ), ] { let path = std::path::Path::new(path); - assert!(!super::can_apply_update(HarnessId::ClaudeCode, path)); - assert_eq!( - super::claude_package_manager_command(path).as_deref(), - Some(command) - ); assert_eq!(super::claude_cask_channel(path).unwrap(), channel); + // Homebrew runs only on macOS and Linux. + if cfg!(unix) { + assert!(super::can_apply_update(HarnessId::ClaudeCode, path)); + assert_eq!( + super::claude_package_manager_command(path).as_deref(), + Some(command) + ); + } } assert!(!super::can_apply_update( HarnessId::ClaudeCode, @@ -3179,4 +3546,144 @@ esac ); coordinator.shutdown().await; } + + #[cfg(unix)] + #[test] + fn homebrew_layout_selects_cask_or_formula_upgrade() { + let codex = super::homebrew_package(std::path::Path::new( + "/opt/homebrew/Caskroom/codex/0.159.0/bin/codex", + )) + .unwrap(); + assert!(codex.cask); + assert_eq!(codex.token, "codex"); + assert_eq!( + codex.brew, + std::path::PathBuf::from("/opt/homebrew/bin/brew") + ); + assert_eq!(codex.upgrade_command(), "brew upgrade --cask codex"); + assert_eq!(codex.upgrade_args(), ["upgrade", "--cask", "codex"]); + assert!(super::can_apply_update( + HarnessId::Codex, + std::path::Path::new("/opt/homebrew/Caskroom/codex/0.159.0/bin/codex") + )); + + let devin = super::homebrew_package(std::path::Path::new( + "/opt/homebrew/Caskroom/devin-cli/3000.10.21/bin/devin", + )) + .unwrap(); + assert_eq!(devin.upgrade_command(), "brew upgrade --cask devin-cli"); + + let formula = super::homebrew_package(std::path::Path::new( + "/home/linuxbrew/.linuxbrew/Cellar/pi-coding-agent/1.2.3/bin/pi", + )) + .unwrap(); + assert!(!formula.cask); + assert_eq!(formula.token, "pi-coding-agent"); + assert_eq!( + formula.upgrade_command(), + "brew upgrade --formula pi-coding-agent" + ); + assert_eq!( + formula.upgrade_args(), + ["upgrade", "--formula", "pi-coding-agent"] + ); + assert_eq!( + formula.brew, + std::path::PathBuf::from("/home/linuxbrew/.linuxbrew/bin/brew") + ); + + assert!( + super::homebrew_package(std::path::Path::new( + "/opt/homebrew/lib/node_modules/@openai/codex/bin/codex" + )) + .is_none() + ); + assert!( + super::homebrew_package(std::path::Path::new( + "/opt/homebrew/Caskroom/../not-a-real-codex" + )) + .is_none() + ); + // npm globals of a keg-only Node live inside the runtime's keg. Brew + // must never upgrade node@20 on behalf of the CLI installed with it. + assert!( + super::homebrew_package(std::path::Path::new( + "/opt/homebrew/Cellar/node@20/20.18.0/lib/node_modules/@openai/codex/bin/codex.js" + )) + .is_none() + ); + assert!( + super::homebrew_package(std::path::Path::new( + "/usr/local/Cellar/python@3.12/3.12.7/Frameworks/Python.framework/Versions/3.12/bin/hermes" + )) + .is_none() + ); + // Formulae that vendor a Node package keep it under libexec. + let gemini = super::homebrew_package(std::path::Path::new( + "/usr/local/Cellar/gemini-cli/0.9.0/libexec/lib/node_modules/@google/gemini-cli/dist/index.js", + )) + .unwrap(); + assert_eq!( + gemini.upgrade_args(), + ["upgrade", "--formula", "gemini-cli"] + ); + assert_eq!(gemini.brew, std::path::PathBuf::from("/usr/local/bin/brew")); + for unversioned in [ + "/opt/homebrew/Caskroom/codex", + "/opt/homebrew/Cellar/codex/bin", + ] { + assert!(super::homebrew_package(std::path::Path::new(unversioned)).is_none()); + } + // A leading dash would be read as a brew option. + assert!( + super::homebrew_package(std::path::Path::new( + "/opt/homebrew/Caskroom/--force/1.0.0/codex" + )) + .is_none() + ); + // brew would load these as local package files. + for token in ["codex.rb", "codex.json"] { + assert!( + super::homebrew_package( + &std::path::Path::new("/opt/homebrew/Cellar") + .join(token) + .join("1.0.0/bin/codex") + ) + .is_none() + ); + } + assert!( + !super::can_apply_update( + HarnessId::Codex, + std::path::Path::new("/tmp/zeron-not-installed/codex") + ), + "an unclassified codex binary stays manual" + ); + + assert_eq!( + super::parse_homebrew_version(true, r#"{"version":"0.159.1"}"#).unwrap(), + "0.159.1" + ); + assert_eq!( + super::parse_homebrew_version(true, r#"{"casks":[{"version":"0.159.1"}]}"#).unwrap(), + "0.159.1" + ); + assert_eq!( + super::parse_homebrew_version(false, r#"{"versions":{"stable":"1.2.3"}}"#).unwrap(), + "1.2.3" + ); + assert_eq!( + super::parse_homebrew_version( + false, + r#"{"formulae":[{"versions":{"stable":"1.2.3"}}]}"# + ) + .unwrap(), + "1.2.3" + ); + assert!(super::parse_homebrew_version(true, r#"{"version":"latest"}"#).is_err()); + assert_eq!( + super::homebrew_url_component("claude-code@latest"), + "claude-code%40latest" + ); + } } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 2f4c74b653..4d47d3bc2c 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -22,6 +22,7 @@ pub mod chat2_host; mod chat_persistence; pub mod diff_sync; pub mod doc_host; +mod fs_watch; pub mod harness_updates; mod http_error; pub mod instance_lock; @@ -40,6 +41,7 @@ pub mod terminals; pub mod titles; mod transcript_history; pub mod uploads; +pub mod voice; pub mod workspace_files; pub mod workspace_host; @@ -47,7 +49,7 @@ pub use agent_accounts::{AgentAccounts, AgentAccountsConfig}; pub use auth::{Auth, AuthConfig, AuthState, AuthUser, OrgMembership}; pub use change_requests::{ChangeRequestCacheKey, CheckoutChangeRequests}; pub use diff_sync::{ - CheckoutDiffSync, DiffFileTextPair, DiffSidecar, DiffSnapshot, TurnSnapshot, + CheckoutDiffSync, DiffFileTextPair, DiffSidecar, DiffSnapshot, TreeSnapshot, TurnSnapshot, capture_commit_diff, capture_diff, capture_diff_against, capture_turn_diff, discard_working_tree, merge_base, read_diff_file_text, snapshot_tree, working_diff_base, }; @@ -241,6 +243,7 @@ impl EngineCore { org_id: profile.org_id().to_string(), user_id: profile.user_id().to_string(), edge: edge.clone(), + local_only: matches!(profile.scope(), WorkspaceScope::Local), }, )?; doc_host.set_workspace(workspace.clone()); diff --git a/crates/engine/src/repos.rs b/crates/engine/src/repos.rs index 9bb2edf821..b4df7c7a79 100644 --- a/crates/engine/src/repos.rs +++ b/crates/engine/src/repos.rs @@ -138,6 +138,10 @@ struct ReposInner { github_avatars: std::sync::Mutex>, github_avatar_pages: std::sync::Mutex>, file_index: FileIndexCache, + /// HEAD commit → its trunk's root commit. History behind a commit never + /// changes, so the full first-parent walk runs once per HEAD, not on + /// every spaces repair pass. + trunk_roots: std::sync::Mutex>, } struct IndexedPath { @@ -183,6 +187,7 @@ impl Repos { .build() .unwrap_or_else(|_| reqwest::Client::new()), github_avatars: std::sync::Mutex::new(HashMap::new()), + trunk_roots: std::sync::Mutex::new(HashMap::new()), github_avatar_pages: std::sync::Mutex::new(HashSet::new()), file_index: std::sync::Mutex::new(HashMap::new()), }), @@ -253,6 +258,40 @@ impl Repos { Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) } + /// Like [`Self::git`], for commands that answer "no" with a silent exit + /// status 1 (`rev-parse --verify --quiet`): `Ok(None)` then, `Err` for + /// any other failure. + async fn git_probe(&self, args: &[&str], cwd: Option<&Path>) -> Result, EngineError> { + let mut cmd = tokio::process::Command::new("git"); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + cmd.as_std_mut().creation_flags(0x08000000); + } + cmd.args(args); + if let Some(cwd) = cwd { + cmd.current_dir(cwd); + } + cmd.stdin(std::process::Stdio::null()); + let output = cmd + .output() + .await + .map_err(|e| EngineError::Other(format!("git spawn failed: {e}")))?; + if output.status.success() { + return Ok(Some(String::from_utf8_lossy(&output.stdout).trim().to_string())); + } + let stderr = String::from_utf8_lossy(&output.stderr); + if output.status.code() == Some(1) && stderr.trim().is_empty() { + return Ok(None); + } + Err(EngineError::Other(format!( + "git {} failed ({}): {}", + args.first().unwrap_or(&"?"), + output.status, + stderr.trim() + ))) + } + /// Async existence probe with a timeout: a wedged network mount just reads /// as "gone" instead of hanging every caller. async fn path_exists(path: &Path) -> bool { @@ -338,6 +377,123 @@ impl Repos { }) } + /// Identity shared by every clone and worktree of one repository: the + /// root commit its trunk grew from, `commit:`. Unlike a remote URL it + /// survives repository renames and transfers, SSH host aliases and + /// transport spellings, and it needs no network. Forks share it with + /// their upstream (the same codebase). + /// + /// A history that cannot vouch for its root — a shallow clone, or no + /// commits yet — falls back to the normalized `origin` remote (else the + /// first remote) as `host/owner/repo`, then to + /// `local:sha256(deviceId ‖ NUL ‖ canonical common git dir)`, so the + /// repository's worktrees on this device still match each other. + /// + /// A folder below the repository's top level carries its in-repository + /// path (`:apps/web`): sibling folders of one monorepo, or + /// plain folders inside an enclosing repository, stay separate projects, + /// while the same subfolder in another clone or worktree still matches. + /// + /// Errors are real failures (git missing, a lock, I/O) — never a silent + /// fallback to a different identity form, which would regroup the + /// sidebar on a transient hiccup. Callers keep their last value. + pub async fn repository_identity(&self, path: &Path) -> Result { + let base = match self.trunk_root_commit(path).await? { + Some(root) => format!("commit:{root}"), + None => self.remote_or_local_identity(path).await?, + }; + let prefix = self.git(&["rev-parse", "--show-prefix"], Some(path)).await?; + let prefix = prefix.trim_end_matches('/'); + Ok(if prefix.is_empty() { + base + } else { + format!("{base}:{prefix}") + }) + } + + /// The normalized remote, else the device-scoped common-git-dir hash. + async fn remote_or_local_identity(&self, path: &Path) -> Result { + let remote = match self.git(&["remote", "get-url", "origin"], Some(path)).await { + Ok(url) => Some(url), + Err(_) => match self.git(&["remote"], Some(path)).await { + Ok(names) => match names.lines().next() { + Some(name) => self + .git(&["remote", "get-url", name.trim()], Some(path)) + .await + .ok(), + None => None, + }, + Err(_) => None, + }, + }; + if let Some(remote) = remote + .as_deref() + .and_then(crate::source_control::parse_git_remote) + { + return Ok( + format!("{}/{}/{}", remote.host, remote.owner, remote.repository) + .to_ascii_lowercase(), + ); + } + let common_dir = self + .git( + &["rev-parse", "--path-format=absolute", "--git-common-dir"], + Some(path), + ) + .await?; + let canonical = + std::fs::canonicalize(&common_dir).unwrap_or_else(|_| PathBuf::from(&common_dir)); + let mut hasher = Sha256::new(); + hasher.update(self.inner.device_id.as_bytes()); + hasher.update([0u8]); + hasher.update(canonical.to_string_lossy().as_bytes()); + Ok(format!("local:{}", hex(&hasher.finalize()))) + } + + /// The root of HEAD's first-parent chain — the trunk's first commit. A + /// merged-in unrelated history arrives as a second parent, so its roots + /// never displace this one. `Ok(None)` when the history can't vouch for + /// a root: a shallow clone (its boundary commits only look parentless, + /// and differ with the fetch depth) or an unborn HEAD. + async fn trunk_root_commit(&self, path: &Path) -> Result, EngineError> { + let shallow = self + .git(&["rev-parse", "--is-shallow-repository"], Some(path)) + .await?; + if shallow == "true" { + return Ok(None); + } + let Some(head) = self + .git_probe(&["rev-parse", "--verify", "--quiet", "HEAD^{commit}"], Some(path)) + .await? + else { + return Ok(None); // unborn HEAD: no commits yet + }; + if let Some(root) = self.inner.trunk_roots.lock().ok().and_then(|cache| cache.get(&head).cloned()) { + return Ok(Some(root)); + } + let roots = self + .git( + &["rev-list", "--first-parent", "--max-parents=0", &head], + Some(path), + ) + .await?; + let Some(root) = roots + .lines() + .map(str::trim) + .find(|sha| !sha.is_empty()) + .map(str::to_owned) + else { + return Ok(None); + }; + if let Ok(mut cache) = self.inner.trunk_roots.lock() { + if cache.len() >= 4096 { + cache.clear(); + } + cache.insert(head, root.clone()); + } + Ok(Some(root)) + } + async fn to_repo(&self, path: &Path) -> Result { let branch = self.current_branch(path).await.ok(); Ok(Repo { diff --git a/crates/engine/src/rpc.rs b/crates/engine/src/rpc.rs index 2ca1fce168..48c9150565 100644 --- a/crates/engine/src/rpc.rs +++ b/crates/engine/src/rpc.rs @@ -50,6 +50,8 @@ //! handlers stay transport-agnostic. This includes the workspace file surface, //! whose checkout always lives on the routed target device. +mod voice_chat; +mod voice_remote; use async_trait::async_trait; use base64::Engine as _; use futures::StreamExt; @@ -607,7 +609,9 @@ enum MutateParams { }, } +#[derive(Clone)] pub struct EngineRpc { + voice: crate::voice::VoiceManager, sessions: SessionsEngine, doc_host: DocHost, workspace: WorkspaceHost, @@ -653,6 +657,7 @@ impl EngineRpc { capabilities: zeron_proto::capabilities::current(), }; Self { + voice: sessions.voice_manager(), sessions, doc_host, workspace, @@ -1028,7 +1033,8 @@ impl EngineRpc { // only unary calls below get the reply deadline. if matches!( method, - methods::WATCH_CHECKOUT_CHANGE_REQUEST + methods::OWN_VOICE_V2 + | methods::WATCH_CHECKOUT_CHANGE_REQUEST | methods::WATCH_WORKSPACE_GIT_STATUS | methods::WATCH_HARNESS_UPDATES ) { @@ -1088,6 +1094,42 @@ impl EngineRpc { } } + async fn prepare_voice_bridge( + &self, + chat: &str, + ) -> Result< + ( + zeron_harness::codex::realtime::RealtimeHandle, + tokio::sync::broadcast::Receiver, + std::sync::Arc, + ), + zeron_proto::voice::VoiceRejection, + > { + use zeron_proto::voice::VoiceRejection; + // Without Codex here a retry can never connect: ask for an install. + if !self + .registry + .resolve(HarnessId::Codex) + .is_ok_and(|codex| codex.installed()) + { + return Err(VoiceRejection::NativeRuntimeUnavailable); + } + let mut request = self + .doc_host + .request_from_chat_row(chat, "") + .ok_or(VoiceRejection::Unsupported)?; + if request.cwd.is_empty() { + request.cwd = "~".into(); + } + self.sessions + .start_idle(chat, request) + .await + .map_err(|_| VoiceRejection::Unsupported)?; + self.sessions + .take_voice_bridge(chat) + .ok_or(VoiceRejection::Unsupported) + } + fn mutate(&self, params: MutateParams) -> Result<(), RpcError> { let failed = |e: crate::EngineError| RpcError::Failed(e.to_string()); match params { @@ -1100,6 +1142,10 @@ impl EngineRpc { cwd, parent_chat_id, } => { + // A voice orchestrator is hidden, so the chats it creates are + // the user's top-level sessions rather than its side chats. + let parent_chat_id = parent_chat_id + .filter(|parent| !zeron_proto::voice::is_orchestrator_chat(parent)); self.workspace .create_chat_with_parent( &chat_id, @@ -1142,8 +1188,8 @@ impl EngineRpc { let chat_ids = deleted.chat_ids; tokio::spawn(async move { for chat_id in chat_ids { - if let Err(err) = sessions.interrupt(&chat_id).await { - tracing::debug!(chat = %chat_id, error = %err, "deleteSpace interrupt skipped"); + if let Err(err) = sessions.terminate(&chat_id).await { + tracing::debug!(chat = %chat_id, error = %err, "deleteSpace teardown skipped"); } doc_host.purge_chat(&chat_id); } @@ -1160,11 +1206,13 @@ impl EngineRpc { .set_chat_branch(&chat_id, &branch) .map_err(failed) .map(drop), - MutateParams::SetChatCwd { chat_id, cwd } => self - .workspace - .set_chat_cwd(&chat_id, &cwd) - .map_err(failed) - .map(drop), + MutateParams::SetChatCwd { chat_id, cwd } => { + self.voice.retire_chat(&chat_id); + self.workspace + .set_chat_cwd(&chat_id, &cwd) + .map_err(failed) + .map(drop) + } MutateParams::SetChatActivity { chat_id, last_message_at, @@ -1174,11 +1222,13 @@ impl EngineRpc { .set_chat_activity(&chat_id, last_message_at, created_at) .map_err(failed) .map(drop), - MutateParams::SetChatHost { chat_id, device_id } => self - .workspace - .set_chat_host(&chat_id, &device_id) - .map_err(failed) - .map(drop), + MutateParams::SetChatHost { chat_id, device_id } => { + self.voice.retire_chat(&chat_id); + self.workspace + .set_chat_host(&chat_id, &device_id) + .map_err(failed) + .map(drop) + } MutateParams::SetChatArchived { chat_id, archived } => self .workspace .set_chat_archived(&chat_id, archived) @@ -1187,12 +1237,15 @@ impl EngineRpc { MutateParams::ChangeSidebarPin { change } => { self.workspace.change_sidebar_pin(&change).map_err(failed) } - MutateParams::SetChatConfig { chat_id, config } => self - .workspace - .set_chat_config(&chat_id, &config) - .map_err(failed) - .map(drop), + MutateParams::SetChatConfig { chat_id, config } => { + self.voice.retire_chat(&chat_id); + self.workspace + .set_chat_config(&chat_id, &config) + .map_err(failed) + .map(drop) + } MutateParams::DeleteChat { chat_id } => { + self.voice.retire_chat(&chat_id); self.workspace.delete_chat(&chat_id).map_err(failed)?; self.doc_host.purge_chat(&chat_id); Ok(()) @@ -1307,6 +1360,12 @@ where } fn forward_deadline(method: &str) -> std::time::Duration { + if method == methods::PREPARE_VOICE_V2 { + return Duration::from_secs(65); + } + if method == methods::NEGOTIATE_VOICE_V2 { + return Duration::from_secs(95); + } use std::time::Duration; match method { methods::CLONE_REPO | methods::FETCH_ALL | methods::APPLY_UPDATE => { @@ -1334,6 +1393,9 @@ const LOGIN_TUNNEL_TTL: Duration = Duration::from_secs(15 * 60); /// list (plus [`is_stream_method`] for streams) to make more of the surface /// device-addressable — the handlers themselves need no changes. fn forwardable(method: &str) -> bool { + if voice_remote::handles(method) { + return true; + } matches!( method, methods::FORK_SIDE_CHAT @@ -1380,6 +1442,8 @@ fn forwardable(method: &str) -> bool { | methods::SEARCH_WORKSPACE_FILES | methods::READ_WORKSPACE_IMAGE | methods::READ_WORKSPACE_FILE + | methods::DELETE_WORKSPACE_ENTRY + | methods::MOVE_WORKSPACE_ENTRY | methods::WRITE_WORKSPACE_FILE | methods::WATCH_WORKSPACE_FILES | methods::CREATE_WORKTREE @@ -1430,6 +1494,9 @@ fn forwardable(method: &str) -> bool { /// Forwardable methods whose reply is a stream (proxied item-by-item). fn is_stream_method(method: &str) -> bool { + if method == methods::OWN_VOICE_V2 { + return true; + } matches!( method, methods::WATCH_DOC_MESSAGES @@ -1700,6 +1767,19 @@ impl RpcService for EngineRpc { } return self.forward(&target, method, params).await; } + if matches!( + method, + methods::SIGN_OUT + | methods::SELECT_ORG + | methods::COMPLETE_AGENT_LOGIN + | methods::ACTIVATE_AGENT_ACCOUNT + | methods::FORGET_AGENT_ACCOUNT + ) { + self.voice.retire(); + } + if voice_remote::handles(method) { + return self.voice_remote(method, params).await; + } if AuthRpc::handles(method) { return AuthRpc::new(self.auth()?.clone()) .handle(method, params) @@ -1881,7 +1961,11 @@ impl RpcService for EngineRpc { "Fork must be created on the source device".into(), )); } - if let Some(existing) = self.workspace.chat(&p.chat_id).map_err(failed)? { + if let Some(existing) = self + .workspace + .chat(&p.chat_id) + .map_err(|e| RpcError::Failed(e.to_string()))? + { if existing.parent_chat_id.as_deref() == Some(parent_chat_id.as_str()) { return RpcReply::value(&existing); } @@ -1985,19 +2069,27 @@ impl RpcService for EngineRpc { .and_then(|v| v.as_bool()) .unwrap_or(false); let p: ChatParams = parse_params(params)?; - if opening_tail { - return Ok(RpcReply::Stream( - opening_doc_messages_stream(self.doc_host.clone(), p.chat_id).await?, - )); - } - let handle = self - .doc_host - .open(&p.chat_id) - .map_err(|e| RpcError::Failed(e.to_string()))?; - Ok(RpcReply::Stream(doc_messages_stream( - handle.watch_messages(), - handle.doc_arc(), - ))) + // An open transcript keeps its chat's checkout diff live (and + // discardable) even when the chat is archived; the guard lives + // exactly as long as the stream. + let interest = self.diff_sync.retain_chat(&p.chat_id); + let stream = if opening_tail { + opening_doc_messages_stream(self.doc_host.clone(), p.chat_id).await? + } else { + let handle = self + .doc_host + .open(&p.chat_id) + .map_err(|e| RpcError::Failed(e.to_string()))?; + doc_messages_stream(handle.watch_messages(), handle.doc_arc()) + }; + Ok(RpcReply::Stream( + stream + .map(move |frame| { + let _held = &interest; + frame + }) + .boxed(), + )) } methods::WATCH_QUEUE => { let p: ChatParams = parse_params(params)?; @@ -2594,7 +2686,7 @@ impl RpcService for EngineRpc { let snapshot = self .diff_sync - .discard_working_tree(&identity.id, &p.expected_checksum) + .discard_working_tree(&identity, &p.expected_checksum) .await .map_err(|e| RpcError::Failed(e.to_string()))?; RpcReply::value(&serde_json::json!({ @@ -2618,6 +2710,9 @@ impl RpcService for EngineRpc { return Err(RpcError::Failed("checkoutId does not match cwd".into())); } let root = identity.root.as_path(); + // A turn's base tree lives in the snapshot's own object + // store, so the snapshot rides along with its sha. + let mut turn_snapshot = None; let (snapshot, base, target) = match p.mode.as_str() { "branch" => { let base_ref = p @@ -2669,7 +2764,9 @@ impl RpcService for EngineRpc { )) .await .map_err(|error| RpcError::Failed(error.to_string()))?; - (snapshot, turn.tree, None) + let base = turn.tree.tree().to_string(); + turn_snapshot = Some(turn.tree); + (snapshot, base, None) } _ => { let base = Box::pin(crate::diff_sync::working_diff_base(root)) @@ -2705,6 +2802,7 @@ impl RpcService for EngineRpc { let pair = Box::pin(crate::diff_sync::read_diff_file_text_at( root, &base, + turn_snapshot.as_ref(), target.as_deref(), file, )) @@ -2719,14 +2817,17 @@ impl RpcService for EngineRpc { )) .await } - "turn" => { - Box::pin(crate::diff_sync::capture_turn_diff( - &self.repos, - root, - &base, - )) - .await - } + "turn" => match turn_snapshot.as_ref() { + Some(turn) => { + Box::pin(crate::diff_sync::capture_turn_diff( + &self.repos, + root, + turn, + )) + .await + } + None => Err(crate::EngineError::Other("no turn recorded".into())), + }, "commit" => { let sha = p .commit_sha @@ -3007,6 +3108,24 @@ impl RpcService for EngineRpc { .map_err(RpcError::from)?; RpcReply::value(&file) } + methods::DELETE_WORKSPACE_ENTRY => { + let request: zeron_proto::DeleteWorkspaceEntryRequest = parse_params(params)?; + let outcome = self + .workspace_files + .delete_entry(request) + .await + .map_err(RpcError::from)?; + RpcReply::value(&outcome) + } + methods::MOVE_WORKSPACE_ENTRY => { + let request: zeron_proto::MoveWorkspaceEntryRequest = parse_params(params)?; + let outcome = self + .workspace_files + .move_entry(request) + .await + .map_err(RpcError::from)?; + RpcReply::value(&outcome) + } methods::WRITE_WORKSPACE_FILE => { let request: zeron_proto::WriteWorkspaceFileRequest = parse_params(params)?; let outcome = tokio::time::timeout( @@ -3273,6 +3392,7 @@ impl RpcService for EngineRpc { RpcReply::value(&snapshot) } methods::ACTIVATE_AGENT_ACCOUNT => { + self.voice.retire(); let p: AgentAccountParams = parse_params(params)?; let snapshot = self .agent_accounts @@ -3282,6 +3402,7 @@ impl RpcService for EngineRpc { RpcReply::value(&snapshot) } methods::FORGET_AGENT_ACCOUNT => { + self.voice.retire(); let p: AgentAccountParams = parse_params(params)?; let snapshot = self .agent_accounts @@ -3359,10 +3480,13 @@ impl RpcService for EngineRpc { .filter_map(|chat| chat.cwd) .map(std::path::PathBuf::from) .collect(); - let chunk = self - .uploads - .read_chunk(&p.path, p.offset, &roots) - .map_err(|e| RpcError::Failed(e.to_string()))?; + let uploads = self.uploads.clone(); + let chunk = tokio::task::spawn_blocking(move || { + uploads.read_chunk(&p.path, p.offset, &roots) + }) + .await + .map_err(|e| RpcError::Failed(e.to_string()))? + .map_err(|e| RpcError::Failed(e.to_string()))?; RpcReply::value(&chunk) } methods::FETCH_TOOL_BLOB => { @@ -3383,6 +3507,99 @@ impl RpcService for EngineRpc { mod tests { use super::*; + #[tokio::test] + async fn voice_owner_is_shared_across_services_and_shutdown_retires_it() { + let temp = tempfile::tempdir().unwrap(); + let core = crate::EngineCore::assemble_with_profile( + crate::EngineProfile::local(temp.path()).unwrap(), + std::sync::Arc::new(HarnessRegistry::new()), + HarnessId::Codex, + None, + ) + .unwrap(); + let first = core.rpc_service(); + let second = core.rpc_service(); + let lease = first.voice.reserve("chat").unwrap(); + assert!(second.voice.owns_chat("chat")); + assert_eq!( + second.voice.reserve("other").unwrap_err(), + zeron_proto::voice::VoiceRejection::Busy + ); + let owner = second.voice.own(lease).unwrap(); + core.sessions.shutdown().await; + assert!(!first.voice.owns_chat("chat")); + drop(owner); + } + + #[tokio::test] + async fn chats_created_by_a_voice_orchestrator_are_top_level() { + let temp = tempfile::tempdir().unwrap(); + let core = crate::EngineCore::assemble_with_profile( + crate::EngineProfile::local(temp.path()).unwrap(), + std::sync::Arc::new(HarnessRegistry::new()), + HarnessId::Codex, + None, + ) + .unwrap(); + let rpc = core.rpc_service(); + let orchestrator = format!("{}1", zeron_proto::voice::ORCHESTRATOR_CHAT_PREFIX); + for (chat, parent, expected) in [ + ("worker", orchestrator.as_str(), None), + ("side", "ordinary", Some("ordinary")), + ] { + rpc.handle( + methods::MUTATE, + serde_json::json!({"op":"createChat", "chatId":chat, + "parentChatId":parent, "deviceId":core.device_id, + "config":{"harness":"codex","sandbox":"workspace-write"}}), + ) + .await + .unwrap(); + let row = core.workspace.chat(chat).unwrap().unwrap(); + assert_eq!(row.parent_chat_id.as_deref(), expected); + } + core.sessions.shutdown().await; + } + + #[tokio::test] + async fn voice_origin_uses_normal_remote_routing() { + let temp = tempfile::tempdir().unwrap(); + let core = crate::EngineCore::assemble_with_profile( + crate::EngineProfile::local(temp.path()).unwrap(), + std::sync::Arc::new(HarnessRegistry::new()), + HarnessId::Codex, + None, + ) + .unwrap(); + let rpc = core.rpc_service(); + let lease = rpc.voice.reserve("voice-origin").unwrap(); + let owner = rpc.voice.own(lease).unwrap(); + for method in [methods::QUEUE_COMMAND, methods::QUEUE_MESSAGE] { + let error = match rpc.handle(method, serde_json::json!({"originChatId":"voice-origin","targetDeviceId":"remote","chatId":"other"})).await { + Err(error) => error, + Ok(_) => panic!("offline remote routing unexpectedly succeeded"), + }; + assert!( + matches!(error, RpcError::Failed(ref message) if message == "cannot reach device remote: remote routing unavailable (offline)") + ); + } + // Voice origin follows the same forwarding boundary as ordinary MCP + // calls; this fixture has no relay configured. + rpc.handle( + methods::MUTATE, + serde_json::json!({"op":"createChat", "chatId":"remote-grok", + "originChatId":"voice-origin", "deviceId":"remote", + "config":{"harness":"grok","sandbox":"workspace-write"}}), + ) + .await + .unwrap(); + let chat = core.workspace.chat("remote-grok").unwrap().unwrap(); + assert_eq!(chat.device_id, "remote"); + assert_eq!(chat.config.unwrap().harness, HarnessId::Grok); + drop(owner); + core.sessions.shutdown().await; + } + // Each subprocess has private HOME/PATH/overrides, avoiding process-global test races. #[cfg(unix)] async fn installer_rpc_fixture(mode: &str) { @@ -3807,6 +4024,8 @@ mod tests { assert!(forwardable(methods::SEARCH_WORKSPACE_FILES)); assert!(forwardable(methods::READ_WORKSPACE_FILE)); assert!(forwardable(methods::READ_WORKSPACE_IMAGE)); + assert!(forwardable(methods::DELETE_WORKSPACE_ENTRY)); + assert!(forwardable(methods::MOVE_WORKSPACE_ENTRY)); assert!(forwardable(methods::WRITE_WORKSPACE_FILE)); assert!(forwardable(methods::WATCH_WORKSPACE_FILES)); assert!(forwardable(methods::WATCH_WORKSPACE_GIT_STATUS)); diff --git a/crates/engine/src/rpc/voice_chat.rs b/crates/engine/src/rpc/voice_chat.rs new file mode 100644 index 0000000000..401253711c --- /dev/null +++ b/crates/engine/src/rpc/voice_chat.rs @@ -0,0 +1,211 @@ +//! Each host keeps one voice orchestrator chat: every call on this device +//! resumes it, so the orchestrator remembers earlier calls. Hosts never share +//! it — another computer has its own. Once the synced transcript passes +//! [`ROTATE_AFTER`] entries, the next call moves to a fresh chat that inherits +//! the Codex thread: memory continues, the transcript starts small again. The +//! old segment is removed only once its successor holds a message. +use super::*; +use zeron_proto::voice::{ORCHESTRATOR_CHAT_PREFIX, ORCHESTRATOR_CHAT_TITLE, VoiceRejection}; +use zeron_proto::{Chat, SessionStatus}; + +/// Transcript entries after which the next call starts a new segment. +pub(super) const ROTATE_AFTER: usize = 1000; + +/// This device's current orchestrator chat: its newest one, if any. +fn current<'a>(chats: &'a [Chat], device: &str) -> Option<&'a Chat> { + chats + .iter() + .filter(|c| { + zeron_proto::voice::is_orchestrator_chat(&c.id) && c.device_id == device && !c.archived + }) + .max_by_key(|c| c.created_at) +} + +impl EngineRpc { + /// The chat a call on this device runs in: reused, rotated or created. + /// Callers hold the voice preparation lock and have checked that no call + /// is live, so nothing is rotated from under one. + pub(super) async fn voice_chat(&self, config: ChatConfig) -> Result { + let device = self.engine_info.device_id.clone(); + let chats = self + .workspace + .read_chats() + .map_err(|_| VoiceRejection::Protocol)?; + let chat = current(&chats, &device).cloned(); + let Some(chat) = chat else { + return self.new_voice_chat(config, None); + }; + // A question left over from an ended call would block this one. + if self + .sessions + .session_status(&chat.id) + .is_some_and(|s| s.status == SessionStatus::AwaitingInput) + { + let _ = self.sessions.interrupt(&chat.id).await; + } + // Delegated work still running keeps its chat and configuration. + if self.sessions.turn_in_flight(&chat.id) { + return Ok(chat.id); + } + let entries = self + .doc_host + .open(&chat.id) + .map_err(|_| VoiceRejection::Protocol)? + .doc() + .read_entries() + .map_err(|_| VoiceRejection::Protocol)? + .len(); + if entries > 0 { + self.prune_superseded(&chats, &chat).await; + } + if entries < ROTATE_AFTER { + if chat.config.as_ref() != Some(&config) { + // A warm process cannot take another configuration. + let _ = self.sessions.interrupt(&chat.id).await; + self.workspace + .set_chat_config(&chat.id, &config) + .map_err(|_| VoiceRejection::Protocol)?; + } + return Ok(chat.id); + } + // The old segment's warm process holds the Codex thread; stop it before + // the next segment resumes the same thread. + let _ = self.sessions.interrupt(&chat.id).await; + let thread = self + .workspace + .chat_harness_session(&chat.id) + .filter(|(id, _)| !id.is_empty()); + // The old segment stays until the new one holds a message: a failed + // start may remove the empty successor, and then the next call + // rotates again from here instead of losing the thread. + self.new_voice_chat(config, thread) + } + + /// Removes this device's earlier segments of `current`'s Codex thread. + /// Orchestrators from other threads (calls before segments) are kept. + async fn prune_superseded(&self, chats: &[Chat], current: &Chat) { + let Some(thread) = current + .harness_session_id + .as_deref() + .filter(|t| !t.is_empty()) + else { + return; + }; + for old in chats.iter().filter(|c| { + c.id != current.id + && c.device_id == current.device_id + && zeron_proto::voice::is_orchestrator_chat(&c.id) + && c.harness_session_id.as_deref() == Some(thread) + }) { + if self.sessions.turn_in_flight(&old.id) { + continue; + } + let _ = self.sessions.interrupt(&old.id).await; + let _ = self.workspace.delete_chat(&old.id); + self.doc_host.purge_chat(&old.id); + } + } + + fn new_voice_chat( + &self, + config: ChatConfig, + thread: Option<(String, Option)>, + ) -> Result { + let chat = format!("{ORCHESTRATOR_CHAT_PREFIX}{}", uuid::Uuid::new_v4()); + // No space: a projectless chat whose cwd is the host's `~`. + self.workspace + .create_chat( + &chat, + None, + Some(&self.engine_info.device_id), + Some(config), + None, + ) + .map_err(|_| VoiceRejection::Protocol)?; + // Same title on every host: the transcript is reachable from every + // client's voice controls, never an untitled "New session". + self.workspace + .rename_chat(&chat, ORCHESTRATOR_CHAT_TITLE) + .map_err(|_| VoiceRejection::Protocol)?; + if let Some((id, cwd)) = thread { + self.workspace + .set_chat_harness_session(&chat, &id, cwd.as_deref().unwrap_or("")); + } + Ok(chat) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn config(model: &str) -> ChatConfig { + serde_json::from_value(serde_json::json!({ + "harness": "codex", "model": model, "sandbox": "workspace-write" + })) + .unwrap() + } + + #[tokio::test] + async fn calls_reuse_the_hosts_chat_and_rotate_a_long_transcript_keeping_the_thread() { + let temp = tempfile::tempdir().unwrap(); + let core = crate::EngineCore::assemble_with_profile( + crate::EngineProfile::local(temp.path()).unwrap(), + std::sync::Arc::new(HarnessRegistry::new()), + HarnessId::Codex, + None, + ) + .unwrap(); + let rpc = core.rpc_service(); + // Another host's orchestrator is never this device's. + let foreign = format!("{ORCHESTRATOR_CHAT_PREFIX}foreign"); + core.workspace + .create_chat(&foreign, None, Some("other-device"), None, None) + .unwrap(); + + let first = rpc.voice_chat(config("a")).await.unwrap(); + assert_ne!(first, foreign); + let row = core.workspace.chat(&first).unwrap().unwrap(); + assert_eq!(row.title.as_deref(), Some(ORCHESTRATOR_CHAT_TITLE)); + assert_eq!(row.device_id, core.device_id); + + // The next call resumes it, taking the configuration it asks for. + assert_eq!(rpc.voice_chat(config("b")).await.unwrap(), first); + let row = core.workspace.chat(&first).unwrap().unwrap(); + assert_eq!(row.config, Some(config("b"))); + + core.workspace + .set_chat_harness_session(&first, "codex-thread", "/home/me"); + let doc = core.doc_host.open(&first).unwrap(); + for i in 0..ROTATE_AFTER { + doc.write_user_message(&format!("m{i}"), "hi", i as i64) + .unwrap(); + } + let inherited = Some(("codex-thread".into(), Some("/home/me".into()))); + let next = rpc.voice_chat(config("b")).await.unwrap(); + assert_ne!(next, first); + assert_eq!(core.workspace.chat_harness_session(&next), inherited); + + // A failed start removes its empty successor; the old segment is + // still there, so the next call rotates again on the same thread. + core.workspace.delete_chat(&next).unwrap(); + core.doc_host.purge_chat(&next); + let retry = rpc.voice_chat(config("b")).await.unwrap(); + assert!(retry != first && retry != next); + assert_eq!(core.workspace.chat_harness_session(&retry), inherited); + + // An empty successor keeps its predecessor; one with a message + // supersedes it. Other orchestrators and threads are never touched. + assert_eq!(rpc.voice_chat(config("b")).await.unwrap(), retry); + assert!(core.workspace.chat(&first).unwrap().is_some()); + core.doc_host + .open(&retry) + .unwrap() + .write_user_message("hello", "hi", 0) + .unwrap(); + assert_eq!(rpc.voice_chat(config("b")).await.unwrap(), retry); + assert!(core.workspace.chat(&first).unwrap().is_none()); + assert!(core.workspace.chat(&foreign).unwrap().is_some()); + core.sessions.shutdown().await; + } +} diff --git a/crates/engine/src/rpc/voice_remote.rs b/crates/engine/src/rpc/voice_remote.rs new file mode 100644 index 0000000000..3e50da890c --- /dev/null +++ b/crates/engine/src/rpc/voice_remote.rs @@ -0,0 +1,189 @@ +use super::*; +use zeron_proto::voice::{VoiceRejection, remote as wire}; + +pub(super) fn handles(method: &str) -> bool { + matches!( + method, + methods::VOICE_CAPABILITIES_V2 + | methods::PREPARE_VOICE_V2 + | methods::OWN_VOICE_V2 + | methods::NEGOTIATE_VOICE_V2 + | methods::CONFIRM_VOICE_MEDIA_V2 + | methods::REPORT_VOICE_MEDIA_V2 + | methods::STOP_VOICE_V2 + | methods::CANCEL_VOICE_ATTEMPT_V2 + ) +} +fn failure(reason: VoiceRejection) -> RpcError { + RpcError::Failed(format!("voice unavailable: {reason:?}")) +} + +impl EngineRpc { + pub(super) async fn voice_remote( + &self, + method: &str, + params: serde_json::Value, + ) -> Result { + let envelope: wire::Envelope = parse_params(params)?; + if envelope.target_device_id != self.engine_info.device_id { + return Err(failure(VoiceRejection::RemoteHost)); + } + let p = envelope.payload; + match method { + methods::VOICE_CAPABILITIES_V2 => RpcReply::value(&wire::Capabilities { + protocol: wire::CAPABILITY.into(), + client_webrtc: true, + voices: vec![], + }), + methods::PREPARE_VOICE_V2 => { + let request: wire::Prepare = parse_params(p)?; + if request.config.harness != HarnessId::Codex { + return Err(failure(VoiceRejection::WrongHarness)); + } + if request.voice.as_ref().is_some_and(|v| v.len() > 128) { + return Err(failure(VoiceRejection::Protocol)); + } + let (mut rx, start) = self.voice.begin_attempt(&request).map_err(failure)?; + if let Some(cancel) = start { + let engine = self.clone(); + tokio::spawn(async move { + let key = request.attempt_key.clone(); + let mut chat = None; + let result = tokio::select! {biased; + _=cancel.cancelled()=>Err(VoiceRejection::InvalidLease), + r=tokio::time::timeout(Duration::from_secs(60),engine.prepare_remote_voice(request,&mut chat,cancel.clone()))=>r.unwrap_or(Err(VoiceRejection::Protocol)), + }; + if let Some(chat) = chat.filter(|chat| { + result.is_err() && engine.workspace.chat(chat).ok().flatten().is_some() + }) { + engine.voice.retire_chat(&chat); + // The chat outlives calls: delegated work it still + // runs survives this failed start. + if !engine.sessions.turn_in_flight(&chat) { + let _ = engine.sessions.interrupt(&chat).await; + } + // Preserve any actual activity; remove only this empty preparation. + if engine + .doc_host + .open(&chat) + .ok() + .is_some_and(|d| d.watch_messages().borrow().entries.is_empty()) + { + let _ = engine.workspace.delete_chat(&chat); + } + } + engine.voice.complete_attempt(&key, result); + }); + } + loop { + if let Some(result) = rx.borrow().clone() { + let prepared = result.map_err(failure)?; + self.voice + .validate_remote(&prepared.lease) + .map_err(failure)?; + return RpcReply::value(&prepared); + } + rx.changed() + .await + .map_err(|_| failure(VoiceRejection::Protocol))?; + } + } + methods::OWN_VOICE_V2 => { + let lease: wire::Lease = parse_params(p)?; + let owner = self.voice.own_remote(lease).map_err(failure)?; + Ok(RpcReply::Stream( + futures::stream::unfold(owner, |mut owner| async move { + owner + .next() + .await + .map(|e| (serde_json::to_value(e).unwrap(), owner)) + }) + .boxed(), + )) + } + methods::NEGOTIATE_VOICE_V2 => RpcReply::value( + &self + .voice + .negotiate_remote(parse_params(p)?) + .await + .map_err(failure)?, + ), + methods::CONFIRM_VOICE_MEDIA_V2 => RpcReply::value( + &self + .voice + .confirm_remote(&parse_params(p)?) + .map_err(failure)?, + ), + methods::REPORT_VOICE_MEDIA_V2 => RpcReply::value( + &self + .voice + .report_remote(&parse_params(p)?) + .map_err(failure)?, + ), + methods::STOP_VOICE_V2 => { + self.voice.stop_remote(&parse_params(p)?).map_err(failure)?; + RpcReply::value(&serde_json::json!({"ok":true})) + } + methods::CANCEL_VOICE_ATTEMPT_V2 => { + let p: wire::Cancel = parse_params(p)?; + self.voice.cancel_attempt(&p.attempt_key).map_err(failure)?; + RpcReply::value(&serde_json::json!({"ok":true})) + } + _ => Err(RpcError::UnknownMethod(method.into())), + } + } + async fn prepare_remote_voice( + &self, + p: wire::Prepare, + resolved: &mut Option, + cancel: tokio_util::sync::CancellationToken, + ) -> Result { + let _preparation = self.voice.preparation().await; + let epoch = self.voice.identity_epoch(); + if self.voice.busy() { + return Err(VoiceRejection::Busy); + } + if cancel.is_cancelled() { + return Err(VoiceRejection::InvalidLease); + } + let chat = self.voice_chat(p.config).await?; + *resolved = Some(chat.clone()); + let (bridge, events, active) = self.prepare_voice_bridge(&chat).await?; + let eligibility = bridge.probe_external().await?; + if p.voice + .as_ref() + .is_some_and(|v| !eligibility.voices.contains(v)) + { + return Err(VoiceRejection::Unsupported); + } + if cancel.is_cancelled() { + return Err(VoiceRejection::InvalidLease); + } + let expected = self + .workspace + .chat(&chat) + .map_err(|_| VoiceRejection::Protocol)? + .ok_or(VoiceRejection::InvalidLease)?; + let doc = self + .doc_host + .open(&chat) + .map_err(|_| VoiceRejection::Protocol)?; + self.voice + .connect_remote( + self.engine_info.device_id.clone(), + chat, + p.voice, + eligibility.voices, + bridge, + events, + active, + doc, + self.sessions.clone(), + self.workspace.clone(), + expected, + epoch, + cancel, + ) + .await + } +} diff --git a/crates/engine/src/run_journal.rs b/crates/engine/src/run_journal.rs index 6295de16a1..a4110bdce0 100644 --- a/crates/engine/src/run_journal.rs +++ b/crates/engine/src/run_journal.rs @@ -12,7 +12,7 @@ use std::collections::HashMap; use std::fs::{File, OpenOptions}; -use std::io::{BufRead, BufReader, Write}; +use std::io::{BufRead, BufReader, Read, Seek, SeekFrom, Write}; use std::path::{Path, PathBuf}; use std::sync::{Mutex, MutexGuard, PoisonError}; @@ -163,11 +163,17 @@ impl RunJournal { /// The last event in a chat's journal, if any (ignores a torn tail line). pub fn last_event(&self, chat_id: &str) -> Result, JournalError> { - let path = self.path_for(chat_id); - if !path.exists() { - return Ok(None); - } - Ok(read_lines(&path)?.into_iter().next_back()) + last_valid_line(&self.path_for(chat_id)) + } + + /// Valid events newest-first, read backwards from the end of the file — for + /// callers that want the most recent match and can stop early instead of + /// materialising the whole journal like `replay` does. + pub fn events_rev( + &self, + chat_id: &str, + ) -> Result>, JournalError> { + RevEvents::open(&self.path_for(chat_id)) } /// Crash-recovery scan: chat ids whose journal's last event is NOT a `Done` — their @@ -183,8 +189,8 @@ impl RunJournal { let Some(chat_id) = path.file_stem().and_then(|s| s.to_str()) else { continue; }; - let last = read_lines(&path)?.into_iter().next_back(); - match last { + // Tail read only: journals reach tens of MB and boot scans every one. + match last_valid_line(&path)? { Some((_, AgentEvent::Done { .. })) | None => {} Some(_) => stale.push(chat_id.to_string()), } @@ -213,35 +219,172 @@ fn read_lines(path: &Path) -> Result, JournalError> { }; let mut out = Vec::new(); for line in BufReader::new(file).lines() { - let line = line?; - if line.trim().is_empty() { - continue; - } - match serde_json::from_str::(&line) { - Ok(parsed) => out.push((parsed.seq, parsed.event)), - Err(err) => { - tracing::warn!(path = %path.display(), error = %err, "journal: skipping malformed line"); - } + if let Some(parsed) = parse_line(path, &line?) { + out.push(parsed); } } Ok(out) } +/// One journal line → `(seq, event)`; blank lines are skipped silently, malformed +/// ones (torn tail writes) with a warning. +fn parse_line(path: &Path, line: &str) -> Option<(u64, AgentEvent)> { + if line.trim().is_empty() { + return None; + } + match serde_json::from_str::(line) { + Ok(parsed) => Some((parsed.seq, parsed.event)), + Err(err) => { + tracing::warn!(path = %path.display(), error = %err, "journal: skipping malformed line"); + None + } + } +} + +/// The last valid event in the file — what `read_lines(path).last()` returns, without +/// reading more than the tail. +fn last_valid_line(path: &Path) -> Result, JournalError> { + RevEvents::open(path)?.next().transpose() +} + /// Next seq (last valid seq + 1, starting at 1) and whether the file ends mid-line. fn scan_tail(path: &Path) -> Result<(u64, bool), JournalError> { - let bytes = match std::fs::read(path) { - Ok(b) => b, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok((1, false)), - Err(e) => return Err(e.into()), + let Some(mut lines) = RevLines::open(path)? else { + return Ok((1, false)); }; - let needs_newline = bytes.last().is_some_and(|b| *b != b'\n'); - let next_seq = read_lines(path)? - .last() + let needs_newline = lines.ends_mid_line()?; + let next_seq = RevEvents::from_lines(path, Some(lines)) + .next() + .transpose()? .map(|(seq, _)| seq + 1) .unwrap_or(1); Ok((next_seq, needs_newline)) } +/// First tail-read size. Journal lines are usually far shorter; a longer line grows the +/// read geometrically, so a multi-MB line costs O(len) rather than O(len²). +const TAIL_CHUNK: usize = 64 * 1024; + +/// Raw lines of a file, last to first, read backwards in chunks — memory is bounded by +/// the longest line rather than the file. `\n` separators are stripped (a preceding `\r` +/// is left in place; JSON parsing treats it as whitespace), so a file ending in `\n` +/// yields an empty line first, which callers skip like any blank line. +struct RevLines { + file: File, + /// File offset of `buf[0]`; bytes before it have not been read yet. + pos: u64, + /// Read but not yet yielded: the file's bytes `[pos, pos + buf.len())`. + buf: Vec, + finished: bool, +} + +impl RevLines { + /// `None` when the file does not exist (an empty journal, as `read_lines` treats it). + fn open(path: &Path) -> std::io::Result> { + let file = match File::open(path) { + Ok(f) => f, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(e) => return Err(e), + }; + let pos = file.metadata()?.len(); + Ok(Some(Self { + file, + pos, + buf: Vec::new(), + finished: false, + })) + } + + /// Prepend the previous chunk of the file to `buf` (at least as large as `buf`). + fn fill(&mut self) -> std::io::Result<()> { + let want = TAIL_CHUNK.max(self.buf.len()) as u64; + let n = want.min(self.pos); + self.pos -= n; + let mut chunk = vec![0u8; n as usize]; + self.file.seek(SeekFrom::Start(self.pos))?; + self.file.read_exact(&mut chunk)?; + chunk.extend_from_slice(&self.buf); + self.buf = chunk; + Ok(()) + } + + /// True when the file is non-empty and its last byte is not `\n` (a torn write). + /// Call before the first `next_line`. + fn ends_mid_line(&mut self) -> std::io::Result { + if self.buf.is_empty() && self.pos > 0 { + self.fill()?; + } + Ok(self.buf.last().is_some_and(|b| *b != b'\n')) + } + + fn next_line(&mut self) -> std::io::Result>> { + loop { + if self.finished { + return Ok(None); + } + if let Some(i) = self.buf.iter().rposition(|b| *b == b'\n') { + let line = self.buf.split_off(i + 1); + self.buf.truncate(i); + return Ok(Some(line)); + } + if self.pos == 0 { + self.finished = true; + return Ok(Some(std::mem::take(&mut self.buf))); + } + self.fill()?; + } + } +} + +/// Valid journal events newest-first: `read_lines` in reverse, with the same +/// blank/malformed-line skipping, reading only as far back as the caller iterates. +struct RevEvents { + path: PathBuf, + /// `None` once exhausted, after an I/O error, or when the file does not exist. + lines: Option, +} + +impl RevEvents { + fn open(path: &Path) -> Result { + Ok(Self::from_lines(path, RevLines::open(path)?)) + } + + fn from_lines(path: &Path, lines: Option) -> Self { + Self { + path: path.to_path_buf(), + lines, + } + } +} + +impl Iterator for RevEvents { + type Item = Result<(u64, AgentEvent), JournalError>; + + fn next(&mut self) -> Option { + loop { + let raw = match self.lines.as_mut()?.next_line() { + Ok(Some(raw)) => raw, + Ok(None) => { + self.lines = None; + return None; + } + Err(err) => { + self.lines = None; + return Some(Err(err.into())); + } + }; + // A torn write can split a multi-byte char: treat it as malformed too. + let Ok(line) = std::str::from_utf8(&raw) else { + tracing::warn!(path = %self.path.display(), "journal: skipping non-UTF-8 line"); + continue; + }; + if let Some(parsed) = parse_line(&self.path, line) { + return Some(Ok(parsed)); + } + } + } +} + /// Journal (`.jsonl`) and resume-budget (`.resume`) paths for `chat_id` under an /// arbitrary journals directory — profile import copies these files between /// profiles without opening a `RunJournal`. @@ -348,4 +491,172 @@ mod tests { assert_eq!(all.len(), 2); assert_eq!(all[1].0, 2); } + + fn line(seq: u64, event: AgentEvent) -> String { + serde_json::to_string(&JournalLine { seq, event }).unwrap() + } + + fn rev_lines(path: &Path) -> Vec> { + let Some(mut lines) = RevLines::open(path).unwrap() else { + return Vec::new(); + }; + std::iter::from_fn(|| lines.next_line().unwrap()).collect() + } + + /// Journal contents covering the tail reader's edge cases; each must read the same + /// backwards as the full forward parse did. + fn tail_cases() -> Vec<(&'static str, Vec)> { + let huge = "x".repeat(5 * TAIL_CHUNK + 123); + let a = line(1, text("a")); + let b = line(2, text("b")); + let big = line(3, text(&huge)); + let fin = line(4, done()); + vec![ + ("empty", Vec::new()), + ("only-newlines", b"\n\n\r\n \n".to_vec()), + ("trailing-newline", format!("{a}\n{b}\n").into_bytes()), + ("no-trailing-newline", format!("{a}\n{b}").into_bytes()), + ("crlf", format!("{a}\r\n{b}\r\n").into_bytes()), + ( + "blank-lines-at-end", + format!("{a}\n{b}\n\n \n").into_bytes(), + ), + ( + "garbage-last-line", + format!("{a}\n{b}\n{{\"seq\":3,\"event\":{{\"type\":\"textD").into_bytes(), + ), + ( + "garbage-then-newline", + format!("{a}\n{b}\nnot json\n\n").into_bytes(), + ), + ("all-garbage", b"nope\n{\"seq\":\nstill nope".to_vec()), + ( + "multi-chunk-last-line", + format!("{a}\n{big}\n").into_bytes(), + ), + ( + "multi-chunk-last-line-unterminated", + format!("{a}\n{big}").into_bytes(), + ), + ( + "multi-chunk-middle-line", + format!("{a}\n{big}\n{fin}\n").into_bytes(), + ), + ( + "multi-chunk-garbage-tail", + format!("{a}\n{b}\n{}", &big[..big.len() - 7]).into_bytes(), + ), + ("multi-chunk-only-line", big.clone().into_bytes()), + ] + } + + #[test] + fn rev_lines_are_forward_lines_reversed() { + let dir = tempfile::tempdir().unwrap(); + for (name, bytes) in tail_cases() { + let path = dir.path().join(format!("{name}.jsonl")); + std::fs::write(&path, &bytes).unwrap(); + let mut expected: Vec> = + bytes.split(|b| *b == b'\n').map(<[u8]>::to_vec).collect(); + expected.reverse(); + assert_eq!(rev_lines(&path), expected, "{name}"); + } + assert!(rev_lines(&dir.path().join("missing.jsonl")).is_empty()); + } + + #[test] + fn tail_reads_match_a_full_forward_parse() { + let dir = tempfile::tempdir().unwrap(); + for (name, bytes) in tail_cases() { + let path = dir.path().join(format!("{name}.jsonl")); + std::fs::write(&path, &bytes).unwrap(); + let all = read_lines(&path).unwrap(); + + // Every valid event, newest-first. + let mut expected = all.clone(); + expected.reverse(); + let rev: Vec<_> = RevEvents::open(&path) + .unwrap() + .collect::>() + .unwrap(); + assert_eq!(rev, expected, "{name}"); + + assert_eq!( + last_valid_line(&path).unwrap(), + all.last().cloned(), + "{name}" + ); + let old_scan_tail = ( + all.last().map(|(seq, _)| seq + 1).unwrap_or(1), + bytes.last().is_some_and(|b| *b != b'\n'), + ); + assert_eq!(scan_tail(&path).unwrap(), old_scan_tail, "{name}"); + } + let missing = dir.path().join("missing.jsonl"); + assert_eq!(last_valid_line(&missing).unwrap(), None); + assert_eq!(scan_tail(&missing).unwrap(), (1, false)); + } + + #[test] + fn stale_sessions_unchanged_by_tail_reads() { + let dir = tempfile::tempdir().unwrap(); + for (name, bytes) in tail_cases() { + std::fs::write(dir.path().join(format!("{name}.jsonl")), bytes).unwrap(); + } + std::fs::write(dir.path().join("ignored.resume"), "2").unwrap(); + let journal = RunJournal::open(dir.path()).unwrap(); + + // The pre-tail-read implementation: full parse, inspect the last event. + let mut expected = Vec::new(); + for entry in std::fs::read_dir(dir.path()).unwrap() { + let path = entry.unwrap().path(); + if path.extension().and_then(|e| e.to_str()) != Some("jsonl") { + continue; + } + let last = read_lines(&path).unwrap().into_iter().next_back(); + if matches!(last, Some((_, ref event)) if !matches!(event, AgentEvent::Done { .. })) { + expected.push(path.file_stem().unwrap().to_str().unwrap().to_string()); + } + } + expected.sort(); + assert_eq!(journal.stale_sessions().unwrap(), expected); + assert!(expected.contains(&"multi-chunk-garbage-tail".to_string())); + assert!(!expected.contains(&"multi-chunk-middle-line".to_string())); + } + + #[test] + fn append_after_multi_chunk_torn_tail_isolates_it() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("chat-1.jsonl"); + let big = line(7, text(&"y".repeat(3 * TAIL_CHUNK))); + std::fs::write( + &path, + format!("{}\n{}", line(6, text("a")), &big[..big.len() / 2]), + ) + .unwrap(); + let journal = RunJournal::open(dir.path()).unwrap(); + assert_eq!(journal.append("chat-1", &text("b")).unwrap(), 7); + assert!(matches!( + journal.last_event("chat-1").unwrap(), + Some((7, AgentEvent::TextDelta { .. })) + )); + assert_eq!(journal.replay("chat-1", 0).unwrap().len(), 2); + } + + #[test] + fn non_utf8_tail_line_is_skipped_as_malformed() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("chat-1.jsonl"); + let mut bytes = format!("{}\n", line(1, done())).into_bytes(); + // A torn write that split a multi-byte char. + bytes + .extend_from_slice(b"{\"seq\":2,\"event\":{\"type\":\"textDelta\",\"text\":\"\xE2\x82"); + std::fs::write(&path, bytes).unwrap(); + let journal = RunJournal::open(dir.path()).unwrap(); + assert!(matches!( + journal.last_event("chat-1").unwrap(), + Some((1, AgentEvent::Done { .. })) + )); + assert!(journal.stale_sessions().unwrap().is_empty()); + } } diff --git a/crates/engine/src/sessions.rs b/crates/engine/src/sessions.rs index fb342c8c3e..cc8a13a9d9 100644 --- a/crates/engine/src/sessions.rs +++ b/crates/engine/src/sessions.rs @@ -28,7 +28,7 @@ use zeron_doc::{ DocError, MessagePart, MessageRole, MessageStatus, STREAM_COMMIT_MS, SegmentWriter, SessionDoc, SessionMessageEntry, fold_event_into_parts, sanitize_tool_call, }; -use zeron_harness::{CancellationToken, Harness, RunControls, SteerMessage}; +use zeron_harness::{CancellationToken, Harness, RunControls, SteerMessage, TurnControl}; use zeron_proto::{ AgentEvent, DoneStatus, HarnessId, RunRequest, Session, SessionStatus, UserInputAnswer, UserInputQuestion, @@ -69,48 +69,94 @@ struct HarnessSessionRef { cwd: String, } -/// Configuration baked into a live harness runtime. The steering mailbox only -/// carries prompt text, so routing a request whose model/effort/sandbox changed -/// would silently run it with the old process configuration. Such requests +/// The configuration a live harness runtime launched with. The steering +/// mailbox carries only the prompt (and its images), so a request whose +/// launch settings differ would silently run with the old process's — it /// must replace the runtime and resume its harness-native session instead. -#[derive(Debug, Clone, PartialEq)] +/// The driver decides what "differ" means ([`Harness::same_runtime`]): a +/// replacement kills the runtime's background work, so a setting the +/// launch never reads, or the same value spelled differently by another +/// client, must never force one. +#[derive(Clone)] struct RuntimeConfig { harness_id: HarnessId, - model: Option, - reasoning: Option, - model_options: serde_json::Map, - cwd: String, - sandbox: zeron_proto::SandboxLevel, - auto_approve: bool, - worktree: Option, + request: RunRequest, + harness: Arc, } impl RuntimeConfig { - fn from_request(harness_id: HarnessId, request: &RunRequest) -> Self { + fn new(harness_id: HarnessId, request: &RunRequest, harness: Arc) -> Self { Self { harness_id, - model: request.model.clone(), - reasoning: request.reasoning, - model_options: request.model_options.clone(), - cwd: request.cwd.clone(), - sandbox: request.sandbox, - auto_approve: request.auto_approve, - worktree: request.worktree.clone(), + request: request.clone(), + harness, } } fn can_route(&self, harness_id: HarnessId, request: &RunRequest) -> bool { - request.attachments.is_empty() && self == &Self::from_request(harness_id, request) + self.harness_id == harness_id && self.harness.same_runtime(&self.request, request) + } + + /// How `request` reaches this runtime: `Some(false)` as is, `Some(true)` + /// with its configuration adopted in place first + /// ([`Harness::reconfigures_in_place`]), `None` only by replacing it. + fn route(&self, harness_id: HarnessId, request: &RunRequest) -> Option { + if self.can_route(harness_id, request) { + Some(false) + } else if self.harness_id == harness_id + && self.harness.reconfigures_in_place(&self.request, request) + { + Some(true) + } else { + None + } + } +} + +/// A run's live voice calls. They hold its idle reaper off, and each hang-up +/// wakes the reaper to restart its window from there. +#[derive(Default)] +pub(crate) struct VoiceActivity { + calls: std::sync::atomic::AtomicUsize, + ended: tokio::sync::Notify, +} +impl VoiceActivity { + pub(crate) fn live(&self) -> bool { + self.calls.load(std::sync::atomic::Ordering::Acquire) > 0 + } + /// A live call until the returned guard drops. + pub(crate) fn call(self: &Arc) -> VoiceCall { + self.calls.fetch_add(1, std::sync::atomic::Ordering::AcqRel); + VoiceCall(self.clone()) + } +} +pub(crate) struct VoiceCall(Arc); +impl Drop for VoiceCall { + fn drop(&mut self) { + self.0 + .calls + .fetch_sub(1, std::sync::atomic::Ordering::AcqRel); + // A stored permit: the reaper hears it even while busy elsewhere. + self.0.ended.notify_one(); } } struct RunHandle { + voice: Option, + voice_active: Arc, run_id: String, steerable: bool, runtime_config: RuntimeConfig, steer_tx: mpsc::Sender, /// Harness-level cancellation (protocol interrupt + child teardown). interrupt_token: CancellationToken, + /// Turn-level stop + the runtime's background-work report. Used for + /// stopping a turn when the driver [`Harness::stops_turn_in_place`]. + turn: TurnControl, + stops_turn_in_place: bool, + /// The harness stream is open. Before that there is no runtime to keep: + /// a stop tears the pending start down instead. + started: Arc, /// Engine-level cancel: arms the run task's grace deadline so a harness that /// ignores its token can never strand the run. cancel: watch::Sender, @@ -126,6 +172,11 @@ struct RunHandle { /// This runtime's provider session holds the fork's copied history (a /// side chat's bootstrap went out on its run or on one of its steers). fork_history_sent: Arc, + /// The user stopped this runtime: it is being torn down and takes no + /// more prompts. Flipped under the ledger lock, so every accepted steer + /// is unambiguously before the stop (cancelled with it) or after it + /// (re-dispatched to a fresh runtime — never lost). + retiring: Arc, } /// One accepted-but-unconfirmed steer: enough to re-dispatch it verbatim. @@ -137,10 +188,23 @@ struct RoutedSteer { /// is recorded only when the runtime confirms consuming it (`Steered`). /// An orphan re-dispatches the bare prompt, still owing the history. fork_history: bool, + /// Accepted after the user's Stop began tearing the runtime down: the + /// Stop did not cancel it, so the message still runs. + after_stop: bool, + /// The run configuration it was sent with, images included: an orphan + /// re-dispatches exactly this, not whatever the chat's latest request + /// became. `None` when the chat had no recorded request. + request: Option>, } struct Inner { + voice: crate::voice::VoiceManager, device_id: String, + /// The engine is shutting down: dying runs re-dispatch nothing. + shutting_down: std::sync::atomic::AtomicBool, + /// Per chat: messages a Stop cancelled while they were still on their + /// way (see [`SessionsEngine::cancel_sent_messages`]). + cancelled_sends: Mutex>>, /// Loopback IPC port this engine serves, once known (0 = not serving): /// what the injected `zeron mcp` server dials back into. ipc_port: std::sync::atomic::AtomicU16, @@ -194,7 +258,10 @@ impl SessionsEngine { let (sessions_tx, _) = watch::channel(Vec::new()); Self { inner: Arc::new(Inner { + voice: crate::voice::VoiceManager::default(), device_id, + shutting_down: std::sync::atomic::AtomicBool::new(false), + cancelled_sends: Mutex::new(HashMap::new()), ipc_port: std::sync::atomic::AtomicU16::new(0), journal, registry, @@ -212,6 +279,10 @@ impl SessionsEngine { } } + pub(crate) fn voice_manager(&self) -> crate::voice::VoiceManager { + self.inner.voice.clone() + } + /// Record the loopback IPC port this engine serves. Runs started after /// this carry Zeron's MCP server (see [`Inner::zeron_mcp`]); until then — /// or with 0 — agents get no Zeron tools rather than a dead server. @@ -304,10 +375,21 @@ impl SessionsEngine { .is_some_and(is_active) } + /// Whether the chat's live runtime reports background work (subagents, + /// background shells) still running — parked or not. + pub fn holds_background_work(&self, chat_id: &str) -> bool { + lock(&self.inner.runs) + .get(chat_id) + .is_some_and(|h| h.turn.background_live()) + } + /// Any run currently working or blocked on input — the auto-updater's /// "don't restart from under a session" gate. pub fn any_active(&self) -> bool { lock(&self.inner.statuses).values().any(is_active) + || lock(&self.inner.runs) + .values() + .any(|r| r.voice_active.live()) } /// A text prompt for `chat_id` would land in the mailbox of a live @@ -315,7 +397,8 @@ impl SessionsEngine { /// but a mailbox delivery writes the user message now — above the reply /// still streaming for the message before it. Such prompts belong in the /// visible queue. `request` = a Run that may differ from the live config - /// (a different config restarts the runtime instead, which is no hold). + /// (one the runtime adopts in place still lands in its mailbox; any other + /// config restarts the runtime instead, which is no hold). pub fn defers_to_turn_end( &self, chat_id: &str, @@ -327,7 +410,8 @@ impl SessionsEngine { let live = lock(&self.inner.runs).get(chat_id).map(|h| { ( h.runtime_config.harness_id, - h.steerable && request.is_none_or(|(id, r)| h.runtime_config.can_route(id, r)), + h.steerable + && request.is_none_or(|(id, r)| h.runtime_config.route(id, r).is_some()), ) }); live.is_some_and(|(harness, routable)| routable && !self.steers_mid_turn(harness)) @@ -346,7 +430,7 @@ impl SessionsEngine { pub fn live_run_steerable(&self, chat_id: &str) -> bool { lock(&self.inner.runs) .get(chat_id) - .is_some_and(|h| h.steerable) + .is_some_and(|h| h.steerable && !h.retiring.load(std::sync::atomic::Ordering::Acquire)) } /// The last request dispatched for a chat (steer→new-turn fallback). @@ -378,6 +462,62 @@ impl SessionsEngine { Ok((replay, rx)) } + /// Local experimental bootstrap. No Submit, empty user entry or title request. + pub async fn start_idle( + &self, + chat_id: &str, + request: RunRequest, + ) -> Result { + // A fresh explicit request may renew an identity-invalidated idle + // process. Never interrupt a native delegated turn to recover voice. + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(8); + loop { + let stale = lock(&self.inner.runs).get(chat_id).map(|run| { + ( + run.voice.as_ref().is_some_and(|v| v.invalidated()), + run.voice_active.live(), + ) + }); + match stale { + Some((true, false)) => { + if self.turn_in_flight(chat_id) { + return Err(EngineError::Other( + "identity changed during delegated work".into(), + )); + } + self.interrupt(chat_id).await?; + break; + } + Some((true, _)) => { + if tokio::time::Instant::now() >= deadline { + return Err(EngineError::Other("voice cleanup in progress".into())); + } + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } + _ => break, + } + } + self.dispatch_inner(chat_id, HarnessId::Codex, request, None, false, true) + .await + } + + pub(crate) fn take_voice_bridge( + &self, + chat_id: &str, + ) -> Option<( + zeron_harness::codex::realtime::RealtimeHandle, + tokio::sync::broadcast::Receiver, + Arc, + )> { + let mut runs = lock(&self.inner.runs); + let run = runs.get_mut(chat_id)?; + Some(( + run.voice.clone()?, + run.voice.as_ref()?.events.subscribe(), + run.voice_active.clone(), + )) + } + /// Start (or route) a run for `chat_id`. /// /// - The user message entry is written to the doc immediately (id = `message_id`). @@ -408,7 +548,14 @@ impl SessionsEngine { message_id: Option, startup_retry: bool, ) -> futures::future::BoxFuture<'a, Result> { - Box::pin(self.dispatch_inner(chat_id, harness_id, request, message_id, startup_retry)) + Box::pin(self.dispatch_inner( + chat_id, + harness_id, + request, + message_id, + startup_retry, + false, + )) } async fn dispatch_inner( @@ -418,6 +565,7 @@ impl SessionsEngine { mut request: RunRequest, mut message_id: Option, startup_retry: bool, + idle: bool, ) -> Result { // Project-less chats store cwd `~` (the creating device can't know the // host's home); expand it here, on the host, where the run spawns. @@ -430,16 +578,28 @@ impl SessionsEngine { let routed = lock(&self.inner.runs).get(chat_id).map(|h| { ( h.run_id.clone(), - h.steerable, - h.runtime_config.can_route(harness_id, &request), + h.steerable && !h.retiring.load(std::sync::atomic::Ordering::Acquire), + h.runtime_config.route(harness_id, &request), h.steer_tx.clone(), h.routed_steers.clone(), h.fork_history_sent.clone(), + h.retiring.clone(), ) }); - if let Some((run_id, steerable, same_runtime, steer_tx, ledger, history_sent)) = routed { + if let Some((run_id, steerable, route, steer_tx, ledger, history_sent, retiring)) = routed { + if idle { + // No prompt rides an idle start, so nothing could carry an + // in-place reconfiguration: only the runtime as is will do. + return if route == Some(false) && harness_id == HarnessId::Codex { + Ok(run_id) + } else { + Err(EngineError::Other("incompatible live runtime".into())) + }; + } let user_id = message_id.clone().unwrap_or_else(new_id); let mut bootstrap = None; + let same_runtime = route.is_some(); + let reconfigure = route == Some(true); let accepted = if steerable && same_runtime { bootstrap = self.warm_fork_history(chat_id, harness_id, &request.prompt, &history_sent); @@ -456,6 +616,12 @@ impl SessionsEngine { zeron_proto::invocation::harness_prompt(delivered, harness_id) }, message_id: Some(user_id.clone()), + // Images ride the mailbox too: replacing the runtime just + // to inline them killed its background work. + attachments: request.attachments.clone(), + // A changed model/effort/option the live process adopts + // in place: replacing it killed its background work. + config: reconfigure.then(|| Box::new(request.clone())), }; if let Ok(permit) = steer_tx.reserve().await { // Commit the reserved slot atomically with the update @@ -469,6 +635,8 @@ impl SessionsEngine { prompt: request.prompt.clone(), message_id: user_id.clone(), fork_history: bootstrap.is_some(), + after_stop: retiring.load(std::sync::atomic::Ordering::Acquire), + request: Some(Box::new(request.clone())), }); permit.send(message); }) @@ -480,6 +648,18 @@ impl SessionsEngine { false }; if accepted { + if reconfigure { + // The runtime runs this configuration from now on: later + // sends compare against it, and a re-dispatch of an + // orphaned steer starts from it. + if let Some(h) = lock(&self.inner.runs) + .get_mut(chat_id) + .filter(|h| h.run_id == run_id) + { + h.runtime_config.request = request.clone(); + } + lock(&self.inner.last_requests).insert(chat_id.to_string(), request.clone()); + } let handle = self.doc_handle(chat_id)?; handle.write_user_message(&user_id, &request.prompt, now_ms())?; self.note_turn_start(chat_id, &request.cwd); @@ -514,21 +694,33 @@ impl SessionsEngine { message_id = Some(user_id); } if !same_runtime { - tracing::debug!( + tracing::info!( chat = %chat_id, "restarting live harness to apply changed run configuration" ); } // Mailbox closed (runtime mid-teardown / non-steering harness) or - // the routed run died with the message reclaimed, or configuration - // changed beyond what the text-only mailbox can carry: replace it. - self.interrupt(chat_id).await?; + // the routed run died with the message reclaimed, or the launch + // configuration changed: replace the runtime. + self.terminate(chat_id).await?; } + // A Stop sent after this message cancelled it while it was on its + // way: never start the stopped work again in a fresh runtime. + if let Some(id) = &message_id + && lock(&self.inner.cancelled_sends) + .get_mut(chat_id) + .is_some_and(|ids| ids.remove(id)) + { + tracing::info!(chat = %chat_id, "message cancelled by a later Stop: not dispatched"); + return Ok(String::new()); + } let harness = self.inner.registry.resolve(harness_id)?; let handle = self.doc_handle(chat_id)?; let user_id = message_id.unwrap_or_else(new_id); - handle.write_user_message(&user_id, &request.prompt, now_ms())?; + if !idle { + handle.write_user_message(&user_id, &request.prompt, now_ms())?; + } // Engine-owned resume (zeron sessions.ts:736 — every dispatch read the // chat's stored harness session): callers always send `resume: None`; @@ -580,40 +772,62 @@ impl SessionsEngine { }) }; let interrupt_token = CancellationToken::new(); + let turn = TurnControl::default(); + let started = Arc::new(std::sync::atomic::AtomicBool::new(false)); let fork_history_sent = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let (voice_handle, realtime, _voice_events) = zeron_harness::codex::realtime::channel(); + let voice_active = Arc::new(VoiceActivity::default()); let controls = RunControls { + realtime: (harness_id == HarnessId::Codex).then_some(realtime), execution_lease: None, request_input, steering: steer_rx, interrupt: interrupt_token.clone(), + turn: turn.clone(), }; lock(&self.inner.runs).insert( chat_id.to_string(), RunHandle { + voice: (harness_id == HarnessId::Codex).then_some(voice_handle), + voice_active: voice_active.clone(), run_id: run_id.clone(), steerable: harness.supports_steering(), - runtime_config: RuntimeConfig::from_request(harness_id, &request), + runtime_config: RuntimeConfig::new(harness_id, &request, harness.clone()), steer_tx, interrupt_token, + stops_turn_in_place: harness.supports_steering() && harness.stops_turn_in_place(), + turn: turn.clone(), + started: started.clone(), cancel: cancel_tx, engine_tx, pending_inputs, routed_steers: Arc::new(Mutex::new(std::collections::VecDeque::new())), fork_history_sent: fork_history_sent.clone(), + retiring: Arc::new(std::sync::atomic::AtomicBool::new(false)), + }, + ); + self.set_status( + chat_id, + if idle { + SessionStatus::Idle + } else { + SessionStatus::Working }, + true, ); - self.set_status(chat_id, SessionStatus::Working, true); // AFTER Working (same causal-order guarantee as the steer path): the // lastMessageAt bump must never be observable ahead of the live run. - self.inner.note_message(chat_id, &request.prompt); + if !idle { + self.inner.note_message(chat_id, &request.prompt); + } // Name the chat NOW, off the first prompt — not after the first // exchange completes ("called New session for a long time for no // reason"; the titler only needs the prompt and skips titled chats; // the Done-time call below stays as the retry for a failed // generation). - if let Some(titles) = self.inner.titles.get() { + if !idle && let Some(titles) = self.inner.titles.get() { titles.maybe_generate(chat_id, harness_id, &request.prompt, &request.cwd); } @@ -628,11 +842,15 @@ impl SessionsEngine { engine_rx, cancel_rx, RunResumeState { + idle, + voice_active, user_message_id: user_id, resume_injected, startup_retry, fork_history_sent, + started, }, + turn, )); Ok(run_id) } @@ -686,7 +904,7 @@ impl SessionsEngine { ) -> Result { let target = lock(&self.inner.runs) .get(chat_id) - .filter(|h| h.steerable) + .filter(|h| h.steerable && !h.retiring.load(std::sync::atomic::Ordering::Acquire)) .map(|h| { ( h.run_id.clone(), @@ -694,9 +912,10 @@ impl SessionsEngine { h.steer_tx.clone(), h.routed_steers.clone(), h.fork_history_sent.clone(), + h.retiring.clone(), ) }); - let Some((run_id, harness_id, steer_tx, ledger, history_sent)) = target else { + let Some((run_id, harness_id, steer_tx, ledger, history_sent, retiring)) = target else { return Ok(SteerOutcome::NotSteerable); }; zeron_proto::invocation::validate_harness_invocations(prompt, harness_id) @@ -704,6 +923,13 @@ impl SessionsEngine { let user_id = message_id.unwrap_or_else(new_id); let bootstrap = self.warm_fork_history(chat_id, harness_id, prompt, &history_sent); let delivered = bootstrap.as_deref().unwrap_or(prompt); + // A steer runs on the configuration live now; record it with the + // message so a re-dispatch keeps it. + let sent_with = self.last_request(chat_id).map(|mut request| { + request.prompt = prompt.to_string(); + request.attachments = Vec::new(); + Box::new(request) + }); let message = SteerMessage { prompt: if harness_id == HarnessId::Opencode { delivered.to_owned() @@ -711,6 +937,8 @@ impl SessionsEngine { zeron_proto::invocation::harness_prompt(delivered, harness_id) }, message_id: Some(user_id.clone()), + attachments: Vec::new(), + config: None, }; // Saturation is backpressure, not a dead runtime. Waiting for room // preserves the live process and every accepted message in a burst. @@ -727,6 +955,8 @@ impl SessionsEngine { prompt: prompt.to_string(), message_id: user_id.clone(), fork_history: bootstrap.is_some(), + after_stop: retiring.load(std::sync::atomic::Ordering::Acquire), + request: sent_with, }); permit.send(message); }); @@ -765,21 +995,94 @@ impl SessionsEngine { Ok(SteerOutcome::Accepted) } - /// Interrupt the live run, if any. The run settles with a synthetic - /// `Done{interrupted}` and its streaming entry stamped `aborted`; this waits - /// (bounded) for that settlement so callers observe a consistent doc. + /// A Stop acting now cancels these messages, sent before it but still + /// on their way: one that reaches dispatch after the runtime went down + /// is dropped rather than run in a fresh runtime. + pub fn cancel_sent_messages(&self, chat_id: &str, message_ids: Vec) { + if message_ids.is_empty() { + return; + } + lock(&self.inner.cancelled_sends) + .entry(chat_id.to_string()) + .or_default() + .extend(message_ids); + } + + /// The user's Stop: a hard boundary. The in-flight turn ends `aborted` + /// and the runtime goes with it — background subagents, background + /// shells, everything the agent runs. The next message resumes the + /// conversation in a fresh process. (Sending, steering and switching + /// models never come here: they keep the runtime and its work.) pub async fn interrupt(&self, chat_id: &str) -> Result { + self.terminate(chat_id).await + } + + /// End the in-flight turn so another prompt can take its place ("send + /// now"), keeping the runtime and its background work. Its streaming + /// entry is stamped `aborted`; this waits (bounded) for that settlement + /// so callers observe a consistent doc. Returns whether there was a turn + /// (or runtime) to stop. + /// + /// A driver that [`Harness::stops_turn_in_place`] ends only the turn: the + /// runtime parks with its background work — subagents, background shells + /// — still running, and the next prompt routes into it. Drivers without + /// that, runs whose harness has not started yet, and stops that do not + /// settle fall back to [`Self::terminate`]. + pub async fn stop_turn(&self, chat_id: &str) -> Result { + const STOP_SETTLE: std::time::Duration = std::time::Duration::from_secs(15); + let target = lock(&self.inner.runs) + .get(chat_id) + .filter(|h| { + h.stops_turn_in_place && h.started.load(std::sync::atomic::Ordering::Acquire) + }) + .map(|h| (h.run_id.clone(), h.turn.clone(), h.pending_inputs.clone())); + let Some((run_id, turn, pending)) = target else { + return self.terminate(chat_id).await; + }; + if !self.turn_in_flight(chat_id) { + return Ok(false); // parked between turns: nothing to stop + } + // Unpark questions first: the stopped turn can never be answered. + let parked: Vec<_> = lock(&pending).drain().map(|(_, tx)| tx).collect(); + for tx in parked { + let _ = tx.send(Vec::new()); + } + turn.stop_turn(); + let deadline = tokio::time::Instant::now() + STOP_SETTLE; + while tokio::time::Instant::now() < deadline { + if !self.is_live(chat_id, &run_id) || !self.turn_in_flight(chat_id) { + return Ok(true); + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + tracing::warn!(chat = %chat_id, "turn stop did not settle; tearing the runtime down"); + self.terminate(chat_id).await + } + + /// Tear the chat's live runtime down (protocol interrupt, then the + /// child). The run settles with a synthetic `Done{interrupted}` and its + /// streaming entry stamped `aborted`; this waits (bounded) for that + /// settlement. Background work the runtime held dies with it. + pub async fn terminate(&self, chat_id: &str) -> Result { let target = lock(&self.inner.runs).get(chat_id).map(|h| { ( h.run_id.clone(), h.interrupt_token.clone(), h.cancel.clone(), h.pending_inputs.clone(), + h.routed_steers.clone(), + h.retiring.clone(), ) }); - let Some((run_id, token, cancel, pending)) = target else { + let Some((run_id, token, cancel, pending, ledger, retiring)) = target else { return Ok(false); }; + // From here the runtime takes no prompt: a send now starts a fresh + // runtime, and one that raced into this mailbox is re-dispatched. + { + let _order = lock(&ledger); + retiring.store(true, std::sync::atomic::Ordering::Release); + } // Publish cancellation BEFORE waking the harness. Otherwise a fast // EOF after token.cancel() can beat this watch notification and be // classified as an error instead of an interrupted turn. @@ -867,6 +1170,10 @@ impl SessionsEngine { }) }); let attempts = self.inner.journal.resume_attempts(&chat_id); + // Fresh: the crashed reply's streaming entry is recent — or no + // reply entry reached disk at all (the doc saves on a debounce, + // and a kill moments into the turn leaves the user's message as + // the transcript's last word), and that message is recent. let fresh = handle .doc() .read_entries() @@ -876,6 +1183,7 @@ impl SessionsEngine { .iter() .rev() .find(|e| e.status == Some(MessageStatus::Streaming)) + .or_else(|| entries.last().filter(|e| e.role == MessageRole::User)) .map(|e| now_ms() - e.created_at < RESUME_FRESH_MS) }) .unwrap_or(false); @@ -962,9 +1270,13 @@ impl SessionsEngine { /// Graceful shutdown: interrupt every live run so streaming entries settle. pub async fn shutdown(&self) { + self.inner + .shutting_down + .store(true, std::sync::atomic::Ordering::Release); + self.inner.voice.retire(); let chats: Vec = lock(&self.inner.runs).keys().cloned().collect(); for chat_id in chats { - if let Err(err) = self.interrupt(&chat_id).await { + if let Err(err) = self.terminate(&chat_id).await { tracing::warn!(chat = %chat_id, error = %err, "shutdown interrupt failed"); } } @@ -1117,6 +1429,33 @@ impl Inner { self.set_status_with_completion(chat_id, status, fresh_start, None); } + /// Publish how many subagents of `chat_id` are streaming. A change is + /// mirrored like any status transition (it doubles as a heartbeat); an + /// unchanged count is a no-op, and a chat without a status row has + /// nothing to badge yet. + fn set_running_subagents(&self, chat_id: &str, running: usize) { + let running = u32::try_from(running).unwrap_or(u32::MAX); + let session = { + let mut statuses = lock(&self.statuses); + let Some(entry) = statuses.get_mut(chat_id) else { + return; + }; + if entry.running_subagents == running { + return; + } + entry.running_subagents = running; + entry.updated_at = Utc::now(); + let session = entry.clone(); + let mut list: Vec = statuses.values().cloned().collect(); + list.sort_by(|a, b| a.chat_id.cmp(&b.chat_id)); + self.sessions_tx.send_replace(list); + session + }; + if let Some(ws) = self.workspace() { + ws.record_session(&session); + } + } + fn has_pending_steers(&self, chat_id: &str, run_id: &str) -> bool { lock(&self.runs) .get(chat_id) @@ -1145,6 +1484,7 @@ impl Inner { status, started_at: None, updated_at: now, + running_subagents: 0, }); // `started_at` is the elapsed-timer base and must only ever mean // "this turn". Entering Working from a settled state always @@ -1190,6 +1530,14 @@ impl Inner { lock(&self.doc_host).clone() } + /// A message the user already sent follows the turn that is ending + /// (see [`crate::doc_host::ChatDocHandle::continues_with_sent_message`]). + fn continues_with_sent_message(&self, chat_id: &str) -> bool { + self.doc_host() + .and_then(|host| host.open(chat_id).ok()) + .is_some_and(|handle| handle.continues_with_sent_message()) + } + /// Zeron's own MCP server for a run of `chat_id`: this binary's `zeron /// mcp` subcommand, dialing the engine's IPC port and stamped with the /// originating chat + device so the agent's side chats link back here. @@ -1263,7 +1611,17 @@ impl Inner { /// never rides `--resume`. An empty stored id is the explicit tombstone — /// no resume, no falling through to staler sources. fn resume_for(&self, chat_id: &str, cwd: &str) -> Option { - let cwd_ok = |session_cwd: &str| session_cwd.is_empty() || session_cwd == cwd; + // The same directory, however it is spelled: an agent reports where + // it runs with symlinks resolved (`/private/var/…` for `/var/…` on + // macOS), and until a turn's Done restores the request's spelling a + // mid-turn crash leaves that one stored — a string compare then + // started the resumed run in a fresh session, its history gone. + let cwd_ok = |session_cwd: &str| { + session_cwd.is_empty() + || session_cwd == cwd + || std::fs::canonicalize(session_cwd) + .is_ok_and(|a| std::fs::canonicalize(cwd).is_ok_and(|b| a == b)) + }; if let Some(known) = lock(&self.harness_sessions).get(chat_id).cloned() { return (!known.session_id.is_empty() && cwd_ok(&known.cwd)) .then_some(known.session_id); @@ -1283,36 +1641,55 @@ impl Inner { /// The last harness session id named anywhere in the chat's journal, with /// the cwd of the `SessionStarted` that governs it. `Done.session_id` /// inherits the cwd of the most recent `SessionStarted` (same run). + /// + /// Walks the journal newest-first and stops at the answer, so boot recovery + /// doesn't load whole multi-MB journals: the newest non-empty id wins, and a + /// `Done` id takes the cwd of the nearest earlier `SessionStarted` (any id). fn journal_harness_session(&self, chat_id: &str) -> Option<(String, String)> { - let events = match self.journal.replay(chat_id, 0) { + let warn = |err: crate::run_journal::JournalError| { + tracing::warn!(chat = %chat_id, error = %err, "journal scan for harness session failed"); + }; + let events = match self.journal.events_rev(chat_id) { Ok(events) => events, Err(err) => { - tracing::warn!(chat = %chat_id, error = %err, "journal scan for harness session failed"); + warn(err); return None; } }; - let mut current_cwd = String::new(); - let mut found: Option<(String, String)> = None; - for (_, event) in events { + // A `Done` id seen first, still waiting for its run's `SessionStarted` cwd. + let mut done_id: Option = None; + for item in events { + let (seq, event) = match item { + Ok(item) => item, + Err(err) => { + warn(err); + return None; + } + }; + if seq == 0 { + continue; // `replay(_, 0)` (the old full scan) never yielded seq 0 + } match event { AgentEvent::SessionStarted { session_id, cwd, .. } => { - current_cwd = cwd; + if let Some(id) = done_id { + return Some((id, cwd)); + } if !session_id.is_empty() { - found = Some((session_id, current_cwd.clone())); + return Some((session_id, cwd)); } } AgentEvent::Done { session_id: Some(session_id), .. - } if !session_id.is_empty() => { - found = Some((session_id, current_cwd.clone())); + } if done_id.is_none() && !session_id.is_empty() => { + done_id = Some(session_id); } _ => {} } } - found + done_id.map(|id| (id, String::new())) } fn remove_run(&self, chat_id: &str, run_id: &str) { @@ -1714,10 +2091,14 @@ fn finish_segment<'a>( /// engine-injected resumes retry — a caller-specified resume fails loudly), /// and whether this run already IS the retry (one attempt only). struct RunResumeState { + idle: bool, + voice_active: Arc, user_message_id: String, resume_injected: bool, startup_retry: bool, fork_history_sent: Arc, + /// Set once the harness stream is open (see [`RunHandle::started`]). + started: Arc, } fn cursor_unstarted_history( @@ -1794,6 +2175,7 @@ async fn drive_run( mut engine_rx: mpsc::UnboundedReceiver, mut cancel_rx: watch::Receiver, resume_state: RunResumeState, + turn: TurnControl, ) { let device_id = inner.device_id.clone(); // Captured for post-run auto-titling (the request moves into the harness). @@ -1822,14 +2204,16 @@ async fn drive_run( None => ProviderSession::Fresh, resumed => ProviderSession::Continued(resumed), }; - if let Some(prompt) = inner.fork_history_prompt( - &chat_id, - &doc, - harness_id, - &request.prompt, - Some(&resume_state.user_message_id), - provider, - ) { + if !resume_state.idle + && let Some(prompt) = inner.fork_history_prompt( + &chat_id, + &doc, + harness_id, + &request.prompt, + Some(&resume_state.user_message_id), + provider, + ) + { request.prompt = prompt; carried_history = true; resume_state @@ -1873,7 +2257,11 @@ async fn drive_run( wire_request.prompt = zeron_proto::invocation::harness_prompt(&wire_request.prompt, harness_id); } - harness.run(wire_request, controls).await + if resume_state.idle { + harness.start_idle(wire_request, controls).await + } else { + harness.run(wire_request, controls).await + } } else { Ok(futures::stream::once(async { Ok(AgentEvent::Done { @@ -1889,9 +2277,33 @@ async fn drive_run( Err(error) => Err(error), }; let mut stream = match started { - Ok(stream) => stream, + Ok(stream) => { + resume_state + .started + .store(true, std::sync::atomic::Ordering::Release); + stream + } Err(err) => { let message = err.to_string(); + tracing::warn!(chat = %chat_id, harness = ?harness_id, error = %message, "run failed to start"); + // The journal alone is live-only: without an entry the transcript + // shows "Run failed" with no reason (an OpenCode server that never + // booted looked exactly like that). + let parts = [MessagePart::Error { + id: "e0".into(), + message: message.clone(), + }]; + if let Err(err) = finish_segment( + &doc, + None, + &new_id(), + &device_id, + now_ms(), + &parts, + MessageStatus::Complete, + ) { + tracing::warn!(chat = %chat_id, error = %err, "start failure entry failed"); + } inner.publish( &chat_id, &AgentEvent::Error { @@ -1961,13 +2373,21 @@ async fn drive_run( // its steering mailbox stay warm, and the next user message (dispatch // routes into a live run) starts the next turn with zero respawn/resume // latency. `Some(when)` = idle since then; the 30-min reaper below ends - // a session nobody comes back to (zeron SESSION_IDLE_MS). - // `ZERON_SESSION_IDLE_MS` overrides the window (tests). + // a session nobody comes back to (zeron SESSION_IDLE_MS). A voice + // orchestrator holds Codex and its MCP servers (~200 MB) for calls only, + // so it goes after 5 minutes. A call holds the reaper off, and the window + // restarts at hang-up. `ZERON_SESSION_IDLE_MS` overrides both (tests). let session_idle = std::env::var("ZERON_SESSION_IDLE_MS") .ok() .and_then(|v| v.parse::().ok()) .map(std::time::Duration::from_millis) - .unwrap_or(std::time::Duration::from_secs(30 * 60)); + .unwrap_or(std::time::Duration::from_secs( + if zeron_proto::voice::is_orchestrator_chat(&chat_id) { + 5 * 60 + } else { + 30 * 60 + }, + )); // A live subagent stretches the window, but never unbounded: every // driver's subagent terminal is a best-effort vendor signal (claude's // untagged task_notification, grok's subagent_finished, codex thread @@ -1976,8 +2396,18 @@ async fn drive_run( // silent this long (4h by default) is presumed lost; the reap stamps its // chip failed. let subagent_silence = session_idle * 8; - let mut idle_since: Option = None; + let mut idle_since: Option = + resume_state.idle.then(tokio::time::Instant::now); + // A parked runtime whose agent reports background work (see + // `TurnControl::background_live`) is not idle: reaping it killed that + // work. The reaper re-checks a full idle window later instead. + let mut reap_deferred_until: Option = None; + // The reaper last deferred for background work. Its end is not idleness + // either: the agent reports the drained set a moment before the turn the + // completion wakes, and reaping in that gap lost the wake (issue #831). + let mut held_for_background = false; let steerable = harness.supports_steering(); + let stops_turn_in_place = steerable && harness.stops_turn_in_place(); // TURN-QUIESCE WATCHDOG (2026-08-12 stuck-Working incident): a harness // that loses a turn's Done — the adapter never settles `session/prompt` // even though the agent finished — strands Working forever: the live @@ -2072,12 +2502,24 @@ async fn drive_run( inner.touch_session(&chat_id); continue; } + // A hang-up restarts the idle window, however long the call. + _ = resume_state.voice_active.ended.notified() => { + if idle_since.is_some() { + idle_since = Some(tokio::time::Instant::now()); + } + continue; + } // An accepted update must not wait behind a warm between-turn // child for the full idle-reaper window. The completed turn is // already durable, so retire the parked process cleanly and let // the queued exclusive lease proceed. _ = tokio::time::sleep_until(tokio::time::Instant::now()), - if idle_since.is_some() && inner.registry.update_pending(harness_id) => + if idle_since.is_some() + && !resume_state.voice_active.live() + && inner.registry.update_pending(harness_id) + && !turn.background_live() + && (subagents.is_empty() + || last_subagent_activity.is_none_or(|at| at.elapsed() >= subagent_silence)) => { if let Some(token) = lock(&inner.runs) .get(&chat_id) @@ -2089,19 +2531,41 @@ async fn drive_run( break SessionStatus::Idle; } // Idle reaper (zeron SESSION_IDLE_MS): a parked persistent session - // nobody returned to in 30 minutes releases its child. The turn + // nobody returned to within its window releases its child. The turn // was finalized at Done, so this end is clean — no aborted stamp. // A live background subagent is somebody still using the child: // the window counts from its last activity, and stretches to // `subagent_silence` while its sink is open. _ = tokio::time::sleep_until( - idle_since - .map(|at| { - at.max(last_subagent_activity.unwrap_or(at)) - + if subagents.is_empty() { session_idle } else { subagent_silence } - }) - .unwrap_or_else(tokio::time::Instant::now) - ), if idle_since.is_some() => { + { + let window = if subagents.is_empty() { session_idle } else { subagent_silence }; + let base = idle_since + .map(|at| at.max(last_subagent_activity.unwrap_or(at)) + window); + match (base, reap_deferred_until) { + (Some(base), Some(deferred)) => base.max(deferred), + (base, deferred) => base.or(deferred).unwrap_or_else(tokio::time::Instant::now), + } + } + ), if idle_since.is_some() && !resume_state.voice_active.live() => { + // A call that began after this wait was armed holds it off. + if resume_state.voice_active.live() { + continue; + } + // The driver's own report of background work (Claude's + // background agents and shells) is authoritative — it + // lists every task the process still runs — so it holds + // the runtime for as long as that work lives. + if turn.background_live() { + tracing::info!(chat = %chat_id, "idle reaper deferred: the agent reports background work"); + reap_deferred_until = Some(tokio::time::Instant::now() + session_idle); + held_for_background = true; + continue; + } + if std::mem::take(&mut held_for_background) { + // The work just ended: a full idle window from here. + reap_deferred_until = Some(tokio::time::Instant::now() + session_idle); + continue; + } tracing::info!( chat = %chat_id, live_subagents = subagents.len(), @@ -2207,7 +2671,8 @@ async fn drive_run( // completion notice, but never notify for an empty boundary // or while an accepted steer still awaits delivery. let completed_turn = ((!folded.is_empty() || writer.is_some()) - && !inner.has_pending_steers(&chat_id, &run_id)) + && !inner.has_pending_steers(&chat_id, &run_id) + && !inner.continues_with_sent_message(&chat_id)) .then(|| entry_id.clone()); if !folded.is_empty() || writer.is_some() { if let Err(err) = finish_segment( @@ -2228,6 +2693,7 @@ async fn drive_run( entry_id = new_id(); segment_started = now_ms(); idle_since = Some(tokio::time::Instant::now()); + reap_deferred_until = None; self_continued_turn = false; inner.set_status_with_completion( &chat_id, SessionStatus::Idle, false, completed_turn, @@ -2263,6 +2729,12 @@ async fn drive_run( { inner.publish(&chat_id, &event); last_subagent_activity = Some(tokio::time::Instant::now()); + // Background subagents can outlive the turn and be the only + // traffic for minutes: keep the row (and its count) inside the + // UI's staleness window while any is live. + if !subagents.is_empty() { + inner.touch_session(&chat_id); + } let is_steer = matches!( sub_event.as_ref(), AgentEvent::UserMessage { .. } | AgentEvent::Steered { .. } @@ -2337,6 +2809,7 @@ async fn drive_run( dirty: false, }, ); + inner.set_running_subagents(&chat_id, subagents.len()); if !chip_streaming { let _ = doc_ref.update_subagent_chip( parent_tool_use_id, @@ -2350,6 +2823,17 @@ async fn drive_run( let done = matches!(sub_event.as_ref(), AgentEvent::Done { .. }); if done { settled_subagents.insert(parent_tool_use_id.clone()); + if !chip_streaming { + // A resumed run can finish an older chip without ever + // opening a sink. The chip's lifecycle is independent of + // whether this run received transcript content. + let _ = doc_ref.update_subagent_chip( + parent_tool_use_id, + None, + subagent_chip_update(sub_event), + None, + ); + } } if let Some(sink) = subagents.get_mut(parent_tool_use_id) { if let AgentEvent::UserMessage { text } = sub_event.as_ref() { @@ -2370,16 +2854,6 @@ async fn drive_run( flush_at = tokio::time::Instant::now() + std::time::Duration::from_millis(STREAM_COMMIT_MS); } - if !chip_streaming && done { - // In-place chip refresh on lifecycle transitions only — - // content never rewrites the parent doc. - let _ = doc_ref.update_subagent_chip( - parent_tool_use_id, - None, - subagent_chip_update(sub_event), - None, - ); - } if done { let status = match sub_event.as_ref() { AgentEvent::Done { @@ -2393,6 +2867,7 @@ async fn drive_run( _ => MessageStatus::Complete, }; let sink = subagents.remove(parent_tool_use_id).expect("checked"); + inner.set_running_subagents(&chat_id, subagents.len()); let doc_id = sink.doc_id.clone(); // FREEZE: the finished transcript uploads as a static R2 // blob (`blob/{chatId}/{subDocId}`) so viewers of @@ -2502,6 +2977,8 @@ async fn drive_run( inner.set_status(&chat_id, SessionStatus::Working, true); } else { match &event { + AgentEvent::SessionStarted { .. } + if resume_state.idle && !saw_session_started => {} AgentEvent::Steered { .. } => { idle_since = None; inner.set_status(&chat_id, SessionStatus::Working, true); @@ -2583,6 +3060,7 @@ async fn drive_run( // helper that is down hard fails the retry too and surfaces its // crash text (the harness now appends exit status + stderr). if resume_state.resume_injected + && !resume_state.idle && !resume_state.startup_retry && !saw_session_started && folded.is_empty() @@ -2775,19 +3253,37 @@ async fn drive_run( { titles.maybe_generate(&chat_id, harness_id, &user_prompt, &run_cwd); } + // A turn stopped in place: the runtime lives on, parked like a + // completed turn. Steers it had accepted but not yet delivered + // were cancelled with the turn (never delivered, never + // confirmed) — drop them, or they would hold the parked session + // Working forever. + let stopped_in_place = + *status == DoneStatus::Interrupted && stops_turn_in_place && !interrupted; + if stopped_in_place + && let Some(ledger) = lock(&inner.runs) + .get(&chat_id) + .filter(|h| h.run_id == run_id) + .map(|h| h.routed_steers.clone()) + { + lock(&ledger).clear(); + } // An accepted steer awaiting its boundary owns the continuation: // the previous Done is an internal handoff, not a completion ping. - // Ordinary queued rows are not in this ledger and still notify. + // So does a message the user already sent that is still on its + // way (see `continues_with_sent_message`); rows the user queued + // themselves still notify. let pending_steer = inner.has_pending_steers(&chat_id, &run_id); let completed_turn = (*status == DoneStatus::Completed && !interrupted && turn_was_active - && !pending_steer) - .then(|| entry_id.clone()); + && !pending_steer + && !inner.continues_with_sent_message(&chat_id)) + .then(|| entry_id.clone()); // PERSISTENT SESSION: a cleanly completed turn on a steerable // harness PARKS instead of ending — child + mailbox stay warm for // the next routed dispatch; per-turn state resets for it. - if *status == DoneStatus::Completed + if (*status == DoneStatus::Completed || stopped_in_place) && steerable && !interrupted && !inner.registry.update_pending(harness_id) @@ -2799,6 +3295,7 @@ async fn drive_run( // Resume-retry is strictly a first-turn concern. saw_session_started = true; idle_since = Some(tokio::time::Instant::now()); + reap_deferred_until = None; self_continued_turn = false; // Accepted steers still own this runtime. Publishing Idle // here lets the ordinary queue overtake that continuation. @@ -2846,6 +3343,7 @@ async fn drive_run( ); } } + inner.set_running_subagents(&chat_id, 0); // Claim any accepted-but-unconfirmed steers BEFORE the handle goes away: // a routed send that raced this exit either finds its entry gone (we own @@ -2857,7 +3355,19 @@ async fn drive_run( .unwrap_or_default(); inner.remove_run(&chat_id, &run_id); inner.set_status_with_completion(&chat_id, final_status, false, final_completed_turn); - if !interrupted && !orphans.is_empty() { + // A Stop cancels the messages it found waiting; one accepted after it + // began is still the user's next message and must run. + let orphans: Vec = if inner + .shutting_down + .load(std::sync::atomic::Ordering::Acquire) + { + Vec::new() + } else if interrupted { + orphans.into_iter().filter(|s| s.after_stop).collect() + } else { + orphans + }; + if !orphans.is_empty() { // The dying run accepted these into its mailbox but never confirmed a // Steered boundary (idle-reaper race, a mid-turn error discarding // queued boundary steers, a parked child death). Their user entries @@ -2870,13 +3380,16 @@ async fn drive_run( let chat = chat_id.clone(); tokio::spawn(async move { for steer in orphans { - let Some(mut request) = engine.last_request(&chat) else { + let Some(mut request) = steer + .request + .map(|request| *request) + .or_else(|| engine.last_request(&chat)) + else { tracing::warn!(chat = %chat, "orphaned steer lost: no run config to re-dispatch"); break; }; request.prompt = steer.prompt.clone(); request.resume = None; - request.attachments = Vec::new(); tracing::info!(chat = %chat, "re-dispatching steer orphaned by a dying run"); let Some(host) = engine.inner.doc_host() else { tracing::warn!(chat = %chat, "orphaned steer lost: doc host unavailable"); @@ -3062,6 +3575,148 @@ mod tests { assert!(!journal.contains("secret")); } + /// The backwards journal scan must answer exactly what the old full forward + /// replay did: newest non-empty id, cwd from the governing `SessionStarted`. + #[tokio::test] + async fn journal_harness_session_matches_forward_scan() { + fn started(id: &str, cwd: &str) -> AgentEvent { + AgentEvent::SessionStarted { + harness: zeron_proto::HarnessId::ClaudeCode, + model: "m".into(), + tools: Vec::new(), + cwd: cwd.into(), + session_id: id.into(), + assistant_message_id: "a".into(), + } + } + fn done(id: Option<&str>) -> AgentEvent { + AgentEvent::Done { + status: DoneStatus::Completed, + result: None, + error: None, + session_id: id.map(Into::into), + } + } + fn text() -> AgentEvent { + AgentEvent::TextDelta { text: "t".into() } + } + /// The pre-change implementation, verbatim, over a forward replay. + fn forward(events: Vec<(u64, AgentEvent)>) -> Option<(String, String)> { + let mut current_cwd = String::new(); + let mut found: Option<(String, String)> = None; + for (_, event) in events { + match event { + AgentEvent::SessionStarted { + session_id, cwd, .. + } => { + current_cwd = cwd; + if !session_id.is_empty() { + found = Some((session_id, current_cwd.clone())); + } + } + AgentEvent::Done { + session_id: Some(session_id), + .. + } if !session_id.is_empty() => { + found = Some((session_id, current_cwd.clone())); + } + _ => {} + } + } + found + } + + /// (chat id, journaled events, expected `(session id, cwd)`). + type Case = ( + &'static str, + Vec, + Option<(&'static str, &'static str)>, + ); + let cases: Vec = vec![ + ("empty", vec![], None), + ("no-ids", vec![text(), done(None), done(Some(""))], None), + ( + "started-only", + vec![started("s1", "/a"), text()], + Some(("s1", "/a")), + ), + ( + "done-inherits-run-cwd", + vec![started("s1", "/a"), text(), done(Some("s2"))], + Some(("s2", "/a")), + ), + ( + "done-before-any-started", + vec![text(), done(Some("s1"))], + Some(("s1", "")), + ), + ( + "empty-started-sets-cwd-for-later-done", + vec![started("s1", "/a"), started("", "/b"), done(Some("s2"))], + Some(("s2", "/b")), + ), + ( + "later-empty-started-does-not-override", + vec![ + started("s1", "/a"), + done(Some("s2")), + started("", "/b"), + text(), + ], + Some(("s2", "/a")), + ), + ( + "newest-run-wins", + vec![ + started("s1", "/a"), + done(Some("s1")), + started("s3", "/c"), + text(), + ], + Some(("s3", "/c")), + ), + ( + "empty-done-ids-ignored", + vec![started("s1", "/a"), done(Some("")), done(None)], + Some(("s1", "/a")), + ), + ( + "only-empty-started-then-done-from-older-run", + vec![ + started("s1", "/a"), + done(Some("s9")), + started("", "/b"), + done(None), + ], + Some(("s9", "/a")), + ), + ]; + + let dir = tempfile::tempdir().unwrap(); + let sessions = SessionsEngine::new( + "host".into(), + Arc::new(RunJournal::open(dir.path().join("journals")).unwrap()), + Arc::new(HarnessRegistry::new()), + ); + for (chat, events, want) in cases { + for event in &events { + sessions.inner.journal.append(chat, event).unwrap(); + } + let want = want.map(|(id, cwd)| (id.to_string(), cwd.to_string())); + let replayed = sessions.inner.journal.replay(chat, 0).unwrap(); + assert_eq!(forward(replayed), want, "{chat}: reference"); + assert_eq!(sessions.inner.journal_harness_session(chat), want, "{chat}"); + } + // Torn tail: the last complete SessionStarted still governs. + let path = dir.path().join("journals").join("started-only.jsonl"); + let mut f = std::fs::OpenOptions::new().append(true).open(path).unwrap(); + std::io::Write::write_all(&mut f, b"{\"seq\":3,\"event\":{\"type\":\"done\"").unwrap(); + assert_eq!( + sessions.inner.journal_harness_session("started-only"), + Some(("s1".into(), "/a".into())) + ); + } + fn request() -> RunRequest { RunRequest { mcp: None, @@ -3079,15 +3734,197 @@ mod tests { } } + struct IdleStartupFixture { + idle_calls: Arc, + inference_calls: Arc, + fail: bool, + } + #[async_trait::async_trait] + impl zeron_harness::Harness for IdleStartupFixture { + fn id(&self) -> HarnessId { + HarnessId::Codex + } + fn display_name(&self) -> &str { + "Offline idle fixture" + } + fn supports_steering(&self) -> bool { + false + } + fn steering_mode(&self) -> zeron_proto::SteeringMode { + zeron_proto::SteeringMode::TurnBoundary + } + fn reasoning_levels(&self) -> &[zeron_proto::ReasoningLevel] { + &[] + } + async fn models(&self) -> Result, zeron_harness::HarnessError> { + Ok(vec![]) + } + async fn start_idle( + &self, + request: RunRequest, + _controls: zeron_harness::RunControls, + ) -> Result< + futures::stream::BoxStream<'static, Result>, + zeron_harness::HarnessError, + > { + use futures::StreamExt; + self.idle_calls + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + assert!(request.prompt.is_empty()); + assert_eq!(request.resume.as_deref(), Some("old-native-thread")); + let event = if self.fail { + AgentEvent::Done { + status: zeron_proto::DoneStatus::Errored, + result: None, + error: Some("offline startup failed".into()), + session_id: None, + } + } else { + AgentEvent::SessionStarted { + harness: HarnessId::Codex, + model: "fixture".into(), + tools: vec![], + cwd: request.cwd, + session_id: "new-native-thread".into(), + assistant_message_id: "fixture-empty".into(), + } + }; + Ok(futures::stream::iter([Ok(event)]) + .chain(futures::stream::pending()) + .boxed()) + } + async fn run( + &self, + _request: RunRequest, + _controls: zeron_harness::RunControls, + ) -> Result< + futures::stream::BoxStream<'static, Result>, + zeron_harness::HarnessError, + > { + self.inference_calls + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Err(zeron_harness::HarnessError::Protocol( + "fixture forbids inference".into(), + )) + } + } + + async fn idle_startup_case(fail: bool) { + use super::*; + let temp = tempfile::tempdir().unwrap(); + let idle_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let inference_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let registry = crate::HarnessRegistry::new(); + registry.register(Arc::new(IdleStartupFixture { + idle_calls: idle_calls.clone(), + inference_calls: inference_calls.clone(), + fail, + })); + let core = crate::EngineCore::assemble_with_profile( + crate::EngineProfile::local(temp.path()).unwrap(), + Arc::new(registry), + HarnessId::Codex, + None, + ) + .unwrap(); + core.workspace + .create_chat("idle", None, Some(&core.device_id), None, None) + .unwrap(); + let mut request = request(); + request.prompt.clear(); + core.sessions + .inner + .remember_harness_session("idle", "old-native-thread", &request.cwd); + core.sessions.start_idle("idle", request).await.unwrap(); + tokio::time::timeout(std::time::Duration::from_secs(5), async { + loop { + let completed = if fail { + core.sessions + .session_status("idle") + .is_some_and(|s| s.status == SessionStatus::Errored) + } else { + core.sessions.inner.resume_for("idle", "/tmp").as_deref() + == Some("new-native-thread") + }; + if completed { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + assert_eq!(idle_calls.load(std::sync::atomic::Ordering::SeqCst), 1); + assert_eq!(inference_calls.load(std::sync::atomic::Ordering::SeqCst), 0); + let entries = core + .doc_host + .open("idle") + .unwrap() + .doc() + .read_entries() + .unwrap(); + assert!( + !entries + .iter() + .any(|entry| entry.role == zeron_doc::MessageRole::User) + ); + if !fail { + assert!(entries.is_empty()); + assert_eq!( + core.sessions.session_status("idle").unwrap().status, + SessionStatus::Idle + ); + assert_eq!( + core.workspace.chat_harness_session("idle").unwrap().0, + "new-native-thread" + ); + let events = core.sessions.inner.journal.replay("idle", 0).unwrap(); + assert!(events.iter().any(|(_, event)| matches!(event, AgentEvent::SessionStarted { session_id, .. } if session_id == "new-native-thread"))); + } + core.sessions.shutdown().await; + } + #[tokio::test] + async fn voice_idle_startup_failure_never_retries_as_inference() { + idle_startup_case(true).await; + } + #[tokio::test] + async fn voice_idle_startup_remembers_thread_without_creating_messages() { + idle_startup_case(false).await; + } + #[test] fn live_routing_requires_the_same_runtime_configuration() { let initial = request(); - let config = RuntimeConfig::from_request(HarnessId::Grok, &initial); + let config = RuntimeConfig::new( + HarnessId::Grok, + &initial, + Arc::new(zeron_harness::AcpHarness::grok()), + ); let mut follow_up = initial.clone(); follow_up.prompt = "second".into(); follow_up.resume = Some("session-1".into()); assert!(config.can_route(HarnessId::Grok, &follow_up)); + assert!(!config.can_route(HarnessId::Devin, &follow_up)); + + // Images ride the mailbox: they never force a replacement runtime. + follow_up.attachments.push("/tmp/image.png".into()); + assert!(config.can_route(HarnessId::Grok, &follow_up)); + + // Another client's spelling of the same option value is the same runtime. + let mut spelled = initial.clone(); + spelled + .model_options + .insert("fastMode".into(), serde_json::json!(true)); + let config = RuntimeConfig::new( + HarnessId::Grok, + &spelled, + Arc::new(zeron_harness::AcpHarness::grok()), + ); + follow_up + .model_options + .insert("fastMode".into(), serde_json::json!("true")); + assert!(config.can_route(HarnessId::Grok, &follow_up)); follow_up.model = Some("grok-4.5".into()); assert!(!config.can_route(HarnessId::Grok, &follow_up)); @@ -3095,10 +3932,34 @@ mod tests { follow_up.reasoning = Some(zeron_proto::ReasoningLevel::Medium); assert!(!config.can_route(HarnessId::Grok, &follow_up)); - follow_up.reasoning = initial.reasoning; + } - follow_up.attachments.push("/tmp/image.png".into()); - assert!(!config.can_route(HarnessId::Grok, &follow_up)); + #[test] + fn claude_routing_compares_what_the_cli_launches_with() { + let claude = || -> Arc { Arc::new(zeron_harness::ClaudeHarness::new()) }; + let mut initial = request(); + initial.model = Some("claude-opus-5-5".into()); + initial + .model_options + .insert("fastMode".into(), serde_json::json!(false)); + let config = RuntimeConfig::new(HarnessId::ClaudeCode, &initial, claude()); + + // The CLI never reads the sandbox level; an option that launches no + // flag in either spelling is the same runtime. + let mut follow_up = initial.clone(); + follow_up.sandbox = SandboxLevel::DangerFullAccess; + follow_up.model_options.clear(); + follow_up.worktree = None; + assert!(config.can_route(HarnessId::ClaudeCode, &follow_up)); + + follow_up + .model_options + .insert("fastMode".into(), serde_json::json!("true")); + assert!(!config.can_route(HarnessId::ClaudeCode, &follow_up)); + follow_up.model_options.clear(); + + follow_up.cwd = "/elsewhere".into(); + assert!(!config.can_route(HarnessId::ClaudeCode, &follow_up)); } #[test] diff --git a/crates/engine/src/spaces.rs b/crates/engine/src/spaces.rs index f640bd14b0..69f4d362f7 100644 --- a/crates/engine/src/spaces.rs +++ b/crates/engine/src/spaces.rs @@ -3,10 +3,11 @@ //! //! A space is a synced (device, folder) pair; the folder need NOT be a git //! repo. This service watches the workspace `spaces` rows owned by THIS device -//! and keeps their `gitDetected`/`checkoutId` stamps truthful: +//! and keeps their `gitDetected`/`checkoutId`/`repositoryId` stamps truthful: //! //! - recheck on boot / when a space row is first observed; -//! - a non-recursive `notify` watcher on the space folder — `.git` appearing or +//! - a non-recursive watch on the space folder (on the process-wide +//! [`FsWatchHub`], so spaces add no watcher threads) — `.git` appearing or //! vanishing (git init / de-git) kicks a recheck; //! - a slow 2-minute repair tick (native watchers coalesce/drop events). //! @@ -32,6 +33,7 @@ use tokio_util::sync::CancellationToken; use zeron_proto::Space; +use crate::fs_watch::{FsWatch, FsWatchHub}; use crate::repos::Repos; use crate::workspace_host::WorkspaceHost; @@ -43,10 +45,10 @@ const REPAIR_INTERVAL: Duration = Duration::from_secs(120); struct SpaceEntry { path: PathBuf, kick_tx: mpsc::UnboundedSender<()>, - /// Keeps the folder watcher alive; dropped on entry close. Filled - /// asynchronously — FSEvents registration blocks, so [`reconcile`] builds - /// it off the runtime and attaches it here once ready. - folder_watch: Mutex>, + /// Keeps the folder watch alive on the shared [`FsWatchHub`]; dropped on + /// entry close. Filled asynchronously — FSEvents registration blocks, so + /// [`reconcile`] builds it off the runtime and attaches it here once ready. + folder_watch: Mutex>, } struct SpacesSyncInner { @@ -141,51 +143,45 @@ fn reconcile(inner: &Arc, spaces: &[Space]) { )); let _ = kick_tx.send(()); // initial check (boot / first observed) - // Non-recursive watcher on the space folder: `.git` appearing/vanishing + // Non-recursive watch on the space folder: `.git` appearing/vanishing // among the direct children is exactly the signal we need. Watch // failures are fine — the repair tick still converges. Built off the - // runtime: FSEvents registration blocks, and reconcile runs on the - // spaces-watch task. + // runtime (canonicalizing the path stats it), and reconcile runs on + // the spaces-watch task. let weak = Arc::downgrade(&entry); tokio::task::spawn_blocking(move || { let Some(entry) = weak.upgrade() else { return; // entry removed before the watcher was ready }; - let tx = entry.kick_tx.clone(); - let result = - notify::recommended_watcher(move |event: Result| { - let Ok(event) = event else { return }; - if event - .paths - .iter() - .any(|p| p.file_name().is_some_and(|n| n == ".git")) - { - let _ = tx.send(()); - } - }); - match result { - Ok(mut watcher) => { - use notify::Watcher as _; - match watcher.watch(&entry.path, notify::RecursiveMode::NonRecursive) { - Ok(()) => { - *lock(&entry.folder_watch) = Some(watcher); - // Close the check→attach gap: a `.git` change while - // unwatched gets caught by this recheck. - let _ = entry.kick_tx.send(()); - } - Err(err) => { - tracing::debug!(path = %entry.path.display(), error = %err, "spaces: watch failed"); - } - } - } - Err(err) => { - tracing::debug!(error = %err, "spaces: watcher create failed"); - } - } + let watch = watch_folder(&FsWatchHub::global(), &entry); + *lock(&entry.folder_watch) = Some(watch); }); } } +/// Register the space folder on the shared [`FsWatchHub`] — one watcher thread +/// for every space instead of one each. A hub rescan (the watch went live, +/// closing the check→attach gap; or events may have been lost) rechecks like +/// a `.git` change. A failed watch (logged by the hub) never signals; the +/// repair tick covers it. Blocking — call from the blocking pool. +fn watch_folder(hub: &Arc, entry: &SpaceEntry) -> FsWatch { + let tx = entry.kick_tx.clone(); + hub.watch( + &entry.path, + notify::RecursiveMode::NonRecursive, + move |event| { + if event.need_rescan() + || event + .paths + .iter() + .any(|p| p.file_name().is_some_and(|n| n == ".git")) + { + let _ = tx.send(()); + } + }, + ) +} + /// Per-space task: trailing-debounce kicks, then recheck git presence. async fn entry_task( inner: Weak, @@ -211,16 +207,26 @@ async fn entry_task( /// Probe git presence and stamp the row — write only on change. async fn check_space(inner: &Arc, space_id: &str, path: &Path) { let detected = inner.repos.is_repo(path).await; - let checkout_id = if detected { - match inner.repos.checkout_identity(path).await { + let (checkout_id, repository_id) = if detected { + let checkout_id = match inner.repos.checkout_identity(path).await { Ok(identity) => Some(identity.id), Err(err) => { tracing::debug!(space = %space_id, error = %err, "spaces: checkout identity failed"); None } - } + }; + // `None` = the check failed; the row keeps its last identity below + // rather than regrouping the sidebar on a transient git error. + let repository_id = match inner.repos.repository_identity(path).await { + Ok(identity) => Some(Some(identity)), + Err(err) => { + tracing::debug!(space = %space_id, error = %err, "spaces: repository identity failed"); + None + } + }; + (checkout_id, repository_id) } else { - None + (None, Some(None)) }; let current = match inner.workspace.read_spaces() { Ok(spaces) => spaces.into_iter().find(|s| s.id == space_id), @@ -232,13 +238,19 @@ async fn check_space(inner: &Arc, space_id: &str, path: &Path) let Some(current) = current else { return; // deleted while checking }; - if current.git_detected == detected && current.checkout_id == checkout_id { + let repository_id = repository_id.unwrap_or_else(|| current.repository_id.clone()); + if current.git_detected == detected + && current.checkout_id == checkout_id + && current.repository_id == repository_id + { return; // unchanged — no oplog growth } - match inner - .workspace - .set_space_git(space_id, detected, checkout_id.as_deref()) - { + match inner.workspace.set_space_git( + space_id, + detected, + checkout_id.as_deref(), + repository_id.as_deref(), + ) { Ok(_) => { tracing::info!(space = %space_id, git = detected, "space git presence updated"); } @@ -317,3 +329,69 @@ async fn spaces_task( } } } + +#[cfg(test)] +mod tests { + use super::*; + + /// The folder watch rides the shared hub: going live sends one recheck + /// (the check→attach gap), `.git` appearing among the direct children + /// kicks, deeper churn does not (non-recursive), and a vanished folder + /// has no live watch and never signals. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn folder_watch_kicks_on_attach_and_git_only() { + let tmp = tempfile::tempdir().unwrap(); + let root = std::fs::canonicalize(tmp.path()).unwrap(); + std::fs::create_dir_all(root.join("src")).unwrap(); + let (kick_tx, mut kick_rx) = mpsc::unbounded_channel(); + let entry = Arc::new(SpaceEntry { + path: root.clone(), + kick_tx, + folder_watch: Mutex::new(None), + }); + let hub = FsWatchHub::new(); + let watch = { + let (hub, entry) = (hub.clone(), entry.clone()); + tokio::task::spawn_blocking(move || watch_folder(&hub, &entry)) + .await + .unwrap() + }; + tokio::time::timeout(Duration::from_secs(5), kick_rx.recv()) + .await + .expect("going live kicks a recheck") + .expect("kick channel open"); + assert!(watch.is_live()); + *lock(&entry.folder_watch) = Some(watch); + tokio::time::sleep(Duration::from_millis(300)).await; + while kick_rx.try_recv().is_ok() {} + + std::fs::write(root.join("src/main.rs"), "fn main() {}").unwrap(); + tokio::time::sleep(Duration::from_millis(500)).await; + assert!(kick_rx.try_recv().is_err(), "nested churn must not kick"); + + std::fs::create_dir(root.join(".git")).unwrap(); + tokio::time::timeout(Duration::from_secs(5), kick_rx.recv()) + .await + .expect("`.git` appearing kicks a recheck") + .expect("kick channel open"); + + let (missing_tx, mut missing_rx) = mpsc::unbounded_channel(); + let missing = Arc::new(SpaceEntry { + path: root.join("gone"), + kick_tx: missing_tx, + folder_watch: Mutex::new(None), + }); + let failed = { + let hub = hub.clone(); + tokio::task::spawn_blocking(move || { + let watch = watch_folder(&hub, &missing); + hub.apply(); + watch + }) + .await + .unwrap() + }; + assert!(!failed.is_live(), "a vanished folder has no live watch"); + assert!(missing_rx.try_recv().is_err(), "and never signals"); + } +} diff --git a/crates/engine/src/titles.rs b/crates/engine/src/titles.rs index 037d1c9dbc..82081a5451 100644 --- a/crates/engine/src/titles.rs +++ b/crates/engine/src/titles.rs @@ -277,6 +277,7 @@ async fn collect_text( let interrupt = CancellationToken::new(); let _cancel_on_drop = interrupt.clone().drop_guard(); let controls = RunControls { + realtime: None, execution_lease, request_input: Box::new(|_questions: Vec| { let (tx, rx) = tokio::sync::oneshot::channel::>(); @@ -285,6 +286,7 @@ async fn collect_text( }), steering: steer_rx, interrupt: interrupt.clone(), + turn: Default::default(), }; let mut stream = harness.run_title(request, controls).await?; let mut text = String::new(); diff --git a/crates/engine/src/uploads.rs b/crates/engine/src/uploads.rs index 9f10ff5f0b..a3aa7a3690 100644 --- a/crates/engine/src/uploads.rs +++ b/crates/engine/src/uploads.rs @@ -10,10 +10,11 @@ //! Attachments live only on the host device — every read proxies through the //! owning device via `ReadAttachmentChunk`; nothing is mirrored to the edge. //! -//! `read_chunk` serves transcript images back in 45KB base64 chunks. Path jail: +//! `read_chunk` serves attachments back in 45KB base64 chunks. Path jail: //! only files under the uploads dir or a workspace-known chat cwd are readable -//! (the RPC layer supplies the cwd roots) — and only supported image types, as -//! in zeron. +//! (the RPC layer supplies the cwd roots). Workspace files must be supported +//! image types, as in zeron; files in the uploads dir read back whatever their +//! type, so a queued message can restore them for editing. use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -331,17 +332,37 @@ impl Uploads { } else { path }; - // Canonicalize BOTH sides so `..` segments and symlinks can't escape. - let resolved = std::fs::canonicalize(path).map_err(|_| outside())?; let read_roots = self .inner .read_only_roots .read() .unwrap_or_else(std::sync::PoisonError::into_inner) .clone(); - let allowed = std::iter::once(&self.inner.dir) + let roots: Vec<&PathBuf> = std::iter::once(&self.inner.dir) .chain(read_roots.iter()) .chain(extra_roots.iter()) + .collect(); + // Jail lexically BEFORE any filesystem call: tool chips send paths an + // agent printed, and merely opening `\\host\share\a.png` (or an autofs + // `/net/host/…`) reaches out to that host — on Windows with the + // user's NTLM credentials. Roots match in raw and canonical form + // (`/var` vs `/private/var`, symlinked cwds). + let requested = lexical_components(Path::new(path)).ok_or_else(outside)?; + let lexically_allowed = roots.iter().any(|root| { + let canonical = std::fs::canonicalize(root).ok(); + [Some(root.as_path()), canonical.as_deref()] + .into_iter() + .flatten() + .filter_map(lexical_components) + .any(|root| requested.len() > root.len() && requested.starts_with(&root)) + }); + if !lexically_allowed { + return Err(outside()); + } + // Canonicalize BOTH sides so symlinks can't escape. + let resolved = std::fs::canonicalize(path).map_err(|_| outside())?; + let allowed = roots + .iter() .filter_map(|root| std::fs::canonicalize(root).ok()) .any(|root| resolved.starts_with(&root) && resolved != root); if !allowed { @@ -354,8 +375,21 @@ impl Uploads { if meta.len() > MAX_BYTES { return Err(EngineError::Other("Attachment is too large".into())); } - let mime_type = mime_by_ext(&resolved) - .ok_or_else(|| EngineError::Other("Attachment is not a supported image".into()))?; + // Files the user attached themselves read back whatever their type (a + // queued message restores them for editing); other roots stay image-only. + let mime_type = match mime_by_ext(&resolved) { + Some(mime) => mime, + None if std::fs::canonicalize(&self.inner.dir) + .is_ok_and(|uploads| resolved.starts_with(uploads)) => + { + "application/octet-stream" + } + None => { + return Err(EngineError::Other( + "Attachment is not a supported image".into(), + )); + } + }; Ok(InspectedFile { name: resolved .file_name() @@ -375,6 +409,48 @@ struct InspectedFile { size: u64, } +/// An absolute local path's components with `.`/`..` resolved WITHOUT +/// touching the filesystem. `None` for relative or drive-relative paths and +/// for any Windows prefix but a drive letter (UNC, `\\.\` devices, verbatim +/// non-disk). `C:` and `\\?\C:` agree; Windows compares case-insensitively. +fn lexical_components(path: &Path) -> Option> { + use std::path::{Component, Prefix}; + if !path.is_absolute() { + return None; + } + let mut out: Vec = Vec::new(); + // Prefix + root: `/..` is `/`, so `..` never pops below them. + let mut anchor = 0; + for component in path.components() { + match component { + Component::Prefix(prefix) => match prefix.kind() { + Prefix::Disk(drive) | Prefix::VerbatimDisk(drive) => { + out.push(format!("{}:", drive.to_ascii_uppercase() as char).into()); + anchor = out.len(); + } + _ => return None, + }, + Component::RootDir => { + out.push(std::path::MAIN_SEPARATOR_STR.into()); + anchor = out.len(); + } + Component::CurDir => {} + Component::ParentDir => { + if out.len() > anchor { + out.pop(); + } + } + Component::Normal(name) => { + #[cfg(windows)] + out.push(name.to_string_lossy().to_lowercase().into()); + #[cfg(not(windows))] + out.push(name.to_os_string()); + } + } + } + Some(out) +} + fn chunk_files(dir: &Path) -> Result, EngineError> { let Ok(entries) = std::fs::read_dir(dir) else { return Ok(Vec::new()); @@ -544,6 +620,35 @@ mod tests { assert!(pending_refs_in("mentions pending://u1/x.png inline only").is_empty()); } + #[test] + fn uploaded_files_of_any_type_read_back_but_other_roots_stay_image_only() { + let dir = tempfile::tempdir().unwrap(); + let workspace = tempfile::tempdir().unwrap(); + let uploads = Uploads::from_root(dir.path()); + for (id, name, bytes) in [ + ("u1", "notes.md", &b"# notes"[..]), + ("u2", "empty.bin", b""), + ] { + uploads.append(id, &BASE64.encode(bytes), Some(0)).unwrap(); + let path = uploads.commit(id, name).unwrap(); + let chunk = uploads.read_chunk(&path, 0, &[]).unwrap(); + assert_eq!(chunk.mime_type, "application/octet-stream"); + assert!(chunk.done); + } + // A non-image in a workspace root is still refused. + let notes = workspace.path().join("notes.md"); + std::fs::write(¬es, "# not an upload").unwrap(); + assert!( + uploads + .read_chunk( + notes.to_str().unwrap(), + 0, + &[workspace.path().to_path_buf()] + ) + .is_err() + ); + } + #[test] fn pending_ref_traversal_cannot_escape_the_jail() { let dir = tempfile::tempdir().unwrap(); @@ -553,6 +658,81 @@ mod tests { assert!(target.starts_with(dir.path())); assert!(!target.to_string_lossy().contains("..")); } + + #[test] + fn network_and_escaping_paths_are_refused_before_touching_the_filesystem() { + let dir = tempfile::tempdir().unwrap(); + let workspace = tempfile::tempdir().unwrap(); + let elsewhere = tempfile::tempdir().unwrap(); + let uploads = Uploads::from_root(dir.path()); + let roots = [workspace.path().to_path_buf()]; + std::fs::write(workspace.path().join("shot.png"), b"png").unwrap(); + std::fs::write(elsewhere.path().join("shot.png"), b"png").unwrap(); + let inside = workspace.path().join("shot.png"); + assert!( + uploads + .read_chunk(inside.to_str().unwrap(), 0, &roots) + .is_ok() + ); + let escape = workspace + .path() + .join("..") + .join(elsewhere.path().file_name().unwrap()) + .join("shot.png"); + let mut paths = vec![ + r"\\host\share\x.png".to_string(), + "//host/share/x.png".into(), + r"\\?\UNC\host\share\x.png".into(), + r"\\.\pipe\x.png".into(), + "file:////host/share/x.png".into(), + "/net/somehost/x.png".into(), + "C:x.png".into(), + "shot.png".into(), + escape.to_string_lossy().into_owned(), + ]; + // A link from outside every root into one canonicalizes inside the + // jail; refusing it proves the lexical check runs before any + // filesystem call on the requested path. + #[cfg(unix)] + { + let link = elsewhere.path().join("link"); + std::os::unix::fs::symlink(workspace.path(), &link).unwrap(); + paths.push(link.join("shot.png").to_string_lossy().into_owned()); + } + for path in paths { + let error = uploads + .read_chunk(&path, 0, &roots) + .expect_err(&format!("accepted {path:?}")); + assert!( + error.to_string().contains("outside the upload cache"), + "{path:?}: {error}" + ); + } + } + + #[test] + fn lexical_components_resolve_dots_and_refuse_non_disk_prefixes() { + let lexical = |path: &str| lexical_components(Path::new(path)); + assert_eq!(lexical("/a/./b/../c/../../.."), lexical("/")); + assert_eq!(lexical("/a/b/../c.png"), lexical("/a/c.png")); + assert_eq!(lexical("relative/c.png"), None); + #[cfg(windows)] + { + assert_eq!(lexical(r"\\?\c:\A\b.png"), lexical(r"C:\a\B.PNG")); + assert_eq!(lexical(r"C:\..\..\a.png"), lexical(r"C:\a.png")); + for path in [ + r"\\host\share\x.png", + "//host/share/x.png", + r"\\?\UNC\host\share\x.png", + r"\\.\pipe\x.png", + r"\\?\pipe\x.png", + "C:x.png", + r"\x.png", + ] { + assert_eq!(lexical(path), None, "{path}"); + } + } + } } /// Imported generated media: only these sanitized fields may leave the engine. diff --git a/crates/engine/src/voice.rs b/crates/engine/src/voice.rs new file mode 100644 index 0000000000..319a6c6b9e --- /dev/null +++ b/crates/engine/src/voice.rs @@ -0,0 +1,310 @@ +//! Exclusive, local, ephemeral voice ownership. Native media remains in Codex's helper. +pub(crate) mod remote; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; +use tokio::sync::mpsc; +use tokio::time::Instant; +use tokio_util::sync::CancellationToken; +use zeron_harness::codex::realtime::{RealtimeHandle, VoiceCommand}; +use zeron_proto::{SessionStatus, voice::*}; + +#[derive(Clone, Default)] +pub struct VoiceManager { + inner: Arc, +} +#[derive(Default)] +struct Inner { + slot: Mutex>, + attempts: + Mutex>, + generation: AtomicU64, + identity_epoch: AtomicU64, + // A successor waits until the old native stop completes, even after its owner is dropped. + preparation: tokio::sync::Mutex<()>, + native_lifecycle: Arc>, +} +struct Slot { + lease: VoiceLease, + snapshot: VoiceSnapshot, + attach_deadline: Instant, + owner_attached: bool, + events: Option>, + sender: mpsc::Sender, + cancel: CancellationToken, + provider: Option, + remote: Option, +} +pub struct VoiceOwner { + manager: VoiceManager, + lease: VoiceLease, + events: mpsc::Receiver, +} +impl VoiceOwner { + pub async fn next(&mut self) -> Option { + self.events.recv().await + } +} +impl Drop for VoiceOwner { + fn drop(&mut self) { + let _ = self.manager.stop(&self.lease); + } +} + +impl VoiceManager { + pub(crate) async fn preparation(&self) -> tokio::sync::MutexGuard<'_, ()> { + self.inner.preparation.lock().await + } + pub(crate) fn identity_epoch(&self) -> u64 { + self.inner.identity_epoch.load(Ordering::Acquire) + } + #[cfg(test)] + pub(crate) fn reserve(&self, chat: &str) -> Result { + self.reserve_with_deadline(chat, self.identity_epoch(), 5) + } + fn reserve_with_deadline( + &self, + chat: &str, + epoch: u64, + seconds: u64, + ) -> Result { + let mut state = self.inner.slot.lock().unwrap(); + if self.identity_epoch() != epoch { + return Err(VoiceRejection::InvalidLease); + } + if state.is_some() { + return Err(VoiceRejection::Busy); + } + let generation = self.inner.generation.fetch_add(1, Ordering::AcqRel) + 1; + let lease = VoiceLease { + session_id: uuid::Uuid::new_v4().to_string(), + generation, + token: uuid::Uuid::new_v4().to_string(), + }; + let snapshot = VoiceSnapshot { + session_id: lease.session_id.clone(), + chat_id: chat.into(), + generation, + phase: VoicePhase::Starting, + muted: false, + playing: false, + work: VoiceWork::Idle, + reason: None, + voice: None, + voices: Vec::new(), + }; + let (sender, events) = mpsc::channel(32); + let _ = sender.try_send(VoiceEvent::Snapshot { + snapshot: snapshot.clone(), + }); + *state = Some(Slot { + lease: lease.clone(), + snapshot, + attach_deadline: Instant::now() + Duration::from_secs(seconds), + owner_attached: false, + events: Some(events), + sender, + cancel: CancellationToken::new(), + provider: None, + remote: None, + }); + drop(state); + let manager = self.clone(); + let pending = lease.clone(); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_secs(seconds)).await; + manager.expire_unattached(&pending); + }); + Ok(lease) + } + fn expire_unattached(&self, lease: &VoiceLease) { + let expired = self.inner.slot.lock().unwrap().as_ref().is_some_and(|s| { + Self::matches(s, lease) && !s.owner_attached && Instant::now() >= s.attach_deadline + }); + if expired { + let _ = self.stop(lease); + } + } + fn matches(s: &Slot, l: &VoiceLease) -> bool { + s.lease.session_id == l.session_id + && s.lease.generation == l.generation + && s.lease.token == l.token + } + pub fn own(&self, lease: VoiceLease) -> Result { + let mut state = self.inner.slot.lock().unwrap(); + let slot = state + .as_mut() + .filter(|s| Self::matches(s, &lease)) + .ok_or(VoiceRejection::InvalidLease)?; + if slot.owner_attached || Instant::now() >= slot.attach_deadline { + return Err(VoiceRejection::InvalidLease); + } + let events = slot.events.take().ok_or(VoiceRejection::InvalidLease)?; + slot.owner_attached = true; + if let Some(remote) = &mut slot.remote { + remote.last_report = Instant::now(); + } + Ok(VoiceOwner { + manager: self.clone(), + lease, + events, + }) + } + fn publish(&self, lease: &VoiceLease, event: VoiceEvent) -> Result<(), VoiceRejection> { + let result = { + let state = self.inner.slot.lock().unwrap(); + let slot = state + .as_ref() + .filter(|s| Self::matches(s, lease)) + .ok_or(VoiceRejection::InvalidLease)?; + slot.sender + .try_send(event) + .map_err(|_| VoiceRejection::Overflow) + }; + if result.is_err() { + let _ = self.stop(lease); + } + result + } + fn update( + &self, + lease: &VoiceLease, + f: impl FnOnce(&mut VoiceSnapshot), + ) -> Result<(), VoiceRejection> { + let snapshot = { + let mut state = self.inner.slot.lock().unwrap(); + let slot = state + .as_mut() + .filter(|s| Self::matches(s, lease)) + .ok_or(VoiceRejection::InvalidLease)?; + f(&mut slot.snapshot); + slot.snapshot.clone() + }; + self.publish(lease, VoiceEvent::Snapshot { snapshot }) + } + pub fn stop(&self, lease: &VoiceLease) -> Result<(), VoiceRejection> { + self.finish(lease, None) + } + fn finish( + &self, + lease: &VoiceLease, + reason: Option, + ) -> Result<(), VoiceRejection> { + let mut state = self.inner.slot.lock().unwrap(); + let Some(s) = state.as_ref() else { + return Ok(()); + }; + if !Self::matches(s, lease) { + return Err(VoiceRejection::InvalidLease); + } + let s = state.take().unwrap(); + drop(state); + Self::close_slot(s, reason); + Ok(()) + } + fn close_slot(s: Slot, reason: Option) { + s.cancel.cancel(); + if let Some(provider) = s.provider.as_ref() { + provider.abort_voice(s.lease.generation); + } + let _ = s.sender.try_send(VoiceEvent::Closed { + generation: s.lease.generation, + reason, + }); + } + #[cfg(test)] + pub(crate) fn owns_chat(&self, chat: &str) -> bool { + self.inner + .slot + .lock() + .unwrap() + .as_ref() + .is_some_and(|s| s.snapshot.chat_id == chat) + } + pub fn retire(&self) { + let mut state = self.inner.slot.lock().unwrap(); + // The same lock covers reservation and identity changes: a pending probe + // may never create a lease after account/profile retirement. + self.inner.identity_epoch.fetch_add(1, Ordering::AcqRel); + for attempt in self.inner.attempts.lock().unwrap().values() { + attempt.cancel.cancel(); + } + self.inner.generation.fetch_add(1, Ordering::AcqRel); + let old = state.take(); + drop(state); + if let Some(slot) = old { + Self::close_slot(slot, None); + } + } + pub(crate) fn retire_chat(&self, chat: &str) { + let mut state = self.inner.slot.lock().unwrap(); + self.inner.identity_epoch.fetch_add(1, Ordering::AcqRel); + let old = if state.as_ref().is_some_and(|s| s.snapshot.chat_id == chat) { + state.take() + } else { + None + }; + drop(state); + if let Some(slot) = old { + Self::close_slot(slot, None); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn exclusive_owner_drop_and_stale_guards() { + let manager = VoiceManager::default(); + let lease = manager.reserve("chat").unwrap(); + assert_eq!(manager.reserve("chat").unwrap_err(), VoiceRejection::Busy); + let mut bad = lease.clone(); + bad.token = "bad".into(); + assert!(manager.own(bad).is_err()); + let owner = manager.own(lease.clone()).unwrap(); + assert!(manager.own(lease.clone()).is_err()); + drop(owner); + let next = manager.reserve("chat").unwrap(); + assert_eq!(manager.stop(&lease), Err(VoiceRejection::InvalidLease)); + assert!(manager.own(next).is_ok()); + } + #[tokio::test(start_paused = true)] + async fn unattached_owner_watchdog_releases_reservation() { + let manager = VoiceManager::default(); + let old = manager.reserve("chat").unwrap(); + tokio::task::yield_now().await; + tokio::time::advance(Duration::from_secs(6)).await; + tokio::task::yield_now().await; + assert!(manager.own(old.clone()).is_err()); + let next = manager.reserve("next").unwrap(); + assert!(next.generation > old.generation); + let owner = manager.own(next).unwrap(); + tokio::time::advance(Duration::from_secs(6)).await; + tokio::task::yield_now().await; + assert!(manager.owns_chat("next")); + drop(owner); + assert!(!manager.owns_chat("next")); + } + + #[tokio::test] + async fn retirement_rejects_a_start_prepared_under_the_old_identity() { + let manager = VoiceManager::default(); + let epoch = manager.identity_epoch(); + manager.retire(); + assert_eq!( + manager.reserve_with_deadline("chat", epoch, 5).unwrap_err(), + VoiceRejection::InvalidLease + ); + assert!(manager.reserve("chat").is_ok()); + } + + #[tokio::test] + async fn account_retirement_invalidates_lease() { + let manager = VoiceManager::default(); + let lease = manager.reserve("chat").unwrap(); + manager.retire(); + assert!(manager.own(lease).is_err()); + } +} diff --git a/crates/engine/src/voice/remote.rs b/crates/engine/src/voice/remote.rs new file mode 100644 index 0000000000..d69802ae15 --- /dev/null +++ b/crates/engine/src/voice/remote.rs @@ -0,0 +1,495 @@ +//! Client-owned media: host state contains signaling and canonical text only. +use super::*; +use tokio::sync::watch; +use zeron_proto::voice::remote as wire; + +type PreparedResult = Option>; +pub(crate) struct Attempt { + request: Option, + created: Instant, + pub cancel: CancellationToken, + result: watch::Sender, +} +struct Negotiation { + id: wire::AttemptKey, + offer: wire::Sdp, + result: watch::Sender>>, +} +pub(super) struct RemoteSlot { + host: String, + last_sequence: u64, + pub last_report: Instant, + confirm_deadline: Option, + negotiation: Option, + confirmed: bool, + attempted: Arc, +} + +impl VoiceManager { + /// Registers before spawning preparation, so cancel-before-prepare is terminal. + pub(crate) fn begin_attempt( + &self, + request: &wire::Prepare, + ) -> Result<(watch::Receiver, Option), VoiceRejection> { + let mut attempts = self.inner.attempts.lock().unwrap(); + attempts.retain(|_, a| { + a.created.elapsed() < Duration::from_secs(wire::ATTEMPT_TTL_SECS) + || (!a.cancel.is_cancelled() + && a.result.borrow().as_ref().is_none_or(Result::is_ok)) + }); + if let Some(a) = attempts.get(&request.attempt_key) { + if a.cancel.is_cancelled() { + return Err(VoiceRejection::InvalidLease); + } + if a.request + .as_ref() + .is_none_or(|p| p.config != request.config || p.voice != request.voice) + { + return Err(VoiceRejection::Protocol); + } + return Ok((a.result.subscribe(), None)); + } + if attempts.len() >= wire::MAX_ATTEMPTS { + return Err(VoiceRejection::Overflow); + } + let (result, rx) = watch::channel(None); + let cancel = CancellationToken::new(); + attempts.insert( + request.attempt_key.clone(), + Attempt { + request: Some(request.clone()), + created: Instant::now(), + cancel: cancel.clone(), + result, + }, + ); + Ok((rx, Some(cancel))) + } + pub(crate) fn complete_attempt( + &self, + key: &wire::AttemptKey, + result: Result, + ) { + let mut attempts = self.inner.attempts.lock().unwrap(); + if let Some(a) = attempts.get_mut(key) { + let result = if a.cancel.is_cancelled() { + Err(VoiceRejection::InvalidLease) + } else { + result + }; + a.result.send_replace(Some(result)); + } + } + pub(crate) fn cancel_attempt(&self, key: &wire::AttemptKey) -> Result<(), VoiceRejection> { + let lease = { + let mut attempts = self.inner.attempts.lock().unwrap(); + if let Some(a) = attempts.get_mut(key) { + a.cancel.cancel(); + let lease = a + .result + .borrow() + .as_ref() + .and_then(|r| r.as_ref().ok()) + .map(|p| p.lease.voice.clone()); + a.result + .send_replace(Some(Err(VoiceRejection::InvalidLease))); + lease + } else { + attempts.retain(|_, a| { + a.created.elapsed() < Duration::from_secs(wire::ATTEMPT_TTL_SECS) + || (!a.cancel.is_cancelled() + && a.result.borrow().as_ref().is_none_or(Result::is_ok)) + }); + if attempts.len() >= wire::MAX_ATTEMPTS { + return Err(VoiceRejection::Overflow); + } + let (result, _) = watch::channel(Some(Err(VoiceRejection::InvalidLease))); + let cancel = CancellationToken::new(); + cancel.cancel(); + attempts.insert( + key.clone(), + Attempt { + request: None, + created: Instant::now(), + cancel, + result, + }, + ); + None + } + }; + if let Some(lease) = lease { + let _ = self.stop(&lease); + } + Ok(()) + } + pub(crate) fn busy(&self) -> bool { + self.inner.slot.lock().unwrap().is_some() + } + fn remote_matches(s: &Slot, lease: &wire::Lease) -> bool { + Self::matches(s, &lease.voice) + && s.remote + .as_ref() + .is_some_and(|r| r.host == lease.host_device_id) + } + pub(crate) fn validate_remote(&self, lease: &wire::Lease) -> Result<(), VoiceRejection> { + self.inner + .slot + .lock() + .unwrap() + .as_ref() + .filter(|s| Self::remote_matches(s, lease)) + .map(|_| ()) + .ok_or(VoiceRejection::InvalidLease) + } + pub(crate) fn own_remote(&self, lease: wire::Lease) -> Result { + self.validate_remote(&lease)?; + self.own(lease.voice) + } + pub(crate) fn stop_remote(&self, lease: &wire::Lease) -> Result<(), VoiceRejection> { + // An old authenticated lease is harmless once retired; never stop a successor. + if self.validate_remote(lease).is_ok() { + self.stop(&lease.voice)?; + } + Ok(()) + } + pub(crate) fn report_remote(&self, p: &wire::Report) -> Result { + let mut state = self.inner.slot.lock().unwrap(); + let s = state + .as_mut() + .filter(|s| Self::remote_matches(s, &p.lease) && s.owner_attached) + .ok_or(VoiceRejection::InvalidLease)?; + let r = s.remote.as_mut().unwrap(); + if p.sequence <= r.last_sequence { + return Ok(wire::Ack { + sequence: r.last_sequence, + }); + } + r.last_sequence = p.sequence; + r.last_report = Instant::now(); + s.snapshot.muted = p.muted; + let ack = wire::Ack { + sequence: p.sequence, + }; + drop(state); + if p.state != wire::MediaState::Ready { + self.stop(&p.lease.voice)?; + } + Ok(ack) + } + pub(crate) fn confirm_remote( + &self, + p: &wire::Confirm, + ) -> Result { + let snapshot = { + let mut state = self.inner.slot.lock().unwrap(); + let s = state + .as_mut() + .filter(|s| Self::remote_matches(s, &p.lease) && s.owner_attached) + .ok_or(VoiceRejection::InvalidLease)?; + let r = s.remote.as_mut().unwrap(); + let n = r + .negotiation + .as_ref() + .filter(|n| n.id == p.negotiation_id) + .ok_or(VoiceRejection::Protocol)?; + if !matches!(&*n.result.borrow(), Some(Ok(_))) { + return Err(VoiceRejection::Protocol); + } + if r.confirm_deadline.is_some_and(|d| Instant::now() >= d) { + return Err(VoiceRejection::InvalidLease); + } + if !r.confirmed { + s.snapshot.muted = p.muted; + } + r.confirmed = true; + r.confirm_deadline = None; + s.snapshot.phase = VoicePhase::Active; + s.snapshot.clone() + }; + self.publish( + &p.lease.voice, + VoiceEvent::Snapshot { + snapshot: snapshot.clone(), + }, + )?; + Ok(snapshot) + } + pub(crate) async fn negotiate_remote( + &self, + p: wire::Negotiate, + ) -> Result { + let mut rx = { + let mut state = self.inner.slot.lock().unwrap(); + let s = state + .as_mut() + .filter(|s| Self::remote_matches(s, &p.lease) && s.owner_attached) + .ok_or(VoiceRejection::InvalidLease)?; + let r = s.remote.as_mut().unwrap(); + if let Some(n) = &r.negotiation { + if n.id != p.negotiation_id || n.offer != p.offer { + return Err(VoiceRejection::Protocol); + } + n.result.subscribe() + } else { + let (result, rx) = watch::channel(None); + r.negotiation = Some(Negotiation { + id: p.negotiation_id.clone(), + offer: p.offer.clone(), + result: result.clone(), + }); + r.attempted.store(true, Ordering::Release); + let bridge = s.provider.clone().ok_or(VoiceRejection::Protocol)?; + let voice = s.snapshot.voice.clone(); + let cancel = s.cancel.clone(); + let manager = self.clone(); + let p = p.clone(); + tokio::spawn(async move { + let operation = async { + let (reply, answer) = tokio::sync::oneshot::channel(); + bridge + .commands + .send(VoiceCommand::External { + voice, + session_id: p.lease.voice.session_id.clone(), + generation: p.lease.voice.generation, + offer: p.offer, + reply, + }) + .await + .map_err(|_| VoiceRejection::Protocol)?; + let answer = answer.await.map_err(|_| VoiceRejection::Protocol)??; + manager.validate_remote(&p.lease)?; + { + let mut state = manager.inner.slot.lock().unwrap(); + if let Some(r) = state + .as_mut() + .filter(|s| Self::remote_matches(s, &p.lease)) + .and_then(|s| s.remote.as_mut()) + { + r.confirm_deadline = Some(Instant::now() + Duration::from_secs(30)); + } + } + Ok(wire::Negotiated { + negotiation_id: p.negotiation_id, + answer, + }) + }; + let outcome = tokio::select! {biased; + _=cancel.cancelled()=>Err(VoiceRejection::InvalidLease), + r=tokio::time::timeout(Duration::from_secs(90),operation)=>r.unwrap_or(Err(VoiceRejection::Protocol)), + }; + if outcome.is_err() { + let _ = manager.finish(&p.lease.voice, outcome.as_ref().err().copied()); + } + result.send_replace(Some(outcome)); + }); + rx + } + }; + loop { + if let Some(result) = rx.borrow().clone() { + return result; + } + rx.changed().await.map_err(|_| VoiceRejection::Protocol)?; + } + } + #[allow(clippy::too_many_arguments)] + pub(crate) async fn connect_remote( + &self, + host: String, + chat: String, + voice: Option, + voices: Vec, + bridge: RealtimeHandle, + mut events: tokio::sync::broadcast::Receiver, + active: Arc, + doc: Arc, + sessions: crate::sessions::SessionsEngine, + workspace: crate::workspace_host::WorkspaceHost, + expected: zeron_proto::Chat, + epoch: u64, + attempt: CancellationToken, + ) -> Result { + let lifecycle = self.inner.native_lifecycle.clone().lock_owned().await; + if attempt.is_cancelled() { + return Err(VoiceRejection::InvalidLease); + } + let lease = self.reserve_with_deadline(&chat, epoch, 15)?; + let attempted = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let cancel = { + let mut state = self.inner.slot.lock().unwrap(); + let s = state + .as_mut() + .filter(|s| Self::matches(s, &lease)) + .ok_or(VoiceRejection::InvalidLease)?; + s.remote = Some(RemoteSlot { + host: host.clone(), + last_sequence: 0, + last_report: Instant::now(), + confirm_deadline: None, + negotiation: None, + confirmed: false, + attempted: attempted.clone(), + }); + s.provider = Some(bridge.clone()); + s.snapshot.voice = voice; + s.snapshot.voices = voices.clone(); + s.cancel.clone() + }; + let prepared = wire::Prepared { + lease: wire::Lease { + host_device_id: host, + voice: lease.clone(), + }, + chat_id: chat.clone(), + voices, + heartbeat_seconds: wire::HEARTBEAT_SECS, + lease_seconds: wire::LEASE_SECS, + }; + let manager = self.clone(); + let call = active.call(); + tokio::spawn(async move { + let _call = call; + let _lifecycle = lifecycle; + let mut ticker = tokio::time::interval(Duration::from_millis(250)); + let mut statuses = sessions.watch_sessions(); + let result=async {loop {tokio::select!{biased; + _=cancel.cancelled()=>return Ok(()), + _=attempt.cancelled()=>return Err(VoiceRejection::InvalidLease), + _=ticker.tick()=>{ + if bridge.invalidated(){return Err(VoiceRejection::InvalidLease);} + if workspace.chat(&chat).ok().flatten().is_none_or(|c|c.device_id!=expected.device_id || c.config!=expected.config || c.cwd!=expected.cwd){return Err(VoiceRejection::InvalidLease);} + let state=manager.inner.slot.lock().unwrap(); + let s=state.as_ref().filter(|s|Self::matches(s,&lease)).ok_or(VoiceRejection::InvalidLease)?; + let r=s.remote.as_ref().unwrap(); + if (s.owner_attached && r.last_report.elapsed()>=Duration::from_secs(wire::LEASE_SECS)) || r.confirm_deadline.is_some_and(|d|Instant::now()>=d){return Err(VoiceRejection::InvalidLease);} + }, + changed=statuses.changed()=>{ + changed.map_err(|_|VoiceRejection::Protocol)?; + let work=match sessions.session_status(&chat).map(|s|s.status){Some(SessionStatus::Working)=>VoiceWork::Working,Some(SessionStatus::AwaitingInput)=>VoiceWork::AwaitingInput,_=>VoiceWork::Idle}; + manager.update(&lease,|s|s.work=work)?; + }, + event=events.recv()=>{ + let event=event.map_err(|_|VoiceRejection::Overflow)?; + match &event { + VoiceEvent::Final{transcript}=>{ + if transcript.session_id!=lease.session_id{continue;} + if doc.commit_voice(transcript).map_err(|_|VoiceRejection::Protocol)?.is_some(){workspace.note_message(&chat,&transcript.text);} + }, + VoiceEvent::Partial{generation,..} | VoiceEvent::InvalidatePlayout{generation,..} if *generation!=lease.generation=>continue, + VoiceEvent::Closed{generation,reason}=>{if *generation!=lease.generation{continue;}return reason.map_or(Ok(()),Err);}, + VoiceEvent::Audio{..} | VoiceEvent::Levels{..}=>return Err(VoiceRejection::Protocol), + _=>{}, + } + manager.publish(&lease,event)?; + } + }}}.await; + bridge.abort_voice(lease.generation); + let _ = bridge.stop().await; + let _ = manager.finish(&lease, result.err()); + // A terminal attempt cannot replay a successful but dead lease. + attempt.cancel(); + let empty = doc.watch_messages().borrow().entries.is_empty(); + if !attempted.load(Ordering::Acquire) && empty { + let _ = sessions.interrupt(&chat).await; + let _ = workspace.delete_chat(&chat); + } + }); + Ok(prepared) + } +} + +#[cfg(test)] +mod tests { + use super::*; + fn request() -> wire::Prepare { + wire::Prepare { + attempt_key: wire::AttemptKey::new(), + config: serde_json::from_value( + serde_json::json!({"harness":"codex","sandbox":"danger-full-access"}), + ) + .unwrap(), + voice: None, + } + } + #[tokio::test] + async fn cancel_before_prepare_and_duplicate_payloads_are_terminal() { + let m = VoiceManager::default(); + let p = request(); + m.cancel_attempt(&p.attempt_key).unwrap(); + assert!(matches!( + m.begin_attempt(&p), + Err(VoiceRejection::InvalidLease) + )); + let mut p = request(); + let (_, first) = m.begin_attempt(&p).unwrap(); + assert!(first.is_some()); + let (_, duplicate) = m.begin_attempt(&p).unwrap(); + assert!(duplicate.is_none()); + p.voice = Some("different".into()); + assert!(matches!(m.begin_attempt(&p), Err(VoiceRejection::Protocol))); + } + #[tokio::test] + async fn bounded_attempt_registry_never_evicts_live_preparations() { + let m = VoiceManager::default(); + for _ in 0..wire::MAX_ATTEMPTS { + m.begin_attempt(&request()).unwrap(); + } + assert!(matches!( + m.begin_attempt(&request()), + Err(VoiceRejection::Overflow) + )); + } + #[tokio::test] + async fn reports_require_owner_and_sequence_and_old_stop_is_harmless() { + let m = VoiceManager::default(); + let lease = m.reserve("chat").unwrap(); + { + let mut state = m.inner.slot.lock().unwrap(); + state.as_mut().unwrap().remote = Some(RemoteSlot { + host: "host".into(), + last_sequence: 0, + last_report: Instant::now(), + confirm_deadline: None, + negotiation: None, + confirmed: false, + attempted: Arc::default(), + }); + } + let lease = wire::Lease { + host_device_id: "host".into(), + voice: lease, + }; + let mut p = wire::Report { + lease: lease.clone(), + sequence: 2, + muted: true, + state: wire::MediaState::Ready, + }; + assert_eq!( + m.report_remote(&p).unwrap_err(), + VoiceRejection::InvalidLease + ); + let owner = m.own_remote(lease.clone()).unwrap(); + assert_eq!(m.report_remote(&p).unwrap().sequence, 2); + p.sequence = 1; + p.muted = false; + assert_eq!(m.report_remote(&p).unwrap().sequence, 2); + assert!( + m.inner + .slot + .lock() + .unwrap() + .as_ref() + .unwrap() + .snapshot + .muted + ); + drop(owner); + let successor = m.reserve("next").unwrap(); + m.stop_remote(&lease).unwrap(); + assert!(m.owns_chat("next")); + m.stop(&successor).unwrap(); + } +} diff --git a/crates/engine/src/workspace_files.rs b/crates/engine/src/workspace_files.rs index 0eebf92c15..fd89a7d8ac 100644 --- a/crates/engine/src/workspace_files.rs +++ b/crates/engine/src/workspace_files.rs @@ -26,6 +26,8 @@ use zeron_rpc::RpcError; use crate::{Repos, WorkspaceHost}; +mod mutations; + const MAX_RELATIVE_PATH_BYTES: usize = 4096; const MAX_RELATIVE_PATH_COMPONENTS: usize = 256; const MAX_ABSOLUTE_PATH_BYTES: usize = 16 * 1024; @@ -52,6 +54,7 @@ struct WorkspaceFilesInner { repos: Repos, workspace: WorkspaceHost, device_id: String, + mutation_gates: Mutex>>>, write_locks: Mutex>>>, watches: Mutex>>, cancel: CancellationToken, @@ -242,6 +245,7 @@ impl WorkspaceFiles { repos, workspace, device_id: device_id.into(), + mutation_gates: Mutex::new(HashMap::new()), write_locks: Mutex::new(HashMap::new()), watches: Mutex::new(HashMap::new()), cancel: CancellationToken::new(), @@ -392,7 +396,14 @@ impl WorkspaceFiles { .await .map_err(|error| WorkspaceFilesError::Io(format!("directory worker failed: {error}")))?; cancel_on_drop.disarm(); - result + result.map(|mut page| { + page.checkout_id = Some(workspace.checkout_id); + page.mutation_capabilities = Some(zeron_proto::WorkspaceMutationCapabilities { + move_entry: cfg!(any(target_os = "linux", target_os = "macos", windows)), + delete_entry: true, + }); + page + }) } pub async fn search( @@ -498,6 +509,15 @@ impl WorkspaceFiles { .into(), )); } + let mutation_guard = self + .mutation_gate(&workspace.checkout_id) + .read_owned() + .await; + if self.resolve_target(&request.target).await? != workspace { + return Err(WorkspaceFilesError::Authorization( + "Workspace changed while waiting to save".into(), + )); + } let relative = WorkspaceRelativePath::file(&request.path)?; let key = WorkspaceFileKey { checkout_id: workspace.checkout_id.clone(), @@ -519,6 +539,7 @@ impl WorkspaceFiles { let cancel_on_drop = CancelOnDrop::new(cancel.clone()); let expected_hash = request.expected_content_hash; let result = tokio::task::spawn_blocking(move || { + let _mutation_guard = mutation_guard; let _write_guard = write_guard; write_file_blocking(&workspace.root, &relative, &expected_hash, &bytes, &cancel) }) @@ -883,6 +904,7 @@ fn normalize_watch_events( changes.insert( path.clone(), WorkspaceFileChange { + operation_id: None, kind: WorkspaceFileChangeKind::Modified, path, old_path: None, @@ -893,6 +915,7 @@ fn normalize_watch_events( changes.insert( path.clone(), WorkspaceFileChange { + operation_id: None, kind: WorkspaceFileChangeKind::Renamed, path, old_path: Some(old_path), @@ -919,6 +942,7 @@ fn normalize_watch_events( continue; }; let incoming = WorkspaceFileChange { + operation_id: None, kind, path: path.clone(), old_path: None, @@ -1091,6 +1115,8 @@ fn list_directory_blocking( .as_ref() .is_some_and(|visible| !visible.contains(&path)); entries.push(WorkspaceEntry { + mutation_revision: (!mutations::is_link(&metadata)) + .then(|| mutations::revision(&metadata)), name: entry.file_name().to_string_lossy().into_owned(), path, kind, @@ -1144,6 +1170,8 @@ fn list_directory_blocking( }) }); Ok(WorkspaceDirectoryPage { + checkout_id: None, + mutation_capabilities: None, directory: directory.wire_path(), entries: page_entries, next_cursor, @@ -1527,7 +1555,7 @@ fn checked_file_metadata( WorkspaceFilesError::Io(error.to_string()) } })?; - if metadata.file_type().is_symlink() { + if mutations::is_link(&metadata) { let message = if index + 1 == components.len() { "path is a symlink" } else { @@ -1986,6 +2014,8 @@ fn checked_directory( root: &Path, directory: &WorkspaceRelativePath, ) -> Result { + let canonical_root = + std::fs::canonicalize(root).map_err(|error| WorkspaceFilesError::Io(error.to_string()))?; let mut current = root.to_path_buf(); for component in directory.as_path().components() { let Component::Normal(component) = component else { @@ -1994,7 +2024,7 @@ fn checked_directory( current.push(component); let metadata = std::fs::symlink_metadata(¤t) .map_err(|error| WorkspaceFilesError::Io(error.to_string()))?; - if metadata.file_type().is_symlink() { + if mutations::is_link(&metadata) { return Err(WorkspaceFilesError::Unsupported( "symlink directories cannot be traversed".into(), )); @@ -2007,7 +2037,7 @@ fn checked_directory( } let canonical = std::fs::canonicalize(¤t) .map_err(|error| WorkspaceFilesError::Io(error.to_string()))?; - if !canonical.starts_with(root) { + if !canonical.starts_with(&canonical_root) { return Err(WorkspaceFilesError::Authorization( "directory escaped workspace".into(), )); @@ -2145,6 +2175,26 @@ mod tests { AtomicBool::new(false) } + #[test] + fn checked_directory_accepts_the_workspace_root_and_its_child_after_canonicalization() { + let root = tempfile::tempdir().unwrap(); + std::fs::create_dir(root.path().join("child")).unwrap(); + let canonical_root = std::fs::canonicalize(root.path()).unwrap(); + + assert_eq!( + checked_directory(root.path(), &WorkspaceRelativePath::directory("").unwrap()).unwrap(), + canonical_root + ); + assert_eq!( + checked_directory( + root.path(), + &WorkspaceRelativePath::directory("child").unwrap() + ) + .unwrap(), + canonical_root.join("child") + ); + } + #[test] fn relative_paths_preserve_normal_and_unicode_components() { let path = WorkspaceRelativePath::file("src/日本語/emoji-🛰️.rs").unwrap(); diff --git a/crates/engine/src/workspace_files/mutations.rs b/crates/engine/src/workspace_files/mutations.rs new file mode 100644 index 0000000000..6b3174fc7d --- /dev/null +++ b/crates/engine/src/workspace_files/mutations.rs @@ -0,0 +1,735 @@ +//! Structural mutations execute on the workspace host, serialized against saves. +use super::*; +use zeron_proto::{ + MoveWorkspaceEntryRequest, WorkspaceMutationOutcome, WorkspaceMutationRejection as Reason, +}; + +type MutationResult = Result; + +impl WorkspaceFiles { + pub async fn delete_entry( + &self, + request: zeron_proto::DeleteWorkspaceEntryRequest, + ) -> Result { + let workspace = self.resolve_target(&request.target).await?; + let gate = self + .mutation_gate(&workspace.checkout_id) + .write_owned() + .await; + if request.expected_checkout_id.is_empty() + || self.resolve_target(&request.target).await? != workspace + || request.expected_checkout_id != workspace.checkout_id + { + return Ok(rejected( + request.operation_id, + Reason::WorkspaceChanged, + "Workspace changed; refresh before deleting", + )); + } + let cancel = Arc::new(AtomicBool::new(false)); + let cancel_on_drop = CancelOnDrop::new(cancel.clone()); + let owner = self.clone(); + let result = tokio::task::spawn_blocking(move || { + let _gate = gate; + let result = match delete_blocking(&workspace, &request, &cancel) { + Ok(()) => WorkspaceMutationOutcome::Applied { + operation_id: request.operation_id.clone(), + checkout_id: workspace.checkout_id.clone(), + change: WorkspaceFileChange { + operation_id: Some(request.operation_id), + kind: WorkspaceFileChangeKind::Removed, + path: request.path, + old_path: None, + }, + entry: None, + }, + Err((reason, message)) => rejected(request.operation_id, reason, &message), + }; + owner.publish_mutation(&workspace.checkout_id, &result); + result + }) + .await + .map_err(|e| WorkspaceFilesError::Io(format!("delete worker failed: {e}")))?; + cancel_on_drop.disarm(); + Ok(result) + } + + fn publish_mutation(&self, checkout: &str, result: &WorkspaceMutationOutcome) { + if let Some(watch) = lock(&self.inner.watches).get(checkout).cloned() { + match result { + WorkspaceMutationOutcome::Applied { change, .. } => { + watch.publish(false, vec![change.clone()]) + } + WorkspaceMutationOutcome::Rejected { + reason: Reason::PartialFailure, + .. + } => watch.publish(true, vec![]), + _ => {} + } + } + } + + pub(super) fn mutation_gate(&self, checkout: &str) -> Arc> { + let mut gates = lock(&self.inner.mutation_gates); + gates.retain(|_, gate| gate.strong_count() > 0); + if let Some(gate) = gates.get(checkout).and_then(Weak::upgrade) { + return gate; + } + let gate = Arc::new(tokio::sync::RwLock::new(())); + gates.insert(checkout.into(), Arc::downgrade(&gate)); + gate + } + + pub async fn move_entry( + &self, + request: MoveWorkspaceEntryRequest, + ) -> Result { + let workspace = self.resolve_target(&request.target).await?; + let gate = self + .mutation_gate(&workspace.checkout_id) + .write_owned() + .await; + let current = self.resolve_target(&request.target).await?; + if request.expected_checkout_id.is_empty() + || current != workspace + || request.expected_checkout_id != workspace.checkout_id + { + return Ok(rejected( + request.operation_id, + Reason::WorkspaceChanged, + "Workspace changed; refresh before moving", + )); + } + let cancel = Arc::new(AtomicBool::new(false)); + let cancel_on_drop = CancelOnDrop::new(cancel.clone()); + let owner = self.clone(); + let result = tokio::task::spawn_blocking(move || { + let _gate = gate; + let result = match move_blocking(&workspace, &request, &cancel) { + Ok(entry) => WorkspaceMutationOutcome::Applied { + operation_id: request.operation_id.clone(), + checkout_id: workspace.checkout_id.clone(), + change: WorkspaceFileChange { + operation_id: Some(request.operation_id), + kind: WorkspaceFileChangeKind::Renamed, + path: entry.path.clone(), + old_path: Some(request.source_path), + }, + entry: Some(entry), + }, + Err((reason, message)) => rejected(request.operation_id, reason, &message), + }; + owner.publish_mutation(&workspace.checkout_id, &result); + result + }) + .await + .map_err(|e| WorkspaceFilesError::Io(format!("move worker failed: {e}")))?; + cancel_on_drop.disarm(); + Ok(result) + } +} + +fn delete_blocking( + workspace: &ResolvedWorkspace, + request: &zeron_proto::DeleteWorkspaceEntryRequest, + cancel: &AtomicBool, +) -> MutationResult<()> { + if request.operation_id.is_empty() || request.operation_id.len() > 128 { + return Err((Reason::InvalidPath, "Invalid operation identity".into())); + } + let (relative, metadata) = source( + &workspace.root, + &request.path, + &request.expected_source_revision, + request.expected_kind, + )?; + if metadata.is_dir() && !request.recursive { + return Err(( + Reason::InvalidDestination, + "Deleting a folder requires recursive confirmation".into(), + )); + } + if cancel.load(Ordering::Acquire) { + return Err((Reason::Busy, "Delete cancelled before execution".into())); + } + let target = workspace.root.join(relative.as_path()); + if metadata.is_dir() { + // std::fs::remove_dir_all does not follow symbolic links and uses platform + // handle-relative traversal. A failed recursive delete may have removed children. + std::fs::remove_dir_all(target).map_err(|e| { + ( + Reason::PartialFailure, + format!("Folder deletion did not complete; refresh to see remaining files: {e}"), + ) + }) + } else { + std::fs::remove_file(target).map_err(io_error) + } +} + +fn rejected(operation_id: String, reason: Reason, message: &str) -> WorkspaceMutationOutcome { + WorkspaceMutationOutcome::Rejected { + operation_id, + reason, + message: message.into(), + } +} + +fn domain_error(error: WorkspaceFilesError) -> (Reason, String) { + let reason = match &error { + WorkspaceFilesError::BadParams(_) => Reason::InvalidPath, + WorkspaceFilesError::Authorization(_) => Reason::WorkspaceChanged, + WorkspaceFilesError::NotFound(_) => Reason::SourceMissing, + WorkspaceFilesError::Unsupported(_) => Reason::Unsupported, + WorkspaceFilesError::Io(_) => Reason::InvalidDestination, + }; + (reason, error.to_string()) +} + +fn io_error(error: std::io::Error) -> (Reason, String) { + use std::io::ErrorKind; + ( + match error.kind() { + ErrorKind::AlreadyExists => Reason::DestinationExists, + ErrorKind::NotFound => Reason::SourceMissing, + ErrorKind::PermissionDenied => Reason::PermissionDenied, + ErrorKind::Unsupported | ErrorKind::CrossesDevices => Reason::Unsupported, + _ => Reason::InvalidDestination, + }, + error.to_string(), + ) +} + +/// Metadata revision is deliberately cheap; directory revisions are not recursive snapshots. +pub(super) fn revision(metadata: &std::fs::Metadata) -> String { + let mut hash = Sha256::new(); + hash.update(format!( + "{:?}:{:?}:{:?}:{}:{}", + metadata.file_type(), + metadata.modified().ok(), + metadata.created().ok(), + metadata.len(), + metadata.permissions().readonly() + )); + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + hash.update(format!( + ":{}:{}:{}:{}", + metadata.dev(), + metadata.ino(), + metadata.ctime(), + metadata.ctime_nsec() + )); + } + hex(&hash.finalize()) +} + +pub(super) fn is_link(metadata: &std::fs::Metadata) -> bool { + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + return metadata.file_attributes() & 0x400 != 0; // FILE_ATTRIBUTE_REPARSE_POINT, includes junctions. + } + #[cfg(not(windows))] + { + metadata.file_type().is_symlink() + } +} + +fn source( + root: &Path, + path: &str, + expected: &str, + kind: WorkspaceEntryKind, +) -> MutationResult<(WorkspaceRelativePath, std::fs::Metadata)> { + let relative = WorkspaceRelativePath::file(path).map_err(domain_error)?; + // Require canonical wire spelling so identity/deduplication is unambiguous. + if relative.wire_path() != path { + return Err(( + Reason::InvalidPath, + "Path must use canonical workspace-relative spelling".into(), + )); + } + let parent = WorkspaceRelativePath(relative.as_path().parent().unwrap_or(Path::new("")).into()); + checked_directory(root, &parent).map_err(domain_error)?; + let metadata = std::fs::symlink_metadata(root.join(relative.as_path())).map_err(io_error)?; + if is_link(&metadata) || !(metadata.is_file() || metadata.is_dir()) { + return Err(( + Reason::Unsupported, + "Links and special files cannot be mutated".into(), + )); + } + let actual_kind = if metadata.is_dir() { + WorkspaceEntryKind::Directory + } else { + WorkspaceEntryKind::File + }; + if actual_kind != kind || expected.is_empty() || revision(&metadata) != expected { + return Err(( + Reason::SourceChanged, + "Entry changed; refresh before trying again".into(), + )); + } + Ok((relative, metadata)) +} + +fn move_blocking( + workspace: &ResolvedWorkspace, + request: &MoveWorkspaceEntryRequest, + cancel: &AtomicBool, +) -> MutationResult { + if request.operation_id.is_empty() || request.operation_id.len() > 128 { + return Err((Reason::InvalidPath, "Invalid operation identity".into())); + } + let (source, metadata) = source( + &workspace.root, + &request.source_path, + &request.expected_source_revision, + request.expected_kind, + )?; + let destination = + WorkspaceRelativePath::file(&request.destination_path).map_err(domain_error)?; + if destination.wire_path() != request.destination_path { + return Err((Reason::InvalidPath, "Invalid destination spelling".into())); + } + if destination == source + || (metadata.is_dir() && destination.as_path().starts_with(source.as_path())) + { + return Err(( + Reason::InvalidDestination, + "Cannot move an entry into itself".into(), + )); + } + let parent = WorkspaceRelativePath( + destination + .as_path() + .parent() + .unwrap_or(Path::new("")) + .into(), + ); + checked_directory(&workspace.root, &parent).map_err(domain_error)?; + if cancel.load(Ordering::Acquire) { + return Err((Reason::Busy, "Move cancelled before execution".into())); + } + move_entry_no_replace(&workspace.root, source.as_path(), destination.as_path())?; + let target = workspace.root.join(destination.as_path()); + let updated = std::fs::symlink_metadata(&target).unwrap_or(metadata); + Ok(WorkspaceEntry { + path: destination.wire_path(), + name: target.file_name().unwrap().to_string_lossy().into_owned(), + kind: request.expected_kind, + size: updated.is_file().then_some(updated.len()), + modified_at: updated.modified().ok().map(chrono::DateTime::from), + ignored: false, + read_only: !updated.is_file(), + mutation_revision: Some(revision(&updated)), + }) +} + +/// Case-insensitive filesystems may reject even a spelling-only rename under +/// NOREPLACE. Stage that single directory entry, never a second hard link. +fn move_entry_no_replace(root: &Path, source: &Path, destination: &Path) -> MutationResult<()> { + match move_no_replace(root, source, destination) { + Ok(()) => Ok(()), + Err(error) => { + #[cfg(unix)] + if error.kind() == std::io::ErrorKind::AlreadyExists + && is_case_alias(root, source, destination) + { + let temporary = source + .parent() + .unwrap_or(Path::new("")) + .join(format!(".zeron-save-{}.tmp", uuid::Uuid::new_v4())); + move_no_replace(root, source, &temporary).map_err(io_error)?; + if let Err(error) = move_no_replace(root, &temporary, destination) { + if let Err(rollback) = move_no_replace(root, &temporary, source) { + return Err(( + Reason::PartialFailure, + format!( + "Rename could not complete ({error}) or restore its original name ({rollback}); recover the entry at {}", + temporary.display() + ), + )); + } + return Err(io_error(error)); + } + return Ok(()); + } + Err(io_error(error)) + } + } +} + +#[cfg(unix)] +fn is_case_alias(root: &Path, source: &Path, destination: &Path) -> bool { + use std::os::unix::fs::MetadataExt; + if source.parent() != destination.parent() { + return false; + } + let (Some(from), Some(to)) = (source.file_name(), destination.file_name()) else { + return false; + }; + if from == to || from.to_string_lossy().to_lowercase() != to.to_string_lossy().to_lowercase() { + return false; + } + let (Ok(a), Ok(b)) = ( + std::fs::symlink_metadata(root.join(source)), + std::fs::symlink_metadata(root.join(destination)), + ) else { + return false; + }; + if a.dev() != b.dev() || a.ino() != b.ino() || is_link(&a) || is_link(&b) { + return false; + } + // Distinct hard-link names are a real collision, not an alias in a + // case-insensitive directory. A failed enumeration never authorizes a move. + let Ok(entries) = std::fs::read_dir(root.join(source.parent().unwrap_or(Path::new("")))) else { + return false; + }; + for entry in entries { + match entry { + Ok(entry) if entry.file_name() == to => return false, + Err(_) => return false, + _ => {} + } + } + true +} + +/// Open every ancestor without following links, and anchor the native rename to those handles. +#[cfg(unix)] +fn open_parent(root: &Path, relative: &Path) -> std::io::Result { + use std::os::unix::fs::OpenOptionsExt; + use std::os::{ + fd::{AsRawFd, FromRawFd}, + unix::ffi::OsStrExt, + }; + let mut directory = std::fs::OpenOptions::new() + .read(true) + .custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW) + .open(root)?; + for component in relative.parent().unwrap_or(Path::new("")).components() { + let Component::Normal(name) = component else { + return Err(std::io::ErrorKind::InvalidInput.into()); + }; + let name = std::ffi::CString::new(name.as_bytes())?; + // SAFETY: a valid directory descriptor and NUL-terminated single path component. + let fd = unsafe { + libc::openat( + directory.as_raw_fd(), + name.as_ptr(), + libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC, + ) + }; + if fd < 0 { + return Err(std::io::Error::last_os_error()); + } + // SAFETY: openat returned a newly owned descriptor. + directory = unsafe { std::fs::File::from_raw_fd(fd) }; + } + Ok(directory) +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +fn move_no_replace(root: &Path, source: &Path, destination: &Path) -> std::io::Result<()> { + use std::os::{fd::AsRawFd, unix::ffi::OsStrExt}; + let from = open_parent(root, source)?; + let to = open_parent(root, destination)?; + let source = std::ffi::CString::new( + source + .file_name() + .ok_or(std::io::ErrorKind::InvalidInput)? + .as_bytes(), + )?; + let destination = std::ffi::CString::new( + destination + .file_name() + .ok_or(std::io::ErrorKind::InvalidInput)? + .as_bytes(), + )?; + // SAFETY: live parent handles and NUL-terminated basename buffers; replacement is disabled. + #[cfg(target_os = "linux")] + let result = unsafe { + libc::renameat2( + from.as_raw_fd(), + source.as_ptr(), + to.as_raw_fd(), + destination.as_ptr(), + libc::RENAME_NOREPLACE, + ) + }; + #[cfg(target_os = "macos")] + let result = unsafe { + libc::renameatx_np( + from.as_raw_fd(), + source.as_ptr(), + to.as_raw_fd(), + destination.as_ptr(), + libc::RENAME_EXCL, + ) + }; + if result == 0 { + Ok(()) + } else { + Err(std::io::Error::last_os_error()) + } +} + +#[cfg(windows)] +fn move_no_replace(root: &Path, source: &Path, destination: &Path) -> std::io::Result<()> { + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::Storage::FileSystem::{MOVEFILE_WRITE_THROUGH, MoveFileExW}; + let from: Vec = root + .join(source) + .as_os_str() + .encode_wide() + .chain(Some(0)) + .collect(); + let to: Vec = root + .join(destination) + .as_os_str() + .encode_wide() + .chain(Some(0)) + .collect(); + // SAFETY: NUL-terminated paths; neither REPLACE_EXISTING nor COPY_ALLOWED is enabled. + if unsafe { MoveFileExW(from.as_ptr(), to.as_ptr(), MOVEFILE_WRITE_THROUGH) } == 0 { + Err(std::io::Error::last_os_error()) + } else { + Ok(()) + } +} + +#[cfg(not(any(target_os = "linux", target_os = "macos", windows)))] +fn move_no_replace(_: &Path, _: &Path, _: &Path) -> std::io::Result<()> { + Err(std::io::ErrorKind::Unsupported.into()) +} + +#[cfg(test)] +mod tests { + use super::*; + fn request(root: &Path, path: &str, to: &str) -> MoveWorkspaceEntryRequest { + let metadata = std::fs::symlink_metadata(root.join(path)).unwrap(); + MoveWorkspaceEntryRequest { + target: WorkspaceTarget { + chat_id: Some("chat".into()), + space_id: None, + checkout_path: None, + }, + operation_id: "op".into(), + expected_checkout_id: "checkout".into(), + source_path: path.into(), + destination_path: to.into(), + expected_source_revision: revision(&metadata), + expected_kind: if metadata.is_dir() { + WorkspaceEntryKind::Directory + } else { + WorkspaceEntryKind::File + }, + } + } + #[test] + fn simultaneous_moves_never_replace_the_winner() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + std::fs::write(root.join("one"), "one").unwrap(); + std::fs::write(root.join("two"), "two").unwrap(); + let barrier = Arc::new(std::sync::Barrier::new(2)); + let results = std::thread::scope(|scope| { + let a = barrier.clone(); + let first = scope.spawn(move || { + a.wait(); + move_entry_no_replace(root, Path::new("one"), Path::new("winner")) + }); + let b = barrier.clone(); + let second = scope.spawn(move || { + b.wait(); + move_entry_no_replace(root, Path::new("two"), Path::new("winner")) + }); + (first.join().unwrap(), second.join().unwrap()) + }); + assert_ne!(results.0.is_ok(), results.1.is_ok()); + let winner = std::fs::read_to_string(root.join("winner")).unwrap(); + let loser = if winner == "one" { "two" } else { "one" }; + assert_eq!(std::fs::read_to_string(root.join(loser)).unwrap(), loser); + } + #[test] + fn case_only_rename_preserves_contents() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("File.txt"), "keep").unwrap(); + move_entry_no_replace(dir.path(), Path::new("File.txt"), Path::new("file.txt")).unwrap(); + assert_eq!( + std::fs::read_to_string(dir.path().join("file.txt")).unwrap(), + "keep" + ); + let names = std::fs::read_dir(dir.path()) + .unwrap() + .map(|entry| entry.unwrap().file_name()) + .collect::>(); + assert_eq!(names, vec![std::ffi::OsString::from("file.txt")]); + } + #[cfg(unix)] + #[test] + fn case_similar_hard_links_are_still_collisions() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("A"), "keep").unwrap(); + // Only filesystems supporting distinct case names can create this fixture. + if std::fs::hard_link(dir.path().join("A"), dir.path().join("a")).is_err() { + return; + } + assert_eq!( + move_entry_no_replace(dir.path(), Path::new("A"), Path::new("a")) + .unwrap_err() + .0, + Reason::DestinationExists + ); + } + #[test] + fn delete_requires_current_revision_and_explicit_recursion() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + std::fs::create_dir(root.join("folder")).unwrap(); + std::fs::write(root.join("folder/file"), "keep").unwrap(); + let ws = ResolvedWorkspace { + checkout_id: "checkout".into(), + root: root.into(), + }; + let mut req = zeron_proto::DeleteWorkspaceEntryRequest { + target: request(root, "folder", "unused").target, + operation_id: "delete".into(), + expected_checkout_id: "checkout".into(), + path: "folder".into(), + expected_source_revision: revision(&std::fs::metadata(root.join("folder")).unwrap()), + expected_kind: WorkspaceEntryKind::Directory, + recursive: false, + }; + assert!(delete_blocking(&ws, &req, &AtomicBool::new(false)).is_err()); + assert!(root.join("folder/file").exists()); + req.recursive = true; + delete_blocking(&ws, &req, &AtomicBool::new(false)).unwrap(); + assert!(!root.join("folder").exists()); + assert_eq!( + delete_blocking(&ws, &req, &AtomicBool::new(false)) + .unwrap_err() + .0, + Reason::SourceMissing + ); + } + #[cfg(unix)] + #[test] + fn deleting_directory_does_not_follow_links_to_external_contents() { + let dir = tempfile::tempdir().unwrap(); + let outside = tempfile::tempdir().unwrap(); + std::fs::create_dir(dir.path().join("folder")).unwrap(); + std::fs::write(outside.path().join("keep"), "keep").unwrap(); + std::os::unix::fs::symlink(outside.path(), dir.path().join("folder/link")).unwrap(); + let ws = ResolvedWorkspace { + checkout_id: "checkout".into(), + root: dir.path().into(), + }; + let req = zeron_proto::DeleteWorkspaceEntryRequest { + target: request(dir.path(), "folder", "unused").target, + operation_id: "delete".into(), + expected_checkout_id: "checkout".into(), + path: "folder".into(), + expected_source_revision: revision( + &std::fs::metadata(dir.path().join("folder")).unwrap(), + ), + expected_kind: WorkspaceEntryKind::Directory, + recursive: true, + }; + delete_blocking(&ws, &req, &AtomicBool::new(false)).unwrap(); + assert_eq!( + std::fs::read_to_string(outside.path().join("keep")).unwrap(), + "keep" + ); + } + + #[test] + fn moves_subtrees_without_replacement_or_prefix_confusion() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + std::fs::create_dir_all(root.join("a/child")).unwrap(); + std::fs::create_dir(root.join("ab")).unwrap(); + std::fs::write(root.join("a/child/á.txt"), "keep").unwrap(); + let workspace = ResolvedWorkspace { + checkout_id: "checkout".into(), + root: root.into(), + }; + let cancel = AtomicBool::new(false); + assert_eq!( + move_blocking(&workspace, &request(root, "a", "a/child/new"), &cancel) + .unwrap_err() + .0, + Reason::InvalidDestination + ); + assert_eq!( + move_blocking(&workspace, &request(root, "a", "ab"), &cancel) + .unwrap_err() + .0, + Reason::DestinationExists + ); + move_blocking(&workspace, &request(root, "a", "ab/a"), &cancel).unwrap(); + assert_eq!( + std::fs::read_to_string(root.join("ab/a/child/á.txt")).unwrap(), + "keep" + ); + } + #[test] + fn stale_sources_invalid_paths_and_cancel_do_not_mutate() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + std::fs::write(root.join("a"), "old").unwrap(); + let workspace = ResolvedWorkspace { + checkout_id: "checkout".into(), + root: root.into(), + }; + let req = request(root, "a", "b"); + std::fs::write(root.join("a"), "changed").unwrap(); + assert_eq!( + move_blocking(&workspace, &req, &AtomicBool::new(false)) + .unwrap_err() + .0, + Reason::SourceChanged + ); + for to in ["../b", ".git/file", "", "/tmp/outside"] { + assert!( + move_blocking(&workspace, &request(root, "a", to), &AtomicBool::new(false)) + .is_err() + ); + } + assert_eq!( + move_blocking(&workspace, &request(root, "a", "b"), &AtomicBool::new(true)) + .unwrap_err() + .0, + Reason::Busy + ); + assert!(root.join("a").exists()); + assert!(!root.join("b").exists()); + } + #[cfg(unix)] + #[test] + fn links_cannot_be_overwritten_or_traversed() { + let dir = tempfile::tempdir().unwrap(); + let outside = tempfile::tempdir().unwrap(); + let root = dir.path(); + std::fs::write(root.join("a"), "keep").unwrap(); + std::os::unix::fs::symlink(outside.path().join("missing"), root.join("b")).unwrap(); + std::os::unix::fs::symlink(outside.path(), root.join("link")).unwrap(); + let ws = ResolvedWorkspace { + checkout_id: "checkout".into(), + root: root.into(), + }; + assert_eq!( + move_blocking(&ws, &request(root, "a", "b"), &AtomicBool::new(false)) + .unwrap_err() + .0, + Reason::DestinationExists + ); + assert!( + move_blocking(&ws, &request(root, "a", "link/a"), &AtomicBool::new(false)).is_err() + ); + assert!(root.join("a").exists()); + assert!(!outside.path().join("a").exists()); + } +} diff --git a/crates/engine/src/workspace_host.rs b/crates/engine/src/workspace_host.rs index df4329a3dd..f0a9b776b2 100644 --- a/crates/engine/src/workspace_host.rs +++ b/crates/engine/src/workspace_host.rs @@ -71,6 +71,7 @@ const RELAY_PROBE_INTERVAL_MS: u64 = 30_000; const RELAY_PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); /// Debounce window for local snapshot saves after a change. const SNAPSHOT_DEBOUNCE_MS: u64 = 1_000; + /// Initial-join retry backoff (base, cap). A first registry-room join that /// fails must not strand the device offline until an app restart — retry until /// it lands. Jittered so N devices restarting together don't resynchronize @@ -150,6 +151,12 @@ pub struct WorkspaceHostConfig { /// When present, the host joins `/registry/{orgId}/ws`. `None` = fully offline /// (local snapshots only; the registry still drives everything device-side). pub edge: Option, + /// The profile can never attach an edge (`WorkspaceScope::Local`), so + /// registry writes fold straight into the local rows instead of queueing + /// for acks that will never come. Only then: a replica that may attach + /// later must keep its writes as ops — re-seeding folded rows as full + /// upserts would revive rows another device deleted meanwhile. + pub local_only: bool, } struct WorkspaceHostInner { @@ -199,7 +206,8 @@ impl WorkspaceHost { /// Load (or migrate, or init) the registry, upsert this device's row, start /// the change-driven task, and join the edge registry room when configured. pub fn open(store: Arc, config: WorkspaceHostConfig) -> Result { - let mut doc = match store.load_snapshot(REGISTRY_DOC_ID)? { + let stored = store.load_snapshot(REGISTRY_DOC_ID)?; + let mut doc = match stored { Some(bytes) => RegistryDoc::from_bytes(&bytes, &config.device_id) .map_err(|e| EngineError::Other(format!("registry snapshot load failed: {e}")))?, None => { @@ -246,6 +254,12 @@ impl WorkspaceHost { doc } }; + // A Local profile never has an edge to ack a pending batch: fold the + // queue (a pre-fix snapshot's 178k never-acked batches, or this + // boot's migration seeds) into the local rows and keep folding every + // write. Otherwise this re-seeds once if local-only writes exist that + // no server has seen. The boot save below persists the result. + doc.set_local_only(config.local_only); // Destructive-break hygiene: the pre-spaces row stays unreachable. store.delete_snapshot(LEGACY_WORKSPACE_DOC_ID).ok(); @@ -316,7 +330,14 @@ impl WorkspaceHost { // Persist immediately: after this boot the migration source is never // read again, so the registry snapshot must exist even if the process // dies before the first debounced save. - host.inner.save_snapshot(); + if let Err(error) = host.inner.persist_snapshot() { + tracing::warn!(%error, "registry snapshot save failed"); + } + // Reclaim free pages (a bloated store, or the pages a one-time + // registry compaction just freed) off the boot path. The store gates + // the VACUUM on its own thresholds, so ordinary boots only checkpoint. + let store = host.inner.store.clone(); + tokio::task::spawn_blocking(move || store.reclaim_free_space()); host.join_room(); tokio::spawn(workspace_task(Arc::downgrade(&host.inner), changed_rx)); if host.inner.config.edge.is_some() { @@ -350,6 +371,9 @@ impl WorkspaceHost { mut token_changes: Option>, token: Option>, ) { + // A room will ack from here on (the test seam joins edge-less + // hosts): queue writes again, re-seeding any folded local-only ones. + self.mutate(|doc| doc.set_local_only(false)); let org_id = self.inner.config.org_id.clone(); let reg = self.inner.reg.clone(); let device_id = self.inner.config.device_id.clone(); @@ -807,6 +831,7 @@ impl WorkspaceHost { git_detected: false, git_checked_at: None, checkout_id: None, + repository_id: None, created_at: Utc::now(), }; self.mutate(|doc| doc.upsert_space(&space))?; @@ -988,6 +1013,7 @@ impl WorkspaceHost { git_detected, git_checked_at: None, checkout_id: None, + repository_id: None, created_at: Utc::now(), }) })?; @@ -1020,11 +1046,13 @@ impl WorkspaceHost { space_id: &str, detected: bool, checkout_id: Option<&str>, + repository_id: Option<&str>, ) -> Result { match self.read(|doc| doc.space(space_id))? { Some(space) if space.device_id == self.inner.config.device_id => { - Ok(self - .mutate(|doc| doc.set_space_git(space_id, detected, checkout_id, Utc::now()))?) + Ok(self.mutate(|doc| { + doc.set_space_git(space_id, detected, checkout_id, repository_id, Utc::now()) + })?) } Some(space) => { tracing::warn!( @@ -1358,14 +1386,18 @@ impl WorkspaceHostInner { } } - fn persist_snapshot(&self) -> Result<(), EngineError> { + /// Returns the snapshot's size in bytes. + fn persist_snapshot(&self) -> Result { // Keep export and disk write serialized: an older background snapshot // must not overwrite an acknowledged migration's durable snapshot. let doc = lock(&self.reg); let bytes = doc.to_bytes()?; self.store .save_snapshot(REGISTRY_DOC_ID, &bytes) - .map_err(|error| EngineError::Other(format!("registry snapshot save failed: {error}"))) + .map_err(|error| { + EngineError::Other(format!("registry snapshot save failed: {error}")) + })?; + Ok(bytes.len()) } /// Presence heartbeat — a memory-only frame on the room, never a row write. @@ -1727,6 +1759,7 @@ mod tests { org_id: "test-org".into(), user_id: "test-user".into(), edge: None, + local_only: false, }, ) .unwrap(); @@ -1783,6 +1816,7 @@ mod tests { org_id: "test-org".into(), user_id: "test-user".into(), edge: None, + local_only: false, }, ) .unwrap(); @@ -1842,6 +1876,175 @@ mod tests { assert_eq!(*preferences.borrow(), collapsed); } + /// A pre-fix `registry1` snapshot: one never-acked batch per session-row + /// touch. Marking each batch in flight as it is written stops the doc + /// coalescing; `in_flight` is not persisted, so it reloads all-unsent. + fn save_bloated_registry(store: &zeron_sync::DocsStore, touches: usize) -> usize { + use super::*; + + let mut doc = RegistryDoc::new("test-device"); + for i in 0..touches { + doc.upsert_session(&running_session( + Utc::now() + chrono::Duration::milliseconds(i as i64), + )) + .unwrap(); + doc.take_pushable(); + } + assert_eq!(doc.pending_len(), touches); + let bytes = doc.to_bytes().unwrap(); + store.save_snapshot(REGISTRY_DOC_ID, &bytes).unwrap(); + bytes.len() + } + + fn running_session(at: chrono::DateTime) -> zeron_proto::Session { + zeron_proto::Session { + chat_id: "chat-1".into(), + device_id: "test-device".into(), + status: zeron_proto::SessionStatus::Working, + last_completed_turn: None, + started_at: None, + updated_at: at, + running_subagents: 0, + } + } + + #[tokio::test] + async fn local_profile_compacts_a_bloated_registry_and_never_queues() { + use super::*; + + let dir = tempfile::tempdir().unwrap(); + let store = Arc::new(DocsStore::open(dir.path()).unwrap()); + let bloated = save_bloated_registry(&store, 5_000); + let host = WorkspaceHost::open( + store.clone(), + WorkspaceHostConfig { + device_id: "test-device".into(), + device_name: "Test device".into(), + platform: "macos".into(), + org_id: "test-org".into(), + user_id: "test-user".into(), + edge: None, + local_only: true, + }, + ) + .unwrap(); + assert_eq!(host.read(|doc| doc.pending_len()), 0); + assert_eq!(host.read_sessions().unwrap().len(), 1); + // The boot save already persisted the compacted snapshot. + let saved = store.load_snapshot(REGISTRY_DOC_ID).unwrap().unwrap(); + assert!(saved.len() * 100 < bloated, "{} vs {bloated}", saved.len()); + assert_eq!( + RegistryDoc::from_bytes(&saved, "test-device") + .unwrap() + .pending_len(), + 0 + ); + + // The 10 s running-chat heartbeat no longer accumulates anything. + for i in 0..100 { + host.record_session(&running_session(Utc::now() + chrono::Duration::seconds(i))); + } + assert_eq!(host.read(|doc| doc.pending_len()), 0); + } + + #[tokio::test] + async fn synced_profile_coalesces_a_bloated_registry() { + use super::*; + + let dir = tempfile::tempdir().unwrap(); + let store = Arc::new(DocsStore::open(dir.path()).unwrap()); + save_bloated_registry(&store, 5_000); + let host = WorkspaceHost::open( + store.clone(), + WorkspaceHostConfig { + device_id: "test-device".into(), + device_name: "Test device".into(), + platform: "macos".into(), + org_id: "test-org".into(), + user_id: "test-user".into(), + // Unreachable edge: writes queue (and retry) without acks. + edge: Some(EdgeConfig::with_static_token("http://127.0.0.1:1", "test")), + local_only: false, + }, + ) + .unwrap(); + // The session row and this boot's device row: in flight at most once + // plus one coalesced unsent op each. + assert!(host.read(|doc| doc.pending_ops_len()) <= 4); + for i in 0..100 { + host.record_session(&running_session(Utc::now() + chrono::Duration::seconds(i))); + } + assert!(host.read(|doc| doc.pending_ops_len()) <= 4); + let saved = store.load_snapshot(REGISTRY_DOC_ID).unwrap().unwrap(); + assert!( + RegistryDoc::from_bytes(&saved, "test-device") + .unwrap() + .pending_ops_len() + <= 4 + ); + } + + #[tokio::test(flavor = "multi_thread")] + async fn boot_compaction_reclaims_the_freed_store_pages() { + use super::*; + + let dir = tempfile::tempdir().unwrap(); + let on_disk = || { + ["docs.sqlite3", "docs.sqlite3-wal"] + .iter() + .map(|f| std::fs::metadata(dir.path().join(f)).map_or(0, |m| m.len())) + .sum::() + }; + // The store opens (and runs its own maintenance) BEFORE the registry + // shrinks, exactly as at boot. + let store = Arc::new(DocsStore::open(dir.path()).unwrap()); + // ~40 MiB of never-acked writes, built as raw snapshot JSON. + let mut snapshot: serde_json::Value = + serde_json::from_slice(&RegistryDoc::new("test-device").to_bytes().unwrap()).unwrap(); + let preview = "x".repeat(8 * 1024); + snapshot["pending"] = (0..5_000) + .map(|i| { + serde_json::json!({ + "batch": format!("b-{i}"), + "ops": [{ + "kind": "chats", + "id": "chat-1", + "op": "update", + "set": { "lastMessagePreview": preview }, + "hlc": format!("{:013}-000000-test-device", i + 1), + }], + }) + }) + .collect(); + store + .save_snapshot(REGISTRY_DOC_ID, &serde_json::to_vec(&snapshot).unwrap()) + .unwrap(); + let bloated = on_disk(); + assert!(bloated > 40 * 1024 * 1024, "{bloated}"); + + let _host = WorkspaceHost::open( + store.clone(), + WorkspaceHostConfig { + device_id: "test-device".into(), + device_name: "Test device".into(), + platform: "macos".into(), + org_id: "test-org".into(), + user_id: "test-user".into(), + edge: None, + local_only: true, + }, + ) + .unwrap(); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20); + while on_disk() * 4 > bloated { + assert!( + std::time::Instant::now() < deadline, + "store not reclaimed after the boot compaction" + ); + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } + } + #[test] fn boot_repairs_the_legacy_unknown_device_sentinel() { assert_eq!( diff --git a/crates/engine/tests/adapter_chaos_live.rs b/crates/engine/tests/adapter_chaos_live.rs new file mode 100644 index 0000000000..38bd28d6d0 --- /dev/null +++ b/crates/engine/tests/adapter_chaos_live.rs @@ -0,0 +1,1442 @@ +//! Opt-in adapter chaos suite against the REAL agent CLIs: the races and edge +//! shapes where turn-end detection and runtime survival break, each checked +//! against hard invariants rather than happy-path output. +//! +//! ZERON_TEST_HARNESS=claude ZERON_TEST_MODEL=claude-sonnet-5-5 \ +//! ZERON_TEST_OTHER_MODEL=claude-haiku-4-5 ZERON_CHAOS_ROUNDS=3 \ +//! cargo test -p zeron-engine --test adapter_chaos_live -- --ignored --nocapture +//! +//! `ZERON_CHAOS_ONLY=` runs one scenario. Invariants, per chat: +//! - Never "done" early: no agent output after a Done unless a new user turn +//! began first (a steer boundary or a fresh process); no Idle→Working flip +//! the user did not cause; every requested sentinel present at the end. +//! - Never stuck: every scenario settles Idle. +//! - Every routed message is delivered (one steer boundary each). +//! - One process for the chat's life, except after the user's Stop. +//! - Nothing the agent started outlives a Stop. +//! +//! `ZERON_TEST_HARNESS`: claude | codex | cursor | opencode | grok | devin | +//! pi | hermes | antigravity. `restart_durability` covers quitting and +//! force-killing the app mid-turn (same environment variables). +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use zeron_doc::SessionCommandPayload; +use zeron_engine::{EngineCore, HarnessRegistry}; +use zeron_harness::{ + AcpHarness, ClaudeHarness, CodexHarness, CursorHarness, Harness, OpencodeHarness, PiHarness, +}; +use zeron_proto::{ + AgentEvent, ChatConfig, HarnessId, ReasoningLevel, RunRequest, SandboxLevel, SessionStatus, +}; + +struct Suite { + core: EngineCore, + id: HarnessId, + name: String, + model: Option, + other_model: Option, + cwd: String, + dir: tempfile::TempDir, + /// Unique per harness so parallel suites never count each other's work. + sleep_base: u32, +} + +/// What one scenario did and saw. +struct Chat<'a> { + suite: &'a Suite, + id: String, + sends: Arc>>, + /// (when, status, last_completed_turn) at every observed change. + timeline: Arc)>>>, + sentinels: Vec, + /// Every message sent: each must start a steer boundary or a process. + runs_sent: usize, + /// User Stops, and agent deaths this scenario caused: each may cost a + /// process. + stops: usize, + /// The scenario kills the agent: its turn may end errored. + allow_errored: bool, + monitor: tokio::task::JoinHandle<()>, + errors: Vec, +} + +/// The selected harness: its id, a fresh driver, and a probe-marker base +/// unique to it (so parallel suites never count each other's work). +fn harness(name: &str) -> (HarnessId, Arc, u32) { + match name { + "claude" => (HarnessId::ClaudeCode, Arc::new(ClaudeHarness::new()), 270), + "codex" => (HarnessId::Codex, Arc::new(CodexHarness::new()), 370), + "cursor" => (HarnessId::Cursor, Arc::new(CursorHarness::new()), 470), + "opencode" => (HarnessId::Opencode, Arc::new(OpencodeHarness::new()), 570), + "grok" => (HarnessId::Grok, Arc::new(AcpHarness::grok()), 670), + "devin" => (HarnessId::Devin, Arc::new(AcpHarness::devin()), 770), + "pi" => (HarnessId::Pi, Arc::new(PiHarness::new()), 870), + "hermes" => (HarnessId::Hermes, Arc::new(AcpHarness::hermes()), 970), + "antigravity" => ( + HarnessId::Antigravity, + Arc::new(AcpHarness::antigravity()), + 1070, + ), + other => panic!("unknown harness {other}"), + } +} + +fn engine(dir: &std::path::Path, name: &str) -> EngineCore { + let (id, harness, _) = harness(name); + let registry = HarnessRegistry::new(); + registry.register(harness); + EngineCore::assemble(&dir.join("engine"), Arc::new(registry), id, None).unwrap() +} + +impl Suite { + fn new() -> Self { + let name = std::env::var("ZERON_TEST_HARNESS").unwrap_or_else(|_| "claude".into()); + let (id, _, sleep_base) = harness(&name); + let dir = tempfile::tempdir().unwrap(); + let cwd = dir.path().to_str().unwrap().to_owned(); + let core = engine(dir.path(), &name); + core.workspace + .create_space("space", &core.device_id, &cwd, None, false) + .unwrap(); + Self { + core, + id, + name, + model: std::env::var("ZERON_TEST_MODEL").ok(), + other_model: std::env::var("ZERON_TEST_OTHER_MODEL").ok(), + cwd, + dir, + sleep_base, + } + } + + fn request(&self, prompt: &str) -> RunRequest { + RunRequest { + mcp: None, + prompt: prompt.into(), + harness: Some(self.id), + model: self.model.clone(), + reasoning: None, + model_options: Default::default(), + cwd: self.cwd.clone(), + sandbox: SandboxLevel::DangerFullAccess, + auto_approve: true, + attachments: vec![], + worktree: None, + resume: None, + } + } + + /// A probe command: sleeps `secs`, tagged with a marker unique to this + /// suite and slot so it can be found in the process table. + fn probe(&self, slot: u32, secs: u32) -> String { + format!( + "python3 -c 'import time; zq{}=1; time.sleep({secs})'", + self.sleep_base + slot + ) + } + + fn probe_marker(&self, slot: u32) -> String { + format!("zq{}=1", self.sleep_base + slot) + } + + /// Processes still running one of this suite's probe commands. + fn probes_alive(&self) -> usize { + let pattern = format!("zq{}[0-9]=1", self.sleep_base / 10); + std::process::Command::new("pgrep") + .args(["-f", &pattern]) + .output() + .map(|o| { + String::from_utf8_lossy(&o.stdout) + .split_whitespace() + .count() + }) + .unwrap_or(0) + } + + fn kill_probes(&self) { + let pattern = format!("zq{}[0-9]=1", self.sleep_base / 10); + let _ = std::process::Command::new("pkill") + .args(["-f", &pattern]) + .status(); + } + + /// SIGKILL every agent process this test runs, whole trees (an agent + /// crash: OOM, `killall`). Only the current scenario's chat holds one: + /// each scenario retires its chat's runtime when it ends. + fn crash_agents(&self) -> usize { + let mut tree = descendants(std::process::id()); + tree.reverse(); + for pid in &tree { + let _ = std::process::Command::new("kill") + .args(["-9", &pid.to_string()]) + .status(); + } + tree.len() + } + + fn chat(&self, scenario: &str, round: usize) -> Chat<'_> { + let id = format!("{scenario}-{round}"); + self.core + .workspace + .create_chat(&id, Some("space"), None, None, None) + .unwrap(); + self.core + .workspace + .set_chat_config( + &id, + &ChatConfig { + harness: self.id, + model: self.model.clone(), + reasoning: None, + model_options: Default::default(), + sandbox: SandboxLevel::DangerFullAccess, + }, + ) + .unwrap(); + let timeline = Arc::new(Mutex::new(Vec::new())); + let monitor = { + let sessions = self.core.sessions.clone(); + let timeline = timeline.clone(); + let chat = id.clone(); + tokio::spawn(async move { + let mut last = None; + loop { + let now = sessions + .session_status(&chat) + .map(|s| (s.status, s.last_completed_turn)); + if now.is_some() && now != last { + let (status, ping) = now.clone().unwrap(); + timeline + .lock() + .unwrap() + .push((Instant::now(), status, ping)); + last = now; + } + tokio::time::sleep(Duration::from_millis(5)).await; + } + }) + }; + Chat { + suite: self, + id, + sends: Default::default(), + timeline, + sentinels: Vec::new(), + runs_sent: 0, + stops: 0, + allow_errored: false, + monitor, + errors: Vec::new(), + } + } +} + +impl Chat<'_> { + fn sentinel(&mut self, tag: &str) -> String { + let token = sentinel_token(tag); + self.sentinels.push(token.clone()); + token + } + + fn status(&self) -> Option { + self.suite + .core + .sessions + .session_status(&self.id) + .map(|s| s.status) + } + + fn events(&self) -> Vec { + self.suite + .core + .sessions + .subscribe(&self.id, 0) + .map(|(events, _)| events.into_iter().map(|e| e.event).collect()) + .unwrap_or_default() + } + + fn text(&self) -> String { + self.events() + .iter() + .filter_map(|e| match e { + AgentEvent::TextDelta { text } => Some(text.as_str()), + _ => None, + }) + .collect() + } + + fn tool_started(&self, needle: &str) -> bool { + self.events().iter().any(|e| { + matches!(e, AgentEvent::ToolCall { call, .. } + if serde_json::to_string(call).is_ok_and(|c| c.contains(needle))) + }) + } + + /// The user's send, through the durable command plane like every client. + fn send(&mut self, request: RunRequest) { + self.runs_sent += 1; + self.sends.lock().unwrap().push(Instant::now()); + self.suite + .core + .doc_host + .queue_command( + &self.id, + SessionCommandPayload::Run { + request, + message_id: uuid::Uuid::new_v4().to_string(), + }, + ) + .unwrap(); + } + + fn say(&mut self, prompt: &str) { + let request = self.suite.request(prompt); + self.send(request); + } + + fn stop(&mut self) { + self.stops += 1; + self.sends.lock().unwrap().push(Instant::now()); + self.suite + .core + .doc_host + .queue_command(&self.id, SessionCommandPayload::Interrupt {}) + .unwrap(); + } + + async fn until( + &mut self, + secs: u64, + what: &str, + mut predicate: impl FnMut(&Self) -> bool, + ) -> bool { + let deadline = Instant::now() + Duration::from_secs(secs); + while !predicate(self) { + if Instant::now() > deadline { + self.errors.push(format!("timed out: {what}")); + return false; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + true + } + + async fn streaming(&mut self, what: &str) -> bool { + let base = self.text().len(); + self.until(180, what, move |c| { + c.status() == Some(SessionStatus::Working) && c.text().len() > base + 40 + }) + .await + } + + /// Settled: Idle, and every sentinel asked for so far has arrived. + async fn settled(&mut self, secs: u64) -> bool { + let sentinels = self.sentinels.clone(); + self.until( + secs, + "the chat to settle Idle with every sentinel", + move |c| { + c.status() == Some(SessionStatus::Idle) && { + let text = c.text(); + sentinels.iter().all(|s| text.contains(s.as_str())) + } + }, + ) + .await + } + + /// Check every invariant; returns the violations. + async fn verdict(mut self) -> Vec { + // Let trailing frames land before judging. + tokio::time::sleep(Duration::from_millis(1500)).await; + self.monitor.abort(); + let events = self.events(); + + // Done followed by agent output with no new user turn in between. + let mut after_done = None; + for (i, e) in events.iter().enumerate() { + match e { + AgentEvent::Done { status, .. } => after_done = Some((i, *status)), + AgentEvent::Steered { .. } | AgentEvent::SessionStarted { .. } => after_done = None, + AgentEvent::TextDelta { text } if !text.trim().is_empty() => { + if let Some((at, status)) = after_done.take() { + self.errors.push(format!( + "output after Done({status:?}) at event {at} with no new turn: {text:?}" + )); + } + } + AgentEvent::ToolCall { id, .. } => { + if let Some((at, status)) = after_done.take() { + self.errors.push(format!( + "tool {id} after Done({status:?}) at event {at} with no new turn" + )); + } + } + _ => {} + } + } + + // A completion ping followed by more work with no user action in + // between. (An Idle without a ping is a handoff — a turn-boundary + // agent taking the message held for its turn end — not "done".) + let sends = self.sends.lock().unwrap().clone(); + let timeline = self.timeline.lock().unwrap().clone(); + let mut idle_since: Option = None; + let mut last_ping: Option = None; + for (at, status, ping) in &timeline { + let pinged = ping.is_some() && *ping != last_ping; + if ping.is_some() { + last_ping = ping.clone(); + } + match status { + SessionStatus::Idle if pinged => idle_since = Some(*at), + SessionStatus::Idle => {} + SessionStatus::Working | SessionStatus::AwaitingInput => { + // A send queued just before the turn ended reaches the + // agent just after: allow the command plane's latency. + if let Some(idle) = idle_since.take() + && !sends + .iter() + .any(|s| *s <= *at && *s + Duration::from_secs(2) >= idle) + { + self.errors.push(format!( + "went Idle then Working again {:?} later with no user action (premature done)", + at.duration_since(idle) + )); + } + } + _ => {} + } + } + + // Every sentinel arrived; nothing stuck. + let text = self.text(); + for s in &self.sentinels { + if !text.contains(s.as_str()) { + self.errors.push(format!("sentinel {s} never arrived")); + } + } + if self.status() != Some(SessionStatus::Idle) { + self.errors + .push(format!("ended {:?}, not Idle", self.status())); + } + + // Every message delivered (a steer boundary, or the process it + // started); one process except after Stops. + let steered = events + .iter() + .filter(|e| matches!(e, AgentEvent::Steered { .. })) + .count(); + let processes = events + .iter() + .filter(|e| matches!(e, AgentEvent::SessionStarted { .. })) + .count(); + if steered + processes < self.runs_sent { + self.errors.push(format!( + "{} messages sent but only {steered} steer boundaries + {processes} processes", + self.runs_sent + )); + } + if processes > 1 + self.stops { + self.errors + .push(format!("{processes} processes for {} stops", self.stops)); + } + if !self.allow_errored + && self.events().iter().any(|e| { + matches!( + e, + AgentEvent::Done { + status: zeron_proto::DoneStatus::Errored, + .. + } + ) + }) + { + self.errors.push("a turn errored".into()); + } + if !self.errors.is_empty() { + let journal: Vec = events + .iter() + .filter(|e| { + !matches!( + e, + AgentEvent::TextDelta { .. } + | AgentEvent::ReasoningDelta { .. } + | AgentEvent::Usage { .. } + | AgentEvent::ContextUsage { .. } + ) + }) + .map(|e| format!("{e:?}").chars().take(160).collect()) + .collect(); + self.errors.push(format!("journal: {journal:#?}")); + } + self.errors + } +} + +/// A unique, word-like token to ask the agent to echo. Random hex reads as a +/// secret or an injection to some models (Grok declines to repeat it). +fn sentinel_token(tag: &str) -> String { + const WORDS: &[&str] = &[ + "CEDAR", "MANGO", "RIVER", "TULIP", "OTTER", "MAPLE", "COMET", "BISON", "LEMON", "WALNUT", + "HARBOR", "PEPPER", + ]; + let id = uuid::Uuid::new_v4().as_u128(); + let word = WORDS[(id % WORDS.len() as u128) as usize]; + format!("{word}{}{}", 100 + (id >> 8) % 900, tag.to_uppercase()) +} + +/// Every process below `root`, parents before children. +fn descendants(root: u32) -> Vec { + let mut out = Vec::new(); + let mut frontier = vec![root]; + while let Some(pid) = frontier.pop() { + let children = std::process::Command::new("pgrep") + .args(["-P", &pid.to_string()]) + .output() + .map(|o| { + String::from_utf8_lossy(&o.stdout) + .split_whitespace() + .filter_map(|p| p.parse::().ok()) + .collect::>() + }) + .unwrap_or_default(); + for child in children { + out.push(child); + frontier.push(child); + } + } + out +} + +/// The answer to every question an approval or ask raises: an allowing +/// option where one is offered, else the first. +fn answer_all(questions: &[zeron_proto::UserInputQuestion]) -> Vec { + questions + .iter() + .map(|q| { + let allow = q.options.iter().find(|o| { + let o = o.to_lowercase(); + (o.contains("allow") + || o.contains("yes") + || o.contains("approve") + || o.contains("accept")) + && !o.contains("don't") + && !o.contains("deny") + && !o.contains("reject") + }); + zeron_proto::UserInputAnswer { + question_id: q.id.clone(), + labels: allow.or(q.options.first()).cloned().into_iter().collect(), + } + }) + .collect() +} + +fn pixel(dir: &std::path::Path) -> String { + let path = dir.join(format!("pixel-{}.png", uuid::Uuid::new_v4().simple())); + std::fs::write( + &path, + [ + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, + 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00, + 0x00, 0x90, 0x77, 0x53, 0xDE, 0x00, 0x00, 0x00, 0x0C, 0x49, 0x44, 0x41, 0x54, 0x08, + 0xD7, 0x63, 0xF8, 0xCF, 0xC0, 0x00, 0x00, 0x03, 0x01, 0x01, 0x00, 0x18, 0xDD, 0x8D, + 0xB0, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82, + ], + ) + .unwrap(); + path.display().to_string() +} + +const ESSAY: &str = "Write a 500-word essay about rivers. Do not use any tools."; + +async fn scenario(suite: &Suite, name: &str, round: usize) -> Vec { + let mut chat = suite.chat(name, round); + match name { + // Three messages within a second while the reply streams. + "rapid_steers" => { + chat.say(ESSAY); + if chat.streaming("the essay to stream").await { + let token = chat.sentinel("RS"); + chat.say("Remember the word ALPHA."); + tokio::time::sleep(Duration::from_millis(300)).await; + chat.say("Remember the word BRAVO."); + tokio::time::sleep(Duration::from_millis(300)).await; + chat.say(&format!( + "Stop the essay. Say hi to my cat {token}, then list every word I asked you to remember. No tools." + )); + if chat.settled(240).await { + let text = chat.text(); + let tail = &text[text.find(&token).unwrap_or(0)..]; + for word in ["ALPHA", "BRAVO"] { + if !tail.contains(word) { + chat.errors + .push(format!("steer {word} never reached the agent")); + } + } + } + } + } + // A steer while a foreground command runs. + "steer_during_tool" => { + let first = chat.sentinel("TA"); + chat.say(&format!( + "Run this exact shell command in the FOREGROUND and wait for it: {} . Then say hi to my cat {first}.", + suite.probe(1, 15) + )); + let marker = suite.probe_marker(1); + if chat + .until(180, "the command to start", |c| c.tool_started(&marker)) + .await + { + let second = chat.sentinel("TB"); + chat.say(&format!( + "When that is done, also say hi to my cat {second}." + )); + chat.settled(240).await; + } + } + // A send landing right as the reply finishes. + "boundary_race" => { + let first = chat.sentinel("BA"); + chat.say(&format!( + "Just say hi to my cat {first} in one short sentence." + )); + if chat + .until(120, "the first reply", |c| { + c.text().contains(first.as_str()) + }) + .await + { + let second = chat.sentinel("BB"); + chat.say(&format!( + "Just say hi to my cat {second} in one short sentence." + )); + chat.settled(180).await; + } + // And a send right after Idle. + let third = chat.sentinel("BC"); + chat.say(&format!( + "Just say hi to my cat {third} in one short sentence." + )); + chat.settled(180).await; + } + // Stop the instant after sending, then send again at once. + "stop_races" => { + chat.say(ESSAY); + chat.stop(); + // The Stop may cancel this message before its agent even starts. + chat.runs_sent -= 1; + chat.until(60, "the instant stop to settle", |c| { + c.status() == Some(SessionStatus::Idle) + }) + .await; + let token = chat.sentinel("SR"); + chat.say(&format!( + "Just say hi to my cat {token} in one short sentence." + )); + chat.settled(180).await; + // Stop mid-command, and send immediately after the Stop. + chat.say(&format!( + "Run this exact shell command in the FOREGROUND and wait for it: {} . Then reply FINISHED.", + suite.probe(2, 300) + )); + let marker = suite.probe_marker(2); + if chat + .until(180, "the command to start", |c| c.tool_started(&marker)) + .await + { + chat.stop(); + let token = chat.sentinel("SS"); + chat.say(&format!( + "Just say hi to my cat {token} in one short sentence." + )); + chat.settled(180).await; + if !chat + .until(20, "the stopped command's process to be gone", |c| { + c.suite.probes_alive() == 0 + }) + .await + { + chat.errors + .push("a Stop left the agent's command running".into()); + } + } + } + // An image sent while the agent works. + "image_mid_turn" => { + chat.say(ESSAY); + if chat.streaming("the essay to stream").await { + let token = chat.sentinel("IM"); + let mut request = suite.request(&format!( + "Stop the essay. I attached a 1x1 test image. Just say hi to my cat {token} in one short sentence. No tools." + )); + request.attachments = vec![pixel(suite.dir.path())]; + chat.send(request); + chat.settled(240).await; + } + } + // Another model chosen for a message sent mid-turn. + "model_switch_mid_turn" => { + let Some(other) = suite.other_model.clone() else { + return Vec::new(); + }; + chat.say(ESSAY); + if chat.streaming("the essay to stream").await { + let token = chat.sentinel("MS"); + let mut request = suite.request(&format!( + "Stop the essay. Just say hi to my cat {token} in one short sentence. No tools." + )); + request.model = Some(other.clone()); + chat.send(request); + if chat.settled(240).await { + let token = chat.sentinel("MT"); + let mut request = suite.request(&format!( + "Just say hi to my cat {token} in one short sentence." + )); + request.model = Some(other); + chat.send(request); + chat.settled(180).await; + } + } + } + // Long silent reasoning must not read as a dead turn. + "long_reasoning" => { + let token = chat.sentinel("LR"); + let mut request = suite.request(&format!( + "Think carefully step by step (privately) about this: how many distinct ways can you make change for 100 cents using pennies, nickels, dimes and quarters? Do not use tools. Then say hi to my cat {token} and tell me the number." + )); + request.reasoning = Some(ReasoningLevel::Max); + chat.send(request); + chat.settled(600).await; + } + // A steer while a subagent works. + "subagent_steer" => { + let delegate = match suite.id { + HarnessId::Codex => "Spawn one subagent (spawn_agent) and wait for it", + HarnessId::ClaudeCode => { + "Use the Agent tool (foreground, general-purpose subagent) and wait for it" + } + _ => { + "Delegate to one subagent (your task/subagent tool, if you have one; otherwise do it yourself) and wait for it" + } + }; + let first = chat.sentinel("SA"); + chat.say(&format!( + "{delegate}: its task is to run this exact shell command in the foreground: {} and then reply ok. After it returns, say hi to my cat {first}.", + suite.probe(3, 20) + )); + if chat + .until(240, "the subagent's command to start", |c| { + c.suite.probes_alive() > 0 + }) + .await + { + let second = chat.sentinel("SB"); + chat.say(&format!( + "Also, at the very end, say hi to my cat {second}." + )); + chat.settled(300).await; + } + } + // The agent asks; the answer resumes the same turn, which ends once. + "question" => { + if suite.id != HarnessId::ClaudeCode { + return Vec::new(); + } + let token = chat.sentinel("QA"); + chat.say(&format!( + "Use the AskUserQuestion tool to ask me to pick exactly one of two colors, red or blue. After I answer, say hi to my cat {token} and tell me my choice. No other tools." + )); + if chat + .until(180, "the question", |c| { + c.status() == Some(SessionStatus::AwaitingInput) + }) + .await + { + let asked = chat.events().into_iter().rev().find_map(|e| match e { + AgentEvent::InputRequested { + request_id, + questions, + } => Some((request_id, questions)), + _ => None, + }); + if let Some((request_id, questions)) = asked { + let answers = answer_all(&questions); + chat.sends.lock().unwrap().push(Instant::now()); + if !suite + .core + .sessions + .respond_input(&chat.id, &request_id, answers) + .unwrap() + { + chat.errors + .push("the answer found no pending question".into()); + } + chat.settled(180).await; + } else { + chat.errors + .push("AwaitingInput without an InputRequested event".into()); + } + } + } + // A steer, then Stop at once: both cancelled, the work killed, and + // the next message runs. + "steer_then_stop" => { + chat.say(&format!( + "Run this exact shell command in the FOREGROUND and wait for it: {} . Then reply FINISHED.", + suite.probe(4, 300) + )); + let marker = suite.probe_marker(4); + if chat + .until(180, "the command to start", |c| c.tool_started(&marker)) + .await + { + chat.say("Also list the files in this directory afterwards."); + chat.stop(); + chat.until(60, "the stop to settle", |c| { + c.status() == Some(SessionStatus::Idle) + }) + .await; + if !chat + .until(20, "the stopped command to be gone", |c| { + c.suite.probes_alive() == 0 + }) + .await + { + chat.errors + .push("a Stop left the agent's command running".into()); + } + let token = chat.sentinel("ST"); + chat.say(&format!( + "Just say hi to my cat {token} in one short sentence." + )); + chat.settled(180).await; + // The cancelled steer must not have run in the new process. + chat.runs_sent -= 1; + } + } + // Several messages queued at once on an idle chat: every one runs, + // in the order sent. + "burst_idle" => { + let first = chat.sentinel("BU"); + chat.say(&format!( + "Just say hi to my cat {first} in one short sentence." + )); + if chat.settled(180).await { + // However the agent takes them — one turn each, or together — + // every message must reach it. + let words = ["ALPHA", "BRAVO", "CHARLIE"]; + for word in words { + chat.say(&format!("Remember the word {word}. Reply only OK.")); + } + let last = chat.sentinel("BZ"); + chat.say(&format!( + "Say hi to my cat {last}, then list every word I asked you to remember. No tools." + )); + if chat.settled(300).await { + let text = chat.text(); + let tail = &text[text.rfind(last.as_str()).unwrap_or(0)..]; + for word in words { + if !tail.contains(word) { + chat.errors + .push(format!("queued message {word} never reached the agent")); + } + } + } + } + } + // Stop on an idle chat holding no work: nothing to stop, and the + // agent must not be torn down for it. + "idle_stop" => { + let first = chat.sentinel("IA"); + chat.say(&format!( + "Just say hi to my cat {first} in one short sentence." + )); + if chat.settled(180).await { + chat.stop(); + chat.stops -= 1; + tokio::time::sleep(Duration::from_secs(2)).await; + let second = chat.sentinel("IB"); + chat.say(&format!( + "Just say hi to my cat {second} in one short sentence." + )); + chat.settled(180).await; + } + } + // A long foreground command with no output: silence is not the end. + "silent_command" => { + let token = chat.sentinel("SC"); + chat.say(&format!( + "Run this exact shell command in the FOREGROUND and wait for it to finish (it takes about 75 seconds and prints nothing): {} . Then say hi to my cat {token}.", + suite.probe(5, 75) + )); + chat.settled(420).await; + } + // The agent dies mid-command (OOM, `killall`): the turn must end, + // visibly, and the next message resumes the same conversation. + "crash_recovery" => { + let word = format!("PELICAN{}", &uuid::Uuid::new_v4().simple().to_string()[..4]) + .to_uppercase(); + let first = chat.sentinel("CA"); + chat.say(&format!( + "Hi! I named my new boat {word}. Just say hi to my cat {first} in one short sentence." + )); + if chat.settled(180).await { + chat.say(&format!( + "Run this exact shell command in the FOREGROUND and wait for it: {} . Then reply FINISHED.", + suite.probe(6, 300) + )); + let marker = suite.probe_marker(6); + if chat + .until(180, "the command to start", |c| c.tool_started(&marker)) + .await + { + chat.allow_errored = true; + chat.stops += 1; + let killed = suite.crash_agents(); + if killed == 0 { + chat.errors.push("found no agent process to kill".into()); + } + chat.until(120, "the crashed turn to end", |c| { + c.status() != Some(SessionStatus::Working) + }) + .await; + let ended = chat + .events() + .iter() + .rev() + .any(|e| matches!(e, AgentEvent::Done { .. })); + if !ended { + chat.errors + .push("the crash ended the turn without a Done".into()); + } + let second = chat.sentinel("CB"); + let said = chat.text().len(); + chat.say(&format!( + "What did I name my boat? Also say hi to my cat {second}. No tools." + )); + if chat.settled(240).await { + let reply = chat.text()[said..].to_uppercase(); + if !reply.contains(&word) { + chat.errors.push(format!( + "the conversation did not survive the crash: {word} missing from {reply:?}" + )); + } + } + } + } + } + // Two chats on the same harness at once: each gets its own replies + // and its own steer. + "concurrent_chats" => { + let mut other = suite.chat(&format!("{name}-b"), round); + chat.say(ESSAY); + other.say(ESSAY); + if chat.streaming("chat A to stream").await && other.streaming("chat B to stream").await + { + let a = chat.sentinel("CA"); + let b = other.sentinel("CB"); + chat.say(&format!( + "Stop the essay. Just say hi to my cat {a} in one short sentence. No tools." + )); + other.say(&format!( + "Stop the essay. Just say hi to my cat {b} in one short sentence. No tools." + )); + chat.settled(240).await; + other.settled(240).await; + if chat.text().contains(b.as_str()) || other.text().contains(a.as_str()) { + chat.errors.push("a reply reached the other chat".into()); + } + } + let other_id = other.id.clone(); + let mut errors = other.verdict().await; + let _ = suite.core.sessions.terminate(&other_id).await; + for e in errors.drain(..) { + chat.errors.push(format!("chat B: {e}")); + } + } + // Approvals on: every permission the agent asks for is answered, + // and the turn carries on to its end. + "approval" => { + let token = chat.sentinel("AP"); + let mut request = suite.request(&format!( + "Run this exact shell command: echo approved > approval.txt . Then say hi to my cat {token}." + )); + request.auto_approve = false; + chat.send(request); + let deadline = Instant::now() + Duration::from_secs(300); + let mut answered = std::collections::HashSet::new(); + loop { + let pending = chat + .events() + .into_iter() + .filter_map(|e| match e { + AgentEvent::InputRequested { + request_id, + questions, + } => Some((request_id, questions)), + _ => None, + }) + .filter(|(id, _)| !answered.contains(id)) + .collect::>(); + for (request_id, questions) in pending { + chat.sends.lock().unwrap().push(Instant::now()); + let _ = suite.core.sessions.respond_input( + &chat.id, + &request_id, + answer_all(&questions), + ); + answered.insert(request_id); + } + let done = chat.status() == Some(SessionStatus::Idle) + && chat.text().contains(token.as_str()); + if done || Instant::now() > deadline { + break; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + chat.settled(30).await; + } + other => panic!("unknown scenario {other}"), + } + let id = chat.id.clone(); + let errors = chat.verdict().await; + // Retire the chat's runtime: a later crash scenario kills every agent + // this test runs. + let _ = suite.core.sessions.terminate(&id).await; + suite.kill_probes(); + errors +} + +const SCENARIOS: &[&str] = &[ + "rapid_steers", + "steer_during_tool", + "boundary_race", + "stop_races", + "image_mid_turn", + "model_switch_mid_turn", + "long_reasoning", + "subagent_steer", + "question", + "steer_then_stop", + "burst_idle", + "idle_stop", + "silent_command", + "crash_recovery", + "concurrent_chats", + "approval", +]; + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +#[ignore = "uses real model quota; select harness and inexpensive model explicitly"] +async fn adapter_chaos() { + if std::env::var_os("RUST_LOG").is_some() { + let _ = tracing_subscriber::fmt() + .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) + .with_test_writer() + .try_init(); + } + let suite = Suite::new(); + suite.kill_probes(); + let rounds: usize = std::env::var("ZERON_CHAOS_ROUNDS") + .ok() + .and_then(|r| r.parse().ok()) + .unwrap_or(1); + let only = std::env::var("ZERON_CHAOS_ONLY").ok(); + let mut failures = Vec::new(); + let mut runs = 0; + for round in 0..rounds { + for name in SCENARIOS { + if only.as_deref().is_some_and(|o| o != *name) { + continue; + } + let started = Instant::now(); + let errors = scenario(&suite, name, round).await; + runs += 1; + let verdict = if errors.is_empty() { "ok" } else { "FAILED" }; + println!( + "{}: round {round} {name}: {verdict} ({:.0}s)", + suite.name, + started.elapsed().as_secs_f32() + ); + for e in &errors { + println!(" {e}"); + } + if !errors.is_empty() { + failures.push(format!("round {round} {name}")); + } + } + } + suite.core.shutdown().await; + suite.kill_probes(); + println!( + "{}: {}/{runs} scenario runs passed", + suite.name, + runs - failures.len() + ); + assert!(failures.is_empty(), "failed: {failures:?}"); +} + +const RESTART_CHAT: &str = "restart"; + +fn status_of(core: &EngineCore) -> Option { + core.sessions.session_status(RESTART_CHAT).map(|s| s.status) +} + +fn text_of(core: &EngineCore) -> String { + core.sessions + .subscribe(RESTART_CHAT, 0) + .map(|(events, _)| { + events + .into_iter() + .filter_map(|e| match e.event { + AgentEvent::TextDelta { text } => Some(text), + _ => None, + }) + .collect() + }) + .unwrap_or_default() +} + +fn completed_of(core: &EngineCore) -> usize { + core.sessions + .subscribe(RESTART_CHAT, 0) + .map(|(events, _)| { + events + .iter() + .filter(|e| { + matches!( + e.event, + AgentEvent::Done { + status: zeron_proto::DoneStatus::Completed, + .. + } + ) + }) + .count() + }) + .unwrap_or(0) +} + +/// Send `prompt` and wait for its turn to complete; returns the reply text. +/// No reply token is demanded: some agents (Grok) refuse to echo one. +async fn ask(core: &EngineCore, name: &str, cwd: &str, prompt: &str, what: &str) -> String { + let (done, said) = (completed_of(core), text_of(core).len()); + send_to(core, restart_request(name, cwd, prompt)); + let finished = tokio::time::timeout(Duration::from_secs(240), async { + while !(completed_of(core) > done && status_of(core) == Some(SessionStatus::Idle)) { + tokio::time::sleep(Duration::from_millis(50)).await; + } + }) + .await; + assert!(finished.is_ok(), "timed out: {what}:\n{}", dump(core)); + text_of(core)[said..].to_owned() +} + +/// A completed turn after recovery's "— resuming" marker: the force-killed +/// turn was picked back up and ran to its end. +fn resumed_to_end(core: &EngineCore) -> bool { + core.sessions + .subscribe(RESTART_CHAT, 0) + .is_ok_and(|(events, _)| { + events + .iter() + .position(|e| { + matches!(&e.event, AgentEvent::Done { error: Some(note), .. } + if note.contains("resuming")) + }) + .is_some_and(|at| { + events[at..].iter().any(|e| { + matches!( + e.event, + AgentEvent::Done { + status: zeron_proto::DoneStatus::Completed, + .. + } + ) + }) + }) + }) +} + +fn send_to(core: &EngineCore, request: RunRequest) { + core.doc_host + .queue_command( + RESTART_CHAT, + SessionCommandPayload::Run { + request, + message_id: uuid::Uuid::new_v4().to_string(), + }, + ) + .unwrap(); +} + +fn restart_request(name: &str, cwd: &str, prompt: &str) -> RunRequest { + RunRequest { + mcp: None, + prompt: prompt.into(), + harness: Some(harness(name).0), + model: std::env::var("ZERON_TEST_MODEL").ok(), + reasoning: None, + model_options: Default::default(), + cwd: cwd.into(), + sandbox: SandboxLevel::DangerFullAccess, + auto_approve: true, + attachments: vec![], + worktree: None, + resume: None, + } +} + +async fn wait_for(secs: u64, what: &str, mut predicate: impl FnMut() -> bool) { + let deadline = Instant::now() + Duration::from_secs(secs); + while !predicate() { + assert!(Instant::now() < deadline, "timed out: {what}"); + tokio::time::sleep(Duration::from_millis(50)).await; + } +} + +/// What the engine holds for the restart chat: journal events (text +/// folded away) and the transcript's entries. +fn dump(core: &EngineCore) -> String { + let events: Vec = core + .sessions + .subscribe(RESTART_CHAT, 0) + .map(|(events, _)| { + events + .into_iter() + .filter(|e| { + !matches!( + e.event, + AgentEvent::TextDelta { .. } | AgentEvent::ReasoningDelta { .. } + ) + }) + .map(|e| format!("{:?}", e.event).chars().take(200).collect()) + .collect() + }) + .unwrap_or_default(); + let entries: Vec = core + .doc_host + .open(RESTART_CHAT) + .ok() + .and_then(|h| h.doc().read_entries().ok()) + .unwrap_or_default() + .iter() + .map(|e| format!("{:?} {:?} {}", e.role, e.status, e.id)) + .collect(); + format!( + "status {:?}\nevents {events:#?}\nentries {entries:#?}\ntext {:?}", + status_of(core), + text_of(core) + ) +} + +fn probe_alive(marker: &str) -> bool { + std::process::Command::new("pgrep") + .args(["-f", marker]) + .output() + .is_ok_and(|o| !o.stdout.is_empty()) +} + +/// Quitting the app mid-turn, and the app being force-killed mid-turn: after +/// the restart nothing reads as still running, the force-killed turn is +/// picked back up and finishes, and the conversation carries on with its +/// context. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +#[ignore = "uses real model quota; select harness and inexpensive model explicitly"] +async fn restart_durability() { + if std::env::var_os("RUST_LOG").is_some() { + let _ = tracing_subscriber::fmt() + .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) + .with_test_writer() + .try_init(); + } + let name = std::env::var("ZERON_TEST_HARNESS").unwrap_or_else(|_| "claude".into()); + let (id, _, base) = harness(&name); + let dir = tempfile::tempdir().unwrap(); + let cwd = dir.path().to_str().unwrap().to_owned(); + let word = format!("HERON{}", &uuid::Uuid::new_v4().simple().to_string()[..4]).to_uppercase(); + + // 1. A conversation with something to remember. + let core = engine(dir.path(), &name); + core.workspace + .create_space("space", &core.device_id, &cwd, None, false) + .unwrap(); + core.workspace + .create_chat(RESTART_CHAT, Some("space"), None, None, None) + .unwrap(); + core.workspace + .set_chat_config( + RESTART_CHAT, + &ChatConfig { + harness: id, + model: std::env::var("ZERON_TEST_MODEL").ok(), + reasoning: None, + model_options: Default::default(), + sandbox: SandboxLevel::DangerFullAccess, + }, + ) + .unwrap(); + ask( + &core, + &name, + &cwd, + &format!("Hi! I named my new boat {word}. A one-line hello back is all I need."), + "the opening reply", + ) + .await; + println!("{name}: opening turn done"); + + if std::env::var_os("ZERON_RESTART_ONLY_FORCE_KILL").is_none() { + // 2. Quit the app mid-command. + let marker = format!("zr{}=1", base + 7); + send_to( + &core, + restart_request( + &name, + &cwd, + &format!( + "Please run this shell command in the foreground and wait for it to finish: python3 -c 'import time; {marker}; time.sleep(300)'" + ), + ), + ); + wait_for(240, "the command to start", || probe_alive(&marker)).await; + core.shutdown().await; + drop(core); + wait_for(30, "quitting to end the agent's command", || { + !probe_alive(&marker) + }) + .await; + println!("{name}: quit mid-command; its work ended"); + + // 3. Restart: nothing stuck, context intact. + let core = engine(dir.path(), &name); + wait_for(60, "the quit turn to read as ended", || { + status_of(&core) != Some(SessionStatus::Working) + }) + .await; + let reply = ask( + &core, + &name, + &cwd, + "Quick question: what did I name my boat? No tools needed.", + "the reply after a restart", + ) + .await; + assert!( + reply.to_uppercase().contains(&word), + "the conversation did not survive quitting the app ({word}): {reply:?}\n{}", + dump(&core) + ); + println!("{name}: restarted with the conversation intact"); + core.shutdown().await; + } else { + core.shutdown().await; + drop(core); + } + + // 4. Force-kill the app mid-command (a separate process: this one is the + // app here), then restart: the interrupted turn is resumed and finishes. + let crash_marker = format!("zr{}=1", base + 8); + let mut child = tokio::process::Command::new(std::env::current_exe().unwrap()) + .args(["crash_phase", "--exact", "--ignored", "--nocapture"]) + .env("ZERON_CRASH_DIR", dir.path()) + .env("ZERON_CRASH_PROMPT", format!( + "Please run this shell command in the foreground and wait for it to finish: python3 -c 'import time; {crash_marker}; time.sleep(30)'" + )) + .env("ZERON_CRASH_MARKER", &crash_marker) + .process_group(0) + .stdout(std::process::Stdio::piped()) + .kill_on_drop(true) + .spawn() + .unwrap(); + let pgid = child.id().unwrap(); + { + use tokio::io::AsyncBufReadExt; + let mut lines = tokio::io::BufReader::new(child.stdout.take().unwrap()).lines(); + tokio::time::timeout(Duration::from_secs(300), async { + while let Ok(Some(line)) = lines.next_line().await { + if line.contains("CRASH-READY") { + return; + } + } + panic!("the app process ended before its command started"); + }) + .await + .expect("the app process to start the command"); + } + let _ = std::process::Command::new("kill") + .args(["-9", &format!("-{pgid}")]) + .status(); + let _ = child.wait().await; + // Whatever the agent left behind outlives a force-kill; clear it so the + // resumed run's own command is what we see. + let _ = std::process::Command::new("pkill") + .args(["-9", "-f", &crash_marker]) + .status(); + println!("{name}: app force-killed mid-command"); + + let core = engine(dir.path(), &name); + let resumed = tokio::time::timeout(Duration::from_secs(420), async { + while !(resumed_to_end(&core) && status_of(&core) == Some(SessionStatus::Idle)) { + tokio::time::sleep(Duration::from_millis(50)).await; + } + }) + .await; + assert!( + resumed.is_ok(), + "the force-killed turn was not resumed to its end:\n{}", + dump(&core) + ); + println!("{name}: the force-killed turn resumed and finished"); + let reply = ask( + &core, + &name, + &cwd, + "Remind me, what did I name my boat? No tools needed.", + "the reply after a force-kill", + ) + .await; + assert!( + reply.to_uppercase().contains(&word), + "the conversation did not survive a force-kill ({word}): {reply:?}\n{}", + dump(&core) + ); + println!("{name}: force-kill survived with the conversation intact"); + core.shutdown().await; + let _ = std::process::Command::new("pkill") + .args(["-9", "-f", &format!("zr{}[0-9]=1", base / 10)]) + .status(); +} + +/// `restart_durability`'s app process: starts the command, reports, and +/// waits to be killed. Does nothing unless launched by that test. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +#[ignore = "launched by restart_durability"] +async fn crash_phase() { + if std::env::var_os("RUST_LOG").is_some() { + let _ = tracing_subscriber::fmt() + .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) + .with_test_writer() + .try_init(); + } + let Some(dir) = std::env::var_os("ZERON_CRASH_DIR") else { + return; + }; + let name = std::env::var("ZERON_TEST_HARNESS").unwrap_or_else(|_| "claude".into()); + let dir = std::path::PathBuf::from(dir); + let core = engine(&dir, &name); + let cwd = dir.to_str().unwrap().to_owned(); + send_to( + &core, + restart_request(&name, &cwd, &std::env::var("ZERON_CRASH_PROMPT").unwrap()), + ); + let marker = std::env::var("ZERON_CRASH_MARKER").unwrap(); + wait_for(240, "the command to start", || probe_alive(&marker)).await; + println!("CRASH-READY"); + tokio::time::sleep(Duration::from_secs(600)).await; +} diff --git a/crates/engine/tests/device_routing.rs b/crates/engine/tests/device_routing.rs index 40d8457908..142f71c4c1 100644 --- a/crates/engine/tests/device_routing.rs +++ b/crates/engine/tests/device_routing.rs @@ -132,12 +132,12 @@ async fn fake_device_room() -> (String, tokio::task::JoinHandle<()>) { // --------------------------------------------------------------------------- /// Instant mock harness so a forwarded QueueCommand fully executes on the target. -struct InstantHarness; +struct InstantHarness(HarnessId); #[async_trait] impl Harness for InstantHarness { fn id(&self) -> HarnessId { - HarnessId::Mock + self.0 } fn display_name(&self) -> &str { "Instant" @@ -161,7 +161,7 @@ impl Harness for InstantHarness { ) -> Result>, HarnessError> { Ok(futures::stream::iter([ Ok(AgentEvent::SessionStarted { - harness: HarnessId::Mock, + harness: self.0, model: "instant-1".into(), tools: vec![], cwd: "/tmp".into(), @@ -183,8 +183,12 @@ impl Harness for InstantHarness { } fn registry() -> Arc { + registry_for(HarnessId::Mock) +} + +fn registry_for(harness: HarnessId) -> Arc { let registry = HarnessRegistry::new(); - registry.register(Arc::new(InstantHarness)); + registry.register(Arc::new(InstantHarness(harness))); Arc::new(registry) } @@ -1843,3 +1847,235 @@ async fn queue_watch_and_single_consumption_route_to_the_remote_chat_host() { core_a.shutdown().await; core_b.shutdown().await; } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn workspace_entry_mutations_are_forwarded_to_the_owning_plain_folder() { + let (relay_url, _relay) = fake_device_room().await; + let dirs = tempfile::tempdir().unwrap(); + let host = assemble(&dirs.path().join("host"), "mutation-host"); + let _host_relay = host.start_host_relay(&relay_url); + let viewer = assemble(&dirs.path().join("viewer"), "mutation-viewer"); + let mut config = LinkCacheConfig::new(relay_url, Arc::new(StaticToken("test-user".into()))); + config.probe_timeout = Duration::from_secs(5); + viewer.set_links(LinkCache::new(config)); + let folder = dirs.path().join("plain-folder"); + std::fs::create_dir(&folder).unwrap(); + std::fs::write(folder.join("original.txt"), "host only").unwrap(); + host.workspace + .create_space( + "mutation-space", + "mutation-host", + &folder.to_string_lossy(), + None, + false, + ) + .unwrap(); + let client = zeron_rpc::memory_client(viewer.rpc_service()); + let page = client + .call( + methods::LIST_WORKSPACE_DIRECTORY, + serde_json::json!({"spaceId":"mutation-space","targetDeviceId":"mutation-host"}), + ) + .await + .unwrap(); + let entry = &page["entries"][0]; + assert!(page["checkoutId"].as_str().unwrap().starts_with("folder-")); + let moved=client.call(methods::MOVE_WORKSPACE_ENTRY,serde_json::json!({ + "spaceId":"mutation-space","targetDeviceId":"mutation-host","operationId":"remote-move", + "expectedCheckoutId":page["checkoutId"],"sourcePath":"original.txt","destinationPath":"renamed.txt", + "expectedSourceRevision":entry["mutationRevision"],"expectedKind":"file" + })).await.unwrap(); + assert_eq!(moved["status"], "applied"); + assert_eq!( + std::fs::read_to_string(folder.join("renamed.txt")).unwrap(), + "host only" + ); + let deleted=client.call(methods::DELETE_WORKSPACE_ENTRY,serde_json::json!({ + "spaceId":"mutation-space","targetDeviceId":"mutation-host","operationId":"remote-delete", + "expectedCheckoutId":page["checkoutId"],"path":"renamed.txt", + "expectedSourceRevision":moved["entry"]["mutationRevision"],"expectedKind":"file","recursive":false + })).await.unwrap(); + assert_eq!(deleted["status"], "applied"); + assert!(!folder.join("renamed.txt").exists()); + viewer.shutdown().await; + host.shutdown().await; +} + +/// Full MCP discovery/create/converse flow over two real engines, a shared +/// registry and the same device relay used by the routing tests above. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn mcp_standalone_session_executes_on_the_selected_device() { + use serde_json::json; + use zeron_mcp::{Origin, Tools, Zeron}; + + let (relay_url, relay) = fake_device_room().await; + let dirs = tempfile::tempdir().unwrap(); + let a = assemble(&dirs.path().join("a"), "device-a"); + // Mock is deliberately disabled in production catalogs. Offer a scripted + // Codex adapter on B so MCP exercises normal availability validation. + let remote_dir = dirs.path().join("b"); + std::fs::create_dir_all(&remote_dir).unwrap(); + std::fs::write(remote_dir.join("device-id"), "device-b").unwrap(); + let profile = zeron_engine::EngineProfile::development(&remote_dir, "dev-org", "dev-user"); + let remote_store = zeron_sync::DocsStore::open(profile.store_root()).unwrap(); + let b = EngineCore::assemble_with_profile( + profile, + registry_for(HarnessId::Codex), + HarnessId::Codex, + None, + ) + .unwrap(); + let registry = zeron_sync::registry::mock_server::MockRegistryServer::start().await; + a.workspace.connect_registry_url(®istry.url()); + b.workspace.connect_registry_url(®istry.url()); + let host = b.start_host_relay(&relay_url); + let mut config = LinkCacheConfig::new(relay_url, Arc::new(StaticToken("test-user".into()))); + config.probe_timeout = Duration::from_secs(5); + a.set_links(LinkCache::new(config)); + let client = zeron_rpc::memory_client(a.rpc_service()); + let tools = Tools::new(Arc::new(Zeron::with_client( + client, + Origin { + chat_id: Some("coordinator".into()), + device_id: Some("device-a".into()), + }, + ))); + a.workspace + .create_chat("coordinator", None, Some("device-a"), None, None) + .unwrap(); + let folder = dirs.path().join("remote-project"); + std::fs::create_dir_all(&folder).unwrap(); + b.workspace + .create_space( + "remote-project", + "device-b", + folder.to_str().unwrap(), + None, + false, + ) + .unwrap(); + + let deadline = tokio::time::Instant::now() + Duration::from_secs(15); + loop { + let devices = tools.call("list_devices", json!({})).await.unwrap(); + let projects = tools + .call("list_projects", json!({"device":"device-b"})) + .await; + if devices["devices"].as_array().unwrap().len() == 2 + && projects + .as_ref() + .is_ok_and(|p| p["projects"].as_array().unwrap().len() == 1) + && tools + .call("list_harnesses", json!({"device":"device-b"})) + .await + .is_ok() + { + break; + } + assert!( + tokio::time::Instant::now() < deadline, + "discovery did not converge" + ); + tokio::time::sleep(Duration::from_millis(100)).await; + } + let projects = tools + .call("list_projects", json!({"device":"device-b"})) + .await + .unwrap(); + let project = projects["projects"][0]["id"].as_str().unwrap(); + tools + .call( + "list_models", + json!({"device":"device-b", "harness":"codex"}), + ) + .await + .unwrap(); + let created = tools + .call( + "create_chat", + json!({ + "kind":"chat", "device":"device-b", "project":project, + "harness":"codex", "title":"Remote MCP session", "prompt":"execute on B", + "wait":true, "timeout_secs":30 + }), + ) + .await + .unwrap(); + let chat_id = created["chatId"].as_str().unwrap(); + assert_eq!(created["kind"], "chat"); + assert!(created["parentChatId"].is_null()); + assert_eq!(created["deviceId"], "device-b"); + assert_eq!(created["turn"]["outcome"], "completed", "{created}"); + assert_eq!(created["turn"]["replies"][0]["text"], "remote reply"); + assert_eq!(created["turn"]["replies"][0]["deviceId"], "device-b"); + let chat = b.workspace.chat(chat_id).unwrap().unwrap(); + assert_eq!(chat.space_id.as_deref(), Some(project)); + assert!( + chat.parent_chat_id.is_none(), + "eligible for Sessions on every client" + ); + assert!( + a.sessions.session_status(chat_id).is_none(), + "A must not execute the prompt" + ); + assert!( + b.sessions.session_status(chat_id).is_some(), + "B actually ran the harness" + ); + let read = tools + .call("read_chat", json!({"chat":chat_id})) + .await + .unwrap(); + assert!( + read["messages"] + .as_array() + .unwrap() + .iter() + .any(|m| m["role"] == "assistant" + && m["text"] == "remote reply" + && m["deviceId"] == "device-b") + ); + let sent = tools + .call( + "send_message", + json!({ + "chat":chat_id, "text":"a second remote turn", "wait":true, "timeout_secs":30 + }), + ) + .await + .unwrap(); + assert_eq!(sent["turn"]["outcome"], "completed", "{sent}"); + assert_eq!( + sent["turn"]["replies"].as_array().unwrap().len(), + 1, + "only the new turn" + ); + assert_eq!(sent["turn"]["replies"][0]["deviceId"], "device-b"); + let waited = tools + .call("wait_for_turn", json!({"chat":chat_id,"timeout_secs":2})) + .await + .unwrap(); + assert_eq!(waited["turn"]["outcome"], "completed"); + let interrupted = tools + .call("interrupt_chat", json!({"chat":chat_id})) + .await + .unwrap(); + let interrupt_id = interrupted["commandId"].as_str().unwrap(); + let deadline = tokio::time::Instant::now() + Duration::from_secs(15); + loop { + // Peer-relay delivery marks the processed-command ledger directly; + // the command row itself arrives separately through session-doc sync. + if remote_store.is_processed(interrupt_id).unwrap() { + break; + } + assert!( + tokio::time::Instant::now() < deadline, + "interrupt must reach B's command ledger" + ); + tokio::time::sleep(Duration::from_millis(100)).await; + } + a.shutdown().await; + b.shutdown().await; + drop(host); + relay.abort(); +} diff --git a/crates/engine/tests/diff_sync_churn.rs b/crates/engine/tests/diff_sync_churn.rs index 658b79aabd..a5af10bb96 100644 --- a/crates/engine/tests/diff_sync_churn.rs +++ b/crates/engine/tests/diff_sync_churn.rs @@ -285,3 +285,333 @@ async fn deleted_checkout_is_evicted_after_grace() { ); core.shutdown().await; } + +/// Block until the chat watch shows `chat_id` with the given archived flag. +async fn wait_chat_archived(core: &EngineCore, chat_id: &str, archived: bool) { + let deadline = tokio::time::Instant::now() + Duration::from_secs(60); + loop { + let settled = core + .workspace + .watch_chats() + .borrow() + .iter() + .any(|c| c.id == chat_id && c.archived == archived); + if settled { + return; + } + assert!( + tokio::time::Instant::now() < deadline, + "chat archive state settled before timeout" + ); + tokio::time::sleep(Duration::from_millis(100)).await; + } +} + +/// Poll until the checkout under `root` is tracked with at least one live +/// fs watch (watches attach on the blocking pool after the entry is added). +async fn wait_watched(sync: &CheckoutDiffSync, root: &Path) { + let deadline = tokio::time::Instant::now() + Duration::from_secs(60); + loop { + if sync + .tracked_checkouts() + .iter() + .any(|(tracked, watches)| tracked == root && *watches > 0) + { + return; + } + assert!( + tokio::time::Instant::now() < deadline, + "checkout watched before timeout" + ); + tokio::time::sleep(Duration::from_millis(100)).await; + } +} + +/// Archived chats cost nothing: no entry, no watch, no capture. Unarchiving +/// tracks the checkout again, and re-archiving drops it at once — archiving is +/// a deliberate state change, not a row flap, so it skips the orphan grace. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn archived_chats_get_no_entry_and_archiving_drops_it() { + let tmp = tempfile::tempdir().expect("tempdir"); + let live_dir = tmp.path().join("live"); + let archived_dir = tmp.path().join("archived"); + init_dirty_repo(&live_dir).await; + init_dirty_repo(&archived_dir).await; + let live_root = std::fs::canonicalize(&live_dir).unwrap(); + let archived_root = std::fs::canonicalize(&archived_dir).unwrap(); + + let core = assemble(&tmp.path().join("data")); + // A grace far beyond the test: any removal observed here is the + // archive fast path, not the orphan timeout. + let sync = CheckoutDiffSync::start_with_orphan_grace( + core.repos.clone(), + core.workspace.clone(), + &core.device_id, + None, + Duration::from_secs(3600), + ); + for (space, dir) in [("space-live", &live_dir), ("space-arch", &archived_dir)] { + core.workspace + .create_space(space, &core.device_id, &dir.to_string_lossy(), None, true) + .expect("space row"); + } + core.workspace + .create_chat("chat-live", Some("space-live"), None, None, None) + .expect("live chat"); + core.workspace + .create_chat("chat-arch", Some("space-arch"), None, None, None) + .expect("archived chat"); + core.workspace + .set_chat_archived("chat-arch", true) + .expect("archive"); + wait_chat_state(&core, "chat-live", true).await; + wait_chat_archived(&core, "chat-arch", true).await; + + sync.repair_now().await; + wait_watched(&sync, &live_root).await; + let tracked: Vec<_> = sync + .tracked_checkouts() + .into_iter() + .map(|(root, _)| root) + .collect(); + assert_eq!( + tracked, + vec![live_root.clone()], + "archived chat's checkout is not tracked" + ); + wait_for_diff(&sync).await; + assert!( + current_diffs(&sync) + .iter() + .all(|diff| Path::new(&diff.cwd) == live_root), + "no capture is published for an archived chat's checkout" + ); + + // Unarchive: the checkout is tracked and watched again. + core.workspace + .set_chat_archived("chat-arch", false) + .expect("unarchive"); + wait_chat_archived(&core, "chat-arch", false).await; + sync.reconcile_now().await; + wait_watched(&sync, &archived_root).await; + + // Re-archive: the entry (watches, published diff) goes on this pass. + core.workspace + .set_chat_archived("chat-arch", true) + .expect("re-archive"); + wait_chat_archived(&core, "chat-arch", true).await; + sync.reconcile_now().await; + let tracked: Vec<_> = sync + .tracked_checkouts() + .into_iter() + .map(|(root, _)| root) + .collect(); + assert_eq!( + tracked, + vec![live_root.clone()], + "archiving drops the entry at once" + ); + assert!( + current_diffs(&sync) + .iter() + .all(|diff| Path::new(&diff.cwd) == live_root), + "archiving drops the checkout's published diff" + ); + core.shutdown().await; +} + +fn chat_row(core: &EngineCore, chat_id: &str) -> zeron_proto::Chat { + core.workspace + .watch_chats() + .borrow() + .iter() + .find(|c| c.id == chat_id) + .cloned() + .expect("chat row") +} + +/// Poll until `check` holds or panic with `what`. +async fn eventually(what: &str, mut check: impl FnMut() -> bool) { + let deadline = tokio::time::Instant::now() + Duration::from_secs(60); + while !check() { + assert!(tokio::time::Instant::now() < deadline, "{what}"); + tokio::time::sleep(Duration::from_millis(100)).await; + } +} + +/// An archived chat someone has open (an engine-held interest guard — one +/// per transcript stream) keeps full functionality: its checkout is tracked +/// and watched, the live diff is published, and its `checkoutId` is stamped +/// even though it was archived before it ever was a checkout. Once the last +/// viewer leaves and the linger lapses, the checkout is released again. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn open_archived_chat_is_tracked_until_interest_lapses() { + let tmp = tempfile::tempdir().expect("tempdir"); + let dir = tmp.path().join("folder"); + std::fs::create_dir_all(&dir).unwrap(); + + let core = assemble(&tmp.path().join("data")); + let sync = CheckoutDiffSync::start_with_timings( + core.repos.clone(), + core.workspace.clone(), + &core.device_id, + None, + Duration::from_secs(3600), + Duration::from_millis(300), + ); + core.workspace + .create_space( + "space-1", + &core.device_id, + &dir.to_string_lossy(), + None, + true, + ) + .expect("space row"); + core.workspace + .create_chat("chat-1", Some("space-1"), None, None, None) + .expect("chat row"); + core.workspace + .set_chat_archived("chat-1", true) + .expect("archive"); + wait_chat_archived(&core, "chat-1", true).await; + // Only now does the folder become a checkout: the archived chat has never + // been grouped, so nothing has stamped its checkoutId. + init_dirty_repo(&dir).await; + let root = std::fs::canonicalize(&dir).unwrap(); + sync.repair_now().await; + assert!( + sync.tracked_checkouts().is_empty(), + "archived and unopened: free" + ); + assert_eq!(chat_row(&core, "chat-1").checkout_id, None); + + let interest = sync.retain_chat("chat-1"); + wait_watched(&sync, &root).await; + let diff = wait_for_diff(&sync).await; + assert_eq!( + Path::new(&diff.cwd), + root, + "the open chat's live diff is published" + ); + eventually("checkoutId stamped for the open archived chat", || { + chat_row(&core, "chat-1").checkout_id.as_deref() == Some(diff.checkout_id.as_str()) + }) + .await; + + // Edits while open still flow through the live watch. + std::fs::write(dir.join("a.txt"), "changed while open\n").unwrap(); + eventually("live diff follows edits", || { + current_diffs(&sync) + .first() + .is_some_and(|d| d.checksum != diff.checksum) + }) + .await; + + drop(interest); + eventually("released once the linger lapses", || { + sync.tracked_checkouts().is_empty() && current_diffs(&sync).is_empty() + }) + .await; + core.shutdown().await; +} + +/// Discard needs no entry: an archived chat's checkout (nobody watching) is +/// discarded directly, still guarded by the snapshot checksum. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn discard_works_for_an_untracked_checkout() { + let tmp = tempfile::tempdir().expect("tempdir"); + let dir = tmp.path().join("repo"); + init_dirty_repo(&dir).await; + let core = assemble(&tmp.path().join("data")); + let sync = CheckoutDiffSync::start_with_orphan_grace( + core.repos.clone(), + core.workspace.clone(), + &core.device_id, + None, + Duration::from_secs(3600), + ); + let identity = core.repos.checkout_identity(&dir).await.expect("identity"); + assert!(sync.tracked_checkouts().is_empty()); + + let stale = sync + .discard_working_tree(&identity, "not-the-checksum") + .await; + assert!(stale.is_err(), "a stale checksum is refused"); + assert_eq!( + std::fs::read_to_string(dir.join("a.txt")).unwrap(), + "one\ntwo\nedited\n" + ); + + let snapshot = zeron_engine::capture_diff(&core.repos, &identity.root) + .await + .expect("capture"); + sync.discard_working_tree(&identity, &snapshot.checksum) + .await + .expect("discard without an entry"); + assert_eq!( + std::fs::read_to_string(dir.join("a.txt")).unwrap(), + "one\ntwo\n" + ); + core.shutdown().await; +} + +/// While a checkout's chats are gone (orphan grace pending), neither repair +/// kicks nor fs churn re-capture it; if the chats come back, the skipped work +/// is caught up at once. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn orphaned_entry_skips_captures_and_catches_up_on_return() { + let tmp = tempfile::tempdir().expect("tempdir"); + let dir = tmp.path().join("repo"); + init_dirty_repo(&dir).await; + let core = assemble(&tmp.path().join("data")); + let sync = CheckoutDiffSync::start_with_orphan_grace( + core.repos.clone(), + core.workspace.clone(), + &core.device_id, + None, + Duration::from_secs(3600), + ); + core.workspace + .create_space( + "space-1", + &core.device_id, + &dir.to_string_lossy(), + None, + true, + ) + .expect("space row"); + core.workspace + .create_chat("chat-1", Some("space-1"), None, None, None) + .expect("chat row"); + wait_chat_state(&core, "chat-1", true).await; + sync.reconcile_now().await; + let root = std::fs::canonicalize(&dir).unwrap(); + wait_watched(&sync, &root).await; + let before = wait_for_diff(&sync).await; + + core.workspace.delete_chat("chat-1").expect("delete chat"); + wait_chat_state(&core, "chat-1", false).await; + sync.reconcile_now().await; // marks orphaned + std::fs::write(dir.join("a.txt"), "edited while orphaned\n").unwrap(); + sync.sync_all(); // a repair tick's kick + tokio::time::sleep(Duration::from_secs(3)).await; + assert_eq!( + current_diffs(&sync)[0].checksum, + before.checksum, + "no capture for a chat-less checkout" + ); + + core.workspace + .create_chat("chat-1", Some("space-1"), None, None, None) + .expect("chat row again"); + wait_chat_state(&core, "chat-1", true).await; + sync.reconcile_now().await; + eventually("skipped capture caught up on return", || { + current_diffs(&sync) + .first() + .is_some_and(|d| d.checksum != before.checksum) + }) + .await; + core.shutdown().await; +} diff --git a/crates/engine/tests/e2e.rs b/crates/engine/tests/e2e.rs index 2c2ccca088..f1ee433898 100644 --- a/crates/engine/tests/e2e.rs +++ b/crates/engine/tests/e2e.rs @@ -11,7 +11,7 @@ use futures::stream::BoxStream; use zeron_doc::{ MessagePart, MessageRole, MessageStatus, SegmentWriter, SessionCommandEntry, - SessionCommandPayload, SessionCommandStatus, SessionDoc, SessionMessageEntry, + SessionCommandPayload, SessionCommandStatus, SessionDoc, SessionMessageEntry, SubagentStatus, }; use zeron_engine::{EngineCore, HarnessRegistry, RunJournal}; use zeron_harness::mock::MockHarness; @@ -1281,6 +1281,160 @@ async fn recover_stale_journal_stamps_aborted_on_boot() { ); } +fn spawn_chip(id: &str, status: SubagentStatus) -> MessagePart { + MessagePart::Tool { + id: id.into(), + call: ToolCall::Unknown { + name: "Agent: scout".into(), + input: None, + }, + output: None, + diff: None, + is_error: false, + resolved: true, + output_ref: None, + output_bytes: None, + diff_ref: None, + diff_stats: None, + subagent_ref: Some(format!("{CHAT}--sub--{id}")), + subagent_status: Some(status), + subagent_tail: None, + } +} + +#[tokio::test] +async fn recover_stale_journal_settles_chips_in_completed_local_entries() { + let dir = tempfile::tempdir().unwrap(); + let device_id = "dev-host-fixed"; + std::fs::write(dir.path().join("device-id"), device_id).unwrap(); + let journal = RunJournal::open(dir.path().join("orgs/dev-org/dev-user/journals")).unwrap(); + journal + .append( + CHAT, + &AgentEvent::Subagent { + parent_tool_use_id: "spawn-running".into(), + event: Box::new(AgentEvent::TextDelta { + text: "working".into(), + }), + }, + ) + .unwrap(); + let doc = SessionDoc::init(CHAT).unwrap(); + for (id, device) in [("local", device_id), ("remote", "other-device")] { + doc.push_message(&SessionMessageEntry { + id: id.into(), + role: MessageRole::Assistant, + parts: vec![ + spawn_chip(&format!("{id}-running"), SubagentStatus::Running), + spawn_chip(&format!("{id}-done"), SubagentStatus::Done), + ], + created_at: 1, + device_id: device.into(), + status: Some(MessageStatus::Complete), + continuation_of: None, + duration_ms: None, + }) + .unwrap(); + } + let store = DocsStore::open(dir.path().join("orgs/dev-org/dev-user")).unwrap(); + store + .save_snapshot(CHAT, &doc.export_snapshot().unwrap()) + .unwrap(); + let core = assemble( + dir.path(), + Arc::new(MockHarness { + script: mock_script(), + }), + ); + let all = entries(&core); + let statuses: Vec<_> = all + .iter() + .flat_map(|e| &e.parts) + .filter_map(|p| match p { + MessagePart::Tool { + subagent_status, .. + } => *subagent_status, + _ => None, + }) + .collect(); + assert_eq!( + statuses, + [ + SubagentStatus::Failed, + SubagentStatus::Done, + SubagentStatus::Running, + SubagentStatus::Done + ] + ); + assert!( + all.iter() + .all(|e| e.status == Some(MessageStatus::Complete)) + ); + core.shutdown().await; +} + +#[tokio::test] +async fn subagent_done_without_a_live_sink_updates_a_persisted_chip() { + let dir = tempfile::tempdir().unwrap(); + let core = assemble( + dir.path(), + Arc::new(MockHarness { + script: vec![ + AgentEvent::Subagent { + parent_tool_use_id: "old-spawn".into(), + event: Box::new(done(DoneStatus::Completed)), + }, + done(DoneStatus::Completed), + ], + }), + ); + core.doc_host + .open(CHAT) + .unwrap() + .doc() + .push_message(&SessionMessageEntry { + id: "old-assistant".into(), + role: MessageRole::Assistant, + parts: vec![spawn_chip("old-spawn", SubagentStatus::Running)], + created_at: 1, + device_id: core.device_id.clone(), + status: Some(MessageStatus::Complete), + continuation_of: None, + duration_ms: None, + }) + .unwrap(); + core.sessions + .dispatch(CHAT, HarnessId::Mock, run_request("resume"), None) + .await + .unwrap(); + wait_for( + || { + core.sessions + .session_status(CHAT) + .is_some_and(|s| s.status == SessionStatus::Idle) + }, + "resumed turn completes", + ) + .await; + let all = entries(&core); + let chip = all + .iter() + .flat_map(|e| &e.parts) + .find(|p| matches!(p, MessagePart::Tool { id, .. } if id == "old-spawn")) + .unwrap(); + assert!( + matches!( + chip, + MessagePart::Tool { + subagent_status: Some(SubagentStatus::Done), + .. + } + ), + "{chip:?}" + ); + core.shutdown().await; +} + #[tokio::test] async fn rpc_surface_over_in_memory_transport() { let dir = tempfile::tempdir().unwrap(); @@ -2963,7 +3117,9 @@ async fn generated_image_is_materialized_before_publication_and_survives_reopen( }) .collect(); assert_eq!(images.len(), 1); - assert!(std::path::Path::new(&images[0]).starts_with(uploads.dir())); + // Materialization canonicalizes the uploads dir; macOS temp dirs sit + // behind the /var -> /private/var symlink. + assert!(std::path::Path::new(&images[0]).starts_with(uploads.dir().canonicalize().unwrap())); let serialized = serde_json::to_string(&journal.replay(CHAT, 0).unwrap()).unwrap(); assert!(!serialized.contains(source.to_str().unwrap())); assert!(!serialized.contains("BASE64_SENTINEL")); @@ -3069,3 +3225,141 @@ async fn real_image_generation_profile_smoke() { ); core.sessions.shutdown().await; } + +/// A harness that fails before streaming (an OpenCode server that never +/// booted) must leave its reason in the transcript, not only a bare +/// "Run failed" status. +#[tokio::test(flavor = "multi_thread")] +async fn start_failure_lands_in_the_transcript() { + struct FailsToStart; + #[async_trait] + impl Harness for FailsToStart { + fn id(&self) -> HarnessId { + HarnessId::Mock + } + fn display_name(&self) -> &str { + "FailsToStart" + } + fn supports_steering(&self) -> bool { + false + } + fn steering_mode(&self) -> SteeringMode { + SteeringMode::TurnBoundary + } + fn reasoning_levels(&self) -> &[ReasoningLevel] { + &[] + } + async fn models(&self) -> Result, HarnessError> { + Ok(vec![]) + } + async fn run( + &self, + _request: RunRequest, + _controls: RunControls, + ) -> Result>, HarnessError> { + Err(HarnessError::Protocol("server never booted".into())) + } + } + + let dir = tempfile::tempdir().unwrap(); + let core = assemble(dir.path(), Arc::new(FailsToStart)); + let handle = core.doc_host.open(CHAT).unwrap(); + queue_as_viewer( + handle.doc(), + "cmd-run-fails", + SessionCommandPayload::Run { + request: run_request("hello"), + message_id: "m-1".into(), + }, + ); + wait_for( + || core.sessions.session_status(CHAT).map(|s| s.status) == Some(SessionStatus::Errored), + "errored", + ) + .await; + let entries = entries_now(&core); + let assistant = entries + .iter() + .find(|e| e.role == MessageRole::Assistant) + .expect("assistant entry for the failed start"); + assert_eq!(assistant.status, Some(MessageStatus::Complete)); + assert!(matches!( + assistant.parts.as_slice(), + [MessagePart::Error { message, .. }] if message.contains("server never booted") + )); + core.sessions.shutdown().await; +} + +/// The session row carries how many subagents are streaming, so sidebars on +/// every device can badge a chat they have not opened. +#[tokio::test(flavor = "multi_thread")] +async fn session_row_counts_running_subagents_until_the_run_ends() { + fn spawn(id: &str) -> AgentEvent { + AgentEvent::ToolCall { + id: id.into(), + call: ToolCall::Unknown { + name: "Agent: probe".into(), + input: None, + }, + } + } + fn chatter(parent: &str) -> AgentEvent { + AgentEvent::Subagent { + parent_tool_use_id: parent.into(), + event: Box::new(AgentEvent::TextDelta { + text: "working".into(), + }), + } + } + let dir = tempfile::tempdir().unwrap(); + let core = assemble( + dir.path(), + Arc::new(ScriptedHarness { + script: vec![ + spawn("sub-1"), + spawn("sub-2"), + chatter("sub-1"), + chatter("sub-2"), + AgentEvent::Subagent { + parent_tool_use_id: "sub-1".into(), + event: Box::new(done(DoneStatus::Completed)), + }, + ], + step_delay: Duration::from_millis(400), + hang_until_interrupt: true, + }), + ); + let running = || { + core.sessions + .session_status(CHAT) + .map(|s| s.running_subagents) + }; + let handle = core.doc_host.open(CHAT).unwrap(); + queue_as_viewer( + handle.doc(), + "subagents-run", + SessionCommandPayload::Run { + request: run_request("fan out"), + message_id: "subagents-user".into(), + }, + ); + + wait_for(|| running() == Some(2), "both subagents counted").await; + wait_for(|| running() == Some(1), "one subagent settled").await; + + queue_as_viewer( + handle.doc(), + "subagents-interrupt", + SessionCommandPayload::Interrupt {}, + ); + wait_for( + || core.sessions.session_status(CHAT).map(|s| s.status) == Some(SessionStatus::Idle), + "run to end", + ) + .await; + assert_eq!( + running(), + Some(0), + "an ended run leaves no subagents counted" + ); +} diff --git a/crates/engine/tests/m5_repos_diffs_terminals.rs b/crates/engine/tests/m5_repos_diffs_terminals.rs index e9571c95c7..3818fcb09b 100644 --- a/crates/engine/tests/m5_repos_diffs_terminals.rs +++ b/crates/engine/tests/m5_repos_diffs_terminals.rs @@ -238,6 +238,146 @@ async fn repos_round_trip_add_branches_worktrees() { ); } +#[tokio::test] +async fn repository_identity_spans_worktrees_and_clones() { + let temp = tempfile::tempdir().expect("tempdir"); + let repos = test_repos(&temp.path().join("data")); + let repo = temp.path().join("repo"); + init_repo(&repo).await; + let linked = temp.path().join("linked"); + git( + &repo, + &[ + "worktree", + "add", + "-q", + "-b", + "side", + linked.to_str().unwrap(), + ], + ) + .await; + + // The trunk's root commit: shared by worktrees, and by clones whatever + // their remotes say — an old name kept alive by a rename redirect, an + // SSH host alias, another transport. + let root = git_stdout(&repo, &["rev-list", "--max-parents=0", "HEAD"]).await; + let identity = repos.repository_identity(&repo).await.expect("identity"); + assert_eq!(identity, format!("commit:{root}")); + assert_eq!(repos.repository_identity(&linked).await.unwrap(), identity); + git( + &repo, + &["remote", "add", "origin", "git@github-alias:zeronsh/old-name.git"], + ) + .await; + let clone = temp.path().join("clone"); + git( + temp.path(), + &["clone", "-q", repo.to_str().unwrap(), clone.to_str().unwrap()], + ) + .await; + git( + &clone, + &["remote", "set-url", "origin", "https://github.com/zeronsh/new-name"], + ) + .await; + assert_eq!(repos.repository_identity(&repo).await.unwrap(), identity); + assert_eq!(repos.repository_identity(&clone).await.unwrap(), identity); + + // Folders below the top level stay their own projects: monorepo + // siblings differ, the same subfolder matches across clones. + for dir in [&repo, &clone] { + std::fs::create_dir_all(dir.join("apps/web")).unwrap(); + std::fs::create_dir_all(dir.join("apps/api")).unwrap(); + } + let web = repos + .repository_identity(&repo.join("apps/web")) + .await + .unwrap(); + assert_eq!(web, format!("{identity}:apps/web")); + assert_ne!( + repos + .repository_identity(&repo.join("apps/api")) + .await + .unwrap(), + web + ); + assert_eq!( + repos + .repository_identity(&clone.join("apps/web")) + .await + .unwrap(), + web + ); + + // An unrelated repository has its own root. + let other = temp.path().join("other"); + std::fs::create_dir_all(&other).unwrap(); + git(&other, &["init", "-q", "-b", "main"]).await; + std::fs::write(other.join("b.txt"), "unrelated\n").unwrap(); + git(&other, &["add", "."]).await; + git(&other, &["commit", "-q", "-m", "other initial"]).await; + assert_ne!(repos.repository_identity(&other).await.unwrap(), identity); + + // Merging an unrelated history in adds a root on a second parent; the + // trunk's root still names the repository. + git(&repo, &["fetch", "-q", other.to_str().unwrap(), "main"]).await; + git( + &repo, + &[ + "merge", + "-q", + "--allow-unrelated-histories", + "-m", + "merge other", + "FETCH_HEAD", + ], + ) + .await; + assert_eq!(repos.repository_identity(&repo).await.unwrap(), identity); + + // A shallow clone can't vouch for its root: the remote names it. + let shallow = temp.path().join("shallow"); + git( + temp.path(), + &[ + "clone", + "-q", + "--depth", + "1", + &format!("file://{}", repo.display()), + shallow.to_str().unwrap(), + ], + ) + .await; + git( + &shallow, + &["remote", "set-url", "origin", "https://GitHub.com/ZeronSH/Zeron.git"], + ) + .await; + assert_eq!( + repos.repository_identity(&shallow).await.unwrap(), + "github.com/zeronsh/zeron" + ); + + // No commits yet: the remote (origin, else the first), then a + // device-scoped local identity. + let empty = temp.path().join("empty"); + std::fs::create_dir_all(&empty).unwrap(); + git(&empty, &["init", "-q", "-b", "main"]).await; + let local = repos.repository_identity(&empty).await.unwrap(); + assert!(local.starts_with("local:"), "{local}"); + git( + &empty, + &["remote", "add", "upstream", "git@github.com:Anara/Comet.git"], + ) + .await; + assert_eq!( + repos.repository_identity(&empty).await.unwrap(), + "github.com/anara/comet" + ); +} + #[tokio::test] async fn git_history_is_topological_paged_and_carries_public_refs() { let tmp = tempfile::tempdir().expect("tempdir"); @@ -1226,7 +1366,7 @@ async fn turn_diff_captures_only_changes_since_snapshot() { // Pre-turn state: a tracked edit and an untracked file already exist. std::fs::write(repo_dir.join("a.txt"), "one\ntwo\npre-turn\n").expect("edit a.txt"); std::fs::write(repo_dir.join("pre.txt"), "before the turn\n").expect("pre.txt"); - let turn_tree = snapshot_tree(&repo_dir).await.expect("snapshot"); + let turn_tree = snapshot_tree(&repos, &repo_dir).await.expect("snapshot"); // Nothing changed yet: the turn diff is empty (pre-existing untracked // files must NOT reappear as new). @@ -1328,6 +1468,7 @@ async fn spaces_sync_stamps_git_presence_and_reacts_to_git_init() { }; assert!(!space.git_detected, "plain folder must read as non-git"); assert!(space.checkout_id.is_none()); + assert!(space.repository_id.is_none()); // `git init` later flips the stamp (watcher and/or explicit recheck). git(&folder, &["init", "-b", "main"]).await; @@ -1346,6 +1487,14 @@ async fn spaces_sync_stamps_git_presence_and_reacts_to_git_init() { .expect("watch alive"); }; assert!(space.checkout_id.is_some(), "git space gains a checkout id"); + assert!( + space + .repository_id + .as_deref() + .is_some_and(|id| id.starts_with("local:")), + "remote-less git space gains a local repository id: {:?}", + space.repository_id + ); core.shutdown().await; } diff --git a/crates/engine/tests/m5c_accounts_uploads_titles.rs b/crates/engine/tests/m5c_accounts_uploads_titles.rs index 00ab909836..5ddf42e489 100644 --- a/crates/engine/tests/m5c_accounts_uploads_titles.rs +++ b/crates/engine/tests/m5c_accounts_uploads_titles.rs @@ -885,11 +885,23 @@ async fn uploads_chunk_commit_readback_and_jail() { .read_chunk(&outside.to_string_lossy(), 0, &[tmp.path().to_path_buf()]) .expect("cwd-rooted read"); assert_eq!(BASE64.decode(&ok.data).expect("data"), b"nope"); - // Non-image extensions are refused even inside the jail (zeron parity). + // Files the user uploaded read back whatever their type (a queued message + // restores them for editing)… let text = PathBuf::from(uploads.dir()).join("notes.txt"); std::fs::create_dir_all(uploads.dir()).expect("uploads dir"); std::fs::write(&text, b"text").expect("txt"); - assert!(uploads.read_chunk(&text.to_string_lossy(), 0, &[]).is_err()); + let chunk = uploads + .read_chunk(&text.to_string_lossy(), 0, &[]) + .expect("an uploaded file reads back"); + assert_eq!(chunk.mime_type, "application/octet-stream"); + // …but a non-image in a workspace root is still refused (zeron parity). + let in_root = tmp.path().join("notes.txt"); + std::fs::write(&in_root, b"text").expect("txt"); + assert!( + uploads + .read_chunk(&in_root.to_string_lossy(), 0, &[tmp.path().to_path_buf()]) + .is_err() + ); // Bogus upload ids never become paths. assert!(uploads.append("../evil", "aGk=", None).is_err()); diff --git a/crates/engine/tests/queued_attachments.rs b/crates/engine/tests/queued_attachments.rs index 2d67cedc51..bb75950443 100644 --- a/crates/engine/tests/queued_attachments.rs +++ b/crates/engine/tests/queued_attachments.rs @@ -239,6 +239,94 @@ async fn run_defers_until_attachment_bytes_land_then_executes_rewritten() { core.shutdown().await; } +/// A file that is not an image rides the same deferred-run transport: it waits +/// for its bytes, runs with the committed path, and reads back as opaque bytes +/// so a queued message can be restored for editing. +#[tokio::test(flavor = "multi_thread")] +async fn run_with_a_non_image_file_defers_executes_and_reads_back() { + let tmp = tempfile::tempdir().unwrap(); + let registry = HarnessRegistry::new(); + registry.register(Arc::new(AckHarness)); + let core = EngineCore::assemble( + &tmp.path().join("data"), + Arc::new(registry), + HarnessId::Mock, + None, + ) + .expect("engine core assembles"); + let client = zeron_rpc::memory_client(core.rpc_service()); + client + .call( + zeron_rpc::methods::MUTATE, + serde_json::json!({ "op": "createChat", "chatId": CHAT, "deviceId": core.device_id }), + ) + .await + .expect("createChat"); + core.workspace + .rename_chat(CHAT, "Pre-titled") + .expect("rename chat"); + + core.doc_host + .queue_command(CHAT, run_payload("msg-file-1", "pending://att-2/notes.md")) + .expect("queue run command"); + tokio::time::sleep(Duration::from_millis(400)).await; + assert_eq!( + complete_assistant_count(&core), + 0, + "run must defer while the file is in transit" + ); + + client + .call( + zeron_rpc::methods::UPLOAD_CHUNK, + serde_json::json!({ "uploadId": "att-2", "seq": 0, "data": BASE64.encode(b"# notes") }), + ) + .await + .expect("upload chunk"); + client + .call( + zeron_rpc::methods::UPLOAD_COMMIT, + serde_json::json!({ "uploadId": "att-2", "fileName": "notes.md" }), + ) + .await + .expect("upload commit"); + wait_for( + || complete_assistant_count(&core) == 1, + "deferred run to execute", + ) + .await; + + let user_text = entries(&core) + .iter() + .find(|e| e.role == MessageRole::User) + .and_then(|e| { + e.parts.iter().find_map(|p| match p { + zeron_doc::MessagePart::Text { text, .. } => Some(text.clone()), + _ => None, + }) + }) + .expect("user entry persisted"); + assert!(!user_text.contains("pending://"), "{user_text}"); + let committed = user_text + .lines() + .find(|line| line.ends_with("att-2-notes.md")) + .and_then(|line| line.strip_prefix("- ")) + .unwrap_or_else(|| panic!("committed path missing: {user_text}")); + + // Restoring the queued message for editing reads the file back. + let chunk = client + .call( + zeron_rpc::methods::READ_ATTACHMENT_CHUNK, + serde_json::json!({ "path": committed, "offset": 0 }), + ) + .await + .expect("read back the uploaded file"); + assert_eq!(chunk["mimeType"], "application/octet-stream"); + assert_eq!(chunk["data"], BASE64.encode(b"# notes")); + + core.shutdown().await; +} + /// Mobile sends made while a turn runs park on the shared queue with /// `pending://` refs (the bytes chase them over the peer link). The queue /// drain must wait for those bytes and hand the harness the committed local diff --git a/crates/engine/tests/restart_resume.rs b/crates/engine/tests/restart_resume.rs index fb07750ae6..02b5a8d068 100644 --- a/crates/engine/tests/restart_resume.rs +++ b/crates/engine/tests/restart_resume.rs @@ -693,6 +693,191 @@ async fn fresh_crash_auto_resumes_and_notes_the_interruption() { core.shutdown().await; } +/// A kill moments into a turn: the journal shows the run, but the doc's +/// debounced save never stored a reply entry, so the user's message is the +/// transcript's last word. That is a crashed turn too — it is picked back +/// up, not left unanswered. +#[tokio::test] +async fn a_crash_before_the_reply_reached_disk_still_auto_resumes() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path().join("data"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("device-id"), "dev-crash").unwrap(); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() as i64; + { + let store = DocsStore::open(dir.join("orgs/dev-org/dev-user")).unwrap(); + let doc = SessionDoc::init(CHAT).unwrap(); + doc.push_message(&SessionMessageEntry { + id: "msg-user-1".into(), + role: MessageRole::User, + parts: vec![MessagePart::Text { + id: "t0".into(), + text: "long task".into(), + }], + created_at: now - 5_000, + device_id: "dev-crash".into(), + status: Some(MessageStatus::Complete), + continuation_of: None, + duration_ms: None, + }) + .unwrap(); + store + .save_snapshot(CHAT, &doc.export_snapshot().unwrap()) + .unwrap(); + let journal = RunJournal::open(dir.join("orgs/dev-org/dev-user/journals")).unwrap(); + journal + .append( + CHAT, + &AgentEvent::SessionStarted { + harness: HarnessId::Mock, + model: "mock-1".into(), + tools: vec![], + cwd: "/tmp".into(), + session_id: "hs-crash".into(), + assistant_message_id: "msg-assistant-1".into(), + }, + ) + .unwrap(); + } + + let requests: RequestLog = Arc::new(Mutex::new(Vec::new())); + let core = assemble( + &dir, + RecordingHarness { + requests: requests.clone(), + session_id: "hs-after-crash".into(), + fail_starts: Default::default(), + }, + ); + wait_for( + || complete_assistant_count(&core) == 1, + "the crashed turn to be picked back up", + ) + .await; + let recorded = requests.lock().unwrap().clone(); + let revived = recorded + .iter() + .find(|r| r.prompt == "long task") + .expect("auto-resumed dispatch reached the harness"); + assert_eq!(revived.resume.as_deref(), Some("hs-crash")); + core.shutdown().await; +} + +/// The agent reports where it runs with symlinks resolved; the chat names +/// its folder through the symlink. A crash mid-turn leaves the resolved +/// spelling stored (only a turn's Done restores the request's), and the +/// recovered run must still resume that same conversation — not start fresh +/// with its history gone (live, Claude Code on macOS's `/var` → `/private/var`). +#[cfg(unix)] +#[tokio::test] +async fn a_crash_recovers_the_conversation_through_a_symlinked_folder() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path().join("data"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("device-id"), "dev-crash").unwrap(); + let real = tmp.path().join("real-project"); + std::fs::create_dir_all(&real).unwrap(); + let link = tmp.path().join("linked-project"); + std::os::unix::fs::symlink(&real, &link).unwrap(); + let real = std::fs::canonicalize(&real).unwrap(); + let (real, link) = (real.to_str().unwrap(), link.to_str().unwrap()); + + let requests: RequestLog = Arc::new(Mutex::new(Vec::new())); + { + let core = assemble( + &dir, + RecordingHarness { + requests: requests.clone(), + session_id: "unused".into(), + fail_starts: Default::default(), + }, + ); + core.workspace + .create_space("space-link", &core.device_id, link, None, false) + .unwrap(); + core.workspace + .create_chat(CHAT, Some("space-link"), None, None, None) + .unwrap(); + core.shutdown().await; + } + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() as i64; + { + let store = DocsStore::open(dir.join("orgs/dev-org/dev-user")).unwrap(); + let doc = SessionDoc::init(CHAT).unwrap(); + doc.push_message(&SessionMessageEntry { + id: "msg-user-1".into(), + role: MessageRole::User, + parts: vec![MessagePart::Text { + id: "t0".into(), + text: "long task".into(), + }], + created_at: now - 5_000, + device_id: "dev-crash".into(), + status: Some(MessageStatus::Complete), + continuation_of: None, + duration_ms: None, + }) + .unwrap(); + store + .save_snapshot(CHAT, &doc.export_snapshot().unwrap()) + .unwrap(); + let journal = RunJournal::open(dir.join("orgs/dev-org/dev-user/journals")).unwrap(); + journal + .append( + CHAT, + &AgentEvent::SessionStarted { + harness: HarnessId::Mock, + model: "mock-1".into(), + tools: vec![], + cwd: real.into(), + session_id: "hs-crash".into(), + assistant_message_id: "msg-assistant-1".into(), + }, + ) + .unwrap(); + } + + let core = assemble( + &dir, + RecordingHarness { + requests: requests.clone(), + session_id: "hs-crash".into(), + fail_starts: Default::default(), + }, + ); + wait_for( + || { + requests + .lock() + .unwrap() + .iter() + .any(|r| r.prompt == "long task") + }, + "the crashed turn to be picked back up", + ) + .await; + let revived = requests + .lock() + .unwrap() + .iter() + .find(|r| r.prompt == "long task") + .cloned() + .unwrap(); + assert_eq!(revived.cwd, link, "the chat's own folder"); + assert_eq!( + revived.resume.as_deref(), + Some("hs-crash"), + "the same conversation, through the symlink" + ); + core.shutdown().await; +} + #[tokio::test] async fn resume_is_cwd_scoped() { let tmp = tempfile::tempdir().unwrap(); diff --git a/crates/engine/tests/rich_composer_delivery.rs b/crates/engine/tests/rich_composer_delivery.rs index 7c06d48bd9..bc116d3ec6 100644 --- a/crates/engine/tests/rich_composer_delivery.rs +++ b/crates/engine/tests/rich_composer_delivery.rs @@ -37,7 +37,7 @@ const HARNESSES: [HarnessId; 9] = [ enum Delivery { Run(RunRequest), - Steer(String), + Steer(String, Vec), } struct DiscoveryGate { started: mpsc::UnboundedSender, @@ -135,7 +135,7 @@ impl Harness for RecordingHarness { _ = controls.interrupt.cancelled() => None, message = controls.steering.recv() => { let message = message?; - delivery.send(Delivery::Steer(message.prompt)).unwrap(); + delivery.send(Delivery::Steer(message.prompt, message.attachments)).unwrap(); Some((Ok(AgentEvent::Steered { assistant_message_id: None, next_assistant_message_id: None }), (controls, delivery))) } } @@ -290,7 +290,7 @@ async fn rich_selections_survive_fresh_warm_steer_and_attachment_delivery_for_ev .dispatch(CHAT, id, request(&warm), None) .await .unwrap(); - let Delivery::Steer(text) = receive(&mut rx).await else { + let Delivery::Steer(text, _) = receive(&mut rx).await else { panic!("warm run must use mailbox") }; assert_eq!(text, expected(&warm, &readable, id), "{id:?}"); @@ -301,7 +301,7 @@ async fn rich_selections_survive_fresh_warm_steer_and_attachment_delivery_for_ev core.sessions.steer(CHAT, &steer, None).await.unwrap(), SteerOutcome::Accepted )); - let Delivery::Steer(text) = receive(&mut rx).await else { + let Delivery::Steer(text, _) = receive(&mut rx).await else { panic!("steer expected") }; assert_eq!(text, expected(&steer, &readable, id), "{id:?}"); @@ -316,11 +316,13 @@ async fn rich_selections_survive_fresh_warm_steer_and_attachment_delivery_for_ev .dispatch(CHAT, id, req.clone(), None) .await .unwrap(); - let Delivery::Run(run) = receive(&mut rx).await else { - panic!("attachments require a new run") + // Images ride the live runtime's mailbox: replacing the runtime to + // inline them killed its background work. + let Delivery::Steer(text, attachments) = receive(&mut rx).await else { + panic!("an attachment must not replace the live runtime") }; - assert_delivered(&run.prompt, &attached, &readable, id); - assert_eq!(run.attachments, req.attachments); + assert_eq!(text, expected(&attached, &readable, id), "{id:?}"); + assert_eq!(attachments, req.attachments); assert_persisted(&core, &attached, 4); core.shutdown().await; } diff --git a/crates/engine/tests/runtime_survival.rs b/crates/engine/tests/runtime_survival.rs new file mode 100644 index 0000000000..4688d049f8 --- /dev/null +++ b/crates/engine/tests/runtime_survival.rs @@ -0,0 +1,1040 @@ +//! A live runtime is the agent's whole process — its background subagents and +//! background shells included. Sending the next message, steering, "send +//! now", switching models, or leaving a session parked must never tear it +//! down behind the user's back (2026-09-29: "sometimes sending a message +//! closes the existing claude session and re-opens it … it kills subagents +//! and sub processes"). The user's Stop is the one deliberate teardown. +//! +//! The harness here models one persistent agent process per `run()`: every +//! `run()` is a spawn, the `interrupt` token is its death, a turn stop ends +//! only the turn, and the test decides when the "background task" finishes. + +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use async_trait::async_trait; +use futures::StreamExt; +use futures::stream::BoxStream; +use tokio::sync::mpsc; + +use zeron_doc::{MessageStatus, SessionCommandPayload}; +use zeron_engine::{EngineCore, HarnessRegistry}; +use zeron_harness::{Harness, HarnessError, RunControls, TurnControl}; +use zeron_proto::{ + AgentEvent, DoneStatus, HarnessId, Model, ReasoningLevel, RunRequest, SandboxLevel, + SessionStatus, SteeringMode, +}; + +const CHAT: &str = "chat-runtime-survival"; + +#[derive(Default)] +struct Ledger { + spawns: AtomicUsize, + kills: AtomicUsize, + /// Every prompt the live process received, with its image count. + prompts: Mutex>, + /// The latest runtime's turn control (the test flips its background work). + turn: Mutex>, + /// The model each in-place reconfiguration switched the process to. + reconfigured: Mutex>>, + /// The model each process was spawned with. + spawned_models: Mutex>>, + /// Ends a turn whose prompt contains `wait`. + release: tokio::sync::Notify, +} + +/// One persistent agent process per `run()`. A prompt containing `hold` +/// stays in flight until stopped; any other prompt completes at once. A +/// steer containing `vanish` kills the process before it confirms the steer. +/// A prompt containing `wait` runs until the test releases it. +struct ProcessHarness { + ledger: Arc, + /// Model changes apply to the live process (`set_model`-style). + adopts_config: bool, + /// Takes input only between turns (Hermes, Antigravity): the engine + /// holds sends made mid-turn for the turn's end. + turn_boundary: bool, +} + +fn done(status: DoneStatus) -> AgentEvent { + AgentEvent::Done { + status, + result: None, + error: None, + session_id: Some("provider-session".into()), + } +} + +#[async_trait] +impl Harness for ProcessHarness { + fn id(&self) -> HarnessId { + HarnessId::Mock + } + fn display_name(&self) -> &str { + "Process" + } + fn supports_steering(&self) -> bool { + true + } + fn steering_mode(&self) -> SteeringMode { + if self.turn_boundary { + SteeringMode::TurnBoundary + } else { + SteeringMode::StepBoundary + } + } + fn reasoning_levels(&self) -> &[ReasoningLevel] { + &[ReasoningLevel::Medium, ReasoningLevel::High] + } + fn deterministic_turn_end(&self) -> bool { + true + } + fn stops_turn_in_place(&self) -> bool { + true + } + fn reconfigures_in_place(&self, live: &RunRequest, next: &RunRequest) -> bool { + self.adopts_config && live.cwd == next.cwd + } + async fn models(&self) -> Result, HarnessError> { + Ok(vec![]) + } + async fn run( + &self, + request: RunRequest, + mut controls: RunControls, + ) -> Result>, HarnessError> { + let ledger = self.ledger.clone(); + ledger.spawns.fetch_add(1, Ordering::SeqCst); + ledger + .spawned_models + .lock() + .unwrap() + .push(request.model.clone()); + *ledger.turn.lock().unwrap() = Some(controls.turn.clone()); + let (tx, rx) = mpsc::channel::>(64); + tokio::spawn(async move { + let turn = controls.turn.clone(); + let send = |event: AgentEvent| { + let tx = tx.clone(); + async move { tx.send(Ok(event)).await.is_ok() } + }; + let prompt = |text: &str, images: usize| { + ledger + .prompts + .lock() + .unwrap() + .push((text.to_owned(), images)); + text.contains("hold") + }; + let _ = send(AgentEvent::SessionStarted { + harness: HarnessId::Mock, + model: "process".into(), + tools: vec![], + cwd: request.cwd.clone(), + session_id: "provider-session".into(), + assistant_message_id: "a0".into(), + }) + .await; + let holding = prompt(&request.prompt, request.attachments.len()); + let _ = send(AgentEvent::TextDelta { + text: format!("on it: {}", request.prompt), + }) + .await; + let mut waiting = request.prompt.contains("wait"); + if !holding && !waiting && !send(done(DoneStatus::Completed)).await { + return; + } + loop { + tokio::select! { + biased; + _ = ledger.release.notified(), if waiting => { + waiting = false; + if !send(done(DoneStatus::Completed)).await { + return; + } + } + _ = controls.interrupt.cancelled() => { + // The process dies, and its background work with it. + ledger.kills.fetch_add(1, Ordering::SeqCst); + let _ = send(done(DoneStatus::Interrupted)).await; + return; + } + _ = turn.stop_requested() => { + if !send(done(DoneStatus::Interrupted)).await { + return; + } + } + steer = controls.steering.recv() => { + let Some(steer) = steer else { return }; + if steer.prompt.contains("vanish") { + return; + } + if let Some(config) = &steer.config { + ledger.reconfigured.lock().unwrap().push(config.model.clone()); + } + let holding = prompt(&steer.prompt, steer.attachments.len()); + let boundary = AgentEvent::Steered { + assistant_message_id: None, + next_assistant_message_id: None, + }; + if !send(boundary).await + || !send(AgentEvent::TextDelta { text: format!("on it: {}", steer.prompt) }).await + { + return; + } + waiting = steer.prompt.contains("wait"); + if !holding && !waiting && !send(done(DoneStatus::Completed)).await { + return; + } + } + } + } + }); + Ok( + futures::stream::unfold(rx, |mut rx| async move { rx.recv().await.map(|e| (e, rx)) }) + .boxed(), + ) + } +} + +struct Rig { + core: EngineCore, + ledger: Arc, + _dir: tempfile::TempDir, +} + +fn rig() -> Rig { + rig_with(false) +} + +fn rig_with(adopts_config: bool) -> Rig { + rig_full(adopts_config, false) +} + +fn rig_full(adopts_config: bool, turn_boundary: bool) -> Rig { + let ledger = Arc::new(Ledger::default()); + let registry = HarnessRegistry::new(); + registry.register(Arc::new(ProcessHarness { + ledger: ledger.clone(), + adopts_config, + turn_boundary, + })); + let dir = tempfile::tempdir().unwrap(); + let core = EngineCore::assemble(dir.path(), Arc::new(registry), HarnessId::Mock, None) + .expect("engine core assembles"); + Rig { + core, + ledger, + _dir: dir, + } +} + +fn request(prompt: &str) -> RunRequest { + RunRequest { + mcp: None, + prompt: prompt.into(), + harness: Some(HarnessId::Mock), + model: Some("process-1".into()), + reasoning: Some(ReasoningLevel::High), + model_options: serde_json::Map::from_iter([("fastMode".into(), serde_json::json!(true))]), + cwd: "/tmp".into(), + sandbox: SandboxLevel::WorkspaceWrite, + auto_approve: false, + attachments: Vec::new(), + worktree: None, + resume: None, + } +} + +fn status(core: &EngineCore) -> Option { + core.sessions.session_status(CHAT).map(|s| s.status) +} + +async fn wait_for(mut predicate: impl FnMut() -> bool, what: &str) { + let deadline = tokio::time::Instant::now() + Duration::from_secs(10); + while !predicate() { + assert!( + tokio::time::Instant::now() < deadline, + "timed out waiting for {what}" + ); + tokio::time::sleep(Duration::from_millis(10)).await; + } +} + +async fn settle(rig: &Rig, prompts: usize) { + wait_for( + || { + status(&rig.core) == Some(SessionStatus::Idle) + && rig.ledger.prompts.lock().unwrap().len() >= prompts + }, + "the turn to park", + ) + .await; +} + +#[tokio::test] +async fn sends_with_images_or_respelled_options_join_the_live_runtime() { + let rig = rig(); + rig.core + .sessions + .dispatch(CHAT, HarnessId::Mock, request("launch the scouts"), None) + .await + .unwrap(); + settle(&rig, 1).await; + + // The phone spells the same option as a string, and the message carries + // an image: neither is a different runtime. + let mut follow_up = request("what is in this screenshot?"); + follow_up.model_options = + serde_json::Map::from_iter([("fastMode".into(), serde_json::json!("true"))]); + follow_up.sandbox = SandboxLevel::WorkspaceWrite; + follow_up.attachments = vec!["/tmp/shot.png".into()]; + rig.core + .sessions + .dispatch(CHAT, HarnessId::Mock, follow_up, None) + .await + .unwrap(); + settle(&rig, 2).await; + + // Through the durable command plane too (the desktop/mobile send path). + rig.core + .doc_host + .queue_command( + CHAT, + SessionCommandPayload::Run { + request: request("and one more"), + message_id: "m-3".into(), + }, + ) + .unwrap(); + settle(&rig, 3).await; + + assert_eq!(rig.ledger.spawns.load(Ordering::SeqCst), 1, "one process"); + assert_eq!(rig.ledger.kills.load(Ordering::SeqCst), 0); + assert_eq!( + rig.ledger.prompts.lock().unwrap()[1], + ("what is in this screenshot?".to_string(), 1), + "the image rode the mailbox" + ); + + // A real configuration change still gets a fresh runtime. + let mut other_model = request("switch models"); + other_model.model = Some("process-2".into()); + rig.core + .sessions + .dispatch(CHAT, HarnessId::Mock, other_model, None) + .await + .unwrap(); + settle(&rig, 4).await; + assert_eq!(rig.ledger.spawns.load(Ordering::SeqCst), 2); + rig.core.sessions.shutdown().await; +} + +/// Switching models mid-chat on a driver that adopts configuration in place +/// (Claude's `set_model`/`apply_flag_settings`, Codex's per-turn params) +/// keeps the process — and every background subagent and shell it holds. +#[tokio::test] +async fn a_model_switch_is_adopted_by_the_live_runtime() { + let rig = rig_with(true); + rig.core + .sessions + .dispatch(CHAT, HarnessId::Mock, request("launch the scouts"), None) + .await + .unwrap(); + settle(&rig, 1).await; + + let mut other_model = request("switch models"); + other_model.model = Some("process-2".into()); + other_model.reasoning = Some(ReasoningLevel::Medium); + rig.core + .sessions + .dispatch(CHAT, HarnessId::Mock, other_model, None) + .await + .unwrap(); + settle(&rig, 2).await; + + // The same model again is the (now reconfigured) same runtime: no + // second reconfiguration rides along. + let mut again = request("still on the new model"); + again.model = Some("process-2".into()); + again.reasoning = Some(ReasoningLevel::Medium); + rig.core + .sessions + .dispatch(CHAT, HarnessId::Mock, again, None) + .await + .unwrap(); + settle(&rig, 3).await; + + assert_eq!(rig.ledger.spawns.load(Ordering::SeqCst), 1, "one process"); + assert_eq!(rig.ledger.kills.load(Ordering::SeqCst), 0); + assert_eq!( + *rig.ledger.reconfigured.lock().unwrap(), + vec![Some("process-2".to_string())] + ); + + // A different directory still needs a different process. + let mut elsewhere = request("over there"); + elsewhere.cwd = "/var".into(); + rig.core + .sessions + .dispatch(CHAT, HarnessId::Mock, elsewhere, None) + .await + .unwrap(); + settle(&rig, 4).await; + assert_eq!(rig.ledger.spawns.load(Ordering::SeqCst), 2); + rig.core.sessions.shutdown().await; +} + +/// A message the runtime accepted but died before confirming is replayed as +/// it was sent — its images and configuration included, under its own id — +/// not as the chat's latest request with the images stripped. +#[tokio::test] +async fn a_message_orphaned_by_a_dying_runtime_replays_as_sent() { + let rig = rig_with(true); + rig.core + .sessions + .dispatch(CHAT, HarnessId::Mock, request("hold: working"), None) + .await + .unwrap(); + wait_for( + || rig.ledger.prompts.lock().unwrap().len() == 1, + "the agent to be running the turn", + ) + .await; + + let mut sent = request("vanish: what is in this screenshot?"); + sent.model = Some("process-2".into()); + sent.attachments = vec!["/tmp/shot.png".into()]; + rig.core + .sessions + .dispatch(CHAT, HarnessId::Mock, sent, Some("m-orphan".into())) + .await + .unwrap(); + settle(&rig, 2).await; + + assert_eq!( + rig.ledger.spawns.load(Ordering::SeqCst), + 2, + "a fresh runtime" + ); + assert_eq!( + rig.ledger.prompts.lock().unwrap()[1], + ("vanish: what is in this screenshot?".to_string(), 1), + "the image survived the replay" + ); + assert_eq!( + rig.ledger.spawned_models.lock().unwrap()[1].as_deref(), + Some("process-2"), + "the configuration it was sent with" + ); + let entries = rig + .core + .doc_host + .open(CHAT) + .unwrap() + .doc() + .read_entries() + .unwrap(); + let orphan: Vec<_> = entries + .iter() + .filter(|e| e.role == zeron_doc::MessageRole::User && e.id != entries[0].id) + .map(|e| e.id.as_str()) + .collect(); + assert_eq!(orphan, ["m-orphan"], "one entry, under its own id"); + rig.core.sessions.shutdown().await; +} + +/// Every distinct completion ping the chat publishes, recorded until the +/// returned handle is aborted. +fn record_pings(rig: &Rig) -> (Arc>>, tokio::task::JoinHandle<()>) { + let pings = Arc::new(Mutex::new(Vec::::new())); + let sessions = rig.core.sessions.clone(); + let seen = pings.clone(); + let task = tokio::spawn(async move { + loop { + if let Some(ping) = sessions + .session_status(CHAT) + .and_then(|s| s.last_completed_turn) + { + let mut seen = seen.lock().unwrap(); + if seen.last() != Some(&ping) { + seen.push(ping); + } + } + tokio::time::sleep(Duration::from_millis(2)).await; + } + }); + (pings, task) +} + +fn queued(rig: &Rig) -> Vec { + rig.core + .doc_host + .open(CHAT) + .unwrap() + .watch_queue() + .borrow() + .iter() + .map(|row| row.text.clone()) + .collect() +} + +/// A send to a turn-boundary agent mid-turn waits for the turn's end — and +/// then runs as it was sent (its image, its model), with no "done" ping for +/// the turn that hands over to it. +#[tokio::test] +async fn a_send_held_for_the_turn_end_runs_as_sent_without_a_ping() { + let rig = rig_full(true, true); + let (pings, recorder) = record_pings(&rig); + rig.core + .sessions + .dispatch(CHAT, HarnessId::Mock, request("wait: first"), None) + .await + .unwrap(); + wait_for( + || rig.ledger.prompts.lock().unwrap().len() == 1, + "the first turn to run", + ) + .await; + let mut sent = request("second"); + sent.model = Some("process-2".into()); + sent.attachments = vec!["/tmp/shot.png".into()]; + rig.core + .doc_host + .queue_command( + CHAT, + SessionCommandPayload::Run { + request: sent, + message_id: "m-held".into(), + }, + ) + .unwrap(); + wait_for(|| queued(&rig) == ["second"], "the send to be held").await; + + rig.ledger.release.notify_one(); + settle(&rig, 2).await; + wait_for( + || { + rig.core + .sessions + .session_status(CHAT) + .and_then(|s| s.last_completed_turn) + .is_some() + }, + "the held send's reply to complete", + ) + .await; + tokio::time::sleep(Duration::from_millis(200)).await; + recorder.abort(); + assert_eq!( + rig.ledger.prompts.lock().unwrap()[1], + ("second".to_string(), 1), + "the held send kept its image" + ); + assert_eq!( + *rig.ledger.reconfigured.lock().unwrap(), + vec![Some("process-2".to_string())], + "the held send ran on the model it was sent with" + ); + assert_eq!(rig.ledger.spawns.load(Ordering::SeqCst), 1); + let last_reply = rig + .core + .doc_host + .open(CHAT) + .unwrap() + .doc() + .read_entries() + .unwrap() + .last() + .map(|e| e.id.clone()) + .unwrap(); + assert_eq!( + *pings.lock().unwrap(), + vec![last_reply], + "only the held send's reply pings: the handover to it is not a completion" + ); + rig.core.sessions.shutdown().await; +} + +/// A Stop cancels what was sent during the turn it stops: a send held for +/// that turn's end never runs — not even after the user's next message +/// thaws the queue. +#[tokio::test] +async fn stop_cancels_sends_held_for_the_stopped_turn() { + let rig = rig_full(false, true); + rig.core + .sessions + .dispatch(CHAT, HarnessId::Mock, request("wait: first"), None) + .await + .unwrap(); + wait_for( + || rig.ledger.prompts.lock().unwrap().len() == 1, + "the first turn to run", + ) + .await; + rig.core + .doc_host + .queue_command( + CHAT, + SessionCommandPayload::Run { + request: request("held before the stop"), + message_id: "m-held".into(), + }, + ) + .unwrap(); + wait_for( + || queued(&rig) == ["held before the stop"], + "the send to be held", + ) + .await; + rig.core + .doc_host + .queue_command(CHAT, SessionCommandPayload::Interrupt {}) + .unwrap(); + wait_for( + || { + rig.ledger.kills.load(Ordering::SeqCst) == 1 + && status(&rig.core) == Some(SessionStatus::Idle) + && queued(&rig).is_empty() + }, + "the stop to cancel the held send", + ) + .await; + rig.core + .doc_host + .queue_command( + CHAT, + SessionCommandPayload::Run { + request: request("after the stop"), + message_id: "m-after".into(), + }, + ) + .unwrap(); + wait_for( + || { + rig.ledger + .prompts + .lock() + .unwrap() + .iter() + .any(|(p, _)| p == "after the stop") + }, + "the next message to run", + ) + .await; + settle(&rig, 2).await; + tokio::time::sleep(Duration::from_millis(300)).await; + assert!( + !rig.ledger + .prompts + .lock() + .unwrap() + .iter() + .any(|(p, _)| p.contains("held before the stop")), + "a send held for the stopped turn ran after the Stop" + ); + rig.core.sessions.shutdown().await; +} + +/// A message sent before a Stop whose dispatch only lands once the Stop +/// has torn the runtime down (a loaded host, a slow agent) was cancelled by +/// it: it must not start the stopped work again in a fresh runtime. +#[tokio::test] +async fn a_message_the_stop_cancelled_never_reaches_a_fresh_runtime() { + let rig = rig(); + rig.core + .sessions + .dispatch(CHAT, HarnessId::Mock, request("hold: working"), None) + .await + .unwrap(); + wait_for( + || rig.ledger.prompts.lock().unwrap().len() == 1, + "the agent to be running the turn", + ) + .await; + // The Stop acts with the message still on its way … + rig.core + .sessions + .cancel_sent_messages(CHAT, vec!["m-late".into()]); + rig.core.sessions.interrupt(CHAT).await.unwrap(); + // … and the message reaches dispatch only now. + rig.core + .sessions + .dispatch( + CHAT, + HarnessId::Mock, + request("hold: sent before the stop"), + Some("m-late".into()), + ) + .await + .unwrap(); + tokio::time::sleep(Duration::from_millis(300)).await; + assert_eq!( + rig.ledger.spawns.load(Ordering::SeqCst), + 1, + "no fresh runtime" + ); + assert_eq!(status(&rig.core), Some(SessionStatus::Idle)); + // The user's next message still runs. + rig.core + .sessions + .dispatch( + CHAT, + HarnessId::Mock, + request("after the stop"), + Some("m-next".into()), + ) + .await + .unwrap(); + settle(&rig, 2).await; + assert_eq!(rig.ledger.prompts.lock().unwrap()[1].0, "after the stop"); + rig.core.sessions.shutdown().await; +} + +/// "Send now" replaces the in-flight turn but keeps the runtime and its +/// background work; the user's Stop is a hard boundary that tears all of it +/// down, and the next message starts a fresh process. +#[tokio::test] +async fn send_now_keeps_the_runtime_and_stop_tears_it_down() { + let rig = rig(); + rig.core + .sessions + .dispatch(CHAT, HarnessId::Mock, request("hold: build it"), None) + .await + .unwrap(); + wait_for( + || rig.ledger.turn.lock().unwrap().is_some(), + "the runtime to start", + ) + .await; + let turn = rig.ledger.turn.lock().unwrap().clone().unwrap(); + turn.set_background(1); + wait_for( + || status(&rig.core) == Some(SessionStatus::Working), + "the turn to run", + ) + .await; + + // Send now: the turn ends in place. + assert!(rig.core.sessions.stop_turn(CHAT).await.unwrap()); + assert_eq!(status(&rig.core), Some(SessionStatus::Idle)); + let doc = rig.core.doc_host.open(CHAT).unwrap(); + assert!( + doc.doc() + .read_entries() + .unwrap() + .iter() + .any(|entry| entry.status == Some(MessageStatus::Aborted)), + "the replaced turn reads as stopped" + ); + assert_eq!(rig.ledger.kills.load(Ordering::SeqCst), 0, "no teardown"); + assert!( + !rig.core.sessions.stop_turn(CHAT).await.unwrap(), + "a parked runtime has no turn to stop" + ); + rig.core + .sessions + .dispatch(CHAT, HarnessId::Mock, request("hold: again"), None) + .await + .unwrap(); + wait_for( + || { + status(&rig.core) == Some(SessionStatus::Working) + && rig.ledger.prompts.lock().unwrap().len() == 2 + }, + "the second turn to run", + ) + .await; + assert_eq!(rig.ledger.spawns.load(Ordering::SeqCst), 1, "one process"); + assert!(turn.background_live()); + + // Stop (the Cancel command): the runtime and its work are gone. + rig.core + .doc_host + .queue_command(CHAT, SessionCommandPayload::Interrupt {}) + .unwrap(); + wait_for( + || status(&rig.core) == Some(SessionStatus::Idle), + "stop to settle", + ) + .await; + wait_for( + || rig.ledger.kills.load(Ordering::SeqCst) == 1, + "the runtime to be torn down", + ) + .await; + assert!(!rig.core.sessions.live_run_steerable(CHAT)); + + // The next message starts a fresh process. + rig.core + .sessions + .dispatch(CHAT, HarnessId::Mock, request("status?"), None) + .await + .unwrap(); + settle(&rig, 3).await; + assert_eq!(rig.ledger.spawns.load(Ordering::SeqCst), 2); + rig.core.sessions.shutdown().await; +} + +/// Stop pressed right after Send: controls bypass the prompt drain, and the +/// Stop used to run before the send had dispatched — it found no turn, did +/// nothing, and the turn then ran in full. +#[tokio::test] +async fn a_stop_right_after_send_stops_that_turn() { + let rig = rig(); + rig.core + .doc_host + .queue_command( + CHAT, + SessionCommandPayload::Run { + request: request("hold: the essay"), + message_id: "m-1".into(), + }, + ) + .unwrap(); + rig.core + .doc_host + .queue_command(CHAT, SessionCommandPayload::Interrupt {}) + .unwrap(); + wait_for( + || rig.ledger.kills.load(Ordering::SeqCst) == 1, + "the Stop to tear the turn down", + ) + .await; + wait_for( + || status(&rig.core) == Some(SessionStatus::Idle), + "the stopped chat to settle", + ) + .await; + assert_eq!(rig.ledger.prompts.lock().unwrap().len(), 1); + rig.core.sessions.shutdown().await; +} + +/// Send pressed right after Stop: it used to land in the mailbox of the +/// runtime the Stop was tearing down, and an interrupted run re-dispatches +/// nothing — the message showed as sent and never ran. +#[tokio::test] +async fn a_send_right_after_stop_runs_in_a_fresh_runtime() { + let rig = rig(); + rig.core + .sessions + .dispatch(CHAT, HarnessId::Mock, request("hold: working"), None) + .await + .unwrap(); + wait_for( + || rig.ledger.prompts.lock().unwrap().len() == 1, + "the agent to be running the turn", + ) + .await; + rig.core + .doc_host + .queue_command(CHAT, SessionCommandPayload::Interrupt {}) + .unwrap(); + rig.core + .doc_host + .queue_command( + CHAT, + SessionCommandPayload::Run { + request: request("after the stop"), + message_id: "m-2".into(), + }, + ) + .unwrap(); + wait_for( + || { + rig.ledger + .prompts + .lock() + .unwrap() + .iter() + .any(|(p, _)| p == "after the stop") + }, + "the message sent after Stop to run", + ) + .await; + settle(&rig, 2).await; + assert_eq!(rig.ledger.kills.load(Ordering::SeqCst), 1); + assert_eq!( + rig.ledger.spawns.load(Ordering::SeqCst), + 2, + "a fresh runtime" + ); + rig.core.sessions.shutdown().await; +} + +/// The raw race under the command plane: a routed send accepted into a +/// runtime after its Stop began is re-dispatched, never dropped. +#[tokio::test] +async fn a_send_accepted_by_a_stopping_runtime_is_redispatched() { + let rig = rig(); + rig.core + .sessions + .dispatch(CHAT, HarnessId::Mock, request("hold: working"), None) + .await + .unwrap(); + wait_for( + || rig.ledger.prompts.lock().unwrap().len() == 1, + "the agent to be running the turn", + ) + .await; + let (stop, send) = tokio::join!( + rig.core.sessions.interrupt(CHAT), + rig.core + .sessions + .dispatch(CHAT, HarnessId::Mock, request("racing the stop"), None) + ); + stop.unwrap(); + send.unwrap(); + wait_for( + || { + rig.ledger + .prompts + .lock() + .unwrap() + .iter() + .filter(|(p, _)| p == "racing the stop") + .count() + >= 1 + && status(&rig.core) == Some(SessionStatus::Idle) + }, + "the racing message to run", + ) + .await; + assert_eq!( + rig.ledger.kills.load(Ordering::SeqCst), + 1, + "the Stop tore it down" + ); + rig.core.sessions.shutdown().await; +} + +/// A steer sent before Stop is cancelled with the turn: it must never run +/// later in the fresh runtime as if it were the user's next message. +#[tokio::test] +async fn a_steer_sent_before_stop_is_cancelled_with_it() { + let rig = rig(); + rig.core + .sessions + .dispatch(CHAT, HarnessId::Mock, request("hold: working"), None) + .await + .unwrap(); + wait_for( + || rig.ledger.prompts.lock().unwrap().len() == 1, + "the agent to be running the turn", + ) + .await; + rig.core + .doc_host + .queue_command( + CHAT, + SessionCommandPayload::Run { + request: request("hold: steer before stop"), + message_id: "m-steer".into(), + }, + ) + .unwrap(); + rig.core + .doc_host + .queue_command(CHAT, SessionCommandPayload::Interrupt {}) + .unwrap(); + wait_for( + || { + rig.ledger.kills.load(Ordering::SeqCst) == 1 + && status(&rig.core) == Some(SessionStatus::Idle) + }, + "the stop to settle", + ) + .await; + tokio::time::sleep(Duration::from_millis(500)).await; + assert_eq!( + rig.ledger.spawns.load(Ordering::SeqCst), + 1, + "nothing re-ran" + ); + assert_eq!(status(&rig.core), Some(SessionStatus::Idle)); + rig.core.sessions.shutdown().await; +} + +/// The idle reaper spares a parked runtime holding background work, so the +/// user's Stop must reach it and end that work. +#[tokio::test] +async fn stop_ends_a_parked_runtime_holding_background_work() { + let rig = rig(); + rig.core + .sessions + .dispatch( + CHAT, + HarnessId::Mock, + request("launch a background scout"), + None, + ) + .await + .unwrap(); + settle(&rig, 1).await; + // A runtime parked with nothing running is left alone. + rig.core + .doc_host + .queue_command(CHAT, SessionCommandPayload::Interrupt {}) + .unwrap(); + tokio::time::sleep(Duration::from_millis(300)).await; + assert_eq!(rig.ledger.kills.load(Ordering::SeqCst), 0); + + let turn = rig.ledger.turn.lock().unwrap().clone().unwrap(); + turn.set_background(1); + rig.core + .doc_host + .queue_command(CHAT, SessionCommandPayload::Interrupt {}) + .unwrap(); + wait_for( + || rig.ledger.kills.load(Ordering::SeqCst) == 1, + "the Stop to end the background work", + ) + .await; + assert!(!rig.core.sessions.live_run_steerable(CHAT)); + rig.core.sessions.shutdown().await; +} + +#[tokio::test(start_paused = true)] +async fn the_idle_reaper_spares_a_runtime_holding_background_work() { + let rig = rig(); + rig.core + .sessions + .dispatch( + CHAT, + HarnessId::Mock, + request("launch a background scout"), + None, + ) + .await + .unwrap(); + settle(&rig, 1).await; + let turn = rig.ledger.turn.lock().unwrap().clone().unwrap(); + turn.set_background(1); + + // Far past the 30-minute idle window: the scout is still running. (The + // odd 20 minutes put its end mid-way through a deferred window.) + tokio::time::sleep(Duration::from_secs(3 * 60 * 60 + 20 * 60)).await; + assert!( + rig.core.sessions.live_run_steerable(CHAT), + "the reaper killed a runtime holding live background work" + ); + assert_eq!(rig.ledger.kills.load(Ordering::SeqCst), 0); + + // Once the background work is done, an idle runtime is released again — + // but not before a full idle window has passed since: the completion's + // wake turn starts a moment after the agent reports the work drained. + turn.set_background(0); + tokio::time::sleep(Duration::from_secs(29 * 60)).await; + assert!( + rig.core.sessions.live_run_steerable(CHAT), + "the reaper killed the runtime as its background work ended" + ); + tokio::time::sleep(Duration::from_secs(2 * 60 * 60)).await; + wait_for( + || rig.ledger.kills.load(Ordering::SeqCst) == 1, + "the idle runtime to be reaped", + ) + .await; + rig.core.sessions.shutdown().await; +} diff --git a/crates/engine/tests/runtime_survival_live.rs b/crates/engine/tests/runtime_survival_live.rs new file mode 100644 index 0000000000..45c08fcf06 --- /dev/null +++ b/crates/engine/tests/runtime_survival_live.rs @@ -0,0 +1,795 @@ +//! Opt-in real-model checks. A chat's agent process — and the background +//! work it holds — survives everything but the user's Stop: a follow-up with +//! an image, another client's spelling of the same options, a steer into a +//! long command, a model switch. Stop tears all of it down +//! (`stop_tears_down_all_agent_work`), and a resumed process never reads +//! as done before its reply. +//! +//! ZERON_TEST_HARNESS=claude ZERON_TEST_MODEL=claude-haiku-4-5 \ +//! cargo test -p zeron-engine --test runtime_survival_live -- --ignored --nocapture +//! +//! `ZERON_TEST_HARNESS`: claude | codex | cursor | opencode | devin | hermes | +//! pi | antigravity | grok. Harnesses whose agents can hold background work +//! across turns (claude, codex) must also finish that work after the cancel. +use std::{sync::Arc, time::Duration}; + +use zeron_doc::SessionCommandPayload; +use zeron_engine::{EngineCore, HarnessRegistry}; +use zeron_harness::{ + AcpHarness, ClaudeHarness, CodexHarness, CursorHarness, Harness, OpencodeHarness, +}; +use zeron_proto::{AgentEvent, ChatConfig, RunRequest, SandboxLevel, SessionStatus}; + +const CHAT: &str = "survival"; + +fn processes(core: &EngineCore) -> usize { + core.sessions + .subscribe(CHAT, 0) + .map(|(events, _)| { + events + .iter() + .filter(|e| matches!(e.event, AgentEvent::SessionStarted { .. })) + .count() + }) + .unwrap_or(0) +} + +fn completed_turns(core: &EngineCore) -> usize { + core.sessions + .subscribe(CHAT, 0) + .map(|(events, _)| { + events + .iter() + .filter(|e| { + matches!( + e.event, + AgentEvent::Done { + status: zeron_proto::DoneStatus::Completed, + .. + } + ) + }) + .count() + }) + .unwrap_or(0) +} + +fn status(core: &EngineCore) -> Option { + core.sessions.session_status(CHAT).map(|s| s.status) +} + +fn journal(core: &EngineCore) -> Vec { + core.sessions + .subscribe(CHAT, 0) + .map(|(events, _)| { + events + .into_iter() + .filter(|e| { + !matches!( + e.event, + AgentEvent::TextDelta { .. } | AgentEvent::ReasoningDelta { .. } + ) + }) + .map(|e| e.event) + .collect() + }) + .unwrap_or_default() +} + +/// Waits for `predicate`, giving up early on an errored session. +async fn wait(core: &EngineCore, secs: u64, mut predicate: impl FnMut() -> bool, what: &str) { + let errored = || status(core) == Some(SessionStatus::Errored); + let settled = tokio::time::timeout(Duration::from_secs(secs), async { + while !predicate() && !errored() { + tokio::time::sleep(Duration::from_millis(50)).await; + } + }) + .await; + if settled.is_err() || errored() { + eprintln!("status: {:?}; events: {:#?}", status(core), journal(core)); + core.shutdown().await; + panic!("gave up waiting for {what}"); + } +} + +fn run( + prompt: &str, + model: &Option, + harness: zeron_proto::HarnessId, + cwd: &str, +) -> RunRequest { + RunRequest { + mcp: None, + prompt: prompt.into(), + harness: Some(harness), + model: model.clone(), + reasoning: None, + model_options: Default::default(), + cwd: cwd.into(), + sandbox: SandboxLevel::DangerFullAccess, + auto_approve: true, + attachments: vec![], + worktree: None, + resume: None, + } +} + +#[tokio::test] +#[ignore = "uses real model quota; select harness and inexpensive model explicitly"] +async fn agent_process_survives_sends_and_cancel() { + let name = std::env::var("ZERON_TEST_HARNESS").expect("select harness"); + let model = std::env::var("ZERON_TEST_MODEL").ok(); + let harness: Arc = match name.as_str() { + "claude" => Arc::new(ClaudeHarness::new()), + "codex" => Arc::new(CodexHarness::new()), + "cursor" => Arc::new(CursorHarness::new()), + "opencode" => Arc::new(OpencodeHarness::new()), + "grok" => Arc::new(AcpHarness::grok()), + "devin" => Arc::new(AcpHarness::devin()), + "hermes" => Arc::new(AcpHarness::hermes()), + "pi" => Arc::new(zeron_harness::PiHarness::new()), + "antigravity" => Arc::new(AcpHarness::antigravity()), + _ => panic!("unknown harness"), + }; + let dir = tempfile::tempdir().unwrap(); + let cwd = dir.path().to_str().unwrap().to_owned(); + let id = harness.id(); + let registry = HarnessRegistry::new(); + registry.register(harness); + let core = + EngineCore::assemble(&dir.path().join("engine"), Arc::new(registry), id, None).unwrap(); + core.workspace + .create_space("space", &core.device_id, &cwd, None, false) + .unwrap(); + core.workspace + .create_chat(CHAT, Some("space"), None, None, None) + .unwrap(); + core.workspace + .set_chat_config( + CHAT, + &ChatConfig { + harness: id, + model: model.clone(), + reasoning: None, + model_options: Default::default(), + sandbox: SandboxLevel::DangerFullAccess, + }, + ) + .unwrap(); + println!("{name}: workspace {cwd}"); + + // 1. Background work that outlives the turn. + let background = match name.as_str() { + "claude" => Some( + "This is an automated test in a disposable directory. Do exactly two things, then end your turn at once without waiting for either: \ + (1) use the Agent tool with run_in_background=true (subagent_type general-purpose, description 'sleeper') and the prompt \ + 'Run this exact bash command and nothing else: sleep 75 && printf done > sub-done . Then reply DONE.'; \ + (2) use the Bash tool with run_in_background=true to run exactly: sleep 70 && printf done > bg-done . \ + Reply only LAUNCHED.", + ), + "codex" => Some( + "This is an automated test in a disposable directory. Spawn one subagent (spawn_agent) whose task is: \ + 'Run this exact shell command and nothing else: sleep 75 && printf done > sub-done . Then reply DONE.' \ + Do not wait for it and do not run the command yourself. Reply only LAUNCHED once it is spawned.", + ), + _ => None, + }; + let opening = background.unwrap_or("This is an automated test. Reply only READY."); + core.doc_host + .queue_command( + CHAT, + SessionCommandPayload::Run { + request: run(opening, &model, id, &cwd), + message_id: "m-1".into(), + }, + ) + .unwrap(); + wait( + &core, + 240, + || completed_turns(&core) >= 1 && status(&core) == Some(SessionStatus::Idle), + "the opening turn", + ) + .await; + assert_eq!(processes(&core), 1); + if background.is_some() { + wait( + &core, + 120, + || { + core.sessions.subscribe(CHAT, 0).is_ok_and(|(events, _)| { + events + .iter() + .any(|e| matches!(e.event, AgentEvent::Subagent { .. })) + }) + }, + "the background subagent to start", + ) + .await; + } + println!("{name}: opening turn parked"); + + // 2. A follow-up from "another client": an image, and option values + // spelled as strings (the phone's encoding). + let image = dir.path().join("pixel.png"); + std::fs::write( + &image, + [ + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, + 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00, + 0x00, 0x90, 0x77, 0x53, 0xDE, 0x00, 0x00, 0x00, 0x0C, 0x49, 0x44, 0x41, 0x54, 0x08, + 0xD7, 0x63, 0xF8, 0xCF, 0xC0, 0x00, 0x00, 0x03, 0x01, 0x01, 0x00, 0x18, 0xDD, 0x8D, + 0xB0, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82, + ], + ) + .unwrap(); + let mut follow_up = run( + &format!( + "Attached image: {} (a 1x1 test pixel). Do not use tools. Reply only SEEN.", + image.display() + ), + &model, + id, + &cwd, + ); + follow_up.attachments = vec![image.display().to_string()]; + follow_up.model_options = serde_json::Map::from_iter([( + "unusedByAnyDriver".to_owned(), + serde_json::Value::String(String::new()), + )]); + core.doc_host + .queue_command( + CHAT, + SessionCommandPayload::Run { + request: follow_up, + message_id: "m-2".into(), + }, + ) + .unwrap(); + wait( + &core, + 240, + || completed_turns(&core) >= 2 && status(&core) == Some(SessionStatus::Idle), + "the image follow-up", + ) + .await; + assert_eq!(processes(&core), 1, "the follow-up restarted the agent"); + println!("{name}: image follow-up joined the live process"); + + // 2b. Steer a streaming turn into a long foreground command. The steer + // ends the streaming reply (Claude's `now`), and until the command and + // the reply after it finish the chat must stay Working with no + // completion ping (2026-10-01: "marked done immediately", sound and + // notification while the agent was still running the steered work). + let pings = |core: &EngineCore| { + core.sessions + .session_status(CHAT) + .and_then(|s| s.last_completed_turn) + }; + let before_pings = pings(&core); + let before_done = completed_turns(&core); + core.doc_host + .queue_command( + CHAT, + SessionCommandPayload::Run { + request: run( + "Write a 600-word essay about rivers. Do not use tools.", + &model, + id, + &cwd, + ), + message_id: "m-2b".into(), + }, + ) + .unwrap(); + wait( + &core, + 240, + || { + status(&core) == Some(SessionStatus::Working) + && core.sessions.subscribe(CHAT, 0).is_ok_and(|(events, _)| { + events + .iter() + .rev() + .take_while(|e| !matches!(e.event, AgentEvent::Done { .. })) + .filter(|e| matches!(&e.event, AgentEvent::TextDelta { .. })) + .count() + > 5 + }) + }, + "the essay to stream", + ) + .await; + core.sessions + .dispatch( + CHAT, + id, + run( + "Stop the essay now. Run this exact shell command in the FOREGROUND and wait for it: sleep 12 && printf ok > steer-done . Then reply only STEERED.", + &model, + id, + &cwd, + ), + Some("m-2b-steer".into()), + ) + .await + .unwrap(); + let steered_at = std::time::Instant::now(); + let mut premature = Vec::new(); + while !dir.path().join("steer-done").exists() { + assert!( + steered_at.elapsed() < Duration::from_secs(240), + "the steered command never ran: {:#?}", + journal(&core) + ); + if status(&core) != Some(SessionStatus::Working) || pings(&core) != before_pings { + premature.push((steered_at.elapsed(), status(&core), pings(&core))); + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + wait( + &core, + 240, + || status(&core) == Some(SessionStatus::Idle) && completed_turns(&core) > before_done, + "the steered turn to end", + ) + .await; + assert!( + premature.is_empty(), + "the steered turn read as done while its command ran: {premature:?}\n{:#?}", + journal(&core) + ); + assert_ne!( + pings(&core), + before_pings, + "the steered turn's end pings once" + ); + assert_eq!(processes(&core), 1, "the steer restarted the agent"); + println!("{name}: steer into a long command stayed Working until its real end"); + + // 2c. Switch models on a follow-up: the live process adopts it. + if let Ok(other) = std::env::var("ZERON_TEST_OTHER_MODEL") { + let before = completed_turns(&core); + core.doc_host + .queue_command( + CHAT, + SessionCommandPayload::Run { + request: run( + "Do not use tools. Reply only SWITCHED.", + &Some(other.clone()), + id, + &cwd, + ), + message_id: "m-2c".into(), + }, + ) + .unwrap(); + wait( + &core, + 240, + || completed_turns(&core) > before && status(&core) == Some(SessionStatus::Idle), + "the model-switch follow-up", + ) + .await; + assert_eq!(processes(&core), 1, "the model switch restarted the agent"); + println!("{name}: switched to {other} in the live process"); + } + + // 3. The background work the opening turn launched ran to completion. + if background.is_some() { + let expect: &[&str] = if name == "claude" { + &["sub-done", "bg-done"] + } else { + &["sub-done"] + }; + wait( + &core, + 240, + || expect.iter().all(|f| dir.path().join(f).exists()), + "the background work to finish", + ) + .await; + println!("{name}: background work finished: {expect:?}"); + assert_eq!(processes(&core), 1); + } + core.shutdown().await; +} + +/// "Marked done the moment I sent it": a chat whose agent process died with +/// background work still running (app restart, idle reap, a config-change +/// restart) resumes in a new process, and the CLI settles the dead tasks +/// with empty results while the new message is still queued. None of them +/// may complete the message — no Idle, no completion ping — before its +/// reply. +/// +/// ZERON_TEST_HARNESS=claude ZERON_TEST_MODEL=claude-sonnet-5-5 \ +/// cargo test -p zeron-engine --test runtime_survival_live resume_after -- --ignored --nocapture +#[tokio::test] +#[ignore = "uses real model quota; select harness and inexpensive model explicitly"] +async fn resume_after_killed_background_work_is_not_done_early() { + let name = std::env::var("ZERON_TEST_HARNESS").unwrap_or_else(|_| "claude".into()); + let model = std::env::var("ZERON_TEST_MODEL").ok(); + let id = match name.as_str() { + "claude" => zeron_proto::HarnessId::ClaudeCode, + "codex" => zeron_proto::HarnessId::Codex, + _ => panic!("claude or codex"), + }; + let dir = tempfile::tempdir().unwrap(); + let cwd = dir.path().to_str().unwrap().to_owned(); + let engine_dir = dir.path().join("engine"); + let assemble = || { + let registry = HarnessRegistry::new(); + let harness: Arc = if id == zeron_proto::HarnessId::Codex { + Arc::new(CodexHarness::new()) + } else { + Arc::new(ClaudeHarness::new()) + }; + registry.register(harness); + EngineCore::assemble(&engine_dir, Arc::new(registry), id, None).unwrap() + }; + let core = assemble(); + core.workspace + .create_space("space", &core.device_id, &cwd, None, false) + .unwrap(); + core.workspace + .create_chat(CHAT, Some("space"), None, None, None) + .unwrap(); + core.doc_host + .queue_command( + CHAT, + SessionCommandPayload::Run { + request: run( + if id == zeron_proto::HarnessId::Codex { + "This is an automated test in a disposable directory. Spawn one subagent (spawn_agent) whose task is: \ + 'Run this exact shell command and nothing else: python3 -c \"import time; time.sleep(170)\" . Then reply DONE.' \ + Do not wait for it and do not run the command yourself. Reply only LAUNCHED once it is spawned." + } else { + "This is an automated test in a disposable directory. Do exactly two things, then end your turn at once without waiting for either: \ + (1) use the Bash tool with run_in_background=true to run exactly: python3 -c 'import time; time.sleep(180)' ; \ + (2) use the Agent tool with run_in_background=true (subagent_type general-purpose) and the prompt \ + 'Run this exact bash command in the foreground and nothing else: python3 -c \"import time; time.sleep(170)\" . Then reply DONE.'. \ + Reply only LAUNCHED." + }, + &model, + id, + &cwd, + ), + message_id: "m-1".into(), + }, + ) + .unwrap(); + wait( + &core, + 240, + || completed_turns(&core) >= 1 && status(&core) == Some(SessionStatus::Idle), + "the opening turn", + ) + .await; + wait( + &core, + 120, + || { + core.sessions.subscribe(CHAT, 0).is_ok_and(|(events, _)| { + events + .iter() + .any(|e| matches!(e.event, AgentEvent::Subagent { .. })) + }) + }, + "the background subagent to start", + ) + .await; + // The process dies with its background work (an app restart). + core.shutdown().await; + drop(core); + println!("{name}: engine restarted under live background work"); + + let core = assemble(); + let pings = |core: &EngineCore| { + core.sessions + .session_status(CHAT) + .and_then(|s| s.last_completed_turn) + }; + let before_pings = pings(&core); + let before_events = core + .sessions + .subscribe(CHAT, 0) + .map(|(events, _)| events.len()) + .unwrap_or(0); + core.doc_host + .queue_command( + CHAT, + SessionCommandPayload::Run { + request: run("Do not use tools. Reply only PONG.", &model, id, &cwd), + message_id: "m-2".into(), + }, + ) + .unwrap(); + // Deltas are tokens: "PONG" may arrive split. + let replied = |core: &EngineCore| { + core.sessions.subscribe(CHAT, 0).is_ok_and(|(events, _)| { + events + .iter() + .skip(before_events) + .filter_map(|e| match &e.event { + AgentEvent::TextDelta { text } => Some(text.as_str()), + _ => None, + }) + .collect::() + .contains("PONG") + }) + }; + wait( + &core, + 60, + || status(&core) == Some(SessionStatus::Working), + "the resumed message to start", + ) + .await; + let sent = std::time::Instant::now(); + let mut premature = Vec::new(); + while !replied(&core) { + assert!( + sent.elapsed() < Duration::from_secs(180), + "no reply: {:#?}", + journal(&core) + ); + if status(&core) != Some(SessionStatus::Working) || pings(&core) != before_pings { + premature.push((sent.elapsed(), status(&core), pings(&core))); + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + wait( + &core, + 120, + || status(&core) == Some(SessionStatus::Idle) && pings(&core) != before_pings, + "the resumed message to end", + ) + .await; + assert!( + premature.is_empty(), + "the resumed message read as done before its reply: {premature:?}\n{:#?}", + journal(&core) + ); + println!("{name}: resumed message stayed Working until its reply"); + core.shutdown().await; + let _ = std::process::Command::new("pkill") + .args(["-f", "time.sleep.1[78]0"]) + .status(); +} + +/// Stop is a hard boundary: the Stop button ends the turn AND everything the +/// agent runs — background subagents, background shells, the foreground +/// command — while sends, steers and model switches never touch them (see +/// `agent_process_survives_sends_and_cancel`). The next message resumes the +/// conversation in a fresh process without reading as done early. +/// +/// ZERON_TEST_HARNESS=claude ZERON_TEST_MODEL=claude-sonnet-5-5 \ +/// cargo test -p zeron-engine --test runtime_survival_live stop_ -- --ignored --nocapture +#[tokio::test] +#[ignore = "uses real model quota; select harness and inexpensive model explicitly"] +async fn stop_tears_down_all_agent_work() { + let name = std::env::var("ZERON_TEST_HARNESS").unwrap_or_else(|_| "claude".into()); + let model = std::env::var("ZERON_TEST_MODEL").ok(); + let (id, harness): (zeron_proto::HarnessId, Arc) = match name.as_str() { + "claude" => ( + zeron_proto::HarnessId::ClaudeCode, + Arc::new(ClaudeHarness::new()), + ), + "codex" => (zeron_proto::HarnessId::Codex, Arc::new(CodexHarness::new())), + _ => panic!("claude or codex"), + }; + let alive = || { + std::process::Command::new("pgrep") + .args(["-f", "time.sleep.(19[1-5])"]) + .output() + .map(|o| { + String::from_utf8_lossy(&o.stdout) + .split_whitespace() + .count() + }) + .unwrap_or(0) + }; + assert_eq!(alive(), 0, "stale probe processes from an earlier run"); + let dir = tempfile::tempdir().unwrap(); + let cwd = dir.path().to_str().unwrap().to_owned(); + let registry = HarnessRegistry::new(); + registry.register(harness); + let core = + EngineCore::assemble(&dir.path().join("engine"), Arc::new(registry), id, None).unwrap(); + core.workspace + .create_space("space", &core.device_id, &cwd, None, false) + .unwrap(); + core.workspace + .create_chat(CHAT, Some("space"), None, None, None) + .unwrap(); + let opening = if id == zeron_proto::HarnessId::Codex { + "This is an automated test in a disposable directory. Do exactly two things, then end your turn without waiting: \ + (1) spawn one subagent (spawn_agent) whose task is: 'Run this exact shell command in the foreground and nothing else: python3 -c \"import time; time.sleep(191)\" . Then reply DONE.' \ + (2) start this exact command as a long-running background terminal session (do not wait for it): python3 -c 'import time; time.sleep(192)' . \ + Reply only LAUNCHED." + } else { + "This is an automated test in a disposable directory. Do exactly two things, then end your turn at once without waiting for either: \ + (1) use the Bash tool with run_in_background=true to run exactly: python3 -c 'import time; time.sleep(192)' ; \ + (2) use the Agent tool with run_in_background=true (subagent_type general-purpose) and the prompt \ + 'Run this exact bash command in the foreground and nothing else: python3 -c \"import time; time.sleep(191)\" . Then reply DONE.'. \ + Reply only LAUNCHED." + }; + core.doc_host + .queue_command( + CHAT, + SessionCommandPayload::Run { + request: run(opening, &model, id, &cwd), + message_id: "m-1".into(), + }, + ) + .unwrap(); + wait( + &core, + 240, + || completed_turns(&core) >= 1 && status(&core) == Some(SessionStatus::Idle), + "the opening turn", + ) + .await; + // Claude wraps each command in a shell (two processes a job); Codex + // runs the command itself. + let per_job = if id == zeron_proto::HarnessId::Codex { + 1 + } else { + 2 + }; + wait( + &core, + 180, + || alive() >= 2 * per_job, + "both background jobs to start", + ) + .await; + println!("{name}: background work running ({} processes)", alive()); + + core.doc_host + .queue_command( + CHAT, + SessionCommandPayload::Run { + request: run( + "Run this exact shell command in the FOREGROUND (not in the background) and wait for it to finish: python3 -c 'import time; time.sleep(193)' . Then reply FINISHED.", + &model, + id, + &cwd, + ), + message_id: "m-2".into(), + }, + ) + .unwrap(); + wait( + &core, + 240, + || status(&core) == Some(SessionStatus::Working) && alive() >= 3 * per_job, + "the foreground command to start", + ) + .await; + core.doc_host + .queue_command(CHAT, SessionCommandPayload::Interrupt {}) + .unwrap(); + wait( + &core, + 60, + || status(&core) == Some(SessionStatus::Idle), + "stop to settle", + ) + .await; + wait( + &core, + 20, + || alive() == 0, + "every agent process to be gone after Stop", + ) + .await; + println!("{name}: Stop ended the turn and all background work"); + + // The next message resumes the conversation in a fresh process. + let before = completed_turns(&core); + core.doc_host + .queue_command( + CHAT, + SessionCommandPayload::Run { + request: run("Do not use tools. Reply only PONG.", &model, id, &cwd), + message_id: "m-3".into(), + }, + ) + .unwrap(); + wait( + &core, + 240, + || completed_turns(&core) > before && status(&core) == Some(SessionStatus::Idle), + "the message after Stop", + ) + .await; + println!("{name}: the next message resumed after Stop"); + core.shutdown().await; + let _ = std::process::Command::new("pkill") + .args(["-f", "time.sleep.(19[1-5])"]) + .status(); +} + +/// Issue #831: the idle reaper ended a parked Claude session whose agent +/// still ran a background shell, killing it before its completion woke the +/// agent. With a short idle window, the shell outlives the window, finishes +/// in the same process, and its completion wakes the agent. +/// +/// ZERON_TEST_MODEL=claude-haiku-5-5 \ +/// cargo test -p zeron-engine --test runtime_survival_live idle_reaper_ -- --ignored --nocapture +#[tokio::test] +#[ignore = "uses real model quota; select an inexpensive model explicitly"] +async fn idle_reaper_spares_a_background_shell_until_it_wakes_the_agent() { + // SAFETY: set before the engine starts; this test binary runs it alone + // under the documented filter. + unsafe { + std::env::set_var("ZERON_SESSION_IDLE_MS", "15000"); + } + let model = std::env::var("ZERON_TEST_MODEL").ok(); + let id = zeron_proto::HarnessId::ClaudeCode; + let dir = tempfile::tempdir().unwrap(); + let cwd = dir.path().to_str().unwrap().to_owned(); + let registry = HarnessRegistry::new(); + registry.register(Arc::new(ClaudeHarness::new())); + let core = + EngineCore::assemble(&dir.path().join("engine"), Arc::new(registry), id, None).unwrap(); + core.workspace + .create_space("space", &core.device_id, &cwd, None, false) + .unwrap(); + core.workspace + .create_chat(CHAT, Some("space"), None, None, None) + .unwrap(); + core.doc_host + .queue_command( + CHAT, + SessionCommandPayload::Run { + request: run( + "This is an automated test in a disposable directory. Do exactly one thing, then end your turn at once without waiting for it: \ + use the Bash tool with run_in_background=true to run exactly: sleep 45 && printf done > bg-done . \ + Reply only STARTED. Later, when you are notified that the command finished, reply only FINISHED.", + &model, + id, + &cwd, + ), + message_id: "m-1".into(), + }, + ) + .unwrap(); + wait( + &core, + 240, + || completed_turns(&core) >= 1 && status(&core) == Some(SessionStatus::Idle), + "the opening turn", + ) + .await; + assert!( + core.sessions.holds_background_work(CHAT), + "the agent never launched the background shell: {:#?}", + journal(&core) + ); + println!("claude: parked with the background shell running"); + // Three idle windows pass before the shell ends: the reaper must spare it. + wait( + &core, + 240, + || dir.path().join("bg-done").exists(), + "the background shell to finish", + ) + .await; + println!("claude: background shell finished"); + wait( + &core, + 240, + || completed_turns(&core) >= 2 && status(&core) == Some(SessionStatus::Idle), + "the completion to wake the agent", + ) + .await; + assert_eq!(processes(&core), 1, "the reaper restarted the agent"); + assert!( + core.sessions.live_run_steerable(CHAT), + "the woken session is still live" + ); + println!("claude: the completion woke the same process"); + core.shutdown().await; +} diff --git a/crates/engine/tests/support/voice_relay.rs b/crates/engine/tests/support/voice_relay.rs new file mode 100644 index 0000000000..8a031d4c4f --- /dev/null +++ b/crates/engine/tests/support/voice_relay.rs @@ -0,0 +1,96 @@ +//! Route-only test relay; no cloud service or credentials. +#![allow(clippy::result_large_err)] +use futures::{SinkExt, StreamExt}; +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; +use tokio::net::TcpListener; +use tokio::sync::mpsc; +use tokio_tungstenite::tungstenite::Message as WsMessage; +use tokio_tungstenite::tungstenite::handshake::server::{ + Request as WsRequest, Response as WsResponse, +}; +use zeron_rpc::{DeviceFrameHeader, decode_device_frame, encode_device_frame}; + +#[derive(Default)] +struct RelayState { + host: Option>>, + clients: HashMap>>, +} + +pub async fn fake_device_room() -> (String, tokio::task::JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind relay"); + let url = format!( + "http://127.0.0.1:{}", + listener.local_addr().expect("addr").port() + ); + let state = Arc::new(Mutex::new(RelayState::default())); + let task = tokio::spawn(async move { + loop { + let Ok((stream, _)) = listener.accept().await else { + break; + }; + let state = state.clone(); + tokio::spawn(async move { + let mut uri = String::new(); + let Ok(ws) = tokio_tungstenite::accept_hdr_async( + stream, + |req: &WsRequest, res: WsResponse| { + uri = req.uri().to_string(); + Ok(res) + }, + ) + .await + else { + return; + }; + let query = uri.split_once('?').map(|(_, q)| q).unwrap_or(""); + let is_host = query.contains("role=host"); + let conn_id = query + .split('&') + .find_map(|kv| kv.strip_prefix("connId=")) + .unwrap_or("anon") + .to_string(); + let (mut sink, mut ws_stream) = ws.split(); + let (tx, mut rx) = mpsc::unbounded_channel::>(); + { + let mut st = state.lock().expect("lock"); + if is_host { + st.host = Some(tx); + } else { + st.clients.insert(conn_id.clone(), tx); + } + } + let writer = tokio::spawn(async move { + while let Some(bytes) = rx.recv().await { + if sink.send(WsMessage::Binary(bytes)).await.is_err() { + break; + } + } + }); + while let Some(Ok(message)) = ws_stream.next().await { + let WsMessage::Binary(bytes) = message else { + continue; + }; + let Ok((header, payload)) = decode_device_frame(&bytes) else { + break; + }; + let st = state.lock().expect("lock"); + if is_host { + let Some(to) = header.to else { continue }; + if let Some(client) = st.clients.get(&to) { + let stripped = DeviceFrameHeader::new(header.s, header.k); + let _ = client + .send(encode_device_frame(&stripped, &payload).expect("encode")); + } + } else if let Some(host) = &st.host { + let mut routed = DeviceFrameHeader::new(header.s, header.k); + routed.from = Some(conn_id.clone()); + let _ = host.send(encode_device_frame(&routed, &payload).expect("encode")); + } + } + writer.abort(); + }); + } + }); + (url, task) +} diff --git a/crates/engine/tests/voice_runtime.rs b/crates/engine/tests/voice_runtime.rs new file mode 100644 index 0000000000..fd755dd7a4 --- /dev/null +++ b/crates/engine/tests/voice_runtime.rs @@ -0,0 +1,492 @@ +//! Offline tests for the remote voice protocol against a fake native Codex package. +use serde_json::json; +use zeron_engine::{EngineCore, EngineProfile, HarnessRegistry}; +use zeron_proto::HarnessId; +use zeron_rpc::methods; + +#[cfg(unix)] +fn native_package(root: &std::path::Path) -> std::path::PathBuf { + use std::os::unix::fs::PermissionsExt; + let fixture = + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../harness/tests/fixtures"); + let binary = root.join("bin/codex"); + let helper = root.join("codex-resources/voice/bin/codex-voice-host"); + std::fs::create_dir_all(binary.parent().unwrap()).unwrap(); + std::fs::create_dir_all(helper.parent().unwrap()).unwrap(); + std::fs::write( + root.join("codex-package.json"), + r#"{"layoutVersion":1,"version":"0.159.0"}"#, + ) + .unwrap(); + for (source, target) in [ + ("fake-codex-voice-native.py", &binary), + ("fake-codex-voice-host.py", &helper), + ] { + std::fs::copy(fixture.join(source), target).unwrap(); + std::fs::set_permissions(target, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + binary +} +#[cfg(unix)] +async fn native_core(temp: &tempfile::TempDir) -> EngineCore { + let package = temp.path().join("codex-package"); + let binary = native_package(&package); + let registry = std::sync::Arc::new(HarnessRegistry::new()); + registry.register(std::sync::Arc::new( + zeron_harness::CodexHarness::new().with_executable(binary), + )); + let core = EngineCore::assemble_with_profile( + EngineProfile::local(&temp.path().join("data")).unwrap(), + registry, + HarnessId::Codex, + None, + ) + .unwrap(); + let config = + serde_json::from_value(json!({"harness":"codex","sandbox":"danger-full-access"})).unwrap(); + core.workspace + .create_chat( + "native-voice", + None, + Some(&core.device_id), + Some(config), + Some(package.display().to_string()), + ) + .unwrap(); + core +} + +#[cfg(unix)] +#[tokio::test] +async fn remote_failed_prepare_after_rotation_preserves_thread_for_retry() { + use zeron_proto::voice::{ORCHESTRATOR_CHAT_PREFIX, remote as wire}; + let temp = tempfile::tempdir().unwrap(); + let core = native_core(&temp).await; + let client = zeron_rpc::memory_client(core.rpc_service()); + let config = + serde_json::from_value(json!({"harness":"codex","sandbox":"danger-full-access"})).unwrap(); + let mut request = wire::Prepare { + attempt_key: wire::AttemptKey::new(), + config, + voice: Some("invalid-voice".into()), + }; + let envelope = |p: serde_json::Value| json!({"targetDeviceId":core.device_id,"payload":p}); + + // Seed a previous call at the rotation threshold with its native thread. + let previous = format!("{ORCHESTRATOR_CHAT_PREFIX}previous"); + core.workspace + .create_chat( + &previous, + None, + Some(&core.device_id), + Some(request.config.clone()), + None, + ) + .unwrap(); + core.workspace + .set_chat_harness_session(&previous, "remembered-thread", ""); + // The engine's `ROTATE_AFTER`. + const ROTATE_AFTER: usize = 1000; + let doc = core.doc_host.open(&previous).unwrap(); + for i in 0..ROTATE_AFTER { + doc.write_user_message(&format!("m{i}"), "remember me", i as i64) + .unwrap(); + } + + // An unsupported voice fails after rotation and exercises the real + // preparation cleanup, rather than manually deleting the successor. + let failure = client + .call(methods::PREPARE_VOICE_V2, envelope(json!(request))) + .await + .unwrap_err(); + assert!( + matches!(failure, zeron_rpc::RpcError::Failed(ref message) + if message == "voice unavailable: Unsupported"), + "unexpected preparation failure: {failure:?}" + ); + let remaining: Vec<_> = core + .workspace + .read_chats() + .unwrap() + .into_iter() + .filter(|c| zeron_proto::voice::is_orchestrator_chat(&c.id)) + .collect(); + assert_eq!( + remaining.len(), + 1, + "cleanup must remove the empty successor" + ); + assert_eq!( + remaining[0].id, previous, + "cleanup must keep the predecessor" + ); + assert_eq!( + core.workspace.chat_harness_session(&previous).unwrap().0, + "remembered-thread" + ); + assert_eq!(doc.doc().read_entries().unwrap().len(), ROTATE_AFTER); + + // A fresh attempt rotates again and resumes the original Codex thread. + request.attempt_key = wire::AttemptKey::new(); + request.voice = None; + let prepared: wire::Prepared = client + .call_as(methods::PREPARE_VOICE_V2, envelope(json!(request))) + .await + .unwrap(); + assert_ne!(prepared.chat_id, previous); + assert_eq!( + core.sessions + .last_request(&prepared.chat_id) + .unwrap() + .resume + .as_deref(), + Some("remembered-thread") + ); + assert!(core.workspace.chat(&previous).unwrap().is_some()); + client + .call(methods::STOP_VOICE_V2, envelope(json!(prepared.lease))) + .await + .unwrap(); + core.sessions.shutdown().await; +} + +#[cfg(unix)] +#[tokio::test] +async fn remote_voice_full_control_flow_and_idempotent_prepare_without_host_audio() { + use zeron_proto::voice::{VoiceEvent, remote as wire}; + let temp = tempfile::tempdir().unwrap(); + let core = native_core(&temp).await; + std::fs::remove_file( + temp.path() + .join("codex-package/codex-resources/voice/bin/codex-voice-host"), + ) + .unwrap(); + let client = zeron_rpc::memory_client(core.rpc_service()); + let request = wire::Prepare { + attempt_key: wire::AttemptKey::new(), + config: serde_json::from_value(json!({"harness":"codex","sandbox":"danger-full-access"})) + .unwrap(), + voice: None, + }; + let envelope = |p: serde_json::Value| json!({"targetDeviceId":core.device_id,"payload":p}); + let info: zeron_proto::EngineInfo = client + .call_as(methods::ENGINE_INFO, json!({})) + .await + .unwrap(); + assert!(info.supports(wire::CAPABILITY)); + let capabilities: wire::Capabilities = client + .call_as(methods::VOICE_CAPABILITIES_V2, envelope(json!({}))) + .await + .unwrap(); + assert_eq!(capabilities.protocol, wire::CAPABILITY); + assert!(capabilities.client_webrtc); + let call = envelope(serde_json::to_value(&request).unwrap()); + let prepared: wire::Prepared = client + .call_as(methods::PREPARE_VOICE_V2, call.clone()) + .await + .unwrap(); + let again: wire::Prepared = client + .call_as(methods::PREPARE_VOICE_V2, call) + .await + .unwrap(); + assert_eq!(again.chat_id, prepared.chat_id); + assert_eq!( + again.lease.voice.session_id, + prepared.lease.voice.session_id + ); + assert!(prepared.chat_id.starts_with("voice-orchestrator-")); + assert_eq!( + core.workspace + .chat(&prepared.chat_id) + .unwrap() + .unwrap() + .title + .as_deref(), + Some(zeron_proto::voice::ORCHESTRATOR_CHAT_TITLE) + ); + let mut owner = client + .subscribe_checked( + methods::OWN_VOICE_V2, + envelope(serde_json::to_value(&prepared.lease).unwrap()), + ) + .await + .unwrap(); + assert!( + client + .subscribe_checked( + methods::OWN_VOICE_V2, + envelope(serde_json::to_value(&prepared.lease).unwrap()) + ) + .await + .is_err() + ); + let id = wire::AttemptKey::new(); + let negotiation = wire::Negotiate { + lease: prepared.lease.clone(), + negotiation_id: id.clone(), + offer: wire::Sdp::new("fixture-offer".into()).unwrap(), + }; + let reply: wire::Negotiated = client + .call_as( + methods::NEGOTIATE_VOICE_V2, + envelope(serde_json::to_value(&negotiation).unwrap()), + ) + .await + .unwrap(); + assert_eq!(reply.answer.expose(), "fixture-answer"); + let repeat: wire::Negotiated = client + .call_as( + methods::NEGOTIATE_VOICE_V2, + envelope(serde_json::to_value(&negotiation).unwrap()), + ) + .await + .unwrap(); + assert_eq!(repeat.answer, reply.answer); + client + .call( + methods::CONFIRM_VOICE_MEDIA_V2, + envelope( + serde_json::to_value(wire::Confirm { + lease: prepared.lease.clone(), + negotiation_id: id, + muted: false, + }) + .unwrap(), + ), + ) + .await + .unwrap(); + tokio::time::timeout(std::time::Duration::from_secs(3), async { + loop { + if matches!( + serde_json::from_value::(owner.recv().await.unwrap()).unwrap(), + VoiceEvent::Final { .. } + ) { + break; + } + } + }) + .await + .unwrap(); + drop(owner); + tokio::time::sleep(std::time::Duration::from_millis(400)).await; + assert!( + client + .call( + methods::PREPARE_VOICE_V2, + envelope(serde_json::to_value(request).unwrap()) + ) + .await + .is_err() + ); + assert!(!temp.path().join("codex-package/helper-wire.jsonl").exists()); + core.sessions.shutdown().await; +} + +#[cfg(unix)] +#[tokio::test] +async fn remote_cancel_before_prepare_and_owner_drop_preserve_other_calls() { + use zeron_proto::voice::remote as wire; + let temp = tempfile::tempdir().unwrap(); + let core = native_core(&temp).await; + let client = zeron_rpc::memory_client(core.rpc_service()); + let wrap = |p: serde_json::Value| json!({"targetDeviceId":core.device_id,"payload":p}); + let config = + serde_json::from_value(json!({"harness":"codex","sandbox":"danger-full-access"})).unwrap(); + let mut request = wire::Prepare { + attempt_key: wire::AttemptKey::new(), + config, + voice: None, + }; + client + .call( + methods::CANCEL_VOICE_ATTEMPT_V2, + wrap(json!({"attemptKey":request.attempt_key})), + ) + .await + .unwrap(); + assert!( + client + .call(methods::PREPARE_VOICE_V2, wrap(json!(request))) + .await + .is_err() + ); + assert!(!temp.path().join("codex-package/voice-wire.jsonl").exists()); + request.attempt_key = wire::AttemptKey::new(); + let prepared: wire::Prepared = client + .call_as(methods::PREPARE_VOICE_V2, wrap(json!(request))) + .await + .unwrap(); + let owner = client + .subscribe_checked(methods::OWN_VOICE_V2, wrap(json!(prepared.lease))) + .await + .unwrap(); + drop(owner); + tokio::time::timeout(std::time::Duration::from_secs(3), async { + while core.workspace.chat(&prepared.chat_id).unwrap().is_some() { + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } + }) + .await + .unwrap(); + let _: zeron_proto::EngineInfo = client + .call_as(methods::ENGINE_INFO, json!({})) + .await + .unwrap(); + // The dropped voice stream did not close the connection or another generation. + request.attempt_key = wire::AttemptKey::new(); + let next: wire::Prepared = client + .call_as(methods::PREPARE_VOICE_V2, wrap(json!(request))) + .await + .unwrap(); + client + .call(methods::STOP_VOICE_V2, wrap(json!(prepared.lease))) + .await + .unwrap(); + let next_owner = client + .subscribe_checked(methods::OWN_VOICE_V2, wrap(json!(next.lease))) + .await + .unwrap(); + drop(next_owner); + core.sessions.shutdown().await; +} + +#[cfg(unix)] +#[path = "support/voice_relay.rs"] +mod voice_relay; + +#[cfg(unix)] +#[tokio::test] +async fn remote_voice_crosses_two_engines_and_owner_drop_releases_host() { + use std::{sync::Arc, time::Duration}; + use zeron_proto::voice::{VoiceEvent, remote as wire}; + use zeron_rpc::{HostRelay, HostRelayConfig, LinkCache, LinkCacheConfig, StaticToken}; + let temp = tempfile::tempdir().unwrap(); + let host = native_core(&temp).await; + std::fs::remove_file( + temp.path() + .join("codex-package/codex-resources/voice/bin/codex-voice-host"), + ) + .unwrap(); + let (url, room) = voice_relay::fake_device_room().await; + let relay = HostRelay::spawn( + HostRelayConfig::new( + &url, + host.device_id.clone(), + Arc::new(StaticToken("test".into())), + ), + host.rpc_service(), + Arc::new(|_| true), + ); + let viewer_dir = tempfile::tempdir().unwrap(); + let viewer = EngineCore::assemble_with_profile( + EngineProfile::local(viewer_dir.path()).unwrap(), + Arc::new(HarnessRegistry::new()), + HarnessId::Codex, + None, + ) + .unwrap(); + let mut links = LinkCacheConfig::new(url, Arc::new(StaticToken("test".into()))); + links.probe_timeout = Duration::from_secs(3); + viewer.set_links(LinkCache::new(links)); + let client = zeron_rpc::memory_client(viewer.rpc_service()); + let wrap = |p: serde_json::Value| json!({"targetDeviceId":host.device_id,"payload":p}); + tokio::time::timeout(Duration::from_secs(12), async { + loop { + if client + .call(methods::VOICE_CAPABILITIES_V2, wrap(json!({}))) + .await + .is_ok() + { + break; + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + }) + .await + .unwrap(); + let request = wire::Prepare { + attempt_key: wire::AttemptKey::new(), + config: serde_json::from_value(json!({"harness":"codex","sandbox":"danger-full-access"})) + .unwrap(), + voice: None, + }; + let prepared: wire::Prepared = client + .call_as(methods::PREPARE_VOICE_V2, wrap(json!(request))) + .await + .unwrap(); + assert!(viewer.workspace.chat(&prepared.chat_id).unwrap().is_none()); + let mut owner = client + .subscribe_checked(methods::OWN_VOICE_V2, wrap(json!(prepared.lease))) + .await + .unwrap(); + let id = wire::AttemptKey::new(); + let _: wire::Negotiated = client + .call_as( + methods::NEGOTIATE_VOICE_V2, + wrap(json!(wire::Negotiate { + lease: prepared.lease.clone(), + negotiation_id: id.clone(), + offer: wire::Sdp::new("fixture-offer".into()).unwrap() + })), + ) + .await + .unwrap(); + client + .call( + methods::CONFIRM_VOICE_MEDIA_V2, + wrap(json!(wire::Confirm { + lease: prepared.lease.clone(), + negotiation_id: id, + muted: false + })), + ) + .await + .unwrap(); + tokio::time::timeout(Duration::from_secs(3), async { + while !matches!( + serde_json::from_value::(owner.recv().await.unwrap()).unwrap(), + VoiceEvent::Final { .. } + ) {} + }) + .await + .unwrap(); + drop(owner); + tokio::time::timeout(Duration::from_secs(4), async { + loop { + let report = wire::Report { + lease: prepared.lease.clone(), + sequence: 1, + muted: true, + state: wire::MediaState::Ready, + }; + if client + .call(methods::REPORT_VOICE_MEDIA_V2, wrap(json!(report))) + .await + .is_err() + { + break; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + }) + .await + .unwrap(); + let info: zeron_proto::EngineInfo = client + .call_as( + methods::ENGINE_INFO, + json!({"targetDeviceId":host.device_id}), + ) + .await + .unwrap(); + assert_eq!(info.device_id, viewer.device_id); // EngineInfo is deliberately local. + client + .call(methods::VOICE_CAPABILITIES_V2, wrap(json!({}))) + .await + .unwrap(); + assert!(!temp.path().join("codex-package/helper-wire.jsonl").exists()); + drop(relay); + room.abort(); + host.sessions.shutdown().await; + viewer.sessions.shutdown().await; +} diff --git a/crates/engine/tests/workspace_files.rs b/crates/engine/tests/workspace_files.rs index cc6ad8c9a1..9b5bb42c33 100644 --- a/crates/engine/tests/workspace_files.rs +++ b/crates/engine/tests/workspace_files.rs @@ -657,6 +657,90 @@ async fn write_rejects_changed_checkout_even_when_contents_match() { ); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn workspace_mutations_validate_revision_and_publish_semantic_events() { + let temp = tempfile::tempdir().unwrap(); + let repo = temp.path().join("repo"); + init_repo(&repo).await; + let core = assemble(&temp.path().join("data"), "device-mutations"); + core.workspace + .create_space( + "space", + &core.device_id, + &repo.to_string_lossy(), + None, + true, + ) + .unwrap(); + core.workspace + .create_chat("chat", Some("space"), None, None, None) + .unwrap(); + let client = zeron_rpc::memory_client(core.rpc_service()); + let page: WorkspaceDirectoryPage = serde_json::from_value( + client + .call( + methods::LIST_WORKSPACE_DIRECTORY, + serde_json::json!({"chatId":"chat"}), + ) + .await + .unwrap(), + ) + .unwrap(); + assert!(page.mutation_capabilities.unwrap().delete_entry); + let revision = page + .entries + .iter() + .find(|e| e.path == "README.md") + .unwrap() + .mutation_revision + .clone() + .unwrap(); + let checkout = page.checkout_id.unwrap(); + let mut watch = client + .subscribe( + methods::WATCH_WORKSPACE_FILES, + serde_json::json!({"chatId":"chat"}), + ) + .await + .unwrap(); + watch.recv().await.unwrap(); + let mut request = serde_json::json!({"chatId":"chat", "operationId":"rename", "expectedCheckoutId":checkout, "sourcePath":"README.md", "destinationPath":"src/read me.md", "expectedSourceRevision":revision, "expectedKind":"file"}); + request["expectedCheckoutId"] = "wrong".into(); + let rejected = client + .call(methods::MOVE_WORKSPACE_ENTRY, request.clone()) + .await + .unwrap(); + assert_eq!(rejected["reason"], "workspaceChanged"); + request["expectedCheckoutId"] = checkout.clone().into(); + let moved = client + .call(methods::MOVE_WORKSPACE_ENTRY, request) + .await + .unwrap(); + assert_eq!(moved["status"], "applied"); + assert!(!repo.join("README.md").exists()); + assert_eq!( + std::fs::read_to_string(repo.join("src/read me.md")).unwrap(), + "hello\n" + ); + tokio::time::timeout(Duration::from_secs(3), async { + loop { + let frame: WorkspaceFileChanges = + serde_json::from_value(watch.recv().await.unwrap()).unwrap(); + if frame.changes.iter().any(|c| { + c.operation_id.as_deref() == Some("rename") + && c.old_path.as_deref() == Some("README.md") + }) { + break; + } + } + }) + .await + .unwrap(); + let deleted = client.call(methods::DELETE_WORKSPACE_ENTRY, serde_json::json!({"chatId":"chat", "operationId":"delete", "expectedCheckoutId":checkout, "path":"src/read me.md", "expectedSourceRevision":moved["entry"]["mutationRevision"], "expectedKind":"file", "recursive":false})).await.unwrap(); + assert_eq!(deleted["status"], "applied"); + assert!(!repo.join("src/read me.md").exists()); +} + // Absolute reads take POSIX paths — the only shape a UI sends. #[cfg(unix)] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] diff --git a/crates/engine/tests/workspace_sync.rs b/crates/engine/tests/workspace_sync.rs index 56d3183b71..54946ce006 100644 --- a/crates/engine/tests/workspace_sync.rs +++ b/crates/engine/tests/workspace_sync.rs @@ -693,6 +693,7 @@ async fn legacy_workspace_doc_migrates_instantly_on_first_boot() { git_detected: true, git_checked_at: Some(now), checkout_id: Some("co-1".into()), + repository_id: None, created_at: now, }) .unwrap(); @@ -721,6 +722,7 @@ async fn legacy_workspace_doc_migrates_instantly_on_first_boot() { legacy .upsert_session(&Session { last_completed_turn: None, + running_subagents: 0, chat_id: "chat-legacy".into(), device_id: "dev-a".into(), status: SessionStatus::Idle, diff --git a/crates/harness/Cargo.toml b/crates/harness/Cargo.toml index d9eeb0d29b..5adfa50dc7 100644 --- a/crates/harness/Cargo.toml +++ b/crates/harness/Cargo.toml @@ -22,11 +22,12 @@ path = "tests/fixtures/pi_rpc.rs" required-features = ["native-fixture"] [dependencies] +zeron-voice-media.workspace = true zeron-proto.workspace = true base64 = "0.22" reqwest.workspace = true tokio.workspace = true -tokio-util = { workspace = true, features = ["io-util"] } +tokio-util = { workspace = true, features = ["io-util", "codec"] } uuid.workspace = true futures.workspace = true async-trait.workspace = true diff --git a/crates/harness/examples/cursor_stability_probe.rs b/crates/harness/examples/cursor_stability_probe.rs index 3b95847947..48dfa9712b 100644 --- a/crates/harness/examples/cursor_stability_probe.rs +++ b/crates/harness/examples/cursor_stability_probe.rs @@ -20,6 +20,7 @@ async fn turn( let (tx, steering) = mpsc::channel(8); let token = CancellationToken::new(); let controls = RunControls { + realtime: None, execution_lease: None, steering, interrupt: token.clone(), @@ -28,6 +29,7 @@ async fn turn( let _ = tx.send(vec![]); rx }), + turn: Default::default(), }; let request = RunRequest { mcp: None, @@ -139,6 +141,7 @@ async fn parked(harness: &CursorHarness, count: usize) { let nonce = format!("PARKED-STABILITY-{}", uuid::Uuid::new_v4()); let (tx, steering) = mpsc::channel(8); let controls = RunControls { + realtime: None, execution_lease: None, steering, interrupt: CancellationToken::new(), @@ -147,6 +150,7 @@ async fn parked(harness: &CursorHarness, count: usize) { let _ = tx.send(vec![]); rx }), + turn: Default::default(), }; let request = RunRequest { mcp: None, @@ -202,7 +206,7 @@ async fn parked(harness: &CursorHarness, count: usize) { println!("auth_clock_advanced_ms={offset} exchanges_before={}",exchanges.lines().count()); } } - tx.send(zeron_harness::SteerMessage{prompt:"Repeat the exact PARKED-STABILITY token from earlier. Reply only the token. Do not use tools or files.".into(),message_id:None}).await.unwrap(); + tx.send(zeron_harness::SteerMessage{prompt:"Repeat the exact PARKED-STABILITY token from earlier. Reply only the token. Do not use tools or files.".into(),message_id:None, attachments: Vec::new(), config: None}).await.unwrap(); } _=>{} } @@ -250,6 +254,7 @@ async fn burst(harness: &CursorHarness, count: usize, cancel: bool) { let (tx, steering) = mpsc::channel(8); let token = CancellationToken::new(); let controls = RunControls { + realtime: None, execution_lease: None, steering, interrupt: token.clone(), @@ -258,6 +263,7 @@ async fn burst(harness: &CursorHarness, count: usize, cancel: bool) { let _ = tx.send(vec![]); rx }), + turn: Default::default(), }; let request = RunRequest { mcp: None, @@ -295,6 +301,8 @@ async fn burst(harness: &CursorHarness, count: usize, cancel: bool) { .send(zeron_harness::SteerMessage { prompt, message_id: None, + attachments: Vec::new(), + config: None, }) .await .is_err() @@ -387,6 +395,7 @@ async fn history(harness: &CursorHarness, count: usize) { .collect(); let (tx, steering) = mpsc::channel(8); let controls = RunControls { + realtime: None, execution_lease: None, steering, interrupt: CancellationToken::new(), @@ -395,6 +404,7 @@ async fn history(harness: &CursorHarness, count: usize) { let _ = tx.send(vec![]); rx }), + turn: Default::default(), }; let request = RunRequest { mcp: None, @@ -422,7 +432,7 @@ async fn history(harness: &CursorHarness, count: usize) { let producer = tokio::spawn(async move { start_rx.await.unwrap(); for token in sent.iter().skip(1) { - tx.send(zeron_harness::SteerMessage{prompt:format!("Add token {token} to your remembered conversation history. Reply with the token from the immediately previous user message, followed by this new token. Do not use tools."),message_id:Some(uuid::Uuid::new_v4().to_string())}).await.unwrap(); + tx.send(zeron_harness::SteerMessage{prompt:format!("Add token {token} to your remembered conversation history. Reply with the token from the immediately previous user message, followed by this new token. Do not use tools."),message_id:Some(uuid::Uuid::new_v4().to_string()),attachments:Vec::new(),config:None}).await.unwrap(); } }); let mut id = String::new(); diff --git a/crates/harness/examples/cursor_steering_probe.rs b/crates/harness/examples/cursor_steering_probe.rs index 2d0f0b52ca..606a8a2d16 100644 --- a/crates/harness/examples/cursor_steering_probe.rs +++ b/crates/harness/examples/cursor_steering_probe.rs @@ -26,6 +26,7 @@ async fn main() -> anyhow::Result<()> { .run( request, RunControls { + realtime: None, execution_lease: None, steering, interrupt: interrupt.clone(), @@ -34,6 +35,7 @@ async fn main() -> anyhow::Result<()> { let _ = tx.send(vec![]); rx }), + turn: Default::default(), }, ) .await?; @@ -56,6 +58,8 @@ async fn main() -> anyhow::Result<()> { .send(SteerMessage { prompt: i.to_string(), message_id: Some(format!("digit-{i}")), + attachments: Vec::new(), + config: None, }) .await .is_err() diff --git a/crates/harness/examples/devin_models_probe.rs b/crates/harness/examples/devin_models_probe.rs index 44140c527c..0d9370cd8c 100644 --- a/crates/harness/examples/devin_models_probe.rs +++ b/crates/harness/examples/devin_models_probe.rs @@ -27,6 +27,7 @@ async fn main() -> anyhow::Result<()> { }; let (_steering, steering) = tokio::sync::mpsc::channel(8); let controls = RunControls { + realtime: None, execution_lease: None, request_input: Box::new(|_| { let (tx, rx) = tokio::sync::oneshot::channel(); @@ -35,6 +36,7 @@ async fn main() -> anyhow::Result<()> { }), steering, interrupt: CancellationToken::new(), + turn: Default::default(), }; let request = RunRequest { mcp: None, diff --git a/crates/harness/examples/grok_subagent_probe.rs b/crates/harness/examples/grok_subagent_probe.rs index bc85f3e35b..9b1e6f016a 100644 --- a/crates/harness/examples/grok_subagent_probe.rs +++ b/crates/harness/examples/grok_subagent_probe.rs @@ -17,6 +17,7 @@ async fn main() { std::fs::create_dir_all(&cwd).unwrap(); let (_steer_tx, steering) = mpsc::channel(8); let controls = RunControls { + realtime: None, execution_lease: None, request_input: Box::new(move |questions| { let (tx, rx) = oneshot::channel(); @@ -32,6 +33,7 @@ async fn main() { }), steering, interrupt: CancellationToken::new(), + turn: Default::default(), }; let request = RunRequest { mcp: None, diff --git a/crates/harness/examples/opencode_subagent_probe.rs b/crates/harness/examples/opencode_subagent_probe.rs index 5057661a7d..21a2e968cd 100644 --- a/crates/harness/examples/opencode_subagent_probe.rs +++ b/crates/harness/examples/opencode_subagent_probe.rs @@ -25,6 +25,7 @@ async fn main() { std::fs::create_dir_all(&cwd).unwrap(); let (_steer_tx, steering) = mpsc::channel(8); let controls = RunControls { + realtime: None, execution_lease: None, request_input: Box::new(move |questions| { let (tx, rx) = oneshot::channel(); @@ -40,6 +41,7 @@ async fn main() { }), steering, interrupt: CancellationToken::new(), + turn: Default::default(), }; // Optional second arg overrides the prompt (e.g. the mock rig's // "TWO subagents" variant exercising concurrent binding). diff --git a/crates/harness/examples/opencode_turn_probe.rs b/crates/harness/examples/opencode_turn_probe.rs index d4e8e9be1b..6b8f262e1f 100644 --- a/crates/harness/examples/opencode_turn_probe.rs +++ b/crates/harness/examples/opencode_turn_probe.rs @@ -63,6 +63,7 @@ async fn main() { .run( request, RunControls { + realtime: None, execution_lease: None, request_input: Box::new(move |questions| { assert!( @@ -85,6 +86,7 @@ async fn main() { }), steering, interrupt: interrupt.clone(), + turn: Default::default(), }, ) .await diff --git a/crates/harness/examples/steering_probe.rs b/crates/harness/examples/steering_probe.rs index 5bbd4d0e25..b5e7485acc 100644 --- a/crates/harness/examples/steering_probe.rs +++ b/crates/harness/examples/steering_probe.rs @@ -56,6 +56,7 @@ async fn main() -> anyhow::Result<()> { resume: None, }; let controls = RunControls { + realtime: None, execution_lease: None, steering, interrupt: interrupt.clone(), @@ -64,6 +65,7 @@ async fn main() -> anyhow::Result<()> { let _ = tx.send(vec![]); rx }), + turn: Default::default(), }; let mut stream = tokio::time::timeout(Duration::from_secs(90), harness.run(request, controls)).await??; @@ -77,7 +79,7 @@ async fn main() -> anyhow::Result<()> { _ = ticker.tick(), if !injected => { if workspace.path().join("started").exists() { for index in 0..3 { - steer.send(SteerMessage { prompt: format!("Additive follow-up {index}: keep all earlier work running. Write ONLY the secret from the first user message into file followup-{index}. Do not read it from another file. Use a shell command to append {index} as one line to receipts. Reply FOLLOWUP-{index}-DONE."), message_id: Some(format!("probe-{index}")) }).await?; + steer.send(SteerMessage { prompt: format!("Additive follow-up {index}: keep all earlier work running. Write ONLY the secret from the first user message into file followup-{index}. Do not read it from another file. Use a shell command to append {index} as one line to receipts. Reply FOLLOWUP-{index}-DONE."), message_id: Some(format!("probe-{index}")), attachments: Vec::new(), config: None }).await?; } injected = true; println!("{name}: injected 3 steers while shell was running"); diff --git a/crates/harness/src/acp/mod.rs b/crates/harness/src/acp/mod.rs index c5d14f02f6..c3ba5dae50 100644 --- a/crates/harness/src/acp/mod.rs +++ b/crates/harness/src/acp/mod.rs @@ -24,6 +24,8 @@ //! others queue steers and deliver them as the next `session/prompt` at the //! turn boundary. The session stays parked between turns while the //! steering mailbox lives. +//! - Turn stop ([`crate::TurnControl::stop_turn`]): `session/cancel` settles +//! the prompt `cancelled`; the agent and its session take the next prompt. //! - Interrupt: `session/cancel`, escalating SIGTERM → SIGKILL; the stream //! always ends with `Done { status: Interrupted }`. @@ -1246,7 +1248,7 @@ pub struct AcpHarness { /// Retain successful catalogs per credential/binary context through outages. models_cache: crate::catalog::Catalog, workspace_commands: crate::skills::CommandDiscovery, - devin_models: devin_models::Catalog, + devin_models: std::sync::Arc, } impl AcpHarness { @@ -1265,7 +1267,7 @@ impl AcpHarness { commands: tokio::sync::OnceCell::new(), models_cache: crate::catalog::Catalog::default(), workspace_commands: crate::skills::CommandDiscovery::default(), - devin_models: devin_models::Catalog::default(), + devin_models: Default::default(), } } @@ -2144,6 +2146,24 @@ impl Harness for AcpHarness { // quiet watchdog must not park a still-pending model request either. true } + /// `session/cancel` settles the prompt `cancelled`; the agent process + /// and its session take the next `session/prompt`. + fn stops_turn_in_place(&self) -> bool { + true + } + /// The session's model, effort and model options are selected over the + /// protocol (`session/set_model`, `session/set_config_option`), so a + /// change applies between turns without a new agent. Clearing one back + /// to the agent's default cannot be expressed that way. + fn reconfigures_in_place(&self, live: &RunRequest, next: &RunRequest) -> bool { + live.cwd == next.cwd + && (next.model.is_some() || live.model.is_none()) + && (next.reasoning.is_some() || live.reasoning.is_none()) + && live + .model_options + .keys() + .all(|key| next.model_options.contains_key(key)) + } fn supports_steering(&self) -> bool { true @@ -2340,18 +2360,19 @@ impl Harness for AcpHarness { let (client, incoming) = client_with_sign_in_prompt(stdin, stdout, self.spec.id, &sign_in_prompted); let (event_tx, event_rx) = mpsc::channel::>(256); - let devin_selection = match request.model.as_deref() { - Some(model) if self.spec.id == HarnessId::Devin => { - let (exe, _) = self.resolve_program(false).await?; - let selection = self - .devin_models - .selection( - &exe, - self.model_discovery_timeout, - model, - &request.model_options, - ) - .await; + let devin_catalog = if self.spec.id == HarnessId::Devin { + let (exe, _) = self.resolve_program(false).await?; + Some(DevinCatalog { + catalog: self.devin_models.clone(), + exe, + timeout: self.model_discovery_timeout, + }) + } else { + None + }; + let devin_selection = match (request.model.as_deref(), &devin_catalog) { + (Some(model), Some(devin)) => { + let selection = devin.selection(model, &request.model_options).await; if selection.is_none() && model == devin_models::FUSION { let chosen = |key: &str| { request @@ -2386,6 +2407,7 @@ impl Harness for AcpHarness { prompt_complete_extension: self.spec.prompt_complete_extension, preempt_steers: self.spec.steering_mode == SteeringMode::StepBoundary, devin_selection, + devin_catalog, resend_unstarted: self.spec.drops_unstarted_cancelled_prompt, prompt_stall: self.spec.prompt_stall, stall_hint: self.spec.stall_hint, @@ -2430,6 +2452,8 @@ struct Session { preempt_steers: bool, /// Devin: the requested model's catalog group (see `devin_models`). devin_selection: Option, + /// Devin: the catalog a later model switch selects its group from. + devin_catalog: Option, /// See `AcpAgentSpec::drops_unstarted_cancelled_prompt`. resend_unstarted: bool, prompt_stall: Option, @@ -2447,6 +2471,33 @@ struct Session { sign_in_prompted: CancellationToken, } +/// Devin's model catalog, for selecting a model's group on the live session. +struct DevinCatalog { + catalog: std::sync::Arc, + exe: PathBuf, + timeout: Duration, +} + +impl DevinCatalog { + async fn selection( + &self, + model: &str, + options: &serde_json::Map, + ) -> Option { + self.catalog + .selection(&self.exe, self.timeout, model, options) + .await + } +} + +/// How a session selects a run's model, effort and model options. +#[derive(Clone, Copy)] +struct ConfigRules { + harness: HarnessId, + effort_in_model_id: bool, + effort_values: fn(Option, Option<&str>) -> Vec<&'static str>, +} + fn initialize_params(harness: HarnessId) -> Value { let mut capabilities = json!({ "fs": { "readTextFile": false, "writeTextFile": false }, @@ -3475,6 +3526,172 @@ fn steering_call_future( /// The per-run event loop: one task multiplexing agent messages, the pending /// turn, the steering mailbox, the interrupt token, and consumer liveness. +/// Select `request`'s model, effort and model options on the session — +/// at its start, and again when a later message changes them. Returns the +/// session's options as they stand afterwards, which the next switch +/// compares against. +async fn select_session_config( + client: &RpcClient, + incoming: &mut mpsc::Receiver, + rules: ConfigRules, + session_id: &str, + mut session_response: Value, + request: &RunRequest, + devin_selection: Option, +) -> Result { + // Devin selects one advertised member of the requested model's group; + // effort and speed baked into a saved id apply unless the run chose + // its own. + let mut request = request.clone(); + if rules.harness == HarnessId::Devin + && let Some(model) = request.model.clone() + { + let selection = devin_selection.unwrap_or_default(); + let advertised = devin_models::wait_for_model( + client, + incoming, + session_id, + &mut session_response, + &model, + &selection.members, + ) + .await?; + request.model = Some(advertised); + if request.reasoning.is_none() { + request.reasoning = selection.effort; + } + if selection.fast && !request.model_options.contains_key("speed") { + request + .model_options + .insert("speed".into(), Value::String("fast".into())); + } + } + // ACP has had two model-selection surfaces. Newer config-option agents + // use category=model below; Grok Build currently advertises only the + // first-class `models` state and requires `session/set_model`. Other + // ACP clients follow the same split. Unlike the best-effort auxiliary options, + // an explicit model switch is strict: prompting with a different + // model than the picker shows is worse than surfacing the RPC error. + let requested_model: Option = match request.model.as_deref() { + Some(model) if rules.effort_in_model_id => Some(effort_variant_id( + &session_response, + model, + request.reasoning, + )), + model => model.map(str::to_owned), + }; + if rules.harness == HarnessId::Antigravity { + validate_config_model_selection( + &session_response, + requested_model.as_deref(), + &request.model_options, + )?; + } + if let Some(model) = first_class_model_change(&session_response, requested_model.as_deref())? { + request_draining( + client, + incoming, + "session/set_model", + json!({ + "sessionId": session_id, + "modelId": model, + }), + ) + .await + .map_err(|error| { + HarnessError::Protocol(format!("agent rejected model switch to {model}: {error}")) + })?; + // The next switch compares against the model now current. + if let Some(models) = session_response.get_mut("models") { + models["currentModelId"] = Value::String(model); + } + } + // Apply the run's model + effort + model options through the + // session's advertised config options. Best-effort for effort and + // traits: a rejected auxiliary set is logged and the agent default + // runs. + let efforts = (rules.effort_values)(request.reasoning, request.model.as_deref()); + let mut options_snapshot = session_response; + let mut sets = config_option_sets( + &options_snapshot, + requested_model.as_deref(), + &efforts, + &request.model_options, + ); + // Devin's effort and speed choices depend on the selected model: + // switch the model first, then choose them from what it offers. + let deferred = rules.harness == HarnessId::Devin + && sets + .iter() + .any(|(id, _)| is_model_config_option(&options_snapshot, id)); + if deferred { + sets.retain(|(id, _)| is_model_config_option(&options_snapshot, id)); + } + let mut pass = 0; + loop { + for (config_id, payload) in std::mem::take(&mut sets) { + let mut params = serde_json::Map::new(); + params.insert("sessionId".into(), session_id.into()); + params.insert("configId".into(), config_id.clone().into()); + if let Some(payload) = payload.as_object() { + for (k, v) in payload { + params.insert(k.clone(), v.clone()); + } + } + match request_draining( + client, + incoming, + "session/set_config_option", + Value::Object(params), + ) + .await + { + Ok(response) => { + if let Some(options) = response.get("configOptions") { + options_snapshot["configOptions"] = options.clone(); + } else if let Some(value) = payload.get("value") + && let Some(option) = options_snapshot["configOptions"] + .as_array_mut() + .and_then(|options| { + options.iter_mut().find(|o| o["id"] == config_id.as_str()) + }) + { + // The next switch compares against the value now set. + option["currentValue"] = value.clone(); + } + } + Err(e) => { + if matches!(rules.harness, HarnessId::Antigravity | HarnessId::Devin) + && requested_model.is_some() + && is_model_config_option(&options_snapshot, &config_id) + { + return Err(HarnessError::Protocol(format!( + "agent rejected requested model {}: {e}", + requested_model.as_deref().unwrap_or_default() + ))); + } + tracing::debug!( + target: "zeron_harness::acp", + "session/set_config_option {config_id}={payload} rejected (agent default runs): {e}" + ); + } + } + } + pass += 1; + if !deferred || pass > 1 { + break; + } + sets = config_option_sets( + &options_snapshot, + requested_model.as_deref(), + &efforts, + &request.model_options, + ); + sets.retain(|(id, _)| !is_model_config_option(&options_snapshot, id)); + } + Ok(options_snapshot) +} + async fn run_session(session: Session) { let Session { // Locals drop in reverse binding order: reap the child before cleanup. @@ -3484,12 +3701,13 @@ async fn run_session(session: Session) { mut incoming, event_tx, controls, - request, + mut request, harness, agent_name, prompt_complete_extension, preempt_steers, devin_selection, + devin_catalog, resend_unstarted, prompt_stall, stall_hint, @@ -3505,12 +3723,19 @@ async fn run_session(session: Session) { sign_in_prompted, } = session; let RunControls { + realtime: _, execution_lease: _execution_lease, request_input, mut steering, interrupt, + turn: turn_control, } = controls; let request_input = std::sync::Arc::new(request_input); + let rules = ConfigRules { + harness, + effort_in_model_id, + effort_values, + }; // ---- handshake + session (interruptible) ------------------------------ let setup = async { @@ -3524,7 +3749,7 @@ async fn run_session(session: Session) { "cwd": request.cwd, "mcpServers": acp_mcp_servers(request.mcp.as_ref()), }); - let (session_id, mut session_response) = if let Some(resume) = &request.resume { + let (session_id, session_response) = if let Some(resume) = &request.resume { let mut load = session_params.clone(); load["sessionId"] = Value::String(resume.clone()); match request_draining(&client, &mut incoming, "session/load", load).await { @@ -3580,159 +3805,31 @@ async fn run_session(session: Session) { "session/new returned no sessionId".into(), )); } - // Devin selects one advertised member of the requested model's group; - // effort and speed baked into a saved id apply unless the run chose - // its own. - let mut request = request.clone(); - if harness == HarnessId::Devin - && let Some(model) = request.model.clone() - { - let selection = devin_selection.clone().unwrap_or_default(); - let advertised = devin_models::wait_for_model( - &client, - &mut incoming, - &session_id, - &mut session_response, - &model, - &selection.members, - ) - .await?; - request.model = Some(advertised); - if request.reasoning.is_none() { - request.reasoning = selection.effort; - } - if selection.fast && !request.model_options.contains_key("speed") { - request - .model_options - .insert("speed".into(), Value::String("fast".into())); - } - } - // ACP has had two model-selection surfaces. Newer config-option agents - // use category=model below; Grok Build currently advertises only the - // first-class `models` state and requires `session/set_model`. Other - // ACP clients follow the same split. Unlike the best-effort auxiliary options, - // an explicit model switch is strict: prompting with a different - // model than the picker shows is worse than surfacing the RPC error. - let requested_model: Option = match request.model.as_deref() { - Some(model) if effort_in_model_id => Some(effort_variant_id( - &session_response, - model, - request.reasoning, - )), - model => model.map(str::to_owned), - }; - if harness == HarnessId::Antigravity { - validate_config_model_selection( - &session_response, - requested_model.as_deref(), - &request.model_options, - )?; - } - if let Some(model) = - first_class_model_change(&session_response, requested_model.as_deref())? - { - request_draining( - &client, - &mut incoming, - "session/set_model", - json!({ - "sessionId": session_id, - "modelId": model, - }), - ) - .await - .map_err(|error| { - HarnessError::Protocol(format!("agent rejected model switch to {model}: {error}")) - })?; - } - // Apply the run's model + effort + model options through the - // session's advertised config options. Best-effort for effort and - // traits: a rejected auxiliary set is logged and the agent default - // runs. - let efforts = effort_values(request.reasoning, request.model.as_deref()); let session_commands = scan_available_commands(&session_response); let init_commands = if session_commands.is_empty() { init_commands } else { session_commands }; - let mut options_snapshot = session_response; - let mut sets = config_option_sets( - &options_snapshot, - requested_model.as_deref(), - &efforts, - &request.model_options, - ); - // Devin's effort and speed choices depend on the selected model: - // switch the model first, then choose them from what it offers. - let deferred = harness == HarnessId::Devin - && sets - .iter() - .any(|(id, _)| is_model_config_option(&options_snapshot, id)); - if deferred { - sets.retain(|(id, _)| is_model_config_option(&options_snapshot, id)); - } - let mut pass = 0; - loop { - for (config_id, payload) in std::mem::take(&mut sets) { - let mut params = serde_json::Map::new(); - params.insert("sessionId".into(), session_id.clone().into()); - params.insert("configId".into(), config_id.clone().into()); - if let Some(payload) = payload.as_object() { - for (k, v) in payload { - params.insert(k.clone(), v.clone()); - } - } - match request_draining( - &client, - &mut incoming, - "session/set_config_option", - Value::Object(params), - ) - .await - { - Ok(response) => { - if let Some(options) = response.get("configOptions") { - options_snapshot["configOptions"] = options.clone(); - } - } - Err(e) => { - if matches!(harness, HarnessId::Antigravity | HarnessId::Devin) - && requested_model.is_some() - && is_model_config_option(&options_snapshot, &config_id) - { - return Err(HarnessError::Protocol(format!( - "agent rejected requested model {}: {e}", - requested_model.as_deref().unwrap_or_default() - ))); - } - tracing::debug!( - target: "zeron_harness::acp", - "session/set_config_option {config_id}={payload} rejected (agent default runs): {e}" - ); - } - } - } - pass += 1; - if !deferred || pass > 1 { - break; - } - sets = config_option_sets( - &options_snapshot, - requested_model.as_deref(), - &efforts, - &request.model_options, - ); - sets.retain(|(id, _)| !is_model_config_option(&options_snapshot, id)); - } - Ok::<(String, bool, Vec), HarnessError>(( + let session_options = select_session_config( + &client, + &mut incoming, + rules, + &session_id, + session_response, + &request, + devin_selection.clone(), + ) + .await?; + Ok::<(String, bool, Vec, Value), HarnessError>(( session_id, steer_ext, init_commands, + session_options, )) }; let setup = unless_sign_in_prompted(setup, &sign_in_prompted, agent_name); - let (session_id, steer_ext, init_commands) = tokio::select! { + let (session_id, steer_ext, init_commands, mut session_options) = tokio::select! { res = tokio::time::timeout(handshake_timeout, setup) => { let res = res.unwrap_or_else(|_| { // A hung handshake (agent waiting on a login it can never @@ -3895,6 +3992,9 @@ async fn run_session(session: Session) { let mut progress_seq: u64 = 0; let mut interrupted = false; let mut interrupt_sent = false; + // A turn stop (`TurnControl::stop_turn`) in flight: `session/cancel` + // ends the prompt and the session stays up for the next one. + let mut stopping = false; let mut done_current = false; let mut done_after_interrupt = false; let mut escalation_target = None; @@ -3932,6 +4032,32 @@ async fn run_session(session: Session) { const BUSY_RECENT: Duration = Duration::from_secs(3); const CANCEL_FLUSH: Duration = Duration::from_secs(2); let mut cancel_flush_deadline: Option = None; + // When our last prompt settled. Only traffic arriving past it (and past + // the settled turn's own trailing frames) is an unowned turn: counting + // the tail of our own turn as "busy" cancelled an idle session for every + // prompt sent within BUSY_RECENT of the last one — and an idle-session + // cancel is not harmless (Hermes 0.20 fails the next prompt with an + // internal error). + const OWN_TAIL: Duration = Duration::from_millis(500); + let mut prompt_settled_at: Option = None; + // Messages that change the model, effort or options: the session takes a + // change only between turns, so one sent mid-turn waits here — and the + // messages after it in the mailbox wait behind it — until the session is + // quiet. `released` hands the next one to the steering branch. + let mut deferred: VecDeque = VecDeque::new(); + let mut released: Option = None; + macro_rules! quiet { + () => { + turn.is_none() + && cancel_flush_deadline.is_none() + && queued_steers.is_empty() + && steering_call.is_none() + && steer_backlog.is_empty() + && open_tools.is_empty() + && !(last_update_at.elapsed() < BUSY_RECENT + && prompt_settled_at.is_none_or(|settled| last_update_at > settled + OWN_TAIL)) + }; + } let mut child_exit = None; let mut exit_drain_deadline = None; @@ -3965,6 +4091,10 @@ async fn run_session(session: Session) { if res.is_err() && client.is_closed() { break 'main; } + prompt_settled_at = Some(tokio::time::Instant::now()); + // The settled prompt's tool calls are over with it; a tool + // still reported open would read as an unowned turn. + open_tools.clear(); starve_deadline = None; prompt_stall_deadline = None; if let Some(id) = current_prompt_id.take() { @@ -3999,7 +4129,7 @@ async fn run_session(session: Session) { .to_owned(), Ok(Err(_)) | Err(_) => "promptRequired".to_owned(), }; - if interrupted { + if interrupted || stopping { // Winding down; abandoned like any queued steer. } else if outcome != "promptRequired" { let (prev, next) = rotate(&mut assistant_message_id); @@ -4081,13 +4211,15 @@ async fn run_session(session: Session) { } // A steer preempted this turn: the session lives on and the // steers continue it below, so there is no turn end to report. + let stopped = std::mem::take(&mut stopping); let preempted = std::mem::take(&mut preempt_sent) && !interrupted + && !stopped && res.is_ok() && !queued_steers.is_empty(); preempt_pending = false; if !preempted { - let (status, mut error) = stop_outcome(&res, interrupted); + let (status, mut error) = stop_outcome(&res, interrupted || stopped); if !interrupted && auth_method.is_some() && res.as_ref().is_err_and(is_auth_required) @@ -4111,7 +4243,7 @@ async fn run_session(session: Session) { { break 'main; } - if interrupted || res.is_err() { + if interrupted || (res.is_err() && !stopped) { break 'main; } } @@ -4338,9 +4470,9 @@ async fn run_session(session: Session) { "promptRequired".to_owned() } }; - if interrupted { - // The run is winding down; the steer is abandoned like - // any queued steer at interrupt. + if interrupted || stopping { + // The run is winding down, or its turn stopping: the + // steer is abandoned like any queued steer. } else if outcome != "promptRequired" { // Injected into a live turn → a Steered boundary. But if // the turn ended while the call was in flight, the @@ -4550,7 +4682,11 @@ async fn run_session(session: Session) { if !send( &event_tx, AgentEvent::Done { - status: DoneStatus::Completed, + status: if std::mem::take(&mut stopping) { + DoneStatus::Interrupted + } else { + DoneStatus::Completed + }, result: None, error: None, session_id: Some(session_id.clone()), @@ -4595,8 +4731,70 @@ async fn run_session(session: Session) { } }, - steer = steering.recv(), if steering_open && !interrupted => match steer { + _ = tokio::time::sleep(Duration::from_millis(100)), + if released.is_none() && !deferred.is_empty() && !interrupted && quiet!() => + { + released = deferred.pop_front(); + }, + + steer = async { + match released.take() { + Some(msg) => Some(msg), + None => steering.recv().await, + } + }, if (released.is_some() || (steering_open && deferred.is_empty())) && !interrupted => match steer { Some(msg) => { + if msg.config.is_some() && !quiet!() { + deferred.push_back(msg); + continue 'main; + } + if let Some(next) = msg.config.as_deref() { + let selection = match (&devin_catalog, next.model.as_deref()) { + (Some(devin), Some(model)) => { + devin.selection(model, &next.model_options).await + } + _ => None, + }; + match select_session_config( + &client, + &mut incoming, + rules, + &session_id, + session_options.clone(), + next, + selection, + ) + .await + { + Ok(options) => { + session_options = options; + request.model = next.model.clone(); + request.reasoning = next.reasoning; + request.model_options = next.model_options.clone(); + } + Err(e) => { + // The message cannot run as it was sent. + let (prev, next) = rotate(&mut assistant_message_id); + let message = e.to_string(); + done_current = true; + if !send(&event_tx, AgentEvent::Steered { + assistant_message_id: Some(prev), + next_assistant_message_id: Some(next), + }).await + || !send(&event_tx, AgentEvent::Error { message: message.clone() }).await + || !send(&event_tx, AgentEvent::Done { + status: DoneStatus::Errored, + result: None, + error: Some(message), + session_id: Some(session_id.clone()), + }).await + { + break 'main; + } + continue 'main; + } + } + } // Same transform as the initial prompt: Claude's // Ultrathink prefix rides every steer too. let text = prompt_transform(request.reasoning, &msg.prompt); @@ -4606,7 +4804,9 @@ async fn run_session(session: Session) { queued_steers.push_back(text); } else if turn.is_none() && (!open_tools.is_empty() - || last_update_at.elapsed() < BUSY_RECENT) + || (last_update_at.elapsed() < BUSY_RECENT + && prompt_settled_at + .is_none_or(|settled| last_update_at > settled + OWN_TAIL))) { // Mid self-continued turn (see BUSY_RECENT above): // cancel it rather than prompt into the starve. @@ -4688,6 +4888,40 @@ async fn run_session(session: Session) { } }, + // End the prompt in flight, not the agent: `session/cancel` + // settles it `cancelled` and the session takes the next prompt + // (background work the agent holds keeps running). Steers still + // waiting for this turn's end go with it. + _ = turn_control.stop_requested(), if !interrupted => { + queued_steers.clear(); + steer_backlog.clear(); + // A steer still in flight goes with the turn too: its answer + // (promptRequired, once the turn is cancelled) would queue it + // again and run the cancelled message after all. + steering_call = None; + deferred.clear(); + released = None; + preempt_pending = false; + if turn.is_some() { + stopping = true; + if !preempt_sent { + client.notify("session/cancel", Some(json!({ "sessionId": session_id }))); + } + } else { + // Between turns: nothing to cancel. Settle the stop so the + // host sees the turn over. + done_current = true; + if !send(&event_tx, AgentEvent::Done { + status: DoneStatus::Interrupted, + result: None, + error: None, + session_id: Some(session_id.clone()), + }).await { + break 'main; + } + } + }, + _ = interrupt.cancelled(), if !interrupt_sent => { interrupt_sent = true; interrupted = true; diff --git a/crates/harness/src/acp/normalize.rs b/crates/harness/src/acp/normalize.rs index e828fc897a..c524b727a4 100644 --- a/crates/harness/src/acp/normalize.rs +++ b/crates/harness/src/acp/normalize.rs @@ -9,7 +9,7 @@ //! statuses are snake_case). use serde_json::Value; -use zeron_proto::{AgentEvent, SlashCommand, TodoItem, ToolCall, ToolDiff}; +use zeron_proto::{AgentEvent, SlashCommand, TodoItem, TodoStatus, ToolCall, ToolDiff}; /// Byte cap applied to tool output text at the harness boundary. The doc-side /// fold applies its own (smaller) cap before anything persists; this one only @@ -403,9 +403,11 @@ pub(crate) fn map_update(update: &Value) -> Vec { .map(|a| a.as_slice()) .unwrap_or_default() .iter() - .map(|e| TodoItem { - text: str_field(e, "content"), - done: e.get("status").and_then(Value::as_str) == Some("completed"), + .map(|e| { + TodoItem::new( + str_field(e, "content"), + TodoStatus::parse(e.get("status").and_then(Value::as_str).unwrap_or("")), + ) }) .collect(); // The plan has no wire id; a stable synthetic id makes every @@ -659,14 +661,8 @@ mod tests { id: zeron_proto::LIVE_PLAN_TOOL_ID.into(), call: ToolCall::Todo { items: vec![ - TodoItem { - text: "read code".into(), - done: true - }, - TodoItem { - text: "write fix".into(), - done: false - }, + TodoItem::new("read code", TodoStatus::Completed), + TodoItem::new("write fix", TodoStatus::InProgress), ] }, } diff --git a/crates/harness/src/claude/catalog.rs b/crates/harness/src/claude/catalog.rs index 21e3fc89da..fd0dadf601 100644 --- a/crates/harness/src/claude/catalog.rs +++ b/crates/harness/src/claude/catalog.rs @@ -254,6 +254,11 @@ pub(super) fn with_discovered_models( continue; } valid = true; + // The CLI lists some models by dated snapshot ("claude-haiku-4-5-20251001"); + // fold those into the curated undated row instead of duplicating it. + let id = snapshot_base(id) + .filter(|base| models.iter().any(|model| model.id == *base)) + .unwrap_or(id); if text("value") == Some("default") { default = Some(id.to_owned()); } @@ -314,6 +319,12 @@ pub(super) fn with_discovered_models( Ok(models) } +/// `claude-haiku-4-5-20251001` -> `claude-haiku-4-5`. +fn snapshot_base(id: &str) -> Option<&str> { + let (base, date) = id.rsplit_once('-')?; + (date.len() == 8 && date.bytes().all(|b| b.is_ascii_digit())).then_some(base) +} + pub fn static_models() -> Vec { vec![ model( @@ -397,6 +408,19 @@ mod tests { assert_eq!(to_effort(Some(ReasoningLevel::XHigh), None), Some("max")); } + #[test] + fn dated_snapshots_fold_into_curated_rows() { + let response = serde_json::json!({"response": {"models": [ + {"value": "claude-haiku-4-5-20251001", "resolvedModel": "claude-haiku-4-5-20251001", + "displayName": "Haiku 4.5", "description": "Fastest for quick answers"}, + {"value": "claude-haiku-9-9-20990101", "displayName": "Haiku 9.9"}, + ]}}); + let models = with_discovered_models(static_models(), &response).unwrap(); + assert_eq!(models.iter().filter(|m| m.label == "Haiku 4.5").count(), 1); + // Snapshots with no curated base still surface as-is. + assert!(models.iter().any(|m| m.id == "claude-haiku-9-9-20990101")); + } + #[test] fn xhigh_family_matching() { assert!(supports_xhigh("claude-fable-5")); diff --git a/crates/harness/src/claude/mod.rs b/crates/harness/src/claude/mod.rs index 9e4d792f89..abacbc3d4b 100644 --- a/crates/harness/src/claude/mod.rs +++ b/crates/harness/src/claude/mod.rs @@ -25,11 +25,23 @@ //! NEVER folded into the parent feed (a background subagent interleaves //! with the parent's own stream — folding them in split contiguous text //! around phantom tool calls). +//! - A `result` that is not a turn's end: every stdin user line carries a +//! uuid, and the CLI reports each one's `command_lifecycle`. A result +//! while one of ours is still written/queued is a boundary (a `now` steer +//! aborting the turn, resume settling dead background tasks) — that +//! message's turn ends with its own result. See [`Commands`]. //! - Steering: queued [`SteerMessage`]s are written to stdin as user lines at //! any time; the CLI folds them into the running turn at its own step -//! boundary. -//! - Interrupt: cancelling [`RunControls::interrupt`] sends the protocol-level -//! interrupt control request, then escalates to SIGTERM and SIGKILL. +//! boundary. A steer's changed model/effort/options apply to the live +//! process first (`set_model`, `apply_flag_settings`) — never a restart. +//! - Turn stop ([`crate::TurnControl::stop_turn`]): the `interrupt` control +//! request with `cancel_queued` ends the turn and the CLI keeps reading +//! stdin. The run opens with an `initialize` declaring +//! `perTaskStopAffordance`, which makes that interrupt spare background +//! agents (the CLI otherwise stops them all); background shells survive +//! either way. `background_tasks_changed` reports the live set to the host. +//! - Interrupt: cancelling [`RunControls::interrupt`] tears the runtime down — +//! the interrupt control request, then SIGTERM and SIGKILL. pub mod catalog; mod discovery; @@ -91,6 +103,36 @@ fn mcp_config_arg(mcp: &zeron_proto::McpServer) -> String { .to_string() } +/// The `--model` value: the 1M context window is selected via a model-id +/// suffix (`sonnet[1m]`), exactly how the CLI itself does it. +fn model_arg(request: &RunRequest) -> Option { + let model = request.model.as_ref()?; + let one_m = request + .model_options + .get("contextWindow") + .and_then(Value::as_str) + == Some("1m"); + Some(if one_m { + format!("{model}[1m]") + } else { + model.clone() + }) +} + +/// Every flag-layer setting a request launches with, for +/// `apply_flag_settings` on a live process: `null` drops the override back +/// to the user's own settings, exactly as a launch without the flag +/// (verified live on 2.1.286 through `get_settings`). +fn flag_settings(request: &RunRequest) -> Value { + let on = |set: bool| if set { Value::Bool(true) } else { Value::Null }; + serde_json::json!({ + "effortLevel": to_effort(request.reasoning, request.model.as_deref()), + "fastMode": on(option_is_on(&request.model_options, "fastMode")), + "alwaysThinkingEnabled": on(option_is_on(&request.model_options, "thinking")), + "ultracode": on(request.reasoning == Some(ReasoningLevel::Ultracode)), + }) +} + fn option_is_on(options: &serde_json::Map, key: &str) -> bool { match options.get(key) { Some(Value::Bool(b)) => *b, @@ -164,10 +206,11 @@ impl ClaudeHarness { }) } - fn build_command(&self, exe: &PathBuf, request: &RunRequest) -> Command { - let mut cmd = Command::new(exe); - crate::compose_child_path(&mut cmd, exe); - cmd.args([ + /// The configuration arguments a run launches with — everything the + /// process bakes in at spawn except the session to resume. Two requests + /// with equal arguments (and cwd) are the same runtime. + fn launch_args(request: &RunRequest) -> Vec { + let mut args: Vec = [ "--print", "--input-format", "stream-json", @@ -186,37 +229,27 @@ impl ClaudeHarness { // Undocumented flag; validated live against 2.1.228. "--permission-prompt-tool", "stdio", - ]); - // The 1M context window is selected via a model-id suffix - // (`sonnet[1m]`), exactly how the CLI itself does it; fast mode and - // always-on thinking are settings overrides. - if let Some(model) = &request.model { - let one_m = request - .model_options - .get("contextWindow") - .and_then(Value::as_str) - == Some("1m"); - cmd.arg("--model"); - cmd.arg(if one_m { - format!("{model}[1m]") - } else { - model.clone() - }); + ] + .map(str::to_owned) + .into(); + if let Some(model) = model_arg(request) { + args.push("--model".into()); + args.push(model); } if let Some(effort) = to_effort(request.reasoning, request.model.as_deref()) { - cmd.args(["--effort", effort]); + args.extend(["--effort".into(), effort.into()]); } if request.auto_approve { - cmd.args([ - "--permission-mode", - "bypassPermissions", - "--dangerously-skip-permissions", - ]); + args.extend( + [ + "--permission-mode", + "bypassPermissions", + "--dangerously-skip-permissions", + ] + .map(str::to_owned), + ); } else { - cmd.args(["--permission-mode", "default"]); - } - if let Some(resume) = &request.resume { - cmd.arg(format!("--resume={resume}")); + args.extend(["--permission-mode".into(), "default".into()]); } let mut settings = serde_json::Map::new(); if option_is_on(&request.model_options, "fastMode") { @@ -229,8 +262,18 @@ impl ClaudeHarness { settings.insert("ultracode".into(), Value::Bool(true)); } if !settings.is_empty() { - cmd.arg("--settings"); - cmd.arg(Value::Object(settings).to_string()); + args.push("--settings".into()); + args.push(Value::Object(settings).to_string()); + } + args + } + + fn build_command(&self, exe: &PathBuf, request: &RunRequest) -> Command { + let mut cmd = Command::new(exe); + crate::compose_child_path(&mut cmd, exe); + cmd.args(Self::launch_args(request)); + if let Some(resume) = &request.resume { + cmd.arg(format!("--resume={resume}")); } if !request.cwd.is_empty() { cmd.current_dir(&request.cwd); @@ -411,6 +454,22 @@ impl Harness for ClaudeHarness { fn deterministic_turn_end(&self) -> bool { true } + /// The `interrupt` control request ends the turn and the CLI keeps + /// reading stdin; background agents and shells run on. + fn stops_turn_in_place(&self) -> bool { + true + } + /// Same launch arguments and cwd. The CLI ignores the sandbox level, and + /// option spellings that launch the same flags are the same runtime. + fn same_runtime(&self, live: &RunRequest, next: &RunRequest) -> bool { + live.cwd == next.cwd && Self::launch_args(live) == Self::launch_args(next) + } + /// Model, context window, effort, fast mode, thinking and ultracode all + /// change on the live process (`set_model`, `apply_flag_settings`). Only + /// the directory and the permission mode it launched with need a new one. + fn reconfigures_in_place(&self, live: &RunRequest, next: &RunRequest) -> bool { + live.cwd == next.cwd && live.auto_approve == next.auto_approve + } /// Credential and executable identity scopes both initialize and catalog caches. fn model_context(&self) -> Result, HarnessError> { @@ -544,6 +603,15 @@ impl ClaudeHarness { // config with settings-sourced ones. cmd.args(["--mcp-config", &mcp_config_arg(mcp)]); } + let normalizer = if let Some(session_id) = &request.resume { + let config = crate::model_context::root( + "CLAUDE_CONFIG_DIR", + crate::executable::home_or_current_dir().join(".claude"), + ); + Normalizer::for_resume(&config, session_id).await + } else { + Normalizer::new() + }; let mut child = cmd.spawn().map_err(|e| { if e.kind() == std::io::ErrorKind::NotFound { HarnessError::NotInstalled(crate::executable::binary_hint(&exe)) @@ -574,6 +642,11 @@ impl ClaudeHarness { let (stdin_tx, stdin_rx) = mpsc::unbounded_channel::(); tokio::spawn(stdin_writer(stdin, stdin_rx)); + if !title_only { + let _ = stdin_tx.send(StdinMsg::Line(wire::initialize_request_line( + "zeron_initialize", + ))); + } // The initial prompt as the first stdin user line (streaming-input // mode). Ultrathink rides every user message — steers included. @@ -582,21 +655,25 @@ impl ClaudeHarness { // also ride the prompt text, so a skipped/unreadable file degrades to // the old-app behavior (the agent opens the path with its Read tool). let images = load_image_blocks(&request.attachments).await; - let first = wire::user_message_line_with_images( + let first_command = uuid::Uuid::new_v4().to_string(); + let first = wire::prompt_line( &apply_ultrathink(request.reasoning, &request.prompt), + &first_command, &images, ); let _ = stdin_tx.send(StdinMsg::Line(first)); let (event_tx, event_rx) = mpsc::channel::>(256); tokio::spawn(run_session(Session { + normalizer, title_only, child, stdout_lines: BufReader::new(stdout).lines(), + first_command, stdin_tx, event_tx, controls, - reasoning: request.reasoning, + config: request, interrupt_grace: self.interrupt_grace, kill_grace: self.kill_grace, stderr_tail, @@ -714,13 +791,18 @@ async fn stdin_writer(mut stdin: ChildStdin, mut rx: mpsc::UnboundedReceiver>, + /// The uuid the opening prompt was written with. + first_command: String, stdin_tx: mpsc::UnboundedSender, event_tx: mpsc::Sender>, controls: RunControls, - reasoning: Option, + /// The configuration the process runs with now: its launch request, then + /// whatever a steer's `config` applied in place. + config: RunRequest, interrupt_grace: Duration, kill_grace: Duration, /// Rolling stderr tail for the crash message on an unexpected exit. @@ -731,27 +813,31 @@ struct Session { /// mailbox, the interrupt token, and consumer liveness. async fn run_session(session: Session) { let Session { + normalizer: mut norm, title_only, mut child, mut stdout_lines, + first_command, stdin_tx, event_tx, controls, - reasoning, + mut config, interrupt_grace, kill_grace, stderr_tail, } = session; let RunControls { + realtime: _, execution_lease: _execution_lease, request_input, mut steering, interrupt, + turn, } = controls; let request_input = Arc::new(request_input); - let mut norm = Normalizer::new(); let mut pending_steers = std::collections::VecDeque::new(); + let mut reconfigures = 0u64; // Top-level tool calls in flight: a steer must not abort them (see // `wire::steer_message_line`). let mut open_tools = std::collections::HashSet::new(); @@ -759,15 +845,42 @@ async fn run_session(session: Session) { let mut interrupted = false; let mut interrupt_sent = false; let mut any_done = false; - // A turn end held back while steers wait for their replay. Rapid `now` - // steers each interrupt the turn the previous one started, and the CLI - // replays only the last (verified on 2.1.280; the earlier texts still - // reach the model). If nothing follows the held result, the steers were - // absorbed: release them and the turn end instead of spinning forever. - const HELD_DONE_SETTLE: Duration = Duration::from_secs(5); - let mut held_done: Option<(AgentEvent, tokio::time::Instant)> = None; + // A turn stop in flight (`TurnControl::stop_turn`): its request id, and + // once the CLI has answered, how long a turn-ending `result` may still + // take. The CLI answers a stop between turns without any result — the + // deadline settles that shape so the host never waits on it. + const STOP_SETTLE: Duration = Duration::from_secs(3); + let mut stops_sent = 0u64; + let mut stopping: Option = None; + let mut stop_settle_at: Option = None; + // A turn end held for steers when the stop came: that turn's end, sent + // as stopped once the stop settles — never before. Released early, the + // host started the replacement prompt while the stopped turn could still + // report, and that late result settled the replacement. + let mut stopped_end: Option = None; + // When the stopped turn's one Done went out, and whether anything has + // happened since — a message of ours written, or new output streamed. A + // result in that window with neither can only be the stopped turn + // reporting late, and is dropped. Anything else is reconciled like any + // result (held for unconfirmed messages, settled by their fallback): + // dropping on time alone lost a quick replacement's own end. + let mut stop_done_at: Option = None; + let mut active_since_stop = false; + let mut commands = Commands::default(); + commands.written(first_command); + // A `result` that is a steer boundary rather than the turn's end (see + // [`Commands`]). It is superseded — dropped — once the waiting message + // starts its own turn, which ends with its own result; it is released + // as the turn's end if that message is cancelled before it starts. + let mut held_done: Option = None; + // Last stdout activity, for the held result's settle fallback. + let mut last_frame_at = tokio::time::Instant::now(); let mut done_after_interrupt = false; let mut escalation: Option> = None; + // The runtime's process tree when its teardown began (see + // [`crate::shutdown_agent`]). + #[cfg_attr(not(unix), allow(unused_mut))] + let mut torn_down: Vec = Vec::new(); 'main: loop { tokio::select! { @@ -778,10 +891,8 @@ async fn run_session(session: Session) { continue; } // The CLI is still producing: whatever it is doing is not - // the quiet end the held turn end waits for. - if let Some((_, deadline)) = held_done.as_mut() { - *deadline = tokio::time::Instant::now() + HELD_DONE_SETTLE; - } + // the quiet end a held turn end's fallback waits for. + last_frame_at = tokio::time::Instant::now(); let frame = match wire::parse_frame(line) { Ok(frame) => frame, Err(e) => { @@ -789,6 +900,17 @@ async fn run_session(session: Session) { continue; } }; + if let Frame::ControlResponse(response) = &frame { + if stopping.as_deref() == Some(response.response.request_id.as_str()) { + stop_settle_at = Some(tokio::time::Instant::now() + STOP_SETTLE); + } + continue; + } + if let Frame::System(system) = &frame + && system.subtype == "background_tasks_changed" + { + turn.set_background(system.tasks.as_ref().map_or(0, Vec::len)); + } if let Frame::ControlRequest(req) = frame { if title_only { let line = control_response_line(&req.request_id, serde_json::json!({ @@ -800,27 +922,70 @@ async fn run_session(session: Session) { } continue; } - // Only the CLI's replay confirms that a prompt joined its - // conversation. Writing stdin must not split ongoing text. - if let Frame::User(ref user) = frame { - // A replay confirms its steer and every earlier one: - // superseded steers are never replayed themselves. - if user.parent_tool_use_id.is_none() - && let Some(at) = user - .uuid - .as_ref() - .and_then(|id| pending_steers.iter().position(|p| p == id)) - { - for _ in 0..=at { - pending_steers.pop_front(); - let (prev, next) = norm.rotate_for_steer(); - if event_tx.send(Ok(AgentEvent::Steered { - assistant_message_id: Some(prev), next_assistant_message_id: Some(next), - })).await.is_err() { break 'main; } - } + // Only the CLI confirms that a prompt joined its + // conversation — its lifecycle or its replay, whichever + // comes first. Writing stdin must not split ongoing text. + // A confirmation covers its steer and every earlier one: + // superseded steers are never replayed themselves. A + // message a turn took up (started, or replayed) also + // supersedes a result held for it: that result was only + // the boundary, and the message's turn ends with its own. + let (confirmed, taken_up) = match &frame { + Frame::CommandLifecycle(cmd) => { + let started = commands.lifecycle(&cmd.command_uuid, &cmd.state); + let confirmed = started || Commands::is_terminal(&cmd.state); + (confirmed.then(|| cmd.command_uuid.clone()), started) + } + Frame::User(user) if user.parent_tool_use_id.is_none() => { + let replayed = user.uuid.clone().filter(|id| pending_steers.contains(id)); + let taken_up = replayed.is_some(); + (replayed, taken_up) + } + _ => (None, false), + }; + if taken_up { + held_done = None; + active_since_stop = true; + } + if let Some(at) = confirmed + .and_then(|id| pending_steers.iter().position(|p| *p == id)) + { + for _ in 0..=at { + pending_steers.pop_front(); + let (prev, next) = norm.rotate_for_steer(); + if event_tx.send(Ok(AgentEvent::Steered { + assistant_message_id: Some(prev), next_assistant_message_id: Some(next), + })).await.is_err() { break 'main; } } } - for ev in norm.normalize(frame, interrupted) { + // Every waiting message was cancelled before it started: + // the held result was the turn's real end after all. + if matches!(frame, Frame::CommandLifecycle(_)) + && !commands.waiting() + && let Some(done) = held_done.take() + { + if event_tx.send(Ok(done)).await.is_err() { + break 'main; + } + any_done = true; + } + // The stopped turn reporting late: a result with nothing + // since the stop's Done. Judged on the frame, before it + // expands — its own Usage is not new activity — and + // dropped whole, usage included. + if matches!(frame, Frame::Result(_)) + && !interrupted + && stopping.is_none() + && !active_since_stop + && stop_done_at.is_some_and(|at| at.elapsed() < STOP_SETTLE) + { + tracing::debug!( + target: "zeron_harness::claude", + "late result of a stopped turn dropped" + ); + continue; + } + for ev in norm.normalize(frame, interrupted || stopping.is_some()) { match &ev { AgentEvent::ToolCall { id, .. } => { open_tools.insert(id.clone()); @@ -833,15 +998,41 @@ async fn run_session(session: Session) { } let is_done = matches!(ev, AgentEvent::Done { .. }); // A `now` steer ends the turn it interrupts with a - // result frame; the steer continues the run, so that + // result frame, and a message queued behind a turn + // that just ended starts another: either way that // result is a steer boundary, not the end of the turn. - if is_done && !interrupted && !pending_steers.is_empty() { - held_done = - Some((ev, tokio::time::Instant::now() + HELD_DONE_SETTLE)); + let boundary = if commands.reported { + commands.waiting() + } else { + !pending_steers.is_empty() + }; + // New content is a new turn's: its result is its own. + if stop_done_at.is_some() + && match &ev { + AgentEvent::TextDelta { text } + | AgentEvent::ReasoningDelta { text } => !text.is_empty(), + AgentEvent::ToolCall { .. } + | AgentEvent::ToolResult { .. } + | AgentEvent::InputRequested { .. } + | AgentEvent::Subagent { .. } => true, + _ => false, + } + { + active_since_stop = true; + } + if is_done && !interrupted && stopping.is_none() && boundary { + held_done = Some(ev); continue; } if is_done { held_done = None; + // The stopped turn has ended; the runtime lives on. + if stopping.take().is_some() { + stopped_end = None; + stop_done_at = Some(tokio::time::Instant::now()); + active_since_stop = false; + } + stop_settle_at = None; } if event_tx.send(Ok(ev)).await.is_err() { break 'main; // consumer gone — reap below @@ -864,13 +1055,38 @@ async fn run_session(session: Session) { steer = steering.recv(), if steering_open && !interrupted => match steer { Some(msg) => { + // A message of ours now owns whatever result comes next. + active_since_stop = true; + // A changed configuration applies before the prompt, on + // the same stdin the CLI reads in order. + if let Some(next) = msg.config { + reconfigures += 1; + if model_arg(&next) != model_arg(&config) { + let line = wire::set_model_request_line( + &format!("zeron_model_{reconfigures}"), + model_arg(&next).as_deref(), + ); + if stdin_tx.send(StdinMsg::Line(line)).is_err() { break 'main; } + } + if flag_settings(&next) != flag_settings(&config) { + let line = wire::apply_flag_settings_line( + &format!("zeron_settings_{reconfigures}"), + flag_settings(&next), + ); + if stdin_tx.send(StdinMsg::Line(line)).is_err() { break 'main; } + } + config = *next; + } let id = uuid::Uuid::new_v4().to_string(); - let line = wire::steer_message_line( - &apply_ultrathink(reasoning, &msg.prompt), + let images = load_image_blocks(&msg.attachments).await; + let line = wire::steer_message_line_with_images( + &apply_ultrathink(config.reasoning, &msg.prompt), &id, open_tools.is_empty(), + &images, ); - pending_steers.push_back(id); + pending_steers.push_back(id.clone()); + commands.written(id); if stdin_tx.send(StdinMsg::Line(line)).is_err() { break 'main; } } None => { @@ -881,9 +1097,62 @@ async fn run_session(session: Session) { } }, + // End the in-flight turn, not the session: the CLI stays up, so + // its background agents and shells keep running. Steers written + // before the stop are cancelled with it (`cancel_queued`) — a + // stopped turn must not continue into them. + _ = turn.stop_requested(), if !interrupted => { + stops_sent += 1; + let id = format!("zeron_stop_{stops_sent}"); + pending_steers.clear(); + commands.cancel_waiting(); + stopping = Some(id.clone()); + stop_settle_at = None; + stop_done_at = None; + if stdin_tx.send(StdinMsg::Line(wire::stop_turn_request_line(&id))).is_err() { + break 'main; + } + // A turn end held for steer replays is that turn's real end: + // its steers were just cancelled, so it ends stopped — once + // the stop settles. A turn the CLI had already begun for + // those steers reports first, and that report is the end. + if let Some(mut done) = held_done.take() { + if let AgentEvent::Done { status, .. } = &mut done { + *status = DoneStatus::Interrupted; + } + stopped_end = Some(done); + } + }, + + // The CLI answered the stop and no turn ended: it was between + // turns. Settle the stop so the host sees the turn over. + _ = tokio::time::sleep_until(stop_settle_at.unwrap_or_else(tokio::time::Instant::now)), + if stop_settle_at.is_some() => { + stop_settle_at = None; + if stopping.take().is_some() { + let done = stopped_end.take().unwrap_or(AgentEvent::Done { + status: DoneStatus::Interrupted, + result: None, + error: None, + session_id: norm.session_id.clone(), + }); + if event_tx.send(Ok(done)).await.is_err() { + break 'main; + } + any_done = true; + stop_done_at = Some(tokio::time::Instant::now()); + active_since_stop = false; + } + }, + _ = interrupt.cancelled(), if !interrupt_sent => { interrupt_sent = true; interrupted = true; + // The runtime is going: so is everything it started. + #[cfg(unix)] + if let Some(pid) = child.id() { + torn_down = crate::process::descendants(pid).await; + } let _ = stdin_tx.send(StdinMsg::Line(wire::interrupt_request_line("int_1"))); // Escalate if the CLI doesn't wind down within the grace // periods: SIGTERM (kills bash trees, runs SessionEnd hooks), @@ -899,16 +1168,17 @@ async fn run_session(session: Session) { }, _ = tokio::time::sleep_until( - held_done.as_ref().map_or_else(tokio::time::Instant::now, |(_, d)| *d) - ), if held_done.is_some() => { + last_frame_at + commands.held_settle().unwrap_or_default() + ), if held_done.is_some() && commands.held_settle().is_some() => { // The steers were absorbed into the turn that just ended. + commands.cancel_waiting(); while pending_steers.pop_front().is_some() { let (prev, next) = norm.rotate_for_steer(); if event_tx.send(Ok(AgentEvent::Steered { assistant_message_id: Some(prev), next_assistant_message_id: Some(next), })).await.is_err() { break 'main; } } - let (done, _) = held_done.take().expect("guarded by if"); + let done = held_done.take().expect("guarded by if"); if event_tx.send(Ok(done)).await.is_err() { break 'main; } @@ -920,7 +1190,7 @@ async fn run_session(session: Session) { } // A turn end still held when the CLI exited is the run's real end. - if let Some((done, _)) = held_done.take() + if let Some(done) = held_done.take() && !event_tx.is_closed() && event_tx.send(Ok(done)).await.is_ok() { @@ -952,12 +1222,101 @@ async fn run_session(session: Session) { } } - shutdown_child(&mut child, kill_grace).await; + crate::shutdown_agent(&mut child, torn_down, kill_grace).await; if let Some(handle) = escalation { handle.abort(); } } +/// How far the CLI has taken each stdin user message we wrote, by the uuid +/// it was written with. A `result` frame ends the TURN only when no message +/// is still waiting to be taken up. A waiting one makes the result a steer +/// boundary — a `now` steer aborting the turn it interrupts, or a message +/// queued behind a turn that just finished — and that message's own turn +/// ends with its own result. Deciding this from the CLI's own lifecycle +/// rather than from quiet time: a held result released by a quiet timer +/// marked the steered turn done mid-way through any long tool call. +#[derive(Default)] +struct Commands { + /// Uuid → how far it got. Ended commands are dropped. + open: std::collections::HashMap, + /// The CLI reports `command_lifecycle`. Older CLIs never do: their only + /// confirmation is the replay, and the settle fallback covers the rest. + reported: bool, +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum CommandState { + /// On stdin; the CLI has not acknowledged it yet. + Written, + /// The CLI read it and will start or cancel it. + Queued, + /// A turn took it up. + Started, +} + +impl Commands { + /// Without lifecycle reports, how long a held result waits in silence + /// for the replay that supersedes it. + const UNREPORTED_SETTLE: Duration = Duration::from_secs(5); + /// A lifecycle-reporting CLI acknowledges a message as soon as it reads + /// it. A hold resting only on unacknowledged messages is a safety net + /// against a message the CLI never reports, not a timing guess. + const UNACKNOWLEDGED_SETTLE: Duration = Duration::from_secs(30); + + fn written(&mut self, id: String) { + self.open.insert(id, CommandState::Written); + } + + fn is_terminal(state: &str) -> bool { + matches!(state, "completed" | "cancelled" | "canceled" | "failed") + } + + /// Apply one `command_lifecycle` frame. True when a turn took the + /// command up (`started`). + fn lifecycle(&mut self, id: &str, state: &str) -> bool { + self.reported = true; + if Self::is_terminal(state) { + self.open.remove(id); + return false; + } + let next = match state { + "started" => CommandState::Started, + "queued" => CommandState::Queued, + _ => return false, + }; + if let Some(current) = self.open.get_mut(id) + && *current != CommandState::Started + { + *current = next; + } + next == CommandState::Started + } + + /// A message is written or queued but no turn has taken it up. + fn waiting(&self) -> bool { + self.open.values().any(|s| *s != CommandState::Started) + } + + /// A stop's `cancel_queued` drops every message not yet taken up. + fn cancel_waiting(&mut self) { + self.open.retain(|_, s| *s == CommandState::Started); + } + + /// The quiet time after which a held result is released as the turn's + /// end; `None` = only the CLI's own lifecycle settles it. + fn held_settle(&self) -> Option { + if !self.reported { + Some(Self::UNREPORTED_SETTLE) + } else if self.open.values().any(|s| *s == CommandState::Queued) { + // A queued message always starts or is cancelled. + None + } else { + Some(Self::UNACKNOWLEDGED_SETTLE) + } + } +} + type RequestInputFn = Box< dyn Fn(Vec) -> tokio::sync::oneshot::Receiver> + Send diff --git a/crates/harness/src/claude/normalize.rs b/crates/harness/src/claude/normalize.rs index d49aa07afc..5ae737c2ab 100644 --- a/crates/harness/src/claude/normalize.rs +++ b/crates/harness/src/claude/normalize.rs @@ -2,7 +2,7 @@ //! decoding, error-code mapping). use serde_json::Value; -use zeron_proto::{AgentEvent, DoneStatus, HarnessId, TodoItem, ToolCall}; +use zeron_proto::{AgentEvent, DoneStatus, HarnessId, TodoItem, TodoStatus, ToolCall}; use super::wire::{ContentBlock, Frame}; @@ -106,9 +106,11 @@ pub(crate) fn decode_tool_use(name: &str, input: &Value) -> ToolCall { .map(|a| a.as_slice()) .unwrap_or_default() .iter() - .map(|t| TodoItem { - text: str_field(t, "content"), - done: t.get("status").and_then(Value::as_str) == Some("completed"), + .map(|t| { + TodoItem::new( + str_field(t, "content"), + TodoStatus::parse(t.get("status").and_then(Value::as_str).unwrap_or("")), + ) }) .collect(), }, @@ -191,6 +193,8 @@ pub(crate) struct Normalizer { /// re-keys them onto the spawn chip's feed (the wire never echoes the /// steer on the child feed — live-verified 2.1.228). agent_tasks: std::collections::HashMap, + /// Later tool calls that wake the same task → its original spawn. + agent_tool_spawns: std::collections::HashMap, /// tool_use ids of Agent/Task spawn calls, recorded from their own /// assistant frames (plus `task_started`'s agent-task pairing). Gates /// `task_notification`: background SHELL tasks settle through the same @@ -212,12 +216,116 @@ impl Normalizer { saw_init: false, last_model: None, agent_tasks: std::collections::HashMap::new(), + agent_tool_spawns: std::collections::HashMap::new(), agent_spawn_tools: std::collections::HashSet::new(), assistant_message_id: new_message_id(), session_id: None, } } + /// Restore only spawn identity from Claude's native history. The CLI + /// does not replay task_started when --resume starts a new process. + /// Session ids are global; search project directories rather than + /// duplicating the CLI's cwd encoding (including long-path hashing). + /// Long sessions' histories run to hundreds of MB: the scan runs off the + /// runtime and only parses lines that can name a spawn or its agent. + pub async fn for_resume(config_root: &std::path::Path, session_id: &str) -> Self { + let config_root = config_root.to_owned(); + let session_id = session_id.to_owned(); + tokio::task::spawn_blocking(move || Self::restore_spawns(&config_root, &session_id)) + .await + .unwrap_or_else(|_| Self::new()) + } + + fn restore_spawns(config_root: &std::path::Path, session_id: &str) -> Self { + use std::io::BufRead as _; + let mut norm = Self::new(); + if session_id.is_empty() + || !session_id + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-') + { + return norm; + } + let Ok(projects) = std::fs::read_dir(config_root.join("projects")) else { + return norm; + }; + let Some(file) = projects.flatten().find_map(|project| { + std::fs::File::open(project.path().join(format!("{session_id}.jsonl"))).ok() + }) else { + return norm; + }; + let mut reader = std::io::BufReader::with_capacity(1 << 16, file); + let mut line = Vec::new(); + loop { + line.clear(); + match reader.read_until(b'\n', &mut line) { + Ok(0) | Err(_) => break, + Ok(_) => {} + } + // Torn lines and older/newer unknown shapes are harmless. + let Ok(text) = std::str::from_utf8(&line) else { + continue; + }; + if !(text.contains("\"Agent\"") + || text.contains("\"Task\"") + || text.contains("agentId")) + { + continue; + } + let Ok(record) = serde_json::from_str::(text) else { + continue; + }; + // A subagent's own nested spawns are not chips on this chat. + if record.get("isSidechain").and_then(Value::as_bool) == Some(true) + || !record.get("parent_tool_use_id").is_none_or(Value::is_null) + { + continue; + } + let Some(blocks) = record.pointer("/message/content").and_then(Value::as_array) else { + continue; + }; + match record.get("type").and_then(Value::as_str) { + Some("assistant") => { + for block in blocks { + if block.get("type").and_then(Value::as_str) == Some("tool_use") + && matches!( + block.get("name").and_then(Value::as_str), + Some("Agent" | "Task") + ) + && let Some(id) = block + .get("id") + .and_then(Value::as_str) + .filter(|id| !id.is_empty()) + { + norm.agent_spawn_tools.insert(id.to_owned()); + } + } + } + Some("user") => { + if let Some(agent) = record + .pointer("/toolUseResult/agentId") + .and_then(Value::as_str) + .filter(|id| !id.is_empty()) + && let Some(spawn) = blocks + .iter() + .filter(|b| { + b.get("type").and_then(Value::as_str) == Some("tool_result") + }) + .filter_map(|b| b.get("tool_use_id").and_then(Value::as_str)) + .find(|id| norm.agent_spawn_tools.contains(*id)) + { + norm.agent_tasks + .entry(agent.to_owned()) + .or_insert_with(|| spawn.to_owned()); + } + } + _ => {} + } + } + norm + } + /// Rotate the assistant message id for a steer boundary; returns /// (previous, next) for the `Steered` event. pub fn rotate_for_steer(&mut self) -> (String, String) { @@ -237,16 +345,22 @@ impl Normalizer { // Surface it as the subagent's tagged Done so the chip flips // done/failed and the transcript freezes. if f.subtype == "task_notification" { - let Some(parent) = f.tool_use_id.as_deref().filter(|t| !t.is_empty()) else { + // A resumed task's notification may carry SendMessage's + // id, or omit the tool id entirely. Its task id is stable. + let parent = f + .task_id + .as_deref() + .and_then(|task| self.agent_tasks.get(task).map(String::as_str)) + .or_else(|| f.tool_use_id.as_deref().filter(|t| !t.is_empty())); + let Some(parent) = parent else { return Vec::new(); }; // Only a KNOWN spawn settles as a subagent. Background // SHELL tasks (`Bash` with `run_in_background`) settle // through this same subtype carrying the Bash call's id — // tagging that Done would bind a subagent ref onto an - // ordinary Run chip. A real spawn's tool_use frame always - // precedes its notification on the wire, so the set is - // populated by the time a genuine one arrives. + // ordinary Run chip. Spawn ids are learned from live + // calls/task starts or restored from the native history. if !self.agent_spawn_tools.contains(parent) { return Vec::new(); } @@ -279,8 +393,13 @@ impl Normalizer { f.tool_use_id.as_deref().filter(|t| !t.is_empty()), ) { - self.agent_tasks.insert(task.to_owned(), tool.to_owned()); - self.agent_spawn_tools.insert(tool.to_owned()); + let spawn = self + .agent_tasks + .entry(task.to_owned()) + .or_insert_with(|| tool.to_owned()); + self.agent_spawn_tools.insert(spawn.clone()); + self.agent_tool_spawns + .insert(tool.to_owned(), spawn.clone()); return Vec::new(); } if f.subtype != "init" || self.saw_init { @@ -311,6 +430,7 @@ impl Normalizer { return Vec::new(); } if let Some(parent) = &f.parent_tool_use_id { + let parent = self.agent_tool_spawns.get(parent).unwrap_or(parent); return match f.event.delta.kind.as_str() { "text_delta" => vec![tag( parent, @@ -350,6 +470,7 @@ impl Normalizer { Frame::Assistant(f) => { if let Some(parent) = &f.parent_tool_use_id { + let parent = self.agent_tool_spawns.get(parent).unwrap_or(parent); // Subagent content, attributed. The 2.1.x wire streams NO // tagged partial deltas (live-verified): a subagent's text // arrives only as full text blocks on its tagged @@ -386,7 +507,7 @@ impl Normalizer { return out; } // Record spawn tool ids up front: `task_notification` keys on - // them, and only foreground spawns ever get a `task_started`. + // them; a task may finish before a `task_started` arrives. for b in f.message.blocks() { if b.kind == "tool_use" && matches!(b.name.as_str(), "Agent" | "Task") { self.agent_spawn_tools.insert(b.id.clone()); @@ -485,6 +606,7 @@ impl Normalizer { Frame::User(f) => { if let Some(parent) = &f.parent_tool_use_id { + let parent = self.agent_tool_spawns.get(parent).unwrap_or(parent); // A subagent's tool results echo on the main channel too; // they belong to its transcript, attributed like its calls. let mut out: Vec = f @@ -533,9 +655,10 @@ impl Normalizer { } // A claude.ai plan window was hit. A hard `rejected` blocks the - // turn — make it visible; allowed/allowed_warning stay quiet. + // turn — make it visible; allowed/allowed_warning stay quiet, and + // so does a reject the account's overage carries past. Frame::RateLimit(f) => { - if f.rate_limit_info.status != "rejected" { + if !f.rate_limit_info.blocks() { return Vec::new(); } let window = @@ -648,7 +771,10 @@ impl Normalizer { } // Control frames are handled by the run loop, not normalized. - Frame::ControlRequest(_) | Frame::Other => Vec::new(), + Frame::ControlRequest(_) + | Frame::ControlResponse(_) + | Frame::CommandLifecycle(_) + | Frame::Other => Vec::new(), } } } @@ -683,10 +809,27 @@ mod tests { &json!({"todos": [{"content": "t", "status": "completed"}]}) ), ToolCall::Todo { - items: vec![TodoItem { - text: "t".into(), - done: true - }] + items: vec![TodoItem::new("t", TodoStatus::Completed)] + } + ); + // Claude's in-progress state survives; unknown/missing reads as pending. + assert_eq!( + decode_tool_use( + "TodoWrite", + &json!({"todos": [ + {"content": "a", "status": "completed"}, + {"content": "b", "status": "in_progress", "activeForm": "Doing b"}, + {"content": "c", "status": "pending"}, + {"content": "d"}, + ]}) + ), + ToolCall::Todo { + items: vec![ + TodoItem::new("a", TodoStatus::Completed), + TodoItem::new("b", TodoStatus::InProgress), + TodoItem::new("c", TodoStatus::Pending), + TodoItem::new("d", TodoStatus::Pending), + ] } ); assert_eq!( @@ -708,6 +851,24 @@ mod tests { Normalizer::new().normalize(frame, false) } + #[test] + fn a_rejected_window_carried_by_overage_is_not_an_error() { + let blocked = normalize_one( + r#"{"type":"rate_limit_event","rate_limit_info":{"status":"rejected","rateLimitType":"five_hour","overageStatus":"rejected"}}"#, + ); + assert!(matches!(blocked.as_slice(), [AgentEvent::Error { .. }])); + for overage in [ + r#""overageStatus":"allowed""#, + r#""overageStatus":"allowed_warning""#, + r#""isUsingOverage":true"#, + ] { + let raw = format!( + r#"{{"type":"rate_limit_event","rate_limit_info":{{"status":"rejected","rateLimitType":"five_hour",{overage}}}}}"# + ); + assert!(normalize_one(&raw).is_empty(), "{overage}"); + } + } + fn result_done(raw: &str) -> AgentEvent { let events = normalize_one(raw); assert_eq!(events.len(), 2, "usage + done"); @@ -1025,6 +1186,130 @@ mod tests { ); } + #[test] + fn resumed_task_notifications_settle_the_original_spawn() { + for notification in [ + r#"{"type":"system","subtype":"task_notification","task_id":"a1","tool_use_id":"toolu_send","status":"completed"}"#, + r#"{"type":"system","subtype":"task_notification","task_id":"a1","status":"stopped"}"#, + ] { + let mut norm = Normalizer::new(); + for raw in [ + r#"{"type":"system","subtype":"task_started","task_id":"a1","tool_use_id":"toolu_spawn","subagent_type":"general-purpose"}"#, + r#"{"type":"assistant","message":{"content":[{"type":"tool_use","id":"toolu_send","name":"SendMessage","input":{"to":"a1","message":"Keep going."}}]}}"#, + r#"{"type":"system","subtype":"task_started","task_id":"a1","tool_use_id":"toolu_send","subagent_type":"general-purpose"}"#, + ] { + norm.normalize(crate::claude::wire::parse_frame(raw).unwrap(), false); + } + let events = norm.normalize( + crate::claude::wire::parse_frame(notification).unwrap(), + false, + ); + let expected = if notification.contains("completed") { + DoneStatus::Completed + } else { + DoneStatus::Interrupted + }; + assert_eq!( + events, + vec![tag( + "toolu_spawn", + AgentEvent::Done { + status: expected, + result: None, + error: None, + session_id: None, + } + )] + ); + // The next steer must still use the original transcript too. + let events = norm.normalize(crate::claude::wire::parse_frame( + r#"{"type":"assistant","message":{"content":[{"type":"tool_use","id":"toolu_send2","name":"SendMessage","input":{"to":"a1","message":"One more check."}}]}}"#, + ).unwrap(), false); + assert!( + events.iter().any(|e| matches!(e, + AgentEvent::Subagent { parent_tool_use_id, event } + if parent_tool_use_id == "toolu_spawn" + && matches!(event.as_ref(), AgentEvent::UserMessage { .. }) + )), + "{events:?}" + ); + } + } + + #[test] + fn resumed_child_frames_use_the_original_transcript() { + let mut norm = Normalizer::new(); + for raw in [ + r#"{"type":"system","subtype":"task_started","task_id":"a1","tool_use_id":"toolu_spawn","subagent_type":"general-purpose"}"#, + r#"{"type":"system","subtype":"task_started","task_id":"a1","tool_use_id":"toolu_send","subagent_type":"general-purpose"}"#, + ] { + norm.normalize(crate::claude::wire::parse_frame(raw).unwrap(), false); + } + for raw in [ + r#"{"type":"stream_event","parent_tool_use_id":"toolu_send","event":{"type":"content_block_delta","delta":{"type":"text_delta","text":"working"}}}"#, + r#"{"type":"assistant","parent_tool_use_id":"toolu_send","message":{"content":[{"type":"text","text":"finished"}]}}"#, + r#"{"type":"user","parent_tool_use_id":"toolu_send","message":{"content":[{"type":"tool_result","tool_use_id":"child-tool"}]}}"#, + ] { + let events = norm.normalize(crate::claude::wire::parse_frame(raw).unwrap(), false); + assert!(!events.is_empty()); + assert!(events.iter().all(|e| matches!(e, + AgentEvent::Subagent { parent_tool_use_id, .. } if parent_tool_use_id == "toolu_spawn" + )), "{events:?}"); + } + } + + #[tokio::test] + async fn resumed_session_restores_task_ids_from_the_native_transcript() { + let root = tempfile::tempdir().unwrap(); + let project = root.path().join("projects/encoded-project"); + std::fs::create_dir_all(&project).unwrap(); + std::fs::write(project.join("session-1.jsonl"), concat!( + "not json\n", + r#"{"type":"assistant","message":{"content":[{"type":"tool_use","id":"toolu_spawn","name":"Agent","input":{"description":"scout"}}]}}"#, + "\n", + r#"{"type":"user","toolUseResult":{"agentId":"a1","status":"async_launched"},"message":{"content":[{"type":"tool_result","tool_use_id":"toolu_spawn"}]}}"#, + "\n", + r#"{"type":"assistant","message":{"content":[{"type":"tool_use","id":"toolu_send","name":"SendMessage","input":{"to":"a1","message":"continue"}}]}}"#, + "\n", + r#"{"type":"user","toolUseResult":{"agentId":"a1"},"message":{"content":[{"type":"tool_result","tool_use_id":"toolu_send"}]}}"#, + "\n", + // A subagent's own nested spawn is not a chip on this chat. + r#"{"type":"assistant","isSidechain":true,"message":{"content":[{"type":"tool_use","id":"toolu_nested","name":"Agent","input":{"description":"deeper"}}]}}"#, + "\n", + r#"{"type":"user","isSidechain":true,"toolUseResult":{"agentId":"a2"},"message":{"content":[{"type":"tool_result","tool_use_id":"toolu_nested"}]}}"#, + "\n{torn tail" + )).unwrap(); + let mut norm = Normalizer::for_resume(root.path(), "session-1").await; + assert!(!norm.agent_spawn_tools.contains("toolu_nested")); + assert!(!norm.agent_tasks.contains_key("a2")); + // The revived process has no original spawn frame on its wire. + norm.normalize(crate::claude::wire::parse_frame( + r#"{"type":"system","subtype":"task_started","task_id":"a1","tool_use_id":"toolu_send","subagent_type":"general-purpose"}"#, + ).unwrap(), false); + let events = norm.normalize(crate::claude::wire::parse_frame( + r#"{"type":"system","subtype":"task_notification","task_id":"a1","tool_use_id":"toolu_send","status":"completed"}"#, + ).unwrap(), false); + assert_eq!( + events, + vec![tag( + "toolu_spawn", + AgentEvent::Done { + status: DoneStatus::Completed, + result: None, + error: None, + session_id: None, + } + )] + ); + assert!(!norm.saw_init, "history must not consume the live init"); + assert!( + Normalizer::for_resume(root.path(), "missing") + .await + .agent_tasks + .is_empty() + ); + } + #[test] fn shell_task_notification_never_settles_a_subagent() { // `Bash` with `run_in_background` settles through the SAME diff --git a/crates/harness/src/claude/wire.rs b/crates/harness/src/claude/wire.rs index 828c4f18d9..1f25d8ac9e 100644 --- a/crates/harness/src/claude/wire.rs +++ b/crates/harness/src/claude/wire.rs @@ -17,10 +17,40 @@ pub(crate) enum Frame { RateLimit(RateLimitFrame), Result(ResultFrame), ControlRequest(ControlRequestFrame), - /// control_response / control_cancel_request / anything unknown. + /// The CLI's answer to one of our control requests. + ControlResponse(ControlResponseFrame), + /// Where the CLI has taken one of our stdin user messages. + CommandLifecycle(CommandLifecycleFrame), + /// control_cancel_request / anything unknown. Other, } +#[derive(Debug, Default, Deserialize)] +pub(crate) struct ControlResponseFrame { + #[serde(default)] + pub response: ControlResponseBody, +} + +#[derive(Debug, Default, Deserialize)] +pub(crate) struct ControlResponseBody { + #[serde(default)] + pub request_id: String, +} + +/// `{"type":"command_lifecycle","command_uuid":…,"state":…}` — emitted for +/// every stdin user message carrying a `uuid` (verified live against CLI +/// 2.1.286): `queued` when read, `started` when a turn takes it up (a `next` +/// steer starts mid-turn, at the step boundary it folds into), then +/// `completed` or `cancelled` (its turn was aborted by a later `now` steer +/// or an interrupt). Background-task wake turns carry no command at all. +#[derive(Debug, Default, Deserialize)] +pub(crate) struct CommandLifecycleFrame { + #[serde(default)] + pub command_uuid: String, + #[serde(default)] + pub state: String, +} + #[derive(Debug, Default, Deserialize)] pub(crate) struct SystemFrame { #[serde(default)] @@ -33,22 +63,25 @@ pub(crate) struct SystemFrame { pub cwd: String, #[serde(default)] pub session_id: String, - /// `task_notification` (a background subagent settling): the spawning - /// Agent tool's id — the only TAGGED terminal signal the 2.1.x wire has - /// for a background subagent (its frames otherwise just stop). + /// `task_notification`: the spawning Agent tool's id, or the latest + /// SendMessage id after a resume. Stop notifications may omit it. #[serde(default, alias = "toolUseId")] pub tool_use_id: Option, /// `task_notification` terminal status (`completed`/`failed`/`killed`…). #[serde(default)] pub status: Option, - /// `task_started`: the agent/task id (`SendMessage`'s `to:` address) — - /// with `tool_use_id`, the agentId→spawn mapping steers need. + /// `task_started` / `task_notification`: stable agent/task id + /// (`SendMessage`'s `to:` address), used to retain spawn identity. #[serde(default, alias = "taskId")] pub task_id: Option, /// `task_started`: present only for AGENT tasks (a subagent spawning), /// absent on subagent-owned background shell tasks. #[serde(default)] pub subagent_type: Option, + /// `background_tasks_changed`: every background task the session holds + /// now (agents and shells) — the authoritative set, not a delta. + #[serde(default)] + pub tasks: Option>, } #[derive(Debug, Default, Deserialize)] @@ -143,6 +176,28 @@ pub(crate) struct RateLimitInfo { pub status: String, #[serde(rename = "rateLimitType", default)] pub rate_limit_type: Option, + /// `allowed`/`allowed_warning` when the account may spend provisioned + /// overage past a rejected window. + #[serde(rename = "overageStatus", default)] + pub overage_status: Option, + #[serde(rename = "isUsingOverage", default)] + pub is_using_overage: bool, + #[serde(rename = "overageInUse", default)] + pub overage_in_use: bool, +} + +impl RateLimitInfo { + /// A rejected window that actually blocks the turn: an account spending + /// provisioned overage keeps running despite the reject. + pub fn blocks(&self) -> bool { + self.status == "rejected" + && !matches!( + self.overage_status.as_deref(), + Some("allowed" | "allowed_warning") + ) + && !self.is_using_overage + && !self.overage_in_use + } } #[derive(Debug, Default, Deserialize)] @@ -200,6 +255,8 @@ pub(crate) fn parse_frame(line: &str) -> Result { "rate_limit_event" => Frame::RateLimit(serde_json::from_value(value)?), "result" => Frame::Result(serde_json::from_value(value)?), "control_request" => Frame::ControlRequest(serde_json::from_value(value)?), + "control_response" => Frame::ControlResponse(serde_json::from_value(value)?), + "command_lifecycle" => Frame::CommandLifecycle(serde_json::from_value(value)?), _ => Frame::Other, }; Ok(frame) @@ -207,6 +264,7 @@ pub(crate) fn parse_frame(line: &str) -> Result { /// A stdin user turn: `{"type":"user","message":{...},"parent_tool_use_id":null}`. /// Steering = another such line mid-run (consumed at a step boundary). +#[cfg(test)] pub(crate) fn user_message_line(text: &str) -> String { json!({ "type": "user", @@ -223,9 +281,26 @@ pub(crate) fn user_message_line(text: &str) -> String { /// was interrupted before a result was received"), so while any tool is open /// the steer goes as `next`: the tool finishes and the steer lands right after /// its result, in the same turn. An interrupted turn still emits a `result`. +#[cfg(test)] pub(crate) fn steer_message_line(text: &str, id: &str, immediate: bool) -> String { + steer_message_line_with_images(text, id, immediate, &[]) +} + +/// [`steer_message_line`] carrying inline images ahead of the text, in the +/// same block shape as [`user_message_line_with_images`]. +pub(crate) fn steer_message_line_with_images( + text: &str, + id: &str, + immediate: bool, + images: &[ImageBlock], +) -> String { + let content = if images.is_empty() { + Value::String(text.to_owned()) + } else { + Value::Array(content_blocks(text, images)) + }; serde_json::json!({"type":"user", "uuid":id, "priority": if immediate { "now" } else { "next" }, - "message":{"role":"user","content":text}, "parent_tool_use_id":null}) + "message":{"role":"user","content":content}, "parent_tool_use_id":null}) .to_string() } @@ -241,10 +316,38 @@ pub(crate) struct ImageBlock { /// first, then the text — the standard Anthropic image+text message shape /// (verified against the real CLI: `--input-format stream-json` accepts image /// content blocks in user frames). Empty `images` degrades to the plain line. +/// The run's opening prompt, tagged with `id` so its `command_lifecycle` +/// frames can be told apart from a steer's. Images precede the text, as in +/// [`user_message_line_with_images`]. +pub(crate) fn prompt_line(text: &str, id: &str, images: &[ImageBlock]) -> String { + let content = if images.is_empty() { + Value::String(text.to_owned()) + } else { + Value::Array(content_blocks(text, images)) + }; + json!({ + "type": "user", + "uuid": id, + "message": { "role": "user", "content": content }, + "parent_tool_use_id": null, + }) + .to_string() +} + +#[cfg(test)] pub(crate) fn user_message_line_with_images(text: &str, images: &[ImageBlock]) -> String { if images.is_empty() { return user_message_line(text); } + json!({ + "type": "user", + "message": { "role": "user", "content": content_blocks(text, images) }, + "parent_tool_use_id": null, + }) + .to_string() +} + +fn content_blocks(text: &str, images: &[ImageBlock]) -> Vec { let mut blocks: Vec = images .iter() .map(|img| { @@ -259,10 +362,50 @@ pub(crate) fn user_message_line_with_images(text: &str, images: &[ImageBlock]) - }) .collect(); blocks.push(json!({ "type": "text", "text": text })); + blocks +} + +/// The run's opening control request. `perTaskStopAffordance` tells the CLI +/// this host stops tasks individually, so an interrupt ends the turn and +/// spares background agents — without it the CLI stops every background +/// agent on interrupt (verified live against 2.1.285; background shells +/// survive either way). +pub(crate) fn initialize_request_line(request_id: &str) -> String { json!({ - "type": "user", - "message": { "role": "user", "content": blocks }, - "parent_tool_use_id": null, + "type": "control_request", + "request_id": request_id, + "request": { "subtype": "initialize", "perTaskStopAffordance": true }, + }) + .to_string() +} + +/// Switch the live process's model (`None` = the user's default model). +pub(crate) fn set_model_request_line(request_id: &str, model: Option<&str>) -> String { + json!({ + "type": "control_request", + "request_id": request_id, + "request": { "subtype": "set_model", "model": model }, + }) + .to_string() +} + +/// Replace the live process's flag-layer settings (effort, fast mode, …). +pub(crate) fn apply_flag_settings_line(request_id: &str, settings: Value) -> String { + json!({ + "type": "control_request", + "request_id": request_id, + "request": { "subtype": "apply_flag_settings", "settings": settings }, + }) + .to_string() +} + +/// Stop the in-flight turn only. `cancel_queued` drops steers the CLI has +/// queued but not yet consumed: a stopped turn never continues into them. +pub(crate) fn stop_turn_request_line(request_id: &str) -> String { + json!({ + "type": "control_request", + "request_id": request_id, + "request": { "subtype": "interrupt", "cancel_queued": true }, }) .to_string() } diff --git a/crates/harness/src/codex/mod.rs b/crates/harness/src/codex/mod.rs index 93ef7527b8..a10d582449 100644 --- a/crates/harness/src/codex/mod.rs +++ b/crates/harness/src/codex/mod.rs @@ -28,16 +28,25 @@ //! become tagged [`AgentEvent::Subagent`] events; unrelated child bookkeeping is //! consumed so it can never settle the parent turn, and unknown methods //! fall through to the parent path (fail open, never silent loss). -//! - Steering: `turn/steer { expectedTurnId }` into the live turn; a rejected -//! steer (the turn-completed race) is queued and delivered as the next -//! `turn/start` on the same thread. The session is persistent across turns -//! while the steering mailbox lives. -//! - Interrupt: cancelling [`RunControls::interrupt`] sends `turn/interrupt`, -//! escalating to SIGTERM → SIGKILL if the child is unresponsive; the stream -//! always ends with `Done { status: Interrupted }`. +//! - Steering: `turn/steer { expectedTurnId, clientUserMessageId }` into the +//! live turn, confirmed when its userMessage item (carrying that id) joins +//! the turn; a rejected steer (the turn-completed race) is queued and +//! delivered as the next `turn/start` on the same thread. The session is +//! persistent across turns while the steering mailbox lives; a steer's +//! changed model/effort/tier rides the next `turn/start`. +//! - Only the turn in flight settles: a stale or duplicate completion is +//! ignored. Commands still running past their turn report as background +//! work, so the host never reaps the app-server from under them. +//! - Turn stop ([`crate::TurnControl::stop_turn`]): `turn/interrupt` ends the +//! turn `Interrupted`; the app-server and its threads (child agents +//! included) stay up for the next `turn/start`. +//! - Interrupt: cancelling [`RunControls::interrupt`] tears the runtime down — +//! `turn/interrupt`, escalating to SIGTERM → SIGKILL if the child is +//! unresponsive; the stream always ends with `Done { status: Interrupted }`. pub(crate) mod catalog; mod normalize; +pub mod realtime; mod subagents; use std::collections::{HashMap, HashSet, VecDeque}; @@ -86,6 +95,21 @@ pub fn resolve_codex_executable() -> Option { crate::executable::find_on_paths("codex", extra) } +/// `canonicalize` yields `\\?\`-prefixed verbatim paths on Windows, which +/// cmd.exe cannot launch batch shims through; keep the plain drive/UNC form. +fn plain_executable(path: PathBuf) -> PathBuf { + #[cfg(windows)] + { + if let Some(rest) = path.to_str().and_then(|p| p.strip_prefix(r"\\?\")) { + return match rest.strip_prefix(r"UNC\") { + Some(share) => PathBuf::from(format!(r"\\{share}")), + None => PathBuf::from(rest), + }; + } + } + path +} + /// Dotted `thread/start` config overrides that add an injected MCP server /// to the user's `mcp_servers` table. fn codex_mcp_overrides(mcp: &zeron_proto::McpServer) -> Vec<(String, Value)> { @@ -331,6 +355,16 @@ impl CodexHarness { } } +/// The `serviceTier` a request runs with; "default" (Standard) is omitted. +fn service_tier(request: &RunRequest) -> Option { + request + .model_options + .get("serviceTier") + .and_then(Value::as_str) + .filter(|t| *t != "default") + .map(str::to_owned) +} + fn reasoning_level(value: &str) -> Option { Some(match value { "minimal" => ReasoningLevel::Minimal, @@ -590,6 +624,31 @@ impl Harness for CodexHarness { fn deterministic_turn_end(&self) -> bool { true } + /// `turn/interrupt` ends the turn; the app-server and its thread — child + /// agent threads included — stay up for the next `turn/start`. + fn stops_turn_in_place(&self) -> bool { + true + } + /// What the app-server launch and its turns read: the sandbox is forced + /// to full access, and `serviceTier` is the only model option used. + fn same_runtime(&self, live: &RunRequest, next: &RunRequest) -> bool { + live.cwd == next.cwd + && live.model == next.model + && to_effort(live.reasoning) == to_effort(next.reasoning) + && service_tier(live) == service_tier(next) + && live.auto_approve == next.auto_approve + } + /// `turn/start` carries model, effort and service tier, and each sticks + /// for later turns — so a change applies from the next turn without a + /// new app-server. Clearing one back to the default cannot be expressed + /// that way (an omitted parameter keeps the previous value). + fn reconfigures_in_place(&self, live: &RunRequest, next: &RunRequest) -> bool { + live.cwd == next.cwd + && live.auto_approve == next.auto_approve + && (next.model.is_some() || live.model.is_none()) + && (to_effort(next.reasoning).is_some() || to_effort(live.reasoning).is_none()) + && (service_tier(next).is_some() || service_tier(live).is_none()) + } /// The signed-in account's visible `model/list` is authoritative. A /// curated snapshot keeps the picker operational when the experimental @@ -652,7 +711,15 @@ impl Harness for CodexHarness { request: RunRequest, controls: RunControls, ) -> Result>, HarnessError> { - self.run_with_mode(request, controls, false).await + self.run_with_mode(request, controls, false, false).await + } + + async fn start_idle( + &self, + request: RunRequest, + controls: RunControls, + ) -> Result>, HarnessError> { + self.run_with_mode(request, controls, false, true).await } async fn run_title( @@ -666,7 +733,7 @@ impl Harness for CodexHarness { request.mcp = None; request.model_options.clear(); request.auto_approve = false; - self.run_with_mode(request, controls, true).await + self.run_with_mode(request, controls, true, false).await } } @@ -676,6 +743,7 @@ impl CodexHarness { mut request: RunRequest, controls: RunControls, title_only: bool, + idle: bool, ) -> Result>, HarnessError> { let native = command_request(&request.prompt, "")?; if native @@ -692,7 +760,13 @@ impl CodexHarness { "Codex commands cannot include attachments; send them in a separate prompt".into(), )); } - let exe = self.resolve_executable()?; + // Pin the physical release for this process and its voice helper: an + // installer may move the current symlink while this runtime stays warm. + let exe = plain_executable( + self.resolve_executable()? + .canonicalize() + .map_err(HarnessError::Io)?, + ); // Yolo mode: danger-full-access + approvalPolicy "never" (set below) — // codex's --dangerously-bypass-approvals-and-sandbox equivalent. // Parity with the Claude adapter, which auto-approves every @@ -747,6 +821,8 @@ impl CodexHarness { let (event_tx, event_rx) = mpsc::channel::>(256); tokio::spawn(run_session(Session { title_only, + idle, + executable: exe, child, client, incoming, @@ -770,7 +846,9 @@ impl CodexHarness { // --------------------------------------------------------------------------- struct Session { + executable: PathBuf, title_only: bool, + idle: bool, child: Child, client: RpcClient, incoming: mpsc::Receiver, @@ -790,6 +868,11 @@ struct Session { #[derive(Default)] struct TurnRouter { active: Option, + /// The app-server announced `active` (`turn/started`). Until then it may + /// not have registered the turn: `turn/interrupt` answers "no active + /// turn to interrupt" (live, 0.159.3, an interrupt right after the + /// `turn/start` response) and the turn would run to its end. + announced: bool, completed: VecDeque, } @@ -810,6 +893,7 @@ impl TurnRouter { self.remember_completed(prev); } self.active = Some(id); + self.announced = true; } fn note_completed(&mut self, id: &str) { @@ -819,13 +903,28 @@ impl TurnRouter { self.remember_completed(id.to_owned()); if self.active.as_deref() == Some(id) { self.active = None; + self.announced = false; } } /// Adopt a turn id from a `turn/start` RESPONSE (the notification is - /// allowed to beat it). + /// allowed to beat it). A turn already running keeps its id: a follow-up + /// `turn/start` folds into it and answers with that same id anyway, and + /// an announced turn is authoritative. fn adopt_started(&mut self, id: String) { - self.active = (!id.is_empty() && !self.is_completed(&id)).then_some(id); + if self.active.is_some() || id.is_empty() || self.is_completed(&id) { + return; + } + self.active = Some(id); + self.announced = false; + } + + /// Whether a `turn/completed` (or failed/aborted) for `id` ends the turn + /// in flight. A completion for a turn already settled, or for another + /// turn while ours runs, is stale: settling on it marked the running + /// turn done mid-way. + fn settles(&self, id: &str) -> bool { + id.is_empty() || (!self.is_completed(id) && self.active.as_deref().is_none_or(|a| a == id)) } fn remember_completed(&mut self, id: String) { @@ -946,7 +1045,9 @@ async fn start_turn(client: &RpcClient, params: Value) -> Result((thread_id, children)) + let voice_context = realtime::ThreadContext { + cwd: thread["cwd"].as_str().unwrap_or(&request.cwd).to_owned(), + model_provider: thread["modelProvider"].as_str().map(str::to_owned), + }; + Ok::<_, HarnessError>((thread_id, children, voice_context)) }; - let (thread_id, mut children) = tokio::select! { + let (thread_id, mut children, voice_context) = tokio::select! { res = setup => match res { Ok(thread_id) => thread_id, Err(e) => { @@ -1128,28 +1237,29 @@ async fn run_session(session: Session) { } }; - let turn_params = |text: &str| -> Value { + // Model, effort and service tier ride every `turn/start` (each sticks + // for later turns): a steer's in-place `config` takes effect from the + // next turn the runtime starts. + let mut live = request.clone(); + let turn_params = |live: &RunRequest, text: &str| -> Value { let mut p = serde_json::Map::new(); p.insert("threadId".into(), Value::String(thread_id.clone())); p.insert("input".into(), prompt_input(text)); p.insert("approvalPolicy".into(), approval_policy.into()); - p.insert( - "sandboxPolicy".into(), - sandbox_policy_value(request.sandbox), - ); + p.insert("sandboxPolicy".into(), sandbox_policy_value(live.sandbox)); // Reasoning summaries stream (`item/reasoning/summaryTextDelta`) only // when asked for — without this codex "thinks" in silence for minutes: // nothing renders and the UI's 45s staleness gate flips Working off // (user report: "not streaming, doesn't say it's working"). p.insert("summary".into(), "auto".into()); - if let Some(model) = &request.model { + if let Some(model) = &live.model { p.insert("model".into(), Value::String(model.clone())); } - if let Some(effort) = effort { + if let Some(effort) = to_effort(live.reasoning) { p.insert("effort".into(), effort.into()); } - if let Some(tier) = &service_tier { - p.insert("serviceTier".into(), Value::String(tier.clone())); + if let Some(tier) = service_tier(live) { + p.insert("serviceTier".into(), Value::String(tier)); } Value::Object(p) }; @@ -1173,19 +1283,30 @@ async fn run_session(session: Session) { } let mut router = TurnRouter::default(); - match start_turn(&client, turn_params(&request.prompt)).await { - Ok(id) => router.adopt_started(id), - Err(e) => { - let _ = event_tx - .send(Ok(AgentEvent::Done { - status: DoneStatus::Errored, - result: None, - error: Some(e.to_string()), - session_id: Some(thread_id.clone()), - })) - .await; - shutdown_child(&mut child, kill_grace).await; - return; + let _voice_bridge = realtime.map(|controls| { + realtime::attach( + client.clone(), + thread_id.clone(), + executable, + voice_context, + controls, + ) + }); + if !idle { + match start_turn(&client, turn_params(&live, &request.prompt)).await { + Ok(id) => router.adopt_started(id), + Err(e) => { + let _ = event_tx + .send(Ok(AgentEvent::Done { + status: DoneStatus::Errored, + result: None, + error: Some(e.to_string()), + session_id: Some(thread_id.clone()), + })) + .await; + shutdown_child(&mut child, kill_grace).await; + return; + } } } @@ -1198,18 +1319,46 @@ async fn run_session(session: Session) { let mut pending_usage: Option = None; // Steers whose `turn/steer` lost the turn-completed race; delivered as the // next `turn/start` when the expected turn's end notification arrives. - let mut queued_steers: VecDeque = VecDeque::new(); + // Each carries the configuration it was sent with, adopted when its own + // `turn/start` goes out. + let mut queued_steers: VecDeque<(String, Option>)> = VecDeque::new(); + // Steers the app-server accepted into the running turn, by the + // `clientUserMessageId` they were sent with. Each is confirmed + // (`Steered`) when its userMessage item joins the turn — not when + // `turn/steer` returns, which split the reply still streaming ahead of + // it into the steer's segment. + let mut accepted_steers: VecDeque = VecDeque::new(); + // A turn stop waiting for the app-server to announce the turn. + let mut stop_when_announced = false; + // Commands the parent thread started and the app-server has not reported + // finished, with their process id and the turn that started them. One + // still open after its turn is background work (a dev server, a + // watcher): its `item/completed` arrives when the process exits, turns + // later (live, 0.159.3). The host's idle reaper must not retire the + // app-server — and the process with it — while it runs. + let mut open_commands: HashMap, Option)> = HashMap::new(); + // An interrupted turn's foreground command is killed WITHOUT an + // `item/completed`: once its process has had a moment to go, drop the + // turn's commands whose process is gone (background ones live on). + let mut prune_at: Option<(tokio::time::Instant, String)> = None; let mut steering_open = true; let mut interrupted = false; let mut interrupt_sent = false; + // A turn stop (`TurnControl::stop_turn`) in flight: the turn's end reads + // as Interrupted and the app-server stays up for the next turn. + let mut stopping = false; // A Done has been emitted for the turn currently/last in flight. - let mut done_current = false; + let mut done_current = idle; let mut current_native = command_request(&request.prompt, &thread_id) .ok() .flatten() .is_some(); let mut done_after_interrupt = false; let mut escalation: Option> = None; + // The runtime's process tree when its teardown began (see + // [`crate::shutdown_agent`]). + #[cfg_attr(not(unix), allow(unused_mut))] + let mut torn_down: Vec = Vec::new(); 'main: loop { tokio::select! { @@ -1240,7 +1389,27 @@ async fn run_session(session: Session) { } } match method.as_str() { - "turn/started" => router.note_started(turn_id(¶ms)), + "turn/started" => { + let id = turn_id(¶ms); + // Native voice handoffs start a turn without going through turn/start. + // Publish the boundary before its text/tool deltas reach the parked engine. + if done_current && !id.is_empty() && !router.is_completed(&id) { + done_current = false; + let (prev, next) = rotate(&mut assistant_message_id); + if !send(&event_tx, AgentEvent::Steered { + assistant_message_id: Some(prev), + next_assistant_message_id: Some(next), + }).await { + break 'main; + } + } + router.note_started(id); + if std::mem::take(&mut stop_when_announced) + && let Some(active) = router.active.clone() + { + send_turn_interrupt(&client, &thread_id, active); + } + } "item/agentMessage/delta" => { streamed_text.insert(item_id(¶ms)); @@ -1269,6 +1438,35 @@ async fn run_session(session: Session) { Phase::Completed }; let item = params.get("item").unwrap_or(&Value::Null); + if matches!(item_type(item), "commandExecution" | "command_execution") { + let id = item.get("id").and_then(Value::as_str).unwrap_or_default(); + let changed = if phase == Phase::Started { + let pid = item.get("processId").and_then(Value::as_str).map(str::to_owned); + open_commands.insert(id.to_owned(), (pid, router.active.clone())).is_none() + } else { + open_commands.remove(id).is_some() + }; + if changed { + turn.set_background(open_commands.len()); + } + } + if matches!(item_type(item), "userMessage" | "user_message") + && let Some(at) = item + .get("clientId") + .and_then(Value::as_str) + .and_then(|id| accepted_steers.iter().position(|s| s == id)) + { + for _ in 0..=at { + accepted_steers.pop_front(); + let (prev, next) = rotate(&mut assistant_message_id); + if !send(&event_tx, AgentEvent::Steered { + assistant_message_id: Some(prev), + next_assistant_message_id: Some(next), + }).await { + break 'main; + } + } + } if phase == Phase::Completed { let output = match item_type(item) { "exitedReviewMode" => item.get("review").and_then(Value::as_str), @@ -1339,9 +1537,38 @@ async fn run_session(session: Session) { } } + // Codex's `update_plan` tool: the whole checklist, replaced + // on every call, with an in-progress step. + "turn/plan/updated" => { + for ev in normalize::plan_update_events(¶ms) { + if !send(&event_tx, ev).await { break 'main; } + } + } + "turn/completed" => { let id = turn_id(¶ms); + if !router.settles(&id) { + tracing::debug!( + target: "zeron_harness::codex", + turn = %id, + "stale turn/completed ignored (not the turn in flight)" + ); + router.note_completed(&id); + continue 'main; + } router.note_completed(&id); + stop_when_announced = false; + // Accepted steers whose item never surfaced still + // joined this turn: confirm them before its end. + while accepted_steers.pop_front().is_some() { + let (prev, next) = rotate(&mut assistant_message_id); + if !send(&event_tx, AgentEvent::Steered { + assistant_message_id: Some(prev), + next_assistant_message_id: Some(next), + }).await { + break 'main; + } + } // Item ids never span turns; without this the set grew // one entry per message for a persistent session's life. streamed_text.clear(); @@ -1357,13 +1584,19 @@ async fn run_session(session: Session) { == Some("failed")) .then(|| "Codex turn failed".to_owned()) }); - let status = if interrupted { + let status = if interrupted || stopping { DoneStatus::Interrupted } else if error.is_some() { DoneStatus::Errored } else { DoneStatus::Completed }; + if status == DoneStatus::Interrupted + || params.pointer("/turn/status").and_then(Value::as_str) == Some("interrupted") + { + prune_at = Some((tokio::time::Instant::now() + COMMAND_EXIT_GRACE, id.clone())); + } + stopping = false; done_current = true; if !send( &event_tx, @@ -1386,11 +1619,14 @@ async fn run_session(session: Session) { // this turn's end becomes the next turn now; otherwise // stay alive for the mailbox — the caller owns teardown. current_native = false; - if let Some(text) = queued_steers.pop_front() { + if let Some((text, config)) = queued_steers.pop_front() { + if let Some(next) = config { + live = *next; + } current_native = command_request(&text, &thread_id).ok().flatten().is_some(); if !steer_as_new_turn( &client, - turn_params(&text), + turn_params(&live, &text), &mut router, &event_tx, &mut assistant_message_id, @@ -1406,13 +1642,32 @@ async fn run_session(session: Session) { } "turn/failed" => { - router.note_completed(&turn_id(¶ms)); + let id = turn_id(¶ms); + if !router.settles(&id) { + router.note_completed(&id); + continue 'main; + } + router.note_completed(&id); + stop_when_announced = false; + accepted_steers.clear(); if let Some(usage) = pending_usage.take() && !send(&event_tx, usage).await { break 'main; } done_current = true; + if std::mem::take(&mut stopping) && !interrupted { + // The stopped turn's end: the thread lives on. + if !send(&event_tx, AgentEvent::Done { + status: DoneStatus::Interrupted, + result: None, + error: None, + session_id: Some(thread_id.clone()), + }).await { + break 'main; + } + continue 'main; + } if interrupted { done_after_interrupt = true; } @@ -1437,8 +1692,27 @@ async fn run_session(session: Session) { } "turn/aborted" => { - router.note_completed(&turn_id(¶ms)); + let id = turn_id(¶ms); + if !router.settles(&id) { + router.note_completed(&id); + continue 'main; + } + router.note_completed(&id); + stop_when_announced = false; + accepted_steers.clear(); done_current = true; + prune_at = Some((tokio::time::Instant::now() + COMMAND_EXIT_GRACE, id.clone())); + if std::mem::take(&mut stopping) && !interrupted { + if !send(&event_tx, AgentEvent::Done { + status: DoneStatus::Interrupted, + result: None, + error: None, + session_id: Some(thread_id.clone()), + }).await { + break 'main; + } + continue 'main; + } if interrupted { done_after_interrupt = true; } @@ -1494,34 +1768,34 @@ async fn run_session(session: Session) { Some(msg) => { let text = msg.prompt; // Native operations run at a turn boundary, never as text - // injected into an already running model turn. Later messages - // must stay behind queued commands: Steered acknowledgments - // retire the engine's accepted-message ledger in FIFO order. - if !done_current && (!queued_steers.is_empty() || current_native || !matches!(command_request(&text, &thread_id), Ok(None))) { - queued_steers.push_back(text); + // injected into an already running model turn. So does a + // changed configuration: `turn/steer` carries no model, + // effort or tier, and only a `turn/start` applies them. + // Later messages must stay behind queued ones: Steered + // acknowledgments retire the engine's accepted-message + // ledger in FIFO order. + if !done_current + && (!queued_steers.is_empty() + || current_native + || msg.config.is_some() + || !matches!(command_request(&text, &thread_id), Ok(None))) + { + queued_steers.push_back((text, msg.config)); continue 'main; } + if let Some(next) = msg.config { + live = *next; + } if let Some(expected) = router.active.clone() { + let client_id = new_message_id(); let steer_params = json!({ "threadId": thread_id, "expectedTurnId": expected, + "clientUserMessageId": client_id, "input": prompt_input(&text), }); match client.request("turn/steer", steer_params).await { - Ok(_) => { - let (prev, next) = rotate(&mut assistant_message_id); - if !send( - &event_tx, - AgentEvent::Steered { - assistant_message_id: Some(prev), - next_assistant_message_id: Some(next), - }, - ) - .await - { - break 'main; - } - } + Ok(_) => accepted_steers.push_back(client_id), // A failed `turn/steer` does NOT mean the text is // bad: most commonly the active turn finished // between the UI send and this request. Queue it @@ -1536,11 +1810,11 @@ async fn run_session(session: Session) { if router.active.as_deref() == Some(expected.as_str()) && !router.is_completed(&expected) { - queued_steers.push_back(text); + queued_steers.push_back((text, None)); } else { current_native = command_request(&text, &thread_id).ok().flatten().is_some(); if !steer_as_new_turn( - &client, turn_params(&text), &mut router, &event_tx, + &client, turn_params(&live, &text), &mut router, &event_tx, &mut assistant_message_id, &mut done_current, ).await { break 'main; } } @@ -1549,7 +1823,7 @@ async fn run_session(session: Session) { } else { current_native = command_request(&text, &thread_id).ok().flatten().is_some(); if !steer_as_new_turn( - &client, turn_params(&text), &mut router, &event_tx, + &client, turn_params(&live, &text), &mut router, &event_tx, &mut assistant_message_id, &mut done_current, ).await { break 'main; } } @@ -1565,9 +1839,44 @@ async fn run_session(session: Session) { } }, + // End the in-flight turn, not the app-server: its threads (child + // agents included) keep running. Steers still waiting for this + // turn's end go with it — a stopped turn never continues. + _ = turn.stop_requested(), if !interrupted => { + queued_steers.clear(); + match router.active.clone() { + Some(active) if !done_current => { + stopping = true; + if router.announced { + send_turn_interrupt(&client, &thread_id, active); + } else { + stop_when_announced = true; + } + } + // Between turns: nothing to interrupt. Settle the stop so + // the host sees the turn over. + _ => { + done_current = true; + if !send(&event_tx, AgentEvent::Done { + status: DoneStatus::Interrupted, + result: None, + error: None, + session_id: Some(thread_id.clone()), + }).await { + break 'main; + } + } + } + }, + _ = interrupt.cancelled(), if !interrupt_sent => { interrupt_sent = true; interrupted = true; + // The runtime is going: so is everything it started. + #[cfg(unix)] + if let Some(pid) = child.id() { + torn_down = crate::process::descendants(pid).await; + } if let Some(turn) = router.active.clone() { let client = client.clone(); let thread = thread_id.clone(); @@ -1599,6 +1908,17 @@ async fn run_session(session: Session) { } }, + _ = tokio::time::sleep_until( + prune_at.as_ref().map_or_else(tokio::time::Instant::now, |(at, _)| *at) + ), if prune_at.is_some() => { + let (_, stopped) = prune_at.take().expect("guarded by if"); + open_commands.retain(|_, (pid, started_in)| { + started_in.as_deref() != Some(stopped.as_str()) + || pid.as_deref().is_some_and(process_alive) + }); + turn.set_background(open_commands.len()); + }, + _ = event_tx.closed() => break 'main, } } @@ -1635,12 +1955,50 @@ async fn run_session(session: Session) { } } - shutdown_child(&mut child, kill_grace).await; + crate::shutdown_agent(&mut child, torn_down, kill_grace).await; if let Some(handle) = escalation { handle.abort(); } } +/// How long an interrupted turn's commands get to exit before a process +/// still running counts as background work. +const COMMAND_EXIT_GRACE: Duration = Duration::from_secs(3); + +/// Whether a process the app-server reported is still running. +fn process_alive(pid: &str) -> bool { + #[cfg(unix)] + { + // SAFETY: signal 0 only probes for existence; nothing is delivered. + pid.parse::() + .is_ok_and(|pid| pid > 0 && unsafe { libc::kill(pid, 0) } == 0) + } + #[cfg(not(unix))] + { + // Unknown: count it gone, which leaves the idle reaper as it was. + let _ = pid; + false + } +} + +/// Ask the app-server to end `turn` (fire-and-forget: its end arrives as the +/// turn's own completion notification). +fn send_turn_interrupt(client: &RpcClient, thread_id: &str, turn: String) { + let client = client.clone(); + let thread = thread_id.to_owned(); + tokio::spawn(async move { + if let Err(e) = client + .request( + "turn/interrupt", + json!({ "threadId": thread, "turnId": turn }), + ) + .await + { + tracing::warn!(target: "zeron_harness::codex", "turn/interrupt failed: {e}"); + } + }); +} + /// Deliver a steer as a fresh `turn/start` on the same thread (the fallback /// leg of the steer race, and the between-turns delivery path). Returns false /// when the loop should end (turn/start failed or the consumer hung up). diff --git a/crates/harness/src/codex/normalize.rs b/crates/harness/src/codex/normalize.rs index 9fa31aa9a6..d5ddd10ae1 100644 --- a/crates/harness/src/codex/normalize.rs +++ b/crates/harness/src/codex/normalize.rs @@ -6,7 +6,7 @@ //! types) are accepted, and unknown item types map to nothing. use serde_json::Value; -use zeron_proto::{AgentEvent, DoneStatus, TodoItem, ToolCall}; +use zeron_proto::{AgentEvent, DoneStatus, TodoItem, TodoStatus, ToolCall}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum Phase { @@ -201,6 +201,30 @@ fn file_change_call(changes: &[(String, String)]) -> ToolCall { } } +/// `turn/plan/updated` → the live checklist chip. Steps carry +/// `pending | inProgress | completed`; like ACP plans the update has no item +/// id, so it refreshes the singleton [`zeron_proto::LIVE_PLAN_TOOL_ID`] in place. +pub(crate) fn plan_update_events(params: &Value) -> Vec { + let Some(plan) = params.get("plan").and_then(Value::as_array) else { + return Vec::new(); + }; + let items = plan + .iter() + .map(|step| { + TodoItem::new( + str_field(step, &["step"]), + TodoStatus::parse(&str_field(step, &["status"])), + ) + }) + .collect(); + tool_lifecycle( + Phase::Completed, + zeron_proto::LIVE_PLAN_TOOL_ID.into(), + ToolCall::Todo { items }, + false, + ) +} + pub(crate) fn item_type(item: &Value) -> &str { item.get("type").and_then(Value::as_str).unwrap_or("") } @@ -378,9 +402,17 @@ pub(crate) fn map_item(phase: Phase, item: &Value) -> Vec { .map(|a| a.as_slice()) .unwrap_or_default() .iter() - .map(|t| TodoItem { - text: str_field(t, &["text"]), - done: field(t, &["completed", "done"]).and_then(Value::as_bool) == Some(true), + .map(|t| { + let done = + field(t, &["completed", "done"]).and_then(Value::as_bool) == Some(true); + TodoItem::new( + str_field(t, &["text"]), + if done { + TodoStatus::Completed + } else { + TodoStatus::Pending + }, + ) }) .collect(); tool_lifecycle(phase, id, ToolCall::Todo { items }, false) @@ -841,6 +873,40 @@ mod tests { ); } + #[test] + fn plan_update_maps_to_the_live_checklist_with_in_progress() { + let events = plan_update_events(&json!({ + "threadId": "t", "turnId": "u", "explanation": null, + "plan": [ + { "step": "read", "status": "completed" }, + { "step": "fix", "status": "inProgress" }, + { "step": "test", "status": "pending" }, + ], + })); + assert_eq!( + events, + vec![ + AgentEvent::ToolCall { + id: zeron_proto::LIVE_PLAN_TOOL_ID.into(), + call: ToolCall::Todo { + items: vec![ + TodoItem::new("read", TodoStatus::Completed), + TodoItem::new("fix", TodoStatus::InProgress), + TodoItem::new("test", TodoStatus::Pending), + ] + }, + }, + AgentEvent::ToolResult { + id: zeron_proto::LIVE_PLAN_TOOL_ID.into(), + is_error: false, + output: None, + diff: None, + }, + ] + ); + assert!(plan_update_events(&json!({ "threadId": "t" })).is_empty()); + } + #[test] fn usage_reads_last_snapshot_under_both_spellings() { assert_eq!( diff --git a/crates/harness/src/codex/realtime.rs b/crates/harness/src/codex/realtime.rs new file mode 100644 index 0000000000..39e1f00d91 --- /dev/null +++ b/crates/harness/src/codex/realtime.rs @@ -0,0 +1,632 @@ +//! Ephemeral native subscription voice in the same app-server process as text/MCP. +//! WebSocket appendAudio is deliberately unavailable: in Codex 0.159 it needs an API key. +#[path = "realtime_host.rs"] +mod host; +use crate::jsonrpc::{Incoming, RpcClient}; +use base64::{Engine as _, engine::general_purpose::STANDARD}; +use serde_json::{Value, json}; +use std::sync::{Arc, Mutex}; +use std::{path::PathBuf, sync::atomic::Ordering, time::Duration}; +use tokio::sync::{broadcast, mpsc, oneshot}; +use tokio_util::sync::CancellationToken; +type AudioAbort = Arc>>; +use zeron_proto::voice::*; + +/// Every Zeron voice session is a projectless orchestrator. Given to both the +/// realtime voice model and the backing Codex model that runs its delegations. +pub const ORCHESTRATOR_INSTRUCTIONS: &str = "This is a Zeron voice session and you are the user's orchestrator. Use the Zeron MCP tools to do the work: list and read chats, create new chats with the right agent and project, send them messages, check on their progress, and report back. Delegate coding and file changes to those chats instead of doing them yourself in this session. Keep spoken replies short and conversational."; + +#[derive(Clone)] +pub(super) struct ThreadContext { + pub cwd: String, + pub model_provider: Option, +} + +pub enum VoiceCommand { + Probe { + external: bool, + reply: oneshot::Sender>, + }, + Start { + voice: Option, + session_id: String, + generation: u64, + reply: oneshot::Sender>, + }, + External { + voice: Option, + session_id: String, + generation: u64, + offer: remote::Sdp, + reply: oneshot::Sender>, + }, + Mute { + muted: bool, + reply: oneshot::Sender>, + }, + Append { + frame: VoiceFrame, + reply: oneshot::Sender>, + }, + Stop { + reply: oneshot::Sender>, + }, +} +pub struct RealtimeControls { + audio_abort: AudioAbort, + invalidated: Arc, + pub commands: mpsc::Receiver, + pub events: broadcast::Sender, +} +#[derive(Clone)] +pub struct RealtimeHandle { + audio_abort: AudioAbort, + invalidated: Arc, + pub commands: mpsc::Sender, + pub events: broadcast::Sender, +} +impl RealtimeHandle { + pub fn invalidated(&self) -> bool { + self.invalidated.load(Ordering::Acquire) + } + /// Immediately closes native capture even if the control actor is waiting on I/O. + pub fn abort_voice(&self, generation: u64) { + if let Some((current, cancel)) = self.audio_abort.lock().unwrap().as_ref() { + if *current == generation { + cancel.cancel(); + } + } + } + + pub async fn probe(&self) -> Result { + self.probe_mode(false).await + } + pub async fn probe_external(&self) -> Result { + self.probe_mode(true).await + } + async fn probe_mode(&self, external: bool) -> Result { + let (reply, rx) = oneshot::channel(); + tokio::time::timeout(Duration::from_secs(15), async { + self.commands + .send(VoiceCommand::Probe { external, reply }) + .await + .map_err(|_| VoiceRejection::Protocol)?; + rx.await.map_err(|_| VoiceRejection::Protocol)? + }) + .await + .map_err(|_| VoiceRejection::Protocol)? + } + + pub async fn stop(&self) -> Result<(), VoiceRejection> { + let (reply, rx) = oneshot::channel(); + tokio::time::timeout(Duration::from_secs(8), async { + self.commands + .send(VoiceCommand::Stop { reply }) + .await + .map_err(|_| VoiceRejection::Protocol)?; + rx.await.map_err(|_| VoiceRejection::Protocol)? + }) + .await + .map_err(|_| VoiceRejection::Protocol)? + } + pub async fn mute(&self, muted: bool) -> Result<(), VoiceRejection> { + let (reply, rx) = oneshot::channel(); + tokio::time::timeout(Duration::from_secs(8), async { + self.commands + .send(VoiceCommand::Mute { muted, reply }) + .await + .map_err(|_| VoiceRejection::Protocol)?; + rx.await.map_err(|_| VoiceRejection::Protocol)? + }) + .await + .map_err(|_| VoiceRejection::Protocol)? + } +} +pub fn channel() -> ( + RealtimeHandle, + RealtimeControls, + broadcast::Receiver, +) { + let audio_abort = Arc::default(); + let invalidated = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let (commands, rx) = mpsc::channel(8); + let (events, receiver) = broadcast::channel(32); + ( + RealtimeHandle { + audio_abort: Arc::clone(&audio_abort), + invalidated: invalidated.clone(), + commands, + events: events.clone(), + }, + RealtimeControls { + audio_abort, + invalidated, + commands: rx, + events, + }, + receiver, + ) +} +pub(crate) struct BridgeTask(tokio::task::JoinHandle<()>); +impl Drop for BridgeTask { + fn drop(&mut self) { + self.0.abort(); + } +} + +/// Stops routing realtime notifications once the bridge ends, so a text-only +/// runtime goes back to plain stdout handling. +struct VoiceSubscription { + client: RpcClient, + overflow: Arc, +} +impl Drop for VoiceSubscription { + fn drop(&mut self) { + self.client.unsubscribe_voice(&self.overflow); + } +} + +pub(super) fn attach( + client: RpcClient, + thread: String, + executable: PathBuf, + context: ThreadContext, + controls: RealtimeControls, +) -> BridgeTask { + let abort = controls.audio_abort.clone(); + let invalidated = controls.invalidated.clone(); + let (mut wire, overflow) = client.subscribe_voice(move || { + invalidated.store(true, Ordering::Release); + if let Some((_, cancel)) = abort.lock().unwrap().as_ref() { + cancel.cancel(); + } + }); + // Moved into the task so it unsubscribes even when aborted before polling. + let subscription = VoiceSubscription { + client: client.clone(), + overflow: overflow.clone(), + }; + BridgeTask(tokio::spawn(async move { + let _subscription = subscription; + let RealtimeControls { + audio_abort, + invalidated, + mut commands, + events, + } = controls; + let mut native: Option = None; + let mut external_active = false; + let mut external_reply: Option>> = None; + let mut starting: Option< + futures::future::BoxFuture<'static, Result>, + > = None; + let mut start_reply: Option>> = None; + let mut answer_tx: Option> = None; + let mut accepted_tx: Option> = None; + let mut expected_session = String::new(); + let server_live = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let mut generation = 0; + let mut sequence = 0; + let mut timer = tokio::time::interval(Duration::from_millis(100)); + timer.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + loop { + tokio::select! { biased; + command=commands.recv()=>match command { + Some(VoiceCommand::Probe{external,reply})=>{let result=if invalidated.load(Ordering::Acquire){Err(VoiceRejection::InvalidLease)}else{probe_mode(&client,&executable,&context,external).await};let _=reply.send(if invalidated.load(Ordering::Acquire){Err(VoiceRejection::InvalidLease)}else{result});}, + Some(VoiceCommand::Start{voice,session_id,generation:next,reply})=>{ + if invalidated.load(Ordering::Acquire){let _=reply.send(Err(VoiceRejection::InvalidLease));continue;} + if external_active || native.is_some() || starting.is_some() {let _=reply.send(Err(VoiceRejection::Busy));continue;} + generation=next;sequence=0; + let abort=CancellationToken::new(); + {let mut slot=audio_abort.lock().unwrap(); + if invalidated.load(Ordering::Acquire){let _=reply.send(Err(VoiceRejection::InvalidLease));continue;} + *slot=Some((next,abort.clone())); + } + let (answer,answer_rx)=oneshot::channel();let (accepted,accepted_rx)=oneshot::channel(); + answer_tx=Some(answer);accepted_tx=Some(accepted);expected_session=session_id.clone(); + starting=Some(Box::pin({let client=client.clone();let thread=thread.clone();let executable=executable.clone();let context=context.clone();let server_live=server_live.clone();async move { + start(client,thread,executable,context,voice,session_id,accepted_rx,answer_rx,abort,server_live).await.map(Started::Local) + }})); + start_reply=Some(reply); + }, + Some(VoiceCommand::External{voice,session_id,generation:next,offer,reply})=>{ + if invalidated.load(Ordering::Acquire){let _=reply.send(Err(VoiceRejection::InvalidLease));continue;} + if external_active || native.is_some() || starting.is_some(){let _=reply.send(Err(VoiceRejection::Busy));continue;} + generation=next;sequence=0; + let abort=CancellationToken::new(); + *audio_abort.lock().unwrap()=Some((next,abort.clone())); + let (answer,answer_rx)=oneshot::channel();let (accepted,accepted_rx)=oneshot::channel(); + answer_tx=Some(answer);accepted_tx=Some(accepted);expected_session=session_id.clone(); + let client=client.clone();let thread=thread.clone();let context=context.clone();let server_live=server_live.clone(); + starting=Some(Box::pin(async move { + tokio::select!{biased; + _=abort.cancelled()=>Err(VoiceRejection::InvalidLease), + answer=start_external(client,thread,context,voice,session_id,offer,accepted_rx,answer_rx,server_live)=>answer.map(Started::External), + } + })); + external_reply=Some(reply); + }, + Some(VoiceCommand::Mute{muted,reply})=>{ + let result=if let Some(host)=native.as_mut(){host.controls(muted).await}else{Err(VoiceRejection::Busy)}; + let failed=result.is_err();let _=reply.send(result); + if failed {native=None;let _=events.send(VoiceEvent::Closed{generation,reason:Some(VoiceRejection::Protocol)});} + }, + Some(VoiceCommand::Append{reply,..})=>{let _=reply.send(Err(VoiceRejection::Unsupported));}, + Some(VoiceCommand::Stop{reply})=>{ + let had_session=server_live.load(Ordering::Acquire);starting=None;native=None;external_active=false; + if let Some(reply)=external_reply.take(){let _=reply.send(Err(VoiceRejection::InvalidLease));} + if let Some(pending)=start_reply.take(){let _=pending.send(Err(VoiceRejection::InvalidLease));} + let result=if had_session{ + let result=stop(&client,&thread).await; + // Native stop acknowledges submission first. Drain its terminal event + // before a successor can relabel old notifications with a new generation. + let closed=tokio::time::timeout(Duration::from_secs(3),async { + while let Some(notification)=wire.recv().await { + if let Incoming::Notification{method,params}=notification { + if params["threadId"]!=thread{continue;} + if method=="thread/realtime/closed" && params["reason"]=="requested"{server_live.store(false,Ordering::Release);return Ok::<_,VoiceRejection>(());} + if let Some(event)=normalize(&thread,generation,&mut sequence,&method,¶ms){let _=events.send(event);} + } + } + Err(VoiceRejection::Protocol) + }).await; + if result.is_err()||!matches!(closed,Ok(Ok(()))){let _=reply.send(Err(VoiceRejection::Protocol));break;} + result + }else{Ok(())}; + let _=events.send(VoiceEvent::Closed{generation,reason:None});let _=reply.send(result); + }, + None=>break, + }, + result=async{starting.as_mut().unwrap().await},if starting.is_some()=>{ + starting=None; + match result { + Ok(Started::Local(host))=>{native=Some(host);if let Some(reply)=start_reply.take(){let _=reply.send(Ok(()));}}, + Ok(Started::External(answer))=>{external_active=true;if let Some(reply)=external_reply.take(){let _=reply.send(Ok(answer));}}, + Err(reason)=>{ + if let Some(reply)=external_reply.take(){let _=reply.send(Err(reason));} + if let Some(reply)=start_reply.take(){let _=reply.send(Err(reason));} + let _=events.send(VoiceEvent::Closed{generation,reason:Some(reason)}); + }} + }, + notification=wire.recv()=>match notification { + Some(Incoming::Notification{method,params})=>{ + if method=="thread/realtime/closed" && params["threadId"]==thread {server_live.store(false,Ordering::Release);} + if method=="account/updated" {native=None;starting=None;external_active=false;if let Some(reply)=external_reply.take(){let _=reply.send(Err(VoiceRejection::ChatgptRequired));}if let Some(reply)=start_reply.take(){let _=reply.send(Err(VoiceRejection::ChatgptRequired));}let _=events.send(VoiceEvent::Closed{generation,reason:Some(VoiceRejection::ChatgptRequired)});continue;} + if params["threadId"]==thread && starting.is_some() { + if method=="thread/realtime/started" { + if params["version"]=="v3" && params["realtimeSessionId"]==expected_session {if let Some(tx)=accepted_tx.take(){let _=tx.send(());}} + else {starting=None;if let Some(reply)=external_reply.take(){let _=reply.send(Err(VoiceRejection::Unsupported));}if let Some(reply)=start_reply.take(){let _=reply.send(Err(VoiceRejection::Unsupported));}let _=events.send(VoiceEvent::Closed{generation,reason:Some(VoiceRejection::Unsupported)});} + continue; + } + if method=="thread/realtime/sdp" { + if let Some(sdp)=params["sdp"].as_str().filter(|s|!s.is_empty()&&s.len()<=64*1024) {if let Some(tx)=answer_tx.take(){let _=tx.send(sdp.to_owned());}} + continue; + } + } + if external_active || native.is_some() || starting.is_some() { + if let Some(event)=normalize(&thread,generation,&mut sequence,&method,¶ms) { + let terminal=matches!(event,VoiceEvent::Closed{..}); let _=events.send(event); + if terminal {native=None;starting=None;external_active=false;if let Some(reply)=external_reply.take(){let _=reply.send(Err(VoiceRejection::Protocol));}if let Some(reply)=start_reply.take(){let _=reply.send(Err(VoiceRejection::Protocol));}} + } + } + },Some(_)=>{},None=>break, + }, + _=timer.tick()=>{ + if overflow.load(Ordering::Acquire)||client.is_closed(){break;} + if let Some(host)=native.as_mut(){ match host.levels().await { + Ok((microphone,speaker))=>{let _=events.send(VoiceEvent::Levels{generation,microphone,speaker});}, + Err(reason)=>{native=None;let _=events.send(VoiceEvent::Closed{generation,reason:Some(reason)});} + }} + } + } + } + let had_session = starting.take().is_some() + || native.is_some() + || external_active + || server_live.load(Ordering::Acquire); + drop(native); + if had_session { + let _ = stop(&client, &thread).await; + } + let _ = events.send(VoiceEvent::Closed { + generation, + reason: Some(VoiceRejection::Protocol), + }); + })) +} +enum Started { + Local(host::NativeHost), + External(remote::Sdp), +} + +async fn probe( + client: &RpcClient, + executable: &std::path::Path, + context: &ThreadContext, +) -> Result { + probe_mode(client, executable, context, false).await +} +async fn probe_mode( + client: &RpcClient, + executable: &std::path::Path, + context: &ThreadContext, + external: bool, +) -> Result { + if !external { + host::helper_path(executable)?; + } + tokio::time::timeout(Duration::from_secs(15), probe_details(client, context)) + .await + .map_err(|_| VoiceRejection::Protocol)? +} +async fn probe_details( + client: &RpcClient, + context: &ThreadContext, +) -> Result { + if context.model_provider.as_deref() != Some("openai") + || !std::path::Path::new(&context.cwd).is_absolute() + { + return Err(VoiceRejection::ChatgptRequired); + } + let account = client + .request("account/read", json!({"refreshToken":false})) + .await + .map_err(|_| VoiceRejection::Protocol)?; + if account["account"]["type"] != "chatgpt" { + return Err(VoiceRejection::ChatgptRequired); + } + let config = client + .request( + "config/read", + json!({"includeLayers":false,"cwd":context.cwd}), + ) + .await + .map_err(|_| VoiceRejection::Protocol)?; + let config = &config["config"]; + if config["model_provider"] + .as_str() + .is_some_and(|p| p != "openai") + || config["model_providers"]["openai"].is_object() + || [ + "experimental_realtime_ws_base_url", + "experimental_realtime_webrtc_call_base_url", + "experimental_realtime_ws_model", + ] + .iter() + .any(|k| !config[*k].is_null()) + { + return Err(VoiceRejection::ChatgptRequired); + } + // No account/rateLimits/read: it is a ~1 s backend round trip, and ordinary + // quota may be exhausted while permitted credits remain. The native backend + // applies spend controls and the final usage decision. + let v = client + .request("thread/realtime/listVoices", json!({})) + .await + .map_err(|_| VoiceRejection::Unsupported)?; + let voices = v["voices"]["v1"] + .as_array() + .ok_or(VoiceRejection::Unsupported)? + .iter() + .filter_map(|v| v.as_str().map(str::to_owned)) + .collect(); + Ok(VoiceEligibility { + available: true, + reason: None, + ordinary_usage_allowed: None, + credits_excluded: false, + format: None, + duplex_verified: true, + native_webrtc: true, + voices, + }) +} +async fn start( + client: RpcClient, + thread: String, + executable: PathBuf, + context: ThreadContext, + voice: Option, + session_id: String, + accepted: oneshot::Receiver<()>, + answer: oneshot::Receiver, + abort: CancellationToken, + server_live: Arc, +) -> Result { + let eligible = probe(&client, &executable, &context).await?; + if voice.as_ref().is_some_and(|v| !eligible.voices.contains(v)) { + return Err(VoiceRejection::Unsupported); + } + let mut host = host::NativeHost::open(&host::helper_path(&executable)?, abort).await?; + let offer = host.exchange(json!({"type":"startTransport"}), 20).await?; + let sdp = offer["sdp"] + .as_str() + .filter(|s| !s.is_empty() && s.len() <= 64 * 1024) + .ok_or(VoiceRejection::Protocol)?; + if offer["type"] != "offer" { + return Err(VoiceRejection::Protocol); + } + let mut params = json!({"threadId":thread,"transport":{"type":"webrtc","sdp":sdp},"version":"v3","outputModality":"audio","realtimeSessionId":session_id,"clientManagedHandoffs":false,"includeStartupContext":true, + "realtimeStartInstructions":ORCHESTRATOR_INSTRUCTIONS, + "initialItems":[{"role":"developer","text":ORCHESTRATOR_INSTRUCTIONS}]}); + if let Some(voice) = voice { + params["voice"] = json!(voice); + } + server_live.store(true, Ordering::Release); + tokio::time::timeout( + Duration::from_secs(30), + client.request("thread/realtime/start", params), + ) + .await + .map_err(|_| VoiceRejection::Protocol)? + .map_err(|_| VoiceRejection::Protocol)?; + tokio::time::timeout(Duration::from_secs(30), accepted) + .await + .map_err(|_| VoiceRejection::Protocol)? + .map_err(|_| VoiceRejection::Protocol)?; + let answer = tokio::time::timeout(Duration::from_secs(30), answer) + .await + .map_err(|_| VoiceRejection::Protocol)? + .map_err(|_| VoiceRejection::Protocol)?; + host.expect( + json!({"type":"applyAnswer","sdp":answer}), + "transportReady", + 20, + ) + .await?; + host.open_devices() + .await + .map_err(|_| VoiceRejection::DeviceUnavailable)?; + // Keep devices muted/suppressed until the engine confirms the owner stream. + Ok(host) +} +async fn start_external( + client: RpcClient, + thread: String, + context: ThreadContext, + voice: Option, + session_id: String, + offer: remote::Sdp, + accepted: oneshot::Receiver<()>, + answer: oneshot::Receiver, + server_live: Arc, +) -> Result { + let eligible = tokio::time::timeout(Duration::from_secs(15), probe_details(&client, &context)) + .await + .map_err(|_| VoiceRejection::Protocol)??; + if voice.as_ref().is_some_and(|v| !eligible.voices.contains(v)) { + return Err(VoiceRejection::Unsupported); + } + let mut params = json!({"threadId":thread,"transport":{"type":"webrtc","sdp":offer.expose()},"version":"v3","outputModality":"audio","realtimeSessionId":session_id,"clientManagedHandoffs":false,"includeStartupContext":true, + "realtimeStartInstructions":ORCHESTRATOR_INSTRUCTIONS,"initialItems":[{"role":"developer","text":ORCHESTRATOR_INSTRUCTIONS}]}); + if let Some(voice) = voice { + params["voice"] = json!(voice); + } + server_live.store(true, Ordering::Release); + tokio::time::timeout(Duration::from_secs(90), async { + client + .request("thread/realtime/start", params) + .await + .map_err(|_| VoiceRejection::Protocol)?; + accepted.await.map_err(|_| VoiceRejection::Protocol)?; + remote::Sdp::new(answer.await.map_err(|_| VoiceRejection::Protocol)?) + }) + .await + .map_err(|_| VoiceRejection::Protocol)? +} + +async fn stop(client: &RpcClient, thread: &str) -> Result<(), VoiceRejection> { + tokio::time::timeout( + Duration::from_secs(2), + client.request("thread/realtime/stop", json!({"threadId":thread})), + ) + .await + .map_err(|_| VoiceRejection::Protocol)? + .map_err(|_| VoiceRejection::Protocol)?; + Ok(()) +} + +/// Canonical completed items only. Legacy transcript/done has no stable item +/// identity and is deliberately not committed alongside the canonical stream. +pub(crate) fn normalize( + thread: &str, + generation: u64, + sequence: &mut u64, + method: &str, + p: &Value, +) -> Option { + if p.get("threadId")?.as_str()? != thread { + return None; + } + match method { + "thread/realtime/item/completed" => { + let item = p.get("item")?; + if item.get("type")?.as_str()? != "transcriptSegment" { + return None; + } + let text = item.get("text")?.as_str()?; + if text.len() > MAX_TRANSCRIPT_BYTES { + return None; + } + let role = match item.get("role")?.as_str()? { + "user" => VoiceRole::User, + "assistant" => VoiceRole::Assistant, + _ => return None, + }; + Some(VoiceEvent::Final { + transcript: VoiceTranscript { + session_id: item.get("realtimeSessionId")?.as_str()?.to_owned(), + item_id: item.get("id")?.as_str()?.to_owned(), + role, + text: text.to_owned(), + promoted_message_id: None, + }, + }) + } + "thread/realtime/outputAudio/delta" => { + let a = p.get("audio")?; + let data = a.get("data")?.as_str()?; + if data.len() > MAX_AUDIO_BYTES.div_ceil(3) * 4 { + return None; + } + let format = VoiceFormat { + encoding: VoiceEncoding::Pcm16Le, + sample_rate: u32::try_from(a.get("sampleRate")?.as_u64()?).ok()?, + channels: u16::try_from(a.get("numChannels")?.as_u64()?).ok()?, + }; + if !format.validate(STANDARD.decode(data).ok()?.len()) { + return None; + } + *sequence += 1; + Some(VoiceEvent::Audio { + frame: VoiceFrame { + generation, + sequence: *sequence, + format, + data: data.to_owned(), + item_id: a.get("itemId").and_then(Value::as_str).map(str::to_owned), + }, + }) + } + "thread/realtime/item/transcript/delta" => { + let text = p.get("delta")?.as_str()?; + (text.len() <= MAX_TRANSCRIPT_BYTES).then(|| VoiceEvent::Partial { + generation, + item_id: p.get("itemId").and_then(Value::as_str).map(str::to_owned), + text: text.to_owned(), + }) + } + "thread/realtime/itemAdded" + if matches!( + p["item"]["type"].as_str(), + Some("input_audio_buffer.speech_started" | "response.cancelled") + ) => + { + Some(VoiceEvent::InvalidatePlayout { + generation, + item_id: p["item"]["item_id"].as_str().unwrap_or_default().to_owned(), + }) + } + "thread/realtime/closed" | "thread/realtime/error" => Some(VoiceEvent::Closed { + generation, + reason: (method.ends_with("error")).then_some(VoiceRejection::Protocol), + }), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn canonical_items_do_not_duplicate_legacy_or_foreign_threads() { + let p = json!({"threadId":"parent", "item":{"type":"transcriptSegment", "id":"item", "realtimeSessionId":"voice", "role":"user", "text":"hello"}}); + let mut seq = 0; + assert!(matches!( + normalize("parent", 1, &mut seq, "thread/realtime/item/completed", &p), + Some(VoiceEvent::Final { .. }) + )); + assert!(normalize("other", 1, &mut seq, "thread/realtime/item/completed", &p).is_none()); + assert!(normalize("parent", 1, &mut seq, "thread/realtime/transcript/done", &p).is_none()); + } +} diff --git a/crates/harness/src/codex/realtime_host.rs b/crates/harness/src/codex/realtime_host.rs new file mode 100644 index 0000000000..fd6443d27b --- /dev/null +++ b/crates/harness/src/codex/realtime_host.rs @@ -0,0 +1,3 @@ +//! Codex package resolver, shared with V2 desktop media: both use the audio +//! helper of the device's standalone Codex installation. +pub(super) use zeron_voice_media::{NativeHost, installed_helper as helper_path}; diff --git a/crates/harness/src/cursor/mod.rs b/crates/harness/src/cursor/mod.rs index 88088908f6..ff363f32aa 100644 --- a/crates/harness/src/cursor/mod.rs +++ b/crates/harness/src/cursor/mod.rs @@ -49,7 +49,7 @@ use tokio::sync::mpsc; use zeron_proto::{ AgentEvent, DoneStatus, HarnessId, Model, ModelOption, ModelOptionChoice, ReasoningLevel, - RunRequest, SteeringMode, TodoItem, ToolCall, + RunRequest, SteeringMode, TodoItem, TodoStatus, ToolCall, }; use crate::process::{Child, ChildStdin, Command, Stdio}; @@ -252,6 +252,16 @@ impl Harness for CursorHarness { fn deterministic_turn_end(&self) -> bool { true } + /// The shim's `stop` cancels the turn's SDK run; the agent (and the + /// shim holding it) take the next prompt. + fn stops_turn_in_place(&self) -> bool { + true + } + /// Each SDK send names its model, so a change runs from the next turn on + /// the same agent; no model is Cursor's `auto`. + fn reconfigures_in_place(&self, live: &RunRequest, next: &RunRequest) -> bool { + live.cwd == next.cwd + } /// Keep a successful catalog during transient outages. A cold failure /// is an error, never a fabricated two-model success. @@ -516,10 +526,12 @@ async fn run_session(session: Session) { stderr_tail, } = session; let RunControls { + realtime: _, execution_lease: _execution_lease, request_input: _request_input, mut steering, interrupt, + turn, } = controls; let mut assistant_message_id = new_message_id(); @@ -527,6 +539,9 @@ async fn run_session(session: Session) { let mut steering_open = true; let mut interrupted = false; let mut interrupt_sent = false; + // A turn stop (`TurnControl::stop_turn`) in flight: the shim cancels the + // run; the agent stays up for the next prompt. + let mut stopping = false; let mut any_done = false; let mut done_after_interrupt = false; // A turn is settled and the session is parked awaiting the next prompt. @@ -557,6 +572,24 @@ async fn run_session(session: Session) { } }, + // Cancel this turn's SDK run, not the agent: the shim answers + // with a cancelled turn and takes the next prompt. Steers still + // undelivered go with the stopped turn. + _ = turn.stop_requested(), if !interrupted => { + if parked { + if !send(AgentEvent::Done { + status: DoneStatus::Interrupted, + result: None, + error: None, + session_id: session_id.clone(), + }).await { break 'main; } + } else { + stopping = true; + pending_steers = 0; + let _ = stdin_tx.send(json!({ "op": "stop" }).to_string()); + } + }, + line = stdout_lines.next_line() => match line { Ok(Some(line)) => { let line = line.trim(); @@ -612,7 +645,7 @@ async fn run_session(session: Session) { error = ?frame.get("error").or_else(|| frame.get("message")), "Cursor SDK run failed"); } - for ev in map_shim_frame(&frame, interrupted) { + for ev in map_shim_frame(&frame, interrupted || stopping) { let is_done = matches!(ev, AgentEvent::Done { .. }); let failed = matches!(ev, AgentEvent::Done { status: DoneStatus::Errored, .. }); // Stamp the session id onto Dones the mapper @@ -627,6 +660,7 @@ async fn run_session(session: Session) { } if is_done { any_done = true; + stopping = false; if interrupted { done_after_interrupt = true; break 'main; @@ -653,7 +687,14 @@ async fn run_session(session: Session) { pending_steers += 1; parked = false; any_done = false; - let _ = stdin_tx.send(json!({ "op": "steer", "prompt": msg.prompt }).to_string()); + let mut line = json!({ "op": "steer", "prompt": msg.prompt }); + // A changed model runs from this message's own turn. + if let Some(next) = msg.config { + line["reconfigure"] = Value::Bool(true); + line["model"] = json!(next.model); + line["modelOptions"] = Value::Object(next.model_options); + } + let _ = stdin_tx.send(line.to_string()); } None => { steering_open = false; @@ -697,6 +738,13 @@ async fn run_session(session: Session) { } } + // The SDK runs tools in the shim's process: snapshot them before it + // goes, so none outlives the runtime (see [`crate::shutdown_agent`]). + #[cfg(unix)] + let tree = match child.id() { + Some(pid) => crate::process::descendants(pid).await, + None => Vec::new(), + }; drop(stdin_tx); // EOF asks the shim to cancel/close the SDK and settle its durable state. // Signals remain the bounded fallback when the SDK cannot shut down. @@ -706,6 +754,8 @@ async fn run_session(session: Session) { ) { shutdown_child(&mut child, kill_grace).await; } + #[cfg(unix)] + crate::process::terminate_tree(&tree, kill_grace).await; if let Some(handle) = escalation { handle.abort(); } @@ -767,15 +817,19 @@ fn decode_tool(name: &str, args: &Value) -> ToolCall { .map(|a| a.as_slice()) .unwrap_or_default() .iter() - .map(|t| TodoItem { - text: t - .get("content") - .or_else(|| t.get("text")) - .and_then(Value::as_str) - .unwrap_or("") - .into(), - done: t.get("status").and_then(Value::as_str) == Some("completed") - || t.get("completed").and_then(Value::as_bool) == Some(true), + .map(|t| { + let status = if t.get("completed").and_then(Value::as_bool) == Some(true) { + TodoStatus::Completed + } else { + TodoStatus::parse(t.get("status").and_then(Value::as_str).unwrap_or("")) + }; + TodoItem::new( + t.get("content") + .or_else(|| t.get("text")) + .and_then(Value::as_str) + .unwrap_or(""), + status, + ) }) .collect(), }, diff --git a/crates/harness/src/cursor/shim.mjs b/crates/harness/src/cursor/shim.mjs index 9b433b8dc8..6e2ca376c1 100644 --- a/crates/harness/src/cursor/shim.mjs +++ b/crates/harness/src/cursor/shim.mjs @@ -13,8 +13,12 @@ // stdin (engine → shim): // {"op":"run","prompt","cwd","model"?,"modelOptions"?,"resume"?} start / first turn // {"op":"user","prompt"} explicit next turn -// {"op":"steer","prompt"} native mid-turn input +// {"op":"steer","prompt","model"?,"modelOptions"?,"reconfigure"?} +// native mid-turn input; +// `reconfigure` = runs as +// its own turn on `model` // {"op":"interrupt"} cancel the live run +// {"op":"stop"} cancel this turn only; the agent stays up // stdout (shim → engine): // {"ev":"ready","agentId","model"?} // {"ev":"steered"} input consumed @@ -330,6 +334,8 @@ if (process.argv[2] === "login") { let agent = null; let run = null; let interrupted = false; +// A turn stop: this turn is cancelled, the agent takes the next prompt. +let stopping = false; let closing = false; let cleanupPromise; beforeExit = () => cleanupPromise ??= (async () => { @@ -445,6 +451,16 @@ function withAuthHint(message) { // Keep ownership until Cursor confirms that the active turn appended the text. // A boundary race returns revert_to_followup; only those messages start a turn. const pendingSteers = []; +// The model the agent runs: set at start, replaced by a reconfiguring +// message. Once replaced, every send names it (a send's model is per-run). +let currentModel = null; +let modelChanged = false; +// Messages up to (not including) the first that changes the model: only +// those may ride the running turn, natively or batched. +function steerableNow() { + const at = pendingSteers.findIndex(message => message.selection); + return at < 0 ? pendingSteers : pendingSteers.slice(0, at); +} const steerDeliveries = new Set(); let steerPump = null; let turnActive = false; @@ -461,7 +477,7 @@ let preemptedRun = null; const NATIVE_STEER_ONLY = process.env.ZERON_CURSOR_NATIVE_STEER_ONLY === "1"; function preemptForSteer() { if (NATIVE_STEER_ONLY) return false; - if (!turnActive || !run || activeTools.size || preempting || interrupted || closing) return false; + if (!turnActive || !run || activeTools.size || preempting || interrupted || stopping || closing) return false; preempting = true; preemptedRun = run; run.cancel().catch(() => {}); @@ -490,7 +506,7 @@ function pumpSteers() { if (typeof target.steer !== "function") { throw new Error("Cursor SDK lacks native steering; update the managed SDK"); } - const message = pendingSteers.find(message => !message.submitted && message.revertedRun !== target); + const message = steerableNow().find(message => !message.submitted && message.revertedRun !== target); if (!message) return; const reverted = await recordNativeSteer(message.prompt); // History lookup can yield while another tool starts. Never let native @@ -522,7 +538,14 @@ function pumpSteers() { } async function followupSteers() { while (pendingSteers.length && !interrupted && !closing) { - const batch = pendingSteers.splice(0); + // A model change starts its own turn: it never joins the messages sent + // before it, and those after it run on its model. + if (pendingSteers[0].selection) { + currentModel = pendingSteers[0].selection; + modelChanged = true; + } + const next = pendingSteers.findIndex((message, i) => i > 0 && message.selection); + const batch = pendingSteers.splice(0, next < 0 ? pendingSteers.length : next); // A later input can be delivered before an earlier one is rejected. // Never replay the delivered input when draining the rejected prefix. const undelivered = batch.filter(message => !message.delivered); @@ -535,6 +558,7 @@ async function followupSteers() { } } function acceptSteer(message) { + if (message.reconfigure) message.selection = modelSelection(message); pendingSteers.push(message); if (turnActive) { if (!preemptForSteer()) void pumpSteers().catch(fatal); @@ -549,13 +573,16 @@ async function runTurn(prompt, ready, accepted) { try { prompt = await preserveInterruptedContext(prompt); ready?.(); - if (interrupted || closing) { + if (interrupted || stopping || closing) { + stopping = false; + turnActive = false; out({ev: "turn", status: "cancelled"}); return; } accepted?.(); let thisRun = null; run = thisRun = await agent.send(prompt, { + ...(modelChanged ? { model: currentModel } : {}), onDelta: ({ update }) => { if (thisRun && thisRun === preemptedRun) return; try { @@ -574,29 +601,39 @@ async function runTurn(prompt, ready, accepted) { // A steer that arrived while send() was creating the run preempts now. if (!pendingSteers.length || !preemptForSteer()) void pumpSteers().catch(fatal); // Interrupt may arrive while send() is still creating the run. - if (interrupted || closing) await run.cancel().catch(() => {}); + if (interrupted || stopping || closing) await run.cancel().catch(() => {}); let result; try { result = await run.wait(); } catch (e) { - if (preempting && !interrupted && !closing) return continueAfterPreempt(); + if (preempting && !interrupted && !stopping && !closing) return continueAfterPreempt(); + const stopped = stopping; + stopping = false; + if (stopped) { + // The agent lives on; the next prompt starts a fresh run. + activeTools.clear(); + run = null; + turnActive = false; + } out({ ev: "turn", - status: interrupted ? "cancelled" : "error", - error: withAuthHint(formatError(e, run?.requestId)), + status: interrupted || stopped ? "cancelled" : "error", + ...(interrupted || stopped ? {} : { error: withAuthHint(formatError(e, run?.requestId)) }), }); return; } - if (preempting && !interrupted && !closing) return continueAfterPreempt(); + if (preempting && !interrupted && !stopping && !closing) return continueAfterPreempt(); activeTools.clear(); await pumpSteers(); await Promise.all(steerDeliveries); run = null; turnActive = false; + const stopped = stopping; + stopping = false; out({ ev: "turn", - status: result?.status ?? "finished", - ...(result?.error?.message ? { error: withAuthHint(formatError(result.error, result.requestId)) } : {}), + status: stopped ? "cancelled" : result?.status ?? "finished", + ...(!stopped && result?.error?.message ? { error: withAuthHint(formatError(result.error, result.requestId)) } : {}), }); if (pendingSteers.length) chain = chain.then(followupSteers).catch(fatal); } @@ -621,6 +658,7 @@ function modelSelection(msg) { async function start(msg) { const model = modelSelection(msg); + currentModel = model; // The SDK loads NO ambient settings unless asked: without settingSources the // user's ~/.cursor/mcp.json and plugins are invisible and the inline zeron // server is the only MCP the agent sees. "project" (and so "all") is left @@ -726,6 +764,16 @@ rl.on("line", (line) => { interrupted = true; if (run) run.cancel().catch(() => {}); break; + case "stop": + // Undelivered steers go with the stopped turn. + pendingSteers.splice(0); + if (!turnActive) { + out({ev: "turn", status: "cancelled"}); + } else { + stopping = true; + if (run) run.cancel().catch(() => {}); + } + break; default: break; } diff --git a/crates/harness/src/executable.rs b/crates/harness/src/executable.rs index 4971f05541..a4c672d8ad 100644 --- a/crates/harness/src/executable.rs +++ b/crates/harness/src/executable.rs @@ -276,7 +276,7 @@ pub fn binary_version(path: &Path) -> Option { version } -fn parse_version(bytes: &[u8]) -> Option { +pub(crate) fn parse_version(bytes: &[u8]) -> Option { String::from_utf8_lossy(bytes) .split_whitespace() .find_map(|word| { diff --git a/crates/harness/src/jsonrpc.rs b/crates/harness/src/jsonrpc.rs index 339e15d2bf..5e160605df 100644 --- a/crates/harness/src/jsonrpc.rs +++ b/crates/harness/src/jsonrpc.rs @@ -44,12 +44,20 @@ type StdoutObserver = Box; type Pending = Arc>>>>; +/// Where realtime voice notifications go instead of the session channel. +struct VoiceRouter { + sender: mpsc::Sender, + overflow: Arc, + abort_media: Box, +} + #[derive(Clone)] pub(crate) struct RpcClient { next_id: Arc, pending: Pending, writer: mpsc::UnboundedSender, closed: Arc, + voice_router: Arc>>, } impl RpcClient { @@ -69,12 +77,14 @@ impl RpcClient { let pending: Pending = Arc::default(); let (incoming_tx, incoming_rx) = mpsc::channel(256); let closed = Arc::new(AtomicBool::new(false)); + let voice_router = Arc::default(); tokio::spawn(read_loop( stdout, Arc::clone(&pending), incoming_tx, closed.clone(), observer, + Arc::clone(&voice_router), )); ( Self { @@ -82,11 +92,41 @@ impl RpcClient { pending, writer: writer_tx, closed, + voice_router, }, incoming_rx, ) } + /// Route `thread/realtime/*` notifications (and `account/updated`) to a + /// voice channel so stdout never waits for playout. Overflow is terminal: + /// the flag is set and `abort_media` runs. + pub fn subscribe_voice( + &self, + abort_media: impl Fn() + Send + Sync + 'static, + ) -> (mpsc::Receiver, Arc) { + let (tx, rx) = mpsc::channel(32); + let overflow = Arc::new(AtomicBool::new(false)); + *self.voice_router.lock().expect("voice router") = Some(VoiceRouter { + sender: tx, + overflow: overflow.clone(), + abort_media: Box::new(abort_media), + }); + (rx, overflow) + } + + /// End the subscription identified by its `overflow` flag; a newer one is + /// left in place. + pub fn unsubscribe_voice(&self, overflow: &Arc) { + let mut router = self.voice_router.lock().expect("voice router"); + if router + .as_ref() + .is_some_and(|r| Arc::ptr_eq(&r.overflow, overflow)) + { + *router = None; + } + } + pub fn is_closed(&self) -> bool { self.closed.load(Ordering::Acquire) } @@ -228,6 +268,7 @@ async fn read_loop( tx: mpsc::Sender, closed: Arc, observer: Option, + voice_router: Arc>>, ) { let mut lines = BufReader::new(stdout).lines(); // A read error ends the loop like EOF: either way the child's stdout is @@ -289,6 +330,8 @@ async fn read_loop( } // Notification. (Some(method), None) => { + let realtime = method.starts_with("thread/realtime/"); + let account_updated = method == "account/updated"; let incoming = Incoming::Notification { method: method.to_owned(), params: msg @@ -296,6 +339,27 @@ async fn read_loop( .map(Value::take) .unwrap_or(Value::Null), }; + if let Some(router) = voice_router.lock().expect("voice router").as_ref() { + if account_updated { + // An account change ends any call; the session still sees it below. + (router.abort_media)(); + if let Incoming::Notification { method, params } = &incoming { + let copy = Incoming::Notification { + method: method.clone(), + params: params.clone(), + }; + if router.sender.try_send(copy).is_err() { + router.overflow.store(true, Ordering::Release); + } + } + } else if realtime { + if router.sender.try_send(incoming).is_err() { + router.overflow.store(true, Ordering::Release); + (router.abort_media)(); + } + continue; + } + } if tx.send(incoming).await.is_err() { return; } @@ -306,6 +370,9 @@ async fn read_loop( // EOF/read error: fail every awaiting request, then signal the loop. closed.store(true, Ordering::Release); pending.lock().expect("pending lock").clear(); + if let Some(router) = voice_router.lock().expect("voice router").as_ref() { + (router.abort_media)(); + } let _ = tx.send(Incoming::Eof).await; } @@ -321,6 +388,7 @@ mod tests { pending: Arc::default(), writer, closed: Arc::new(AtomicBool::new(false)), + voice_router: Arc::default(), }; client.notify("session/cancel", Some(json!({"sessionId": "parent"}))); let frame: Value = serde_json::from_str(&receiver.try_recv().unwrap()).unwrap(); @@ -332,6 +400,23 @@ mod tests { assert!(client.pending.lock().unwrap().is_empty()); } + #[test] + fn stale_voice_unsubscribe_keeps_the_newer_subscription() { + let client = RpcClient { + next_id: Arc::new(AtomicI64::new(0)), + pending: Arc::default(), + writer: mpsc::unbounded_channel().0, + closed: Arc::new(AtomicBool::new(false)), + voice_router: Arc::default(), + }; + let (_old_rx, old) = client.subscribe_voice(|| {}); + let (_new_rx, new) = client.subscribe_voice(|| {}); + client.unsubscribe_voice(&old); + assert!(client.voice_router.lock().unwrap().is_some()); + client.unsubscribe_voice(&new); + assert!(client.voice_router.lock().unwrap().is_none()); + } + #[tokio::test] async fn requests_after_eof_fail_without_entering_pending_map() { let (writer, mut receiver) = mpsc::unbounded_channel(); @@ -340,6 +425,7 @@ mod tests { pending: Arc::default(), writer, closed: Arc::new(AtomicBool::new(true)), + voice_router: Arc::default(), }; let result = tokio::time::timeout( std::time::Duration::from_millis(100), diff --git a/crates/harness/src/lib.rs b/crates/harness/src/lib.rs index acefa20519..02b8b75b44 100644 --- a/crates/harness/src/lib.rs +++ b/crates/harness/src/lib.rs @@ -44,10 +44,88 @@ pub enum HarnessError { pub struct SteerMessage { pub prompt: String, pub message_id: Option, + /// Staged image paths sent with this prompt. Drivers that inline images + /// deliver them with the text; the rest rely on the path refs the prompt + /// text already carries. + pub attachments: Vec, + /// The run configuration this prompt was sent with, when it differs + /// from the live runtime's and the driver adopts it in place + /// ([`Harness::reconfigures_in_place`]): apply it before the prompt. + pub config: Option>, +} + +impl SteerMessage { + pub fn text(prompt: impl Into) -> Self { + Self { + prompt: prompt.into(), + message_id: None, + attachments: Vec::new(), + config: None, + } + } +} + +/// Host ↔ runtime signals that span a persistent runtime's turns. +/// +/// Stopping a turn is not tearing down the runtime: a runtime holds work the +/// user never asked to stop (background subagents, background shells). A +/// driver that returns true from [`Harness::stops_turn_in_place`] answers +/// [`Self::stop_turn`] by ending only the in-flight turn with +/// `Done { status: Interrupted }` and staying alive for the next prompt; the +/// `interrupt` token remains the runtime teardown. +#[derive(Clone, Default)] +pub struct TurnControl { + stop: std::sync::Arc, + background: std::sync::Arc, +} + +impl TurnControl { + /// Host: end the in-flight turn, keeping the runtime and its background work. + pub fn stop_turn(&self) { + self.stop.notify_one(); + } + + /// Driver: resolves once per [`Self::stop_turn`]. + pub async fn stop_requested(&self) { + self.stop.notified().await; + } + + /// Driver: how many background tasks the runtime currently holds. + pub fn set_background(&self, tasks: usize) { + self.background + .store(tasks, std::sync::atomic::Ordering::Release); + } + + /// Host: the runtime reports live background work, so retiring it now + /// would kill work nobody asked to stop. + pub fn background_live(&self) -> bool { + self.background.load(std::sync::atomic::Ordering::Acquire) > 0 + } +} + +/// Run-config values compared as their wire meaning, not their JSON type: +/// clients disagree on spelling (the phone sends `"true"` where the desktop +/// sends `true`), and neither spelling is a different configuration. +pub fn same_model_options( + a: &serde_json::Map, + b: &serde_json::Map, +) -> bool { + fn canonical(value: &serde_json::Value) -> Option { + match value { + serde_json::Value::Null => None, + serde_json::Value::String(s) if s.is_empty() => None, + serde_json::Value::String(s) => Some(s.clone()), + other => Some(other.to_string()), + } + } + let keys: std::collections::BTreeSet<&String> = a.keys().chain(b.keys()).collect(); + keys.into_iter() + .all(|key| a.get(key).and_then(canonical) == b.get(key).and_then(canonical)) } /// Host-side controls handed to a run: input-request bridge + steering mailbox. pub struct RunControls { + pub realtime: Option, /// Shared execution gate held until the harness has shut down and reaped /// its subprocess, including when the host drops the event stream. Each /// detached session task must retain this lease through its cleanup. @@ -63,6 +141,8 @@ pub struct RunControls { /// interrupt, then escalates to SIGTERM/SIGKILL on the child after a grace /// period. The run's stream ends with `Done { status: Interrupted }`. pub interrupt: CancellationToken, + /// Turn-level stop and background-work reporting (see [`TurnControl`]). + pub turn: TurnControl, } /// Catalog provenance stays internal; RPC clients retain the Vec shape. @@ -112,6 +192,33 @@ pub trait Harness: Send + Sync { fn authoritative_prompt_end(&self) -> bool { self.deterministic_turn_end() } + /// Whether this driver honors [`TurnControl::stop_turn`]: it ends only + /// the in-flight turn (`Done { Interrupted }`) and the runtime stays up + /// with its background work. Otherwise stopping a turn tears the runtime + /// down through the `interrupt` token. + fn stops_turn_in_place(&self) -> bool { + false + } + /// Whether a live runtime started for `live` can take `next` as its + /// next prompt. Only a difference that changes how the runtime was + /// launched needs a fresh one — replacing a runtime kills its background + /// work. The default covers what every driver's launch reads (the + /// sandbox level and auto-approve are read only by drivers that + /// override this); drivers narrow it further to their own launch. + /// Whether a live runtime started for `live` adopts `next`'s differing + /// configuration in place, before `next`'s prompt, instead of being + /// replaced (which kills its background work). The prompt then arrives + /// with [`SteerMessage::config`] set. Consulted only when + /// [`Self::same_runtime`] is false. + fn reconfigures_in_place(&self, _live: &RunRequest, _next: &RunRequest) -> bool { + false + } + fn same_runtime(&self, live: &RunRequest, next: &RunRequest) -> bool { + live.model == next.model + && live.reasoning == next.reasoning + && same_model_options(&live.model_options, &next.model_options) + && live.cwd == next.cwd + } async fn models(&self) -> Result, HarnessError>; fn model_context(&self) -> Result, HarnessError> { Ok(None) @@ -159,6 +266,15 @@ pub trait Harness: Send + Sync { )) } + /// Bootstrap without a synthetic user prompt; providers opt in explicitly. + async fn start_idle( + &self, + _request: RunRequest, + _controls: RunControls, + ) -> Result>, HarnessError> { + Err(HarnessError::Protocol("idle runtime unsupported".into())) + } + /// Run one (persistent) session; the stream ends with `AgentEvent::Done`. async fn run( &self, @@ -397,6 +513,35 @@ pub use pi::PiHarness; // Child lifecycle (shared by the codex and ACP harnesses) // --------------------------------------------------------------------------- +/// End an agent runtime together with every process it started. `earlier` +/// is a descendant snapshot taken when the teardown began, in case the +/// agent exits before this one: its orphans can no longer be traced then. +/// Windows' job object already owns the whole tree. +pub(crate) async fn shutdown_agent( + child: &mut process::Child, + earlier: Vec, + kill_grace: std::time::Duration, +) { + #[cfg(unix)] + { + let mut tree = earlier; + if let Some(pid) = child.id() { + for pid in process::descendants(pid).await { + if !tree.contains(&pid) { + tree.push(pid); + } + } + } + shutdown_child(child, kill_grace).await; + process::terminate_tree(&tree, kill_grace).await; + } + #[cfg(not(unix))] + { + let _ = earlier; + shutdown_child(child, kill_grace).await; + } +} + /// Reap the child: Unix sends SIGTERM then SIGKILL after `kill_grace`; /// Windows terminates the owned job after protocol shutdown has finished. pub(crate) async fn shutdown_child(child: &mut process::Child, kill_grace: std::time::Duration) { diff --git a/crates/harness/src/mock.rs b/crates/harness/src/mock.rs index 590154b794..e6d99e276e 100644 --- a/crates/harness/src/mock.rs +++ b/crates/harness/src/mock.rs @@ -505,6 +505,66 @@ impl Harness for MockHarness { }) .into_iter() .flatten(); + // Dev/testing knob: `ZERON_MOCK_TODO=1` appends a checklist the agent + // works through — one `Todo` call rewritten in place per step, the way + // TodoWrite does — so the todo panel's open / fold / finished states + // can be watched live (pace it with `ZERON_MOCK_DELAY_MS`). + let mock_todo = std::env::var("ZERON_MOCK_TODO") + .ok() + .is_some_and(|v| !v.is_empty() && v != "0"); + let todo_events = mock_todo + .then(|| { + const STEPS: [&str; 8] = [ + "Read the fold path in crates/doc", + "Add the status field to TodoItem", + "Map in-progress in every normalizer", + "Write the fold-window view model", + "Render the todo tray above the composer", + "Persist open state per chat", + "Run the unit tests", + "Write the design note", + ]; + let list = |active: usize| zeron_proto::ToolCall::Todo { + items: STEPS + .iter() + .enumerate() + .map(|(ix, text)| { + zeron_proto::TodoItem::new( + *text, + match ix.cmp(&active) { + std::cmp::Ordering::Less => zeron_proto::TodoStatus::Completed, + std::cmp::Ordering::Equal => { + zeron_proto::TodoStatus::InProgress + } + std::cmp::Ordering::Greater => zeron_proto::TodoStatus::Pending, + }, + ) + }) + .collect(), + }; + let mut events = vec![AgentEvent::TextDelta { + text: "\n### Todo check\n\nWorking through the checklist.\n\n".into(), + }]; + // Steps 0..=8: the last write has every item completed. + for active in 0..=STEPS.len() { + events.push(AgentEvent::ToolCall { + id: "mock-todo".into(), + call: list(active), + }); + events.push(AgentEvent::ToolResult { + id: "mock-todo".into(), + is_error: false, + output: None, + diff: None, + }); + events.push(AgentEvent::TextDelta { + text: format!("Update {} written.\n\n", active + 1), + }); + } + events + }) + .into_iter() + .flatten(); let events: Vec> = body .iter() .cycle() @@ -513,6 +573,7 @@ impl Harness for MockHarness { .chain(thinking_events) .chain(code_tool_events) .chain(subagent_events) + .chain(todo_events) .chain(code_event) .chain(table_event) .chain(mend_event) diff --git a/crates/harness/src/opencode/mod.rs b/crates/harness/src/opencode/mod.rs index 252f6da516..c19f450c85 100644 --- a/crates/harness/src/opencode/mod.rs +++ b/crates/harness/src/opencode/mod.rs @@ -61,11 +61,12 @@ use tokio::sync::mpsc; use zeron_proto::{ AgentEvent, DoneStatus, HarnessId, Model, ModelOption, ModelOptionChoice, ReasoningLevel, - RunRequest, SlashCommand, SteeringMode, TodoItem, ToolCall, UserInputAnswer, UserInputQuestion, + RunRequest, SlashCommand, SteeringMode, TodoItem, TodoStatus, ToolCall, UserInputAnswer, + UserInputQuestion, }; use crate::process::{Child, Command, Stdio}; -use crate::{Harness, HarnessError, RunControls, shutdown_child}; +use crate::{Harness, HarnessError, RunControls}; /// opencode loads plugins and MCP config before the server answers; cold /// plugin-heavy starts can take minutes. Shared by chat startup and model @@ -371,6 +372,19 @@ impl Harness for OpencodeHarness { fn deterministic_turn_end(&self) -> bool { true } + /// Aborting the session ends the turn; the server and the session take + /// the next prompt. + fn stops_turn_in_place(&self) -> bool { + true + } + /// The model and effort ride each prompt (1.x) or are set on the + /// session (2.x), so a change applies from the next prompt without a new + /// server. Clearing one back to the default cannot be expressed that way. + fn reconfigures_in_place(&self, live: &RunRequest, next: &RunRequest) -> bool { + live.cwd == next.cwd + && (next.model.is_some() || live.model.is_none()) + && (next.reasoning.is_some() || live.reasoning.is_none()) + } /// Live discovery off `GET /provider` (what the desktop app populates its /// picker from). Account/config changes invalidate the last-good catalog; @@ -605,33 +619,35 @@ impl Server { .arg(port.to_string()) .arg("--hostname") .arg("127.0.0.1") + // 2.x prefers OPENCODE_PASSWORD over the legacy name; a user's + // own value would otherwise lock us out of our server (401). + .env("OPENCODE_PASSWORD", &password) .env("OPENCODE_SERVER_PASSWORD", &password) .env("OPENCODE_CLIENT", "zeron"); if let Some(mcp) = mcp { - let version_exe = exe.to_path_buf(); - let version = tokio::task::spawn_blocking(move || { - crate::executable::binary_version(&version_exe) - }) - .await - .map_err(|e| HarnessError::Protocol(format!("opencode version probe: {e}")))? - .ok_or_else(|| { - HarnessError::Protocol( - "cannot determine opencode version for MCP configuration".into(), - ) - })?; - let protocol = if version.major >= 2 { - Protocol::V2 - } else { - Protocol::V1 - }; - cmd.env( - "OPENCODE_CONFIG_CONTENT", - mcp_config( - std::env::var("OPENCODE_CONFIG_CONTENT").ok().as_deref(), - mcp, - protocol, - )?, - ); + // The config shape differs by generation. If even the cold probe + // can't tell, run without the Zeron MCP server rather than fail. + match opencode_version(exe).await { + Some(version) => { + let protocol = if version.major >= 2 { + Protocol::V2 + } else { + Protocol::V1 + }; + cmd.env( + "OPENCODE_CONFIG_CONTENT", + mcp_config( + std::env::var("OPENCODE_CONFIG_CONTENT").ok().as_deref(), + mcp, + protocol, + )?, + ); + } + None => tracing::warn!( + binary_path = %exe.display(), + "opencode version unknown; starting without the Zeron MCP server" + ), + } } crate::compose_child_path(&mut cmd, exe); if let Some(cwd) = cwd { @@ -842,7 +858,8 @@ impl Server { async fn shutdown(&mut self, kill_grace: Duration) { if let Some(child) = self.child.as_mut() { - shutdown_child(child, kill_grace).await; + // The server and every command it started. + crate::shutdown_agent(child, Vec::new(), kill_grace).await; } } @@ -1480,10 +1497,12 @@ async fn run_session(session: Session) { initial_native_command_selected, } = session; let RunControls { + realtime: _, execution_lease: _execution_lease, request_input, mut steering, interrupt, + turn: turn_control, } = controls; let request_input = Arc::new(request_input); let directory = (!request.cwd.is_empty()).then(|| request.cwd.clone()); @@ -1578,14 +1597,7 @@ async fn run_session(session: Session) { } }; - let model = request - .model - .as_deref() - .and_then(|m| m.split_once('/')) - .map(|(provider, model)| (provider.to_owned(), model.to_owned())); - let variant = model.as_ref().and_then(|(provider, model_id)| { - pick_variant(&providers, provider, model_id, request.reasoning) - }); + let (mut model, mut variant) = model_selection(&providers, &request); let context_windows: HashMap = providers .all .iter() @@ -1725,9 +1737,15 @@ async fn run_session(session: Session) { let mut pending_spawns: VecDeque = VecDeque::new(); // Child sessions created before their spawn chip was seen (id → title). let mut unbound_children: HashMap = HashMap::new(); - let mut queued_steers: VecDeque<(String, bool)> = VecDeque::new(); + let mut queued_steers: VecDeque = VecDeque::new(); let mut steering_open = true; let mut interrupt_requested = false; + // A turn stop (`TurnControl::stop_turn`) in flight: the session is + // aborted like an interrupt, but the server and session stay up. + let mut stopping = false; + // A stop the server never acknowledged: the turn may still be running, + // so the runtime is torn down and the stop reported only once it is gone. + let mut stop_teardown = false; let mut pending_usage: Option = None; let mut done_sent = false; @@ -1748,11 +1766,12 @@ async fn run_session(session: Session) { turn.active = false; if let Some(usage) = pending_usage.take() && !interrupt_requested + && !stopping && !send(&event_tx, usage).await { break $label; } - if interrupt_requested { + if interrupt_requested || stopping { settle_children(&mut children, &event_tx, DoneStatus::Interrupted).await; let _ = send(&event_tx, AgentEvent::Done { status: DoneStatus::Interrupted, @@ -1761,18 +1780,32 @@ async fn run_session(session: Session) { session_id: Some(session_id.clone()), }).await; done_sent = true; - break $label; + if interrupt_requested { + break $label; + } + // A stopped turn: the server and session take the next prompt. + stopping = false; + abort_deadline = None; + continue $label; } - if let Some((first, native_command_selected)) = queued_steers.pop_front() { + if let Some(first) = queued_steers.pop_front() { turn_generation = turn_generation.wrapping_add(1); + let native_command_selected = first.native; + let config = first.config; + let mut attachments = first.attachments; // Plain-text steers waiting together go out as one prompt, // each confirmed by its own Steered boundary; a native - // command always travels alone. - let mut texts = vec![first]; + // command always travels alone, and a changed configuration + // starts a new prompt (the ones after it were sent with it). + let mut texts = vec![first.text]; while !native_command_selected - && queued_steers.front().is_some_and(|(_, native)| !native) + && queued_steers + .front() + .is_some_and(|next| !next.native && next.config.is_none()) { - texts.push(queued_steers.pop_front().expect("front checked").0); + let next = queued_steers.pop_front().expect("front checked"); + texts.push(next.text); + attachments.extend(next.attachments); } let mut consumer_gone = false; for _ in &texts { @@ -1789,24 +1822,33 @@ async fn run_session(session: Session) { break $label; } let steer = texts.join("\n\n"); - match post_prompt( - &server, - &bus_tx, - &session_id, - dir, - &commands, - &steer, - native_command_selected, - turn_generation, - &command_failure_tx, - TurnSpec { - model: model.as_ref(), - variant: variant.as_deref(), - attachments: &[], - }, - ) - .await - { + let adopted = match config { + Some(next) => adopt_config(&server, &session_id, dir, &next) + .await + .map(|selection| (model, variant) = selection), + None => Ok(()), + }; + let sent = match adopted { + Ok(()) => post_prompt( + &server, + &bus_tx, + &session_id, + dir, + &commands, + &steer, + native_command_selected, + turn_generation, + &command_failure_tx, + TurnSpec { + model: model.as_ref(), + variant: variant.as_deref(), + attachments: &attachments, + }, + ) + .await, + Err(e) => Err(e), + }; + match sent { Ok(()) => { turn = TurnState::begin(stall); continue $label; @@ -1858,6 +1900,7 @@ async fn run_session(session: Session) { if turn.active && !turn.preempted && !interrupt_requested + && !stopping && !queued_steers.is_empty() && turn.open_tools.is_empty() { @@ -1901,6 +1944,44 @@ async fn run_session(session: Session) { _ = event_tx.closed() => break 'main, + // End the turn, not the server: abort the session's generation + // and keep both up for the next prompt. Steers still waiting for + // this turn's end go with it. + _ = turn_control.stop_requested(), if !interrupt_requested => { + queued_steers.clear(); + if turn.active && !stopping { + stopping = true; + turn.idle_ready = true; + let abort = tokio::time::timeout( + Duration::from_secs(5), + server.abort_session(&session_id, dir), + ) + .await; + if matches!(abort, Ok(Ok(_))) { + abort_deadline = Some(tokio::time::Instant::now() + interrupt_grace); + } else { + // The turn may still be running: this session must + // never take a prompt on top of it. + tracing::warn!( + target: "zeron_harness::opencode", + "turn stop abort failed; tearing the runtime down" + ); + stop_teardown = true; + break 'main; + } + } else if !turn.active { + // Between turns: nothing to abort. Settle the stop so the + // host sees the turn over. + let _ = send(&event_tx, AgentEvent::Done { + status: DoneStatus::Interrupted, + result: None, + error: None, + session_id: Some(session_id.clone()), + }).await; + done_sent = true; + } + } + _ = interrupt.cancelled(), if !interrupt_requested => { interrupt_requested = true; if turn.active { @@ -1941,7 +2022,7 @@ async fn run_session(session: Session) { failure = command_failure_rx.recv() => { let Some(failure) = failure else { continue 'main; }; - if failure.generation != turn_generation || !turn.active || interrupt_requested { + if failure.generation != turn_generation || !turn.active || interrupt_requested || stopping { tracing::debug!( target: "zeron_harness::opencode", failed_generation = failure.generation, @@ -1981,35 +2062,49 @@ async fn run_session(session: Session) { HarnessId::Opencode, ); if turn.active { - queued_steers.push_back((prompt, native_command_selected)); + queued_steers.push_back(QueuedSteer { + text: prompt, + native: native_command_selected, + attachments: steer.attachments, + config: steer.config, + }); maybe_preempt!(); } else { turn_generation = turn_generation.wrapping_add(1); - // Between turns (shouldn't happen — the engine - // steers live runs — but deliver, don't drop). + // Between turns: the engine routes the next message + // of a parked session here — images included. let (prev, next) = rotate(&mut assistant_message_id); let _ = send(&event_tx, AgentEvent::Steered { assistant_message_id: Some(prev), next_assistant_message_id: Some(next), }).await; - match post_prompt( - &server, - &bus_tx, - &session_id, - dir, - &commands, - &prompt, - native_command_selected, - turn_generation, - &command_failure_tx, - TurnSpec { - model: model.as_ref(), - variant: variant.as_deref(), - attachments: &[], - }, - ) - .await - { + let adopted = match &steer.config { + Some(next) => adopt_config(&server, &session_id, dir, next) + .await + .map(|selection| (model, variant) = selection), + None => Ok(()), + }; + let sent = match adopted { + Ok(()) => post_prompt( + &server, + &bus_tx, + &session_id, + dir, + &commands, + &prompt, + native_command_selected, + turn_generation, + &command_failure_tx, + TurnSpec { + model: model.as_ref(), + variant: variant.as_deref(), + attachments: &steer.attachments, + }, + ) + .await, + Err(e) => Err(e), + }; + match sent { Ok(()) => turn = TurnState::begin(stall), Err(error) => { let message = error.to_string(); @@ -2044,6 +2139,16 @@ async fn run_session(session: Session) { } _ = stall_sleep => { + if abort_deadline.is_some() && stopping { + // The stop's abort acknowledged nothing within the grace: + // the turn may still be running. Tear the runtime down. + tracing::warn!( + target: "zeron_harness::opencode", + "turn stop unacknowledged; tearing the runtime down" + ); + stop_teardown = true; + break 'main; + } if abort_deadline.is_some() { // Abort acknowledged nothing within the grace: hard stop. settle_children(&mut children, &event_tx, DoneStatus::Interrupted).await; @@ -2087,7 +2192,7 @@ async fn run_session(session: Session) { msg = bus_rx.recv() => { let Some(msg) = msg else { break 'main }; match msg { - BusMsg::CommandFailed(_) if interrupt_requested => {} + BusMsg::CommandFailed(_) if interrupt_requested || stopping => {} BusMsg::CommandFailed(message) => { let _ = send(&event_tx, AgentEvent::Done { status: DoneStatus::Errored, result: None, @@ -2139,13 +2244,18 @@ async fn run_session(session: Session) { break 'main; } BusMsg::Event(event) => { - if interrupt_requested { + if interrupt_requested || stopping { // Only the terminal idle/interrupt acknowledgement may // affect an aborted turn; discard late content and usage. - let kind = event.get("type").and_then(Value::as_str); - let ours = event.pointer("/properties/sessionID").and_then(Value::as_str) == Some(session_id.as_str()); + // v1's /global/event wraps it ({payload}), like + // `handle_bus_event` reads: matching the envelope + // missed every acknowledgement, and each stop + // waited out the grace. + let payload = event.get("payload").unwrap_or(&event); + let kind = payload.get("type").and_then(Value::as_str); + let ours = payload.pointer("/properties/sessionID").and_then(Value::as_str) == Some(session_id.as_str()); let idle = kind == Some("session.idle") || kind == Some("session.interrupted") - || (kind == Some("session.status") && event.pointer("/properties/status/type").and_then(Value::as_str) == Some("idle")); + || (kind == Some("session.status") && payload.pointer("/properties/status/type").and_then(Value::as_str) == Some("idle")); if ours && idle { settle_idle!('main); } continue; } @@ -2170,7 +2280,7 @@ async fn run_session(session: Session) { BusOutcome::TurnIdle => settle_idle!('main), // Our own steer preempt: a steer boundary. BusOutcome::TurnInterrupted - if turn.preempted && !interrupt_requested => + if turn.preempted && !interrupt_requested && !stopping => { settle_idle!('main) } @@ -2185,6 +2295,25 @@ async fn run_session(session: Session) { } } + if stop_teardown { + // Close the mailbox before the stop reads settled: the next prompt + // must start a fresh runtime, never land in this one. + drop(steering); + bus_handle.abort(); + server.shutdown(kill_grace).await; + settle_children(&mut children, &event_tx, DoneStatus::Interrupted).await; + let _ = send( + &event_tx, + AgentEvent::Done { + status: DoneStatus::Interrupted, + result: None, + error: None, + session_id: Some(session_id.clone()), + }, + ) + .await; + return; + } if !done_sent { // Consumer went away (stream dropped): nothing to report to. tracing::debug!(target: "zeron_harness::opencode", "run loop ended without settling"); @@ -2287,6 +2416,53 @@ fn context_usage_event(info: &Value, context_windows: &HashMap) -> } /// The requested effort as a variant id the model actually advertises. +/// A message waiting for the turn's end. +struct QueuedSteer { + text: String, + /// It selects a native command (which always travels alone). + native: bool, + attachments: Vec, + /// The configuration it was sent with, adopted before its prompt. + config: Option>, +} + +/// The `provider/model` pair and reasoning variant a request runs with. +fn model_selection( + providers: &ProviderCatalog, + request: &RunRequest, +) -> (Option<(String, String)>, Option) { + let model = request + .model + .as_deref() + .and_then(|m| m.split_once('/')) + .map(|(provider, model)| (provider.to_owned(), model.to_owned())); + let variant = model.as_ref().and_then(|(provider, model_id)| { + pick_variant(providers, provider, model_id, request.reasoning) + }); + (model, variant) +} + +/// Adopt `next`'s model and effort for the prompts that follow: 1.x carries +/// them on each prompt, 2.x sets them on the session first. The catalog is +/// fetched again here rather than held for the session's life. +async fn adopt_config( + server: &Server, + session_id: &str, + dir: Option<&str>, + next: &RunRequest, +) -> Result<(Option<(String, String)>, Option), HarnessError> { + let providers = server.provider_catalog(dir).await.unwrap_or_default(); + let (model, variant) = model_selection(&providers, next); + if server.protocol().await == Protocol::V2 + && let Some((provider, model_id)) = &model + { + server + .set_model(session_id, provider, model_id, variant.as_deref(), dir) + .await?; + } + Ok((model, variant)) +} + fn pick_variant( providers: &ProviderCatalog, provider_id: &str, @@ -3615,13 +3791,13 @@ fn oc_tool_call(name: &str, input: &Value) -> ToolCall { .map(|a| a.as_slice()) .unwrap_or_default() .iter() - .map(|t| TodoItem { - text: t - .get("content") - .and_then(Value::as_str) - .unwrap_or_default() - .to_owned(), - done: t.get("status").and_then(Value::as_str) == Some("completed"), + .map(|t| { + TodoItem::new( + t.get("content").and_then(Value::as_str).unwrap_or_default(), + TodoStatus::parse( + t.get("status").and_then(Value::as_str).unwrap_or_default(), + ), + ) }) .collect(), }, @@ -4234,6 +4410,46 @@ mod context_tests { } } +/// Budget for a `--version` the shared probe gave up on: a freshly installed +/// binary's first exec waits on the OS malware scan (1.1s on an M-series Mac +/// for 2.0.20, longer on slower machines and under Windows Defender). +const COLD_VERSION_TIMEOUT: Duration = Duration::from_secs(30); + +/// The shared probe caps `--version` at 2s and caches a timeout for the +/// binary's lifetime, so one cold first exec after an upgrade failed every +/// later run instantly until restart. Retry past that cache with a longer +/// budget, and on success clear the cached failure for other callers. +async fn opencode_version(exe: &std::path::Path) -> Option { + let cached_exe = exe.to_path_buf(); + let cached = + tokio::task::spawn_blocking(move || crate::executable::binary_version(&cached_exe)) + .await + .ok() + .flatten(); + if cached.is_some() { + return cached; + } + let mut cmd = Command::new(exe); + cmd.arg("--version") + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + let output = tokio::time::timeout(COLD_VERSION_TIMEOUT, cmd.output()) + .await + .ok()? + .ok()?; + if !output.status.success() { + return None; + } + let version = crate::executable::parse_version(&output.stdout) + .or_else(|| crate::executable::parse_version(&output.stderr))?; + if let Some(stem) = exe.file_stem().and_then(|s| s.to_str()) { + crate::executable::invalidate_versions(&[stem]); + } + Some(version) +} + /// Inline config is the final user config layer. Preserve inherited overrides /// and other MCP servers; never write chat identity into a shared config file. fn mcp_config( @@ -4346,6 +4562,108 @@ http.createServer((req, res) => {{ } } + #[cfg(unix)] + #[tokio::test] + async fn unknown_version_starts_without_mcp_instead_of_failing() { + use std::os::unix::fs::PermissionsExt; + let fixture = tempfile::tempdir().unwrap(); + let exe = fixture.path().join("opencode"); + // `--version` fails (a probe that timed out or printed no version). + let script = r#"#!/usr/bin/env node +const http = require('node:http'); +if (process.argv.includes('--version')) process.exit(1); +const config = process.env.OPENCODE_CONFIG_CONTENT ?? null; +const port = Number(process.argv[process.argv.indexOf('--port') + 1]); +http.createServer((req, res) => { + res.setHeader('content-type', 'application/json'); + if (req.url === '/config-probe') { res.end(JSON.stringify({config})); return; } + if (req.url === '/api/info') { res.end(JSON.stringify({version: '2.0.20'})); return; } + res.statusCode = 404; res.end('{}'); +}).listen(port, '127.0.0.1'); +"#; + std::fs::write(&exe, script).unwrap(); + std::fs::set_permissions(&exe, std::fs::Permissions::from_mode(0o700)).unwrap(); + let mcp = zeron_proto::McpServer { + name: "zeron".into(), + command: "zeron".into(), + args: vec!["mcp".into()], + env: Default::default(), + }; + let mut server = Server::spawn( + &exe, + fixture.path().to_str(), + Duration::from_secs(5), + Some(&mcp), + ) + .await + .expect("an unknown version must not fail the run"); + let probe = server.get_json("/config-probe", None).await.unwrap(); + server.shutdown(Duration::from_millis(100)).await; + assert_eq!(probe["config"], Value::Null); + } + + /// A freshly installed binary's first `--version` outlasts the shared + /// 2s probe (OS malware scan). That cached failure used to fail every + /// run; the cold retry must still find 2.x and inject the MCP config. + #[cfg(unix)] + #[tokio::test] + async fn cold_first_version_probe_still_injects_mcp() { + use std::os::unix::fs::PermissionsExt; + let fixture = tempfile::tempdir().unwrap(); + let exe = fixture.path().join("opencode"); + let warmed = fixture.path().join("warmed"); + let script = format!( + r#"#!/usr/bin/env node +const fs = require('node:fs'); +const http = require('node:http'); +if (process.argv.includes('--version')) {{ + const cold = !fs.existsSync({warmed:?}); + fs.writeFileSync({warmed:?}, ''); + setTimeout(() => {{ console.log('opencode v2.0.20'); process.exit(0); }}, cold ? 3000 : 0); +}} else {{ + const config = JSON.parse(process.env.OPENCODE_CONFIG_CONTENT); + const auth = {{ + password: process.env.OPENCODE_PASSWORD, + legacy: process.env.OPENCODE_SERVER_PASSWORD, + }}; + const port = Number(process.argv[process.argv.indexOf('--port') + 1]); + http.createServer((req, res) => {{ + res.setHeader('content-type', 'application/json'); + if (req.url === '/config-probe') {{ + res.end(JSON.stringify({{server: config.mcp.servers.zeron, auth}})); return; + }} + if (req.url === '/api/info') {{ res.end(JSON.stringify({{version: '2.0.20'}})); return; }} + res.statusCode = 404; res.end('{{}}'); + }}).listen(port, '127.0.0.1'); +}} +"# + ); + std::fs::write(&exe, script).unwrap(); + std::fs::set_permissions(&exe, std::fs::Permissions::from_mode(0o700)).unwrap(); + let mcp = zeron_proto::McpServer { + name: "zeron".into(), + command: "zeron".into(), + args: vec!["mcp".into()], + env: Default::default(), + }; + for _ in 0..2 { + let mut server = Server::spawn( + &exe, + fixture.path().to_str(), + Duration::from_secs(10), + Some(&mcp), + ) + .await + .expect("a slow first --version must not fail the run"); + let probe = server.get_json("/config-probe", None).await.unwrap(); + server.shutdown(Duration::from_millis(100)).await; + assert_eq!(probe["server"]["command"], json!(["zeron", "mcp"])); + // 2.x reads OPENCODE_PASSWORD first: both must carry ours. + assert!(probe["auth"]["password"].is_string()); + assert_eq!(probe["auth"]["password"], probe["auth"]["legacy"]); + } + } + #[test] fn mcp_injection_preserves_config_and_scopes_identity_for_both_protocols() { let mut mcp = zeron_proto::McpServer { diff --git a/crates/harness/src/opencode/tests.rs b/crates/harness/src/opencode/tests.rs index 42b2489644..f362de441b 100644 --- a/crates/harness/src/opencode/tests.rs +++ b/crates/harness/src/opencode/tests.rs @@ -237,6 +237,8 @@ impl TurnWire { .send(crate::SteerMessage { prompt: "second".into(), message_id: None, + attachments: Vec::new(), + config: None, }) .await .unwrap(); @@ -255,6 +257,7 @@ impl TurnWire { server: Server::attached(base), event_tx, controls: RunControls { + realtime: None, execution_lease: None, request_input: Box::new(move |questions| { let answer = answer.expect("fixture must not ask for input"); @@ -272,6 +275,7 @@ impl TurnWire { }), steering, interrupt: interrupt.clone(), + turn: Default::default(), }, request: serde_json::from_value(request).unwrap(), interrupt_grace: Duration::from_secs(2), @@ -411,6 +415,8 @@ async fn completed_turn_keeps_mailbox_alive_for_the_next_queued_request() { .send(crate::SteerMessage { prompt: "after completion".into(), message_id: Some("second".into()), + attachments: Vec::new(), + config: None, }) .await .unwrap(); @@ -1333,13 +1339,22 @@ fn tool_names_type_the_common_calls() { "todowrite", &json!({"todos": [ {"content": "step one", "status": "completed"}, - {"content": "step two", "status": "pending"}, + {"content": "step two", "status": "in_progress"}, + {"content": "step three", "status": "pending"}, + {"content": "step four", "status": "cancelled"}, ]}), ); - assert!(matches!( - &call, - ToolCall::Todo { items } if items.len() == 2 && items[0].done && !items[1].done - )); + assert_eq!( + call, + ToolCall::Todo { + items: vec![ + TodoItem::new("step one", TodoStatus::Completed), + TodoItem::new("step two", TodoStatus::InProgress), + TodoItem::new("step three", TodoStatus::Pending), + TodoItem::new("step four", TodoStatus::Pending), + ] + } + ); let call = oc_tool_call("mystery", &json!({"x": 1})); assert!(matches!(&call, ToolCall::Unknown { name, input: Some(_) } if name == "mystery")); assert!(!call.is_subagent_spawn()); diff --git a/crates/harness/src/pi/mod.rs b/crates/harness/src/pi/mod.rs index d4cb1660ae..a990028abc 100644 --- a/crates/harness/src/pi/mod.rs +++ b/crates/harness/src/pi/mod.rs @@ -228,6 +228,14 @@ impl Process { self.child.shutdown(grace).await; } } +/// Whether `request` picks a thinking level (an effort, or thinking off). +fn thinking_selected(request: &RunRequest) -> bool { + request.reasoning.is_some() + || request + .model_options + .get("pi_thinking") + .is_some_and(|v| v == "off") +} fn response_data(frame: Value) -> Result { if frame["success"] == true { Ok(frame["data"].clone()) @@ -265,6 +273,14 @@ impl Harness for PiHarness { fn deterministic_turn_end(&self) -> bool { true } + /// `set_model` and `set_thinking_level` change the live process. Clearing + /// either back to the default cannot be expressed that way. + fn reconfigures_in_place(&self, live: &RunRequest, next: &RunRequest) -> bool { + let model = |r: &RunRequest| r.model.as_deref().filter(|m| *m != "default").is_some(); + live.cwd == next.cwd + && (model(next) || !model(live)) + && (thinking_selected(next) || !thinking_selected(live)) + } async fn models(&self) -> Result, HarnessError> { Ok(self.model_catalog(false).await?.models) } @@ -374,10 +390,12 @@ impl Harness for PiHarness { }; // The lease lives through shutdown even if the consumer drops its stream. let RunControls { + realtime: _, execution_lease: _lease, request_input, mut steering, interrupt, + turn: _, } = controls; runner.process.dialogs.input = Some(std::sync::Arc::from(request_input)); let consumer = runner.tx.clone(); @@ -519,7 +537,34 @@ impl Runner { .query(json!({"type":"set_steering_mode","mode":"all"}), backlog) .await?; } - if let Some(model) = self.request.model.as_deref().filter(|s| *s != "default") { + let request = self.request.clone(); + self.configure(&request, backlog).await?; + let commands = self + .process + .query(json!({"type":"get_commands"}), backlog) + .await?; + self.extension_commands = commands["commands"] + .as_array() + .into_iter() + .flatten() + .filter(|c| c["source"] == "extension") + .map(|c| string(c, "name").to_owned()) + .collect(); + self.emit(AgentEvent::AvailableCommands { + commands: catalog::commands(&commands), + }) + .await?; + self.process + .query(json!({"type":"get_state"}), backlog) + .await + } + /// Select `request`'s model and thinking level on the live process. + async fn configure( + &mut self, + request: &RunRequest, + backlog: &mut Vec, + ) -> Result<(), HarnessError> { + if let Some(model) = request.model.as_deref().filter(|s| *s != "default") { let models = self .process .query(json!({"type":"get_available_models"}), backlog) @@ -545,27 +590,20 @@ impl Runner { ) .await?; } - if self.request.reasoning.is_some() - || self - .request - .model_options - .get("pi_thinking") - .is_some_and(|v| v == "off") - { + if thinking_selected(request) { let supported = self .process .query(json!({"type":"get_available_thinking_levels"}), backlog) .await?; let levels = supported["levels"].as_array().cloned().unwrap_or_default(); - let desired = if self - .request + let desired = if request .model_options .get("pi_thinking") .is_some_and(|v| v == "off") { "off".to_string() } else { - serde_json::to_value(self.request.reasoning) + serde_json::to_value(request.reasoning) .unwrap() .as_str() .unwrap_or("medium") @@ -590,24 +628,7 @@ impl Runner { ) .await?; } - let commands = self - .process - .query(json!({"type":"get_commands"}), backlog) - .await?; - self.extension_commands = commands["commands"] - .as_array() - .into_iter() - .flatten() - .filter(|c| c["source"] == "extension") - .map(|c| string(c, "name").to_owned()) - .collect(); - self.emit(AgentEvent::AvailableCommands { - commands: catalog::commands(&commands), - }) - .await?; - self.process - .query(json!({"type":"get_state"}), backlog) - .await + Ok(()) } async fn run( &mut self, @@ -673,6 +694,18 @@ impl Runner { })) }) { if let Some(steer) = self.queued.pop_front() { + // A changed model or thinking level applies on the + // live process before the message it was sent with. + if let Some(next) = &steer.config { + let mut backlog = vec![]; + self.configure(next, &mut backlog).await?; + self.request.model = next.model.clone(); + self.request.reasoning = next.reasoning; + self.request.model_options = next.model_options.clone(); + for frame in backlog { + self.frame(frame).await?; + } + } if !self.active { self.norm.reset(); } @@ -680,7 +713,8 @@ impl Runner { self.active = true; // Atomic Pi operation: queue at a step boundary if busy, start if idle. // A separate get_state + steer pair would strand an input on the idle race. - self.submit(steer.prompt.clone(), json!([]), true)?; + let images = load_images(&steer.attachments).await; + self.submit(steer.prompt.clone(), images, true)?; self.deliveries.push_back(Delivery { epoch: self.epoch, queued: false, diff --git a/crates/harness/src/process.rs b/crates/harness/src/process.rs index de5b7ebdbe..ac99237089 100644 --- a/crates/harness/src/process.rs +++ b/crates/harness/src/process.rs @@ -25,10 +25,132 @@ pub(crate) fn signal_target(child: &Child) -> Option Vec { + let Ok(out) = tokio::process::Command::new("ps") + .args(["-A", "-o", "pid=,ppid="]) + .stdin(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .output() + .await + else { + return Vec::new(); + }; + let table: Vec<(i32, i32)> = String::from_utf8_lossy(&out.stdout) + .lines() + .filter_map(|line| { + let mut cols = line.split_whitespace().map(str::parse::); + Some((cols.next()?.ok()?, cols.next()?.ok()?)) + }) + .collect(); + let mut found = Vec::new(); + let mut frontier = vec![root as i32]; + while let Some(parent) = frontier.pop() { + for &(pid, ppid) in &table { + if ppid == parent && pid > 1 && !found.contains(&pid) { + found.push(pid); + frontier.push(pid); + } + } + } + found +} + +/// Terminate `pids` and the process groups they lead: SIGTERM, then SIGKILL +/// whatever is still running after `grace`. +#[cfg(unix)] +pub(crate) async fn terminate_tree(pids: &[i32], grace: std::time::Duration) { + // A group leader's whole group (its own children included); anything + // else by pid. + let targets: Vec = pids + .iter() + .map(|&pid| { + // SAFETY: getpgid only inspects; a vanished pid returns -1. + if unsafe { libc::getpgid(pid) } == pid { + -pid + } else { + pid + } + }) + .collect(); + let alive = |target: i32| unsafe { libc::kill(target, 0) } == 0; + for &target in &targets { + crate::send_signal(&target, crate::Signal::Term); + } + let deadline = tokio::time::Instant::now() + grace; + while targets.iter().any(|&t| alive(t)) && tokio::time::Instant::now() < deadline { + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } + for &target in &targets { + if alive(target) { + crate::send_signal(&target, crate::Signal::Kill); + } + } +} + #[cfg(all(test, unix))] mod tests { use super::*; + /// A grandchild in its own process group (how agent CLIs start shells) + /// survives its parent's death; the descendant snapshot still finds it + /// and `terminate_tree` ends it. + #[tokio::test] + async fn tree_teardown_reaches_shells_in_their_own_process_group() { + /// Ends the fixture shell even when an assertion fails first. + struct Reap(i32); + impl Drop for Reap { + fn drop(&mut self) { + // SAFETY: SIGKILL to the fixture's own pid. + unsafe { libc::kill(self.0, libc::SIGKILL) }; + } + } + let dir = tempfile::tempdir().unwrap(); + let ready = dir.path().join("ready"); + // The child reports its pid only once it leads its own group: waiting + // for it to exist was not enough (it had not called setpgid yet). + let script = format!( + "python3 -c 'import os, time; os.setpgid(0, 0); open(\"{}\", \"w\").write(str(os.getpid())); time.sleep(60)' & sleep 60", + ready.display() + ); + let mut parent = tokio::process::Command::new("sh") + .args(["-c", &script]) + .kill_on_drop(true) + .spawn() + .unwrap(); + let root = parent.id().unwrap(); + let mut shell = None; + for _ in 0..250 { + if let Some(pid) = std::fs::read_to_string(&ready) + .ok() + .and_then(|s| s.trim().parse::().ok()) + { + shell = Some(pid); + break; + } + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } + let shell = shell.expect("the shell leads its own process group"); + let _reap = Reap(shell); + assert_eq!(unsafe { libc::getpgid(shell) }, shell); + let tree = descendants(root).await; + assert!(tree.contains(&shell), "{tree:?}"); + // The parent dies hard (an unresponsive agent's SIGKILL): the shell + // is orphaned, not killed. + parent.kill().await.unwrap(); + assert_eq!(unsafe { libc::kill(shell, 0) }, 0, "orphan survives"); + terminate_tree(&tree, std::time::Duration::from_secs(2)).await; + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + assert_ne!(unsafe { libc::kill(shell, 0) }, 0, "orphan ended"); + } + #[tokio::test] async fn unix_launch_retains_tokio_types_and_output() { let mut command: tokio::process::Command = Command::new(std::env::current_exe().unwrap()); diff --git a/crates/harness/src/process/owned.rs b/crates/harness/src/process/owned.rs index bb799a50cf..2926cf35e0 100644 --- a/crates/harness/src/process/owned.rs +++ b/crates/harness/src/process/owned.rs @@ -32,6 +32,14 @@ impl Child { } pub(crate) async fn shutdown(&mut self, grace: std::time::Duration) { + // Everything the agent started, taken while it is still attached: + // a command the agent ran in its own session (Devin does) is not in + // the agent's process group and would outlive the runtime. + #[cfg(unix)] + let tree = match (self.group, self.inner.id()) { + (Some(_), Some(pid)) => crate::process::descendants(pid).await, + _ => Vec::new(), + }; #[cfg(unix)] if let Some(group) = self.group.take() { crate::send_signal(&group, crate::Signal::Term); @@ -52,6 +60,8 @@ impl Child { } } crate::shutdown_child(&mut self.inner, grace).await; + #[cfg(unix)] + crate::process::terminate_tree(&tree, grace).await; } pub(crate) fn request_group_shutdown(&self) { diff --git a/crates/harness/tests/acp.rs b/crates/harness/tests/acp.rs index af0ee2d2ce..f6aa77c6f1 100644 --- a/crates/harness/tests/acp.rs +++ b/crates/harness/tests/acp.rs @@ -15,7 +15,7 @@ use zeron_harness::{ }; use zeron_proto::{ AgentEvent, DoneStatus, HarnessId, ReasoningLevel, RunRequest, SandboxLevel, SteeringMode, - TodoItem, ToolCall, UserInputAnswer, + TodoItem, TodoStatus, ToolCall, UserInputAnswer, }; fn fixture_path() -> PathBuf { @@ -56,6 +56,7 @@ fn controls() -> (RunControls, mpsc::Sender, CancellationToken) { let (steer_tx, steer_rx) = mpsc::channel(8); let token = CancellationToken::new(); let controls = RunControls { + realtime: None, execution_lease: None, request_input: Box::new(move |questions| { let (tx, rx) = oneshot::channel(); @@ -71,6 +72,7 @@ fn controls() -> (RunControls, mpsc::Sender, CancellationToken) { }), steering: steer_rx, interrupt: token.clone(), + turn: Default::default(), }; (controls, steer_tx, token) } @@ -199,14 +201,8 @@ async fn happy_path_maps_chunks_tools_diffs_plans_and_commands() { id: "acp-plan".into(), call: ToolCall::Todo { items: vec![ - TodoItem { - text: "read".into(), - done: true - }, - TodoItem { - text: "fix".into(), - done: false - }, + TodoItem::new("read", TodoStatus::Completed), + TodoItem::new("fix", TodoStatus::InProgress), ] }, })); @@ -238,6 +234,162 @@ async fn config_options_apply_requested_model_and_effort() { assert_eq!(dones(&events), vec![(DoneStatus::Completed, None)]); } +/// A model switch reaches the live session: between turns before the +/// message's prompt, and — sent mid-turn — only once the turn has ended, +/// still ahead of its own prompt. No new agent either way. +#[tokio::test] +async fn a_model_switch_is_selected_on_the_live_session() { + let (controls, steer, _token) = controls(); + let opening = request("scenario:reconfigure"); + let mut fast = opening.clone(); + fast.model = Some("grok-4-fast".into()); + assert!(harness().reconfigures_in_place(&opening, &fast)); + let mut stream = harness() + .run(opening.clone(), controls) + .await + .expect("run starts"); + let mut texts = Vec::new(); + let mut started = 0; + let mut dones = 0; + while dones < 3 { + let event = tokio::time::timeout(Duration::from_secs(10), stream.next()) + .await + .expect("event in time") + .expect("stream open") + .expect("stream event"); + match event { + AgentEvent::SessionStarted { .. } => started += 1, + AgentEvent::TextDelta { text } => { + if text == "on fast" { + // Mid-turn: back to the opening model. + steer + .send(SteerMessage { + config: Some(Box::new(opening.clone())), + ..SteerMessage::text("back") + }) + .await + .unwrap(); + } + texts.push(text); + } + AgentEvent::Done { status, error, .. } => { + assert_eq!(status, DoneStatus::Completed, "{error:?}"); + dones += 1; + if dones == 1 { + steer + .send(SteerMessage { + config: Some(Box::new(fast.clone())), + ..SteerMessage::text("go fast") + }) + .await + .unwrap(); + } + } + _ => {} + } + } + assert_eq!(started, 1); + assert_eq!(texts, ["turn1", "on fast", "back on 4.5"]); +} + +/// Tearing the runtime down ends everything the agent started — a command +/// it ran in its own session included (live, Devin: it outlived quitting +/// the app). +#[cfg(unix)] +#[tokio::test] +async fn teardown_ends_a_command_the_agent_detached() { + let dir = tempfile::tempdir().unwrap(); + let pidfile = dir.path().join("detached.pid"); + let (controls, _steer, token) = controls(); + let mut stream = harness() + .run( + request(&format!("scenario:detached {}", pidfile.display())), + controls, + ) + .await + .expect("run starts"); + loop { + let event = tokio::time::timeout(Duration::from_secs(10), stream.next()) + .await + .expect("event in time") + .expect("stream open") + .expect("stream event"); + if matches!(event, AgentEvent::Done { .. }) { + break; + } + } + let pid: i32 = std::fs::read_to_string(&pidfile) + .unwrap() + .trim() + .parse() + .unwrap(); + // SAFETY: signal 0 only probes for existence. + let alive = || unsafe { libc::kill(pid, 0) } == 0; + assert!(alive(), "the detached command runs"); + token.cancel(); + while tokio::time::timeout(Duration::from_secs(10), stream.next()) + .await + .expect("teardown in time") + .is_some() + {} + let deadline = std::time::Instant::now() + Duration::from_secs(5); + while alive() && std::time::Instant::now() < deadline { + tokio::time::sleep(Duration::from_millis(50)).await; + } + assert!(!alive(), "the detached command outlived the runtime"); +} + +/// "Send now" while a steer's request is still in flight: the stop cancels +/// that steer with the turn. Its late promptRequired answer must not queue +/// it again, or the cancelled message runs after all. +#[tokio::test] +async fn a_turn_stop_cancels_a_steer_still_in_flight() { + use zeron_harness::TurnControl; + let (mut controls, steer, _token) = controls(); + let turn = TurnControl::default(); + controls.turn = turn.clone(); + let mut stream = harness() + .run(request("scenario:stop-steer"), controls) + .await + .expect("run starts"); + macro_rules! next { + () => { + tokio::time::timeout(Duration::from_secs(10), stream.next()) + .await + .expect("event in time") + .expect("stream open") + .expect("stream event") + }; + } + loop { + if matches!(next!(), AgentEvent::TextDelta { text } if text == "working") { + break; + } + } + steer + .send(SteerMessage::text("cancelled steer")) + .await + .unwrap(); + tokio::time::sleep(Duration::from_millis(200)).await; + turn.stop_turn(); + loop { + if let AgentEvent::Done { status, .. } = next!() { + assert_eq!(status, DoneStatus::Interrupted); + break; + } + } + steer.send(SteerMessage::text("after")).await.unwrap(); + let mut texts = Vec::new(); + loop { + match next!() { + AgentEvent::TextDelta { text } => texts.push(text), + AgentEvent::Done { .. } => break, + _ => {} + } + } + assert_eq!(texts, ["fresh"], "the cancelled steer ran after the stop"); +} + #[tokio::test] async fn resumed_first_class_model_is_switched_before_prompt() { let (controls, _steer, _token) = controls(); @@ -282,6 +434,8 @@ async fn steering_extension_injects_mid_turn() { .send(SteerMessage { prompt: "redirect please".into(), message_id: None, + attachments: Vec::new(), + config: None, }) .await .expect("steer sent"); @@ -328,6 +482,8 @@ async fn steer_racing_the_turn_end_never_emits_steered_after_done() { .send(SteerMessage { prompt: "redirect please".into(), message_id: None, + attachments: Vec::new(), + config: None, }) .await .expect("steer sent"); @@ -379,6 +535,8 @@ async fn rejected_steer_queues_and_delivers_at_the_turn_boundary() { .send(SteerMessage { prompt: "redirect please".into(), message_id: None, + attachments: Vec::new(), + config: None, }) .await .expect("steer sent"); @@ -2015,6 +2173,8 @@ async fn acp_boundary_steer(scenario: &str, trigger_on_done: bool) { .send(zeron_harness::SteerMessage { prompt: "second".into(), message_id: None, + attachments: Vec::new(), + config: None, }) .await .unwrap(); @@ -2280,3 +2440,68 @@ async fn shared_acp_skills_require_explicit_native_command_classification() { ); } } + +#[tokio::test] +async fn a_stopped_turn_keeps_the_agent_for_the_next_prompt() { + use zeron_harness::TurnControl; + let (mut controls, steer, _token) = controls(); + let turn = TurnControl::default(); + controls.turn = turn.clone(); + assert!(harness().stops_turn_in_place()); + let mut stream = harness() + .run(request("scenario:stop-in-place"), controls) + .await + .expect("run starts"); + async fn next_event( + stream: &mut futures::stream::BoxStream<'static, Result>, + ) -> Option { + tokio::time::timeout(Duration::from_secs(10), stream.next()) + .await + .expect("event in time") + .map(|r| r.expect("stream event")) + } + loop { + match next_event(&mut stream).await { + Some(AgentEvent::TextDelta { text }) if text == "working" => break, + Some(_) => {} + None => panic!("stream ended before the turn streamed"), + } + } + turn.stop_turn(); + loop { + match next_event(&mut stream).await { + Some(AgentEvent::Done { status, .. }) => { + assert_eq!(status, DoneStatus::Interrupted); + break; + } + Some(_) => {} + None => panic!("a stopped turn must not end the agent's stream"), + } + } + // Sent right after the stop: our own turn's tail is not an unowned + // turn, so no second `session/cancel` precedes this prompt. + steer.send(SteerMessage::text("carry on")).await.unwrap(); + let mut resumed = Vec::new(); + loop { + match next_event(&mut stream).await { + Some(done @ AgentEvent::Done { .. }) => { + resumed.push(done); + break; + } + Some(event) => resumed.push(event), + None => panic!("stream ended before the next turn finished"), + } + } + assert!(resumed.contains(&AgentEvent::TextDelta { + text: "resumed".into() + })); + assert!(matches!( + resumed.last(), + Some(AgentEvent::Done { + status: DoneStatus::Completed, + .. + }) + )); + drop(steer); + assert!(next_event(&mut stream).await.is_none()); +} diff --git a/crates/harness/tests/acp_quiet.rs b/crates/harness/tests/acp_quiet.rs index 38e08ea984..c5def1f42d 100644 --- a/crates/harness/tests/acp_quiet.rs +++ b/crates/harness/tests/acp_quiet.rs @@ -60,6 +60,7 @@ fn controls() -> (RunControls, mpsc::Sender, CancellationToken) { let (steer_tx, steer_rx) = mpsc::channel(8); let token = CancellationToken::new(); let controls = RunControls { + realtime: None, execution_lease: None, request_input: Box::new(move |questions: Vec| { let (tx, rx) = oneshot::channel(); @@ -75,6 +76,7 @@ fn controls() -> (RunControls, mpsc::Sender, CancellationToken) { }), steering: steer_rx, interrupt: token.clone(), + turn: Default::default(), }; (controls, steer_tx, token) } @@ -158,6 +160,8 @@ async fn delayed_turn(scenario: &str) { .send(SteerMessage { message_id: None, prompt: "second".into(), + attachments: Vec::new(), + config: None, }) .await .unwrap(); @@ -165,6 +169,8 @@ async fn delayed_turn(scenario: &str) { .send(SteerMessage { message_id: None, prompt: "third".into(), + attachments: Vec::new(), + config: None, }) .await .unwrap(); @@ -207,6 +213,8 @@ async fn delayed_turn(scenario: &str) { .send(SteerMessage { message_id: None, prompt: "fourth".into(), + attachments: Vec::new(), + config: None, }) .await .unwrap(); @@ -271,6 +279,8 @@ async fn cancel_quiet(scenario: &str) { .send(SteerMessage { message_id: None, prompt: "must not run".into(), + attachments: Vec::new(), + config: None, }) .await .unwrap(); diff --git a/crates/harness/tests/acp_stall.rs b/crates/harness/tests/acp_stall.rs index 6acf1dafea..783b314c71 100644 --- a/crates/harness/tests/acp_stall.rs +++ b/crates/harness/tests/acp_stall.rs @@ -37,6 +37,7 @@ async fn silent_agent_errors_via_the_prompt_stall_watchdog() { let (_steer_tx, steer_rx) = mpsc::channel(8); let token = CancellationToken::new(); let controls = RunControls { + realtime: None, execution_lease: None, request_input: Box::new(move |_| { let (tx, rx) = oneshot::channel(); @@ -45,6 +46,7 @@ async fn silent_agent_errors_via_the_prompt_stall_watchdog() { }), steering: steer_rx, interrupt: token.clone(), + turn: Default::default(), }; let request = RunRequest { mcp: None, diff --git a/crates/harness/tests/claude.rs b/crates/harness/tests/claude.rs index a759c72a6c..7fb98a1bbc 100644 --- a/crates/harness/tests/claude.rs +++ b/crates/harness/tests/claude.rs @@ -60,6 +60,7 @@ fn controls( let (steer_tx, steer_rx) = mpsc::channel(8); let token = CancellationToken::new(); let controls = RunControls { + realtime: None, execution_lease: None, request_input: Box::new(move |questions| { let (tx, rx) = oneshot::channel(); @@ -75,6 +76,7 @@ fn controls( }), steering: steer_rx, interrupt: token.clone(), + turn: Default::default(), }; (controls, steer_tx, token) } @@ -291,6 +293,7 @@ async fn ask_user_question_round_trips_through_the_control_channel() { let token = CancellationToken::new(); let seen = asked.clone(); let controls = RunControls { + realtime: None, execution_lease: None, request_input: Box::new(move |questions| { seen.lock().unwrap().extend(questions.iter().cloned()); @@ -307,6 +310,7 @@ async fn ask_user_question_round_trips_through_the_control_channel() { }), steering: steer_rx, interrupt: token.clone(), + turn: Default::default(), }; let events = run_to_end(&harness(), request("scenario:askuser"), controls).await; @@ -378,6 +382,8 @@ async fn ultrathink_preserves_selected_commands_on_initial_and_steered_sends() { .send(SteerMessage { prompt, message_id: None, + attachments: Vec::new(), + config: None, }) .await .unwrap(); @@ -401,6 +407,8 @@ async fn steering_lines_are_written_to_stdin_mid_run() { .send(SteerMessage { prompt: "redirect please".into(), message_id: None, + attachments: Vec::new(), + config: None, }) .await .expect("steer queued"); @@ -866,6 +874,8 @@ async fn a_replay_confirms_superseded_steers_and_the_turn_ends() { .send(SteerMessage { prompt: prompt.into(), message_id: None, + attachments: Vec::new(), + config: None, }) .await .expect("steer queued"); @@ -898,6 +908,399 @@ async fn a_replay_confirms_superseded_steers_and_the_turn_ends() { )); } +fn steer_msg(prompt: &str) -> SteerMessage { + SteerMessage { + prompt: prompt.into(), + message_id: None, + attachments: Vec::new(), + config: None, + } +} + +/// The premature-"done" bug: a `now` steer aborts the streaming turn, whose +/// result was held for the steer — and released by a 5s quiet timer while +/// the steered turn ran a long tool. The engine read that stale Done as the +/// steered turn completing (sound, notification, parked mid-turn). +#[tokio::test] +async fn a_steered_turn_running_a_long_tool_is_not_marked_done() { + let (controls, steer, _token) = controls("A"); + steer.send(steer_msg("stop and sleep")).await.unwrap(); + let stream = harness() + .run(request("scenario:lifecycle-now-steer"), controls) + .await + .expect("run starts"); + let events = tokio::time::timeout( + Duration::from_secs(20), + stream.map(|r| r.expect("event")).collect::>(), + ) + .await + .expect("run ends"); + let dones: Vec<_> = events + .iter() + .filter(|e| matches!(e, AgentEvent::Done { .. })) + .collect(); + assert_eq!( + dones.len(), + 1, + "exactly the steered turn's own end: {events:?}" + ); + let done_at = events + .iter() + .position(|e| matches!(e, AgentEvent::Done { .. })) + .unwrap(); + let tool_result_at = events + .iter() + .position(|e| matches!(e, AgentEvent::ToolResult { id, .. } if id == "tool-sleep")) + .expect("tool result"); + assert!( + done_at > tool_result_at, + "Done before the tool finished: {events:?}" + ); + assert!(matches!( + dones[0], + AgentEvent::Done { + status: DoneStatus::Completed, + .. + } + )); + assert_eq!( + events + .iter() + .filter(|e| matches!(e, AgentEvent::Steered { .. })) + .count(), + 1 + ); +} + +/// A message queued behind a turn that just finished: that turn's result is +/// a boundary; the run's one Done is the queued message's own. +#[tokio::test] +async fn a_message_queued_behind_a_finishing_turn_owns_the_turn_end() { + let (controls, steer, _token) = controls("A"); + steer.send(steer_msg("follow up")).await.unwrap(); + let events = run_to_end( + &harness(), + request("scenario:lifecycle-queued-behind-end"), + controls, + ) + .await; + let dones = events + .iter() + .filter(|e| matches!(e, AgentEvent::Done { .. })) + .count(); + assert_eq!(dones, 1, "{events:?}"); + let steered_at = events + .iter() + .position(|e| matches!(e, AgentEvent::Steered { .. })) + .expect("steered"); + let second_at = events + .iter() + .position(|e| matches!(e, AgentEvent::TextDelta { text } if text == "second answer")) + .expect("second answer"); + assert!(steered_at < second_at); + assert!(matches!(events.last(), Some(AgentEvent::Done { .. }))); +} + +/// "Send now" while a turn end is held for a queued steer: the stop's one +/// Done waits for the stop to settle, so the replacement prompt — sent the +/// moment that Done arrives — runs to its own end, never settled by the +/// stopped turn's late result. +#[tokio::test] +async fn send_now_over_a_held_turn_end_never_settles_the_replacement() { + use zeron_harness::TurnControl; + let (mut controls, steer, _token) = controls("A"); + let turn = TurnControl::default(); + controls.turn = turn.clone(); + let mut stream = harness() + .run(request("scenario:stop-held"), controls) + .await + .expect("run starts"); + steer.send(steer_msg("queued steer")).await.unwrap(); + macro_rules! next { + () => { + tokio::time::timeout(Duration::from_secs(10), stream.next()) + .await + .expect("event in time") + .expect("stream open") + .expect("stream event") + }; + } + loop { + if matches!(next!(), AgentEvent::TextDelta { text } if text == "first answer") { + break; + } + } + // Let the result be held behind the queued steer, then stop. + tokio::time::sleep(Duration::from_millis(200)).await; + turn.stop_turn(); + let stopped = loop { + if let done @ AgentEvent::Done { .. } = next!() { + break done; + } + }; + assert!( + matches!( + stopped, + AgentEvent::Done { + status: DoneStatus::Interrupted, + .. + } + ), + "{stopped:?}" + ); + // The replacement goes out the moment the stop settles. + steer.send(steer_msg("replacement")).await.unwrap(); + let mut after = Vec::new(); + let end = loop { + match next!() { + done @ AgentEvent::Done { .. } => break done, + event => after.push(event), + } + }; + assert!( + after.contains(&AgentEvent::TextDelta { + text: "replacement".into() + }), + "the replacement turn was settled before its reply: {after:?} then {end:?}" + ); + assert!( + matches!( + end, + AgentEvent::Done { + status: DoneStatus::Completed, + .. + } + ), + "{end:?}" + ); +} + +/// After a stop, a replacement that answers within the stop's settle window +/// from a CLI that confirms nothing (no lifecycle, no replay) still ends: its +/// result is reconciled like any unconfirmed steer's, never dropped as the +/// stopped turn reporting late (which left the chat Working). +#[tokio::test] +async fn a_quick_unconfirmed_replacement_after_a_stop_still_completes() { + use zeron_harness::TurnControl; + let (mut controls, steer, _token) = controls("A"); + let turn = TurnControl::default(); + controls.turn = turn.clone(); + let mut stream = harness() + .run(request("scenario:stop-unconfirmed-replacement"), controls) + .await + .expect("run starts"); + macro_rules! next { + () => { + tokio::time::timeout(Duration::from_secs(15), stream.next()) + .await + .expect("event in time") + .expect("stream open") + .expect("stream event") + }; + } + loop { + if matches!(next!(), AgentEvent::TextDelta { text } if text == "working") { + break; + } + } + turn.stop_turn(); + loop { + if let AgentEvent::Done { status, .. } = next!() { + assert_eq!(status, DoneStatus::Interrupted); + break; + } + } + steer.send(steer_msg("replacement")).await.unwrap(); + let mut after = Vec::new(); + let end = loop { + match next!() { + done @ AgentEvent::Done { .. } => break done, + event => after.push(event), + } + }; + assert!( + after.contains(&AgentEvent::TextDelta { + text: "new reply".into() + }), + "{after:?}" + ); + assert!( + matches!( + end, + AgentEvent::Done { + status: DoneStatus::Completed, + .. + } + ), + "{end:?}" + ); +} + +/// A stopped turn reporting a second, delayed result — no new prompt, no +/// new content — is dropped whole: no second Done (an Errored one failed +/// the parked session), and none of its usage. +#[tokio::test] +async fn a_stopped_turns_delayed_duplicate_result_is_dropped() { + use zeron_harness::TurnControl; + let (mut controls, _steer, _token) = controls("A"); + let turn = TurnControl::default(); + controls.turn = turn.clone(); + let mut stream = harness() + .run(request("scenario:stop-late-duplicate"), controls) + .await + .expect("run starts"); + loop { + let event = tokio::time::timeout(Duration::from_secs(10), stream.next()) + .await + .expect("event in time") + .expect("stream open") + .expect("stream event"); + if matches!(event, AgentEvent::TextDelta { text } if text == "working") { + break; + } + } + turn.stop_turn(); + loop { + let event = tokio::time::timeout(Duration::from_secs(10), stream.next()) + .await + .expect("event in time") + .expect("stream open") + .expect("stream event"); + if let AgentEvent::Done { status, .. } = event { + assert_eq!(status, DoneStatus::Interrupted); + break; + } + } + let mut after = Vec::new(); + let quiet = tokio::time::timeout(Duration::from_secs(2), async { + while let Some(event) = stream.next().await { + after.push(event.expect("stream event")); + } + }) + .await; + assert!(quiet.is_err(), "the stream ended: {after:?}"); + assert!( + !after + .iter() + .any(|e| matches!(e, AgentEvent::Done { .. } | AgentEvent::Usage { .. })), + "the stopped turn's late duplicate was forwarded: {after:?}" + ); +} + +/// A queued message the CLI cancels before starting releases the held turn +/// end at once, from the lifecycle frame — not after a quiet timer. +#[tokio::test] +async fn a_cancelled_queued_message_releases_the_turn_end_immediately() { + let (controls, steer, _token) = controls("A"); + steer.send(steer_msg("never started")).await.unwrap(); + let started = std::time::Instant::now(); + let mut stream = harness() + .run(request("scenario:lifecycle-queued-cancelled"), controls) + .await + .expect("run starts"); + let status = tokio::time::timeout(Duration::from_secs(10), async { + while let Some(event) = stream.next().await { + if let AgentEvent::Done { status, .. } = event.expect("event") { + return status; + } + } + panic!("stream ended without Done"); + }) + .await + .expect("released"); + assert_eq!(status, DoneStatus::Completed); + assert!( + started.elapsed() < Duration::from_secs(3), + "{:?}", + started.elapsed() + ); +} + +/// A follow-up sent with another model/effort is adopted by the live process +/// instead of replacing it (which killed its background agents and shells). +#[tokio::test] +async fn a_changed_model_and_effort_apply_to_the_live_process() { + use zeron_proto::ReasoningLevel; + assert!(harness().reconfigures_in_place(&request("a"), &{ + let mut next = request("b"); + next.model = Some("claude-haiku-4-5".into()); + next + })); + let mut cwd_moved = request("b"); + cwd_moved.cwd = "/elsewhere".into(); + assert!(!harness().reconfigures_in_place(&request("a"), &cwd_moved)); + + let (controls, steer, _token) = controls("A"); + let mut launch = request("scenario:reconfigure"); + launch.model = Some("claude-sonnet-5-5".into()); + launch.reasoning = Some(ReasoningLevel::Low); + launch + .model_options + .insert("fastMode".into(), serde_json::json!(true)); + let mut next = launch.clone(); + next.model = Some("claude-haiku-4-5".into()); + next.reasoning = Some(ReasoningLevel::High); + next.model_options.clear(); + let mut switched = steer_msg("switched"); + switched.config = Some(Box::new(next.clone())); + steer.send(switched).await.unwrap(); + let mut same = steer_msg("same again"); + same.config = None; + steer.send(same).await.unwrap(); + drop(steer); + let events = run_to_end(&harness(), launch, controls).await; + assert!( + events.contains(&AgentEvent::TextDelta { + text: "on haiku".into() + }), + "{events:?}" + ); + assert!( + matches!( + events.last(), + Some(AgentEvent::Done { + status: DoneStatus::Completed, + .. + }) + ), + "{events:?}" + ); +} + +/// "Marked done the moment I sent it": resuming a session whose background +/// tasks died with its old process, the CLI settles them with empty results +/// while the new prompt is still queued. Those are not the prompt's end. +#[tokio::test] +async fn results_settling_killed_tasks_on_resume_do_not_end_the_prompt() { + let (controls, _steer, _token) = controls("A"); + let events = run_to_end( + &harness(), + request("Reply PONG scenario:resume-killed-tasks"), + controls, + ) + .await; + let dones: Vec<_> = events + .iter() + .filter(|e| matches!(e, AgentEvent::Done { .. })) + .collect(); + assert_eq!(dones.len(), 1, "{events:?}"); + assert!( + matches!(dones[0], AgentEvent::Done { status: DoneStatus::Completed, result: Some(r), .. } if r == "PONG"), + "{events:?}" + ); + let pong = events + .iter() + .position(|e| matches!(e, AgentEvent::TextDelta { text } if text == "PONG")) + .expect("reply"); + assert!( + events + .iter() + .position(|e| matches!(e, AgentEvent::Done { .. })) + .unwrap() + > pong + ); +} + /// A steer the CLI never replays must not hold the turn end forever. #[tokio::test] async fn an_unreplayed_steer_releases_the_turn_end() { @@ -906,6 +1309,8 @@ async fn an_unreplayed_steer_releases_the_turn_end() { .send(SteerMessage { prompt: "absorbed steer".into(), message_id: None, + attachments: Vec::new(), + config: None, }) .await .expect("steer queued"); @@ -931,3 +1336,124 @@ async fn an_unreplayed_steer_releases_the_turn_end() { assert_eq!(steered, 1); assert!(started.elapsed() >= std::time::Duration::from_secs(4)); } + +// --------------------------------------------------------------------------- +// Stopping a turn in place +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn a_stopped_turn_keeps_the_process_and_its_background_work() { + use zeron_harness::TurnControl; + let (mut controls, steer, _token) = controls("A"); + let turn = TurnControl::default(); + controls.turn = turn.clone(); + assert!(harness().stops_turn_in_place()); + let mut stream = harness() + .run(request("scenario:stop-in-place"), controls) + .await + .expect("run starts"); + async fn next_event( + stream: &mut futures::stream::BoxStream<'static, Result>, + ) -> Option { + tokio::time::timeout(Duration::from_secs(10), stream.next()) + .await + .expect("event in time") + .map(|r| r.expect("stream event")) + } + loop { + match next_event(&mut stream).await { + Some(AgentEvent::TextDelta { text }) if text == "working" => break, + Some(_) => {} + None => panic!("stream ended before the turn streamed"), + } + } + assert!( + turn.background_live(), + "background_tasks_changed must report the live task" + ); + + turn.stop_turn(); + let stopped = loop { + match next_event(&mut stream).await { + Some(done @ AgentEvent::Done { .. }) => break done, + Some(_) => {} + None => panic!("a stopped turn must not end the process's stream"), + } + }; + assert!( + matches!( + stopped, + AgentEvent::Done { + status: DoneStatus::Interrupted, + error: None, + .. + } + ), + "{stopped:?}" + ); + assert!( + turn.background_live(), + "the stop spared the background task" + ); + + // The same process takes the next prompt — with its image inlined. + let dir = tempfile::tempdir().unwrap(); + let image = dir.path().join("shot.png"); + std::fs::write(&image, b"\x89PNG\r\n\x1a\nfake").unwrap(); + steer + .send(SteerMessage { + prompt: "carry on".into(), + message_id: None, + attachments: vec![image.to_string_lossy().into_owned()], + config: None, + }) + .await + .unwrap(); + let mut resumed = Vec::new(); + loop { + match next_event(&mut stream).await { + Some(done @ AgentEvent::Done { .. }) => { + resumed.push(done); + break; + } + Some(event) => resumed.push(event), + None => panic!("stream ended before the next turn finished"), + } + } + assert!( + resumed.contains(&AgentEvent::TextDelta { + text: "resumed:image=yes".into() + }), + "{resumed:?}" + ); + assert!( + resumed + .iter() + .any(|e| matches!(e, AgentEvent::Steered { .. })) + ); + assert!(matches!( + resumed.last(), + Some(AgentEvent::Done { + status: DoneStatus::Completed, + .. + }) + )); + assert!(!turn.background_live()); + + // A stop that lands between turns still settles: the CLI answers and no + // result follows, so the driver reports the (empty) turn stopped. + turn.stop_turn(); + assert!(matches!( + next_event(&mut stream).await, + Some(AgentEvent::Done { + status: DoneStatus::Interrupted, + .. + }) + )); + + drop(steer); + assert!( + next_event(&mut stream).await.is_none(), + "closing the mailbox ends the run" + ); +} diff --git a/crates/harness/tests/codex.rs b/crates/harness/tests/codex.rs index 0da11a6aa3..afd08c46c4 100644 --- a/crates/harness/tests/codex.rs +++ b/crates/harness/tests/codex.rs @@ -15,7 +15,7 @@ use zeron_harness::{ }; use zeron_proto::{ AgentEvent, DoneStatus, HarnessId, ReasoningLevel, RunRequest, SandboxLevel, TodoItem, - ToolCall, UserInputAnswer, UserInputQuestion, + TodoStatus, ToolCall, UserInputAnswer, UserInputQuestion, }; fn fixture_path() -> PathBuf { @@ -115,6 +115,7 @@ fn controls( let (steer_tx, steer_rx) = mpsc::channel(8); let token = CancellationToken::new(); let controls = RunControls { + realtime: None, execution_lease: None, request_input: Box::new(move |questions| { let (tx, rx) = oneshot::channel(); @@ -130,6 +131,7 @@ fn controls( }), steering: steer_rx, interrupt: token.clone(), + turn: Default::default(), }; (controls, steer_tx, token) } @@ -293,14 +295,8 @@ async fn happy_path_maps_deltas_items_usage_and_done() { id: "td1".into(), call: ToolCall::Todo { items: vec![ - TodoItem { - text: "a".into(), - done: true - }, - TodoItem { - text: "b".into(), - done: false - }, + TodoItem::new("a", TodoStatus::Completed), + TodoItem::new("b", TodoStatus::Pending), ] }, })); @@ -366,6 +362,8 @@ async fn steering_uses_turn_steer_with_expected_turn_id() { .send(SteerMessage { prompt: "redirect please".into(), message_id: None, + attachments: Vec::new(), + config: None, }) .await .expect("steer queued"); @@ -402,6 +400,148 @@ async fn steering_uses_turn_steer_with_expected_turn_id() { ); } +/// An accepted steer joins the turn when its userMessage item lands, after +/// the reply streaming ahead of it — `Steered` must split there, not at the +/// `turn/steer` response (which moved the old reply's tail below the steer). +#[tokio::test] +async fn a_steer_splits_the_reply_where_its_message_joins_the_turn() { + let (controls, steer, _token) = controls("Yes"); + steer + .send(SteerMessage::text("redirect please")) + .await + .expect("steer queued"); + let events = run_to_end(&harness(), request("scenario:steer-item"), controls).await; + let at = |pred: &dyn Fn(&AgentEvent) -> bool| { + events + .iter() + .position(pred) + .unwrap_or_else(|| panic!("missing event: {events:?}")) + }; + let still_first = + at(&|e| matches!(e, AgentEvent::TextDelta { text } if text == "-still-first")); + let steered = at(&|e| matches!(e, AgentEvent::Steered { .. })); + let reply = at(&|e| matches!(e, AgentEvent::TextDelta { text } if text == "steered")); + assert!(still_first < steered && steered < reply, "{events:?}"); + assert_eq!( + events + .iter() + .filter(|e| matches!(e, AgentEvent::Steered { .. })) + .count(), + 1 + ); + assert!(matches!( + events.last(), + Some(AgentEvent::Done { + status: DoneStatus::Completed, + .. + }) + )); +} + +/// Only the turn in flight settles: a stale or duplicate `turn/completed` +/// marked the running turn done mid-way (sound, notification, park). +#[tokio::test] +async fn a_stale_turn_completion_never_settles_the_running_turn() { + let (controls, _steer, _token) = controls("Yes"); + let events = run_to_end(&harness(), request("scenario:stale-completion"), controls).await; + let dones: Vec<_> = events + .iter() + .enumerate() + .filter(|(_, e)| matches!(e, AgentEvent::Done { .. })) + .collect(); + assert_eq!(dones.len(), 1, "{events:?}"); + let after = events + .iter() + .position(|e| matches!(e, AgentEvent::TextDelta { text } if text == "after")) + .expect("after"); + assert!(dones[0].0 > after, "{events:?}"); +} + +/// A stop issued before the app-server announces the turn waits for +/// `turn/started`: an earlier `turn/interrupt` is rejected, the turn runs to +/// its end, and the host's fallback tore the whole runtime down. +#[tokio::test] +async fn a_stop_before_the_turn_is_announced_still_interrupts_it() { + use zeron_harness::TurnControl; + let (mut controls, _steer, _token) = controls("Yes"); + let turn = TurnControl::default(); + controls.turn = turn.clone(); + let mut stream = harness() + .run(request("scenario:stop-before-announce"), controls) + .await + .expect("run starts"); + let status = tokio::time::timeout(std::time::Duration::from_secs(10), async { + while let Some(event) = stream.next().await { + match event.expect("event") { + AgentEvent::TextDelta { text } if text == "working" => turn.stop_turn(), + AgentEvent::Done { status, .. } => return status, + _ => {} + } + } + panic!("stream ended without Done"); + }) + .await + .expect("stopped turn settles"); + assert_eq!(status, DoneStatus::Interrupted); +} + +/// A command still running after its turn (a dev server) holds the parked +/// runtime against the idle reaper; an interrupted turn's killed foreground +/// command — which never gets an `item/completed` — does not. +#[tokio::test] +async fn open_commands_report_background_work_and_killed_ones_do_not() { + use zeron_harness::TurnControl; + let (mut controls, steer, _token) = controls("Yes"); + let turn = TurnControl::default(); + controls.turn = turn.clone(); + let mut stream = harness() + .run(request("scenario:background-commands"), controls) + .await + .expect("run starts"); + async fn until_done( + stream: &mut futures::stream::BoxStream<'static, Result>, + mut on: impl FnMut(&AgentEvent), + ) -> DoneStatus { + tokio::time::timeout(Duration::from_secs(10), async { + while let Some(event) = stream.next().await { + let event = event.expect("event"); + on(&event); + if let AgentEvent::Done { status, .. } = event { + return status; + } + } + panic!("stream ended without Done"); + }) + .await + .expect("turn ends") + } + assert_eq!(until_done(&mut stream, |_| {}).await, DoneStatus::Completed); + assert!(turn.background_live(), "the dev server outlives its turn"); + + steer + .send(SteerMessage::text("run the tests")) + .await + .unwrap(); + let stop = turn.clone(); + let status = until_done(&mut stream, |e| { + if matches!(e, AgentEvent::TextDelta { text } if text == "running") { + stop.stop_turn(); + } + }) + .await; + assert_eq!(status, DoneStatus::Interrupted); + // The killed foreground command's process is gone once the grace passes. + tokio::time::sleep(Duration::from_millis(3500)).await; + assert!(turn.background_live(), "the dev server still runs"); + + steer + .send(SteerMessage::text("stop the server")) + .await + .unwrap(); + assert_eq!(until_done(&mut stream, |_| {}).await, DoneStatus::Completed); + assert!(!turn.background_live(), "nothing left running"); +} + #[tokio::test] async fn rejected_steer_falls_back_to_a_follow_up_turn() { let (controls, steer, _token) = controls("Yes"); @@ -417,6 +557,8 @@ async fn rejected_steer_falls_back_to_a_follow_up_turn() { .link() ), message_id: None, + attachments: Vec::new(), + config: None, }) .await .expect("steer queued"); @@ -466,6 +608,7 @@ async fn approvals_round_trip_as_input_requests() { let token = CancellationToken::new(); let seen = asked.clone(); let controls = RunControls { + realtime: None, execution_lease: None, request_input: Box::new(move |questions| { seen.lock().unwrap().extend(questions.iter().cloned()); @@ -482,6 +625,7 @@ async fn approvals_round_trip_as_input_requests() { }), steering: steer_rx, interrupt: token.clone(), + turn: Default::default(), }; let mut req = request("scenario:approve"); req.auto_approve = false; @@ -1057,6 +1201,8 @@ async fn live_subagent_spawn_and_followup_keep_one_transcript() { steer.send(SteerMessage { prompt: "Reuse the SAME existing subagent for one more task: reply exactly child-second. Use followup_task if available, otherwise send_input. Do not spawn a new agent. Wait for it to finish, then reply exactly parent-second. Do not inspect or change files.".into(), message_id: None, + attachments: Vec::new(), + config: None, }).await.unwrap(); } if turn == 2 { @@ -1464,6 +1610,8 @@ async fn native_command_during_a_turn_waits_for_its_boundary() { .send(SteerMessage { prompt: "/review".into(), message_id: None, + attachments: Vec::new(), + config: None, }) .await .unwrap(); @@ -1499,6 +1647,8 @@ async fn native_skill_and_file_references_survive_initial_and_steered_turns() { .send(SteerMessage { prompt: harness_prompt(&format!("Also {}", followup.link()), HarnessId::Codex), message_id: Some("skill-steer".into()), + attachments: Vec::new(), + config: None, }) .await .unwrap(); @@ -1548,6 +1698,8 @@ async fn ordinary_followup_cannot_overtake_a_queued_native_command() { .send(SteerMessage { prompt: prompt.into(), message_id: None, + attachments: Vec::new(), + config: None, }) .await .unwrap(); @@ -1578,3 +1730,276 @@ async fn ordinary_followup_cannot_overtake_a_queued_native_command() { ] ); } + +#[tokio::test] +async fn a_model_change_sent_mid_turn_takes_effect_in_its_own_turn() { + let (controls, steer, _token) = controls("Yes"); + let mut stream = harness() + .run(request("scenario:reconfigure"), controls) + .await + .expect("run starts"); + let mut events = Vec::new(); + loop { + let event = tokio::time::timeout(Duration::from_secs(10), stream.next()) + .await + .expect("event in time") + .expect("stream open") + .expect("stream event"); + let working = event + == AgentEvent::TextDelta { + text: "working".into(), + }; + events.push(event); + if working { + break; + } + } + let mut model_b = request(""); + model_b.model = Some("model-b".into()); + model_b.reasoning = Some(ReasoningLevel::Low); + for (text, config) in [ + ("to-b", Some(model_b)), + ("back-to-a", Some(request(""))), + ("plain", None), + ] { + steer + .send(SteerMessage { + config: config.map(Box::new), + ..SteerMessage::text(text) + }) + .await + .unwrap(); + } + let mut completed = 0; + while completed < 4 { + let event = tokio::time::timeout(Duration::from_secs(10), stream.next()) + .await + .expect("event in time") + .expect("stream open") + .expect("stream event"); + if let AgentEvent::Done { status, error, .. } = &event { + assert_eq!(*status, DoneStatus::Completed, "{error:?}"); + completed += 1; + } + events.push(event); + } + let texts: Vec<&str> = events + .iter() + .filter_map(|e| match e { + AgentEvent::TextDelta { text } => Some(text.as_str()), + _ => None, + }) + .collect(); + assert_eq!(texts, ["working", "to-b", "back-to-a", "plain"]); +} + +#[tokio::test] +async fn a_stopped_turn_keeps_the_app_server_for_the_next_prompt() { + use zeron_harness::TurnControl; + let (mut controls, steer, _token) = controls("Yes"); + let turn = TurnControl::default(); + controls.turn = turn.clone(); + assert!(harness().stops_turn_in_place()); + let mut stream = harness() + .run(request("scenario:stop-in-place"), controls) + .await + .expect("run starts"); + async fn next_event( + stream: &mut futures::stream::BoxStream<'static, Result>, + ) -> Option { + tokio::time::timeout(Duration::from_secs(10), stream.next()) + .await + .expect("event in time") + .map(|r| r.expect("stream event")) + } + loop { + match next_event(&mut stream).await { + Some(AgentEvent::TextDelta { text }) if text == "working" => break, + Some(_) => {} + None => panic!("stream ended before the turn streamed"), + } + } + turn.stop_turn(); + loop { + match next_event(&mut stream).await { + Some(AgentEvent::Done { status, .. }) => { + assert_eq!(status, DoneStatus::Interrupted); + break; + } + Some(_) => {} + None => panic!("a stopped turn must not end the app-server's stream"), + } + } + steer.send(SteerMessage::text("carry on")).await.unwrap(); + let mut resumed = Vec::new(); + loop { + match next_event(&mut stream).await { + Some(done @ AgentEvent::Done { .. }) => { + resumed.push(done); + break; + } + Some(event) => resumed.push(event), + None => panic!("stream ended before the next turn finished"), + } + } + assert!(resumed.contains(&AgentEvent::TextDelta { + text: "resumed".into() + })); + assert!(matches!( + resumed.last(), + Some(AgentEvent::Done { + status: DoneStatus::Completed, + .. + }) + )); + drop(steer); + assert!(next_event(&mut stream).await.is_none()); +} + +#[tokio::test] +async fn idle_voice_runtime_has_no_initial_turn_and_preserves_mcp() { + let dir = tempfile::tempdir().unwrap(); + let fixture = + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/fake-codex-voice.py"); + let driver = CodexHarness::new() + .with_executable(fixture) + .with_graces(Duration::from_millis(10), Duration::from_millis(100)); + let mut req = request("must never be submitted"); + req.cwd = dir.path().display().to_string(); + req.mcp = Some(zeron_proto::McpServer { + name: "zeron".into(), + command: "zeron".into(), + args: vec!["mcp".into()], + env: Default::default(), + }); + let (mut controls, steer, token) = controls("Yes"); + let (voice, bridge, _events) = zeron_harness::codex::realtime::channel(); + controls.realtime = Some(bridge); + let mut stream = driver.start_idle(req, controls).await.unwrap(); + assert!(matches!( + tokio::time::timeout(Duration::from_secs(3), stream.next()) + .await + .unwrap() + .unwrap() + .unwrap(), + AgentEvent::SessionStarted { .. } + )); + let (reply, receive) = oneshot::channel(); + voice + .commands + .send(zeron_harness::codex::realtime::VoiceCommand::Start { + voice: None, + session_id: "fixture-voice".into(), + generation: 1, + reply, + }) + .await + .unwrap(); + assert_eq!( + receive.await.unwrap(), + Err(zeron_proto::voice::VoiceRejection::NativeRuntimeUnavailable) + ); + let calls = std::fs::read_to_string(dir.path().join("voice-wire.jsonl")).unwrap(); + assert!(!calls.contains("turn/start")); + assert!(!calls.contains("thread/realtime/start")); + assert!(calls.contains("mcp_servers")); + steer + .send(SteerMessage::text("text remains usable")) + .await + .unwrap(); + tokio::time::timeout(Duration::from_secs(3), async { + while let Some(event) = stream.next().await { + if matches!(event.unwrap(), AgentEvent::Done { .. }) { + break; + } + } + }) + .await + .unwrap(); + token.cancel(); + drop(stream); +} + +#[cfg(unix)] +#[tokio::test] +async fn external_voice_keeps_canonical_events_without_any_local_helper() { + use std::os::unix::fs::PermissionsExt; + use zeron_harness::codex::realtime::{VoiceCommand, channel}; + use zeron_proto::voice::{VoiceEvent, remote::Sdp}; + let dir = tempfile::tempdir().unwrap(); + let binary = dir.path().join("bin/codex"); + std::fs::create_dir_all(binary.parent().unwrap()).unwrap(); + std::fs::copy( + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/fake-codex-voice-native.py"), + &binary, + ) + .unwrap(); + std::fs::set_permissions(&binary, std::fs::Permissions::from_mode(0o755)).unwrap(); + let driver = CodexHarness::new().with_executable(binary); + let mut req = request(""); + req.cwd = dir.path().display().to_string(); + req.mcp = Some(zeron_proto::McpServer { + name: "zeron".into(), + command: "zeron".into(), + args: vec!["mcp".into()], + env: Default::default(), + }); + let (mut controls, _steer, token) = controls("Yes"); + let (voice, bridge, mut events) = channel(); + controls.realtime = Some(bridge); + let mut stream = driver.start_idle(req, controls).await.unwrap(); + assert!(matches!( + tokio::time::timeout(Duration::from_secs(3), stream.next()) + .await + .unwrap() + .unwrap() + .unwrap(), + AgentEvent::SessionStarted { .. } + )); + let pump = tokio::spawn(async move { while stream.next().await.is_some() {} }); + assert!(voice.probe_external().await.unwrap().available); + assert_eq!( + voice.probe().await.unwrap_err(), + zeron_proto::voice::VoiceRejection::NativeRuntimeUnavailable + ); + let (reply, answer) = oneshot::channel(); + voice + .commands + .send(VoiceCommand::External { + voice: None, + session_id: "external-session".into(), + generation: 1, + offer: Sdp::new("fixture-offer".into()).unwrap(), + reply, + }) + .await + .unwrap(); + assert_eq!( + tokio::time::timeout(Duration::from_secs(3), answer) + .await + .unwrap() + .unwrap() + .unwrap() + .expose(), + "fixture-answer" + ); + // Re-emission occurs after negotiation has completed, when there is no NativeHost. + std::fs::write(dir.path().join("replay-transcripts"), "true").unwrap(); + tokio::time::timeout(Duration::from_secs(3), async { + loop { + if let VoiceEvent::Final { transcript } = events.recv().await.unwrap() { + assert_eq!(transcript.session_id, "external-session"); + break; + } + } + }) + .await + .unwrap(); + voice.stop().await.unwrap(); + assert!(!dir.path().join("helper-wire.jsonl").exists()); + let wire = std::fs::read_to_string(dir.path().join("voice-wire.jsonl")).unwrap(); + assert!(wire.contains("mcp_servers")); + assert!(!wire.contains("appendAudio")); + token.cancel(); + pump.abort(); +} diff --git a/crates/harness/tests/cursor.rs b/crates/harness/tests/cursor.rs index 60a0c7d3dc..75a0394f9a 100644 --- a/crates/harness/tests/cursor.rs +++ b/crates/harness/tests/cursor.rs @@ -50,6 +50,7 @@ fn controls() -> (RunControls, mpsc::Sender, CancellationToken) { let (steer_tx, steer_rx) = mpsc::channel(8); let token = CancellationToken::new(); let controls = RunControls { + realtime: None, execution_lease: None, request_input: Box::new(move |_| { let (tx, rx) = oneshot::channel(); @@ -58,6 +59,7 @@ fn controls() -> (RunControls, mpsc::Sender, CancellationToken) { }), steering: steer_rx, interrupt: token.clone(), + turn: Default::default(), }; (controls, steer_tx, token) } @@ -187,6 +189,8 @@ async fn steer_after_done_becomes_the_next_turn() { .send(SteerMessage { prompt: "follow up".into(), message_id: None, + attachments: Vec::new(), + config: None, }) .await .expect("steer sent"); @@ -338,6 +342,8 @@ async fn followup_crash_is_not_hidden_by_a_previous_completed_turn() { .send(SteerMessage { prompt: "follow up".into(), message_id: None, + attachments: Vec::new(), + config: None, }) .await .unwrap(); @@ -393,6 +399,8 @@ async fn steering_spam_preserves_every_turn_in_order_and_closes_cleanly() { .send(SteerMessage { prompt: format!("ITEM-{n}"), message_id: None, + attachments: Vec::new(), + config: None, }) .await .unwrap(); @@ -460,6 +468,8 @@ async fn cancelling_a_native_steering_burst_stops_without_starting_another_turn( .send(SteerMessage { prompt: format!("LIVE-STEER-{n}"), message_id: None, + attachments: Vec::new(), + config: None, }) .await .unwrap(); diff --git a/crates/harness/tests/cursor_shim.rs b/crates/harness/tests/cursor_shim.rs index 45c4dba36b..e136dfdb60 100644 --- a/crates/harness/tests/cursor_shim.rs +++ b/crates/harness/tests/cursor_shim.rs @@ -778,3 +778,63 @@ async fn steer_during_text_preempts_the_run_and_continues_immediately() { assert_eq!(prompts.lines().count(), 2, "{prompts}"); assert!(prompts.lines().last().unwrap().contains("preempt-me")); } + +/// A model switch runs from its own message's send on the live agent: the +/// messages sent before it never ride the new model, and those after it do. +#[tokio::test] +async fn a_model_switch_names_its_model_from_its_own_send_on() { + use tokio::io::AsyncWriteExt; + let fixture = SessionFixture::new(); + let (mut child, mut stdin, mut lines) = fixture.start("first", false).await; + while frame(&mut lines).await["ev"] != "turn" {} + stdin + .write_all( + concat!( + "{\"op\":\"steer\",\"prompt\":\"plain-before\"}\n", + "{\"op\":\"steer\",\"prompt\":\"on-two\",\"reconfigure\":true,\"model\":\"m-2\",\"modelOptions\":{}}\n", + "{\"op\":\"steer\",\"prompt\":\"after\"}\n", + ) + .as_bytes(), + ) + .await + .unwrap(); + let mut steered = 0; + loop { + let frame = frame(&mut lines).await; + if frame["ev"] == "steered" { + steered += 1; + } + if frame["ev"] == "turn" && steered == 3 { + break; + } + } + finish(&mut child, stdin).await; + let store = + std::fs::read_to_string(fixture.dir.path().join("state/by-agent/agent-fixture")).unwrap(); + let dir = std::path::Path::new(store.trim()); + let prompts = std::fs::read_to_string(dir.join("prompts.ndjson")).unwrap(); + let models = std::fs::read_to_string(dir.join("models.ndjson")).unwrap_or_default(); + let named: Vec = models + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect(); + assert!(named.iter().all(|send| send["model"] == "m-2"), "{models}"); + assert!( + named + .iter() + .any(|send| send["prompt"].as_str().unwrap().contains("on-two")), + "the switching message ran on its model: {models}" + ); + assert!( + named + .iter() + .all(|send| !send["prompt"].as_str().unwrap().contains("plain-before")), + "an earlier message rode the new model: {models}" + ); + assert!( + prompts + .lines() + .any(|p| p.contains("plain-before") && !p.contains("on-two")), + "an earlier message was merged into the switch: {prompts}" + ); +} diff --git a/crates/harness/tests/fixtures/codex/voice-0.159.0.json b/crates/harness/tests/fixtures/codex/voice-0.159.0.json new file mode 100644 index 0000000000..acf2c48c4e --- /dev/null +++ b/crates/harness/tests/fixtures/codex/voice-0.159.0.json @@ -0,0 +1,463 @@ +{ + "version": "0.159.0", + "schemas": { + "ThreadRealtimeAppendAudioParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "ThreadRealtimeAudioChunk": { + "description": "EXPERIMENTAL - thread realtime audio chunk.", + "properties": { + "data": { + "type": "string" + }, + "itemId": { + "type": [ + "string", + "null" + ] + }, + "numChannels": { + "format": "uint16", + "minimum": 0.0, + "type": "integer" + }, + "sampleRate": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "samplesPerChannel": { + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + } + }, + "required": [ + "data", + "numChannels", + "sampleRate" + ], + "type": "object" + } + }, + "description": "EXPERIMENTAL - append audio input to thread realtime.", + "properties": { + "audio": { + "$ref": "#/definitions/ThreadRealtimeAudioChunk" + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "audio", + "threadId" + ], + "title": "ThreadRealtimeAppendAudioParams", + "type": "object" + }, + "ThreadRealtimeItemAddedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "EXPERIMENTAL - raw non-audio thread realtime item emitted by the backend.", + "properties": { + "item": true, + "threadId": { + "type": "string" + } + }, + "required": [ + "item", + "threadId" + ], + "title": "ThreadRealtimeItemAddedNotification", + "type": "object" + }, + "ThreadRealtimeOutputAudioDeltaNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "ThreadRealtimeAudioChunk": { + "description": "EXPERIMENTAL - thread realtime audio chunk.", + "properties": { + "data": { + "type": "string" + }, + "itemId": { + "type": [ + "string", + "null" + ] + }, + "numChannels": { + "format": "uint16", + "minimum": 0.0, + "type": "integer" + }, + "sampleRate": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "samplesPerChannel": { + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + } + }, + "required": [ + "data", + "numChannels", + "sampleRate" + ], + "type": "object" + } + }, + "description": "EXPERIMENTAL - streamed output audio emitted by thread realtime.", + "properties": { + "audio": { + "$ref": "#/definitions/ThreadRealtimeAudioChunk" + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "audio", + "threadId" + ], + "title": "ThreadRealtimeOutputAudioDeltaNotification", + "type": "object" + }, + "ThreadRealtimeStartParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "CodexResponseHandoffMode": { + "enum": [ + "thinking", + "commentary", + "bemTags" + ], + "type": "string" + }, + "ConversationTextRole": { + "enum": [ + "user", + "developer", + "assistant" + ], + "type": "string" + }, + "RealtimeConversationVersion": { + "enum": [ + "v1", + "v2", + "v3" + ], + "type": "string" + }, + "RealtimeOutputModality": { + "enum": [ + "text", + "audio" + ], + "type": "string" + }, + "RealtimeVoice": { + "enum": [ + "alloy", + "arbor", + "ash", + "ballad", + "breeze", + "cedar", + "coral", + "cove", + "echo", + "ember", + "juniper", + "maple", + "marin", + "sage", + "shimmer", + "sol", + "spruce", + "vale", + "verse" + ], + "type": "string" + }, + "ThreadRealtimeInitialItem": { + "description": "EXPERIMENTAL - role-bearing text item included when a realtime V3 session starts.", + "properties": { + "role": { + "$ref": "#/definitions/ConversationTextRole" + }, + "text": { + "type": "string" + } + }, + "required": [ + "role", + "text" + ], + "type": "object" + }, + "ThreadRealtimeStartTransport": { + "description": "EXPERIMENTAL - transport used by thread realtime.", + "oneOf": [ + { + "properties": { + "type": { + "enum": [ + "websocket" + ], + "title": "WebsocketThreadRealtimeStartTransportType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "WebsocketThreadRealtimeStartTransport", + "type": "object" + }, + { + "properties": { + "sdp": { + "description": "SDP offer generated by a WebRTC RTCPeerConnection after configuring audio and the realtime events data channel.", + "type": "string" + }, + "type": { + "enum": [ + "webrtc" + ], + "title": "WebrtcThreadRealtimeStartTransportType", + "type": "string" + } + }, + "required": [ + "sdp", + "type" + ], + "title": "WebrtcThreadRealtimeStartTransport", + "type": "object" + }, + { + "properties": { + "callId": { + "description": "Identifier of a realtime call already created and negotiated by the client.", + "type": "string" + }, + "type": { + "enum": [ + "existingCall" + ], + "title": "ExistingCallThreadRealtimeStartTransportType", + "type": "string" + } + }, + "required": [ + "callId", + "type" + ], + "title": "ExistingCallThreadRealtimeStartTransport", + "type": "object" + } + ] + } + }, + "description": "EXPERIMENTAL - start a thread-scoped realtime session.", + "properties": { + "backendReasoningStatus": { + "description": "Relays public reasoning summaries as quiet context for realtime V3 delegations. Defaults to false.", + "type": "boolean" + }, + "clientManagedHandoffs": { + "description": "Leaves Codex response handoffs to the client's explicit append calls instead of forwarding them automatically. Defaults to false.", + "type": [ + "boolean", + "null" + ] + }, + "codexResponseHandoffChannelPrefixes": { + "additionalProperties": { + "items": { + "type": "string" + }, + "type": "array" + }, + "description": "Overrides BEM channel prefixes by `analysis`, `commentary`, or `final`. Omitted channels retain their default uppercase bracketed prefixes.", + "type": [ + "object", + "null" + ] + }, + "codexResponseHandoffMode": { + "anyOf": [ + { + "$ref": "#/definitions/CodexResponseHandoffMode" + }, + { + "type": "null" + } + ], + "description": "Selects how automatic Codex responses are routed in Frameless Bidi sessions. Omitted values default to `thinking`. Realtime V1 and V2 ignore this setting." + }, + "codexResponseItemPrefix": { + "description": "Optional prefix added to automatic Codex response items when `codexResponsesAsItems` is true.", + "type": [ + "string", + "null" + ] + }, + "codexResponsesAsItems": { + "description": "Sends automatic Codex responses as realtime conversation items instead of handoff appends.", + "type": [ + "boolean", + "null" + ] + }, + "delegationAckFiller": { + "description": "Controls whether a realtime V3 delegation produces an acknowledgement filler. Omitted values preserve the Realtime API's default behavior.", + "type": [ + "boolean", + "null" + ] + }, + "flushTranscriptTailOnSessionEnd": { + "description": "Routes any transcript tail remaining at session end through Codex. Defaults to false. TODO: Remove this rollout knob once transcript-tail flushing is always enabled.", + "type": [ + "boolean", + "null" + ] + }, + "includeStartupContext": { + "description": "Set to false to start without Codex's startup context. Omitted or null includes it.", + "type": [ + "boolean", + "null" + ] + }, + "initialItems": { + "description": "Adds complete role-bearing text items to the initial Frameless Bidi session history. This is only supported by realtime V3 and is sent during session startup. Requests are limited to 128 items and 8,192 estimated text tokens in total.", + "items": { + "$ref": "#/definitions/ThreadRealtimeInitialItem" + }, + "type": [ + "array", + "null" + ] + }, + "model": { + "description": "Overrides the configured realtime model for this session only.", + "type": [ + "string", + "null" + ] + }, + "outputModality": { + "allOf": [ + { + "$ref": "#/definitions/RealtimeOutputModality" + } + ], + "description": "Selects text or audio output for the realtime session. Transport and voice stay independent so clients can choose how they connect separately from what the model emits." + }, + "prompt": { + "type": [ + "string", + "null" + ] + }, + "realtimeEndInstructions": { + "description": "Developer instructions given to the backing Codex model when this realtime session ends.", + "type": [ + "string", + "null" + ] + }, + "realtimeSessionId": { + "type": [ + "string", + "null" + ] + }, + "realtimeStartInstructions": { + "description": "Developer instructions given to the backing Codex model when this realtime session starts.", + "type": [ + "string", + "null" + ] + }, + "threadId": { + "type": "string" + }, + "transport": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadRealtimeStartTransport" + }, + { + "type": "null" + } + ] + }, + "version": { + "anyOf": [ + { + "$ref": "#/definitions/RealtimeConversationVersion" + }, + { + "type": "null" + } + ], + "description": "Overrides the configured realtime protocol version for this session only." + }, + "voice": { + "anyOf": [ + { + "$ref": "#/definitions/RealtimeVoice" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "outputModality", + "threadId" + ], + "title": "ThreadRealtimeStartParams", + "type": "object" + }, + "ThreadRealtimeTranscriptDoneNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "EXPERIMENTAL - final transcript text emitted when realtime completes a transcript part.", + "properties": { + "role": { + "type": "string" + }, + "text": { + "description": "Final complete text for the transcript part.", + "type": "string" + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "role", + "text", + "threadId" + ], + "title": "ThreadRealtimeTranscriptDoneNotification", + "type": "object" + } + } +} diff --git a/crates/harness/tests/fixtures/fake-acp.sh b/crates/harness/tests/fixtures/fake-acp.sh index 7aa10108cb..339a079446 100755 --- a/crates/harness/tests/fixtures/fake-acp.sh +++ b/crates/harness/tests/fixtures/fake-acp.sh @@ -200,6 +200,51 @@ case "$promptline" in emit "{\"id\":$pid,\"result\":{\"stopReason\":\"end_turn\"}}" ;; +*scenario:detached*) + # A command the agent starts in its own session (Devin does): not in the + # agent's process group, so a group kill alone leaves it running. + pidfile=$(printf '%s' "$promptline" | sed 's/.*scenario:detached \([^ "]*\).*/\1/') + python3 -c "import os,time; os.setsid(); open('$pidfile','w').write(str(os.getpid())); time.sleep(60)" & + while [ ! -s "$pidfile" ]; do sleep 0.05; done + update '{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"started"}}' + emit "{\"id\":$pid,\"result\":{\"stopReason\":\"end_turn\"}}" + cat >/dev/null + ;; + +*scenario:reconfigure*) + # Model switches: one sent between turns, one sent mid-turn. Each set must + # reach the session before the prompt it was sent with. + next_turn() { # reads 0..n sets then the prompt; leaves them in SETS / pid + SETS="" + read -r promptline || exit 1 + while has "$promptline" '"method":"session/set_config_option"'; do + emit "{\"id\":$(rid "$promptline"),\"result\":{}}" + SETS="$SETS $promptline" + read -r promptline || exit 1 + done + has "$promptline" '"method":"session/prompt"' || exit 1 + pid=$(rid "$promptline") + } + update '{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"turn1"}}' + emit "{\"id\":$pid,\"result\":{\"stopReason\":\"end_turn\"}}" + next_turn + if has "$SETS" '"value":"grok-4-fast"'; then + update '{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"on fast"}}' + else + update '{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"switch missing"}}' + fi + sleep 0.5 + emit "{\"id\":$pid,\"result\":{\"stopReason\":\"end_turn\"}}" + next_turn + if has "$SETS" '"value":"grok-4.5"'; then + update '{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"back on 4.5"}}' + else + update '{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"switch missing"}}' + fi + emit "{\"id\":$pid,\"result\":{\"stopReason\":\"end_turn\"}}" + cat >/dev/null + ;; + *scenario:happy*) update '{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"Hello"}}' update '{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"thinking"}}' @@ -268,6 +313,32 @@ case "$promptline" in fi ;; +*scenario:stop-steer*) + # "Send now" with a steer still in flight: the steering request is + # unanswered when the turn stop's session/cancel arrives, and answers + # promptRequired before the cancelled prompt settles. The cancelled steer + # must never run. + update '{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"working"}}' + read -r steerline || exit 1 + has "$steerline" '"method":"_session/steering"' || exit 1 + sid=$(rid "$steerline") + read -r cancel || exit 1 + has "$cancel" '"method":"session/cancel"' || exit 1 + emit "{\"id\":$sid,\"result\":{\"outcome\":\"promptRequired\"}}" + sleep 0.2 + emit "{\"id\":$pid,\"result\":{\"stopReason\":\"cancelled\"}}" + read -r next || exit 1 + has "$next" '"method":"session/prompt"' || exit 1 + nid=$(rid "$next") + if has "$next" 'cancelled steer'; then + update '{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"REPLAYED"}}' + else + update '{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"fresh"}}' + fi + emit "{\"id\":$nid,\"result\":{\"stopReason\":\"end_turn\"}}" + cat >/dev/null + ;; + *scenario:steer-race*) update '{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"first"}}' read -r steerline || exit 1 @@ -410,6 +481,20 @@ case "$promptline" in fi ;; +*scenario:stop-in-place*) + # A turn stop: session/cancel settles the prompt `cancelled`; the agent + # and its session take the next session/prompt. + update '{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"working"}}' + read -r intline || exit 1 + has "$intline" '"method":"session/cancel"' || exit 8 + emit "{\"id\":$pid,\"result\":{\"stopReason\":\"cancelled\"}}" + read -r nextline || exit 1 + has "$nextline" '"method":"session/prompt"' || exit 9 + update '{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"resumed"}}' + emit "{\"id\":$(rid "$nextline"),\"result\":{\"stopReason\":\"end_turn\"}}" + cat >/dev/null + ;; + *scenario:wedge*) update '{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"working"}}' # Ignore session/cancel entirely — forces the SIGTERM escalation path. diff --git a/crates/harness/tests/fixtures/fake-claude.sh b/crates/harness/tests/fixtures/fake-claude.sh index 940e013e60..b2655a4b12 100755 --- a/crates/harness/tests/fixtures/fake-claude.sh +++ b/crates/harness/tests/fixtures/fake-claude.sh @@ -8,8 +8,23 @@ # crates/harness/tests/claude.rs. read -r first || exit 1 +# A run opens with an `initialize` control request (the SDK handshake); the +# real CLI answers it before the first user line. (Command discovery sends +# its own initialize as the only line — matched by its scenario below.) +initialized=false +while :; do + case "$first" in + *'"request_id":"zeron_initialize"'*) + case "$first" in *'"perTaskStopAffordance":true'*) initialized=true ;; esac + printf '%s\n' '{"type":"control_response","response":{"subtype":"success","request_id":"zeron_initialize","response":{}}}' + read -r first || exit 1 + ;; + *) break ;; + esac +done emit() { printf '%s\n' "$1"; } +uuid_of() { printf '%s\n' "$1" | sed 's/.*"uuid":"\([^"]*\)".*/\1/'; } case "$first" in @@ -127,6 +142,184 @@ case "$first" in emit '{"type":"result","subtype":"success","result":"steered","errors":[],"usage":{"input_tokens":1,"output_tokens":1},"session_id":"sess-steer"}' ;; +*scenario:lifecycle-now-steer*) + # CLI 2.1.286, captured live: every uuid-tagged user line gets + # `command_lifecycle` frames. A `now` steer aborts the streaming turn + # (result, terminal_reason aborted_streaming), the first command is + # cancelled and the steer's own turn starts. That turn then runs a tool + # for longer than any quiet-time heuristic: no Done may land before the + # steered turn's own result. + fid=$(uuid_of "$first") + emit "{\"type\":\"command_lifecycle\",\"command_uuid\":\"$fid\",\"state\":\"queued\"}" + emit "{\"type\":\"command_lifecycle\",\"command_uuid\":\"$fid\",\"state\":\"started\"}" + emit '{"type":"system","subtype":"init","model":"claude-sonnet-5-5","tools":["Bash"],"cwd":"/tmp","session_id":"sess-lc"}' + emit "$first" + emit '{"type":"stream_event","parent_tool_use_id":null,"event":{"type":"content_block_delta","delta":{"type":"text_delta","text":"essay"}}}' + read -r steer || exit 1 + case "$steer" in *'"priority":"now"'*) ;; *) exit 9 ;; esac + sid=$(uuid_of "$steer") + emit "{\"type\":\"command_lifecycle\",\"command_uuid\":\"$sid\",\"state\":\"queued\"}" + emit '{"type":"result","subtype":"success","is_error":false,"terminal_reason":"aborted_streaming","result":"essay","errors":[],"usage":{"input_tokens":1,"output_tokens":1},"session_id":"sess-lc"}' + emit "{\"type\":\"command_lifecycle\",\"command_uuid\":\"$fid\",\"state\":\"cancelled\"}" + emit "{\"type\":\"command_lifecycle\",\"command_uuid\":\"$sid\",\"state\":\"started\"}" + emit '{"type":"system","subtype":"init","model":"claude-sonnet-5-5","tools":["Bash"],"cwd":"/tmp","session_id":"sess-lc"}' + emit "$steer" + emit '{"type":"assistant","parent_tool_use_id":null,"message":{"content":[{"type":"tool_use","id":"tool-sleep","name":"Bash","input":{"command":"sleep 8"}}]}}' + sleep "${FAKE_CLAUDE_TOOL_SECS:-6}" + emit '{"type":"user","parent_tool_use_id":null,"message":{"content":[{"type":"tool_result","tool_use_id":"tool-sleep","is_error":false}]}}' + emit '{"type":"stream_event","parent_tool_use_id":null,"event":{"type":"content_block_delta","delta":{"type":"text_delta","text":"DONE"}}}' + emit '{"type":"result","subtype":"success","is_error":false,"terminal_reason":"completed","result":"DONE","errors":[],"usage":{"input_tokens":1,"output_tokens":1},"session_id":"sess-lc"}' + emit "{\"type\":\"command_lifecycle\",\"command_uuid\":\"$sid\",\"state\":\"completed\"}" + ;; + +*scenario:lifecycle-queued-behind-end*) + # A message that reaches the CLI as its turn is finishing waits for that + # turn's result, then starts its own turn: the first result is a + # boundary, and the run's only Done is the queued message's. + fid=$(uuid_of "$first") + emit "{\"type\":\"command_lifecycle\",\"command_uuid\":\"$fid\",\"state\":\"started\"}" + emit '{"type":"system","subtype":"init","model":"claude-sonnet-5-5","tools":[],"cwd":"/tmp","session_id":"sess-q"}' + emit '{"type":"stream_event","parent_tool_use_id":null,"event":{"type":"content_block_delta","delta":{"type":"text_delta","text":"first answer"}}}' + read -r steer || exit 1 + sid=$(uuid_of "$steer") + emit "{\"type\":\"command_lifecycle\",\"command_uuid\":\"$sid\",\"state\":\"queued\"}" + emit '{"type":"result","subtype":"success","is_error":false,"terminal_reason":"completed","result":"first answer","errors":[],"usage":{"input_tokens":1,"output_tokens":1},"session_id":"sess-q"}' + emit "{\"type\":\"command_lifecycle\",\"command_uuid\":\"$fid\",\"state\":\"completed\"}" + sleep 1 + emit "{\"type\":\"command_lifecycle\",\"command_uuid\":\"$sid\",\"state\":\"started\"}" + emit "$steer" + emit '{"type":"stream_event","parent_tool_use_id":null,"event":{"type":"content_block_delta","delta":{"type":"text_delta","text":"second answer"}}}' + emit '{"type":"result","subtype":"success","is_error":false,"terminal_reason":"completed","result":"second answer","errors":[],"usage":{"input_tokens":1,"output_tokens":1},"session_id":"sess-q"}' + emit "{\"type\":\"command_lifecycle\",\"command_uuid\":\"$sid\",\"state\":\"completed\"}" + ;; + +*scenario:stop-held*) + # "Send now" while a turn's result is held for a queued steer: the CLI is + # slow to answer the stop, had already begun the steer's turn, and that + # turn's aborted result lands after the stop — before the replacement. + fid=$(uuid_of "$first") + emit "{\"type\":\"command_lifecycle\",\"command_uuid\":\"$fid\",\"state\":\"started\"}" + emit '{"type":"system","subtype":"init","model":"claude-sonnet-5-5","tools":[],"cwd":"/tmp","session_id":"sess-sh"}' + emit '{"type":"stream_event","parent_tool_use_id":null,"event":{"type":"content_block_delta","delta":{"type":"text_delta","text":"first answer"}}}' + read -r steer || exit 1 + sid=$(uuid_of "$steer") + emit "{\"type\":\"command_lifecycle\",\"command_uuid\":\"$sid\",\"state\":\"queued\"}" + emit '{"type":"result","subtype":"success","is_error":false,"result":"first answer","errors":[],"usage":{"input_tokens":1,"output_tokens":1},"session_id":"sess-sh"}' + emit "{\"type\":\"command_lifecycle\",\"command_uuid\":\"$fid\",\"state\":\"completed\"}" + read -r stop || exit 1 + case "$stop" in *'"subtype":"interrupt"'*) ;; *) exit 8 ;; esac + rid=$(printf '%s\n' "$stop" | sed 's/.*"request_id":"\([^"]*\)".*/\1/') + sleep 0.5 + emit "{\"type\":\"command_lifecycle\",\"command_uuid\":\"$sid\",\"state\":\"started\"}" + emit "{\"type\":\"control_response\",\"response\":{\"subtype\":\"success\",\"request_id\":\"$rid\",\"response\":{\"still_queued\":[]}}}" + emit '{"type":"result","subtype":"error_during_execution","errors":["[ede_diagnostic] result_type=user last_content_type=n/a stop_reason=tool_use"],"usage":{"input_tokens":1,"output_tokens":1},"session_id":"sess-sh"}' + emit "{\"type\":\"command_lifecycle\",\"command_uuid\":\"$sid\",\"state\":\"cancelled\"}" + read -r next || exit 1 + nid=$(uuid_of "$next") + emit "{\"type\":\"command_lifecycle\",\"command_uuid\":\"$nid\",\"state\":\"started\"}" + emit "$next" + emit '{"type":"stream_event","parent_tool_use_id":null,"event":{"type":"content_block_delta","delta":{"type":"text_delta","text":"replacement"}}}' + sleep 0.3 + emit '{"type":"result","subtype":"success","is_error":false,"result":"replacement","errors":[],"usage":{"input_tokens":1,"output_tokens":1},"session_id":"sess-sh"}' + emit "{\"type\":\"command_lifecycle\",\"command_uuid\":\"$nid\",\"state\":\"completed\"}" + cat >/dev/null + ;; + +*scenario:stop-unconfirmed-replacement*) + # A CLI that confirms nothing (no command_lifecycle, no replays): a turn is + # stopped, and the replacement answers within the stop's settle window. + # Its result is its own end, not the stopped turn reporting late. + emit '{"type":"system","subtype":"init","model":"claude-sonnet-5-5","tools":[],"cwd":"/tmp","session_id":"sess-su"}' + emit '{"type":"stream_event","parent_tool_use_id":null,"event":{"type":"content_block_delta","delta":{"type":"text_delta","text":"working"}}}' + read -r stop || exit 1 + case "$stop" in *'"subtype":"interrupt"'*) ;; *) exit 8 ;; esac + rid=$(printf '%s\n' "$stop" | sed 's/.*"request_id":"\([^"]*\)".*/\1/') + emit "{\"type\":\"control_response\",\"response\":{\"subtype\":\"success\",\"request_id\":\"$rid\",\"response\":{\"still_queued\":[]}}}" + emit '{"type":"result","subtype":"error_during_execution","errors":["[ede_diagnostic] result_type=user last_content_type=n/a stop_reason=tool_use"],"usage":{"input_tokens":1,"output_tokens":1},"session_id":"sess-su"}' + read -r next || exit 1 + emit '{"type":"stream_event","parent_tool_use_id":null,"event":{"type":"content_block_delta","delta":{"type":"text_delta","text":"new reply"}}}' + emit '{"type":"result","subtype":"success","is_error":false,"result":"new reply","errors":[],"usage":{"input_tokens":1,"output_tokens":1},"session_id":"sess-su"}' + cat >/dev/null + ;; + +*scenario:stop-late-duplicate*) + # The stopped turn reports a second, delayed result after its stop has + # settled, with no new prompt and no new content. + emit '{"type":"system","subtype":"init","model":"claude-sonnet-5-5","tools":[],"cwd":"/tmp","session_id":"sess-ld"}' + emit '{"type":"stream_event","parent_tool_use_id":null,"event":{"type":"content_block_delta","delta":{"type":"text_delta","text":"working"}}}' + read -r stop || exit 1 + case "$stop" in *'"subtype":"interrupt"'*) ;; *) exit 8 ;; esac + rid=$(printf '%s\n' "$stop" | sed 's/.*"request_id":"\([^"]*\)".*/\1/') + emit "{\"type\":\"control_response\",\"response\":{\"subtype\":\"success\",\"request_id\":\"$rid\",\"response\":{\"still_queued\":[]}}}" + emit '{"type":"result","subtype":"error_during_execution","errors":["[ede_diagnostic] result_type=user last_content_type=n/a stop_reason=tool_use"],"usage":{"input_tokens":1,"output_tokens":1},"session_id":"sess-ld"}' + sleep 0.5 + emit '{"type":"result","subtype":"error_during_execution","errors":["late duplicate"],"usage":{"input_tokens":7,"output_tokens":7},"session_id":"sess-ld"}' + cat >/dev/null + ;; + +*scenario:lifecycle-queued-cancelled*) + # A queued message cancelled before any turn takes it up: the result held + # for it was the turn's real end, released at once — no quiet timer. + fid=$(uuid_of "$first") + emit "{\"type\":\"command_lifecycle\",\"command_uuid\":\"$fid\",\"state\":\"started\"}" + emit '{"type":"system","subtype":"init","model":"claude-sonnet-5-5","tools":[],"cwd":"/tmp","session_id":"sess-qc"}' + read -r steer || exit 1 + sid=$(uuid_of "$steer") + emit "{\"type\":\"command_lifecycle\",\"command_uuid\":\"$sid\",\"state\":\"queued\"}" + emit '{"type":"result","subtype":"success","is_error":false,"result":"only","errors":[],"usage":{"input_tokens":1,"output_tokens":1},"session_id":"sess-qc"}' + emit "{\"type\":\"command_lifecycle\",\"command_uuid\":\"$fid\",\"state\":\"completed\"}" + emit "{\"type\":\"command_lifecycle\",\"command_uuid\":\"$sid\",\"state\":\"cancelled\"}" + exec sleep 30 + ;; + +*scenario:reconfigure*) + # A follow-up sent with another model and effort: the live process + # adopts them (`set_model`, then `apply_flag_settings`) before the prompt + # — the CLI reads stdin in order. Live-verified on 2.1.286. + emit '{"type":"system","subtype":"init","model":"claude-sonnet-5-5","tools":[],"cwd":"/tmp","session_id":"sess-rc"}' + emit '{"type":"result","subtype":"success","result":"first","errors":[],"usage":{"input_tokens":1,"output_tokens":1},"session_id":"sess-rc"}' + read -r model || exit 1 + case "$model" in *'"subtype":"set_model"'*) ;; *) exit 11 ;; esac + case "$model" in *'"model":"claude-haiku-4-5"'*) ;; *) exit 11 ;; esac + read -r flags || exit 1 + case "$flags" in *'"subtype":"apply_flag_settings"'*) ;; *) exit 12 ;; esac + case "$flags" in *'"effortLevel":"high"'*) ;; *) exit 13 ;; esac + case "$flags" in *'"fastMode":null'*) ;; *) exit 14 ;; esac + read -r prompt || exit 1 + case "$prompt" in *'"type":"user"'*) ;; *) exit 15 ;; esac + case "$prompt" in *'switched'*) ;; *) exit 15 ;; esac + emit '{"type":"system","subtype":"init","model":"claude-haiku-4-5","tools":[],"cwd":"/tmp","session_id":"sess-rc"}' + emit "$prompt" + emit '{"type":"stream_event","parent_tool_use_id":null,"event":{"type":"content_block_delta","delta":{"type":"text_delta","text":"on haiku"}}}' + emit '{"type":"result","subtype":"success","result":"on haiku","errors":[],"usage":{"input_tokens":1,"output_tokens":1},"session_id":"sess-rc"}' + # An unchanged configuration sends no control request at all. + read -r again || exit 1 + case "$again" in *'"type":"user"'*) ;; *) exit 16 ;; esac + case "$again" in *'same again'*) ;; *) exit 16 ;; esac + emit "$again" + emit '{"type":"result","subtype":"success","result":"same","errors":[],"usage":{"input_tokens":1,"output_tokens":1},"session_id":"sess-rc"}' + ;; + +*scenario:resume-killed-tasks*) + # Captured live on 2.1.286: `--resume` of a session whose background tasks + # died with its previous process. The CLI settles them with two empty + # `result` frames (num_turns 0) while the new prompt is still queued — + # they are not that prompt's end. + fid=$(uuid_of "$first") + emit '{"type":"system","subtype":"task_notification","task_id":"a-1","status":"stopped"}' + emit '{"type":"system","subtype":"task_notification","task_id":"b-1","status":"stopped"}' + emit "{\"type\":\"command_lifecycle\",\"command_uuid\":\"$fid\",\"state\":\"queued\"}" + emit '{"type":"system","subtype":"init","model":"claude-sonnet-5-5","tools":[],"cwd":"/tmp","session_id":"sess-rk"}' + emit '{"type":"result","subtype":"success","is_error":false,"num_turns":0,"result":"","errors":[],"usage":{"input_tokens":0,"output_tokens":0},"session_id":"sess-rk"}' + emit '{"type":"system","subtype":"init","model":"claude-sonnet-5-5","tools":[],"cwd":"/tmp","session_id":"sess-rk"}' + emit '{"type":"result","subtype":"success","is_error":false,"num_turns":0,"result":"","errors":[],"usage":{"input_tokens":0,"output_tokens":0},"session_id":"sess-rk"}' + emit "{\"type\":\"command_lifecycle\",\"command_uuid\":\"$fid\",\"state\":\"started\"}" + emit "$first" + emit '{"type":"stream_event","parent_tool_use_id":null,"event":{"type":"content_block_delta","delta":{"type":"text_delta","text":"PONG"}}}' + emit '{"type":"result","subtype":"success","is_error":false,"num_turns":1,"terminal_reason":"completed","result":"PONG","errors":[],"usage":{"input_tokens":1,"output_tokens":1},"session_id":"sess-rk"}' + emit "{\"type\":\"command_lifecycle\",\"command_uuid\":\"$fid\",\"state\":\"completed\"}" + ;; + *scenario:superseded-steers*) # CLI 2.1.280 with rapid `now` steers: the second interrupts the turn the # first started before it is replayed; only the last steer is replayed. @@ -152,6 +345,37 @@ case "$first" in exec sleep 30 ;; +*scenario:stop-in-place*) + # A turn stop is the `interrupt` control request with `cancel_queued`: the + # CLI ends the turn (an error_during_execution result carrying only its + # diagnostic) and keeps reading stdin. Background tasks survive because the + # run declared perTaskStopAffordance (live-verified 2.1.285). + [ "$initialized" = true ] || exit 7 + emit '{"type":"system","subtype":"init","model":"claude-fable-5","tools":["Bash"],"cwd":"/tmp","session_id":"sess-stop"}' + emit '{"type":"system","subtype":"background_tasks_changed","tasks":[{"task_id":"bg-1","task_type":"local_agent","description":"scout"}]}' + emit '{"type":"stream_event","parent_tool_use_id":null,"event":{"type":"content_block_delta","delta":{"type":"text_delta","text":"working"}}}' + read -r stop || exit 1 + case "$stop" in *'"subtype":"interrupt"'*) ;; *) exit 8 ;; esac + case "$stop" in *'"cancel_queued":true'*) ;; *) exit 8 ;; esac + rid=$(printf '%s\n' "$stop" | sed 's/.*"request_id":"\([^"]*\)".*/\1/') + emit "{\"type\":\"control_response\",\"response\":{\"subtype\":\"success\",\"request_id\":\"$rid\",\"response\":{\"still_queued\":[]}}}" + emit '{"type":"user","message":{"role":"user","content":[{"type":"text","text":"[Request interrupted by user]"}]},"parent_tool_use_id":null}' + emit '{"type":"result","subtype":"error_during_execution","errors":["[ede_diagnostic] result_type=user last_content_type=n/a stop_reason=tool_use"],"usage":{"input_tokens":1,"output_tokens":1},"session_id":"sess-stop"}' + # Same process, next turn. + read -r next || exit 1 + case "$next" in *'"type":"image"'*) img=yes ;; *) img=no ;; esac + emit "$next" + emit "{\"type\":\"stream_event\",\"parent_tool_use_id\":null,\"event\":{\"type\":\"content_block_delta\",\"delta\":{\"type\":\"text_delta\",\"text\":\"resumed:image=$img\"}}}" + emit '{"type":"system","subtype":"background_tasks_changed","tasks":[]}' + emit '{"type":"result","subtype":"success","result":"resumed","errors":[],"usage":{"input_tokens":1,"output_tokens":1},"session_id":"sess-stop"}' + # A stop between turns: answered, and no result follows. + read -r idle_stop || exit 1 + case "$idle_stop" in *'"subtype":"interrupt"'*) ;; *) exit 9 ;; esac + rid=$(printf '%s\n' "$idle_stop" | sed 's/.*"request_id":"\([^"]*\)".*/\1/') + emit "{\"type\":\"control_response\",\"response\":{\"subtype\":\"success\",\"request_id\":\"$rid\",\"response\":{\"still_queued\":[]}}}" + cat >/dev/null + ;; + *scenario:interrupt*) emit '{"type":"system","subtype":"init","model":"claude-fable-5","tools":[],"cwd":"/tmp","session_id":"sess-int"}' # Wedge without reading stdin — forces the SIGTERM escalation path. diff --git a/crates/harness/tests/fixtures/fake-codex-voice-host.py b/crates/harness/tests/fixtures/fake-codex-voice-host.py new file mode 100755 index 0000000000..acd8322c62 --- /dev/null +++ b/crates/harness/tests/fixtures/fake-codex-voice-host.py @@ -0,0 +1,42 @@ +#!/usr/bin/python3 +"""Offline Codex native helper. Length-prefixed controls; never opens hardware/network.""" +import json, pathlib, struct, sys, os, time +root=pathlib.Path(__file__).resolve().parents[3] +if sys.argv[1:] == ['--build-commit']: + print('fixture-build');sys.exit(0) +log=root/'helper-wire.jsonl' +(root/'helper.pid').write_text(str(os.getpid())) +def receive(): + header=sys.stdin.buffer.read(4) + if not header:return None + length=struct.unpack('>I',header)[0] + if length>128*1024:raise ValueError('overflow') + return json.loads(sys.stdin.buffer.read(length)) +def send(value): + payload=json.dumps(value).encode();sys.stdout.buffer.write(struct.pack('>I',len(payload))+payload);sys.stdout.buffer.flush() +try: + while (frame:=receive()) is not None: + with log.open('a') as out:out.write(json.dumps(frame)+'\n') + kind=frame['type'] + if kind=='hello':assert frame=={'type':'hello','protocol':1,'buildCommit':'fixture-build'};send({'type':'ready'}) + elif kind=='initializeRuntime':send({'type':'runtimeReady'}) + elif kind=='startTransport':send({'type':'offer','sdp':'fixture-offer'}) + elif kind=='applyAnswer':assert frame['sdp']=='fixture-answer';send({'type':'transportReady'}) + elif kind=='openDevices': + manifest=pathlib.Path(__file__).resolve().parents[1]/'manifest.json' + version=json.loads(manifest.read_text())['appVersion'] if manifest.exists() else '0.159.0' + expected={'type':'openDevices'} + if tuple(map(int,version.split('.'))) >= (0,161,0):expected['selection']={} + assert frame==expected + if (root/'helper-mode').exists() and (root/'helper-mode').read_text().strip()=='exit-open':sys.exit(25) + send({'type':'devicesOpened'}) + elif kind=='setAudioControls': + if (root/'helper-mode').exists() and (root/'helper-mode').read_text().strip()=='exit-controls':sys.exit(26) + if (root/'helper-mode').exists() and (root/'helper-mode').read_text().strip()=='stall-controls': + (root/'controls-stalled').write_text('true');time.sleep(60) + send({'type':'audioControlsApplied'}) + elif kind=='inspectAudio':send({'type':'audioState','state':{'microphonePeak':1024,'speakerPeak':0}}) + elif kind=='close':send({'type':'closed'});break + else:raise ValueError('unexpected command') +finally: + with log.open('a') as out:out.write('{"type":"exited"}\n') diff --git a/crates/harness/tests/fixtures/fake-codex-voice-native.py b/crates/harness/tests/fixtures/fake-codex-voice-native.py new file mode 100755 index 0000000000..5997468016 --- /dev/null +++ b/crates/harness/tests/fixtures/fake-codex-voice-native.py @@ -0,0 +1,69 @@ +#!/usr/bin/python3 +"""Offline native app-server with WebRTC signaling and canonical voice events.""" +import json, pathlib, sys, threading, time +root=pathlib.Path(__file__).resolve().parents[1] +session=None +account_initialized=False +thread='idle-thread' +wire_lock=threading.Lock() +def send(v): + with wire_lock:print(json.dumps(v),flush=True) +def notify(method,params):send({'method':method,'params':dict(threadId=thread,**params)}) +def identity_watch(): + while True: + if (root/'identity-updated').exists(): + (root/'identity-updated').unlink() + send({'method':'account/updated','params':{'authMode':'chatgpt'}}) + (root/'identity-notified').write_text('true') + if (root/'replay-transcripts').exists() and session: + (root/'replay-transcripts').unlink() + for role,item,text in [('user','u1','Make a local Codex task'),('assistant','a1','I can do that.')]: + notify('thread/realtime/item/completed',{'item':{'type':'transcriptSegment','id':item,'realtimeSessionId':session,'role':role,'text':text}}) + (root/'replay-sent').write_text('true') + time.sleep(0.01) +threading.Thread(target=identity_watch,daemon=True).start() +for line in sys.stdin: + frame=json.loads(line);method=frame.get('method') + with (root/'voice-wire.jsonl').open('a') as out:out.write(json.dumps(frame)+'\n') + if 'id' not in frame:continue + result={} + if method=='config/read': + result={'config':json.loads((root/'project-config').read_text()) if frame.get('params',{}).get('cwd') and (root/'project-config').exists() else {}} + elif method=='account/read': + if account_initialized and (root/'probe-delay').exists(): + (root/'probing').write_text('true') + while not (root/'probe-release').exists():time.sleep(0.01) + result={'account':{'type':(root/'account-mode').read_text().strip() if (root/'account-mode').exists() else 'chatgpt'}} + if not account_initialized: + # Native Codex announces its initial account before answering the + # first account/read, even when no login or account switch occurred. + send({'method':'account/updated','params':{'authMode':'chatgpt' if result['account']['type']=='chatgpt' else 'apikey','planType':None}}) + account_initialized=True + elif method=='account/rateLimits/read':result=json.loads((root/'rate-limits').read_text()) if (root/'rate-limits').exists() else {'ordinaryUsageAllowed':True} + elif method=='thread/realtime/listVoices':result={'voices':{'v1':['juniper','ember'],'v2':['alloy'],'defaultV1':'juniper','defaultV2':'alloy'}} + elif method in ('thread/start','thread/resume'):result={'thread':{'id':thread,'turns':[]},'cwd':frame['params'].get('cwd',str(root)),'modelProvider':(root/'thread-provider').read_text().strip() if (root/'thread-provider').exists() else 'openai'} + elif method=='turn/start':result={'turn':{'id':'text-turn'}} + elif method=='thread/realtime/start': + p=frame['params'];assert p['transport']=={'type':'webrtc','sdp':'fixture-offer'};assert p['version']=='v3';assert p['outputModality']=='audio';assert p['clientManagedHandoffs'] is False;assert p['includeStartupContext'] is True + assert 'Zeron MCP' in p['realtimeStartInstructions'] and p['initialItems']==[{'role':'developer','text':p['realtimeStartInstructions']}] + session=p['realtimeSessionId'] + elif method=='thread/realtime/appendAudio':raise RuntimeError('subscription voice must never append PCM') + send({'id':frame['id'],'result':result}) + if method=='thread/realtime/start': + notify('thread/realtime/started',{'realtimeSessionId':session,'version':'v3'}) + notify('thread/realtime/sdp',{'sdp':'fixture-answer'}) + for role,item,text in [('user','u1','Make a local Codex task'),('user','u1','Make a local Codex task'),('assistant','a1','I can do that.')]: + notify('thread/realtime/item/transcript/delta',{'itemId':item,'delta':text}) + notify('thread/realtime/item/completed',{'item':{'type':'transcriptSegment','id':item,'realtimeSessionId':session,'role':role,'text':text}}) + notify('thread/realtime/transcript/done',{'role':role,'text':text}) + if (root/'conversation-only').exists():continue + notify('turn/started',{'turn':{'id':'native-task'}}) + notify('item/agentMessage/delta',{'turnId':'native-task','itemId':'task-reply','delta':'Native task output'}) + notify('thread/realtime/item/completed',{'item':{'type':'bemItemPromoted','id':'promotion','realtimeSessionId':session,'turnId':'native-task','itemId':'task-reply','presentation':{'type':'wholeItem'}}}) + elif method=='thread/realtime/stop': + if (root/'spontaneous-close').exists():notify('thread/realtime/closed',{'reason':'transport_closed'}) + def closed(): + time.sleep(0.1 if (root/'delayed-close').exists() else 0) + notify('thread/realtime/closed',{'reason':'requested'}) + threading.Thread(target=closed,daemon=True).start() + elif method=='turn/start':notify('turn/completed',{'turn':{'id':'text-turn','status':'completed'}}) diff --git a/crates/harness/tests/fixtures/fake-codex-voice.py b/crates/harness/tests/fixtures/fake-codex-voice.py new file mode 100755 index 0000000000..43cac0045e --- /dev/null +++ b/crates/harness/tests/fixtures/fake-codex-voice.py @@ -0,0 +1,24 @@ +#!/usr/bin/env python3 +"""Offline app-server peer; any realtime start is a test failure.""" +import json +import pathlib +import sys +for line in sys.stdin: + frame = json.loads(line) + method = frame.get('method') + with pathlib.Path('voice-wire.jsonl').open('a') as log: + log.write(json.dumps(frame) + '\n') + if 'id' not in frame: + continue + result = {} + if method == 'config/read': + result = {'config': {}} + elif method in ('thread/start', 'thread/resume'): + result = {'thread': {'id': 'idle-thread', 'turns': []}} + elif method == 'turn/start': + result = {'turn': {'id': 'text-turn'}} + elif method == 'thread/realtime/start': + sys.exit(98) + print(json.dumps({'id': frame['id'], 'result': result}), flush=True) + if method == 'turn/start': + print(json.dumps({'method': 'turn/completed', 'params': {'threadId': 'idle-thread', 'turn': {'id':'text-turn', 'status':'completed'}}}), flush=True) diff --git a/crates/harness/tests/fixtures/fake-codex.sh b/crates/harness/tests/fixtures/fake-codex.sh index 96fadb09ad..62ea04a48e 100755 --- a/crates/harness/tests/fixtures/fake-codex.sh +++ b/crates/harness/tests/fixtures/fake-codex.sh @@ -26,6 +26,10 @@ has "$line" '"method":"initialized"' || exit 1 # ---- thread start / resume ------------------------------------------------- read -r line || exit 1 +if has "$line" '"method":"account/read"'; then + emit "{\"id\":$(rid "$line"),\"result\":{\"account\":null,\"requiresOpenaiAuth\":false}}" + read -r line || exit 1 +fi if has "$line" '"method":"config/read"'; then emit "{\"id\":$(rid "$line"),\"result\":{\"config\":{\"mcp_servers\":{\"test\":{\"enabled\":true}}}}}" read -r line || exit 1 @@ -285,6 +289,81 @@ case "$turnline" in ;; # NOTE: steer-race before steer — `case` takes the first matching glob. +*scenario:steer-item*) + # Live shape (0.159.3): an accepted turn/steer joins the turn only when + # the reply streaming ahead of it finishes — its userMessage item carries + # the clientUserMessageId it was sent with as `clientId`. + emit "{\"id\":$tid,\"result\":{\"turn\":{\"id\":\"t-1\"}}}" + emit '{"method":"turn/started","params":{"threadId":"th-1","turn":{"id":"t-1"}}}' + emit '{"method":"item/agentMessage/delta","params":{"threadId":"th-1","itemId":"m1","delta":"first"}}' + read -r steerline || exit 1 + has "$steerline" '"method":"turn/steer"' || exit 8 + cid=$(printf '%s' "$steerline" | sed 's/.*"clientUserMessageId":"\([^"]*\)".*/\1/') + [ -n "$cid" ] && [ "$cid" != "$steerline" ] || exit 8 + emit "{\"id\":$(rid "$steerline"),\"result\":{\"turnId\":\"t-1\"}}" + emit '{"method":"item/agentMessage/delta","params":{"threadId":"th-1","itemId":"m1","delta":"-still-first"}}' + emit '{"method":"item/completed","params":{"threadId":"th-1","item":{"type":"agentMessage","id":"m1","text":"first-still-first"}}}' + emit "{\"method\":\"item/started\",\"params\":{\"threadId\":\"th-1\",\"item\":{\"type\":\"userMessage\",\"id\":\"u2\",\"clientId\":\"$cid\",\"content\":[{\"type\":\"text\",\"text\":\"redirect please\"}]}}}" + emit '{"method":"item/agentMessage/delta","params":{"threadId":"th-1","itemId":"m2","delta":"steered"}}' + emit '{"method":"turn/completed","params":{"threadId":"th-1","turn":{"id":"t-1","status":"completed"}}}' + ;; + +*scenario:stale-completion*) + # A completion for a turn other than the one in flight must not settle it. + emit "{\"id\":$tid,\"result\":{\"turn\":{\"id\":\"t-1\"}}}" + emit '{"method":"turn/started","params":{"threadId":"th-1","turn":{"id":"t-1"}}}' + emit '{"method":"item/agentMessage/delta","params":{"threadId":"th-1","itemId":"m1","delta":"before"}}' + emit '{"method":"turn/completed","params":{"threadId":"th-1","turn":{"id":"t-zombie","status":"completed"}}}' + emit '{"method":"item/agentMessage/delta","params":{"threadId":"th-1","itemId":"m1","delta":"after"}}' + emit '{"method":"turn/completed","params":{"threadId":"th-1","turn":{"id":"t-1","status":"completed"}}}' + # A duplicate of the settled turn's completion is stale too. + emit '{"method":"turn/completed","params":{"threadId":"th-1","turn":{"id":"t-1","status":"completed"}}}' + ;; + +*scenario:stop-before-announce*) + # The turn/start response lands before the app-server registers the turn: + # an interrupt now is rejected ("no active turn to interrupt", live + # 0.159.3). The stop must wait for turn/started. + emit "{\"id\":$tid,\"result\":{\"turn\":{\"id\":\"t-1\"}}}" + emit '{"method":"item/agentMessage/delta","params":{"threadId":"th-1","itemId":"m1","delta":"working"}}' + if read -t 1 early; then + has "$early" '"method":"turn/interrupt"' && + emit "{\"id\":$(rid "$early"),\"error\":{\"code\":-32600,\"message\":\"no active turn to interrupt\"}}" + exec sleep 30 + fi + emit '{"method":"turn/started","params":{"threadId":"th-1","turn":{"id":"t-1"}}}' + read -r intline || exit 1 + has "$intline" '"method":"turn/interrupt"' && has "$intline" '"turnId":"t-1"' || exit 9 + emit "{\"id\":$(rid "$intline"),\"result\":{}}" + emit '{"method":"turn/completed","params":{"threadId":"th-1","turn":{"id":"t-1","status":"interrupted"}}}' + cat >/dev/null + ;; + +*scenario:background-commands*) + # A command still running after its turn is background work; an + # interrupted turn's foreground command is killed without item/completed + # (live, 0.159.3) and must not pin the runtime once its process is gone. + emit "{\"id\":$tid,\"result\":{\"turn\":{\"id\":\"t-1\"}}}" + emit '{"method":"turn/started","params":{"threadId":"th-1","turn":{"id":"t-1"}}}' + emit "{\"method\":\"item/started\",\"params\":{\"threadId\":\"th-1\",\"item\":{\"type\":\"commandExecution\",\"id\":\"bg\",\"command\":\"npm run dev\",\"processId\":\"$$\",\"status\":\"inProgress\"}}}" + emit '{"method":"turn/completed","params":{"threadId":"th-1","turn":{"id":"t-1","status":"completed"}}}' + read -r next || exit 1 + emit "{\"id\":$(rid "$next"),\"result\":{\"turn\":{\"id\":\"t-2\"}}}" + emit '{"method":"turn/started","params":{"threadId":"th-1","turn":{"id":"t-2"}}}' + emit '{"method":"item/started","params":{"threadId":"th-1","item":{"type":"commandExecution","id":"fg","command":"sleep 60","processId":"999999","status":"inProgress"}}}' + emit '{"method":"item/agentMessage/delta","params":{"threadId":"th-1","itemId":"m2","delta":"running"}}' + read -r intline || exit 1 + has "$intline" '"method":"turn/interrupt"' || exit 9 + emit "{\"id\":$(rid "$intline"),\"result\":{}}" + emit '{"method":"turn/completed","params":{"threadId":"th-1","turn":{"id":"t-2","status":"interrupted"}}}' + read -r last || exit 1 + emit "{\"id\":$(rid "$last"),\"result\":{\"turn\":{\"id\":\"t-3\"}}}" + emit '{"method":"turn/started","params":{"threadId":"th-1","turn":{"id":"t-3"}}}' + emit '{"method":"item/completed","params":{"threadId":"th-1","item":{"type":"commandExecution","id":"bg","status":"completed"}}}' + emit '{"method":"turn/completed","params":{"threadId":"th-1","turn":{"id":"t-3","status":"completed"}}}' + cat >/dev/null + ;; + *scenario:steer-race*) emit "{\"id\":$tid,\"result\":{\"turn\":{\"id\":\"t-1\"}}}" emit '{"method":"turn/started","params":{"turn":{"id":"t-1"}}}' @@ -376,6 +455,53 @@ case "$turnline" in fi ;; +*scenario:stop-in-place*) + # A turn stop: turn/interrupt ends the turn, the app-server stays up, and + # the next prompt is a turn/start on the same thread. + emit "{\"id\":$tid,\"result\":{\"turn\":{\"id\":\"t-1\"}}}" + emit '{"method":"turn/started","params":{"turn":{"id":"t-1"}}}' + emit '{"method":"item/agentMessage/delta","params":{"itemId":"m1","delta":"working"}}' + read -r intline || exit 1 + iid=$(rid "$intline") + has "$intline" '"method":"turn/interrupt"' && has "$intline" '"turnId":"t-1"' || exit 8 + emit "{\"id\":$iid,\"result\":{}}" + emit '{"method":"turn/completed","params":{"turn":{"id":"t-1","status":"interrupted"}}}' + read -r nextline || exit 1 + has "$nextline" '"method":"turn/start"' && has "$nextline" '"threadId":"th-1"' || exit 9 + emit "{\"id\":$(rid "$nextline"),\"result\":{\"turn\":{\"id\":\"t-2\"}}}" + emit '{"method":"turn/started","params":{"turn":{"id":"t-2"}}}' + emit '{"method":"item/agentMessage/delta","params":{"itemId":"m2","delta":"resumed"}}' + emit '{"method":"turn/completed","params":{"turn":{"id":"t-2","status":"completed"}}}' + cat >/dev/null + ;; + +*scenario:reconfigure*) + # Model changes sent mid-turn (A -> B -> A, then a plain follow-up): none + # may ride turn/steer, which carries no model. Each starts its own turn + # with the configuration it was sent with. + emit "{\"id\":$tid,\"result\":{\"turn\":{\"id\":\"t-1\"}}}" + emit '{"method":"turn/started","params":{"turn":{"id":"t-1"}}}' + emit '{"method":"item/agentMessage/delta","params":{"itemId":"m1","delta":"working"}}' + sleep 0.5 + emit '{"method":"turn/completed","params":{"turn":{"id":"t-1","status":"completed"}}}' + n=2 + for want in 'to-b|"model":"model-b"|"effort":"low"' \ + 'back-to-a|"model":"gpt-5.6-sol"|"effort":"ultra"' \ + 'plain|"model":"gpt-5.6-sol"|"effort":"ultra"'; do + read -r next || exit 1 + text=${want%%|*}; rest=${want#*|}; model=${rest%%|*}; effort=${rest#*|} + { has "$next" '"method":"turn/start"' && has "$next" "\"text\":\"$text\"" \ + && has "$next" "$model" && has "$next" "$effort"; } \ + || { fail_turn "$(rid "$next")" "turn $n: wanted $text with $model $effort"; exit 0; } + emit "{\"id\":$(rid "$next"),\"result\":{\"turn\":{\"id\":\"t-$n\"}}}" + emit "{\"method\":\"turn/started\",\"params\":{\"turn\":{\"id\":\"t-$n\"}}}" + emit "{\"method\":\"item/agentMessage/delta\",\"params\":{\"itemId\":\"m$n\",\"delta\":\"$text\"}}" + emit "{\"method\":\"turn/completed\",\"params\":{\"turn\":{\"id\":\"t-$n\",\"status\":\"completed\"}}}" + n=$((n + 1)) + done + cat >/dev/null + ;; + *scenario:wedge*) emit "{\"id\":$tid,\"result\":{\"turn\":{\"id\":\"t-1\"}}}" emit '{"method":"turn/started","params":{"turn":{"id":"t-1"}}}' diff --git a/crates/harness/tests/fixtures/fake-cursor-sdk.mjs b/crates/harness/tests/fixtures/fake-cursor-sdk.mjs index 77ddb8517c..47be3f16b5 100644 --- a/crates/harness/tests/fixtures/fake-cursor-sdk.mjs +++ b/crates/harness/tests/fixtures/fake-cursor-sdk.mjs @@ -18,6 +18,8 @@ export class JsonlLocalAgentStore { return fs.existsSync(log)?fs.readFileSync(log,'utf8').trim().split('\n').filter(Boolean).map(JSON.parse).filter(p=>!isUncheckpointed(p)):[]; }; this.logPrompt=(prompt)=>fs.appendFileSync(path.join(dir,'prompts.ndjson'),JSON.stringify(prompt)+'\n'); + // Sends that named a model (a model switch on the live agent). + this.logModel=(prompt,model)=>fs.appendFileSync(path.join(dir,'models.ndjson'),JSON.stringify({prompt,model})+'\n'); this.agents={ get:async()=>read().agent, create:async({agent})=>{const data=read();data.agent=agent;write(data);return agent;}, @@ -35,8 +37,9 @@ const checkpoint={schemaVersion:1,rootBlobId:'retained-conversation-history'}; function instance(store) { return { agentId:'agent-fixture',model:{id:'composer-2.5'},close(){}, - async send(prompt,{onDelta}) { + async send(prompt,{onDelta,model}) { store.logPrompt(prompt); + if(model) store.logModel(prompt,model.id); const doc=await store.agents.get({}); if(doc.activeRunId) throw new Error('Agent already has active run'); const runId='run-'+Date.now(); diff --git a/crates/harness/tests/fixtures/pi_rpc.rs b/crates/harness/tests/fixtures/pi_rpc.rs index 1380e946a9..1f7d151cd8 100644 --- a/crates/harness/tests/fixtures/pi_rpc.rs +++ b/crates/harness/tests/fixtures/pi_rpc.rs @@ -79,7 +79,7 @@ fn main() { ), "get_available_models" => response( &v, - json!({"models":[{"id":"mock","provider":"mock","name":"Mock","reasoning":true,"contextWindow":128000}]}), + json!({"models":[{"id":"mock","provider":"mock","name":"Mock","reasoning":true,"contextWindow":128000},{"id":"mock-2","provider":"mock","name":"Mock 2","reasoning":true,"contextWindow":128000}]}), ), "get_available_thinking_levels" => { response(&v, json!({"levels":["off","low","medium","high"]})) @@ -107,6 +107,9 @@ fn main() { if text == "env" { text = format!("env:{}", std::env::var_os("CLAUDECODE").is_some()); } + if text == "which-model" { + text = format!("{model}/{thinking}"); + } if text == "reject" { emit( json!({"type":"response","id":v["id"],"command":"prompt","success":false,"error":"preflight rejected"}), diff --git a/crates/harness/tests/managed_install.rs b/crates/harness/tests/managed_install.rs index 395926b550..93ff178267 100644 --- a/crates/harness/tests/managed_install.rs +++ b/crates/harness/tests/managed_install.rs @@ -29,10 +29,12 @@ async fn managed_install_reaches_session_started() { let (_steer_tx, steering) = mpsc::channel(1); let interrupt = CancellationToken::new(); let controls = RunControls { + realtime: None, execution_lease: None, request_input: Box::new(|_| tokio::sync::oneshot::channel().1), steering, interrupt: interrupt.clone(), + turn: Default::default(), }; let request = RunRequest { mcp: None, diff --git a/crates/harness/tests/managed_install_failure.rs b/crates/harness/tests/managed_install_failure.rs index 89a6df1d87..aa2dda590c 100644 --- a/crates/harness/tests/managed_install_failure.rs +++ b/crates/harness/tests/managed_install_failure.rs @@ -38,10 +38,12 @@ async fn silent_npm_enoent_death_surfaces_decoded_error() { let harness = AcpHarness::grok(); let (_steer_tx, steering) = mpsc::channel(1); let controls = RunControls { + realtime: None, execution_lease: None, request_input: Box::new(|_| tokio::sync::oneshot::channel().1), steering, interrupt: CancellationToken::new(), + turn: Default::default(), }; let request = RunRequest { mcp: None, diff --git a/crates/harness/tests/opencode.rs b/crates/harness/tests/opencode.rs index 0be238e9b9..d4059c2bc3 100644 --- a/crates/harness/tests/opencode.rs +++ b/crates/harness/tests/opencode.rs @@ -43,6 +43,8 @@ struct FakeOpencode { /// Leading 500s to answer `POST /session` with (the opencode /// lazy-migration crash class: first access 500s, retry succeeds). fail_session_creates: Arc>, + /// Answer `POST …/abort` with a 500 (a wedged server). + fail_aborts: Arc>, } impl FakeOpencode { @@ -62,6 +64,7 @@ impl FakeOpencode { )), first_prompt_had_subscriber: Arc::new(Mutex::new(None)), fail_session_creates: Arc::new(Mutex::new(0)), + fail_aborts: Arc::new(Mutex::new(false)), }; let accept = fake.clone(); tokio::spawn(async move { @@ -239,6 +242,10 @@ impl FakeOpencode { ("GET", "/session/ses_resume") => ("200 OK", json!({ "id": "ses_resume" })), ("GET", p) if p.starts_with("/session/") => ("404 Not Found", json!({})), ("POST", p) if p.ends_with("/prompt_async") => ("204 No Content", json!({})), + ("POST", p) if p.ends_with("/abort") && *self.fail_aborts.lock().unwrap() => ( + "500 Internal Server Error", + json!({ "name": "UnknownError", "data": { "message": "abort failed" } }), + ), ("POST", p) if p.ends_with("/abort") => ("200 OK", json!(true)), ("POST", p) if p.contains("/permission/") || p.contains("/question/") => { ("200 OK", json!(true)) @@ -274,6 +281,7 @@ fn controls() -> (RunControls, mpsc::Sender, CancellationToken) { let (steer_tx, steering) = mpsc::channel(8); let token = CancellationToken::new(); let controls = RunControls { + realtime: None, execution_lease: None, request_input: Box::new(move |questions| { let (tx, rx) = oneshot::channel(); @@ -289,6 +297,7 @@ fn controls() -> (RunControls, mpsc::Sender, CancellationToken) { }), steering, interrupt: token.clone(), + turn: Default::default(), }; (controls, steer_tx, token) } @@ -568,6 +577,8 @@ async fn steer_queues_mid_turn_and_delivers_at_idle() { .send(SteerMessage { prompt: "also do this".into(), message_id: None, + attachments: Vec::new(), + config: None, }) .await .unwrap(); @@ -622,6 +633,273 @@ async fn interrupt_aborts_and_settles_interrupted() { )); } +/// Start a run on `driver` with a turn stop wired in, and wait until its +/// first turn streams. +async fn stoppable_turn( + fake: &FakeOpencode, + driver: OpencodeHarness, +) -> ( + futures::stream::BoxStream<'static, Result>, + mpsc::Sender, + zeron_harness::TurnControl, +) { + let (mut controls, steer, _token) = controls(); + let turn = zeron_harness::TurnControl::default(); + controls.turn = turn.clone(); + assert!(driver.stops_turn_in_place()); + let mut stream = driver + .run(request("hi"), controls) + .await + .expect("run starts"); + let _ = next_event(&mut stream).await; + let _ = next_event(&mut stream).await; + assistant_message(fake, "ses_test", "msg_1"); + fake.emit(json!({ + "type": "message.part.updated", + "properties": { "part": { + "id": "prt_t", "messageID": "msg_1", "sessionID": "ses_test", + "type": "text", "text": "working", + }}, + })); + assert_eq!( + next_event(&mut stream).await, + AgentEvent::TextDelta { + text: "working".into() + } + ); + (stream, steer, turn) +} + +/// A stop the server never acknowledged must not leave the session taking +/// prompts: the turn may still be running. The runtime goes, its mailbox +/// closed before the stop reads settled. +async fn assert_torn_down( + stream: &mut futures::stream::BoxStream<'static, Result>, + steer: &mpsc::Sender, +) { + let events = drain_to_done(stream).await; + assert!( + matches!( + events.last(), + Some(AgentEvent::Done { + status: DoneStatus::Interrupted, + error: None, + .. + }) + ), + "{events:?}" + ); + assert!( + steer.is_closed(), + "the mailbox closes before the stop settles" + ); + let end = tokio::time::timeout(Duration::from_secs(5), stream.next()) + .await + .expect("stream ends"); + assert!(end.is_none(), "the runtime is gone: {end:?}"); +} + +#[tokio::test] +async fn a_stop_the_server_rejects_tears_the_runtime_down() { + let fake = FakeOpencode::start().await; + *fake.fail_aborts.lock().unwrap() = true; + let (mut stream, steer, turn) = stoppable_turn(&fake, harness(&fake)).await; + turn.stop_turn(); + wait_posts(&fake, "/session/ses_test/abort", 1).await; + assert_torn_down(&mut stream, &steer).await; +} + +#[tokio::test] +async fn a_stop_the_server_never_settles_tears_the_runtime_down() { + let fake = FakeOpencode::start().await; + let driver = harness(&fake).with_graces(Duration::from_millis(200), Duration::from_millis(100)); + let (mut stream, steer, turn) = stoppable_turn(&fake, driver).await; + turn.stop_turn(); + wait_posts(&fake, "/session/ses_test/abort", 1).await; + // Accepted, but no idle ever follows. + assert_torn_down(&mut stream, &steer).await; +} + +#[tokio::test] +async fn an_acknowledged_stop_drops_late_output_and_keeps_the_session() { + let fake = FakeOpencode::start().await; + let (mut stream, steer, turn) = stoppable_turn(&fake, harness(&fake)).await; + turn.stop_turn(); + wait_posts(&fake, "/session/ses_test/abort", 1).await; + // The stopped turn's output still in flight when the abort landed. + fake.emit(json!({ + "type": "message.part.updated", + "properties": { "part": { + "id": "prt_late", "messageID": "msg_1", "sessionID": "ses_test", + "type": "text", "text": "late", + }}, + })); + idle(&fake, "ses_test"); + let events = drain_to_done(&mut stream).await; + assert_eq!( + events, + vec![AgentEvent::Done { + status: DoneStatus::Interrupted, + result: None, + error: None, + session_id: Some("ses_test".into()), + }], + "the stopped turn's late output is dropped" + ); + + // The same session takes the next prompt. + steer + .send(SteerMessage::text("carry on")) + .await + .expect("the mailbox stays open"); + let prompts = wait_posts(&fake, "/session/ses_test/prompt_async", 2).await; + assert_eq!(prompts[1]["parts"][0]["text"], "carry on"); + assistant_message(&fake, "ses_test", "msg_2"); + idle(&fake, "ses_test"); + let events = drain_to_done(&mut stream).await; + assert!(matches!( + events.last(), + Some(AgentEvent::Done { + status: DoneStatus::Completed, + .. + }) + )); +} + +#[tokio::test] +async fn images_sent_mid_turn_ride_the_queued_prompt() { + let fake = FakeOpencode::start().await; + let (controls, steer, _token) = controls(); + let mut stream = harness(&fake) + .run(request("hi"), controls) + .await + .expect("run starts"); + let _ = next_event(&mut stream).await; + let _ = next_event(&mut stream).await; + assistant_message(&fake, "ses_test", "msg_1"); + // A running tool holds the steers for the turn's end (no preempt). + fake.emit(json!({ + "type": "message.part.updated", + "properties": { "part": { + "id": "prt_tool", "messageID": "msg_1", "sessionID": "ses_test", + "type": "tool", "tool": "bash", "callID": "call_1", + "state": { "status": "running", "input": { "command": "sleep 1" } }, + }}, + })); + let _ = next_event(&mut stream).await; // ToolStarted + for (text, image) in [("first", "/tmp/one.png"), ("second", "/tmp/two.png")] { + steer + .send(SteerMessage { + prompt: text.into(), + message_id: None, + attachments: vec![image.into()], + config: None, + }) + .await + .unwrap(); + } + tokio::time::sleep(Duration::from_millis(100)).await; + fake.emit(json!({ + "type": "message.part.updated", + "properties": { "part": { + "id": "prt_tool", "messageID": "msg_1", "sessionID": "ses_test", + "type": "tool", "tool": "bash", "callID": "call_1", + "state": { "status": "completed", "input": { "command": "sleep 1" }, "output": "" }, + }}, + })); + idle(&fake, "ses_test"); + + let prompts = wait_posts(&fake, "/session/ses_test/prompt_async", 2).await; + let parts = prompts[1]["parts"].as_array().expect("parts"); + let text = parts + .iter() + .find(|p| p["type"] == "text") + .and_then(|p| p["text"].as_str()) + .unwrap(); + assert!(text.contains("first") && text.contains("second"), "{text}"); + let images: Vec<&str> = parts + .iter() + .filter(|p| p["type"] == "file") + .filter_map(|p| p["url"].as_str()) + .collect(); + assert_eq!(images, ["file:///tmp/one.png", "file:///tmp/two.png"]); +} + +/// A model switch reaches the live server: from the next prompt between +/// turns, and mid-turn from the prompt of the message it was sent with — +/// never merged into a prompt sent with the previous model. +#[tokio::test] +async fn a_model_switch_rides_the_next_prompt() { + let fake = FakeOpencode::start().await; + let (controls, steer, _token) = controls(); + let mut opening = request("hi"); + opening.model = Some("prov/model-a".into()); + let driver = harness(&fake); + let mut switched = request(""); + switched.model = Some("prov/model-b".into()); + assert!(driver.reconfigures_in_place(&opening, &switched)); + let mut stream = driver + .run(opening.clone(), controls) + .await + .expect("run starts"); + let _ = next_event(&mut stream).await; + let _ = next_event(&mut stream).await; + assistant_message(&fake, "ses_test", "msg_1"); + idle(&fake, "ses_test"); + drain_to_done(&mut stream).await; + + let send = |text: &str, config: Option<&RunRequest>| SteerMessage { + config: config.cloned().map(Box::new), + ..SteerMessage::text(text) + }; + steer.send(send("on b", Some(&switched))).await.unwrap(); + wait_posts(&fake, "/session/ses_test/prompt_async", 2).await; + assistant_message(&fake, "ses_test", "msg_2"); + // A running tool holds what follows for the turn's end. + fake.emit(json!({ + "type": "message.part.updated", + "properties": { "part": { + "id": "prt_tool", "messageID": "msg_2", "sessionID": "ses_test", + "type": "tool", "tool": "bash", "callID": "call_1", + "state": { "status": "running", "input": { "command": "sleep 1" } }, + }}, + })); + steer.send(send("still b", None)).await.unwrap(); + steer.send(send("back on a", Some(&opening))).await.unwrap(); + tokio::time::sleep(Duration::from_millis(100)).await; + fake.emit(json!({ + "type": "message.part.updated", + "properties": { "part": { + "id": "prt_tool", "messageID": "msg_2", "sessionID": "ses_test", + "type": "tool", "tool": "bash", "callID": "call_1", + "state": { "status": "completed", "input": { "command": "sleep 1" }, "output": "" }, + }}, + })); + idle(&fake, "ses_test"); + wait_posts(&fake, "/session/ses_test/prompt_async", 3).await; + assistant_message(&fake, "ses_test", "msg_3"); + idle(&fake, "ses_test"); + let prompts = wait_posts(&fake, "/session/ses_test/prompt_async", 4).await; + let sent: Vec<(String, String)> = prompts + .iter() + .map(|p| { + ( + p["parts"][0]["text"].as_str().unwrap_or("").to_owned(), + p["model"]["modelID"].as_str().unwrap_or("").to_owned(), + ) + }) + .collect(); + assert_eq!( + sent, + [ + ("hi".to_owned(), "model-a".to_owned()), + ("on b".to_owned(), "model-b".to_owned()), + ("still b".to_owned(), "model-b".to_owned()), + ("back on a".to_owned(), "model-a".to_owned()), + ] + ); +} + #[tokio::test] async fn provider_retries_surface_and_cap_out() { let fake = FakeOpencode::start().await; diff --git a/crates/harness/tests/pi_live.rs b/crates/harness/tests/pi_live.rs index 872fbb7bd0..8c8a4866d1 100644 --- a/crates/harness/tests/pi_live.rs +++ b/crates/harness/tests/pi_live.rs @@ -68,6 +68,7 @@ async fn real_pi_mock_lifecycle() { let (steer, steering) = mpsc::channel(8); let interrupt = CancellationToken::new(); let controls = RunControls { + realtime: None, execution_lease: None, steering, interrupt: interrupt.clone(), @@ -80,6 +81,7 @@ async fn real_pi_mock_lifecycle() { .unwrap(); rx }), + turn: Default::default(), }; let request = RunRequest { prompt: prompt.into(), @@ -118,6 +120,8 @@ async fn real_pi_mock_lifecycle() { .send(SteerMessage { prompt: "redirect".into(), message_id: None, + attachments: Vec::new(), + config: None, }) .await .unwrap(); @@ -173,10 +177,12 @@ async fn real_pi_mock_lifecycle() { // continues in a new session and says so instead of failing every message. let (_, steering) = mpsc::channel(1); let controls = RunControls { + realtime: None, execution_lease: None, steering, interrupt: CancellationToken::new(), request_input: Box::new(|_| oneshot::channel().1), + turn: Default::default(), }; let request = RunRequest { prompt: "after loss".into(), @@ -244,10 +250,12 @@ async fn real_pi_steering_bursts_share_the_next_model_call() { let cwd = dir.path(); let (tx, steering) = mpsc::channel(8); let controls = RunControls { + realtime: None, execution_lease: None, steering, interrupt: CancellationToken::new(), request_input: Box::new(|_| oneshot::channel().1), + turn: Default::default(), }; let request = RunRequest { prompt: "burst hold".into(), @@ -275,6 +283,8 @@ async fn real_pi_steering_bursts_share_the_next_model_call() { tx.send(SteerMessage { prompt: prompt.clone(), message_id: Some(format!("burst-user-{i}")), + attachments: Vec::new(), + config: None, }) .await .unwrap(); @@ -295,12 +305,7 @@ async fn real_pi_steering_bursts_share_the_next_model_call() { // step, rather than being claimed as part of the already-running call. let late = vec!["late-1", "late-2", "late-3"]; for prompt in &late { - tx.send(SteerMessage { - prompt: (*prompt).into(), - message_id: None, - }) - .await - .unwrap(); + tx.send(SteerMessage::text(*prompt)).await.unwrap(); } drop(tx); wait_probe_lines(&inputs, burst.len() + late.len()).await; diff --git a/crates/harness/tests/pi_rpc.rs b/crates/harness/tests/pi_rpc.rs index 1ed3277747..ab9b5d7ea3 100644 --- a/crates/harness/tests/pi_rpc.rs +++ b/crates/harness/tests/pi_rpc.rs @@ -32,6 +32,7 @@ fn controls() -> (RunControls, mpsc::Sender, CancellationToken) { let token = CancellationToken::new(); ( RunControls { + realtime: None, execution_lease: None, steering: rx, interrupt: token.clone(), @@ -40,6 +41,7 @@ fn controls() -> (RunControls, mpsc::Sender, CancellationToken) { let _ = tx.send(vec![]); rx }), + turn: Default::default(), }, tx, token, @@ -214,6 +216,8 @@ async fn steers_confirm_on_consumption_and_interrupt_is_terminal_once() { tx.send(SteerMessage { prompt: "redirect".into(), message_id: Some("user-id".into()), + attachments: Vec::new(), + config: None, }) .await .unwrap(); @@ -262,6 +266,8 @@ async fn idle_mailbox_starts_another_turn_without_restarting_process() { tx.send(SteerMessage { prompt: "second".into(), message_id: None, + attachments: Vec::new(), + config: None, }) .await .unwrap(); @@ -509,6 +515,8 @@ async fn steering_burst_reaches_one_model_step_and_confirms_each_message_on_cons tx.send(SteerMessage { prompt: prompt.clone(), message_id: Some(format!("user-{i}")), + attachments: Vec::new(), + config: None, }) .await .unwrap(); @@ -588,6 +596,8 @@ async fn handled_input_between_steers_keeps_its_own_delivery_receipt() { tx.send(SteerMessage { prompt: prompt.into(), message_id: None, + attachments: Vec::new(), + config: None, }) .await .unwrap(); @@ -726,6 +736,8 @@ async fn late_extension_notifications_do_not_reopen_completed_turns() { tx.send(SteerMessage { prompt: "next".into(), message_id: None, + attachments: Vec::new(), + config: None, }) .await .unwrap(); @@ -748,3 +760,49 @@ async fn late_extension_notifications_do_not_reopen_completed_turns() { 1 ); } + +/// A model or thinking switch reaches the live process before the message +/// it was sent with: no new process, no lost session. +#[tokio::test] +async fn a_model_switch_applies_to_the_live_process() { + let dir = tempfile::tempdir().unwrap(); + let (c, tx, _) = controls(); + let mut opening = request(dir.path(), "which-model"); + opening.model = Some("mock/mock".into()); + let mut switched = opening.clone(); + switched.model = Some("mock/mock-2".into()); + switched.reasoning = Some(zeron_proto::ReasoningLevel::High); + let driver = harness().with_session_store(dir.path().join("index")); + assert!(driver.reconfigures_in_place(&opening, &switched)); + let mut stream = driver.run(opening, c).await.unwrap(); + let mut text = String::new(); + let mut dones = 0; + let mut started = 0; + while dones < 2 { + let event = tokio::time::timeout(Duration::from_secs(5), stream.next()) + .await + .unwrap() + .unwrap() + .unwrap(); + match event { + AgentEvent::SessionStarted { .. } => started += 1, + AgentEvent::TextDelta { text: delta } => text.push_str(&delta), + AgentEvent::Done { .. } => { + dones += 1; + text.push('|'); + if dones == 1 { + tx.send(SteerMessage { + config: Some(Box::new(switched.clone())), + ..SteerMessage::text("which-model") + }) + .await + .unwrap(); + } + } + _ => {} + } + } + assert_eq!(started, 1); + assert!(text.contains("reply:mock/medium|"), "{text}"); + assert!(text.contains("reply:mock-2/high|"), "{text}"); +} diff --git a/crates/harness/tests/real_acp_lifecycle.rs b/crates/harness/tests/real_acp_lifecycle.rs index 7dd779e624..8492da3c26 100644 --- a/crates/harness/tests/real_acp_lifecycle.rs +++ b/crates/harness/tests/real_acp_lifecycle.rs @@ -14,6 +14,7 @@ async fn live_run(cancel: bool) { let (steer, steering) = mpsc::channel(8); let interrupt = CancellationToken::new(); let controls = RunControls { + realtime: None, execution_lease: None, steering, interrupt: interrupt.clone(), @@ -22,6 +23,7 @@ async fn live_run(cancel: bool) { let _ = tx.send(Vec::new()); rx }), + turn: Default::default(), }; let request = RunRequest { mcp: None, @@ -58,7 +60,7 @@ async fn live_run(cancel: bool) { // Both messages must queue until the slow original prompt // responds. The real adapter rejects overlapping prompts. for word in ["SECOND-DONE", "THIRD-DONE"] { - steer.send(SteerMessage { prompt: format!("Do not call tools. Reply exactly {word}."), message_id: None }).await.unwrap(); + steer.send(SteerMessage { prompt: format!("Do not call tools. Reply exactly {word}."), message_id: None, attachments: Vec::new(), config: None }).await.unwrap(); } } AgentEvent::TextDelta { text: delta } => text.push_str(&delta), diff --git a/crates/harness/tests/real_quiet_survey.rs b/crates/harness/tests/real_quiet_survey.rs index 9d674af508..4bfb909528 100644 --- a/crates/harness/tests/real_quiet_survey.rs +++ b/crates/harness/tests/real_quiet_survey.rs @@ -22,6 +22,7 @@ fn controls() -> (RunControls, mpsc::Sender, CancellationToken) { let (steer_tx, steer_rx) = mpsc::channel(8); let token = CancellationToken::new(); let controls = RunControls { + realtime: None, execution_lease: None, request_input: Box::new(move |questions: Vec| { let (tx, rx) = oneshot::channel(); @@ -37,6 +38,7 @@ fn controls() -> (RunControls, mpsc::Sender, CancellationToken) { }), steering: steer_rx, interrupt: token.clone(), + turn: Default::default(), }; (controls, steer_tx, token) } diff --git a/crates/harness/tests/windows_native.rs b/crates/harness/tests/windows_native.rs index 26e7e5fa10..86eed5ff13 100644 --- a/crates/harness/tests/windows_native.rs +++ b/crates/harness/tests/windows_native.rs @@ -226,6 +226,7 @@ async fn exercise(prompt: &str, resume: Option<&str>) { let (steer_tx, steering) = mpsc::channel(4); let interrupt = CancellationToken::new(); let controls = RunControls { + realtime: None, execution_lease: None, request_input: Box::new(|_| { let (tx, rx) = oneshot::channel(); @@ -234,6 +235,7 @@ async fn exercise(prompt: &str, resume: Option<&str>) { }), steering, interrupt: interrupt.clone(), + turn: Default::default(), }; let operation = async { let mut stream = harness @@ -320,6 +322,7 @@ async fn exercise_tree(prompt: &str, drop_stream: bool) { let (_steer, steering) = mpsc::channel(1); let interrupt = CancellationToken::new(); let controls = RunControls { + realtime: None, execution_lease: None, request_input: Box::new(|_| { let (_, rx) = oneshot::channel(); @@ -327,6 +330,7 @@ async fn exercise_tree(prompt: &str, drop_stream: bool) { }), steering, interrupt: interrupt.clone(), + turn: Default::default(), }; let mut stream = harness .run(request(dir.path(), prompt, None), controls) @@ -479,6 +483,7 @@ async fn batch_overrides_launch_through_cmd() { unsafe { std::env::set_var("ZERON_TEST_BATCH_ARGS_FILE", &received) }; let (_steer, steering) = mpsc::channel(1); let controls = RunControls { + realtime: None, execution_lease: None, request_input: Box::new(|_| { let (tx, rx) = oneshot::channel(); @@ -487,6 +492,7 @@ async fn batch_overrides_launch_through_cmd() { }), steering, interrupt: CancellationToken::new(), + turn: Default::default(), }; // Consume the whole stream: the shim cannot speak any agent protocol, // so the run must fail loudly — but only AFTER a safe launch. diff --git a/crates/mcp/src/jsonrpc.rs b/crates/mcp/src/jsonrpc.rs index d43963d285..86cdd751ec 100644 --- a/crates/mcp/src/jsonrpc.rs +++ b/crates/mcp/src/jsonrpc.rs @@ -21,8 +21,8 @@ const PROTOCOL_VERSIONS: [&str; 3] = ["2025-06-18", "2025-03-26", "2024-11-05"]; const LATEST_PROTOCOL: &str = "2025-06-18"; const INSTRUCTIONS: &str = "\ -Zeron runs coding agents in chats, each hosted on a device inside a project \ -(a folder on that device). These tools operate the local Zeron engine: \ +Zeron runs coding agents in chats, each hosted on a device, optionally in a project \ +(a folder on that specific device). These tools operate the local Zeron engine: \ discover devices/projects/chats, create chats with a chosen harness and \ model, read transcripts, and send messages between chats.\n\ \n\ @@ -35,6 +35,14 @@ For parallel delegation, use `create_chats` with a prompt for each chat or \ even if your harness executes tool calls sequentially. Alternatively, launch ALL \ chats/messages with `wait: false` first, then use `wait_for_turn` to collect replies. \ Do not wait for one worker before launching the next independent worker. \ +Use `kind: chat` in `create_chat` or each `create_chats` request for a standalone \ +session visible in Sessions; it cannot have a parent. Use `kind: side` for a child \ +with an explicit parent or your origin chat. Omitted kind keeps legacy defaults: \ +side with an origin/parent, standalone otherwise. Side chats cannot create chats. \ +Discover hosts with `list_devices`, then `list_projects {device}`, \ +`list_harnesses {device}` and `list_models {device, harness}`. A project fixes \ +its host; with both project and device it must belong to that device. Use listed \ +ids when names/paths repeat. Without project or device, creation uses the local engine. \ If a chat is `awaitingInput`, answer it with `respond_to_input`."; const PARSE_ERROR: i64 = -32700; diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 4cfbd8bf9a..5262d854e7 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -6,9 +6,11 @@ //! deliver a message to a chat in this state" choice the composer makes //! for humans. +use std::collections::HashMap; use std::sync::Arc; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use std::time::{Duration, Instant}; +use anyhow::Context; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use zeron_doc::SessionCommandPayload; @@ -38,6 +40,9 @@ pub struct ToolDef { pub struct Tools { zeron: Arc, + // Remember successful sends on this MCP connection so wait_for_turn after + // wait:false also waits for a newly created chat with no session row yet. + pending_turns: tokio::sync::Mutex>>, } fn chat_key_schema(extra: Value) -> Value { @@ -55,6 +60,10 @@ fn chat_key_schema(extra: Value) -> Value { json!({ "type": "object", "properties": properties, "required": ["chat"] }) } +fn device_schema() -> Value { + json!({ "type": "string", "description": "Host device id or exact name. Omit for the local engine." }) +} + fn catalog() -> Vec { let mut tools = vec![ ToolDef { @@ -69,21 +78,22 @@ fn catalog() -> Vec { }, ToolDef { name: "list_projects", - description: "Projects: a folder on a device. Each chat belongs to one project, which fixes its host device and working directory.", - input_schema: json!({ "type": "object", "properties": {} }), + description: "Projects: folders on specific devices. Filter by device (id or name); omit for all projects. Use ids when names or paths repeat.", + input_schema: json!({ "type": "object", "properties": { "device": device_schema() } }), }, ToolDef { name: "list_harnesses", - description: "Agent harnesses (claude-code, codex, cursor, …) and whether each is available on this device.", - input_schema: json!({ "type": "object", "properties": {} }), + description: "Agent harnesses (claude-code, codex, cursor, …) available on the chosen device. Omit device for the local engine.", + input_schema: json!({ "type": "object", "properties": { "device": device_schema() } }), }, ToolDef { name: "list_models", - description: "Models a harness offers on this device. Model ids are harness-specific strings; pass one to create_chat.", + description: "Models a harness offers on the chosen device (local engine when omitted). Model ids are harness-specific strings; pass one to create_chat.", input_schema: json!({ "type": "object", "properties": { - "harness": { "type": "string", "description": "Harness id, e.g. claude-code or codex." } + "harness": { "type": "string", "description": "Harness id, e.g. claude-code or codex." }, + "device": device_schema() }, "required": ["harness"] }), @@ -109,15 +119,16 @@ fn catalog() -> Vec { }, ToolDef { name: "create_chat", - description: "Create a chat in a project (or project-less on a device) with a harness and model. The new chat records your chat as its parent (parentChatId). Optionally send a first prompt and wait for the reply. Returns the new chat id. For parallel delegation use create_chats, or leave wait=false on every launch and wait only after all chats have been started.", + description: "Create a standalone session visible in Sessions with kind chat, or a child with kind side. Omitted kind preserves the default: side with an origin/parent, standalone otherwise. A side chat cannot create chats. Select a project and/or device; catalogs are validated on that host. Optionally send a first prompt and wait for the reply. Returns the new chat id. For parallel delegation use create_chats, or leave wait=false on every launch and wait only after all chats have been started.", input_schema: json!({ "type": "object", "properties": { - "project": { "type": "string", "description": "Project id, path, or name. Required unless device is given." }, - "device": { "type": "string", "description": "Host device (id or name) for a project-less chat; defaults to this device." }, + "kind": { "type": "string", "enum": ["chat", "side"], "description": "chat: standalone, no parent allowed. side: requires parent or origin. Omit for legacy defaults." }, + "project": { "type": "string", "description": "Project id, path, or name. Belongs to a specific device; when device is supplied, search only there. Optional." }, + "device": { "type": "string", "description": "Host device (id or name). With project, it must own that project. Without either, use the local engine." }, "parent": { "type": "string", "description": "Parent chat to record (id, prefix, or title). Defaults to the chat you are speaking from." }, - "harness": { "type": "string", "description": "Harness id (see list_harnesses). Defaults to claude-code when available." }, - "model": { "type": "string", "description": "Model id from list_models. Omit for the harness default." }, + "harness": { "type": "string", "description": "Harness id (see list_harnesses with the chosen device). Defaults to claude-code when available." }, + "model": { "type": "string", "description": "Model id from list_models on the chosen device. Omit for the harness default." }, "reasoning": { "type": "string", "description": "Reasoning level the model supports (e.g. low, medium, high, max)." }, "sandbox": { "type": "string", "enum": ["read-only", "workspace-write", "danger-full-access"], "default": "workspace-write" }, "title": { "type": "string", "description": "Sidebar title. Otherwise the engine titles it from the first exchange." }, @@ -191,7 +202,7 @@ fn catalog() -> Vec { ( "create_chats", "create_chat", - "Create multiple independent side chats concurrently. Put each chat's prompt in its request to start all work together. Prefer this for parallel delegation, including harnesses that execute tool calls sequentially. Each request has create_chat arguments; wait defaults to false. Results preserve request order and include per-request errors; successful requests are not rolled back.", + "Create multiple standalone or side chats concurrently (kind per request). Put each chat's prompt in its request to start all work together. Prefer this for parallel delegation, including harnesses that execute tool calls sequentially. Each request has create_chat arguments; wait defaults to false. Results preserve request order and include per-request errors; successful requests are not rolled back.", ), ( "send_messages", @@ -232,6 +243,7 @@ struct ChatArgs { #[derive(Deserialize)] struct ListModelsArgs { harness: String, + device: Option, } #[derive(Deserialize, Default)] @@ -244,8 +256,35 @@ struct ListChatsArgs { limit: Option, } +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +enum ChatKind { + Chat, + Side, +} + +#[derive(Deserialize, Default)] +struct DeviceArgs { + device: Option, +} + +/// Messages already in the transcript before a send. Replies are matched by +/// id, not by time: `createdAt` is stamped on the host's clock, which can run +/// behind the caller's when the chat lives on another device. +#[derive(Clone, Default)] +struct TurnStart { + message_ids: Vec, +} + +#[derive(Clone)] +struct PendingTurn { + baseline: Option, + start: TurnStart, +} + #[derive(Deserialize, Default)] struct CreateChatArgs { + kind: Option, project: Option, device: Option, parent: Option, @@ -319,9 +358,10 @@ fn wait_duration(secs: Option) -> Duration { .min(MAX_WAIT) } +#[cfg(test)] fn now_millis() -> i64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_millis() as i64) .unwrap_or_default() } @@ -389,7 +429,10 @@ fn last_pending_input(messages: &[RenderedMessage]) -> Option { impl Tools { pub fn new(zeron: Arc) -> Self { - Self { zeron } + Self { + zeron, + pending_turns: Default::default(), + } } pub fn list(&self) -> Vec { @@ -406,8 +449,8 @@ impl Tools { let result = match name { "whoami" => self.whoami().await, "list_devices" => self.list_devices().await, - "list_projects" => self.list_projects().await, - "list_harnesses" => self.list_harnesses().await, + "list_projects" => self.list_projects(parse(args)?).await, + "list_harnesses" => self.list_harnesses(parse(args)?).await, "list_models" => self.list_models(parse(args)?).await, "list_chats" => self.list_chats(parse(args)?).await, "get_chat" => self.get_chat(parse(args)?).await, @@ -422,7 +465,7 @@ impl Tools { "archive_chat" => self.archive_chat(parse(args)?).await, other => return Err(format!("unknown tool: {other}")), }; - result.map_err(|e| e.to_string()) + result.map_err(|e| format!("{e:#}")) } /// Poll every request together, including its optional wait. A waiting @@ -500,8 +543,12 @@ impl Tools { })) } - async fn list_projects(&self) -> anyhow::Result { - let (spaces, devices) = tokio::try_join!(self.zeron.spaces(), self.zeron.devices())?; + async fn list_projects(&self, args: DeviceArgs) -> anyhow::Result { + let (mut spaces, devices) = tokio::try_join!(self.zeron.spaces(), self.zeron.devices())?; + if let Some(device) = args.device.as_deref() { + let device = self.zeron.resolve_device_id(Some(device)).await?; + spaces.retain(|s| s.device_id == device); + } let device_name = |id: &str| devices.iter().find(|d| d.id == id).map(|d| d.name.clone()); Ok(json!({ "projects": spaces.iter().map(|s| json!({ @@ -515,8 +562,12 @@ impl Tools { })) } - async fn list_harnesses(&self) -> anyhow::Result { - let harnesses = self.zeron.harnesses().await?; + async fn list_harnesses(&self, args: DeviceArgs) -> anyhow::Result { + let device = match args.device.as_deref() { + Some(key) => Some(self.zeron.resolve_device_id(Some(key)).await?), + None => None, + }; + let harnesses = self.zeron.harnesses_on(device.as_deref()).await?; Ok(json!({ "harnesses": harnesses.iter().map(|h| json!({ "id": h.id, @@ -532,7 +583,11 @@ impl Tools { async fn list_models(&self, args: ListModelsArgs) -> anyhow::Result { let harness: HarnessId = parse_enum("harness", &args.harness).map_err(anyhow::Error::msg)?; - let models = self.zeron.models(harness).await?; + let device = match args.device.as_deref() { + Some(key) => Some(self.zeron.resolve_device_id(Some(key)).await?), + None => None, + }; + let models = self.zeron.models_on(harness, device.as_deref()).await?; Ok(json!({ "harness": harness, "models": models.iter().map(|m| json!({ @@ -551,7 +606,11 @@ impl Tools { self.zeron.sessions() )?; if let Some(project) = args.project.as_deref() { - let space = self.zeron.resolve_space(project).await?; + let (space, _) = self + .zeron + .resolve_target(Some(project), args.device.as_deref()) + .await?; + let space = space.expect("project provided"); chats.retain(|c| c.space_id.as_deref() == Some(space.id.as_str())); } if args.device.is_some() { @@ -585,7 +644,7 @@ impl Tools { let (spaces, sessions, entries) = tokio::try_join!( self.zeron.spaces(), self.zeron.sessions(), - self.zeron.transcript(&chat.id) + self.zeron.transcript_on(&chat.id, Some(&chat.device_id)) )?; let rendered = render_entries(&entries, RenderOptions::default()); let mut summary = summarize_chat(&chat, &spaces, &sessions); @@ -603,28 +662,65 @@ impl Tools { "Side chats cannot create chats. Ask your parent chat to create another side chat." ); } - let harnesses = self.zeron.harnesses().await?; + // Parent: the explicit `parent` argument, else the chat this server + // speaks for. Resolved so a prefix/title works and a typo fails loud. + let explicit_parent = args + .parent + .as_deref() + .map(str::trim) + .filter(|p| !p.is_empty()); + anyhow::ensure!( + args.kind != Some(ChatKind::Chat) || explicit_parent.is_none(), + "kind chat cannot have a parent" + ); + let parent_chat_id = if args.kind == Some(ChatKind::Chat) { + None + } else { + match explicit_parent { + Some(key) => Some(self.zeron.resolve_chat(key).await?.id), + None => self.zeron.origin().chat_id.clone(), + } + // A hidden voice orchestrator creates top-level sessions. + .filter(|parent| !zeron_proto::voice::is_orchestrator_chat(parent)) + }; + anyhow::ensure!( + args.kind != Some(ChatKind::Side) || parent_chat_id.is_some(), + "kind side requires a parent or origin chat" + ); + let kind = if parent_chat_id.is_some() { + ChatKind::Side + } else { + ChatKind::Chat + }; + + if let Some(parent) = parent_chat_id.as_deref() { + let chat = self.zeron.resolve_chat(parent).await?; + anyhow::ensure!( + chat.parent_chat_id.is_none(), + "Cannot create a child of a side chat. Choose a top-level parent chat." + ); + } + let (space, device_id) = self + .zeron + .resolve_target(args.project.as_deref(), args.device.as_deref()) + .await?; + let harnesses = self.zeron.harnesses_on(Some(&device_id)).await?; let harness = match args.harness.as_deref() { Some(raw) => { let id: HarnessId = parse_enum("harness", raw).map_err(anyhow::Error::msg)?; - if let Some(info) = harnesses.iter().find(|h| h.id == id) - && !info.available() - { - anyhow::bail!( - "harness {raw} is not available on this device (see list_harnesses)" - ); - } + anyhow::ensure!( + harnesses.iter().any(|h| h.id == id && h.available()), + "harness {raw} is not available on device {device_id} (see list_harnesses with device)" + ); id } - None => default_harness(&harnesses)?, + None => default_harness(&harnesses).with_context(|| format!("device {device_id}"))?, }; - if let Some(model) = args.model.as_deref() - && let Ok(models) = self.zeron.models(harness).await - && !models.is_empty() - && !models.iter().any(|m| m.id == model) - { - anyhow::bail!( - "model {model:?} is not offered by {harness:?}; available: {}", + if let Some(model) = args.model.as_deref() { + let models = self.zeron.models_on(harness, Some(&device_id)).await?; + anyhow::ensure!( + models.iter().any(|m| m.id == model), + "model {model:?} is not offered by {harness:?} on device {device_id}; available: {}", models .iter() .map(|m| m.id.as_str()) @@ -648,37 +744,6 @@ impl Tools { sandbox, }; - let (space, device_id) = match args.project.as_deref() { - Some(project) => { - let space = self.zeron.resolve_space(project).await?; - let device_id = space.device_id.clone(); - (Some(space), device_id) - } - None => ( - None, - self.zeron.resolve_device_id(args.device.as_deref()).await?, - ), - }; - - // Parent: the explicit `parent` argument, else the chat this server - // speaks for. Resolved so a prefix/title works and a typo fails loud. - let parent_chat_id = match args - .parent - .as_deref() - .map(str::trim) - .filter(|p| !p.is_empty()) - { - Some(key) => Some(self.zeron.resolve_chat(key).await?.id), - None => self.zeron.origin().chat_id.clone(), - }; - - if let Some(parent) = parent_chat_id.as_deref() { - let chat = self.zeron.resolve_chat(parent).await?; - anyhow::ensure!( - chat.parent_chat_id.is_none(), - "Cannot create a child of a side chat. Choose a top-level parent chat." - ); - } let chat_id = uuid::Uuid::new_v4().to_string(); let mut mutate = json!({ "op": "createChat", @@ -717,6 +782,7 @@ impl Tools { let mut result = json!({ "chatId": chat_id, + "kind": kind, "deviceId": device_id, "project": space.as_ref().map(|s| json!({ "id": s.id, "name": s.display_name(), "path": s.path })), "harness": harness, @@ -752,15 +818,18 @@ impl Tools { .deliver(&chat, space.as_ref(), &harnesses, None, prompt, "run") .await?; result["sent"] = sent; + let pending = Arc::new(PendingTurn { + baseline: None, + // A new chat has no earlier messages. + start: TurnStart::default(), + }); + self.pending_turns + .lock() + .await + .insert(chat.id.clone(), pending.clone()); if args.wait { result["turn"] = self - .await_turn( - &chat, - None, - true, - wait_duration(args.timeout_secs), - now_millis(), - ) + .await_pending_turn(&chat, pending, wait_duration(args.timeout_secs)) .await?; } } @@ -769,7 +838,10 @@ impl Tools { async fn read_chat(&self, args: ReadChatArgs) -> anyhow::Result { let chat = self.zeron.resolve_chat(&args.chat).await?; - let entries = self.zeron.transcript(&chat.id).await?; + let entries = self + .zeron + .transcript_on(&chat.id, Some(&chat.device_id)) + .await?; let rendered = render_entries( &entries, RenderOptions { @@ -809,7 +881,7 @@ impl Tools { let (spaces, sessions, harnesses) = tokio::try_join!( self.zeron.spaces(), self.zeron.sessions(), - self.zeron.harnesses() + self.zeron.harnesses_on(Some(&chat.device_id)) )?; let space = chat .space_id @@ -817,7 +889,13 @@ impl Tools { .and_then(|id| spaces.iter().find(|s| s.id == id)); let baseline = session_for(&sessions, &chat); let mode = args.mode.as_deref().unwrap_or("auto"); - let sent_at = now_millis(); + let message_ids = self + .zeron + .transcript_on(&chat.id, Some(&chat.device_id)) + .await? + .into_iter() + .map(|m| m.id) + .collect(); let body = self.attribute(&chat, text).await; let mut result = json!({ "chatId": chat.id, @@ -826,15 +904,17 @@ impl Tools { result["sent"] = self .deliver(&chat, space, &harnesses, baseline.as_ref(), body, mode) .await?; + let pending = Arc::new(PendingTurn { + baseline, + start: TurnStart { message_ids }, + }); + self.pending_turns + .lock() + .await + .insert(chat.id.clone(), pending.clone()); if args.wait { result["turn"] = self - .await_turn( - &chat, - baseline.as_ref(), - true, - wait_duration(args.timeout_secs), - sent_at, - ) + .await_pending_turn(&chat, pending, wait_duration(args.timeout_secs)) .await?; } Ok(result) @@ -842,9 +922,23 @@ impl Tools { async fn wait_for_turn(&self, args: WaitArgs) -> anyhow::Result { let chat = self.zeron.resolve_chat(&args.chat).await?; - let turn = self - .await_turn(&chat, None, false, wait_duration(args.timeout_secs), 0) - .await?; + let pending = self.pending_turns.lock().await.get(&chat.id).cloned(); + let turn = match pending { + Some(pending) => { + self.await_pending_turn(&chat, pending, wait_duration(args.timeout_secs)) + .await? + } + None => { + self.await_turn( + &chat, + None, + false, + wait_duration(args.timeout_secs), + TurnStart::default(), + ) + .await? + } + }; Ok(json!({ "chatId": chat.id, "title": chat.title, "turn": turn })) } @@ -871,7 +965,10 @@ impl Tools { let request_id = match args.request_id { Some(id) => id, None => { - let entries = self.zeron.transcript(&chat.id).await?; + let entries = self + .zeron + .transcript_on(&chat.id, Some(&chat.device_id)) + .await?; let rendered = render_entries(&entries, RenderOptions::default()); last_pending_input(&rendered) .and_then(|p| { @@ -912,7 +1009,8 @@ impl Tools { /// Prefix the sender's identity when this server speaks for a chat, so /// the receiving agent (and the human reading that transcript) can tell - /// an agent-to-agent message from a typed one. + /// an agent-to-agent message from a typed one. Reply instructions always + /// use the full id: display prefixes need not uniquely identify a chat. async fn attribute(&self, target: &Chat, text: &str) -> String { let Some(origin_id) = self.zeron.origin().chat_id.as_deref() else { return text.to_owned(); @@ -932,7 +1030,7 @@ impl Tools { }; format!( "[Message from Zeron chat {label}. Reply to it with the Zeron `send_message` tool, chat {}.]\n\n{text}", - short(origin_id) + origin_id ) } @@ -1025,37 +1123,90 @@ impl Tools { })) } + async fn await_pending_turn( + &self, + chat: &Chat, + pending: Arc, + timeout: Duration, + ) -> anyhow::Result { + let turn = self + .await_turn( + chat, + pending.baseline.as_ref(), + true, + timeout, + pending.start.clone(), + ) + .await?; + if turn["timedOut"] == false { + let mut turns = self.pending_turns.lock().await; + // A concurrent send may already have installed a newer baseline. + if turns + .get(&chat.id) + .is_some_and(|p| Arc::ptr_eq(p, &pending)) + { + turns.remove(&chat.id); + } + } + Ok(turn) + } + /// Wait, then report the outcome with the assistant messages that - /// landed since `since_millis`. + /// are not in `start`'s baseline. async fn await_turn( &self, chat: &Chat, baseline: Option<&Session>, expect_turn: bool, timeout: Duration, - since_millis: i64, + start: TurnStart, ) -> anyhow::Result { - let (outcome, session) = self + let deadline = Instant::now() + timeout; + let (mut outcome, session) = self .zeron .wait_for_turn(chat, baseline, expect_turn, timeout) .await?; - let entries = self.zeron.transcript(&chat.id).await.unwrap_or_default(); - let rendered = render_entries(&entries, RenderOptions::default()); - let replies: Vec<&RenderedMessage> = rendered - .iter() - .filter(|m| m.role == zeron_doc::MessageRole::Assistant) - .filter(|m| m.created_at >= since_millis.saturating_sub(2_000)) - .collect(); - let replies: Vec<&RenderedMessage> = if replies.is_empty() { - rendered + // Registry session updates can arrive before the separate transcript doc. + // Keep the same deadline while waiting for a response to this send. + let mut rendered = Vec::new(); + let mut replies = Vec::new(); + loop { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + if outcome == TurnOutcome::Completed && expect_turn { + outcome = TurnOutcome::TimedOut; + } + break; + } + let entries = match tokio::time::timeout( + remaining, + self.zeron.transcript_on(&chat.id, Some(&chat.device_id)), + ) + .await + { + Ok(entries) => entries?, + Err(_) => { + outcome = TurnOutcome::TimedOut; + break; + } + }; + rendered = render_entries(&entries, RenderOptions::default()); + replies = rendered .iter() - .rev() - .find(|m| m.role == zeron_doc::MessageRole::Assistant) - .into_iter() - .collect() - } else { - replies - }; + .filter(|m| m.role == zeron_doc::MessageRole::Assistant) + .filter(|m| !start.message_ids.contains(&m.id)) + .cloned() + .collect(); + if !expect_turn + || outcome != TurnOutcome::Completed + || replies + .iter() + .any(|m| m.status != Some(zeron_doc::MessageStatus::Streaming)) + { + break; + } + tokio::time::sleep(Duration::from_millis(100).min(remaining)).await; + } let (status, _) = status_of(session.as_ref()); Ok(json!({ "outcome": outcome, @@ -1069,9 +1220,6 @@ impl Tools { /// claude-code when it is offered here, else the first available harness. fn default_harness(harnesses: &[HarnessInfo]) -> anyhow::Result { - if harnesses.is_empty() { - return Ok(HarnessId::ClaudeCode); - } harnesses .iter() .find(|h| h.id == HarnessId::ClaudeCode && h.available()) @@ -1095,13 +1243,17 @@ mod tests { use std::sync::Mutex; use zeron_rpc::{RpcError, RpcReply, RpcService, memory_client, methods}; - /// A fixed little workspace: one device, one project, one chat with a - /// two-message transcript. Writes are recorded for assertions. + /// Two devices with distinct catalogs and repeated project paths, plus + /// two chats with a two-message transcript. Writes are recorded for assertions. #[derive(Default)] struct World { writes: Mutex>, dispatch_barrier: Option, beta_parent: Option, + beta_remote: bool, + extra_chats: Vec, + catalog_error: Option<&'static str>, + reads: Mutex>, } fn stream(item: Value) -> RpcReply { @@ -1111,6 +1263,16 @@ mod tests { #[async_trait] impl RpcService for World { async fn handle(&self, method: &str, params: Value) -> Result { + if matches!(method, methods::LIST_HARNESSES | methods::LIST_MODELS) { + self.reads + .lock() + .unwrap() + .push((method.into(), params.clone())); + if self.catalog_error == Some(method) { + return Err(RpcError::Failed("catalog unavailable".into())); + } + } + let remote = params["targetDeviceId"] == "dev-remote"; Ok(match method { methods::LOCAL_DEVICE => RpcReply::Value(json!({ "deviceId": "dev-local" })), methods::ENGINE_INFO => RpcReply::Value(json!({ @@ -1119,12 +1281,19 @@ mod tests { methods::WATCH_DEVICES => stream(json!([{ "id": "dev-local", "name": "Laptop", "platform": "linux", "lastSeenAt": null - }])), + }, {"id": "dev-remote", "name": "Worker", "platform": "linux", "lastSeenAt": null}])), methods::WATCH_SPACES => stream(json!([{ "id": "space-1", "deviceId": "dev-local", "path": "/repo/comet", "gitDetected": true, "createdAt": "2026-09-01T00:00:00Z" + }, { + "id": "space-remote", "deviceId": "dev-remote", "path": "/repo/comet", + "gitDetected": true, "createdAt": "2026-09-01T00:00:00Z" + }, { + "id": "space-unique", "deviceId": "dev-remote", "path": "/repo/unique", + "gitDetected": true, "createdAt": "2026-09-01T00:00:00Z" }])), - methods::WATCH_CHATS => stream(json!([ + methods::WATCH_CHATS => { + let mut chats = json!([ { "id": "chat-alpha-1", "deviceId": "dev-local", "title": "Alpha", "archived": false, "spaceId": "space-1", @@ -1132,19 +1301,32 @@ mod tests { "createdAt": "2026-09-01T00:00:00Z" }, { - "id": "chat-beta-2", "deviceId": "dev-local", "title": "Beta", + "id": "chat-beta-2", "deviceId": if self.beta_remote { "dev-remote" } else { "dev-local" }, "title": "Beta", "parentChatId": self.beta_parent, "archived": false, "spaceId": "space-1", "createdAt": "2026-09-02T00:00:00Z" } - ])), + ]); + chats + .as_array_mut() + .unwrap() + .extend(self.extra_chats.clone()); + stream(chats) + } methods::WATCH_SESSIONS => stream(json!([])), + methods::LIST_HARNESSES if remote => RpcReply::Value(json!([ + {"id":"codex", "name":"Remote Codex", "installed":true}, + {"id":"cursor", "name":"Disabled Cursor", "installed":true, "enabled":false} + ])), methods::LIST_HARNESSES => RpcReply::Value(json!([ { "id": "claude-code", "name": "Claude Code", "supportsSteering": true, "steeringMode": "step-boundary", "reasoningLevels": [], "installed": true, "enabled": true }, { "id": "codex", "name": "Codex", "supportsSteering": true, "steeringMode": "turn-boundary", "reasoningLevels": [], "installed": false, "enabled": true } ])), + methods::LIST_MODELS if remote => RpcReply::Value(json!([ + {"id":"remote-model", "label":"Remote model"} + ])), methods::LIST_MODELS => RpcReply::Value(json!([ { "id": "opus", "label": "Opus" }, { "id": "sonnet", "label": "Sonnet" } ])), @@ -1254,11 +1436,85 @@ mod tests { "{prompt}" ); assert!(prompt.ends_with("please review")); + assert!(prompt.contains("tool, chat chat-beta-2.]"), "{prompt}"); assert_eq!(params["command"]["request"]["cwd"], "/repo/comet"); assert_eq!(params["command"]["request"]["harness"], "claude-code"); assert_eq!(params["command"]["request"]["model"], "opus"); } + #[tokio::test] + async fn replies_use_the_full_id_with_multiple_voice_orchestrators() { + let first = "voice-orchestrator-11111111-1111-4111-8111-111111111111"; + let second = "voice-orchestrator-22222222-2222-4222-8222-222222222222"; + let world = Arc::new(World { + extra_chats: [first, second] + .into_iter() + .map(|id| { + json!({ + "id": id, "deviceId": "dev-local", "title": "Voice session", + "archived": false, "createdAt": "2026-10-01T00:00:00Z" + }) + }) + .collect(), + ..Default::default() + }); + let orchestrator = tools( + world.clone(), + Origin { + chat_id: Some(second.into()), + device_id: None, + }, + ); + let error = orchestrator + .zeron + .resolve_chat("voice-or") + .await + .unwrap_err(); + assert!(error.to_string().contains("2 chats start with"), "{error}"); + orchestrator + .call( + "send_message", + json!({ + "chat": "alpha", "text": "please review" + }), + ) + .await + .unwrap(); + let reply_id = { + let writes = world.writes.lock().unwrap(); + let prompt = writes.last().unwrap().1["command"]["request"]["prompt"] + .as_str() + .unwrap(); + prompt + .split_once("tool, chat ") + .unwrap() + .1 + .split_once(".]\n\n") + .unwrap() + .0 + .to_owned() + }; + assert_eq!(reply_id, second); + let worker = tools( + world.clone(), + Origin { + chat_id: Some("chat-alpha-1".into()), + device_id: None, + }, + ); + worker + .call( + "send_message", + json!({ + "chat": reply_id, "text": "review complete" + }), + ) + .await + .unwrap(); + let writes = world.writes.lock().unwrap(); + assert_eq!(writes.last().unwrap().1["chatId"], second); + } + #[tokio::test] async fn auto_steers_busy_chats_even_at_turn_boundaries_or_after_long_quiet_tools() { let world = Arc::new(World::default()); @@ -1271,6 +1527,7 @@ mod tests { started_at: None, updated_at: chrono::Utc::now() - chrono::Duration::minutes(10), last_completed_turn: None, + running_subagents: 0, }; // No mid-turn capability is required for a live mailbox delivery. let sent = tools @@ -1298,7 +1555,7 @@ mod tests { let err = tools .call( "create_chat", - json!({ "project": "comet", "model": "nope" }), + json!({ "project": "space-1", "model": "nope" }), ) .await .unwrap_err(); @@ -1307,7 +1564,7 @@ mod tests { let created = tools .call( "create_chat", - json!({ "project": "/repo/comet", "model": "sonnet", "title": "Review", "prompt": "go" }), + json!({ "project": "space-1", "model": "sonnet", "title": "Review", "prompt": "go" }), ) .await .unwrap(); @@ -1339,7 +1596,7 @@ mod tests { }, ); let created = tools - .call("create_chat", json!({ "project": "/repo/comet" })) + .call("create_chat", json!({ "project": "space-1" })) .await .unwrap(); assert_eq!(created["parentChatId"], "chat-beta-2"); @@ -1352,7 +1609,7 @@ mod tests { let created = tools .call( "create_chat", - json!({ "project": "/repo/comet", "parent": "Alpha" }), + json!({ "project": "space-1", "parent": "Alpha" }), ) .await .unwrap(); @@ -1360,7 +1617,7 @@ mod tests { let err = tools .call( "create_chat", - json!({ "project": "/repo/comet", "parent": "nope" }), + json!({ "project": "space-1", "parent": "nope" }), ) .await .unwrap_err(); @@ -1485,7 +1742,7 @@ mod tests { device_id: None, }, ); - for args in [json!({}), json!({"parent":"Alpha"})] { + for args in [json!({}), json!({"parent":"Alpha"}), json!({"kind":"chat"})] { assert!( side.call("create_chat", args) .await @@ -1494,7 +1751,10 @@ mod tests { ); } let batch = side - .call("create_chats", json!({"requests":[{}, {"parent":"Alpha"}]})) + .call( + "create_chats", + json!({"requests":[{}, {"parent":"Alpha"}, {"kind":"chat"}]}), + ) .await .unwrap(); assert!( @@ -1514,6 +1774,532 @@ mod tests { assert!(world.writes.lock().unwrap().is_empty()); } + #[tokio::test] + async fn kinds_preserve_defaults_and_reject_invalid_arguments_before_writing() { + let world = Arc::new(World::default()); + let root = tools(world.clone(), Origin::default()); + for args in [ + json!({"kind":"side"}), + json!({"kind":"unknown"}), + json!({"kind":"chat", "parent":"Alpha", "title":"must not write"}), + ] { + assert!(root.call("create_chat", args).await.is_err()); + } + assert!(world.writes.lock().unwrap().is_empty()); + let from_chat = tools( + world.clone(), + Origin { + chat_id: Some("chat-alpha-1".into()), + device_id: None, + }, + ); + for (args, kind, parent) in [ + (json!({"kind":"chat"}), "chat", Value::Null), + (json!({"kind":"chat", "parent":" "}), "chat", Value::Null), + (json!({"kind":"side"}), "side", json!("chat-alpha-1")), + (json!({}), "side", json!("chat-alpha-1")), + (json!({"parent":"Beta"}), "side", json!("chat-beta-2")), + ] { + let created = from_chat.call("create_chat", args).await.unwrap(); + assert_eq!(created["kind"], kind); + assert_eq!(created["parentChatId"], parent); + let writes = world.writes.lock().unwrap(); + let row = &writes.last().unwrap().1; + if kind == "chat" { + assert!(row.get("parentChatId").is_none()); + } else { + assert_eq!(row["parentChatId"], parent); + } + } + assert_eq!( + root.call("create_chat", json!({})).await.unwrap()["kind"], + "chat" + ); + assert_eq!( + root.call("create_chat", json!({"kind":"side", "parent":"Alpha"})) + .await + .unwrap()["kind"], + "side" + ); + } + + #[tokio::test] + async fn project_and_device_resolution_scopes_names_and_exact_paths() { + let world = Arc::new(World::default()); + let tools = tools(world.clone(), Origin::default()); + for args in [ + json!({"project":"comet"}), + json!({"project":"/repo/comet"}), + json!({"project":"space-1", "device":"Worker"}), + json!({"project":"missing", "device":"Worker"}), + json!({"device":"missing"}), + ] { + let error = tools.call("create_chat", args).await.unwrap_err(); + assert!(world.writes.lock().unwrap().is_empty(), "{error}"); + } + let error = tools + .call("create_chat", json!({"project":"/repo/comet"})) + .await + .unwrap_err(); + for candidate in ["space-1", "space-remote", "dev-local", "dev-remote"] { + assert!(error.contains(candidate), "{error}"); + } + for project in ["comet", "/repo/comet", "space-remote"] { + let created = tools + .call("create_chat", json!({"project":project,"device":"Worker"})) + .await + .unwrap(); + assert_eq!(created["deviceId"], "dev-remote"); + assert_eq!(created["project"]["id"], "space-remote"); + assert_eq!(created["harness"], "codex"); + } + let created = tools + .call("create_chat", json!({"project":"unique"})) + .await + .unwrap(); + assert_eq!(created["deviceId"], "dev-remote"); + let created = tools + .call("create_chat", json!({"device":"dev-remote"})) + .await + .unwrap(); + assert!(created["project"].is_null()); + assert_eq!(created["deviceId"], "dev-remote"); + let created = tools.call("create_chat", json!({})).await.unwrap(); + assert_eq!(created["deviceId"], "dev-local"); + } + + #[tokio::test] + async fn discovery_and_creation_use_the_selected_hosts_catalogs() { + let world = Arc::new(World::default()); + let tools = tools(world.clone(), Origin::default()); + assert_eq!( + tools.call("list_projects", json!({})).await.unwrap()["projects"] + .as_array() + .unwrap() + .len(), + 3 + ); + let projects = tools + .call("list_projects", json!({"device":"Worker"})) + .await + .unwrap(); + assert_eq!(projects["projects"].as_array().unwrap().len(), 2); + assert!( + projects["projects"] + .as_array() + .unwrap() + .iter() + .all(|p| p["deviceId"] == "dev-remote") + ); + assert_eq!( + tools.call("list_harnesses", json!({})).await.unwrap()["harnesses"][0]["id"], + "claude-code" + ); + assert_eq!( + tools + .call("list_harnesses", json!({"device":"Worker"})) + .await + .unwrap()["harnesses"][0]["id"], + "codex" + ); + assert_eq!( + tools + .call("list_models", json!({"harness":"codex", "device":"Worker"})) + .await + .unwrap()["models"][0]["id"], + "remote-model" + ); + assert_eq!( + tools + .call("list_models", json!({"harness":"claude-code"})) + .await + .unwrap()["models"][0]["id"], + "opus" + ); + for args in [ + json!({"device":"Worker", "harness":"claude-code"}), + json!({"device":"Worker", "harness":"cursor"}), + json!({"device":"Laptop", "harness":"codex"}), + json!({"device":"Laptop", "harness":"mock"}), + json!({"device":"Worker", "harness":"codex", "model":"opus"}), + ] { + assert!(tools.call("create_chat", args).await.is_err()); + assert!(world.writes.lock().unwrap().is_empty()); + } + let created = tools + .call( + "create_chat", + json!({"device":"Worker", "harness":"codex", "model":"remote-model"}), + ) + .await + .unwrap(); + assert_eq!(created["harness"], "codex"); + assert_eq!(created["model"], "remote-model"); + for method in [methods::LIST_HARNESSES, methods::LIST_MODELS] { + let broken = Arc::new(World { + catalog_error: Some(method), + ..Default::default() + }); + let tools = self::tools(broken.clone(), Origin::default()); + let error = tools + .call( + "create_chat", + json!({"device":"Worker", "model":"remote-model"}), + ) + .await + .unwrap_err(); + assert!( + error.contains("dev-remote") && error.contains("catalog unavailable"), + "{error}" + ); + assert!(broken.writes.lock().unwrap().is_empty()); + } + } + + #[tokio::test] + async fn remote_send_selects_the_remote_default_harness() { + let world = Arc::new(World { + beta_remote: true, + ..Default::default() + }); + let tools = tools(world.clone(), Origin::default()); + tools + .call("send_message", json!({"chat":"Beta", "text":"go"})) + .await + .unwrap(); + assert_eq!( + world.reads.lock().unwrap().last().unwrap().1["targetDeviceId"], + "dev-remote" + ); + assert_eq!( + world.writes.lock().unwrap().last().unwrap().1["command"]["request"]["harness"], + "codex" + ); + } + + #[tokio::test] + async fn mixed_batch_preserves_order_and_errors_per_request() { + let world = Arc::new(World::default()); + let tools = tools( + world.clone(), + Origin { + chat_id: Some("chat-alpha-1".into()), + device_id: None, + }, + ); + let result = tools + .call( + "create_chats", + json!({"requests":[ + {"kind":"chat", "device":"Worker", "project":"comet"}, + {"kind":"chat", "parent":"Alpha"}, + {"kind":"side", "project":"space-1"}, + {"kind":"bogus"} + ]}), + ) + .await + .unwrap(); + let results = &result["results"]; + assert_eq!(results[0]["result"]["kind"], "chat"); + assert_eq!(results[0]["result"]["deviceId"], "dev-remote"); + assert_eq!(results[1]["isError"], true); + assert_eq!(results[2]["result"]["kind"], "side"); + assert_eq!(results[2]["result"]["parentChatId"], "chat-alpha-1"); + assert_eq!(results[3]["isError"], true); + for i in 0..4 { + assert_eq!(results[i]["index"], i); + } + assert_eq!(world.writes.lock().unwrap().len(), 2); + } + + /// Sessions and transcripts are independently delivered. The command reply + /// is deliberately slow, so the assistant starts before QueueCommand returns. + struct DelayedTurn { + world: World, + sent: Mutex>, + reply_delay: Duration, + never_reply: bool, + /// Host clock offset: a remote engine stamps createdAt on its own clock. + host_skew_millis: i64, + } + + #[async_trait] + impl RpcService for DelayedTurn { + async fn handle(&self, method: &str, params: Value) -> Result { + match method { + methods::WATCH_CHATS => { + let reply = self.world.handle(method, params).await?; + let RpcReply::Stream(mut rows) = reply else { + unreachable!() + }; + let mut rows = rows.next().await.unwrap(); + if let Some((_, _, chat)) = self.sent.lock().unwrap().as_ref() + && chat != "chat-beta-2" + { + rows.as_array_mut().unwrap().push(json!({ + "id": chat, "deviceId":"dev-remote", "archived":false, + "createdAt":chrono::Utc::now(), "config":{"harness":"codex", "sandbox":"workspace-write"} + })); + } + Ok(stream(rows)) + } + methods::QUEUE_COMMAND => { + let reply = self.world.handle(method, params.clone()).await?; + *self.sent.lock().unwrap() = Some(( + now_millis(), + Instant::now(), + params["chatId"].as_str().unwrap().into(), + )); + tokio::time::sleep(Duration::from_millis(150)).await; + Ok(reply) + } + methods::WATCH_SESSIONS => { + let sent = self.sent.lock().unwrap().clone(); + let Some((_, _, chat)) = sent else { + return Ok(stream(json!([]))); + }; + Ok(RpcReply::Stream( + futures::stream::once(async { json!([]) }) + .chain(futures::stream::once(async move { + tokio::time::sleep(Duration::from_millis(50)).await; + json!([{"chatId":chat, "deviceId":"dev-remote", "status":"idle", + "updatedAt":chrono::Utc::now(), "lastCompletedTurn":"new-turn"}]) + })) + .boxed(), + )) + } + methods::WATCH_DOC_MESSAGES => { + let sent = self.sent.lock().unwrap().clone(); + // Only the existing chat has history. Its previous reply + // shares the send's timestamp, so only its id tells them apart. + let mut messages = json!([]); + if params["chatId"] == "chat-beta-2" { + let created_at = sent.as_ref().map_or_else(now_millis, |(t, _, _)| *t); + messages.as_array_mut().unwrap().push(json!({ + "id":"previous-reply", "role":"assistant", "createdAt":created_at, + "deviceId":"dev-remote", "status":"complete", "parts":[{"kind":"text", "id":"t", "text":"old reply"}] + })); + } + if let Some((timestamp, started, _)) = sent + && started.elapsed() >= self.reply_delay + && !self.never_reply + { + messages.as_array_mut().unwrap().push(json!({ + "id":"new-reply", "role":"assistant", "createdAt":timestamp + self.host_skew_millis, + "deviceId":"dev-remote", "status":"complete", "parts":[{"kind":"text", "id":"t", "text":"new reply"}] + })); + } + Ok(stream(json!({"reset":messages}))) + } + _ => self.world.handle(method, params).await, + } + } + } + + #[tokio::test] + async fn wait_allows_a_missing_session_and_a_late_remote_transcript() { + for create in [true, false] { + let service = Arc::new(DelayedTurn { + world: World { + beta_remote: true, + ..Default::default() + }, + sent: Mutex::new(None), + reply_delay: Duration::from_millis(450), + never_reply: false, + host_skew_millis: 0, + }); + let tools = Tools::new(Arc::new(Zeron::with_client( + memory_client(service), + Origin::default(), + ))); + let (name, args) = if create { + ( + "create_chat", + json!({"kind":"chat", "device":"Worker", "prompt":"go", "wait":true, "timeout_secs":2}), + ) + } else { + ( + "send_message", + json!({"chat":"Beta", "text":"go", "wait":true, "timeout_secs":2}), + ) + }; + let result = tools.call(name, args).await.unwrap(); + assert_eq!(result["turn"]["outcome"], "completed", "{result}"); + assert_eq!( + result["turn"]["replies"].as_array().unwrap().len(), + 1, + "{result}" + ); + assert_eq!(result["turn"]["replies"][0]["text"], "new reply"); + } + } + + #[tokio::test] + async fn standalone_wait_after_nonblocking_send_keeps_the_send_baseline() { + for create in [true, false] { + let service = Arc::new(DelayedTurn { + world: World { + beta_remote: true, + ..Default::default() + }, + sent: Mutex::new(None), + reply_delay: Duration::from_millis(450), + never_reply: false, + host_skew_millis: 0, + }); + let tools = Tools::new(Arc::new(Zeron::with_client( + memory_client(service), + Origin::default(), + ))); + let sent = if create { + tools + .call( + "create_chat", + json!({"kind":"chat", "device":"Worker", "prompt":"go", "wait":false}), + ) + .await + .unwrap() + } else { + tools + .call( + "send_message", + json!({"chat":"Beta", "text":"go", "wait":false}), + ) + .await + .unwrap() + }; + let result = tools + .call( + "wait_for_turn", + json!({"chat":sent["chatId"], "timeout_secs":2}), + ) + .await + .unwrap(); + assert_eq!(result["turn"]["outcome"], "completed", "{result}"); + assert_eq!(result["turn"]["replies"].as_array().unwrap().len(), 1); + assert_eq!(result["turn"]["replies"][0]["text"], "new reply"); + } + } + + /// A remote host stamps replies on its own clock. One running behind the + /// caller must not hide the reply to this send. + #[tokio::test] + async fn wait_returns_the_reply_from_a_host_whose_clock_is_behind() { + for create in [true, false] { + let service = Arc::new(DelayedTurn { + world: World { + beta_remote: true, + ..Default::default() + }, + sent: Mutex::new(None), + reply_delay: Duration::from_millis(100), + never_reply: false, + host_skew_millis: -30_000, + }); + let tools = Tools::new(Arc::new(Zeron::with_client( + memory_client(service), + Origin::default(), + ))); + let (name, args) = if create { + ( + "create_chat", + json!({"kind":"chat", "device":"Worker", "prompt":"go", "wait":true, "timeout_secs":2}), + ) + } else { + ( + "send_message", + json!({"chat":"Beta", "text":"go", "wait":true, "timeout_secs":2}), + ) + }; + let result = tools.call(name, args).await.unwrap(); + assert_eq!(result["turn"]["outcome"], "completed", "{result}"); + let replies = result["turn"]["replies"].as_array().unwrap(); + assert!(replies.iter().any(|r| r["text"] == "new reply"), "{result}"); + } + } + + #[tokio::test] + async fn completed_session_without_new_transcript_times_out_without_old_reply() { + let service = Arc::new(DelayedTurn { + world: World { + beta_remote: true, + ..Default::default() + }, + sent: Mutex::new(None), + reply_delay: Duration::ZERO, + never_reply: true, + host_skew_millis: 0, + }); + let tools = Tools::new(Arc::new(Zeron::with_client( + memory_client(service), + Origin::default(), + ))); + let result = tools + .call( + "send_message", + json!({"chat":"Beta", "text":"go", "wait":true, "timeout_secs":1}), + ) + .await + .unwrap(); + assert_eq!(result["turn"]["outcome"], "timedOut"); + assert_eq!(result["turn"]["replies"], json!([])); + } + + #[tokio::test] + async fn remote_replies_do_not_require_synchronized_clocks() { + for host_skew_millis in [-1_000, 1_000] { + for wait in [true, false] { + let service = Arc::new(DelayedTurn { + world: World { + beta_remote: true, + ..Default::default() + }, + sent: Mutex::new(None), + reply_delay: Duration::from_millis(450), + never_reply: false, + host_skew_millis, + }); + let tools = Tools::new(Arc::new(Zeron::with_client( + memory_client(service), + Origin::default(), + ))); + let sent = tools + .call( + "send_message", + json!({ + "chat":"Beta", "text":"go", "wait":wait, "timeout_secs":2 + }), + ) + .await + .unwrap(); + let result = if wait { + sent + } else { + tools + .call( + "wait_for_turn", + json!({ + "chat":sent["chatId"], "timeout_secs":2 + }), + ) + .await + .unwrap() + }; + assert_eq!( + result["turn"]["outcome"], "completed", + "offset={host_skew_millis}, wait={wait}: {result}" + ); + assert_eq!( + result["turn"]["replies"].as_array().unwrap().len(), + 1, + "{result}" + ); + assert_eq!(result["turn"]["replies"][0]["id"], "new-reply"); + } + } + } + #[tokio::test] async fn initialize_and_list_over_jsonrpc() { let world = Arc::new(World::default()); diff --git a/crates/mcp/src/zeron.rs b/crates/mcp/src/zeron.rs index d31767d22c..8661975f61 100644 --- a/crates/mcp/src/zeron.rs +++ b/crates/mcp/src/zeron.rs @@ -244,22 +244,66 @@ impl Zeron { } pub async fn harnesses(&self) -> anyhow::Result> { - let value = self.call(methods::LIST_HARNESSES, json!({})).await?; - serde_json::from_value(value).context("ListHarnesses: unexpected shape") + self.harnesses_on(None).await + } + + pub async fn harnesses_on(&self, device: Option<&str>) -> anyhow::Result> { + let mut params = json!({}); + if let Some(device) = device { + params["targetDeviceId"] = json!(device); + } + let value = self + .call(methods::LIST_HARNESSES, params) + .await + .with_context(|| format!("harness catalog on device {}", device.unwrap_or("local")))?; + serde_json::from_value(value).with_context(|| { + format!( + "ListHarnesses: unexpected catalog on device {}", + device.unwrap_or("local") + ) + }) } pub async fn models(&self, harness: HarnessId) -> anyhow::Result> { + self.models_on(harness, None).await + } + + pub async fn models_on( + &self, + harness: HarnessId, + device: Option<&str>, + ) -> anyhow::Result> { + let mut params = json!({ "harness": harness }); + if let Some(device) = device { + params["targetDeviceId"] = json!(device); + } let value = self - .call(methods::LIST_MODELS, json!({ "harness": harness })) - .await?; - serde_json::from_value(value).context("ListModels: unexpected shape") + .call(methods::LIST_MODELS, params) + .await + .with_context(|| format!("model catalog on device {}", device.unwrap_or("local")))?; + serde_json::from_value(value).with_context(|| { + format!( + "ListModels: unexpected catalog on device {}", + device.unwrap_or("local") + ) + }) } /// The full transcript: attach, take the opening `reset` frame, detach. pub async fn transcript(&self, chat_id: &str) -> anyhow::Result> { - let mut rx = self - .subscribe(methods::WATCH_DOC_MESSAGES, json!({ "chatId": chat_id })) - .await?; + self.transcript_on(chat_id, None).await + } + + pub async fn transcript_on( + &self, + chat_id: &str, + device: Option<&str>, + ) -> anyhow::Result> { + let mut params = json!({ "chatId": chat_id }); + if let Some(device) = device { + params["targetDeviceId"] = json!(device); + } + let mut rx = self.subscribe(methods::WATCH_DOC_MESSAGES, params).await?; let mut entries = Vec::new(); let deadline = Instant::now() + SNAPSHOT_TIMEOUT; loop { @@ -348,6 +392,43 @@ impl Zeron { resolve_space_in(&spaces, key.trim()) } + /// Resolve the host first when specified, so repeated project names/paths + /// on other machines cannot influence selection. + pub async fn resolve_target( + &self, + project: Option<&str>, + device: Option<&str>, + ) -> anyhow::Result<(Option, String)> { + let device = device.map(str::trim).filter(|d| !d.is_empty()); + let host = match device { + Some(key) => Some(self.resolve_device_id(Some(key)).await?), + None => None, + }; + if let Some(project) = project { + let mut spaces = self.spaces().await?; + if let Some(host) = &host { + if let Some(space) = spaces.iter().find(|s| s.id == project.trim()) { + anyhow::ensure!( + space.device_id == *host, + "project {} belongs to device {}, not device {host}", + space.id, + space.device_id + ); + } + spaces.retain(|s| s.device_id == *host); + } + let space = resolve_space_in(&spaces, project.trim())?; + return Ok((Some(space.clone()), space.device_id)); + } + Ok(( + None, + match host { + Some(host) => host, + None => self.local_device_id().await?, + }, + )) + } + /// Device id or exact name; `None` means this engine's own device. pub async fn resolve_device_id(&self, key: Option<&str>) -> anyhow::Result { let Some(key) = key.map(str::trim).filter(|k| !k.is_empty()) else { @@ -531,17 +612,18 @@ pub fn resolve_space_in(spaces: &[Space], key: &str) -> anyhow::Result { if key.is_empty() { bail!("project is required"); } - if let Some(space) = spaces.iter().find(|s| s.id == key || s.path == key) { + if let Some(space) = spaces.iter().find(|s| s.id == key) { return Ok(space.clone()); } let normalized = key.trim_end_matches(['/', '\\']); + let by_path: Vec<&Space> = spaces + .iter() + .filter(|s| s.path.trim_end_matches(['/', '\\']) == normalized) + .collect(); let by_name: Vec<&Space> = spaces .iter() .filter(|s| s.display_name().eq_ignore_ascii_case(normalized)) .collect(); - if let [one] = by_name.as_slice() { - return Ok((*one).clone()); - } let by_suffix: Vec<&Space> = spaces .iter() .filter(|s| { @@ -549,21 +631,23 @@ pub fn resolve_space_in(spaces: &[Space], key: &str) -> anyhow::Result { path.ends_with(normalized) || path.contains(normalized) }) .collect(); - if let [one] = by_suffix.as_slice() { - return Ok((*one).clone()); - } - let candidates = if by_name.len() > 1 { + let candidates = if !by_path.is_empty() { + &by_path + } else if !by_name.is_empty() { &by_name } else { &by_suffix }; + if let [one] = candidates.as_slice() { + return Ok((*one).clone()); + } if candidates.len() > 1 { bail!( "{} projects match {key:?}; use an id: {}", candidates.len(), candidates .iter() - .map(|s| format!("{} ({})", s.path, s.id)) + .map(|s| format!("{} (id {}, device {})", s.path, s.id, s.device_id)) .collect::>() .join(", ") ); @@ -688,10 +772,30 @@ mod tests { git_detected: true, git_checked_at: None, checkout_id: None, + repository_id: None, created_at: Utc::now(), } } + #[test] + fn repeated_exact_paths_and_names_do_not_select_the_first_project() { + let mut remote = space("s2", "/repo/comet", Some("remote")); + remote.device_id = "other-device".into(); + let spaces = [space("s1", "/repo/comet", None), remote]; + let err = resolve_space_in(&spaces, "/repo/comet/") + .unwrap_err() + .to_string(); + for candidate in ["s1", "s2", "dev", "other-device"] { + assert!(err.contains(candidate), "{err}"); + } + // A unique path suffix must not override ambiguous display names. + let spaces = [ + space("s1", "/repo/comet", None), + space("s2", "/repo/other", Some("comet")), + ]; + assert!(resolve_space_in(&spaces, "comet").is_err()); + } + #[test] fn space_resolution_accepts_id_path_name_or_suffix() { let spaces = vec![ diff --git a/crates/mobile/Cargo.toml b/crates/mobile/Cargo.toml index 12d8a1beaf..1e1845b303 100644 --- a/crates/mobile/Cargo.toml +++ b/crates/mobile/Cargo.toml @@ -21,6 +21,12 @@ required-features = ["bindgen"] bindgen = ["uniffi/cli"] [dependencies] +zeron-voice-session.workspace = true +zeron-orb.workspace = true +zeron-veil.workspace = true +tokio.workspace = true +tokio-util.workspace = true +async-trait.workspace = true uniffi = "0.32" zeron-markdown.workspace = true zeron-text.workspace = true diff --git a/crates/mobile/src/caption.rs b/crates/mobile/src/caption.rs new file mode 100644 index 0000000000..bf1c59cdf1 --- /dev/null +++ b/crates/mobile/src/caption.rs @@ -0,0 +1,94 @@ +//! The voice call caption's streaming veil (`zeron-veil`), exactly as the +//! desktop stage runs it. Offsets are UTF-16 so the platform can apply them to +//! its attributed strings directly. +use std::sync::Mutex; +use std::time::Instant; +use zeron_veil::CaptionVeil; + +/// A veiled UTF-16 range of the caption and its opacity (0..1). +#[derive(Debug, Clone, PartialEq, uniffi::Record)] +pub struct CaptionSpan { + pub start: u32, + pub end: u32, + pub alpha: f32, +} + +#[derive(Debug, Clone, PartialEq, uniffi::Record)] +pub struct CaptionFrame { + /// The tail of the utterance to show (ellipsized in front when long). + pub text: String, + pub spans: Vec, + /// The previous utterance while it fades out, and its opacity. + pub previous: Option, + pub previous_alpha: f32, + /// Something is still fading: ask for the next frame. + pub animating: bool, +} + +/// One call's caption. Advance it whenever the caption changes and on every +/// display frame while the last frame was `animating`. +#[derive(uniffi::Object)] +pub struct CaptionFader { + veil: Mutex, +} + +#[uniffi::export] +impl CaptionFader { + #[uniffi::constructor] + pub fn new(max_chars: u32) -> std::sync::Arc { + std::sync::Arc::new(Self { + veil: Mutex::new(CaptionVeil::new(max_chars as usize)), + }) + } + + pub fn frame(&self, item: Option, text: String, reduced_motion: bool) -> CaptionFrame { + let mut veil = self.veil.lock().unwrap(); + let frame = veil.advance(item.as_deref(), &text, Instant::now(), reduced_motion); + let utf16 = |byte: usize| frame.text[..byte].encode_utf16().count() as u32; + let spans = frame + .spans + .iter() + .map(|(range, alpha)| CaptionSpan { + start: utf16(range.start), + end: utf16(range.end), + alpha: *alpha, + }) + .collect(); + let (previous, previous_alpha) = frame + .previous + .clone() + .map_or((None, 0.0), |(text, alpha)| (Some(text), alpha)); + CaptionFrame { + text: frame.text, + spans, + previous, + previous_alpha, + animating: veil.is_animating(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn spans_are_utf16_offsets_into_the_shown_tail() { + let fader = CaptionFader::new(160); + let frame = fader.frame(Some("a".into()), "Hola 👋 ".into(), false); + assert_eq!(frame.text, "Hola 👋"); + // "👋" is two UTF-16 units: the whole caption spans 7 of them. + assert_eq!( + frame.spans, + vec![CaptionSpan { + start: 0, + end: 7, + alpha: 0.0 + }] + ); + assert!(frame.animating); + let frame = fader.frame(Some("b".into()), "Sí".into(), false); + assert_eq!(frame.previous.as_deref(), Some("Hola 👋")); + assert_eq!(frame.previous_alpha, 1.0); + } +} diff --git a/crates/mobile/src/client_ffi/mod.rs b/crates/mobile/src/client_ffi/mod.rs index 02b480b041..80d8213f11 100644 --- a/crates/mobile/src/client_ffi/mod.rs +++ b/crates/mobile/src/client_ffi/mod.rs @@ -12,6 +12,8 @@ //! (`snapshot()` / `subscribe()`), never over FFI. mod session; +mod voice; +pub use voice::*; mod types; use std::sync::Arc; diff --git a/crates/mobile/src/client_ffi/types.rs b/crates/mobile/src/client_ffi/types.rs index eb3d4d1752..c62862edae 100644 --- a/crates/mobile/src/client_ffi/types.rs +++ b/crates/mobile/src/client_ffi/types.rs @@ -604,6 +604,10 @@ pub struct ProjectView { pub device_online: bool, pub git_detected: bool, pub created_at_ms: i64, + /// Shared by every checkout of one repository, on any device. + pub group_key: String, + /// The repository group's name, shared like `color_index`. + pub group_name: String, /// Most urgent indicator among its active sessions. pub indicator: ChatIndicator, pub unseen_count: u32, @@ -623,6 +627,8 @@ impl From<&zc::ProjectView> for ProjectView { device_online: p.device_online, git_detected: p.git_detected, created_at_ms: p.created_at_ms, + group_key: p.group_key.clone(), + group_name: p.group_name.clone(), indicator: p.indicator.into(), unseen_count: p.unseen_count, sessions: rows(&p.sessions), diff --git a/crates/mobile/src/client_ffi/voice.rs b/crates/mobile/src/client_ffi/voice.rs new file mode 100644 index 0000000000..6eaa15f46d --- /dev/null +++ b/crates/mobile/src/client_ffi/voice.rs @@ -0,0 +1,550 @@ +//! Bounded platform callbacks; no PCM or provider credentials cross UniFFI. +//! The call state is reduced here (`zeron_voice_session::VoiceView`), so the +//! platform renders typed snapshots exactly as the desktop reads them. +use super::*; +use crate::orb::VoiceOrb; +use std::collections::HashMap; +use std::sync::{ + Mutex, + atomic::{AtomicBool, AtomicU64, Ordering}, +}; +use tokio::sync::{mpsc, oneshot, watch}; +use tokio_util::sync::CancellationToken; +use zeron_proto::voice::{VoicePhase, VoiceRejection, VoiceRole, VoiceWork, remote::Sdp}; +use zeron_voice_session::{VoiceMediaEndpoint, VoiceView}; + +#[derive(Clone, Copy, uniffi::Enum)] +pub enum VoiceMediaOperation { + Prepare, + Offer, + ApplyAnswer, + SetMuted, + Levels, + Close, +} +#[derive(Clone, uniffi::Record)] +pub struct VoiceMediaRequest { + pub request_id: u64, + pub operation: VoiceMediaOperation, + pub sdp: Option, + pub muted: bool, +} +/// Why a platform media operation failed. +#[derive(Clone, Copy, Debug, PartialEq, Eq, uniffi::Enum)] +pub enum VoiceMediaFailure { + PermissionDenied, + Unavailable, +} +#[uniffi::export(with_foreign)] +pub trait VoiceMediaListener: Send + Sync { + fn on_request(&self, request: VoiceMediaRequest); +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, uniffi::Enum)] +pub enum VoiceCallPhase { + Connecting, + Active, + Ending, +} +#[derive(Clone, Copy, Debug, PartialEq, Eq, uniffi::Enum)] +pub enum VoiceCallWork { + Idle, + /// The orchestrator's Codex turn (or a delegation) is running. + Working, + /// Codex asked the user something; answer it in the transcript. + AwaitingInput, +} +#[derive(Clone, Copy, Debug, PartialEq, Eq, uniffi::Enum)] +pub enum VoiceSpeaker { + User, + Assistant, +} +/// Everything a call screen shows. Ephemeral: never log or persist captions. +#[derive(Clone, Debug, uniffi::Record)] +pub struct VoiceCallState { + pub phase: VoiceCallPhase, + pub orb: VoiceOrb, + /// Full id of the host's orchestrator chat (its canonical transcript). + pub chat_id: Option, + pub work: VoiceCallWork, + pub muted: bool, + /// The assistant's voice is playing. + pub speaking: bool, + pub caption: String, + /// Set once the caption is a final segment; live partials have none. + pub caption_speaker: Option, + /// The utterance the caption belongs to: a change is a new speaker turn. + pub caption_item: Option, + /// Normalized 0…1 peaks (microphone is 0 while muted). + pub microphone: f32, + pub speaker: f32, + /// Styles the host offers, once it reports them. + pub voices: Vec, +} +/// Why a call ended without the user hanging up. +#[derive(Clone, Copy, Debug, PartialEq, Eq, uniffi::Enum)] +pub enum VoiceEndReason { + MicrophoneDenied, + AudioUnavailable, + /// Codex on the host must be signed in with ChatGPT. + SignInRequired, + UsageUnavailable, + /// The host already has a voice call. + Busy, + HostUnavailable, + /// The host's Zeron is too old or has remote voice disabled. + HostIncompatible, + ConnectionLost, +} +impl From for VoiceEndReason { + fn from(reason: VoiceRejection) -> Self { + use VoiceRejection::*; + match reason { + MicrophonePermissionDenied => Self::MicrophoneDenied, + DeviceUnavailable | MicrophoneMetadataMissing => Self::AudioUnavailable, + ChatgptRequired => Self::SignInRequired, + IncludedUsageUnavailable | CreditExclusionUnverified => Self::UsageUnavailable, + Busy => Self::Busy, + RemoteHost => Self::HostUnavailable, + Disabled + | Unsupported + | WrongHarness + | NativeRuntimeUnavailable + | AudioFormatUnverified + | DuplexUnverified => Self::HostIncompatible, + InvalidLease | StaleGeneration | Overflow | Protocol => Self::ConnectionLost, + } + } +} +#[uniffi::export(with_foreign)] +pub trait VoiceSessionListener: Send + Sync { + fn on_voice_state(&self, state: VoiceCallState); + /// Terminal, exactly once. `None` is an orderly end. + fn on_voice_closed(&self, reason: Option); +} + +/// Hosts advertising this capability accept client-media voice calls. +#[uniffi::export] +pub fn voice_host_capability() -> String { + zeron_proto::voice::remote::CAPABILITY.into() +} +/// Codex voice styles to offer before a host reports its own. +#[uniffi::export] +pub fn default_voice_styles() -> Vec { + zeron_proto::voice::DEFAULT_VOICES + .iter() + .map(|voice| (*voice).to_owned()) + .collect() +} + +fn call_state(view: &VoiceView) -> VoiceCallState { + VoiceCallState { + phase: match view.phase { + VoicePhase::Active => VoiceCallPhase::Active, + VoicePhase::Stopping | VoicePhase::Closed | VoicePhase::Failed => { + VoiceCallPhase::Ending + } + VoicePhase::Checking | VoicePhase::Starting => VoiceCallPhase::Connecting, + }, + orb: VoiceOrb::from_state(view.orb_state()), + chat_id: view.chat_id().map(str::to_owned), + work: match view.work() { + VoiceWork::Idle => VoiceCallWork::Idle, + VoiceWork::Working => VoiceCallWork::Working, + VoiceWork::AwaitingInput => VoiceCallWork::AwaitingInput, + }, + muted: view.muted(), + speaking: view.playing(), + caption: view.caption().to_owned(), + caption_speaker: view.caption_role().map(|role| match role { + VoiceRole::User => VoiceSpeaker::User, + VoiceRole::Assistant => VoiceSpeaker::Assistant, + }), + caption_item: view.caption_item().map(str::to_owned), + microphone: view.microphone_level(), + speaker: view.speaker_level(), + voices: view + .snapshot + .as_ref() + .map(|s| s.voices.clone()) + .unwrap_or_default(), + } +} + +struct Reply { + sdp: Option, + microphone: u16, + speaker: u16, +} +struct PlatformMedia { + listener: Arc, + pending: Mutex>>>, + next: AtomicU64, + closed: AtomicBool, +} +impl PlatformMedia { + async fn request( + &self, + operation: VoiceMediaOperation, + sdp: Option, + muted: bool, + ) -> Result { + let id = self.next.fetch_add(1, Ordering::Relaxed) + 1; + let (tx, rx) = oneshot::channel(); + { + let mut pending = self.pending.lock().unwrap(); + if self.closed.load(Ordering::Acquire) { + return Err(VoiceRejection::InvalidLease); + } + if pending.len() >= 4 { + return Err(VoiceRejection::Overflow); + } + pending.insert(id, tx); + } + struct Pending<'a>(&'a PlatformMedia, u64); + impl Drop for Pending<'_> { + fn drop(&mut self) { + self.0.pending.lock().unwrap().remove(&self.1); + } + } + let _pending = Pending(self, id); + self.listener.on_request(VoiceMediaRequest { + request_id: id, + operation, + sdp, + muted, + }); + tokio::time::timeout(std::time::Duration::from_secs(30), rx) + .await + .map_err(|_| VoiceRejection::DeviceUnavailable)? + .map_err(|_| VoiceRejection::DeviceUnavailable)? + } +} +#[async_trait::async_trait] +impl VoiceMediaEndpoint for PlatformMedia { + async fn prepare(&self) -> Result<(), VoiceRejection> { + self.request(VoiceMediaOperation::Prepare, None, true) + .await + .map(|_| ()) + } + async fn offer(&self) -> Result { + Sdp::new( + self.request(VoiceMediaOperation::Offer, None, true) + .await? + .sdp + .ok_or(VoiceRejection::Protocol)?, + ) + } + async fn apply_answer(&self, answer: Sdp) -> Result<(), VoiceRejection> { + self.request( + VoiceMediaOperation::ApplyAnswer, + Some(answer.expose().into()), + true, + ) + .await + .map(|_| ()) + } + async fn set_muted(&self, muted: bool) -> Result<(), VoiceRejection> { + self.request(VoiceMediaOperation::SetMuted, None, muted) + .await + .map(|_| ()) + } + async fn levels(&self) -> Result<(u16, u16), VoiceRejection> { + let r = self + .request(VoiceMediaOperation::Levels, None, true) + .await?; + Ok((r.microphone, r.speaker)) + } + fn close(&self) { + if self.closed.swap(true, Ordering::AcqRel) { + return; + } + let pending = std::mem::take(&mut *self.pending.lock().unwrap()); + for (_, tx) in pending { + let _ = tx.send(Err(VoiceRejection::InvalidLease)); + } + self.listener.on_request(VoiceMediaRequest { + request_id: 0, + operation: VoiceMediaOperation::Close, + sdp: None, + muted: true, + }); + } +} + +#[derive(uniffi::Object)] +pub struct VoiceCall { + media: Arc, + cancel: CancellationToken, + muted: watch::Sender, +} +impl Drop for VoiceCall { + fn drop(&mut self) { + self.media.close(); + self.cancel.cancel(); + } +} +#[uniffi::export] +impl VoiceCall { + pub fn set_muted(&self, muted: bool) { + if !self.cancel.is_cancelled() { + self.muted.send_replace(muted); + } + } + pub fn stop(&self) { + self.media.close(); + self.cancel.cancel(); + } + /// Late native callbacks simply miss the retired request. No resume is possible. + pub fn complete_media( + &self, + request_id: u64, + failure: Option, + sdp: Option, + microphone: u16, + speaker: u16, + ) { + if self.media.closed.load(Ordering::Acquire) { + return; + } + if let Some(tx) = self.media.pending.lock().unwrap().remove(&request_id) { + let reply = match failure { + Some(VoiceMediaFailure::PermissionDenied) => { + Err(VoiceRejection::MicrophonePermissionDenied) + } + Some(VoiceMediaFailure::Unavailable) => Err(VoiceRejection::DeviceUnavailable), + None if sdp.as_ref().is_some_and(|s| s.len() > 65_536) => { + Err(VoiceRejection::DeviceUnavailable) + } + None => Ok(Reply { + sdp, + microphone, + speaker, + }), + }; + let _ = tx.send(reply); + } + } +} + +/// Reduce owner events and local mute into call snapshots for the platform. +async fn present( + listener: Arc, + mut events: mpsc::Receiver, + muted: watch::Receiver, +) { + let mut view = VoiceView::new(); + listener.on_voice_state(call_state(&view)); + let mut muted = Some(muted); + loop { + let changed = tokio::select! { + event = events.recv() => match event { + Some(event) => view.reduce(event, std::time::Instant::now()), + None => return, + }, + changed = async { muted.as_mut().unwrap().changed().await }, if muted.is_some() => { + match changed { + Ok(()) => { + let value = *muted.as_mut().unwrap().borrow_and_update(); + view.set_muted(value) + } + // The call handle is gone; its cancellation ends us next. + Err(_) => { + muted = None; + false + } + } + } + }; + if changed { + listener.on_voice_state(call_state(&view)); + } + } +} + +#[uniffi::export] +impl CoreClient { + /// Returns immediately. Callbacks are dispatched on the core runtime; the + /// platform must hop to its media/UI executor and call complete_media. + pub fn start_voice( + &self, + host_device_id: String, + voice: Option, + media: Arc, + listener: Arc, + ) -> Arc { + let platform = Arc::new(PlatformMedia { + listener: media, + pending: Default::default(), + next: AtomicU64::new(0), + closed: AtomicBool::new(false), + }); + let cancel = self.client.voice_cancellation(); + let (muted, rx) = watch::channel(false); + let presented_mute = rx.clone(); + let handle = Arc::new(VoiceCall { + media: platform.clone(), + cancel: cancel.clone(), + muted, + }); + let client = self.client.clone(); + zc::runtime::handle().spawn(async move { + let (events, receiver) = mpsc::channel(32); + let forward = tokio::spawn(present(listener.clone(), receiver, presented_mute)); + let operation = async { + let control = client + .voice_transport(&host_device_id) + .await + .map_err(|_| VoiceRejection::RemoteHost)?; + let config = zeron_proto::ChatConfig { + harness: zeron_proto::HarnessId::Codex, + model: None, + reasoning: None, + model_options: Default::default(), + sandbox: zeron_proto::SandboxLevel::WorkspaceWrite, + }; + zeron_voice_session::run( + control, + platform.clone(), + config, + voice, + cancel.clone(), + events, + rx, + ) + .await + }; + let result = tokio::select! {biased;_=cancel.cancelled()=>Ok(()),r=operation=>r}; + platform.close(); + // Every sender is gone: the presenter drains what was queued. + let _ = forward.await; + listener.on_voice_closed(result.err().map(VoiceEndReason::from)); + }); + handle + } +} + +#[cfg(test)] +mod tests { + use super::*; + use zeron_proto::voice::{VoiceEvent, VoiceSnapshot}; + struct Listener(Mutex>); + impl VoiceMediaListener for Listener { + fn on_request(&self, r: VoiceMediaRequest) { + self.0.lock().unwrap().push(r.request_id); + } + } + fn media(listener: Arc) -> Arc { + Arc::new(PlatformMedia { + listener, + pending: Default::default(), + next: AtomicU64::new(0), + closed: AtomicBool::new(false), + }) + } + #[tokio::test] + async fn close_releases_pending_callbacks_and_rejects_late_replies() { + let listener = Arc::new(Listener(Mutex::new(vec![]))); + let media = media(listener.clone()); + let clone = media.clone(); + let task = tokio::spawn(async move { clone.offer().await }); + tokio::task::yield_now().await; + media.close(); + assert!(task.await.unwrap().is_err()); + assert!(media.pending.lock().unwrap().is_empty()); + let (muted, _) = watch::channel(false); + let call = VoiceCall { + media: media.clone(), + cancel: CancellationToken::new(), + muted, + }; + call.complete_media(1, None, Some("late SDP".into()), 0, 0); + assert!(media.offer().await.is_err()); + assert_eq!( + listener + .0 + .lock() + .unwrap() + .iter() + .filter(|id| **id == 0) + .count(), + 1 + ); + } + + #[tokio::test] + async fn denied_microphone_is_reported_as_such() { + let listener = Arc::new(Listener(Mutex::new(vec![]))); + let media = media(listener.clone()); + let (muted, _) = watch::channel(false); + let call = VoiceCall { + media: media.clone(), + cancel: CancellationToken::new(), + muted, + }; + let clone = media.clone(); + let task = tokio::spawn(async move { clone.prepare().await }); + while listener.0.lock().unwrap().is_empty() { + tokio::task::yield_now().await; + } + call.complete_media(1, Some(VoiceMediaFailure::PermissionDenied), None, 0, 0); + let reason = task.await.unwrap().unwrap_err(); + assert_eq!( + VoiceEndReason::from(reason), + VoiceEndReason::MicrophoneDenied + ); + } + + struct States(Mutex>); + impl VoiceSessionListener for States { + fn on_voice_state(&self, state: VoiceCallState) { + self.0.lock().unwrap().push(state); + } + fn on_voice_closed(&self, _: Option) {} + } + + #[tokio::test] + async fn presenter_names_the_orchestrator_and_applies_local_mute_first() { + let states = Arc::new(States(Mutex::new(vec![]))); + let (events, receiver) = mpsc::channel(8); + let (mute, muted) = watch::channel(false); + let task = tokio::spawn(present(states.clone(), receiver, muted)); + events + .send(VoiceEvent::Snapshot { + snapshot: VoiceSnapshot { + session_id: "s".into(), + chat_id: "voice-orchestrator-full".into(), + generation: 1, + phase: VoicePhase::Active, + muted: false, + playing: false, + work: VoiceWork::AwaitingInput, + reason: None, + voice: None, + voices: vec!["maple".into()], + }, + }) + .await + .unwrap(); + while states.0.lock().unwrap().len() < 2 { + tokio::task::yield_now().await; + } + mute.send_replace(true); + while states.0.lock().unwrap().len() < 3 { + tokio::task::yield_now().await; + } + drop(events); + task.await.unwrap(); + let states = states.0.lock().unwrap(); + assert_eq!(states[0].phase, VoiceCallPhase::Connecting); + assert_eq!(states[0].orb, VoiceOrb::Connecting); + assert_eq!( + states[1].chat_id.as_deref(), + Some("voice-orchestrator-full") + ); + assert_eq!(states[1].work, VoiceCallWork::AwaitingInput); + assert_eq!(states[1].orb, VoiceOrb::AwaitingInput); + assert_eq!(states[1].voices, vec!["maple".to_owned()]); + assert!(states[2].muted); + } +} diff --git a/crates/mobile/src/layout/markdown.rs b/crates/mobile/src/layout/markdown.rs index f5e59e9b48..641408ccfb 100644 --- a/crates/mobile/src/layout/markdown.rs +++ b/crates/mobile/src/layout/markdown.rs @@ -35,6 +35,9 @@ pub(crate) struct PText { pub links: Vec, /// Chip rect within a line box: (top offset, height). pub chip: (f32, f32), + /// Spans painted as mention chips (the desktop's file badge: a soft pill + /// with an icon well at its start), with each one's icon. + pub badges: Vec<(usize, String)>, } pub(crate) struct PCode { @@ -168,6 +171,7 @@ pub(crate) fn prepare_runs(ctx: &mut Ctx, runs: &[InlineRun], kind: TextKind, mu paints, links, chip: ((lh - chip_h) / 2.0, chip_h), + badges: Vec::new(), } } @@ -210,6 +214,7 @@ pub(crate) fn prepare_plain( }], links: Vec::new(), chip: (0.0, 0.0), + badges: Vec::new(), } } @@ -387,6 +392,7 @@ fn prepare_code(ctx: &mut Ctx, language: Option<&str>, code: &str) -> PCode { .collect(), links: Vec::new(), chip: (0.0, 0.0), + badges: Vec::new(), }; let label = language.filter(|l| !l.is_empty()).map(|l| { let (size, lh) = TYPE.small; @@ -461,6 +467,21 @@ pub(crate) fn place_text(t: &PText, x: f32, y: f32, width: f32, out: Option<&mut if paint.chip { out.fill(x + f.x, top + t.chip.0, f.width, t.chip.1, 5.0, ColorRole::InlineCodeBackground); } + if let Some((_, icon)) = t.badges.iter().find(|(span, _)| *span == f.span) { + let (chip_top, chip_h) = t.chip; + out.fill(x + f.x, top + chip_top, f.width, chip_h, 5.0, ColorRole::ToolBadge); + if f.range.start == span.range.start { + let well = chip_h - 2.0; + let (wx, wy) = (x + f.x + 1.0, top + chip_top + 1.0); + out.fill(wx, wy, well, well, 4.0, ColorRole::ToolWell); + let size = well - 4.0; + out.widget( + WidgetKind::Icon { name: icon.clone(), color: ColorRole::TextSoft }, + (wx + (well - size) / 2.0, wy + (well - size) / 2.0, size, size), + None, + ); + } + } if f.utf16.is_empty() { continue; } diff --git a/crates/mobile/src/layout/rows.rs b/crates/mobile/src/layout/rows.rs index 915535b616..272721dc85 100644 --- a/crates/mobile/src/layout/rows.rs +++ b/crates/mobile/src/layout/rows.rs @@ -18,6 +18,7 @@ use zeron_markdown::parser::{IncrementalParser, TopBlock}; use zeron_text::WhiteSpace; use super::display::{ColorRole, DisplayBuilder, FadeEdge, TextRun, WidgetKind}; +use super::file_icons::file_icon_asset; use super::markdown::{Ctx, PBlock, PText, Px, place, place_text, prepare_block, prepare_plain}; use super::style::{Family, TYPE, Weight}; use super::tools::{ThoughtState, ToolGroup, place_tools}; @@ -69,6 +70,8 @@ pub(crate) fn next_version() -> u64 { pub(crate) struct UserBubble { pub text: PText, pub images: Vec, + /// Non-image attachments (desktop-sent ZIPs, docs): icon asset + name. + pub files: Vec<(String, PText)>, pub pending: bool, pub expanded: bool, pub more: PText, @@ -134,6 +137,7 @@ pub(crate) mod geom { pub const BUBBLE_FOLD_LINES: usize = 8; pub const BUBBLE_FOLD_SHOW: usize = 6; pub const THUMB: f32 = 76.0; + pub const FILE_PILL: f32 = 32.0; pub const CHIP_LINE: f32 = 32.0; pub const IMAGE: f32 = 260.0; pub const WORKING: f32 = 36.0; @@ -476,14 +480,41 @@ impl RowBuilder { let key = row_key(&format!("{id}#u")); // Shared parser: strips the image trailer *and* hidden Appshot context. let parsed = zeron_client::attachments::parse_user_message(content); - let body = parsed.text.as_str(); - let images: Vec = parsed.images.into_iter().map(|i| i.path).collect(); let (size, lh) = TYPE.body; let style = ctx.typo.style(Family::Sans, Weight::Regular, false, size); let lh = ctx.typo.px(lh); - let text = prepare_user_text(ctx, body.trim(), style, lh); + // Chips stand in for their attachments (as on the desktop): those + // leave the strip, and an image chip opens its upload on tap. + let mentions = zeron_proto::attachment_mentions::attachment_mentions(&parsed.text); + let chipped = |path: &str| mentions.iter().any(|mention| mention.names_attachment(path)); + let previews: Vec<(u32, String)> = mentions + .iter() + .filter(|mention| mention.is_image) + .filter_map(|mention| { + let image = parsed.images.iter().find(|image| mention.names_attachment(&image.path))?; + Some((mention.index, image.path.clone())) + }) + .collect(); + let text = prepare_user_text(ctx, parsed.text.trim(), style, lh, &previews); + // Copied text reads attachment chips as their plain label. + let body = zeron_proto::attachment_mentions::attachment_mention_prompt(&parsed.text); let (msize, mlh) = TYPE.small; let mstyle = ctx.typo.style(Family::Sans, Weight::Medium, false, msize); + // Only images go to image widgets; other files (desktop-sent ZIPs) + // get a name pill, never an image load. + let (images, others): (Vec<_>, Vec<_>) = parsed + .images + .into_iter() + .filter(|i| !chipped(&i.path)) + .partition(|i| zeron_proto::attachment_mentions::is_image_path(&i.path)); + let images = images.into_iter().map(|i| i.path).collect(); + let files = others + .iter() + .map(|f| { + let name = zeron_proto::attachment_mentions::attachment_display_name(&f.name); + (file_icon_asset(name), prepare_plain(ctx, name, mstyle, ctx.typo.px(mlh), ColorRole::Text, WhiteSpace::Pre)) + }) + .collect(); let expanded = self.expanded.contains(&key); let more = prepare_plain( ctx, @@ -501,55 +532,132 @@ impl RowBuilder { content: Content::User(UserBubble { text, images, + files, pending, expanded, more, }), - copy_text: body.to_owned(), + copy_text: body, } } } -/// User prompt text with `[name](zeron-file:path)` mentions shown as atomic -/// accent `@name` chips (the desktop's file-chip rendering). -fn prepare_user_text(ctx: &mut Ctx, body: &str, style: super::style::Resolved, lh: f32) -> PText { - let links = zeron_proto::file_mentions::file_mention_links(body); - if links.is_empty() { +/// One chip in a sent prompt: its source range, label and icon, and for an +/// image chip the link that opens its upload. +struct UserChip { + range: std::ops::Range, + label: String, + icon: String, + open: Option, +} + +/// The link an image chip opens: the host attachment ref, percent-encoded. +fn image_preview_link(reference: &str) -> String { + let mut out = String::from("zeron-preview://image?ref="); + for byte in reference.bytes() { + if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') { + out.push(byte as char); + } else { + out.push_str(&format!("%{byte:02X}")); + } + } + out +} + +/// The chips a sent prompt's canonical links read as, like the desktop's: +/// files and folders (`zeron-file:`), skills and commands (`zeron-invoke:`), +/// and attachments (`zeron-image:` / `zeron-attachment:`), in text order. +/// `previews` pairs image chip numbers with the uploads they open. +fn user_chips(body: &str, previews: &[(u32, String)]) -> Vec { + let mut chips: Vec = zeron_proto::file_mentions::file_mention_links(body) + .into_iter() + .map(|link| UserChip { + icon: if link.is_dir { "fileicon-folders-folder".to_owned() } else { file_icon_asset(&link.path) }, + range: link.range, + label: link.basename, + open: None, + }) + .collect(); + chips.extend(zeron_proto::invocation::invocation_links(body).into_iter().map(|(range, invocation)| UserChip { + range, + label: invocation.name().to_owned(), + icon: if invocation.prefix() == '/' { "command" } else { "wand.and.stars" }.to_owned(), + open: None, + })); + chips.extend(zeron_proto::attachment_mentions::attachment_mentions(body).into_iter().map(|mention| UserChip { + range: mention.range, + icon: if mention.is_image { "photo".to_owned() } else { file_icon_asset(&mention.label) }, + open: previews + .iter() + .find(|(index, _)| mention.is_image && *index == mention.index) + .map(|(_, reference)| image_preview_link(reference)), + label: mention.label, + })); + chips.sort_by_key(|chip| chip.range.start); + // Links never nest; drop any overlap rather than paint two chips at once. + let mut end = 0; + chips.retain(|chip| { + let keep = chip.range.start >= end; + if keep { + end = chip.range.end; + } + keep + }); + chips +} + +/// User prompt text with its canonical links shown as atomic chips: the +/// desktop's soft pill with an icon well, the label in the body font. +fn prepare_user_text(ctx: &mut Ctx, body: &str, style: super::style::Resolved, lh: f32, previews: &[(u32, String)]) -> PText { + let chips = user_chips(body, previews); + if chips.is_empty() { return prepare_plain(ctx, body, style, lh, ColorRole::Text, WhiteSpace::PreWrap); } - let (size, _) = TYPE.body; - let chip = ctx.typo.style(Family::Sans, Weight::Medium, false, size); + // The pill sits two points inside the line box, centered like the + // desktop's; its icon well is the pill's height less a point each side. + let chip_h = lh - ctx.typo.px(4.0); + let chip_pad = (1.0 + (chip_h - 2.0) + ctx.typo.px(5.0), ctx.typo.px(6.0)); let mut text = String::with_capacity(body.len()); let mut spans = Vec::new(); let mut paints = Vec::new(); + let mut badges = Vec::new(); + let mut links = Vec::new(); let mut at = 0; - let mut push = |text: &mut String, piece: &str, style: zeron_text::StyleId, atomic: bool, color: ColorRole| { + // Returns the index of the span pushed for a non-empty piece. + let mut push = |text: &mut String, piece: &str, pad: (f32, f32), atomic: bool, link: Option| { if piece.is_empty() { - return; + return None; } let start = text.len(); text.push_str(piece); spans.push(zeron_text::Span { range: start..text.len(), - style, - pad_start: 0.0, - pad_end: 0.0, + style: style.id, + pad_start: pad.0, + pad_end: pad.1, atomic, }); paints.push(super::markdown::SpanPaint { - color, + color: ColorRole::Text, decoration: super::display::Decoration::None, - link: None, + link, chip: false, }); + Some(spans.len() - 1) }; - for link in &links { - push(&mut text, &body[at..link.range.start], style.id, false, ColorRole::Text); - push(&mut text, &format!("@{}", link.basename), chip.id, true, ColorRole::Link); - at = link.range.end; + for chip in chips { + push(&mut text, &body[at..chip.range.start], (0.0, 0.0), false, None); + let link = chip.open.map(|url| { + links.push(url); + (links.len() - 1) as u16 + }); + if let Some(span) = push(&mut text, &chip.label, chip_pad, true, link) { + badges.push((span, chip.icon)); + } + at = chip.range.end; } - push(&mut text, &body[at..], style.id, false, ColorRole::Text); + push(&mut text, &body[at..], (0.0, 0.0), false, None); let p = zeron_text::prepare( &ctx.typo.book, ctx.cache, @@ -566,8 +674,9 @@ fn prepare_user_text(ctx: &mut Ctx, body: &str, style: super::style::Resolved, l lh, base: super::style::baseline(lh, style), paints, - links: Vec::new(), - chip: (0.0, 0.0), + links, + chip: ((lh - chip_h) / 2.0, chip_h), + badges, } } @@ -690,18 +799,36 @@ fn place_user(u: &UserBubble, px: Px, x: f32, y: f32, cw: f32, out: Option<&mut let more_h = if folds { u.more.lh + px.v(4.0) } else { 0.0 }; let bubble_w = if folds { max_w } else { stats.max_line_width.ceil() + pad_x * 2.0 }; let bubble_h = if stats.line_count == 0 { 0.0 } else { text_h + more_h + pad_y * 2.0 }; - let thumbs_h = if u.images.is_empty() { 0.0 } else { px.v(THUMB) + if bubble_h > 0.0 { px.v(8.0) } else { 0.0 } }; + let side = px.v(THUMB); + let gap = px.v(6.0); + let pill_h = px.v(FILE_PILL); + // Thumbnails row, then one pill per file, stacked above the bubble. + let files_y = if u.images.is_empty() { 0.0 } else { side + gap }; + let attach_h = (files_y + u.files.len() as f32 * (pill_h + gap) - gap).max(0.0); + let thumbs_h = if attach_h > 0.0 { attach_h + if bubble_h > 0.0 { px.v(8.0) } else { 0.0 } } else { 0.0 }; let h = thumbs_h + bubble_h; let Some(out) = out else { return h }; // Thumbnails right-aligned above the bubble. - let side = px.v(THUMB); - let gap = px.v(6.0); let mut tx = x + cw - side; for img in u.images.iter().rev() { out.fill(tx, y, side, side, px.v(12.0), ColorRole::ChipBackground); out.widget(WidgetKind::Image { reference: img.clone() }, (tx, y, side, side), None); tx -= side + gap; } + // Files: right-aligned icon + name pills; long names fade at the edge. + let is = px.v(16.0); + let inset = px.v(10.0) + is + px.v(8.0); + let chrome = inset + px.v(12.0); + let name_w = (max_w - chrome).max(1.0); + let mut fy = y + files_y; + for (icon, name) in &u.files { + let pw = chrome + name.p.max_content_width().ceil().min(name_w); + let fx = x + cw - pw; + out.fill(fx, fy, pw, pill_h, px.v(12.0), ColorRole::ChipBackground); + out.widget(WidgetKind::Icon { name: icon.clone(), color: ColorRole::TextSoft }, (fx + px.v(10.0), fy + (pill_h - is) / 2.0, is, is), None); + place_text_lines(name, fx + inset, fy + (pill_h - name.lh) / 2.0, name_w, 1, px, out); + fy += pill_h + gap; + } if bubble_h > 0.0 { let bx = x + cw - bubble_w; let by = y + thumbs_h; @@ -740,7 +867,7 @@ pub(crate) fn content_heap_bytes(content: &Content) -> usize { } match content { Content::Block(b) => block(b), - Content::User(u) => u.text.p.heap_bytes() + u.more.p.heap_bytes(), + Content::User(u) => u.text.p.heap_bytes() + u.more.p.heap_bytes() + u.files.iter().map(|(_, n)| n.p.heap_bytes()).sum::(), Content::Tools(t) => super::tools::heap_bytes(t), Content::Chip(c) => c.text.p.heap_bytes(), Content::Image { reference } => reference.len(), diff --git a/crates/mobile/src/layout/tests.rs b/crates/mobile/src/layout/tests.rs index 01ac1c72cb..d503e96903 100644 --- a/crates/mobile/src/layout/tests.rs +++ b/crates/mobile/src/layout/tests.rs @@ -464,15 +464,31 @@ fn bench_layout_passes() { } #[test] -fn user_mentions_render_as_accent_chips() { +fn user_mentions_render_as_chips() { + // The desktop's chips: a soft pill with the file theme's icon on a well, + // the label in the body color — for files, folders, skills and commands. let mut w = worker(390.0); - let text = "Look at [mod.rs](zeron-file:crates/mobile/src/layout/mod.rs) please".to_owned(); + let skill = zeron_proto::invocation::Invocation::Skill { name: "review".into(), path: "/repo/SKILL.md".into(), command: None }; + let text = format!( + "Look at [mod.rs](zeron-file:crates/mobile/src/layout/mod.rs) in [layout](zeron-file:crates/mobile/src/layout/) with {}", + skill.link() + ); w.input = debug_input(vec![DebugEntry { id: "u".into(), user: true, text, streaming: false }], false); let frame = w.pass(); let d = frame.display(0).unwrap(); - assert!(d.text.contains("@mod.rs"), "{}", d.text); - assert!(!d.text.contains("zeron-file:")); - assert!(d.runs.iter().any(|r| r.color == display::ColorRole::Link)); + assert!(d.text.contains("mod.rs") && !d.text.contains("@mod.rs"), "{}", d.text); + assert!(!d.text.contains("zeron-"), "{}", d.text); + assert!(d.runs.iter().all(|r| r.color != display::ColorRole::Link)); + let icons: Vec<&str> = d + .widgets + .iter() + .filter_map(|w| match &w.kind { + display::WidgetKind::Icon { name, .. } => Some(name.as_str()), + _ => None, + }) + .collect(); + assert_eq!(icons, ["fileicon-files-rust", "fileicon-folders-folder", "wand.and.stars"]); + assert_eq!(d.boxes.iter().filter(|b| b.color == display::ColorRole::ToolBadge).count(), 3); } #[test] @@ -487,6 +503,35 @@ fn user_attachments_render_as_images_not_trailer_text() { assert!(d.widgets.iter().any(|w| matches!(&w.kind, display::WidgetKind::Image { reference } if reference.ends_with("shot.png")))); } +#[test] +fn synced_file_attachments_render_as_chips_and_name_pills() { + // A desktop send mixing an image and ZIPs (one still a queued-upload ref), + // with composer chips in the prompt. Chipped attachments show as their + // chip only; the image chip opens its upload; the unchipped ZIP keeps a + // name pill and never reaches image loading. + let mut w = worker(390.0); + let text = "Compare [Image 1](zeron-image:1) with [notes.zip](zeron-attachment:2)\n\nAttached images (local files — open them to view):\n- /tmp/uploads/ab12cd34-Image_1.png\n- /tmp/uploads/ab12cd34-notes.zip\n- pending://up-3/logs.zip".to_owned(); + w.input = debug_input(vec![DebugEntry { id: "u".into(), user: true, text, streaming: false }], false); + let frame = w.pass(); + let d = frame.display(0).unwrap(); + assert!(!d.widgets.iter().any(|w| matches!(&w.kind, display::WidgetKind::Image { .. }))); + let icons: Vec<&str> = d + .widgets + .iter() + .filter_map(|w| match &w.kind { + display::WidgetKind::Icon { name, .. } => Some(name.as_str()), + _ => None, + }) + .collect(); + // The unchipped ZIP's pill, then the bubble's two chips. + assert_eq!(icons, ["fileicon-files-compressed", "photo", "fileicon-files-compressed"]); + assert!(d.text.contains("Compare Image 1 with notes.zip"), "{}", d.text); + assert!(d.text.contains("logs.zip") && !d.text.contains("ab12cd34-"), "{}", d.text); + assert!(!d.text.contains("zeron-image") && !d.text.contains("zeron-attachment"), "{}", d.text); + let links: Vec<&str> = d.links.iter().map(|l| l.url.as_str()).collect(); + assert_eq!(links, ["zeron-preview://image?ref=%2Ftmp%2Fuploads%2Fab12cd34-Image_1.png"]); +} + #[test] fn folded_user_message_fades_its_last_line() { let mut w = worker(390.0); diff --git a/crates/mobile/src/layout/tools.rs b/crates/mobile/src/layout/tools.rs index f815276e39..8a3bd8458e 100644 --- a/crates/mobile/src/layout/tools.rs +++ b/crates/mobile/src/layout/tools.rs @@ -653,6 +653,7 @@ fn prepare_thought(ctx: &mut Ctx, st: &Styles, lines: &[Vec], more: b paints, links: Vec::new(), chip: (0.0, 0.0), + badges: Vec::new(), }; prepared.push(ThoughtLine { indent, gutter, bars, body: Some(body) }); } diff --git a/crates/mobile/src/lib.rs b/crates/mobile/src/lib.rs index b291ff16a6..f54d888654 100644 --- a/crates/mobile/src/lib.rs +++ b/crates/mobile/src/lib.rs @@ -3,11 +3,15 @@ //! - [`client_ffi`]: account, workspace and session state (wraps `zeron-client`). //! - [`layout`]: analytic transcript layout — markdown → measured display lists //! (wraps `zeron-markdown` + `zeron-text`). +//! - [`orb`]: the desktop's voice orb as paintable frames (wraps `zeron-orb`). +//! - [`caption`]: the voice caption's streaming veil (wraps `zeron-veil`). uniffi::setup_scaffolding!("zeron_core"); mod client_ffi; pub mod layout; +pub mod caption; +pub mod orb; pub mod wallpaper; /// Version handshake: the Swift/Kotlin bindings must match the linked library. diff --git a/crates/mobile/src/orb.rs b/crates/mobile/src/orb.rs new file mode 100644 index 0000000000..9c0a84509c --- /dev/null +++ b/crates/mobile/src/orb.rs @@ -0,0 +1,192 @@ +//! The desktop's thinking orb for platform canvases. Geometry, clock, audio +//! response and state crossfades come from `zeron-orb`, exactly as the GPUI +//! widget runs them; the platform only strokes lines and fills disks. +//! +//! Frames are flat `f32` lists so a 30 fps loop crosses the FFI with two +//! buffers instead of hundreds of records: +//! - `lines`: `[x1, y1, x2, y2, width, white, alpha]` per segment; +//! - `dots`: `[x, y, radius, white, alpha]` per disk, back to front. +//! +//! Coordinates are logical points in a `size × size` box. `white` is ink +//! (0 = darkest on paper); dark appearances paint `1 - white`. Paint lines, +//! then dots, onto a transparent canvas. +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; +use zeron_orb::{OrbAnimator, OrbSize, OrbState, engine::Frame}; + +/// The four tuned size presets (inline 20, avatar 64, large 96, hero 128). +#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)] +pub enum OrbPreset { + Inline, + Avatar, + Large, + Hero, +} + +impl From for OrbSize { + fn from(preset: OrbPreset) -> Self { + match preset { + OrbPreset::Inline => OrbSize::Inline, + OrbPreset::Avatar => OrbSize::Avatar, + OrbPreset::Large => OrbSize::Large, + OrbPreset::Hero => OrbSize::Hero, + } + } +} + +/// What the voice orb is showing, in call terms. +#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)] +pub enum VoiceOrb { + /// No call: the calm form of a muted orchestrator. + Idle, + Connecting, + Listening, + Speaking, + Working, + AwaitingInput, + Muted, +} + +impl From for OrbState { + fn from(orb: VoiceOrb) -> Self { + match orb { + VoiceOrb::Idle | VoiceOrb::Muted => OrbState::Breathing, + VoiceOrb::Connecting => OrbState::Connecting, + VoiceOrb::Listening => OrbState::Listening, + VoiceOrb::Speaking => OrbState::Composing, + VoiceOrb::Working => OrbState::Working, + VoiceOrb::AwaitingInput => OrbState::Solving, + } + } +} + +impl VoiceOrb { + pub(crate) fn from_state(state: OrbState) -> Self { + match state { + OrbState::Connecting => VoiceOrb::Connecting, + OrbState::Listening => VoiceOrb::Listening, + OrbState::Composing => VoiceOrb::Speaking, + OrbState::Working => VoiceOrb::Working, + OrbState::Solving => VoiceOrb::AwaitingInput, + _ => VoiceOrb::Muted, + } + } +} + +#[derive(Debug, Clone, uniffi::Record)] +pub struct OrbFrame { + /// Edge length of the artwork box, in logical points. + pub size: f32, + pub lines: Vec, + pub dots: Vec, +} + +/// One animated orb. Calls are cheap and safe from any thread; drive +/// [`OrbRenderer::next_frame`] from the display link. +#[derive(uniffi::Object)] +pub struct OrbRenderer { + inner: Mutex, +} + +struct Inner { + animator: OrbAnimator, + frame: Frame, +} + +/// Below this the desktop paint path skips a primitive too. +const MIN_ALPHA: f32 = 0.02; + +#[uniffi::export] +impl OrbRenderer { + /// Crossfades state changes over 300 ms, like the desktop voice orbs. + #[uniffi::constructor] + pub fn new(preset: OrbPreset, orb: VoiceOrb) -> Arc { + Arc::new(Self { + inner: Mutex::new(Inner { + animator: OrbAnimator::new(orb.into(), preset.into()) + .with_transition(Duration::from_millis(300)), + frame: Frame::new(), + }), + }) + } + + pub fn set_orb(&self, orb: VoiceOrb) { + let inner = &mut *self.inner.lock().unwrap(); + inner.animator.set_state(orb.into(), &inner.frame); + } + + /// Normalized 0…1 peaks; they speed the motion up, never the geometry. + pub fn set_audio_levels(&self, microphone: f32, speaker: f32) { + self.inner + .lock() + .unwrap() + .animator + .set_audio_levels(microphone, speaker, Instant::now()); + } + + /// Advance to now and return the frame to paint. Time only accumulates + /// while `animating`; reduced motion shows the static representative frame. + pub fn next_frame(&self, animating: bool, reduced_motion: bool) -> OrbFrame { + let inner = &mut *self.inner.lock().unwrap(); + inner + .animator + .tick(Instant::now(), animating && !reduced_motion, reduced_motion); + inner.animator.draw(&mut inner.frame); + let r_min = inner.animator.r_min(); + let mut lines = Vec::with_capacity(inner.frame.lines.len() * 7); + for l in inner.frame.lines.iter().filter(|l| l.a >= MIN_ALPHA) { + lines.extend_from_slice(&[l.x1, l.y1, l.x2, l.y2, l.w, l.white, l.a]); + } + let mut dots = Vec::with_capacity(inner.frame.dots.len() * 5); + for d in inner.frame.dots.iter().filter(|d| d.a >= MIN_ALPHA) { + dots.extend_from_slice(&[d.x, d.y, d.r.max(r_min), d.white, d.a]); + } + OrbFrame { + size: inner.animator.pixels(), + lines, + dots, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_voice_orb_paints_finite_geometry_inside_its_box() { + for orb in [ + VoiceOrb::Idle, + VoiceOrb::Connecting, + VoiceOrb::Listening, + VoiceOrb::Speaking, + VoiceOrb::Working, + VoiceOrb::AwaitingInput, + VoiceOrb::Muted, + ] { + let renderer = OrbRenderer::new(OrbPreset::Hero, orb); + renderer.set_audio_levels(0.5, 0.8); + let frame = renderer.next_frame(true, false); + assert_eq!(frame.size, 128.0); + assert!(!frame.dots.is_empty(), "{orb:?}"); + assert_eq!(frame.dots.len() % 5, 0); + assert_eq!(frame.lines.len() % 7, 0); + assert!(frame.dots.iter().chain(&frame.lines).all(|v| v.is_finite())); + assert_eq!( + VoiceOrb::from_state(orb.into()), + match orb { + VoiceOrb::Idle => VoiceOrb::Muted, + orb => orb, + } + ); + } + } + + #[test] + fn reduced_motion_is_a_still_frame() { + let renderer = OrbRenderer::new(OrbPreset::Avatar, VoiceOrb::Listening); + let first = renderer.next_frame(true, true); + std::thread::sleep(Duration::from_millis(20)); + assert_eq!(first.dots, renderer.next_frame(true, true).dots); + } +} diff --git a/crates/orb/Cargo.toml b/crates/orb/Cargo.toml new file mode 100644 index 0000000000..504bf7c273 --- /dev/null +++ b/crates/orb/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "zeron-orb" +version.workspace = true +edition.workspace = true +license.workspace = true +publish.workspace = true + +# The thinking-orb animation engine without a renderer: desktop paints its +# frames through GPUI, mobile through Core Graphics, from identical geometry. +[dependencies] diff --git a/crates/orb/src/animator.rs b/crates/orb/src/animator.rs new file mode 100644 index 0000000000..345de9a5e5 --- /dev/null +++ b/crates/orb/src/animator.rs @@ -0,0 +1,300 @@ +//! Renderer-agnostic orb playback: the continuous clock, audio-driven speed +//! and state crossfades. Hosts decide when to tick and paint the frames. +use std::time::{Duration, Instant}; + +use crate::{ + REDUCED_MOTION_T, + engine::{Frame, draw_mode_into_resolved}, + motion::{AnimationClock, AudioResponse}, + presets::{Resolved, resolve_preset}, + types::{OrbSize, OrbState}, +}; + +struct StateTransition { + from: Frame, + source: Option, + started: f64, +} + +/// Blend two frames by opacity with a smoothstep weight. +pub fn crossfade(from: &Frame, to: &Frame, progress: f32, out: &mut Frame) { + out.clear(); + let progress = progress.clamp(0.0, 1.0); + let weight = progress * progress * (3.0 - 2.0 * progress); + for (frame, opacity) in [(from, 1.0 - weight), (to, weight)] { + out.dots.extend(frame.dots.iter().filter_map(|dot| { + let mut dot = *dot; + dot.a *= opacity; + (dot.a >= 0.005).then_some(dot) + })); + out.lines.extend(frame.lines.iter().filter_map(|line| { + let mut line = *line; + line.a *= opacity; + (line.a >= 0.005).then_some(line) + })); + } +} + +/// One orb's animation state. The engine takes continuous, unbounded time — +/// its modes mix incommensurate frequencies, so there is no seamless wrap +/// point — and this owns that clock. +pub struct OrbAnimator { + state: OrbState, + size: OrbSize, + /// Multiplier on top of the preset's baked speed. + speed: f32, + clock: AnimationClock, + audio: Option, + transition_duration: Duration, + transition: Option, + resolved: Resolved, + target_frame: Frame, + t: f32, +} + +impl OrbAnimator { + pub fn new(state: OrbState, size: OrbSize) -> Self { + Self { + state, + size, + speed: 1.0, + clock: AnimationClock::default(), + audio: None, + transition_duration: Duration::ZERO, + transition: None, + resolved: resolve_preset(state, size), + target_frame: Frame::new(), + t: 0.0, + } + } + + /// Crossfade state geometry without restarting the clock. + pub fn with_transition(mut self, duration: Duration) -> Self { + self.set_transition(duration); + self + } + + pub fn set_transition(&mut self, duration: Duration) { + self.transition_duration = duration; + } + + pub fn state(&self) -> OrbState { + self.state + } + + pub fn size(&self) -> OrbSize { + self.size + } + + pub fn speed(&self) -> f32 { + self.speed + } + + /// Logical edge length of the preset's artwork. + pub fn pixels(&self) -> f32 { + self.size.pixels() + } + + /// Smallest painted dot radius for the current preset. + pub fn r_min(&self) -> f32 { + self.resolved.opts.r_min.unwrap_or(0.3) + } + + pub fn is_running(&self) -> bool { + self.clock.is_running() + } + + pub fn is_transitioning(&self) -> bool { + self.transition.is_some() + } + + /// Change state. `visible` is the frame on screen now: an interrupted + /// fade continues from its visible composite instead of jumping back. + pub fn set_state(&mut self, state: OrbState, visible: &Frame) -> bool { + if self.state == state { + return false; + } + self.transition = (!self.transition_duration.is_zero() + && self.clock.is_running() + && (!visible.dots.is_empty() || !visible.lines.is_empty())) + .then(|| StateTransition { + from: visible.clone(), + // Animate the outgoing form, unless it is itself mid-fade. + source: self.transition.is_none().then(|| self.resolved.clone()), + started: self.clock.active_seconds, + }); + self.state = state; + self.resolved = resolve_preset(state, self.size); + true + } + + pub fn set_size(&mut self, size: OrbSize) -> bool { + if self.size == size { + return false; + } + self.size = size; + self.resolved = resolve_preset(self.state, size); + true + } + + pub fn set_speed(&mut self, speed: f32) -> bool { + let speed = if speed.is_finite() { + speed.clamp(0.0, 100.0) + } else { + 1.0 + }; + let changed = self.speed != speed; + self.speed = speed; + changed + } + + /// Audio affects only visual motion, with independent microphone and + /// speaker envelopes. + pub fn set_audio_levels(&mut self, microphone: f32, speaker: f32, now: Instant) -> bool { + self.audio + .get_or_insert_with(|| AudioResponse::new(now)) + .set(microphone, speaker, now) + } + + /// Freeze accumulated time (paused, hidden or reduced motion). + pub fn stop(&mut self, now: Instant) { + self.clock.stop(now); + } + + pub fn cancel_transition(&mut self) { + self.transition = None; + } + + /// Advance the clock to `now`. Time only accumulates while `animating`. + pub fn tick(&mut self, now: Instant, animating: bool, reduced: bool) { + let audio_speed = self.audio.as_ref().map_or(1.0, |audio| audio.speed(now)); + let rate = f64::from(self.resolved.speed) * f64::from(self.speed) * audio_speed; + self.clock.advance(now, rate, animating); + self.t = if reduced { + REDUCED_MOTION_T + } else { + self.clock.seconds as f32 + }; + if reduced { + self.transition = None; + } + } + + /// Geometry at the last tick, in the preset's logical pixels. + pub fn draw(&mut self, out: &mut Frame) { + let progress = self.transition.as_ref().map_or(1.0, |transition| { + ((self.clock.active_seconds - transition.started) + / self.transition_duration.as_secs_f64()) as f32 + }); + if progress >= 1.0 { + self.transition = None; + } + let size = self.size.pixels(); + let Some(transition) = &mut self.transition else { + // The allocation-free path outside a fade. + draw_mode_into_resolved(self.resolved.mode, size, self.t, &self.resolved.opts, out); + return; + }; + draw_mode_into_resolved( + self.resolved.mode, + size, + self.t, + &self.resolved.opts, + &mut self.target_frame, + ); + if let Some(source) = &transition.source { + draw_mode_into_resolved( + source.mode, + size, + self.t, + &source.opts, + &mut transition.from, + ); + } + crossfade(&transition.from, &self.target_frame, progress, out); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::engine::{Dot, Line}; + + #[test] + fn state_crossfade_preserves_endpoints_and_blends_dots_and_lines() { + let from = Frame { + dots: vec![Dot::new(1.0, 2.0, 0.0, 1.0, 0.5)], + lines: vec![Line { + x1: 0.0, + y1: 0.0, + x2: 1.0, + y2: 1.0, + white: 0.5, + a: 1.0, + w: 1.0, + }], + }; + let to = Frame { + dots: vec![Dot::new(3.0, 4.0, 0.0, 1.0, 0.5)], + lines: vec![], + }; + let mut out = Frame::new(); + crossfade(&from, &to, 0.0, &mut out); + assert_eq!(out.dots.len(), 1); + assert_eq!(out.dots[0].x, 1.0); + assert_eq!(out.lines[0].a, 1.0); + crossfade(&from, &to, 0.5, &mut out); + assert_eq!(out.dots.len(), 2); + assert_eq!(out.dots[0].a, 0.5); + assert_eq!(out.dots[1].a, 0.5); + assert_eq!(out.lines[0].a, 0.5); + crossfade(&from, &to, 1.0, &mut out); + assert_eq!(out.dots.len(), 1); + assert_eq!(out.dots[0].x, 3.0); + assert!(out.lines.is_empty()); + } + + #[test] + fn interrupted_state_transition_starts_from_the_visible_frame() { + let now = Instant::now(); + let mut orb = OrbAnimator::new(OrbState::Working, OrbSize::Avatar) + .with_transition(Duration::from_millis(300)); + orb.tick(now, true, false); + let mut visible = Frame::new(); + visible + .dots + .push(Dot::new(42.0, 0.0, 0.0, 1.0, 0.5).with_a(0.4)); + assert!(orb.set_state(OrbState::Listening, &visible)); + assert_eq!(orb.transition.as_ref().unwrap().from.dots[0].x, 42.0); + assert!(orb.transition.as_ref().unwrap().source.is_some()); + visible.dots[0].a = 0.2; + orb.set_state(OrbState::Composing, &visible); + let transition = orb.transition.as_ref().unwrap(); + assert_eq!(transition.from.dots[0].a, 0.2); + // A fade from a fade keeps the composite rather than redrawing it. + assert!(transition.source.is_none()); + orb.tick(now + Duration::from_millis(16), false, true); + assert!(orb.transition.is_none()); + assert!(!orb.is_running()); + } + + #[test] + fn stopped_orbs_switch_state_without_a_fade_and_finish_fades() { + let now = Instant::now(); + let mut orb = OrbAnimator::new(OrbState::Working, OrbSize::Hero) + .with_transition(Duration::from_millis(300)); + let mut frame = Frame::new(); + orb.tick(now, false, false); + orb.draw(&mut frame); + orb.set_state(OrbState::Listening, &frame); + assert!(!orb.is_transitioning()); + orb.tick(now, true, false); + orb.draw(&mut frame); + orb.set_state(OrbState::Composing, &frame); + assert!(orb.is_transitioning()); + orb.tick(now + Duration::from_millis(400), true, false); + orb.draw(&mut frame); + assert!(!orb.is_transitioning()); + assert!(!frame.dots.is_empty()); + } +} diff --git a/crates/orb/src/engine/braid.rs b/crates/orb/src/engine/braid.rs new file mode 100644 index 0000000000..1168e10e3e --- /dev/null +++ b/crates/orb/src/engine/braid.rs @@ -0,0 +1,58 @@ +// Extracted from Bezel 6141af9c16f7353cdf36003f7404e0a94566a163; MIT. See THIRD_PARTY_NOTICES.md. +//! Braid: three strands plait around the sphere — the "weaving" state. + +use crate::engine::{ + core::{Dot, Frame, frac, make_proj, radius_scale, with_fib_dirs}, + profiles::{MAX_GHOST_N, MAX_STRAND_N, ModeOpts, count_usize}, +}; +use std::f32::consts::PI; + +pub fn draw_braid_into(size: f32, t: f32, o: &ModeOpts, out: &mut Frame) { + let cx = size / 2.0; + let cy = size / 2.0; + let r = (size / 2.0) * 0.76; + let pt = make_proj(t * 0.4, 0.3, cx, cy, 1.0); + let rs = radius_scale(size, o.rs_pow.unwrap_or(0.6)); + + let dots = &mut out.dots; + let ghost_n = count_usize(o.ghost_n, 150.0, 0, MAX_GHOST_N as usize); + let strand_n = count_usize(o.strand_n, 52.0, 0, MAX_STRAND_N as usize); + let inv_r = 1.0 / r; + dots.reserve(ghost_n.saturating_add(strand_n.saturating_mul(3))); + + with_fib_dirs(ghost_n, |directions| { + for d in directions { + let (px, py, z) = pt.project(d[0] * r, d[1] * r, d[2] * r); + let depth = (z * inv_r + 1.0) / 2.0; + dots.push(Dot::new(px, py, z, 0.8 * rs, 0.78).with_a(0.1 + 0.22 * depth)); + } + }); + + let turns = o.turns.unwrap_or(3.0); + let r_base = o.r_base.unwrap_or(1.2); + let r_depth = o.r_depth.unwrap_or(1.8); + let inv_strand = 1.0 / strand_n as f32; + for s in 0..3 { + let phase = (s as f32 / 3.0) * 2.0 * PI; + for i in 0..strand_n { + let u = (frac(i as f32 * inv_strand + t * 0.045) * 2.0 - 1.0) * 0.96; + let surf = (1.0 - u * u).max(0.0).sqrt(); + let end_fade = ((0.96 - u.abs()) / 0.12).clamp(0.0, 1.0); + let a = u * PI * turns + phase; + let weave = 1.0 + 0.075 * (u * PI * turns * 2.0 + phase * 2.0 + t * 0.8).sin(); + let rr = surf * r * weave; + let (px, py, zr) = pt.project(a.cos() * rr, u * r * weave, a.sin() * rr); + let depth = (zr * inv_r + 1.0) / 2.0; + dots.push( + Dot::new( + px, + py, + zr, + (r_base + r_depth * depth) * rs, + 0.55 - 0.45 * depth, + ) + .with_a(end_fade * (0.45 + 0.55 * depth)), + ); + } + } +} diff --git a/crates/orb/src/engine/concepts.rs b/crates/orb/src/engine/concepts.rs new file mode 100644 index 0000000000..92ec0d0a97 --- /dev/null +++ b/crates/orb/src/engine/concepts.rs @@ -0,0 +1,195 @@ +// Extracted from Bezel 6141af9c16f7353cdf36003f7404e0a94566a163; MIT. See THIRD_PARTY_NOTICES.md. +//! Original low-density orb concepts: focus, gyroscope, and memory echoes. +//! +//! These modes deliberately reuse the engine's existing count/radius knobs and +//! emit dots only, keeping them on the cheapest GPUI paint path. + +use crate::engine::{ + core::{Dot, Frame, frac, make_proj, radius_scale, with_unit_circle}, + profiles::{MAX_LANES, MAX_PARTICLES, MAX_SEGS, ModeOpts, count_usize}, +}; +use std::f32::consts::PI; + +/// Iris-like streams converge on a small, steady focal core. +pub fn draw_focus_into(size: f32, t: f32, o: &ModeOpts, out: &mut Frame) { + let center = size / 2.0; + let extent = size * 0.38 * o.spread.unwrap_or(1.0); + let lanes = count_usize(o.lanes, 6.0, 1, MAX_LANES as usize); + let segs = count_usize(o.segs, 12.0, 1, MAX_SEGS as usize); + let core_n = count_usize(o.particles, 5.0, 1, MAX_PARTICLES as usize); + let rs = radius_scale(size, o.rs_pow.unwrap_or(0.6)); + let r_base = o.r_base.unwrap_or(0.9); + let r_depth = o.r_depth.unwrap_or(1.25); + let total = lanes.saturating_mul(segs).saturating_add(core_n); + out.dots.reserve(total); + + // Close and reopen a six-blade aperture without collapsing the samples + // into a star. Every blade keeps a continuous curved silhouette; only its + // inner edge moves, so the concept remains readable in a frozen frame. + let openness = 0.5 + 0.5 * (t * 0.9).sin(); + let inner = 0.12 + 0.2 * openness; + let rotation = t * 0.11; + let inv_segs = 1.0 / segs as f32; + with_unit_circle(lanes, |directions| { + for (lane, direction) in directions.iter().enumerate() { + for i in 0..segs { + let u = (i as f32 + 0.5) * inv_segs; + let radial = inner + (1.0 - inner) * u; + let curl = (0.88 - 0.24 * openness) * (1.0 - u).powf(1.25); + let twist = rotation + curl; + let (st, ct) = twist.sin_cos(); + let dx = direction[0] * ct - direction[1] * st; + let dy = direction[0] * st + direction[1] * ct; + let inner_weight = 1.0 - u; + let shimmer = 0.96 + 0.05 * (t * 1.4 + lane as f32 * 0.8).sin(); + out.dots.push( + Dot::new( + center + dx * extent * radial, + center + dy * extent * radial, + 0.0, + (r_base + r_depth * inner_weight) * shimmer * rs, + 0.12 + 0.52 * u, + ) + .with_a(0.48 + 0.52 * (PI * u).sin()), + ); + } + } + }); + + // A tiny breathing nucleus makes the destination legible at inline size. + let core_radius = size * (0.035 + 0.004 * (t * 1.5).sin()); + with_unit_circle(core_n, |circle| { + for p in circle { + out.dots.push(Dot::new( + center + p[0] * core_radius, + center + p[1] * core_radius, + 0.0, + (r_base + r_depth * 1.15) * rs, + 0.08, + )); + } + }); +} + +/// Three inference loops carry thoughts in alternating directions. +pub fn draw_gyroscope_into(size: f32, t: f32, o: &ModeOpts, out: &mut Frame) { + let center = size / 2.0; + let radius = size * 0.385 * o.spread.unwrap_or(1.0); + let rings = count_usize(o.lanes, 3.0, 1, MAX_LANES as usize).min(6); + let segs = count_usize(o.segs, 24.0, 3, MAX_SEGS as usize); + let rs = radius_scale(size, o.rs_pow.unwrap_or(0.6)); + let r_base = o.r_base.unwrap_or(0.8); + let r_depth = o.r_depth.unwrap_or(1.5); + let proj = make_proj(t * 0.055, 0.18, center, center, radius); + out.dots + .reserve(rings.saturating_mul(segs.saturating_add(2))); + + with_unit_circle(segs, |circle| { + for ring in 0..rings { + let spread = if rings > 1 { + ring as f32 / (rings - 1) as f32 - 0.5 + } else { + 0.0 + }; + let tilt_x = spread * 1.55 + 0.12 * (t * 0.16 + ring as f32).sin(); + let tilt_y = ring as f32 * PI / rings as f32 + 0.32; + let (sx, cx) = tilt_x.sin_cos(); + let (sy, cy) = tilt_y.sin_cos(); + let direction = if ring % 2 == 0 { 1.0 } else { -1.0 }; + let orient = |ca: f32, sa: f32| { + let y1 = sa * cx; + let z1 = sa * sx; + (ca * cy + z1 * sy, y1, -ca * sy + z1 * cy) + }; + + for p in circle { + let (x, y, z) = orient(p[0], p[1]); + let (px, py, depth_z) = proj.project(x, y, z); + let depth = (depth_z + 1.0) * 0.5; + out.dots.push( + Dot::new( + px, + py, + depth_z, + (r_base * 0.9 + r_depth * 0.5 * depth) * rs, + 0.62 - 0.34 * depth, + ) + .with_a(0.34 + 0.4 * depth), + ); + } + + // A leading thought and a softer echo make direction and velocity + // legible without turning the complete track into visual noise. + let travel = + t * (0.72 + ring as f32 * 0.08) * direction + ring as f32 * 2.0 * PI / rings as f32; + for (trail, offset) in [0.0, -0.22 * direction].into_iter().enumerate() { + let (sa, ca) = (travel + offset).sin_cos(); + let (x, y, z) = orient(ca, sa); + let (px, py, depth_z) = proj.project(x, y, z); + let depth = (depth_z + 1.0) * 0.5; + let strength = if trail == 0 { 1.0 } else { 0.48 }; + out.dots.push( + Dot::new( + px, + py, + depth_z + 0.002, + (r_base + r_depth * depth + 1.65 * strength) * rs, + 0.44 - 0.42 * depth - 0.2 * strength, + ) + .with_a((0.55 + 0.45 * depth) * strength), + ); + } + } + }); +} + +/// Concentric echoes emerge from a stable core and dissolve at the boundary. +pub fn draw_echo_into(size: f32, t: f32, o: &ModeOpts, out: &mut Frame) { + let center = size / 2.0; + let extent = size * 0.4 * o.spread.unwrap_or(1.0); + let rings = count_usize(o.lanes, 4.0, 1, MAX_LANES as usize); + let segs = count_usize(o.segs, 18.0, 3, MAX_SEGS as usize); + let core_n = count_usize(o.particles, 3.0, 1, MAX_PARTICLES as usize); + let rs = radius_scale(size, o.rs_pow.unwrap_or(0.6)); + let r_base = o.r_base.unwrap_or(0.85); + let r_depth = o.r_depth.unwrap_or(1.05); + out.dots + .reserve(rings.saturating_mul(segs).saturating_add(core_n)); + + with_unit_circle(segs, |circle| { + for ring in 0..rings { + let phase = frac(t * 0.14 + ring as f32 / rings as f32); + let radius = extent * (0.13 + 0.87 * phase); + let life = (PI * phase).sin().max(0.0); + let turn = ring as f32 * 0.37 - t * 0.1; + let (st, ct) = turn.sin_cos(); + for p in circle { + let x = p[0] * ct - p[1] * st; + let y = p[0] * st + p[1] * ct; + out.dots.push( + Dot::new( + center + x * radius, + center + y * radius, + 0.0, + (r_base + r_depth * (1.0 - phase)) * rs, + 0.18 + 0.52 * phase, + ) + .with_a(life.powf(0.58)), + ); + } + } + }); + + let core_radius = size * 0.025; + with_unit_circle(core_n, |circle| { + for p in circle { + out.dots.push(Dot::new( + center + p[0] * core_radius, + center + p[1] * core_radius, + 0.0, + (r_base + r_depth * 1.4) * rs, + 0.06, + )); + } + }); +} diff --git a/crates/orb/src/engine/core.rs b/crates/orb/src/engine/core.rs new file mode 100644 index 0000000000..fc55d78e54 --- /dev/null +++ b/crates/orb/src/engine/core.rs @@ -0,0 +1,232 @@ +// Extracted from Bezel 6141af9c16f7353cdf36003f7404e0a94566a163; MIT. See THIRD_PARTY_NOTICES.md. +//! Shared primitives for the dotted 3D thought-orbs. +//! Ported from thinking-orbs / inkform (HalftoneSphere lineage). + +use std::{cell::RefCell, f32::consts::PI}; + +type Vec3 = [f32; 3]; +type Vec2 = [f32; 2]; + +thread_local! { + /// Per-thread topology caches, indexed directly by sample count. Public + /// draw inputs are capped before reaching these helpers, so the sparse + /// vectors stay small while lookup remains O(1). + static FIBONACCI_DIRECTIONS: RefCell>>> = const { RefCell::new(Vec::new()) }; + static UNIT_CIRCLES: RefCell>>> = const { RefCell::new(Vec::new()) }; +} + +/// A projected, depth-shaded disk. +#[derive(Clone, Copy, Debug)] +pub struct Dot { + pub x: f32, + pub y: f32, + pub z: f32, + pub r: f32, + /// Ink value: 0 = darkest ink on paper. Mirrored on dark themes. + pub white: f32, + pub a: f32, +} + +impl Dot { + pub fn new(x: f32, y: f32, z: f32, r: f32, white: f32) -> Self { + Self { + x, + y, + z, + r, + white, + a: 1.0, + } + } + + pub fn with_a(mut self, a: f32) -> Self { + self.a = a; + self + } +} + +/// A stroked edge between two projected points (the `connecting` web). +#[derive(Clone, Copy, Debug)] +pub struct Line { + pub x1: f32, + pub y1: f32, + pub x2: f32, + pub y2: f32, + pub white: f32, + pub a: f32, + pub w: f32, +} + +/// Frame geometry produced by a mode painter (backend-agnostic). +#[derive(Clone, Debug, Default)] +pub struct Frame { + pub dots: Vec, + pub lines: Vec, +} + +impl Frame { + pub fn new() -> Self { + Self::default() + } + + /// Drop all geometry but keep the allocated capacity, so a steady-state + /// animation loop reuses one pair of buffers forever. + #[inline] + pub fn clear(&mut self) { + self.dots.clear(); + self.lines.clear(); + } +} + +/// Spin + tilt + orthographic projection, precomputed once per frame. +/// +/// This used to be a `Box`, which meant a heap allocation on every +/// `draw_mode` call and an indirect call per dot. As a plain struct it is +/// stack-allocated and `project` inlines into the dot loops. +#[derive(Clone, Copy, Debug)] +pub struct Proj { + st: f32, + ct: f32, + sy: f32, + cyw: f32, + cx: f32, + cy: f32, + scale: f32, +} + +impl Proj { + #[inline] + pub fn project(&self, x: f32, y: f32, z: f32) -> (f32, f32, f32) { + let x1 = x * self.cyw + z * self.sy; + let z1 = -x * self.sy + z * self.cyw; + let y1 = y * self.ct - z1 * self.st; + let z2 = y * self.st + z1 * self.ct; + (self.cx + x1 * self.scale, self.cy - y1 * self.scale, z2) + } +} + +#[inline] +pub fn lerp(a: f32, b: f32, f: f32) -> f32 { + a + (b - a) * f +} + +#[inline] +pub fn frac(x: f32) -> f32 { + x - x.floor() +} + +/// Deterministic hash in [0, 1). +/// +/// The magic numbers are the canonical GLSL `fract(sin(dot(...)) * 43758.5453)` +/// constants, kept verbatim from upstream so the generated dot layouts match. +/// They carry more digits than `f32` can hold; that is intentional — they are +/// quoted as written, and truncating them would be a gratuitous divergence. +#[inline] +#[allow(clippy::excessive_precision)] +pub fn hash_d(a: f32, b: f32) -> f32 { + let h = (a * 12.9898 + b * 78.233).sin() * 43758.5453; + h - h.floor() +} + +/// Value noise on a 2D lattice — smooth, deterministic, cheap. +pub fn vnoise(x: f32, y: f32) -> f32 { + let xi = x.floor(); + let yi = y.floor(); + let mut fx = x - xi; + let mut fy = y - yi; + fx = fx * fx * (3.0 - 2.0 * fx); + fy = fy * fy * (3.0 - 2.0 * fy); + let a = hash_d(xi, yi); + let b = hash_d(xi + 1.0, yi); + let c = hash_d(xi, yi + 1.0); + let d = hash_d(xi + 1.0, yi + 1.0); + a + (b - a) * fx + (c - a) * fy + (a - b - c + d) * fx * fy +} + +/// Stable directions on a unit sphere (Fibonacci lattice). +pub fn fib_dir(i: usize, n: usize) -> (f32, f32, f32) { + let golden = PI * (3.0 - 5.0_f32.sqrt()); + let y = 1.0 - (2.0 * (i as f32 + 0.5)) / n as f32; + let rad = (1.0 - y * y).sqrt(); + let a = i as f32 * golden; + (rad * a.cos(), y, rad * a.sin()) +} + +/// Borrow the stable Fibonacci sphere for `n` points from a thread-local cache. +/// Profiles reuse the same handful of sanitized counts every frame, so their +/// square roots and trigonometry are normally paid only once. +pub fn with_fib_dirs(n: usize, f: impl FnOnce(&[Vec3]) -> R) -> R { + FIBONACCI_DIRECTIONS.with(|cell| { + let mut cache = cell.borrow_mut(); + if cache.len() <= n { + cache.resize_with(n + 1, || None); + } + let directions = cache[n].get_or_insert_with(|| { + (0..n) + .map(|i| { + let (x, y, z) = fib_dir(i, n); + [x, y, z] + }) + .collect::>() + .into_boxed_slice() + }); + f(directions) + }) +} + +/// Borrow evenly spaced `(cos θ, sin θ)` samples for a closed circle. +/// Circle topology occurs in almost every mode and is invariant across frames. +pub fn with_unit_circle(n: usize, f: impl FnOnce(&[Vec2]) -> R) -> R { + UNIT_CIRCLES.with(|cell| { + let mut cache = cell.borrow_mut(); + if cache.len() <= n { + cache.resize_with(n + 1, || None); + } + let circle = cache[n].get_or_insert_with(|| { + let step = 2.0 * PI / n.max(1) as f32; + (0..n) + .map(|i| { + let a = i as f32 * step; + [a.cos(), a.sin()] + }) + .collect::>() + .into_boxed_slice() + }); + f(circle) + }) +} + +/// Shortest signed angular distance, wrapped to (-π, π]. +#[inline] +pub fn angle_delta(a: f32, b: f32) -> f32 { + (a - b).sin().atan2((a - b).cos()) +} + +/// Shared spin + tilt + orthographic projection. +pub fn make_proj(yaw: f32, tilt: f32, cx: f32, cy: f32, scale: f32) -> Proj { + Proj { + st: tilt.sin(), + ct: tilt.cos(), + sy: yaw.sin(), + cyw: yaw.cos(), + cx, + cy, + scale, + } +} + +/// Dot radii were tuned for a 300pt frame; sub-linear scaling keeps small +/// spinners legible. Lower pow = radii shrink less with size. +#[inline] +pub fn radius_scale(size: f32, pow: f32) -> f32 { + (size / 300.0).powf(pow) +} + +/// Z-sort far→near so nearer dots paint over farther ones. +/// +/// `sort_unstable_by` is used deliberately: it sorts in place (the stable sort +/// allocates a scratch buffer every call) and ties only happen between dots at +/// identical depth, where paint order is visually irrelevant. +pub fn sort_dots(dots: &mut [Dot]) { + dots.sort_unstable_by(|a, b| a.z.partial_cmp(&b.z).unwrap_or(std::cmp::Ordering::Equal)); +} diff --git a/crates/orb/src/engine/lattice.rs b/crates/orb/src/engine/lattice.rs new file mode 100644 index 0000000000..319797d509 --- /dev/null +++ b/crates/orb/src/engine/lattice.rs @@ -0,0 +1,292 @@ +// Extracted from Bezel 6141af9c16f7353cdf36003f7404e0a94566a163; MIT. See THIRD_PARTY_NOTICES.md. +//! Sphere-lattice modes: globe (searching), rubik (solving), wave (listening). + +use crate::engine::{ + core::{Dot, Frame, angle_delta, hash_d, make_proj, radius_scale, with_unit_circle}, + profiles::{MAX_LATTICE_RINGS, MAX_LON_DENSITY, MAX_MOVE_COUNT, ModeOpts, count_usize}, +}; +use std::{cell::RefCell, f32::consts::PI}; + +struct Move { + axis: u8, + lo: f32, + hi: f32, + ang: f32, +} + +thread_local! { + /// Scratch for Rubik band moves — reused every frame so Solving is + /// allocation-free in steady state. + static MOVES: RefCell> = const { RefCell::new(Vec::new()) }; + static AMOUNTS: RefCell> = const { RefCell::new(Vec::new()) }; +} + +fn solve_cycle_into( + time: f32, + count: usize, + slot_dur: f32, + rest: f32, + amount: &mut Vec, +) -> (i32, f32) { + amount.clear(); + amount.resize(count, 0.0); + let mut active = -1_i32; + if count == 0 { + return (active, 0.0); + } + let cyc = 2.0 * count as f32 * slot_dur + rest; + let tc = time % cyc; + if tc < 2.0 * count as f32 * slot_dur { + // The guard above and this division are two independent float + // operations, so rounding can let `tc / slot_dur` reach `2 * count` + // even though `tc < 2 * count * slot_dur` held — e.g. count = 9, + // slot_dur = 0.42, tc = 7.559999466. Without the clamp `2*count-1-slot` + // wraps to `usize::MAX` and the loop below indexes out of bounds. + let slot = ((tc / slot_dur).floor() as usize).min(2 * count - 1); + let p = (tc - slot as f32 * slot_dur) / slot_dur; + let p = p.clamp(0.0, 1.0); + let ep = p * p * (3.0 - 2.0 * p); + let emphasis = (PI * p).sin().max(0.0).sqrt(); + if slot < count { + for a in amount.iter_mut().take(slot) { + *a = 1.0; + } + amount[slot] = ep; + active = slot as i32; + } else { + let u = 2 * count - 1 - slot; + for a in amount.iter_mut().take(u) { + *a = 1.0; + } + if u < count { + amount[u] = 1.0 - ep; + } + active = u as i32; + } + return (active, emphasis); + } + (active, 0.0) +} + +fn apply_moves( + pt3: (f32, f32, f32), + moves: &[Move], + amounts: &[f32], + active: i32, +) -> (f32, f32, f32, bool) { + let (mut x, mut y, mut z) = pt3; + let mut in_active = false; + for (i, mv) in moves.iter().enumerate() { + if amounts[i] <= 0.0 { + continue; + } + let coord = match mv.axis { + 0 => x, + 1 => y, + _ => z, + }; + if coord < mv.lo || coord >= mv.hi { + continue; + } + if i as i32 == active { + in_active = true; + } + let a = mv.ang * amounts[i]; + let ca = a.cos(); + let sa = a.sin(); + match mv.axis { + 0 => { + let y2 = y * ca - z * sa; + z = y * sa + z * ca; + y = y2; + } + 1 => { + let x2 = x * ca + z * sa; + z = -x * sa + z * ca; + x = x2; + } + _ => { + let x2 = x * ca - y * sa; + y = x * sa + y * ca; + x = x2; + } + } + } + (x, y, z, in_active) +} + +fn make_moves_into(count: usize, moves: &mut Vec) { + moves.clear(); + moves.reserve(count); + for i in 0..count { + let i_f = i as f32; + let axis = (hash_d(i_f, 2.3) * 3.0).floor().min(2.0) as u8; + let lo = -1.0 + 0.5 * (hash_d(i_f, 5.9) * 4.0).floor().min(3.0); + let dir = if hash_d(i_f, 7.7) < 0.5 { 1.0 } else { -1.0 }; + moves.push(Move { + axis, + lo, + hi: lo + 0.5, + ang: dir * PI / 2.0, + }); + } +} + +/// Globe: lat/long field, a scan meridian sweeps — searching. +pub fn draw_globe_into(size: f32, t: f32, o: &ModeOpts, out: &mut Frame) { + let spin = 0.5; + let cx = size / 2.0; + let cy = size / 2.0; + let radius = (size / 2.0) * 0.82; + let tilt = 0.4 + 0.06 * (t * 0.35).sin(); + let pt = make_proj(t * spin, tilt, cx, cy, radius); + let scan = t * (spin + (1.7 - spin) * o.scan_mul.unwrap_or(1.0)); + let rs = radius_scale(size, o.rs_pow.unwrap_or(0.6)); + let dim_base = o.dim_base.unwrap_or(1.0); + + let dots = &mut out.dots; + let lat_rings = count_usize(o.lat_rings, 17.0, 1, MAX_LATTICE_RINGS as usize); + let lon_density = o.lon_density.unwrap_or(44.0).clamp(1.0, MAX_LON_DENSITY); + let r_base = o.r_base.unwrap_or(0.6); + let r_depth = o.r_depth.unwrap_or(1.7); + let r_boost = o.r_boost.unwrap_or(1.0); + let ink_far = o.ink_far.unwrap_or(0.62); + let ink_span = o.ink_span.unwrap_or(0.54); + let inv_lat = 1.0 / lat_rings as f32; + + for li in 0..=lat_rings { + let lat = -PI / 2.0 + (li as f32 * inv_lat) * PI; + let cos_lat = lat.cos(); + let sin_lat = lat.sin(); + let lon_count = ((cos_lat.abs() * lon_density).round() as usize).max(1); + let lon_step = 2.0 * PI / lon_count as f32; + with_unit_circle(lon_count, |circle| { + for (lj, p) in circle.iter().enumerate() { + let lon = lj as f32 * lon_step; + let (clon, slon) = (p[0], p[1]); + let (px, py, z) = pt.project(cos_lat * clon, sin_lat, cos_lat * slon); + let depth = (z + 1.0) / 2.0; + let d = angle_delta(lon + t * spin, scan); + let boost = (-(d * d) / 0.18).exp() * z.max(0.0); + dots.push( + Dot::new( + px, + py, + z, + (r_base + r_depth * depth + r_boost * boost) * rs, + ink_far - ink_span * depth, + ) + .with_a(dim_base + (1.0 - dim_base) * boost.min(1.0)), + ); + } + }); + } +} + +/// Rubik: bands twist in quarter turns, scramble → solve — solving. +pub fn draw_rubik_into(size: f32, t: f32, o: &ModeOpts, out: &mut Frame) { + let cx = size / 2.0; + let cy = size / 2.0; + let r = (size / 2.0) * 0.82; + // Keep the lattice almost still so each quarter-turn reads as the action, + // rather than getting lost inside a continuously spinning globe. + let pt = make_proj(t * 0.12, 0.35 + 0.035 * (t * 0.45).sin(), cx, cy, r); + let rs = radius_scale(size, o.rs_pow.unwrap_or(0.6)); + let move_count = count_usize(o.move_count, 14.0, 0, MAX_MOVE_COUNT as usize); + + MOVES.with(|moves_cell| { + AMOUNTS.with(|amounts_cell| { + let mut moves = moves_cell.borrow_mut(); + let mut amounts = amounts_cell.borrow_mut(); + if moves.len() != move_count { + make_moves_into(move_count, &mut moves); + } + let (active, emphasis) = solve_cycle_into(t, move_count, 0.58, 1.35, &mut amounts); + + let dots = &mut out.dots; + let lat_rings = count_usize(o.lat_rings, 15.0, 1, MAX_LATTICE_RINGS as usize); + let lon_density = o.lon_density.unwrap_or(40.0).clamp(1.0, MAX_LON_DENSITY); + let r_base = o.r_base.unwrap_or(0.6); + let r_depth = o.r_depth.unwrap_or(1.7); + let r_active = o.r_active.unwrap_or(0.3); + let ink_far = o.ink_far.unwrap_or(0.62); + let ink_span = o.ink_span.unwrap_or(0.54); + let inv_lat = 1.0 / lat_rings as f32; + + for li in 0..=lat_rings { + let lat = -PI / 2.0 + (li as f32 * inv_lat) * PI; + let cos_lat = lat.cos(); + let sin_lat = lat.sin(); + let lon_count = ((cos_lat.abs() * lon_density).round() as usize).max(1); + with_unit_circle(lon_count, |circle| { + for p in circle { + let (x, y, z, in_active) = apply_moves( + (cos_lat * p[0], sin_lat, cos_lat * p[1]), + &moves, + &amounts, + active, + ); + let (px, py, zr) = pt.project(x, y, z); + let depth = (zr + 1.0) / 2.0; + let active_mix = if in_active { emphasis } else { 0.0 }; + dots.push( + Dot::new( + px, + py, + zr, + (r_base + r_depth * depth + r_active * 2.1 * active_mix) * rs, + ink_far - ink_span * depth - 0.24 * active_mix, + ) + .with_a(0.58 + 0.14 * depth + 0.28 * active_mix), + ); + } + }); + } + }); + }); +} + +/// Wave: a waveform rolls through the rings — listening. +pub fn draw_wave_into(size: f32, t: f32, o: &ModeOpts, out: &mut Frame) { + let cx = size / 2.0; + let cy = size / 2.0; + let r = (size / 2.0) * 0.874; + let pt = make_proj(t * 0.18, 0.38, cx, cy, 1.0); + let rs = radius_scale(size, o.rs_pow.unwrap_or(0.6)); + + let dots = &mut out.dots; + let rings = count_usize(o.rings, 15.0, 1, MAX_LATTICE_RINGS as usize); + let lon_density = o.lon_density.unwrap_or(40.0).clamp(1.0, MAX_LON_DENSITY); + let r_base = o.r_base.unwrap_or(0.6); + let r_depth = o.r_depth.unwrap_or(1.7); + let inv_rings = 1.0 / rings as f32; + let inv_r = 1.0 / r; + + for ri in 0..=rings { + let lat = -PI / 2.0 + (ri as f32 * inv_rings) * PI; + let cos_lat = lat.cos(); + let sin_lat = lat.sin(); + let w = + 0.62 * (t * 2.1 - ri as f32 * 0.52).sin() + 0.38 * (t * 1.27 + ri as f32 * 0.83).sin(); + let rr = r * (0.88 + 0.105 * w); + let lon_count = ((cos_lat.abs() * lon_density).round() as usize).max(1); + let crest = w.max(0.0); + let crest_r = 1.0 + 0.4 * crest; + let crest_ink = 0.1 * crest; + with_unit_circle(lon_count, |circle| { + for p in circle { + let (clon, slon) = (p[0], p[1]); + let (px, py, z) = + pt.project(cos_lat * clon * rr, sin_lat * rr, cos_lat * slon * rr); + let depth = (z * inv_r + 1.0) / 2.0; + dots.push(Dot::new( + px, + py, + z, + (r_base + r_depth * depth) * crest_r * rs, + 0.66 - 0.56 * depth - crest_ink, + )); + } + }); + } +} diff --git a/crates/orb/src/engine/mod.rs b/crates/orb/src/engine/mod.rs new file mode 100644 index 0000000000..1cc153c7bd --- /dev/null +++ b/crates/orb/src/engine/mod.rs @@ -0,0 +1,82 @@ +// Extracted from Bezel 6141af9c16f7353cdf36003f7404e0a94566a163; MIT. See THIRD_PARTY_NOTICES.md. +//! Backend-agnostic animation engine (ported from thinking-orbs). + +mod braid; +mod concepts; +mod core; +mod lattice; +mod morph; +mod orbits; +mod profiles; +mod ribbon; +mod web; + +pub use core::{Dot, Frame, Line, Proj, make_proj, sort_dots}; +pub use profiles::{ + MAX_ICON_D, MAX_MORPH_DOTS, MAX_NODE_N, MAX_SIZE, MIN_SIZE, ModeOpts, base_profile, + sanitize_mode_opts, sanitize_size, scale_counts, scale_radii, +}; + +use crate::types::ModeKey; +use braid::draw_braid_into; +use concepts::{draw_echo_into, draw_focus_into, draw_gyroscope_into}; +use lattice::{draw_globe_into, draw_rubik_into, draw_wave_into}; +use morph::draw_morph_into; +use orbits::draw_orbits_into; +use ribbon::draw_ribbon_into; +use web::draw_web_into; + +/// Paint one frame for the given mode into an existing [`Frame`], reusing its +/// buffers. +/// +/// This is the allocation-free entry point for the **geometry** buffers: `out` +/// is cleared but keeps its capacity, so a steady-state animation loop performs +/// no heap growth after the first frame (the Solving mode reuses thread-local +/// scratch for its move tables). Prefer this over [`draw_mode`] in render loops. +/// +/// `size` and every count-like field of `opts` are sanitized (finite + hard +/// ceilings) before geometry runs — see [`sanitize_mode_opts`]. +pub fn draw_mode_into(mode: ModeKey, size: f32, t: f32, opts: &ModeOpts, out: &mut Frame) { + let size = sanitize_size(size); + let opts = sanitize_mode_opts(opts); + let t = if t.is_finite() { t } else { 0.0 }; + draw_mode_into_resolved(mode, size, t, &opts, out); +} + +/// Fast path for the widget's already-sanitized, size-bounded presets. +/// +/// Only for options from [`crate::resolve_preset`], which are already finite +/// and bounded: it skips the 40-field clone/sanitize pass on every widget +/// tick. Arbitrary options must go through [`draw_mode_into`]. +#[doc(hidden)] +pub fn draw_mode_into_resolved(mode: ModeKey, size: f32, t: f32, opts: &ModeOpts, out: &mut Frame) { + out.clear(); + match mode { + ModeKey::Orbits => draw_orbits_into(size, t, opts, out), + ModeKey::Globe => draw_globe_into(size, t, opts, out), + ModeKey::Rubik => draw_rubik_into(size, t, opts, out), + ModeKey::Wave => draw_wave_into(size, t, opts, out), + ModeKey::Web => draw_web_into(size, t, opts, out), + ModeKey::Braid => draw_braid_into(size, t, opts, out), + ModeKey::Ribbon | ModeKey::Ring => draw_ribbon_into(size, t, opts, out), + ModeKey::Morph => draw_morph_into(size, t, opts, out), + ModeKey::Focus => draw_focus_into(size, t, opts, out), + ModeKey::Gyroscope => draw_gyroscope_into(size, t, opts, out), + ModeKey::Echo => draw_echo_into(size, t, opts, out), + } + // Face-on modes intentionally emit z=0 in painter order, so a comparison + // sort cannot change their result. Avoid it entirely. + if !matches!(mode, ModeKey::Focus | ModeKey::Echo) { + sort_dots(&mut out.dots); + } +} + +/// Paint one frame for the given mode into a fresh [`Frame`]. +/// +/// Convenience wrapper around [`draw_mode_into`] for one-off calls and tests; +/// it allocates. Render loops should use [`draw_mode_into`] instead. +pub fn draw_mode(mode: ModeKey, size: f32, t: f32, opts: &ModeOpts) -> Frame { + let mut frame = Frame::new(); + draw_mode_into(mode, size, t, opts, &mut frame); + frame +} diff --git a/crates/orb/src/engine/morph.rs b/crates/orb/src/engine/morph.rs new file mode 100644 index 0000000000..4c5467b727 --- /dev/null +++ b/crates/orb/src/engine/morph.rs @@ -0,0 +1,172 @@ +// Extracted from Bezel 6141af9c16f7353cdf36003f7404e0a94566a163; MIT. See THIRD_PARTY_NOTICES.md. +//! Morph: dotted outline cycling circle → triangle → square — "shaping". + +use crate::engine::{ + core::{Dot, Frame}, + profiles::{MAX_MORPH_DOTS, ModeOpts}, +}; +use std::f32::consts::PI; + +/// Outline resolution used to re-parameterise the blended shape by arc length. +const M_SAMPLES: usize = 160; +/// Largest vertex count across the shipped polygons (square, incl. the +/// repeated closing vertex). +const MAX_VERTS: usize = 8; + +const TRIANGLE: [(f32, f32); 3] = [(0.0, -0.26), (0.24, 0.16), (-0.24, 0.16)]; +const SQUARE: [(f32, f32); 4] = [(0.0, -0.2), (0.2, -0.2), (0.2, 0.2), (-0.2, 0.2)]; + +fn smooth_e(x: f32) -> f32 { + x * x * (3.0 - 2.0 * x) +} + +fn circle_path(f: f32) -> (f32, f32) { + let a = -PI / 2.0 + f * 2.0 * PI; + (a.cos() * 0.24, a.sin() * 0.24) +} + +/// Arc-length parameterisation of a closed polygon. +/// +/// Used to be a `Box` built three times per frame; now it is a plain +/// stack value with fixed-capacity length table. +struct PolyPath { + verts: &'static [(f32, f32)], + lengths: [f32; MAX_VERTS], + total: f32, +} + +impl PolyPath { + fn new(verts: &'static [(f32, f32)]) -> Self { + debug_assert!(verts.len() <= MAX_VERTS); + let mut lengths = [0.0; MAX_VERTS]; + let mut total = 0.0; + let v = verts.len(); + for i in 0..v { + let a = verts[i]; + let b = verts[(i + 1) % v]; + let l = (b.0 - a.0).hypot(b.1 - a.1); + lengths[i] = l; + total += l; + } + Self { + verts, + lengths, + total, + } + } + + fn at(&self, f: f32) -> (f32, f32) { + let v = self.verts.len(); + let mut target = f * self.total; + let mut i = 0; + // Bounds check first: `i` can never leave `0..v`. + while i < v - 1 && target > self.lengths[i] { + target -= self.lengths[i]; + i += 1; + } + let a = self.verts[i]; + let b = self.verts[(i + 1) % v]; + let ff = if self.lengths[i] > 0.0 { + (target / self.lengths[i]).min(1.0) + } else { + 0.0 + }; + (a.0 + (b.0 - a.0) * ff, a.1 + (b.1 - a.1) * ff) + } +} + +enum Shape { + Circle, + Poly(PolyPath), +} + +impl Shape { + fn at(&self, f: f32) -> (f32, f32) { + match self { + Shape::Circle => circle_path(f), + Shape::Poly(p) => p.at(f), + } + } +} + +fn morph_n(d: f32) -> usize { + let n = (34.0 * d).round().max(6.0); + (n as usize).clamp(6, MAX_MORPH_DOTS) +} + +const HOLD: f32 = 1.4; +const MORPH: f32 = 0.9; +const SEG: f32 = HOLD + MORPH; + +pub fn draw_morph_into(size: f32, t: f32, o: &ModeOpts, out: &mut Frame) { + let cycle = [ + Shape::Circle, + Shape::Poly(PolyPath::new(&TRIANGLE)), + Shape::Poly(PolyPath::new(&SQUARE)), + ]; + let k_len = cycle.len(); + let tc = t.rem_euclid(SEG * k_len as f32); + let k = ((tc / SEG).floor() as usize).min(k_len - 1); + let local = tc - k as f32 * SEG; + let m = if local > HOLD { + smooth_e((local - HOLD) / MORPH) + } else { + 0.0 + }; + let sprd = o.spread.unwrap_or(1.0); + + let p_a = &cycle[k]; + let p_b = &cycle[(k + 1) % k_len]; + + // Fixed-size stack scratch — no per-frame heap traffic. + let mut pts = [(0.0f32, 0.0f32); M_SAMPLES]; + let mut lengths = [0.0f32; M_SAMPLES]; + let inv_samples = 1.0 / M_SAMPLES as f32; + for (i, p) in pts.iter_mut().enumerate() { + let f = i as f32 * inv_samples; + let a = p_a.at(f); + let b = p_b.at(f); + *p = ( + (a.0 + (b.0 - a.0) * m) * sprd, + (a.1 + (b.1 - a.1) * m) * sprd, + ); + } + let mut total = 0.0f32; + for i in 0..M_SAMPLES { + let a = pts[i]; + let b = pts[(i + 1) % M_SAMPLES]; + let l = (b.0 - a.0).hypot(b.1 - a.1); + lengths[i] = l; + total += l; + } + + let n = morph_n(o.icon_d.unwrap_or(1.0)); + let re = o.r_dot.unwrap_or(0.021) * 1.35 * sprd; + let pulse = 1.0 + 0.018 * (t * 2.35).sin(); + + let dots = &mut out.dots; + dots.reserve(n); + let c2 = size / 2.0; + let radius = (re * size).max(0.35); + let mut seg = 0usize; + let mut acc = 0.0f32; + let inv_n = 1.0 / n as f32; + for k2 in 0..n { + let target = k2 as f32 * inv_n * total; + // Bounds check first: `seg` can never leave `0..M_SAMPLES`. + while seg < M_SAMPLES - 1 && acc + lengths[seg] < target { + acc += lengths[seg]; + seg += 1; + } + let a = pts[seg]; + let b = pts[(seg + 1) % M_SAMPLES]; + let f = if lengths[seg] > 0.0 { + ((target - acc) / lengths[seg]).min(1.0) + } else { + 0.0 + }; + let x = (a.0 + (b.0 - a.0) * f) * pulse; + let y = (a.1 + (b.1 - a.1) * f) * pulse; + dots.push(Dot::new(c2 + x * size, c2 + y * size, 0.0, radius, 0.1)); + } +} diff --git a/crates/orb/src/engine/orbits.rs b/crates/orb/src/engine/orbits.rs new file mode 100644 index 0000000000..b29b30fb8d --- /dev/null +++ b/crates/orb/src/engine/orbits.rs @@ -0,0 +1,90 @@ +// Extracted from Bezel 6141af9c16f7353cdf36003f7404e0a94566a163; MIT. See THIRD_PARTY_NOTICES.md. +//! Orbits: particles on tilted orbits — the "working" state. + +use crate::engine::{ + core::{Dot, Frame, hash_d, make_proj, radius_scale, with_unit_circle}, + profiles::{MAX_GHOST_N, MAX_ORBIT_N, MAX_PARTICLES, ModeOpts, count_usize}, +}; +use std::f32::consts::PI; + +pub fn draw_orbits_into(size: f32, t: f32, o: &ModeOpts, out: &mut Frame) { + let cx = size / 2.0; + let cy = size / 2.0; + let r = (size / 2.0) * 0.82; + let pt = make_proj(t * 0.12, 0.3, cx, cy, 1.0); + let rs = radius_scale(size, o.rs_pow.unwrap_or(0.6)); + + let orbit_n = count_usize(o.orbit_n, 12.0, 0, MAX_ORBIT_N as usize); + let ghost_n = count_usize(o.ghost_n, 40.0, 0, MAX_GHOST_N as usize); + let particles = count_usize(o.particles, 3.0, 0, MAX_PARTICLES as usize); + + let dots = &mut out.dots; + let per_orbit = ghost_n.saturating_add(particles); + dots.reserve(orbit_n.saturating_mul(per_orbit)); + let ghost_r = o.ghost_r.unwrap_or(0.9) * rs; + let ghost_a = o.ghost_a.unwrap_or(0.5); + let part_r = o.part_r.unwrap_or(1.2); + let part_r_depth = o.part_r_depth.unwrap_or(1.6); + + for orb in 0..orbit_n { + let orb_f = orb as f32; + let h1 = hash_d(orb_f, 1.7); + let h2 = hash_d(orb_f, 5.2); + let h3 = hash_d(orb_f, 8.9); + let ro = r * (0.45 + 0.52 * h1); + let th = h1 * 2.0 * PI; + let phi = (2.0 * h2 - 1.0).acos(); + // Build a stable plane basis by crossing the normal with whichever + // world axis is least parallel to it. + let nx = phi.sin() * th.cos(); + let ny = phi.cos(); + let nz = phi.sin() * th.sin(); + let (mut ux, mut uy, mut uz) = if nz.abs() < 0.9 { + (-ny, nx, 0.0) + } else { + (0.0, -nz, ny) + }; + let ul = (ux * ux + uy * uy + uz * uz).sqrt().max(1e-6); + ux /= ul; + uy /= ul; + uz /= ul; + let vx = ny * uz - nz * uy; + let vy = nz * ux - nx * uz; + let vz = nx * uy - ny * ux; + let speed = (0.25 + 0.55 * h3) * if h3 > 0.5 { 1.0 } else { -1.0 }; + let inv_ro = 1.0 / ro; + + // Ghost topology is invariant; reuse its unit-circle samples instead + // of evaluating 2 × orbit_n × ghost_n trig functions every frame. + with_unit_circle(ghost_n, |circle| { + for p in circle { + let (ca, sa) = (p[0], p[1]); + let (px, py, z) = pt.project( + (ux * ca + vx * sa) * ro, + (uy * ca + vy * sa) * ro, + (uz * ca + vz * sa) * ro, + ); + let depth = (z * inv_ro + 1.0) / 2.0; + dots.push(Dot::new(px, py, z, ghost_r, 0.72).with_a(ghost_a * (0.4 + 0.6 * depth))); + } + }); + // the particles doing the work + for m in 0..particles { + let a = t * speed + (m as f32 / particles as f32) * 2.0 * PI + h2 * 6.0; + let (ca, sa) = (a.cos(), a.sin()); + let (px, py, z) = pt.project( + (ux * ca + vx * sa) * ro, + (uy * ca + vy * sa) * ro, + (uz * ca + vz * sa) * ro, + ); + let depth = (z * inv_ro + 1.0) / 2.0; + dots.push(Dot::new( + px, + py, + z, + (part_r + part_r_depth * depth) * rs, + 0.3 - 0.22 * depth, + )); + } + } +} diff --git a/crates/orb/src/engine/profiles.rs b/crates/orb/src/engine/profiles.rs new file mode 100644 index 0000000000..78640cbdd2 --- /dev/null +++ b/crates/orb/src/engine/profiles.rs @@ -0,0 +1,385 @@ +// Extracted from Bezel 6141af9c16f7353cdf36003f7404e0a94566a163; MIT. See THIRD_PARTY_NOTICES.md. +//! Density profiles + the multiplier machinery that scales them. + +/// Hard ceilings for count-like knobs. Preset values sit well below these; +/// they only bite adversarial or mistaken power-user inputs. +pub const MAX_LATTICE_RINGS: f32 = 128.0; +pub const MAX_LON_DENSITY: f32 = 256.0; +pub const MAX_ORBIT_N: f32 = 64.0; +pub const MAX_GHOST_N: f32 = 512.0; +pub const MAX_PARTICLES: f32 = 16.0; +pub const MAX_NODE_N: f32 = 128.0; +pub const MAX_SIGNALS: f32 = 64.0; +pub const MAX_STRAND_N: f32 = 256.0; +pub const MAX_LANES: f32 = 32.0; +pub const MAX_SEGS: f32 = 512.0; +pub const MAX_MOVE_COUNT: f32 = 64.0; +pub const MAX_ICON_D: f32 = 8.0; +/// Max dots the Morph painter will emit after `icon_d` is applied. +pub const MAX_MORPH_DOTS: usize = 512; +/// Logical size (px) accepted by [`crate::engine::draw_mode_into`]. +pub const MIN_SIZE: f32 = 1.0; +pub const MAX_SIZE: f32 = 1024.0; + +/// Free-form numeric knobs for mode painters (mirrors upstream `ModeOpts`). +/// +/// **Power-user / advanced API.** The normal path is the host widget / +/// [`crate::resolve_preset`], which only ever pass hand-tuned finite values. +/// +/// If you build a [`ModeOpts`] yourself and pass it to +/// [`crate::engine::draw_mode`] / [`crate::engine::draw_mode_into`], every count-like field is +/// clamped by [`sanitize_mode_opts`] before geometry runs: +/// +/// | Field family | Range after sanitize | +/// |--------------|----------------------| +/// | lattice rings / density | 1…128 / 1…256 | +/// | `orbit_n`, `ghost_n`, `particles` | 0…64 / 0…512 / 0…16 | +/// | `node_n`, `signals` | 0…128 / 0…64 | +/// | `strand_n`, `lanes`, `segs` | 0…256 / 1…32 / 1…512 | +/// | `move_count` | 0…64 | +/// | `icon_d` | 0.02…8 | +/// +/// Non-finite values fall back to the field default used by the painter. +/// New fields may be added without a major version bump. +#[derive(Clone, Debug, Default)] +#[non_exhaustive] +pub struct ModeOpts { + // lattice + pub lat_rings: Option, + pub lon_density: Option, + pub rings: Option, + pub r_base: Option, + pub r_depth: Option, + pub r_boost: Option, + pub r_active: Option, + pub ink_far: Option, + pub ink_span: Option, + pub rs_pow: Option, + pub r_min: Option, + pub move_count: Option, + pub scan_mul: Option, + pub dim_base: Option, + // orbits + pub orbit_n: Option, + pub ghost_n: Option, + pub ghost_r: Option, + pub ghost_a: Option, + pub particles: Option, + pub part_r: Option, + pub part_r_depth: Option, + // web + pub node_n: Option, + pub thr: Option, + pub signals: Option, + pub node_r: Option, + pub node_r_depth: Option, + pub line_w: Option, + // braid + pub strand_n: Option, + pub turns: Option, + // ribbon / ring + pub lanes: Option, + pub segs: Option, + pub face_on: Option, + pub spin: Option, + pub band_mul: Option, + pub wob_mul: Option, + // morph + pub r_dot: Option, + pub icon_d: Option, + pub spread: Option, + pub r_size_mul: Option, +} + +/// Clamp a free-form float option into a finite range, or leave `None`. +fn clamp_opt(v: Option, min: f32, max: f32) -> Option { + v.and_then(|x| { + if x.is_finite() { + Some(x.clamp(min, max)) + } else { + None + } + }) +} + +/// Return a safe copy of `opts` with every count-like / radius-like knob +/// finite and within the hard ceilings above. +/// +/// Called automatically by [`crate::engine::draw_mode_into`]. Safe to call yourself +/// when composing custom profiles. +pub fn sanitize_mode_opts(opts: &ModeOpts) -> ModeOpts { + ModeOpts { + lat_rings: clamp_opt(opts.lat_rings, 1.0, MAX_LATTICE_RINGS), + lon_density: clamp_opt(opts.lon_density, 1.0, MAX_LON_DENSITY), + rings: clamp_opt(opts.rings, 1.0, MAX_LATTICE_RINGS), + r_base: clamp_opt(opts.r_base, 0.0, 64.0), + r_depth: clamp_opt(opts.r_depth, 0.0, 64.0), + r_boost: clamp_opt(opts.r_boost, 0.0, 64.0), + r_active: clamp_opt(opts.r_active, 0.0, 64.0), + ink_far: clamp_opt(opts.ink_far, 0.0, 1.0), + ink_span: clamp_opt(opts.ink_span, 0.0, 2.0), + rs_pow: clamp_opt(opts.rs_pow, 0.05, 4.0), + r_min: clamp_opt(opts.r_min, 0.0, 32.0), + move_count: clamp_opt(opts.move_count, 0.0, MAX_MOVE_COUNT), + scan_mul: clamp_opt(opts.scan_mul, 0.0, 16.0), + dim_base: clamp_opt(opts.dim_base, 0.0, 1.0), + orbit_n: clamp_opt(opts.orbit_n, 0.0, MAX_ORBIT_N), + ghost_n: clamp_opt(opts.ghost_n, 0.0, MAX_GHOST_N), + ghost_r: clamp_opt(opts.ghost_r, 0.0, 64.0), + ghost_a: clamp_opt(opts.ghost_a, 0.0, 1.0), + particles: clamp_opt(opts.particles, 0.0, MAX_PARTICLES), + part_r: clamp_opt(opts.part_r, 0.0, 64.0), + part_r_depth: clamp_opt(opts.part_r_depth, 0.0, 64.0), + node_n: clamp_opt(opts.node_n, 0.0, MAX_NODE_N), + thr: clamp_opt(opts.thr, 0.0, 4.0), + signals: clamp_opt(opts.signals, 0.0, MAX_SIGNALS), + node_r: clamp_opt(opts.node_r, 0.0, 64.0), + node_r_depth: clamp_opt(opts.node_r_depth, 0.0, 64.0), + line_w: clamp_opt(opts.line_w, 0.0, 32.0), + strand_n: clamp_opt(opts.strand_n, 0.0, MAX_STRAND_N), + turns: clamp_opt(opts.turns, 0.0, 32.0), + lanes: clamp_opt(opts.lanes, 1.0, MAX_LANES), + segs: clamp_opt(opts.segs, 1.0, MAX_SEGS), + face_on: clamp_opt(opts.face_on, 0.0, 1.0), + spin: clamp_opt(opts.spin, -16.0, 16.0), + band_mul: clamp_opt(opts.band_mul, 0.05, 8.0), + wob_mul: clamp_opt(opts.wob_mul, 0.0, 8.0), + r_dot: clamp_opt(opts.r_dot, 0.0, 1.0), + icon_d: clamp_opt(opts.icon_d, 0.02, MAX_ICON_D), + spread: clamp_opt(opts.spread, 0.05, 4.0), + r_size_mul: clamp_opt(opts.r_size_mul, 0.0, 64.0), + } +} + +/// Clamp the logical paint size in pixels. +pub fn sanitize_size(size: f32) -> f32 { + if size.is_finite() { + size.clamp(MIN_SIZE, MAX_SIZE) + } else { + 64.0 + } +} + +/// Interpret a count-like option as `usize` after sanitization has run. +/// +/// Prefer calling this on already-sanitized opts. Non-finite / missing use +/// `default`; the result is always in `min..=max`. +#[inline] +pub fn count_usize(v: Option, default: f32, min: usize, max: usize) -> usize { + let x = match v { + Some(x) if x.is_finite() => x, + _ => default, + }; + let lo = min as f32; + let hi = max as f32; + (x.round().clamp(lo, hi)) as usize +} + +/// Scale total dot density. 2-D lattices (rings × dots-per-ring) take √scale +/// each side so the TOTAL scales by `scale`. Flat lists scale linearly. +pub fn scale_counts(opts: &ModeOpts, scale: f32) -> ModeOpts { + let mut out = opts.clone(); + let scale = if scale.is_finite() && scale > 0.0 { + scale + } else { + 1.0 + }; + let rt = scale.sqrt(); + + // Upstream COUNT_PAIRS: latRings×lonDensity first (owns lonDensity), + // then rings×lonDensity only if latRings was absent. + if let (Some(va), Some(vb)) = (out.lat_rings, out.lon_density) { + out.lat_rings = Some((va * rt).round().clamp(2.0, MAX_LATTICE_RINGS)); + out.lon_density = Some((vb * rt).round().clamp(2.0, MAX_LON_DENSITY)); + } else if let (Some(va), Some(vb)) = (out.rings, out.lon_density) { + out.rings = Some((va * rt).round().clamp(2.0, MAX_LATTICE_RINGS)); + out.lon_density = Some((vb * rt).round().clamp(2.0, MAX_LON_DENSITY)); + } + // lanes × segs pair + if let (Some(va), Some(vb)) = (out.lanes, out.segs) { + out.lanes = Some((va * rt).round().clamp(2.0, MAX_LANES)); + out.segs = Some((vb * rt).round().clamp(2.0, MAX_SEGS)); + } + + // linear count keys (0 means opt-out — do not resurrect) + let scale_key = |v: Option, max: f32| match v { + Some(0.0) => Some(0.0), + Some(v) if v.is_finite() => Some((v * scale).round().clamp(1.0, max)), + Some(_) => None, + None => None, + }; + out.orbit_n = scale_key(out.orbit_n, MAX_ORBIT_N); + out.ghost_n = scale_key(out.ghost_n, MAX_GHOST_N); + out.node_n = scale_key(out.node_n, MAX_NODE_N); + out.strand_n = scale_key(out.strand_n, MAX_STRAND_N); + out.signals = scale_key(out.signals, MAX_SIGNALS); + + if let Some(v) = out.icon_d { + if v.is_finite() { + out.icon_d = Some((v * scale).clamp(0.02, MAX_ICON_D)); + } else { + out.icon_d = None; + } + } + out +} + +/// Scale every key that sets a dot's rendered radius. +pub fn scale_radii(opts: &ModeOpts, scale: f32) -> ModeOpts { + let mut out = opts.clone(); + let scale = if scale.is_finite() { scale } else { 1.0 }; + let mul = |v: Option| v.and_then(|x| x.is_finite().then_some(x * scale)); + out.r_base = mul(out.r_base); + out.r_depth = mul(out.r_depth); + out.r_active = mul(out.r_active); + out.r_dot = mul(out.r_dot); + out.ghost_r = mul(out.ghost_r); + out.part_r = mul(out.part_r); + out.part_r_depth = mul(out.part_r_depth); + out.node_r = mul(out.node_r); + out.node_r_depth = mul(out.node_r_depth); + out.r_size_mul = Some(out.r_size_mul.unwrap_or(1.0) * scale); + out +} + +impl ModeOpts { + /// The base profile for `mode`, then hand the mutable options over for the + /// power-user tweaks a preset cannot express. The struct is + /// `#[non_exhaustive]`, so this is the only way to build one outside the + /// crate. + pub fn fill(mode: crate::types::ModeKey, f: impl FnOnce(&mut Self)) -> Self { + let mut opts = base_profile(mode); + f(&mut opts); + opts + } +} + +/// Base (fine) profiles per mode, before preset multipliers. +pub fn base_profile(mode: crate::types::ModeKey) -> ModeOpts { + use crate::types::ModeKey::*; + match mode { + Globe => ModeOpts { + lat_rings: Some(17.0), + lon_density: Some(44.0), + r_base: Some(0.6), + r_depth: Some(1.7), + r_boost: Some(1.0), + ink_far: Some(0.62), + ink_span: Some(0.54), + rs_pow: Some(0.6), + r_min: Some(0.3), + ..Default::default() + }, + Orbits => ModeOpts { + orbit_n: Some(12.0), + ghost_n: Some(40.0), + ghost_r: Some(0.9), + ghost_a: Some(0.5), + particles: Some(3.0), + part_r: Some(1.2), + part_r_depth: Some(1.6), + rs_pow: Some(0.6), + r_min: Some(0.3), + ..Default::default() + }, + Rubik => ModeOpts { + lat_rings: Some(15.0), + lon_density: Some(40.0), + move_count: Some(14.0), + r_base: Some(0.6), + r_depth: Some(1.7), + r_active: Some(0.3), + ink_far: Some(0.62), + ink_span: Some(0.54), + rs_pow: Some(0.6), + r_min: Some(0.3), + ..Default::default() + }, + Wave => ModeOpts { + rings: Some(15.0), + lon_density: Some(40.0), + r_base: Some(0.6), + r_depth: Some(1.7), + rs_pow: Some(0.6), + r_min: Some(0.3), + ..Default::default() + }, + Web => ModeOpts { + node_n: Some(30.0), + thr: Some(0.72), + signals: Some(5.0), + node_r: Some(1.4), + node_r_depth: Some(1.8), + line_w: Some(0.8), + rs_pow: Some(0.6), + r_min: Some(0.3), + ..Default::default() + }, + Braid => ModeOpts { + strand_n: Some(52.0), + turns: Some(3.0), + ghost_n: Some(150.0), + r_base: Some(1.2), + r_depth: Some(1.8), + rs_pow: Some(0.6), + r_min: Some(0.3), + ..Default::default() + }, + Ribbon => ModeOpts { + lanes: Some(5.0), + segs: Some(88.0), + ghost_n: Some(150.0), + r_base: Some(1.1), + r_depth: Some(1.7), + rs_pow: Some(0.6), + r_min: Some(0.3), + ..Default::default() + }, + Ring => ModeOpts { + lanes: Some(5.0), + segs: Some(88.0), + ghost_n: Some(0.0), + face_on: Some(1.0), + r_base: Some(1.1), + r_depth: Some(1.7), + rs_pow: Some(0.6), + r_min: Some(0.3), + ..Default::default() + }, + Morph => ModeOpts { + r_dot: Some(0.021), + icon_d: Some(1.0), + r_min: Some(0.25), + ..Default::default() + }, + Focus => ModeOpts { + lanes: Some(6.0), + segs: Some(12.0), + particles: Some(5.0), + r_base: Some(0.9), + r_depth: Some(1.25), + rs_pow: Some(0.6), + r_min: Some(0.3), + ..Default::default() + }, + Gyroscope => ModeOpts { + lanes: Some(3.0), + segs: Some(28.0), + r_base: Some(0.8), + r_depth: Some(1.5), + rs_pow: Some(0.6), + r_min: Some(0.3), + ..Default::default() + }, + Echo => ModeOpts { + lanes: Some(4.0), + segs: Some(18.0), + particles: Some(3.0), + r_base: Some(0.85), + r_depth: Some(1.05), + rs_pow: Some(0.6), + r_min: Some(0.3), + ..Default::default() + }, + } +} diff --git a/crates/orb/src/engine/ribbon.rs b/crates/orb/src/engine/ribbon.rs new file mode 100644 index 0000000000..2b4ae5568a --- /dev/null +++ b/crates/orb/src/engine/ribbon.rs @@ -0,0 +1,110 @@ +// Extracted from Bezel 6141af9c16f7353cdf36003f7404e0a94566a163; MIT. See THIRD_PARTY_NOTICES.md. +//! Ribbon / ring: undulating sash or face-on breathing ring. + +use crate::engine::{ + core::{Dot, Frame, make_proj, radius_scale, with_fib_dirs}, + profiles::{MAX_GHOST_N, MAX_LANES, MAX_SEGS, ModeOpts, count_usize}, +}; +use std::f32::consts::PI; + +pub fn draw_ribbon_into(size: f32, t: f32, o: &ModeOpts, out: &mut Frame) { + let cx = size / 2.0; + let cy = size / 2.0; + let r = (size / 2.0) * 0.78; + let spin = o.spin.unwrap_or(1.0); + let cam_tilt = 0.3; + let pt = make_proj(t * 0.1 * spin, cam_tilt, cx, cy, 1.0); + let rs = radius_scale(size, o.rs_pow.unwrap_or(0.6)); + let face_on = o.face_on.unwrap_or(0.0) != 0.0; + + let dots = &mut out.dots; + let ghost_n = count_usize(o.ghost_n, 150.0, 0, MAX_GHOST_N as usize); + let inv_r = 1.0 / r; + dots.reserve(ghost_n); + with_fib_dirs(ghost_n, |directions| { + for d in directions { + let (px, py, z) = pt.project(d[0] * r, d[1] * r, d[2] * r); + let depth = (z * inv_r + 1.0) / 2.0; + dots.push(Dot::new(px, py, z, 0.8 * rs, 0.78).with_a(0.1 + 0.22 * depth)); + } + }); + + let ya = t * 0.24 * spin; + let ta = if face_on { + -cam_tilt + } else { + 0.55 + 0.3 * (t * 0.18).sin() * spin + }; + let ux = ya.cos(); + let uy = 0.0; + let uz = ya.sin(); + let (sta, cta) = (ta.sin(), ta.cos()); + let vx = -uz * sta; + let vy = cta; + let vz = ux * sta; + // plane normal n = u × v + let nx = uy * vz - uz * vy; + let ny = uz * vx - ux * vz; + let nz = ux * vy - uy * vx; + + let wob_mul = o.wob_mul.unwrap_or(1.0); + let wob_amp = 0.23 * wob_mul; + let base_r = if face_on { + r / (1.0 + 0.85 * wob_amp) + } else { + r + }; + + let base_lanes = o.lanes.unwrap_or(5.0).clamp(1.0, MAX_LANES); + let segs = count_usize(o.segs, 88.0, 1, MAX_SEGS as usize); + let lanes = (base_lanes * o.band_mul.unwrap_or(1.0)) + .round() + .clamp(1.0, MAX_LANES) as usize; + let r_base = o.r_base.unwrap_or(1.1); + let r_depth = o.r_depth.unwrap_or(1.7); + let seg_step = 2.0 * PI / segs as f32; + let half = (lanes as f32 - 1.0) / 2.0; + let inv_half = 1.0 / half.max(1.0); + dots.reserve(lanes.saturating_mul(segs)); + + for w in 0..lanes { + let centered = w as f32 - half; + let lane_off = centered * 0.075; + let edge = centered.abs() * inv_half; + let edge_r = 1.0 - 0.25 * edge; + let edge_ink = 0.18 * edge; + for k in 0..segs { + let a = k as f32 * seg_step; + let (ca, sa) = (a.cos(), a.sin()); + let breath = if face_on { + 0.72 + 0.28 * (t * 0.48).sin() + } else { + 1.0 + }; + let wob = (0.16 * (a * 3.0 - t * 1.7 + w as f32 * 0.22).sin() + + 0.07 * (a * 5.0 + t * 1.1).sin()) + * wob_mul + * breath; + let radial = if face_on { 1.0 + wob } else { 1.0 }; + let off = if face_on { lane_off } else { lane_off + wob }; + let x = ux * ca + vx * sa + nx * off; + let y = uy * ca + vy * sa + ny * off; + let z = uz * ca + vz * sa + nz * off; + let l = (x * x + y * y + z * z).sqrt(); + let inv_l = if l > 1e-6 { 1.0 / l } else { 0.0 }; + let rr = base_r * radial * inv_l; + let (px, py, zr) = pt.project(x * rr, y * rr, z * rr); + let depth = (zr * inv_r + 1.0) / 2.0; + dots.push( + Dot::new( + px, + py, + zr, + (r_base + r_depth * depth) * edge_r * rs, + 0.52 - 0.44 * depth + edge_ink, + ) + .with_a(0.4 + 0.6 * depth), + ); + } + } +} diff --git a/crates/orb/src/engine/web.rs b/crates/orb/src/engine/web.rs new file mode 100644 index 0000000000..3b7ad3603c --- /dev/null +++ b/crates/orb/src/engine/web.rs @@ -0,0 +1,120 @@ +// Extracted from Bezel 6141af9c16f7353cdf36003f7404e0a94566a163; MIT. See THIRD_PARTY_NOTICES.md. +//! Web: a constellation wires itself — the "connecting" state. + +use crate::engine::{ + core::{Dot, Frame, Line, frac, hash_d, lerp, make_proj, radius_scale, vnoise, with_fib_dirs}, + profiles::{MAX_NODE_N, MAX_SIGNALS, ModeOpts, count_usize}, +}; +use std::cell::RefCell; + +thread_local! { + /// Reused between frames so the steady-state animation loop never + /// allocates. Holds `(unit_x, unit_y, unit_z, proj_x, proj_y, proj_z)` + /// per node: projecting once up front turns the O(n²) edge scan from + /// ~2n² projections into n. + static NODES: RefCell> = const { RefCell::new(Vec::new()) }; + static EDGES: RefCell> = const { RefCell::new(Vec::new()) }; +} + +pub fn draw_web_into(size: f32, t: f32, o: &ModeOpts, out: &mut Frame) { + let cx = size / 2.0; + let cy = size / 2.0; + let r = (size / 2.0) * 0.8 * o.spread.unwrap_or(1.0); + let pt = make_proj(t * 0.12, 0.32, cx, cy, r); + let rs = radius_scale(size, o.rs_pow.unwrap_or(0.6)); + + let node_n = count_usize(o.node_n, 30.0, 0, MAX_NODE_N as usize); + let thr = o.thr.unwrap_or(0.72); + let node_r = o.node_r.unwrap_or(1.4); + let node_r_depth = o.node_r_depth.unwrap_or(1.8); + let line_w = (o.line_w.unwrap_or(0.8) * rs).max(0.6); + let inv_thr = if thr > 0.0 { 1.0 / thr } else { 0.0 }; + + NODES.with(|cell| { + EDGES.with(|edges_cell| { + let mut nodes = cell.borrow_mut(); + let mut edges = edges_cell.borrow_mut(); + nodes.clear(); + edges.clear(); + nodes.reserve(node_n); + + with_fib_dirs(node_n, |directions| { + for (i, d) in directions.iter().enumerate() { + let i_f = i as f32; + let x = d[0] + 0.3 * (vnoise(i_f * 0.31 + 9.0, t * 0.24) - 0.5) * 2.0; + let y = d[1] + 0.3 * (vnoise(i_f * 0.53 + 27.0, t * 0.21) - 0.5) * 2.0; + let z = d[2] + 0.3 * (vnoise(i_f * 0.77 + 55.0, t * 0.27) - 0.5) * 2.0; + let l = (x * x + y * y + z * z).sqrt(); + let inv_l = if l > 1e-6 { 1.0 / l } else { 0.0 }; + let (ux, uy, uz) = (x * inv_l, y * inv_l, z * inv_l); + let (px, py, pz) = pt.project(ux, uy, uz); + nodes.push([ux, uy, uz, px, py, pz]); + } + }); + + for i in 0..node_n { + let a = nodes[i]; + for j in (i + 1)..node_n { + let b = nodes[j]; + let dx = a[0] - b[0]; + let dy = a[1] - b[1]; + let dz = a[2] - b[2]; + let d2 = dx * dx + dy * dy + dz * dz; + // Compare squared distances — avoids a sqrt for every + // rejected pair, and most pairs are rejected. + if d2 >= thr * thr { + continue; + } + let dist = d2.sqrt(); + let depth = ((a[5] + b[5]) / 2.0 + 1.0) / 2.0; + out.lines.push(Line { + x1: a[3], + y1: a[4], + x2: b[3], + y2: b[4], + white: 0.42, + a: (1.0 - dist * inv_thr) * (0.3 + 0.55 * depth), + w: line_w, + }); + edges.push((i, j)); + } + } + + for (i, n) in nodes.iter().enumerate() { + let depth = (n[5] + 1.0) / 2.0; + let pulse = 1.0 + 0.25 * (t * 1.4 + i as f32 * 2.7).sin(); + out.dots.push(Dot::new( + n[3], + n[4], + n[5], + (node_r + node_r_depth * depth) * pulse * rs, + 0.55 - 0.45 * depth, + )); + } + + let signals = count_usize(o.signals, 5.0, 0, MAX_SIGNALS as usize); + for s in 0..signals { + if edges.is_empty() { + break; + } + let s_f = s as f32; + let journey = t * 0.48 + s_f * 3.73; + let cycle = journey.floor(); + let edge_index = (hash_d(cycle, s_f * 4.7 + 1.3) * edges.len() as f32) + .floor() + .min((edges.len() - 1) as f32) as usize; + let (a, b) = edges[edge_index]; + let f = frac(journey); + let px = lerp(nodes[a][3], nodes[b][3], f); + let py = lerp(nodes[a][4], nodes[b][4], f); + let zr = lerp(nodes[a][5], nodes[b][5], f); + let depth = (zr + 1.0) / 2.0; + let life = (std::f32::consts::PI * f).sin().max(0.0); + out.dots.push( + Dot::new(px, py, zr, (node_r * 1.5 + node_r_depth * depth) * rs, 0.05) + .with_a(life * (0.5 + 0.5 * depth)), + ); + } + }); + }); +} diff --git a/crates/orb/src/lib.rs b/crates/orb/src/lib.rs new file mode 100644 index 0000000000..0c91ab0eb3 --- /dev/null +++ b/crates/orb/src/lib.rs @@ -0,0 +1,23 @@ +// Extracted from Bezel 6141af9c16f7353cdf36003f7404e0a94566a163; MIT. See THIRD_PARTY_NOTICES.md. +//! Thinking orbs — dotted loading indicators for AI & agent UIs, ported from +//! gpui-thinking-orbs (MIT), itself a port of Jakub Antalik's thinking-orbs. +//! +//! Twelve hand-tuned animated states and four size presets, emitted as +//! monochrome geometry ([`engine::Frame`]). Nothing here paints: the desktop +//! widget renders frames through GPUI and the mobile apps through their native +//! 2-D APIs, so every platform shows the same artwork. + +mod animator; +mod motion; +mod presets; +mod types; + +pub mod engine; + +pub use animator::{OrbAnimator, crossfade}; +pub use motion::{AnimationClock, AudioResponse}; +pub use presets::{Resolved, resolve_preset}; +pub use types::{ModeKey, OrbSize, OrbState, OrbTheme}; + +/// Reduced motion freezes every orb on this representative frame. +pub const REDUCED_MOTION_T: f32 = 0.6; diff --git a/crates/orb/src/motion.rs b/crates/orb/src/motion.rs new file mode 100644 index 0000000000..f044f1d905 --- /dev/null +++ b/crates/orb/src/motion.rs @@ -0,0 +1,158 @@ +//! Continuous animation time and bounded, independent audio envelopes. +use std::time::Instant; + +#[derive(Default)] +pub struct AnimationClock { + pub seconds: f64, + pub active_seconds: f64, + last: Option, + rate: f64, + running: bool, +} + +impl AnimationClock { + pub fn advance(&mut self, now: Instant, rate: f64, running: bool) { + if let Some(last) = self.last { + if self.running { + let dt = now.saturating_duration_since(last).as_secs_f64(); + self.seconds += dt * self.rate; + self.active_seconds += dt; + } + } + self.last = Some(now); + self.rate = rate; + self.running = running; + } + + pub fn stop(&mut self, now: Instant) { + self.advance(now, self.rate, false); + } + + pub fn is_running(&self) -> bool { + self.running + } +} + +struct Envelope { + origin: f32, + target: f32, + changed: Instant, +} + +impl Envelope { + fn new(now: Instant) -> Self { + Self { + origin: 0.0, + target: 0.0, + changed: now, + } + } + + fn value(&self, now: Instant) -> f32 { + let tau = if self.target > self.origin { + 0.080 + } else { + 0.300 + }; + let dt = now.saturating_duration_since(self.changed).as_secs_f32(); + self.target + (self.origin - self.target) * (-dt / tau).exp() + } + + fn set(&mut self, peak: f32, now: Instant) -> bool { + // Ignore quiet background noise; compress peaks without exceeding 1. + let peak = if peak.is_finite() { + peak.clamp(0.0, 1.0) + } else { + 0.0 + }; + let x = ((peak - 0.015) / 0.985).max(0.0); + let target = 1.2 * x / (x + 0.2); + if self.target == target { + return false; + } + self.origin = self.value(now); + self.target = target; + self.changed = now; + true + } +} + +pub struct AudioResponse { + microphone: Envelope, + speaker: Envelope, +} + +impl AudioResponse { + pub fn new(now: Instant) -> Self { + Self { + microphone: Envelope::new(now), + speaker: Envelope::new(now), + } + } + + pub fn set(&mut self, microphone: f32, speaker: f32, now: Instant) -> bool { + let mic_changed = self.microphone.set(microphone, now); + let speaker_changed = self.speaker.set(speaker, now); + mic_changed || speaker_changed + } + + pub fn speed(&self, now: Instant) -> f64 { + let mic = self.microphone.value(now); + let speaker = self.speaker.value(now); + // A bounded, continuous combination, even when both sides overlap. + 1.0 + f64::from((1.0 - (1.0 - mic) * (1.0 - speaker)).clamp(0.0, 1.0)) * 0.3 + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; + + #[test] + fn changing_speed_after_long_uptime_preserves_position() { + let now = Instant::now(); + let mut clock = AnimationClock::default(); + clock.advance(now, 1.0, true); + let later = now + Duration::from_secs(3600); + clock.advance(later, 2.0, true); + assert_eq!(clock.seconds, 3600.0); + clock.advance(later + Duration::from_millis(100), 2.0, true); + assert!((clock.seconds - 3600.2).abs() < 1e-8); + } + + #[test] + fn hidden_and_paused_time_does_not_advance_animation() { + let now = Instant::now(); + let mut clock = AnimationClock::default(); + clock.advance(now, 2.0, true); + clock.stop(now + Duration::from_secs(1)); + clock.advance(now + Duration::from_secs(60), 3.0, true); + assert_eq!(clock.seconds, 2.0); + clock.advance(now + Duration::from_secs(61), 3.0, true); + assert_eq!(clock.seconds, 5.0); + assert_eq!(clock.active_seconds, 2.0); + } + + #[test] + fn audio_ignores_noise_and_smooths_attack_release_and_channel_overlap() { + let now = Instant::now(); + let mut audio = AudioResponse::new(now); + audio.set(0.01, f32::NAN, now); + assert_eq!(audio.speed(now + Duration::from_secs(1)), 1.0); + audio.set(1.0, 0.0, now); + assert_eq!(audio.speed(now), 1.0); + let attack = now + Duration::from_millis(80); + assert!((1.15..1.25).contains(&audio.speed(attack))); + let before = audio.speed(attack); + audio.set(0.0, 1.0, attack); + assert_eq!(audio.speed(attack), before); + for ms in 80..1000 { + assert!((1.0..=1.300001).contains(&audio.speed(now + Duration::from_millis(ms)))); + } + let quiet = now + Duration::from_secs(1); + audio.set(0.0, 0.0, quiet); + assert!(audio.speed(quiet + Duration::from_millis(300)) > 1.05); + assert!(audio.speed(quiet + Duration::from_secs(3)) < 1.001); + } +} diff --git a/crates/orb/src/presets.rs b/crates/orb/src/presets.rs new file mode 100644 index 0000000000..7fa5f6f9db --- /dev/null +++ b/crates/orb/src/presets.rs @@ -0,0 +1,354 @@ +// Extracted from Bezel 6141af9c16f7353cdf36003f7404e0a94566a163; MIT. See THIRD_PARTY_NOTICES.md. +//! Twelve states × four sizes — original ports plus library-native concepts. + +use crate::{ + engine::{ModeOpts, base_profile, scale_counts, scale_radii}, + types::{ModeKey, OrbSize, OrbState}, +}; + +/// Fully resolved draw options for a (state, size) pair. +#[derive(Clone, Debug)] +pub struct Resolved { + pub mode: ModeKey, + pub speed: f32, + pub opts: ModeOpts, +} + +struct Preset { + speed: f32, + count: f32, + size: f32, + // extra knobs merged after scaling + scan_mul: Option, + dim_base: Option, + spin: Option, + band_mul: Option, + wob_mul: Option, + spread: Option, +} + +fn preset_for(mode: ModeKey, size: OrbSize) -> Preset { + use ModeKey::*; + + // The large designs preserve each avatar preset's character while adding + // detail gradually. `scale_counts` understands each painter's topology + // (2-D lattices, flat lists, lane/segment pairs), so this is deliberately + // not a naive coordinate scale. + if matches!(size, OrbSize::Large | OrbSize::Hero) { + let mut preset = preset_for(mode, OrbSize::Avatar); + let (count, radius, speed) = match size { + OrbSize::Large => (1.15, 1.05, 0.95), + OrbSize::Hero => (1.30, 1.10, 0.90), + _ => unreachable!(), + }; + preset.count *= count; + preset.size *= radius; + preset.speed *= speed; + return preset; + } + + match (mode, size) { + (Orbits, OrbSize::Avatar) => Preset { + speed: 1.885, + count: 1.0, + size: 1.0, + scan_mul: None, + dim_base: None, + spin: None, + band_mul: None, + wob_mul: None, + spread: None, + }, + (Orbits, OrbSize::Inline) => Preset { + speed: 3.9, + count: 0.238, + size: 2.4, + scan_mul: None, + dim_base: None, + spin: None, + band_mul: None, + wob_mul: None, + spread: None, + }, + (Globe, OrbSize::Avatar) => Preset { + speed: 2.015, + count: 0.42, + size: 1.15, + scan_mul: Some(4.08), + dim_base: Some(0.45), + spin: None, + band_mul: None, + wob_mul: None, + spread: None, + }, + (Globe, OrbSize::Inline) => Preset { + speed: 2.665, + count: 0.105, + size: 1.75, + scan_mul: Some(4.335), + dim_base: Some(0.45), + spin: None, + band_mul: None, + wob_mul: None, + spread: None, + }, + (Rubik, OrbSize::Avatar) => Preset { + speed: 1.82, + count: 0.35, + size: 1.05, + scan_mul: None, + dim_base: None, + spin: None, + band_mul: None, + wob_mul: None, + spread: None, + }, + (Rubik, OrbSize::Inline) => Preset { + speed: 1.95, + count: 0.088, + size: 1.9, + scan_mul: None, + dim_base: None, + spin: None, + band_mul: None, + wob_mul: None, + spread: None, + }, + (Wave, OrbSize::Avatar) => Preset { + speed: 3.15, + count: 0.341, + size: 1.0, + scan_mul: None, + dim_base: None, + spin: None, + band_mul: None, + wob_mul: None, + spread: None, + }, + (Wave, OrbSize::Inline) => Preset { + speed: 3.35, + count: 0.105, + size: 1.6, + scan_mul: None, + dim_base: None, + spin: None, + band_mul: None, + wob_mul: None, + spread: None, + }, + (Web, OrbSize::Avatar) => Preset { + speed: 3.315, + count: 1.35, + size: 0.95, + scan_mul: None, + dim_base: None, + spin: None, + band_mul: None, + wob_mul: None, + spread: None, + }, + (Web, OrbSize::Inline) => Preset { + speed: 6.63, + count: 0.25, + size: 1.52, + scan_mul: None, + dim_base: None, + spin: None, + band_mul: None, + wob_mul: None, + spread: None, + }, + (Braid, OrbSize::Avatar) => Preset { + speed: 1.625, + count: 0.5, + size: 1.0, + scan_mul: None, + dim_base: None, + spin: None, + band_mul: None, + wob_mul: None, + spread: None, + }, + (Braid, OrbSize::Inline) => Preset { + speed: 2.75, + count: 0.1125, + size: 1.36, + scan_mul: None, + dim_base: None, + spin: None, + band_mul: None, + wob_mul: None, + spread: None, + }, + (Ribbon, OrbSize::Avatar) => Preset { + speed: 1.95, + count: 0.25, + size: 0.85, + scan_mul: None, + dim_base: None, + spin: Some(0.0), + band_mul: Some(2.9), + wob_mul: Some(1.0), + spread: None, + }, + (Ribbon, OrbSize::Inline) => Preset { + speed: 2.5, + count: 0.051, + size: 1.073, + scan_mul: None, + dim_base: None, + spin: Some(0.0), + band_mul: Some(3.6), + wob_mul: Some(1.0), + spread: None, + }, + (Ring, OrbSize::Avatar) => Preset { + speed: 1.55, + count: 0.25, + size: 0.956, + scan_mul: None, + dim_base: None, + spin: Some(0.0), + band_mul: Some(2.8), + wob_mul: Some(0.368), + spread: None, + }, + (Ring, OrbSize::Inline) => Preset { + speed: 2.1, + count: 0.028, + size: 1.622, + scan_mul: None, + dim_base: None, + spin: Some(0.0), + band_mul: Some(3.2), + wob_mul: Some(0.565), + spread: None, + }, + (Morph, OrbSize::Avatar) => Preset { + speed: 2.405, + count: 0.702, + size: 0.395, + scan_mul: None, + dim_base: None, + spin: None, + band_mul: None, + wob_mul: None, + spread: Some(1.45), + }, + (Morph, OrbSize::Inline) => Preset { + speed: 2.08, + count: 0.53, + size: 1.011, + scan_mul: None, + dim_base: None, + spin: None, + band_mul: None, + wob_mul: None, + spread: Some(1.45), + }, + (Focus, OrbSize::Avatar) => Preset { + speed: 1.72, + count: 1.0, + size: 1.0, + scan_mul: None, + dim_base: None, + spin: None, + band_mul: None, + wob_mul: None, + spread: Some(1.0), + }, + (Focus, OrbSize::Inline) => Preset { + speed: 2.2, + count: 0.22, + size: 1.45, + scan_mul: None, + dim_base: None, + spin: None, + band_mul: None, + wob_mul: None, + spread: Some(1.0), + }, + (Gyroscope, OrbSize::Avatar) => Preset { + speed: 1.48, + count: 1.0, + size: 1.0, + scan_mul: None, + dim_base: None, + spin: None, + band_mul: None, + wob_mul: None, + spread: Some(1.0), + }, + (Gyroscope, OrbSize::Inline) => Preset { + speed: 2.05, + count: 0.25, + size: 1.55, + scan_mul: None, + dim_base: None, + spin: None, + band_mul: None, + wob_mul: None, + spread: Some(1.0), + }, + (Echo, OrbSize::Avatar) => Preset { + speed: 1.85, + count: 1.0, + size: 1.0, + scan_mul: None, + dim_base: None, + spin: None, + band_mul: None, + wob_mul: None, + spread: Some(1.0), + }, + (Echo, OrbSize::Inline) => Preset { + speed: 2.45, + count: 0.25, + size: 1.55, + scan_mul: None, + dim_base: None, + spin: None, + band_mul: None, + wob_mul: None, + spread: Some(1.0), + }, + (_, OrbSize::Large | OrbSize::Hero) => unreachable!(), + } +} + +/// Resolve a (state, size) pair to its mode + fully-scaled draw options. +pub fn resolve_preset(state: OrbState, size: OrbSize) -> Resolved { + let mode = ModeKey::from_state(state); + let preset = preset_for(mode, size); + let mut opts = base_profile(mode); + if (preset.count - 1.0).abs() > f32::EPSILON { + opts = scale_counts(&opts, preset.count); + } + if (preset.size - 1.0).abs() > f32::EPSILON { + opts = scale_radii(&opts, preset.size); + } + if let Some(v) = preset.scan_mul { + opts.scan_mul = Some(v); + } + if let Some(v) = preset.dim_base { + opts.dim_base = Some(v); + } + if let Some(v) = preset.spin { + opts.spin = Some(v); + } + if let Some(v) = preset.band_mul { + opts.band_mul = Some(v); + } + if let Some(v) = preset.wob_mul { + opts.wob_mul = Some(v); + } + if let Some(v) = preset.spread { + opts.spread = Some(v); + } + + Resolved { + mode, + speed: preset.speed, + opts, + } +} diff --git a/crates/orb/src/types.rs b/crates/orb/src/types.rs new file mode 100644 index 0000000000..3a88b54cc8 --- /dev/null +++ b/crates/orb/src/types.rs @@ -0,0 +1,217 @@ +// Extracted from Bezel 6141af9c16f7353cdf36003f7404e0a94566a163; MIT. See THIRD_PARTY_NOTICES.md. +//! Public types shared by the animation engine and the GPUI widget. + +/// The twelve shipped states — each a hand-tuned animation. +/// +/// Marked `#[non_exhaustive]`: matching on this from another crate needs a +/// wildcard arm, so shipping a tenth state is not a breaking change. Prefer +/// [`OrbState::label`] / [`OrbState::as_str`] over matching where you can. +#[non_exhaustive] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +pub enum OrbState { + /// Particles on tilted orbits. + #[default] + Working, + /// A scan meridian sweeps a dotted globe. + Searching, + /// Bands scramble in quarter turns, then click back. + Solving, + /// A waveform rolls through latitude rings. + Listening, + /// A constellation wires itself, packets running the edges. + Connecting, + /// Three strands plait around the sphere. + Weaving, + /// An undulating multi-band sash. + Composing, + /// A face-on ring slowly morphing. + Breathing, + /// A dotted outline morphs circle → triangle → square. + Shaping, + /// An iris of particles converges and relaxes around a focal point. + Focusing, + /// Counter-rotating great circles form a reasoning gyroscope. + Reasoning, + /// Concentric memory echoes travel out from a steady core. + Recalling, +} + +impl OrbState { + /// All states in playground / gallery order. + /// + /// This is a slice so adding future states does not change its public type. + pub const ALL_STATES: &'static [OrbState] = &[ + OrbState::Working, + OrbState::Searching, + OrbState::Solving, + OrbState::Listening, + OrbState::Connecting, + OrbState::Weaving, + OrbState::Composing, + OrbState::Breathing, + OrbState::Shaping, + OrbState::Focusing, + OrbState::Reasoning, + OrbState::Recalling, + ]; + + /// The original nine-state gallery. + /// + /// Kept at its original array type for source compatibility. New code + /// should iterate [`Self::ALL_STATES`]. + #[deprecated(since = "0.2.0", note = "use OrbState::ALL_STATES")] + pub const ALL: [OrbState; 9] = [ + OrbState::Working, + OrbState::Searching, + OrbState::Solving, + OrbState::Listening, + OrbState::Connecting, + OrbState::Weaving, + OrbState::Composing, + OrbState::Breathing, + OrbState::Shaping, + ]; + + /// Human-readable status label (matches upstream aria defaults). + pub fn label(self) -> &'static str { + match self { + OrbState::Working => "Working…", + OrbState::Searching => "Searching…", + OrbState::Solving => "Solving…", + OrbState::Listening => "Listening…", + OrbState::Connecting => "Connecting…", + OrbState::Weaving => "Weaving…", + OrbState::Composing => "Composing…", + OrbState::Breathing => "Thinking…", + OrbState::Shaping => "Shaping…", + OrbState::Focusing => "Focusing…", + OrbState::Reasoning => "Reasoning…", + OrbState::Recalling => "Recalling…", + } + } + + /// Stable snake_case name (matches the web package `state` prop). + pub fn as_str(self) -> &'static str { + match self { + OrbState::Working => "working", + OrbState::Searching => "searching", + OrbState::Solving => "solving", + OrbState::Listening => "listening", + OrbState::Connecting => "connecting", + OrbState::Weaving => "weaving", + OrbState::Composing => "composing", + OrbState::Breathing => "breathing", + OrbState::Shaping => "shaping", + OrbState::Focusing => "focusing", + OrbState::Reasoning => "reasoning", + OrbState::Recalling => "recalling", + } + } +} + +/// Rendered size in logical pixels. Four tuned presets ship: inline (20), +/// avatar (64), large (96), and hero (128). Larger sizes add detail gradually +/// instead of merely stretching the 64 px artwork. +#[non_exhaustive] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +pub enum OrbSize { + /// Chat-avatar scale (64 logical px). + #[default] + Avatar, + /// Inline-text scale (20 logical px). + Inline, + /// Prominent status / card scale (96 logical px). + Large, + /// Hero / empty-state scale (128 logical px). + Hero, +} + +impl OrbSize { + /// All sizes in compact-to-prominent gallery order. + pub const ALL_SIZES: &'static [OrbSize] = &[ + OrbSize::Inline, + OrbSize::Avatar, + OrbSize::Large, + OrbSize::Hero, + ]; + + /// Logical pixel edge length of the orb. + pub fn pixels(self) -> f32 { + match self { + OrbSize::Inline => 20.0, + OrbSize::Avatar => 64.0, + OrbSize::Large => 96.0, + OrbSize::Hero => 128.0, + } + } + + /// Stable lowercase name for controls and command-line arguments. + pub fn as_str(self) -> &'static str { + match self { + OrbSize::Inline => "inline", + OrbSize::Avatar => "avatar", + OrbSize::Large => "large", + OrbSize::Hero => "hero", + } + } + + /// Compact playground label. + pub fn label(self) -> &'static str { + match self { + OrbSize::Inline => "20 · inline", + OrbSize::Avatar => "64 · avatar", + OrbSize::Large => "96 · large", + OrbSize::Hero => "128 · hero", + } + } +} + +/// Theme mode for monochrome ink on transparent canvas. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +pub enum OrbTheme { + /// Resolve from the window appearance (GPUI `WindowAppearance`). + #[default] + Auto, + /// Light ink for dark backgrounds. + Dark, + /// Dark ink for light backgrounds. + Light, +} + +/// Internal mode keys — one painter per key. Grows in lockstep with +/// [`OrbState`], hence `#[non_exhaustive]`. +#[non_exhaustive] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ModeKey { + Orbits, + Globe, + Rubik, + Wave, + Web, + Braid, + Ribbon, + Ring, + Morph, + Focus, + Gyroscope, + Echo, +} + +impl ModeKey { + pub fn from_state(state: OrbState) -> Self { + match state { + OrbState::Working => ModeKey::Orbits, + OrbState::Searching => ModeKey::Globe, + OrbState::Solving => ModeKey::Rubik, + OrbState::Listening => ModeKey::Wave, + OrbState::Connecting => ModeKey::Web, + OrbState::Weaving => ModeKey::Braid, + OrbState::Composing => ModeKey::Ribbon, + OrbState::Breathing => ModeKey::Ring, + OrbState::Shaping => ModeKey::Morph, + OrbState::Focusing => ModeKey::Focus, + OrbState::Reasoning => ModeKey::Gyroscope, + OrbState::Recalling => ModeKey::Echo, + } + } +} diff --git a/crates/preview/src/discovery.rs b/crates/preview/src/discovery.rs index 34fe6cb71a..0ee2977cae 100644 --- a/crates/preview/src/discovery.rs +++ b/crates/preview/src/discovery.rs @@ -1,5 +1,6 @@ //! Observe only this user's listeners. HTTP probes run only after project -//! ownership has been established from the process's actual working directory. +//! ownership has been established from the process's actual working directory +//! and commands performing authentication have been excluded. use std::{ collections::HashMap, net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}, @@ -23,6 +24,31 @@ impl Listener { pub fn belongs_to(&self, root: &Path) -> bool { self.cwd.starts_with(root) } + + /// A login CLI may open an HTTP listener in the project's cwd, but that + /// listener is a browser callback, not a development server. Some (such as + /// Infisical) abort the login on an unsolicited HEAD, so exclude explicit + /// authentication commands before connecting at all. Match whole arguments, + /// not paths, URLs or source strings that happen to mention authentication. + pub(crate) fn is_authentication_command(&self) -> bool { + self.args + .iter() + .skip(1) + .take_while(|arg| arg.as_str() != "--") + .any(|arg| { + matches!( + arg.as_str(), + "login" + | "auth" + | "authenticate" + | "signin" + | "sign-in" + | "sso" + | "oauth" + | "oauth2" + ) + }) + } } /// Preserve a useful framework label without advertising process arguments, @@ -432,6 +458,53 @@ pub fn same_process(_pid: u32, _started_at: u64) -> bool { mod tests { use super::*; #[test] + fn authentication_commands_are_not_preview_candidates() { + let listener = |args: &[&str]| Listener { + pid: 1, + parent: 0, + cwd: "/project".into(), + args: args.iter().map(|arg| (*arg).into()).collect(), + started_at: 1, + address: "127.0.0.1:3000".parse().unwrap(), + zeron_owned: true, + }; + for args in [ + vec!["/usr/bin/infisical", "login"], + vec![ + "infisical", + "--silent", + "login", + "--domain", + "https://app.infisical.com", + ], + vec!["gh", "auth", "login"], + vec!["aws", "sso", "login"], + vec!["gcloud", "auth", "application-default", "login"], + vec!["codex", "login"], + vec!["node", "/tools/firebase.js", "login"], + vec!["cli", "authenticate"], + vec!["cli", "signin"], + vec!["cli", "sign-in"], + vec!["cli", "oauth"], + vec!["cli", "oauth2"], + ] { + assert!(listener(&args).is_authentication_command(), "{args:?}"); + } + for args in [ + vec![], + vec!["node", "/project/auth/server.js"], + vec!["node", "/project/login.js"], + vec!["node", "/project/node_modules/vite/bin/vite.js"], + vec!["next", "dev"], + vec!["python3", "-m", "http.server", "3000"], + vec!["python3", "-c", "import auth; auth.serve()"], + vec!["server", "--auth", "--login-url=https://example.com/login"], + vec!["infisical", "run", "--", "node", "server.js", "login"], + ] { + assert!(!listener(&args).is_authentication_command(), "{args:?}"); + } + } + #[test] fn service_identity_ignores_port_configuration() { let args = |port: &str| vec!["node".into(), "api.js".into(), "--port".into(), port.into()]; assert_eq!( diff --git a/crates/preview/src/service.rs b/crates/preview/src/service.rs index ae028d45c1..9416093ad5 100644 --- a/crates/preview/src/service.rs +++ b/crates/preview/src/service.rs @@ -226,6 +226,7 @@ impl PreviewService { .filter(|l| { l.address.port() != zeron_proto::PREVIEW_PROXY_PORT && l.pid != std::process::id() + && !l.is_authentication_command() }) .filter_map(|listener| { roots diff --git a/crates/preview/tests/discovery.rs b/crates/preview/tests/discovery.rs index 6228ea5abe..3d511e77ce 100644 --- a/crates/preview/tests/discovery.rs +++ b/crates/preview/tests/discovery.rs @@ -157,3 +157,117 @@ async fn a_discovered_server_is_probed_once_not_every_cycle() { ); service.shutdown().await; } + +/// Infisical's browser login decodes the first callback request as JSON. A +/// discovery HEAD has no body and aborts that login with EOF. Even opening a +/// connection to these one-shot listeners is outside preview discovery's remit. +#[tokio::test] +async fn authentication_callbacks_receive_no_discovery_connections() { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + let _serial = SERIAL.lock().await; + let temp = tempfile::tempdir().unwrap(); + let app = temp.path().join("app"); + std::fs::create_dir(&app).unwrap(); + let ready = temp.path().join("callback-port"); + let connections = temp.path().join("callback-connections"); + let script = format!( + r#"import json,socket +s=socket.socket() +s.bind(('127.0.0.1',0)) +s.listen() +open({ready:?},'w').write(str(s.getsockname()[1])) +c,_=s.accept() +open({connections:?},'a').write('connected\n') +r=c.makefile('rb') +request=r.readline() +headers={{}} +while True: + line=r.readline() + if line in (b'\r\n',b''): break + name,value=line.decode().split(':',1) + headers[name.lower()]=value.strip() +body=r.read(int(headers.get('content-length','0'))) +if not body: raise RuntimeError('EOF') +assert request.startswith(b'POST ') +assert json.loads(body)=={{'code':'test-code'}} +c.sendall(b'HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nOK') +"#, + ready = ready.display().to_string(), + connections = connections.display().to_string(), + ); + let mut callback = Child( + std::process::Command::new("python3") + .args([ + "-c", + &script, + "login", + "--domain", + "https://app.infisical.com", + ]) + .current_dir(&app) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::inherit()) + .spawn() + .unwrap(), + ); + let port: u16 = tokio::time::timeout(Duration::from_secs(5), async { + loop { + if let Some(port) = std::fs::read_to_string(&ready) + .ok() + .and_then(|s| s.parse::().ok()) + { + break port; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + }) + .await + .expect("callback did not start"); + let server = launch(&app, true); + let service = PreviewService::new( + temp.path().join("names.json"), + "local".into(), + "Laptop".into(), + ) + .unwrap(); + let roots = vec![app]; + service.start(Arc::new(move || roots.clone()), None).await; + wait(&service, Some(server.0.id())).await; + // Discovery actually runs, including multiple cycles after the ordinary + // development server is found. The callback must remain entirely untouched. + tokio::time::sleep(Duration::from_secs(5)).await; + assert!( + !connections.exists(), + "preview discovery connected to the authentication callback" + ); + assert!(callback.0.try_wait().unwrap().is_none()); + + // The real browser request can still finish the waiting sign-in. + let body = r#"{"code":"test-code"}"#; + let mut browser = tokio::net::TcpStream::connect(("127.0.0.1", port)) + .await + .unwrap(); + browser + .write_all( + format!( + "POST / HTTP/1.1\r\nHost: localhost:{port}\r\nContent-Length: {}\r\n\r\n{body}", + body.len() + ) + .as_bytes(), + ) + .await + .unwrap(); + let mut response = String::new(); + tokio::time::timeout( + Duration::from_secs(5), + browser.read_to_string(&mut response), + ) + .await + .unwrap() + .unwrap(); + assert!(response.starts_with("HTTP/1.1 200 OK")); + assert_eq!(std::fs::read_to_string(connections).unwrap(), "connected\n"); + assert!(callback.0.wait().unwrap().success()); + service.shutdown().await; +} diff --git a/crates/proto/src/agent.rs b/crates/proto/src/agent.rs index 25c750e857..d7973c0355 100644 --- a/crates/proto/src/agent.rs +++ b/crates/proto/src/agent.rs @@ -415,11 +415,78 @@ pub const SUBAGENT_INPUT_KEEP: [&str; 5] = [ "subagent_type", ]; +/// Where one checklist item stands. Claude's TodoWrite, OpenCode and ACP plans +/// distinguish the item being worked on from those still waiting; Codex and +/// Cursor only report done / not done. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum TodoStatus { + #[default] + Pending, + InProgress, + Completed, +} + +impl TodoStatus { + /// Decode a harness status string. Anything unrecognised (including + /// `cancelled`) is `Pending`: not finished, not being worked on. + pub fn parse(raw: &str) -> Self { + match raw { + "completed" | "complete" | "done" => Self::Completed, + "in_progress" | "inProgress" | "in-progress" | "active" => Self::InProgress, + _ => Self::Pending, + } + } +} + +/// One checklist entry. +/// +/// `done` is the original wire field and stays authoritative for completion, +/// so docs written before `status` existed (and readers that ignore it) keep +/// working. `status` is additive and written only when it adds information — +/// i.e. for an in-progress item; a missing or unreadable value derives from +/// `done`. Build items with [`TodoItem::new`] to keep the two consistent. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TodoItem { pub text: String, pub done: bool, + #[serde( + default, + skip_serializing_if = "Option::is_none", + deserialize_with = "lenient_todo_status" + )] + pub status: Option, +} + +impl TodoItem { + pub fn new(text: impl Into, status: TodoStatus) -> Self { + Self { + text: text.into(), + done: status == TodoStatus::Completed, + status: (status == TodoStatus::InProgress).then_some(status), + } + } + + /// Effective status: `done` wins, then the explicit status, else pending. + pub fn status(&self) -> TodoStatus { + if self.done { + TodoStatus::Completed + } else { + self.status.unwrap_or_default() + } + } +} + +/// A status this build does not know (a future `blocked`, say) must not fail +/// the whole tool call — the part would vanish from the transcript. Treat it +/// as absent so the item falls back to `done`. +fn lenient_todo_status<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + let value = Option::::deserialize(deserializer)?; + Ok(value.and_then(|v| serde_json::from_value(v).ok())) } /// A slash command advertised by the agent (ACP `availableCommands`): typed as @@ -811,3 +878,68 @@ mod generated_image_tests { assert_eq!(serde_json::from_value::(value).unwrap(), event); } } + +#[cfg(test)] +mod todo_item_tests { + use super::*; + use serde_json::json; + + #[test] + fn legacy_items_without_status_derive_from_done() { + let done: TodoItem = serde_json::from_value(json!({ "text": "a", "done": true })).unwrap(); + let open: TodoItem = serde_json::from_value(json!({ "text": "b", "done": false })).unwrap(); + assert_eq!(done.status(), TodoStatus::Completed); + assert_eq!(open.status(), TodoStatus::Pending); + assert_eq!(open.status, None); + } + + #[test] + fn only_in_progress_adds_a_field_so_other_docs_are_unchanged() { + let pending = serde_json::to_value(TodoItem::new("a", TodoStatus::Pending)).unwrap(); + let done = serde_json::to_value(TodoItem::new("b", TodoStatus::Completed)).unwrap(); + let active = serde_json::to_value(TodoItem::new("c", TodoStatus::InProgress)).unwrap(); + assert_eq!(pending, json!({ "text": "a", "done": false })); + assert_eq!(done, json!({ "text": "b", "done": true })); + assert_eq!( + active, + json!({ "text": "c", "done": false, "status": "inProgress" }) + ); + } + + #[test] + fn unknown_status_falls_back_to_done_instead_of_failing_the_call() { + let call: ToolCall = serde_json::from_value(json!({ + "kind": "todo", + "items": [ + { "text": "a", "done": false, "status": "blocked" }, + { "text": "b", "done": true, "status": 7 }, + ] + })) + .unwrap(); + let ToolCall::Todo { items } = call else { + panic!("expected a todo call"); + }; + assert_eq!(items[0].status(), TodoStatus::Pending); + assert_eq!(items[1].status(), TodoStatus::Completed); + } + + #[test] + fn done_outranks_a_stale_status() { + let item = TodoItem { + text: "a".into(), + done: true, + status: Some(TodoStatus::InProgress), + }; + assert_eq!(item.status(), TodoStatus::Completed); + } + + #[test] + fn harness_status_strings_decode() { + assert_eq!(TodoStatus::parse("completed"), TodoStatus::Completed); + assert_eq!(TodoStatus::parse("in_progress"), TodoStatus::InProgress); + assert_eq!(TodoStatus::parse("inProgress"), TodoStatus::InProgress); + assert_eq!(TodoStatus::parse("pending"), TodoStatus::Pending); + assert_eq!(TodoStatus::parse("cancelled"), TodoStatus::Pending); + assert_eq!(TodoStatus::parse(""), TodoStatus::Pending); + } +} diff --git a/crates/proto/src/attachment_mentions.rs b/crates/proto/src/attachment_mentions.rs new file mode 100644 index 0000000000..54eb15398c --- /dev/null +++ b/crates/proto/src/attachment_mentions.rs @@ -0,0 +1,314 @@ +//! Canonical attachment chips shared by the editor, transcript and harness +//! delivery. +//! +//! A composer attachment is referenced from the prompt by a strict local +//! Markdown link carrying only its per-draft number, never a path. Images read +//! `[Image N]`; other files carry their file name. The editor projects either +//! to a chip; providers receive the plain label, which pairs with the upload of +//! the same name. +use std::ops::Range; + +pub const IMAGE_MENTION_SCHEME: &str = "zeron-image:"; +pub const ATTACHMENT_MENTION_SCHEME: &str = "zeron-attachment:"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AttachmentMention { + pub range: Range, + pub index: u32, + /// `Image N` for images, the file name for other attachments. + pub label: String, + pub is_image: bool, +} + +impl AttachmentMention { + /// Whether this chip names the attachment at `path`. Images match by + /// draft number (`Image 2` ↔ `ab12cd34-Image_2.png`), files by name as + /// the engine sanitizes it. + pub fn names_attachment(&self, path: &str) -> bool { + if self.is_image { + return image_index_from_name(path) == Some(self.index); + } + let sanitized = |name: &str| -> String { + name.chars() + .map(|c| { + if c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_') { + c + } else { + '_' + } + }) + .collect() + }; + let name = attachment_display_name(path.rsplit(['/', '\\']).next().unwrap_or(path)); + sanitized(&self.label) == sanitized(name) + } +} + +/// The user-visible name of the `index`th image of a draft. +pub fn image_label(index: u32) -> String { + format!("Image {index}") +} + +/// The chip link for attachment `index`: an image when `file_name` is `None`. +pub fn attachment_mention_link(index: u32, file_name: Option<&str>) -> String { + match file_name { + None => format!("[{}]({IMAGE_MENTION_SCHEME}{index})", image_label(index)), + Some(name) => format!( + "[{}]({ATTACHMENT_MENTION_SCHEME}{index})", + crate::file_mentions::escape_mention_label(name) + ), + } +} + +/// Recover the draft number from an upload or staged image name such as +/// `Image 2.png` / `ab12cd34-Image_2.png` (uploads sanitize spaces). +pub fn image_index_from_name(name: &str) -> Option { + let name = name.rsplit(['/', '\\']).next()?; + let stem = name.rsplit_once('.').map_or(name, |(stem, _)| stem); + let digits_at = stem.trim_end_matches(|c: char| c.is_ascii_digit()).len(); + let (head, digits) = stem.split_at(digits_at); + let head = head.strip_suffix([' ', '_'])?; + if !(head == "Image" || head.ends_with("-Image")) { + return None; + } + digits.parse().ok().filter(|index| *index > 0) +} + +/// Whether an attachment ref names an image, as opposed to any other file. +/// The same extensions the desktop decodes (`format_by_extension`), so every +/// client shows a given attachment the same way. +pub fn is_image_path(path: &str) -> bool { + path.rsplit_once('.').is_some_and(|(_, extension)| { + matches!( + extension.to_ascii_lowercase().as_str(), + "png" | "jpg" | "jpeg" | "gif" | "webp" | "svg" | "bmp" | "tif" | "tiff" + ) + }) +} + +/// The file name to show for an attachment ref: uploads are stored as +/// `{id8}-{name}`, and the prefix is not part of the name. +pub fn attachment_display_name(name: &str) -> &str { + match name.split_once('-') { + Some((id, rest)) if id.len() == 8 && id.bytes().all(|b| b.is_ascii_hexdigit()) => rest, + _ => name, + } +} + +/// Undo [`crate::file_mentions::escape_mention_label`], rejecting any label +/// that is not exactly what escaping produces. +fn unescape_label(raw: &str) -> Option { + let mut out = String::new(); + let mut chars = raw.chars(); + while let Some(c) = chars.next() { + match c { + '\\' => match chars.next()? { + escaped @ ('\\' | '[' | ']' | '`') => out.push(escaped), + _ => return None, + }, + '[' | ']' | '`' => return None, + c if c.is_control() => return None, + c => out.push(c), + } + } + (!out.is_empty()).then_some(out) +} + +/// Only canonical chip links are decoded, never escaped text or code examples. +pub fn attachment_mentions(text: &str) -> Vec { + if !text.contains(IMAGE_MENTION_SCHEME) && !text.contains(ATTACHMENT_MENTION_SCHEME) { + return Vec::new(); + } + let mut image_depth = 0; + pulldown_cmark::Parser::new(text) + .into_offset_iter() + .filter_map(|(event, range)| { + match &event { + pulldown_cmark::Event::Start(pulldown_cmark::Tag::Image { .. }) => { + image_depth += 1; + } + pulldown_cmark::Event::End(pulldown_cmark::TagEnd::Image) => { + image_depth -= 1; + } + _ => {} + } + if image_depth > 0 { + return None; + } + let pulldown_cmark::Event::Start(pulldown_cmark::Tag::Link { dest_url, .. }) = event + else { + return None; + }; + let (scheme, is_image) = if dest_url.starts_with(IMAGE_MENTION_SCHEME) { + (IMAGE_MENTION_SCHEME, true) + } else { + (ATTACHMENT_MENTION_SCHEME, false) + }; + let digits = dest_url.strip_prefix(scheme)?; + if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + let index: u32 = digits.parse().ok().filter(|index| *index > 0)?; + let source = &text[range.clone()]; + let label = if is_image { + (source == attachment_mention_link(index, None)).then(|| image_label(index))? + } else { + let tail = format!("]({scheme}{index})"); + unescape_label(source.strip_prefix('[')?.strip_suffix(&tail)?)? + }; + Some(AttachmentMention { + range, + index, + label, + is_image, + }) + }) + .collect() +} + +/// The attachment numbers a draft mentions, in order of appearance, without +/// repeats. +pub fn attachment_mention_indices(text: &str) -> Vec { + let mut seen = Vec::new(); + for mention in attachment_mentions(text) { + if !seen.contains(&mention.index) { + seen.push(mention.index); + } + } + seen +} + +/// Replace chips with their plain label. Providers read prose, and the label +/// pairs with the attached upload. +pub fn attachment_mention_prompt(text: &str) -> String { + replace_mentions(text, |_| true) +} + +/// Replace chips whose attachment is not attached by plain text, so a dangling +/// reference never leaves the composer as a link. +pub fn demote_unattached_mentions(text: &str, attached: &[u32]) -> String { + replace_mentions(text, |index| !attached.contains(&index)) +} + +fn replace_mentions(text: &str, replace: impl Fn(u32) -> bool) -> String { + let mut out = String::new(); + let mut at = 0; + for mention in attachment_mentions(text) { + if !replace(mention.index) { + continue; + } + out.push_str(&text[at..mention.range.start]); + out.push_str(&mention.label); + at = mention.range.end; + } + out.push_str(&text[at..]); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn image(index: u32) -> String { + attachment_mention_link(index, None) + } + + #[test] + fn links_round_trip_and_become_plain_labels() { + assert_eq!(image(2), "[Image 2](zeron-image:2)"); + let notes = attachment_mention_link(3, Some("my [notes].md")); + assert_eq!(notes, "[my \\[notes\\].md](zeron-attachment:3)"); + let text = format!("compare {} with {notes} and {}", image(2), image(10)); + let mentions = attachment_mentions(&text); + assert_eq!( + mentions + .iter() + .map(|m| (m.index, m.label.as_str(), m.is_image)) + .collect::>(), + vec![ + (2, "Image 2", true), + (3, "my [notes].md", false), + (10, "Image 10", true) + ] + ); + assert_eq!(&text[mentions[1].range.clone()], notes); + assert_eq!( + attachment_mention_prompt(&text), + "compare Image 2 with my [notes].md and Image 10" + ); + assert_eq!( + attachment_mention_indices(&format!("{}{}", image(2), image(2))), + vec![2] + ); + } + + #[test] + fn hostile_and_literal_links_stay_ordinary_text() { + let link = image(1); + let file = attachment_mention_link(1, Some("a.md")); + for literal in [ + format!("`{link}`"), + format!("```\n{link}\n```"), + format!("\\{link}"), + format!("![example {link}](example.png)"), + format!("`{file}`"), + format!("\\{file}"), + "[Image 1](zeron-image:0)".to_string(), + "[Image 1](zeron-image:)".to_string(), + "[Image 1](zeron-image:1x)".to_string(), + "[Image 1](zeron-image:-1)".to_string(), + "[Image 1](zeron-image:../x)".to_string(), + "[Image 2](zeron-image:1)".to_string(), + "[Other](zeron-image:1)".to_string(), + "[Image 1](zeron-image:99999999999)".to_string(), + "[Image 01](zeron-image:01)".to_string(), + "[](zeron-attachment:1)".to_string(), + "[a.md](zeron-attachment:0)".to_string(), + "[a.md](zeron-attachment:1 \"title\")".to_string(), + "[a\nb.md](zeron-attachment:1)".to_string(), + "[a`b.md](zeron-attachment:1)".to_string(), + "[a\\xb.md](zeron-attachment:1)".to_string(), + ] { + assert!(attachment_mentions(&literal).is_empty(), "{literal}"); + assert_eq!(attachment_mention_prompt(&literal), literal); + } + } + + #[test] + fn unattached_mentions_demote_to_labels_only() { + let text = format!( + "{} and {}", + image(1), + attachment_mention_link(2, Some("a.md")) + ); + assert_eq!( + demote_unattached_mentions(&text, &[2]), + format!("Image 1 and {}", attachment_mention_link(2, Some("a.md"))) + ); + } + + #[test] + fn image_names_recover_their_number() { + for (name, index) in [ + ("Image 3.png", Some(3)), + ("ab12cd34-Image_12.png", Some(12)), + ("/uploads/ab-Image_2.webp", Some(2)), + ("Image 0.png", None), + ("image.png", None), + ("Imaged 2.png", None), + ("cat.png", None), + ] { + assert_eq!(image_index_from_name(name), index, "{name}"); + } + } + + #[test] + fn attachment_refs_split_images_from_files() { + assert!(is_image_path("/uploads/ab12cd34-shot.PNG")); + assert!(is_image_path("pending://u1/Image_1.jpeg")); + assert!(!is_image_path("/uploads/ab12cd34-notes.zip")); + assert!(!is_image_path("/uploads/png")); + assert_eq!(attachment_display_name("ab12cd34-notes.zip"), "notes.zip"); + assert_eq!(attachment_display_name("my-notes.zip"), "my-notes.zip"); + } +} diff --git a/crates/proto/src/entities.rs b/crates/proto/src/entities.rs index b2b3b7cfe3..3947465ea6 100644 --- a/crates/proto/src/entities.rs +++ b/crates/proto/src/entities.rs @@ -128,6 +128,14 @@ pub struct Space { /// (sha256(deviceId ‖ NUL ‖ git_dir)) — diff grouping key for root sessions. #[serde(default, skip_serializing_if = "Option::is_none")] pub checkout_id: Option, + /// Owner-stamped when git: identity shared by every clone and worktree of + /// one repository — its trunk's root commit (`commit:`, which + /// survives renames, transfers and remote spellings), else for a shallow + /// or empty history the normalized origin remote (`host/owner/repo`), + /// else `local:` + a device-scoped hash of the common git dir. Opaque to + /// readers: projects with equal ids group together. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub repository_id: Option, pub created_at: DateTime, } @@ -235,6 +243,12 @@ pub struct Chat { } impl Chat { + /// A session the user sees in chat lists: neither another chat's worker + /// (`parent_chat_id`) nor a hidden voice orchestrator. + pub fn is_top_level(&self) -> bool { + self.parent_chat_id.is_none() && !crate::voice::is_orchestrator_chat(&self.id) + } + /// True when this chat syncs over the chat2 dumb relay. pub fn on_chat2(&self) -> bool { self.room_gen.unwrap_or(1) >= 2 @@ -302,6 +316,11 @@ pub struct Session { pub status: SessionStatus, pub started_at: Option>, pub updated_at: DateTime, + /// Subagents of this chat streaming right now. Rides the session row (and + /// its staleness window) so every device's sidebar can badge a chat it + /// has not opened; read it through `view::running_subagents`. + #[serde(default)] + pub running_subagents: u32, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -480,6 +499,10 @@ pub struct ListWorkspaceDirectoryRequest { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct WorkspaceDirectoryPage { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub checkout_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mutation_capabilities: Option, pub directory: String, pub entries: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -490,6 +513,8 @@ pub struct WorkspaceDirectoryPage { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct WorkspaceEntry { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mutation_revision: Option, pub path: String, pub name: String, pub kind: WorkspaceEntryKind, @@ -700,6 +725,8 @@ pub struct WorkspaceFileChanges { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct WorkspaceFileChange { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub operation_id: Option, pub kind: WorkspaceFileChangeKind, pub path: String, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -1399,6 +1426,7 @@ mod tests { sequence: 4, resync_required: false, changes: vec![WorkspaceFileChange { + operation_id: None, kind: WorkspaceFileChangeKind::Renamed, path: "src/new.rs".into(), old_path: Some("src/old.rs".into()), @@ -1414,3 +1442,126 @@ mod tests { ); } } + +/// Host capabilities; absent fields keep older peers read-only for mutations. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WorkspaceMutationCapabilities { + #[serde(default)] + pub move_entry: bool, + #[serde(default)] + pub delete_entry: bool, +} + +/// Rename and move share the same no-replacement operation. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MoveWorkspaceEntryRequest { + #[serde(flatten)] + pub target: WorkspaceTarget, + pub operation_id: String, + pub expected_checkout_id: String, + pub source_path: String, + pub destination_path: String, + pub expected_source_revision: String, + pub expected_kind: WorkspaceEntryKind, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DeleteWorkspaceEntryRequest { + #[serde(flatten)] + pub target: WorkspaceTarget, + pub operation_id: String, + pub expected_checkout_id: String, + pub path: String, + pub expected_source_revision: String, + pub expected_kind: WorkspaceEntryKind, + /// Explicit consent to delete the directory's current contents. + pub recursive: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "status", rename_all = "camelCase")] +pub enum WorkspaceMutationOutcome { + #[serde(rename_all = "camelCase")] + Applied { + operation_id: String, + checkout_id: String, + change: WorkspaceFileChange, + #[serde(default, skip_serializing_if = "Option::is_none")] + entry: Option, + }, + #[serde(rename_all = "camelCase")] + Rejected { + operation_id: String, + reason: WorkspaceMutationRejection, + message: String, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum WorkspaceMutationRejection { + InvalidPath, + WorkspaceChanged, + SourceMissing, + SourceChanged, + DestinationExists, + InvalidDestination, + PermissionDenied, + Unsupported, + Busy, + PartialFailure, +} + +#[cfg(test)] +mod mutation_contract_tests { + use super::*; + #[test] + fn old_peers_remain_readable_without_mutation_support() { + let page: WorkspaceDirectoryPage = serde_json::from_value(serde_json::json!({ + "directory":"", "entries":[{"path":"a", "name":"a", "kind":"file", "ignored":false, "readOnly":false}], + "truncated":false + })).unwrap(); + assert_eq!(page.checkout_id, None); + assert_eq!(page.mutation_capabilities, None); + assert_eq!(page.entries[0].mutation_revision, None); + let change: WorkspaceFileChange = + serde_json::from_value(serde_json::json!({"kind":"removed", "path":"a"})).unwrap(); + assert_eq!(change.operation_id, None); + } + #[test] + fn mutation_requests_and_outcomes_round_trip() { + let request = serde_json::json!({"chatId":"chat", "operationId":"op", "expectedCheckoutId":"checkout", "sourcePath":"a", "destinationPath":"b", "expectedSourceRevision":"rev", "expectedKind":"file"}); + let decoded: MoveWorkspaceEntryRequest = serde_json::from_value(request.clone()).unwrap(); + let encoded = serde_json::to_value(decoded).unwrap(); + for (key, value) in request.as_object().unwrap() { + assert_eq!(&encoded[key], value); + } + let delete: DeleteWorkspaceEntryRequest = serde_json::from_value(serde_json::json!({"chatId":"chat", "operationId":"op", "expectedCheckoutId":"checkout", "path":"a", "expectedSourceRevision":"rev", "expectedKind":"directory", "recursive":true})).unwrap(); + assert_eq!( + serde_json::from_value::( + serde_json::to_value(&delete).unwrap() + ) + .unwrap(), + delete + ); + let outcome = WorkspaceMutationOutcome::Rejected { + operation_id: "op".into(), + reason: WorkspaceMutationRejection::DestinationExists, + message: "Destination exists".into(), + }; + assert_eq!( + serde_json::from_value::( + serde_json::to_value(&outcome).unwrap() + ) + .unwrap(), + outcome + ); + assert!( + serde_json::from_value::(serde_json::json!("overwrite")) + .is_err() + ); + } +} diff --git a/crates/proto/src/file_mentions.rs b/crates/proto/src/file_mentions.rs index 93d916e576..990cdf4712 100644 --- a/crates/proto/src/file_mentions.rs +++ b/crates/proto/src/file_mentions.rs @@ -39,7 +39,7 @@ fn percent_decode_path(encoded: &str) -> Option { String::from_utf8(bytes).ok() } -fn escape_mention_label(label: &str) -> String { +pub(crate) fn escape_mention_label(label: &str) -> String { label .replace('\\', "\\\\") .replace('[', "\\[") diff --git a/crates/proto/src/invocation.rs b/crates/proto/src/invocation.rs index 54c614cf5d..c33c800d32 100644 --- a/crates/proto/src/invocation.rs +++ b/crates/proto/src/invocation.rs @@ -207,7 +207,9 @@ pub fn validate_harness_invocations(text: &str, harness: crate::HarnessId) -> Re /// Keep selected skill identity intact until Codex builds native input blocks. /// Other providers receive readable Markdown and their advertised command text. pub fn harness_prompt(text: &str, harness: crate::HarnessId) -> String { - let text = crate::file_mentions::file_mention_prompt(text); + let text = crate::file_mentions::file_mention_prompt( + &crate::attachment_mentions::attachment_mention_prompt(text), + ); if harness == crate::HarnessId::Codex { return text; } @@ -250,6 +252,7 @@ pub fn harness_prompt(text: &str, harness: crate::HarnessId) -> String { } pub fn invocation_prompt(text: &str) -> String { + let text = &crate::attachment_mentions::attachment_mention_prompt(text); let mut result = String::new(); let mut at = 0; for (range, invocation) in invocation_links(text) { @@ -302,6 +305,23 @@ mod tests { crate::HarnessId::Opencode, ]; + #[test] + fn image_chips_reach_every_provider_as_their_plain_label() { + let raw = format!( + "compare {} with {}", + crate::attachment_mentions::attachment_mention_link(1, None), + crate::attachment_mentions::attachment_mention_link(3, None) + ); + assert_eq!(invocation_prompt(&raw), "compare Image 1 with Image 3"); + for harness in HARNESSES { + assert_eq!( + harness_prompt(&raw, harness), + "compare Image 1 with Image 3", + "{harness:?}" + ); + } + } + #[test] fn leading_commands_respect_markdown_indentation() { for text in [ diff --git a/crates/proto/src/lib.rs b/crates/proto/src/lib.rs index 92729dec72..f1e02af319 100644 --- a/crates/proto/src/lib.rs +++ b/crates/proto/src/lib.rs @@ -4,6 +4,7 @@ //! Context occupancy is replicated per chat; billing `Usage` remains a harness passthrough. pub mod agent; +pub mod attachment_mentions; pub mod entities; pub mod file_mentions; pub mod invocation; @@ -11,6 +12,7 @@ pub mod motion; pub mod preview; pub mod sidebar_pins; pub mod view; +pub mod voice; pub mod workspace; pub use agent::*; diff --git a/crates/proto/src/view.rs b/crates/proto/src/view.rs index acb9bb45a2..d244311844 100644 --- a/crates/proto/src/view.rs +++ b/crates/proto/src/view.rs @@ -63,6 +63,15 @@ pub fn effective_indicator(session: Option<&Session>, now: DateTime) -> Ind } } +/// Subagents running under a chat, staleness-checked like +/// [`effective_indicator`]: a session row nobody has refreshed inside +/// [`SESSION_STALE_MS`] belongs to a dead engine and counts nothing. Pure. +pub fn running_subagents(session: Option<&Session>, now: DateTime) -> u32 { + session + .filter(|s| now.signed_duration_since(s.updated_at).num_milliseconds() <= SESSION_STALE_MS) + .map_or(0, |s| s.running_subagents) +} + /// The full display status for a chat row / tab dot: live states win, then the /// synced seen marker decides completed-vs-idle. Staleness gating rides on /// [`effective_indicator`]; the derivation itself is [`crate::chat_indicator`]. @@ -122,6 +131,30 @@ pub fn sort_spaces(spaces: &mut [Space]) { }); } +/// The project a space belongs to: spaces sharing a repository identity — +/// clones and worktrees of one repository, on any device — are one project. +/// A space without an identity is a project of its own. Pure. +pub fn project_key(space: &Space) -> String { + match space.repository_id.as_deref() { + Some(repository_id) => format!("repo:{repository_id}"), + None => space.id.clone(), + } +} + +/// The space that speaks for `space`'s whole project: the oldest member, id +/// tiebreak. Its name and color stand for every member, and the choice is +/// the same on every device, so a repository looks alike everywhere. Pure. +pub fn representative_space<'a>(spaces: &'a [Space], space: &'a Space) -> &'a Space { + let Some(repository_id) = space.repository_id.as_deref() else { + return space; + }; + spaces + .iter() + .filter(|s| s.repository_id.as_deref() == Some(repository_id)) + .min_by_key(|s| (s.created_at, s.id.as_str())) + .unwrap_or(space) +} + /// Sidebar order: `last_message_at` desc, falling back to `created_at`; ties /// break by `created_at` desc then id so the sort is total and stable across /// devices. Pure. @@ -176,6 +209,40 @@ pub fn gate_phase( } } +#[cfg(test)] +mod project_tests { + use super::*; + + fn space(id: &str, device: &str, repository: Option<&str>, minutes: i64) -> Space { + Space { + id: id.into(), + device_id: device.into(), + path: format!("/{device}/{id}"), + name: None, + git_detected: repository.is_some(), + git_checked_at: None, + checkout_id: None, + repository_id: repository.map(str::to_string), + created_at: DateTime::::UNIX_EPOCH + chrono::TimeDelta::minutes(minutes), + } + } + + #[test] + fn spaces_sharing_a_repository_are_one_project_led_by_the_oldest() { + let spaces = vec![ + space("laptop", "mac", Some("github.com/o/r"), 2), + space("server", "vps", Some("github.com/o/r"), 1), + space("notes", "mac", None, 0), + ]; + assert_eq!(project_key(&spaces[0]), "repo:github.com/o/r"); + assert_eq!(project_key(&spaces[0]), project_key(&spaces[1])); + assert_eq!(project_key(&spaces[2]), "notes"); + assert_eq!(representative_space(&spaces, &spaces[0]).id, "server"); + assert_eq!(representative_space(&spaces, &spaces[1]).id, "server"); + assert_eq!(representative_space(&spaces, &spaces[2]).id, "notes"); + } +} + #[cfg(test)] mod gate_tests { use super::*; @@ -189,6 +256,32 @@ mod gate_tests { } } + fn running_session(running: u32, updated_at: DateTime) -> Session { + Session { + last_completed_turn: None, + chat_id: "chat".into(), + device_id: "dev".into(), + status: SessionStatus::Working, + started_at: None, + updated_at, + running_subagents: running, + } + } + + #[test] + fn running_subagents_count_only_while_the_session_row_is_fresh() { + let now = Utc::now(); + let fresh = running_session(3, now - chrono::Duration::seconds(10)); + assert_eq!(running_subagents(Some(&fresh), now), 3); + // A crashed engine's row must not badge a chat forever. + let stale = running_session( + 3, + now - chrono::Duration::milliseconds(SESSION_STALE_MS + 1), + ); + assert_eq!(running_subagents(Some(&stale), now), 0); + assert_eq!(running_subagents(None, now), 0); + } + #[test] fn workspace_scope_controls_the_auth_gate() { assert_eq!( diff --git a/crates/proto/src/voice.rs b/crates/proto/src/voice.rs new file mode 100644 index 0000000000..8fac73f6ea --- /dev/null +++ b/crates/proto/src/voice.rs @@ -0,0 +1,260 @@ +//! Ephemeral local voice contracts. These must never enter the document journal. +use serde::{Deserialize, Serialize}; +pub mod remote; + +pub const MAX_AUDIO_BYTES: usize = 16_384; +pub const MEDIA_QUEUE_FRAMES: usize = 8; +pub const MAX_TRANSCRIPT_BYTES: usize = 32_768; + +/// Id prefix of the hidden chat that hosts a voice orchestrator session. It +/// never takes a sidebar row, and chats it creates are top-level sessions +/// rather than its side chats. +pub const ORCHESTRATOR_CHAT_PREFIX: &str = "voice-orchestrator-"; +/// Title of every orchestrator chat; transcripts never trigger a titler. +pub const ORCHESTRATOR_CHAT_TITLE: &str = "Voice session"; +/// Codex voice styles offered before a host reports its own list. +pub const DEFAULT_VOICES: &[&str] = &[ + "juniper", "maple", "spruce", "ember", "vale", "breeze", "arbor", "sol", "cove", +]; + +pub fn is_orchestrator_chat(chat_id: &str) -> bool { + chat_id.starts_with(ORCHESTRATOR_CHAT_PREFIX) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum VoiceRejection { + Disabled, + Unsupported, + RemoteHost, + WrongHarness, + ChatgptRequired, + IncludedUsageUnavailable, + CreditExclusionUnverified, + AudioFormatUnverified, + DuplexUnverified, + Busy, + InvalidLease, + StaleGeneration, + Overflow, + DeviceUnavailable, + MicrophonePermissionDenied, + MicrophoneMetadataMissing, + Protocol, + NativeRuntimeUnavailable, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct VoiceEligibility { + pub available: bool, + #[serde(default)] + pub reason: Option, + /// Provider-reported permission; None is unknown, never permission. + #[serde(default)] + pub ordinary_usage_allowed: Option, + pub credits_excluded: bool, + #[serde(default)] + pub format: Option, + #[serde(default)] + pub duplex_verified: bool, + /// Native subscription WebRTC transport; PCM never leaves the voice helper. + #[serde(default)] + pub native_webrtc: bool, + #[serde(default)] + pub voices: Vec, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum VoicePhase { + #[default] + Closed, + Checking, + Starting, + Active, + Stopping, + Failed, +} +impl VoicePhase { + pub fn replaces_composer(self) -> bool { + matches!(self, Self::Starting | Self::Active | Self::Stopping) + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum VoiceWork { + #[default] + Idle, + Working, + AwaitingInput, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct VoiceSnapshot { + pub session_id: String, + pub chat_id: String, + pub generation: u64, + pub phase: VoicePhase, + pub muted: bool, + pub playing: bool, + pub work: VoiceWork, + #[serde(default)] + pub reason: Option, + #[serde(default)] + pub voice: Option, + #[serde(default)] + pub voices: Vec, +} + +/// Debug deliberately omits the bearer secret. Not a durable preference. +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct VoiceLease { + pub session_id: String, + pub generation: u64, + pub token: String, +} +impl std::fmt::Debug for VoiceLease { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("VoiceLease") + .field("session_id", &self.session_id) + .field("generation", &self.generation) + .finish_non_exhaustive() + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum VoiceEncoding { + Pcm16Le, +} +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct VoiceFormat { + pub encoding: VoiceEncoding, + pub sample_rate: u32, + pub channels: u16, +} +impl VoiceFormat { + pub fn validate(&self, bytes: usize) -> bool { + self.sample_rate == 24_000 + && self.channels == 1 + && bytes > 0 + && bytes <= MAX_AUDIO_BYTES + && bytes.is_multiple_of(2) + } +} + +/// Base64 PCM. No Debug implementation: audio must never be logged implicitly. +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct VoiceFrame { + pub generation: u64, + pub sequence: u64, + pub format: VoiceFormat, + pub data: String, + #[serde(default)] + pub item_id: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum VoiceRole { + User, + Assistant, +} +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct VoiceTranscript { + pub session_id: String, + pub item_id: String, + pub role: VoiceRole, + pub text: String, + #[serde(default)] + pub promoted_message_id: Option, +} + +#[derive(Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum VoiceEvent { + Snapshot { + snapshot: VoiceSnapshot, + }, + Audio { + frame: VoiceFrame, + }, + Partial { + generation: u64, + #[serde(default)] + item_id: Option, + text: String, + }, + Final { + transcript: VoiceTranscript, + }, + InvalidatePlayout { + generation: u64, + item_id: String, + }, + Levels { + generation: u64, + microphone: u16, + speaker: u16, + }, + Closed { + generation: u64, + reason: Option, + }, +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn unknown_permission_is_not_authorization_and_secrets_are_not_debugged() { + let old = r#"{"available":false,"creditsExcluded":false}"#; + let eligibility: VoiceEligibility = serde_json::from_str(old).unwrap(); + assert_eq!(eligibility.ordinary_usage_allowed, None); + let lease = VoiceLease { + session_id: "id".into(), + generation: 1, + token: "SECRET".into(), + }; + assert!(!format!("{lease:?}").contains("SECRET")); + assert!(serde_json::from_str::("\"unknown\"").is_err()); + for phase in [ + VoicePhase::Starting, + VoicePhase::Active, + VoicePhase::Stopping, + ] { + assert!(phase.replaces_composer()); + } + assert!(!VoicePhase::Checking.replaces_composer()); + } + + #[test] + fn orchestrator_chats_are_never_top_level_sessions() { + let mut chat: crate::Chat = serde_json::from_value(serde_json::json!({ + "id": format!("{ORCHESTRATOR_CHAT_PREFIX}1"), + "deviceId": "device", + "title": null, + "archived": false, + "cwd": null, + "branch": null, + "checkoutId": null, + "config": null, + "lastMessagePreview": null, + "lastMessageAt": null, + "createdAt": "2026-10-01T00:00:00Z", + })) + .unwrap(); + assert!(!chat.is_top_level()); + chat.id = "ordinary".into(); + assert!(chat.is_top_level()); + chat.parent_chat_id = Some("parent".into()); + assert!(!chat.is_top_level()); + } +} diff --git a/crates/proto/src/voice/remote.rs b/crates/proto/src/voice/remote.rs new file mode 100644 index 0000000000..9a9a675f3a --- /dev/null +++ b/crates/proto/src/voice/remote.rs @@ -0,0 +1,188 @@ +//! Ephemeral client-media control. Never persist these payloads in sync/queues. +use super::{VoiceLease, VoiceRejection}; +use serde::{Deserialize, Deserializer, Serialize}; +use std::fmt; + +pub const CAPABILITY: &str = "voice-client-media-v1"; +pub const MAX_SDP_BYTES: usize = 64 * 1024; +pub const HEARTBEAT_SECS: u64 = 5; +pub const LEASE_SECS: u64 = 15; +pub const ATTEMPT_TTL_SECS: u64 = 300; +pub const MAX_ATTEMPTS: usize = 256; + +/// Random capability, not a timestamp or a user-chosen label. +#[derive(Clone, PartialEq, Eq, Hash, Serialize)] +#[serde(transparent)] +pub struct AttemptKey(String); +impl AttemptKey { + pub fn new() -> Self { + Self(uuid::Uuid::new_v4().to_string()) + } + pub fn expose(&self) -> &str { + &self.0 + } +} +impl Default for AttemptKey { + fn default() -> Self { + Self::new() + } +} +impl fmt::Debug for AttemptKey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("AttemptKey([redacted])") + } +} +impl<'de> Deserialize<'de> for AttemptKey { + fn deserialize>(d: D) -> Result { + let s = String::deserialize(d)?; + let id = uuid::Uuid::parse_str(&s) + .map_err(|_| serde::de::Error::custom("invalid attempt key"))?; + if id.get_version_num() != 4 { + return Err(serde::de::Error::custom( + "attempt key must be random UUID v4", + )); + } + Ok(Self(id.to_string())) + } +} + +#[derive(Clone, PartialEq, Eq, Serialize)] +#[serde(transparent)] +pub struct Sdp(String); +impl Sdp { + pub fn new(value: String) -> Result { + if value.is_empty() || value.len() > MAX_SDP_BYTES { + return Err(VoiceRejection::Protocol); + } + Ok(Self(value)) + } + pub fn expose(&self) -> &str { + &self.0 + } +} +impl fmt::Debug for Sdp { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("Sdp([redacted])") + } +} +impl<'de> Deserialize<'de> for Sdp { + fn deserialize>(d: D) -> Result { + Self::new(String::deserialize(d)?) + .map_err(|_| serde::de::Error::custom("invalid SDP length")) + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Envelope { + pub target_device_id: String, + pub payload: T, +} +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Lease { + pub host_device_id: String, + pub voice: VoiceLease, +} +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Capabilities { + pub protocol: String, + pub client_webrtc: bool, + pub voices: Vec, +} +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Prepare { + pub attempt_key: AttemptKey, + pub config: crate::ChatConfig, + #[serde(default)] + pub voice: Option, +} +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Prepared { + pub lease: Lease, + pub chat_id: String, + pub voices: Vec, + pub heartbeat_seconds: u64, + pub lease_seconds: u64, +} +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Negotiate { + pub lease: Lease, + pub negotiation_id: AttemptKey, + pub offer: Sdp, +} +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Negotiated { + pub negotiation_id: AttemptKey, + pub answer: Sdp, +} +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Confirm { + pub lease: Lease, + pub negotiation_id: AttemptKey, + pub muted: bool, +} +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum MediaState { + Ready, + Failed, + Closed, +} +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Report { + pub lease: Lease, + pub sequence: u64, + pub muted: bool, + pub state: MediaState, +} +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Ack { + pub sequence: u64, +} +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Cancel { + pub attempt_key: AttemptKey, +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn bounded_redacted_signaling_and_attempts() { + assert!(Sdp::new("é".repeat(MAX_SDP_BYTES / 2 + 1)).is_err()); + assert!(serde_json::from_str::("\"\"").is_err()); + let s = Sdp::new("SECRET SDP".into()).unwrap(); + assert!(!format!("{s:?}").contains("SECRET")); + let key = AttemptKey::new(); + assert!(!format!("{key:?}").contains(key.expose())); + assert_eq!( + serde_json::from_str::(&serde_json::to_string(&key).unwrap()).unwrap(), + key + ); + assert!(serde_json::from_str::("\"predictable\"").is_err()); + assert!( + serde_json::from_str::(&format!( + r#"{{"attemptKey":"{}","extra":1}}"#, + key.expose() + )) + .is_err() + ); + } + #[test] + fn routed_payload_stays_strict() { + let value = + serde_json::json!({"targetDeviceId":"host","payload":{"attemptKey":AttemptKey::new()}}); + assert!(serde_json::from_value::>(value.clone()).is_ok()); + assert!(serde_json::from_value::(value).is_err()); + } +} diff --git a/crates/proto/src/workspace.rs b/crates/proto/src/workspace.rs index 6453abb741..c407888d1d 100644 --- a/crates/proto/src/workspace.rs +++ b/crates/proto/src/workspace.rs @@ -24,6 +24,7 @@ pub mod capabilities { MESSAGE_QUEUE_CLEAN_ATTACHMENT_TEXT_V1, MESSAGE_QUEUE_EDIT_LEASE_V1, HARNESS_UPDATES_V1, + crate::voice::remote::CAPABILITY, ]; pub fn current() -> Vec { @@ -103,7 +104,8 @@ mod tests { "message-queue-attachments-v1", "message-queue-clean-attachment-text-v1", "message-queue-edit-lease-v1", - "harness-updates-v1" + "harness-updates-v1", + "voice-client-media-v1" ], }) ); diff --git a/crates/rpc/src/lib.rs b/crates/rpc/src/lib.rs index c32758ab0f..ec3bacfa63 100644 --- a/crates/rpc/src/lib.rs +++ b/crates/rpc/src/lib.rs @@ -33,6 +33,14 @@ pub use server::{serve_connection, serve_ws_listener}; /// RPC method names — single source of truth for both ends. /// Full surface: docs/research/feature-inventory.md §2. pub mod methods { + pub const VOICE_CAPABILITIES_V2: &str = "VoiceCapabilitiesV2"; + pub const PREPARE_VOICE_V2: &str = "PrepareVoiceV2"; + pub const OWN_VOICE_V2: &str = "OwnVoiceV2"; + pub const NEGOTIATE_VOICE_V2: &str = "NegotiateVoiceV2"; + pub const CONFIRM_VOICE_MEDIA_V2: &str = "ConfirmVoiceMediaV2"; + pub const REPORT_VOICE_MEDIA_V2: &str = "ReportVoiceMediaV2"; + pub const STOP_VOICE_V2: &str = "StopVoiceV2"; + pub const CANCEL_VOICE_ATTEMPT_V2: &str = "CancelVoiceAttemptV2"; pub const WATCH_PREVIEWS: &str = "WatchPreviews"; pub const LIST_HARNESSES: &str = "ListHarnesses"; pub const CANCEL_INSTALL: &str = "CancelInstall"; @@ -163,6 +171,8 @@ pub mod methods { pub const SEARCH_WORKSPACE_FILES: &str = "SearchWorkspaceFiles"; pub const READ_WORKSPACE_IMAGE: &str = "ReadWorkspaceImage"; pub const READ_WORKSPACE_FILE: &str = "ReadWorkspaceFile"; + pub const MOVE_WORKSPACE_ENTRY: &str = "MoveWorkspaceEntry"; + pub const DELETE_WORKSPACE_ENTRY: &str = "DeleteWorkspaceEntry"; pub const WRITE_WORKSPACE_FILE: &str = "WriteWorkspaceFile"; pub const WATCH_WORKSPACE_FILES: &str = "WatchWorkspaceFiles"; pub const CREATE_WORKTREE: &str = "CreateWorktree"; diff --git a/crates/sync/src/store.rs b/crates/sync/src/store.rs index 83ee6ebea1..d5ff71b1f1 100644 --- a/crates/sync/src/store.rs +++ b/crates/sync/src/store.rs @@ -5,7 +5,7 @@ use std::collections::HashSet; use std::path::Path; use std::sync::{Mutex, MutexGuard, PoisonError}; -use std::time::{SystemTime, UNIX_EPOCH}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use rusqlite::{Connection, OptionalExtension, params}; @@ -76,6 +76,26 @@ const MIGRATIONS: &[&str] = &[ "ALTER TABLE chat_sync_jobs ADD COLUMN cursor TEXT NOT NULL DEFAULT '';", ]; +const STORE_BUSY_TIMEOUT: Duration = Duration::from_secs(5); + +/// WAL size retained after a checkpoint resets the log. SQLite's default (-1) +/// keeps the file at its all-time high-water mark (observed: 400 MB). Steady +/// state never reaches this — auto-checkpoint folds the log every ~4 MB +/// (1000 pages) — so the cap only bites after a large burst (a multi-MB +/// snapshot, a VACUUM). 64 MiB bounds idle disk use while sparing ordinary +/// bursts a truncate/re-extend cycle on every write. +const JOURNAL_SIZE_LIMIT_BYTES: i64 = 64 * 1024 * 1024; + +/// VACUUM ([`DocsStore::reclaim_free_space`]) only when free pages are BOTH a large share of the file +/// and a meaningful absolute size: a healthy database never pays the rewrite. +const VACUUM_MIN_FREE_BYTES: i64 = 32 * 1024 * 1024; +/// `freelist_count / page_count` above which reclaiming is worth a rewrite. +const VACUUM_MIN_FREE_DENOMINATOR: i64 = 4; + +/// Maintenance is skipped (and logged) rather than waited on when another +/// connection holds a lock. +const MAINTENANCE_BUSY_TIMEOUT: Duration = Duration::from_millis(250); + /// SQLite-backed store under a data directory (`{data_dir}/docs.sqlite3`). /// /// Holds warm-open doc snapshots (the DO room is authoritative; these make @@ -93,11 +113,20 @@ impl DocsStore { pub fn open(data_dir: impl AsRef) -> Result { let data_dir = data_dir.as_ref(); std::fs::create_dir_all(data_dir)?; - let mut conn = Connection::open(data_dir.join("docs.sqlite3"))?; + let path = data_dir.join("docs.sqlite3"); + let mut conn = Connection::open(&path)?; conn.pragma_update(None, "journal_mode", "WAL")?; conn.pragma_update(None, "synchronous", "NORMAL")?; - conn.busy_timeout(std::time::Duration::from_secs(5))?; + conn.pragma_update(None, "journal_size_limit", JOURNAL_SIZE_LIMIT_BYTES)?; + conn.busy_timeout(STORE_BUSY_TIMEOUT)?; migrate(&mut conn)?; + // Best-effort hygiene must never stall boot behind another connection. + conn.busy_timeout(MAINTENANCE_BUSY_TIMEOUT)?; + // Only the cheap WAL truncate here: open sits on boot paths (engine + // assembly, the iOS client), and a VACUUM's cost is linear in the + // live size. Callers run `reclaim_free_space` off those paths. + checkpoint_truncate(&conn, &path, "open"); + conn.busy_timeout(STORE_BUSY_TIMEOUT)?; Ok(Self { conn: Mutex::new(conn), failed_publications: Mutex::new(HashSet::new()), @@ -562,6 +591,28 @@ impl DocsStore { Ok(()) } + /// Reclaim free space: WAL truncate, then a VACUUM only past the + /// free-page thresholds, under a short busy timeout. Cheap when there is + /// nothing to reclaim. Blocking and holds the store for the VACUUM (it + /// rewrites the live database) — run it off any latency-sensitive path; + /// best-effort — problems are logged, never returned. + pub fn reclaim_free_space(&self) { + store_blocking(|| { + let conn = self.conn(); + let Some(path) = conn.path().map(std::path::PathBuf::from) else { + return; + }; + if let Err(err) = conn.busy_timeout(MAINTENANCE_BUSY_TIMEOUT) { + tracing::warn!(%err, "docs store: reclaim skipped"); + return; + } + maintain(&conn, &path); + if let Err(err) = conn.busy_timeout(STORE_BUSY_TIMEOUT) { + tracing::warn!(%err, "docs store: busy timeout not restored after reclaim"); + } + }); + } + pub(crate) fn conn(&self) -> MutexGuard<'_, Connection> { // A poisoned lock only means another thread panicked mid-query; the // connection itself is still usable. @@ -597,6 +648,81 @@ fn migrate(conn: &mut Connection) -> Result<(), StoreError> { Ok(()) } +/// Fold and truncate a bloated WAL, then reclaim free pages if they dominate +/// the file. Every step is best-effort — failures (including SQLITE_BUSY from +/// another connection) are logged. Steady-state WAL growth is handled by +/// SQLite's auto-checkpoint plus `journal_size_limit`, so there is no +/// periodic pass. +fn maintain(conn: &Connection, path: &Path) { + checkpoint_truncate(conn, path, "open"); + match reclaim_free_pages(conn) { + // VACUUM in WAL mode writes the whole live database into the WAL. + Ok(true) => checkpoint_truncate(conn, path, "vacuum"), + Ok(false) => {} + Err(err) => tracing::warn!(%err, "docs store: VACUUM skipped"), + } +} + +/// `PRAGMA wal_checkpoint(TRUNCATE)`; logs instead of failing. +fn checkpoint_truncate(conn: &Connection, path: &Path, reason: &str) { + let wal_path = path.with_extension("sqlite3-wal"); + let wal_bytes = || std::fs::metadata(&wal_path).map_or(0, |m| m.len()); + let before = wal_bytes(); + let result = conn.query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |row| { + Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)) + }); + match result { + Ok((0, frames)) => { + if before > 0 { + tracing::info!( + reason, + frames, + wal_bytes_before = before, + wal_bytes_after = wal_bytes(), + "docs store: WAL checkpointed and truncated" + ); + } + } + Ok((_, frames)) => tracing::warn!( + reason, + frames, + wal_bytes = before, + "docs store: WAL checkpoint busy (another connection is active); left for later" + ), + Err(err) => tracing::warn!(reason, %err, "docs store: WAL checkpoint failed"), + } +} + +/// VACUUM when free pages are both a large fraction of the file and above an +/// absolute floor. Returns whether a VACUUM ran. Rewrite cost is linear in the +/// LIVE size (~0.6 s for 150 MB live / 70 MB free on an M-series SSD, warm +/// cache); healthy databases skip it entirely. +fn reclaim_free_pages(conn: &Connection) -> rusqlite::Result { + let pragma = |name: &str| conn.pragma_query_value(None, name, |row| row.get::<_, i64>(0)); + let page_size = pragma("page_size")?; + let page_count = pragma("page_count")?; + let freelist = pragma("freelist_count")?; + if !worth_vacuuming(page_size, page_count, freelist) { + return Ok(false); + } + let started = Instant::now(); + conn.execute_batch("VACUUM")?; + let after = pragma("page_count")? * page_size; + tracing::info!( + bytes_before = page_count * page_size, + bytes_after = after, + free_bytes = freelist * page_size, + elapsed_ms = started.elapsed().as_millis() as u64, + "docs store: reclaimed free pages with VACUUM" + ); + Ok(true) +} + +fn worth_vacuuming(page_size: i64, page_count: i64, freelist: i64) -> bool { + freelist * page_size >= VACUUM_MIN_FREE_BYTES + && freelist * VACUUM_MIN_FREE_DENOMINATOR >= page_count +} + fn now_ms() -> i64 { SystemTime::now() .duration_since(UNIX_EPOCH) @@ -847,3 +973,176 @@ mod publication_failure_tests { } } } + +#[cfg(test)] +mod maintenance_tests { + use super::*; + + const MIB: usize = 1024 * 1024; + + fn pragma(conn: &Connection, name: &str) -> i64 { + conn.pragma_query_value(None, name, |row| row.get(0)) + .unwrap() + } + + fn file_len(path: &Path) -> u64 { + std::fs::metadata(path).map_or(0, |m| m.len()) + } + + /// 48 MiB of snapshots, 40 MiB of them deleted: well past both thresholds. + fn fragmented_store(dir: &Path) { + let store = DocsStore::open(dir).unwrap(); + for i in 0..48 { + store + .save_snapshot(&format!("d{i}"), &vec![7; MIB]) + .unwrap(); + } + for i in 0..40 { + store.delete_snapshot(&format!("d{i}")).unwrap(); + } + let conn = store.conn(); + assert!(worth_vacuuming( + pragma(&conn, "page_size"), + pragma(&conn, "page_count"), + pragma(&conn, "freelist_count") + )); + } + + #[test] + fn open_folds_and_truncates_a_bloated_wal() { + let dir = tempfile::tempdir().unwrap(); + drop(DocsStore::open(dir.path()).unwrap()); + let wal = dir.path().join("docs.sqlite3-wal"); + // A live connection keeps the WAL from being cleaned up on close. + let other = Connection::open(dir.path().join("docs.sqlite3")).unwrap(); + other.pragma_update(None, "wal_autocheckpoint", 0).unwrap(); + for i in 0..8 { + other + .execute( + "INSERT INTO snapshots(doc_id,bytes,saved_at) VALUES (?1,zeroblob(?2),0)", + params![format!("d{i}"), MIB as i64], + ) + .unwrap(); + } + assert!(file_len(&wal) > 8 * MIB as u64); + + let store = DocsStore::open(dir.path()).unwrap(); + assert_eq!(file_len(&wal), 0, "boot checkpoint truncates the WAL"); + assert!(store.has_snapshot("d7").unwrap(), "frames folded, not lost"); + assert_eq!( + pragma(&store.conn(), "journal_size_limit"), + JOURNAL_SIZE_LIMIT_BYTES + ); + } + + #[test] + fn reclaim_vacuums_a_fragmented_database_once() { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("docs.sqlite3"); + fragmented_store(dir.path()); + let before = file_len(&db); + + let store = DocsStore::open(dir.path()).unwrap(); + // Open never pays for the rewrite; the reclaim pass does. + assert!(pragma(&store.conn(), "freelist_count") > 0); + store.reclaim_free_space(); + assert_eq!(pragma(&store.conn(), "freelist_count"), 0); + assert!(file_len(&db) < before / 2, "file shrank"); + assert_eq!(file_len(&dir.path().join("docs.sqlite3-wal")), 0); + assert_eq!(store.load_snapshot("d47").unwrap().unwrap().len(), MIB); + assert!( + !reclaim_free_pages(&store.conn()).unwrap(), + "a compacted database is not rewritten again" + ); + } + + #[test] + fn reclaim_after_a_post_open_shrink_vacuums_without_a_relaunch() { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("docs.sqlite3"); + // Open-time maintenance finds nothing to do; the shrink happens after. + let store = DocsStore::open(dir.path()).unwrap(); + store + .save_snapshot("registry1", &vec![7; 48 * MIB]) + .unwrap(); + store.save_snapshot("registry1", &vec![7; MIB]).unwrap(); + assert!(pragma(&store.conn(), "freelist_count") > 0); + let before = file_len(&db); + + store.reclaim_free_space(); + assert_eq!(pragma(&store.conn(), "freelist_count"), 0); + assert!(file_len(&db) < before / 2, "file shrank"); + assert_eq!(file_len(&dir.path().join("docs.sqlite3-wal")), 0); + assert_eq!( + store.load_snapshot("registry1").unwrap().unwrap().len(), + MIB + ); + // The busy timeout is back at the store's normal wait. + let busy: i64 = pragma(&store.conn(), "busy_timeout"); + assert_eq!(busy, STORE_BUSY_TIMEOUT.as_millis() as i64); + // Idempotent and cheap once compact. + store.reclaim_free_space(); + assert_eq!(pragma(&store.conn(), "freelist_count"), 0); + } + + #[test] + fn small_or_proportionally_minor_freelists_are_left_alone() { + let dir = tempfile::tempdir().unwrap(); + let store = DocsStore::open(dir.path()).unwrap(); + for i in 0..4 { + store + .save_snapshot(&format!("d{i}"), &vec![7; MIB]) + .unwrap(); + } + for i in 0..3 { + store.delete_snapshot(&format!("d{i}")).unwrap(); + } + let freelist = pragma(&store.conn(), "freelist_count"); + assert!(freelist > 0); + assert!(!reclaim_free_pages(&store.conn()).unwrap(), "below 32 MiB"); + assert_eq!(pragma(&store.conn(), "freelist_count"), freelist); + + let page = 4096; + let floor = VACUUM_MIN_FREE_BYTES / page; + assert!(worth_vacuuming(page, floor * 4, floor)); + assert!(!worth_vacuuming(page, floor * 4 + 1, floor), "under 25%"); + assert!(!worth_vacuuming(page, floor, floor - 1), "under the floor"); + } + + #[test] + fn maintenance_yields_to_a_connection_holding_the_database() { + // WAL lets VACUUM proceed beside a reader; a held write lock defers it. + for (lock, vacuumed) in [ + ("BEGIN; SELECT count(*) FROM snapshots;", true), + ("BEGIN IMMEDIATE;", false), + ] { + let dir = tempfile::tempdir().unwrap(); + fragmented_store(dir.path()); + let other = Connection::open(dir.path().join("docs.sqlite3")).unwrap(); + other.execute_batch(lock).unwrap(); + + let started = Instant::now(); + let store = DocsStore::open(dir.path()).expect("maintenance never fails open"); + store.reclaim_free_space(); + assert!( + started.elapsed() < Duration::from_secs(3), + "{lock}: maintenance waited on the store busy timeout" + ); + assert_eq!(store.load_snapshot("d47").unwrap().unwrap().len(), MIB); + assert_eq!( + pragma(&store.conn(), "freelist_count") == 0, + vacuumed, + "{lock}" + ); + + other.execute_batch("COMMIT").unwrap(); + store.save_snapshot("after", b"ok").unwrap(); + assert!(store.has_snapshot("after").unwrap()); + drop(store); + // A deferred VACUUM simply runs on the next uncontended pass. + let store = DocsStore::open(dir.path()).unwrap(); + store.reclaim_free_space(); + assert_eq!(pragma(&store.conn(), "freelist_count"), 0, "{lock}"); + } + } +} diff --git a/crates/sync/src/sync_jobs.rs b/crates/sync/src/sync_jobs.rs index 4347a9be6c..dfe09a3ed3 100644 --- a/crates/sync/src/sync_jobs.rs +++ b/crates/sync/src/sync_jobs.rs @@ -114,6 +114,27 @@ impl DocsStore { }) } + /// Thin-client delivery discovery includes an unacknowledged host wake + /// even after the room has ACKed every outgoing row. + pub fn pending_viewer_deliveries( + &self, + after: &str, + limit: usize, + ) -> Result, StoreError> { + store_blocking(|| { + let conn = self.conn(); + let mut q = conn.prepare( + "SELECT doc_id FROM (SELECT doc_id FROM chat_outbox + UNION SELECT doc_id FROM chat_sync_jobs WHERE kind='viewer-delivery') + WHERE doc_id>?1 ORDER BY doc_id LIMIT ?2", + )?; + Ok( + q.query_map(params![after, limit.min(64) as i64], |r| r.get(0))? + .collect::>()?, + ) + }) + } + pub fn pending_sync_docs(&self, after: &str, limit: usize) -> Result, StoreError> { store_blocking(|| { let conn = self.conn(); @@ -133,6 +154,52 @@ impl DocsStore { #[cfg(test)] mod tests { use super::*; + #[test] + fn viewer_discovery_retains_acked_wakes_and_pages_without_duplicates() { + let dir = tempfile::tempdir().unwrap(); + let store = DocsStore::open(dir.path()).unwrap(); + for n in 0..80 { + let id = format!("chat-{n:03}"); + store.schedule_sync_job(&id, "viewer-delivery").unwrap(); + store + .enqueue_chat_update(&id, &format!("batch-{n}"), b"bytes") + .unwrap(); + } + store + .acknowledge_chat_update("chat-000", "batch-0") + .unwrap(); + drop(store); + let store = DocsStore::open(dir.path()).unwrap(); + let first = store.pending_viewer_deliveries("", 100).unwrap(); + assert_eq!(first.len(), 64); + assert_eq!(first[0], "chat-000", "ACKed rows still owe a wake"); + let last = store + .pending_viewer_deliveries(first.last().unwrap(), 100) + .unwrap(); + assert_eq!(last.len(), 16); + let old = store + .sync_job_version("chat-000", "viewer-delivery") + .unwrap() + .unwrap(); + store + .schedule_sync_job("chat-000", "viewer-delivery") + .unwrap(); + store + .complete_sync_job("chat-000", "viewer-delivery", old) + .unwrap(); + assert!( + store + .sync_job_version("chat-000", "viewer-delivery") + .unwrap() + .is_some() + ); + store.delete_snapshot("chat-000").unwrap(); + assert_eq!( + store.pending_viewer_deliveries("", 1).unwrap(), + vec!["chat-001"] + ); + } + #[test] fn reconciliation_resumes_and_new_requests_fence_old_completion() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/sync/tests/registry_client.rs b/crates/sync/tests/registry_client.rs index 5c66d37df4..dff1eeef8d 100644 --- a/crates/sync/tests/registry_client.rs +++ b/crates/sync/tests/registry_client.rs @@ -117,6 +117,7 @@ async fn two_clients_converge_and_stream_live_updates() { let mut doc = doc_a.lock().unwrap(); doc.upsert_session(&Session { last_completed_turn: None, + running_subagents: 0, chat_id: "chat-1".into(), device_id: "dev-a".into(), status: SessionStatus::Working, @@ -426,6 +427,7 @@ async fn churn_stays_bounded_no_history_growth() { let mut d = doc.lock().unwrap(); d.upsert_session(&Session { last_completed_turn: None, + running_subagents: 0, chat_id: "chat-1".into(), device_id: "dev-a".into(), status: if i % 2 == 0 { diff --git a/crates/sync/tests/registry_edge.rs b/crates/sync/tests/registry_edge.rs index a46953fa31..419d4e970f 100644 --- a/crates/sync/tests/registry_edge.rs +++ b/crates/sync/tests/registry_edge.rs @@ -80,6 +80,7 @@ async fn two_rust_clients_converge_through_a_real_registry_do() { doc.upsert_chat(&chat("chat-live", "dev-live-a")).unwrap(); doc.upsert_session(&Session { last_completed_turn: None, + running_subagents: 0, chat_id: "chat-live".into(), device_id: "dev-live-a".into(), status: SessionStatus::Working, @@ -197,6 +198,7 @@ async fn cursor_delta_and_churn_stay_bounded_on_a_real_do() { let mut d = doc.lock().unwrap(); d.upsert_session(&Session { last_completed_turn: None, + running_subagents: 0, chat_id: "chat-churn".into(), device_id: "dev-churn".into(), status: if i % 2 == 0 { diff --git a/crates/ui/Cargo.toml b/crates/ui/Cargo.toml index 60bd67d758..cf52c0992a 100644 --- a/crates/ui/Cargo.toml +++ b/crates/ui/Cargo.toml @@ -6,9 +6,13 @@ license.workspace = true publish.workspace = true [features] +voice-experimental = [] # Compatibility flag; native subscription voice uses Codex's packaged helper. project-palette-fixture = [] +compact-picker-fixture = ["gpui_platform/test-support"] -browser-fixture = [] +# Native captures explicitly present the freshly drawn scene, including when +# a nested Wayland compositor has stopped requesting idle frames. +browser-fixture = ["gpui/bench"] windows-render-fixture = [] appshots-fixture = ["gpui_platform/test-support"] resource-profile = ["gpui/bench", "gpui_platform/test-support"] @@ -33,6 +37,10 @@ required-features = ["windows-render-fixture"] name = "appshots-fixture" required-features = ["appshots-fixture"] +[[example]] +name = "tool-images-fixture" +required-features = ["appshots-fixture"] + [[example]] name = "new-project-fixture" required-features = ["project-palette-fixture"] @@ -41,7 +49,16 @@ required-features = ["project-palette-fixture"] name = "sidebar-fixture" required-features = ["project-palette-fixture"] +[[example]] +name = "compact-picker-fixture" +required-features = ["compact-picker-fixture"] + [dependencies] +zeron-voice-media.workspace = true +zeron-voice-session.workspace = true +zeron-orb.workspace = true +zeron-veil.workspace = true +zeron-voice = { path = "../voice" } url = "2" zeron-proto.workspace = true similar.workspace = true @@ -55,6 +72,7 @@ zeron-theme.workspace = true gpui.workspace = true gpui_platform.workspace = true gpui_tokio.workspace = true +tokio-util.workspace = true gpui-base.workspace = true tokio.workspace = true futures.workspace = true @@ -89,7 +107,7 @@ wry = { version = "0.56", default-features = false, features = ["os-webview"] } raw-window-handle = "0.6" objc2 = "0.6" block2 = "0.6" -objc2-app-kit = { version = "0.3.2", features = ["NSApplication", "NSBitmapImageRep", "NSImage", "NSEvent", "NSResponder", "NSView", "NSWindow"] } +objc2-app-kit = { version = "0.3.2", features = ["NSApplication", "NSHapticFeedback", "NSBitmapImageRep", "NSImage", "NSEvent", "NSResponder", "NSView", "NSWindow"] } objc2-foundation = "0.3.2" objc2-web-kit = { version = "0.3.2", features = ["WKWebView", "WKNavigationDelegate", "WKNavigationAction", "WKSnapshotConfiguration"] } reqwest.workspace = true @@ -112,4 +130,11 @@ mimalloc.workspace = true tracing-subscriber.workspace = true [target.'cfg(windows)'.dependencies] +# Embedded browser: WebView2 in visual-hosting mode, composited by the zui +# overlay plane. Same windows 0.61 as gpui_windows, so COM types interoperate. +webview2-com = "0.38" +windows = { version = "0.61", features = ["Win32_Foundation", "Win32_Graphics_Direct2D_Common", "Win32_Graphics_DirectComposition", "Win32_System_Com", "Win32_UI_Input_KeyboardAndMouse", "Win32_UI_WindowsAndMessaging"] } +windows-core = "0.61" +raw-window-handle = "0.6" +reqwest.workspace = true windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Security", "Win32_System_Threading", "Win32_UI_WindowsAndMessaging"] } diff --git a/crates/ui/assets/icons/alt-arrow-down.svg b/crates/ui/assets/icons/alt-arrow-down.svg index 53e3b066c5..00cb48a570 100644 --- a/crates/ui/assets/icons/alt-arrow-down.svg +++ b/crates/ui/assets/icons/alt-arrow-down.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/crates/ui/assets/icons/alt-arrow-left.svg b/crates/ui/assets/icons/alt-arrow-left.svg index 4dd27113c9..6af13e8fe7 100644 --- a/crates/ui/assets/icons/alt-arrow-left.svg +++ b/crates/ui/assets/icons/alt-arrow-left.svg @@ -1 +1 @@ - + diff --git a/crates/ui/assets/icons/alt-arrow-right.svg b/crates/ui/assets/icons/alt-arrow-right.svg index 00eb28aa30..644eed71cc 100644 --- a/crates/ui/assets/icons/alt-arrow-right.svg +++ b/crates/ui/assets/icons/alt-arrow-right.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/crates/ui/assets/icons/alt-arrow-up.svg b/crates/ui/assets/icons/alt-arrow-up.svg index 0a88aa4e5e..88f3979358 100644 --- a/crates/ui/assets/icons/alt-arrow-up.svg +++ b/crates/ui/assets/icons/alt-arrow-up.svg @@ -1 +1 @@ - + diff --git a/crates/ui/assets/icons/arrow-left.svg b/crates/ui/assets/icons/arrow-left.svg index 96bafa1d3d..e409f87e46 100644 --- a/crates/ui/assets/icons/arrow-left.svg +++ b/crates/ui/assets/icons/arrow-left.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/crates/ui/assets/icons/arrow-right.svg b/crates/ui/assets/icons/arrow-right.svg index ef861410c3..f31f6bd6cb 100644 --- a/crates/ui/assets/icons/arrow-right.svg +++ b/crates/ui/assets/icons/arrow-right.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/crates/ui/assets/icons/collapse-arrows.svg b/crates/ui/assets/icons/collapse-arrows.svg index b744c52801..c5340b8a1a 100644 --- a/crates/ui/assets/icons/collapse-arrows.svg +++ b/crates/ui/assets/icons/collapse-arrows.svg @@ -1 +1 @@ - + diff --git a/crates/ui/assets/icons/expand-arrows.svg b/crates/ui/assets/icons/expand-arrows.svg index f2b57dfcf8..7ffed0e6ad 100644 --- a/crates/ui/assets/icons/expand-arrows.svg +++ b/crates/ui/assets/icons/expand-arrows.svg @@ -1 +1 @@ - + diff --git a/crates/ui/assets/icons/fast-tier-bold.svg b/crates/ui/assets/icons/fast-tier-bold.svg new file mode 100644 index 0000000000..cf4b3a0f4c --- /dev/null +++ b/crates/ui/assets/icons/fast-tier-bold.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/ui/assets/icons/file-tree.svg b/crates/ui/assets/icons/file-tree.svg index 6e8160c1f1..85d51efe7d 100644 --- a/crates/ui/assets/icons/file-tree.svg +++ b/crates/ui/assets/icons/file-tree.svg @@ -1,7 +1 @@ - - - - - - - + diff --git a/crates/ui/assets/icons/folder.svg b/crates/ui/assets/icons/folder.svg index 77a59f4bf4..be589c8e42 100644 --- a/crates/ui/assets/icons/folder.svg +++ b/crates/ui/assets/icons/folder.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/crates/ui/assets/icons/fork.svg b/crates/ui/assets/icons/fork.svg new file mode 100644 index 0000000000..379aa04327 --- /dev/null +++ b/crates/ui/assets/icons/fork.svg @@ -0,0 +1 @@ + diff --git a/crates/ui/assets/icons/gallery.svg b/crates/ui/assets/icons/gallery.svg new file mode 100644 index 0000000000..86a2d57f05 --- /dev/null +++ b/crates/ui/assets/icons/gallery.svg @@ -0,0 +1 @@ + diff --git a/crates/ui/assets/icons/git-branch.svg b/crates/ui/assets/icons/git-branch.svg index c8820da1e0..a01c97e42b 100644 --- a/crates/ui/assets/icons/git-branch.svg +++ b/crates/ui/assets/icons/git-branch.svg @@ -1,7 +1 @@ - - - - - - - + diff --git a/crates/ui/assets/icons/microphone-off.svg b/crates/ui/assets/icons/microphone-off.svg new file mode 100644 index 0000000000..d3ed06752f --- /dev/null +++ b/crates/ui/assets/icons/microphone-off.svg @@ -0,0 +1 @@ + diff --git a/crates/ui/assets/icons/microphone.svg b/crates/ui/assets/icons/microphone.svg new file mode 100644 index 0000000000..85068dfdf1 --- /dev/null +++ b/crates/ui/assets/icons/microphone.svg @@ -0,0 +1 @@ + diff --git a/crates/ui/assets/icons/phone-hang-up.svg b/crates/ui/assets/icons/phone-hang-up.svg new file mode 100644 index 0000000000..de2cf1aafe --- /dev/null +++ b/crates/ui/assets/icons/phone-hang-up.svg @@ -0,0 +1 @@ + diff --git a/crates/ui/assets/icons/plus.svg b/crates/ui/assets/icons/plus.svg index 7f9fff563a..fff3ff23f0 100644 --- a/crates/ui/assets/icons/plus.svg +++ b/crates/ui/assets/icons/plus.svg @@ -1 +1 @@ - + diff --git a/crates/ui/assets/icons/pull-request.svg b/crates/ui/assets/icons/pull-request.svg index cd875ce477..9e221e3e08 100644 --- a/crates/ui/assets/icons/pull-request.svg +++ b/crates/ui/assets/icons/pull-request.svg @@ -1,7 +1 @@ - - - - - - - + diff --git a/crates/ui/assets/icons/sidebar-minimalistic-left.svg b/crates/ui/assets/icons/sidebar-minimalistic-left.svg deleted file mode 100644 index 5a6a23703b..0000000000 --- a/crates/ui/assets/icons/sidebar-minimalistic-left.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/crates/ui/assets/icons/sidebar-minimalistic.svg b/crates/ui/assets/icons/sidebar-minimalistic.svg deleted file mode 100644 index ed94e9e580..0000000000 --- a/crates/ui/assets/icons/sidebar-minimalistic.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/crates/ui/assets/icons/worktree.svg b/crates/ui/assets/icons/worktree.svg new file mode 100644 index 0000000000..cea0138cc7 --- /dev/null +++ b/crates/ui/assets/icons/worktree.svg @@ -0,0 +1 @@ + diff --git a/crates/ui/assets/sounds/voice-end.wav b/crates/ui/assets/sounds/voice-end.wav new file mode 100644 index 0000000000..d2be6c2b86 Binary files /dev/null and b/crates/ui/assets/sounds/voice-end.wav differ diff --git a/crates/ui/assets/sounds/voice-start.wav b/crates/ui/assets/sounds/voice-start.wav new file mode 100644 index 0000000000..866e5957be Binary files /dev/null and b/crates/ui/assets/sounds/voice-start.wav differ diff --git a/crates/ui/build.rs b/crates/ui/build.rs index ecf17357da..8f446fcdbe 100644 --- a/crates/ui/build.rs +++ b/crates/ui/build.rs @@ -1,6 +1,32 @@ use std::{env, path::PathBuf, process::Command}; fn main() { + if env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("macos") { + println!("cargo:rerun-if-changed=src/dictation/permission.m"); + let out = PathBuf::from(env::var_os("OUT_DIR").unwrap()); + let object = out.join("voice-permission.o"); + assert!( + Command::new("clang") + .args(["-fobjc-arc", "-c", "src/dictation/permission.m", "-o"]) + .arg(&object) + .status() + .unwrap() + .success() + ); + assert!( + Command::new("ar") + .arg("crus") + .arg(out.join("libvoice-permission.a")) + .arg(object) + .status() + .unwrap() + .success() + ); + println!("cargo:rustc-link-search=native={}", out.display()); + println!("cargo:rustc-link-lib=static=voice-permission"); + println!("cargo:rustc-link-lib=framework=AVFoundation"); + } + println!("cargo:rerun-if-changed=src/browser/linux/helper.c"); if env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("linux") { return; diff --git a/crates/ui/examples/browser-fixture.rs b/crates/ui/examples/browser-fixture.rs index 94fe48619f..cb2464dc73 100644 --- a/crates/ui/examples/browser-fixture.rs +++ b/crates/ui/examples/browser-fixture.rs @@ -64,6 +64,31 @@ fn capture(directory: &std::path::Path, name: &str) -> anyhow::Result<()> { Ok(()) } +// The Linux frame loop can be idle while the fixture's async task changes +// theme/page state. Present the current scene before reading native pixels. +async fn capture_frame( + window: gpui::WindowHandle, + directory: &std::path::Path, + name: &str, + cx: &mut AsyncApp, +) -> anyhow::Result<()> { + #[cfg(target_os = "linux")] + { + linux::wait_for_visible_window(cx).await?; + gpui::AnyWindowHandle::from(window).update(cx, |_, w, cx| { + w.refresh(); + let arena = w.draw(cx); + w.present_if_needed(); + arena.clear(); + })?; + // Let the nested compositor present the submitted native frame. + pause(cx, 50).await; + } + #[cfg(not(target_os = "linux"))] + let _ = (window, cx); + capture(directory, name) +} + #[cfg(any(target_os = "macos", target_os = "linux"))] fn validate_blur( directory: &std::path::Path, @@ -210,7 +235,7 @@ fn main() -> anyhow::Result<()> { }); let (first_id, first) = window.update(cx, |shell, w, cx| shell.fixture_open_browser(None, w, cx))?; pause(cx, 500).await; - capture(&output, "browser-empty-dark")?; + capture_frame(window, &output, "browser-empty-dark", cx).await?; #[cfg(any(target_os = "macos", target_os = "linux"))] { window.update(cx, |_, w, cx| first.update(cx, |b, cx| b.navigate(&_origin, w, cx)))?; @@ -220,7 +245,7 @@ fn main() -> anyhow::Result<()> { pause(cx, 50).await; } pause(cx, 500).await; - capture(&output, "browser-preview-dark")?; + capture_frame(window, &output, "browser-preview-dark", cx).await?; // Real DOM click, native navigation and history. first.read_with(cx, |b, _| b.fixture_eval("document.getElementById('details').click()")); let deadline = std::time::Instant::now() + Duration::from_secs(15); @@ -290,7 +315,7 @@ fn main() -> anyhow::Result<()> { anyhow::ensure!(first.read_with(cx, |b,_| b.fixture_overlay_visible()), "toolbar tooltip did not paint on the overlay plane"); anyhow::ensure!(first.read_with(cx, |b,_| b.fixture_focused() && !b.fixture_overlay_at((left+100.) as f64,(top+100.) as f64)), "tooltip stole native focus or page hit testing"); anyhow::ensure!(first.read_with(cx, |b,_| b.fixture_visibility_changes() == before), "tooltip hid the page"); - capture(&output, "browser-tooltip-dark")?; + capture_frame(window, &output, "browser-tooltip-dark", cx).await?; // Also dwell on the tab itself: its URL tooltip is the // original reported flicker case, distinct from toolbar hover. for _ in 0..2 { @@ -314,7 +339,7 @@ fn main() -> anyhow::Result<()> { anyhow::ensure!(first.read_with(cx, |b,_| b.fixture_native_visible()), "menu froze or hid the live page"); anyhow::ensure!(first.read_with(cx, |b,_| b.fixture_overlay_at((left+100.) as f64,(top+150.) as f64)), "menu failed to intercept outside clicks above the browser"); std::fs::write(output.join("backdrop-layers.txt"),first.read_with(cx,|b,_|b.fixture_backdrop_layers()))?; - capture(&output, "browser-menu-dark")?; + capture_frame(window, &output, "browser-menu-dark", cx).await?; first.read_with(cx, |b,_| b.fixture_eval("document.addEventListener('click', () => { document.title = 'Unexpected page click'; })")); pause(cx, 100).await; first.read_with(cx, |b,_| b.fixture_click((left+100.) as f64,(top+150.) as f64)); @@ -331,7 +356,7 @@ fn main() -> anyhow::Result<()> { } #[cfg(not(target_os = "macos"))] { - capture(&output, "browser-menu-dark")?; + capture_frame(window, &output, "browser-menu-dark", cx).await?; window.update(cx, |shell, _, cx| shell.fixture_browser_menu(false, cx))?; } pause(cx, 500).await; @@ -397,7 +422,7 @@ fn main() -> anyhow::Result<()> { } anyhow::ensure!(std::time::Instant::now() anyhow::Result<()> { pause(cx,350).await; first.read_with(cx,|b,_|b.fixture_eval("document.title='Fieldnotes'")); eprintln!("Browser fixture: right sidebar transitions passed"); - capture(&output,"browser-blur-baseline-dark")?; + capture_frame(window, &output, "browser-blur-baseline-dark", cx).await?; window.update(cx,|s,_,cx|s.fixture_browser_menu(true,cx))?; pause(cx,1000).await; let regions=first.read_with(cx,|b,_|b.fixture_backdrops()); anyhow::ensure!(!regions.is_empty(),"menu has no native browser backdrop"); std::fs::write(output.join("blur-regions.json"),serde_json::to_string(®ions)?)?; - capture(&output,"browser-blur-dark")?; + capture_frame(window, &output, "browser-blur-dark", cx).await?; cx.update(|cx|appearance::set_mode(appearance::AppearanceMode::Light,cx)); pause(cx,700).await; - capture(&output,"browser-blur-light")?; + capture_frame(window, &output, "browser-blur-light", cx).await?; first.read_with(cx,|b,_|b.fixture_eval("document.getElementById('browser-blur-grid').style.background='#263d35'")); pause(cx,500).await; - capture(&output,"browser-blur-solid-light")?; + capture_frame(window, &output, "browser-blur-solid-light", cx).await?; let window_width=gpui::AnyWindowHandle::from(window).update(cx,|_,w,_|f32::from(w.viewport_size().width))?; cx.update(|cx|appearance::set_surface(zeron_theme::SurfacePreference::Opaque,cx)); pause(cx,500).await; anyhow::ensure!(first.read_with(cx,|b,_|b.fixture_backdrops().is_empty()),"opaque appearance retained native blur"); - capture(&output,"browser-menu-opaque")?; + capture_frame(window, &output, "browser-menu-opaque", cx).await?; cx.update(|cx| {appearance::set_mode(appearance::AppearanceMode::Dark,cx);appearance::set_surface(zeron_theme::SurfacePreference::Frosted,cx);}); window.update(cx,|s,_,cx|s.fixture_browser_menu(false,cx))?; pause(cx,500).await; @@ -469,7 +494,7 @@ fn main() -> anyhow::Result<()> { window.update(cx, |shell, _, cx| shell.fixture_expand_browser(cx))?; cx.update(|cx| appearance::set_mode(appearance::AppearanceMode::Light, cx)); pause(cx, 600).await; - capture(&output, "browser-light")?; + capture_frame(window, &output, "browser-light", cx).await?; #[cfg(any(target_os = "macos", target_os = "linux"))] { anyhow::ensure!(first.read_with(cx, |b, _| b.fixture_native_visible()), "page not restored after overlays/takeover"); @@ -484,7 +509,7 @@ fn main() -> anyhow::Result<()> { while !first.read_with(cx, |b, _| b.page.error.is_some()) { anyhow::ensure!(std::time::Instant::now() < deadline, "load failure was not reported: {:?}", first.read_with(cx, |b, _| b.page.clone())); pause(cx, 50).await; } - pause(cx, 300).await; capture(&output, "browser-error-light")?; + pause(cx, 300).await; capture_frame(window, &output, "browser-error-light", cx).await?; } // Reject arbitrary schemes while preserving the existing page. let before = first.read_with(cx, |b, _| b.page.url.clone()); diff --git a/crates/ui/examples/browser-fixture/linux.rs b/crates/ui/examples/browser-fixture/linux.rs index eebaee31bf..4282bbe16c 100644 --- a/crates/ui/examples/browser-fixture/linux.rs +++ b/crates/ui/examples/browser-fixture/linux.rs @@ -4,6 +4,35 @@ use gpui::{ }; use zeron_ui::browser::BrowserSurface; +/// The first software-rendered frame can take longer than the fixture's +/// initial pause on a loaded runner. Yield to GPUI until X11 maps the window; +/// blocking this thread would prevent the frame we're waiting for. +pub(super) async fn wait_for_visible_window(cx: &mut AsyncApp) -> anyhow::Result<()> { + // Wayland captures the already-mapped nested compositor's X11 window. + if std::env::var("ZERON_BROWSER_CAPTURE_WINDOW").is_ok_and(|id| !id.is_empty()) { + return Ok(()); + } + let deadline = std::time::Instant::now() + Duration::from_secs(10); + loop { + let windows = std::process::Command::new("xdotool") + .args([ + "search", + "--onlyvisible", + "--pid", + &std::process::id().to_string(), + ]) + .output()?; + if windows.status.success() && !windows.stdout.is_empty() { + return Ok(()); + } + anyhow::ensure!( + std::time::Instant::now() < deadline, + "fixture window did not become visible within 10 seconds" + ); + pause(cx, 50).await; + } +} + async fn eval( page: &Entity, script: &str, @@ -229,7 +258,7 @@ pub async fn exercise( }) })?; pause(cx, 100).await; - capture(output, "linux-ime-dark")?; + capture_frame(window, output, "linux-ime-dark", cx).await?; window.update(cx, |_, w, cx| { page.update(cx, |b, cx| { gpui::EntityInputHandler::replace_text_in_range(b, None, "日本語", w, cx) @@ -257,7 +286,7 @@ pub async fn exercise( page.read_with(cx, |b, _| b.fixture_linux_menu_open()), "HTML select did not open a GPUI menu" ); - capture(output, "linux-select-dark")?; + capture_frame(window, output, "linux-select-dark", cx).await?; for key in ["down", "enter"] { dispatch( window, @@ -300,7 +329,7 @@ pub async fn exercise( page.read_with(cx, |b, _| b.fixture_linux_menu_open()), "context menu did not open in GPUI" ); - capture(output, "linux-context-dark")?; + capture_frame(window, output, "linux-context-dark", cx).await?; dispatch( window, PlatformInput::KeyDown(gpui::KeyDownEvent { @@ -345,7 +374,7 @@ pub async fn exercise( cx, )?; pause(cx, 800).await; - capture(output, "linux-tooltip-dark")?; + capture_frame(window, output, "linux-tooltip-dark", cx).await?; let button=eval(&page,"(()=>{let r=document.getElementById('browser-button').getBoundingClientRect();return [r.x+15,r.y+10]})()",cx).await?; click( window, @@ -437,7 +466,7 @@ pub async fn exercise( (width - layout as f64).abs() < 2., "CSS viewport {width} differs from GPUI {layout}" ); - capture(output, "linux-resize-dark")?; + capture_frame(window, output, "linux-resize-dark", cx).await?; for _ in 0..4 { window.update(cx, |s, _, cx| s.fixture_toggle_sidebar(false, cx))?; pause(cx, 400).await; @@ -469,7 +498,7 @@ pub async fn exercise( "reopened sidebar lost browser" ); cx.update(|cx| appearance::set_surface(zeron_theme::SurfacePreference::Frosted, cx)); - capture(output, "browser-blur-baseline-dark")?; + capture_frame(window, output, "browser-blur-baseline-dark", cx).await?; window.update(cx, |s, _, cx| s.fixture_browser_menu(true, cx))?; pause(cx, 700).await; let presses = eval(&page, "window.pagePresses||0", cx).await?; @@ -500,10 +529,10 @@ pub async fn exercise( ); window.update(cx, |s, _, cx| s.fixture_browser_menu(true, cx))?; pause(cx, 500).await; - capture(output, "browser-blur-dark")?; + capture_frame(window, output, "browser-blur-dark", cx).await?; cx.update(|cx| appearance::set_mode(appearance::AppearanceMode::Light, cx)); pause(cx, 500).await; - capture(output, "browser-blur-light")?; + capture_frame(window, output, "browser-blur-light", cx).await?; eval( &page, "document.getElementById('browser-blur-grid').style.background='#263d35'", @@ -511,10 +540,10 @@ pub async fn exercise( ) .await?; pause(cx, 300).await; - capture(output, "browser-blur-solid-light")?; + capture_frame(window, output, "browser-blur-solid-light", cx).await?; cx.update(|cx| appearance::set_surface(zeron_theme::SurfacePreference::Opaque, cx)); pause(cx, 300).await; - capture(output, "browser-menu-opaque")?; + capture_frame(window, output, "browser-menu-opaque", cx).await?; let bounds = page.read_with(cx, |b, _| b.fixture_linux_bounds()); let viewport = AnyWindowHandle::from(window).update(cx, |_, w, _| f32::from(w.viewport_size().width))?; diff --git a/crates/ui/examples/browser-fixture/transcript_links.rs b/crates/ui/examples/browser-fixture/transcript_links.rs index f27d612a36..12f7246fdf 100644 --- a/crates/ui/examples/browser-fixture/transcript_links.rs +++ b/crates/ui/examples/browser-fixture/transcript_links.rs @@ -54,11 +54,14 @@ async fn screenshot( name: &str, cx: &mut AsyncApp, ) -> anyhow::Result<()> { - // Synthetic key/focus dispatch updates hit testing synchronously. Request - // a presented frame too, especially when Wayland's frame loop is idle. - window.update(cx, |_, w, _| w.on_next_frame(|window, _| window.refresh()))?; - pause(cx, 250).await; - capture(output, name) + #[cfg(target_os = "linux")] + return capture_frame(window, output, name, cx).await; + #[cfg(not(target_os = "linux"))] + { + window.update(cx, |_, w, _| w.on_next_frame(|window, _| window.refresh()))?; + pause(cx, 250).await; + capture(output, name) + } } pub(super) async fn exercise( diff --git a/crates/ui/examples/compact-picker-fixture.rs b/crates/ui/examples/compact-picker-fixture.rs new file mode 100644 index 0000000000..6c5ab4b27d --- /dev/null +++ b/crates/ui/examples/compact-picker-fixture.rs @@ -0,0 +1,322 @@ +//! Isolated visual and keyboard evidence for the compact model picker. +use gpui::{ + AppContext, AsyncApp, Bounds, Context, Entity, Render, Window, WindowBounds, WindowOptions, + div, prelude::*, px, size, +}; +use std::{path::Path, path::PathBuf, time::Duration}; +use zeron_ui::*; + +struct Fixture { + pickers: Entity, + title: &'static str, +} + +impl Render for Fixture { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let theme = theme::Theme::of(cx).clone(); + div() + .id("compact-picker-fixture") + .size_full() + .bg(theme.surface) + .text_color(theme.text) + .font_family(theme.font_sans.clone()) + .p(px(24.0)) + .child( + div() + .size_full() + .flex() + .flex_col() + .justify_end() + .gap(px(16.0)) + .child(settings::widgets::page_header(&theme, self.title, None)) + .child( + div() + .relative() + .h(px(48.0)) + .flex() + .items_center() + .child(self.pickers.clone()), + ), + ) + } +} + +async fn pause(cx: &mut AsyncApp) { + cx.background_executor() + .timer(Duration::from_millis(260)) + .await; +} + +fn capture( + window: gpui::AnyWindowHandle, + cx: &mut AsyncApp, + output: &Path, + name: &str, +) -> anyhow::Result<()> { + window.update(cx, |_, window, cx| { + window.draw(cx).clear(); + window.render_to_image()?.save(output.join(name))?; + Ok(()) + })? +} + +fn press(window: gpui::AnyWindowHandle, cx: &mut AsyncApp, key: &str) -> anyhow::Result<()> { + window.update(cx, |_, window, cx| { + assert!( + window.dispatch_keystroke(gpui::Keystroke::parse(key).unwrap(), cx), + "compact picker did not handle {key}" + ); + })?; + Ok(()) +} + +async fn capture_matrix_scene( + window: gpui::WindowHandle, + cx: &mut AsyncApp, + output: &Path, + appearance: appearance::AppearanceMode, + width: f32, + label: &str, +) -> anyhow::Result<()> { + window.update(cx, |view, window, cx| { + appearance::set_mode(appearance, cx); + window.resize(size(px(width), px(560.0))); + view.title = if appearance == appearance::AppearanceMode::Light { + "Compact picker · light" + } else { + "Compact picker · dark" + }; + cx.notify(); + })?; + pause(cx).await; + capture( + window.into(), + cx, + output, + &format!("compact-panel-{label}.png"), + )?; + press(window.into(), cx, "enter")?; + pause(cx).await; + window.update(cx, |view, _, cx| { + assert!(view.pickers.read(cx).fixture_compact_state(cx).model_list); + })?; + capture( + window.into(), + cx, + output, + &format!("compact-model-list-{label}.png"), + )?; + press(window.into(), cx, "escape")?; + pause(cx).await; + Ok(()) +} + +fn main() -> anyhow::Result<()> { + let runtime = tokio::runtime::Runtime::new()?; + let _guard = runtime.enter(); + let output = PathBuf::from(std::env::args().nth(1).expect("output directory")); + std::fs::create_dir_all(&output)?; + let temp = tempfile::tempdir()?; + let data = temp.path().to_path_buf(); + gpui_platform::application() + .with_assets(icons::Assets) + .run(move |cx| { + gpui_tokio::init(cx); + gpui_base::init(cx); + let mut prefs = settings::UiSettings::default(); + prefs.compact_model_picker = true; + settings::init(prefs.clone(), data.clone(), cx); + let fonts = typography::register_fonts(cx); + typography::init( + prefs.ui_font_family.clone(), + prefs.ui_font_size, + prefs.terminal_font_family.clone(), + prefs.terminal_font_size, + prefs.code_font_family.clone(), + prefs.code_font_size, + fonts, + cx, + ); + theme_library::init(data, cx); + appearance::init( + appearance::AppearanceMode::Dark, + prefs.theme_selection, + prefs.accent, + prefs.surface, + cx, + ); + composer::init(cx, prefs.composer_send_behavior); + + let state = cx.new(|_| state::AppState::new()); + let pickers = cx.new(|cx| { + let mut pickers = pickers::Pickers::new(state, cx); + pickers.fixture_compact_catalog(cx); + pickers + }); + let window = cx + .open_window( + WindowOptions { + window_bounds: Some(WindowBounds::Windowed(Bounds::new( + gpui::point(px(40.0), px(40.0)), + size(px(840.0), px(560.0)), + ))), + ..Default::default() + }, + |window, cx| { + pickers.update(cx, |pickers, cx| { + pickers.open_model_menu(window, cx) + }); + cx.new(|_| Fixture { + pickers, + title: "Compact picker · dark", + }) + }, + ) + .unwrap(); + cx.activate(true); + + cx.spawn(async move |cx| { + let result: anyhow::Result<()> = async { + pause(cx).await; + for (appearance, width, label) in [ + (appearance::AppearanceMode::Dark, 840.0, "dark-wide"), + (appearance::AppearanceMode::Dark, 360.0, "dark-narrow"), + (appearance::AppearanceMode::Light, 840.0, "light-wide"), + (appearance::AppearanceMode::Light, 360.0, "light-narrow"), + ] { + capture_matrix_scene(window, cx, &output, appearance, width, label).await?; + } + + window.update(cx, |view, window, cx| { + appearance::set_mode(appearance::AppearanceMode::Dark, cx); + window.resize(size(px(840.0), px(560.0))); + view.title = "Compact picker · keyboard controls"; + cx.notify(); + })?; + pause(cx).await; + + // Model → Fast → Effort, then choose the ladder's maximum + // using the real keyboard path. + for key in ["down", "down", "end"] { + press(window.into(), cx, key)?; + } + pause(cx).await; + window.update(cx, |view, _, cx| { + let state = view.pickers.read(cx).fixture_compact_state(cx); + assert_eq!(state.reasoning, Some(zeron_proto::ReasoningLevel::XHigh)); + })?; + capture( + window.into(), + cx, + &output, + "compact-effort-keyboard-xhigh.png", + )?; + + // Effort → Fast toggles the exact service-tier option. + for key in ["up", "enter"] { + press(window.into(), cx, key)?; + } + window.update(cx, |view, _, cx| { + assert!(view.pickers.read(cx).fixture_compact_state(cx).fast); + })?; + pause(cx).await; + capture( + window.into(), + cx, + &output, + "compact-fast-keyboard.png", + )?; + + // Return to Model, enter the list, move by keyboard, and + // star the focused model without changing the selection. + for key in ["up", "enter", "down", "cmd-shift-f"] { + press(window.into(), cx, key)?; + } + pause(cx).await; + window.update(cx, |view, _, cx| { + let state = view.pickers.read(cx).fixture_compact_state(cx); + assert!(state.model_list); + assert_eq!(state.focused_model.as_deref(), Some("gpt-5.3-codex")); + assert!(state.focused_model_favorite); + })?; + capture( + window.into(), + cx, + &output, + "compact-favorite-keyboard.png", + )?; + + press(window.into(), cx, "cmd-shift-f")?; + pause(cx).await; + window.update(cx, |view, _, cx| { + let state = view.pickers.read(cx).fixture_compact_state(cx); + assert_eq!(state.focused_model.as_deref(), Some("gpt-5.3-codex")); + assert!(!state.focused_model_favorite); + })?; + capture( + window.into(), + cx, + &output, + "compact-favorite-removed-keyboard.png", + )?; + + // Repeat the keyboard paths at the opposite visual and + // geometry extreme so the dense controls are exercised, + // rather than only photographed, in the narrow layout. + press(window.into(), cx, "escape")?; + window.update(cx, |view, window, cx| { + appearance::set_mode(appearance::AppearanceMode::Light, cx); + window.resize(size(px(360.0), px(560.0))); + view.title = "Compact picker · light narrow keyboard"; + cx.notify(); + })?; + // Fast turns back off, then Effort moves to its lowest level. + for key in ["down", "enter", "down", "home"] { + press(window.into(), cx, key)?; + } + pause(cx).await; + window.update(cx, |view, _, cx| { + let state = view.pickers.read(cx).fixture_compact_state(cx); + assert!(!state.fast); + assert_eq!(state.reasoning, Some(zeron_proto::ReasoningLevel::Low)); + })?; + capture( + window.into(), + cx, + &output, + "compact-effort-keyboard-light-narrow.png", + )?; + + for key in ["up", "up", "enter", "down", "cmd-shift-f"] { + press(window.into(), cx, key)?; + } + pause(cx).await; + window.update(cx, |view, _, cx| { + let state = view.pickers.read(cx).fixture_compact_state(cx); + assert!(state.model_list); + assert_eq!(state.focused_model.as_deref(), Some("gpt-5.3-codex")); + assert!(state.focused_model_favorite); + })?; + capture( + window.into(), + cx, + &output, + "compact-favorite-keyboard-light-narrow.png", + )?; + + std::fs::write( + output.join("result.txt"), + "PASS: compact picker panel/list layout at dark/light and narrow/wide sizes; keyboard effort, fast tier, and favorite identity behavior.\n", + )?; + Ok(()) + } + .await; + if let Err(error) = result { + eprintln!("compact picker fixture failed: {error:#}"); + } + cx.update(|cx| cx.quit()); + }) + .detach(); + }); + Ok(()) +} diff --git a/crates/ui/examples/macos-resource-profile.rs b/crates/ui/examples/macos-resource-profile.rs index 7ff3673c95..77f0361174 100644 --- a/crates/ui/examples/macos-resource-profile.rs +++ b/crates/ui/examples/macos-resource-profile.rs @@ -148,6 +148,7 @@ fn main() -> anyhow::Result<()> { .any(|entry| entry.status == Some(zeron_doc::MessageStatus::Streaming)); state.apply_sessions(vec![zeron_proto::Session { last_completed_turn: None, + running_subagents: 0, chat_id: "profile".into(), device_id: "local".into(), status: if streaming { diff --git a/crates/ui/examples/new-project-fixture.rs b/crates/ui/examples/new-project-fixture.rs index 40ea092f89..a41f523027 100644 --- a/crates/ui/examples/new-project-fixture.rs +++ b/crates/ui/examples/new-project-fixture.rs @@ -13,7 +13,11 @@ fn main() -> anyhow::Result<()> { let mut settings = settings::UiSettings::default(); settings.sidebar_show_branch = true; if let Ok(path) = std::env::var("ZERON_FIXTURE_BACKGROUND") { - settings.new_thread_composer_background = Some(settings::NewThreadComposerBackground { path, name: "Uploaded background".into() }); + settings.new_thread_composer_background = Some(settings::NewThreadComposerBackground { + path, + name: "Uploaded background".into(), + adjustment: settings::NewThreadBackgroundAdjustment::default(), + }); } if let Some(folder) = std::env::var_os("ZERON_FIXTURE_WALLPAPER_FOLDER") { settings.wallpaper_folder = Some(folder.into()); diff --git a/crates/ui/examples/project-selector-fixture.rs b/crates/ui/examples/project-selector-fixture.rs index 1fcbb2f166..d038555738 100644 --- a/crates/ui/examples/project-selector-fixture.rs +++ b/crates/ui/examples/project-selector-fixture.rs @@ -1,5 +1,6 @@ //! Native project selector review fixture, with isolated synthetic data. //! Run under a desktop/Xvfb, then click the sidebar's "All projects" selector. +//! ZERON_FIXTURE_PROJECT= starts the new-session canvas on that project. use gpui::{AppContext, Bounds, WindowBounds, WindowOptions, point, px, size}; use zeron_ui::*; @@ -38,11 +39,14 @@ fn main() { s.chats_synced = true; s.spaces_synced = true; s.spaces = serde_json::from_value(serde_json::json!([ - {"id":"a","deviceId":"local","path":"/projects/anara","createdAt":"2026-09-15T00:00:00Z"}, - {"id":"b","deviceId":"remote","path":"/projects/anara","createdAt":"2026-09-15T00:00:00Z"}, + {"id":"a","deviceId":"local","path":"/projects/anara","repositoryId":"github.com/anara/anara","createdAt":"2026-09-15T00:00:00Z"}, + {"id":"b","deviceId":"remote","path":"/projects/anara","repositoryId":"github.com/anara/anara","createdAt":"2026-09-15T00:00:00Z"}, {"id":"c","deviceId":"long","path":"/projects/design-system-with-a-long-project-name","createdAt":"2026-09-15T00:00:00Z"}, {"id":"d","deviceId":"unknown","path":"/projects/fieldnotes","createdAt":"2026-09-15T00:00:00Z"} ])).unwrap(); + if let Ok(space) = std::env::var("ZERON_FIXTURE_PROJECT") { + s.selected_space = Some(space); + } s }); let boot = EngineBootConfig { data_dir: data, ipc_port: 0, edge_url: String::new(), diff --git a/crates/ui/examples/sidebar-fixture.rs b/crates/ui/examples/sidebar-fixture.rs index bfd7342848..d59d03b7d0 100644 --- a/crates/ui/examples/sidebar-fixture.rs +++ b/crates/ui/examples/sidebar-fixture.rs @@ -1,4 +1,5 @@ -//! Isolated native sidebar review fixture. ZERON_SIDEBAR_COMPACT / ZERON_SIDEBAR_HIDE_LABEL select layout. +//! Isolated native sidebar review fixture. ZERON_SIDEBAR_COMPACT / ZERON_SIDEBAR_HIDE_LABEL select layout; +//! ZERON_SIDEBAR_BY_PROJECT groups by project, with a remote clone of the local repository. use gpui::{AppContext, Bounds, WindowBounds, WindowOptions, px, size}; use zeron_ui::*; @@ -14,7 +15,8 @@ fn main() -> anyhow::Result<()> { settings.sidebar_show_branch = true; settings.sidebar_compact = std::env::var_os("ZERON_SIDEBAR_COMPACT").is_some(); settings.sidebar_show_project_label = std::env::var_os("ZERON_SIDEBAR_HIDE_LABEL").is_none(); - settings.sidebar_organization = settings::SidebarOrganization::InOneList; + let by_project = std::env::var_os("ZERON_SIDEBAR_BY_PROJECT").is_some(); + settings.sidebar_organization = if by_project { settings::SidebarOrganization::ByProject } else { settings::SidebarOrganization::InOneList }; settings.sidebar_width = 310.0; settings.sidebar_pins_mut("local".into()).extend(["chat-0".into(), "chat-1".into()]); let project_path = data.join("fieldnotes"); @@ -75,6 +77,13 @@ fn main() -> anyhow::Result<()> { change_request: Some(zeron_proto::ChangeRequestSummary { provider: "github".into(), number: 412 + ix as u64, title: chat.title.clone().unwrap(), url: "https://github.com/zeronsh/zeron/pull/412".into(), state: zeron_proto::ChangeRequestState::Open, base_ref: "main".into(), head_ref: source.branch.clone() }), }); } + if by_project { + s.spaces[0].repository_id = Some("github.com/zeronsh/fieldnotes".into()); + s.spaces.push(serde_json::from_value(serde_json::json!({"id":"fieldnotes-remote","deviceId":"remote","path":"/projects/fieldnotes","repositoryId":"github.com/zeronsh/fieldnotes","createdAt":chrono::Utc::now()})).unwrap()); + for ix in [3, 7] { + s.chats[ix].space_id = Some("fieldnotes-remote".into()); + } + } s.chats[4].last_message_at = Some(chrono::Utc::now()); s.chats[8].archived = true; s @@ -84,8 +93,11 @@ fn main() -> anyhow::Result<()> { window_bounds: Some(WindowBounds::Windowed(Bounds::new(gpui::point(px(12.),px(30.)), size(px(1100.),px(800.))))), titlebar: Some(gpui::TitlebarOptions { title: None, appears_transparent: true, traffic_light_position: Some(gpui::point(px(14.),px(14.))) }), app_owns_titlebar_drag: true, + // Same backdrop as the app window, or the frosted surface has nothing to blur. + window_background: theme::Theme::of(cx).window_background_appearance(), ..Default::default() }, |_, cx| cx.new(|cx| shell::Shell::new(state.clone(), boot, cx))).unwrap(); + appearance::reapply_window_background(cx); state.update(cx, |_, cx| cx.notify()); cx.activate(true); }); diff --git a/crates/ui/examples/tool-images-fixture.rs b/crates/ui/examples/tool-images-fixture.rs new file mode 100644 index 0000000000..c84b6ef464 --- /dev/null +++ b/crates/ui/examples/tool-images-fixture.rs @@ -0,0 +1,428 @@ +//! Interactive tool-image preview session with isolated data and mock images. +//! Images load through the real engine RPC (ReadAttachmentChunk) from a temp +//! workspace. Close the window to exit. No agent runs. +use gpui::{AppContext, Bounds, WindowBounds, WindowOptions, px, size}; +use image::{ImageEncoder, Rgba, RgbaImage}; +use serde_json::json; +use std::{path::Path, sync::Arc}; +use zeron_proto::ToolCall; +use zeron_ui::*; + +fn port() -> u16 { + std::net::TcpListener::bind("127.0.0.1:0") + .unwrap() + .local_addr() + .unwrap() + .port() +} + +fn gradient(width: u32, height: u32, seed: u8) -> RgbaImage { + RgbaImage::from_fn(width, height, |x, y| { + let (fx, fy) = (x as f32 / width as f32, y as f32 / height as f32); + let ring = (((fx - 0.5).powi(2) + (fy - 0.5).powi(2)).sqrt() * 18.0).sin() > 0.6; + Rgba([ + (fx * 255.0) as u8 ^ seed, + (fy * 220.0) as u8, + if ring { 255 } else { 90 + seed / 2 }, + 255, + ]) + }) +} + +fn write_png(path: &Path, image: &RgbaImage) -> anyhow::Result<()> { + std::fs::create_dir_all(path.parent().unwrap())?; + let file = std::io::BufWriter::new(std::fs::File::create(path)?); + image::codecs::png::PngEncoder::new_with_quality( + file, + image::codecs::png::CompressionType::Fast, + image::codecs::png::FilterType::NoFilter, + ) + .write_image( + image, + image.width(), + image.height(), + image::ExtendedColorType::Rgba8, + )?; + Ok(()) +} + +fn seed_workspace(root: &Path) -> anyhow::Result<()> { + write_png(&root.join("screenshots/home.png"), &gradient(1600, 1000, 0))?; + let mut chart = RgbaImage::from_pixel(800, 500, Rgba([24, 24, 28, 255])); + for (i, h) in [120u32, 260, 180, 420, 330, 210].iter().enumerate() { + for x in (60 + i as u32 * 120)..(140 + i as u32 * 120) { + for y in (480 - h)..480 { + chart.put_pixel(x, y, Rgba([90, 160 + i as u8 * 15, 255, 255])); + } + } + } + write_png(&root.join("charts/chart.png"), &chart)?; + write_png(&root.join("user/attached.png"), &gradient(900, 600, 77))?; + write_png(&root.join("generated/gen.png"), &gradient(1024, 1024, 150))?; + std::fs::create_dir_all(root.join("photos"))?; + image::DynamicImage::ImageRgba8(gradient(1920, 1080, 33)) + .to_rgb8() + .save(root.join("photos/sunset.jpg"))?; + std::fs::create_dir_all(root.join("assets"))?; + image::DynamicImage::ImageRgba8(gradient(512, 512, 200)).save(root.join("assets/icon.webp"))?; + { + let file = std::fs::File::create(root.join("assets/spinner.gif"))?; + let mut encoder = image::codecs::gif::GifEncoder::new(file); + for seed in [10, 120] { + encoder.encode_frame(image::Frame::new(gradient(300, 300, seed)))?; + } + } + std::fs::write( + root.join("assets/logo.svg"), + r##"zeron"##, + )?; + std::fs::write(root.join("assets/notes.txt"), "not an image")?; + // Decode-bound check: 15 MP source, previewed as a ≤1280×640 thumbnail. + write_png(&root.join("big/huge.png"), &gradient(5000, 3000, 5))?; + for i in 0..12 { + write_png( + &root.join(format!("stress/frame-{i:02}.png")), + &gradient(1400, 900, i * 20), + )?; + } + Ok(()) +} + +fn tool(id: &str, call: ToolCall, output: &str, is_error: bool) -> serde_json::Value { + json!({"id": id, "kind": "tool", "call": call, "isError": is_error, "resolved": true, "output": output}) +} + +async fn pause(cx: &mut gpui::AsyncApp, ms: u64) { + cx.background_executor() + .timer(std::time::Duration::from_millis(ms)) + .await; +} + +fn capture( + window: gpui::AnyWindowHandle, + cx: &mut gpui::AsyncApp, + dir: &Path, + name: &str, +) -> anyhow::Result<()> { + // Best effort: some platforms (Windows) have no render_to_image. + window.update(cx, |_, w, cx| { + w.draw(cx).clear(); + if let Ok(image) = w.render_to_image() { + image.save(dir.join(format!("{name}.png"))).ok(); + } + })?; + Ok(()) +} + +/// Expand, spam-toggle, collapse and copy through the production views, +/// asserting residency at each step. Restores the user's clipboard. +async fn self_check( + window: gpui::WindowHandle, + dir: &Path, + chart: &str, + cx: &mut gpui::AsyncApp, +) -> anyhow::Result<()> { + std::fs::create_dir_all(dir)?; + let transcript = window.update(cx, |shell, _, _| shell.fixture_transcript())?; + let stats = + |cx: &mut gpui::AsyncApp| transcript.read_with(cx, |t, _| t.fixture_tool_image_stats()); + let toggle = |open: bool, cx: &mut gpui::AsyncApp| { + transcript.update(cx, |t, cx| t.fixture_tool_images(open, cx)) + }; + let mut log = String::new(); + pause(cx, 1500).await; + capture(window.into(), cx, dir, "1-collapsed")?; + let collapsed = stats(cx); + log += &format!( + "collapsed: {collapsed:?} +" + ); + anyhow::ensure!(collapsed == (0, 0), "collapsed chips hold previews"); + + toggle(true, cx); + transcript.update(cx, |t, cx| t.fixture_appshots_start(cx)); + pause(cx, 8000).await; + capture(window.into(), cx, dir, "2-expanded")?; + let expanded = stats(cx); + log += &format!( + "expanded: {expanded:?} +" + ); + anyhow::ensure!( + expanded.0 > 0 && expanded.1 > 0, + "expanded chips decoded nothing" + ); + + let started = std::time::Instant::now(); + for i in 0..60 { + toggle(i % 2 == 1, cx); + pause(cx, 16).await; + } + pause(cx, 300).await; + let spammed = stats(cx); + log += &format!( + "after 60 toggles in {:?}: {spammed:?} +", + started.elapsed() + ); + anyhow::ensure!(spammed == expanded, "spam re-decoded or dropped previews"); + + toggle(false, cx); + pause(cx, 250).await; + let grace = stats(cx); + log += &format!( + "250ms after collapse: {grace:?} +" + ); + pause(cx, 1200).await; + capture(window.into(), cx, dir, "3-collapsed-again")?; + let freed = stats(cx); + log += &format!( + "1450ms after collapse: {freed:?} +" + ); + anyhow::ensure!(freed == (0, 0), "collapsed previews were not freed"); + + let previous = cx.update(|cx| cx.read_from_clipboard()); + gpui::AnyWindowHandle::from(window).update(cx, |_, w, cx| { + transcript.update(cx, |t, cx| t.fixture_image_menu(chart.into(), None, w, cx)) + })?; + pause(cx, 2500).await; + capture(window.into(), cx, dir, "4-menu")?; + gpui::AnyWindowHandle::from(window).update(cx, |_, w, cx| { + transcript.update(cx, |t, cx| { + t.fixture_image_menu(chart.into(), Some(0), w, cx) + }) + })?; + let text = cx.update(|cx| cx.read_from_clipboard().and_then(|c| c.text())); + log += &format!( + "copy path: {text:?} +" + ); + anyhow::ensure!(text.as_deref() == Some(chart), "copy path mismatch"); + gpui::AnyWindowHandle::from(window).update(cx, |_, w, cx| { + transcript.update(cx, |t, cx| { + t.fixture_image_menu(chart.into(), Some(1), w, cx) + }) + })?; + pause(cx, 2000).await; + let image = cx.update(|cx| { + cx.read_from_clipboard().and_then(|c| { + c.entries().iter().find_map(|e| match e { + gpui::ClipboardEntry::Image(image) => Some(image.bytes().len()), + _ => None, + }) + }) + }); + log += &format!( + "copy image bytes: {image:?} +" + ); + if let Some(previous) = previous { + cx.update(|cx| cx.write_to_clipboard(previous)); + } + anyhow::ensure!( + image.is_some_and(|n| n > 0), + "copy image put no image on the clipboard" + ); + log += "PASS +"; + print!("{log}"); + std::fs::write(dir.join("result.txt"), log)?; + Ok(()) +} + +fn main() -> anyhow::Result<()> { + tracing_subscriber::fmt() + .with_env_filter("warn,zeron_ui::tool_images=debug") + .init(); + let temp = tempfile::tempdir()?; + let workspace = temp.path().join("workspace"); + seed_workspace(&workspace)?; + let cwd = workspace.to_string_lossy().into_owned(); + let abs = |rel: &str| { + workspace + .join(rel.replace('/', std::path::MAIN_SEPARATOR_STR)) + .to_string_lossy() + .into_owned() + }; + let runtime = tokio::runtime::Runtime::new()?; + let core = runtime.block_on(async { + zeron_engine::EngineCore::assemble( + &temp.path().join("engine"), + Arc::new(zeron_engine::default_registry()), + zeron_proto::HarnessId::ClaudeCode, + None, + ) + })?; + core.workspace.create_chat( + "tool-images", + None, + Some(&core.device_id), + None, + Some(cwd.clone()), + )?; + core.workspace + .rename_chat("tool-images", "Tool image previews")?; + let ipc_port = port(); + let _ipc = runtime.block_on(zeron_engine::serve_ipc(ipc_port, core.rpc_service()))?; + let data = temp.path().join("ui"); + std::fs::create_dir(&data)?; + let boot = EngineBootConfig { + data_dir: data.clone(), + ipc_port, + edge_url: String::new(), + edge_token: None, + org_id: None, + workos_client_id: None, + default_harness: zeron_proto::HarnessId::ClaudeCode, + }; + let handle = runtime.block_on(state::EngineHandle::bootstrap(boot.clone()))?; + let chats = core.workspace.read_chats()?; + let device = core.device_id.clone(); + + let read = |path: &str| ToolCall::ReadFile { path: path.into() }; + let exec = |command: &str| ToolCall::Exec { + command: command.into(), + }; + let outside = if cfg!(windows) { + r"C:\Windows\Web\Wallpaper\Windows\img0.jpg" + } else { + "/usr/share/backgrounds/outside.png" + }; + let capture = std::env::args() + .skip_while(|arg| arg != "--capture") + .nth(1) + .map(std::path::PathBuf::from); + let chart = abs("charts/chart.png"); + let entries = json!([ + {"id": "u1", "role": "user", "createdAt": 1788900000000_i64, "deviceId": device, + "parts": [{"id": "t", "kind": "text", "text": attachments::with_attachments( + "Here is the mockup I attached. Render every image the tools touch.", + &[abs("user/attached.png")])}]}, + {"id": "a1", "role": "assistant", "createdAt": 1788900001000_i64, "deviceId": device, "status": "complete", + "parts": [ + {"id": "p0", "kind": "text", "text": "Open a tool group, then expand a chip. Its image decodes only while the chip is open. Right-click any image for **Copy path** / **Copy image**."}, + tool("t1", read("screenshots/home.png"), "Read image (1600×1000)", false), + tool("t2", exec("python scripts/plot.py --out charts/chart.png"), "Saved chart to charts/chart.png", false), + tool("t3", ToolCall::Mcp { server: "browser".into(), tool: "screenshot".into(), + input: Some(json!({"path": abs("photos/sunset.jpg"), "fullPage": true})) }, "Captured viewport", false), + tool("t4", exec("ls assets"), "icon.webp\nlogo.svg\nnotes.txt\nspinner.gif", false), + tool("t5", read("big/huge.png"), "Read image (5000×3000)", false), + tool("t6", read("missing/nope.png"), "File not found", true), + tool("t7", read(outside), "Read image", false), + tool("t8", read("src/main.rs"), "fn main() {}", false), + {"id": "p1", "kind": "text", "text": "Above: PNG, chart, JPEG via MCP, WebP/SVG/GIF from output, a 15 MP PNG, a missing file and a path outside the workspace jail (both show *Image unavailable*)."}, + {"id": "g1", "kind": "image", "path": abs("generated/gen.png"), "name": "gen.png", "mimeType": "image/png"}, + ]}, + {"id": "a2", "role": "assistant", "createdAt": 1788900002000_i64, "deviceId": device, "status": "complete", + "parts": (0..12).map(|i| tool(&format!("s{i}"), read(&format!("stress/frame-{i:02}.png")), "Read image (1400×900)", false)) + .chain(std::iter::once(json!({"id": "p2", "kind": "text", "text": "Stress group: spam-toggle these chips. Previews reuse their texture inside the 600 ms grace and free after it (watch the `tool_images` debug log)."}))) + .collect::>()}, + ]); + + gpui_platform::application() + .with_assets(icons::Assets) + .run(move |cx| { + gpui_tokio::init(cx); + gpui_base::init(cx); + let settings = settings::UiSettings::default(); + settings::init(settings.clone(), data.clone(), cx); + let fonts = typography::register_fonts(cx); + typography::init( + settings.ui_font_family.clone(), + settings.ui_font_size, + settings.terminal_font_family.clone(), + settings.terminal_font_size, + settings.code_font_family.clone(), + settings.code_font_size, + fonts, + cx, + ); + theme_library::init(data.clone(), cx); + appearance::init( + appearance::AppearanceMode::Dark, + settings.theme_selection, + settings.accent, + settings.surface, + cx, + ); + history::init( + settings.git_history_columns, + settings.git_history_column_widths, + settings.git_history_column_order, + settings.git_history_author_display, + cx, + ); + composer::init(cx, settings.composer_send_behavior); + terminal::panel::init(cx); + app_menus::init(cx); + let state = cx.new(|_| { + let mut s = state::AppState::new(); + s.fixture_attachment_engine(handle); + s.connection = zeron_proto::view::ConnectionStatus::Ready; + s.workspace_scope = Some(zeron_proto::WorkspaceScope::Development); + s.local_device_id = Some(device.clone()); + s.devices = vec![ + serde_json::from_value(json!({"id": device, "name": "This device", + "platform": std::env::consts::OS, "lastSeenAt": null})) + .unwrap(), + ]; + s.chats = chats; + s.selected_chat = Some("tool-images".into()); + s.auto_selected = true; + s.chats_synced = true; + s.spaces_synced = true; + s.no_project = true; + s + }); + let window = cx + .open_window( + WindowOptions { + window_background: theme::Theme::of(cx).window_background_appearance(), + window_bounds: Some(WindowBounds::Windowed(Bounds::new( + gpui::point(px(40.), px(40.)), + size(px(1200.), px(900.)), + ))), + ..Default::default() + }, + |_, cx| cx.new(|cx| shell::Shell::new(state.clone(), boot, cx)), + ) + .unwrap(); + cx.on_window_closed(|cx, _| cx.quit()).detach(); + state.update(cx, |_, cx| cx.notify()); + cx.activate(true); + cx.spawn(async move |cx| { + cx.background_executor() + .timer(std::time::Duration::from_millis(800)) + .await; + state.update(cx, |s, cx| { + s.receive_transcript_frame( + zeron_doc::TranscriptFrame::Reset { + reset: serde_json::from_value(entries).unwrap(), + }, + cx, + ) + .unwrap(); + cx.notify(); + }); + if let Some(dir) = capture { + if let Err(error) = self_check(window, &dir, &chart, cx).await { + eprintln!("self-check failed: {error:#}"); + std::fs::write( + dir.join("result.txt"), + format!( + "FAILED: {error:#} +" + ), + ) + .ok(); + } + cx.update(|cx| cx.quit()); + } + }) + .detach(); + }); + runtime.block_on(core.shutdown()); + Ok(()) +} diff --git a/crates/ui/src/appshots.rs b/crates/ui/src/appshots.rs index 20d12f5e9e..26290b0e20 100644 --- a/crates/ui/src/appshots.rs +++ b/crates/ui/src/appshots.rs @@ -751,7 +751,9 @@ pub(crate) fn restore_queued_appshots( // their PNG. Normalize their bytes too, without changing attachment IDs. if png_dimensions(screenshot.bytes()).is_some() { if let Some(bytes) = trim_appshot_padding(screenshot.bytes()).map_err(|_| invalid())? { - screenshot.image = Arc::new(gpui::Image::from_bytes(gpui::ImageFormat::Png, bytes)); + screenshot.content = crate::attachments::AttachmentContent::Image(Arc::new( + gpui::Image::from_bytes(gpui::ImageFormat::Png, bytes), + )); } } let content = node.text().unwrap_or_default(); @@ -938,7 +940,11 @@ pub(crate) mod tests { screenshot: StagedAttachment { id: "image-1".into(), name: "Safari Appshot.png".into(), - image: Arc::new(Image::from_bytes(ImageFormat::Png, Vec::new())), + content: crate::attachments::AttachmentContent::Image(Arc::new(Image::from_bytes( + ImageFormat::Png, + Vec::new(), + ))), + mention: None, }, screenshot_dimensions: Some((1440, 900)), app_icon: None, diff --git a/crates/ui/src/attachments.rs b/crates/ui/src/attachments.rs index 6692feb720..06b0d65f55 100644 --- a/crates/ui/src/attachments.rs +++ b/crates/ui/src/attachments.rs @@ -46,6 +46,64 @@ const MAX_READ_CHUNKS: usize = 1_000; /// The body used for image-only sends (`use-attachments.ts`). pub const ATTACHMENT_ONLY_TEXT: &str = "See the attached image(s)."; +/// The body used when any attachment is not an image. +pub const FILE_ATTACHMENT_ONLY_TEXT: &str = "See the attached file(s)."; + +/// Whether `text` is the placeholder body of an attachments-only send. +pub fn is_attachment_only_text(text: &str) -> bool { + text == ATTACHMENT_ONLY_TEXT || text == FILE_ATTACHMENT_ONLY_TEXT +} + +/// The placeholder body for an attachments-only send of `paths`. +pub fn attachment_only_text(paths: &[String]) -> &'static str { + if paths.iter().all(|path| is_image_path(path)) { + ATTACHMENT_ONLY_TEXT + } else { + FILE_ATTACHMENT_ONLY_TEXT + } +} + +/// Whether an attachment ref names an image, as opposed to any other file. +pub fn is_image_path(path: &str) -> bool { + format_by_extension(Path::new(path)).is_some() +} + +pub use zeron_proto::attachment_mentions::attachment_display_name; + +/// Whether `mention` is the chip for the attachment at `path`. +pub fn chip_names_attachment( + mention: &zeron_proto::attachment_mentions::AttachmentMention, + path: &str, +) -> bool { + mention.names_attachment(path) +} + +/// Number restored attachments after the chips in `text` that name them, so +/// those chips stay live; an attachment no chip names is left unnumbered. +pub fn pair_with_chips(text: &str, attachments: &mut [StagedAttachment]) { + let chips = zeron_proto::attachment_mentions::attachment_mentions(text); + for att in attachments { + att.mention = chips + .iter() + .find(|chip| chip_names_attachment(chip, &att.name)) + .map(|chip| chip.index); + } +} + +/// The attachments no chip in `text` stands for, in order. A chip is the +/// attachment's handle wherever it shows, so only these still need a tile; +/// anything a chip can't be matched to keeps its tile. +pub fn unchipped_attachments<'a>(text: &str, paths: &'a [String]) -> Vec<&'a String> { + let mentions = zeron_proto::attachment_mentions::attachment_mentions(text); + paths + .iter() + .filter(|path| { + !mentions + .iter() + .any(|mention| chip_names_attachment(mention, path)) + }) + .collect() +} /// How attachments ride the prompt (use-attachments.ts `withAttachments`): /// plain local paths appended to the text — the files are staged on the device @@ -57,7 +115,7 @@ pub fn with_attachments(text: &str, paths: &[String]) -> String { } let refs: Vec = paths.iter().map(|p| format!("- {p}")).collect(); let body = if text.is_empty() { - ATTACHMENT_ONLY_TEXT + attachment_only_text(paths) } else { text }; @@ -153,7 +211,7 @@ pub fn parse_user_message_images(content: &str) -> ParsedUserMessage { }; } ParsedUserMessage { - text: if body.trim() == ATTACHMENT_ONLY_TEXT { + text: if is_attachment_only_text(body.trim()) { String::new() } else { body.to_string() @@ -169,10 +227,19 @@ pub fn user_message_rail_text(content: &str) -> String { if !parsed.text.trim().is_empty() { return parsed.text; } + let kind = if parsed + .attachments + .iter() + .all(|att| is_image_path(&att.path)) + { + "image" + } else { + "file" + }; match parsed.attachments.len() { 0 => content.to_string(), - 1 => "Attached image".to_string(), - n => format!("{n} attached images"), + 1 => format!("Attached {kind}"), + n => format!("{n} attached {kind}s"), } } @@ -180,21 +247,62 @@ pub fn user_message_rail_text(content: &str) -> String { // Staging (use-attachments.ts intake) // --------------------------------------------------------------------------- -/// An image staged in the composer, before upload. The raw bytes live inside -/// the [`Image`] (gpui decodes them at paint; the same Arc feeds thumbnails, -/// the lightbox, the upload, and the post-send cache seed). +/// What a staged attachment holds. Images keep their decoded-at-paint +/// [`Image`], which feeds thumbnails, the lightbox, the upload and the +/// post-send cache seed; any other file stays opaque bytes. +#[derive(Clone)] +pub enum AttachmentContent { + Image(Arc), + File(Arc>), +} + +/// A file staged in the composer, before upload. #[derive(Clone)] pub struct StagedAttachment { pub id: String, - /// File name with a type-matching extension (use-attachments.ts + /// File name. Images carry a type-matching extension (use-attachments.ts /// `ensureExtension` — agents sniff images by extension). pub name: String, - pub image: Arc, + pub content: AttachmentContent, + /// The composer's chip number when a chip in the draft mentions this + /// attachment. `None` for attachments the prompt does not reference + /// (appshots, drafts restored from before chips). + pub mention: Option, } impl StagedAttachment { pub fn bytes(&self) -> &[u8] { - &self.image.bytes + match &self.content { + AttachmentContent::Image(image) => &image.bytes, + AttachmentContent::File(bytes) => bytes, + } + } + + /// The picture, for attachments that are images. + pub fn image(&self) -> Option<&Arc> { + match &self.content { + AttachmentContent::Image(image) => Some(image), + AttachmentContent::File(_) => None, + } + } + + fn new(name: String, content: AttachmentContent) -> Self { + Self { + id: uuid::Uuid::new_v4().to_string(), + name, + content, + mention: None, + } + } + + fn from_image(name: String, image: Image) -> Self { + Self::new(name, AttachmentContent::Image(Arc::new(image))) + } + + fn from_file(name: &str, bytes: Vec) -> Self { + // Attachment refs ride the prompt one per line; a name must not add lines. + let name = name.replace(['\r', '\n'], "_"); + Self::new(name, AttachmentContent::File(Arc::new(bytes))) } } @@ -231,46 +339,104 @@ pub fn ensure_extension(name: &str, format: ImageFormat) -> String { } } -/// Stage a file from disk (picker / drop / pasted path). `Err` carries the -/// user-facing message (mirrors the old `onError` copy). +/// Stage a file from disk (picker / drop / pasted path). An image keeps its +/// thumbnail (a BMP is converted to PNG, which agents can read); any other +/// regular file is staged as opaque bytes. `Err` carries the user-facing +/// message (mirrors the old `onError` copy). pub fn stage_file(path: &Path) -> Result { - let display_name = path - .file_name() - .map(|n| n.to_string_lossy().to_string()) - .unwrap_or_else(|| "image".to_string()); - let Some(format) = format_by_extension(path) else { - return Err(format!("{display_name} is not a supported image.")); - }; - let meta = std::fs::metadata(path).map_err(|_| format!("{display_name} could not be read."))?; + if format_by_extension(path).is_none() { + return stage_plain_file(path); + } + let mut staged = stage_file_verbatim(path)?; + if let Some(image) = staged + .image() + .filter(|image| image.format == ImageFormat::Bmp) + { + let png = + bmp_to_png(image).map_err(|_| format!("{} is not a valid image.", staged.name))?; + staged.content = AttachmentContent::Image(Arc::new(png)); + staged.name = Path::new(&staged.name) + .with_extension("png") + .to_string_lossy() + .into_owned(); + } + Ok(staged) +} + +/// Read a regular file within the attachment size limit. `Err` carries the +/// user-facing message. +fn read_regular_file(path: &Path, name: &str) -> Result, String> { + let meta = std::fs::metadata(path).map_err(|_| format!("{name} could not be read."))?; + if !meta.is_file() { + return Err(format!("{name} is not a file.")); + } if meta.len() > MAX_ATTACHMENT_BYTES { - return Err(format!("{display_name} is too large (24 MB max).")); + return Err(format!("{name} is too large (24 MB max).")); } - let bytes = std::fs::read(path).map_err(|_| format!("{display_name} could not be read."))?; - Ok(StagedAttachment { - id: uuid::Uuid::new_v4().to_string(), - name: ensure_extension(&display_name, format), - image: Arc::new(Image::from_bytes(format, bytes)), - }) + std::fs::read(path).map_err(|_| format!("{name} could not be read.")) } -/// Stage an image pasted from the clipboard. +fn file_name(path: &Path, fallback: &str) -> String { + path.file_name().map_or_else( + || fallback.to_string(), + |n| n.to_string_lossy().into_owned(), + ) +} + +/// Stage a file's bytes exactly as stored, for callers that decode the image +/// themselves (wallpapers) rather than hand it to an agent. +pub fn stage_file_verbatim(path: &Path) -> Result { + let name = file_name(path, "image"); + let Some(format) = format_by_extension(path) else { + return Err(format!("{name} is not a supported image.")); + }; + let bytes = read_regular_file(path, &name)?; + Ok(StagedAttachment::from_image( + ensure_extension(&name, format), + Image::from_bytes(format, bytes), + )) +} + +/// Stage any other regular file as opaque bytes. +fn stage_plain_file(path: &Path) -> Result { + let name = file_name(path, "file"); + let bytes = read_regular_file(path, &name)?; + Ok(StagedAttachment::from_file(&name, bytes)) +} + +/// Stage an image pasted from the clipboard. A BMP (what Windows puts there for +/// a screenshot) is converted to PNG like [`stage_file`] does; one that won't +/// decode stays as pasted. pub fn stage_clipboard_image(image: Image) -> StagedAttachment { - let format = image.format; - StagedAttachment { - id: uuid::Uuid::new_v4().to_string(), - name: ensure_extension("image", format), - image: Arc::new(image), - } + let image = if image.format == ImageFormat::Bmp + && let Ok(png) = bmp_to_png(&image) + { + png + } else { + image + }; + StagedAttachment::from_image(ensure_extension("image", image.format), image) +} + +/// Windows screenshots are BMP, but no agent harness inlines BMP and the +/// agent's `Read` tool can't decode one — so they go out as PNG. +fn bmp_to_png(bmp: &Image) -> Result { + let decoded = image::load_from_memory_with_format(&bmp.bytes, image::ImageFormat::Bmp) + .map_err(|e| e.to_string())?; + let mut png = std::io::Cursor::new(Vec::new()); + decoded + .write_to(&mut png, image::ImageFormat::Png) + .map_err(|e| e.to_string())?; + Ok(Image::from_bytes(ImageFormat::Png, png.into_inner())) } /// Stage native macOS capture bytes without a temporary file. The capture /// service already encoded PNG and enforces the shared size limit. pub fn stage_png_bytes(name: String, bytes: Vec) -> StagedAttachment { - StagedAttachment { - id: uuid::Uuid::new_v4().to_string(), - name: ensure_extension(&name, ImageFormat::Png), - image: Arc::new(Image::from_bytes(ImageFormat::Png, bytes)), - } + StagedAttachment::from_image( + ensure_extension(&name, ImageFormat::Png), + Image::from_bytes(ImageFormat::Png, bytes), + ) } // --------------------------------------------------------------------------- @@ -463,14 +629,15 @@ pub struct LoadedAttachmentImage { } /// `ReadAttachmentChunk` loop: 45KB base64 chunks until `done` (bounded, with -/// the same stuck-offset guard as zeron's `readAttachmentImage`). -pub async fn read_attachment_image( +/// the same stuck-offset guard as zeron's `readAttachmentImage`). Returns the +/// file's name, MIME type and bytes. +pub(crate) async fn read_attachment_bytes( engine: &EngineHandle, executor: &BackgroundExecutor, target_device_id: Option<&str>, path: &str, expected_raster_mime: Option<&str>, -) -> Option { +) -> Option<(String, String, Vec)> { let mut name = String::new(); let mut mime = String::new(); let mut b64 = String::new(); @@ -496,10 +663,7 @@ pub async fn read_attachment_image( return None; } let data = chunk.get("data")?.as_str()?; - if expected_raster_mime.is_some() - && b64.len().saturating_add(data.len()) - > (MAX_ATTACHMENT_BYTES as usize).div_ceil(3) * 4 - { + if b64.len().saturating_add(data.len()) > (MAX_ATTACHMENT_BYTES as usize).div_ceil(3) * 4 { return None; } b64.push_str(data); @@ -513,10 +677,50 @@ pub async fn read_attachment_image( } offset = next; } - if !done || b64.is_empty() { + if !done { + return None; + } + // Callers await this on the UI thread; decode the (up to 24 MiB) payload + // off it. + let bytes = executor + .spawn(async move { BASE64.decode(b64.as_bytes()).ok() }) + .await?; + Some((name, mime, bytes)) +} + +/// Read back a staged file of any type (a queued message restored for editing). +pub async fn read_attachment_file( + engine: &EngineHandle, + executor: &BackgroundExecutor, + target_device_id: Option<&str>, + path: &str, +) -> Option { + let (name, _, bytes) = + read_attachment_bytes(engine, executor, target_device_id, path, None).await?; + Some(StagedAttachment::from_file( + attachment_display_name(&name), + bytes, + )) +} + +pub async fn read_attachment_image( + engine: &EngineHandle, + executor: &BackgroundExecutor, + target_device_id: Option<&str>, + path: &str, + expected_raster_mime: Option<&str>, +) -> Option { + let (name, mime, bytes) = read_attachment_bytes( + engine, + executor, + target_device_id, + path, + expected_raster_mime, + ) + .await?; + if bytes.is_empty() { return None; } - let bytes = BASE64.decode(b64.as_bytes()).ok()?; let image = if let Some(expected) = expected_raster_mime { if expected != mime || !matches!( @@ -909,6 +1113,21 @@ pub fn seed_attachment(device_id: &str, path: &str, name: &str, image: Arc, pub(crate) viewer: crate::image_viewer::ImageView, + /// Fill behind an image drawn on a transparent canvas (diagrams). + pub(crate) plate: Option, } impl PreviewImage { @@ -927,8 +1148,14 @@ impl PreviewImage { name: name.into(), image, viewer: Default::default(), + plate: None, } } + + pub(crate) fn with_plate(mut self, plate: gpui::Hsla) -> Self { + self.plate = Some(plate); + self + } } /// Shared image viewer over a dim scrim. Escape and a click close it; @@ -969,9 +1196,14 @@ pub(crate) fn lightbox_with_size( }) }); let content = match natural_size { - Some(natural) => preview - .viewer - .render(preview.image.clone(), natural, None, window, cx), + Some(natural) => preview.viewer.render( + preview.image.clone(), + natural, + None, + preview.plate, + window, + cx, + ), None => div() .text_color(ink(0.6)) .child("Loading image…") @@ -1076,6 +1308,77 @@ mod tests { assert!(thumbnail.bytes.len() < 160 * 112 * 4); } + #[test] + fn any_regular_file_stages_with_its_exact_bytes() { + let dir = tempfile::tempdir().unwrap(); + for (name, bytes) in [ + ("notes.md", &b"# notes\n"[..]), + ("archive.tar.gz", &[0x1f, 0x8b, 0, 0xff][..]), + ("LICENSE", b"MIT"), + ("empty.txt", b""), + ] { + let path = dir.path().join(name); + std::fs::write(&path, bytes).unwrap(); + let staged = stage_file(&path).unwrap(); + assert_eq!(staged.name, name); + assert_eq!(staged.bytes(), bytes); + assert!(staged.image().is_none()); + } + // An image still keeps its thumbnail. + let png = dir.path().join("a.png"); + std::fs::write(&png, b"not decoded here").unwrap(); + assert!(stage_file(&png).unwrap().image().is_some()); + } + + #[test] + fn folders_and_oversize_files_are_refused_with_a_message() { + let dir = tempfile::tempdir().unwrap(); + assert!(stage_file(dir.path()).err().unwrap().contains("not a file")); + let large = dir.path().join("large.bin"); + std::fs::File::create(&large) + .unwrap() + .set_len(MAX_ATTACHMENT_BYTES + 1) + .unwrap(); + assert!(stage_file(&large).err().unwrap().contains("too large")); + assert!(stage_file(&dir.path().join("missing.md")).is_err()); + } + + #[test] + fn a_staged_name_cannot_add_attachment_lines() { + let staged = StagedAttachment::from_file("a\n- injected.md", Vec::new()); + assert_eq!(staged.name, "a_- injected.md"); + } + + #[test] + fn file_attachments_round_trip_with_their_own_placeholder() { + let only_files = with_attachments("", &["/uploads/ab12cd34-notes.md".to_string()]); + assert!(only_files.starts_with(FILE_ATTACHMENT_ONLY_TEXT)); + let parsed = parse_user_message_images(&only_files); + assert_eq!(parsed.text, ""); + assert_eq!(parsed.attachments[0].path, "/uploads/ab12cd34-notes.md"); + assert_eq!(user_message_rail_text(&only_files), "Attached file"); + + // A mixed send reads as files; an image-only send is unchanged. + let mixed = with_attachments("", &["/a/cat.png".to_string(), "/a/notes.md".to_string()]); + assert!(mixed.starts_with(FILE_ATTACHMENT_ONLY_TEXT)); + assert_eq!(user_message_rail_text(&mixed), "2 attached files"); + let images = with_attachments("", &["/a/cat.png".to_string()]); + assert!(images.starts_with(ATTACHMENT_ONLY_TEXT)); + assert!(is_attachment_only_text(ATTACHMENT_ONLY_TEXT)); + assert!(is_attachment_only_text(FILE_ATTACHMENT_ONLY_TEXT)); + } + + #[test] + fn upload_prefixes_are_not_part_of_the_displayed_name() { + assert_eq!(attachment_display_name("ab12cd34-notes.md"), "notes.md"); + assert_eq!(attachment_display_name("my-notes.md"), "my-notes.md"); + assert_eq!( + attachment_display_name("zzzzzzzz-notes.md"), + "zzzzzzzz-notes.md" + ); + assert_eq!(attachment_display_name("notes.md"), "notes.md"); + } + #[test] fn with_attachments_round_trips_through_parse() { let paths = vec!["/data/uploads/ab-cat.png".to_string(), "/x/dog.jpg".into()]; @@ -1176,6 +1479,72 @@ mod tests { } } + /// A 1x1 32-bit BI_RGB bitmap as the Windows clipboard produces it: one + /// BGRX pixel, the fourth byte unused (zero). + fn clipboard_bmp() -> Vec { + let mut bmp = b"BM".to_vec(); + // File size, reserved, pixel offset, header size, width, height. + for field in [58u32, 0, 54, 40, 1, 1] { + bmp.extend(field.to_le_bytes()); + } + bmp.extend([1, 0, 32, 0]); // planes, bits per pixel + bmp.extend([0; 24]); // BI_RGB, image size, resolution, palette + bmp.extend([30, 20, 10, 0]); + bmp + } + + #[test] + fn pasted_bmp_screenshot_is_staged_as_an_opaque_png() { + let staged = stage_clipboard_image(Image::from_bytes(ImageFormat::Bmp, clipboard_bmp())); + assert_eq!(staged.image().unwrap().format, ImageFormat::Png); + assert_eq!(staged.name, "image.png"); + let png = image::load_from_memory(staged.bytes()).unwrap(); + assert_eq!(png.into_rgba8().get_pixel(0, 0).0, [10, 20, 30, 255]); + } + + #[test] + fn bmp_file_is_staged_as_png_but_wallpapers_keep_the_original() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("Screenshot 1.BMP"); + std::fs::write(&path, clipboard_bmp()).unwrap(); + + let staged = stage_file(&path).unwrap(); + assert_eq!(staged.image().unwrap().format, ImageFormat::Png); + assert_eq!(staged.name, "Screenshot 1.png"); + assert!(image::load_from_memory(staged.bytes()).is_ok()); + + let verbatim = stage_file_verbatim(&path).unwrap(); + assert_eq!(verbatim.image().unwrap().format, ImageFormat::Bmp); + assert_eq!(verbatim.name, "Screenshot 1.BMP"); + assert_eq!(verbatim.bytes(), clipboard_bmp()); + } + + #[test] + fn non_bmp_files_are_staged_untouched() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("shot.png"); + image::RgbImage::new(1, 1).save(&path).unwrap(); + let staged = stage_file(&path).unwrap(); + assert_eq!(staged.name, "shot.png"); + assert_eq!(staged.bytes(), std::fs::read(&path).unwrap()); + } + + #[test] + fn undecodable_bmp_is_refused_for_files_and_kept_for_pastes() { + let broken = b"BM not really a bitmap".to_vec(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("broken.bmp"); + std::fs::write(&path, &broken).unwrap(); + assert_eq!( + stage_file(&path).err().unwrap(), + "broken.bmp is not a valid image." + ); + + let pasted = stage_clipboard_image(Image::from_bytes(ImageFormat::Bmp, broken)); + assert_eq!(pasted.image().unwrap().format, ImageFormat::Bmp); + assert_eq!(pasted.name, "image.bmp"); + } + #[test] fn retry_ladder_is_2s_doubling_capped_at_15s() { assert_eq!(retry_delay(0), Duration::from_millis(2_000)); @@ -1396,6 +1765,78 @@ mod generated_image_tests { } } + struct FileRpc(Vec); + + #[async_trait::async_trait] + impl zeron_rpc::RpcService for FileRpc { + async fn handle( + &self, + _: &str, + _: serde_json::Value, + ) -> Result { + zeron_rpc::RpcReply::value(&serde_json::json!({ + "name": "ab12cd34-notes.md", + "mimeType": "application/octet-stream", + "data": BASE64.encode(&self.0), + "nextOffset": self.0.len(), + "done": true, + })) + } + } + + #[test] + fn chips_stand_in_for_their_attachments() { + use zeron_proto::attachment_mentions::attachment_mention_link; + let paths: Vec = [ + "/uploads/ab12cd34-Image_1.png", + "pending://att-2/my notes.md", + "/uploads/ef56ab78-Image_3.png", + "/uploads/0a1b2c3d-Cargo.toml", + ] + .map(String::from) + .to_vec(); + let text = format!( + "compare {} with {}", + attachment_mention_link(1, None), + attachment_mention_link(2, Some("my notes.md")), + ); + // Image 3 and Cargo.toml have no chip, so they keep their tiles. + assert_eq!(unchipped_attachments(&text, &paths), [&paths[2], &paths[3]]); + // A committed upload sanitizes the name the chip still carries. + let committed = vec!["/uploads/12345678-my_notes.md".to_string()]; + assert!(unchipped_attachments(&text, &committed).is_empty()); + // Plain text keeps every tile. + assert_eq!(unchipped_attachments("Image 1", &paths).len(), 4); + } + + #[tokio::test] + async fn a_queued_file_reads_back_with_its_bytes_and_display_name() { + let executor = gpui_platform::background_executor(); + for bytes in [b"# notes\n".to_vec(), Vec::new()] { + let engine = EngineHandle::from_test_client(zeron_rpc::memory_client(Arc::new( + FileRpc(bytes.clone()), + ))); + let file = read_attachment_file(&engine, &executor, None, "/uploads/ab12cd34-notes.md") + .await + .unwrap(); + assert_eq!(file.name, "notes.md"); + assert_eq!(file.bytes(), bytes); + assert!(file.image().is_none()); + // An image read-back never accepts an empty body. + if bytes.is_empty() { + let image = read_attachment_image( + &engine, + &executor, + None, + "/uploads/ab12cd34-notes.md", + None, + ) + .await; + assert!(image.is_none()); + } + } + } + #[tokio::test] async fn generated_image_chunk_reader_targets_owner_and_bounds_decode() { let executor = gpui_platform::background_executor(); diff --git a/crates/ui/src/browser/mod.rs b/crates/ui/src/browser/mod.rs index 6b4cb526a8..83f03f529b 100644 --- a/crates/ui/src/browser/mod.rs +++ b/crates/ui/src/browser/mod.rs @@ -7,6 +7,10 @@ mod macos; use linux as native; #[cfg(target_os = "macos")] use macos as native; +#[cfg(windows)] +mod windows_webview; +#[cfg(windows)] +use windows_webview as native; pub mod model; mod view; @@ -70,12 +74,12 @@ pub enum BrowserEvent { /// A window/profile's ephemeral website data, allocated on first navigation. #[derive(Clone, Default)] pub struct BrowserContext { - #[cfg(any(target_os = "macos", target_os = "linux"))] + #[cfg(any(target_os = "macos", target_os = "linux", windows))] data: native::BrowserData, } pub struct BrowserSurface { - #[cfg(any(target_os = "macos", target_os = "linux"))] + #[cfg(any(target_os = "macos", target_os = "linux", windows))] context: BrowserContext, address: Entity, focus: FocusHandle, @@ -92,19 +96,22 @@ pub struct BrowserSurface { presentation: Presentation, #[cfg(target_os = "macos")] resize_inset: gpui::Pixels, - #[cfg(target_os = "macos")] + #[cfg(any(target_os = "macos", windows))] right_occlusion: gpui::Pixels, _input_sub: Subscription, - #[cfg(any(target_os = "macos", target_os = "linux"))] + #[cfg(any(target_os = "macos", target_os = "linux", windows))] native: Option, - #[cfg(any(target_os = "macos", target_os = "linux"))] + #[cfg(any(target_os = "macos", target_os = "linux", windows))] native_tx: tokio::sync::mpsc::Sender, - #[cfg(any(target_os = "macos", target_os = "linux"))] + #[cfg(any(target_os = "macos", target_os = "linux", windows))] _native_task: gpui::Task<()>, - #[cfg(target_os = "macos")] + #[cfg(any(target_os = "macos", windows))] favicon_task: Option>, - #[cfg(target_os = "macos")] + #[cfg(any(target_os = "macos", windows))] favicon_generation: u64, + /// The page's pointer cursor (Windows: GPUI owns the cursor). + #[cfg(windows)] + page_cursor: gpui::CursorStyle, } impl EventEmitter for BrowserSurface {} @@ -135,9 +142,9 @@ impl BrowserSurface { cx.notify(); } }); - #[cfg(any(target_os = "macos", target_os = "linux"))] + #[cfg(any(target_os = "macos", target_os = "linux", windows))] let (native_tx, mut events) = tokio::sync::mpsc::channel(64); - #[cfg(any(target_os = "macos", target_os = "linux"))] + #[cfg(any(target_os = "macos", target_os = "linux", windows))] let native_task = cx.spawn_in(window, async move |this, cx| { while let Some(event) = events.recv().await { if this @@ -150,10 +157,10 @@ impl BrowserSurface { } } }); - #[cfg(not(any(target_os = "macos", target_os = "linux")))] + #[cfg(not(any(target_os = "macos", target_os = "linux", windows)))] let _ = (window, context); Self { - #[cfg(any(target_os = "macos", target_os = "linux"))] + #[cfg(any(target_os = "macos", target_os = "linux", windows))] context, address, focus: cx.focus_handle(), @@ -170,19 +177,21 @@ impl BrowserSurface { presentation: Presentation::Hidden, #[cfg(target_os = "macos")] resize_inset: gpui::px(0.0), - #[cfg(target_os = "macos")] + #[cfg(any(target_os = "macos", windows))] right_occlusion: gpui::px(0.0), _input_sub: input_sub, - #[cfg(any(target_os = "macos", target_os = "linux"))] + #[cfg(any(target_os = "macos", target_os = "linux", windows))] native: None, - #[cfg(any(target_os = "macos", target_os = "linux"))] + #[cfg(any(target_os = "macos", target_os = "linux", windows))] native_tx, - #[cfg(any(target_os = "macos", target_os = "linux"))] + #[cfg(any(target_os = "macos", target_os = "linux", windows))] _native_task: native_task, - #[cfg(target_os = "macos")] + #[cfg(any(target_os = "macos", windows))] favicon_task: None, - #[cfg(target_os = "macos")] + #[cfg(any(target_os = "macos", windows))] favicon_generation: 0, + #[cfg(windows)] + page_cursor: gpui::CursorStyle::Arrow, } } @@ -193,7 +202,7 @@ impl BrowserSurface { self.remote = remote; } pub fn set_shortcuts(&mut self, keymap: &crate::settings::KeymapConfig) { - #[cfg(target_os = "macos")] + #[cfg(any(target_os = "macos", windows))] if let Some(native) = &self.native { native.set_shortcuts( crate::settings::ShortcutId::ALL @@ -203,12 +212,12 @@ impl BrowserSurface { .collect(), ); } - #[cfg(not(target_os = "macos"))] + #[cfg(not(any(target_os = "macos", windows)))] let _ = keymap; } pub fn focus_address(&mut self, window: &mut Window, cx: &mut Context) { - #[cfg(target_os = "macos")] + #[cfg(any(target_os = "macos", windows))] if let Some(native) = &self.native { native.focus_chrome(); } @@ -226,7 +235,7 @@ impl BrowserSurface { } /// Crop a GPUI overlay out of both native painting and native hit testing. - #[cfg(target_os = "macos")] + #[cfg(any(target_os = "macos", windows))] pub fn set_right_occlusion(&mut self, width: gpui::Pixels, cx: &mut Context) { if self.right_occlusion != width { self.right_occlusion = width; @@ -239,7 +248,7 @@ impl BrowserSurface { return; } self.presentation = presentation; - #[cfg(any(target_os = "macos", target_os = "linux"))] + #[cfg(any(target_os = "macos", target_os = "linux", windows))] if let Some(native) = &mut self.native { native.present(presentation); } @@ -331,9 +340,9 @@ impl BrowserSurface { self.page.title.clear(); self.page.error = None; self.clear_favicon(cx); - #[cfg(any(target_os = "macos", target_os = "linux"))] + #[cfg(any(target_os = "macos", target_os = "linux", windows))] { - #[cfg(target_os = "macos")] + #[cfg(any(target_os = "macos", windows))] { self.favicon_generation += 1; self.favicon_task = None; @@ -354,7 +363,7 @@ impl BrowserSurface { } window.focus(&self.focus, cx); } - #[cfg(not(any(target_os = "macos", target_os = "linux")))] + #[cfg(not(any(target_os = "macos", target_os = "linux", windows)))] { let _ = window; cx.open_url(&url); @@ -369,7 +378,7 @@ impl BrowserSurface { } fn reload(&mut self, cx: &mut Context) { - #[cfg(any(target_os = "macos", target_os = "linux"))] + #[cfg(any(target_os = "macos", target_os = "linux", windows))] if let Some(native) = &self.native { if self.page.error.is_some() { if let Some(url) = &self.page.url { @@ -380,7 +389,7 @@ impl BrowserSurface { } self.page.error = None; } - #[cfg(not(any(target_os = "macos", target_os = "linux")))] + #[cfg(not(any(target_os = "macos", target_os = "linux", windows)))] self.open_external(cx); cx.notify(); } @@ -397,11 +406,11 @@ impl BrowserSurface { } fn history(&mut self, forward: bool) { - #[cfg(any(target_os = "macos", target_os = "linux"))] + #[cfg(any(target_os = "macos", target_os = "linux", windows))] if let Some(native) = &self.native { native.history(forward); } - #[cfg(not(any(target_os = "macos", target_os = "linux")))] + #[cfg(not(any(target_os = "macos", target_os = "linux", windows)))] let _ = forward; } @@ -415,7 +424,7 @@ impl BrowserSurface { pub fn close(&mut self, cx: &mut Context) { self.set_presentation(Presentation::Hidden, cx); self.clear_favicon(cx); - #[cfg(any(target_os = "macos", target_os = "linux"))] + #[cfg(any(target_os = "macos", target_os = "linux", windows))] { if let Some(native) = &mut self.native { native.present(Presentation::Hidden); @@ -425,7 +434,7 @@ impl BrowserSurface { cx.defer(move |cx| gpui::ImageSource::Render(image).evict(None, cx)); } self.native = None; - #[cfg(target_os = "macos")] + #[cfg(any(target_os = "macos", windows))] { self.favicon_task = None; self.favicon_generation += 1; @@ -434,7 +443,7 @@ impl BrowserSurface { } } -#[cfg(target_os = "macos")] +#[cfg(any(target_os = "macos", windows))] impl BrowserSurface { fn on_native_event( &mut self, @@ -495,6 +504,13 @@ impl BrowserSurface { }); } } + #[cfg(windows)] + native::NativeEvent::Cursor(style) => { + if self.page_cursor != style { + self.page_cursor = style; + cx.notify(); + } + } native::NativeEvent::Favicon { page, url } => { if self.page.url.as_deref() != Some(&page) || !model::allowed_navigation(&url) { return; @@ -607,7 +623,11 @@ impl BrowserSurface { self.presentation != Presentation::Hidden && self.native.as_ref().is_some_and(|n| n.image.is_some()) } - #[cfg(not(any(target_os = "macos", target_os = "linux")))] + #[cfg(windows)] + { + self.presentation != Presentation::Hidden && self.native.is_some() + } + #[cfg(not(any(target_os = "macos", target_os = "linux", windows)))] { false } diff --git a/crates/ui/src/browser/model.rs b/crates/ui/src/browser/model.rs index 92d5a94b35..306bc13a70 100644 --- a/crates/ui/src/browser/model.rs +++ b/crates/ui/src/browser/model.rs @@ -137,6 +137,14 @@ pub fn allowed_navigation(address: &str) -> bool { }) } +/// Subframe navigations: web content, including a frame's own documents +/// (`about:blank`, `about:srcdoc`, `data:`, `blob:`) — never a scheme that +/// leaves the page (`file:`, `javascript:`, app links). +pub fn allowed_frame_navigation(address: &str) -> bool { + allowed_navigation(address) + || url::Url::parse(address).is_ok_and(|u| matches!(u.scheme(), "about" | "data" | "blob")) +} + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum Presentation { #[default] @@ -193,6 +201,28 @@ mod tests { assert!(!allowed_navigation("https://user@example.com/")); } #[test] + fn frames_load_web_content_but_never_leave_the_page() { + for ok in [ + "https://example.com/embed", + "about:blank", + "about:srcdoc", + "data:text/html,hi", + "blob:https://example.com/0f3e", + ] { + assert!(allowed_frame_navigation(ok), "{ok}"); + } + for blocked in [ + "zeron://open/chat/a", + "ms-settings:privacy", + "file:///C:/Windows/win.ini", + "javascript:alert(1)", + "mailto:a@example.com", + "", + ] { + assert!(!allowed_frame_navigation(blocked), "{blocked}"); + } + } + #[test] fn native_visibility_never_outlives_its_surface() { assert_eq!(presentation(false, false), Presentation::Hidden); assert_eq!(presentation(false, true), Presentation::Hidden); diff --git a/crates/ui/src/browser/view.rs b/crates/ui/src/browser/view.rs index 47d268754b..f968cfddf2 100644 --- a/crates/ui/src/browser/view.rs +++ b/crates/ui/src/browser/view.rs @@ -284,7 +284,7 @@ impl BrowserSurface { if self.page.url.is_none() && self.previews_task.is_some() { return self.preview_body(theme, cx); } - let external = !cfg!(any(target_os = "macos", target_os = "linux")); + let external = !cfg!(any(target_os = "macos", target_os = "linux", windows)); let has_error = self.page.error.is_some(); let title = if has_error { "Couldn’t load this page" @@ -399,11 +399,140 @@ impl BrowserSurface { } } +#[cfg(windows)] +impl BrowserSurface { + /// The page composites natively beneath GPUI's overlay plane; this + /// element only positions it and forwards pointer input that GPUI's hit + /// testing delivers here, so popovers above the page occlude naturally. + fn windows_page( + &self, + body: gpui::Stateful, + host: std::rc::Rc, + cx: &mut Context, + ) -> gpui::Stateful { + use super::native::{PointerKind, wheel_units}; + let entity = cx.entity().downgrade(); + let forward = |kind: PointerKind| { + move |this: &mut Self, + position: gpui::Point, + button: Option, + modifiers: gpui::Modifiers, + clicks: usize| { + if let Some(native) = &this.native { + native.pointer(kind, position, button, modifiers, clicks); + } + } + }; + let right_occlusion = self.right_occlusion; + let mut body = body.cursor(self.page_cursor).child( + gpui::canvas( + |_, _, _| (), + move |bounds, _, window, _| { + // The Files panel slides over the page from the right. + let mut mask = window.content_mask().bounds; + let right = (window.viewport_size().width - right_occlusion).max(mask.left()); + mask.size.width = mask.size.width.min(right - mask.left()); + let scale = window.scale_factor(); + let composition = window.native_composition(); + let page = std::rc::Rc::downgrade(&host); + window.on_present(move || { + if let Some(page) = page.upgrade() { + page.sync(bounds, mask, scale, composition); + } + }); + // A drag that began in the page keeps extending its + // selection outside the page, like a native child. + let capture = entity.clone(); + window.on_mouse_event(move |event: &gpui::MouseMoveEvent, phase, _, cx| { + if phase == gpui::DispatchPhase::Bubble + && !bounds.contains(&event.position) + && !cx.has_active_drag() + { + let _ = capture.update(cx, |this, _| { + if let Some(native) = + this.native.as_ref().filter(|native| native.pressed()) + { + native.pointer( + PointerKind::Move, + event.position, + None, + event.modifiers, + 0, + ); + } + }); + } + }); + }, + ) + .absolute() + .inset_0(), + ); + for button in [MouseButton::Left, MouseButton::Right, MouseButton::Middle] { + let down = forward(PointerKind::Down); + let up = forward(PointerKind::Up); + let up_out = forward(PointerKind::Up); + body = body + .on_mouse_down( + button, + cx.listener(move |this, event: &gpui::MouseDownEvent, window, cx| { + if cx.has_active_drag() { + return; + } + window.focus(&this.focus, cx); + down( + this, + event.position, + Some(event.button), + event.modifiers, + event.click_count, + ); + cx.stop_propagation(); + }), + ) + .on_mouse_up( + button, + cx.listener(move |this, event: &gpui::MouseUpEvent, _, cx| { + up(this, event.position, Some(event.button), event.modifiers, 0); + cx.stop_propagation(); + }), + ) + .on_mouse_up_out( + button, + cx.listener(move |this, event: &gpui::MouseUpEvent, _, _| { + if this.native.as_ref().is_some_and(|native| native.pressed()) { + up_out(this, event.position, Some(event.button), event.modifiers, 0); + } + }), + ); + } + let moved = forward(PointerKind::Move); + body.on_mouse_move( + cx.listener(move |this, event: &gpui::MouseMoveEvent, _, cx| { + if !cx.has_active_drag() { + moved(this, event.position, None, event.modifiers, 0); + } + }), + ) + .on_scroll_wheel(cx.listener(|this, event: &gpui::ScrollWheelEvent, _, cx| { + if let Some(native) = &this.native { + native.wheel(event.position, wheel_units(event.delta), event.modifiers); + cx.stop_propagation(); + } + })) + .on_hover(cx.listener(|this, hovered: &bool, _, _| { + if !*hovered && let Some(native) = &this.native { + native.leave(); + } + })) + } +} + impl Render for BrowserSurface { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let theme = Theme::of(cx).clone(); let focused = self.address.focus_handle(cx).is_focused(window); - let external = !cfg!(any(target_os = "macos", target_os = "linux")); + let external = !cfg!(any(target_os = "macos", target_os = "linux", windows)); let has_page = self.page.url.is_some(); let back = button( "browser-back", @@ -446,11 +575,11 @@ impl Render for BrowserSurface { .on_mouse_down( MouseButton::Left, cx.listener(|this, _, _, _| { - #[cfg(target_os = "macos")] + #[cfg(any(target_os = "macos", windows))] if let Some(native) = &this.native { native.focus_chrome(); } - #[cfg(not(target_os = "macos"))] + #[cfg(not(any(target_os = "macos", windows)))] let _ = this; }), ) @@ -613,7 +742,17 @@ impl Render for BrowserSurface { } else { body.child(self.empty_body(&theme, cx)) }; - #[cfg(not(any(target_os = "macos", target_os = "linux")))] + #[cfg(windows)] + let body = if let Some(native) = &self.native { + if self.page.error.is_some() { + body.child(self.empty_body(&theme, cx)) + } else { + self.windows_page(body, native.handle(), cx) + } + } else { + body.child(self.empty_body(&theme, cx)) + }; + #[cfg(not(any(target_os = "macos", target_os = "linux", windows)))] let body = body.child(self.empty_body(&theme, cx)); #[cfg(target_os = "linux")] diff --git a/crates/ui/src/browser/windows_webview.rs b/crates/ui/src/browser/windows_webview.rs new file mode 100644 index 0000000000..d96594be75 --- /dev/null +++ b/crates/ui/src/browser/windows_webview.rs @@ -0,0 +1,1360 @@ +//! Windows boundary for the browser: WebView2 in visual-hosting mode. Each +//! page is a DirectComposition visual mounted in the zui overlay plane's +//! native layer, so DWM composites it between GPUI's base and overlay planes +//! without copies or child windows. GPUI keeps every pointer event and +//! forwards only those that reach the page; the page owns the keyboard while +//! focused. Callbacks enqueue events and never re-enter GPUI. +use super::model::{PageState, Presentation, allowed_frame_navigation, allowed_navigation}; +use gpui::{Bounds, CursorStyle, Keystroke, Modifiers, MouseButton, Pixels, Point, Window}; +use std::{ + cell::{Cell, RefCell}, + rc::Rc, +}; +use webview2_com::{ + AcceleratorKeyPressedEventHandler, CreateCoreWebView2CompositionControllerCompletedHandler, + CreateCoreWebView2EnvironmentCompletedHandler, CursorChangedEventHandler, + DocumentTitleChangedEventHandler, DownloadStartingEventHandler, FaviconChangedEventHandler, + HistoryChangedEventHandler, LaunchingExternalUriSchemeEventHandler, + Microsoft::Web::WebView2::Win32::*, NavigationCompletedEventHandler, + NavigationStartingEventHandler, NewWindowRequestedEventHandler, + PermissionRequestedEventHandler, ProcessFailedEventHandler, SourceChangedEventHandler, + take_pwstr, +}; +use windows::{ + Win32::{ + Foundation::{HWND, POINT, RECT}, + Graphics::{Direct2D::Common::D2D_RECT_F, DirectComposition::*}, + UI::Input::KeyboardAndMouse::{ + GetKeyState, VIRTUAL_KEY, VK_CONTROL, VK_LWIN, VK_MENU, VK_RWIN, VK_SHIFT, + }, + }, + core::{HSTRING, Interface, PWSTR}, +}; + +type Sender = tokio::sync::mpsc::Sender; + +pub(super) enum NativeEvent { + Changed, + Finished, + NewTab(String), + Key(Keystroke), + Favicon { page: String, url: String }, + Cursor(CursorStyle), +} + +// --------------------------------------------------------------------------- +// Shared environment: one browser process for a window's tabs +// --------------------------------------------------------------------------- + +type EnvironmentWaiter = Box)>; + +#[derive(Default)] +enum Environment { + #[default] + Idle, + Pending(Vec), + Ready(ICoreWebView2Environment), + Failed(String), +} + +struct EnvironmentState { + environment: Environment, + /// Set once the shared folder answered ERROR_BUSY (see [`is_busy`]). + own_folder: bool, + /// InPrivate profile of this window/profile's pages. Every InPrivate + /// controller with the same profile name shares one cookie jar — across + /// windows, profile switches and processes sharing the folder — so each + /// `BrowserData` gets its own, as macOS gets a fresh non-persistent store. + profile: String, +} + +impl Default for EnvironmentState { + fn default() -> Self { + static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let started = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |elapsed| elapsed.as_nanos()); + Self { + environment: Environment::default(), + own_folder: false, + profile: format!( + "zeron-{}-{started}-{}", + std::process::id(), + NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + ), + } + } +} + +/// A window/profile's WebView2 environment, created on first use: one +/// browser process for all of its tabs. Pages are InPrivate, so nothing +/// outlives the session (as macOS's non-persistent store); the user-data +/// folder only holds the runtime's own caches. +#[derive(Clone, Default)] +pub(super) struct BrowserData(Rc>); + +impl BrowserData { + fn with_environment( + &self, + done: impl FnOnce(Result) + 'static, + ) { + let mut state = self.0.borrow_mut(); + match &mut state.environment { + Environment::Ready(environment) => { + let environment = environment.clone(); + drop(state); + done(Ok(environment)); + } + Environment::Failed(error) => { + let error = error.clone(); + drop(state); + done(Err(error)); + } + Environment::Pending(waiters) => waiters.push(Box::new(done)), + Environment::Idle => { + state.environment = Environment::Pending(vec![Box::new(done)]); + drop(state); + create_environment(Rc::downgrade(&self.0)); + } + } + } + + /// Drop `environment` after its browser process exited, so the next page + /// starts a fresh one. A newer environment is left alone. + fn forget(&self, environment: &ICoreWebView2Environment) { + let mut state = self.0.borrow_mut(); + if matches!(&state.environment, Environment::Ready(ready) if ready == environment) { + state.environment = Environment::Idle; + } + } + + fn profile(&self) -> String { + self.0.borrow().profile.clone() + } + + /// Move this process to its own folder after the shared one was busy. + /// Returns false when that already happened (no further retries). + fn fall_back(&self) -> bool { + let mut state = self.0.borrow_mut(); + if state.own_folder { + return false; + } + state.own_folder = true; + if !matches!(state.environment, Environment::Pending(_)) { + state.environment = Environment::Idle; + } + true + } +} + +/// A folder still held by another process (a second Zeron, or one shutting +/// down after a quick restart) answers ERROR_BUSY at any creation step. +fn is_busy(error: &windows::core::Error) -> bool { + error.code() == windows::core::HRESULT(0x8007_00AA_u32 as i32) +} + +/// The runtime's caches are shared across launches for a warm start; a +/// busy shared folder sends this process to its own. +fn user_data_folder(own: bool) -> std::path::PathBuf { + let temp = std::env::temp_dir(); + if !own { + return temp.join("zeron-webview2"); + } + // Sweep folders of exited processes. Only those: removal deletes what + // it can before failing on a locked file, so a live instance's folder + // would lose its unlocked files. + let pid = std::process::id(); + let sweep = temp.clone(); + std::thread::spawn(move || { + for entry in std::fs::read_dir(&sweep).into_iter().flatten().flatten() { + let name = entry.file_name(); + let owner = name + .to_str() + .and_then(|name| name.strip_prefix("zeron-webview2-")) + .and_then(|owner| owner.parse::().ok()); + if let Some(owner) = owner + && owner != pid + && !process_alive(owner) + { + let _ = std::fs::remove_dir_all(entry.path()); + } + } + }); + temp.join(format!("zeron-webview2-{pid}")) +} + +/// Whether `pid` names a running process (pids are reused, so a false +/// "alive" only leaves a stale folder for a later sweep). +fn process_alive(pid: u32) -> bool { + use windows_sys::Win32::{ + Foundation::{CloseHandle, STILL_ACTIVE}, + System::Threading::{GetExitCodeProcess, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION}, + }; + unsafe { + let process = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid); + if process.is_null() { + return false; + } + let mut code = 0u32; + let alive = GetExitCodeProcess(process, &mut code) != 0 && code == STILL_ACTIVE as u32; + CloseHandle(process); + alive + } +} + +fn create_environment(shared: std::rc::Weak>) { + let Some(state) = shared.upgrade() else { + return; + }; + let own = state.borrow().own_folder; + drop(state); + let finish = |shared: &std::rc::Weak>, + outcome: Result| { + let Some(shared) = shared.upgrade() else { + return; + }; + let next = match &outcome { + Ok(environment) => Environment::Ready(environment.clone()), + Err(error) => Environment::Failed(error.clone()), + }; + let waiters = match std::mem::replace(&mut shared.borrow_mut().environment, next) { + Environment::Pending(waiters) => waiters, + _ => Vec::new(), + }; + for waiter in waiters { + waiter(outcome.clone()); + } + }; + let retry = |shared: std::rc::Weak>| { + if let Some(state) = shared.upgrade() { + state.borrow_mut().own_folder = true; + } + create_environment(shared); + }; + let callback = shared.clone(); + let handler = CreateCoreWebView2EnvironmentCompletedHandler::create(Box::new( + move |result, environment| { + match result.and_then(|()| environment.ok_or_else(windows::core::Error::empty)) { + Ok(environment) => finish(&callback, Ok(environment)), + Err(error) if !own && is_busy(&error) => retry(callback), + Err(error) => finish(&callback, Err(runtime_error(&error))), + } + Ok(()) + }, + )); + let created = unsafe { + CreateCoreWebView2EnvironmentWithOptions( + None, + &HSTRING::from(user_data_folder(own).to_string_lossy().as_ref()), + None::<&ICoreWebView2EnvironmentOptions>, + &handler, + ) + }; + match created { + Ok(()) => {} + Err(error) if !own && is_busy(&error) => retry(shared), + Err(error) => finish(&shared, Err(runtime_error(&error))), + } +} + +fn runtime_error(error: &windows::core::Error) -> String { + tracing::warn!(%error, "WebView2 unavailable"); + "The Microsoft Edge WebView2 Runtime is required to show pages here.".into() +} + +// --------------------------------------------------------------------------- +// Page +// --------------------------------------------------------------------------- + +/// COM objects of a created page. +struct Page { + /// The environment the page lives in (forgotten if its browser exits). + environment: ICoreWebView2Environment, + composition: ICoreWebView2CompositionController, + controller: ICoreWebView2Controller, + webview: ICoreWebView2, + /// The page's root visual and the layer it mounts in, from `generation`. + visual: Option<(IDCompositionVisual, IDCompositionVisual, u64)>, + mounted: bool, + /// What was last handed to WebView2 and the visual, so an unchanged + /// frame makes no calls at all. + applied: Option, + shown: Option, +} + +#[derive(Clone, Copy, PartialEq)] +struct Applied { + origin: (f32, f32), + size: (i32, i32), + clip: [f32; 4], + scale: f32, +} + +#[derive(Default)] +struct Geometry { + /// Page bounds and their visible part, in physical pixels. + origin: (f32, f32), + size: (i32, i32), + clip: D2D_RECT_F, + scale: f32, +} + +pub(super) struct Host { + hwnd: HWND, + tx: Sender, + /// Where pages are created (again, after the browser process exited). + data: BrowserData, + page: RefCell>, + pending_url: RefCell>, + requested_url: RefCell>, + error: RefCell>, + loading: Cell, + changed_pending: Cell, + presentation: Cell, + geometry: RefCell, + has_area: Cell, + /// Pointer buttons pressed inside the page (forwarded until released). + pressed: Cell, + hovered: Cell, + shortcuts: RefCell>, +} + +pub(super) struct NativePage(Rc); + +impl NativePage { + pub fn new(window: &Window, data: &BrowserData, tx: Sender) -> Result { + use raw_window_handle::{HasWindowHandle, RawWindowHandle}; + let hwnd = match HasWindowHandle::window_handle(window) + .map_err(|error| error.to_string())? + .as_raw() + { + RawWindowHandle::Win32(handle) => HWND(handle.hwnd.get() as *mut _), + _ => return Err("Browser requires a Win32 window".into()), + }; + window + .enable_scene_overlay() + .map_err(|error| error.to_string())?; + let host = Rc::new(Host { + hwnd, + tx, + data: data.clone(), + page: RefCell::new(None), + pending_url: RefCell::new(None), + requested_url: RefCell::new(None), + error: RefCell::new(None), + loading: Cell::new(true), + changed_pending: Cell::new(false), + presentation: Cell::new(Presentation::Hidden), + geometry: RefCell::new(Geometry::default()), + has_area: Cell::new(false), + pressed: Cell::new(0), + hovered: Cell::new(false), + shortcuts: RefCell::new(Vec::new()), + }); + Host::start(&host, data.clone()); + Ok(Self(host)) + } + + pub fn handle(&self) -> Rc { + self.0.clone() + } + + pub fn set_shortcuts(&self, shortcuts: Vec) { + *self.0.shortcuts.borrow_mut() = shortcuts + .iter() + .filter_map(|s| Keystroke::parse(s).ok()) + .collect(); + } + + pub fn present(&mut self, presentation: Presentation) { + self.0.presentation.set(presentation); + if presentation != Presentation::Live { + self.0.leave(); + } + self.0.update_visibility(); + } + + pub fn load(&self, url: &str) -> Result<(), String> { + self.0.error.borrow_mut().take(); + *self.0.requested_url.borrow_mut() = Some(url.into()); + self.0.loading.set(true); + match &*self.0.page.borrow() { + Some(page) => unsafe { page.webview.Navigate(&HSTRING::from(url)) } + .map_err(|error| error.message()), + None => { + *self.0.pending_url.borrow_mut() = Some(url.into()); + Ok(()) + } + } + } + + pub fn reload(&self) { + let failed = self.0.error.borrow_mut().take().is_some(); + if failed && self.0.page.borrow().is_none() { + // Nothing is being created (that ends in a page or an error): + // start over, in a fresh environment if the old one died. + self.0.loading.set(true); + Host::start(&self.0, self.0.data.clone()); + } + if let Some(page) = &*self.0.page.borrow() { + // Retrying the requested URL also works after a failed load. + let failed_url = self.0.requested_url.borrow().clone(); + unsafe { + let _ = match failed_url { + Some(url) if self.0.page_url().is_none() => { + page.webview.Navigate(&HSTRING::from(url)) + } + _ => page.webview.Reload(), + }; + } + } + self.0.update_visibility(); + self.0.changed(); + } + + pub fn history(&self, forward: bool) { + if let Some(page) = &*self.0.page.borrow() { + unsafe { + let _ = if forward { + page.webview.GoForward() + } else { + page.webview.GoBack() + }; + } + } + } + + pub fn state(&self) -> PageState { + let host = &self.0; + host.changed_pending.set(false); + let (title, can_back, can_forward) = match &*host.page.borrow() { + Some(page) => unsafe { + let mut title = PWSTR::null(); + let mut back = windows::core::BOOL::default(); + let mut forward = windows::core::BOOL::default(); + let _ = page.webview.DocumentTitle(&mut title); + let _ = page.webview.CanGoBack(&mut back); + let _ = page.webview.CanGoForward(&mut forward); + (take_pwstr(title), back.as_bool(), forward.as_bool()) + }, + None => (String::new(), false, false), + }; + PageState { + url: host + .requested_url + .borrow() + .clone() + .or_else(|| host.page_url()), + title, + loading: host.loading.get(), + can_back, + can_forward, + error: host.error.borrow().clone(), + } + } + + pub fn discover_favicon(&self, page_url: String) { + if let Some(page) = &*self.0.page.borrow() + && let Ok(webview) = page.webview.cast::() + { + let mut uri = PWSTR::null(); + if unsafe { webview.FaviconUri(&mut uri) }.is_ok() { + let url = take_pwstr(uri); + if !url.is_empty() { + let _ = self.0.tx.try_send(NativeEvent::Favicon { + page: page_url, + url, + }); + } + } + } + } + + /// Return the keyboard to GPUI chrome (the address bar). + pub fn focus_chrome(&self) { + use windows::Win32::UI::{ + Input::KeyboardAndMouse::{GetFocus, SetFocus}, + WindowsAndMessaging::IsChild, + }; + unsafe { + let focus = GetFocus(); + if !focus.is_invalid() && focus != self.0.hwnd && IsChild(self.0.hwnd, focus).as_bool() + { + let _ = SetFocus(Some(self.0.hwnd)); + } + } + } + + /// A press, release or move at `position` (window coordinates). + pub fn pointer( + &self, + kind: PointerKind, + position: Point, + button: Option, + modifiers: Modifiers, + click_count: usize, + ) { + self.0 + .pointer(kind, position, button, modifiers, click_count); + } + + pub fn wheel(&self, position: Point, delta: Point, modifiers: Modifiers) { + self.0.wheel(position, delta, modifiers); + } + + pub fn leave(&self) { + self.0.leave(); + } + + pub fn pressed(&self) -> bool { + self.0.pressed.get() != 0 + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub(super) enum PointerKind { + Down, + Up, + Move, +} + +impl Host { + /// Create the page in `data`'s environment. A busy folder at any step + /// moves the window to its own folder and retries once. + fn start(host: &Rc, data: BrowserData) { + let weak = Rc::downgrade(host); + data.clone().with_environment(move |environment| { + let Some(host) = weak.upgrade() else { return }; + match environment { + Ok(environment) => { + if let Err(error) = Host::create_controller(&host, &environment, data) { + host.fail(&runtime_error(&error)); + } + } + Err(error) => host.fail(&error), + } + }); + } + + fn create_controller( + host: &Rc, + environment: &ICoreWebView2Environment, + data: BrowserData, + ) -> windows::core::Result<()> { + let profile = HSTRING::from(data.profile()); + let attach_environment = environment.clone(); + let weak = Rc::downgrade(host); + let handler = CreateCoreWebView2CompositionControllerCompletedHandler::create(Box::new( + move |result, composition| { + let Some(host) = weak.upgrade() else { + return Ok(()); + }; + match result.and_then(|()| composition.ok_or_else(windows::core::Error::empty)) { + Ok(composition) => { + if let Err(error) = Host::attach(&host, composition, &attach_environment) { + host.fail(&runtime_error(&error)); + } + } + Err(error) if is_busy(&error) && data.fall_back() => Host::start(&host, data), + Err(error) => host.fail(&runtime_error(&error)), + } + Ok(()) + }, + )); + // InPrivate in this window's own profile, or not at all: a runtime + // without controller options would persist cookies in the folder. + unsafe { + let environment = environment.cast::()?; + let options = environment.CreateCoreWebView2ControllerOptions()?; + options.SetIsInPrivateModeEnabled(true)?; + options.SetProfileName(&profile)?; + environment + .CreateCoreWebView2CompositionControllerWithOptions(host.hwnd, &options, &handler) + } + } + + fn attach( + host: &Rc, + composition: ICoreWebView2CompositionController, + environment: &ICoreWebView2Environment, + ) -> windows::core::Result<()> { + let controller: ICoreWebView2Controller = composition.cast()?; + let webview = unsafe { controller.CoreWebView2()? }; + unsafe { + let settings = webview.Settings()?; + settings.SetAreDevToolsEnabled(false)?; + // No page↔host channel exists; keep it that way. + settings.SetIsWebMessageEnabled(false)?; + settings.SetAreHostObjectsAllowed(false)?; + settings.SetIsStatusBarEnabled(false)?; + settings.SetIsZoomControlEnabled(true)?; + if let Ok(controller) = controller.cast::() { + // GPUI owns DPI: it re-syncs the scale with the bounds. + controller.SetShouldDetectMonitorScaleChanges(false)?; + } + controller.SetIsVisible(false)?; + let mut token = 0i64; + let weak = Rc::downgrade(host); + webview.add_NavigationStarting( + &NavigationStartingEventHandler::create(Box::new(move |_, args| { + if let (Some(host), Some(args)) = (weak.upgrade(), args) { + // Fail closed: an unreadable target is cancelled. + let mut uri = PWSTR::null(); + let uri = args.Uri(&mut uri).map(|()| take_pwstr(uri)); + match uri { + Ok(uri) if allowed_navigation(&uri) => { + *host.requested_url.borrow_mut() = Some(uri); + host.error.borrow_mut().take(); + host.loading.set(true); + host.changed(); + } + _ => args.SetCancel(true)?, + } + } + Ok(()) + })), + &mut token, + )?; + // Subframes too (the main-frame event does not see them): web + // content only — their own documents (about:blank, srcdoc, data:, + // blob:) included — never a scheme that leaves the page. + webview.add_FrameNavigationStarting( + &NavigationStartingEventHandler::create(Box::new(move |_, args| { + if let Some(args) = args { + let mut uri = PWSTR::null(); + let allowed = args + .Uri(&mut uri) + .is_ok_and(|()| allowed_frame_navigation(&take_pwstr(uri))); + if !allowed { + args.SetCancel(true)?; + } + } + Ok(()) + })), + &mut token, + )?; + // Never hand a page's link to another app (zeron://, ms-*, mailto:) + // from inside Zeron, prompt or not. + if let Ok(webview18) = webview.cast::() { + webview18.add_LaunchingExternalUriScheme( + &LaunchingExternalUriSchemeEventHandler::create(Box::new(move |_, args| { + if let Some(args) = args { + args.SetCancel(true)?; + } + Ok(()) + })), + &mut token, + )?; + } + // Camera, microphone, location, notifications, clipboard reads: + // nothing a preview needs, and no prompt that looks like Zeron's. + webview.add_PermissionRequested( + &PermissionRequestedEventHandler::create(Box::new(move |_, args| { + if let Some(args) = args { + args.SetState(COREWEBVIEW2_PERMISSION_STATE_DENY)?; + } + Ok(()) + })), + &mut token, + )?; + let weak = Rc::downgrade(host); + webview.add_SourceChanged( + &SourceChangedEventHandler::create(Box::new(move |_, _| { + if let Some(host) = weak.upgrade() { + host.requested_url.borrow_mut().take(); + host.changed(); + } + Ok(()) + })), + &mut token, + )?; + let weak = Rc::downgrade(host); + webview.add_HistoryChanged( + &HistoryChangedEventHandler::create(Box::new(move |_, _| { + if let Some(host) = weak.upgrade() { + host.changed(); + } + Ok(()) + })), + &mut token, + )?; + let weak = Rc::downgrade(host); + webview.add_DocumentTitleChanged( + &DocumentTitleChangedEventHandler::create(Box::new(move |_, _| { + if let Some(host) = weak.upgrade() { + host.changed(); + } + Ok(()) + })), + &mut token, + )?; + let weak = Rc::downgrade(host); + webview.add_NavigationCompleted( + &NavigationCompletedEventHandler::create(Box::new(move |_, args| { + let Some(host) = weak.upgrade() else { + return Ok(()); + }; + host.loading.set(false); + if let Some(args) = args { + let mut success = windows::core::BOOL::default(); + args.IsSuccess(&mut success)?; + if !success.as_bool() { + let mut status = COREWEBVIEW2_WEB_ERROR_STATUS::default(); + args.WebErrorStatus(&mut status)?; + if let Some(message) = navigation_error(status) { + tracing::warn!(status = status.0, "browser navigation failed"); + *host.error.borrow_mut() = Some(message.into()); + host.update_visibility(); + } + } + } + host.changed(); + let _ = host.tx.try_send(NativeEvent::Finished); + Ok(()) + })), + &mut token, + )?; + let weak = Rc::downgrade(host); + webview.add_NewWindowRequested( + &NewWindowRequestedEventHandler::create(Box::new(move |_, args| { + if let (Some(host), Some(args)) = (weak.upgrade(), args) { + // Handled first: no default popup window, whatever + // fails below. Only a click opens a tab — WebView2 + // has no popup blocker, and each opened tab could + // open more. + args.SetHandled(true)?; + let mut clicked = windows::core::BOOL::default(); + args.IsUserInitiated(&mut clicked)?; + let mut uri = PWSTR::null(); + args.Uri(&mut uri)?; + let uri = take_pwstr(uri); + if clicked.as_bool() && allowed_navigation(&uri) { + let _ = host.tx.try_send(NativeEvent::NewTab(uri)); + } + } + Ok(()) + })), + &mut token, + )?; + let weak = Rc::downgrade(host); + webview.add_ProcessFailed( + &ProcessFailedEventHandler::create(Box::new(move |_, args| { + let (Some(host), Some(args)) = (weak.upgrade(), args) else { + return Ok(()); + }; + let mut kind = COREWEBVIEW2_PROCESS_FAILED_KIND::default(); + args.ProcessFailedKind(&mut kind)?; + match kind { + // The whole environment is gone: every page of it + // needs a new one, which Reload then creates. + COREWEBVIEW2_PROCESS_FAILED_KIND_BROWSER_PROCESS_EXITED => { + host.browser_exited(); + } + // Reload recreates the renderer. + COREWEBVIEW2_PROCESS_FAILED_KIND_RENDER_PROCESS_EXITED + | COREWEBVIEW2_PROCESS_FAILED_KIND_RENDER_PROCESS_UNRESPONSIVE => { + host.fail("The page stopped responding. Reload to continue."); + } + // GPU, utility and subframe processes are restarted + // by WebView2; the page keeps working. + kind => tracing::debug!(kind = kind.0, "browser helper process failed"), + } + Ok(()) + })), + &mut token, + )?; + if let Ok(webview4) = webview.cast::() { + let weak = Rc::downgrade(host); + webview4.add_DownloadStarting( + &DownloadStartingEventHandler::create(Box::new(move |_, args| { + let Some(args) = args else { + return Ok(()); + }; + args.SetCancel(true)?; + let mut uri = PWSTR::null(); + let uri = args + .DownloadOperation() + .and_then(|download| download.Uri(&mut uri)) + .map(|()| take_pwstr(uri)) + .unwrap_or_default(); + let Some(host) = weak.upgrade() else { + return Ok(()); + }; + // Only a page that turned out to be a file is an error + // (as on macOS); a download the page started leaves it + // as it was. + let requested = host.requested_url.borrow().clone(); + if requested.is_some_and(|requested| requested == uri) { + host.fail( + "This file can’t be previewed here. Open it in your default browser.", + ); + } else { + host.loading.set(false); + host.changed(); + } + Ok(()) + })), + &mut token, + )?; + } + if let Ok(webview15) = webview.cast::() { + let weak = Rc::downgrade(host); + webview15.add_FaviconChanged( + &FaviconChangedEventHandler::create(Box::new(move |sender, _| { + let (Some(host), Some(sender)) = (weak.upgrade(), sender) else { + return Ok(()); + }; + let mut uri = PWSTR::null(); + let mut source = PWSTR::null(); + if let Ok(sender) = sender.cast::() { + sender.FaviconUri(&mut uri)?; + } + sender.Source(&mut source)?; + let (url, page) = (take_pwstr(uri), take_pwstr(source)); + if !url.is_empty() { + let _ = host.tx.try_send(NativeEvent::Favicon { page, url }); + } + Ok(()) + })), + &mut token, + )?; + } + let weak = Rc::downgrade(host); + composition.add_CursorChanged( + &CursorChangedEventHandler::create(Box::new(move |sender, _| { + if let (Some(host), Some(sender)) = (weak.upgrade(), sender) { + let mut id = 0u32; + sender.SystemCursorId(&mut id)?; + let _ = host.tx.try_send(NativeEvent::Cursor(cursor_style(id))); + } + Ok(()) + })), + &mut token, + )?; + let weak = Rc::downgrade(host); + controller.add_AcceleratorKeyPressed( + &AcceleratorKeyPressedEventHandler::create(Box::new(move |_, args| { + let (Some(host), Some(args)) = (weak.upgrade(), args) else { + return Ok(()); + }; + let mut kind = COREWEBVIEW2_KEY_EVENT_KIND::default(); + args.KeyEventKind(&mut kind)?; + if kind != COREWEBVIEW2_KEY_EVENT_KIND_KEY_DOWN + && kind != COREWEBVIEW2_KEY_EVENT_KIND_SYSTEM_KEY_DOWN + { + return Ok(()); + } + let mut key = 0u32; + args.VirtualKey(&mut key)?; + if let Some(keystroke) = accelerator(key) + && host.claims(&keystroke) + && host.tx.try_send(NativeEvent::Key(keystroke)).is_ok() + { + args.SetHandled(true)?; + } + Ok(()) + })), + &mut token, + )?; + } + *host.page.borrow_mut() = Some(Page { + environment: environment.clone(), + composition, + controller, + webview, + visual: None, + mounted: false, + applied: None, + shown: None, + }); + host.apply_geometry(); + host.update_visibility(); + if let Some(url) = host.pending_url.borrow_mut().take() + && let Some(page) = &*host.page.borrow() + { + unsafe { + let _ = page.webview.Navigate(&HSTRING::from(url)); + } + } + host.changed(); + Ok(()) + } + + fn page_url(&self) -> Option { + let page = self.page.borrow(); + let page = page.as_ref()?; + let mut source = PWSTR::null(); + unsafe { page.webview.Source(&mut source) }.ok()?; + Some(take_pwstr(source)).filter(|url| !url.is_empty() && url != "about:blank") + } + + fn changed(&self) { + if !self.changed_pending.replace(true) && self.tx.try_send(NativeEvent::Changed).is_err() { + self.changed_pending.set(false); + } + } + + fn fail(&self, message: &str) { + *self.error.borrow_mut() = Some(message.into()); + self.loading.set(false); + self.update_visibility(); + self.changed(); + } + + /// The page's browser process exited: release the dead page and its + /// environment, and keep the URL so Reload rebuilds the page there. + fn browser_exited(&self) { + let url = self + .requested_url + .borrow() + .clone() + .or_else(|| self.page_url()); + if let Some(page) = self.close_page() { + self.data.forget(&page.environment); + } + *self.pending_url.borrow_mut() = url; + self.fail("The browser stopped. Reload to continue."); + } + + fn close_page(&self) -> Option { + let page = self.page.borrow_mut().take()?; + unsafe { + if let Some((visual, layer, _)) = &page.visual + && page.mounted + { + let _ = layer.RemoveVisual(visual); + } + let _ = page.controller.Close(); + } + Some(page) + } + + /// Browser and app shortcuts go to GPUI even while the page has focus. + fn claims(&self, keystroke: &Keystroke) -> bool { + let browser = [ + "ctrl-l", "ctrl-t", "ctrl-w", "ctrl-[", "ctrl-]", "ctrl-k", "ctrl-,", + ] + .iter() + .any(|combo| Keystroke::parse(combo).is_ok_and(|k| &k == keystroke)); + browser || self.shortcuts.borrow().iter().any(|k| k == keystroke) + } + + /// Mount, position and clip the page for a painted frame. Runs from the + /// frame's presentation callback; the renderer commits it with the frame. + pub fn sync( + &self, + bounds: Bounds, + mask: Bounds, + scale: f32, + composition: Option, + ) { + let visible = bounds.intersect(&mask); + { + let mut geometry = self.geometry.borrow_mut(); + let x = f32::from(bounds.origin.x) * scale; + let y = f32::from(bounds.origin.y) * scale; + geometry.origin = (x.round(), y.round()); + geometry.size = ( + (f32::from(bounds.size.width) * scale).round().max(0.) as i32, + (f32::from(bounds.size.height) * scale).round().max(0.) as i32, + ); + geometry.clip = D2D_RECT_F { + left: (f32::from(visible.origin.x) * scale - x).max(0.), + top: (f32::from(visible.origin.y) * scale - y).max(0.), + right: (f32::from(visible.origin.x + visible.size.width) * scale - x).max(0.), + bottom: (f32::from(visible.origin.y + visible.size.height) * scale - y).max(0.), + }; + geometry.scale = scale; + } + self.has_area + .set(f32::from(visible.size.width) > 0. && f32::from(visible.size.height) > 0.); + if let Some(composition) = composition { + self.remount(composition); + } + self.apply_geometry(); + self.update_visibility(); + } + + /// (Re)create the page visual when the renderer's device generation + /// changes (first frame, or GPU recovery). + fn remount(&self, composition: gpui::NativeComposition) { + let mut page = self.page.borrow_mut(); + let Some(page) = page.as_mut() else { return }; + // Handles are only valid for their generation, and the renderer frees + // older ones; a callback carrying an older generation than the mounted + // one must not touch its pointers. + if page + .visual + .as_ref() + .is_some_and(|(_, _, generation)| *generation >= composition.generation) + { + return; + } + let mounted = unsafe { + let (Some(device), Some(layer)) = ( + IDCompositionDevice::from_raw_borrowed(&composition.device), + IDCompositionVisual::from_raw_borrowed(&composition.layer), + ) else { + return; + }; + device.CreateVisual().and_then(|visual| { + page.composition.SetRootVisualTarget(&visual)?; + Ok((visual, layer.clone())) + }) + }; + match mounted { + Ok((visual, layer)) => { + page.visual = Some((visual, layer, composition.generation)); + page.mounted = false; + page.applied = None; + } + Err(error) => tracing::warn!(%error, "browser visual unavailable"), + } + } + + fn apply_geometry(&self) { + let mut page = self.page.borrow_mut(); + let Some(page) = page.as_mut() else { return }; + let geometry = self.geometry.borrow(); + let next = Applied { + origin: geometry.origin, + size: geometry.size, + clip: [ + geometry.clip.left, + geometry.clip.top, + geometry.clip.right, + geometry.clip.bottom, + ], + scale: geometry.scale, + }; + let previous = page.applied.replace(next); + if previous == Some(next) { + return; + } + unsafe { + if let Some((visual, _, _)) = &page.visual { + if previous.is_none_or(|p| p.origin != next.origin) { + let _ = visual.SetOffsetX2(next.origin.0); + let _ = visual.SetOffsetY2(next.origin.1); + } + if previous.is_none_or(|p| p.clip != next.clip) { + let _ = visual.SetClip2(&geometry.clip); + } + } + if previous.is_none_or(|p| p.size != next.size) { + let _ = page.controller.SetBounds(RECT { + left: 0, + top: 0, + right: next.size.0, + bottom: next.size.1, + }); + } + if next.scale > 0. + && previous.is_none_or(|p| p.scale != next.scale) + && let Ok(controller) = page.controller.cast::() + { + let _ = controller.SetRasterizationScale(f64::from(next.scale)); + } + } + } + + /// Visible pages render and mount; hidden ones unmount and let + /// Chromium throttle them (timers, painting) like a background tab. + fn update_visibility(&self) { + let visible = self.presentation.get() != Presentation::Hidden + && self.error.borrow().is_none() + && self.has_area.get(); + let mut page = self.page.borrow_mut(); + let Some(page) = page.as_mut() else { return }; + unsafe { + if page.shown.replace(visible) != Some(visible) { + let _ = page.controller.SetIsVisible(visible); + } + if let Some((visual, layer, _)) = &page.visual + && page.mounted != visible + { + let _ = if visible { + layer.AddVisual(visual, true, None::<&IDCompositionVisual>) + } else { + layer.RemoveVisual(visual) + }; + page.mounted = visible; + } + } + if !visible { + self.pressed.set(0); + } + } + + fn local_point(&self, position: Point) -> POINT { + let geometry = self.geometry.borrow(); + POINT { + x: (f32::from(position.x) * geometry.scale - geometry.origin.0).round() as i32, + y: (f32::from(position.y) * geometry.scale - geometry.origin.1).round() as i32, + } + } + + fn pointer( + &self, + kind: PointerKind, + position: Point, + button: Option, + modifiers: Modifiers, + click_count: usize, + ) { + let page = self.page.borrow(); + let Some(page) = page.as_ref() else { return }; + let bit = match button { + Some(MouseButton::Left) => 1, + Some(MouseButton::Right) => 2, + Some(MouseButton::Middle) => 4, + _ => 0, + }; + let pressed = match kind { + PointerKind::Down => self.pressed.get() | bit, + PointerKind::Up => self.pressed.get() & !bit, + PointerKind::Move => self.pressed.get(), + }; + let event = match (kind, button) { + (PointerKind::Move, _) => COREWEBVIEW2_MOUSE_EVENT_KIND_MOVE, + (PointerKind::Down, Some(MouseButton::Left)) if click_count % 2 == 0 => { + COREWEBVIEW2_MOUSE_EVENT_KIND_LEFT_BUTTON_DOUBLE_CLICK + } + (PointerKind::Down, Some(MouseButton::Left)) => { + COREWEBVIEW2_MOUSE_EVENT_KIND_LEFT_BUTTON_DOWN + } + (PointerKind::Up, Some(MouseButton::Left)) => { + COREWEBVIEW2_MOUSE_EVENT_KIND_LEFT_BUTTON_UP + } + (PointerKind::Down, Some(MouseButton::Right)) => { + COREWEBVIEW2_MOUSE_EVENT_KIND_RIGHT_BUTTON_DOWN + } + (PointerKind::Up, Some(MouseButton::Right)) => { + COREWEBVIEW2_MOUSE_EVENT_KIND_RIGHT_BUTTON_UP + } + (PointerKind::Down, Some(MouseButton::Middle)) => { + COREWEBVIEW2_MOUSE_EVENT_KIND_MIDDLE_BUTTON_DOWN + } + (PointerKind::Up, Some(MouseButton::Middle)) => { + COREWEBVIEW2_MOUSE_EVENT_KIND_MIDDLE_BUTTON_UP + } + _ => return, + }; + self.pressed.set(pressed); + self.hovered.set(true); + unsafe { + let _ = page.composition.SendMouseInput( + event, + virtual_keys(pressed, modifiers), + 0, + self.local_point(position), + ); + if kind == PointerKind::Down { + let _ = page + .controller + .MoveFocus(COREWEBVIEW2_MOVE_FOCUS_REASON_PROGRAMMATIC); + } + } + } + + /// GPUI line deltas use WHEEL_DELTA per three lines; precise (pixel) + /// deltas map 100 px to one notch, matching Chromium's own scaling. + fn wheel(&self, position: Point, delta: Point, modifiers: Modifiers) { + let page = self.page.borrow(); + let Some(page) = page.as_ref() else { return }; + let point = self.local_point(position); + let keys = virtual_keys(self.pressed.get(), modifiers); + unsafe { + if delta.y != 0. { + let _ = page.composition.SendMouseInput( + COREWEBVIEW2_MOUSE_EVENT_KIND_WHEEL, + keys, + (delta.y.round() as i32) as u32, + point, + ); + } + if delta.x != 0. { + let _ = page.composition.SendMouseInput( + COREWEBVIEW2_MOUSE_EVENT_KIND_HORIZONTAL_WHEEL, + keys, + (-(delta.x.round()) as i32) as u32, + point, + ); + } + } + } + + fn leave(&self) { + if !self.hovered.replace(false) { + return; + } + if let Some(page) = &*self.page.borrow() { + unsafe { + let _ = page.composition.SendMouseInput( + COREWEBVIEW2_MOUSE_EVENT_KIND_LEAVE, + COREWEBVIEW2_MOUSE_EVENT_VIRTUAL_KEYS_NONE, + 0, + POINT::default(), + ); + } + } + } +} + +impl Drop for Host { + fn drop(&mut self) { + self.close_page(); + } +} + +/// Wheel units: GPUI lines (3 per notch) or pixels (100 per notch). +pub(super) fn wheel_units(delta: gpui::ScrollDelta) -> Point { + const WHEEL_DELTA: f32 = 120.; + match delta { + gpui::ScrollDelta::Lines(lines) => { + gpui::point(lines.x * WHEEL_DELTA / 3., lines.y * WHEEL_DELTA / 3.) + } + gpui::ScrollDelta::Pixels(pixels) => gpui::point( + f32::from(pixels.x) * WHEEL_DELTA / 100., + f32::from(pixels.y) * WHEEL_DELTA / 100., + ), + } +} + +fn virtual_keys(pressed: u32, modifiers: Modifiers) -> COREWEBVIEW2_MOUSE_EVENT_VIRTUAL_KEYS { + let mut keys = COREWEBVIEW2_MOUSE_EVENT_VIRTUAL_KEYS_NONE.0; + if pressed & 1 != 0 { + keys |= COREWEBVIEW2_MOUSE_EVENT_VIRTUAL_KEYS_LEFT_BUTTON.0; + } + if pressed & 2 != 0 { + keys |= COREWEBVIEW2_MOUSE_EVENT_VIRTUAL_KEYS_RIGHT_BUTTON.0; + } + if pressed & 4 != 0 { + keys |= COREWEBVIEW2_MOUSE_EVENT_VIRTUAL_KEYS_MIDDLE_BUTTON.0; + } + if modifiers.shift { + keys |= COREWEBVIEW2_MOUSE_EVENT_VIRTUAL_KEYS_SHIFT.0; + } + if modifiers.control { + keys |= COREWEBVIEW2_MOUSE_EVENT_VIRTUAL_KEYS_CONTROL.0; + } + COREWEBVIEW2_MOUSE_EVENT_VIRTUAL_KEYS(keys) +} + +/// User-facing copy for a failed main-frame navigation; cancellations +/// (a newer navigation, a blocked scheme) are silent. +fn navigation_error(status: COREWEBVIEW2_WEB_ERROR_STATUS) -> Option<&'static str> { + match status { + COREWEBVIEW2_WEB_ERROR_STATUS_OPERATION_CANCELED => None, + COREWEBVIEW2_WEB_ERROR_STATUS_CONNECTION_ABORTED + | COREWEBVIEW2_WEB_ERROR_STATUS_CONNECTION_RESET + | COREWEBVIEW2_WEB_ERROR_STATUS_DISCONNECTED => { + Some("The connection was interrupted. Try loading this page again.") + } + _ => Some("Check the address and make sure your server is running, then try again."), + } +} + +/// Windows system cursor ids (`IDC_*`) as GPUI cursor styles. +fn cursor_style(id: u32) -> CursorStyle { + match id { + 32513 => CursorStyle::IBeam, + 32515 => CursorStyle::Crosshair, + 32642 => CursorStyle::ResizeUpLeftDownRight, + 32643 => CursorStyle::ResizeUpRightDownLeft, + 32644 => CursorStyle::ResizeLeftRight, + 32645 => CursorStyle::ResizeUpDown, + 32648 => CursorStyle::OperationNotAllowed, + 32649 => CursorStyle::PointingHand, + _ => CursorStyle::Arrow, + } +} + +/// The GPUI keystroke for a key-down the page received, when it is one an +/// app shortcut could use (a modifier other than Shift, or a function key). +fn accelerator(key: u32) -> Option { + let down = |vk: VIRTUAL_KEY| unsafe { GetKeyState(i32::from(vk.0)) } < 0; + let control = down(VK_CONTROL); + let alt = down(VK_MENU); + let platform = down(VK_LWIN) || down(VK_RWIN); + let shift = down(VK_SHIFT); + let name = match key { + 0x30..=0x39 | 0x41..=0x5A => char::from_u32(key)?.to_ascii_lowercase().to_string(), + 0x70..=0x7B => format!("f{}", key - 0x6F), + 0x09 => "tab".into(), + 0xBC => ",".into(), + 0xBE => ".".into(), + 0xBF => "/".into(), + 0xDB => "[".into(), + 0xDD => "]".into(), + 0xBA => ";".into(), + 0xDE => "'".into(), + 0xC0 => "`".into(), + 0xBD => "-".into(), + 0xBB => "=".into(), + 0xDC => "\\".into(), + _ => return None, + }; + if !(control || alt || platform || matches!(key, 0x70..=0x7B)) { + return None; + } + let mut combo = String::new(); + for (held, prefix) in [ + (control, "ctrl-"), + (alt, "alt-"), + (platform, "win-"), + (shift, "shift-"), + ] { + if held { + combo.push_str(prefix); + } + } + combo.push_str(&name); + Keystroke::parse(&combo).ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn wheel_units_match_win32_notches() { + let lines = wheel_units(gpui::ScrollDelta::Lines(gpui::point(0., 3.))); + assert_eq!(lines.y, 120.); + let pixels = wheel_units(gpui::ScrollDelta::Pixels(gpui::point( + gpui::px(-50.), + gpui::px(100.), + ))); + assert_eq!((pixels.x, pixels.y), (-60., 120.)); + } + + #[test] + fn cursors_and_errors_map_to_gpui_and_copy() { + assert_eq!(cursor_style(32649), CursorStyle::PointingHand); + assert_eq!(cursor_style(32513), CursorStyle::IBeam); + assert_eq!(cursor_style(1), CursorStyle::Arrow); + assert!(navigation_error(COREWEBVIEW2_WEB_ERROR_STATUS_OPERATION_CANCELED).is_none()); + assert!( + navigation_error(COREWEBVIEW2_WEB_ERROR_STATUS_CANNOT_CONNECT) + .unwrap() + .contains("server") + ); + } + + #[test] + fn virtual_keys_carry_buttons_and_modifiers() { + let keys = virtual_keys( + 1 | 4, + Modifiers { + shift: true, + ..Default::default() + }, + ); + assert_eq!( + keys.0, + COREWEBVIEW2_MOUSE_EVENT_VIRTUAL_KEYS_LEFT_BUTTON.0 + | COREWEBVIEW2_MOUSE_EVENT_VIRTUAL_KEYS_MIDDLE_BUTTON.0 + | COREWEBVIEW2_MOUSE_EVENT_VIRTUAL_KEYS_SHIFT.0 + ); + } +} diff --git a/crates/ui/src/change_requests.rs b/crates/ui/src/change_requests.rs index fb51b0e562..ee81ed45cb 100644 --- a/crates/ui/src/change_requests.rs +++ b/crates/ui/src/change_requests.rs @@ -242,10 +242,33 @@ impl ChangeRequestClientState { true } - pub fn store(&mut self, key: ChangeRequestWatchKey, snapshot: CheckoutChangeRequestStatus) { + /// Returns whether the frame changes what any row can render. Snapshots + /// only surface through their `change_request` (re-validated against the + /// identity fields), so a first "no PR" frame or a bare `updated_at` bump + /// stores silently. + pub fn store( + &mut self, + key: ChangeRequestWatchKey, + snapshot: CheckoutChangeRequestStatus, + ) -> bool { + fn visible( + snapshot: &CheckoutChangeRequestStatus, + ) -> Option<(&str, &str, &str, &str, &ChangeRequestSummary)> { + snapshot.change_request.as_ref().map(|summary| { + ( + snapshot.device_id.as_str(), + snapshot.cwd.as_str(), + snapshot.branch.as_str(), + snapshot.checkout_id.as_str(), + summary, + ) + }) + } + let changed = self.snapshots.get(&key).and_then(visible) != visible(&snapshot); // A new frame replaces the old branch/checkout context for this path. // Rendering validates the complete identity again before exposing it. self.snapshots.insert(key, snapshot); + changed } pub fn retain_targets(&mut self, targets: &HashSet) { @@ -393,6 +416,7 @@ mod tests { git_detected: true, git_checked_at: None, checkout_id: Some("checkout".into()), + repository_id: None, created_at: Utc.timestamp_opt(0, 0).unwrap(), } } @@ -595,6 +619,30 @@ mod tests { assert!(state.change_request_for_chat(&chat, &[]).is_none()); } + #[test] + fn store_reports_only_visible_changes() { + let key = ChangeRequestWatchKey { + device_id: "local".into(), + cwd: "/repo".into(), + branch: "feature/pr".into(), + checkout_id: Some("checkout".into()), + }; + let mut none = snapshot("local", "/repo", "checkout"); + none.change_request = None; + let mut state = ChangeRequestClientState::default(); + // A first "no PR" frame renders nothing new. + assert!(!state.store(key.clone(), none.clone())); + assert!(state.store(key.clone(), snapshot("local", "/repo", "checkout"))); + // The host re-polls: identical PR, fresher timestamp. + let mut refreshed = snapshot("local", "/repo", "checkout"); + refreshed.updated_at = Utc.timestamp_opt(60, 0).unwrap(); + assert!(!state.store(key.clone(), refreshed.clone())); + let mut merged = refreshed; + merged.change_request.as_mut().unwrap().state = ChangeRequestState::Merged; + assert!(state.store(key.clone(), merged)); + assert!(state.store(key, none)); + } + #[test] fn local_and_remote_watch_params_route_to_the_checkout_host() { let target = ChangeRequestWatchKey { diff --git a/crates/ui/src/composer.rs b/crates/ui/src/composer.rs index ee8eaba92e..6241db2541 100644 --- a/crates/ui/src/composer.rs +++ b/crates/ui/src/composer.rs @@ -16,11 +16,11 @@ use std::time::{Duration, Instant}; use gpui::{ AnyTooltip, App, BorderStyle, Bounds, ClipboardEntry, ClipboardItem, Context, CursorStyle, DispatchPhase, ElementInputHandler, Entity, EntityInputHandler, EventEmitter, FocusHandle, - Focusable, GlobalElementId, KeyBinding, KeyDownEvent, LayoutId, MouseButton, MouseDownEvent, - MouseMoveEvent, MouseUpEvent, ObjectFit, PaintQuad, PathPromptOptions, Pixels, Point, Role, - ScrollWheelEvent, SharedString, Style, StyledImage as _, Subscription, Task, TextRun, - TextStyle, UTF16Selection, UnderlineStyle, Window, WrappedLine, actions, div, fill, img, point, - prelude::*, px, quad, relative, size, + Focusable, GlobalElementId, KeyBinding, KeyDownEvent, KeyUpEvent, Keystroke, LayoutId, + ModifiersChangedEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ObjectFit, + PaintQuad, PathPromptOptions, Pixels, Point, Role, ScrollWheelEvent, SharedString, Style, + StyledImage as _, Subscription, Task, TextRun, TextStyle, UTF16Selection, UnderlineStyle, + Window, WrappedLine, actions, div, fill, img, point, prelude::*, px, quad, relative, size, }; use unicode_segmentation::UnicodeSegmentation; @@ -41,6 +41,14 @@ use crate::settings::{ComposerSendBehavior, platform_combo}; use crate::state::{AppState, Indicator}; use crate::theme::Theme; +mod chip; +pub use chip::ChipKind; +use chip::*; +pub(crate) use chip::{ + CHIP_PAD_FAMILY, ChipIcon, chip_icon, chip_pad_ranges, chip_text, paint_chip, + text_chip_label_offset, +}; + // --------------------------------------------------------------------------- // Constants + pure decision logic // --------------------------------------------------------------------------- @@ -392,7 +400,7 @@ pub const CLUSTER_Y_DELTA: f32 = ACTIONS_BOTTOM_PAD + 16.0 + PILL_BORDER_V / 2.0 - COMPACT_TOTAL_HEIGHT / 2.0; /// Attachment and Send share an outer inset: compact 8px, expanded 12px. -/// Glide both edges together while the model picker changes groups. +/// Keep both action groups aligned with the changing surface inset. pub const CLUSTER_X_DELTA: f32 = 4.0; /// Optical join between the picker group and the paperclip. This is tighter /// than the structural spacing ladder because the narrow paperclip glyph @@ -401,17 +409,6 @@ pub const ACTION_UTILITY_GAP: f32 = 2.0; /// Structural separation between utility actions and the primary Send action. pub const ACTION_PRIMARY_GAP: f32 = Theme::SPACE_SM; -/// Fade out at the old endpoint, relocate while invisible, then fade in at -/// the new endpoint. Only a six-pixel nudge is visible; a long label never -/// sweeps across the prompt. Compact amount is reversible with the shared clock. -fn model_handoff(compact: f32) -> (f32, f32, f32) { - let compact = compact.clamp(0.0, 1.0); - let side = if compact < 0.5 { 0.0 } else { 1.0 }; - let opacity = ((compact - 0.5).abs() - 0.06).max(0.0) / 0.44; - let drift = (1.0 - opacity) * if side == 0.0 { 6.0 } else { -6.0 }; - (side, opacity, drift) -} - /// The shared outer inset for the in-flight morph: eases from the OLD mode's resting /// inset to the committed mode's (compact 8 ↔ expanded 12). pub fn morph_cluster_inset(expanded: bool, progress: f32) -> f32 { @@ -501,6 +498,25 @@ pub enum SendButtonMode { Stop, } +/// Per-chat bookkeeping for attachment chips. Numbers only grow within a draft, +/// so a removed attachment never renumbers or retargets an earlier mention. +#[derive(Default)] +struct AttachmentDraft { + /// The highest number handed out so far. + next: u32, + /// Attachments whose last chip or tile was removed. Undo that brings their + /// chip back restages the attachment from here. + unstaged: Vec, +} + +/// A staged attachment offered by the `@` list. +struct AttachmentRow { + index: u32, + label: String, + /// The picture, for an image. + image: Option>, +} + /// What the composer holds that a send could carry. A staged image or diff /// comment counts: both synthesize their own prompt body, so either alone is /// a legal send — and during a live run has to read as Queue, not Stop. @@ -855,6 +871,7 @@ actions!( Undo, Redo, MentionTab, + ToggleDictation, OutdentList, ] ); @@ -869,8 +886,9 @@ const UNDO_LIMIT: usize = 200; const MENTION_TOOLTIP_DELAY: Duration = Duration::from_millis(420); const MENTION_TOOLTIP_HEIGHT: f32 = 24.0; -// Narrow nonbreaking spaces give UI-font chips compact insets and gaps, -// while preserving the chip's atomic wrapping and source/caret projection. +// Nonbreaking spaces give chips their side inset, shaped in Geist like the +// rest of the padding, while preserving the chip's atomic wrapping and +// source/caret projection. const MENTION_SIDE_PAD: &str = "\u{00A0}"; /// A private URI scheme keeps file mentions distinguishable from ordinary /// Markdown links pasted into the composer. @@ -894,8 +912,24 @@ struct FileMentionLink { range: Range, basename: String, path: String, - is_dir: bool, - prefix: char, + kind: ChipKind, + /// The draft number when this chip references a staged attachment. + attachment: Option, +} + +impl FileMentionLink { + /// The character that marks this kind of reference. + fn prefix(&self) -> char { + match self.kind { + ChipKind::Skill => '$', + ChipKind::Command => '/', + _ => '@', + } + } + + fn is_dir(&self) -> bool { + self.kind == ChipKind::Directory + } } /// Build the text inserted when a workspace item is dropped at an arbitrary @@ -912,16 +946,10 @@ fn reference_suffix(next: Option) -> (&'static str, usize) { } } -fn dropped_file_mention( - content: &str, - range: Range, - path: &str, - is_dir: bool, -) -> Option<(String, usize)> { - if range.start > range.end - || !local_path_is_safe(path) - || !content.is_char_boundary(range.start) - { +/// Place a reference link at an arbitrary selection, supplying the separators +/// a completion would already have had around it. +fn dropped_reference(content: &str, range: Range, link: &str) -> Option<(String, usize)> { + if range.start > range.end || !content.is_char_boundary(range.start) { return None; } let suffix = content.get(range.end..)?; @@ -937,7 +965,7 @@ fn dropped_file_mention( "" }; let (trailing, advance) = reference_suffix(suffix.chars().next()); - let inserted = format!("{prefix}{}{trailing}", local_file_link(path, is_dir)); + let inserted = format!("{prefix}{link}{trailing}"); let cursor_advance = inserted.len() + advance; Some((inserted, cursor_advance)) } @@ -949,8 +977,12 @@ fn file_mention_links(text: &str) -> Vec { range: link.range, basename: link.basename, path: link.path, - is_dir: link.is_dir, - prefix: '@', + kind: if link.is_dir { + ChipKind::Directory + } else { + ChipKind::File + }, + attachment: None, }) .collect() } @@ -968,6 +1000,9 @@ struct TextProjection { struct MentionTooltipTarget { range: Range, path: SharedString, + /// Set for attachment chips. An image chip has no tooltip: a click opens + /// the picture full size instead. + attachment: Option, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -1176,14 +1211,21 @@ fn wrap_reference_chips(line: &mut WrappedLine, chips: &[Range], width: P impl TextProjection { fn new(raw: &str) -> Self { - Self::project(raw, None, false) + Self::project(raw, None, false, None) } - fn rich(raw: &str, active: Option>) -> Self { - Self::project(raw, active, true) + /// `live` names the attachments the draft still holds; a chip outside it is + /// a dangling reference and shows as its plain label. + fn rich(raw: &str, active: Option>, live: Option<&HashSet>) -> Self { + Self::project(raw, active, true, live) } - fn project(raw: &str, active: Option>, compact: bool) -> Self { + fn project( + raw: &str, + active: Option>, + compact: bool, + live: Option<&HashSet>, + ) -> Self { let mut links = file_mention_links(raw); links.extend( zeron_proto::invocation::invocation_links(raw) @@ -1196,8 +1238,33 @@ impl TextProjection { invocation.name().to_string() }, path: invocation.detail(), - is_dir: false, - prefix: invocation.prefix(), + kind: if invocation.prefix() == '/' { + ChipKind::Command + } else { + ChipKind::Skill + }, + attachment: None, + }), + ); + links.extend( + zeron_proto::attachment_mentions::attachment_mentions(raw) + .into_iter() + .map(|mention| FileMentionLink { + range: mention.range, + // A file attachment's chip names the file, so its icon + // follows the name; an image has no path to follow. + path: if mention.is_image { + String::new() + } else { + mention.label.clone() + }, + basename: mention.label, + kind: if mention.is_image { + ChipKind::Image + } else { + ChipKind::File + }, + attachment: Some(mention.index), }), ); links.sort_by_key(|link| link.range.start); @@ -1213,13 +1280,14 @@ impl TextProjection { .zip(labels) .map(|(link, label)| { let label = label.replace(' ', "\u{00A0}"); - let marker = link.prefix; + if let (Some(index), Some(live)) = (link.attachment, live) + && !live.contains(&index) + { + return (link.range.clone(), link.basename.clone(), None); + } let pad = MENTION_SIDE_PAD; - ( - link.range.clone(), - format!("{pad}{marker}{label}{pad}"), - Some(link), - ) + let display = format!("{pad}{CHIP_ICON_SLOT}{label}{CHIP_TRAILING_PAD}"); + (link.range.clone(), display, Some(link)) }) .collect(); if let Some(active) = active { @@ -1337,7 +1405,7 @@ fn mention_display_labels(links: &[FileMentionLink]) -> Vec { let mut groups: HashMap<(char, &str), Vec<&FileMentionLink>> = HashMap::new(); for link in links { groups - .entry((link.prefix, &link.basename)) + .entry((link.prefix(), &link.basename)) .or_default() .push(link); } @@ -1346,9 +1414,9 @@ fn mention_display_labels(links: &[FileMentionLink]) -> Vec { .iter() .map(|link| { labels - .entry((link.prefix, &link.basename, &link.path)) + .entry((link.prefix(), &link.basename, &link.path)) .or_insert_with(|| { - let duplicates: Vec<_> = groups[&(link.prefix, link.basename.as_str())] + let duplicates: Vec<_> = groups[&(link.prefix(), link.basename.as_str())] .iter() .filter(|other| other.path != link.path) .collect(); @@ -1359,7 +1427,7 @@ fn mention_display_labels(links: &[FileMentionLink]) -> Vec { let suffix = (1..=parts.len()) .map(|count| parts[parts.len() - count..].join("/")) .find(|suffix| { - if link.prefix != '@' { + if link.prefix() != '@' { duplicates.iter().all(|other| !other.path.ends_with(suffix)) } else { let suffix: Vec<_> = suffix.split('/').collect(); @@ -1374,7 +1442,7 @@ fn mention_display_labels(links: &[FileMentionLink]) -> Vec { } }) .unwrap_or_else(|| link.path.clone()); - if link.prefix == '@' { + if link.prefix() == '@' { suffix } else { format!("{} · {suffix}", link.basename) @@ -1393,7 +1461,19 @@ pub struct SentMentionSpan { pub range: Range, /// Full workspace-relative path (labels can be shortened to basenames). pub path: SharedString, - pub is_dir: bool, + pub kind: ChipKind, + /// The draft number of an attachment chip. + pub attachment: Option, + /// The upload an attachment chip names, once the transcript pairs them. + pub upload: Option, +} + +/// Cheap probe: whether `raw` could hold a file, skill or attachment chip. +fn has_mention_scheme(raw: &str) -> bool { + raw.contains(FILE_MENTION_SCHEME) + || raw.contains(zeron_proto::invocation::INVOCATION_SCHEME) + || raw.contains(zeron_proto::attachment_mentions::IMAGE_MENTION_SCHEME) + || raw.contains(zeron_proto::attachment_mentions::ATTACHMENT_MENTION_SCHEME) } /// Project a sent message's raw Markdown for transcript display: mention links @@ -1402,9 +1482,7 @@ pub struct SentMentionSpan { /// substring probe keeps ordinary prompts on the zero-allocation path, so this /// is safe to call for every user row. pub fn sent_mention_display(raw: &str) -> Option<(String, Vec)> { - if !raw.contains(FILE_MENTION_SCHEME) - && !raw.contains(zeron_proto::invocation::INVOCATION_SCHEME) - { + if !has_mention_scheme(raw) { return None; } let projection = TextProjection::new(raw); @@ -1419,9 +1497,11 @@ pub fn sent_mention_display(raw: &str) -> Option<(String, Vec)> path: SharedString::from(format!( "{}{}", link.path, - if link.is_dir { "/" } else { "" } + if link.is_dir() { "/" } else { "" } )), - is_dir: link.is_dir, + kind: link.kind, + attachment: link.attachment, + upload: None, }) .collect(); Some((projection.display, spans)) @@ -1509,6 +1589,7 @@ fn input_bindings(context: &'static str) -> Vec { KeyBinding::new("shift-tab", OutdentList, ctx), KeyBinding::new("shift-enter", Newline, ctx), KeyBinding::new("backspace", Backspace, ctx), + KeyBinding::new("shift-backspace", Backspace, ctx), KeyBinding::new("delete", Delete, ctx), KeyBinding::new("left", Left, ctx), KeyBinding::new("right", Right, ctx), @@ -1628,6 +1709,7 @@ pub fn init(cx: &mut App, send_behavior: ComposerSendBehavior) { let palette = Some(PALETTE_SEARCH_CONTEXT); let mut palette_bindings = vec![ KeyBinding::new("backspace", Backspace, palette), + KeyBinding::new("shift-backspace", Backspace, palette), KeyBinding::new("delete", Delete, palette), KeyBinding::new("home", Home, palette), KeyBinding::new("end", End, palette), @@ -1704,6 +1786,106 @@ pub enum ComposerInputEvent { range: Range, revision: u64, }, + /// An image chip was clicked: open its picture full size. + OpenAttachment(u32), +} + +#[derive(Clone)] +enum DictationInputEvent { + /// The dictation shortcut went down (auto-repeat is ignored)… + Press, + /// …and came back up, or focus left the editor while it was held. + Release, + Changed, + Submit(u64), +} + +/// A held dictation shortcut. `keystroke` is the binding, used to recognise +/// its release: the key coming up, or any of its modifiers being let go +/// (macOS does not deliver key-up for Command chords). +struct DictationKey { + keystroke: Option, + _blur: Subscription, +} + +/// Dictation is hold to talk, from the microphone (pointer or Enter/Space +/// while it is focused) and from the shortcut. Each releases only its own hold. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum HoldSource { + Pointer, + Button, + Key, +} + +struct DictationHold { + source: HoldSource, + /// When this hold started dictation; `None` when it took over a session + /// started another way (assistive Click), which it then only finishes. + started: Option, +} + +/// A release sooner than this is a click, not speech: explain hold to talk +/// instead of transcribing a fraction of a second of audio. +const DICTATION_TAP: Duration = Duration::from_millis(300); +impl EventEmitter for ComposerInput {} + +/// The composer's morph into and out of dictation, and the clock's warning +/// window before the recording limit. +const VOICE_MORPH: Duration = Duration::from_millis(420); +const VOICE_CURVE: motion::CubicBezier = motion::CubicBezier::new(0.2, 0.0, 0.0, 1.0); +const VOICE_LIMIT_WARNING: Duration = Duration::from_secs(10); +/// Height of the voice track and its gap to the Stop control. +const VOICE_TRACK_HEIGHT: f32 = 32.0; +const VOICE_TRACK_GAP: f32 = 8.0; + +/// Everything the voice track draws, captured from the dictation meter. +#[derive(Clone)] +struct VoiceFrame { + label: String, + mode: crate::dictation::waveform::Mode, + bars: Vec, + level: f32, + elapsed: Option, +} + +/// Interruptible 0↔1 progress of the dictation morph: a retarget starts from +/// the current value, so toggling mid-flight reverses without a jump. +#[derive(Clone, Copy)] +struct VoiceTween { + from: f32, + to: f32, + start: Instant, +} + +impl Default for VoiceTween { + fn default() -> Self { + Self { + from: 0.0, + to: 0.0, + start: Instant::now(), + } + } +} + +impl VoiceTween { + fn value(&self, now: Instant) -> f32 { + let raw = now.saturating_duration_since(self.start).as_secs_f32() + / (VOICE_MORPH.as_secs_f32() * motion::speed_scale()); + motion::lerp(self.from, self.to, VOICE_CURVE.eval(raw)) + } + + fn retarget(&mut self, to: f32, now: Instant, reduced: bool) { + if self.to == to { + return; + } + self.from = if reduced { to } else { self.value(now) }; + self.to = to; + self.start = now; + } + + fn settled(&self, now: Instant) -> bool { + self.value(now) == self.to + } } /// Shaping inputs excluding mutable viewport and selection geometry. @@ -1774,6 +1956,10 @@ pub struct ComposerInput { max_ascent: f32, #[cfg(test)] layout_rebuilds: usize, + /// The runs of the last rebuilt layout; the test text system shapes + /// every run alike, so tests check the requested fonts here. + #[cfg(test)] + last_runs: Vec, /// Normally keeps the caret visible through edits and rewraps. Manual /// wheel scrolling pauses it until the next caret move or edit. follow_cursor: bool, @@ -1813,6 +1999,11 @@ pub struct ComposerInput { blink_anchor: Instant, /// Half-period repaint driver, alive only while the input is focused. blink_task: Option>, + dictation: crate::dictation::Dictation, + transcriber: Option>, + dictation_task: Option>, + /// The dictation shortcut while it is held down. + dictation_key: Option, // -- undo history -- undo_stack: Vec, redo_stack: Vec, @@ -1826,6 +2017,15 @@ pub struct ComposerInput { /// Last prepainted chip bounds; the paint-phase pointer listener uses /// these instead of attempting to infer text geometry from the cursor. mention_hits: Vec, + /// The image chip a click started on, and where. + chip_press: Option<(u32, Point)>, + /// The attachments the draft holds, by chip number, with each image's + /// picture. `None` outside the composer, where every attachment link is + /// shown as a chip. + attachment_chips: Option>, + /// The draft the attachment chips belong to; copied chips carry it so a paste + /// into another draft can tell it must not reuse their numbers. + attachment_scope: String, mention_tooltip: MentionTooltipPhase, mention_tooltip_generation: u64, mention_tooltip_popup: Option>, @@ -1878,6 +2078,8 @@ impl ComposerInput { max_ascent: INPUT_TEXT_SIZE, #[cfg(test)] layout_rebuilds: 0, + #[cfg(test)] + last_runs: Vec::new(), follow_cursor: true, text_size: INPUT_TEXT_SIZE, configured_line_height: INPUT_LINE_HEIGHT, @@ -1901,12 +2103,19 @@ impl ComposerInput { display_is_placeholder: true, blink_anchor: Instant::now(), blink_task: None, + dictation: Default::default(), + transcriber: None, + dictation_task: None, + dictation_key: None, undo_stack: Vec::new(), redo_stack: Vec::new(), last_edit: None, mention_open: false, mention_has_selection: false, mention_hits: Vec::new(), + chip_press: None, + attachment_chips: None, + attachment_scope: String::new(), mention_tooltip: MentionTooltipPhase::Hidden, mention_tooltip_generation: 0, mention_tooltip_popup: None, @@ -2009,7 +2218,15 @@ impl ComposerInput { fn refresh_projection(&mut self) { self.projection = if self.mentions_enabled { - TextProjection::rich(&self.content, Some(self.editing_source_range())) + let live: Option> = self + .attachment_chips + .as_ref() + .map(|images| images.keys().copied().collect()); + TextProjection::rich( + &self.content, + Some(self.editing_source_range()), + live.as_ref(), + ) } else { TextProjection { display: self.content.clone(), @@ -2027,13 +2244,25 @@ impl ComposerInput { is_dir: bool, cx: &mut Context, ) { - if self.read_only || self.marked_range.is_some() || !local_path_is_safe(path) { + if !local_path_is_safe(path) { + return; + } + self.replace_mention_link(range, &local_file_link(path, is_dir), cx); + } + + /// Replace a completed `@query` token with a canonical reference link. + pub(crate) fn replace_mention_link( + &mut self, + range: Range, + link: &str, + cx: &mut Context, + ) { + if self.read_only || self.marked_range.is_some() { return; } self.invalidate_mention_tooltip(); - let path = local_file_link(path, is_dir); let (trailing, advance) = reference_suffix(self.content[range.end..].chars().next()); - let inserted = format!("{path}{trailing}"); + let inserted = format!("{link}{trailing}"); self.record_edit(&range, &inserted); self.edit_revision = self.edit_revision.wrapping_add(1); self.content = @@ -2055,6 +2284,94 @@ impl ComposerInput { /// uses the same strict local Markdown transport and projected chip as an /// `@` mention selected from completion. fn insert_dropped_mention(&mut self, path: &str, is_dir: bool, cx: &mut Context) -> bool { + if !local_path_is_safe(path) { + return false; + } + self.insert_reference(&local_file_link(path, is_dir), cx) + } + + /// Delete every chip that mentions attachment `index`, along with one separator + /// when the chip sat between spaces. Returns whether anything was removed. + pub(crate) fn remove_attachment_chips(&mut self, index: u32, cx: &mut Context) -> bool { + if self.read_only || self.marked_range.is_some() { + return false; + } + let ranges: Vec> = + zeron_proto::attachment_mentions::attachment_mentions(&self.content) + .into_iter() + .filter(|mention| mention.index == index) + .map(|mention| mention.range) + .collect(); + if ranges.is_empty() { + return false; + } + self.invalidate_mention_tooltip(); + for mut range in ranges.into_iter().rev() { + let separated = + range.start == 0 || self.content[..range.start].ends_with(char::is_whitespace); + if separated && self.content[range.end..].starts_with(' ') { + range.end += 1; + } + self.record_edit(&range, ""); + self.edit_revision = self.edit_revision.wrapping_add(1); + self.content.replace_range(range.clone(), ""); + let shift = |offset: usize| { + if offset >= range.end { + offset - range.len() + } else { + offset.min(range.start) + } + }; + self.selected_range = shift(self.selected_range.start)..shift(self.selected_range.end); + } + self.last_edit = None; + self.selection_reversed = false; + self.preferred_column = None; + self.refresh_projection(); + self.reset_blink(); + self.needs_measure = true; + cx.emit(ComposerInputEvent::Edited); + cx.notify(); + true + } + + /// Tell the editor which staged attachments its chips may reference (`None` + /// for one that is not an image). Chips for any other number show as plain + /// text. + pub(crate) fn set_attachment_chips( + &mut self, + scope: &str, + attachments: HashMap>>, + cx: &mut Context, + ) { + let unchanged = self.attachment_scope == scope + && self.attachment_chips.as_ref().is_some_and(|current| { + current.len() == attachments.len() + && attachments.iter().all(|(index, image)| { + current.get(index).is_some_and(|known| { + known.image.as_ref().map(std::sync::Arc::as_ptr) + == image.as_ref().map(std::sync::Arc::as_ptr) + }) + }) + }); + if unchanged { + return; + } + self.attachment_scope = scope.to_string(); + self.attachment_chips = Some( + attachments + .into_iter() + .map(|(index, image)| (index, ChipAttachment { image })) + .collect(), + ); + self.invalidate_mention_tooltip(); + self.refresh_projection(); + self.needs_measure = true; + cx.notify(); + } + + /// Insert a canonical reference link at the selection, as one undo step. + pub(crate) fn insert_reference(&mut self, link: &str, cx: &mut Context) -> bool { // The platform still owns the marked range during IME composition. // Inserting a chip into it would leave those offsets pointing inside // the new reference, and the next IME update could delete the chip. @@ -2063,7 +2380,7 @@ impl ComposerInput { } let range = self.selected_range.clone(); let Some((inserted, cursor_advance)) = - dropped_file_mention(&self.content, range.clone(), path, is_dir) + dropped_reference(&self.content, range.clone(), link) else { return false; }; @@ -2184,6 +2501,7 @@ impl ComposerInput { } pub fn set_text(&mut self, text: impl Into, cx: &mut Context) { + self.cancel_dictation(); self.invalidate_mention_tooltip(); self.edit_revision = self.edit_revision.wrapping_add(1); self.content = text.into(); @@ -2260,6 +2578,22 @@ impl ComposerInput { })); } + fn is_image_chip(&self, target: &MentionTooltipTarget) -> bool { + target + .attachment + .and_then(|index| self.attachment_chips.as_ref()?.get(&index)) + .is_some_and(|chip| chip.image.is_some()) + } + + /// The number of the image chip under `position`, if any. + fn image_chip_at(&self, position: Point) -> Option { + self.mention_hits + .iter() + .find(|hit| hit.bounds.contains(&position)) + .filter(|hit| self.is_image_chip(&hit.target)) + .and_then(|hit| hit.target.attachment) + } + fn on_mention_pointer_move(&mut self, position: Point, cx: &mut Context) { if self.is_selecting { self.invalidate_mention_tooltip(); @@ -2269,6 +2603,7 @@ impl ComposerInput { .mention_hits .iter() .find(|hit| hit.bounds.contains(&position)) + .filter(|hit| !self.is_image_chip(&hit.target)) .map(|hit| hit.target.clone()); let in_popup = self .mention_tooltip_popup @@ -2336,6 +2671,242 @@ impl ComposerInput { } } + pub(crate) fn cancel_dictation(&mut self) { + self.dictation.cancel(); + self.transcriber = None; // Drop signals capture cancellation and invalidates pending results. + self.dictation_task = None; + } + + fn begin_dictation( + &mut self, + transcriber: Box, + cx: &mut Context, + ) { + self.cancel_dictation(); + self.dictation.begin( + &self.content, + self.projection.normalize_range(self.selected_range.clone()), + ); + self.transcriber = Some(transcriber); + self.last_edit = None; + let generation = self.dictation.generation; + let started = Instant::now(); + self.dictation_task = Some(cx.spawn(async move |this, cx| { + loop { + cx.background_executor() + .timer(Duration::from_millis(40)) + .await; + if !this + .update(cx, |input, cx| { + let requesting = + input.dictation.phase == crate::dictation::Phase::Requesting; + let active = input.poll_dictation(generation, cx); + if requesting && input.dictation.phase == crate::dictation::Phase::Listening + { + tracing::debug!( + target: "zeron_ui::dictation", + activation_to_listening_ms = started.elapsed().as_millis(), + "Dictation capture ready" + ); + } + active + }) + .unwrap_or(false) + { + break; + } + } + })); + cx.emit(DictationInputEvent::Changed); + cx.notify(); + } + + pub(crate) fn finish_dictation(&mut self, send: bool, cx: &mut Context) -> bool { + if !self.dictation.phase.active() { + return false; + } + // Repeated clicks/Enter only mark one pending send and end audio once. + if self.dictation.finish(send, Instant::now()) { + if let Some(transcriber) = &mut self.transcriber { + transcriber.finish(); + } + } + cx.emit(DictationInputEvent::Changed); + cx.notify(); + true + } + + fn complete_dictation(&mut self, phase: crate::dictation::Phase, cx: &mut Context) { + let send = self.dictation.complete(phase); + self.transcriber = None; + self.last_edit = None; + cx.emit(DictationInputEvent::Changed); + if send { + cx.emit(DictationInputEvent::Submit(self.dictation.generation)); + } + cx.notify(); + } + + fn apply_dictation(&mut self, text: &str, cx: &mut Context) { + let before = (!self.dictation.has_partial && !text.is_empty()).then(|| self.snapshot()); + if let Some(cursor) = self.dictation.replace(&mut self.content, text) { + if let Some(before) = before { + self.undo_stack.push(before); + if self.undo_stack.len() > UNDO_LIMIT { + self.undo_stack.remove(0); + } + self.redo_stack.clear(); + } + // Moving the selection explicitly stops dictation (see move_to / + // select_to). The original selection collapses on the first result. + self.selected_range = cursor..cursor; + self.selection_reversed = false; + self.caret_affinity = CaretAffinity::Downstream; + self.preferred_column = None; + self.edit_revision = self.edit_revision.wrapping_add(1); + self.refresh_projection(); + self.invalidate_mention_tooltip(); + self.follow_cursor = true; + self.reset_blink(); + self.needs_measure = true; + cx.emit(ComposerInputEvent::Edited); + cx.notify(); + } + } + + fn poll_dictation(&mut self, generation: u64, cx: &mut Context) -> bool { + use crate::dictation::{Event, Phase}; + if generation != self.dictation.generation || !self.dictation.phase.active() { + return false; + } + while let Some(event) = self.transcriber.as_mut().and_then(|service| service.poll()) { + match event { + Event::Listening => { + if self.dictation.phase == Phase::Requesting { + self.dictation.phase = Phase::Listening; + self.dictation.meter.start(Instant::now()); + } + cx.emit(DictationInputEvent::Changed); + cx.notify(); + } + Event::Finalizing => { + self.finish_dictation(false, cx); + } + Event::Partial(text) => self.apply_dictation(&text, cx), + Event::Final(text) => { + if text.trim().is_empty() && !self.dictation.has_partial { + self.complete_dictation(Phase::NoSpeech, cx); + return false; + } + self.apply_dictation(&text, cx); + self.complete_dictation(Phase::Idle, cx); + return false; + } + Event::Denied(message) => self.complete_dictation(Phase::Denied(message), cx), + Event::Unavailable(message) => { + self.complete_dictation(Phase::Unavailable(message), cx) + } + Event::Failed(message) => self.complete_dictation(Phase::Failed(message), cx), + Event::Cancelled => { + self.cancel_dictation(); + cx.emit(DictationInputEvent::Changed); + cx.notify(); + } + } + if !self.dictation.phase.active() { + self.transcriber = None; + return false; + } + } + if self.dictation.phase == Phase::Listening + && let Some(service) = self.transcriber.as_mut() + { + let level = service.level(); + if self.dictation.meter.record(Instant::now(), level) { + // The composer drives smooth frames itself; this keeps the + // stepped reduced-motion waveform current. + cx.emit(DictationInputEvent::Changed); + } + } + if self.dictation.timed_out(Instant::now()) { + self.complete_dictation( + Phase::Failed( + "Dictation took too long. Your draft is safe. Wait a moment, then try a shorter recording.".into(), + ), + cx, + ); + return false; + } + true + } + + fn toggle_dictation_action( + &mut self, + _: &ToggleDictation, + window: &mut Window, + cx: &mut Context, + ) { + if self.dictation_key.is_some() { + return; // Auto-repeat while held. + } + if !crate::dictation::enabled(cx) && !self.dictation.phase.active() { + // Off by default: let another binding of the same chord run. + cx.propagate(); + return; + } + // Mirror `apply_keymap`, which binds the default for an unparseable combo. + let parse = |combo: &str| Keystroke::parse(&crate::settings::platform_combo(combo)).ok(); + let keystroke = parse(&crate::settings::current(cx).keymap.toggle_dictation) + .or_else(|| parse(crate::settings::ShortcutId::ToggleDictation.default_combo())); + let blur = cx.on_blur(&self.focus_handle, window, |input, _, cx| { + input.release_dictation_key(cx); + }); + self.dictation_key = Some(DictationKey { + keystroke, + _blur: blur, + }); + cx.emit(DictationInputEvent::Press); + } + + fn release_dictation_key(&mut self, cx: &mut Context) { + if self.dictation_key.take().is_some() { + cx.emit(DictationInputEvent::Release); + } + } + + fn on_dictation_key_up(&mut self, event: &KeyUpEvent, _: &mut Window, cx: &mut Context) { + let released = self.dictation_key.as_ref().is_some_and(|held| { + held.keystroke + .as_ref() + .is_none_or(|binding| binding.key.eq_ignore_ascii_case(&event.keystroke.key)) + }); + if released { + self.release_dictation_key(cx); + } + } + + fn on_dictation_modifiers( + &mut self, + event: &ModifiersChangedEvent, + _: &mut Window, + cx: &mut Context, + ) { + let released = self + .dictation_key + .as_ref() + .and_then(|held| held.keystroke.as_ref()) + .is_some_and(|binding| { + let (bound, now) = (&binding.modifiers, &event.modifiers); + (bound.platform && !now.platform) + || (bound.control && !now.control) + || (bound.alt && !now.alt) + || (bound.shift && !now.shift) + }); + if released { + self.release_dictation_key(cx); + } + } + // ---- undo history ---- fn snapshot(&self) -> EditSnapshot { @@ -2350,6 +2921,7 @@ impl ComposerInput { /// Called with the range about to be replaced, BEFORE the content changes, /// so the pushed snapshot is the pre-edit state. fn record_edit(&mut self, range: &Range, new_text: &str) { + self.cancel_dictation(); let kind = if new_text.is_empty() { EditKind::Delete } else { @@ -2396,6 +2968,7 @@ impl ComposerInput { } fn restore(&mut self, snapshot: EditSnapshot, cx: &mut Context) { + self.cancel_dictation(); self.invalidate_mention_tooltip(); self.edit_revision = self.edit_revision.wrapping_add(1); self.content = snapshot.content; @@ -2415,6 +2988,11 @@ impl ComposerInput { } fn undo(&mut self, _: &Undo, _: &mut Window, cx: &mut Context) { + if self.dictation.phase.active() { + cx.emit(DictationInputEvent::Changed); + cx.notify(); + } + self.cancel_dictation(); if self.read_only { return; } @@ -2426,6 +3004,11 @@ impl ComposerInput { } fn redo(&mut self, _: &Redo, _: &mut Window, cx: &mut Context) { + if self.dictation.phase.active() { + cx.emit(DictationInputEvent::Changed); + cx.notify(); + } + self.cancel_dictation(); if self.read_only { return; } @@ -2447,6 +3030,7 @@ impl ComposerInput { } fn move_to(&mut self, offset: usize, cx: &mut Context) { + self.cancel_dictation(); self.last_edit = None; let offset = self.projection.normalize_range(offset..offset).start; self.selected_range = offset..offset; @@ -2460,6 +3044,7 @@ impl ComposerInput { } fn select_to(&mut self, offset: usize, cx: &mut Context) { + self.cancel_dictation(); self.last_edit = None; self.extend_selection(offset, cx); } @@ -2666,6 +3251,11 @@ impl ComposerInput { } fn select_all(&mut self, _: &SelectAll, _: &mut Window, cx: &mut Context) { + self.select_all_text(cx); + } + + /// Select the whole draft — an inline rename opens with its name selected. + pub fn select_all_text(&mut self, cx: &mut Context) { self.move_to(0, cx); self.select_to(self.content.len(), cx); } @@ -2804,7 +3394,11 @@ impl ComposerInput { if let Some((raw, text)) = self.clipboard_selection() { cx.write_to_clipboard(ClipboardItem::new_string_with_json_metadata( text.clone(), - serde_json::json!({ "zeronComposerV1": raw, "text": text }), + serde_json::json!({ + "zeronComposerV1": raw, + "text": text, + "zeronAttachmentScope": self.attachment_scope, + }), )); } else if self.copies_transcript_selection && let Some(text) = crate::markdown::selection::selected_text() @@ -2820,7 +3414,11 @@ impl ComposerInput { if let Some((raw, text)) = self.clipboard_selection() { cx.write_to_clipboard(ClipboardItem::new_string_with_json_metadata( text.clone(), - serde_json::json!({ "zeronComposerV1": raw, "text": text }), + serde_json::json!({ + "zeronComposerV1": raw, + "text": text, + "zeronAttachmentScope": self.attachment_scope, + }), )); self.last_edit = None; @@ -2866,7 +3464,19 @@ impl ComposerInput { { if value.get("text").and_then(|v| v.as_str()) == Some(text.as_str()) { if let Some(raw) = value.get("zeronComposerV1").and_then(|v| v.as_str()) { - text = raw.to_owned(); + // Chip numbers belong to one draft: chips copied from + // another chat would name the wrong attachment, so + // they paste as their plain labels instead. + let from_this_draft = value + .get("zeronAttachmentScope") + .and_then(|v| v.as_str()) + .is_some_and(|scope| scope == self.attachment_scope); + if from_this_draft + || zeron_proto::attachment_mentions::attachment_mentions(raw) + .is_empty() + { + text = raw.to_owned(); + } } } } @@ -2894,6 +3504,9 @@ impl ComposerInput { cx: &mut Context, ) { if self.read_only + // A delayed paste lookup must not rewrite the range captured by + // dictation and make its final transcript fail the draft guard. + || self.dictation.phase.active() || self.edit_revision != revision || self.text() != original || self.marked_range.is_some() @@ -3049,6 +3662,13 @@ impl ComposerInput { } fn on_key_down(&mut self, event: &KeyDownEvent, _: &mut Window, cx: &mut Context) { + if event.keystroke.key == "escape" && self.dictation.phase.active() { + self.cancel_dictation(); + cx.emit(DictationInputEvent::Changed); + cx.notify(); + cx.stop_propagation(); + return; + } if escape_dismisses_completion(&event.keystroke.key, self.mention_open) { cx.emit(ComposerInputEvent::MentionDismiss); cx.stop_propagation(); @@ -3118,6 +3738,32 @@ impl ComposerInput { None } + /// [`chip_label_offset`] for the chip label starting at projected byte + /// `label`, from the shaped line holding it. + fn chip_label_offset( + &self, + label: usize, + row_top: Pixels, + room: Pixels, + window: &Window, + ) -> Pixels { + let line_ix = self + .line_starts + .partition_point(|start| *start <= label) + .saturating_sub(1); + match (self.last_lines.get(line_ix), self.line_starts.get(line_ix)) { + (Some(line), Some(start)) => chip_label_offset( + &line.unwrapped_layout, + label - start, + row_top, + self.line_height, + room, + window, + ), + _ => px(0.0), + } + } + /// Content-local boxes occupied by a projected byte range, split at every /// soft wrap. A caret exactly at a wrap boundary belongs visually to both /// rows in GPUI; using the explicit wrap indices lets the range's first @@ -3248,6 +3894,10 @@ impl ComposerInput { self.invalidate_mention_tooltip(); window.focus(&self.focus_handle, cx); let intent = press_intent(event.click_count, event.modifiers.shift); + self.chip_press = (intent == PressIntent::PlaceCaret) + .then(|| self.image_chip_at(event.position)) + .flatten() + .map(|index| (index, event.position)); self.is_selecting = true; self.drag_position = Some(event.position); self.drag_unit = None; @@ -3327,7 +3977,15 @@ impl ComposerInput { } } - fn on_mouse_up(&mut self, _: &MouseUpEvent, _: &mut Window, _: &mut Context) { + fn on_mouse_up(&mut self, event: &MouseUpEvent, _: &mut Window, cx: &mut Context) { + // A click that starts and ends on an image chip opens the picture, as + // the thumbnails did; a drag across it only selects. + if let Some((index, down)) = self.chip_press.take() + && self.image_chip_at(event.position) == Some(index) + && (event.position - down).magnitude() <= 4.0 + { + cx.emit(ComposerInputEvent::OpenAttachment(index)); + } self.is_selecting = false; self.drag_position = None; self.drag_generation = self.drag_generation.wrapping_add(1); @@ -3545,20 +4203,20 @@ impl ComposerInput { let font_size = style.font_size.to_pixels(window.rem_size()); self.line_height = px(self.configured_line_height); - // Chips read as inline code: the markdown renderer's recipe (mono font - // + the spectrum's `code_text`) over the rounded `code_wash` beneath. + // Inline code: the markdown renderer's recipe (mono font + the + // spectrum's `code_text`) over the rounded `code_wash` beneath. let (chip_font, chip_color) = { let theme = Theme::of(cx); (gpui::font(theme.font_mono.clone()), theme.code_text) }; - let run_for = |len: usize, underline: bool, chip: bool| TextRun { + let run_for = |len: usize, underline: bool, code: bool| TextRun { len, - font: if chip { + font: if code { chip_font.clone() } else { style.font() }, - color: if chip { chip_color } else { style.color }, + color: if code { chip_color } else { style.color }, // Rounded mention washes are painted explicitly beneath the text; // TextRun backgrounds are square and can disappear in wrapped runs. background_color: None, @@ -3615,6 +4273,9 @@ impl ComposerInput { } for (_, range) in &self.projection.mentions { boundaries.extend([range.start, range.end]); + for pad in chip_pad_ranges(range) { + boundaries.extend([pad.start, pad.end]); + } } if let Some(range) = &marked { boundaries.extend([range.start, range.end]); @@ -3646,17 +4307,24 @@ impl ComposerInput { .projection .mentions .partition_point(|(_, range)| range.end <= r[0]); - let chip = self + let chip_range = self .projection .mentions .get(mention_ix) - .is_some_and(|(_, range)| range.contains(&r[0])); + .map(|(_, range)| range) + .filter(|range| range.contains(&r[0])); + let chip = chip_range.is_some(); let code = face_depth[composer_markdown::Face::Code as usize] > 0; let mut run = run_for( r[1] - r[0], marked.as_ref().is_some_and(|range| range.contains(&r[0])), - chip || code, + code, ); + if chip_range.is_some_and(|range| { + chip_pad_ranges(range).iter().any(|pad| pad.contains(&r[0])) + }) { + run.font.family = CHIP_PAD_FAMILY.into(); + } if !chip { if face_depth[composer_markdown::Face::Bold as usize] > 0 { run.font.weight = gpui::FontWeight::BOLD; @@ -3686,7 +4354,11 @@ impl ComposerInput { run }) .collect(); - + #[cfg(test)] + { + self.last_runs = runs.clone(); + } + // Each logical list line reserves its marker width for continuation // rows. Painting and hit testing apply the same continuation offset. let mut lines = Vec::new(); @@ -3944,6 +4616,7 @@ impl EntityInputHandler for ComposerInput { if self.read_only { return; } + self.cancel_dictation(); let single_line_text; let new_text = if self.single_line { single_line_text = new_text.replace(['\r', '\n'], " "); @@ -3992,6 +4665,7 @@ impl EntityInputHandler for ComposerInput { if self.read_only { return; } + self.cancel_dictation(); let single_line_text; let new_text = if self.single_line { single_line_text = new_text.replace(['\r', '\n'], " "); @@ -4090,22 +4764,23 @@ struct MentionPathTooltip { impl Render for MentionPathTooltip { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let theme = Theme::of(cx).for_popup(); + let card = crate::popover::popover_card(&theme) + .max_w(px(480.0)) + .h(px(MENTION_TOOLTIP_HEIGHT)) + .flex() + .items_center() + .p_0() + .px(px(8.0)) + .font_family(theme.font_mono.clone()) + .text_size(px(11.0)) + .text_color(theme.text_muted) + .child(div().min_w_0().truncate().child(self.path.clone())); motion::fade_quick( ("file-mention-path-tooltip", self.activation), div().child(crate::frost::frosted( crate::popover::CARD_RADIUS, crate::frost::MENU_BLUR, - crate::popover::popover_card(&theme) - .h(px(MENTION_TOOLTIP_HEIGHT)) - .max_w(px(480.0)) - .flex() - .items_center() - .p_0() - .px(px(8.0)) - .font_family(theme.font_mono.clone()) - .text_size(px(11.0)) - .text_color(theme.text_muted) - .child(div().min_w_0().truncate().child(self.path.clone())), + card, )), ) } @@ -4113,8 +4788,11 @@ impl Render for MentionPathTooltip { struct ComposerTextPrepaint { cursor: Option, - mention_quads: Vec, + /// Each chip row's bounds; the first row of a chip also carries its icon. + mention_chips: Vec<(Bounds, Option)>, mention_hits: Vec, + /// Image chips take the pointing hand: a click opens them. + image_chip_hitboxes: Vec, selection_quads: Vec, /// Completion preview: window-space origin of the end-of-text caret plus /// the suffix to paint there (shaped at paint time — it never joins the @@ -4197,37 +4875,45 @@ impl gpui::Element for ComposerTextElement { let origin = point(bounds.left() - px(input.scroll_left), bounds.top() - scroll); let selection_color = Theme::of(cx).selection; let caret_color = Theme::of(cx).caret; - // The inline-code recipe: chips use the spectrum wash like `code` spans. - let mention_color = Theme::of(cx).code_wash; - let mut mention_quads = Vec::new(); + let mut mention_chips = Vec::new(); + let appearance = Theme::of(cx).appearance; let mut mention_hits = Vec::new(); for (mention, display) in &input.projection.mentions { + let mut first_row = true; let target = MentionTooltipTarget { range: mention.range.clone(), - path: SharedString::from(format!( - "{}{}", - mention.path, - if mention.is_dir { "/" } else { "" } - )), + path: if mention.attachment.is_some() { + SharedString::from(mention.basename.clone()) + } else { + SharedString::from(format!( + "{}{}", + mention.path, + if mention.is_dir() { "/" } else { "" } + )) + }, + attachment: mention.attachment, }; + // The path tooltip stays flush so the pointer can move onto it. + let (tooltip_height, tooltip_gap) = (MENTION_TOOLTIP_HEIGHT, 1.0); + let label = chip_pad_ranges(display)[0].end; for local_bounds in input.bounds_for_display_range(display.clone()) { + let row_top = origin.y + local_bounds.origin.y; + // Centered on the label (see `paint_chip`). + let label_offset = input.chip_label_offset(label, row_top, px(1.5), window); let chip_bounds = Bounds::new( point( origin.x + local_bounds.origin.x, - origin.y + local_bounds.origin.y + px(2.0), + row_top + px(1.5) + label_offset, ), - size(local_bounds.size.width, local_bounds.size.height - px(4.0)), + size(local_bounds.size.width, local_bounds.size.height - px(3.0)), ); - mention_quads.push(quad( + mention_chips.push(( chip_bounds, - px(5.0), - mention_color, - px(0.0), - gpui::transparent_black(), - BorderStyle::default(), + first_row.then(|| chip_icon(mention.kind, &mention.path, appearance)), )); - let above_anchor = chip_bounds.top() - px(MENTION_TOOLTIP_HEIGHT) - px(1.0); + first_row = false; + let above_anchor = chip_bounds.top() - px(tooltip_height) - px(tooltip_gap); let anchor_y = if above_anchor >= px(0.0) { above_anchor } else { @@ -4340,10 +5026,16 @@ impl gpui::Element for ComposerTextElement { .point_for_index(input.content.len()) .map(|p| (point(origin.x + p.x, origin.y + p.y), g)) }); + let image_chip_hitboxes = mention_hits + .iter() + .filter(|hit| input.is_image_chip(&hit.target)) + .map(|hit| window.insert_hitbox(hit.bounds, gpui::HitboxBehavior::Normal)) + .collect(); ComposerTextPrepaint { cursor, - mention_quads, + mention_chips, mention_hits, + image_chip_hitboxes, selection_quads, ghost, } @@ -4374,6 +5066,9 @@ impl gpui::Element for ComposerTextElement { input.update(cx, |input, cx| input.on_mouse_move(event, cx)); } }); + for hitbox in &prepaint.image_chip_hitboxes { + window.set_cursor_style(CursorStyle::PointingHand, hitbox); + } // WrappedLine isn't Clone — temporarily take the shaped lines out of the // entity for painting, then put them back for mouse mapping. @@ -4394,8 +5089,9 @@ impl gpui::Element for ComposerTextElement { bounds: paint_bounds, }), |window| { - for quad in prepaint.mention_quads.drain(..) { - window.paint_quad(quad); + let theme = Theme::of(cx).clone(); + for (chip, icon) in prepaint.mention_chips.drain(..) { + paint_chip(window, chip, icon.as_ref(), &theme, cx); } for quad in prepaint.selection_quads.drain(..) { window.paint_quad(quad); @@ -4567,6 +5263,9 @@ impl Render for ComposerInput { .on_action(cx.listener(Self::message_newline_or_accept)) .on_action(cx.listener(Self::modified_submit)) .on_action(cx.listener(Self::submit)) + .on_action(cx.listener(Self::toggle_dictation_action)) + .on_key_up(cx.listener(Self::on_dictation_key_up)) + .on_modifiers_changed(cx.listener(Self::on_dictation_modifiers)) .on_action(cx.listener(Self::undo)) .on_action(cx.listener(Self::redo)) .on_key_down(cx.listener(Self::on_key_down)) @@ -5254,6 +5953,10 @@ pub struct Composer { pub(crate) input: Entity, /// Draft displaced while a queued message occupies the composer. pub(crate) queue_edit_draft: Option<(String, Vec, Vec)>, + /// The displaced draft's chip numbering and undo stash. The queued + /// message gets its own while it is edited, so its chips can never name + /// the draft's attachments. + queue_edit_attachment_draft: Option, /// Composer actions row plus the new-session floating target tab /// ([`Pickers::render_new_thread_target_selectors`]). pickers: Entity, @@ -5262,6 +5965,7 @@ pub struct Composer { /// Staged-but-unsent attachments per chat key (use-attachments.ts `stash`): /// navigating away and back restores them; memory-only, like the original. pub(crate) attachments: HashMap>, + attachment_drafts: HashMap, /// Rich window captures keyed exactly like drafts and ordinary staged /// attachments. Each owns one screenshot that joins the existing upload /// path only at send time. @@ -5327,6 +6031,7 @@ pub struct Composer { pub(crate) queue_edit_renew_task: Option>, /// Focus once on mount, navigation, or after opening/closing a queue edit. pub(crate) focus_pending: bool, + dictation_hold: Option, /// Live drag over the queue panel: which row, and where it would land. pub(crate) queue_drag: Option, pub(crate) queue_scroll: gpui::ScrollHandle, @@ -5335,6 +6040,11 @@ pub struct Composer { /// Rows awaiting a host-authoritative removal acknowledgement. They stay /// visible but inert until the host wins the race against queue delivery. pub(crate) queue_removing: HashSet, + /// The agent's checklist tray: latest list of the selected chat, and the + /// per-chat open/fold state (in memory, like the right-pane flags). + pub(crate) todo_cache: crate::todo_panel::TodoCache, + pub(crate) todo_panels: HashMap, + pub(crate) todo_scroll: gpui::ScrollHandle, /// Whether the modifier overlay should currently reveal the queue hint. /// The shell owns modifier tracking and clears this on window deactivation. queue_shortcut_revealed: bool, @@ -5376,9 +6086,7 @@ pub struct Composer { /// Pill height actually rendered last frame — a committed flip morphs /// from here, so mid-flight reversals hand off without a jump. last_rendered_height: f32, - model_handoff_position: f32, - model_handoff_from: f32, - model_handoff_morph: Option, + #[cfg(test)] model_bounds: Rc>>>, dock_frame: Option, dock_reflow: crate::composer_dock::DockReflow, @@ -5402,6 +6110,14 @@ pub struct Composer { side_chat: bool, _picker_focus: Subscription, _input_events: Subscription, + _dictation_events: Subscription, + dictation_activation: Option, + dictation_blur: Option, + dictation_focus: FocusHandle, + /// The last live frame of the voice pill, kept so it can settle away + /// after the transcript lands instead of vanishing. + voice_last: Option, + voice_tween: VoiceTween, } impl EventEmitter for Composer {} @@ -5462,8 +6178,11 @@ impl Composer { } pub fn new(state: Entity, cx: &mut Context) -> Self { - cx.on_release(|this, cx| this.release_queue_previews(cx)) - .detach(); + cx.on_release(|this, cx| { + this.input.update(cx, |input, _| input.cancel_dictation()); + this.release_queue_previews(cx); + }) + .detach(); let input = cx.new(|cx| { let mut input = ComposerInput::with_context("Do anything…", MESSAGE_COMPOSER_CONTEXT, cx); @@ -5519,12 +6238,19 @@ impl Composer { this.dismiss_mention(cx) } } + ComposerInputEvent::OpenAttachment(index) => this.open_attachment(*index, cx), ComposerInputEvent::PastedImages(images) => { - let staged = images - .iter() - .map(|image| attachments::stage_clipboard_image(image.clone())) - .collect(); - this.add_staged(staged, cx); + let images = images.clone(); + this.stage_in_background( + move || { + images + .into_iter() + .map(attachments::stage_clipboard_image) + .map(Ok) + .collect() + }, + cx, + ); } ComposerInputEvent::PastedPaths(paths) => this.add_paths(paths.clone(), cx), ComposerInputEvent::PastedText { range, revision } => { @@ -5532,17 +6258,38 @@ impl Composer { } }); cx.observe_global::(|this, cx| { + if !crate::settings::current(cx).dictation_enabled { + this.input.update(cx, |input, _| input.cancel_dictation()); + } this.on_input_edited(cx); }) .detach(); + let dictation_events = cx.subscribe(&input, |this: &mut Self, _, event, cx| match event { + DictationInputEvent::Press => this.press_dictation(HoldSource::Key, cx), + DictationInputEvent::Release => this.release_dictation(HoldSource::Key, cx), + DictationInputEvent::Changed => cx.notify(), + DictationInputEvent::Submit(generation) => { + if this.input.read(cx).dictation.generation == *generation + && composer_has_content( + this.input.read(cx).text(), + this.staged().len() + this.staged_appshots().len(), + this.staged_comments(cx).len(), + ) + { + this.on_submit(cx); + } + } + }); let current_key = state.read(cx).selected_chat.clone().unwrap_or_default(); let mut composer = Self { state, input, queue_edit_draft: None, + queue_edit_attachment_draft: None, pickers, drafts: HashMap::new(), attachments: HashMap::new(), + attachment_drafts: HashMap::new(), appshots: HashMap::new(), appshot_entrances: HashMap::new(), preview: None, @@ -5581,11 +6328,15 @@ impl Composer { queue_edit_task: None, queue_edit_renew_task: None, focus_pending: true, + dictation_hold: None, queue_drag: None, queue_scroll: gpui::ScrollHandle::new(), queue_full_preview: None, queue_previews: HashMap::new(), queue_removing: HashSet::new(), + todo_cache: Default::default(), + todo_panels: HashMap::new(), + todo_scroll: gpui::ScrollHandle::new(), queue_shortcut_revealed: false, expanded_mode: false, flip_epoch: 0, @@ -5597,9 +6348,7 @@ impl Composer { settle_task: None, flip_morph: None, last_rendered_height: 0.0, - model_handoff_position: 1.0, - model_handoff_from: 1.0, - model_handoff_morph: None, + #[cfg(test)] model_bounds: Default::default(), dock_frame: None, dock_reflow: Default::default(), @@ -5616,6 +6365,12 @@ impl Composer { side_chat: false, _picker_focus: picker_focus, _input_events: input_events, + _dictation_events: dictation_events, + dictation_activation: None, + dictation_blur: None, + dictation_focus: cx.focus_handle(), + voice_last: None, + voice_tween: VoiceTween::default(), }; // Dev knob: pre-stage attachments (drop/paste can't be synthesized on // a rig) — `ZERON_ATTACH=/path/a.png[,/path/b.png]`, and @@ -5637,18 +6392,14 @@ impl Composer { if std::env::var("ZERON_ATTACH_PREVIEW").is_ok_and(|v| v == "1") && let Some(first) = staged.first() { - composer.preview = Some(attachments::PreviewImage::new( - first.name.clone(), - first.image.clone(), - )); + composer.preview = first + .image() + .map(|image| attachments::PreviewImage::new(first.name.clone(), image.clone())); composer.preview_focus_pending = true; } if !staged.is_empty() { - composer - .attachments - .entry(composer.current_key.clone()) - .or_default() - .extend(staged); + let key = composer.current_key.clone(); + composer.adopt_staged_attachments(key, staged, cx); } } composer @@ -5679,6 +6430,24 @@ impl Composer { .unwrap_or(&[]) } + /// Staged attachments that get a tile above the input. One the draft + /// mentions as a chip is already on screen there (hover previews it, + /// deleting it unstages it), so only attachments without a chip are tiled. + fn tiled_attachments<'a>(&'a self, cx: &App) -> Vec<&'a StagedAttachment> { + let chipped: Vec = self + .input + .read(cx) + .projection + .mentions + .iter() + .filter_map(|(link, _)| link.attachment) + .collect(); + self.staged() + .iter() + .filter(|att| att.mention.is_none_or(|index| !chipped.contains(&index))) + .collect() + } + pub(crate) fn queue_preview_limit(&self) -> usize { if self.last_available_width.unwrap_or(COMPOSER_MAX_WIDTH) < 520.0 { 1 @@ -5687,6 +6456,23 @@ impl Composer { } } + /// Open a staged image full size, from its chip (the tiles' click). + fn open_attachment(&mut self, index: u32, cx: &mut Context) { + let Some((name, image)) = self + .staged() + .iter() + .filter(|att| att.mention == Some(index)) + .find_map(|att| Some((att.name.clone(), att.image()?.clone()))) + else { + return; + }; + let preview = attachments::PreviewImage::new(name, image); + preview.viewer.reset(); + self.preview = Some(preview); + self.preview_focus_pending = true; + cx.notify(); + } + pub(crate) fn show_queue_image( &mut self, preview: attachments::PreviewImage, @@ -5793,6 +6579,16 @@ impl Composer { self.failure.as_ref() } + /// The failure notice this draft renders. Chat-scoped failures render + /// only under their own chat; a global failure (no key) renders everywhere. + pub(crate) fn visible_failure(&self) -> Option { + self.failure.clone().filter(|_| { + self.failure_key + .as_ref() + .is_none_or(|key| *key == self.current_key) + }) + } + /// Show a dismissable failure chip for the current draft's session. pub(crate) fn show_error(&mut self, message: impl Into, cx: &mut Context) { self.failure = Some(message.into()); @@ -5800,40 +6596,56 @@ impl Composer { cx.notify(); } - fn add_staged(&mut self, staged: Vec, cx: &mut Context) { - if self.queue_edit_finishing { - return; - } - if staged.is_empty() { - return; - } - self.attachments - .entry(self.current_key.clone()) - .or_default() - .extend(staged); - self.focus_pending = true; - cx.notify(); + /// Run `stage` on the background executor — reading a file and converting + /// a BMP are too slow for the UI thread — and add what it staged to the + /// draft that is current now, even if the user has navigated away by the + /// time it finishes. Failures surface in that draft's failure notice, all + /// of them together so one refused file does not hide another. + fn stage_in_background( + &mut self, + stage: impl FnOnce() -> Vec> + Send + 'static, + cx: &mut Context, + ) { + let key = self.current_key.clone(); + cx.spawn(async move |this, cx| { + let results = cx.background_executor().spawn(async move { stage() }).await; + this.update(cx, |this, cx| { + let mut staged = Vec::new(); + let mut refused = Vec::new(); + for result in results { + match result { + Ok(att) => staged.push(att), + Err(message) => refused.push(message), + } + } + if !refused.is_empty() { + this.failure = Some(refused.join(" ").into()); + this.failure_key = Some(key.clone()); + } + if !staged.is_empty() && !this.queue_edit_finishing { + this.adopt_staged_attachments(key, staged, cx); + this.focus_pending = true; + } + cx.notify(); + }) + .ok(); + }) + .detach(); } - /// Stage image files (picker / drop / pasted paths). Non-images are - /// skipped silently (matching the original's `image/*` filter); read - /// failures and oversize files surface in the failure notice. + /// Stage files (picker / drop / pasted paths): images with thumbnails, any + /// other file as opaque bytes. Folders, read failures and oversize files + /// surface in the failure notice. pub(crate) fn add_paths(&mut self, paths: Vec, cx: &mut Context) { - let mut staged = Vec::new(); - for path in &paths { - if attachments::format_by_extension(path).is_none() { - continue; - } - match attachments::stage_file(path) { - Ok(att) => staged.push(att), - Err(message) => { - self.failure = Some(message.into()); - self.failure_key = Some(self.current_key.clone()); - cx.notify(); - } - } - } - self.add_staged(staged, cx); + self.stage_in_background( + move || { + paths + .iter() + .map(|path| attachments::stage_file(path)) + .collect() + }, + cx, + ); } /// Add a file-tree or file-tab drop through the existing file-mention @@ -5858,16 +6670,173 @@ impl Composer { } } + /// The next chip number for a draft: above everything it holds or + /// mentions, so a number is never reused. + fn next_attachment_index(&mut self, key: &str, text: &str) -> u32 { + let held = self + .attachments + .get(key) + .into_iter() + .flatten() + .filter_map(|att| att.mention) + .max(); + let mentioned = zeron_proto::attachment_mentions::attachment_mention_indices(text) + .into_iter() + .max(); + let draft = self.attachment_drafts.entry(key.to_string()).or_default(); + draft.next = draft + .next + .max(held.unwrap_or(0)) + .max(mentioned.unwrap_or(0)) + + 1; + draft.next + } + + /// Add newly staged files to a draft. Each is numbered and mentioned at the + /// caret as one edit: images read "Image 1", "Image 2", ...; other files + /// keep their name. A file the editor cannot take a chip for, or that was + /// staged for a draft the user has since left, stays a plain attachment. + fn adopt_staged_attachments( + &mut self, + key: String, + mut staged: Vec, + cx: &mut Context, + ) { + if key != self.current_key { + self.attachments.entry(key).or_default().extend(staged); + return; + } + let text = self.input.read(cx).text().to_string(); + let mut links = Vec::with_capacity(staged.len()); + let mut indices = Vec::with_capacity(staged.len()); + for att in &mut staged { + let index = self.next_attachment_index(&key, &text); + let file_name = match att.image() { + Some(image) => { + att.name = attachments::ensure_extension( + &zeron_proto::attachment_mentions::image_label(index), + image.format, + ); + None + } + None => Some(att.name.as_str()), + }; + links.push(zeron_proto::attachment_mentions::attachment_mention_link( + index, file_name, + )); + indices.push(index); + } + // Chips are inserted before the attachments join the draft, so a + // draft that cannot take them (read-only, composing) keeps the files + // and their original names. + let mentioned = self + .input + .update(cx, |input, cx| input.insert_reference(&links.join(" "), cx)); + if mentioned { + for (att, index) in staged.iter_mut().zip(indices) { + att.mention = Some(index); + } + } + self.attachments.entry(key).or_default().extend(staged); + self.sync_attachment_chips(cx); + cx.notify(); + } + + /// Hand the editor the attachments its chips may reference. + fn sync_attachment_chips(&mut self, cx: &mut Context) { + let attachments: HashMap>> = self + .staged() + .iter() + .filter_map(|att| att.mention.map(|index| (index, att.image().cloned()))) + .collect(); + let scope = self.current_key.clone(); + self.input.update(cx, |input, cx| { + input.set_attachment_chips(&scope, attachments, cx) + }); + } + + /// Keep staged attachments and their chips in step: one whose last chip is + /// gone leaves the draft (kept for undo), and one whose chip came back + /// returns. Attachments the prompt never mentions are left alone. + fn reconcile_attachment_mentions(&mut self, cx: &mut Context) { + if self.wizard.is_none() && !self.queue_edit_finishing { + let mentioned = zeron_proto::attachment_mentions::attachment_mention_indices( + self.input.read(cx).text(), + ); + let is_mentioned = + |att: &StagedAttachment| att.mention.is_none_or(|index| mentioned.contains(&index)); + let key = self.current_key.clone(); + let draft = self.attachment_drafts.entry(key.clone()).or_default(); + let (restored, mut stash): (Vec<_>, Vec<_>) = std::mem::take(&mut draft.unstaged) + .into_iter() + .partition(is_mentioned); + let (mut kept, dropped): (Vec<_>, Vec<_>) = self + .attachments + .remove(&key) + .unwrap_or_default() + .into_iter() + .partition(is_mentioned); + let changed = !restored.is_empty() || !dropped.is_empty(); + kept.extend(restored); + kept.sort_by_key(|att| att.mention.unwrap_or(0)); + stash.extend(dropped); + draft.unstaged = stash; + if !kept.is_empty() { + self.attachments.insert(key, kept); + } + if changed { + cx.notify(); + } + } + self.sync_attachment_chips(cx); + } + + /// Set the draft's chip numbering and undo stash aside while a queued + /// message occupies the composer. + pub(crate) fn displace_attachment_draft(&mut self) { + self.queue_edit_attachment_draft = Some( + self.attachment_drafts + .remove(&self.current_key) + .unwrap_or_default(), + ); + } + + /// Bring back what [`Self::displace_attachment_draft`] set aside; the + /// edit's own numbering and stash are dropped with it. + pub(crate) fn restore_attachment_draft(&mut self) { + if let Some(draft) = self.queue_edit_attachment_draft.take() { + self.attachment_drafts + .insert(self.current_key.clone(), draft); + } + } + fn remove_attachment(&mut self, id: &str, cx: &mut Context) { if self.queue_edit_finishing { return; } + let mut mention = None; if let Some(list) = self.attachments.get_mut(&self.current_key) { - list.retain(|a| a.id != id); + if let Some(at) = list.iter().position(|a| a.id == id) { + let removed = list.remove(at); + if let Some(index) = removed.mention { + mention = Some(index); + // Undo of the chip removal below restages this image. + self.attachment_drafts + .entry(self.current_key.clone()) + .or_default() + .unstaged + .push(removed); + } + } if list.is_empty() { self.attachments.remove(&self.current_key); } } + if let Some(index) = mention { + self.input + .update(cx, |input, cx| input.remove_attachment_chips(index, cx)); + } + self.sync_attachment_chips(cx); cx.notify(); } @@ -5908,6 +6877,7 @@ impl Composer { /// raw image bytes, and a deleted chat's stage could never be sent again. pub fn purge_chat(&mut self, chat_id: &str, cx: &mut Context) { self.attachments.remove(chat_id); + self.attachment_drafts.remove(chat_id); self.appshots.remove(chat_id); self.state.update(cx, |state, _| { state.purge_review_comments(chat_id); @@ -5949,9 +6919,10 @@ impl Composer { /// The staged-thumbnail strip (attachment-ui.tsx AttachmentStrip): /// `flex flex-wrap gap-2 px-4 pt-3`, 56px rounded thumbs, a remove button - /// revealed on hover, click opens the full-size preview. + /// revealed on hover, click opens the full-size preview. Attachments with + /// a chip in the draft are left out. fn render_attachment_strip(&self, theme: &Theme, cx: &mut Context) -> Option { - let staged = self.staged(); + let staged = self.tiled_attachments(cx); if staged.is_empty() { return None; } @@ -5966,7 +6937,6 @@ impl Composer { .pt(px(STRIP_PAD_TOP)); for (ix, att) in staged.iter().enumerate() { let group: SharedString = format!("composer-att-{}", att.id).into(); - let preview = attachments::PreviewImage::new(att.name.clone(), att.image.clone()); let remove_id = att.id.clone(); let remove_label: SharedString = format!("Remove {}", att.name).into(); strip = strip.child( @@ -5974,39 +6944,7 @@ impl Composer { .group(group.clone()) .flex_none() .relative() - .child( - div() - .id(("composer-att-thumb", ix)) - .size(px(STRIP_THUMB)) - .rounded(px(8.0)) - .overflow_hidden() - .border_1() - .border_color(crate::theme::hairline(0.10)) - .cursor_pointer() - .on_click(cx.listener(move |this, _, _, cx| { - preview.viewer.reset(); - this.preview = Some(preview.clone()); - this.preview_focus_pending = true; - cx.notify(); - })) - .child( - img(att.image.clone()) - // EXPLICIT dims, not size_full: img layout - // honors the image's intrinsic aspect - // ratio over a percent height (gpui - // f8d8a90 repoint), so size_full let a - // tall photo grow past the frame — the - // rectangular overflow clip then squared - // the bottom corners (2026-08-19 report). - // 56−2 = frame minus its 1px borders. - .w(px(STRIP_THUMB - 2.0)) - .h(px(STRIP_THUMB - 2.0)) - // Own radii — the frame's rounding only - // clips rectangularly (7 = 8 - border). - .rounded(px(7.0)) - .object_fit(ObjectFit::Cover), - ), - ) + .child(self.attachment_tile(att, ix, cx)) // Own layer: inside the frosted pill everything shares one // draw order and images render last, so without it the // thumbnail paints OVER this button (user report). @@ -6048,6 +6986,75 @@ impl Composer { Some(strip) } + /// One staged attachment's tile: an image's thumbnail (a click opens the + /// full-size preview) or a file's icon and name. + fn attachment_tile( + &self, + att: &StagedAttachment, + ix: usize, + cx: &mut Context, + ) -> gpui::AnyElement { + let frame = div() + .id(("composer-att-thumb", ix)) + .size(px(STRIP_THUMB)) + .rounded(px(8.0)) + .overflow_hidden() + .border_1() + .border_color(crate::theme::hairline(0.10)); + let Some(image) = att.image() else { + let theme = Theme::of(cx); + return frame + .flex() + .flex_col() + .items_center() + .justify_center() + .gap(px(3.0)) + .px(px(4.0)) + .child( + crate::file_icons::icon( + crate::file_icons::FileIconIdentity::file(&att.name), + theme.appearance, + ) + .size(px(24.0)), + ) + .child( + div() + .w_full() + .truncate() + .text_center() + .text_size(px(10.0)) + .text_color(theme.text_muted) + .child(SharedString::from(att.name.clone())), + ) + .into_any_element(); + }; + let preview = attachments::PreviewImage::new(att.name.clone(), image.clone()); + frame + .cursor_pointer() + .on_click(cx.listener(move |this, _, _, cx| { + preview.viewer.reset(); + this.preview = Some(preview.clone()); + this.preview_focus_pending = true; + cx.notify(); + })) + .child( + img(image.clone()) + // EXPLICIT dims, not size_full: img layout honors the + // image's intrinsic aspect ratio over a percent height + // (gpui f8d8a90 repoint), so size_full let a tall photo + // grow past the frame — the rectangular overflow clip + // then squared the bottom corners (2026-08-19 report). + // 56−2 = frame minus its 1px borders. + .w(px(STRIP_THUMB - 2.0)) + .h(px(STRIP_THUMB - 2.0)) + // Own radii — the frame's rounding only clips + // rectangularly (7 = 8 - border). + .rounded(px(7.0)) + .object_fit(ObjectFit::Cover), + ) + .into_any_element() + } + fn render_appshot_strip( &self, theme: &Theme, @@ -6072,10 +7079,13 @@ impl Composer { - 2.0 * STRIP_PAD_X - 2.0 * APPSHOT_IMAGE_INSET; for (ix, appshot) in appshots.iter().enumerate() { + let Some(screenshot) = appshot.screenshot.image().cloned() else { + continue; + }; let group: SharedString = format!("composer-appshot-{}", appshot.id).into(); let preview = crate::attachments::PreviewImage::new( appshot.screenshot.name.clone(), - appshot.screenshot.image.clone(), + screenshot.clone(), ); let preview_on_key = preview.clone(); let preview_on_a11y = preview.clone(); @@ -6174,7 +7184,7 @@ impl Composer { 44.0, false, true, - img(appshot.screenshot.image.clone()) + img(screenshot) .w(px(image_width)) .h(px(image_height)) .object_fit(ObjectFit::Contain), @@ -6538,6 +7548,7 @@ impl Composer { } fn on_input_edited(&mut self, cx: &mut Context) { + self.reconcile_attachment_mentions(cx); if self.wizard.is_some() { if self.mention.token.is_some() || self.mention_task.is_some() { self.reset_mention(None, cx); @@ -6598,7 +7609,7 @@ impl Composer { self.mention.token = token.clone(); if !refining { self.mention.results.clear(); - self.mention.active = None; + self.mention.active = (!self.mention_attachment_matches().is_empty()).then_some(0); // Fresh open: the row stack restarts at the top. crate::popover::reset_menu_scroll(&self.mention_scroll, &mut self.popup_bar); } @@ -6652,7 +7663,9 @@ impl Composer { // that the canonical reference format cannot encode. results.retain(|result| local_path_is_safe(&result.path)); composer.mention.error = None; - composer.mention.active = (!results.is_empty()).then_some(0); + composer.mention.active = (!results.is_empty() + || !composer.mention_attachment_matches().is_empty()) + .then_some(0); composer.mention.results = results; // New result set: the row stack restarts at the top. crate::popover::reset_menu_scroll( @@ -6665,7 +7678,8 @@ impl Composer { Err(err) => { tracing::warn!(%err, "file mention search failed"); composer.mention.results.clear(); - composer.mention.active = None; + composer.mention.active = + (!composer.mention_attachment_matches().is_empty()).then_some(0); composer.mention.error = Some(mention_error_message(&err)); } } @@ -6678,8 +7692,8 @@ impl Composer { } fn move_mention(&mut self, delta: isize, cx: &mut Context) { - self.mention.active = - crate::popover::menu_step(self.mention.active, self.mention.results.len(), delta); + let rows = self.mention_attachment_matches().len() + self.mention.results.len(); + self.mention.active = crate::popover::menu_step(self.mention.active, rows, delta); if let Some(active) = self.mention.active { // Keep the keyboard cursor visible in the scrolled row stack. self.mention_scroll.scroll_to_item(active); @@ -6700,21 +7714,65 @@ impl Composer { cx.notify(); } + /// Staged attachments that match the typed `@` query, pinned above file + /// results. Matching ignores case and spaces, so `@im`, `@image2` and + /// `@2` all find "Image 2", and `@note` finds "notes.md". + fn mention_attachment_matches(&self) -> Vec { + let Some(token) = self.mention.token.as_ref() else { + return Vec::new(); + }; + let normalize = |text: &str| -> String { + text.chars() + .filter(|c| !c.is_whitespace()) + .flat_map(char::to_lowercase) + .collect() + }; + let wanted = normalize(&token.query); + let mut rows: Vec<_> = self + .staged() + .iter() + .filter_map(|att| { + let index = att.mention?; + let label = if att.image().is_some() { + zeron_proto::attachment_mentions::image_label(index) + } else { + att.name.clone() + }; + normalize(&label).contains(&wanted).then(|| AttachmentRow { + index, + label, + image: att.image().cloned(), + }) + }) + .collect(); + rows.sort_by_key(|row| row.index); + rows + } + fn accept_mention(&mut self, cx: &mut Context) { let Some(token) = self.mention.token.clone() else { return; }; - let Some((path, is_dir)) = self - .mention - .active - .and_then(|active| self.mention.results.get(active)) - .map(|result| (result.path.clone(), result.is_dir)) - else { + let rows = self.mention_attachment_matches(); + let Some(active) = self.mention.active else { return; }; - self.input.update(cx, |input, cx| { - input.replace_mention(token.range, &path, is_dir, cx) - }); + if let Some(row) = rows.get(active) { + let link = zeron_proto::attachment_mentions::attachment_mention_link( + row.index, + row.image.is_none().then_some(row.label.as_str()), + ); + self.input.update(cx, |input, cx| { + input.replace_mention_link(token.range, &link, cx) + }); + } else if let Some(result) = self.mention.results.get(active - rows.len()) { + let (path, is_dir) = (result.path.clone(), result.is_dir); + self.input.update(cx, |input, cx| { + input.replace_mention(token.range, &path, is_dir, cx) + }); + } else { + return; + } self.reset_mention(None, cx); cx.notify(); } @@ -6736,7 +7794,8 @@ impl Composer { }), ) .on_mouse_down_out(cx.listener(|this, _, _, cx| this.dismiss_mention(cx))); - if self.mention.loading && self.mention.results.is_empty() { + let attachment_rows = self.mention_attachment_matches(); + if self.mention.loading && self.mention.results.is_empty() && attachment_rows.is_empty() { card = card.child(crate::popover::skeleton_rows( "file-mention-loading", theme, @@ -6744,7 +7803,12 @@ impl Composer { cx.entity_id(), cx, )); - } else if let Some(error) = self.mention.error.clone() { + } else if let Some(error) = self + .mention + .error + .clone() + .filter(|_| attachment_rows.is_empty()) + { card = card.child( div() .px(px(12.0)) @@ -6753,7 +7817,7 @@ impl Composer { .text_color(theme.danger_muted) .child(error), ); - } else if self.mention.results.is_empty() { + } else if self.mention.results.is_empty() && attachment_rows.is_empty() { card = card.child( div() .px(px(12.0)) @@ -6767,8 +7831,53 @@ impl Composer { }), ); } else { - let mut rows: Vec = Vec::with_capacity(self.mention.results.len()); - for (ix, result) in self.mention.results.iter().enumerate() { + let mut rows: Vec = + Vec::with_capacity(attachment_rows.len() + self.mention.results.len()); + // Staged attachments stay pinned above every file result. + for (ix, row) in attachment_rows.iter().enumerate() { + let selected = self.mention.active == Some(ix); + let (icon, detail) = match &row.image { + Some(image) => ( + img(image.clone()) + .size(px(16.0)) + .rounded(px(3.0)) + .object_fit(ObjectFit::Cover) + .into_any_element(), + "Attached image", + ), + None => ( + crate::file_icons::icon( + crate::file_icons::FileIconIdentity::file(&row.label), + theme.appearance, + ) + .size(px(16.0)) + .into_any_element(), + "Attached file", + ), + }; + rows.push( + crate::popover::menu_row( + theme, + selected, + format!("attachment-mention-result-{ix}"), + ) + .id(("attachment-mention-result", ix)) + .on_click(cx.listener(move |this, _, _, cx| { + this.mention.active = Some(ix); + this.accept_mention(cx); + })) + .child(crate::popover::completion_row_content( + theme, + icon, + row.label.clone().into(), + detail.into(), + )) + .into_any_element(), + ); + } + let attachment_count = attachment_rows.len(); + for (file_ix, result) in self.mention.results.iter().enumerate() { + let ix = attachment_count + file_ix; let selected = self.mention.active == Some(ix); let (directory, name) = match result.path.rsplit_once('/') { Some((directory, name)) => (directory.to_string(), name.to_string()), @@ -7256,7 +8365,9 @@ impl Composer { fn on_state_changed(&mut self, cx: &mut Context) { { let state = self.state.read(cx); - let now = chrono::Utc::now(); + // AppState also notifies at clock-only transitions, so a run that + // goes stale drops its interrupt at the cutoff. + let now = crate::state::clock_now(); retain_live_interrupts(&mut self.interrupting, |chat_id| { matches!( state.indicator_for(chat_id, now), @@ -7294,7 +8405,11 @@ impl Composer { if let Some((draft, mut attachments, mut appshots)) = self.queue_edit_draft.take() { appshots.extend(self.appshots.remove(&self.current_key).unwrap_or_default()); self.appshots.insert(self.current_key.clone(), appshots); - let edited = self.input.read(cx).text().to_string(); + // The edit's chip numbers would collide with the draft's: its + // chips become their labels and its attachments plain tiles. + let edited = zeron_proto::attachment_mentions::attachment_mention_prompt( + self.input.read(cx).text(), + ); let text = [draft, edited] .into_iter() .filter(|text| !text.is_empty()) @@ -7304,7 +8419,12 @@ impl Composer { attachments.extend( self.attachments .remove(&self.current_key) - .unwrap_or_default(), + .unwrap_or_default() + .into_iter() + .map(|att| StagedAttachment { + mention: None, + ..att + }), ); self.attachments .insert(self.current_key.clone(), attachments); @@ -7389,6 +8509,7 @@ impl Composer { .as_ref() .is_some_and(|w| w.request_id == request_id); if !same { + self.input.update(cx, |input, _| input.cancel_dictation()); if self.wizard.is_none() { self.drafts.insert( self.current_key.clone(), @@ -7505,11 +8626,12 @@ impl Composer { if self.editing_queued.is_some() { return SendButtonMode::Send; } - let has_text = composer_has_content( - self.input.read(cx).text(), - self.staged().len() + self.staged_appshots().len(), - self.staged_comments(cx).len(), - ); + let has_text = self.input.read(cx).dictation.phase.active() + || composer_has_content( + self.input.read(cx).text(), + self.staged().len() + self.staged_appshots().len(), + self.staged_comments(cx).len(), + ); send_button_mode(self.run_live(cx), has_text) } @@ -7531,6 +8653,12 @@ impl Composer { } fn on_submit(&mut self, cx: &mut Context) { + if self + .input + .update(cx, |input, cx| input.finish_dictation(true, cx)) + { + return; + } if self.commit_queue_edit(cx) { return; } @@ -7578,6 +8706,12 @@ impl Composer { /// content. With a truly empty composer it instead activates the most /// recently queued row, and never turns an empty chord into Stop. fn on_modified_submit(&mut self, cx: &mut Context) { + if self + .input + .update(cx, |input, cx| input.finish_dictation(true, cx)) + { + return; + } if self.commit_queue_edit(cx) { return; } @@ -7693,6 +8827,7 @@ impl Composer { .attachments .remove(&self.current_key) .unwrap_or_default(); + self.attachment_drafts.remove(&self.current_key); let staged_appshots = self.appshots.remove(&self.current_key).unwrap_or_default(); let mut staged = ordinary_staged.clone(); staged.extend( @@ -7712,6 +8847,12 @@ impl Composer { taken }); let typed = text.clone(); + // A chip for an attachment that is no longer staged must not leave as a link. + let attached: Vec = ordinary_staged + .iter() + .filter_map(|att| att.mention) + .collect(); + let text = zeron_proto::attachment_mentions::demote_unattached_mentions(&text, &attached); let text = crate::comments::with_comments(&text, &comments); self.preview = None; let message_id = uuid::Uuid::new_v4().to_string(); @@ -7798,30 +8939,20 @@ impl Composer { // rewrite instead of blanking into a reload skeleton. if queued_flow { for (upload_id, att) in upload_ids.iter().zip(&staged) { - attachments::seed_attachment_alias( - &device_id, - upload_id, - &att.name, - att.image.clone(), - ); + attachments::seed_staged_alias(&device_id, upload_id, att); if let Some(local) = local_device_id.as_deref() && local != device_id { - attachments::seed_attachment_alias( - local, - upload_id, - &att.name, - att.image.clone(), - ); + attachments::seed_staged_alias(local, upload_id, att); } } } for (path, att) in echo_paths.iter().zip(&staged) { - attachments::seed_attachment(&device_id, path, &att.name, att.image.clone()); + attachments::seed_staged(&device_id, path, att); if let Some(local) = local_device_id.as_deref() && local != device_id { - attachments::seed_attachment(local, path, &att.name, att.image.clone()); + attachments::seed_staged(local, path, att); } } @@ -7974,9 +9105,9 @@ impl Composer { // Attachment in the original send path). let seed_device = host_device_id.clone().unwrap_or_else(|| device_id.clone()); for (path, att) in attachment_paths.iter().zip(&staged) { - attachments::seed_attachment(&seed_device, path, &att.name, att.image.clone()); + attachments::seed_staged(&seed_device, path, att); if seed_device != device_id { - attachments::seed_attachment(&device_id, path, &att.name, att.image.clone()); + attachments::seed_staged(&device_id, path, att); } } let appshot_paths: HashMap = staged @@ -8174,7 +9305,7 @@ impl Composer { let queue_text = if !clean_queue_attachment_text { content.as_str() } else if queue_body.trim().is_empty() && !attachment_paths.is_empty() { - attachments::ATTACHMENT_ONLY_TEXT + attachments::attachment_only_text(&attachment_paths) } else { queue_body.as_str() }; @@ -8616,6 +9747,24 @@ impl Composer { let Some(question) = wizard.current().cloned() else { return gpui::Empty.into_any_element(); }; + // The answer borrows the composer's editor, but the composer's own + // sizing does not run while the panel shows. Without a limit of its + // own the editor kept a stale one-line viewport, so new lines painted + // below the card instead of growing it. Grow up to the composer's cap. + let answer_cap = TEXTAREA_MAX - TEXTAREA_PAD_V; + self.input.update(cx, |input, cx| { + if input.viewport_height != Some(answer_cap) + || input.settled_viewport_height != Some(answer_cap) + || input.resizing + || input.overflow_top_padding != 0.0 + { + input.viewport_height = Some(answer_cap); + input.settled_viewport_height = Some(answer_cap); + input.resizing = false; + input.overflow_top_padding = 0.0; + cx.notify(); + } + }); let page = wizard.page; let last = page + 1 >= wizard.questions.len(); let typed_empty = self.input.read(cx).is_empty(); @@ -8692,16 +9841,20 @@ impl Composer { }) }); - div() + // Stands in for the composer pill, so it is the same frosted surface: + // without the backdrop blur the translucent fill let the transcript + // show through unblurred. + let panel = div() .id("question-panel") .track_focus(&self.wizard_focus) .on_key_down(cx.listener(|this, event: &KeyDownEvent, window, cx| { this.on_wizard_key(event, window, cx) })) + .occlude() .rounded(px(COMPOSER_RADIUS)) .border_1() .border_color(theme.border) - .bg(theme.input_glass_bg()) + .bg(theme.composer_surface_bg()) .when(!theme.is_frost(), |el| el.shadow_lg()) .flex() .flex_col() @@ -8806,7 +9959,516 @@ impl Composer { .when(!can_advance, |el| el.opacity(0.4)) .on_click(cx.listener(|this, _, _, cx| this.wizard_advance(cx))), ), + ); + crate::frost::frosted(COMPOSER_RADIUS, crate::frost::MENU_BLUR, panel).into_any_element() + } + + /// `refocus` returns focus to the editor afterwards. Enter/Space on the + /// focused microphone keeps focus there so its key-up ends the hold. + fn toggle_dictation(&mut self, refocus: bool, cx: &mut Context) { + use crate::dictation::Phase; + if !crate::dictation::enabled(cx) && !self.input.read(cx).dictation.phase.active() { + return; + } + // A queue row still acquiring its edit lease is about to replace the + // draft, which would discard the dictation. + if self.wizard.is_some() + || self.queue_edit_finishing + || self.queue_edit_pending_id.is_some() + || self.sending + || self.input.read(cx).read_only + { + return; + } + self.input + .update(cx, |input, cx| match input.dictation.phase { + Phase::Requesting => { + input.cancel_dictation(); + cx.emit(DictationInputEvent::Changed); + cx.notify(); + } + Phase::Listening => { + input.finish_dictation(false, cx); + } + // The voice track's Cancel stops transcription. + Phase::Finalizing => {} + _ if input.marked_range.is_some() => { + input.dictation.phase = Phase::Unavailable( + "Finish composing the current character before starting dictation.".into(), + ); + cx.emit(DictationInputEvent::Changed); + } + _ => { + if let Some(service) = crate::dictation::start(cx) { + input.begin_dictation(service, cx); + } + } + }); + self.focus_pending |= refocus; + cx.notify(); + } + + /// Hold to talk: the microphone or the shortcut going down starts + /// dictation. Pressing during a session started by assistive Click takes + /// it over, so releasing finishes it. + fn press_dictation(&mut self, source: HoldSource, cx: &mut Context) { + use crate::dictation::Phase; + let phase = self.input.read(cx).dictation.phase.clone(); + // Repeats from the same source are ignored. Another source takes the + // session over, so a hold whose release went missing (focus moved + // within the composer) can always be ended. + if phase.active() + && self + .dictation_hold + .as_ref() + .is_some_and(|hold| hold.source == source) + { + return; + } + self.dictation_hold = None; + match phase { + Phase::Finalizing => {} + Phase::Requesting | Phase::Listening => { + self.dictation_hold = Some(DictationHold { + source, + started: None, + }); + } + _ => { + self.toggle_dictation(source != HoldSource::Button, cx); + if self.input.read(cx).dictation.phase.active() { + self.dictation_hold = Some(DictationHold { + source, + started: Some(Instant::now()), + }); + } + } + } + } + + /// Letting go transcribes what was said. A tap explains hold to talk, + /// and a release that only answered the permission prompt records nothing. + fn release_dictation(&mut self, source: HoldSource, cx: &mut Context) { + use crate::dictation::Phase; + if self + .dictation_hold + .as_ref() + .is_none_or(|hold| hold.source != source) + { + return; + } + let tapped = self + .dictation_hold + .take() + .and_then(|hold| hold.started) + .is_some_and(|started| started.elapsed() < DICTATION_TAP); + self.input + .update(cx, |input, cx| match input.dictation.phase { + Phase::Requesting if crate::dictation::permission_pending() => { + input.cancel_dictation(); + cx.emit(DictationInputEvent::Changed); + cx.notify(); + } + Phase::Requesting | Phase::Listening if tapped => { + input.cancel_dictation(); + input.dictation.phase = Phase::Tapped; + cx.emit(DictationInputEvent::Changed); + cx.notify(); + } + Phase::Requesting | Phase::Listening => { + input.finish_dictation(false, cx); + } + _ => {} + }); + // Only the pointer moved focus to the microphone. A key hold already + // sits in the editor (or focus has left it on purpose), and Enter/Space + // keeps the microphone focused. + self.focus_pending |= source == HoldSource::Pointer; + cx.notify(); + } + + /// Microphone at rest; the one Stop control while dictating. `t` morphs + /// the mic glyph out and the accent glass plate, stop square or spinner + /// in; the glow follows the live voice level. + fn render_dictation_button( + &self, + t: f32, + frame: Option<&VoiceFrame>, + cx: &mut Context, + ) -> Option { + use crate::dictation::{Phase, glass, waveform::Mode}; + if !crate::dictation::enabled(cx) || self.wizard.is_some() { + return None; + } + let theme = Theme::of(cx).clone(); + let phase = self.input.read(cx).dictation.phase.clone(); + let active = phase.active(); + let label = phase.action_label().to_owned(); + let live = frame.is_some_and(|f| f.mode == Mode::Live); + let glow = frame.map_or(0.0, |f| f.level * f.level.sqrt()); + // Spinner while the microphone opens or the model transcribes; the + // stop square once the voice is live (and while retracting from it). + let busy = matches!(phase, Phase::Requesting | Phase::Finalizing) + || (!active && frame.is_some_and(|f| f.mode != Mode::Live)); + let tooltip = label.clone(); + let composer = cx.entity().downgrade(); + // Per-composer key: the main and sidechat composers share this + // render, and the hover fade store is keyed by string. + let hover_key: SharedString = format!("composer-dictation-{}", cx.entity_id()).into(); + let centered = || { + div() + .absolute() + .inset_0() + .flex() + .items_center() + .justify_center() + }; + let button = div() + .id("composer-dictation") + .debug_selector(|| "composer-dictation".into()) + .relative() + .size(px(28.0)) + .flex_none() + .rounded_full() + .role(Role::Button) + .aria_label(label) + .aria_toggled(if active { + gpui::Toggled::True + } else { + gpui::Toggled::False + }) + .tab_index(0) + .cursor_pointer() + // At rest it is a sibling of the paperclip: the same eased ink + // wash. Live, it is the accent plate and dims like Send. + .on_hover(motion::hover_listener(hover_key.clone())) + .when(t <= 0.0, |el| { + el.bg(motion::hover_blend( + &hover_key, + gpui::transparent_black(), + crate::theme::ink(0.10), + )) + }) + .when(t > 0.0, |el| { + glass::accent(el, &theme, t, glow).hover(|style| style.opacity(0.85)) + }) + .focus_visible(|style| style.border_1().border_color(theme.accent)) + .tooltip(crate::settings::widgets::text_tooltip(tooltip)) + .on_mouse_down( + MouseButton::Left, + cx.listener(|this, _, _, cx| this.press_dictation(HoldSource::Pointer, cx)), + ) + .on_mouse_up( + MouseButton::Left, + cx.listener(|this, _, _, cx| this.release_dictation(HoldSource::Pointer, cx)), + ) + .on_mouse_up_out( + MouseButton::Left, + cx.listener(|this, _, _, cx| this.release_dictation(HoldSource::Pointer, cx)), + ) + .on_key_down(cx.listener(|this, event: &KeyDownEvent, _, cx| { + if matches!(event.keystroke.key.as_str(), "enter" | "space") { + cx.stop_propagation(); + if !event.is_held { + this.press_dictation(HoldSource::Button, cx); + } + } + })) + .on_key_up(cx.listener(|this, event: &KeyUpEvent, _, cx| { + if matches!(event.keystroke.key.as_str(), "enter" | "space") { + cx.stop_propagation(); + this.release_dictation(HoldSource::Button, cx); + } + })) + // Assistive technology cannot hold: Click starts, then finishes. + .on_a11y_action(gpui::AccessibleAction::Click, move |_, _, cx| { + composer + .update(cx, |this, cx| this.toggle_dictation(true, cx)) + .ok(); + }) + .when(t < 1.0, |el| { + let scale = 1.0 - 0.75 * t; + el.child( + centered().child( + crate::icons::icon(crate::icons::MICROPHONE) + .size(px(18.0)) + .text_color(theme.text_muted.opacity(1.0 - t)) + .with_transformation(gpui::Transformation::scale(gpui::size( + scale, scale, + ))), + ), + ) + }) + .when(t > 0.0 && busy, |el| { + el.child( + centered() + .opacity(t) + .child(crate::loaders::mini_mono_spinner( + "dictation-progress", + 2.0, + theme.on_accent, + cx.entity_id(), + cx, + )), + ) + }) + .when(t > 0.0 && !busy, |el| { + // Square grows from a quarter of its size, as the mic shrinks. + let side = 9.0 * (0.25 + 0.75 * t) * if live { 1.0 + 0.08 * glow } else { 1.0 }; + el.child( + centered().child( + div() + .size(px(side)) + .rounded(px(2.5)) + .bg(theme.on_accent.opacity(t)), + ), + ) + }); + Some(button.into_any_element()) + } + + fn dismiss_dictation(&mut self, cx: &mut Context) { + self.input.update(cx, |input, cx| { + input.cancel_dictation(); + cx.emit(DictationInputEvent::Changed); + cx.notify(); + }); + self.focus_pending = true; + cx.notify(); + } + + fn dictation_action_button( + &self, + active: bool, + cx: &mut Context, + ) -> gpui::Stateful { + let theme = Theme::of(cx); + let action_label = if active { + "Cancel dictation" + } else { + "Dismiss dictation message" + }; + let composer = cx.entity().downgrade(); + div() + .id("dictation-dismiss") + .debug_selector(|| "dictation-dismiss".into()) + .role(Role::Button) + .aria_label(action_label) + .tab_index(0) + .flex_none() + .h(px(24.0)) + .px(px(8.0)) + .flex() + .items_center() + .rounded_full() + .text_color(theme.text_muted) + .cursor_pointer() + .hover(|s| s.bg(theme.surface_raised_hover).text_color(theme.text)) + .focus_visible(|s| s.border_1().border_color(theme.accent)) + .on_click(cx.listener(|this, _, _, cx| this.dismiss_dictation(cx))) + .on_key_down(cx.listener(|this, event: &KeyDownEvent, _, cx| { + if matches!(event.keystroke.key.as_str(), "enter" | "space") { + cx.stop_propagation(); + this.dismiss_dictation(cx); + } + })) + .on_a11y_action(gpui::AccessibleAction::Click, move |_, _, cx| { + composer + .update(cx, |this, cx| this.dismiss_dictation(cx)) + .ok(); + }) + } + + fn render_dictation_status(&self, cx: &mut Context) -> Option { + // Live dictation renders inside the composer; only outcomes that + // need reading (no speech, errors) appear below it. + let phase = self.input.read(cx).dictation.phase.clone(); + if phase.active() { + return None; + } + let (title, detail) = phase.status()?; + let (title, detail) = (title.to_owned(), detail.to_owned()); + let phase = &phase; + let failed = matches!( + phase, + crate::dictation::Phase::Denied(_) + | crate::dictation::Phase::Unavailable(_) + | crate::dictation::Phase::Failed(_) + ); + let theme = Theme::of(cx); + Some( + motion::fade_in( + "dictation-message-enter", + div() + .id("dictation-status") + .flex() + .items_start() + .gap(px(8.0)) + .px(px(12.0)) + .text_size(px(12.0)) + .line_height(px(18.0)) + .child( + div() + .flex_none() + .mt(px(6.0)) + .size(px(6.0)) + .rounded_full() + .bg(if failed { + theme.warning + } else { + theme.text_faint + }), + ) + .child( + div() + .id("dictation-live-status") + .role(Role::Status) + .aria_label(format!("{title}. {detail}")) + .flex_1() + .min_w_0() + .flex() + .flex_wrap() + .gap_x(px(8.0)) + .child(div().text_color(theme.text).child(title)) + .child(div().min_w_0().text_color(theme.text_muted).child(detail)), + ) + .child( + self.dictation_action_button(false, cx) + .mt(px(-3.0)) + .child("Dismiss"), + ), ) + .into_any_element(), + ) + } + + /// Advances the dictation morph. Returns its eased progress and the frame + /// to draw: live while dictating, the last live frame while retracting. + fn update_voice(&mut self, window: &mut Window, cx: &App) -> (f32, Option) { + use crate::dictation::{Phase, waveform::Mode}; + let now = Instant::now(); + let reduced = motion::reduced_motion(cx); + let dictation = &self.input.read(cx).dictation; + let active = dictation.phase.active(); + self.voice_tween + .retarget(if active { 1.0 } else { 0.0 }, now, reduced); + let t = self.voice_tween.value(now); + if active { + let meter = &dictation.meter; + let mode = match dictation.phase { + Phase::Listening => Mode::Live, + Phase::Finalizing if meter.since_start(now).is_some() => Mode::Processing, + _ => Mode::Waiting, + }; + let label = dictation + .phase + .status() + .map(|(title, detail)| format!("{title}. {detail}")) + .unwrap_or_default(); + self.voice_last = Some(VoiceFrame { + label, + mode, + bars: meter.bars(now, !reduced), + level: if mode == Mode::Live { + meter.level(now) + } else { + 0.0 + }, + elapsed: meter.since_start(now).map(|_| meter.elapsed(now)), + }); + } else if t <= 0.0 { + self.voice_last = None; + } + if !reduced && (active || !self.voice_tween.settled(now)) { + // Bars scroll, the glow follows the voice, and the morph advances. + window.request_animation_frame(); + } + (t, self.voice_last.clone()) + } + + /// The waveform track that unrolls from Stop across the action row. `t` + /// is the morph progress; the box keeps its final geometry and occludes + /// the controls fading out beneath it. + fn render_voice_track( + &self, + t: f32, + frame: &VoiceFrame, + left: f32, + right: f32, + top: f32, + cx: &mut Context, + ) -> gpui::AnyElement { + use crate::dictation::{glass, waveform}; + let theme = Theme::of(cx).clone(); + let animate = !motion::reduced_motion(cx); + let active = self.input.read(cx).dictation.phase.active(); + let live = frame.mode == waveform::Mode::Live && active; + let clock = frame.elapsed.map(|elapsed| { + let limit = Duration::from_secs(zeron_voice::MAX_SECONDS as u64); + div() + .flex_none() + .font_family(theme.font_mono.clone()) + .text_size(px(11.0)) + .opacity(t) + .text_color(if live && elapsed + VOICE_LIMIT_WARNING >= limit { + theme.warning + } else if live { + theme.text_muted + } else { + theme.text_faint + }) + .child(waveform::clock(elapsed)) + }); + let track = glass::light( + div() + .id("dictation-live-status") + .role(Role::Status) + .aria_label(frame.label.clone()) + .h_full() + .w(gpui::relative(t.max(0.0))) + .min_w(px(VOICE_TRACK_HEIGHT * t.min(1.0))) + .rounded_full() + .overflow_hidden() + .flex() + .items_center() + .gap(px(10.0)) + .pl(px(12.0)) + .pr(px(12.0)), + &theme, + t, + ) + .child( + div() + .flex_1() + .min_w_0() + .h(px(16.0)) + .flex() + .child(waveform::waveform( + frame.bars.clone(), + frame.mode, + waveform::Paint { + ink: theme.text.opacity(0.8), + quiet: theme.text_faint.opacity(0.5), + }, + waveform::Phase { + intro: t, + collapse: if active { 0.0 } else { 1.0 - t }, + }, + animate, + )), + ) + .children(clock); + div() + .id("dictation-track") + .absolute() + .left(px(left)) + .right(px(right)) + .top(px(top)) + .h(px(VOICE_TRACK_HEIGHT)) + .flex() + .justify_end() + .occlude() + .child(track) .into_any_element() } @@ -8840,6 +10502,7 @@ impl Composer { let blocked = self.send_blocked(cx); div() .id("composer-send") + .debug_selector(|| "composer-send".into()) .size(px(28.0)) .flex_none() .rounded_full() @@ -8894,6 +10557,48 @@ impl Focusable for Composer { impl Render for Composer { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + if self.dictation_blur.is_none() { + // Stop, Send and keyboard navigation within the composer must not + // discard capture. Only leaving the whole composer invalidates it. + let focus = self.dictation_focus.clone(); + self.dictation_blur = Some(cx.on_focus_out(&focus, window, |this, _, _, cx| { + this.input.update(cx, |input, cx| { + if !input.dictation.phase.active() { + return; + } + if input.dictation.phase == crate::dictation::Phase::Requesting + && crate::dictation::permission_pending() + { + return; + } + input.cancel_dictation(); + cx.emit(DictationInputEvent::Changed); + cx.notify(); + }); + })); + } + if self.dictation_activation.is_none() { + self.dictation_activation = + Some(cx.observe_window_activation(window, |this, window, cx| { + if !window.is_window_active() { + this.input.update(cx, |input, cx| { + if !input.dictation.phase.active() { + return; + } + // The permission bridge checks the originating key + // window before starting any capture after a prompt. + if input.dictation.phase == crate::dictation::Phase::Requesting + && crate::dictation::permission_pending() + { + return; + } + input.cancel_dictation(); + cx.notify(); + }); + cx.notify(); + } + })); + } if self.focus_pending { self.focus_pending = false; let focus = self.input.focus_handle(cx); @@ -9036,13 +10741,7 @@ impl Render for Composer { }; let expanded = self.expanded_mode; - // Chat-scoped failures render only under their own chat; a global - // failure (no key) renders everywhere. - let failure = self.failure.clone().filter(|_| { - self.failure_key - .as_ref() - .is_none_or(|key| *key == self.current_key) - }); + let failure = self.visible_failure(); // Composer honesty: when the target's delivery path is degraded, say // UP FRONT that a send will queue (a durable local write delivered on // reconnect) instead of letting the button imply instant delivery. @@ -9151,6 +10850,13 @@ impl Render for Composer { self.staged().len() + self.staged_appshots().len(), self.staged_comments(cx).len(), ); + // The checklist tray stacks above the queue (or directly above the + // composer), one step narrower than what follows it. + let has_queue = !self.state.read(cx).queue.is_empty(); + let container = container.when_some( + self.render_todo_panel(has_queue, window, cx), + |el, panel| el.child(motion::fade_quick("composer-todo", div().child(panel))), + ); let container = container.when_some( self.render_queue_panel(show_queue_latest_shortcut, window, cx), |el, panel| { @@ -9203,7 +10909,7 @@ impl Render for Composer { // `morph_t`) animates. Steady state renders exactly the target. // Staged attachments add the wrap strip's height to the pill in BOTH // modes (attachment-ui.tsx AttachmentStrip sits above the input row). - let staged_count = self.staged().len(); + let staged_count = self.tiled_attachments(cx).len(); // The input width excludes the inline controls in compact mode. // Wrap against the pill's content width in both modes, accounting // for the outer container padding and the pill's 1px borders. @@ -9350,6 +11056,11 @@ impl Render for Composer { }); let send_button = self.render_send_button(mode, cx); + let (voice_t, voice_frame) = self.update_voice(window, cx); + let dictating = self.input.read(cx).dictation.phase.active(); + let microphone = self.render_dictation_button(voice_t, voice_frame.as_ref(), cx); + let attach_action = cx.entity().downgrade(); + let attach_hover_key: SharedString = format!("composer-attach-{}", cx.entity_id()).into(); // Attach button — opens the native image picker (the original's hidden // ``); paste/drop also feed // the same strip. The leading utility group owns the spacing between @@ -9365,21 +11076,100 @@ impl Render for Composer { .cursor_pointer() // zeron composer-actions.tsx attach: `transition-colors`. .bg(motion::hover_blend( - "composer-attach", + &attach_hover_key, gpui::transparent_black(), crate::theme::ink(0.10), )) - .on_hover(motion::hover_listener("composer-attach")) - .on_click(cx.listener(|this, _, _, cx| this.open_file_picker(cx))) - .tooltip(crate::settings::widgets::text_tooltip("Attach images")) - .child( - crate::icons::icon(crate::icons::PAPERCLIP) - // Its painted bounds are centered in the 24px viewbox; - // a larger glyph balances the brand icon without moving - // it off-center inside the unchanged 28px hit target. - .size(px(18.0)) - .text_color(theme.text_muted), - ); + .on_hover(motion::hover_listener(attach_hover_key)) + .relative() + // While dictating, the attachment slot becomes Cancel: the + // paperclip and the cross swap with a scale and fade. The action + // follows the dictation phase, never the animation. + .role(Role::Button) + .aria_label(if dictating { + "Cancel dictation" + } else { + "Attach images" + }) + .tab_index(0) + .focus_visible(|style| style.border_1().border_color(theme.accent)) + .when(dictating, |el| { + el.debug_selector(|| "dictation-dismiss".into()) + }) + .tooltip(crate::settings::widgets::text_tooltip(if dictating { + "Cancel dictation" + } else { + "Attach images" + })) + .on_click(cx.listener(move |this, _, _, cx| { + if this.input.read(cx).dictation.phase.active() { + this.dismiss_dictation(cx); + } else { + this.open_file_picker(cx); + } + })) + .on_key_down(cx.listener(|this, event: &KeyDownEvent, _, cx| { + if matches!(event.keystroke.key.as_str(), "enter" | "space") { + cx.stop_propagation(); + if this.input.read(cx).dictation.phase.active() { + this.dismiss_dictation(cx); + } else { + this.open_file_picker(cx); + } + } + })) + .on_a11y_action(gpui::AccessibleAction::Click, move |_, _, cx| { + attach_action + .update(cx, |this, cx| { + if this.input.read(cx).dictation.phase.active() { + this.dismiss_dictation(cx); + } else { + this.open_file_picker(cx); + } + }) + .ok(); + }) + .when(voice_t < 1.0, |el| { + let scale = 1.0 - 0.75 * voice_t; + el.child( + div() + .absolute() + .inset_0() + .flex() + .items_center() + .justify_center() + .child( + crate::icons::icon(crate::icons::PAPERCLIP) + // Its painted bounds are centered in the 24px viewbox; + // a larger glyph balances the brand icon without moving + // it off-center inside the unchanged 28px hit target. + .size(px(18.0)) + .text_color(theme.text_muted.opacity(1.0 - voice_t)) + .with_transformation(gpui::Transformation::scale(gpui::size( + scale, scale, + ))), + ), + ) + }) + .when(voice_t > 0.0, |el| { + let scale = 0.25 + 0.75 * voice_t; + el.child( + div() + .absolute() + .inset_0() + .flex() + .items_center() + .justify_center() + .child( + crate::icons::icon(crate::icons::CLOSE) + .size(px(16.0)) + .text_color(theme.text_muted.opacity(voice_t)) + .with_transformation(gpui::Transformation::scale(gpui::size( + scale, scale, + ))), + ), + ) + }); // Staged-thumbnail strip (attachment-ui.tsx AttachmentStrip), above // the input inside the pill in both modes. let strip = self.render_attachment_strip(&theme, cx); @@ -9397,6 +11187,7 @@ impl Render for Composer { // Open menus keep their own keyboard/search focus. if !this.pickers.read(cx).is_open() { window.focus(&this.input.focus_handle(cx), cx); + window.prevent_default(); } }), ) @@ -9408,31 +11199,9 @@ impl Render for Composer { // The pill's bottom edge is stationary on screen (the composer sits at // the bottom of the shell column; growth moves the TOP edge), so the // controls pin to the bottom and only the text glides with the reveal - // (round-9 follow-up: the send/attach/chips must not ride the height, - // while the model picker fades between its two horizontal anchors). + // while the model selector stays in the right action group. let cluster_dy = morph_cluster_dy(layout_morph_t); let action_inset = morph_cluster_inset(expanded, layout_morph_t); - // Share the height/route timeline instead of starting an independent - // animation. Reversals continue from the current handoff phase. - if self.model_handoff_morph != self.flip_morph { - self.model_handoff_from = self.model_handoff_position; - self.model_handoff_morph = self.flip_morph; - } - let compact_target = if expanded { 0.0 } else { 1.0 }; - self.model_handoff_position = if dock_owns_layout { - dock_compact_amount - } else { - self.flip_morph.map_or(compact_target, |morph| { - motion::lerp( - self.model_handoff_from, - compact_target, - motion::EASE_IN_OUT.eval(morph.raw(now_ms)), - ) - }) - }; - // The shell supplies this frame's animated width before rendering us. - // Measured bounds still belong to the previous frame here; using them - // would add the per-frame width delta to the model selector's glide. let surface_width = self.last_available_width.map_or_else( || { self.surface_bounds @@ -9443,37 +11212,55 @@ impl Render for Composer { }, |width| (width - 2.0 * Theme::SPACE_LG).max(0.0), ); - let model_travel = (surface_width - - PILL_BORDER_V - - action_inset - - 28.0 - - ACTION_UTILITY_GAP - - self - .model_bounds - .get() - .map_or(0.0, |bounds| f32::from(bounds.size.width)) - - ACTION_PRIMARY_GAP - - 28.0 - - action_inset) - .max(0.0); - let (model_side, model_opacity, model_drift) = model_handoff(self.model_handoff_position); - let model_offset = (model_side - compact_target) * model_travel + model_drift; - let measured_model_bounds = self.model_bounds.clone(); let model_picker = div() .min_w_0() .max_w(px(surface_width * 0.45)) .relative() - .left(px(model_offset)) - .opacity(model_opacity) - .child(self.pickers.clone()) - .child( + .child(self.pickers.clone()); + #[cfg(test)] + let model_picker = { + let measured_model_bounds = self.model_bounds.clone(); + model_picker.child( gpui::canvas( move |bounds, _, _| measured_model_bounds.set(Some(bounds)), |_, _, _, _| {}, ) .absolute() .inset_0(), - ); + ) + }; + // The model chip already contributes 6px of trailing padding. Remove + // that from its gap to the narrow microphone glyph so the visible + // label–mic and mic–Send spaces balance without shrinking hit targets. + let model_action_gap = if microphone.is_some() { + ACTION_PRIMARY_GAP - 6.0 + } else { + ACTION_PRIMARY_GAP + }; + // The voice track spans from the attachment slot to Stop, over the + // model selector (and, compact, the text), which fade beneath it. + let voice_track = voice_frame + .as_ref() + .filter(|_| voice_t > 0.0 && microphone.is_some()) + .map(|frame| { + // Expanded: the row's 2px top pad; compact: centred in the + // 47px line, riding the same cluster glide as the buttons. + let top = if expanded { + 2.0 + } else { + (COMPACT_TOTAL_HEIGHT - PILL_BORDER_V - VOICE_TRACK_HEIGHT) / 2.0 - cluster_dy + }; + self.render_voice_track( + voice_t, + frame, + action_inset + 28.0 + VOICE_TRACK_GAP, + action_inset + 28.0 + ACTION_PRIMARY_GAP + 28.0 + VOICE_TRACK_GAP, + top, + cx, + ) + }); + let beneath_voice = 1.0 - voice_t.clamp(0.0, 1.0); + let model_picker = model_picker.opacity(beneath_voice); let body = if expanded { // Expanded: textarea on top (`px-4 pb-1 pt-4`), actions row // (8px bottom + 2px top, 32px chips → 42px) ABSOLUTE at the pill's @@ -9482,7 +11269,7 @@ impl Render for Composer { // text viewport follows the animated height so it cannot paint // over the controls. Its width stays fixed (no tween rewraps); // top padding eases 12→16. Attachment and Send stay on the bottom - // anchor while the model chip fades between its horizontal slots. + // anchor with the model selector always beside Send. pill.h(px(pill_height)) .overflow_hidden() .relative() @@ -9511,10 +11298,8 @@ impl Render for Composer { .flex() .flex_row() .items_center() - // Shared group geometry (see CLUSTER_X_DELTA): the - // attachment belongs to the utility pickers, while - // Send has a larger structural separation. - .gap(px(ACTION_PRIMARY_GAP)) + // Attachment stays left; model, microphone and Send stay right. + .gap(px(model_action_gap)) .px(px(action_inset)) .pt(px(2.0)) .pb(px(ACTIONS_BOTTOM_PAD)) @@ -9526,19 +11311,28 @@ impl Render for Composer { .flex_row() .items_center() .gap(px(ACTION_UTILITY_GAP)) - .child(attach) - .child(model_picker), + .child(attach), + ) + .child(model_picker) + .child( + div() + .flex_none() + .flex() + .items_center() + .gap(px(ACTION_PRIMARY_GAP)) + .children(microphone) + .child(send_button), ) - .child(send_button), + .children(voice_track), ) } else { // Compact pill: attachment on the left, input in the middle, - // then model and Send on the right, all on one 47px line. + // then model, microphone and Send on the right, all on one 47px line. // The row is BOTTOM-justified: during the collapse morph the pill // top sweeps down over a stationary row, the text walks down from // its expanded resting place via a decaying relative offset, and // attachment/Send hold their spots (the centering delta gliding - // in), with the model handoff sharing that same timeline. + // in). The model selector remains beside microphone and Send. let text_glide = if dock_owns_layout { collapse_text_glide(dock_layout.hero_height, dock_compact_amount) } else { @@ -9558,6 +11352,7 @@ impl Render for Composer { .child( div() .h(px(COMPACT_TOTAL_HEIGHT - PILL_BORDER_V)) + .relative() .flex() .flex_row() .items_center() @@ -9567,6 +11362,9 @@ impl Render for Composer { .pl(px(action_inset)) .relative() .top(px(-cluster_dy)) + .flex() + .items_center() + .gap(px(ACTION_UTILITY_GAP)) .child(attach), ) .child( @@ -9576,6 +11374,9 @@ impl Render for Composer { .px(px(8.0)) .relative() .top(px(-text_glide)) + // The draft waits under the voice track and + // fades back as the transcript lands in it. + .opacity(beneath_voice) .child(self.render_input_with_completion()), ) .child( @@ -9589,12 +11390,17 @@ impl Render for Composer { .child( div() .flex_none() - .pl(px(ACTION_PRIMARY_GAP)) + .pl(px(model_action_gap)) .pr(px(action_inset)) .relative() .top(px(-cluster_dy)) + .flex() + .items_center() + .gap(px(ACTION_PRIMARY_GAP)) + .children(microphone) .child(send_button), - ), + ) + .children(voice_track), ) }; let new_thread_target_selectors = (new_thread_chrome_opacity > 0.0).then(|| { @@ -9675,7 +11481,24 @@ impl Render for Composer { } else { container }; - let container = container.child(pill_surface); + let dictation_status = self.render_dictation_status(cx); + let container = container.child( + div() + .track_focus(&self.dictation_focus) + .on_key_down(cx.listener(|this, event: &KeyDownEvent, _, cx| { + if event.keystroke.key == "escape" + && this.input.read(cx).dictation.phase.active() + { + this.dismiss_dictation(cx); + cx.stop_propagation(); + } + })) + .flex() + .flex_col() + .gap(px(Theme::SPACE_SM)) + .child(pill_surface) + .children(dictation_status), + ); // The lower slot keeps a stable footprint for Git projects while its // old floating checkout/ref controls dissolve into the session footer. @@ -9782,11 +11605,14 @@ impl Render for Composer { #[cfg(test)] mod modal_selection_tests; +#[cfg(test)] +mod attachment_chip_tests; + #[cfg(test)] mod tests { use super::*; - fn composer_focus_window( + pub(super) fn composer_focus_window( cx: &mut gpui::TestAppContext, ) -> (tempfile::TempDir, gpui::WindowHandle) { let dir = tempfile::tempdir().unwrap(); @@ -9826,6 +11652,54 @@ mod tests { .unwrap(); } + #[gpui::test] + fn shift_backspace_deletes_text_and_selection_in_all_input_contexts( + cx: &mut gpui::TestAppContext, + ) { + cx.update(|cx| { + gpui_base::init(cx); + cx.set_global(Theme::dark()); + init(cx, ComposerSendBehavior::default()); + }); + for context in [ + GENERIC_COMPOSER_CONTEXT, + MESSAGE_COMPOSER_CONTEXT, + PALETTE_SEARCH_CONTEXT, + ] { + let handle = cx.add_window(|window, cx| { + let mut input = ComposerInput::with_context("", context, cx); + input.set_text("café", cx); + window.focus(&input.focus_handle, cx); + input + }); + cx.update_window(handle.into(), |_, window, cx| window.draw(cx).clear()) + .unwrap(); + cx.simulate_keystrokes(handle.into(), "shift-backspace"); + handle + .update(cx, |input, _, cx| { + assert_eq!(input.text(), "caf", "{context}"); + input.set_text("hello world", cx); + input.move_to(6, cx); + input.extend_selection(11, cx); + }) + .unwrap(); + cx.simulate_keystrokes(handle.into(), "shift-backspace"); + handle + .update(cx, |input, _, cx| { + assert_eq!(input.text(), "hello ", "{context}"); + input.set_text("", cx); + }) + .unwrap(); + cx.simulate_keystrokes(handle.into(), "shift-backspace"); + assert_eq!( + handle + .read_with(cx, |input, _| input.text().to_owned()) + .unwrap(), + "" + ); + } + } + #[gpui::test] fn dock_morph_restores_skinny_height_with_a_continuous_editor_origin( cx: &mut gpui::TestAppContext, @@ -9834,83 +11708,72 @@ mod tests { let input = handle .read_with(cx, |composer, _| composer.input.clone()) .unwrap(); - for thread_width in [436.0, 592.0, 768.0, 1232.0] { - for docked in [true, false] { - let amounts = if docked { - [0.0, 0.2, 0.6, 0.98, 1.0] - } else { - [1.0, 0.98, 0.6, 0.2, 0.0] - }; - for amount in amounts { - let outer_width = motion::lerp(COMPOSER_MAX_WIDTH, thread_width, amount); - cx.update(|cx| { - handle - .update(cx, |composer, window, cx| { - window.resize(size(px(outer_width), px(800.0))); - composer.set_available_width(outer_width, cx); - composer.state.update(cx, |state, _| { - state.selected_chat = docked.then(|| "chat".into()); - }); - composer.on_state_changed(cx); - composer - .input - .update(cx, |input, cx| input.set_text("Hi", cx)); - composer.expanded_mode = false; - let mut frame = crate::composer_dock::DockFrame::settled(docked); - frame.amount = amount; - frame.active = amount != if docked { 1.0 } else { 0.0 }; - composer.set_dock_frame(frame, cx); + // The trigger stays beside Send whichever picker the chip opens. + for compact_picker in [false, true] { + cx.update(|cx| { + crate::settings::update(crate::settings::SavePolicy::Debounced, cx, |settings| { + settings.compact_model_picker = compact_picker; + }); + }); + for thread_width in [436.0, 592.0, 768.0, 1232.0] { + for docked in [true, false] { + let amounts = if docked { + [0.0, 0.2, 0.6, 0.98, 1.0] + } else { + [1.0, 0.98, 0.6, 0.2, 0.0] + }; + for amount in amounts { + let outer_width = motion::lerp(COMPOSER_MAX_WIDTH, thread_width, amount); + cx.update(|cx| { + handle + .update(cx, |composer, window, cx| { + window.resize(size(px(outer_width), px(800.0))); + composer.set_available_width(outer_width, cx); + composer.state.update(cx, |state, _| { + state.selected_chat = docked.then(|| "chat".into()); + }); + composer.on_state_changed(cx); + composer + .input + .update(cx, |input, cx| input.set_text("Hi", cx)); + composer.expanded_mode = false; + let mut frame = crate::composer_dock::DockFrame::settled(docked); + frame.amount = amount; + frame.active = amount != if docked { 1.0 } else { 0.0 }; + composer.set_dock_frame(frame, cx); + }) + .unwrap(); + cx.update_window(handle.into(), |_, window, cx| { + window.refresh(); + window.draw(cx).clear(); }) .unwrap(); - cx.update_window(handle.into(), |_, window, cx| { - window.refresh(); - window.draw(cx).clear(); - }) - .unwrap(); - // Inspect the first painted frame before TestAppContext - // flushes effects and automatically draws dirty views. - handle.read_with(cx, |composer, cx| { - assert_eq!(composer.input, input); - let surface = composer.surface_bounds.get().unwrap(); - assert!((f32::from(surface.size.width) - (outer_width - 2.0 * Theme::SPACE_LG)).abs() <= 1.0, - "surface width clipped: docked={docked}, amount={amount}, outer={outer_width}, surface={surface:?}"); - let origin = input.read(cx).last_bounds.unwrap().origin; - assert!((f32::from(origin.y - surface.top()) - (17.0 - 4.0 * amount)).abs() <= 1.0, - "editor jumped: docked={docked}, amount={amount}, origin={origin:?}, surface={surface:?}"); - let model = composer.model_bounds.get().unwrap(); - let inset = motion::lerp(12.0, 8.0, amount); - let left = surface.left() + px(1.0 + inset + 28.0 + ACTION_UTILITY_GAP); - let travel = surface.size.width - px(2.0 + inset + 28.0 + ACTION_UTILITY_GAP - + ACTION_PRIMARY_GAP + 28.0 + inset) - model.size.width; - let (side, _, drift) = model_handoff(amount); - let expected_x = left + travel * side + px(drift); - assert!((f32::from(model.left() - expected_x)).abs() <= 1.0, - "model jumped: docked={docked}, amount={amount}, actual={model:?}, expected={expected_x:?}"); - let expected = if docked { COMPACT_TOTAL_HEIGHT } else { COMPOSER_MIN_HEIGHT }; - assert!((composer.last_rendered_height + composer.dock_clearance_correction - expected).abs() < 0.1); - assert!((composer.last_rendered_height - motion::lerp(COMPOSER_MIN_HEIGHT, COMPACT_TOTAL_HEIGHT, amount)).abs() < 0.1); - }).unwrap(); - }); + // Inspect the first painted frame before TestAppContext + // flushes effects and automatically draws dirty views. + handle.read_with(cx, |composer, cx| { + assert_eq!(composer.input, input); + let surface = composer.surface_bounds.get().unwrap(); + assert!((f32::from(surface.size.width) - (outer_width - 2.0 * Theme::SPACE_LG)).abs() <= 1.0, + "surface width clipped: docked={docked}, amount={amount}, outer={outer_width}, surface={surface:?}"); + let origin = input.read(cx).last_bounds.unwrap().origin; + assert!((f32::from(origin.y - surface.top()) - (17.0 - 4.0 * amount)).abs() <= 1.0, + "editor jumped: docked={docked}, amount={amount}, origin={origin:?}, surface={surface:?}"); + let model = composer.model_bounds.get().unwrap(); + let inset = motion::lerp(12.0, 8.0, amount); + let expected_right = surface.right() - px(1.0 + inset + 28.0 + ACTION_PRIMARY_GAP); + assert!((f32::from(model.right() - expected_right)).abs() <= 1.0, + "model must stay right aligned: compact_picker={compact_picker}, docked={docked}, amount={amount}, actual={model:?}, expected={expected_right:?}"); + let expected = if docked { COMPACT_TOTAL_HEIGHT } else { COMPOSER_MIN_HEIGHT }; + assert!((composer.last_rendered_height + composer.dock_clearance_correction - expected).abs() < 0.1); + assert!((composer.last_rendered_height - motion::lerp(COMPOSER_MIN_HEIGHT, COMPACT_TOTAL_HEIGHT, amount)).abs() < 0.1); + }).unwrap(); + }); + } } } } } - #[test] - fn model_handoff_hides_relocation_and_keeps_visible_motion_local() { - assert_eq!(model_handoff(0.0), (0.0, 1.0, 0.0)); - assert_eq!(model_handoff(1.0), (1.0, 1.0, -0.0)); - for amount in [0.44, 0.49, 0.50, 0.51, 0.56] { - assert!(model_handoff(amount).1 < 0.0001); - } - for step in 0..=100 { - let (side, opacity, drift) = model_handoff(step as f32 / 100.0); - assert!((0.0..=1.0).contains(&opacity)); - assert!(drift.abs() <= 6.0); - assert!(side == 0.0 || side == 1.0); - } - } - #[gpui::test] fn dock_reflow_paints_attachment_wrap_without_a_height_jump(cx: &mut gpui::TestAppContext) { let (_dir, handle) = composer_focus_window(cx); @@ -10038,7 +11901,6 @@ mod tests { "text growth jumped from {before} to {}", composer.last_rendered_height ); - assert!((composer.model_handoff_position - 0.7).abs() < 0.001); assert!( (f32::from(composer.model_bounds.get().unwrap().left() - model_before)) .abs() @@ -10132,10 +11994,10 @@ mod tests { .read_with(cx, |composer, _| { let surface = composer.surface_bounds.get().unwrap(); let model = composer.model_bounds.get().unwrap(); - let left = surface.left() + px(1.0 + 12.0 + 28.0 + ACTION_UTILITY_GAP); + let right = surface.right() - px(1.0 + 12.0 + 28.0 + ACTION_PRIMARY_GAP); assert!( - (f32::from(model.left() - left)).abs() <= 1.0, - "expanded controls must finish on the left" + (f32::from(model.right() - right)).abs() <= 1.0, + "expanded controls must remain on the right" ); }) .unwrap(); @@ -10225,7 +12087,15 @@ mod tests { cx.update_window(handle.into(), |_, window, cx| { window.draw(cx).clear(); assert!(input.read(cx).focus_handle.is_focused(window)); - assert_eq!(input.read(cx).text(), "Keep this draft"); + // An attached image is mentioned at the caret. + assert_eq!( + input.read(cx).text(), + if accepted { + "Keep this draft [Image 1](zeron-image:1) " + } else { + "Keep this draft" + } + ); }) .unwrap(); assert_eq!( @@ -10242,6 +12112,7 @@ mod tests { composer.add_paths(vec![image_path], cx); }) .unwrap(); + cx.run_until_parked(); cx.update_window(handle.into(), |_, window, cx| { window.draw(cx).clear(); assert!(input.read(cx).focus_handle.is_focused(window)); @@ -10249,6 +12120,34 @@ mod tests { .unwrap(); } + #[gpui::test] + fn staged_files_land_in_the_draft_they_were_added_to(cx: &mut gpui::TestAppContext) { + let (dir, handle) = composer_focus_window(cx); + let bmp = dir.path().join("shot.bmp"); + image::RgbImage::new(1, 1).save(&bmp).unwrap(); + handle + .update(cx, |composer, _, cx| { + composer.current_key = "chat-a".into(); + composer.add_paths(vec![bmp, dir.path().join("notes.txt")], cx); + // Staging runs in the background; navigating meanwhile must + // not move the attachment into the other chat's draft. + composer.current_key = "chat-b".into(); + assert!(composer.attachments.is_empty()); + }) + .unwrap(); + cx.run_until_parked(); + handle + .read_with(cx, |composer, _| { + let names: Vec<_> = composer.attachments["chat-a"] + .iter() + .map(|att| att.name.as_str()) + .collect(); + assert_eq!(names, ["shot.png"]); + assert!(!composer.attachments.contains_key("chat-b")); + }) + .unwrap(); + } + #[gpui::test] fn composer_picker_escape_restores_focus_but_click_away_does_not( cx: &mut gpui::TestAppContext, @@ -10666,6 +12565,48 @@ mod tests { assert!(server_in.try_recv().is_err()); } + /// A run that goes stale purely by the clock (no state frame) drops its + /// pending interrupt at the staleness cutoff, not at some later + /// unrelated state change. + #[gpui::test] + fn interrupt_drops_when_its_run_goes_stale_by_clock_alone(cx: &mut gpui::TestAppContext) { + use chrono::TimeDelta; + let now = chrono::DateTime::parse_from_rfc3339("2026-07-19T12:00:00Z") + .unwrap() + .to_utc(); + let clock = crate::state::TestClock::start(now); + let updated = now - TimeDelta::seconds(10); + let state = cx.new(|cx| { + let mut state = AppState::new(); + state.sessions = vec![zeron_proto::Session { + last_completed_turn: None, + chat_id: "c".into(), + device_id: "remote".into(), + status: zeron_proto::SessionStatus::Working, + started_at: Some(updated), + updated_at: updated, + running_subagents: 0, + }]; + state.selected_chat = Some("c".into()); + state.watch_clock_transitions(cx); + state + }); + let composer = cx.new(|cx| Composer::new(state, cx)); + composer.update(cx, |composer, _| { + assert!(begin_interrupt(&mut composer.interrupting, "c")); + }); + let cutoff = updated + TimeDelta::milliseconds(crate::state::SESSION_STALE_MS + 1); + let before = cutoff - TimeDelta::milliseconds(1); + clock.set(before); + cx.executor() + .advance_clock((before - now).to_std().unwrap()); + assert!(composer.read_with(cx, |composer, _| composer.is_interrupting("c"))); + clock.set(cutoff); + cx.executor() + .advance_clock(std::time::Duration::from_millis(1)); + assert!(!composer.read_with(cx, |composer, _| composer.is_interrupting("c"))); + } + #[gpui::test] fn inline_reference_text_preserves_selection_and_ime(cx: &mut gpui::TestAppContext) { let (_dir, handle) = composer_focus_window(cx); @@ -10693,8 +12634,9 @@ mod tests { assert_eq!(input.projection.mentions.len(), 3); for (mention, display) in &input.projection.mentions { let label = &input.projection.display[display.clone()]; - assert!(label.starts_with(&format!("{MENTION_SIDE_PAD}{}", mention.prefix))); - assert!(label.ends_with(MENTION_SIDE_PAD)); + let _ = mention; + assert!(label.starts_with(&format!("{MENTION_SIDE_PAD}{CHIP_ICON_SLOT}"))); + assert!(label.ends_with(CHIP_TRAILING_PAD)); let shell = input.bounds_for_display_range(display.clone()); assert_eq!(shell.len(), 1); assert!(shell[0].size.width > px(0.0)); @@ -10950,7 +12892,7 @@ mod tests { #[test] fn hidden_markdown_hit_testing_targets_visible_text() { let raw = "***café***\nactive"; - let projection = TextProjection::rich(raw, Some(12..raw.len())); + let projection = TextProjection::rich(raw, Some(12..raw.len()), None); assert_eq!(projection.display_to_raw(0), 3); assert_eq!( &raw[projection.display_to_raw(0)..projection.display_to_raw(1)], @@ -10968,7 +12910,7 @@ mod tests { assert_eq!(shown.graphemes(true).count(), 31); assert!(shown.ends_with(".rs")); let raw = local_file_link(&format!("src/{label}"), false); - let projected = TextProjection::rich(&raw, Some(0..raw.len())); + let projected = TextProjection::rich(&raw, Some(0..raw.len()), None); assert_eq!(projected.mentions[0].0.range, 0..raw.len()); assert!(projected.mentions[0].0.path.ends_with(&label)); assert!(projected.display.contains('…')); @@ -11563,6 +13505,9 @@ mod tests { } input.set_text("**selected**", cx); input.selected_range = 2..10; + for outside in ["/tmp/file.rs", "../file.rs"] { + assert!(!input.insert_dropped_mention(outside, false, cx)); + } assert!(input.insert_dropped_mention("src/main.rs", false, cx)); assert_eq!( input.text(), @@ -11683,9 +13628,10 @@ mod tests { }; let raw = format!("**café** {} end\nactive", invocation.link()); let active = raw.rfind('\n').unwrap() + 1..raw.len(); - let projection = TextProjection::rich(&raw, Some(active)); + let projection = TextProjection::rich(&raw, Some(active), None); assert!(projection.display.starts_with("café")); - assert!(projection.display.contains("$Bla\u{00A0}Bla")); + assert!(projection.display.contains("Bla\u{00A0}Bla")); + assert!(!projection.display.contains('$')); assert_eq!(projection.mentions.len(), 1); let (link, display) = &projection.mentions[0]; assert_eq!(projection.raw_to_display(link.range.start), display.start); @@ -11699,7 +13645,8 @@ mod tests { Some(link.range.start) ); let (sent, _) = sent_mention_display(&raw).unwrap(); - assert!(sent.contains("$bla-bla:bla-bla")); + assert!(sent.contains("bla-bla:bla-bla")); + assert!(!sent.contains('$')); assert!(sent.starts_with("**café**")); } @@ -11877,6 +13824,7 @@ mod tests { MentionTooltipTarget { range, path: path.into(), + attachment: None, } } @@ -12883,34 +14831,30 @@ mod tests { assert_eq!(links.len(), 1); assert_eq!(links[0].path, "src/a file#[x].rs"); assert_eq!(links[0].basename, "a file#[x].rs"); - assert!(!links[0].is_dir); + assert!(!links[0].is_dir()); let folder = local_file_link("src/components", true); assert_eq!(folder, "[components](zeron-file:src/components/)"); let links = file_mention_links(&folder); assert_eq!(links[0].path, "src/components"); - assert!(links[0].is_dir); + assert!(links[0].is_dir()); } #[test] fn dropped_mentions_are_separated_from_surrounding_text() { + let link = local_file_link("src/lib.rs", false); let (inserted, cursor_advance) = - dropped_file_mention("fixnow", 3..3, "src/lib.rs", false).expect("valid drop"); + dropped_reference("fixnow", 3..3, &link).expect("valid drop"); assert_eq!(inserted, " [lib.rs](zeron-file:src/lib.rs) "); assert_eq!(cursor_advance, inserted.len()); + let link = local_file_link("src/components", true); let (inserted, cursor_advance) = - dropped_file_mention("fix now", 3..3, "src/components", true).expect("valid drop"); + dropped_reference("fix now", 3..3, &link).expect("valid drop"); assert_eq!(inserted, " [components](zeron-file:src/components/)"); assert_eq!(cursor_advance, inserted.len() + 1); } - #[test] - fn dropped_mentions_reject_paths_outside_the_workspace() { - assert!(dropped_file_mention("", 0..0, "/tmp/file.rs", false).is_none()); - assert!(dropped_file_mention("", 0..0, "../file.rs", false).is_none()); - } - #[test] fn file_mentions_reject_external_or_noncanonical_markdown() { assert!(file_mention_links("[site](https://example.com/a)").is_empty()); @@ -12941,15 +14885,15 @@ mod tests { range: 0..0, basename: "mod.rs".into(), path: "foo/mod.rs".into(), - is_dir: false, - prefix: '@', + kind: ChipKind::File, + attachment: None, }, FileMentionLink { range: 0..0, basename: "oomod.rs".into(), path: "bar/oomod.rs".into(), - is_dir: false, - prefix: '@', + kind: ChipKind::File, + attachment: None, }, ]; assert_eq!( @@ -12965,7 +14909,7 @@ mod tests { let (link, chip) = &projection.mentions[0]; assert_eq!( &projection.display[chip.clone()], - "\u{00A0}@composer.rs\u{00A0}" + format!("{MENTION_SIDE_PAD}{CHIP_ICON_SLOT}composer.rs{CHIP_TRAILING_PAD}") ); assert_eq!(projection.display_to_raw(chip.start + 1), link.range.start); assert_eq!(projection.display_to_raw(chip.end - 1), link.range.end); @@ -12997,14 +14941,113 @@ mod tests { assert_eq!(spans.len(), 2); assert_eq!( &display[spans[0].range.clone()], - "\u{00A0}@composer.rs\u{00A0}" + format!("{MENTION_SIDE_PAD}{CHIP_ICON_SLOT}composer.rs{CHIP_TRAILING_PAD}") ); - assert!(!spans[0].is_dir); + assert_eq!(spans[0].kind, ChipKind::File); + assert_eq!(spans[1].kind, ChipKind::Directory); assert_eq!(spans[0].path.as_ref(), "src/composer.rs"); - assert!(spans[1].is_dir); assert_eq!(spans[1].path.as_ref(), "src/components/"); } + /// The padding pinned to Geist is exactly the chip's NBSP padding, so the + /// label keeps the interface font and the insets don't follow it. + #[test] + fn chip_pad_ranges_cover_only_the_padding() { + let raw = format!( + "open {} now", + zeron_proto::attachment_mentions::attachment_mention_link(1, None) + ); + let (display, spans) = sent_mention_display(&raw).expect("image chip projects"); + let [lead, trail] = chip_pad_ranges(&spans[0].range); + assert_eq!( + &display[lead.clone()], + format!("{MENTION_SIDE_PAD}{CHIP_ICON_SLOT}") + ); + assert_eq!(&display[trail.clone()], CHIP_TRAILING_PAD); + assert_eq!(&display[lead.end..trail.start], "Image\u{a0}1"); + } + + /// The composer shapes a chip's padding in Geist and everything else, + /// the chip's label included, in the interface font. + #[gpui::test] + fn composer_chip_padding_is_shaped_in_geist(cx: &mut gpui::TestAppContext) { + with_composer_input(cx, |input, window, cx| { + let raw = format!("open {} now", local_file_link("src/composer.rs", false)); + input.set_text(&raw, cx); + let mut style = window.text_style(); + style.font_family = "Geist Mono".into(); + input.layout_text(px(600.0), &style, window, cx); + let (_, chip) = input.projection.mentions[0].clone(); + let pads = chip_pad_ranges(&chip); + let mut at = 0; + let mut pinned = 0; + for run in &input.last_runs { + let range = at..at + run.len; + at = range.end; + if pads.iter().any(|pad| pad.contains(&range.start)) { + assert_eq!(run.font.family.as_ref(), CHIP_PAD_FAMILY, "{range:?}"); + assert!(pads.iter().any(|pad| pad.end >= range.end), "{range:?}"); + pinned += run.len; + } else { + assert_eq!(run.font.family.as_ref(), "Geist Mono", "{range:?}"); + } + } + assert_eq!(pinned, pads.iter().map(|pad| pad.len()).sum::()); + }); + } + + /// The queue's family overrides are every chip's padding, in order and + /// never empty, as `with_font_family_overrides` requires. + #[test] + fn queue_chip_overrides_are_the_padding_in_order() { + let raw = format!( + "{} then {}", + local_file_link("a.rs", false), + zeron_proto::attachment_mentions::attachment_mention_link(1, None), + ); + let (_, spans) = sent_mention_display(&raw).expect("chips project"); + let overrides = chip_pad_overrides(&spans); + let expected: Vec<_> = spans + .iter() + .flat_map(|span| chip_pad_ranges(&span.range)) + .collect(); + assert_eq!(overrides, expected); + assert!( + overrides + .windows(2) + .all(|pair| pair[0].end <= pair[1].start) + ); + } + + /// A chip centers on its label's cap height: the row's middle for Geist, + /// and a nudge toward the label for faces whose caps sit higher or lower. + #[test] + fn chip_centering_follows_the_label_face() { + // A 14 px label on a 22 px row whose top is at y = 100, at 1x. + let center = |ascent: f32, descent: f32, cap: f32| { + let em = |units: f32| units / 1000.0 * 14.0; + label_center_offset(100.0, 22.0, em(ascent), em(descent), em(cap), 1.0) + }; + // Geist, alone or as the padding beside a Geist Mono label. + assert_eq!(center(1005.0, 295.0, 710.0), 0.0); + // A label with much shorter caps sits lower, so the pill follows. + assert_eq!(center(1005.0, 295.0, 560.0), 1.0); + // A tall face lowers the baseline past the next pixel. + assert_eq!(center(1200.0, 295.0, 710.0), 1.0); + // Without a cap height there is nothing to center on. + assert_eq!(center(1005.0, 295.0, 0.0), 0.0); + // The baseline snaps to a pixel where the row lands, so the same + // face can need the pill a pixel lower on a row a little further down. + assert_eq!( + label_center_offset(100.0, 22.0, 14.966, 4.13, 10.0, 1.0), + 0.0 + ); + assert_eq!( + label_center_offset(100.2, 22.0, 14.966, 4.13, 10.0, 1.0), + 1.0 + ); + } + /// Ordinary prompts must stay on the zero-cost path, including ones that /// merely *talk about* the scheme without containing a valid mention. #[test] @@ -14172,3 +16215,7 @@ impl Composer { cx.notify(); } } + +#[cfg(test)] +#[path = "composer_dictation_tests.rs"] +mod dictation_tests; diff --git a/crates/ui/src/composer/attachment_chip_tests.rs b/crates/ui/src/composer/attachment_chip_tests.rs new file mode 100644 index 0000000000..b0d8afb18c --- /dev/null +++ b/crates/ui/src/composer/attachment_chip_tests.rs @@ -0,0 +1,792 @@ +use super::tests::composer_focus_window; +use super::*; +use zeron_proto::attachment_mentions::{attachment_mention_indices, attachment_mention_link}; + +fn png() -> gpui::Image { + let mut bytes = std::io::Cursor::new(Vec::new()); + image::RgbImage::new(1, 1) + .write_to(&mut bytes, image::ImageFormat::Png) + .unwrap(); + gpui::Image::from_bytes(gpui::ImageFormat::Png, bytes.into_inner()) +} + +fn paste_images( + handle: &gpui::WindowHandle, + cx: &mut gpui::TestAppContext, + count: usize, +) { + handle + .update(cx, |composer, _, cx| { + composer.input.update(cx, |_, cx| { + cx.emit(ComposerInputEvent::PastedImages( + (0..count).map(|_| png()).collect(), + )) + }); + }) + .unwrap(); + cx.run_until_parked(); +} + +/// Replace a range of the draft the way typing would, so each edit reaches the +/// composer as an ordinary input edit. +fn edit( + handle: &gpui::WindowHandle, + cx: &mut gpui::TestAppContext, + range_and_text: impl FnOnce(&ComposerInput) -> (Range, String), +) { + handle + .update(cx, |composer, window, cx| { + composer.input.update(cx, |input, cx| { + let (range, new_text) = range_and_text(input); + let range = input.range_to_utf16(&range); + input.replace_text_in_range(Some(range), &new_text, window, cx); + }); + }) + .unwrap(); + cx.run_until_parked(); +} + +fn append(handle: &gpui::WindowHandle, cx: &mut gpui::TestAppContext, new_text: &str) { + let new_text = new_text.to_string(); + edit(handle, cx, move |input| { + let end = input.text().len(); + (end..end, new_text) + }); +} + +fn text(handle: &gpui::WindowHandle, cx: &mut gpui::TestAppContext) -> String { + handle + .read_with(cx, |composer, cx| { + composer.input.read(cx).text().to_string() + }) + .unwrap() +} + +fn staged_names( + handle: &gpui::WindowHandle, + cx: &mut gpui::TestAppContext, +) -> Vec { + handle + .read_with(cx, |composer, _| { + composer + .staged() + .iter() + .map(|att| att.name.clone()) + .collect() + }) + .unwrap() +} + +#[gpui::test] +fn pasting_images_numbers_them_and_mentions_each_at_the_caret(cx: &mut gpui::TestAppContext) { + let (_dir, handle) = composer_focus_window(cx); + handle + .update(cx, |composer, _, cx| { + composer + .input + .update(cx, |input, cx| input.set_text("compare ", cx)); + }) + .unwrap(); + paste_images(&handle, cx, 2); + assert_eq!(staged_names(&handle, cx), ["Image 1.png", "Image 2.png"]); + assert_eq!( + text(&handle, cx), + format!( + "compare {} {} ", + attachment_mention_link(1, None), + attachment_mention_link(2, None) + ) + ); + handle + .read_with(cx, |composer, cx| { + let display = &composer.input.read(cx).projection.display; + assert!( + display.contains(&format!("{CHIP_ICON_SLOT}Image\u{a0}1")), + "{display:?}" + ); + assert_eq!(composer.input.read(cx).projection.mentions.len(), 2); + }) + .unwrap(); +} + +#[gpui::test] +fn one_undo_removes_a_paste_and_redo_restores_it(cx: &mut gpui::TestAppContext) { + let (_dir, handle) = composer_focus_window(cx); + paste_images(&handle, cx, 2); + handle + .update(cx, |composer, window, cx| { + composer + .input + .update(cx, |input, cx| input.undo(&Undo, window, cx)); + }) + .unwrap(); + cx.run_until_parked(); + assert_eq!(text(&handle, cx), ""); + assert!(staged_names(&handle, cx).is_empty()); + handle + .update(cx, |composer, window, cx| { + composer + .input + .update(cx, |input, cx| input.redo(&Redo, window, cx)); + }) + .unwrap(); + cx.run_until_parked(); + assert_eq!(attachment_mention_indices(&text(&handle, cx)), vec![1, 2]); + assert_eq!(staged_names(&handle, cx), ["Image 1.png", "Image 2.png"]); +} + +#[gpui::test] +fn deleting_the_last_chip_unstages_and_undo_restores_the_image(cx: &mut gpui::TestAppContext) { + let (_dir, handle) = composer_focus_window(cx); + paste_images(&handle, cx, 2); + handle + .update(cx, |composer, _, cx| { + composer + .input + .update(cx, |input, cx| input.remove_attachment_chips(1, cx)); + }) + .unwrap(); + cx.run_until_parked(); + assert_eq!(staged_names(&handle, cx), ["Image 2.png"]); + handle + .update(cx, |composer, window, cx| { + composer + .input + .update(cx, |input, cx| input.undo(&Undo, window, cx)); + }) + .unwrap(); + cx.run_until_parked(); + assert_eq!(staged_names(&handle, cx), ["Image 1.png", "Image 2.png"]); +} + +#[gpui::test] +fn numbers_are_never_reused_after_a_removal(cx: &mut gpui::TestAppContext) { + let (_dir, handle) = composer_focus_window(cx); + paste_images(&handle, cx, 2); + handle + .update(cx, |composer, _, cx| { + composer + .input + .update(cx, |input, cx| input.remove_attachment_chips(2, cx)); + }) + .unwrap(); + cx.run_until_parked(); + paste_images(&handle, cx, 1); + assert_eq!(staged_names(&handle, cx), ["Image 1.png", "Image 3.png"]); +} + +#[gpui::test] +fn removing_a_thumbnail_removes_every_chip_for_it(cx: &mut gpui::TestAppContext) { + let (_dir, handle) = composer_focus_window(cx); + paste_images(&handle, cx, 1); + append( + &handle, + cx, + &format!(" and {}", attachment_mention_link(1, None)), + ); + assert_eq!(attachment_mention_indices(&text(&handle, cx)), vec![1]); + assert_eq!(staged_names(&handle, cx).len(), 1); + handle + .update(cx, |composer, _, cx| { + let id = composer.staged()[0].id.clone(); + composer.remove_attachment(&id, cx); + }) + .unwrap(); + cx.run_until_parked(); + assert!(attachment_mention_indices(&text(&handle, cx)).is_empty()); + assert!(!text(&handle, cx).contains("zeron-image")); + assert!(staged_names(&handle, cx).is_empty()); +} + +#[gpui::test] +fn a_chip_may_repeat_and_the_image_stays_until_the_last_is_removed(cx: &mut gpui::TestAppContext) { + let (_dir, handle) = composer_focus_window(cx); + paste_images(&handle, cx, 1); + append( + &handle, + cx, + &format!(" again {}", attachment_mention_link(1, None)), + ); + for expected_left in [1usize, 0] { + edit(&handle, cx, |input| { + let first = zeron_proto::attachment_mentions::attachment_mentions(input.text())[0] + .range + .clone(); + (first, String::new()) + }); + assert_eq!(staged_names(&handle, cx).len(), expected_left); + } +} + +#[gpui::test] +fn attachments_without_chips_are_left_alone(cx: &mut gpui::TestAppContext) { + let (_dir, handle) = composer_focus_window(cx); + handle + .update(cx, |composer, _, cx| { + let key = composer.current_key.clone(); + composer + .attachments + .entry(key) + .or_default() + .push(crate::attachments::stage_clipboard_image(png())); + composer + .input + .update(cx, |input, cx| input.set_text("hello", cx)); + }) + .unwrap(); + cx.run_until_parked(); + assert_eq!(staged_names(&handle, cx), ["image.png"]); +} + +fn tiled_names( + handle: &gpui::WindowHandle, + cx: &mut gpui::TestAppContext, +) -> Vec { + handle + .read_with(cx, |composer, cx| { + composer + .tiled_attachments(cx) + .iter() + .map(|att| att.name.clone()) + .collect() + }) + .unwrap() +} + +#[gpui::test] +fn only_attachments_without_a_chip_get_a_tile(cx: &mut gpui::TestAppContext) { + let (_dir, handle) = composer_focus_window(cx); + paste_images(&handle, cx, 2); + // The chips stand in for the staged images: no tile repeats them. + assert_eq!(staged_names(&handle, cx).len(), 2); + assert!(tiled_names(&handle, cx).is_empty()); + handle + .update(cx, |composer, _, _| { + let key = composer.current_key.clone(); + composer + .attachments + .entry(key) + .or_default() + .push(crate::attachments::stage_clipboard_image(png())); + }) + .unwrap(); + cx.run_until_parked(); + // One with no chip keeps its tile, the only handle it has. + assert_eq!(tiled_names(&handle, cx), ["image.png"]); +} + +#[gpui::test] +fn undoing_a_thumbnail_removal_restores_image_and_chip(cx: &mut gpui::TestAppContext) { + let (_dir, handle) = composer_focus_window(cx); + paste_images(&handle, cx, 1); + handle + .update(cx, |composer, _, cx| { + let id = composer.staged()[0].id.clone(); + composer.remove_attachment(&id, cx); + }) + .unwrap(); + cx.run_until_parked(); + assert!(staged_names(&handle, cx).is_empty()); + handle + .update(cx, |composer, window, cx| { + composer + .input + .update(cx, |input, cx| input.undo(&Undo, window, cx)); + }) + .unwrap(); + cx.run_until_parked(); + assert_eq!(staged_names(&handle, cx), ["Image 1.png"]); + assert_eq!(attachment_mention_indices(&text(&handle, cx)), vec![1]); +} + +fn add_file( + handle: &gpui::WindowHandle, + cx: &mut gpui::TestAppContext, + path: std::path::PathBuf, +) { + handle + .update(cx, |composer, _, cx| composer.add_paths(vec![path], cx)) + .unwrap(); + cx.run_until_parked(); +} + +fn write_file(dir: &tempfile::TempDir, name: &str) -> std::path::PathBuf { + let path = dir.path().join(name); + std::fs::write(&path, b"# notes").unwrap(); + path +} + +#[gpui::test] +fn a_file_attachment_is_staged_with_a_chip_named_after_it(cx: &mut gpui::TestAppContext) { + let (dir, handle) = composer_focus_window(cx); + add_file(&handle, cx, write_file(&dir, "notes.md")); + assert_eq!(staged_names(&handle, cx), ["notes.md"]); + assert_eq!( + text(&handle, cx), + format!("{} ", attachment_mention_link(1, Some("notes.md"))) + ); + handle + .read_with(cx, |composer, cx| { + let input = composer.input.read(cx); + assert_eq!(input.projection.mentions.len(), 1); + assert_eq!(input.projection.mentions[0].0.kind, ChipKind::File); + assert!(input.projection.display.contains("notes.md")); + assert_eq!(composer.staged()[0].bytes(), b"# notes"); + }) + .unwrap(); +} + +#[gpui::test] +fn images_and_files_share_one_numbering(cx: &mut gpui::TestAppContext) { + let (dir, handle) = composer_focus_window(cx); + paste_images(&handle, cx, 1); + add_file(&handle, cx, write_file(&dir, "notes.md")); + paste_images(&handle, cx, 1); + assert_eq!( + staged_names(&handle, cx), + ["Image 1.png", "notes.md", "Image 3.png"] + ); + assert_eq!( + attachment_mention_indices(&text(&handle, cx)), + vec![1, 2, 3] + ); +} + +#[gpui::test] +fn deleting_a_file_chip_removes_the_file_and_undo_restores_it(cx: &mut gpui::TestAppContext) { + let (dir, handle) = composer_focus_window(cx); + add_file(&handle, cx, write_file(&dir, "notes.md")); + handle + .update(cx, |composer, _, cx| { + composer + .input + .update(cx, |input, cx| input.remove_attachment_chips(1, cx)); + }) + .unwrap(); + cx.run_until_parked(); + assert!(staged_names(&handle, cx).is_empty()); + handle + .update(cx, |composer, window, cx| { + composer + .input + .update(cx, |input, cx| input.undo(&Undo, window, cx)); + }) + .unwrap(); + cx.run_until_parked(); + assert_eq!(staged_names(&handle, cx), ["notes.md"]); +} + +#[gpui::test] +fn at_completion_offers_staged_files_by_name(cx: &mut gpui::TestAppContext) { + let (dir, handle) = composer_focus_window(cx); + paste_images(&handle, cx, 1); + add_file(&handle, cx, write_file(&dir, "notes.md")); + append(&handle, cx, " @note"); + handle + .update(cx, |composer, _, cx| { + let rows = composer.mention_attachment_matches(); + assert_eq!( + rows.iter() + .map(|row| row.label.as_str()) + .collect::>(), + ["notes.md"] + ); + assert!(rows[0].image.is_none()); + composer.accept_mention(cx); + }) + .unwrap(); + cx.run_until_parked(); + assert_eq!( + attachment_mention_indices(&text(&handle, cx)), + vec![1, 2], + "the file chip is mentioned a second time" + ); + assert!( + text(&handle, cx) + .trim_end() + .ends_with(&attachment_mention_link(2, Some("notes.md"))) + ); +} + +#[gpui::test] +fn a_folder_is_refused_without_staging(cx: &mut gpui::TestAppContext) { + let (dir, handle) = composer_focus_window(cx); + add_file(&handle, cx, dir.path().to_path_buf()); + assert!(staged_names(&handle, cx).is_empty()); + assert_eq!(text(&handle, cx), ""); + handle + .read_with(cx, |composer, _| assert!(composer.failure().is_some())) + .unwrap(); +} + +#[gpui::test] +fn a_chip_without_a_staged_image_shows_as_plain_text(cx: &mut gpui::TestAppContext) { + let (_dir, handle) = composer_focus_window(cx); + handle + .update(cx, |composer, _, cx| { + composer.input.update(cx, |input, cx| { + input.set_text(format!("see {}", attachment_mention_link(4, None)), cx) + }); + }) + .unwrap(); + cx.run_until_parked(); + handle + .read_with(cx, |composer, cx| { + let input = composer.input.read(cx); + assert!(input.projection.mentions.is_empty()); + assert!(input.projection.display.contains("Image 4")); + assert!(!input.projection.display.contains('@')); + }) + .unwrap(); +} + +#[gpui::test] +fn images_without_chips_still_count_as_content_and_send_state(cx: &mut gpui::TestAppContext) { + let (_dir, handle) = composer_focus_window(cx); + paste_images(&handle, cx, 1); + handle + .read_with(cx, |composer, cx| { + assert!(composer.has_draft(cx)); + assert!(composer_has_content( + composer.input.read(cx).text(), + composer.staged().len(), + 0 + )); + }) + .unwrap(); +} + +#[gpui::test] +fn at_completion_pins_staged_images_above_files_and_filters_them(cx: &mut gpui::TestAppContext) { + let (_dir, handle) = composer_focus_window(cx); + paste_images(&handle, cx, 2); + append(&handle, cx, " @"); + handle + .read_with(cx, |composer, _| { + let matches = composer.mention_attachment_matches(); + assert_eq!( + matches.iter().map(|row| row.index).collect::>(), + vec![1, 2] + ); + assert_eq!(composer.mention.active, Some(0)); + }) + .unwrap(); + append(&handle, cx, "image2"); + handle + .update(cx, |composer, _, cx| { + let matches = composer.mention_attachment_matches(); + assert_eq!( + matches.iter().map(|row| row.index).collect::>(), + vec![2] + ); + composer.accept_mention(cx); + }) + .unwrap(); + cx.run_until_parked(); + assert_eq!( + text(&handle, cx), + format!( + "{} {} {} ", + attachment_mention_link(1, None), + attachment_mention_link(2, None), + attachment_mention_link(2, None) + ) + ); + assert_eq!(staged_names(&handle, cx), ["Image 1.png", "Image 2.png"]); +} + +#[gpui::test] +fn at_completion_has_no_image_rows_when_nothing_is_staged(cx: &mut gpui::TestAppContext) { + let (_dir, handle) = composer_focus_window(cx); + handle + .update(cx, |composer, _, cx| { + composer + .input + .update(cx, |input, cx| input.set_text("@", cx)); + }) + .unwrap(); + cx.run_until_parked(); + handle + .read_with(cx, |composer, _| { + assert!(composer.mention_attachment_matches().is_empty()); + }) + .unwrap(); +} + +#[gpui::test] +fn chips_copied_from_another_draft_paste_as_plain_labels(cx: &mut gpui::TestAppContext) { + let (_dir, handle) = composer_focus_window(cx); + handle + .update(cx, |composer, window, cx| { + let link = attachment_mention_link(1, None); + composer.input.update(cx, |input, cx| { + input.attachment_scope = "chat-a".into(); + cx.write_to_clipboard(ClipboardItem::new_string_with_json_metadata( + "Image 1".into(), + serde_json::json!({ + "zeronComposerV1": link, + "text": "Image 1", + "zeronAttachmentScope": "chat-a", + }), + )); + input.attachment_scope = "chat-b".into(); + input.paste(&Paste, window, cx); + }); + }) + .unwrap(); + assert_eq!(text(&handle, cx), "Image 1"); + handle + .update(cx, |composer, window, cx| { + composer.input.update(cx, |input, cx| { + input.set_text("", cx); + input.attachment_scope = "chat-a".into(); + input.paste(&Paste, window, cx); + }); + }) + .unwrap(); + assert_eq!(text(&handle, cx), attachment_mention_link(1, None)); +} + +#[test] +fn sent_messages_project_image_chips_for_the_transcript() { + let raw = format!("look at {} please", attachment_mention_link(2, None)); + let (display, spans) = sent_mention_display(&raw).expect("image chips project"); + assert_eq!(spans.len(), 1); + assert_eq!(spans[0].kind, ChipKind::Image); + assert_eq!( + &display[spans[0].range.clone()], + format!("{MENTION_SIDE_PAD}{CHIP_ICON_SLOT}Image\u{a0}2{CHIP_TRAILING_PAD}") + ); + assert!(sent_mention_display("Image 2 without a link").is_none()); + assert!(sent_mention_display("[Image 2](zeron-image:9)").is_none()); +} + +#[test] +fn file_chips_use_the_file_theme_to_tell_formats_apart() { + use crate::theme::Appearance::Dark; + let themed = |kind, name| match chip_icon(kind, name, Dark) { + ChipIcon::FileTheme(path) => path, + ChipIcon::Glyph(_) => panic!("{name} should use the file theme"), + }; + assert_ne!( + themed(ChipKind::File, "README.md"), + themed(ChipKind::File, "src/main.rs") + ); + assert_ne!( + themed(ChipKind::File, "logo.png"), + themed(ChipKind::File, "src/lib.rs") + ); + assert_ne!( + themed(ChipKind::File, "src/lib.rs"), + themed(ChipKind::Directory, "src/") + ); + assert!(matches!( + chip_icon(ChipKind::Image, "", Dark), + ChipIcon::Glyph(_) + )); +} + +/// Put a queued message carrying `Image 1` and `notes.md` into the composer, +/// as an acquired edit does. +fn begin_queued_edit( + handle: &gpui::WindowHandle, + cx: &mut gpui::TestAppContext, + dir: &tempfile::TempDir, +) { + let notes = crate::attachments::stage_file(&write_file(dir, "notes.md")).unwrap(); + let image = crate::attachments::stage_png_bytes("ab12cd34-Image_1.png".into(), Vec::new()); + let text = format!( + "summarize {} and {}", + attachment_mention_link(1, None), + attachment_mention_link(2, Some("notes.md")) + ); + handle + .update(cx, |composer, _, cx| { + composer.editing_queued = Some("row".into()); + composer.swap_in_queued_draft(text, vec![image, notes], Vec::new(), cx); + }) + .unwrap(); + cx.run_until_parked(); +} + +fn remove_chip(handle: &gpui::WindowHandle, cx: &mut gpui::TestAppContext, index: u32) { + edit(handle, cx, move |input| { + let chip = zeron_proto::attachment_mentions::attachment_mentions(input.text()) + .into_iter() + .find(|chip| chip.index == index) + .unwrap(); + (chip.range, String::new()) + }); +} + +#[gpui::test] +fn a_queued_edit_keeps_its_chips_live_and_deleting_one_unstages_it(cx: &mut gpui::TestAppContext) { + let (dir, handle) = composer_focus_window(cx); + begin_queued_edit(&handle, cx, &dir); + // The restored attachments (named as uploaded) take the numbers of the + // chips naming them. + assert_eq!( + staged_names(&handle, cx), + ["ab12cd34-Image_1.png", "notes.md"] + ); + handle + .read_with(cx, |composer, cx| { + assert_eq!(composer.input.read(cx).projection.mentions.len(), 2); + }) + .unwrap(); + + // A file attached during the edit is numbered after the message's chips. + add_file(&handle, cx, write_file(&dir, "logs.zip")); + assert_eq!( + staged_names(&handle, cx), + ["ab12cd34-Image_1.png", "notes.md", "logs.zip"] + ); + assert_eq!( + attachment_mention_indices(&text(&handle, cx)), + vec![1, 2, 3] + ); + + // Deleting its chip unstages it, so saving would not upload it... + remove_chip(&handle, cx, 3); + assert_eq!( + staged_names(&handle, cx), + ["ab12cd34-Image_1.png", "notes.md"] + ); + // ...and undo brings it back. + handle + .update(cx, |composer, window, cx| { + composer + .input + .update(cx, |input, cx| input.undo(&Undo, window, cx)); + }) + .unwrap(); + cx.run_until_parked(); + assert_eq!( + staged_names(&handle, cx), + ["ab12cd34-Image_1.png", "notes.md", "logs.zip"] + ); + + // A restored file's chip deletes the same way, and the saved text never + // carries a chip for an attachment that is not saved with it. + remove_chip(&handle, cx, 2); + append( + &handle, + cx, + &format!(" {}", attachment_mention_link(9, None)), + ); + assert_eq!( + staged_names(&handle, cx), + ["ab12cd34-Image_1.png", "logs.zip"] + ); + handle + .read_with(cx, |composer, cx| { + let saved = composer.queue_edit_text(cx); + assert_eq!(attachment_mention_indices(&saved), vec![1, 3]); + assert!(saved.ends_with(" Image 9"), "{saved:?}"); + }) + .unwrap(); +} + +#[gpui::test] +fn leaving_a_queued_edit_restores_the_draft_and_its_numbering(cx: &mut gpui::TestAppContext) { + let (dir, handle) = composer_focus_window(cx); + handle + .update(cx, |composer, _, cx| { + composer + .input + .update(cx, |input, cx| input.set_text("draft ", cx)); + }) + .unwrap(); + paste_images(&handle, cx, 1); + let draft = text(&handle, cx); + + begin_queued_edit(&handle, cx, &dir); + add_file(&handle, cx, write_file(&dir, "logs.zip")); + remove_chip(&handle, cx, 3); + handle + .update(cx, |composer, _, cx| composer.clear_queue_edit_local(cx)) + .unwrap(); + cx.run_until_parked(); + + // The draft comes back with its own image and live chip... + assert_eq!(text(&handle, cx), draft); + assert_eq!(staged_names(&handle, cx), ["Image 1.png"]); + handle + .read_with(cx, |composer, cx| { + assert_eq!(composer.input.read(cx).projection.mentions.len(), 1); + }) + .unwrap(); + // ...and its own numbering: the edit's numbers never leak into it. + paste_images(&handle, cx, 1); + assert_eq!(staged_names(&handle, cx), ["Image 1.png", "Image 2.png"]); +} + +/// Press on the first chip and release `drag` away from where it went down. +fn click_first_chip( + handle: &gpui::WindowHandle, + cx: &mut gpui::TestAppContext, + drag: Point, +) { + cx.update_window((*handle).into(), |_, window, cx| window.draw(cx).clear()) + .unwrap(); + handle + .update(cx, |composer, window, cx| { + composer.input.update(cx, |input, cx| { + let at = input.mention_hits[0].bounds.center(); + input.on_mouse_down( + &MouseDownEvent { + button: MouseButton::Left, + position: at, + click_count: 1, + ..Default::default() + }, + window, + cx, + ); + input.on_mouse_up( + &MouseUpEvent { + button: MouseButton::Left, + position: at + drag, + click_count: 1, + ..Default::default() + }, + window, + cx, + ); + }); + }) + .unwrap(); + cx.run_until_parked(); +} + +#[gpui::test] +fn clicking_an_image_chip_opens_the_picture_full_size(cx: &mut gpui::TestAppContext) { + let (_dir, handle) = composer_focus_window(cx); + paste_images(&handle, cx, 1); + // Hovering shows no card: the click is how the picture is seen. + cx.update_window(handle.into(), |_, window, cx| window.draw(cx).clear()) + .unwrap(); + handle + .update(cx, |composer, _, cx| { + composer.input.update(cx, |input, cx| { + let at = input.mention_hits[0].bounds.center(); + input.on_mention_pointer_move(at, cx); + assert_eq!(input.mention_tooltip, MentionTooltipPhase::Hidden); + }); + }) + .unwrap(); + + // A drag across the chip only selects. + click_first_chip(&handle, cx, point(px(40.0), px(0.0))); + handle + .read_with(cx, |composer, _| assert!(composer.preview.is_none())) + .unwrap(); + + click_first_chip(&handle, cx, point(px(0.0), px(0.0))); + handle + .read_with(cx, |composer, _| { + let preview = composer.preview.as_ref().expect("the chip opens its image"); + assert_eq!(preview.name.as_ref(), "Image 1.png"); + }) + .unwrap(); +} diff --git a/crates/ui/src/composer/chip.rs b/crates/ui/src/composer/chip.rs new file mode 100644 index 0000000000..25d2481eab --- /dev/null +++ b/crates/ui/src/composer/chip.rs @@ -0,0 +1,293 @@ +//! What a chip looks like: its pill, icon and hover preview. +use super::*; + +/// A staged attachment an attachment chip can refer to; an image chip opens +/// its picture on click. +pub(super) struct ChipAttachment { + pub(super) image: Option>, +} + +/// Spaces reserve room at the start of every chip for its icon well, which is +/// painted over them, so the text, caret and wrapping all treat the chip as +/// plain text. Non-breaking spaces, because the bundled Geist has no wider or +/// thinner ones. +pub(super) const CHIP_ICON_SLOT: &str = "\u{00A0}\u{00A0}\u{00A0}\u{00A0}\u{00A0}"; +/// Breathing room after a chip's label. +pub(super) const CHIP_TRAILING_PAD: &str = "\u{00A0}\u{00A0}"; +/// The bundled face a chip's padding is shaped in. A space's advance follows +/// the font (Geist Mono's is 2.4× Geist's), and the counts above are tuned to +/// Geist, so pinning the padding keeps every chip's icon slot and insets the +/// same whatever the interface font. The label keeps the surrounding font. +pub(crate) const CHIP_PAD_FAMILY: &str = "Geist"; +/// The icon sits at this size in its well. +const CHIP_ICON_SIZE: f32 = 14.0; + +/// What a chip refers to; it picks the chip's icon. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ChipKind { + Image, + Skill, + Command, + File, + Directory, +} + +/// What a chip's icon is painted from: a monochrome glyph tinted like the +/// chip, or the file theme's own polychrome artwork, which tells formats and +/// languages apart. +pub(crate) enum ChipIcon { + Glyph(&'static str), + FileTheme(SharedString), +} + +/// The padding around the label in a chip's display `range`: the side +/// bearing and icon slot before it, and the trailing room after it. Callers +/// shape both in [`CHIP_PAD_FAMILY`]. +pub(crate) fn chip_pad_ranges(range: &Range) -> [Range; 2] { + let lead_end = (range.start + MENTION_SIDE_PAD.len() + CHIP_ICON_SLOT.len()).min(range.end); + let trail_start = range + .end + .saturating_sub(CHIP_TRAILING_PAD.len()) + .max(lead_end); + [range.start..lead_end, trail_start..range.end] +} + +/// How far to move a chip's pill down from the middle of its row, which +/// starts at `row_top`, so the pill centers its label's caps. GPUI puts a +/// line's baseline `(ascent - descent) / 2` below the row's middle, with the +/// tallest face's metrics, then rounds it to a device pixel (`scale` per +/// point). Geist's caps sit on the middle (1005 - 295 = 710, its cap height); +/// other faces and the rounding drift them by a pixel or so. Everything is in +/// window coordinates, since the rounding depends on where the row lands. +pub(super) fn label_center_offset( + row_top: f32, + line_height: f32, + ascent: f32, + descent: f32, + cap_height: f32, + scale: f32, +) -> f32 { + if cap_height <= 0.0 { + // A face without a cap height: keep the row's middle. + return 0.0; + } + // GPUI's glyph snapping: halves round toward zero. + let snap = |y: f32| { + let device = y * scale; + let rounded = if device.fract().abs() == 0.5 { + device.trunc() + } else { + device.round() + }; + rounded / scale + }; + let baseline = snap(row_top + (line_height - ascent - descent) / 2.0 + ascent); + let offset = baseline - cap_height / 2.0 - (row_top + line_height / 2.0); + // The pill lands on whole device pixels too; a fraction would only + // round it away from a label that is already centered. + snap(offset) +} + +/// How far to move a chip's pill down from the middle of its row so it +/// centers the label (see [`paint_chip`]). `label` is the label's first byte +/// within `line`, and `row_top` the row's top in window coordinates. The +/// shift stays within `room`, the pill's inset from its row, so it never +/// reaches into the next row. +pub(crate) fn chip_label_offset( + line: &gpui::LineLayout, + label: usize, + row_top: Pixels, + line_height: Pixels, + room: Pixels, + window: &Window, +) -> Pixels { + // The face that actually shaped the label, fallbacks included. + let Some(font_id) = line + .runs + .iter() + .find(|run| run.glyphs.iter().any(|glyph| glyph.index >= label)) + .map(|run| run.font_id) + else { + return px(0.0); + }; + let cap_height = window.text_system().cap_height(font_id, line.font_size); + let offset = label_center_offset( + f32::from(row_top), + f32::from(line_height), + f32::from(line.ascent), + f32::from(line.descent), + f32::from(cap_height), + window.scale_factor(), + ); + px(offset.clamp(-f32::from(room), f32::from(room))) +} + +/// [`chip_label_offset`] for a chip at byte `label` of a text element's +/// `layout`, whose lines are its text split at newlines. +pub(crate) fn text_chip_label_offset( + layout: &gpui::TextLayout, + label: usize, + row_top: Pixels, + room: Pixels, + window: &Window, +) -> Pixels { + let mut start = 0; + for line in layout.line_layouts() { + let end = start + line.unwrapped_layout.len; + if label < end { + return chip_label_offset( + &line.unwrapped_layout, + label - start, + row_top, + layout.line_height(), + room, + window, + ); + } + start = end + 1; + } + px(0.0) +} + +/// The non-empty padding ranges of `spans`, in order: what [`chip_text`] +/// shapes in [`CHIP_PAD_FAMILY`]. +pub(super) fn chip_pad_overrides(spans: &[SentMentionSpan]) -> Vec> { + spans + .iter() + .flat_map(|span| chip_pad_ranges(&span.range)) + .filter(|range| !range.is_empty()) + .collect() +} + +/// The icon of a chip. `path` names the file or folder for the file kinds. +pub(crate) fn chip_icon( + kind: ChipKind, + path: &str, + appearance: crate::theme::Appearance, +) -> ChipIcon { + use crate::file_icons::{FileIconIdentity, asset_path}; + match kind { + ChipKind::Image => ChipIcon::Glyph(crate::icons::GALLERY), + ChipKind::Skill => ChipIcon::Glyph(crate::icons::MAGIC_STICK_3), + ChipKind::Command => ChipIcon::Glyph(crate::icons::COMMAND), + ChipKind::File => ChipIcon::FileTheme(asset_path(FileIconIdentity::file(path), appearance)), + ChipKind::Directory => ChipIcon::FileTheme(asset_path( + FileIconIdentity::directory(path.trim_end_matches('/'), false), + appearance, + )), + } +} + +/// Paint a chip like the transcript's file badges: a soft rounded pill whose +/// first row starts with the icon on a small well. Callers center `chip` on +/// the label's cap height: the row's middle moved by [`chip_label_offset`], +/// which is zero for Geist (ascent 1005, descent 295, cap 710 per 1000 units) +/// and corrects for other interface fonts. +pub(crate) fn paint_chip( + window: &mut Window, + chip: Bounds, + icon: Option<&ChipIcon>, + theme: &Theme, + cx: &App, +) { + window.paint_quad(quad( + chip, + px(5.0), + theme.ink(0.09), + px(0.0), + gpui::transparent_black(), + BorderStyle::default(), + )); + let Some(icon) = icon else { + return; + }; + let well = chip.size.height - px(2.0); + let well_bounds = Bounds::new(chip.origin + point(px(1.0), px(1.0)), size(well, well)); + window.paint_quad(quad( + well_bounds, + px(4.0), + crate::file_icons::well_bg(theme), + px(0.0), + gpui::transparent_black(), + BorderStyle::default(), + )); + // The icon follows the chip's size, so chips on a small line stay tidy. + let side = px(CHIP_ICON_SIZE).min(well - px(4.0)); + let bounds = Bounds::new( + well_bounds.origin + point((well - side) / 2.0, (well - side) / 2.0), + size(side, side), + ); + match icon { + ChipIcon::Glyph(path) => { + let _ = window.paint_svg( + bounds, + (*path).into(), + None, + gpui::TransformationMatrix::default(), + theme.text_muted, + cx, + ); + } + ChipIcon::FileTheme(path) => { + if let Some(image) = crate::file_icons::raster(path, cx) { + let _ = window.paint_image(bounds, Default::default(), image, 0, false); + } + } + } +} + +/// A sent message's text with its chips painted over their spans, for rows +/// that show one line of it (the queue). `text` is the projected display text +/// and `spans` its chips, as [`sent_mention_display`] returns them. +pub(crate) fn chip_text( + text: SharedString, + spans: Vec, + theme: &Theme, +) -> gpui::AnyElement { + if spans.is_empty() { + return text.into_any_element(); + } + // Split runs at the padding (a default highlight changes nothing else), + // so the family override lands on whole runs over the inherited style. + let pads = chip_pad_overrides(&spans); + let styled = gpui::StyledText::new(text) + .with_highlights( + pads.iter() + .map(|range| (range.clone(), gpui::HighlightStyle::default())), + ) + .with_font_family_overrides( + pads.into_iter() + .map(|range| (range, SharedString::from(CHIP_PAD_FAMILY))), + ); + let layout = styled.layout().clone(); + let icons: Vec = spans + .iter() + .map(|span| chip_icon(span.kind, &span.path, theme.appearance)) + .collect(); + let theme = theme.clone(); + let underlay = gpui::canvas( + |_, _, _| (), + move |_, _, window, cx| { + for (span, icon) in spans.iter().zip(&icons) { + let label = chip_pad_ranges(&span.range)[0].end; + for (row, rect) in + crate::markdown::render::range_rects(&layout, &span.range, 0.0, 2.0) + .into_iter() + .enumerate() + { + let row_top = rect.origin.y - px(2.0); + let offset = text_chip_label_offset(&layout, label, row_top, px(2.0), window); + let rect = Bounds::new(rect.origin + point(px(0.0), offset), rect.size); + paint_chip(window, rect, (row == 0).then_some(icon), &theme, cx); + } + } + }, + ) + .absolute() + .size_full(); + div() + .relative() + .child(underlay) + .child(styled) + .into_any_element() +} diff --git a/crates/ui/src/composer_dictation_tests.rs b/crates/ui/src/composer_dictation_tests.rs new file mode 100644 index 0000000000..30e0f19914 --- /dev/null +++ b/crates/ui/src/composer_dictation_tests.rs @@ -0,0 +1,1199 @@ +use super::*; +use crate::dictation::{Event, Phase, Transcriber}; +use gpui::{AppContext, TestAppContext}; +use std::{cell::RefCell, collections::VecDeque}; + +#[derive(Default)] +struct FakeState { + events: VecDeque, + finishes: usize, + drops: usize, +} + +struct Fake(Rc>); +impl Transcriber for Fake { + fn poll(&mut self) -> Option { + self.0.borrow_mut().events.pop_front() + } + fn finish(&mut self) { + self.0.borrow_mut().finishes += 1; + } +} +impl Drop for Fake { + fn drop(&mut self) { + self.0.borrow_mut().drops += 1; + } +} + +fn start(input: &mut ComposerInput, cx: &mut Context) -> Rc> { + let fake = Rc::new(RefCell::new(FakeState::default())); + input.begin_dictation(Box::new(Fake(fake.clone())), cx); + fake +} + +fn deliver( + input: &mut ComposerInput, + fake: &Rc>, + events: impl IntoIterator, + cx: &mut Context, +) { + fake.borrow_mut().events.extend(events); + input.poll_dictation(input.dictation.generation, cx); +} + +#[gpui::test] +fn dictation_replaces_unicode_selection_and_undoes_as_one_edit(cx: &mut TestAppContext) { + cx.update(|cx| cx.set_global(Theme::dark())); + let window = cx.add_window(|_, cx| ComposerInput::new("Draft", cx)); + window + .update(cx, |input, window, cx| { + input.set_text("👩🏽‍💻 replace café", cx); + let begin = input.content.find("replace").unwrap(); + input.selected_range = begin..begin + "replace".len(); + let fake = start(input, cx); + deliver( + input, + &fake, + [ + Event::Listening, + Event::Partial("你好".into()), + Event::Partial("bonjour 🌍".into()), + ], + cx, + ); + assert_eq!(input.content, "👩🏽‍💻 bonjour 🌍 café"); + assert_eq!(input.undo_stack.len(), 1); + input.finish_dictation(false, cx); + deliver(input, &fake, [Event::Final("salut 🌍".into())], cx); + assert_eq!(input.content, "👩🏽‍💻 salut 🌍 café"); + assert_eq!(fake.borrow().drops, 1); + input.undo(&Undo, window, cx); + assert_eq!(input.content, "👩🏽‍💻 replace café"); + assert_eq!(input.selected_range, begin..begin + 7); + input.redo(&Redo, window, cx); + assert_eq!(input.content, "👩🏽‍💻 salut 🌍 café"); + }) + .unwrap(); +} + +#[gpui::test] +fn dictation_manual_edit_stops_before_replacing_partial(cx: &mut TestAppContext) { + cx.update(|cx| cx.set_global(Theme::dark())); + let window = cx.add_window(|_, cx| ComposerInput::new("Draft", cx)); + window + .update(cx, |input, window, cx| { + input.set_text("before after", cx); + input.selected_range = 7..7; + let fake = start(input, cx); + let old_generation = input.dictation.generation; + deliver(input, &fake, [Event::Partial("speech ".into())], cx); + input.replace_text_in_range(Some(7..13), "typed", window, cx); + assert_eq!(input.content, "before typed after"); + assert_eq!(fake.borrow().drops, 1); + fake.borrow_mut() + .events + .push_back(Event::Final("stale".into())); + assert!(!input.poll_dictation(old_generation, cx)); + assert_eq!(input.content, "before typed after"); + input.undo(&Undo, window, cx); + assert_eq!(input.content, "before speech after"); + input.undo(&Undo, window, cx); + assert_eq!(input.content, "before after"); + }) + .unwrap(); +} + +#[gpui::test] +fn dictation_ime_commit_preserves_composition_and_history(cx: &mut TestAppContext) { + cx.update(|cx| cx.set_global(Theme::dark())); + let window = cx.add_window(|_, cx| ComposerInput::new("Draft", cx)); + window + .update(cx, |input, window, cx| { + let fake = start(input, cx); + deliver(input, &fake, [Event::Partial("Hello ".into())], cx); + input.replace_and_mark_text_in_range(None, "に", None, window, cx); + assert_eq!(fake.borrow().drops, 1); + input.replace_and_mark_text_in_range(None, "日本", None, window, cx); + input.replace_text_in_range(None, "日本語", window, cx); + assert_eq!(input.content, "Hello 日本語"); + input.undo(&Undo, window, cx); + assert_eq!(input.content, "Hello "); + input.undo(&Undo, window, cx); + assert_eq!(input.content, ""); + }) + .unwrap(); +} + +#[gpui::test] +fn dictation_selection_move_and_undo_during_permissions_cancel(cx: &mut TestAppContext) { + cx.update(|cx| cx.set_global(Theme::dark())); + let window = cx.add_window(|_, cx| ComposerInput::new("Draft", cx)); + window + .update(cx, |input, window, cx| { + input.set_text("draft", cx); + let fake = start(input, cx); + input.undo(&Undo, window, cx); // No history is still a deliberate cancellation. + assert_eq!(fake.borrow().drops, 1); + let fake = start(input, cx); + deliver(input, &fake, [Event::Partial(" words".into())], cx); + input.move_to(0, cx); + assert_eq!(fake.borrow().drops, 1); + assert_eq!(input.content, "draft words"); + assert_eq!(input.selected_range, 0..0); + }) + .unwrap(); +} + +#[gpui::test] +fn dictation_send_finalizes_once_and_keeps_latest_final(cx: &mut TestAppContext) { + let input = cx.new(|cx| ComposerInput::new("Draft", cx)); + let mut events = cx.events::(&input); + input.update(cx, |input, cx| { + let fake = start(input, cx); + deliver( + input, + &fake, + [Event::Listening, Event::Partial("hel".into())], + cx, + ); + assert!(input.finish_dictation(true, cx)); + assert!(input.finish_dictation(true, cx)); + assert!(input.finish_dictation(false, cx)); + assert_eq!(fake.borrow().finishes, 1); + deliver( + input, + &fake, + [ + Event::Final("hello".into()), + Event::Final("duplicate".into()), + ], + cx, + ); + assert_eq!(input.content, "hello"); + assert_eq!(input.dictation.phase, Phase::Idle); + assert_eq!(fake.borrow().drops, 1); + }); + let mut sends = 0; + while let Ok(event) = events.try_recv() { + if matches!(event, DictationInputEvent::Submit(_)) { + sends += 1; + } + } + assert_eq!(sends, 1); +} + +#[gpui::test] +fn dictation_failure_cancels_send_and_retry_preserves_draft(cx: &mut TestAppContext) { + let input = cx.new(|cx| ComposerInput::new("Draft", cx)); + let mut events = cx.events::(&input); + input.update(cx, |input, cx| { + let fake = start(input, cx); + deliver(input, &fake, [Event::Partial("keep me".into())], cx); + input.finish_dictation(true, cx); + deliver(input, &fake, [Event::Failed("Disconnected".into())], cx); + assert_eq!(input.content, "keep me"); + assert_eq!(input.dictation.phase, Phase::Failed("Disconnected".into())); + assert_eq!(fake.borrow().drops, 1); + for error in [ + Event::Denied("Permission denied".into()), + Event::Unavailable("Locale unavailable".into()), + ] { + let fake = start(input, cx); + deliver(input, &fake, [error], cx); + assert_eq!(input.content, "keep me"); + assert!(!input.dictation.phase.active()); + assert_eq!(fake.borrow().drops, 1); + } + let fake = start(input, cx); + deliver( + input, + &fake, + [ + Event::Partial(" again".into()), + Event::Final(" again".into()), + ], + cx, + ); + assert_eq!(input.content, "keep me again"); + }); + while let Ok(event) = events.try_recv() { + assert!(!matches!(event, DictationInputEvent::Submit(_))); + } +} + +#[gpui::test] +fn dictation_empty_final_retains_selection_or_partial(cx: &mut TestAppContext) { + let input = cx.new(|cx| ComposerInput::new("Draft", cx)); + input.update(cx, |input, cx| { + for partial in [None, Some("replacement")] { + input.set_text("selected", cx); + input.selected_range = 0..8; + let fake = start(input, cx); + if let Some(text) = partial { + deliver(input, &fake, [Event::Partial(text.into())], cx); + } + input.finish_dictation(false, cx); + deliver(input, &fake, [Event::Final(String::new())], cx); + assert_eq!(input.content, partial.unwrap_or("selected")); + assert_eq!(input.undo_stack.len(), usize::from(partial.is_some())); + } + }); +} + +#[gpui::test] +fn dictation_draft_swap_invalidates_permissions_finalization_and_stale_results( + cx: &mut TestAppContext, +) { + let input = cx.new(|cx| ComposerInput::new("Draft", cx)); + let mut events = cx.events::(&input); + input.update(cx, |input, cx| { + for finalizing in [false, true] { + input.set_text("original", cx); + let fake = start(input, cx); + let generation = input.dictation.generation; + if finalizing { + input.finish_dictation(true, cx); + } + input.set_text("different chat / queued draft", cx); + fake.borrow_mut() + .events + .extend([Event::Listening, Event::Final("late".into())]); + assert!(!input.poll_dictation(generation, cx)); + assert_eq!(fake.borrow().drops, 1); + assert_eq!(input.content, "different chat / queued draft"); + assert!(input.undo_stack.is_empty()); + } + }); + while let Ok(event) = events.try_recv() { + assert!(!matches!(event, DictationInputEvent::Submit(_))); + } +} + +#[gpui::test] +fn dictation_cancelled_native_capture_drops_pending_send(cx: &mut TestAppContext) { + let input = cx.new(|cx| ComposerInput::new("Draft", cx)); + let mut events = cx.events::(&input); + input.update(cx, |input, cx| { + let fake = start(input, cx); + deliver(input, &fake, [Event::Partial("keep draft".into())], cx); + input.finish_dictation(true, cx); + deliver(input, &fake, [Event::Cancelled], cx); + assert_eq!(fake.borrow().drops, 1); + assert_eq!(input.content, "keep draft"); + assert_eq!(input.dictation.phase, Phase::Idle); + }); + while let Ok(event) = events.try_recv() { + assert!(!matches!(event, DictationInputEvent::Submit(_))); + } +} + +#[gpui::test] +fn dictation_releasing_input_releases_microphone(cx: &mut TestAppContext) { + let input = cx.new(|cx| ComposerInput::new("Draft", cx)); + let fake = input.update(cx, |input, cx| start(input, cx)); + drop(input); + cx.update(|_| {}); // Entity cleanup runs at the end of the update. + assert_eq!(fake.borrow().drops, 1); +} + +#[gpui::test] +fn dictation_empty_send_does_not_interrupt_or_send(cx: &mut TestAppContext) { + let state = cx.new(|_| AppState::new()); + let composer = cx.new(|cx| Composer::new(state, cx)); + composer.update(cx, |composer, cx| { + composer.input.update(cx, |input, cx| { + let fake = start(input, cx); + input.finish_dictation(true, cx); + deliver(input, &fake, [Event::Final(String::new())], cx); + }); + }); + composer.read_with(cx, |composer, _| { + assert!( + composer.failure.is_none(), + "must not attempt a send to the disconnected engine" + ); + assert!(!composer.sending); + assert!(composer.interrupting.is_empty()); + }); +} + +#[gpui::test] +fn dictation_timeout_preserves_latest_partial_without_sending(cx: &mut TestAppContext) { + let input = cx.new(|cx| ComposerInput::new("Draft", cx)); + let mut events = cx.events::(&input); + input.update(cx, |input, cx| { + let fake = start(input, cx); + deliver(input, &fake, [Event::Partial("latest".into())], cx); + // Drive the same deadline without a wall-clock sleep or real audio. + input + .dictation + .finish(true, Instant::now() - crate::dictation::FINALIZE_TIMEOUT); + let generation = input.dictation.generation; + assert!(!input.poll_dictation(generation, cx)); + assert!(!input.poll_dictation(generation, cx)); + assert_eq!(input.content, "latest"); + assert_eq!(fake.borrow().drops, 1); + }); + let mut sends = 0; + while let Ok(event) = events.try_recv() { + if matches!(event, DictationInputEvent::Submit(_)) { + sends += 1; + } + } + assert_eq!(sends, 0); +} + +#[gpui::test] +fn dictation_mention_and_slash_completion_commit_partial_first(cx: &mut TestAppContext) { + let input = cx.new(|cx| ComposerInput::new("Draft", cx)); + input.update(cx, |input, cx| { + input.enable_mentions(); + input.set_text("@sr", cx); + let fake = start(input, cx); + deliver(input, &fake, [Event::Partial(" extra".into())], cx); + input.replace_mention(0..3, "src/lib.rs", false, cx); + assert_eq!(fake.borrow().drops, 1); + assert!(input.content.contains("src/lib.rs")); + assert!(input.content.ends_with(" extra")); + input.set_text("/com", cx); + let fake = start(input, cx); + input.replace_plain_token(0..4, "/compact", cx); + assert_eq!(fake.borrow().drops, 1); + assert_eq!(input.content, "/compact "); + }); +} + +#[gpui::test] +fn dictation_queue_save_waits_for_final_and_cancel_drops_capture(cx: &mut TestAppContext) { + let state = cx.new(|_| AppState::new()); + let composer = cx.new(|cx| Composer::new(state, cx)); + let fake = composer.update(cx, |composer, cx| { + composer.editing_queued = Some("queued-row".into()); + let fake = composer.input.update(cx, |input, cx| { + let fake = start(input, cx); + deliver( + input, + &fake, + [Event::Listening, Event::Partial("draft".into())], + cx, + ); + fake + }); + assert!(composer.commit_queue_edit(cx)); + assert!(composer.commit_queue_edit(cx)); + assert!( + composer.failure.is_none(), + "queue save must wait for the final result" + ); + assert_eq!(fake.borrow().finishes, 1); + fake + }); + composer.update(cx, |composer, cx| { + composer.input.update(cx, |input, cx| { + deliver(input, &fake, [Event::Final("final queue draft".into())], cx) + }); + }); + composer.read_with(cx, |composer, cx| { + assert_eq!(composer.input.read(cx).text(), "final queue draft"); + // A disconnected test host reports a lease error only AFTER finalization. + assert!( + composer + .failure + .as_ref() + .is_some_and(|s| s.contains("lease")) + ); + }); + composer.update(cx, |composer, cx| { + let fake = composer.input.update(cx, |input, cx| start(input, cx)); + composer.cancel_queue_edit(cx); + assert_eq!(fake.borrow().drops, 1); + let fake = composer.input.update(cx, |input, cx| start(input, cx)); + composer.clear_queue_edit(cx); + assert_eq!( + fake.borrow().drops, + 1, + "clearing an edit without a saved draft must still stop capture" + ); + }); +} + +#[gpui::test] +fn dictation_completed_send_event_cannot_send_a_replacement_draft(cx: &mut TestAppContext) { + let state = cx.new(|_| AppState::new()); + let composer = cx.new(|cx| Composer::new(state, cx)); + composer.update(cx, |composer, cx| { + composer.input.update(cx, |input, cx| { + let fake = start(input, cx); + input.finish_dictation(true, cx); + deliver(input, &fake, [Event::Final("old draft".into())], cx); + // The Submit effect is queued; invalidate it before effects flush. + input.set_text("new draft", cx); + }); + }); + composer.read_with(cx, |composer, cx| { + assert!(composer.failure.is_none()); + assert!(!composer.sending); + assert_eq!(composer.input.read(cx).text(), "new draft"); + }); +} + +#[cfg(target_os = "macos")] +#[gpui::test] +fn dictation_shortcut_holds_until_its_modifier_is_released(cx: &mut TestAppContext) { + let (_dir, handle) = super::tests::composer_focus_window(cx); + cx.update(|cx| { + crate::shell::apply_keymap( + cx, + &crate::settings::KeymapConfig::default(), + ComposerSendBehavior::Enter, + ) + }); + let fake = handle + .update(cx, |composer, _, cx| { + composer.input.update(cx, |input, cx| { + let fake = start(input, cx); + deliver( + input, + &fake, + [Event::Listening, Event::Partial("keyboard".into())], + cx, + ); + fake + }) + }) + .unwrap(); + cx.update_window(handle.into(), |_, window, cx| window.draw(cx).clear()) + .unwrap(); + // Key down and its auto-repeat hold the recording open. + cx.simulate_keystrokes(handle.into(), "cmd-d cmd-d"); + cx.run_until_parked(); + assert_eq!(fake.borrow().finishes, 0); + let mut visual = gpui::VisualTestContext::from_window(handle.into(), cx); + visual.simulate_modifiers_change(gpui::Modifiers::command()); + assert_eq!( + fake.borrow().finishes, + 0, + "D up alone never arrives on macOS" + ); + // Letting go of Command ends the hold. + visual.simulate_modifiers_change(gpui::Modifiers::none()); + assert_eq!(fake.borrow().finishes, 1); + handle + .update(cx, |composer, window, cx| { + assert!(composer.input.read(cx).focus_handle.is_focused(window)); + assert_eq!(composer.input.read(cx).dictation.phase, Phase::Finalizing); + composer.input.update(cx, |input, cx| { + deliver(input, &fake, [Event::Final("keyboard".into())], cx) + }); + }) + .unwrap(); +} + +#[gpui::test] +fn dictation_pointer_hold_finishes_on_release_even_outside_the_button(cx: &mut TestAppContext) { + let (dir, handle) = super::tests::composer_focus_window(cx); + enable_dictation(dir.path(), cx); + for release_outside in [false, true] { + let fake = handle + .update(cx, |composer, _, cx| { + composer.input.update(cx, |input, cx| { + let fake = start(input, cx); + deliver(input, &fake, [Event::Listening], cx); + fake + }) + }) + .unwrap(); + cx.update_window(handle.into(), |_, window, cx| window.draw(cx).clear()) + .unwrap(); + let mut visual = gpui::VisualTestContext::from_window(handle.into(), cx); + let button = visual.debug_bounds("composer-dictation").unwrap(); + visual.simulate_mouse_down( + button.center(), + MouseButton::Left, + gpui::Modifiers::default(), + ); + assert_eq!(fake.borrow().finishes, 0, "holding keeps recording"); + let up = if release_outside { + button.origin - point(px(40.0), px(40.0)) + } else { + button.center() + }; + visual.simulate_mouse_up(up, MouseButton::Left, gpui::Modifiers::default()); + assert_eq!(fake.borrow().drops, 0, "release must finish, not cancel"); + assert_eq!(fake.borrow().finishes, 1, "outside={release_outside}"); + handle + .update(cx, |composer, _, cx| { + composer.input.update(cx, |input, cx| { + assert_eq!(input.dictation.phase, Phase::Finalizing); + deliver(input, &fake, [Event::Final("Bonjour".into())], cx); + }); + }) + .unwrap(); + } +} + +#[gpui::test] +fn dictation_tap_explains_hold_to_talk_without_transcribing(cx: &mut TestAppContext) { + let (dir, handle) = super::tests::composer_focus_window(cx); + enable_dictation(dir.path(), cx); + let fake = handle + .update(cx, |composer, _, cx| { + let fake = composer.input.update(cx, |input, cx| { + input.set_text("keep", cx); + let fake = start(input, cx); + deliver(input, &fake, [Event::Listening], cx); + fake + }); + // As if this press had just started the session. + composer.dictation_hold = Some(DictationHold { + source: HoldSource::Pointer, + started: Some(Instant::now()), + }); + fake + }) + .unwrap(); + cx.update_window(handle.into(), |_, window, cx| window.draw(cx).clear()) + .unwrap(); + let mut visual = gpui::VisualTestContext::from_window(handle.into(), cx); + let button = visual.debug_bounds("composer-dictation").unwrap(); + visual.simulate_mouse_up( + button.center(), + MouseButton::Left, + gpui::Modifiers::default(), + ); + assert_eq!(fake.borrow().finishes, 0); + assert_eq!(fake.borrow().drops, 1, "a tap releases the microphone"); + handle + .read_with(cx, |composer, cx| { + let input = composer.input.read(cx); + assert_eq!(input.dictation.phase, Phase::Tapped); + assert_eq!(input.text(), "keep"); + assert!(composer.dictation_hold.is_none()); + }) + .unwrap(); +} + +#[gpui::test] +fn dictation_switching_windows_releases_capture_and_retains_draft(cx: &mut TestAppContext) { + let (_dir, handle) = super::tests::composer_focus_window(cx); + handle + .update(cx, |_, window, _| window.activate_window()) + .unwrap(); + cx.run_until_parked(); + let fake = handle + .update(cx, |composer, _, cx| { + composer.input.update(cx, |input, cx| { + let fake = start(input, cx); + deliver( + input, + &fake, + [Event::Listening, Event::Partial("keep on switch".into())], + cx, + ); + input.finish_dictation(true, cx); + fake + }) + }) + .unwrap(); + let other = cx.add_window(|_, cx| ComposerInput::new("Other window", cx)); + other + .update(cx, |_, window, _| window.activate_window()) + .unwrap(); + cx.run_until_parked(); + assert_eq!(fake.borrow().drops, 1); + handle + .read_with(cx, |composer, cx| { + assert_eq!(composer.input.read(cx).text(), "keep on switch"); + assert_eq!(composer.input.read(cx).dictation.phase, Phase::Idle); + assert!(composer.failure.is_none()); + }) + .unwrap(); +} + +#[gpui::test] +fn dictation_question_takeover_stops_capture_without_losing_draft(cx: &mut TestAppContext) { + let state = cx.new(|_| AppState::new()); + let composer = cx.new(|cx| Composer::new(state.clone(), cx)); + let fake = composer.update(cx, |composer, cx| { + composer.input.update(cx, |input, cx| { + let fake = start(input, cx); + deliver(input, &fake, [Event::Partial("preserved draft".into())], cx); + fake + }) + }); + state.update(cx, |state, cx| { + state.transcript = vec![SessionMessageEntry { + duration_ms: None, + id: "question-message".into(), + role: MessageRole::Assistant, + created_at: 0, + device_id: "host".into(), + status: None, + continuation_of: None, + parts: vec![MessagePart::Input { + id: "input".into(), + request_id: "request".into(), + resolved: false, + questions: vec![UserInputQuestion { + id: "q".into(), + header: "Choose".into(), + question: "Continue?".into(), + options: vec!["Yes".into()], + multi_select: false, + prefill: None, + multiline: false, + }], + }], + }]; + cx.notify(); + }); + assert_eq!(fake.borrow().drops, 1); + composer.read_with(cx, |composer, cx| { + assert!(composer.wizard.is_some()); + // The question borrows the editor; the dictated draft waits under the + // chat's key until the question closes. + assert_eq!(composer.input.read(cx).text(), ""); + assert_eq!( + composer + .drafts + .get(&composer.current_key) + .map(String::as_str), + Some("preserved draft") + ); + }); +} + +#[gpui::test] +fn dictation_pointer_stop_keeps_capture_until_final_result(cx: &mut TestAppContext) { + let (dir, handle) = super::tests::composer_focus_window(cx); + enable_dictation(dir.path(), cx); + let fake = handle + .update(cx, |composer, _, cx| { + composer.input.update(cx, |input, cx| { + let fake = start(input, cx); + deliver(input, &fake, [Event::Listening], cx); + fake + }) + }) + .unwrap(); + cx.update_window(handle.into(), |_, window, cx| window.draw(cx).clear()) + .unwrap(); + let mut visual = gpui::VisualTestContext::from_window(handle.into(), cx); + let button = visual.debug_bounds("composer-dictation").unwrap(); + visual.simulate_click(button.center(), gpui::Modifiers::default()); + assert_eq!( + fake.borrow().drops, + 0, + "Stop must finish, not cancel, the recording" + ); + assert_eq!(fake.borrow().finishes, 1); + handle + .update(cx, |composer, _, cx| { + composer.input.update(cx, |input, cx| { + assert_eq!(input.dictation.phase, Phase::Finalizing); + deliver(input, &fake, [Event::Final("Bonjour".into())], cx); + assert_eq!(input.text(), "Bonjour"); + }) + }) + .unwrap(); +} + +#[gpui::test] +fn dictation_pointer_send_waits_for_final_and_submits_once(cx: &mut TestAppContext) { + let (_dir, handle) = super::tests::composer_focus_window(cx); + let (input, fake) = handle + .update(cx, |composer, _, cx| { + let fake = composer.input.update(cx, |input, cx| { + let fake = start(input, cx); + deliver( + input, + &fake, + [Event::Listening, Event::Partial("unfinished words".into())], + cx, + ); + fake + }); + (composer.input.clone(), fake) + }) + .unwrap(); + let mut events = cx.events::(&input); + cx.update_window(handle.into(), |_, window, cx| window.draw(cx).clear()) + .unwrap(); + let mut visual = gpui::VisualTestContext::from_window(handle.into(), cx); + let button = visual.debug_bounds("composer-send").unwrap(); + visual.simulate_click(button.center(), gpui::Modifiers::default()); + visual.simulate_click(button.center(), gpui::Modifiers::default()); + assert_eq!(fake.borrow().drops, 0); + assert_eq!(fake.borrow().finishes, 1); + handle + .read_with(cx, |composer, cx| { + assert!( + composer.failure.is_none(), + "neither click may attempt an early send" + ); + assert_eq!(composer.input.read(cx).text(), "unfinished words"); + }) + .unwrap(); + input.update(cx, |input, cx| { + assert_eq!(input.dictation.phase, Phase::Finalizing); + deliver(input, &fake, [Event::Final("send this once".into())], cx); + }); + let mut sends = 0; + while let Ok(event) = events.try_recv() { + if matches!(event, DictationInputEvent::Submit(_)) { + sends += 1; + } + } + assert_eq!(sends, 1); +} + +#[gpui::test] +fn dictation_silence_explains_empty_result_and_never_sends_existing_draft(cx: &mut TestAppContext) { + let input = cx.new(|cx| ComposerInput::new("Draft", cx)); + let mut events = cx.events::(&input); + input.update(cx, |input, cx| { + input.set_text("keep my draft", cx); + let fake = start(input, cx); + deliver(input, &fake, [Event::Listening], cx); + input.finish_dictation(true, cx); + deliver(input, &fake, [Event::Final(String::new())], cx); + assert_eq!(input.text(), "keep my draft"); + assert_eq!(input.dictation.phase, Phase::NoSpeech); + }); + while let Ok(event) = events.try_recv() { + assert!(!matches!(event, DictationInputEvent::Submit(_))); + } +} + +fn enable_dictation(dir: &std::path::Path, cx: &mut TestAppContext) { + let model = dir.join("models/parakeet-tdt-0.6b-v3-int8"); + std::fs::create_dir_all(&model).unwrap(); + for file in zeron_voice::manifest().files { + std::fs::File::create(model.join(file.name)) + .unwrap() + .set_len(file.size) + .unwrap(); + } + std::fs::write(model.join("verified"), zeron_voice::manifest().revision).unwrap(); + cx.update(|cx| { + crate::dictation::init(dir.into(), cx); + crate::settings::update(crate::settings::SavePolicy::Immediate, cx, |s| { + s.dictation_enabled = true + }); + }); +} + +#[gpui::test] +fn dictation_focus_within_composer_keeps_capture_but_leaving_cancels(cx: &mut TestAppContext) { + let (dir, handle) = super::tests::composer_focus_window(cx); + enable_dictation(dir.path(), cx); + handle + .update(cx, |_, window, _| window.activate_window()) + .unwrap(); + cx.run_until_parked(); + let fake = handle + .update(cx, |composer, _, cx| { + composer.input.update(cx, |input, cx| { + let fake = start(input, cx); + deliver(input, &fake, [Event::Listening], cx); + fake + }) + }) + .unwrap(); + cx.update_window(handle.into(), |_, window, cx| window.draw(cx).clear()) + .unwrap(); + handle + .update(cx, |composer, window, cx| { + // The composer surface is the ancestor of the editor and its controls. + window.focus(&composer.dictation_focus, cx); + }) + .unwrap(); + cx.update_window(handle.into(), |_, window, cx| window.draw(cx).clear()) + .unwrap(); + cx.run_until_parked(); + assert_eq!( + fake.borrow().drops, + 0, + "focus within the composer must retain capture" + ); + let elsewhere = cx.update(|cx| cx.focus_handle()); + handle + .update(cx, |composer, window, cx| { + window.focus(&elsewhere, cx); + assert!(composer.input.read(cx).dictation.phase.active()); + }) + .unwrap(); + cx.update_window(handle.into(), |_, window, cx| window.draw(cx).clear()) + .unwrap(); + cx.run_until_parked(); + assert_eq!(fake.borrow().drops, 1); + handle + .read_with(cx, |composer, cx| { + assert_eq!(composer.input.read(cx).dictation.phase, Phase::Idle) + }) + .unwrap(); +} + +#[gpui::test] +fn dictation_cancel_control_preserves_draft_and_clears_pending_send(cx: &mut TestAppContext) { + let (_dir, handle) = super::tests::composer_focus_window(cx); + let (input, fake) = handle + .update(cx, |composer, _, cx| { + let fake = composer.input.update(cx, |input, cx| { + input.set_text("Keep this draft", cx); + let fake = start(input, cx); + deliver(input, &fake, [Event::Listening], cx); + input.finish_dictation(true, cx); + fake + }); + (composer.input.clone(), fake) + }) + .unwrap(); + let mut events = cx.events::(&input); + cx.update_window(handle.into(), |_, window, cx| window.draw(cx).clear()) + .unwrap(); + let mut visual = gpui::VisualTestContext::from_window(handle.into(), cx); + let cancel = visual.debug_bounds("dictation-dismiss").unwrap(); + visual.simulate_click(cancel.center(), gpui::Modifiers::default()); + assert_eq!(fake.borrow().drops, 1); + input.read_with(cx, |input, _| { + assert_eq!(input.text(), "Keep this draft"); + assert_eq!(input.dictation.phase, Phase::Idle); + }); + while let Ok(event) = events.try_recv() { + assert!(!matches!(event, DictationInputEvent::Submit(_))); + } +} + +#[gpui::test] +fn dictation_cancel_control_is_keyboard_operable(cx: &mut TestAppContext) { + let (_dir, handle) = super::tests::composer_focus_window(cx); + let fake = handle + .update(cx, |composer, window, cx| { + let fake = composer.input.update(cx, |input, cx| { + input.set_text("Keep this draft", cx); + let fake = start(input, cx); + deliver(input, &fake, [Event::Listening], cx); + fake + }); + window.focus(&composer.input.focus_handle(cx), cx); + fake + }) + .unwrap(); + cx.update_window(handle.into(), |_, window, cx| { + window.draw(cx).clear(); + window.focus_next(cx); + window.draw(cx).clear(); + }) + .unwrap(); + cx.run_until_parked(); + assert_eq!( + fake.borrow().drops, + 0, + "tabbing to Cancel must retain capture" + ); + cx.simulate_keystrokes(handle.into(), "space"); + cx.run_until_parked(); + assert_eq!(fake.borrow().drops, 1); + assert_eq!(fake.borrow().finishes, 0, "Cancel must not transcribe"); + handle + .read_with(cx, |composer, cx| { + assert_eq!(composer.input.read(cx).text(), "Keep this draft"); + assert_eq!(composer.input.read(cx).dictation.phase, Phase::Idle); + }) + .unwrap(); +} + +#[gpui::test] +fn dictation_keeps_draft_stable_when_pasted_references_resolve(cx: &mut TestAppContext) { + let input = cx.new(|cx| ComposerInput::new("Draft", cx)); + let mut events = cx.events::(&input); + input.update(cx, |input, cx| { + input.set_text("@README.md ", cx); + let original = input.text().to_owned(); + let revision = input.edit_revision; + let fake = start(input, cx); + deliver(input, &fake, [Event::Listening], cx); + input.finish_dictation(true, cx); + input.apply_pasted_references( + &original, + original.len(), + revision, + vec![(0..10, local_file_link("README.md", false))], + cx, + ); + assert_eq!(input.text(), original); + deliver(input, &fake, [Event::Final("explain this".into())], cx); + assert_eq!(input.text(), "@README.md explain this"); + }); + let mut sends = 0; + while let Ok(event) = events.try_recv() { + if matches!(event, DictationInputEvent::Submit(_)) { + sends += 1; + } + } + assert_eq!(sends, 1); +} + +#[gpui::test] +fn dictation_escape_from_composer_controls_cancels_pending_send(cx: &mut TestAppContext) { + let (_dir, handle) = super::tests::composer_focus_window(cx); + let fake = handle + .update(cx, |composer, window, cx| { + let fake = composer.input.update(cx, |input, cx| { + input.set_text("Keep this draft", cx); + let fake = start(input, cx); + deliver(input, &fake, [Event::Listening], cx); + input.finish_dictation(true, cx); + fake + }); + window.focus(&composer.dictation_focus, cx); + fake + }) + .unwrap(); + cx.update_window(handle.into(), |_, window, cx| window.draw(cx).clear()) + .unwrap(); + cx.simulate_keystrokes(handle.into(), "escape"); + cx.run_until_parked(); + assert_eq!(fake.borrow().drops, 1); + handle + .read_with(cx, |composer, cx| { + assert_eq!(composer.input.read(cx).dictation.phase, Phase::Idle); + assert_eq!(composer.input.read(cx).text(), "Keep this draft"); + assert!(composer.failure.is_none()); + }) + .unwrap(); +} + +#[gpui::test] +fn dictation_empty_undo_and_redo_notify_cancellation(cx: &mut TestAppContext) { + cx.update(|cx| cx.set_global(Theme::dark())); + let window = cx.add_window(|_, cx| ComposerInput::new("Draft", cx)); + let input = window.update(cx, |_, _, cx| cx.entity()).unwrap(); + let mut events = cx.events::(&input); + for redo in [false, true] { + let fake = input.update(cx, |input, cx| { + let fake = start(input, cx); + deliver(input, &fake, [Event::Listening], cx); + fake + }); + while events.try_recv().is_ok() {} + window + .update(cx, |input, window, cx| { + if redo { + input.redo(&Redo, window, cx); + } else { + input.undo(&Undo, window, cx); + } + assert_eq!(input.dictation.phase, Phase::Idle); + }) + .unwrap(); + assert_eq!(fake.borrow().drops, 1); + assert!(matches!( + events.try_recv(), + Ok(DictationInputEvent::Changed) + )); + } +} + +#[gpui::test] +fn dictation_blank_results_preserve_partial_and_pending_send(cx: &mut TestAppContext) { + let input = cx.new(|cx| ComposerInput::new("Draft", cx)); + let mut events = cx.events::(&input); + input.update(cx, |input, cx| { + let fake = start(input, cx); + deliver( + input, + &fake, + [Event::Partial("Keep these words".into())], + cx, + ); + deliver(input, &fake, [Event::Partial(" ".into())], cx); + assert_eq!(input.text(), "Keep these words"); + input.finish_dictation(true, cx); + deliver(input, &fake, [Event::Final(" \n\t ".into())], cx); + assert_eq!(input.text(), "Keep these words"); + assert_eq!(input.dictation.phase, Phase::Idle); + }); + let mut sends = 0; + while let Ok(event) = events.try_recv() { + if matches!(event, DictationInputEvent::Submit(_)) { + sends += 1; + } + } + assert_eq!(sends, 1); +} + +#[test] +fn voice_morph_reverses_mid_flight_without_jumping() { + let start = std::time::Instant::now(); + let mut tween = super::VoiceTween::default(); + tween.retarget(1.0, start, false); + let mid = start + super::VOICE_MORPH / 3; + let at_reverse = tween.value(mid); + assert!(at_reverse > 0.0 && at_reverse < 1.0, "{at_reverse}"); + tween.retarget(0.0, mid, false); + assert!((tween.value(mid) - at_reverse).abs() < 1e-6); + assert_eq!(tween.value(mid + super::VOICE_MORPH), 0.0); + // Reduced motion snaps to the target. + tween.retarget(1.0, mid, true); + assert_eq!(tween.value(mid), 1.0); +} + +#[gpui::test] +fn dictation_stop_while_loading_keeps_selection_until_final_and_sends_once( + cx: &mut TestAppContext, +) { + let input = cx.new(|cx| ComposerInput::new("Draft", cx)); + let mut events = cx.events::(&input); + input.update(cx, |input, cx| { + input.set_text("keep selected suffix", cx); + input.selected_range = 5..13; + let fake = start(input, cx); + assert_eq!(input.dictation.phase, Phase::Requesting); + deliver(input, &fake, [Event::Listening], cx); + input.finish_dictation(false, cx); + input.finish_dictation(true, cx); + // A queued readiness event cannot put a stopped session back in Listening. + deliver(input, &fake, [Event::Listening, Event::Finalizing], cx); + assert_eq!(input.dictation.phase, Phase::Finalizing); + assert_eq!(input.content, "keep selected suffix"); + assert_eq!(input.selected_range, 5..13); + assert_eq!(fake.borrow().finishes, 1); + deliver( + input, + &fake, + [Event::Final("speech".into()), Event::Final("late".into())], + cx, + ); + assert_eq!(input.content, "keep speech suffix"); + assert_eq!(input.dictation.phase, Phase::Idle); + assert_eq!(fake.borrow().drops, 1); + }); + let mut sends = 0; + while let Ok(event) = events.try_recv() { + sends += usize::from(matches!(event, DictationInputEvent::Submit(_))); + } + assert_eq!(sends, 1); +} + +#[gpui::test] +fn dictation_load_failure_or_timeout_preserves_selected_draft_and_rejects_late_final( + cx: &mut TestAppContext, +) { + let input = cx.new(|cx| ComposerInput::new("Draft", cx)); + let mut events = cx.events::(&input); + input.update(cx, |input, cx| { + for timeout in [false, true] { + input.set_text("keep my draft", cx); + input.selected_range = 0..13; + let fake = start(input, cx); + let generation = input.dictation.generation; + deliver(input, &fake, [Event::Listening], cx); + if timeout { + input + .dictation + .finish(true, Instant::now() - crate::dictation::FINALIZE_TIMEOUT); + fake.borrow_mut().events.push_back(Event::Finalizing); + input.poll_dictation(generation, cx); + } else { + input.finish_dictation(true, cx); + deliver( + input, + &fake, + [Event::Failed("Could not load the model".into())], + cx, + ); + } + assert!(matches!(input.dictation.phase, Phase::Failed(_))); + fake.borrow_mut() + .events + .push_back(Event::Final("late speech".into())); + assert!(!input.poll_dictation(generation, cx)); + assert_eq!(input.content, "keep my draft"); + assert_eq!(input.selected_range, 0..13); + assert!(input.undo_stack.is_empty()); + assert_eq!(fake.borrow().drops, 1); + } + }); + while let Ok(event) = events.try_recv() { + assert!(!matches!(event, DictationInputEvent::Submit(_))); + } +} + +#[gpui::test] +fn dictation_microphone_key_hold_keeps_focus_and_queue_lease_blocks_start(cx: &mut TestAppContext) { + let (dir, handle) = super::tests::composer_focus_window(cx); + enable_dictation(dir.path(), cx); + handle + .update(cx, |composer, _, cx| { + // Enter/Space on the focused microphone must leave focus there, + // or its key-up never reaches the hold. + composer.focus_pending = false; + composer.press_dictation(HoldSource::Button, cx); + assert!(composer.input.read(cx).dictation.phase.active()); + assert!(!composer.focus_pending); + composer + .input + .update(cx, |input, _| input.cancel_dictation()); + composer.dictation_hold = None; + // An edit lease still in flight is about to replace the draft. + composer.queue_edit_pending_id = Some("row".into()); + composer.press_dictation(HoldSource::Pointer, cx); + assert!(!composer.input.read(cx).dictation.phase.active()); + assert!(composer.dictation_hold.is_none()); + }) + .unwrap(); +} + +#[gpui::test] +fn dictation_another_source_takes_over_a_hold_whose_release_was_lost(cx: &mut TestAppContext) { + let (dir, handle) = super::tests::composer_focus_window(cx); + enable_dictation(dir.path(), cx); + let fake = handle + .update(cx, |composer, _, cx| { + let fake = composer.input.update(cx, |input, cx| { + let fake = start(input, cx); + deliver(input, &fake, [Event::Listening], cx); + fake + }); + composer.dictation_hold = Some(DictationHold { + source: HoldSource::Button, + started: None, + }); + composer.press_dictation(HoldSource::Pointer, cx); + composer.release_dictation(HoldSource::Pointer, cx); + fake + }) + .unwrap(); + assert_eq!(fake.borrow().finishes, 1); +} + +#[gpui::test] +fn dictation_shortcut_falls_through_while_dictation_is_off(cx: &mut TestAppContext) { + gpui::actions!(dictation_test, [Probe]); + let (_dir, handle) = super::tests::composer_focus_window(cx); + let fired = Rc::new(std::cell::Cell::new(false)); + let probe = fired.clone(); + cx.update(|cx| { + crate::shell::apply_keymap( + cx, + &crate::settings::KeymapConfig::default(), + ComposerSendBehavior::Enter, + ); + // Another binding of the same chord, as a user may already have. + cx.bind_keys([KeyBinding::new("secondary-d", Probe, None)]); + cx.on_action(move |_: &Probe, _| probe.set(true)); + }); + cx.simulate_keystrokes(handle.into(), "secondary-d"); + assert!( + fired.get(), + "dictation is off by default and must not eat ⌘D" + ); + handle + .read_with(cx, |composer, cx| { + assert!(composer.dictation_hold.is_none()); + assert!(composer.input.read(cx).dictation_key.is_none()); + }) + .unwrap(); +} diff --git a/crates/ui/src/dictation.rs b/crates/ui/src/dictation.rs new file mode 100644 index 0000000000..b9b7d40d34 --- /dev/null +++ b/crates/ui/src/dictation.rs @@ -0,0 +1,323 @@ +//! Viewport-local dictation. Only transcript strings cross this boundary; audio +//! never enters the engine, a document, an attachment, or the RPC transport. +use std::ops::Range; +use std::time::{Duration, Instant}; + +pub(crate) mod glass; +mod meter; +mod model; +pub(crate) mod waveform; +pub(crate) use meter::{Bar, FLOOR, Meter}; +pub(crate) use model::{card, enabled, init}; + +pub(crate) const FINALIZE_TIMEOUT: Duration = Duration::from_secs(30); + +#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)] +#[serde(tag = "kind", content = "text", rename_all = "snake_case")] +pub(crate) enum Event { + Listening, + Finalizing, + Partial(String), + Final(String), + Denied(String), + Unavailable(String), + Failed(String), + Cancelled, +} + +/// Implementations and their callbacks are owned by the UI thread. Tests inject +/// a fake; permission prompts and microphone access are never needed in CI. +pub(crate) trait Transcriber { + fn poll(&mut self) -> Option; + fn finish(&mut self); + /// Peak input RMS since the previous call, for the live waveform. + fn level(&mut self) -> f32 { + 0.0 + } +} + +#[cfg(target_os = "macos")] +unsafe extern "C" { + fn zeron_microphone_permission() -> i32; + fn zeron_request_microphone(); + fn zeron_microphone_window() -> usize; +} +fn permission() -> i32 { + #[cfg(target_os = "macos")] + { + unsafe { zeron_microphone_permission() } + } + #[cfg(not(target_os = "macos"))] + { + 1 + } +} +pub(crate) fn permission_pending() -> bool { + permission() == 0 +} +fn origin_window() -> usize { + #[cfg(target_os = "macos")] + { + unsafe { zeron_microphone_window() } + } + #[cfg(not(target_os = "macos"))] + { + 1 + } +} +struct Native { + pending: Option, + origin_window: usize, + dir: std::path::PathBuf, + device: Option, + session: Option, + finished: bool, +} +impl Transcriber for Native { + fn poll(&mut self) -> Option { + if let Some(event) = self.pending.take() { + return Some(event); + } + if self.finished { + return None; + } + if self.session.is_none() { + match permission() { + 0 => return None, + -2 => { + self.finished = true; + return Some(Event::Unavailable( + "Dictation requires the packaged Zeron app.".into(), + )); + } + -1 => { + self.finished = true; + return Some(Event::Denied("Allow Microphone access for Zeron in System Settings → Privacy & Security, then retry.".into())); + } + _ if self.origin_window == 0 || origin_window() != self.origin_window => { + self.finished = true; + return Some(Event::Cancelled); + } + _ => match zeron_voice::Session::start(self.dir.clone(), self.device.clone()) { + Ok(s) => self.session = Some(s), + Err(e) => { + self.finished = true; + return Some(Event::Failed(e.to_string())); + } + }, + } + } + self.session.as_mut()?.poll().map(|event| match event { + zeron_voice::Event::Listening => Event::Listening, + zeron_voice::Event::Finalizing => Event::Finalizing, + zeron_voice::Event::Final(t) => Event::Final(t), + zeron_voice::Event::Failed(e) => Event::Failed(e), + }) + } + fn level(&mut self) -> f32 { + self.session.as_ref().map_or(0.0, |s| s.take_level()) + } + fn finish(&mut self) { + if let Some(s) = &mut self.session { + s.finish(); + } else { + self.finished = true; + self.pending = Some(Event::Final(String::new())); + } + } +} +pub(crate) fn start(cx: &gpui::App) -> Option> { + if !enabled(cx) { + return None; + } + let origin_window = origin_window(); + #[cfg(target_os = "macos")] + if permission() == 0 { + unsafe { + zeron_request_microphone(); + } + } + Some(Box::new(Native { + pending: None, + origin_window, + dir: model::directory(cx), + device: crate::settings::current(cx).dictation_input.clone(), + session: None, + finished: false, + })) +} + +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub(crate) enum Phase { + #[default] + Idle, + Requesting, + Listening, + Finalizing, + NoSpeech, + /// Released almost immediately: dictation is hold to talk. + Tapped, + Denied(String), + Unavailable(String), + Failed(String), +} + +impl Phase { + pub fn active(&self) -> bool { + matches!(self, Self::Requesting | Self::Listening | Self::Finalizing) + } + + /// Button names describe the action, independently of the live status. + pub fn action_label(&self) -> &'static str { + match self { + Self::Idle | Self::Tapped => "Hold to dictate", + Self::Requesting | Self::Listening => "Release to transcribe", + Self::Finalizing => "Transcribing", + Self::NoSpeech | Self::Denied(_) | Self::Unavailable(_) | Self::Failed(_) => { + "Hold to retry dictation" + } + } + } + + pub fn status(&self) -> Option<(&str, &str)> { + Some(match self { + Self::Idle => return None, + Self::Requesting => ("Getting ready…", "Wait for Listening before speaking."), + Self::Listening => ("Listening", "Release when you’re done · Up to 1 minute"), + Self::Finalizing => ("Transcribing…", "Processing on this device."), + Self::NoSpeech => ( + "No speech detected", + "Check your microphone, then try again.", + ), + Self::Tapped => ( + "Hold to dictate", + "Keep holding the microphone or the shortcut while you speak.", + ), + Self::Denied(message) | Self::Unavailable(message) => { + ("Dictation unavailable", message) + } + Self::Failed(message) => ("Dictation stopped", message), + }) + } +} + +#[derive(Default)] +pub(crate) struct Dictation { + pub phase: Phase, + pub generation: u64, + range: Range, + expected: String, + pub has_partial: bool, + pending_send: bool, + finish_started: Option, + pub meter: Meter, +} + +impl Dictation { + pub fn begin(&mut self, content: &str, selection: Range) { + self.cancel(); + self.phase = Phase::Requesting; + self.range = selection; + self.expected = content.to_owned(); + self.has_partial = false; + } + + /// Invalidates late callbacks AND pending sends, preserving committed text. + pub fn cancel(&mut self) { + self.generation = self.generation.wrapping_add(1); + self.phase = Phase::Idle; + self.pending_send = false; + self.finish_started = None; + self.expected.clear(); + self.meter.reset(); + } + + /// Returns true only on the transition that must finish the audio stream. + pub fn finish(&mut self, send: bool, now: Instant) -> bool { + if !self.phase.active() { + return false; + } + self.pending_send |= send; + if self.phase == Phase::Finalizing { + return false; + } + self.phase = Phase::Finalizing; + self.finish_started = Some(now); + self.meter.freeze(now); + true + } + + pub fn timed_out(&self, now: Instant) -> bool { + self.finish_started + .is_some_and(|at| now.duration_since(at) >= FINALIZE_TIMEOUT) + } + + /// Empty recognition does not erase selected text or the latest partial. + /// A changed draft cannot be overwritten, even if a caller missed cancellation. + pub fn replace(&mut self, content: &mut String, transcript: &str) -> Option { + if !self.phase.active() || *content != self.expected { + self.cancel(); + return None; + } + if transcript.trim().is_empty() { + return None; + } + content.replace_range(self.range.clone(), transcript); + self.range.end = self.range.start + transcript.len(); + self.expected.clone_from(content); + self.has_partial = true; + Some(self.range.end) + } + + pub fn complete(&mut self, phase: Phase) -> bool { + let send = self.pending_send && phase == Phase::Idle; + self.cancel(); + self.phase = phase; + send + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn finishing_before_permission_does_not_wait_or_start_capture() { + let mut native = Native { + pending: None, + origin_window: 0, + dir: std::path::PathBuf::new(), + device: None, + session: None, + finished: false, + }; + native.finish(); + assert_eq!(native.poll(), Some(Event::Final(String::new()))); + assert_eq!(native.poll(), None); + assert!(native.session.is_none()); + } + + #[test] + fn finalization_deadline_does_not_restart_when_send_follows_stop() { + let mut state = Dictation::default(); + state.begin("draft", 5..5); + let start = Instant::now(); + assert!(state.finish(false, start)); + assert!(!state.finish(true, start + Duration::from_secs(1))); + assert!(!state.timed_out(start + FINALIZE_TIMEOUT - Duration::from_millis(1))); + assert!(state.timed_out(start + FINALIZE_TIMEOUT)); + assert!(state.complete(Phase::Idle)); + assert!(!state.complete(Phase::Idle)); + } + + #[test] + fn unexpected_draft_change_rejects_late_partial_and_send() { + let mut state = Dictation::default(); + state.begin("old", 0..3); + state.finish(true, Instant::now()); + let mut current = "new draft".to_owned(); + assert_eq!(state.replace(&mut current, "late"), None); + assert_eq!(current, "new draft"); + assert!(!state.complete(Phase::Idle)); + } +} diff --git a/crates/ui/src/dictation/glass.rs b/crates/ui/src/dictation/glass.rs new file mode 100644 index 0000000000..e39eae3cc0 --- /dev/null +++ b/crates/ui/src/dictation/glass.rs @@ -0,0 +1,89 @@ +//! Glass surfaces for the voice controls: a light plate for the waveform track +//! and an accent plate for Stop. Each is a vertical gradient (lit from above), +//! a hairline rim, an inner top highlight, and a soft drop. Dark appearances +//! keep the structure with translucent white instead of opaque fills. +use gpui::{ + Background, BoxShadow, Hsla, Styled, hsla, linear_color_stop, linear_gradient, point, px, +}; + +use crate::theme::Theme; + +fn shadow(color: Hsla, y: f32, blur: f32, spread: f32, inset: bool) -> BoxShadow { + BoxShadow { + color, + offset: point(px(0.0), px(y)), + blur_radius: px(blur), + spread_radius: px(spread), + inset, + } +} + +fn white(alpha: f32) -> Hsla { + hsla(0.0, 0.0, 1.0, alpha) +} + +fn black(alpha: f32) -> Hsla { + hsla(0.0, 0.0, 0.0, alpha) +} + +fn vertical(top: Hsla, bottom: Hsla) -> Background { + linear_gradient( + 180.0, + linear_color_stop(top, 0.0), + linear_color_stop(bottom, 1.0), + ) +} + +/// A lighter tint of `color` for the lit top of a gradient. +fn lift(color: Hsla, amount: f32) -> Hsla { + crate::motion::mix(color, white(color.a), amount) +} + +/// Neutral glass plate. `t` fades the whole treatment in with the morph. +pub(crate) fn light(el: E, theme: &Theme, t: f32) -> E { + let dark = theme.appearance.is_dark(); + let (top, bottom, rim, highlight, drop) = if dark { + ( + white(0.09), + white(0.04), + white(0.10), + white(0.10), + black(0.35), + ) + } else { + ( + hsla(0.0, 0.0, 0.985, 1.0), + hsla(0.0, 0.0, 0.925, 1.0), + black(0.10), + white(0.95), + black(0.08), + ) + }; + el.bg(vertical(top.opacity(t), bottom.opacity(t))) + .border_1() + .border_color(rim.opacity(t)) + .shadow(vec![ + shadow(highlight.opacity(t), 1.0, 0.0, 0.0, true), + shadow(drop.opacity(t), 1.0, 3.0, 0.0, false), + ]) +} + +/// Accent glass plate. `t` fades it in over the resting control; `glow` +/// (0–1, the live voice level) spreads its coloured shadow. +pub(crate) fn accent(el: E, theme: &Theme, t: f32, glow: f32) -> E { + let dark = theme.appearance.is_dark(); + let base = theme.accent; + let top = lift(base, if dark { 0.18 } else { 0.32 }); + let bottom = base; + let rim = lift(base, 0.45).opacity(if dark { 0.45 } else { 0.7 }); + let highlight = white(if dark { 0.22 } else { 0.38 }); + let halo = base.opacity((0.22 + 0.4 * glow) * t); + el.bg(vertical(top.opacity(t), bottom.opacity(t))) + .border_1() + .border_color(rim.opacity(t)) + .shadow(vec![ + shadow(highlight.opacity(t), 1.0, 0.0, 0.0, true), + shadow(white(0.12 * t), 0.0, 0.0, 1.0, true), + shadow(halo, 2.0 + 2.0 * glow, 6.0 + 14.0 * glow, 0.0, false), + ]) +} diff --git a/crates/ui/src/dictation/meter.rs b/crates/ui/src/dictation/meter.rs new file mode 100644 index 0000000000..7ddf7152f5 --- /dev/null +++ b/crates/ui/src/dictation/meter.rs @@ -0,0 +1,247 @@ +//! Live input level for the dictation waveform. Only a loudness envelope is +//! kept here: one normalized amplitude per fixed time slot, never audio. +//! +//! Slots sit on a fixed clock from the moment capture starts, so bars stay +//! evenly spaced however irregularly the UI polls. Drawing trails the clock by +//! [`LAG`], longer than one poll, which means a slot is always filled before +//! it scrolls into view and the waveform can glide continuously. +use std::collections::VecDeque; +use std::time::{Duration, Instant}; + +/// One bar per slot. +pub(crate) const STEP: Duration = Duration::from_millis(60); +const LAG: Duration = Duration::from_millis(70); +/// Enough slots for the widest composer; older bars have scrolled away. +const CAPACITY: usize = 320; +/// A bar never drops below its neighbour faster than this, so syllables leave +/// a short tail instead of flickering between loud and silent. +const RELEASE: f32 = 0.62; +/// The quietest bar still reads as a dot on the baseline. +pub(crate) const FLOOR: f32 = 0.08; + +#[derive(Debug, Default, Clone)] +pub(crate) struct Meter { + started: Option, + frozen: Option, + slots: VecDeque, + filled: u64, + pending: f32, +} + +/// One visible bar: `age` in slots behind the trailing edge (0 = newest) and +/// its normalized height. +#[derive(Debug, Clone, Copy, PartialEq)] +pub(crate) struct Bar { + pub age: f32, + pub amplitude: f32, +} + +/// Maps microphone RMS onto a perceptual 0–1 scale: −50 dBFS is silence and +/// −12 dBFS, a close, raised voice, fills the bar. +pub(crate) fn normalize(rms: f32) -> f32 { + if !rms.is_finite() || rms <= 1e-6 { + return 0.0; + } + ((20.0 * rms.log10() + 50.0) / 38.0).clamp(0.0, 1.0) +} + +impl Meter { + pub fn start(&mut self, now: Instant) { + *self = Self { + started: Some(now), + ..Self::default() + }; + } + + /// Time since capture began; `None` until the microphone is live. + pub fn since_start(&self, now: Instant) -> Option { + self.started.map(|at| now.saturating_duration_since(at)) + } + + pub fn reset(&mut self) { + *self = Self::default(); + } + + /// Stops the clock where capture ended; bars and timer hold still. + pub fn freeze(&mut self, now: Instant) { + if self.started.is_some() && self.frozen.is_none() { + self.frozen = Some(now); + } + } + + /// Folds in the loudest RMS since the previous call. Returns true when a + /// new slot was filled. + pub fn record(&mut self, now: Instant, rms: f32) -> bool { + let Some(started) = self.started.filter(|_| self.frozen.is_none()) else { + return false; + }; + self.pending = self.pending.max(normalize(rms)); + let due = now.saturating_duration_since(started).as_nanos() / STEP.as_nanos() + 1; + let due = due as u64; + if self.filled >= due { + return false; + } + while self.filled < due { + let previous = self.slots.back().copied().unwrap_or(0.0); + self.slots.push_back(self.pending.max(previous * RELEASE)); + if self.slots.len() > CAPACITY { + self.slots.pop_front(); + } + self.filled += 1; + } + self.pending = 0.0; + true + } + + pub fn elapsed(&self, now: Instant) -> Duration { + self.started.map_or(Duration::ZERO, |started| { + self.frozen + .unwrap_or(now) + .saturating_duration_since(started) + }) + } + + /// Bars visible at `now`, newest first. `continuous` false snaps the + /// scroll to whole slots for reduced motion. + pub fn bars(&self, now: Instant, continuous: bool) -> Vec { + let Some(started) = self.started else { + return Vec::new(); + }; + let clock = self + .frozen + .map_or(now, |frozen| frozen.min(now)) + .saturating_duration_since(started); + let Some(clock) = clock.checked_sub(LAG) else { + return Vec::new(); + }; + let head = clock.as_secs_f32() / STEP.as_secs_f32(); + let first = self.filled - self.slots.len() as u64; + self.slots + .iter() + .enumerate() + .rev() + .filter_map(|(i, amplitude)| { + let age = head - (first + i as u64) as f32; + (age >= 0.0).then(|| Bar { + age: if continuous { age } else { age.floor() }, + amplitude: *amplitude, + }) + }) + .collect() + } + + /// Current loudness for the glow, blended between the two newest visible + /// slots as the newest one scrolls in, so it changes without steps. + pub fn level(&self, now: Instant) -> f32 { + let bars = self.bars(now, true); + match bars.as_slice() { + [] => 0.0, + [only] => only.amplitude * only.age.clamp(0.0, 1.0), + [newest, previous, ..] => { + let t = newest.age.clamp(0.0, 1.0); + previous.amplitude + (newest.amplitude - previous.amplitude) * t + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ms(n: u64) -> Duration { + Duration::from_millis(n) + } + + #[test] + fn normalizes_speech_range_and_rejects_invalid_levels() { + assert_eq!(normalize(0.0), 0.0); + assert_eq!(normalize(f32::NAN), 0.0); + assert_eq!(normalize(10f32.powf(-50.0 / 20.0)), 0.0); + assert_eq!(normalize(1.0), 1.0); + let mid = normalize(0.02); + assert!(mid > 0.2 && mid < 0.8, "{mid}"); + } + + #[test] + fn slots_stay_on_the_clock_when_polls_are_irregular() { + let t0 = Instant::now(); + let mut meter = Meter::default(); + meter.start(t0); + assert!(meter.record(t0 + ms(5), 0.1)); + assert!(!meter.record(t0 + ms(30), 0.0)); + // A late poll fills every slot it skipped. + assert!(meter.record(t0 + STEP * 3 + ms(1), 0.0)); + assert_eq!(meter.filled, 4); + let bars = meter.bars(t0 + STEP * 3 + LAG + ms(1), true); + assert_eq!(bars.len(), 4); + for pair in bars.windows(2) { + assert!((pair[1].age - pair[0].age - 1.0).abs() < 1e-3); + } + // Loud slot decays into its neighbours instead of dropping to zero. + let loud = normalize(0.1); + assert_eq!(bars[3].amplitude, loud); + assert!((bars[2].amplitude - loud * RELEASE).abs() < 1e-6); + } + + #[test] + fn peak_between_slots_is_kept_for_the_next_slot() { + let t0 = Instant::now(); + let mut meter = Meter::default(); + meter.start(t0); + meter.record(t0, 0.0); + assert!(!meter.record(t0 + ms(20), 0.2)); + assert!(meter.record(t0 + STEP, 0.0)); + assert_eq!(meter.slots.back().copied(), Some(normalize(0.2))); + } + + #[test] + fn freezing_stops_scroll_and_timer() { + let t0 = Instant::now(); + let mut meter = Meter::default(); + meter.start(t0); + meter.record(t0 + STEP * 5, 0.05); + meter.freeze(t0 + STEP * 5); + assert!(!meter.record(t0 + STEP * 9, 0.5)); + let later = t0 + Duration::from_secs(10); + assert_eq!(meter.elapsed(later), STEP * 5); + assert_eq!(meter.bars(later, true), meter.bars(later + ms(500), true)); + } + + #[test] + fn level_glides_between_slots() { + let t0 = Instant::now(); + let mut meter = Meter::default(); + meter.start(t0); + meter.record(t0, 0.0); + meter.record(t0 + STEP, 0.1); + let at = |offset: Duration| meter.level(t0 + STEP + LAG + ms(1) + offset); + let loud = normalize(0.1); + assert!(at(Duration::ZERO) < 0.03); + let half = at(STEP / 2); + assert!((half - loud / 2.0).abs() < 0.03, "{half}"); + assert!((at(STEP) - loud).abs() < 1e-6); + } + + #[test] + fn reduced_motion_snaps_to_whole_slots() { + let t0 = Instant::now(); + let mut meter = Meter::default(); + meter.start(t0); + meter.record(t0 + STEP * 2, 0.05); + let bars = meter.bars(t0 + STEP * 2 + LAG + ms(20), false); + assert!(bars.iter().all(|bar| bar.age.fract() == 0.0)); + } + + #[test] + fn capacity_bounds_history() { + let t0 = Instant::now(); + let mut meter = Meter::default(); + meter.start(t0); + meter.record(t0 + STEP * (CAPACITY as u32 * 2), 0.05); + assert_eq!(meter.slots.len(), CAPACITY); + let bars = meter.bars(t0 + STEP * (CAPACITY as u32 * 2) + LAG + ms(1), true); + assert_eq!(bars.len(), CAPACITY); + assert!(bars[0].age < 1.0); + } +} diff --git a/crates/ui/src/dictation/model.rs b/crates/ui/src/dictation/model.rs new file mode 100644 index 0000000000..56f6e631f6 --- /dev/null +++ b/crates/ui/src/dictation/model.rs @@ -0,0 +1,710 @@ +use crate::{ + popover, + settings::{self, widgets}, + theme::Theme, +}; +use gpui::{App, Context, Entity, Global, Task, Window, div, prelude::*, px}; +use std::{ + path::PathBuf, + sync::{ + Arc, + atomic::{AtomicBool, AtomicU64, Ordering}, + }, + time::{Duration, Instant}, +}; +struct VoiceGlobal { + card: Entity, + directory: PathBuf, +} +impl Global for VoiceGlobal {} +pub(crate) fn init(root: PathBuf, cx: &mut App) { + let directory = root.join("models/parakeet-tdt-0.6b-v3-int8"); + let card = cx.new(|_| VoiceCard { + scroll: widgets::PageScroll::default(), + ready: zeron_voice::installed(&directory), + cache_present: directory.exists(), + directory: directory.clone(), + cancel: None, + task: None, + progress: 0, + error: None, + inputs: Inputs::default(), + input_select: widgets::SelectState::default(), + voice_select: widgets::SelectState::default(), + host_select: widgets::SelectState::default(), + hosts: Vec::new(), + call_live: false, + voices: zeron_proto::voice::DEFAULT_VOICES + .iter() + .map(|voice| (*voice).to_owned()) + .collect(), + shortcut: None, + }); + cx.set_global(VoiceGlobal { card, directory }); +} +pub(crate) fn directory(cx: &App) -> PathBuf { + cx.global::().directory.clone() +} +pub(crate) fn enabled(cx: &App) -> bool { + settings::current(cx).dictation_enabled + && cx + .try_global::() + .is_some_and(|g| g.card.read(cx).ready) +} +pub(crate) fn card(cx: &mut App) -> Entity { + if !cx.has_global::() { + init(std::env::temp_dir().join("zeron-voice-test"), cx); + } + cx.global::().card.clone() +} +pub(crate) struct VoiceCard { + scroll: widgets::PageScroll, + directory: PathBuf, + ready: bool, + cache_present: bool, + cancel: Option>, + task: Option>, + progress: u64, + error: Option, + inputs: Inputs, + input_select: widgets::SelectState, + voice_select: widgets::SelectState, + host_select: widgets::SelectState, + hosts: Vec<(Option, String, bool, String)>, + call_live: bool, + /// Native voice styles, refreshed with the live session's reported catalog. + voices: Vec, + /// Created on first render (it needs this card's context). + shortcut: Option>, +} +/// Microphones as last enumerated, refreshed off the UI thread while the page +/// is visible so newly connected devices appear. +#[derive(Default)] +struct Inputs { + devices: Vec, + default: Option, + checked: Option, + task: Option>, +} +const INPUT_REFRESH: Duration = Duration::from_secs(3); +impl VoiceCard { + pub(crate) fn set_hosts( + &mut self, + hosts: Vec<(Option, String, bool, String)>, + live: bool, + cx: &mut Context, + ) { + if self.hosts != hosts || self.call_live != live { + self.hosts = hosts; + self.call_live = live; + cx.notify(); + } + } + + pub(crate) fn set_voices(&mut self, voices: &[String], cx: &mut Context) { + if !voices.is_empty() && self.voices != voices { + self.voices = voices.to_vec(); + cx.notify(); + } + } + + fn set_enabled(&mut self, enabled: bool, cx: &mut Context) { + settings::update(settings::SavePolicy::Immediate, cx, |s| { + s.dictation_enabled = enabled + }); + cx.refresh_windows(); + cx.notify(); + } + fn primary(&mut self, cx: &mut Context) { + if let Some(cancel) = &self.cancel { + cancel.store(true, Ordering::Release); + } else if self.ready { + self.set_enabled(!settings::current(cx).dictation_enabled, cx); + } else { + self.download(cx); + } + cx.notify(); + } + fn download(&mut self, cx: &mut Context) { + if self.cancel.is_some() { + return; + } + self.error = None; + self.progress = 0; + let cancel = Arc::new(AtomicBool::new(false)); + self.cancel = Some(cancel.clone()); + let progress = Arc::new(AtomicU64::new(0)); + let counter = progress.clone(); + let dir = self.directory.clone(); + let (tx, rx) = std::sync::mpsc::sync_channel(1); + std::thread::spawn(move || { + let r = zeron_voice::download(&dir, &cancel, |n| counter.store(n, Ordering::Relaxed)) + .map_err(|_| { + if cancel.load(Ordering::Acquire) { "Download cancelled.".to_owned() } + else { "Couldn’t download or verify the model. Check your connection and free storage, then retry.".to_owned() } + }); + let _ = tx.send(r); + }); + self.task = Some(cx.spawn(async move |this, cx| { + loop { + cx.background_executor() + .timer(Duration::from_millis(100)) + .await; + let result = rx.try_recv(); + let done = !matches!(result, Err(std::sync::mpsc::TryRecvError::Empty)); + if this + .update(cx, |this, cx| { + this.progress = progress.load(Ordering::Relaxed); + if done { + this.cache_present = this.directory.exists(); + } + match result { + Ok(Ok(())) => { + this.ready = true; + let cancelled = this + .cancel + .as_ref() + .is_some_and(|c| c.load(Ordering::Acquire)); + this.cancel = None; + this.set_enabled(!cancelled, cx); + } + Ok(Err(e)) => { + this.cancel = None; + this.error = Some(e); + } + Err(std::sync::mpsc::TryRecvError::Disconnected) => { + this.cancel = None; + this.error = Some("Download interrupted. Try again.".into()); + } + _ => {} + } + cx.notify(); + }) + .is_err() + || done + { + break; + } + } + })); + cx.notify(); + } + fn remove(&mut self, cx: &mut Context) { + if zeron_voice::busy() { + self.error = Some("Stop dictation before removing the model.".into()); + cx.notify(); + return; + } + self.set_enabled(false, cx); + zeron_voice::unload(); + match std::fs::remove_dir_all(&self.directory) { + Ok(()) => { + self.cache_present = false; + self.ready = false; + self.progress = 0; + self.error = None + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + self.cache_present = false; + self.ready = false; + self.progress = 0; + self.error = None; + } + Err(_) => { + self.error = + Some("Could not remove the model. Check folder permissions and retry.".into()) + } + } + cx.notify(); + } +} +impl popover::ScrollRailHost for VoiceCard { + fn rail_bar(&mut self) -> &mut popover::MenuScrollbarState { + self.scroll.rail_bar() + } + fn rail_scroll(&self) -> Option { + self.scroll.rail_scroll() + } +} +impl VoiceCard { + fn refresh_inputs(&mut self, cx: &mut Context) { + if self.inputs.task.is_some() + || self + .inputs + .checked + .is_some_and(|at| at.elapsed() < INPUT_REFRESH) + { + return; + } + let scan = cx.background_executor().spawn(async { + ( + zeron_voice::input_devices(), + zeron_voice::default_input_device(), + ) + }); + self.inputs.task = Some(cx.spawn(async move |this, cx| { + let (devices, default) = scan.await; + this.update(cx, |this, cx| { + let changed = this.inputs.devices != devices || this.inputs.default != default; + this.inputs.devices = devices; + this.inputs.default = default; + this.inputs.checked = Some(Instant::now()); + if changed { + cx.notify(); + } + }) + .ok(); + // A static settings page otherwise has no reason to render again, + // so checking the age only in render misses hot-plugged devices. + // Invalidate once when the scan expires. Only a visible card starts + // the next scan, so leaving Settings does not keep polling devices. + cx.background_executor().timer(INPUT_REFRESH).await; + this.update(cx, |this, cx| { + this.inputs.task = None; + cx.notify(); + }) + .ok(); + })); + } + + /// Options for the microphone select and the index of the current one. A + /// saved device that is unplugged stays listed so the choice is visible; + /// recording falls back to the system default meanwhile. + fn input_options(&self, saved: Option<&str>) -> (Vec, usize) { + let default_name = self.inputs.default.as_ref().and_then(|id| { + self.inputs + .devices + .iter() + .find(|d| &d.id == id) + .map(|d| d.name.clone()) + }); + let mut options = vec![match default_name { + Some(name) => widgets::SelectOption::new("System default").detail(name), + None => widgets::SelectOption::new("System default"), + }]; + options.extend( + self.inputs + .devices + .iter() + .map(|d| widgets::SelectOption::new(d.name.clone())), + ); + let selected = match saved { + None => 0, + Some(id) => match self.inputs.devices.iter().position(|d| d.id == id) { + Some(ix) => ix + 1, + None => { + options.push(widgets::SelectOption::new("Disconnected microphone")); + options.len() - 1 + } + }, + }; + (options, selected) + } +} +impl Render for VoiceCard { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let theme = Theme::of(cx).for_settings_surface(); + let saved_voice = settings::current(cx).codex_voice; + let mut voice_ids: Vec> = std::iter::once(None) + .chain(self.voices.iter().cloned().map(Some)) + .collect(); + if saved_voice.is_some() && !voice_ids.contains(&saved_voice) { + voice_ids.push(saved_voice.clone()); + } + let selected_voice = voice_ids + .iter() + .position(|v| *v == saved_voice) + .unwrap_or(0); + let voice_options: Vec<_> = voice_ids + .iter() + .map(|voice| { + let label = voice.as_deref().map_or_else( + || "Codex default".to_owned(), + |voice| { + let mut chars = voice.chars(); + chars + .next() + .map(|first| first.to_uppercase().chain(chars).collect::()) + .unwrap_or_default() + }, + ); + widgets::SelectOption::new(label) + }) + .collect(); + let voice_control = widgets::select( + "voice-style", + "Codex voice style", + &theme, + |card: &mut Self| &mut card.voice_select, + ) + .options(voice_options, selected_voice) + .width(200.0) + .on_select(move |_, ix, _, cx| { + let voice = voice_ids.get(ix).cloned().flatten(); + settings::update(settings::SavePolicy::Immediate, cx, |s| { + s.codex_voice = voice + }); + cx.refresh_windows(); + cx.notify(); + }) + .render(&self.voice_select, cx); + let host_row = { + let selected = settings::current(cx).codex_voice_device; + let mut hosts = self.hosts.clone(); + if selected.is_some() && !hosts.iter().any(|h| h.0 == selected) { + hosts.push(( + selected.clone(), + "Unavailable device".into(), + false, + "Choose another device".into(), + )); + } + let index = hosts.iter().position(|h| h.0 == selected).unwrap_or(0); + let options = hosts + .iter() + .map(|h| widgets::SelectOption::new(h.1.clone()).detail(h.3.clone())) + .collect::>(); + let control = widgets::select( + "codex-voice-device", + "Codex voice device", + &theme, + |card: &mut Self| &mut card.host_select, + ) + .options(options, index) + .width(200.0) + .on_select(move |card, ix, _, cx| { + if card.call_live { + return; + } + if let Some((id, _, true, _)) = hosts.get(ix) { + let id = id.clone(); + settings::update(settings::SavePolicy::Immediate, cx, |s| { + s.codex_voice_device = id + }); + cx.refresh_windows(); + cx.notify(); + } + }) + .render(&self.host_select, cx); + widgets::card_row(&theme, true) + .child( + div() + .flex_1() + .flex() + .flex_col() + .child(widgets::row_title(&theme, "Codex voice device")) + .child(widgets::meta_line( + &theme, + vec![ + if self.call_live { + "End the call to change devices" + } else { + "Runs Codex; audio stays on this device" + } + .into_any_element(), + ], + )), + ) + .child(control) + }; + let conversation_card = widgets::section_card(&theme).child(host_row).child( + widgets::card_row(&theme, true) + .child( + div() + .flex_1() + .min_w(px(160.0)) + .flex() + .flex_col() + .child(widgets::row_title(&theme, "Codex voice style")) + .child(widgets::meta_line( + &theme, + vec!["Applies to your next voice session".into_any_element()], + )), + ) + .child(voice_control), + ); + let enabled = settings::current(cx).dictation_enabled; + let downloading = self.cancel.is_some(); + let on = downloading || (enabled && self.ready); + let size_mb = zeron_voice::download_size() as f64 / 1e6; + let meta = if downloading { + Some(format!( + "Downloading · {:.0} of {size_mb:.0} MB", + self.progress as f64 / 1e6 + )) + } else if !self.ready { + Some(format!("{size_mb:.0} MB download")) + } else { + None + }; + let weak = cx.entity().downgrade(); + let switch = widgets::toggle_switch(&theme, on, "voice-dictation") + .id("voice-enable") + .cursor_pointer() + .tab_index(0) + .role(gpui::Role::Switch) + .aria_label("Dictation") + .aria_toggled(if on { + gpui::Toggled::True + } else { + gpui::Toggled::False + }) + .focus_visible(|s| s.border_2().border_color(theme.accent).opacity(1.0)) + .on_click(cx.listener(|this, _, _, cx| this.primary(cx))) + .on_key_down(cx.listener(|this, event: &gpui::KeyDownEvent, _, cx| { + if matches!(event.keystroke.key.as_str(), "enter" | "space") { + cx.stop_propagation(); + this.primary(cx); + } + })) + .on_a11y_action(gpui::AccessibleAction::Click, move |_, _, cx| { + weak.update(cx, |this, cx| this.primary(cx)).ok(); + }); + let progress = downloading.then(|| { + div() + .mt(px(8.0)) + .w_full() + .h(px(3.0)) + .rounded_full() + .bg(theme.border) + .child( + div() + .h_full() + .rounded_full() + .w(gpui::relative( + (self.progress as f32 / zeron_voice::download_size() as f32) + .clamp(0.0, 1.0), + )) + .bg(theme.accent), + ) + }); + let dictation_row = widgets::card_row(&theme, true) + .child( + div() + .flex_1() + .min_w(px(160.0)) + .flex() + .flex_col() + .child(widgets::row_title(&theme, "Dictation")) + .children(meta.map(|meta| { + div() + .id("voice-status") + .role(gpui::Role::Status) + .aria_label(meta.clone()) + .child(widgets::meta_line(&theme, vec![meta.into_any_element()])) + })) + .children(progress), + ) + .child(switch); + let microphone_row = (enabled && self.ready).then(|| { + self.refresh_inputs(cx); + let saved = settings::current(cx).dictation_input.clone(); + let (options, selected) = self.input_options(saved.as_deref()); + let ids: Vec> = std::iter::once(None) + .chain(self.inputs.devices.iter().map(|d| Some(d.id.clone()))) + .chain(std::iter::once(saved)) + .collect(); + let control = widgets::select( + "voice-microphone", + "Microphone", + &theme, + |card: &mut Self| &mut card.input_select, + ) + .options(options, selected) + .width(200.0) + .on_select(move |_, ix, _, cx| { + let input = ids.get(ix).cloned().flatten(); + settings::update(settings::SavePolicy::Immediate, cx, |s| { + s.dictation_input = input + }); + cx.notify(); + }) + .render(&self.input_select, cx); + widgets::card_row(&theme, false) + .child( + div() + .flex_1() + .min_w(px(160.0)) + .child(widgets::row_title(&theme, "Microphone")), + ) + .child(control) + }); + // Always visible, so ⌘D can be rebound (and freed) before the model + // is downloaded. + let shortcut_row = { + let field = self + .shortcut + .get_or_insert_with(|| { + cx.new(|cx| { + settings::shortcuts::ShortcutField::new( + settings::ShortcutId::ToggleDictation, + cx, + ) + }) + }) + .clone(); + widgets::card_row(&theme, false) + .child( + div() + .flex_1() + .min_w(px(160.0)) + .flex() + .flex_col() + .child(widgets::row_title(&theme, "Shortcut")) + .child(widgets::meta_line( + &theme, + vec!["Hold to talk, release to transcribe".into_any_element()], + )), + ) + .child(field) + }; + let model_card = (self.cache_present && !downloading).then(|| { + let remove_weak = cx.entity().downgrade(); + widgets::section_card(&theme).child( + widgets::card_row(&theme, true) + .child( + div() + .flex_1() + .min_w(px(160.0)) + .flex() + .flex_col() + .child(widgets::row_title(&theme, "Speech model")) + .child(widgets::meta_line( + &theme, + vec![ + "Parakeet v3".into_any_element(), + format!("{size_mb:.0} MB").into_any_element(), + ], + )), + ) + .child( + widgets::action_button(&theme, widgets::ActionTone::Quiet) + .id("voice-remove") + .role(gpui::Role::Button) + .aria_label("Remove speech model") + .tab_index(0) + .child("Remove") + .focus_visible(|s| s.border_2().border_color(theme.accent)) + .on_click(cx.listener(|this, _, _, cx| this.remove(cx))) + .on_key_down(cx.listener(|this, event: &gpui::KeyDownEvent, _, cx| { + if matches!(event.keystroke.key.as_str(), "enter" | "space") { + cx.stop_propagation(); + this.remove(cx); + } + })) + .on_a11y_action(gpui::AccessibleAction::Click, move |_, _, cx| { + remove_weak.update(cx, |this, cx| this.remove(cx)).ok(); + }), + ), + ) + }); + let error = self.error.as_ref().map(|e| { + div() + .id("voice-error") + .role(gpui::Role::Status) + .aria_label(e.clone()) + .child(widgets::error_strip(&theme, e.clone())) + }); + let scrollbar = popover::rail(self, "voice-page-scrollbar", &theme, cx); + div() + .id("voice-page-host") + .relative() + .size_full() + .on_hover(cx.listener(|this, hovered: &bool, _, cx| { + if this.scroll.set_list_hovered(*hovered) { + cx.notify(); + } + })) + .child( + crate::edge_fade::edge_faded( + 16.0, + true, + true, + div() + .id("voice-page") + .size_full() + .overflow_y_scroll() + .track_scroll(&self.scroll.scroll) + .child( + widgets::page_column() + .child(widgets::page_header(&theme, "Voice", None)) + .child(conversation_card) + .child( + widgets::section_card(&theme) + .child(dictation_row) + .children(microphone_row) + .child(shortcut_row), + ) + .children(error) + .children(model_card), + ), + ) + .fade_overflow_y(&self.scroll.scroll), + ) + .children(scrollbar) + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[gpui::test] + fn dictation_partial_cache_is_removable_after_restart(cx: &mut gpui::TestAppContext) { + let root = tempfile::tempdir().unwrap(); + let cache = root.path().join("models/parakeet-tdt-0.6b-v3-int8"); + std::fs::create_dir_all(&cache).unwrap(); + std::fs::write(cache.join("encoder-model.int8.onnx.part"), b"partial").unwrap(); + cx.update(|cx| settings::init(settings::UiSettings::default(), root.path(), cx)); + let card = cx.update(card); + card.update(cx, |card, cx| { + assert!(!card.ready); + assert_eq!(card.progress, 0); + assert!(card.cache_present); + card.remove(cx); + assert!(!card.cache_present); + assert!(!card.ready); + }); + assert!(!cache.exists()); + } + #[gpui::test] + fn microphone_choice_survives_disconnection(cx: &mut gpui::TestAppContext) { + let dir = tempfile::tempdir().unwrap(); + cx.update(|cx| settings::init(settings::UiSettings::default(), dir.path(), cx)); + let card = cx.update(card); + card.update(cx, |card, _| { + card.inputs.devices = vec![ + zeron_voice::InputDevice { + id: "coreaudio:built-in".into(), + name: "MacBook Pro Microphone".into(), + }, + zeron_voice::InputDevice { + id: "coreaudio:usb".into(), + name: "USB Microphone".into(), + }, + ]; + let (options, selected) = card.input_options(None); + assert_eq!((options.len(), selected), (3, 0)); + assert_eq!(card.input_options(Some("coreaudio:usb")).1, 2); + let (options, selected) = card.input_options(Some("coreaudio:gone")); + assert_eq!((options.len(), selected), (4, 3)); + }); + } + #[gpui::test] + fn dictation_is_opt_in_and_disabling_preserves_download(cx: &mut gpui::TestAppContext) { + let dir = tempfile::tempdir().unwrap(); + cx.update(|cx| settings::init(settings::UiSettings::default(), dir.path(), cx)); + cx.update(|cx| assert!(!enabled(cx))); + let card = cx.update(card); + card.update(cx, |card, cx| { + card.ready = true; + assert!(!settings::current(cx).dictation_enabled); + card.primary(cx); + }); + cx.update(|cx| assert!(enabled(cx))); + card.update(cx, |card, cx| { + card.primary(cx); + assert!(card.ready); + }); + cx.update(|cx| assert!(!enabled(cx))); + } +} diff --git a/crates/ui/src/dictation/permission.m b/crates/ui/src/dictation/permission.m new file mode 100644 index 0000000000..00bf5d8f26 --- /dev/null +++ b/crates/ui/src/dictation/permission.m @@ -0,0 +1,17 @@ +#import +#import +// No Rust callbacks or audio retained by permission blocks. +int zeron_microphone_permission(void) { + NSBundle *bundle = NSBundle.mainBundle; + if (![bundle.bundlePath.pathExtension isEqualToString:@"app"] || ![bundle objectForInfoDictionaryKey:@"NSMicrophoneUsageDescription"]) return -2; + AVAuthorizationStatus status = [AVCaptureDevice authorizationStatusForMediaType:AVMediaTypeAudio]; + if (status == AVAuthorizationStatusAuthorized) return 1; + if (status == AVAuthorizationStatusNotDetermined) return 0; + return -1; +} +void zeron_request_microphone(void) { + [AVCaptureDevice requestAccessForMediaType:AVMediaTypeAudio completionHandler:^(BOOL granted) {}]; +} +uintptr_t zeron_microphone_window(void) { + return NSApp.active ? (uintptr_t)(__bridge void *)NSApp.keyWindow : 0; +} diff --git a/crates/ui/src/dictation/waveform.rs b/crates/ui/src/dictation/waveform.rs new file mode 100644 index 0000000000..8dfe87ef3e --- /dev/null +++ b/crates/ui/src/dictation/waveform.rs @@ -0,0 +1,151 @@ +//! Painted dictation waveform: one rounded bar per meter slot, scrolling from +//! the trailing edge. A single canvas keeps a full-width waveform to one +//! element and a handful of quads per frame. +use std::time::Instant; + +use gpui::{Bounds, Hsla, IntoElement, Styled, point, px, size}; + +use super::{Bar, FLOOR}; + +pub(crate) const BAR_WIDTH: f32 = 3.0; +const BAR_GAP: f32 = 3.0; +const PITCH: f32 = BAR_WIDTH + BAR_GAP; +/// Bars dissolve over this distance at the leading edge instead of clipping. +const EDGE_FADE: f32 = 28.0; +/// Seconds for the transcribing highlight to cross the bars once. +const SWEEP_PERIOD: f32 = 1.6; +/// Seconds per breath of the placeholder baseline while capture starts. +const BREATH_PERIOD: f32 = 1.4; + +#[derive(Clone, Copy, PartialEq)] +pub(crate) enum Mode { + /// Microphone is opening: a quiet baseline breathes. + Waiting, + /// Live input: bars follow the voice. + Live, + /// Capture ended: bars hold still while a highlight sweeps across them. + Processing, +} + +pub(crate) struct Paint { + pub ink: Hsla, + pub quiet: Hsla, +} + +/// A process-wide clock so repeated renders stay phase-continuous. +pub(crate) fn seconds() -> f32 { + static EPOCH: std::sync::OnceLock = std::sync::OnceLock::new(); + EPOCH.get_or_init(Instant::now).elapsed().as_secs_f32() +} + +pub(crate) fn ease_out(t: f32) -> f32 { + let t = t.clamp(0.0, 1.0); + 1.0 - (1.0 - t).powi(3) +} + +/// Entrance and exit progress, each 0–1. +#[derive(Clone, Copy, Default)] +pub(crate) struct Phase { + /// Baseline draws in from the trailing edge as the pill appears. + pub intro: f32, + /// Bars settle back into the baseline as the transcript lands. + pub collapse: f32, +} + +/// Bars ride a fixed grid of dots, so live input rises out of the same +/// baseline the waiting state shows. `motion` false freezes the waiting +/// breath and the processing sweep; the indicator still names the state. +pub(crate) fn waveform( + bars: Vec, + mode: Mode, + paint: Paint, + phase: Phase, + motion: bool, +) -> impl IntoElement { + let now = if motion { seconds() } else { 0.0 }; + gpui::canvas( + |_, _, _| {}, + move |bounds, _, window, _| { + let height = f32::from(bounds.size.height); + let left = f32::from(bounds.left()); + let right = f32::from(bounds.right()) - BAR_WIDTH; + let mid = f32::from(bounds.center().y); + let width = (right - left).max(1.0); + // Newest slot's scroll position; the dots scroll with the bars. + let offset = match mode { + Mode::Waiting => 0.0, + _ => bars.first().map_or(0.0, |bar| bar.age.fract()), + }; + let count = ((width + BAR_WIDTH) / PITCH).ceil() as usize + 1; + let sweep = (now / SWEEP_PERIOD).fract(); + let settle = 1.0 - ease_out(phase.collapse); + for k in 0..count { + let x = right - (k as f32 + offset) * PITCH; + let fade = ((x - left) / EDGE_FADE).clamp(0.0, 1.0); + // Draw in from the trailing edge: 0 there, 1 at the leading edge. + let distance = 1.0 - (x - left) / width; + let reveal = ease_out((phase.intro - 0.5 * distance) / 0.5); + let alpha = fade * reveal; + if alpha <= 0.0 { + continue; + } + let (amplitude, color) = match (mode, bars.get(k)) { + (Mode::Waiting, _) | (_, None) => { + // A soft swell travels toward the leading edge. + let swell = if mode == Mode::Waiting { + let wave = now / BREATH_PERIOD - k as f32 / 18.0; + (0.5 + 0.5 * (wave * std::f32::consts::TAU).sin()).powi(3) + } else { + 0.0 + }; + (FLOOR + 0.1 * swell, paint.quiet) + } + (_, Some(bar)) => { + // The newest bar rises out of the baseline as it enters. + let grow = if motion { ease_out(bar.age) } else { 1.0 }; + let color = if mode == Mode::Live { + paint.ink + } else { + // Right-to-left highlight, matching the scroll. + let position = 1.0 - (x - left) / width; + let distance = (position - (sweep * 1.4 - 0.2)).abs(); + let glow = (1.0 - distance / 0.18).clamp(0.0, 1.0); + crate::motion::mix(paint.quiet, paint.ink, ease_out(glow)) + }; + let lift = (1.0 - FLOOR) * bar.amplitude * grow * settle; + (FLOOR + lift, crate::motion::mix(paint.quiet, color, settle)) + } + }; + let h = + (amplitude.clamp(FLOOR, 1.0) * height * (0.4 + 0.6 * reveal)).max(BAR_WIDTH); + window.paint_quad( + gpui::fill( + Bounds::new(point(px(x), px(mid - h / 2.0)), size(px(BAR_WIDTH), px(h))), + color.opacity(alpha), + ) + .corner_radii(px(BAR_WIDTH / 2.0)), + ); + } + }, + ) + .flex_1() + .min_w_0() + .h_full() +} + +/// Display time for the recording clock: `0:07`. +pub(crate) fn clock(elapsed: std::time::Duration) -> String { + let seconds = elapsed.as_secs(); + format!("{}:{:02}", seconds / 60, seconds % 60) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn clock_formats_minutes_and_padded_seconds() { + assert_eq!(clock(std::time::Duration::from_millis(7_900)), "0:07"); + assert_eq!(clock(std::time::Duration::from_secs(60)), "1:00"); + } +} diff --git a/crates/ui/src/edge_fade.rs b/crates/ui/src/edge_fade.rs index c78b8999e7..d59b0e6f9e 100644 --- a/crates/ui/src/edge_fade.rs +++ b/crates/ui/src/edge_fade.rs @@ -63,6 +63,7 @@ pub fn edge_faded(band: f32, top: bool, bottom: bool, child: impl IntoElement) - overflow_y: None, scroll_x: None, smooth_overflow_x: false, + grow_x: false, child: child.into_any_element(), } } @@ -81,6 +82,7 @@ pub struct EdgeFaded { overflow_y: Option (bool, bool)>>, scroll_x: Option, smooth_overflow_x: bool, + grow_x: bool, child: AnyElement, } @@ -139,6 +141,16 @@ impl EdgeFaded { self } + /// [`Self::fade_overflow_x`] for a scrolling strip: each side's ramp is + /// as wide as the content hidden past it, up to the band. The ramp still + /// reaches zero exactly at the clip edge, but a strip scrolled by 2 px + /// dims 2 px instead of a whole band — no snap as the fade switches on. + pub fn fade_scroll_x(mut self, handle: &ScrollHandle) -> Self { + self.scroll_x = Some(handle.clone()); + self.grow_x = true; + self + } + /// Ease a right-edge label fade into view as overflow grows from zero to /// one band. Unlike scroll chrome, a label should not suddenly dim its /// final characters as soon as it becomes a fraction too wide. @@ -221,7 +233,17 @@ impl Element for EdgeFaded { } let (mut left, mut right) = (self.left, self.right); let mut outset_right = 0.0; - if let Some(scroll) = &self.scroll_x { + let (mut band_left, mut band_right) = (None, None); + if let Some(scroll) = &self.scroll_x + && self.grow_x + { + let hidden_left = (-f32::from(scroll.offset().x)).max(0.0); + let hidden_right = (f32::from(scroll.max_offset().x) - hidden_left).max(0.0); + left &= hidden_left > 0.5; + right &= hidden_right > 0.5; + band_left = Some(px(hidden_left.min(self.band))); + band_right = Some(px(hidden_right.min(self.band))); + } else if let Some(scroll) = &self.scroll_x { let scrolled = -f32::from(scroll.offset().x); let max_scroll = f32::from(scroll.max_offset().x); left &= scrolled > 1.0; @@ -246,6 +268,8 @@ impl Element for EdgeFaded { band: px(self.band), band_top: self.band_top.map(px), band_bottom: self.band_bottom.map(px), + band_left, + band_right, top, bottom, left, diff --git a/crates/ui/src/file_icons.rs b/crates/ui/src/file_icons.rs index 2609c01147..a7d43d8828 100644 --- a/crates/ui/src/file_icons.rs +++ b/crates/ui/src/file_icons.rs @@ -6,9 +6,13 @@ //! authored SVG colors are preserved in light mode; dark mode lifts the //! palette's darker accents without replacing polychrome artwork with a tint. -use std::{borrow::Cow, collections::HashMap, sync::LazyLock}; +use std::{ + borrow::Cow, + collections::HashMap, + sync::{Arc, LazyLock, Mutex}, +}; -use gpui::{AssetSource, Img, Result, SharedString, Styled as _, img}; +use gpui::{App, AssetSource, Img, RenderImage, Result, SharedString, Styled as _, img}; use rust_embed::RustEmbed; use serde::Deserialize; use zeron_syntax::LanguageId; @@ -204,6 +208,21 @@ pub fn icon(identity: FileIconIdentity<'_>, appearance: Appearance) -> Img { img(asset_path(identity, appearance)).flex_none() } +/// A file-theme icon rasterized for painting straight into a text layout, +/// where no [`Img`] element can sit. Rasterized once per asset path. +pub(crate) fn raster(path: &SharedString, cx: &App) -> Option> { + static CACHE: LazyLock>>> = + LazyLock::new(Default::default); + let mut cache = CACHE.lock().unwrap(); + if let Some(image) = cache.get(path) { + return Some(image.clone()); + } + let svg = Assets.load(path).ok()??; + let image = cx.svg_renderer().render_single_frame(&svg, 2.0).ok()?; + cache.insert(path.clone(), image.clone()); + Some(image) +} + /// Whether a filename resolves to a themed identity rather than the generic /// document fallback. This is intentionally stricter than [`asset_path`]: it /// lets prose renderers decorate a standalone filename without treating every diff --git a/crates/ui/src/files/client.rs b/crates/ui/src/files/client.rs index a7669da2e6..8b100ccba2 100644 --- a/crates/ui/src/files/client.rs +++ b/crates/ui/src/files/client.rs @@ -132,7 +132,7 @@ impl WorkspaceFilesClient { /// Refresh through the cached children before publishing the new listing. /// Usually this reads only the pages already visited. If a cached child is /// missing, reach the end before treating it as deleted. - pub(super) async fn list_directory_snapshot( + pub(crate) async fn list_directory_snapshot( &self, mut request: ListWorkspaceDirectoryRequest, cached_paths: &[String], @@ -156,6 +156,11 @@ impl WorkspaceFilesClient { } request.cursor = Some(cursor); let next = self.list_directory(request.clone()).await?; + if next.checkout_id != page.checkout_id { + return Err(FilesClientError::Decode( + "Workspace changed between directory pages".into(), + )); + } for entry in &next.entries { remaining.remove(entry.path.as_str()); } @@ -256,6 +261,20 @@ impl WorkspaceFilesClient { self.call(methods::WRITE_WORKSPACE_FILE, &request).await } + pub async fn move_entry( + &self, + request: zeron_proto::MoveWorkspaceEntryRequest, + ) -> Result { + self.call(methods::MOVE_WORKSPACE_ENTRY, &request).await + } + + pub async fn delete_entry( + &self, + request: zeron_proto::DeleteWorkspaceEntryRequest, + ) -> Result { + self.call(methods::DELETE_WORKSPACE_ENTRY, &request).await + } + pub async fn watch(&self) -> Result, FilesClientError> { let request = WatchWorkspaceFilesRequest { target: self.context.target.clone(), @@ -356,6 +375,40 @@ mod tests { }) } + #[tokio::test] + async fn structural_mutations_preserve_remote_addressing_and_never_retry_transport_errors() { + let transport = Arc::new(DeterministicTransport { + scripted_responses: Mutex::new([Err(RpcError::Transport("reply lost".into()))].into()), + ..Default::default() + }); + let client = WorkspaceFilesClient::with_transport( + transport.clone(), + FilesRequestContext { + target: target(), + target_device_id: Some("host".into()), + cwd: "/remote".into(), + checkout_id: Some("checkout".into()), + }, + ); + let result = client + .move_entry(zeron_proto::MoveWorkspaceEntryRequest { + target: target(), + operation_id: "op".into(), + expected_checkout_id: "checkout".into(), + source_path: "a".into(), + destination_path: "folder/a".into(), + expected_source_revision: "rev".into(), + expected_kind: zeron_proto::WorkspaceEntryKind::File, + }) + .await; + assert!(result.is_err()); + let calls = transport.calls.lock().unwrap(); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].0, methods::MOVE_WORKSPACE_ENTRY); + assert_eq!(calls[0].1["targetDeviceId"], "host"); + assert_eq!(calls[0].1["expectedCheckoutId"], "checkout"); + assert_eq!(calls[0].1["sourcePath"], "a"); + } #[tokio::test] async fn cached_directory_refresh_collects_pages_but_initial_load_stays_lazy() { for refresh in [false, true] { diff --git a/crates/ui/src/files/context_menu.rs b/crates/ui/src/files/context_menu.rs new file mode 100644 index 0000000000..c6cefee1bd --- /dev/null +++ b/crates/ui/src/files/context_menu.rs @@ -0,0 +1,322 @@ +//! Tree menus share the editor's popup surface, positioning and dismissal. +use super::*; +use crate::{icons, popover, theme::Theme}; +use gpui::{ClipboardItem, KeyDownEvent, MouseButton}; +use mutations::WorkspaceInteractionOrigin; + +pub(super) struct TreeContextMenu { + pub path: String, + pub origin: WorkspaceInteractionOrigin, + pub position: Point, + pub active: usize, +} + +fn absolute_workspace_path(root: &str, relative: &str) -> String { + // Interpret the owning host's path, not the desktop's OS. PathBuf::join + // would mix separators when viewing a Windows workspace from Linux/macOS. + let bytes = root.as_bytes(); + let windows = root.starts_with("\\\\") + || (bytes.len() >= 3 + && bytes[0].is_ascii_alphabetic() + && bytes[1] == b':' + && matches!(bytes[2], b'/' | b'\\')); + if windows { + format!( + "{}\\{}", + root.trim_end_matches(['/', '\\']).replace('/', "\\"), + relative.replace('/', "\\") + ) + } else { + format!("{}/{}", root.trim_end_matches('/'), relative) + } +} + +impl FilesSurface { + pub(super) fn open_tree_context_menu( + &mut self, + path: String, + position: Point, + window: &mut Window, + cx: &mut Context, + ) { + let Some(origin) = self.interaction_origin(cx) else { + return; + }; + if self.tree.node(&path).is_none() { + return; + } + self.tree.select(path.clone()); + self.tree_focus.focus(window, cx); + self.close_editor_context_menu(cx); + self.tree_context_menu.open(TreeContextMenu { + path, + origin, + position, + active: 0, + }); + cx.notify(); + } + + pub(super) fn close_tree_context_menu(&mut self, cx: &mut Context) { + if self.tree_context_menu.begin_close() { + popover::reap_popup(cx, |files: &mut Self| &mut files.tree_context_menu); + cx.notify(); + } + } + + pub(super) fn tree_menu_key( + &mut self, + event: &KeyDownEvent, + window: &mut Window, + cx: &mut Context, + ) -> bool { + if !self.tree_context_menu.is_open() { + return false; + } + match popover::classify_key( + &event.keystroke.key, + event.keystroke.modifiers.platform, + event.keystroke.modifiers.control, + ) { + popover::MenuKey::Escape => self.close_tree_context_menu(cx), + key @ (popover::MenuKey::Up | popover::MenuKey::Down) => { + if let Some(menu) = self.tree_context_menu.as_open() { + let mut next = menu.active; + for _ in 0..4 { + next = popover::menu_step( + Some(next), + 4, + if matches!(key, popover::MenuKey::Up) { + -1 + } else { + 1 + }, + ) + .unwrap_or(0); + if next < 2 || self.can_mutate_tree_entry(&menu.path, next == 3, cx) { + break; + } + } + self.tree_context_menu.open_mut().unwrap().active = next; + cx.notify(); + } + } + popover::MenuKey::Enter => { + if let Some(menu) = self.tree_context_menu.as_open() { + self.dispatch_tree_menu(menu.active, window, cx); + } + } + _ => {} + } + window.prevent_default(); + cx.stop_propagation(); + true + } + + fn dispatch_tree_menu(&mut self, action: usize, window: &mut Window, cx: &mut Context) { + let Some(menu) = self.tree_context_menu.as_open() else { + return; + }; + let path = menu.path.clone(); + let origin = menu.origin.clone(); + if !self.accepts_origin(&origin, cx) { + self.close_tree_context_menu(cx); + return; + } + let Some(node) = self.tree.node(&path) else { + self.close_tree_context_menu(cx); + return; + }; + let is_directory = node.entry.kind == zeron_proto::WorkspaceEntryKind::Directory; + self.close_tree_context_menu(cx); + match action { + 0 => cx.emit(FilesEvent::AddToChat { + path, + is_directory, + origin, + }), + 1 => { + cx.write_to_clipboard(ClipboardItem::new_string(absolute_workspace_path( + &origin.context.cwd, + &path, + ))); + self.tree_focus.focus(window, cx); + } + 2 => self.begin_tree_rename(path, window, cx), + 3 => self.begin_tree_delete(path, window, cx), + _ => {} + } + } + + pub(super) fn render_tree_context_menu( + &self, + theme: &Theme, + cx: &mut Context, + ) -> Option { + let menu = self.tree_context_menu.get()?; + let theme = theme.for_popup(); + let mut card = popover::popover_card(&theme) + .w(px(190.0)) + .flex() + .flex_col() + .on_mouse_down_out(cx.listener(|this, _, _, cx| this.close_tree_context_menu(cx))) + .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()); + for (index, (label, icon_path)) in [ + ("Add to chat", icons::CHAT_ROUND_LINE), + ("Copy path", icons::COPY), + ("Rename…", icons::PEN), + ("Delete…", icons::TRASH_BIN_MINIMALISTIC), + ] + .into_iter() + .enumerate() + { + if index == 2 { + card = card.child(popover::menu_separator()); + } + let enabled = index < 2 || self.can_mutate_tree_entry(&menu.path, index == 3, cx); + card = card.child( + popover::menu_row(&theme, menu.active == index, format!("tree-menu-{index}")) + .id(gpui::SharedString::from(format!("tree-menu-{index}"))) + .role(gpui::Role::MenuItem) + .aria_label(label) + .when(index == 3, |row| row.text_color(theme.danger)) + .when(index == 1, |row| { + row.tooltip(|_, cx| { + cx.new(|_| preview::FileEditorTooltip { + text: "Copy full path".into(), + }) + .into() + }) + }) + .when(!enabled, |el| el.opacity(0.38).cursor_default()) + .when(enabled, |el| { + el.on_click(cx.listener(move |this, _, window, cx| { + this.dispatch_tree_menu(index, window, cx) + })) + }) + .child( + icons::icon(icon_path) + .size(px(16.0)) + .text_color(if index == 3 { + theme.danger + } else { + theme.text_muted + }), + ) + .child(label), + ); + } + Some(popover::menu_at( + "tree-context-menu", + menu.position, + card.into_any_element(), + self.tree_context_menu.closing_since(), + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui::{AppContext, TestAppContext}; + + #[test] + fn full_paths_follow_the_owning_hosts_path_format() { + for (root, expected) in [ + ("/workspace", "/workspace/src/a.txt"), + ("/workspace/", "/workspace/src/a.txt"), + ("/", "/src/a.txt"), + (r"C:\work", r"C:\work\src\a.txt"), + (r"C:\", r"C:\src\a.txt"), + ("C:/work/", r"C:\work\src\a.txt"), + (r"\\server\share\work\", r"\\server\share\work\src\a.txt"), + (r"\\?\C:\work", r"\\?\C:\work\src\a.txt"), + ] { + assert_eq!(absolute_workspace_path(root, "src/a.txt"), expected); + } + } + + #[gpui::test] + fn copy_uses_the_remote_workspace_root(cx: &mut TestAppContext) { + cx.update(|cx| { + gpui_base::init(cx); + cx.set_global(Theme::dark()); + }); + for (root, expected) in [ + ("/remote/project", "/remote/project/a.txt"), + (r"D:\remote\project", r"D:\remote\project\a.txt"), + ] { + let window = cx.add_window(|_, cx| { + let state = cx.new(|_| { + let mut state = super::super::test_support::state(); + state.chats[0].device_id = "remote-host".into(); + state.chats[0].cwd = Some(root.into()); + state + }); + super::super::test_support::explorer(state, cx) + }); + window + .update(cx, |files, window, cx| { + assert_eq!( + files + .request_context + .as_ref() + .unwrap() + .target_device_id + .as_deref(), + Some("remote-host") + ); + files.open_tree_context_menu( + "a.txt".into(), + gpui::point(px(50.), px(50.)), + window, + cx, + ); + files.dispatch_tree_menu(1, window, cx); + assert_eq!( + cx.read_from_clipboard().unwrap().text().as_deref(), + Some(expected) + ); + }) + .unwrap(); + } + } + + #[gpui::test] + fn menu_selects_without_opening_and_copies_full_path(cx: &mut TestAppContext) { + let (files, cx) = super::super::test_support::setup(cx); + let events = std::rc::Rc::new(std::cell::RefCell::new(Vec::new())); + let recorded = events.clone(); + let _subscription = cx.update(|_, cx| { + cx.subscribe(&files, move |_, event, _| { + recorded.borrow_mut().push(event.clone()) + }) + }); + files.update_in(cx, |files, window, cx| { + files.open_tree_context_menu("a.txt".into(), gpui::point(px(50.), px(50.)), window, cx); + assert_eq!(files.tree.selected(), Some("a.txt")); + assert!(events.borrow().is_empty()); + files.dispatch_tree_menu(1, window, cx); + assert_eq!( + cx.read_from_clipboard().unwrap().text().as_deref(), + Some("/workspace/a.txt") + ); + files.open_tree_context_menu( + "folder".into(), + gpui::point(px(50.), px(50.)), + window, + cx, + ); + assert!(!files.tree.is_expanded("folder")); + files.dispatch_tree_menu(0, window, cx); + }); + cx.run_until_parked(); + assert!(events.borrow().iter().any(|event|matches!(event,FilesEvent::AddToChat{path,is_directory:true,..} if path=="folder"))); + assert!( + !events + .borrow() + .iter() + .any(|event| matches!(event, FilesEvent::OpenFile(_))) + ); + } +} diff --git a/crates/ui/src/files/drag.rs b/crates/ui/src/files/drag.rs new file mode 100644 index 0000000000..ccccd82aff --- /dev/null +++ b/crates/ui/src/files/drag.rs @@ -0,0 +1,490 @@ +//! Workspace drag targets are scoped to the tree viewport; the chat keeps its own receiver. +use super::*; +use gpui::{AnyElement, Bounds, CursorStyle, DragMoveEvent}; +use std::{cell::RefCell, rc::Rc}; +use zeron_proto::WorkspaceEntryKind; + +#[derive(Default)] +pub(super) struct TreeDrag { + rows: Rc, Option)>>>, + pub payload: Option, + pub destination: Option, + pub pointer: Point, + pub bounds: Bounds, + hover_since: Option, + last_tick: Option, + tick_task: Option>, +} + +/// The server is authoritative for existence/permissions/collisions. This +/// resolver handles gesture semantics without touching the filesystem. +pub(super) fn destination_path( + source: &str, + directory: &str, + is_directory: bool, +) -> Option { + if source.is_empty() || model::parent_path(source).as_deref() == Some(directory) { + return None; + } + if is_directory && mutations::contains_path(source, directory) { + return None; + } + let name = source.rsplit('/').next()?; + Some(if directory.is_empty() { + name.into() + } else { + format!("{directory}/{name}") + }) +} + +impl FilesSurface { + pub(super) fn track_tree_drop_row( + &self, + key: String, + target: Option, + height: f32, + content: AnyElement, + ) -> AnyElement { + let rows = self.tree_drag.rows.clone(); + div() + .relative() + .w_full() + .h(px(height)) + .flex_none() + .child(content) + .child( + gpui::canvas( + move |bounds, _, _| { + rows.borrow_mut() + .insert(key.clone(), (bounds, target.clone())); + }, + |_, _, _, _| {}, + ) + .absolute() + .inset_0(), + ) + .into_any_element() + } + + pub(super) fn render_tree_root_target(&self, cx: &mut Context) -> AnyElement { + let theme = crate::theme::Theme::of(cx); + let active = self.tree_drag.destination.as_deref() == Some(""); + let label = if self.tree_drag.payload.is_some() { + "Move to workspace root" + } else { + "Workspace root" + }; + self.track_tree_drop_row( + "".into(), + Some("".into()), + 24., + div() + .id("tree-workspace-root") + .debug_selector(|| "tree-workspace-root".into()) + .h(px(24.)) + .w_full() + .px(px(8.)) + .flex() + .items_center() + .text_size(px(10.5)) + .text_color(theme.text_muted) + .when(active, |el| el.bg(crate::theme::wash(0.16))) + .aria_label(label) + .child(label) + .into_any_element(), + ) + } + + pub(super) fn reset_tree_drop_rows(&self) { + self.tree_drag.rows.borrow_mut().clear(); + } + + pub(super) fn tree_drag_compatible(&self, payload: &WorkspacePathDrag, cx: &gpui::App) -> bool { + payload.source == WorkspacePathSource::Tree + && payload.origin.as_ref().is_some_and(|origin| { + self.accepts_origin(origin, cx) && origin.checkout_id == self.effective_checkout_id + }) + && !self.mutation_busy() + && self.tree_rename.is_none() + && self.tree_delete.is_none() + && self + .mutation_capabilities + .is_some_and(|caps| caps.move_entry) + && self.tree.node(&payload.path).is_some_and(|node| { + node.entry.kind != WorkspaceEntryKind::Symlink + && node.entry.mutation_revision.is_some() + && node.entry.mutation_revision == payload.revision + }) + } + + fn drop_directory_at(&self, point: Point) -> Option { + if !self.tree_drag.bounds.contains(&point) { + return None; + } + // The rail remains a scroll control, not a root drop target. + if point.x > self.tree_drag.bounds.right() - px(12.) { + return None; + } + let rows = self.tree_drag.rows.borrow(); + for (bounds, target) in rows.values() { + if bounds.contains(&point) { + let path = target.as_ref()?; + if path.is_empty() { + return Some(String::new()); + } + let node = self.tree.node(path)?; + return match node.entry.kind { + WorkspaceEntryKind::Directory => Some(path.clone()), + WorkspaceEntryKind::File => model::parent_path(path), + WorkspaceEntryKind::Symlink => None, + }; + } + } + let viewport = self.tree_list.viewport_bounds(); + let last = rows.values().map(|(bounds, _)| bounds.bottom()).max(); + if viewport.contains(&point) && last.is_none_or(|bottom| point.y >= bottom) { + Some(String::new()) + } else { + None + } + } + + pub(super) fn on_tree_drag_move( + &mut self, + event: &DragMoveEvent, + window: &mut Window, + cx: &mut Context, + ) { + let payload = event.drag(cx).clone(); + self.tree_drag.bounds = event.bounds; + if !event.bounds.contains(&event.event.position) || !self.tree_drag_compatible(&payload, cx) + { + self.clear_tree_drag(window, cx); + return; + } + self.close_tree_context_menu(cx); + self.tree_drag.pointer = event.event.position; + self.tree_drag.payload = Some(payload.clone()); + let destination = self + .drop_directory_at(event.event.position) + .filter(|directory| { + destination_path(&payload.path, directory, payload.is_directory).is_some() + }); + if self.tree_drag.destination != destination { + self.tree_drag.hover_since = Some(cx.background_executor().now()); + self.tree_drag.destination = destination; + cx.notify(); + } + if self.tree_drag.tick_task.is_none() { + self.tree_drag.last_tick = Some(cx.background_executor().now()); + self.tree_drag.tick_task = Some(cx.spawn_in(window, async move |this, cx| { + loop { + cx.background_executor() + .timer(Duration::from_millis(16)) + .await; + if !this + .update_in(cx, |files, window, cx| files.tree_drag_tick(window, cx)) + .unwrap_or(false) + { + break; + } + } + })); + } + cx.set_active_drag_cursor_style( + if self.tree_drag.destination.is_some() { + CursorStyle::ClosedHand + } else { + CursorStyle::OperationNotAllowed + }, + window, + ); + } + + pub(super) fn on_tree_drop( + &mut self, + payload: &WorkspacePathDrag, + window: &mut Window, + cx: &mut Context, + ) { + let destination = if self.tree_drag_compatible(payload, cx) { + self.drop_directory_at(window.mouse_position()) + .and_then(|directory| { + destination_path(&payload.path, &directory, payload.is_directory) + }) + } else { + None + }; + self.clear_tree_drag(window, cx); + if let Some(destination) = destination { + self.request_mutation(&payload.path, Some(destination), cx); + } + } + + pub(super) fn tree_drag_tick(&mut self, window: &mut Window, cx: &mut Context) -> bool { + let Some(payload) = self.tree_drag.payload.clone() else { + return false; + }; + let pointer = window.mouse_position(); + if !cx.has_active_drag() + || !window.is_window_active() + || !self.tree_drag_compatible(&payload, cx) + || !self.tree_drag.bounds.contains(&pointer) + { + self.clear_tree_drag(window, cx); + return false; + } + let now = cx.background_executor().now(); + let elapsed = self + .tree_drag + .last_tick + .replace(now) + .map(|last| now.saturating_duration_since(last).as_secs_f32()) + .unwrap_or(0.) + .min(0.05); + let viewport = self.tree_list.viewport_bounds(); + let speed = if viewport.contains(&pointer) { + edge_scroll_speed( + f32::from(pointer.y), + f32::from(viewport.top()), + f32::from(viewport.bottom()), + ) + } else { + 0. + }; + if speed != 0. { + let offset = self.tree_list.scroll_px_offset_for_scrollbar(); + self.tree_list + .set_offset_from_scrollbar(gpui::point(offset.x, offset.y - px(speed * elapsed))); + cx.notify(); + } + let destination = self.drop_directory_at(pointer).filter(|directory| { + destination_path(&payload.path, directory, payload.is_directory).is_some() + }); + if destination != self.tree_drag.destination { + self.tree_drag.destination = destination; + self.tree_drag.hover_since = Some(now); + cx.notify(); + } + if self + .tree_drag + .hover_since + .is_some_and(|since| now.saturating_duration_since(since) >= Duration::from_millis(650)) + { + self.tree_drag.hover_since = None; + if let Some(directory) = self.tree_drag.destination.clone().filter(|p| !p.is_empty()) { + if self.tree.expand(&directory) { + self.sync_tree_list(); + let needs_load = self + .tree + .node(&directory) + .is_some_and(|node| node.stale || !node.has_loaded); + if needs_load { + self.load_directory(directory, None, cx); + } + cx.notify(); + } + } + } + true + } + + pub(crate) fn suspend_tree_interactions(&mut self, cx: &mut Context) { + if self.tree_drag.payload.is_some() + || self.tree_context_menu.is_open() + || self + .tree_rename + .as_ref() + .is_some_and(|rename| !rename.submitted) + || self.tree_delete.is_some() + { + self.interaction_generation = self.interaction_generation.wrapping_add(1); + self.tree_drag = TreeDrag::default(); + self.close_tree_context_menu(cx); + if self + .tree_rename + .as_ref() + .is_some_and(|rename| !rename.submitted) + { + self.tree_rename = None; + } + if let Some(dialog) = self.tree_delete.take() { + cx.emit(FilesEvent::HoldMutation { + origin: dialog.origin, + path: None, + }); + } + cx.notify(); + } + } + + pub(super) fn clear_tree_drag(&mut self, window: &mut Window, cx: &mut Context) { + self.tree_drag.tick_task = None; + self.tree_drag.hover_since = None; + self.tree_drag.last_tick = None; + if self.tree_drag.payload.take().is_some() || self.tree_drag.destination.take().is_some() { + self.tree_drag.destination = None; + cx.set_active_drag_cursor_style(CursorStyle::Arrow, window); + cx.notify(); + } + } +} + +fn edge_scroll_speed(y: f32, top: f32, bottom: f32) -> f32 { + if y < top || y > bottom || bottom <= top { + return 0.; + } + let band = 28.0_f32.min((bottom - top) / 2.0); + if y < top + band { + -540.0 * (1.0 - (y - top) / band) + } else if y > bottom - band { + 540.0 * (1.0 - (bottom - y) / band) + } else { + 0.0 + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[gpui::test] + fn real_tree_drop_emits_one_move_and_no_chat_reference(cx: &mut gpui::TestAppContext) { + let (files, cx) = super::super::test_support::setup(cx); + let events = std::rc::Rc::new(std::cell::RefCell::new(Vec::new())); + let recorded = events.clone(); + let _sub = cx.update(|_, cx| { + cx.subscribe(&files, move |_, event, _| { + recorded.borrow_mut().push(event.clone()) + }) + }); + let start = cx.debug_bounds("tree-entry:a.txt").unwrap().center(); + let end = cx.debug_bounds("tree-entry:folder").unwrap().center(); + cx.simulate_mouse_down(start, gpui::MouseButton::Left, gpui::Modifiers::default()); + cx.simulate_mouse_move( + start + gpui::point(px(9.), px(0.)), + Some(gpui::MouseButton::Left), + gpui::Modifiers::default(), + ); + cx.simulate_mouse_move( + end, + Some(gpui::MouseButton::Left), + gpui::Modifiers::default(), + ); + cx.simulate_mouse_up(end, gpui::MouseButton::Left, gpui::Modifiers::default()); + cx.run_until_parked(); + let events = events.borrow(); + let moves = events + .iter() + .filter_map(|event| { + if let FilesEvent::Mutate(intent) = event { + Some(intent) + } else { + None + } + }) + .collect::>(); + assert_eq!(moves.len(), 1); + assert_eq!(moves[0].destination.as_deref(), Some("folder/a.txt")); + assert!( + !events + .iter() + .any(|event| matches!(event, FilesEvent::AddToChat { .. })) + ); + } + #[gpui::test] + fn hover_expands_only_after_delay_and_escape_stops_tasks(cx: &mut gpui::TestAppContext) { + let (files, cx) = super::super::test_support::setup(cx); + let start = cx.debug_bounds("tree-entry:a.txt").unwrap().center(); + let end = cx.debug_bounds("tree-entry:folder").unwrap().center(); + cx.simulate_mouse_down(start, gpui::MouseButton::Left, gpui::Modifiers::default()); + cx.simulate_mouse_move( + start + gpui::point(px(9.), px(0.)), + Some(gpui::MouseButton::Left), + gpui::Modifiers::default(), + ); + cx.simulate_mouse_move( + end, + Some(gpui::MouseButton::Left), + gpui::Modifiers::default(), + ); + cx.executor().advance_clock(Duration::from_millis(649)); + files.update_in(cx, |files, window, cx| { + files.tree_drag_tick(window, cx); + assert!(!files.tree.is_expanded("folder")); + }); + cx.executor().advance_clock(Duration::from_millis(1)); + files.update_in(cx, |files, window, cx| { + files.tree_drag_tick(window, cx); + assert!(files.tree.is_expanded("folder")); + }); + cx.simulate_keystrokes("escape"); + files.read_with(cx, |files, cx| { + assert!(files.tree_drag.tick_task.is_none()); + assert!(files.tree_drag.destination.is_none()); + assert!(!cx.has_active_drag()); + }); + } + #[cfg(target_os = "windows")] + #[gpui::test] + fn windows_row_jitter_preserves_click_and_row_starts_drag(cx: &mut gpui::TestAppContext) { + let (files, cx) = super::super::test_support::setup(cx); + let events = std::rc::Rc::new(std::cell::RefCell::new(Vec::new())); + let recorded = events.clone(); + let _sub = cx.update(|_, cx| { + cx.subscribe(&files, move |_, event, _| { + recorded.borrow_mut().push(event.clone()) + }) + }); + let row = cx.debug_bounds("tree-entry:a.txt").unwrap().center(); + cx.simulate_mouse_down(row, gpui::MouseButton::Left, gpui::Modifiers::default()); + cx.simulate_mouse_move( + row + gpui::point(px(3.), px(0.)), + Some(gpui::MouseButton::Left), + gpui::Modifiers::default(), + ); + cx.update(|_, cx| assert!(!cx.has_active_drag())); + cx.simulate_mouse_up( + row + gpui::point(px(3.), px(0.)), + gpui::MouseButton::Left, + gpui::Modifiers::default(), + ); + cx.run_until_parked(); + assert!( + events + .borrow() + .iter() + .any(|event| matches!(event,FilesEvent::OpenFile(path) if path=="a.txt")) + ); + cx.simulate_mouse_down(row, gpui::MouseButton::Left, gpui::Modifiers::default()); + cx.simulate_mouse_move( + row + gpui::point(px(9.), px(0.)), + Some(gpui::MouseButton::Left), + gpui::Modifiers::default(), + ); + cx.update(|_, cx| assert!(cx.has_active_drag())); + cx.simulate_keystrokes("escape"); + } + #[test] + fn autoscroll_is_bounded_and_stops_outside_the_viewport() { + assert_eq!(edge_scroll_speed(100., 100., 500.), -540.); + assert_eq!(edge_scroll_speed(500., 100., 500.), 540.); + assert_eq!(edge_scroll_speed(300., 100., 500.), 0.); + assert_eq!(edge_scroll_speed(99., 100., 500.), 0.); + assert!(edge_scroll_speed(486., 100., 500.) > 0.); + } + #[test] + fn tree_drop_resolves_parent_root_and_descendants() { + assert_eq!( + destination_path("a/file", "b", false).as_deref(), + Some("b/file") + ); + assert_eq!( + destination_path("a/file", "", false).as_deref(), + Some("file") + ); + assert_eq!(destination_path("a/file", "a", false), None); + assert_eq!(destination_path("a", "a/sub", true), None); + assert_eq!(destination_path("a", "a", true), None); + assert_eq!(destination_path("a", "ab", true).as_deref(), Some("ab/a")); + } +} diff --git a/crates/ui/src/files/image_preview.rs b/crates/ui/src/files/image_preview.rs index d85ebf1c02..88d23e9dfb 100644 --- a/crates/ui/src/files/image_preview.rs +++ b/crates/ui/src/files/image_preview.rs @@ -205,6 +205,7 @@ impl Render for ImagePreview { display.image, gpui::size(px(source.width), px(source.height)), None, + None, window, cx, )); diff --git a/crates/ui/src/files/markdown_preview.rs b/crates/ui/src/files/markdown_preview.rs index 51fc1975d3..dfe7a7ab51 100644 --- a/crates/ui/src/files/markdown_preview.rs +++ b/crates/ui/src/files/markdown_preview.rs @@ -239,14 +239,29 @@ impl MarkdownPreview { 480.0, ); let mut retired = Vec::new(); + // Admission accounts current rasters, so a sharper one must still fit + // the document budget. + let mut used: usize = self + .images + .values() + .chain(self.diagrams.values()) + .filter_map(|m| m.as_ref().ok()) + .map(|m| m.bytes) + .sum(); for media in self .images .values_mut() .chain(self.diagrams.values_mut()) .filter_map(|m| m.as_mut().ok()) { - let next = media.preview_for_view(target, window.scale_factor()); + let others = used - media.bytes; + let next = media.preview_within( + target, + window.scale_factor(), + MAX_MEDIA_BYTES.saturating_sub(others), + ); if !Arc::ptr_eq(&media.image, &next.image) { + used = others + next.bytes; retired.push(std::mem::replace(media, next)); self.media_dirty = true; } @@ -966,38 +981,22 @@ impl MarkdownPreview { loaded: &crate::image_media::MediaImage, id: gpui::SharedString, name: String, + plate: Option, weak: gpui::WeakEntity, ) -> AnyElement { - use gpui::StyledImage as _; - let preview = crate::attachments::PreviewImage::new(name, loaded.image.clone()); + let mut preview = crate::attachments::PreviewImage::new(name, loaded.image.clone()); + preview.plate = plate; let source = loaded.clone(); - div() - .id(id) - .w_full() - .max_w(px(loaded.width)) - .mx_auto() - .max_h(px(480.0)) - .aspect_ratio(loaded.width / loaded.height) - .cursor_pointer() - .role(gpui::Role::Button) - .aria_label("Enlarge image") - .on_click(move |_, window, cx| { - cx.stop_propagation(); - let _ = weak.update(cx, |view, cx| { - view.close_media_preview(cx); - view.zoom_source = Some(source.clone()); - preview.viewer.reset(); - view.preview_image = Some(preview.clone()); - window.focus(&view.preview_focus, cx); - cx.notify(); - }); - }) - .child( - gpui::img(loaded.image.clone()) - .size_full() - .object_fit(gpui::ObjectFit::Contain), - ) - .into_any_element() + crate::image_media::preview_element(loaded, id, move |window, cx| { + let _ = weak.update(cx, |view, cx| { + view.close_media_preview(cx); + view.zoom_source = Some(source.clone()); + preview.viewer.reset(); + view.preview_image = Some(preview.clone()); + window.focus(&view.preview_focus, cx); + cx.notify(); + }); + }) } #[cfg(test)] @@ -1129,6 +1128,7 @@ impl MarkdownPreview { loaded, format!("{id}-image").into(), "Mermaid diagram".into(), + Some(crate::markdown::mermaid::Palette::plate(theme)), diagram_owner.clone(), ), Some(Err(error)) => div() @@ -1147,7 +1147,7 @@ impl MarkdownPreview { }) .into_any_element(), }; - render::DiagramUi { + render::DiagramView::Diagram(render::DiagramUi { body, show_source: source_shown, toggle_source: Rc::new(move |_, cx| { @@ -1159,16 +1159,13 @@ impl MarkdownPreview { cx.notify(); }); }), - } + }) })), - image: Rc::new(move |image, id, theme| match images.get(&image.source) { - Some(Ok(loaded)) => { - let mut el = - div() - .flex() - .flex_col() - .gap(px(4.0)) - .child(Self::media_element( + image: Some(Rc::new(move |image, id, theme| { + match images.get(&image.source) { + Some(Ok(loaded)) => { + let mut el = div().flex().flex_col().gap(px(4.0)).child( + Self::media_element( loaded, id, if image.alt.is_empty() { @@ -1176,12 +1173,17 @@ impl MarkdownPreview { } else { image.alt.clone() }, + None, image_owner.clone(), - )); - if let Some(target) = image.link.clone() { - let link = image_link.clone(); - el = el.child( - super::toolbar_button("markdown-image-link", "Open image link") + ), + ); + if let Some(target) = image.link.clone() { + let link = image_link.clone(); + el = el.child( + super::toolbar_button( + "markdown-image-link", + "Open image link", + ) .on_click(move |_, window, cx| { render::activate_link( render::LinkTarget::new(&target, &target), @@ -1196,51 +1198,52 @@ impl MarkdownPreview { .size(px(crate::surface_chrome::ICON_SIZE)) .text_color(theme.text_muted), ), - ); + ); + } + el.into_any_element() } - el.into_any_element() - } - state => { - let text = if image.source.starts_with("https://") - || image.source.starts_with("http://") - { - format!("{} — {}", image.alt, image.source) - } else { - format!( - "{} — {}", - image.alt, - state - .and_then(|s| s.as_ref().err()) - .map(String::as_str) - .unwrap_or(if image_allowed.contains(&image.source) { - "Loading image…" - } else { - "Document image preview limit reached" + state => { + let text = if image.source.starts_with("https://") + || image.source.starts_with("http://") + { + format!("{} — {}", image.alt, image.source) + } else { + format!( + "{} — {}", + image.alt, + state + .and_then(|s| s.as_ref().err()) + .map(String::as_str) + .unwrap_or(if image_allowed.contains(&image.source) { + "Loading image…" + } else { + "Document image preview limit reached" + }) + ) + }; + let target = image.source.clone(); + let external = + target.starts_with("https://") || target.starts_with("http://"); + div() + .id(id) + .text_color(theme.text_muted) + .child(text) + .when(external, |el| { + let link = image_link.clone(); + el.cursor_pointer().on_click(move |_, window, cx| { + render::activate_link( + render::LinkTarget::new(&target, &target), + render::LinkAction::Primary, + Some(&link), + window, + cx, + ); }) - ) - }; - let target = image.source.clone(); - let external = - target.starts_with("https://") || target.starts_with("http://"); - div() - .id(id) - .text_color(theme.text_muted) - .child(text) - .when(external, |el| { - let link = image_link.clone(); - el.cursor_pointer().on_click(move |_, window, cx| { - render::activate_link( - render::LinkTarget::new(&target, &target), - render::LinkAction::Primary, - Some(&link), - window, - cx, - ); }) - }) - .into_any_element() + .into_any_element() + } } - }), + })), }); opts.link = Some(link.clone()); opts.copy = Some(self.copy_ui_for(&opts.row_key, cx)); diff --git a/crates/ui/src/files/mod.rs b/crates/ui/src/files/mod.rs index 08d73af527..0a69668a12 100644 --- a/crates/ui/src/files/mod.rs +++ b/crates/ui/src/files/mod.rs @@ -18,7 +18,9 @@ use crate::{ }; pub mod client; +mod context_menu; pub mod document; +mod drag; pub mod editor; pub mod editor_adapter; mod git_status; @@ -26,9 +28,14 @@ mod image_preview; pub(crate) mod markdown_media; mod markdown_preview; pub mod model; +pub mod mutations; pub mod preview; +mod rename; pub mod search; mod sections; +mod subagents; +#[cfg(test)] +pub(crate) mod test_support; pub mod tree; pub mod watch; @@ -80,13 +87,41 @@ pub(super) fn toolbar_button(id: &'static str, label: &'static str) -> gpui::Sta /// that workspace instead of leaking a path from the UI machine. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct WorkspacePathDrag { + pub origin: Option, + pub source: WorkspacePathSource, + pub revision: Option, pub path: String, pub is_directory: bool, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum WorkspacePathSource { + Tree, + Search, + FileTab, +} + impl WorkspacePathDrag { pub(crate) fn new(path: String, is_directory: bool) -> Self { - Self { path, is_directory } + Self { + path, + is_directory, + origin: None, + source: WorkspacePathSource::Search, + revision: None, + } + } + + pub(crate) fn with_origin( + mut self, + origin: Option, + source: WorkspacePathSource, + revision: Option, + ) -> Self { + self.origin = origin; + self.source = source; + self.revision = revision; + self } fn title(&self) -> SharedString { @@ -147,6 +182,16 @@ pub(crate) fn workspace_path_drag_ghost( #[derive(Debug, Clone, PartialEq)] pub enum FilesEvent { + AddToChat { + path: String, + is_directory: bool, + origin: mutations::WorkspaceInteractionOrigin, + }, + HoldMutation { + origin: mutations::WorkspaceInteractionOrigin, + path: Option, + }, + Mutate(mutations::MutationIntent), OpenFile(String), RevealFile(String), OpenWebLink(crate::markdown::render::LinkActivation), @@ -167,6 +212,8 @@ pub enum FilesEvent { }, /// A footer row: open this side chat (by id) in the right pane. OpenChildChat(String), + /// A footer row's double-click: edit this side chat's title in place. + RenameChildChat(String), ChildChatContextMenu { chat_id: String, position: Point, @@ -205,6 +252,18 @@ struct EditorContextMenu { } pub struct FilesSurface { + surface_id: gpui::EntityId, + interaction_generation: u64, + effective_checkout_id: Option, + mutation_capabilities: Option, + pending_mutation: Option, + deferred_file_changes: Vec, + applied_mutations: std::collections::VecDeque, + mutation_error: Option, + mutation_hold: Option, + tree_drag: drag::TreeDrag, + tree_rename: Option, + tree_delete: Option, state: Entity, chat_id: String, review_comment_flush_source: u64, @@ -231,6 +290,7 @@ pub struct FilesSurface { watch_sequence: Option, watch_error: Option, preview: FilePreviewState, + tree_context_menu: crate::popover::Popup, pending_line_navigation: Option<(u32, Option)>, line_navigation_generation: u64, editor_context_menu: crate::popover::Popup, @@ -245,6 +305,9 @@ pub struct FilesSurface { impl Render for FilesSurface { fn render(&mut self, window: &mut gpui::Window, cx: &mut Context) -> impl IntoElement { + if !cx.has_active_drag() || !self.is_current_target(cx) { + self.clear_tree_drag(window, cx); + } if std::mem::take(&mut self.search_restore_tree_focus) { self.tree_focus.focus(window, cx); } @@ -281,8 +344,15 @@ impl Render for FilesSurface { .flex_col() .children(header) .child(div().flex_1().min_h_0().w_full().child(body)) + .children( + self.mutation_error + .as_ref() + .map(|message| crate::popover::error_row(&theme, message).into_any_element()), + ) .children(sections) .children(editor_context_menu) + .children(self.render_tree_context_menu(&theme, cx)) + .children(self.render_tree_delete(&theme, window, cx)) } } @@ -539,6 +609,7 @@ impl FilesSurface { ComposerInputEvent::PastedImages(_) | ComposerInputEvent::PastedPaths(_) | ComposerInputEvent::PastedText { .. } + | ComposerInputEvent::OpenAttachment(_) | ComposerInputEvent::CursorMoved | ComposerInputEvent::ViewportChanged => {} }); @@ -562,6 +633,18 @@ impl FilesSurface { .ok(); }); let mut surface = Self { + surface_id: cx.entity_id(), + interaction_generation: 0, + effective_checkout_id: None, + mutation_capabilities: None, + pending_mutation: None, + deferred_file_changes: Vec::new(), + applied_mutations: std::collections::VecDeque::new(), + mutation_error: None, + mutation_hold: None, + tree_drag: drag::TreeDrag::default(), + tree_rename: None, + tree_delete: None, state, chat_id, review_comment_flush_source: NEXT_REVIEW_COMMENT_FLUSH_SOURCE @@ -593,6 +676,7 @@ impl FilesSurface { word_wrap, editor_font_size, ), + tree_context_menu: crate::popover::Popup::default(), pending_line_navigation: None, line_navigation_generation: 0, editor_context_menu: crate::popover::Popup::default(), @@ -957,6 +1041,8 @@ impl FilesSurface { let reload = surface.tree.node(&directory).is_some_and(|node| node.stale); match result { Ok(page) => { + surface.effective_checkout_id = page.checkout_id.clone(); + surface.mutation_capabilities = page.mutation_capabilities; surface.error = None; surface.tree.apply_page(page, generation); if directory.is_empty() { @@ -1027,6 +1113,22 @@ impl FilesSurface { } fn apply_target(&mut self, next: Option, cx: &mut Context) { + if let Some(dialog) = self.tree_delete.take() { + cx.emit(FilesEvent::HoldMutation { + origin: dialog.origin, + path: None, + }); + } + self.tree_rename = None; + self.tree_drag = drag::TreeDrag::default(); + self.mutation_hold = None; + self.interaction_generation = self.interaction_generation.wrapping_add(1); + self.effective_checkout_id = None; + self.mutation_capabilities = None; + self.pending_mutation = None; + self.deferred_file_changes.clear(); + self.applied_mutations.clear(); + self.mutation_error = None; self.release_git_status(); self.suspend_images(cx); self.cancel_review_comment_flush(cx); @@ -1034,6 +1136,7 @@ impl FilesSurface { self.watch_task = None; self.watch_sequence = None; self.watch_error = None; + self.tree_context_menu = crate::popover::Popup::default(); self.editor_context_menu = crate::popover::Popup::default(); self.preview.reset(); self.pending_line_navigation = None; diff --git a/crates/ui/src/files/model.rs b/crates/ui/src/files/model.rs index ca30729f54..609fb09224 100644 --- a/crates/ui/src/files/model.rs +++ b/crates/ui/src/files/model.rs @@ -383,6 +383,92 @@ impl FileTreeModel { } } + /// Re-key a confirmed move without throwing away expansion or selection. + pub fn relocate_subtree( + &mut self, + old: &str, + new: &str, + entry: Option, + ) -> bool { + if old.is_empty() || old == new || is_descendant(new, old) { + return false; + } + let remap = |path: &str| -> String { + if path == old { + new.to_string() + } else if is_descendant(path, old) { + format!("{new}{}", &path[old.len()..]) + } else { + path.to_string() + } + }; + if !self.nodes.contains_key(old) { + return false; + } + let old_parent = parent_path(old).unwrap(); + let new_parent = parent_path(new).unwrap(); + if let Some(parent) = self.nodes.get_mut(&old_parent) { + parent.children.retain(|p| p != old); + } + let nodes = std::mem::take(&mut self.nodes); + for (path, mut node) in nodes { + let next = remap(&path); + node.entry.path = next.clone(); + if path == old { + node.entry.name = new.rsplit('/').next().unwrap_or(new).into(); + } + node.children = node.children.iter().map(|p| remap(p)).collect(); + if is_descendant(&path, old) || path == old { + node.stale = true; + if node.entry.kind == WorkspaceEntryKind::Directory { + node.load = DirectoryLoadState::Unloaded; + } + } + self.nodes.insert(next, node); + } + if let Some(entry) = entry { + if let Some(node) = self.nodes.get_mut(new) { + node.entry = entry; + } + } + self.expanded = self.expanded.iter().map(|p| remap(p)).collect(); + self.selected = self.selected.as_deref().map(remap); + let mut ancestor = Some(new_parent.clone()); + while let Some(path) = ancestor { + self.expanded.insert(path.clone()); + ancestor = parent_path(&path); + } + self.listing_children.clear(); + if let Some(parent) = self.nodes.get_mut(&new_parent) { + if !parent.children.iter().any(|p| p == new) { + parent.children.push(new.into()); + } + } + for parent in [&old_parent, &new_parent] { + if let Some(node) = self.nodes.get_mut(parent) { + node.stale = true; + node.load = DirectoryLoadState::Unloaded; + let mut children = std::mem::take(&mut node.children); + children.sort_by(|a, b| compare_paths(&self.nodes, a, b)); + self.nodes.get_mut(parent).unwrap().children = children; + } + } + self.rebuild_visible_rows(); + true + } + + /// Invalidate asynchronous listings without resetting the visible tree. + pub fn invalidate_loads(&mut self) { + self.generation = self.generation.wrapping_add(1); + self.listing_children.clear(); + for node in self.nodes.values_mut() { + if matches!(node.load, DirectoryLoadState::Loading { .. }) { + node.load = DirectoryLoadState::Unloaded; + node.stale = true; + } + } + } + pub fn remove(&mut self, path: &str) -> bool { if path.is_empty() || !self.nodes.contains_key(path) { return false; @@ -556,6 +642,7 @@ impl FileTreeModel { fn root_entry() -> WorkspaceEntry { WorkspaceEntry { + mutation_revision: None, path: String::new(), name: String::new(), kind: WorkspaceEntryKind::Directory, @@ -614,6 +701,7 @@ mod tests { fn entry(path: &str, kind: WorkspaceEntryKind) -> WorkspaceEntry { WorkspaceEntry { + mutation_revision: None, path: path.into(), name: path.rsplit('/').next().unwrap_or(path).into(), kind, @@ -630,6 +718,8 @@ mod tests { next_cursor: Option<&str>, ) -> WorkspaceDirectoryPage { WorkspaceDirectoryPage { + checkout_id: None, + mutation_capabilities: None, directory: directory.into(), entries, next_cursor: next_cursor.map(str::to_string), @@ -637,6 +727,36 @@ mod tests { } } + #[test] + fn relocation_keeps_descendants_selection_and_expansion() { + let mut tree = FileTreeModel::new(); + tree.begin_load("", None, tree.generation()); + tree.apply_page( + page( + "", + vec![ + entry("a", WorkspaceEntryKind::Directory), + entry("b", WorkspaceEntryKind::Directory), + ], + None, + ), + tree.generation(), + ); + tree.expand("a"); + tree.begin_load("a", None, tree.generation()); + tree.apply_page( + page("a", vec![entry("a/child", WorkspaceEntryKind::File)], None), + tree.generation(), + ); + tree.select("a/child"); + assert!(tree.relocate_subtree("a", "b/a", None)); + assert!(tree.is_expanded("b/a")); + assert_eq!(tree.selected(), Some("b/a/child")); + assert!(tree.node("a").is_none()); + assert!(tree.node("b/a/child").is_some()); + assert!(!tree.relocate_subtree("a", "b/a", None)); + } + #[test] fn background_refresh_keeps_loaded_rows_even_when_empty_or_failed() { let mut tree = FileTreeModel::new(); diff --git a/crates/ui/src/files/mutations.rs b/crates/ui/src/files/mutations.rs new file mode 100644 index 0000000000..04d16575d3 --- /dev/null +++ b/crates/ui/src/files/mutations.rs @@ -0,0 +1,445 @@ +//! Shared identity and reconciliation for tree mutations and workspace references. +use super::*; +use zeron_proto::{WorkspaceEntry, WorkspaceEntryKind, WorkspaceMutationOutcome}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WorkspaceInteractionOrigin { + pub context: FilesRequestContext, + pub surface_id: gpui::EntityId, + pub generation: u64, + pub checkout_id: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MutationIntent { + pub operation_id: String, + pub origin: WorkspaceInteractionOrigin, + pub entry: WorkspaceEntry, + /// None deletes; Some moves without replacement (including rename). + pub destination: Option, +} + +pub(crate) fn contains_path(ancestor: &str, path: &str) -> bool { + path == ancestor + || path + .strip_prefix(ancestor) + .is_some_and(|suffix| suffix.starts_with('/')) +} + +impl MutationIntent { + pub(crate) fn affects(&self, path: &str) -> bool { + contains_path(&self.entry.path, path) + || self + .destination + .as_deref() + .is_some_and(|to| contains_path(to, path)) + } +} + +impl FilesSurface { + pub(crate) fn interaction_origin(&self, cx: &gpui::App) -> Option { + self.is_current_target(cx) + .then(|| WorkspaceInteractionOrigin { + context: self.request_context.clone().unwrap(), + surface_id: self.surface_id, + generation: self.interaction_generation, + checkout_id: self.effective_checkout_id.clone().or_else(|| { + self.request_context + .as_ref() + .and_then(|c| c.checkout_id.clone()) + }), + }) + } + + pub(crate) fn accepts_origin( + &self, + origin: &WorkspaceInteractionOrigin, + cx: &gpui::App, + ) -> bool { + self.is_current_target(cx) + && self.surface_id == origin.surface_id + && self.interaction_generation == origin.generation + && self.request_context.as_ref() == Some(&origin.context) + } + + pub(crate) fn shares_workspace(&self, origin: &WorkspaceInteractionOrigin) -> bool { + let Some(context) = &self.request_context else { + return false; + }; + if context.target_device_id != origin.context.target_device_id { + return false; + } + let checkout = self + .effective_checkout_id + .as_ref() + .or(context.checkout_id.as_ref()); + match (checkout, origin.checkout_id.as_ref()) { + (Some(a), Some(b)) => a == b, + _ => context.cwd == origin.context.cwd, + } + } + + pub(crate) fn mutation_busy(&self) -> bool { + self.pending_mutation.is_some() + } + + pub(crate) fn prepare_mutation(&mut self, intent: MutationIntent, cx: &mut Context) { + self.pending_mutation = Some(intent); + for (path, document) in &mut self.preview.documents { + if self.pending_mutation.as_ref().unwrap().affects(path) { + document.autosave_task = None; + } + } + self.mutation_error = None; + cx.notify(); + } + + pub(crate) fn mutation_has_save(&self) -> bool { + self.pending_mutation.as_ref().is_some_and(|intent| { + self.preview + .documents + .iter() + .any(|(path, doc)| intent.affects(path) && doc.pending_save.is_some()) + }) + } + + pub(crate) fn hold_mutation(&mut self, path: Option, cx: &mut Context) { + self.mutation_hold = path; + let paths = self.preview.documents.keys().cloned().collect::>(); + for path in paths { + if self.mutation_blocks_path(&path) { + self.preview.documents.get_mut(&path).unwrap().autosave_task = None; + } else { + self.schedule_autosave(path, cx); + } + } + } + pub(super) fn mutation_blocks_path(&self, path: &str) -> bool { + if self + .mutation_hold + .as_deref() + .is_some_and(|hold| contains_path(hold, path)) + { + return true; + } + self.pending_mutation + .as_ref() + .is_some_and(|intent| intent.affects(path)) + } + + pub(crate) fn report_mutation_error(&mut self, message: String, cx: &mut Context) { + self.mutation_error = Some(message.into()); + if let Some(rename) = self.tree_rename.as_mut() { + rename.submitted = false; + } + cx.notify(); + } + + pub(super) fn request_mutation( + &mut self, + path: &str, + destination: Option, + cx: &mut Context, + ) { + if self.pending_mutation.is_some() { + return; + } + let Some(origin) = self.interaction_origin(cx) else { + return; + }; + let Some(entry) = self.tree.node(path).map(|n| n.entry.clone()) else { + return; + }; + let available = self.mutation_capabilities.is_some_and(|c| { + if destination.is_some() { + c.move_entry + } else { + c.delete_entry + } + }); + if !available + || origin.checkout_id.is_none() + || entry.mutation_revision.is_none() + || entry.kind == WorkspaceEntryKind::Symlink + { + return; + } + cx.emit(FilesEvent::Mutate(MutationIntent { + operation_id: uuid::Uuid::new_v4().to_string(), + origin, + entry, + destination, + })); + } + + pub(crate) fn finish_mutation( + &mut self, + intent: &MutationIntent, + result: &Result, + cx: &mut Context, + ) { + if self.pending_mutation.as_ref().map(|p| &p.operation_id) != Some(&intent.operation_id) { + return; + } + // A semantic watch event is authoritative even if the RPC reply was lost. + let observed = self + .deferred_file_changes + .iter() + .flat_map(|frame| &frame.changes) + .find(|change| change.operation_id.as_deref() == Some(&intent.operation_id)) + .cloned(); + let observed_applied = observed.is_some(); + if let Some(change) = observed { + self.apply_semantic_mutation(&change, None, true, cx); + } + match result { + Ok(WorkspaceMutationOutcome::Applied { + checkout_id, + change, + entry, + .. + }) if intent.origin.checkout_id.as_ref() == Some(checkout_id) => { + self.apply_semantic_mutation(change, entry.clone(), true, cx); + } + Ok(WorkspaceMutationOutcome::Rejected { message, .. }) | Err(message) => { + if !observed_applied { + self.mutation_error = Some(message.clone().into()); + } + } + _ => self.mutation_error = Some("Workspace changed before operation completed".into()), + } + if observed_applied || matches!(result, Ok(WorkspaceMutationOutcome::Applied { .. })) { + self.tree_rename = None; + } else if let Some(rename) = &mut self.tree_rename { + rename.submitted = false; + } + self.pending_mutation = None; + let frames = std::mem::take(&mut self.deferred_file_changes); + // Queued events may contain duplicate native remove/create pairs. After + // applying the semantic transition, reconcile rather than destroying state. + drop(frames); + self.loads.clear(); + self.tree.invalidate_loads(); + self.tree_list_generation = self.tree.generation(); + self.refresh(cx); + self.reconcile_open_documents(cx); + let paths = self.preview.documents.keys().cloned().collect::>(); + for path in paths { + self.schedule_autosave(path, cx); + } + if !self.search_state.query.is_empty() { + self.search_state.query.clear(); + self.on_search_edited(cx); + } + cx.notify(); + } + + pub(super) fn apply_semantic_mutation( + &mut self, + change: &zeron_proto::WorkspaceFileChange, + entry: Option, + local: bool, + cx: &mut Context, + ) { + if let Some(id) = &change.operation_id { + if self.applied_mutations.contains(id) { + return; + } + self.applied_mutations.push_back(id.clone()); + if self.applied_mutations.len() > 128 { + self.applied_mutations.pop_front(); + } + } + self.loads.clear(); + self.tree.invalidate_loads(); + match change.kind { + zeron_proto::WorkspaceFileChangeKind::Renamed => { + if let Some(old) = &change.old_path { + self.tree.relocate_subtree(old, &change.path, entry); + let phases = if local { + self.preview + .documents + .iter() + .filter(|(p, _)| contains_path(old, p)) + .map(|(p, d)| { + ( + format!("{}{}", change.path, &p[old.len()..]), + d.phase.clone(), + ) + }) + .collect::>() + } else { + Vec::new() + }; + for (old_path, new_path) in self.rename_documents(old, change.path.clone(), cx) + { + cx.emit(FilesEvent::FileRenamed { old_path, new_path }); + } + for (path, phase) in phases { + if let Some(doc) = self.preview.documents.get_mut(&path) { + doc.phase = phase; + } + } + if !self.presentation.is_editor() { + self.tree.select(change.path.clone()); + } + } + } + zeron_proto::WorkspaceFileChangeKind::Removed => { + let rows = self.tree.visible_rows().to_vec(); + let index = rows.iter().position(|r| r.path == change.path); + let next = index + .and_then(|i| { + rows[i + 1..] + .iter() + .chain(rows[..i].iter().rev()) + .find(|r| { + matches!(r.kind, model::VisibleRowKind::Entry) + && !contains_path(&change.path, &r.path) + }) + }) + .map(|r| r.path.clone()); + self.tree.remove(&change.path); + self.mark_document_deleted(&change.path, cx); + if let Some(path) = next { + self.tree.select(path); + } + } + _ => {} + } + self.invalidate_markdown_images(None, cx); + self.tree_list_generation = self.tree.generation(); + self.sync_tree_list(); + self.reveal_tree_selection(); + cx.notify(); + } +} + +#[cfg(test)] +mod tests { + use super::super::document::{DocumentKey, DocumentPhase, FileDocument}; + use super::*; + use gpui::{AppContext, TestAppContext}; + + #[gpui::test] + fn watch_before_lost_reply_still_remaps_the_dirty_buffer(cx: &mut TestAppContext) { + let (files, cx) = super::super::test_support::setup(cx); + files.update_in(cx, |files, _, cx| { + let origin = files.interaction_origin(cx).unwrap(); + let intent = MutationIntent { + operation_id: "observed".into(), + origin, + entry: files.tree.node("a.txt").unwrap().entry.clone(), + destination: Some("b.txt".into()), + }; + let mut document = FileDocument::loading(DocumentKey { + chat_id: "chat".into(), + checkout_id: Some("checkout".into()), + path: "a.txt".into(), + }); + document.phase = DocumentPhase::Ready; + document.revision = 4; + document.saved_revision = 1; + files.preview.documents.insert("a.txt".into(), document); + files.prepare_mutation(intent.clone(), cx); + let change = zeron_proto::WorkspaceFileChange { + operation_id: Some("observed".into()), + kind: zeron_proto::WorkspaceFileChangeKind::Renamed, + path: "b.txt".into(), + old_path: Some("a.txt".into()), + }; + files.apply_workspace_changes( + zeron_proto::WorkspaceFileChanges { + sequence: 2, + resync_required: false, + changes: vec![change], + }, + cx, + ); + assert!(files.preview.documents.contains_key("a.txt")); + files.apply_workspace_changes( + zeron_proto::WorkspaceFileChanges { + sequence: 3, + resync_required: false, + changes: vec![], + }, + cx, + ); + files.finish_mutation(&intent, &Err("reply lost".into()), cx); + assert_eq!(files.watch_sequence, Some(3)); + assert!(files.preview.documents["b.txt"].is_dirty()); + assert_eq!(files.preview.documents["b.txt"].phase, DocumentPhase::Ready); + assert!(files.mutation_error.is_none()); + assert!(!files.mutation_busy()); + }); + } + #[test] + fn path_membership_uses_component_boundaries() { + assert!(contains_path("a", "a/child")); + assert!(contains_path("a", "a")); + assert!(!contains_path("a", "ab/child")); + } + + #[gpui::test] + fn confirmed_move_preserves_dirty_editor_and_duplicate_events_are_idempotent( + cx: &mut TestAppContext, + ) { + cx.update(|cx| { + gpui_base::init(cx); + cx.set_global(crate::theme::Theme::dark()); + }); + let window = cx.add_window(|_, cx| { + let state = cx.new(|_| AppState::new()); + FilesSurface::new_explorer(state, "chat".into(), false, cx) + }); + window + .update(cx, |files, window, cx| { + let theme = crate::theme::Theme::of(cx).clone(); + let editor = super::super::editor::new_file_editor( + "unsaved text", + "folder/a.txt", + false, + &theme, + window, + cx, + ); + let mut doc = FileDocument::loading(DocumentKey { + chat_id: "chat".into(), + checkout_id: Some("checkout".into()), + path: "folder/a.txt".into(), + }); + doc.editor = Some(editor.clone()); + doc.phase = DocumentPhase::Ready; + doc.revision = 3; + doc.saved_revision = 1; + files.preview.documents.insert("folder/a.txt".into(), doc); + let change = zeron_proto::WorkspaceFileChange { + operation_id: Some("op".into()), + kind: zeron_proto::WorkspaceFileChangeKind::Renamed, + path: "other".into(), + old_path: Some("folder".into()), + }; + files.apply_semantic_mutation(&change, None, true, cx); + files.apply_semantic_mutation(&change, None, true, cx); + let doc = &files.preview.documents["other/a.txt"]; + assert_eq!(doc.editor.as_ref(), Some(&editor)); + assert_eq!(editor.read(cx).value().as_ref(), "unsaved text"); + assert!(doc.is_dirty()); + assert_eq!(doc.phase, DocumentPhase::Ready); + assert!(!files.preview.documents.contains_key("folder/a.txt")); + let removed = zeron_proto::WorkspaceFileChange { + operation_id: Some("delete".into()), + kind: zeron_proto::WorkspaceFileChangeKind::Removed, + path: "other".into(), + old_path: None, + }; + files.apply_semantic_mutation(&removed, None, true, cx); + assert_eq!( + files.preview.documents["other/a.txt"].phase, + DocumentPhase::DeletedOnDisk + ); + assert_eq!(editor.read(cx).value().as_ref(), "unsaved text"); + }) + .unwrap(); + } +} diff --git a/crates/ui/src/files/preview.rs b/crates/ui/src/files/preview.rs index 1538988207..a151ee1c01 100644 --- a/crates/ui/src/files/preview.rs +++ b/crates/ui/src/files/preview.rs @@ -97,7 +97,7 @@ enum ReloadDecision { pub(super) struct FilePreviewState { images_visible: bool, - documents: HashMap, + pub(super) documents: HashMap, document_recency: VecDeque, active: Option, highlights: HashMap, @@ -1483,7 +1483,10 @@ impl FilesSurface { } pub(super) fn schedule_autosave(&mut self, path: String, cx: &mut Context) { - if !self.preview.autosave_enabled || self.preview.autosave_paused_for_reload(&path) { + if self.mutation_blocks_path(&path) + || !self.preview.autosave_enabled + || self.preview.autosave_paused_for_reload(&path) + { return; } let delay = Duration::from_millis(self.preview.autosave_delay_ms); @@ -1522,7 +1525,7 @@ impl FilesSurface { } pub(super) fn save_document(&mut self, path: String, cx: &mut Context) { - if self.target_change_pending { + if self.mutation_blocks_path(&path) || self.target_change_pending { return; } let Some(context) = self.request_context.clone() else { @@ -1911,10 +1914,24 @@ impl FilesSurface { document.reconcile_task = None; document.pending_save = None; document.key.path = new_document_path.clone(); + let was_markdown = super::markdown_preview::is_markdown(old_document_path); + let is_markdown = super::markdown_preview::is_markdown(new_document_path); + if was_markdown != is_markdown { + document.show_markdown = is_markdown; + if let Some(view) = document.markdown.take() { + view.update(cx, |view, cx| view.suspend(cx)); + } + } if let Some(file) = document.file.as_mut() { file.path = new_document_path.clone(); } if let Some(editor) = document.editor.clone() { + // Retarget the existing editor so selection and undo survive. + // Its previous adapter captures parsed spans for the old language. + editor.update(cx, |editor, cx| { + editor.set_highlighter(new_document_path.clone(), cx); + editor.set_highlighter_factory(Rc::new(|_| None), cx); + }); let event_path = new_document_path.clone(); document.editor_events = Some(super::editor::subscribe_to_changes(&editor, event_path, cx)); @@ -1928,11 +1945,7 @@ impl FilesSurface { if self.editor_path.as_deref() == Some(old_document_path) { self.editor_path = Some(new_document_path.clone()); } - if let Some(highlight) = self.preview.highlights.remove(old_document_path) { - self.preview - .highlights - .insert(new_document_path.clone(), highlight); - } + self.preview.highlights.remove(old_document_path); if let Some(anchors) = self.preview.comment_anchors.remove(old_document_path) { self.preview .comment_anchors @@ -1967,6 +1980,17 @@ impl FilesSurface { .is_some_and(FileDocument::is_dirty) { self.read_file(path.clone(), cx); + } else if let Some(document) = self.preview.documents.get(path) { + if let Some(editor) = &document.editor { + let source = editor.read(cx).value().to_string(); + self.request_editor_highlight(path.clone(), source, document.revision, cx); + } else if let Some((text, hash)) = document + .file + .as_ref() + .and_then(|file| file.text.clone().zip(file.content_hash.clone())) + { + self.request_file_highlight(path.clone(), text, hash, cx); + } } } if !renames.is_empty() { @@ -3336,6 +3360,142 @@ fn read_only_message(reason: Option) -> SharedString { #[cfg(test)] mod tests { use super::*; + use gpui::TestAppContext; + + #[gpui::test] + fn renaming_markdown_to_text_keeps_dirty_editor_and_undo(cx: &mut TestAppContext) { + let (files, cx) = super::super::test_support::setup(cx); + let editor = files.update_in(cx, |files, window, cx| { + let theme = Theme::of(cx).clone(); + let editor = super::super::editor::new_file_editor( + "# Original", + "note.md", + false, + &theme, + window, + cx, + ); + let mut document = cached_document("note.md", "# Original"); + document.set_loaded(zeron_proto::WorkspaceFileText { + checkout_id: "checkout".into(), + path: "note.md".into(), + text: Some("# Original".into()), + content_hash: Some("saved-hash".into()), + size: 10, + modified_at: None, + encoding: zeron_proto::WorkspaceTextEncoding::Utf8, + line_ending: Some(zeron_proto::WorkspaceLineEnding::Lf), + read_only_reason: None, + truncated: false, + }); + editor.update(cx, |editor, cx| { + editor.set_selected_range(10..10, cx); + editor.replace(" unsaved", window, cx); + editor.set_selected_range(2..10, cx); + }); + document.editor = Some(editor.clone()); + document.mark_user_edit(); + files.preview.documents.insert("note.md".into(), document); + files.preview.active = Some("note.md".into()); + files.editor_path = Some("note.md".into()); + files.presentation = super::super::FilesPresentation::Editor; + assert!( + files + .prepare_markdown_preview("note.md", Some(&editor), cx) + .is_some() + ); + files.apply_semantic_mutation( + &zeron_proto::WorkspaceFileChange { + operation_id: Some("rename-md".into()), + kind: zeron_proto::WorkspaceFileChangeKind::Renamed, + path: "note.txt".into(), + old_path: Some("note.md".into()), + }, + None, + true, + cx, + ); + let document = &files.preview.documents["note.txt"]; + assert!(!document.show_markdown); + assert!(document.markdown.is_none()); + assert!(document.is_dirty()); + assert_eq!(document.phase, DocumentPhase::Ready); + assert_eq!(document.editor.as_ref(), Some(&editor)); + assert_eq!(editor.read(cx).value().as_ref(), "# Original unsaved"); + assert_eq!(editor.read(cx).selected_range(), 2..10); + assert_eq!(document.saved_hash.as_deref(), Some("saved-hash")); + assert!( + files + .prepare_markdown_preview("note.txt", Some(&editor), cx) + .is_none() + ); + editor.update(cx, |editor, cx| editor.focus(window, cx)); + editor + }); + cx.run_until_parked(); + cx.update(|window, cx| { + window.draw(cx).clear(); + window.dispatch_action(Box::new(gpui_base::input::Undo), cx); + }); + cx.run_until_parked(); + assert_eq!( + editor.read_with(cx, |editor, _| editor.value().to_string()), + "# Original" + ); + } + + #[gpui::test] + fn rename_refreshes_language_and_preserves_markdown_view_choice(cx: &mut TestAppContext) { + let (files, cx) = super::super::test_support::setup(cx); + files.update_in(cx, |files, window, cx| { + let theme = Theme::of(cx).clone(); + let editor = super::super::editor::new_file_editor( + "let value = 1;", + "note.txt", + false, + &theme, + window, + cx, + ); + let mut document = cached_document("note.txt", "let value = 1;"); + document.editor = Some(editor); + files.preview.documents.insert("note.txt".into(), document); + files.rename_documents("note.txt", "note.rs".into(), cx); + }); + cx.run_until_parked(); + cx.executor().advance_clock(Duration::from_millis(120)); + cx.run_until_parked(); + files.update_in(cx, |files, _, cx| { + let key = DocumentHighlightKey::new( + zeron_syntax::language_for_path("note.rs").unwrap(), + "let value = 1;", + ); + assert!( + files.preview.syntax_cache.get(&key).is_some(), + "the open editor must be parsed in the new language without an edit" + ); + files.rename_documents("note.rs", "note.md".into(), cx); + assert!(files.preview.documents["note.md"].show_markdown); + files + .preview + .documents + .get_mut("note.md") + .unwrap() + .show_markdown = false; + files.rename_documents("note.md", "renamed.md".into(), cx); + assert!( + !files.preview.documents["renamed.md"].show_markdown, + "renaming within Markdown must preserve the user's code view choice" + ); + files.rename_documents("renamed.md", "renamed.txt".into(), cx); + assert!( + files.preview.documents["renamed.txt"] + .highlight_task + .is_none() + ); + assert!(!files.preview.highlights.contains_key("renamed.txt")); + }); + } /// A missing file gets its own wording — the transport's RPC framing /// never reaches the reader — while every other failure keeps its own. diff --git a/crates/ui/src/files/rename.rs b/crates/ui/src/files/rename.rs new file mode 100644 index 0000000000..df7c7b4daa --- /dev/null +++ b/crates/ui/src/files/rename.rs @@ -0,0 +1,544 @@ +//! Inline single-line rename and permanent-delete confirmation. +use super::*; +use crate::{popover, theme::Theme}; +use gpui::{AnyElement, Focusable, KeyDownEvent, MouseButton}; +use gpui_base::input::{Input, InputEvent, InputState}; + +pub(super) struct TreeRename { + pub path: String, + pub origin: mutations::WorkspaceInteractionOrigin, + pub input: Entity, + pub submitted: bool, + revision: Option, + _events: Subscription, +} +pub(super) struct TreeDelete { + pub path: String, + pub origin: mutations::WorkspaceInteractionOrigin, + pub focus: FocusHandle, + pub confirm_focused: bool, + revision: Option, +} + +pub(super) fn name_selection(name: &str, directory: bool) -> std::ops::Range { + let end = if directory { + name.len() + } else { + name.rfind('.').filter(|i| *i > 0).unwrap_or(name.len()) + }; + 0..end +} +pub(super) fn renamed_path(path: &str, name: &str) -> Result { + if name.is_empty() + || name == "." + || name == ".." + || name.eq_ignore_ascii_case(".git") + || name.contains(['/', '\\', '\0', ':']) + { + return Err("Enter a single file name without path separators"); + } + let parent = model::parent_path(path).unwrap_or_default(); + Ok(if parent.is_empty() { + name.into() + } else { + format!("{parent}/{name}") + }) +} +impl FilesSurface { + pub(super) fn can_mutate_tree_entry(&self, path: &str, deleting: bool, cx: &gpui::App) -> bool { + !self.mutation_busy() + && self.is_current_target(cx) + && self.effective_checkout_id.is_some() + && self.mutation_capabilities.is_some_and(|c| { + if deleting { + c.delete_entry + } else { + c.move_entry + } + }) + && self.tree.node(path).is_some_and(|n| { + n.entry.mutation_revision.is_some() + && n.entry.kind != zeron_proto::WorkspaceEntryKind::Symlink + }) + } + pub(super) fn begin_tree_rename( + &mut self, + path: String, + window: &mut Window, + cx: &mut Context, + ) { + if !self.can_mutate_tree_entry(&path, false, cx) { + return; + } + let Some(origin) = self.interaction_origin(cx) else { + return; + }; + let entry = self.tree.node(&path).unwrap().entry.clone(); + let input = cx.new(|cx| InputState::new(window, cx).default_value(entry.name.clone())); + input.update(cx, |state, cx| { + state.set_selected_range( + name_selection( + &entry.name, + entry.kind == zeron_proto::WorkspaceEntryKind::Directory, + ), + cx, + ) + }); + let events = cx.subscribe_in(&input, window, |this, _, event, window, cx| match event { + InputEvent::PressEnter { .. } => this.submit_tree_rename(window, cx), + InputEvent::Blur + if this + .tree_rename + .as_ref() + .is_some_and(|rename| !rename.submitted) => + { + this.tree_rename = None; + cx.notify(); + } + _ => {} + }); + let focus = input.focus_handle(cx); + self.tree.select(path.clone()); + self.reveal_tree_selection(); + self.mutation_error = None; + self.tree_rename = Some(TreeRename { + path, + origin, + input, + submitted: false, + revision: entry.mutation_revision, + _events: events, + }); + window.focus(&focus, cx); + cx.notify(); + } + fn submit_tree_rename(&mut self, window: &mut Window, cx: &mut Context) { + let Some(rename) = &self.tree_rename else { + return; + }; + if rename.submitted { + return; + } + if !self.accepts_origin(&rename.origin, cx) { + self.tree_rename = None; + cx.notify(); + return; + } + if self + .tree + .node(&rename.path) + .and_then(|node| node.entry.mutation_revision.as_ref()) + != rename.revision.as_ref() + { + self.report_mutation_error("Entry changed; cancel and reopen Rename".into(), cx); + return; + } + let path = rename.path.clone(); + let name = rename.input.read(cx).value().to_string(); + let destination = match renamed_path(&path, &name) { + Ok(path) => path, + Err(message) => { + self.report_mutation_error(message.into(), cx); + return; + } + }; + if path == destination { + self.tree_rename = None; + self.tree_focus.focus(window, cx); + cx.notify(); + return; + } + self.tree_rename.as_mut().unwrap().submitted = true; + self.request_mutation(&path, Some(destination), cx); + } + pub(super) fn render_tree_rename( + &self, + path: &str, + cx: &mut Context, + ) -> Option { + let rename = self + .tree_rename + .as_ref() + .filter(|rename| rename.path == path)?; + let theme = Theme::of(cx).clone(); + // gpui-base's Input is unstyled: explicitly supply the same visible + // caret and selection colors as the file editor, including theme changes. + rename.input.update(cx, |input, _| { + input.set_editor_style(super::editor_adapter::editor_style(&theme)); + }); + Some( + div() + .id("tree-rename-input") + .debug_selector(|| "tree-rename-input".into()) + .flex_1() + .min_w_0() + .h(px(23.)) + .px(px(4.)) + .flex() + .items_center() + .rounded(px(4.)) + .border_1() + .border_color(theme.accent) + .bg(theme.input_glass_bg()) + .font_family(theme.font_sans.clone()) + .text_size(px(11.5)) + .text_color(theme.text) + .cursor_text() + .occlude() + .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) + .on_click(|_, _, cx| cx.stop_propagation()) + .on_key_down(cx.listener(|this, event: &KeyDownEvent, window, cx| { + if event.keystroke.key == "escape" { + if this.tree_rename.as_ref().is_some_and(|r| !r.submitted) { + this.tree_rename = None; + this.tree_focus.focus(window, cx); + cx.notify(); + } + window.prevent_default(); + cx.stop_propagation(); + } + })) + .child(Input::new(&rename.input)) + .into_any_element(), + ) + } + pub(super) fn begin_tree_delete( + &mut self, + path: String, + window: &mut Window, + cx: &mut Context, + ) { + if !self.can_mutate_tree_entry(&path, true, cx) { + return; + } + let Some(origin) = self.interaction_origin(cx) else { + return; + }; + let focus = cx.focus_handle(); + focus.focus(window, cx); + cx.emit(FilesEvent::HoldMutation { + origin: origin.clone(), + path: Some(path.clone()), + }); + let revision = self + .tree + .node(&path) + .and_then(|node| node.entry.mutation_revision.clone()); + self.tree_delete = Some(TreeDelete { + path, + origin, + focus, + confirm_focused: false, + revision, + }); + cx.notify(); + } + pub(super) fn dismiss_tree_delete( + &mut self, + confirm: bool, + window: &mut Window, + cx: &mut Context, + ) { + let Some(dialog) = self.tree_delete.take() else { + return; + }; + cx.emit(FilesEvent::HoldMutation { + origin: dialog.origin.clone(), + path: None, + }); + if confirm && self.accepts_origin(&dialog.origin, cx) { + if self + .tree + .node(&dialog.path) + .and_then(|node| node.entry.mutation_revision.as_ref()) + != dialog.revision.as_ref() + { + self.report_mutation_error( + "Entry changed; reopen Delete to confirm its current contents".into(), + cx, + ); + } else { + self.request_mutation(&dialog.path, None, cx); + } + } + self.tree_focus.focus(window, cx); + cx.notify(); + } + pub(super) fn render_tree_delete( + &self, + theme: &Theme, + window: &Window, + cx: &mut Context, + ) -> Option { + let dialog = self.tree_delete.as_ref()?; + let theme = theme.for_popup(); + let directory = self + .tree + .node(&dialog.path) + .is_some_and(|n| n.entry.kind == zeron_proto::WorkspaceEntryKind::Directory); + let name = dialog.path.rsplit('/').next().unwrap_or(&dialog.path); + let copy = format!( + "Permanently delete {}? {}Open editor buffers will be kept for recovery.", + name, + if directory { + "All current folder contents will be deleted. " + } else { + "This cannot be undone. " + } + ); + let card = popover::dialog_card(&theme) + .w(px(380.)) + .gap(px(12.)) + .track_focus(&dialog.focus) + .on_key_down(cx.listener(|this, event: &KeyDownEvent, window, cx| { + match event.keystroke.key.as_str() { + "escape" => this.dismiss_tree_delete(false, window, cx), + "tab" | "left" | "right" => { + if let Some(dialog) = this.tree_delete.as_mut() { + dialog.confirm_focused = !dialog.confirm_focused; + cx.notify(); + } + } + "enter" => { + let confirm = this.tree_delete.as_ref().is_some_and(|d| d.confirm_focused); + this.dismiss_tree_delete(confirm, window, cx); + } + _ => {} + } + window.prevent_default(); + cx.stop_propagation(); + })) + .child(popover::dialog_title(&theme, "Delete permanently?")) + .child(popover::dialog_body(&theme, copy)) + .child( + div() + .flex() + .justify_end() + .gap(px(8.)) + .child( + popover::btn_ghost(&theme, "Cancel", "tree-delete-cancel") + .id("tree-delete-cancel") + .role(gpui::Role::Button) + .aria_label("Cancel") + .when(!dialog.confirm_focused, |el| { + el.bg(crate::theme::wash(0.12)) + }) + .on_click(cx.listener(|this, _, window, cx| { + this.dismiss_tree_delete(false, window, cx) + })), + ) + .child( + popover::btn_danger(&theme, "Delete") + .id("tree-delete-confirm") + .role(gpui::Role::Button) + .aria_label("Delete") + .when(dialog.confirm_focused, |el| { + el.border_1().border_color(theme.text) + }) + .on_click(cx.listener(|this, _, window, cx| { + this.dismiss_tree_delete(true, window, cx) + })), + ), + ); + Some(popover::modal( + "tree-delete-dialog", + window.viewport_size(), + card.into_any_element(), + )) + } +} +#[cfg(test)] +mod tests { + use super::*; + #[gpui::test] + fn rename_input_keeps_focus_and_supports_caret_editing(cx: &mut gpui::TestAppContext) { + let (files, cx) = super::super::test_support::setup(cx); + let events = std::rc::Rc::new(std::cell::RefCell::new(Vec::new())); + let recorded = events.clone(); + let _sub = cx.update(|_, cx| { + cx.subscribe(&files, move |_, event, _| { + recorded.borrow_mut().push(event.clone()) + }) + }); + files.update_in(cx, |files, window, cx| { + files.tree.select("a.txt"); + files.tree_focus.focus(window, cx); + }); + cx.simulate_keystrokes("f2"); + let input = files.read_with(cx, |files, _| { + files.tree_rename.as_ref().unwrap().input.clone() + }); + assert_eq!(input.read_with(cx, |input, _| input.selected_range()), 0..1); + cx.simulate_keystrokes("right"); + assert_eq!(input.read_with(cx, |input, _| input.selected_range()), 1..1); + cx.simulate_input("b"); + assert_eq!( + input.read_with(cx, |input, _| input.value().to_string()), + "ab.txt" + ); + + let bounds = cx.debug_bounds("tree-rename-input").unwrap(); + cx.simulate_click(bounds.center(), gpui::Modifiers::default()); + files.update_in(cx, |files, window, cx| { + assert!( + files.tree_rename.is_some(), + "clicking the field must not activate its tree row" + ); + assert!(input.focus_handle(cx).is_focused(window)); + assert!(!files.tree_focus.is_focused(window)); + }); + for theme in [Theme::dark(), Theme::light()] { + cx.update(|window, cx| { + cx.set_global(theme); + window.refresh(); + window.draw(cx).clear(); + }); + input.read_with(cx, |input, _| { + let (caret, _) = input.cursor_layout().expect("rename must lay out a caret"); + assert!(caret.size.width > px(0.)); + assert!(caret.size.height > px(0.)); + assert!(caret.left() >= bounds.left()); + assert!(caret.right() <= bounds.right()); + assert!(caret.top() >= bounds.top()); + assert!(caret.bottom() <= bounds.bottom()); + }); + } + cx.simulate_keystrokes("escape"); + files.update_in(cx, |files, window, _| { + assert!(files.tree_rename.is_none()); + assert!(files.tree_focus.is_focused(window)); + }); + cx.run_until_parked(); + assert!( + !events + .borrow() + .iter() + .any(|event| matches!(event, FilesEvent::OpenFile(_) | FilesEvent::Mutate(_))) + ); + + cx.simulate_keystrokes("f2"); + cx.simulate_input("résumé"); + cx.simulate_keystrokes("enter"); + cx.run_until_parked(); + let events = events.borrow(); + let intents = events + .iter() + .filter_map(|event| match event { + FilesEvent::Mutate(intent) => Some(intent), + _ => None, + }) + .collect::>(); + assert_eq!(intents.len(), 1); + assert_eq!(intents[0].destination.as_deref(), Some("résumé.txt")); + } + + #[gpui::test] + fn refreshed_revision_does_not_authorize_an_already_open_confirmation( + cx: &mut gpui::TestAppContext, + ) { + let (files, cx) = super::super::test_support::setup(cx); + let events = std::rc::Rc::new(std::cell::RefCell::new(Vec::new())); + let recorded = events.clone(); + let _sub = cx.update(|_, cx| { + cx.subscribe(&files, move |_, event, _| { + recorded.borrow_mut().push(event.clone()) + }) + }); + files.update_in(cx, |files, window, cx| { + files.begin_tree_delete("a.txt".into(), window, cx); + files.begin_tree_rename("a.txt".into(), window, cx); + let input = files.tree_rename.as_ref().unwrap().input.clone(); + input.update(cx, |input, cx| input.set_value("renamed.txt", window, cx)); + let mut entry = + super::super::test_support::entry("a.txt", zeron_proto::WorkspaceEntryKind::File); + entry.mutation_revision = Some("external-edit".into()); + files.tree.apply_page( + zeron_proto::WorkspaceDirectoryPage { + directory: "".into(), + checkout_id: Some("checkout".into()), + mutation_capabilities: files.mutation_capabilities, + entries: vec![entry], + next_cursor: None, + truncated: false, + }, + files.tree.generation(), + ); + files.submit_tree_rename(window, cx); + assert!(!files.tree_rename.as_ref().unwrap().submitted); + assert!( + files + .mutation_error + .as_ref() + .unwrap() + .contains("Entry changed") + ); + files.dismiss_tree_delete(true, window, cx); + assert!( + files + .mutation_error + .as_ref() + .unwrap() + .contains("Entry changed") + ); + }); + cx.run_until_parked(); + assert!( + !events + .borrow() + .iter() + .any(|event| matches!(event, FilesEvent::Mutate(_))) + ); + } + + #[gpui::test] + fn inline_rename_submits_once_and_cancel_delete_never_mutates(cx: &mut gpui::TestAppContext) { + let (files, cx) = super::super::test_support::setup(cx); + let events = std::rc::Rc::new(std::cell::RefCell::new(Vec::new())); + let recorded = events.clone(); + let _sub = cx.update(|_, cx| { + cx.subscribe(&files, move |_, event, _| { + recorded.borrow_mut().push(event.clone()) + }) + }); + files.update_in(cx, |files, window, cx| { + files.begin_tree_rename("a.txt".into(), window, cx); + let input = files.tree_rename.as_ref().unwrap().input.clone(); + input.update(cx, |input, cx| input.set_value("new.txt", window, cx)); + files.submit_tree_rename(window, cx); + files.submit_tree_rename(window, cx); + }); + cx.run_until_parked(); + assert_eq!( + events + .borrow() + .iter() + .filter(|event| matches!(event, FilesEvent::Mutate(_))) + .count(), + 1 + ); + events.borrow_mut().clear(); + files.update_in(cx, |files, window, cx| { + files.tree_rename = None; + files.begin_tree_delete("folder".into(), window, cx); + assert!(!files.tree_delete.as_ref().unwrap().confirm_focused); + files.dismiss_tree_delete(false, window, cx); + }); + cx.run_until_parked(); + assert!( + !events + .borrow() + .iter() + .any(|event| matches!(event, FilesEvent::Mutate(_))) + ); + } + #[test] + fn rename_preserves_parent_and_selects_only_basename() { + assert_eq!(renamed_path("src/a.txt", "é.txt").unwrap(), "src/é.txt"); + for invalid in ["", ".", "..", "../a", "a/b", ".git", "a\\b"] { + assert!(renamed_path("src/a", invalid).is_err()); + } + assert_eq!(name_selection("foo.test.ts", false), 0..8); + assert_eq!(name_selection(".gitignore", false), 0..10); + assert_eq!(name_selection("foo.ts", true), 0..6); + } +} diff --git a/crates/ui/src/files/search.rs b/crates/ui/src/files/search.rs index c17905d723..20a0ffbfe9 100644 --- a/crates/ui/src/files/search.rs +++ b/crates/ui/src/files/search.rs @@ -610,7 +610,11 @@ impl FilesSurface { let is_directory = row.kind == WorkspaceEntryKind::Directory; let decoration = self.git_decoration(&row.path, is_directory, cx); let padding = 8.0 + row.depth as f32 * super::tree::TREE_INDENT; - let drag_payload = WorkspacePathDrag::new(row.path.clone(), is_directory); + let drag_payload = WorkspacePathDrag::new(row.path.clone(), is_directory).with_origin( + self.interaction_origin(cx), + super::WorkspacePathSource::Search, + None, + ); let content = div() .id(("files-search-result", index)) .role(gpui::Role::TreeItem) diff --git a/crates/ui/src/files/sections.rs b/crates/ui/src/files/sections.rs index cba70073ff..33b3e94db0 100644 --- a/crates/ui/src/files/sections.rs +++ b/crates/ui/src/files/sections.rs @@ -18,17 +18,19 @@ use std::hash::{Hash, Hasher}; use chrono::{DateTime, Utc}; use gpui::{ - Animation, AnimationExt as _, AnyElement, Context, EntityId, MouseButton, ScrollHandle, + Animation, AnimationExt as _, AnyElement, Context, Entity, EntityId, MouseButton, ScrollHandle, SharedString, div, prelude::*, px, }; use zeron_doc::{MessagePart, SubagentStatus}; use zeron_proto::{Chat, ChatIndicator}; +use crate::composer::ComposerInput; use crate::icons::{self, icon}; use crate::state::AppState; use crate::theme::Theme; use crate::{loaders, motion}; +use super::subagents::{Group, GroupState, SubagentPlan}; use super::{FilesEvent, FilesSurface}; const SECTION_HEADER_HEIGHT: f32 = 28.0; @@ -46,10 +48,12 @@ const EMPTY_PAD: f32 = 10.0; const LIST_FADE_BAND: f32 = 16.0; /// Hover group of a section header (reveals its actions). const HEADER_GROUP: &str = "files-section-header"; +/// How far each level of the Finished dropdown steps in. +const GROUP_INDENT: f32 = 10.0; /// Rows a section shows before "Show more" pages it, and the page size — /// the sidebar's Archived shelf numbers. -const INITIAL_ROWS: usize = 10; -const PAGE_ROWS: usize = 10; +pub(super) const INITIAL_ROWS: usize = 10; +pub(super) const PAGE_ROWS: usize = 10; /// An open section never shrinks below this, so one or two rows still /// leave the section room to breathe. const MIN_BODY_HEIGHT: f32 = 120.0; @@ -119,10 +123,18 @@ pub(super) struct ExplorerSections { /// One scroll handle per section list, so the edge fades can read /// overflow at paint time. scroll: HashMap, + /// Open/closed and paging state of the Finished dropdown. + groups: GroupState, /// Hash of what the footer would draw, so the state observer only /// re-renders the explorer when a section's contents actually changed — /// not on every streamed transcript delta. fingerprint: u64, + /// The side chat whose title the shell is editing in place, and its + /// field — drawn over that row's title. + chat_rename: Option<(String, Entity)>, + /// A section opened to reveal a renamed row: its motion starts on the + /// render that knows the body height. + reveal: Option
, } impl Default for ExplorerSections { @@ -139,7 +151,10 @@ impl Default for ExplorerSections { ] .into_iter() .collect(), + groups: GroupState::default(), fingerprint: 0, + chat_rename: None, + reveal: None, } } } @@ -177,6 +192,24 @@ impl ExplorerSections { self.open.insert(section, !open); } + /// Start the opening motion of a section a reveal opened. + fn begin_reveal(&mut self, section: Section, height: f32) { + if self.reveal != Some(section) || !self.is_open(section) { + return; + } + self.reveal = None; + let epoch = self.motion.get(§ion).map_or(1, |m| m.epoch + 1); + self.motion.insert( + section, + DisclosureMotion { + epoch, + from: 0.0, + to: height, + started: std::time::Instant::now(), + }, + ); + } + fn scroll(&self, section: Section) -> ScrollHandle { self.scroll .get(§ion) @@ -367,10 +400,24 @@ fn content_height_unfloored(section: Section, count: usize, shown: usize) -> f32 } let visible = count.min(shown); let more = if count > shown { 1 } else { 0 }; - let slots = visible + more; + rows_height(visible + more) +} + +/// The height of a list of `slots` row-sized items: the inset, the rows and +/// the gaps between them. +fn rows_height(slots: usize) -> f32 { SECTION_BODY_INSET + slots as f32 * ROW_HEIGHT + slots.saturating_sub(1) as f32 * ROW_GAP } +/// The Subagents body's wanted height: its rows and group headers, or the +/// empty-state copy when nothing is left to list. +fn subagents_height(plan: &SubagentPlan, groups: &GroupState) -> f32 { + match plan.slots(groups) { + 0 => content_height(Section::Subagents, 0, INITIAL_ROWS), + slots => rows_height(slots).max(MIN_BODY_HEIGHT), + } +} + /// Split the footer's body budget between two open sections: each may take /// what it wants, and a short one hands its slack to the other. Closed /// sections get 0. Pure. @@ -393,6 +440,47 @@ fn chrome_height() -> f32 { } impl FilesSurface { + /// Show (or clear) the shell's inline rename of one of the Chats rows. + pub(crate) fn set_chat_rename( + &mut self, + rename: Option<(String, Entity)>, + cx: &mut Context, + ) { + let key = |rename: &Option<(String, Entity)>| { + rename + .as_ref() + .map(|(id, input)| (id.clone(), input.entity_id())) + }; + if key(&self.sections.chat_rename) != key(&rename) { + if let Some((chat_id, _)) = &rename { + self.reveal_chat_row(chat_id, cx); + } + self.sections.chat_rename = rename; + cx.notify(); + } + } + + /// Open the Chats section, page it, and scroll its list so `chat_id`'s + /// row shows — the inline rename's field must never sit on a hidden row. + fn reveal_chat_row(&mut self, chat_id: &str, cx: &mut Context) { + let rows = child_chat_rows(self.state.read(cx), &self.chat_id, Utc::now()); + let Some(index) = rows.iter().position(|row| row.chat_id == chat_id) else { + return; + }; + if !self.sections.is_open(Section::Chats) { + self.sections.open.insert(Section::Chats, true); + self.sections.reveal = Some(Section::Chats); + } + // Page in the same steps "Show more" takes. + if index >= self.sections.shown(Section::Chats) { + self.sections.shown.insert( + Section::Chats, + INITIAL_ROWS + (index + 1 - INITIAL_ROWS).div_ceil(PAGE_ROWS) * PAGE_ROWS, + ); + } + self.sections.scroll(Section::Chats).scroll_to_item(index); + } + /// Re-render only when the footer's contents changed. pub(super) fn refresh_sections(&mut self, cx: &mut Context) { let fingerprint = fingerprint(self.state.read(cx), &self.chat_id, Utc::now()); @@ -412,12 +500,9 @@ impl FilesSurface { ) }; self.sections.fingerprint = fingerprint(self.state.read(cx), &self.chat_id, now); + let plan = SubagentPlan::new(subagents); let wants = [ - content_height( - Section::Subagents, - subagents.len(), - self.sections.shown(Section::Subagents), - ), + subagents_height(&plan, &self.sections.groups), content_height( Section::Chats, chats.len(), @@ -431,7 +516,14 @@ impl FilesSurface { let budget = FOOTER_HEIGHT - chrome_height(); let heights = body_budget(budget, wants, open); let view = cx.entity_id(); - let subagent_body = self.render_subagent_rows(&subagents, view, theme, cx); + let subagent_body = self.render_subagent_rows(&plan, view, theme, cx); + let subagent_badge = (plan.running() > 0).then(|| { + crate::running_pill::running_pill( + "files-subagents-header", + plan.running() as u32, + theme, + ) + }); let chat_body = self.render_chat_rows(&chats, theme, cx); let chats_actions = self.render_chats_header_actions(theme, cx); div() @@ -446,10 +538,11 @@ impl FilesSurface { .pb(px(FOOTER_PAD_BOTTOM)) .child(self.render_section( Section::Subagents, - subagents.len(), + plan.total(), wants[0], heights[0], None, + subagent_badge, subagent_body, theme, cx, @@ -460,6 +553,7 @@ impl FilesSurface { wants[1], heights[1], Some(chats_actions), + None, chat_body, theme, cx, @@ -495,7 +589,7 @@ impl FilesSurface { .child( header_action( "files-sections-fork", - icons::GIT_BRANCH, + icons::FORK, "Fork this chat", theme, ) @@ -515,11 +609,13 @@ impl FilesSurface { wanted: f32, height: f32, actions: Option, + badge: Option, body: AnyElement, theme: &Theme, cx: &mut Context, ) -> AnyElement { let open = self.sections.is_open(section); + self.sections.begin_reveal(section, height); // The collapsed header carries the count; open, the rows speak. let label: SharedString = if open || count == 0 { section.label().into() @@ -565,14 +661,25 @@ impl FilesSurface { cx.notify(); })) .child( + // The badge trails the title, not the caret: the title + // truncates first, the badge never does. div() .flex_1() .min_w_0() - .truncate() - .text_size(crate::typography::ui_rems(12.0)) - .font_weight(gpui::FontWeight::MEDIUM) - .text_color(theme.text_muted.opacity(0.5)) - .child(label), + .flex() + .flex_row() + .items_center() + .gap(px(6.0)) + .child( + div() + .min_w_0() + .truncate() + .text_size(crate::typography::ui_rems(12.0)) + .font_weight(gpui::FontWeight::MEDIUM) + .text_color(theme.text_muted.opacity(0.5)) + .child(label), + ) + .children(badge), ) .children(actions) .child(self.render_chevron(section, open, theme)); @@ -667,40 +774,29 @@ impl FilesSurface { theme: &Theme, cx: &mut Context, ) -> AnyElement { - div() - .id(SharedString::from(format!( - "files-section-{}-more", - section.key() - ))) - .role(gpui::Role::Button) - .flex_none() - .h(px(ROW_HEIGHT)) - .flex() - .items_center() - .px(px(Theme::SPACE_SM)) - .rounded(px(8.0)) - .cursor_pointer() - .text_size(crate::typography::ui_rems(12.0)) - .text_color(theme.text_muted.opacity(0.7)) - .hover(|s| s.bg(theme.glass_hover()).text_color(theme.text_muted)) - .child(format!("Show {} more", remaining.min(PAGE_ROWS))) - .on_click(cx.listener(move |this, _, _, cx| { - cx.stop_propagation(); - let shown = this.sections.shown(section) + PAGE_ROWS; - this.sections.shown.insert(section, shown); - cx.notify(); - })) - .into_any_element() + show_more_row( + format!("files-section-{}-more", section.key()), + remaining, + 0.0, + theme, + ) + .on_click(cx.listener(move |this, _, _, cx| { + cx.stop_propagation(); + let shown = this.sections.shown(section) + PAGE_ROWS; + this.sections.shown.insert(section, shown); + cx.notify(); + })) + .into_any_element() } fn render_subagent_rows( &self, - rows: &[SubagentRow], + plan: &SubagentPlan, view: EntityId, theme: &Theme, cx: &mut Context, ) -> AnyElement { - if rows.is_empty() { + if plan.total() == 0 { return empty_state( "Subagents will appear here when they are created", None, @@ -708,51 +804,192 @@ impl FilesSurface { ); } let now = Utc::now(); - let shown = self.sections.shown(Section::Subagents); let scroll = self.sections.scroll(Section::Subagents); let mut list = row_list("files-subagent-rows", &scroll); - for row in rows.iter().take(shown) { - let glyph = status_glyph( - format!("files-subagent-{}", row.doc_id), - row.indicator(), - view, - theme, - cx, - ); - let open = row.clone(); - list = list.child( - compact_row(format!("files-subagent-{}", row.doc_id), theme) - .aria_label(SharedString::from(format!("Open subagent {}", row.title))) - .on_click(cx.listener(move |_, _, _, cx| { - cx.stop_propagation(); - cx.emit(FilesEvent::OpenSubagent { - doc_id: open.doc_id.clone(), - title: open.title.to_string(), - frozen: open.frozen(), - }); - })) - .child(glyph) - .child(row_title( - format!("files-subagent-title-{}", row.doc_id), - row.title.clone(), - )) - .child(time_ago_label( - zeron_proto::view::format_time_ago(row.spawned_at, now).into(), - theme, - )), - ); + for row in &plan.active { + list = list.child(self.render_subagent_row(row, 0.0, now, view, theme, cx)); } - if rows.len() > shown { - list = list.child(self.render_show_more( - Section::Subagents, - rows.len() - shown, + if plan.finished() > 0 { + list = list.child(self.render_group_header( + Group::Finished, + plan.finished(), + 0.0, theme, cx, )); + if self.sections.groups.is_open(Group::Finished) { + for group in [Group::Completed, Group::Failed] { + let rows = plan.rows(group); + if rows.is_empty() { + continue; + } + list = list.child(self.render_group_header( + group, + rows.len(), + GROUP_INDENT, + theme, + cx, + )); + if !self.sections.groups.is_open(group) { + continue; + } + let shown = self.sections.groups.shown(group); + for row in rows.iter().take(shown) { + list = list.child(self.render_subagent_row( + row, + 2.0 * GROUP_INDENT, + now, + view, + theme, + cx, + )); + } + if rows.len() > shown { + list = list.child(self.render_group_show_more( + group, + rows.len() - shown, + 2.0 * GROUP_INDENT, + theme, + cx, + )); + } + } + } } faded_list(list, &scroll) } + /// One subagent: status glyph, title, and the time it was last updated. + fn render_subagent_row( + &self, + row: &SubagentRow, + indent: f32, + now: DateTime, + view: EntityId, + theme: &Theme, + cx: &mut Context, + ) -> AnyElement { + let glyph = status_glyph( + format!("files-subagent-{}", row.doc_id), + row.indicator(), + view, + theme, + cx, + ); + let open = row.clone(); + compact_row(format!("files-subagent-{}", row.doc_id), theme) + .pl(px(Theme::SPACE_SM + indent)) + .aria_label(SharedString::from(format!("Open subagent {}", row.title))) + .on_click(cx.listener(move |_, _, _, cx| { + cx.stop_propagation(); + cx.emit(FilesEvent::OpenSubagent { + doc_id: open.doc_id.clone(), + title: open.title.to_string(), + frozen: open.frozen(), + }); + })) + .child(glyph) + .child(row_title( + format!("files-subagent-title-{}", row.doc_id), + row.title.clone(), + )) + .child(time_ago_label( + zeron_proto::view::format_time_ago(row.spawned_at, now).into(), + theme, + )) + .into_any_element() + } + + /// A dropdown header inside the Subagents body: caret and + /// "Label (count)". Clicking toggles the group. + fn render_group_header( + &self, + group: Group, + count: usize, + indent: f32, + theme: &Theme, + cx: &mut Context, + ) -> AnyElement { + let open = self.sections.groups.is_open(group); + let chevron = icon(icons::ALT_ARROW_RIGHT) + .size(px(12.0)) + .text_color(theme.text_muted.opacity(0.5)) + .with_transformation(gpui::Transformation::rotate(gpui::percentage(if open { + 0.25 + } else { + 0.0 + }))); + div() + .id(SharedString::from(format!( + "files-subagent-group-{}", + group.key() + ))) + .role(gpui::Role::Button) + .aria_label(SharedString::from(format!( + "{} {} subagents", + if open { "Collapse" } else { "Expand" }, + group.label() + ))) + .flex_none() + .h(px(ROW_HEIGHT)) + .flex() + .flex_row() + .items_center() + .gap(px(2.0)) + .rounded(px(8.0)) + .pl(px(Theme::SPACE_SM + indent - 4.0)) + .pr(px(4.0)) + .cursor_pointer() + .hover(|s| s.bg(theme.glass_hover())) + .on_click(cx.listener(move |this, _, _, cx| { + this.sections.groups.toggle(group); + cx.notify(); + })) + .child( + div() + .flex_none() + .size(px(16.0)) + .flex() + .items_center() + .justify_center() + .child(chevron), + ) + .child( + div() + .flex_1() + .min_w_0() + .truncate() + .text_size(crate::typography::ui_rems(12.0)) + .font_weight(gpui::FontWeight::MEDIUM) + .text_color(theme.text_muted.opacity(0.7)) + .child(SharedString::from(format!("{} ({count})", group.label()))), + ) + .into_any_element() + } + + /// "Show N more" under one finished category, indented with its rows. + fn render_group_show_more( + &self, + group: Group, + remaining: usize, + indent: f32, + theme: &Theme, + cx: &mut Context, + ) -> AnyElement { + show_more_row( + format!("files-subagent-group-{}-more", group.key()), + remaining, + indent, + theme, + ) + .on_click(cx.listener(move |this, _, _, cx| { + cx.stop_propagation(); + this.sections.groups.page_up(group); + cx.notify(); + })) + .into_any_element() + } + fn render_chat_rows( &self, rows: &[ChildChatRow], @@ -769,7 +1006,7 @@ impl FilesSurface { .child( pill_button( "files-sections-empty-fork", - icons::GIT_BRANCH, + icons::FORK, "Fork", theme, ) @@ -811,12 +1048,36 @@ impl FilesSurface { ); let open_id = row.chat_id.clone(); let menu_id = row.chat_id.clone(); + let rename_input = self + .sections + .chat_rename + .as_ref() + .filter(|(id, _)| *id == row.chat_id) + .map(|(_, input)| input.clone()); + let title = match rename_input { + Some(input) => crate::shell::chat_title_editor( + format!("files-chat-title-editor-{}", row.chat_id).into(), + input, + theme, + ), + None => row_title( + format!("files-chat-title-{}", row.chat_id), + row.title.clone(), + ) + .into_any_element(), + }; list = list.child( compact_row(format!("files-chat-{}", row.chat_id), theme) .aria_label(SharedString::from(format!("Open side chat {}", row.title))) - .on_click(cx.listener(move |_, _, _, cx| { + .on_click(cx.listener(move |_, event: &gpui::ClickEvent, _, cx| { cx.stop_propagation(); - cx.emit(FilesEvent::OpenChildChat(open_id.clone())); + // The first click opened the tab; the second edits + // the title in place. + if event.click_count() >= 2 { + cx.emit(FilesEvent::RenameChildChat(open_id.clone())); + } else { + cx.emit(FilesEvent::OpenChildChat(open_id.clone())); + } })) .on_mouse_down( MouseButton::Right, @@ -829,10 +1090,7 @@ impl FilesSurface { }), ) .child(glyph) - .child(row_title( - format!("files-chat-title-{}", row.chat_id), - row.title.clone(), - )) + .child(title) .children(row.change_request.clone().map(|summary| { crate::change_requests::pull_request_badge( format!("files-chat-pr-{}", row.chat_id).into(), @@ -884,6 +1142,31 @@ fn header_action( ) } +/// A section's "Show N more" row (a page is [`PAGE_ROWS`]), indented to line +/// up with the rows it pages. +fn show_more_row( + id: String, + remaining: usize, + indent: f32, + theme: &Theme, +) -> gpui::Stateful { + div() + .id(SharedString::from(id)) + .role(gpui::Role::Button) + .flex_none() + .h(px(ROW_HEIGHT)) + .flex() + .items_center() + .pl(px(Theme::SPACE_SM + indent)) + .pr(px(Theme::SPACE_SM)) + .rounded(px(8.0)) + .cursor_pointer() + .text_size(crate::typography::ui_rems(12.0)) + .text_color(theme.text_muted.opacity(0.7)) + .hover(|s| s.bg(theme.glass_hover()).text_color(theme.text_muted)) + .child(format!("Show {} more", remaining.min(PAGE_ROWS))) +} + /// The empty state's pill buttons — the explorer's Retry button shape. fn pill_button( id: &'static str, @@ -1268,6 +1551,108 @@ mod tests { assert_eq!(rows[0].status, ChatIndicator::Idle); } + #[gpui::test] + fn double_clicked_side_chat_asks_for_an_inline_rename(cx: &mut gpui::TestAppContext) { + use gpui::{AppContext as _, MouseDownEvent, MouseUpEvent}; + + let (files, cx) = super::super::test_support::setup(cx); + files.update(cx, |files, cx| { + files.state.update(cx, |state, _| { + let mut side = chat("side", Some("chat"), 1); + side.title = Some("Side work".into()); + state.chats.push(side); + }); + }); + cx.update(|window, cx| { + window.refresh(); + window.draw(cx).clear(); + }); + let events = std::rc::Rc::new(std::cell::RefCell::new(Vec::new())); + let recorded = events.clone(); + let _sub = cx.update(|_, cx| { + cx.subscribe(&files, move |_, event, _| { + recorded.borrow_mut().push(event.clone()) + }) + }); + let row = cx.debug_bounds("files-chat-title-side").unwrap(); + for click_count in [1, 2] { + cx.simulate_event(MouseDownEvent { + button: MouseButton::Left, + position: row.center(), + click_count, + ..Default::default() + }); + cx.simulate_event(MouseUpEvent { + button: MouseButton::Left, + position: row.center(), + click_count, + ..Default::default() + }); + } + cx.run_until_parked(); + { + let events = events.borrow(); + assert!(matches!(&events[..], [ + FilesEvent::OpenChildChat(open), + FilesEvent::RenameChildChat(rename), + ] if open == "side" && rename == "side")); + } + + // The shell's field replaces the row title while the edit is open. + let input = cx.update(|_, cx| cx.new(|cx| ComposerInput::new("Session title", cx))); + files.update(cx, |files, cx| { + files.set_chat_rename(Some(("side".into(), input)), cx) + }); + cx.update(|window, cx| window.draw(cx).clear()); + assert!(cx.debug_bounds("files-chat-title-editor-side").is_some()); + assert!(cx.debug_bounds("files-chat-title-side").is_none()); + files.update(cx, |files, cx| files.set_chat_rename(None, cx)); + cx.update(|window, cx| window.draw(cx).clear()); + assert!(cx.debug_bounds("files-chat-title-side").is_some()); + } + + #[gpui::test] + fn inline_rename_reveals_a_side_chat_in_a_collapsed_paged_section( + cx: &mut gpui::TestAppContext, + ) { + use gpui::AppContext as _; + + let (files, cx) = super::super::test_support::setup(cx); + files.update(cx, |files, cx| { + files.state.update(cx, |state, _| { + for ix in 0..12 { + state + .chats + .push(chat(&format!("side-{ix:02}"), Some("chat"), ix)); + } + }); + files.sections.toggle(Section::Chats, 0.0, 0.0); + assert!(!files.sections.is_open(Section::Chats)); + }); + cx.update(|window, cx| { + window.refresh(); + window.draw(cx).clear(); + }); + + // Newest first: side-11 is the twelfth row, past the first page. + let input = cx.update(|_, cx| cx.new(|cx| ComposerInput::new("Session title", cx))); + files.update(cx, |files, cx| { + files.set_chat_rename(Some(("side-11".into(), input)), cx) + }); + cx.update(|window, cx| window.draw(cx).clear()); + files.read_with(cx, |files, _| { + assert!(files.sections.is_open(Section::Chats)); + assert_eq!( + files.sections.shown(Section::Chats), + INITIAL_ROWS + PAGE_ROWS + ); + // The reveal became the section's opening motion. + assert_eq!(files.sections.reveal, None); + assert_eq!(files.sections.motion[&Section::Chats].from, 0.0); + }); + assert!(cx.debug_bounds("files-chat-title-editor-side-11").is_some()); + } + #[test] fn fingerprint_tracks_membership_and_status() { let mut state = AppState::new(); diff --git a/crates/ui/src/files/subagents.rs b/crates/ui/src/files/subagents.rs new file mode 100644 index 0000000000..a7beec720b --- /dev/null +++ b/crates/ui/src/files/subagents.rs @@ -0,0 +1,249 @@ +//! The Subagents section's model, kept pure so its shape and height are +//! testable without a window. +//! +//! Running subagents lead the body as plain rows. Everything that has +//! settled folds into one **Finished** dropdown, closed until opened, that +//! splits into **Completed** and **Failed** lists. Rows are read from the +//! transcript's spawn chips, so nothing here removes or hides one. + +use std::collections::{HashMap, HashSet}; + +use zeron_doc::SubagentStatus; + +use super::sections::{INITIAL_ROWS, PAGE_ROWS, SubagentRow}; + +/// A collapsible group of finished subagents. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub(super) enum Group { + /// Parent of the two categories below. + Finished, + Completed, + Failed, +} + +impl Group { + pub fn label(self) -> &'static str { + match self { + Group::Finished => "Finished", + Group::Completed => "Completed", + Group::Failed => "Failed", + } + } + + pub fn key(self) -> &'static str { + match self { + Group::Finished => "finished", + Group::Completed => "completed", + Group::Failed => "failed", + } + } +} + +/// Open/closed and paging state of the groups. The Finished dropdown starts +/// closed, so the body opens on what is running; the two lists inside it start +/// open, so one click shows every finished subagent. +#[derive(Debug, Default)] +pub(super) struct GroupState { + /// Groups flipped away from their default. + flipped: HashSet, + /// Rows revealed per category ("Show more" pages this up). + shown: HashMap, +} + +impl GroupState { + pub fn is_open(&self, group: Group) -> bool { + let default_open = group != Group::Finished; + default_open != self.flipped.contains(&group) + } + + pub fn toggle(&mut self, group: Group) { + if !self.flipped.remove(&group) { + self.flipped.insert(group); + } + } + + pub fn shown(&self, group: Group) -> usize { + self.shown + .get(&group) + .copied() + .unwrap_or(INITIAL_ROWS) + .max(INITIAL_ROWS) + } + + pub fn page_up(&mut self, group: Group) { + let shown = self.shown(group) + PAGE_ROWS; + self.shown.insert(group, shown); + } +} + +/// The rows the Subagents body draws, in the order it draws them. +#[derive(Debug, Default, PartialEq, Eq)] +pub(super) struct SubagentPlan { + /// Not settled: running first (longest-running leading), then any row + /// whose status has not been stamped yet. + pub active: Vec, + pub completed: Vec, + pub failed: Vec, +} + +impl SubagentPlan { + /// Sort `rows` (already in display order) into the three lists. + pub fn new(rows: Vec) -> Self { + let mut plan = Self::default(); + for row in rows { + match row.status { + Some(SubagentStatus::Done) => plan.completed.push(row), + Some(SubagentStatus::Failed) => plan.failed.push(row), + _ => plan.active.push(row), + } + } + plan + } + + /// Subagents streaming right now. + pub fn running(&self) -> usize { + self.active + .iter() + .filter(|row| row.status == Some(SubagentStatus::Running)) + .count() + } + + pub fn finished(&self) -> usize { + self.completed.len() + self.failed.len() + } + + /// Everything the list would show with all groups open. + pub fn total(&self) -> usize { + self.active.len() + self.finished() + } + + /// The rows behind a group; `Finished` is both categories, completed first. + pub fn rows(&self, group: Group) -> Vec<&SubagentRow> { + match group { + Group::Finished => self.completed.iter().chain(&self.failed).collect(), + Group::Completed => self.completed.iter().collect(), + Group::Failed => self.failed.iter().collect(), + } + } + + /// Row-sized slots the body occupies: each visible row, each group + /// header, and a "Show more" row wherever a category is paged. + pub fn slots(&self, groups: &GroupState) -> usize { + let mut slots = self.active.len(); + if self.finished() == 0 { + return slots; + } + slots += 1; + if !groups.is_open(Group::Finished) { + return slots; + } + for group in [Group::Completed, Group::Failed] { + let count = self.rows(group).len(); + if count == 0 { + continue; + } + slots += 1; + if groups.is_open(group) { + let shown = groups.shown(group); + slots += count.min(shown) + usize::from(count > shown); + } + } + slots + } +} + +#[cfg(test)] +mod tests { + use chrono::Utc; + + use super::*; + + fn row(id: &str, status: Option) -> SubagentRow { + SubagentRow { + doc_id: id.into(), + title: id.to_owned().into(), + status, + spawned_at: Utc::now(), + } + } + + fn mixed() -> Vec { + vec![ + row("run", Some(SubagentStatus::Running)), + row("new", None), + row("ok-1", Some(SubagentStatus::Done)), + row("bad", Some(SubagentStatus::Failed)), + row("ok-2", Some(SubagentStatus::Done)), + ] + } + + fn ids(rows: &[SubagentRow]) -> Vec<&str> { + rows.iter().map(|r| r.doc_id.as_str()).collect() + } + + /// Groups as the section first draws them, with Finished opened. + fn opened() -> GroupState { + let mut groups = GroupState::default(); + groups.toggle(Group::Finished); + groups + } + + #[test] + fn settled_rows_fold_into_lists_and_the_rest_stay_on_top() { + let plan = SubagentPlan::new(mixed()); + assert_eq!(ids(&plan.active), ["run", "new"]); + assert_eq!(ids(&plan.completed), ["ok-1", "ok-2"]); + assert_eq!(ids(&plan.failed), ["bad"]); + assert_eq!((plan.running(), plan.finished(), plan.total()), (1, 3, 5)); + } + + #[test] + fn finished_starts_closed_and_its_lists_start_open() { + let groups = GroupState::default(); + assert!(!groups.is_open(Group::Finished)); + assert!(groups.is_open(Group::Completed)); + assert!(groups.is_open(Group::Failed)); + } + + #[test] + fn slots_follow_what_is_open() { + let plan = SubagentPlan::new(mixed()); + let mut groups = GroupState::default(); + // Closed: 2 active + the Finished header. + assert_eq!(plan.slots(&groups), 2 + 1); + groups.toggle(Group::Finished); + // + (Completed header + 2) + (Failed header + 1) + assert_eq!(plan.slots(&groups), 2 + 1 + 3 + 2); + groups.toggle(Group::Failed); + assert_eq!(plan.slots(&groups), 2 + 1 + 3 + 1); + // Closing the parent keeps each list's own state for the next open. + groups.toggle(Group::Finished); + assert_eq!(plan.slots(&groups), 2 + 1); + groups.toggle(Group::Finished); + assert_eq!(plan.slots(&groups), 2 + 1 + 3 + 1); + } + + #[test] + fn nothing_finished_means_no_group_chrome() { + let rows = vec![row("run", Some(SubagentStatus::Running))]; + let plan = SubagentPlan::new(rows); + assert_eq!(plan.slots(&opened()), 1); + assert_eq!(SubagentPlan::default().slots(&opened()), 0); + } + + #[test] + fn a_long_list_pages_and_counts_its_show_more_row() { + let rows: Vec<_> = (0..25) + .map(|i| row(&format!("ok-{i}"), Some(SubagentStatus::Done))) + .collect(); + let plan = SubagentPlan::new(rows); + let mut groups = opened(); + // Finished header + Completed header + 10 rows + "Show more". + assert_eq!(plan.slots(&groups), 1 + 1 + INITIAL_ROWS + 1); + groups.page_up(Group::Completed); + assert_eq!(plan.slots(&groups), 1 + 1 + INITIAL_ROWS + PAGE_ROWS + 1); + groups.page_up(Group::Completed); + // All 25 shown: no "Show more" left. + assert_eq!(plan.slots(&groups), 1 + 1 + 25); + } +} diff --git a/crates/ui/src/files/test_support.rs b/crates/ui/src/files/test_support.rs new file mode 100644 index 0000000000..b4c60ac2f4 --- /dev/null +++ b/crates/ui/src/files/test_support.rs @@ -0,0 +1,70 @@ +use super::*; +use gpui::{AppContext, TestAppContext, VisualTestContext}; +use zeron_proto::{WorkspaceDirectoryPage, WorkspaceEntry, WorkspaceEntryKind}; + +pub(crate) fn state() -> AppState { + let mut state = AppState::new(); + state.local_device_id = Some("device".into()); + state.chats.push( + serde_json::from_value(serde_json::json!({ + "id":"chat", "deviceId":"device", "archived":false, "cwd":"/workspace", + "checkoutId":"checkout", "createdAt":"2026-01-01T00:00:00Z" + })) + .unwrap(), + ); + state +} + +pub(crate) fn explorer(state: Entity, cx: &mut Context) -> FilesSurface { + let mut files = FilesSurface::new_explorer(state, "chat".into(), false, cx); + files.effective_checkout_id = Some("checkout".into()); + files.mutation_capabilities = Some(zeron_proto::WorkspaceMutationCapabilities { + move_entry: true, + delete_entry: true, + }); + files.tree.begin_load("", None, files.tree.generation()); + files.tree.apply_page( + WorkspaceDirectoryPage { + directory: "".into(), + checkout_id: Some("checkout".into()), + mutation_capabilities: files.mutation_capabilities, + entries: vec![ + entry("a.txt", WorkspaceEntryKind::File), + entry("folder", WorkspaceEntryKind::Directory), + ], + next_cursor: None, + truncated: false, + }, + files.tree.generation(), + ); + files.sync_tree_list(); + files +} + +pub(super) fn setup(cx: &mut TestAppContext) -> (Entity, &mut VisualTestContext) { + cx.update(|cx| { + gpui_base::init(cx); + cx.set_global(crate::theme::Theme::dark()); + }); + let (files, cx) = cx.add_window_view(|_, cx| { + let state = cx.new(|_| state()); + explorer(state, cx) + }); + cx.update(|window, cx| { + window.activate_window(); + window.draw(cx).clear(); + }); + (files, cx) +} +pub(super) fn entry(path: &str, kind: WorkspaceEntryKind) -> WorkspaceEntry { + WorkspaceEntry { + path: path.into(), + name: path.rsplit('/').next().unwrap().into(), + kind, + size: Some(1), + modified_at: None, + ignored: false, + read_only: false, + mutation_revision: Some("rev".into()), + } +} diff --git a/crates/ui/src/files/tree.rs b/crates/ui/src/files/tree.rs index 1711f78e4f..8c87d7a7ad 100644 --- a/crates/ui/src/files/tree.rs +++ b/crates/ui/src/files/tree.rs @@ -114,6 +114,13 @@ fn tree_rail_geometry( Some((metrics, bounds.top())) } +fn tree_scroll_overflow(list: &gpui::ListState) -> (bool, bool) { + // ListState reports a negative offset as content scrolls upward. + let offset = -f32::from(list.scroll_px_offset_for_scrollbar().y); + let max = f32::from(list.max_offset_for_scrollbar().y); + (offset > 0.5, offset < max - 0.5) +} + /// The file tree's share of the shared floating rail: it owns no scroll /// handle, so geometry comes from the list state's own accessors and drags /// land through the list state's scrollbar offset setter. @@ -166,6 +173,7 @@ impl FilesSurface { } pub(super) fn on_tree_scrolled(&mut self, cx: &mut Context) { + self.close_tree_context_menu(cx); // The list repaints itself; the floating rail needs a view pass. cx.notify(); } @@ -179,8 +187,13 @@ impl FilesSurface { pub(super) fn render_tree(&mut self, cx: &mut Context) -> AnyElement { let theme = Theme::of(cx).clone(); let scrollbar = popover::rail(self, "files-tree-scrollbar", &theme, cx); + self.reset_tree_drop_rows(); + let root_target = self.render_tree_root_target(cx); div() .id("files-tree") + .debug_selector(|| "files-tree".into()) + .on_drag_move::(cx.listener(Self::on_tree_drag_move)) + .on_drop::(cx.listener(Self::on_tree_drop)) .role(gpui::Role::Tree) .aria_label("Workspace file tree") .relative() @@ -197,6 +210,7 @@ impl FilesSurface { .on_key_down(cx.listener(|this, event: &KeyDownEvent, window, cx| { this.on_tree_key_down(event, window, cx) })) + .child(root_target) .child({ // The sidebar's overflow treatment: rows fade under the top // and bottom edges while there is more to scroll to, read @@ -211,11 +225,7 @@ impl FilesSurface { .min_h_0() .with_sizing_behavior(ListSizingBehavior::Auto), ) - .fade_overflow_y_with(move |_| { - let offset = f32::from(overflow.scroll_px_offset_for_scrollbar().y); - let max = f32::from(overflow.max_offset_for_scrollbar().y); - (offset > 0.5, offset < max - 0.5) - }) + .fade_overflow_y_with(move |_| tree_scroll_overflow(&overflow)) }) .children(scrollbar) .into_any_element() @@ -232,17 +242,34 @@ impl FilesSurface { }; let theme = Theme::of(cx).clone(); let padding = 8.0 + row.depth as f32 * TREE_INDENT; + let drop_key = row.path.clone(); + let drop_target = match &row.kind { + VisibleRowKind::Entry => Some(row.path.clone()), + VisibleRowKind::Empty { directory } => Some(directory.clone()), + _ => None, + }; let content = match row.kind { VisibleRowKind::Entry => { let Some(node) = self.tree.node(&row.path).cloned() else { return gpui::Empty.into_any_element(); }; let path = row.path.clone(); + let menu_path = path.clone(); let selected = self.tree.selected() == Some(path.as_str()); let focused = self.tree_focus.is_focused(window); let is_directory = node.entry.kind == WorkspaceEntryKind::Directory; let decoration = self.git_decoration(&row.path, is_directory, cx); - let drag_payload = WorkspacePathDrag::new(path.clone(), is_directory); + let drag_payload = WorkspacePathDrag::new(path.clone(), is_directory).with_origin( + self.interaction_origin(cx), + super::WorkspacePathSource::Tree, + node.entry.mutation_revision.clone(), + ); + let drag_owner = cx.weak_entity(); + let renaming = self + .tree_rename + .as_ref() + .is_some_and(|rename| rename.path == path); + let rename_element = self.render_tree_rename(&path, cx); let expanded = is_directory && self.tree.is_expanded(&path); let text_color = if let Some(decoration) = decoration { decoration.color(&theme) @@ -265,6 +292,14 @@ impl FilesSurface { ))) .role(gpui::Role::TreeItem) .aria_label(node.entry.name.clone()) + .debug_selector({ + let path = row.path.clone(); + move || format!("tree-entry:{path}") + }) + .when( + is_directory && self.tree_drag.destination.as_deref() == Some(&path), + |el| el.bg(crate::theme::wash(0.18)), + ) .aria_selected(selected) .when(is_directory, |element| element.aria_expanded(expanded)) .h(px(TREE_ROW_HEIGHT)) @@ -289,9 +324,26 @@ impl FilesSurface { this.tree_focus.focus(window, cx); this.activate_tree_path(path.clone(), cx); })) - .on_drag(drag_payload, |payload, _, _, cx| { - cx.stop_propagation(); - workspace_path_drag_ghost(payload, cx) + .on_mouse_down( + MouseButton::Right, + cx.listener(move |this, event: &gpui::MouseDownEvent, window, cx| { + cx.stop_propagation(); + window.prevent_default(); + this.open_tree_context_menu( + menu_path.clone(), + event.position, + window, + cx, + ); + }), + ) + .when(!renaming, |element| { + element.on_drag(drag_payload, move |payload, _, _, cx| { + let _ = drag_owner + .update(cx, |files, cx| files.close_tree_context_menu(cx)); + cx.stop_propagation(); + workspace_path_drag_ghost(payload, cx) + }) }) .child( div() @@ -317,15 +369,16 @@ impl FilesSurface { .size(px(14.0)) .flex_none(), ) - .child( + .child(rename_element.unwrap_or_else(|| { div() .min_w_0() .truncate() .font_family(theme.font_sans.clone()) .text_size(px(11.5)) .text_color(text_color) - .child(node.entry.name), - ) + .child(node.entry.name) + .into_any_element() + })) .into_any_element() } VisibleRowKind::Loading { .. } => status_row( @@ -395,7 +448,12 @@ impl FilesSurface { ) .into_any_element(), }; - with_indent_guides(content, row.depth, TREE_ROW_HEIGHT, &theme) + self.track_tree_drop_row( + drop_key, + drop_target, + TREE_ROW_HEIGHT, + with_indent_guides(content, row.depth, TREE_ROW_HEIGHT, &theme), + ) } pub(super) fn activate_tree_path(&mut self, path: String, cx: &mut Context) { @@ -436,6 +494,47 @@ impl FilesSurface { window: &mut Window, cx: &mut Context, ) { + // Leave text input and editing shortcuts to the inline field. Stopping + // printable keys here would also prevent the platform's text insertion. + if self.tree_rename.is_some() { + return; + } + if event.keystroke.key == "escape" && cx.has_active_drag() { + cx.stop_active_drag(window); + self.clear_tree_drag(window, cx); + cx.stop_propagation(); + return; + } + if self.tree_menu_key(event, window, cx) { + return; + } + if event.keystroke.key == "menu" + || (event.keystroke.key == "f10" && event.keystroke.modifiers.shift) + { + if let Some(path) = self.tree.selected().map(str::to_string) { + self.open_tree_context_menu( + path, + self.tree_list.viewport_bounds().origin, + window, + cx, + ); + } + window.prevent_default(); + cx.stop_propagation(); + return; + } + if matches!(event.keystroke.key.as_str(), "f2" | "delete") { + if let Some(path) = self.tree.selected().map(str::to_string) { + if event.keystroke.key == "f2" { + self.begin_tree_rename(path, window, cx); + } else { + self.begin_tree_delete(path, window, cx); + } + } + window.prevent_default(); + cx.stop_propagation(); + return; + } let handled = match event.keystroke.key.as_str() { "up" => { self.tree.select_previous(); @@ -531,7 +630,7 @@ fn status_row( mod tests { use super::super::model::VisibleTreeRow; use super::*; - use gpui::{ListAlignment, ListOffset, ListState}; + use gpui::{AppContext, ListAlignment, ListOffset, ListState}; fn rows(paths: &[&str]) -> Vec { paths @@ -544,6 +643,57 @@ mod tests { .collect() } + #[gpui::test] + fn tree_fades_only_at_edges_with_hidden_rows(cx: &mut gpui::TestAppContext) { + struct TestTree(ListState); + impl gpui::Render for TestTree { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + list(self.0.clone(), |_, _, _| { + div().h(px(TREE_ROW_HEIGHT)).w_full().into_any_element() + }) + .w_full() + .h_full() + } + } + + let cx = cx.add_empty_window(); + let state = ListState::new(10, ListAlignment::Top, px(0.0)).measure_all(); + let view = cx.update(|_, cx| cx.new(|_| TestTree(state.clone()))); + let mut draw = |height| { + cx.draw( + gpui::point(px(0.0), px(0.0)), + gpui::size(px(200.0), px(height)), + |_, _| view.clone().into_any_element(), + ); + }; + + draw(100.0); + assert_eq!(state.max_offset_for_scrollbar().y, px(170.0)); + assert_eq!(tree_scroll_overflow(&state), (false, true)); + + state.set_offset_from_scrollbar(gpui::point(px(0.0), px(-85.0))); + draw(100.0); + assert_eq!(tree_scroll_overflow(&state), (true, true)); + + state.scroll_to_reveal_item(9); + draw(100.0); + assert_eq!(tree_scroll_overflow(&state), (true, false)); + let last = state.bounds_for_item(9).unwrap(); + let viewport = state.viewport_bounds(); + assert!(last.top() >= viewport.top() && last.bottom() <= viewport.bottom()); + + // A taller viewport or removed rows clamps scrolling during layout; + // the fade must disappear in that same frame. + draw(400.0); + assert_eq!(tree_scroll_overflow(&state), (false, false)); + draw(100.0); + state.scroll_to_reveal_item(9); + draw(100.0); + state.splice(2..10, 0); + draw(100.0); + assert_eq!(tree_scroll_overflow(&state), (false, false)); + } + #[test] fn directory_load_keeps_the_viewport_on_the_same_path() { let old = rows(&["a", "src", "z"]); diff --git a/crates/ui/src/files/watch.rs b/crates/ui/src/files/watch.rs index acbdedce71..06ea0dd3f4 100644 --- a/crates/ui/src/files/watch.rs +++ b/crates/ui/src/files/watch.rs @@ -71,8 +71,36 @@ impl FilesSurface { })); } - fn apply_workspace_changes(&mut self, frame: WorkspaceFileChanges, cx: &mut Context) { - let gap = sequence_needs_resync(self.watch_sequence, frame.sequence); + pub(super) fn apply_workspace_changes( + &mut self, + mut frame: WorkspaceFileChanges, + cx: &mut Context, + ) { + if self.pending_mutation.is_some() { + // Defer affected events; unrelated changes still flow through. Keep + // sequence accounting on the live stream, not the deferred fragments. + let intent = self.pending_mutation.as_ref().unwrap(); + let (affected, independent): (Vec<_>, Vec<_>) = + frame.changes.into_iter().partition(|change| { + intent.affects(&change.path) + || change + .old_path + .as_deref() + .is_some_and(|old| intent.affects(old)) + }); + if !affected.is_empty() || frame.resync_required { + self.deferred_file_changes.push(WorkspaceFileChanges { + sequence: frame.sequence, + resync_required: frame.resync_required, + changes: affected, + }); + } + frame.changes = independent; + frame.resync_required = false; + } + + let gap = self.pending_mutation.is_none() + && sequence_needs_resync(self.watch_sequence, frame.sequence); tracing::trace!( sequence = frame.sequence, previous_sequence = ?self.watch_sequence, @@ -91,6 +119,16 @@ impl FilesSurface { let mut parents = HashSet::new(); for change in frame.changes { + if change.operation_id.is_some() { + self.apply_semantic_mutation(&change, None, false, cx); + if let Some(parent) = parent_path(&change.path) { + parents.insert(parent); + } + if let Some(old) = change.old_path.as_deref().and_then(parent_path) { + parents.insert(old); + } + continue; + } self.invalidate_markdown_images(Some(&change.path), cx); if let Some(old_path) = &change.old_path { self.invalidate_markdown_images(Some(old_path), cx); @@ -103,6 +141,11 @@ impl FilesSurface { } } WorkspaceFileChangeKind::Modified => { + // Saves (including atomic replacement) change the metadata + // revision used by move/delete, even when the name is unchanged. + if let Some(parent) = parent_path(&change.path) { + parents.insert(parent); + } self.reconcile_document(change.path, cx); } WorkspaceFileChangeKind::Removed => { @@ -114,7 +157,7 @@ impl FilesSurface { } WorkspaceFileChangeKind::Renamed => { if let Some(old_path) = change.old_path { - self.tree.remove(&old_path); + self.tree.relocate_subtree(&old_path, &change.path, None); for (old_path, new_path) in self.rename_documents(&old_path, change.path.clone(), cx) { @@ -160,6 +203,116 @@ pub(super) fn sequence_needs_resync(previous: Option, next: u64) -> bool { #[cfg(test)] mod tests { use super::*; + use gpui::AppContext; + + #[gpui::test] + fn completed_save_refreshes_revision_before_the_next_mutation(cx: &mut gpui::TestAppContext) { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let _guard = runtime.enter(); + let (out, mut requests) = tokio::sync::mpsc::channel(16); + let (replies, inbound) = tokio::sync::mpsc::channel(16); + let engine = + crate::state::EngineHandle::from_test_client(zeron_rpc::RpcClient::new(out, inbound)); + let state = cx.new(|_| { + let mut state = super::super::test_support::state(); + state.set_test_engine(engine); + state + }); + let files = cx.new(|cx| super::super::test_support::explorer(state, cx)); + let events = std::rc::Rc::new(std::cell::RefCell::new(Vec::new())); + let recorded = events.clone(); + let _sub = cx.update(|cx| { + cx.subscribe(&files, move |_, event, _| { + recorded.borrow_mut().push(event.clone()) + }) + }); + files.update(cx, |files, cx| { + files.apply_workspace_changes( + WorkspaceFileChanges { + sequence: 1, + resync_required: false, + changes: vec![zeron_proto::WorkspaceFileChange { + operation_id: None, + kind: WorkspaceFileChangeKind::Modified, + path: "a.txt".into(), + old_path: None, + }], + }, + cx, + ); + }); + cx.run_until_parked(); + let request: serde_json::Value = serde_json::from_str( + &requests + .try_recv() + .expect("Modified must reload the containing directory"), + ) + .unwrap(); + assert_eq!( + request["method"], + zeron_rpc::methods::LIST_WORKSPACE_DIRECTORY + ); + assert_eq!(request["params"]["directory"], ""); + let mut entry = + super::super::test_support::entry("a.txt", zeron_proto::WorkspaceEntryKind::File); + entry.mutation_revision = Some("after-save".into()); + runtime.block_on(async { + replies + .send( + serde_json::json!({ + "id": request["id"], "ok": { + "directory": "", "checkoutId": "checkout", + "mutationCapabilities": {"moveEntry": true, "deleteEntry": true}, + "entries": [entry], "nextCursor": null, "truncated": false + } + }) + .to_string(), + ) + .await + .unwrap(); + tokio::time::timeout(Duration::from_secs(1), async { + while replies.capacity() < replies.max_capacity() { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + }); + cx.run_until_parked(); + files.update(cx, |files, cx| { + assert_eq!( + files + .tree + .node("a.txt") + .unwrap() + .entry + .mutation_revision + .as_deref(), + Some("after-save") + ); + files.request_mutation("a.txt", Some("renamed.txt".into()), cx); + files.request_mutation("a.txt", Some("folder/a.txt".into()), cx); + files.request_mutation("a.txt", None, cx); + }); + cx.run_until_parked(); + let events = events.borrow(); + let intents = events + .iter() + .filter_map(|event| match event { + FilesEvent::Mutate(intent) => Some(intent), + _ => None, + }) + .collect::>(); + assert_eq!(intents.len(), 3); + assert!( + intents + .iter() + .all(|intent| intent.entry.mutation_revision.as_deref() == Some("after-save")) + ); + } #[test] fn sequence_gaps_require_resync() { diff --git a/crates/ui/src/glass.rs b/crates/ui/src/glass.rs new file mode 100644 index 0000000000..68df45fee9 --- /dev/null +++ b/crates/ui/src/glass.rs @@ -0,0 +1,161 @@ +//! Glass surfaces shared by controls: each is a vertical gradient (lit from +//! above), a hairline rim, an inner top highlight, and a soft drop. Dark +//! appearances keep the structure with translucent white instead of opaque +//! fills. `light` is the neutral plate (a track), `accent` the active plate +//! (Stop, an enabled switch, a slider's fill) and `thumb` the opaque handle +//! that rides on either. +use gpui::{ + Background, Bounds, BoxShadow, Corners, Hsla, Pixels, Styled, Window, hsla, linear_color_stop, + linear_gradient, point, px, +}; + +use crate::theme::Theme; + +fn shadow(color: Hsla, y: f32, blur: f32, spread: f32, inset: bool) -> BoxShadow { + BoxShadow { + color, + offset: point(px(0.0), px(y)), + blur_radius: px(blur), + spread_radius: px(spread), + inset, + } +} + +fn white(alpha: f32) -> Hsla { + hsla(0.0, 0.0, 1.0, alpha) +} + +fn black(alpha: f32) -> Hsla { + hsla(0.0, 0.0, 0.0, alpha) +} + +fn vertical(top: Hsla, bottom: Hsla) -> Background { + linear_gradient( + 180.0, + linear_color_stop(top, 0.0), + linear_color_stop(bottom, 1.0), + ) +} + +/// A lighter tint of `color` for the lit top of a gradient. +fn lift(color: Hsla, amount: f32) -> Hsla { + crate::motion::mix(color, white(color.a), amount) +} + +/// One glass surface, applicable to an element or paintable in a canvas. +pub(crate) struct Plate { + background: Background, + rim: Hsla, + shadows: Vec, +} + +impl Plate { + pub(crate) fn apply(self, el: E) -> E { + el.bg(self.background) + .border_1() + .border_color(self.rim) + .shadow(self.shadows) + } + + /// Paints the plate the way a styled element would: drops beneath the + /// fill, the rim on it, and inset light above it. + pub(crate) fn paint(&self, window: &mut Window, bounds: Bounds, radius: f32) { + let corners = Corners::all(px(radius)); + let (inset, drop): (Vec<_>, Vec<_>) = self.shadows.iter().cloned().partition(|s| s.inset); + window.paint_drop_shadows(bounds, corners, &drop); + window.paint_quad(gpui::quad( + bounds, + corners, + self.background, + px(1.0), + self.rim, + gpui::BorderStyle::default(), + )); + window.paint_inset_shadows(bounds, corners, &inset); + } +} + +/// Neutral glass. `t` fades the whole treatment in. Dark appearances lift +/// translucent white off the surface; light ones sink a translucent tint +/// into it (shaded under the top edge, lit inside the bottom edge), since a +/// near-white plate on a near-white surface washes out. +pub(crate) fn light_plate(theme: &Theme, t: f32) -> Plate { + if theme.appearance.is_dark() { + return Plate { + background: vertical(white(0.08 * t), white(0.05 * t)), + rim: white(0.09 * t), + shadows: vec![ + shadow(white(0.07 * t), 1.0, 0.0, 0.0, true), + shadow(black(0.16 * t), 1.0, 2.0, 0.0, false), + ], + }; + } + Plate { + background: vertical(black(0.075 * t), black(0.04 * t)), + rim: black(0.08 * t), + // Inset only: GPUI paints drop shadows under the whole box, so an + // outer lip would show through the translucent fill and whiten it. + shadows: vec![ + shadow(black(0.08 * t), 1.0, 2.0, 0.0, true), + shadow(white(0.55 * t), -1.0, 0.0, 0.0, true), + ], + } +} + +/// Accent glass. `t` fades it in over the resting control; `glow` (0–1) +/// spreads its coloured shadow. +pub(crate) fn accent_plate(theme: &Theme, t: f32, glow: f32) -> Plate { + let dark = theme.appearance.is_dark(); + // The theme's fill token, as every other accent fill uses. Dark plates + // keep their lighting faint so the token reads as itself, not a pastel. + let base = theme.accent_strong; + let top = lift(base, if dark { 0.06 } else { 0.22 }); + // Light: a defined edge a shade deeper than the fill, with a paler ring + // just inside it. Dark: a soft lifted rim. + let rim = if dark { + lift(base, 0.35).opacity(0.35) + } else { + crate::motion::mix(base, black(1.0), 0.14) + }; + let highlight = white(if dark { 0.12 } else { 0.32 }); + let ring = white(if dark { 0.0 } else { 0.22 }); + // Dark plates sit flat at rest; light ones keep a faint coloured glow. + let halo = base.opacity((if dark { 0.0 } else { 0.14 } + 0.3 * glow) * t); + Plate { + background: vertical(top.opacity(t), base.opacity(t)), + rim: rim.opacity(t), + shadows: vec![ + shadow(highlight.opacity(t), 1.0, 0.0, 0.0, true), + shadow(ring.opacity(t), 0.0, 0.0, 1.0, true), + shadow(halo, 2.0 + 2.0 * glow, 6.0 + 14.0 * glow, 0.0, false), + ], + } +} + +/// The opaque handle: the light-appearance neutral plate in every +/// appearance, so it stays solid over translucent dark glass, with the +/// appearance's own drop beneath it. +pub(crate) fn thumb_plate(theme: &Theme) -> Plate { + let dark = theme.appearance.is_dark(); + let mut shadows = vec![ + shadow(white(0.8), 1.0, 0.0, 0.0, true), + shadow(black(if dark { 0.22 } else { 0.14 }), 1.0, 2.0, 0.0, false), + ]; + if !dark { + // A wide, faint ambient drop separates it from pale tracks. + shadows.push(shadow(black(0.06), 2.0, 6.0, 0.0, false)); + } + Plate { + background: vertical(hsla(0.0, 0.0, 1.0, 1.0), hsla(0.0, 0.0, 0.965, 1.0)), + rim: black(if dark { 0.12 } else { 0.11 }), + shadows, + } +} + +pub(crate) fn light(el: E, theme: &Theme, t: f32) -> E { + light_plate(theme, t).apply(el) +} + +pub(crate) fn thumb(el: E, theme: &Theme) -> E { + thumb_plate(theme).apply(el) +} diff --git a/crates/ui/src/haptics.rs b/crates/ui/src/haptics.rs new file mode 100644 index 0000000000..ae2ae19b03 --- /dev/null +++ b/crates/ui/src/haptics.rs @@ -0,0 +1,29 @@ +//! Discrete native feedback for user-driven control detents. + +pub(crate) fn selection_step() { + #[cfg(all(target_os = "macos", not(test)))] + { + use objc2_app_kit::{ + NSHapticFeedbackManager, NSHapticFeedbackPattern, NSHapticFeedbackPerformanceTime, + NSHapticFeedbackPerformer, + }; + // UI input handlers run on the main thread. AppKit gracefully does + // nothing on devices without a haptic-capable trackpad. + if objc2::MainThreadMarker::new().is_some() { + NSHapticFeedbackManager::defaultPerformer().performFeedbackPattern_performanceTime( + NSHapticFeedbackPattern::LevelChange, + NSHapticFeedbackPerformanceTime::Now, + ); + } + } + #[cfg(test)] + STEPS.with(|steps| steps.set(steps.get() + 1)); +} + +#[cfg(test)] +thread_local! { static STEPS: std::cell::Cell = const { std::cell::Cell::new(0) }; } + +#[cfg(test)] +pub(crate) fn step_count() -> usize { + STEPS.with(|steps| steps.get()) +} diff --git a/crates/ui/src/icons.rs b/crates/ui/src/icons.rs index f747fec383..7a6bf92c5b 100644 --- a/crates/ui/src/icons.rs +++ b/crates/ui/src/icons.rs @@ -16,7 +16,10 @@ use std::borrow::Cow; -use gpui::{AssetSource, Hsla, Result, SharedString, Styled as _, Svg, svg}; +use gpui::{ + AssetSource, Div, Hsla, ParentElement as _, Result, SharedString, Styled as _, Svg, div, px, + svg, +}; macro_rules! icon_assets { ($(($const_name:ident, $path:literal)),+ $(,)?) => { @@ -50,8 +53,10 @@ macro_rules! icon_assets { icon_assets![ (PROJECT_DEFAULT, "project-default"), (REMOTE_SERVER, "remote-server"), - // Service-tier bolt, drawn in the toolbar family's linear weight. + // Service-tier bolt, drawn in the toolbar family's linear weight; the + // filled twin marks fast mode on. (FAST_TIER, "fast-tier"), + (FAST_TIER_BOLD, "fast-tier-bold"), // Solar Icons (Linear), CC BY 4.0 — 480 Design. (MONITOR, "monitor"), (SUN, "sun"), @@ -80,26 +85,28 @@ icon_assets![ (FOLDER_WITH_FILES, "folder-with-files"), // Original tree glyph with compact nodes for the independent Files panel. (FILE_TREE, "file-tree"), - (FOLDER, "folder"), // Hand-drawn floppy disk in the Solar Linear style. Workspace editor save. (FLOPPY_DISK, "floppy-disk"), - // Hand-drawn git-branch glyph in the Solar Linear style (like the - // terminal/plus/return ports) — the set has no branch icon. + // Zeron Icons (icons.zeron.sh): 24px canvas, 1.75px round strokes. The + // git family shares rails at x=6/18 and 2.25-radius nodes; the carets + // and arrows are the site's one path pre-rotated per direction. + (FOLDER, "folder"), + (FORK, "fork"), (GIT_BRANCH, "git-branch"), - // Provider-neutral pull-request glyph, drawn in the same linear family. (PULL_REQUEST, "pull-request"), + (WORKTREE, "worktree"), + (PLUS, "plus"), + (ARROW_LEFT, "arrow-left"), + (ARROW_RIGHT, "arrow-right"), + (ALT_ARROW_DOWN, "alt-arrow-down"), + (ALT_ARROW_UP, "alt-arrow-up"), + (ALT_ARROW_LEFT, "alt-arrow-left"), + (ALT_ARROW_RIGHT, "alt-arrow-right"), // Compact history-ref glyphs, drawn in the same linear style. (CLOUD, "cloud"), (TAG, "tag"), - (SIDEBAR_MINIMALISTIC, "sidebar-minimalistic"), - // Mirrored variant (zeron window-controls.tsx `-scale-x-100`): the LEFT - // sidebar toggle shows the panel line on the left; gpui divs have no - // scale transform at the pinned rev, so the flip is baked into the asset. - (SIDEBAR_MINIMALISTIC_LEFT, "sidebar-minimalistic-left"), (KEY_MINIMALISTIC, "key-minimalistic"), (KEYBOARD, "keyboard"), - (ARROW_LEFT, "arrow-left"), - (ARROW_RIGHT, "arrow-right"), (ARROW_UP, "arrow-up"), // arrow-up mirrored (like the sidebar flip) — the Solar Linear set here // has no plain arrow-down. @@ -110,11 +117,6 @@ icon_assets![ // Hand-drawn return/enter arrow in the Solar Linear style (like the // terminal/plus/close ports) — the set has no return glyph. (RETURN, "return"), - (ALT_ARROW_DOWN, "alt-arrow-down"), - // alt-arrow-down mirrored (drawn as its twin: same stroke, caps, joints) — - // the embedded Solar Linear set ships no up chevron. The collapse half of - // the long-user-message expander. - (ALT_ARROW_UP, "alt-arrow-up"), // Hand-drawn expand/maximize arrows in the Solar Linear style (like the // terminal/plus/return ports) — the set has no expand glyph. (EXPAND_ARROWS, "expand-arrows"), @@ -128,8 +130,6 @@ icon_assets![ (SPLIT_COLUMNS, "split-columns"), // Long-line wrapping toggle shared by changes and agent Markdown fences. (WRAP_TEXT, "wrap-text"), - (ALT_ARROW_LEFT, "alt-arrow-left"), - (ALT_ARROW_RIGHT, "alt-arrow-right"), (SMARTPHONE, "smartphone"), (ARCHIVE_UP_MINIMALISTIC, "archive-up-minimalistic"), (REFRESH, "refresh"), @@ -160,6 +160,8 @@ icon_assets![ (FILE_DATA, "file-data"), (FILE_MARKDOWN, "file-markdown"), (FILE_IMAGE, "file-image"), + // A framed picture: the icon of `Image N` chips. + (GALLERY, "gallery"), (GLOBAL, "global"), (CHECKLIST, "checklist"), (WIDGET, "widget"), @@ -178,10 +180,13 @@ icon_assets![ // plus/return ports) — the embedded set has neither. (BELL, "bell"), (VOLUME_LOUD, "volume-loud"), + // Hand-drawn microphone pair in the Solar Linear style — voice controls. + (MICROPHONE, "microphone"), + (MICROPHONE_OFF, "microphone-off"), + (PHONE_HANG_UP, "phone-hang-up"), // Hand-drawn zeron glyphs (terminal-panel.tsx / composer-actions.tsx / // menu-check.tsx / logo.tsx). (TERMINAL, "terminal"), - (PLUS, "plus"), (CLOSE, "close"), // Hand-drawn Linux caption glyphs (minimize dash, maximize square, // restore stacked squares) in the same style as `close` — drawn for the @@ -255,6 +260,47 @@ pub fn icon(path: &'static str) -> Svg { svg().path(path).flex_none() } +/// The Zeron Icons sidebar glyph (icons.zeron.sh "Sidebar" / "Right +/// sidebar"): a rounded frame holding a panel whose width morphs 5.5 → 1.75 +/// as the sidebar closes. gpui SVGs are static, so it is drawn from quads in +/// the source's 24-unit space, scaled to `size`. `open` is the morph progress +/// (1 = open; drive it with [`crate::motion::state_t`]); `right` mirrors it. +pub fn sidebar_glyph(open: f32, right: bool, size: f32, color: Hsla) -> Div { + let s = size / 24.0; + // ``: the centered stroke + // grows the box by half the stroke on each side. + let stroke = 1.75; + let frame = div() + .absolute() + .left(px((3.0 - stroke / 2.0) * s)) + .top(px((4.0 - stroke / 2.0) * s)) + .w(px((18.0 + stroke) * s)) + .h(px((16.0 + stroke) * s)) + .rounded(px((4.0 + stroke / 2.0) * s)) + .border(px(stroke * s)) + .border_color(color); + // ``; closed: w=1.75. + let inset = px(6.5 * s); + let panel = div() + .absolute() + .top(px(7.5 * s)) + .w(px(crate::motion::lerp(1.75, 5.5, open.clamp(0.0, 1.0)) * s)) + .h(px(9.0 * s)) + .rounded(px(0.875 * s)) + .bg(color); + let panel = if right { + panel.right(inset) + } else { + panel.left(inset) + }; + div() + .relative() + .flex_none() + .size(px(size)) + .child(frame) + .child(panel) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/ui/src/image_media.rs b/crates/ui/src/image_media.rs index f89605df8e..dc8b08543f 100644 --- a/crates/ui/src/image_media.rs +++ b/crates/ui/src/image_media.rs @@ -60,6 +60,12 @@ fn raster_size( size } +/// Memory a prepared SVG holds: its source and wrapper copies, plus both the +/// CPU pixels and GPU texture of one raster. +fn svg_retained_bytes(svg: &str, raster: (u32, u32)) -> usize { + svg.len() * 2 + 1024 + raster.0 as usize * raster.1 as usize * 8 +} + impl MediaImage { /// Preserve the sanitized vector source; only the outer raster viewport changes. pub(crate) fn for_view(&self, viewport: (f32, f32), dpi: f32, pixels: usize) -> Self { @@ -82,14 +88,25 @@ impl MediaImage { image: Arc::new(Image::from_bytes(ImageFormat::Svg, wrapper.into_bytes())), width: self.width, height: self.height, - bytes: self - .bytes - .max(svg.len() * 2 + 1024 + size.0 as usize * size.1 as usize * 8), + bytes: svg_retained_bytes(svg, size), svg: self.svg.clone(), raster_size: Some(size), } } + /// Re-rasterize for a new view only when the variant fits the memory the + /// owner can still spend (`available`, excluding this media). A larger + /// raster that does not fit keeps the current one: slightly softer, never + /// over budget. + pub(crate) fn preview_within(&self, viewport: (f32, f32), dpi: f32, available: usize) -> Self { + let next = self.preview_for_view(viewport, dpi); + if next.bytes > self.bytes && next.bytes > available { + self.clone() + } else { + next + } + } + pub(crate) fn preview_for_view(&self, viewport: (f32, f32), dpi: f32) -> Self { self.for_view(viewport, dpi, PREVIEW_PIXELS) } @@ -117,6 +134,36 @@ impl MediaImage { } } +/// Prepared media centered at its natural size within the reading column, +/// capped at 480px tall. A click requests the enlarged lightbox. +pub(crate) fn preview_element( + loaded: &MediaImage, + id: gpui::SharedString, + on_click: impl Fn(&mut gpui::Window, &mut gpui::App) + 'static, +) -> gpui::AnyElement { + use gpui::{InteractiveElement as _, IntoElement as _, StyledImage as _, prelude::*}; + gpui::div() + .id(id) + .w_full() + .max_w(gpui::px(loaded.width)) + .mx_auto() + .max_h(gpui::px(480.0)) + .aspect_ratio(loaded.width / loaded.height) + .cursor_pointer() + .role(gpui::Role::Button) + .aria_label("Enlarge image") + .on_click(move |_, window, cx| { + cx.stop_propagation(); + on_click(window, cx); + }) + .child( + gpui::img(loaded.image.clone()) + .size_full() + .object_fit(gpui::ObjectFit::Contain), + ) + .into_any_element() +} + pub(crate) fn svg_options() -> usvg::Options<'static> { static FONTS: OnceLock> = OnceLock::new(); let fonts = FONTS @@ -157,15 +204,14 @@ pub(crate) fn decode_image(mime: &str, bytes: Vec) -> Result zeron_proto::MAX_WORKSPACE_IMAGE_BYTES { return Err("Prepared SVG exceeds preview size limit".into()); } - let maximum = raster_size(width, height, (900.0, 480.0), 4.0, PREVIEW_PIXELS); - // Reserve the largest admitted preview across supported display densities, - // including both CPU pixels and GPU texture, plus source and wrapper. - let retained = svg.len() * 2 + 1024 + maximum.0 as usize * maximum.1 as usize * 8; + // Account the raster that exists, not the largest one any view could + // request: owners re-check their budget before a larger re-raster + // (`MediaImage::preview_within`). let media = MediaImage { image: Arc::new(Image::from_bytes(ImageFormat::Svg, Vec::new())), width, height, - bytes: retained, + bytes: svg_retained_bytes(&svg, (0, 0)), svg: Some(Arc::from(svg)), raster_size: None, }; @@ -208,6 +254,16 @@ pub(crate) fn decode_raster_image(bytes: Vec, max_bytes: usize) -> Result image::Limits { + let mut limits = image::Limits::default(); + limits.max_image_width = Some(4096); + limits.max_image_height = Some(4096); + limits.max_alloc = Some(64 * 1024 * 1024); + limits +} + fn decode_raster_image_bounded( bytes: Vec, max_bytes: usize, @@ -219,11 +275,7 @@ fn decode_raster_image_bounded( let mut reader = image::ImageReader::new(Cursor::new(bytes)) .with_guessed_format() .map_err(|e| e.to_string())?; - let mut limits = image::Limits::default(); - limits.max_image_width = Some(4096); - limits.max_image_height = Some(4096); - limits.max_alloc = Some(64 * 1024 * 1024); - reader.limits(limits); + reader.limits(raster_limits()); // Decode one frame and encode a static PNG so GPUI cannot expand unbounded animation frames. let decoded = reader.decode().map_err(|e| e.to_string())?; // Generated previews retain one bounded 8-bit frame. This keeps each @@ -351,6 +403,32 @@ mod tests { } } + #[test] + fn svg_accounting_follows_the_current_raster_and_upgrades_respect_budget() { + let media = decode_image( + "image/svg+xml", + br#""#.to_vec(), + ) + .unwrap(); + let exact = |m: &MediaImage| { + let (w, h) = m.raster_size.unwrap(); + m.svg.as_ref().unwrap().len() * 2 + 1024 + w as usize * h as usize * 8 + }; + // A small diagram no longer reserves the largest preview any view + // could ask for (900x480 at 4x). + assert_eq!(media.bytes, exact(&media)); + assert!(media.bytes < 1024 * 1024); + let sharper = media.preview_within((900.0, 480.0), 4.0, usize::MAX); + assert!(sharper.bytes > media.bytes); + assert_eq!(sharper.bytes, exact(&sharper)); + // Without room for the larger raster, the current one stays. + let held = media.preview_within((900.0, 480.0), 4.0, media.bytes); + assert!(Arc::ptr_eq(&held.image, &media.image)); + // A smaller raster frees memory and is always taken. + let smaller = sharper.preview_within((100.0, 50.0), 1.0, 0); + assert!(smaller.bytes < sharper.bytes); + } + #[test] fn extreme_aspect_ratios_stay_inside_the_pixel_budget() { for (w, h) in [(1e20, 1.0), (1.0, 1e20)] { diff --git a/crates/ui/src/image_viewer.rs b/crates/ui/src/image_viewer.rs index b36b337557..10bbbda8a3 100644 --- a/crates/ui/src/image_viewer.rs +++ b/crates/ui/src/image_viewer.rs @@ -207,6 +207,7 @@ impl ImageView { image: Arc, natural: Size, on_image_click: Option, + plate: Option, _window: &mut Window, _cx: &mut App, ) -> AnyElement { @@ -283,6 +284,18 @@ impl ImageView { on_click(window, cx); } }) + .when_some(plate, |viewport, plate| { + viewport.child( + div() + .absolute() + .left(px(origin.x)) + .top(px(origin.y)) + .w(px(natural.width * geometry.scale)) + .h(px(natural.height * geometry.scale)) + .rounded(px(10.0)) + .bg(plate), + ) + }) .child( gpui::img(image) .absolute() diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index 79a33d9886..101b3ea204 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -28,10 +28,13 @@ pub mod composer; mod composer_dock; mod composer_markdown; mod context_usage; +mod dictation; pub mod edge_fade; pub mod file_icons; pub mod files; pub mod frost; +mod glass; +mod haptics; pub mod history; pub mod icons; pub(crate) mod image_media; @@ -45,11 +48,14 @@ mod new_thread_background_image; mod new_thread_background_mask; mod notice; pub mod notify; +pub mod orb; pub mod pickers; pub mod popover; pub mod project_actions; pub mod queue; pub mod rail; +mod roll_text; +pub mod running_pill; pub mod settings; pub mod shell; pub mod sound; @@ -57,10 +63,13 @@ pub mod state; pub(crate) mod surface_chrome; pub mod syntax_cache; pub mod terminal; +mod todo_panel; +pub(crate) mod tool_images; pub mod theme; pub mod theme_library; pub mod transcript; pub mod typography; +pub mod voice; mod workspace_links; use std::path::PathBuf; @@ -193,7 +202,11 @@ pub fn run_app(config: UiConfig) { app_update::AppUpdate::init(config.boot().edge_url, data_dir.clone(), cx); cx.register_url_scheme("zeron").detach(); - let state = cx.new(|_| state::AppState::new()); + let state = cx.new(|cx| { + let mut state = state::AppState::new(); + state.watch_clock_transitions(cx); + state + }); let url_state = state.clone(); cx.spawn(async move |cx| { while let Some(url) = url_rx.next().await { diff --git a/crates/ui/src/loaders.rs b/crates/ui/src/loaders.rs index 182b45fe36..263c5dc77f 100644 --- a/crates/ui/src/loaders.rs +++ b/crates/ui/src/loaders.rs @@ -6,7 +6,9 @@ //! come from [`motion::staggered_phase`], so all cells stay phase-locked. //! Cells animate inside fixed-size slots — opacity and inner size //! are paint-local and never move surrounding layout. Reduced motion snaps every -//! cell to its rest state automatically (gpui `reduce_motion`). +//! cell to its rest state automatically (gpui `reduce_motion`). Activity grids +//! retain a gentle brightness pulse with system reduced motion; explicit On +//! and background pause keep them still. use gpui::{ AnyElement, App, AppContext, Context, Entity, EntityId, IntoElement, ParentElement, @@ -121,7 +123,7 @@ pub fn gradient_spinner( ) -> impl IntoElement { let center = (MATRIX_SIDE as f32 - 1.0) / 2.0; let max = MATRIX_SIDE as f32 - 1.0 + center; - let delta = motion::pulse_delta_slow(&GRADIENT_SPIN, view, cx); + let pulse = motion::activity_pulse_slow(view, cx); div() .flex() .flex_col() @@ -141,7 +143,7 @@ pub fn gradient_spinner( .size(px(cell_px)) .rounded(px(cell_px / 2.0)) .bg(tint) - .opacity(motion::gspin_opacity(delta + phase, GSPIN_DIM)) + .opacity(pulse.opacity(phase, GSPIN_DIM)) })) })) } @@ -246,7 +248,7 @@ fn mini_spinner_cells( /// (0,0) → (0,1) → (1,1) → (2,1) → (2,0) → (1,0). const RING: [[usize; COLS]; ROWS] = [[0, 1], [5, 2], [4, 3]]; const RING_LEN: f32 = (COLS * ROWS) as f32; - let delta = motion::pulse_delta(&GRADIENT_SPIN, view, cx); + let pulse = motion::activity_pulse(view, cx); div() .flex() .flex_col() @@ -263,11 +265,88 @@ fn mini_spinner_cells( .size(px(cell_px)) .rounded(px(cell_px / 2.0)) .bg(tint) - .opacity(motion::gspin_opacity(delta + phase, GSPIN_DIM)) + .opacity(pulse.opacity(phase, GSPIN_DIM)) })) })) } +/// A rounded fill that breathes on the zeron pulse clock between `floor` and +/// `peak` opacity, filling whatever positioned box it is placed in. Like [`pulse_dot`] it repaints from its own cached +/// view, so the surrounding chrome stays still. +pub fn pulse_glow( + key: impl Into, + radius: f32, + tint: gpui::Hsla, + floor: f32, + peak: f32, +) -> impl IntoElement { + PulseGlow { + key: key.into(), + style: GlowStyle { + radius, + tint, + floor, + peak, + }, + } +} + +#[derive(Clone, Copy, PartialEq)] +struct GlowStyle { + radius: f32, + tint: gpui::Hsla, + floor: f32, + peak: f32, +} + +#[derive(IntoElement)] +struct PulseGlow { + key: SharedString, + style: GlowStyle, +} + +impl RenderOnce for PulseGlow { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let style = self.style; + let view = window.with_global_id(self.key.into(), |id, window| { + window.with_element_state(id, |previous: Option>, _| { + let view = previous.unwrap_or_else(|| cx.new(|_| PulseGlowView { style })); + view.update(cx, |view, cx| { + if view.style != style { + view.style = style; + cx.notify(); + } + }); + (view.clone(), view) + }) + }); + view.cached(gpui::StyleRefinement::default().size_full()) + } +} + +struct PulseGlowView { + style: GlowStyle, +} + +impl Render for PulseGlowView { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let GlowStyle { + radius, + tint, + floor, + peak, + } = self.style; + let delta = motion::pulse_delta_slow(&ZERON_PULSE, cx.entity_id(), cx); + // pulse_opacity spans 0.08..1; renormalise to 0..1 before mapping. + let wave = (motion::pulse_opacity(delta) - 0.08) / 0.92; + div() + .size_full() + .rounded(px(radius)) + .bg(tint) + .opacity(motion::lerp(floor, peak, wave)) + } +} + /// Stroke width of [`upload_progress_ring`]. const RING_STROKE: f32 = 2.5; /// Polyline segments for a full circle — plenty for a ≤40px ring. @@ -281,8 +360,54 @@ const RING_SEGMENTS: f32 = 64.0; /// polylines. Fixed white-on-wash palette: the caller dims the image behind /// it, which reads in both themes. pub fn upload_progress_ring(percent: u8, diameter: f32) -> AnyElement { + let ring = progress_arc( + percent, + diameter, + gpui::hsla(0.0, 0.0, 1.0, 0.22), + gpui::hsla(0.0, 0.0, 1.0, 0.95), + ); + div() + .relative() + .size(px(diameter)) + .flex() + .items_center() + .justify_center() + .child(ring) + .child( + div() + .text_size(px(9.0)) + .font_weight(gpui::FontWeight::SEMIBOLD) + .text_color(gpui::hsla(0.0, 0.0, 1.0, 0.95)) + .child(SharedString::from(format!("{percent}%"))), + ) + .into_any_element() +} + +/// [`upload_progress_ring`] without the percent, in the caller's colors: for +/// a spot too small to label, such as a sent chip's icon well. +pub fn upload_progress_arc( + percent: u8, + diameter: f32, + track: gpui::Hsla, + arc: gpui::Hsla, +) -> AnyElement { + div() + .relative() + .size(px(diameter)) + .child(progress_arc(percent, diameter, track, arc)) + .into_any_element() +} + +/// A faint full track plus an arc growing clockwise from 12 o'clock, filling +/// its parent. +fn progress_arc( + percent: u8, + diameter: f32, + track: gpui::Hsla, + arc: gpui::Hsla, +) -> gpui::Canvas<()> { let frac = f32::from(percent.min(100)) / 100.0; - let ring = canvas( + canvas( |_, _, _| (), move |bounds, _, window, _| { let center = bounds.center(); @@ -310,27 +435,12 @@ pub fn upload_progress_ring(percent: u8, diameter: f32) -> AnyElement { window.paint_path(path, color); } }; - paint_arc(1.0, gpui::hsla(0.0, 0.0, 1.0, 0.22)); - paint_arc(frac, gpui::hsla(0.0, 0.0, 1.0, 0.95)); + paint_arc(1.0, track); + paint_arc(frac, arc); }, ) .absolute() - .inset_0(); - div() - .relative() - .size(px(diameter)) - .flex() - .items_center() - .justify_center() - .child(ring) - .child( - div() - .text_size(px(9.0)) - .font_weight(gpui::FontWeight::SEMIBOLD) - .text_color(gpui::hsla(0.0, 0.0, 1.0, 0.95)) - .child(SharedString::from(format!("{percent}%"))), - ) - .into_any_element() + .inset_0() } /// Full-window boot splash: the app's dot loader (the same [`gradient_spinner`] diff --git a/crates/ui/src/markdown/mermaid.rs b/crates/ui/src/markdown/mermaid.rs index 9cce173a6c..a22b6ea4df 100644 --- a/crates/ui/src/markdown/mermaid.rs +++ b/crates/ui/src/markdown/mermaid.rs @@ -8,16 +8,23 @@ pub const MAX_SOURCE_BYTES: usize = 16 * 1024; // CPU work across previews; UI owners discard results from superseded revisions. static RENDER_LOCK: Mutex<()> = Mutex::new(()); +/// Zeron's diagram style. The canvas is left transparent so the diagram sits +/// on its fence body; `canvas` is that body's opaque approximation, used where +/// the engine needs a solid mask (edge label pills, hollow markers). #[derive(Clone)] pub struct Palette { dark: bool, font: String, - background: String, - raised: String, + canvas: String, + node: String, + group: String, text: String, - muted: String, + label: String, + line: String, border: String, - accent: String, + grid: String, + accent_line: String, + accent_wash: String, } fn color(color: gpui::Hsla, background: gpui::Hsla) -> String { @@ -36,17 +43,36 @@ fn color(color: gpui::Hsla, background: gpui::Hsla) -> String { impl Palette { pub fn from_theme(theme: &Theme) -> Self { + let dark = theme.appearance.is_dark(); + let canvas = Self::plate(theme); + // Nodes are cards lifted off the fence: white in light, one ink step + // up in dark, where the panel is already the deepest plane. + let node = if dark { + canvas.blend(theme.ink(0.06)) + } else { + theme.bg + }; Self { - dark: theme.appearance.is_dark(), + dark, font: theme.font_sans.to_string(), - background: color(theme.surface, theme.bg), - raised: color(theme.surface_raised, theme.bg), - text: color(theme.text, theme.bg), - muted: color(theme.text_muted, theme.bg), - border: color(theme.border_strong, theme.bg), - accent: color(theme.accent, theme.bg), + canvas: color(canvas, theme.bg), + node: color(node, canvas), + group: color(theme.ink(0.03), canvas), + text: color(theme.text, node), + label: color(theme.text_muted, canvas), + line: color(theme.text_faint, canvas), + border: color(theme.border_strong, canvas), + grid: color(theme.border, canvas), + accent_line: color(theme.accent.opacity(0.6), canvas), + accent_wash: color(theme.accent.opacity(0.12), node), } } + + /// The fence body, an ink wash over the panel (see `code_block_frame`). + /// Also the fill behind a diagram shown off its fence, as in the lightbox. + pub fn plate(theme: &Theme) -> gpui::Hsla { + theme.bg.blend(theme.ink(0.035)) + } } pub fn render(source: &str, palette: &Palette) -> Result { @@ -67,29 +93,42 @@ pub fn render(source: &str, palette: &Palette) -> Result { } else { mermaid_rs_renderer::Theme::modern() }; + // A denser layout than the engine's default: diagrams are scaled to the + // reading column, so slack spacing shrinks the labels. + options.layout.node_spacing = 36.0; + options.layout.rank_spacing = 40.0; + options.layout.node_padding_x = 18.0; + options.layout.node_padding_y = 10.0; let theme = &mut options.theme; theme.font_family = palette.font.clone(); theme.font_size = 14.0; - theme.background = palette.background.clone(); - theme.primary_color = palette.raised.clone(); + theme.background = palette.canvas.clone(); + theme.primary_color = palette.node.clone(); theme.primary_text_color = palette.text.clone(); theme.primary_border_color = palette.border.clone(); - theme.text_color = palette.text.clone(); - theme.line_color = palette.muted.clone(); - theme.secondary_color = palette.raised.clone(); - theme.tertiary_color = palette.raised.clone(); - theme.edge_label_background = palette.background.clone(); - theme.cluster_background = palette.background.clone(); + theme.text_color = palette.label.clone(); + theme.line_color = palette.line.clone(); + theme.secondary_color = palette.node.clone(); + theme.tertiary_color = palette.group.clone(); + theme.edge_label_background = palette.canvas.clone(); + theme.cluster_background = palette.group.clone(); theme.cluster_border = palette.border.clone(); - theme.sequence_actor_fill = palette.raised.clone(); + theme.sequence_actor_fill = palette.node.clone(); theme.sequence_actor_border = palette.border.clone(); - theme.sequence_actor_line = palette.muted.clone(); - theme.sequence_note_fill = palette.raised.clone(); - theme.sequence_note_border = palette.border.clone(); - theme.sequence_activation_fill = palette.accent.clone(); - theme.sequence_activation_border = palette.border.clone(); + theme.sequence_actor_line = palette.border.clone(); + theme.sequence_note_fill = palette.accent_wash.clone(); + theme.sequence_note_border = palette.accent_line.clone(); + theme.sequence_activation_fill = palette.accent_wash.clone(); + theme.sequence_activation_border = palette.accent_line.clone(); + if diagram_keyword(source) == Some("gantt") { + // Gantt derives its bar hues from this color; a neutral gray would + // turn every section red. The softened accent keeps bar labels + // legible in both appearances. + theme.primary_border_color = palette.accent_line.clone(); + } let svg = mermaid_rs_renderer::render_with_options(source, options).map_err(|e| e.to_string())?; + let svg = restyle(svg, palette); if svg.len() > 2 * 1024 * 1024 { return Err("Diagram output exceeds preview size limit".into()); } @@ -98,6 +137,128 @@ pub fn render(source: &str, palette: &Palette) -> Result { .unwrap_or_else(|_| Err("Diagram could not be rendered".into())) } +/// The diagram type: the first word after any front matter and comments. +fn diagram_keyword(source: &str) -> Option<&str> { + let mut lines = source + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .peekable(); + if lines.next_if_eq(&"---").is_some() { + lines.by_ref().find(|line| *line == "---"); + } + lines + .find(|line| !line.starts_with("%%")) + .and_then(|line| line.split_whitespace().next()) +} + +/// Finishing touches the engine's theme cannot express, applied to its +/// pinned output: a transparent canvas, softer node corners, cards for +/// boxes the engine fills with its background, decisions in the accent and +/// themed Gantt gridlines. Only elements still carrying the palette's own +/// colors change, so explicit `style`/`classDef` colors survive. +fn restyle(svg: String, palette: &Palette) -> String { + let canvas_fill = format!(" fill=\"{}\"", palette.canvas); + let node_fill = format!(" fill=\"{}\"", palette.node); + let border_stroke = format!(" stroke=\"{}\"", palette.border); + let mut out = String::with_capacity(svg.len()); + let mut rest = svg.as_str(); + while let Some(start) = rest.find('<') { + out.push_str(&rest[..start]); + rest = &rest[start..]; + let Some(end) = rest.find('>') else { break }; + let tag = &rest[..=end]; + rest = &rest[end + 1..]; + if is_canvas(tag, palette) { + continue; + } + if tag.starts_with(" Vec<(&str, &str)> { + let parts: Vec<_> = tag.split('"').collect(); + parts + .as_chunks::<2>() + .0 + .iter() + .filter_map(|[name, value]| { + let name = name.trim_end().strip_suffix('=')?; + Some((name.rsplit(' ').next()?, *value)) + }) + .collect() +} + +/// A full-bleed background: a plain rectangle in the canvas color. +fn is_canvas(tag: &str, palette: &Palette) -> bool { + if !tag.starts_with(" {} + "fill" => fill = Some(value), + _ => return false, + } + } + fill == Some(palette.canvas.as_str()) +} + +fn is_default_diamond(tag: &str, palette: &Palette) -> bool { + let attrs = attributes(tag); + let get = |key: &str| attrs.iter().find(|(name, _)| *name == key).map(|(_, v)| *v); + if get("fill") != Some(palette.node.as_str()) || get("stroke") != Some(palette.border.as_str()) + { + return false; + } + let Some(points) = get("points") else { + return false; + }; + let points: Vec<(f32, f32)> = points + .split_whitespace() + .filter_map(|point| { + let (x, y) = point.split_once(',')?; + Some((x.parse().ok()?, y.parse().ok()?)) + }) + .collect(); + let [top, right, bottom, left] = points[..] else { + return false; + }; + (top.0 - bottom.0).abs() < 0.05 + && (left.1 - right.1).abs() < 0.05 + && left.0 < top.0 + && top.0 < right.0 + && top.1 < left.1 + && left.1 < bottom.1 +} + #[cfg(test)] mod tests { use super::*; @@ -189,4 +350,48 @@ mod tests { assert!(render(&"x".repeat(MAX_SOURCE_BYTES + 1), &palette).is_err()); assert!(render("flowchart TD\nA[Hola
mundo] --> B[Fin]", &palette).is_ok()); } + + #[test] + fn diagrams_take_zeron_style_and_keep_explicit_colors() { + for theme in [Theme::light(), Theme::dark()] { + let palette = Palette::from_theme(&theme); + let svg = render( + "flowchart TD\nA[Inicio] --> B{¿Listo?}\nB --> C[Fin]\nB --> D{Otra}\nstyle D fill:#dbeafe,stroke:#2563eb", + &palette, + ) + .unwrap(); + // The fence body shows through: no full-bleed background remains. + assert!(!svg.contains(&format!("fill=\"{}\"/>", palette.canvas))); + assert!(svg.contains(" rx=\"8\" ry=\"8\" ")); + assert!(!svg.contains(" rx=\"3\" ry=\"3\" ")); + let diamonds: Vec<_> = svg.match_indices("= 2); + assert_eq!(accented, 1, "only the default-colored decision is tinted"); + assert!(svg.contains("fill=\"#dbeafe\" stroke=\"#2563eb\"")); + + let gantt = render( + "%% plan\ngantt\ndateFormat YYYY-MM-DD\nsection A\nTask :2026-09-08, 2d", + &palette, + ) + .unwrap(); + assert!(!gantt.contains("#E2E8F0")); + } + } + + #[test] + fn diagram_keyword_skips_front_matter_and_comments() { + assert_eq!(diagram_keyword("gantt\n title X"), Some("gantt")); + assert_eq!( + diagram_keyword("---\ntitle: Plan\n---\n%% note\n\n gantt"), + Some("gantt") + ); + assert_eq!(diagram_keyword("flowchart LR; A-->B"), Some("flowchart")); + assert_eq!(diagram_keyword(" \n%% only"), None); + } } diff --git a/crates/ui/src/markdown/mermaid_cache.rs b/crates/ui/src/markdown/mermaid_cache.rs new file mode 100644 index 0000000000..b64f5c27e1 --- /dev/null +++ b/crates/ui/src/markdown/mermaid_cache.rs @@ -0,0 +1,366 @@ +//! Lazily rendered Mermaid diagrams for a surface that discovers its fences +//! while painting (the agent transcript). +//! +//! Rows request their fences as they lay out, so only painted diagrams cost +//! anything. The owner renders one requested source at a time on a background +//! executor and drops requests whose rows left the viewport before their turn. +//! Results are retained under a byte budget, least recently painted first out; +//! an evicted diagram simply renders again when its row returns. Diagrams +//! painted in the latest two passes are never evicted, so the budget is soft +//! only when more diagrams than it admits are on screen at once. +use crate::image_media::MediaImage; +use gpui::SharedString; +use std::collections::{HashMap, HashSet}; + +pub(crate) const MAX_RETAINED_BYTES: usize = 64 * 1024 * 1024; +const MAX_ENTRIES: usize = 64; + +pub(crate) enum Lookup { + Pending, + Ready(MediaImage), + Failed(SharedString), +} + +enum State { + Pending, + Ready(MediaImage), + Failed(SharedString), +} + +struct Entry { + state: State, + /// Paint pass that last requested this source. + used: u64, + /// Rows that painted this source, remeasured when its result lands. + rows: Vec, +} + +#[derive(Default)] +pub(crate) struct MermaidCache { + entries: HashMap, + frame: u64, + style: Option, + view: Option<((f32, f32), f32)>, + new_requests: bool, + /// Diagram frames switched to their source, keyed by frame id. + source_visible: HashSet, +} + +/// The renderer keys a diagram frame `"{row_key}-mermaid-{ix}"`. +pub(crate) fn frame_row(frame_id: &str) -> &str { + frame_id + .rsplit_once("-mermaid-") + .map_or(frame_id, |(row, _)| row) +} + +impl MermaidCache { + /// Start a paint pass. A theme change discards every diagram, since the + /// palette is baked into the generated SVG; the media is returned for + /// release. + pub(crate) fn begin_frame(&mut self, style: u32) -> Vec { + self.frame += 1; + if self.style == Some(style) { + return Vec::new(); + } + self.style = Some(style); + self.drain() + } + + /// Rasterize retained diagrams for the column width and display density. + /// Returns superseded rasters for release. + pub(crate) fn set_view(&mut self, target: (f32, f32), scale: f32) -> Vec { + if self.view == Some((target, scale)) { + return Vec::new(); + } + self.view = Some((target, scale)); + let mut retired = Vec::new(); + let mut used = self.retained_bytes(); + for entry in self.entries.values_mut() { + if let State::Ready(media) = &mut entry.state { + let others = used - media.bytes; + let next = + media.preview_within(target, scale, MAX_RETAINED_BYTES.saturating_sub(others)); + if !std::sync::Arc::ptr_eq(&media.image, &next.image) { + used = others + next.bytes; + retired.push(std::mem::replace(media, next)); + } + } + } + retired + } + + /// Look up a fence painted in the current pass, queueing it if unseen. + pub(crate) fn request(&mut self, code: &str, frame_id: &str) -> Lookup { + let frame = self.frame; + let entry = match self.entries.get_mut(code) { + Some(entry) => entry, + None => { + self.new_requests = true; + self.entries.entry(code.to_owned()).or_insert(Entry { + state: State::Pending, + used: frame, + rows: Vec::new(), + }) + } + }; + entry.used = frame; + let row = frame_row(frame_id); + if !entry.rows.iter().any(|known| known == row) { + entry.rows.push(row.to_owned().into()); + } + match &entry.state { + State::Pending => Lookup::Pending, + State::Ready(media) => Lookup::Ready(media.clone()), + State::Failed(reason) => Lookup::Failed(reason.clone()), + } + } + + /// Whether a fence was requested since the last call. + pub(crate) fn take_new_requests(&mut self) -> bool { + std::mem::take(&mut self.new_requests) + } + + /// The next source to render. Requests not repainted in the latest two + /// passes belong to rows that scrolled away; they are forgotten and + /// requested again if their row returns. Two passes, because a request + /// can be made while the current pass has only laid out some of its rows. + pub(crate) fn next_job(&mut self) -> Option { + let frame = self.frame; + self.entries.retain(|_, entry| { + !matches!(entry.state, State::Pending) || frame.saturating_sub(entry.used) <= 1 + }); + self.entries + .iter() + .filter(|(_, entry)| matches!(entry.state, State::Pending)) + .max_by_key(|(_, entry)| entry.used) + .map(|(code, _)| code.clone()) + } + + /// Store a finished render. Returns the rows that painted it (to + /// remeasure) and evicted media (to release); `None` when the result is + /// obsolete because the theme changed while it rendered. + pub(crate) fn finish( + &mut self, + code: String, + style: u32, + result: Result, + ) -> Option<(Vec, Vec)> { + if self.style != Some(style) { + return None; + } + let state = match result { + Ok(media) => State::Ready(match self.view { + Some((target, scale)) => media.preview_for_view(target, scale), + None => media, + }), + Err(reason) => State::Failed(reason.into()), + }; + let frame = self.frame; + let entry = self.entries.entry(code).or_insert(Entry { + state: State::Pending, + used: frame, + rows: Vec::new(), + }); + entry.state = state; + let rows = entry.rows.clone(); + Some((rows, self.evict())) + } + + /// Release settled diagrams least recently painted first until the + /// retained memory and entry count fit their limits. + fn evict(&mut self) -> Vec { + let mut released = Vec::new(); + let frame = self.frame; + while self.retained_bytes() > MAX_RETAINED_BYTES || self.entries.len() > MAX_ENTRIES { + let Some(code) = self + .entries + .iter() + .filter(|(_, entry)| { + !matches!(entry.state, State::Pending) && frame.saturating_sub(entry.used) > 1 + }) + .min_by_key(|(_, entry)| entry.used) + .map(|(code, _)| code.clone()) + else { + break; + }; + if let Some(Entry { + state: State::Ready(media), + .. + }) = self.entries.remove(&code) + { + released.push(media); + } + } + released + } + + pub(crate) fn retained_bytes(&self) -> usize { + self.entries + .values() + .filter_map(|entry| match &entry.state { + State::Ready(media) => Some(media.bytes), + _ => None, + }) + .sum() + } + + /// Forget every diagram, returning retained media for release. + pub(crate) fn drain(&mut self) -> Vec { + self.entries + .drain() + .filter_map(|(_, entry)| match entry.state { + State::Ready(media) => Some(media), + _ => None, + }) + .collect() + } + + pub(crate) fn source_visible(&self, frame_id: &str) -> bool { + self.source_visible.contains(frame_id) + } + + pub(crate) fn toggle_source(&mut self, frame_id: &SharedString) { + if !self.source_visible.remove(frame_id) { + self.source_visible.insert(frame_id.clone()); + } + } + + // Used by the transcript tests, which only run on Linux. + #[cfg(all(test, target_os = "linux"))] + pub(crate) fn ready_count(&self) -> usize { + self.entries + .values() + .filter(|entry| matches!(entry.state, State::Ready(_))) + .count() + } + + #[cfg(all(test, target_os = "linux"))] + pub(crate) fn ready_media(&self) -> Option { + self.entries.values().find_map(|entry| match &entry.state { + State::Ready(media) => Some(media.clone()), + _ => None, + }) + } + + pub(crate) fn has_source_toggles(&self) -> bool { + !self.source_visible.is_empty() + } + + /// Keep source toggles only for frames whose row still exists. + pub(crate) fn retain_rows(&mut self, rows: &HashSet<&str>) { + self.source_visible + .retain(|frame_id| rows.contains(frame_row(frame_id))); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn media(width: u32) -> MediaImage { + crate::image_media::decode_image( + "image/svg+xml", + format!( + r#""# + ) + .into_bytes(), + ) + .unwrap() + } + + #[test] + fn requests_queue_once_and_track_their_rows() { + let mut cache = MermaidCache::default(); + assert!(cache.begin_frame(1).is_empty()); + assert!(matches!( + cache.request("graph TD; A-->B", "chat#p.0-mermaid-3"), + Lookup::Pending + )); + assert!(matches!( + cache.request("graph TD; A-->B", "chat#p.2-mermaid-0"), + Lookup::Pending + )); + assert!(cache.take_new_requests()); + assert!(!cache.take_new_requests()); + let code = cache.next_job().unwrap(); + let (rows, released) = cache.finish(code, 1, Ok(media(80))).unwrap(); + assert_eq!(rows, ["chat#p.0", "chat#p.2"]); + assert!(released.is_empty()); + assert!(matches!( + cache.request("graph TD; A-->B", "chat#p.0-mermaid-3"), + Lookup::Ready(_) + )); + assert!(!cache.take_new_requests()); + assert!(cache.next_job().is_none()); + } + + #[test] + fn requests_that_scrolled_away_are_dropped_before_rendering() { + let mut cache = MermaidCache::default(); + cache.begin_frame(1); + cache.request("a", "row-mermaid-0"); + cache.begin_frame(1); + cache.request("b", "row2-mermaid-0"); + // `a` was painted in the previous pass: still eligible. + assert!(cache.next_job().is_some()); + cache.begin_frame(1); + cache.request("b", "row2-mermaid-0"); + assert_eq!(cache.next_job().as_deref(), Some("b")); + cache.finish("b".into(), 1, Err("bad".into())); + assert!(cache.next_job().is_none()); + assert!(matches!( + cache.request("b", "row2-mermaid-0"), + Lookup::Failed(_) + )); + } + + #[test] + fn theme_changes_discard_diagrams_and_obsolete_results() { + let mut cache = MermaidCache::default(); + cache.begin_frame(1); + cache.request("a", "row-mermaid-0"); + cache.finish("a".into(), 1, Ok(media(80))); + assert_eq!(cache.begin_frame(2).len(), 1); + assert!(matches!( + cache.request("a", "row-mermaid-0"), + Lookup::Pending + )); + assert!(cache.finish("a".into(), 1, Ok(media(80))).is_none()); + assert!(matches!( + cache.request("a", "row-mermaid-0"), + Lookup::Pending + )); + } + + #[test] + fn eviction_spares_diagrams_painted_in_the_latest_passes() { + let mut cache = MermaidCache::default(); + for ix in 0..MAX_ENTRIES { + cache.begin_frame(1); + let code = format!("graph {ix}"); + cache.request(&code, &format!("row{ix}-mermaid-0")); + cache.finish(code, 1, Err("bad".into())); + } + cache.begin_frame(1); + cache.begin_frame(1); + cache.request("graph 0", "row0-mermaid-0"); + cache.request("fresh", "fresh-mermaid-0"); + cache.finish("fresh".into(), 1, Ok(media(80))); + assert_eq!(cache.entries.len(), MAX_ENTRIES); + assert!(cache.entries.contains_key("graph 0")); + assert!(cache.entries.contains_key("fresh")); + assert!(!cache.entries.contains_key("graph 1")); + } + + #[test] + fn source_toggles_follow_their_rows() { + let mut cache = MermaidCache::default(); + let frame: SharedString = "chat#p.0-mermaid-2".into(); + cache.toggle_source(&frame); + assert!(cache.source_visible(&frame)); + cache.retain_rows(&HashSet::from(["chat#p.0"])); + assert!(cache.source_visible(&frame)); + cache.retain_rows(&HashSet::from(["chat#p.1"])); + assert!(!cache.source_visible(&frame)); + } +} diff --git a/crates/ui/src/markdown/mod.rs b/crates/ui/src/markdown/mod.rs index 11c952ff40..f7302481d8 100644 --- a/crates/ui/src/markdown/mod.rs +++ b/crates/ui/src/markdown/mod.rs @@ -27,3 +27,4 @@ pub mod veil; pub use parser::{Block, BlockTree, IncrementalParser, InlineRun, InlineStyle, parse_full}; pub mod mermaid; +pub(crate) mod mermaid_cache; diff --git a/crates/ui/src/markdown/render.rs b/crates/ui/src/markdown/render.rs index 9dced2fb3b..e3d07d3009 100644 --- a/crates/ui/src/markdown/render.rs +++ b/crates/ui/src/markdown/render.rs @@ -8,7 +8,7 @@ //! chunk opacity veil over the text runs (see [`super::veil`]) — opacity only, //! zero translate, applied after layout-relevant properties are fixed. -use std::cell::RefCell; +use std::cell::{Cell, RefCell}; use std::collections::HashMap; use std::ops::Range; use std::path::PathBuf; @@ -109,8 +109,18 @@ pub struct TaskUi { #[derive(Clone)] pub struct MediaUi { - pub diagram: Option DiagramUi>>, - pub image: Rc AnyElement>, + pub diagram: Option DiagramView>>, + /// `None` keeps inline images in their ordinary text rendering. + pub image: Option AnyElement>>, +} + +/// How a Mermaid fence presents itself on the owning surface. +pub enum DiagramView { + /// The ordinary source fence, unchanged (no diagram is available yet). + Source, + /// The source fence, flagged with the reason it cannot be drawn. + Failed(SharedString), + Diagram(DiagramUi), } pub struct DiagramUi { @@ -366,7 +376,7 @@ impl Render for CodeScrollbarDragGhost { } } -struct CodeBlockTooltip(&'static str); +struct CodeBlockTooltip(SharedString); impl Render for CodeBlockTooltip { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { @@ -379,9 +389,10 @@ impl Render for CodeBlockTooltip { .border_color(theme.border_strong) .bg(theme.surface_raised) .shadow_md() + .max_w(px(360.0)) .text_size(px(11.0)) .text_color(theme.text) - .child(self.0) + .child(self.0.clone()) } } @@ -917,7 +928,10 @@ fn render_table( TableAlign::Center => cell.text_center(), TableAlign::Right => cell.text_right(), }; - if opts.media.is_some() + if opts + .media + .as_ref() + .is_some_and(|media| media.image.is_some()) && all[r] .get(c) .is_some_and(|runs| runs.iter().any(|run| run.style.image.is_some())) @@ -1213,6 +1227,7 @@ pub(super) fn flat_text_presented_element( let underlay = canvas( |bounds, window, _| window.insert_hitbox(bounds, gpui::HitboxBehavior::Normal), move |_, hitbox, window, _| { + let surface = PAINTING_SURFACE.with(Cell::get); for range in &code_ranges { for rect in range_rects(&layout, range, INLINE_CODE_PAD_X, INLINE_CODE_INSET_Y) { window.paint_quad(quad( @@ -1225,7 +1240,7 @@ pub(super) fn flat_text_presented_element( )); } } - if let Some(range) = super::selection::wash_range(&sel_key) { + if let Some(range) = super::selection::wash_range(surface, &sel_key) { let range = offsets .as_ref() .map_or_else(|| range.clone(), |map| map.displayed_range(range.clone())); @@ -1245,6 +1260,7 @@ pub(super) fn flat_text_presented_element( // mouse listeners. REGISTRY.with(|r| { r.borrow_mut().push(RegEntry { + surface, key: sel_key.clone(), text: flat_text.clone(), layout: layout.clone(), @@ -1255,6 +1271,7 @@ pub(super) fn flat_text_presented_element( window, hitbox, &sel_key, + surface, &flat_text, &layout, offsets.clone(), @@ -1354,7 +1371,8 @@ fn paint_text_selection_with_wash( layout: &gpui::TextLayout, wash: Hsla, ) { - if let Some(range) = super::selection::wash_range(key) { + let surface = PAINTING_SURFACE.with(Cell::get); + if let Some(range) = super::selection::wash_range(surface, key) { for rect in range_rects(layout, &range, 0.0, 0.0) { window.paint_quad(quad( rect, @@ -1368,13 +1386,14 @@ fn paint_text_selection_with_wash( } REGISTRY.with(|r| { r.borrow_mut().push(RegEntry { + surface, key: key.clone(), text: text.clone(), layout: layout.clone(), offsets: None, }) }); - register_selection_listeners(window, hitbox, key, text, layout, None); + register_selection_listeners(window, hitbox, key, surface, text, layout, None); } /// The wrapping div shared by every selectable text region: markdown @@ -1388,7 +1407,7 @@ pub(crate) fn selectable_text_wrap() -> Div { div().relative().cursor(CursorStyle::IBeam) } -fn selectable_text_element( +pub(crate) fn selectable_text_element( key: std::sync::Arc, text: SharedString, runs: Vec, @@ -1418,6 +1437,9 @@ fn code_line_selection_key(row_key: &str, code_ix: usize, line_ix: usize) -> std /// continuity model that lets a drag span paragraphs/list items (Zed gets /// this for free from its single-element markdown; our tree rebuilds it). struct RegEntry { + /// Which painted transcript this element belongs to (see + /// `selection_frame_reset_for`); 0 for surfaces that never share a frame. + surface: u64, key: std::sync::Arc, text: SharedString, layout: gpui::TextLayout, @@ -1426,6 +1448,11 @@ struct RegEntry { thread_local! { static REGISTRY: RefCell> = const { RefCell::new(Vec::new()) }; + /// The surface currently painting: set by its reset canvas, which paints + /// before any of its text, and stamped onto each entry it registers. + static PAINTING_SURFACE: Cell = const { Cell::new(0) }; + /// Surfaces whose reset painted since the latest one's previous reset. + static PAINTED_SURFACES: RefCell> = const { RefCell::new(Vec::new()) }; } #[cfg(test)] @@ -1466,13 +1493,38 @@ pub(super) fn selection_test_snapshot( /// the transcript root (before any markdown), so each frame's registry holds /// exactly that frame's visible text elements in paint order. pub fn selection_frame_reset() -> impl IntoElement { + selection_frame_reset_for(0) +} + +/// `selection_frame_reset` for one of several surfaces painted in the same +/// frame (the main chat beside a side chat or subagent tab). It clears only +/// its own surface's entries and stamps the text painted after it, so +/// another transcript's reset can't wipe this one's drag anchor. +/// +/// A surface that stops painting (closed side chat or subagent tab) never +/// resets again, so its entries are dropped once a full cycle of this +/// surface's resets passes without it. +pub fn selection_frame_reset_for(surface: u64) -> impl IntoElement { canvas( |_, _, _| (), - |_, _, _, _| { - REGISTRY.with(|r| { - r.borrow_mut() - .retain(|e| !selection_scope(&e.key).is_empty()) - }) + move |_, _, _, _| { + PAINTING_SURFACE.with(|s| s.set(surface)); + PAINTED_SURFACES.with(|painted| { + let mut painted = painted.borrow_mut(); + // Seeing this surface again closes a cycle: `painted` now + // holds every surface still on screen. + let cycle = painted.contains(&surface); + REGISTRY.with(|r| { + r.borrow_mut().retain(|e| { + !selection_scope(&e.key).is_empty() + || (e.surface != surface && (!cycle || painted.contains(&e.surface))) + }) + }); + if cycle { + painted.clear(); + } + painted.push(surface); + }); }, ) .absolute() @@ -1509,18 +1561,32 @@ pub fn selection_surface_reset(prefix: String) -> impl IntoElement { } /// `(element index, byte offset)` for a window position: the registered -/// element whose vertical band contains it, else the nearest by vertical -/// distance (a drag past the gutter or between blocks clamps sensibly). +/// element whose bounds contain it, else the nearest horizontally within the +/// closest vertical band. Vertical distance stays primary so dragging beyond +/// a short paragraph's edge keeps selecting that line. Horizontal distance +/// distinguishes side-by-side text such as table cells and their padding. fn registry_point(position: gpui::Point) -> Option<(usize, usize)> { REGISTRY.with(|r| { let reg = r.borrow(); let anchor = super::selection::anchor_key().unwrap_or_default(); - let mut best: Option<(usize, f32)> = None; + // A drag only resolves against its own surface, so a second + // transcript on screen can't capture it. + let anchor_surface = super::selection::anchor_surface(); + let mut best: Option<(usize, f32, f32)> = None; for (ei, entry) in reg.iter().enumerate() { - if selection_scope(&entry.key) != selection_scope(&anchor) { + if selection_scope(&entry.key) != selection_scope(&anchor) + || entry.surface != anchor_surface + { continue; } let b = entry.layout.bounds(); + let dx = if position.x < b.left() { + f32::from(b.left() - position.x) + } else if position.x > b.right() { + f32::from(position.x - b.right()) + } else { + 0.0 + }; let dy = if position.y < b.top() { f32::from(b.top() - position.y) } else if position.y > b.bottom() { @@ -1528,14 +1594,14 @@ fn registry_point(position: gpui::Point) -> Option<(usize, usize)> } else { 0.0 }; - if best.is_none_or(|(_, d)| dy < d) { - best = Some((ei, dy)); + if best.is_none_or(|(_, best_dy, best_dx)| (dy, dx) < (best_dy, best_dx)) { + best = Some((ei, dy, dx)); } - if dy == 0.0 { + if dy == 0.0 && dx == 0.0 { break; } } - let (ei, _) = best?; + let (ei, _, _) = best?; let ix = match reg[ei].layout.index_for_position(position) { Ok(ix) | Err(ix) => ix, }; @@ -1559,7 +1625,7 @@ fn resolve_drag(head: (usize, usize)) -> bool { let filtered: Vec<_> = reg .iter() .enumerate() - .filter(|(_, e)| selection_scope(&e.key) == scope) + .filter(|(_, e)| selection_scope(&e.key) == scope && e.surface == entry.surface) .collect(); let Some(index) = filtered.iter().position(|(ix, _)| *ix == head.0) else { return false; @@ -1589,6 +1655,7 @@ fn register_selection_listeners( window: &mut Window, hitbox: gpui::Hitbox, key: &std::sync::Arc, + surface: u64, text: &SharedString, layout: &gpui::TextLayout, offsets: Option, @@ -1611,12 +1678,12 @@ fn register_selection_listeners( match e.click_count { 2 => { let range = super::selection::word_range(&text, ix); - super::selection::begin_with_span(&key, &text, range); + super::selection::begin_with_span_in(surface, &key, &text, range); } n if n >= 3 => { - super::selection::begin_with_span(&key, &text, 0..text.len()); + super::selection::begin_with_span_in(surface, &key, &text, 0..text.len()); } - _ => super::selection::begin(&key, ix), + _ => super::selection::begin_in(surface, &key, ix), } window.refresh(); } else if super::selection::clear_if_owner(&key) { @@ -1752,7 +1819,7 @@ fn text_element( opts: &RenderOptions, theme: &Theme, ) -> AnyElement { - if let Some(media) = &opts.media { + if let Some(image_ui) = opts.media.as_ref().and_then(|media| media.image.as_ref()) { if runs.iter().any(|run| run.style.image.is_some()) { let mut elements = Vec::new(); let mut start = 0; @@ -1770,7 +1837,7 @@ fn text_element( theme, )); } - elements.push((media.image)( + elements.push(image_ui( image, format!("{}-image-{ix}-{index}", opts.row_key).into(), theme, @@ -1967,7 +2034,27 @@ fn render_code_block( if language.is_some_and(|l| l.eq_ignore_ascii_case("mermaid")) { if let Some(handler) = opts.media.as_ref().and_then(|media| media.diagram.as_ref()) { let frame_id: SharedString = format!("{}-mermaid-{ix}", opts.row_key).into(); - let diagram = handler(code, frame_id.clone(), theme); + let diagram = match handler(code, frame_id.clone(), theme) { + DiagramView::Source => { + return render_code_block_source( + language, code, top_ix, ix, opts, theme, highlight, + ); + } + DiagramView::Failed(reason) => { + let notice = code_notice(format!("{frame_id}-failure").into(), reason, theme); + return render_code_block_source_with_actions( + language, + code, + top_ix, + ix, + opts, + theme, + highlight, + vec![notice], + ); + } + DiagramView::Diagram(diagram) => diagram, + }; let toggle = diagram.toggle_source.clone(); let toggle_action = code_icon_action( format!("{frame_id}-source-toggle").into(), @@ -2031,7 +2118,7 @@ fn code_icon_action( cx.stop_propagation(); handler(window, cx); }) - .tooltip(move |_, cx| cx.new(move |_| CodeBlockTooltip(label)).into()) + .tooltip(move |_, cx| cx.new(move |_| CodeBlockTooltip(label.into())).into()) .child( crate::icons::icon(icon_path) .size(px(13.0)) @@ -2040,6 +2127,26 @@ fn code_icon_action( .into_any_element() } +/// A passive header marker whose tooltip explains why a fence stays source. +fn code_notice(id: SharedString, message: SharedString, theme: &Theme) -> AnyElement { + div() + .id(id) + .size(px(CODE_ACTION_SIZE)) + .flex() + .items_center() + .justify_center() + .tooltip(move |_, cx| { + let message = message.clone(); + cx.new(move |_| CodeBlockTooltip(message)).into() + }) + .child( + crate::icons::icon(crate::icons::DANGER_TRIANGLE) + .size(px(13.0)) + .text_color(theme.warning_muted), + ) + .into_any_element() +} + fn code_copy_button( code: &str, ix: usize, @@ -2073,7 +2180,7 @@ fn code_copy_button( cx.stop_propagation(); handler(ix, code_text.clone(), window, cx); }) - .tooltip(|_, cx| cx.new(|_| CodeBlockTooltip("Copy code")).into()) + .tooltip(|_, cx| cx.new(|_| CodeBlockTooltip("Copy code".into())).into()) .child( crate::icons::icon(if copied { crate::icons::CHECK @@ -2261,11 +2368,14 @@ fn render_code_block_source_with_actions( }) .tooltip(move |_, cx| { cx.new(move |_| { - CodeBlockTooltip(if fit_content { - "Use horizontal scrolling" - } else { - "Fit content" - }) + CodeBlockTooltip( + if fit_content { + "Use horizontal scrolling" + } else { + "Fit content" + } + .into(), + ) }) .into() }) @@ -2559,6 +2669,401 @@ mod tests { } } + struct MarkdownSelectionHarness; + + impl Render for MarkdownSelectionHarness { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let theme = Theme::of(cx).clone(); + let source = "# Heading\n\nBefore **bold** and *italic* with `code`.\n\n\ + | Agent | Branch | Target |\n\ + | --- | --- | --- |\n\ + | linux | `lab-linux` | Linux jobs, ten minutes or less. This long target wraps across several visual lines in its cell. |\n\ + | macos | `lab-macos` | macOS jobs |\n\n\ + > A **quoted** paragraph.\n\n\ + - A list with `inline code`.\n\n\ + After the table."; + div() + .size_full() + .child(selection_frame_reset()) + .child(render_tree( + &parse_full(source), + &RenderOptions::settled("markdown-selection-test".into()), + &theme, + window, + &|_| None, + )) + } + } + + fn selection_key_for_text(text: &str) -> String { + REGISTRY.with(|registry| { + registry + .borrow() + .iter() + .find(|entry| entry.text.as_ref() == text) + .unwrap_or_else(|| panic!("missing painted text: {text}")) + .key + .to_string() + }) + } + + fn selection_position(key: &str, ix: usize) -> gpui::Point { + let (_, layout, _) = selection_test_snapshot(key); + layout.position_for_index(ix).expect("text position") + + point(px(0.1), layout.line_height() / 2.0) + } + + #[gpui::test] + fn markdown_drag_tracks_each_table_column_and_wrapped_cell(cx: &mut TestAppContext) { + let _selection = super::super::selection::test_state_lock(); + cx.update(|cx| cx.set_global(Theme::dark())); + let (_, cx) = cx.add_window_view(|_, _| MarkdownSelectionHarness); + cx.simulate_resize(size(px(560.0), px(800.0))); + cx.update(|window, cx| { + window.refresh(); + window.draw(cx).clear(); + }); + + let target = "Linux jobs, ten minutes or less. This long target wraps across several visual lines in its cell."; + let target_key = selection_key_for_text(target); + assert!(selection_test_bounds(&target_key).size.height > px(MD_LINE_HEIGHT)); + // Every column must track the pointer, including later visual lines + // in a wrapped cell, and a drag must contract and reverse normally. + for text in ["linux", "lab-linux", target, "lab-macos", "macOS jobs"] { + let key = selection_key_for_text(text); + let start = selection_position(&key, 1); + let end = selection_position(&key, text.len() - 1); + cx.simulate_event(gpui::MouseDownEvent { + button: gpui::MouseButton::Left, + position: start, + click_count: 1, + ..Default::default() + }); + for (position, expected) in [ + (end, &text[1..text.len() - 1]), + (start, ""), + (selection_position(&key, 0), &text[..1]), + ] { + cx.simulate_event(gpui::MouseMoveEvent { + position, + pressed_button: Some(gpui::MouseButton::Left), + ..Default::default() + }); + assert_eq!( + super::super::selection::selected_text().unwrap_or_default(), + expected, + "drag inside {text}" + ); + } + cx.simulate_event(gpui::MouseUpEvent { + button: gpui::MouseButton::Left, + position: selection_position(&key, 0), + ..Default::default() + }); + super::super::selection::clear_if_owner(&key); + } + } + + #[gpui::test] + fn markdown_drag_clamps_in_cell_padding_and_outside_paragraphs(cx: &mut TestAppContext) { + let _selection = super::super::selection::test_state_lock(); + cx.update(|cx| cx.set_global(Theme::dark())); + let (_, cx) = cx.add_window_view(|_, _| MarkdownSelectionHarness); + cx.simulate_resize(size(px(560.0), px(800.0))); + cx.update(|window, cx| { + window.refresh(); + window.draw(cx).clear(); + }); + for text in [ + "linux", + "lab-linux", + "lab-macos", + "macOS jobs", + "A quoted paragraph.", + ] { + let key = selection_key_for_text(text); + let bounds = selection_test_bounds(&key); + // Padding belongs to the nearest cell, even if an earlier column + // has text at the same y. Each side clamps to that cell's endpoint. + for (position, expected) in [ + ( + point( + bounds.right() + px(4.0), + selection_position(&key, text.len()).y, + ), + &text[1..], + ), + ( + point(bounds.left() - px(4.0), selection_position(&key, 0).y), + &text[..1], + ), + ] { + cx.simulate_event(gpui::MouseDownEvent { + button: gpui::MouseButton::Left, + position: selection_position(&key, 1), + click_count: 1, + ..Default::default() + }); + cx.simulate_event(gpui::MouseMoveEvent { + position, + pressed_button: Some(gpui::MouseButton::Left), + ..Default::default() + }); + let selected = super::super::selection::selected_text(); + super::super::selection::end_active_drag(); + super::super::selection::clear_if_owner(&key); + assert_eq!(selected.as_deref(), Some(expected), "padding beside {text}"); + } + } + // A far-right drag on a short heading must stay on that line instead + // of jumping to a wider paragraph below (Euclidean nearest is wrong). + let key = selection_key_for_text("Heading"); + cx.simulate_event(gpui::MouseDownEvent { + button: gpui::MouseButton::Left, + position: selection_position(&key, 0), + click_count: 1, + ..Default::default() + }); + cx.simulate_event(gpui::MouseMoveEvent { + position: selection_position(&key, 0) + point(px(1000.0), px(0.0)), + pressed_button: Some(gpui::MouseButton::Left), + ..Default::default() + }); + let selected = super::super::selection::selected_text(); + super::super::selection::end_active_drag(); + super::super::selection::clear_if_owner(&key); + assert_eq!(selected.as_deref(), Some("Heading")); + } + + #[gpui::test] + fn markdown_drag_crosses_cells_and_formatted_blocks_in_document_order(cx: &mut TestAppContext) { + let _selection = super::super::selection::test_state_lock(); + cx.update(|cx| cx.set_global(Theme::dark())); + let (_, cx) = cx.add_window_view(|_, _| MarkdownSelectionHarness); + cx.simulate_resize(size(px(560.0), px(800.0))); + cx.update(|window, cx| { + window.refresh(); + window.draw(cx).clear(); + }); + let branch_key = selection_key_for_text("lab-linux"); + let target = "Linux jobs, ten minutes or less. This long target wraps across several visual lines in its cell."; + let target_key = selection_key_for_text(target); + let macos_key = selection_key_for_text("lab-macos"); + let before_key = selection_key_for_text("Before bold and italic with code."); + let after_key = selection_key_for_text("After the table."); + for (anchor_key, anchor_ix, head_key, head_ix, expected) in [ + ( + &branch_key, + 4, + &target_key, + 10, + "linux\nLinux jobs".to_string(), + ), + ( + &target_key, + 10, + &branch_key, + 4, + "linux\nLinux jobs".to_string(), + ), + ( + &branch_key, + 4, + &macos_key, + 3, + format!("linux\n{target}\nmacos\nlab"), + ), + ( + &before_key, + 0, + &after_key, + 16, + format!( + "Before bold and italic with code.\nAgent\nBranch\nTarget\nlinux\nlab-linux\n{target}\nmacos\nlab-macos\nmacOS jobs\nA quoted paragraph.\nA list with inline code.\nAfter the table." + ), + ), + ] { + cx.simulate_event(gpui::MouseDownEvent { + button: gpui::MouseButton::Left, + position: selection_position(anchor_key, anchor_ix), + click_count: 1, + ..Default::default() + }); + cx.simulate_event(gpui::MouseMoveEvent { + position: selection_position(head_key, head_ix), + pressed_button: Some(gpui::MouseButton::Left), + ..Default::default() + }); + let selected = super::super::selection::selected_text(); + super::super::selection::end_active_drag(); + super::super::selection::clear_if_owner(anchor_key); + assert_eq!(selected.as_deref(), Some(expected.as_str())); + } + } + + /// Two transcripts painted side by side (main chat + side chat or subagent + /// tab), each opening with its own `selection_frame_reset()`. A side + /// chat's forked history repeats its parent's row keys: `prefixes` equal. + struct TwoPaneSelectionHarness { + prefixes: [&'static str; 2], + second_pane: bool, + } + + impl TwoPaneSelectionHarness { + fn new(prefixes: [&'static str; 2]) -> Self { + Self { + prefixes, + second_pane: true, + } + } + } + + impl Render for TwoPaneSelectionHarness { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let theme = Theme::of(cx).clone(); + let pane = |prefix: &str, surface: u64| { + let opts = RenderOptions::settled(prefix.to_string().into()); + div() + .w(px(320.0)) + .h_full() + .child(selection_frame_reset_for(surface)) + .child(text_element( + &[InlineRun { + text: "alpha beta gamma delta".into(), + style: InlineStyle::default(), + }], + MD_TEXT_SIZE, + MD_LINE_HEIGHT, + false, + 0, + 0, + &opts, + &theme, + )) + }; + div() + .size_full() + .flex() + .child(pane(self.prefixes[0], 1)) + .when(self.second_pane, |el| el.child(pane(self.prefixes[1], 2))) + } + } + + #[gpui::test] + fn dragging_selects_text_in_every_pane_of_a_two_transcript_layout(cx: &mut TestAppContext) { + let _selection = super::super::selection::test_state_lock(); + cx.update(|cx| cx.set_global(Theme::dark())); + let (_, cx) = cx.add_window_view(|_, _| TwoPaneSelectionHarness::new(["pane-a", "pane-b"])); + cx.simulate_resize(size(px(640.0), px(240.0))); + cx.update(|window, cx| { + window.refresh(); + let _ = window.draw(cx); + }); + + // Each pane is 320px wide; drag across the start of its text. The + // last-painted pane is the control: it always worked. + for (name, left) in [("pane-b", 320.0), ("pane-a", 0.0)] { + let start = point(px(left + 1.0), px(9.0)); + cx.simulate_event(gpui::MouseDownEvent { + button: gpui::MouseButton::Left, + position: start, + click_count: 1, + ..Default::default() + }); + cx.simulate_event(gpui::MouseMoveEvent { + position: start + point(px(60.0), px(0.0)), + pressed_button: Some(gpui::MouseButton::Left), + ..Default::default() + }); + let selected = super::super::selection::selected_text(); + super::super::selection::end_active_drag(); + super::super::selection::clear_if_owner(&format!("{name}:0")); + assert!( + selected.is_some_and(|text| !text.is_empty()), + "a drag in {name} must select text" + ); + } + } + + #[gpui::test] + fn a_forked_pane_sharing_keys_keeps_its_selection_to_itself(cx: &mut TestAppContext) { + use super::super::selection; + let _selection = selection::test_state_lock(); + cx.update(|cx| cx.set_global(Theme::dark())); + let (_, cx) = cx.add_window_view(|_, _| TwoPaneSelectionHarness::new(["fork", "fork"])); + cx.simulate_resize(size(px(640.0), px(240.0))); + cx.update(|window, cx| { + window.refresh(); + let _ = window.draw(cx); + }); + + // Both panes paint `fork:0`. Whichever pane the drag starts in, it + // must select there (the other pane's same-key listener must not + // clear it) and wash only there. + for (surface, other, left) in [(1, 2, 0.0), (2, 1, 320.0)] { + let start = point(px(left + 1.0), px(9.0)); + let end = start + point(px(60.0), px(0.0)); + cx.simulate_event(gpui::MouseDownEvent { + button: gpui::MouseButton::Left, + position: start, + click_count: 1, + ..Default::default() + }); + cx.simulate_event(gpui::MouseMoveEvent { + position: end, + pressed_button: Some(gpui::MouseButton::Left), + ..Default::default() + }); + cx.simulate_event(gpui::MouseUpEvent { + button: gpui::MouseButton::Left, + position: end, + ..Default::default() + }); + let selected = selection::selected_text(); + let washed = selection::wash_range(surface, "fork:0"); + let leaked = selection::wash_range(other, "fork:0"); + selection::clear_if_owner("fork:0"); + let selected = selected.unwrap_or_default(); + assert!( + "alpha beta gamma delta".starts_with(&selected) && !selected.is_empty(), + "a drag in surface {surface} copies its own text, got {selected:?}" + ); + assert_eq!(washed, Some(0..selected.len()), "surface {surface} washes"); + assert_eq!(leaked, None, "surface {other} stays unwashed"); + } + } + + #[gpui::test] + fn a_closed_pane_leaves_no_registry_entries_behind(cx: &mut TestAppContext) { + let _selection = super::super::selection::test_state_lock(); + cx.update(|cx| cx.set_global(Theme::dark())); + let (view, cx) = + cx.add_window_view(|_, _| TwoPaneSelectionHarness::new(["pane-a", "pane-b"])); + cx.simulate_resize(size(px(640.0), px(240.0))); + let entries = |surface: u64| { + REGISTRY.with(|r| r.borrow().iter().filter(|e| e.surface == surface).count()) + }; + cx.update(|window, cx| { + window.refresh(); + let _ = window.draw(cx); + }); + assert!(entries(2) > 0); + + // The side chat closes: its reset never paints again. + view.update(cx, |harness, cx| { + harness.second_pane = false; + cx.notify(); + }); + for _ in 0..2 { + cx.update(|window, cx| { + window.refresh(); + let _ = window.draw(cx); + }); + } + assert_eq!(entries(2), 0, "the closed pane's text is dropped"); + assert!(entries(1) > 0, "the open pane keeps its text"); + } + #[gpui::test] fn code_block_lines_participate_in_text_selection(cx: &mut TestAppContext) { let _selection = super::super::selection::test_state_lock(); diff --git a/crates/ui/src/markdown/selection.rs b/crates/ui/src/markdown/selection.rs index 74639c573e..3bf2743d49 100644 --- a/crates/ui/src/markdown/selection.rs +++ b/crates/ui/src/markdown/selection.rs @@ -30,6 +30,9 @@ pub struct Span { #[derive(Clone, Default)] struct MdSelection { + /// Painted surface (transcript) the selection lives in. Keys are only + /// unique within one: a side chat's forked history repeats its parent's. + surface: u64, /// Element that owns the drag (where the mouse went down). anchor_key: String, /// Byte offset of the anchor within its element. @@ -87,7 +90,13 @@ pub fn resolve_spans(elements: &[(&str, &str)], a: (usize, usize), b: (usize, us /// Begin a drag anchored at `(key, ix)`; claims the global selection. pub fn begin(key: &str, ix: usize) { + begin_in(0, key, ix); +} + +/// `begin` on one of several surfaces painted in the same frame. +pub(crate) fn begin_in(surface: u64, key: &str, ix: usize) { *state().lock().unwrap() = Some(MdSelection { + surface, anchor_key: key.to_string(), anchor_ix: ix, dragging: true, @@ -98,7 +107,13 @@ pub fn begin(key: &str, ix: usize) { /// Begin with an immediate span (double/triple click inside one element). pub fn begin_with_span(key: &str, text: &str, range: Range) { + begin_with_span_in(0, key, text, range); +} + +/// `begin_with_span` on one of several surfaces painted in the same frame. +pub(crate) fn begin_with_span_in(surface: u64, key: &str, text: &str, range: Range) { *state().lock().unwrap() = Some(MdSelection { + surface, anchor_key: key.to_string(), anchor_ix: range.start, dragging: true, @@ -126,6 +141,11 @@ pub(crate) fn anchor_key() -> Option { .map(|s| s.anchor_key.clone()) } +/// The surface the selection lives in (0 when there is none). +pub(crate) fn anchor_surface() -> u64 { + state().lock().unwrap().as_ref().map_or(0, |s| s.surface) +} + /// Whether a markdown selection drag is currently in flight. pub fn is_dragging() -> bool { state() @@ -268,10 +288,11 @@ pub fn clear_if_owner(key: &str) -> bool { false } -/// The wash range for `key` this frame (empty ⇒ nothing to paint). -pub fn wash_range(key: &str) -> Option> { +/// The wash range for `key` painted on `surface` this frame (empty ⇒ nothing +/// to paint). Another surface showing the same key stays unwashed. +pub fn wash_range(surface: u64, key: &str) -> Option> { let guard = state().lock().unwrap(); - let sel = guard.as_ref()?; + let sel = guard.as_ref().filter(|sel| sel.surface == surface)?; sel.spans .iter() .find(|s| s.key == key && !s.range.is_empty()) @@ -381,9 +402,9 @@ mod tests { let spans = resolve_spans(&elems(), (0, 6), (1, 6)); assert!(update_spans(spans.clone())); assert!(!update_spans(spans)); // unchanged ⇒ no repaint - assert_eq!(wash_range("p1"), Some(6..15)); - assert_eq!(wash_range("p2"), Some(0..6)); - assert_eq!(wash_range("p3"), None); + assert_eq!(wash_range(0, "p1"), Some(6..15)); + assert_eq!(wash_range(0, "p2"), Some(0..6)); + assert_eq!(wash_range(0, "p3"), None); assert_eq!(end_drag("p1").as_deref(), Some("paragraph\nsecond")); assert_eq!(selected_text().as_deref(), Some("paragraph\nsecond")); // Settled: a down elsewhere clears via the owner's listener. @@ -434,7 +455,7 @@ mod tests { fn double_click_span() { let _state = test_state_lock(); begin_with_span("p1", "hello world", 6..11); - assert_eq!(wash_range("p1"), Some(6..11)); + assert_eq!(wash_range(0, "p1"), Some(6..11)); assert_eq!(end_drag("p1").as_deref(), Some("world")); } diff --git a/crates/ui/src/markdown/veil.rs b/crates/ui/src/markdown/veil.rs index 7d5f5e7969..a8a45e4d87 100644 --- a/crates/ui/src/markdown/veil.rs +++ b/crates/ui/src/markdown/veil.rs @@ -1,237 +1,16 @@ -//! Streaming fade veil — per-appended-chunk opacity over already-committed text. +//! Streaming fade veil for GPUI text: the chunk tracking lives in +//! `zeron-veil` (shared with the voice captions on desktop and mobile); this +//! module recolors GPUI text runs with its spans. //! -//! The desktop app (docs/research/mugen-pretext.md §2e) commits streamed text to -//! layout instantly and dissolves a purely cosmetic veil over the newly arrived -//! characters. This module is the gpui port of that idea: -//! -//! - [`ElemVeil`] tracks, per rendered text element, the previously rendered -//! flat text. Each append registers the new byte range as a [`Chunk`] with its -//! arrival time; a fast stream keeps several chunks fading concurrently (a -//! rolling veil). A chunk fades exactly once — already-faded text never -//! re-animates, and a fully settled element returns no spans at all. -//! - [`apply_veil`] multiplies the fading alpha into the `TextRun` colors -//! covering each chunk. This is paint-only by construction: a color-only run -//! split cannot change layout — gpui shapes text through cosmic-text, whose -//! `Attrs::compatible` ignores color/metadata, so adjacent same-font runs are -//! shaped as one contiguous word even across the split (kerning and ligatures -//! survive; wrapping is byte-identical to the unsplit render). -//! -//! Constants and curve match mugen-markdown's web `FadePainter` (the engine the -//! desktop app ships): per-chunk duration adapts to the stream's cadence — an -//! EMA of inter-append gaps, `duration = clamp(ema × 3, 120ms, 400ms)` — the -//! dissolve eases with `veil = (1 − p)^1.6` (text alpha `1 − veil`), and a -//! backed-up stream (3+ concurrent chunks) gets a small speed boost. **Zero -//! translate** — opacity only, per the "no positional offset on streamed -//! content" rule. - -use std::collections::HashMap; -use std::ops::Range; -use std::time::Instant; +//! [`apply_veil`] multiplies the fading alpha into the `TextRun` colors +//! covering each chunk. This is paint-only by construction: a color-only run +//! split cannot change layout — gpui shapes text through cosmic-text, whose +//! `Attrs::compatible` ignores color/metadata, so adjacent same-font runs are +//! shaped as one contiguous word even across the split (kerning and ligatures +//! survive; wrapping is byte-identical to the unsplit render). use gpui::TextRun; - -/// EMA seed for the inter-append gap (mugen `EMA_SEED_MS`). -pub const VEIL_EMA_SEED_MS: f32 = 160.0; -/// Duration clamp (mugen `MIN_FADE_MS` / `MAX_FADE_MS`). -pub const VEIL_MIN_FADE_MS: f32 = 120.0; -pub const VEIL_MAX_FADE_MS: f32 = 400.0; -/// Dissolve exponent (mugen: `alpha = (1 - p) ** 1.6`). -pub const VEIL_CURVE_POW: f32 = 1.6; -/// Gap clamp feeding the EMA (mugen: `min(gap, 1000)`). -const VEIL_GAP_CLAMP_MS: f32 = 1000.0; - -/// One appended chunk of text mid-fade. -#[derive(Debug, Clone)] -struct Chunk { - /// Byte range in the element's flat text. - range: Range, - started: Instant, - /// Fade duration fixed at arrival from the cadence EMA. - duration_ms: f32, -} - -/// A veiled byte range and its current opacity (0..1). -pub type VeilSpan = (Range, f32); - -/// Text alpha for a fade progress `p` (0..1): the veil dissolves as -/// `(1 − p)^1.6`, so the text shows through at `1 − veil`. Pure. -pub fn veil_opacity(p: f32) -> f32 { - 1.0 - (1.0 - p.clamp(0.0, 1.0)).powf(VEIL_CURVE_POW) -} - -/// Chunk fade duration for the current inter-append EMA (mugen: -/// `min(MAX, max(MIN, ema * 3))`). -pub fn veil_duration_ms(ema_ms: f32) -> f32 { - (ema_ms * 3.0).clamp(VEIL_MIN_FADE_MS, VEIL_MAX_FADE_MS) -} - -/// Fast-stream boost: 3+ chunks fading concurrently speed up by 30% each -/// (mugen: `1 + 0.3 * max(0, veils.length - 2)`). -pub fn veil_boost(active_chunks: usize) -> f32 { - 1.0 + 0.3 * active_chunks.saturating_sub(2) as f32 -} - -/// EMA update on a new append gap (mugen: `ema*0.7 + min(gap,1000)*0.3`). -pub fn veil_ema_next(ema_ms: f32, gap_ms: f32) -> f32 { - ema_ms * 0.7 + gap_ms.min(VEIL_GAP_CLAMP_MS) * 0.3 -} - -/// Per-element chunk tracker: remembers the last rendered flat text and fades -/// every newly appended suffix exactly once. -#[derive(Debug)] -pub struct ElemVeil { - prev: String, - chunks: Vec, - /// EMA of inter-append gaps (drives per-chunk durations). - ema_ms: f32, - last_append: Option, -} - -impl Default for ElemVeil { - fn default() -> Self { - Self { - prev: String::new(), - chunks: Vec::new(), - ema_ms: VEIL_EMA_SEED_MS, - last_append: None, - } - } -} - -/// Longest common prefix length, snapped back to a char boundary. -fn common_prefix(a: &str, b: &str) -> usize { - let mut p = a - .as_bytes() - .iter() - .zip(b.as_bytes()) - .take_while(|(x, y)| x == y) - .count(); - while p > 0 && !b.is_char_boundary(p) { - p -= 1; - } - p -} - -impl ElemVeil { - /// Adopt `text` as the committed baseline without fading it — the attach - /// semantics of mugen's `FadePainter.attach` (`length = - /// chromeFreeLength(content)`): content already on screen when the painter - /// attaches never animates; only later appends do. - fn seed(&mut self, text: &str) { - self.prev.clear(); - self.prev.push_str(text); - } - - /// Advance to `text` at `now`: registers a fading chunk for newly appended - /// bytes, prunes settled chunks, and returns the active spans. Idempotent - /// for unchanged text — safe to call once per frame (or twice, when a row - /// is both measured and painted). - pub fn advance(&mut self, text: &str, now: Instant) -> Vec { - if text != self.prev { - // Non-append rewrites (the incremental parser re-deriving a block's - // flat text — e.g. `**bold` collapsing into a bold run) keep the - // common prefix's committed fades and re-veil only the changed tail. - let p = common_prefix(&self.prev, text); - self.chunks.retain_mut(|c| { - c.range.end = c.range.end.min(p); - c.range.start < c.range.end - }); - if text.len() > p { - // Cadence-adaptive duration (mugen FadePainter): update the - // EMA with the gap since the previous append. - if let Some(last) = self.last_append { - let gap = now.saturating_duration_since(last).as_secs_f32() * 1000.0; - self.ema_ms = veil_ema_next(self.ema_ms, gap); - } - self.last_append = Some(now); - self.chunks.push(Chunk { - range: p..text.len(), - started: now, - duration_ms: veil_duration_ms(self.ema_ms), - }); - } - self.prev.clear(); - self.prev.push_str(text); - } - let boost = veil_boost(self.chunks.len()); - self.chunks.retain(|c| { - let elapsed = now.saturating_duration_since(c.started).as_secs_f32() * 1000.0; - elapsed * boost < c.duration_ms - }); - let boost = veil_boost(self.chunks.len()); - self.chunks - .iter() - .map(|c| { - let elapsed = now.saturating_duration_since(c.started).as_secs_f32() * 1000.0; - let progress = (elapsed * boost / c.duration_ms).clamp(0.0, 1.0); - (c.range.clone(), veil_opacity(progress)) - }) - .collect() - } - - /// Any chunk still fading (as of the last [`advance`](Self::advance))? - pub fn is_fading(&self) -> bool { - !self.chunks.is_empty() - } -} - -/// Veil state for one live streaming row, keyed by the render tree's stable -/// per-element discriminator (top-level block ix / nested ix scheme — stable -/// across appends because the incremental parser only reparses the tail). -#[derive(Debug, Default)] -pub struct RowVeil { - elems: HashMap, - /// Attach pass in progress: elements first seen while seeding adopt their - /// current text as the committed baseline instead of fading it in. Set for - /// rows that already carried text when the transcript (re)attached to the - /// chat — switching back to a streaming session must not dissolve the - /// whole existing reply (user report; mugen's `FadePainter.attach` baseline). - seeding: bool, -} - -impl RowVeil { - /// A veil whose first render pass seeds baselines instead of fading. - pub fn seeded() -> Self { - Self { - elems: HashMap::new(), - seeding: true, - } - } - - /// The attach pass is over — elements that appear from here on are newly - /// streamed content and fade normally. - pub fn finish_seeding(&mut self) { - self.seeding = false; - } - - pub fn advance(&mut self, elem: usize, text: &str, now: Instant) -> Vec { - if self.seeding && !self.elems.contains_key(&elem) { - let mut baseline = ElemVeil::default(); - baseline.seed(text); - self.elems.insert(elem, baseline); - return Vec::new(); - } - self.elems.entry(elem).or_default().advance(text, now) - } - - /// Any element still fading? Drives the once-per-frame repaint request. - pub fn is_fading(&self) -> bool { - self.elems.values().any(ElemVeil::is_fading) - } -} - -/// Intersect spans with `[start, end)` and shift them to local offsets — used -/// by per-line code rendering where chunks are tracked on the whole code text. -pub fn slice_spans(spans: &[VeilSpan], start: usize, end: usize) -> Vec { - spans - .iter() - .filter_map(|(r, a)| { - let s = r.start.max(start); - let e = r.end.min(end); - (s < e).then(|| (s - start..e - start, *a)) - }) - .collect() -} +pub use zeron_veil::{RowVeil, VeilSpan, slice_spans}; /// Multiply veil opacities into the runs' paint colors, splitting runs at span /// boundaries. Fonts, lengths, and text are untouched — the total run length is @@ -285,146 +64,6 @@ pub fn apply_veil(runs: Vec, spans: &[VeilSpan]) -> Vec { mod tests { use super::*; use gpui::{font, px}; - use std::time::Duration; - - fn at(base: Instant, ms: u64) -> Instant { - base + Duration::from_millis(ms) - } - - #[test] - fn first_text_fades_and_settles_once() { - let t0 = Instant::now(); - let mut v = ElemVeil::default(); - let spans = v.advance("hello", t0); - assert_eq!(spans, vec![(0..5, 0.0)]); - // Mid-fade: opacity strictly between 0 and 1, range unchanged. - let spans = v.advance("hello", at(t0, 250)); - assert_eq!(spans.len(), 1); - assert!(spans[0].1 > 0.0 && spans[0].1 < 1.0); - // Settled: pruned, never re-animates. - assert!(v.advance("hello", at(t0, 600)).is_empty()); - assert!(!v.is_fading()); - assert!(v.advance("hello", at(t0, 700)).is_empty()); - } - - #[test] - fn appended_chunks_fade_concurrently_and_independently() { - let t0 = Instant::now(); - let mut v = ElemVeil::default(); - v.advance("one ", t0); - let spans = v.advance("one two ", at(t0, 100)); - assert_eq!(spans.len(), 2); - assert_eq!(spans[0].0, 0..4); - assert_eq!(spans[1].0, 4..8); - // The older chunk is further along its fade than the newer one. - assert!(spans[0].1 > spans[1].1); - // After the first settles (past its adaptive duration), only the - // newer chunk remains. - let spans = v.advance("one two ", at(t0, 410)); - assert_eq!(spans.len(), 1); - assert_eq!(spans[0].0, 4..8); - } - - #[test] - fn fade_constants_match_mugen_fade_painter() { - // @wingleeio/mugen-markdown dist/index.mjs: EMA_SEED_MS=160, - // MIN_FADE_MS=120, MAX_FADE_MS=400, alpha=(1-p)**1.6, - // ema = ema*0.7 + min(gap,1000)*0.3, boost = 1 + 0.3*max(0,n-2). - assert_eq!(VEIL_EMA_SEED_MS, 160.0); - assert_eq!(VEIL_MIN_FADE_MS, 120.0); - assert_eq!(VEIL_MAX_FADE_MS, 400.0); - assert_eq!(VEIL_CURVE_POW, 1.6); - // duration = clamp(ema*3, 120, 400). - assert_eq!(veil_duration_ms(160.0), 400.0); // seed → clamped at max - assert_eq!(veil_duration_ms(30.0), 120.0); // fast stream → floor - assert_eq!(veil_duration_ms(60.0), 180.0); - // EMA update. - assert_eq!(veil_ema_next(160.0, 100.0), 160.0 * 0.7 + 100.0 * 0.3); - assert_eq!(veil_ema_next(160.0, 5000.0), 160.0 * 0.7 + 1000.0 * 0.3); - // Fast-stream boost kicks in at the 3rd concurrent chunk. - assert_eq!(veil_boost(0), 1.0); - assert_eq!(veil_boost(2), 1.0); - assert!((veil_boost(3) - 1.3).abs() < 1e-6); - assert!((veil_boost(5) - 1.9).abs() < 1e-6); - } - - #[test] - fn seeded_row_adopts_existing_text_without_fading() { - // Switching back to a streaming session: the text already on screen is - // the attach BASELINE (mugen FadePainter.attach) — no full-reply fade. - let t0 = Instant::now(); - let mut row = RowVeil::seeded(); - assert!(row.advance(0, "already streamed text", t0).is_empty()); - assert!(row.advance(1, "second block", t0).is_empty()); - assert!(!row.is_fading()); - // Appends AFTER the attach fade normally — only the new suffix. - let spans = row.advance(0, "already streamed text plus", at(t0, 100)); - assert_eq!(spans, vec![(21..26, 0.0)]); - // The attach pass ends after the first render: elements first seen - // later are newly streamed content and fade from empty. - row.finish_seeding(); - let spans = row.advance(2, "new block", at(t0, 200)); - assert_eq!(spans, vec![(0..9, 0.0)]); - } - - #[test] - fn default_row_veil_fades_first_text() { - // Mid-stream new rows keep the old behavior: their first chunk fades. - let t0 = Instant::now(); - let mut row = RowVeil::default(); - let spans = row.advance(0, "fresh", t0); - assert_eq!(spans, vec![(0..5, 0.0)]); - } - - #[test] - fn faded_text_never_reanimates_on_append() { - let t0 = Instant::now(); - let mut v = ElemVeil::default(); - v.advance("stable", t0); - // Fully settled... - assert!(v.advance("stable", at(t0, 600)).is_empty()); - // ...then an append veils ONLY the new suffix. - let spans = v.advance("stable more", at(t0, 700)); - assert_eq!(spans, vec![(6..11, 0.0)]); - } - - #[test] - fn non_append_rewrite_keeps_prefix_and_reveils_tail() { - let t0 = Instant::now(); - let mut v = ElemVeil::default(); - v.advance("intro **bol", t0); - // Markdown resolves: flat text loses the `**` marker. - let spans = v.advance("intro bold", at(t0, 100)); - // The shared prefix's chunk is clamped, the changed tail is one chunk. - assert_eq!(spans.len(), 2); - assert_eq!(spans[0].0, 0..6); - assert_eq!(spans[1].0, 6..10); - assert!(spans[0].1 > spans[1].1); - } - - #[test] - fn common_prefix_respects_char_boundaries() { - // "é" = 0xC3 0xA9, "è" = 0xC3 0xA8 — byte prefix would split the char. - assert_eq!(common_prefix("é", "è"), 0); - assert_eq!(common_prefix("abé", "abè"), 2); - assert_eq!(common_prefix("same", "same"), 4); - } - - #[test] - fn veil_opacity_curve_endpoints() { - // Text alpha = 1 - (1-p)^1.6: 0 at arrival, 1 when the veil is gone. - assert_eq!(veil_opacity(0.0), 0.0); - assert_eq!(veil_opacity(1.0), 1.0); - let mid = veil_opacity(0.5); - assert!(mid > 0.0 && mid < 1.0); - // The pow-1.6 ease-out reveals faster than linear early on. - assert!(mid > 0.5); - assert!((mid - (1.0 - 0.5f32.powf(1.6))).abs() < 1e-6); - // Monotonic + clamped. - assert!(veil_opacity(0.2) <= veil_opacity(0.4)); - assert_eq!(veil_opacity(-1.0), 0.0); - assert_eq!(veil_opacity(2.0), 1.0); - } fn run(len: usize, color: gpui::Hsla) -> TextRun { TextRun { @@ -482,27 +121,4 @@ mod tests { assert_eq!(out[0].background_color.unwrap().a, 0.25); assert_eq!(out[0].underline.as_ref().unwrap().color.unwrap().a, 0.25); } - - #[test] - fn slice_spans_shifts_to_local_offsets() { - let spans = vec![(3..10, 0.4), (12..20, 0.1)]; - // A "line" covering bytes 5..15. - let local = slice_spans(&spans, 5, 15); - assert_eq!(local, vec![(0..5, 0.4), (7..10, 0.1)]); - // Disjoint window → empty. - assert!(slice_spans(&spans, 25, 30).is_empty()); - } - - #[test] - fn row_veil_tracks_elements_independently() { - let t0 = Instant::now(); - let mut row = RowVeil::default(); - row.advance(0, "para", t0); - row.advance(2, "code", t0); - assert!(row.is_fading()); - row.advance(0, "para", at(t0, 600)); - assert!(row.is_fading(), "elem 2 hasn't been advanced past its fade"); - row.advance(2, "code", at(t0, 600)); - assert!(!row.is_fading()); - } } diff --git a/crates/ui/src/motion.rs b/crates/ui/src/motion.rs index 3363911098..31f8afa958 100644 --- a/crates/ui/src/motion.rs +++ b/crates/ui/src/motion.rs @@ -21,6 +21,8 @@ //! switch; pure helpers take the flag explicitly where they run outside elements. //! [`ReduceMotion`] drives that switch from the OS accessibility setting or the //! user's override, and optionally from main-window focus. +//! Activity grids use [`ActivityPulse`] to retain a gentle brightness cue under +//! system reduced motion; explicit On and background pause keep them still. //! //! translateY is implemented as a relative-position `top` inset: taffy applies //! relative insets after layout, so — like a CSS transform — siblings never move. @@ -122,10 +124,59 @@ fn pulse_delta_every(spec: &MotionSpec, view: EntityId, stride: u64, cx: &mut Ap return 0.0; } pulse_lease_every(view, stride, cx); + pulse_phase(spec, cx) +} + +fn pulse_phase(spec: &MotionSpec, cx: &mut App) -> f32 { let clock = cx.default_global::(); (clock.epoch.elapsed().as_secs_f32() / spec.total().as_secs_f32()).fract() } +/// Activity feedback uses a slow, uniform brightness pulse when the system +/// reduces motion. Explicit On and background pause still stop all animation. +/// Decorative animation continues to use the reduced-motion-gated helpers. +#[derive(Clone, Copy)] +pub struct ActivityPulse { + phase: f32, + subtle: bool, +} + +impl ActivityPulse { + pub fn opacity(&self, cell_phase: f32, dim: f32) -> f32 { + if self.subtle { + // No travelling chase or size change, just a gentle 2.4s fade. + 0.6 + 0.2 * pulse_wave(self.phase) + } else { + gspin_opacity(self.phase + cell_phase, dim) + } + } +} + +pub fn activity_pulse(view: EntityId, cx: &mut App) -> ActivityPulse { + activity_pulse_every(view, 1, cx) +} + +pub fn activity_pulse_slow(view: EntityId, cx: &mut App) -> ActivityPulse { + activity_pulse_every(view, 2, cx) +} + +fn activity_pulse_every(view: EntityId, stride: u64, cx: &mut App) -> ActivityPulse { + let subtle = cx.reduce_motion(); + let animate = !subtle + || cx.try_global::().is_some_and(|state| { + state.preference == ReduceMotion::System + && state.system + && !(state.pause_in_background && !state.active) + }); + let phase = if animate { + schedule_pulse_every(view, if subtle { 2 } else { stride }, cx); + pulse_phase(if subtle { &ZERON_PULSE } else { &GRADIENT_SPIN }, cx) + } else { + 0.0 + }; + ActivityPulse { phase, subtle } +} + /// Schedule cosmetic animation through the same bounded clock as loaders. /// Renew only while painting an active animation; the clock parks after the /// last lease expires, including when a view is hidden or removed. @@ -137,6 +188,10 @@ fn pulse_lease_every(view: EntityId, stride: u64, cx: &mut App) { if cx.reduce_motion() { return; } + schedule_pulse_every(view, stride, cx); +} + +fn schedule_pulse_every(view: EntityId, stride: u64, cx: &mut App) { let clock = cx.default_global::(); let now = Instant::now(); clock @@ -409,6 +464,9 @@ pub const EASE_TAILWIND: CubicBezier = CubicBezier::new(0.4, 0.0, 0.2, 1.0); /// CSS `transition-colors` default: 150ms over [`EASE_TAILWIND`] — the temporal /// blend every interactive hover wash rides in the original. pub const HOVER_FADE: MotionSpec = MotionSpec::new(150, EASE_TAILWIND); +/// Zeron Icons (icons.zeron.sh) state morph: `--zi-duration: 280ms` over +/// `cubic-bezier(.22, 1, .36, 1)` — the sidebar glyph's panel open ↔ closed. +pub const GLYPH_STATE: MotionSpec = MotionSpec::new(280, EASE_OUT_QUINT); /// Zeron loader pulse period: 2.4s. pub const ZERON_PULSE: MotionSpec = MotionSpec::new(2400, EASE); /// Gradient matrix spinner wave period: 750ms. @@ -547,6 +605,35 @@ where }) } +/// [`menu_in`] travelling from the trigger's side: `from` is the signed +/// starting offset (negative above the resting place for dropdowns, +/// positive below it for menus that open upward). +pub fn menu_in_from(id: impl Into, from: f32, element: E) -> AnimationElement +where + E: Styled + IntoElement + 'static, +{ + element.with_animation(id, MENU_IN.animation(), move |el, t| { + el.relative() + .opacity(0.3 + 0.7 * t) + .top(px(from * (1.0 - t))) + }) +} + +/// [`menu_out`] retreating toward the trigger by half the entrance travel. +pub fn menu_out_toward( + id: impl Into, + toward: f32, + t: f32, + element: E, +) -> AnimationElement +where + E: Styled + IntoElement + 'static, +{ + element.with_animation(id, MENU_OUT.animation(), move |el, _| { + el.relative().opacity(1.0 - t).top(px(toward * 0.5 * t)) + }) +} + /// Popover exit: the reverse of [`menu_in`] — fade to 0 + translateY 0→−2 over /// [`MENU_OUT`]. Unlike the entrances, the eased progress `t` comes from the /// caller (computed off [`crate::popover::Popup`]'s closing instant at render @@ -805,6 +892,128 @@ pub fn hover_blend(key: &str, rest: Hsla, hover: Hsla) -> Hsla { mix(rest, hover, hover_t(key)) } +// --------------------------------------------------------------------------- +// Two-state glyph morphs (CSS `transition` on a `data-state` flip) +// --------------------------------------------------------------------------- +// +// Zeron Icons morph between two states (the sidebar glyph's panel narrows when +// the sidebar closes). Same manual clock and liveness rules as the hover +// fades, but keyed by the element's *state* rather than pointer events: the +// first read of a key snaps to its state (mounting never replays a morph), a +// flip re-anchors at the current value so a mid-flight toggle reverses +// smoothly, and an entry unread for a full frame is pruned. + +#[derive(Debug, Clone, Copy)] +struct StateEntry { + origin: f32, + target: f32, + started: Instant, + spec: MotionSpec, + seen: u64, +} + +impl StateEntry { + fn duration(&self) -> Duration { + self.spec.total().mul_f32(speed_scale()) + } + + fn value(&self, now: Instant) -> f32 { + let duration = self.duration(); + let elapsed = now.saturating_duration_since(self.started); + if duration.is_zero() || elapsed >= duration { + return self.target; + } + let raw = elapsed.as_secs_f32() / duration.as_secs_f32(); + lerp(self.origin, self.target, self.spec.progress(raw)) + } + + fn settled(&self, now: Instant) -> bool { + self.origin == self.target || now.saturating_duration_since(self.started) >= self.duration() + } +} + +/// Per-key state-morph store. Pure core (explicit `now`) — unit-testable. +#[derive(Default)] +pub struct StateMorphs { + entries: HashMap, + frame: u64, +} + +impl StateMorphs { + /// Progress (0 = off, 1 = on) of `key` toward `on` at `now`; stamps + /// liveness. Reduced motion snaps a flip straight to its endpoint. + pub fn value_at( + &mut self, + key: &str, + on: bool, + spec: MotionSpec, + reduced: bool, + now: Instant, + ) -> f32 { + let target = if on { 1.0 } else { 0.0 }; + let frame = self.frame; + let Some(entry) = self.entries.get_mut(key) else { + self.entries.insert( + key.to_string(), + StateEntry { + origin: target, + target, + started: now, + spec, + seen: frame, + }, + ); + return target; + }; + if entry.target != target { + let current = entry.value(now); + *entry = StateEntry { + origin: if reduced { target } else { current }, + target, + started: now, + spec, + seen: frame, + }; + } + entry.seen = frame; + entry.value(now) + } + + /// Once-per-frame bookkeeping: prune unmounted entries and report whether + /// any morph is still mid-flight (→ keep frames coming). + pub fn tick_at(&mut self, now: Instant) -> bool { + self.frame += 1; + let frame = self.frame; + let mut active = false; + self.entries.retain(|_, entry| { + if entry.seen + 1 < frame { + return false; + } + active |= !entry.settled(now); + true + }); + active + } +} + +thread_local! { + static STATE_MORPHS: RefCell = RefCell::new(StateMorphs::default()); +} + +/// Morph progress (0..1) of `key` toward `on` this frame. +pub fn state_t(key: &str, on: bool, spec: MotionSpec, reduced: bool) -> f32 { + STATE_MORPHS.with(|morphs| { + morphs + .borrow_mut() + .value_at(key, on, spec, reduced, Instant::now()) + }) +} + +/// Frame-drive hook, beside [`hover_fades_active`]: call ONCE per window frame. +pub fn state_morphs_active() -> bool { + STATE_MORPHS.with(|morphs| morphs.borrow_mut().tick_at(Instant::now())) +} + // --------------------------------------------------------------------------- // Reduced motion // --------------------------------------------------------------------------- @@ -1045,6 +1254,171 @@ fn refresh_system(_cx: &mut App) {} #[cfg(test)] mod tests { + #[test] + fn reduced_activity_pulse_is_slow_uniform_and_gentle() { + for phase in [0.0, 0.1, 0.25, 0.5, 0.75, 0.9] { + let pulse = ActivityPulse { + phase, + subtle: true, + }; + let opacity = pulse.opacity(0.0, 0.1); + assert!((0.6..=0.8).contains(&opacity)); + assert_eq!(opacity, pulse.opacity(0.5, 0.1), "no travelling wave"); + } + let rest = ActivityPulse { + phase: 0.0, + subtle: true, + } + .opacity(0.0, 0.1); + let crest = ActivityPulse { + phase: 0.5, + subtle: true, + } + .opacity(0.0, 0.1); + assert!( + crest > rest, + "the activity cue must visibly change brightness" + ); + } + + #[gpui::test] + fn activity_pulse_honors_explicit_on_and_background_pause(cx: &mut gpui::TestAppContext) { + use gpui::AppContext as _; + + cx.update(|cx| { + for (preference, system, pause_in_background, active, animates) in [ + (ReduceMotion::On, false, false, true, false), + (ReduceMotion::On, true, false, true, false), + (ReduceMotion::System, true, true, false, false), + (ReduceMotion::Off, true, true, false, false), + (ReduceMotion::System, true, true, true, true), + (ReduceMotion::System, true, false, false, true), + (ReduceMotion::System, false, false, true, true), + (ReduceMotion::Off, true, false, true, true), + ] { + cx.set_global(MotionState { + preference, + pause_in_background, + system, + active, + }); + apply(cx); + let view = cx.new(|_| ()); + let pulse = activity_pulse(view.entity_id(), cx); + let leased = cx + .try_global::() + .is_some_and(|clock| clock.leases.contains_key(&view.entity_id())); + assert_eq!( + leased, animates, + "{preference:?}, system={system}, pause={pause_in_background}, active={active}" + ); + if reduced_motion(cx) { + assert!(pulse.subtle); + let decorative_view = cx.new(|_| ()); + assert_eq!( + pulse_delta(&ZERON_PULSE, decorative_view.entity_id(), cx), + 0.0 + ); + assert!(!cx.try_global::().is_some_and(|clock| { + clock.leases.contains_key(&decorative_view.entity_id()) + })); + } + } + }); + } + + #[gpui::test] + fn cached_activity_loaders_keep_renewing_under_system_reduced_motion( + cx: &mut gpui::TestAppContext, + ) { + use gpui::{AppContext as _, Context, ParentElement as _, Render}; + + struct Loaders; + impl Render for Loaders { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let theme = crate::theme::Theme::dark(); + gpui::div() + .child(crate::loaders::gradient_spinner( + "working", + &theme, + 2.5, + cx.entity_id(), + cx, + )) + .child(crate::loaders::mini_glyph_spinner( + "sidebar-working", + 2.0, + theme.glyph, + cx.entity_id(), + cx, + )) + } + } + + cx.update(|cx| { + cx.set_global(MotionState { + preference: ReduceMotion::System, + pause_in_background: false, + system: true, + active: true, + }); + apply(cx); + }); + let window = cx.add_window(|_, _| Loaders); + cx.update_window(window.into(), |_, window, cx| window.draw(cx).clear()) + .unwrap(); + cx.run_until_parked(); + for _ in 0..20 { + cx.background_executor.advance_clock(PULSE_TICK); + cx.run_until_parked(); + cx.update_window(window.into(), |_, window, cx| window.draw(cx).clear()) + .unwrap(); + } + cx.update(|cx| { + let clock = cx.global::(); + assert!(clock.tick >= 20); + assert_eq!( + clock.leases.len(), + 2, + "transcript and cached sidebar loader" + ); + assert!( + clock.leases.values().all(|lease| lease.stride == 2), + "both loaders renew at 15Hz" + ); + }); + } + + #[gpui::test] + fn system_reduced_motion_keeps_activity_loaders_ticking(cx: &mut gpui::TestAppContext) { + use gpui::AppContext as _; + + cx.update(|cx| { + cx.set_global(MotionState { + preference: ReduceMotion::System, + pause_in_background: false, + system: true, + active: true, + }); + apply(cx); + assert!(reduced_motion(cx)); + + let view = cx.new(|_| ()); + drop(crate::loaders::gradient_spinner( + "working", + &crate::theme::Theme::dark(), + 2.5, + view.entity_id(), + cx, + )); + assert!( + cx.try_global::() + .is_some_and(|clock| clock.leases.contains_key(&view.entity_id())), + "a working indicator must keep receiving ticks with system reduced motion" + ); + }); + } + #[test] fn pulse_stride_reestablishes_after_each_paint() { let now = Instant::now(); @@ -1263,6 +1637,38 @@ mod tests { assert_eq!(fades.value_at("pill", ms(225)), 0.0, "lands at rest"); } + #[test] + fn state_morph_snaps_on_mount_and_reverses_continuously() { + let mut morphs = StateMorphs::default(); + let t0 = Instant::now(); + let ms = |m: u64| t0 + Duration::from_millis(m); + let morph = |m: &mut StateMorphs, on, at| m.value_at("glyph", on, GLYPH_STATE, false, at); + + // First sight lands on the state — no replay at mount. + assert_eq!(morph(&mut morphs, true, t0), 1.0); + assert!(!morphs.tick_at(t0)); + + // Flip: starts from 1, mid-flight strictly between, lands at 0. + assert_eq!(morph(&mut morphs, false, ms(10)), 1.0); + let mid = morph(&mut morphs, false, ms(80)); + assert!(mid > 0.0 && mid < 1.0, "mid-flight: {mid}"); + assert!(morphs.tick_at(ms(80))); + + // Reverse mid-flight re-anchors — no jump — then lands at 1. + let at_flip = morph(&mut morphs, true, ms(80)); + assert!((at_flip - mid).abs() < 1e-4, "continuity: {mid} vs {at_flip}"); + assert_eq!(morph(&mut morphs, true, ms(400)), 1.0); + assert!(!morphs.tick_at(ms(400))); + + // Reduced motion snaps a flip. + assert_eq!(morphs.value_at("glyph", false, GLYPH_STATE, true, ms(500)), 0.0); + + // Unread for a full frame: pruned (a remount snaps again). + morphs.tick_at(ms(600)); + morphs.tick_at(ms(700)); + assert!(morphs.entries.is_empty()); + } + #[test] fn hover_fade_reduced_motion_snaps() { let mut fades = HoverFades::default(); @@ -1419,3 +1825,13 @@ mod tests { #[cfg(windows)] #[path = "motion/windows_pulse.rs"] mod windows_pulse; + +/// A bounded activation sheen for Fast service tier; GPUI handles reduced motion. +pub fn fast_tier( + id: impl Into, + element: impl IntoElement + gpui::Styled + 'static, +) -> impl IntoElement { + element.with_animation(id, Animation::new(Duration::from_millis(700)), |el, t| { + el.opacity(1.0 - 0.35 * (1.0 - t) * (std::f32::consts::PI * t).sin()) + }) +} diff --git a/crates/ui/src/new_thread_background_effects.rs b/crates/ui/src/new_thread_background_effects.rs index abd0eee26c..bedcd0af4c 100644 --- a/crates/ui/src/new_thread_background_effects.rs +++ b/crates/ui/src/new_thread_background_effects.rs @@ -1,5 +1,5 @@ //! Effects remain cached source-space images; a separate alpha mask follows layout. -use crate::settings::NewThreadBackgroundEffect; +use crate::settings::{NewThreadBackgroundAdjustment, NewThreadBackgroundEffect}; use crate::theme::Theme; use gpui::{Pixels, px}; use std::path::{Path, PathBuf}; @@ -12,12 +12,18 @@ use std::time::Instant; pub(crate) struct Readiness { current: Option>, previous: Option>, + current_adjustment: NewThreadBackgroundAdjustment, + previous_adjustment: NewThreadBackgroundAdjustment, + current_path: Option, + previous_path: Option, started: Option, } pub(crate) struct ArtworkFrame { pub current: Option>, pub previous: Option>, + pub current_adjustment: NewThreadBackgroundAdjustment, + pub previous_adjustment: NewThreadBackgroundAdjustment, pub mix: f32, pub active: bool, } @@ -32,6 +38,8 @@ impl Readiness { pub fn frame( &mut self, image: Option>, + path: Option<&Path>, + adjustment: NewThreadBackgroundAdjustment, enabled: bool, reduced: bool, now: Instant, @@ -39,17 +47,31 @@ impl Readiness { let progress = self.started.map_or(1.0, |start| crossfade_mix(start, now)); if reduced || progress >= 1.0 { self.previous = None; + self.previous_path = None; self.started = None; } + // Effects may still be loading, but a crop belongs to the source path, + // not its effect raster. Replacement images keep their own framing. + if path.is_some() && path == self.current_path.as_deref() { + self.current_adjustment = adjustment.normalized(); + } + if path.is_some() && path == self.previous_path.as_deref() { + self.previous_adjustment = adjustment.normalized(); + } // None while enabled means "still loading", not "remove artwork". if self.started.is_none() && (!enabled || image.is_some()) { let target = if enabled { image } else { None }; if self.current.as_ref().map(|image| image.id) != target.as_ref().map(|image| image.id) { self.previous = self.current.take(); + self.previous_adjustment = self.current_adjustment; + self.previous_path = self.current_path.take(); self.current = target; + self.current_adjustment = adjustment.normalized(); + self.current_path = path.map(Path::to_path_buf); if reduced { self.previous = None; + self.previous_path = None; } else { self.started = Some(now); } @@ -58,6 +80,8 @@ impl Readiness { ArtworkFrame { current: self.current.clone(), previous: self.previous.clone(), + current_adjustment: self.current_adjustment, + previous_adjustment: self.previous_adjustment, mix: self.started.map_or(1.0, |start| crossfade_mix(start, now)), active: self.started.is_some(), } @@ -414,11 +438,13 @@ mod tests { let latest = artwork(); let mut ready = Readiness::default(); let now = Instant::now(); - ready.frame(Some(first.clone()), true, true, now); - let loading = ready.frame(None, true, false, now); + let path = Some(Path::new("background.png")); + let adjustment = NewThreadBackgroundAdjustment::default(); + ready.frame(Some(first.clone()), path, adjustment, true, true, now); + let loading = ready.frame(None, path, adjustment, true, false, now); assert_eq!(loading.current.unwrap().id, first.id); assert!(!loading.active); - let start = ready.frame(Some(next.clone()), true, false, now); + let start = ready.frame(Some(next.clone()), path, adjustment, true, false, now); assert_eq!(start.mix, 0.0); assert_eq!(start.previous.unwrap().id, first.id); let halfway = now @@ -427,22 +453,93 @@ mod tests { * crate::motion::speed_scale() * 0.5, ); - let frame = ready.frame(Some(latest.clone()), true, false, halfway); + let frame = ready.frame(Some(latest.clone()), path, adjustment, true, false, halfway); assert!((frame.mix - 0.875).abs() < 0.001); assert_eq!(frame.current.unwrap().id, next.id); let done = now + std::time::Duration::from_secs(1); - let frame = ready.frame(Some(latest.clone()), true, false, done); + let frame = ready.frame(Some(latest.clone()), path, adjustment, true, false, done); assert_eq!(frame.previous.unwrap().id, next.id); assert_eq!(frame.current.unwrap().id, latest.id); assert_eq!(frame.mix, 0.0); - let frame = ready.frame(Some(latest), true, true, done); + let frame = ready.frame(Some(latest), path, adjustment, true, true, done); assert!(!frame.active); assert!(frame.previous.is_none()); assert_eq!(frame.mix, 1.0); - let removed = ready.frame(None, false, true, done); + let removed = ready.frame(None, None, adjustment, false, true, done); assert!(removed.current.is_none() && removed.previous.is_none()); } + #[test] + fn wallpaper_replacement_and_removal_keep_the_departing_crop() { + let first = artwork(); + let next = artwork(); + let first_path = Some(Path::new("first.png")); + let next_path = Some(Path::new("next.png")); + let cropped = NewThreadBackgroundAdjustment { + focal_x: 0.2, + focal_y: 0.8, + zoom: 2.0, + }; + let centered = NewThreadBackgroundAdjustment::default(); + let mut ready = Readiness::default(); + let now = Instant::now(); + ready.frame(Some(first.clone()), first_path, cropped, true, true, now); + let loading = ready.frame(None, next_path, centered, true, false, now); + assert_eq!(loading.current.as_ref().unwrap().id, first.id); + assert_eq!(loading.current_adjustment, cropped); + let start = ready.frame(Some(next), next_path, centered, true, false, now); + assert_eq!(start.previous_adjustment, cropped); + assert_eq!(start.current_adjustment, centered); + let latest = artwork(); + let latest_path = Some(Path::new("latest.png")); + let waiting = ready.frame(Some(latest.clone()), latest_path, cropped, true, false, now); + assert_eq!(waiting.previous_adjustment, cropped); + assert_eq!(waiting.current_adjustment, centered); + let done = now + std::time::Duration::from_secs(1); + let adopted = ready.frame(Some(latest), latest_path, cropped, true, false, done); + assert_eq!(adopted.previous_adjustment, centered); + assert_eq!(adopted.current_adjustment, cropped); + + let mut ready = Readiness::default(); + ready.frame(Some(first), first_path, cropped, true, true, now); + let removed = ready.frame(None, None, centered, false, false, now); + assert!(removed.current.is_none()); + assert!(removed.previous.is_some()); + assert_eq!(removed.previous_adjustment, cropped); + } + + #[test] + fn applying_crop_during_effect_loading_updates_the_same_source_without_a_fade() { + let first = artwork(); + let effect = artwork(); + let path = Some(Path::new("background.png")); + let centered = NewThreadBackgroundAdjustment::default(); + let cropped = NewThreadBackgroundAdjustment { + zoom: 2.0, + ..centered + }; + let latest = NewThreadBackgroundAdjustment { + zoom: 3.0, + ..centered + }; + let mut ready = Readiness::default(); + let now = Instant::now(); + ready.frame(Some(first), path, centered, true, true, now); + let loading = ready.frame(None, path, cropped, true, false, now); + assert_eq!(loading.current_adjustment, cropped); + assert!(!loading.active); + let start = ready.frame(Some(effect.clone()), path, cropped, true, false, now); + assert_eq!(start.previous_adjustment, cropped); + assert_eq!(start.current_adjustment, cropped); + let applied = ready.frame(Some(effect.clone()), path, latest, true, false, now); + assert_eq!(applied.previous_adjustment, latest); + assert_eq!(applied.current_adjustment, latest); + let done = now + std::time::Duration::from_secs(1); + let applied = ready.frame(Some(effect), path, cropped, true, false, done); + assert_eq!(applied.current_adjustment, cropped); + assert!(!applied.active); + } + #[gpui::test] fn prewarming_decodes_off_thread_and_reuses_artwork_without_hero_geometry( cx: &mut gpui::TestAppContext, diff --git a/crates/ui/src/new_thread_background_mask.rs b/crates/ui/src/new_thread_background_mask.rs index d36ba347b7..303fefde6d 100644 --- a/crates/ui/src/new_thread_background_mask.rs +++ b/crates/ui/src/new_thread_background_mask.rs @@ -1,6 +1,7 @@ //! Paint-time source-alpha feather. Resizing changes only GPU parameters, not //! the image identity, pixels, atlas entry, or an asynchronous raster job. -use gpui::{Bounds, ImageAlphaMask, Pixels, RenderImage, Window, point, px, size}; +use crate::settings::NewThreadBackgroundAdjustment; +use gpui::{Bounds, Corners, ImageAlphaMask, Pixels, Point, RenderImage, Window, point, px, size}; use std::{cell::Cell, rc::Rc, sync::Arc}; // Reveal half-strength artwork through the cutout's darkest area. The main @@ -9,6 +10,122 @@ pub(crate) const CUTOUT_REVEAL_OPACITY: f32 = 0.5; pub(crate) type SurfaceBounds = Rc>>>; +#[derive(Clone, Copy)] +struct FittedGeometry { + bounds: Bounds, + width: f32, + height: f32, + overflow_x: f32, + overflow_y: f32, +} + +fn fitted_geometry( + source_width: f32, + source_height: f32, + bounds: Bounds, + adjustment: NewThreadBackgroundAdjustment, +) -> Option { + let width = f32::from(bounds.size.width); + let height = f32::from(bounds.size.height); + if width <= 0.0 + || height <= 0.0 + || !width.is_finite() + || !height.is_finite() + || source_width <= 0.0 + || source_height <= 0.0 + || !source_width.is_finite() + || !source_height.is_finite() + { + return None; + } + + let adjustment = adjustment.normalized(); + let cover = (width / source_width).max(height / source_height); + let fitted_width = (source_width * cover * adjustment.zoom).max(width); + let fitted_height = (source_height * cover * adjustment.zoom).max(height); + let overflow_x = (fitted_width - width).max(0.0); + let overflow_y = (fitted_height - height).max(0.0); + let fitted = Bounds::new( + point( + bounds.left() - px(overflow_x * adjustment.focal_x), + bounds.top() - px(overflow_y * adjustment.focal_y), + ), + size(px(fitted_width), px(fitted_height)), + ); + Some(FittedGeometry { + bounds: fitted, + width: fitted_width, + height: fitted_height, + overflow_x, + overflow_y, + }) +} + +fn source_geometry( + source: &RenderImage, + bounds: Bounds, + adjustment: NewThreadBackgroundAdjustment, +) -> Option { + let source_size = source.size(0); + fitted_geometry( + source_size.width.0 as f32, + source_size.height.0 as f32, + bounds, + adjustment, + ) +} + +/// Translate a direct-manipulation drag into viewport-independent framing. +pub(crate) fn pan_adjustment( + source: &RenderImage, + bounds: Bounds, + adjustment: NewThreadBackgroundAdjustment, + delta: Point, +) -> NewThreadBackgroundAdjustment { + let adjustment = adjustment.normalized(); + let Some(geometry) = source_geometry(source, bounds, adjustment) else { + return adjustment; + }; + let mut next = adjustment; + if geometry.overflow_x > 0.0 { + next.focal_x -= f32::from(delta.x) / geometry.overflow_x; + } + if geometry.overflow_y > 0.0 { + next.focal_y -= f32::from(delta.y) / geometry.overflow_y; + } + next.normalized() +} + +/// Preserve the source pixel beneath `anchor` while changing magnification. +pub(crate) fn zoom_adjustment_around( + source: &RenderImage, + bounds: Bounds, + adjustment: NewThreadBackgroundAdjustment, + zoom: f32, + anchor: Point, +) -> NewThreadBackgroundAdjustment { + let adjustment = adjustment.normalized(); + let Some(previous) = source_geometry(source, bounds, adjustment) else { + return adjustment; + }; + let mut next = NewThreadBackgroundAdjustment { zoom, ..adjustment }.normalized(); + let Some(fitted) = source_geometry(source, bounds, next) else { + return adjustment; + }; + + let source_x = f32::from(anchor.x - previous.bounds.left()) / previous.width; + let source_y = f32::from(anchor.y - previous.bounds.top()) / previous.height; + if fitted.overflow_x > 0.0 { + let desired_left = f32::from(anchor.x) - source_x * fitted.width; + next.focal_x = (f32::from(bounds.left()) - desired_left) / fitted.overflow_x; + } + if fitted.overflow_y > 0.0 { + let desired_top = f32::from(anchor.y) - source_y * fitted.height; + next.focal_y = (f32::from(bounds.top()) - desired_top) / fitted.overflow_y; + } + next.normalized() +} + fn mask(bounds: Bounds, composer: Bounds, cutout: bool) -> ImageAlphaMask { let height = f32::from(bounds.size.height); // Keep the cleared area open through the hero's bottom. A taller image @@ -46,6 +163,44 @@ fn mask(bounds: Bounds, composer: Bounds, cutout: bool) -> Image } } +fn adjusted_image_paint( + source: &RenderImage, + bounds: Bounds, + adjustment: NewThreadBackgroundAdjustment, + corner_radii: Corners, +) -> Option<(Bounds, Bounds, Corners)> { + let fitted = source_geometry(source, bounds, adjustment)?; + Some((bounds, fitted.bounds, corner_radii)) +} + +fn paint_adjusted_with_mask( + source: Arc, + bounds: Bounds, + adjustment: NewThreadBackgroundAdjustment, + corner_radii: Corners, + mask: Option, + window: &mut Window, +) { + let Some((visible, fitted, corner_radii)) = + adjusted_image_paint(&source, bounds, adjustment, corner_radii) + else { + return; + }; + let _ = window.paint_image_fitted_masked(visible, fitted, corner_radii, source, 0, false, mask); +} + +/// Paint the exact responsive crop used by the new-thread hero, without its +/// composer mask. Appearance uses this for a faithful adjustment preview. +pub(crate) fn paint_adjusted( + source: Arc, + bounds: Bounds, + adjustment: NewThreadBackgroundAdjustment, + corner_radii: Corners, + window: &mut Window, +) { + paint_adjusted_with_mask(source, bounds, adjustment, corner_radii, None, window); +} + /// All elements have finished prepaint before this reads the measured surface, /// so the first visible frame uses the current composer, including on sidebar /// resize and right-panel handoffs. Object-fit cropping is independent. @@ -53,32 +208,17 @@ pub(crate) fn paint( source: Arc, bounds: Bounds, composer: Bounds, + adjustment: NewThreadBackgroundAdjustment, cutout: bool, window: &mut Window, ) { - let width = f32::from(bounds.size.width); - let height = f32::from(bounds.size.height); - let source_size = source.size(0); - if width <= 0.0 || height <= 0.0 || source_size.width.0 <= 0 || source_size.height.0 <= 0 { - return; - } - let scale = (width / source_size.width.0 as f32).max(height / source_size.height.0 as f32); - let fitted_size = size( - px(source_size.width.0 as f32 * scale), - px(source_size.height.0 as f32 * scale), - ); - let fitted = Bounds::new( - bounds.center() - point(fitted_size.width * 0.5, fitted_size.height * 0.5), - fitted_size, - ); - let _ = window.paint_image_fitted_masked( + paint_adjusted_with_mask( + source, bounds, - fitted, + adjustment, Default::default(), - source, - 0, - false, Some(mask(bounds, composer, cutout)), + window, ); } @@ -87,6 +227,221 @@ mod tests { use super::*; use gpui::{Context, Render, canvas, div, prelude::*}; + fn hero(width: f32, height: f32) -> Bounds { + Bounds::new(point(px(40.0), px(24.0)), size(px(width), px(height))) + } + + fn fitted( + source: (f32, f32), + viewport: Bounds, + adjustment: NewThreadBackgroundAdjustment, + ) -> FittedGeometry { + fitted_geometry(source.0, source.1, viewport, adjustment).unwrap() + } + + fn assert_near(actual: Pixels, expected: Pixels) { + assert!( + (f32::from(actual - expected)).abs() < 0.001, + "expected {expected:?}, got {actual:?}" + ); + } + + fn source(width: u32, height: u32) -> RenderImage { + RenderImage::new([image::Frame::new(image::RgbaImage::from_pixel( + width, + height, + image::Rgba([79, 151, 233, 180]), + ))]) + } + + #[test] + fn default_adjustment_is_the_existing_centered_cover_crop() { + let viewport = hero(1000.0, 500.0); + let crop = fitted( + (1600.0, 900.0), + viewport, + NewThreadBackgroundAdjustment::default(), + ); + assert_eq!(crop.bounds.size.width, px(1000.0)); + assert_eq!(crop.bounds.size.height, px(562.5)); + assert_eq!(crop.bounds.left(), viewport.left()); + assert_eq!(crop.bounds.top(), viewport.top() - px(31.25)); + assert_eq!(crop.bounds.center(), viewport.center()); + } + + #[test] + fn focal_extremes_align_the_overflowing_image_edges() { + let viewport = hero(1000.0, 500.0); + let start = fitted( + (1600.0, 900.0), + viewport, + NewThreadBackgroundAdjustment { + focal_x: 0.0, + focal_y: 0.0, + zoom: 2.0, + }, + ); + assert_eq!(start.bounds.left(), viewport.left()); + assert_eq!(start.bounds.top(), viewport.top()); + + let end = fitted( + (1600.0, 900.0), + viewport, + NewThreadBackgroundAdjustment { + focal_x: 1.0, + focal_y: 1.0, + zoom: 2.0, + }, + ); + assert_near(end.bounds.right(), viewport.right()); + assert_near(end.bounds.bottom(), viewport.bottom()); + } + + #[test] + fn zoom_multiplies_cover_before_positioning() { + let viewport = hero(1000.0, 500.0); + let base = fitted( + (1600.0, 900.0), + viewport, + NewThreadBackgroundAdjustment::default(), + ); + let zoomed = fitted( + (1600.0, 900.0), + viewport, + NewThreadBackgroundAdjustment { + zoom: 2.0, + ..Default::default() + }, + ); + assert_near(zoomed.bounds.size.width, base.bounds.size.width * 2.0); + assert_near(zoomed.bounds.size.height, base.bounds.size.height * 2.0); + assert_eq!(zoomed.bounds.center(), viewport.center()); + } + + #[test] + fn resizing_preserves_the_normalized_focal_alignment() { + let adjustment = NewThreadBackgroundAdjustment { + focal_x: 0.23, + focal_y: 0.71, + zoom: 2.2, + }; + for viewport in [hero(420.0, 300.0), hero(960.0, 420.0), hero(1200.0, 760.0)] { + let crop = fitted((1600.0, 900.0), viewport, adjustment); + let x = f32::from(viewport.left() - crop.bounds.left()) / crop.overflow_x; + let y = f32::from(viewport.top() - crop.bounds.top()) / crop.overflow_y; + assert!((x - adjustment.focal_x).abs() < 0.0001); + assert!((y - adjustment.focal_y).abs() < 0.0001); + } + } + + #[test] + fn every_supported_crop_covers_the_entire_viewport() { + for source in [(900.0, 1600.0), (1600.0, 900.0), (1000.0, 1000.0)] { + for viewport in [hero(360.0, 760.0), hero(1000.0, 500.0), hero(640.0, 640.0)] { + for focal_x in [0.0, 0.37, 1.0] { + for focal_y in [0.0, 0.61, 1.0] { + for zoom in [1.0, 1.8, NewThreadBackgroundAdjustment::MAX_ZOOM] { + let crop = fitted( + source, + viewport, + NewThreadBackgroundAdjustment { + focal_x, + focal_y, + zoom, + }, + ); + assert!(crop.bounds.left() <= viewport.left()); + assert!(crop.bounds.top() <= viewport.top()); + assert!(crop.bounds.right() >= viewport.right()); + assert!(crop.bounds.bottom() >= viewport.bottom()); + } + } + } + } + } + } + + #[test] + fn masked_passes_share_identical_adjusted_geometry() { + let viewport = hero(1000.0, 500.0); + let composer = Bounds::new(point(px(180.0), px(320.0)), size(px(720.0), px(124.0))); + let adjustment = NewThreadBackgroundAdjustment { + focal_x: 0.17, + focal_y: 0.82, + zoom: 1.7, + }; + let main = fitted((1600.0, 900.0), viewport, adjustment); + let reveal = fitted((1600.0, 900.0), viewport, adjustment); + assert_eq!(main.bounds, reveal.bounds); + let main_mask = mask(viewport, composer, true); + let reveal_mask = mask(viewport, composer, false); + assert_ne!(main_mask.bounds, reveal_mask.bounds); + assert_eq!(main_mask.bottom_fade, reveal_mask.bottom_fade); + } + + #[test] + fn pan_adjustment_tracks_direct_pointer_movement() { + let source = source(16, 9); + let viewport = hero(100.0, 50.0); + let adjustment = NewThreadBackgroundAdjustment { + zoom: 2.0, + ..Default::default() + }; + let before = source_geometry(&source, viewport, adjustment).unwrap(); + let next = pan_adjustment(&source, viewport, adjustment, point(px(10.0), px(-5.0))); + let after = source_geometry(&source, viewport, next).unwrap(); + assert_near(after.bounds.left(), before.bounds.left() + px(10.0)); + assert_near(after.bounds.top(), before.bounds.top() - px(5.0)); + } + + #[test] + fn pointer_anchored_zoom_keeps_the_same_source_pixel_underneath() { + let source = source(16, 9); + let viewport = hero(100.0, 50.0); + let adjustment = NewThreadBackgroundAdjustment { + zoom: 1.5, + ..Default::default() + }; + let anchor = point(viewport.left() + px(60.0), viewport.top() + px(30.0)); + let before = source_geometry(&source, viewport, adjustment).unwrap(); + let source_x = f32::from(anchor.x - before.bounds.left()) / before.width; + let source_y = f32::from(anchor.y - before.bounds.top()) / before.height; + + let next = zoom_adjustment_around(&source, viewport, adjustment, 2.4, anchor); + let after = source_geometry(&source, viewport, next).unwrap(); + let next_source_x = f32::from(anchor.x - after.bounds.left()) / after.width; + let next_source_y = f32::from(anchor.y - after.bounds.top()) / after.height; + assert!((next_source_x - source_x).abs() < 0.0001); + assert!((next_source_y - source_y).abs() < 0.0001); + } + + #[test] + fn preview_paint_keeps_rounded_viewport_corners_when_panning_and_zooming() { + let source = source(16, 9); + let viewport = hero(100.0, 50.0); + let rounded = Corners::all(px(11.0)); + let initial = NewThreadBackgroundAdjustment::default(); + let zoomed = zoom_adjustment_around(&source, viewport, initial, 2.0, viewport.center()); + let panned = pan_adjustment(&source, viewport, zoomed, point(px(10.0), px(-5.0))); + + for adjustment in [initial, zoomed, panned] { + let (visible, fitted, corners) = + adjusted_image_paint(&source, viewport, adjustment, rounded).unwrap(); + let (hero_visible, hero_fitted, hero_corners) = + adjusted_image_paint(&source, viewport, adjustment, Default::default()).unwrap(); + assert_eq!(visible, viewport); + assert_eq!(corners, rounded); + assert_eq!(hero_corners, Corners::all(px(0.0))); + assert_eq!((visible, fitted), (hero_visible, hero_fitted)); + assert_eq!( + fitted, + source_geometry(&source, viewport, adjustment) + .unwrap() + .bounds + ); + } + } + #[gpui::test] fn background_paint_sees_same_frame_composer_bounds_even_when_painted_first( cx: &mut gpui::TestAppContext, @@ -113,7 +468,14 @@ mod tests { move |bounds, _, window, _| { painted.set(surface.get()); if let Some(composer) = surface.get() { - paint(source, bounds, composer, true, window); + paint( + source, + bounds, + composer, + NewThreadBackgroundAdjustment::default(), + true, + window, + ); } }, ) diff --git a/crates/ui/src/orb/mod.rs b/crates/ui/src/orb/mod.rs new file mode 100644 index 0000000000..baa22ac3e8 --- /dev/null +++ b/crates/ui/src/orb/mod.rs @@ -0,0 +1,19 @@ +// Extracted from Bezel 6141af9c16f7353cdf36003f7404e0a94566a163; MIT. See THIRD_PARTY_NOTICES.md. +//! Thinking orbs — dotted loading indicators for AI & agent UIs, ported from +//! gpui-thinking-orbs (MIT), itself a port of Jakub Antalik's thinking-orbs. +//! +//! The animation engine and playback live in `zeron-orb`, shared with the +//! mobile apps; this module is its GPUI widget and paint path. +//! +//! ```ignore +//! cx.new(|_| Orb::new().state(OrbState::Searching).size(OrbSize::Avatar)) +//! ``` +//! +//! Style flows through the environment like the rest of bezel: [`OrbTheme::Auto`] +//! resolves ink against the installed [`theme::Appearance`] at paint time. + +mod orb; +mod paint; + +pub use orb::{DEFAULT_TARGET_FPS, Orb, orb_element}; +pub use zeron_orb::{ModeKey, OrbSize, OrbState, OrbTheme, Resolved, engine, resolve_preset}; diff --git a/crates/ui/src/orb/orb.rs b/crates/ui/src/orb/orb.rs new file mode 100644 index 0000000000..33820d2a30 --- /dev/null +++ b/crates/ui/src/orb/orb.rs @@ -0,0 +1,451 @@ +// Extracted from Bezel 6141af9c16f7353cdf36003f7404e0a94566a163; MIT. See THIRD_PARTY_NOTICES.md. +//! The [`Orb`] component: a Rust builder over the animation engine. +//! +//! The engine takes continuous, unbounded time — its modes mix incommensurate +//! frequencies, so there is no seamless wrap point and gpui's folded +//! `with_animation` clock would jump at every loop. An entity owns the real +//! clock; [`orb_element`] is the same paint one layer down for hosts that +//! already tick. + +use std::{cell::RefCell, rc::Rc, time::Duration}; + +use gpui::{ + Bounds, Context, IntoElement, ParentElement, Pixels, Render, Styled, Task, Window, canvas, div, + px, +}; +use std::time::Instant; +use zeron_orb::{ + OrbAnimator, OrbSize, OrbState, OrbTheme, + engine::{Frame, draw_mode_into}, + resolve_preset, +}; + +use crate::orb::paint::paint_frame; + +/// Frames per second the orb redraws at when no explicit rate is set. +/// +/// The orb is a status indicator, not a game: its motion is slow and organic, +/// and at 30 fps it is indistinguishable from 60 while costing half as much. +/// Every redraw walks the whole element tree, so the tick rate — not the +/// geometry — is what dominates CPU. +pub const DEFAULT_TARGET_FPS: f32 = 30.0; + +/// Animated thinking-orb status indicator for AI / agent UIs. +/// +/// ```ignore +/// cx.new(|_| Orb::new().state(OrbState::Searching).size(OrbSize::Avatar)) +/// ``` +pub struct Orb { + /// State, size, speed, clock, audio and crossfades — shared with mobile. + animator: OrbAnimator, + /// Uniform magnification of the size preset's painted geometry. + scale: f32, + theme: OrbTheme, + paused: bool, + /// When true, freeze on a static representative frame (`t = 0.6`). The + /// system `reduce_motion` setting forces the same, so hosts only set this + /// for their own per-surface motion preferences. + reduced_motion: bool, + /// Redraw rate ceiling. + target_fps: f32, + /// Stop animating while the host window is not the active one. + pause_when_inactive: bool, + /// Host-controlled visibility. When false the timer is cancelled and the + /// orb freezes — use this when the entity is still mounted but scrolled + /// off-screen (gpui has no intersection observer). + visible: bool, + + /// Geometry buffer reused across frames. Behind `Rc>` because + /// the canvas paint callback must be `'static` and so cannot borrow `self`. + frame: Rc>, + /// Explicit invalidation for animation ticks and semantic changes. Parent + /// renders between ticks reuse the retained geometry. + geometry_dirty: bool, + + /// Pending redraw timer. At most one stays in flight; dropping it cancels + /// animation immediately when the orb becomes paused or invisible. + tick: Option>, + /// Window-activation subscription, registered lazily on first render since + /// it needs a `Window`. Dropping it unsubscribes. + activation: Option, +} + +impl Default for Orb { + fn default() -> Self { + Self::new() + } +} + +impl Orb { + pub fn new() -> Self { + Self { + animator: OrbAnimator::new(OrbState::Working, OrbSize::Avatar), + scale: 1.0, + theme: OrbTheme::Auto, + paused: false, + reduced_motion: false, + target_fps: DEFAULT_TARGET_FPS, + pause_when_inactive: true, + visible: true, + frame: Rc::new(RefCell::new(Frame::new())), + geometry_dirty: true, + tick: None, + activation: None, + } + } + + pub fn state(mut self, state: OrbState) -> Self { + // Nothing is on screen yet, so there is no frame to fade from. + self.animator.set_state(state, &Frame::new()); + self + } + + pub fn size(mut self, size: OrbSize) -> Self { + self.animator.set_size(size); + self + } + + /// Paint the size preset magnified, e.g. a hero orb filling a stage. + pub fn scale(mut self, scale: f32) -> Self { + self.scale = sanitize_scale(scale); + self + } + + pub fn theme(mut self, theme: OrbTheme) -> Self { + self.theme = theme; + self + } + + pub fn speed(mut self, speed: f32) -> Self { + self.animator.set_speed(speed); + self + } + + /// Freeze the accumulated animation time while paused. + pub fn paused(mut self, paused: bool) -> Self { + self.paused = paused; + self + } + + /// Crossfade state geometry without remounting or restarting the clock. + pub fn state_transition(mut self, duration: Duration) -> Self { + self.animator.set_transition(duration); + self + } + + pub fn reduced_motion(mut self, reduced: bool) -> Self { + self.reduced_motion = reduced; + self + } + + /// Cap the redraw rate. Values are clamped to `1.0..=240.0`. + /// + /// Lower is cheaper: cost scales linearly with this number. + pub fn target_fps(mut self, fps: f32) -> Self { + self.target_fps = sanitize_fps(fps); + self + } + + /// Whether to freeze while the host window is inactive. Defaults to `true`. + /// + /// A background window's animation is not visible to anyone, so this is + /// usually free. Set it to `false` if the orb must keep moving in a window + /// that is visible but unfocused — a side panel, or a floating HUD. + pub fn pause_when_inactive(mut self, pause: bool) -> Self { + self.pause_when_inactive = pause; + self + } + + /// Whether the host considers this orb on-screen. Defaults to `true`. + /// + /// gpui has no intersection observer. When you keep the entity mounted in + /// a scrollable list but it has scrolled away, call + /// [`Self::set_visible`]`(false)` (or build with `.visible(false)`) so the + /// timer stops. Prefer unmounting when you can. + pub fn visible(mut self, visible: bool) -> Self { + self.visible = visible; + self + } + + /// Mutable setters for interactive playgrounds. + pub fn set_state(&mut self, state: OrbState, cx: &mut Context) { + // A hidden, paused or reduced orb has a stopped clock, so it switches + // without a fade. + if self.animator.set_state(state, &self.frame.borrow()) { + self.geometry_dirty = true; + cx.notify(); + } + } + + pub fn set_size(&mut self, size: OrbSize, cx: &mut Context) { + if self.animator.set_size(size) { + self.geometry_dirty = true; + cx.notify(); + } + } + + pub fn set_scale(&mut self, scale: f32, cx: &mut Context) { + let scale = sanitize_scale(scale); + if (self.scale - scale).abs() > f32::EPSILON { + self.scale = scale; + cx.notify(); + } + } + + pub fn set_theme(&mut self, theme: OrbTheme, cx: &mut Context) { + if self.theme != theme { + self.theme = theme; + cx.notify(); + } + } + + pub fn set_speed(&mut self, speed: f32, cx: &mut Context) { + if self.animator.set_speed(speed) { + self.geometry_dirty = true; + cx.notify(); + } + } + + /// Audio affects only visual motion, with independent microphone and + /// speaker envelopes evaluated at the orb's frame rate. + pub fn set_audio_levels(&mut self, microphone: f32, speaker: f32, cx: &mut Context) { + if self + .animator + .set_audio_levels(microphone, speaker, Instant::now()) + { + self.geometry_dirty = true; + cx.notify(); + } + } + + pub fn set_paused(&mut self, paused: bool, cx: &mut Context) { + if self.paused == paused { + return; + } + self.paused = paused; + if paused { + self.animator.stop(Instant::now()); + } + self.geometry_dirty = true; + cx.notify(); + } + + pub fn set_reduced_motion(&mut self, reduced: bool, cx: &mut Context) { + if self.reduced_motion != reduced { + self.reduced_motion = reduced; + if reduced { + self.animator.stop(Instant::now()); + self.animator.cancel_transition(); + } + self.geometry_dirty = true; + cx.notify(); + } + } + + pub fn set_target_fps(&mut self, fps: f32, cx: &mut Context) { + self.target_fps = sanitize_fps(fps); + cx.notify(); + } + + pub fn set_pause_when_inactive(&mut self, pause: bool, cx: &mut Context) { + if self.pause_when_inactive != pause { + self.pause_when_inactive = pause; + cx.notify(); + } + } + + /// Host visibility gate — see [`Self::visible`]. + pub fn set_visible(&mut self, visible: bool, cx: &mut Context) { + if self.visible != visible { + self.visible = visible; + if !visible { + self.animator.stop(Instant::now()); + self.animator.cancel_transition(); + } + self.geometry_dirty = true; + cx.notify(); + } + } + + pub fn state_value(&self) -> OrbState { + self.animator.state() + } + + pub fn size_value(&self) -> OrbSize { + self.animator.size() + } + + pub fn theme_value(&self) -> OrbTheme { + self.theme + } + + pub fn speed_value(&self) -> f32 { + self.animator.speed() + } + + pub fn is_paused(&self) -> bool { + self.paused + } + + pub fn reduced_motion_value(&self) -> bool { + self.reduced_motion + } + + pub fn target_fps_value(&self) -> f32 { + self.target_fps + } + + pub fn pause_when_inactive_value(&self) -> bool { + self.pause_when_inactive + } + + pub fn is_visible(&self) -> bool { + self.visible + } + + /// Ink direction, resolved against the installed bezel appearance — + /// `Auto` follows it, no window subscription needed (the appearance is + /// process-wide and its switch repaints the window anyway). + fn dark(&self, cx: &gpui::App) -> bool { + match self.theme { + OrbTheme::Dark => true, + OrbTheme::Light => false, + OrbTheme::Auto => crate::theme::Theme::of(cx).appearance.is_dark(), + } + } + + /// Queue the next redraw, honouring [`Self::target_fps`]. + fn schedule_tick(&mut self, cx: &mut Context) { + // Parent renders may happen between animation frames. Keep the timer + // already in flight rather than cancel/reallocate it and push the next + // frame farther into the future. + if self.tick.is_some() { + return; + } + let period = Duration::from_secs_f32(1.0 / self.target_fps); + self.tick = Some(cx.spawn(async move |this, cx| { + cx.background_executor().timer(period).await; + let _ = this.update(cx, |orb, cx| { + orb.tick = None; + orb.geometry_dirty = true; + cx.notify(); + }); + })); + } +} + +impl Render for Orb { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + // An orb in a background window stops ticking entirely, so it needs a + // nudge to start again when the window comes back. Registered once, + // here, because it needs a `Window`. + if self.activation.is_none() { + self.activation = Some(cx.observe_window_activation(window, |orb, window, cx| { + if orb.pause_when_inactive && !window.is_window_active() { + orb.animator.stop(Instant::now()); + } + orb.geometry_dirty = true; + cx.notify(); + })); + } + + let size_px = self.animator.pixels(); + let scale = self.scale; + let dark = self.dark(cx); + let reduced = self.reduced_motion || cx.reduce_motion(); + let animating = self.visible + && !self.paused + && !reduced + && (!self.pause_when_inactive || window.is_window_active()); + self.animator.tick(Instant::now(), animating, reduced); + let r_min = self.animator.r_min(); + + // Only ticks and semantic changes invalidate geometry. A parent can + // re-render much faster than this orb's target FPS; those extra renders + // reuse the retained frame rather than running animation math again. + if self.geometry_dirty { + self.animator.draw(&mut self.frame.borrow_mut()); + self.geometry_dirty = false; + } + + if animating { + self.schedule_tick(cx); + } else { + // Drop any in-flight tick so a paused, hidden, or backgrounded orb + // costs nothing at all. + self.tick = None; + } + + let frame = self.frame.clone(); + div() + .size(px(size_px * scale)) + .flex_shrink_0() + .overflow_hidden() + .child( + canvas( + move |_bounds: Bounds, _window, _cx| (), + move |bounds, (), window, _cx| { + paint_frame(window, bounds, &frame.borrow(), dark, r_min, scale); + }, + ) + .size_full(), + ) + } +} + +/// Paint one frame of an orb at animation time `t` (seconds, unbounded) — the +/// pure, host-ticked form of [`Orb`]. Build it inside any render that runs on +/// a clock of its own; the reduced-motion convention is `t = 0.6`. +/// +/// `frame` is the caller's geometry buffer, overwritten here and handed to the +/// paint closure. Geometry is a function of `t`, so there is nothing to cache +/// between frames — but a host that keeps one buffer per orb reuses its two +/// `Vec`s forever instead of growing a pair from empty on every tick. +pub fn orb_element( + state: OrbState, + size: OrbSize, + t: f32, + frame: &Rc>, +) -> impl IntoElement { + let resolved = resolve_preset(state, size); + let size_px = size.pixels(); + let frame = frame.clone(); + draw_mode_into( + resolved.mode, + size_px, + t, + &resolved.opts, + &mut frame.borrow_mut(), + ); + let r_min = resolved.opts.r_min.unwrap_or(0.3); + div() + .size(px(size_px)) + .flex_shrink_0() + .overflow_hidden() + .child( + canvas( + move |_bounds: Bounds, _window, _cx| (), + move |bounds, (), window, cx| { + let dark = crate::theme::Theme::of(cx).appearance.is_dark(); + paint_frame(window, bounds, &frame.borrow(), dark, r_min, 1.0); + }, + ) + .size_full(), + ) +} + +fn sanitize_scale(scale: f32) -> f32 { + if scale.is_finite() { + scale.clamp(0.25, 8.0) + } else { + 1.0 + } +} + +fn sanitize_fps(fps: f32) -> f32 { + if fps.is_finite() { + fps.clamp(1.0, 30.0) + } else { + DEFAULT_TARGET_FPS + } +} diff --git a/crates/ui/src/orb/paint.rs b/crates/ui/src/orb/paint.rs new file mode 100644 index 0000000000..01a7487449 --- /dev/null +++ b/crates/ui/src/orb/paint.rs @@ -0,0 +1,133 @@ +// Extracted from Bezel 6141af9c16f7353cdf36003f7404e0a94566a163; MIT. See THIRD_PARTY_NOTICES.md. +//! Paint a backend-agnostic [`Frame`] into a GPUI window. + +use gpui::{ + BorderStyle, Bounds, PathBuilder, Pixels, Point, Window, opaque_grey, point, px, quad, size, + transparent_black, +}; +use zeron_orb::engine::{Frame, Line}; + +/// Map ink `white` + substrate into a grayscale with alpha. +fn ink_color(white: f32, alpha: f32, dark: bool) -> gpui::Hsla { + let w = white.clamp(0.0, 1.0); + let lightness = if dark { 1.0 - w } else { w }; + opaque_grey(lightness, alpha.clamp(0.0, 1.0)) +} + +/// Paint every stroke as its own `Path`, at its exact colour. +/// +/// Merging segments into shared `PathBuilder`s was tried and reverted. It is +/// tempting — a single builder accepts any number of disjoint subpaths, and +/// each `Path` primitive costs GPUI a full two-pass render — but it visibly +/// brightens the connecting state's web. +/// +/// The cause is that GPUI composites paths in two stages with different blend +/// modes: subpaths of one path are rasterised together into a cleared +/// intermediate texture with premultiplied-alpha blending, and that texture is +/// then blitted with `color: OVER, alpha: ADDITIVE`. So where strokes overlap, +/// merging changes which stage accumulates them, and the result does not match +/// painting each segment separately. Merging also forces a shared colour per +/// path, which costs another approximation on top. +/// +/// Measured: batching saves nothing at one orb (2.0 % either way) and about one +/// point of a core at twelve. Not worth a visible change to the artwork. +fn paint_lines(window: &mut Window, origin: Point, lines: &[Line], dark: bool, scale: f32) { + for l in lines { + if l.a < 0.02 { + continue; + } + let mut b = PathBuilder::stroke(px(l.w * scale)); + b.move_to(point( + origin.x + px(l.x1 * scale), + origin.y + px(l.y1 * scale), + )); + b.line_to(point( + origin.x + px(l.x2 * scale), + origin.y + px(l.y2 * scale), + )); + if let Ok(path) = b.build() { + window.paint_path(path, ink_color(l.white, l.a, dark)); + } + } +} + +/// Paint dots as rounded quads (true circles at GPU level via corner radii). +fn paint_dots( + window: &mut Window, + origin: Point, + frame: &Frame, + dark: bool, + r_min: f32, + scale: f32, +) { + for d in &frame.dots { + if d.a < 0.02 { + continue; + } + let r = d.r.max(r_min) * scale; + let diameter = r * 2.0; + let bounds = Bounds { + origin: point( + origin.x + px(d.x * scale - r), + origin.y + px(d.y * scale - r), + ), + size: size(px(diameter), px(diameter)), + }; + // Fully rounded corners → disk. + window.paint_quad(quad( + bounds, + px(r), + ink_color(d.white, d.a, dark), + px(0.), + transparent_black(), + BorderStyle::default(), + )); + } +} + +/// Verification scaffold: `ORB_NO_LAYER=1` paints without the `paint_layer` +/// wrap, i.e. the pre-optimization path. `scripts/golden-diff.sh` uses it to +/// render both paths from one binary and diff the results pixel-for-pixel. +/// Read once; costs a single relaxed atomic load per frame. +fn no_layer() -> bool { + static N: std::sync::OnceLock = std::sync::OnceLock::new(); + *N.get_or_init(|| std::env::var("ORB_NO_LAYER").as_deref() == Ok("1")) +} + +/// Paint a complete frame into `bounds` (top-left of the orb). +/// +/// Background is fully transparent — the host supplies the substrate. +/// `scale` magnifies the preset's geometry uniformly (dots, strokes and +/// spacing), so a preset tuned at one edge length stays crisp at another. +pub fn paint_frame( + window: &mut Window, + bounds: Bounds, + frame: &Frame, + dark: bool, + r_min: f32, + scale: f32, +) { + if no_layer() { + paint_lines(window, bounds.origin, &frame.lines, dark, scale); + paint_dots(window, bounds.origin, frame, dark, r_min, scale); + return; + } + + // Everything is painted inside one layer, which is the single biggest win + // in the paint path. Outside a layer, `Scene::insert_primitive` calls + // `BoundsTree::insert` for *every* primitive to compute its draw order — + // a bounding-volume-hierarchy insertion whose `find_max_ordering` walk + // degenerates when primitives overlap heavily, which is exactly the case + // here (hundreds of discs stacked inside a 64px box). Inside a layer the + // tree is touched once and every primitive inherits the layer's order. + // + // Ordering is preserved: `Scene::finish` sorts with `sort_by_key`, which is + // stable, so quads sharing the layer's order keep their insertion order — + // and the engine already emits dots back-to-front. The painter's algorithm + // still holds. Confirmed by pixel-diffing the golden grid against a build + // with this call removed: every dot-only orb came out byte-identical. + window.paint_layer(bounds, |window| { + paint_lines(window, bounds.origin, &frame.lines, dark, scale); + paint_dots(window, bounds.origin, frame, dark, r_min, scale); + }); +} diff --git a/crates/ui/src/pickers.rs b/crates/ui/src/pickers.rs index ff7e800f6a..58ab0cbb71 100644 --- a/crates/ui/src/pickers.rs +++ b/crates/ui/src/pickers.rs @@ -11,7 +11,10 @@ //! in free functions with unit tests; RPC results land in [`Loadable`] slots //! rendered as skeletons / inline errors with Retry. -use std::collections::HashMap; +mod compact; + +use crate::roll_text::{roll_text, rolling}; +use std::collections::{HashMap, HashSet}; use std::path::PathBuf; use std::time::Duration; @@ -241,8 +244,28 @@ pub fn offered_options( // Pure: folder-browser navigation (used by the shell's add-space flow) // --------------------------------------------------------------------------- +/// Whether `path` is drive-rooted (`C:`, `C:\…`, `C:/…`). Judged by shape, +/// not `cfg`: the device being browsed may be a Windows machine reached from +/// any platform. +fn is_windows_path(path: &str) -> bool { + let bytes = path.as_bytes(); + bytes.len() >= 2 + && bytes[0].is_ascii_alphabetic() + && bytes[1] == b':' + && bytes.get(2).is_none_or(|b| matches!(b, b'/' | b'\\')) +} + /// Parent of an absolute path; `None` at the filesystem root. pub fn parent_path(path: &str) -> Option { + if is_windows_path(path) { + let (drive, rest) = path.split_at(2); + let rest = rest.trim_matches(['/', '\\']); + if rest.is_empty() { + return None; // drive root + } + let parent = rest.rfind(['/', '\\']).map_or("", |at| &rest[..at]); + return Some(format!("{drive}\\{parent}")); + } let trimmed = path.trim_end_matches('/'); if trimmed.is_empty() { return None; // was "/" (or empty) @@ -256,8 +279,10 @@ pub fn parent_path(path: &str) -> Option { /// Join a listing path and an entry name. pub fn child_path(base: &str, name: &str) -> String { - if base.ends_with('/') { + if base.ends_with(['/', '\\']) { format!("{base}{name}") + } else if is_windows_path(base) { + format!("{base}\\{name}") } else { format!("{base}/{name}") } @@ -302,13 +327,29 @@ pub fn segment_target(names: &[&str], query: &str) -> Option { hits.next().is_none().then_some(ix) } -/// Interpret a palette query as a typed path jump: absolute (`/disk2/projects`) -/// or home-relative (`~`, `~/github`). Returns the absolute path to browse, -/// trailing slash trimmed. `home` is the device's resolved home — `None` -/// until the first listing lands, when `~` can't expand yet. A query like -/// `~foo` is a folder name, not a path. +/// Whether a palette query is path-shaped (absolute, home-relative or +/// drive-rooted) rather than a folder name to filter by. +pub fn is_typed_path(query: &str) -> bool { + query.starts_with(['/', '~']) || is_windows_path(query) +} + +/// Interpret a palette query as a typed path jump: absolute (`/disk2/projects`), +/// drive-rooted (`D:\projects`) or home-relative (`~`, `~/github`). Returns the +/// absolute path to browse, trailing separator trimmed. `home` is the device's +/// resolved home — `None` until the first listing lands, when `~` can't expand +/// yet. A query like `~foo` is a folder name, not a path. pub fn typed_path_target(query: &str, home: Option<&str>) -> Option { let query = query.trim(); + if is_windows_path(query) { + let path = query.replace('/', "\\"); + let trimmed = path.trim_end_matches('\\'); + // `D:` and `D:\` both mean the drive root. + return Some(if trimmed.len() == 2 { + format!("{trimmed}\\") + } else { + trimmed.to_string() + }); + } if let Some(rest) = query.strip_prefix('~') { let home = home?.trim_end_matches('/'); if rest.is_empty() { @@ -334,10 +375,17 @@ pub fn typed_path_target(query: &str, home: Option<&str>) -> Option { /// Breadcrumb segments for a path: `(label, full path)`, root first. pub fn breadcrumbs(path: &str) -> Vec<(String, String)> { - let mut out: Vec<(String, String)> = vec![("/".to_string(), "/".to_string())]; - let mut acc = String::new(); - for segment in path.split('/').filter(|s| !s.is_empty()) { - acc.push('/'); + let (drive, sep, rest) = if is_windows_path(path) { + let (drive, rest) = path.split_at(2); + (drive, '\\', rest) + } else { + ("", '/', path) + }; + let root = format!("{drive}{sep}"); + let mut out = vec![(root.clone(), root)]; + let mut acc = drive.to_string(); + for segment in rest.split(['/', sep]).filter(|s| !s.is_empty()) { + acc.push(sep); acc.push_str(segment); out.push((segment.to_string(), acc.clone())); } @@ -358,12 +406,46 @@ pub fn browser_rows(listing: &FolderListing) -> Vec<&zeron_proto::FolderEntry> { /// the first Down lands on row 0. const NO_ACTIVE_ROW: usize = usize::MAX; +/// One project-picker row: every space of a project +/// ([`zeron_proto::view::project_key`]), named for its representative. +#[derive(Clone, Debug, PartialEq, Eq)] +struct ProjectRow { + key: String, + name: String, +} + +/// One device-picker row: a device, and the picked project's checkout on it. +#[derive(Clone, Debug, PartialEq)] +struct DeviceRow { + device_id: String, + name: String, + /// The project's space on this device; `None` when no project is picked. + space: Option, + /// The checkout's path, when the device holds several of the project. + detail: Option, +} + +/// What names the selected model, shared by the composer chip and the +/// compact panel so the two never disagree about it. +#[derive(Clone, Debug, PartialEq, Eq)] +enum ModelName { + /// The catalog row's label, else the remembered pick's, else the id. + Named(SharedString), + /// Nothing names it yet, but the harness or model catalog that would + /// is still on its way. + Loading, + /// Nothing offers a model: no agents at all, or a provider whose + /// catalog came back empty or failed with no pick remembered. + None { no_agents: bool }, +} + /// Which pane the harness/model picker's icon rail is showing (t3code /// ModelPickerContent `selectedInstanceId | "favorites"`). `Harness` means /// "the effective harness's list" — the rail has no browse-without-commit /// state; clicking a brand icon picks that harness. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] enum ModelRail { + All, Favorites, #[default] Harness, @@ -526,8 +608,21 @@ pub struct Pickers { menu_geometry: HashMap, model_trigger_bounds: Option>, // Keep the open menu anchored while its selected label and options change. - open_model_width: Option, + /// The model chip glides to its content's width as the model, effort + /// or fast tier changes; `chip_resizing` clips instead of truncating + /// while it moves. + chip_width: Option, + chip_resizing: bool, open_model_height: f32, + compact_model_list: bool, + /// The model list's provider tab strip: its sideways scroll, and the + /// tab last brought into view as the list scrolled. + compact_strip_scroll: gpui::ScrollHandle, + compact_strip_viewed: Option, + effort_dragging: bool, + compact_motion: compact::CompactMotion, + compact_keyboard: bool, + effort_bounds: Option>, open: popover::Popup, /// The harness/model picker's rail selection (favorites vs the effective /// harness's list). Re-primed on every open. @@ -550,9 +645,26 @@ pub struct Pickers { harnesses: Loadable>, models: HashMap>>, model_refresh_errors: HashMap, + /// The loaded harness list belongs to the previous device: reload it + /// once, keeping its rows on screen meanwhile (see the state observer). + harnesses_stale: bool, + /// The stale harness list's reload is in flight (one request). + harnesses_revalidating: bool, + /// Loaded model slots still holding the previous device's catalog: shown + /// while the new device's reload runs, never used to resolve a send, and + /// cleared only when the new device's answer lands (a failure replaces + /// the rows rather than keeping the wrong device's). + stale_models: HashSet, + /// Stale slots whose reload is in flight (one request per slot). + revalidating: HashSet, refs: Loadable>, /// Space id the `refs` slot belongs to (invalidated on space change). refs_space: Option, + /// The previous project's resolved ref, shown ONLY by the checkout and + /// branch chip labels until this project's refs land — so a project + /// switch never blinks them through "Select ref". Never read by the + /// popover or the send plan. + held_ref: Option, /// Highlighted row in the open list (keyboard nav). active: usize, /// Models-list scroll — keyboard nav keeps the highlighted row in view. @@ -665,6 +777,7 @@ impl Pickers { ComposerInputEvent::PastedImages(_) | ComposerInputEvent::PastedPaths(_) | ComposerInputEvent::PastedText { .. } + | ComposerInputEvent::OpenAttachment(_) | ComposerInputEvent::CursorMoved | ComposerInputEvent::ViewportChanged | ComposerInputEvent::MentionNavigate(_) @@ -687,22 +800,36 @@ impl Pickers { // (and possibly the device) changed under them. let space = state.read(cx).selected_space.clone(); let device = state.read(cx).effective_device_id(); - if space != this.space_owner || device != this.device_owner { + let device_changed = device != this.device_owner; + if space != this.space_owner || device_changed { this.space_owner = space; this.device_owner = device; - this.target_generation = this.target_generation.wrapping_add(1); this.setting_menu = None; this.setting_bounds = None; + this.held_ref = this.selected_ref().cloned().or(this.held_ref.take()); this.refs_task = None; - this.load_task = None; this.config.branch = None; this.config.checkout = CheckoutKind::default(); this.refs = Loadable::Idle; this.refs_space = None; - // Catalogs are per-DEVICE (fetched from the space's host): - // a space switch may land on another device, so refetch. - this.harnesses = Loadable::Idle; - this.models.clear(); + } + // Catalogs are per-DEVICE (fetched from the space's host), so a + // project switch on the same device keeps them. A device switch + // revalidates against the new host with the loaded rows still on + // screen, so the model chip never blanks; in-flight loads for + // the old host are discarded by the generation bump and restart. + if device_changed { + this.target_generation = this.target_generation.wrapping_add(1); + this.load_task = None; + this.harnesses_revalidating = false; + if matches!(this.harnesses, Loadable::Ready(_)) { + this.harnesses_stale = true; + } else { + this.harnesses = Loadable::Idle; + } + this.models.retain(|_, slot| matches!(slot, Loadable::Ready(_))); + this.stale_models = this.models.keys().copied().collect(); + this.revalidating.clear(); this.model_refresh_errors.clear(); this.catalog_rev += 1; } @@ -753,8 +880,16 @@ impl Pickers { target_generation: 0, menu_geometry: HashMap::new(), model_trigger_bounds: None, - open_model_width: None, + chip_width: None, + chip_resizing: false, open_model_height: model_menu_height(0), + compact_model_list: false, + compact_strip_scroll: gpui::ScrollHandle::new(), + compact_strip_viewed: None, + effort_dragging: false, + compact_motion: compact::CompactMotion::default(), + compact_keyboard: false, + effort_bounds: None, config: DraftConfig::default(), title, defaults, @@ -777,8 +912,13 @@ impl Pickers { harnesses: Loadable::Idle, models: HashMap::new(), model_refresh_errors: HashMap::new(), + harnesses_stale: false, + harnesses_revalidating: false, + stale_models: HashSet::new(), + revalidating: HashSet::new(), refs: Loadable::Idle, refs_space: None, + held_ref: None, active: 0, model_scroll: gpui::UniformListScrollHandle::new(), model_rows_cache: std::cell::RefCell::new(None), @@ -891,8 +1031,12 @@ impl Pickers { // New-chat canvas: the remembered last-used harness (sticky defaults), // when the loaded catalog still offers it (the device may have // disabled it in Settings → Providers since). + // A list still from the previous device reads as not loaded: it may + // offer harnesses this device lacks, and what the chip shows must be + // what a send would carry. + let fresh = self.harnesses.ready().filter(|_| !self.harnesses_stale); if let Some(harness) = self.defaults.harness { - let offered = match self.harnesses.ready() { + let offered = match fresh { Some(list) => offered_harnesses(list).iter().any(|d| d.id == harness), None => true, // catalog not loaded yet — trust the memory }; @@ -904,9 +1048,7 @@ impl Pickers { // harness first, and resolving chips against it would boot the // new-chat canvas onto "Mock" instead of Claude Code + its default // model (it stays available under `ZERON_HARNESS=mock`). - self.harnesses - .ready() - .and_then(|list| offered_harnesses(list).first().map(|d| d.id)) + fresh.and_then(|list| offered_harnesses(list).first().map(|d| d.id)) } /// Effective model id: the draft pick, the selected chat's config, or (on @@ -931,15 +1073,30 @@ impl Pickers { /// Effective reasoning — always concrete once the model is known: the /// draft pick / chat config / remembered default, clamped to the selected /// model's ladder, falling back to the model's default level. - fn effective_reasoning(&self, cx: &App) -> Option { - let explicit = self.config.reasoning.or_else(|| { + /// The reasoning level picked, configured or remembered — before any + /// clamping to the selected model's ladder. + fn explicit_reasoning(&self, cx: &App) -> Option { + self.config.reasoning.or_else(|| { match self.state.read(cx).selected_chat_row() { Some(chat) => chat.config.as_ref().and_then(|c| c.reasoning), - // New chat: the remembered last-used level. - None => self.defaults.reasoning, + // New chat: the level last used with this model, else the + // last-used level overall. + None => self + .effective_harness(cx) + .and_then(|h| self.defaults.reasoning_for(h, self.effective_model_id(cx))) + .or(self.defaults.reasoning), } - }); - if self.selected_model(cx).is_none() { + }) + } + + fn effective_reasoning(&self, cx: &App) -> Option { + let explicit = self.explicit_reasoning(cx); + // The old device's ladder must not clamp the chip's level either: + // a send in this window carries the unclamped value. + let stale = self + .effective_harness(cx) + .is_some_and(|h| self.stale_models.contains(&h)); + if stale || self.selected_model(cx).is_none() { // Catalog not loaded yet: show the explicit value as-is (nothing // to clamp against); it resolves to a concrete level on load. return explicit; @@ -960,6 +1117,29 @@ impl Pickers { selected_catalog_model(models, selected) } + fn model_name(&self, cx: &App) -> ModelName { + if self.no_agents_available() && self.effective_harness(cx).is_none() { + return ModelName::None { no_agents: true }; + } + if let Some(label) = self.selected_model_label(cx) { + return ModelName::Named(label.into()); + } + // A list still from the previous device is loading for this one. + let catalog_loading = self.harnesses_stale + || matches!(self.harnesses, Loadable::Idle | Loadable::Loading); + let models_loading = self.effective_harness(cx).is_some_and(|harness| { + !matches!( + self.models.get(&harness), + Some(Loadable::Ready(_)) | Some(Loadable::Error(_)) + ) + }); + if catalog_loading || models_loading { + ModelName::Loading + } else { + ModelName::None { no_agents: false } + } + } + fn selected_model_label(&self, cx: &App) -> Option { self.selected_model(cx) .map(|model| model.label.clone()) @@ -1017,8 +1197,10 @@ impl Pickers { /// enabled). False while the catalog is still loading or failed /// (nothing to conclude yet; offline sends must not be blocked on it). pub fn no_agents_available(&self) -> bool { + // The previous device's list can't speak for this one. self.harnesses .ready() + .filter(|_| !self.harnesses_stale) .is_some_and(|list| self.offered(list).is_empty()) } @@ -1038,8 +1220,20 @@ impl Pickers { /// `Mutate createChat`: concrete model + reasoning whenever the catalog is /// loaded (no "engine picks a default" passthrough). pub fn resolved(&self, cx: &App) -> ResolvedRunConfig { + let harness = self.effective_harness(cx); + // A catalog still from the previous device must not pick for this + // one (its default model, its effort ladder): send only what was + // chosen or remembered, as when no catalog has loaded. + if harness.is_some_and(|h| self.stale_models.contains(&h)) { + return ResolvedRunConfig { + harness, + model: self.effective_model_id(cx).map(str::to_string), + reasoning: self.explicit_reasoning(cx), + model_options: self.explicit_options(cx), + }; + } ResolvedRunConfig { - harness: self.effective_harness(cx), + harness, model: self .selected_model(cx) .map(|m| m.id.clone()) @@ -1080,6 +1274,7 @@ impl Pickers { /// Outside clicks and navigation keep focus at the clicked destination. fn dismiss(&mut self, cx: &mut Context) { self.focus_on_mount = false; + self.effort_dragging = false; self.cancel_setting_hover(); self.setting_menu = None; self.setting_bounds = None; @@ -1135,8 +1330,12 @@ impl Pickers { return; } if kind == PickerKind::HarnessModel { - self.open_model_width = self.model_trigger_bounds.map(|bounds| bounds.size.width); self.open_model_height = model_menu_height(self.setting_groups(cx).len()); + self.compact_model_list = false; + self.compact_strip_viewed = None; + self.effort_dragging = false; + self.compact_keyboard = false; + self.compact_motion = compact::CompactMotion::default(); } self.open.open(kind); self.focus_on_mount = true; @@ -1171,7 +1370,9 @@ impl Pickers { self.config.harness = None; self.model_rail = ModelRail::Harness; } else if kind == PickerKind::HarnessModel { - self.model_rail = if !self.harness_locked(cx) && !self.defaults.favorites.is_empty() { + self.model_rail = if self.compact_model_picker(cx) { + ModelRail::All + } else if !self.harness_locked(cx) && !self.defaults.favorites.is_empty() { ModelRail::Favorites } else { ModelRail::Harness @@ -1186,7 +1387,7 @@ impl Pickers { }, PickerKind::Branch => self.selected_ref_index(cx), PickerKind::HarnessModel => self.selected_model_index(cx), - PickerKind::Space => self.selected_space_index(cx), + PickerKind::Space => self.selected_project_index(cx), PickerKind::Device => self.selected_device_index(cx), }; if kind == PickerKind::HarnessModel { @@ -1266,7 +1467,9 @@ impl Pickers { let reload = match self.harnesses { Loadable::Idle => true, Loadable::Loading => false, - Loadable::Ready(_) | Loadable::Error(_) => force, + Loadable::Ready(_) | Loadable::Error(_) => { + force || (self.harnesses_stale && !self.harnesses_revalidating) + } }; if !reload { return; @@ -1274,6 +1477,9 @@ impl Pickers { let Some(engine) = self.engine(cx) else { return; }; + if self.harnesses_stale { + self.harnesses_revalidating = true; + } let target = self.space_target(cx); let generation = self.target_generation; if !matches!(self.harnesses, Loadable::Ready(_)) { @@ -1299,6 +1505,8 @@ impl Pickers { if pickers.target_generation != generation { return; } + pickers.harnesses_stale = false; + pickers.harnesses_revalidating = false; pickers.catalog_rev += 1; pickers.harnesses = match result { Ok(value) => match serde_json::from_value::>(value) { @@ -1343,7 +1551,11 @@ impl Pickers { let reload = match self.models.get(&harness) { None | Some(Loadable::Idle) => true, Some(Loadable::Loading) => false, - Some(Loadable::Ready(_)) | Some(Loadable::Error(_)) => force, + Some(Loadable::Ready(_)) | Some(Loadable::Error(_)) => { + force + || (self.stale_models.contains(&harness) + && !self.revalidating.contains(&harness)) + } }; if !reload { return; @@ -1351,6 +1563,9 @@ impl Pickers { let Some(engine) = self.engine(cx) else { return; }; + if self.stale_models.contains(&harness) { + self.revalidating.insert(harness); + } let target = self.space_target(cx); let generation = self.target_generation; if !matches!(self.models.get(&harness), Some(Loadable::Ready(_))) { @@ -1410,13 +1625,29 @@ impl Pickers { }, Err(err) => Loadable::Error(err.to_string()), }; - pickers.apply_model_catalog(harness, loaded, cx); + pickers.land_model_reload(harness, loaded, cx); }) .ok(); }) .detach(); } + /// A model load for the current device landed. A slot that still held + /// the previous device's rows is current again either way — on failure + /// the error replaces those rows instead of being retained under them. + fn land_model_reload( + &mut self, + harness: HarnessId, + loaded: Loadable>, + cx: &mut Context, + ) { + self.revalidating.remove(&harness); + if self.stale_models.remove(&harness) && matches!(loaded, Loadable::Error(_)) { + self.models.remove(&harness); + } + self.apply_model_catalog(harness, loaded, cx); + } + fn apply_model_catalog( &mut self, harness: HarnessId, @@ -1508,6 +1739,7 @@ impl Pickers { .call(methods::LIST_REFS, serde_json::Value::Object(params)) .await; this.update(cx, |pickers, cx| { + pickers.held_ref = None; pickers.refs = match result { Ok(value) => match serde_json::from_value::>(value) { Ok(refs) => Loadable::Ready(refs), @@ -1635,7 +1867,7 @@ impl Pickers { // provider settings so both the picker and first createChat use the // new harness, even when its model catalog has not loaded yet. let model = self.defaults.model_for(harness).map(|m| m.id.clone()); - let reasoning = self.defaults.reasoning; + let reasoning = self.defaults.reasoning_for(harness, model.as_deref()); let options = model .as_deref() .and_then(|model| self.defaults.model_options_for(harness, model)) @@ -1688,7 +1920,9 @@ impl Pickers { self.update_chat_config(cx, move |config| config.model = Some(model_id)); } else { // New chat: draft pick + sticky last-used memory for this harness. + // Effort follows the model: its own remembered level, if any. self.config.model = Some(model_id.clone()); + self.config.reasoning = None; if let Some(harness) = self.effective_harness(cx) { let label = self .models @@ -1710,7 +1944,13 @@ impl Pickers { self.update_chat_config(cx, move |config| config.reasoning = Some(level)); } else { self.config.reasoning = Some(level); - self.defaults.reasoning = Some(level); + if let Some(harness) = self.effective_harness(cx) { + let model = self.effective_model_id(cx).map(str::to_owned); + self.defaults + .remember_reasoning(harness, model.as_deref(), level); + } else { + self.defaults.reasoning = Some(level); + } self.save_defaults(); } cx.notify(); @@ -1996,6 +2236,8 @@ impl Pickers { fn activate_model_row(&mut self, cx: &mut Context) { if self.setting_menu.is_some() { self.activate_setting_choice(cx); + } else if self.compact_model_picker(cx) && self.compact_model_list { + self.activate_model_index(self.active, cx); } else if let Some(index) = self.active.checked_sub(self.model_rows_len(cx)) { if let Some(group) = self.setting_groups(cx).get(index) { self.open_setting(SettingScope::Tray, group.id.clone(), cx); @@ -2022,20 +2264,35 @@ impl Pickers { self.pick_harness(row.harness, cx); } self.pick_model(row.model.id, cx); + if self.compact_model_picker(cx) { + self.compact_model_list = false; + self.focus_on_mount = true; + } } /// Star/unstar a model and persist it with the sticky defaults. fn toggle_model_favorite(&mut self, harness: HarnessId, model: &str, cx: &mut Context) { self.reload_defaults(); + let keyboard_row = (self.compact_model_picker(cx) && self.compact_keyboard) + .then(|| self.model_rows(cx).get(self.active).cloned()) + .flatten(); self.defaults.toggle_favorite(harness, model); self.persist_defaults(); self.catalog_rev += 1; - // Starring REORDERS the list (stars float to the top / leave the - // favorites view) — re-home the keyboard highlight onto the SELECTED - // row so exactly one row reads highlighted afterwards. Following the - // starred row instead left its cursor wash next to the selected - // row's ring: "two highlighted rows" (user report, twice). + // Pointer interactions retain the selected-row highlight after sorting. self.active = self.selected_model_index(cx); + // Keyboard focus follows identity, independently of selection styling. + if let Some(row) = keyboard_row { + self.active = self + .model_rows(cx) + .iter() + .position(|candidate| { + candidate.harness == row.harness && candidate.model.id == row.model.id + }) + .unwrap_or(self.active); + self.model_scroll + .scroll_to_item(self.active, gpui::ScrollStrategy::Nearest); + } cx.notify(); } @@ -2088,6 +2345,20 @@ impl Pickers { .or_else(|| self.selected_ref().map(|r| r.name.clone())) } + /// The ref the checkout and branch chips name: the resolved one, else — + /// while a project switch's refs load — the previous project's. + fn display_ref(&self) -> Option<&RepoRef> { + match self.refs { + Loadable::Idle | Loadable::Loading => self.selected_ref().or(self.held_ref.as_ref()), + _ => self.selected_ref(), + } + } + + fn display_ref_is_worktree(&self) -> bool { + self.display_ref() + .is_some_and(|row| row.worktree_path.is_some()) + } + /// The existing worktree the picked ref is materialized in, if any. fn selected_ref_worktree(&self) -> Option { self.selected_ref().and_then(|r| r.worktree_path.clone()) @@ -2117,7 +2388,7 @@ impl Pickers { match self.config.checkout { CheckoutKind::NewWorktree => "New worktree", CheckoutKind::Local => { - if self.selected_ref_worktree().is_some() { + if self.display_ref_is_worktree() { "Current worktree" } else { "Current checkout" @@ -2129,65 +2400,100 @@ impl Pickers { /// Label of the ref trigger: `From ` only when a NEW worktree will be /// created off it (t3code `getBranchTriggerLabel`); the bare name otherwise. fn ref_label(&self) -> SharedString { - match (self.config.checkout, self.effective_ref_name()) { + let name = self + .config + .branch + .clone() + .or_else(|| self.display_ref().map(|row| row.name.clone())); + match (self.config.checkout, name) { (_, None) => SharedString::from("Select ref"), (CheckoutKind::NewWorktree, Some(name)) => SharedString::from(format!("From {name}")), (CheckoutKind::Local, Some(name)) => SharedString::from(name), } } - // ---- the space picker (new-session canvas) ---- + // ---- the project picker (new-session canvas) ---- - /// The picker's project rows: scoped to the canvas's device — the device - /// switcher narrows the list, projects on other devices don't show - /// (pick the device first, then its project). Unscoped only while the - /// device is still unknown (pre-probe boot). - fn scoped_space_rows(&self, cx: &App) -> Vec { + /// The picker's project rows: one per project across every device — + /// clones and worktrees sharing a repository identity are one row, named + /// for their representative. The device chip then picks which checkout. + fn project_rows(&self, cx: &App) -> Vec { let state = self.state.read(cx); - let device = state.effective_device_id(); - state - .spaces_sorted() - .into_iter() - .filter(|s| match device.as_deref() { - Some(d) => s.device_id == d, - None => true, - }) - .cloned() - .collect() + let mut rows: Vec = Vec::new(); + for space in &state.spaces { + let key = zeron_proto::view::project_key(space); + if rows.iter().any(|row| row.key == key) { + continue; + } + rows.push(ProjectRow { + key, + name: state.representative_space(space).display_name().to_string(), + }); + } + rows.sort_by_key(|row| (row.name.to_lowercase(), row.key.clone())); + rows } - /// [`Self::scoped_space_rows`] matching the search query, ranked + /// [`Self::project_rows`] matching the search query, ranked /// (`popover::filter_indices`). - fn filtered_space_rows(&self, cx: &App) -> Vec { + fn filtered_project_rows(&self, cx: &App) -> Vec { let query = self.search.read(cx).text().to_string(); - let spaces = self.scoped_space_rows(cx); - let names: Vec = spaces - .iter() - .map(|s| s.display_name().to_string()) - .collect(); + let rows = self.project_rows(cx); + let names: Vec = rows.iter().map(|row| row.name.clone()).collect(); popover::filter_indices(&query, &names) .into_iter() - .map(|ix| spaces[ix].clone()) + .map(|ix| rows[ix].clone()) .collect() } /// Current project row on an unsearched open, or the final opt-out row. /// An implicit empty selection has no highlight until the user navigates. - fn selected_space_index(&self, cx: &App) -> usize { + fn selected_project_index(&self, cx: &App) -> usize { if self.state.read(cx).no_project { - return self.scoped_space_rows(cx).len(); + return self.project_rows(cx).len(); } let selected = self .state .read(cx) .selected_space_row() - .map(|s| s.id.clone()); + .map(zeron_proto::view::project_key); selected - .as_deref() - .and_then(|id| self.scoped_space_rows(cx).iter().position(|s| s.id == id)) + .and_then(|key| self.project_rows(cx).iter().position(|row| row.key == key)) .unwrap_or(NO_ACTIVE_ROW) } + /// Pick a project, keeping the device when it has a checkout of it: + /// the current checkout, else one on the current device, else this + /// device's, else the first. + fn pick_project(&mut self, key: String, cx: &mut Context) { + let space_id = { + let state = self.state.read(cx); + let Some(member) = state + .spaces + .iter() + .find(|s| zeron_proto::view::project_key(s) == key) + else { + return; + }; + let members = state.project_members(member); + let selected = state.selected_space_row().map(|s| s.id.clone()); + let device = state.effective_device_id(); + members + .iter() + .find(|s| selected.as_deref() == Some(s.id.as_str())) + .or_else(|| { + members + .iter() + .find(|s| device.as_deref() == Some(s.device_id.as_str())) + }) + .or(members.first()) + .map(|s| s.id.clone()) + }; + if let Some(space_id) = space_id { + self.pick_space(space_id, cx); + } + } + /// Re-home the canvas onto another project. The state observer does the /// heavy lifting: branch draft, ref cache, and the per-device /// harness/model catalogs all invalidate on the project change. @@ -2237,11 +2543,39 @@ impl Pickers { } } - /// Devices in picker order: this device first, then by name. - fn device_rows(&self, cx: &App) -> Vec { + /// The device picker's rows. With a project picked: its checkouts, one + /// row per device (this device first), with the path when a device holds + /// several. Without one: every device — project-less sessions run in its + /// home. + fn device_rows(&self, cx: &App) -> Vec { let state = self.state.read(cx); let local = state.local_device_id.clone(); - let mut devices: Vec = state.devices.clone(); + let device_name = |id: &str| { + state + .device_name(id) + .unwrap_or("Unknown device") + .to_string() + }; + if let Some(space) = state.selected_space_row() { + let members = state.project_members(space); + return members + .iter() + .map(|member| { + let shared = members + .iter() + .filter(|other| other.device_id == member.device_id) + .count() + > 1; + DeviceRow { + device_id: member.device_id.clone(), + name: device_name(&member.device_id), + space: Some((*member).clone()), + detail: shared.then(|| member.path.clone()), + } + }) + .collect(); + } + let mut devices: Vec<&zeron_proto::Device> = state.devices.iter().collect(); devices.sort_by_key(|d| { ( local.as_deref() != Some(d.id.as_str()), @@ -2250,14 +2584,28 @@ impl Pickers { ) }); devices + .into_iter() + .map(|device| DeviceRow { + device_id: device.id.clone(), + name: device.name.clone(), + space: None, + detail: None, + }) + .collect() } /// [`Self::device_rows`] filtered by the search box (same ranked /// substring match as the project rows). - fn filtered_device_rows(&self, cx: &App) -> Vec { + fn filtered_device_rows(&self, cx: &App) -> Vec { let query = self.search.read(cx).text().to_string(); let rows = self.device_rows(cx); - let names: Vec = rows.iter().map(|d| d.name.clone()).collect(); + let names: Vec = rows + .iter() + .map(|row| match &row.detail { + Some(detail) => format!("{} {detail}", row.name), + None => row.name.clone(), + }) + .collect(); popover::filter_indices(&query, &names) .into_iter() .map(|ix| rows[ix].clone()) @@ -2265,26 +2613,51 @@ impl Pickers { } fn selected_device_index(&self, cx: &App) -> usize { - let effective = self.state.read(cx).effective_device_id(); + let (selected, effective) = { + let state = self.state.read(cx); + ( + state.selected_space_row().map(|s| s.id.clone()), + state.effective_device_id(), + ) + }; self.device_rows(cx) .iter() - .position(|d| Some(d.id.as_str()) == effective.as_deref()) + .position(|row| match &row.space { + Some(space) => selected.as_deref() == Some(space.id.as_str()), + None => effective.as_deref() == Some(row.device_id.as_str()), + }) .unwrap_or(0) } - /// The device popover: search + one row per device (name, muted "offline" - /// tag, check on the canvas's effective device). + /// A device row picks the project's checkout there, or — without a + /// project — the device itself. + fn pick_device_row(&mut self, row: DeviceRow, cx: &mut Context) { + match row.space { + Some(space) => self.pick_space(space.id, cx), + None => self.pick_device(row.device_id, cx), + } + } + + /// The device popover: search + one row per device (name, muted path when + /// a device holds several checkouts of the project, offline glyph, check + /// on the canvas's target). fn render_device_popover(&mut self, cx: &mut Context) -> AnyElement { let theme = Theme::of(cx).for_popup(); let now = chrono::Utc::now(); let rows = self.filtered_device_rows(cx); - let (effective, local, online): (Option, Option, Vec) = { + let (selected_space, effective, local, online): ( + Option, + Option, + Option, + Vec, + ) = { let state = self.state.read(cx); ( + state.selected_space_row().map(|s| s.id.clone()), state.effective_device_id(), state.local_device_id.clone(), rows.iter() - .map(|d| state.device_online(&d.id, now)) + .map(|row| state.device_online(&row.device_id, now)) .collect(), ) }; @@ -2308,11 +2681,16 @@ impl Pickers { .gap(px(2.0)) .max_h(px(self.list_budget(64.0))) .children(rows.into_iter().zip(online).enumerate().map( - |(ix, (device, online))| { - let is_local = local.as_deref() == Some(device.id.as_str()); - let label: SharedString = device.name.clone().into(); - let is_selected = effective.as_deref() == Some(device.id.as_str()); - let pick_id = device.id.clone(); + |(ix, (row, online))| { + let is_local = local.as_deref() == Some(row.device_id.as_str()); + let label: SharedString = row.name.clone().into(); + let is_selected = match &row.space { + Some(space) => { + selected_space.as_deref() == Some(space.id.as_str()) + } + None => effective.as_deref() == Some(row.device_id.as_str()), + }; + let detail = row.detail.clone().map(SharedString::from); popover::menu_row_nav( &theme, is_selected, @@ -2321,9 +2699,28 @@ impl Pickers { ) .id(("device-row", ix)) .on_click(cx.listener(move |this, _, _, cx| { - this.pick_device(pick_id.clone(), cx); + this.pick_device_row(row.clone(), cx); })) - .child(div().flex_1().min_w_0().truncate().child(label)) + .child( + div() + .flex_1() + .min_w_0() + .flex() + .flex_row() + .items_baseline() + .gap(px(6.0)) + .child(div().flex_none().child(label)) + .when_some(detail, |el, detail| { + el.child( + div() + .min_w_0() + .truncate() + .text_size(crate::typography::ui_rems(10.0)) + .text_color(theme.text_muted) + .child(detail), + ) + }), + ) // The local device wears a muted right-aligned "You" // instead of a "(this device)" suffix in the name. .when(is_local, |el| { @@ -2358,26 +2755,24 @@ impl Pickers { .into_any_element() } - /// The project popover: search + one row per project on the picked device - /// (check on the current pick), then "New project…" and the opt-out rows. Rows - /// are device-scoped, so no per-row `@ device` tag — the device chip next - /// door names the host. + /// The project popover: search + one row per project across devices + /// (check on the current pick), then "New project…" and the opt-out rows. + /// No per-row `@ device` tag — the device chip next door picks the host. fn render_space_popover(&mut self, cx: &mut Context) -> AnyElement { let theme = Theme::of(cx).for_popup(); - let rows = self.filtered_space_rows(cx); + let rows = self.filtered_project_rows(cx); let selected = self .state .read(cx) .selected_space_row() - .map(|s| s.id.clone()); + .map(zeron_proto::view::project_key); let active = self.active; let no_project_index = rows.len(); let scrollbar = popover::rail(self, "space-scrollbar", &theme, cx); let body: AnyElement = if rows.is_empty() { - // Distinguish "the filter ate everything" from "this device has - // no projects yet" — the scoped list makes the latter common. + // Distinguish "the filter ate everything" from "no projects yet". let empty: &str = if self.search.read(cx).text().is_empty() { - "No projects on this device." + "No projects yet." } else { "No projects match." }; @@ -2397,10 +2792,10 @@ impl Pickers { .flex_col() .gap(px(2.0)) .max_h(px(self.list_budget(152.0))) - .children(rows.into_iter().enumerate().map(|(ix, space)| { - let label: SharedString = space.display_name().to_string().into(); - let is_selected = selected.as_deref() == Some(space.id.as_str()); - let pick_id = space.id.clone(); + .children(rows.into_iter().enumerate().map(|(ix, row)| { + let label: SharedString = row.name.into(); + let is_selected = selected.as_deref() == Some(row.key.as_str()); + let key = row.key; popover::menu_row_nav( &theme, is_selected, @@ -2409,7 +2804,7 @@ impl Pickers { ) .id(("space-row", ix)) .on_click(cx.listener(move |this, _, _, cx| { - this.pick_space(pick_id.clone(), cx); + this.pick_project(key.clone(), cx); })) .child(div().flex_1().min_w_0().truncate().child(label)) })), @@ -2488,17 +2883,17 @@ impl Pickers { self.pick_ref(row, cx); } if self.open_kind() == Some(PickerKind::Space) { - let rows = self.filtered_space_rows(cx); - if let Some(space) = rows.get(self.active) { - self.pick_space(space.id.clone(), cx); + let rows = self.filtered_project_rows(cx); + if let Some(row) = rows.get(self.active) { + self.pick_project(row.key.clone(), cx); } else if self.active == rows.len() { self.pick_no_project(cx); } } if self.open_kind() == Some(PickerKind::Device) - && let Some(device) = self.filtered_device_rows(cx).into_iter().nth(self.active) + && let Some(row) = self.filtered_device_rows(cx).into_iter().nth(self.active) { - self.pick_device(device.id, cx); + self.pick_device_row(row, cx); } // Palette-search Enter submits the highlighted model or setting. if self.open_kind() == Some(PickerKind::HarnessModel) { @@ -2513,6 +2908,23 @@ impl Pickers { if !self.open.is_open() { return; } + if self.open_kind() == Some(PickerKind::HarnessModel) && self.compact_model_picker(cx) { + // Escape only steps back a page or closes a submenu: it should + // not light focus rings on the controls it returns to. The next + // navigation key brings them back. + self.compact_keyboard = event.keystroke.key != "escape"; + if self.compact_model_list + && event.keystroke.modifiers.platform + && event.keystroke.modifiers.shift + && event.keystroke.key.eq_ignore_ascii_case("f") + { + if let Some(row) = self.model_rows(cx).get(self.active).cloned() { + self.toggle_model_favorite(row.harness, &row.model.id, cx); + } + cx.stop_propagation(); + return; + } + } if self.setting_menu.is_some() { match event.keystroke.key.as_str() { "escape" | "left" => { @@ -2541,7 +2953,19 @@ impl Pickers { cx.notify(); return; } + if self.open_kind() == Some(PickerKind::HarnessModel) && self.compact_model_picker(cx) { + if !self.compact_model_list { + self.compact_panel_key(event, cx); + return; + } + if event.keystroke.key == "escape" { + self.show_compact_panel(cx); + cx.stop_propagation(); + return; + } + } if event.keystroke.key == "right" + && !self.compact_model_picker(cx) && self.open_kind() == Some(PickerKind::HarnessModel) && self.active >= self.model_rows_len(cx) { @@ -2579,9 +3003,14 @@ impl Pickers { Some(PickerKind::Checkout) => 2, // Continue from model rows into the pinned settings triggers. Some(PickerKind::HarnessModel) => { - self.model_rows_len(cx) + self.setting_groups(cx).len() + self.model_rows_len(cx) + + if self.compact_model_picker(cx) { + 0 + } else { + self.setting_groups(cx).len() + } } - Some(PickerKind::Space) => self.filtered_space_rows(cx).len() + 1, + Some(PickerKind::Space) => self.filtered_project_rows(cx).len() + 1, Some(PickerKind::Device) => self.filtered_device_rows(cx).len(), None => 0, }; @@ -2634,6 +3063,7 @@ impl Pickers { fn measure_trigger(&self, kind: PickerKind, cx: &Context) -> impl IntoElement { let entity = cx.entity().downgrade(); + let compact = kind == PickerKind::HarnessModel && self.compact_model_picker(cx); // New-thread model and workspace menus open down, matching the // centered composer's layout. In-thread menus retain adaptive placement. let below = (self.title.is_some() || self.state.read(cx).selected_chat.is_none()) @@ -2654,21 +3084,25 @@ impl Pickers { // Prefer below on the new-chat canvas only when it can fit // useful content. Tall drafts/attachments can put the trigger // near the window bottom; retain adaptive placement there. - if below && space_below >= 180.0 { + let prefer_below = below && space_below >= 180.0; + if prefer_below { geometry = popover::MenuGeometry { height: space_below, below: true, }; } + // The compact card opens down on the new-chat canvas like the + // standard menu, and up from the docked composer. + if compact && !prefer_below { + geometry = popover::MenuGeometry { + height: (f32::from(bounds.top()) - 14.0).clamp(0.0, 640.0), + below: false, + }; + } entity .update(cx, |this, cx| { if kind == PickerKind::HarnessModel { this.model_trigger_bounds = Some(bounds); - if this.mounted_kind() == Some(kind) && this.open_model_width.is_none() - { - this.open_model_width = Some(bounds.size.width); - cx.notify(); - } } if this.menu_geometry.get(&kind) != Some(&geometry) { this.menu_geometry.insert(kind, geometry); @@ -2685,6 +3119,63 @@ impl Pickers { // ---- render ---- + /// The model chip's content width (padding, brand slot, label, effort + /// and fast mark), eased toward on every change. The chip is anchored at + /// its trailing edge beside Send, as is its popover, so only its leading + /// edge moves and an open card stays put while the selection updates. + #[allow(clippy::too_many_arguments)] + fn model_chip_width( + &mut self, + window: &mut Window, + label: &SharedString, + suffix: Option<&SharedString>, + label_loading: bool, + fast: bool, + cx: &App, + ) -> f32 { + const GAP: f32 = 6.0; + let size = crate::typography::ui_rems(12.0).to_pixels(window.rem_size()); + let mut font = window.text_style().font(); + font.weight = gpui::FontWeight::MEDIUM; + let measure = |text: &SharedString| -> f32 { + let run = gpui::TextRun { + len: text.len(), + font: font.clone(), + color: gpui::black(), + background_color: None, + underline: None, + strikethrough: None, + }; + f32::from( + window + .text_system() + .shape_line(text.clone(), size, &[run], None) + .width, + ) + .ceil() + }; + let label_width = if label_loading { 56.0 } else { measure(label) }; + let mut width = 2.0 * 6.0 + 16.0 + GAP + label_width; + if let Some(suffix) = suffix { + width += GAP + measure(suffix); + } + if fast { + width += GAP + 13.0; + } + let target = width.min(248.0); + let (value, moving) = compact::ScalarTransition::sample( + &mut self.chip_width, + target, + std::time::Instant::now(), + cx.reduce_motion(), + ); + self.chip_resizing = moving; + if moving { + window.request_animation_frame(); + } + value + } + #[allow(clippy::too_many_arguments)] fn trigger_chip( &self, @@ -2714,6 +3205,7 @@ impl Pickers { // this picker's entity. let id: SharedString = format!("{base}-{}", cx.entity_id()).into(); let open = self.open_kind() == Some(kind); + let resizing = kind == PickerKind::HarnessModel && self.chip_resizing; // Ghost pill (zeron composer/styles.tsx `pill`): `h-8 rounded-lg px-2.5 // gap-1.5 text-[12px] font-medium text-muted-foreground`, icons size-4, // hover/open wash — no border, no caret; the actions row stays quiet. @@ -2723,10 +3215,6 @@ impl Pickers { .id(id.clone()) .h(px(32.0)) .max_w(px(248.0)) - .when( - kind == PickerKind::HarnessModel && self.mounted_kind() == Some(kind), - |chip| chip.when_some(self.open_model_width, |chip, width| chip.w(width)), - ) // Shrinkable under row pressure — four footer chips share one // line; without min_w_0 they overflowed and painted overlapped. .min_w_0() @@ -2734,7 +3222,7 @@ impl Pickers { .flex_row() .items_center() .gap(px(6.0)) - // The model trigger sits immediately beside the attachment. + // The model trigger sits beside the right-hand composer actions. // Its own padding participates in that visible gap; footer and // destination triggers keep their wider independent hit areas. .px(px(if kind == PickerKind::HarnessModel { @@ -2790,10 +3278,22 @@ impl Pickers { }, ) .when(label_loading, |el| { + // Nothing is named on screen: the label rolls in from empty. + rolling(format!("{id}-label"), SharedString::default(), true); el.child(popover::skeleton_bar(56.0, cx.entity_id(), cx)) }) .when(!label_loading, |el| { - el.child(div().min_w_0().truncate().child(label)) + // A changing label rolls (Scritto-style); at rest it is the + // plain truncating label, or the clip-don't-ellipsize variant + // while the model chip's width glides. + let rolling = rolling(format!("{id}-label"), label.clone(), cx.reduce_motion()); + el.child(match rolling { + Some(rolling) => rolling, + None if resizing => { + resizing_chip_text(format!("{id}-label").into(), 1.0, None, label) + } + None => div().min_w_0().truncate().child(label).into_any_element(), + }) }) // The effort half of the combined model+effort chip (and the space // chip's "@ device" tag): muted, no icon — one button, two tones. @@ -2801,14 +3301,27 @@ impl Pickers { // pressure the suffix yields FIRST (large shrink factor) so the // model name — the run's identity — truncates last. .when_some(suffix, |el, (suffix, tint)| { - el.child( + let color = tint.unwrap_or(theme.text_muted.opacity(0.7)); + let rolling = rolling(format!("{id}-suffix"), suffix.clone(), cx.reduce_motion()); + el.child(if let Some(rolling) = rolling { + div() + .flex() + .flex_shrink(1000.0) + .min_w_0() + .text_color(color) + .child(rolling) + .into_any_element() + } else if resizing { + resizing_chip_text(format!("{id}-suffix").into(), 1000.0, Some(color), suffix) + } else { div() .flex_shrink(1000.0) .min_w_0() + .text_color(color) .truncate() - .text_color(tint.unwrap_or(theme.text_muted.opacity(0.7))) - .child(suffix), - ) + .child(suffix) + .into_any_element() + }) }) } @@ -2866,7 +3379,7 @@ impl Pickers { .flex_none() .text_color(theme.text_muted.opacity(0.7)), ) - .child(div().min_w_0().truncate().child(label)) + .child(roll_text(format!("{id}-label"), label, cx.reduce_motion())) .child( crate::icons::icon(crate::icons::ALT_ARROW_DOWN) .size(px(12.0)) @@ -2878,13 +3391,31 @@ impl Pickers { /// A read-only footer label (locked sessions — t3code's /// `resolveLockedWorkspaceLabel` span). fn footer_label(icon_path: &'static str, label: SharedString, theme: &Theme) -> gpui::Div { + Self::footer_label_shell(icon_path, theme) + .max_w(px(160.0)) + .child(div().min_w_0().truncate().child(label)) + } + + /// A [`Self::footer_label`] that takes whatever width the row leaves it + /// and fades its tail only when that isn't enough. + fn footer_faded_label( + id: &'static str, + icon_path: &'static str, + label: SharedString, + theme: &Theme, + ) -> gpui::Div { + Self::footer_label_shell(icon_path, theme).child(crate::shell::sidebar_faded_label( + id.into(), + false, + label, + )) + } + + fn footer_label_shell(icon_path: &'static str, theme: &Theme) -> gpui::Div { div() .h(px(20.0)) - // Four of these share one row now (device, project, checkout, - // ref): cap each early and let them SHRINK (`min_w_0`) — without - // it the clusters overflowed into each other and the labels - // painted overlapped (user report). - .max_w(px(160.0)) + // Labels SHRINK (`min_w_0`) — without it the clusters overflowed + // into each other and the labels painted overlapped (user report). .min_w_0() .flex() .flex_row() @@ -2897,13 +3428,14 @@ impl Pickers { .child( crate::icons::icon(icon_path) .size(px(12.0)) + .flex_none() .text_color(theme.text_muted.opacity(0.6)), ) - .child(div().min_w_0().truncate().child(label)) } - /// New-session destination controls. Machine and project form the - /// original chip-only cluster floating above the composer's trailing edge. + /// New-session destination controls: the project, then the device it + /// runs on, as a chip-only cluster floating above the composer's trailing + /// edge. pub fn render_new_thread_target_selectors(&mut self, cx: &mut Context) -> AnyElement { let theme = Theme::of(cx).clone(); let closing = (self.open.closing_since(), self.menu_geometry().below); @@ -2962,17 +3494,17 @@ impl Pickers { .items_center() .gap(px(4.0)) .child(attach_overlay_end( - device_chip, + project_chip, &mut overlay, - PickerKind::Device, - "device-popover", + PickerKind::Space, + "project-popover", closing, )) .child(attach_overlay_end( - project_chip, + device_chip, &mut overlay, - PickerKind::Space, - "project-popover", + PickerKind::Device, + "device-popover", closing, )) .into_any_element() @@ -3006,9 +3538,9 @@ impl Pickers { } _ => None, }; - let kind_icon = match (self.config.checkout, self.selected_ref_worktree().is_some()) { + let kind_icon = match (self.config.checkout, self.display_ref_is_worktree()) { (CheckoutKind::Local, false) => crate::icons::FOLDER, - _ => crate::icons::FOLDER_WITH_FILES, + _ => crate::icons::WORKTREE, }; let checkout_chip = self.footer_chip( PickerKind::Checkout, @@ -3088,7 +3620,7 @@ impl Pickers { }; let is_worktree = chat.cwd.as_deref().is_some_and(|cwd| cwd != space.path); let (icon_path, label) = if is_worktree { - (crate::icons::FOLDER_WITH_FILES, "Worktree") + (crate::icons::WORKTREE, "Worktree") } else { (crate::icons::FOLDER, "Local checkout") }; @@ -3109,7 +3641,8 @@ impl Pickers { .items_center() .gap(px(4.0)) .min_w_0() - .child(Self::footer_label( + .child(Self::footer_faded_label( + "composer-session-branch", crate::icons::GIT_BRANCH, chat.branch .clone() @@ -3170,9 +3703,9 @@ impl Pickers { &theme, cx, ); - let kind_icon = match (self.config.checkout, self.selected_ref_worktree().is_some()) { + let kind_icon = match (self.config.checkout, self.display_ref_is_worktree()) { (CheckoutKind::Local, false) => crate::icons::FOLDER, - _ => crate::icons::FOLDER_WITH_FILES, + _ => crate::icons::WORKTREE, }; let kind_chip = self.footer_chip( PickerKind::Checkout, @@ -3263,6 +3796,9 @@ impl Pickers { popover::popover_card_flush(&theme) .w(px(width)) .h(px(self.menu_geometry().height.min(self.open_model_height))) + .when(self.compact_model_picker(cx), |el| { + el.h(px(self.compact_motion.frame_height)) + }) .track_focus(&self.focus) .on_key_down(cx.listener(|this, event: &KeyDownEvent, window, cx| { this.on_key_down(event, window, cx) @@ -3351,7 +3887,9 @@ impl Pickers { /// through the exit animation, and the rail must keep measuring the /// closing menu's own handle, not `menu_scroll`'s idle geometry. fn active_menu_scroll(&self) -> gpui::ScrollHandle { - if self.mounted_kind() == Some(PickerKind::HarnessModel) { + if self.mounted_kind() == Some(PickerKind::HarnessModel) + && (self.model_rail != ModelRail::All || self.compact_model_list) + { self.model_scroll_base() } else { self.menu_scroll.clone() @@ -3526,7 +4064,7 @@ impl Pickers { "Current checkout" }; let local_icon = if has_worktree { - crate::icons::FOLDER_WITH_FILES + crate::icons::WORKTREE } else { crate::icons::FOLDER }; @@ -3535,7 +4073,7 @@ impl Pickers { ( CheckoutKind::NewWorktree, "New worktree", - crate::icons::FOLDER_WITH_FILES, + crate::icons::WORKTREE, ), ]; let active = self.active; @@ -3580,8 +4118,15 @@ impl Pickers { /// Model picker with favorites and harness tabs above a scoped search. /// Existing chats show only their own harness tab and models. fn render_harness_model_popover(&mut self, cx: &mut Context) -> AnyElement { + let compact = self.compact_model_picker(cx); let height = self.menu_geometry().height.min(self.open_model_height); let (list_height, tray_height) = model_menu_budgets(height, self.setting_groups(cx).len()); + let list_height = if compact { + compact::compact_list_height(self.model_rows_len(cx)) + .min((height - compact::LIST_HEADER - 2.0).max(0.0)) + } else { + list_height + }; let theme = Theme::of(cx).for_popup(); @@ -3745,6 +4290,10 @@ impl Pickers { ); } + if compact { + tabs = self.compact_model_back_header(cx); + } + // ── search row: icon + borderless input over a full-bleed hairline. // The placeholder names the scope — the query never leaves the // viewed tab (user request; the old global search hid the rail). @@ -3882,7 +4431,8 @@ impl Pickers { // ── traits tray: the reasoning ladder + model options PINNED under // the list (the separate Traits popover folded in here — user // request). Hidden entirely when the selected model has neither. - let has_tray = self.title.is_none() + let has_tray = !compact + && self.title.is_none() && (!self.trait_ladder(cx).is_empty() || self .selected_model(cx) @@ -3924,6 +4474,9 @@ impl Pickers { row: &ModelRowData, cx: &mut Context, ) -> AnyElement { + if self.compact_model_picker(cx) { + return self.render_compact_model_row(ix, row, cx); + } let theme = Theme::of(cx).for_popup(); let effective = self.effective_harness(cx); let is_selected = Some(row.harness) == effective @@ -4295,12 +4848,20 @@ impl Pickers { } /// The tray's settings. A model configured in place keeps them in its - /// hover card only. + /// hover card only — except in the compact picker, whose panel is the + /// one place the selected model's settings live. fn setting_groups(&self, cx: &App) -> Vec { // Titles always run at minimal reasoning with no model options. - if self.title.is_some() || self.selected_model(cx).is_some_and(configured_in_place) { + if self.title.is_some() { return Vec::new(); } + if self.selected_model(cx).is_some_and(configured_in_place) { + return if self.compact_model_picker(cx) { + card_order(self.live_groups(cx)) + } else { + Vec::new() + }; + } self.live_groups(cx) } @@ -4595,6 +5156,12 @@ impl Pickers { .into_iter() .filter(|g| tray || !is_toggle(g)); for (ix, group) in groups.enumerate() { + if tray + && self.compact_model_picker(cx) + && !Self::compact_option_visible(&group.id, &self.compact_hidden_options(cx)) + { + continue; + } let open = self.setting_scope == scope && self.setting_menu.as_ref() == Some(&group.id); let value = group .choices @@ -4615,7 +5182,11 @@ impl Pickers { ) .id((row_id, ix)) .relative() - .h(px(30.0)) + .h(px(if self.compact_model_picker(cx) { + 26.0 + } else { + 30.0 + })) .py(px(0.0)) .on_click(cx.listener(move |this, _, window, cx| { if tray { @@ -4823,16 +5394,132 @@ impl Pickers { .child(row), ); } + // The compact panel's own inset already closes the card below the + // last row; a second one left the bottom twice as deep as the sides. + let compact = tray && self.compact_model_picker(cx); div() .flex() .flex_col() .gap(px(2.0)) - .py(px(popover::CARD_INSET)) + .pt(px(popover::CARD_INSET)) + .when(!compact, |el| el.pb(px(popover::CARD_INSET))) .children(rows) .into_any_element() } } +/// Readable interaction state for the isolated compact-picker fixture. +#[cfg(feature = "compact-picker-fixture")] +pub struct CompactPickerFixtureState { + pub model_list: bool, + pub explicit_reasoning: Option, + pub reasoning: Option, + pub fast: bool, + pub focused_model: Option, + pub focused_model_favorite: bool, +} + +/// Synthetic catalog and assertions for the isolated compact-picker fixture. +/// None of this is linked into production builds. +#[cfg(feature = "compact-picker-fixture")] +impl Pickers { + pub fn fixture_compact_catalog(&mut self, cx: &mut Context) { + self.config.harness = Some(HarnessId::Codex); + self.config.model = Some("gpt-5.4".into()); + self.config.reasoning = None; + self.defaults + .model_options_mut(HarnessId::Codex, "gpt-5.4") + .clear(); + self.harnesses = Loadable::Ready( + serde_json::from_value(serde_json::json!([ + { + "id":"codex", + "name":"Codex", + "installed":true, + "enabled":true, + "supportsSteering":true, + "steeringMode":"step-boundary", + "reasoningLevels":[] + } + ])) + .unwrap(), + ); + self.models.insert( + HarnessId::Codex, + Loadable::Ready( + serde_json::from_value(serde_json::json!([ + { + "id":"gpt-5.4", + "label":"GPT-5.4", + "description":"Best for complex coding and reasoning", + "reasoningLevels":["low","medium","high","xhigh"], + "options":[ + { + "id":"serviceTier", + "label":"Service tier", + "defaultChoice":"standard", + "choices":[ + {"id":"standard","label":"Standard"}, + {"id":"fast","label":"Fast"} + ] + }, + { + "id":"contextWindow", + "label":"Context window", + "defaultChoice":"standard", + "choices":[ + {"id":"standard","label":"Standard"}, + {"id":"1m","label":"1M tokens"} + ] + } + ] + }, + { + "id":"gpt-5.3-codex", + "label":"GPT-5.3 Codex", + "description":"Optimized for agentic coding" + }, + { + "id":"gpt-5.2", + "label":"GPT-5.2", + "description":"General purpose reasoning" + }, + { + "id":"gpt-5.1-codex-mini", + "label":"GPT-5.1 Codex Mini", + "description":"Fast, efficient coding" + } + ])) + .unwrap(), + ), + ); + self.compact_model_list = false; + self.compact_keyboard = false; + self.active = 0; + self.catalog_rev += 1; + cx.notify(); + } + + pub fn fixture_compact_state(&self, cx: &App) -> CompactPickerFixtureState { + let focused = self + .compact_model_list + .then(|| self.model_rows(cx).get(self.active).cloned()) + .flatten(); + CompactPickerFixtureState { + model_list: self.compact_model_list, + explicit_reasoning: self.config.reasoning, + reasoning: self.effective_reasoning(cx), + fast: self + .compact_fast_choice(cx) + .is_some_and(|(_, _, _, fast)| fast), + focused_model_favorite: focused + .as_ref() + .is_some_and(|row| self.defaults.is_favorite(row.harness, &row.model.id)), + focused_model: focused.map(|row| row.model.id), + } + } +} + /// The floating-scrollbar treatment for every picker list /// ([`popover::rail`] folds the note/hide/metrics/render + pointer listeners /// into one call): one shared rail state, fed by whichever handle @@ -4949,6 +5636,7 @@ fn scoped_model_rows<'a>( ambiguous: false, }; let in_scope = |descriptor: &HarnessDescriptor, model: &Model| match rail { + ModelRail::All => true, ModelRail::Favorites => is_favorite(descriptor.id, &model.id), ModelRail::Harness => Some(descriptor.id) == effective, }; @@ -4985,22 +5673,31 @@ fn scoped_model_rows<'a>( input_ix += 1; } } - ranked.sort_by_key(|(rank, unstarred, ix, _)| (*rank, *unstarred, *ix)); + ranked.sort_by_key(|(rank, unstarred, ix, _)| { + if rail == ModelRail::All { + (*unstarred, *rank, *ix) + } else { + (*rank, *unstarred, *ix) + } + }); return ranked.into_iter().map(|(_, _, _, row)| row).collect(); } match rail { - ModelRail::Favorites => { + ModelRail::All | ModelRail::Favorites => { let mut rows = Vec::new(); for descriptor in descriptors { let Some(models) = models_for(descriptor.id) else { continue; }; for model in models { - if is_favorite(descriptor.id, &model.id) { + if rail == ModelRail::All || is_favorite(descriptor.id, &model.id) { rows.push(row(descriptor, model)); } } } + if rail == ModelRail::All { + rows.sort_by_key(|row| !is_favorite(row.harness, &row.model.id)); + } rows } ModelRail::Harness => { @@ -5220,6 +5917,64 @@ fn model_menu_budgets(height: f32, setting_count: usize) -> (f32, f32) { (body - tray, tray) } +/// Chip text while the chip's width animates: clipped rather than +/// ellipsized (an ellipsis would flicker in and out each frame), with the +/// clip edge faded so a half-shown glyph never ends in a hard cut. The ramp +/// is as wide as the hidden text, so it is zero exactly at the clip edge. +fn resizing_chip_text( + id: SharedString, + shrink: f32, + color: Option, + text: SharedString, +) -> AnyElement { + let overflow = gpui::ScrollHandle::new(); + crate::edge_fade::edge_faded( + 16.0, + false, + false, + div() + .id(id) + .flex_shrink(shrink) + .min_w_0() + .overflow_hidden() + .track_scroll(&overflow) + .flex() + .when_some(color, |el, color| el.text_color(color)) + .child(div().flex_none().whitespace_nowrap().child(text)), + ) + .fade_right(true) + .fade_scroll_x(&overflow) + .into_any_element() +} + +/// Fast mode's `(on, off)` choices, whatever form a harness gives it: a +/// `fastMode`/`fast_mode` on/off toggle (Claude), Cursor's `fast` true/false, +/// or a tier/speed option offering `fast` (Codex, Devin). Off is the default +/// when fast isn't, else the other choice — Cursor runs some models fast by +/// default. Every model then gets the same fast-mode UI. +fn fast_mode_values(option: &zeron_proto::ModelOption) -> Option<(&str, &str)> { + let has = |id: &str| option.choices.iter().any(|choice| choice.id == id); + let on = if matches!(option.id.as_str(), "fastMode" | "fast_mode") && has("on") { + "on" + } else if option.id == "fast" && has("true") { + "true" + } else if has("fast") { + "fast" + } else { + return None; + }; + let off = if option.default_choice != on && has(&option.default_choice) { + option.default_choice.as_str() + } else { + option + .choices + .iter() + .map(|choice| choice.id.as_str()) + .find(|id| *id != on)? + }; + Some((on, off)) +} + /// Attach the (single) open popover above a selector trigger. fn attach_overlay( chip: gpui::Stateful, @@ -5352,6 +6107,7 @@ impl Render for Pickers { // loaded, so that's a conclusion, not a loading gap) — the chip says // so instead of wearing a brand mark for an agent that can't run. let no_agents = self.no_agents_available() && self.effective_harness(cx).is_none(); + let model_name = self.model_name(cx); let model_label: SharedString = if let Some(title) = &self.title { // The saved choice, not the tab being browsed. match (title.harness, title.model.as_deref()) { @@ -5367,19 +6123,16 @@ impl Render for Pickers { .unwrap_or_else(|| id.to_owned()) .into(), } - } else if no_agents { - SharedString::from("No agents available") } else { - let label = self.selected_model_label(cx); - label.map(SharedString::from).unwrap_or_default() + match &model_name { + ModelName::Named(label) => label.clone(), + ModelName::None { no_agents: true } => "No agents available".into(), + ModelName::Loading | ModelName::None { .. } => SharedString::default(), + } }; - let catalog_loading = matches!(self.harnesses, Loadable::Idle | Loadable::Loading); - let models_loading = self.effective_harness(cx).is_some_and(|harness| { - !matches!( - self.models.get(&harness), - Some(Loadable::Ready(_)) | Some(Loadable::Error(_)) - ) - }); + // A list still from the previous device is loading for this one. + let catalog_loading = self.harnesses_stale + || matches!(self.harnesses, Loadable::Idle | Loadable::Loading); // Harness unknown while the catalog resolves: the pixel-glyph loader // instead of guessing a brand mark. let chip_icon_loading = self.title.is_none() @@ -5388,8 +6141,7 @@ impl Render for Pickers { && catalog_loading; // Harness known but nothing names the model yet (fresh install, no // remembered pick): a ghost label instead of a bare icon. - let chip_label_loading = - !no_agents && model_label.is_empty() && (catalog_loading || models_loading); + let chip_label_loading = self.title.is_none() && model_name == ModelName::Loading; let chip_harness = match &self.title { Some(title) => title.harness, None => self.effective_harness(cx), @@ -5404,10 +6156,9 @@ impl Render for Pickers { ), }; // The chip names the model and its effort only; the other options - // (context, fast mode, ...) live in the popover. The effort brightens - // when it departs from the model's default. + // (context, fast mode, ...) live in the popover. The effort always + // reads in the muted second tone, default or not (user request). let effort = self.effective_reasoning(cx); - let effort_customized = effort != default_reasoning(&self.trait_ladder(cx)); // Render the open popover's body first (mutable borrow), then the // chips. Branch/Checkout render in the composer FOOTER row (see // `render_footer`), not here. @@ -5419,38 +6170,76 @@ impl Render for Pickers { | Some(PickerKind::Space) | Some(PickerKind::Device) => None, Some(PickerKind::HarnessModel) => { - let content = self.render_harness_model_popover(cx); - Some(( - PickerKind::HarnessModel, - // Compact single-harness pane (t3 ModelPickerContent - // shrunk to its tabbed layout). - self.popover_frame_flush(304.0, content, cx), - )) + let menu = if self.compact_model_picker(cx) { + self.render_compact_menu(window, cx) + } else { + let content = self.render_harness_model_popover(cx); + self.popover_frame_flush(304.0, content, cx) + }; + Some((PickerKind::HarnessModel, menu)) } None => None, }; - // The composer places this model chip beside the attachment button: + // The composer places this model chip beside microphone and Send: // brand icon + model name, then the effort as the chip's muted second - // tone. No suffix when the model has no reasoning ladder, nor for the - // title picker (titles always run at minimal reasoning). - let chip_suffix = effort.filter(|_| self.title.is_none()).map(|level| { - ( - SharedString::from(reasoning_label(level)), - effort_customized.then(|| theme.text.opacity(0.85)), - ) + // tone — the ladder's level, else an effort option's choice (Cursor). + // None for the title picker (titles always run at minimal reasoning). + let chip_suffix = effort + .filter(|_| self.title.is_none()) + .map(|level| (SharedString::from(reasoning_label(level)), None)) + .or_else(|| { + self.title + .is_none() + .then(|| self.compact_effort(cx)) + .flatten() + .and_then(|e| e.labels.get(e.selected).cloned()) + .map(|label| (label, None)) + }); + let fast = self.selected_model(cx).is_some_and(|model| { + model.options.iter().any(|option| { + fast_mode_values(option).is_some_and(|(on, _)| { + self.resolved(cx) + .model_options + .get(&option.id) + .and_then(|value| value.as_str()) + .unwrap_or(&option.default_choice) + == on + }) + }) }); - let model_chip = self.trigger_chip( - PickerKind::HarnessModel, - model_label, - true, - Some(harness_icon), - chip_icon_loading, + let chip_width = self.model_chip_width( + window, + &model_label, + chip_suffix.as_ref().map(|(text, _)| text), chip_label_loading, - chip_suffix, - &theme, + fast, cx, ); + let model_chip = self + .trigger_chip( + PickerKind::HarnessModel, + model_label, + true, + Some(harness_icon), + chip_icon_loading, + chip_label_loading, + chip_suffix, + &theme, + cx, + ) + .when(fast, |chip| { + chip.child(motion::fast_tier( + "composer-fast-tier", + div().flex_none().child( + crate::icons::icon(crate::icons::FAST_TIER_BOLD) + .size(px(13.0)) + .text_color(theme.accent), + ), + )) + }); + let model_chip = model_chip.w(px(chip_width)); + // Both presentations align to the chip's trailing edge beside Send. let model_chip = attach_overlay_end( model_chip, &mut overlay, @@ -5999,11 +6788,73 @@ mod tests { } } + #[test] + fn every_fast_mode_encoding_gets_the_same_controls() { + let option = |id: &str, choices: &[&str], default: &str| ModelOption { + id: id.into(), + label: id.into(), + choices: choices + .iter() + .map(|c| ModelOptionChoice { + id: (*c).into(), + label: (*c).into(), + }) + .collect(), + default_choice: default.into(), + }; + // Codex tier, Claude toggle, ACP speed and the snake_case toggle. + assert_eq!( + fast_mode_values(&option("serviceTier", &["default", "fast"], "default")), + Some(("fast", "default")) + ); + assert_eq!( + fast_mode_values(&option("fastMode", &["off", "on"], "off")), + Some(("on", "off")) + ); + assert_eq!( + fast_mode_values(&option("speed", &["standard", "fast"], "standard")), + Some(("fast", "standard")) + ); + assert_eq!( + fast_mode_values(&option("fast_mode", &["off", "on"], "off")), + Some(("on", "off")) + ); + // Cursor's true/false switch, off by default (Opus) and on by + // default (Grok, Composer): both still toggle between the two. + assert_eq!( + fast_mode_values(&option("fast", &["false", "true"], "false")), + Some(("true", "false")) + ); + assert_eq!( + fast_mode_values(&option("fast", &["false", "true"], "true")), + Some(("true", "false")) + ); + assert_eq!( + fast_mode_values(&option("serviceTier", &["default", "fast"], "fast")), + Some(("fast", "default")) + ); + // Not fast mode: other toggles, including Cursor's true/false ones. + assert_eq!( + fast_mode_values(&option("thinking", &["off", "on"], "off")), + None + ); + assert_eq!( + fast_mode_values(&option("thinking", &["false", "true"], "true")), + None + ); + assert_eq!( + fast_mode_values(&option("contextWindow", &["200k", "1m"], "200k")), + None + ); + } + #[gpui::test] - fn open_model_trigger_keeps_its_width_across_model_and_option_changes( - cx: &mut gpui::TestAppContext, - ) { - cx.update(|cx| cx.set_global(Theme::dark())); + fn open_model_trigger_follows_the_selection_while_open(cx: &mut gpui::TestAppContext) { + cx.update(|cx| { + cx.set_global(Theme::dark()); + // Settle the width glide in one frame. + motion::set_reduced_motion(cx, true); + }); let handle = cx.add_window(|_, cx| { let state = cx.new(|_| AppState::new()); let mut pickers = Pickers::new(state, cx); @@ -6037,15 +6888,18 @@ mod tests { cx.update_window(handle.into(), |_, window, cx| window.draw(cx).clear()) .unwrap(); handle - .read_with(cx, |pickers, _| pickers.model_trigger_bounds.unwrap()) + .read_with(cx, |pickers, _| { + pickers.model_trigger_bounds.unwrap().size.width + }) .unwrap() }; - let before = draw(cx); + let short = draw(cx); handle .update(cx, |pickers, window, cx| { pickers.open_model_menu(window, cx) }) .unwrap(); + let mut widths = Vec::new(); for (model, fast) in [("long", false), ("long", true), ("short", false)] { handle .update(cx, |pickers, _, cx| { @@ -6060,12 +6914,14 @@ mod tests { cx.notify(); }) .unwrap(); - assert_eq!( - draw(cx), - before, - "open trigger moved for {model}, fast={fast}" - ); + widths.push(draw(cx)); } + // The open chip names the live selection instead of keeping the + // width it opened with: longer for the long model, longer again + // with its effort and fast mark, and back once the short one returns. + assert!(widths[0] > short, "{widths:?} vs {short:?}"); + assert!(widths[1] > widths[0], "{widths:?}"); + assert_eq!(widths[2], short); } #[gpui::test] @@ -6294,6 +7150,7 @@ mod tests { git_detected: true, git_checked_at: None, checkout_id: None, + repository_id: None, created_at: chrono::Utc::now(), }]); state @@ -6313,14 +7170,302 @@ mod tests { assert!(pickers.defaults.no_project); assert!(pickers.state.read(cx).auto_selected); assert!(pickers.defaults.project.is_none()); - assert_eq!(pickers.selected_space_index(cx), 1); + assert_eq!(pickers.selected_project_index(cx), 1); }); state.update(cx, |state, cx| state.select_device("remote".into(), cx)); cx.run_until_parked(); pickers.update(cx, |pickers, cx| { assert_eq!(pickers.space_target(cx).as_deref(), Some("remote")); - assert_eq!(pickers.selected_space_index(cx), 0); // empty device - assert!(pickers.target_generation >= 2); + // Projects list across devices; the opt-out row stays picked. + assert_eq!(pickers.selected_project_index(cx), 1); + // Only the device switch re-targets the per-device catalogs; the + // same-device projectless pick kept them. + assert_eq!(pickers.target_generation, 1); + }); + } + + fn git_space(id: &str, device: &str) -> Space { + Space { + id: id.into(), + device_id: device.into(), + path: format!("/{device}/{id}"), + name: None, + git_detected: true, + git_checked_at: None, + checkout_id: None, + repository_id: None, + created_at: chrono::Utc::now(), + } + } + + fn repo_ref(name: &str, current: bool, worktree: Option<&str>) -> RepoRef { + RepoRef { + name: name.into(), + current, + worktree_path: worktree.map(str::to_string), + } + } + + #[gpui::test] + fn project_switch_on_one_device_keeps_the_model_catalog(cx: &mut gpui::TestAppContext) { + let state = cx.new(|_| { + let mut state = AppState::new(); + state.local_device_id = Some("local".into()); + state.apply_spaces(vec![git_space("a", "local"), git_space("b", "local")]); + state.selected_space = Some("a".into()); + state + }); + let pickers = cx.new(|cx| Pickers::new(state.clone(), cx)); + pickers.update(cx, |pickers, cx| { + pickers.defaults = ComposerDefaults::default(); + pickers.harnesses = Loadable::Ready(vec![descriptor(HarnessId::Codex, "Codex")]); + pickers.apply_model_catalog( + HarnessId::Codex, + Loadable::Ready(vec![bare_model("gpt", "GPT")]), + cx, + ); + assert_eq!(pickers.model_name(cx), ModelName::Named("GPT".into())); + }); + state.update(cx, |state, cx| state.select_space(Some("b".into()), cx)); + cx.run_until_parked(); + pickers.update(cx, |pickers, cx| { + // Same host: the catalog stands, the chip never blanks. + assert_eq!(pickers.model_name(cx), ModelName::Named("GPT".into())); + assert!(matches!(pickers.harnesses, Loadable::Ready(_))); + assert!(!pickers.harnesses_stale && pickers.stale_models.is_empty()); + }); + } + + #[gpui::test] + fn device_switch_revalidates_the_catalog_without_blanking(cx: &mut gpui::TestAppContext) { + let state = cx.new(|_| { + let mut state = AppState::new(); + state.local_device_id = Some("local".into()); + state.apply_spaces(vec![git_space("a", "local"), git_space("b", "remote")]); + state.selected_space = Some("a".into()); + state + }); + let pickers = cx.new(|cx| Pickers::new(state.clone(), cx)); + pickers.update(cx, |pickers, cx| { + pickers.defaults = ComposerDefaults::default(); + // A picked harness keeps the chip naming its model throughout. + pickers.config.harness = Some(HarnessId::Codex); + pickers.harnesses = Loadable::Ready(vec![descriptor(HarnessId::Codex, "Codex")]); + pickers.apply_model_catalog( + HarnessId::Codex, + Loadable::Ready(vec![bare_model("gpt", "GPT")]), + cx, + ); + pickers.models.insert(HarnessId::ClaudeCode, Loadable::Loading); + }); + state.update(cx, |state, cx| state.select_space(Some("b".into()), cx)); + cx.run_until_parked(); + pickers.update(cx, |pickers, cx| { + // Old rows stay on screen, marked for one reload from the new host; + // a load in flight for the old host restarts instead. + assert_eq!(pickers.model_name(cx), ModelName::Named("GPT".into())); + assert!(pickers.harnesses_stale); + assert_eq!(pickers.stale_models, HashSet::from([HarnessId::Codex])); + assert!(!pickers.models.contains_key(&HarnessId::ClaudeCode)); + // The old device's catalog never picks for the new one: with no + // explicit or remembered model, a send leaves the choice to the + // new device instead of carrying the old catalog's default. + assert_eq!(pickers.resolved(cx).model, None); + // A failed reload replaces the old device's rows — it is not + // retained like a same-device refresh failure. + pickers.land_model_reload( + HarnessId::Codex, + Loadable::Error("device offline".into()), + cx, + ); + assert!(pickers.stale_models.is_empty()); + assert!(matches!( + pickers.models.get(&HarnessId::Codex), + Some(Loadable::Error(_)) + )); + assert_ne!(pickers.model_name(cx), ModelName::Named("GPT".into())); + // With nothing picked or remembered, the old device's harness list + // doesn't choose either — for the chip or a send. + pickers.config.harness = None; + assert!(pickers.harnesses_stale); + assert_eq!(pickers.effective_harness(cx), None); + assert_eq!(pickers.resolved(cx).harness, None); + // ...and the chip reads as loading, never blank. + assert_eq!(pickers.model_name(cx), ModelName::Loading); + }); + } + + #[gpui::test] + fn same_device_refresh_failure_keeps_the_loaded_rows(cx: &mut gpui::TestAppContext) { + let state = cx.new(|_| { + let mut state = AppState::new(); + state.local_device_id = Some("local".into()); + state.apply_spaces(vec![git_space("a", "local")]); + state.selected_space = Some("a".into()); + state + }); + let pickers = cx.new(|cx| Pickers::new(state, cx)); + pickers.update(cx, |pickers, cx| { + pickers.defaults = ComposerDefaults::default(); + pickers.harnesses = Loadable::Ready(vec![descriptor(HarnessId::Codex, "Codex")]); + pickers.apply_model_catalog( + HarnessId::Codex, + Loadable::Ready(vec![bare_model("gpt", "GPT")]), + cx, + ); + pickers.land_model_reload(HarnessId::Codex, Loadable::Error("flaky".into()), cx); + assert_eq!(pickers.model_name(cx), ModelName::Named("GPT".into())); + assert!(matches!( + pickers.models.get(&HarnessId::Codex), + Some(Loadable::Ready(_)) + )); + }); + } + + #[gpui::test] + fn project_switch_holds_the_ref_labels_until_refs_land(cx: &mut gpui::TestAppContext) { + let state = cx.new(|_| { + let mut state = AppState::new(); + state.local_device_id = Some("local".into()); + state.apply_spaces(vec![git_space("a", "local"), git_space("b", "local")]); + state.selected_space = Some("a".into()); + state + }); + let pickers = cx.new(|cx| Pickers::new(state.clone(), cx)); + pickers.update(cx, |pickers, _| { + pickers.refs = Loadable::Ready(vec![ + repo_ref("main", false, None), + repo_ref("feature", true, Some("/wt/feature")), + ]); + pickers.refs_space = Some("a".into()); + assert_eq!(pickers.ref_label(), SharedString::from("feature")); + assert_eq!(pickers.checkout_label(), "Current worktree"); + }); + state.update(cx, |state, cx| state.select_space(Some("b".into()), cx)); + cx.run_until_parked(); + pickers.update(cx, |pickers, cx| { + // The chips keep naming the last ref while b's refs load... + assert!(matches!(pickers.refs, Loadable::Idle)); + assert_eq!(pickers.ref_label(), SharedString::from("feature")); + assert_eq!(pickers.checkout_label(), "Current worktree"); + // ...but nothing that acts on refs sees a's rows. + assert!(pickers.filtered_ref_rows(cx).is_empty()); + assert_eq!( + pickers.checkout_plan(), + CheckoutPlan::CurrentCheckout { branch: None } + ); + pickers.refs = Loadable::Ready(vec![repo_ref("trunk", true, None)]); + pickers.held_ref = None; // as the refs load does on landing + assert_eq!(pickers.ref_label(), SharedString::from("trunk")); + assert_eq!(pickers.checkout_label(), "Current checkout"); + }); + } + + #[gpui::test] + fn project_picker_lists_repositories_then_their_devices(cx: &mut gpui::TestAppContext) { + let space = |id: &str, device: &str, name: &str, repository: Option<&str>, minutes| Space { + id: id.into(), + device_id: device.into(), + path: format!("/{device}/{id}"), + name: Some(name.into()), + git_detected: repository.is_some(), + git_checked_at: None, + checkout_id: None, + repository_id: repository.map(str::to_string), + created_at: chrono::DateTime::::UNIX_EPOCH + + chrono::TimeDelta::minutes(minutes), + }; + let state = cx.new(|_| { + let mut state = AppState::new(); + state.local_device_id = Some("mac".into()); + state.devices = ["mac", "vps"] + .into_iter() + .map(|id| { + serde_json::from_value(serde_json::json!({ + "id": id, "name": id.to_uppercase(), "platform": "macos", "lastSeenAt": null + })) + .unwrap() + }) + .collect(); + state.apply_spaces(vec![ + space("notes", "mac", "notes", None, 0), + space("server", "vps", "comet", Some("github.com/o/comet"), 1), + space( + "laptop", + "mac", + "comet-laptop", + Some("github.com/o/comet"), + 2, + ), + space( + "server-wt", + "vps", + "comet-wt", + Some("github.com/o/comet"), + 3, + ), + ]); + state.selected_space = Some("notes".into()); + state + }); + let pickers = cx.new(|cx| Pickers::new(state.clone(), cx)); + pickers.update(cx, |pickers, cx| { + // One row per repository, named for its oldest checkout. + let rows: Vec<(String, String)> = pickers + .project_rows(cx) + .into_iter() + .map(|row| (row.name, row.key)) + .collect(); + assert_eq!( + rows, + [ + ("comet".into(), "repo:github.com/o/comet".into()), + ("notes".into(), "notes".into()) + ] + ); + // Picking it keeps the canvas on this device's checkout. + pickers.pick_project("repo:github.com/o/comet".into(), cx); + assert_eq!( + pickers.state.read(cx).selected_space.as_deref(), + Some("laptop") + ); + assert_eq!(pickers.selected_project_index(cx), 0); + // The device picker offers only the project's checkouts; a device + // holding two is told apart by path. + let devices: Vec<(String, Option)> = pickers + .device_rows(cx) + .into_iter() + .map(|row| (row.device_id, row.detail)) + .collect(); + assert_eq!( + devices, + [ + ("mac".into(), None), + ("vps".into(), Some("/vps/server".into())), + ("vps".into(), Some("/vps/server-wt".into())), + ] + ); + let server = pickers.device_rows(cx).remove(1); + pickers.pick_device_row(server, cx); + assert_eq!( + pickers.state.read(cx).selected_space.as_deref(), + Some("server") + ); + assert_eq!(pickers.selected_device_index(cx), 1); + // Without a project, every device is offered. + pickers.pick_no_project(cx); + let devices: Vec<(String, bool)> = pickers + .device_rows(cx) + .into_iter() + .map(|row| (row.device_id, row.space.is_some())) + .collect(); + assert_eq!(devices, [("mac".into(), false), ("vps".into(), false)]); + }); + // A device switch keeps the project when the device has a checkout. + state.update(cx, |state, cx| { + state.select_space(Some("laptop".into()), cx); + state.select_device("vps".into(), cx); + assert_eq!(state.selected_space.as_deref(), Some("server")); }); } @@ -7215,6 +8360,669 @@ mod tests { supports_steering: false, } } + #[gpui::test] + fn model_name_never_reads_select_model_while_anything_can_still_name_it( + cx: &mut gpui::TestAppContext, + ) { + let dir = tempfile::tempdir().unwrap(); + cx.update(|cx| { + cx.set_global(Theme::dark()); + let mut settings = crate::settings::UiSettings::default(); + settings.compact_model_picker = true; + crate::settings::init(settings, dir.path(), cx); + }); + let handle = cx.add_window(|_, cx| { + let state = cx.new(|_| AppState::new()); + Pickers::new(state, cx) + }); + let named = |label: &str| ModelName::Named(label.into()); + handle + .update(cx, |picker, _, cx| { + // Harness catalog still loading, a pick remembered: named. + picker.config.harness = Some(HarnessId::Codex); + picker.defaults.remember_model( + HarnessId::Codex, + "gpt-6-luna".into(), + "GPT-6-Luna".into(), + ); + assert_eq!(picker.model_name(cx), named("GPT-6-Luna")); + // Model catalog loading, pick remembered: still named. + picker.harnesses = Loadable::Ready(vec![descriptor(HarnessId::Codex, "Codex")]); + picker.models.insert(HarnessId::Codex, Loadable::Loading); + assert_eq!(picker.model_name(cx), named("GPT-6-Luna")); + // A draft id the catalog never learned keeps its id. + picker.config.model = Some("gpt-7".into()); + assert_eq!(picker.model_name(cx), named("gpt-7")); + // Nothing picked or remembered while loading: loading, not + // "Select model". + picker.config.model = None; + picker.defaults.model_by_harness.clear(); + assert_eq!(picker.model_name(cx), ModelName::Loading); + picker.harnesses = Loadable::Loading; + assert_eq!(picker.model_name(cx), ModelName::Loading); + // Catalog in, nothing remembered: its default model. + picker.harnesses = Loadable::Ready(vec![descriptor(HarnessId::Codex, "Codex")]); + picker.models.insert( + HarnessId::Codex, + Loadable::Ready(vec![bare_model("gpt-a", "A"), bare_model("gpt-b", "B")]), + ); + assert_eq!(picker.model_name(cx), named("A")); + // Only a provider with nothing to offer is unnamed. + picker + .models + .insert(HarnessId::Codex, Loadable::Ready(Vec::new())); + assert_eq!(picker.model_name(cx), ModelName::None { no_agents: false }); + picker + .models + .insert(HarnessId::Codex, Loadable::Error("offline".into())); + assert_eq!(picker.model_name(cx), ModelName::None { no_agents: false }); + // ...and a remembered pick names it even then. + picker.defaults.remember_model( + HarnessId::Codex, + "gpt-6-luna".into(), + "GPT-6-Luna".into(), + ); + assert_eq!(picker.model_name(cx), named("GPT-6-Luna")); + }) + .unwrap(); + } + + #[gpui::test] + fn compact_panel_shortcuts_drive_models_providers_and_effort(cx: &mut gpui::TestAppContext) { + let dir = tempfile::tempdir().unwrap(); + cx.update(|cx| { + cx.set_global(Theme::dark()); + let mut settings = crate::settings::UiSettings::default(); + settings.compact_model_picker = true; + crate::settings::init(settings, dir.path(), cx); + }); + let laddered = |id: &str, label: &str| { + let mut model = bare_model(id, label); + model.reasoning_levels = vec![ReasoningLevel::Low, ReasoningLevel::High]; + model + }; + let handle = cx.add_window(|_, cx| { + let state = cx.new(|_| AppState::new()); + let mut picker = Pickers::new(state, cx); + picker.config.harness = Some(HarnessId::Codex); + picker.harnesses = Loadable::Ready(vec![ + descriptor(HarnessId::Codex, "Codex"), + descriptor(HarnessId::ClaudeCode, "Claude"), + ]); + picker.models.insert( + HarnessId::Codex, + Loadable::Ready(vec![ + laddered("model", "Model"), + laddered("other", "Other model"), + laddered("third", "Third model"), + ]), + ); + picker.models.insert( + HarnessId::ClaudeCode, + Loadable::Ready(vec![laddered("claude", "Claude model")]), + ); + picker + }); + handle + .update(cx, |picker, window, cx| picker.open_model_menu(window, cx)) + .unwrap(); + cx.update_window(handle.into(), |_, window, cx| window.draw(cx).clear()) + .unwrap(); + // Down opens the list on the model after the selected one; Enter + // picks it and lands back on the panel. + cx.simulate_keystrokes(handle.into(), "down"); + handle + .read_with(cx, |picker, cx| { + assert!(picker.compact_model_list); + assert_eq!(picker.model_rows(cx)[picker.active].model.id, "other"); + }) + .unwrap(); + cx.simulate_keystrokes(handle.into(), "enter"); + handle + .read_with(cx, |picker, cx| { + assert!(!picker.compact_model_list); + assert_eq!(picker.selected_model(cx).unwrap().id, "other"); + }) + .unwrap(); + // Up opens it on the model before; Escape steps back without a pick. + cx.simulate_keystrokes(handle.into(), "up"); + handle + .read_with(cx, |picker, cx| { + assert!(picker.compact_model_list); + assert_eq!(picker.model_rows(cx)[picker.active].model.id, "model"); + }) + .unwrap(); + cx.simulate_keystrokes(handle.into(), "escape"); + handle + .read_with(cx, |picker, cx| { + assert!(!picker.compact_model_list); + assert_eq!(picker.selected_model(cx).unwrap().id, "other"); + }) + .unwrap(); + // In the list, ⌘⇧F stars the highlighted model without picking it, + // and the highlight follows that model through the re-sort. + cx.simulate_keystrokes(handle.into(), "down"); + cx.simulate_keystrokes(handle.into(), "cmd-shift-f"); + handle + .read_with(cx, |picker, cx| { + assert!(picker.defaults.is_favorite(HarnessId::Codex, "third")); + assert_eq!(picker.active, 0, "Favorite should move to the first row"); + assert_eq!(picker.model_rows(cx)[picker.active].model.id, "third"); + assert_eq!(picker.selected_model(cx).unwrap().id, "other"); + }) + .unwrap(); + cx.simulate_keystrokes(handle.into(), "cmd-shift-f"); + handle + .read_with(cx, |picker, cx| { + assert!(!picker.defaults.is_favorite(HarnessId::Codex, "third")); + assert_eq!( + picker.active, 2, + "Focus must follow the unstarred model back" + ); + assert_eq!(picker.model_rows(cx)[picker.active].model.id, "third"); + }) + .unwrap(); + // Tab cycles providers in place, each at its last-used model. + cx.simulate_keystrokes(handle.into(), "escape"); + cx.simulate_keystrokes(handle.into(), "tab"); + handle + .read_with(cx, |picker, cx| { + assert!(!picker.compact_model_list); + assert_eq!(picker.resolved(cx).harness, Some(HarnessId::ClaudeCode)); + }) + .unwrap(); + cx.simulate_keystrokes(handle.into(), "tab"); + handle + .read_with(cx, |picker, cx| { + assert_eq!(picker.resolved(cx).harness, Some(HarnessId::Codex)); + assert_eq!(picker.selected_model(cx).unwrap().id, "other"); + }) + .unwrap(); + cx.simulate_keystrokes(handle.into(), "shift-tab"); + handle + .read_with(cx, |picker, cx| { + assert_eq!(picker.resolved(cx).harness, Some(HarnessId::ClaudeCode)); + }) + .unwrap(); + // Left/Right and Home/End set the effort, one haptic per change + // and none at an unchanged endpoint. + let haptics_before = handle + .read_with(cx, |_, _| crate::haptics::step_count()) + .unwrap(); + cx.simulate_keystrokes(handle.into(), "home home"); + handle + .read_with(cx, |picker, cx| { + assert_eq!(picker.effective_reasoning(cx), Some(ReasoningLevel::Low)) + }) + .unwrap(); + cx.simulate_keystrokes(handle.into(), "right right left right"); + handle + .read_with(cx, |picker, cx| { + assert_eq!(picker.effective_reasoning(cx), Some(ReasoningLevel::High)); + assert_eq!(crate::haptics::step_count() - haptics_before, 4); + }) + .unwrap(); + } + + #[gpui::test] + fn compact_panel_lists_settings_of_models_configured_in_place(cx: &mut gpui::TestAppContext) { + let dir = tempfile::tempdir().unwrap(); + cx.update(|cx| { + cx.set_global(Theme::dark()); + let mut settings = crate::settings::UiSettings::default(); + settings.compact_model_picker = true; + crate::settings::init(settings, dir.path(), cx); + }); + let handle = cx.add_window(|_, cx| { + let state = cx.new(|_| AppState::new()); + Pickers::new(state, cx) + }); + let option = |id: &str, label: &str, choices: &[(&str, &str)]| ModelOption { + id: id.into(), + label: label.into(), + default_choice: choices[0].0.into(), + choices: choices + .iter() + .map(|(id, label)| ModelOptionChoice { + id: (*id).into(), + label: (*label).into(), + }) + .collect(), + }; + handle + .update(cx, |pickers, _, cx| { + let mut fusion = bare_model("fusion", "Fusion"); + fusion.reasoning_levels = vec![ReasoningLevel::Medium, ReasoningLevel::High]; + fusion.options = vec![ + option( + "sidekick", + "Sidekick", + &[("swe-2", "SWE-2"), ("swe-3", "SWE-3")], + ), + option("lead", "Lead", &[("fable", "Fable"), ("sol", "Sol")]), + ]; + pickers.config.harness = Some(HarnessId::Devin); + pickers.harnesses = Loadable::Ready(vec![descriptor(HarnessId::Devin, "Devin")]); + pickers.models.insert( + HarnessId::Devin, + Loadable::Ready(vec![bare_model("adaptive", "Adaptive"), fusion]), + ); + pickers.pick_model("fusion".into(), cx); + // No hover card in the compact panel: Lead leads, as in the card. + let hidden = pickers.compact_hidden_options(cx); + let visible: Vec<_> = pickers + .setting_groups(cx) + .into_iter() + .filter(|g| Pickers::compact_option_visible(&g.id, &hidden)) + .map(|g| g.id) + .collect(); + assert_eq!( + visible, + [ + ModelSetting::Option("lead".into()), + ModelSetting::Option("sidekick".into()) + ] + ); + }) + .unwrap(); + } + + #[gpui::test] + fn compact_fast_button_toggles_cursor_fast_by_default_models(cx: &mut gpui::TestAppContext) { + let dir = tempfile::tempdir().unwrap(); + cx.update(|cx| { + cx.set_global(Theme::dark()); + let mut settings = crate::settings::UiSettings::default(); + settings.compact_model_picker = true; + crate::settings::init(settings, dir.path(), cx); + }); + let handle = cx.add_window(|_, cx| { + let state = cx.new(|_| AppState::new()); + Pickers::new(state, cx) + }); + handle + .update(cx, |pickers, window, cx| { + let mut grok = bare_model("grok-4.6", "Grok 4.6"); + grok.options = vec![ModelOption { + id: "fast".into(), + label: "Fast".into(), + default_choice: "true".into(), + choices: [("false", "false"), ("true", "Fast\u{200b}")] + .iter() + .map(|(id, label)| ModelOptionChoice { + id: (*id).into(), + label: (*label).into(), + }) + .collect(), + }]; + pickers.config.harness = Some(HarnessId::Cursor); + pickers.harnesses = Loadable::Ready(vec![descriptor(HarnessId::Cursor, "Cursor")]); + pickers + .models + .insert(HarnessId::Cursor, Loadable::Ready(vec![grok])); + pickers.pick_model("grok-4.6".into(), cx); + // On by default: the button starts lit and turning it off is + // an explicit pick; turning it back on returns to the default. + let (option, next, default, fast) = pickers.compact_fast_choice(cx).unwrap(); + assert_eq!((next.as_str(), default, fast), ("false", false, true)); + pickers.pick_option(option, next, default, cx); + let (option, next, default, fast) = pickers.compact_fast_choice(cx).unwrap(); + assert_eq!((next.as_str(), default, fast), ("true", true, false)); + pickers.pick_option(option, next, default, cx); + assert!(pickers.compact_fast_choice(cx).unwrap().3); + assert!(!pickers.explicit_options(cx).contains_key("fast")); + // F on the open panel flips it the same way. + pickers.open.open(PickerKind::HarnessModel); + let key = |key: &str| KeyDownEvent { + keystroke: gpui::Keystroke::parse(key).unwrap(), + is_held: false, + prefer_character_input: false, + }; + pickers.on_key_down(&key("f"), window, cx); + assert!(!pickers.compact_fast_choice(cx).unwrap().3); + pickers.on_key_down(&key("cmd-f"), window, cx); + assert!(!pickers.compact_fast_choice(cx).unwrap().3); + pickers.on_key_down(&key("f"), window, cx); + assert!(pickers.compact_fast_choice(cx).unwrap().3); + }) + .unwrap(); + } + + #[gpui::test] + fn compact_list_hides_loading_and_failed_harnesses(cx: &mut gpui::TestAppContext) { + let dir = tempfile::tempdir().unwrap(); + cx.update(|cx| { + cx.set_global(Theme::dark()); + let mut settings = crate::settings::UiSettings::default(); + settings.compact_model_picker = true; + crate::settings::init(settings, dir.path(), cx); + }); + let handle = cx.add_window(|_, cx| { + let state = cx.new(|_| AppState::new()); + let mut picker = Pickers::new(state, cx); + picker.config.harness = Some(HarnessId::Codex); + picker.harnesses = Loadable::Ready(vec![ + descriptor(HarnessId::Codex, "Codex"), + descriptor(HarnessId::ClaudeCode, "Claude"), + descriptor(HarnessId::Grok, "Grok"), + ]); + picker.models.insert( + HarnessId::Codex, + Loadable::Ready(vec![bare_model("model", "Model")]), + ); + picker.models.insert( + HarnessId::ClaudeCode, + Loadable::Error("Catalog timed out".into()), + ); + picker.models.insert(HarnessId::Grok, Loadable::Loading); + picker + }); + handle + .update(cx, |picker, window, cx| { + picker.open_model_menu(window, cx); + picker.show_compact_models(cx); + assert_eq!(picker.model_rows_len(cx), 1); + }) + .unwrap(); + cx.update_window(handle.into(), |_, window, cx| window.draw(cx).clear()) + .unwrap(); + // Only the ready model is navigable: no status rows follow it. + cx.simulate_keystrokes(handle.into(), "down"); + handle + .read_with(cx, |picker, _| assert_eq!(picker.active, 0)) + .unwrap(); + handle + .update(cx, |picker, _, cx| { + picker.models.insert( + HarnessId::ClaudeCode, + Loadable::Ready(vec![bare_model("claude", "Claude model")]), + ); + picker.catalog_rev += 1; + // A provider's models become directly selectable when its + // catalog finishes loading; no provider-page detour is needed. + assert_eq!(picker.model_rows_len(cx), 2); + assert_eq!(picker.model_rows(cx)[1].harness, HarnessId::ClaudeCode); + picker.activate_model_index(1, cx); + assert_eq!(picker.resolved(cx).harness, Some(HarnessId::ClaudeCode)); + assert_eq!(picker.resolved(cx).model.as_deref(), Some("claude")); + picker.show_compact_models(cx); + assert_eq!(picker.model_rows_len(cx), 2); + assert_eq!(picker.model_rows(cx)[picker.active].harness, HarnessId::ClaudeCode); + }) + .unwrap(); + } + + #[gpui::test] + fn compact_slider_drives_cursor_effort_option(cx: &mut gpui::TestAppContext) { + let dir = tempfile::tempdir().unwrap(); + cx.update(|cx| { + cx.set_global(Theme::dark()); + let mut settings = crate::settings::UiSettings::default(); + settings.compact_model_picker = true; + crate::settings::init(settings, dir.path(), cx); + }); + let handle = cx.add_window(|_, cx| { + let state = cx.new(|_| AppState::new()); + Pickers::new(state, cx) + }); + handle + .update(cx, |picker, _, cx| { + // Cursor's shape: no reasoning ladder, effort as an option. + let mut opus = bare_model("claude-opus-5", "Claude Opus 5"); + opus.options = vec![ModelOption { + id: "effort".into(), + label: "Effort".into(), + default_choice: "high".into(), + choices: [("low", "Low"), ("medium", "Medium"), ("high", "High")] + .iter() + .map(|(id, label)| ModelOptionChoice { + id: (*id).into(), + label: (*label).into(), + }) + .collect(), + }]; + picker.config.harness = Some(HarnessId::Cursor); + picker.harnesses = Loadable::Ready(vec![descriptor(HarnessId::Cursor, "Cursor")]); + picker + .models + .insert(HarnessId::Cursor, Loadable::Ready(vec![opus])); + picker.pick_model("claude-opus-5".into(), cx); + let effort = picker.compact_effort(cx).unwrap(); + assert_eq!(effort.labels, ["Low", "Medium", "High"]); + assert_eq!(effort.selected, 2); + // The slider owns it: no Effort row in the panel. + assert_eq!(picker.compact_hidden_options(cx), ["effort"]); + picker.pick_compact_effort(effort, 0, cx); + assert_eq!(picker.resolved(cx).model_options["effort"], "low"); + assert_eq!(picker.compact_effort(cx).unwrap().selected, 0); + // Back to the default clears the explicit pick. + let effort = picker.compact_effort(cx).unwrap(); + picker.pick_compact_effort(effort, 2, cx); + assert!(!picker.explicit_options(cx).contains_key("effort")); + }) + .unwrap(); + } + + #[gpui::test] + fn switching_provider_restores_its_last_model_and_effort(cx: &mut gpui::TestAppContext) { + let dir = tempfile::tempdir().unwrap(); + cx.update(|cx| { + cx.set_global(Theme::dark()); + let mut settings = crate::settings::UiSettings::default(); + settings.compact_model_picker = true; + crate::settings::init(settings, dir.path(), cx); + }); + let handle = cx.add_window(|_, cx| { + let state = cx.new(|_| AppState::new()); + Pickers::new(state, cx) + }); + let laddered = |id: &str, label: &str| { + let mut model = bare_model(id, label); + model.reasoning_levels = vec![ + ReasoningLevel::Low, + ReasoningLevel::Medium, + ReasoningLevel::High, + ]; + model + }; + handle + .update(cx, |picker, _, cx| { + picker.harnesses = Loadable::Ready(vec![ + descriptor(HarnessId::Codex, "Codex"), + descriptor(HarnessId::ClaudeCode, "Claude"), + ]); + picker.models.insert( + HarnessId::Codex, + Loadable::Ready(vec![laddered("gpt-a", "A"), laddered("gpt-b", "B")]), + ); + picker.models.insert( + HarnessId::ClaudeCode, + Loadable::Ready(vec![laddered("opus", "Opus")]), + ); + picker.pick_harness(HarnessId::Codex, cx); + picker.pick_model("gpt-b".into(), cx); + picker.pick_reasoning(ReasoningLevel::Low, cx); + picker.pick_compact_provider(HarnessId::ClaudeCode, cx); + picker.pick_reasoning(ReasoningLevel::High, cx); + // Back to Codex: its last model, at that model's last effort. + picker.pick_compact_provider(HarnessId::Codex, cx); + assert_eq!(picker.resolved(cx).model.as_deref(), Some("gpt-b")); + assert_eq!(picker.effective_reasoning(cx), Some(ReasoningLevel::Low)); + // A model with no effort of its own takes the last one used + // anywhere (High, on Claude). + picker.pick_model("gpt-a".into(), cx); + assert_eq!(picker.effective_reasoning(cx), Some(ReasoningLevel::High)); + picker.pick_compact_provider(HarnessId::ClaudeCode, cx); + assert_eq!(picker.resolved(cx).model.as_deref(), Some("opus")); + assert_eq!(picker.effective_reasoning(cx), Some(ReasoningLevel::High)); + // The list's tabs are its provider groups, in list order; + // starring adds a Starred tab ahead of them, holding the + // starred models from every provider. + picker.show_compact_models(cx); + assert_eq!( + picker.compact_groups(cx), + vec![(Some(HarnessId::Codex), 0), (Some(HarnessId::ClaudeCode), 2)] + ); + picker.toggle_model_favorite(HarnessId::Codex, "gpt-a", cx); + assert_eq!( + picker.compact_groups(cx), + vec![ + (None, 0), + (Some(HarnessId::Codex), 1), + (Some(HarnessId::ClaudeCode), 2), + ] + ); + let rows = picker.model_rows(cx); + assert_eq!( + (rows[0].harness, rows[0].model.id.as_str()), + (HarnessId::Codex, "gpt-a") + ); + }) + .unwrap(); + } + + #[gpui::test] + fn compact_picker_shares_selection_reset_and_thread_restrictions( + cx: &mut gpui::TestAppContext, + ) { + let dir = tempfile::tempdir().unwrap(); + cx.update(|cx| { + cx.set_global(Theme::dark()); + let mut settings = crate::settings::UiSettings::default(); + settings.compact_model_picker = true; + crate::settings::init(settings, dir.path(), cx); + }); + let handle = cx.add_window(|_, cx| { + let state = cx.new(|_| AppState::new()); + let mut picker = Pickers::new(state, cx); + picker.config.harness = Some(HarnessId::Codex); + picker.config.model = Some("codex-model".into()); + picker.harnesses = Loadable::Ready(vec![ + descriptor(HarnessId::Codex, "Codex"), + descriptor(HarnessId::ClaudeCode, "Claude"), + ]); + let mut model = bare_model("codex-model", "Codex model"); + model.reasoning_levels = vec![ReasoningLevel::Low, ReasoningLevel::High]; + picker + .models + .insert(HarnessId::Codex, Loadable::Ready(vec![model])); + picker.models.insert( + HarnessId::ClaudeCode, + Loadable::Ready(vec![bare_model("claude-model", "Claude model")]), + ); + picker + }); + handle + .update(cx, |picker, window, cx| { + picker.open_model_menu(window, cx); + assert!(!picker.compact_model_list); + // Models from every offered provider are directly selectable. + picker.show_compact_models(cx); + assert_eq!(picker.model_rows_len(cx), 2); + assert_eq!(picker.model_rows(cx)[0].harness, HarnessId::Codex); + assert_eq!(picker.model_rows(cx)[1].harness, HarnessId::ClaudeCode); + // Searching must also find another provider's model. + picker.search.update(cx, |input, cx| input.set_text("Claude", cx)); + assert_eq!(picker.model_rows_len(cx), 1); + assert_eq!(picker.model_rows(cx)[0].harness, HarnessId::ClaudeCode); + picker.activate_model_index(0, cx); + assert!(!picker.compact_model_list); + assert_eq!(picker.resolved(cx).harness, Some(HarnessId::ClaudeCode)); + assert_eq!(picker.resolved(cx).model.as_deref(), Some("claude-model")); + picker.pick_harness(HarnessId::Codex, cx); + // Clicking a foreign-provider row works without a search too. + picker.show_compact_models(cx); + picker.activate_model_index(1, cx); + assert_eq!(picker.resolved(cx).harness, Some(HarnessId::ClaudeCode)); + picker.pick_harness(HarnessId::Codex, cx); + // Switching provider (Tab on the panel) lands back on the + // panel at that provider's model. + picker.pick_compact_provider(HarnessId::ClaudeCode, cx); + assert!(!picker.compact_model_list); + assert_eq!(picker.resolved(cx).harness, Some(HarnessId::ClaudeCode)); + assert_eq!(picker.resolved(cx).model.as_deref(), Some("claude-model")); + picker.pick_harness(HarnessId::Codex, cx); + picker.pick_model("codex-model".into(), cx); + picker.pick_reasoning(ReasoningLevel::Low, cx); + assert_eq!(picker.config.model.as_deref(), Some("codex-model")); + picker.state.update(cx, |state, cx| { + state.selected_chat = Some("thread".into()); + cx.notify(); + }); + picker.show_compact_models(cx); + assert_eq!(picker.model_rows_len(cx), 1); + assert_eq!(picker.rail_descriptors(cx)[0].id, HarnessId::Codex); + picker.search.update(cx, |input, cx| input.set_text("Claude", cx)); + assert_eq!(picker.model_rows_len(cx), 0); + picker.search.update(cx, |input, cx| input.set_text("", cx)); + // A chat's provider is fixed: one tab, its own. + assert_eq!( + picker.compact_groups(cx), + vec![(Some(HarnessId::Codex), 0)] + ); + picker.compact_model_list = false; + picker.focus_on_mount = true; + }) + .unwrap(); + cx.update_window(handle.into(), |_, window, cx| window.draw(cx).clear()) + .unwrap(); + handle + .read_with(cx, |picker, _| assert!(picker.effort_bounds.is_some())) + .unwrap(); + handle + .update(cx, |picker, _, cx| { + picker.show_compact_models(cx); + picker + .search + .update(cx, |input, cx| input.set_text("no-matching-model", cx)); + picker.active = 0; + picker.activate_model_row(cx); + assert!( + picker.setting_menu.is_none(), + "Empty search must not activate hidden settings" + ); + picker.search.update(cx, |input, cx| input.set_text("", cx)); + }) + .unwrap(); + cx.update_window(handle.into(), |_, window, cx| window.draw(cx).clear()) + .unwrap(); + } + + #[test] + fn compact_all_models_keeps_favorites_first_in_catalog_and_search() { + let descriptors = vec![ + descriptor(HarnessId::ClaudeCode, "Claude"), + descriptor(HarnessId::Codex, "Codex"), + ]; + let claude = vec![ + bare_model("plain-a", "Model A"), + bare_model("star-a", "My Model A"), + ]; + let codex = vec![ + bare_model("plain-b", "Model B"), + bare_model("star-b", "My Model B"), + ]; + for query in ["", "model"] { + let rows = scoped_model_rows( + query, + ModelRail::All, + Some(HarnessId::Codex), + &descriptors, + |harness| match harness { + HarnessId::ClaudeCode => Some(claude.as_slice()), + HarnessId::Codex => Some(codex.as_slice()), + _ => None, + }, + |_, id| id.starts_with("star"), + ); + assert_eq!( + rows.iter() + .map(|row| row.model.id.as_str()) + .collect::>(), + vec!["star-a", "star-b", "plain-a", "plain-b"] + ); + } + } #[test] fn tab_search_never_leaves_the_viewed_harness() { @@ -7487,6 +9295,28 @@ mod tests { assert_eq!(breadcrumbs("/").len(), 1); } + #[test] + fn windows_folder_paths_and_breadcrumbs() { + assert_eq!( + parent_path(r"D:\Random\zeron"), + Some(r"D:\Random".to_string()) + ); + assert_eq!(parent_path(r"D:\Random"), Some(r"D:\".to_string())); + assert_eq!(parent_path(r"D:\Random\"), Some(r"D:\".to_string())); + assert_eq!(parent_path(r"D:\"), None); + assert_eq!(parent_path("D:"), None); + assert_eq!(child_path(r"D:\", "Random"), r"D:\Random"); + assert_eq!(child_path(r"D:\Random", "zeron"), r"D:\Random\zeron"); + let crumbs = breadcrumbs(r"D:\Random\zeron"); + let labels: Vec<&str> = crumbs.iter().map(|(l, _)| l.as_str()).collect(); + assert_eq!(labels, [r"D:\", "Random", "zeron"]); + assert_eq!(crumbs[0].1, r"D:\"); + assert_eq!(crumbs[1].1, r"D:\Random"); + assert_eq!(breadcrumbs(r"D:\").len(), 1); + assert!(!is_windows_path("/D:/x")); + assert!(!is_windows_path("ab:/x")); + } + #[test] fn completion_prefix_lengths() { // Case-insensitive; the length indexes into the NAME's bytes. @@ -7539,6 +9369,26 @@ mod tests { assert_eq!(typed_path_target("/disk2", None), Some("/disk2".into())); } + #[test] + fn typed_path_target_accepts_windows_drive_paths() { + let home = Some(r"C:\Users\wing"); + assert_eq!(typed_path_target(r"D:\", home), Some(r"D:\".into())); + assert_eq!(typed_path_target("D:", home), Some(r"D:\".into())); + assert_eq!(typed_path_target("D:/", home), Some(r"D:\".into())); + assert_eq!( + typed_path_target(r"D:\Random\zeron\", home), + Some(r"D:\Random\zeron".into()) + ); + // Forward slashes normalise so the crumb trail can match the path. + assert_eq!( + typed_path_target("D:/Random/zeron", None), + Some(r"D:\Random\zeron".into()) + ); + assert!(is_typed_path(r"D:\x")); + assert!(is_typed_path("/x") && is_typed_path("~")); + assert!(!is_typed_path("src") && !is_typed_path("ab:/x")); + } + #[test] fn browser_navigation_reducer() { let listing = FolderListing { diff --git a/crates/ui/src/pickers/compact.rs b/crates/ui/src/pickers/compact.rs new file mode 100644 index 0000000000..8441319abe --- /dev/null +++ b/crates/ui/src/pickers/compact.rs @@ -0,0 +1,1485 @@ +//! Alternate presentation of the same model and option mutations. +use super::*; + +pub(super) const COMPACT_WIDTH: f32 = 256.0; +/// One-line model rows (plus the 2px gap) so about seven fit at once. +pub(super) const COMPACT_ROW_HEIGHT: f32 = 32.0; +pub(super) const COMPACT_LIST_ROWS: f32 = 7.0; +/// The list fits its rows, up to [`COMPACT_LIST_ROWS`]; empty and loading +/// states keep room for a few skeleton rows. +pub(super) fn compact_list_height(rows: usize) -> f32 { + let rows = if rows == 0 { + 4.0 + } else { + (rows as f32).min(COMPACT_LIST_ROWS) + }; + rows * (COMPACT_ROW_HEIGHT + popover::MENU_GAP) + 2.0 * popover::CARD_INSET +} +// A 28px rail under a 44x32 pill thumb that overhangs it by 2px, so the +// handle reads as the thing to grab. Stops and pointer input share the +// thumb's centre inset; the fill runs a rail radius past the centre, always +// hidden under the thumb. +const SLIDER_HEIGHT: f32 = 36.0; +const RAIL_HEIGHT: f32 = 28.0; +const RAIL_TOP: f32 = (SLIDER_HEIGHT - RAIL_HEIGHT) / 2.0; +const THUMB_WIDTH: f32 = 44.0; +const THUMB_HEIGHT: f32 = 32.0; +const THUMB_INSET: f32 = THUMB_WIDTH / 2.0; +const FILL_PAST: f32 = RAIL_HEIGHT / 2.0; +/// Both header buttons fill the header's height, so they sit one card inset +/// from the card's top and sides and their radius is concentric with its +/// corners. 8pt of padding around the two-line title puts its text 12pt +/// from the card's top and leading edges. +const HEADER_HEIGHT: f32 = 48.0; +/// A model without an effort ladder titles the header with its name alone. +const HEADER_HEIGHT_SINGLE: f32 = 36.0; +/// Flush with the card inset, a 15pt glyph centred in 32pt ends 12pt from +/// the card edge: the slider rail's end. The provider button mirrors it on +/// the leading side. +const FAST_BUTTON_WIDTH: f32 = 32.0; +/// The header inside the card inset, then the slider block and the space +/// above the option rows. The slider's 4pt below its box leaves the rail +/// 12pt from the card's bottom when no options follow. +fn header_block(effort: bool) -> f32 { + 2.0 * popover::CARD_INSET + + if effort { + HEADER_HEIGHT + } else { + HEADER_HEIGHT_SINGLE + } +} +const SLIDER_TOP: f32 = 2.0; +const SLIDER_BOTTOM: f32 = 4.0; +const EFFORT_BLOCK: f32 = SLIDER_TOP + SLIDER_HEIGHT + SLIDER_BOTTOM; +const OPTIONS_GAP: f32 = 4.0; +/// The card's 1pt border, top and bottom, sits inside its height. +const CARD_BORDERS: f32 = 2.0; +/// The model list's chrome above the rows: the back button with the +/// provider tab strip (one row plus the card inset above and below, 40), +/// then the search row (40). +pub(super) const LIST_HEADER: f32 = 80.0; +/// Option rows (26pt plus a 2pt gap) all show up to this many, then scroll. +const OPTIONS_MAX_ROWS: usize = 6; +/// The rows' 4pt top inset, then the rows; the panel's inset closes below. +fn options_height(count: usize) -> f32 { + 2.0 + count.min(OPTIONS_MAX_ROWS) as f32 * 28.0 +} + +/// Option ids Cursor uses for an effort ladder (its models carry none). +const EFFORT_OPTION_IDS: [&str; 3] = ["effort", "reasoning", "reasoning_effort"]; + +pub(super) struct CompactEffort { + pub labels: Vec, + pub selected: usize, + stops: EffortStops, +} + +enum EffortStops { + Reasoning(Vec), + Option { + id: String, + choices: Vec, + default: String, + }, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub(super) enum CompactPage { + Panel, + Models, +} + +#[derive(Default)] +pub(super) struct CompactMotion { + effort: Option, + press: Option, + drag_fraction: Option, + energy: Option, + /// Shimmer clock for fast mode and the top effort levels, integrated + /// per frame so changing intensity never jumps the band. + shimmer_phase: f32, + shimmer_last_frame: Option, + fast: Option, + height: Option, + page: Option, + reveal: Option, + pub frame_height: f32, +} + +pub(super) struct ScalarTransition { + from: f32, + to: f32, + started: std::time::Instant, +} +impl ScalarTransition { + pub(super) fn value(&self, now: std::time::Instant) -> f32 { + let progress = motion::RESIZE.progress( + now.duration_since(self.started).as_secs_f32() + / motion::RESIZE + .total() + .mul_f32(motion::speed_scale()) + .as_secs_f32(), + ); + motion::lerp(self.from, self.to, progress) + } + pub(super) fn sample( + state: &mut Option, + target: f32, + now: std::time::Instant, + reduced: bool, + ) -> (f32, bool) { + let Some(transition) = state else { + *state = Some(Self { + from: target, + to: target, + started: now, + }); + return (target, false); + }; + if reduced { + transition.from = target; + transition.to = target; + return (target, false); + } + if transition.to != target { + transition.from = transition.value(now); + transition.to = target; + transition.started = now; + } + let value = transition.value(now); + (value, (value - target).abs() > 0.001) + } +} + +impl Pickers { + pub(super) fn render_compact_model_row( + &mut self, + ix: usize, + row: &ModelRowData, + cx: &mut Context, + ) -> AnyElement { + let theme = Theme::of(cx).for_popup(); + let selected = self.effective_harness(cx) == Some(row.harness) + && self + .selected_model(cx) + .is_some_and(|model| model.id == row.model.id); + let favorite = self.defaults.is_favorite(row.harness, &row.model.id); + let (icon, tint) = harness_brand_icon(row.harness); + let harness = row.harness; + let model = row.model.id.clone(); + let label: SharedString = row.model.label.clone().into(); + // The brand mark names the provider; a description appears only to + // tell identically named rows apart. + let attribution: Option = row + .model + .description + .as_deref() + .map(str::trim) + .filter(|d| row.ambiguous && !d.is_empty()) + .map(|d| SharedString::from(d.to_owned())); + let hovered = self.active == ix; + let details: SharedString = match row.model.description.as_deref() { + Some(description) => format!( + "{} · {}\n{}", + row.model.label, row.harness_name, description + ) + .into(), + None => format!("{} · {}", row.model.label, row.harness_name).into(), + }; + let accessible_name = details.clone(); + let favorite_hint: SharedString = format!( + "{} {} {}", + if favorite { "Remove" } else { "Add" }, + row.model.label, + if favorite { + "from favorites" + } else { + "to favorites" + } + ) + .into(); + let favorite_tooltip: SharedString = + format!("{}\n⌘⇧F on the highlighted model", favorite_hint).into(); + let item = div() + .id(("model-row", ix)) + .role(gpui::Role::ListBoxOption) + .aria_label(accessible_name) + .aria_selected(selected) + .tooltip(move |_, cx| cx.new(|_| PickerHint(details.clone())).into()) + .tooltip_show_delay(std::time::Duration::from_millis(500)) + .h(px(COMPACT_ROW_HEIGHT)) + .pl(px(8.0)) + .pr(px(4.0)) + .rounded(px(popover::MENU_ITEM_RADIUS)) + .flex() + .items_center() + .gap(px(8.0)) + .cursor_pointer() + .text_color(theme.text) + // Every row reserves the glass rim so selection doesn't shift content. + .border_1() + .border_color(gpui::transparent_black()) + .when(selected, |el| crate::glass::light(el, &theme, 1.0)) + .when(!selected && self.active == ix, |el| { + el.bg(crate::theme::ink(0.05)) + }) + .when(self.compact_keyboard && self.active == ix, |el| { + el.aria_active_descendant() + }) + .on_hover(cx.listener(move |this, hovered: &bool, _, cx| { + if *hovered && (this.active != ix || this.compact_keyboard) { + this.compact_keyboard = false; + this.active = ix; + cx.notify(); + } + })) + .on_click(cx.listener(move |this, _, _, cx| this.activate_model_index(ix, cx))) + .child( + crate::icons::icon(icon) + .size(px(14.0)) + .flex_none() + .text_color(tint.unwrap_or(theme.text_muted)), + ) + .child( + div() + .flex_1() + .min_w_0() + .flex() + .items_baseline() + .gap(px(6.0)) + .text_size(crate::typography::ui_rems(12.0)) + .child( + div() + .flex_none() + .max_w_full() + .truncate() + .font_weight(gpui::FontWeight::MEDIUM) + .child(label), + ) + .when_some(attribution, |el, attribution| { + el.child( + div() + .min_w_0() + .truncate() + .text_size(crate::typography::ui_rems(11.0)) + .text_color(theme.text_muted) + .child(attribution), + ) + }), + ) + .child( + div() + .id(("model-star", ix)) + .role(gpui::Role::Button) + .aria_label(favorite_hint) + .aria_toggled(if favorite { + gpui::Toggled::True + } else { + gpui::Toggled::False + }) + .aria_keyshortcuts("Meta+Shift+F") + .tooltip(move |_, cx| cx.new(|_| PickerHint(favorite_tooltip.clone())).into()) + .size(px(24.0)) + .rounded(px(6.0)) + .flex() + .items_center() + .justify_center() + // Quiet until the row is hovered or the model is starred. + .when(!favorite && !hovered, |el| el.opacity(0.0)) + .hover(|s| s.bg(crate::theme::ink(0.08))) + .on_click(cx.listener(move |this, _, _, cx| { + cx.stop_propagation(); + this.toggle_model_favorite(harness, &model, cx); + })) + .child( + crate::icons::icon(if favorite { + crate::icons::STAR_BOLD + } else { + crate::icons::STAR + }) + .size(px(13.0)) + .text_color(if favorite { + theme.accent + } else { + theme.text_muted + }), + ), + ); + div() + .pb(px(popover::MENU_GAP)) + .child(item) + .into_any_element() + } + + pub(super) fn compact_model_picker(&self, cx: &App) -> bool { + self.title.is_none() && crate::settings::compact_model_picker(cx) + } + + pub(super) fn show_compact_models(&mut self, cx: &mut Context) { + // Browse every offered provider, just as the standard picker's rail + // allows. rail_descriptors still limits existing chats to their provider. + // A foreign-provider row switches the provider before picking its model. + let rail = if self.harness_locked(cx) { + ModelRail::Harness + } else { + ModelRail::All + }; + self.show_compact_list(rail, "Search models…", cx); + } + + fn show_compact_list( + &mut self, + rail: ModelRail, + placeholder: &'static str, + cx: &mut Context, + ) { + self.setting_menu = None; + self.compact_model_list = true; + self.model_rail = rail; + self.reset_compact_search(placeholder, cx); + // Open on the current model: its provider's group at the top when + // the model sits near the start of it, else centred on the model. + let selected = self.selected_model_index(cx); + self.active = selected; + let start = self + .compact_groups(cx) + .into_iter() + .rev() + .find(|(_, start)| *start <= selected) + .map_or(0, |(_, start)| start); + if selected - start < COMPACT_LIST_ROWS as usize - 2 { + // One row of the previous group stays above, under the edge + // fade, so the group's first row is never washed out and the + // list shows there is more above. + self.model_scroll.scroll_to_item_strict_with_offset( + start, + gpui::ScrollStrategy::Top, + usize::from(start > 0), + ); + } else { + self.model_scroll + .scroll_to_item_strict(selected, gpui::ScrollStrategy::Center); + } + self.focus_on_mount = true; + cx.notify(); + } + + /// Back from the model list to the panel. + pub(super) fn show_compact_panel(&mut self, cx: &mut Context) { + self.compact_model_list = false; + self.focus_on_mount = true; + cx.notify(); + } + + /// Clear the shared filter for a new page without letting the clear's + /// Edited event reset the highlight anchored right after. + fn reset_compact_search(&mut self, placeholder: &'static str, cx: &mut Context) { + self.search_reset_muted = !self.search.read(cx).text().is_empty(); + self.search.update(cx, |input, cx| { + input.set_placeholder(placeholder, cx); + if !input.text().is_empty() { + input.set_text("", cx); + } + }); + } + + /// Picking a provider runs its last-used model at that model's last + /// settings (the remembered defaults take over once the harness moves). + pub(super) fn pick_compact_provider(&mut self, harness: HarnessId, cx: &mut Context) { + self.model_rail = ModelRail::Harness; + self.pick_harness(harness, cx); + self.show_compact_panel(cx); + } + + /// Where each provider's group starts in the unsearched list: starred + /// models first, then one run per provider. + pub(super) fn compact_groups(&self, cx: &App) -> Vec<(Option, usize)> { + let rows = self.model_rows(cx); + let mut groups: Vec<(Option, usize)> = Vec::new(); + for (ix, row) in rows.iter().enumerate() { + let group = + (!self.defaults.is_favorite(row.harness, &row.model.id)).then_some(row.harness); + if groups.last().is_none_or(|(last, _)| *last != group) { + groups.push((group, ix)); + } + } + groups + } + + /// The model list's top row: back to the panel, then the providers as + /// tabs. Each tab jumps the list to its group, and the group at the top + /// of the scroll lights its tab. The strip fades at both edges over the + /// tabs scrolled past them. + pub(super) fn compact_model_back_header(&mut self, cx: &mut Context) -> gpui::Div { + let theme = Theme::of(cx).for_popup(); + let searching = !self.search.read(cx).text().trim().is_empty(); + let groups = if searching { + Vec::new() + } else { + self.compact_groups(cx) + }; + // Rows share one pitch, so the scroll offset names the top row. + let top = (f32::from(-self.model_scroll_base().offset().y) + / (COMPACT_ROW_HEIGHT + popover::MENU_GAP)) + .round() + .max(0.0) as usize; + let current = groups + .iter() + .rev() + .find(|(_, start)| *start <= top) + .map(|(group, _)| *group); + // Many providers overflow the strip: keep the viewed tab in sight + // as the list scrolls, without fighting a manual sideways scroll. + let viewed_ix = groups.iter().position(|(group, _)| current == Some(*group)); + if viewed_ix != self.compact_strip_viewed { + self.compact_strip_viewed = viewed_ix; + if let Some(ix) = viewed_ix { + self.compact_strip_scroll.scroll_to_item(ix); + } + } + let strip = (!groups.is_empty()).then(|| { + let tabs = div() + .id("compact-group-strip") + .flex_1() + .min_w_0() + .h_full() + .flex() + .items_center() + .gap(px(popover::MENU_GAP)) + .overflow_x_scroll() + .track_scroll(&self.compact_strip_scroll) + .children(groups.into_iter().map(|(group, start)| { + let viewed = current == Some(group); + let (icon, tint, name): (&'static str, Option, SharedString) = + match group { + None => (crate::icons::STAR_BOLD, None, "Starred".into()), + Some(harness) => { + let (icon, tint) = harness_brand_icon(harness); + let name = self + .rail_descriptors(cx) + .into_iter() + .find(|d| d.id == harness) + .map(|d| d.name) + .unwrap_or_default(); + (icon, tint, name.into()) + } + }; + let hint = name.clone(); + div() + .id(SharedString::from(format!("compact-group-{start}"))) + .role(gpui::Role::Button) + .aria_label(SharedString::from(format!("Jump to {name}"))) + .tooltip(move |_, cx| cx.new(|_| PickerHint(hint.clone())).into()) + // A row-height square with the rows' corners, so the + // strip sits on the card's grid like the list below. + .flex_none() + .size(px(COMPACT_ROW_HEIGHT)) + .rounded(px(popover::MENU_ITEM_RADIUS)) + .flex() + .items_center() + .justify_center() + .cursor_pointer() + .when(viewed, |el| el.bg(crate::theme::ink(0.08))) + .when(!viewed, |el| { + el.opacity(0.7) + .hover(|s| s.bg(crate::theme::ink(0.05)).opacity(1.0)) + }) + .on_click(cx.listener(move |this, _, _, cx| { + this.compact_keyboard = false; + this.active = start; + this.model_scroll + .scroll_to_item_strict(start, gpui::ScrollStrategy::Top); + cx.notify(); + })) + .child(crate::icons::icon(icon).size(px(14.0)).text_color( + tint.unwrap_or(if viewed { theme.text } else { theme.text_muted }), + )) + })); + // Each side fades over the tabs hidden past it, up to one tab + // pitch, reaching zero exactly at the clip edge. + crate::edge_fade::edge_faded( + COMPACT_ROW_HEIGHT + popover::MENU_GAP, + false, + false, + tabs, + ) + .fade_left(true) + .fade_right(true) + .fade_scroll_x(&self.compact_strip_scroll) + }); + // The card's inset on every side of one row-height strip: the same + // 4pt edge and 32pt pitch as the list rows and the panel's buttons. + div() + .h(px(COMPACT_ROW_HEIGHT + 2.0 * popover::CARD_INSET)) + .flex_none() + .px(px(popover::CARD_INSET)) + .flex() + .items_center() + .gap(px(popover::MENU_GAP)) + .child( + div() + .id("compact-model-back") + .role(gpui::Role::Button) + .aria_label("Back") + .flex_none() + .size(px(COMPACT_ROW_HEIGHT)) + .rounded(px(popover::MENU_ITEM_RADIUS)) + .flex() + .items_center() + .justify_center() + .cursor_pointer() + .hover(|s| s.bg(crate::theme::ink(0.05))) + .on_click(cx.listener(|this, _, _, cx| this.show_compact_panel(cx))) + .child( + crate::icons::icon(crate::icons::ALT_ARROW_LEFT) + .size(px(14.0)) + .text_color(theme.text_muted), + ), + ) + .children(strip) + } + + pub(super) fn compact_fast_choice(&self, cx: &App) -> Option<(String, String, bool, bool)> { + let (option, on, off) = self + .selected_model(cx)? + .options + .iter() + .find_map(|o| fast_mode_values(o).map(|(on, off)| (o, on, off)))?; + let options = self.explicit_options(cx); + let fast = options + .get(&option.id) + .and_then(|v| v.as_str()) + .unwrap_or(&option.default_choice) + == on; + let next = if fast { off } else { on }; + Some(( + option.id.clone(), + next.to_owned(), + next == option.default_choice, + fast, + )) + } + + /// The selected model's fast-mode option id, whichever form it takes. + pub(super) fn fast_option_id(&self, cx: &App) -> Option { + self.selected_model(cx)? + .options + .iter() + .find(|o| fast_mode_values(o).is_some()) + .map(|o| o.id.clone()) + } + + /// The panel's title for a [`ModelName`]. Loading also draws a ghost + /// bar in the name's place, as the composer chip does. + fn compact_title_text(name: &ModelName) -> SharedString { + match name { + ModelName::Named(label) => label.clone(), + ModelName::Loading => "Loading models…".into(), + ModelName::None { no_agents: true } => "No agents available".into(), + ModelName::None { .. } => "Select model".into(), + } + } + + /// The slider's stops: the model's reasoning ladder, else an option + /// shaped like one (Cursor's `effort`/`reasoning` choices), so every + /// model with an effort gets the same slider. + pub(super) fn compact_effort(&self, cx: &App) -> Option { + let levels = self.trait_ladder(cx); + if !levels.is_empty() { + let current = self.effective_reasoning(cx); + return Some(CompactEffort { + labels: levels.iter().map(|l| reasoning_label(*l).into()).collect(), + selected: levels.iter().position(|l| Some(*l) == current).unwrap_or(0), + stops: EffortStops::Reasoning(levels), + }); + } + let option = self + .selected_model(cx)? + .options + .iter() + .find(|o| EFFORT_OPTION_IDS.contains(&o.id.as_str()) && o.choices.len() > 1)?; + let options = self.explicit_options(cx); + let current = options + .get(&option.id) + .and_then(|v| v.as_str()) + .unwrap_or(&option.default_choice); + Some(CompactEffort { + labels: option + .choices + .iter() + .map(|c| SharedString::from(c.label.clone())) + .collect(), + selected: option + .choices + .iter() + .position(|c| c.id == current) + .unwrap_or(0), + stops: EffortStops::Option { + id: option.id.clone(), + choices: option.choices.iter().map(|c| c.id.clone()).collect(), + default: option.default_choice.clone(), + }, + }) + } + + /// Option ids the compact card draws as its own controls, not rows. + pub(super) fn compact_hidden_options(&self, cx: &App) -> Vec { + let effort = match self.compact_effort(cx).map(|e| e.stops) { + Some(EffortStops::Option { id, .. }) => Some(id), + _ => None, + }; + self.fast_option_id(cx).into_iter().chain(effort).collect() + } + + pub(super) fn pick_effort_at(&mut self, x: gpui::Pixels, cx: &mut Context) { + let Some(bounds) = self.effort_bounds else { + return; + }; + let Some(effort) = self.compact_effort(cx) else { + return; + }; + let count = effort.labels.len(); + let Some(index) = effort_index( + f32::from(x - bounds.left()), + f32::from(bounds.size.width), + count, + ) else { + return; + }; + if self.effort_dragging { + let fraction = + effort_fraction(f32::from(x - bounds.left()), f32::from(bounds.size.width)); + self.compact_motion.drag_fraction = Some(if count > 1 { fraction } else { 0.0 }); + cx.notify(); + } + self.pick_compact_effort(effort, index, cx); + } + + /// Effort and fast mode have their own controls in the compact card. + pub(super) fn compact_option_visible(id: &ModelSetting, hidden: &[String]) -> bool { + !matches!(id, ModelSetting::Reasoning) + && !matches!(id, ModelSetting::Option(id) if hidden.contains(id)) + } + + pub(super) fn pick_compact_effort( + &mut self, + effort: CompactEffort, + index: usize, + cx: &mut Context, + ) { + if index == effort.selected { + return; + } + match effort.stops { + EffortStops::Reasoning(levels) => { + let Some(level) = levels.get(index).copied() else { + return; + }; + self.pick_reasoning(level, cx); + } + EffortStops::Option { + id, + choices, + default, + } => { + let Some(choice) = choices.get(index).cloned() else { + return; + }; + let is_default = choice == default; + self.pick_option(id, choice, is_default, cx); + } + } + crate::haptics::selection_step(); + } + + pub(super) fn render_compact_menu( + &mut self, + window: &mut Window, + cx: &mut Context, + ) -> AnyElement { + let hidden = self.compact_hidden_options(cx); + let options = self + .setting_groups(cx) + .iter() + .filter(|g| Self::compact_option_visible(&g.id, &hidden)) + .count(); + let effort = self.compact_effort(cx).is_some(); + let panel_height = CARD_BORDERS + + header_block(effort) + + if effort { EFFORT_BLOCK } else { 0.0 } + + if options == 0 { + 0.0 + } else { + OPTIONS_GAP + options_height(options) + }; + let page = if self.compact_model_list { + CompactPage::Models + } else { + CompactPage::Panel + }; + let target = self.menu_geometry().height.min(match page { + CompactPage::Models => { + LIST_HEADER + compact_list_height(self.model_rows_len(cx)) + CARD_BORDERS + } + CompactPage::Panel => panel_height, + }); + let (height, moving) = ScalarTransition::sample( + &mut self.compact_motion.height, + target, + std::time::Instant::now(), + cx.reduce_motion(), + ); + self.compact_motion.frame_height = height; + if moving { + window.request_animation_frame(); + } + let content = match page { + CompactPage::Models => self.render_harness_model_popover(cx), + CompactPage::Panel => self.render_compact_model_panel(window, cx), + }; + let now = std::time::Instant::now(); + if self.compact_motion.page.is_some_and(|last| last != page) { + self.compact_motion.reveal = Some(ScalarTransition { + from: 0.0, + to: 1.0, + started: now, + }); + } + self.compact_motion.page = Some(page); + let (reveal, revealing) = ScalarTransition::sample( + &mut self.compact_motion.reveal, + 1.0, + now, + cx.reduce_motion(), + ); + if revealing { + window.request_animation_frame(); + } + // The new page fades in while the card resizes to it: the swap + // never shows at full strength and nothing slides sideways against + // the vertical resize. Initial opening uses the popover entrance. + let content = div().opacity(reveal).child(content).into_any_element(); + self.popover_frame_flush(COMPACT_WIDTH, content, cx) + } + + /// The panel's shortcuts, live only while it is the picker's page: Up + /// and Down open the model list on the model beside the selected one, + /// Tab cycles providers, Left/Right (Home/End) set the effort, F toggles + /// fast mode. + pub(super) fn compact_panel_key(&mut self, event: &KeyDownEvent, cx: &mut Context) { + self.compact_keyboard = true; + match event.keystroke.key.as_str() { + "escape" => self.animate_close(cx), + "f" if !event.keystroke.modifiers.modified() => { + let Some((option, choice, default, _)) = self.compact_fast_choice(cx) else { + return; + }; + self.pick_option(option, choice, default, cx); + } + "up" | "down" => { + self.show_compact_models(cx); + let delta = if event.keystroke.key == "up" { -1 } else { 1 }; + if let Some(next) = + popover::menu_step(Some(self.active), self.model_rows_len(cx), delta) + { + self.active = next; + self.model_scroll + .scroll_to_item(next, gpui::ScrollStrategy::Nearest); + } + } + "enter" | "space" => self.show_compact_models(cx), + "tab" => { + let delta = if event.keystroke.modifiers.shift { + -1 + } else { + 1 + }; + self.cycle_compact_provider(delta, cx); + } + "left" | "right" | "home" | "end" => { + if let Some(effort) = self.compact_effort(cx) { + let last = effort.labels.len().saturating_sub(1); + let next = match event.keystroke.key.as_str() { + "left" => effort.selected.saturating_sub(1), + "right" => (effort.selected + 1).min(last), + "home" => 0, + _ => last, + }; + self.pick_compact_effort(effort, next, cx); + } + } + _ => return, + } + cx.notify(); + cx.stop_propagation(); + } + + /// Tab on the panel: the next provider on offer, wrapping, at its + /// last-used model. A chat's fixed provider stays put. + fn cycle_compact_provider(&mut self, delta: isize, cx: &mut Context) { + if self.harness_locked(cx) { + return; + } + let providers = self.rail_descriptors(cx); + let current = self + .effective_harness(cx) + .and_then(|harness| providers.iter().position(|d| d.id == harness)); + if let Some(next) = popover::menu_step(current, providers.len(), delta) { + self.pick_compact_provider(providers[next].id, cx); + } + } + + pub(super) fn render_compact_model_panel( + &mut self, + window: &mut Window, + cx: &mut Context, + ) -> AnyElement { + let theme = Theme::of(cx).for_popup(); + let (levels, selected) = self + .compact_effort(cx) + .map_or((Vec::new(), 0), |e| (e.labels, e.selected)); + let name = self.model_name(cx); + let label = Self::compact_title_text(&name); + let name_element: AnyElement = if name == ModelName::Loading { + popover::skeleton_bar(72.0, cx.entity_id(), cx) + } else { + div() + .min_w_0() + .truncate() + .child(label.clone()) + .into_any_element() + }; + let effort: SharedString = levels + .get(selected) + .cloned() + .unwrap_or_else(|| "Default".into()); + let model_accessible: SharedString = + format!("{} · {} · Change model", effort, label).into(); + let now = std::time::Instant::now(); + let reduced = cx.reduce_motion(); + // Fast mode alone brings the fill to life: a sheen and speed streaks + // racing toward the thumb, and a breathing glow. + let fast = self + .compact_fast_choice(cx) + .is_some_and(|(_, _, _, fast)| fast); + let (intensity, powering) = ScalarTransition::sample( + &mut self.compact_motion.energy, + if fast { 1.0 } else { 0.0 }, + now, + reduced, + ); + if powering { + window.request_animation_frame(); + } + let dt = self + .compact_motion + .shimmer_last_frame + .replace(now) + .map_or(0.0, |last| now.duration_since(last).as_secs_f32().min(0.05)); + let alive = + intensity > 0.001 && !reduced && window.is_window_active() && self.open.is_open(); + if alive { + self.compact_motion.shimmer_phase = + (self.compact_motion.shimmer_phase + dt * (0.32 + 0.22 * intensity)).fract(); + window.request_animation_frame(); + } + let phase = self.compact_motion.shimmer_phase; + let breath = if alive { + 0.5 + 0.5 * (std::f32::consts::TAU * phase).sin() + } else { + 0.5 + }; + // The effort titles the model's name as one button filling the row + // up to fast mode, its text on the rail's and option rows' edge; fast + // mode is a small icon button whose glyph ends where the rail does. + // The chevron leans toward the list on hover. + let model_key: SharedString = format!("compact-model-link-{}", cx.entity_id()).into(); + let hover = motion::hover_t(&model_key); + let lean = motion::EASE_OUT_QUINT.eval(hover); + let title = div() + .id("compact-select-model") + .role(gpui::Role::Button) + .aria_label(model_accessible) + .flex_1() + .min_w_0() + .h_full() + .px(px(8.0)) + .rounded(px(popover::MENU_ITEM_RADIUS)) + .bg(crate::theme::ink(0.05 * hover)) + .flex() + .flex_col() + .items_start() + .justify_center() + .cursor_pointer() + .on_hover(motion::hover_listener(model_key)) + .on_click(cx.listener(|this, _, _, cx| { + this.compact_keyboard = false; + this.show_compact_models(cx); + })) + .when(!levels.is_empty(), |el| { + // The slider retitles the panel as it drags; the level name + // rolls between values like the composer chips. + el.child( + div() + .text_size(crate::typography::ui_rems(14.0)) + .line_height(px(17.0)) + .font_weight(gpui::FontWeight::SEMIBOLD) + .text_color(theme.text) + .child(crate::roll_text::roll_text( + format!("compact-effort-title-{}", cx.entity_id()), + effort.clone(), + reduced, + )), + ) + }) + .child( + div() + .max_w_full() + .min_w_0() + .flex() + .items_center() + .gap(px(3.0)) + .map(|el| { + if levels.is_empty() { + // The name is the whole title: no effort above it. + el.text_size(crate::typography::ui_rems(14.0)) + .line_height(px(17.0)) + .font_weight(gpui::FontWeight::SEMIBOLD) + .text_color(theme.text) + } else { + el.text_size(crate::typography::ui_rems(12.0)) + .line_height(px(15.0)) + .text_color(motion::mix(theme.text_muted, theme.text, hover)) + } + }) + .child(name_element) + .child( + // Leans 3pt toward the list and firms up on hover. + div() + .flex_none() + .relative() + .left(px(3.0 * lean)) + .opacity(0.55 + 0.45 * lean) + .child( + crate::icons::icon(crate::icons::ALT_ARROW_RIGHT) + .size(px(10.0)) + .text_color(motion::mix(theme.text_muted, theme.text, lean)), + ), + ), + ); + let mut fast_button = None; + if let Some((option, choice, default, fast)) = self.compact_fast_choice(cx) { + let (fast_t, fading) = ScalarTransition::sample( + &mut self.compact_motion.fast, + if fast { 1.0 } else { 0.0 }, + now, + reduced, + ); + if fading { + window.request_animation_frame(); + } + let fast_key: SharedString = format!("compact-fast-{}", cx.entity_id()).into(); + let fast_hover = motion::hover_t(&fast_key); + fast_button = Some( + div() + .id("compact-fast") + .role(gpui::Role::Button) + .aria_label("Fast mode") + .aria_toggled(if fast { + gpui::Toggled::True + } else { + gpui::Toggled::False + }) + .tooltip(move |_, cx| { + cx.new(|_| { + PickerHint( + if fast { + "Fast mode on · Turn off" + } else { + "Fast mode off · Turn on" + } + .into(), + ) + }) + .into() + }) + // Bare like the model button beside it: a plate only on + // hover. The glyph's colour is the state, and the + // slider's fill carries the accent. + .w(px(FAST_BUTTON_WIDTH)) + .h_full() + .flex_none() + .rounded(px(popover::MENU_ITEM_RADIUS)) + .flex() + .items_center() + .justify_center() + .cursor_pointer() + .bg(crate::theme::ink(0.05 * fast_hover)) + .on_hover(motion::hover_listener(fast_key)) + .on_click(cx.listener(move |this, _, _, cx| { + this.compact_keyboard = false; + this.pick_option(option.clone(), choice.clone(), default, cx); + })) + .child({ + let color = motion::mix( + motion::mix(theme.text_muted, theme.text, fast_hover), + theme.accent, + fast_t, + ); + // On fills the bolt: the solid twin fades in over the + // outline, which shares its silhouette. + div() + .relative() + .size(px(15.0)) + .child( + crate::icons::icon(crate::icons::FAST_TIER) + .size(px(15.0)) + .text_color(color), + ) + .when(fast_t > 0.0, |el| { + el.child( + crate::icons::icon(crate::icons::FAST_TIER_BOLD) + .absolute() + .top_0() + .left_0() + .size(px(15.0)) + .text_color(color.opacity(fast_t)), + ) + }) + }), + ); + } + let header_height = if levels.is_empty() { + HEADER_HEIGHT_SINGLE + } else { + HEADER_HEIGHT + }; + let header = div() + .h(px(header_height)) + .flex_none() + .flex() + .gap(px(popover::CARD_INSET)) + .child(title) + .children(fast_button); + let mut panel = div() + .p(px(popover::CARD_INSET)) + .flex() + .flex_col() + .child(header); + if !levels.is_empty() { + let target = if levels.len() > 1 { + selected as f32 / (levels.len() - 1) as f32 + } else { + 0.0 + }; + let (mut fraction, moving) = + ScalarTransition::sample(&mut self.compact_motion.effort, target, now, reduced); + if self.effort_dragging { + fraction = self.compact_motion.drag_fraction.unwrap_or(target); + // Keep the release animation rooted at the actual pointer, not the previous stop. + self.compact_motion.effort = Some(ScalarTransition { + from: fraction, + to: fraction, + started: now, + }); + } + if moving { + window.request_animation_frame(); + } + let (press, pressing) = ScalarTransition::sample( + &mut self.compact_motion.press, + if self.effort_dragging { 1.0 } else { 0.0 }, + now, + reduced, + ); + if pressing { + window.request_animation_frame(); + } + let entity = cx.entity().downgrade(); + let drag_entity = entity.clone(); + let slider = div() + .id("compact-effort-slider") + .role(gpui::Role::Slider) + .aria_label("Reasoning effort") + .aria_value(effort.clone()) + .aria_numeric_value(selected as f64) + .aria_min_numeric_value(0.0) + .aria_max_numeric_value(levels.len().saturating_sub(1) as f64) + .aria_numeric_value_step(1.0) + .aria_orientation(gpui::Orientation::Horizontal) + .aria_description("Use Left and Right to adjust reasoning; Home and End select the first and last levels") + .relative() + .h(px(SLIDER_HEIGHT)) + .cursor_pointer() + .on_mouse_down( + gpui::MouseButton::Left, + cx.listener(|this, event: &gpui::MouseDownEvent, window, cx| { + window.focus(&this.focus, cx); + this.compact_keyboard = false; + this.active = NO_ACTIVE_ROW; + this.effort_dragging = true; + this.pick_effort_at(event.position.x, cx); + cx.notify(); + cx.stop_propagation(); + }), + ) + .child( + gpui::canvas( + move |bounds, _, cx| { + let _ = entity.update(cx, |this, _| this.effort_bounds = Some(bounds)); + }, + move |_, _, window, _| { + let release = drag_entity.clone(); + window.on_mouse_event(move |_: &gpui::MouseUpEvent, phase, _, cx| { + if phase == gpui::DispatchPhase::Bubble { + let _ = release.update(cx, |this, cx| { + if this.effort_dragging { + this.effort_dragging = false; + this.compact_motion.drag_fraction = None; + cx.notify(); + } + }); + } + }); + let entity = drag_entity.clone(); + window.on_mouse_event( + move |event: &gpui::MouseMoveEvent, phase, _, cx| { + if phase == gpui::DispatchPhase::Bubble + && event.pressed_button == Some(gpui::MouseButton::Left) + { + let _ = entity.update(cx, |this, cx| { + if this.effort_dragging { + this.pick_effort_at(event.position.x, cx); + } + }); + } + }, + ); + }, + ) + .absolute() + .inset_0(), + ) + .child(crate::glass::light( + div() + .absolute() + .left_0() + .right_0() + .top(px(RAIL_TOP)) + .h(px(RAIL_HEIGHT)) + .rounded_full(), + &theme, + 1.0, + )) + // A contained glow: it must not wash over the option rows. + .child(effort_fill( + &theme, + fraction, + levels.len(), + Shimmer { + intensity, + phase, + breath, + }, + )) + .child({ + // A plain handle that keeps its size; a press only lifts + // it on a softer, wider drop. + let (w, h) = (THUMB_WIDTH, THUMB_HEIGHT); + // Fast mode breathes a soft accent halo under the thumb. + let live = intensity * (0.55 + 0.45 * breath); + let place = |el: gpui::Div| { + el.absolute() + .left(gpui::relative(fraction)) + .ml(px(-w / 2.0)) + .w(px(w)) + .h(px(h)) + .rounded_full() + }; + div() + .absolute() + .left(px(THUMB_INSET)) + .right(px(THUMB_INSET)) + .top(px((SLIDER_HEIGHT - h) / 2.0)) + .when(intensity > 0.001, |el| { + el.child(place(div()).shadow(vec![gpui::BoxShadow { + color: theme.accent.opacity(0.35 * live), + offset: gpui::point(px(0.0), px(1.0)), + blur_radius: px(10.0), + spread_radius: px(0.0), + inset: false, + }])) + }) + .when(press > 0.001, |el| { + el.child(place(div()).shadow(vec![gpui::BoxShadow { + color: gpui::black().opacity(0.12 * press), + offset: gpui::point(px(0.0), px(2.0 * press)), + blur_radius: px(8.0), + spread_radius: px(0.0), + inset: false, + }])) + }) + .child( + crate::glass::thumb(place(div()), &theme), + ) + }); + // The rail starts on the same edge as the title and row labels. + panel = panel.child( + div() + .px(px(8.0)) + .pt(px(SLIDER_TOP)) + .pb(px(SLIDER_BOTTOM)) + .child(slider), + ); + } + let hidden = self.compact_hidden_options(cx); + let option_count = self + .setting_groups(cx) + .iter() + .filter(|g| Self::compact_option_visible(&g.id, &hidden)) + .count(); + if option_count > 0 { + let options = self.render_traits_sections(cx); + let scrollbar = popover::rail(self, "compact-options-scrollbar", &theme, cx); + let chrome = CARD_BORDERS + + header_block(!levels.is_empty()) + + if levels.is_empty() { 0.0 } else { EFFORT_BLOCK } + + OPTIONS_GAP; + // Space, not a rule, separates the options from the effort group. + panel = panel.child( + div().mt(px(OPTIONS_GAP)).child( + popover::menu_scroll_host("compact-options-host") + .on_hover(cx.listener(Self::on_menu_list_hover)) + .child(popover::faded_menu_list( + &self.menu_scroll, + popover::menu_scroll_list("compact-options", &self.menu_scroll) + .max_h(px(options_height(option_count) + .min((self.menu_geometry().height - chrome).max(0.0)))) + .child(options), + )) + .children(scrollbar), + ), + ); + } + panel.into_any_element() + } +} + +/// The accent glass fill from the rail's start to just past the thumb, with +/// the stops painted over rail and fill in one coordinate space. +#[derive(Clone, Copy)] +struct Shimmer { + /// 0 at rest, up to 1 for fast mode on a top level. + intensity: f32, + /// 0–1 position of the band's sweep. + phase: f32, + /// 0–1 breathing of the glow. + breath: f32, +} + +fn effort_fill(theme: &Theme, fraction: f32, count: usize, shimmer: Shimmer) -> AnyElement { + let Shimmer { + intensity, + phase, + breath, + } = shimmer; + // A contained glow that breathes; it must not wash over the option rows. + let fill = crate::glass::accent_plate(theme, 1.0, intensity * (0.2 + 0.25 * breath)); + let accent = theme.accent; + // Quiet stops: a hint of the ladder, not a second focal point. + let filled_stop = gpui::white().opacity(0.4); + let open_stop = theme.text_muted.opacity(0.28); + gpui::canvas( + |_, _, _| (), + move |bounds, _, window, _| { + let width = f32::from(bounds.size.width); + let run = (width - 2.0 * THUMB_INSET).max(0.0); + let center = THUMB_INSET + fraction * run; + let rect = |x: f32, y: f32, w: f32, h: f32| { + gpui::Bounds::new( + bounds.origin + gpui::point(px(x), px(y)), + gpui::size(px(w), px(h)), + ) + }; + fill.paint( + window, + rect(0.0, RAIL_TOP, (center + FILL_PAST).min(width), RAIL_HEIGHT), + RAIL_HEIGHT / 2.0, + ); + // Fast mode: a band of light flows through the whole fill toward + // the thumb. Two slow waves drift at different speeds, shading + // the fill between a lifted accent and a hue-shifted one, and the + // light gathers just before the thumb. It is painted in thin + // strips whose gradients join end to end; the end strips carry + // the fill's corner radius, so it never leaves the rounded ends. + let fill_end = (center + FILL_PAST).min(width); + if intensity > 0.001 && fill_end > RAIL_HEIGHT { + let inset = 1.0; + let height = RAIL_HEIGHT - 2.0 * inset; + let radius = height / 2.0; + let (from, to) = (inset, fill_end - inset); + let base = accent; + let lifted = motion::mix(base, gpui::white(), 0.45); + let shifted = gpui::hsla( + (base.h + 0.07).fract(), + base.s, + (base.l + 0.14).min(0.9), + 1.0, + ); + let tau = std::f32::consts::TAU; + let flow = |x: f32| -> gpui::Hsla { + let slow = 0.5 + 0.5 * (tau * (x / 150.0 - 2.0 * phase)).sin(); + let quick = 0.5 + 0.5 * (tau * (x / 64.0 - 3.0 * phase) + 1.7).sin(); + let glow = (0.65 * slow + 0.35 * quick).powi(2); + let charge = (-(center - x).max(0.0) / 40.0).exp() * (0.6 + 0.4 * breath); + let lead = ((x - from) / 28.0).clamp(0.0, 1.0); + let alpha = intensity * ((0.08 + 0.34 * glow) * lead + 0.22 * charge); + motion::mix(shifted, lifted, slow).opacity(alpha.min(1.0)) + }; + let mut edges = vec![from, (from + radius).min(to)]; + let inner_end = (to - radius).max(edges[1]); + let mut x = edges[1]; + while x + 4.0 < inner_end { + x += 4.0; + edges.push(x); + } + if inner_end > *edges.last().unwrap() { + edges.push(inner_end); + } + if to > *edges.last().unwrap() { + edges.push(to); + } + let last = edges.len().saturating_sub(2); + for (i, pair) in edges.windows(2).enumerate() { + let (x0, x1) = (pair[0], pair[1]); + if x1 - x0 < 0.1 { + continue; + } + let r = px(radius.min(x1 - x0)); + let zero = px(0.0); + let corners = gpui::Corners { + top_left: if i == 0 { r } else { zero }, + bottom_left: if i == 0 { r } else { zero }, + top_right: if i == last { r } else { zero }, + bottom_right: if i == last { r } else { zero }, + }; + window.paint_quad(gpui::quad( + rect(x0, RAIL_TOP + inset, x1 - x0, height), + corners, + gpui::linear_gradient( + 90.0, + gpui::linear_color_stop(flow(x0), 0.0), + gpui::linear_color_stop(flow(x1), 1.0), + ), + px(0.0), + gpui::transparent_black(), + gpui::BorderStyle::default(), + )); + } + } + for i in 0..count { + let stop = if count > 1 { + i as f32 / (count - 1) as f32 + } else { + 0.0 + }; + let x = THUMB_INSET + stop * run; + // Filled stops follow the animated front, never jump ahead of it. + let color = if stop <= fraction { + filled_stop + } else { + open_stop + }; + window.paint_quad(gpui::quad( + rect(x - 2.0, SLIDER_HEIGHT / 2.0 - 2.0, 4.0, 4.0), + px(2.0), + color, + px(0.0), + gpui::transparent_black(), + gpui::BorderStyle::default(), + )); + } + }, + ) + .absolute() + .inset_0() + .into_any_element() +} + +fn effort_fraction(x: f32, width: f32) -> f32 { + ((x - THUMB_INSET) / (width - THUMB_INSET * 2.0).max(1.0)).clamp(0.0, 1.0) +} + +/// Slider stops include both endpoints and only advertised reasoning levels. +fn effort_index(x: f32, width: f32, count: usize) -> Option { + (count > 0) + .then(|| (effort_fraction(x, width) * count.saturating_sub(1) as f32).round() as usize) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn compact_transitions_start_settled_and_retarget_without_jumping() { + let start = std::time::Instant::now(); + let mut state = None; + assert_eq!( + ScalarTransition::sample(&mut state, 0.5, start, false), + (0.5, false) + ); + assert_eq!( + ScalarTransition::sample(&mut state, 1.0, start, false).0, + 0.5 + ); + let middle = start + motion::RESIZE.total().mul_f32(motion::speed_scale() * 0.5); + let before = state.as_ref().unwrap().value(middle); + let retargeted = ScalarTransition::sample(&mut state, 0.0, middle, false).0; + assert!((before - retargeted).abs() < 0.0001); + assert_eq!( + ScalarTransition::sample(&mut state, 0.0, middle, true), + (0.0, false) + ); + } + + #[test] + fn continuous_drag_and_stops_share_inset_geometry() { + let width = 228.0; + for count in 2..=8 { + for index in 0..count { + let fraction = index as f32 / (count - 1) as f32; + let x = THUMB_INSET + fraction * (width - THUMB_INSET * 2.0); + assert!((effort_fraction(x, width) - fraction).abs() < 0.00001); + assert_eq!(effort_index(x, width, count), Some(index)); + assert!(x - 2.0 >= 0.0 && x + 2.0 <= width); + } + } + assert!((effort_fraction(68.0, width) - 0.25).abs() < 0.00001); + assert_eq!(effort_fraction(-100.0, width), 0.0); + assert_eq!(effort_fraction(500.0, width), 1.0); + } + + #[test] + fn effort_stops_clamp_and_round_to_advertised_levels() { + assert_eq!(effort_index(100.0, 200.0, 0), None); + assert_eq!(effort_index(100.0, 200.0, 1), Some(0)); + assert_eq!(effort_index(-20.0, 200.0, 5), Some(0)); + assert_eq!(effort_index(100.0, 200.0, 5), Some(2)); + assert_eq!(effort_index(250.0, 200.0, 5), Some(4)); + } +} + +struct PickerHint(SharedString); +impl Render for PickerHint { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let theme = Theme::of(cx).for_popup(); + let card = popover::popover_card(&theme) + .max_w(px(320.0)) + .p(px(8.0)) + .text_size(crate::typography::ui_rems(12.0)) + .line_height(px(17.0)) + .text_color(theme.text) + .child(self.0.clone()); + crate::frost::frosted(popover::CARD_RADIUS, crate::frost::MENU_BLUR, card) + } +} diff --git a/crates/ui/src/popover.rs b/crates/ui/src/popover.rs index 7f11259b44..c9b28e287c 100644 --- a/crates/ui/src/popover.rs +++ b/crates/ui/src/popover.rs @@ -513,14 +513,29 @@ fn frosted_menu(exit: Option, content: AnyElement) -> AnyElement { /// and the overlay also keeps stray clicks from reaching whatever sits /// underneath. fn menu_motion(id: SharedString, exit: Option, inner: gpui::Div) -> AnyElement { + menu_motion_from(id, exit, inner, -2.0) +} + +/// [`menu_motion`] with the entrance travelling `from` px (signed) out of +/// the trigger's side, and the exit retreating back toward it. +fn menu_motion_from( + id: SharedString, + exit: Option, + inner: gpui::Div, + from: f32, +) -> AnyElement { if let Some(t) = exit { let inner = inner.relative().child(div().absolute().inset_0().occlude()); - motion::menu_out(SharedString::from(format!("{id}-out")), t, inner).into_any_element() + motion::menu_out_toward(SharedString::from(format!("{id}-out")), from, t, inner) + .into_any_element() } else { - motion::menu_in(id, inner).into_any_element() + motion::menu_in_from(id, from, inner).into_any_element() } } +/// Entrance travel for menus that open away from a trigger edge. +const MENU_TRAVEL: f32 = 4.0; + /// Wrap popover content in a floating anchored layer attached to the trigger: /// the caller `.child(anchored_menu(...))`s this from the trigger element while /// open. Plays `menu-in` (0.14s fade + 2px drop); `closing` (the [`Popup`] @@ -583,10 +598,11 @@ pub fn anchored_menu_below_end( gpui::anchored() .anchor(Anchor::TopRight) .snap_to_window_with_margin(px(8.0)) - .child(menu_motion( + .child(menu_motion_from( id.into(), exit, div().occlude().pt(px(6.0)).child(content), + -MENU_TRAVEL, )), ) .priority(1) @@ -684,10 +700,11 @@ pub fn anchored_menu_below_layer( gpui::anchored() .anchor(Anchor::TopLeft) .snap_to_window_with_margin(px(8.0)) - .child(menu_motion( + .child(menu_motion_from( id.into(), exit, div().occlude().pt(px(gap)).child(content), + -MENU_TRAVEL, )), ) .priority(priority) @@ -711,10 +728,11 @@ pub fn anchored_menu_above( gpui::anchored() .anchor(Anchor::BottomLeft) .snap_to_window_with_margin(px(8.0)) - .child(menu_motion( + .child(menu_motion_from( id.into(), exit, div().occlude().pb(px(6.0)).child(content), + MENU_TRAVEL, )), ) .priority(1) @@ -783,10 +801,11 @@ pub fn anchored_menu_above_end( gpui::anchored() .anchor(Anchor::BottomRight) .snap_to_window_with_margin(px(8.0)) - .child(menu_motion( + .child(menu_motion_from( id.into(), exit, div().occlude().pb(px(6.0)).child(content), + MENU_TRAVEL, )), ) .priority(1) diff --git a/crates/ui/src/queue.rs b/crates/ui/src/queue.rs index adbf4b0494..76d4f525b8 100644 --- a/crates/ui/src/queue.rs +++ b/crates/ui/src/queue.rs @@ -79,7 +79,7 @@ const ROW_GAP: f32 = 0.0; const ROW_SLOT: f32 = ROW_HEIGHT + ROW_GAP; const ROW_PAD_X: f32 = 8.0; const ROW_RADIUS: f32 = 8.0; -const PANEL_RADIUS: f32 = 16.0; +pub(crate) const PANEL_RADIUS: f32 = 16.0; const PANEL_PAD_X: f32 = 4.0; const PANEL_PAD_TOP: f32 = 4.0; /// The custom 24px queue glyphs have quieter geometry than the legacy set, so @@ -196,7 +196,7 @@ fn one_line(text: &str) -> SharedString { fn queue_visible_text(text: &str, attachments: &[String]) -> String { let text = crate::appshots::strip_context_for_display(text); if text.trim().is_empty() && !attachments.is_empty() { - return crate::attachments::ATTACHMENT_ONLY_TEXT.to_string(); + return crate::attachments::attachment_only_text(attachments).to_string(); } if attachments.is_empty() { return text.to_string(); @@ -212,12 +212,28 @@ fn queue_visible_text(text: &str, attachments: &[String]) -> String { return text.to_string(); } if parsed.text.trim().is_empty() { - crate::attachments::ATTACHMENT_ONLY_TEXT.to_string() + crate::attachments::attachment_only_text(attachments).to_string() } else { parsed.text } } +/// The row's one-line label and the chips to paint over it. Commands, skills, +/// files and attachments show the same chips as the transcript; editing and +/// delivery still read the stored text, which keeps their canonical links. +fn queue_row_text( + text: &str, + attachments: &[String], +) -> (SharedString, Vec) { + let visible = queue_visible_text(text, attachments); + match crate::composer::sent_mention_display(&visible) { + // Line breaks become spaces of the same size, so the chips' spans + // still line up with the text. + Some((display, spans)) => (display.replace(['\r', '\n'], " ").into(), spans), + None => (one_line(&visible), Vec::new()), + } +} + /// Presentation-only metadata. Never expose the observed accessibility payload. /// Rows show thumbnails only; these names surface as tooltips and labels. fn queue_attachment_labels(text: &str, paths: &[String]) -> Vec { @@ -241,7 +257,7 @@ fn queue_hidden_attachments_label(labels: &[String], shown: usize) -> Option gpui::Div { +pub(crate) fn queue_panel_surface(theme: &Theme) -> gpui::Div { div() .occlude() .rounded_t(px(PANEL_RADIUS)) @@ -426,14 +442,17 @@ impl Composer { let being_removed = self.queue_removing.contains(&item.id); let delivery_blocked = item.delivery_gate.is_some(); let interaction_blocked = delivery_blocked || being_removed; - let text = match &item.delivery_gate { + let (text, chips) = match &item.delivery_gate { Some(QueueDeliveryGate::Editing { owner_device_id, .. - }) if !being_edited => SharedString::from(format!("Editing on {owner_device_id}")), + }) if !being_edited => ( + SharedString::from(format!("Editing on {owner_device_id}")), + Vec::new(), + ), Some(QueueDeliveryGate::ReviewRequired { .. }) if !being_edited => { - SharedString::from("Needs review") + (SharedString::from("Needs review"), Vec::new()) } - _ => one_line(&queue_visible_text(&item.text, &item.attachments)), + _ => queue_row_text(&item.text, &item.attachments), }; let edit_id = item.id.clone(); @@ -570,29 +589,32 @@ impl Composer { // from the editing state. .when(being_edited, |el| el.child(div().w(px(14.0)).flex_none())) .when(!being_edited, |el| { + // A line with chips is taller, so their pills have room. + let line_height = if chips.is_empty() { 16.0 } else { 20.0 }; let content = div() .flex_1() .min_w_0() .truncate() .text_size(px(QUEUE_TEXT_SIZE)) - .line_height(px(16.0)) + .line_height(px(line_height)) .text_color(theme.text.opacity(0.9)) - .child(text); - let labels = queue_attachment_labels(&item.text, &item.attachments); + .child(crate::composer::chip_text(text, chips, theme)); + // Attachments the row's chips already show get no tile. + let tiled: Vec = + crate::attachments::unchipped_attachments(&item.text, &item.attachments) + .into_iter() + .cloned() + .collect(); + let labels = queue_attachment_labels(&item.text, &tiled); let limit = self.queue_preview_limit(); let hidden = queue_hidden_attachments_label(&labels, limit); - el.children( - item.attachments - .iter() - .zip(&labels) - .take(limit) - .enumerate() - .map(|(index, (path, label))| { - self.queue_thumbnail(&key, index, path, label.into(), cx) - }), - ) + el.children(tiled.iter().zip(&labels).take(limit).enumerate().map( + |(index, (path, label))| { + self.queue_thumbnail(&key, index, path, label.into(), cx) + }, + )) .when_some(hidden, |el, hidden| { - let remaining = item.attachments.len() - limit; + let remaining = tiled.len() - limit; let hidden: SharedString = hidden.into(); el.child( div() @@ -711,7 +733,12 @@ impl Composer { ); let keys: std::collections::HashSet<_> = items[visible] .iter() - .flat_map(|item| item.attachments.iter().take(self.queue_preview_limit())) + .flat_map(|item| { + attachments::unchipped_attachments(&item.text, &item.attachments) + .into_iter() + .take(self.queue_preview_limit()) + }) + .filter(|path| attachments::is_image_path(path)) .map(|path| (device.clone(), path.clone())) .take(64) .collect(); @@ -878,6 +905,23 @@ impl Composer { } }) .tooltip_show_delay(std::time::Duration::from_millis(350)); + // A file that is not an image has no thumbnail to load: its icon stands in. + if !attachments::is_image_path(path) { + return frame + .flex() + .items_center() + .justify_center() + .child( + crate::file_icons::icon( + crate::file_icons::FileIconIdentity::file( + attachments::attachment_display_name(&label), + ), + Theme::of(cx).appearance, + ) + .size(px(16.0)), + ) + .into_any_element(); + } match snapshot { Some(image) => { let path = path.to_owned(); @@ -1350,14 +1394,23 @@ impl Composer { .and_then(|v| serde_json::from_value::>(v.clone()).ok()); let mut load_failed = paths.is_none(); for path in paths.unwrap_or_default() { - let loaded = crate::attachments::read_attachment_image( - &engine, cx.background_executor(), Some(&host_device_id), &path, - None, - ).await; + let loaded = if crate::attachments::is_image_path(&path) { + crate::attachments::read_attachment_image( + &engine, cx.background_executor(), Some(&host_device_id), &path, + None, + ).await.map(|loaded| crate::attachments::StagedAttachment { + id: uuid::Uuid::new_v4().to_string(), + mention: None, + name: loaded.name, + content: crate::attachments::AttachmentContent::Image(loaded.image), + }) + } else { + crate::attachments::read_attachment_file( + &engine, cx.background_executor(), Some(&host_device_id), &path, + ).await + }; match loaded { - Some(loaded) => loaded_attachments.push(crate::attachments::StagedAttachment { - id: uuid::Uuid::new_v4().to_string(), name: loaded.name, image: loaded.image, - }), + Some(staged) => loaded_attachments.push(staged), None => { load_failed = true; break; } } } @@ -1411,7 +1464,7 @@ impl Composer { .to_string(); let attachments: Vec = serde_json::from_value(reply["attachments"].clone()).unwrap_or_default(); let text = queue_visible_text(&raw_text, &attachments); - let text = if !attachments.is_empty() && text == crate::attachments::ATTACHMENT_ONLY_TEXT { + let text = if !attachments.is_empty() && crate::attachments::is_attachment_only_text(&text) { String::new() } else { text }; let selected_matches = composer.state.read(cx).selected_chat.as_deref() @@ -1441,15 +1494,12 @@ impl Composer { composer.queue_edit_base_text_hash = Some(base_text_hash.to_string()); composer.queue_edit_chat_id = Some(chat_id.clone()); composer.queue_edit_host_device_id = Some(host_device_id.clone()); - composer.queue_edit_draft = Some(( - composer.input.read(cx).text().to_string(), - composer.attachments.remove(&composer.current_key).unwrap_or_default(), - composer.appshots.remove(&composer.current_key).unwrap_or_default(), - )); - composer.attachments.insert(composer.current_key.clone(), loaded_attachments); - composer.appshots.insert(composer.current_key.clone(), loaded_appshots); - composer.focus_pending = true; - composer.input.update(cx, |input, cx| input.set_text(text, cx)); + composer.swap_in_queued_draft( + text, + std::mem::take(&mut loaded_attachments), + std::mem::take(&mut loaded_appshots), + cx, + ); composer.start_queue_edit_renewal(engine.clone(), cx); } Ok(reply) @@ -1475,13 +1525,55 @@ impl Composer { self.queue_edit_task = Some(task); } + /// Set the draft aside and fill the composer with a queued message. + /// Restored attachments take the numbers of the chips that name them, so + /// those chips stay live; the draft's own numbering waits with the draft. + pub(crate) fn swap_in_queued_draft( + &mut self, + text: String, + mut attachments: Vec, + appshots: Vec, + cx: &mut Context, + ) { + self.queue_edit_draft = Some(( + self.input.read(cx).text().to_string(), + self.attachments + .remove(&self.current_key) + .unwrap_or_default(), + self.appshots.remove(&self.current_key).unwrap_or_default(), + )); + self.displace_attachment_draft(); + crate::attachments::pair_with_chips(&text, &mut attachments); + self.attachments + .insert(self.current_key.clone(), attachments); + self.appshots.insert(self.current_key.clone(), appshots); + self.focus_pending = true; + self.input.update(cx, |input, cx| input.set_text(text, cx)); + } + + /// The text a queue edit saves: a chip whose attachment was removed is + /// saved as its plain label. + pub(crate) fn queue_edit_text(&self, cx: &gpui::App) -> String { + let attached: Vec = self.staged().iter().filter_map(|att| att.mention).collect(); + zeron_proto::attachment_mentions::demote_unattached_mentions( + self.input.read(cx).text(), + &attached, + ) + } + /// Save the composer into the existing row, including its attachments. /// An entirely empty composer removes the row. pub(crate) fn commit_queue_edit(&mut self, cx: &mut Context) -> bool { if self.editing_queued.is_none() { return false; } - let text = self.input.read(cx).text().to_string(); + if self + .input + .update(cx, |input, cx| input.finish_dictation(true, cx)) + { + return true; + } + let text = self.queue_edit_text(cx); if !self.check_reference_delivery(&text, cx) { return true; } @@ -1498,6 +1590,7 @@ impl Composer { if self.editing_queued.is_none() { return false; } + self.input.update(cx, |input, _| input.cancel_dictation()); self.finish_queue_edit("cancel", None, cx); true } @@ -1507,7 +1600,7 @@ impl Composer { self.clear_queue_edit_local(cx); } - fn clear_queue_edit_local(&mut self, cx: &mut Context) { + pub(crate) fn clear_queue_edit_local(&mut self, cx: &mut Context) { self.editing_queued = None; self.queue_edit_lease_id = None; self.queue_edit_base_text_hash = None; @@ -1516,11 +1609,13 @@ impl Composer { self.queue_edit_pending_id = None; self.queue_edit_finishing = false; self.input.update(cx, |input, cx| { + input.cancel_dictation(); input.read_only = false; cx.notify(); }); self.queue_edit_task = None; self.queue_edit_renew_task = None; + self.restore_attachment_draft(); if let Some((text, attachments, appshots)) = self.queue_edit_draft.take() { self.appshots.insert(self.current_key.clone(), appshots); self.input.update(cx, |input, cx| input.set_text(text, cx)); @@ -1566,6 +1661,7 @@ impl Composer { }); self.queue_edit_finishing = true; self.input.update(cx, |input, cx| { + input.cancel_dictation(); input.read_only = true; cx.notify(); }); @@ -1587,7 +1683,7 @@ impl Composer { params["text"].as_str().unwrap_or_default(), &staged_appshots, &appshot_paths, ).into(); if params["text"].as_str().is_some_and(|text| text.trim().is_empty()) && !paths.is_empty() { - params["text"] = crate::attachments::ATTACHMENT_ONLY_TEXT.into(); + params["text"] = crate::attachments::attachment_only_text(&paths).into(); } params["attachments"] = serde_json::json!(paths); } @@ -2042,6 +2138,102 @@ mod tests { assert_eq!(super::queue_hidden_attachments_label(&[], 2), None); } + /// A row's text without the room reserved for chip icons and padding. + fn plain(display: &gpui::SharedString) -> String { + display + .replace('\u{a0}', " ") + .split_whitespace() + .collect::>() + .join(" ") + } + + fn kinds(spans: &[crate::composer::SentMentionSpan]) -> Vec { + spans.iter().map(|span| span.kind).collect() + } + + /// Rows label references the way the transcript does, never as raw + /// `zeron-invoke:`/`zeron-file:` links, and still hide attachment trailers. + #[test] + fn queue_rows_label_commands_skills_and_files() { + use crate::composer::ChipKind; + use zeron_proto::invocation::Invocation; + let command = Invocation::Command { + name: "compact".into(), + } + .link(); + let skill = Invocation::Skill { + name: "review-pr".into(), + path: "/skills/review-pr/SKILL.md".into(), + command: None, + } + .link(); + let file = zeron_proto::file_mentions::local_file_link("src/queue.rs", false); + let text = format!("{command} then {skill}\non {file}"); + let (display, spans) = super::queue_row_text(&text, &[]); + assert_eq!(plain(&display), "compact then review-pr on queue.rs"); + assert_eq!( + kinds(&spans), + [ChipKind::Command, ChipKind::Skill, ChipKind::File] + ); + // Every chip span lies inside the row's text, line breaks included. + assert!(spans.iter().all(|span| span.range.end <= display.len())); + assert!(!display.contains('\n')); + + let paths = vec!["/tmp/image.png".to_string()]; + let legacy = crate::attachments::with_attachments(&command, &paths); + assert_eq!(plain(&super::queue_row_text(&legacy, &paths).0), "compact"); + let (display, spans) = super::queue_row_text("plain text", &[]); + assert_eq!(display.as_ref(), "plain text"); + assert!(spans.is_empty()); + } + + /// A queued message with attachment chips shows them as chips in its row, + /// and an attachments-only message names what it holds. + #[test] + fn queue_rows_label_attachment_chips_and_file_only_sends() { + use zeron_proto::attachment_mentions::attachment_mention_link; + let paths = vec![ + "/uploads/ab12cd34-Image_1.png".to_string(), + "/uploads/ef56ab78-notes.md".to_string(), + ]; + let text = format!( + "compare {} and {}", + attachment_mention_link(1, None), + attachment_mention_link(2, Some("notes.md")) + ); + let (display, spans) = super::queue_row_text(&text, &paths); + assert_eq!(plain(&display), "compare Image 1 and notes.md"); + assert_eq!( + kinds(&spans), + [ + crate::composer::ChipKind::Image, + crate::composer::ChipKind::File + ] + ); + + let files = vec!["/uploads/ef56ab78-notes.md".to_string()]; + assert_eq!( + queue_visible_text("", &files), + crate::attachments::FILE_ATTACHMENT_ONLY_TEXT + ); + let mixed = vec!["/a/cat.png".to_string(), "/a/notes.md".to_string()]; + assert_eq!( + queue_visible_text("", &mixed), + crate::attachments::FILE_ATTACHMENT_ONLY_TEXT + ); + let images = vec!["/a/cat.png".to_string()]; + assert_eq!( + queue_visible_text("", &images), + crate::attachments::ATTACHMENT_ONLY_TEXT + ); + // A legacy trailer on a files-only row is hidden like an image one. + let legacy = crate::attachments::with_attachments("", &files); + assert_eq!( + queue_visible_text(&legacy, &files), + crate::attachments::FILE_ATTACHMENT_ONLY_TEXT + ); + } + #[test] fn legacy_attachment_trailers_are_hidden_from_queue_text() { let paths = vec!["/tmp/image.png".to_string()]; diff --git a/crates/ui/src/roll_text.rs b/crates/ui/src/roll_text.rs new file mode 100644 index 0000000000..7eeac4edc3 --- /dev/null +++ b/crates/ui/src/roll_text.rs @@ -0,0 +1,504 @@ +//! Rolling label transitions — a port of Scritto's `` roll +//! (scrit.to/playground) for chip labels. +//! +//! When a keyed label changes, the characters it shares with the old value at +//! either end stay put (the trailing run slides to its new place), while the +//! changed middle rolls: old glyphs rise out and shrink away as new ones rise +//! in from below — each softened by Scritto's 0.1em blur and 2° tilt while +//! in motion — staggered left to right, and the label's width glides between +//! the two. The glyphs ease out without Scritto's spring overshoot so the +//! label lands dead still (user request). +//! +//! Steady state renders the ordinary truncating label, so layout, ellipsis and +//! hit-testing are unchanged whenever nothing is moving; the custom element +//! only exists for the ~0.7s a roll takes. Glyphs are painted from the shaped +//! lines themselves, so kerning matches the plain label and the hand-off at +//! the end is invisible. + +use std::cell::RefCell; +use std::collections::HashMap; +use std::time::{Duration, Instant}; + +use gpui::{ + App, Bounds, ContentMask, Element, ElementId, GlobalElementId, InspectorElementId, + IntoElement, LayoutId, ParentElement as _, Pixels, Radians, ScaledPixels, ShapedLine, + SharedString, Style, Styled as _, TextRun, TransformationMatrix, Window, div, point, px, +}; + +use crate::motion::{CubicBezier, EASE_OUT_QUINT, RESIZE, lerp, speed_scale}; + +/// Scritto's default roll duration. +const DURATION: Duration = Duration::from_millis(550); +/// Per-glyph travel (em), resting scale, tilt (degrees), blur (em) and the +/// stagger spread (fraction of the duration) — Scritto's +/// `{y: .35, scale: .6, rotate: 2, blur: .1, stagger: .3}`. +const TRAVEL_EM: f32 = 0.35; +const REST_SCALE: f32 = 0.6; +const TILT_DEG: f32 = 2.0; +const BLUR_EM: f32 = 0.1; +const STAGGER: f32 = 0.3; +/// The glyph roll: Scritto's `cubic-bezier(.22,1,.36,1)`, a long settle with +/// no overshoot. +const GLIDE: CubicBezier = EASE_OUT_QUINT; + +/// Width and the shared trailing run settle on the shell's 200ms resize — the +/// same clock the model chip's own width glide runs on — so whatever follows +/// the label (the chip's effort text) reaches its final place together with +/// the chip, early, and never moves again while the glyphs finish rolling. +fn layout_progress(elapsed: f32) -> f32 { + RESIZE.progress(elapsed / RESIZE.total().as_secs_f32()) +} + + +#[derive(Clone)] +struct Roll { + from: SharedString, + to: SharedString, + started: Instant, +} + +/// Bound on remembered labels (see [`rolling`]). +const MAX_KEYS: usize = 256; + +thread_local! { + /// Last label per key, plus the roll in flight (if any). + static ROLLS: RefCell)>> = + RefCell::new(HashMap::new()); +} + +/// The whole roll in unscaled seconds: the stagger pushes the last glyph's +/// start out by up to STAGGER of the duration. +fn span() -> f32 { + DURATION.as_secs_f32() * (1.0 + STAGGER) +} + +fn total() -> Duration { + Duration::from_secs_f32(span() * speed_scale()) +} + +/// The label for `key`: plain at rest, rolling for a moment after `text` +/// changes. The first sight of a key never rolls. +pub fn roll_text(key: impl Into, text: SharedString, reduced: bool) -> gpui::AnyElement { + rolling(key, text.clone(), reduced) + .unwrap_or_else(|| div().min_w_0().truncate().child(text).into_any_element()) +} + +/// The rolling element while `key`'s label is mid-roll, else `None` — for +/// callers with their own at-rest presentation. Records `text` either way. +pub fn rolling( + key: impl Into, + text: SharedString, + reduced: bool, +) -> Option { + let key = key.into(); + let now = Instant::now(); + let roll = ROLLS.with(|rolls| { + let mut rolls = rolls.borrow_mut(); + // Keys can be per-turn (the working trailer), so bound the map: past + // the cap, forget settled labels — they show plain next time. + if rolls.len() >= MAX_KEYS && !rolls.contains_key(&key) { + rolls.retain(|_, (_, roll)| roll.is_some()); + } + let entry = rolls.entry(key).or_insert_with(|| (text.clone(), None)); + if entry.0 != text { + let from = std::mem::replace(&mut entry.0, text.clone()); + entry.1 = (!reduced).then(|| Roll { + from, + to: text.clone(), + started: now, + }); + } + // Finished — or reduced motion switched on mid-roll, where no frames + // would come to finish it: settle on the plain label now. + if reduced + || entry + .1 + .as_ref() + .is_some_and(|roll| now.saturating_duration_since(roll.started) >= total()) + { + entry.1 = None; + } + entry.1.clone() + }); + roll.map(|roll| RollText { roll }.into_any_element()) +} + +/// Byte lengths of the shared leading and trailing character runs, never +/// overlapping in either string. +fn shared_ends(from: &str, to: &str) -> (usize, usize) { + let prefix: usize = from + .chars() + .zip(to.chars()) + .take_while(|(a, b)| a == b) + .map(|(c, _)| c.len_utf8()) + .sum(); + let room = (from.len() - prefix).min(to.len() - prefix); + let suffix: usize = from[prefix..] + .chars() + .rev() + .zip(to[prefix..].chars().rev()) + .take_while(|(a, b)| a == b) + .map(|(c, _)| c.len_utf8()) + .scan(0, |sum, len| { + *sum += len; + Some(*sum) + }) + .take_while(|sum| *sum <= room) + .last() + .unwrap_or(0); + (prefix, suffix) +} + +struct RollText { + roll: Roll, +} + +/// One shaped glyph: its x and advance in the line, and its byte index. +struct Glyph { + font: gpui::FontId, + id: gpui::GlyphId, + index: usize, + x: f32, + /// The shaper's vertical offset, which gpui adds below the baseline. + y: f32, + advance: f32, + emoji: bool, +} + +struct Shaped { + from: ShapedLine, + to: ShapedLine, + font_size: Pixels, + line_height: Pixels, +} + +impl IntoElement for RollText { + type Element = Self; + + fn into_element(self) -> Self::Element { + self + } +} + +impl RollText { + fn elapsed(&self) -> f32 { + Instant::now() + .saturating_duration_since(self.roll.started) + .as_secs_f32() + / speed_scale() + } +} + +impl Element for RollText { + type RequestLayoutState = Shaped; + type PrepaintState = (); + + fn id(&self) -> Option { + None + } + + fn source_location(&self) -> Option<&'static core::panic::Location<'static>> { + None + } + + fn request_layout( + &mut self, + _id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + window: &mut Window, + cx: &mut App, + ) -> (LayoutId, Shaped) { + let text_style = window.text_style(); + let font_size = text_style.font_size.to_pixels(window.rem_size()); + // Exactly gpui's text element: line height snapped to the device + // pixel grid (not rounded to a logical pixel), so the line box and + // baseline match the plain label it hands off to. + let line_height = window.pixel_snap( + text_style + .line_height + .to_pixels(font_size.into(), window.rem_size()), + ); + let shape = |text: &SharedString| { + let run = TextRun { + len: text.len(), + font: text_style.font(), + color: text_style.color, + background_color: None, + underline: None, + strikethrough: None, + }; + window + .text_system() + .shape_line(text.clone(), font_size, &[run], None) + }; + let from = shape(&self.roll.from); + let to = shape(&self.roll.to); + let progress = layout_progress(self.elapsed()); + // Rounded up like gpui's text element sizes the plain label, so the + // settled width equals the label that replaces this element. + let width = lerp(f32::from(from.width), f32::from(to.width), progress).ceil(); + let mut style = Style::default(); + style.size.width = gpui::Length::Definite(px(width).into()); + style.size.height = gpui::Length::Definite(line_height.into()); + style.min_size.width = px(0.0).into(); + style.flex_shrink = 1.0; + let layout_id = window.request_layout(style, [], cx); + ( + layout_id, + Shaped { + from, + to, + font_size, + line_height, + }, + ) + } + + fn prepaint( + &mut self, + _id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + _bounds: Bounds, + _request_layout: &mut Shaped, + _window: &mut Window, + _cx: &mut App, + ) { + } + + fn paint( + &mut self, + _id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + bounds: Bounds, + shaped: &mut Shaped, + _prepaint: &mut (), + window: &mut Window, + cx: &mut App, + ) { + let elapsed = self.elapsed(); + let duration = DURATION.as_secs_f32(); + let color = window.text_style().color; + let font_size = shaped.font_size; + let travel = f32::from(font_size) * TRAVEL_EM; + let (prefix, suffix) = shared_ends(&self.roll.from, &self.roll.to); + let from_len = self.roll.from.len(); + let to_len = self.roll.to.len(); + // The trailing run slides from its old x to its new one with the width. + let slide = layout_progress(elapsed); + let suffix_dx = f32::from( + shaped.from.x_for_index(from_len - suffix) - shaped.to.x_for_index(to_len - suffix), + ) * (1.0 - slide); + + // Glyphs of one line with their x extents and role. + let glyphs = |line: &ShapedLine| { + let mut out = Vec::new(); + for run in line.runs.iter() { + for glyph in &run.glyphs { + out.push(( + run.font_id, + glyph.id, + glyph.index, + f32::from(glyph.position.x), + f32::from(glyph.position.y), + glyph.is_emoji, + )); + } + } + let width = f32::from(line.width); + let ends: Vec = out + .iter() + .skip(1) + .map(|g| g.3) + .chain(std::iter::once(width)) + .collect(); + out.into_iter() + .zip(ends) + .map(|((font, id, index, x, y, emoji), end)| Glyph { + font, + id, + index, + x, + y, + advance: (end - x).max(0.0), + emoji, + }) + .collect::>() + }; + let from_glyphs = glyphs(&shaped.from); + let to_glyphs = glyphs(&shaped.to); + let changed = |index: usize, len: usize| index >= prefix && index < len - suffix; + // Stagger sweeps left to right across each side's changed glyphs. + let sweep = |list: &[Glyph], len: usize| { + let xs: Vec = list + .iter() + .filter(|g| changed(g.index, len)) + .map(|g| g.x) + .collect(); + let count = xs.len(); + let min = xs.iter().copied().fold(f32::INFINITY, f32::min); + let max = xs.iter().copied().fold(f32::NEG_INFINITY, f32::max); + let spread = duration * STAGGER * count.saturating_sub(1) as f32 / count.max(1) as f32; + move |x: f32| { + if max > min { + spread * ((x - min) / (max - min)).clamp(0.0, 1.0) + } else { + 0.0 + } + } + }; + let from_delay = sweep(&from_glyphs, from_len); + let to_delay = sweep(&to_glyphs, to_len); + + let padding_top = (shaped.line_height - shaped.to.ascent - shaped.to.descent) / 2.0; + let baseline = bounds.origin.y + padding_top + shaped.to.ascent; + let center_y = f32::from(bounds.origin.y + shaped.line_height / 2.0); + let scale_factor = window.scale_factor(); + // Vertical room for glyphs rolling past the line box, and a small + // horizontal allowance for the blur halo; the width otherwise clips. + let room = px(travel * 2.0); + let halo = px((f32::from(font_size) * BLUR_EM * 3.0).ceil()); + let mask = Bounds { + origin: point(bounds.origin.x - halo, bounds.origin.y - room), + size: gpui::size(bounds.size.width + halo * 2.0, bounds.size.height + room * 2.0), + }; + // `motion` = 1 − progress: Scritto's resting-state distance. Tilt and + // blur fade with it; scale and translate are the caller's. + let paint = |window: &mut Window, + glyph: &Glyph, + dx: f32, + dy: f32, + scale: f32, + motion: f32, + alpha: f32| { + if alpha <= 0.0 { + return; + } + let left = f32::from(bounds.origin.x) + glyph.x; + let center_x = left + glyph.advance / 2.0; + let rest_origin = point(px(left), px(f32::from(baseline) + glyph.y)); + if glyph.emoji { + // Emoji can be neither tinted nor transformed; they swap at + // the midpoint and ride the translation alone. + if alpha < 0.5 { + return; + } + let origin = point(px(left + dx), px(f32::from(baseline) + glyph.y + dy)); + let _ = window.paint_emoji(origin, glyph.font, glyph.id, font_size); + return; + } + if motion <= 0.0 && dx == 0.0 && dy == 0.0 { + // At rest (the shared runs): the ordinary pipeline, so these + // glyphs antialias exactly like the label they hand off to. + let _ = window.paint_glyph( + rest_origin, + glyph.font, + glyph.id, + font_size, + color.opacity(alpha.min(1.0)), + ); + return; + } + if scale >= 1.0 && motion <= 0.0 { + // The sliding tail: a pure translation, same pipeline. + let origin = point(px(left + dx), px(f32::from(baseline) + glyph.y + dy)); + let _ = window.paint_glyph( + origin, + glyph.font, + glyph.id, + font_size, + color.opacity(alpha.min(1.0)), + ); + return; + } + // Mid-roll: one full-size raster, scaled and tilted about the + // glyph's center at composite time, softened by Scritto's blur. + let dev = |value: f32| ScaledPixels(value * scale_factor); + let transformation = TransformationMatrix::unit() + .translate(point(dev(center_x + dx), dev(center_y + dy))) + .rotate(Radians((TILT_DEG * motion).to_radians())) + .scale(gpui::size(scale, scale)) + .translate(point(dev(-center_x), dev(-center_y))); + let _ = window.paint_glyph_transformed( + rest_origin, + glyph.font, + glyph.id, + font_size, + color.opacity(alpha.min(1.0)), + transformation, + px(f32::from(font_size) * BLUR_EM * motion), + ); + }; + window.with_content_mask(Some(ContentMask { bounds: mask }), |window| { + for glyph in &from_glyphs { + if !changed(glyph.index, from_len) { + continue; // the new line paints the shared runs + } + let progress = GLIDE.eval(((elapsed - from_delay(glyph.x)) / duration).clamp(0.0, 1.0)); + paint( + window, + glyph, + 0.0, + -travel * progress, + lerp(1.0, REST_SCALE, progress), + progress, + 1.0 - progress, + ); + } + for glyph in &to_glyphs { + if glyph.index < prefix { + paint(window, glyph, 0.0, 0.0, 1.0, 0.0, 1.0); + } else if glyph.index >= to_len - suffix { + paint(window, glyph, suffix_dx, 0.0, 1.0, 0.0, 1.0); + } else { + let progress = + GLIDE.eval(((elapsed - to_delay(glyph.x)) / duration).clamp(0.0, 1.0)); + paint( + window, + glyph, + 0.0, + travel * (1.0 - progress), + lerp(REST_SCALE, 1.0, progress), + 1.0 - progress, + progress, + ); + } + } + }); + // Keep rolling on the shared ~30Hz pulse clock rather than at the + // display's refresh rate: the working timer rolls every second, and + // full-rate frames for most of every agent turn were exactly the + // battery drain the pulse clock exists to prevent. The lease lapses + // on its own once the roll stops painting. + if Instant::now().saturating_duration_since(self.roll.started) < total() { + crate::motion::pulse_lease(window.current_view(), cx); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn shared_ends_keep_common_runs() { + assert_eq!(shared_ends("Opus 5.5", "Opus 5.1"), (7, 0)); + assert_eq!(shared_ends("feature/a", "fix/a"), (1, 2)); + assert_eq!(shared_ends("main", "main-2"), (4, 0)); + // Never overlapping: "aa" → "aaa" shares the two leading chars only. + assert_eq!(shared_ends("aa", "aaa"), (2, 0)); + assert_eq!(shared_ends("ab", "b"), (0, 1)); + } + + #[test] + fn glyphs_settle_without_overshoot() { + assert_eq!(GLIDE.eval(0.0), 0.0); + assert_eq!(GLIDE.eval(1.0), 1.0); + let mut last = 0.0; + for i in 0..=200 { + let value = GLIDE.eval(i as f32 / 200.0); + assert!(value <= 1.0, "overshoot at {i}: {value}"); + assert!(value >= last, "not monotonic at {i}"); + last = value; + } + } +} diff --git a/crates/ui/src/running_pill.rs b/crates/ui/src/running_pill.rs new file mode 100644 index 0000000000..46e7fa670d --- /dev/null +++ b/crates/ui/src/running_pill.rs @@ -0,0 +1,359 @@ +//! How many subagents are running, drawn the same way wherever it turns up: +//! the "● 3" pill on the sidebar's chat rows (beside the row's own activity +//! indicator) and the Subagents header, and the pulsing button with a count +//! badge on the titlebar's explorer toggle. + +use gpui::{ + AnyElement, App, AppContext as _, Bounds, Context, Entity, Hsla, IntoElement, Render, + RenderOnce, SharedString, Styled as _, TextAlign, TextRun, Window, canvas, div, fill, point, + prelude::*, px, size, +}; + +use crate::loaders; +use crate::motion::{self, ZERON_PULSE}; +use crate::theme::Theme; + +/// Pill height. The sidebar's status slot is 13px and its rows 29px; 16px +/// matches the sidebar's pull-request badge so the two never disagree. +const HEIGHT: f32 = 16.0; +const DOT: f32 = 5.0; +/// Pill padding either side of the content, and the gap between dot and digits. +const PAD: f32 = 6.0; +const GAP: f32 = 4.0; +/// A capped label ends in "+", whose thin arms leave the eye a wider gap +/// than the same padding after a digit; the padding after it is trimmed by +/// this much so the two ends look balanced. +const PLUS_TRIM: f32 = 1.0; +/// Where the middle of Geist Mono's digits sits above the baseline, as a +/// fraction of the font size: (726 + -16) / 2 units of 1000 for the round +/// digits, 710 / 2 for the flat ones. +const DIGIT_MID: f32 = 0.355; +/// Height of the count badge on the titlebar button. +const BADGE: f32 = 13.0; +/// Corner radius of the titlebar's icon buttons. +const BUTTON_RADIUS: f32 = 6.0; +/// Past this the count would outgrow the pill; it reads "99+". +const COUNT_CAP: u32 = 99; + +/// The count as drawn: exact up to [`COUNT_CAP`], then "99+". +pub fn count_label(count: u32) -> SharedString { + if count > COUNT_CAP { + format!("{COUNT_CAP}+").into() + } else { + count.to_string().into() + } +} + +fn is_capped(count: u32) -> bool { + count > COUNT_CAP +} + +/// The pill for `count` running subagents; callers draw nothing at zero. +/// `key` scopes the pulse's animation state — one per placement. +pub fn running_pill(key: impl Into, count: u32, theme: &Theme) -> AnyElement { + RunningPill { + key: key.into(), + count, + tone: theme.busy, + family: theme.font_mono.clone(), + } + .into_any_element() +} + +#[derive(IntoElement)] +struct RunningPill { + key: SharedString, + count: u32, + tone: Hsla, + family: SharedString, +} + +impl RenderOnce for RunningPill { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let spec = PillSpec { + count: self.count, + tone: self.tone, + family: self.family, + }; + // The pill's width follows its label, and a cached view needs that up + // front; shaping is cached by the text system. + let font_size = pill_font_size(window); + let width = spec.shape(window, font_size).width; + let total = px(PAD + DOT + GAP + spec.right_pad()) + width; + // Own the pulse in a cached view so the surrounding list stays still. + let view = window.with_global_id(self.key.into(), |id, window| { + window.with_element_state(id, |previous: Option>, _| { + let view = previous.unwrap_or_else(|| cx.new(|_| PillView { spec: spec.clone() })); + view.update(cx, |view, cx| { + if view.spec != spec { + view.spec = spec.clone(); + cx.notify(); + } + }); + (view.clone(), view) + }) + }); + view.cached( + gpui::StyleRefinement::default() + .w(total) + .h(px(HEIGHT)) + .flex_none(), + ) + } +} + +fn pill_font_size(window: &Window) -> gpui::Pixels { + crate::typography::ui_rems(10.0).to_pixels(window.rem_size()) +} + +#[derive(Clone, PartialEq)] +struct PillSpec { + count: u32, + tone: Hsla, + family: SharedString, +} + +impl PillSpec { + fn right_pad(&self) -> f32 { + if is_capped(self.count) { + PAD - PLUS_TRIM + } else { + PAD + } + } + + fn shape(&self, window: &Window, font_size: gpui::Pixels) -> gpui::ShapedLine { + shape_count( + window, + self.count, + &self.family, + gpui::FontWeight::MEDIUM, + self.tone, + font_size, + ) + } +} + +fn shape_count( + window: &Window, + count: u32, + family: &SharedString, + weight: gpui::FontWeight, + color: Hsla, + font_size: gpui::Pixels, +) -> gpui::ShapedLine { + let label = count_label(count); + let run = TextRun { + len: label.len(), + font: gpui::Font { + family: family.clone(), + features: gpui::FontFeatures::default(), + fallbacks: None, + weight, + style: gpui::FontStyle::Normal, + }, + color, + background_color: None, + underline: None, + strikethrough: None, + }; + window + .text_system() + .shape_line(label, font_size, &[run], None) +} + +struct PillView { + spec: PillSpec, +} + +impl Render for PillView { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let spec = self.spec.clone(); + let font_size = pill_font_size(window); + let line = spec.shape(window, font_size); + let delta = motion::pulse_delta_slow(&ZERON_PULSE, cx.entity_id(), cx); + let wave = motion::lerp(0.4, 1.0, (motion::pulse_opacity(delta) - 0.08) / 0.92); + canvas( + |_, _, _| (), + move |bounds: Bounds, _, window, cx| { + paint_pill(bounds, &spec, &line, font_size, wave, window, cx); + }, + ) + .size_full() + } +} + +/// Paint the pill: background, dot, digits. Everything hangs off the digits' +/// own baseline so the dot and the digits can never drift apart. Text +/// metrics and glyph rounding differ per font size and display scale, so the +/// baseline is snapped to a device pixel here (as gpui would on painting), +/// and the dot is centred on the digits' actual middle from that baseline. +/// Where to put a line of digits so their middle sits on the middle of +/// `bounds`: the digits' baseline, snapped to a device pixel (as gpui would on +/// painting), and the top of the line box that lands gpui's own centring of +/// ascent-plus-descent on that baseline. Returns `(line_top, baseline)`. +fn digits_baseline( + bounds: Bounds, + font_size: gpui::Pixels, + line: &gpui::ShapedLine, + scale: f32, +) -> (f32, f32) { + let height = f32::from(bounds.size.height); + let digit_mid = f32::from(font_size) * DIGIT_MID; + let baseline = + ((f32::from(bounds.origin.y) + height / 2.0 + digit_mid) * scale).round() / scale; + let baseline_in_box = + (height - f32::from(line.ascent) - f32::from(line.descent)) / 2.0 + f32::from(line.ascent); + (baseline - baseline_in_box, baseline) +} + +fn paint_pill( + bounds: Bounds, + spec: &PillSpec, + line: &gpui::ShapedLine, + font_size: gpui::Pixels, + wave: f32, + window: &mut Window, + cx: &mut App, +) { + let scale = window.scale_factor(); + let snap = |value: f32| (value * scale).round() / scale; + let height = bounds.size.height; + window.paint_quad(fill(bounds, spec.tone.opacity(0.16)).corner_radii(height / 2.0)); + + // Baseline: the digits' middle on the pill's middle, then onto a device + // pixel. `line_top` is where gpui expects the line box so its own + // centring of ascent+descent lands the baseline there. + let digit_mid = f32::from(font_size) * DIGIT_MID; + let baseline = snap(f32::from(bounds.origin.y) + f32::from(height) / 2.0 + digit_mid); + let baseline_in_box = (f32::from(height) - f32::from(line.ascent) - f32::from(line.descent)) + / 2.0 + + f32::from(line.ascent); + let text_origin = point( + px(snap(f32::from(bounds.origin.x) + PAD + DOT + GAP)), + px(baseline - baseline_in_box), + ); + + // Dot: whole device pixels, centred on the digits. + let diameter = snap(DOT).max(1.0 / scale); + let centre_y = baseline - digit_mid; + let dot = Bounds::new( + point( + px(snap(f32::from(bounds.origin.x) + PAD)), + px(snap(centre_y - diameter / 2.0)), + ), + size(px(diameter), px(diameter)), + ); + window.paint_quad(fill(dot, spec.tone.opacity(wave)).corner_radii(px(diameter / 2.0))); + + let _ = line.paint(text_origin, height, TextAlign::Left, None, window, cx); +} + +/// Marks the titlebar's explorer button while subagents run, so they can be +/// found with the panel closed: the button's face breathes in the activity +/// tone, and a small count badge sits on its corner. Both are drawn over the +/// button, so it keeps its size and position among the titlebar controls. +pub fn mark_files_button( + button: gpui::Stateful, + count: u32, + theme: &Theme, +) -> gpui::Stateful { + let tone = theme.busy; + button + .relative() + .child(div().absolute().inset_0().child(loaders::pulse_glow( + "files-button-glow", + BUTTON_RADIUS, + tone, + 0.10, + 0.30, + ))) + .child( + div() + .absolute() + .top(px(-3.0)) + .right(px(-3.0)) + .child(CountBadge { + count, + tone, + family: theme.font_mono.clone(), + }), + ) +} + +/// The count badge on the titlebar button, painted rather than laid out so +/// the digits can be centred exactly: a laid-out text box lands on a rounded +/// device pixel, which read as the digit sitting left of centre. +#[derive(IntoElement)] +struct CountBadge { + count: u32, + tone: Hsla, + family: SharedString, +} + +/// Padding either side of the badge's digits. +const BADGE_PAD: f32 = 3.0; + +impl RenderOnce for CountBadge { + fn render(self, window: &mut Window, _cx: &mut App) -> impl IntoElement { + let font_size = crate::typography::ui_rems(9.0).to_pixels(window.rem_size()); + let line = shape_count( + window, + self.count, + &self.family, + gpui::FontWeight::SEMIBOLD, + gpui::white(), + font_size, + ); + let capped = is_capped(self.count); + let text_width = f32::from(line.width); + // Capped labels trim the padding after the "+" (see [`PLUS_TRIM`]). + let right_pad = if capped { + BADGE_PAD - PLUS_TRIM + } else { + BADGE_PAD + }; + let width = (BADGE_PAD + text_width + right_pad).max(BADGE); + let tone = self.tone; + canvas( + |_, _, _| (), + move |bounds: Bounds, _, window, cx| { + let scale = window.scale_factor(); + let snap = |value: f32| (value * scale).round() / scale; + window.paint_quad(fill(bounds, tone).corner_radii(bounds.size.height / 2.0)); + let (line_top, _) = digits_baseline(bounds, font_size, &line, scale); + // Single digits sit centred in the badge; a capped label sits + // at the padding, its trimmed side toward the "+". + let left = f32::from(bounds.origin.x); + let x = if capped { + left + BADGE_PAD + } else { + left + (f32::from(bounds.size.width) - text_width) / 2.0 + }; + let origin = point(px(snap(x)), px(line_top)); + let _ = line.paint( + origin, + bounds.size.height, + TextAlign::Left, + None, + window, + cx, + ); + }, + ) + .w(px(width)) + .h(px(BADGE)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn count_label_caps_at_ninety_nine() { + assert_eq!(count_label(1).as_ref(), "1"); + assert_eq!(count_label(99).as_ref(), "99"); + assert_eq!(count_label(100).as_ref(), "99+"); + } +} diff --git a/crates/ui/src/settings.rs b/crates/ui/src/settings.rs index b3d9a63f2c..5bdd350768 100644 --- a/crates/ui/src/settings.rs +++ b/crates/ui/src/settings.rs @@ -65,13 +65,50 @@ pub const FILES_AUTOSAVE_DELAY_MAX_MS: u64 = 10_000; const FILE_NAME: &str = "ui-settings.json"; const NEW_THREAD_BACKGROUND_DIR: &str = "new-thread-backgrounds"; -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct NewThreadComposerBackground { /// Managed copy inside Zeron's device-local data directory. pub path: String, /// Original file name shown in Appearance settings. pub name: String, + /// Viewport-relative framing, kept with the image it belongs to. + #[serde(default)] + pub adjustment: NewThreadBackgroundAdjustment, +} + +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct NewThreadBackgroundAdjustment { + /// Normalized horizontal alignment of the overflowing image: 0 left, 1 right. + pub focal_x: f32, + /// Normalized vertical alignment of the overflowing image: 0 top, 1 bottom. + pub focal_y: f32, + /// Multiplier applied after the image has been scaled to cover the viewport. + pub zoom: f32, +} + +impl NewThreadBackgroundAdjustment { + pub const MIN_ZOOM: f32 = 1.0; + pub const MAX_ZOOM: f32 = 4.0; + + pub fn normalized(mut self) -> Self { + let defaults = Self::default(); + self.focal_x = clamp_or(self.focal_x, 0.0, 1.0, defaults.focal_x); + self.focal_y = clamp_or(self.focal_y, 0.0, 1.0, defaults.focal_y); + self.zoom = clamp_or(self.zoom, Self::MIN_ZOOM, Self::MAX_ZOOM, defaults.zoom); + self + } +} + +impl Default for NewThreadBackgroundAdjustment { + fn default() -> Self { + Self { + focal_x: 0.5, + focal_y: 0.5, + zoom: 1.0, + } + } } #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -294,6 +331,8 @@ impl SettingsStore { } pub fn init(settings: UiSettings, data_dir: impl Into, cx: &mut App) { + let data_dir = data_dir.into(); + crate::dictation::init(data_dir.clone(), cx); cx.set_global(SettingsStore { current: settings, data_dir: data_dir.into(), @@ -311,11 +350,17 @@ pub fn current(cx: &App) -> UiSettings { .unwrap_or_default() } +/// Read the picker preference without cloning the full settings for each model row. +pub fn compact_model_picker(cx: &App) -> bool { + cx.try_global::() + .is_some_and(|store| store.current.compact_model_picker) +} + /// Copy a selected image into Zeron's device-local data directory and make it /// the new-thread canvas background. A unique file name avoids stale image /// caches when the background is replaced. pub fn install_new_thread_composer_background(source: &Path, cx: &mut App) -> Result<(), String> { - let staged = crate::attachments::stage_file(source)?; + let staged = crate::attachments::stage_file_verbatim(source)?; // Do not persist the candidate or retire the old managed file until the // renderer's decoder has accepted the exact bytes we are about to save. let image = crate::new_thread_background_image::decode(staged.bytes()).map_err(|_| { @@ -390,6 +435,7 @@ fn prepare_background_file( let replacement = NewThreadComposerBackground { path: destination.to_string_lossy().into_owned(), name: staged.name, + adjustment: NewThreadBackgroundAdjustment::default(), }; Ok(PreparedBackgroundFile(Some(replacement))) } @@ -471,6 +517,19 @@ pub fn set_new_thread_background_effect(effect: NewThreadBackgroundEffect, cx: & } } +pub fn set_new_thread_background_adjustment( + adjustment: NewThreadBackgroundAdjustment, + cx: &mut App, +) { + if update(SavePolicy::Immediate, cx, |settings| { + if let Some(background) = settings.new_thread_composer_background.as_mut() { + background.adjustment = adjustment.normalized(); + } + }) { + cx.refresh_windows(); + } +} + fn remove_managed_new_thread_background( background: Option<&NewThreadComposerBackground>, backgrounds_dir: &Path, @@ -728,6 +787,11 @@ pub const SKILL_COMPLETION_HARNESSES: [(zeron_proto::HarnessId, &str); 9] = [ #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct UiSettings { + pub dictation_enabled: bool, + /// Dictation microphone as a `zeron_voice::InputDevice` id; `None` + /// follows the system default. + #[serde(skip_serializing_if = "Option::is_none")] + pub dictation_input: Option, #[serde(skip_serializing_if = "Option::is_none")] pub window_geometry: Option, /// Submit using Enter or the platform modifier plus Enter. @@ -736,6 +800,8 @@ pub struct UiSettings { pub skills_in_slash_menu: bool, pub skill_completion_by_harness: std::collections::HashMap, + /// Open model selection with an effort slider and a separate model list. + pub compact_model_picker: bool, pub sidebar_width: f32, pub sidebar_collapsed: bool, /// Legacy: the grouped-by-project toggle predates spaces (which group by @@ -899,6 +965,12 @@ pub struct UiSettings { pub reduce_motion: crate::motion::ReduceMotion, /// Also snap animations while the main window is not focused. pub pause_animations_in_background: bool, + /// Stable native Codex voice id only; devices and microphone state are never persisted. + #[serde(default)] + pub codex_voice: Option, + /// Local preference: never transfers an active call or credentials. + #[serde(default)] + pub codex_voice_device: Option, /// Pre-theme settings used `accentColor`. Read it once, migrate to /// [`Self::accent`], and never write it again. #[serde(default, rename = "accentColor", skip_serializing)] @@ -908,6 +980,8 @@ pub struct UiSettings { impl Default for UiSettings { fn default() -> Self { Self { + dictation_enabled: false, + dictation_input: None, window_geometry: None, sidebar_width: SIDEBAR_DEFAULT, sidebar_collapsed: false, @@ -947,6 +1021,7 @@ impl Default for UiSettings { composer_send_behavior: ComposerSendBehavior::default(), skills_in_slash_menu: false, skill_completion_by_harness: Default::default(), + compact_model_picker: true, appshots_enabled: false, appshot_sound_enabled: true, appshot_destination: crate::appshots::AppshotDestination::Automatic, @@ -983,6 +1058,8 @@ impl Default for UiSettings { new_thread_background_effect: NewThreadBackgroundEffect::None, reduce_motion: crate::motion::ReduceMotion::System, pause_animations_in_background: false, + codex_voice: None, + codex_voice_device: None, legacy_accent_color: None, } } @@ -1017,6 +1094,7 @@ const JUMP_LABELS: [&str; JUMP_SLOTS] = [ /// rather than panicking. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum ShortcutId { + ToggleDictation, CaptureAppshot, RandomWallpaper, SaveFile, @@ -1035,7 +1113,8 @@ pub enum ShortcutId { } impl ShortcutId { - pub const ALL: [ShortcutId; 14 + JUMP_SLOTS] = [ + pub const ALL: [ShortcutId; 15 + JUMP_SLOTS] = [ + ShortcutId::ToggleDictation, ShortcutId::CaptureAppshot, ShortcutId::RandomWallpaper, ShortcutId::SaveFile, @@ -1068,6 +1147,7 @@ impl ShortcutId { /// Row label (zeron lib/shortcuts.ts `SHORTCUT_DEFINITIONS`, verbatim). pub fn label(self) -> &'static str { match self { + ShortcutId::ToggleDictation => "Hold to dictate", ShortcutId::RandomWallpaper => "Random wallpaper", ShortcutId::CaptureAppshot => "Capture Appshot", ShortcutId::SaveFile => "Save file", @@ -1095,6 +1175,7 @@ impl ShortcutId { /// this guards against only exists off macOS). pub fn default_combo_on(self, mac: bool) -> &'static str { match self { + ShortcutId::ToggleDictation => "mod-d", ShortcutId::RandomWallpaper => "mod-u", ShortcutId::CaptureAppshot if mac => "ctrl-alt-space", ShortcutId::CaptureAppshot => "mod-alt-space", @@ -1143,6 +1224,7 @@ impl ShortcutId { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct KeymapConfig { + pub toggle_dictation: String, #[cfg_attr(not(any(target_os = "macos", target_os = "linux")), serde(skip))] pub capture_appshot: String, pub random_wallpaper: String, @@ -1209,6 +1291,7 @@ pub fn sidebar_pin_profile_key( impl Default for KeymapConfig { fn default() -> Self { Self { + toggle_dictation: ShortcutId::ToggleDictation.default_combo().into(), capture_appshot: ShortcutId::CaptureAppshot.default_combo().into(), random_wallpaper: ShortcutId::RandomWallpaper.default_combo().into(), save_file: ShortcutId::SaveFile.default_combo().into(), @@ -1231,6 +1314,7 @@ impl Default for KeymapConfig { impl KeymapConfig { pub fn get(&self, id: ShortcutId) -> &str { match id { + ShortcutId::ToggleDictation => &self.toggle_dictation, ShortcutId::CaptureAppshot => &self.capture_appshot, ShortcutId::RandomWallpaper => &self.random_wallpaper, ShortcutId::SaveFile => &self.save_file, @@ -1255,6 +1339,7 @@ impl KeymapConfig { pub fn set(&mut self, id: ShortcutId, combo: String) { match id { + ShortcutId::ToggleDictation => self.toggle_dictation = combo, ShortcutId::CaptureAppshot => self.capture_appshot = combo, ShortcutId::RandomWallpaper => self.random_wallpaper = combo, ShortcutId::SaveFile => self.save_file = combo, @@ -1521,6 +1606,106 @@ impl UiSettings { } } + /// Three-way merge for a view that keeps a working copy of the settings: + /// fields `edited` changed since `base` win, every other field keeps + /// `current`. A stale copy therefore never reverts a choice another + /// surface saved meanwhile. The destructure is exhaustive, so a new field + /// does not compile until it is merged here too. + pub fn merge_changes(base: &Self, edited: &Self, mut current: Self) -> Self { + macro_rules! merge { + ($($field:ident),* $(,)?) => {{ + let Self { $($field),* } = edited; + $(if *$field != base.$field { + current.$field = $field.clone(); + })* + }}; + } + merge!( + dictation_enabled, + dictation_input, + window_geometry, + composer_send_behavior, + skills_in_slash_menu, + skill_completion_by_harness, + compact_model_picker, + sidebar_width, + sidebar_collapsed, + sidebar_grouped, + sidebar_organization, + sidebar_sort, + sidebar_show_project_label, + sidebar_compact, + sidebar_show_project_icon, + sidebar_show_harness, + sidebar_show_branch, + sidebar_show_pull_request, + github_star_banner_dismissed, + last_space_id, + last_project_action_by_space_id, + open_tabs, + space_filter, + sidebar_sections_by_profile, + sidebar_pinned_session_ids_by_profile, + tab_order, + space_order, + sound_enabled, + sound_completion_enabled, + sound_input_enabled, + sound_attention_enabled, + notifications_enabled, + notifications_background_only, + files_panel_width, + agent_update_notifications, + right_pane_width, + right_pane_open, + terminal_height, + terminal_open, + keymap, + appshots_enabled, + appshot_sound_enabled, + appshot_destination, + escape_stops_active_agent, + settings_section, + appearance, + git_history_columns, + git_history_column_widths, + git_history_column_order, + git_history_author_display, + ui_font_family, + ui_font_size, + terminal_font_family, + terminal_font_size, + code_font_family, + code_font_size, + theme_selection, + diff_split, + diff_wrap, + code_fences_fit_content, + transcript_width, + open_web_links_in_zeron, + transcript_compact_mode, + files_autosave_enabled, + files_autosave_delay_ms, + files_word_wrap, + files_show_all, + accent, + surface, + new_thread_composer_background, + wallpaper_folder, + wallpaper_source, + wallpaper_history, + wallpaper_theme_colors, + wallpaper_color, + new_thread_background_effect, + reduce_motion, + pause_animations_in_background, + codex_voice, + codex_voice_device, + legacy_accent_color, + ); + current + } + /// Clamp widths into their legal ranges (also heals NaN to defaults). pub fn clamped(mut self) -> Self { self.transcript_width = normalize_transcript_width(self.transcript_width); @@ -1564,6 +1749,9 @@ impl UiSettings { self.git_history_column_widths = self.git_history_column_widths.clamped(); self.git_history_column_order = self.git_history_column_order.normalized(); self.ui_font_size = self.ui_font_size.normalized(); + if let Some(background) = self.new_thread_composer_background.as_mut() { + background.adjustment = background.adjustment.normalized(); + } self.keymap.heal_jump_slots(); self.keymap.heal_reserved_composer_shortcuts(); self @@ -1776,6 +1964,19 @@ mod tests { assert!(UiSettings::load(dir.path()).skills_in_slash_menu); } + #[test] + fn compact_model_picker_is_default_and_opt_out_persists() { + let legacy: UiSettings = serde_json::from_str("{}").unwrap(); + assert!(legacy.compact_model_picker); + let settings = UiSettings { + compact_model_picker: false, + ..legacy + }; + let saved = serde_json::to_string(&settings).unwrap(); + let loaded: UiSettings = serde_json::from_str(&saved).unwrap(); + assert!(!loaded.compact_model_picker); + } + #[test] fn composer_send_behavior_is_opt_in_for_old_and_partial_settings() { let dir = tempfile::tempdir().unwrap(); @@ -1817,6 +2018,7 @@ mod tests { height: 800.0, }; let settings = UiSettings { + dictation_enabled: false, window_geometry: Some(geometry), ..Default::default() }; @@ -2177,6 +2379,7 @@ mod tests { Some(&NewThreadComposerBackground { path: unrelated.to_string_lossy().into_owned(), name: "keep.png".into(), + adjustment: NewThreadBackgroundAdjustment::default(), }), &backgrounds, ); @@ -2185,12 +2388,115 @@ mod tests { Some(&NewThreadComposerBackground { path: managed.to_string_lossy().into_owned(), name: "owned.png".into(), + adjustment: NewThreadBackgroundAdjustment::default(), }), &backgrounds, ); assert!(!managed.exists()); } + #[test] + fn legacy_background_defaults_to_centered_cover_adjustment() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + UiSettings::path(dir.path()), + r#"{"newThreadComposerBackground":{"path":"managed.png","name":"background.png"}}"#, + ) + .unwrap(); + + let loaded = UiSettings::load(dir.path()); + assert_eq!( + loaded.new_thread_composer_background.unwrap().adjustment, + NewThreadBackgroundAdjustment::default() + ); + } + + #[test] + fn background_adjustment_normalizes_invalid_and_out_of_range_values() { + let normalized = NewThreadBackgroundAdjustment { + focal_x: f32::NAN, + focal_y: 2.0, + zoom: f32::INFINITY, + } + .normalized(); + assert_eq!( + normalized, + NewThreadBackgroundAdjustment { + focal_x: 0.5, + focal_y: 1.0, + zoom: 1.0, + } + ); + + let clamped = UiSettings { + new_thread_composer_background: Some(NewThreadComposerBackground { + path: "managed.png".into(), + name: "background.png".into(), + adjustment: NewThreadBackgroundAdjustment { + focal_x: -1.0, + focal_y: 0.25, + zoom: 99.0, + }, + }), + ..Default::default() + } + .clamped(); + assert_eq!( + clamped.new_thread_composer_background.unwrap().adjustment, + NewThreadBackgroundAdjustment { + focal_x: 0.0, + focal_y: 0.25, + zoom: NewThreadBackgroundAdjustment::MAX_ZOOM, + } + ); + } + + #[gpui::test] + fn background_adjustment_setter_normalizes_and_persists_immediately( + cx: &mut gpui::TestAppContext, + ) { + let dir = tempfile::tempdir().unwrap(); + cx.update(|cx| { + let settings = UiSettings { + new_thread_composer_background: Some(NewThreadComposerBackground { + path: "managed.png".into(), + name: "background.png".into(), + adjustment: NewThreadBackgroundAdjustment::default(), + }), + ..Default::default() + }; + init(settings, dir.path(), cx); + set_new_thread_background_adjustment( + NewThreadBackgroundAdjustment { + focal_x: -0.5, + focal_y: 0.7, + zoom: 2.25, + }, + cx, + ); + + let expected = NewThreadBackgroundAdjustment { + focal_x: 0.0, + focal_y: 0.7, + zoom: 2.25, + }; + assert_eq!( + current(cx) + .new_thread_composer_background + .unwrap() + .adjustment, + expected + ); + assert_eq!( + UiSettings::load(dir.path()) + .new_thread_composer_background + .unwrap() + .adjustment, + expected + ); + }); + } + #[gpui::test] fn invalid_background_replacement_preserves_previous_image_and_settings( cx: &mut gpui::TestAppContext, @@ -2245,6 +2551,14 @@ mod tests { }; init(initial, dir.path(), cx); install_new_thread_composer_background(&first, cx).unwrap(); + set_new_thread_background_adjustment( + NewThreadBackgroundAdjustment { + focal_x: 0.2, + focal_y: 0.8, + zoom: 2.0, + }, + cx, + ); let old_path = current(cx).new_thread_composer_background.unwrap().path; install_new_thread_composer_background(&second, cx).unwrap(); let settings = current(cx); @@ -2259,6 +2573,10 @@ mod tests { settings.new_thread_background_effect, NewThreadBackgroundEffect::Ascii ); + assert_eq!( + replacement.adjustment, + NewThreadBackgroundAdjustment::default() + ); assert!(!Path::new(&old_path).exists()); assert!(first.exists() && second.exists()); assert_eq!( @@ -2307,7 +2625,11 @@ mod tests { fn round_trip() { let dir = tempfile::tempdir().unwrap(); let settings = UiSettings { + dictation_enabled: false, + dictation_input: Some("coreaudio:usb-mic".into()), window_geometry: None, + codex_voice: Some("ember".into()), + codex_voice_device: Some("fedora".into()), sidebar_width: 300.0, sidebar_collapsed: true, sidebar_grouped: true, @@ -2364,6 +2686,7 @@ mod tests { composer_send_behavior: ComposerSendBehavior::ModEnter, skills_in_slash_menu: true, skill_completion_by_harness: Default::default(), + compact_model_picker: true, appshots_enabled: false, appshot_sound_enabled: true, // The destination is only persisted where Appshots exist (macOS and @@ -2416,6 +2739,11 @@ mod tests { new_thread_composer_background: Some(NewThreadComposerBackground { path: "/tmp/zeron/new-thread-background.png".into(), name: "background.png".into(), + adjustment: NewThreadBackgroundAdjustment { + focal_x: 0.25, + focal_y: 0.75, + zoom: 1.8, + }, }), wallpaper_folder: Some("/tmp/wallpapers".into()), wallpaper_source: Some("/tmp/wallpapers/background.png".into()), @@ -2434,6 +2762,9 @@ mod tests { assert!(json.contains(r#""codeFencesFitContent": true"#)); assert!(json.contains(r#""openWebLinksInZeron": false"#)); assert!(json.contains(r#""newThreadBackgroundEffect": "ascii""#)); + assert!(json.contains(r#""focalX": 0.25"#)); + assert!(json.contains(r#""focalY": 0.75"#)); + assert!(json.contains(r#""zoom": 1.8"#)); assert!(json.contains(r#""reduceMotion": "on""#)); assert!(json.contains(r#""pauseAnimationsInBackground": true"#)); assert!(json.contains(r#""terminalFontFamily": "installed:Menlo""#)); diff --git a/crates/ui/src/settings/accounts.rs b/crates/ui/src/settings/accounts.rs index 35eaddaf39..260c8afd45 100644 --- a/crates/ui/src/settings/accounts.rs +++ b/crates/ui/src/settings/accounts.rs @@ -121,7 +121,9 @@ pub fn is_openable_login_url(url: &str) -> bool { "https" => true, "http" => { let authority = rest.split(['/', '?', '#']).next().unwrap_or(""); - let authority = authority.rsplit_once('@').map_or(authority, |(_, host)| host); + let authority = authority + .rsplit_once('@') + .map_or(authority, |(_, host)| host); let host = match authority.strip_prefix('[') { Some(v6) => v6.split(']').next().unwrap_or(""), None => authority.split(':').next().unwrap_or(""), @@ -1185,7 +1187,10 @@ impl AccountsPage { // line, so a failed probe never changes the row's height. let usage: AnyElement = if account.usage_windows.is_empty() { meta.extend(self.render_usage_missing(account, theme)); - div().w(px(USAGE_COLUMN_WIDTH)).flex_none().into_any_element() + div() + .w(px(USAGE_COLUMN_WIDTH)) + .flex_none() + .into_any_element() } else { let resets = account .usage_windows @@ -1371,7 +1376,9 @@ impl AccountsPage { .flex_1() .min_w_0() .child(widgets::row_title(theme, email.clone()).truncate()) - .when(!meta.is_empty(), |el| el.child(widgets::meta_line(theme, meta))), + .when(!meta.is_empty(), |el| { + el.child(widgets::meta_line(theme, meta)) + }), ) .child(usage) .child( @@ -2218,7 +2225,9 @@ impl Render for AccountsPage { mod tests { #[test] fn only_web_and_loopback_login_urls_open() { - assert!(is_openable_login_url("https://claude.com/cai/oauth/authorize?x=1")); + assert!(is_openable_login_url( + "https://claude.com/cai/oauth/authorize?x=1" + )); assert!(is_openable_login_url("http://localhost:1455/auth/callback")); assert!(is_openable_login_url("http://127.0.0.1:8080/")); assert!(is_openable_login_url("http://[::1]:9000/cb")); diff --git a/crates/ui/src/settings/appearance.rs b/crates/ui/src/settings/appearance.rs index 9b25d004c6..1d6774a57b 100644 --- a/crates/ui/src/settings/appearance.rs +++ b/crates/ui/src/settings/appearance.rs @@ -5,11 +5,12 @@ use std::cell::Cell; use std::collections::HashSet; use std::path::{Path, PathBuf}; use std::rc::Rc; +use std::sync::Arc; use gpui::{ - AnyElement, Context, Entity, EventEmitter, FocusHandle, Focusable, Hsla, IntoElement, - KeyDownEvent, ObjectFit, Render, SharedString, StyledImage as _, Subscription, Window, div, - img, prelude::*, px, + AnyElement, Bounds, Context, Entity, EventEmitter, FocusHandle, Focusable, Hsla, IntoElement, + KeyDownEvent, ObjectFit, Pixels, Point, Render, ScrollDelta, SharedString, StyledImage as _, + Subscription, Window, div, img, point, prelude::*, px, }; use zeron_theme::vscode::{ImportReport, SourceCompilation}; use zeron_theme::{ @@ -39,6 +40,91 @@ struct ImportDialog { error: Option, } +struct BackgroundAdjustmentDialog { + focus: FocusHandle, + zoom_focus: FocusHandle, + end_focus: FocusHandle, + return_focus: Option, + path: String, + source: Option>, + draft: crate::settings::NewThreadBackgroundAdjustment, + preview_bounds: Rc>>>, + zoom_bounds: Rc>>>, + drag: Option<( + Point, + crate::settings::NewThreadBackgroundAdjustment, + )>, + pinch: Option<(f32, f32)>, +} + +#[derive(Clone)] +struct BackgroundAdjustmentDrag; + +impl Render for BackgroundAdjustmentDrag { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + gpui::Empty + } +} + +#[derive(Clone)] +struct BackgroundAdjustmentZoomDrag; + +impl Render for BackgroundAdjustmentZoomDrag { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + gpui::Empty + } +} + +const BACKGROUND_ZOOM_STEP: f32 = 0.1; + +fn background_pinch_factor(factor: f32, delta: f32, relative_delta: bool) -> f32 { + if !delta.is_finite() { + return factor; + } + // Direct Manipulation reports a ratio to the last scale; the other + // native backends report an additive change to the gesture's scale. + let next = if relative_delta { + factor * (1.0 + delta) + } else { + factor + delta + }; + next.max(0.001) +} + +fn background_zoom_fraction(zoom: f32) -> f32 { + let minimum = crate::settings::NewThreadBackgroundAdjustment::MIN_ZOOM; + let maximum = crate::settings::NewThreadBackgroundAdjustment::MAX_ZOOM; + ((zoom - minimum) / (maximum - minimum)).clamp(0.0, 1.0) +} + +fn background_zoom_at_x(bounds: Bounds, x: Pixels) -> f32 { + let minimum = crate::settings::NewThreadBackgroundAdjustment::MIN_ZOOM; + let maximum = crate::settings::NewThreadBackgroundAdjustment::MAX_ZOOM; + let fraction = ((f32::from(x - bounds.left()) - 7.0) + / (f32::from(bounds.size.width) - 14.0).max(1.0)) + .clamp(0.0, 1.0); + minimum + fraction * (maximum - minimum) +} + +fn background_preview_size( + viewport: gpui::Size, + sidebar_width: f32, + sidebar_collapsed: bool, +) -> gpui::Size { + let hero_width = (f32::from(viewport.width) + - if sidebar_collapsed { + 0.0 + } else { + sidebar_width + }) + .max(1.0); + let hero_height = + crate::shell::new_thread_background_height(f32::from(viewport.height)).max(1.0); + let max_width = (f32::from(viewport.width) - 84.0).clamp(1.0, 556.0); + let scale = (max_width / hero_width).min(258.0 / hero_height); + gpui::size(px(hero_width * scale), px(hero_height * scale)) +} + /// The three independently configurable font slots. Interface and code/diff /// draw from the whole catalog — proportional faces are legal there. The /// terminal draws from the fixed-width subset only. @@ -227,6 +313,7 @@ pub struct AppearancePage { background_effect_select: widgets::SelectState, reduce_motion_select: widgets::SelectState, import_dialog: Option, + background_adjustment_dialog: Option, review_entry: Option, library_error: Option, background_error: Option, @@ -490,6 +577,7 @@ impl AppearancePage { background_effect_select: widgets::SelectState::default(), reduce_motion_select: widgets::SelectState::default(), import_dialog: None, + background_adjustment_dialog: None, review_entry: None, library_error: None, background_error: None, @@ -968,6 +1056,163 @@ impl AppearancePage { cx.notify(); } + fn open_background_adjustment(&mut self, window: &mut Window, cx: &mut Context) { + let Some(background) = crate::settings::current(cx).new_thread_composer_background else { + return; + }; + let focus = cx.focus_handle(); + let zoom_focus = cx.focus_handle(); + let return_focus = window.focused(cx); + window.focus(&focus, cx); + self.background_adjustment_dialog = Some(BackgroundAdjustmentDialog { + focus, + zoom_focus, + end_focus: cx.focus_handle(), + return_focus, + path: background.path, + source: None, + draft: background.adjustment.normalized(), + preview_bounds: Rc::default(), + zoom_bounds: Rc::default(), + drag: None, + pinch: None, + }); + cx.notify(); + } + + fn dismiss_background_adjustment(&mut self, window: &mut Window, cx: &mut Context) { + if let Some(dialog) = self.background_adjustment_dialog.take() + && let Some(focus) = dialog.return_focus + { + window.focus(&focus, cx); + } + cx.notify(); + } + + fn apply_background_adjustment(&mut self, window: &mut Window, cx: &mut Context) { + let Some(dialog) = self.background_adjustment_dialog.as_ref() else { + return; + }; + // A shuffle or replacement must not receive a draft for another image. + if dialog.source.is_none() + || crate::settings::current(cx) + .new_thread_composer_background + .as_ref() + .is_none_or(|background| background.path != dialog.path) + { + return; + } + crate::settings::set_new_thread_background_adjustment(dialog.draft, cx); + self.dismiss_background_adjustment(window, cx); + } + + fn reset_background_adjustment(&mut self, cx: &mut Context) { + if let Some(dialog) = self.background_adjustment_dialog.as_mut() { + dialog.draft = Default::default(); + dialog.drag = None; + dialog.pinch = None; + } + cx.notify(); + } + + fn set_background_zoom(&mut self, zoom: f32, cx: &mut Context) { + self.zoom_background_around(zoom, None, cx); + } + + fn set_background_zoom_from_x(&mut self, x: Pixels, cx: &mut Context) { + let Some(bounds) = self + .background_adjustment_dialog + .as_ref() + .and_then(|dialog| dialog.zoom_bounds.get()) + else { + return; + }; + self.set_background_zoom(background_zoom_at_x(bounds, x), cx); + } + + fn zoom_background_around( + &mut self, + zoom: f32, + anchor: Option>, + cx: &mut Context, + ) { + let Some(dialog) = self.background_adjustment_dialog.as_mut() else { + return; + }; + if dialog.source.is_none() { + return; + } + let zoom = zoom.clamp( + crate::settings::NewThreadBackgroundAdjustment::MIN_ZOOM, + crate::settings::NewThreadBackgroundAdjustment::MAX_ZOOM, + ); + let next = match (dialog.preview_bounds.get(), dialog.source.as_ref()) { + (Some(bounds), Some(source)) => { + crate::new_thread_background_mask::zoom_adjustment_around( + source, + bounds, + dialog.draft, + zoom, + anchor.unwrap_or_else(|| bounds.center()), + ) + } + _ => crate::settings::NewThreadBackgroundAdjustment { + zoom, + ..dialog.draft + }, + }; + if dialog.draft != next { + dialog.draft = next; + dialog.drag = None; + cx.notify(); + } + } + + fn pan_background(&mut self, delta: Point, cx: &mut Context) { + let Some(dialog) = self.background_adjustment_dialog.as_mut() else { + return; + }; + let (Some(bounds), Some(source)) = (dialog.preview_bounds.get(), dialog.source.as_ref()) + else { + return; + }; + let before = dialog.draft; + dialog.draft = crate::new_thread_background_mask::pan_adjustment( + source, + bounds, + dialog.draft, + point(px(delta.x), px(delta.y)), + ); + if dialog.draft != before { + cx.notify(); + } + } + + fn drag_background(&mut self, position: Point, cx: &mut Context) { + let Some(dialog) = self.background_adjustment_dialog.as_ref() else { + return; + }; + let Some((start, initial)) = dialog.drag else { + return; + }; + let delta = point( + f32::from(position.x - start.x), + f32::from(position.y - start.y), + ); + if let Some(dialog) = self.background_adjustment_dialog.as_mut() { + dialog.draft = initial; + } + self.pan_background(delta, cx); + } + + fn finish_background_drag(&mut self, cx: &mut Context) { + if let Some(dialog) = self.background_adjustment_dialog.as_mut() + && dialog.drag.take().is_some() + { + cx.notify(); + } + } + fn finish_import(&mut self, cx: &mut Context) { let Some(dialog) = self.import_dialog.as_mut() else { return; @@ -1150,7 +1395,9 @@ fn surface_label(surface: SurfacePreference) -> &'static str { fn reduce_motion_helper(preference: ReduceMotion, system: bool) -> &'static str { match (preference, system) { - (ReduceMotion::System, true) => "Following the system, which currently reduces motion.", + (ReduceMotion::System, true) => { + "Following reduced system motion. Activity indicators use a gentle brightness pulse." + } (ReduceMotion::System, false) => "Following the system, which currently allows motion.", (ReduceMotion::On, _) => "Animations skip straight to their final state.", (ReduceMotion::Off, _) => "Animations play even if the system asks for less motion.", @@ -2334,6 +2581,523 @@ impl AppearancePage { Some(popover::modal("theme-import-dialog", viewport, card)) } + fn render_background_adjustment_dialog( + &mut self, + viewport: gpui::Size, + theme: &Theme, + cx: &mut Context, + ) -> Option { + let dialog = self.background_adjustment_dialog.as_ref()?; + let focus = dialog.focus.clone(); + let zoom_focus = dialog.zoom_focus.clone(); + let end_focus = dialog.end_focus.clone(); + let tab_start = focus.clone(); + let tab_end = end_focus.clone(); + let path = dialog.path.clone(); + let draft = dialog.draft; + let preview_bounds = dialog.preview_bounds.clone(); + let zoom_bounds = dialog.zoom_bounds.clone(); + let dragging = dialog.drag.is_some(); + let settings = crate::settings::current(cx); + let same_image = settings + .new_thread_composer_background + .as_ref() + .is_some_and(|background| background.path == path); + let available = Path::new(&path).is_file(); + let source = (same_image && available) + .then(|| { + crate::new_thread_background_effects::prepare( + settings.new_thread_background_effect, + theme, + Path::new(&path), + cx, + ) + }) + .flatten(); + let ready = source.is_some(); + let preview_size = + background_preview_size(viewport, settings.sidebar_width, settings.sidebar_collapsed); + if let Some(dialog) = self.background_adjustment_dialog.as_mut() { + dialog.source = source.clone(); + } + + let paint_source = source.clone(); + let measured_bounds = preview_bounds.clone(); + let preview = div() + .id("new-thread-background-adjustment-preview") + .relative() + .w(preview_size.width + px(2.0)) + .h(preview_size.height + px(2.0)) + .overflow_hidden() + .rounded(px(12.0)) + .border_1() + .border_color(crate::theme::hairline(0.12)) + .bg(theme.bg) + .cursor(if !ready { + gpui::CursorStyle::Arrow + } else if dragging { + gpui::CursorStyle::ClosedHand + } else { + gpui::CursorStyle::OpenHand + }) + .on_scroll_wheel( + cx.listener(|this, event: &gpui::ScrollWheelEvent, window, cx| { + let delta = match event.delta { + ScrollDelta::Pixels(delta) => f32::from(delta.y), + ScrollDelta::Lines(delta) => delta.y * 40.0, + }; + let Some(dialog) = this.background_adjustment_dialog.as_ref() else { + return; + }; + let zoom = dialog.draft.zoom * (delta * 0.0025).clamp(-2.0, 2.0).exp(); + this.zoom_background_around(zoom, Some(event.position), cx); + cx.stop_propagation(); + window.prevent_default(); + }), + ) + .on_pinch(cx.listener(|this, event: &gpui::PinchEvent, window, cx| { + let Some(dialog) = this.background_adjustment_dialog.as_mut() else { + return; + }; + if event.phase == gpui::TouchPhase::Ended { + dialog.pinch = None; + return; + } + if event.phase == gpui::TouchPhase::Started { + dialog.pinch = Some((dialog.draft.zoom, 1.0)); + } + let current_zoom = dialog.draft.zoom; + let (start, factor) = dialog.pinch.get_or_insert((current_zoom, 1.0)); + *factor = + background_pinch_factor(*factor, event.delta, cfg!(target_os = "windows")); + let zoom = *start * *factor; + this.zoom_background_around(zoom, Some(event.position), cx); + cx.stop_propagation(); + window.prevent_default(); + })) + .on_mouse_down( + gpui::MouseButton::Left, + cx.listener(|this, event: &gpui::MouseDownEvent, window, cx| { + let Some(dialog) = this.background_adjustment_dialog.as_mut() else { + return; + }; + if dialog.source.is_none() { + return; + } + window.focus(&dialog.focus, cx); + dialog.drag = Some((event.position, dialog.draft)); + cx.notify(); + cx.stop_propagation(); + }), + ) + .on_drag(BackgroundAdjustmentDrag, |drag, _, _, cx| { + cx.stop_propagation(); + cx.new(|_| drag.clone()) + }) + .child( + gpui::canvas( + move |bounds, _, _| measured_bounds.set(Some(bounds)), + move |bounds, _, window, _| { + if let Some(source) = paint_source.clone() { + crate::new_thread_background_mask::paint_adjusted( + source, + bounds, + draft, + // Canvas content masks are rectangular; round the image + // itself to the frame's 12px radius inside its 1px border. + gpui::Corners::all(px(11.0)), + window, + ); + } + }, + ) + .w(preview_size.width) + .h(preview_size.height), + ) + .when(source.is_none(), |preview| { + preview.child( + div() + .absolute() + .inset_0() + .flex() + .items_center() + .justify_center() + .text_size(typography::ui_rems(11.5)) + .text_color(theme.text_muted) + .child(if !same_image { + "Background changed. Close and adjust the new image." + } else if !available { + "Image unavailable. Choose a replacement or remove it." + } else { + "Preparing preview…" + }), + ) + }) + .when(dragging, |preview| { + preview + .child( + div() + .absolute() + .left(gpui::relative(1.0 / 3.0)) + .top_0() + .bottom_0() + .w(px(1.0)) + .bg(gpui::white().opacity(0.22)), + ) + .child( + div() + .absolute() + .left(gpui::relative(2.0 / 3.0)) + .top_0() + .bottom_0() + .w(px(1.0)) + .bg(gpui::white().opacity(0.22)), + ) + .child( + div() + .absolute() + .top(gpui::relative(1.0 / 3.0)) + .left_0() + .right_0() + .h(px(1.0)) + .bg(gpui::white().opacity(0.22)), + ) + .child( + div() + .absolute() + .top(gpui::relative(2.0 / 3.0)) + .left_0() + .right_0() + .h(px(1.0)) + .bg(gpui::white().opacity(0.22)), + ) + }); + + let zoom_fraction = background_zoom_fraction(draft.zoom); + let measured_zoom_bounds = zoom_bounds.clone(); + let zoom_slider = div() + .id("new-thread-background-adjustment-zoom") + .track_focus(&zoom_focus) + .tab_index(0) + .role(gpui::Role::Slider) + .aria_label("Background zoom") + .aria_numeric_value(f64::from(draft.zoom * 100.0)) + .aria_min_numeric_value(f64::from( + crate::settings::NewThreadBackgroundAdjustment::MIN_ZOOM * 100.0, + )) + .aria_max_numeric_value(f64::from( + crate::settings::NewThreadBackgroundAdjustment::MAX_ZOOM * 100.0, + )) + .aria_numeric_value_step(f64::from(BACKGROUND_ZOOM_STEP * 100.0)) + .aria_value(format!("{:.0}%", draft.zoom * 100.0)) + .focus_visible(|s| s.border_2().border_color(theme.accent)) + .relative() + .w(px(168.0)) + .h(px(28.0)) + .cursor_pointer() + .on_mouse_down( + gpui::MouseButton::Left, + cx.listener(|this, event: &gpui::MouseDownEvent, window, cx| { + if let Some(dialog) = this.background_adjustment_dialog.as_ref() { + window.focus(&dialog.zoom_focus, cx); + } + this.set_background_zoom_from_x(event.position.x, cx); + cx.stop_propagation(); + window.prevent_default(); + }), + ) + .on_drag(BackgroundAdjustmentZoomDrag, |drag, _, _, cx| { + cx.stop_propagation(); + cx.new(|_| drag.clone()) + }) + .on_key_down(cx.listener(|this, event: &KeyDownEvent, window, cx| { + let Some(current) = this + .background_adjustment_dialog + .as_ref() + .map(|dialog| dialog.draft.zoom) + else { + return; + }; + let next = match event.keystroke.key.as_str() { + "left" | "down" => current - BACKGROUND_ZOOM_STEP, + "right" | "up" => current + BACKGROUND_ZOOM_STEP, + "home" => crate::settings::NewThreadBackgroundAdjustment::MIN_ZOOM, + "end" => crate::settings::NewThreadBackgroundAdjustment::MAX_ZOOM, + _ => return, + }; + this.set_background_zoom(next, cx); + cx.stop_propagation(); + window.prevent_default(); + })) + .child( + gpui::canvas( + move |bounds, _, _| measured_zoom_bounds.set(Some(bounds)), + |_, _, _, _| {}, + ) + .absolute() + .inset_0(), + ) + .child( + div() + .absolute() + .left(px(7.0)) + .right(px(7.0)) + .top(px(12.0)) + .h(px(4.0)) + .rounded_full() + .bg(theme.border) + .child( + div() + .h_full() + .w(gpui::relative(zoom_fraction)) + .rounded_full() + .bg(theme.accent), + ) + .child( + div() + .absolute() + .left(gpui::relative(zoom_fraction)) + .ml(px(-7.0)) + .top(px(-5.0)) + .size(px(14.0)) + .rounded_full() + .bg(theme.accent), + ), + ); + let controls = div() + .mt(px(14.0)) + .flex() + .items_center() + .justify_between() + .gap(px(12.0)) + .child( + compact_action(theme, "Reset", "new-thread-background-adjustment-reset") + .tab_index(0) + .role(gpui::Role::Button) + .focus_visible(|s| s.border_2().border_color(theme.accent).opacity(1.0)) + .on_click(cx.listener(|this, _, _, cx| this.reset_background_adjustment(cx))), + ) + .child( + div() + .flex() + .items_center() + .gap(px(6.0)) + .child( + compact_action(theme, "−", "new-thread-background-adjustment-zoom-out") + .tab_index(0) + .role(gpui::Role::Button) + .aria_label("Zoom out") + .focus_visible(|s| s.border_2().border_color(theme.accent).opacity(1.0)) + .on_click(cx.listener(move |this, _, _, cx| { + let Some(dialog) = this.background_adjustment_dialog.as_ref() + else { + return; + }; + this.set_background_zoom( + dialog.draft.zoom - BACKGROUND_ZOOM_STEP, + cx, + ); + })), + ) + .child(zoom_slider) + .child( + div() + .w(px(52.0)) + .text_center() + .text_size(typography::ui_rems(11.5)) + .text_color(theme.text_muted) + .child(format!("{:.0}%", draft.zoom * 100.0)), + ) + .child( + compact_action(theme, "+", "new-thread-background-adjustment-zoom-in") + .tab_index(0) + .role(gpui::Role::Button) + .aria_label("Zoom in") + .focus_visible(|s| s.border_2().border_color(theme.accent).opacity(1.0)) + .on_click(cx.listener(move |this, _, _, cx| { + let Some(dialog) = this.background_adjustment_dialog.as_ref() + else { + return; + }; + this.set_background_zoom( + dialog.draft.zoom + BACKGROUND_ZOOM_STEP, + cx, + ); + })), + ), + ); + + let footer = div() + .mt(px(18.0)) + .pt(px(12.0)) + .border_t_1() + .border_color(theme.border) + .flex() + .items_center() + .justify_end() + .gap(px(8.0)) + .child( + compact_action(theme, "Cancel", "new-thread-background-adjustment-cancel") + .h(px(34.0)) + .px(px(13.0)) + .tab_index(0) + .role(gpui::Role::Button) + .focus_visible(|s| s.border_2().border_color(theme.accent).opacity(1.0)) + .on_click(cx.listener(|this, _, window, cx| { + this.dismiss_background_adjustment(window, cx) + })), + ) + .child( + widgets::text_action(theme, widgets::ActionTone::Solid, "Apply") + .id("new-thread-background-adjustment-apply") + .h(px(34.0)) + .px(px(14.0)) + .py(px(0.0)) + .flex() + .items_center() + .when(!ready, |button| button.opacity(0.45)) + .when(ready, |button| { + button + .tab_index(0) + .role(gpui::Role::Button) + .focus_visible(|s| s.border_2().border_color(theme.accent).opacity(1.0)) + .on_click(cx.listener(|this, _, window, cx| { + this.apply_background_adjustment(window, cx) + })) + }), + ); + + let header = div() + .flex() + .items_start() + .gap(px(16.0)) + .child( + div() + .flex_1() + .min_w_0() + .child(popover::dialog_title(theme, "Adjust background")) + .child( + popover::dialog_body(theme, "Drag to reposition. Scroll or pinch to zoom.") + .mt(px(4.0)), + ), + ) + .child( + div() + .id("new-thread-background-adjustment-close") + .size(px(28.0)) + .rounded(px(7.0)) + .border_1() + .border_color(theme.border) + .bg(theme.surface_raised.opacity(0.28)) + .flex() + .items_center() + .justify_center() + .cursor_pointer() + .hover(|style| style.bg(theme.surface_raised_hover)) + .tab_index(0) + .role(gpui::Role::Button) + .aria_label("Close background adjustment") + .focus_visible(|s| s.border_2().border_color(theme.accent).opacity(1.0)) + .on_click(cx.listener(|this, _, window, cx| { + this.dismiss_background_adjustment(window, cx) + })) + .child( + icons::icon(icons::CLOSE) + .size(px(12.0)) + .text_color(theme.text_muted), + ), + ); + + let card = popover::dialog_card(theme) + .id("new-thread-background-adjustment-card") + .w(px((f32::from(viewport.width) - 40.0).clamp(1.0, 600.0))) + .p(px(20.0)) + .track_focus(&focus) + .tab_group() + .tab_index(0) + .tab_stop(false) + .on_key_down( + cx.listener(move |this, event: &gpui::KeyDownEvent, window, cx| { + let step = if event.keystroke.modifiers.shift { + 24.0 + } else { + 8.0 + }; + match popover::classify_key( + event.keystroke.key.as_str(), + event.keystroke.modifiers.platform, + event.keystroke.modifiers.control, + ) { + popover::MenuKey::Escape => this.dismiss_background_adjustment(window, cx), + popover::MenuKey::ModEnter => this.apply_background_adjustment(window, cx), + _ => match event.keystroke.key.as_str() { + "tab" => { + if event.keystroke.modifiers.shift { + window.focus_prev(cx); + } else { + window.focus_next(cx); + } + if !tab_start.contains_focused(window, cx) { + if event.keystroke.modifiers.shift { + window.focus(&tab_end, cx); + window.focus_prev(cx); + } else { + window.focus(&tab_start, cx); + window.focus_next(cx); + } + } + } + "left" => this.pan_background(point(step, 0.0), cx), + "right" => this.pan_background(point(-step, 0.0), cx), + "up" => this.pan_background(point(0.0, step), cx), + "down" => this.pan_background(point(0.0, -step), cx), + "+" | "=" => { + let zoom = this + .background_adjustment_dialog + .as_ref() + .map_or(1.0, |dialog| dialog.draft.zoom + BACKGROUND_ZOOM_STEP); + this.set_background_zoom(zoom, cx); + } + "-" => { + let zoom = this + .background_adjustment_dialog + .as_ref() + .map_or(1.0, |dialog| dialog.draft.zoom - BACKGROUND_ZOOM_STEP); + this.set_background_zoom(zoom, cx); + } + _ => return, + }, + } + cx.stop_propagation(); + window.prevent_default(); + }), + ) + .on_mouse_down_out( + cx.listener(|this, _, window, cx| this.dismiss_background_adjustment(window, cx)), + ) + .child(header) + .child(div().h(px(16.0))) + .child( + div() + .w_full() + .h(px(260.0)) + .flex() + .items_center() + .justify_center() + .child(preview), + ) + .child(controls) + .child(footer) + .child(div().track_focus(&end_focus).tab_index(0).tab_stop(false)) + .into_any_element(); + + Some(popover::modal( + "new-thread-background-adjustment-dialog", + viewport, + card, + )) + } + fn render_review_dialog( &mut self, viewport: gpui::Size, @@ -2847,6 +3611,25 @@ impl Render for AppearancePage { .gap(px(8.0)) .when(current_background.is_some(), |actions| { actions + .when(background_available, |actions| { + actions.child( + compact_action( + &theme, + "Adjust", + "new-thread-background-adjust", + ) + .tab_index(0) + .role(gpui::Role::Button) + .focus_visible(|s| { + s.border_2().border_color(theme.accent).opacity(1.0) + }) + .on_click( + cx.listener(|this, _, window, cx| { + this.open_background_adjustment(window, cx) + }), + ), + ) + }) .child( compact_action( &theme, @@ -3099,7 +3882,8 @@ impl Render for AppearancePage { .clone() .or_else(|| theme_library::load_warning(cx).map(SharedString::from)); let modal = self - .render_import_dialog(window.viewport_size(), &theme, window, cx) + .render_background_adjustment_dialog(window.viewport_size(), &theme, cx) + .or_else(|| self.render_import_dialog(window.viewport_size(), &theme, window, cx)) .or_else(|| self.render_review_dialog(window.viewport_size(), &theme, cx)); let ui_picker = self.render_font_picker( @@ -3197,6 +3981,16 @@ impl Render for AppearancePage { this.drag_width(event.event.position.x, window, cx); }, )) + .on_drag_move(cx.listener( + |this, event: &gpui::DragMoveEvent, _, cx| { + this.drag_background(event.event.position, cx); + }, + )) + .on_drag_move(cx.listener( + |this, event: &gpui::DragMoveEvent, _, cx| { + this.set_background_zoom_from_x(event.event.position.x, cx); + }, + )) .on_mouse_up( gpui::MouseButton::Left, cx.listener(|this, _: &gpui::MouseUpEvent, _, cx| { @@ -3205,6 +3999,7 @@ impl Render for AppearancePage { cx.notify(); } this.apply_pending_width(cx); + this.finish_background_drag(cx); }), ) .on_mouse_up_out( @@ -3215,6 +4010,7 @@ impl Render for AppearancePage { cx.notify(); } this.apply_pending_width(cx); + this.finish_background_drag(cx); }), ) .relative() @@ -3285,6 +4081,204 @@ impl Render for AppearancePage { mod tests { use super::*; + fn background_settings() -> crate::settings::UiSettings { + crate::settings::UiSettings { + new_thread_composer_background: Some(crate::settings::NewThreadComposerBackground { + path: "background.png".into(), + name: "Background".into(), + adjustment: crate::settings::NewThreadBackgroundAdjustment { + focal_x: 0.25, + focal_y: 0.75, + zoom: 2.0, + }, + }), + ..Default::default() + } + } + + fn ready_background_preview(page: &mut AppearancePage) { + page.background_adjustment_dialog.as_mut().unwrap().source = Some(Arc::new( + gpui::RenderImage::new([image::Frame::new(image::RgbaImage::new(4, 4))]), + )); + } + + #[gpui::test] + fn background_adjustment_cancel_and_reset_leave_saved_settings_untouched( + cx: &mut gpui::TestAppContext, + ) { + let dir = tempfile::tempdir().unwrap(); + let original = background_settings(); + cx.update(|cx| { + gpui_base::init(cx); + cx.set_global(Theme::dark()); + crate::settings::init(original.clone(), dir.path(), cx); + }); + let window = cx.add_window(|_, cx| AppearancePage::new(cx)); + window + .update(cx, |page, window, cx| { + window.focus(&page.width_focus, cx); + page.open_background_adjustment(window, cx); + let dialog = page.background_adjustment_dialog.as_mut().unwrap(); + dialog.drag = Some((point(px(0.0), px(0.0)), dialog.draft)); + dialog.pinch = Some((2.0, 1.0)); + page.reset_background_adjustment(cx); + let dialog = page.background_adjustment_dialog.as_ref().unwrap(); + assert_eq!(dialog.draft, Default::default()); + assert!(dialog.drag.is_none()); + assert!(dialog.pinch.is_none()); + assert_eq!(crate::settings::current(cx), original); + page.dismiss_background_adjustment(window, cx); + assert!(page.background_adjustment_dialog.is_none()); + assert!(page.width_focus.is_focused(window)); + assert_eq!(crate::settings::current(cx), original); + assert!(!crate::settings::UiSettings::path(dir.path()).exists()); + }) + .unwrap(); + } + + #[gpui::test] + fn background_adjustment_apply_waits_for_preview_and_persists_only_the_draft( + cx: &mut gpui::TestAppContext, + ) { + let dir = tempfile::tempdir().unwrap(); + let original = background_settings(); + cx.update(|cx| { + gpui_base::init(cx); + cx.set_global(Theme::dark()); + crate::settings::init(original.clone(), dir.path(), cx); + }); + let window = cx.add_window(|_, cx| AppearancePage::new(cx)); + window + .update(cx, |page, window, cx| { + window.focus(&page.width_focus, cx); + page.open_background_adjustment(window, cx); + page.apply_background_adjustment(window, cx); + assert!(page.background_adjustment_dialog.is_some()); + assert_eq!(crate::settings::current(cx), original); + ready_background_preview(page); + page.reset_background_adjustment(cx); + assert_eq!(crate::settings::current(cx), original); + page.apply_background_adjustment(window, cx); + assert!(page.background_adjustment_dialog.is_none()); + assert!(page.width_focus.is_focused(window)); + let mut expected = original.clone(); + expected + .new_thread_composer_background + .as_mut() + .unwrap() + .adjustment = Default::default(); + assert_eq!(crate::settings::current(cx), expected); + assert_eq!(crate::settings::UiSettings::load(dir.path()), expected); + }) + .unwrap(); + } + + #[gpui::test] + fn background_adjustment_does_not_apply_to_a_replaced_or_removed_image( + cx: &mut gpui::TestAppContext, + ) { + let dir = tempfile::tempdir().unwrap(); + cx.update(|cx| { + gpui_base::init(cx); + cx.set_global(Theme::dark()); + crate::settings::init(background_settings(), dir.path(), cx); + }); + let window = cx.add_window(|_, cx| AppearancePage::new(cx)); + window + .update(cx, |page, window, cx| { + page.open_background_adjustment(window, cx); + ready_background_preview(page); + page.reset_background_adjustment(cx); + crate::settings::update(crate::settings::SavePolicy::Immediate, cx, |settings| { + settings + .new_thread_composer_background + .as_mut() + .unwrap() + .path = "replacement.png".into(); + }); + let replacement = crate::settings::current(cx); + page.apply_background_adjustment(window, cx); + assert_eq!(crate::settings::current(cx), replacement); + assert!(page.background_adjustment_dialog.is_some()); + crate::settings::update(crate::settings::SavePolicy::Immediate, cx, |settings| { + settings.new_thread_composer_background = None; + }); + page.apply_background_adjustment(window, cx); + assert!( + crate::settings::current(cx) + .new_thread_composer_background + .is_none() + ); + assert!(page.background_adjustment_dialog.is_some()); + }) + .unwrap(); + } + + #[test] + fn background_preview_matches_the_runtime_hero_aspect_ratio() { + for viewport in [ + gpui::size(px(1440.0), px(900.0)), + gpui::size(px(800.0), px(1200.0)), + ] { + for collapsed in [false, true] { + let preview = background_preview_size(viewport, 260.0, collapsed); + let hero_width = f32::from(viewport.width) - if collapsed { 0.0 } else { 260.0 }; + let hero_height = + crate::shell::new_thread_background_height(f32::from(viewport.height)); + let aspect = f32::from(preview.width) / f32::from(preview.height); + assert!((aspect - hero_width / hero_height).abs() < 0.00001); + assert!(f32::from(preview.width) <= 556.0); + assert!(f32::from(preview.height) <= 260.0); + } + } + } + + #[test] + fn background_pinch_preserves_native_scale_and_round_trips() { + for relative_delta in [false, true] { + let mut previous = 1.0; + let mut factor = 1.0; + for scale in [1.5, 2.0, 1.5, 1.0] { + let delta = if relative_delta { + scale / previous - 1.0 + } else { + scale - previous + }; + factor = background_pinch_factor(factor, delta, relative_delta); + assert!((factor - scale).abs() < 0.00001); + previous = scale; + } + assert!((factor - 1.0).abs() < 0.00001); + } + } + + #[test] + fn background_pinch_clamps_degenerate_scales_and_ignores_invalid_deltas() { + for relative_delta in [false, true] { + assert_eq!(background_pinch_factor(1.0, -1.0, relative_delta), 0.001); + assert_eq!(background_pinch_factor(1.0, -2.0, relative_delta), 0.001); + for delta in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] { + assert_eq!(background_pinch_factor(1.5, delta, relative_delta), 1.5); + } + } + } + + #[test] + fn background_zoom_slider_matches_its_inset_track_and_clamps() { + let bounds = Bounds::new(point(px(100.0), px(20.0)), gpui::size(px(168.0), px(28.0))); + let minimum = crate::settings::NewThreadBackgroundAdjustment::MIN_ZOOM; + let maximum = crate::settings::NewThreadBackgroundAdjustment::MAX_ZOOM; + + assert_eq!(background_zoom_at_x(bounds, px(90.0)), minimum); + assert_eq!(background_zoom_at_x(bounds, px(107.0)), minimum); + assert_eq!(background_zoom_at_x(bounds, px(261.0)), maximum); + assert_eq!(background_zoom_at_x(bounds, px(280.0)), maximum); + assert_eq!(background_zoom_at_x(bounds, px(184.0)), 2.5); + assert_eq!(background_zoom_fraction(minimum), 0.0); + assert_eq!(background_zoom_fraction(2.5), 0.5); + assert_eq!(background_zoom_fraction(maximum), 1.0); + } + #[test] fn every_mode_gets_a_card() { assert_eq!(AppearanceMode::ALL.len(), 3); @@ -3534,7 +4528,9 @@ mod tests { }); let (_page, cx) = cx.add_window_view(|_, cx| AppearancePage::new(cx)); cx.update(|window, cx| window.draw(cx).clear()); - let trigger = cx.debug_bounds("appearance-surface").expect("glass trigger"); + let trigger = cx + .debug_bounds("appearance-surface") + .expect("glass trigger"); cx.simulate_click(trigger.center(), gpui::Modifiers::default()); cx.update(|window, cx| window.draw(cx).clear()); let frosted = cx diff --git a/crates/ui/src/settings/composer.rs b/crates/ui/src/settings/composer.rs index 66e6fd85ec..7a10356c4b 100644 --- a/crates/ui/src/settings/composer.rs +++ b/crates/ui/src/settings/composer.rs @@ -49,7 +49,11 @@ pub struct ComposerDefaults { /// Last model picked, per harness (restored on harness switch). pub model_by_harness: HashMap, /// Last reasoning level picked (global, like zeron's `reasoning` key). + /// The fallback for models with no level of their own yet. pub reasoning: Option, + /// Last reasoning level picked per harness and model id, so switching + /// back to a model (or its provider) restores the effort it was run at. + pub reasoning_by_model: HashMap>, /// Last non-default model option picks (option id → choice id), per /// harness and model id. Model-scoped because each pick was validated /// against that model's catalog row, so it stays safe to send before the @@ -143,6 +147,29 @@ impl ComposerDefaults { .or_default() } + /// The remembered reasoning level for one model, else the global one. + pub fn reasoning_for(&self, harness: HarnessId, model: Option<&str>) -> Option { + model + .and_then(|model| self.reasoning_by_model.get(&harness)?.get(model).copied()) + .or(self.reasoning) + } + + /// Remember a reasoning pick for one model (and as the global fallback). + pub fn remember_reasoning( + &mut self, + harness: HarnessId, + model: Option<&str>, + level: ReasoningLevel, + ) { + self.reasoning = Some(level); + if let Some(model) = model { + self.reasoning_by_model + .entry(harness) + .or_default() + .insert(model.to_string(), level); + } + } + /// The cached display label for a model id, if ever seen. pub fn label_for(&self, id: &str) -> Option<&str> { self.model_labels.get(id).map(String::as_str) diff --git a/crates/ui/src/settings/devices.rs b/crates/ui/src/settings/devices.rs index 7777ca7061..a851c40fdd 100644 --- a/crates/ui/src/settings/devices.rs +++ b/crates/ui/src/settings/devices.rs @@ -289,7 +289,9 @@ impl Render for DevicesPage { let rename_name = device.name.clone(); let mut meta: Vec = vec![ div() - .child(SharedString::from(platform_label(&device.platform).to_string())) + .child(SharedString::from( + platform_label(&device.platform).to_string(), + )) .into_any_element(), ]; if let Some(version) = device.version.as_deref().filter(|v| !v.is_empty()) { @@ -340,9 +342,11 @@ impl Render for DevicesPage { .role(gpui::Role::Button) .aria_label(format!("Copy device ID {}", device.id)) .focus_visible(|s| s.border_2().border_color(theme.accent)) - .on_click(cx.listener(move |this, _, _, cx| { - this.copy_id(copy_id.clone(), cx); - })), + .on_click(cx.listener( + move |this, _, _, cx| { + this.copy_id(copy_id.clone(), cx); + }, + )), ) .child( widgets::text_action(&theme, widgets::ActionTone::Filled, "Rename") diff --git a/crates/ui/src/settings/harnesses.rs b/crates/ui/src/settings/harnesses.rs index 81c70d38d0..6367679d73 100644 --- a/crates/ui/src/settings/harnesses.rs +++ b/crates/ui/src/settings/harnesses.rs @@ -131,15 +131,13 @@ fn harness_update_label(status: &HarnessUpdateStatus, theme: &Theme) -> (SharedS Some(latest) => format!("{installed} · v{latest} available"), None => format!("{installed} · Update available"), }; - if status.can_apply { - available - } else { - status - .manual_command - .as_deref() - .map(|instruction| format!("{available} · {instruction}")) - .unwrap_or(available) - } + // Applicable Homebrew updates still name the brew command when the + // cask or formula has not published the upstream release yet. + status + .manual_command + .as_deref() + .map(|instruction| format!("{available} · {instruction}")) + .unwrap_or(available) } HarnessUpdatePhase::WaitingForIdle => format!("{installed} · Waiting for agent to be idle"), HarnessUpdatePhase::Preparing => format!("{installed} · Preparing update…"), diff --git a/crates/ui/src/settings/shortcuts.rs b/crates/ui/src/settings/shortcuts.rs index e64a47a486..de18c564f7 100644 --- a/crates/ui/src/settings/shortcuts.rs +++ b/crates/ui/src/settings/shortcuts.rs @@ -230,53 +230,14 @@ impl ShortcutsPage { } RecordOutcome::Ignored => {} RecordOutcome::Set(combo) => { - if recording == ShortcutId::CaptureAppshot - && crate::appshots::validate_shortcut(&combo).is_err() - { - self.conflict_notice = Some( - format!( - "Use {} with a letter, number, function key or navigation key.", - if cfg!(target_os = "macos") { - "Control, Option or Command" - } else { - "Control or Alt" - } - ) - .into(), - ); - self.stop_recording(); - cx.notify(); - cx.stop_propagation(); - return; - } - if send_combo_is_reserved(self.composer_send_behavior, &combo) { - self.conflict_notice = Some( - format!("{} is reserved for the composer.", display_combo(&combo)).into(), - ); - self.stop_recording(); - cx.notify(); - cx.stop_propagation(); - return; - } - // A combo already bound elsewhere is REFUSED, naming the owner - // (zeron settings.shortcuts.tsx: "… is already assigned to …"). - if let Some(owner) = conflict_owner(&self.keymap, recording, &combo) { - self.conflict_notice = Some( - format!( - "{} is already assigned to {}.", - display_combo(&combo), - owner.label() - ) - .into(), - ); - self.stop_recording(); - cx.notify(); - } else { + self.stop_recording(); + self.conflict_notice = + refusal(&self.keymap, self.composer_send_behavior, recording, &combo); + if self.conflict_notice.is_none() { self.keymap.set(recording, combo); - self.stop_recording(); - self.conflict_notice = None; self.commit(cx); } + cx.notify(); } } cx.stop_propagation(); @@ -329,87 +290,28 @@ impl ShortcutsPage { theme: &Theme, cx: &mut Context, ) -> gpui::Div { - let accent = theme.accent; - let combo = self.keymap.get(id).to_string(); - let is_recording = recording == Some(id); - let non_default = combo != id.default_combo(); - let chip_text: SharedString = if is_recording { - "Press keys…".into() - } else { - display_combo(&combo).into() - }; - div() - .flex() - .flex_none() - .items_center() - .gap(px(20.0)) - .when(non_default && !is_recording, |el| { - el.child( - div() - .id(("shortcut-reset", ix)) - .role(gpui::Role::Button) - .aria_label(format!("Reset {} shortcut", id.label())) - .min_h(px(24.0)) - .tab_index(0) - .focus_visible(move |style| style.border_2().border_color(accent)) - .text_size(crate::typography::ui_rems(11.0)) - .text_color(theme.text_muted) - .cursor_pointer() - .hover(|s| s.text_color(theme.text)) - .on_click(cx.listener(move |this, _, _, cx| { - this.keymap.reset(id); - this.stop_recording(); - this.commit(cx); - })) - .child(SharedString::from("Reset")), - ) - }) - .child( - div() - .id(("shortcut-combo", ix)) - .role(gpui::Role::Button) - .aria_label(format!( - "Change {} shortcut: {}", - id.label(), - display_combo(&combo) - )) - .tab_index(0) - .min_w(px(96.0)) - .h(px(widgets::SELECT_HEIGHT)) - .px(px(12.0)) - .rounded(px(8.0)) - // Same glass wash as the settings dropdowns; the - // transparent edge is held for the focus ring. - .border_1() - .border_color(gpui::transparent_black()) - .focus_visible(move |style| style.border_color(accent)) - .flex() - .items_center() - .justify_center() - .font_family(theme.font_mono.clone()) - .text_size(crate::typography::ui_rems(12.0)) - .cursor_pointer() - .map(|el| { - if is_recording { - el.bg(accent.opacity(0.16)) - .border_color(accent.opacity(0.55)) - .text_color(theme.text) - } else { - let hover_key = format!("shortcut-combo-{ix}-hover"); - el.bg(crate::motion::hover_blend( - &hover_key, - widgets::select_fill(theme, false), - widgets::select_fill(theme, true), - )) - .on_hover(crate::motion::hover_listener(hover_key)) - .text_color(theme.text) - } + let page = cx.entity().downgrade(); + let reset_page = page.clone(); + binding_control( + id, + ix, + self.keymap.get(id), + recording == Some(id), + theme, + move |_, cx| { + reset_page + .update(cx, |this, cx| { + this.keymap.reset(id); + this.stop_recording(); + this.commit(cx); }) - .on_click(cx.listener(move |this, _, window, cx| { - this.start_recording(id, window, cx); - })) - .child(chip_text), - ) + .ok(); + }, + move |window, cx| { + page.update(cx, |this, cx| this.start_recording(id, window, cx)) + .ok(); + }, + ) } fn on_scroll_hovered(&mut self, hovered: &bool, _: &mut Window, cx: &mut Context) { @@ -459,6 +361,303 @@ impl popover::ScrollRailHost for ShortcutsPage { } } +/// One rebindable shortcut on the settings page of the feature it drives +/// (Settings → Voice). Records and refuses combos exactly like a Shortcuts +/// row, then saves straight to the store and re-applies the keymap. +pub struct ShortcutField { + id: ShortcutId, + focus: FocusHandle, + recording: bool, + recording_subscriptions: Option<[gpui::Subscription; 3]>, + notice: Option, +} + +impl ShortcutField { + pub fn new(id: ShortcutId, cx: &mut Context) -> Self { + cx.on_release(|field, _| { + if field.recording { + crate::appshots::set_recording(false); + } + }) + .detach(); + Self { + id, + focus: cx.focus_handle(), + recording: false, + recording_subscriptions: None, + notice: None, + } + } + + fn start_recording(&mut self, window: &mut Window, cx: &mut Context) { + self.recording = true; + crate::appshots::set_recording(true); + let field = cx.entity().downgrade(); + // Bound actions run before Div key listeners; intercept first so the + // chord being recorded (⌘D itself) never starts dictation. + let interceptor = cx.intercept_keystrokes(move |event, window, cx| { + let _ = field.update(cx, |field, cx| { + if field.focus.is_focused(window) { + field.record_keystroke(&event.keystroke, cx); + } + }); + }); + let blur = cx.on_blur(&self.focus, window, |field, _, cx| { + field.stop_recording(); + cx.notify(); + }); + // The Voice page outlives its window (closing it keeps the app + // running), and a close does not blur. Never leave Appshots' + // capture shortcut suspended. + let field = cx.entity().downgrade(); + let closed = cx.on_window_closed(move |cx, _| { + field + .update(cx, |field, cx| { + field.stop_recording(); + cx.notify(); + }) + .ok(); + }); + self.recording_subscriptions = Some([interceptor, blur, closed]); + self.notice = None; + window.focus(&self.focus, cx); + cx.notify(); + } + + fn stop_recording(&mut self) { + if self.recording { + crate::appshots::set_recording(false); + } + self.recording = false; + self.recording_subscriptions = None; + } + + fn record_keystroke(&mut self, keystroke: &Keystroke, cx: &mut Context) { + let mods = &keystroke.modifiers; + match record_key( + &keystroke.key, + mods.control, + mods.alt, + mods.shift, + mods.platform, + ) { + RecordOutcome::Cancelled => self.stop_recording(), + RecordOutcome::Ignored => {} + RecordOutcome::Set(combo) => { + self.stop_recording(); + let current = crate::settings::current(cx); + self.notice = refusal( + ¤t.keymap, + current.composer_send_behavior, + self.id, + &combo, + ); + if self.notice.is_none() { + self.save(Some(combo), cx); + } + } + } + cx.notify(); + cx.stop_propagation(); + } + + /// `None` restores the default binding. + fn save(&mut self, combo: Option, cx: &mut Context) { + let id = self.id; + crate::settings::update( + crate::settings::SavePolicy::Immediate, + cx, + |settings| match combo { + Some(combo) => settings.keymap.set(id, combo), + None => settings.keymap.reset(id), + }, + ); + let current = crate::settings::current(cx); + crate::shell::apply_keymap(cx, ¤t.keymap, current.composer_send_behavior); + cx.notify(); + } +} + +impl Render for ShortcutField { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let theme = Theme::of(cx).for_settings_surface(); + let combo = crate::settings::current(cx).keymap.get(self.id).to_string(); + let ix = ShortcutId::ALL + .iter() + .position(|&id| id == self.id) + .unwrap_or(0); + let reset = cx.entity().downgrade(); + let record = reset.clone(); + let helper: Option = if self.recording { + Some("Press Escape to cancel.".into()) + } else { + self.notice.clone() + }; + div() + .id("shortcut-field") + .track_focus(&self.focus) + .flex() + .flex_col() + .items_end() + .gap(px(4.0)) + .child(binding_control( + self.id, + ix, + &combo, + self.recording, + &theme, + move |_, cx| { + reset + .update(cx, |field, cx| { + field.stop_recording(); + field.notice = None; + field.save(None, cx); + }) + .ok(); + }, + move |window, cx| { + record + .update(cx, |field, cx| field.start_recording(window, cx)) + .ok(); + }, + )) + .children(helper.map(|helper| { + div() + .text_size(crate::typography::ui_rems(12.0)) + .text_color(theme.text_muted) + .child(helper) + })) + } +} + +/// Reset (when customized) plus the click-to-record combo chip; recording +/// inverts it to the accent wash. Shared by Settings → Shortcuts and the +/// feature pages that own a shortcut ([`ShortcutField`]). +fn binding_control( + id: ShortcutId, + ix: usize, + combo: &str, + is_recording: bool, + theme: &Theme, + on_reset: impl Fn(&mut Window, &mut gpui::App) + 'static, + on_record: impl Fn(&mut Window, &mut gpui::App) + 'static, +) -> gpui::Div { + let accent = theme.accent; + let combo = combo.to_string(); + let non_default = combo != id.default_combo(); + let chip_text: SharedString = if is_recording { + "Press keys…".into() + } else { + display_combo(&combo).into() + }; + div() + .flex() + .flex_none() + .items_center() + .gap(px(20.0)) + .when(non_default && !is_recording, |el| { + el.child( + div() + .id(("shortcut-reset", ix)) + .role(gpui::Role::Button) + .aria_label(format!("Reset {} shortcut", id.label())) + .min_h(px(24.0)) + .tab_index(0) + .focus_visible(move |style| style.border_2().border_color(accent)) + .text_size(crate::typography::ui_rems(11.0)) + .text_color(theme.text_muted) + .cursor_pointer() + .hover(|s| s.text_color(theme.text)) + .on_click(move |_, window, cx| on_reset(window, cx)) + .child(SharedString::from("Reset")), + ) + }) + .child( + div() + .id(("shortcut-combo", ix)) + .role(gpui::Role::Button) + .aria_label(format!( + "Change {} shortcut: {}", + id.label(), + display_combo(&combo) + )) + .tab_index(0) + .min_w(px(96.0)) + .h(px(widgets::SELECT_HEIGHT)) + .px(px(12.0)) + .rounded(px(8.0)) + // Same glass wash as the settings dropdowns; the + // transparent edge is held for the focus ring. + .border_1() + .border_color(gpui::transparent_black()) + .focus_visible(move |style| style.border_color(accent)) + .flex() + .items_center() + .justify_center() + .font_family(theme.font_mono.clone()) + .text_size(crate::typography::ui_rems(12.0)) + .cursor_pointer() + .map(|el| { + if is_recording { + el.bg(accent.opacity(0.16)) + .border_color(accent.opacity(0.55)) + .text_color(theme.text) + } else { + let hover_key = format!("shortcut-combo-{ix}-hover"); + el.bg(crate::motion::hover_blend( + &hover_key, + widgets::select_fill(theme, false), + widgets::select_fill(theme, true), + )) + .on_hover(crate::motion::hover_listener(hover_key)) + .text_color(theme.text) + } + }) + .on_click(move |_, window, cx| on_record(window, cx)) + .child(chip_text), + ) +} + +/// Why `combo` cannot be bound to `id`, if it cannot. Conflicts never +/// persist; they're refused at record time, naming the owner (zeron +/// settings.shortcuts.tsx: "… is already assigned to …"). Pure. +fn refusal( + keymap: &KeymapConfig, + behavior: ComposerSendBehavior, + id: ShortcutId, + combo: &str, +) -> Option { + if id == ShortcutId::CaptureAppshot && crate::appshots::validate_shortcut(combo).is_err() { + return Some( + format!( + "Use {} with a letter, number, function key or navigation key.", + if cfg!(target_os = "macos") { + "Control, Option or Command" + } else { + "Control or Alt" + } + ) + .into(), + ); + } + if send_combo_is_reserved(behavior, combo) { + return Some(format!("{} is reserved for the composer.", display_combo(combo)).into()); + } + conflict_owner(keymap, id, combo).map(|owner| { + // Name the page for shortcuts that live on their feature's page. + let page = match group(owner) { + page @ ("Appshots" | "Voice") => format!(" in Settings → {page}"), + _ => String::new(), + }; + format!( + "{} is already assigned to {}{page}.", + display_combo(combo), + owner.label() + ) + .into() + }) +} + /// The shortcut (other than `id`) already bound to `combo`, if any. Pure. pub fn conflict_owner(keymap: &KeymapConfig, id: ShortcutId, combo: &str) -> Option { ShortcutId::ALL @@ -479,7 +678,7 @@ pub fn modifier_send_label(is_macos: bool) -> &'static str { /// extends the match and appears on the page by construction /// (`every_shortcut_lands_in_a_rendered_group` holds the other half: its group /// name must be listed here). -const GROUP_ORDER: [&str; 8] = [ +const GROUP_ORDER: [&str; 9] = [ "Appearance", "Files", "Browser", @@ -488,6 +687,7 @@ const GROUP_ORDER: [&str; 8] = [ "Projects", "Jump to session", "Appshots", + "Voice", ]; /// The section a shortcut's row renders under. @@ -502,6 +702,7 @@ fn group(id: ShortcutId) -> &'static str { | ShortcutId::ToggleFiles | ShortcutId::ToggleTerminal => "Panels", ShortcutId::NewProject => "Projects", + ShortcutId::ToggleDictation => "Voice", ShortcutId::OpenModelPicker | ShortcutId::NewSession | ShortcutId::NextSession @@ -513,6 +714,13 @@ fn group(id: ShortcutId) -> &'static str { impl Render for ShortcutsPage { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + // Feature pages (Voice) rebind their shortcut through the store. Read + // it back so a later commit here never republishes an older keymap. + if self.recording.is_none() + && let Some(store) = cx.try_global::() + { + self.keymap.clone_from(&store.current.keymap); + } if self.appshots_page { if std::mem::take(&mut self.appshots_focus_pending) { window.focus(&self.focus, cx); @@ -594,6 +802,45 @@ impl Render for ShortcutsPage { cx.notify(); })), ); + let compact_model_picker = crate::settings::compact_model_picker(cx); + let compact_model_picker_row = widgets::card_row(&theme, false) + .child( + div() + .flex_1() + .min_w_0() + .child(widgets::row_title(&theme, "Compact model picker")) + .child(widgets::meta_line( + &theme, + vec![ + div() + .child("Adjust effort with a slider, then open the model list when needed.") + .into_any_element(), + ], + )), + ) + .child( + widgets::toggle_switch(&theme, compact_model_picker, "compact-model-picker") + .id("compact-model-picker-toggle") + .tab_index(0) + .role(gpui::Role::Switch) + .aria_label("Compact model picker") + .aria_toggled(if compact_model_picker { + gpui::Toggled::True + } else { + gpui::Toggled::False + }) + .focus_visible(|s| s.border_2().border_color(theme.accent)) + .cursor_pointer() + .on_click(cx.listener(move |_, _, _, cx| { + crate::settings::update( + crate::settings::SavePolicy::Debounced, + cx, + |settings| settings.compact_model_picker = !compact_model_picker, + ); + cx.refresh_windows(); + cx.notify(); + })), + ); let escape_behavior_row = widgets::card_row(&theme, false) .child( div() @@ -655,6 +902,7 @@ impl Render for ShortcutsPage { widgets::section_card(&theme) .child(send_behavior_row) .child(compact_mode_row) + .child(compact_model_picker_row) .child(escape_behavior_row), ) .child(self.thread_naming.clone()), @@ -672,7 +920,8 @@ impl Render for ShortcutsPage { // own top margin is zeroed. let mut groups: Vec = Vec::new(); for name in GROUP_ORDER { - if name == "Appshots" { + // Feature pages own these: Settings → Appshots and → Voice. + if name == "Appshots" || name == "Voice" { continue; } let mut card = widgets::section_card(&theme).mt_0(); @@ -799,6 +1048,84 @@ impl Render for ShortcutsPage { mod tests { use super::*; + #[gpui::test] + fn voice_shortcut_records_on_its_own_page_and_shortcuts_reads_it_back( + cx: &mut gpui::TestAppContext, + ) { + let dir = tempfile::tempdir().unwrap(); + cx.update(|cx| { + gpui_base::init(cx); + cx.set_global(Theme::default()); + crate::settings::init(crate::settings::UiSettings::default(), dir.path(), cx); + }); + assert_eq!(ShortcutId::ToggleDictation.default_combo(), "mod-d"); + assert_eq!(group(ShortcutId::ToggleDictation), "Voice"); + let modifier = if cfg!(target_os = "macos") { + "cmd" + } else { + "ctrl" + }; + let (field, cx) = + cx.add_window_view(|_, cx| ShortcutField::new(ShortcutId::ToggleDictation, cx)); + // A chord another shortcut owns is refused and the binding is kept. + field.update_in(cx, |field, window, cx| field.start_recording(window, cx)); + cx.simulate_keystrokes(&format!("{modifier}-b")); + field.update(cx, |field, cx| { + assert!(!field.recording); + assert!(field.notice.is_some()); + assert_eq!( + crate::settings::current(cx).keymap.toggle_dictation, + "mod-d" + ); + }); + field.update_in(cx, |field, window, cx| field.start_recording(window, cx)); + cx.simulate_keystrokes(&format!("{modifier}-shift-v")); + field.update(cx, |field, cx| { + assert!(field.notice.is_none()); + assert_eq!( + crate::settings::current(cx).keymap.toggle_dictation, + "mod-shift-v" + ); + }); + // Closing the window mid-recording (the app keeps running) must not + // leave Appshots' capture shortcut suspended. + field.update_in(cx, |field, window, cx| field.start_recording(window, cx)); + cx.update(|window, _| window.remove_window()); + cx.run_until_parked(); + field.update(cx, |field, _| assert!(!field.recording)); + // Elsewhere, a clash with a feature page's shortcut says where it lives. + let notice = refusal( + &KeymapConfig::default(), + ComposerSendBehavior::Enter, + ShortcutId::ToggleSidebar, + "mod-d", + ) + .unwrap(); + assert!( + notice.ends_with("Hold to dictate in Settings → Voice."), + "{notice}" + ); + // Settings → Shortcuts, opened later with an older copy, adopts the + // store's binding so its own commits cannot revert Voice's. + let (page, cx) = cx.add_window_view(|_, cx| { + let state = cx.new(|_| AppState::new()); + ShortcutsPage::new( + state, + KeymapConfig::default(), + false, + ComposerSendBehavior::Enter, + false, + false, + AppshotDestination::Automatic, + cx, + ) + }); + cx.update(|window, cx| window.draw(cx).clear()); + page.update(cx, |page, _| { + assert_eq!(page.keymap.toggle_dictation, "mod-shift-v"); + }); + } + #[gpui::test] fn conversation_controls_work_after_moving_out_of_shortcuts(cx: &mut gpui::TestAppContext) { cx.update(|cx| { diff --git a/crates/ui/src/settings/thread_naming.rs b/crates/ui/src/settings/thread_naming.rs index 2c7012230c..7e566afbdf 100644 --- a/crates/ui/src/settings/thread_naming.rs +++ b/crates/ui/src/settings/thread_naming.rs @@ -27,9 +27,12 @@ impl ThreadNamingCard { let state = state.clone(); cx.new(|cx| Pickers::new_for_titles(state, TitleSettings::default(), cx)) }; - let picked = cx.subscribe(&picker, |this: &mut Self, _, event: &TitleModelPicked, cx| { - this.call(Some(event.0.clone()), cx); - }); + let picked = cx.subscribe( + &picker, + |this: &mut Self, _, event: &TitleModelPicked, cx| { + this.call(Some(event.0.clone()), cx); + }, + ); // Settings can open before the engine connects: load once it does. let state_sub = cx.observe(&state, |this: &mut Self, _, cx| { if matches!(this.settings, Loadable::Idle) { @@ -155,7 +158,9 @@ impl Render for ThreadNamingCard { .child(control), ) .when_some(self.error.clone(), |card, error| { - card.child(widgets::card_row(&theme, false).child(widgets::error_strip(&theme, error))) + card.child( + widgets::card_row(&theme, false).child(widgets::error_strip(&theme, error)), + ) }) } } diff --git a/crates/ui/src/settings/wallpaper.rs b/crates/ui/src/settings/wallpaper.rs index 2bbff9664a..e413aec32f 100644 --- a/crates/ui/src/settings/wallpaper.rs +++ b/crates/ui/src/settings/wallpaper.rs @@ -57,7 +57,7 @@ fn choose( (recency, *rank) }); for (_, path) in candidates { - if let Ok(staged) = crate::attachments::stage_file(&path) + if let Ok(staged) = crate::attachments::stage_file_verbatim(&path) && let Ok(image) = crate::new_thread_background_image::decode(staged.bytes()) { return Ok((path, staged, image)); @@ -72,7 +72,9 @@ const LOOKAHEAD: usize = 3; struct QueueKey { folder: Option, history: Vec, - background: Option, + /// Managed path only: adjusting the active image's framing must not + /// discard the decoded lookahead. + background: Option, effect: super::NewThreadBackgroundEffect, light: bool, data_dir: PathBuf, @@ -88,7 +90,9 @@ impl QueueKey { Self { folder: settings.wallpaper_folder, history, - background: settings.new_thread_composer_background, + background: settings + .new_thread_composer_background + .map(|background| background.path), effect: settings.new_thread_background_effect, light: cx .try_global::() @@ -152,11 +156,10 @@ fn remove_orphaned_preloads(key: &QueueKey) { }; // Compare names, not full paths, so a differently spelled data directory // can never make the active image look orphaned. - let active = key.background.as_ref().and_then(|background| { - Path::new(&background.path) - .file_name() - .map(|name| name.to_owned()) - }); + let active = key + .background + .as_deref() + .and_then(|path| Path::new(path).file_name().map(|name| name.to_owned())); for entry in entries.flatten() { let name = entry.file_name(); let managed = name @@ -358,6 +361,59 @@ mod tests { cx.run_until_parked(); } + #[gpui::test] + fn warm_switch_resets_previous_background_adjustment(cx: &mut gpui::TestAppContext) { + let dir = tempfile::tempdir().unwrap(); + let folder = wallpaper_folder(dir.path()); + let data = dir.path().join("data"); + cx.update(|cx| { + cx.set_global(crate::theme::Theme::default()); + super::super::init( + super::super::UiSettings { + wallpaper_folder: Some(folder.clone()), + ..Default::default() + }, + &data, + cx, + ); + super::super::install_new_thread_composer_background(&folder.join("0.png"), cx) + .unwrap(); + preload(cx); + }); + cx.run_until_parked(); + cx.update(|cx| { + // Framing belongs to the active image; it must not flush the + // decoded lookahead for the next shuffle. + let generation = cx.global::().generation; + super::super::set_new_thread_background_adjustment( + super::super::NewThreadBackgroundAdjustment { + focal_x: 0.2, + focal_y: 0.8, + zoom: 2.0, + }, + cx, + ); + preload(cx); + assert_eq!(cx.global::().generation, generation); + assert_eq!(cx.global::().ready.len(), LOOKAHEAD); + let expected = cx.global::().ready[0].source.clone(); + randomize(cx).detach(); + assert_eq!(cx.global::().ready.len(), LOOKAHEAD - 1); + let settings = super::super::current(cx); + assert_eq!(settings.wallpaper_source.as_ref(), Some(&expected)); + assert_eq!( + settings + .new_thread_composer_background + .as_ref() + .unwrap() + .adjustment, + super::super::NewThreadBackgroundAdjustment::default(), + ); + assert_eq!(super::super::UiSettings::load(&data), settings); + }); + cx.run_until_parked(); + } + #[gpui::test] fn first_preload_retires_copies_left_by_a_previous_session(cx: &mut gpui::TestAppContext) { let dir = tempfile::tempdir().unwrap(); @@ -377,6 +433,7 @@ mod tests { super::super::NewThreadComposerBackground { path: active.to_string_lossy().into_owned(), name: "active.png".into(), + adjustment: super::super::NewThreadBackgroundAdjustment::default(), }, ), ..Default::default() diff --git a/crates/ui/src/settings/wallpaper_colors.rs b/crates/ui/src/settings/wallpaper_colors.rs index 60ca76477b..6f5e092bf3 100644 --- a/crates/ui/src/settings/wallpaper_colors.rs +++ b/crates/ui/src/settings/wallpaper_colors.rs @@ -252,6 +252,7 @@ mod tests { super::super::NewThreadComposerBackground { path: image.to_string_lossy().into_owned(), name: "existing.png".into(), + adjustment: super::super::NewThreadBackgroundAdjustment::default(), }, ), ..Default::default() diff --git a/crates/ui/src/settings/widgets.rs b/crates/ui/src/settings/widgets.rs index fdbd8928db..d31b17775a 100644 --- a/crates/ui/src/settings/widgets.rs +++ b/crates/ui/src/settings/widgets.rs @@ -1649,10 +1649,21 @@ impl RenderOnce for SettingsScroll { /// A one-line hover note for settings controls (reset times, icon-only /// actions), in the same frosted chip as the rest of the app's tooltips. -pub struct TextTooltip(pub SharedString); +pub struct TextTooltip { + text: SharedString, + above: bool, +} + +/// How far a [`text_tooltip_above`] chip sits over the pointer; clears a +/// small control the pointer is anywhere inside. +const TOOLTIP_ABOVE_GAP: f32 = 20.0; impl Render for TextTooltip { - fn render(&mut self, _window: &mut gpui::Window, cx: &mut gpui::Context) -> impl IntoElement { + fn render( + &mut self, + _window: &mut gpui::Window, + cx: &mut gpui::Context, + ) -> impl IntoElement { let theme = Theme::of(cx); let card = div() .max_w(px(320.0)) @@ -1664,8 +1675,20 @@ impl Render for TextTooltip { .bg(crate::popover::surface_bg(theme)) .text_size(px(11.0)) .text_color(theme.text_muted) - .child(self.0.clone()); - crate::frost::frosted(6.0, crate::frost::MENU_BLUR, card) + .child(self.text.clone()); + let card = crate::frost::frosted(6.0, crate::frost::MENU_BLUR, card); + if !self.above { + return card.into_any_element(); + } + // gpui hangs a tooltip below-right of the pointer. A zero-height box + // with end-aligned content makes the chip overflow upward instead. + div() + .h(px(0.0)) + .flex() + .flex_col() + .justify_end() + .child(div().relative().bottom(px(TOOLTIP_ABOVE_GAP)).child(card)) + .into_any_element() } } @@ -1674,5 +1697,26 @@ pub fn text_tooltip( text: impl Into, ) -> impl Fn(&mut gpui::Window, &mut gpui::App) -> gpui::AnyView + 'static { let text: SharedString = text.into(); - move |_, cx| cx.new(|_| TextTooltip(text.clone())).into() + move |_, cx| { + cx.new(|_| TextTooltip { + text: text.clone(), + above: false, + }) + .into() + } +} + +/// Like [`text_tooltip`], shown above the pointer instead of below it, for +/// controls with content underneath that the chip would otherwise cover. +pub fn text_tooltip_above( + text: impl Into, +) -> impl Fn(&mut gpui::Window, &mut gpui::App) -> gpui::AnyView + 'static { + let text: SharedString = text.into(); + move |_, cx| { + cx.new(|_| TextTooltip { + text: text.clone(), + above: true, + }) + .into() + } } diff --git a/crates/ui/src/shell.rs b/crates/ui/src/shell.rs index c763fb3e77..82ef0503a5 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -14,7 +14,7 @@ use std::path::PathBuf; use std::time::Duration; -use chrono::Utc; +use chrono::{DateTime, Utc}; use gpui::{ Action, AnyElement, App, ClipboardItem, Context, Empty, Entity, FocusHandle, Focusable as _, IntoElement, KeyBinding, Keystroke, ModifiersChangedEvent, MouseButton, MouseDownEvent, @@ -24,7 +24,7 @@ use gpui::{ use gpui_tokio::Tokio; use zeron_engine::InstanceLock; -use zeron_proto::{AuthState, WorkspaceScope}; +use zeron_proto::{AuthState, ChatConfig, HarnessId, WorkspaceScope}; use zeron_rpc::methods; use crate::changes::{Changes, ChangesEvent, DiscardWorkingTreeRequest}; @@ -62,7 +62,10 @@ mod actions_ui; mod chat_dropzone; #[cfg(test)] mod chat_dropzone_tests; +#[cfg(test)] +mod chat_rename_tests; mod command_palette; +mod file_mutations; mod files_panel; mod harness_updates; mod navigation_focus; @@ -75,6 +78,8 @@ mod sidebar_sections; pub(crate) mod spaces; use side_chats::SideChatTab; mod tabs; +mod voice_stage; +use voice_stage::VOICE_STAGE_ORB_SCALE; use spaces::{AddSpaceFlow, RenameSpaceDialog}; @@ -417,6 +422,14 @@ pub fn apply_keymap( // rebuilding Zeron's bindings so the file editor keymap remains active. gpui_base::init(cx); crate::composer::init(cx, composer_send_behavior); + cx.bind_keys([KeyBinding::new( + &valid_or_default( + &keymap.toggle_dictation, + ShortcutId::ToggleDictation.default_combo(), + ), + crate::composer::ToggleDictation, + Some("MessageComposer"), + )]); // Fixed app-level shortcuts (Settings on every platform; ⌘Q quit, ⌘W // close, ⌘M minimize, ⌘H hide on macOS) — these back the native menu // key equivalents and must survive keymap re-application. @@ -522,6 +535,7 @@ pub enum SettingsSection { Appearance, Files, Notifications, + Voice, Shortcuts, /// Composer and conversation behavior plus thread naming. #[default] @@ -532,10 +546,11 @@ pub enum SettingsSection { impl SettingsSection { /// Sections shown in Settings. `Agents` is a legacy Accounts route alias. - pub const ALL: [SettingsSection; 9] = [ + pub const ALL: [SettingsSection; 10] = [ SettingsSection::General, SettingsSection::Appearance, SettingsSection::Notifications, + SettingsSection::Voice, SettingsSection::Shortcuts, SettingsSection::Harnesses, SettingsSection::Devices, @@ -560,7 +575,7 @@ impl SettingsSection { /// Where a generic "open Settings" lands for a remembered section: legacy /// aliases resolve to their page, and a section this build does not show - /// (Appshots off-desktop) falls back to General. + /// (Appshots off macOS/Linux) falls back to General. pub(crate) fn reopenable(self) -> Self { let section = self.canonical(); if section.visible_in_nav() { @@ -579,6 +594,7 @@ impl SettingsSection { SettingsSection::Appearance => "appearance", SettingsSection::Files => "files", SettingsSection::Notifications => "notifications", + SettingsSection::Voice => "voice", SettingsSection::Shortcuts => "shortcuts", SettingsSection::General => "general", SettingsSection::Appshots => "appshots", @@ -595,6 +611,7 @@ impl SettingsSection { "appearance" => SettingsSection::Appearance, "files" => SettingsSection::Files, "notifications" => SettingsSection::Notifications, + "voice" => SettingsSection::Voice, "shortcuts" => SettingsSection::Shortcuts, "general" | "conversations" => SettingsSection::General, "appshots" => SettingsSection::Appshots, @@ -619,6 +636,7 @@ impl SettingsSection { SettingsSection::Appearance => "Appearance", SettingsSection::Files => "Files", SettingsSection::Notifications => "Notifications", + SettingsSection::Voice => "Voice", SettingsSection::Shortcuts => "Shortcuts", SettingsSection::General => "General", SettingsSection::Appshots => "Appshots", @@ -1002,6 +1020,38 @@ pub(crate) fn sidebar_faded_label( .fade_label_overflow(&overflow) } +/// A chat row's inline title editor, swapped in for its faded title while +/// the chat is renamed in place: the title's 13/17 type in an accent-ringed +/// field. +pub(crate) fn chat_title_editor( + id: SharedString, + input: Entity, + theme: &Theme, +) -> AnyElement { + div() + .id(id.clone()) + .debug_selector(move || id.to_string()) + .flex_1() + .min_w_0() + .h(px(21.0)) + .my(px(-2.0)) + .px(px(4.0)) + .flex() + .items_center() + .rounded(px(4.0)) + .border_1() + .border_color(theme.accent) + .bg(theme.input_glass_bg()) + .text_color(theme.text) + .cursor_text() + // The field is not the row: clicks place the caret instead of + // opening the chat or starting a drag. + .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) + .on_click(|_, _, cx| cx.stop_propagation()) + .child(div().flex_1().min_w_0().overflow_hidden().child(input)) + .into_any_element() +} + /// Ramp height of the sidebar's scroll-edge fade (the gpui /// [`gpui::EdgeFade`] scope — per-primitive, so text fades per glyph). const SIDEBAR_GLASS_FADE_BAND: f32 = 24.0; @@ -1271,13 +1321,14 @@ fn new_thread_background_opacity(is_frost: bool) -> f32 { } } -fn new_thread_background_height(viewport_height: f32) -> f32 { +pub(crate) fn new_thread_background_height(viewport_height: f32) -> f32 { (viewport_height.max(0.0) * NEW_THREAD_BACKGROUND_VIEWPORT_RATIO) .min(NEW_THREAD_BACKGROUND_MAX_HEIGHT) } fn new_thread_background( artwork: Option>, + adjustment: settings::NewThreadBackgroundAdjustment, viewport_height: f32, hero_width: f32, composer_bounds: crate::new_thread_background_mask::SurfaceBounds, @@ -1322,6 +1373,7 @@ fn new_thread_background( artwork.clone(), bounds, composer, + adjustment, cutout, window, ); @@ -1342,13 +1394,32 @@ enum SplashPhase { Gone, } -/// The chat-row Rename dialog. -struct RenameChatDialog { +/// How long past a revealing disclosure's motion the sidebar keeps scrolling +/// a renamed row into view. +const CHAT_RENAME_REVEAL_GRACE: std::time::Duration = std::time::Duration::from_millis(150); + +/// Which list draws a chat's inline title editor: the sidebar's session row, +/// or the side-chat row in the explorer's Chats footer. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ChatRenameSurface { + Sidebar, + Explorer, +} + +/// A chat title edited in place on its row (double-click, or Rename in the +/// chat menu). Enter or blur commits, Escape cancels. +struct ChatRename { chat_id: String, + surface: ChatRenameSurface, input: Entity, - /// Focus the input on the dialog's first paint (opened without window access). + /// Focus the input on its first paint (opened without window access). focus_pending: bool, + /// Until when the sidebar scrolls to keep the row in view — long + /// enough to follow a disclosure that opened to reveal it. + reveal_until: Option, _events: Subscription, + /// Commit-on-blur, armed once the input has taken focus. + _blur: Option, } /// Account lifecycle owned by this process. Sign-in on a local workspace @@ -1440,6 +1511,63 @@ enum ShellEscapeOutcome { Ignored, } +/// When the shell next has render-only clock pixels to repaint, as of `now`: +/// every second while the selected chat is Working (the trailer's elapsed +/// timer and its 7s flavour word), else the next wall-clock minute for +/// relative "5m"/"2h" labels. State transitions (staleness, device presence, +/// send grace) arrive as AppState notifications instead — see +/// [`AppState::watch_clock_transitions`]. Nothing else ticks: AwaitingInput +/// and Errored show no clock, and the connection pill has no countdown (its +/// spinner animates itself). +fn shell_clock_wake(state: &AppState, now: DateTime) -> DateTime { + let working = state + .selected_chat + .as_deref() + .is_some_and(|id| state.indicator_for(id, now) == Indicator::Working); + if working { + return now + chrono::TimeDelta::seconds(1); + } + DateTime::from_timestamp((now.timestamp().div_euclid(60) + 1) * 60, 0) + .unwrap_or(now + chrono::TimeDelta::minutes(1)) +} + +/// A one-shot redraw timer, re-armed on every render for the view's next +/// clock-driven change. Idle views sleep until that deadline instead of +/// polling. +#[derive(Default)] +struct ClockRedraw { + wake_at: Option>, + task: Option>, +} + +impl ClockRedraw { + /// Notify the view at `wake` unless a redraw is already due no later. + /// `slot` finds this timer on the view so the firing task can clear it. + fn arm( + &mut self, + wake: DateTime, + now: DateTime, + slot: fn(&mut V) -> &mut ClockRedraw, + cx: &mut Context, + ) { + if self.task.is_some() && self.wake_at.is_some_and(|at| at <= wake) { + return; + } + let delay = (wake - now).to_std().unwrap_or_default(); + self.wake_at = Some(wake); + self.task = Some(cx.spawn(async move |this, cx| { + cx.background_executor().timer(delay).await; + this.update(cx, |view, cx| { + let clock = slot(view); + clock.wake_at = None; + clock.task = None; + cx.notify(); + }) + .ok(); + })); + } +} + fn resolve_shell_escape( key: &str, blocking_overlay: bool, @@ -1750,6 +1878,24 @@ pub struct Shell { sidebar_pane: Entity, transcript: Entity, composer: Entity, + /// The window's voice orchestrator: independent of the selected chat. + voice: Entity, + /// Sidebar footer orb (session live) and the full-window stage's orb. + voice_footer_orb: Entity, + voice_stage_orb: Entity, + /// Above the composer, only while the orchestrator's own chat is open. + voice_composer_orb: Entity, + /// The stage caption's streaming veil (shared with mobile). + voice_caption: zeron_veil::CaptionVeil, + voice_stage_focus: FocusHandle, + voice_stage_was_open: bool, + /// Last rendered route, used to distinguish navigation from staying in Settings. + voice_stage_route: Route, + voice_stage_changed_at: Option, + /// The chat selected when the stage opened; picking another closes it. + voice_stage_selection: Option, + _voice_observation: gpui::Subscription, + _voice_catalog: gpui::Subscription, /// Measured height of the bottom chrome stack (status strip + composer + /// terminal dock) the full-height transcript scrolls under. Paint-time /// measurement schedules another frame whenever this value changes. @@ -1774,6 +1920,9 @@ pub struct Shell { /// In-flight disclosure tweens, shared by device groups, Pinned and Archived. pub(super) sidebar_disclosure_motion: std::collections::HashMap, + /// Disclosures a reveal (an inline rename of a hidden row) just opened; + /// each starts its motion on the render that knows its height. + pub(super) sidebar_reveal_motions: std::collections::HashSet, /// The jump-hint overlay: true while the held modifiers exactly match a /// jump shortcut, which swaps the first nine rows' time-ago for their /// key-cap chip (t3code's `showJumpHints`). Frame-transient — window @@ -1857,7 +2006,8 @@ pub struct Shell { appearance_settings_sub: Option, /// Session-row context menu, including the Copy submenu. chat_menu: popover::Popup, - rename_dialog: Option, + /// The chat title being edited in place on its row. + chat_rename: Option, /// Chat id awaiting delete confirmation. delete_confirm: Option, /// Global confirmation/error dialog for the Changes-pane trash action. The @@ -1955,6 +2105,9 @@ pub struct Shell { boot: EngineBootConfig, data_dir: PathBuf, settings: UiSettings, + /// The store's settings when `settings` last synced with it. Fields that + /// differ from this are the Shell's edits; all others follow the store. + settings_base: UiSettings, /// Session-scoped panel open flags (terminal / changes per chat; §1.10-1.11 /// parity — heights stay in [`UiSettings`]). panels: SessionPanels, @@ -2055,8 +2208,8 @@ pub struct Shell { /// Clears the jump hints when the window deactivates: a Cmd+Tab away /// swallows the key-up, so without this the chips stay on screen for good. activation_sub: Option, - /// 1s heartbeat re-rendering the working indicator (elapsed + flavour word). - _ticker: Task<()>, + /// Redraw scheduled for the next clock-driven change (see [`shell_clock_wake`]). + clock: ClockRedraw, _state_observation: Subscription, _composer_events: Subscription, /// The primary transcript's spawn-chip events (subagent tabs). @@ -2079,6 +2232,32 @@ impl Shell { let transcript = cx.new(|cx| Transcript::new(state.clone(), cx)); transcript.update(cx, |transcript, _| transcript.retain_for_route_exit()); let composer = cx.new(|cx| Composer::new(state.clone(), cx)); + let voice = cx.new(|_| crate::voice::VoiceController::default()); + let voice_footer_orb = cx.new(|_| { + crate::orb::Orb::new() + .size(crate::orb::OrbSize::Inline) + .state_transition(Duration::from_millis(300)) + .visible(false) + }); + let voice_stage_orb = cx.new(|_| { + crate::orb::Orb::new() + .size(crate::orb::OrbSize::Hero) + .state_transition(Duration::from_millis(300)) + .scale(VOICE_STAGE_ORB_SCALE) + .visible(false) + }); + let voice_composer_orb = cx.new(|_| { + crate::orb::Orb::new() + .size(crate::orb::OrbSize::Avatar) + .state_transition(Duration::from_millis(300)) + .visible(false) + }); + let voice_observation = cx.observe(&voice, |_: &mut Shell, _, cx| cx.notify()); + // Settings → Agents installed or toggled Codex: voice follows. + let voice_catalog = + cx.observe_global::(|this: &mut Shell, cx| { + this.check_voice_codex(true, cx) + }); let links = Self::session_links(None, cx); transcript.update(cx, |transcript, _| { transcript.set_workspace_link_handler(links) @@ -2128,43 +2307,10 @@ impl Shell { }); // Spawn chips open their subagent's transcript as a right-pane tab. let transcript_events = cx.subscribe(&transcript, Self::on_transcript_event); - // Working-indicator heartbeat: notify once a second while a session is - // live so elapsed time and the flavour word stay fresh. - let ticker = cx.spawn(async move |this, cx| { - let mut displayed_minute = Utc::now().timestamp().div_euclid(60); - loop { - cx.background_executor().timer(Duration::from_secs(1)).await; - let minute = Utc::now().timestamp().div_euclid(60); - let minute_changed = minute != displayed_minute; - displayed_minute = minute; - let alive = this.update(cx, |shell: &mut Shell, cx| { - let live = { - let s = shell.state.read(cx); - s.selected_chat - .as_deref() - .is_some_and(|id| s.indicator_for(id, Utc::now()) != Indicator::None) - // The connection pill's retry countdown needs the - // same per-second refresh while degraded. - || matches!( - s.connectivity.state, - zeron_proto::ConnectivityState::Offline - | zeron_proto::ConnectivityState::Reconnecting - ) - }; - // Relative sidebar times still advance when unchanged - // presence heartbeats no longer invalidate the whole UI. - if live || minute_changed { - cx.notify(); - } - }); - if alive.is_err() { - break; - } - } - }); let data_dir = boot.data_dir.clone(); let mut settings = settings::current(cx); state.update(cx, |state, cx| { + state.watch_clock_transitions(cx); state.set_change_requests_visible(settings.sidebar_show_pull_request, cx) }); crate::appshots::set_enabled(settings.appshots_enabled); @@ -2236,6 +2382,18 @@ impl Shell { sidebar_pane, transcript, composer, + voice, + voice_footer_orb, + voice_stage_orb, + voice_composer_orb, + voice_caption: zeron_veil::CaptionVeil::new(voice_stage::STAGE_CAPTION_CHARS), + voice_stage_focus: cx.focus_handle(), + voice_stage_was_open: false, + voice_stage_route: Route::Chat, + voice_stage_changed_at: None, + voice_stage_selection: None, + _voice_observation: voice_observation, + _voice_catalog: voice_catalog, // Seed with the compact composer stack's rough height so the // first frame's clearance isn't zero (the measure corrects it). bottom_stack: std::rc::Rc::new(std::cell::Cell::new(120.0)), @@ -2247,6 +2405,7 @@ impl Shell { sessions_open: true, archived_shown: 0, sidebar_collapsed_groups: std::collections::HashSet::new(), + sidebar_reveal_motions: std::collections::HashSet::new(), sidebar_disclosure_motion: std::collections::HashMap::new(), jump_hints: false, terminal: None, @@ -2302,7 +2461,7 @@ impl Shell { files_settings_sub: None, appearance_settings_sub: None, chat_menu: popover::Popup::default(), - rename_dialog: None, + chat_rename: None, delete_confirm: None, discard_working_tree: None, discard_working_tree_task: None, @@ -2360,6 +2519,7 @@ impl Shell { import_current: None, boot, data_dir, + settings_base: settings::current(cx), settings, panels: SessionPanels::default(), active_chat: String::new(), @@ -2406,7 +2566,7 @@ impl Shell { navigation_focus: navigation_focus::NavigationFocus::new(cx), unfocused: cx.focus_handle(), activation_sub: None, - _ticker: ticker, + clock: ClockRedraw::default(), _state_observation: observation, _composer_events: composer_events, _transcript_events: transcript_events, @@ -2442,7 +2602,7 @@ impl Shell { { // Reuse upstream's project-filter and device defaults for a new // canvas. Automatic capture on an existing canvas keeps its pick. - self.open_new_session(cx); + self.open_new_session(None, cx); } else { self.route = Route::Chat; } @@ -2470,6 +2630,16 @@ impl Shell { // ---- splash ---- fn on_state_changed(&mut self, state: &Entity, cx: &mut Context) { + // Voice is an orchestrator over every thread: navigation keeps it, + // only losing the engine that hosts it ends the session. + if self.voice.read(cx).chat_id.is_some() + && state + .read(cx) + .engine() + .is_none_or(|engine| !self.voice.read(cx).belongs_to(engine)) + { + self.voice.update(cx, |voice, cx| voice.cancel(cx)); + } self.prune_file_explorers(cx); self.refresh_harness_update_watch(cx); if state.read(cx).engine().is_none() { @@ -2548,12 +2718,7 @@ impl Shell { .collect() }; for device_id in &device_ids { - crate::attachments::seed_attachment( - device_id, - &pending_path, - &att.name, - att.image.clone(), - ); + crate::attachments::seed_staged(device_id, &pending_path, &att); } let text = crate::attachments::with_attachments( "Here is the screenshot of the bug.", @@ -2606,7 +2771,7 @@ impl Shell { let send_pending = state.send_pending(&s.chat_id, now); let chat = state.chats.iter().find(|c| c.id == s.chat_id); let title = chat.and_then(|c| c.title.clone()); - let notify = chat.is_some_and(|c| c.parent_chat_id.is_none()); + let notify = chat.is_some_and(|c| c.is_top_level()); (s.chat_id.clone(), status, send_pending, title, notify) }) .collect(); @@ -2760,14 +2925,18 @@ impl Shell { if state.read(cx).selected_chat.is_none() { // A set sidebar filter is an explicit standing choice — the // canvas defaults (project AND its device) follow it, even - // over a remembered "no project" opt-out. Otherwise the last - // selected project stands, unless opted out. + // over a remembered "no project" opt-out. Otherwise the canvas's + // own last pick stands (`last_space_id` also follows opened + // chats, so it is only the fallback), unless opted out. let exists = |id: &String| state.read(cx).space_row(id).is_some(); let filter = self.settings.space_filter.clone().filter(&exists); let target = match filter { Some(filter) => Some(filter), None if !state.read(cx).no_project => { - self.settings.last_space_id.clone().filter(&exists) + crate::settings::composer::ComposerDefaults::load(&self.data_dir) + .project + .filter(&exists) + .or_else(|| self.settings.last_space_id.clone().filter(&exists)) } None => None, }; @@ -3098,10 +3267,43 @@ impl Shell { .collect() } + /// Attach a workspace path only while the surface it came from still + /// shows this chat's workspace; a drag outliving a session switch is dropped. + fn attach_workspace_drag( + &mut self, + payload: &WorkspacePathDrag, + composer: &Entity, + window: &mut Window, + cx: &mut Context, + ) { + let Some(origin) = &payload.origin else { + return; + }; + let current = crate::files::client::FilesRequestContext::for_chat( + self.state.read(cx), + &self.panel_key(cx), + ); + if current.as_ref() != Some(&origin.context) || !matches!(self.route, Route::Chat) { + return; + } + let valid = self + .files + .values() + .chain(self.file_surfaces.values()) + .any(|files| { + files.entity_id() == origin.surface_id && files.read(cx).accepts_origin(origin, cx) + }); + if valid { + composer.update(cx, |composer, cx| { + composer.add_workspace_path(&payload.path, payload.is_directory, window, cx) + }); + } + } + fn workspace_path_for_surface( &self, surface: RightSurface, - _cx: &App, + cx: &App, ) -> Option { let path = match surface { RightSurface::File(id) => self.file_surface_paths.get(&id)?.clone(), @@ -3114,7 +3316,15 @@ impl Shell { return None; } }; - Some(WorkspacePathDrag::new(path, false)) + let RightSurface::File(id) = surface else { + return None; + }; + let origin = self.file_surfaces.get(&id)?.read(cx).interaction_origin(cx); + Some(WorkspacePathDrag::new(path, false).with_origin( + origin, + crate::files::WorkspacePathSource::FileTab, + None, + )) } /// Drag-reorder a surface tab within this chat's strip. @@ -3385,7 +3595,7 @@ impl Shell { LinkAction::External }; } - let outcome = resolved.web_outcome(cfg!(any(target_os = "macos", target_os = "linux"))); + let outcome = resolved.web_outcome(cfg!(any(target_os = "macos", target_os = "linux", windows))); if outcome == LinkOutcome::Internal { self.set_surfaces_open(true, cx); self.add_browser_surface(activation.target.navigation.clone().ok(), window, cx); @@ -3535,6 +3745,35 @@ impl Shell { return; } match event { + FilesEvent::HoldMutation { origin, path } => { + let surfaces = this + .files + .values() + .chain(this.file_surfaces.values()) + .filter(|s| s.read(cx).shares_workspace(origin)) + .cloned() + .collect::>(); + for surface in surfaces { + surface.update(cx, |files, cx| files.hold_mutation(path.clone(), cx)); + } + } + FilesEvent::AddToChat { + path, + is_directory, + origin, + } => { + let payload = WorkspacePathDrag::new(path.clone(), *is_directory) + .with_origin( + Some(origin.clone()), + crate::files::WorkspacePathSource::Tree, + None, + ); + let composer = this.composer.clone(); + this.attach_workspace_drag(&payload, &composer, window, cx); + } + FilesEvent::Mutate(intent) => { + this.start_file_mutation(source.clone(), intent.clone(), cx) + } // Navigation from an editor stays in its own chat. FilesEvent::OpenFile(path) => { let owner = (source.read(cx).chat_id().to_owned(), owner_state.clone()); @@ -3572,6 +3811,7 @@ impl Shell { // surface never emits them. FilesEvent::OpenSubagent { .. } | FilesEvent::OpenChildChat(_) + | FilesEvent::RenameChildChat(_) | FilesEvent::ChildChatContextMenu { .. } | FilesEvent::NewChildChat | FilesEvent::ForkChat => {} @@ -3956,7 +4196,8 @@ impl Shell { ) { let was_active = self.resolved_right_active(cx) == surface; let restore_focus = was_active && self.navigation_focus.in_right(window, cx); - self.navigation_focus.remember(&self.shortcut_focus, window, cx); + self.navigation_focus + .remember(&self.shortcut_focus, window, cx); let key = self.panel_key(cx); let files = match surface { RightSurface::File(id) => self.file_surfaces.get(&id).cloned(), @@ -4383,37 +4624,20 @@ impl Shell { self.settings.theme_selection = crate::appearance::themes(cx); self.settings.accent = crate::appearance::accent(cx); self.settings.surface = crate::appearance::surface(cx); - self.sync_independent_settings(cx); + self.pull_settings(cx); settings::replace(self.settings.clone(), SavePolicy::Debounced, cx); + self.settings_base = settings::current(cx); } - /// Controls outside the Shell mutate these choices directly. A geometry - /// save must never publish the Shell's older values over those selections. - /// The typography globals own the font choices but persist every change - /// immediately, so the central store is an equally canonical read and - /// keeps this block on a single source. - fn sync_independent_settings(&mut self, cx: &App) { + /// Controls outside the Shell (Voice, typography, diff and transcript + /// toggles, …) save to the central store directly. Fold those into the + /// working copy while keeping the Shell's own edits, so a geometry save + /// can never publish an older value over another surface's choice. + fn pull_settings(&mut self, cx: &App) { let current = settings::current(cx); - self.settings.window_geometry = current.window_geometry; - self.settings.new_thread_composer_background = current.new_thread_composer_background; - self.settings.new_thread_background_effect = current.new_thread_background_effect; - self.settings.wallpaper_folder = current.wallpaper_folder; - self.settings.wallpaper_source = current.wallpaper_source; - self.settings.wallpaper_history = current.wallpaper_history; - self.settings.wallpaper_theme_colors = current.wallpaper_theme_colors; - self.settings.wallpaper_color = current.wallpaper_color; - self.settings.open_web_links_in_zeron = current.open_web_links_in_zeron; - self.settings.ui_font_family = current.ui_font_family; - self.settings.ui_font_size = current.ui_font_size; - self.settings.terminal_font_family = current.terminal_font_family; - self.settings.terminal_font_size = current.terminal_font_size; - self.settings.code_font_family = current.code_font_family; - self.settings.code_font_size = current.code_font_size; - self.settings.transcript_width = current.transcript_width; - self.settings.skill_completion_by_harness = current.skill_completion_by_harness; - self.settings.skills_in_slash_menu = current.skills_in_slash_menu; - self.settings.reduce_motion = current.reduce_motion; - self.settings.pause_animations_in_background = current.pause_animations_in_background; + self.settings = + UiSettings::merge_changes(&self.settings_base, &self.settings, current.clone()); + self.settings_base = current; } fn retry_engine(&mut self, cx: &mut Context) { @@ -4821,9 +5045,39 @@ impl Shell { None => Empty.into_any_element(), } } - SettingsSection::Shortcuts - | SettingsSection::General - | SettingsSection::Appshots => { + SettingsSection::Voice => { + let card = crate::dictation::card(cx); + let state = self.state.read(cx); + let mut hosts = vec![(None, "This device".to_owned(), true, "Local".to_owned())]; + for device in &state.devices { + if state.local_device_id.as_deref() == Some(&device.id) { + continue; + } + let online = state.device_online(&device.id, chrono::Utc::now()); + let compatible = device.supports(zeron_proto::voice::remote::CAPABILITY); + let detail = if !online { + "Offline" + } else if !compatible { + "Update Zeron to use remote voice" + } else { + "Available" + }; + hosts.push(( + Some(device.id.clone()), + device.name.clone(), + online && compatible, + detail.into(), + )); + } + let live = self.voice.read(cx).is_live(); + card.update(cx, |card, cx| card.set_hosts(hosts, live, cx)); + if let Some(snapshot) = &self.voice.read(cx).snapshot { + let voices = snapshot.voices.clone(); + card.update(cx, |card, cx| card.set_voices(&voices, cx)); + } + card.into_any_element() + } + SettingsSection::Shortcuts | SettingsSection::General | SettingsSection::Appshots => { if self.shortcuts_page.is_none() { let state = self.state.clone(); let keymap = self.settings.keymap.clone(); @@ -4927,48 +5181,188 @@ impl Shell { })); } + /// Start editing `chat_id`'s title in place on its row: the sidebar's + /// session row, or — for a side chat — its row under the file tree. fn open_rename_chat(&mut self, chat_id: String, cx: &mut Context) { self.close_chat_menu(cx); - let current = self + if self.chat_rename.is_some() { + self.finish_rename_chat(true, cx); + } + let Some((current, parent, archived)) = self .state .read(cx) .chats .iter() .find(|c| c.id == chat_id) - .and_then(|c| c.title.clone()) - .unwrap_or_default(); + .map(|c| { + ( + c.title.clone().unwrap_or_default(), + c.parent_chat_id.clone(), + c.archived, + ) + }) + else { + return; + }; + // The sidebar lists only top-level chats; side chats live in the + // explorer's Chats footer. Either way the row must be on screen + // first: a field on a hidden row would take keystrokes unseen. + let surface = if parent.is_some() { + ChatRenameSurface::Explorer + } else { + ChatRenameSurface::Sidebar + }; + let revealed = match surface { + ChatRenameSurface::Sidebar => self.reveal_sidebar_chat(&chat_id, cx), + // The footer lists the open chat's live side chats; it reveals + // the row itself once handed the rename. + ChatRenameSurface::Explorer => { + !archived + && parent.as_deref() == Some(self.active_chat.as_str()) + && self.files_panel_open(cx) + && self.files.contains_key(&self.panel_key(cx)) + } + }; + if !revealed { + // The sidebar explains its own refusals. + if surface == ChatRenameSurface::Explorer { + self.sidebar_notice = Some("Open this side chat's parent to rename it".into()); + } + cx.notify(); + return; + } let input = cx.new(|cx| { - ComposerInput::new("Session title", cx).with_accessibility_role(gpui::Role::TextInput) + ComposerInput::new("Session title", cx) + .with_single_line() + .with_text_metrics(13.0, 17.0) + .with_accessibility_role(gpui::Role::TextInput) + }); + input.update(cx, |input, cx| { + input.set_text(current, cx); + input.select_all_text(cx); }); - input.update(cx, |input, cx| input.set_text(current, cx)); let events = cx.subscribe(&input, |this: &mut Shell, _, event, cx| { if matches!(event, ComposerInputEvent::Submitted) { - this.submit_rename_chat(cx); + this.finish_rename_chat(true, cx); } }); - self.rename_dialog = Some(RenameChatDialog { + // A pending composer refocus (the first click of the double-click + // opened the chat, or a side chat's tab) must not steal the field + // before it is typed in. + for composer in + std::iter::once(&self.composer).chain(self.side_chats.values().map(|tab| &tab.composer)) + { + composer.update(cx, |composer, _| composer.focus_pending = false); + } + self.chat_rename = Some(ChatRename { chat_id, + surface, input, focus_pending: true, + reveal_until: Some( + std::time::Instant::now() + motion::COLLAPSE.total() + CHAT_RENAME_REVEAL_GRACE, + ), _events: events, + _blur: None, }); + self.sync_explorer_chat_rename(cx); cx.notify(); } - fn submit_rename_chat(&mut self, cx: &mut Context) { - let Some(dialog) = self.rename_dialog.take() else { + /// Focus a just-opened inline rename and arm its commit-on-blur. + fn focus_rename_chat(&mut self, window: &mut Window, cx: &mut Context) { + let Some(rename) = self.chat_rename.as_mut() else { + return; + }; + if !std::mem::take(&mut rename.focus_pending) { + return; + } + let focus = rename.input.focus_handle(cx); + window.focus(&focus, cx); + rename._blur = Some(cx.on_blur(&focus, window, |this, _, cx| { + this.finish_rename_chat(true, cx); + })); + } + + /// Close the inline rename, saving a changed, non-empty title when + /// `commit` (Enter or blur) and dropping it otherwise (Escape). + fn finish_rename_chat(&mut self, commit: bool, cx: &mut Context) { + let Some(rename) = self.chat_rename.take() else { return; }; - let title = dialog.input.read(cx).text().trim().to_string(); - if !title.is_empty() { + self.sync_explorer_chat_rename(cx); + let title = rename.input.read(cx).text().trim().to_string(); + let unchanged = self + .state + .read(cx) + .chats + .iter() + .find(|c| c.id == rename.chat_id) + .is_none_or(|c| c.title.as_deref() == Some(title.as_str())); + if commit && !title.is_empty() && !unchanged { self.mutate( - serde_json::json!({ "op": "renameChat", "chatId": dialog.chat_id, "title": title }), + serde_json::json!({ "op": "renameChat", "chatId": rename.chat_id, "title": title }), cx, ); } cx.notify(); } + /// Hand the explorer footers the side-chat rename to draw (or clear it). + fn sync_explorer_chat_rename(&mut self, cx: &mut Context) { + let rename = self + .chat_rename + .as_ref() + .filter(|rename| rename.surface == ChatRenameSurface::Explorer) + .map(|rename| (rename.chat_id.clone(), rename.input.clone())); + for files in self.files.values() { + files.update(cx, |files, cx| files.set_chat_rename(rename.clone(), cx)); + } + } + + /// Scroll the sidebar so the renamed row (painted at `row`) clears the + /// list's edge fades, while the rename is still revealing it. + fn keep_rename_row_in_view(&mut self, row: gpui::Bounds, cx: &mut Context) { + if !self.chat_rename_revealing() { + return; + } + let viewport = self.sidebar_scroll.bounds(); + let band = px(SIDEBAR_GLASS_FADE_BAND); + let delta = if row.top() < viewport.top() + band { + row.top() - (viewport.top() + band) + } else if row.bottom() > viewport.bottom() - band { + row.bottom() - (viewport.bottom() - band) + } else { + return; + }; + let offset = self.sidebar_scroll.offset(); + let max = self.sidebar_scroll.max_offset().y; + let y = (offset.y - delta).clamp(-max, px(0.0)); + if y != offset.y { + self.sidebar_scroll.set_offset(gpui::point(offset.x, y)); + cx.notify(); + } + } + + fn chat_rename_revealing(&self) -> bool { + self.chat_rename + .as_ref() + .and_then(|rename| rename.reveal_until) + .is_some_and(|until| std::time::Instant::now() < until) + } + + /// The inline title editor for `chat_id`'s row on `surface`, if one is open. + fn rename_input_for( + &self, + chat_id: &str, + surface: ChatRenameSurface, + ) -> Option> { + self.chat_rename + .as_ref() + .filter(|rename| rename.chat_id == chat_id && rename.surface == surface) + .map(|rename| rename.input.clone()) + } + fn archive_chat(&mut self, chat_id: String, cx: &mut Context) { self.set_chat_archived(chat_id, true, cx); } @@ -5219,6 +5613,7 @@ impl Shell { /// the keys it records before they can dispatch. pub(super) fn overlay_owns_keyboard(&self, cx: &App) -> bool { self.command_palette.is_some() + || self.voice.read(cx).stage_open || self.section_dialog.is_some() || self.section_menu.is_some() || self.add_space.is_some() @@ -6136,13 +6531,14 @@ impl Shell { // leave two competing + placements across the responsive variants. let plus_alpha = self.titlebar_plus_alpha(cx); let show_plus = plus_alpha > 0.01; + let over_artwork = settings::current(cx) + .new_thread_composer_background + .as_ref() + .is_some_and(|background| std::path::Path::new(&background.path).is_file()); let island_target = if matches!(self.route, Route::Chat) - && self.state.read(cx).selected_chat.is_none() + && (self.state.read(cx).selected_chat.is_none() || self.voice.read(cx).stage_open) && self.settings.sidebar_collapsed - && settings::current(cx) - .new_thread_composer_background - .as_ref() - .is_some_and(|background| std::path::Path::new(&background.path).is_file()) + && over_artwork { 1.0 } else { @@ -6200,9 +6596,19 @@ impl Shell { .h_full() .w(px(caption_buttons_width(self.linux_left_caption_count()))) })) - .child(window_control_button( + .child(window_control_button_with( "toggle-sidebar", - icons::SIDEBAR_MINIMALISTIC_LEFT, + icons::sidebar_glyph( + motion::state_t( + "toggle-sidebar", + !self.settings.sidebar_collapsed, + motion::GLYPH_STATE, + self.reduced_motion, + ), + false, + 16.0, + theme.text_muted, + ), ShortcutId::ToggleSidebar.label(), &theme, cx.listener(|this, _, _, cx| this.toggle_sidebar(cx)), @@ -6241,7 +6647,7 @@ impl Shell { icons::PLUS, ShortcutId::NewSession.label(), &theme, - cx.listener(|this, _, _, cx| this.open_new_session(cx)), + cx.listener(|this, _, _, cx| this.open_new_session(None, cx)), )) })) .into_any_element() @@ -6741,6 +7147,7 @@ impl Shell { // carries the titlebar clearance inside the scroll. .flex() .flex_col() + .opacity(self.voice_stage_underlay_opacity(cx)) .child(div().flex_1().min_h_0().child(outlet)), ) .child( @@ -6769,6 +7176,7 @@ impl Shell { SettingsSection::Appearance => icons::TUNING, SettingsSection::Files => icons::FOLDER, SettingsSection::Notifications => icons::BELL, + SettingsSection::Voice => icons::MICROPHONE, SettingsSection::Shortcuts => icons::KEYBOARD, SettingsSection::General => icons::SETTINGS, SettingsSection::Appshots => icons::MONITOR, @@ -6901,6 +7309,9 @@ impl Shell { // nine chips appear together instead of leaving a hole on whichever // row is busy or under the pointer. jump_label: Option, + // Whether the row wears its project icon. Rows inside a project + // group leave it to the group header. + project_icon: bool, search_query: Option<&str>, theme: &Theme, cx: &mut Context, @@ -6929,7 +7340,7 @@ impl Shell { .is_some_and(|chat| { self.state.read(cx).local_device_id.as_deref() != Some(chat.device_id.as_str()) }); - let project_icon = (search_query.is_none() && self.settings.sidebar_show_project_icon) + let project_icon = (project_icon && search_query.is_none()) .then(|| self.render_project_icon(&id, SIDEBAR_ACTIVE_HARNESS_ICON_SIZE, selected, cx)); let corner_hovered = !preview && self.chat_status_hover.as_deref() == Some(row_id.as_str()); let archived_muted = archived && search_query.is_none() && !selected && !corner_hovered; @@ -6976,6 +7387,20 @@ impl Shell { let shows_metadata = branch.is_some() || change_request.is_some(); let queued = queued && !undelivered; let working = status == zeron_proto::ChatIndicator::Working && !queued && !undelivered; + // Subagents running under this chat show as a "● N" pill beside the + // row's own activity indicator, never in place of it: the pill takes + // the time stamp's spot (compact) or leads the status (regular), and + // keeps showing after the parent's own turn has settled. + let running_subagents = self.state.read(cx).running_subagents_for(&id, Utc::now()); + let subagent_pill = |suffix: &str| { + (running_subagents > 0).then(|| { + crate::running_pill::running_pill( + format!("{row_id}-subagents-{suffix}"), + running_subagents, + theme, + ) + }) + }; let compact_status = compact.then(|| { let glyph = if working { loaders::mini_glyph_spinner( @@ -7123,20 +7548,33 @@ impl Shell { .flex() .flex_row() .items_center() - .gap(px(4.0)) - .child(glyph) + .gap(px(6.0)) + .children(subagent_pill("label")) .child( div() - .text_size(crate::typography::ui_rems(10.0)) - .font_weight(gpui::FontWeight::MEDIUM) - .text_color(status_color) - .child(SharedString::from(label)), + .flex() + .flex_row() + .items_center() + .gap(px(4.0)) + .child(glyph) + .child( + div() + .text_size(crate::typography::ui_rems(10.0)) + .font_weight(gpui::FontWeight::MEDIUM) + .text_color(status_color) + .child(SharedString::from(label)), + ), ) .into_any_element() } None => div() + .flex() + .flex_row() + .items_center() + .gap(px(6.0)) .text_size(crate::typography::ui_rems(10.0)) .font_weight(gpui::FontWeight::MEDIUM) + .children(subagent_pill("idle")) .child(time_ago.clone()) .into_any_element(), } @@ -7188,6 +7626,12 @@ impl Shell { this.chat_hover_resync = true; this.set_chat_archived(archive_id.clone(), !archived, cx); })) + // Above the pill: below it the chip would cover the next row. + .tooltip(crate::settings::widgets::text_tooltip_above(if archived { + "Unarchive session" + } else { + ShortcutId::ArchiveSession.label() + })) }) .child(corner_body) .into_any_element() @@ -7202,6 +7646,10 @@ impl Shell { }; let select_id = id.clone(); let menu_id = id.clone(); + let search_query_none = search_query.is_none(); + let rename_input = (search_query_none && !preview) + .then(|| self.rename_input_for(&id, ChatRenameSurface::Sidebar)) + .flatten(); // Hover fades over transition-colors (zeron session-row.tsx) — both // the wash and the title brighten ride the same 150ms blend. let fade_key = format!("{row_id}-hover"); @@ -7271,8 +7719,14 @@ impl Shell { }) }) .cursor_pointer() - .on_click(cx.listener(move |this, _, _, cx| { - this.open_chat(select_id.clone(), cx); + .on_click(cx.listener(move |this, event: &gpui::ClickEvent, _, cx| { + // The palette's copy of the row only opens; the sidebar's + // double-click edits the title in place. + if event.click_count() >= 2 && search_query_none { + this.open_rename_chat(select_id.clone(), cx); + } else { + this.open_chat(select_id.clone(), cx); + } })) .on_mouse_down( MouseButton::Right, @@ -7297,6 +7751,31 @@ impl Shell { cx.new(|_| DragGhost) }) }) + // A row revealed for an inline rename reports where it painted, + // frame by frame while its disclosure opens, so the list can + // scroll it clear of the edges. + .when( + rename_input.is_some() && self.chat_rename_revealing(), + |el| { + let shell = cx.weak_entity(); + el.child( + gpui::canvas( + |_, _, _| {}, + move |bounds, _, window, cx| { + window.defer(cx, move |_, cx| { + shell + .update(cx, |this, cx| { + this.keep_rename_row_in_view(bounds, cx) + }) + .ok(); + }); + }, + ) + .absolute() + .inset_0(), + ) + }, + ) .when(self.chat_hover_resync && !preview, |el| { let shell = cx.weak_entity(); let row_id = row_id.clone(); @@ -7378,14 +7857,22 @@ impl Shell { }, ) .children(project_icon) - .child(sidebar_faded_label( - format!("chat-title-{content_id}").into(), - true, - div() - .text_size(crate::typography::ui_rems(13.0)) - .line_height(px(17.0)) - .child(popover::search_highlight(title, search_query, theme)), - )) + .child(match rename_input { + Some(input) => chat_title_editor( + format!("chat-title-editor-{content_id}").into(), + input, + theme, + ), + None => sidebar_faded_label( + format!("chat-title-{content_id}").into(), + true, + div() + .text_size(crate::typography::ui_rems(13.0)) + .line_height(px(17.0)) + .child(popover::search_highlight(title, search_query, theme)), + ) + .into_any_element(), + }) .when(!compact && !show_label && remote, |el| { el.child( icon(icons::REMOTE_SERVER) @@ -7394,22 +7881,38 @@ impl Shell { .text_color(subline), ) }) - .when( - if compact { - remote || corner_hovered - } else { - !show_label - }, - |el| { - el.child( - div() - .flex_none() - .text_color(subline) - .children(corner.take()), - ) - }, - ) + .when(!compact && !show_label, |el| { + el.child( + div() + .flex_none() + .text_color(subline) + .children(corner.take()), + ) + }) + // Compact trailing cluster, right-packed: the PR badge sits + // LEFT of one fixed slot that shows the relative time, swaps + // to the archive affordance while the row is hovered, and + // shows the Cmd+N jump legend while the modifier is held. + // The legend wins over the hover archive (user request). + // There is no remote globe — the slot's fixed width keeps + // the column aligned across rows regardless of a PR badge. .when(compact, |el| { + // Cmd-held legend beats the hover archive; preview rows + // (the palette) never archive. + let show_legend = compact_jump_label.is_some(); + let show_archive = corner_hovered && !show_legend && !preview; + // The slot is 30px (holds "17m"); a text-length jump + // hint widens it to a floor so "Ctrl+1" vs "Ctrl+2" + // never nudges the column. + let text_hint = compact_jump_label + .as_ref() + .is_some_and(|label| label.chars().count() > 3); + // Running subagents take the time stamp's place; the + // legend and the hover archive still win. + let pill = (!show_legend && !show_archive) + .then(|| subagent_pill("compact")) + .flatten(); + let archive_id = id.clone(); el.children(change_request.clone().map(|summary| { if preview { crate::change_requests::pull_request_badge_preview( @@ -7427,22 +7930,9 @@ impl Shell { ) } })) - }) - .when(compact, |el| { - // The time slot is 30px, which holds "17m" but not - // "Ctrl+2": unwrapped, the hint broke after the `+` - // and stacked two lines. A text-length hint keeps one - // line in a wider slot — a floor, not content sized, - // so "Ctrl+1" (a narrower glyph) doesn't nudge its - // row's badge off the others'. The floor scales with - // the UI font like the text does, and a longer - // rebound combo grows the slot instead of spilling - // over the title. - let text_hint = compact_jump_label - .as_ref() - .is_some_and(|label| label.chars().count() > 3); - el.child( + .child( div() + .id(SharedString::from(format!("{row_id}-corner"))) .debug_selector({ let id = id.clone(); move || format!("chat-time-{id}") @@ -7450,13 +7940,63 @@ impl Shell { .when(text_hint, |el| { el.min_w(crate::typography::ui_rems(COMPACT_JUMP_HINT_WIDTH)) }) - .when(!text_hint, |el| el.w(px(30.0))) + // A running-subagents pill can be wider than the plain time. + .when(!text_hint, |el| el.min_w(px(30.0))) + // Fixed height so swapping the time text for the + // archive glyph never resizes the slot. + .h(px(14.0)) .flex_none() - .whitespace_nowrap() - .text_right() - .text_size(crate::typography::ui_rems(11.0)) - .text_color(subline) - .child(compact_jump_label.unwrap_or(time_ago)), + .flex() + .items_center() + .justify_end() + .aria_label(if show_archive { + if archived { "Unarchive" } else { "Archive" } + } else { + "Session time" + }) + .when(show_archive, |el| { + el.cursor_pointer() + .on_mouse_down(MouseButton::Left, |_, _, cx| { + cx.stop_propagation() + }) + .on_click(cx.listener(move |this, _, _, cx| { + cx.stop_propagation(); + this.chat_hover_resync = true; + this.set_chat_archived( + archive_id.clone(), + !archived, + cx, + ); + })) + .tooltip(crate::settings::widgets::text_tooltip_above( + if archived { + "Unarchive session" + } else { + ShortcutId::ArchiveSession.label() + }, + )) + }) + .child(if show_archive { + icon(if archived { + icons::ARCHIVE_UP_MINIMALISTIC + } else { + icons::ARCHIVE_MINIMALISTIC + }) + .size(px(SIDEBAR_ACTIVE_HARNESS_ICON_SIZE)) + .flex_none() + .text_color(theme.text_muted) + .into_any_element() + } else if let Some(pill) = pill { + pill + } else { + div() + .whitespace_nowrap() + .text_right() + .text_size(crate::typography::ui_rems(11.0)) + .text_color(subline) + .child(compact_jump_label.unwrap_or(time_ago)) + .into_any_element() + }), ) }), ) @@ -7618,13 +8158,19 @@ impl Shell { use zeron_proto::ConnectivityState as S; let conn = self.state.read(cx).connectivity.clone(); let selected = self.state.read(cx).selected_chat.as_deref(); - let chat = conn.chats.iter() + let chat = conn + .chats + .iter() .find(|c| Some(c.chat_id.as_str()) == selected); let chat_state = chat.map(|c| c.sync_state); let (label, glyph): (SharedString, AnyElement) = match conn.state { _ if chat_state == Some(zeron_proto::ChatSyncState::StorageError) => ( "Changes could not be saved".into(), - div().size(px(5.0)).rounded_full().bg(theme.warning).into_any_element(), + div() + .size(px(5.0)) + .rounded_full() + .bg(theme.warning) + .into_any_element(), ), S::Disabled => return None, S::Connected => { @@ -7632,9 +8178,13 @@ impl Shell { ( caption.into(), loaders::mini_mono_spinner( - "chat-sync-spinner", 2.0, theme.text_muted, - self.sidebar_pane.entity_id(), cx, - ).into_any_element(), + "chat-sync-spinner", + 2.0, + theme.text_muted, + self.sidebar_pane.entity_id(), + cx, + ) + .into_any_element(), ) } S::Offline => ( @@ -7887,7 +8437,6 @@ impl Shell { // t3code's archived accordion, below the active list. let archived_section = self.render_archived_section(theme, cx); - // The space filter lives ABOVE the scroll region (fixed) so its // dropdown can float without being clipped by the list's overflow. let filter_row = self.render_spaces_filter(theme, cx); @@ -8662,6 +9211,7 @@ impl Shell { (true, true) => "Close settings · ⌘,".into(), (true, false) => "Close settings · Ctrl+,".into(), }; + let voice_trigger = self.render_voice_trigger(theme, cx); div() .w_full() .flex() @@ -8671,55 +9221,61 @@ impl Shell { .child(trigger) .child( div() - .id("settings-trigger") - .debug_selector(|| "settings-trigger".into()) - .role(gpui::Role::Button) - .aria_label(if settings_open { - "Close settings" - } else { - "Settings" - }) - .aria_toggled(if settings_open { - gpui::Toggled::True - } else { - gpui::Toggled::False - }) - .tooltip(move |_, cx| { - let text = settings_tooltip.clone(); - cx.new(|_| SurfaceTabTooltip { text }).into() - }) - .tab_index(0) - .size(px(SIDEBAR_FOOTER_BUTTON_SIZE)) - .flex_none() - .rounded(px(8.0)) .flex() + .flex_none() .items_center() - .justify_center() - .cursor_pointer() - .bg(if settings_open { - theme.glass_hover() - } else { - motion::hover_blend( - "settings-trigger", - theme.glass_hover().opacity(0.0), - theme.glass_hover(), - ) - }) - .on_hover(motion::hover_listener("settings-trigger")) - .focus_visible(|s| s.border_2().border_color(theme.accent)) - .on_click(cx.listener(|this, _, _, cx| this.toggle_settings(cx))) + .gap(px(2.0)) + .children(voice_trigger) .child( - icon(icons::SETTINGS) - .size(px(15.0)) - .text_color(if settings_open { - theme.text + div() + .id("settings-trigger") + .debug_selector(|| "settings-trigger".into()) + .role(gpui::Role::Button) + .aria_label(if settings_open { + "Close settings" + } else { + "Settings" + }) + .aria_toggled(if settings_open { + gpui::Toggled::True + } else { + gpui::Toggled::False + }) + .tooltip(move |_, cx| { + let text = settings_tooltip.clone(); + cx.new(|_| SurfaceTabTooltip { text }).into() + }) + .tab_index(0) + .size(px(SIDEBAR_FOOTER_BUTTON_SIZE)) + .flex_none() + .rounded(px(8.0)) + .flex() + .items_center() + .justify_center() + .cursor_pointer() + .bg(if settings_open { + theme.glass_hover() } else { motion::hover_blend( "settings-trigger", - theme.text_muted, - theme.text, + theme.glass_hover().opacity(0.0), + theme.glass_hover(), ) - }), + }) + .on_hover(motion::hover_listener("settings-trigger")) + .focus_visible(|s| s.border_2().border_color(theme.accent)) + .on_click(cx.listener(|this, _, _, cx| this.toggle_settings(cx))) + .child(icon(icons::SETTINGS).size(px(15.0)).text_color( + if settings_open { + theme.text + } else { + motion::hover_blend( + "settings-trigger", + theme.text_muted, + theme.text, + ) + }, + )), ), ) .into_any_element() @@ -9132,9 +9688,8 @@ impl Shell { self.set_harness_updates_expanded(false, cx); return true; } - if self.rename_dialog.is_some() { - self.rename_dialog = None; - cx.notify(); + if self.chat_rename.is_some() { + self.finish_rename_chat(false, cx); return true; } if self.rename_space_dialog.is_some() { @@ -9188,6 +9743,11 @@ impl Shell { window: &mut Window, cx: &mut Context, ) { + if event.keystroke.key == "escape" && self.voice.read(cx).stage_open { + self.set_voice_stage_open(false, cx); + cx.stop_propagation(); + return; + } if event.keystroke.key == "escape" && self.sidebar_session_transfer.is_some() { cx.stop_active_drag(window); self.cancel_sidebar_session_transfer(cx); @@ -9308,11 +9868,17 @@ impl Shell { .child( popover::menu_row(&theme, false, format!("chat-menu-rename-{chat_id}")) .id("chat-menu-rename") - .on_click(cx.listener(move |this, _, _, cx| { + .on_click(cx.listener(move |this, _, window, cx| { + // A side chat's title is edited on its row + // under the file tree: dock the explorer + // when the menu came from its tab. + if is_side_chat && !this.files_panel_open(cx) { + this.add_files_surface(window, cx); + } this.open_rename_chat(rename_id.clone(), cx) })) .child(icon(icons::PEN).size(px(16.0)).text_color(theme.text_muted)) - .child(SharedString::from("Rename…")), + .child(SharedString::from("Rename")), ) .when(!is_side_chat, |menu| { menu.child( @@ -9525,52 +10091,6 @@ impl Shell { )); } - if let Some(dialog) = &mut self.rename_dialog { - if std::mem::take(&mut dialog.focus_pending) { - window.focus(&dialog.input.focus_handle(cx), cx); - } - let input = dialog.input.clone(); - let card = popover::dialog_card(&theme) - .on_key_down(cx.listener(|this, ev: &gpui::KeyDownEvent, _, cx| { - if ev.keystroke.key == "escape" { - this.rename_dialog = None; - cx.notify(); - cx.stop_propagation(); - } - })) - .child(popover::dialog_title(&theme, "Rename session")) - .child( - div() - .mt(px(12.0)) - .child(popover::dialog_field(input.into_any_element())), - ) - .child( - div() - .mt(px(16.0)) - .flex() - .flex_row() - .justify_end() - .gap(px(8.0)) - .child( - popover::btn_ghost(&theme, "Cancel", "rename-chat-cancel") - .id("rename-chat-cancel") - .on_click(cx.listener(|this, _, _, cx| { - this.rename_dialog = None; - cx.notify(); - })), - ) - .child( - popover::btn_primary(&theme, "Rename") - .id("rename-chat-save") - .on_click( - cx.listener(|this, _, _, cx| this.submit_rename_chat(cx)), - ), - ), - ) - .into_any_element(); - overlays.push(popover::modal("rename-chat-dialog", viewport, card)); - } - overlays.extend(self.render_space_overlays(viewport, window, cx)); overlays.extend(self.render_section_overlays(viewport, window, cx)); if let Some(overlay) = self.render_command_palette(viewport, window, cx) { @@ -9821,6 +10341,10 @@ impl Shell { let ui_settings = settings::current(cx); let new_thread_background_setting = ui_settings.new_thread_composer_background; let new_thread_background_effect = ui_settings.new_thread_background_effect; + let new_thread_background_adjustment = new_thread_background_setting + .as_ref() + .map(|background| background.adjustment) + .unwrap_or_default(); let frame_time = self.render_time.unwrap_or_else(std::time::Instant::now); // Prewarm even in an established thread. Decode/effect work is not // contingent on a hero measurement or a navigation gesture. @@ -9836,6 +10360,10 @@ impl Shell { }); let artwork_frame = self.new_thread_artwork_ready.frame( artwork, + new_thread_background_setting + .as_ref() + .map(|background| std::path::Path::new(&background.path)), + new_thread_background_adjustment, new_thread_background_setting.is_some(), self.reduced_motion, frame_time, @@ -9883,6 +10411,7 @@ impl Shell { .inset_0() .child(new_thread_background( artwork_frame.previous, + artwork_frame.previous_adjustment, self.viewport_height, width, bounds.clone(), @@ -9891,6 +10420,7 @@ impl Shell { )) .child(new_thread_background( artwork_frame.current, + artwork_frame.current_adjustment, self.viewport_height, width, bounds, @@ -10001,6 +10531,7 @@ impl Shell { }; let status = self.render_status_strip(composer_width, cx); self.chat_dropzone("chat-dropzone", self.composer.clone(), cx) + .debug_selector(|| "chat-dropzone".into()) .track_focus(&self.navigation_focus.main) .capture_any_mouse_down(cx.listener(|this, _, window, cx| { this.capture_navigation_focus(false, false, window, cx); @@ -10128,6 +10659,7 @@ impl Shell { .absolute() .inset_0(), ) + .children(self.render_voice_composer_orb(composer_width, cx)) .child(status) .when(has_spaces || no_project || has_appshots, |el| { let composer_opacity = self.composer_dock.borrow().opacity(); @@ -10949,8 +11481,7 @@ impl Shell { .h(px(24.0)) .w(px(CHIP_W)) .flex_none() - .pl(px(4.0)) - .pr(px(8.0)) + .px(px(4.0)) .rounded(px(6.0)) .flex() .flex_row() @@ -11035,25 +11566,77 @@ impl Shell { // up the carve-out. The bubble dispatch reaches the chip // before the strip, and the handler consumes the drag, so // the two never double-apply. - .on_drop::(cx.listener( - move |this, payload: &RightTabDrag, _, cx| { - if payload.panel_key != this.panel_key(cx) { - this.right_tab_drag = None; - cx.notify(); - return; - } - let to = this - .right_tab_drag - .as_ref() - .map(|d| d.over) - .unwrap_or(payload.from); + .on_drop::(cx.listener(move |this, payload: &RightTabDrag, _, cx| { + if payload.panel_key != this.panel_key(cx) { this.right_tab_drag = None; - this.reorder_right_tabs(payload.from, to, cx); - }, - )) + cx.notify(); + return; + } + let to = this + .right_tab_drag + .as_ref() + .map(|d| d.over) + .unwrap_or(payload.from); + this.right_tab_drag = None; + this.reorder_right_tabs(payload.from, to, cx); + })) + .child( + // Leading slot: the surface's icon. + div() + .flex_none() + .size(px(18.0)) + .flex() + .items_center() + .justify_center() + .child(if subagent_running { + loaders::mini_glyph_spinner( + format!("subagent-tab-{ix}"), + 2.0, + theme.glyph, + cx.entity_id(), + cx, + ) + .into_any_element() + } else if let Some(favicon) = browser_favicon { + gpui::img(favicon).size(px(12.0)).into_any_element() + } else if matches!(surface, RightSurface::File(_)) { + crate::file_icons::icon( + crate::file_icons::FileIconIdentity::file( + file_identity_path.as_ref(), + ), + theme.appearance, + ) + .size(px(14.0)) + .when(!is_active, |icon| icon.opacity(0.78)) + .into_any_element() + } else { + icon(icon_path) + .size(px(12.0)) + .text_color(if is_active { + theme.text_muted + } else { + theme.text_muted.opacity(0.7) + }) + .into_any_element() + }), + ) + .child( + div() + .flex_1() + .min_w_0() + .truncate() + .text_size(crate::typography::ui_rems(11.5)) + .text_color(if is_active { + theme.text + } else { + theme.text_muted + }) + .child(title), + ) .child( - // Leading slot: icon normally, ✕ on tab hover — two - // stacked layers opacity-swapped by the group hover. + // Trailing slot: the unsaved dot normally, ✕ on tab + // hover — two stacked layers opacity-swapped by the + // group hover. div() .id(("right-surface-close", ix)) .debug_selector(|| format!("right-surface-close-{ix}")) @@ -11061,6 +11644,9 @@ impl Shell { .size(px(18.0)) .rounded(px(4.0)) .relative() + .role(gpui::Role::Button) + .aria_label("Close tab") + .tooltip(crate::settings::widgets::text_tooltip("Close tab")) .hover(|s| s.bg(crate::theme::wash(0.12))) // The tab owns a drag payload. Claim the close press // before it reaches that parent or GPUI starts a tab @@ -11073,46 +11659,18 @@ impl Shell { cx.stop_propagation(); this.close_right_surface(surface, window, cx); })) - .child( - div() - .absolute() - .inset_0() - .flex() - .items_center() - .justify_center() - .group_hover(group.clone(), |s| s.opacity(0.0)) - .child(if subagent_running { - loaders::mini_glyph_spinner( - format!("subagent-tab-{ix}"), - 2.0, - theme.glyph, - cx.entity_id(), - cx, - ) - .into_any_element() - } else if let Some(favicon) = browser_favicon { - gpui::img(favicon).size(px(12.0)).into_any_element() - } else if matches!(surface, RightSurface::File(_)) { - crate::file_icons::icon( - crate::file_icons::FileIconIdentity::file( - file_identity_path.as_ref(), - ), - theme.appearance, - ) - .size(px(14.0)) - .when(!is_active, |icon| icon.opacity(0.78)) - .into_any_element() - } else { - icon(icon_path) - .size(px(12.0)) - .text_color(if is_active { - theme.text_muted - } else { - theme.text_muted.opacity(0.7) - }) - .into_any_element() - }), - ) + .when(dirty, |slot| { + slot.child( + div() + .absolute() + .inset_0() + .flex() + .items_center() + .justify_center() + .group_hover(group.clone(), |s| s.opacity(0.0)) + .child(div().size(px(6.0)).rounded_full().bg(theme.text_muted)), + ) + }) .child( div() .absolute() @@ -11128,28 +11686,7 @@ impl Shell { .text_color(theme.text_muted), ), ), - ) - .child( - div() - .min_w_0() - .truncate() - .text_size(crate::typography::ui_rems(11.5)) - .text_color(if is_active { - theme.text - } else { - theme.text_muted - }) - .child(title), - ) - .when(dirty, |chip| { - chip.child( - div() - .flex_none() - .size(px(6.0)) - .rounded_full() - .bg(theme.text_muted), - ) - }); + ); // Sliding transform while a sibling drags over (the terminal // drawer's exact recipe): animate 150ms between committed // offsets; the dragged tab leaves an invisible spacer — the @@ -11840,7 +12377,19 @@ fn window_control_button( theme: &Theme, on_click: impl Fn(&gpui::ClickEvent, &mut Window, &mut App) + 'static, ) -> impl IntoElement { - let muted = theme.text_muted; + let glyph = icon(icon_path).size(px(16.0)).text_color(theme.text_muted); + window_control_button_with(id, glyph, label, theme, on_click) +} + +/// [`window_control_button`] around a caller-drawn glyph (the morphing +/// sidebar glyph). +fn window_control_button_with( + id: &'static str, + glyph: impl IntoElement, + label: &'static str, + theme: &Theme, + on_click: impl Fn(&gpui::ClickEvent, &mut Window, &mut App) + 'static, +) -> impl IntoElement { let fade_key = format!("window-control-{id}"); div() .id(id) @@ -11877,7 +12426,7 @@ fn window_control_button( on_click(event, window, cx) }) .tooltip(crate::settings::widgets::text_tooltip(label)) - .child(icon(icon_path).size(px(16.0)).text_color(muted)) + .child(glyph) } const WINDOWS_CAPTION_BUTTON_WIDTH: f32 = 36.0; @@ -12026,7 +12575,18 @@ fn header_icon_button( theme: &Theme, on_click: impl Fn(&gpui::ClickEvent, &mut Window, &mut App) + 'static, ) -> gpui::Stateful { - let muted = theme.text_muted; + let glyph = icon(icon_path).size(px(16.0)).text_color(theme.text_muted); + header_icon_button_with(id, glyph, label, on_click) +} + +/// [`header_icon_button`] around a caller-drawn glyph (the morphing sidebar +/// glyph). +fn header_icon_button_with( + id: &'static str, + glyph: impl IntoElement, + label: &'static str, + on_click: impl Fn(&gpui::ClickEvent, &mut Window, &mut App) + 'static, +) -> gpui::Stateful { let fade_key = format!("header-icon-{id}"); div() .id(id) @@ -12054,12 +12614,32 @@ fn header_icon_button( on_click(event, window, cx) }) .tooltip(crate::settings::widgets::text_tooltip(label)) - .child(icon(icon_path).size(px(16.0)).text_color(muted)) + .child(glyph) } impl Render for Shell { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + // Every repaint (state frame, input, or the clock firing) reschedules + // the next clock-driven one; the sidebar and transcript follow this + // view's notifications. + let now = Utc::now(); + let wake = shell_clock_wake(self.state.read(cx), now); + self.clock + .arm(wake, now, |shell: &mut Shell| &mut shell.clock, cx); + let active_files_key = self.panel_key(cx); + let hidden_explorers = self + .files + .iter() + .filter(|(key, _)| { + key.as_str() != active_files_key || !matches!(self.route, Route::Chat) + }) + .map(|(_, files)| files.clone()) + .collect::>(); + for files in hidden_explorers { + files.update(cx, |files, cx| files.suspend_tree_interactions(cx)); + } settings::wallpaper::preload(cx); + self.sync_voice_stage(window, cx); self.navigation_focus .remember(&self.shortcut_focus, window, cx); if let Some(command) = self.pending_workspace_command.take() { @@ -12068,7 +12648,7 @@ impl Render for Shell { WorkspaceCommand::Model => self .composer .update(cx, |c, cx| c.open_model_menu(window, cx)), - WorkspaceCommand::New => self.open_new_session(cx), + WorkspaceCommand::New => self.open_new_session(None, cx), WorkspaceCommand::Resume => self.toggle_command_palette(window, cx), WorkspaceCommand::Settings => self.open_last_settings(cx), WorkspaceCommand::Diff if !self.active_chat.is_empty() => { @@ -12089,6 +12669,7 @@ impl Render for Shell { _ => {} } } + self.focus_rename_chat(window, cx); self.render_time = Some(std::time::Instant::now()); if self.all_file_edits_flushed(cx) @@ -12131,7 +12712,7 @@ impl Render for Shell { self.settings.theme_selection = crate::appearance::themes(cx); self.settings.accent = crate::appearance::accent(cx); self.settings.surface = crate::appearance::surface(cx); - self.sync_independent_settings(cx); + self.pull_settings(cx); let theme = Theme::of(cx); // The shell frost sits over native desktop blur on macOS and Windows. // Content surfaces add their own backgrounds over this shared tint. @@ -12183,7 +12764,7 @@ impl Render for Shell { } else { px(0.0) }; - #[cfg(target_os = "macos")] + #[cfg(any(target_os = "macos", windows))] let browser_overlay_width = px(if self.files_visible_width(cx) > 0.0 { self.files_visible_width(cx) + PANE_RESIZE_HITBOX_HALF_WIDTH } else { @@ -12197,10 +12778,9 @@ impl Render for Shell { ); browser.update(cx, |browser, cx| { #[cfg(target_os = "macos")] - { - browser.set_resize_inset(browser_resize_inset, cx); - browser.set_right_occlusion(browser_overlay_width, cx); - } + browser.set_resize_inset(browser_resize_inset, cx); + #[cfg(any(target_os = "macos", windows))] + browser.set_right_occlusion(browser_overlay_width, cx); browser.set_shortcuts(&self.settings.keymap); browser.set_presentation(presentation, cx); }); @@ -12243,6 +12823,9 @@ impl Render for Shell { // at once when a check is due by the wall clock. update.read(cx).poke(); } + if window.is_window_active() && !this.voice.read(cx).offered() { + this.check_voice_codex(true, cx); + } if !window.is_window_active() { this.reset_command_palette_key_state(); this.set_jump_hints(false, cx); @@ -12366,7 +12949,7 @@ impl Render for Shell { })) // New session works from anywhere — `open_new_session` routes back // to chat itself, so Settings is not a dead spot. - .on_action(cx.listener(|this, _: &NewSession, _, cx| this.open_new_session(cx))) + .on_action(cx.listener(|this, _: &NewSession, _, cx| this.open_new_session(None, cx))) // Native Settings menu item and the platform convention (Cmd+, on // macOS, Ctrl+, elsewhere) toggle the modal from any section. .on_action(cx.listener(|this, _: &OpenSettings, _, cx| this.toggle_settings(cx))) @@ -12495,6 +13078,8 @@ impl Render for Shell { // for the return trip. if let Route::Settings(section) = self.route { let settings_page = self.render_settings_page(section, window, cx); + // A call started from Settings opens its stage here too. + let voice_stage = self.render_voice_stage(window, cx); let overlays = self.render_overlays(window.viewport_size(), window, cx); let border_color = Theme::of(cx).border; let sidebar_tone = @@ -12513,6 +13098,7 @@ impl Render for Shell { .size_full() .relative() .child(settings_page) + .children(voice_stage) .child(drag) .children(overlays); break 'ready root @@ -12613,6 +13199,9 @@ impl Render for Shell { }; let files_panel = self.render_files_panel(window, cx); let overlays = self.render_overlays(window.viewport_size(), window, cx); + // Full-window voice stage: above the page chrome, below dialogs. + let voice_stage = self.render_voice_stage(window, cx); + let under_stage = self.voice_stage_underlay_opacity(cx); // Copied out (not held) — `render_title_bar` needs `cx` mutable. let border_color = Theme::of(cx).border; // No inset cards (user request): the conversation column sits @@ -12636,6 +13225,7 @@ impl Render for Shell { .flex() .flex_row() .overflow_hidden() + .opacity(under_stage) .child(main) .into_any_element(); // The whole app page is one keyed `animate-in` entrance (zeron @@ -12701,6 +13291,7 @@ impl Render for Shell { .h_full() .flex_none() .relative() + .opacity(under_stage) .child( div() .h_full() @@ -12712,7 +13303,18 @@ impl Render for Shell { .child(right_seam), ), ) - .child(div().absolute().top_0().left_0().right_0().child(title_bar)) + .child( + div() + .absolute() + .top_0() + .left_0() + .right_0() + .opacity(under_stage) + .child(title_bar), + ) + // The stage covers the conversation's titlebar but never + // the cluster: the sidebar toggle and navigation stay live. + .children(voice_stage) .child(self.render_titlebar_cluster(cx)) .children(overlays); root.child(sidebar_tone) @@ -12739,7 +13341,8 @@ impl Render for Shell { // scheduling `with_animation` would have requested). Hover color fades // ride the same clock; their once-per-frame tick lives here (this is // the window's root render — it runs exactly once per frame). - if self.motion_active.get() | motion::hover_fades_active() { + if self.motion_active.get() | motion::hover_fades_active() | motion::state_morphs_active() + { window.request_animation_frame(); } @@ -12896,17 +13499,26 @@ mod tests { chat.sync_state = S::Waiting; chat.connected = false; - assert_eq!(chat_sync_pill_caption(&chat), Some("Sync queued — changes are saved")); + assert_eq!( + chat_sync_pill_caption(&chat), + Some("Sync queued — changes are saved") + ); chat.sync_state = S::Connecting; assert_eq!(chat_sync_pill_caption(&chat), Some("Syncing…")); chat.sync_state = S::Offline; - assert_eq!(chat_sync_pill_caption(&chat), Some("Offline — changes are saved")); + assert_eq!( + chat_sync_pill_caption(&chat), + Some("Offline — changes are saved") + ); // Real pending pushes remain visible even with a live room. chat.connected = true; chat.pending_pushes = 1; chat.sync_state = S::Waiting; - assert_eq!(chat_sync_pill_caption(&chat), Some("Sync queued — changes are saved")); + assert_eq!( + chat_sync_pill_caption(&chat), + Some("Sync queued — changes are saved") + ); chat.sync_state = S::Connecting; assert_eq!(chat_sync_pill_caption(&chat), Some("Syncing…")); } @@ -13102,6 +13714,138 @@ mod tests { ); } + /// A minute boundary. + fn clock_epoch() -> DateTime { + DateTime::parse_from_rfc3339("2026-07-19T12:00:00Z") + .unwrap() + .to_utc() + } + + fn clock_session( + chat_id: &str, + status: zeron_proto::SessionStatus, + updated_at: DateTime, + ) -> zeron_proto::Session { + zeron_proto::Session { + last_completed_turn: None, + chat_id: chat_id.into(), + device_id: "remote".into(), + status, + started_at: Some(updated_at), + updated_at, + running_subagents: 0, + } + } + + struct ClockProbe { + clock: ClockRedraw, + } + + fn clock_probe( + cx: &mut gpui::TestAppContext, + ) -> ( + gpui::Entity, + std::rc::Rc>, + gpui::Subscription, + ) { + use gpui::AppContext as _; + let probe = cx.new(|_| ClockProbe { + clock: ClockRedraw::default(), + }); + let redraws = std::rc::Rc::new(std::cell::Cell::new(0)); + let counter = redraws.clone(); + let subscription = + cx.update(|cx| cx.observe(&probe, move |_, _| counter.set(counter.get() + 1))); + (probe, redraws, subscription) + } + + fn arm_probe( + cx: &mut gpui::TestAppContext, + probe: &gpui::Entity, + wake: DateTime, + now: DateTime, + ) { + probe.update(cx, |probe, cx| { + probe + .clock + .arm(wake, now, |probe: &mut ClockProbe| &mut probe.clock, cx) + }); + } + + /// Schedules the shell clock for `state` at `now` and proves the redraw + /// lands exactly at `expected` on the simulated clock — not a millisecond + /// earlier, and without any intermediate polling. + fn assert_redraw_at( + cx: &mut gpui::TestAppContext, + state: &AppState, + now: DateTime, + expected: DateTime, + ) { + let wake = shell_clock_wake(state, now); + assert_eq!(wake, expected); + let (probe, redraws, _subscription) = clock_probe(cx); + arm_probe(cx, &probe, wake, now); + let delay = (expected - now).to_std().unwrap(); + cx.executor() + .advance_clock(delay - Duration::from_millis(1)); + assert_eq!(redraws.get(), 0, "no redraw before the deadline"); + cx.executor().advance_clock(Duration::from_millis(1)); + assert_eq!(redraws.get(), 1, "one redraw at the deadline"); + } + + #[gpui::test] + fn idle_shell_sleeps_until_the_minute(cx: &mut gpui::TestAppContext) { + let now = clock_epoch() + chrono::TimeDelta::seconds(20); + let mut state = AppState::new(); + // A failed run, a pending question and a reconnecting pill show no + // clock: only the relative-time minute remains. + state.sessions = vec![ + clock_session("failed", zeron_proto::SessionStatus::Errored, now), + clock_session("idle", zeron_proto::SessionStatus::Idle, now), + ]; + state.selected_chat = Some("failed".into()); + state.connectivity.state = zeron_proto::ConnectivityState::Reconnecting; + assert_redraw_at( + cx, + &state, + now, + clock_epoch() + chrono::TimeDelta::minutes(1), + ); + } + + #[gpui::test] + fn selected_working_chat_redraws_every_second(cx: &mut gpui::TestAppContext) { + let now = clock_epoch(); + let mut state = AppState::new(); + state.sessions = vec![clock_session( + "chat", + zeron_proto::SessionStatus::Working, + now, + )]; + state.selected_chat = Some("chat".into()); + assert_redraw_at(cx, &state, now, now + chrono::TimeDelta::seconds(1)); + // Awaiting input shows no elapsed timer; its staleness cutoff is an + // AppState notification, not a shell tick. + state.sessions[0].status = zeron_proto::SessionStatus::AwaitingInput; + assert_redraw_at(cx, &state, now, now + chrono::TimeDelta::minutes(1)); + } + + #[gpui::test] + fn clock_redraw_keeps_the_earliest_deadline(cx: &mut gpui::TestAppContext) { + let now = clock_epoch(); + let (probe, redraws, _subscription) = clock_probe(cx); + arm_probe(cx, &probe, now + chrono::TimeDelta::seconds(10), now); + // An earlier deadline replaces the pending wake; a later one does not. + arm_probe(cx, &probe, now + chrono::TimeDelta::seconds(5), now); + arm_probe(cx, &probe, now + chrono::TimeDelta::seconds(30), now); + cx.executor().advance_clock(Duration::from_secs(5)); + assert_eq!(redraws.get(), 1); + // Fired timers are one-shot: nothing else is pending. + cx.executor().advance_clock(Duration::from_secs(60)); + assert_eq!(redraws.get(), 1); + assert!(probe.read_with(cx, |probe, _| probe.clock.task.is_none())); + } + #[test] fn escape_ignores_non_live_or_ineligible_views() { assert_eq!( @@ -14215,7 +14959,7 @@ mod exit_regressions { }); shell.on_state_changed(&shell.state.clone(), cx); shell.settings.space_filter = Some("b".into()); - shell.open_new_session(cx); + shell.open_new_session(None, cx); shell.on_state_changed(&shell.state.clone(), cx); assert!(shell.active_chat.is_empty()); shell.settings.appshot_destination = @@ -14404,7 +15148,8 @@ mod exit_regressions { settings::update(settings::SavePolicy::Immediate, cx, |settings| { settings.wallpaper_folder = Some(dir.path().join("wallpapers")); settings.wallpaper_source = Some(dir.path().join("wallpapers/current.png")); - settings.wallpaper_history = vec![dir.path().join("wallpapers/current.png")]; + settings.wallpaper_history = + vec![dir.path().join("wallpapers/current.png")]; settings.window_geometry = geometry; settings.open_web_links_in_zeron = open_links_in_zeron; settings.terminal_font_family = terminal_family.clone(); @@ -14426,8 +15171,14 @@ mod exit_regressions { shell.settings.terminal_height = 300.0 + step as f32; shell.schedule_save(cx); let current = settings::current(cx); - assert_eq!(current.wallpaper_history, vec![dir.path().join("wallpapers/current.png")]); - assert_eq!(current.wallpaper_folder, Some(dir.path().join("wallpapers"))); + assert_eq!( + current.wallpaper_history, + vec![dir.path().join("wallpapers/current.png")] + ); + assert_eq!( + current.wallpaper_folder, + Some(dir.path().join("wallpapers")) + ); assert_eq!( current.wallpaper_source, Some(dir.path().join("wallpapers/current.png")) @@ -14455,7 +15206,10 @@ mod exit_regressions { settings::flush(cx); let loaded = settings::UiSettings::load(dir.path()); assert_eq!(loaded.window_geometry, geometry); - assert_eq!(loaded.wallpaper_history, vec![dir.path().join("wallpapers/current.png")]); + assert_eq!( + loaded.wallpaper_history, + vec![dir.path().join("wallpapers/current.png")] + ); assert_eq!(loaded.wallpaper_folder, Some(dir.path().join("wallpapers"))); assert_eq!( loaded.wallpaper_source, @@ -14476,6 +15230,74 @@ mod exit_regressions { } } + #[gpui::test] + fn shell_saves_never_revert_settings_written_outside_the_shell(cx: &mut TestAppContext) { + let dir = tempfile::tempdir().unwrap(); + cx.update(|cx| { + gpui_base::init(cx); + cx.set_global(Theme::default()); + crate::app_menus::init(cx); + crate::history::init( + Default::default(), + Default::default(), + Default::default(), + Default::default(), + cx, + ); + settings::init(settings::UiSettings::default(), dir.path(), cx); + }); + let window = cx.add_window(|_, cx| { + let state = cx.new(|_| AppState::new()); + Shell::new( + state, + EngineBootConfig { + data_dir: dir.path().into(), + ipc_port: 0, + edge_url: "http://127.0.0.1:1".into(), + edge_token: None, + org_id: None, + workos_client_id: None, + default_harness: zeron_proto::HarnessId::Mock, + }, + cx, + ) + }); + let before = cx.update(|cx| settings::current(cx)); + window + .update(cx, |shell, _, cx| { + // Toggles that write the store directly (the General page's + // Compact mode, notification and sidebar switches, ...). + settings::set_transcript_compact_mode(!before.transcript_compact_mode, cx); + settings::update(SavePolicy::Immediate, cx, |s| { + s.notifications_enabled = !before.notifications_enabled; + s.sidebar_show_branch = !before.sidebar_show_branch; + s.escape_stops_active_agent = !before.escape_stops_active_agent; + }); + // Navigating away saves the shell's own working copy. + shell.remember_settings_section(SettingsSection::Notifications, cx); + // The shell's own writes still land. + shell.settings.sidebar_width += 10.0; + shell.schedule_save(cx); + }) + .unwrap(); + let after = cx.update(|cx| settings::current(cx)); + assert_eq!( + after.transcript_compact_mode, + !before.transcript_compact_mode + ); + assert_eq!(after.notifications_enabled, !before.notifications_enabled); + assert_eq!(after.sidebar_show_branch, !before.sidebar_show_branch); + assert_eq!( + after.escape_stops_active_agent, + !before.escape_stops_active_agent + ); + assert_eq!( + after.settings_section, + SettingsSection::Notifications.canonical() + ); + assert_eq!(after.sidebar_width, before.sidebar_width + 10.0); + } + #[gpui::test] fn workspace_slash_commands_open_existing_zeron_surfaces(cx: &mut TestAppContext) { use crate::composer::WorkspaceCommand; @@ -14601,6 +15423,82 @@ mod exit_regressions { .unwrap(); } + #[gpui::test] + fn shell_saves_keep_every_setting_chosen_outside_the_shell(cx: &mut TestAppContext) { + let dir = tempfile::tempdir().unwrap(); + cx.update(|cx| { + settings::init(settings::UiSettings::default(), dir.path(), cx); + crate::history::init( + Default::default(), + Default::default(), + Default::default(), + Default::default(), + cx, + ); + gpui_base::init(cx); + cx.set_global(Theme::default()); + crate::app_menus::init(cx); + }); + let window = cx.add_window(|_, cx| { + let state = cx.new(|_| AppState::new()); + Shell::new( + state, + EngineBootConfig { + data_dir: dir.path().into(), + ipc_port: 0, + edge_url: "http://127.0.0.1:1".into(), + edge_token: None, + org_id: None, + workos_client_id: None, + default_harness: zeron_proto::HarnessId::Mock, + }, + cx, + ) + }); + window + .update(cx, |shell, _, cx| { + // Voice, transcript and diff controls save straight to the + // store. None of them is known to the Shell. + settings::update(SavePolicy::Immediate, cx, |settings| { + settings.dictation_enabled = true; + settings.dictation_input = Some("coreaudio:usb".into()); + settings.codex_voice = Some("ember".into()); + settings.transcript_compact_mode = true; + settings.code_fences_fit_content = true; + settings.diff_split = true; + settings.diff_wrap = true; + }); + // Leaving Settings and resizing both republish the Shell's copy. + shell.settings.settings_section = SettingsSection::Appearance; + shell.settings.sidebar_width = 300.0; + shell.schedule_save(cx); + let saved = settings::current(cx); + assert!(saved.dictation_enabled); + assert_eq!(saved.dictation_input.as_deref(), Some("coreaudio:usb")); + assert_eq!(saved.codex_voice.as_deref(), Some("ember")); + assert!(saved.transcript_compact_mode); + assert!(saved.code_fences_fit_content); + assert!(saved.diff_split); + assert!(saved.diff_wrap); + assert_eq!(saved.settings_section, SettingsSection::Appearance); + assert_eq!(saved.sidebar_width, 300.0); + // An outside change to a field the Shell also holds still wins + // once the Shell has nothing newer of its own. + settings::update(SavePolicy::Immediate, cx, |settings| { + settings.dictation_enabled = false; + settings.codex_voice = None; + settings.sidebar_width = 320.0; + }); + shell.schedule_save(cx); + let saved = settings::current(cx); + assert!(!saved.dictation_enabled); + assert_eq!(saved.codex_voice, None); + assert_eq!(saved.sidebar_width, 320.0); + assert_eq!(shell.settings.sidebar_width, 320.0); + }) + .unwrap(); + } + #[gpui::test] fn sidebar_settings_trigger_toggles_the_settings_route(cx: &mut TestAppContext) { // Just the footer row: the full shell's settings modal would cover it. @@ -14787,7 +15685,7 @@ mod exit_regressions { window .update(cx, |shell, _, cx| match destination { "chat" => shell.open_chat("existing-session".into(), cx), - "new" => shell.open_new_session(cx), + "new" => shell.open_new_session(None, cx), "back" => shell.apply_nav(NavEntry::Chat("existing-session".into()), cx), "settings" => { shell.open_settings(SettingsSection::Devices, cx); @@ -14868,6 +15766,7 @@ mod exit_regressions { git_detected: false, git_checked_at: None, checkout_id: None, + repository_id: None, created_at: Utc::now(), }]); // Boot opened an existing project session after loading defaults. @@ -14875,7 +15774,7 @@ mod exit_regressions { state.no_project = false; }); shell.settings.space_filter = None; - shell.open_new_session(cx); + shell.open_new_session(None, cx); assert!(shell.state.read(cx).no_project); assert!(shell.state.read(cx).selected_space.is_none()); assert_eq!( @@ -14896,6 +15795,149 @@ mod exit_regressions { .unwrap(); } + #[gpui::test] + fn new_session_reopens_where_it_was_left_after_visiting_a_chat(cx: &mut TestAppContext) { + let dir = tempfile::tempdir().unwrap(); + cx.update(|cx| { + gpui_base::init(cx); + cx.set_global(Theme::default()); + crate::app_menus::init(cx); + crate::history::init( + Default::default(), + Default::default(), + Default::default(), + Default::default(), + cx, + ); + settings::init(settings::UiSettings::default(), dir.path(), cx); + }); + let window = cx.add_window(|_, cx| { + let state = cx.new(|_| AppState::new()); + Shell::new( + state, + EngineBootConfig { + data_dir: dir.path().into(), + ipc_port: 0, + edge_url: "http://127.0.0.1:1".into(), + edge_token: None, + org_id: None, + workos_client_id: None, + default_harness: zeron_proto::HarnessId::Mock, + }, + cx, + ) + }); + let space = |id: &str, device: &str| zeron_proto::Space { + id: id.into(), + device_id: device.into(), + path: format!("/{id}"), + name: None, + git_detected: false, + git_checked_at: None, + checkout_id: None, + repository_id: None, + created_at: Utc::now(), + }; + window + .update(cx, |shell, _, cx| { + shell.state.update(cx, |state, cx| { + state.apply_spaces(vec![space("mine", "local"), space("other", "remote")]); + state.apply_chats(vec![zeron_proto::Chat { + id: "elsewhere".into(), + device_id: "remote".into(), + title: None, + archived: false, + cwd: None, + branch: None, + checkout_id: None, + source_context: None, + config: None, + last_message_preview: None, + last_message_at: None, + created_at: Utc::now(), + harness_session_id: None, + harness_session_cwd: None, + parent_chat_id: None, + space_id: Some("other".into()), + last_seen_at: None, + room_gen: None, + }]); + state.select_space(Some("mine".into()), cx); + }); + shell.settings.space_filter = None; + shell.open_chat("elsewhere".into(), cx); + assert_eq!(shell.state.read(cx).selected_space.as_deref(), Some("other")); + shell.open_new_session(None, cx); + let state = shell.state.read(cx); + assert!(state.selected_chat.is_none()); + assert_eq!(state.selected_space.as_deref(), Some("mine")); + assert_eq!(state.effective_device_id().as_deref(), Some("local")); + }) + .unwrap(); + } + + #[gpui::test] + fn new_session_in_project_homes_the_canvas_on_that_project(cx: &mut TestAppContext) { + let dir = tempfile::tempdir().unwrap(); + cx.update(|cx| { + gpui_base::init(cx); + cx.set_global(Theme::default()); + crate::app_menus::init(cx); + crate::history::init( + Default::default(), + Default::default(), + Default::default(), + Default::default(), + cx, + ); + settings::init(settings::UiSettings::default(), dir.path(), cx); + }); + let window = cx.add_window(|_, cx| { + let state = cx.new(|_| AppState::new()); + Shell::new( + state, + EngineBootConfig { + data_dir: dir.path().into(), + ipc_port: 0, + edge_url: "http://127.0.0.1:1".into(), + edge_token: None, + org_id: None, + workos_client_id: None, + default_harness: zeron_proto::HarnessId::Mock, + }, + cx, + ) + }); + let space = |id: &str| zeron_proto::Space { + id: id.into(), + device_id: "local".into(), + path: format!("/{id}"), + name: None, + git_detected: false, + git_checked_at: None, + checkout_id: None, + repository_id: None, + created_at: Utc::now(), + }; + window + .update(cx, |shell, _, cx| { + shell.state.update(cx, |state, _| { + state.apply_spaces(vec![space("one"), space("two")]); + }); + shell.settings.space_filter = None; + shell.open_new_session(Some("two".into()), cx); + let state = shell.state.read(cx); + assert!(state.selected_chat.is_none()); + assert_eq!(state.selected_space.as_deref(), Some("two")); + + // The explicit project wins over a standing filter. + shell.settings.space_filter = Some("one".into()); + shell.open_new_session(Some("two".into()), cx); + assert_eq!(shell.state.read(cx).selected_space.as_deref(), Some("two")); + }) + .unwrap(); + } + #[cfg(any(target_os = "macos", target_os = "linux"))] #[gpui::test] fn transcript_links_open_new_tabs_and_reject_stale_sessions(cx: &mut TestAppContext) { @@ -15144,6 +16186,7 @@ mod exit_regressions { git_detected: false, git_checked_at: None, checkout_id: None, + repository_id: None, created_at: Utc::now(), }]); }); @@ -15737,6 +16780,18 @@ mod right_tab_mouse_regressions { }); } + #[gpui::test] + fn right_tab_close_sits_at_the_trailing_edge(cx: &mut TestAppContext) { + let (_shell, cx) = setup(cx); + let tab = cx.debug_bounds("right-surface-tab-0").unwrap(); + let close = cx.debug_bounds("right-surface-close-0").unwrap(); + assert!( + close.left() > tab.center().x, + "close is not after the title" + ); + assert_eq!(close.right(), tab.right() - px(4.)); + } + #[gpui::test] fn tab_strip_scrolls_over_chips_inside_titlebar(cx: &mut TestAppContext) { let (shell, cx) = setup(cx); @@ -16035,6 +17090,9 @@ impl Shell { self.transcript .update(cx, |t, cx| t.fixture_appshots_start(cx)); } + pub fn fixture_transcript(&self) -> Entity { + self.transcript.clone() + } } #[cfg(test)] @@ -16243,6 +17301,7 @@ mod settings_modal_regressions { ("settings/agents", SettingsSection::Harnesses), ("settings/harnesses", SettingsSection::Harnesses), ("settings/general", SettingsSection::General), + ("settings/voice", SettingsSection::Voice), ("settings/conversations", SettingsSection::General), ("settings/files", SettingsSection::Files), ("settings/appshots", SettingsSection::Appshots), @@ -16396,6 +17455,38 @@ mod settings_modal_regressions { .unwrap(); } + #[gpui::test] + fn voice_stage_opens_over_settings(cx: &mut TestAppContext) { + let dir = tempfile::tempdir().unwrap(); + init_settings_test(settings::UiSettings::default(), dir.path(), cx); + let (shell, cx) = cx.add_window_view(|_, cx| test_shell(dir.path(), cx)); + shell.update(cx, |shell, cx| { + shell.debug_gate = Some(GatePhase::Ready); + shell.reduced_motion = true; + shell.open_settings(SettingsSection::Voice, cx); + }); + cx.run_until_parked(); + cx.update(|window, cx| window.draw(cx).clear()); + assert!(cx.debug_bounds("voice-stage").is_none()); + + shell.update(cx, |shell, cx| { + shell.voice.update(cx, |voice, _| { + voice.phase = zeron_proto::voice::VoicePhase::Active + }); + shell.set_voice_stage_open(true, cx); + }); + cx.update(|window, cx| window.draw(cx).clear()); + let stage = cx + .debug_bounds("voice-stage") + .expect("stage renders in Settings"); + shell.read_with(cx, |shell, cx| { + assert!(matches!(shell.route, Route::Settings(_))); + assert!(shell.voice.read(cx).stage_open); + // The settings sidebar stays usable beside the stage. + assert_eq!(f32::from(stage.origin.x), shell.settings.sidebar_width); + }); + } + #[gpui::test] fn escape_closes_what_is_open_inside_settings_before_settings(cx: &mut TestAppContext) { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/ui/src/shell/actions_ui.rs b/crates/ui/src/shell/actions_ui.rs index c2b5df9358..217625c8e2 100644 --- a/crates/ui/src/shell/actions_ui.rs +++ b/crates/ui/src/shell/actions_ui.rs @@ -188,8 +188,14 @@ impl Shell { name.update(cx, |input, cx| input.set_text(draft.name, cx)); let command = cx.new(|cx| ComposerInput::new("Command", cx)); command.update(cx, |input, cx| input.set_text(draft.command, cx)); - let name_events = cx.subscribe(&name, |_: &mut Shell, _, _, cx| cx.notify()); - let command_events = cx.subscribe(&command, |_: &mut Shell, _, _, cx| cx.notify()); + let name_events = cx.subscribe( + &name, + |_: &mut Shell, _, _: &crate::composer::ComposerInputEvent, cx| cx.notify(), + ); + let command_events = cx.subscribe( + &command, + |_: &mut Shell, _, _: &crate::composer::ComposerInputEvent, cx| cx.notify(), + ); self.project_actions.editor = Some(ProjectActionEditor { key, action_id, diff --git a/crates/ui/src/shell/chat_dropzone.rs b/crates/ui/src/shell/chat_dropzone.rs index 33216b4647..65c924156e 100644 --- a/crates/ui/src/shell/chat_dropzone.rs +++ b/crates/ui/src/shell/chat_dropzone.rs @@ -21,21 +21,19 @@ impl Shell { cx.notify(); })) .on_drop::(cx.listener( - move |_, payload: &WorkspacePathDrag, window, cx| { - workspace.update(cx, |composer, cx| { - composer.add_workspace_path(&payload.path, payload.is_directory, window, cx) - }); + move |this, payload: &WorkspacePathDrag, window, cx| { + this.attach_workspace_drag(payload, &workspace, window, cx); + cx.notify(); + }, + )) + .on_drop::(cx.listener( + move |this, payload: &RightTabDrag, window, cx| { + if let Some(path) = &payload.workspace_path { + this.attach_workspace_drag(path, &composer, window, cx); + } cx.notify(); }, )) - .on_drop::(cx.listener(move |_, payload: &RightTabDrag, window, cx| { - if let Some(path) = &payload.workspace_path { - composer.update(cx, |composer, cx| { - composer.add_workspace_path(&path.path, path.is_directory, window, cx) - }); - } - cx.notify(); - })) } pub(super) fn attachment_drop_overlay(theme: &Theme) -> gpui::Stateful { diff --git a/crates/ui/src/shell/chat_dropzone_tests.rs b/crates/ui/src/shell/chat_dropzone_tests.rs index 5b77466253..1987b05343 100644 --- a/crates/ui/src/shell/chat_dropzone_tests.rs +++ b/crates/ui/src/shell/chat_dropzone_tests.rs @@ -18,6 +18,12 @@ impl Render for DropHost { self.shell.update(cx, |shell, cx| { let main = shell.render_main(window, 400., 400., cx); let right = shell.render_right_pane(window, cx); + // Tree and search rows stamp the explorer's current origin; the + // stale source models a drag that outlived its workspace. + let origin = shell + .files + .get(&shell.panel_key(cx)) + .and_then(|files| files.read(cx).interaction_origin(cx)); div() .size_full() .flex() @@ -27,22 +33,30 @@ impl Render for DropHost { // to enumerate a checkout. Targets and file tabs are real UI. .child( div().h(px(24.)).flex().children( - [("file", "src/tree.rs", false), ("directory", "src", true)] - .into_iter() - .map(|(id, path, directory)| { - div() - .id(id) - .debug_selector(move || id.into()) - .w(px(100.)) - .h_full() - .on_drag( - WorkspacePathDrag::new(path.into(), directory), - |payload, _, _, cx| { - crate::files::workspace_path_drag_ghost(payload, cx) - }, - ) - .child(id) - }), + [ + ("file", "src/tree.rs", false, origin.clone()), + ("directory", "src", true, origin), + ("stale", "src/stale.rs", false, None), + ] + .into_iter() + .map(|(id, path, directory, origin)| { + div() + .id(id) + .debug_selector(move || id.into()) + .w(px(100.)) + .h_full() + .on_drag( + WorkspacePathDrag::new(path.into(), directory).with_origin( + origin, + crate::files::WorkspacePathSource::Tree, + None, + ), + |payload, _, _, cx| { + crate::files::workspace_path_drag_ghost(payload, cx) + }, + ) + .child(id) + }), ), ) .child( @@ -129,6 +143,15 @@ fn setup_with_shell( cx.update(|window, cx| { shell.update(cx, |shell, cx| { shell.add_file_surface("src/tab.rs".into(), window, cx); + let explorer = cx.new(|cx| { + crate::files::FilesSurface::new_explorer( + shell.state.clone(), + shell.active_chat.clone(), + false, + cx, + ) + }); + shell.files.insert(shell.panel_key(cx), explorer); let mut side = shell.state.read(cx).chats[0].clone(); side.id = "saved-side".into(); side.parent_chat_id = Some("parent".into()); @@ -243,6 +266,22 @@ fn workspace_drops_target_saved_and_unsaved_side_chat_composers(cx: &mut TestApp } }); } + // A payload without a live origin attaches to neither side nor main chats. + let side = cx.update(|_, cx| { + shell.read(cx).side_chats[&1] + .composer + .read(cx) + .surface_bounds() + .get() + .unwrap() + .center() + }); + drag(cx, "stale", side); + drag(cx, "stale", gpui::point(px(200.), px(150.))); + shell.read_with(cx, |shell, cx| { + assert!(!text(&shell.side_chats[&1].composer, cx).contains("stale.rs")); + assert!(text(&shell.composer, cx).is_empty()); + }); } #[gpui::test] @@ -283,3 +322,124 @@ fn file_tab_drops_attach_to_side_chat_transcript_and_main_without_reordering( assert_eq!(shell.right_tabs[&shell.panel_key(cx)], original_tabs); }); } + +/// Real files of each kind a user might drag in from the desktop: an archive, +/// an image, a folder and a file over the size limit. +fn external_files() -> (tempfile::TempDir, gpui::ExternalPaths) { + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("notes.zip"), + b"PK\x03\x04 not really zipped", + ) + .unwrap(); + let mut png = std::io::Cursor::new(Vec::new()); + image::RgbImage::new(1, 1) + .write_to(&mut png, image::ImageFormat::Png) + .unwrap(); + std::fs::write(dir.path().join("shot.png"), png.into_inner()).unwrap(); + std::fs::create_dir(dir.path().join("folder")).unwrap(); + // Sparse, so the test never writes 24 MB to disk. + std::fs::File::create(dir.path().join("huge.bin")) + .unwrap() + .set_len(crate::attachments::MAX_ATTACHMENT_BYTES + 1) + .unwrap(); + let paths = ["notes.zip", "shot.png", "folder", "huge.bin"] + .into_iter() + .map(|name| dir.path().join(name)) + .collect(); + (dir, gpui::ExternalPaths(paths)) +} + +/// Drag files in from outside the app and release them at `to` the way the +/// platform reports it: enter, hover, then drop. +fn drop_external(cx: &mut VisualTestContext, paths: gpui::ExternalPaths, to: Point) { + cx.simulate_event(gpui::FileDropEvent::Entered { + position: to, + paths, + }); + cx.update(|window, cx| { + assert!(cx.has_active_drag()); + window.draw(cx).clear(); + }); + cx.simulate_event(gpui::FileDropEvent::Pending { position: to }); + cx.update(|window, cx| window.draw(cx).clear()); + cx.simulate_event(gpui::FileDropEvent::Submit { position: to }); + cx.run_until_parked(); + cx.update(|window, cx| { + assert!(!cx.has_active_drag()); + window.draw(cx).clear(); + }); +} + +fn staged_names(composer: &Entity, cx: &App) -> Vec { + composer + .read(cx) + .staged() + .iter() + .map(|att| att.name.clone()) + .collect() +} + +/// A mixed drop from outside the app lands in the conversation it is released +/// over, main or side chat: the archive and the image are staged with chips in +/// that draft, and the folder and the oversize file are both named in that +/// composer's error notice. Composers not yet dropped on stay untouched. +#[gpui::test] +fn external_file_drops_stage_files_and_refuse_folders_and_oversize_files(cx: &mut TestAppContext) { + use zeron_proto::attachment_mentions::attachment_mention_link; + let (shell, cx) = setup_with_shell(cx, true); + let (_files, paths) = external_files(); + let targets = [None, Some(2), Some(1)]; + let composer = |shell: &Shell, target: Option| match target { + None => shell.composer.clone(), + Some(id) => shell.side_chats[&id].composer.clone(), + }; + for (n, target) in targets.into_iter().enumerate() { + let to = match target { + None => cx.debug_bounds("chat-dropzone").unwrap().center(), + Some(id) => cx.update(|window, cx| { + shell.update(cx, |shell, cx| { + shell.activate_right_surface(RightSurface::SideChat(id), window, cx); + shell.right_tween = None; + }); + window.draw(cx).clear(); + let bounds = shell.read(cx).side_chats[&id] + .composer + .read(cx) + .surface_bounds() + .get() + .unwrap(); + // Over the side chat's transcript, not its input. + gpui::point(bounds.center().x, px(180.)) + }), + }; + drop_external(cx, paths.clone(), to); + shell.read_with(cx, |shell, cx| { + let dropped = composer(shell, target); + assert_eq!( + staged_names(&dropped, cx), + ["notes.zip", "Image 2.png"], + "target={target:?}" + ); + assert_eq!( + text(&dropped, cx), + format!( + "{} {} ", + attachment_mention_link(1, Some("notes.zip")), + attachment_mention_link(2, None) + ), + "target={target:?}" + ); + assert_eq!( + dropped.read(cx).visible_failure().as_deref(), + Some("folder is not a file. huge.bin is too large (24 MB max)."), + "target={target:?}" + ); + for other in targets[n + 1..].iter().map(|other| composer(shell, *other)) { + assert!(staged_names(&other, cx).is_empty(), "target={target:?}"); + assert!(text(&other, cx).is_empty(), "target={target:?}"); + assert!(other.read(cx).failure().is_none(), "target={target:?}"); + } + }); + } +} diff --git a/crates/ui/src/shell/chat_rename_tests.rs b/crates/ui/src/shell/chat_rename_tests.rs new file mode 100644 index 0000000000..195d935587 --- /dev/null +++ b/crates/ui/src/shell/chat_rename_tests.rs @@ -0,0 +1,401 @@ +//! Inline chat renames on the sidebar row, without booting an engine. +use super::*; +use gpui::{AppContext, Modifiers, TestAppContext, VisualTestContext}; + +struct SidebarHost(Entity); + +impl Render for SidebarHost { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + self.0.update(cx, |shell, cx| { + shell.focus_rename_chat(window, cx); + div() + .w(px(280.0)) + .h(px(800.0)) + .capture_key_down(cx.listener(Shell::on_key_down_capture)) + .child(shell.render_chat_sidebar(&Theme::default(), cx)) + }) + } +} + +fn setup(cx: &mut TestAppContext) -> (Entity, &mut VisualTestContext) { + let dir = tempfile::tempdir().unwrap(); + cx.update(|cx| { + gpui_base::init(cx); + cx.set_global(Theme::default()); + crate::app_menus::init(cx); + settings::init(UiSettings::default(), dir.path(), cx); + crate::history::init( + Default::default(), + Default::default(), + Default::default(), + Default::default(), + cx, + ); + apply_keymap( + cx, + &KeymapConfig::default(), + ComposerSendBehavior::default(), + ); + }); + let (host, cx) = cx.add_window_view(|_, cx| { + SidebarHost(cx.new(|cx| { + let state = cx.new(|_| AppState::new()); + let mut shell = Shell::new( + state, + EngineBootConfig { + data_dir: dir.path().into(), + ipc_port: 0, + edge_url: "http://127.0.0.1:1".into(), + edge_token: None, + org_id: None, + workos_client_id: None, + default_harness: zeron_proto::HarnessId::Mock, + }, + cx, + ); + shell.settings.sidebar_organization = SidebarOrganization::InOneList; + shell.reduced_motion = true; + shell.state.update(cx, |state, _| { + state.workspace_scope = Some(WorkspaceScope::Local); + state.local_device_id = Some("local".into()); + state.no_project = true; + state.chats = ["older", "newer"] + .into_iter() + .enumerate() + .map(|(ix, id)| { + serde_json::from_value(serde_json::json!({ + "id": id, "title": id, "deviceId": "local", "archived": false, + "createdAt": Utc::now() - chrono::Duration::minutes(10 - ix as i64), + })) + .unwrap() + }) + .collect(); + }); + shell + })) + }); + let shell = host.read_with(cx, |host, _| host.0.clone()); + cx.update(|window, cx| { + window.activate_window(); + window.draw(cx).clear(); + }); + (shell, cx) +} + +fn redraw(cx: &mut VisualTestContext) { + cx.update(|window, cx| { + window.refresh(); + window.draw(cx).clear(); + }); +} + +fn double_click(cx: &mut VisualTestContext, position: Point) { + for click_count in [1, 2] { + cx.simulate_event(MouseDownEvent { + button: MouseButton::Left, + position, + click_count, + ..Default::default() + }); + cx.simulate_event(MouseUpEvent { + button: MouseButton::Left, + position, + click_count, + ..Default::default() + }); + } + redraw(cx); +} + +/// A rename that reached `mutate` reports the missing engine on the sidebar. +fn mutated(shell: &Entity, cx: &mut VisualTestContext) -> bool { + shell.read_with(cx, |shell, _| shell.sidebar_notice.is_some()) +} + +#[gpui::test] +fn double_click_edits_a_sidebar_title_in_place(cx: &mut TestAppContext) { + let (shell, cx) = setup(cx); + let row = cx.debug_bounds("chat-older").unwrap(); + double_click(cx, row.center()); + + // The title swaps for a focused field holding the whole name, selected; + // no modal opens. + assert!(cx.debug_bounds("chat-title-older").is_none()); + let field = cx.debug_bounds("chat-title-editor-older").unwrap(); + assert!(row.contains(&field.center())); + let input = shell.read_with(cx, |shell, _| { + let rename = shell.chat_rename.as_ref().unwrap(); + assert_eq!(rename.chat_id, "older"); + assert_eq!(rename.surface, ChatRenameSurface::Sidebar); + rename.input.clone() + }); + cx.update(|window, cx| { + assert!(input.focus_handle(cx).is_focused(window)); + assert_eq!(input.read(cx).text(), "older"); + }); + + // Clicking inside the field keeps editing instead of activating the row. + cx.simulate_click(field.center(), Modifiers::default()); + redraw(cx); + assert!(shell.read_with(cx, |shell, _| shell.chat_rename.is_some())); + + // Escape drops the edit without renaming. + cx.simulate_keystrokes("escape"); + redraw(cx); + assert!(shell.read_with(cx, |shell, _| shell.chat_rename.is_none())); + assert!(cx.debug_bounds("chat-title-older").is_some()); + assert!(!mutated(&shell, cx)); + + // Enter on an unchanged title closes without a mutation. + double_click(cx, row.center()); + cx.simulate_keystrokes("enter"); + redraw(cx); + assert!(shell.read_with(cx, |shell, _| shell.chat_rename.is_none())); + assert!(!mutated(&shell, cx)); + + // Typing replaces the selected name; Enter commits it. + double_click(cx, row.center()); + cx.simulate_input("Renamed"); + let input = shell.read_with(cx, |shell, _| { + shell.chat_rename.as_ref().unwrap().input.clone() + }); + assert_eq!( + input.read_with(cx, |input, _| input.text().to_string()), + "Renamed" + ); + cx.simulate_keystrokes("enter"); + redraw(cx); + assert!(shell.read_with(cx, |shell, _| shell.chat_rename.is_none())); + assert!(mutated(&shell, cx)); +} + +#[gpui::test] +fn menu_rename_opens_the_same_field_and_blur_commits(cx: &mut TestAppContext) { + let (shell, cx) = setup(cx); + shell.update(cx, |shell, cx| shell.open_rename_chat("newer".into(), cx)); + redraw(cx); + assert!(cx.debug_bounds("chat-title-editor-newer").is_some()); + assert!(cx.debug_bounds("chat-title-editor-older").is_none()); + + cx.simulate_input("Moved on"); + // Focus leaving the field saves the edit. + cx.update(|window, _| window.blur()); + redraw(cx); + assert!(shell.read_with(cx, |shell, _| shell.chat_rename.is_none())); + assert!(mutated(&shell, cx)); +} + +/// Open a rename the way `/rename` does — without touching the row. +fn rename(shell: &Entity, cx: &mut VisualTestContext, chat_id: &str) { + shell.update(cx, |shell, cx| shell.open_rename_chat(chat_id.into(), cx)); + redraw(cx); +} + +fn editing(shell: &Entity, cx: &mut VisualTestContext) -> Option { + shell.read_with(cx, |shell, _| { + shell + .chat_rename + .as_ref() + .map(|rename| rename.chat_id.clone()) + }) +} + +fn add_chats(shell: &Entity, cx: &mut VisualTestContext, ids: &[&str], archived: bool) { + shell.update(cx, |shell, cx| { + shell.state.update(cx, |state, _| { + for (ix, id) in ids.iter().enumerate() { + state.chats.push( + serde_json::from_value(serde_json::json!({ + "id": id, "title": id, "deviceId": "local", "archived": archived, + "createdAt": Utc::now() - chrono::Duration::minutes(30 + ix as i64), + })) + .unwrap(), + ); + } + }); + cx.notify(); + }); + redraw(cx); +} + +#[gpui::test] +fn rename_opens_a_collapsed_pinned_section_with_its_motion(cx: &mut TestAppContext) { + let (shell, cx) = setup(cx); + shell.update(cx, |shell, cx| { + shell.set_chat_pinned("older".into(), true, cx); + shell.pinned_open = false; + cx.notify(); + }); + redraw(cx); + + rename(&shell, cx, "older"); + assert_eq!(editing(&shell, cx).as_deref(), Some("older")); + shell.read_with(cx, |shell, _| { + assert!(shell.pinned_open); + // The queued reveal became the disclosure's opening motion. + assert!(shell.sidebar_reveal_motions.is_empty()); + let motion = shell.sidebar_disclosure_motion["pinned"]; + assert_eq!(motion.from, 0.0); + assert!(motion.to > 0.0); + }); + assert!(cx.debug_bounds("chat-title-editor-older").is_some()); +} + +#[gpui::test] +fn rename_opens_collapsed_sessions_groups_and_the_sidebar(cx: &mut TestAppContext) { + let (shell, cx) = setup(cx); + shell.update(cx, |shell, cx| { + shell.sessions_open = false; + shell.settings.sidebar_collapsed = true; + cx.notify(); + }); + redraw(cx); + rename(&shell, cx, "newer"); + shell.read_with(cx, |shell, _| { + assert!(shell.sessions_open); + assert!(!shell.settings.sidebar_collapsed); + }); + assert_eq!(editing(&shell, cx).as_deref(), Some("newer")); + + // Grouped by project: the chat's collapsed group opens. + shell.update(cx, |shell, cx| { + shell.finish_rename_chat(false, cx); + shell.settings.sidebar_organization = SidebarOrganization::ByProject; + shell + .sidebar_collapsed_groups + .insert("project:home:local".into()); + cx.notify(); + }); + redraw(cx); + rename(&shell, cx, "older"); + shell.read_with(cx, |shell, _| { + assert!(shell.sidebar_collapsed_groups.is_empty()); + assert!(shell.sidebar_reveal_motions.is_empty()); + }); + assert!(cx.debug_bounds("chat-title-editor-older").is_some()); +} + +#[gpui::test] +fn rename_pages_the_archived_shelf_to_its_row(cx: &mut TestAppContext) { + let (shell, cx) = setup(cx); + let ids: Vec = (0..14).map(|ix| format!("archived-{ix:02}")).collect(); + add_chats( + &shell, + cx, + &ids.iter().map(String::as_str).collect::>(), + true, + ); + shell.update(cx, |shell, cx| { + shell.archived_open = false; + cx.notify(); + }); + redraw(cx); + + // Oldest last: the 13th row is past the first page of ten. + rename(&shell, cx, "archived-12"); + shell.read_with(cx, |shell, _| { + assert!(shell.archived_open); + assert_eq!(shell.archived_shown, 35); + }); + assert!(cx.debug_bounds("chat-title-editor-archived-12").is_some()); +} + +#[gpui::test] +fn rename_scrolls_a_far_row_into_view(cx: &mut TestAppContext) { + let (shell, cx) = setup(cx); + let ids: Vec = (0..40).map(|ix| format!("chat-{ix:02}")).collect(); + add_chats( + &shell, + cx, + &ids.iter().map(String::as_str).collect::>(), + false, + ); + rename(&shell, cx, "chat-39"); + for _ in 0..4 { + cx.run_until_parked(); + redraw(cx); + } + let field = cx.debug_bounds("chat-title-editor-chat-39").unwrap(); + let viewport = shell.read_with(cx, |shell, _| shell.sidebar_scroll.bounds()); + assert!(shell.read_with(cx, |shell, _| shell.sidebar_scroll.offset().y < px(0.0))); + assert!(field.top() >= viewport.top() && field.bottom() <= viewport.bottom()); +} + +#[gpui::test] +fn rename_refuses_a_row_the_project_filter_hides(cx: &mut TestAppContext) { + let (shell, cx) = setup(cx); + shell.update(cx, |shell, cx| { + shell.settings.space_filter = Some("elsewhere".into()); + cx.notify(); + }); + redraw(cx); + rename(&shell, cx, "older"); + assert_eq!(editing(&shell, cx), None); + // The filter is the user's: it stays, and the sidebar says why. + shell.read_with(cx, |shell, _| { + assert_eq!(shell.settings.space_filter.as_deref(), Some("elsewhere")); + assert!(shell.sidebar_notice.is_some()); + }); +} + +#[gpui::test] +fn rename_expands_a_collapsed_custom_section(cx: &mut TestAppContext) { + use zeron_proto::SidebarSectionChange; + + let (shell, cx) = setup(cx); + shell.update(cx, |shell, cx| { + for change in [ + SidebarSectionChange::Create { + id: "work".into(), + name: "Work".into(), + }, + SidebarSectionChange::Assign { + session_id: "older".into(), + section_id: Some("work".into()), + }, + SidebarSectionChange::Collapse { + id: "work".into(), + collapsed: true, + }, + ] { + assert!(shell.change_sidebar_section(change, cx)); + } + }); + redraw(cx); + + rename(&shell, cx, "older"); + shell.update(cx, |shell, cx| { + let sections = shell.active_sidebar_sections(cx); + assert!(!sections[0].collapsed); + assert!(shell.sidebar_reveal_motions.is_empty()); + assert!(shell.sidebar_disclosure_motion.contains_key("custom:work")); + }); + assert!(cx.debug_bounds("chat-title-editor-older").is_some()); +} + +#[gpui::test] +fn inline_rename_keeps_the_row_height_in_compact_and_full_rows(cx: &mut TestAppContext) { + let (shell, cx) = setup(cx); + for compact in [true, false] { + shell.update(cx, |shell, cx| { + shell.settings.sidebar_compact = compact; + cx.notify(); + }); + redraw(cx); + let row = cx.debug_bounds("chat-older").unwrap(); + double_click(cx, row.center()); + + // The field takes the title's place without growing the row. + let field = cx.debug_bounds("chat-title-editor-older").unwrap(); + assert!(row.contains(&field.center()), "compact: {compact}"); + assert_eq!( + cx.debug_bounds("chat-older").unwrap().size.height, + row.size.height, + "compact: {compact}" + ); + + cx.simulate_keystrokes("escape"); + redraw(cx); + assert!(shell.read_with(cx, |shell, _| shell.chat_rename.is_none())); + } +} diff --git a/crates/ui/src/shell/command_palette.rs b/crates/ui/src/shell/command_palette.rs index 11141efc0e..6798adfe2e 100644 --- a/crates/ui/src/shell/command_palette.rs +++ b/crates/ui/src/shell/command_palette.rs @@ -146,7 +146,7 @@ impl Shell { let mut chats: Vec<_> = state .chats .iter() - .filter(|chat| chat.parent_chat_id.is_none()) + .filter(|chat| chat.is_top_level()) .filter(|chat| { let project = state .space_for_chat(chat) @@ -205,7 +205,7 @@ impl Shell { } self.close_command_palette(window, cx); match entry { - Entry::NewChat => self.open_new_session(cx), + Entry::NewChat => self.open_new_session(None, cx), Entry::NewProject => self.open_add_space(cx), Entry::Settings => self.open_last_settings(cx), Entry::Theme(_) => unreachable!(), @@ -337,6 +337,7 @@ impl Shell { false, None, None, + false, Some(&query), &theme, cx, diff --git a/crates/ui/src/shell/file_mutations.rs b/crates/ui/src/shell/file_mutations.rs new file mode 100644 index 0000000000..9330db0640 --- /dev/null +++ b/crates/ui/src/shell/file_mutations.rs @@ -0,0 +1,316 @@ +//! Coordinate structural changes across every editor of the affected workspace. +use super::*; +use crate::files::{client::WorkspaceFilesClient, mutations::MutationIntent}; +use zeron_proto::{ + DeleteWorkspaceEntryRequest, MoveWorkspaceEntryRequest, WorkspaceMutationOutcome, +}; + +impl Shell { + pub(super) fn start_file_mutation( + &mut self, + source: Entity, + mut intent: MutationIntent, + cx: &mut Context, + ) { + if !source.read(cx).accepts_origin(&intent.origin, cx) { + return; + } + let Some(engine) = self.state.read(cx).engine().cloned() else { + source.update(cx, |files, cx| { + files.report_mutation_error("Workspace service is unavailable".into(), cx) + }); + return; + }; + let surfaces = self + .files + .values() + .chain(self.file_surfaces.values()) + .filter(|surface| surface.read(cx).shares_workspace(&intent.origin)) + .cloned() + .collect::>(); + if surfaces + .iter() + .any(|surface| surface.read(cx).mutation_busy()) + { + source.update(cx, |files, cx| { + files.report_mutation_error("Another file operation is still running".into(), cx) + }); + return; + } + for surface in &surfaces { + surface.update(cx, |files, cx| files.prepare_mutation(intent.clone(), cx)); + } + let waited_for_save = surfaces + .iter() + .any(|surface| surface.read(cx).mutation_has_save()); + let client = WorkspaceFilesClient::new(engine, intent.origin.context.clone()); + cx.spawn(async move |shell, cx| { + let result: Result = async { + let mut ready = false; + for _ in 0..400 { + if !surfaces + .iter() + .any(|surface| surface.read_with(cx, |files, _| files.mutation_has_save())) + { + ready = true; + break; + } + cx.background_executor() + .timer(Duration::from_millis(25)) + .await; + } + if !ready { + return Err("Wait for the pending save to finish and try again".into()); + } + if !source.read_with(cx, |files, cx| files.accepts_origin(&intent.origin, cx)) { + return Err("Workspace changed before the operation started".into()); + } + // Only refresh the source revision when our own save was awaited. + // Otherwise an external edit must cause SourceChanged, not consent. + if waited_for_save { + let page = client + .list_directory_snapshot( + zeron_proto::ListWorkspaceDirectoryRequest { + target: intent.origin.context.target.clone(), + directory: crate::files::model::parent_path(&intent.entry.path) + .unwrap_or_default(), + include_ignored: true, + cursor: None, + }, + &[intent.entry.path.clone()], + ) + .await + .map_err(|e| e.to_string())?; + if page.checkout_id != intent.origin.checkout_id { + return Err("Workspace changed while saving".into()); + } + let entry = page + .entries + .into_iter() + .find(|entry| entry.path == intent.entry.path) + .ok_or("Source no longer exists")?; + intent.entry.mutation_revision = entry.mutation_revision; + } + let owner_active = shell + .update(cx, |shell, cx| { + matches!(shell.route, Route::Chat) + && intent.origin.context.target.chat_id.as_deref() + == Some(shell.panel_key(cx).as_str()) + }) + .unwrap_or(false); + if !owner_active + || !source.read_with(cx, |files, cx| files.accepts_origin(&intent.origin, cx)) + { + return Err("File operation cancelled after switching sessions".into()); + } + let checkout = intent + .origin + .checkout_id + .clone() + .ok_or("Workspace identity unavailable")?; + let revision = intent + .entry + .mutation_revision + .clone() + .ok_or("Refresh the tree before trying again")?; + if let Some(destination) = &intent.destination { + client + .move_entry(MoveWorkspaceEntryRequest { + target: intent.origin.context.target.clone(), + operation_id: intent.operation_id.clone(), + expected_checkout_id: checkout, + source_path: intent.entry.path.clone(), + destination_path: destination.clone(), + expected_source_revision: revision, + expected_kind: intent.entry.kind, + }) + .await + .map_err(|e| e.to_string()) + } else { + client + .delete_entry(DeleteWorkspaceEntryRequest { + target: intent.origin.context.target.clone(), + operation_id: intent.operation_id.clone(), + expected_checkout_id: checkout, + path: intent.entry.path.clone(), + expected_source_revision: revision, + expected_kind: intent.entry.kind, + recursive: intent.entry.kind + == zeron_proto::WorkspaceEntryKind::Directory, + }) + .await + .map_err(|e| e.to_string()) + } + } + .await; + for surface in &surfaces { + let _ = surface.update(cx, |files, cx| files.finish_mutation(&intent, &result, cx)); + } + }) + .detach(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui::{AppContext, TestAppContext, VisualTestContext}; + use std::{cell::RefCell, rc::Rc}; + + struct DragHost { + shell: Entity, + files: Entity, + _directory: tempfile::TempDir, + } + impl Render for DragHost { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let main = self + .shell + .update(cx, |shell, cx| shell.render_main(window, 600., 600., cx)); + div() + .flex() + .w(px(900.)) + .h(px(600.)) + .child( + div() + .w(px(280.)) + .h_full() + .flex_none() + .child(self.files.clone()), + ) + .child(main) + } + } + fn setup( + cx: &mut TestAppContext, + ) -> (Entity, Entity, &mut VisualTestContext) { + cx.update(|cx| { + gpui_base::init(cx); + cx.set_global(Theme::dark()); + crate::app_menus::init(cx); + }); + let (host, cx) = cx.add_window_view(|_, cx| { + let directory = tempfile::tempdir().unwrap(); + let state = cx.new(|_| { + let mut state = crate::files::test_support::state(); + state.selected_chat = Some("chat".into()); + state.no_project = true; + state + }); + let files = cx.new(|cx| crate::files::test_support::explorer(state.clone(), cx)); + let shell = cx.new(|cx| { + let mut shell = Shell::new( + state, + EngineBootConfig { + data_dir: directory.path().into(), + ipc_port: 0, + edge_url: "http://127.0.0.1:1".into(), + edge_token: None, + org_id: None, + workos_client_id: None, + default_harness: zeron_proto::HarnessId::Mock, + }, + cx, + ); + shell.active_chat = "chat".into(); + shell.route = Route::Chat; + shell.files.insert("chat".into(), files.clone()); + shell + }); + DragHost { + shell, + files, + _directory: directory, + } + }); + let (shell, files) = host.read_with(cx, |host, _| (host.shell.clone(), host.files.clone())); + cx.update(|window, cx| { + window.activate_window(); + window.draw(cx).clear(); + }); + (shell, files, cx) + } + + #[gpui::test] + fn tree_and_production_chat_dropzones_are_exclusive(cx: &mut TestAppContext) { + let (shell, files, cx) = setup(cx); + let events = Rc::new(RefCell::new(Vec::new())); + let recorded = events.clone(); + let _sub = cx.update(|_, cx| { + cx.subscribe(&files, move |_, event, _| { + recorded.borrow_mut().push(event.clone()) + }) + }); + let input = shell.read_with(cx, |shell, cx| shell.composer.read(cx).input.clone()); + input.update(cx, |input, cx| input.set_text("keep this ", cx)); + cx.update(|window, cx| window.draw(cx).clear()); + let start = cx.debug_bounds("tree-entry:a.txt").unwrap().center(); + let folder = cx.debug_bounds("tree-entry:folder").unwrap().center(); + let chat = cx.debug_bounds("chat-dropzone").unwrap().center(); + // Visit the tree target, then finish over the actual conversation. + cx.simulate_mouse_down(start, MouseButton::Left, gpui::Modifiers::default()); + cx.simulate_mouse_move( + start + gpui::point(px(9.), px(0.)), + Some(MouseButton::Left), + gpui::Modifiers::default(), + ); + cx.simulate_mouse_move(folder, Some(MouseButton::Left), gpui::Modifiers::default()); + cx.simulate_mouse_move(chat, Some(MouseButton::Left), gpui::Modifiers::default()); + cx.simulate_mouse_up(chat, MouseButton::Left, gpui::Modifiers::default()); + cx.run_until_parked(); + let text = input.read_with(cx, |input, _| input.text().to_string()); + assert!(text.contains("keep this")); + assert!(text.contains("a.txt")); + assert!( + !events + .borrow() + .iter() + .any(|event| matches!(event, FilesEvent::Mutate(_))) + ); + // Visit the chat, then finish over the tree target. + cx.simulate_mouse_down(start, MouseButton::Left, gpui::Modifiers::default()); + cx.simulate_mouse_move( + start + gpui::point(px(9.), px(0.)), + Some(MouseButton::Left), + gpui::Modifiers::default(), + ); + cx.simulate_mouse_move(chat, Some(MouseButton::Left), gpui::Modifiers::default()); + cx.simulate_mouse_move(folder, Some(MouseButton::Left), gpui::Modifiers::default()); + cx.simulate_mouse_up(folder, MouseButton::Left, gpui::Modifiers::default()); + cx.run_until_parked(); + assert_eq!( + input.read_with(cx, |input, _| input.text().to_string()), + text + ); + assert_eq!( + events + .borrow() + .iter() + .filter(|event| matches!(event, FilesEvent::Mutate(_))) + .count(), + 1 + ); + cx.update(|_, cx| assert!(!cx.has_active_drag())); + } + + #[gpui::test] + fn stale_workspace_reference_cannot_attach_to_a_new_chat(cx: &mut TestAppContext) { + let (shell, files, cx) = setup(cx); + let origin = files + .read_with(cx, |files, cx| files.interaction_origin(cx)) + .unwrap(); + let payload = WorkspacePathDrag::new("a.txt".into(), false).with_origin( + Some(origin), + crate::files::WorkspacePathSource::Tree, + Some("rev".into()), + ); + shell.update_in(cx, |shell, window, cx| { + shell.active_chat = "other".into(); + let input = shell.composer.read(cx).input.clone(); + let before = input.read(cx).text().to_string(); + let composer = shell.composer.clone(); + shell.attach_workspace_drag(&payload, &composer, window, cx); + assert_eq!(input.read(cx).text(), before); + }); + } +} diff --git a/crates/ui/src/shell/files_panel.rs b/crates/ui/src/shell/files_panel.rs index 8cd01cb49f..46164330ef 100644 --- a/crates/ui/src/shell/files_panel.rs +++ b/crates/ui/src/shell/files_panel.rs @@ -172,6 +172,37 @@ impl Shell { &files, window, move |this: &mut Self, source, event, window, cx| match event { + FilesEvent::HoldMutation { origin, path } => { + let surfaces = this + .files + .values() + .chain(this.file_surfaces.values()) + .filter(|s| s.read(cx).shares_workspace(origin)) + .cloned() + .collect::>(); + for surface in surfaces { + surface.update(cx, |files, cx| files.hold_mutation(path.clone(), cx)); + } + } + FilesEvent::AddToChat { + path, + is_directory, + origin, + } => { + let payload = WorkspacePathDrag::new(path.clone(), *is_directory) + .with_origin( + Some(origin.clone()), + crate::files::WorkspacePathSource::Tree, + None, + ); + let composer = this.composer.clone(); + this.attach_workspace_drag(&payload, &composer, window, cx); + } + FilesEvent::Mutate(intent) + if this.accepts_file_navigation(&owner, &source, cx) => + { + this.start_file_mutation(source.clone(), intent.clone(), cx); + } FilesEvent::OpenFile(path) if this.accepts_file_navigation(&owner, &source, cx) => { @@ -201,6 +232,9 @@ impl Shell { cx, ), FilesEvent::OpenChildChat(chat_id) => this.open_child_chat_tab(chat_id, cx), + FilesEvent::RenameChildChat(chat_id) => { + this.open_rename_chat(chat_id.clone(), cx) + } FilesEvent::ChildChatContextMenu { chat_id, position } => { this.chat_menu.open(ChatMenuState { chat_id: chat_id.clone(), @@ -242,6 +276,9 @@ impl Shell { if !self.files_panel_open(cx) { return; } + if let Some(files) = self.files.get(&self.panel_key(cx)).cloned() { + files.update(cx, |files, cx| files.suspend_tree_interactions(cx)); + } let from = self.files_visible_width(cx); self.panels .update(&self.panel_key(cx), |p| p.files_open = false); diff --git a/crates/ui/src/shell/navigation_focus.rs b/crates/ui/src/shell/navigation_focus.rs index a20ce5b0d5..5c80ab3fa2 100644 --- a/crates/ui/src/shell/navigation_focus.rs +++ b/crates/ui/src/shell/navigation_focus.rs @@ -95,6 +95,8 @@ impl Shell { self.navigation_focus.right.clone(), self.navigation_focus.right.clone(), ) + } else if self.voice.read(cx).stage_open { + (self.voice_stage_focus.clone(), self.unfocused.clone()) } else { (self.composer.focus_handle(cx), self.unfocused.clone()) } diff --git a/crates/ui/src/shell/project_icon.rs b/crates/ui/src/shell/project_icon.rs index 2d2347b9ca..f9e2471dc0 100644 --- a/crates/ui/src/shell/project_icon.rs +++ b/crates/ui/src/shell/project_icon.rs @@ -288,8 +288,9 @@ impl Shell { ) -> AnyElement { let state = self.state.read(cx); let chat = state.chats.iter().find(|chat| chat.id == chat_id); - let space = chat.and_then(|chat| state.space_for_chat(chat)); - let name = space + // The tooltip names this row's own project; the artwork is shared. + let name = chat + .and_then(|chat| state.space_for_chat(chat)) .map(|space| space.display_name().to_string()) .unwrap_or_else(|| "Home".into()); // Same fallback as the row's "@ device" fragment. @@ -297,10 +298,55 @@ impl Shell { .and_then(|chat| state.device_name(&chat.device_id)) .unwrap_or("Unknown device") .to_string(); - let seed = space + let art = self.project_icon_art(chat_id, selected, cx); + project_icon_frame(chat_id, &name, &device, size, art) + } + + /// A project group header's icon: the same artwork its rows would wear, + /// without the tooltip — the label beside it already names the project. + pub(super) fn render_project_group_icon( + &self, + chat_id: &str, + size: f32, + cx: &mut Context, + ) -> AnyElement { + div() + .size(px(size)) + .flex_none() + .child(self.project_icon_art(chat_id, false, cx)) + .into_any_element() + } + + /// Artwork for a chat's project, shared by every space of the project: + /// the representative's name and color, read from this device's checkout + /// when there is one (no round trip), else from the representative. + fn project_icon_art( + &self, + chat_id: &str, + selected: bool, + cx: &mut Context, + ) -> AnyElement { + let state = self.state.read(cx); + let space = state + .chats + .iter() + .find(|chat| chat.id == chat_id) + .and_then(|chat| state.space_for_chat(chat)); + let representative = space.map(|space| state.representative_space(space)); + let name = representative + .map(|space| space.display_name().to_string()) + .unwrap_or_else(|| "Home".into()); + let seed = representative .map(|space| space.path.clone()) .unwrap_or_else(|| "home".into()); - let context = space.map(|space| FilesRequestContext { + let artwork = space.map(|space| { + state + .project_members(space) + .into_iter() + .find(|member| state.local_device_id.as_deref() == Some(&member.device_id)) + .unwrap_or_else(|| state.representative_space(space)) + }); + let context = artwork.map(|space| FilesRequestContext { target: zeron_proto::WorkspaceTarget { chat_id: None, space_id: Some(space.id.clone()), @@ -312,13 +358,7 @@ impl Shell { checkout_id: space.checkout_id.clone(), }); let Some(context) = context else { - return project_icon_frame( - chat_id, - &name, - &device, - size, - monogram(&name, &seed, selected, Theme::of(cx)), - ); + return monogram(&name, &seed, selected, Theme::of(cx)); }; let key = format!( "{:?}:{:?}:{}:{:?}:{}", @@ -331,13 +371,7 @@ impl Shell { let engine = state.engine().cloned(); // Don't cache a remote miss before a connection exists. if context.target_device_id.is_some() && engine.is_none() { - return project_icon_frame( - chat_id, - &name, - &device, - size, - monogram(&name, &seed, selected, Theme::of(cx)), - ); + return monogram(&name, &seed, selected, Theme::of(cx)); } let mut cache = self.project_icons.borrow_mut(); cache.retain(|_, entity| entity.read(cx).refreshed.elapsed() < Duration::from_secs(300)); @@ -355,15 +389,9 @@ impl Shell { .clone(); drop(cache); if entity.read(cx).media.is_none() { - return project_icon_frame( - chat_id, - &name, - &device, - size, - monogram(&name, &seed, selected, Theme::of(cx)), - ); + return monogram(&name, &seed, selected, Theme::of(cx)); } - project_icon_frame(chat_id, &name, &device, size, entity) + entity.into_any_element() } } diff --git a/crates/ui/src/shell/sidebar_sections.rs b/crates/ui/src/shell/sidebar_sections.rs index acc66a453d..e1f18a2c85 100644 --- a/crates/ui/src/shell/sidebar_sections.rs +++ b/crates/ui/src/shell/sidebar_sections.rs @@ -265,6 +265,7 @@ impl Shell { + SIDEBAR_LIST_GAP * rows.len().saturating_sub(1) as f32 }; let motion_key = format!("custom:{id}"); + self.begin_queued_sidebar_reveal(&motion_key, open, height); let hover_id = id.clone(); let toggle_id = id.clone(); let toggle_motion = motion_key.clone(); diff --git a/crates/ui/src/shell/spaces.rs b/crates/ui/src/shell/spaces.rs index 4614f4dd42..9f52147dad 100644 --- a/crates/ui/src/shell/spaces.rs +++ b/crates/ui/src/shell/spaces.rs @@ -918,6 +918,81 @@ mod pinned_session_tests { cx.run_until_parked(); } + #[gpui::test] + fn sidebar_projects_sharing_a_repository_group_only_by_project(cx: &mut gpui::TestAppContext) { + use super::*; + let dir = tempfile::tempdir().unwrap(); + let window = pin_test_shell(cx, dir.path()); + window + .update(cx, |shell, _, cx| { + shell.settings.sidebar_organization = SidebarOrganization::ByProject; + shell.state.update(cx, |state, _| { + state.workspace_scope = Some(WorkspaceScope::Local); + state.local_device_id = Some("local".into()); + // The same repository on this device and on another, + // plus an unrelated local project. + state.spaces = [ + ("remote-clone", "remote", Some("github.com/o/r"), 0), + ("local-clone", "local", Some("github.com/o/r"), 1), + ("other", "local", None, 2), + ] + .into_iter() + .map(|(id, device, repository, minutes)| { + serde_json::from_value(serde_json::json!({ + "id": id, "deviceId": device, "path": format!("/{device}/{id}"), + "name": id, "repositoryId": repository, + "createdAt": Utc::now() - chrono::Duration::minutes(10 - minutes), + })) + .unwrap() + }) + .collect(); + state.chats = [ + ("remote-new", "remote-clone", "remote"), + ("other-mid", "other", "local"), + ("local-old", "local-clone", "local"), + ] + .into_iter() + .enumerate() + .map(|(ix, (id, space, device))| { + let mut chat = pin_test_chat(id); + chat.space_id = Some(space.into()); + chat.device_id = device.into(); + chat.created_at = Utc::now() - chrono::Duration::minutes(ix as i64); + chat + }) + .collect(); + }); + assert_eq!( + shell.sidebar_visible_order(cx), + ids(&["remote-new", "local-old", "other-mid"]) + ); + // The oldest clone names the group on every device; this + // device's checkout leads the project's members. + let state = shell.state.read(cx); + let local_chat = state.chats.iter().find(|c| c.id == "local-old").unwrap(); + assert_eq!( + sidebar_project_group(state, local_chat), + ("repo:github.com/o/r".into(), "remote-clone".into()) + ); + let local_space = state.space_row("local-clone").unwrap(); + assert_eq!(state.representative_space(local_space).id, "remote-clone"); + let members: Vec<&str> = state + .project_members(local_space) + .into_iter() + .map(|space| space.id.as_str()) + .collect(); + assert_eq!(members, ["local-clone", "remote-clone"]); + + shell.settings.sidebar_organization = SidebarOrganization::ByDevice; + assert_eq!( + shell.sidebar_visible_order(cx), + ids(&["other-mid", "local-old", "remote-new"]) + ); + }) + .unwrap(); + cx.run_until_parked(); + } + #[gpui::test] fn sidebar_remote_pins_ignore_old_local_preferences(cx: &mut gpui::TestAppContext) { let dir = tempfile::tempdir().unwrap(); @@ -1239,7 +1314,10 @@ mod pinned_session_tests { let row = cx.debug_bounds("chat-older").unwrap(); let title = cx.debug_bounds("chat-title-older").unwrap(); cx.simulate_mouse_move(row.center(), None, gpui::Modifiers::default()); - assert!(cx.debug_bounds("chat-title-older").unwrap().size.width < title.size.width); + // The globe/archive slot is always reserved, so hovering swaps its + // glyph in place: the title keeps its width and the status and time + // columns never move. + assert_eq!(cx.debug_bounds("chat-title-older").unwrap().size.width, title.size.width); assert_eq!(cx.debug_bounds("chat-status-older").unwrap(), status); assert_eq!(cx.debug_bounds("chat-time-older").unwrap(), time); } @@ -1695,11 +1773,11 @@ mod pinned_session_tests { let row = cx.debug_bounds("chat-a").unwrap(); cx.simulate_mouse_move(row.center(), None, gpui::Modifiers::default()); - let pointer = cx.debug_bounds("chat-a-corner").unwrap().center(); + let pointer = cx.debug_bounds("chat-time-a").unwrap().center(); cx.simulate_mouse_move(pointer, None, gpui::Modifiers::default()); for (archived, next) in [ - ("a", Some(("b", "chat-b-corner"))), - ("b", Some(("c", "chat-c-corner"))), + ("a", Some(("b", "chat-time-b"))), + ("b", Some(("c", "chat-time-c"))), ("c", None), ] { cx.simulate_click(pointer, gpui::Modifiers::default()); @@ -2073,6 +2151,10 @@ const SIDEBAR_DISCLOSURE_SECTION_HEIGHT: f32 = SIDEBAR_SECTION_GAP + SIDEBAR_DISCLOSURE_HEADER_HEIGHT; pub(super) const SIDEBAR_DISCLOSURE_TWEEN_GRACE: std::time::Duration = std::time::Duration::from_millis(120); +/// The Archived shelf shows this many rows, then "Show more" pages by +/// [`ARCHIVED_PAGE_ROWS`]. +const ARCHIVED_INITIAL_ROWS: usize = 10; +const ARCHIVED_PAGE_ROWS: usize = 25; /// Put this machine's device group first without disturbing the recency-based /// order of any remote groups. A targeted promotion is more truthful than a @@ -2097,12 +2179,35 @@ fn promote_local_device_group( } } +/// Project-mode group of a chat as (key, label). Projects sharing a +/// repository identity fold into one group named for their representative +/// space; project-less sessions group per device and read as `~`. +fn sidebar_project_group(state: &AppState, chat: &zeron_proto::Chat) -> (String, String) { + match state.space_for_chat(chat) { + Some(space) => ( + zeron_proto::view::project_key(space), + state.representative_space(space).display_name().to_string(), + ), + None => match chat.space_id.clone() { + Some(space_id) => (space_id, "?".to_string()), + None => (format!("home:{}", chat.device_id), "~".to_string()), + }, + } +} + /// Shared quiet rule for sidebar groups and palette sections. pub(super) fn sidebar_separator(theme: &Theme) -> gpui::Div { div().h(px(1.0)).bg(theme.border.opacity(0.6)) } -fn sidebar_disclosure_header(theme: &Theme, label: SharedString, chevron: AnyElement) -> gpui::Div { +/// `icon` leads the label — a project group's icon, which its rows then omit. +fn sidebar_disclosure_header( + theme: &Theme, + icon: Option, + label: SharedString, + action: Option, + chevron: AnyElement, +) -> gpui::Div { div() .flex() .flex_row() @@ -2111,6 +2216,7 @@ fn sidebar_disclosure_header(theme: &Theme, label: SharedString, chevron: AnyEle .h(px(SIDEBAR_DISCLOSURE_HEADER_HEIGHT)) .px(px(Theme::SPACE_SM)) .cursor_pointer() + .children(icon) .child(super::sidebar_faded_label( "sidebar-disclosure-label".into(), false, @@ -2121,6 +2227,7 @@ fn sidebar_disclosure_header(theme: &Theme, label: SharedString, chevron: AnyEle .child(label), )) .child(div().flex_1()) + .children(action) .child(chevron) } @@ -2227,10 +2334,14 @@ fn device_glyph(platform: &str) -> &'static str { } /// Segment-aware "is `path` at or under `base`" (`/media/a` is not under -/// `/media/ab`); a root base covers everything. +/// `/media/ab`); a root base covers everything. Either separator counts, so +/// Windows drive paths (`D:\` under `D:\`) work too. fn path_under(path: &str, base: &str) -> bool { - let base = base.trim_end_matches('/'); - base.is_empty() || path == base || path.starts_with(&format!("{base}/")) + let base = base.trim_end_matches(['/', '\\']); + base.is_empty() + || path + .strip_prefix(base) + .is_some_and(|rest| rest.is_empty() || rest.starts_with(['/', '\\'])) } /// The space-row Rename dialog (same shape as [`RenameChatDialog`]). @@ -3079,15 +3190,12 @@ impl Shell { return false; } let current_pins = self.sidebar_pins_for_profile(&drag.profile_key, cx); - let visible_ids: HashSet = self - .state - .read(cx) + let state = self.state.read(cx); + let in_filter = state.project_filter(drag.filter.as_deref()); + let visible_ids: HashSet = state .overview_chats(Utc::now()) .into_iter() - .filter(|(_, chat)| match &drag.filter { - Some(space_id) => chat.space_id.as_deref() == Some(space_id.as_str()), - None => true, - }) + .filter(|(_, chat)| in_filter(chat)) .map(|(_, chat)| chat.id.clone()) .collect(); let current_ids = current_pins @@ -3106,10 +3214,12 @@ impl Shell { } } - /// The filter's scrollable rows: "All projects", then spaces matching - /// the search (ranked — `popover::filter_indices`). "All" only shows on - /// an empty query (searching means hunting a space). The "New project…" - /// action is not a row here — the card renders it as a pinned footer. + /// The filter's scrollable rows: "All projects", then projects matching + /// the search (ranked — `popover::filter_indices`) — one row per + /// repository across devices, carrying its local-first checkout's id. "All" + /// only shows on an empty query (searching means hunting a project). The + /// "New project…" action is not a row here — the card renders it as a + /// pinned footer. fn spaces_menu_rows(&self, cx: &App) -> Vec { let query = self .spaces_menu @@ -3117,10 +3227,10 @@ impl Shell { .map(|menu| menu.search.read(cx).text().to_string()) .unwrap_or_default(); let state = self.state.read(cx); - let spaces = state.spaces_sorted(); - let names: Vec = spaces + let projects = state.projects(); + let names: Vec = projects .iter() - .map(|s| s.display_name().to_string()) + .map(|members| state.representative_space(members[0]).display_name().to_string()) .collect(); let mut rows: Vec = Vec::new(); if query.trim().is_empty() { @@ -3129,11 +3239,31 @@ impl Shell { rows.extend( popover::filter_indices(&query, &names) .into_iter() - .map(|ix| SpacesMenuRow::Space(spaces[ix].id.clone())), + .map(|ix| SpacesMenuRow::Space(projects[ix][0].id.clone())), ); rows } + /// The repository the sidebar filter names, if any — any checkout's row + /// selects (and reads as) the whole project. + fn space_filter_key(&self, cx: &App) -> Option { + let state = self.state.read(cx); + let id = self.settings.space_filter.as_deref()?; + Some( + state + .space_row(id) + .map_or_else(|| id.to_owned(), zeron_proto::view::project_key), + ) + } + + /// The project key of a menu row's space id. + fn space_row_key(&self, id: &str, cx: &App) -> String { + self.state + .read(cx) + .space_row(id) + .map_or_else(|| id.to_owned(), zeron_proto::view::project_key) + } + fn open_spaces_menu(&mut self, window: &mut Window, cx: &mut Context) { self.close_sidebar_view_menu(cx); // "PaletteSearch" context: ↑↓/⏎ stay unbound in the input and bubble @@ -3149,7 +3279,7 @@ impl Shell { } }); // The highlight starts ON the current filter row. - let current = self.settings.space_filter.clone(); + let current = self.space_filter_key(cx); let handle = search.read(cx).focus_handle(cx); self.spaces_menu.open(SpacesMenu { search, @@ -3164,9 +3294,9 @@ impl Shell { let rows = self.spaces_menu_rows(cx); let start = match ¤t { None => 0, - Some(id) => rows + Some(key) => rows .iter() - .position(|row| matches!(row, SpacesMenuRow::Space(s) if s == id)) + .position(|row| matches!(row, SpacesMenuRow::Space(s) if self.space_row_key(s, cx) == *key)) .unwrap_or(0), }; if let Some(menu) = self.spaces_menu.open_mut() { @@ -3792,7 +3922,7 @@ impl Shell { } /// The sidebar's space-filter row: current filter ("All projects" or the - /// space's name) + chevron, the dropdown floating beneath while open. + /// space's name), the dropdown floating beneath while open. /// Sits OUTSIDE the sidebar's scroll region so the float never clips. pub(super) fn render_spaces_filter( &mut self, @@ -3806,9 +3936,14 @@ impl Shell { let state = self.state.read(cx); match filter.as_deref().and_then(|id| state.space_row(id)) { Some(space) => { - let (tag, offline) = state.space_device_tag(space, Utc::now()); + let members = state.project_members(space); + let (tag, offline) = state.project_device_tag(&members, Utc::now()); ( - space.display_name().to_string().into(), + state + .representative_space(space) + .display_name() + .to_string() + .into(), Some((tag.into(), offline)), ) } @@ -3865,9 +4000,8 @@ impl Shell { .flex_none() .text_color(theme.text_muted), ) - // flex_1 pushes the caret to the trigger's right edge and gives - // long space names a bound to fade against; the "@ device" - // tag hugs the name inside it rather than sitting by the caret. + // flex_1 gives long space names a bound to fade against; the + // "@ device" tag hugs the name inside it. .child( div() .flex_1() @@ -3901,12 +4035,6 @@ impl Shell { ) }) }), - ) - .child( - icon(icons::ALT_ARROW_DOWN) - .size(px(14.0)) - .flex_none() - .text_color(theme.text_muted.opacity(0.6)), ); let trigger = if self.spaces_menu.get().is_some() { let closing = self.spaces_menu.closing_since(); @@ -4022,9 +4150,10 @@ impl Shell { let rows = self.spaces_menu_rows(cx); let scrollbar = popover::rail(self, "spaces-menu-scrollbar", theme, cx); let filter = self.settings.space_filter.clone(); - // Keep the host tag so projects with the same name on different - // devices remain distinguishable. Consume `rows` to avoid cloning - // the list children per frame. + let filter_key = self.space_filter_key(cx); + // Each row is a whole repository: its name, and the devices holding a + // checkout so same-named projects stay distinguishable. Consume `rows` + // to avoid cloning the list children per frame. let details: Vec<( SpacesMenuRow, SharedString, @@ -4042,28 +4171,35 @@ impl Shell { false, filter.is_none(), ), - SpacesMenuRow::Space(id) => { - let selected = filter.as_deref() == Some(id.as_str()); - match state.space_row(&id) { - Some(space) => { - let (tag, offline) = state.space_device_tag(space, Utc::now()); - ( - SpacesMenuRow::Space(id), - space.display_name().to_string().into(), - Some(tag.into()), - offline, - selected, - ) - } - None => ( + SpacesMenuRow::Space(id) => match state.space_row(&id) { + Some(space) => { + let key = zeron_proto::view::project_key(space); + let selected = filter_key.as_deref() == Some(key.as_str()); + let members = state.project_members(space); + let (tag, offline) = state.project_device_tag(&members, Utc::now()); + ( + SpacesMenuRow::Space(id), + state + .representative_space(space) + .display_name() + .to_string() + .into(), + Some(tag.into()), + offline, + selected, + ) + } + None => { + let selected = filter.as_deref() == Some(id.as_str()); + ( SpacesMenuRow::Space(id), SharedString::from("?"), None, false, selected, - ), + ) } - } + }, // spaces_menu_rows never yields this variant — the // footer is rendered by the card, not the list. SpacesMenuRow::AddSpace => unreachable!(), @@ -4254,9 +4390,7 @@ impl Shell { for chat in chats { let key = Some(( if self.settings.sidebar_organization == SidebarOrganization::ByProject { - chat.space_id - .clone() - .unwrap_or_else(|| format!("home:{}", chat.device_id)) + sidebar_project_group(state, &chat).0 } else { chat.device_id.clone() }, @@ -4298,6 +4432,123 @@ impl Shell { visible } + /// The group a chat's row sits under when the sidebar groups by device or + /// by project; `None` in one list. + fn sidebar_group_key(&self, state: &AppState, chat: &zeron_proto::Chat) -> Option { + match self.settings.sidebar_organization { + SidebarOrganization::ByDevice => Some(chat.device_id.clone()), + SidebarOrganization::ByProject => Some(sidebar_project_group(state, chat).0), + SidebarOrganization::InOneList => None, + } + } + + /// A device/project group's key in `sidebar_collapsed_groups`. + fn sidebar_group_collapse_key(&self, group: &str) -> String { + let organization = match self.settings.sidebar_organization { + SidebarOrganization::ByDevice => "device", + SidebarOrganization::ByProject => "project", + SidebarOrganization::InOneList => "list", + }; + format!("{organization}:{group}") + } + + /// Open whatever hides `chat_id`'s sidebar row — the sidebar itself, the + /// Pinned / Sessions / Archived disclosure, its custom section or group, + /// the Archived page — so an inline rename has a row to draw on. Each + /// disclosure opens with its usual motion. False when the row cannot be + /// shown: the project filter hides it (the filter is the user's to + /// clear), or a synced section could not be expanded. Either way the + /// sidebar notice says why. + pub(super) fn reveal_sidebar_chat(&mut self, chat_id: &str, cx: &mut Context) -> bool { + let (active, archived) = { + let state = self.state.read(cx); + let active = state + .sidebar_chats(Utc::now(), self.settings.space_filter.as_deref()) + .into_iter() + .find(|(_, chat)| chat.id == chat_id) + .map(|(_, chat)| chat.clone()); + let archived = self + .archived_sidebar_chats(cx) + .iter() + .position(|chat| chat.id == chat_id); + (active, archived) + }; + if active.is_none() && archived.is_none() { + self.sidebar_notice = Some("Clear the project filter to rename this session".into()); + return false; + } + if let Some(chat) = &active { + if self.active_sidebar_pins(cx).contains(&chat.id) { + if !self.pinned_open { + self.pinned_open = true; + self.queue_sidebar_reveal("pinned"); + } + } else if let Some(section) = self + .active_sidebar_sections(cx) + .into_iter() + .find(|section| section.session_ids.contains(&chat.id)) + { + if section.collapsed { + if !self.change_sidebar_section( + zeron_proto::SidebarSectionChange::Collapse { + id: section.id.clone(), + collapsed: false, + }, + cx, + ) { + return false; + } + self.queue_sidebar_reveal(&format!("custom:{}", section.id)); + } + } else if let Some(group) = self.sidebar_group_key(self.state.read(cx), chat) { + let collapse_key = self.sidebar_group_collapse_key(&group); + if self.sidebar_collapsed_groups.remove(&collapse_key) { + self.queue_sidebar_reveal(&format!("group:{collapse_key}")); + } + } else if !self.sessions_open { + self.sessions_open = true; + self.queue_sidebar_reveal("sessions"); + } + } else if let Some(index) = archived { + if !self.archived_open { + self.archived_open = true; + self.archived_shown = ARCHIVED_INITIAL_ROWS; + self.queue_sidebar_reveal("archived"); + } + // Page the shelf just far enough to include the row, in the + // same steps "Show more" takes. + let shown = self.archived_shown.max(ARCHIVED_INITIAL_ROWS); + if index >= shown { + self.archived_shown = ARCHIVED_INITIAL_ROWS + + (index + 1 - ARCHIVED_INITIAL_ROWS).div_ceil(ARCHIVED_PAGE_ROWS) + * ARCHIVED_PAGE_ROWS; + } + } + if self.settings.sidebar_collapsed { + self.toggle_sidebar(cx); + } + cx.notify(); + true + } + + /// Mark a disclosure opened for a reveal: its motion starts on the next + /// render, where the body height is known. Like a header click, it + /// replaces the resort glide for this movement. + fn queue_sidebar_reveal(&mut self, key: &str) { + self.sidebar_reveal_motions.insert(key.to_owned()); + self.sidebar_prev_order.clear(); + self.sidebar_resort.clear(); + self.sidebar_new_keys.clear(); + } + + /// Start a revealed disclosure's opening motion now that its height is + /// known. + pub(super) fn begin_queued_sidebar_reveal(&mut self, key: &str, open: bool, body_height: f32) { + if open && self.sidebar_reveal_motions.remove(key) { + self.begin_sidebar_disclosure_motion(key, 0.0, body_height); + } + } + /// Shared metadata and visibility settings for active and archived sessions. fn sidebar_chat_data( &self, @@ -4317,7 +4568,7 @@ impl Shell { .device_name(&chat.device_id) .unwrap_or("Unknown device") .to_string(); - let mut folder = project.clone(); + let mut folder = project; // Unknown device → no fragment, same as the archived list. if state.device_name(&chat.device_id).is_some() { folder = format!("{folder} @ {device}"); @@ -4335,12 +4586,7 @@ impl Shell { .filter(|_| self.settings.sidebar_show_pull_request); let group = match self.settings.sidebar_organization { SidebarOrganization::ByDevice => Some((chat.device_id.clone(), device)), - SidebarOrganization::ByProject => Some(( - chat.space_id - .clone() - .unwrap_or_else(|| format!("home:{}", chat.device_id)), - project, - )), + SidebarOrganization::ByProject => Some(sidebar_project_group(state, &chat)), SidebarOrganization::InOneList => None, }; ActiveChatRow { @@ -4588,6 +4834,16 @@ impl Shell { .as_ref() .map_or_else(|| "regular".to_owned(), |(key, _)| format!("regular:{key}")) }; + // A project group wears the project icon on its header instead + // of repeating it on every row. + let project_group = self.settings.sidebar_organization + == SidebarOrganization::ByProject + && group + .as_ref() + .is_some_and(|(key, _)| !key.starts_with("section:")); + let header_icon_chat = (project_group && self.settings.sidebar_show_project_icon) + .then(|| rows.first().map(|row| row.chat.id.clone())) + .flatten(); let mut rendered_rows = Vec::with_capacity(rows.len()); for (group_index, row) in rows.into_iter().enumerate() { let ActiveChatRow { @@ -4672,6 +4928,7 @@ impl Shell { is_moving, if is_moving { None } else { drag }, jump_label, + self.settings.sidebar_show_project_icon && !project_group, None, theme, cx, @@ -4771,12 +5028,7 @@ impl Shell { continue; } } - let organization = match self.settings.sidebar_organization { - SidebarOrganization::ByDevice => "device", - SidebarOrganization::ByProject => "project", - SidebarOrganization::InOneList => "list", - }; - let collapse_key = format!("{organization}:{key}"); + let collapse_key = self.sidebar_group_collapse_key(&key); let motion_key = format!("group:{collapse_key}"); let collapsed = self.sidebar_collapsed_groups.contains(&collapse_key); let row_count = rendered_rows.len(); @@ -4788,6 +5040,7 @@ impl Shell { .map(|(_, height, _)| *height) .sum::() + SIDEBAR_LIST_GAP * row_count.saturating_sub(1) as f32; + self.begin_queued_sidebar_reveal(&motion_key, !collapsed, body_height); let body = div() .w_full() .flex() @@ -4811,7 +5064,60 @@ impl Shell { let chevron = self.sidebar_disclosure_chevron(&motion_key, !collapsed, theme); let toggle_key = collapse_key.clone(); let toggle_motion_key = motion_key.clone(); - let header = sidebar_disclosure_header(theme, visible_label, chevron) + let group_icon = header_icon_chat.map(|chat_id| { + self.render_project_group_icon(&chat_id, SIDEBAR_ACTIVE_HARNESS_ICON_SIZE, cx) + }); + // A real project's group gets a hover-revealed `+` that opens a new + // chat homed on it — on this device's checkout when it has one + // (the project picker's local-first order), never silently on a + // possibly offline machine just because it cloned first. The + // device chip still switches among the repository's checkouts. + let group_name = SharedString::from(format!("sidebar-group-hover-{collapse_key}")); + let group_space = (self.settings.sidebar_organization + == SidebarOrganization::ByProject) + .then(|| { + let state = self.state.read(cx); + state + .spaces + .iter() + .find(|space| zeron_proto::view::project_key(space) == key) + .and_then(|space| state.project_members(space).first().map(|m| m.id.clone())) + }) + .flatten(); + let new_chat_button = group_space.map(|project| { + div() + .id(SharedString::from(format!( + "sidebar-group-new-chat-{collapse_key}" + ))) + .size(px(20.0)) + .flex_none() + .flex() + .items_center() + .justify_center() + .rounded(px(4.0)) + .cursor_pointer() + .role(gpui::Role::Button) + .aria_label("New chat in project") + .opacity(0.0) + .group_hover(group_name.clone(), |style| style.opacity(1.0)) + .hover(|el| el.bg(theme.glass_hover())) + .on_click(cx.listener(move |this, _, _, cx| { + cx.stop_propagation(); + this.open_new_session(Some(project.clone()), cx); + })) + .tooltip(crate::settings::widgets::text_tooltip_above( + "New chat in project", + )) + .child( + icon(icons::PLUS) + .size(px(14.0)) + .text_color(theme.text_muted), + ) + .into_any_element() + }); + let header = + sidebar_disclosure_header(theme, group_icon, visible_label, new_chat_button, chevron) + .group(group_name) .id(SharedString::from(format!("sidebar-group-{collapse_key}"))) .on_click(cx.listener(move |this, _, _, cx| { let was_open = !this.sidebar_collapsed_groups.contains(&toggle_key); @@ -4864,13 +5170,14 @@ impl Shell { cx: &mut Context, ) -> AnyElement { let open = self.pinned_open; + self.begin_queued_sidebar_reveal("pinned", open, body_height); let label = if open { "Pinned".into() } else { format!("Pinned ({})", items.len()).into() }; let chevron = self.sidebar_disclosure_chevron("pinned", open, theme); - let header = sidebar_disclosure_header(theme, label, chevron) + let header = sidebar_disclosure_header(theme, None, label, None, chevron) .id("pinned-toggle") .debug_selector(|| "pinned-toggle".into()) .on_drag_move::(cx.listener( @@ -4944,13 +5251,14 @@ impl Shell { return content; } let open = self.sessions_open; + self.begin_queued_sidebar_reveal("sessions", open, body_height); let label = if open { "Sessions".into() } else { format!("Sessions ({count})").into() }; let chevron = self.sidebar_disclosure_chevron("sessions", open, theme); - let header = sidebar_disclosure_header(theme, label, chevron) + let header = sidebar_disclosure_header(theme, None, label, None, chevron) .id("sessions-toggle") .debug_selector(|| "sessions-toggle".into()) .on_drag_move::(cx.listener( @@ -5009,6 +5317,23 @@ impl Shell { .into_any_element() } + /// The Archived shelf's chats under the project filter, in sidebar order. + fn archived_sidebar_chats(&self, cx: &App) -> Vec { + let state = self.state.read(cx); + let in_filter = state.project_filter(self.settings.space_filter.as_deref()); + let mut rows: Vec = state + .chats + .iter() + // Spawned children stay out of the Archived section too — the + // same top-level rule as `visible_chats`. + .filter(|c| c.archived && c.is_top_level()) + .filter(|chat| in_filter(chat)) + .cloned() + .collect(); + rows.sort_by(|left, right| compare_sidebar_chats(self.settings.sidebar_sort, left, right)); + rows + } + /// Archived sessions share active-row data and layout, with a restore action. /// The shelf starts with ten sessions and pages by 25. pub(super) fn render_archived_section( @@ -5016,26 +5341,10 @@ impl Shell { theme: &Theme, cx: &mut Context, ) -> Option { - const INITIAL: usize = 10; - const PAGE: usize = 25; + const INITIAL: usize = ARCHIVED_INITIAL_ROWS; + const PAGE: usize = ARCHIVED_PAGE_ROWS; let now = Utc::now(); - let filter = self.settings.space_filter.clone(); - let mut rows: Vec = { - let state = self.state.read(cx); - state - .chats - .iter() - // Spawned children stay out of the Archived section too — the - // same top-level rule as `visible_chats`. - .filter(|c| c.archived && c.parent_chat_id.is_none()) - .filter(|chat| match &filter { - Some(space_id) => chat.space_id.as_deref() == Some(space_id.as_str()), - None => true, - }) - .cloned() - .collect() - }; - rows.sort_by(|left, right| compare_sidebar_chats(self.settings.sidebar_sort, left, right)); + let rows = self.archived_sidebar_chats(cx); if rows.is_empty() { return None; } @@ -5078,6 +5387,7 @@ impl Shell { } else { 0.0 }; + self.begin_queued_sidebar_reveal("archived", open, body_height); // Match Pinned: a muted label with a right-aligned disclosure chevron. // The count only shows while collapsed. let label: SharedString = if open { @@ -5086,7 +5396,7 @@ impl Shell { format!("Archived ({total})").into() }; let chevron = self.sidebar_disclosure_chevron("archived", open, theme); - let header = sidebar_disclosure_header(theme, label, chevron) + let header = sidebar_disclosure_header(theme, None, label, None, chevron) .id("archived-toggle") .on_click(cx.listener(move |this, _, _, cx| { let was_open = this.archived_open; @@ -5134,6 +5444,7 @@ impl Shell { false, None, None, + self.settings.sidebar_show_project_icon, None, theme, cx, @@ -5442,11 +5753,8 @@ impl Shell { }; if rows.is_empty() { let text = flow.search.read(cx).text().to_string(); - if text.starts_with('/') || text.starts_with('~') { - if let Some(target) = crate::pickers::typed_path_target(&text, flow.home.as_deref()) - { - self.add_space_descend(target, false, cx); - } + if let Some(target) = crate::pickers::typed_path_target(&text, flow.home.as_deref()) { + self.add_space_descend(target, false, cx); } return; } @@ -5480,16 +5788,17 @@ impl Shell { { return false; } - // A typed PATH jump: an absolute (`/disk2/`) or home-relative (`~/x/`) - // query browses that path directly — mounts at unconventional roots - // (and anywhere else) are reachable without a Locations row. Same - // trailing-`/` trigger as the folder-name descend below. + // A typed PATH jump: an absolute (`/disk2/`), drive-rooted (`D:\x\`) + // or home-relative (`~/x/`) query browses that path directly — mounts + // at unconventional roots (and anywhere else) are reachable without a + // Locations row. Same trailing-separator trigger as the folder-name + // descend below. { let Some(flow) = self.add_space.as_ref() else { return false; }; let text = flow.search.read(cx).text().to_string(); - if text.ends_with('/') && (text.starts_with('/') || text.starts_with('~')) { + if crate::pickers::is_typed_path(&text) && text.ends_with(['/', '\\']) { let target = crate::pickers::typed_path_target(&text, flow.home.as_deref()); let Some(target) = target else { // Path-shaped but unresolvable (`~/…` before home is @@ -5689,6 +5998,7 @@ impl Shell { git_detected, git_checked_at: None, checkout_id: None, + repository_id: None, created_at: Utc::now(), }; self.state.update(cx, |s, cx| { @@ -6803,6 +7113,18 @@ mod project_flow_tests { assert_eq!(deep, ["d", "e"]); } + #[test] + fn path_under_handles_posix_and_windows_drive_paths() { + assert!(path_under("/media/a", "/")); + assert!(path_under("/media/a", "/media")); + assert!(!path_under("/media/ab", "/media/a")); + // A drive-root crumb hides itself, not a sibling drive. + assert!(path_under(r"D:\", r"D:\")); + assert!(path_under(r"D:\Random", r"D:\")); + assert!(!path_under(r"D:\Random2", r"D:\Random")); + assert!(!path_under(r"C:\Random", r"D:\")); + } + #[gpui::test] fn devices_locations_folders_and_back_clear_stale_state(cx: &mut gpui::TestAppContext) { let data = tempfile::tempdir().unwrap(); diff --git a/crates/ui/src/shell/tabs.rs b/crates/ui/src/shell/tabs.rs index 8aa5e6a2a5..22b07f4929 100644 --- a/crates/ui/src/shell/tabs.rs +++ b/crates/ui/src/shell/tabs.rs @@ -110,7 +110,7 @@ impl Shell { || self.sync_flow.has_visible_overlay() || self.delete_confirm.is_some() || self.delete_space_confirm.is_some() - || self.rename_dialog.is_some() + || self.chat_rename.is_some() || self.rename_space_dialog.is_some() || self.discard_working_tree.is_some() || self.chat_menu.get().is_some() @@ -205,6 +205,9 @@ impl Shell { /// Open a session from the sidebar: select it, the main area follows. pub(crate) fn open_chat(&mut self, chat_id: String, cx: &mut Context) { self.command_palette = None; + // Opening any session steps the voice stage aside — including the one + // already selected under it. The call keeps running in the background. + self.set_voice_stage_open(false, cx); self.route = Route::Chat; self.focus_composer(cx); self.state @@ -213,14 +216,18 @@ impl Shell { } /// `+` in the titlebar: open the new-session canvas. A set sidebar filter - /// re-homes the canvas onto that project; under "All" the current pick - /// (the last selected project, restored from composer defaults) stands. + /// re-homes the canvas onto that project; under "All" the canvas reopens + /// on the project/device it was left at (see `AppState::canvas_target`). /// /// A new chat always starts with the terminal hidden: when the drawer is /// open it just hides (detach, not close — the source chat's tabs and /// PTYs survive for the return trip). - pub(super) fn open_new_session(&mut self, cx: &mut Context) { + /// + /// `project` (the per-project `+` on a sidebar group header) homes the + /// canvas on that project and wins over the sidebar filter. + pub(super) fn open_new_session(&mut self, project: Option, cx: &mut Context) { self.command_palette = None; + self.set_voice_stage_open(false, cx); self.route = Route::Chat; self.focus_composer(cx); // Pre-hide before the selection flips so the state change can't @@ -239,24 +246,26 @@ impl Shell { } let target = { let state = self.state.read(cx); - self.settings - .space_filter - .clone() + project + .or_else(|| self.settings.space_filter.clone()) .filter(|id| state.space_row(id).is_some()) }; let defaults = crate::settings::composer::ComposerDefaults::load(&self.data_dir); self.state.update(cx, |s, cx| { + // Leaving a chat puts the canvas's own target back; the filter + // (an explicit standing choice) then applies on top of it. + let restores = s.canvas_target.is_some(); + s.select_chat(None, cx); if target.is_some() { s.select_space(target, cx); - } else if defaults.no_project { - // Opening an existing project session (including boot's last - // session) must not erase the saved new-session opt-out. + } else if !restores && defaults.no_project { + // No canvas target was set aside (e.g. after a runtime swap): + // fall back to the saved new-session opt-out. s.select_space(None, cx); if let Some(device) = defaults.device { s.select_device(device, cx); } } - s.select_chat(None, cx); }); // The canvas never restores a drawer: a previously opened canvas // terminal must not pop open on a fresh new chat. @@ -396,6 +405,13 @@ impl Shell { let available_titlebar_width = (self.viewport_width - row_left - right_pad - trailing_width - row_gap * 3.0).max(0.0); + let running_subagents = { + let state = self.state.read(cx); + state + .selected_chat + .as_deref() + .map_or(0, |chat| state.running_subagents_for(chat, Utc::now())) + }; let trailing: Option = if on_canvas { None } else { @@ -496,13 +512,31 @@ impl Shell { .aria_label(files_panel_label) .when(self.files_panel_open(cx), |button| { button.bg(crate::theme::wash(0.09)) + }) + // Running subagents live in this panel: mark the + // button so they are findable while it is closed. + .when(running_subagents > 0, |button| { + crate::running_pill::mark_files_button( + button, + running_subagents, + &theme, + ) }), ) - .child(header_icon_button( + .child(header_icon_button_with( "toggle-changes", - icons::SIDEBAR_MINIMALISTIC, + icons::sidebar_glyph( + motion::state_t( + "toggle-changes", + right_pane_open, + motion::GLYPH_STATE, + self.reduced_motion, + ), + true, + 16.0, + theme.text_muted, + ), ShortcutId::ToggleChanges.label(), - &theme, cx.listener(|this, _, _, cx| this.toggle_right_pane(cx)), )), ) @@ -537,7 +571,7 @@ impl Shell { .child( header_icon_button( "session-fork", - icons::GIT_BRANCH, + icons::FORK, "Fork this session", &theme, cx.listener(|this, _, _, cx| this.create_side_chat(cx)), diff --git a/crates/ui/src/shell/voice_stage.rs b/crates/ui/src/shell/voice_stage.rs new file mode 100644 index 0000000000..5e1ed96de9 --- /dev/null +++ b/crates/ui/src/shell/voice_stage.rs @@ -0,0 +1,1120 @@ +//! The voice orchestrator's chrome: the sidebar footer trigger and the +//! full-window stage. A voice session follows no chat selection, so neither +//! surface lives inside the conversation column — the user moves between +//! threads freely while the orchestrator keeps listening. + +use super::*; +use zeron_proto::voice::{VoicePhase, VoiceWork}; + +/// The stage paints the hero preset magnified to fill the canvas. +pub(super) const VOICE_STAGE_ORB_SCALE: f32 = 2.25; +/// Stage orb footprint, including breathing room around the artwork. +const STAGE_ORB_BOX: f32 = 128.0 * VOICE_STAGE_ORB_SCALE + 112.0; +const STAGE_ENTER_MS: f32 = 460.0; +const STAGE_EXIT_MS: f32 = 220.0; +/// Captions show the tail of a long utterance rather than wrapping off-stage. +pub(super) const STAGE_CAPTION_CHARS: usize = 160; +const FOOTER_HOVER: &str = "voice-footer-orb"; +/// Call-bar geometry: round controls inside a floating pill. +const BAR_CONTROL: f32 = 44.0; +const BAR_PAD: f32 = 8.0; + +/// Stage visibility for a transition that flipped `elapsed_ms` ago. +pub(super) fn stage_reveal(open: bool, elapsed_ms: f32, reduced: bool) -> f32 { + match (open, reduced) { + (true, true) => 1.0, + (false, true) => 0.0, + (true, false) => motion::EASE_OUT_EXPO.eval(elapsed_ms / STAGE_ENTER_MS), + (false, false) => 1.0 - motion::EASE.eval(elapsed_ms / STAGE_EXIT_MS), + } +} + +impl Shell { + /// The footer microphone: a new orchestrator in a projectless Codex chat + /// on this device. The chat's model follows the composer when it already + /// targets Codex; otherwise Codex's own default model runs the delegations. + pub(super) fn start_voice(&mut self, cx: &mut Context) { + let state = self.state.read(cx); + let (Some(engine), Some(device)) = (state.engine().cloned(), state.local_device_id.clone()) + else { + return; + }; + let config = self + .composer + .read(cx) + .pickers() + .read(cx) + .resolved(cx) + .chat_config() + .filter(|config| config.harness == HarnessId::Codex) + .unwrap_or_else(|| ChatConfig { + harness: HarnessId::Codex, + model: None, + reasoning: None, + model_options: Default::default(), + sandbox: zeron_proto::SandboxLevel::WorkspaceWrite, + }); + let saved = settings::current(cx); + let device = saved.codex_voice_device.unwrap_or(device); + let voice = saved.codex_voice; + let host_name = state + .device_name(&device) + .unwrap_or("This device") + .to_owned(); + self.voice.update(cx, |controller, cx| { + controller.start(engine, device, config, voice, cx); + controller.host_name = Some(host_name); + controller.set_stage_open(true, cx); + }); + } + + /// Read whether this device's Codex can take a call; `force` re-reads it + /// after Settings → Agents changes or on coming back to a window that + /// hides voice (Codex may have been installed meanwhile). + pub(super) fn check_voice_codex(&mut self, force: bool, cx: &mut Context) { + let Some(engine) = self.state.read(cx).engine().cloned() else { + return; + }; + self.voice + .update(cx, |voice, cx| voice.check_codex(&engine, force, cx)); + } + + pub(super) fn end_voice(&mut self, cx: &mut Context) { + self.voice.update(cx, |voice, cx| voice.cancel(cx)); + } + + pub(super) fn set_voice_stage_open(&mut self, open: bool, cx: &mut Context) { + self.voice + .update(cx, |voice, cx| voice.set_stage_open(open, cx)); + } + + /// Track stage open/close flips for the transition clock and hand focus + /// over: the stage owns the keyboard while it is up. Navigating to + /// another thread or to Settings steps the stage aside; voice continues. + pub(super) fn sync_voice_stage(&mut self, window: &mut Window, cx: &mut Context) { + let selected = self.state.read(cx).selected_chat.clone(); + let navigating_to_settings = + self.route != self.voice_stage_route && matches!(self.route, Route::Settings(_)); + // Record every render, even with no visibility change: leaving and + // reentering the same settings page must count as navigation too. + self.voice_stage_route = self.route; + if self.voice.read(cx).stage_open + && self.voice_stage_was_open + && (navigating_to_settings || selected != self.voice_stage_selection) + { + self.set_voice_stage_open(false, cx); + } + let open = self.voice.read(cx).stage_open; + if open == self.voice_stage_was_open { + return; + } + self.voice_stage_was_open = open; + self.voice_stage_selection = selected; + self.voice_stage_changed_at = Some(std::time::Instant::now()); + let focus = if open { + self.voice_stage_focus.clone() + } else { + self.composer.focus_handle(cx) + }; + if window.is_window_active() { + window.focus(&focus, cx); + } + } + + pub(super) fn render_voice_trigger( + &mut self, + theme: &Theme, + cx: &mut Context, + ) -> Option { + self.check_voice_codex(false, cx); + let voice = self.voice.read(cx); + // Without Codex on this device a call could only fail. + if !voice.offered() { + return None; + } + let live = voice.is_live(); + let stage_open = voice.stage_open; + let failure = (voice.phase == VoicePhase::Failed) + .then(|| voice.reason_text()) + .filter(|text| !text.is_empty()); + let (orb_state, microphone, speaker) = ( + voice.orb_state(), + voice.microphone_level(), + voice.speaker_level(), + ); + let reduced = self.reduced_motion; + self.voice_footer_orb.update(cx, |orb, cx| { + orb.set_visible(live, cx); + orb.set_state(orb_state, cx); + orb.set_audio_levels(microphone, speaker, cx); + orb.set_reduced_motion(reduced, cx); + }); + + let button = div() + .id("voice-trigger") + .debug_selector(|| "voice-trigger".into()) + .role(gpui::Role::Button) + .tab_index(0) + .relative() + .size(px(SIDEBAR_FOOTER_BUTTON_SIZE)) + .flex_none() + .rounded(px(8.0)) + .flex() + .items_center() + .justify_center() + .cursor_pointer() + .bg(motion::hover_blend( + FOOTER_HOVER, + theme.glass_hover().opacity(0.0), + theme.glass_hover(), + )) + .on_hover(motion::hover_listener(FOOTER_HOVER)) + .focus_visible(|s| s.border_2().border_color(theme.accent)); + + if live { + return button + .aria_label(if stage_open { + "Hide voice" + } else { + "Open voice" + }) + .tooltip(stage_tooltip(if stage_open { + "Hide voice · Esc" + } else { + "Voice is live · open" + })) + .on_click( + cx.listener(move |this, _, _, cx| this.set_voice_stage_open(!stage_open, cx)), + ) + .child(self.voice_footer_orb.clone()) + .into_any_element() + .into(); + } + + let tooltip: SharedString = failure + .map(SharedString::from) + .unwrap_or_else(|| "Start voice · Codex orchestrator".into()); + let button = button + .aria_label("Start voice") + .tooltip(move |_, cx| { + let text = tooltip.clone(); + cx.new(|_| SurfaceTabTooltip { text }).into() + }) + .on_click(cx.listener(|this, _, _, cx| this.start_voice(cx))) + .child( + icon(icons::MICROPHONE) + .size(px(15.0)) + .text_color(motion::hover_blend( + FOOTER_HOVER, + theme.text_muted, + theme.text, + )), + ); + let Some(failure) = failure else { + return Some(button.into_any_element()); + }; + // A failed start explains itself once, anchored to the microphone; + // the next press retries. + let popup_theme = theme.for_popup(); + let card = popover::popover_card(&popup_theme) + .w(px(232.0)) + .flex() + .flex_row() + .items_start() + .gap(px(8.0)) + .child( + icon(icons::DANGER_TRIANGLE) + .mt(px(1.0)) + .size(px(14.0)) + .text_color(popup_theme.warning), + ) + .child( + div() + .flex_1() + .min_w_0() + .text_size(crate::typography::ui_rems(12.0)) + .text_color(popup_theme.text_muted) + .child(SharedString::from(failure)), + ) + .child( + div() + .id("voice-failure-dismiss") + .flex_none() + .cursor_pointer() + .rounded(px(4.0)) + .on_click(cx.listener(|this, _, _, cx| { + cx.stop_propagation(); + this.voice.update(cx, |voice, cx| voice.dismiss_reason(cx)); + })) + .child( + icon(icons::CLOSE) + .size(px(14.0)) + .text_color(popup_theme.text_faint), + ), + ) + .into_any_element(); + button + .child( + div() + .absolute() + .top(px(4.0)) + .right(px(4.0)) + .size(px(5.0)) + .rounded_full() + .bg(theme.warning), + ) + .child(popover::anchored_menu_above_end( + "voice-failure", + card, + None, + )) + .into_any_element() + .into() + } + + /// The live orb above the orchestrator chat's composer. Every other chat + /// keeps its plain composer; pressing the orb returns to the stage. + pub(super) fn render_voice_composer_orb( + &mut self, + composer_width: f32, + cx: &mut Context, + ) -> Option { + let voice = self.voice.read(cx); + let shown = matches!(self.route, Route::Chat) + && voice.is_live() + && voice.chat_id.is_some() + && voice.chat_id == self.state.read(cx).selected_chat; + let (orb_state, microphone, speaker) = ( + voice.orb_state(), + voice.microphone_level(), + voice.speaker_level(), + ); + let reduced = self.reduced_motion; + self.voice_composer_orb.update(cx, |orb, cx| { + orb.set_visible(shown, cx); + orb.set_state(orb_state, cx); + orb.set_audio_levels(microphone, speaker, cx); + orb.set_reduced_motion(reduced, cx); + }); + if !shown { + return None; + } + let orb = div() + .id("voice-composer-orb") + .debug_selector(|| "voice-composer-orb".into()) + .role(gpui::Role::Button) + .aria_label("Open voice") + .tab_index(0) + .rounded_full() + .cursor_pointer() + .tooltip(stage_tooltip("Open voice")) + .on_click(cx.listener(|this, _, _, cx| this.set_voice_stage_open(true, cx))) + .child(self.voice_composer_orb.clone()); + Some( + motion::fade_in( + "voice-composer-orb-in", + div() + .w_full() + .max_w(px(composer_width)) + .mx_auto() + .pb(px(4.0)) + .flex() + .justify_center() + .child(orb), + ) + .into_any_element(), + ) + } + + /// How far the stage has arrived: 0 hidden, 1 fully covering the page. + pub(super) fn voice_stage_reveal(&self, cx: &App) -> f32 { + let elapsed = self + .voice_stage_changed_at + .map_or(f32::INFINITY, |at| at.elapsed().as_secs_f32() * 1000.0); + stage_reveal(self.voice.read(cx).stage_open, elapsed, self.reduced_motion) + } + + /// Opacity for the page the stage covers. A glass stage has no fill, so + /// what it covers fades out and only the window frost shows through. + pub(super) fn voice_stage_underlay_opacity(&self, cx: &App) -> f32 { + if Theme::of(cx).is_glass() { + 1.0 - self.voice_stage_reveal(cx) + } else { + 1.0 + } + } + + /// Full-window stage: the session's orb over the new-thread hero artwork, + /// live caption, and the session controls. Escape returns to the chats + /// without ending voice. + pub(super) fn render_voice_stage( + &mut self, + window: &mut Window, + cx: &mut Context, + ) -> Option { + let open = self.voice.read(cx).stage_open; + let reveal = self.voice_stage_reveal(cx); + if reveal > 0.0 && reveal < 1.0 { + self.motion_active.set(true); + } + let voice = self.voice.read(cx); + let (orb_state, microphone, speaker) = ( + voice.orb_state(), + voice.microphone_level(), + voice.speaker_level(), + ); + let (caption_item, partial) = ( + voice.caption_item().map(str::to_owned), + voice.caption.text().to_owned(), + ); + let snapshot = voice.snapshot.clone(); + let chat_id = voice.chat_id.clone(); + let awaiting = snapshot + .as_ref() + .is_some_and(|s| s.work == VoiceWork::AwaitingInput); + let reduced = self.reduced_motion; + self.voice_stage_orb.update(cx, |orb, cx| { + orb.set_visible(open, cx); + orb.set_state(orb_state, cx); + orb.set_audio_levels(microphone, speaker, cx); + orb.set_reduced_motion(reduced, cx); + // The orb blooms out of the footer as the stage arrives. + orb.set_scale(VOICE_STAGE_ORB_SCALE * (0.82 + 0.18 * reveal), cx); + }); + if reveal <= 0.001 { + return None; + } + + // Streamed words veil in like transcript text; a new speaker turn + // fades the previous caption out (zeron-veil, shared with mobile). + let caption = self.voice_caption.advance( + caption_item.as_deref(), + &partial, + std::time::Instant::now(), + self.reduced_motion, + ); + if self.voice_caption.is_animating() { + self.motion_active.set(true); + } + + let theme = Theme::of(cx).clone(); + let viewport = window.viewport_size(); + // The sidebar stays usable beside the stage; collapsed, the stage + // spans the whole window. Settings always shows its own sidebar. + let left = if matches!(self.route, Route::Settings(_)) { + self.settings.sidebar_width + } else { + self.sidebar_now() + }; + let width = (f32::from(viewport.width) - left).max(0.0); + let ui_settings = settings::current(cx); + let artwork = ui_settings + .new_thread_composer_background + .as_ref() + .and_then(|background| { + crate::new_thread_background_effects::prepare( + ui_settings.new_thread_background_effect, + &theme, + std::path::Path::new(&background.path), + cx, + ) + }); + let adjustment = ui_settings + .new_thread_composer_background + .as_ref() + .map(|background| background.adjustment) + .unwrap_or_default(); + // The new-thread hero's artwork with only its soft bottom fade: no + // cutout around the orb, so no light channel splits the picture. + let hero = artwork.map(|artwork| { + div() + .absolute() + .top_0() + .left_0() + .w(px(width)) + .h(px(new_thread_background_height(f32::from(viewport.height)))) + .opacity(new_thread_background_opacity(theme.is_frost())) + .child( + gpui::canvas( + |_, _, _| {}, + move |bounds, _, window, _| { + crate::new_thread_background_mask::paint( + artwork.clone(), + bounds, + bounds, + adjustment, + false, + window, + ); + }, + ) + .absolute() + .inset_0(), + ) + }); + + let orb_block = div() + .relative() + .size(px(STAGE_ORB_BOX)) + .flex() + .items_center() + .justify_center() + .child(self.voice_stage_orb.clone()); + + let center = div() + .relative() + .top(px(18.0 * (1.0 - reveal))) + .flex() + .flex_col() + .items_center() + .child(orb_block) + .child( + div() + .relative() + .mt(px(10.0)) + .w(px(560.0)) + .max_w(px((width - 48.0).max(200.0))) + .h(px(48.0)) + .overflow_hidden() + .text_center() + .text_size(crate::typography::ui_rems(14.0)) + .line_height(px(22.0)) + .text_color(theme.text_muted) + .child(veiled_caption(caption.text, &caption.spans, &theme, window)) + .children(caption.previous.map(|(previous, opacity)| { + div() + .absolute() + .top_0() + .left_0() + .right_0() + .opacity(opacity) + .child(previous) + })), + ); + + let controls = self.render_voice_call_bar(&theme, awaiting, chat_id, cx); + // The stage occludes the conversation titlebar. Restore its native + // drag and double-click behavior above the stage artwork and controls. + let titlebar = self.titlebar_drag_region( + "voice-stage-titlebar", + div() + .absolute() + .top_0() + .left_0() + .right_0() + .h(px(Theme::TITLEBAR_HEIGHT)), + cx, + ); + + Some( + div() + .id("voice-stage") + .debug_selector(|| "voice-stage".into()) + .track_focus(&self.voice_stage_focus) + .absolute() + .top_0() + .bottom_0() + .right_0() + .left(px(left)) + .occlude() + .opacity(reveal) + // On glass the stage shows the window frost like the new-thread + // page; the page beneath fades out instead (see `render`). + .when(!theme.is_glass(), |stage| stage.bg(theme.bg)) + .on_key_down(cx.listener(|this, event: &gpui::KeyDownEvent, _, cx| { + let key = event.keystroke.key.as_str(); + let plain = !event.keystroke.modifiers.modified(); + if key == "escape" { + this.set_voice_stage_open(false, cx); + cx.stop_propagation(); + } else if key == "m" && plain { + this.voice.update(cx, |voice, cx| voice.toggle_mute(cx)); + cx.stop_propagation(); + } + })) + .child(div().absolute().inset_0().children(hero)) + .child( + div() + .absolute() + .inset_0() + .flex() + .flex_col() + .items_center() + .justify_center() + .pb(px(64.0)) + .child(center), + ) + .child( + div() + .absolute() + .left_0() + .right_0() + .bottom(px(32.0 - 16.0 * (1.0 - reveal))) + .flex() + .justify_center() + .child(controls), + ) + .child(titlebar) + .into_any_element(), + ) + } + + /// Video-call style bar: session time, the call controls grouped around + /// a red hang-up, and the way back to the chats. + fn render_voice_call_bar( + &mut self, + theme: &Theme, + awaiting: bool, + chat_id: Option, + cx: &mut Context, + ) -> AnyElement { + let popup = theme.for_popup(); + let voice = self.voice.read(cx); + let muted = voice.muted(); + let elapsed = voice + .active_since + .map(|since| crate::voice::format_elapsed(since.elapsed().as_secs())); + let live_tone = if awaiting { + popup.warning + } else if muted { + popup.text_faint + } else { + popup.success + }; + + // Session clock, like a meeting's running time. + let clock = div() + .flex() + .items_center() + .gap(px(8.0)) + .pl(px(10.0)) + .pr(px(6.0)) + .child(div().size(px(7.0)).rounded_full().bg(live_tone)) + .child( + div() + .min_w(px(40.0)) + .font_family(popup.font_mono.clone()) + .text_size(crate::typography::ui_rems(13.0)) + .text_color(popup.text_muted) + .child(SharedString::from(elapsed.unwrap_or_else(|| "–:––".into()))), + ); + + let clock = clock.children(voice.host_name.clone().map(|name| { + div() + .text_size(crate::typography::ui_rems(12.0)) + .text_color(popup.text_faint) + .child(name) + })); + + // Microphone: inverted plate while muted. + let mic = round_control("voice-bar-mic", &popup, muted) + .aria_label(if muted { + "Unmute microphone" + } else { + "Mute microphone" + }) + .tooltip(stage_tooltip(if muted { + "Unmute · M" + } else { + "Mute · M" + })) + .on_click(cx.listener(|this, _, _, cx| { + this.voice.update(cx, |voice, cx| voice.toggle_mute(cx)) + })) + .child( + icon(if muted { + icons::MICROPHONE_OFF + } else { + icons::MICROPHONE + }) + .size(px(19.0)) + .text_color(if muted { popup.on_solid } else { popup.text }), + ); + + let transcript = chat_id.map(|chat_id| { + round_control("voice-bar-transcript", &popup, false) + .aria_label(if awaiting { + "Answer Codex in the transcript" + } else { + "Open the voice transcript" + }) + .tooltip(stage_tooltip(if awaiting { + "Codex is asking you something" + } else { + "Transcript" + })) + .on_click(cx.listener(move |this, _, _, cx| this.open_chat(chat_id.clone(), cx))) + .child( + icon(icons::CHAT_ROUND_LINE) + .size(px(19.0)) + .text_color(if awaiting { popup.warning } else { popup.text }), + ) + .when(awaiting, |el| { + el.child( + div() + .absolute() + .top(px(9.0)) + .right(px(9.0)) + .size(px(8.0)) + .rounded_full() + .border_2() + .border_color(popover::surface_bg(&popup)) + .bg(popup.warning), + ) + }) + }); + + // The hang-up: the one saturated control, wide enough to never be + // mistaken for its neighbours. + let end = div() + .id("voice-bar-end") + .role(gpui::Role::Button) + .aria_label("End voice") + .tab_index(0) + .h(px(BAR_CONTROL)) + .px(px(20.0)) + .rounded_full() + .flex() + .items_center() + .gap(px(8.0)) + .cursor_pointer() + .bg(motion::hover_blend( + "voice-bar-end", + popup.danger, + popup.danger.blend(gpui::black().opacity(0.14)), + )) + .on_hover(motion::hover_listener("voice-bar-end")) + .focus_visible(|s| s.border_2().border_color(popup.accent)) + .tooltip(stage_tooltip("End voice")) + .on_click(cx.listener(|this, _, _, cx| this.end_voice(cx))) + .child( + icon(icons::PHONE_HANG_UP) + .size(px(19.0)) + .text_color(gpui::white()), + ) + .child( + div() + .text_size(crate::typography::ui_rems(13.0)) + .font_weight(gpui::FontWeight::SEMIBOLD) + .text_color(gpui::white()) + .child(SharedString::from("End")), + ); + + let back = round_control("voice-bar-back", &popup, false) + .aria_label("Back to chats") + .tooltip(stage_tooltip("Back to chats · Esc")) + .on_click(cx.listener(|this, _, _, cx| this.set_voice_stage_open(false, cx))) + .child( + icon(icons::ALT_ARROW_DOWN) + .size(px(19.0)) + .text_color(popup.text_muted), + ); + + let divider = || div().w(px(1.0)).h(px(24.0)).mx(px(4.0)).bg(popup.border); + let radius = BAR_CONTROL / 2.0 + BAR_PAD; + let bar = div() + .flex() + .items_center() + .gap(px(6.0)) + .p(px(BAR_PAD)) + .rounded(px(radius)) + .border_1() + .border_color(popup.border.opacity(0.7)) + .bg(popover::surface_bg(&popup)) + .when(!popup.is_frost(), |el| el.shadow_lg()) + .text_color(popup.text) + .child(clock) + .child(divider()) + .child(mic) + .children(transcript) + .child(end) + .child(divider()) + .child(back); + crate::frost::frosted(radius, 18.0, bar).into_any_element() + } +} + +/// The caption with its fading words recolored, paint-only (`apply_veil`). +fn veiled_caption( + text: String, + spans: &[zeron_veil::VeilSpan], + theme: &Theme, + window: &Window, +) -> gpui::StyledText { + let mut run = window.text_style().to_run(text.len()); + run.color = theme.text_muted; + let runs = crate::markdown::veil::apply_veil(vec![run], spans); + gpui::StyledText::new(text).with_runs(runs) +} + +fn stage_tooltip(text: &'static str) -> impl Fn(&mut Window, &mut App) -> gpui::AnyView + 'static { + move |_, cx| cx.new(|_| SurfaceTabTooltip { text: text.into() }).into() +} + +/// A round call-bar control; `active` inverts it onto the solid plate. +fn round_control(id: &'static str, theme: &Theme, active: bool) -> gpui::Stateful { + div() + .id(id) + .role(gpui::Role::Button) + .tab_index(0) + .relative() + .size(px(BAR_CONTROL)) + .flex_none() + .rounded_full() + .flex() + .items_center() + .justify_center() + .cursor_pointer() + .text_color(if active { theme.on_solid } else { theme.text }) + .bg(if active { + theme.solid + } else { + motion::hover_blend(id, theme.glass_hover().opacity(0.5), theme.glass_hover()) + }) + .on_hover(motion::hover_listener(id)) + .focus_visible(|s| s.border_2().border_color(theme.accent)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[gpui::test] + fn voice_stage_topbar_accepts_window_drag_presses(cx: &mut gpui::TestAppContext) { + struct StageHost(Entity); + impl Render for StageHost { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + self.0.update(cx, |shell, cx| { + div() + .size_full() + .relative() + .children(shell.render_voice_stage(window, cx)) + }) + } + } + + let dir = tempfile::tempdir().unwrap(); + cx.update(|cx| { + gpui_base::init(cx); + cx.set_global(Theme::default()); + crate::app_menus::init(cx); + settings::init(settings::UiSettings::default(), dir.path(), cx); + }); + let (host, cx) = cx.add_window_view(|_, cx| { + StageHost(cx.new(|cx| { + let state = cx.new(|_| AppState::new()); + let mut shell = Shell::new( + state, + EngineBootConfig { + data_dir: dir.path().into(), + ipc_port: 0, + edge_url: "http://127.0.0.1:1".into(), + edge_token: None, + org_id: None, + workos_client_id: None, + default_harness: HarnessId::Mock, + }, + cx, + ); + shell.reduced_motion = true; + shell.voice.update(cx, |voice, _| { + voice.phase = VoicePhase::Active; + voice.stage_open = true; + }); + shell + })) + }); + let shell = host.read_with(cx, |host, _| host.0.clone()); + cx.update(|window, cx| window.draw(cx).clear()); + let bounds = cx.debug_bounds("voice-stage").unwrap(); + let topbar = gpui::point( + bounds.center().x, + bounds.top() + px(Theme::TITLEBAR_HEIGHT / 2.0), + ); + cx.simulate_mouse_down(topbar, MouseButton::Left, gpui::Modifiers::default()); + shell.read_with(cx, |shell, _| assert!(shell.titlebar_should_move)); + cx.simulate_mouse_up(topbar, MouseButton::Left, gpui::Modifiers::default()); + shell.read_with(cx, |shell, _| assert!(!shell.titlebar_should_move)); + + let body = gpui::point( + bounds.center().x, + bounds.top() + px(Theme::TITLEBAR_HEIGHT + 32.0), + ); + cx.simulate_mouse_down(body, MouseButton::Left, gpui::Modifiers::default()); + shell.read_with(cx, |shell, _| assert!(!shell.titlebar_should_move)); + cx.simulate_mouse_up(body, MouseButton::Left, gpui::Modifiers::default()); + } + + #[gpui::test] + fn voice_stage_can_start_and_reopen_in_settings_until_navigation( + cx: &mut gpui::TestAppContext, + ) { + struct StageHost(Entity); + impl Render for StageHost { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + self.0.update(cx, |shell, cx| { + shell.sync_voice_stage(window, cx); + div() + .size_full() + .children(shell.render_voice_stage(window, cx)) + }) + } + } + + let dir = tempfile::tempdir().unwrap(); + cx.update(|cx| { + gpui_base::init(cx); + cx.set_global(Theme::default()); + crate::app_menus::init(cx); + settings::init(settings::UiSettings::default(), dir.path(), cx); + }); + let (host, cx) = cx.add_window_view(|_, cx| { + StageHost(cx.new(|cx| { + let state = cx.new(|_| AppState::new()); + let mut shell = Shell::new( + state, + EngineBootConfig { + data_dir: dir.path().into(), + ipc_port: 0, + edge_url: "http://127.0.0.1:1".into(), + edge_token: None, + org_id: None, + workos_client_id: None, + default_harness: HarnessId::Mock, + }, + cx, + ); + shell.reduced_motion = true; + shell.route = Route::Settings(SettingsSection::Voice); + // The initial stage opens while native voice is still preparing. + shell.voice.update(cx, |voice, cx| { + voice.phase = VoicePhase::Checking; + voice.set_stage_open(true, cx); + }); + shell + })) + }); + let shell = host.read_with(cx, |host, _| host.0.clone()); + let stays_open = |cx: &mut gpui::VisualTestContext| { + for _ in 0..3 { + cx.update(|window, cx| window.draw(cx).clear()); + shell.read_with(cx, |shell, cx| assert!(shell.voice.read(cx).stage_open)); + assert!(cx.debug_bounds("voice-stage").is_some()); + } + }; + stays_open(cx); + + shell.update(cx, |shell, cx| { + shell + .voice + .update(cx, |voice, _| voice.phase = VoicePhase::Active); + shell.set_voice_stage_open(false, cx); + }); + cx.update(|window, cx| window.draw(cx).clear()); + shell.update(cx, |shell, cx| shell.set_voice_stage_open(true, cx)); + stays_open(cx); + + // Choosing another settings page hides the stage without ending voice. + shell.update(cx, |shell, cx| { + shell.open_settings(SettingsSection::General, cx) + }); + cx.update(|window, cx| window.draw(cx).clear()); + shell.read_with(cx, |shell, cx| { + assert!(!shell.voice.read(cx).stage_open); + assert!(shell.voice.read(cx).is_live()); + }); + shell.update(cx, |shell, cx| shell.set_voice_stage_open(true, cx)); + stays_open(cx); + + // Leaving and reentering the same settings page is navigation too. + shell.update(cx, |shell, _| shell.route = Route::Chat); + cx.update(|window, cx| window.draw(cx).clear()); + shell.update(cx, |shell, cx| { + shell.open_settings(SettingsSection::General, cx) + }); + cx.update(|window, cx| window.draw(cx).clear()); + shell.read_with(cx, |shell, cx| { + assert!(!shell.voice.read(cx).stage_open); + assert!(shell.voice.read(cx).is_live()); + }); + + shell.update(cx, |shell, cx| shell.set_voice_stage_open(true, cx)); + stays_open(cx); + shell.update(cx, |shell, cx| { + shell.state.update(cx, |state, _| { + state.selected_chat = Some("another-chat".into()) + }); + }); + cx.update(|window, cx| window.draw(cx).clear()); + shell.read_with(cx, |shell, cx| { + assert!(!shell.voice.read(cx).stage_open); + assert!(shell.voice.read(cx).is_live()); + }); + } + + #[gpui::test] + fn opening_any_session_steps_the_stage_aside_without_ending_voice( + cx: &mut gpui::TestAppContext, + ) { + struct StageHost(Entity); + impl Render for StageHost { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + self.0.update(cx, |shell, cx| { + shell.sync_voice_stage(window, cx); + div() + .size_full() + .children(shell.render_voice_stage(window, cx)) + }) + } + } + + let dir = tempfile::tempdir().unwrap(); + cx.update(|cx| { + gpui_base::init(cx); + cx.set_global(Theme::default()); + crate::app_menus::init(cx); + settings::init(settings::UiSettings::default(), dir.path(), cx); + }); + let (host, cx) = cx.add_window_view(|_, cx| { + StageHost(cx.new(|cx| { + let state = cx.new(|_| AppState::new()); + let mut shell = Shell::new( + state, + EngineBootConfig { + data_dir: dir.path().into(), + ipc_port: 0, + edge_url: "http://127.0.0.1:1".into(), + edge_token: None, + org_id: None, + workos_client_id: None, + default_harness: HarnessId::Mock, + }, + cx, + ); + shell.reduced_motion = true; + shell + .state + .update(cx, |state, _| state.selected_chat = Some("current".into())); + shell.voice.update(cx, |voice, cx| { + voice.phase = VoicePhase::Active; + voice.set_stage_open(true, cx); + }); + shell + })) + }); + let shell = host.read_with(cx, |host, _| host.0.clone()); + let open_stage = |cx: &mut gpui::VisualTestContext| { + shell.update(cx, |shell, cx| shell.set_voice_stage_open(true, cx)); + cx.update(|window, cx| window.draw(cx).clear()); + shell.read_with(cx, |shell, cx| assert!(shell.voice.read(cx).stage_open)); + }; + let stepped_aside = |cx: &mut gpui::VisualTestContext, selected: &str| { + cx.update(|window, cx| window.draw(cx).clear()); + shell.read_with(cx, |shell, cx| { + assert!(!shell.voice.read(cx).stage_open); + assert!(shell.voice.read(cx).is_live(), "the call keeps running"); + assert_eq!(shell.route, Route::Chat); + assert_eq!( + shell.state.read(cx).selected_chat.as_deref(), + Some(selected) + ); + }); + }; + cx.update(|window, cx| window.draw(cx).clear()); + + // The session already under the stage: the selection doesn't change, + // so only an explicit step-aside shows it. + shell.update(cx, |shell, cx| shell.open_chat("current".into(), cx)); + stepped_aside(cx, "current"); + + // From Settings back to the session the stage was opened over. + shell.update(cx, |shell, _| { + shell.route = Route::Settings(SettingsSection::Voice) + }); + open_stage(cx); + shell.update(cx, |shell, cx| shell.open_chat("current".into(), cx)); + stepped_aside(cx, "current"); + + // Another session. + open_stage(cx); + shell.update(cx, |shell, cx| shell.open_chat("other".into(), cx)); + stepped_aside(cx, "other"); + } + + #[gpui::test] + fn composer_orb_shows_only_on_the_orchestrator_chat_and_returns_to_the_call( + cx: &mut gpui::TestAppContext, + ) { + struct OrbHost(Entity); + impl Render for OrbHost { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + self.0.update(cx, |shell, cx| { + div() + .size_full() + .children(shell.render_voice_composer_orb(600.0, cx)) + }) + } + } + + let dir = tempfile::tempdir().unwrap(); + cx.update(|cx| { + gpui_base::init(cx); + cx.set_global(Theme::default()); + crate::app_menus::init(cx); + settings::init(settings::UiSettings::default(), dir.path(), cx); + }); + let orchestrator = format!("{}1", zeron_proto::voice::ORCHESTRATOR_CHAT_PREFIX); + let (host, cx) = cx.add_window_view(|_, cx| { + OrbHost(cx.new(|cx| { + let state = cx.new(|_| AppState::new()); + let shell = Shell::new( + state, + EngineBootConfig { + data_dir: dir.path().into(), + ipc_port: 0, + edge_url: "http://127.0.0.1:1".into(), + edge_token: None, + org_id: None, + workos_client_id: None, + default_harness: HarnessId::Mock, + }, + cx, + ); + shell.voice.update(cx, |voice, _| { + voice.phase = VoicePhase::Active; + voice.chat_id = Some(orchestrator.clone()); + }); + shell + })) + }); + let shell = host.read_with(cx, |host, _| host.0.clone()); + let select = |chat: &str, cx: &mut gpui::VisualTestContext| { + let chat = chat.to_owned(); + shell.update(cx, |shell, cx| { + shell + .state + .update(cx, |state, _| state.selected_chat = Some(chat)); + }); + cx.update(|window, cx| window.draw(cx).clear()); + }; + + select("another-chat", cx); + assert!(cx.debug_bounds("voice-composer-orb").is_none()); + + select(&orchestrator, cx); + let orb = cx.debug_bounds("voice-composer-orb").unwrap(); + cx.simulate_click(orb.center(), gpui::Modifiers::default()); + shell.read_with(cx, |shell, cx| assert!(shell.voice.read(cx).stage_open)); + } + + #[test] + fn stage_reveal_eases_in_and_out_and_snaps_for_reduced_motion() { + assert_eq!(stage_reveal(true, 0.0, false), 0.0); + assert_eq!(stage_reveal(true, f32::INFINITY, false), 1.0); + assert!(stage_reveal(true, STAGE_ENTER_MS / 4.0, false) > 0.5); + assert_eq!(stage_reveal(false, 0.0, false), 1.0); + assert_eq!(stage_reveal(false, f32::INFINITY, false), 0.0); + assert_eq!(stage_reveal(true, 0.0, true), 1.0); + assert_eq!(stage_reveal(false, 0.0, true), 0.0); + } +} diff --git a/crates/ui/src/sound.rs b/crates/ui/src/sound.rs index 86002859bd..38b2d2d951 100644 --- a/crates/ui/src/sound.rs +++ b/crates/ui/src/sound.rs @@ -1,7 +1,8 @@ //! Session notification sounds — the herdr approach (state-transition chimes //! played through the platform's own audio CLI, zero Rust audio deps): //! -//! - embedded completion, input-request, attention and Appshot chimes; +//! - embedded completion, input-request, attention and Appshot chimes, and +//! the start and end of a voice call; //! - macOS Appshots use a preloaded native player; other cues write to a //! temp file and use the system player on a //! background thread: `afplay` (macOS), PowerShell `Media.SoundPlayer` @@ -25,6 +26,8 @@ static SOUND_APPSHOT: &[u8] = include_bytes!("../assets/sounds/appshot.wav"); static SOUND_DONE: &[u8] = include_bytes!("../assets/sounds/done.wav"); static SOUND_REQUEST: &[u8] = include_bytes!("../assets/sounds/request.wav"); static SOUND_ATTENTION: &[u8] = include_bytes!("../assets/sounds/attention.wav"); +static SOUND_VOICE_START: &[u8] = include_bytes!("../assets/sounds/voice-start.wav"); +static SOUND_VOICE_END: &[u8] = include_bytes!("../assets/sounds/voice-end.wav"); /// Which notification chime to play. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -48,6 +51,16 @@ pub fn play(sound: Sound) { play_in_background(data); } +/// A voice call starting (`true`) or ending. Only its two ends sound: the +/// caller plays each once per call, never in between. +pub fn play_voice(start: bool) { + play_in_background(if start { + SOUND_VOICE_START + } else { + SOUND_VOICE_END + }); +} + /// Confirm captured pixels with a soft shutter and clear chime. /// The caller honors the dedicated capture sound setting. pub fn play_appshot() { diff --git a/crates/ui/src/state.rs b/crates/ui/src/state.rs index a8618b4017..7d1234679a 100644 --- a/crates/ui/src/state.rs +++ b/crates/ui/src/state.rs @@ -123,12 +123,16 @@ pub enum EngineMode { trait EngineBackend: Send + Sync { fn client(&self) -> &RpcClient; fn mode(&self) -> EngineMode; + async fn media_client(&self) -> Result { + Err(RpcError::Failed("voice media unavailable".into())) + } /// Graceful teardown (drains runs / flushes docs for the in-process engine). async fn shutdown(&self); } /// Embedded engine: owns the [`EngineCore`] and an in-memory RPC loop. struct InProcessEngine { + service: Arc, runtime: Arc>>, boot_task: tokio::task::JoinHandle<()>, refresh_task: tokio::task::JoinHandle<()>, @@ -140,6 +144,9 @@ struct InProcessEngine { #[async_trait] impl EngineBackend for InProcessEngine { + async fn media_client(&self) -> Result { + Ok(memory_client(self.service.clone())) + } fn client(&self) -> &RpcClient { &self.client } @@ -244,6 +251,9 @@ struct RemoteEngine { #[async_trait] impl EngineBackend for RemoteEngine { + async fn media_client(&self) -> Result { + connect_ws(&self.url).await + } fn client(&self) -> &RpcClient { &self.client } @@ -345,7 +355,8 @@ impl EngineHandle { // // Best-effort — losing the bind race with another engine costs other // viewports, not this one. - let ipc_task = match zeron_engine::serve_ipc(engine_config.ipc_port, service).await { + let ipc_task = match zeron_engine::serve_ipc(engine_config.ipc_port, service.clone()).await + { Ok(task) => Some(task), Err(err) => { tracing::warn!( @@ -417,6 +428,7 @@ impl EngineHandle { }); let handle = EngineHandle { inner: Arc::new(InProcessEngine { + service, runtime, boot_task, refresh_task, @@ -530,6 +542,10 @@ impl EngineHandle { self.inner.mode() } + pub async fn media_client(&self) -> Result { + self.inner.media_client().await + } + pub fn engine_info(&self) -> &EngineInfo { &self.engine_info } @@ -582,9 +598,19 @@ async fn query_engine_info(client: &RpcClient) -> Result { pub use zeron_proto::view::{ ChatGroup, ConnectionStatus, GatePhase, Indicator, SESSION_STALE_MS, attention_rank, chat_location, display_status, effective_indicator, format_time_ago, gate_phase, group_chats, - parse_auth_state, project_label, sort_active, sort_chats, sort_spaces, sort_tabs, + parse_auth_state, project_label, running_subagents, sort_active, sort_chats, sort_spaces, sort_tabs, }; +/// What a session row shows that ticks with the clock rather than with its +/// content: the staleness-checked indicator and running-subagent count. A +/// change in either must repaint even when the row itself is untouched. +fn session_presence(session: &Session, now: DateTime) -> (Indicator, u32) { + ( + effective_indicator(Some(session), now), + running_subagents(Some(session), now), + ) +} + // --------------------------------------------------------------------------- // Org gate (pure) // --------------------------------------------------------------------------- @@ -644,6 +670,56 @@ struct PendingSend { /// incident fell into. pub const UNDELIVERED_GRACE_MS: i64 = 120_000; +/// The one-shot timer behind [`AppState::watch_clock_transitions`]. +#[derive(Default)] +struct ClockTransitions { + /// Re-arms the timer after every notify. + observation: Option, + /// The last time observers saw current state (a notify or a wake). + checked_at: Option>, + wake_at: Option>, + task: Option>, +} + +#[cfg(test)] +thread_local! { + static TEST_CLOCK: std::cell::Cell>> = const { std::cell::Cell::new(None) }; +} + +/// Wall-clock time for clock-driven state derivations. Tests pin it to the +/// simulated executor's timeline with [`TestClock`]. +pub(crate) fn clock_now() -> DateTime { + #[cfg(test)] + if let Some(now) = TEST_CLOCK.with(std::cell::Cell::get) { + return now; + } + Utc::now() +} + +/// Pins [`clock_now`] on this test thread until dropped. +#[cfg(test)] +pub(crate) struct TestClock; + +#[cfg(test)] +impl TestClock { + pub(crate) fn start(now: DateTime) -> Self { + let clock = Self; + clock.set(now); + clock + } + + pub(crate) fn set(&self, now: DateTime) { + TEST_CLOCK.with(|clock| clock.set(Some(now))); + } +} + +#[cfg(test)] +impl Drop for TestClock { + fn drop(&mut self) { + TEST_CLOCK.with(|clock| clock.set(None)); + } +} + /// A send's attachment-upload leg in flight. `done` is bumped by the upload /// task per completed chunk (binary bytes); the working label reads it every /// paint (the spinner already animates each frame), so no notify plumbing is @@ -661,6 +737,14 @@ pub fn canvas_panel_key(space_id: Option<&str>) -> String { format!("{CANVAS_PANEL_PREFIX}{}", space_id.unwrap_or("")) } +/// The new-session canvas's project/device pick, see [`AppState::canvas_target`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct CanvasTarget { + space: Option, + no_project: bool, + device: Option, +} + /// Root application state. Reducer methods (`apply_*`, [`Self::session_for`], …) /// are plain `&mut self` functions so tests construct the struct directly; gpui /// glue ([`Self::bootstrap`], [`Self::select_chat`]) layers subscriptions on top. @@ -677,7 +761,7 @@ pub struct AppState { device_presentation: Option>, // Presence ticks also retire stale remote session indicators. Remember // their last published appearance even when the device rows stay online. - session_presence_presentation: Vec, + session_presence_presentation: Vec<(Indicator, u32)>, /// Live edge posture (WatchConnectivity): drives the connection pill, /// composer honesty ("will queue"), and the Queued send badges. pub connectivity: zeron_proto::Connectivity, @@ -712,6 +796,10 @@ pub struct AppState { /// device whose projects the project picker lists. `None` falls back to /// the local device. pub selected_device: Option, + /// The new-session canvas's own target, set aside while a chat is open + /// (a chat implies ITS project/device) and put back on return, so the + /// canvas always reopens where the user left it. + pub(crate) canvas_target: Option, pub selected_chat: Option, /// Boot auto-select happened (or a manual selection superseded it). pub auto_selected: bool, @@ -770,6 +858,8 @@ pub struct AppState { /// Data directory (`ui-settings.json`, `composer-defaults.json`); set at /// bootstrap so child views can persist small preference files. pub data_dir: Option, + /// Notifies at clock-driven transitions (see [`Self::watch_clock_transitions`]). + clock: ClockTransitions, engine: Option, watch_tasks: Vec>, transcript_task: Option>, @@ -802,6 +892,31 @@ fn is_text_append(frame: &TranscriptFrame) -> bool { if upsert.is_empty() && remove.is_empty() && !append.is_empty()) } +/// Each session's presence at `now` (the lease-dependent part of a frame). +fn session_presence_at(sessions: &[Session], now: DateTime) -> Vec<(Indicator, u32)> { + sessions + .iter() + .map(|session| session_presence(session, now)) + .collect() +} + +/// Device rows as rendered at `now`: the heartbeat timestamp only through its +/// online state and last-seen label. +fn device_presentation_at(devices: &[Device], now: DateTime) -> Vec<(Device, bool, String)> { + devices + .iter() + .map(|device| { + let mut metadata = device.clone(); + metadata.last_seen_at = None; + ( + metadata, + crate::settings::devices::device_online(device.last_seen_at, now), + crate::settings::devices::format_last_seen(device.last_seen_at, now), + ) + }) + .collect() +} + impl Default for AppState { fn default() -> Self { Self::new() @@ -829,6 +944,7 @@ impl AppState { selected_space: None, no_project: false, selected_device: None, + canvas_target: None, selected_chat: None, transcript: Vec::new(), queue: Vec::new(), @@ -848,6 +964,7 @@ impl AppState { local_device_id: None, harness_updates: Vec::new(), data_dir: None, + clock: ClockTransitions::default(), engine: None, watch_tasks: Vec::new(), transcript_task: None, @@ -1035,27 +1152,37 @@ impl AppState { true } - pub fn apply_chats(&mut self, mut chats: Vec) { + /// Returns whether anything observable changed. The engine re-publishes + /// identical lists (presence and sync churn elsewhere in the doc), and + /// each notify re-renders the whole shell. + pub fn apply_chats(&mut self, mut chats: Vec) -> bool { + let mut changed = false; if let Some(pending) = &self.pending_side_chat { if chats.iter().any(|chat| chat.id == pending.id) { self.pending_side_chat = None; + changed = true; } else { chats.push(pending.clone()); } } sort_chats(&mut chats); - self.chats = chats; - self.link_roots_revision = self.link_roots_revision.wrapping_add(1); - self.chats_synced = true; - self.transcript_cache - .retain(|cached| self.chats.iter().any(|c| c.id == cached.chat_id)); + if !self.chats_synced || self.chats != chats { + changed = true; + self.chats = chats; + self.link_roots_revision = self.link_roots_revision.wrapping_add(1); + self.chats_synced = true; + self.transcript_cache + .retain(|cached| self.chats.iter().any(|c| c.id == cached.chat_id)); + } if let Some(selected) = &self.selected_chat && !self.chats.iter().any(|c| &c.id == selected) { + changed = true; // Selected chat vanished (deleted elsewhere): drop selection + transcript. self.transcript_baselines.remove(selected); self.prepared_transcripts.remove(selected); self.selected_chat = None; + self.restore_canvas_target(); self.transcript.clear(); self.context_usage = None; self.transcript_revision = self.transcript_revision.wrapping_add(1); @@ -1064,6 +1191,7 @@ impl AppState { self.queue.clear(); self.queue_task = None; } + changed } pub fn apply_sessions(&mut self, sessions: Vec) -> bool { @@ -1071,10 +1199,7 @@ impl AppState { } fn apply_sessions_at(&mut self, sessions: Vec, now: DateTime) -> bool { - let presence: Vec<_> = sessions - .iter() - .map(|session| effective_indicator(Some(session), now)) - .collect(); + let presence = session_presence_at(&sessions, now); let presentation: Vec<_> = sessions .iter() .map(|session| { @@ -1093,11 +1218,22 @@ impl AppState { changed } - pub fn apply_spaces(&mut self, mut spaces: Vec) { + /// Returns whether the list or the healed selection changed. + pub fn apply_spaces(&mut self, mut spaces: Vec) -> bool { sort_spaces(&mut spaces); - self.spaces = spaces; - self.link_roots_revision = self.link_roots_revision.wrapping_add(1); - self.spaces_synced = true; + let previous_selection = self.selected_space.clone(); + let mut changed = false; + if !self.spaces_synced || self.spaces != spaces { + changed = true; + self.spaces = spaces; + self.link_roots_revision = self.link_roots_revision.wrapping_add(1); + self.spaces_synced = true; + } + self.heal_space_selection(); + changed || self.selected_space != previous_selection + } + + fn heal_space_selection(&mut self) { if self.no_project { self.selected_space = None; return; @@ -1216,9 +1352,13 @@ impl AppState { } } - pub fn apply_connectivity(&mut self, connectivity: zeron_proto::Connectivity) { + /// Returns whether the posture changed. The first frame after attach always + /// counts: it ends the bootstrap gap the shell's alert baseline waits on. + pub fn apply_connectivity(&mut self, connectivity: zeron_proto::Connectivity) -> bool { + let changed = !self.connectivity_observed || self.connectivity != connectivity; self.connectivity = connectivity; self.connectivity_observed = true; + changed } /// Is this chat's delivery path degraded — will a send QUEUE rather than @@ -1241,8 +1381,7 @@ impl AppState { if Some(chat.device_id.as_str()) == self.local_device_id.as_deref() { return false; } - if self.connectivity.state == S::Offline - || !self.device_online(&chat.device_id, Utc::now()) + if self.connectivity.state == S::Offline || !self.device_online(&chat.device_id, Utc::now()) { return true; } @@ -1288,23 +1427,8 @@ impl AppState { self.change_requests .clear_unsupported_on_version_change(&device.id, device.version.as_deref()); } - let presentation: Vec<_> = devices - .iter() - .map(|device| { - let mut metadata = device.clone(); - metadata.last_seen_at = None; - ( - metadata, - crate::settings::devices::device_online(device.last_seen_at, now), - crate::settings::devices::format_last_seen(device.last_seen_at, now), - ) - }) - .collect(); - let session_presence: Vec<_> = self - .sessions - .iter() - .map(|session| effective_indicator(Some(session), now)) - .collect(); + let presentation = device_presentation_at(&devices, now); + let session_presence = session_presence_at(&self.sessions, now); let changed = self.device_presentation.as_ref() != Some(&presentation) || self.session_presence_presentation != session_presence; self.device_presentation = Some(presentation); @@ -1365,19 +1489,33 @@ impl AppState { .map(|s| s.id.clone()) } - pub fn apply_harness_updates(&mut self, statuses: Vec) { + pub fn apply_harness_updates( + &mut self, + statuses: Vec, + ) -> bool { + if self.harness_updates == statuses { + return false; + } self.harness_updates = statuses; + true } - pub fn apply_auth(&mut self, auth: AuthState) { + pub fn apply_auth(&mut self, auth: AuthState) -> bool { + if self.auth.as_ref() == Some(&auth) { + return false; + } self.auth = Some(auth); + true } /// Tolerant AuthStatus frame reducer (see [`parse_auth_state`]). - pub fn apply_auth_value(&mut self, value: serde_json::Value) { + pub fn apply_auth_value(&mut self, value: serde_json::Value) -> bool { match parse_auth_state(&value) { Some(auth) => self.apply_auth(auth), - None => tracing::warn!("dropping unrecognized AuthStatus frame"), + None => { + tracing::warn!("dropping unrecognized AuthStatus frame"); + false + } } } @@ -1652,11 +1790,16 @@ impl AppState { /// A `WatchTransfers` snapshot: the engine-side relay leg's in-flight /// queued-attachment transfers, replacing the whole set each frame. - pub fn apply_transfers(&mut self, transfers: Vec) { - self.transfers = transfers + pub fn apply_transfers(&mut self, transfers: Vec) -> bool { + let transfers: HashMap<_, _> = transfers .into_iter() .map(|t| (t.upload_id, (t.done, t.total))) .collect(); + if self.transfers == transfers { + return false; + } + self.transfers = transfers; + true } /// Percent of one queued attachment's relay transfer, by the uploadId @@ -1766,10 +1909,11 @@ impl AppState { /// another chat (`parent_chat_id`, the Zeron MCP's orchestration link) /// are the parent's workers, not sessions the user started: they stay /// reachable by id/deep link but never take a sidebar row or jump slot. + /// Voice orchestrator chats are hidden the same way. pub fn visible_chats(&self) -> impl Iterator { self.chats .iter() - .filter(|c| !c.archived && c.parent_chat_id.is_none()) + .filter(|c| !c.archived && c.is_top_level()) } pub(crate) fn restore_composer_target( @@ -1792,6 +1936,34 @@ impl AppState { } } + fn current_canvas_target(&self) -> CanvasTarget { + CanvasTarget { + space: self.selected_space.clone(), + no_project: self.no_project, + device: self.selected_device.clone(), + } + } + + /// Put back the canvas target set aside when a chat opened. A project + /// deleted meanwhile heals the way [`Self::apply_spaces`] would. + fn restore_canvas_target(&mut self) { + let Some(target) = self.canvas_target.take() else { + return; + }; + self.selected_device = target.device; + self.no_project = target.no_project; + self.selected_space = target.space; + if self.spaces_synced + && !self.no_project + && self + .selected_space + .as_ref() + .is_none_or(|id| !self.spaces.iter().any(|s| &s.id == id)) + { + self.selected_space = self.first_space_on_picked_device(); + } + } + pub fn selected_space_row(&self) -> Option<&Space> { if self.no_project { return None; @@ -1812,17 +1984,22 @@ impl AppState { } /// Pick the composer's target device. Keeps the project pick consistent: - /// a project on another device can't survive the switch — fall back to - /// the first project on the new device, else "no project". + /// it moves to the project's checkout on the new device, else the first + /// project there, else "no project". pub fn select_device(&mut self, device_id: String, cx: &mut Context) { - let project_moves = self + let moving = self .selected_space_row() - .is_some_and(|s| s.device_id != device_id); - if project_moves { + .filter(|s| s.device_id != device_id); + if let Some(space) = moving { let first = self - .spaces_sorted() - .iter() + .project_members(space) + .into_iter() .find(|s| s.device_id == device_id) + .or_else(|| { + self.spaces_sorted() + .into_iter() + .find(|s| s.device_id == device_id) + }) .map(|s| s.id.clone()); self.no_project = first.is_none(); self.selected_space = first; @@ -1848,6 +2025,35 @@ impl AppState { self.space_row(chat.space_id.as_deref()?) } + /// The space whose name and color stand for `space`'s whole project + /// ([`zeron_proto::view::representative_space`]). + pub fn representative_space<'a>(&'a self, space: &'a Space) -> &'a Space { + zeron_proto::view::representative_space(&self.spaces, space) + } + + /// Every space of `space`'s project ([`zeron_proto::view::project_key`]): + /// this device's first, then by device name and path. + pub fn project_members(&self, space: &Space) -> Vec<&Space> { + let key = zeron_proto::view::project_key(space); + let local = self.local_device_id.as_deref(); + let mut members: Vec<&Space> = self + .spaces + .iter() + .filter(|s| zeron_proto::view::project_key(s) == key) + .collect(); + members.sort_by_key(|s| { + ( + local != Some(s.device_id.as_str()), + self.device_name(&s.device_id) + .unwrap_or_default() + .to_lowercase(), + s.path.clone(), + s.id.clone(), + ) + }); + members + } + /// Non-archived chats of a space in tab (creation) order. Chats with a /// dangling/missing `space_id` are invisible by construction. pub fn chats_in_space(&self, space_id: &str) -> Vec<&Chat> { @@ -1934,19 +2140,99 @@ impl AppState { now: DateTime, space_filter: Option<&str>, ) -> Vec<(ChatIndicator, &Chat)> { + let in_filter = self.project_filter(space_filter); self.overview_chats(now) .into_iter() - .filter(|(_, chat)| match space_filter { - Some(space_id) => chat.space_id.as_deref() == Some(space_id), - None => true, - }) + .filter(|(_, chat)| in_filter(chat)) .collect() } + /// The sidebar's project filter matches by repository: it stores one + /// checkout's space id, and a chat in ANY checkout of that repository + /// (clones and worktrees on every device, [`zeron_proto::view::project_key`]) + /// matches. A filter naming a space not synced here matches its own id. + pub fn project_filter<'a>(&'a self, filter: Option<&str>) -> impl Fn(&Chat) -> bool + 'a { + let filter = filter.map(|id| { + ( + id.to_owned(), + self.space_row(id).map(zeron_proto::view::project_key), + ) + }); + move |chat| { + let Some((id, key)) = &filter else { + return true; + }; + match chat.space_id.as_deref() { + Some(space_id) if space_id == id => true, + Some(space_id) => key.as_ref().is_some_and(|key| { + self.space_row(space_id) + .is_some_and(|space| zeron_proto::view::project_key(space) == *key) + }), + None => false, + } + } + } + + /// Every project — one per repository across devices — as its checkouts + /// in [`Self::project_members`] order (this device's first), ordered by + /// the project's name. + pub fn projects(&self) -> Vec> { + let mut seen = std::collections::HashSet::new(); + let mut projects: Vec> = self + .spaces + .iter() + .filter(|space| seen.insert(zeron_proto::view::project_key(space))) + .map(|space| self.project_members(space)) + .collect(); + projects.sort_by_key(|members| { + let representative = self.representative_space(members[0]); + ( + representative.display_name().to_lowercase(), + representative.id.clone(), + ) + }); + projects + } + + /// A project's host tag: its one device as in [`Self::space_device_tag`], + /// else the devices holding a checkout. Offline only when every one is. + pub fn project_device_tag(&self, members: &[&Space], now: DateTime) -> (String, bool) { + let mut devices: Vec<&str> = Vec::new(); + for member in members { + if !devices.contains(&member.device_id.as_str()) { + devices.push(member.device_id.as_str()); + } + } + match devices.as_slice() { + [] => (String::new(), false), + [_] => self.space_device_tag(members[0], now), + _ => { + let offline = devices.iter().all(|d| !self.device_online(d, now)); + let tag = if devices.len() == 2 { + devices + .iter() + .map(|d| self.device_name(d).unwrap_or("Unknown device")) + .collect::>() + .join(" · ") + } else { + format!("{} devices", devices.len()) + }; + (format!("@ {tag}"), offline) + } + } + } + pub fn session_for(&self, chat_id: &str) -> Option<&Session> { self.sessions.iter().find(|s| s.chat_id == chat_id) } + /// Subagents running under `chat_id` right now (the engine's count on its + /// session row; 0 once that row goes stale). Every device can ask, opened + /// chat or not. + pub fn running_subagents_for(&self, chat_id: &str, now: DateTime) -> u32 { + running_subagents(self.session_for(chat_id), now) + } + /// Staleness-checked status dot for a chat row. A send in flight reads as /// Working (see [`Self::display_status_for`]). pub fn indicator_for(&self, chat_id: &str, now: DateTime) -> Indicator { @@ -1956,6 +2242,113 @@ impl AppState { effective_indicator(self.session_for(chat_id), now) } + /// The earliest moment after `now` at which a clock-derived value flips + /// with no frame to announce it: a Working/AwaitingInput session going + /// stale ([`SESSION_STALE_MS`]), a remote device leaving its online window + /// (offline glyphs, degraded delivery, Queued badges), or a pending send's + /// grace expiring into "Not delivered". Each deadline is the first instant + /// the predicate reads differently. Relative-time labels are excluded: + /// they advance on the views' minute heartbeat. Pure. + pub fn next_clock_transition(&self, now: DateTime) -> Option> { + self.clock_deadlines() + .filter(|deadline| *deadline > now) + .min() + } + + /// Whether any clock-driven transition fell in `(from, to]`. Heartbeats + /// that move a deadline later without notifying (session and device + /// liveness leases) leave an armed timer early; this tells a real + /// transition from such an obsolete wake. Pure. + fn clock_transition_between(&self, from: DateTime, to: DateTime) -> bool { + self.clock_deadlines() + .any(|deadline| from < deadline && deadline <= to) + } + + fn clock_deadlines(&self) -> impl Iterator> + '_ { + let stale = self + .sessions + .iter() + .filter(|session| { + matches!( + session.status, + zeron_proto::SessionStatus::Working | zeron_proto::SessionStatus::AwaitingInput + ) + }) + // `age_ms > SESSION_STALE_MS` first holds one millisecond past it. + .map(|session| { + session.updated_at + chrono::TimeDelta::milliseconds(SESSION_STALE_MS + 1) + }); + let offline = self + .devices + .iter() + .filter(|device| self.local_device_id.as_deref() != Some(device.id.as_str())) + .filter_map(|device| device.last_seen_at) + // Whole seconds `<= DEVICE_ONLINE_WINDOW_SECS` stay online. + .map(|at| { + at + chrono::TimeDelta::seconds( + crate::settings::devices::DEVICE_ONLINE_WINDOW_SECS + 1, + ) + }); + let grace = self.pending_sends.values().map(|pending| { + pending.started + chrono::TimeDelta::milliseconds(UNDELIVERED_GRACE_MS + 1) + }); + stale.chain(offline).chain(grace) + } + + /// Notify this entity at every clock-driven transition, so each state + /// observer (composer interrupts, session sounds, views) sees a session + /// go stale, a device go dark or a send's grace expire on time — no state + /// frame announces those. Idempotent; every notify re-arms a single + /// one-shot timer for the next deadline, so idle state never polls. + pub fn watch_clock_transitions(&mut self, cx: &mut Context) { + if self.clock.observation.is_none() { + self.clock.observation = + Some(cx.observe_self(|state, cx| state.arm_clock_transition(cx))); + } + self.arm_clock_transition(cx); + } + + /// Re-evaluate the clock-dependent parts of the change-detection caches. + fn refresh_clock_presentations(&mut self, now: DateTime) { + self.session_presence_presentation = session_presence_at(&self.sessions, now); + if self.device_presentation.is_some() { + self.device_presentation = Some(device_presentation_at(&self.devices, now)); + } + } + + fn arm_clock_transition(&mut self, cx: &mut Context) { + let now = clock_now(); + self.clock.checked_at = Some(now); + let wake = self.next_clock_transition(now); + if wake == self.clock.wake_at && self.clock.task.is_some() { + return; + } + self.clock.wake_at = wake; + self.clock.task = wake.map(|wake| { + let delay = (wake - now).to_std().unwrap_or_default(); + cx.spawn(async move |this, cx| { + cx.background_executor().timer(delay).await; + this.update(cx, |state, cx| { + state.clock.wake_at = None; + state.clock.task = None; + let now = clock_now(); + let from = state.clock.checked_at.unwrap_or(now); + if state.clock_transition_between(from, now) { + // Record what this transition changed, so the frame + // that later reverts it (a heartbeat reviving the + // session or device) compares unequal and notifies. + state.refresh_clock_presentations(now); + // The self-observer re-arms for the next deadline. + cx.notify(); + } else { + state.arm_clock_transition(cx); + } + }) + .ok(); + }) + }); + } + pub fn selected_chat_row(&self) -> Option<&Chat> { let id = self.selected_chat.as_deref()?; self.chats.iter().find(|c| c.id == id) @@ -2014,6 +2407,7 @@ impl AppState { self.selected_space = None; self.no_project = false; self.selected_device = None; + self.canvas_target = None; self.selected_chat = None; self.auto_selected = false; self.chats_synced = false; @@ -2061,6 +2455,7 @@ impl AppState { state.attach_engine(engine, cx); } state.select_chat(Some(chat.id), cx); + state.watch_clock_transitions(cx); state } @@ -2212,29 +2607,27 @@ impl AppState { cx, handle.clone(), methods::WATCH_CONNECTIVITY, - |state, value| { - state.apply_connectivity(value); - true - }, + AppState::apply_connectivity, ), spawn_watch( cx, handle.clone(), methods::WATCH_TRANSFERS, - |state, value| { - state.apply_transfers(value); - true - }, + AppState::apply_transfers, + ), + spawn_watch( + cx, + handle.clone(), + methods::WATCH_SPACES, + AppState::apply_spaces, ), - spawn_watch(cx, handle.clone(), methods::WATCH_SPACES, |state, value| { - state.apply_spaces(value); - true - }), // Auth frames parse tolerantly — engine and proto tags differ today. - spawn_watch(cx, handle.clone(), methods::AUTH_STATUS, |state, value| { - state.apply_auth_value(value); - true - }), + spawn_watch( + cx, + handle.clone(), + methods::AUTH_STATUS, + AppState::apply_auth_value, + ), spawn_local_device_probe(cx, handle.clone()), ]); if supports_harness_updates { @@ -2242,10 +2635,7 @@ impl AppState { cx, handle.clone(), methods::WATCH_HARNESS_UPDATES, - |state, value| { - state.apply_harness_updates(value); - true - }, + AppState::apply_harness_updates, )); } self.watch_tasks = watch_tasks; @@ -2317,29 +2707,34 @@ impl AppState { cx.notify(); } - fn apply_pending_deep_link(&mut self, cx: &mut Context) { + /// Returns whether a pending link was resolved (selected or rejected). + fn apply_pending_deep_link(&mut self, cx: &mut Context) -> bool { let Some(link) = self.pending_deep_link.clone() else { - return; + return false; }; let Some(locator) = crate::links::workspace_locator( self.workspace_scope, self.auth.as_ref(), self.local_device_id.as_deref(), ) else { - return; + return false; }; if locator != link.workspace { self.pending_deep_link = None; self.deep_link_notice = Some("This conversation link belongs to another workspace".into()); - return; + return true; } if self.chats.iter().any(|chat| chat.id == link.chat_id) { self.pending_deep_link = None; self.select_chat(Some(link.chat_id), cx); + true } else if self.chats_synced { self.pending_deep_link = None; self.deep_link_notice = Some("The linked conversation was not found".into()); + true + } else { + false } } @@ -2364,6 +2759,11 @@ impl AppState { } return; } + match (&self.selected_chat, &chat_id) { + (None, Some(_)) => self.canvas_target = Some(self.current_canvas_target()), + (Some(_), None) => self.restore_canvas_target(), + _ => {} + } // Take the destination before trimming: switching to the oldest warm // transcript must not evict the very entry we are about to display. let cached = self @@ -2454,7 +2854,7 @@ impl AppState { self.queue_task = None; if let Some(id) = chat_id.as_deref() { // A chat implies its project (or the lack of one); `select_chat(None)` - // (the new-session canvas) keeps the current project pick. + // (the new-session canvas) restores the pick set aside above. if let Some(chat) = self.chats.iter().find(|c| c.id == id) { match chat.space_id.clone() { Some(space_id) => { @@ -2574,7 +2974,10 @@ impl AppState { cx.spawn(async move |_, _| { if let Err(error) = handle .client() - .call(methods::FOCUS_CHAT, serde_json::json!({ "chatId": chat_id })) + .call( + methods::FOCUS_CHAT, + serde_json::json!({ "chatId": chat_id }), + ) .await { tracing::debug!(%chat_id, %error, "chat focus sync hint unavailable"); @@ -2653,10 +3056,16 @@ fn spawn_chats_watch(cx: &mut Context, handle: EngineHandle) -> Task<( } }; let alive = this.update(cx, |state, cx| { - state.apply_chats(parsed); - state.apply_pending_deep_link(cx); - state.reconcile_change_request_watches(cx); - cx.notify(); + // Identical re-published lists skip the re-sort fan-out: + // watch reconciliation and a whole-shell re-render. + let changed = state.apply_chats(parsed); + let linked = state.apply_pending_deep_link(cx); + if changed { + state.reconcile_change_request_watches(cx); + } + if changed || linked { + cx.notify(); + } }); if alive.is_err() { return; @@ -2739,8 +3148,9 @@ fn spawn_change_request_watch( }; if this .update(cx, |state, cx| { - state.change_requests.store(target.clone(), snapshot); - cx.notify(); + if state.change_requests.store(target.clone(), snapshot) { + cx.notify(); + } }) .is_err() { @@ -2801,11 +3211,11 @@ fn spawn_watch( }; let alive = this.update(cx, |state, cx| { let changed = apply(state, parsed); - state.apply_pending_deep_link(cx); - if changed { - if matches!(method, methods::WATCH_SPACES | methods::WATCH_DEVICES) { - state.reconcile_change_request_watches(cx); - } + let linked = state.apply_pending_deep_link(cx); + if changed && matches!(method, methods::WATCH_SPACES | methods::WATCH_DEVICES) { + state.reconcile_change_request_watches(cx); + } + if changed || linked { cx.notify(); } }); @@ -3665,6 +4075,7 @@ mod tests { git_detected: false, git_checked_at: None, checkout_id: None, + repository_id: None, created_at: base + TimeDelta::minutes(created_min), } } @@ -3677,6 +4088,7 @@ mod tests { ) -> Session { Session { last_completed_turn: None, + running_subagents: 0, chat_id: chat_id.into(), device_id: "dev".into(), status, @@ -3921,6 +4333,7 @@ mod tests { let now = Utc::now(); let mut row = Session { last_completed_turn: None, + running_subagents: 0, chat_id: "chat".into(), device_id: "host".into(), status: SessionStatus::Working, @@ -3952,6 +4365,7 @@ mod tests { let now = Utc::now(); state.sessions = vec![Session { last_completed_turn: None, + running_subagents: 0, chat_id: "chat".into(), device_id: "host".into(), status: SessionStatus::Working, @@ -4494,6 +4908,46 @@ mod tests { }); } + #[gpui::test] + fn canvas_reopens_on_its_own_target_after_visiting_chats(cx: &mut gpui::TestAppContext) { + let state = cx.new(|_| AppState::new()); + state.update(cx, |state, cx| { + state.apply_spaces(vec![ + space("s1", "dev", "/a", 1), + space("s2", "remote", "/b", 2), + ]); + let mut remote = chat("remote-chat", 1, None); + remote.device_id = "remote".into(); + remote.space_id = Some("s2".into()); + let mut loose = chat("loose-chat", 2, None); + loose.device_id = "remote".into(); + state.apply_chats(vec![remote, loose.clone()]); + state.select_space(Some("s1".into()), cx); + + // Chats imply their own target while open… + state.select_chat(Some("remote-chat".into()), cx); + assert_eq!(state.selected_space.as_deref(), Some("s2")); + state.select_chat(Some("loose-chat".into()), cx); + assert!(state.no_project); + // …and the canvas returns to where it was left. + state.select_chat(None, cx); + assert!(!state.no_project); + assert_eq!(state.selected_space.as_deref(), Some("s1")); + assert_eq!(state.effective_device_id().as_deref(), Some("dev")); + + // A projectless canvas survives a project chat, and a chat deleted + // elsewhere lands back on it too. + state.select_space(None, cx); + state.select_device("remote".into(), cx); + state.select_chat(Some("remote-chat".into()), cx); + state.apply_chats(vec![loose]); + assert!(state.selected_chat.is_none()); + assert!(state.no_project); + assert!(state.selected_space.is_none()); + assert_eq!(state.effective_device_id().as_deref(), Some("remote")); + }); + } + #[test] fn chats_in_space_filters_and_orders() { let mut state = AppState::new(); @@ -4528,6 +4982,64 @@ mod tests { assert_eq!(overview, ["old", "new", "dangling"]); } + #[test] + fn project_filter_and_projects_span_a_repository_across_devices() { + let mut state = AppState::new(); + state.local_device_id = Some("laptop".into()); + let mut remote = space("remote", "metal", "/w/anara", 1); + remote.repository_id = Some("commit:abc".into()); + let mut local = space("local", "laptop", "/h/anara", 2); + local.repository_id = Some("commit:abc".into()); + let other = space("other", "laptop", "/h/notes", 3); + state.apply_spaces(vec![remote, local, other]); + let in_space = |id: &str, space: &str| { + let mut chat = chat(id, 0, None); + chat.space_id = Some(space.into()); + chat + }; + state.apply_chats(vec![ + in_space("on-remote", "remote"), + in_space("on-local", "local"), + in_space("on-other", "other"), + ]); + + // One project per repository, its checkouts local-first. + let projects: Vec> = state + .projects() + .iter() + .map(|members| members.iter().map(|s| s.id.as_str()).collect()) + .collect(); + assert_eq!(projects, vec![vec!["local", "remote"], vec!["other"]]); + + // Filtering on either checkout shows the whole repository's chats. + for filter in ["remote", "local"] { + let mut ids: Vec<&str> = state + .sidebar_chats(Utc::now(), Some(filter)) + .iter() + .map(|(_, c)| c.id.as_str()) + .collect(); + ids.sort(); + assert_eq!(ids, ["on-local", "on-remote"], "filter {filter}"); + } + let other_only: Vec<&str> = state + .sidebar_chats(Utc::now(), Some("other")) + .iter() + .map(|(_, c)| c.id.as_str()) + .collect(); + assert_eq!(other_only, ["on-other"]); + + // A two-device project tags both hosts; a one-device one keeps the + // plain host tag. + let anara = state.projects().into_iter().next().unwrap(); + let (tag, _) = state.project_device_tag(&anara, Utc::now()); + assert!(tag.starts_with("@ ") && tag.contains(" · "), "{tag}"); + let notes = state.projects().into_iter().nth(1).unwrap(); + assert_eq!( + state.project_device_tag(¬es, Utc::now()).0, + state.space_device_tag(notes[0], Utc::now()).0 + ); + } + #[test] fn apply_chats_drops_vanished_selection() { let mut state = AppState::new(); @@ -4542,6 +5054,301 @@ mod tests { assert_eq!(state.selected_chat.as_deref(), Some("b")); } + fn clock_epoch() -> DateTime { + DateTime::parse_from_rfc3339("2026-07-19T12:00:00Z") + .unwrap() + .to_utc() + } + + /// Builds a clock-watching state at the pinned `now` and counts its + /// notifications. + fn clock_state( + cx: &mut gpui::TestAppContext, + setup: impl FnOnce(&mut AppState), + ) -> ( + Entity, + std::rc::Rc>, + gpui::Subscription, + ) { + let state = cx.new(|cx| { + let mut state = AppState::new(); + setup(&mut state); + state.watch_clock_transitions(cx); + state + }); + let notifies = std::rc::Rc::new(std::cell::Cell::new(0)); + let counter = notifies.clone(); + let subscription = + cx.update(|cx| cx.observe(&state, move |_, _| counter.set(counter.get() + 1))); + (state, notifies, subscription) + } + + /// Steps the simulated executor and the pinned wall clock together. + fn advance_to( + cx: &mut gpui::TestAppContext, + clock: &TestClock, + from: DateTime, + to: DateTime, + ) { + clock.set(to); + cx.executor().advance_clock((to - from).to_std().unwrap()); + } + + /// Proves the state notifies exactly once, exactly at `deadline`. + fn assert_state_notifies_at( + cx: &mut gpui::TestAppContext, + now: DateTime, + deadline: DateTime, + setup: impl FnOnce(&mut AppState), + ) { + let clock = TestClock::start(now); + let (_state, notifies, _subscription) = clock_state(cx, setup); + let before = deadline - TimeDelta::milliseconds(1); + advance_to(cx, &clock, now, before); + assert_eq!(notifies.get(), 0, "no notify before the deadline"); + advance_to(cx, &clock, before, deadline); + assert_eq!(notifies.get(), 1, "one notify at the deadline"); + // Nothing else is due: the next minute passes silently. + advance_to(cx, &clock, deadline, deadline + TimeDelta::minutes(1)); + assert_eq!(notifies.get(), 1); + } + + #[gpui::test] + fn stale_session_notifies_state_at_its_cutoff(cx: &mut gpui::TestAppContext) { + let now = clock_epoch(); + let row = session("row", SessionStatus::Working, 10, now); + let cutoff = row.updated_at + TimeDelta::milliseconds(SESSION_STALE_MS + 1); + let mut probe = AppState::new(); + probe.sessions = vec![row.clone()]; + assert_eq!( + probe.indicator_for("row", cutoff - TimeDelta::milliseconds(1)), + Indicator::Working + ); + assert_eq!(probe.indicator_for("row", cutoff), Indicator::None); + assert_state_notifies_at(cx, now, cutoff, |state| state.sessions = vec![row]); + } + + #[gpui::test] + fn remote_device_notifies_state_when_it_goes_offline(cx: &mut gpui::TestAppContext) { + let now = clock_epoch(); + let seen = now - TimeDelta::seconds(40); + let mut remote = device("remote", "Remote"); + remote.last_seen_at = Some(seen); + // The local device is always online; its stale heartbeat is no deadline. + let mut local = device("local", "Local"); + local.last_seen_at = Some(now - TimeDelta::seconds(65)); + let offline = + seen + TimeDelta::seconds(crate::settings::devices::DEVICE_ONLINE_WINDOW_SECS + 1); + let mut probe = AppState::new(); + probe.devices = vec![remote.clone()]; + assert!(probe.device_online("remote", offline - TimeDelta::milliseconds(1))); + assert!(!probe.device_online("remote", offline)); + assert_state_notifies_at(cx, now, offline, |state| { + state.local_device_id = Some("local".into()); + state.devices = vec![local, remote]; + }); + } + + #[gpui::test] + fn pending_send_notifies_state_when_its_grace_expires(cx: &mut gpui::TestAppContext) { + let now = clock_epoch(); + let started = now - TimeDelta::seconds(100); + let expiry = started + TimeDelta::milliseconds(UNDELIVERED_GRACE_MS + 1); + let mut probe = AppState::new(); + probe.begin_pending_send("chat", "message", started); + assert!(!probe.send_undelivered("chat", expiry - TimeDelta::milliseconds(1))); + assert!(probe.send_undelivered("chat", expiry)); + assert_state_notifies_at(cx, now, expiry, |state| { + state.begin_pending_send("chat", "message", started) + }); + } + + #[gpui::test] + fn settled_state_arms_no_clock(cx: &mut gpui::TestAppContext) { + let now = clock_epoch(); + let clock = TestClock::start(now); + let (state, notifies, _subscription) = clock_state(cx, |state| { + state.sessions = vec![ + session("failed", SessionStatus::Errored, 0, now), + session("idle", SessionStatus::Idle, 0, now), + ]; + }); + assert!(state.read_with(cx, |state, _| state.clock.task.is_none())); + advance_to(cx, &clock, now, now + TimeDelta::hours(1)); + assert_eq!(notifies.get(), 0); + } + + #[gpui::test] + fn silent_heartbeat_defers_the_stale_notify(cx: &mut gpui::TestAppContext) { + let now = clock_epoch(); + let clock = TestClock::start(now); + let row = session("row", SessionStatus::Working, 10, now); + let first_cutoff = row.updated_at + TimeDelta::milliseconds(SESSION_STALE_MS + 1); + let (state, notifies, _subscription) = clock_state(cx, |state| { + state.apply_sessions_at(vec![row], now); + }); + // A heartbeat only refreshes the lease: the reducer stores it without + // notifying, so the armed timer is now early. + let heartbeat = now + TimeDelta::seconds(20); + advance_to(cx, &clock, now, heartbeat); + state.update(cx, |state, _| { + let changed = state.apply_sessions_at( + vec![session("row", SessionStatus::Working, 0, heartbeat)], + heartbeat, + ); + assert!(!changed); + }); + advance_to(cx, &clock, heartbeat, first_cutoff); + assert_eq!(notifies.get(), 0, "an obsolete deadline re-arms silently"); + let cutoff = heartbeat + TimeDelta::milliseconds(SESSION_STALE_MS + 1); + advance_to( + cx, + &clock, + first_cutoff, + cutoff - TimeDelta::milliseconds(1), + ); + assert_eq!(notifies.get(), 0); + advance_to(cx, &clock, cutoff - TimeDelta::milliseconds(1), cutoff); + assert_eq!(notifies.get(), 1); + } + + #[gpui::test] + fn revival_after_a_clock_transition_notifies_and_rearms(cx: &mut gpui::TestAppContext) { + let now = clock_epoch(); + let clock = TestClock::start(now); + let row = session("row", SessionStatus::Working, 0, now); + let stale_at = now + TimeDelta::milliseconds(SESSION_STALE_MS + 1); + let (state, notifies, _subscription) = clock_state(cx, |state| { + state.apply_sessions_at(vec![row], now); + }); + advance_to(cx, &clock, now, stale_at); + assert_eq!(notifies.get(), 1, "went stale by clock alone"); + // A heartbeat revives it: that is a visible change again, and the + // revived lease arms its own cutoff. + let revived = stale_at + TimeDelta::seconds(1); + advance_to(cx, &clock, stale_at, revived); + state.update(cx, |state, cx| { + let changed = state.apply_sessions_at( + vec![session("row", SessionStatus::Working, 0, revived)], + revived, + ); + assert!(changed, "revival is a change"); + cx.notify(); + }); + assert_eq!(notifies.get(), 2); + let next_cutoff = revived + TimeDelta::milliseconds(SESSION_STALE_MS + 1); + advance_to(cx, &clock, revived, next_cutoff); + assert_eq!(notifies.get(), 3, "the revived lease's cutoff fires"); + } + + #[test] + fn identical_chats_frames_report_no_change() { + let mut state = AppState::new(); + // The first frame flips `chats_synced` even when empty. + assert!(state.apply_chats(vec![])); + assert!(!state.apply_chats(vec![])); + assert!(state.apply_chats(vec![chat("b", 1, None), chat("a", 0, None)])); + let revision = state.link_roots_revision; + // Same rows in a different wire order sort to the same list. + assert!(!state.apply_chats(vec![chat("a", 0, None), chat("b", 1, None)])); + assert_eq!(state.link_roots_revision, revision); + let mut renamed = chat("a", 0, None); + renamed.title = Some("Renamed".into()); + assert!(state.apply_chats(vec![renamed.clone(), chat("b", 1, None)])); + assert_ne!(state.link_roots_revision, revision); + // A selection pointing at no row still heals on an unchanged frame. + state.selected_chat = Some("gone".into()); + assert!(state.apply_chats(vec![renamed, chat("b", 1, None)])); + assert_eq!(state.selected_chat, None); + } + + #[test] + fn identical_spaces_frames_report_no_change() { + let mut state = AppState::new(); + assert!(state.apply_spaces(vec![])); + assert!(!state.apply_spaces(vec![])); + let frame = vec![space("s2", "dev", "/b", 2), space("s1", "dev", "/a", 1)]; + assert!(state.apply_spaces(frame.clone())); + assert_eq!(state.selected_space.as_deref(), Some("s1")); + assert!(!state.apply_spaces(frame.clone())); + // A vanished selection still heals (and reports it) on an unchanged list. + state.selected_space = Some("gone".into()); + assert!(state.apply_spaces(frame)); + assert_eq!(state.selected_space.as_deref(), Some("s1")); + } + + #[test] + fn identical_connectivity_frames_report_no_change() { + use zeron_proto::{Connectivity, ConnectivityState}; + let mut state = AppState::new(); + // The first frame ends the bootstrap gap even when it equals the default. + assert!(state.apply_connectivity(Connectivity::default())); + assert!(state.connectivity_observed); + assert!(!state.apply_connectivity(Connectivity::default())); + let reconnecting = Connectivity { + state: ConnectivityState::Reconnecting, + retry_at_ms: 5_000, + ..Default::default() + }; + assert!(state.apply_connectivity(reconnecting.clone())); + assert!(!state.apply_connectivity(reconnecting.clone())); + assert!(state.apply_connectivity(Connectivity { + retry_at_ms: 9_000, + ..reconnecting + })); + // Re-attach reopens the gap: the next frame counts again. + state.connectivity_observed = false; + assert!(state.apply_connectivity(state.connectivity.clone())); + } + + #[test] + fn identical_transfer_auth_and_harness_frames_report_no_change() { + use zeron_proto::{HarnessUpdatePhase, HarnessUpdateStatus, TransferProgress}; + let mut state = AppState::new(); + assert!(!state.apply_transfers(vec![])); + let progress = |done| TransferProgress { + upload_id: "u1".into(), + file_name: "a.png".into(), + done, + total: 100, + }; + assert!(state.apply_transfers(vec![progress(10)])); + assert!(!state.apply_transfers(vec![progress(10)])); + assert!(state.apply_transfers(vec![progress(20)])); + assert_eq!(state.transfer_percent("u1"), Some(20)); + assert!(state.apply_transfers(vec![])); + + assert!(state.apply_auth(AuthState::SignedOut)); + assert!(!state.apply_auth_value(serde_json::json!({ "state": "signedOut" }))); + let user = UserProfile { + id: "u".into(), + email: "w@example.com".into(), + name: None, + }; + assert!(state.apply_auth(AuthState::NeedsOrganization { user: user.clone() })); + assert!(!state.apply_auth(AuthState::NeedsOrganization { user })); + assert!(!state.apply_auth_value(serde_json::json!({ "bogus": true }))); + + let status = |phase| HarnessUpdateStatus { + harness: HarnessId::Codex, + installed_version: Some("1.0".into()), + latest_version: Some("2.0".into()), + channel: None, + source: Default::default(), + policy: Default::default(), + phase, + progress: None, + checked_at: None, + error: None, + can_apply: true, + manual_command: None, + }; + assert!(!state.apply_harness_updates(vec![])); + assert!(state.apply_harness_updates(vec![status(HarnessUpdatePhase::Available)])); + assert!(!state.apply_harness_updates(vec![status(HarnessUpdatePhase::Available)])); + assert!(state.apply_harness_updates(vec![status(HarnessUpdatePhase::Current)])); + } + #[test] fn apply_chat_config_stamps_the_row() { let mut state = AppState::new(); diff --git a/crates/ui/src/todo_panel.rs b/crates/ui/src/todo_panel.rs new file mode 100644 index 0000000000..216f0b78ee --- /dev/null +++ b/crates/ui/src/todo_panel.rs @@ -0,0 +1,1142 @@ +//! The agent's checklist, docked above the composer. +//! +//! Every harness that plans (Claude's TodoWrite, OpenCode, ACP and Codex +//! plans) emits the *whole* list on each update, so the panel simply tracks the +//! latest `Todo` tool part of the selected chat. It sits in the same tray stack +//! as the message queue (`queue.rs`), one step narrower, so the two read as one +//! object emerging from behind the composer. +//! +//! The first half of this file is pure — which list is current, what the header +//! says, which rows a long list folds to — so it is unit-tested without a +//! window. The second half is the gpui rendering on [`Composer`]. + +use std::collections::hash_map::DefaultHasher; +use std::hash::{Hash, Hasher}; +use std::ops::Range; +use std::time::Duration; + +use gpui::{ + AnyElement, Context, InteractiveElement as _, IntoElement, ParentElement as _, SharedString, + StatefulInteractiveElement as _, Styled, Window, div, prelude::*, px, +}; + +use zeron_doc::{MessagePart, MessageRole, SessionMessageEntry}; +use zeron_proto::{TodoItem, TodoStatus, ToolCall}; + +use crate::composer::{Composer, QUEUE_COMPOSER_OVERLAP}; +use crate::icons::{self, icon}; +use crate::motion::{self, AnimationExt as _}; +use crate::theme::Theme; + +/// Lists longer than this fold to a [`FOCUS_WINDOW`] around the current item. +pub(crate) const FOLD_ABOVE: usize = 6; +pub(crate) const FOCUS_WINDOW: usize = 3; + +/// The checklist the agent most recently wrote, or `None` when there is none +/// (or the latest write cleared it with an empty list). +/// +/// `entries` is the selected chat's own transcript; subagent traffic lives in +/// separate docs and never reaches it. ACP plans reuse one tool id for every +/// update, so "latest part" — not "first part with this id" — is the contract. +pub(crate) fn latest_todo(entries: &[SessionMessageEntry]) -> Option> { + let items = entries + .iter() + .rev() + .filter(|entry| entry.role == MessageRole::Assistant) + .flat_map(|entry| entry.parts.iter().rev()) + .find_map(|part| match part { + MessagePart::Tool { + call: ToolCall::Todo { items }, + .. + } => Some(items), + _ => None, + })?; + (!items.is_empty()).then(|| items.clone()) +} + +/// What the collapsed header reports. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct TodoSummary { + pub total: usize, + pub done: usize, + /// First in-progress item. + pub active: Option, + /// First item not yet completed. + pub next: Option, +} + +impl TodoSummary { + pub fn of(items: &[TodoItem]) -> Self { + let mut summary = Self { + total: items.len(), + done: 0, + active: None, + next: None, + }; + for (ix, item) in items.iter().enumerate() { + match item.status() { + TodoStatus::Completed => summary.done += 1, + TodoStatus::InProgress => { + summary.active.get_or_insert(ix); + summary.next.get_or_insert(ix); + } + TodoStatus::Pending => { + summary.next.get_or_insert(ix); + } + } + } + summary + } + + pub fn finished(&self) -> bool { + self.total > 0 && self.done == self.total + } + + /// The item the header names: what is being worked on, else what is next. + pub fn headline(&self) -> Option { + self.active.or(self.next) + } +} + +/// The slice of a long list that stays visible when folded: three items with +/// the current one in the middle (one of context before it, one after), +/// clamped to the list ends. A finished list shows its last three. Lists up to +/// [`FOLD_ABOVE`] are never folded. +pub(crate) fn focus_window(items: &[TodoItem]) -> Range { + let total = items.len(); + if total <= FOLD_ABOVE { + return 0..total; + } + let summary = TodoSummary::of(items); + let focus = summary.headline().unwrap_or(total - 1); + let start = focus.saturating_sub(1).min(total - FOCUS_WINDOW); + start..start + FOCUS_WINDOW +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum FoldSide { + Earlier, + Later, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum TodoRow { + /// An item, by its index in the agent's list. + Item(usize), + /// A fold toggle standing in for `count` hidden items (`open`: they are + /// currently revealed, and the row hides them again). + Fold { + side: FoldSide, + count: usize, + open: bool, + }, +} + +/// The rows of the expanded list, top to bottom. Items always keep the +/// agent's order; folds sit at the edges of the list they stand for. +pub(crate) fn rows(items: &[TodoItem], show_earlier: bool, show_later: bool) -> Vec { + let window = focus_window(items); + let earlier = window.start; + let later = items.len() - window.end; + let mut out = Vec::with_capacity(items.len() + 2); + if earlier > 0 { + out.push(TodoRow::Fold { + side: FoldSide::Earlier, + count: earlier, + open: show_earlier, + }); + if show_earlier { + out.extend((0..earlier).map(TodoRow::Item)); + } + } + out.extend(window.clone().map(TodoRow::Item)); + if later > 0 { + if show_later { + out.extend((window.end..items.len()).map(TodoRow::Item)); + } + out.push(TodoRow::Fold { + side: FoldSide::Later, + count: later, + open: show_later, + }); + } + out +} + +/// Identity of a finished list, so a dismissal holds until the agent writes a +/// different one. +fn signature(items: &[TodoItem]) -> u64 { + let mut hasher = DefaultHasher::new(); + for item in items { + item.text.hash(&mut hasher); + item.status().hash(&mut hasher); + } + hasher.finish() +} + +/// Per-chat presentation state. In memory only — like the right-pane flags in +/// `shell::SessionPanels` it lasts for the app run, not across restarts. +#[derive(Debug, Default, Clone)] +pub(crate) struct TodoPanelState { + /// The user's explicit choice; `None` follows the automatic rule: open + /// while work remains, compact once everything is done. + expanded: Option, + show_earlier: bool, + show_later: bool, + /// Signature of the list the user dismissed. + dismissed: Option, + /// Whether the previous frame was settled, to detect the transition. + was_settled: bool, + /// Bumped on every toggle so the list's fade-in replays. + epoch: u32, +} + +impl TodoPanelState { + /// Feed the frame's facts in. `settled` is "everything done and the turn + /// idle": reaching it drops any explicit choice, so the panel tidies itself + /// to the compact state exactly once — a later manual expand is respected. + pub fn observe(&mut self, settled: bool) { + if settled && !self.was_settled { + self.expanded = None; + } + self.was_settled = settled; + } + + /// A finished list is compact by default even while a new turn runs, so + /// last turn's checklist does not pop open again each time you send. + pub fn is_expanded(&self, finished: bool) -> bool { + self.expanded.unwrap_or(!finished) + } + + pub fn toggle(&mut self, finished: bool) { + self.expanded = Some(!self.is_expanded(finished)); + self.epoch = self.epoch.wrapping_add(1); + } + + pub fn toggle_fold(&mut self, side: FoldSide) { + match side { + FoldSide::Earlier => self.show_earlier = !self.show_earlier, + FoldSide::Later => self.show_later = !self.show_later, + } + } + + pub fn dismiss(&mut self, items: &[TodoItem]) { + self.dismissed = Some(signature(items)); + } + + /// A dismissal holds until the agent writes a different list. It covers + /// unfinished lists too: one the agent abandoned (an interrupted turn, a + /// cancelled item that never completes) must not stay pinned forever. + pub fn is_dismissed(&self, items: &[TodoItem]) -> bool { + self.dismissed == Some(signature(items)) + } +} + +/// The latest list for the selected chat, recomputed only when its transcript +/// changes (the scan walks back from the end, but a chat with no todo walks all +/// of it, and the composer renders far more often than the transcript changes). +#[derive(Debug, Default)] +pub(crate) struct TodoCache { + key: Option<(String, u64, usize)>, + items: Option>, +} + +impl TodoCache { + pub fn latest( + &mut self, + chat: &str, + revision: u64, + entries: &[SessionMessageEntry], + ) -> Option<&[TodoItem]> { + let key = (chat.to_owned(), revision, entries.len()); + if self.key.as_ref() != Some(&key) { + self.items = latest_todo(entries); + self.key = Some(key); + } + self.items.as_deref() + } +} + +// --------------------------------------------------------------------------- +// Rendering +// --------------------------------------------------------------------------- + +const HEADER_HEIGHT: f32 = 32.0; +const ROW_PAD_X: f32 = 8.0; +const ROW_RADIUS: f32 = 8.0; +const TEXT_SIZE: f32 = 12.5; +const TEXT_LINE: f32 = 17.0; +const GLYPH_SLOT: f32 = 14.0; +const ACTION_SIZE: f32 = 24.0; +/// One step narrower than the queue tray, which is itself inset from the +/// composer; stacked above a queue the todo tray steps in once more. +const SIDE_INSET: f32 = 16.0; + +impl Composer { + /// The checklist tray, or `None` when the chat has no todo (or the user + /// dismissed a finished one). `below_queue`: a queue tray follows. + pub(crate) fn render_todo_panel( + &mut self, + below_queue: bool, + window: &mut Window, + cx: &mut Context, + ) -> Option { + let live = self.run_live(cx); + let (chat_id, items) = { + let state = self.state.read(cx); + let chat_id = state.selected_chat.clone()?; + let items = self + .todo_cache + .latest(&chat_id, state.transcript_revision, &state.transcript)? + .to_vec(); + (chat_id, items) + }; + let summary = TodoSummary::of(&items); + let settled = summary.finished() && !live; + + let panel = self.todo_panels.entry(chat_id.clone()).or_default(); + panel.observe(settled); + if panel.is_dismissed(&items) { + return None; + } + let expanded = panel.is_expanded(summary.finished()); + let (show_earlier, show_later, epoch) = (panel.show_earlier, panel.show_later, panel.epoch); + + let theme = Theme::of(cx).clone(); + let view = cx.entity_id(); + // Dismissable whenever the turn is idle, finished or not. + let header = self.todo_header(&chat_id, &items, summary, expanded, !live, &theme, cx); + + let surface = crate::queue::queue_panel_surface(&theme).child(header); + let surface = if expanded { + let list = div().flex().flex_col().children( + rows(&items, show_earlier, show_later) + .into_iter() + .map(|row| match row { + TodoRow::Item(ix) => todo_item_row(ix, &items[ix], live, &theme, view, cx), + TodoRow::Fold { side, count, open } => { + self.todo_fold_row(&chat_id, side, count, open, &theme, cx) + } + }), + ); + // Same fade-and-scroll treatment as the queue's rows; a long + // expanded fold scrolls instead of pushing the transcript up. + let rows = crate::edge_fade::edge_faded( + Theme::TRANSCRIPT_FADE_BAND, + true, + true, + div() + .id("todo-panel-rows") + .max_h(window.viewport_size().height * 0.3) + .overflow_y_scroll() + .track_scroll(&self.todo_scroll) + .child(list), + ) + .fade_overflow_y(&self.todo_scroll) + .outset_bottom(TEXT_SIZE); + surface.child( + div() + .mt(px(2.0)) + // The tray's lowest strip hides behind the composer; keep + // the last row clear of that edge. + .pb(px(6.0)) + .child(rows) + .with_animation( + SharedString::from(format!("todo-rows-{epoch}")), + motion::FADE_QUICK.animation(), + |el, t| el.opacity(t), + ), + ) + } else { + surface + }; + + let inset = if below_queue { + SIDE_INSET * 2.0 + } else { + SIDE_INSET + }; + Some( + div() + .mx(px(inset)) + // Cancel the column gap and tuck the tray behind what follows + // (the queue tray, or the composer itself). + .mb(px(-(Theme::SPACE_SM + QUEUE_COMPOSER_OVERLAP))) + .child(crate::frost::frosted( + crate::queue::PANEL_RADIUS, + crate::frost::MENU_BLUR, + surface, + )) + .into_any_element(), + ) + } + + /// `Todo · 2/5` and what is being worked on; one button that toggles the + /// list, plus a dismiss while the turn is idle. + #[allow(clippy::too_many_arguments)] + fn todo_header( + &self, + chat_id: &str, + items: &[TodoItem], + summary: TodoSummary, + expanded: bool, + dismissable: bool, + theme: &Theme, + cx: &mut Context, + ) -> AnyElement { + let accent = theme.accent; + let label = if expanded { + "Collapse todo list" + } else { + "Expand todo list" + }; + let headline: Option<(SharedString, gpui::Hsla)> = if summary.finished() { + Some(("All done".into(), theme.text_faint)) + } else { + summary + .headline() + .map(|ix| (items[ix].text.clone().into(), theme.text_muted)) + }; + + let finished = summary.finished(); + let toggle_chat = chat_id.to_owned(); + let toggle = + div() + .id("todo-panel-toggle") + .role(gpui::Role::Button) + .aria_label(label) + .flex_1() + .min_w_0() + .h(px(HEADER_HEIGHT)) + .px(px(ROW_PAD_X)) + .rounded(px(ROW_RADIUS)) + .flex() + .items_center() + .gap(px(8.0)) + .cursor_pointer() + .hover(|s| s.bg(theme.element_hover)) + .tab_index(0) + .focus_visible(move |s| s.bg(accent.opacity(0.14))) + .on_click(cx.listener(move |this, _, _, cx| { + this.todo_panels + .entry(toggle_chat.clone()) + .or_default() + .toggle(finished); + cx.notify(); + })) + .tooltip(crate::settings::widgets::text_tooltip(label)) + .tooltip_show_delay(Duration::from_millis(350)) + .child( + icon(if summary.finished() { + icons::CHECK + } else { + icons::CHECKLIST + }) + .size(px(14.0)) + .text_color(if summary.finished() { + theme.success + } else { + theme.text_muted + }), + ) + .child( + div() + .flex_none() + .flex() + .items_baseline() + .gap(px(6.0)) + .text_size(px(TEXT_SIZE)) + .child( + div() + .font_weight(gpui::FontWeight::MEDIUM) + .text_color(theme.text) + .child("Todo"), + ) + .child(div().text_color(theme.text_faint).child(SharedString::from( + format!("{}/{}", summary.done, summary.total), + ))), + ) + // The expanded list already names every item; the headline is the + // collapsed state's whole point. + .when(!expanded, |el| { + el.when_some(headline, |el, (text, color)| { + el.child( + div() + .flex_1() + .min_w_0() + .truncate() + .text_size(px(TEXT_SIZE)) + .text_color(color) + .child(text), + ) + }) + }) + .when(expanded, |el| el.child(div().flex_1())) + .child( + icon(if expanded { + icons::ALT_ARROW_DOWN + } else { + icons::ALT_ARROW_UP + }) + .size(px(13.0)) + .text_color(theme.text_muted.opacity(0.7)), + ); + + let dismiss_chat = chat_id.to_owned(); + let dismiss_items = items.to_vec(); + div() + .flex() + .items_center() + .gap(px(2.0)) + .child(toggle) + .when(dismissable, |row| { + row.child( + div() + .id("todo-panel-dismiss") + .role(gpui::Role::Button) + .aria_label("Dismiss todo list") + .size(px(ACTION_SIZE)) + .flex_none() + .flex() + .items_center() + .justify_center() + .rounded(px(5.0)) + .cursor_pointer() + .hover(|s| s.bg(theme.element_hover)) + .tab_index(0) + .focus_visible(move |s| s.bg(accent.opacity(0.18))) + .on_click(cx.listener(move |this, _, _, cx| { + this.todo_panels + .entry(dismiss_chat.clone()) + .or_default() + .dismiss(&dismiss_items); + cx.notify(); + })) + .tooltip(crate::settings::widgets::text_tooltip("Dismiss")) + .tooltip_show_delay(Duration::from_millis(350)) + .child( + icon(icons::CLOSE) + .size(px(11.0)) + .text_color(theme.text_muted.opacity(0.8)), + ), + ) + }) + .into_any_element() + } + + /// "N earlier" / "N later": stands in for the items folded off one end of a + /// long list; clicking reveals them in place, clicking again hides them. + fn todo_fold_row( + &self, + chat_id: &str, + side: FoldSide, + count: usize, + open: bool, + theme: &Theme, + cx: &mut Context, + ) -> AnyElement { + let accent = theme.accent; + let noun = match side { + FoldSide::Earlier => "earlier", + FoldSide::Later => "later", + }; + let text = SharedString::from(format!("{count} {noun}")); + let aria = format!( + "{} {count} {noun} items", + if open { "Hide" } else { "Show" } + ); + // The arrow points to where the hidden items are (or, once revealed, + // back to where they fold away). + let glyph = match (side, open) { + (FoldSide::Earlier, false) | (FoldSide::Later, true) => icons::ALT_ARROW_UP, + _ => icons::ALT_ARROW_DOWN, + }; + let chat_id = chat_id.to_owned(); + div() + .id(SharedString::from(format!("todo-fold-{noun}"))) + .role(gpui::Role::Button) + .aria_label(aria) + .h(px(24.0)) + .px(px(ROW_PAD_X)) + .rounded(px(6.0)) + .flex() + .items_center() + .gap(px(8.0)) + .cursor_pointer() + .hover(|s| s.bg(theme.element_hover)) + .tab_index(0) + .focus_visible(move |s| s.bg(accent.opacity(0.14))) + .on_click(cx.listener(move |this, _, _, cx| { + this.todo_panels + .entry(chat_id.clone()) + .or_default() + .toggle_fold(side); + cx.notify(); + })) + .child( + div() + .w(px(GLYPH_SLOT)) + .flex_none() + .flex() + .justify_center() + .child(icon(glyph).size(px(12.0)).text_color(theme.text_faint)), + ) + .child( + div() + .text_size(px(11.5)) + .text_color(theme.text_faint) + .child(text), + ) + .into_any_element() + } +} + +/// One checklist row: status glyph + text. Completed work recedes, the +/// in-progress item is the brightest thing in the list. +fn todo_item_row( + ix: usize, + item: &TodoItem, + live: bool, + theme: &Theme, + view: gpui::EntityId, + cx: &mut gpui::App, +) -> AnyElement { + let status = item.status(); + let (color, weight) = match status { + TodoStatus::Completed => (theme.text_faint, gpui::FontWeight::NORMAL), + TodoStatus::InProgress => (theme.text, gpui::FontWeight::MEDIUM), + TodoStatus::Pending => (theme.text_muted, gpui::FontWeight::NORMAL), + }; + div() + .id(SharedString::from(format!("todo-item-{ix}"))) + .min_h(px(26.0)) + .px(px(ROW_PAD_X)) + .py(px(4.0)) + .flex() + .items_start() + .gap(px(8.0)) + .child( + div() + .w(px(GLYPH_SLOT)) + .h(px(TEXT_LINE)) + .flex_none() + .flex() + .items_center() + .justify_center() + .child(status_glyph(ix, status, live, theme, view, cx)), + ) + .child( + div() + .flex_1() + .min_w_0() + .text_size(px(TEXT_SIZE)) + .line_height(px(TEXT_LINE)) + .font_weight(weight) + .text_color(color) + .child(SharedString::from(item.text.clone())), + ) + .into_any_element() +} + +/// check / active / pending. An in-progress item only animates while the turn +/// is live; on an idle chat it is a still ring (the agent is not working on +/// it), so a stopped run does not look busy forever. +fn status_glyph( + ix: usize, + status: TodoStatus, + live: bool, + theme: &Theme, + view: gpui::EntityId, + cx: &mut gpui::App, +) -> AnyElement { + match status { + TodoStatus::Completed => icon(icons::CHECK) + .size(px(12.0)) + .text_color(theme.success) + .into_any_element(), + TodoStatus::InProgress if live => crate::loaders::mini_glyph_spinner( + format!("todo-active-{ix}"), + 2.5, + theme.glyph, + view, + cx, + ) + .into_any_element(), + TodoStatus::InProgress => div() + .size(px(10.0)) + .rounded_full() + .border_1() + .border_color(theme.accent) + .flex() + .items_center() + .justify_center() + .child(div().size(px(4.0)).rounded_full().bg(theme.accent)) + .into_any_element(), + TodoStatus::Pending => div() + .size(px(10.0)) + .rounded_full() + .border_1() + .border_color(theme.text_faint.opacity(0.7)) + .into_any_element(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn items(spec: &str) -> Vec { + // x = completed, > = in progress, . = pending + spec.chars() + .enumerate() + .map(|(i, c)| { + TodoItem::new( + format!("item {i}"), + match c { + 'x' => TodoStatus::Completed, + '>' => TodoStatus::InProgress, + _ => TodoStatus::Pending, + }, + ) + }) + .collect() + } + + fn entry(role: MessageRole, parts: Vec) -> SessionMessageEntry { + SessionMessageEntry { + id: "m".into(), + role, + parts, + created_at: 0, + device_id: "d".into(), + status: None, + continuation_of: None, + duration_ms: None, + } + } + + fn todo_part(id: &str, list: Vec) -> MessagePart { + MessagePart::Tool { + id: id.into(), + call: ToolCall::Todo { items: list }, + is_error: false, + resolved: true, + output: None, + diff: None, + output_ref: None, + output_bytes: None, + diff_ref: None, + diff_stats: None, + subagent_ref: None, + subagent_status: None, + subagent_tail: None, + } + } + + #[test] + fn latest_todo_is_the_last_write_across_entries() { + let first = entry(MessageRole::Assistant, vec![todo_part("a", items("x.."))]); + let second = entry( + MessageRole::Assistant, + vec![ + todo_part("b", items("xx.")), + MessagePart::Text { + id: "t".into(), + text: "working".into(), + }, + todo_part("c", items("xx>")), + ], + ); + assert_eq!(latest_todo(&[first, second]), Some(items("xx>"))); + } + + #[test] + fn acp_plan_reusing_one_id_resolves_to_the_newest_segment() { + let plan = zeron_proto::LIVE_PLAN_TOOL_ID; + let old = entry(MessageRole::Assistant, vec![todo_part(plan, items(">.."))]); + let new = entry(MessageRole::Assistant, vec![todo_part(plan, items("x>."))]); + assert_eq!(latest_todo(&[old, new]), Some(items("x>."))); + } + + #[test] + fn an_empty_write_clears_and_user_rows_are_ignored() { + let list = entry(MessageRole::Assistant, vec![todo_part("a", items("x."))]); + let cleared = entry(MessageRole::Assistant, vec![todo_part("b", Vec::new())]); + assert_eq!(latest_todo(&[list.clone(), cleared]), None); + let user = entry(MessageRole::User, vec![todo_part("u", items("."))]); + assert_eq!(latest_todo(&[user]), None); + assert_eq!(latest_todo(&[list]), Some(items("x."))); + assert_eq!(latest_todo(&[]), None); + } + + #[test] + fn summary_counts_and_names_the_current_item() { + let s = TodoSummary::of(&items("xx>..")); + assert_eq!( + (s.total, s.done, s.active, s.next), + (5, 2, Some(2), Some(2)) + ); + assert_eq!(s.headline(), Some(2)); + assert!(!s.finished()); + // Nothing started: the first unfinished item is the headline. + let s = TodoSummary::of(&items("x...")); + assert_eq!((s.active, s.headline()), (None, Some(1))); + // An in-progress item later in the list still wins over an earlier + // pending one: the agent is working on it. + assert_eq!(TodoSummary::of(&items(".>.")).headline(), Some(1)); + let s = TodoSummary::of(&items("xxx")); + assert!(s.finished()); + assert_eq!(s.headline(), None); + assert!(!TodoSummary::of(&[]).finished()); + } + + #[test] + fn short_lists_never_fold() { + for n in 0..=FOLD_ABOVE { + let list = items(&".".repeat(n)); + assert_eq!(focus_window(&list), 0..n); + assert!( + rows(&list, false, false) + .iter() + .all(|r| matches!(r, TodoRow::Item(_))) + ); + } + } + + #[test] + fn long_lists_fold_to_three_around_the_current_item() { + // Current item in the middle: one before, it, one after. + assert_eq!(focus_window(&items("xxxx>.....")), 3..6); + // At the start / end the window clamps instead of shrinking. + assert_eq!(focus_window(&items(">.......")), 0..3); + assert_eq!(focus_window(&items("xxxxxxx>")), 5..8); + // No in-progress item: the first unfinished one is the focus. + assert_eq!(focus_window(&items("xxxx.....")), 3..6); + // Everything done: the tail. + assert_eq!(focus_window(&items("xxxxxxxx")), 5..8); + // Always exactly three when folded. + for spec in ["xxxxxxx>", ">.......", "xx>.....", "xxxxxxxx", "........"] { + assert_eq!(focus_window(&items(spec)).len(), FOCUS_WINDOW, "{spec}"); + } + } + + #[test] + fn rows_put_folds_at_the_edges_with_counts() { + let list = items("xxxx>....."); + assert_eq!( + rows(&list, false, false), + vec![ + TodoRow::Fold { + side: FoldSide::Earlier, + count: 3, + open: false + }, + TodoRow::Item(3), + TodoRow::Item(4), + TodoRow::Item(5), + TodoRow::Fold { + side: FoldSide::Later, + count: 4, + open: false + }, + ] + ); + // No fold on a side with nothing hidden. + let head = rows(&items(">......."), false, false); + assert!(matches!(head.first(), Some(TodoRow::Item(0)))); + assert!(matches!( + head.last(), + Some(TodoRow::Fold { + side: FoldSide::Later, + count: 5, + .. + }) + )); + } + + #[test] + fn expanding_a_fold_reveals_items_without_reordering() { + let list = items("xxxx>....."); + let order = |rows: &[TodoRow]| -> Vec { + rows.iter() + .filter_map(|r| match r { + TodoRow::Item(ix) => Some(*ix), + _ => None, + }) + .collect() + }; + for (earlier, later) in [(false, false), (true, false), (false, true), (true, true)] { + let shown = order(&rows(&list, earlier, later)); + assert!(shown.windows(2).all(|w| w[0] < w[1]), "{earlier} {later}"); + } + // Both open: the whole list, in order, and each toggle still present + // so it can be closed again. + let all = rows(&list, true, true); + assert_eq!(order(&all), (0..list.len()).collect::>()); + assert!(matches!( + all.first(), + Some(TodoRow::Fold { open: true, .. }) + )); + assert!(matches!(all.last(), Some(TodoRow::Fold { open: true, .. }))); + // Earlier items appear after their fold row, later items before theirs. + assert_eq!(all[1], TodoRow::Item(0)); + assert_eq!(all[all.len() - 2], TodoRow::Item(9)); + } + + #[test] + fn panel_follows_the_work_then_tidies_itself_once() { + let mut state = TodoPanelState::default(); + // Working: open by default. + state.observe(false); + assert!(state.is_expanded(false)); + // A finished list stays compact when a new turn starts (not settled, + // since the turn is live) instead of popping open again. + assert!(!state.is_expanded(true)); + // User collapses mid-run: respected. + state.toggle(false); + state.observe(false); + assert!(!state.is_expanded(false)); + // Everything done and idle: compact, and the explicit choice resets. + state.toggle(false); // user had re-expanded + assert!(state.is_expanded(false)); + state.observe(true); + assert!(!state.is_expanded(true)); + // Opening the finished list by hand sticks (no re-collapse each frame). + state.toggle(true); + state.observe(true); + assert!(state.is_expanded(true)); + // New work arrives: back to the automatic open state... + state.observe(false); + assert!(state.is_expanded(false)); + // ...and finishing again tidies again. + state.observe(true); + assert!(!state.is_expanded(true)); + } + + #[test] + fn toggle_replays_the_fade_and_folds_toggle_independently() { + let mut state = TodoPanelState::default(); + let before = state.epoch; + state.toggle(false); + assert_ne!(state.epoch, before); + state.toggle_fold(FoldSide::Later); + assert!(state.show_later && !state.show_earlier); + state.toggle_fold(FoldSide::Later); + assert!(!state.show_later); + } + + #[test] + fn dismissal_holds_only_for_the_list_it_was_made_on() { + let done = items("xxx"); + let mut state = TodoPanelState::default(); + assert!(!state.is_dismissed(&done)); + state.dismiss(&done); + assert!(state.is_dismissed(&done)); + // A changed list (reopened, or a new one) is shown again. + assert!(!state.is_dismissed(&items("xx."))); + assert!(!state.is_dismissed(&items("xxxx"))); + // An abandoned list that will never finish (interrupted turn, a + // cancelled item) can be dismissed too, until the agent rewrites it. + let stuck = items("x.."); + state.dismiss(&stuck); + assert!(state.is_dismissed(&stuck)); + assert!(!state.is_dismissed(&items("xx."))); + } + + #[test] + fn cache_rescans_only_when_the_transcript_changes() { + let mut cache = TodoCache::default(); + let one = vec![entry( + MessageRole::Assistant, + vec![todo_part("a", items("x."))], + )]; + assert_eq!(cache.latest("chat", 1, &one), Some(items("x.").as_slice())); + // Same chat, revision and length: served from cache, not rescanned. + assert_eq!(cache.latest("chat", 1, &one), Some(items("x.").as_slice())); + let two = vec![ + one[0].clone(), + entry(MessageRole::Assistant, vec![todo_part("b", items("xx"))]), + ]; + assert_eq!(cache.latest("chat", 2, &two), Some(items("xx").as_slice())); + // Another chat never reuses the first chat's list. + assert_eq!(cache.latest("other", 2, &[]), None); + } +} + +#[cfg(test)] +mod composer_tests { + use super::*; + use crate::state::AppState; + + fn assistant_with_todo(list: Vec) -> SessionMessageEntry { + SessionMessageEntry { + id: "m".into(), + role: MessageRole::Assistant, + parts: vec![MessagePart::Tool { + id: "t".into(), + call: ToolCall::Todo { items: list }, + is_error: false, + resolved: true, + output: None, + diff: None, + output_ref: None, + output_bytes: None, + diff_ref: None, + diff_stats: None, + subagent_ref: None, + subagent_status: None, + subagent_tail: None, + }], + created_at: 0, + device_id: "d".into(), + status: None, + continuation_of: None, + duration_ms: None, + } + } + + fn items(done: usize, total: usize) -> Vec { + (0..total) + .map(|i| { + TodoItem::new( + format!("step {i}"), + if i < done { + TodoStatus::Completed + } else { + TodoStatus::Pending + }, + ) + }) + .collect() + } + + fn window( + cx: &mut gpui::TestAppContext, + ) -> ( + tempfile::TempDir, + gpui::Entity, + gpui::WindowHandle, + ) { + let dir = tempfile::tempdir().unwrap(); + cx.update(|cx| { + gpui_base::init(cx); + cx.set_global(Theme::dark()); + crate::app_menus::init(cx); + crate::history::init( + Default::default(), + Default::default(), + Default::default(), + Default::default(), + cx, + ); + crate::settings::init(crate::settings::UiSettings::default(), dir.path(), cx); + }); + let state = cx.new(|_| AppState::new()); + let handle = { + let state = state.clone(); + cx.add_window(move |_, cx| Composer::new(state, cx)) + }; + (dir, state, handle) + } + + fn show_todo( + state: &gpui::Entity, + handle: &gpui::WindowHandle, + chat: &str, + entries: Vec, + cx: &mut gpui::TestAppContext, + ) { + state.update(cx, |state, _| { + state.selected_chat = Some(chat.into()); + state.transcript = entries; + state.transcript_revision += 1; + }); + handle + .update(cx, |composer, _, _| composer.current_key = chat.into()) + .unwrap(); + } + + /// Whether the tray is on screen for the selected chat right now. + fn rendered(handle: &gpui::WindowHandle, cx: &mut gpui::TestAppContext) -> bool { + handle + .update(cx, |composer, window, cx| { + composer.render_todo_panel(false, window, cx).is_some() + }) + .unwrap() + } + + #[gpui::test] + fn tray_appears_only_when_the_chat_has_a_todo(cx: &mut gpui::TestAppContext) { + let (_dir, state, handle) = window(cx); + show_todo(&state, &handle, "a", Vec::new(), cx); + assert!(!rendered(&handle, cx)); + show_todo( + &state, + &handle, + "a", + vec![assistant_with_todo(items(1, 3))], + cx, + ); + assert!(rendered(&handle, cx)); + // The agent clearing its list (an empty write) takes the tray away. + show_todo( + &state, + &handle, + "a", + vec![assistant_with_todo(Vec::new())], + cx, + ); + assert!(!rendered(&handle, cx)); + } + + #[gpui::test] + fn open_state_is_remembered_per_chat(cx: &mut gpui::TestAppContext) { + let (_dir, state, handle) = window(cx); + let entries = || vec![assistant_with_todo(items(1, 3))]; + show_todo(&state, &handle, "a", entries(), cx); + assert!(rendered(&handle, cx)); + handle + .update(cx, |composer, _, _| { + composer.todo_panels.get_mut("a").unwrap().toggle(false); + }) + .unwrap(); + // Another chat starts from the automatic state, and A keeps its choice. + show_todo(&state, &handle, "b", entries(), cx); + assert!(rendered(&handle, cx)); + show_todo(&state, &handle, "a", entries(), cx); + assert!(rendered(&handle, cx)); + handle + .read_with(cx, |composer, _| { + assert!(!composer.todo_panels["a"].is_expanded(false)); + assert!(composer.todo_panels["b"].is_expanded(false)); + }) + .unwrap(); + } + + #[gpui::test] + fn a_dismissed_finished_list_stays_gone_until_the_list_changes(cx: &mut gpui::TestAppContext) { + let (_dir, state, handle) = window(cx); + let finished = || vec![assistant_with_todo(items(3, 3))]; + show_todo(&state, &handle, "a", finished(), cx); + assert!(rendered(&handle, cx)); + handle + .update(cx, |composer, _, _| { + composer + .todo_panels + .get_mut("a") + .unwrap() + .dismiss(&items(3, 3)); + }) + .unwrap(); + assert!(!rendered(&handle, cx)); + // A new list (more work) is shown again. + show_todo( + &state, + &handle, + "a", + vec![assistant_with_todo(items(3, 5))], + cx, + ); + assert!(rendered(&handle, cx)); + } +} diff --git a/crates/ui/src/tool_images.rs b/crates/ui/src/tool_images.rs new file mode 100644 index 0000000000..a51f07a0fc --- /dev/null +++ b/crates/ui/src/tool_images.rs @@ -0,0 +1,902 @@ +//! Image previews inside expanded tool chips: files a tool read, wrote or +//! printed. Pixels exist only while their chip is expanded. Opening reads the +//! file from the agent's device and decodes a bounded BGRA thumbnail off the +//! UI thread (handed straight to GPUI — no PNG re-encode, no asset cache). +//! Collapsing starts a short grace; when it lapses the CPU frame and the GPU +//! atlas tiles are released. Rapid toggling inside the grace reuses the +//! texture instead of re-reading and re-decoding the file. + +use std::{ + collections::HashMap, + io::Cursor, + sync::Arc, + time::{Duration, Instant}, +}; + +use gpui::{Context, RenderImage, SharedString, Task}; +use zeron_proto::ToolCall; + +use crate::state::EngineHandle; + +/// Previews per chip; further paths stay in the text output. +pub(crate) const MAX_IMAGES_PER_TOOL: usize = 4; +/// Decoded thumbnail bounds: the 560×220 frame at 2× density (≤ 1.9 MiB +/// BGRA per copy). +const THUMB_MAX: (u32, u32) = (1120, 440); +/// Collapsed chips keep pixels this long, so open/close spam never re-decodes. +pub(crate) const RELEASE_GRACE: Duration = Duration::from_millis(600); +/// Expanded-but-offscreen previews may be dropped past this (CPU + GPU bytes). +const BUDGET_BYTES: usize = 64 * 1024 * 1024; +/// Only previews unpainted this long count as offscreen. List overdraw paints +/// rows beyond the viewport, so a frame-based rule evicted visible previews +/// and re-decoded them in a loop once the painted set outgrew the budget. +const STALE_AFTER: Duration = Duration::from_secs(2); + +const IMAGE_EXTENSIONS: [&str; 9] = [ + "png", "jpg", "jpeg", "gif", "webp", "bmp", "svg", "tif", "tiff", +]; + +pub(crate) fn is_image_path(path: &str) -> bool { + let name = path.rsplit(['/', '\\']).next().unwrap_or(path); + name.rsplit_once('.').is_some_and(|(stem, ext)| { + !stem.is_empty() && IMAGE_EXTENSIONS.iter().any(|e| ext.eq_ignore_ascii_case(e)) + }) +} + +/// UNC (`\\host\share`, `//host/share`), device and verbatim (`\\.\`, +/// `\\?\`, `\??\`) and drive-relative (`C:x.png`) forms. Agent output names +/// them freely, and merely opening one can reach another host (on Windows +/// with the user's NTLM credentials), so they are never previewed. The +/// engine's jail refuses them as well. +fn is_remote_or_device_path(path: &str) -> bool { + let bytes = path.as_bytes(); + let separator = |b: Option<&u8>| matches!(b, Some(b'/' | b'\\')); + (separator(bytes.first()) && separator(bytes.get(1))) + || path.starts_with(r"\??\") + || (bytes.len() >= 2 + && bytes[0].is_ascii_alphabetic() + && bytes[1] == b':' + && !separator(bytes.get(2))) +} + +fn push_path(out: &mut Vec, path: &str) { + if out.len() < MAX_IMAGES_PER_TOOL + && is_image_path(path) + && !is_remote_or_device_path(path) + && !out.iter().any(|known| known.as_ref() == path) + { + out.push(SharedString::from(path.to_owned())); + } +} + +/// Image-looking local paths in free text (commands, output, JSON strings). +/// URLs and glob patterns are skipped; trailing sentence punctuation is not +/// part of a path. +fn push_text_paths(out: &mut Vec, text: &str) { + let separators = |c: char| { + c.is_whitespace() + || matches!( + c, + '"' | '\'' + | '`' + | '<' + | '>' + | '(' + | ')' + | '[' + | ']' + | '{' + | '}' + | ',' + | ';' + | '|' + | '=' + ) + }; + for token in text.split(separators) { + if out.len() >= MAX_IMAGES_PER_TOOL { + return; + } + let token = token.trim_end_matches(['.', ':', '!', '?']); + let token = token.strip_prefix("file://").unwrap_or(token); + if token.contains("://") || token.starts_with("data:") || token.contains(['*', '?']) { + continue; + } + push_path(out, token); + } +} + +fn push_json_paths(out: &mut Vec, value: &serde_json::Value) { + match value { + serde_json::Value::String(text) => push_text_paths(out, text), + serde_json::Value::Array(items) => items.iter().for_each(|v| push_json_paths(out, v)), + serde_json::Value::Object(map) => map.values().for_each(|v| push_json_paths(out, v)), + _ => {} + } +} + +/// The image files a tool call touched, as reported (possibly relative to the +/// chat's working directory). Computed once per row build, never per paint. +pub(crate) fn image_paths(call: &ToolCall, output: Option<&str>) -> Arc<[SharedString]> { + let mut out = Vec::new(); + match call { + ToolCall::ReadFile { path } + | ToolCall::WriteFile { path, .. } + | ToolCall::EditFile { path, .. } + | ToolCall::ApplyPatch { path: Some(path) } => push_path(&mut out, path), + ToolCall::Exec { command } => push_text_paths(&mut out, command), + ToolCall::Mcp { + input: Some(input), .. + } + | ToolCall::Unknown { + input: Some(input), .. + } => push_json_paths(&mut out, input), + _ => {} + } + if let Some(output) = output { + push_text_paths(&mut out, output); + } + out.into() +} + +/// Absolute path on the owning host. Relative paths join the chat's cwd in +/// that host's separator style (the UI may run on another OS). +pub(crate) fn resolve_path(path: &str, cwd: Option<&str>) -> String { + let bytes = path.as_bytes(); + let absolute = path.starts_with(['/', '\\']) + || (bytes.len() >= 3 + && bytes[0].is_ascii_alphabetic() + && bytes[1] == b':' + && matches!(bytes[2], b'/' | b'\\')); + match cwd.filter(|_| !absolute) { + Some(cwd) if cwd.contains('\\') => format!( + "{}\\{}", + cwd.trim_end_matches(['/', '\\']), + path.trim_start_matches("./").replace('/', "\\") + ), + Some(cwd) => format!( + "{}/{}", + cwd.trim_end_matches('/'), + path.trim_start_matches("./") + ), + None => path.to_owned(), + } +} + +/// One file on one device. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub(crate) struct ImageKey { + pub device: String, + pub path: String, +} + +/// What a frame paints for one key. +#[derive(Clone)] +pub(crate) enum Thumb { + Loading, + Ready { + image: Arc, + aspect: f32, + }, + Failed, +} + +enum State { + /// The read/decode task; dropping it cancels the load. + Loading(#[allow(dead_code)] Task<()>), + Ready { + image: Arc, + aspect: f32, + bytes: usize, + painted: Instant, + }, + Failed, +} + +struct Entry { + state: State, + /// Set while no expanded chip holds the key. + release_at: Option, +} + +/// Where loads go: the engine and this device's id (remote owners relay). +pub(crate) struct Loader { + pub engine: Option, + pub local_device: Option, +} + +#[derive(Default)] +pub(crate) struct ToolImages { + entries: HashMap, + /// Expanded chip (`{row}#d{ix}`) → the keys it shows. + holders: HashMap>, + bytes: usize, + sweeper: Option>, +} + +impl ToolImages { + pub(crate) fn is_idle(&self) -> bool { + self.entries.is_empty() + } + + #[cfg(test)] + pub(crate) fn retained_bytes(&self) -> usize { + self.bytes + } + + #[cfg(feature = "appshots-fixture")] + pub(crate) fn stats(&self) -> (usize, usize) { + (self.entries.len(), self.bytes) + } + + /// An expanded chip shows `keys`: cancel their release and start any + /// missing loads. Returns one thumb per key. + pub(crate) fn hold( + &mut self, + chip: &SharedString, + keys: Vec, + loader: &Loader, + get: fn(&mut V) -> &mut ToolImages, + cx: &mut Context, + ) -> Vec { + let now = cx.background_executor().now(); + let thumbs = keys + .iter() + .map(|key| { + if !self.entries.contains_key(key) { + self.start_load(key.clone(), loader, get, cx); + } + let entry = self.entries.get_mut(key).expect("inserted above"); + entry.release_at = None; + match &mut entry.state { + State::Loading(_) => Thumb::Loading, + State::Ready { + image, + aspect, + painted, + .. + } => { + *painted = now; + Thumb::Ready { + image: image.clone(), + aspect: *aspect, + } + } + State::Failed => Thumb::Failed, + } + }) + .collect(); + if self.holders.get(chip) != Some(&keys) { + // Keys that dropped out of the chip (its output changed) start the + // grace like a collapse; ones it still shows stay held. + if let Some(previous) = self.holders.insert(chip.clone(), keys) { + self.start_grace(previous, get, cx); + } + } + thumbs + } + + /// The chip collapsed (or left the transcript): its keys start the grace. + pub(crate) fn release( + &mut self, + chip: &SharedString, + get: fn(&mut V) -> &mut ToolImages, + cx: &mut Context, + ) { + let Some(keys) = self.holders.remove(chip) else { + return; + }; + self.start_grace(keys, get, cx); + } + + /// Keys no expanded chip holds any more start the release grace. + fn start_grace( + &mut self, + keys: Vec, + get: fn(&mut V) -> &mut ToolImages, + cx: &mut Context, + ) { + let at = cx.background_executor().now() + RELEASE_GRACE; + for key in keys { + if self.holders.values().any(|held| held.contains(&key)) { + continue; + } + if let Some(entry) = self.entries.get_mut(&key) { + entry.release_at = Some(at); + } + } + self.schedule_sweep(get, cx); + } + + /// Release every chip of a row whose body is hidden. + pub(crate) fn release_row( + &mut self, + row: &str, + get: fn(&mut V) -> &mut ToolImages, + cx: &mut Context, + ) { + if self.holders.is_empty() { + return; + } + let chips: Vec = self + .holders + .keys() + .filter(|chip| chip_row(chip) == row) + .cloned() + .collect(); + for chip in chips { + self.release(&chip, get, cx); + } + } + + /// Release chips whose row left the transcript (compact folds, resets). + pub(crate) fn retain_rows( + &mut self, + live: impl Fn(&str) -> bool, + get: fn(&mut V) -> &mut ToolImages, + cx: &mut Context, + ) { + if self.holders.is_empty() { + return; + } + let gone: Vec = self + .holders + .keys() + .filter(|chip| !live(chip_row(chip))) + .cloned() + .collect(); + for chip in gone { + self.release(&chip, get, cx); + } + } + + /// Drop everything now (chat switch). + pub(crate) fn clear(&mut self, cx: &mut gpui::App) { + self.holders.clear(); + self.sweeper = None; + let released: Vec<_> = self.entries.drain().map(|(_, e)| e.state).collect(); + self.bytes = 0; + free(released, cx); + } + + fn start_load( + &mut self, + key: ImageKey, + loader: &Loader, + get: fn(&mut V) -> &mut ToolImages, + cx: &mut Context, + ) { + let Some(engine) = loader.engine.clone() else { + self.entries.insert( + key, + Entry { + state: State::Failed, + release_at: None, + }, + ); + return; + }; + let target = (loader.local_device.as_deref() != Some(key.device.as_str())) + .then(|| key.device.clone()); + let task_key = key.clone(); + let task = cx.spawn(async move |view, cx| { + let executor = cx.background_executor().clone(); + let raw = crate::attachments::read_attachment_bytes( + &engine, + &executor, + target.as_deref(), + &task_key.path, + None, + ) + .await; + let decoded = match raw { + Some((_, mime, bytes)) => { + executor + .spawn(async move { decode_thumbnail(&mime, bytes) }) + .await + } + None => None, + }; + view.update(cx, |view, cx| { + get(view).finish(task_key, decoded, cx); + cx.notify(); + }) + .ok(); + }); + self.entries.insert( + key, + Entry { + state: State::Loading(task), + release_at: None, + }, + ); + } + + fn finish( + &mut self, + key: ImageKey, + decoded: Option<(Arc, f32)>, + cx: &mut gpui::App, + ) { + // Released while loading: the result is simply dropped (never uploaded). + let Some(entry) = self.entries.get_mut(&key) else { + return; + }; + if !matches!(entry.state, State::Loading(_)) { + return; + } + entry.state = match decoded { + Some((image, aspect)) => { + let bytes = retained_bytes(&image); + self.bytes += bytes; + State::Ready { + image, + aspect, + bytes, + painted: cx.background_executor().now(), + } + } + None => State::Failed, + }; + tracing::debug!(path = %key.path, retained = self.bytes, "tool image decoded"); + self.enforce_budget(&key, cx); + } + + /// Over budget: drop the least recently painted offscreen previews. The + /// painted set itself may exceed the budget; it is never evicted. A held + /// preview reloads when it is painted again. + fn enforce_budget(&mut self, keep: &ImageKey, cx: &mut gpui::App) { + let mut released = Vec::new(); + let Some(stale) = cx.background_executor().now().checked_sub(STALE_AFTER) else { + return; + }; + while self.bytes > BUDGET_BYTES { + let oldest = self + .entries + .iter() + .filter_map(|(key, entry)| match entry.state { + State::Ready { painted, .. } if key != keep && painted < stale => { + Some((painted, key.clone())) + } + _ => None, + }) + .min_by_key(|(painted, _)| *painted); + let Some((_, key)) = oldest else { break }; + if let Some(entry) = self.entries.remove(&key) { + self.bytes -= state_bytes(&entry.state); + released.push(entry.state); + } + } + free(released, cx); + } + + fn sweep(&mut self, cx: &mut gpui::App) { + let now = cx.background_executor().now(); + let expired: Vec = self + .entries + .iter() + .filter(|(_, entry)| entry.release_at.is_some_and(|at| at <= now)) + .map(|(key, _)| key.clone()) + .collect(); + let mut released = Vec::with_capacity(expired.len()); + for key in expired { + if let Some(entry) = self.entries.remove(&key) { + self.bytes -= state_bytes(&entry.state); + released.push(entry.state); + } + } + if !released.is_empty() { + tracing::debug!( + released = released.len(), + retained = self.bytes, + "tool images released" + ); + } + free(released, cx); + } + + /// One timer for the earliest pending release. Grace is constant, so a + /// later release never precedes the one already scheduled. + fn schedule_sweep( + &mut self, + get: fn(&mut V) -> &mut ToolImages, + cx: &mut Context, + ) { + if self.sweeper.is_some() { + return; + } + let Some(at) = self.entries.values().filter_map(|e| e.release_at).min() else { + return; + }; + let delay = at.saturating_duration_since(cx.background_executor().now()); + self.sweeper = Some(cx.spawn(async move |view, cx| { + cx.background_executor().timer(delay).await; + view.update(cx, |view, cx| { + let images = get(view); + images.sweeper = None; + images.sweep(cx); + images.schedule_sweep(get, cx); + }) + .ok(); + })); + } +} + +fn chip_row(chip: &str) -> &str { + chip.rsplit_once("#d").map_or(chip, |(row, _)| row) +} + +fn retained_bytes(image: &RenderImage) -> usize { + // GPUI keeps the CPU frame for atlas re-uploads plus the GPU copy. + image.as_bytes(0).map_or(0, |b| b.len() * 2) +} + +fn state_bytes(state: &State) -> usize { + match state { + State::Ready { bytes, .. } => *bytes, + _ => 0, + } +} + +/// Free released pixels: dropping a Loading task cancels its read; a Ready +/// image leaves every window's sprite atlas once the current update ends. +fn free(states: Vec, cx: &mut gpui::App) { + let images: Vec> = states + .into_iter() + .filter_map(|state| match state { + State::Ready { image, .. } => Some(image), + _ => None, + }) + .collect(); + if !images.is_empty() { + cx.defer(move |cx| { + for image in images { + cx.drop_image(image, None); + } + }); + } +} + +/// Decode one static frame and downsample to [`THUMB_MAX`], as BGRA. +fn decode_thumbnail(mime: &str, bytes: Vec) -> Option<(Arc, f32)> { + if mime == "image/svg+xml" { + let media = crate::image_media::decode_image(mime, bytes).ok()?; + let image = media + .image + .to_image_data(gpui::SvgRenderer::new(Arc::new(crate::icons::Assets))) + .ok()?; + return Some((image, media.width / media.height.max(1.0))); + } + let mut reader = image::ImageReader::new(Cursor::new(bytes)) + .with_guessed_format() + .ok()?; + // Shared preview bounds: several chips may decode at once. + reader.limits(crate::image_media::raster_limits()); + let decoded = reader.decode().ok()?; + let (width, height) = (decoded.width(), decoded.height()); + if width == 0 || height == 0 { + return None; + } + let decoded = if width > THUMB_MAX.0 || height > THUMB_MAX.1 { + decoded.thumbnail(THUMB_MAX.0, THUMB_MAX.1) + } else { + decoded + }; + let mut pixels = decoded.into_rgba8(); + for pixel in pixels.chunks_exact_mut(4) { + pixel.swap(0, 2); + } + Some(( + Arc::new(RenderImage::new(vec![image::Frame::new(pixels)])), + width as f32 / height as f32, + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui::AppContext as _; + + fn paths(call: ToolCall, output: Option<&str>) -> Vec { + image_paths(&call, output) + .iter() + .map(|p| p.to_string()) + .collect() + } + + #[test] + fn finds_images_in_paths_commands_output_and_json() { + assert_eq!( + paths( + ToolCall::ReadFile { + path: "shots/A.PNG".into() + }, + None + ), + ["shots/A.PNG"] + ); + assert!( + paths( + ToolCall::ReadFile { + path: "src/main.rs".into() + }, + None + ) + .is_empty() + ); + assert_eq!( + paths( + ToolCall::Exec { + command: "python plot.py --out=chart.png".into() + }, + Some("Saved to /tmp/x/chart.png.\nsee https://a.b/c.png and **/*.jpg") + ), + ["chart.png", "/tmp/x/chart.png"] + ); + assert_eq!( + paths( + ToolCall::Mcp { + server: "browser".into(), + tool: "screenshot".into(), + input: Some(serde_json::json!({"opts": {"file": r"C:\shots\home.webp"}})) + }, + None + ), + [r"C:\shots\home.webp"] + ); + let many = (0..9).map(|i| format!("{i}.jpg ")).collect::(); + assert_eq!( + paths(ToolCall::Exec { command: many }, None).len(), + MAX_IMAGES_PER_TOOL + ); + } + + #[test] + fn relative_paths_join_the_owning_hosts_cwd() { + assert_eq!(resolve_path("a/b.png", Some("/w")), "/w/a/b.png"); + assert_eq!(resolve_path("./b.png", Some("/w/")), "/w/b.png"); + assert_eq!(resolve_path("a/b.png", Some(r"C:\w")), r"C:\w\a\b.png"); + assert_eq!(resolve_path(r"D:\x.png", Some(r"C:\w")), r"D:\x.png"); + assert_eq!(resolve_path("/x.png", Some("/w")), "/x.png"); + assert_eq!(resolve_path("x.png", None), "x.png"); + assert_eq!(resolve_path("C:x.png", Some("/w")), "/w/C:x.png"); + } + + #[test] + fn network_device_and_drive_relative_paths_are_never_previewed() { + let output = [ + r"\\attacker\share\a.png", + "//attacker/share/b.png", + r"\/attacker\share\c.png", + "file:////attacker/share/d.png", + r"\\?\UNC\attacker\share\e.png", + r"\\?\C:\shots\f.png", + r"\\.\pipe\g.png", + r"\??\UNC\attacker\share\h.png", + "C:i.png", + ] + .join("\n"); + let found = paths( + ToolCall::Exec { + command: "open ok.png".into(), + }, + Some(&output), + ); + assert_eq!(found, ["ok.png"]); + assert!( + paths( + ToolCall::ReadFile { + path: r"\\attacker\share\a.png".into() + }, + None + ) + .is_empty() + ); + // Ordinary absolute paths in both styles still preview. + assert_eq!( + paths( + ToolCall::Exec { + command: r"cp /tmp/a.png C:\shots\b.png D:/c.png".into() + }, + None + ), + ["/tmp/a.png", r"C:\shots\b.png", "D:/c.png"] + ); + } + + #[test] + fn thumbnails_are_bounded_bgra() { + let mut png = Cursor::new(Vec::new()); + image::RgbaImage::from_pixel(4000, 1000, image::Rgba([255, 0, 0, 255])) + .write_to(&mut png, image::ImageFormat::Png) + .unwrap(); + let (image, aspect) = decode_thumbnail("image/png", png.into_inner()).unwrap(); + let size = image.size(0); + assert!(size.width.0 as u32 <= THUMB_MAX.0 && size.height.0 as u32 <= THUMB_MAX.1); + assert_eq!(aspect, 4.0); + // Red in RGBA is [0, 0, 255, 255] in BGRA. + assert_eq!(&image.as_bytes(0).unwrap()[..4], &[0, 0, 255, 255]); + assert!(decode_thumbnail("image/png", b"nope".to_vec()).is_none()); + // Past the shared decoder bounds: refused, never fully allocated. + let mut wide = Cursor::new(Vec::new()); + image::GrayImage::new(4097, 1) + .write_to(&mut wide, image::ImageFormat::Png) + .unwrap(); + assert!(decode_thumbnail("image/png", wide.into_inner()).is_none()); + } + + struct Host { + images: ToolImages, + } + + fn get(host: &mut Host) -> &mut ToolImages { + &mut host.images + } + + #[gpui::test] + fn collapse_spam_keeps_pixels_and_settled_collapse_frees_them(cx: &mut gpui::TestAppContext) { + let host = cx.new(|_| Host { + images: ToolImages::default(), + }); + let key = ImageKey { + device: "d".into(), + path: "/w/a.png".into(), + }; + let chip = SharedString::from("row#d0"); + let loader = Loader { + engine: None, + local_device: None, + }; + host.update(cx, |host, cx| { + let mut pixels = image::RgbaImage::new(100, 50); + pixels.fill(1); + host.images.entries.insert( + key.clone(), + Entry { + state: State::Loading(Task::ready(())), + release_at: None, + }, + ); + host.images.finish( + key.clone(), + Some(( + Arc::new(RenderImage::new(vec![image::Frame::new(pixels)])), + 2.0, + )), + cx, + ); + assert_eq!(host.images.retained_bytes(), 100 * 50 * 4 * 2); + // Spam: every reopen inside the grace reuses the same texture. + for _ in 0..50 { + let thumbs = host.images.hold(&chip, vec![key.clone()], &loader, get, cx); + assert!(matches!(thumbs[0], Thumb::Ready { aspect: 2.0, .. })); + host.images.release(&chip, get, cx); + } + assert!(!host.images.is_idle()); + }); + cx.executor().advance_clock(RELEASE_GRACE / 2); + cx.run_until_parked(); + host.update(cx, |host, _| assert!(!host.images.is_idle())); + cx.executor().advance_clock(RELEASE_GRACE); + cx.run_until_parked(); + host.update(cx, |host, _| { + assert!(host.images.is_idle(), "collapsed previews are freed"); + assert_eq!(host.images.retained_bytes(), 0); + }); + // A failed load (no engine) is retried after collapse + grace. + host.update(cx, |host, cx| { + let thumbs = host.images.hold(&chip, vec![key.clone()], &loader, get, cx); + assert!(matches!(thumbs[0], Thumb::Failed)); + host.images.release(&chip, get, cx); + }); + cx.executor().advance_clock(RELEASE_GRACE * 2); + cx.run_until_parked(); + host.update(cx, |host, _| assert!(host.images.is_idle())); + } + + #[gpui::test] + fn shared_keys_stay_while_another_chip_holds_them_and_rows_release( + cx: &mut gpui::TestAppContext, + ) { + let host = cx.new(|_| Host { + images: ToolImages::default(), + }); + let key = ImageKey { + device: "d".into(), + path: "/w/a.png".into(), + }; + let loader = Loader { + engine: None, + local_device: None, + }; + host.update(cx, |host, cx| { + host.images + .hold(&"r1#d0".into(), vec![key.clone()], &loader, get, cx); + host.images + .hold(&"r2#d3".into(), vec![key.clone()], &loader, get, cx); + host.images.release(&"r1#d0".into(), get, cx); + assert!(host.images.entries[&key].release_at.is_none()); + host.images.retain_rows(|row| row == "r1", get, cx); + assert!(host.images.entries[&key].release_at.is_some()); + host.images + .hold(&"r2#d3".into(), vec![key.clone()], &loader, get, cx); + host.images.release_row("r2", get, cx); + assert!(host.images.holders.is_empty()); + }); + cx.executor().advance_clock(RELEASE_GRACE * 2); + cx.run_until_parked(); + host.update(cx, |host, _| assert!(host.images.is_idle())); + } + + #[gpui::test] + fn keys_dropped_from_a_held_chip_are_released(cx: &mut gpui::TestAppContext) { + let host = cx.new(|_| Host { + images: ToolImages::default(), + }); + let key = |path: &str| ImageKey { + device: "d".into(), + path: path.into(), + }; + let (a, b) = (key("/w/a.png"), key("/w/b.png")); + let chip = SharedString::from("row#d0"); + let loader = Loader { + engine: None, + local_device: None, + }; + host.update(cx, |host, cx| { + host.images + .hold(&chip, vec![a.clone(), b.clone()], &loader, get, cx); + // The chip's output changed while expanded: `a` is gone from it. + host.images.hold(&chip, vec![b.clone()], &loader, get, cx); + assert!(host.images.entries[&a].release_at.is_some()); + assert!(host.images.entries[&b].release_at.is_none()); + }); + cx.executor().advance_clock(RELEASE_GRACE * 2); + cx.run_until_parked(); + host.update(cx, |host, _| { + assert!(!host.images.entries.contains_key(&a)); + assert!(host.images.entries.contains_key(&b)); + }); + } + + #[gpui::test] + fn budget_drops_only_previews_unpainted_for_a_while(cx: &mut gpui::TestAppContext) { + let host = cx.new(|_| Host { + images: ToolImages::default(), + }); + let insert = |host: &mut Host, i: usize, cx: &mut Context| { + let key = ImageKey { + device: "d".into(), + path: format!("/{i}.png"), + }; + host.images.entries.insert( + key.clone(), + Entry { + state: State::Loading(Task::ready(())), + release_at: None, + }, + ); + // 2048² BGRA = 16 MiB CPU + 16 MiB GPU per preview. + let image = + RenderImage::new(vec![image::Frame::new(image::RgbaImage::new(2048, 2048))]); + host.images.finish(key, Some((Arc::new(image), 1.0)), cx); + }; + host.update(cx, |host, cx| { + for i in 0..3 { + insert(host, i, cx); + } + // Recently painted previews survive even past the budget. + assert_eq!(host.images.entries.len(), 3); + assert!(host.images.retained_bytes() > BUDGET_BYTES); + }); + cx.executor().advance_clock(STALE_AFTER * 2); + host.update(cx, |host, cx| { + insert(host, 3, cx); + assert!(host.images.retained_bytes() <= BUDGET_BYTES); + assert!(host.images.entries.contains_key(&ImageKey { + device: "d".into(), + path: "/3.png".into(), + })); + }); + } +} diff --git a/crates/ui/src/transcript.rs b/crates/ui/src/transcript.rs index ef584e9b8e..ad5fd30166 100644 --- a/crates/ui/src/transcript.rs +++ b/crates/ui/src/transcript.rs @@ -33,15 +33,16 @@ use std::sync::{Arc, Weak}; use std::time::{Duration, Instant}; use gpui::{ - AnyElement, BorderStyle, Bounds, ClipboardItem, ContentMask, Context, Entity, ListAlignment, - ListOffset, ListScrollEvent, ListState, MouseButton, MouseMoveEvent, MouseUpEvent, ObjectFit, - PathBuilder, Pixels, Point, SharedString, StyledImage as _, StyledText, Subscription, Task, - TextAlign, TextRun, Window, canvas, div, img, list, point, prelude::*, px, quad, size, + AnyElement, Bounds, ClipboardItem, ContentMask, Context, Entity, ListAlignment, ListOffset, + ListScrollEvent, ListState, MouseButton, MouseMoveEvent, MouseUpEvent, ObjectFit, PathBuilder, + Pixels, Point, SharedString, StyledImage as _, StyledText, Subscription, Task, TextAlign, + TextRun, Window, canvas, div, img, list, point, prelude::*, px, size, }; use zeron_doc::{MessagePart, MessageRole, MessageStatus, SessionMessageEntry, SubagentStatus}; use zeron_proto::ToolCall; +use crate::markdown::mermaid_cache::{self, MermaidCache}; use crate::markdown::parser::{ Block, BlockTree, IncrementalParser, InlineRun, InlineStyle, parse_full, }; @@ -379,6 +380,9 @@ pub struct ToolItem { /// is the `detail`; `resolved == false` means it is still streaming (the /// chip then defaults open). pub kind: ToolItemKind, + /// Image files the call read, wrote or printed (as reported; relative + /// paths resolve against the chat cwd). Expanding the chip previews them. + pub images: Arc<[SharedString]>, } /// Subagent spawn chips — [`ToolCall::is_subagent_spawn`], the shared genus @@ -728,6 +732,7 @@ fn thought_item(part_id: &str, tree: &BlockTree, live: bool) -> ToolItem { subagent_status: None, subagent_tail: None, kind: ToolItemKind::Thought, + images: Arc::new([]), } } @@ -826,10 +831,7 @@ pub fn tool_detail( }); } let output = output?; - let mut lines: Vec = output - .lines() - .map(|l| SharedString::from(l.to_owned())) - .collect(); + let mut lines: Vec = output.lines().map(output_line).collect(); // Trim trailing blank output lines so the block hugs its content. while lines.last().is_some_and(|l| l.trim().is_empty()) { lines.pop(); @@ -845,6 +847,18 @@ pub fn tool_detail( }) } +/// Chars kept per output line. Lines clip rather than wrap, but each one is +/// still shaped whole for selection — a minified megabyte must not be. +const OUTPUT_LINE_MAX_CHARS: usize = 8 * 1024; + +/// One verbatim output line, cut to [`OUTPUT_LINE_MAX_CHARS`] with an ellipsis. +fn output_line(line: &str) -> SharedString { + match line.char_indices().nth(OUTPUT_LINE_MAX_CHARS) { + Some((cut, _)) => SharedString::from(format!("{}…", &line[..cut])), + None => SharedString::from(line.to_owned()), + } +} + /// Columns at which an invocation line soft-wraps into continuation lines. /// The wrap is char-counted, not measured — block heights must be analytic — /// so the budget is sized to fit the narrowest useful transcript pane. @@ -889,7 +903,14 @@ pub fn call_block(call: &ToolCall) -> Option { ToolCall::WebSearch { query } => query.clone(), ToolCall::Todo { items } => items .iter() - .map(|i| format!("{} {}", if i.done { "[x]" } else { "[ ]" }, i.text)) + .map(|i| { + let mark = match i.status() { + zeron_proto::TodoStatus::Completed => "[x]", + zeron_proto::TodoStatus::InProgress => "[~]", + zeron_proto::TodoStatus::Pending => "[ ]", + }; + format!("{mark} {}", i.text) + }) .collect::>() .join("\n"), ToolCall::Mcp { @@ -1135,6 +1156,7 @@ fn tool_fingerprint(tools: &[ToolItem], auto_open: bool) -> u64 { // Thought/note chips share `ToolCall::Unknown` — the kind is the // label/icon discriminator, so a flip must re-splice. acc.push(t.kind as u8); + acc.push(t.images.len() as u8); // Detail payload arriving (or growing) must re-splice the row even // when the resolved bit didn't change. match t.detail.as_deref() { @@ -1316,10 +1338,33 @@ pub fn rows_for_entry( // Markdown never lands in the bubble. let (body, badges) = crate::badges::split(&parsed.text); let body = agent_message_display(&body); - let (text, mentions) = match crate::composer::sent_mention_display(&body) { + // Chips stand in for their attachments: those leave the strip, and a + // chip carries its upload's path for its progress and, for an image, + // a click opening it. + let chips = zeron_proto::attachment_mentions::attachment_mentions(&body); + let mut attachments = parsed.attachments; + let (text, mut mentions) = match crate::composer::sent_mention_display(&body) { Some((display, spans)) => (display, spans), None => (body, Vec::new()), }; + for span in &mut mentions { + let Some(chip) = chips + .iter() + .find(|chip| Some(chip.index) == span.attachment) + else { + continue; + }; + span.upload = attachments + .iter() + .find(|att| crate::attachments::chip_names_attachment(chip, &att.path)) + .map(|att| att.path.clone().into()); + } + attachments.retain(|att| { + att.appshot.is_some() + || !chips + .iter() + .any(|chip| crate::attachments::chip_names_attachment(chip, &att.path)) + }); let copy_text = (!text.trim().is_empty()).then(|| SharedString::from(text.clone())); return vec![Row { id: entry.id.clone().into(), @@ -1328,7 +1373,7 @@ pub fn rows_for_entry( kind: RowKind::User { text: text.into(), mentions: Arc::new(mentions), - attachments: Arc::new(parsed.attachments), + attachments: Arc::new(attachments), badges: Arc::new(badges), pending, }, @@ -1434,6 +1479,7 @@ pub fn rows_for_entry( subagent_status: *subagent_status, subagent_tail: subagent_tail.clone().map(SharedString::from), kind: ToolItemKind::Call, + images: crate::tool_images::image_paths(call, output.as_deref()), }; if compact { // Compact mode keeps ONE group for the whole turn — @@ -2127,6 +2173,26 @@ pub fn detail_height(detail: &ToolDetail) -> f32 { DETAIL_SEPARATOR + body } +/// Tool image preview frame height; widths follow each image's aspect. +const TOOL_IMAGE_HEIGHT: f32 = 220.0; +const TOOL_IMAGE_MAX_WIDTH: f32 = 560.0; +const TOOL_IMAGE_PAD: f32 = 8.0; +/// Analytic height an open chip's image strip adds (separator + frames). +const TOOL_IMAGE_STRIP_HEIGHT: f32 = DETAIL_SEPARATOR + TOOL_IMAGE_HEIGHT + 2.0 * TOOL_IMAGE_PAD; + +/// The open image context menu: what to copy and where it lives. +struct ImageMenu { + path: String, + devices: Vec, + position: Point, + active: Option, +} + +/// Spawn chips never expand, so they never preview images either. +fn chip_has_images(tool: &ToolItem) -> bool { + !tool.images.is_empty() && !is_spawn_link(tool) +} + /// Height of the "Show full output/diff" affordance row appended below an /// open detail whose full payload lives in the sidecar (chat2-sync A3). pub const BLOB_AFFORDANCE_HEIGHT: f32 = 24.0; @@ -2152,10 +2218,7 @@ fn blob_detail(text: &str, is_diff: bool) -> Option { let diff: zeron_proto::ToolDiff = serde_json::from_str(text).ok()?; return tool_detail(None, Some(&diff), None); } - let mut lines: Vec = text - .lines() - .map(|l| SharedString::from(l.to_owned())) - .collect(); + let mut lines: Vec = text.lines().map(output_line).collect(); while lines.last().is_some_and(|l| l.trim().is_empty()) { lines.pop(); } @@ -3253,6 +3316,23 @@ pub struct Transcript { /// Focused while the lightbox is open so Escape reaches it. attachment_preview_focus: gpui::FocusHandle, attachment_preview_return_focus: Option, + /// Mermaid fences drawn as diagrams in settled Markdown blocks. Each + /// row's media handler requests its fences while laying out, so only + /// painted diagrams are rendered (see [`MermaidCache`]). + diagrams: Rc>, + /// The media handler over `diagrams`, built once and shared by every row. + diagram_media: Option, + /// The single serialized diagram render loop, while requests remain. + diagram_worker: Option>, + /// Prepared diagram shown in the lightbox: its natural size frames the + /// enlarged raster, which is released when the lightbox closes. + diagram_zoom: Option, + /// Expanded tool chips' image previews; freed shortly after collapse. + tool_images: crate::tool_images::ToolImages, + /// Right-click menu over any transcript image (Copy path / Copy image). + image_menu: crate::popover::Popup, + image_menu_focus: gpui::FocusHandle, + image_copy: Option>, /// In-flight ReadAttachmentChunk loads, keyed by device, path and validation /// policy; results land in the global attachment cache. attachment_loads: HashMap>, @@ -3428,6 +3508,12 @@ impl Transcript { .ok(); }); let observe = cx.observe(&state, |this: &mut Self, _, cx| this.sync(cx)); + cx.on_release(|this: &mut Self, cx| { + this.close_diagram_zoom(cx); + crate::image_media::release_media(this.diagrams.borrow_mut().drain(), cx); + this.tool_images.clear(cx); + }) + .detach(); let text_changes = cx.subscribe( &state, |this: &mut Self, state, event: &crate::state::TranscriptTextChanged, cx| { @@ -3528,6 +3614,14 @@ impl Transcript { attachment_preview: None, attachment_preview_focus: cx.focus_handle(), attachment_preview_return_focus: None, + diagrams: Rc::default(), + diagram_media: None, + diagram_worker: None, + diagram_zoom: None, + tool_images: Default::default(), + image_menu: Default::default(), + image_menu_focus: cx.focus_handle(), + image_copy: None, attachment_loads: HashMap::new(), attachment_retries: HashMap::new(), blob_details: HashMap::new(), @@ -4593,6 +4687,7 @@ impl Transcript { } self.chat_id = selected; self.rows.clear(); + self.tool_images.clear(cx); self.row_cache.clear(); self.live_parsers.clear(); self.tree_cache.clear(); @@ -4911,6 +5006,11 @@ impl Transcript { .collect(); self.code_fences .retain(|key, _| active_code_fences.contains(key)); + // Diagram source toggles follow the same stable row identity. + if self.diagrams.borrow().has_source_toggles() { + let row_ids: HashSet<&str> = new_rows.iter().map(|row| row.id.as_ref()).collect(); + self.diagrams.borrow_mut().retain_rows(&row_ids); + } // Text already streamed before this (re)attach is the veil BASELINE: // its rows' veils seed instead of fading (render creates them from @@ -4955,6 +5055,7 @@ impl Transcript { None => { self.rows = new_rows; self.refresh_protected_attachments(cx); + self.release_departed_tool_images(cx); self.reconcile_own_turn_prompt(); // Replay readiness is independent of row content: an empty // reset (or one identical to optimistic rows) still resolves @@ -5007,6 +5108,7 @@ impl Transcript { } } self.refresh_protected_attachments(cx); + self.release_departed_tool_images(cx); self.reconcile_own_turn_prompt(); self.restore_pending_viewport(replay); self.promote_materialized_queued_turn(attached, cx); @@ -5435,10 +5537,23 @@ impl Transcript { let mut keys = std::collections::HashSet::new(); for row in &self.rows { // Generated images use bounded LRU retention, not history-wide protection. - if let RowKind::User { attachments, .. } = &row.kind { - for att in attachments.iter() { + if let RowKind::User { + attachments, + mentions, + .. + } = &row.kind + { + let chipped = mentions + .iter() + .filter(|span| span.kind == crate::composer::ChipKind::Image) + .filter_map(|span| span.upload.as_ref().map(|path| path.to_string())); + for path in attachments + .iter() + .map(|att| att.path.clone()) + .chain(chipped) + { for dev in &devices { - keys.insert((dev.clone(), att.path.clone())); + keys.insert((dev.clone(), path.clone())); } } } @@ -5609,6 +5724,465 @@ impl Transcript { self.attachment_retries.insert(key, task); } + /// Open a sent image chip's upload full size, as its thumbnail did. An + /// image still loading opens on a later click. + fn open_chip_image( + &mut self, + path: &SharedString, + window: &mut Window, + cx: &mut Context, + ) { + let device_ids = self.attachment_device_ids(cx); + if let crate::attachments::AttachmentSnapshot::Loaded(image) = + self.attachment_state(&device_ids, path, None, cx) + { + let preview = crate::attachments::PreviewImage::new(image.name, image.image.clone()); + self.attachment_preview_return_focus = window.focused(cx); + preview.viewer.reset(); + self.attachment_preview = Some(preview); + window.focus(&self.attachment_preview_focus, cx); + cx.notify(); + } + } + + /// Whether an attachment ref is still crossing to the chat's host, and + /// how far along it is. Two ref shapes mean "still crossing": the queued + /// flow's `pending://` (bytes ship engine-side after the send; the host + /// rewrites the ref to an absolute path once they land and the run + /// starts) and the legacy echo's synthetic `pending/`. Percent sources, in + /// order: this attachment's own relay transfer (`WatchTransfers`, by the + /// uploadId its ref names — the leg that actually takes time), else the + /// send-wide staging/legacy upload percent. Neither → no number, so a + /// ring never shows one that isn't a real transfer position (2026-08-20 + /// report: the staging-only percent blinked out in ~100ms and lied about + /// the slow part). + fn upload_status(&self, path: &str, cx: &Context) -> (bool, Option) { + let sending = path.starts_with("pending://") || path.starts_with("pending/"); + let upload_id = path + .strip_prefix("pending://") + .and_then(|rest| rest.split_once('/')) + .map(|(id, _)| id); + let percent = upload_id + .and_then(|id| self.state.read(cx).transfer_percent(id)) + .or_else(|| { + sending + .then(|| self.state.read(cx).upload_progress_percent()) + .flatten() + }); + (sending, percent) + } + + /// What a sent bubble's attachment chips show beyond their label: the + /// progress of an upload still crossing to the host in place of the icon + /// (the strip's thumbnails used to carry it), a warning for an image that + /// can't be read back, and a click target opening a loaded image. + fn chip_overlays( + &mut self, + row_id: &SharedString, + mentions: &[crate::composer::SentMentionSpan], + theme: &Theme, + cx: &mut Context, + ) -> Vec { + use crate::attachments::AttachmentSnapshot; + if mentions.iter().all(|span| span.upload.is_none()) { + return Vec::new(); + } + let device_ids = self.attachment_device_ids(cx); + let mut overlays = Vec::new(); + for (ix, span) in mentions.iter().enumerate() { + let Some(upload) = &span.upload else { + continue; + }; + let (sending, percent) = self.upload_status(upload, cx); + let mut status = sending.then(|| match percent { + Some(pct) => crate::loaders::upload_progress_arc( + pct, + CHIP_STATUS_SIZE, + theme.text_muted.opacity(0.35), + theme.text, + ), + None => crate::loaders::mini_glyph_spinner( + format!("chip-sending-{row_id}-{ix}"), + 2.0, + theme.glyph, + cx.entity_id(), + cx, + ) + .into_any_element(), + }); + let mut open = None; + if span.kind == crate::composer::ChipKind::Image { + // Loading here keeps the image ready for a click, as the + // strip's thumbnail did. + match self.attachment_state(&device_ids, upload, None, cx) { + AttachmentSnapshot::Loaded(_) => open = Some(upload.clone()), + AttachmentSnapshot::Error { .. } if !sending => { + status = Some( + crate::icons::icon(crate::icons::DANGER_TRIANGLE) + .size(px(CHIP_STATUS_SIZE - 2.0)) + .text_color(theme.text_muted) + .into_any_element(), + ); + } + _ => {} + } + } + if open.is_some() || status.is_some() { + overlays.push(ChipOverlay { + range: span.range.clone(), + open, + status, + }); + } + } + overlays + } + + // ---- tool-call image previews + the image context menu ---- + + fn tool_images_of(this: &mut Self) -> &mut crate::tool_images::ToolImages { + &mut this.tool_images + } + + /// The chat whose agent produced these rows (a subagent tab borrows the + /// selected chat's host and cwd). + fn image_host(&self, cx: &gpui::App) -> (Option, Option) { + let state = self.state.read(cx); + let chat = self + .chat_id + .as_deref() + .and_then(|id| state.chats.iter().find(|chat| chat.id == id)) + .or_else(|| state.selected_chat_row()); + ( + chat.map(|chat| chat.device_id.clone()), + chat.and_then(|chat| chat.cwd.clone()), + ) + } + + /// Hold the previews of every chip whose body is mounted; release the + /// rest. Returns `(absolute path, thumb)` lists per chip index. + fn hold_tool_images( + &mut self, + row_id: &SharedString, + tools: &[ToolItem], + opens: &[bool], + folds: &[FoldState], + cx: &mut Context, + ) -> Vec>> { + if !tools.iter().any(chip_has_images) { + return vec![None; tools.len()]; + } + let (device, cwd) = self.image_host(cx); + let loader = crate::tool_images::Loader { + engine: self.state.read(cx).engine().cloned(), + local_device: self.state.read(cx).local_device_id.clone(), + }; + tools + .iter() + .enumerate() + .map(|(ix, tool)| { + if !chip_has_images(tool) { + return None; + } + let chip = SharedString::from(format!("{row_id}#d{ix}")); + let tweening = folds[ix].epoch > 0 + && folds[ix] + .toggled_at + .is_some_and(|at| at.elapsed() < FOLD_TWEEN_WINDOW); + if !opens[ix] && !tweening { + self.tool_images.release(&chip, Self::tool_images_of, cx); + return None; + } + let keys: Vec<_> = tool + .images + .iter() + .map(|path| crate::tool_images::ImageKey { + device: device.clone().unwrap_or_default(), + path: crate::tool_images::resolve_path(path, cwd.as_deref()), + }) + .collect(); + let paths: Vec = keys.iter().map(|key| key.path.clone()).collect(); + let loader = if device.is_some() { + &loader + } else { + &crate::tool_images::Loader { + engine: None, + local_device: None, + } + }; + let thumbs = self + .tool_images + .hold(&chip, keys, loader, Self::tool_images_of, cx); + Some(paths.into_iter().zip(thumbs).collect()) + }) + .collect() + } + + /// Rows left the transcript (compact fold settled, history rebuilt): + /// their chips' previews start the release grace. + fn release_departed_tool_images(&mut self, cx: &mut Context) { + if self.tool_images.is_idle() { + return; + } + let live: HashSet<&str> = self + .rows + .iter() + .filter(|row| matches!(row.kind, RowKind::ToolGroup { .. })) + .map(|row| row.id.as_ref()) + .collect(); + self.tool_images + .retain_rows(|row| live.contains(row), Self::tool_images_of, cx); + } + + fn render_tool_image_strip( + &self, + chip: &SharedString, + images: Vec<(String, crate::tool_images::Thumb)>, + collapses: bool, + theme: &Theme, + cx: &mut Context, + ) -> AnyElement { + use crate::tool_images::Thumb; + let devices: Rc<[String]> = self.image_host(cx).0.into_iter().collect(); + let frames = images.into_iter().enumerate().map(|(ix, (path, thumb))| { + let name = SharedString::from(file_badge_name(&path).to_owned()); + let width = match &thumb { + Thumb::Ready { aspect, .. } => { + (TOOL_IMAGE_HEIGHT * aspect).clamp(48.0, TOOL_IMAGE_MAX_WIDTH) + } + _ => TOOL_IMAGE_HEIGHT * 4.0 / 3.0, + }; + let menu_devices = devices.clone(); + let frame = div() + .id(SharedString::from(format!("{chip}-img{ix}"))) + .w(px(width)) + .h(px(TOOL_IMAGE_HEIGHT)) + .flex_none() + .flex() + .items_center() + .justify_center() + .rounded(px(8.0)) + .overflow_hidden() + .border_1() + .border_color(crate::theme::hairline(0.07)) + .bg(crate::theme::ink(0.035)) + .aria_label(name.clone()) + .on_mouse_down( + gpui::MouseButton::Right, + cx.listener(move |this, event: &gpui::MouseDownEvent, window, cx| { + cx.stop_propagation(); + this.open_image_menu( + path.clone(), + menu_devices.to_vec(), + event.position, + window, + cx, + ); + }), + ); + match thumb { + // Explicit dims: img layout honors intrinsic aspect over a + // percent height (see the attachment strip). + Thumb::Ready { image, .. } => frame.child( + img(image) + .w(px(width - 2.0)) + .h(px(TOOL_IMAGE_HEIGHT - 2.0)) + .rounded(px(7.0)) + .object_fit(ObjectFit::Contain), + ), + Thumb::Loading => frame + .text_size(px(TOOL_TEXT_SIZE)) + .text_color(theme.text_faint) + .child("Loading image…"), + Thumb::Failed => frame + .flex_col() + .gap(px(4.0)) + .px(px(12.0)) + .text_size(px(TOOL_TEXT_SIZE)) + .text_color(theme.text_faint) + .child("Image unavailable") + .child(div().max_w_full().truncate().child(name)), + } + }); + div() + .h(px(TOOL_IMAGE_STRIP_HEIGHT)) + .flex_none() + .flex() + .flex_col() + .child( + div() + .h(px(DETAIL_SEPARATOR)) + .flex_none() + .when(!collapses, |line| line.bg(crate::theme::hairline(0.06))), + ) + .child( + div() + .id(SharedString::from(format!("{chip}-images"))) + .h(px(TOOL_IMAGE_HEIGHT + 2.0 * TOOL_IMAGE_PAD)) + .py(px(TOOL_IMAGE_PAD)) + .when(!collapses, |strip| strip.px(px(TOOL_IMAGE_PAD))) + .flex() + .flex_row() + .gap(px(TOOL_IMAGE_PAD)) + .overflow_x_scroll() + .children(frames), + ) + .into_any_element() + } + + /// Open the image menu at the pointer. `devices` are the candidate owners + /// of `path`, tried in order when copying the image. + fn open_image_menu( + &mut self, + path: String, + devices: Vec, + position: Point, + window: &mut Window, + cx: &mut Context, + ) { + self.image_menu.open(ImageMenu { + path, + devices, + position, + active: None, + }); + window.focus(&self.image_menu_focus, cx); + cx.notify(); + } + + fn close_image_menu(&mut self, cx: &mut Context) { + if self.image_menu.begin_close() { + crate::popover::reap_popup(cx, |this: &mut Self| &mut this.image_menu); + cx.notify(); + } + } + + fn image_menu_key(&mut self, event: &gpui::KeyDownEvent, cx: &mut Context) { + use crate::popover::MenuKey; + let Some(menu) = self.image_menu.open_mut() else { + return; + }; + match crate::popover::classify_key( + &event.keystroke.key, + event.keystroke.modifiers.platform, + event.keystroke.modifiers.control, + ) { + MenuKey::Escape => self.close_image_menu(cx), + MenuKey::Up => menu.active = crate::popover::menu_step(menu.active, 2, -1), + MenuKey::Down => menu.active = crate::popover::menu_step(menu.active, 2, 1), + MenuKey::Enter => { + if let Some(action) = menu.active { + self.dispatch_image_menu(action, cx); + } + } + _ => return, + } + cx.stop_propagation(); + cx.notify(); + } + + fn dispatch_image_menu(&mut self, action: usize, cx: &mut Context) { + let Some(menu) = self.image_menu.as_open() else { + return; + }; + let (path, devices) = (menu.path.clone(), menu.devices.clone()); + self.close_image_menu(cx); + match action { + 0 => cx.write_to_clipboard(gpui::ClipboardItem::new_string(path)), + 1 => self.copy_image(path, devices, cx), + _ => {} + } + } + + /// Copy the original file (not the preview) from its owning device. + fn copy_image(&mut self, path: String, devices: Vec, cx: &mut Context) { + let Some(engine) = self.state.read(cx).engine().cloned() else { + return; + }; + let local = self.state.read(cx).local_device_id.clone(); + self.image_copy = Some(cx.spawn(async move |this, cx| { + let executor = cx.background_executor().clone(); + for device in &devices { + let target = (local.as_deref() != Some(device.as_str())).then_some(device.as_str()); + if let Some((_, mime, bytes)) = crate::attachments::read_attachment_bytes( + &engine, &executor, target, &path, None, + ) + .await + { + let format = + gpui::ImageFormat::from_mime_type(&mime).unwrap_or(gpui::ImageFormat::Png); + // SVG is markup an agent may have written: copy the parsed, + // re-serialized tree (no scripts or external resources), + // as previews render it. + let bytes = if format == gpui::ImageFormat::Svg { + let svg = executor.spawn(async move { + usvg::Tree::from_data(&bytes, &crate::image_media::svg_options()) + .map(|tree| tree.to_string(&usvg::WriteOptions::default())) + }); + let Ok(svg) = svg.await else { + return; + }; + svg.into_bytes() + } else { + bytes + }; + let image = gpui::Image::from_bytes(format, bytes); + this.update(cx, |this, cx| { + cx.write_to_clipboard(gpui::ClipboardItem::new_image(&image)); + this.image_copy = None; + }) + .ok(); + return; + } + } + })); + } + + fn render_image_menu(&self, cx: &mut Context) -> Option { + let menu = self.image_menu.get()?; + let theme = Theme::of(cx).for_popup(); + let mut card = crate::popover::popover_card(&theme) + .id("transcript-image-menu-card") + .track_focus(&self.image_menu_focus) + .role(gpui::Role::Menu) + .w(px(190.0)) + .flex() + .flex_col() + .on_key_down(cx.listener(|this, event, _, cx| this.image_menu_key(event, cx))) + .on_mouse_down_out(cx.listener(|this, _, _, cx| this.close_image_menu(cx))) + .on_mouse_down(gpui::MouseButton::Left, |_, _, cx| cx.stop_propagation()); + for (index, (label, icon)) in [ + ("Copy path", crate::icons::COPY), + ("Copy image", crate::icons::FILE_IMAGE), + ] + .into_iter() + .enumerate() + { + let id = SharedString::from(format!("transcript-image-menu-{index}")); + card = card.child( + crate::popover::menu_row(&theme, menu.active == Some(index), id.clone()) + .id(id) + .role(gpui::Role::MenuItem) + .aria_label(label) + .on_click( + cx.listener(move |this, _, _, cx| this.dispatch_image_menu(index, cx)), + ) + .child( + crate::icons::icon(icon) + .size(px(16.0)) + .text_color(theme.text_muted), + ) + .child(label), + ); + } + Some(crate::popover::menu_at( + "transcript-image-menu", + menu.position, + card.into_any_element(), + self.image_menu.closing_since(), + )) + } + /// The inside of a user bubble: the prompt text, clipped to /// [`USER_COLLAPSED_LINES`] until expanded, plus the expander chevron for /// prompts past the cap. Returns the bubble's children in order. @@ -5629,6 +6203,7 @@ impl Transcript { window: &Window, cx: &mut Context, ) -> AnyElement { + let chip_overlays = self.chip_overlays(row_id, &mentions, theme, cx); let fold = self.user_folds.get(row_id).copied().unwrap_or_default(); let expanded = fold.open.unwrap_or(false); let line_height = @@ -5700,6 +6275,10 @@ impl Transcript { theme, measured_h.clone(), cx.entity_id(), + chip_overlays, + Rc::new(cx.listener(|this, path: &SharedString, window, cx| { + this.open_chip_image(path, window, cx); + })), )); // Height motion uses the same ease-out curve as sidebars, tool folds, // and pane transitions, with duration scaled to travel distance. The @@ -5815,26 +6394,200 @@ impl Transcript { .into_any_element() } - fn render_generated_image( + /// Media wiring for settled Markdown: Mermaid fences become diagrams + /// once rendered and keep their source until then (or on failure). + /// Inline images keep their ordinary chat rendering. + fn diagram_media(&mut self, cx: &Context) -> render::MediaUi { + let cache = self.diagrams.clone(); + let owner = cx.weak_entity(); + let media = self.diagram_media.get_or_insert_with(|| render::MediaUi { + image: None, + diagram: Some(Rc::new(move |code, id, _theme| { + let lookup = cache.borrow_mut().request(code, &id); + match lookup { + mermaid_cache::Lookup::Pending => render::DiagramView::Source, + mermaid_cache::Lookup::Failed(reason) => render::DiagramView::Failed(reason), + mermaid_cache::Lookup::Ready(media) => { + let show_source = cache.borrow().source_visible(&id); + let body = if show_source { + gpui::Empty.into_any_element() + } else { + let open = owner.clone(); + let source = media.clone(); + crate::image_media::preview_element( + &media, + format!("{id}-image").into(), + move |window, cx| { + let _ = open.update(cx, |this, cx| { + this.open_diagram_preview(source.clone(), window, cx) + }); + }, + ) + }; + let toggle = owner.clone(); + render::DiagramView::Diagram(render::DiagramUi { + body, + show_source, + toggle_source: Rc::new(move |_, cx| { + let _ = toggle.update(cx, |this, cx| { + this.diagrams.borrow_mut().toggle_source(&id); + let row = mermaid_cache::frame_row(&id).to_owned(); + this.diagram_layout_changed(&[row.into()], cx); + }); + }), + }) + } + } + })), + }); + media.clone() + } + + /// Render requested diagrams one at a time off the UI thread. The loop + /// picks its next source between renders, so rows that scrolled away in + /// the meantime are skipped rather than queued. + fn ensure_diagram_worker(&mut self, cx: &mut Context) { + if self.diagram_worker.is_some() || !self.diagrams.borrow_mut().take_new_requests() { + return; + } + self.diagram_worker = Some(cx.spawn(async move |this, cx| { + loop { + let job = this.update(cx, |this, cx| { + let job = this.diagrams.borrow_mut().next_job(); + if job.is_none() { + this.diagram_worker = None; + } + job.map(|code| { + ( + code, + crate::markdown::mermaid::Palette::from_theme(Theme::of(cx)), + crate::theme::style_generation(), + ) + }) + }); + let Ok(Some((code, palette, style))) = job else { + return; + }; + let source = code.clone(); + let result = cx + .background_executor() + .spawn(async move { + let svg = crate::markdown::mermaid::render(&source, &palette)?; + crate::image_media::decode_image("image/svg+xml", svg.into_bytes()) + }) + .await; + if this + .update(cx, |this, cx| this.finish_diagram(code, style, result, cx)) + .is_err() + { + return; + } + } + })); + } + + fn finish_diagram( &mut self, - row_id: &SharedString, - owner: &str, - path: &str, - name: &str, - mime_type: &str, + code: String, + style: u32, + result: Result, cx: &mut Context, - ) -> AnyElement { - use crate::attachments::AttachmentSnapshot; - let devices = self.generated_attachment_device_ids(owner, cx); - let state = self.attachment_state(&devices, path, Some(mime_type), cx); - let theme = Theme::of(cx).clone(); - let frame = div() - .id(SharedString::from(format!("{row_id}-generated"))) - .w(px(512.0)) - .max_w_full() - .h(px(320.0)) - .max_h(px(420.0)) - .flex() + ) { + let Some((rows, released)) = self.diagrams.borrow_mut().finish(code, style, result) else { + return; + }; + crate::image_media::release_media(released, cx); + self.diagram_layout_changed(&rows, cx); + } + + /// A diagram replaced its source (or the reverse): remeasure the rows + /// painting it and let the bottom pin and the own-turn runway absorb the + /// height change, exactly like any other layout-affecting row update. + fn diagram_layout_changed(&mut self, rows: &[SharedString], cx: &mut Context) { + let mut changed = false; + for (ix, row) in self.rows.iter().enumerate() { + if rows.contains(&row.id) { + self.list.remeasure_items(ix..ix + 1); + changed = true; + } + } + if !changed { + return; + } + self.viewport_layout_revision = self.viewport_layout_revision.wrapping_add(1); + if self.pinned { + self.wake_spring(); + } + if self.own_turn.is_some() { + self.own_turn_kick = true; + } + cx.notify(); + } + + /// Open a diagram in the shared lightbox, rasterized for the window + /// within the memory the retained diagrams leave available. + fn open_diagram_preview( + &mut self, + source: crate::image_media::MediaImage, + window: &mut Window, + cx: &mut Context, + ) { + self.close_diagram_zoom(cx); + let viewport = window.viewport_size(); + let available = mermaid_cache::MAX_RETAINED_BYTES + .saturating_sub(self.diagrams.borrow().retained_bytes()); + let enlarged = source.enlarged( + ( + f32::from(viewport.width) * 0.9, + f32::from(viewport.height) * 0.85, + ), + window.scale_factor(), + available, + None, + ); + self.attachment_preview_return_focus = window.focused(cx); + self.attachment_preview = Some( + crate::attachments::PreviewImage::new("Mermaid diagram", enlarged.image) + .with_plate(crate::markdown::mermaid::Palette::plate(Theme::of(cx))), + ); + self.diagram_zoom = Some(source); + window.focus(&self.attachment_preview_focus, cx); + cx.notify(); + } + + /// Release a diagram lightbox's dedicated raster. The row's own preview + /// stays with the diagram cache. + fn close_diagram_zoom(&mut self, cx: &mut gpui::App) { + let Some(source) = self.diagram_zoom.take() else { + return; + }; + if let Some(preview) = self.attachment_preview.take() + && !Arc::ptr_eq(&preview.image, &source.image) + { + cx.defer(move |cx| gpui::ImageSource::Image(preview.image).evict(None, cx)); + } + } + + fn render_generated_image( + &mut self, + row_id: &SharedString, + owner: &str, + path: &str, + name: &str, + mime_type: &str, + cx: &mut Context, + ) -> AnyElement { + use crate::attachments::AttachmentSnapshot; + let devices = self.generated_attachment_device_ids(owner, cx); + let state = self.attachment_state(&devices, path, Some(mime_type), cx); + let theme = Theme::of(cx).clone(); + let frame = div() + .id(SharedString::from(format!("{row_id}-generated"))) + .w(px(512.0)) + .max_w_full() + .h(px(320.0)) + .max_h(px(420.0)) + .flex() .items_center() .justify_center() .rounded(px(12.0)) @@ -5849,7 +6602,21 @@ impl Transcript { .min(1.0); let preview = crate::attachments::PreviewImage::new(name.to_owned(), loaded.image.clone()); + let menu_path = path.to_owned(); frame + .on_mouse_down( + gpui::MouseButton::Right, + cx.listener(move |this, event: &gpui::MouseDownEvent, window, cx| { + cx.stop_propagation(); + this.open_image_menu( + menu_path.clone(), + devices.clone(), + event.position, + window, + cx, + ); + }), + ) .w(px(dimensions.0 as f32 * scale)) .h(px(dimensions.1 as f32 * scale)) .role(gpui::Role::Button) @@ -5909,34 +6676,16 @@ impl Transcript { .pt(px(4.0)) .pb(px(6.0)); for (aix, att) in atts.iter().enumerate() { + if !crate::attachments::is_image_path(&att.path) { + strip = strip.child(user_file_pill(&att.name, Theme::of(cx))); + continue; + } let state = self.attachment_state(&device_ids, &att.path, None, cx); // The in-flight send's progress belongs ON the thumbnail - // (2026-08-18 user request). Two ref shapes mean "still - // crossing": the queued flow's `pending://` (bytes ship - // engine-side after the send; the host rewrites the ref to an - // absolute path once they land and the run starts) and the - // legacy echo's synthetic `pending/`. Percent sources, in order: - // this attachment's own relay transfer (`WatchTransfers`, by the - // uploadId its ref names — the leg that actually takes time), - // else the send-wide staging/legacy upload percent. Neither → the - // indeterminate spinner (staged-but-waiting, retry backoff, or - // committed-awaiting-rewrite), so the ring never shows a number - // that isn't a real transfer position (2026-08-20 report: the - // staging-only percent blinked out in ~100ms and lied about the - // slow part). - let sending = att.path.starts_with("pending://") || att.path.starts_with("pending/"); - let upload_id = att - .path - .strip_prefix("pending://") - .and_then(|rest| rest.split_once('/')) - .map(|(id, _)| id); - let uploading = upload_id - .and_then(|id| self.state.read(cx).transfer_percent(id)) - .or_else(|| { - sending - .then(|| self.state.read(cx).upload_progress_percent()) - .flatten() - }); + // (2026-08-18 user request). No percent → the indeterminate + // spinner (staged-but-waiting, retry backoff, or + // committed-awaiting-rewrite); see `upload_status`. + let (sending, uploading) = self.upload_status(&att.path, cx); if let Some(appshot) = &att.appshot { let has_image = matches!(&state, AttachmentSnapshot::Loaded(_)); let theme = Theme::of(cx).clone(); @@ -6094,8 +6843,22 @@ impl Transcript { image.name.clone(), image.image.clone(), ); + let (menu_path, menu_devices) = (att.path.clone(), device_ids.clone()); frame .id(SharedString::from(format!("{row_id}#att{aix}"))) + .on_mouse_down( + gpui::MouseButton::Right, + cx.listener(move |this, event: &gpui::MouseDownEvent, window, cx| { + cx.stop_propagation(); + this.open_image_menu( + menu_path.clone(), + menu_devices.clone(), + event.position, + window, + cx, + ); + }), + ) .relative() .border_1() .border_color(crate::theme::hairline(0.11)) @@ -6223,7 +6986,10 @@ impl Transcript { fn render_working_trailer(&mut self, cx: &mut Context) -> Option { let now = chrono::Utc::now(); - let (sending, queued, elapsed_secs, seed) = if let Some(doc_id) = &self.doc_override { + // `turn` (the turn's start, ms) keys the rolling word and timer, so a + // new turn's labels appear fresh instead of rolling from the last + // turn's final values. + let (sending, queued, elapsed_secs, seed, turn) = if let Some(doc_id) = &self.doc_override { // A subagent doc has no Session row — `indicator_for` would read // the PARENT chat's live state into this tab. Liveness rides the // doc itself instead: the sink's assistant entry streams until @@ -6241,7 +7007,7 @@ impl Transcript { return None; } let elapsed = ((now.timestamp_millis() - last.created_at).max(0) / 1000) as i64; - (false, false, elapsed, flavour_seed(doc_id)) + (false, false, elapsed, flavour_seed(doc_id), last.created_at) } else { let chat_id = self.chat_id.clone()?; // Failed-send state first: past the grace window the trailer IS @@ -6264,7 +7030,7 @@ impl Transcript { .into_any_element(), ); } - let (sending, queued, elapsed) = { + let (sending, queued, elapsed, turn) = { let state = self.state.read(cx); if state.indicator_for(&chat_id, now) != crate::state::Indicator::Working { return None; @@ -6276,8 +7042,8 @@ impl Transcript { // with no timer instead; the word + timer start with the // turn. let turn_started = state.session_for(&chat_id).and_then(|s| s.started_at); - let sending = - sending_bridge(state.pending_send_started(&chat_id, now), turn_started); + let send_started = state.pending_send_started(&chat_id, now); + let sending = sending_bridge(send_started, turn_started); // Degraded delivery path: the send is a durable local write // waiting on connectivity — say so instead of faking // progress. (The overlay holds while degraded, so this line @@ -6286,9 +7052,14 @@ impl Transcript { let elapsed = turn_started .map(|t| now.signed_duration_since(t).num_seconds().max(0)) .unwrap_or(0); - (sending, queued, elapsed) + // While sending, the session row still carries the PREVIOUS + // turn: the bridge keys on its own send instead, so it never + // rolls out of that turn's word or an earlier send's. + let turn = if sending { send_started } else { turn_started } + .map_or(0, |t| t.timestamp_millis()); + (sending, queued, elapsed, turn) }; - (sending, queued, elapsed, flavour_seed(&chat_id)) + (sending, queued, elapsed, flavour_seed(&chat_id), turn) }; if self.compact_mode && !sending && !queued && elapsed_secs > 0 { let entry_id = if let Some(doc_id) = &self.doc_override { @@ -6334,6 +7105,7 @@ impl Transcript { cx, )) .child( + // The flavour word rotates every 7s; roll it as it turns. div() .text_size(crate::typography::ui_rems(12.0)) .text_color(if queued { @@ -6341,19 +7113,29 @@ impl Transcript { } else { theme.text_muted }) - .child(SharedString::from(if queued { - word.to_string() - } else { - format!("{word}…") - })), + .child(crate::roll_text::roll_text( + format!("working-word-{}-{turn}", cx.entity_id()), + SharedString::from(if queued { + word.to_string() + } else { + format!("{word}…") + }), + cx.reduce_motion(), + )), ) .when(!sending, |el| { + // The timer ticks every second; its digits roll in place + // (the unit suffix holds). el.child( div() .relative() .top(px(1.0)) .text_color(theme.text_faint) - .child(SharedString::from(format_elapsed(elapsed_secs))), + .child(crate::roll_text::roll_text( + format!("working-timer-{}-{turn}", cx.entity_id()), + SharedString::from(format_elapsed(elapsed_secs)), + cx.reduce_motion(), + )), ) }) .into_any_element(), @@ -6487,7 +7269,7 @@ impl Transcript { let code = self.code_uis_for(&row.id, &top.block, *block_ix, cx); let opts = RenderOptions { tasks: None, - media: None, + media: Some(self.diagram_media(cx)), row_key: row.id.clone(), veil: None, cache: (!render_cache_disabled()).then(|| self.render_cache.clone()), @@ -6546,9 +7328,18 @@ impl Transcript { }) .clone() }); + // A streaming fence may still be growing, so only blocks the + // reply has already moved past draw diagrams: a later row of + // the same entry proves this block is complete. The tail + // keeps its source until the next block or completion, and + // per-token commits never start a render. + let settled = self + .rows + .get(ix + 1) + .is_some_and(|next| next.entry_id == row.entry_id); let opts = RenderOptions { tasks: None, - media: None, + media: settled.then(|| self.diagram_media(cx)), row_key: row.id.clone(), veil: veil.clone(), cache: (!render_cache_disabled()).then(|| self.render_cache.clone()), @@ -6568,6 +7359,8 @@ impl Transcript { let seed_opts = RenderOptions { cache: None, link: None, + // Seed fences as source text, never request diagrams. + media: None, ..opts.clone() }; let _ = render::render_block( @@ -6635,6 +7428,8 @@ impl Transcript { RowKind::ErrorChip { message } => error_chip(message.clone(), &theme), RowKind::ForkMarker { source_title, .. } => fork_marker(source_title.clone(), &theme), }; + // Diagram fences this row just requested start rendering after layout. + self.ensure_diagram_worker(cx); // Hover-revealed metadata strip: a RESERVED 32px lane under the // entry's last row. Timestamp, copy action, and copied feedback only @@ -7074,7 +7869,13 @@ impl Transcript { && fold .toggled_at .is_some_and(|at| at.elapsed() < TOOL_FOLD.total())); - let tools = if body_visible { tools.as_slice() } else { &[] }; + let tools = if body_visible { + tools.as_slice() + } else { + self.tool_images + .release_row(row_id, Self::tool_images_of, cx); + &[] + }; // Chips render their EFFECTIVE detail: the precomputed doc-resident // one, upgraded in place by a fetched sidecar blob (chat2-sync A3). // Resolved per paint (a HashMap probe per chip) so fetched content @@ -7169,7 +7970,7 @@ impl Transcript { .zip(&invocations) .enumerate() .map(|(ix, (detail, invocation))| { - if detail.is_none() && invocation.is_none() { + if detail.is_none() && invocation.is_none() && !chip_has_images(&tools[ix]) { return FoldState::default(); } self.tool_details @@ -7190,9 +7991,13 @@ impl Transcript { // it: nothing inside the fold opens itself. let default_open = tool.kind == ToolItemKind::Thought && !tool.resolved && !self.compact_mode; - (detail.is_some() || invocation.is_some()) && fold.open.unwrap_or(default_open) + (detail.is_some() || invocation.is_some() || chip_has_images(tool)) + && fold.open.unwrap_or(default_open) }) .collect(); + // Image previews exist only while their chip's body is mounted (open, + // or tweening shut). Every other chip releases its pixels. + let chip_images = self.hold_tool_images(row_id, tools, &detail_opens, &detail_folds, cx); let detail_highlights: Vec>> = details .iter() .enumerate() @@ -7215,36 +8020,44 @@ impl Transcript { .zip(&affordances) .zip(&detail_opens) .zip(&detail_folds) - .map(|((((detail, invocation), affordance), open), fold)| { - let target = if *open { - base_row_height - + invocation.as_deref().map_or(0.0, detail_height) - + detail.as_deref().map_or(0.0, detail_height) - + if affordance.is_some() { - BLOB_AFFORDANCE_HEIGHT - } else { - 0.0 - } - } else { - base_row_height - }; - if !cx.reduce_motion() { - if let Some(at) = fold.toggled_at { - let t = TOOL_FOLD - .curve - .eval(at.elapsed().as_secs_f32() / TOOL_FOLD.total().as_secs_f32()); - if t < 1.0 { - motion_active = true; + .zip(tools.iter()) + .map( + |(((((detail, invocation), affordance), open), fold), tool)| { + let target = if *open { + base_row_height + + invocation.as_deref().map_or(0.0, detail_height) + + if chip_has_images(tool) { + TOOL_IMAGE_STRIP_HEIGHT + } else { + 0.0 + } + + detail.as_deref().map_or(0.0, detail_height) + + if affordance.is_some() { + BLOB_AFFORDANCE_HEIGHT + } else { + 0.0 + } + } else { + base_row_height + }; + if !cx.reduce_motion() { + if let Some(at) = fold.toggled_at { + let t = TOOL_FOLD + .curve + .eval(at.elapsed().as_secs_f32() / TOOL_FOLD.total().as_secs_f32()); + if t < 1.0 { + motion_active = true; + } + return motion::lerp( + fold.from + base_row_height - CHIP_CARD_HEIGHT, + target, + t, + ); } - return motion::lerp( - fold.from + base_row_height - CHIP_CARD_HEIGHT, - target, - t, - ); } - } - target - }) + target + }, + ) .collect(); let reduce_motion = cx.reduce_motion(); let now = Instant::now(); @@ -7444,7 +8257,7 @@ impl Transcript { } let detail = details[ix].clone(); let invocation = invocations[ix].clone(); - if detail.is_none() && invocation.is_none() { + if detail.is_none() && invocation.is_none() && !chip_has_images(tool) { return reveal_tool_row( tool_chip( tool, @@ -7531,7 +8344,12 @@ impl Transcript { .flex_none() .when(!collapses, |line| line.bg(crate::theme::hairline(0.06))), ) - .child(detail_body(invocation, None, theme)); + .child(detail_body(invocation, None, &format!("{key}-call"), theme)); + } + if let Some(images) = chip_images[ix].clone() { + panel = panel.child( + self.render_tool_image_strip(&key, images, collapses, theme, cx), + ); } if let Some(detail) = detail.as_deref() { panel = panel @@ -7541,7 +8359,12 @@ impl Transcript { .flex_none() .when(!collapses, |line| line.bg(crate::theme::hairline(0.06))), ) - .child(detail_body(detail, detail_highlights[ix].clone(), theme)); + .child(detail_body( + detail, + detail_highlights[ix].clone(), + &format!("{key}-out"), + theme, + )); } if let Some(ChipAffordance { blob_ref, label }) = affordance { let loading = matches!( @@ -7701,17 +8524,16 @@ fn agent_message_display(text: &str) -> String { format!("Message from {name}\n\n{body}") } -fn user_bubble_text( - row_id: &SharedString, - text: SharedString, - mentions: Arc>, +/// The bubble text's runs: body text in the sans font, chips as labels in it +/// with their padding pinned to [`crate::composer::CHIP_PAD_FAMILY`], and an +/// agent attribution's name in bold. +fn user_bubble_runs( + text: &str, + mentions: &[crate::composer::SentMentionSpan], theme: &Theme, - measured_h: Rc>, - entity_id: gpui::EntityId, -) -> AnyElement { - // Split runs at chip boundaries (spans are in order): body text keeps the - // sans font, chips read as inline code. Size/line-height flow from the - // bubble's div like every text child. +) -> Vec { + // Split runs at chip boundaries (spans are in order). Size and line + // height flow from the bubble's div like every text child. let body_run = |len: usize| TextRun { len, font: gpui::font(theme.font_sans.clone()), @@ -7720,21 +8542,28 @@ fn user_bubble_text( underline: None, strikethrough: None, }; - let chip_run = |len: usize| TextRun { - len, - font: gpui::font(theme.font_mono.clone()), - color: theme.code_text, - background_color: None, - underline: None, - strikethrough: None, - }; let mut runs = Vec::with_capacity(mentions.len() * 2 + 1); let mut at = 0; for span in mentions.iter() { if at < span.range.start { runs.push(body_run(span.range.start - at)); } - runs.push(chip_run(span.range.len())); + // Chips are labels in the body font, like the composer's, with their + // padding pinned to the face its insets are tuned for. + let [lead, trail] = crate::composer::chip_pad_ranges(&span.range); + for (len, pad) in [ + (lead.len(), true), + (trail.start - lead.end, false), + (trail.len(), true), + ] { + if len > 0 { + let mut run = body_run(len); + if pad { + run.font.family = crate::composer::CHIP_PAD_FAMILY.into(); + } + runs.push(run); + } + } at = span.range.end; } if at < text.len() { @@ -7773,24 +8602,56 @@ fn user_bubble_text( }) .collect(); } + runs +} + +fn user_bubble_text( + row_id: &SharedString, + text: SharedString, + mentions: Arc>, + theme: &Theme, + measured_h: Rc>, + entity_id: gpui::EntityId, + overlays: Vec, + open_image: Rc, +) -> AnyElement { + let runs = user_bubble_runs(&text, &mentions, theme); let styled = StyledText::new(text.clone()).with_runs(runs); let layout = styled.layout().clone(); - let wash = theme.code_wash; + let chip_layout = layout.clone(); + // A chip showing a status paints it over its icon well instead. + let chip_icons: Vec> = mentions + .iter() + .map(|span| { + (!overlays + .iter() + .any(|overlay| overlay.status.is_some() && overlay.range == span.range)) + .then(|| crate::composer::chip_icon(span.kind, &span.path, theme.appearance)) + }) + .collect(); let sel_key: std::sync::Arc = format!("{row_id}:u").into(); let sel_theme = theme.clone(); let underlay = canvas( |bounds, window, _| window.insert_hitbox(bounds, gpui::HitboxBehavior::Normal), move |_, hitbox, window, cx| { - for span in mentions.iter() { - for rect in render::range_rects(&layout, &span.range, 0.0, 2.0) { - window.paint_quad(quad( - rect, - px(5.0), - wash, - px(0.0), - gpui::transparent_black(), - BorderStyle::default(), - )); + for (span, icon) in mentions.iter().zip(&chip_icons) { + // Centered on the label like the composer's chips. + let label = crate::composer::chip_pad_ranges(&span.range)[0].end; + for (row, rect) in render::range_rects(&layout, &span.range, 0.0, 2.0) + .into_iter() + .enumerate() + { + let row_top = rect.origin.y - px(2.0); + let offset = crate::composer::text_chip_label_offset( + &layout, + label, + row_top, + px(2.0), + window, + ); + let rect = Bounds::new(rect.origin + point(px(0.0), offset), rect.size); + let icon = icon.as_ref().filter(|_| row == 0); + crate::composer::paint_chip(window, rect, icon, &sel_theme, cx); } } render::paint_text_selection(window, hitbox, &sel_key, &text, &layout, &sel_theme); @@ -7819,9 +8680,206 @@ fn user_bubble_text( .size_full(); // Same wrapper as the assistant markdown: user-bubble text is // selectable (paint_text_selection above), so it gets the I-beam too. - render::selectable_text_wrap() + let text = render::selectable_text_wrap() .child(underlay) .child(styled) + .into_any_element(); + if overlays.is_empty() { + return text; + } + ChipOverlays { + id: format!("{row_id}-chip-overlays").into(), + child: text, + layout: chip_layout, + overlays, + open: open_image, + well: crate::file_icons::well_bg(theme), + } + .into_any_element() +} + +/// The upload status painted in a sent chip's icon well. +const CHIP_STATUS_SIZE: f32 = 14.0; + +/// One sent chip's extras, laid over its pill: a status in place of its icon +/// and, for a loaded image, a click target opening it full size. +struct ChipOverlay { + range: Range, + open: Option, + status: Option, +} + +/// Wraps a sent bubble's text with its chips' overlays. They follow the +/// shaped text, so a chip that wraps gets a click target per row; its status +/// sits on the first row, where the icon would be. +struct ChipOverlays { + id: SharedString, + child: AnyElement, + layout: gpui::TextLayout, + overlays: Vec, + open: Rc, + well: gpui::Hsla, +} + +impl IntoElement for ChipOverlays { + type Element = Self; + fn into_element(self) -> Self { + self + } +} + +impl gpui::Element for ChipOverlays { + type RequestLayoutState = (); + type PrepaintState = Vec; + + fn id(&self) -> Option { + Some(self.id.clone().into()) + } + + fn source_location(&self) -> Option<&'static core::panic::Location<'static>> { + None + } + + fn request_layout( + &mut self, + _: Option<&gpui::GlobalElementId>, + _: Option<&gpui::InspectorElementId>, + window: &mut Window, + cx: &mut gpui::App, + ) -> (gpui::LayoutId, ()) { + (self.child.request_layout(window, cx), ()) + } + + fn prepaint( + &mut self, + _: Option<&gpui::GlobalElementId>, + _: Option<&gpui::InspectorElementId>, + _: Bounds, + _: &mut (), + window: &mut Window, + cx: &mut gpui::App, + ) -> Vec { + self.child.prepaint(window, cx); + let mut targets = Vec::new(); + for (index, overlay) in self.overlays.iter_mut().enumerate() { + let rects = render::range_rects(&self.layout, &overlay.range, 0.0, 2.0); + if let (Some(status), Some(first)) = (overlay.status.take(), rects.first()) { + // Matches `paint_chip`'s icon well. + let side = first.size.height - px(2.0); + let mut well = div() + .size(side) + .rounded(px(4.0)) + .bg(self.well) + .flex() + .items_center() + .justify_center() + .child(status) + .into_any_element(); + well.prepaint_as_root( + first.origin + point(px(1.0), px(1.0)), + size(side, side).map(gpui::AvailableSpace::Definite), + window, + cx, + ); + targets.push(well); + } + let Some(path) = overlay.open.clone() else { + continue; + }; + for (part, rect) in rects.into_iter().enumerate() { + let open = self.open.clone(); + let path = path.clone(); + let mut target = div() + .id(SharedString::from(format!("{}-{index}-{part}", self.id))) + .w(rect.size.width) + .h(rect.size.height) + .cursor_pointer() + .role(gpui::Role::Button) + .aria_label(format!( + "Preview {}", + crate::attachments::attachment_display_name( + path.rsplit(['/', '\\']).next().unwrap_or(&path), + ) + )) + .on_click(move |event, window, cx| { + // A drag that selects text across the chip is not a click. + if event.click_count() == 1 + && crate::markdown::selection::selected_text().is_none() + { + open(&path, window, cx); + } + }) + .into_any_element(); + target.prepaint_as_root( + rect.origin, + rect.size.map(gpui::AvailableSpace::Definite), + window, + cx, + ); + targets.push(target); + } + } + targets + } + + fn paint( + &mut self, + _: Option<&gpui::GlobalElementId>, + _: Option<&gpui::InspectorElementId>, + _: Bounds, + _: &mut (), + targets: &mut Vec, + window: &mut Window, + cx: &mut gpui::App, + ) { + self.child.paint(window, cx); + for target in targets { + target.paint(window, cx); + } + } +} + +/// A sent message's non-image attachment: the file's icon and name on the +/// same soft pill the transcript uses for file badges. Its bytes are never +/// read back to the transcript, so there is no thumbnail to wait for. +fn user_file_pill(name: &str, theme: &Theme) -> AnyElement { + let name = crate::attachments::attachment_display_name(name); + div() + .h(px(28.0)) + .max_w(px(260.0)) + .min_w_0() + .flex() + .items_center() + .gap(px(6.0)) + .rounded(px(7.0)) + .bg(theme.ink(0.06)) + .pl(px(2.0)) + .pr(px(10.0)) + .text_size(px(13.0)) + .text_color(theme.text.opacity(0.85)) + .child( + div() + .size(px(24.0)) + .flex_none() + .flex() + .items_center() + .justify_center() + .rounded(px(5.0)) + .bg(crate::file_icons::well_bg(theme)) + .child( + crate::file_icons::icon( + crate::file_icons::FileIconIdentity::file(name), + theme.appearance, + ) + .size(px(16.0)), + ), + ) + .child( + div() + .min_w_0() + .truncate() + .child(SharedString::from(name.to_owned())), + ) .into_any_element() } @@ -7989,9 +9047,36 @@ fn file_badge_name(path: &str) -> &str { fn detail_body( detail: &ToolDetail, diff_highlights: Option>, + selection_key: &str, theme: &Theme, ) -> AnyElement { let body = div().w_full().min_w_0().flex().flex_col().overflow_hidden(); + // Every line joins the transcript's selection, so paths, commands and + // output can be drag-selected (across lines too) and copied. Lines clip + // rather than ellipsize: selection maps the pointer onto the full text. + let selectable = |line_ix: usize, text: SharedString, runs: Vec| { + div() + .w_full() + .min_w_0() + .overflow_hidden() + .whitespace_nowrap() + .child(render::selectable_text_element( + format!("{selection_key}-{line_ix}").into(), + text, + runs, + theme.selection, + )) + }; + let plain = |text: &SharedString, color: gpui::Hsla| { + vec![TextRun { + len: text.len(), + font: gpui::font(theme.font_mono.clone()), + color, + background_color: None, + underline: None, + strikethrough: None, + }] + }; match detail { // No comment layer: an inline tool diff is a record of what the // agent already did, not a review surface. @@ -8006,7 +9091,9 @@ fn detail_body( .py(px(6.0)) .font_family(theme.font_mono.clone()) .text_size(px(TOOL_TEXT_SIZE)) - .children(stats.iter().map(|stat| { + .children(stats.iter().enumerate().map(|(stat_ix, stat)| { + let path = SharedString::from(stat.path.clone()); + let runs = plain(&path, theme.text_faint); div() .h(px(OUTPUT_LINE_HEIGHT)) .w_full() @@ -8026,9 +9113,7 @@ fn detail_body( div() .min_w_0() .flex_1() - .truncate() - .text_color(theme.text_faint) - .child(SharedString::from(stat.path.clone())), + .child(selectable(stat_ix, path, runs)), ) .child( div() @@ -8051,15 +9136,22 @@ fn detail_body( .py(px(6.0)) .font_family(theme.font_mono.clone()) .text_size(px(TOOL_TEXT_SIZE)) - .children(lines.iter().map(|line| { - div() + .children(lines.iter().enumerate().map(|(line_ix, line)| { + let row = div() .h(px(OUTPUT_LINE_HEIGHT)) .w_full() .min_w_0() .flex() .items_center() - .text_color(theme.text_faint) - .child(div().w_full().min_w_0().truncate().child(line.clone())) + .text_color(theme.text_faint); + if line.is_empty() { + return row; + } + row.child(selectable( + line_ix, + line.clone(), + plain(line, theme.text_faint), + )) })) .when(*truncated_by > 0, |block| { block.child(more_lines_row(*truncated_by, theme)) @@ -8071,7 +9163,7 @@ fn detail_body( } => body .py(px(6.0)) .text_size(px(TOOL_TEXT_SIZE)) - .children(lines.iter().map(|line| { + .children(lines.iter().enumerate().map(|(line_ix, line)| { let row = div() .h(px(OUTPUT_LINE_HEIGHT)) .w_full() @@ -8081,13 +9173,7 @@ fn detail_body( let Some((text, runs)) = thought_line_text(line, theme) else { return row; // blank separator row }; - row.child( - div() - .w_full() - .min_w_0() - .truncate() - .child(StyledText::new(text).with_runs(runs)), - ) + row.child(selectable(line_ix, text, runs)) })) .when(*truncated_by > 0, |block| { block.child(more_lines_row(*truncated_by, theme)) @@ -8893,6 +9979,23 @@ impl Render for Transcript { // Release gpui-side decoded copies of any images the attachment LRU // evicted since the last frame (no-op when nothing was evicted). crate::attachments::flush_evicted(Some(window), cx); + // Diagram requests are scoped to paint passes; rasters follow the + // reading column and display density (a no-op while both hold). + let released_diagrams = { + let mut diagrams = self.diagrams.borrow_mut(); + let mut released = diagrams.begin_frame(crate::theme::style_generation()); + let list_width = f32::from(self.list.viewport_bounds().size.width); + let column = if list_width > 0.0 { + self.content_width.min(list_width) + } else { + self.content_width + }; + released.extend(diagrams.set_view((column.max(1.0), 480.0), window.scale_factor())); + released + }; + if !released_diagrams.is_empty() { + crate::image_media::release_media(released_diagrams, cx); + } // Own-turn driver: measurements are only authoritative after layout, // so reservation sizing, the send glide, and the outgrown-handoff // each advance at most once per requested frame. Scheduled on every @@ -9025,22 +10128,34 @@ impl Render for Transcript { .on_mouse_move(cx.listener(Self::on_selection_mouse_move)) .on_mouse_up(MouseButton::Left, cx.listener(Self::on_selection_mouse_up)) .on_mouse_up_out(MouseButton::Left, cx.listener(Self::on_selection_mouse_up)) - // FIRST child ⇒ paints first: clears the frame's markdown text- - // selection registry before any row's text elements re-register - // (document paint order = selection order; see markdown/render.rs). - .child(crate::markdown::render::selection_frame_reset()) + // FIRST child ⇒ paints first: clears this transcript's slice of the + // frame's markdown text-selection registry before any row's text + // elements re-register (document paint order = selection order; + // see markdown/render.rs). Keyed by entity so a side chat or + // subagent tab painted in the same frame can't wipe it. + .child(crate::markdown::render::selection_frame_reset_for( + cx.entity_id().as_u64(), + )) .child(content) .child(rail); + let root = root.children(self.render_image_menu(cx)); // Full-size viewer for a clicked user-bubble thumbnail // (AttachmentPreviewDialog: bare lightbox, click closes). if let Some(preview) = self.attachment_preview.clone() { let weak = cx.weak_entity(); - return root.child(crate::attachments::lightbox( + // A diagram's enlarged raster is framed by its natural size. + let natural_size = self + .diagram_zoom + .as_ref() + .map(|source| size(px(source.width), px(source.height))); + return root.child(crate::attachments::lightbox_with_size( window, &preview, &self.attachment_preview_focus, + natural_size, move |window, cx| { if let Ok(focus) = weak.update(cx, |this, cx| { + this.close_diagram_zoom(cx); this.attachment_preview = None; cx.notify(); this.attachment_preview_return_focus.take() @@ -10546,6 +11661,46 @@ mod tests { } } + #[gpui::test] + fn expanded_tool_call_text_drag_selects(cx: &mut gpui::TestAppContext) { + cx.update(|cx| { + gpui_base::init(cx); + cx.set_global(Theme::dark()); + }); + let command = "cat /workspace/src/main.rs"; + struct Detail; + impl Render for Detail { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let theme = Theme::of(cx).clone(); + div() + .w(px(600.0)) + .child(render::selection_frame_reset_for(7)) + .child(detail_body( + &ToolDetail::Output { + lines: vec!["cat /workspace/src/main.rs".into()], + truncated_by: 0, + }, + None, + "probe", + &theme, + )) + } + } + let (_, cx) = cx.add_window_view(|_, _| Detail); + cx.run_until_parked(); + let bounds = render::selection_test_bounds("probe-0"); + let start = bounds.origin + gpui::point(px(1.0), bounds.size.height / 2.0); + let end = start + gpui::point(bounds.size.width + px(40.0), px(0.0)); + cx.simulate_mouse_down(start, MouseButton::Left, gpui::Modifiers::default()); + cx.simulate_mouse_move(end, Some(MouseButton::Left), gpui::Modifiers::default()); + cx.simulate_mouse_up(end, MouseButton::Left, gpui::Modifiers::default()); + assert_eq!( + crate::markdown::selection::selected_text().as_deref(), + Some(command) + ); + crate::markdown::selection::clear_if_owner("probe-0"); + } + #[gpui::test] fn generated_image_click_opens_lightbox_and_escape_restores_focus( cx: &mut gpui::TestAppContext, @@ -12335,6 +13490,7 @@ mod tests { this.state.update(cx, |state, _| { state.sessions.push(zeron_proto::Session { last_completed_turn: None, + running_subagents: 0, chat_id: "chat".into(), device_id: "test".into(), status: zeron_proto::SessionStatus::Working, @@ -13061,6 +14217,275 @@ mod tests { }); } + /// Run the next queued diagram synchronously, as the worker would. + fn render_next_diagram(transcript: &Entity, cx: &mut gpui::App) -> bool { + transcript.update(cx, |this, cx| { + let Some(code) = this.diagrams.borrow_mut().next_job() else { + return false; + }; + let palette = crate::markdown::mermaid::Palette::from_theme(Theme::of(cx)); + let result = crate::markdown::mermaid::render(&code, &palette).and_then(|svg| { + crate::image_media::decode_image("image/svg+xml", svg.into_bytes()) + }); + assert!(result.is_ok(), "fixture diagram must render"); + this.finish_diagram(code, crate::theme::style_generation(), result, cx); + true + }) + } + + const MERMAID_FENCE: &str = + "```mermaid\nflowchart TD\n A[Request] --> B[Plan]\n B --> C[Done]\n```\n"; + + #[test] + fn mermaid_streaming_tail_keeps_source_until_the_reply_moves_past_it() { + with_window(|transcript, window, cx| { + let reply = |status, text: String| { + vec![ + prompt("prompt"), + assistant("reply", status, vec![text_part("text", &text)]), + ] + }; + let mut text = String::from("Intro.\n\n"); + // Token-sized commits through the open fence, then the closed + // fence while it is still the reply's tail. + for token in MERMAID_FENCE.split_inclusive([' ', '\n']) { + text.push_str(token); + transcript.update(cx, |this, cx| { + this.rail_enabled = false; + feed(this, reply(MessageStatus::Streaming, text.clone()), cx) + }); + tick(&transcript, window, cx); + let this = transcript.read(cx); + assert!( + this.diagram_worker.is_none(), + "a streaming tail fence must not start a render" + ); + assert!(this.diagrams.borrow_mut().next_job().is_none()); + } + // Enough reply after the fence that the viewport can anchor on + // it without clamping back to the end. + text.push_str(&format!( + "\nAfter the diagram. {}", + "More detail. ".repeat(300) + )); + transcript.update(cx, |this, cx| { + feed(this, reply(MessageStatus::Streaming, text.clone()), cx) + }); + draw(window, cx); + assert!( + transcript.read(cx).diagram_worker.is_some(), + "a fence followed by more reply renders" + ); + // A pinned end is glued, where rows report no bounds; read + // them with the viewport anchored at the diagram row instead. + transcript.update(cx, |this, cx| { + this.pinned = false; + let ix = this + .rows + .iter() + .position(|row| row.id.as_ref() == "reply#text.1") + .unwrap(); + this.list.scroll_to(ListOffset { + item_ix: ix, + offset_in_item: px(0.0), + }); + cx.notify(); + }); + draw(window, cx); + let fence_height = |cx: &gpui::App| { + let this = transcript.read(cx); + let ix = this + .rows + .iter() + .position(|row| row.id.as_ref() == "reply#text.1") + .unwrap(); + this.list.bounds_for_item(ix).unwrap().size.height + }; + let source_height = fence_height(cx); + assert!(render_next_diagram(&transcript, cx)); + assert!( + !render_next_diagram(&transcript, cx), + "one request per source" + ); + draw(window, cx); + assert_ne!( + fence_height(cx), + source_height, + "the diagram replaced its source" + ); + let frame: SharedString = "reply#text.1-mermaid-1".into(); + transcript.update(cx, |this, cx| { + assert_eq!(this.diagrams.borrow().ready_count(), 1); + this.diagrams.borrow_mut().toggle_source(&frame); + this.diagram_layout_changed(&["reply#text.1".into()], cx); + }); + draw(window, cx); + assert_eq!( + fence_height(cx), + source_height, + "the toggle restores the source" + ); + // Completion keeps row identity, the rendered diagram and + // the reader's source toggle; nothing renders again. + transcript.update(cx, |this, cx| { + feed(this, reply(MessageStatus::Complete, text.clone()), cx) + }); + draw(window, cx); + let this = transcript.read(cx); + assert!(this.diagrams.borrow().source_visible(&frame)); + assert!(this.diagrams.borrow_mut().next_job().is_none()); + }); + } + + #[test] + fn mermaid_diagram_swap_keeps_a_pinned_stream_at_the_bottom() { + with_window(|transcript, window, cx| { + let intro = "A paragraph that explains the result in detail. ".repeat(12); + let reply = |status, tail: &str| { + let mut text = String::new(); + for section in 0..8 { + text.push_str(&format!("Section {section}. {intro}\n\n")); + } + text.push_str(MERMAID_FENCE); + text.push_str(tail); + vec![ + prompt("prompt"), + assistant("reply", status, vec![text_part("text", &text)]), + ] + }; + transcript.update(cx, |this, cx| { + this.rail_enabled = false; + feed( + this, + reply(MessageStatus::Streaming, "\nAfter the diagram."), + cx, + ) + }); + for _ in 0..50 { + tick(&transcript, window, cx); + } + let this = transcript.read(cx); + assert!(this.pinned); + assert!( + this.list.max_offset_for_scrollbar().y > px(100.0), + "must overflow" + ); + assert!(this.distance_from_bottom() <= 1.0); + assert!(render_next_diagram(&transcript, cx)); + for _ in 0..50 { + tick(&transcript, window, cx); + } + let this = transcript.read(cx); + assert!(this.pinned, "a diagram swap is not user input"); + assert!(this.distance_from_bottom() <= 1.0, "pin follows the swap"); + // More streaming after the swap keeps following. + transcript.update(cx, |this, cx| { + feed( + this, + reply( + MessageStatus::Streaming, + &format!("\nAfter the diagram.\n\n{intro}\n\n{intro}"), + ), + cx, + ) + }); + for _ in 0..50 { + tick(&transcript, window, cx); + } + let this = transcript.read(cx); + assert!(this.pinned); + assert!(this.distance_from_bottom() <= 1.0); + assert!(this.diagrams.borrow_mut().next_job().is_none()); + }); + } + + #[test] + fn mermaid_completion_renders_the_tail_without_moving_the_runway_prompt() { + with_window(|transcript, window, cx| { + transcript.update(cx, |this, cx| { + feed(this, vec![prompt("prompt")], cx); + this.rail_enabled = false; + }); + draw(window, cx); + transcript.update(cx, |this, cx| { + this.on_own_send("chat".into(), "prompt".into(), cx) + }); + draw(window, cx); + for _ in 0..80 { + tick(&transcript, window, cx); + } + let reply = |status| { + vec![ + prompt("prompt"), + assistant( + "reply", + status, + vec![text_part( + "text", + &format!("Here it is:\n\n{MERMAID_FENCE}"), + )], + ), + ] + }; + transcript.update(cx, |this, cx| { + feed(this, reply(MessageStatus::Streaming), cx) + }); + for _ in 0..50 { + tick(&transcript, window, cx); + } + assert!(transcript.read(cx).diagram_worker.is_none()); + transcript.update(cx, |this, cx| { + feed(this, reply(MessageStatus::Complete), cx) + }); + for _ in 0..50 { + tick(&transcript, window, cx); + } + let prompt_top = |cx: &gpui::App| { + let this = transcript.read(cx); + assert!(this.own_turn.is_some(), "a short reply keeps its runway"); + this.list.bounds_for_item(0).unwrap().top() + }; + let before = prompt_top(cx); + assert!(render_next_diagram(&transcript, cx)); + // No controller tick between the swap and this paint. + draw(window, cx); + assert_eq!(prompt_top(cx), before, "the diagram swap moved the prompt"); + let this = transcript.read(cx); + assert!( + this.list.max_offset_for_scrollbar().y <= px(2.5), + "the swap opened blank space below the runway" + ); + for _ in 0..50 { + tick(&transcript, window, cx); + } + assert_eq!(prompt_top(cx), before); + + // The lightbox frames an enlarged raster by the diagram's + // natural size and releases it on close. + let media = transcript + .read(cx) + .diagrams + .borrow() + .ready_media() + .expect("rendered diagram"); + cx.update_window(window.into(), |_, window, cx| { + transcript.update(cx, |this, cx| { + this.open_diagram_preview(media.clone(), window, cx) + }); + }) + .unwrap(); + draw(window, cx); + transcript.update(cx, |this, cx| { + assert!(this.attachment_preview.is_some()); + assert!(this.diagram_zoom.is_some()); + this.close_diagram_zoom(cx); + assert!(this.attachment_preview.is_none()); + assert!(this.diagram_zoom.is_none()); + }); + draw(window, cx); + }); + } + #[test] fn send_glide_never_crosses_the_prompt_during_remeasurement() { with_window(|transcript, window, cx| { @@ -13477,10 +14902,202 @@ mod tests { assert_eq!(attachments.len(), 1); } + /// A sent bubble shapes each chip's padding in Geist and its label, like + /// the rest of the body, in the interface font. + #[test] + fn bubble_chip_padding_is_shaped_in_geist() { + let raw = format!( + "open {} and {}", + zeron_proto::attachment_mentions::attachment_mention_link(1, None), + zeron_proto::attachment_mentions::attachment_mention_link(2, Some("notes.md")), + ); + let (text, spans) = crate::composer::sent_mention_display(&raw).expect("chips project"); + let mut theme = Theme::dark(); + theme.font_sans = "Geist Mono".into(); + let runs = user_bubble_runs(&text, &spans, &theme); + assert_eq!(runs.iter().map(|run| run.len).sum::(), text.len()); + let mut at = 0; + let mut pads = Vec::new(); + for run in &runs { + let range = at..at + run.len; + at = range.end; + if run.font.family.as_ref() == crate::composer::CHIP_PAD_FAMILY { + pads.push(range); + } else { + assert_eq!(run.font.family.as_ref(), "Geist Mono"); + } + } + let expected: Vec<_> = spans + .iter() + .flat_map(|span| crate::composer::chip_pad_ranges(&span.range)) + .collect(); + assert_eq!(pads, expected); + } + /// A sent prompt's file mentions render as chips in the transcript: the /// row carries the projected display text plus spans, while ordinary /// prompts keep the empty-spans fast path. The row version derives from /// the RAW text either way, so projection never perturbs the diff key. + /// A chip whose upload is still crossing to the host shows its progress + /// in place of its icon, as the strip's thumbnail did; once the bytes + /// land (the ref is rewritten to a local path) the icon comes back. + #[gpui::test] + fn sent_chips_show_upload_progress_until_their_bytes_land(cx: &mut gpui::TestAppContext) { + use zeron_proto::attachment_mentions::attachment_mention_link; + let dir = tempfile::tempdir().unwrap(); + cx.update(|cx| { + gpui_base::init(cx); + cx.set_global(Theme::dark()); + crate::settings::init(crate::settings::UiSettings::default(), dir.path(), cx); + }); + let state = cx.new(|_| AppState::new()); + state.update(cx, |state, _| { + state.apply_transfers(vec![zeron_proto::TransferProgress { + upload_id: "att-1".into(), + file_name: "Image 1.png".into(), + done: 430, + total: 1_000, + }]) + }); + let transcript = cx.new(|cx| Transcript::new(state, cx)); + let body = format!( + "compare {} with {} and {}", + attachment_mention_link(1, None), + attachment_mention_link(2, Some("notes.md")), + attachment_mention_link(3, Some("done.md")), + ); + let raw = crate::attachments::with_attachments( + &body, + &[ + "pending://att-1/Image 1.png".to_string(), + "pending://att-2/notes.md".to_string(), + "/uploads/ef56ab78-done.md".to_string(), + ], + ); + let mut entry = assistant("u5", MessageStatus::Complete, vec![]); + entry.role = MessageRole::User; + entry.status = None; + entry.parts = vec![text_part("t0", &raw)]; + let rows = rows_for_entry(&entry, true, false, &mut parse); + let RowKind::User { + mentions, + attachments, + .. + } = &rows[0].kind + else { + panic!("expected a user row"); + }; + assert!(attachments.is_empty()); + let mentions = mentions.clone(); + transcript.update(cx, |transcript, cx| { + let overlays = transcript.chip_overlays(&"u5".into(), &mentions, &Theme::dark(), cx); + // The image (43% across) and the file still crossing show a + // status; the landed file shows its icon. + assert_eq!( + overlays + .iter() + .map(|overlay| (overlay.range.clone(), overlay.status.is_some())) + .collect::>(), + [ + (mentions[0].range.clone(), true), + (mentions[1].range.clone(), true) + ] + ); + assert_eq!( + transcript.upload_status("pending://att-1/Image 1.png", cx), + (true, Some(43)) + ); + assert_eq!( + transcript.upload_status("/uploads/ef56ab78-done.md", cx), + (false, None) + ); + }); + } + + /// A sent message with image, file-attachment and workspace-file chips + /// reads as chips in the bubble; only attachments without a chip stay in + /// the strip above. + #[test] + fn user_bubbles_project_attachment_chips_in_place_of_the_strip() { + use crate::composer::ChipKind; + use zeron_proto::attachment_mentions::attachment_mention_link; + let body = format!( + "compare {} with {} and {}", + attachment_mention_link(1, None), + attachment_mention_link(2, Some("notes.md")), + "[composer.rs](zeron-file:crates/ui/src/composer.rs)", + ); + let raw = crate::attachments::with_attachments( + &body, + &[ + "/uploads/ab12cd34-Image_1.png".to_string(), + "/uploads/ef56ab78-notes.md".to_string(), + "/uploads/0a1b2c3d-Image_3.png".to_string(), + ], + ); + let mut entry = assistant("u4", MessageStatus::Complete, vec![]); + entry.role = MessageRole::User; + entry.status = None; + entry.parts = vec![text_part("t0", &raw)]; + let rows = rows_for_entry(&entry, false, false, &mut parse); + let RowKind::User { + text, + mentions, + attachments, + .. + } = &rows[0].kind + else { + panic!("expected a user row"); + }; + assert!(!text.contains("zeron-") && !text.contains("Attached images")); + assert_eq!( + mentions.iter().map(|m| m.kind).collect::>(), + [ChipKind::Image, ChipKind::File, ChipKind::File] + ); + let labels: Vec = mentions + .iter() + .map(|m| { + text[m.range.clone()] + .trim_matches(|c: char| !c.is_alphanumeric()) + .replace('\u{a0}', " ") + }) + .collect(); + assert_eq!(labels, ["Image 1", "notes.md", "composer.rs"]); + // Chips stand in for their attachments: only the unchipped image is + // left in the strip, and each attachment chip carries its upload. + assert_eq!( + attachments + .iter() + .map(|a| a.path.as_str()) + .collect::>(), + ["/uploads/0a1b2c3d-Image_3.png"] + ); + assert_eq!( + mentions + .iter() + .map(|m| m.upload.as_deref()) + .collect::>(), + [ + Some("/uploads/ab12cd34-Image_1.png"), + Some("/uploads/ef56ab78-notes.md"), + None + ] + ); + + // A files-only send hides the placeholder body. + let raw = crate::attachments::with_attachments("", &["/uploads/ef56ab78-notes.md".into()]); + entry.parts = vec![text_part("t0", &raw)]; + let rows = rows_for_entry(&entry, false, false, &mut parse); + let RowKind::User { + text, attachments, .. + } = &rows[0].kind + else { + panic!("expected a user row"); + }; + assert!(text.is_empty()); + assert_eq!(attachments.len(), 1); + } + #[test] fn user_rows_project_file_mentions_into_chips() { let raw = "look at [composer.rs](zeron-file:crates/ui/src/composer.rs) please"; @@ -13498,12 +15115,10 @@ mod tests { ); assert!(text.contains("composer.rs")); assert_eq!(mentions.len(), 1); - assert!(!mentions[0].is_dir); + assert_eq!(mentions[0].kind, crate::composer::ChipKind::File); assert_eq!(mentions[0].path.as_ref(), "crates/ui/src/composer.rs"); - assert_eq!(&text[mentions[0].range.clone()], { - let projected: &str = "\u{00A0}@composer.rs\u{00A0}"; - projected - }); + let chip = &text[mentions[0].range.clone()]; + assert!(chip.starts_with('\u{00A0}') && chip.contains("composer.rs")); assert_eq!(rows[0].version, (raw.len() as u64) << 1); entry.parts = vec![text_part("t0", "no mentions here")]; @@ -13631,6 +15246,21 @@ mod tests { assert_eq!(truncated_by, 40 - OUTPUT_DETAIL_MAX_LINES); assert_eq!(lines[0].as_ref(), " indented 0"); + // A minified megabyte line is cut on a char boundary before shaping, + // in the summary and in a fetched full output alike. + let long = format!("{}\nshort", "é".repeat(1024 * 1024)); + for detail in [ + tool_detail(Some(&long), None, None), + blob_detail(&long, false), + ] { + let Some(ToolDetail::Output { lines, .. }) = detail else { + panic!("expected output detail"); + }; + assert_eq!(lines[0].chars().count(), OUTPUT_LINE_MAX_CHARS + 1); + assert!(lines[0].ends_with("é…")); + assert_eq!(lines[1].as_ref(), "short"); + } + // Nothing → no affordance. assert!(tool_detail(None, None, None).is_none()); assert!(tool_detail(Some("\n\n"), None, None).is_none()); @@ -13652,6 +15282,7 @@ mod tests { subagent_status: None, subagent_tail: None, kind: ToolItemKind::Call, + images: Arc::new([]), }; let edit = |p: &str| ToolItem { part_id: "fixture".into(), @@ -13671,6 +15302,7 @@ mod tests { subagent_status: None, subagent_tail: None, kind: ToolItemKind::Call, + images: Arc::new([]), }; let tools = vec![ exec("ls"), @@ -13706,6 +15338,7 @@ mod tests { subagent_status: None, subagent_tail: None, kind: ToolItemKind::Call, + images: Arc::new([]), }, ToolItem { part_id: "fixture".into(), @@ -13723,6 +15356,7 @@ mod tests { subagent_status: None, subagent_tail: None, kind: ToolItemKind::Call, + images: Arc::new([]), }, ToolItem { part_id: "fixture".into(), @@ -13738,6 +15372,7 @@ mod tests { subagent_status: None, subagent_tail: None, kind: ToolItemKind::Call, + images: Arc::new([]), }, ]; assert_eq!(tool_group_summary(&tools), "Read 1 file · searched 2 times"); @@ -13822,14 +15457,8 @@ mod tests { ); let todo = ToolCall::Todo { items: vec![ - zeron_proto::TodoItem { - text: "a".into(), - done: true, - }, - zeron_proto::TodoItem { - text: "b".into(), - done: false, - }, + zeron_proto::TodoItem::new("a", zeron_proto::TodoStatus::Completed), + zeron_proto::TodoItem::new("b", zeron_proto::TodoStatus::Pending), ], }; assert_eq!(tool_chip_content(&todo), ("Todo", "1/2 done".to_string())); @@ -13909,21 +15538,16 @@ mod tests { // Todos list one item per line with checkbox state. let Some(ToolDetail::Output { lines, .. }) = call_block(&ToolCall::Todo { items: vec![ - zeron_proto::TodoItem { - text: "a".into(), - done: true, - }, - zeron_proto::TodoItem { - text: "b".into(), - done: false, - }, + zeron_proto::TodoItem::new("a", zeron_proto::TodoStatus::Completed), + zeron_proto::TodoItem::new("b", zeron_proto::TodoStatus::InProgress), + zeron_proto::TodoItem::new("c", zeron_proto::TodoStatus::Pending), ], }) else { panic!("expected an output block") }; assert_eq!( lines.iter().map(|l| l.as_ref()).collect::>(), - vec!["[x] a", "[ ] b"] + vec!["[x] a", "[~] b", "[ ] c"] ); // Blank invocation → no block; the chip stays a plain card. @@ -14191,4 +15815,48 @@ impl Transcript { self.list.scroll_to(gpui::ListOffset::default()); cx.notify(); } + + /// Open or close every tool group and image chip, as clicks would. + pub fn fixture_tool_images(&mut self, open: bool, cx: &mut Context) { + let now = Instant::now(); + for row in &self.rows { + let RowKind::ToolGroup { tools, .. } = &row.kind else { + continue; + }; + let fold = self.folds.entry(row.id.clone()).or_default(); + fold.open = Some(open); + for (ix, tool) in tools.iter().enumerate() { + if chip_has_images(tool) { + let chip = self + .tool_details + .entry(SharedString::from(format!("{}#d{ix}", row.id))) + .or_default(); + chip.open = Some(open); + chip.epoch += 1; + chip.toggled_at = Some(now); + } + } + } + self.list.remeasure(); + cx.notify(); + } + + /// `(previews resident, retained bytes)`. + pub fn fixture_tool_image_stats(&self) -> (usize, usize) { + self.tool_images.stats() + } + + pub fn fixture_image_menu( + &mut self, + path: String, + action: Option, + window: &mut Window, + cx: &mut Context, + ) { + let devices = self.image_host(cx).0.into_iter().collect(); + self.open_image_menu(path, devices, gpui::point(px(420.), px(260.)), window, cx); + if let Some(action) = action { + self.dispatch_image_menu(action, cx); + } + } } diff --git a/crates/ui/src/voice.rs b/crates/ui/src/voice.rs new file mode 100644 index 0000000000..b5b62e481c --- /dev/null +++ b/crates/ui/src/voice.rs @@ -0,0 +1,635 @@ +//! Window-owned voice orchestrator. Each session runs in the host's own +//! projectless Codex chat, resumed across calls, and survives navigation +//! between threads; only an explicit end, an engine change or a provider +//! failure closes it. No automatic reconnect or microphone resume. +use crate::orb::OrbState; +use crate::state::EngineHandle; +use gpui::{Context, Task}; +use gpui_tokio::Tokio; +use zeron_proto::voice::*; +mod permissions; +mod remote; + +pub enum VoiceControl { + Mute(bool), +} + +pub struct VoiceController { + pub phase: VoicePhase, + pub chat_id: Option, + pub reason: Option, + pub snapshot: Option, + /// The newest speaker turn's live text, then its final. + pub caption: zeron_voice_session::Caption, + pub microphone_level: u16, + pub speaker_level: u16, + /// The full-window stage is presented over the shell. + pub stage_open: bool, + pub host_name: Option, + speaker_last_loud: Option, + /// When the session first became active; drives the stage's call timer. + pub active_since: Option, + engine: Option, + controls: Option>, + epoch: u64, + task: Option>, + cancellation: tokio_util::sync::CancellationToken, + /// Whether this device's Codex is installed and enabled: no Codex, no + /// voice. `None` until the catalog answers. + codex_ready: Option, + /// The connection `codex_ready` was read from, and its read in flight. + codex_engine: Option, + codex_check: Option>, + /// This call played its start sound and owes its end sound. + cued: bool, +} +impl Default for VoiceController { + fn default() -> Self { + Self { + phase: VoicePhase::Closed, + chat_id: None, + reason: None, + snapshot: None, + caption: Default::default(), + microphone_level: 0, + speaker_level: 0, + stage_open: false, + host_name: None, + speaker_last_loud: None, + active_since: None, + engine: None, + controls: None, + epoch: 0, + task: None, + cancellation: tokio_util::sync::CancellationToken::new(), + codex_ready: None, + codex_engine: None, + codex_check: None, + cued: false, + } + } +} +impl VoiceController { + /// Voice can be offered here. An unread catalog counts as yes, so a slow + /// or failed read never hides it; a live call or its failure stays. + pub fn offered(&self) -> bool { + self.codex_ready != Some(false) || self.is_live() || self.reason.is_some() + } + + /// Read whether `engine`'s device can take a call: installed and enabled + /// in Settings → Agents. `force` re-reads a connection already read. + pub fn check_codex(&mut self, engine: &EngineHandle, force: bool, cx: &mut Context) { + let same = self + .codex_engine + .as_ref() + .is_some_and(|e| e.same_connection(engine)); + if same && !force { + return; + } + if !same { + self.codex_ready = None; + } + self.codex_engine = Some(engine.clone()); + let engine = engine.clone(); + self.codex_check = Some(cx.spawn(async move |this, cx| { + let ready = engine + .client() + .call(zeron_rpc::methods::LIST_HARNESSES, serde_json::json!({})) + .await + .ok() + .and_then(|value| { + serde_json::from_value::>(value) + .ok() + }) + .map(|list| { + list.iter().any(|d| { + d.id == zeron_proto::HarnessId::Codex + && d.installed + && zeron_engine::registry::descriptor_enabled(d) + }) + }); + this.update(cx, |voice, cx| { + if ready.is_some() && ready != voice.codex_ready { + voice.codex_ready = ready; + cx.notify(); + } + }) + .ok(); + })); + } + + /// A session is underway (including its preparation and native stop). + pub fn is_live(&self) -> bool { + matches!(self.phase, VoicePhase::Checking) || self.phase.replaces_composer() + } + + /// Start an orchestrator in a new projectless Codex chat on `host_device_id`. + /// `config` is the chat's Codex configuration; its harness must be Codex. + pub fn start( + &mut self, + engine: EngineHandle, + host_device_id: String, + config: zeron_proto::ChatConfig, + voice: Option, + cx: &mut Context, + ) { + self.cancel(cx); + // The call sounds as it is placed, well before the microphone opens. + crate::sound::play_voice(true); + self.cued = true; + self.engine = Some(engine.clone()); + self.phase = VoicePhase::Checking; + self.chat_id = None; + self.reason = None; + let epoch = self.epoch; + let cancellation = self.cancellation.clone(); + let (controls, control_rx) = tokio::sync::mpsc::channel(8); + self.controls = Some(controls); + let (events, mut event_rx) = tokio::sync::mpsc::channel(32); + let query = Tokio::spawn(cx, async move { + // Media belongs to this viewport, even when the chosen host is + // this device. The execution host never opens audio devices. + remote::run( + engine, + host_device_id, + config, + voice, + cancellation, + events, + control_rx, + ) + .await + }); + self.task = Some(cx.spawn(async move |this, cx| { + let receive = async { + while let Some(event) = event_rx.recv().await { + if this + .update(cx, |controller, cx| { + if controller.epoch == epoch { + controller.reduce(event, cx); + } + }) + .is_err() + { + break; + } + } + }; + receive.await; + let result = query.await.unwrap_or(Err(VoiceRejection::Protocol)); + let _ = this.update(cx, |controller, cx| { + if controller.epoch != epoch { + return; + } + match result { + // A provider failure already reduced to Failed keeps its reason. + Ok(()) if controller.phase == VoicePhase::Failed => {} + Ok(()) => { + controller.phase = VoicePhase::Closed; + } + Err(reason) => { + controller.phase = VoicePhase::Failed; + controller.reason = Some(reason); + } + } + controller.reset_session(); + cx.notify(); + }); + })); + cx.notify(); + } + + /// Ending now sounds: a hang-up or a connected call ending. A failure to + /// connect only explains itself. + fn end_sounds(&self) -> bool { + self.cued && (self.active_since.is_some() || self.reason.is_none()) + } + + fn reset_session(&mut self) { + if self.end_sounds() { + crate::sound::play_voice(false); + } + self.cued = false; + self.snapshot = None; + self.caption.clear(); + self.microphone_level = 0; + self.speaker_level = 0; + self.controls = None; + self.task = None; + self.stage_open = false; + self.active_since = None; + } + + /// End the session (any phase). The provider stop runs in the background. + pub fn cancel(&mut self, cx: &mut Context) { + self.epoch = self.epoch.wrapping_add(1); + self.cancellation.cancel(); + self.cancellation = tokio_util::sync::CancellationToken::new(); + self.reset_session(); + self.phase = VoicePhase::Closed; + self.chat_id = None; + self.reason = None; + self.engine = None; + cx.notify(); + } + + pub fn set_stage_open(&mut self, open: bool, cx: &mut Context) { + let open = open && self.is_live(); + if self.stage_open != open { + self.stage_open = open; + cx.notify(); + } + } + + /// Forget a failure once it has been seen. + pub fn dismiss_reason(&mut self, cx: &mut Context) { + if self.reason.take().is_some() { + if self.phase == VoicePhase::Failed { + self.phase = VoicePhase::Closed; + } + cx.notify(); + } + } + + pub fn belongs_to(&self, engine: &EngineHandle) -> bool { + self.engine + .as_ref() + .is_some_and(|e| e.same_connection(engine)) + } + pub fn muted(&self) -> bool { + self.snapshot.as_ref().is_some_and(|s| s.muted) + } + pub fn toggle_mute(&mut self, cx: &mut Context) { + let muted = self.muted(); + if let Some(snapshot) = &mut self.snapshot { + snapshot.muted = !muted; + } + cx.notify(); + if let Some(controls) = &self.controls { + if controls.try_send(VoiceControl::Mute(!muted)).is_err() { + self.cancel(cx); + self.reason = Some(VoiceRejection::Overflow); + } + } + } + + /// The utterance the caption shows (a speaker turn), if the provider names it. + pub fn caption_item(&self) -> Option<&str> { + self.caption.item() + } + + pub fn orb_state(&self) -> OrbState { + orb_state(self.phase, self.snapshot.as_ref()) + } + + /// Normalized 0…1 microphone loudness; zero while muted. + pub fn microphone_level(&self) -> f32 { + if self.muted() { + return 0.0; + } + f32::from(self.microphone_level) / f32::from(u16::MAX) + } + + /// Normalized 0…1 speaker peak; the orb smooths each channel separately. + pub fn speaker_level(&self) -> f32 { + f32::from(self.speaker_level) / f32::from(u16::MAX) + } + + pub fn reduce(&mut self, event: VoiceEvent, cx: &mut Context) { + match event { + VoiceEvent::Snapshot { mut snapshot } => { + if let Some(previous) = &self.snapshot { + snapshot.playing = previous.playing; + } + if self.chat_id.is_none() { + self.chat_id = Some(snapshot.chat_id.clone()); + } + + if self.chat_id.as_deref() != Some(snapshot.chat_id.as_str()) { + return; + } + if self.snapshot.as_ref().is_some_and(|old| { + old.generation != snapshot.generation || old.session_id != snapshot.session_id + }) { + return; + } + self.phase = snapshot.phase; + if self.phase == VoicePhase::Active && self.active_since.is_none() { + self.active_since = Some(std::time::Instant::now()); + } + self.snapshot = Some(snapshot); + } + VoiceEvent::Partial { + generation, + item_id, + text, + } if self + .snapshot + .as_ref() + .is_some_and(|s| s.generation == generation) => + { + if !self.caption.partial(item_id, &text) { + self.cancel(cx); + self.phase = VoicePhase::Failed; + self.reason = Some(VoiceRejection::Overflow); + } + } + VoiceEvent::Levels { + generation, + microphone, + speaker, + } if self + .snapshot + .as_ref() + .is_some_and(|s| s.generation == generation) => + { + self.microphone_level = microphone; + self.speaker_level = speaker; + if let Some(snapshot) = &mut self.snapshot { + let threshold = if snapshot.playing { 328 } else { 655 }; + if speaker >= threshold { + snapshot.playing = true; + self.speaker_last_loud = Some(std::time::Instant::now()); + } else if self + .speaker_last_loud + .is_none_or(|t| t.elapsed() >= std::time::Duration::from_millis(250)) + { + snapshot.playing = false; + } + } + } + // The final segment keeps showing as the caption until the next + // item starts; the canonical copy is already in the transcript. + VoiceEvent::Final { transcript } + if self + .snapshot + .as_ref() + .is_some_and(|s| s.session_id == transcript.session_id) => + { + self.caption.complete(&transcript); + } + VoiceEvent::Closed { generation, reason } + if self + .snapshot + .as_ref() + .is_some_and(|s| s.generation == generation) => + { + self.phase = if reason.is_some() { + VoicePhase::Failed + } else { + VoicePhase::Closed + }; + self.reason = reason; + self.reset_session(); + } + _ => return, + } + cx.notify(); + } + pub fn reason_text(&self) -> &'static str { + match self.reason { + Some(VoiceRejection::CreditExclusionUnverified) => { + "Codex controls subscription usage and any enabled additional credits." + } + Some(VoiceRejection::WrongHarness) => "Voice requires Codex.", + Some(VoiceRejection::RemoteHost) => "Voice runs on this device only.", + Some(VoiceRejection::IncludedUsageUnavailable) => { + "Codex usage is currently unavailable." + } + Some(VoiceRejection::AudioFormatUnverified) => { + "This Codex voice format has not been verified." + } + Some(VoiceRejection::ChatgptRequired) => "Sign in to Codex with ChatGPT to use voice.", + // Remote calls still play audio here, through this device's Codex helper. + Some(VoiceRejection::NativeRuntimeUnavailable) => { + "Install or update Codex on this device with its official installer to use voice. npm installs lack the voice runtime." + } + Some(VoiceRejection::DeviceUnavailable) => { + "Check microphone permission and your audio devices." + } + Some(VoiceRejection::MicrophonePermissionDenied) => { + "Allow Zeron to use the microphone in System Settings → Privacy & Security → Microphone." + } + Some(VoiceRejection::MicrophoneMetadataMissing) => { + "Microphone setup is missing. Rebuild or reinstall Zeron, then restart it." + } + Some(VoiceRejection::Busy) => "Another window already owns the voice session.", + Some(_) => "Voice could not connect. Try again in a moment.", + None => "", + } + } +} + +/// Call-timer text: `m:ss`, or `h:mm:ss` past the hour. +pub fn format_elapsed(seconds: u64) -> String { + let (hours, minutes, seconds) = (seconds / 3600, seconds / 60 % 60, seconds % 60); + if hours > 0 { + format!("{hours}:{minutes:02}:{seconds:02}") + } else { + format!("{minutes}:{seconds:02}") + } +} + +/// Shared with the mobile apps so every orb reads the call the same way. +pub use zeron_voice_session::orb_state; + +impl Drop for VoiceController { + fn drop(&mut self) { + self.cancellation.cancel(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui::AppContext; + + fn snapshot() -> VoiceSnapshot { + VoiceSnapshot { + session_id: "one".into(), + chat_id: "chat".into(), + generation: 2, + phase: VoicePhase::Active, + muted: false, + playing: false, + work: VoiceWork::Idle, + reason: None, + voice: None, + voices: Vec::new(), + } + } + + #[test] + fn voice_is_offered_unless_codex_is_known_missing() { + let mut voice = VoiceController::default(); + assert!(voice.offered(), "an unread catalog never hides voice"); + voice.codex_ready = Some(true); + assert!(voice.offered()); + voice.codex_ready = Some(false); + assert!(!voice.offered()); + // A failure explains itself before the trigger goes away. + voice.reason = Some(VoiceRejection::NativeRuntimeUnavailable); + assert!(voice.offered()); + voice.reason = None; + voice.phase = VoicePhase::Checking; + assert!(voice.offered(), "a call in progress keeps its trigger"); + } + + #[test] + fn only_a_hang_up_or_a_connected_call_sounds_its_end() { + let mut voice = VoiceController::default(); + assert!(!voice.end_sounds(), "no call was placed"); + voice.cued = true; + assert!(voice.end_sounds(), "hanging up while connecting"); + voice.reason = Some(VoiceRejection::Busy); + assert!(!voice.end_sounds(), "a failure to connect stays quiet"); + voice.active_since = Some(std::time::Instant::now()); + assert!(voice.end_sounds(), "a connected call that drops"); + } + + #[gpui::test] + fn voice_reducer_ignores_stale_and_foreign_events(cx: &mut gpui::TestAppContext) { + let voice = cx.new(|_| VoiceController::default()); + voice.update(cx, |voice, cx| { + voice.chat_id = Some("chat".into()); + let snapshot = snapshot(); + voice.reduce( + VoiceEvent::Snapshot { + snapshot: snapshot.clone(), + }, + cx, + ); + voice.reduce( + VoiceEvent::Partial { + generation: 1, + item_id: None, + text: "old".into(), + }, + cx, + ); + assert!(voice.caption.text().is_empty()); + voice.reduce( + VoiceEvent::Partial { + generation: 2, + item_id: None, + text: "current".into(), + }, + cx, + ); + let mut foreign = snapshot; + foreign.chat_id = "other".into(); + foreign.generation = 3; + voice.reduce(VoiceEvent::Snapshot { snapshot: foreign }, cx); + assert_eq!(voice.snapshot.as_ref().unwrap().generation, 2); + voice.reduce( + VoiceEvent::Closed { + generation: 1, + reason: None, + }, + cx, + ); + assert!(voice.is_live()); + voice.set_stage_open(true, cx); + assert!(voice.stage_open); + voice.cancel(cx); + assert!(!voice.is_live()); + assert!(!voice.stage_open); + assert!(voice.caption.text().is_empty()); + // A closed session never reopens the stage. + voice.set_stage_open(true, cx); + assert!(!voice.stage_open); + }); + } + + /// Codex delivers the user's final after the answer started streaming: + /// the caption stays on the answer and keeps every streamed word. + #[gpui::test] + fn late_user_final_keeps_the_streaming_answer_captioned(cx: &mut gpui::TestAppContext) { + let voice = cx.new(|_| VoiceController::default()); + voice.update(cx, |voice, cx| { + voice.chat_id = Some("chat".into()); + voice.reduce( + VoiceEvent::Snapshot { + snapshot: snapshot(), + }, + cx, + ); + let partial = |item: &str, text: &str| VoiceEvent::Partial { + generation: 2, + item_id: Some(item.into()), + text: text.into(), + }; + voice.reduce(partial("user", "Open the test"), cx); + voice.reduce(partial("answer", "Sure, "), cx); + voice.reduce( + VoiceEvent::Final { + transcript: VoiceTranscript { + session_id: "one".into(), + item_id: "user".into(), + role: VoiceRole::User, + text: "Open the test.".into(), + promoted_message_id: None, + }, + }, + cx, + ); + voice.reduce(partial("answer", "opening it."), cx); + assert_eq!(voice.caption.text(), "Sure, opening it."); + assert_eq!(voice.caption_item(), Some("answer")); + }); + } + + #[gpui::test] + fn provider_failure_closes_the_stage_and_keeps_its_reason(cx: &mut gpui::TestAppContext) { + let voice = cx.new(|_| VoiceController::default()); + voice.update(cx, |voice, cx| { + voice.chat_id = Some("chat".into()); + voice.reduce( + VoiceEvent::Snapshot { + snapshot: snapshot(), + }, + cx, + ); + voice.set_stage_open(true, cx); + voice.reduce( + VoiceEvent::Closed { + generation: 2, + reason: Some(VoiceRejection::DeviceUnavailable), + }, + cx, + ); + assert_eq!(voice.phase, VoicePhase::Failed); + assert!(!voice.stage_open); + assert!(!voice.reason_text().is_empty()); + voice.dismiss_reason(cx); + assert_eq!(voice.phase, VoicePhase::Closed); + }); + } + + #[test] + fn call_timer_reads_like_a_call() { + assert_eq!(format_elapsed(0), "0:00"); + assert_eq!(format_elapsed(75), "1:15"); + assert_eq!(format_elapsed(3600 + 62), "1:01:02"); + } + + #[test] + fn visual_state_keeps_muting_and_playback_orthogonal() { + let mut s = snapshot(); + s.muted = true; + s.playing = true; + assert_eq!(orb_state(s.phase, Some(&s)), OrbState::Composing); + s.playing = false; + assert_eq!(orb_state(s.phase, Some(&s)), OrbState::Breathing); + s.muted = false; + assert_eq!(orb_state(s.phase, Some(&s)), OrbState::Listening); + s.work = VoiceWork::Working; + assert_eq!(orb_state(s.phase, Some(&s)), OrbState::Working); + s.work = VoiceWork::AwaitingInput; + assert_eq!(orb_state(s.phase, Some(&s)), OrbState::Solving); + assert_eq!( + orb_state(VoicePhase::Starting, Some(&s)), + OrbState::Connecting + ); + } +} diff --git a/crates/ui/src/voice/permissions.rs b/crates/ui/src/voice/permissions.rs new file mode 100644 index 0000000000..df1542a2dd --- /dev/null +++ b/crates/ui/src/voice/permissions.rs @@ -0,0 +1,68 @@ +//! Ask for microphone permission in the viewport's macOS process identity +//! before its engine opens native devices. CoreAudio can otherwise yield silence +//! without displaying a privacy prompt. +use zeron_proto::voice::VoiceRejection; + +pub(super) async fn microphone() -> Result<(), VoiceRejection> { + #[cfg(target_os = "macos")] + if let Some(reply) = macos::request()? { + if !reply.await.map_err(|_| VoiceRejection::DeviceUnavailable)? { + return Err(VoiceRejection::MicrophonePermissionDenied); + } + } + Ok(()) +} + +#[cfg(target_os = "macos")] +mod macos { + use super::*; + use block2::RcBlock; + use objc2::{ + msg_send, + runtime::{AnyClass, AnyObject, Bool}, + }; + use objc2_foundation::{NSBundle, ns_string}; + use std::sync::Mutex; + use tokio::sync::oneshot; + + #[link(name = "AVFoundation", kind = "framework")] + unsafe extern "C" { + static AVMediaTypeAudio: *const AnyObject; + } + + // Keep Objective-C objects and the block out of the async future. AVFoundation + // copies the completion block and may invoke it on any queue. + pub(super) fn request() -> Result>, VoiceRejection> { + let device = AnyClass::get(c"AVCaptureDevice").ok_or(VoiceRejection::DeviceUnavailable)?; + let status: isize = + unsafe { msg_send![device, authorizationStatusForMediaType: AVMediaTypeAudio] }; + match status { + 3 => return Ok(None), // authorized + 1 | 2 => return Err(VoiceRejection::MicrophonePermissionDenied), + 0 => {} // not determined + _ => return Err(VoiceRejection::DeviceUnavailable), + } + // Requesting capture permission without a purpose string terminates the + // process. Both app bundles and direct Cargo builds supply this string. + if NSBundle::mainBundle() + .objectForInfoDictionaryKey(ns_string!("NSMicrophoneUsageDescription")) + .is_none() + { + return Err(VoiceRejection::MicrophoneMetadataMissing); + } + let (tx, rx) = oneshot::channel(); + let reply = Mutex::new(Some(tx)); + let completion = RcBlock::new(move |granted: Bool| { + if let Some(tx) = reply.lock().unwrap().take() { + let _ = tx.send(granted.as_bool()); + } + }); + unsafe { + let _: () = msg_send![device, + requestAccessForMediaType: AVMediaTypeAudio, + completionHandler: &*completion + ]; + } + Ok(Some(rx)) + } +} diff --git a/crates/ui/src/voice/remote.rs b/crates/ui/src/voice/remote.rs new file mode 100644 index 0000000000..dc2f6cfbee --- /dev/null +++ b/crates/ui/src/voice/remote.rs @@ -0,0 +1,70 @@ +use crate::state::EngineHandle; +use std::sync::Arc; +use tokio::sync::{mpsc, watch}; +use tokio_util::sync::CancellationToken; +use zeron_proto::voice::{remote::Sdp, *}; +use zeron_voice_session::VoiceMediaEndpoint; + +struct Media(zeron_voice_media::DesktopMedia); +#[async_trait::async_trait] +impl VoiceMediaEndpoint for Media { + async fn prepare(&self) -> Result<(), VoiceRejection> { + super::permissions::microphone().await?; + self.0.prepare().await + } + async fn offer(&self) -> Result { + self.0.offer().await + } + async fn apply_answer(&self, answer: Sdp) -> Result<(), VoiceRejection> { + self.0.apply_answer(answer).await + } + async fn set_muted(&self, muted: bool) -> Result<(), VoiceRejection> { + self.0.set_muted(muted).await + } + async fn levels(&self) -> Result<(u16, u16), VoiceRejection> { + self.0.levels().await + } + fn close(&self) { + self.0.close(); + } +} +#[allow(clippy::too_many_arguments)] +pub(super) async fn run( + engine: EngineHandle, + host: String, + config: zeron_proto::ChatConfig, + voice: Option, + cancel: CancellationToken, + events: mpsc::Sender, + mut controls: mpsc::Receiver, +) -> Result<(), VoiceRejection> { + let client = Arc::new( + engine + .media_client() + .await + .map_err(|_| VoiceRejection::Protocol)?, + ); + let control = Arc::new(zeron_voice_session::RpcTransport { client, host }); + // Audio runs here through this device's own standalone Codex helper; the + // call's app-server runs on `host`. Resolution may consult the login shell. + let codex = tokio::task::spawn_blocking(zeron_harness::codex::resolve_codex_executable) + .await + .ok() + .flatten(); + let media = Arc::new(Media(zeron_voice_media::DesktopMedia::for_codex( + codex.as_deref(), + )?)); + let (mute_tx, muted) = watch::channel(false); + let call = + zeron_voice_session::run(control, media, config, voice, cancel.clone(), events, muted); + tokio::pin!(call); + loop { + tokio::select! {biased; + result=&mut call=>return result, + command=controls.recv()=>match command { + Some(super::VoiceControl::Mute(value))=>{mute_tx.send_replace(value);}, + None=>{cancel.cancel();return (&mut call).await;}, + } + } + } +} diff --git a/crates/ui/tests/voice_orb_geometry.rs b/crates/ui/tests/voice_orb_geometry.rs new file mode 100644 index 0000000000..843eb0c7ee --- /dev/null +++ b/crates/ui/tests/voice_orb_geometry.rs @@ -0,0 +1,352 @@ +// MIT: extracted with the orb; see THIRD_PARTY_NOTICES.md. +//! Smoke tests + microbench for mode painters. + +use zeron_ui::orb::{ + ModeKey, OrbSize, OrbState, + engine::{MAX_ICON_D, MAX_MORPH_DOTS, MAX_NODE_N, ModeOpts, draw_mode, sanitize_mode_opts}, + resolve_preset, +}; + +#[test] +fn all_states_emit_geometry() { + for &state in OrbState::ALL_STATES { + for &size in OrbSize::ALL_SIZES { + let resolved = resolve_preset(state, size); + let frame = draw_mode(resolved.mode, size.pixels(), 1.25, &resolved.opts); + assert!( + !frame.dots.is_empty(), + "{:?}/{:?} produced zero dots", + state, + size + ); + for d in &frame.dots { + assert!(d.r.is_finite() && d.r >= 0.0); + assert!(d.x.is_finite() && d.y.is_finite() && d.z.is_finite()); + assert!(d.a >= 0.0 && d.a <= 1.0 + 1e-3); + } + } + } +} + +#[test] +fn connecting_emits_lines() { + let resolved = resolve_preset(OrbState::Connecting, OrbSize::Avatar); + let frame = draw_mode(resolved.mode, 64.0, 2.0, &resolved.opts); + assert!(!frame.lines.is_empty(), "connecting should wire edges"); + assert!(!frame.dots.is_empty()); +} + +#[test] +fn reduced_motion_frame_is_deterministic() { + let resolved = resolve_preset(OrbState::Working, OrbSize::Avatar); + let a = draw_mode(resolved.mode, 64.0, 0.6, &resolved.opts); + let b = draw_mode(resolved.mode, 64.0, 0.6, &resolved.opts); + assert_eq!(a.dots.len(), b.dots.len()); + for (da, db) in a.dots.iter().zip(b.dots.iter()) { + assert!((da.x - db.x).abs() < 1e-4); + assert!((da.y - db.y).abs() < 1e-4); + } +} + +#[test] +fn solve_cycle_survives_float_boundary_move_counts() { + // `move_count` is a public `ModeOpts` knob. Certain values put the slot + // boundary exactly on a float rounding edge, where `tc / slot_dur` reaches + // `2 * count` even though the `tc < 2 * count * slot_dur` guard passed — + // which used to underflow `2*count-1-slot` to `usize::MAX` and panic. + // + // Sweep enough move counts and adjacent float values to exercise rounding + // at both forward and reverse slot boundaries. + const SLOT_DUR: f32 = 0.58; + let resolved = resolve_preset(OrbState::Solving, OrbSize::Avatar); + for count in 1..40u32 { + let mut opts = resolved.opts.clone(); + opts.move_count = Some(count as f32); + for c in 1..=count { + // Step onto the largest float strictly below each slot boundary. + let boundary = 2.0 * c as f32 * SLOT_DUR; + for t in [ + f32::from_bits(boundary.to_bits() - 1), + boundary, + f32::from_bits(boundary.to_bits() + 1), + ] { + let frame = draw_mode(resolved.mode, 64.0, t, &opts); + assert!(!frame.dots.is_empty()); + } + } + } +} + +#[test] +fn new_concepts_are_distinct_low_density_modes() { + let concepts = [ + (OrbState::Focusing, ModeKey::Focus, 77, 23), + (OrbState::Reasoning, ModeKey::Gyroscope, 90, 32), + (OrbState::Recalling, ModeKey::Echo, 75, 21), + ]; + + for (state, mode, avatar_dots, inline_dots) in concepts { + assert_eq!(ModeKey::from_state(state), mode); + assert!(OrbState::ALL_STATES.contains(&state)); + for (size, expected) in [ + (OrbSize::Avatar, avatar_dots), + (OrbSize::Inline, inline_dots), + ] { + let resolved = resolve_preset(state, size); + let frame = draw_mode(resolved.mode, size.pixels(), 0.6, &resolved.opts); + assert_eq!(frame.dots.len(), expected, "{state:?}/{size:?}"); + assert!( + frame.lines.is_empty(), + "new concepts stay on the dot-only paint path" + ); + assert!( + frame.dots.len() < 100, + "new concepts must remain low density" + ); + } + } +} + +#[test] +fn new_concepts_animate_and_keep_their_depth_identity() { + for &state in &[OrbState::Focusing, OrbState::Reasoning, OrbState::Recalling] { + let resolved = resolve_preset(state, OrbSize::Avatar); + let a = draw_mode(resolved.mode, 64.0, 0.2, &resolved.opts); + let b = draw_mode(resolved.mode, 64.0, 1.2, &resolved.opts); + assert!( + a.dots + .iter() + .zip(&b.dots) + .any(|(a, b)| (a.x - b.x).abs() > 0.01 || (a.y - b.y).abs() > 0.01), + "{state:?} did not animate" + ); + + let has_depth = a.dots.iter().any(|dot| dot.z.abs() > 0.01); + assert_eq!(has_depth, state == OrbState::Reasoning); + } +} + +#[test] +fn larger_sizes_add_detail_without_changing_the_legacy_presets() { + assert_eq!(OrbSize::ALL_SIZES.len(), 4); + assert_eq!(OrbSize::Large.pixels(), 96.0); + assert_eq!(OrbSize::Hero.pixels(), 128.0); + + for &state in OrbState::ALL_STATES { + let avatar = resolve_preset(state, OrbSize::Avatar); + let large = resolve_preset(state, OrbSize::Large); + let hero = resolve_preset(state, OrbSize::Hero); + let avatar_frame = draw_mode(avatar.mode, 64.0, 0.6, &avatar.opts); + let large_frame = draw_mode(large.mode, 96.0, 0.6, &large.opts); + let hero_frame = draw_mode(hero.mode, 128.0, 0.6, &hero.opts); + + assert_eq!(avatar.mode, large.mode); + assert_eq!(avatar.mode, hero.mode); + assert!( + large_frame.dots.len() >= avatar_frame.dots.len(), + "{state:?}/large" + ); + assert!( + hero_frame.dots.len() >= large_frame.dots.len(), + "{state:?}/hero" + ); + assert!(large.speed <= avatar.speed); + assert!(hero.speed <= large.speed); + } +} + +#[test] +fn legacy_all_constant_keeps_its_original_array_shape() { + #[allow(deprecated)] + let original_nine: [OrbState; 9] = OrbState::ALL; + assert_eq!(original_nine.len(), 9); + assert_eq!(OrbState::ALL_STATES.len(), 12); +} + +#[test] +fn bench_geometry_cost() { + use std::time::Instant; + for &state in OrbState::ALL_STATES { + for &size in OrbSize::ALL_SIZES { + let resolved = resolve_preset(state, size); + for i in 0..20 { + let _ = draw_mode( + resolved.mode, + size.pixels(), + i as f32 * 0.016, + &resolved.opts, + ); + } + let n = 1000usize; + let t0 = Instant::now(); + let mut total_dots = 0usize; + let mut total_lines = 0usize; + for i in 0..n { + let f = draw_mode( + resolved.mode, + size.pixels(), + i as f32 * 0.016, + &resolved.opts, + ); + total_dots += f.dots.len(); + total_lines += f.lines.len(); + } + let elapsed = t0.elapsed(); + let per = elapsed / n as u32; + let avg_dots = total_dots / n; + let avg_lines = total_lines / n; + eprintln!( + "{:>12}/{:<6} {:>5} dots {:>4} lines {:>8.2?} /frame (~{:.0}k geom-fps)", + state.as_str(), + size.pixels() as u32, + avg_dots, + avg_lines, + per, + 1.0 / per.as_secs_f64() / 1000.0 + ); + } + } +} + +/// Strokes are painted at their exact colour, one `Path` per segment. Batching +/// them into shared paths was tried and reverted for visibly brightening the +/// web (see `paint.rs`), so what matters now is that every alpha the engine +/// emits is in range and finite — a stroke painter has no clamping to hide +/// behind. +#[test] +fn stroke_colours_are_paintable() { + let resolved = resolve_preset(OrbState::Connecting, OrbSize::Avatar); + let mut seen = 0usize; + for i in 0..400 { + let frame = draw_mode(resolved.mode, 64.0, i as f32 * 0.05, &resolved.opts); + for l in &frame.lines { + assert!( + l.a.is_finite() && (0.0..=1.0).contains(&l.a), + "alpha {}", + l.a + ); + assert!( + l.white.is_finite() && (0.0..=1.0).contains(&l.white), + "white {}", + l.white + ); + assert!(l.w.is_finite() && l.w > 0.0, "stroke width {}", l.w); + assert!(l.x1.is_finite() && l.y1.is_finite()); + assert!(l.x2.is_finite() && l.y2.is_finite()); + seen += 1; + } + } + assert!(seen > 0, "connecting emitted no strokes across 400 frames"); +} + +#[test] +fn adversarial_mode_opts_stay_bounded_and_finite() { + // Power-user knobs used to accept Inf / huge finites and OOM or NaN. + // draw_mode_into sanitizes first; geometry must stay finite and small. + let cases: &[(ModeKey, ModeOpts)] = &[ + ( + ModeKey::Web, + ModeOpts::fill(ModeKey::Web, |o| { + o.node_n = Some(f32::INFINITY); + o.signals = Some(1.0e12); + }), + ), + ( + ModeKey::Globe, + ModeOpts::fill(ModeKey::Globe, |o| { + o.lat_rings = Some(0.0); + o.lon_density = Some(5_000.0); + }), + ), + ( + ModeKey::Rubik, + ModeOpts::fill(ModeKey::Rubik, |o| { + o.lat_rings = Some(0.0); + o.move_count = Some(1.0e7); + }), + ), + ( + ModeKey::Wave, + ModeOpts::fill(ModeKey::Wave, |o| { + o.rings = Some(0.0); + o.lon_density = Some(f32::NAN); + }), + ), + ( + ModeKey::Orbits, + ModeOpts::fill(ModeKey::Orbits, |o| { + o.orbit_n = Some(f32::INFINITY); + o.ghost_n = Some(1.0e9); + o.particles = Some(1.0e6); + }), + ), + ( + ModeKey::Morph, + ModeOpts::fill(ModeKey::Morph, |o| { + o.icon_d = Some(1.0e6); + }), + ), + ( + ModeKey::Ribbon, + ModeOpts::fill(ModeKey::Ribbon, |o| { + o.lanes = Some(1.0e6); + o.segs = Some(1.0e6); + o.band_mul = Some(1.0e6); + o.ghost_n = Some(1.0e9); + }), + ), + ( + ModeKey::Braid, + ModeOpts::fill(ModeKey::Braid, |o| { + o.strand_n = Some(1.0e9); + o.ghost_n = Some(1.0e9); + }), + ), + ]; + + for (mode, opts) in cases { + let frame = draw_mode(*mode, 64.0, 1.25, opts); + assert!( + frame.dots.len() <= 80_000, + "{mode:?} emitted {} dots — sanitize failed", + frame.dots.len() + ); + for d in &frame.dots { + assert!(d.x.is_finite() && d.y.is_finite() && d.z.is_finite() && d.r.is_finite()); + } + for l in &frame.lines { + assert!(l.x1.is_finite() && l.y1.is_finite() && l.x2.is_finite() && l.y2.is_finite()); + } + } + + let morph = draw_mode( + ModeKey::Morph, + 64.0, + 0.6, + &ModeOpts::fill(ModeKey::Morph, |o| { + o.icon_d = Some(MAX_ICON_D * 100.0); + }), + ); + assert!(morph.dots.len() <= MAX_MORPH_DOTS); + + let web = draw_mode( + ModeKey::Web, + 64.0, + 1.0, + &ModeOpts::fill(ModeKey::Web, |o| { + o.node_n = Some(MAX_NODE_N * 10.0); + }), + ); + // node_n capped → dots include nodes + a few signal packets + assert!(web.dots.len() < MAX_NODE_N as usize + 100); + + // The base mode is irrelevant here: the sanitizer is what is on trial. + let clean = sanitize_mode_opts(&ModeOpts::fill(ModeKey::Web, |o| { + o.node_n = Some(f32::INFINITY); + o.lat_rings = Some(-3.0); + o.icon_d = Some(f32::NAN); + })); + assert_eq!(clean.node_n, None); // non-finite → dropped + assert_eq!(clean.lat_rings, Some(1.0)); // clamped up + assert_eq!(clean.icon_d, None); +} diff --git a/crates/veil/Cargo.toml b/crates/veil/Cargo.toml new file mode 100644 index 0000000000..2ad0885985 --- /dev/null +++ b/crates/veil/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "zeron-veil" +version.workspace = true +edition.workspace = true +license.workspace = true +publish.workspace = true + +# The streaming fade veil without a renderer: the desktop transcript recolors +# GPUI runs with it; voice captions use it on desktop and mobile. +[dependencies] diff --git a/crates/veil/src/caption.rs b/crates/veil/src/caption.rs new file mode 100644 index 0000000000..d793cdfa2b --- /dev/null +++ b/crates/veil/src/caption.rs @@ -0,0 +1,204 @@ +//! A live voice caption: the tail of the current utterance, its streamed words +//! veiled in exactly like transcript text, and the previous utterance fading +//! out when the speaker turn changes. +use crate::{ElemVeil, VeilSpan, slice_spans, veil_opacity}; +use std::time::Instant; + +/// The previous utterance fades out over this long when the turn changes. +pub const CAPTION_EXIT_MS: f32 = 180.0; + +/// Text tail of `text` holding at most `max` characters, ellipsized in front. +pub fn caption_tail(text: &str, max: usize) -> String { + caption_window(text, max).0 +} + +/// The shown tail, the byte offset in `text` where it starts, and the length +/// of the ellipsis in front of it. +fn caption_window(text: &str, max: usize) -> (String, usize, usize) { + let start = text.len() - text.trim_start().len(); + let end = text.trim_end().len().max(start); + let body = &text[start..end]; + let count = body.chars().count(); + if count <= max { + return (body.to_owned(), start, 0); + } + let skip = body + .char_indices() + .nth(count - max) + .map_or(body.len(), |(i, _)| i); + let tail = &body[skip..]; + let shown = tail.trim_start(); + let ellipsis = '…'.len_utf8(); + ( + format!("…{shown}"), + start + skip + (tail.len() - shown.len()), + ellipsis, + ) +} + +/// One frame of a caption. +#[derive(Debug, Clone, PartialEq)] +pub struct CaptionFrame { + pub text: String, + /// Veiled byte ranges of `text` and their opacity (0..1). + pub spans: Vec, + /// The previous utterance while it fades out, and its opacity. + pub previous: Option<(String, f32)>, +} + +/// Veil state for one call's caption. Advance it with the current utterance +/// on every frame while [`is_animating`](Self::is_animating). +pub struct CaptionVeil { + max_chars: usize, + item: Option, + veil: ElemVeil, + shown: String, + outgoing: Option<(String, Instant)>, +} + +impl CaptionVeil { + pub fn new(max_chars: usize) -> Self { + Self { + max_chars, + item: None, + veil: ElemVeil::default(), + shown: String::new(), + outgoing: None, + } + } + + /// Advance to `text` of utterance `item` at `now`. A new item fades the + /// previous caption out and the new one in from empty; a final that + /// repeats its partials never re-animates. Reduced motion shows text + /// directly but keeps tracking it, so turning motion back on mid-call + /// does not re-fade what is already on screen. + pub fn advance( + &mut self, + item: Option<&str>, + text: &str, + now: Instant, + reduced_motion: bool, + ) -> CaptionFrame { + if item != self.item.as_deref() { + let previous = std::mem::take(&mut self.shown); + self.outgoing = (!previous.is_empty() && !reduced_motion).then_some((previous, now)); + self.item = item.map(str::to_owned); + self.veil = ElemVeil::default(); + } + let spans = self.veil.advance(text, now); + let (shown, from, ellipsis) = caption_window(text, self.max_chars); + let spans = if reduced_motion { + Vec::new() + } else { + slice_spans(&spans, from, from + shown.len() - ellipsis) + .into_iter() + .map(|(range, alpha)| (range.start + ellipsis..range.end + ellipsis, alpha)) + .collect() + }; + self.shown.clone_from(&shown); + let previous = self.outgoing.take().and_then(|(previous, started)| { + let elapsed = now.saturating_duration_since(started).as_secs_f32() * 1000.0; + let progress = elapsed / CAPTION_EXIT_MS; + (progress < 1.0 && !reduced_motion).then(|| { + self.outgoing = Some((previous.clone(), started)); + (previous, 1.0 - veil_opacity(progress)) + }) + }); + CaptionFrame { + text: shown, + spans, + previous, + } + } + + /// Something is still fading (as of the last advance): keep repainting. + pub fn is_animating(&self) -> bool { + self.veil.is_fading() || self.outgoing.is_some() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; + + fn at(base: Instant, ms: u64) -> Instant { + base + Duration::from_millis(ms) + } + + #[test] + fn caption_keeps_the_latest_words_of_a_long_utterance() { + assert_eq!(caption_tail(" short ", 10), "short"); + assert_eq!(caption_tail("one two three four five", 9), "…four five"); + // Multibyte text is cut on character boundaries. + assert_eq!(caption_tail("ñandú camión", 6), "…camión"); + assert_eq!(caption_tail(" ", 6), ""); + } + + #[test] + fn streamed_words_fade_in_once_and_settle() { + let t0 = Instant::now(); + let mut caption = CaptionVeil::new(160); + let frame = caption.advance(Some("a"), "Hello", t0, false); + assert_eq!(frame.text, "Hello"); + assert_eq!(frame.spans, vec![(0..5, 0.0)]); + let frame = caption.advance(Some("a"), "Hello there", at(t0, 100), false); + assert_eq!(frame.spans.len(), 2); + assert_eq!(frame.spans[1].0, 5..11); + assert!(caption.is_animating()); + // The final repeats the partials: nothing re-animates. + let frame = caption.advance(Some("a"), "Hello there", at(t0, 900), false); + assert!(frame.spans.is_empty()); + assert!(!caption.is_animating()); + } + + #[test] + fn veil_follows_the_visible_tail_of_a_long_utterance() { + let t0 = Instant::now(); + let mut caption = CaptionVeil::new(9); + caption.advance(Some("a"), "one two three four", t0, false); + // Settle, then append: only the new word is veiled, at its position in + // the ellipsized tail rather than the full utterance. + caption.advance(Some("a"), "one two three four", at(t0, 900), false); + let frame = caption.advance(Some("a"), "one two three four five", at(t0, 1000), false); + assert_eq!(frame.text, "…four five"); + let ellipsis = '…'.len_utf8(); + assert_eq!(frame.spans, vec![(ellipsis + 4..ellipsis + 9, 0.0)]); + assert_eq!(&frame.text[frame.spans[0].0.clone()], " five"); + } + + #[test] + fn a_new_turn_fades_the_previous_caption_out() { + let t0 = Instant::now(); + let mut caption = CaptionVeil::new(160); + caption.advance(Some("user"), "Open the sync test", t0, false); + caption.advance(Some("user"), "Open the sync test", at(t0, 900), false); + let frame = caption.advance(Some("assistant"), "On it", at(t0, 1000), false); + assert_eq!( + frame.spans, + vec![(0..5, 0.0)], + "the new turn fades in from empty" + ); + let (previous, alpha) = frame.previous.unwrap(); + assert_eq!(previous, "Open the sync test"); + assert_eq!(alpha, 1.0); + let frame = caption.advance(Some("assistant"), "On it", at(t0, 1090), false); + let (_, alpha) = frame.previous.unwrap(); + assert!(alpha > 0.0 && alpha < 1.0); + let frame = caption.advance(Some("assistant"), "On it", at(t0, 1200), false); + assert!(frame.previous.is_none()); + } + + #[test] + fn reduced_motion_shows_text_directly_and_never_refades_it() { + let t0 = Instant::now(); + let mut caption = CaptionVeil::new(160); + caption.advance(Some("a"), "Hi", t0, false); + let frame = caption.advance(Some("b"), "Hello", at(t0, 50), true); + assert!(frame.spans.is_empty()); + assert!(frame.previous.is_none()); + // Motion back on: the text already shown stays settled. + let frame = caption.advance(Some("b"), "Hello", at(t0, 900), false); + assert!(frame.spans.is_empty()); + } +} diff --git a/crates/veil/src/lib.rs b/crates/veil/src/lib.rs new file mode 100644 index 0000000000..53b7b3d9cb --- /dev/null +++ b/crates/veil/src/lib.rs @@ -0,0 +1,402 @@ +//! Streaming fade veil — per-appended-chunk opacity over already-committed text. +//! +//! The desktop app (docs/research/mugen-pretext.md §2e) commits streamed text to +//! layout instantly and dissolves a purely cosmetic veil over the newly arrived +//! characters. This crate is the renderer-agnostic part, shared by the desktop +//! transcript (`zeron-ui`, which recolors GPUI text runs) and the voice call +//! captions on desktop and mobile: +//! +//! - [`ElemVeil`] tracks, per rendered text element, the previously rendered +//! flat text. Each append registers the new byte range as a chunk with its +//! arrival time; a fast stream keeps several chunks fading concurrently (a +//! rolling veil). A chunk fades exactly once — already-faded text never +//! re-animates, and a fully settled element returns no spans at all. +//! - [`CaptionVeil`] veils a live caption shown as the tail of a long +//! utterance, and fades the previous utterance out when the speaker turn +//! changes. +//! +//! Constants and curve match mugen-markdown's web `FadePainter` (the engine the +//! desktop app ships): per-chunk duration adapts to the stream's cadence — an +//! EMA of inter-append gaps, `duration = clamp(ema × 3, 120ms, 400ms)` — the +//! dissolve eases with `veil = (1 − p)^1.6` (text alpha `1 − veil`), and a +//! backed-up stream (3+ concurrent chunks) gets a small speed boost. **Zero +//! translate** — opacity only, per the "no positional offset on streamed +//! content" rule. + +mod caption; +pub use caption::{CAPTION_EXIT_MS, CaptionFrame, CaptionVeil, caption_tail}; + +use std::collections::HashMap; +use std::ops::Range; +use std::time::Instant; + +/// EMA seed for the inter-append gap (mugen `EMA_SEED_MS`). +pub const VEIL_EMA_SEED_MS: f32 = 160.0; +/// Duration clamp (mugen `MIN_FADE_MS` / `MAX_FADE_MS`). +pub const VEIL_MIN_FADE_MS: f32 = 120.0; +pub const VEIL_MAX_FADE_MS: f32 = 400.0; +/// Dissolve exponent (mugen: `alpha = (1 - p) ** 1.6`). +pub const VEIL_CURVE_POW: f32 = 1.6; +/// Gap clamp feeding the EMA (mugen: `min(gap, 1000)`). +const VEIL_GAP_CLAMP_MS: f32 = 1000.0; + +/// One appended chunk of text mid-fade. +#[derive(Debug, Clone)] +struct Chunk { + /// Byte range in the element's flat text. + range: Range, + started: Instant, + /// Fade duration fixed at arrival from the cadence EMA. + duration_ms: f32, +} + +/// A veiled byte range and its current opacity (0..1). +pub type VeilSpan = (Range, f32); + +/// Text alpha for a fade progress `p` (0..1): the veil dissolves as +/// `(1 − p)^1.6`, so the text shows through at `1 − veil`. Pure. +pub fn veil_opacity(p: f32) -> f32 { + 1.0 - (1.0 - p.clamp(0.0, 1.0)).powf(VEIL_CURVE_POW) +} + +/// Chunk fade duration for the current inter-append EMA (mugen: +/// `min(MAX, max(MIN, ema * 3))`). +pub fn veil_duration_ms(ema_ms: f32) -> f32 { + (ema_ms * 3.0).clamp(VEIL_MIN_FADE_MS, VEIL_MAX_FADE_MS) +} + +/// Fast-stream boost: 3+ chunks fading concurrently speed up by 30% each +/// (mugen: `1 + 0.3 * max(0, veils.length - 2)`). +pub fn veil_boost(active_chunks: usize) -> f32 { + 1.0 + 0.3 * active_chunks.saturating_sub(2) as f32 +} + +/// EMA update on a new append gap (mugen: `ema*0.7 + min(gap,1000)*0.3`). +pub fn veil_ema_next(ema_ms: f32, gap_ms: f32) -> f32 { + ema_ms * 0.7 + gap_ms.min(VEIL_GAP_CLAMP_MS) * 0.3 +} + +/// Per-element chunk tracker: remembers the last rendered flat text and fades +/// every newly appended suffix exactly once. +#[derive(Debug)] +pub struct ElemVeil { + prev: String, + chunks: Vec, + /// EMA of inter-append gaps (drives per-chunk durations). + ema_ms: f32, + last_append: Option, +} + +impl Default for ElemVeil { + fn default() -> Self { + Self { + prev: String::new(), + chunks: Vec::new(), + ema_ms: VEIL_EMA_SEED_MS, + last_append: None, + } + } +} + +/// Longest common prefix length, snapped back to a char boundary. +fn common_prefix(a: &str, b: &str) -> usize { + let mut p = a + .as_bytes() + .iter() + .zip(b.as_bytes()) + .take_while(|(x, y)| x == y) + .count(); + while p > 0 && !b.is_char_boundary(p) { + p -= 1; + } + p +} + +impl ElemVeil { + /// Adopt `text` as the committed baseline without fading it — the attach + /// semantics of mugen's `FadePainter.attach` (`length = + /// chromeFreeLength(content)`): content already on screen when the painter + /// attaches never animates; only later appends do. + fn seed(&mut self, text: &str) { + self.prev.clear(); + self.prev.push_str(text); + } + + /// Advance to `text` at `now`: registers a fading chunk for newly appended + /// bytes, prunes settled chunks, and returns the active spans. Idempotent + /// for unchanged text — safe to call once per frame (or twice, when a row + /// is both measured and painted). + pub fn advance(&mut self, text: &str, now: Instant) -> Vec { + if text != self.prev { + // Non-append rewrites (the incremental parser re-deriving a block's + // flat text — e.g. `**bold` collapsing into a bold run) keep the + // common prefix's committed fades and re-veil only the changed tail. + let p = common_prefix(&self.prev, text); + self.chunks.retain_mut(|c| { + c.range.end = c.range.end.min(p); + c.range.start < c.range.end + }); + if text.len() > p { + // Cadence-adaptive duration (mugen FadePainter): update the + // EMA with the gap since the previous append. + if let Some(last) = self.last_append { + let gap = now.saturating_duration_since(last).as_secs_f32() * 1000.0; + self.ema_ms = veil_ema_next(self.ema_ms, gap); + } + self.last_append = Some(now); + self.chunks.push(Chunk { + range: p..text.len(), + started: now, + duration_ms: veil_duration_ms(self.ema_ms), + }); + } + self.prev.clear(); + self.prev.push_str(text); + } + let boost = veil_boost(self.chunks.len()); + self.chunks.retain(|c| { + let elapsed = now.saturating_duration_since(c.started).as_secs_f32() * 1000.0; + elapsed * boost < c.duration_ms + }); + let boost = veil_boost(self.chunks.len()); + self.chunks + .iter() + .map(|c| { + let elapsed = now.saturating_duration_since(c.started).as_secs_f32() * 1000.0; + let progress = (elapsed * boost / c.duration_ms).clamp(0.0, 1.0); + (c.range.clone(), veil_opacity(progress)) + }) + .collect() + } + + /// Any chunk still fading (as of the last [`advance`](Self::advance))? + pub fn is_fading(&self) -> bool { + !self.chunks.is_empty() + } +} + +/// Veil state for one live streaming row, keyed by the render tree's stable +/// per-element discriminator (top-level block ix / nested ix scheme — stable +/// across appends because the incremental parser only reparses the tail). +#[derive(Debug, Default)] +pub struct RowVeil { + elems: HashMap, + /// Attach pass in progress: elements first seen while seeding adopt their + /// current text as the committed baseline instead of fading it in. Set for + /// rows that already carried text when the transcript (re)attached to the + /// chat — switching back to a streaming session must not dissolve the + /// whole existing reply (user report; mugen's `FadePainter.attach` baseline). + seeding: bool, +} + +impl RowVeil { + /// A veil whose first render pass seeds baselines instead of fading. + pub fn seeded() -> Self { + Self { + elems: HashMap::new(), + seeding: true, + } + } + + /// The attach pass is over — elements that appear from here on are newly + /// streamed content and fade normally. + pub fn finish_seeding(&mut self) { + self.seeding = false; + } + + pub fn advance(&mut self, elem: usize, text: &str, now: Instant) -> Vec { + if self.seeding && !self.elems.contains_key(&elem) { + let mut baseline = ElemVeil::default(); + baseline.seed(text); + self.elems.insert(elem, baseline); + return Vec::new(); + } + self.elems.entry(elem).or_default().advance(text, now) + } + + /// Any element still fading? Drives the once-per-frame repaint request. + pub fn is_fading(&self) -> bool { + self.elems.values().any(ElemVeil::is_fading) + } +} + +/// Intersect spans with `[start, end)` and shift them to local offsets — used +/// by per-line code rendering where chunks are tracked on the whole code text. +pub fn slice_spans(spans: &[VeilSpan], start: usize, end: usize) -> Vec { + spans + .iter() + .filter_map(|(r, a)| { + let s = r.start.max(start); + let e = r.end.min(end); + (s < e).then(|| (s - start..e - start, *a)) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; + + fn at(base: Instant, ms: u64) -> Instant { + base + Duration::from_millis(ms) + } + + #[test] + fn first_text_fades_and_settles_once() { + let t0 = Instant::now(); + let mut v = ElemVeil::default(); + let spans = v.advance("hello", t0); + assert_eq!(spans, vec![(0..5, 0.0)]); + // Mid-fade: opacity strictly between 0 and 1, range unchanged. + let spans = v.advance("hello", at(t0, 250)); + assert_eq!(spans.len(), 1); + assert!(spans[0].1 > 0.0 && spans[0].1 < 1.0); + // Settled: pruned, never re-animates. + assert!(v.advance("hello", at(t0, 600)).is_empty()); + assert!(!v.is_fading()); + assert!(v.advance("hello", at(t0, 700)).is_empty()); + } + + #[test] + fn appended_chunks_fade_concurrently_and_independently() { + let t0 = Instant::now(); + let mut v = ElemVeil::default(); + v.advance("one ", t0); + let spans = v.advance("one two ", at(t0, 100)); + assert_eq!(spans.len(), 2); + assert_eq!(spans[0].0, 0..4); + assert_eq!(spans[1].0, 4..8); + // The older chunk is further along its fade than the newer one. + assert!(spans[0].1 > spans[1].1); + // After the first settles (past its adaptive duration), only the + // newer chunk remains. + let spans = v.advance("one two ", at(t0, 410)); + assert_eq!(spans.len(), 1); + assert_eq!(spans[0].0, 4..8); + } + + #[test] + fn fade_constants_match_mugen_fade_painter() { + // @wingleeio/mugen-markdown dist/index.mjs: EMA_SEED_MS=160, + // MIN_FADE_MS=120, MAX_FADE_MS=400, alpha=(1-p)**1.6, + // ema = ema*0.7 + min(gap,1000)*0.3, boost = 1 + 0.3*max(0,n-2). + assert_eq!(VEIL_EMA_SEED_MS, 160.0); + assert_eq!(VEIL_MIN_FADE_MS, 120.0); + assert_eq!(VEIL_MAX_FADE_MS, 400.0); + assert_eq!(VEIL_CURVE_POW, 1.6); + // duration = clamp(ema*3, 120, 400). + assert_eq!(veil_duration_ms(160.0), 400.0); // seed → clamped at max + assert_eq!(veil_duration_ms(30.0), 120.0); // fast stream → floor + assert_eq!(veil_duration_ms(60.0), 180.0); + // EMA update. + assert_eq!(veil_ema_next(160.0, 100.0), 160.0 * 0.7 + 100.0 * 0.3); + assert_eq!(veil_ema_next(160.0, 5000.0), 160.0 * 0.7 + 1000.0 * 0.3); + // Fast-stream boost kicks in at the 3rd concurrent chunk. + assert_eq!(veil_boost(0), 1.0); + assert_eq!(veil_boost(2), 1.0); + assert!((veil_boost(3) - 1.3).abs() < 1e-6); + assert!((veil_boost(5) - 1.9).abs() < 1e-6); + } + + #[test] + fn seeded_row_adopts_existing_text_without_fading() { + // Switching back to a streaming session: the text already on screen is + // the attach BASELINE (mugen FadePainter.attach) — no full-reply fade. + let t0 = Instant::now(); + let mut row = RowVeil::seeded(); + assert!(row.advance(0, "already streamed text", t0).is_empty()); + assert!(row.advance(1, "second block", t0).is_empty()); + assert!(!row.is_fading()); + // Appends AFTER the attach fade normally — only the new suffix. + let spans = row.advance(0, "already streamed text plus", at(t0, 100)); + assert_eq!(spans, vec![(21..26, 0.0)]); + // The attach pass ends after the first render: elements first seen + // later are newly streamed content and fade from empty. + row.finish_seeding(); + let spans = row.advance(2, "new block", at(t0, 200)); + assert_eq!(spans, vec![(0..9, 0.0)]); + } + + #[test] + fn default_row_veil_fades_first_text() { + // Mid-stream new rows keep the old behavior: their first chunk fades. + let t0 = Instant::now(); + let mut row = RowVeil::default(); + let spans = row.advance(0, "fresh", t0); + assert_eq!(spans, vec![(0..5, 0.0)]); + } + + #[test] + fn faded_text_never_reanimates_on_append() { + let t0 = Instant::now(); + let mut v = ElemVeil::default(); + v.advance("stable", t0); + // Fully settled... + assert!(v.advance("stable", at(t0, 600)).is_empty()); + // ...then an append veils ONLY the new suffix. + let spans = v.advance("stable more", at(t0, 700)); + assert_eq!(spans, vec![(6..11, 0.0)]); + } + + #[test] + fn non_append_rewrite_keeps_prefix_and_reveils_tail() { + let t0 = Instant::now(); + let mut v = ElemVeil::default(); + v.advance("intro **bol", t0); + // Markdown resolves: flat text loses the `**` marker. + let spans = v.advance("intro bold", at(t0, 100)); + // The shared prefix's chunk is clamped, the changed tail is one chunk. + assert_eq!(spans.len(), 2); + assert_eq!(spans[0].0, 0..6); + assert_eq!(spans[1].0, 6..10); + assert!(spans[0].1 > spans[1].1); + } + + #[test] + fn common_prefix_respects_char_boundaries() { + // "é" = 0xC3 0xA9, "è" = 0xC3 0xA8 — byte prefix would split the char. + assert_eq!(common_prefix("é", "è"), 0); + assert_eq!(common_prefix("abé", "abè"), 2); + assert_eq!(common_prefix("same", "same"), 4); + } + + #[test] + fn veil_opacity_curve_endpoints() { + // Text alpha = 1 - (1-p)^1.6: 0 at arrival, 1 when the veil is gone. + assert_eq!(veil_opacity(0.0), 0.0); + assert_eq!(veil_opacity(1.0), 1.0); + let mid = veil_opacity(0.5); + assert!(mid > 0.0 && mid < 1.0); + // The pow-1.6 ease-out reveals faster than linear early on. + assert!(mid > 0.5); + assert!((mid - (1.0 - 0.5f32.powf(1.6))).abs() < 1e-6); + // Monotonic + clamped. + assert!(veil_opacity(0.2) <= veil_opacity(0.4)); + assert_eq!(veil_opacity(-1.0), 0.0); + assert_eq!(veil_opacity(2.0), 1.0); + } + + #[test] + fn slice_spans_shifts_to_local_offsets() { + let spans = vec![(3..10, 0.4), (12..20, 0.1)]; + // A "line" covering bytes 5..15. + let local = slice_spans(&spans, 5, 15); + assert_eq!(local, vec![(0..5, 0.4), (7..10, 0.1)]); + // Disjoint window → empty. + assert!(slice_spans(&spans, 25, 30).is_empty()); + } + + #[test] + fn row_veil_tracks_elements_independently() { + let t0 = Instant::now(); + let mut row = RowVeil::default(); + row.advance(0, "para", t0); + row.advance(2, "code", t0); + assert!(row.is_fading()); + row.advance(0, "para", at(t0, 600)); + assert!(row.is_fading(), "elem 2 hasn't been advanced past its fade"); + row.advance(2, "code", at(t0, 600)); + assert!(!row.is_fading()); + } +} diff --git a/crates/voice-media/Cargo.toml b/crates/voice-media/Cargo.toml new file mode 100644 index 0000000000..aabd8c088b --- /dev/null +++ b/crates/voice-media/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "zeron-voice-media" +version.workspace = true +edition.workspace = true +license.workspace = true +publish.workspace = true + +[dependencies] +zeron-voice-session.workspace = true +async-trait.workspace = true +zeron-proto.workspace = true +tokio.workspace = true +tokio-util.workspace = true +serde_json.workspace = true +sha2.workspace = true +semver = "1" + +[dev-dependencies] +tempfile.workspace = true diff --git a/crates/voice-media/src/lib.rs b/crates/voice-media/src/lib.rs new file mode 100644 index 0000000000..f1a7584f89 --- /dev/null +++ b/crates/voice-media/src/lib.rs @@ -0,0 +1,666 @@ +//! Codex's packaged WebRTC helper protocol (Apache-2.0, OpenAI rust-v0.159.0). +//! Audio, AEC, interruption and encrypted media stay inside that native process. +//! Only bounded SDP signaling, controls and level meters cross this pipe. +use serde_json::{Value, json}; +use std::{ + path::{Path, PathBuf}, + process::Stdio, + time::Duration, +}; +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + process::{ChildStdin, ChildStdout, Command}, +}; +use zeron_proto::voice::VoiceRejection; +const MAX_FRAME: usize = 128 * 1024; + +pub struct NativeHost { + stop: tokio_util::sync::CancellationToken, + input: ChildStdin, + output: ChildStdout, + device_selection: bool, +} +impl Drop for NativeHost { + fn drop(&mut self) { + self.stop.cancel(); + } +} +impl NativeHost { + fn command(path: &Path) -> Command { + let mut cmd = Command::new(path); + // Fixed allowlist mirrors Codex. Never inherit API keys or dynamic loader/plugin overrides. + cmd.env_clear(); + for (k, v) in std::env::vars_os() { + if matches!( + k.to_string_lossy().to_ascii_uppercase().as_str(), + "SYSTEMROOT" + | "WINDIR" + | "HOME" + | "USERPROFILE" + | "LOCALAPPDATA" + | "APPDATA" + | "TEMP" + | "TMP" + | "TMPDIR" + | "XDG_RUNTIME_DIR" + | "PULSE_SERVER" + | "PULSE_COOKIE" + | "PIPEWIRE_REMOTE" + | "DBUS_SESSION_BUS_ADDRESS" + | "HTTP_PROXY" + | "HTTPS_PROXY" + | "ALL_PROXY" + | "NO_PROXY" + | "SSL_CERT_FILE" + | "SSL_CERT_DIR" + | "REQUESTS_CA_BUNDLE" + | "CURL_CA_BUNDLE" + ) { + cmd.env(k, v); + } + } + for k in [ + "GST_PLUGIN_PATH", + "GST_PLUGIN_PATH_1_0", + "GST_PLUGIN_SYSTEM_PATH", + "GST_PLUGIN_SYSTEM_PATH_1_0", + ] { + cmd.env(k, ""); + } + cmd.env( + "GST_REGISTRY", + if cfg!(windows) { "NUL" } else { "/dev/null" }, + ) + .env("GST_REGISTRY_UPDATE", "no") + .env("GST_REGISTRY_FORK", "no"); + #[cfg(target_os = "linux")] + for directory in [ + "/usr/lib/x86_64-linux-gnu/alsa-lib", + "/usr/lib/aarch64-linux-gnu/alsa-lib", + "/usr/lib64/alsa-lib", + "/usr/lib/alsa-lib", + ] { + if Path::new(directory).is_dir() { + cmd.env("ALSA_PLUGIN_DIR", directory); + break; + } + } + cmd.current_dir(path.parent().unwrap()) + .stderr(Stdio::null()) + .kill_on_drop(true); + cmd + } + pub async fn open( + path: &Path, + stop: tokio_util::sync::CancellationToken, + ) -> Result { + let device_selection = helper_device_selection(path)?; + let result = tokio::time::timeout( + Duration::from_secs(5), + Self::command(path).arg("--build-commit").output(), + ) + .await + .map_err(|_| VoiceRejection::NativeRuntimeUnavailable)? + .map_err(|_| VoiceRejection::NativeRuntimeUnavailable)?; + let commit = std::str::from_utf8(&result.stdout) + .ok() + .map(str::trim) + .filter(|s| !s.is_empty() && s.len() <= 128) + .filter(|_| result.status.success()) + .ok_or(VoiceRejection::NativeRuntimeUnavailable)?; + let mut child = Self::command(path) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .map_err(|_| VoiceRejection::NativeRuntimeUnavailable)?; + let input = child.stdin.take().ok_or(VoiceRejection::Protocol)?; + let output = child.stdout.take().ok_or(VoiceRejection::Protocol)?; + let reaper_stop = stop.clone(); + tokio::spawn(async move { + tokio::select! {biased; + _=reaper_stop.cancelled()=>{let _=child.start_kill();let _=child.wait().await;}, + _=child.wait()=>{}, + } + }); + let mut host = Self { + stop: stop.clone(), + input, + output, + device_selection, + }; + // A helper that cannot start its runtime (e.g. a library that no longer + // matches its runtime.json) exits here; that is the runtime, not the call. + let unavailable = |reason| match reason { + VoiceRejection::Protocol => VoiceRejection::NativeRuntimeUnavailable, + reason => reason, + }; + host.expect( + json!({"type":"hello","protocol":1,"buildCommit":commit}), + "ready", + 30, + ) + .await + .map_err(unavailable)?; + host.expect(json!({"type":"initializeRuntime"}), "runtimeReady", 30) + .await + .map_err(unavailable)?; + Ok(host) + } + pub async fn exchange( + &mut self, + message: Value, + seconds: u64, + ) -> Result { + if self.stop.is_cancelled() { + return Err(VoiceRejection::Protocol); + } + struct ExchangeGuard(Option); + impl Drop for ExchangeGuard { + fn drop(&mut self) { + if let Some(stop) = &self.0 { + stop.cancel(); + } + } + } + // A cancelled partial read invalidates the pipe, including when the + // caller drops this future without dropping the media endpoint yet. + let mut guard = ExchangeGuard(Some(self.stop.clone())); + let result = tokio::time::timeout(Duration::from_secs(seconds), async { + let payload = serde_json::to_vec(&message).map_err(|_| VoiceRejection::Protocol)?; + if payload.len() > MAX_FRAME { + return Err(VoiceRejection::Overflow); + } + self.input + .write_u32(payload.len() as u32) + .await + .map_err(|_| VoiceRejection::Protocol)?; + self.input + .write_all(&payload) + .await + .map_err(|_| VoiceRejection::Protocol)?; + self.input + .flush() + .await + .map_err(|_| VoiceRejection::Protocol)?; + let length = self + .output + .read_u32() + .await + .map_err(|_| VoiceRejection::Protocol)? as usize; + if length == 0 || length > MAX_FRAME { + return Err(VoiceRejection::Overflow); + } + let mut payload = vec![0; length]; + self.output + .read_exact(&mut payload) + .await + .map_err(|_| VoiceRejection::Protocol)?; + serde_json::from_slice(&payload).map_err(|_| VoiceRejection::Protocol) + }) + .await + .map_err(|_| VoiceRejection::Protocol)?; + if result.is_ok() { + guard.0 = None; + } + result + } + pub async fn expect( + &mut self, + message: Value, + expected: &str, + seconds: u64, + ) -> Result<(), VoiceRejection> { + if self.exchange(message, seconds).await?["type"] != expected { + self.stop.cancel(); + return Err(VoiceRejection::Protocol); + } + Ok(()) + } + pub async fn controls(&mut self, muted: bool) -> Result<(), VoiceRejection> { + self.expect(json!({"type":"setAudioControls","controls":{"microphoneMuted":muted,"speakerSuppressed":false}}),"audioControlsApplied",5).await + } + pub async fn open_devices(&mut self) -> Result<(), VoiceRejection> { + let mut message = json!({"type":"openDevices"}); + // Codex 0.161 requires selection even for the default devices. Older + // helpers reject that field, despite sharing the same protocol version. + if self.device_selection { + message["selection"] = json!({}); + } + self.expect(message, "devicesOpened", 5).await + } + /// Synchronous local shutdown, independent of pending RPC or helper I/O. + pub fn close(&self) { + self.stop.cancel(); + } + pub async fn levels(&mut self) -> Result<(u16, u16), VoiceRejection> { + let v = self.exchange(json!({"type":"inspectAudio"}), 5).await?; + if v["type"] != "audioState" { + return Err(VoiceRejection::Protocol); + } + let mic = v["state"]["microphonePeak"] + .as_u64() + .and_then(|n| u16::try_from(n).ok()) + .ok_or(VoiceRejection::Protocol)?; + let speaker = v["state"]["speakerPeak"] + .as_u64() + .and_then(|n| u16::try_from(n).ok()) + .ok_or(VoiceRejection::Protocol)?; + Ok((mic, speaker)) + } +} + +fn helper_device_selection(path: &Path) -> Result { + let root = path + .parent() + .and_then(Path::parent) + .ok_or(VoiceRejection::NativeRuntimeUnavailable)?; + let bytes = match std::fs::read(root.join("manifest.json")) { + Ok(bytes) => bytes, + // The pinned development runtime and older fixtures may omit the + // upstream manifest; they use the original openDevices format. + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false), + Err(_) => return Err(VoiceRejection::NativeRuntimeUnavailable), + }; + let manifest: Value = + serde_json::from_slice(&bytes).map_err(|_| VoiceRejection::NativeRuntimeUnavailable)?; + let version = manifest["appVersion"] + .as_str() + .and_then(|v| semver::Version::parse(v).ok()) + .ok_or(VoiceRejection::Unsupported)?; + Ok(version >= semver::Version::new(0, 161, 0)) +} + +fn runtime_target() -> Result<&'static str, VoiceRejection> { + match (std::env::consts::OS, std::env::consts::ARCH) { + ("macos", "aarch64") => Ok("aarch64-apple-darwin"), + ("macos", "x86_64") => Ok("x86_64-apple-darwin"), + ("linux", "x86_64") => Ok("x86_64-unknown-linux-gnu"), + ("linux", "aarch64") => Ok("aarch64-unknown-linux-gnu"), + ("windows", "x86_64") => Ok("x86_64-pc-windows-msvc"), + ("windows", "aarch64") => Ok("aarch64-pc-windows-msvc"), + _ => Err(VoiceRejection::Unsupported), + } +} + +const HELPER: &str = if cfg!(windows) { + "bin/codex-voice-host.exe" +} else { + "bin/codex-voice-host" +}; + +/// The helper shipped inside the device's standalone Codex installation (layout +/// 1, version 0.159 or later), resolved from its `codex` executable. Zeron never +/// redistributes this runtime; npm installations do not contain it. The helper +/// only initializes from a `codex-resources/voice` directory. +pub fn installed_helper(codex_executable: &Path) -> Result { + let binary = codex_executable + .canonicalize() + .map_err(|_| VoiceRejection::NativeRuntimeUnavailable)?; + let root = binary + .parent() + .and_then(Path::parent) + .ok_or(VoiceRejection::NativeRuntimeUnavailable)?; + let manifest: Value = std::fs::read(root.join("codex-package.json")) + .ok() + .and_then(|b| serde_json::from_slice(&b).ok()) + .ok_or(VoiceRejection::NativeRuntimeUnavailable)?; + let version = manifest["version"] + .as_str() + .and_then(|v| semver::Version::parse(v).ok()) + .ok_or(VoiceRejection::Unsupported)?; + if manifest["layoutVersion"] != 1 || version < semver::Version::new(0, 159, 0) { + return Err(VoiceRejection::Unsupported); + } + let path = root.join("codex-resources/voice").join(HELPER); + if path.canonicalize().ok().as_ref() != Some(&path) || !path.is_file() { + return Err(VoiceRejection::NativeRuntimeUnavailable); + } + Ok(path) +} + +/// Client media for a remote call: an explicit development runtime +/// (`ZERON_VOICE_MEDIA_DIR`, see `scripts/package-voice-runtime.py`), otherwise +/// this device's standalone Codex installation. Never reads Codex credentials. +pub fn media_helper(codex_executable: Option<&Path>) -> Result { + if let Some(path) = std::env::var_os("ZERON_VOICE_MEDIA_DIR") { + return verify_runtime(Path::new(&path)); + } + installed_helper(codex_executable.ok_or(VoiceRejection::NativeRuntimeUnavailable)?) +} + +pub const BUILD_COMMIT: &str = "a956835d020762cb2b570053af06f643a11c0ecc"; + +pub fn verify_runtime(root: &Path) -> Result { + use sha2::{Digest, Sha256}; + let reject = || VoiceRejection::NativeRuntimeUnavailable; + // Fail here, clearly, rather than when the helper refuses to start. + if root.file_name() != Some("voice".as_ref()) + || root.parent().and_then(Path::file_name) != Some("codex-resources".as_ref()) + { + return Err(reject()); + } + let manifest: Value = serde_json::from_slice( + &std::fs::read(root.join("zeron-runtime.json")).map_err(|_| reject())?, + ) + .map_err(|_| reject())?; + if manifest["buildCommit"] != BUILD_COMMIT + || manifest["protocol"] != 1 + || manifest["target"] != runtime_target()? + { + return Err(VoiceRejection::Unsupported); + } + let files = manifest["sha256"].as_object().ok_or_else(reject)?; + if !files.contains_key(HELPER) + || !files.contains_key("runtime.json") + || !files.contains_key("NOTICE.md") + { + return Err(reject()); + } + for (name, digest) in files { + let path = Path::new(name); + if path + .components() + .any(|c| !matches!(c, std::path::Component::Normal(_))) + { + return Err(reject()); + } + let bytes = std::fs::read(root.join(path)).map_err(|_| reject())?; + if digest.as_str() != Some(format!("{:x}", Sha256::digest(&bytes)).as_str()) { + return Err(reject()); + } + } + Ok(root.join(HELPER)) +} + +#[cfg(test)] +mod tests { + use super::*; + use sha2::{Digest, Sha256}; + + fn codex_package(root: &Path, version: &str, helper: bool) -> PathBuf { + let codex = root.join(if cfg!(windows) { + "bin/codex.exe" + } else { + "bin/codex" + }); + std::fs::create_dir_all(codex.parent().unwrap()).unwrap(); + std::fs::write(&codex, "").unwrap(); + std::fs::write( + root.join("codex-package.json"), + json!({"layoutVersion":1,"version":version}).to_string(), + ) + .unwrap(); + if helper { + let path = root.join("codex-resources/voice").join(HELPER); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, "").unwrap(); + } + codex + } + + #[test] + fn helper_is_resolved_from_the_installed_codex_package() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().canonicalize().unwrap().join("release"); + let codex = codex_package(&root, "0.160.0", true); + assert_eq!( + installed_helper(&codex).unwrap(), + root.join("codex-resources/voice").join(HELPER) + ); + // Older layouts and npm-style installs without resources are unavailable. + codex_package(&root, "0.158.9", true); + assert_eq!( + installed_helper(&codex).unwrap_err(), + VoiceRejection::Unsupported + ); + let npm = temp.path().join("npm"); + let codex = codex_package(&npm, "0.160.0", false); + assert_eq!( + installed_helper(&codex).unwrap_err(), + VoiceRejection::NativeRuntimeUnavailable + ); + std::fs::remove_file(npm.join("codex-package.json")).unwrap(); + assert_eq!( + installed_helper(&codex).unwrap_err(), + VoiceRejection::NativeRuntimeUnavailable + ); + } + + /// An opt-in runtime check: no provider connection or audio devices. + #[tokio::test] + #[ignore = "requires a projected runtime in ZERON_VOICE_MEDIA_DIR"] + async fn packaged_native_runtime_initializes_without_audio_devices() { + let path = media_helper(None).unwrap(); + let host = NativeHost::open(&path, Default::default()).await.unwrap(); + host.close(); + } + + #[test] + fn runtime_rejects_missing_tampered_misplaced_and_incompatible_resources() { + let temp = tempfile::tempdir().unwrap(); + let dir = temp.path().join("Resources/codex-resources/voice"); + std::fs::create_dir_all(&dir).unwrap(); + assert!(verify_runtime(&dir).is_err()); + let mut hashes = serde_json::Map::new(); + for name in [HELPER, "runtime.json", "NOTICE.md"] { + let path = dir.join(name); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, name).unwrap(); + hashes.insert( + name.into(), + json!(format!("{:x}", Sha256::digest(name.as_bytes()))), + ); + } + let mut manifest = json!({"protocol":1,"buildCommit":BUILD_COMMIT,"target":runtime_target().unwrap(),"sha256":hashes}); + let save = |m: &Value| { + std::fs::write( + dir.join("zeron-runtime.json"), + serde_json::to_vec(m).unwrap(), + ) + .unwrap() + }; + save(&manifest); + assert_eq!(verify_runtime(&dir).unwrap(), dir.join(HELPER)); + // The helper refuses to start outside `codex-resources/voice`. + let elsewhere = temp.path().join("Resources/voice"); + std::fs::rename(temp.path().join("Resources/codex-resources"), &elsewhere).unwrap(); + assert!(verify_runtime(&elsewhere.join("voice")).is_err()); + std::fs::rename(&elsewhere, temp.path().join("Resources/codex-resources")).unwrap(); + std::fs::write(dir.join("runtime.json"), "tampered").unwrap(); + assert!(verify_runtime(&dir).is_err()); + std::fs::write(dir.join("runtime.json"), "runtime.json").unwrap(); + manifest["protocol"] = json!(2); + save(&manifest); + assert_eq!( + verify_runtime(&dir).unwrap_err(), + VoiceRejection::Unsupported + ); + manifest["protocol"] = json!(1); + manifest["target"] = json!("incompatible-platform"); + save(&manifest); + assert_eq!( + verify_runtime(&dir).unwrap_err(), + VoiceRejection::Unsupported + ); + manifest["target"] = json!(runtime_target().unwrap()); + manifest["sha256"].as_object_mut().unwrap().remove(HELPER); + save(&manifest); + assert!(verify_runtime(&dir).is_err()); + manifest["sha256"][HELPER] = json!(format!("{:x}", Sha256::digest(HELPER.as_bytes()))); + manifest["sha256"]["../escape"] = json!("digest"); + save(&manifest); + assert!(verify_runtime(&dir).is_err()); + } + + #[cfg(unix)] + #[tokio::test] + async fn desktop_media_opens_default_devices_with_old_and_new_helpers() { + use std::os::unix::fs::PermissionsExt; + use zeron_voice_session::VoiceMediaEndpoint; + + for version in [None, Some("0.159.0"), Some("0.160.0"), Some("0.161.0")] { + let dir = tempfile::tempdir().unwrap(); + let codex = codex_package(dir.path(), version.unwrap_or("0.160.0"), true); + let helper = installed_helper(&codex).unwrap(); + std::fs::write( + &helper, + include_bytes!("../../harness/tests/fixtures/fake-codex-voice-host.py"), + ) + .unwrap(); + std::fs::set_permissions(&helper, std::fs::Permissions::from_mode(0o755)).unwrap(); + if let Some(version) = version { + std::fs::write( + helper + .parent() + .unwrap() + .parent() + .unwrap() + .join("manifest.json"), + json!({"appVersion":version}).to_string(), + ) + .unwrap(); + } + let media = DesktopMedia { + path: helper, + host: Default::default(), + stop: Default::default(), + }; + media.prepare().await.unwrap(); + media.offer().await.unwrap(); + media + .apply_answer( + zeron_proto::voice::remote::Sdp::new("fixture-answer".into()).unwrap(), + ) + .await + .unwrap(); + media.set_muted(true).await.unwrap(); + assert_eq!(media.levels().await.unwrap(), (1024, 0)); + media.stop.cancel(); + } + } + + #[test] + fn malformed_helper_metadata_is_not_treated_as_a_legacy_runtime() { + let dir = tempfile::tempdir().unwrap(); + let codex = codex_package(dir.path(), "0.161.0", true); + let helper = installed_helper(&codex).unwrap(); + let manifest = helper + .parent() + .unwrap() + .parent() + .unwrap() + .join("manifest.json"); + for contents in ["{", "{}", r#"{"appVersion":"invalid"}"#] { + std::fs::write(&manifest, contents).unwrap(); + assert!(helper_device_selection(&helper).is_err()); + } + } + + #[cfg(unix)] + #[tokio::test] + async fn helper_that_cannot_start_its_runtime_is_a_runtime_failure() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let helper = dir.path().join("codex-voice-host"); + // Answers hello, then exits on initializeRuntime like a helper whose + // libraries no longer match its runtime.json (exit 23). + std::fs::write( + &helper, + "#!/usr/bin/env python3\nimport json,struct,sys\n\ + if sys.argv[1:]==['--build-commit']: print('commit'); sys.exit(0)\n\ + n=struct.unpack('>I',sys.stdin.buffer.read(4))[0]; sys.stdin.buffer.read(n)\n\ + b=json.dumps({'type':'ready'}).encode()\n\ + sys.stdout.buffer.write(struct.pack('>I',len(b))+b); sys.stdout.flush()\n\ + sys.stdin.buffer.read(4); sys.exit(23)\n", + ) + .unwrap(); + std::fs::set_permissions(&helper, std::fs::Permissions::from_mode(0o755)).unwrap(); + let result = NativeHost::open(&helper, Default::default()).await; + assert_eq!(result.err(), Some(VoiceRejection::NativeRuntimeUnavailable)); + } +} + +/// Desktop implementation shared by UIs; permission belongs to the viewport. +/// Instantiate one per call. Closing cannot wait behind a locked helper pipe. +pub struct DesktopMedia { + path: std::path::PathBuf, + host: tokio::sync::Mutex>, + stop: tokio_util::sync::CancellationToken, +} +impl DesktopMedia { + /// See [`media_helper`]: the local standalone Codex unless a development + /// runtime is set explicitly. + pub fn for_codex(codex_executable: Option<&Path>) -> Result { + Ok(Self { + path: media_helper(codex_executable)?, + host: Default::default(), + stop: Default::default(), + }) + } +} +#[async_trait::async_trait] +impl zeron_voice_session::VoiceMediaEndpoint for DesktopMedia { + async fn prepare(&self) -> Result<(), VoiceRejection> { + let host = NativeHost::open(&self.path, self.stop.clone()).await?; + if self.stop.is_cancelled() { + return Err(VoiceRejection::InvalidLease); + } + *self.host.lock().await = Some(host); + Ok(()) + } + async fn offer(&self) -> Result { + let mut state = self.host.lock().await; + let host = state.as_mut().ok_or(VoiceRejection::InvalidLease)?; + let offer = host.exchange(json!({"type":"startTransport"}), 20).await?; + if offer["type"] != "offer" { + return Err(VoiceRejection::Protocol); + } + zeron_proto::voice::remote::Sdp::new( + offer["sdp"] + .as_str() + .ok_or(VoiceRejection::Protocol)? + .into(), + ) + } + async fn apply_answer( + &self, + answer: zeron_proto::voice::remote::Sdp, + ) -> Result<(), VoiceRejection> { + let mut state = self.host.lock().await; + let host = state.as_mut().ok_or(VoiceRejection::InvalidLease)?; + host.expect( + json!({"type":"applyAnswer","sdp":answer.expose()}), + "transportReady", + 20, + ) + .await?; + host.open_devices().await?; + host.expect(json!({"type":"setAudioControls","controls":{"microphoneMuted":true,"speakerSuppressed":true}}),"audioControlsApplied",5).await + } + async fn set_muted(&self, muted: bool) -> Result<(), VoiceRejection> { + self.host + .lock() + .await + .as_mut() + .ok_or(VoiceRejection::InvalidLease)? + .controls(muted) + .await + } + async fn levels(&self) -> Result<(u16, u16), VoiceRejection> { + self.host + .lock() + .await + .as_mut() + .ok_or(VoiceRejection::InvalidLease)? + .levels() + .await + } + fn close(&self) { + self.stop.cancel(); + } +} +impl Drop for DesktopMedia { + fn drop(&mut self) { + self.stop.cancel(); + } +} diff --git a/crates/voice-session/Cargo.toml b/crates/voice-session/Cargo.toml new file mode 100644 index 0000000000..f7b712078e --- /dev/null +++ b/crates/voice-session/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "zeron-voice-session" +version.workspace = true +edition.workspace = true +license.workspace = true +publish.workspace = true + +[dependencies] +zeron-proto.workspace = true +zeron-orb.workspace = true +zeron-rpc.workspace = true +async-trait.workspace = true +tokio.workspace = true +tokio-util.workspace = true +serde.workspace = true +serde_json.workspace = true +futures.workspace = true +tracing.workspace = true + +[dev-dependencies] +tokio = { workspace = true, features = ["test-util"] } diff --git a/crates/voice-session/src/lib.rs b/crates/voice-session/src/lib.rs new file mode 100644 index 0000000000..5ebe6b3807 --- /dev/null +++ b/crates/voice-session/src/lib.rs @@ -0,0 +1,638 @@ +//! Shared call lifecycle. Platforms own media; the execution host owns Codex. +mod view; +use async_trait::async_trait; +use futures::{StreamExt, stream::BoxStream}; +use serde_json::{Value, json}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; +use tokio::sync::{mpsc, watch}; +use tokio_util::sync::CancellationToken; +pub use view::{Caption, SpeakerActivity, VoiceView, orb_state}; +use zeron_proto::voice::{remote as wire, *}; +use zeron_rpc::{RpcClient, methods}; + +#[async_trait] +pub trait VoiceMediaEndpoint: Send + Sync { + /// Platform permission and media runtime only; capture stays muted. + async fn prepare(&self) -> Result<(), VoiceRejection>; + async fn offer(&self) -> Result; + /// Return only when the local transport is ready. Devices remain muted. + async fn apply_answer(&self, answer: wire::Sdp) -> Result<(), VoiceRejection>; + async fn set_muted(&self, muted: bool) -> Result<(), VoiceRejection>; + async fn levels(&self) -> Result<(u16, u16), VoiceRejection>; + /// Must stop local capture without awaiting any RPC or pending media call. + fn close(&self); +} +#[async_trait] +pub trait VoiceControlTransport: Send + Sync { + async fn call( + &self, + method: &str, + payload: Value, + seconds: u64, + ) -> Result; + async fn own( + &self, + lease: &wire::Lease, + ) -> Result>, VoiceRejection>; +} +/// Uses ephemeral RPC only. No offline ledger, nudge or implicit side-effect retry. +pub struct RpcTransport { + pub client: Arc, + pub host: String, +} +#[async_trait] +impl VoiceControlTransport for RpcTransport { + async fn call( + &self, + method: &str, + payload: Value, + seconds: u64, + ) -> Result { + tokio::time::timeout( + Duration::from_secs(seconds), + self.client.call( + method, + json!({"targetDeviceId":self.host,"payload":payload}), + ), + ) + .await + .map_err(|_| VoiceRejection::Protocol)? + .map_err(rejection) + } + async fn own( + &self, + lease: &wire::Lease, + ) -> Result>, VoiceRejection> { + let rx = tokio::time::timeout( + Duration::from_secs(8), + self.client.subscribe_checked( + methods::OWN_VOICE_V2, + json!({"targetDeviceId":self.host,"payload":lease}), + ), + ) + .await + .map_err(|_| VoiceRejection::Protocol)? + .map_err(rejection)?; + let client = self.client.clone(); + Ok( + futures::stream::unfold((rx, client), |(mut rx, client)| async move { + rx.recv().await.map(|v| { + ( + serde_json::from_value(v).map_err(|_| VoiceRejection::Protocol), + (rx, client), + ) + }) + }) + .boxed(), + ) + } +} +pub fn rejection(error: zeron_rpc::RpcError) -> VoiceRejection { + let message = error.to_string(); + for reason in [ + VoiceRejection::Disabled, + VoiceRejection::NativeRuntimeUnavailable, + VoiceRejection::ChatgptRequired, + VoiceRejection::Busy, + VoiceRejection::InvalidLease, + VoiceRejection::RemoteHost, + VoiceRejection::WrongHarness, + VoiceRejection::Unsupported, + VoiceRejection::Overflow, + ] { + if message.contains(&format!("{reason:?}")) { + return reason; + } + } + if matches!(error, zeron_rpc::RpcError::UnknownMethod(_)) { + VoiceRejection::Unsupported + } else { + VoiceRejection::Protocol + } +} +async fn call( + control: &dyn VoiceControlTransport, + method: &str, + payload: impl serde::Serialize, + seconds: u64, +) -> Result { + serde_json::from_value( + control + .call( + method, + serde_json::to_value(payload).map_err(|_| VoiceRejection::Protocol)?, + seconds, + ) + .await?, + ) + .map_err(|_| VoiceRejection::Protocol) +} +struct Scope { + media: Arc, + control: Arc, + key: wire::AttemptKey, + lease: Arc>>, + tasks: Vec>, + cancel: CancellationToken, +} +impl Drop for Scope { + fn drop(&mut self) { + self.media.close(); + self.cancel.cancel(); + for task in &self.tasks { + task.abort(); + } + let control = self.control.clone(); + let key = self.key.clone(); + let lease = self.lease.lock().unwrap().clone(); + if let Ok(runtime) = tokio::runtime::Handle::try_current() { + runtime.spawn(async move { + if let Some(lease) = lease { + let _ = control.call(methods::STOP_VOICE_V2, json!(lease), 8).await; + } + let _ = control + .call( + methods::CANCEL_VOICE_ATTEMPT_V2, + json!(wire::Cancel { attempt_key: key }), + 8, + ) + .await; + }); + } + } +} + +/// Runs once. Reconnection requires a new explicit call and fresh attempt key. +pub async fn run( + control: Arc, + media: Arc, + config: zeron_proto::ChatConfig, + voice: Option, + cancel: CancellationToken, + events: mpsc::Sender, + muted: watch::Receiver, +) -> Result<(), VoiceRejection> { + let started = tokio::time::Instant::now(); + let local_cancel = cancel.child_token(); + let failure = Arc::new(Mutex::new(None)); + let mut scope = Scope { + media: media.clone(), + control: control.clone(), + key: wire::AttemptKey::new(), + lease: Arc::default(), + tasks: vec![], + cancel: local_cancel.clone(), + }; + let operation = async { + tracing::info!( + stage = "capabilities", + elapsed_ms = started.elapsed().as_millis() as u64, + "remote voice stage" + ); + let cap: wire::Capabilities = call( + control.as_ref(), + methods::VOICE_CAPABILITIES_V2, + json!({}), + 8, + ) + .await?; + if !cap.client_webrtc || cap.protocol != wire::CAPABILITY { + return Err(VoiceRejection::Unsupported); + } + tracing::info!( + stage = "local_media", + elapsed_ms = started.elapsed().as_millis() as u64, + "remote voice stage" + ); + media.prepare().await?; + tracing::info!( + stage = "prepare_host", + elapsed_ms = started.elapsed().as_millis() as u64, + "remote voice stage" + ); + let prepared: wire::Prepared = call( + control.as_ref(), + methods::PREPARE_VOICE_V2, + wire::Prepare { + attempt_key: scope.key.clone(), + config, + voice, + }, + 65, + ) + .await?; + *scope.lease.lock().unwrap() = Some(prepared.lease.clone()); + tracing::info!( + stage = "attach_owner", + elapsed_ms = started.elapsed().as_millis() as u64, + "remote voice stage" + ); + let mut owner = control.own(&prepared.lease).await?; + let lease = prepared.lease.clone(); + let owner_cancel = local_cancel.clone(); + let owner_events = events.clone(); + let owner_failure = failure.clone(); + let owner_media = media.clone(); + let owner_mute = muted.clone(); + scope.tasks.push(tokio::spawn(async move { + let result = async { + while let Some(event) = owner.next().await { + let mut event = event?; + if let VoiceEvent::Snapshot { snapshot } = &mut event { + snapshot.muted = *owner_mute.borrow(); + } + let current = match &event { + VoiceEvent::Snapshot { snapshot } => { + snapshot.generation == lease.voice.generation + && snapshot.session_id == lease.voice.session_id + } + VoiceEvent::Final { transcript } => { + transcript.session_id == lease.voice.session_id + } + VoiceEvent::Partial { generation, .. } + | VoiceEvent::Closed { generation, .. } + | VoiceEvent::InvalidatePlayout { generation, .. } => { + *generation == lease.voice.generation + } + _ => return Err(VoiceRejection::Protocol), + }; + if !current { + continue; + } + let closed = if let VoiceEvent::Closed { reason, .. } = &event { + Some(*reason) + } else { + None + }; + owner_events + .try_send(event) + .map_err(|_| VoiceRejection::Overflow)?; + if let Some(reason) = closed { + return reason.map_or(Ok(()), Err); + } + } + Err(VoiceRejection::Protocol) + } + .await; + *owner_failure.lock().unwrap() = result.err(); + owner_media.close(); + owner_cancel.cancel(); + })); + let heartbeat_control = control.clone(); + let lease = prepared.lease.clone(); + let heartbeat_cancel = local_cancel.clone(); + let heartbeat_failure = failure.clone(); + let heartbeat_media = media.clone(); + let heartbeat_mute = muted.clone(); + scope.tasks.push(tokio::spawn(async move { + let mut sequence = 0; + let mut interval = tokio::time::interval(Duration::from_secs(wire::HEARTBEAT_SECS)); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + loop { + interval.tick().await; + sequence += 1; + let muted_now = *heartbeat_mute.borrow(); + let result: Result = call( + heartbeat_control.as_ref(), + methods::REPORT_VOICE_MEDIA_V2, + wire::Report { + lease: lease.clone(), + sequence, + muted: muted_now, + state: wire::MediaState::Ready, + }, + 10, + ) + .await; + if !matches!(result,Ok(wire::Ack{sequence:s}) if s==sequence) { + *heartbeat_failure.lock().unwrap() = Some(VoiceRejection::Protocol); + heartbeat_media.close(); + heartbeat_cancel.cancel(); + return; + } + } + })); + tracing::info!( + stage = "local_offer", + elapsed_ms = started.elapsed().as_millis() as u64, + "remote voice stage" + ); + let offer = media.offer().await?; + let negotiation_id = wire::AttemptKey::new(); + tracing::info!( + stage = "negotiate", + elapsed_ms = started.elapsed().as_millis() as u64, + "remote voice stage" + ); + let answer: wire::Negotiated = call( + control.as_ref(), + methods::NEGOTIATE_VOICE_V2, + wire::Negotiate { + lease: prepared.lease.clone(), + negotiation_id: negotiation_id.clone(), + offer, + }, + 95, + ) + .await?; + if answer.negotiation_id != negotiation_id { + return Err(VoiceRejection::Protocol); + } + tracing::info!( + stage = "apply_answer", + elapsed_ms = started.elapsed().as_millis() as u64, + "remote voice stage" + ); + media.apply_answer(answer.answer).await?; + let initial_muted = *muted.borrow(); + tracing::info!( + stage = "confirm", + elapsed_ms = started.elapsed().as_millis() as u64, + "remote voice stage" + ); + let snapshot: VoiceSnapshot = call( + control.as_ref(), + methods::CONFIRM_VOICE_MEDIA_V2, + wire::Confirm { + lease: prepared.lease.clone(), + negotiation_id, + muted: initial_muted, + }, + 8, + ) + .await?; + if snapshot.session_id != prepared.lease.voice.session_id + || snapshot.generation != prepared.lease.voice.generation + { + return Err(VoiceRejection::InvalidLease); + } + if local_cancel.is_cancelled() { + return Err(VoiceRejection::InvalidLease); + } + let muted_now = *muted.borrow(); + tracing::info!( + stage = "active", + elapsed_ms = started.elapsed().as_millis() as u64, + "remote voice stage" + ); + media.set_muted(muted_now).await?; + let mut muted = muted; + let mut tick = tokio::time::interval(Duration::from_millis(100)); + tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + loop { + tokio::select! {biased; + changed=muted.changed()=>{changed.map_err(|_|VoiceRejection::Protocol)?;let value=*muted.borrow_and_update();media.set_muted(value).await?;}, + _=tick.tick()=>{ + let (microphone,speaker)=media.levels().await?; + // Visual samples are lossy and leave headroom: a slow consumer + // must drop meters, never overflow on owner finals/controls. + if events.capacity()>events.max_capacity()/4{ + let _=events.try_send(VoiceEvent::Levels{generation:prepared.lease.voice.generation,microphone,speaker}); + } + }, + _=events.closed()=>return Ok(()), + } + } + }; + let result = tokio::select! {biased; + _=local_cancel.cancelled()=>failure.lock().unwrap().map_or(Ok(()),Err), + result=operation=>result, + }; + drop(scope); + tracing::info!(elapsed_ms = started.elapsed().as_millis() as u64, reason = ?result.as_ref().err(), "remote voice closed"); + result +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicBool, Ordering}; + struct Media { + prepared: AtomicBool, + closed: AtomicBool, + block: bool, + } + #[async_trait] + impl VoiceMediaEndpoint for Media { + async fn prepare(&self) -> Result<(), VoiceRejection> { + self.prepared.store(true, Ordering::SeqCst); + Ok(()) + } + async fn offer(&self) -> Result { + if self.block { + futures::future::pending::<()>().await; + } + wire::Sdp::new("offer".into()) + } + async fn apply_answer(&self, _: wire::Sdp) -> Result<(), VoiceRejection> { + Ok(()) + } + async fn set_muted(&self, _: bool) -> Result<(), VoiceRejection> { + assert!(!self.closed.load(Ordering::SeqCst)); + Ok(()) + } + async fn levels(&self) -> Result<(u16, u16), VoiceRejection> { + Ok((100, 200)) + } + fn close(&self) { + self.closed.store(true, Ordering::SeqCst); + } + } + struct Control { + supported: bool, + half_open: bool, + calls: Mutex>, + lease: wire::Lease, + } + #[async_trait] + impl VoiceControlTransport for Control { + async fn call(&self, method: &str, p: Value, _: u64) -> Result { + self.calls.lock().unwrap().push(method.into()); + if self.half_open && method == methods::REPORT_VOICE_MEDIA_V2 { + tokio::time::sleep(Duration::from_secs(10)).await; + return Err(VoiceRejection::Protocol); + } + Ok(match method { + methods::VOICE_CAPABILITIES_V2 => json!(wire::Capabilities { + protocol: wire::CAPABILITY.into(), + client_webrtc: self.supported, + voices: vec![] + }), + methods::PREPARE_VOICE_V2 => json!(wire::Prepared { + lease: self.lease.clone(), + chat_id: "voice-orchestrator-full-id".into(), + voices: vec![], + heartbeat_seconds: 5, + lease_seconds: 15 + }), + methods::NEGOTIATE_VOICE_V2 => json!(wire::Negotiated { + negotiation_id: serde_json::from_value(p["negotiationId"].clone()).unwrap(), + answer: wire::Sdp::new("answer".into()).unwrap() + }), + methods::REPORT_VOICE_MEDIA_V2 => json!({"sequence":p["sequence"]}), + methods::CONFIRM_VOICE_MEDIA_V2 => json!(VoiceSnapshot { + session_id: self.lease.voice.session_id.clone(), + chat_id: "voice-orchestrator-full-id".into(), + generation: 1, + phase: VoicePhase::Active, + muted: false, + playing: false, + work: VoiceWork::Idle, + reason: None, + voice: None, + voices: vec![] + }), + _ => json!({}), + }) + } + async fn own( + &self, + _: &wire::Lease, + ) -> Result>, VoiceRejection> + { + Ok(futures::stream::pending().boxed()) + } + } + fn control(supported: bool) -> Arc { + Arc::new(Control { + supported, + half_open: false, + calls: Default::default(), + lease: wire::Lease { + host_device_id: "host".into(), + voice: VoiceLease { + session_id: "session".into(), + generation: 1, + token: "secret".into(), + }, + }, + }) + } + fn config() -> zeron_proto::ChatConfig { + serde_json::from_value(json!({"harness":"codex","sandbox":"danger-full-access"})).unwrap() + } + #[tokio::test] + async fn incompatible_host_never_requests_microphone() { + let media = Arc::new(Media { + prepared: AtomicBool::new(false), + closed: AtomicBool::new(false), + block: false, + }); + let (tx, _rx) = mpsc::channel(8); + let (_mute, muted) = watch::channel(false); + assert_eq!( + run( + control(false), + media.clone(), + config(), + None, + CancellationToken::new(), + tx, + muted + ) + .await + .unwrap_err(), + VoiceRejection::Unsupported + ); + assert!(!media.prepared.load(Ordering::SeqCst)); + assert!(media.closed.load(Ordering::SeqCst)); + } + #[tokio::test] + async fn cancelling_a_stalled_offer_closes_media_before_remote_cleanup() { + let media = Arc::new(Media { + prepared: AtomicBool::new(false), + closed: AtomicBool::new(false), + block: true, + }); + let control = control(true); + let (tx, _rx) = mpsc::channel(8); + let (_mute, muted) = watch::channel(false); + let cancel = CancellationToken::new(); + let task = tokio::spawn(run( + control.clone(), + media.clone(), + config(), + None, + cancel.clone(), + tx, + muted, + )); + for _ in 0..100 { + if media.prepared.load(Ordering::SeqCst) { + break; + } + tokio::task::yield_now().await; + } + cancel.cancel(); + task.await.unwrap().unwrap(); + assert!(media.closed.load(Ordering::SeqCst)); + tokio::task::yield_now().await; + assert!( + control + .calls + .lock() + .unwrap() + .iter() + .any(|m| m == methods::CANCEL_VOICE_ATTEMPT_V2) + ); + } + #[tokio::test] + async fn active_call_uses_local_levels_and_drop_closes_media() { + let media = Arc::new(Media { + prepared: AtomicBool::new(false), + closed: AtomicBool::new(false), + block: false, + }); + let (tx, mut rx) = mpsc::channel(8); + let (_mute, muted) = watch::channel(false); + let task = tokio::spawn(run( + control(true), + media.clone(), + config(), + None, + CancellationToken::new(), + tx, + muted, + )); + assert!(matches!( + tokio::time::timeout(Duration::from_secs(1), rx.recv()) + .await + .unwrap(), + Some(VoiceEvent::Levels { + microphone: 100, + speaker: 200, + .. + }) + )); + task.abort(); + let _ = task.await; + assert!(media.closed.load(Ordering::SeqCst)); + } + #[tokio::test(start_paused = true)] + async fn half_open_heartbeat_closes_stalled_media_without_user_action() { + let media = Arc::new(Media { + prepared: AtomicBool::new(false), + closed: AtomicBool::new(false), + block: true, + }); + let mut control = control(true); + Arc::get_mut(&mut control).unwrap().half_open = true; + let (tx, _rx) = mpsc::channel(8); + let (_mute, muted) = watch::channel(false); + let started = tokio::time::Instant::now(); + let result = run( + control, + media.clone(), + config(), + None, + CancellationToken::new(), + tx, + muted, + ) + .await; + assert_eq!(result.unwrap_err(), VoiceRejection::Protocol); + assert!(media.closed.load(Ordering::SeqCst)); + assert!(started.elapsed() <= Duration::from_secs(wire::LEASE_SECS)); + } +} diff --git a/crates/voice-session/src/view.rs b/crates/voice-session/src/view.rs new file mode 100644 index 0000000000..bdc2daea55 --- /dev/null +++ b/crates/voice-session/src/view.rs @@ -0,0 +1,541 @@ +//! What a client-media call shows: phase, live caption, levels and the orb's +//! state, reduced from the owner stream. Renderer-agnostic so every platform +//! presents the orchestrator the same way. +use std::time::{Duration, Instant}; +use zeron_orb::OrbState; +use zeron_proto::voice::*; + +/// The orb for a voice phase and host snapshot. Speaking wins over work and +/// mute, so a muted user still sees the assistant answer. +pub fn orb_state(phase: VoicePhase, snapshot: Option<&VoiceSnapshot>) -> OrbState { + if matches!( + phase, + VoicePhase::Checking | VoicePhase::Starting | VoicePhase::Stopping + ) { + return OrbState::Connecting; + } + match snapshot { + Some(s) if s.playing => OrbState::Composing, + Some(s) if s.work == VoiceWork::AwaitingInput => OrbState::Solving, + Some(s) if s.work == VoiceWork::Working => OrbState::Working, + Some(s) if s.muted => OrbState::Breathing, + Some(_) => OrbState::Listening, + None => OrbState::Breathing, + } +} + +/// Visual speaker activity from playout peaks: hysteresis and a short hold +/// bridge syllable gaps. Client media has no host-side meter, so the client +/// derives `playing` itself. +#[derive(Default)] +pub struct SpeakerActivity { + playing: bool, + last_loud: Option, +} +impl SpeakerActivity { + pub fn update(&mut self, peak: u16, now: Instant) -> bool { + const ENTER: u16 = 655; + const EXIT: u16 = 328; + const HOLD: Duration = Duration::from_millis(250); + if peak >= if self.playing { EXIT } else { ENTER } { + self.playing = true; + self.last_loud = Some(now); + } else if self + .last_loud + .is_none_or(|last| now.saturating_duration_since(last) >= HOLD) + { + self.playing = false; + } + self.playing + } +} + +/// Speaker turns the caption remembers; older ones can no longer complete. +const CAPTION_TURNS: usize = 8; + +/// The live caption across speaker turns. Codex interleaves them: a user +/// turn's final often lands after the assistant has started answering. Each +/// turn keeps its own text and the caption stays on the newest one, so a late +/// final never takes the caption back and deltas never restart it. +#[derive(Default)] +pub struct Caption { + /// Oldest first; the last turn is the one shown. + turns: Vec, +} + +struct CaptionTurn { + item: Option, + text: String, + /// Known once the turn's final arrives. + role: Option, +} + +impl Caption { + pub fn text(&self) -> &str { + self.turns.last().map_or("", |t| t.text.as_str()) + } + + /// The shown turn's item: a change is a new speaker turn. + pub fn item(&self) -> Option<&str> { + self.turns.last().and_then(|t| t.item.as_deref()) + } + + /// The shown turn's speaker, once its final arrived. + pub fn role(&self) -> Option { + self.turns.last().and_then(|t| t.role) + } + + pub fn clear(&mut self) { + self.turns.clear(); + } + + fn position(&self, item: &str) -> Option { + self.turns + .iter() + .rposition(|t| t.item.as_deref() == Some(item)) + } + + fn push(&mut self, turn: CaptionTurn) { + self.turns.push(turn); + let excess = self.turns.len().saturating_sub(CAPTION_TURNS); + self.turns.drain(..excess); + } + + /// Append a streamed delta to its turn; a new item starts a new turn. A + /// delta without an item continues the shown turn while it streams. + /// Returns false when the turn outgrew [`MAX_TRANSCRIPT_BYTES`]: it then + /// restarts from this delta, showing the tail rather than growing. + pub fn partial(&mut self, item: Option, delta: &str) -> bool { + let index = match item { + Some(ref item) => self.position(item), + None => self + .turns + .len() + .checked_sub(1) + .filter(|&last| self.turns[last].role.is_none()), + }; + let index = index.unwrap_or_else(|| { + self.push(CaptionTurn { + item, + text: String::new(), + role: None, + }); + self.turns.len() - 1 + }); + let turn = &mut self.turns[index]; + if turn.role.is_some() { + // The final already settled this turn. + return true; + } + let fits = turn.text.len() + delta.len() <= MAX_TRANSCRIPT_BYTES; + if !fits { + turn.text.clear(); + } + turn.text.push_str(delta); + fits + } + + /// A turn's final text and speaker. An unseen turn that completes while + /// another one streams is older than it, so it never takes the caption. + pub fn complete(&mut self, transcript: &VoiceTranscript) { + let found = self.position(&transcript.item_id).or_else(|| { + // Deltas without an item belong to the turn this final completes. + self.turns + .last() + .filter(|t| t.item.is_none() && t.role.is_none()) + .map(|_| self.turns.len() - 1) + }); + if let Some(index) = found { + let turn = &mut self.turns[index]; + turn.item = Some(transcript.item_id.clone()); + turn.text.clone_from(&transcript.text); + turn.role = Some(transcript.role); + return; + } + let turn = CaptionTurn { + item: Some(transcript.item_id.clone()), + text: transcript.text.clone(), + role: Some(transcript.role), + }; + match self.turns.last() { + Some(last) if last.role.is_none() => { + let streaming = self.turns.len() - 1; + self.turns.insert(streaming, turn); + let excess = self.turns.len().saturating_sub(CAPTION_TURNS); + self.turns.drain(..excess); + } + _ => self.push(turn), + } + } +} + +/// Reduced state of one remote call. Events from another generation or +/// session are ignored; the first snapshot names the orchestrator chat. +#[derive(Default)] +pub struct VoiceView { + pub phase: VoicePhase, + pub snapshot: Option, + pub microphone: u16, + pub speaker: u16, + caption: Caption, + muted: bool, + speaker_activity: SpeakerActivity, +} + +impl VoiceView { + pub fn new() -> Self { + Self { + phase: VoicePhase::Checking, + ..Self::default() + } + } + + pub fn chat_id(&self) -> Option<&str> { + self.snapshot.as_ref().map(|s| s.chat_id.as_str()) + } + + /// The newest speaker turn: live deltas, then its final text. + pub fn caption(&self) -> &str { + self.caption.text() + } + + /// Set once the caption is a final segment; live deltas have none. + pub fn caption_role(&self) -> Option { + self.caption.role() + } + + /// The utterance (speaker turn) the caption belongs to, if named. + pub fn caption_item(&self) -> Option<&str> { + self.caption.item() + } + + pub fn work(&self) -> VoiceWork { + self.snapshot.as_ref().map_or(VoiceWork::Idle, |s| s.work) + } + + pub fn playing(&self) -> bool { + self.snapshot.as_ref().is_some_and(|s| s.playing) + } + + pub fn muted(&self) -> bool { + self.muted + } + + /// The local mute is authoritative for presentation: it applies before + /// the host acknowledges it. + pub fn set_muted(&mut self, muted: bool) -> bool { + let changed = self.muted != muted; + self.muted = muted; + if let Some(snapshot) = &mut self.snapshot { + snapshot.muted = muted; + } + changed + } + + pub fn orb_state(&self) -> OrbState { + orb_state(self.phase, self.snapshot.as_ref()) + } + + /// Normalized 0…1 microphone loudness; zero while muted. + pub fn microphone_level(&self) -> f32 { + if self.muted { + 0.0 + } else { + f32::from(self.microphone) / f32::from(u16::MAX) + } + } + + pub fn speaker_level(&self) -> f32 { + f32::from(self.speaker) / f32::from(u16::MAX) + } + + fn current(&self, generation: u64) -> bool { + self.snapshot + .as_ref() + .is_some_and(|s| s.generation == generation) + } + + /// Whether the event changed what the call shows. + pub fn reduce(&mut self, event: VoiceEvent, now: Instant) -> bool { + match event { + VoiceEvent::Snapshot { mut snapshot } => { + if let Some(old) = &self.snapshot { + if old.generation != snapshot.generation + || old.session_id != snapshot.session_id + { + return false; + } + snapshot.playing = old.playing; + } + snapshot.muted = self.muted; + self.phase = snapshot.phase; + self.snapshot = Some(snapshot); + } + VoiceEvent::Partial { + generation, + item_id, + text, + } if self.current(generation) => { + // A runaway utterance shows its tail rather than grow. + self.caption.partial(item_id, &text); + } + VoiceEvent::Final { transcript } + if self + .snapshot + .as_ref() + .is_some_and(|s| s.session_id == transcript.session_id) => + { + self.caption.complete(&transcript); + } + VoiceEvent::Levels { + generation, + microphone, + speaker, + } if self.current(generation) => { + self.microphone = microphone; + self.speaker = speaker; + let playing = self.speaker_activity.update(speaker, now); + if let Some(snapshot) = &mut self.snapshot { + snapshot.playing = playing; + } + } + VoiceEvent::Closed { generation, reason } if self.current(generation) => { + self.phase = if reason.is_some() { + VoicePhase::Failed + } else { + VoicePhase::Closed + }; + } + _ => return false, + } + true + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn snapshot(generation: u64) -> VoiceSnapshot { + VoiceSnapshot { + session_id: "session".into(), + chat_id: "voice-orchestrator-full-id".into(), + generation, + phase: VoicePhase::Active, + muted: false, + playing: false, + work: VoiceWork::Idle, + reason: None, + voice: None, + voices: vec![], + } + } + + #[test] + fn speaker_activity_ignores_noise_and_holds_through_syllable_gaps() { + let now = Instant::now(); + let mut activity = SpeakerActivity::default(); + assert!(!activity.update(500, now)); + assert!(activity.update(1024, now)); + assert!(activity.update(0, now + Duration::from_millis(249))); + assert!(!activity.update(0, now + Duration::from_millis(250))); + assert!(!activity.update(500, now + Duration::from_millis(300))); + } + + #[test] + fn remote_view_derives_speaking_and_keeps_it_across_snapshots() { + let now = Instant::now(); + let mut view = VoiceView::new(); + assert_eq!(view.orb_state(), OrbState::Connecting); + assert!(view.reduce( + VoiceEvent::Snapshot { + snapshot: snapshot(2) + }, + now + )); + assert_eq!(view.chat_id(), Some("voice-orchestrator-full-id")); + assert_eq!(view.orb_state(), OrbState::Listening); + view.reduce( + VoiceEvent::Levels { + generation: 2, + microphone: 0, + speaker: 4000, + }, + now, + ); + assert_eq!(view.orb_state(), OrbState::Composing); + let mut working = snapshot(2); + working.work = VoiceWork::AwaitingInput; + view.reduce(VoiceEvent::Snapshot { snapshot: working }, now); + // The host never meters client media; its `playing: false` is stale. + assert!(view.playing()); + view.reduce( + VoiceEvent::Levels { + generation: 2, + microphone: 0, + speaker: 0, + }, + now + Duration::from_secs(1), + ); + assert_eq!(view.orb_state(), OrbState::Solving); + assert!(view.set_muted(true)); + assert_eq!(view.microphone_level(), 0.0); + } + + #[test] + fn stale_events_and_runaway_partials_stay_bounded() { + let now = Instant::now(); + let mut view = VoiceView::new(); + assert!(!view.reduce( + VoiceEvent::Partial { + generation: 1, + item_id: None, + text: "early".into() + }, + now + )); + view.reduce( + VoiceEvent::Snapshot { + snapshot: snapshot(2), + }, + now, + ); + assert!(!view.reduce( + VoiceEvent::Snapshot { + snapshot: snapshot(3) + }, + now + )); + assert!(!view.reduce( + VoiceEvent::Closed { + generation: 1, + reason: None + }, + now + )); + for _ in 0..5 { + view.reduce( + VoiceEvent::Partial { + generation: 2, + item_id: Some("item".into()), + text: "x".repeat(MAX_TRANSCRIPT_BYTES / 2), + }, + now, + ); + } + assert!(view.caption().len() <= MAX_TRANSCRIPT_BYTES); + view.reduce( + VoiceEvent::Final { + transcript: VoiceTranscript { + session_id: "session".into(), + item_id: "item".into(), + role: VoiceRole::Assistant, + text: "done".into(), + promoted_message_id: None, + }, + }, + now, + ); + assert_eq!(view.caption(), "done"); + assert_eq!(view.caption_role(), Some(VoiceRole::Assistant)); + view.reduce( + VoiceEvent::Partial { + generation: 2, + item_id: Some("next-item".into()), + text: "next".into(), + }, + now, + ); + assert_eq!(view.caption(), "next"); + assert_eq!(view.caption_role(), None); + assert!(view.reduce( + VoiceEvent::Closed { + generation: 2, + reason: Some(VoiceRejection::Protocol) + }, + now + )); + assert_eq!(view.phase, VoicePhase::Failed); + } + + fn final_of(item: &str, role: VoiceRole, text: &str) -> VoiceTranscript { + VoiceTranscript { + session_id: "session".into(), + item_id: item.into(), + role, + text: text.into(), + promoted_message_id: None, + } + } + + /// The order Codex really sends: the user's final lands after the + /// assistant has started answering. + #[test] + fn a_late_user_final_never_takes_the_caption_from_the_assistant() { + let mut caption = Caption::default(); + caption.partial(Some("user".into()), "Open the "); + caption.partial(Some("user".into()), "sync test"); + caption.partial(Some("assistant".into()), "Sure, "); + caption.partial(Some("assistant".into()), "opening it"); + assert_eq!(caption.text(), "Sure, opening it"); + caption.complete(&final_of("user", VoiceRole::User, "Open the sync test.")); + assert_eq!(caption.text(), "Sure, opening it"); + assert_eq!(caption.item(), Some("assistant")); + assert_eq!(caption.role(), None); + caption.partial(Some("assistant".into()), " now."); + assert_eq!(caption.text(), "Sure, opening it now."); + caption.complete(&final_of( + "assistant", + VoiceRole::Assistant, + "Sure, opening it now.", + )); + assert_eq!(caption.text(), "Sure, opening it now."); + assert_eq!(caption.role(), Some(VoiceRole::Assistant)); + } + + /// A user turn transcribed only as a final, after the answer started. + #[test] + fn an_unseen_final_is_older_than_the_streaming_turn() { + let mut caption = Caption::default(); + caption.partial(Some("assistant".into()), "On it"); + caption.complete(&final_of("user", VoiceRole::User, "Open the test")); + assert_eq!(caption.text(), "On it"); + assert_eq!(caption.item(), Some("assistant")); + // With nothing streaming, a final is the newest turn. + caption.complete(&final_of("assistant", VoiceRole::Assistant, "On it.")); + caption.complete(&final_of("user-2", VoiceRole::User, "Thanks")); + assert_eq!(caption.text(), "Thanks"); + assert_eq!(caption.role(), Some(VoiceRole::User)); + } + + #[test] + fn deltas_without_an_item_continue_the_streaming_turn() { + let mut caption = Caption::default(); + caption.partial(None, "Hello "); + caption.partial(None, "there"); + assert_eq!(caption.text(), "Hello there"); + caption.complete(&final_of("a", VoiceRole::Assistant, "Hello there.")); + assert_eq!(caption.item(), Some("a")); + // A settled turn is not continued: the next delta starts a new one. + caption.partial(None, "Next"); + assert_eq!(caption.text(), "Next"); + assert_eq!(caption.role(), None); + } + + #[test] + fn a_settled_turn_ignores_late_deltas_and_old_turns_are_forgotten() { + let mut caption = Caption::default(); + caption.partial(Some("a".into()), "Done"); + caption.complete(&final_of("a", VoiceRole::Assistant, "Done.")); + caption.partial(Some("a".into()), " again"); + assert_eq!(caption.text(), "Done."); + for i in 0..CAPTION_TURNS * 2 { + caption.partial(Some(format!("turn-{i}")), "x"); + } + assert_eq!(caption.turns.len(), CAPTION_TURNS); + assert_eq!( + caption.item(), + Some(format!("turn-{}", CAPTION_TURNS * 2 - 1).as_str()) + ); + } +} diff --git a/crates/voice/Cargo.toml b/crates/voice/Cargo.toml new file mode 100644 index 0000000000..0730ba7a63 --- /dev/null +++ b/crates/voice/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "zeron-voice" +version.workspace = true +edition.workspace = true +license.workspace = true +publish.workspace = true + +[dependencies] +anyhow.workspace = true +parakeet-rs = "=0.3.8" +cpal = "=0.17.3" +rubato = "=0.16.2" +reqwest.workspace = true +tokio.workspace = true +sha2.workspace = true +serde.workspace = true +serde_json.workspace = true + +[dev-dependencies] +hound = "3.5" +tempfile.workspace = true diff --git a/crates/voice/NOTICE.md b/crates/voice/NOTICE.md new file mode 100644 index 0000000000..e780303383 --- /dev/null +++ b/crates/voice/NOTICE.md @@ -0,0 +1,17 @@ +# Parakeet v3 attribution and runtime + +NVIDIA Parakeet TDT 0.6B v3 weights © NVIDIA, licensed under Creative Commons Attribution 4.0 International: https://creativecommons.org/licenses/by/4.0/ + +Original model: https://huggingface.co/nvidia/parakeet-tdt-0.6b-v3 +Source model card inspected at revision `541d1f99c6b0c3cd0b11a95167540bb8edefd82b`. + +ONNX conversion by Ivan Stupakov: https://huggingface.co/istupakov/parakeet-tdt-0.6b-v3-onnx/tree/8f23f0c03c8761650bdb5b40aaf3e40d2c15f1ce +The conversion card identifies NVIDIA v3 as its base and documents NeMo ASRModel export and vocabulary extraction. Its exact original-weight revision and INT8 conversion tool version are not published. Zeron pins the conversion's immutable revision and SHA-256 of every consumed artifact in model.json; it does not claim independent numerical equivalence to NVIDIA's FP32 weights. The change is an INT8 ONNX conversion; no fine-tuned model is used. + +Runtime: parakeet-rs 0.3.8 (MIT OR Apache-2.0), ONNX Runtime 1.28.0 via ort/ort-sys 2.0.0-rc.13, CPU execution. Cargo.lock pins registry checksums and the transitive graph. ONNX Runtime is MIT licensed. Capture: cpal 0.17.3 (Apache-2.0). Sample-rate conversion: rubato 0.16.2 (MIT), using its anti-aliasing FFT resampler to convert device-rate mono audio to 16 kHz. Runtime libraries are linked into the application by ort-sys; model weights are an optional download, never bundled. + +TDT v3 is an offline model. Zeron records at most 60 seconds and decodes once on Stop. There are no live partial hypotheses in the production adapter. The editor's partial-result seam exists for deterministic lifecycle tests and future adapter work; it does not imply native streaming support. + +Supported languages: Bulgarian, Croatian, Czech, Danish, Dutch, English, Estonian, Finnish, French, German, Greek, Hungarian, Italian, Latvian, Lithuanian, Maltese, Polish, Portuguese, Romanian, Russian, Slovak, Slovenian, Spanish, Swedish, Ukrainian. + +Platform baseline: native CPU ONNX and CPAL on macOS, Windows, and Linux. Linux build hosts need ALSA development headers in addition to existing desktop dependencies. macOS requires a packaged app with NSMicrophoneUsageDescription and the audio-input entitlement under hardened runtime. No Apple Speech entitlement, cloud provider, Python installation, or engine-host audio transport is used. Only macOS arm64 is verified locally; other targets require their native CI/package validation. diff --git a/crates/voice/examples/verify.rs b/crates/voice/examples/verify.rs new file mode 100644 index 0000000000..794aa1b2f2 --- /dev/null +++ b/crates/voice/examples/verify.rs @@ -0,0 +1,30 @@ +use std::{path::PathBuf, sync::atomic::AtomicBool, time::Instant}; +fn main() -> anyhow::Result<()> { + let mut args = std::env::args().skip(1); + let dir = PathBuf::from(args.next().expect("model directory")); + if !zeron_voice::installed(&dir) { + zeron_voice::download(&dir, &AtomicBool::new(false), |_| {})?; + } + let start = Instant::now(); + let mut model = zeron_voice::Recognizer::load(&dir)?; + println!("load_ms={}", start.elapsed().as_millis()); + for file in args { + let mut wav = hound::WavReader::open(&file)?; + let spec = wav.spec(); + anyhow::ensure!( + spec.channels == 1 && spec.bits_per_sample == 16, + "mono PCM16 fixture required" + ); + let samples: Vec = wav + .samples::() + .map(|s| s.unwrap() as f32 / 32768.) + .collect(); + let start = Instant::now(); + let text = model.transcribe(samples, spec.sample_rate)?; + println!( + "fixture={file} inference_ms={} transcript={text:?}", + start.elapsed().as_millis() + ); + } + Ok(()) +} diff --git a/crates/voice/model.json b/crates/voice/model.json new file mode 100644 index 0000000000..50779f63e3 --- /dev/null +++ b/crates/voice/model.json @@ -0,0 +1,23 @@ +{ + "repository": "istupakov/parakeet-tdt-0.6b-v3-onnx", + "revision": "8f23f0c03c8761650bdb5b40aaf3e40d2c15f1ce", + "source": "nvidia/parakeet-tdt-0.6b-v3", + "license": "CC-BY-4.0", + "files": [ + { + "name": "decoder_joint-model.int8.onnx", + "size": 18202004, + "sha256": "eea7483ee3d1a30375daedc8ed83e3960c91b098812127a0d99d1c8977667a70" + }, + { + "name": "encoder-model.int8.onnx", + "size": 652183999, + "sha256": "6139d2fa7e1b086097b277c7149725edbab89cc7c7ae64b23c741be4055aff09" + }, + { + "name": "vocab.txt", + "size": 93939, + "sha256": "d58544679ea4bc6ac563d1f545eb7d474bd6cfa467f0a6e2c1dc1c7d37e3c35d" + } + ] +} diff --git a/crates/voice/src/lib.rs b/crates/voice/src/lib.rs new file mode 100644 index 0000000000..e475e93c2a --- /dev/null +++ b/crates/voice/src/lib.rs @@ -0,0 +1,602 @@ +//! Desktop-local Parakeet v3. No engine, document, RPC or audio persistence. +use anyhow::{Context, Result, bail}; +use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; +use parakeet_rs::{ParakeetTDT, Transcriber}; +use sha2::{Digest, Sha256}; +use std::{ + io::Write, + path::Path, + sync::{ + Arc, Mutex, + atomic::{AtomicBool, AtomicU32, Ordering}, + }, +}; + +mod resample; + +pub const MAX_SECONDS: usize = 60; +#[derive(serde::Deserialize)] +pub struct Artifact { + pub name: String, + pub size: u64, + pub sha256: String, +} +#[derive(serde::Deserialize)] +pub struct Manifest { + pub repository: String, + pub revision: String, + pub files: Vec, +} +pub fn manifest() -> Manifest { + serde_json::from_str(include_str!("../model.json")).expect("pinned model manifest") +} +pub fn download_size() -> u64 { + manifest().files.iter().map(|f| f.size).sum() +} +pub fn installed(dir: &Path) -> bool { + manifest() + .files + .iter() + .all(|f| std::fs::metadata(dir.join(&f.name)).is_ok_and(|m| m.len() == f.size)) + && std::fs::read_to_string(dir.join("verified")).is_ok_and(|r| r == manifest().revision) +} + +/// Called only on a worker. Temporary files never establish readiness. +pub fn download(dir: &Path, cancel: &AtomicBool, mut progress: impl FnMut(u64)) -> Result<()> { + tokio::runtime::Builder::new_current_thread().enable_all().build()?.block_on(async { + std::fs::create_dir_all(dir)?; + let client = reqwest::Client::builder().connect_timeout(std::time::Duration::from_secs(20)).build()?; + let m=manifest(); let mut total=0; + for file in m.files { + if cancel.load(Ordering::Acquire) { bail!("Download cancelled") } + let staging=dir.join(format!("{}.part",file.name)); + let result=async { + let mut response=tokio::select! { + _=cancelled(cancel)=>bail!("Download cancelled"), + response=tokio::time::timeout(std::time::Duration::from_secs(30),client.get(format!("https://huggingface.co/{}/resolve/{}/{}",m.repository,m.revision,file.name)).send())=>response??.error_for_status()?, + }; + let mut out=std::fs::File::create(&staging)?;let mut hash=Sha256::new();let mut size=0; + loop { + let chunk=tokio::select! { + _=cancelled(cancel)=>bail!("Download cancelled"), + chunk=tokio::time::timeout(std::time::Duration::from_secs(30),response.chunk())=>chunk??, + }; + let Some(chunk)=chunk else {break}; + size+=chunk.len() as u64;if size>file.size {bail!("Unexpected model size")} + hash.update(&chunk);out.write_all(&chunk)?;total+=chunk.len() as u64;progress(total); + } + if size!=file.size || format!("{:x}",hash.finalize())!=file.sha256 {bail!("Model checksum verification failed")} + out.sync_all()?;std::fs::rename(&staging,dir.join(&file.name))?;Ok::<_,anyhow::Error>(()) + }.await; + if result.is_err() {let _=std::fs::remove_file(staging);} result?; + } + if cancel.load(Ordering::Acquire) {bail!("Download cancelled")} + std::fs::write(dir.join("verified"),manifest().revision)?;Ok(()) + }) +} +async fn cancelled(cancel: &AtomicBool) { + while !cancel.load(Ordering::Acquire) { + tokio::time::sleep(std::time::Duration::from_millis(40)).await; + } +} + +pub struct Recognizer(ParakeetTDT); +impl Recognizer { + pub fn load(dir: &Path) -> Result { + // Verify before handing bytes to the native runtime, including after restart. + for f in manifest().files { + let mut input = std::fs::File::open(dir.join(&f.name))?; + let mut hash = Sha256::new(); + std::io::copy(&mut input, &mut hash)?; + if format!("{:x}", hash.finalize()) != f.sha256 { + bail!("Model is damaged. Remove it in Settings and download again.") + } + } + Ok(Self(ParakeetTDT::from_pretrained(dir, None).map_err( + |_| anyhow::anyhow!("Could not load Parakeet v3"), + )?)) + } + pub fn transcribe(&mut self, samples: Vec, rate: u32) -> Result { + if !(8_000..=192_000).contains(&rate) { + bail!("Unsupported microphone sample rate") + } + if samples.len() > rate as usize * MAX_SECONDS { + bail!("Recording exceeds one minute") + } + if samples.len() < rate as usize / 5 || samples.iter().all(|s| s.abs() < 0.0001) { + return Ok(String::new()); + } + let samples = resample::for_model(samples, rate)?; + self.0 + .transcribe_samples(samples, resample::MODEL_RATE, 1, None) + .map(|r| r.text) + .map_err(|_| anyhow::anyhow!("Could not transcribe this recording")) + } +} + +struct Audio { + /// Only the audio callback touches this until the stream is dropped. + samples: Mutex>, + // Outside the lock: the capture loop polls these every 10 ms and must + // never make the real-time callback's `try_lock` drop a buffer. + failed: AtomicBool, + full: AtomicBool, +} +impl Audio { + fn with_capacity(capacity: usize) -> Self { + Self { + samples: Mutex::new(Vec::with_capacity(capacity)), + failed: AtomicBool::new(false), + full: AtomicBool::new(false), + } + } +} +/// Loudest RMS since the UI last read it. Non-negative `f32` bit patterns sort +/// like their values, so `fetch_max` keeps the peak without a lock. +fn meter(data: &[T], channels: usize, level: &AtomicU32) +where + f32: cpal::FromSample, +{ + let frames = data.len() / channels.max(1); + if frames == 0 { + return; + } + let energy = data + .chunks_exact(channels) + .map(|frame| { + let s = frame.iter().map(|s| s.to_sample::()).sum::() / channels as f32; + s * s + }) + .sum::(); + let rms = (energy / frames as f32).sqrt(); + if rms.is_finite() { + level.fetch_max(rms.to_bits(), Ordering::Relaxed); + } +} +fn append(data: &[T], channels: usize, rate: u32, a: &Audio) +where + f32: cpal::FromSample, +{ + if let Ok(mut samples) = a.samples.try_lock() { + for frame in data.chunks_exact(channels) { + if samples.len() >= rate as usize * MAX_SECONDS { + a.full.store(true, Ordering::Release); + break; + } + samples.push(frame.iter().map(|s| s.to_sample::()).sum::() / channels as f32); + } + } +} +/// A microphone the user can choose: a stable identifier and a display name. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InputDevice { + pub id: String, + pub name: String, +} + +/// Every input device the default host reports. Enumeration can block on the +/// audio server, so call it off the UI thread. +pub fn input_devices() -> Vec { + let Ok(devices) = cpal::default_host().input_devices() else { + return Vec::new(); + }; + let mut devices: Vec<_> = devices + .filter_map(|device| { + Some(InputDevice { + id: device.id().ok()?.to_string(), + name: device.description().ok()?.name().to_owned(), + }) + }) + .collect(); + devices.dedup_by(|a, b| a.id == b.id); + devices +} + +/// The id of the device the system currently records from by default. +pub fn default_input_device() -> Option { + Some( + cpal::default_host() + .default_input_device()? + .id() + .ok()? + .to_string(), + ) +} + +/// The chosen device when it is connected, the system default otherwise. +fn input_device(id: Option<&str>) -> Option { + let host = cpal::default_host(); + id.and_then(|id| id.parse::().ok()) + .and_then(|id| host.device_by_id(&id)) + .or_else(|| host.default_input_device()) +} + +pub struct Capture { + stream: Option, + audio: Arc